@zhuan-ai/zhuanspec 2.16.0 → 2.16.3
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/index.js +37 -0
- package/dist/commands/progress.d.ts +12 -0
- package/dist/commands/progress.js +58 -5
- package/dist/commands/validate-reports.d.ts +17 -0
- package/dist/commands/validate-reports.js +76 -0
- 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 +14 -5
- package/dist/core/hooks/post-archive.js +25 -13
- package/dist/core/hooks/record-progress.d.ts +46 -0
- package/dist/core/hooks/record-progress.js +137 -48
- package/dist/core/hooks/summarize.js +18 -19
- package/dist/core/hooks/user-input-hook.js +4 -1
- package/dist/core/init.d.ts +5 -0
- package/dist/core/init.js +165 -7
- package/dist/core/templates/agents-template.d.ts +1 -1
- package/dist/core/templates/agents-template.js +6 -21
- package/dist/core/templates/slash-command-templates.js +92 -47
- package/dist/core/templates/tasks-template.js +6 -0
- package/dist/core/templates/tdd-tasks-template.js +8 -0
- package/dist/core/validation/report-schema.d.ts +74 -0
- package/dist/core/validation/report-schema.js +254 -0
- package/dist/utils/phase-utils.d.ts +13 -0
- package/dist/utils/phase-utils.js +38 -5
- package/package.json +23 -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
|
|
@@ -155,12 +155,21 @@ export async function runCollectKnowledge(filePath) {
|
|
|
155
155
|
...(correctionHints.length > 0 ? [...correctionHints, ''] : []),
|
|
156
156
|
...phaseGuide,
|
|
157
157
|
'',
|
|
158
|
-
'
|
|
159
|
-
'
|
|
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. 用户回复「否」时,本次不写入任何知识文件。',
|
|
160
166
|
'',
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
'
|
|
167
|
+
'🔴 **红线(违反等同于绕过质量门禁)**:',
|
|
168
|
+
`- **禁止绕过 skill 直接 Write/Edit 创建 \`zhuanspec/knowledge/\` 下的任何文件**;${knowledgeRef}。`,
|
|
169
|
+
'- **禁止默认全沉淀**:不允许 AI 自行判定 "哪些内容值得沉淀" 后一次性写入多个文件。',
|
|
170
|
+
'- **禁止跳过询问**:未调用 askUserQuestion 完成多选确认前,不得启动任何写入动作。',
|
|
171
|
+
'',
|
|
172
|
+
'如果本次只是常规代码修改,忽略此提示即可(用户回复「否」也会跳过)。',
|
|
164
173
|
].join('\n');
|
|
165
174
|
return {
|
|
166
175
|
continue: true,
|
|
@@ -3,6 +3,7 @@ import { FileSystemUtils } from '../../utils/file-system.js';
|
|
|
3
3
|
import { readdirSync, rmSync } from 'fs';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { getBeijingTime, getBeijingTimeForFilename } from './record-progress.js';
|
|
6
|
+
import { scanKnowledgeEntries, rebuildIndexContent, } from './knowledge-index.js';
|
|
6
7
|
export async function postArchiveHook(options) {
|
|
7
8
|
const output = await runPostArchiveHook(options.archived || '');
|
|
8
9
|
if (options.json) {
|
|
@@ -19,21 +20,11 @@ async function runPostArchiveHook(archivedChange) {
|
|
|
19
20
|
const repoRoot = process.cwd();
|
|
20
21
|
const zhuanspecDir = path.join(repoRoot, 'zhuanspec');
|
|
21
22
|
const knowledgeDir = path.join(zhuanspecDir, 'knowledge');
|
|
22
|
-
// 1. Update archive log
|
|
23
|
-
console.log(chalk.gray(' → 更新知识索引...'));
|
|
24
|
-
await FileSystemUtils.createDirectory(knowledgeDir);
|
|
25
23
|
const indexPath = path.join(knowledgeDir, 'index.md');
|
|
26
|
-
|
|
27
|
-
if (await FileSystemUtils.fileExists(indexPath)) {
|
|
28
|
-
const existing = await FileSystemUtils.readFile(indexPath);
|
|
29
|
-
await FileSystemUtils.writeFile(indexPath, `${existing.trimEnd()}\n${line}\n`);
|
|
30
|
-
}
|
|
31
|
-
else {
|
|
32
|
-
await FileSystemUtils.writeFile(indexPath, `# Knowledge Index\n\n## Archive Log\n${line}\n`);
|
|
33
|
-
}
|
|
34
|
-
// 2. Collect change knowledge (extract best practices, implicit conventions)
|
|
24
|
+
// 1. Collect change knowledge (extract best practices, implicit conventions)
|
|
35
25
|
// 注意:知识库只保留三类目录 troubleshooting / best-practices / implicit-conventions,
|
|
36
26
|
// 原“决策类”内容合并进 best-practices;踩坑类走 zhuanspec:knowledge skill 单独沉淀。
|
|
27
|
+
await FileSystemUtils.createDirectory(knowledgeDir);
|
|
37
28
|
console.log(chalk.gray(' → 提取设计文档中的知识记录...'));
|
|
38
29
|
const knowledgeResult = await collectChangeKnowledge(zhuanspecDir, archivedChange, knowledgeDir);
|
|
39
30
|
if (knowledgeResult.bestPractices > 0) {
|
|
@@ -42,7 +33,28 @@ async function runPostArchiveHook(archivedChange) {
|
|
|
42
33
|
if (knowledgeResult.implicitConventions > 0) {
|
|
43
34
|
console.log(chalk.gray(` - 隐式约定:${knowledgeResult.implicitConventions} 条`));
|
|
44
35
|
}
|
|
45
|
-
//
|
|
36
|
+
// 2. Reindex 三分区(修复:archive 前的 refreshBusinessTemplateFromRemote 会用远端 index.md
|
|
37
|
+
// 覆盖本地,导致 review/knowledge skill 期间 appendIndexEntry 的登记行丢失。
|
|
38
|
+
// 这里基于本地 troubleshooting/ best-practices/ implicit-conventions/ 实际文件重建三分区,
|
|
39
|
+
// 保留远端拉来的 ## Archive Log 既有内容。)
|
|
40
|
+
console.log(chalk.gray(' → 重建知识索引(三分区 + Archive Log)...'));
|
|
41
|
+
const scan = await scanKnowledgeEntries(knowledgeDir);
|
|
42
|
+
const existingIndex = (await FileSystemUtils.fileExists(indexPath))
|
|
43
|
+
? await FileSystemUtils.readFile(indexPath)
|
|
44
|
+
: null;
|
|
45
|
+
const rebuilt = rebuildIndexContent(scan, existingIndex);
|
|
46
|
+
await FileSystemUtils.writeFile(indexPath, rebuilt);
|
|
47
|
+
console.log(chalk.gray(` - troubleshooting=${scan.entries.filter((e) => e.category === 'troubleshooting').length}, ` +
|
|
48
|
+
`best-practices=${scan.entries.filter((e) => e.category === 'best-practices').length}, ` +
|
|
49
|
+
`implicit-conventions=${scan.entries.filter((e) => e.category === 'implicit-conventions').length}`));
|
|
50
|
+
if (scan.warnings.length > 0) {
|
|
51
|
+
console.log(chalk.yellow(` ⚠ Front Matter 不完整 ${scan.warnings.length} 条,建议补齐关键词/适用场景`));
|
|
52
|
+
}
|
|
53
|
+
// 3. Append 本次 archive log 行
|
|
54
|
+
const archiveLogLine = `- ${getBeijingTime()} archived: ${archivedChange}`;
|
|
55
|
+
const afterReindex = await FileSystemUtils.readFile(indexPath);
|
|
56
|
+
await FileSystemUtils.writeFile(indexPath, `${afterReindex.trimEnd()}\n${archiveLogLine}\n`);
|
|
57
|
+
// 4. Cleanup temp files
|
|
46
58
|
console.log(chalk.gray(' → 清理临时文件...'));
|
|
47
59
|
const tempFilesCleaned = await cleanupTempFiles(zhuanspecDir);
|
|
48
60
|
if (tempFilesCleaned > 0) {
|
|
@@ -32,6 +32,20 @@ export declare function getBeijingTime(): string;
|
|
|
32
32
|
* Returns format: "2026-04-21 18-35-22" (文件名安全格式)
|
|
33
33
|
*/
|
|
34
34
|
export declare function getBeijingTimeForFilename(): string;
|
|
35
|
+
/**
|
|
36
|
+
* v2.16+ 活跃耗时累加器
|
|
37
|
+
*
|
|
38
|
+
* 每次 PostToolUse 心跳到达时调用,根据 gap = now - lastActiveAt 判断:
|
|
39
|
+
* gap < PHASE_IDLE_THRESHOLD_MS → 活跃,累加到 durationMs
|
|
40
|
+
* gap >= PHASE_IDLE_THRESHOLD_MS → 空闲,累加到 idleMs 并 push idleSegments
|
|
41
|
+
*
|
|
42
|
+
* 同步刷新 wallClockMs、lastActiveAt、lastUpdatedAt。
|
|
43
|
+
* 返回本次增量便于调用方决定是否刷新 stats。
|
|
44
|
+
*/
|
|
45
|
+
export declare function tickActivePhaseDuration(entry: PhaseDurationRecord, nowEpoch?: number, nowTimestamp?: string): {
|
|
46
|
+
activeDelta: number;
|
|
47
|
+
idleDelta: number;
|
|
48
|
+
};
|
|
35
49
|
interface RecordProgressOptions {
|
|
36
50
|
json?: boolean;
|
|
37
51
|
file?: string;
|
|
@@ -143,14 +157,32 @@ interface ClarificationRecord {
|
|
|
143
157
|
}
|
|
144
158
|
/**
|
|
145
159
|
* Phase duration record for tracking time spent in each phase
|
|
160
|
+
*
|
|
161
|
+
* v2.16+:字段语义反转
|
|
162
|
+
* - durationMs:覆盖为“活跃耗时”(剔除空闲段,PHASE_IDLE_THRESHOLD_MS 内累加),
|
|
163
|
+
* 业务主指标,与上报/采集口径保持一致。
|
|
164
|
+
* - wallClockMs:原 durationMs 的语义(endedAt|lastUpdatedAt - startedAt 墙钟差),
|
|
165
|
+
* 仅作审计/调试参考,迁移期老 progress.json 会把老的 durationMs 复制到此字段。
|
|
166
|
+
* - idleMs / idleSegments:活跃耗时之外被剔除的空闲明细(可观测)。
|
|
167
|
+
* - lastActiveAt:最后一次 hook 心跳的北京时间字符串,下次心跳到达时计算 gap。
|
|
146
168
|
*/
|
|
147
169
|
export interface PhaseDurationRecord {
|
|
148
170
|
phase: Phase;
|
|
149
171
|
startedAt: string;
|
|
150
172
|
endedAt?: string;
|
|
173
|
+
lastUpdatedAt?: string;
|
|
151
174
|
durationMs: number;
|
|
152
175
|
taskCount: number;
|
|
153
176
|
completedTaskCount: number;
|
|
177
|
+
wallClockMs?: number;
|
|
178
|
+
idleMs?: number;
|
|
179
|
+
lastActiveAt?: string;
|
|
180
|
+
_lastActiveEpoch?: number;
|
|
181
|
+
idleSegments?: Array<{
|
|
182
|
+
from: string;
|
|
183
|
+
to: string;
|
|
184
|
+
durationMs: number;
|
|
185
|
+
}>;
|
|
154
186
|
}
|
|
155
187
|
/**
|
|
156
188
|
* Proposal change record for tracking modifications to proposal files
|
|
@@ -321,6 +353,20 @@ export interface ProgressData {
|
|
|
321
353
|
review: number;
|
|
322
354
|
archive: number;
|
|
323
355
|
};
|
|
356
|
+
wallClockMs?: {
|
|
357
|
+
techDesign: number;
|
|
358
|
+
propose: number;
|
|
359
|
+
apply: number;
|
|
360
|
+
review: number;
|
|
361
|
+
archive: number;
|
|
362
|
+
};
|
|
363
|
+
idleMs?: {
|
|
364
|
+
techDesign: number;
|
|
365
|
+
propose: number;
|
|
366
|
+
apply: number;
|
|
367
|
+
review: number;
|
|
368
|
+
archive: number;
|
|
369
|
+
};
|
|
324
370
|
};
|
|
325
371
|
proposalChanges: ProposalChangeRecord[];
|
|
326
372
|
corrections?: CorrectionRecord[];
|