kld-sdd 2.6.7 → 2.6.9
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/bin/kld-sdd-init.js +39 -3
- package/lib/init.js +320 -45
- package/lib/workspace-layout.js +2 -0
- package/package.json +2 -2
- package/skywalk-sdd/context-client.cjs +59 -5
- package/skywalk-sdd/ontology/active-changes.cjs +297 -0
- package/skywalk-sdd/ontology/change-key.cjs +241 -0
- package/skywalk-sdd/ontology/cli.cjs +135 -0
- package/skywalk-sdd/ontology/list-changes.cjs +110 -0
- package/skywalk-sdd/ontology/modules.cjs +167 -0
- package/skywalk-sdd/ontology/naming-diagnose.cjs +594 -0
- package/skywalk-sdd/ontology/sdd-config.cjs +335 -0
- package/skywalk-sdd/ontology/workspace-layout.cjs +194 -0
- package/templates/dot-sdd.yaml +8 -0
- package/templates/git-hooks/commit-msg-sdd-trailer.cjs +224 -0
- package/templates/modules.yaml +13 -0
- package/templates/openspec/proposal.md +7 -1
- package/templates/sdd.config.yaml +12 -0
- package/templates/skills/kld-sdd/openspec-sync-specs/SKILL.md +148 -0
- package/templates/skills/kld-sdd/openspec-update-change/SKILL.md +86 -0
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +3 -3
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +1 -1
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +11 -1
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +73 -3
- package/templates/skills/kld-sdd/opsx-design/SKILL.md +9 -0
- package/templates/skills/kld-sdd/opsx-explore/SKILL.md +37 -17
- package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +9 -14
- package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +83 -109
- package/templates/skills/kld-sdd/opsx-ontology-query/phase-1-prechange.md +276 -0
- package/templates/skills/kld-sdd/opsx-ontology-query/phase-2-during.md +354 -0
- package/templates/skills/kld-sdd/opsx-ontology-query/phase-3-postchange.md +223 -0
- package/templates/skills/kld-sdd/opsx-ontology-query/phase-4-explore.md +240 -0
- package/templates/skills/kld-sdd/opsx-ontology-query/phase-5-governance.md +232 -0
- package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +92 -4
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +87 -16
- package/templates/skills/kld-sdd/opsx-propose/checklist.md +1 -0
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +33 -3
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +10 -0
- package/templates/skills/kld-sdd/opsx-tdd-core/checklist.md +1 -1
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const CODES = {
|
|
8
|
+
MISSING: 'SDD_YAML_MISSING',
|
|
9
|
+
FORMAT_INVALID: 'SDD_YAML_FORMAT_INVALID',
|
|
10
|
+
SPEC_PATH_MISSING: 'SDD_SPEC_PATH_MISSING',
|
|
11
|
+
SPEC_PATH_INVALID: 'SDD_SPEC_PATH_INVALID',
|
|
12
|
+
REMOTE_MISMATCH: 'SDD_SPEC_REMOTE_MISMATCH',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const LAYOUTS = {
|
|
16
|
+
MULTI: 'multi',
|
|
17
|
+
MONO: 'mono',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function stripYamlQuotes(value) {
|
|
21
|
+
const text = String(value == null ? '' : value).trim();
|
|
22
|
+
if (
|
|
23
|
+
(text.startsWith('"') && text.endsWith('"')) ||
|
|
24
|
+
(text.startsWith("'") && text.endsWith("'"))
|
|
25
|
+
) {
|
|
26
|
+
return text.slice(1, -1);
|
|
27
|
+
}
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Minimal parser for .sdd.yaml,两种布局:
|
|
33
|
+
*
|
|
34
|
+
* 多仓(默认):spec 是另一个 clone
|
|
35
|
+
* version: 1
|
|
36
|
+
* spec_repository: git@host:group/repo.git
|
|
37
|
+
*
|
|
38
|
+
* 单仓:openspec 与代码同仓,无外部 spec 可指
|
|
39
|
+
* version: 1
|
|
40
|
+
* layout: mono
|
|
41
|
+
*/
|
|
42
|
+
function parseSddYaml(text) {
|
|
43
|
+
const lines = String(text || '').split(/\r?\n/);
|
|
44
|
+
let version = null;
|
|
45
|
+
let specRepository = null;
|
|
46
|
+
let layout = null;
|
|
47
|
+
|
|
48
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
49
|
+
const raw = lines[index];
|
|
50
|
+
const line = raw.replace(/#.*$/, '').trim();
|
|
51
|
+
if (!line) continue;
|
|
52
|
+
|
|
53
|
+
const versionMatch = /^version:\s*(\d+)\s*$/.exec(line);
|
|
54
|
+
if (versionMatch) {
|
|
55
|
+
version = Number(versionMatch[1]);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const layoutMatch = /^layout:\s*(.+?)\s*$/.exec(line);
|
|
60
|
+
if (layoutMatch) {
|
|
61
|
+
const value = stripYamlQuotes(layoutMatch[1]).toLowerCase();
|
|
62
|
+
if (value !== LAYOUTS.MONO && value !== LAYOUTS.MULTI) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
code: CODES.FORMAT_INVALID,
|
|
66
|
+
message: `.sdd.yaml layout 只能是 mono 或 multi,实际: ${value}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
layout = value;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const repoMatch = /^spec_repository:\s*(.+?)\s*$/.exec(line);
|
|
74
|
+
if (repoMatch) {
|
|
75
|
+
specRepository = stripYamlQuotes(repoMatch[1]);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
code: CODES.FORMAT_INVALID,
|
|
82
|
+
message: `.sdd.yaml 第 ${index + 1} 行无法解析: ${raw}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (version == null) {
|
|
87
|
+
return { ok: false, code: CODES.FORMAT_INVALID, message: '.sdd.yaml 缺少 version' };
|
|
88
|
+
}
|
|
89
|
+
if (version !== 1) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
code: CODES.FORMAT_INVALID,
|
|
93
|
+
message: `不支持的 .sdd.yaml version: ${version}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const resolvedLayout = layout || LAYOUTS.MULTI;
|
|
97
|
+
// 单仓没有外部 spec 仓库可指,spec_repository 可省略
|
|
98
|
+
if (resolvedLayout === LAYOUTS.MULTI && !specRepository) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
code: CODES.FORMAT_INVALID,
|
|
102
|
+
message: '.sdd.yaml 缺少 spec_repository(单仓请写 layout: mono)',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
version,
|
|
109
|
+
layout: resolvedLayout,
|
|
110
|
+
spec_repository: specRepository || '',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function loadSddYaml(codeRepoRoot) {
|
|
115
|
+
const filePath = path.join(codeRepoRoot, '.sdd.yaml');
|
|
116
|
+
if (!fs.existsSync(filePath)) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
code: CODES.MISSING,
|
|
120
|
+
message: `.sdd.yaml 不存在: ${filePath}`,
|
|
121
|
+
path: filePath,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const parsed = parseSddYaml(fs.readFileSync(filePath, 'utf8'));
|
|
125
|
+
if (!parsed.ok) return { ...parsed, path: filePath };
|
|
126
|
+
return { ...parsed, path: filePath };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 单仓声明:.sdd.yaml 显式写了 layout: mono */
|
|
130
|
+
function isMonoLayout(repoRoot) {
|
|
131
|
+
const loaded = loadSddYaml(repoRoot);
|
|
132
|
+
return loaded.ok && loaded.layout === LAYOUTS.MONO;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function gitConfig(cwd, key) {
|
|
136
|
+
try {
|
|
137
|
+
return execFileSync('git', ['config', '--local', '--get', key], {
|
|
138
|
+
cwd,
|
|
139
|
+
encoding: 'utf8',
|
|
140
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
141
|
+
}).trim();
|
|
142
|
+
} catch {
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function gitRemoteUrl(cwd, remote = 'origin') {
|
|
148
|
+
try {
|
|
149
|
+
return execFileSync('git', ['remote', 'get-url', remote], {
|
|
150
|
+
cwd,
|
|
151
|
+
encoding: 'utf8',
|
|
152
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
153
|
+
}).trim();
|
|
154
|
+
} catch {
|
|
155
|
+
return '';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeRemoteUrl(url) {
|
|
160
|
+
if (!url) return '';
|
|
161
|
+
let value = String(url).trim().replace(/\\/g, '/');
|
|
162
|
+
value = value.replace(/\.git$/i, '');
|
|
163
|
+
const sshMatch = /^git@([^:]+):(.+)$/.exec(value);
|
|
164
|
+
if (sshMatch) {
|
|
165
|
+
return `${sshMatch[1].toLowerCase()}/${sshMatch[2].replace(/^\/+/, '').toLowerCase()}`;
|
|
166
|
+
}
|
|
167
|
+
const schemeMatch = /^https?:\/\/([^/]+)\/(.+)$/i.exec(value);
|
|
168
|
+
if (schemeMatch) {
|
|
169
|
+
return `${schemeMatch[1].toLowerCase()}/${schemeMatch[2].replace(/^\/+/, '').toLowerCase()}`;
|
|
170
|
+
}
|
|
171
|
+
return value.toLowerCase();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function remotesMatch(left, right) {
|
|
175
|
+
const a = normalizeRemoteUrl(left);
|
|
176
|
+
const b = normalizeRemoteUrl(right);
|
|
177
|
+
return Boolean(a && b && a === b);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resolveSpecPath(codeRepoRoot, env = process.env) {
|
|
181
|
+
const fromEnv = env.KLD_SDD_SPEC_PATH && String(env.KLD_SDD_SPEC_PATH).trim();
|
|
182
|
+
if (fromEnv) {
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
source: 'env',
|
|
186
|
+
path: path.resolve(fromEnv),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const fromGit = gitConfig(codeRepoRoot, 'sdd.specPath');
|
|
191
|
+
if (fromGit) {
|
|
192
|
+
return {
|
|
193
|
+
ok: true,
|
|
194
|
+
source: 'git-config',
|
|
195
|
+
path: path.resolve(fromGit),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
code: CODES.SPEC_PATH_MISSING,
|
|
202
|
+
message: '未找到 spec clone 路径。请设置 KLD_SDD_SPEC_PATH 或 git config --local sdd.specPath <path>',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function validateSpecPath(specPath) {
|
|
207
|
+
if (!specPath || !fs.existsSync(specPath)) {
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
code: CODES.SPEC_PATH_INVALID,
|
|
211
|
+
message: `spec 路径不存在: ${specPath}`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (!fs.existsSync(path.join(specPath, '.git'))) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
code: CODES.SPEC_PATH_INVALID,
|
|
218
|
+
message: `spec 路径不是 Git 仓库: ${specPath}`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return { ok: true, path: specPath };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function setSpecPath(codeRepoRoot, specPath) {
|
|
225
|
+
const absolute = path.resolve(specPath);
|
|
226
|
+
const valid = validateSpecPath(absolute);
|
|
227
|
+
if (!valid.ok) return valid;
|
|
228
|
+
execFileSync('git', ['config', '--local', 'sdd.specPath', absolute], {
|
|
229
|
+
cwd: codeRepoRoot,
|
|
230
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
231
|
+
});
|
|
232
|
+
return { ok: true, path: absolute };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function resolveCodeRepoContext(codeRepoRoot, env = process.env) {
|
|
236
|
+
const sddYaml = loadSddYaml(codeRepoRoot);
|
|
237
|
+
const specPathResult = resolveSpecPath(codeRepoRoot, env);
|
|
238
|
+
if (!specPathResult.ok) {
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
code: specPathResult.code,
|
|
242
|
+
message: specPathResult.message,
|
|
243
|
+
sddYaml,
|
|
244
|
+
specPath: null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const pathValid = validateSpecPath(specPathResult.path);
|
|
249
|
+
if (!pathValid.ok) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
code: pathValid.code,
|
|
253
|
+
message: pathValid.message,
|
|
254
|
+
sddYaml,
|
|
255
|
+
specPath: specPathResult.path,
|
|
256
|
+
specPathSource: specPathResult.source,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const result = {
|
|
261
|
+
ok: true,
|
|
262
|
+
sddYaml,
|
|
263
|
+
specPath: pathValid.path,
|
|
264
|
+
specPathSource: specPathResult.source,
|
|
265
|
+
specRemote: gitRemoteUrl(pathValid.path),
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
if (sddYaml.ok && result.specRemote) {
|
|
269
|
+
result.remoteMatches = remotesMatch(sddYaml.spec_repository, result.specRemote);
|
|
270
|
+
if (!result.remoteMatches) {
|
|
271
|
+
result.ok = false;
|
|
272
|
+
result.code = CODES.REMOTE_MISMATCH;
|
|
273
|
+
result.message = `.sdd.yaml spec_repository 与本地 spec remote 不一致: ${sddYaml.spec_repository} vs ${result.specRemote}`;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function gitHeadSha(repoRoot) {
|
|
281
|
+
try {
|
|
282
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
283
|
+
cwd: repoRoot,
|
|
284
|
+
encoding: 'utf8',
|
|
285
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
286
|
+
}).trim();
|
|
287
|
+
} catch {
|
|
288
|
+
return '';
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function gitWorkingTreeClean(repoRoot) {
|
|
293
|
+
try {
|
|
294
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
295
|
+
cwd: repoRoot,
|
|
296
|
+
encoding: 'utf8',
|
|
297
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
298
|
+
}).trim();
|
|
299
|
+
return status === '';
|
|
300
|
+
} catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function objectExists(repoRoot, sha) {
|
|
306
|
+
if (!sha) return false;
|
|
307
|
+
try {
|
|
308
|
+
execFileSync('git', ['cat-file', '-e', `${sha}^{commit}`], {
|
|
309
|
+
cwd: repoRoot,
|
|
310
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
311
|
+
});
|
|
312
|
+
return true;
|
|
313
|
+
} catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = {
|
|
319
|
+
CODES,
|
|
320
|
+
LAYOUTS,
|
|
321
|
+
parseSddYaml,
|
|
322
|
+
isMonoLayout,
|
|
323
|
+
loadSddYaml,
|
|
324
|
+
gitConfig,
|
|
325
|
+
gitRemoteUrl,
|
|
326
|
+
normalizeRemoteUrl,
|
|
327
|
+
remotesMatch,
|
|
328
|
+
resolveSpecPath,
|
|
329
|
+
validateSpecPath,
|
|
330
|
+
setSpecPath,
|
|
331
|
+
resolveCodeRepoContext,
|
|
332
|
+
gitHeadSha,
|
|
333
|
+
gitWorkingTreeClean,
|
|
334
|
+
objectExists,
|
|
335
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const WORKSPACE_FILE = '.sdd-workspace.yaml';
|
|
7
|
+
const IGNORE_DIR_NAMES = new Set([
|
|
8
|
+
'node_modules',
|
|
9
|
+
'.git',
|
|
10
|
+
'.claude',
|
|
11
|
+
'.codebuddy',
|
|
12
|
+
'.kunlunzhima',
|
|
13
|
+
'.cursor',
|
|
14
|
+
'.opencode',
|
|
15
|
+
'skywalk-sdd',
|
|
16
|
+
'openspec',
|
|
17
|
+
'openspec-templates',
|
|
18
|
+
'dist',
|
|
19
|
+
'build',
|
|
20
|
+
'coverage',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function isGitRepo(dir) {
|
|
24
|
+
return fs.existsSync(path.join(dir, '.git'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function looksLikeSpecRepo(dir) {
|
|
28
|
+
const name = path.basename(dir).toLowerCase();
|
|
29
|
+
if (/-sdd-specs$/.test(name) || name.endsWith('sdd-specs')) return true;
|
|
30
|
+
if (fs.existsSync(path.join(dir, 'modules.yaml'))) return true;
|
|
31
|
+
if (fs.existsSync(path.join(dir, 'openspec', 'changes'))) return true;
|
|
32
|
+
if (fs.existsSync(path.join(dir, 'openspec', 'specs', 'overview.md'))) return true;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function listChildGitRepos(workspaceRoot) {
|
|
37
|
+
if (!fs.existsSync(workspaceRoot)) return [];
|
|
38
|
+
return fs.readdirSync(workspaceRoot)
|
|
39
|
+
.filter((name) => !name.startsWith('.') && !IGNORE_DIR_NAMES.has(name))
|
|
40
|
+
.map((name) => ({ name, abs: path.join(workspaceRoot, name) }))
|
|
41
|
+
.filter((entry) => {
|
|
42
|
+
try {
|
|
43
|
+
return fs.statSync(entry.abs).isDirectory() && isGitRepo(entry.abs);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Detect personal workspace layout: cwd hosts multiple/sibling git repos,
|
|
53
|
+
* and skills should live once at workspace root (not in every code repo).
|
|
54
|
+
*/
|
|
55
|
+
function detectWorkspaceLayout(workspaceRoot = process.cwd()) {
|
|
56
|
+
const root = path.resolve(workspaceRoot);
|
|
57
|
+
const children = listChildGitRepos(root);
|
|
58
|
+
const rootIsGit = isGitRepo(root);
|
|
59
|
+
const rootLooksSpec = looksLikeSpecRepo(root);
|
|
60
|
+
|
|
61
|
+
const specCandidates = children.filter((c) => looksLikeSpecRepo(c.abs));
|
|
62
|
+
let spec = null;
|
|
63
|
+
if (specCandidates.length === 1) {
|
|
64
|
+
spec = specCandidates[0];
|
|
65
|
+
} else if (specCandidates.length > 1) {
|
|
66
|
+
spec = specCandidates.find((c) => fs.existsSync(path.join(c.abs, 'modules.yaml')))
|
|
67
|
+
|| specCandidates.find((c) => /-sdd-specs$/i.test(c.name))
|
|
68
|
+
|| specCandidates[0];
|
|
69
|
+
} else if (rootLooksSpec && rootIsGit) {
|
|
70
|
+
spec = { name: path.basename(root), abs: root };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const codeRepos = children.filter((c) => !spec || c.abs !== spec.abs);
|
|
74
|
+
|
|
75
|
+
const isWorkspace = (!rootIsGit && children.length >= 1)
|
|
76
|
+
|| (children.length >= 2)
|
|
77
|
+
|| (Boolean(spec) && codeRepos.length >= 1 && !rootLooksSpec);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
root,
|
|
81
|
+
isWorkspace: Boolean(isWorkspace),
|
|
82
|
+
rootIsGit,
|
|
83
|
+
specRepo: spec,
|
|
84
|
+
codeRepos,
|
|
85
|
+
allChildGitRepos: children,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseWorkspaceYaml(text) {
|
|
90
|
+
const result = {
|
|
91
|
+
version: 1,
|
|
92
|
+
spec_path: '',
|
|
93
|
+
code_repos: [],
|
|
94
|
+
};
|
|
95
|
+
const lines = String(text || '').split(/\r?\n/);
|
|
96
|
+
let inCode = false;
|
|
97
|
+
for (const raw of lines) {
|
|
98
|
+
const line = raw.replace(/#.*$/, '').replace(/\s+$/, '');
|
|
99
|
+
if (!line.trim()) continue;
|
|
100
|
+
const version = /^version:\s*(\d+)\s*$/.exec(line);
|
|
101
|
+
if (version) {
|
|
102
|
+
result.version = Number(version[1]);
|
|
103
|
+
inCode = false;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const spec = /^spec_path:\s*(.+?)\s*$/.exec(line);
|
|
107
|
+
if (spec) {
|
|
108
|
+
let value = spec[1].trim();
|
|
109
|
+
if (
|
|
110
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
111
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
112
|
+
) {
|
|
113
|
+
value = value.slice(1, -1);
|
|
114
|
+
}
|
|
115
|
+
result.spec_path = value;
|
|
116
|
+
inCode = false;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (/^code_repos:\s*$/.test(line)) {
|
|
120
|
+
inCode = true;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (inCode) {
|
|
124
|
+
const item = /^\s*-\s+(.+?)\s*$/.exec(line);
|
|
125
|
+
if (item) {
|
|
126
|
+
result.code_repos.push(item[1].trim());
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (/^\S/.test(line)) inCode = false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function loadWorkspaceFile(workspaceRoot) {
|
|
136
|
+
const filePath = path.join(workspaceRoot, WORKSPACE_FILE);
|
|
137
|
+
if (!fs.existsSync(filePath)) {
|
|
138
|
+
return { ok: false, path: filePath, data: null };
|
|
139
|
+
}
|
|
140
|
+
const data = parseWorkspaceYaml(fs.readFileSync(filePath, 'utf8'));
|
|
141
|
+
return { ok: true, path: filePath, data };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function writeWorkspaceFile(workspaceRoot, layout) {
|
|
145
|
+
const specRel = layout.specRepo
|
|
146
|
+
? path.relative(workspaceRoot, layout.specRepo.abs) || '.'
|
|
147
|
+
: '';
|
|
148
|
+
const codeRels = (layout.codeRepos || []).map((c) =>
|
|
149
|
+
path.relative(workspaceRoot, c.abs) || c.name,
|
|
150
|
+
);
|
|
151
|
+
const lines = [
|
|
152
|
+
'# 个人工作目录编排清单(不替代各仓 .sdd.yaml)',
|
|
153
|
+
'# skills 只部署在本工作目录;代码仓仅挂 Hook / 关联 spec。',
|
|
154
|
+
'version: 1',
|
|
155
|
+
`spec_path: ${specRel}`,
|
|
156
|
+
'code_repos:',
|
|
157
|
+
...codeRels.map((name) => ` - ${name}`),
|
|
158
|
+
'',
|
|
159
|
+
];
|
|
160
|
+
const filePath = path.join(workspaceRoot, WORKSPACE_FILE);
|
|
161
|
+
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
|
|
162
|
+
return filePath;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Compare discovery with saved workspace file; return newly found code repos.
|
|
167
|
+
*/
|
|
168
|
+
function findNewCodeRepos(workspaceRoot) {
|
|
169
|
+
const layout = detectWorkspaceLayout(workspaceRoot);
|
|
170
|
+
const saved = loadWorkspaceFile(workspaceRoot);
|
|
171
|
+
const known = new Set(
|
|
172
|
+
saved.ok && saved.data
|
|
173
|
+
? saved.data.code_repos.map((item) => path.resolve(workspaceRoot, item))
|
|
174
|
+
: [],
|
|
175
|
+
);
|
|
176
|
+
const newcomers = layout.codeRepos.filter((c) => !known.has(path.resolve(c.abs)));
|
|
177
|
+
return {
|
|
178
|
+
layout,
|
|
179
|
+
saved,
|
|
180
|
+
newcomers,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
WORKSPACE_FILE,
|
|
186
|
+
isGitRepo,
|
|
187
|
+
looksLikeSpecRepo,
|
|
188
|
+
listChildGitRepos,
|
|
189
|
+
detectWorkspaceLayout,
|
|
190
|
+
parseWorkspaceYaml,
|
|
191
|
+
loadWorkspaceFile,
|
|
192
|
+
writeWorkspaceFile,
|
|
193
|
+
findNewCodeRepos,
|
|
194
|
+
};
|