@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.
@@ -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
@@ -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
@@ -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
- * Creates or updates progress.json with the new phase value
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
- * Creates or updates progress.json with the new phase value
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
- try {
29
- progress = JSON.parse(await FileSystemUtils.readFile(progressPath));
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
- catch {
32
- progress = { phase };
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
- progress = { phase };
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.lastUpdatedAt = new Date().toISOString();
40
- await FileSystemUtils.writeFile(progressPath, JSON.stringify(progress, null, 2));
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuan-ai/zhuanspec",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "zhuanspec",