@zhuan-ai/zhuanspec 2.16.0 → 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/index.js +17 -0
- package/dist/commands/progress.d.ts +12 -0
- package/dist/commands/progress.js +43 -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 +14 -5
- package/dist/core/hooks/post-archive.js +25 -13
- package/dist/core/hooks/record-progress.d.ts +1 -0
- package/dist/core/hooks/record-progress.js +16 -0
- package/dist/core/hooks/summarize.js +18 -19
- 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 +72 -46
- package/dist/utils/phase-utils.js +5 -0
- package/package.json +22 -20
package/dist/cli/index.js
CHANGED
|
@@ -329,6 +329,23 @@ progressCmd
|
|
|
329
329
|
process.exit(1);
|
|
330
330
|
}
|
|
331
331
|
});
|
|
332
|
+
// Progress list-corrections subcommand (review-step0 踩坑沉淀决策清单)
|
|
333
|
+
progressCmd
|
|
334
|
+
.command('list-corrections <change-id>')
|
|
335
|
+
.description('Render pending correction candidates (filter trivial acks + dedup) for review-step0 decision making')
|
|
336
|
+
.option('--out <path>', 'Output file path (default: zhuanspec/changes/<id>/review/pending-corrections.md)')
|
|
337
|
+
.option('--json', 'Print result as JSON to stdout instead of writing markdown file')
|
|
338
|
+
.action(async (changeId, options) => {
|
|
339
|
+
try {
|
|
340
|
+
const progressCommand = new ProgressCommand();
|
|
341
|
+
await progressCommand.listCorrections(changeId, options);
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
console.log();
|
|
345
|
+
ora().fail(`Error: ${error.message}`);
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
332
349
|
// Progress mark-baseline subcommand (Task 0 手动路径)
|
|
333
350
|
progressCmd
|
|
334
351
|
.command('mark-baseline <change-id> <phase>')
|
|
@@ -68,6 +68,18 @@ export declare class ProgressCommand {
|
|
|
68
68
|
showCorrection(changeId: string, options?: {
|
|
69
69
|
tail?: number;
|
|
70
70
|
}): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* List pending correction candidates with trivial-ack filtering and dedup.
|
|
73
|
+
* Usage: zhuanspec progress list-corrections <change-id> [--out <path>] [--json]
|
|
74
|
+
*
|
|
75
|
+
* 默认会把 markdown 清单写到 `zhuanspec/changes/<id>/review/pending-corrections.md`,
|
|
76
|
+
* 供 review 阶段「踩坑沉淀」步骤的 askUserQuestion 三选项决策使用。
|
|
77
|
+
* `--json` 模式只走 stdout JSON,不写文件,便于 skill / 测试程序消费。
|
|
78
|
+
*/
|
|
79
|
+
listCorrections(changeId: string, options?: {
|
|
80
|
+
out?: string;
|
|
81
|
+
json?: boolean;
|
|
82
|
+
}): Promise<void>;
|
|
71
83
|
/**
|
|
72
84
|
* Mark phase baseline manually.
|
|
73
85
|
* Usage: zhuanspec progress mark-baseline <change-id> <phase> [--force]
|
|
@@ -13,6 +13,7 @@ import { PHASE_ORDER } from '../utils/phase-utils.js';
|
|
|
13
13
|
import { initializeProgress, atomicWriteJson, getBeijingTime } from '../core/hooks/record-progress.js';
|
|
14
14
|
import { recomputeAccuracyRate, persistAccuracyJson, getOrCreatePhaseBucket, applyPhaseOverride, confirmPhaseAccuracy, appendAccuracyDebugLog, } from '../core/metrics/code-accuracy.js';
|
|
15
15
|
import { resolveZhuanSpecRoot } from '../utils/resolve-root.js';
|
|
16
|
+
import { selectCorrectionCandidates, renderPendingCorrectionsMarkdown, } from '../core/corrections/select-candidates.js';
|
|
16
17
|
export class ProgressCommand {
|
|
17
18
|
/**
|
|
18
19
|
* Recover damaged progress.json using three-tier fallback strategy
|
|
@@ -468,7 +469,10 @@ export class ProgressCommand {
|
|
|
468
469
|
promptIndex: markerMeta.phaseInputIndex,
|
|
469
470
|
promptSnippet: markerMeta.promptSnippet,
|
|
470
471
|
};
|
|
471
|
-
|
|
472
|
+
// v2.15.16:与 phaseDurations.startedAt / lastUpdatedAt 等字段对齐,统一使用北京时间
|
|
473
|
+
// naive 字符串。correctionContext.resolvedAt / expiresAt 仍保留 ISOString —— 它们
|
|
474
|
+
// 走 30min TTL(new Date(expiresAt) < Date.now())比较,ISO 解析更稳健。
|
|
475
|
+
progress.lastUpdatedAt = getBeijingTime();
|
|
472
476
|
await fs.writeFile(progressPath, JSON.stringify(progress, null, 2), 'utf-8');
|
|
473
477
|
}
|
|
474
478
|
}
|
|
@@ -483,7 +487,8 @@ export class ProgressCommand {
|
|
|
483
487
|
const raw = await FileSystemUtils.readFile(progressPath);
|
|
484
488
|
const progress = JSON.parse(raw);
|
|
485
489
|
progress.askedPitfallSaved = true;
|
|
486
|
-
|
|
490
|
+
// v2.15.16:统一北京时间格式
|
|
491
|
+
progress.lastUpdatedAt = getBeijingTime();
|
|
487
492
|
await fs.writeFile(progressPath, JSON.stringify(progress, null, 2), 'utf-8');
|
|
488
493
|
}
|
|
489
494
|
catch {
|
|
@@ -575,6 +580,42 @@ export class ProgressCommand {
|
|
|
575
580
|
}
|
|
576
581
|
console.log();
|
|
577
582
|
}
|
|
583
|
+
/**
|
|
584
|
+
* List pending correction candidates with trivial-ack filtering and dedup.
|
|
585
|
+
* Usage: zhuanspec progress list-corrections <change-id> [--out <path>] [--json]
|
|
586
|
+
*
|
|
587
|
+
* 默认会把 markdown 清单写到 `zhuanspec/changes/<id>/review/pending-corrections.md`,
|
|
588
|
+
* 供 review 阶段「踩坑沉淀」步骤的 askUserQuestion 三选项决策使用。
|
|
589
|
+
* `--json` 模式只走 stdout JSON,不写文件,便于 skill / 测试程序消费。
|
|
590
|
+
*/
|
|
591
|
+
async listCorrections(changeId, options) {
|
|
592
|
+
const cwd = resolveZhuanSpecRoot();
|
|
593
|
+
const changeDir = path.join(cwd, 'zhuanspec', 'changes', changeId);
|
|
594
|
+
if (!await FileSystemUtils.directoryExists(changeDir)) {
|
|
595
|
+
throw new Error(`Change '${changeId}' not found`);
|
|
596
|
+
}
|
|
597
|
+
const result = await selectCorrectionCandidates(cwd, changeId);
|
|
598
|
+
if (options?.json) {
|
|
599
|
+
// stdout JSON 模式:用于程序化消费(不写文件)
|
|
600
|
+
console.log(JSON.stringify(result, null, 2));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const markdown = renderPendingCorrectionsMarkdown(result);
|
|
604
|
+
const outPath = options?.out
|
|
605
|
+
? (path.isAbsolute(options.out) ? options.out : path.join(cwd, options.out))
|
|
606
|
+
: path.join(changeDir, 'review', 'pending-corrections.md');
|
|
607
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
608
|
+
await fs.writeFile(outPath, markdown, 'utf-8');
|
|
609
|
+
const rel = path.relative(cwd, outPath) || outPath;
|
|
610
|
+
console.log(`\n✓ Pending corrections written to: ${rel}`);
|
|
611
|
+
console.log(` - 数据源: ${result.level === 1 ? 'Level 1 (correctionSignal)' : result.level === 2 ? 'Level 2 (summary 关键词)' : '无'}`);
|
|
612
|
+
console.log(` - 原始条目: ${result.totalRaw}`);
|
|
613
|
+
console.log(` - 有效候选: ${result.candidates.length}`);
|
|
614
|
+
console.log(` - 已过滤: ${result.dropped.length}`);
|
|
615
|
+
if (result.notice)
|
|
616
|
+
console.log(` - 提示: ${result.notice}`);
|
|
617
|
+
console.log('');
|
|
618
|
+
}
|
|
578
619
|
/**
|
|
579
620
|
* Mark phase baseline manually.
|
|
580
621
|
* Usage: zhuanspec progress mark-baseline <change-id> <phase> [--force]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending corrections candidate selection.
|
|
3
|
+
*
|
|
4
|
+
* 责任:把 `zhuanspec/changes/<id>/metrics/user_inputs.json` 里的纠偏 prompt 加工成
|
|
5
|
+
* "可读、去毛刺、去重、带建议分类" 的候选清单,给 review 阶段的踩坑沉淀决策使用。
|
|
6
|
+
*
|
|
7
|
+
* 不写文件、不改 progress.json,只做"读 + 计算 + 返回结构"。
|
|
8
|
+
*/
|
|
9
|
+
export type CandidateCategory = 'troubleshooting' | 'best-practices' | 'implicit-conventions';
|
|
10
|
+
export type CandidateSourceLevel = 1 | 2;
|
|
11
|
+
export interface CorrectionCandidate {
|
|
12
|
+
/** 1-based 序号,对应"用户回复编号清单"的编号 */
|
|
13
|
+
index: number;
|
|
14
|
+
/** 关联的 user_inputs.json.inputId */
|
|
15
|
+
inputId?: string;
|
|
16
|
+
timestamp?: string;
|
|
17
|
+
phase?: string;
|
|
18
|
+
/** 优先 fullPrompt,没有就 summary,再没有就空字符串 */
|
|
19
|
+
text: string;
|
|
20
|
+
/** 截断展示用的预览(≤ 200 字) */
|
|
21
|
+
preview: string;
|
|
22
|
+
/** 命中的纠偏关键词(取 USER_CORRECTION_KEYWORDS / REQUIREMENT_CHANGE_KEYWORDS) */
|
|
23
|
+
matchedKeywords: string[];
|
|
24
|
+
isRequirementChange: boolean;
|
|
25
|
+
/** 启发式建议分类(用户/skill 仍可改) */
|
|
26
|
+
suggestedCategory: CandidateCategory;
|
|
27
|
+
/** 拟用文件名(不含目录前缀) */
|
|
28
|
+
suggestedFilename: string;
|
|
29
|
+
}
|
|
30
|
+
export interface DroppedCandidate {
|
|
31
|
+
/** drop 后保留的原始序号(按出现顺序,1-based)——用于在 markdown 里展示 */
|
|
32
|
+
rawIndex: number;
|
|
33
|
+
inputId?: string;
|
|
34
|
+
timestamp?: string;
|
|
35
|
+
reason: 'empty' | 'trivial-acknowledgement' | 'too-short-no-keyword' | 'duplicate-of' | 'subset-of';
|
|
36
|
+
/** 当 reason 为 duplicate-of / subset-of 时,指向被引用的有效候选编号 */
|
|
37
|
+
refIndex?: number;
|
|
38
|
+
preview: string;
|
|
39
|
+
}
|
|
40
|
+
export interface CandidateSelectionResult {
|
|
41
|
+
changeId: string;
|
|
42
|
+
/** 数据源层级(1: user_inputs 带 correctionSignal;2: 关键词推断) */
|
|
43
|
+
level: CandidateSourceLevel | null;
|
|
44
|
+
totalRaw: number;
|
|
45
|
+
candidates: CorrectionCandidate[];
|
|
46
|
+
dropped: DroppedCandidate[];
|
|
47
|
+
/** 数据完整性提示——读不到 user_inputs.json 时给空候选 + 错误说明 */
|
|
48
|
+
notice?: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 从 change 目录抽取并加工纠偏候选。
|
|
52
|
+
*/
|
|
53
|
+
export declare function selectCorrectionCandidates(cwd: string, changeId: string): Promise<CandidateSelectionResult>;
|
|
54
|
+
/**
|
|
55
|
+
* 把候选结果渲染成 markdown,用于落到 review/pending-corrections.md。
|
|
56
|
+
*/
|
|
57
|
+
export declare function renderPendingCorrectionsMarkdown(result: CandidateSelectionResult): string;
|
|
58
|
+
//# sourceMappingURL=select-candidates.d.ts.map
|
|
@@ -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) {
|