@zhuan-ai/zhuanspec 2.5.0 โ†’ 2.6.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.
@@ -282,11 +282,9 @@ export class ProgressCommand {
282
282
  ? Math.round((durationMin / completedCount) * (totalTasks - completedCount))
283
283
  : 0;
284
284
  console.log('');
285
- console.log(`๐Ÿ“Š ๅ˜ๆ›ด่ฟ›ๅบฆ: ${changeId}`);
285
+ console.log(`๐Ÿ“Š ๅ˜ๆ›ด่ฟ›ๅบฆ: ${changeId} | Phase: ${phase} (${phaseIndex}/${phases.length}) | ${progressBar} ${percentage.toFixed(1)}% (${completedCount}/${totalTasks})`);
286
286
  console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”');
287
287
  console.log('');
288
- console.log(`Phase: ${phase} (${phaseIndex}/${phases.length})`);
289
- console.log('');
290
288
  // Wave status
291
289
  for (const wave of waves) {
292
290
  const waveStatusEmoji = wave.status === 'completed' ? 'โœ…' : wave.status === 'in_progress' ? '๐Ÿ”„' : 'โณ';
@@ -300,8 +298,6 @@ export class ProgressCommand {
300
298
  }
301
299
  console.log('');
302
300
  }
303
- // Overall progress
304
- console.log(`ๆ•ดไฝ“: ${progressBar} ${percentage.toFixed(1)}% (${completedCount}/${totalTasks})`);
305
301
  // Duration info
306
302
  if (durationMin > 0) {
307
303
  console.log(`่€—ๆ—ถ: ${durationMin}m | ้ข„่ฎกๅ‰ฉไฝ™: ~${estimatedRemaining}m`);
@@ -58,7 +58,10 @@ export declare class InitCommand {
58
58
  private resolveClaudeAssetSource;
59
59
  private copyClaudeFileIfNeeded;
60
60
  private copyClaudeDirectoryIfNeeded;
61
- private copyClaudeDirectoryWithFallback;
61
+ /**
62
+ * Copy .claude directory from common source (unified source, no fallback).
63
+ */
64
+ private copyClaudeDirectoryFromCommon;
62
65
  private copyClaudeDirectoryFromSingleSource;
63
66
  private directoryHasAtLeastOneFile;
64
67
  private copyClaudeDirectoryByFileSystem;
package/dist/core/init.js CHANGED
@@ -821,7 +821,7 @@ export class InitCommand {
821
821
  syncBusinessClaudeAssets(projectPath) {
822
822
  const fallbackResult = {
823
823
  claudeMd: { source: 'none', status: 'missing' },
824
- dotClaude: { source: 'none', status: 'missing' },
824
+ dotClaude: { source: 'none', files: { added: [], overwritten: [], skipped: [] } },
825
825
  };
826
826
  if (!this.businessDirection) {
827
827
  return fallbackResult;
@@ -839,13 +839,11 @@ export class InitCommand {
839
839
  : null;
840
840
  const commonClaudeMd = path.join(commonClaudeRoot, 'CLAUDE.md');
841
841
  const resolvedClaudeMdSource = this.resolveClaudeAssetSource(businessClaudeMd, commonClaudeMd);
842
- const businessDotClaude = businessRoot
843
- ? path.join(businessRoot, '.claude')
844
- : null;
842
+ // .claude directory: always from common directory (not business-specific)
845
843
  const commonDotClaude = path.join(commonClaudeRoot, '.claude');
846
844
  return {
847
845
  claudeMd: this.copyClaudeFileIfNeeded(resolvedClaudeMdSource, path.join(projectPath, 'CLAUDE.md')),
848
- dotClaude: this.copyClaudeDirectoryWithFallback(businessDotClaude, commonDotClaude, path.join(projectPath, '.claude')),
846
+ dotClaude: this.copyClaudeDirectoryFromCommon(commonDotClaude, path.join(projectPath, '.claude')),
849
847
  };
850
848
  }
851
849
  catch {
@@ -949,15 +947,27 @@ export class InitCommand {
949
947
  if (!sourceAsset) {
950
948
  return { source: 'none', status: 'missing' };
951
949
  }
950
+ const targetExists = existsSync(targetPath);
951
+ if (targetExists) {
952
+ const sourceContent = readFileSync(sourceAsset.path);
953
+ const targetContent = readFileSync(targetPath);
954
+ if (Buffer.compare(sourceContent, targetContent) === 0) {
955
+ return { source: sourceAsset.source, status: 'skipped-unchanged' };
956
+ }
957
+ }
952
958
  cpSync(sourceAsset.path, targetPath, { force: true });
953
- return { source: sourceAsset.source, status: 'copied' };
959
+ return { source: sourceAsset.source, status: targetExists ? 'overwritten' : 'copied' };
954
960
  }
955
961
  copyClaudeDirectoryIfNeeded(sourceAsset, targetPath) {
962
+ const emptyResult = {
963
+ source: 'none',
964
+ files: { added: [], overwritten: [], skipped: [] },
965
+ };
956
966
  if (!sourceAsset) {
957
- return { source: 'none', status: 'missing' };
967
+ return emptyResult;
958
968
  }
959
969
  if (!existsSync(sourceAsset.path)) {
960
- return { source: sourceAsset.source, status: 'missing' };
970
+ return { source: sourceAsset.source, files: { added: [], overwritten: [], skipped: [] } };
961
971
  }
962
972
  const repoRoot = this.findGitRootForPath(sourceAsset.path);
963
973
  if (!repoRoot) {
@@ -973,26 +983,25 @@ export class InitCommand {
973
983
  path.isAbsolute(relativeSourceDir)) {
974
984
  return this.copyClaudeDirectoryByFileSystem(sourceAsset, targetPath);
975
985
  }
976
- const targetAlreadyExisted = existsSync(targetPath);
977
- const copiedAny = this.copyGitTrackedDirectoryFiles(canonicalRepoRoot, relativeSourceDir, targetPath);
978
- if (!copiedAny) {
979
- return { source: sourceAsset.source, status: 'missing' };
986
+ const filesResult = this.copyGitTrackedDirectoryFiles(canonicalRepoRoot, relativeSourceDir, targetPath);
987
+ const totalFiles = filesResult.added.length + filesResult.overwritten.length + filesResult.skipped.length;
988
+ if (totalFiles === 0) {
989
+ return { source: sourceAsset.source, files: { added: [], overwritten: [], skipped: [] } };
980
990
  }
981
991
  return {
982
992
  source: sourceAsset.source,
983
- status: targetAlreadyExisted ? 'kept-existing' : 'copied',
993
+ files: filesResult,
984
994
  };
985
995
  }
986
- copyClaudeDirectoryWithFallback(businessSourcePath, commonSourcePath, targetPath) {
987
- const fromBusiness = this.copyClaudeDirectoryFromSingleSource(businessSourcePath, 'business', targetPath);
988
- if (fromBusiness) {
989
- return fromBusiness;
990
- }
996
+ /**
997
+ * Copy .claude directory from common source (unified source, no fallback).
998
+ */
999
+ copyClaudeDirectoryFromCommon(commonSourcePath, targetPath) {
991
1000
  const fromCommon = this.copyClaudeDirectoryFromSingleSource(commonSourcePath, 'common', targetPath);
992
1001
  if (fromCommon) {
993
1002
  return fromCommon;
994
1003
  }
995
- return { source: 'none', status: 'missing' };
1004
+ return { source: 'none', files: { added: [], overwritten: [], skipped: [] } };
996
1005
  }
997
1006
  copyClaudeDirectoryFromSingleSource(sourcePath, sourceLabel, targetPath) {
998
1007
  if (!sourcePath || !existsSync(sourcePath)) {
@@ -1025,11 +1034,42 @@ export class InitCommand {
1025
1034
  return false;
1026
1035
  }
1027
1036
  copyClaudeDirectoryByFileSystem(sourceAsset, targetPath) {
1028
- if (existsSync(targetPath)) {
1029
- rmSync(targetPath, { recursive: true, force: true });
1030
- }
1031
- cpSync(sourceAsset.path, targetPath, { recursive: true, force: true });
1032
- return { source: sourceAsset.source, status: 'copied' };
1037
+ const result = {
1038
+ added: [],
1039
+ overwritten: [],
1040
+ skipped: [],
1041
+ };
1042
+ const syncDir = (srcDir, dstDir, relBase) => {
1043
+ mkdirSync(dstDir, { recursive: true });
1044
+ const entries = readdirSync(srcDir, { withFileTypes: true });
1045
+ for (const entry of entries) {
1046
+ const srcPath = path.join(srcDir, entry.name);
1047
+ const dstPath = path.join(dstDir, entry.name);
1048
+ const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
1049
+ if (entry.isDirectory()) {
1050
+ syncDir(srcPath, dstPath, relPath);
1051
+ }
1052
+ else {
1053
+ const sourceContent = readFileSync(srcPath);
1054
+ if (existsSync(dstPath)) {
1055
+ const targetContent = readFileSync(dstPath);
1056
+ if (Buffer.compare(sourceContent, targetContent) === 0) {
1057
+ result.skipped.push(relPath);
1058
+ }
1059
+ else {
1060
+ writeFileSync(dstPath, sourceContent);
1061
+ result.overwritten.push(relPath);
1062
+ }
1063
+ }
1064
+ else {
1065
+ writeFileSync(dstPath, sourceContent);
1066
+ result.added.push(relPath);
1067
+ }
1068
+ }
1069
+ }
1070
+ };
1071
+ syncDir(sourceAsset.path, targetPath, '');
1072
+ return { source: sourceAsset.source, files: result };
1033
1073
  }
1034
1074
  findGitRootForPath(sourcePath) {
1035
1075
  try {
@@ -1053,6 +1093,11 @@ export class InitCommand {
1053
1093
  }
1054
1094
  }
1055
1095
  copyGitTrackedDirectoryFiles(gitRoot, sourceDirRelativeToGitRoot, targetDir) {
1096
+ const result = {
1097
+ added: [],
1098
+ overwritten: [],
1099
+ skipped: [],
1100
+ };
1056
1101
  try {
1057
1102
  const sourcePrefix = sourceDirRelativeToGitRoot.endsWith('/')
1058
1103
  ? sourceDirRelativeToGitRoot
@@ -1067,40 +1112,81 @@ export class InitCommand {
1067
1112
  .map((line) => line.trim())
1068
1113
  .filter((line) => line.length > 0);
1069
1114
  if (trackedFiles.length === 0) {
1070
- return false;
1071
- }
1072
- if (existsSync(targetDir)) {
1073
- rmSync(targetDir, { recursive: true, force: true });
1115
+ return result;
1074
1116
  }
1117
+ // ็กฎไฟ็›ฎๆ ‡็›ฎๅฝ•ๅญ˜ๅœจ๏ผŒไฝ†ไธๅˆ ้™คๅทฒๆœ‰ๅ†…ๅฎน
1075
1118
  mkdirSync(targetDir, { recursive: true });
1076
1119
  trackedFiles.forEach((repoFilePath) => {
1077
1120
  const relativePath = repoFilePath.startsWith(sourcePrefix)
1078
1121
  ? repoFilePath.slice(sourcePrefix.length)
1079
1122
  : path.basename(repoFilePath);
1080
1123
  const targetFilePath = path.join(targetDir, relativePath);
1124
+ // ็กฎไฟ็›ฎๆ ‡ๆ–‡ไปถ็š„็ˆถ็›ฎๅฝ•ๅญ˜ๅœจ
1081
1125
  mkdirSync(path.dirname(targetFilePath), { recursive: true });
1082
- const content = execSync(`git show HEAD:${repoFilePath}`, {
1126
+ // ่Žทๅ–ๆบๆ–‡ไปถๅ†…ๅฎน
1127
+ const sourceContent = execSync(`git show HEAD:${repoFilePath}`, {
1083
1128
  encoding: 'buffer',
1084
1129
  stdio: ['pipe', 'pipe', 'ignore'],
1085
1130
  cwd: gitRoot,
1086
1131
  });
1087
- writeFileSync(targetFilePath, content);
1132
+ // ๆฃ€ๆŸฅ็›ฎๆ ‡ๆ–‡ไปถๆ˜ฏๅฆๅญ˜ๅœจ
1133
+ if (existsSync(targetFilePath)) {
1134
+ // ๆฏ”่พƒๅ†…ๅฎนๆ˜ฏๅฆ็›ธๅŒ
1135
+ const targetContent = readFileSync(targetFilePath);
1136
+ if (Buffer.compare(sourceContent, targetContent) === 0) {
1137
+ // ๅ†…ๅฎน็›ธๅŒ๏ผŒ่ทณ่ฟ‡
1138
+ result.skipped.push(relativePath);
1139
+ }
1140
+ else {
1141
+ // ๅ†…ๅฎนไธๅŒ๏ผŒ่ฆ†็›–
1142
+ writeFileSync(targetFilePath, sourceContent);
1143
+ result.overwritten.push(relativePath);
1144
+ }
1145
+ }
1146
+ else {
1147
+ // ๆ–‡ไปถไธๅญ˜ๅœจ๏ผŒๆ–ฐๅขž
1148
+ writeFileSync(targetFilePath, sourceContent);
1149
+ result.added.push(relativePath);
1150
+ }
1088
1151
  });
1089
- return true;
1152
+ return result;
1090
1153
  }
1091
1154
  catch {
1092
- return false;
1155
+ return result;
1093
1156
  }
1094
1157
  }
1095
1158
  describeClaudeAssetResult(assetName, result) {
1096
- if (result.status === 'missing') {
1159
+ if (result.source === 'none') {
1097
1160
  return `${assetName} ๆœชๆ‰พๅˆฐๅฏ็”จๆจกๆฟ`;
1098
1161
  }
1099
1162
  const sourceText = result.source === 'business' ? 'ไธšๅŠก็›ฎๅฝ•' : 'common/claude';
1100
- if (result.status === 'kept-existing') {
1101
- return `${assetName} ไฝฟ็”จ${sourceText}ๆจกๆฟ๏ผŒ็›ฎๆ ‡ๅทฒๅญ˜ๅœจๆœช่ฆ†็›–`;
1163
+ if (assetName === 'CLAUDE.md') {
1164
+ const fileResult = result;
1165
+ if (fileResult.status === 'missing') {
1166
+ return `${assetName} ๆœชๆ‰พๅˆฐๅฏ็”จๆจกๆฟ`;
1167
+ }
1168
+ const statusText = fileResult.status === 'overwritten' ? 'ๅทฒ่ฆ†็›–' : 'ๅทฒๅคๅˆถ';
1169
+ return `${assetName} ๅทฒไปŽ${sourceText}ๆจกๆฟ${statusText}`;
1170
+ }
1171
+ // .claude ็›ฎๅฝ•๏ผˆ็ปŸไธ€ไปŽ common ่Žทๅ–๏ผ‰
1172
+ const dirResult = result;
1173
+ const { added, overwritten, skipped } = dirResult.files;
1174
+ const total = added.length + overwritten.length + skipped.length;
1175
+ if (total === 0) {
1176
+ return `${assetName} ๆ— ๆ–‡ไปถ้œ€ๅŒๆญฅ`;
1177
+ }
1178
+ const parts = [];
1179
+ if (added.length > 0) {
1180
+ parts.push(`ๆ–ฐๅขž ${added.length} ไธช`);
1181
+ }
1182
+ if (overwritten.length > 0) {
1183
+ parts.push(`่ฆ†็›– ${overwritten.length} ไธช`);
1184
+ }
1185
+ if (skipped.length > 0) {
1186
+ parts.push(`่ทณ่ฟ‡ ${skipped.length} ไธช`);
1102
1187
  }
1103
- return `${assetName} ๅทฒไปŽ${sourceText}ๆจกๆฟๅคๅˆถ`;
1188
+ // .claude ็›ฎๅฝ•็ปŸไธ€ไปŽ common/claude ่Žทๅ–๏ผŒsourceText ๅ›บๅฎšไธบ 'common/claude'
1189
+ return `${assetName} ๅทฒไปŽ common/claude ๆจกๆฟๅŒๆญฅ๏ผš${parts.join('ใ€')}`;
1104
1190
  }
1105
1191
  async writeTemplateFiles(zhuanspecPath, config, skipExisting) {
1106
1192
  const context = {};
@@ -5,6 +5,12 @@
5
5
  * Uses a layer-by-layer approach: each wave contains tasks with in-degree 0.
6
6
  */
7
7
  import type { ParsedTask, ExecutionPlan } from './types.js';
8
+ /**
9
+ * Detect file conflicts among tasks in the same wave.
10
+ * Returns a map of file path -> list of task IDs that modify it.
11
+ * Only entries with 2+ task IDs indicate actual conflicts.
12
+ */
13
+ export declare function detectFileConflicts(tasks: ParsedTask[]): Map<string, string[]>;
8
14
  /**
9
15
  * Generates an execution plan from parsed tasks.
10
16
  *
@@ -5,6 +5,23 @@
5
5
  * Uses a layer-by-layer approach: each wave contains tasks with in-degree 0.
6
6
  */
7
7
  import { TaskGraph } from './task-graph.js';
8
+ /**
9
+ * Detect file conflicts among tasks in the same wave.
10
+ * Returns a map of file path -> list of task IDs that modify it.
11
+ * Only entries with 2+ task IDs indicate actual conflicts.
12
+ */
13
+ export function detectFileConflicts(tasks) {
14
+ const fileToTasks = new Map();
15
+ for (const task of tasks) {
16
+ for (const file of (task.files ?? [])) {
17
+ if (!fileToTasks.has(file)) {
18
+ fileToTasks.set(file, []);
19
+ }
20
+ fileToTasks.get(file).push(task.id);
21
+ }
22
+ }
23
+ return fileToTasks;
24
+ }
8
25
  /**
9
26
  * Generates an execution plan from parsed tasks.
10
27
  *
@@ -24,6 +41,7 @@ export function generateExecutionPlan(tasks) {
24
41
  parallelTasks: 0,
25
42
  serialTasks: 0,
26
43
  hasCycle: false,
44
+ fileConflicts: [],
27
45
  errors: [],
28
46
  };
29
47
  }
@@ -38,6 +56,7 @@ export function generateExecutionPlan(tasks) {
38
56
  parallelTasks: 0,
39
57
  serialTasks: 0,
40
58
  hasCycle: true,
59
+ fileConflicts: [],
41
60
  errors: validation.errors,
42
61
  };
43
62
  }
@@ -96,10 +115,76 @@ export function generateExecutionPlan(tasks) {
96
115
  }
97
116
  }
98
117
  }
118
+ // Redistribute file conflicts across waves
119
+ // For each wave, detect tasks that share files and split them into subsequent waves
120
+ const fileConflicts = [];
121
+ let waveIdx = 0;
122
+ while (waveIdx < waves.length) {
123
+ const currentWave = waves[waveIdx];
124
+ const conflictMap = detectFileConflicts(currentWave.tasks);
125
+ // Collect tasks that need to be moved out of this wave
126
+ // Key: task ID, Value: how many waves forward to push it
127
+ const taskShift = new Map();
128
+ for (const [file, taskIds] of conflictMap) {
129
+ if (taskIds.length < 2)
130
+ continue;
131
+ // Sort by task ID (ascending) โ€” first stays, rest get pushed forward
132
+ const sorted = [...taskIds].sort();
133
+ // Record conflict info (deduplicate by file)
134
+ if (!fileConflicts.some(fc => fc.file === file)) {
135
+ fileConflicts.push({ file, taskIds: sorted });
136
+ }
137
+ for (let i = 1; i < sorted.length; i++) {
138
+ const id = sorted[i];
139
+ // Push at least i waves forward; take the max if already scheduled to move
140
+ const existing = taskShift.get(id) ?? 0;
141
+ if (i > existing) {
142
+ taskShift.set(id, i);
143
+ }
144
+ }
145
+ }
146
+ if (taskShift.size > 0) {
147
+ // Remove conflicting tasks from current wave
148
+ const tasksToKeep = currentWave.tasks.filter(t => !taskShift.has(t.id));
149
+ const tasksToMove = currentWave.tasks.filter(t => taskShift.has(t.id));
150
+ currentWave.tasks = tasksToKeep;
151
+ currentWave.parallel = tasksToKeep.length > 1;
152
+ // Insert moved tasks into target waves (wave+shift)
153
+ for (const task of tasksToMove) {
154
+ const shift = taskShift.get(task.id);
155
+ const targetIdx = waveIdx + shift;
156
+ if (targetIdx < waves.length) {
157
+ // Insert into existing wave (maintain task ID sort order)
158
+ waves[targetIdx].tasks.push(task);
159
+ waves[targetIdx].tasks.sort((a, b) => a.id.localeCompare(b.id));
160
+ }
161
+ else {
162
+ // Need to create new intermediate waves to fill the gap
163
+ while (waves.length <= targetIdx) {
164
+ waves.push({
165
+ wave: waves.length + 1, // temporary, renumbered below
166
+ tasks: [],
167
+ parallel: false,
168
+ });
169
+ }
170
+ waves[targetIdx].tasks.push(task);
171
+ waves[targetIdx].tasks.sort((a, b) => a.id.localeCompare(b.id));
172
+ }
173
+ }
174
+ }
175
+ waveIdx++;
176
+ }
177
+ // Remove any empty waves that may have been left behind
178
+ const nonEmptyWaves = waves.filter(w => w.tasks.length > 0);
179
+ // Renumber waves sequentially from 1 and recalculate parallel flag
180
+ for (let i = 0; i < nonEmptyWaves.length; i++) {
181
+ nonEmptyWaves[i].wave = i + 1;
182
+ nonEmptyWaves[i].parallel = nonEmptyWaves[i].tasks.length > 1;
183
+ }
99
184
  // Calculate statistics
100
185
  let parallelTasks = 0;
101
186
  let serialTasks = 0;
102
- for (const wave of waves) {
187
+ for (const wave of nonEmptyWaves) {
103
188
  if (wave.parallel) {
104
189
  parallelTasks += wave.tasks.length;
105
190
  }
@@ -108,12 +193,13 @@ export function generateExecutionPlan(tasks) {
108
193
  }
109
194
  }
110
195
  return {
111
- waves,
196
+ waves: nonEmptyWaves,
112
197
  totalTasks: incompleteTasks.length,
113
198
  parallelTasks,
114
199
  serialTasks,
115
200
  hasCycle: false,
116
201
  errors,
202
+ fileConflicts,
117
203
  };
118
204
  }
119
205
  //# sourceMappingURL=execution-planner.js.map
@@ -4,6 +4,19 @@
4
4
  * Parses tasks.md content into structured ParsedTask array.
5
5
  */
6
6
  import type { ParsedTask } from './types.js';
7
+ /**
8
+ * Extract file paths from a task description.
9
+ *
10
+ * Handles three formats:
11
+ * 1. Backtick code blocks: `src/core/foo.ts`, `com/example/Foo.java`
12
+ * 2. Bare paths with known prefixes: src/xxx/yyy.ts, components/Foo.vue,
13
+ * com/example/service/FooService.java (Java package paths)
14
+ * 3. Quoted paths: "src/core/foo.ts" or 'com/example/Foo.java'
15
+ *
16
+ * @param description - Task description text
17
+ * @returns Deduplicated array of file paths
18
+ */
19
+ export declare function extractFilePaths(description: string): string[];
7
20
  /**
8
21
  * Parse tasks.md content into an array of ParsedTask objects.
9
22
  *
@@ -61,6 +61,62 @@ function cleanDescription(text) {
61
61
  .trim();
62
62
  return cleaned;
63
63
  }
64
+ // File path extraction patterns
65
+ // Backtick code blocks: `path/to/File.java` or `src/core/foo.ts`
66
+ const CODE_BLOCK_REGEX = /`([^`]+\.[a-zA-Z]+)`/g;
67
+ // Bare paths with common directory prefixes (language-agnostic):
68
+ // - TypeScript/JS: src/, components/, pages/, views/, hooks/, utils/, lib/, app/
69
+ // - Java: src/main/java/, src/test/java/, com/, org/, net/ (package paths)
70
+ // - Relative: ./, ../
71
+ const PATH_REGEX = /(?:(?:src|components?|pages?|views?|hooks?|utils?|lib|app|test|tests|spec|specs)\/|\.\.?\/|(?:[a-z][a-z0-9]*\.)+(?:[a-z][a-z0-9]*\/))[a-zA-Z0-9_\-/.]+\.[a-zA-Z]+/g;
72
+ // Quoted paths: "path/to/File.java" or 'src/core/foo.ts'
73
+ const QUOTED_PATH_REGEX = /["']([^"'\s]+\/[^"'\s]+\.[a-zA-Z]+)["']/g;
74
+ /**
75
+ * Extract file paths from a task description.
76
+ *
77
+ * Handles three formats:
78
+ * 1. Backtick code blocks: `src/core/foo.ts`, `com/example/Foo.java`
79
+ * 2. Bare paths with known prefixes: src/xxx/yyy.ts, components/Foo.vue,
80
+ * com/example/service/FooService.java (Java package paths)
81
+ * 3. Quoted paths: "src/core/foo.ts" or 'com/example/Foo.java'
82
+ *
83
+ * @param description - Task description text
84
+ * @returns Deduplicated array of file paths
85
+ */
86
+ export function extractFilePaths(description) {
87
+ const paths = [];
88
+ // 1. Backtick code blocks
89
+ let match;
90
+ const codeBlockRegex = new RegExp(CODE_BLOCK_REGEX.source, 'g');
91
+ while ((match = codeBlockRegex.exec(description)) !== null) {
92
+ paths.push(match[1]);
93
+ }
94
+ // 2. Bare path format
95
+ const pathRegex = new RegExp(PATH_REGEX.source, 'g');
96
+ while ((match = pathRegex.exec(description)) !== null) {
97
+ paths.push(match[0]);
98
+ }
99
+ // 3. Quoted paths
100
+ const quotedPathRegex = new RegExp(QUOTED_PATH_REGEX.source, 'g');
101
+ while ((match = quotedPathRegex.exec(description)) !== null) {
102
+ paths.push(match[1]);
103
+ }
104
+ return [...new Set(paths)].filter(p => {
105
+ // Ignore node_modules paths
106
+ if (p.includes('node_modules'))
107
+ return false;
108
+ // Must have a file extension
109
+ if (!/\.[a-zA-Z]+$/.test(p))
110
+ return false;
111
+ // Ignore pure numeric paths like "1.1" or "2.3"
112
+ if (/^\d+\.\d+$/.test(p))
113
+ return false;
114
+ // Must contain at least one slash (actual path, not just a filename)
115
+ if (!p.includes('/'))
116
+ return false;
117
+ return true;
118
+ });
119
+ }
64
120
  /**
65
121
  * Parse tasks.md content into an array of ParsedTask objects.
66
122
  *
@@ -102,6 +158,7 @@ export function parseTasks(content) {
102
158
  description,
103
159
  skills,
104
160
  depends,
161
+ files: extractFilePaths(description),
105
162
  completed: checkboxState.toLowerCase() === 'x',
106
163
  });
107
164
  }
@@ -19,6 +19,8 @@ export interface ParsedTask {
19
19
  skills: string[];
20
20
  /** Task IDs this task depends on, e.g., ["1.1", "2.1"] */
21
21
  depends: string[];
22
+ /** File paths involved in this task, e.g., ["src/core/foo.ts", "src/utils/bar.ts"] */
23
+ files: string[];
22
24
  /** Whether the task is completed (checkbox state [x] vs [ ]) */
23
25
  completed: boolean;
24
26
  }
@@ -33,6 +35,15 @@ export interface ExecutionWave {
33
35
  /** Whether tasks can run in parallel (tasks.length > 1) */
34
36
  parallel: boolean;
35
37
  }
38
+ /**
39
+ * Represents a file conflict between tasks in the same wave
40
+ */
41
+ export interface FileConflict {
42
+ /** File path that has conflicting tasks */
43
+ file: string;
44
+ /** Task IDs that modify this file in the same wave */
45
+ taskIds: string[];
46
+ }
36
47
  /**
37
48
  * Represents the complete execution plan
38
49
  */
@@ -47,6 +58,8 @@ export interface ExecutionPlan {
47
58
  serialTasks: number;
48
59
  /** Whether there is a cycle in the dependency graph */
49
60
  hasCycle: boolean;
61
+ /** File conflicts detected and resolved by wave reassignment */
62
+ fileConflicts: FileConflict[];
50
63
  /** Validation errors */
51
64
  errors: string[];
52
65
  }
@@ -85,6 +85,13 @@ export declare function checkProposalFormatValid(proposalContent: string): Stric
85
85
  * 3. If progress.json exists without phase field -> Apply phase (default)
86
86
  */
87
87
  export declare function detectPhase(changeDir: string): 'propose' | 'apply' | 'review';
88
+ /**
89
+ * Rule: file-conflict-detection
90
+ *
91
+ * Checks that no two tasks in the same wave modify the same file.
92
+ * Uses extractFilePaths to detect file paths from task descriptions.
93
+ */
94
+ export declare function checkFileConflicts(parsedTasks: ParsedTask[]): StrictCheckResult;
88
95
  /**
89
96
  * Rule: test-case-coverage
90
97
  * Validates that tasks.md has test case coverage section.
@@ -493,11 +493,23 @@ function checkSpecCodeConsistent(specsDir, repoRoot, phase) {
493
493
  }
494
494
  // Get git diff files
495
495
  let touchedFiles = [];
496
+ // Resolve the actual git root so file paths from git commands can be resolved correctly
497
+ let gitRoot = repoRoot;
496
498
  try {
497
- const gitOutput = execSync('git diff --name-only', {
499
+ gitRoot = execSync('git rev-parse --show-toplevel', {
498
500
  cwd: repoRoot,
499
501
  encoding: 'utf-8',
500
502
  stdio: ['pipe', 'pipe', 'ignore'],
503
+ }).trim() || repoRoot;
504
+ }
505
+ catch {
506
+ // fallback to repoRoot
507
+ }
508
+ try {
509
+ const gitOutput = execSync('git diff --name-only HEAD', {
510
+ cwd: gitRoot,
511
+ encoding: 'utf-8',
512
+ stdio: ['pipe', 'pipe', 'ignore'],
501
513
  });
502
514
  touchedFiles = gitOutput.trim().split('\n').filter(Boolean);
503
515
  }
@@ -505,12 +517,12 @@ function checkSpecCodeConsistent(specsDir, repoRoot, phase) {
505
517
  // Git command failed, try git status for uncommitted changes
506
518
  try {
507
519
  const statusOutput = execSync('git status --porcelain', {
508
- cwd: repoRoot,
520
+ cwd: gitRoot,
509
521
  encoding: 'utf-8',
510
522
  stdio: ['pipe', 'pipe', 'ignore'],
511
523
  });
512
524
  touchedFiles = statusOutput.trim().split('\n')
513
- .map(line => line.replace(/^\s*[A-Z]+\s+/, '').trim())
525
+ .map(line => line.replace(/^\s*[A-Z?]+\s+/, '').trim())
514
526
  .filter(Boolean);
515
527
  }
516
528
  catch {
@@ -531,7 +543,7 @@ function checkSpecCodeConsistent(specsDir, repoRoot, phase) {
531
543
  return true;
532
544
  }
533
545
  try {
534
- const fullPath = path.join(repoRoot, file);
546
+ const fullPath = path.join(gitRoot, file);
535
547
  if (!existsSync(fullPath))
536
548
  return false;
537
549
  const content = readFileSync(fullPath, 'utf-8').toLowerCase();
@@ -548,6 +560,50 @@ function checkSpecCodeConsistent(specsDir, repoRoot, phase) {
548
560
  result.passed = result.errors.length === 0;
549
561
  return result;
550
562
  }
563
+ /**
564
+ * Rule: file-conflict-detection
565
+ *
566
+ * Checks that no two tasks in the same wave modify the same file.
567
+ * Uses extractFilePaths to detect file paths from task descriptions.
568
+ */
569
+ export function checkFileConflicts(parsedTasks) {
570
+ const result = {
571
+ ruleId: 'file-conflict-detection',
572
+ ruleName: 'File conflict detection',
573
+ passed: true,
574
+ errors: [],
575
+ warnings: [],
576
+ };
577
+ // Group tasks by wave (using task ID prefix, e.g., "1" for "1.1", "1.2")
578
+ const waveMap = new Map();
579
+ for (const task of parsedTasks) {
580
+ const wave = task.id.split('.')[0];
581
+ if (!waveMap.has(wave)) {
582
+ waveMap.set(wave, []);
583
+ }
584
+ waveMap.get(wave).push(task);
585
+ }
586
+ // Check each wave for file conflicts
587
+ for (const [wave, tasks] of waveMap) {
588
+ const fileToTasks = new Map();
589
+ for (const task of tasks) {
590
+ for (const file of (task.files ?? [])) {
591
+ if (!fileToTasks.has(file)) {
592
+ fileToTasks.set(file, []);
593
+ }
594
+ fileToTasks.get(file).push(task.id);
595
+ }
596
+ }
597
+ for (const [file, taskIds] of fileToTasks) {
598
+ if (taskIds.length >= 2) {
599
+ result.passed = false;
600
+ result.errors.push(`File conflict in Wave ${wave}: "${file}" is modified by tasks [${taskIds.join(', ')}]. ` +
601
+ `Add @depends to specify execution order or tasks will be serialized automatically.`);
602
+ }
603
+ }
604
+ }
605
+ return result;
606
+ }
551
607
  /**
552
608
  * Rule: test-case-coverage
553
609
  * Validates that tasks.md has test case coverage section.
@@ -629,6 +685,7 @@ export async function runStrictValidation(tasksContent, parsedTasks, knownSkillN
629
685
  checkTaskOrderingByWave(tasksContent, parsedTasks),
630
686
  checkSubagentDirectives(parsedTasks),
631
687
  checkReviewGateDefined(tasksContent),
688
+ checkFileConflicts(parsedTasks),
632
689
  checkTestCaseCoverage(tasksContent, hasTestCase),
633
690
  ];
634
691
  // Add new rules if parameters are provided
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuan-ai/zhuanspec",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "zhuanspec",
@@ -39,26 +39,6 @@
39
39
  "!dist/**/__tests__",
40
40
  "!dist/**/*.map"
41
41
  ],
42
- "scripts": {
43
- "lint": "eslint src/",
44
- "build": "node build.js",
45
- "dev": "tsc --watch",
46
- "dev:cli": "pnpm build && node bin/zhuanspec.js",
47
- "test": "vitest run",
48
- "test:watch": "vitest",
49
- "test:ui": "vitest --ui",
50
- "test:coverage": "vitest --coverage",
51
- "test:postinstall": "node scripts/postinstall.js",
52
- "prepare": "npm run build",
53
- "prepublishOnly": "npm run build",
54
- "postinstall": "node scripts/postinstall.js",
55
- "check:pack-version": "node scripts/pack-version-check.mjs",
56
- "diagnose:cursor": "node scripts/diagnose-cursor-commands.js",
57
- "release": "pnpm run release:ci",
58
- "release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",
59
- "release:local": "pnpm exec changeset version && pnpm run check:pack-version && pnpm exec changeset publish",
60
- "changeset": "changeset"
61
- },
62
42
  "engines": {
63
43
  "node": ">=20.19.0"
64
44
  },
@@ -80,5 +60,23 @@
80
60
  "ora": "^8.2.0",
81
61
  "yaml": "^2.8.2",
82
62
  "zod": "^4.0.17"
63
+ },
64
+ "scripts": {
65
+ "lint": "eslint src/",
66
+ "build": "node build.js",
67
+ "dev": "tsc --watch",
68
+ "dev:cli": "pnpm build && node bin/zhuanspec.js",
69
+ "test": "vitest run",
70
+ "test:watch": "vitest",
71
+ "test:ui": "vitest --ui",
72
+ "test:coverage": "vitest --coverage",
73
+ "test:postinstall": "node scripts/postinstall.js",
74
+ "postinstall": "node scripts/postinstall.js",
75
+ "check:pack-version": "node scripts/pack-version-check.mjs",
76
+ "diagnose:cursor": "node scripts/diagnose-cursor-commands.js",
77
+ "release": "pnpm run release:ci",
78
+ "release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",
79
+ "release:local": "pnpm exec changeset version && pnpm run check:pack-version && pnpm exec changeset publish",
80
+ "changeset": "changeset"
83
81
  }
84
- }
82
+ }