@zhuan-ai/zhuanspec 2.6.0 → 2.8.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.
@@ -8,13 +8,27 @@
8
8
  */
9
9
  import { Phase } from '../../utils/phase-utils.js';
10
10
  /**
11
- * Get Beijing time (UTC+8) as ISO string format
12
- * Returns format: "2026-04-19T18:35:22.934+08:00"
11
+ * Atomic write JSON file: write to temp file first, then rename
12
+ * This prevents half-written files during crashes or concurrent writes
13
+ * @param filePath - Target file path
14
+ * @param data - Data to write (will be JSON stringified)
15
+ */
16
+ export declare function atomicWriteJson(filePath: string, data: unknown): Promise<void>;
17
+ /**
18
+ * Recover damaged progress.json for write operations
19
+ * Uses same three-tier strategy as read side
20
+ * @param filePath - Path to progress.json
21
+ * @returns Recovered ProgressData or null
22
+ */
23
+ export declare function recoverProgressJsonForWrite(filePath: string): Promise<ProgressData | null>;
24
+ /**
25
+ * Get Beijing time (UTC+8) as display format
26
+ * Returns format: "2026-04-21 18:35:22" (北京时间)
13
27
  */
14
28
  export declare function getBeijingTime(): string;
15
29
  /**
16
30
  * Get Beijing time date string for session ID and filenames
17
- * Returns format: "2026-04-19T18-35-22"
31
+ * Returns format: "2026-04-21 18-35-22" (文件名安全格式)
18
32
  */
19
33
  export declare function getBeijingTimeForFilename(): string;
