@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.
Files changed (103) hide show
  1. package/bin/zhuanspec-hook.js +3 -0
  2. package/dist/cli/hooks.d.ts +14 -0
  3. package/dist/cli/hooks.js +465 -0
  4. package/dist/cli/index.js +100 -0
  5. package/dist/commands/artifact-workflow.js +15 -34
  6. package/dist/commands/design.d.ts +42 -0
  7. package/dist/commands/design.js +337 -0
  8. package/dist/commands/progress.d.ts +38 -0
  9. package/dist/commands/progress.js +349 -0
  10. package/dist/commands/review.d.ts +32 -0
  11. package/dist/commands/review.js +472 -0
  12. package/dist/commands/validate.d.ts +14 -0
  13. package/dist/commands/validate.js +161 -10
  14. package/dist/core/archive.d.ts +1 -0
  15. package/dist/core/archive.js +45 -3
  16. package/dist/core/completions/command-registry.js +67 -0
  17. package/dist/core/configurators/slash/amazon-q.js +32 -2
  18. package/dist/core/configurators/slash/antigravity.js +8 -2
  19. package/dist/core/configurators/slash/auggie.js +16 -1
  20. package/dist/core/configurators/slash/base.js +1 -1
  21. package/dist/core/configurators/slash/claude.d.ts +4 -0
  22. package/dist/core/configurators/slash/claude.js +56 -1
  23. package/dist/core/configurators/slash/cline.js +8 -2
  24. package/dist/core/configurators/slash/codebuddy.js +22 -1
  25. package/dist/core/configurators/slash/codex.js +21 -0
  26. package/dist/core/configurators/slash/costrict.js +15 -0
  27. package/dist/core/configurators/slash/crush.js +22 -1
  28. package/dist/core/configurators/slash/cursor.js +22 -1
  29. package/dist/core/configurators/slash/factory.js +16 -1
  30. package/dist/core/configurators/slash/gemini.js +8 -2
  31. package/dist/core/configurators/slash/github-copilot.js +19 -1
  32. package/dist/core/configurators/slash/iflow.js +22 -1
  33. package/dist/core/configurators/slash/kilocode.js +4 -1
  34. package/dist/core/configurators/slash/opencode.js +27 -0
  35. package/dist/core/configurators/slash/qoder.d.ts +4 -0
  36. package/dist/core/configurators/slash/qoder.js +59 -1
  37. package/dist/core/configurators/slash/qwen.js +8 -2
  38. package/dist/core/configurators/slash/roocode.js +8 -2
  39. package/dist/core/configurators/slash/windsurf.js +8 -2
  40. package/dist/core/dashboard/metrics.d.ts +33 -0
  41. package/dist/core/dashboard/metrics.js +114 -0
  42. package/dist/core/hooks/collect-knowledge.d.ts +16 -0
  43. package/dist/core/hooks/collect-knowledge.js +203 -0
  44. package/dist/core/hooks/context-load-hook.d.ts +25 -0
  45. package/dist/core/hooks/context-load-hook.js +159 -0
  46. package/dist/core/hooks/deviation-check.d.ts +27 -0
  47. package/dist/core/hooks/deviation-check.js +403 -0
  48. package/dist/core/hooks/deviation-handler.d.ts +43 -0
  49. package/dist/core/hooks/deviation-handler.js +98 -0
  50. package/dist/core/hooks/init.d.ts +14 -0
  51. package/dist/core/hooks/init.js +244 -0
  52. package/dist/core/hooks/notify-milestone.d.ts +14 -0
  53. package/dist/core/hooks/notify-milestone.js +170 -0
  54. package/dist/core/hooks/post-apply.d.ts +29 -0
  55. package/dist/core/hooks/post-apply.js +173 -0
  56. package/dist/core/hooks/post-archive.d.ts +7 -0
  57. package/dist/core/hooks/post-archive.js +208 -0
  58. package/dist/core/hooks/pre-apply.d.ts +34 -0
  59. package/dist/core/hooks/pre-apply.js +139 -0
  60. package/dist/core/hooks/pre-archive.d.ts +7 -0
  61. package/dist/core/hooks/pre-archive.js +50 -0
  62. package/dist/core/hooks/record-progress.d.ts +49 -0
  63. package/dist/core/hooks/record-progress.js +609 -0
  64. package/dist/core/hooks/review-hooks.d.ts +89 -0
  65. package/dist/core/hooks/review-hooks.js +345 -0
  66. package/dist/core/hooks/review-orchestrator.d.ts +40 -0
  67. package/dist/core/hooks/review-orchestrator.js +146 -0
  68. package/dist/core/hooks/summarize.d.ts +15 -0
  69. package/dist/core/hooks/summarize.js +282 -0
  70. package/dist/core/hooks/user-input-hook.d.ts +25 -0
  71. package/dist/core/hooks/user-input-hook.js +179 -0
  72. package/dist/core/init.d.ts +8 -0
  73. package/dist/core/init.js +251 -23
  74. package/dist/core/parsers/requirement-blocks.js +13 -10
  75. package/dist/core/templates/agents-template.d.ts +1 -1
  76. package/dist/core/templates/agents-template.js +513 -244
  77. package/dist/core/templates/index.d.ts +1 -0
  78. package/dist/core/templates/index.js +1 -0
  79. package/dist/core/templates/skill-templates.js +46 -156
  80. package/dist/core/templates/slash-command-templates.d.ts +1 -1
  81. package/dist/core/templates/slash-command-templates.js +352 -21
  82. package/dist/core/templates/tasks-template.d.ts +7 -0
  83. package/dist/core/templates/tasks-template.js +121 -25
  84. package/dist/core/templates/tdd-tasks-template.d.ts +3 -0
  85. package/dist/core/templates/tdd-tasks-template.js +91 -38
  86. package/dist/core/templates/test-cases-template.d.ts +41 -0
  87. package/dist/core/templates/test-cases-template.js +128 -0
  88. package/dist/core/validation/strict-rules.d.ts +44 -5
  89. package/dist/core/validation/strict-rules.js +302 -8
  90. package/dist/core/validation/validator.js +52 -2
  91. package/dist/core/view.d.ts +1 -0
  92. package/dist/core/view.js +60 -2
  93. package/dist/mcp/index.d.ts +28 -0
  94. package/dist/mcp/index.js +31 -0
  95. package/dist/utils/file-system.d.ts +1 -0
  96. package/dist/utils/file-system.js +11 -0
  97. package/dist/utils/item-discovery.js +24 -2
  98. package/dist/utils/phase-utils.d.ts +36 -0
  99. package/dist/utils/phase-utils.js +117 -0
  100. package/package.json +3 -2
  101. package/schemas/spec-driven/schema.yaml +45 -31
  102. package/schemas/spec-driven/templates/spec.md +142 -5
  103. 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