@zhuan-ai/zhuanspec 2.9.5 → 2.11.2
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/cli/hooks.js +8 -8
- package/dist/cli/index.js +1 -0
- package/dist/commands/design.d.ts +12 -2
- package/dist/commands/design.js +83 -10
- package/dist/commands/progress.js +11 -0
- package/dist/commands/review.d.ts +1 -25
- package/dist/commands/review.js +51 -408
- package/dist/core/completions/command-registry.js +4 -0
- package/dist/core/hooks/collect-knowledge.d.ts +13 -5
- package/dist/core/hooks/collect-knowledge.js +56 -164
- package/dist/core/hooks/deviation-check.js +159 -55
- package/dist/core/hooks/init.js +46 -0
- package/dist/core/hooks/post-apply.js +2 -0
- package/dist/core/hooks/pre-archive.js +13 -14
- package/dist/core/hooks/pre-review.d.ts +27 -0
- package/dist/core/hooks/pre-review.js +169 -0
- package/dist/core/hooks/record-progress.d.ts +25 -0
- package/dist/core/hooks/record-progress.js +88 -1
- package/dist/core/hooks/review-hooks.js +176 -7
- package/dist/core/hooks/review-orchestrator.js +58 -11
- package/dist/core/hooks/tdd-phase-hook.d.ts +59 -0
- package/dist/core/hooks/tdd-phase-hook.js +313 -0
- package/dist/core/init.d.ts +1 -0
- package/dist/core/init.js +45 -2
- package/dist/core/templates/agents-template.d.ts +1 -1
- package/dist/core/templates/agents-template.js +139 -23
- package/dist/core/templates/skill-templates.js +21 -1
- package/dist/core/templates/slash-command-templates.js +163 -63
- package/dist/core/templates/tasks-template.js +89 -0
- package/dist/core/update.d.ts +3 -0
- package/dist/core/update.js +85 -2
- package/dist/core/validation/strict-rules.d.ts +47 -0
- package/dist/core/validation/strict-rules.js +589 -42
- package/dist/utils/git-repo-detector.js +1 -1
- package/dist/utils/phase-utils.js +14 -1
- package/package.json +22 -20
|
@@ -17,29 +17,28 @@ async function runPreArchiveHook(changeId) {
|
|
|
17
17
|
return { continue: false, stopReason: 'missing-change', systemMessage: 'Missing --change for pre-archive check' };
|
|
18
18
|
}
|
|
19
19
|
const changeDir = path.join(process.cwd(), 'zhuanspec', 'changes', changeId);
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
const reviewDir = path.join(changeDir, 'review');
|
|
21
|
+
const codeReviewPath = path.join(reviewDir, 'code-review-result.json');
|
|
22
|
+
const unitTestPath = path.join(reviewDir, 'unit-test-result.json');
|
|
23
|
+
const reviewReportPath = path.join(reviewDir, 'review-report.md');
|
|
24
|
+
if (!(await FileSystemUtils.fileExists(codeReviewPath)) ||
|
|
25
|
+
!(await FileSystemUtils.fileExists(unitTestPath)) ||
|
|
25
26
|
!(await FileSystemUtils.fileExists(reviewReportPath))) {
|
|
26
27
|
return {
|
|
27
28
|
continue: false,
|
|
28
29
|
stopReason: 'review-or-tests-missing',
|
|
29
|
-
systemMessage: 'Pre-archive check failed: missing review.json
|
|
30
|
+
systemMessage: 'Pre-archive check failed: missing changes/<id>/review/code-review-result.json, unit-test-result.json, or review-report.md',
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const testPassed =
|
|
36
|
-
|
|
37
|
-
const critical = Number(review?.summary?.critical ?? 0);
|
|
38
|
-
if (!reviewPassed || !testPassed || critical > 0 || !specConsistencyPassed) {
|
|
33
|
+
const codeReview = JSON.parse(await FileSystemUtils.readFile(codeReviewPath));
|
|
34
|
+
const unitTest = JSON.parse(await FileSystemUtils.readFile(unitTestPath));
|
|
35
|
+
const critical = Number(codeReview?.criticalCount ?? 0);
|
|
36
|
+
const testPassed = Boolean(unitTest?.testPassed);
|
|
37
|
+
if (critical > 0 || !testPassed) {
|
|
39
38
|
return {
|
|
40
39
|
continue: false,
|
|
41
40
|
stopReason: 'quality-gate-failed',
|
|
42
|
-
systemMessage: `Pre-archive check failed:
|
|
41
|
+
systemMessage: `Pre-archive check failed: critical=${critical}, testPassed=${testPassed}`,
|
|
43
42
|
};
|
|
44
43
|
}
|
|
45
44
|
return {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-Review Hook
|
|
3
|
+
*
|
|
4
|
+
* Quality gate checks before entering Review phase:
|
|
5
|
+
* 1. All tasks in tasks.md are completed
|
|
6
|
+
* 2. Progress data is consistent with tasks.md
|
|
7
|
+
* 3. Specs completeness check (all delta specs have content)
|
|
8
|
+
*/
|
|
9
|
+
interface CheckResult {
|
|
10
|
+
passed: boolean;
|
|
11
|
+
message: string;
|
|
12
|
+
details?: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface PreReviewResult {
|
|
15
|
+
passed: boolean;
|
|
16
|
+
details: {
|
|
17
|
+
tasksComplete: CheckResult;
|
|
18
|
+
progressConsistent: CheckResult;
|
|
19
|
+
specsComplete: CheckResult;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Run all pre-review checks for a change directory.
|
|
24
|
+
*/
|
|
25
|
+
export declare function preReviewHook(changePath: string): Promise<PreReviewResult>;
|
|
26
|
+
export {};
|
|
27
|
+
//# sourceMappingURL=pre-review.d.ts.map
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-Review Hook
|
|
3
|
+
*
|
|
4
|
+
* Quality gate checks before entering Review phase:
|
|
5
|
+
* 1. All tasks in tasks.md are completed
|
|
6
|
+
* 2. Progress data is consistent with tasks.md
|
|
7
|
+
* 3. Specs completeness check (all delta specs have content)
|
|
8
|
+
*/
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
11
|
+
import { readdirSync, existsSync } from 'fs';
|
|
12
|
+
/**
|
|
13
|
+
* Run all pre-review checks for a change directory.
|
|
14
|
+
*/
|
|
15
|
+
export async function preReviewHook(changePath) {
|
|
16
|
+
const checks = {
|
|
17
|
+
tasksComplete: await checkTasksCompletion(changePath),
|
|
18
|
+
progressConsistent: await checkProgressConsistency(changePath),
|
|
19
|
+
specsComplete: await checkSpecsCompleteness(changePath),
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
passed: Object.values(checks).every(c => c.passed),
|
|
23
|
+
details: checks,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Check that all tasks in tasks.md are completed.
|
|
28
|
+
*/
|
|
29
|
+
async function checkTasksCompletion(changePath) {
|
|
30
|
+
const tasksPath = path.join(changePath, 'tasks.md');
|
|
31
|
+
if (!await FileSystemUtils.fileExists(tasksPath)) {
|
|
32
|
+
return {
|
|
33
|
+
passed: false,
|
|
34
|
+
message: 'tasks.md not found',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const content = await FileSystemUtils.readFile(tasksPath);
|
|
39
|
+
const allTasks = content.match(/^- \[[ x]\] .+$/gm) || [];
|
|
40
|
+
const uncompletedTasks = content.match(/^- \[ \] .+$/gm) || [];
|
|
41
|
+
const completedTasks = content.match(/^- \[x\] .+$/gm) || [];
|
|
42
|
+
if (allTasks.length === 0) {
|
|
43
|
+
return {
|
|
44
|
+
passed: false,
|
|
45
|
+
message: 'No tasks found in tasks.md',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (uncompletedTasks.length > 0) {
|
|
49
|
+
return {
|
|
50
|
+
passed: false,
|
|
51
|
+
message: `${uncompletedTasks.length}/${allTasks.length} tasks uncompleted`,
|
|
52
|
+
details: uncompletedTasks.map(t => t.trim()),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
passed: true,
|
|
57
|
+
message: `All ${completedTasks.length} tasks completed`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return {
|
|
62
|
+
passed: false,
|
|
63
|
+
message: 'Failed to read tasks.md',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Check that progress.json data is consistent with tasks.md counts.
|
|
69
|
+
*/
|
|
70
|
+
async function checkProgressConsistency(changePath) {
|
|
71
|
+
const progressPath = path.join(changePath, 'metrics', 'progress.json');
|
|
72
|
+
const tasksPath = path.join(changePath, 'tasks.md');
|
|
73
|
+
if (!await FileSystemUtils.fileExists(progressPath)) {
|
|
74
|
+
return {
|
|
75
|
+
passed: true,
|
|
76
|
+
message: 'No progress.json found (skipped)',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (!await FileSystemUtils.fileExists(tasksPath)) {
|
|
80
|
+
return {
|
|
81
|
+
passed: true,
|
|
82
|
+
message: 'No tasks.md found (skipped)',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const progressContent = await FileSystemUtils.readFile(progressPath);
|
|
87
|
+
const progress = JSON.parse(progressContent);
|
|
88
|
+
const tasksContent = await FileSystemUtils.readFile(tasksPath);
|
|
89
|
+
const totalFromTasks = (tasksContent.match(/^- \[[ x]\] .+$/gm) || []).length;
|
|
90
|
+
const completedFromTasks = (tasksContent.match(/^- \[x\] .+$/gm) || []).length;
|
|
91
|
+
const totalFromProgress = progress.totalTasks || 0;
|
|
92
|
+
if (totalFromProgress !== totalFromTasks) {
|
|
93
|
+
return {
|
|
94
|
+
passed: false,
|
|
95
|
+
message: `Task count mismatch: progress.json says ${totalFromProgress}, tasks.md has ${totalFromTasks}`,
|
|
96
|
+
details: [
|
|
97
|
+
`progress.json totalTasks: ${totalFromProgress}`,
|
|
98
|
+
`tasks.md total tasks: ${totalFromTasks}`,
|
|
99
|
+
`tasks.md completed: ${completedFromTasks}`,
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
passed: true,
|
|
105
|
+
message: `Progress consistent: ${completedFromTasks}/${totalFromTasks} tasks`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return {
|
|
110
|
+
passed: true,
|
|
111
|
+
message: 'Progress consistency check skipped (parse error)',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Check that all delta spec files under specs/ have meaningful content.
|
|
117
|
+
*/
|
|
118
|
+
async function checkSpecsCompleteness(changePath) {
|
|
119
|
+
const specsDir = path.join(changePath, 'specs');
|
|
120
|
+
if (!existsSync(specsDir)) {
|
|
121
|
+
return {
|
|
122
|
+
passed: false,
|
|
123
|
+
message: 'specs/ directory not found',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const capabilities = readdirSync(specsDir, { withFileTypes: true })
|
|
128
|
+
.filter(e => e.isDirectory())
|
|
129
|
+
.map(e => e.name);
|
|
130
|
+
if (capabilities.length === 0) {
|
|
131
|
+
return {
|
|
132
|
+
passed: false,
|
|
133
|
+
message: 'No capability directories found in specs/',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const emptySpecs = [];
|
|
137
|
+
for (const cap of capabilities) {
|
|
138
|
+
const specPath = path.join(specsDir, cap, 'spec.md');
|
|
139
|
+
if (!existsSync(specPath)) {
|
|
140
|
+
emptySpecs.push(`${cap}/spec.md (missing)`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const content = await FileSystemUtils.readFile(specPath);
|
|
144
|
+
// Check if spec has at least one delta marker (ADDED/MODIFIED/REMOVED)
|
|
145
|
+
const hasDelta = /\b(ADDED|MODIFIED|REMOVED)\b/.test(content);
|
|
146
|
+
if (!hasDelta) {
|
|
147
|
+
emptySpecs.push(`${cap}/spec.md (no delta markers)`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (emptySpecs.length > 0) {
|
|
151
|
+
return {
|
|
152
|
+
passed: false,
|
|
153
|
+
message: `${emptySpecs.length} spec(s) incomplete`,
|
|
154
|
+
details: emptySpecs,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
passed: true,
|
|
159
|
+
message: `All ${capabilities.length} spec(s) complete`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return {
|
|
164
|
+
passed: false,
|
|
165
|
+
message: 'Failed to check specs completeness',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=pre-review.js.map
|
|
@@ -98,6 +98,8 @@ interface SkillCallRecord {
|
|
|
98
98
|
durationMs?: number;
|
|
99
99
|
success: boolean;
|
|
100
100
|
outputSummary?: string;
|
|
101
|
+
afterAction?: string;
|
|
102
|
+
flowResumed?: boolean;
|
|
101
103
|
}
|
|
102
104
|
/**
|
|
103
105
|
* Hook 触发记录
|
|
@@ -149,6 +151,28 @@ export interface PhaseDurationRecord {
|
|
|
149
151
|
taskCount: number;
|
|
150
152
|
completedTaskCount: number;
|
|
151
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Proposal change record for tracking modifications to proposal files
|
|
156
|
+
*/
|
|
157
|
+
export interface ProposalChangeRecord {
|
|
158
|
+
changeRecordId: string;
|
|
159
|
+
timestamp: string;
|
|
160
|
+
phase: string;
|
|
161
|
+
triggeredBy: 'reverse-sync' | 'manual-edit' | 'ai-supplement' | 'review-fix';
|
|
162
|
+
modifiedFiles: Array<{
|
|
163
|
+
filePath: string;
|
|
164
|
+
changeType: 'modified' | 'created' | 'deleted';
|
|
165
|
+
summary: string;
|
|
166
|
+
diff?: {
|
|
167
|
+
before: string;
|
|
168
|
+
after: string;
|
|
169
|
+
};
|
|
170
|
+
linesAdded?: number;
|
|
171
|
+
linesRemoved?: number;
|
|
172
|
+
}>;
|
|
173
|
+
reason?: string;
|
|
174
|
+
relatedDeviationId?: string;
|
|
175
|
+
}
|
|
152
176
|
export interface ProgressData {
|
|
153
177
|
changeId: string;
|
|
154
178
|
sessionId: string;
|
|
@@ -181,6 +205,7 @@ export interface ProgressData {
|
|
|
181
205
|
archive: number;
|
|
182
206
|
};
|
|
183
207
|
};
|
|
208
|
+
proposalChanges: ProposalChangeRecord[];
|
|
184
209
|
events?: Array<{
|
|
185
210
|
event: string;
|
|
186
211
|
timestamp: string;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 3. Track tool calls for metrics
|
|
8
8
|
*/
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import { promises as fsPromises } from 'fs';
|
|
10
|
+
import { promises as fsPromises, existsSync, readFileSync, unlinkSync } 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;
|
|
@@ -103,6 +103,7 @@ export async function recoverProgressJsonForWrite(filePath) {
|
|
|
103
103
|
linesRemoved: 0,
|
|
104
104
|
deviationCount: 0,
|
|
105
105
|
deviationRecords: [],
|
|
106
|
+
proposalChanges: [],
|
|
106
107
|
reviewStats: { loopCount: 0, criticalFixes: 0, testFixes: 0, consistencyFixes: 0 },
|
|
107
108
|
phaseTransitions: [],
|
|
108
109
|
phaseDurations: [{
|
|
@@ -281,6 +282,34 @@ async function detectActiveChange(cwd, filePath) {
|
|
|
281
282
|
candidates.sort((a, b) => b.sortKey - a.sortKey);
|
|
282
283
|
return { changeId: candidates[0].changeId, phase: candidates[0].phase };
|
|
283
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Check if a file path is a proposal file
|
|
287
|
+
*/
|
|
288
|
+
function isProposalFile(filePath, changePath) {
|
|
289
|
+
const absFilePath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
|
|
290
|
+
const absChangePath = path.isAbsolute(changePath) ? changePath : path.resolve(process.cwd(), changePath);
|
|
291
|
+
if (!absFilePath.startsWith(absChangePath + path.sep))
|
|
292
|
+
return false;
|
|
293
|
+
const relativePath = path.relative(absChangePath, absFilePath);
|
|
294
|
+
return ['proposal.md', 'tasks.md', 'design.md', 'test-cases.md']
|
|
295
|
+
.some(f => relativePath === f)
|
|
296
|
+
|| relativePath.startsWith('specs' + path.sep);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Generate a brief summary of proposal file changes from tool input
|
|
300
|
+
*/
|
|
301
|
+
function generateProposalChangeSummary(toolInput) {
|
|
302
|
+
const content = typeof toolInput.content === 'string' ? toolInput.content : '';
|
|
303
|
+
if (!content)
|
|
304
|
+
return 'File modified';
|
|
305
|
+
// Extract first meaningful line as summary
|
|
306
|
+
const lines = content.split('\n').filter(l => l.trim().length > 0);
|
|
307
|
+
if (lines.length === 0)
|
|
308
|
+
return 'File modified';
|
|
309
|
+
const firstLine = lines[0].trim();
|
|
310
|
+
// Truncate to 120 chars
|
|
311
|
+
return firstLine.length > 120 ? firstLine.substring(0, 117) + '...' : firstLine;
|
|
312
|
+
}
|
|
284
313
|
async function runRecordProgress(filePath, toolName, success, stdinData) {
|
|
285
314
|
const cwd = process.cwd();
|
|
286
315
|
// Priority: filePath-derived > progress.json scan > environment variables
|
|
@@ -327,6 +356,7 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
|
|
|
327
356
|
};
|
|
328
357
|
progress.phaseTransitions = progress.phaseTransitions || [];
|
|
329
358
|
progress.phaseDurations = progress.phaseDurations || [];
|
|
359
|
+
progress.proposalChanges = progress.proposalChanges || [];
|
|
330
360
|
progress.stats = progress.stats || {
|
|
331
361
|
tokenUsageTotal: 0,
|
|
332
362
|
contextLoad: 0,
|
|
@@ -434,6 +464,43 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
|
|
|
434
464
|
progress.filesModified.push(filePath);
|
|
435
465
|
}
|
|
436
466
|
}
|
|
467
|
+
// Detect proposal file changes and record them
|
|
468
|
+
const changePath = path.join(zhuanspecDir, 'changes', changeId);
|
|
469
|
+
const WRITE_EDIT_TOOLS = new Set(['Write', 'Edit']);
|
|
470
|
+
if (filePath && WRITE_EDIT_TOOLS.has(toolName) && isProposalFile(filePath, changePath)) {
|
|
471
|
+
const relativeProposalPath = path.relative(changePath, path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath));
|
|
472
|
+
const linesContent = typeof toolInput.content === 'string' ? toolInput.content : '';
|
|
473
|
+
const contentLines = linesContent ? linesContent.split('\n').length : 0;
|
|
474
|
+
const record = {
|
|
475
|
+
changeRecordId: `pc-${Date.now()}`,
|
|
476
|
+
timestamp,
|
|
477
|
+
phase,
|
|
478
|
+
triggeredBy: phase === 'apply' ? 'reverse-sync' : phase === 'review' ? 'review-fix' : 'manual-edit',
|
|
479
|
+
modifiedFiles: [{
|
|
480
|
+
filePath: relativeProposalPath,
|
|
481
|
+
changeType: toolName === 'Write' ? 'created' : 'modified',
|
|
482
|
+
summary: generateProposalChangeSummary(toolInput),
|
|
483
|
+
linesAdded: contentLines,
|
|
484
|
+
linesRemoved: 0,
|
|
485
|
+
}],
|
|
486
|
+
};
|
|
487
|
+
// Read related deviation ID from temp file (cross-process communication)
|
|
488
|
+
let relatedDeviationId;
|
|
489
|
+
try {
|
|
490
|
+
const deviationIdPath = path.join(changePath, 'metrics', '.last-deviation-id');
|
|
491
|
+
if (existsSync(deviationIdPath)) {
|
|
492
|
+
relatedDeviationId = readFileSync(deviationIdPath, 'utf-8').trim();
|
|
493
|
+
unlinkSync(deviationIdPath); // consume after reading
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
// graceful degradation - no deviation ID available
|
|
498
|
+
}
|
|
499
|
+
if (relatedDeviationId) {
|
|
500
|
+
record.relatedDeviationId = relatedDeviationId;
|
|
501
|
+
}
|
|
502
|
+
progress.proposalChanges.push(record);
|
|
503
|
+
}
|
|
437
504
|
// Try to estimate lines from content
|
|
438
505
|
const content = stdinData.tool_input?.content;
|
|
439
506
|
if (content) {
|
|
@@ -470,6 +537,24 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
|
|
|
470
537
|
progress.stats.durationMs.archive = elapsedMs;
|
|
471
538
|
}
|
|
472
539
|
}
|
|
540
|
+
// === Auto-count tasks from tasks.md ===
|
|
541
|
+
const tasksPath = path.join(changeDir, 'tasks.md');
|
|
542
|
+
if (await FileSystemUtils.fileExists(tasksPath)) {
|
|
543
|
+
try {
|
|
544
|
+
const tasksContent = await FileSystemUtils.readFile(tasksPath);
|
|
545
|
+
const totalTaskMatches = tasksContent.match(/^- \[[ x]\] .+$/gm) || [];
|
|
546
|
+
const completedTaskMatches = tasksContent.match(/^- \[x\] .+$/gm) || [];
|
|
547
|
+
progress.totalTasks = totalTaskMatches.length;
|
|
548
|
+
// Update completedTasks array with completed task descriptions
|
|
549
|
+
const completedDescriptions = completedTaskMatches.map(t => t.replace(/^- \[x\] /, '').trim());
|
|
550
|
+
if (completedDescriptions.length > 0) {
|
|
551
|
+
progress.completedTasks = completedDescriptions;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
// Ignore tasks.md read errors
|
|
556
|
+
}
|
|
557
|
+
}
|
|
473
558
|
// Write progress file atomically
|
|
474
559
|
await atomicWriteJson(progressPath, progress);
|
|
475
560
|
// Also update tool_calls.json separately for detailed tracking
|
|
@@ -569,6 +654,7 @@ function createNewProgress(changeId) {
|
|
|
569
654
|
consistencyFixes: 0,
|
|
570
655
|
},
|
|
571
656
|
phaseTransitions: [],
|
|
657
|
+
proposalChanges: [],
|
|
572
658
|
phaseDurations: [{
|
|
573
659
|
phase: initialPhase,
|
|
574
660
|
startedAt: getBeijingTime(),
|
|
@@ -617,6 +703,7 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
|
|
|
617
703
|
progress.filesModified = progress.filesModified || [];
|
|
618
704
|
progress.completedTasks = progress.completedTasks || [];
|
|
619
705
|
progress.deviationRecords = progress.deviationRecords || [];
|
|
706
|
+
progress.proposalChanges = progress.proposalChanges || [];
|
|
620
707
|
progress.phaseTransitions = progress.phaseTransitions || [];
|
|
621
708
|
progress.phaseDurations = progress.phaseDurations || [];
|
|
622
709
|
progress.reviewStats = progress.reviewStats || {
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
* - Controls loop count (max 3)
|
|
14
14
|
*/
|
|
15
15
|
import path from 'path';
|
|
16
|
+
import fs from 'fs';
|
|
16
17
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
17
18
|
import { getBeijingTime } from './record-progress.js';
|
|
18
19
|
// Output file paths for skill results
|
|
19
|
-
const SKILL_OUTPUT_DIR = 'zhuanspec/.skill-outputs';
|
|
20
20
|
const CODE_REVIEW_OUTPUT = 'code-review-result.json';
|
|
21
21
|
const UNIT_TEST_OUTPUT = 'unit-test-result.json';
|
|
22
22
|
const SPEC_CONSISTENCY_OUTPUT = 'spec-consistency-result.json';
|
|
@@ -143,15 +143,19 @@ export async function unitTestResultCheck(changeId, skillOutput) {
|
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
145
|
export async function specConsistencyResultCheck(changeId, skillOutput) {
|
|
146
|
-
|
|
146
|
+
let output = skillOutput || await readSkillOutput(changeId, SPEC_CONSISTENCY_OUTPUT);
|
|
147
|
+
// Fallback: auto-execute basic consistency analysis when no JSON exists
|
|
148
|
+
if (!output) {
|
|
149
|
+
output = await runBasicConsistencyAnalysis(changeId);
|
|
150
|
+
}
|
|
147
151
|
if (!output) {
|
|
148
152
|
return {
|
|
149
153
|
pass: false,
|
|
150
154
|
needFix: false,
|
|
151
155
|
loopCount: 0,
|
|
152
|
-
metrics: { error: 'No skill output found' },
|
|
156
|
+
metrics: { error: 'No skill output found and basic analysis failed' },
|
|
153
157
|
nextAction: 'stop',
|
|
154
|
-
fixPrompt: 'Spec-consistency skill output not found. Please run spec-code-consistency check first.',
|
|
158
|
+
fixPrompt: 'Spec-consistency skill output not found and basic analysis failed. Please run spec-code-consistency check first.',
|
|
155
159
|
};
|
|
156
160
|
}
|
|
157
161
|
const loopCount = (output.loopCount || 0) + 1;
|
|
@@ -201,11 +205,174 @@ export async function specConsistencyResultCheck(changeId, skillOutput) {
|
|
|
201
205
|
};
|
|
202
206
|
}
|
|
203
207
|
// ============================================================
|
|
208
|
+
// Basic Consistency Analysis Fallback
|
|
209
|
+
// ============================================================
|
|
210
|
+
/**
|
|
211
|
+
* Run basic consistency analysis when spec-consistency-result.json is missing.
|
|
212
|
+
* Reads specs/ under the change directory, extracts Requirements/Scenarios,
|
|
213
|
+
* searches touched files for keyword matches, and writes a basic result.
|
|
214
|
+
*/
|
|
215
|
+
async function runBasicConsistencyAnalysis(changeId) {
|
|
216
|
+
const cwd = process.cwd();
|
|
217
|
+
const changeDir = path.join(cwd, 'zhuanspec', 'changes', changeId);
|
|
218
|
+
const specsDir = path.join(changeDir, 'specs');
|
|
219
|
+
// Check if specs directory exists
|
|
220
|
+
if (!await FileSystemUtils.directoryExists(specsDir)) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const specEntries = await fs.promises.readdir(specsDir, { withFileTypes: true });
|
|
225
|
+
const allRequirements = [];
|
|
226
|
+
for (const entry of specEntries) {
|
|
227
|
+
if (!entry.isDirectory())
|
|
228
|
+
continue;
|
|
229
|
+
const specFile = path.join(specsDir, entry.name, 'spec.md');
|
|
230
|
+
if (!await FileSystemUtils.fileExists(specFile))
|
|
231
|
+
continue;
|
|
232
|
+
const content = await FileSystemUtils.readFile(specFile);
|
|
233
|
+
const lines = content.split('\n');
|
|
234
|
+
let currentSection = '';
|
|
235
|
+
let currentReq = null;
|
|
236
|
+
let reqCounter = 0;
|
|
237
|
+
let scenarioCounter = 0;
|
|
238
|
+
for (const line of lines) {
|
|
239
|
+
if (line.match(/^##\s+(ADDED|Added|\u65b0\u589e)/i))
|
|
240
|
+
currentSection = 'ADDED';
|
|
241
|
+
else if (line.match(/^##\s+(MODIFIED|Modified|\u4fee\u6539)/i))
|
|
242
|
+
currentSection = 'MODIFIED';
|
|
243
|
+
else if (line.match(/^##\s+(REMOVED|Removed|\u5220\u9664)/i))
|
|
244
|
+
currentSection = 'REMOVED';
|
|
245
|
+
else if (line.match(/^###\s+(Requirement|\u9700\u6c42)[\uff1a:]/i)) {
|
|
246
|
+
const reqName = line.replace(/^###\s+(Requirement|\u9700\u6c42)[\uff1a:]\s+/i, '').trim();
|
|
247
|
+
if (currentSection === 'ADDED' || currentSection === 'MODIFIED') {
|
|
248
|
+
reqCounter++;
|
|
249
|
+
currentReq = { id: `REQ-${reqCounter}`, name: reqName, scenarios: [] };
|
|
250
|
+
allRequirements.push(currentReq);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
currentReq = null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else if (line.match(/^####\s+(Scenario|\u573a\u666f)[\uff1a:]/i) && currentReq) {
|
|
257
|
+
scenarioCounter++;
|
|
258
|
+
const scenarioDesc = line.replace(/^####\s+(Scenario|\u573a\u666f)[\uff1a:]\s+/i, '').trim();
|
|
259
|
+
currentReq.scenarios.push({ id: `SC-${scenarioCounter}`, description: scenarioDesc });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (allRequirements.length === 0) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
// Get touched files from git
|
|
267
|
+
let touchedFiles = [];
|
|
268
|
+
try {
|
|
269
|
+
const { execSync } = await import('child_process');
|
|
270
|
+
const diffOutput = execSync('git diff --name-only HEAD', {
|
|
271
|
+
cwd,
|
|
272
|
+
encoding: 'utf-8',
|
|
273
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
274
|
+
});
|
|
275
|
+
touchedFiles = diffOutput.split('\n').map(f => f.trim()).filter(f => f.length > 0);
|
|
276
|
+
const statusOutput = execSync('git status --porcelain', {
|
|
277
|
+
cwd,
|
|
278
|
+
encoding: 'utf-8',
|
|
279
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
280
|
+
});
|
|
281
|
+
const statusFiles = statusOutput.split('\n')
|
|
282
|
+
.map(l => l.replace(/^\s*[A-Z?]+\s+/, '').trim())
|
|
283
|
+
.filter(f => f.length > 0);
|
|
284
|
+
touchedFiles = [...new Set([...touchedFiles, ...statusFiles])];
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// No git info available
|
|
288
|
+
}
|
|
289
|
+
// Build scenario mapping and check coverage
|
|
290
|
+
const mapping = [];
|
|
291
|
+
const uncoveredScenarios = [];
|
|
292
|
+
let totalScenarios = 0;
|
|
293
|
+
let coveredCount = 0;
|
|
294
|
+
for (const req of allRequirements) {
|
|
295
|
+
const scenarios = req.scenarios.length > 0
|
|
296
|
+
? req.scenarios
|
|
297
|
+
: [{ id: `${req.id}-implicit`, description: req.name }];
|
|
298
|
+
for (const scenario of scenarios) {
|
|
299
|
+
totalScenarios++;
|
|
300
|
+
const keywords = scenario.description
|
|
301
|
+
.toLowerCase()
|
|
302
|
+
.split(/[\s\-_/\uff0c\u3001]+/)
|
|
303
|
+
.filter(w => w.length > 3);
|
|
304
|
+
let found = false;
|
|
305
|
+
let matchedFile = '';
|
|
306
|
+
for (const file of touchedFiles) {
|
|
307
|
+
const fileLower = file.toLowerCase();
|
|
308
|
+
if (keywords.some(kw => fileLower.includes(kw))) {
|
|
309
|
+
found = true;
|
|
310
|
+
matchedFile = file;
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
const fullPath = path.isAbsolute(file) ? file : path.join(cwd, file);
|
|
315
|
+
const fileContent = await FileSystemUtils.readFile(fullPath);
|
|
316
|
+
if (keywords.some(kw => fileContent.toLowerCase().includes(kw))) {
|
|
317
|
+
found = true;
|
|
318
|
+
matchedFile = file;
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Skip unreadable files
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (found) {
|
|
327
|
+
coveredCount++;
|
|
328
|
+
mapping.push({
|
|
329
|
+
requirementId: req.id,
|
|
330
|
+
scenarioId: scenario.id,
|
|
331
|
+
codeFile: matchedFile,
|
|
332
|
+
status: 'covered',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
mapping.push({
|
|
337
|
+
requirementId: req.id,
|
|
338
|
+
scenarioId: scenario.id,
|
|
339
|
+
codeFile: '',
|
|
340
|
+
status: 'missing',
|
|
341
|
+
});
|
|
342
|
+
uncoveredScenarios.push({
|
|
343
|
+
requirementId: req.id,
|
|
344
|
+
scenarioId: scenario.id,
|
|
345
|
+
description: scenario.description,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const consistencyRate = totalScenarios > 0
|
|
351
|
+
? Math.round((coveredCount / totalScenarios) * 100)
|
|
352
|
+
: 100;
|
|
353
|
+
const result = {
|
|
354
|
+
consistencyRate,
|
|
355
|
+
totalRequirements: allRequirements.length,
|
|
356
|
+
totalScenarios,
|
|
357
|
+
coveredScenarios: coveredCount,
|
|
358
|
+
uncoveredScenarios,
|
|
359
|
+
mapping,
|
|
360
|
+
loopCount: 0,
|
|
361
|
+
};
|
|
362
|
+
// Write the result so subsequent calls can read it directly
|
|
363
|
+
await writeSkillOutput(changeId, SPEC_CONSISTENCY_OUTPUT, result);
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// ============================================================
|
|
204
371
|
// Helper Functions
|
|
205
372
|
// ============================================================
|
|
206
373
|
async function readSkillOutput(changeId, filename) {
|
|
207
374
|
const cwd = process.cwd();
|
|
208
|
-
const outputPath = path.join(cwd,
|
|
375
|
+
const outputPath = path.join(cwd, 'zhuanspec', 'changes', changeId, 'review', filename);
|
|
209
376
|
try {
|
|
210
377
|
if (await FileSystemUtils.fileExists(outputPath)) {
|
|
211
378
|
const content = await FileSystemUtils.readFile(outputPath);
|
|
@@ -276,7 +443,7 @@ export async function writeSpecConsistencyOutput(changeId, output) {
|
|
|
276
443
|
export { writeSkillOutput };
|
|
277
444
|
async function writeSkillOutput(changeId, filename, data) {
|
|
278
445
|
const cwd = process.cwd();
|
|
279
|
-
const outputDir = path.join(cwd,
|
|
446
|
+
const outputDir = path.join(cwd, 'zhuanspec', 'changes', changeId, 'review');
|
|
280
447
|
await FileSystemUtils.createDirectory(outputDir);
|
|
281
448
|
const outputPath = path.join(outputDir, filename);
|
|
282
449
|
await FileSystemUtils.writeFile(outputPath, JSON.stringify(data, null, 2));
|
|
@@ -337,7 +504,9 @@ ${results.codeReview.pass && results.unitTest.pass && results.specConsistency.pa
|
|
|
337
504
|
// Write report to change directory
|
|
338
505
|
const cwd = process.cwd();
|
|
339
506
|
const changeDir = path.join(cwd, 'zhuanspec', 'changes', changeId);
|
|
340
|
-
const
|
|
507
|
+
const reviewDir = path.join(changeDir, 'review');
|
|
508
|
+
const reportPath = path.join(reviewDir, 'review-report.md');
|
|
509
|
+
await FileSystemUtils.createDirectory(reviewDir);
|
|
341
510
|
await FileSystemUtils.writeFile(reportPath, report);
|
|
342
511
|
return report;
|
|
343
512
|
}
|