@zhuan-ai/zhuanspec 2.4.8 → 2.6.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.
@@ -7,6 +7,12 @@
7
7
  * - Estimated remaining time
8
8
  */
9
9
  export declare class ProgressCommand {
10
+ /**
11
+ * Recover damaged progress.json using three-tier fallback strategy
12
+ * @param filePath - Path to progress.json file
13
+ * @returns Recovered ProgressData or null if unrecoverable
14
+ */
15
+ private recoverProgressJson;
10
16
  /**
11
17
  * Show progress for a specific change
12
18
  * Usage: zhuanspec progress show <change-id>
@@ -12,6 +12,69 @@ import { FileSystemUtils } from '../utils/file-system.js';
12
12
  import { PHASE_ORDER } from '../utils/phase-utils.js';
13
13
  import { initializeProgress } from '../core/hooks/record-progress.js';
14
14
  export class ProgressCommand {
15
+ /**
16
+ * Recover damaged progress.json using three-tier fallback strategy
17
+ * @param filePath - Path to progress.json file
18
+ * @returns Recovered ProgressData or null if unrecoverable
19
+ */
20
+ async recoverProgressJson(filePath) {
21
+ const content = await FileSystemUtils.readFile(filePath);
22
+ // Layer 1: Standard JSON parse
23
+ try {
24
+ return JSON.parse(content);
25
+ }
26
+ catch {
27
+ // Continue to Layer 2
28
+ }
29
+ // Layer 2: Extract first top-level JSON object (handle trailing corruption)
30
+ try {
31
+ // Find the first complete JSON object
32
+ const objectStart = content.indexOf('{');
33
+ if (objectStart === -1)
34
+ return null;
35
+ // Track brace depth to find object boundary
36
+ let depth = 0;
37
+ let objectEnd = -1;
38
+ for (let i = objectStart; i < content.length; i++) {
39
+ if (content[i] === '{')
40
+ depth++;
41
+ else if (content[i] === '}') {
42
+ depth--;
43
+ if (depth === 0) {
44
+ objectEnd = i + 1;
45
+ break;
46
+ }
47
+ }
48
+ }
49
+ if (objectEnd > objectStart) {
50
+ const objectContent = content.slice(objectStart, objectEnd);
51
+ return JSON.parse(objectContent);
52
+ }
53
+ }
54
+ catch {
55
+ // Continue to Layer 3
56
+ }
57
+ // Layer 3: Minimal field extraction using regex
58
+ const phaseMatch = content.match(/"phase"\s*:\s*"([^"]+)"/);
59
+ const currentTaskMatch = content.match(/"currentTask"\s*:\s*"([^"]+)"/);
60
+ const totalTasksMatch = content.match(/"totalTasks"\s*:\s*(\d+)/);
61
+ const changeIdMatch = content.match(/"changeId"\s*:\s*"([^"]+)"/);
62
+ const sessionIdMatch = content.match(/"sessionId"\s*:\s*"([^"]+)"/);
63
+ if (phaseMatch || changeIdMatch) {
64
+ return {
65
+ changeId: changeIdMatch?.[1] || '',
66
+ sessionId: sessionIdMatch?.[1] || '',
67
+ startedAt: '',
68
+ lastUpdatedAt: '',
69
+ phase: phaseMatch?.[1] || 'unknown',
70
+ currentNode: phaseMatch?.[1] || 'unknown',
71
+ currentTask: currentTaskMatch?.[1] || '',
72
+ completedTasks: [],
73
+ totalTasks: totalTasksMatch ? parseInt(totalTasksMatch[1], 10) : 0,
74
+ };
75
+ }
76
+ return null;
77
+ }
15
78
  /**
16
79
  * Show progress for a specific change
17
80
  * Usage: zhuanspec progress show <change-id>
@@ -24,16 +87,11 @@ export class ProgressCommand {
24
87
  if (!await FileSystemUtils.directoryExists(changeDir)) {
25
88
  throw new Error(`Change '${changeId}' not found`);
26
89
  }
27
- // Read progress.json
90
+ // Read progress.json with recovery support
28
91
  const progressPath = path.join(metricsDir, 'progress.json');
29
92
  let progress = null;
30
93
  if (await FileSystemUtils.fileExists(progressPath)) {
31
- try {
32
- progress = JSON.parse(await FileSystemUtils.readFile(progressPath));
33
- }
34
- catch {
35
- // Ignore parse errors
36
- }
94
+ progress = await this.recoverProgressJson(progressPath);
37
95
  }
38
96
  // Read tasks.md
39
97
  const tasksPath = path.join(changeDir, 'tasks.md');
@@ -67,16 +125,11 @@ export class ProgressCommand {
67
125
  }
68
126
  const changeDir = path.join(zhuanspecDir, 'changes', changeId);
69
127
  const metricsDir = path.join(changeDir, 'metrics');
70
- // Read progress.json
128
+ // Read progress.json with recovery support
71
129
  const progressPath = path.join(metricsDir, 'progress.json');
72
130
  let progress = null;
73
131
  if (await FileSystemUtils.fileExists(progressPath)) {
74
- try {
75
- progress = JSON.parse(await FileSystemUtils.readFile(progressPath));
76
- }
77
- catch {
78
- // Ignore parse errors
79
- }
132
+ progress = await this.recoverProgressJson(progressPath);
80
133
  }
81
134
  // Read tasks.md
82
135
  const tasksPath = path.join(changeDir, 'tasks.md');
@@ -126,18 +179,36 @@ export class ProgressCommand {
126
179
  currentWave = parseInt(waveMatch[1], 10);
127
180
  continue;
128
181
  }
129
- // Match task lines: - [ ] X.Y description or - [x] X.Y description
130
- const taskMatch = line.match(/^-\s*\[([x ])\]\s*(\d+\.\d+)\s*(.+)/);
131
- if (taskMatch) {
132
- const statusChar = taskMatch[1];
133
- const taskId = taskMatch[2];
134
- const description = taskMatch[3].trim();
182
+ // Match new format task lines: - [ ] X.Y description or - [x] X.Y description
183
+ const newFormatMatch = line.match(/^-\s*\[([x ])\]\s*(\d+\.\d+)\s*(.+)/);
184
+ if (newFormatMatch) {
185
+ const statusChar = newFormatMatch[1];
186
+ const taskId = newFormatMatch[2];
187
+ const description = newFormatMatch[3].trim();
135
188
  tasks.push({
136
189
  taskId,
137
190
  status: statusChar === 'x' ? 'completed' : 'pending',
138
191
  wave: currentWave,
139
192
  description: description.split('@')[0].trim(), // Remove @skill/@depends tags
140
193
  });
194
+ continue;
195
+ }
196
+ // Match legacy format task headers: #### T1: description or #### T2: description
197
+ const legacyMatch = line.match(/^####\s*(T\d+)\s*:\s*(.+)$/);
198
+ if (legacyMatch) {
199
+ const taskId = legacyMatch[1];
200
+ const rawDescription = legacyMatch[2].trim();
201
+ // Check if description ends with ✅ (completed marker)
202
+ const isCompleted = rawDescription.endsWith('✅');
203
+ const description = isCompleted
204
+ ? rawDescription.slice(0, -1).trim()
205
+ : rawDescription;
206
+ tasks.push({
207
+ taskId,
208
+ status: isCompleted ? 'completed' : 'pending',
209
+ wave: currentWave,
210
+ description: description.split('@')[0].trim(), // Remove @skill/@depends tags
211
+ });
141
212
  }
142
213
  }
143
214
  return tasks;
@@ -211,11 +282,9 @@ export class ProgressCommand {
211
282
  ? Math.round((durationMin / completedCount) * (totalTasks - completedCount))
212
283
  : 0;
213
284
  console.log('');
214
- console.log(`📊 变更进度: ${changeId}`);
285
+ console.log(`📊 变更进度: ${changeId} | Phase: ${phase} (${phaseIndex}/${phases.length}) | ${progressBar} ${percentage.toFixed(1)}% (${completedCount}/${totalTasks})`);
215
286
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
216
287
  console.log('');
217
- console.log(`Phase: ${phase} (${phaseIndex}/${phases.length})`);
218
- console.log('');
219
288
  // Wave status
220
289
  for (const wave of waves) {
221
290
  const waveStatusEmoji = wave.status === 'completed' ? '✅' : wave.status === 'in_progress' ? '🔄' : '⏳';
@@ -229,8 +298,6 @@ export class ProgressCommand {
229
298
  }
230
299
  console.log('');
231
300
  }
232
- // Overall progress
233
- console.log(`整体: ${progressBar} ${percentage.toFixed(1)}% (${completedCount}/${totalTasks})`);
234
301
  // Duration info
235
302
  if (durationMin > 0) {
236
303
  console.log(`耗时: ${durationMin}m | 预计剩余: ~${estimatedRemaining}m`);
@@ -11,15 +11,118 @@ import { promises as fsPromises } from 'fs';
11
11
  import { FileSystemUtils } from '../../utils/file-system.js';
12
12
  import { PHASE_ORDER } from '../../utils/phase-utils.js';
13
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
+ }
14
116
  /**
15
117
  * Get Beijing time (UTC+8) as ISO string format
16
118
  * Returns format: "2026-04-19T18:35:22.934+08:00"
17
119
  */
