@zhuan-ai/zhuanspec 2.15.7 → 2.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/hooks.d.ts +13 -1
- package/dist/cli/hooks.js +75 -2
- package/dist/cli/index.js +111 -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 +78 -0
- package/dist/commands/progress.js +349 -3
- 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/corrections/select-candidates.d.ts +58 -0
- package/dist/core/corrections/select-candidates.js +357 -0
- package/dist/core/hooks/collect-knowledge.js +80 -5
- 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/post-archive.js +25 -13
- package/dist/core/hooks/record-progress.d.ts +64 -1
- package/dist/core/hooks/record-progress.js +224 -54
- package/dist/core/hooks/summarize.js +107 -20
- 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 +16 -0
- package/dist/core/init.js +208 -11
- 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 +27 -25
- 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 +208 -66
- 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/dist/utils/phase-utils.js +5 -0
- package/package.json +22 -20
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
* 4. Generate events.json entries for key events
|
|
9
9
|
*/
|
|
10
10
|
import path from 'path';
|
|
11
|
+
import { promises as fsPromises } from 'fs';
|
|
11
12
|
import { execSync } from 'child_process';
|
|
12
13
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
13
14
|
import { executePostApply } from './post-apply.js';
|
|
14
|
-
import { getBeijingTime, detectActiveChange } from './record-progress.js';
|
|
15
|
+
import { getBeijingTime, detectActiveChange, atomicWriteJson } from './record-progress.js';
|
|
15
16
|
import { computeAccuracy } from '../metrics/code-accuracy.js';
|
|
16
17
|
import { detectHookHost, sanitizeCodexEnvelope } from '../../utils/hook-host.js';
|
|
17
18
|
export async function summarizeHook(options) {
|
|
@@ -215,6 +216,29 @@ async function runSummarize() {
|
|
|
215
216
|
systemMessage += `- AI产出: ${accResult.aiTotalLines} 行\n`;
|
|
216
217
|
systemMessage += `- 用户纠正: ${accResult.userCorrectionLines} 行\n`;
|
|
217
218
|
systemMessage += `- 准确率: ${(accResult.rate * 100).toFixed(1)}%${accResult.overridden ? ' (已手动修正)' : ''}\n`;
|
|
219
|
+
// 分阶段准确率(Task 5):按 phase/trackedKind 展示桶级快照;历史数据无桶时降级提示
|
|
220
|
+
const buckets = progressData.accuracy.phaseAccuracy ?? [];
|
|
221
|
+
systemMessage += `\n分阶段准确率:\n`;
|
|
222
|
+
if (buckets.length === 0) {
|
|
223
|
+
systemMessage += `- —(历史数据,无分阶段指标)\n`;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
for (const b of buckets) {
|
|
227
|
+
const rawPct = ((b.accuracyRateRaw ?? b.accuracyRate ?? 1) * 100).toFixed(1);
|
|
228
|
+
const showPct = ((b.accuracyRate ?? 1) * 100).toFixed(1);
|
|
229
|
+
const overrideCount = b.overrideHistory?.length ?? 0;
|
|
230
|
+
const lastReason = overrideCount > 0
|
|
231
|
+
? b.overrideHistory[overrideCount - 1].reason
|
|
232
|
+
: '';
|
|
233
|
+
const confirmedTag = b.confirmed
|
|
234
|
+
? `✓已确认(${b.confirmed.by})`
|
|
235
|
+
: '未确认';
|
|
236
|
+
const overrideTag = overrideCount > 0
|
|
237
|
+
? ` override=${overrideCount}次(${lastReason})`
|
|
238
|
+
: '';
|
|
239
|
+
systemMessage += `- [${b.phase}/${b.trackedKind}] AI=${b.aiTotalLines} 纠偏=${b.userCorrectionLines} raw=${rawPct}% 展示=${showPct}%${overrideTag} ${confirmedTag}\n`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
218
242
|
}
|
|
219
243
|
}
|
|
220
244
|
if (allTasksComplete) {
|
|
@@ -239,40 +263,63 @@ async function runSummarize() {
|
|
|
239
263
|
&& progressData.askedPitfallSaved !== true) {
|
|
240
264
|
pitfallAskPending = true;
|
|
241
265
|
const n = progressData.corrections.length;
|
|
242
|
-
const previews = progressData.corrections
|
|
243
|
-
.filter(c => c.event === 'detected')
|
|
244
|
-
.slice(-3)
|
|
245
|
-
.map((c, i) => ` ${i + 1}. ${c.promptPreview || '(no preview)'}`)
|
|
246
|
-
.join('\n');
|
|
247
266
|
pitfallAdditionalContext = [
|
|
248
267
|
'## ⚠️ ZhuanSpec 知识沉淀询问(收尾必做)',
|
|
249
268
|
'',
|
|
250
|
-
`本轮会话发生过 ${n}
|
|
269
|
+
`本轮会话发生过 ${n} 次用户纠偏且尚未沉淀,必须按以下顺序处理(严禁跳步、严禁仅根据最近 N 条摘要拍脑袋决策、严禁自行 Write 知识文件):`,
|
|
251
270
|
'',
|
|
252
271
|
'### 必须执行的步骤',
|
|
253
272
|
'',
|
|
254
|
-
`1.
|
|
255
|
-
'
|
|
256
|
-
`
|
|
257
|
-
'
|
|
258
|
-
'
|
|
273
|
+
`1. **先生成决策清单(必须)**:运行`,
|
|
274
|
+
' ```bash',
|
|
275
|
+
` zhuanspec progress list-corrections ${changeId}`,
|
|
276
|
+
' ```',
|
|
277
|
+
' CLI 会过滤 trivial 确认词(如 "是的/好的/ok")+ 去重(完全重复 + 子串包含)+ 启发式分类,输出到 `zhuanspec/changes/<change-id>/review/pending-corrections.md`,并在 stdout 给出 **合计 / 有效 / 过滤** 三个计数。',
|
|
259
278
|
'',
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
' -
|
|
263
|
-
' -
|
|
279
|
+
'2. **用 askUserQuestion 询问用户(三选项,禁止默认)**:',
|
|
280
|
+
' - header: "踩坑沉淀"',
|
|
281
|
+
' - question: "本轮有 M 条有效纠偏候选(已过滤 trivial / 去重,详见 review/pending-corrections.md),请选择沉淀范围:"',
|
|
282
|
+
' - 选项 1: "全部沉淀"',
|
|
283
|
+
' - 选项 2: "选择性沉淀(请回复编号清单,例如 1,3,5)"',
|
|
284
|
+
' - 选项 3: "跳过"',
|
|
264
285
|
'',
|
|
265
|
-
`3. **用户选"
|
|
286
|
+
`3. **用户选 "全部沉淀" 或 "选择性沉淀"**:必须调用 \`@skill:zhuanspec:knowledge --mode=correction --correction <change-id>\` (或 \`/zhuanspec:knowledge\` 模式 B)完成沉淀。change-id 值为 \`${changeId}\`。选择性沉淀时把用户回复的编号串以 \`--include <编号串>\` 透传给 skill。`,
|
|
287
|
+
' - **禁止直接 Write/Edit 创建 `zhuanspec/knowledge/` 下的任何文件**,禁止自行判定分类或文件名格式;所有写入由 skill 统一执行。',
|
|
288
|
+
' - skill 必须以 `pending-corrections.md` 作为商定范围的单一源,禁止从会话记忆里“发挥”额外条目。',
|
|
266
289
|
'',
|
|
267
|
-
|
|
268
|
-
previews || ' (none)',
|
|
290
|
+
`4. **用户选 "跳过"**:运行 \`zhuanspec progress resolve-correction ${changeId} --mark-pitfall-saved\` 标记已处理,不写入任何知识文件。`,
|
|
269
291
|
'',
|
|
270
292
|
'### 红线提示',
|
|
271
293
|
'- 禁止绕过 skill 直接 Write/Edit 知识文件(等同于绕过质量门禁)。',
|
|
294
|
+
'- 禁止跳过 list-corrections 一步,仅靠 promptPreview 拍脑袋决策。',
|
|
272
295
|
'- 禁止新建 decisions/ 或三类目录以外的分类。',
|
|
273
296
|
'- skill 模式 B 完成后会自动写入 askedPitfallSaved=true,无需人工重复调用 CLI。',
|
|
274
297
|
].join('\n');
|
|
275
|
-
systemMessage += `\n⚠️ 检测到 ${n}
|
|
298
|
+
systemMessage += `\n⚠️ 检测到 ${n} 次用户纠偏未沉淀——请先运行 zhuanspec progress list-corrections ${changeId},再用 askUserQuestion 三选项决策,用户选「全部/选择性沉淀」后必须调用 @skill:zhuanspec:knowledge 而非自行 Write。\n`;
|
|
299
|
+
}
|
|
300
|
+
// === Task 0 路径 1:Stop 时按核心产出齐备度自动打 phase baseline ===
|
|
301
|
+
// 幂等:已打标则跳过;先到先得(auto-file-complete 优于 auto-stop)
|
|
302
|
+
try {
|
|
303
|
+
if (['techDesign', 'propose', 'apply', 'review'].includes(phase)) {
|
|
304
|
+
const outputsReady = await isPhaseOutputsComplete(phase, changeDir, allTasksComplete);
|
|
305
|
+
if (outputsReady && await FileSystemUtils.fileExists(progressPath)) {
|
|
306
|
+
const fullProgress = JSON.parse(await FileSystemUtils.readFile(progressPath));
|
|
307
|
+
const now = getBeijingTime();
|
|
308
|
+
fullProgress.phaseBaselines = fullProgress.phaseBaselines || {};
|
|
309
|
+
const key = phase;
|
|
310
|
+
if (!fullProgress.phaseBaselines[key]?.markedAt) {
|
|
311
|
+
fullProgress.phaseBaselines[key] = {
|
|
312
|
+
markedAt: now,
|
|
313
|
+
markedBy: 'auto-file-complete',
|
|
314
|
+
firstStopAt: now,
|
|
315
|
+
};
|
|
316
|
+
await atomicWriteJson(progressPath, fullProgress);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// baseline 打标失败不阻断 summarize 主流程
|
|
276
323
|
}
|
|
277
324
|
return {
|
|
278
325
|
continue: true,
|
|
@@ -371,4 +418,44 @@ function computeWaves(tasks) {
|
|
|
371
418
|
}
|
|
372
419
|
return Array.from(wavesMap.values()).sort((a, b) => a.waveId - b.waveId);
|
|
373
420
|
}
|
|
421
|
+
/**
|
|
422
|
+
* 判定指定 phase 的核心产出文件是否齐备(Task 0 路径 1 auto-file-complete 的前置条件)。
|
|
423
|
+
*
|
|
424
|
+
* 判定标准:
|
|
425
|
+
* - techDesign → changes/{changeId}/techDesign/ 目录存在且至少有一份 .md 文件
|
|
426
|
+
* - propose → proposal.md / tasks.md / design.md 全部存在
|
|
427
|
+
* - apply → tasks.md 全部打勾
|
|
428
|
+
* - review → review/review-report.md 存在
|
|
429
|
+
*/
|
|
430
|
+
async function isPhaseOutputsComplete(phase, changeDir, allTasksComplete) {
|
|
431
|
+
if (phase === 'techDesign') {
|
|
432
|
+
const techDesignDir = path.join(changeDir, 'techDesign');
|
|
433
|
+
if (!(await FileSystemUtils.directoryExists(techDesignDir)))
|
|
434
|
+
return false;
|
|
435
|
+
try {
|
|
436
|
+
const files = await fsPromises.readdir(techDesignDir);
|
|
437
|
+
return files.some(f => f.endsWith('.md'));
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (phase === 'propose') {
|
|
444
|
+
const required = ['proposal.md', 'tasks.md', 'design.md'];
|
|
445
|
+
for (const f of required) {
|
|
446
|
+
if (!(await FileSystemUtils.fileExists(path.join(changeDir, f)))) {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
if (phase === 'apply') {
|
|
453
|
+
return allTasksComplete;
|
|
454
|
+
}
|
|
455
|
+
if (phase === 'review') {
|
|
456
|
+
const reviewReportPath = path.join(changeDir, 'review', 'review-report.md');
|
|
457
|
+
return await FileSystemUtils.fileExists(reviewReportPath);
|
|
458
|
+
}
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
374
461
|
//# sourceMappingURL=summarize.js.map
|
|
@@ -1,9 +1,42 @@
|
|
|
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 为唯一纠偏事实源。
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* 纠偏信号分级:
|
|
21
|
+
* - 'hard' : 命中 USER_CORRECTION_KEYWORDS / REQUIREMENT_CHANGE_KEYWORDS 中的硬关键词
|
|
22
|
+
* - 'soft' : 保留位,当前未使用;未来可接入语义/模式匹配
|
|
23
|
+
* - 'none' : 无纠偏信号
|
|
6
24
|
*/
|
|
25
|
+
export interface CorrectionSignal {
|
|
26
|
+
kind: 'hard' | 'soft' | 'none';
|
|
27
|
+
matchedKeywords: string[];
|
|
28
|
+
isRequirementChange?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 用户对四选一的最终选择。由 `zhuanspec progress resolve-correction` 回写。
|
|
32
|
+
*/
|
|
33
|
+
export interface UserInputResolution {
|
|
34
|
+
path?: 'A' | 'B' | 'C' | 'D';
|
|
35
|
+
resolvedAt?: string;
|
|
36
|
+
note?: string;
|
|
37
|
+
/** 本条 prompt 是否真的弹了四选一(hard 信号命中即触发) */
|
|
38
|
+
triggeredFourOption?: boolean;
|
|
39
|
+
}
|
|
7
40
|
interface UserInputOutput {
|
|
8
41
|
continue: boolean;
|
|
9
42
|
systemMessage?: string;
|
|
@@ -11,6 +44,9 @@ interface UserInputOutput {
|
|
|
11
44
|
inputRecorded: boolean;
|
|
12
45
|
triggerType: string;
|
|
13
46
|
summaryLength: number;
|
|
47
|
+
/** 四选一或 AD 两选一注入时附加 additionalContext/options */
|
|
48
|
+
additionalContext?: string;
|
|
49
|
+
options?: string[];
|
|
14
50
|
};
|
|
15
51
|
}
|
|
16
52
|
/**
|
|
@@ -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
|