@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.
@@ -6,7 +6,6 @@ export declare class ReviewCommand {
6
6
  }): Promise<void>;
7
7
  private selectChangeInteractively;
8
8
  private collectRequirementNames;
9
- private collectTouchedFiles;
10
9
  private collectTouchedFileContent;
11
10
  private runTests;
12
11
  private buildSummary;
@@ -14,6 +13,7 @@ export declare class ReviewCommand {
14
13
  private printSummary;
15
14
  /**
16
15
  * Fetch Sonar issues using MCP
16
+ * Uses the first detected repository's branch for SonarQube queries
17
17
  */
18
18
  private fetchSonarIssues;
19
19
  /**
@@ -2,6 +2,7 @@ import { execSync } from 'child_process';
2
2
  import { promises as fs, readdirSync, existsSync } from 'fs';
3
3
  import path from 'path';
4
4
  import { FileSystemUtils } from '../utils/file-system.js';
5
+ import { GitRepoDetector } from '../utils/git-repo-detector.js';
5
6
  import { mcp__sonar__get_Sonar_Query } from '../mcp/index.js';
6
7
  import { writeCodeReviewOutput, writeUnitTestOutput, writeSpecConsistencyOutput, codeReviewResultCheck, unitTestResultCheck, specConsistencyResultCheck, generateReviewReport, } from '../core/hooks/review-hooks.js';
7
8
  export class ReviewCommand {
@@ -13,8 +14,8 @@ export class ReviewCommand {
13
14
  }
14
15
  changeName = selected;
15
16
  }
16
- const repoRoot = process.cwd();
17
- const changeDir = path.join(repoRoot, 'zhuanspec', 'changes', changeName);
17
+ const cwd = process.cwd();
18
+ const changeDir = path.join(cwd, 'zhuanspec', 'changes', changeName);
18
19
  const metricsDir = path.join(changeDir, 'metrics');
19
20
  const proposalPath = path.join(changeDir, 'proposal.md');
20
21
  const tasksPath = path.join(changeDir, 'tasks.md');
@@ -24,10 +25,13 @@ export class ReviewCommand {
24
25
  if (!proposalExists || !tasksExists) {
25
26
  throw new Error(`Change '${changeName}' is not ready for review. Missing proposal.md or tasks.md`);
26
27
  }
28
+ // Detect all git repositories (parent + children)
29
+ const allRepos = GitRepoDetector.detectAllGitRepos(cwd);
27
30
  const issues = [];
28
31
  const requirementNames = await this.collectRequirementNames(changeSpecsDir);
29
- const touchedFiles = await this.collectTouchedFiles(repoRoot);
30
- const touchedContent = await this.collectTouchedFileContent(repoRoot, touchedFiles);
32
+ // Use GitRepoDetector to get all changed files across all detected repos
33
+ const touchedFiles = GitRepoDetector.getAllChanges(cwd).filter((file) => !file.startsWith('zhuanspec/changes/archive/'));
34
+ const touchedContent = await this.collectTouchedFileContent(cwd, touchedFiles);
31
35
  // Track whether every requirement has at least one weak signal in changed code.
32
36
  for (const req of requirementNames) {
33
37
  const normalized = req.toLowerCase();
@@ -50,9 +54,10 @@ export class ReviewCommand {
50
54
  });
51
55
  }
52
56
  // Very lightweight code-quality checks.
57
+ // touchedFiles now contains absolute paths from GitRepoDetector
53
58
  for (const file of touchedFiles) {
54
59
  if (file.endsWith('.ts') || (file.endsWith('.js') && !file.includes('node_modules'))) {
55
- const content = await fs.readFile(path.join(repoRoot, file), 'utf-8');
60
+ const content = await fs.readFile(file, 'utf-8');
56
61
  if (content.includes('TODO') || content.includes('FIXME')) {
57
62
  issues.push({
58
63
  severity: 'minor',
@@ -62,8 +67,8 @@ export class ReviewCommand {
62
67
  }
63
68
  }
64
69
  }
65
- // Sonar MCP integration - fetch issues from SonarQube
66
- const sonarIssues = await this.fetchSonarIssues(repoRoot);
70
+ // Sonar MCP integration - fetch issues from SonarQube (use first detected repo if available)
71
+ const sonarIssues = await this.fetchSonarIssues(allRepos);
67
72
  for (const issue of sonarIssues) {
68
73
  const severity = issue.type === 'BUG' || issue.type === 'VULNERABILITY' ? 'critical' : 'important';
69
74
  issues.push({
@@ -74,7 +79,7 @@ export class ReviewCommand {
74
79
  }
75
80
  // Scenario→Test mapping validation
76
81
  const scenarios = await this.collectScenarios(changeSpecsDir);
77
- const testFiles = await this.findTestFiles(repoRoot);
82
+ const testFiles = await this.findTestFiles(cwd);
78
83
  for (const scenario of scenarios) {
79
84
  const testPatterns = this.scenarioToTestMethodNames(scenario.name);
80
85
  const matched = testFiles.some(tf => testPatterns.some(p => tf.toLowerCase().includes(p.toLowerCase())));
@@ -87,7 +92,7 @@ export class ReviewCommand {
87
92
  }
88
93
  }
89
94
  const testCommand = options?.testCommand?.trim() || 'npm test';
90
- const testResult = this.runTests(repoRoot, testCommand);
95
+ const testResult = this.runTests(cwd, testCommand);
91
96
  const coverageThreshold = Number(options?.coverageThreshold ?? 80);
92
97
  if (testResult.coverage !== null && testResult.coverage < coverageThreshold) {
93
98
  issues.push({
@@ -221,23 +226,11 @@ export class ReviewCommand {
221
226
  await walk(changeSpecsDir);
222
227
  return Array.from(new Set(requirements));
223
228
  }
224
- async collectTouchedFiles(repoRoot) {
225
- try {
226
- const output = execSync('git diff --name-only', { cwd: repoRoot, encoding: 'utf-8' });
227
- return output
228
- .split('\n')
229
- .map((item) => item.trim())
230
- .filter((item) => item.length > 0 && !item.startsWith('zhuanspec/changes/archive/'));
231
- }
232
- catch {
233
- return [];
234
- }
235
- }
236
229
  async collectTouchedFileContent(repoRoot, files) {
237
230
  const result = [];
238
231
  for (const file of files) {
239
232
  try {
240
- const full = path.join(repoRoot, file);
233
+ const full = path.isAbsolute(file) ? file : path.join(repoRoot, file);
241
234
  const stat = await fs.stat(full);
242
235
  if (!stat.isFile())
243
236
  continue;
@@ -342,11 +335,18 @@ export class ReviewCommand {
342
335
  }
343
336
  /**
344
337
  * Fetch Sonar issues using MCP
338
+ * Uses the first detected repository's branch for SonarQube queries
345
339
  */
346
- async fetchSonarIssues(repoRoot) {
340
+ async fetchSonarIssues(allRepos) {
341
+ // If no repos detected, skip Sonar queries
342
+ if (allRepos.length === 0) {
343
+ return [];
344
+ }
347
345
  try {
346
+ // Use the first detected repository's branch
347
+ const primaryRepo = allRepos[0];
348
348
  const branch = execSync('git branch --show-current', {
349
- cwd: repoRoot,
349
+ cwd: primaryRepo,
350
350
  encoding: 'utf-8',
351
351
  stdio: ['pipe', 'pipe', 'ignore'],
352
352
  }).trim();
@@ -26,10 +26,6 @@ export declare class ValidateCommand {
26
26
  * Add a section skeleton to proposal.md
27
27
  */
28
28
  private addProposalSectionSkeleton;
29
- /**
30
- * Add review gate section to tasks.md
31
- */
32
- private addReviewGateToTasks;
33
29
  private printReport;
34
30
  private printNextSteps;
35
31
  /**
@@ -189,12 +189,6 @@ export class ValidateCommand {
189
189
  }
190
190
  continue;
191
191
  }
192
- // 2. Missing review gate - add to tasks.md
193
- if (issue.message.includes('review gate') || issue.message.includes('zhuanspec review')) {
194
- await this.addReviewGateToTasks(changeDir);
195
- fixed = true;
196
- continue;
197
- }
198
192
  // 4. Missing @skill tag - add @skill:none (existing logic)
199
193
  if (issue.message.includes('Skill Mapping') || issue.message.includes('@skill')) {
200
194
  const tasksFile = path.join(changeDir, 'tasks.md');
@@ -264,22 +258,6 @@ export class ValidateCommand {
264
258
  // Cannot read/write proposal.md
265
259
  }
266
260
  }
267
- /**
268
- * Add review gate section to tasks.md
269
- */
270
- async addReviewGateToTasks(changeDir) {
271
- const tasksPath = path.join(changeDir, 'tasks.md');
272
- try {
273
- let content = await fs.readFile(tasksPath, 'utf-8');
274
- if (!content.includes('zhuanspec review') && !content.includes('审查门禁')) {
275
- content = content.trimEnd() + '\n\n## 审查门禁\n\n- [ ] 执行 `zhuanspec review <change-id>` 并确认通过\n';
276
- await fs.writeFile(tasksPath, content, 'utf-8');
277
- }
278
- }
279
- catch {
280
- // Cannot read/write tasks.md
281
- }
282
- }
283
261
  printReport(type, id, report, durationMs, json, taskReport, opts) {
284
262
  if (json) {
285
263
  const out = {
@@ -9,6 +9,7 @@ import chalk from 'chalk';
9
9
  import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, } from './specs-apply.js';
10
10
  import { preArchiveHook } from './hooks/pre-archive.js';
11
11
  import { postArchiveHook } from './hooks/post-archive.js';
12
+ import { GitRepoDetector } from '../utils/git-repo-detector.js';
12
13
  const ARCH_REPO_URL = 'http://gitlab.zhuanspirit.com/zz-kf/spec_repo.git';
13
14
  const ARCH_REPO_BRANCH = 'spec_repo-feature-6612-2';
14
15
  export class ArchiveCommand {
@@ -25,12 +26,18 @@ export class ArchiveCommand {
25
26
  catch {
26
27
  throw new Error("未找到 ZhuanSpec 变更目录。请先运行 'zhuanspec init'。");
27
28
  }
29
+ const skipSpecs = options.skipSpecs === true;
28
30
  const businessDirection = await this.resolveBusinessDirection(zhuanspecDir, options.businessDirection);
29
- if (!businessDirection) {
30
- throw new Error('未解析到业务方向,无法执行归档前拉取与归档后回推。请使用 --business-direction 或在 zhuanspec/.business-direction、zhuanspec/project.md 中配置。');
31
+ if (!businessDirection && !skipSpecs) {
32
+ throw new Error('未解析到业务方向,无法执行归档前拉取与归档后回推。请使用 --business-direction 或在 zhuanspec/.business-direction、zhuanspec/project.md 中配置。如果是工具类/纯本地变更,请使用 --skip-specs 跳过远端同步。');
33
+ }
34
+ if (businessDirection) {
35
+ console.log(chalk.gray(`业务方向:${businessDirection}`));
36
+ await this.refreshBusinessTemplateFromRemote(zhuanspecDir, businessDirection);
37
+ }
38
+ else {
39
+ console.log(chalk.gray('--skip-specs 模式:跳过远端模板拉取。'));
31
40
  }
32
- console.log(chalk.gray(`业务方向:${businessDirection}`));
33
- await this.refreshBusinessTemplateFromRemote(zhuanspecDir, businessDirection);
34
41
  // Get change name interactively if not provided
35
42
  if (!changeName) {
36
43
  const selectedChange = await this.selectChange(changesDir);
@@ -255,7 +262,12 @@ export class ArchiveCommand {
255
262
  await postArchiveHook({ archived: archiveName, json: false });
256
263
  // Push archive result (specs + archive + knowledge) to remote
257
264
  console.log(chalk.cyan('\n━━━ 推送到远端 ━━━'));
258
- await this.pushArchiveResultToRemote(zhuanspecDir, businessDirection);
265
+ if (businessDirection) {
266
+ await this.pushArchiveResultToRemote(zhuanspecDir, businessDirection);
267
+ }
268
+ else {
269
+ console.log(chalk.gray(' --skip-specs 模式:跳过远端推送。'));
270
+ }
259
271
  // Output feedback link after archive completes
260
272
  console.log(chalk.cyan('\n━━━ 使用反馈 ━━━'));
261
273
  console.log(chalk.yellow(' 请填写使用反馈,帮助我们改进 ZhuanSpec 工作流:'));
@@ -591,13 +603,8 @@ export class ArchiveCommand {
591
603
  });
592
604
  }
593
605
  hasGitChanges(repoDir) {
594
- try {
595
- const output = this.git(repoDir, 'git status --porcelain');
596
- return output.trim().length > 0;
597
- }
598
- catch {
599
- return false;
600
- }
606
+ const diffResult = GitRepoDetector.getGitDiff(repoDir);
607
+ return diffResult.changedFiles.length > 0;
601
608
  }
602
609
  async copyDirectoryContentsIfExists(sourceDir, targetDir) {
603
610
  try {
@@ -12,8 +12,8 @@
12
12
  * - Out of scope → block with deviation handling options
13
13
  */
14
14
  import path from 'path';
15
- import { execSync } from 'child_process';
16
15
  import { FileSystemUtils } from '../../utils/file-system.js';
16
+ import { GitRepoDetector } from '../../utils/git-repo-detector.js';
17
17
  import { checkPreApplyConditions } from './pre-apply.js';
18
18
  import { recordHookTrigger } from './record-progress.js';
19
19
  // Pitfall keywords for knowledge collection
@@ -113,7 +113,7 @@ async function runDeviationCheck(filePath, trigger, promptText, _options) {
113
113
  // Resume check: when session is resumed in apply phase, do a one-time deviation scan.
114
114
  if (phase === 'apply' && trigger === 'resume') {
115
115
  const estimatedFiles = await parseEstimatedChanges(proposalPath);
116
- const gitDiffFiles = await parseGitChangedFiles(cwd);
116
+ const gitDiffFiles = GitRepoDetector.getAllChanges(cwd);
117
117
  const outOfScope = gitDiffFiles.filter((file) => {
118
118
  if (file.includes('zhuanspec/'))
119
119
  return false;
@@ -292,34 +292,6 @@ ZhuanSpec Iron Law 3: Reverse Sync - When deviation is found, update proposal fi
292
292
  continue: true,
293
293
  };
294
294
  }
295
- async function parseGitChangedFiles(repoRoot) {
296
- try {
297
- const output = execSync('git diff --name-only', {
298
- cwd: repoRoot,
299
- encoding: 'utf-8',
300
- stdio: ['pipe', 'pipe', 'ignore'],
301
- });
302
- return output.split('\n').map((line) => line.trim()).filter(Boolean);
303
- }
304
- catch {
305
- // fall through
306
- }
307
- try {
308
- const status = execSync('git status --porcelain', {
309
- cwd: repoRoot,
310
- encoding: 'utf-8',
311
- stdio: ['pipe', 'pipe', 'ignore'],
312
- });
313
- return status
314
- .split('\n')
315
- .map((line) => line.replace(/^\s*[A-Z?]+\s+/, '').trim())
316
- .filter(Boolean);
317
- }
318
- catch {
319
- // ignore
320
- }
321
- return [];
322
- }
323
295
  async function parseEstimatedChanges(proposalPath) {
324
296
  try {
325
297
  const content = await FileSystemUtils.readFile(proposalPath);
@@ -28,6 +28,7 @@ export declare function handleDeviation(context: DeviationContext): Promise<Devi
28
28
  * Complete deviation handling after user confirms
29
29
  *
30
30
  * @param deviationId - The deviation ID to mark as resolved
31
+ * @param changeId - The change ID
31
32
  * @param specChanged - List of spec files that were changed
32
33
  * @param codeChanged - List of code files that were changed
33
34
  */
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import path from 'path';
10
10
  import { FileSystemUtils } from '../../utils/file-system.js';
11
- import { getBeijingTime } from './record-progress.js';
11
+ import { getBeijingTime, atomicWriteJson, recoverProgressJsonForWrite, } from './record-progress.js';
12
12
  /**
13
13
  * Handle deviation correction flow
14
14
  *
@@ -20,24 +20,50 @@ export async function handleDeviation(context) {
20
20
  const zhuanspecDir = path.join(cwd, 'zhuanspec');
21
21
  const changeDir = path.join(zhuanspecDir, 'changes', context.changeId);
22
22
  const progressPath = path.join(changeDir, 'metrics', 'progress.json');
23
+ const timestamp = getBeijingTime();
23
24
  // Record deviation in progress.json
24
25
  const deviationId = `dev-${Date.now()}`;
25
26
  const deviationRecord = {
26
27
  deviationId,
27
- detectedAt: getBeijingTime(),
28
+ detectedAt: timestamp,
28
29
  triggerType: 'user-input',
29
30
  userInput: context.userInput.slice(0, 500),
30
31
  specChanged: [],
31
32
  codeChanged: [],
32
33
  resolvedAt: undefined,
33
34
  };
34
- // Update progress.json with deviation record
35
+ // Update progress.json with deviation record (using recovery strategy)
35
36
  if (await FileSystemUtils.fileExists(progressPath)) {
36
- const progressData = JSON.parse(await FileSystemUtils.readFile(progressPath));
37
- progressData.deviationRecords = progressData.deviationRecords || [];
38
- progressData.deviationRecords.push(deviationRecord);
39
- progressData.deviationCount = (progressData.deviationCount || 0) + 1;
40
- await FileSystemUtils.writeFile(progressPath, JSON.stringify(progressData, null, 2));
37
+ const recovered = await recoverProgressJsonForWrite(progressPath);
38
+ if (recovered) {
39
+ const progressData = recovered;
40
+ // Fill missing fields
41
+ progressData.deviationRecords = progressData.deviationRecords || [];
42
+ progressData.deviationCount = progressData.deviationCount || 0;
43
+ progressData.toolCalls = progressData.toolCalls || [];
44
+ progressData.skillCalls = progressData.skillCalls || [];
45
+ progressData.hookTriggers = progressData.hookTriggers || [];
46
+ progressData.clarifications = progressData.clarifications || [];
47
+ progressData.filesModified = progressData.filesModified || [];
48
+ progressData.completedTasks = progressData.completedTasks || [];
49
+ progressData.phaseTransitions = progressData.phaseTransitions || [];
50
+ progressData.phaseDurations = progressData.phaseDurations || [];
51
+ progressData.reviewStats = progressData.reviewStats || {
52
+ loopCount: 0,
53
+ criticalFixes: 0,
54
+ testFixes: 0,
55
+ consistencyFixes: 0,
56
+ };
57
+ progressData.stats = progressData.stats || {
58
+ tokenUsageTotal: 0,
59
+ contextLoad: 0,
60
+ durationMs: { propose: 0, apply: 0, review: 0, archive: 0 },
61
+ };
62
+ progressData.deviationRecords.push(deviationRecord);
63
+ progressData.deviationCount += 1;
64
+ progressData.lastUpdatedAt = timestamp;
65
+ await atomicWriteJson(progressPath, progressData);
66
+ }
41
67
  }
42
68
  // Step 1: Modify spec file first
43
69
  return {
@@ -49,6 +75,7 @@ export async function handleDeviation(context) {
49
75
  * Complete deviation handling after user confirms
50
76
  *
51
77
  * @param deviationId - The deviation ID to mark as resolved
78
+ * @param changeId - The change ID
52
79
  * @param specChanged - List of spec files that were changed
53
80
  * @param codeChanged - List of code files that were changed
54
81
  */
@@ -57,14 +84,40 @@ export async function completeDeviation(deviationId, changeId, specChanged, code
57
84
  const zhuanspecDir = path.join(cwd, 'zhuanspec');
58
85
  const changeDir = path.join(zhuanspecDir, 'changes', changeId);
59
86
  const progressPath = path.join(changeDir, 'metrics', 'progress.json');
87
+ const timestamp = getBeijingTime();
60
88
  if (await FileSystemUtils.fileExists(progressPath)) {
61
- const progressData = JSON.parse(await FileSystemUtils.readFile(progressPath));
62
- const deviation = progressData.deviationRecords?.find((d) => d.deviationId === deviationId);
63
- if (deviation) {
64
- deviation.specChanged = specChanged;
65
- deviation.codeChanged = codeChanged;
66
- deviation.resolvedAt = getBeijingTime();
67
- await FileSystemUtils.writeFile(progressPath, JSON.stringify(progressData, null, 2));
89
+ const recovered = await recoverProgressJsonForWrite(progressPath);
90
+ if (recovered) {
91
+ const progressData = recovered;
92
+ // Fill missing fields
93
+ progressData.deviationRecords = progressData.deviationRecords || [];
94
+ progressData.toolCalls = progressData.toolCalls || [];
95
+ progressData.skillCalls = progressData.skillCalls || [];
96
+ progressData.hookTriggers = progressData.hookTriggers || [];
97
+ progressData.clarifications = progressData.clarifications || [];
98
+ progressData.filesModified = progressData.filesModified || [];
99
+ progressData.completedTasks = progressData.completedTasks || [];
100
+ progressData.phaseTransitions = progressData.phaseTransitions || [];
101
+ progressData.phaseDurations = progressData.phaseDurations || [];
102
+ progressData.reviewStats = progressData.reviewStats || {
103
+ loopCount: 0,
104
+ criticalFixes: 0,
105
+ testFixes: 0,
106
+ consistencyFixes: 0,
107
+ };
108
+ progressData.stats = progressData.stats || {
109
+ tokenUsageTotal: 0,
110
+ contextLoad: 0,
111
+ durationMs: { propose: 0, apply: 0, review: 0, archive: 0 },
112
+ };
113
+ const deviation = progressData.deviationRecords.find((d) => d.deviationId === deviationId);
114
+ if (deviation) {
115
+ deviation.specChanged = specChanged;
116
+ deviation.codeChanged = codeChanged;
117
+ deviation.resolvedAt = timestamp;
118
+ progressData.lastUpdatedAt = timestamp;
119
+ await atomicWriteJson(progressPath, progressData);
120
+ }
68
121
  }
69
122
  }
70
123
  }
@@ -9,7 +9,8 @@
9
9
  import path from 'path';
10
10
  import { execSync } from 'child_process';
11
11
  import { FileSystemUtils } from '../../utils/file-system.js';
12
- import { getBeijingTime } from './record-progress.js';
12
+ import { GitRepoDetector } from '../../utils/git-repo-detector.js';
13
+ import { getBeijingTime, atomicWriteJson, recoverProgressJsonForWrite, } from './record-progress.js';
13
14
  export async function postApplyHook(options) {
14
15
  const output = await runPostApplyHook(options.change || '');
15
16
  if (options.json) {
@@ -26,7 +27,10 @@ async function runPostApplyHook(changeId) {
26
27
  systemMessage: 'Post-apply hook skipped: no change provided',
27
28
  };
28
29
  }
29
- const repoRoot = process.cwd();
30
+ // Use GitRepoDetector to find the repository root
31
+ const zhuanspecDir = path.join(process.cwd(), 'zhuanspec');
32
+ const detectedRepoRoot = GitRepoDetector.findParentGitRepo(zhuanspecDir);
33
+ const repoRoot = detectedRepoRoot || process.cwd();
30
34
  const changeDir = path.join(repoRoot, 'zhuanspec', 'changes', changeId);
31
35
  return await executePostApply(changeDir, repoRoot);
32
36
  }
@@ -100,34 +104,15 @@ async function detectDeviation(changeDir, repoRoot) {
100
104
  catch {
101
105
  // Cannot read proposal
102
106
  }
103
- // Get actual changed files
107
+ // Get actual changed files using GitRepoDetector
108
+ const zhuanspecDir = path.join(repoRoot, 'zhuanspec');
104
109
  let actualFiles = [];
105
110
  try {
106
- const output = execSync('git diff --name-only HEAD~1 HEAD', {
107
- cwd: repoRoot,
108
- encoding: 'utf-8',
109
- stdio: ['pipe', 'pipe', 'ignore'],
110
- });
111
- actualFiles = output.trim().split('\n').filter(Boolean);
111
+ actualFiles = GitRepoDetector.getAllChanges(repoRoot);
112
112
  }
113
113
  catch {
114
- // Try git status for uncommitted changes
115
- try {
116
- const statusOutput = execSync('git status --porcelain', {
117
- cwd: repoRoot,
118
- encoding: 'utf-8',
119
- stdio: ['pipe', 'pipe', 'ignore'],
120
- });
121
- actualFiles = statusOutput
122
- .trim()
123
- .split('\n')
124
- .map(line => line.replace(/^\s*[A-Z?]+\s+/, '').trim())
125
- .filter(Boolean);
126
- }
127
- catch {
128
- // No git info available
129
- return { detected: false };
130
- }
114
+ // No git info available
115
+ return { detected: false };
131
116
  }
132
117
  // Find files outside proposal scope (excluding zhuanspec internal files)
133
118
  const outOfScopeFiles = [];
@@ -145,29 +130,107 @@ async function detectDeviation(changeDir, repoRoot) {
145
130
  };
146
131
  }
147
132
  /**
148
- * Update progress.json to track phase transitions
133
+ * Update progress.json to track phase transitions (with data preservation)
149
134
  */
150
135
  async function updateProgressJson(changeDir, event) {
151
136
  const metricsDir = path.join(changeDir, 'metrics');
152
137
  const progressPath = path.join(metricsDir, 'progress.json');
138
+ const timestamp = getBeijingTime();
153
139
  await FileSystemUtils.createDirectory(metricsDir);
154
- let progress = {};
155
- try {
156
- if (await FileSystemUtils.fileExists(progressPath)) {
157
- const content = await FileSystemUtils.readFile(progressPath);
158
- progress = JSON.parse(content);
140
+ let progress;
141
+ // Use three-layer recovery strategy to preserve all data
142
+ if (await FileSystemUtils.fileExists(progressPath)) {
143
+ const recovered = await recoverProgressJsonForWrite(progressPath);
144
+ if (recovered) {
145
+ progress = recovered;
146
+ // Fill missing fields with defaults
147
+ progress.toolCalls = progress.toolCalls || [];
148
+ progress.skillCalls = progress.skillCalls || [];
149
+ progress.hookTriggers = progress.hookTriggers || [];
150
+ progress.clarifications = progress.clarifications || [];
151
+ progress.filesModified = progress.filesModified || [];
152
+ progress.completedTasks = progress.completedTasks || [];
153
+ progress.deviationRecords = progress.deviationRecords || [];
154
+ progress.phaseTransitions = progress.phaseTransitions || [];
155
+ progress.phaseDurations = progress.phaseDurations || [];
156
+ progress.reviewStats = progress.reviewStats || {
157
+ loopCount: 0,
158
+ criticalFixes: 0,
159
+ testFixes: 0,
160
+ consistencyFixes: 0,
161
+ };
162
+ progress.stats = progress.stats || {
163
+ tokenUsageTotal: 0,
164
+ contextLoad: 0,
165
+ durationMs: { propose: 0, apply: 0, review: 0, archive: 0 },
166
+ };
167
+ }
168
+ else {
169
+ // Recovery failed - create minimal valid structure
170
+ progress = {
171
+ changeId: path.basename(changeDir),
172
+ sessionId: '',
173
+ startedAt: timestamp,
174
+ lastUpdatedAt: timestamp,
175
+ phase: 'apply',
176
+ currentNode: 'apply',
177
+ currentTask: '',
178
+ completedTasks: [],
179
+ totalTasks: 0,
180
+ toolCalls: [],
181
+ skillCalls: [],
182
+ hookTriggers: [],
183
+ clarifications: [],
184
+ filesModified: [],
185
+ linesAdded: 0,
186
+ linesRemoved: 0,
187
+ deviationCount: 0,
188
+ deviationRecords: [],
189
+ reviewStats: { loopCount: 0, criticalFixes: 0, testFixes: 0, consistencyFixes: 0 },
190
+ phaseTransitions: [],
191
+ phaseDurations: [],
192
+ stats: { tokenUsageTotal: 0, contextLoad: 0, durationMs: { propose: 0, apply: 0, review: 0, archive: 0 } },
193
+ };
159
194
  }
160
195
  }
161
- catch {
162
- progress = {};
196
+ else {
197
+ // No existing file - create new structure
198
+ progress = {
199
+ changeId: path.basename(changeDir),
200
+ sessionId: '',
201
+ startedAt: timestamp,
202
+ lastUpdatedAt: timestamp,
203
+ phase: 'apply',
204
+ currentNode: 'apply',
205
+ currentTask: '',
206
+ completedTasks: [],
207
+ totalTasks: 0,
208
+ toolCalls: [],
209
+ skillCalls: [],
210
+ hookTriggers: [],
211
+ clarifications: [],
212
+ filesModified: [],
213
+ linesAdded: 0,
214
+ linesRemoved: 0,
215
+ deviationCount: 0,
216
+ deviationRecords: [],
217
+ reviewStats: { loopCount: 0, criticalFixes: 0, testFixes: 0, consistencyFixes: 0 },
218
+ phaseTransitions: [],
219
+ phaseDurations: [],
220
+ stats: { tokenUsageTotal: 0, contextLoad: 0, durationMs: { propose: 0, apply: 0, review: 0, archive: 0 } },
221
+ };
222
+ }
223
+ // Add event to events array (optional tracking)
224
+ if (!progress.events) {
225
+ progress.events = [];
163
226
  }
164
- progress.lastEvent = event;
165
- progress.lastUpdatedAt = getBeijingTime();
166
- progress.events = progress.events || [];
167
227
  progress.events.push({
168
228
  event,
169
- timestamp: getBeijingTime(),
229
+ timestamp,
170
230
  });
171
- await FileSystemUtils.writeFile(progressPath, JSON.stringify(progress, null, 2));
231
+ progress.lastEvent = event;
232
+ progress.lastUpdatedAt = timestamp;
233
+ // Use atomic write to prevent corruption
234
+ await atomicWriteJson(progressPath, progress);
172
235
  }
173
236
  //# sourceMappingURL=post-apply.js.map