@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
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending corrections candidate selection.
|
|
3
|
+
*
|
|
4
|
+
* 责任:把 `zhuanspec/changes/<id>/metrics/user_inputs.json` 里的纠偏 prompt 加工成
|
|
5
|
+
* "可读、去毛刺、去重、带建议分类" 的候选清单,给 review 阶段的踩坑沉淀决策使用。
|
|
6
|
+
*
|
|
7
|
+
* 不写文件、不改 progress.json,只做"读 + 计算 + 返回结构"。
|
|
8
|
+
*/
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { promises as fs } from 'fs';
|
|
11
|
+
import { USER_CORRECTION_KEYWORDS, REQUIREMENT_CHANGE_KEYWORDS, PITFALL_KEYWORDS, BEST_PRACTICE_KEYWORDS, IMPLICIT_CONVENTION_KEYWORDS, } from '../hooks/deviation-check.js';
|
|
12
|
+
// ---------- Internals ----------
|
|
13
|
+
/**
|
|
14
|
+
* 规范化字符串——用于过滤与去重的"语义指纹"。
|
|
15
|
+
* 1) 转小写(英文)
|
|
16
|
+
* 2) 把中英文标点 / 空白合并成单空格
|
|
17
|
+
* 3) trim
|
|
18
|
+
* 注意:保留中文字符本身(不做拼音/分词),只去标点和空白。
|
|
19
|
+
*/
|
|
20
|
+
function normalize(text) {
|
|
21
|
+
return text
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
// 中英文标点 + 各类空白统一替换为空格
|
|
24
|
+
.replace(/[\s\p{P}\p{S}]+/gu, ' ')
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
/** 纯确认/招呼类 prompt,不具备沉淀价值 */
|
|
28
|
+
const TRIVIAL_ACK_SET = new Set([
|
|
29
|
+
'是', '是的', '好', '好的', '嗯', '嗯嗯', '对', '行', '可以', '确认',
|
|
30
|
+
'同意', '继续', '没问题', '明白', '了解', '知道了', '收到', '辛苦', '辛苦了',
|
|
31
|
+
'ok', 'okay', 'k', 'yes', 'y', 'yep', 'yeah', 'sure', 'fine', 'cool',
|
|
32
|
+
'go', 'go on', 'plan approved', 'continue', 'thanks', 'thx',
|
|
33
|
+
]);
|
|
34
|
+
/** 拟用文件名 slug 长度上限 */
|
|
35
|
+
const SLUG_MAX_LEN = 48;
|
|
36
|
+
function toKebabSlug(text) {
|
|
37
|
+
// 取规范化后的前若干字 → 把空格转 -,并去掉非字母数字中文
|
|
38
|
+
const base = normalize(text);
|
|
39
|
+
// 仅保留字母数字 / 中文 / 空格,再把空格转 -
|
|
40
|
+
const cleaned = base.replace(/[^a-z0-9\u4e00-\u9fa5\s]/g, '').replace(/\s+/g, '-');
|
|
41
|
+
return cleaned.slice(0, SLUG_MAX_LEN).replace(/^-+|-+$/g, '') || 'untitled';
|
|
42
|
+
}
|
|
43
|
+
function formatDate(ts) {
|
|
44
|
+
// user_inputs.timestamp 已经是北京时间字符串 "YYYY-MM-DD HH:mm:ss"
|
|
45
|
+
if (ts && /^\d{4}-\d{2}-\d{2}/.test(ts))
|
|
46
|
+
return ts.slice(0, 10).replace(/-/g, '');
|
|
47
|
+
const now = new Date();
|
|
48
|
+
const yyyy = now.getFullYear();
|
|
49
|
+
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
|
50
|
+
const dd = String(now.getDate()).padStart(2, '0');
|
|
51
|
+
return `${yyyy}${mm}${dd}`;
|
|
52
|
+
}
|
|
53
|
+
function buildPreview(text, maxLen = 200) {
|
|
54
|
+
const oneLine = text.replace(/\s+/g, ' ').trim();
|
|
55
|
+
if (oneLine.length <= maxLen)
|
|
56
|
+
return oneLine;
|
|
57
|
+
return oneLine.slice(0, maxLen - 1) + '…';
|
|
58
|
+
}
|
|
59
|
+
function detectKeywords(text) {
|
|
60
|
+
const lower = text.toLowerCase();
|
|
61
|
+
const hits = new Set();
|
|
62
|
+
for (const kw of USER_CORRECTION_KEYWORDS) {
|
|
63
|
+
if (lower.includes(kw.toLowerCase()))
|
|
64
|
+
hits.add(kw);
|
|
65
|
+
}
|
|
66
|
+
if (hits.size > 0) {
|
|
67
|
+
return { hits: Array.from(hits).slice(0, 6), isRequirementChange: false };
|
|
68
|
+
}
|
|
69
|
+
const reqHits = new Set();
|
|
70
|
+
for (const kw of REQUIREMENT_CHANGE_KEYWORDS) {
|
|
71
|
+
if (lower.includes(kw.toLowerCase()))
|
|
72
|
+
reqHits.add(kw);
|
|
73
|
+
}
|
|
74
|
+
if (reqHits.size > 0) {
|
|
75
|
+
return { hits: Array.from(reqHits).slice(0, 6), isRequirementChange: true };
|
|
76
|
+
}
|
|
77
|
+
return { hits: [], isRequirementChange: false };
|
|
78
|
+
}
|
|
79
|
+
const BEST_PRACTICE_HINTS = [
|
|
80
|
+
'后续', '之后都', '都要这么', '必须', '一律', '统一', '永远', '今后',
|
|
81
|
+
'always', 'going forward', 'from now on',
|
|
82
|
+
];
|
|
83
|
+
const TROUBLESHOOTING_HINTS = ['错了', '不对', '有问题', 'bug', 'wrong', 'incorrect'];
|
|
84
|
+
function classifyCandidate(text, hits, isReqChange) {
|
|
85
|
+
const lower = text.toLowerCase();
|
|
86
|
+
// 1) 命中显式 PITFALL → troubleshooting
|
|
87
|
+
if (PITFALL_KEYWORDS.some((k) => lower.includes(k.toLowerCase())))
|
|
88
|
+
return 'troubleshooting';
|
|
89
|
+
// 2) 显式纠错动词 → troubleshooting(覆盖大多数纠偏 prompt)
|
|
90
|
+
if (TROUBLESHOOTING_HINTS.some((k) => lower.includes(k.toLowerCase())))
|
|
91
|
+
return 'troubleshooting';
|
|
92
|
+
// 3) 显式约定关键词
|
|
93
|
+
if (IMPLICIT_CONVENTION_KEYWORDS.some((k) => lower.includes(k.toLowerCase())))
|
|
94
|
+
return 'implicit-conventions';
|
|
95
|
+
// 4) 显式最佳实践 / 长期生效 hint
|
|
96
|
+
if (BEST_PRACTICE_KEYWORDS.some((k) => lower.includes(k.toLowerCase())))
|
|
97
|
+
return 'best-practices';
|
|
98
|
+
if (BEST_PRACTICE_HINTS.some((k) => lower.includes(k.toLowerCase())))
|
|
99
|
+
return 'best-practices';
|
|
100
|
+
// 5) 需求变更类 → 暂归 best-practices("决策类输出归并到 best-practices/")
|
|
101
|
+
if (isReqChange)
|
|
102
|
+
return 'best-practices';
|
|
103
|
+
// 6) 命中纠偏关键词但没显式陷阱词 → 默认踩坑
|
|
104
|
+
if (hits.length > 0)
|
|
105
|
+
return 'troubleshooting';
|
|
106
|
+
return 'troubleshooting';
|
|
107
|
+
}
|
|
108
|
+
function isTrivialAck(text) {
|
|
109
|
+
const norm = normalize(text);
|
|
110
|
+
if (norm.length === 0)
|
|
111
|
+
return true;
|
|
112
|
+
if (TRIVIAL_ACK_SET.has(norm))
|
|
113
|
+
return true;
|
|
114
|
+
// 仅由若干个字符 + 标点构成的极短 prompt(如 "好。"、"!!" 已被 normalize 干掉标点)
|
|
115
|
+
if (norm.length <= 2)
|
|
116
|
+
return true;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 从 user_inputs.json 抽取候选 raw entries。
|
|
121
|
+
* - Level 1: correctionSignal.kind === 'hard' && postBaseline === true
|
|
122
|
+
* - Level 2: 条目缺 correctionSignal → 用 summary 关键词扫描兜底
|
|
123
|
+
*/
|
|
124
|
+
async function loadRawEntries(cwd, changeId) {
|
|
125
|
+
const userInputsPath = path.join(cwd, 'zhuanspec', 'changes', changeId, 'metrics', 'user_inputs.json');
|
|
126
|
+
let raw;
|
|
127
|
+
try {
|
|
128
|
+
raw = await fs.readFile(userInputsPath, 'utf8');
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return { entries: [], level: null, notice: `user_inputs.json 不存在或不可读:${userInputsPath}` };
|
|
132
|
+
}
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(raw);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return { entries: [], level: null, notice: 'user_inputs.json 解析失败(JSON 损坏),请人工检查。' };
|
|
139
|
+
}
|
|
140
|
+
const inputs = Array.isArray(parsed.inputs) ? parsed.inputs : [];
|
|
141
|
+
if (inputs.length === 0)
|
|
142
|
+
return { entries: [], level: null };
|
|
143
|
+
const hasSignal = inputs.some((i) => i && typeof i === 'object' && 'correctionSignal' in i);
|
|
144
|
+
const level = hasSignal ? 1 : 2;
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const entry of inputs) {
|
|
147
|
+
if (!entry || typeof entry !== 'object')
|
|
148
|
+
continue;
|
|
149
|
+
const inputId = entry['inputId'] || undefined;
|
|
150
|
+
const timestamp = entry['timestamp'] || undefined;
|
|
151
|
+
const phase = entry['phase'] || undefined;
|
|
152
|
+
const fullPrompt = entry['fullPrompt'] || '';
|
|
153
|
+
const summary = entry['summary'] || '';
|
|
154
|
+
const text = (fullPrompt || summary || '').toString();
|
|
155
|
+
if (level === 1) {
|
|
156
|
+
const sig = entry['correctionSignal'];
|
|
157
|
+
const postBaseline = entry['postBaseline'] === true;
|
|
158
|
+
if (!sig || sig.kind !== 'hard' || !postBaseline)
|
|
159
|
+
continue;
|
|
160
|
+
out.push({
|
|
161
|
+
inputId,
|
|
162
|
+
timestamp,
|
|
163
|
+
phase,
|
|
164
|
+
text,
|
|
165
|
+
fromLevel: 1,
|
|
166
|
+
signalKeywords: Array.isArray(sig.matchedKeywords) ? sig.matchedKeywords : [],
|
|
167
|
+
isRequirementChange: sig.isRequirementChange === true,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
// Level 2: 用 summary 推断
|
|
172
|
+
const lower = (summary || text).toLowerCase();
|
|
173
|
+
const hitCorrection = USER_CORRECTION_KEYWORDS.some((k) => lower.includes(k.toLowerCase()));
|
|
174
|
+
const hitReq = REQUIREMENT_CHANGE_KEYWORDS.some((k) => lower.includes(k.toLowerCase()));
|
|
175
|
+
if (!hitCorrection && !hitReq)
|
|
176
|
+
continue;
|
|
177
|
+
out.push({ inputId, timestamp, phase, text, fromLevel: 2, isRequirementChange: hitReq && !hitCorrection });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { entries: out, level };
|
|
181
|
+
}
|
|
182
|
+
// ---------- Public API ----------
|
|
183
|
+
/**
|
|
184
|
+
* 从 change 目录抽取并加工纠偏候选。
|
|
185
|
+
*/
|
|
186
|
+
export async function selectCorrectionCandidates(cwd, changeId) {
|
|
187
|
+
const { entries, level, notice } = await loadRawEntries(cwd, changeId);
|
|
188
|
+
const totalRaw = entries.length;
|
|
189
|
+
const accepted = [];
|
|
190
|
+
const dropped = [];
|
|
191
|
+
// 已接受候选的 normalized 文本,用于做"完全相等 / 子串包含"去重
|
|
192
|
+
const acceptedNormalized = [];
|
|
193
|
+
for (let i = 0; i < entries.length; i++) {
|
|
194
|
+
const e = entries[i];
|
|
195
|
+
const rawIndex = i + 1;
|
|
196
|
+
const preview = buildPreview(e.text || '');
|
|
197
|
+
// Step 1: 空内容
|
|
198
|
+
if (!e.text || e.text.trim().length === 0) {
|
|
199
|
+
dropped.push({ rawIndex, inputId: e.inputId, timestamp: e.timestamp, reason: 'empty', preview });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
// Step 2: trivial 确认
|
|
203
|
+
if (isTrivialAck(e.text)) {
|
|
204
|
+
dropped.push({ rawIndex, inputId: e.inputId, timestamp: e.timestamp, reason: 'trivial-acknowledgement', preview });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
// Step 3: 关键词命中 + 长度兜底
|
|
208
|
+
const { hits: detectedHits, isRequirementChange: detectedReq } = detectKeywords(e.text);
|
|
209
|
+
const matchedKeywords = e.signalKeywords && e.signalKeywords.length > 0 ? e.signalKeywords : detectedHits;
|
|
210
|
+
const isReqChange = e.isRequirementChange ?? detectedReq;
|
|
211
|
+
const norm = normalize(e.text);
|
|
212
|
+
if (norm.length < 6 && matchedKeywords.length === 0) {
|
|
213
|
+
dropped.push({ rawIndex, inputId: e.inputId, timestamp: e.timestamp, reason: 'too-short-no-keyword', preview });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
// Step 4: 去重——完全相等 或 子串包含(短的被吸收)
|
|
217
|
+
let dupRefIdx;
|
|
218
|
+
let dupReason;
|
|
219
|
+
for (const prev of acceptedNormalized) {
|
|
220
|
+
if (prev.norm === norm) {
|
|
221
|
+
dupRefIdx = prev.idx;
|
|
222
|
+
dupReason = 'duplicate-of';
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
// 子串包含:当前是已接受的真子串(且当前更短至少 4 字)
|
|
226
|
+
if (norm.length >= 4 && prev.norm.includes(norm) && norm.length < prev.norm.length) {
|
|
227
|
+
dupRefIdx = prev.idx;
|
|
228
|
+
dupReason = 'subset-of';
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
// 反向:当前比已接受的长,且包含已接受 → 替换语义;保守处理:仅在长包含短时把短的标记为 subset。
|
|
232
|
+
// 不修改已接受集,避免编号漂移。
|
|
233
|
+
}
|
|
234
|
+
if (dupRefIdx && dupReason) {
|
|
235
|
+
dropped.push({
|
|
236
|
+
rawIndex,
|
|
237
|
+
inputId: e.inputId,
|
|
238
|
+
timestamp: e.timestamp,
|
|
239
|
+
reason: dupReason,
|
|
240
|
+
refIndex: dupRefIdx,
|
|
241
|
+
preview,
|
|
242
|
+
});
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// Step 5: 收编为有效候选
|
|
246
|
+
const idx = accepted.length + 1;
|
|
247
|
+
const suggestedCategory = classifyCandidate(e.text, matchedKeywords, isReqChange);
|
|
248
|
+
const slug = toKebabSlug(e.text);
|
|
249
|
+
const suggestedFilename = `${formatDate(e.timestamp)}-${slug}.md`;
|
|
250
|
+
accepted.push({
|
|
251
|
+
index: idx,
|
|
252
|
+
inputId: e.inputId,
|
|
253
|
+
timestamp: e.timestamp,
|
|
254
|
+
phase: e.phase,
|
|
255
|
+
text: e.text,
|
|
256
|
+
preview,
|
|
257
|
+
matchedKeywords,
|
|
258
|
+
isRequirementChange: isReqChange,
|
|
259
|
+
suggestedCategory,
|
|
260
|
+
suggestedFilename,
|
|
261
|
+
});
|
|
262
|
+
acceptedNormalized.push({ idx, norm });
|
|
263
|
+
}
|
|
264
|
+
return { changeId, level, totalRaw, candidates: accepted, dropped, notice };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* 把候选结果渲染成 markdown,用于落到 review/pending-corrections.md。
|
|
268
|
+
*/
|
|
269
|
+
export function renderPendingCorrectionsMarkdown(result) {
|
|
270
|
+
const { changeId, level, totalRaw, candidates, dropped, notice } = result;
|
|
271
|
+
const droppedByReason = {
|
|
272
|
+
trivial: dropped.filter((d) => d.reason === 'trivial-acknowledgement').length,
|
|
273
|
+
empty: dropped.filter((d) => d.reason === 'empty').length,
|
|
274
|
+
short: dropped.filter((d) => d.reason === 'too-short-no-keyword').length,
|
|
275
|
+
duplicate: dropped.filter((d) => d.reason === 'duplicate-of' || d.reason === 'subset-of').length,
|
|
276
|
+
};
|
|
277
|
+
const lines = [];
|
|
278
|
+
lines.push(`# Pending Corrections — ${changeId}`);
|
|
279
|
+
lines.push('');
|
|
280
|
+
lines.push(`> 由 \`zhuanspec progress list-corrections\` 生成。review 阶段「踩坑沉淀」请基于此清单做选择性沉淀决策,禁止靠最近 N 条做盲选。`);
|
|
281
|
+
lines.push('');
|
|
282
|
+
lines.push(`- **数据源**: ${level === 1 ? 'Level 1 — user_inputs.correctionSignal' : level === 2 ? 'Level 2 — summary 关键词推断' : '无(user_inputs.json 缺失或无候选)'}`);
|
|
283
|
+
lines.push(`- **原始条目**: ${totalRaw}`);
|
|
284
|
+
lines.push(`- **有效候选**: ${candidates.length}`);
|
|
285
|
+
lines.push(`- **已过滤**: ${dropped.length}(trivial=${droppedByReason.trivial}、空=${droppedByReason.empty}、过短=${droppedByReason.short}、去重=${droppedByReason.duplicate})`);
|
|
286
|
+
if (notice) {
|
|
287
|
+
lines.push('');
|
|
288
|
+
lines.push(`> ⚠️ ${notice}`);
|
|
289
|
+
}
|
|
290
|
+
lines.push('');
|
|
291
|
+
if (candidates.length === 0) {
|
|
292
|
+
lines.push('## 候选列表');
|
|
293
|
+
lines.push('');
|
|
294
|
+
lines.push('(无有效候选,可直接在 review 步骤 0 选 "跳过"。)');
|
|
295
|
+
lines.push('');
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
lines.push('## 候选列表');
|
|
299
|
+
lines.push('');
|
|
300
|
+
for (const c of candidates) {
|
|
301
|
+
lines.push(`### ${c.index}. ${c.preview.length > 60 ? c.preview.slice(0, 57) + '…' : c.preview}`);
|
|
302
|
+
lines.push('');
|
|
303
|
+
lines.push(`- **拟分类**: \`${c.suggestedCategory}\``);
|
|
304
|
+
lines.push(`- **拟用文件名**: \`${c.suggestedCategory}/${c.suggestedFilename}\``);
|
|
305
|
+
if (c.timestamp)
|
|
306
|
+
lines.push(`- **时间**: ${c.timestamp}`);
|
|
307
|
+
if (c.phase)
|
|
308
|
+
lines.push(`- **phase**: ${c.phase}`);
|
|
309
|
+
if (c.inputId)
|
|
310
|
+
lines.push(`- **inputId**: ${c.inputId}`);
|
|
311
|
+
if (c.matchedKeywords.length > 0)
|
|
312
|
+
lines.push(`- **关键词命中**: ${c.matchedKeywords.join(', ')}`);
|
|
313
|
+
if (c.isRequirementChange)
|
|
314
|
+
lines.push('- **类型**: 需求变更(非 AI 做错)');
|
|
315
|
+
lines.push('');
|
|
316
|
+
lines.push('```');
|
|
317
|
+
// 全文截断 800 字,避免超长 prompt 把清单撑爆
|
|
318
|
+
const body = (c.text || '').length > 800 ? c.text.slice(0, 797) + '…' : c.text;
|
|
319
|
+
lines.push(body);
|
|
320
|
+
lines.push('```');
|
|
321
|
+
lines.push('');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (dropped.length > 0) {
|
|
325
|
+
lines.push('## 已过滤候选(不沉淀)');
|
|
326
|
+
lines.push('');
|
|
327
|
+
lines.push('| 原始# | 原因 | 引用候选# | 摘要 |');
|
|
328
|
+
lines.push('|------:|------|----------:|------|');
|
|
329
|
+
for (const d of dropped) {
|
|
330
|
+
const reasonLabel = d.reason === 'duplicate-of'
|
|
331
|
+
? '重复'
|
|
332
|
+
: d.reason === 'subset-of'
|
|
333
|
+
? '子串包含'
|
|
334
|
+
: d.reason === 'trivial-acknowledgement'
|
|
335
|
+
? '纯确认/无内容'
|
|
336
|
+
: d.reason === 'too-short-no-keyword'
|
|
337
|
+
? '过短且未命中关键词'
|
|
338
|
+
: '空';
|
|
339
|
+
const ref = d.refIndex ? `#${d.refIndex}` : '—';
|
|
340
|
+
const safePreview = (d.preview || '').replace(/\|/g, '\\|');
|
|
341
|
+
lines.push(`| ${d.rawIndex} | ${reasonLabel} | ${ref} | ${safePreview} |`);
|
|
342
|
+
}
|
|
343
|
+
lines.push('');
|
|
344
|
+
}
|
|
345
|
+
lines.push('## 操作指引');
|
|
346
|
+
lines.push('');
|
|
347
|
+
lines.push('在 review 阶段,主 agent 会用 `askUserQuestion` 询问以下三选项:');
|
|
348
|
+
lines.push('');
|
|
349
|
+
lines.push('- **A. 全部沉淀**:将上述 N 条有效候选全部交给 `@skill:zhuanspec:knowledge` 沉淀。');
|
|
350
|
+
lines.push('- **B. 选择性沉淀**:回复编号清单(例如 `1,3,5`),仅沉淀指定候选。');
|
|
351
|
+
lines.push('- **C. 跳过**:本轮不沉淀,仅运行 `zhuanspec progress resolve-correction <change-id> --mark-pitfall-saved`。');
|
|
352
|
+
lines.push('');
|
|
353
|
+
lines.push('> 注意:「拟分类 / 拟用文件名」仅为启发式建议,最终由 skill 模式 B 的分类决策树确认。');
|
|
354
|
+
lines.push('');
|
|
355
|
+
return lines.join('\n');
|
|
356
|
+
}
|
|
357
|
+
//# sourceMappingURL=select-candidates.js.map
|
|
@@ -8,8 +8,56 @@
|
|
|
8
8
|
* Design principle: no keyword matching, no auto-save — the LLM decides.
|
|
9
9
|
*/
|
|
10
10
|
import path from 'path';
|
|
11
|
+
import { promises as fs } from 'fs';
|
|
11
12
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
12
13
|
import { detectHookHost, sanitizeCodexEnvelope } from '../../utils/hook-host.js';
|
|
14
|
+
/** Level 2 兼容用的关键词(与 deviation-check 中 USER_CORRECTION_KEYWORDS/REQUIREMENT_CHANGE_KEYWORDS 保持同步) */
|
|
15
|
+
const FALLBACK_CORRECTION_KEYWORDS = ['不对', '错了', '不是这样', '改成', '重做', '错误', '有问题', '有 bug'];
|
|
16
|
+
const FALLBACK_REQUIREMENT_KEYWORDS = ['需求改', '需求变更', '需求调整', '新增需求', '补充需求'];
|
|
17
|
+
async function readCorrectionStats(cwd, changeId) {
|
|
18
|
+
if (!changeId)
|
|
19
|
+
return null;
|
|
20
|
+
const userInputsPath = path.join(cwd, 'zhuanspec', 'changes', changeId, 'metrics', 'user_inputs.json');
|
|
21
|
+
try {
|
|
22
|
+
const raw = await fs.readFile(userInputsPath, 'utf8');
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
const inputs = Array.isArray(parsed.inputs) ? parsed.inputs : [];
|
|
25
|
+
if (inputs.length === 0)
|
|
26
|
+
return { level: 3, hardCorrectionCount: 0, requirementChangeCount: 0 };
|
|
27
|
+
const hasSignal = inputs.some((i) => i && typeof i === 'object' && 'correctionSignal' in i);
|
|
28
|
+
if (hasSignal) {
|
|
29
|
+
let hard = 0;
|
|
30
|
+
let req = 0;
|
|
31
|
+
for (const entry of inputs) {
|
|
32
|
+
const sig = entry['correctionSignal'];
|
|
33
|
+
const postBaseline = entry['postBaseline'] === true;
|
|
34
|
+
if (!sig || sig.kind !== 'hard' || !postBaseline)
|
|
35
|
+
continue;
|
|
36
|
+
if (sig.isRequirementChange)
|
|
37
|
+
req += 1;
|
|
38
|
+
else
|
|
39
|
+
hard += 1;
|
|
40
|
+
}
|
|
41
|
+
return { level: 1, hardCorrectionCount: hard, requirementChangeCount: req };
|
|
42
|
+
}
|
|
43
|
+
// Level 2: 按 summary 关键词推断
|
|
44
|
+
let hard2 = 0;
|
|
45
|
+
let req2 = 0;
|
|
46
|
+
for (const entry of inputs) {
|
|
47
|
+
const summary = String(entry['summary'] || '').toLowerCase();
|
|
48
|
+
if (!summary)
|
|
49
|
+
continue;
|
|
50
|
+
if (FALLBACK_REQUIREMENT_KEYWORDS.some((k) => summary.includes(k.toLowerCase())))
|
|
51
|
+
req2 += 1;
|
|
52
|
+
else if (FALLBACK_CORRECTION_KEYWORDS.some((k) => summary.includes(k.toLowerCase())))
|
|
53
|
+
hard2 += 1;
|
|
54
|
+
}
|
|
55
|
+
return { level: 2, hardCorrectionCount: hard2, requirementChangeCount: req2 };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
13
61
|
export async function collectKnowledgeHook(options) {
|
|
14
62
|
let stdinData = {};
|
|
15
63
|
try {
|
|
@@ -61,6 +109,8 @@ export async function runCollectKnowledge(filePath) {
|
|
|
61
109
|
const cwd = process.cwd();
|
|
62
110
|
const knowledgeDir = path.join(cwd, 'zhuanspec', 'knowledge');
|
|
63
111
|
const hasKnowledge = await FileSystemUtils.directoryExists(knowledgeDir);
|
|
112
|
+
// 读纠偏统计(三级 fallback,读失败不阻断主流程)
|
|
113
|
+
const stats = await readCorrectionStats(cwd, changeId);
|
|
64
114
|
const knowledgeRef = hasKnowledge
|
|
65
115
|
? '参考 `zhuanspec/knowledge/` 下已有文件的格式(统一 Front Matter + 三类分目录)'
|
|
66
116
|
: '将写入 `zhuanspec/knowledge/troubleshooting/`、`best-practices/` 或 `implicit-conventions/` 对应目录(仅此三类)';
|
|
@@ -84,17 +134,42 @@ export async function runCollectKnowledge(filePath) {
|
|
|
84
134
|
'⚠️ **去重要求**:保存前先读取 `zhuanspec/knowledge/` 下已有文件,',
|
|
85
135
|
'若已有同类记录,跳过或追加到已有文件,不要重复新建。',
|
|
86
136
|
];
|
|
137
|
+
// 纠偏累计提示(基于 user_inputs.json)
|
|
138
|
+
//
|
|
139
|
+
// 阈值选择的启发式意图:
|
|
140
|
+
// - hardCorrectionCount >= 2:一次纠偏不提示避免打扰,两次及以上视为"稳定坑",优先沉淀
|
|
141
|
+
// - requirementChangeCount >= 1:需求变更对设计决策影响通常较大,出现一次即提示
|
|
142
|
+
// 本处仅做"是否优先沉淀"的强度提示,具体三类目录分类一律交给 @skill:zhuanspec:knowledge 的分类决策树,不在本 hook 做分类预判。
|
|
143
|
+
const correctionHints = [];
|
|
144
|
+
if (stats && stats.level <= 2) {
|
|
145
|
+
if (stats.hardCorrectionCount >= 2) {
|
|
146
|
+
correctionHints.push(`⚠️ 本次 apply 已累计 ${stats.hardCorrectionCount} 次纠偏(数据源 Level ${stats.level}),若本次改动是对其中某条纠偏的解决,**请优先沉淀**(具体分类交由 @skill:zhuanspec:knowledge 决定)。`);
|
|
147
|
+
}
|
|
148
|
+
if (stats.requirementChangeCount >= 1) {
|
|
149
|
+
correctionHints.push(`⚠️ 本次 apply 涉及 ${stats.requirementChangeCount} 次需求变更,若本次改动属于其中的设计决策负载,**建议优先沉淀为项目知识**(具体分类交由 @skill:zhuanspec:knowledge 决定)。`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
87
152
|
const additionalContext = [
|
|
88
153
|
`💡 **知识沉淀提示**(针对刚才对 \`${filePath || '该文件'}\` 的改动)`,
|
|
89
154
|
'',
|
|
155
|
+
...(correctionHints.length > 0 ? [...correctionHints, ''] : []),
|
|
90
156
|
...phaseGuide,
|
|
91
157
|
'',
|
|
92
|
-
'
|
|
93
|
-
'
|
|
158
|
+
'🔴 **硬性流程(顺序不可跳)**:',
|
|
159
|
+
'1. 先向用户询问「这次改动是否值得记录为项目知识?(是/否)」。',
|
|
160
|
+
'2. 用户回复「是」后,**必须调用 \`@skill:zhuanspec:knowledge\`(模式 A)**,由 skill 严格按以下顺序执行:',
|
|
161
|
+
' - 4.1 **列候选清单**(不写入任何文件)',
|
|
162
|
+
' - 4.2 **调用 \`askUserQuestion\` 多选确认**(A全部 / B选择性 / C跳过)',
|
|
163
|
+
' - 4.3 按用户选择写入,未选中的条目一律不写',
|
|
164
|
+
' - 4.4 输出摘要',
|
|
165
|
+
'3. 用户回复「否」时,本次不写入任何知识文件。',
|
|
166
|
+
'',
|
|
167
|
+
'🔴 **红线(违反等同于绕过质量门禁)**:',
|
|
168
|
+
`- **禁止绕过 skill 直接 Write/Edit 创建 \`zhuanspec/knowledge/\` 下的任何文件**;${knowledgeRef}。`,
|
|
169
|
+
'- **禁止默认全沉淀**:不允许 AI 自行判定 "哪些内容值得沉淀" 后一次性写入多个文件。',
|
|
170
|
+
'- **禁止跳过询问**:未调用 askUserQuestion 完成多选确认前,不得启动任何写入动作。',
|
|
94
171
|
'',
|
|
95
|
-
|
|
96
|
-
`**禁止绕过 skill 直接 Write/Edit 创建 \`zhuanspec/knowledge/\` 下的任何文件**;${knowledgeRef}。`,
|
|
97
|
-
'如果本次只是常规代码修改,忽略此提示即可。',
|
|
172
|
+
'如果本次只是常规代码修改,忽略此提示即可(用户回复「否」也会跳过)。',
|
|
98
173
|
].join('\n');
|
|
99
174
|
return {
|
|
100
175
|
continue: true,
|
|
@@ -15,7 +15,7 @@ interface DeviationCheckOptions {
|
|
|
15
15
|
json?: boolean;
|
|
16
16
|
file?: string;
|
|
17
17
|
tool?: string;
|
|
18
|
-
trigger?: 'pre-tool' | '
|
|
18
|
+
trigger?: 'pre-tool' | 'resume';
|
|
19
19
|
prompt?: string;
|
|
20
20
|
}
|
|
21
21
|
declare const PITFALL_KEYWORDS: string[];
|
|
@@ -24,6 +24,14 @@ declare const IMPLICIT_CONVENTION_KEYWORDS: string[];
|
|
|
24
24
|
declare const USER_CORRECTION_KEYWORDS: string[];
|
|
25
25
|
declare const REQUIREMENT_CHANGE_KEYWORDS: string[];
|
|
26
26
|
export declare function deviationCheckHook(options: DeviationCheckOptions): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* 识别 Codex CLI/App 由 Ambient Suggestions 等后台特性发出的系统内部 prompt。
|
|
29
|
+
* 这类 prompt 并非用户真实输入,不应触发纠偏/需求变更/范围检查。
|
|
30
|
+
*
|
|
31
|
+
* 参考:openai/codex#18541 —— Ambient Suggestions 会调用 UserPromptSubmit 钩子
|
|
32
|
+
* 但不调用对应的 Stop 钩子,且目前无法在项目级关闭。
|
|
33
|
+
*/
|
|
34
|
+
export declare function isCodexInternalPrompt(prompt: string): boolean;
|
|
27
35
|
/**
|
|
28
36
|
* 从提案中提取类名 / 标识符等符号,用于 L2 符号级匹配。
|
|
29
37
|
* 识别规则:
|
|
@@ -33,5 +41,22 @@ export declare function deviationCheckHook(options: DeviationCheckOptions): Prom
|
|
|
33
41
|
* - 排除全小写且无下划线的短词(避免与英文动词擞车)
|
|
34
42
|
*/
|
|
35
43
|
declare function parseEstimatedSymbols(proposalPath: string): Promise<string[]>;
|
|
44
|
+
export declare function extractProposalSummary(content: string, maxLength: number): string;
|
|
45
|
+
/**
|
|
46
|
+
* 构造纠偏四选一上下文(注入给主 agent)
|
|
47
|
+
* 要求主 agent:
|
|
48
|
+
* 1. 停止任何 Edit/Write
|
|
49
|
+
* 2. 用 askUserQuestion 让用户四选一
|
|
50
|
+
* 3. 按选择走对应路径
|
|
51
|
+
* 4. 路径完成后运行 resolve-correction 清理标记
|
|
52
|
+
*/
|
|
53
|
+
export declare function buildCorrectionOptionsContext(proposalSummary: string, changeId: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* 构造需求变更(T2)硬拦截的上下文。
|
|
56
|
+
* 与 buildCorrectionOptionsContext 的区别:
|
|
57
|
+
* - 仅提供 A / D 两个选项(需求变更必然要先改 Spec,无 Bug 豁免路径)
|
|
58
|
+
* - 文案强调“新增/变更功能范围”而非“AI 做错”
|
|
59
|
+
*/
|
|
60
|
+
export declare function buildRequirementChangeContext(proposalSummary: string, changeId: string): string;
|
|
36
61
|
export { PITFALL_KEYWORDS, USER_CORRECTION_KEYWORDS, BEST_PRACTICE_KEYWORDS, IMPLICIT_CONVENTION_KEYWORDS, REQUIREMENT_CHANGE_KEYWORDS, parseEstimatedSymbols };
|
|
37
62
|
//# sourceMappingURL=deviation-check.d.ts.map
|