@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.
Files changed (36) hide show
  1. package/dist/cli/hooks.js +8 -8
  2. package/dist/cli/index.js +1 -0
  3. package/dist/commands/design.d.ts +12 -2
  4. package/dist/commands/design.js +83 -10
  5. package/dist/commands/progress.js +11 -0
  6. package/dist/commands/review.d.ts +1 -25
  7. package/dist/commands/review.js +51 -408
  8. package/dist/core/completions/command-registry.js +4 -0
  9. package/dist/core/hooks/collect-knowledge.d.ts +13 -5
  10. package/dist/core/hooks/collect-knowledge.js +56 -164
  11. package/dist/core/hooks/deviation-check.js +159 -55
  12. package/dist/core/hooks/init.js +46 -0
  13. package/dist/core/hooks/post-apply.js +2 -0
  14. package/dist/core/hooks/pre-archive.js +13 -14
  15. package/dist/core/hooks/pre-review.d.ts +27 -0
  16. package/dist/core/hooks/pre-review.js +169 -0
  17. package/dist/core/hooks/record-progress.d.ts +25 -0
  18. package/dist/core/hooks/record-progress.js +88 -1
  19. package/dist/core/hooks/review-hooks.js +176 -7
  20. package/dist/core/hooks/review-orchestrator.js +58 -11
  21. package/dist/core/hooks/tdd-phase-hook.d.ts +59 -0
  22. package/dist/core/hooks/tdd-phase-hook.js +313 -0
  23. package/dist/core/init.d.ts +1 -0
  24. package/dist/core/init.js +45 -2
  25. package/dist/core/templates/agents-template.d.ts +1 -1
  26. package/dist/core/templates/agents-template.js +139 -23
  27. package/dist/core/templates/skill-templates.js +21 -1
  28. package/dist/core/templates/slash-command-templates.js +163 -63
  29. package/dist/core/templates/tasks-template.js +89 -0
  30. package/dist/core/update.d.ts +3 -0
  31. package/dist/core/update.js +85 -2
  32. package/dist/core/validation/strict-rules.d.ts +47 -0
  33. package/dist/core/validation/strict-rules.js +589 -42
  34. package/dist/utils/git-repo-detector.js +1 -1
  35. package/dist/utils/phase-utils.js +14 -1
  36. 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 reviewPath = path.join(changeDir, 'metrics', 'review.json');
21
- const testsPath = path.join(changeDir, 'metrics', 'tests.json');
22
- const reviewReportPath = path.join(changeDir, 'review-report.md');
23
- if (!(await FileSystemUtils.fileExists(reviewPath)) ||
24
- !(await FileSystemUtils.fileExists(testsPath)) ||
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/tests.json/review-report.md',
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 review = JSON.parse(await FileSystemUtils.readFile(reviewPath));
33
- const tests = JSON.parse(await FileSystemUtils.readFile(testsPath));
34
- const reviewPassed = Boolean(review.passed);
35
- const testPassed = Number(tests.failed ?? 1) === 0;
36
- const specConsistencyPassed = review?.checks?.specCompliance === true;
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: review=${reviewPassed}, tests=${testPassed}, specConsistency=${specConsistencyPassed}, critical=${critical}`,
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
- const output = skillOutput || await readSkillOutput(changeId, SPEC_CONSISTENCY_OUTPUT);
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, SKILL_OUTPUT_DIR, changeId, filename);
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, SKILL_OUTPUT_DIR, changeId);
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 reportPath = path.join(changeDir, 'review-report.md');
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
  }