20
34
  interface RecordProgressOptions {
@@ -23,6 +37,156 @@ interface RecordProgressOptions {
23
37
  tool?: string;
24
38
  success?: string;
25
39
  }
40
+ interface ToolCallRecord {
41
+ callId: string;
42
+ tool: string;
43
+ input: {
44
+ file_path?: string;
45
+ content?: string;
46
+ pattern?: string;
47
+ path?: string;
48
+ command?: string;
49
+ skill?: string;
50
+ args?: string;
51
+ agent_type?: string;
52
+ mcp_server?: string;
53
+ mcp_tool?: string;
54
+ };
55
+ output: {
56
+ success: boolean;
57
+ lines?: number;
58
+ matches?: number;
59
+ exit_code?: number;
60
+ result?: string;
61
+ };
62
+ timestamp: string;
63
+ durationMs?: number;
64
+ phase: string;
65
+ taskId?: string;
66
+ }
67
+ interface DeviationRecord {
68
+ deviationId: string;
69
+ detectedAt: string;
70
+ triggerType: 'task-complete' | 'phase-complete' | 'user-input';
71
+ userInput?: string;
72
+ specChanged: string[];
73
+ codeChanged: string[];
74
+ resolvedAt?: string;
75
+ }
76
+ interface ReviewStats {
77
+ loopCount: number;
78
+ criticalFixes: number;
79
+ testFixes: number;
80
+ consistencyFixes: number;
81
+ }
82
+ interface PhaseTransition {
83
+ from: string;
84
+ to: string;
85
+ timestamp: string;
86
+ triggeredBy: string;
87
+ }
88
+ /**
89
+ * Skill 调用记录
90
+ */
91
+ interface SkillCallRecord {
92
+ callId: string;
93
+ skill: string;
94
+ args?: string;
95
+ description?: string;
96
+ triggeredAt: string;
97
+ phase: string;
98
+ durationMs?: number;
99
+ success: boolean;
100
+ outputSummary?: string;
101
+ }
102
+ /**
103
+ * Hook 触发记录
104
+ */
105
+ interface HookTriggerRecord {
106
+ hookId: string;
107
+ hookName: string;
108
+ hookPhase: string;
109
+ triggeredAt: string;
110
+ phase: string;
111
+ toolName?: string;
112
+ filePath?: string;
113
+ success: boolean;
114
+ durationMs?: number;
115
+ message?: string;
116
+ }
117
+ /**
118
+ * 咨询问题记录 (AskQuestion)
119
+ */
120
+ interface ClarificationRecord {
121
+ clarificationId: string;
122
+ questions: Array<{
123
+ question: string;
124
+ header?: string;
125
+ options: Array<{
126
+ label: string;
127
+ description?: string;
128
+ }>;
129
+ multiSelect: boolean;
130
+ }>;
131
+ answers: Record<string, string>;
132
+ annotations?: Record<string, {
133
+ notes?: string;
134
+ preview?: string;
135
+ }>;
136
+ askedAt: string;
137
+ answeredAt?: string;
138
+ phase: string;
139
+ context?: string;
140
+ }
141
+ /**
142
+ * Phase duration record for tracking time spent in each phase
143
+ */
144
+ export interface PhaseDurationRecord {
145
+ phase: Phase;
146
+ startedAt: string;
147
+ endedAt?: string;
148
+ durationMs: number;
149
+ taskCount: number;
150
+ completedTaskCount: number;
151
+ }
152
+ export interface ProgressData {
153
+ changeId: string;
154
+ sessionId: string;
155
+ startedAt: string;
156
+ lastUpdatedAt: string;
157
+ phase: string;
158
+ currentNode: string;
159
+ currentTask: string;
160
+ completedTasks: string[];
161
+ totalTasks: number;
162
+ toolCalls: ToolCallRecord[];
163
+ skillCalls: SkillCallRecord[];
164
+ hookTriggers: HookTriggerRecord[];
165
+ clarifications: ClarificationRecord[];
166
+ filesModified: string[];
167
+ linesAdded: number;
168
+ linesRemoved: number;
169
+ deviationCount: number;
170
+ deviationRecords: DeviationRecord[];
171
+ reviewStats: ReviewStats;
172
+ phaseTransitions: PhaseTransition[];
173
+ phaseDurations: PhaseDurationRecord[];
174
+ stats: {
175
+ tokenUsageTotal: number;
176
+ contextLoad: number;
177
+ durationMs: {
178
+ propose: number;
179
+ apply: number;
180
+ review: number;
181
+ archive: number;
182
+ };
183
+ };
184
+ events?: Array<{
185
+ event: string;
186
+ timestamp: string;
187
+ }>;
188
+ lastEvent?: string;
189
+ }
26
190
  export declare function recordProgressHook(options: RecordProgressOptions): Promise<void>;
27
191
  /**
28
192
  * Initialize progress.json at phase start
@@ -17,7 +17,7 @@ const fs = fsPromises;
17
17
  * @param filePath - Target file path
18
18
  * @param data - Data to write (will be JSON stringified)
19
19
  */
20
- async function atomicWriteJson(filePath, data) {
20
+ export async function atomicWriteJson(filePath, data) {
21
21
  const tmpPath = `${filePath}.tmp`;
22
22
  const content = JSON.stringify(data, null, 2);
23
23
  try {
@@ -43,7 +43,7 @@ async function atomicWriteJson(filePath, data) {
43
43
  * @param filePath - Path to progress.json
44
44
  * @returns Recovered ProgressData or null
45
45
  */
46
- async function recoverProgressJsonForWrite(filePath) {
46
+ export async function recoverProgressJsonForWrite(filePath) {
47
47
  try {
48
48
  const content = await FileSystemUtils.readFile(filePath);
49
49
  // Layer 1: Standard JSON parse
@@ -83,13 +83,14 @@ async function recoverProgressJsonForWrite(filePath) {
83
83
  const changeIdMatch = content.match(/"changeId"\s*:\s*"([^"]+)"/);
84
84
  const sessionIdMatch = content.match(/"sessionId"\s*:\s*"([^"]+)"/);
85
85
  if (phaseMatch || changeIdMatch) {
86
+ const recoveredPhase = phaseMatch?.[1] || 'unknown';
86
87
  return {
87
88
  changeId: changeIdMatch?.[1] || '',
88
89
  sessionId: sessionIdMatch?.[1] || '',
89
90
  startedAt: '',
90
91
  lastUpdatedAt: '',
91
- phase: phaseMatch?.[1] || 'unknown',
92
- currentNode: phaseMatch?.[1] || 'unknown',
92
+ phase: recoveredPhase,
93
+ currentNode: recoveredPhase,
93
94
  currentTask: '',
94
95
  completedTasks: [],
95
96
  totalTasks: 0,
@@ -104,6 +105,13 @@ async function recoverProgressJsonForWrite(filePath) {
104
105
  deviationRecords: [],
105
106
  reviewStats: { loopCount: 0, criticalFixes: 0, testFixes: 0, consistencyFixes: 0 },
106
107
  phaseTransitions: [],
108
+ phaseDurations: [{
109
+ phase: recoveredPhase,
110
+ startedAt: '',
111
+ durationMs: 0,
112
+ taskCount: 0,
113
+ completedTaskCount: 0,
114
+ }],
107
115
  stats: { tokenUsageTotal: 0, contextLoad: 0, durationMs: { propose: 0, apply: 0, review: 0, archive: 0 } },
108
116
  };
109
117
  }
@@ -114,8 +122,8 @@ async function recoverProgressJsonForWrite(filePath) {
114
122
  return null;
115
123
  }
116
124
  /**
117
- * Get Beijing time (UTC+8) as ISO string format
118
- * Returns format: "2026-04-19T18:35:22.934+08:00"
125
+ * Get Beijing time (UTC+8) as display format
126
+ * Returns format: "2026-04-21 18:35:22" (北京时间)
119
127
  */
120
128
  export function getBeijingTime() {
121
129
  const now = new Date();
@@ -123,19 +131,18 @@ export function getBeijingTime() {
123
131
  const utcMs = now.getTime() + now.getTimezoneOffset() * 60 * 1000;
124
132
  const beijingMs = utcMs + 8 * 60 * 60 * 1000;
125
133
  const beijingTime = new Date(beijingMs);
126
- // Format as ISO string with +08:00 timezone
134
+ // Format: yyyy-MM-dd hh:mm:ss
127
135
  const year = beijingTime.getFullYear();
128
136
  const month = String(beijingTime.getMonth() + 1).padStart(2, '0');
129
137
  const day = String(beijingTime.getDate()).padStart(2, '0');
130
138
  const hours = String(beijingTime.getHours()).padStart(2, '0');
131
139
  const minutes = String(beijingTime.getMinutes()).padStart(2, '0');
132
140
  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`;
141
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
135
142
  }
136
143
  /**
137
144
  * Get Beijing time date string for session ID and filenames
138
- * Returns format: "2026-04-19T18-35-22"
145
+ * Returns format: "2026-04-21 18-35-22" (文件名安全格式)
139
146
  */
140
147
  export function getBeijingTimeForFilename() {
141
148
  const now = new Date();
@@ -149,7 +156,7 @@ export function getBeijingTimeForFilename() {
149
156
  const hours = String(beijingTime.getHours()).padStart(2, '0');
150
157
  const minutes = String(beijingTime.getMinutes()).padStart(2, '0');
151
158
  const seconds = String(beijingTime.getSeconds()).padStart(2, '0');
152
- return `${year}-${month}-${day}T${hours}-${minutes}-${seconds}`;
159
+ return `${year}-${month}-${day} ${hours}-${minutes}-${seconds}`;
153
160
  }
154
161
  export async function recordProgressHook(options) {
155
162
  // Read stdin for Claude Code hook input
@@ -277,6 +284,7 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
277
284
  consistencyFixes: 0,
278
285
  };
279
286
  progress.phaseTransitions = progress.phaseTransitions || [];
287
+ progress.phaseDurations = progress.phaseDurations || [];
280
288
  progress.stats = progress.stats || {
281
289
  tokenUsageTotal: 0,
282
290
  contextLoad: 0,
@@ -375,8 +383,14 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
375
383
  progress.currentNode = process.env.ZHUANSPEC_CURRENT_NODE || phase;
376
384
  progress.currentTask = currentTask || progress.currentTask;
377
385
  progress.toolCalls.push(toolCall);
378
- if (filePath && !progress.filesModified.includes(filePath)) {
379
- progress.filesModified.push(filePath);
386
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
387
+ if (filePath && WRITE_TOOLS.has(toolName) && !progress.filesModified.includes(filePath)) {
388
+ // Exclude ZhuanSpec internal files (spec/metrics/doc files inside zhuanspec/ dir)
389
+ const absFilePath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
390
+ const zhuanspecDirPrefix = path.resolve(process.cwd(), 'zhuanspec') + path.sep;
391
+ if (!absFilePath.startsWith(zhuanspecDirPrefix)) {
392
+ progress.filesModified.push(filePath);
393
+ }
380
394
  }
381
395
  // Try to estimate lines from content
382
396
  const content = stdinData.tool_input?.content;
@@ -486,13 +500,14 @@ function generateProgressBar(percentage, blocks) {
486
500
  }
487
501
  function createNewProgress(changeId) {
488
502
  const sessionId = `sess-${getBeijingTimeForFilename()}`;
503
+ const initialPhase = process.env.ZHUANSPEC_PHASE || 'idle';
489
504
  return {
490
505
  changeId,
491
506
  sessionId,
492
507
  startedAt: getBeijingTime(),
493
508
  lastUpdatedAt: getBeijingTime(),
494
- phase: process.env.ZHUANSPEC_PHASE || 'idle',
495
- currentNode: process.env.ZHUANSPEC_CURRENT_NODE || process.env.ZHUANSPEC_PHASE || 'idle',
509
+ phase: initialPhase,
510
+ currentNode: process.env.ZHUANSPEC_CURRENT_NODE || initialPhase,
496
511
  currentTask: '',
497
512
  completedTasks: [],
498
513
  totalTasks: 0,
@@ -512,6 +527,13 @@ function createNewProgress(changeId) {
512
527
  consistencyFixes: 0,
513
528
  },
514
529
  phaseTransitions: [],
530
+ phaseDurations: [{
531
+ phase: initialPhase,
532
+ startedAt: getBeijingTime(),
533
+ durationMs: 0,
534
+ taskCount: 0,
535
+ completedTaskCount: 0,
536
+ }],
515
537
  stats: {
516
538
  tokenUsageTotal: 0,
517
539
  contextLoad: 0,
@@ -538,12 +560,60 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
538
560
  // Ensure metrics directory exists
539
561
  await FileSystemUtils.createDirectory(metricsDir);
540
562
  const progressPath = path.join(metricsDir, 'progress.json');
563
+ const timestamp = getBeijingTime();
541
564
  // Create or update progress file (with recovery support)
542
565
  let progress;
543
566
  if (await FileSystemUtils.fileExists(progressPath)) {
544
567
  const recovered = await recoverProgressJsonForWrite(progressPath);
545
568
  if (recovered) {
546
569
  progress = recovered;
570
+ // Fill missing fields with defaults
571
+ progress.toolCalls = progress.toolCalls || [];
572
+ progress.skillCalls = progress.skillCalls || [];
573
+ progress.hookTriggers = progress.hookTriggers || [];
574
+ progress.clarifications = progress.clarifications || [];
575
+ progress.filesModified = progress.filesModified || [];
576
+ progress.completedTasks = progress.completedTasks || [];
577
+ progress.deviationRecords = progress.deviationRecords || [];
578
+ progress.phaseTransitions = progress.phaseTransitions || [];
579
+ progress.phaseDurations = progress.phaseDurations || [];
580
+ progress.reviewStats = progress.reviewStats || {
581
+ loopCount: 0,
582
+ criticalFixes: 0,
583
+ testFixes: 0,
584
+ consistencyFixes: 0,
585
+ };
586
+ progress.stats = progress.stats || {
587
+ tokenUsageTotal: 0,
588
+ contextLoad: 0,
589
+ durationMs: { propose: 0, apply: 0, review: 0, archive: 0 },
590
+ };
591
+ const previousPhase = progress.phase;
592
+ if (previousPhase !== initialPhase) {
593
+ // Record phase transition
594
+ progress.phaseTransitions.push({
595
+ from: previousPhase,
596
+ to: initialPhase,
597
+ timestamp,
598
+ triggeredBy: 'initializeProgress',
599
+ });
600
+ // Update previous phase duration
601
+ const prevPhaseDuration = progress.phaseDurations.find(pd => pd.phase === previousPhase && !pd.endedAt);
602
+ if (prevPhaseDuration) {
603
+ prevPhaseDuration.endedAt = timestamp;
604
+ const startTime = new Date(prevPhaseDuration.startedAt).getTime();
605
+ const endTime = new Date(timestamp).getTime();
606
+ prevPhaseDuration.durationMs = endTime > startTime ? endTime - startTime : 0;
607
+ }
608
+ // Add new phase duration
609
+ progress.phaseDurations.push({
610
+ phase: initialPhase,
611
+ startedAt: timestamp,
612
+ durationMs: 0,
613
+ taskCount: 0,
614
+ completedTaskCount: 0,
615
+ });
616
+ }
547
617
  progress.phase = initialPhase;
548
618
  progress.currentNode = initialPhase;
549
619
  }
@@ -558,7 +628,7 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
558
628
  progress.phase = initialPhase;
559
629
  progress.currentNode = initialPhase;
560
630
  }
561
- progress.lastUpdatedAt = getBeijingTime();
631
+ progress.lastUpdatedAt = timestamp;
562
632
  await atomicWriteJson(progressPath, progress);
563
633
  }
564
634
  /**
@@ -227,6 +227,7 @@ function parseTasks(content) {
227
227
  const tasks = [];
228
228
  const lines = content.split('\n');
229
229
  let currentWave = 0;
230
+ let autoId = 0;
230
231
  for (const line of lines) {
231
232
  // Match wave headers: ### Wave 1, ### Wave 2, etc.
232
233
  const waveMatch = line.match(/^###\s*Wave\s*(\d+)/i);
@@ -234,17 +235,35 @@ function parseTasks(content) {
234
235
  currentWave = parseInt(waveMatch[1], 10);
235
236
  continue;
236
237
  }
237
- // Match task lines: - [ ] X.Y description or - [x] X.Y description
238
- const taskMatch = line.match(/^-\s*\[([x ])\]\s*(\d+\.\d+)\s*(.+)/);
239
- if (taskMatch) {
240
- const statusChar = taskMatch[1];
241
- const taskId = taskMatch[2];
242
- tasks.push({
243
- taskId,
244
- status: statusChar === 'x' ? 'completed' : 'pending',
245
- wave: currentWave,
246
- });
238
+ // Match any checkbox line: - [ ] ... or - [x] ...
239
+ const checkboxMatch = line.match(/^-\s*\[([x ])\]\s*(.+)/);
240
+ if (!checkboxMatch)
241
+ continue;
242
+ const statusChar = checkboxMatch[1];
243
+ const rest = checkboxMatch[2].trim();
244
+ // Skip review gate lines (legacy tasks.md files)
245
+ if (rest.includes('zhuanspec review') || rest.includes('审查门禁'))
246
+ continue;
247
+ // Extract task ID from multiple formats:
248
+ // Format 1: "1.1 description" or "1.1: description"
249
+ const numericIdMatch = rest.match(/^(\d+\.\d+)[:\s]/);
250
+ // Format 2: "T1: description" or "T1 description"
251
+ const tPrefixIdMatch = rest.match(/^(T\d+)[:\s]/i);
252
+ let taskId;
253
+ if (numericIdMatch) {
254
+ taskId = numericIdMatch[1];
255
+ }
256
+ else if (tPrefixIdMatch) {
257
+ taskId = tPrefixIdMatch[1].toUpperCase();
258
+ }
259
+ else {
260
+ taskId = `task-${++autoId}`;
247
261
  }
262
+ tasks.push({
263
+ taskId,
264
+ status: statusChar === 'x' ? 'completed' : 'pending',
265
+ wave: currentWave,
266
+ });
248
267
  }
249
268
  return tasks;
250
269
  }