@zhuan-ai/zhuanspec 2.6.0 → 2.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/review.d.ts +1 -1
- package/dist/commands/review.js +24 -24
- package/dist/commands/validate.d.ts +0 -4
- package/dist/commands/validate.js +0 -22
- package/dist/core/archive.js +22 -13
- package/dist/core/hooks/deviation-check.js +2 -30
- package/dist/core/hooks/deviation-handler.d.ts +1 -0
- package/dist/core/hooks/deviation-handler.js +68 -15
- package/dist/core/hooks/post-apply.js +102 -39
- package/dist/core/hooks/record-progress.d.ts +167 -3
- package/dist/core/hooks/record-progress.js +144 -32
- package/dist/core/hooks/summarize.js +29 -10
- package/dist/core/init.js +11 -7
- package/dist/core/templates/agents-template.d.ts +1 -1
- package/dist/core/templates/agents-template.js +1 -1
- package/dist/core/templates/slash-command-templates.js +1 -1
- package/dist/core/validation/strict-rules.js +10 -42
- package/dist/utils/git-repo-detector.d.ts +130 -0
- package/dist/utils/git-repo-detector.js +275 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/phase-utils.d.ts +3 -3
- package/dist/utils/phase-utils.js +133 -10
- package/package.json +1 -1
|
@@ -8,13 +8,27 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { Phase } from '../../utils/phase-utils.js';
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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-
|
|
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:
|
|
92
|
-
currentNode:
|
|
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
|
|
118
|
-
* Returns format: "2026-04-
|
|
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
|
|
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
|
-
|
|
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-
|
|
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}
|
|
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
|
|
@@ -204,38 +211,80 @@ function extractMcpTool(toolName) {
|
|
|
204
211
|
return parts.length >= 3 ? parts[2] : undefined;
|
|
205
212
|
}
|
|
206
213
|
/**
|
|
207
|
-
* Detect active change from progress.json files
|
|
208
|
-
*
|
|
209
|
-
*
|
|
214
|
+
* Detect active change from progress.json files.
|
|
215
|
+
*
|
|
216
|
+
* Strategy:
|
|
217
|
+
* 1. Primary — if filePath is under zhuanspec/changes/{changeId}/, extract changeId directly.
|
|
218
|
+
* 2. Fallback — scan all changes and return the most recently updated one
|
|
219
|
+
* (by lastUpdatedAt field; stub files without it fall back to filesystem mtime).
|
|
210
220
|
*/
|
|
211
|
-
async function detectActiveChange(cwd) {
|
|
221
|
+
async function detectActiveChange(cwd, filePath) {
|
|
212
222
|
const changesDir = path.join(cwd, 'zhuanspec', 'changes');
|
|
213
223
|
if (!await FileSystemUtils.directoryExists(changesDir)) {
|
|
214
224
|
return null;
|
|
215
225
|
}
|
|
226
|
+
// Primary: derive changeId from the file being modified
|
|
227
|
+
if (filePath) {
|
|
228
|
+
const absFilePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
229
|
+
const changesDirAbs = path.resolve(changesDir);
|
|
230
|
+
if (absFilePath.startsWith(changesDirAbs + path.sep)) {
|
|
231
|
+
const changeId = path.relative(changesDirAbs, absFilePath).split(path.sep)[0];
|
|
232
|
+
if (changeId && changeId !== 'archive') {
|
|
233
|
+
const progressPath = path.join(changesDirAbs, changeId, 'metrics', 'progress.json');
|
|
234
|
+
if (await FileSystemUtils.fileExists(progressPath)) {
|
|
235
|
+
try {
|
|
236
|
+
const progress = JSON.parse(await FileSystemUtils.readFile(progressPath));
|
|
237
|
+
if (progress.phase && progress.phase !== 'idle' && PHASE_ORDER.includes(progress.phase)) {
|
|
238
|
+
return { changeId, phase: progress.phase };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Fall through to fallback
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Fallback: most recently updated active change
|
|
216
249
|
const entries = await fs.readdir(changesDir, { withFileTypes: true });
|
|
250
|
+
const candidates = [];
|
|
217
251
|
for (const entry of entries) {
|
|
218
252
|
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'archive')
|
|
219
253
|
continue;
|
|
220
254
|
const progressPath = path.join(changesDir, entry.name, 'metrics', 'progress.json');
|
|
221
|
-
if (await FileSystemUtils.fileExists(progressPath))
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
255
|
+
if (!await FileSystemUtils.fileExists(progressPath))
|
|
256
|
+
continue;
|
|
257
|
+
try {
|
|
258
|
+
const progress = JSON.parse(await FileSystemUtils.readFile(progressPath));
|
|
259
|
+
if (!progress.phase || progress.phase === 'idle' || !PHASE_ORDER.includes(progress.phase))
|
|
260
|
+
continue;
|
|
261
|
+
let sortKey;
|
|
262
|
+
if (progress.lastUpdatedAt) {
|
|
263
|
+
// "2026-04-22 11:39:59" (Beijing time) — replace space with T for Date parsing
|
|
264
|
+
sortKey = new Date(progress.lastUpdatedAt.replace(' ', 'T')).getTime();
|
|
265
|
+
if (isNaN(sortKey))
|
|
266
|
+
sortKey = 0;
|
|
227
267
|
}
|
|
228
|
-
|
|
229
|
-
//
|
|
268
|
+
else {
|
|
269
|
+
// Stub files (e.g. techDesign) lack lastUpdatedAt — use filesystem mtime
|
|
270
|
+
const stat = await fs.stat(progressPath);
|
|
271
|
+
sortKey = stat.mtime.getTime();
|
|
230
272
|
}
|
|
273
|
+
candidates.push({ changeId: entry.name, phase: progress.phase, sortKey });
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// Ignore parse errors
|
|
231
277
|
}
|
|
232
278
|
}
|
|
233
|
-
|
|
279
|
+
if (candidates.length === 0)
|
|
280
|
+
return null;
|
|
281
|
+
candidates.sort((a, b) => b.sortKey - a.sortKey);
|
|
282
|
+
return { changeId: candidates[0].changeId, phase: candidates[0].phase };
|
|
234
283
|
}
|
|
235
284
|
async function runRecordProgress(filePath, toolName, success, stdinData) {
|
|
236
285
|
const cwd = process.cwd();
|
|
237
|
-
// Priority: progress.json > environment variables
|
|
238
|
-
const activeChange = await detectActiveChange(cwd);
|
|
286
|
+
// Priority: filePath-derived > progress.json scan > environment variables
|
|
287
|
+
const activeChange = await detectActiveChange(cwd, filePath);
|
|
239
288
|
const phase = activeChange?.phase || process.env.ZHUANSPEC_PHASE || 'idle';
|
|
240
289
|
const changeId = activeChange?.changeId || process.env.ZHUANSPEC_CHANGE_ID || '';
|
|
241
290
|
const currentTask = process.env.ZHUANSPEC_CURRENT_TASK || '';
|
|
@@ -277,6 +326,7 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
|
|
|
277
326
|
consistencyFixes: 0,
|
|
278
327
|
};
|
|
279
328
|
progress.phaseTransitions = progress.phaseTransitions || [];
|
|
329
|
+
progress.phaseDurations = progress.phaseDurations || [];
|
|
280
330
|
progress.stats = progress.stats || {
|
|
281
331
|
tokenUsageTotal: 0,
|
|
282
332
|
contextLoad: 0,
|
|
@@ -375,8 +425,14 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
|
|
|
375
425
|
progress.currentNode = process.env.ZHUANSPEC_CURRENT_NODE || phase;
|
|
376
426
|
progress.currentTask = currentTask || progress.currentTask;
|
|
377
427
|
progress.toolCalls.push(toolCall);
|
|
378
|
-
|
|
379
|
-
|
|
428
|
+
const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
|
|
429
|
+
if (filePath && WRITE_TOOLS.has(toolName) && !progress.filesModified.includes(filePath)) {
|
|
430
|
+
// Exclude ZhuanSpec internal files (spec/metrics/doc files inside zhuanspec/ dir)
|
|
431
|
+
const absFilePath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
|
|
432
|
+
const zhuanspecDirPrefix = path.resolve(process.cwd(), 'zhuanspec') + path.sep;
|
|
433
|
+
if (!absFilePath.startsWith(zhuanspecDirPrefix)) {
|
|
434
|
+
progress.filesModified.push(filePath);
|
|
435
|
+
}
|
|
380
436
|
}
|
|
381
437
|
// Try to estimate lines from content
|
|
382
438
|
const content = stdinData.tool_input?.content;
|
|
@@ -486,13 +542,14 @@ function generateProgressBar(percentage, blocks) {
|
|
|
486
542
|
}
|
|
487
543
|
function createNewProgress(changeId) {
|
|
488
544
|
const sessionId = `sess-${getBeijingTimeForFilename()}`;
|
|
545
|
+
const initialPhase = process.env.ZHUANSPEC_PHASE || 'idle';
|
|
489
546
|
return {
|
|
490
547
|
changeId,
|
|
491
548
|
sessionId,
|
|
492
549
|
startedAt: getBeijingTime(),
|
|
493
550
|
lastUpdatedAt: getBeijingTime(),
|
|
494
|
-
phase:
|
|
495
|
-
currentNode: process.env.ZHUANSPEC_CURRENT_NODE ||
|
|
551
|
+
phase: initialPhase,
|
|
552
|
+
currentNode: process.env.ZHUANSPEC_CURRENT_NODE || initialPhase,
|
|
496
553
|
currentTask: '',
|
|
497
554
|
completedTasks: [],
|
|
498
555
|
totalTasks: 0,
|
|
@@ -512,6 +569,13 @@ function createNewProgress(changeId) {
|
|
|
512
569
|
consistencyFixes: 0,
|
|
513
570
|
},
|
|
514
571
|
phaseTransitions: [],
|
|
572
|
+
phaseDurations: [{
|
|
573
|
+
phase: initialPhase,
|
|
574
|
+
startedAt: getBeijingTime(),
|
|
575
|
+
durationMs: 0,
|
|
576
|
+
taskCount: 0,
|
|
577
|
+
completedTaskCount: 0,
|
|
578
|
+
}],
|
|
515
579
|
stats: {
|
|
516
580
|
tokenUsageTotal: 0,
|
|
517
581
|
contextLoad: 0,
|
|
@@ -538,12 +602,60 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
|
|
|
538
602
|
// Ensure metrics directory exists
|
|
539
603
|
await FileSystemUtils.createDirectory(metricsDir);
|
|
540
604
|
const progressPath = path.join(metricsDir, 'progress.json');
|
|
605
|
+
const timestamp = getBeijingTime();
|
|
541
606
|
// Create or update progress file (with recovery support)
|
|
542
607
|
let progress;
|
|
543
608
|
if (await FileSystemUtils.fileExists(progressPath)) {
|
|
544
609
|
const recovered = await recoverProgressJsonForWrite(progressPath);
|
|
545
610
|
if (recovered) {
|
|
546
611
|
progress = recovered;
|
|
612
|
+
// Fill missing fields with defaults
|
|
613
|
+
progress.toolCalls = progress.toolCalls || [];
|
|
614
|
+
progress.skillCalls = progress.skillCalls || [];
|
|
615
|
+
progress.hookTriggers = progress.hookTriggers || [];
|
|
616
|
+
progress.clarifications = progress.clarifications || [];
|
|
617
|
+
progress.filesModified = progress.filesModified || [];
|
|
618
|
+
progress.completedTasks = progress.completedTasks || [];
|
|
619
|
+
progress.deviationRecords = progress.deviationRecords || [];
|
|
620
|
+
progress.phaseTransitions = progress.phaseTransitions || [];
|
|
621
|
+
progress.phaseDurations = progress.phaseDurations || [];
|
|
622
|
+
progress.reviewStats = progress.reviewStats || {
|
|
623
|
+
loopCount: 0,
|
|
624
|
+
criticalFixes: 0,
|
|
625
|
+
testFixes: 0,
|
|
626
|
+
consistencyFixes: 0,
|
|
627
|
+
};
|
|
628
|
+
progress.stats = progress.stats || {
|
|
629
|
+
tokenUsageTotal: 0,
|
|
630
|
+
contextLoad: 0,
|
|
631
|
+
durationMs: { propose: 0, apply: 0, review: 0, archive: 0 },
|
|
632
|
+
};
|
|
633
|
+
const previousPhase = progress.phase;
|
|
634
|
+
if (previousPhase !== initialPhase) {
|
|
635
|
+
// Record phase transition
|
|
636
|
+
progress.phaseTransitions.push({
|
|
637
|
+
from: previousPhase,
|
|
638
|
+
to: initialPhase,
|
|
639
|
+
timestamp,
|
|
640
|
+
triggeredBy: 'initializeProgress',
|
|
641
|
+
});
|
|
642
|
+
// Update previous phase duration
|
|
643
|
+
const prevPhaseDuration = progress.phaseDurations.find(pd => pd.phase === previousPhase && !pd.endedAt);
|
|
644
|
+
if (prevPhaseDuration) {
|
|
645
|
+
prevPhaseDuration.endedAt = timestamp;
|
|
646
|
+
const startTime = new Date(prevPhaseDuration.startedAt).getTime();
|
|
647
|
+
const endTime = new Date(timestamp).getTime();
|
|
648
|
+
prevPhaseDuration.durationMs = endTime > startTime ? endTime - startTime : 0;
|
|
649
|
+
}
|
|
650
|
+
// Add new phase duration
|
|
651
|
+
progress.phaseDurations.push({
|
|
652
|
+
phase: initialPhase,
|
|
653
|
+
startedAt: timestamp,
|
|
654
|
+
durationMs: 0,
|
|
655
|
+
taskCount: 0,
|
|
656
|
+
completedTaskCount: 0,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
547
659
|
progress.phase = initialPhase;
|
|
548
660
|
progress.currentNode = initialPhase;
|
|
549
661
|
}
|
|
@@ -558,7 +670,7 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
|
|
|
558
670
|
progress.phase = initialPhase;
|
|
559
671
|
progress.currentNode = initialPhase;
|
|
560
672
|
}
|
|
561
|
-
progress.lastUpdatedAt =
|
|
673
|
+
progress.lastUpdatedAt = timestamp;
|
|
562
674
|
await atomicWriteJson(progressPath, progress);
|
|
563
675
|
}
|
|
564
676
|
/**
|
|
@@ -571,7 +683,7 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
|
|
|
571
683
|
*/
|
|
572
684
|
export async function recordHookTrigger(hookName, hookPhase, success, options) {
|
|
573
685
|
const cwd = process.cwd();
|
|
574
|
-
const activeChange = await detectActiveChange(cwd);
|
|
686
|
+
const activeChange = await detectActiveChange(cwd, options?.filePath);
|
|
575
687
|
const phase = activeChange?.phase || process.env.ZHUANSPEC_PHASE || 'idle';
|
|
576
688
|
const changeId = activeChange?.changeId || process.env.ZHUANSPEC_CHANGE_ID || '';
|
|
577
689
|
if (!changeId || phase === 'idle')
|
|
@@ -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
|
|
238
|
-
const
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
}
|
package/dist/core/init.js
CHANGED
|
@@ -314,7 +314,7 @@ export class InitCommand {
|
|
|
314
314
|
}
|
|
315
315
|
const specTemplateSpinner = this.startSpinner(`正在同步 ${this.businessDirection} 业务规范模板...`);
|
|
316
316
|
const syncResult = this.syncBusinessSpecTemplate(zhuanspecPath);
|
|
317
|
-
if (syncResult.syncedSpecs || syncResult.syncedChanges) {
|
|
317
|
+
if (syncResult.syncedSpecs || syncResult.syncedChanges || syncResult.syncedKnowledge) {
|
|
318
318
|
const syncedParts = [];
|
|
319
319
|
if (syncResult.syncedSpecs) {
|
|
320
320
|
syncedParts.push('specs');
|
|
@@ -322,6 +322,9 @@ export class InitCommand {
|
|
|
322
322
|
if (syncResult.syncedChanges) {
|
|
323
323
|
syncedParts.push('changes');
|
|
324
324
|
}
|
|
325
|
+
if (syncResult.syncedKnowledge) {
|
|
326
|
+
syncedParts.push('knowledge');
|
|
327
|
+
}
|
|
325
328
|
specTemplateSpinner.stopAndPersist({
|
|
326
329
|
symbol: PALETTE.white('▌'),
|
|
327
330
|
text: PALETTE.white(`已从远端模板同步 ${syncedParts.join('、')} 目录`),
|
|
@@ -791,28 +794,29 @@ export class InitCommand {
|
|
|
791
794
|
}
|
|
792
795
|
syncBusinessSpecTemplate(zhuanspecPath) {
|
|
793
796
|
if (!this.businessDirection) {
|
|
794
|
-
return { syncedSpecs: false, syncedChanges: false };
|
|
797
|
+
return { syncedSpecs: false, syncedChanges: false, syncedKnowledge: false };
|
|
795
798
|
}
|
|
796
799
|
const tempDir = this.cloneRemoteArchitectureRepoToTemp();
|
|
797
800
|
if (!tempDir) {
|
|
798
|
-
return { syncedSpecs: false, syncedChanges: false };
|
|
801
|
+
return { syncedSpecs: false, syncedChanges: false, syncedKnowledge: false };
|
|
799
802
|
}
|
|
800
803
|
try {
|
|
801
804
|
const specsRoot = path.join(tempDir, 'specs');
|
|
802
805
|
const businessRoot = this.resolveBusinessDirectionRoot(specsRoot);
|
|
803
806
|
if (!businessRoot) {
|
|
804
|
-
return { syncedSpecs: false, syncedChanges: false };
|
|
807
|
+
return { syncedSpecs: false, syncedChanges: false, syncedKnowledge: false };
|
|
805
808
|
}
|
|
806
809
|
const templateRoot = this.findFirstTemplateSourceRoot(businessRoot);
|
|
807
810
|
if (!templateRoot) {
|
|
808
|
-
return { syncedSpecs: false, syncedChanges: false };
|
|
811
|
+
return { syncedSpecs: false, syncedChanges: false, syncedKnowledge: false };
|
|
809
812
|
}
|
|
810
813
|
const syncedSpecs = this.copyDirectoryContentsIfExists(path.join(templateRoot, 'specs'), path.join(zhuanspecPath, 'specs'));
|
|
811
814
|
const syncedChanges = this.copyDirectoryContentsIfExists(path.join(templateRoot, 'changes'), path.join(zhuanspecPath, 'changes'));
|
|
812
|
-
|
|
815
|
+
const syncedKnowledge = this.copyDirectoryContentsIfExists(path.join(templateRoot, 'knowledge'), path.join(zhuanspecPath, 'knowledge'));
|
|
816
|
+
return { syncedSpecs, syncedChanges, syncedKnowledge };
|
|
813
817
|
}
|
|
814
818
|
catch {
|
|
815
|
-
return { syncedSpecs: false, syncedChanges: false };
|
|
819
|
+
return { syncedSpecs: false, syncedChanges: false, syncedKnowledge: false };
|
|
816
820
|
}
|
|
817
821
|
finally {
|
|
818
822
|
rmSync(tempDir, { recursive: true, force: true });
|