@zhuan-ai/zhuanspec 2.9.5 → 2.11.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.js +8 -8
- package/dist/cli/index.js +1 -0
- package/dist/commands/design.d.ts +12 -2
- package/dist/commands/design.js +83 -10
- package/dist/commands/progress.js +11 -0
- package/dist/commands/review.d.ts +1 -25
- package/dist/commands/review.js +51 -408
- package/dist/core/completions/command-registry.js +4 -0
- package/dist/core/hooks/collect-knowledge.d.ts +13 -5
- package/dist/core/hooks/collect-knowledge.js +56 -164
- package/dist/core/hooks/deviation-check.js +159 -55
- package/dist/core/hooks/init.js +46 -0
- package/dist/core/hooks/post-apply.js +2 -0
- package/dist/core/hooks/pre-archive.js +13 -14
- package/dist/core/hooks/pre-review.d.ts +27 -0
- package/dist/core/hooks/pre-review.js +169 -0
- package/dist/core/hooks/record-progress.d.ts +25 -0
- package/dist/core/hooks/record-progress.js +88 -1
- package/dist/core/hooks/review-hooks.js +176 -7
- package/dist/core/hooks/review-orchestrator.js +58 -11
- package/dist/core/hooks/tdd-phase-hook.d.ts +59 -0
- package/dist/core/hooks/tdd-phase-hook.js +313 -0
- package/dist/core/init.d.ts +1 -0
- package/dist/core/init.js +45 -2
- package/dist/core/templates/agents-template.d.ts +1 -1
- package/dist/core/templates/agents-template.js +139 -23
- package/dist/core/templates/skill-templates.js +21 -1
- package/dist/core/templates/slash-command-templates.js +163 -63
- package/dist/core/templates/tasks-template.js +89 -0
- package/dist/core/update.d.ts +3 -0
- package/dist/core/update.js +85 -2
- package/dist/core/validation/strict-rules.d.ts +47 -0
- package/dist/core/validation/strict-rules.js +589 -42
- package/dist/utils/git-repo-detector.js +1 -1
- package/dist/utils/phase-utils.js +14 -1
- package/package.json +22 -20
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PostToolUse Hook - Collect Knowledge
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Fires after Write/Edit in Apply phase. Injects a lightweight prompt
|
|
5
|
+
* into Claude's context so the LLM can decide (with user confirmation)
|
|
6
|
+
* whether the change is worth capturing as project knowledge.
|
|
7
|
+
*
|
|
8
|
+
* Design principle: no keyword matching, no auto-save — the LLM decides.
|
|
8
9
|
*/
|
|
9
10
|
import path from 'path';
|
|
10
11
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
11
|
-
import { PITFALL_KEYWORDS, BEST_PRACTICE_KEYWORDS, IMPLICIT_CONVENTION_KEYWORDS } from './deviation-check.js';
|
|
12
|
-
import { getBeijingTime, getBeijingTimeForFilename } from './record-progress.js';
|
|
13
12
|
export async function collectKnowledgeHook(options) {
|
|
14
|
-
// Read stdin for Claude Code hook input
|
|
15
13
|
let stdinData = {};
|
|
16
14
|
try {
|
|
17
15
|
const stdinContent = await readStdin();
|
|
@@ -22,22 +20,12 @@ export async function collectKnowledgeHook(options) {
|
|
|
22
20
|
catch {
|
|
23
21
|
// Ignore parse errors
|
|
24
22
|
}
|
|
25
|
-
// Get content from stdin or options
|
|
26
|
-
const content = stdinData.tool_input?.content
|
|
27
|
-
|| options.content
|
|
28
|
-
|| '';
|
|
29
23
|
const filePath = stdinData.tool_input?.file_path
|
|
30
24
|
|| options.file
|
|
31
25
|
|| '';
|
|
32
|
-
const output = await runCollectKnowledge(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
else {
|
|
37
|
-
if (output.systemMessage) {
|
|
38
|
-
console.log(output.systemMessage);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
26
|
+
const output = await runCollectKnowledge(filePath);
|
|
27
|
+
// Always output JSON — additionalContext must reach Claude Code
|
|
28
|
+
console.log(JSON.stringify(output));
|
|
41
29
|
}
|
|
42
30
|
async function readStdin() {
|
|
43
31
|
return new Promise((resolve) => {
|
|
@@ -48,156 +36,60 @@ async function readStdin() {
|
|
|
48
36
|
process.stdin.on('error', () => resolve(null));
|
|
49
37
|
});
|
|
50
38
|
}
|
|
51
|
-
async function runCollectKnowledge(
|
|
39
|
+
export async function runCollectKnowledge(filePath) {
|
|
52
40
|
const phase = process.env.ZHUANSPEC_PHASE || 'idle';
|
|
53
41
|
const changeId = process.env.ZHUANSPEC_CHANGE_ID || '';
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
};
|
|
42
|
+
// Only apply and archive phases trigger knowledge capture
|
|
43
|
+
// archive is the most important moment — end-of-change reflection
|
|
44
|
+
const isActivePhase = phase === 'apply' || phase === 'archive';
|
|
45
|
+
if (!changeId || !isActivePhase) {
|
|
46
|
+
return { continue: true };
|
|
59
47
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const troubleshootingDir = path.join(knowledgeDir, 'troubleshooting');
|
|
65
|
-
const bestPracticesDir = path.join(knowledgeDir, 'best-practices');
|
|
66
|
-
const implicitConventionsDir = path.join(knowledgeDir, 'implicit-conventions');
|
|
67
|
-
// Check for all keyword types
|
|
68
|
-
const contentLower = content.toLowerCase();
|
|
69
|
-
const detectedPitfalls = PITFALL_KEYWORDS.filter(kw => contentLower.includes(kw.toLowerCase()));
|
|
70
|
-
const detectedBestPractices = BEST_PRACTICE_KEYWORDS.filter(kw => contentLower.includes(kw.toLowerCase()));
|
|
71
|
-
const detectedImplicitConventions = IMPLICIT_CONVENTION_KEYWORDS.filter(kw => contentLower.includes(kw.toLowerCase()));
|
|
72
|
-
// If no keywords detected, return early
|
|
73
|
-
if (detectedPitfalls.length === 0 && detectedBestPractices.length === 0 && detectedImplicitConventions.length === 0) {
|
|
74
|
-
return {
|
|
75
|
-
continue: true,
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
// Create directories as needed
|
|
79
|
-
await FileSystemUtils.createDirectory(knowledgeDir);
|
|
80
|
-
const collectedTypes = [];
|
|
81
|
-
let totalEntries = 0;
|
|
82
|
-
// Process pitfalls
|
|
83
|
-
if (detectedPitfalls.length > 0) {
|
|
84
|
-
await FileSystemUtils.createDirectory(troubleshootingDir);
|
|
85
|
-
const pitfall = extractKnowledgeContext(content, filePath, detectedPitfalls, 'troubleshooting');
|
|
86
|
-
const timestamp = getBeijingTimeForFilename();
|
|
87
|
-
const filename = `pitfall-${timestamp}.md`;
|
|
88
|
-
const pitfallPath = path.join(troubleshootingDir, filename);
|
|
89
|
-
await FileSystemUtils.writeFile(pitfallPath, formatKnowledgeRecord(pitfall));
|
|
90
|
-
updateKnowledgeIndex(knowledgeDir, 'troubleshooting', pitfall.title, filename);
|
|
91
|
-
collectedTypes.push('pitfall');
|
|
92
|
-
totalEntries++;
|
|
93
|
-
}
|
|
94
|
-
// Process best practices
|
|
95
|
-
if (detectedBestPractices.length > 0) {
|
|
96
|
-
await FileSystemUtils.createDirectory(bestPracticesDir);
|
|
97
|
-
const bestPractice = extractKnowledgeContext(content, filePath, detectedBestPractices, 'best_practice');
|
|
98
|
-
const timestamp = getBeijingTimeForFilename();
|
|
99
|
-
const filename = `best-practice-${timestamp}.md`;
|
|
100
|
-
const bpPath = path.join(bestPracticesDir, filename);
|
|
101
|
-
await FileSystemUtils.writeFile(bpPath, formatKnowledgeRecord(bestPractice));
|
|
102
|
-
updateKnowledgeIndex(knowledgeDir, 'best-practices', bestPractice.title, filename);
|
|
103
|
-
collectedTypes.push('best practice');
|
|
104
|
-
totalEntries++;
|
|
105
|
-
}
|
|
106
|
-
// Process implicit conventions
|
|
107
|
-
if (detectedImplicitConventions.length > 0) {
|
|
108
|
-
await FileSystemUtils.createDirectory(implicitConventionsDir);
|
|
109
|
-
const implicitConvention = extractKnowledgeContext(content, filePath, detectedImplicitConventions, 'implicit_contract');
|
|
110
|
-
const timestamp = getBeijingTimeForFilename();
|
|
111
|
-
const filename = `implicit-convention-${timestamp}.md`;
|
|
112
|
-
const icPath = path.join(implicitConventionsDir, filename);
|
|
113
|
-
await FileSystemUtils.writeFile(icPath, formatKnowledgeRecord(implicitConvention));
|
|
114
|
-
updateKnowledgeIndex(knowledgeDir, 'implicit-conventions', implicitConvention.title, filename);
|
|
115
|
-
collectedTypes.push('implicit convention');
|
|
116
|
-
totalEntries++;
|
|
48
|
+
// Only skip writes to knowledge/ itself to prevent capture loops
|
|
49
|
+
// Archive phase writes to zhuanspec/changes/archive/... should still trigger
|
|
50
|
+
if (filePath.includes('zhuanspec/knowledge/')) {
|
|
51
|
+
return { continue: true };
|
|
117
52
|
}
|
|
53
|
+
const cwd = process.cwd();
|
|
54
|
+
const knowledgeDir = path.join(cwd, 'zhuanspec', 'knowledge');
|
|
55
|
+
const hasKnowledge = await FileSystemUtils.directoryExists(knowledgeDir);
|
|
56
|
+
const knowledgeRef = hasKnowledge
|
|
57
|
+
? '参考 `zhuanspec/knowledge/` 下已有文件的格式'
|
|
58
|
+
: '保存到 `zhuanspec/knowledge/troubleshooting/`、`best-practices/` 或 `implicit-conventions/` 对应目录';
|
|
59
|
+
// Phase-specific capture focus to avoid duplicate entries across apply and archive
|
|
60
|
+
const phaseGuide = phase === 'archive'
|
|
61
|
+
? [
|
|
62
|
+
'**当前处于归档阶段** — 重点捕获整个变更过程的回顾性总结:',
|
|
63
|
+
'- 贯穿整个实施过程的模式或规律',
|
|
64
|
+
'- 事后才意识到的设计决策或取舍',
|
|
65
|
+
'- apply 阶段**未曾记录**的新洞察',
|
|
66
|
+
'',
|
|
67
|
+
'⚠️ **去重要求**:保存前先读取 `zhuanspec/knowledge/` 下已有文件,',
|
|
68
|
+
'若本次内容与已有记录高度重叠,跳过或仅追加补充内容,不要重复新建文件。',
|
|
69
|
+
]
|
|
70
|
+
: [
|
|
71
|
+
'**当前处于实施阶段** — 重点捕获即时发现的问题:',
|
|
72
|
+
'- 修复了一个非显而易见的 bug 或绕过了一个坑',
|
|
73
|
+
'- 发现了一个隐式约定或框架默认行为',
|
|
74
|
+
'- 总结了一个值得复用的最佳实践',
|
|
75
|
+
'',
|
|
76
|
+
'⚠️ **去重要求**:保存前先读取 `zhuanspec/knowledge/` 下已有文件,',
|
|
77
|
+
'若已有同类记录,跳过或追加到已有文件,不要重复新建。',
|
|
78
|
+
];
|
|
79
|
+
const additionalContext = [
|
|
80
|
+
`💡 **知识沉淀提示**(针对刚才对 \`${filePath || '该文件'}\` 的改动)`,
|
|
81
|
+
'',
|
|
82
|
+
...phaseGuide,
|
|
83
|
+
'',
|
|
84
|
+
'询问方式:',
|
|
85
|
+
'> "这次改动是否值得记录为项目知识?(回复是/否)"',
|
|
86
|
+
'',
|
|
87
|
+
`用户确认后,用 Write 工具写入知识文件,${knowledgeRef}。`,
|
|
88
|
+
'如果本次只是常规代码修改,忽略此提示即可。',
|
|
89
|
+
].join('\n');
|
|
118
90
|
return {
|
|
119
91
|
continue: true,
|
|
120
|
-
|
|
121
|
-
hookSpecificOutput: {
|
|
122
|
-
knowledgeCollected: `${totalEntries} entries`,
|
|
123
|
-
pitfallDetected: detectedPitfalls.length > 0,
|
|
124
|
-
},
|
|
92
|
+
hookSpecificOutput: { additionalContext },
|
|
125
93
|
};
|
|
126
94
|
}
|
|
127
|
-
async function updateKnowledgeIndex(knowledgeDir, section, title, filename) {
|
|
128
|
-
const indexPath = path.join(knowledgeDir, 'index.md');
|
|
129
|
-
const subdir = section === 'troubleshooting' ? 'troubleshooting' :
|
|
130
|
-
section === 'best-practices' ? 'best-practices' :
|
|
131
|
-
'implicit-conventions';
|
|
132
|
-
const indexEntry = `\n- [${title}](${subdir}/${filename}) - ${getBeijingTime()}\n`;
|
|
133
|
-
if (await FileSystemUtils.fileExists(indexPath)) {
|
|
134
|
-
const existingIndex = await FileSystemUtils.readFile(indexPath);
|
|
135
|
-
// Find section or create it
|
|
136
|
-
const sectionHeader = `## ${section}`;
|
|
137
|
-
if (existingIndex.includes(sectionHeader)) {
|
|
138
|
-
// Append to existing section
|
|
139
|
-
const updated = existingIndex.replace(sectionHeader, `${sectionHeader}${indexEntry}`);
|
|
140
|
-
await FileSystemUtils.writeFile(indexPath, updated);
|
|
141
|
-
}
|
|
142
|
-
else {
|
|
143
|
-
// Add new section
|
|
144
|
-
await FileSystemUtils.writeFile(indexPath, `${existingIndex}\n\n${sectionHeader}${indexEntry}`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
else {
|
|
148
|
-
await FileSystemUtils.writeFile(indexPath, `# Knowledge Index\n\n## troubleshooting\n\n## best-practices\n\n## implicit-conventions\n${indexEntry}`);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
function extractKnowledgeContext(content, filePath, keywords, type) {
|
|
152
|
-
// Extract relevant context around keywords
|
|
153
|
-
const lines = content.split('\n');
|
|
154
|
-
const relevantLines = [];
|
|
155
|
-
for (let i = 0; i < lines.length; i++) {
|
|
156
|
-
const lineLower = lines[i].toLowerCase();
|
|
157
|
-
if (keywords.some(kw => lineLower.includes(kw.toLowerCase()))) {
|
|
158
|
-
// Include surrounding context (3 lines before and after)
|
|
159
|
-
const start = Math.max(0, i - 3);
|
|
160
|
-
const end = Math.min(lines.length, i + 4);
|
|
161
|
-
for (let j = start; j < end; j++) {
|
|
162
|
-
relevantLines.push(lines[j]);
|
|
163
|
-
}
|
|
164
|
-
relevantLines.push('---');
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
// Generate title from first keyword found
|
|
168
|
-
const firstKeyword = keywords[0];
|
|
169
|
-
const typeLabel = type === 'troubleshooting' ? 'Issue' :
|
|
170
|
-
type === 'best_practice' ? 'Best Practice' :
|
|
171
|
-
'Convention';
|
|
172
|
-
const title = `${typeLabel}: ${firstKeyword} in ${filePath}`;
|
|
173
|
-
return {
|
|
174
|
-
type,
|
|
175
|
-
title,
|
|
176
|
-
content: relevantLines.join('\n'),
|
|
177
|
-
source: filePath,
|
|
178
|
-
timestamp: getBeijingTime(),
|
|
179
|
-
tags: keywords,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
function formatKnowledgeRecord(entry) {
|
|
183
|
-
return `# ${entry.title}
|
|
184
|
-
|
|
185
|
-
**Type**: ${entry.type}
|
|
186
|
-
**Source**: ${entry.source}
|
|
187
|
-
**Timestamp**: ${entry.timestamp}
|
|
188
|
-
**Tags**: ${entry.tags.join(', ')}
|
|
189
|
-
|
|
190
|
-
## Context
|
|
191
|
-
|
|
192
|
-
${entry.content}
|
|
193
|
-
|
|
194
|
-
## Resolution
|
|
195
|
-
|
|
196
|
-
[To be filled when resolved]
|
|
197
|
-
|
|
198
|
-
## Lessons Learned
|
|
199
|
-
|
|
200
|
-
[To be extracted after resolution]
|
|
201
|
-
`;
|
|
202
|
-
}
|
|
203
95
|
//# sourceMappingURL=collect-knowledge.js.map
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* - Out of scope → block with deviation handling options
|
|
13
13
|
*/
|
|
14
14
|
import path from 'path';
|
|
15
|
+
import fs from 'fs';
|
|
15
16
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
16
17
|
import { GitRepoDetector } from '../../utils/git-repo-detector.js';
|
|
17
18
|
import { checkPreApplyConditions } from './pre-apply.js';
|
|
@@ -35,11 +36,33 @@ const IMPLICIT_CONVENTION_KEYWORDS = [
|
|
|
35
36
|
'默认行为', '默认配置', '默认值', '隐式规则',
|
|
36
37
|
'implicit convention', 'default behavior', 'convention',
|
|
37
38
|
];
|
|
38
|
-
// User correction keywords
|
|
39
|
+
// User correction/pitfall keywords — bilingual, flexible matching
|
|
39
40
|
const USER_CORRECTION_KEYWORDS = [
|
|
40
|
-
|
|
41
|
-
'
|
|
42
|
-
'
|
|
41
|
+
// 中文纠错
|
|
42
|
+
'不对', '错了', '不是', '有问题', '有个问题', '这里有问题',
|
|
43
|
+
'这样不行', '不行', '有bug', '有Bug', '踩坑', '坑了',
|
|
44
|
+
'应该是', '应该改', '需要修改', '修改一下', '改一下',
|
|
45
|
+
'不应该', '这里错了', '你理解错了', '理解错误', '理解有误',
|
|
46
|
+
'不是这个意思', '不是这样的', '重做', '重新来', '逻辑不对',
|
|
47
|
+
'实现不对', '方向错了', '写错了', '搞错了',
|
|
48
|
+
// English corrections
|
|
49
|
+
'wrong', 'incorrect', "that's wrong", "you're wrong", 'not right',
|
|
50
|
+
'should be', 'should have been', "that's not", 'no no', 'wait no',
|
|
51
|
+
'fix this', 'this is wrong', 'this is incorrect', 'mistake', 'mistaken',
|
|
52
|
+
'broken', 'not working', "doesn't work", "doesn't seem right",
|
|
53
|
+
'pitfall', 'gotcha', 'issue here', 'problem here',
|
|
54
|
+
'actually no', 'actually wrong', 'you misunderstood', 'misunderstood',
|
|
55
|
+
'not what i meant', 'not what i wanted', 'redo this', 'redo that',
|
|
56
|
+
];
|
|
57
|
+
// Edit intent keywords — bilingual, for Apply phase scope check
|
|
58
|
+
const EDIT_INTENT_KEYWORDS = [
|
|
59
|
+
// 中文
|
|
60
|
+
'修改', '改', '新增', '添加', '增加', '删除', '移除', '重构',
|
|
61
|
+
'优化', '更新', '替换', '重写', '实现', '完成', '做一下',
|
|
62
|
+
// English
|
|
63
|
+
'add', 'change', 'edit', 'update', 'refactor', 'remove', 'delete',
|
|
64
|
+
'replace', 'rewrite', 'implement', 'fix', 'create', 'write',
|
|
65
|
+
'modify', 'revise', 'adjust',
|
|
43
66
|
];
|
|
44
67
|
export async function deviationCheckHook(options) {
|
|
45
68
|
const startTime = Date.now();
|
|
@@ -65,14 +88,16 @@ export async function deviationCheckHook(options) {
|
|
|
65
88
|
const trigger = options.trigger || 'pre-tool';
|
|
66
89
|
const output = await runDeviationCheck(filePath, trigger, promptText, options);
|
|
67
90
|
const durationMs = Date.now() - startTime;
|
|
68
|
-
|
|
69
|
-
await recordHookTrigger('deviation-check',
|
|
91
|
+
const hookEvent = trigger === 'post-prompt' ? 'UserPromptSubmit' : 'PreToolUse';
|
|
92
|
+
await recordHookTrigger('deviation-check', hookEvent, output.continue, {
|
|
70
93
|
toolName,
|
|
71
94
|
filePath,
|
|
72
95
|
message: output.systemMessage,
|
|
73
96
|
durationMs,
|
|
74
97
|
});
|
|
75
|
-
|
|
98
|
+
// post-prompt always outputs JSON so Claude Code can read additionalContext
|
|
99
|
+
const useJson = options.json || trigger === 'post-prompt';
|
|
100
|
+
if (useJson) {
|
|
76
101
|
console.log(JSON.stringify(output));
|
|
77
102
|
}
|
|
78
103
|
else {
|
|
@@ -98,12 +123,15 @@ async function runDeviationCheck(filePath, trigger, promptText, _options) {
|
|
|
98
123
|
const phase = process.env.ZHUANSPEC_PHASE || 'idle';
|
|
99
124
|
const changeId = process.env.ZHUANSPEC_CHANGE_ID || '';
|
|
100
125
|
const currentTask = process.env.ZHUANSPEC_CURRENT_TASK || '';
|
|
101
|
-
// No change bound or idle phase → allow
|
|
126
|
+
// No change bound or idle phase → allow, post-prompt silences itself to avoid noise
|
|
102
127
|
if (!changeId || phase === 'idle') {
|
|
103
|
-
|
|
104
|
-
continue: true
|
|
105
|
-
|
|
106
|
-
|
|
128
|
+
if (trigger === 'post-prompt') {
|
|
129
|
+
return { continue: true };
|
|
130
|
+
}
|
|
131
|
+
const msg = !changeId
|
|
132
|
+
? '✓ 当前无绑定变更,正常工作流'
|
|
133
|
+
: `✓ 变更 [${changeId}] 处于 idle 阶段,正常工作流`;
|
|
134
|
+
return { continue: true, systemMessage: msg };
|
|
107
135
|
}
|
|
108
136
|
const cwd = process.cwd();
|
|
109
137
|
const zhuanspecDir = path.join(cwd, 'zhuanspec');
|
|
@@ -121,6 +149,13 @@ async function runDeviationCheck(filePath, trigger, promptText, _options) {
|
|
|
121
149
|
return !estimatedFiles.map(normalizePath).some((scope) => normalized.includes(scope) || scope.includes(normalized));
|
|
122
150
|
});
|
|
123
151
|
if (outOfScope.length > 0) {
|
|
152
|
+
const resumeDeviationId = `dev-resume-${Date.now()}`;
|
|
153
|
+
// Write deviation ID to temp file for record-progress to read (cross-process)
|
|
154
|
+
const metricsDir = path.join(changeDir, 'metrics');
|
|
155
|
+
if (!fs.existsSync(metricsDir)) {
|
|
156
|
+
fs.mkdirSync(metricsDir, { recursive: true });
|
|
157
|
+
}
|
|
158
|
+
fs.writeFileSync(path.join(metricsDir, '.last-deviation-id'), resumeDeviationId, 'utf-8');
|
|
124
159
|
return {
|
|
125
160
|
continue: false,
|
|
126
161
|
stopReason: 'Resume deviation detected',
|
|
@@ -136,46 +171,51 @@ async function runDeviationCheck(filePath, trigger, promptText, _options) {
|
|
|
136
171
|
systemMessage: '✓ Resume deviation check passed',
|
|
137
172
|
};
|
|
138
173
|
}
|
|
139
|
-
// Post-prompt check:
|
|
174
|
+
// Post-prompt check: inject proposal context for 2-dimension consistency check in Apply phase.
|
|
140
175
|
if (phase === 'apply' && trigger === 'post-prompt') {
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// User correction detected - trigger deviation handling
|
|
145
|
-
return {
|
|
146
|
-
continue: false,
|
|
147
|
-
stopReason: 'User correction detected in Apply phase',
|
|
148
|
-
systemMessage: `⚠️ DEVIATION TRIGGER: 检测到用户纠正关键词,当前阶段为 ${phase}
|
|
149
|
-
请按偏离修正流程处理:
|
|
150
|
-
1. 先修改 spec 文件
|
|
151
|
-
2. 用户确认后再修改代码
|
|
152
|
-
3. 用户确认无误后触发知识沉淀`,
|
|
153
|
-
hookSpecificOutput: {
|
|
154
|
-
additionalContext: `User input: ${promptText.slice(0, 200)}`,
|
|
155
|
-
options: [
|
|
156
|
-
'修改 Spec 文件',
|
|
157
|
-
'直接修改代码(跳过 Spec)',
|
|
158
|
-
'取消修改',
|
|
159
|
-
],
|
|
160
|
-
},
|
|
161
|
-
};
|
|
176
|
+
// Skip if no proposal exists
|
|
177
|
+
if (!await FileSystemUtils.fileExists(proposalPath)) {
|
|
178
|
+
return { continue: true };
|
|
162
179
|
}
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
180
|
+
const lowerPrompt = promptText.toLowerCase();
|
|
181
|
+
// Detect correction/pitfall keywords
|
|
182
|
+
const correctionFound = USER_CORRECTION_KEYWORDS.some(kw => lowerPrompt.includes(kw.toLowerCase()));
|
|
183
|
+
// Detect edit intent
|
|
184
|
+
const hasEditIntent = EDIT_INTENT_KEYWORDS.some(kw => lowerPrompt.includes(kw.toLowerCase()));
|
|
185
|
+
// No action needed if purely read-only and no correction signal
|
|
186
|
+
if (!hasEditIntent && !correctionFound) {
|
|
187
|
+
return { continue: true };
|
|
188
|
+
}
|
|
189
|
+
// Load proposal for context injection
|
|
190
|
+
const proposalContent = await FileSystemUtils.readFile(proposalPath);
|
|
191
|
+
const proposalSummary = extractProposalSummary(proposalContent, 1500);
|
|
192
|
+
const reverseSyncFiles = [
|
|
193
|
+
'`proposal.md`(更新变更原因/影响范围)',
|
|
194
|
+
'`tasks.md`(新增或调整对应任务)',
|
|
195
|
+
'`specs/*/spec.md`(更新受影响的 spec 需求条目)',
|
|
196
|
+
'`design.md`(如存在,更新技术设计决策)',
|
|
197
|
+
].join('、');
|
|
198
|
+
let additionalContext = '## ⚠️ ZhuanSpec Apply 阶段一致性检测\n\n';
|
|
199
|
+
additionalContext += `**当前变更提案摘要:**\n${proposalSummary}\n\n`;
|
|
200
|
+
additionalContext += `**处理以下用户请求前,请先做两维度一致性检测:**\n\n`;
|
|
201
|
+
additionalContext += `- **维度1(范围)**:用户请求涉及的文件/功能是否在提案中明确提及?\n`;
|
|
202
|
+
additionalContext += ` - 在提案范围内 → 正常实施\n`;
|
|
203
|
+
additionalContext += ` - **超出提案范围** → 先告知用户,执行 Reverse Sync:依次更新 ${reverseSyncFiles},经用户确认后再实施代码\n\n`;
|
|
204
|
+
additionalContext += `- **维度2(逻辑一致性)**:用户请求的逻辑调整是否与提案设计意图一致?\n`;
|
|
205
|
+
additionalContext += ` - 一致 → 正常实施\n`;
|
|
206
|
+
additionalContext += ` - **逻辑有偏差** → 说明偏差点,同样执行 Reverse Sync 更新所有相关提案文件(${reverseSyncFiles}),经用户确认后再实施\n\n`;
|
|
207
|
+
if (correctionFound) {
|
|
208
|
+
additionalContext += `**⚠️ 检测到纠错关键词** — 用户可能遇到了问题/踩坑\n`;
|
|
209
|
+
additionalContext += `修复完成后,请主动询问用户:\n`;
|
|
210
|
+
additionalContext += `> "这个问题是否值得记录为踩坑经验,防止下次再犯?(回复是/否)"\n`;
|
|
211
|
+
additionalContext += `若用户确认,将问题描述和解决方案整理后保存到 \`zhuanspec/knowledge/troubleshooting/\` 目录下的新 .md 文件中。\n\n`;
|
|
175
212
|
}
|
|
176
213
|
return {
|
|
177
214
|
continue: true,
|
|
178
|
-
systemMessage:
|
|
215
|
+
systemMessage: `✓ ZhuanSpec: Apply 阶段一致性检测已注入上下文${correctionFound ? '(含踩坑沉淀提示)' : ''}`,
|
|
216
|
+
hookSpecificOutput: {
|
|
217
|
+
additionalContext,
|
|
218
|
+
},
|
|
179
219
|
};
|
|
180
220
|
}
|
|
181
221
|
// Propose phase → block all code modifications (Iron Law 1: No Spec No Code)
|
|
@@ -236,6 +276,34 @@ async function runDeviationCheck(filePath, trigger, promptText, _options) {
|
|
|
236
276
|
}
|
|
237
277
|
// Apply phase → check deviation
|
|
238
278
|
if (phase === 'apply') {
|
|
279
|
+
// === Task completion gate for Apply→Review transition ===
|
|
280
|
+
if (trigger === 'post-prompt') {
|
|
281
|
+
const lowerPrompt = promptText.toLowerCase();
|
|
282
|
+
const reviewTransitionKeywords = [
|
|
283
|
+
'review', '审查', '进入review', 'review阶段', '代码审查',
|
|
284
|
+
'zhuanspec review', '/review', '开始review',
|
|
285
|
+
];
|
|
286
|
+
const isReviewTransition = reviewTransitionKeywords.some(kw => lowerPrompt.includes(kw));
|
|
287
|
+
if (isReviewTransition && await FileSystemUtils.fileExists(tasksPath)) {
|
|
288
|
+
try {
|
|
289
|
+
const tasksContent = await FileSystemUtils.readFile(tasksPath);
|
|
290
|
+
const uncompleted = tasksContent.match(/^- \[ \] .+$/gm) || [];
|
|
291
|
+
if (uncompleted.length > 0) {
|
|
292
|
+
return {
|
|
293
|
+
continue: false,
|
|
294
|
+
stopReason: 'Task completion gate: uncompleted tasks block Review transition',
|
|
295
|
+
systemMessage: `⚠️ BLOCKED: Cannot transition to Review — ${uncompleted.length} tasks uncompleted in tasks.md.\n\nUncompleted tasks:\n${uncompleted.map(t => ` ${t.trim()}`).join('\n')}\n\nPlease complete all tasks before entering Review, or use \`zhuanspec review <id> --force\` to override.`,
|
|
296
|
+
hookSpecificOutput: {
|
|
297
|
+
additionalContext: `Task completion gate blocked Apply→Review transition. ${uncompleted.length} uncompleted tasks found.`,
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
// If tasks.md read fails, allow transition
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
239
307
|
let estimatedFiles = [];
|
|
240
308
|
let currentTaskFiles = [];
|
|
241
309
|
// Parse proposal for estimated changes
|
|
@@ -261,6 +329,12 @@ async function runDeviationCheck(filePath, trigger, promptText, _options) {
|
|
|
261
329
|
}
|
|
262
330
|
// File is out of scope → deviation detected
|
|
263
331
|
const deviationId = `dev-${Date.now()}`;
|
|
332
|
+
// Write deviation ID to temp file for record-progress to read (cross-process)
|
|
333
|
+
const deviationMetricsDir = path.join(changeDir, 'metrics');
|
|
334
|
+
if (!fs.existsSync(deviationMetricsDir)) {
|
|
335
|
+
fs.mkdirSync(deviationMetricsDir, { recursive: true });
|
|
336
|
+
}
|
|
337
|
+
fs.writeFileSync(path.join(deviationMetricsDir, '.last-deviation-id'), deviationId, 'utf-8');
|
|
264
338
|
return {
|
|
265
339
|
continue: false,
|
|
266
340
|
stopReason: 'Deviation detected: File not in approved proposal scope',
|
|
@@ -287,6 +361,10 @@ ZhuanSpec Iron Law 3: Reverse Sync - When deviation is found, update proposal fi
|
|
|
287
361
|
},
|
|
288
362
|
};
|
|
289
363
|
}
|
|
364
|
+
// post-prompt in non-apply phases (propose/techDesign/review/...) → silently allow
|
|
365
|
+
if (trigger === 'post-prompt') {
|
|
366
|
+
return { continue: true };
|
|
367
|
+
}
|
|
290
368
|
// Default: allow
|
|
291
369
|
return {
|
|
292
370
|
continue: true,
|
|
@@ -295,25 +373,36 @@ ZhuanSpec Iron Law 3: Reverse Sync - When deviation is found, update proposal fi
|
|
|
295
373
|
async function parseEstimatedChanges(proposalPath) {
|
|
296
374
|
try {
|
|
297
375
|
const content = await FileSystemUtils.readFile(proposalPath);
|
|
376
|
+
const seen = new Set();
|
|
298
377
|
const files = [];
|
|
299
|
-
|
|
378
|
+
const addFile = (raw) => {
|
|
379
|
+
const cleaned = raw.replace(/[`'"*[\]()]/g, '').trim();
|
|
380
|
+
if (cleaned.length > 2 && !seen.has(cleaned)) {
|
|
381
|
+
seen.add(cleaned);
|
|
382
|
+
files.push(cleaned);
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
// Scan entire proposal for file paths with known extensions
|
|
386
|
+
const filePattern = /(?:^|[\s`'"([\]|])([a-zA-Z0-9_\-./]+\.(?:ts|tsx|js|jsx|java|py|go|rs|md|json|yaml|yml|sql|kt|scala|c|cpp|cs|sh|xml|html|css|scss|vue|svelte))(?:[\s`'")\]|,]|$)/gm;
|
|
387
|
+
let m;
|
|
388
|
+
while ((m = filePattern.exec(content)) !== null) {
|
|
389
|
+
addFile(m[1]);
|
|
390
|
+
}
|
|
391
|
+
// Also collect bullet-point entries under 影响范围/Affected/Impact sections
|
|
300
392
|
const lines = content.split('\n');
|
|
301
393
|
let inAffectedSection = false;
|
|
302
394
|
for (const line of lines) {
|
|
303
|
-
if (
|
|
395
|
+
if (/影响范围|Affected code|Affected files|Impact/i.test(line) && line.startsWith('#')) {
|
|
304
396
|
inAffectedSection = true;
|
|
305
397
|
continue;
|
|
306
398
|
}
|
|
307
|
-
if (inAffectedSection && line.startsWith('
|
|
308
|
-
// End of section
|
|
399
|
+
if (inAffectedSection && line.startsWith('#')) {
|
|
309
400
|
inAffectedSection = false;
|
|
310
401
|
}
|
|
311
402
|
if (inAffectedSection && line.trim().startsWith('-')) {
|
|
312
|
-
// Extract file path from bullet point
|
|
313
403
|
const match = line.match(/-\s*(.+)/);
|
|
314
|
-
if (match)
|
|
315
|
-
|
|
316
|
-
}
|
|
404
|
+
if (match)
|
|
405
|
+
addFile(match[1].trim());
|
|
317
406
|
}
|
|
318
407
|
}
|
|
319
408
|
return files;
|
|
@@ -322,6 +411,21 @@ async function parseEstimatedChanges(proposalPath) {
|
|
|
322
411
|
return [];
|
|
323
412
|
}
|
|
324
413
|
}
|
|
414
|
+
function extractProposalSummary(content, maxLength) {
|
|
415
|
+
const patterns = [
|
|
416
|
+
/^## (?:Summary|摘要|Overview|概述)[\s\S]*?(?=\n## |\n# |$)/im,
|
|
417
|
+
/^## (?:What Changes|变更内容|Changes|修改内容)[\s\S]*?(?=\n## |\n# |$)/im,
|
|
418
|
+
/^## (?:Impact|影响范围|Affected)[\s\S]*?(?=\n## |\n# |$)/im,
|
|
419
|
+
];
|
|
420
|
+
const sections = [];
|
|
421
|
+
for (const pattern of patterns) {
|
|
422
|
+
const match = content.match(pattern);
|
|
423
|
+
if (match)
|
|
424
|
+
sections.push(match[0].trim());
|
|
425
|
+
}
|
|
426
|
+
const combined = sections.length > 0 ? sections.join('\n\n') : content;
|
|
427
|
+
return combined.length > maxLength ? combined.substring(0, maxLength) + '...' : combined;
|
|
428
|
+
}
|
|
325
429
|
async function parseCurrentTaskFiles(tasksPath, currentTask) {
|
|
326
430
|
if (!currentTask) {
|
|
327
431
|
return [];
|
package/dist/core/hooks/init.js
CHANGED
|
@@ -8,9 +8,50 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import fs from 'fs';
|
|
11
|
+
import { exec } from 'child_process';
|
|
12
|
+
import { promisify } from 'util';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
11
14
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
12
15
|
import { initializeProgress, recordHookTrigger, getBeijingTime } from './record-progress.js';
|
|
13
16
|
import { getCurrentPhase, getPhaseFromProgress } from '../../utils/phase-utils.js';
|
|
17
|
+
const execAsync = promisify(exec);
|
|
18
|
+
async function checkVersionUpdate() {
|
|
19
|
+
try {
|
|
20
|
+
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'package.json');
|
|
21
|
+
const pkgContent = JSON.parse(await fs.promises.readFile(pkgPath, 'utf-8'));
|
|
22
|
+
const current = pkgContent.version;
|
|
23
|
+
const { stdout } = await execAsync('npm view @zhuan-ai/zhuanspec version', { timeout: 5000 });
|
|
24
|
+
const latest = stdout.trim();
|
|
25
|
+
if (!latest || !current)
|
|
26
|
+
return null;
|
|
27
|
+
const parse = (v) => v.replace(/^v/, '').split('-')[0].split('.').map(Number);
|
|
28
|
+
const [cM, cm, cp] = parse(current);
|
|
29
|
+
const [lM, lm, lp] = parse(latest);
|
|
30
|
+
const isOutdated = lM !== cM ? lM > cM : lm !== cm ? lm > cm : lp > cp;
|
|
31
|
+
if (!isOutdated)
|
|
32
|
+
return `✓ ZhuanSpec v${current} 已是最新版本,开始 happy coding 吧~`;
|
|
33
|
+
return `⚠️ ZhuanSpec 版本过旧!\n 当前版本: v${current} → 最新版本: v${latest}\n 请执行: 1) npm i -g @zhuan-ai/zhuanspec 2) zhuanspec init(同步最新模板到本项目)`;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function updateClaudeHudCustomLine(versionBanner) {
|
|
40
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(process.env.HOME || '~', '.claude');
|
|
41
|
+
const hudConfigPath = path.join(configDir, 'plugins', 'claude-hud', 'config.json');
|
|
42
|
+
try {
|
|
43
|
+
const raw = await fs.promises.readFile(hudConfigPath, 'utf-8');
|
|
44
|
+
const config = JSON.parse(raw);
|
|
45
|
+
if (!config.display)
|
|
46
|
+
config.display = {};
|
|
47
|
+
config.display.customLine = versionBanner;
|
|
48
|
+
await fs.promises.writeFile(hudConfigPath, JSON.stringify(config, null, 2));
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
14
55
|
export async function initHook(options) {
|
|
15
56
|
const startTime = Date.now();
|
|
16
57
|
const output = await runInitHook(options);
|
|
@@ -31,6 +72,9 @@ export async function initHook(options) {
|
|
|
31
72
|
catch {
|
|
32
73
|
// Ignore write errors
|
|
33
74
|
}
|
|
75
|
+
if (output.versionBanner) {
|
|
76
|
+
await updateClaudeHudCustomLine(output.versionBanner);
|
|
77
|
+
}
|
|
34
78
|
if (options.json) {
|
|
35
79
|
console.log(JSON.stringify(output));
|
|
36
80
|
}
|
|
@@ -226,9 +270,11 @@ async function runInitHook(_options) {
|
|
|
226
270
|
additionalContext += `Found ${rulesFiles.length} rule files in .claude/rules: ${rulesFiles.join(', ')}\n`;
|
|
227
271
|
additionalContext += `(These are loaded automatically by Claude Code system)\n\n`;
|
|
228
272
|
}
|
|
273
|
+
const versionBanner = await checkVersionUpdate();
|
|
229
274
|
return {
|
|
230
275
|
continue: true,
|
|
231
276
|
systemMessage: systemMessage || '✓ ZhuanSpec session initialized',
|
|
277
|
+
versionBanner: versionBanner || undefined,
|
|
232
278
|
hookSpecificOutput: {
|
|
233
279
|
additionalContext: additionalContext || undefined,
|
|
234
280
|
env: {
|
|
@@ -189,6 +189,7 @@ async function updateProgressJson(changeDir, event) {
|
|
|
189
189
|
reviewStats: { loopCount: 0, criticalFixes: 0, testFixes: 0, consistencyFixes: 0 },
|
|
190
190
|
phaseTransitions: [],
|
|
191
191
|
phaseDurations: [],
|
|
192
|
+
proposalChanges: [],
|
|
192
193
|
stats: { tokenUsageTotal: 0, contextLoad: 0, durationMs: { propose: 0, apply: 0, review: 0, archive: 0 } },
|
|
193
194
|
};
|
|
194
195
|
}
|
|
@@ -217,6 +218,7 @@ async function updateProgressJson(changeDir, event) {
|
|
|
217
218
|
reviewStats: { loopCount: 0, criticalFixes: 0, testFixes: 0, consistencyFixes: 0 },
|
|
218
219
|
phaseTransitions: [],
|
|
219
220
|
phaseDurations: [],
|
|
221
|
+
proposalChanges: [],
|
|
220
222
|
stats: { tokenUsageTotal: 0, contextLoad: 0, durationMs: { propose: 0, apply: 0, review: 0, archive: 0 } },
|
|
221
223
|
};
|
|
222
224
|
}
|