@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.
- 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 +19 -12
- 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 +86 -16
- package/dist/core/hooks/summarize.js +29 -10
- 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
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { readdirSync, existsSync, realpathSync } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { select, input } from '@inquirer/prompts';
|
|
5
|
+
/**
|
|
6
|
+
* Git repository detector utilities for finding git repos in a directory hierarchy
|
|
7
|
+
*/
|
|
8
|
+
export class GitRepoDetector {
|
|
9
|
+
/**
|
|
10
|
+
* Finds the parent git repository by traversing upward from the given directory.
|
|
11
|
+
*
|
|
12
|
+
* Uses `git rev-parse --show-toplevel` to determine the repository root.
|
|
13
|
+
* Returns null if the given directory is not inside a git repository.
|
|
14
|
+
*
|
|
15
|
+
* @param zhuanspecDir - The directory to start searching from (typically the zhuanspec directory)
|
|
16
|
+
* @returns The absolute path to the git repository root, or null if not found
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const repo = GitRepoDetector.findParentGitRepo('/path/to/project/zhuanspec');
|
|
20
|
+
* // Returns '/path/to/project' if it's a git repo, or null otherwise
|
|
21
|
+
*/
|
|
22
|
+
static findParentGitRepo(zhuanspecDir) {
|
|
23
|
+
try {
|
|
24
|
+
const output = execSync('git rev-parse --show-toplevel', {
|
|
25
|
+
cwd: zhuanspecDir,
|
|
26
|
+
encoding: 'utf-8',
|
|
27
|
+
stdio: ['pipe', 'pipe', 'ignore'], // Suppress stderr to avoid console pollution
|
|
28
|
+
});
|
|
29
|
+
const repoPath = output.trim();
|
|
30
|
+
if (repoPath) {
|
|
31
|
+
// Resolve symbolic links to get the real path
|
|
32
|
+
return realpathSync(repoPath);
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Git command failed - directory is not inside a git repository
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Finds all child git repositories by scanning subdirectories for .git directories.
|
|
43
|
+
*
|
|
44
|
+
* Scans the given directory and its subdirectories up to the specified depth,
|
|
45
|
+
* looking for directories that contain a `.git` subdirectory.
|
|
46
|
+
*
|
|
47
|
+
* @param zhuanspecDir - The directory to start scanning from
|
|
48
|
+
* @param maxDepth - Maximum depth to scan (default: 2)
|
|
49
|
+
* @returns Array of absolute paths to discovered git repository roots
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* const repos = GitRepoDetector.findChildGitRepos('/path/to/project', 3);
|
|
53
|
+
* // Returns ['/path/to/project/subdir1', '/path/to/project/subdir2'] if they have .git
|
|
54
|
+
*/
|
|
55
|
+
static findChildGitRepos(zhuanspecDir, maxDepth = 2) {
|
|
56
|
+
const discoveredRepos = [];
|
|
57
|
+
/**
|
|
58
|
+
* Recursively scans directories for .git folders
|
|
59
|
+
*/
|
|
60
|
+
const scanDirectory = (dir, currentDepth) => {
|
|
61
|
+
if (currentDepth > maxDepth) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (!entry.isDirectory()) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const subDirPath = path.join(dir, entry.name);
|
|
71
|
+
// Check if this directory contains a .git folder
|
|
72
|
+
const gitDir = path.join(subDirPath, '.git');
|
|
73
|
+
if (existsSync(gitDir)) {
|
|
74
|
+
// Resolve symbolic links to get the real path
|
|
75
|
+
const realPath = realpathSync(subDirPath);
|
|
76
|
+
discoveredRepos.push(realPath);
|
|
77
|
+
}
|
|
78
|
+
// Continue scanning subdirectories (depth + 1)
|
|
79
|
+
scanDirectory(subDirPath, currentDepth + 1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Ignore errors when reading directories (permission issues, etc.)
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
scanDirectory(zhuanspecDir, 1);
|
|
87
|
+
return discoveredRepos;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Detects all git repositories by combining upward and downward search results.
|
|
91
|
+
*
|
|
92
|
+
* Merges the parent git repository (found by traversing upward) with any
|
|
93
|
+
* child repositories (found by scanning downward). Results are deduplicated
|
|
94
|
+
* using a Set to ensure each repository appears only once.
|
|
95
|
+
*
|
|
96
|
+
* @param zhuanspecDir - The directory to start detection from
|
|
97
|
+
* @returns Array of absolute paths to all detected git repository roots
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* const allRepos = GitRepoDetector.detectAllGitRepos('/path/to/project/zhuanspec');
|
|
101
|
+
* // Returns ['/path/to/project', '/path/to/project/submodule1', ...]
|
|
102
|
+
*/
|
|
103
|
+
static detectAllGitRepos(zhuanspecDir) {
|
|
104
|
+
const allRepos = new Set();
|
|
105
|
+
// Find parent repository (traverse upward)
|
|
106
|
+
const parentRepo = this.findParentGitRepo(zhuanspecDir);
|
|
107
|
+
if (parentRepo) {
|
|
108
|
+
allRepos.add(parentRepo);
|
|
109
|
+
}
|
|
110
|
+
// Find child repositories (scan downward)
|
|
111
|
+
const childRepos = this.findChildGitRepos(zhuanspecDir);
|
|
112
|
+
for (const repo of childRepos) {
|
|
113
|
+
allRepos.add(repo);
|
|
114
|
+
}
|
|
115
|
+
return Array.from(allRepos);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Merges git diff results from multiple repositories, deduplicating file paths.
|
|
119
|
+
*
|
|
120
|
+
* Takes the changed files from each repository and combines them into a single
|
|
121
|
+
* deduplicated list. File paths are normalized to be relative to their respective
|
|
122
|
+
* repository roots.
|
|
123
|
+
*
|
|
124
|
+
* @param results - Array of GitDiffResult objects from different repositories
|
|
125
|
+
* @returns Deduplicated array of file paths (relative to respective repo roots)
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* const merged = GitRepoDetector.mergeGitDiffs([
|
|
129
|
+
* { repoPath: '/repo1', changedFiles: ['src/a.ts', 'src/b.ts'] },
|
|
130
|
+
* { repoPath: '/repo2', changedFiles: ['src/a.ts', 'src/c.ts'] }
|
|
131
|
+
* ]);
|
|
132
|
+
* // Returns ['src/a.ts', 'src/b.ts', 'src/c.ts'] (deduplicated)
|
|
133
|
+
*/
|
|
134
|
+
static mergeGitDiffs(results) {
|
|
135
|
+
const allFiles = new Set();
|
|
136
|
+
for (const result of results) {
|
|
137
|
+
for (const file of result.changedFiles) {
|
|
138
|
+
// Add each file path to the Set for automatic deduplication
|
|
139
|
+
allFiles.add(file);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return Array.from(allFiles);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Gets the list of changed files in a single repository.
|
|
146
|
+
*
|
|
147
|
+
* Executes `git diff --name-only` to get the list of modified files.
|
|
148
|
+
* Returns relative paths based on the repository root.
|
|
149
|
+
*
|
|
150
|
+
* @param repoPath - Absolute path to the git repository root
|
|
151
|
+
* @returns GitDiffResult containing the repo path and changed files
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* const diff = GitRepoDetector.getGitDiff('/path/to/repo');
|
|
155
|
+
* // Returns { repoPath: '/path/to/repo', changedFiles: ['src/file1.ts', 'src/file2.ts'] }
|
|
156
|
+
*/
|
|
157
|
+
static getGitDiff(repoPath) {
|
|
158
|
+
const allFiles = new Set();
|
|
159
|
+
// Get tracked changes (staged + unstaged) against HEAD
|
|
160
|
+
try {
|
|
161
|
+
const headDiff = execSync('git diff --name-only HEAD', {
|
|
162
|
+
cwd: repoPath,
|
|
163
|
+
encoding: 'utf-8',
|
|
164
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
165
|
+
});
|
|
166
|
+
headDiff.split('\n')
|
|
167
|
+
.map((line) => line.trim())
|
|
168
|
+
.filter((line) => line.length > 0)
|
|
169
|
+
.map((file) => path.resolve(repoPath, file))
|
|
170
|
+
.forEach((f) => allFiles.add(f));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// HEAD may not exist (empty repo) — fall through to status
|
|
174
|
+
}
|
|
175
|
+
// Also capture untracked new files via git status --porcelain
|
|
176
|
+
try {
|
|
177
|
+
const statusOutput = execSync('git status --porcelain', {
|
|
178
|
+
cwd: repoPath,
|
|
179
|
+
encoding: 'utf-8',
|
|
180
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
181
|
+
});
|
|
182
|
+
statusOutput.split('\n')
|
|
183
|
+
.map((line) => line.replace(/^\s*[A-Z?]+\s+/, '').trim())
|
|
184
|
+
.filter((line) => line.length > 0)
|
|
185
|
+
.map((file) => path.resolve(repoPath, file))
|
|
186
|
+
.forEach((f) => allFiles.add(f));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Git status failed
|
|
190
|
+
}
|
|
191
|
+
if (allFiles.size === 0) {
|
|
192
|
+
return { repoPath, changedFiles: [] };
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
repoPath,
|
|
196
|
+
changedFiles: Array.from(allFiles),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Gets all changed files across all detected repositories.
|
|
201
|
+
*
|
|
202
|
+
* Combines detectAllGitRepos with getGitDiff and mergeGitDiffs to provide
|
|
203
|
+
* a complete picture of all changes in the repository hierarchy.
|
|
204
|
+
*
|
|
205
|
+
* @param zhuanspecDir - The directory to start detection from
|
|
206
|
+
* @returns Deduplicated array of all changed file paths across all repos
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* const allChanges = GitRepoDetector.getAllChanges('/path/to/project/zhuanspec');
|
|
210
|
+
* // Returns ['src/a.ts', 'submodule/src/b.ts', ...]
|
|
211
|
+
*/
|
|
212
|
+
static getAllChanges(zhuanspecDir) {
|
|
213
|
+
const allRepos = this.detectAllGitRepos(zhuanspecDir);
|
|
214
|
+
const diffResults = [];
|
|
215
|
+
for (const repo of allRepos) {
|
|
216
|
+
const diff = this.getGitDiff(repo);
|
|
217
|
+
if (diff.changedFiles.length > 0) {
|
|
218
|
+
diffResults.push(diff);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return this.mergeGitDiffs(diffResults);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Prompts user for action when no git repository is detected.
|
|
226
|
+
*
|
|
227
|
+
* Provides three options:
|
|
228
|
+
* - Manual input: Enter a repository path manually
|
|
229
|
+
* - Skip: Continue execution without git checks
|
|
230
|
+
* - Abort: Terminate the current command
|
|
231
|
+
*
|
|
232
|
+
* @returns
|
|
233
|
+
* - string: The repository path entered by user (when 'manual' is selected)
|
|
234
|
+
* - 'skip': User chose to skip git checks
|
|
235
|
+
* - null: User chose to abort the command
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* const result = await promptForGitRepoOrSkip();
|
|
239
|
+
* if (result === null) {
|
|
240
|
+
* console.log('Command aborted');
|
|
241
|
+
* process.exit(1);
|
|
242
|
+
* } else if (result === 'skip') {
|
|
243
|
+
* console.log('Proceeding without git checks');
|
|
244
|
+
* } else {
|
|
245
|
+
* console.log(`Using repo: ${result}`);
|
|
246
|
+
* }
|
|
247
|
+
*/
|
|
248
|
+
export async function promptForGitRepoOrSkip() {
|
|
249
|
+
const action = await select({
|
|
250
|
+
message: 'No git repository detected. What would you like to do?',
|
|
251
|
+
choices: [
|
|
252
|
+
{ name: 'Enter repository path manually', value: 'manual' },
|
|
253
|
+
{ name: 'Skip git checks and continue', value: 'skip' },
|
|
254
|
+
{ name: 'Abort command', value: 'abort' },
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
if (action === 'abort') {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
if (action === 'skip') {
|
|
261
|
+
return 'skip';
|
|
262
|
+
}
|
|
263
|
+
// action === 'manual': prompt for repository path
|
|
264
|
+
const repoPath = await input({
|
|
265
|
+
message: 'Enter the git repository path:',
|
|
266
|
+
validate: (value) => {
|
|
267
|
+
if (!value.trim()) {
|
|
268
|
+
return 'Repository path cannot be empty';
|
|
269
|
+
}
|
|
270
|
+
return true;
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
return repoPath.trim();
|
|
274
|
+
}
|
|
275
|
+
//# sourceMappingURL=git-repo-detector.js.map
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { validateChangeName, createChange } from './change-utils.js';
|
|
2
2
|
export type { ValidationResult, CreateChangeOptions } from './change-utils.js';
|
|
3
3
|
export { readChangeMetadata, writeChangeMetadata, resolveSchemaForChange, validateSchemaName, ChangeMetadataError, } from './change-metadata.js';
|
|
4
|
+
export { GitRepoDetector } from './git-repo-detector.js';
|
|
5
|
+
export type { GitDiffResult } from './git-repo-detector.js';
|
|
4
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/utils/index.js
CHANGED
|
@@ -2,4 +2,6 @@
|
|
|
2
2
|
export { validateChangeName, createChange } from './change-utils.js';
|
|
3
3
|
// Change metadata utilities
|
|
4
4
|
export { readChangeMetadata, writeChangeMetadata, resolveSchemaForChange, validateSchemaName, ChangeMetadataError, } from './change-metadata.js';
|
|
5
|
+
// Git repository detection utilities
|
|
6
|
+
export { GitRepoDetector } from './git-repo-detector.js';
|
|
5
7
|
//# sourceMappingURL=index.js.map
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides:
|
|
5
5
|
* - Phase type definition
|
|
6
|
-
* - setPhase: Write phase to progress.json
|
|
6
|
+
* - setPhase: Write phase to progress.json (with data preservation)
|
|
7
7
|
* - getPhaseFromProgress: Read phase from progress.json
|
|
8
8
|
* - getCurrentPhase: Comprehensive phase detection
|
|
9
9
|
*/
|
|
@@ -11,8 +11,8 @@ export type Phase = 'idle' | 'techDesign' | 'propose' | 'apply' | 'review' | 'ar
|
|
|
11
11
|
export declare const PHASE_ORDER: Phase[];
|
|
12
12
|
export declare const PHASE_MARKERS: Partial<Record<Phase, string>>;
|
|
13
13
|
/**
|
|
14
|
-
* Set phase in progress.json
|
|
15
|
-
*
|
|
14
|
+
* Set phase in progress.json with full data preservation
|
|
15
|
+
* Uses three-layer recovery strategy to prevent data loss
|
|
16
16
|
*/
|
|
17
17
|
export declare function setPhase(changeDir: string, phase: Phase): Promise<void>;
|
|
18
18
|
/**
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides:
|
|
5
5
|
* - Phase type definition
|
|
6
|
-
* - setPhase: Write phase to progress.json
|
|
6
|
+
* - setPhase: Write phase to progress.json (with data preservation)
|
|
7
7
|
* - getPhaseFromProgress: Read phase from progress.json
|
|
8
8
|
* - getCurrentPhase: Comprehensive phase detection
|
|
9
9
|
*/
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import { FileSystemUtils } from './file-system.js';
|
|
12
|
+
import { recoverProgressJsonForWrite, getBeijingTime, atomicWriteJson, } from '../core/hooks/record-progress.js';
|
|
12
13
|
export const PHASE_ORDER = ['idle', 'techDesign', 'propose', 'apply', 'review', 'archive'];
|
|
13
14
|
export const PHASE_MARKERS = {
|
|
14
15
|
techDesign: '.tech-design',
|
|
@@ -16,28 +17,150 @@ export const PHASE_MARKERS = {
|
|
|
16
17
|
review: 'review-report.md',
|
|
17
18
|
};
|
|
18
19
|
/**
|
|
19
|
-
* Set phase in progress.json
|
|
20
|
-
*
|
|
20
|
+
* Set phase in progress.json with full data preservation
|
|
21
|
+
* Uses three-layer recovery strategy to prevent data loss
|
|
21
22
|
*/
|
|
22
23
|
export async function setPhase(changeDir, phase) {
|
|
23
24
|
const metricsDir = path.join(changeDir, 'metrics');
|
|
24
25
|
await FileSystemUtils.createDirectory(metricsDir);
|
|
25
26
|
const progressPath = path.join(metricsDir, 'progress.json');
|
|
27
|
+
const timestamp = getBeijingTime();
|
|
26
28
|
let progress;
|
|
29
|
+
// Use three-layer recovery strategy to preserve all data
|
|
27
30
|
if (await FileSystemUtils.fileExists(progressPath)) {
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
const recovered = await recoverProgressJsonForWrite(progressPath);
|
|
32
|
+
if (recovered) {
|
|
33
|
+
progress = recovered;
|
|
34
|
+
// Fill missing arrays/objects with defaults
|
|
35
|
+
progress.toolCalls = progress.toolCalls || [];
|
|
36
|
+
progress.skillCalls = progress.skillCalls || [];
|
|
37
|
+
progress.hookTriggers = progress.hookTriggers || [];
|
|
38
|
+
progress.clarifications = progress.clarifications || [];
|
|
39
|
+
progress.filesModified = progress.filesModified || [];
|
|
40
|
+
progress.completedTasks = progress.completedTasks || [];
|
|
41
|
+
progress.deviationRecords = progress.deviationRecords || [];
|
|
42
|
+
progress.phaseTransitions = progress.phaseTransitions || [];
|
|
43
|
+
progress.phaseDurations = progress.phaseDurations || [];
|
|
44
|
+
progress.reviewStats = progress.reviewStats || {
|
|
45
|
+
loopCount: 0,
|
|
46
|
+
criticalFixes: 0,
|
|
47
|
+
testFixes: 0,
|
|
48
|
+
consistencyFixes: 0,
|
|
49
|
+
};
|
|
50
|
+
progress.stats = progress.stats || {
|
|
51
|
+
tokenUsageTotal: 0,
|
|
52
|
+
contextLoad: 0,
|
|
53
|
+
durationMs: { propose: 0, apply: 0, review: 0, archive: 0 },
|
|
54
|
+
};
|
|
30
55
|
}
|
|
31
|
-
|
|
32
|
-
|
|
56
|
+
else {
|
|
57
|
+
// Recovery failed - create minimal valid structure instead of empty object
|
|
58
|
+
progress = {
|
|
59
|
+
changeId: path.basename(changeDir),
|
|
60
|
+
sessionId: '',
|
|
61
|
+
startedAt: timestamp,
|
|
62
|
+
lastUpdatedAt: timestamp,
|
|
63
|
+
phase,
|
|
64
|
+
currentNode: phase,
|
|
65
|
+
currentTask: '',
|
|
66
|
+
completedTasks: [],
|
|
67
|
+
totalTasks: 0,
|
|
68
|
+
toolCalls: [],
|
|
69
|
+
skillCalls: [],
|
|
70
|
+
hookTriggers: [],
|
|
71
|
+
clarifications: [],
|
|
72
|
+
filesModified: [],
|
|
73
|
+
linesAdded: 0,
|
|
74
|
+
linesRemoved: 0,
|
|
75
|
+
deviationCount: 0,
|
|
76
|
+
deviationRecords: [],
|
|
77
|
+
reviewStats: { loopCount: 0, criticalFixes: 0, testFixes: 0, consistencyFixes: 0 },
|
|
78
|
+
phaseTransitions: [],
|
|
79
|
+
phaseDurations: [{
|
|
80
|
+
phase,
|
|
81
|
+
startedAt: timestamp,
|
|
82
|
+
durationMs: 0,
|
|
83
|
+
taskCount: 0,
|
|
84
|
+
completedTaskCount: 0,
|
|
85
|
+
}],
|
|
86
|
+
stats: { tokenUsageTotal: 0, contextLoad: 0, durationMs: { propose: 0, apply: 0, review: 0, archive: 0 } },
|
|
87
|
+
};
|
|
33
88
|
}
|
|
34
89
|
}
|
|
35
90
|
else {
|
|
36
|
-
|
|
91
|
+
// No existing file - create new structure
|
|
92
|
+
progress = {
|
|
93
|
+
changeId: path.basename(changeDir),
|
|
94
|
+
sessionId: '',
|
|
95
|
+
startedAt: timestamp,
|
|
96
|
+
lastUpdatedAt: timestamp,
|
|
97
|
+
phase,
|
|
98
|
+
currentNode: phase,
|
|
99
|
+
currentTask: '',
|
|
100
|
+
completedTasks: [],
|
|
101
|
+
totalTasks: 0,
|
|
102
|
+
toolCalls: [],
|
|
103
|
+
skillCalls: [],
|
|
104
|
+
hookTriggers: [],
|
|
105
|
+
clarifications: [],
|
|
106
|
+
filesModified: [],
|
|
107
|
+
linesAdded: 0,
|
|
108
|
+
linesRemoved: 0,
|
|
109
|
+
deviationCount: 0,
|
|
110
|
+
deviationRecords: [],
|
|
111
|
+
reviewStats: { loopCount: 0, criticalFixes: 0, testFixes: 0, consistencyFixes: 0 },
|
|
112
|
+
phaseTransitions: [],
|
|
113
|
+
phaseDurations: [{
|
|
114
|
+
phase,
|
|
115
|
+
startedAt: timestamp,
|
|
116
|
+
durationMs: 0,
|
|
117
|
+
taskCount: 0,
|
|
118
|
+
completedTaskCount: 0,
|
|
119
|
+
}],
|
|
120
|
+
stats: { tokenUsageTotal: 0, contextLoad: 0, durationMs: { propose: 0, apply: 0, review: 0, archive: 0 } },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// Record phase transition
|
|
124
|
+
const previousPhase = progress.phase;
|
|
125
|
+
// Ensure phaseDurations has at least one entry for current phase
|
|
126
|
+
if (progress.phaseDurations.length === 0) {
|
|
127
|
+
progress.phaseDurations.push({
|
|
128
|
+
phase,
|
|
129
|
+
startedAt: timestamp,
|
|
130
|
+
durationMs: 0,
|
|
131
|
+
taskCount: 0,
|
|
132
|
+
completedTaskCount: 0,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (previousPhase !== phase) {
|
|
136
|
+
progress.phaseTransitions.push({
|
|
137
|
+
from: previousPhase,
|
|
138
|
+
to: phase,
|
|
139
|
+
timestamp,
|
|
140
|
+
triggeredBy: 'setPhase',
|
|
141
|
+
});
|
|
142
|
+
// Update phaseDurations: fill endedAt for previous phase
|
|
143
|
+
const prevPhaseDuration = progress.phaseDurations.find(pd => pd.phase === previousPhase && !pd.endedAt);
|
|
144
|
+
if (prevPhaseDuration) {
|
|
145
|
+
prevPhaseDuration.endedAt = timestamp;
|
|
146
|
+
const startTime = new Date(prevPhaseDuration.startedAt).getTime();
|
|
147
|
+
const endTime = new Date(timestamp).getTime();
|
|
148
|
+
prevPhaseDuration.durationMs = endTime > startTime ? endTime - startTime : 0;
|
|
149
|
+
}
|
|
150
|
+
// Add new phase duration record
|
|
151
|
+
progress.phaseDurations.push({
|
|
152
|
+
phase,
|
|
153
|
+
startedAt: timestamp,
|
|
154
|
+
durationMs: 0,
|
|
155
|
+
taskCount: 0,
|
|
156
|
+
completedTaskCount: 0,
|
|
157
|
+
});
|
|
37
158
|
}
|
|
38
159
|
progress.phase = phase;
|
|
39
|
-
progress.
|
|
40
|
-
|
|
160
|
+
progress.currentNode = phase;
|
|
161
|
+
progress.lastUpdatedAt = timestamp;
|
|
162
|
+
// Use atomic write to prevent corruption
|
|
163
|
+
await atomicWriteJson(progressPath, progress);
|
|
41
164
|
}
|
|
42
165
|
/**
|
|
43
166
|
* Get phase from progress.json
|