@zhuan-ai/zhuanspec 2.2.5 → 2.5.0
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/bin/zhuanspec-hook.js +3 -0
- package/dist/cli/hooks.d.ts +14 -0
- package/dist/cli/hooks.js +465 -0
- package/dist/cli/index.js +100 -0
- package/dist/commands/artifact-workflow.js +15 -34
- package/dist/commands/design.d.ts +42 -0
- package/dist/commands/design.js +337 -0
- package/dist/commands/progress.d.ts +38 -0
- package/dist/commands/progress.js +349 -0
- package/dist/commands/review.d.ts +32 -0
- package/dist/commands/review.js +472 -0
- package/dist/commands/validate.d.ts +14 -0
- package/dist/commands/validate.js +161 -10
- package/dist/core/archive.d.ts +1 -0
- package/dist/core/archive.js +45 -3
- package/dist/core/completions/command-registry.js +67 -0
- package/dist/core/configurators/slash/amazon-q.js +32 -2
- package/dist/core/configurators/slash/antigravity.js +8 -2
- package/dist/core/configurators/slash/auggie.js +16 -1
- package/dist/core/configurators/slash/base.js +1 -1
- package/dist/core/configurators/slash/claude.d.ts +4 -0
- package/dist/core/configurators/slash/claude.js +56 -1
- package/dist/core/configurators/slash/cline.js +8 -2
- package/dist/core/configurators/slash/codebuddy.js +22 -1
- package/dist/core/configurators/slash/codex.js +21 -0
- package/dist/core/configurators/slash/costrict.js +15 -0
- package/dist/core/configurators/slash/crush.js +22 -1
- package/dist/core/configurators/slash/cursor.js +22 -1
- package/dist/core/configurators/slash/factory.js +16 -1
- package/dist/core/configurators/slash/gemini.js +8 -2
- package/dist/core/configurators/slash/github-copilot.js +19 -1
- package/dist/core/configurators/slash/iflow.js +22 -1
- package/dist/core/configurators/slash/kilocode.js +4 -1
- package/dist/core/configurators/slash/opencode.js +27 -0
- package/dist/core/configurators/slash/qoder.d.ts +4 -0
- package/dist/core/configurators/slash/qoder.js +59 -1
- package/dist/core/configurators/slash/qwen.js +8 -2
- package/dist/core/configurators/slash/roocode.js +8 -2
- package/dist/core/configurators/slash/windsurf.js +8 -2
- package/dist/core/dashboard/metrics.d.ts +33 -0
- package/dist/core/dashboard/metrics.js +114 -0
- package/dist/core/hooks/collect-knowledge.d.ts +16 -0
- package/dist/core/hooks/collect-knowledge.js +203 -0
- package/dist/core/hooks/context-load-hook.d.ts +25 -0
- package/dist/core/hooks/context-load-hook.js +159 -0
- package/dist/core/hooks/deviation-check.d.ts +27 -0
- package/dist/core/hooks/deviation-check.js +403 -0
- package/dist/core/hooks/deviation-handler.d.ts +43 -0
- package/dist/core/hooks/deviation-handler.js +98 -0
- package/dist/core/hooks/init.d.ts +14 -0
- package/dist/core/hooks/init.js +244 -0
- package/dist/core/hooks/notify-milestone.d.ts +14 -0
- package/dist/core/hooks/notify-milestone.js +170 -0
- package/dist/core/hooks/post-apply.d.ts +29 -0
- package/dist/core/hooks/post-apply.js +173 -0
- package/dist/core/hooks/post-archive.d.ts +7 -0
- package/dist/core/hooks/post-archive.js +208 -0
- package/dist/core/hooks/pre-apply.d.ts +34 -0
- package/dist/core/hooks/pre-apply.js +139 -0
- package/dist/core/hooks/pre-archive.d.ts +7 -0
- package/dist/core/hooks/pre-archive.js +50 -0
- package/dist/core/hooks/record-progress.d.ts +49 -0
- package/dist/core/hooks/record-progress.js +609 -0
- package/dist/core/hooks/review-hooks.d.ts +89 -0
- package/dist/core/hooks/review-hooks.js +345 -0
- package/dist/core/hooks/review-orchestrator.d.ts +40 -0
- package/dist/core/hooks/review-orchestrator.js +146 -0
- package/dist/core/hooks/summarize.d.ts +15 -0
- package/dist/core/hooks/summarize.js +282 -0
- package/dist/core/hooks/user-input-hook.d.ts +25 -0
- package/dist/core/hooks/user-input-hook.js +179 -0
- package/dist/core/init.d.ts +8 -0
- package/dist/core/init.js +251 -23
- package/dist/core/parsers/requirement-blocks.js +13 -10
- package/dist/core/templates/agents-template.d.ts +1 -1
- package/dist/core/templates/agents-template.js +513 -244
- package/dist/core/templates/index.d.ts +1 -0
- package/dist/core/templates/index.js +1 -0
- package/dist/core/templates/skill-templates.js +46 -156
- package/dist/core/templates/slash-command-templates.d.ts +1 -1
- package/dist/core/templates/slash-command-templates.js +352 -21
- package/dist/core/templates/tasks-template.d.ts +7 -0
- package/dist/core/templates/tasks-template.js +121 -25
- package/dist/core/templates/tdd-tasks-template.d.ts +3 -0
- package/dist/core/templates/tdd-tasks-template.js +91 -38
- package/dist/core/templates/test-cases-template.d.ts +41 -0
- package/dist/core/templates/test-cases-template.js +128 -0
- package/dist/core/validation/strict-rules.d.ts +44 -5
- package/dist/core/validation/strict-rules.js +302 -8
- package/dist/core/validation/validator.js +52 -2
- package/dist/core/view.d.ts +1 -0
- package/dist/core/view.js +60 -2
- package/dist/mcp/index.d.ts +28 -0
- package/dist/mcp/index.js +31 -0
- package/dist/utils/file-system.d.ts +1 -0
- package/dist/utils/file-system.js +11 -0
- package/dist/utils/item-discovery.js +24 -2
- package/dist/utils/phase-utils.d.ts +36 -0
- package/dist/utils/phase-utils.js +117 -0
- package/package.json +3 -2
- package/schemas/spec-driven/schema.yaml +45 -31
- package/schemas/spec-driven/templates/spec.md +142 -5
- package/schemas/spec-driven/templates/tasks.md +73 -9
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostToolUse Hook - Record Progress
|
|
3
|
+
*
|
|
4
|
+
* This hook runs after Write/Edit operations to:
|
|
5
|
+
* 1. Record the modification to progress.json
|
|
6
|
+
* 2. Update tasks.md status if applicable
|
|
7
|
+
* 3. Track tool calls for metrics
|
|
8
|
+
*/
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { promises as fsPromises } from 'fs';
|
|
11
|
+
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
12
|
+
import { PHASE_ORDER } from '../../utils/phase-utils.js';
|
|
13
|
+
const fs = fsPromises;
|
|
14
|
+
/**
|
|
15
|
+
* Atomic write JSON file: write to temp file first, then rename
|
|
16
|
+
* This prevents half-written files during crashes or concurrent writes
|
|
17
|
+
* @param filePath - Target file path
|
|
18
|
+
* @param data - Data to write (will be JSON stringified)
|
|
19
|
+
*/
|
|
20
|
+
async function atomicWriteJson(filePath, data) {
|
|
21
|
+
const tmpPath = `${filePath}.tmp`;
|
|
22
|
+
const content = JSON.stringify(data, null, 2);
|
|
23
|
+
try {
|
|
24
|
+
// Write to temp file first
|
|
25
|
+
await FileSystemUtils.writeFile(tmpPath, content);
|
|
26
|
+
// Rename to final path (atomic on most filesystems)
|
|
27
|
+
await fs.rename(tmpPath, filePath);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// On failure, try to clean up temp file
|
|
31
|
+
try {
|
|
32
|
+
await fs.unlink(tmpPath);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Ignore cleanup errors
|
|
36
|
+
}
|
|
37
|
+
// Do not throw - preserve original file and continue silently
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Recover damaged progress.json for write operations
|
|
42
|
+
* Uses same three-tier strategy as read side
|
|
43
|
+
* @param filePath - Path to progress.json
|
|
44
|
+
* @returns Recovered ProgressData or null
|
|
45
|
+
*/
|
|
46
|
+
async function recoverProgressJsonForWrite(filePath) {
|
|
47
|
+
try {
|
|
48
|
+
const content = await FileSystemUtils.readFile(filePath);
|
|
49
|
+
// Layer 1: Standard JSON parse
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(content);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Continue to Layer 2
|
|
55
|
+
}
|
|
56
|
+
// Layer 2: Extract first top-level JSON object
|
|
57
|
+
const objectStart = content.indexOf('{');
|
|
58
|
+
if (objectStart === -1)
|
|
59
|
+
return null;
|
|
60
|
+
let depth = 0;
|
|
61
|
+
let objectEnd = -1;
|
|
62
|
+
for (let i = objectStart; i < content.length; i++) {
|
|
63
|
+
if (content[i] === '{')
|
|
64
|
+
depth++;
|
|
65
|
+
else if (content[i] === '}') {
|
|
66
|
+
depth--;
|
|
67
|
+
if (depth === 0) {
|
|
68
|
+
objectEnd = i + 1;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (objectEnd > objectStart) {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(content.slice(objectStart, objectEnd));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Continue to Layer 3
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Layer 3: Minimal field extraction
|
|
82
|
+
const phaseMatch = content.match(/"phase"\s*:\s*"([^"]+)"/);
|
|
83
|
+
const changeIdMatch = content.match(/"changeId"\s*:\s*"([^"]+)"/);
|
|
84
|
+
const sessionIdMatch = content.match(/"sessionId"\s*:\s*"([^"]+)"/);
|
|
85
|
+
if (phaseMatch || changeIdMatch) {
|
|
86
|
+
return {
|
|
87
|
+
changeId: changeIdMatch?.[1] || '',
|
|
88
|
+
sessionId: sessionIdMatch?.[1] || '',
|
|
89
|
+
startedAt: '',
|
|
90
|
+
lastUpdatedAt: '',
|
|
91
|
+
phase: phaseMatch?.[1] || 'unknown',
|
|
92
|
+
currentNode: phaseMatch?.[1] || 'unknown',
|
|
93
|
+
currentTask: '',
|
|
94
|
+
completedTasks: [],
|
|
95
|
+
totalTasks: 0,
|
|
96
|
+
toolCalls: [],
|
|
97
|
+
skillCalls: [],
|
|
98
|
+
hookTriggers: [],
|
|
99
|
+
clarifications: [],
|
|
100
|
+
filesModified: [],
|
|
101
|
+
linesAdded: 0,
|
|
102
|
+
linesRemoved: 0,
|
|
103
|
+
deviationCount: 0,
|
|
104
|
+
deviationRecords: [],
|
|
105
|
+
reviewStats: { loopCount: 0, criticalFixes: 0, testFixes: 0, consistencyFixes: 0 },
|
|
106
|
+
phaseTransitions: [],
|
|
107
|
+
stats: { tokenUsageTotal: 0, contextLoad: 0, durationMs: { propose: 0, apply: 0, review: 0, archive: 0 } },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// File read error - return null
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Get Beijing time (UTC+8) as ISO string format
|
|
118
|
+
* Returns format: "2026-04-19T18:35:22.934+08:00"
|
|
119
|
+
*/
|
|
120
|
+
export function getBeijingTime() {
|
|
121
|
+
const now = new Date();
|
|
122
|
+
// Get UTC time and add 8 hours for Beijing
|
|
123
|
+
const utcMs = now.getTime() + now.getTimezoneOffset() * 60 * 1000;
|
|
124
|
+
const beijingMs = utcMs + 8 * 60 * 60 * 1000;
|
|
125
|
+
const beijingTime = new Date(beijingMs);
|
|
126
|
+
// Format as ISO string with +08:00 timezone
|
|
127
|
+
const year = beijingTime.getFullYear();
|
|
128
|
+
const month = String(beijingTime.getMonth() + 1).padStart(2, '0');
|
|
129
|
+
const day = String(beijingTime.getDate()).padStart(2, '0');
|
|
130
|
+
const hours = String(beijingTime.getHours()).padStart(2, '0');
|
|
131
|
+
const minutes = String(beijingTime.getMinutes()).padStart(2, '0');
|
|
132
|
+
const seconds = String(beijingTime.getSeconds()).padStart(2, '0');
|
|
133
|
+
const ms = String(beijingTime.getMilliseconds()).padStart(3, '0');
|
|
134
|
+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${ms}+08:00`;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Get Beijing time date string for session ID and filenames
|
|
138
|
+
* Returns format: "2026-04-19T18-35-22"
|
|
139
|
+
*/
|
|
140
|
+
export function getBeijingTimeForFilename() {
|
|
141
|
+
const now = new Date();
|
|
142
|
+
// Get UTC time and add 8 hours for Beijing
|
|
143
|
+
const utcMs = now.getTime() + now.getTimezoneOffset() * 60 * 1000;
|
|
144
|
+
const beijingMs = utcMs + 8 * 60 * 60 * 1000;
|
|
145
|
+
const beijingTime = new Date(beijingMs);
|
|
146
|
+
const year = beijingTime.getFullYear();
|
|
147
|
+
const month = String(beijingTime.getMonth() + 1).padStart(2, '0');
|
|
148
|
+
const day = String(beijingTime.getDate()).padStart(2, '0');
|
|
149
|
+
const hours = String(beijingTime.getHours()).padStart(2, '0');
|
|
150
|
+
const minutes = String(beijingTime.getMinutes()).padStart(2, '0');
|
|
151
|
+
const seconds = String(beijingTime.getSeconds()).padStart(2, '0');
|
|
152
|
+
return `${year}-${month}-${day}T${hours}-${minutes}-${seconds}`;
|
|
153
|
+
}
|
|
154
|
+
export async function recordProgressHook(options) {
|
|
155
|
+
// Read stdin for Claude Code hook input
|
|
156
|
+
let stdinData = {};
|
|
157
|
+
try {
|
|
158
|
+
const stdinContent = await readStdin();
|
|
159
|
+
if (stdinContent) {
|
|
160
|
+
stdinData = JSON.parse(stdinContent);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Ignore parse errors
|
|
165
|
+
}
|
|
166
|
+
// Get file path from stdin or options
|
|
167
|
+
const filePath = stdinData.tool_input?.file_path
|
|
168
|
+
|| options.file
|
|
169
|
+
|| '';
|
|
170
|
+
const toolName = stdinData.tool_name || options.tool || 'Write';
|
|
171
|
+
const success = stdinData.tool_result?.success !== false
|
|
172
|
+
&& options.success !== 'false';
|
|
173
|
+
const output = await runRecordProgress(filePath, toolName, success, stdinData);
|
|
174
|
+
if (options.json) {
|
|
175
|
+
console.log(JSON.stringify(output));
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
if (output.systemMessage) {
|
|
179
|
+
console.log(output.systemMessage);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function readStdin() {
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
let data = '';
|
|
186
|
+
process.stdin.setEncoding('utf8');
|
|
187
|
+
process.stdin.on('data', chunk => data += chunk);
|
|
188
|
+
process.stdin.on('end', () => resolve(data));
|
|
189
|
+
process.stdin.on('error', () => resolve(null));
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
// Extract MCP server name from tool name (e.g., mcp__aicodegather__beforeEditFile)
|
|
193
|
+
function extractMcpServer(toolName) {
|
|
194
|
+
if (!toolName.startsWith('mcp__'))
|
|
195
|
+
return undefined;
|
|
196
|
+
const parts = toolName.split('__');
|
|
197
|
+
return parts.length >= 2 ? parts[1] : undefined;
|
|
198
|
+
}
|
|
199
|
+
// Extract MCP tool name from tool name
|
|
200
|
+
function extractMcpTool(toolName) {
|
|
201
|
+
if (!toolName.startsWith('mcp__'))
|
|
202
|
+
return undefined;
|
|
203
|
+
const parts = toolName.split('__');
|
|
204
|
+
return parts.length >= 3 ? parts[2] : undefined;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Detect active change from progress.json files
|
|
208
|
+
* Scans changes directory metrics progress.json to find non-idle phase
|
|
209
|
+
* Returns null if no active change found
|
|
210
|
+
*/
|
|
211
|
+
async function detectActiveChange(cwd) {
|
|
212
|
+
const changesDir = path.join(cwd, 'zhuanspec', 'changes');
|
|
213
|
+
if (!await FileSystemUtils.directoryExists(changesDir)) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
const entries = await fs.readdir(changesDir, { withFileTypes: true });
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'archive')
|
|
219
|
+
continue;
|
|
220
|
+
const progressPath = path.join(changesDir, entry.name, 'metrics', 'progress.json');
|
|
221
|
+
if (await FileSystemUtils.fileExists(progressPath)) {
|
|
222
|
+
try {
|
|
223
|
+
const progress = JSON.parse(await FileSystemUtils.readFile(progressPath));
|
|
224
|
+
if (progress.phase && progress.phase !== 'idle' && PHASE_ORDER.includes(progress.phase)) {
|
|
225
|
+
return { changeId: entry.name, phase: progress.phase };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// Ignore parse errors
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
async function runRecordProgress(filePath, toolName, success, stdinData) {
|
|
236
|
+
const cwd = process.cwd();
|
|
237
|
+
// Priority: progress.json > environment variables
|
|
238
|
+
const activeChange = await detectActiveChange(cwd);
|
|
239
|
+
const phase = activeChange?.phase || process.env.ZHUANSPEC_PHASE || 'idle';
|
|
240
|
+
const changeId = activeChange?.changeId || process.env.ZHUANSPEC_CHANGE_ID || '';
|
|
241
|
+
const currentTask = process.env.ZHUANSPEC_CURRENT_TASK || '';
|
|
242
|
+
// No active change → nothing to record
|
|
243
|
+
if (!changeId || phase === 'idle') {
|
|
244
|
+
return {
|
|
245
|
+
continue: true,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const zhuanspecDir = path.join(cwd, 'zhuanspec');
|
|
249
|
+
const changeDir = path.join(zhuanspecDir, 'changes', changeId);
|
|
250
|
+
const metricsDir = path.join(changeDir, 'metrics');
|
|
251
|
+
// Ensure metrics directory exists
|
|
252
|
+
await FileSystemUtils.createDirectory(metricsDir);
|
|
253
|
+
const progressPath = path.join(metricsDir, 'progress.json');
|
|
254
|
+
const toolCallsPath = path.join(metricsDir, 'tool_calls.json');
|
|
255
|
+
// Load existing progress or create new (with recovery support)
|
|
256
|
+
let progress;
|
|
257
|
+
if (await FileSystemUtils.fileExists(progressPath)) {
|
|
258
|
+
const recovered = await recoverProgressJsonForWrite(progressPath);
|
|
259
|
+
if (recovered) {
|
|
260
|
+
progress = recovered;
|
|
261
|
+
progress.phase = progress.phase || phase;
|
|
262
|
+
progress.currentNode = progress.currentNode || phase;
|
|
263
|
+
progress.completedTasks = progress.completedTasks || [];
|
|
264
|
+
progress.toolCalls = progress.toolCalls || [];
|
|
265
|
+
progress.skillCalls = progress.skillCalls || [];
|
|
266
|
+
progress.hookTriggers = progress.hookTriggers || [];
|
|
267
|
+
progress.clarifications = progress.clarifications || [];
|
|
268
|
+
progress.filesModified = progress.filesModified || [];
|
|
269
|
+
progress.linesAdded = progress.linesAdded || 0;
|
|
270
|
+
progress.linesRemoved = progress.linesRemoved || 0;
|
|
271
|
+
progress.deviationCount = progress.deviationCount || 0;
|
|
272
|
+
progress.deviationRecords = progress.deviationRecords || [];
|
|
273
|
+
progress.reviewStats = progress.reviewStats || {
|
|
274
|
+
loopCount: 0,
|
|
275
|
+
criticalFixes: 0,
|
|
276
|
+
testFixes: 0,
|
|
277
|
+
consistencyFixes: 0,
|
|
278
|
+
};
|
|
279
|
+
progress.phaseTransitions = progress.phaseTransitions || [];
|
|
280
|
+
progress.stats = progress.stats || {
|
|
281
|
+
tokenUsageTotal: 0,
|
|
282
|
+
contextLoad: 0,
|
|
283
|
+
durationMs: { propose: 0, apply: 0, review: 0, archive: 0 },
|
|
284
|
+
};
|
|
285
|
+
progress.stats.durationMs = progress.stats.durationMs || {
|
|
286
|
+
propose: 0,
|
|
287
|
+
apply: 0,
|
|
288
|
+
review: 0,
|
|
289
|
+
archive: 0,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
progress = createNewProgress(changeId);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
progress = createNewProgress(changeId);
|
|
298
|
+
}
|
|
299
|
+
// Record tool call with extended tool support
|
|
300
|
+
const toolInput = stdinData.tool_input || {};
|
|
301
|
+
const callId = `call-${Date.now()}`;
|
|
302
|
+
const timestamp = getBeijingTime();
|
|
303
|
+
// Detect and record Skill calls separately
|
|
304
|
+
if (toolName === 'Skill') {
|
|
305
|
+
const skillCall = {
|
|
306
|
+
callId,
|
|
307
|
+
skill: typeof toolInput.skill === 'string' ? toolInput.skill : '',
|
|
308
|
+
args: typeof toolInput.args === 'string' ? toolInput.args : undefined,
|
|
309
|
+
triggeredAt: timestamp,
|
|
310
|
+
phase,
|
|
311
|
+
success,
|
|
312
|
+
};
|
|
313
|
+
progress.skillCalls.push(skillCall);
|
|
314
|
+
}
|
|
315
|
+
// Detect and record AskQuestion calls as clarifications
|
|
316
|
+
if (toolName === 'AskUserQuestion') {
|
|
317
|
+
const questionsData = toolInput.questions || [];
|
|
318
|
+
const answersData = stdinData.tool_result || {};
|
|
319
|
+
const clarification = {
|
|
320
|
+
clarificationId: callId,
|
|
321
|
+
questions: questionsData.map(q => ({
|
|
322
|
+
question: typeof q.question === 'string' ? q.question : '',
|
|
323
|
+
header: typeof q.header === 'string' ? q.header : undefined,
|
|
324
|
+
options: (q.options || []).map(opt => ({
|
|
325
|
+
label: typeof opt.label === 'string' ? opt.label : '',
|
|
326
|
+
description: typeof opt.description === 'string' ? opt.description : undefined,
|
|
327
|
+
})),
|
|
328
|
+
multiSelect: typeof q.multiSelect === 'boolean' ? q.multiSelect : false,
|
|
329
|
+
})),
|
|
330
|
+
answers: answersData.answers || {},
|
|
331
|
+
annotations: answersData.annotations || undefined,
|
|
332
|
+
askedAt: timestamp,
|
|
333
|
+
answeredAt: timestamp, // Assuming answered immediately in hook context
|
|
334
|
+
phase,
|
|
335
|
+
};
|
|
336
|
+
progress.clarifications.push(clarification);
|
|
337
|
+
}
|
|
338
|
+
// Standard tool call record
|
|
339
|
+
const toolCall = {
|
|
340
|
+
callId,
|
|
341
|
+
tool: toolName,
|
|
342
|
+
input: {
|
|
343
|
+
file_path: filePath,
|
|
344
|
+
content: typeof toolInput.content === 'string' ? toolInput.content : undefined,
|
|
345
|
+
// Extended inputs
|
|
346
|
+
pattern: typeof toolInput.pattern === 'string' ? toolInput.pattern : undefined,
|
|
347
|
+
path: typeof toolInput.path === 'string' ? toolInput.path : undefined,
|
|
348
|
+
command: typeof toolInput.command === 'string' ? toolInput.command : undefined,
|
|
349
|
+
skill: typeof toolInput.skill === 'string' ? toolInput.skill : undefined,
|
|
350
|
+
args: typeof toolInput.args === 'string' ? toolInput.args : undefined,
|
|
351
|
+
agent_type: typeof toolInput.subagent_type === 'string' ? toolInput.subagent_type : undefined,
|
|
352
|
+
mcp_server: extractMcpServer(toolName),
|
|
353
|
+
mcp_tool: extractMcpTool(toolName),
|
|
354
|
+
},
|
|
355
|
+
output: {
|
|
356
|
+
success,
|
|
357
|
+
},
|
|
358
|
+
timestamp,
|
|
359
|
+
phase,
|
|
360
|
+
taskId: currentTask,
|
|
361
|
+
};
|
|
362
|
+
// Update progress
|
|
363
|
+
progress.lastUpdatedAt = getBeijingTime();
|
|
364
|
+
// Record phase transition if phase changed
|
|
365
|
+
const previousPhase = progress.phase;
|
|
366
|
+
progress.phase = phase;
|
|
367
|
+
if (previousPhase !== phase) {
|
|
368
|
+
progress.phaseTransitions.push({
|
|
369
|
+
from: previousPhase,
|
|
370
|
+
to: phase,
|
|
371
|
+
timestamp: getBeijingTime(),
|
|
372
|
+
triggeredBy: currentTask || 'manual',
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
progress.currentNode = process.env.ZHUANSPEC_CURRENT_NODE || phase;
|
|
376
|
+
progress.currentTask = currentTask || progress.currentTask;
|
|
377
|
+
progress.toolCalls.push(toolCall);
|
|
378
|
+
if (filePath && !progress.filesModified.includes(filePath)) {
|
|
379
|
+
progress.filesModified.push(filePath);
|
|
380
|
+
}
|
|
381
|
+
// Try to estimate lines from content
|
|
382
|
+
const content = stdinData.tool_input?.content;
|
|
383
|
+
if (content) {
|
|
384
|
+
const lines = content.split('\n').length;
|
|
385
|
+
if (toolName === 'Write') {
|
|
386
|
+
progress.linesAdded += lines;
|
|
387
|
+
}
|
|
388
|
+
else if (toolName === 'Edit') {
|
|
389
|
+
// For Edit, we can't easily track lines removed/added
|
|
390
|
+
progress.linesAdded += 1; // Estimate
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const tokenUsageFromEnv = Number(process.env.ZHUANSPEC_TOKEN_USAGE || 0);
|
|
394
|
+
const contextLoadFromEnv = Number(process.env.ZHUANSPEC_CONTEXT_LOAD || 0);
|
|
395
|
+
const tokenUsageFromStdIn = Number(stdinData.tool_result?.tokenUsage || 0);
|
|
396
|
+
const contextLoadFromStdIn = Number(stdinData.tool_result?.contextLoad || 0);
|
|
397
|
+
progress.stats.tokenUsageTotal += tokenUsageFromEnv + tokenUsageFromStdIn;
|
|
398
|
+
progress.stats.contextLoad += contextLoadFromEnv + contextLoadFromStdIn;
|
|
399
|
+
// Calculate duration for current phase from phaseTransitions
|
|
400
|
+
// Find the last transition to current phase
|
|
401
|
+
const transitionsToCurrentPhase = progress.phaseTransitions.filter((t) => t.to === phase);
|
|
402
|
+
const currentPhaseStart = transitionsToCurrentPhase[transitionsToCurrentPhase.length - 1];
|
|
403
|
+
if (currentPhaseStart) {
|
|
404
|
+
const startTime = new Date(currentPhaseStart.timestamp).getTime();
|
|
405
|
+
const elapsedMs = Date.now() - startTime;
|
|
406
|
+
if (elapsedMs > 0) {
|
|
407
|
+
if (phase === 'propose')
|
|
408
|
+
progress.stats.durationMs.propose = elapsedMs;
|
|
409
|
+
if (phase === 'apply')
|
|
410
|
+
progress.stats.durationMs.apply = elapsedMs;
|
|
411
|
+
if (phase === 'review')
|
|
412
|
+
progress.stats.durationMs.review = elapsedMs;
|
|
413
|
+
if (phase === 'archive')
|
|
414
|
+
progress.stats.durationMs.archive = elapsedMs;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Write progress file atomically
|
|
418
|
+
await atomicWriteJson(progressPath, progress);
|
|
419
|
+
// Also update tool_calls.json separately for detailed tracking
|
|
420
|
+
let toolCallsLog;
|
|
421
|
+
if (await FileSystemUtils.fileExists(toolCallsPath)) {
|
|
422
|
+
try {
|
|
423
|
+
toolCallsLog = JSON.parse(await FileSystemUtils.readFile(toolCallsPath));
|
|
424
|
+
toolCallsLog.toolCalls.push(toolCall);
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
toolCallsLog = {
|
|
428
|
+
changeId,
|
|
429
|
+
sessionId: progress.sessionId,
|
|
430
|
+
toolCalls: [toolCall],
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
toolCallsLog = {
|
|
436
|
+
changeId,
|
|
437
|
+
sessionId: progress.sessionId,
|
|
438
|
+
toolCalls: [toolCall],
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
await FileSystemUtils.writeFile(toolCallsPath, JSON.stringify(toolCallsLog, null, 2));
|
|
442
|
+
// Check if this completes the current task
|
|
443
|
+
// (simplified: if file was modified and we're in apply phase, mark task as potentially complete)
|
|
444
|
+
let taskMarked = '';
|
|
445
|
+
if (phase === 'apply' && currentTask && filePath) {
|
|
446
|
+
const tasksPath = path.join(changeDir, 'tasks.md');
|
|
447
|
+
if (await FileSystemUtils.fileExists(tasksPath)) {
|
|
448
|
+
const tasksContent = await FileSystemUtils.readFile(tasksPath);
|
|
449
|
+
// Check if the task line already has [x] or needs manual confirmation
|
|
450
|
+
const taskPattern = new RegExp(`-\\s*\\[[x ]\\]\\s*${currentTask.replace('.', '\\.')}`);
|
|
451
|
+
const taskMatch = tasksContent.match(taskPattern);
|
|
452
|
+
if (taskMatch && !taskMatch[0].includes('[x]')) {
|
|
453
|
+
// Task is pending - we don't auto-mark it here, but note it for Stop hook
|
|
454
|
+
taskMarked = currentTask;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// Calculate progress percentage and visual bar
|
|
459
|
+
const totalTasks = progress.totalTasks || 0;
|
|
460
|
+
const completedTasks = progress.completedTasks.length;
|
|
461
|
+
const percentage = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0;
|
|
462
|
+
const progressBar = generateProgressBar(percentage, 12);
|
|
463
|
+
const progressDisplay = totalTasks > 0
|
|
464
|
+
? `${progressBar} ${percentage.toFixed(1)}% (${completedTasks}/${totalTasks})`
|
|
465
|
+
: '进度: N/A (任务数待定)';
|
|
466
|
+
return {
|
|
467
|
+
continue: true,
|
|
468
|
+
systemMessage: `📝 Progress recorded: ${toolName} ${filePath}
|
|
469
|
+
| Phase: ${phase} | Task: ${currentTask || 'N/A'}
|
|
470
|
+
| 进度: ${progressDisplay}`,
|
|
471
|
+
hookSpecificOutput: {
|
|
472
|
+
progressUpdated: true,
|
|
473
|
+
taskMarked,
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Generate visual progress bar
|
|
479
|
+
* @param percentage - Progress percentage (0-100)
|
|
480
|
+
* @param blocks - Number of blocks in the bar
|
|
481
|
+
*/
|
|
482
|
+
function generateProgressBar(percentage, blocks) {
|
|
483
|
+
const filled = Math.round((percentage / 100) * blocks);
|
|
484
|
+
const empty = blocks - filled;
|
|
485
|
+
return '█'.repeat(filled) + '░'.repeat(empty);
|
|
486
|
+
}
|
|
487
|
+
function createNewProgress(changeId) {
|
|
488
|
+
const sessionId = `sess-${getBeijingTimeForFilename()}`;
|
|
489
|
+
return {
|
|
490
|
+
changeId,
|
|
491
|
+
sessionId,
|
|
492
|
+
startedAt: getBeijingTime(),
|
|
493
|
+
lastUpdatedAt: getBeijingTime(),
|
|
494
|
+
phase: process.env.ZHUANSPEC_PHASE || 'idle',
|
|
495
|
+
currentNode: process.env.ZHUANSPEC_CURRENT_NODE || process.env.ZHUANSPEC_PHASE || 'idle',
|
|
496
|
+
currentTask: '',
|
|
497
|
+
completedTasks: [],
|
|
498
|
+
totalTasks: 0,
|
|
499
|
+
toolCalls: [],
|
|
500
|
+
skillCalls: [],
|
|
501
|
+
hookTriggers: [],
|
|
502
|
+
clarifications: [],
|
|
503
|
+
filesModified: [],
|
|
504
|
+
linesAdded: 0,
|
|
505
|
+
linesRemoved: 0,
|
|
506
|
+
deviationCount: 0,
|
|
507
|
+
deviationRecords: [],
|
|
508
|
+
reviewStats: {
|
|
509
|
+
loopCount: 0,
|
|
510
|
+
criticalFixes: 0,
|
|
511
|
+
testFixes: 0,
|
|
512
|
+
consistencyFixes: 0,
|
|
513
|
+
},
|
|
514
|
+
phaseTransitions: [],
|
|
515
|
+
stats: {
|
|
516
|
+
tokenUsageTotal: 0,
|
|
517
|
+
contextLoad: 0,
|
|
518
|
+
durationMs: {
|
|
519
|
+
propose: 0,
|
|
520
|
+
apply: 0,
|
|
521
|
+
review: 0,
|
|
522
|
+
archive: 0,
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Initialize progress.json at phase start
|
|
529
|
+
* This function should be called when a change is created, before any files are written.
|
|
530
|
+
* @param changeId - The change ID
|
|
531
|
+
* @param initialPhase - The initial phase to set (default: 'propose')
|
|
532
|
+
*/
|
|
533
|
+
export async function initializeProgress(changeId, initialPhase = 'propose') {
|
|
534
|
+
const cwd = process.cwd();
|
|
535
|
+
const zhuanspecDir = path.join(cwd, 'zhuanspec');
|
|
536
|
+
const changeDir = path.join(zhuanspecDir, 'changes', changeId);
|
|
537
|
+
const metricsDir = path.join(changeDir, 'metrics');
|
|
538
|
+
// Ensure metrics directory exists
|
|
539
|
+
await FileSystemUtils.createDirectory(metricsDir);
|
|
540
|
+
const progressPath = path.join(metricsDir, 'progress.json');
|
|
541
|
+
// Create or update progress file (with recovery support)
|
|
542
|
+
let progress;
|
|
543
|
+
if (await FileSystemUtils.fileExists(progressPath)) {
|
|
544
|
+
const recovered = await recoverProgressJsonForWrite(progressPath);
|
|
545
|
+
if (recovered) {
|
|
546
|
+
progress = recovered;
|
|
547
|
+
progress.phase = initialPhase;
|
|
548
|
+
progress.currentNode = initialPhase;
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
progress = createNewProgress(changeId);
|
|
552
|
+
progress.phase = initialPhase;
|
|
553
|
+
progress.currentNode = initialPhase;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
progress = createNewProgress(changeId);
|
|
558
|
+
progress.phase = initialPhase;
|
|
559
|
+
progress.currentNode = initialPhase;
|
|
560
|
+
}
|
|
561
|
+
progress.lastUpdatedAt = getBeijingTime();
|
|
562
|
+
await atomicWriteJson(progressPath, progress);
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Record a hook trigger event
|
|
566
|
+
* This function can be called from other hooks to record their execution
|
|
567
|
+
* @param hookName - The name of the hook (e.g., 'init', 'deviation-check')
|
|
568
|
+
* @param hookPhase - The lifecycle phase (SessionStart, PreToolUse, PostToolUse, Stop)
|
|
569
|
+
* @param success - Whether the hook executed successfully
|
|
570
|
+
* @param options - Additional context (toolName, filePath, message, durationMs)
|
|
571
|
+
*/
|
|
572
|
+
export async function recordHookTrigger(hookName, hookPhase, success, options) {
|
|
573
|
+
const cwd = process.cwd();
|
|
574
|
+
const activeChange = await detectActiveChange(cwd);
|
|
575
|
+
const phase = activeChange?.phase || process.env.ZHUANSPEC_PHASE || 'idle';
|
|
576
|
+
const changeId = activeChange?.changeId || process.env.ZHUANSPEC_CHANGE_ID || '';
|
|
577
|
+
if (!changeId || phase === 'idle')
|
|
578
|
+
return;
|
|
579
|
+
const zhuanspecDir = path.join(cwd, 'zhuanspec');
|
|
580
|
+
const changeDir = path.join(zhuanspecDir, 'changes', changeId);
|
|
581
|
+
const metricsDir = path.join(changeDir, 'metrics');
|
|
582
|
+
const progressPath = path.join(metricsDir, 'progress.json');
|
|
583
|
+
if (!await FileSystemUtils.fileExists(progressPath))
|
|
584
|
+
return;
|
|
585
|
+
try {
|
|
586
|
+
const progress = JSON.parse(await FileSystemUtils.readFile(progressPath));
|
|
587
|
+
// Ensure hookTriggers array exists
|
|
588
|
+
progress.hookTriggers = progress.hookTriggers || [];
|
|
589
|
+
const hookRecord = {
|
|
590
|
+
hookId: `hook-${Date.now()}`,
|
|
591
|
+
hookName,
|
|
592
|
+
hookPhase,
|
|
593
|
+
triggeredAt: getBeijingTime(),
|
|
594
|
+
phase,
|
|
595
|
+
toolName: options?.toolName,
|
|
596
|
+
filePath: options?.filePath,
|
|
597
|
+
success,
|
|
598
|
+
durationMs: options?.durationMs,
|
|
599
|
+
message: options?.message,
|
|
600
|
+
};
|
|
601
|
+
progress.hookTriggers.push(hookRecord);
|
|
602
|
+
progress.lastUpdatedAt = getBeijingTime();
|
|
603
|
+
await atomicWriteJson(progressPath, progress);
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
// Ignore errors
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
//# sourceMappingURL=record-progress.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review Hooks - Result Checkers for Review Phase
|
|
3
|
+
*
|
|
4
|
+
* Three independent hooks for Review phase (Phase 3):
|
|
5
|
+
* 1. code-review-result: Check code-review-expert skill output
|
|
6
|
+
* 2. unit-test-result: Check generate-mockito-unit-test-skill output
|
|
7
|
+
* 3. spec-consistency-result: Check spec-code-consistency output
|
|
8
|
+
*
|
|
9
|
+
* Each hook:
|
|
10
|
+
* - Reads skill output file
|
|
11
|
+
* - Extracts key metrics (Critical count, coverage, consistency)
|
|
12
|
+
* - Decides if fix loop is needed
|
|
13
|
+
* - Controls loop count (max 3)
|
|
14
|
+
*/
|
|
15
|
+
declare const MAX_LOOP_COUNT = 3;
|
|
16
|
+
interface CodeReviewOutput {
|
|
17
|
+
criticalCount: number;
|
|
18
|
+
importantCount: number;
|
|
19
|
+
infoCount: number;
|
|
20
|
+
issues: Array<{
|
|
21
|
+
level: 'critical' | 'important' | 'info';
|
|
22
|
+
file: string;
|
|
23
|
+
line?: number;
|
|
24
|
+
message: string;
|
|
25
|
+
ruleId?: string;
|
|
26
|
+
}>;
|
|
27
|
+
sonarStatus?: 'pass' | 'fail' | 'skip';
|
|
28
|
+
loopCount?: number;
|
|
29
|
+
}
|
|
30
|
+
interface HookResult {
|
|
31
|
+
pass: boolean;
|
|
32
|
+
needFix: boolean;
|
|
33
|
+
loopCount: number;
|
|
34
|
+
metrics: Record<string, unknown>;
|
|
35
|
+
fixPrompt?: string;
|
|
36
|
+
nextAction?: 'continue' | 'fix' | 'stop';
|
|
37
|
+
}
|
|
38
|
+
export declare function codeReviewResultCheck(changeId: string, skillOutput?: CodeReviewOutput): Promise<HookResult>;
|
|
39
|
+
interface UnitTestOutput {
|
|
40
|
+
testPassed: boolean;
|
|
41
|
+
passRate: number;
|
|
42
|
+
coverage: number;
|
|
43
|
+
coverageThreshold: number;
|
|
44
|
+
newTestsGenerated: string[];
|
|
45
|
+
failedTests: string[];
|
|
46
|
+
loopCount?: number;
|
|
47
|
+
}
|
|
48
|
+
export declare function unitTestResultCheck(changeId: string, skillOutput?: UnitTestOutput): Promise<HookResult>;
|
|
49
|
+
interface SpecConsistencyOutput {
|
|
50
|
+
consistencyRate: number;
|
|
51
|
+
totalRequirements: number;
|
|
52
|
+
totalScenarios: number;
|
|
53
|
+
coveredScenarios: number;
|
|
54
|
+
uncoveredScenarios: Array<{
|
|
55
|
+
requirementId: string;
|
|
56
|
+
scenarioId: string;
|
|
57
|
+
description: string;
|
|
58
|
+
}>;
|
|
59
|
+
mapping: Array<{
|
|
60
|
+
requirementId: string;
|
|
61
|
+
scenarioId: string;
|
|
62
|
+
codeFile: string;
|
|
63
|
+
codeLine?: number;
|
|
64
|
+
status: 'covered' | 'partial' | 'missing';
|
|
65
|
+
}>;
|
|
66
|
+
loopCount?: number;
|
|
67
|
+
}
|
|
68
|
+
export declare function specConsistencyResultCheck(changeId: string, skillOutput?: SpecConsistencyOutput): Promise<HookResult>;
|
|
69
|
+
export declare function writeCodeReviewOutput(changeId: string, output: CodeReviewOutput): Promise<void>;
|
|
70
|
+
export declare function writeUnitTestOutput(changeId: string, output: UnitTestOutput): Promise<void>;
|
|
71
|
+
export declare function writeSpecConsistencyOutput(changeId: string, output: SpecConsistencyOutput): Promise<void>;
|
|
72
|
+
export { writeSkillOutput };
|
|
73
|
+
declare function writeSkillOutput(changeId: string, filename: string, data: unknown): Promise<void>;
|
|
74
|
+
export interface ReviewReport {
|
|
75
|
+
changeId: string;
|
|
76
|
+
timestamp: string;
|
|
77
|
+
codeReview: HookResult;
|
|
78
|
+
unitTest: HookResult;
|
|
79
|
+
specConsistency: HookResult;
|
|
80
|
+
overallPass: boolean;
|
|
81
|
+
canArchive: boolean;
|
|
82
|
+
}
|
|
83
|
+
export declare function generateReviewReport(changeId: string, results: {
|
|
84
|
+
codeReview: HookResult;
|
|
85
|
+
unitTest: HookResult;
|
|
86
|
+
specConsistency: HookResult;
|
|
87
|
+
}): Promise<string>;
|
|
88
|
+
export { CodeReviewOutput, UnitTestOutput, SpecConsistencyOutput, HookResult, MAX_LOOP_COUNT, };
|
|
89
|
+
//# sourceMappingURL=review-hooks.d.ts.map
|