18
120
  export function getBeijingTime() {
19
121
  const now = new Date();
20
- // Beijing time is UTC+8
21
- const beijingOffset = 8 * 60; // 8 hours in minutes
22
- const beijingTime = new Date(now.getTime() + beijingOffset * 60 * 1000 - now.getTimezoneOffset() * 60 * 1000);
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);
23
126
  // Format as ISO string with +08:00 timezone
24
127
  const year = beijingTime.getFullYear();
25
128
  const month = String(beijingTime.getMonth() + 1).padStart(2, '0');
@@ -36,8 +139,10 @@ export function getBeijingTime() {
36
139
  */
37
140
  export function getBeijingTimeForFilename() {
38
141
  const now = new Date();
39
- const beijingOffset = 8 * 60;
40
- const beijingTime = new Date(now.getTime() + beijingOffset * 60 * 1000 - now.getTimezoneOffset() * 60 * 1000);
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);
41
146
  const year = beijingTime.getFullYear();
42
147
  const month = String(beijingTime.getMonth() + 1).padStart(2, '0');
43
148
  const day = String(beijingTime.getDate()).padStart(2, '0');
@@ -147,11 +252,12 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
147
252
  await FileSystemUtils.createDirectory(metricsDir);
148
253
  const progressPath = path.join(metricsDir, 'progress.json');
149
254
  const toolCallsPath = path.join(metricsDir, 'tool_calls.json');
150
- // Load existing progress or create new
255
+ // Load existing progress or create new (with recovery support)
151
256
  let progress;
152
257
  if (await FileSystemUtils.fileExists(progressPath)) {
153
- try {
154
- progress = JSON.parse(await FileSystemUtils.readFile(progressPath));
258
+ const recovered = await recoverProgressJsonForWrite(progressPath);
259
+ if (recovered) {
260
+ progress = recovered;
155
261
  progress.phase = progress.phase || phase;
156
262
  progress.currentNode = progress.currentNode || phase;
157
263
  progress.completedTasks = progress.completedTasks || [];
@@ -183,7 +289,7 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
183
289
  archive: 0,
184
290
  };
185
291
  }
186
- catch {
292
+ else {
187
293
  progress = createNewProgress(changeId);
188
294
  }
189
295
  }
@@ -290,17 +396,26 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
290
396
  const contextLoadFromStdIn = Number(stdinData.tool_result?.contextLoad || 0);
291
397
  progress.stats.tokenUsageTotal += tokenUsageFromEnv + tokenUsageFromStdIn;
292
398
  progress.stats.contextLoad += contextLoadFromEnv + contextLoadFromStdIn;
293
- const elapsedMs = Date.now() - new Date(progress.startedAt).getTime();
294
- if (phase === 'propose')
295
- progress.stats.durationMs.propose = elapsedMs;
296
- if (phase === 'apply')
297
- progress.stats.durationMs.apply = elapsedMs;
298
- if (phase === 'review')
299
- progress.stats.durationMs.review = elapsedMs;
300
- if (phase === 'archive')
301
- progress.stats.durationMs.archive = elapsedMs;
302
- // Write progress file
303
- await FileSystemUtils.writeFile(progressPath, JSON.stringify(progress, null, 2));
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);
304
419
  // Also update tool_calls.json separately for detailed tracking
305
420
  let toolCallsLog;
306
421
  if (await FileSystemUtils.fileExists(toolCallsPath)) {
@@ -423,16 +538,16 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
423
538
  // Ensure metrics directory exists
424
539
  await FileSystemUtils.createDirectory(metricsDir);
425
540
  const progressPath = path.join(metricsDir, 'progress.json');
426
- // Create or update progress file
541
+ // Create or update progress file (with recovery support)
427
542
  let progress;
428
543
  if (await FileSystemUtils.fileExists(progressPath)) {
429
- try {
430
- progress = JSON.parse(await FileSystemUtils.readFile(progressPath));
431
- // Update phase (support phase transitions)
544
+ const recovered = await recoverProgressJsonForWrite(progressPath);
545
+ if (recovered) {
546
+ progress = recovered;
432
547
  progress.phase = initialPhase;
433
548
  progress.currentNode = initialPhase;
434
549
  }
435
- catch {
550
+ else {
436
551
  progress = createNewProgress(changeId);
437
552
  progress.phase = initialPhase;
438
553
  progress.currentNode = initialPhase;
@@ -444,7 +559,7 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
444
559
  progress.currentNode = initialPhase;
445
560
  }
446
561
  progress.lastUpdatedAt = getBeijingTime();
447
- await FileSystemUtils.writeFile(progressPath, JSON.stringify(progress, null, 2));
562
+ await atomicWriteJson(progressPath, progress);
448
563
  }
449
564
  /**
450
565
  * Record a hook trigger event
@@ -485,7 +600,7 @@ export async function recordHookTrigger(hookName, hookPhase, success, options) {
485
600
  };
486
601
  progress.hookTriggers.push(hookRecord);
487
602
  progress.lastUpdatedAt = getBeijingTime();
488
- await FileSystemUtils.writeFile(progressPath, JSON.stringify(progress, null, 2));
603
+ await atomicWriteJson(progressPath, progress);
489
604
  }
490
605
  catch {
491
606
  // Ignore errors
@@ -58,7 +58,10 @@ export declare class InitCommand {
58
58
  private resolveClaudeAssetSource;
59
59
  private copyClaudeFileIfNeeded;
60
60
  private copyClaudeDirectoryIfNeeded;
61
- private copyClaudeDirectoryWithFallback;
61
+ /**
62
+ * Copy .claude directory from common source (unified source, no fallback).
63
+ */
64
+ private copyClaudeDirectoryFromCommon;
62
65
  private copyClaudeDirectoryFromSingleSource;
63
66
  private directoryHasAtLeastOneFile;
64
67
  private copyClaudeDirectoryByFileSystem;