@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.
- package/dist/commands/progress.js +1 -5
- package/dist/core/init.d.ts +4 -1
- package/dist/core/init.js +122 -36
- package/dist/core/task-graph/execution-planner.d.ts +6 -0
- package/dist/core/task-graph/execution-planner.js +88 -2
- package/dist/core/task-graph/task-parser.d.ts +13 -0
- package/dist/core/task-graph/task-parser.js +57 -0
- package/dist/core/task-graph/types.d.ts +13 -0
- package/dist/core/validation/strict-rules.d.ts +7 -0
- package/dist/core/validation/strict-rules.js +61 -4
- package/package.json +20 -22
|
@@ -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`);
|
package/dist/core/init.d.ts
CHANGED
|
@@ -58,7 +58,10 @@ export declare class InitCommand {
|
|
|
58
58
|
private resolveClaudeAssetSource;
|
|
59
59
|
private copyClaudeFileIfNeeded;
|
|
60
60
|
private copyClaudeDirectoryIfNeeded;
|
|
61
|
-
|
|
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',
|
|
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
|
-
|
|
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.
|
|
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
|
|
967
|
+
return emptyResult;
|
|
958
968
|
}
|
|
959
969
|
if (!existsSync(sourceAsset.path)) {
|
|
960
|
-
return { source: sourceAsset.source,
|
|
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
|
|
977
|
-
const
|
|
978
|
-
if (
|
|
979
|
-
return { source: sourceAsset.source,
|
|
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
|
-
|
|
993
|
+
files: filesResult,
|
|
984
994
|
};
|
|
985
995
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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',
|
|
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
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1152
|
+
return result;
|
|
1090
1153
|
}
|
|
1091
1154
|
catch {
|
|
1092
|
-
return
|
|
1155
|
+
return result;
|
|
1093
1156
|
}
|
|
1094
1157
|
}
|
|
1095
1158
|
describeClaudeAssetResult(assetName, result) {
|
|
1096
|
-
if (result.
|
|
1159
|
+
if (result.source === 'none') {
|
|
1097
1160
|
return `${assetName} ๆชๆพๅฐๅฏ็จๆจกๆฟ`;
|
|
1098
1161
|
}
|
|
1099
1162
|
const sourceText = result.source === 'business' ? 'ไธๅก็ฎๅฝ' : 'common/claude';
|
|
1100
|
-
if (
|
|
1101
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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.
|
|
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
|
+
}
|