kld-sdd 2.6.8 → 2.6.10

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.
Files changed (31) hide show
  1. package/lib/init.js +194 -88
  2. package/package.json +2 -2
  3. package/skywalk-sdd/context-client.cjs +59 -5
  4. package/skywalk-sdd/index.cjs +3 -2
  5. package/skywalk-sdd/ontology/cli.cjs +34 -7
  6. package/skywalk-sdd/ontology/naming-diagnose.cjs +77 -17
  7. package/skywalk-sdd/ontology/resolve-spec-root.cjs +102 -0
  8. package/skywalk-sdd/ontology/sdd-config.cjs +62 -16
  9. package/skywalk-sdd/ontology/workspace-layout.cjs +81 -0
  10. package/templates/git-hooks/commit-msg-sdd-trailer.cjs +9 -4
  11. package/templates/skills/kld-sdd/openspec-sync-specs/SKILL.md +148 -0
  12. package/templates/skills/kld-sdd/openspec-update-change/SKILL.md +86 -0
  13. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +2 -1
  14. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +4 -2
  15. package/templates/skills/kld-sdd/opsx-check/SKILL.md +37 -2
  16. package/templates/skills/kld-sdd/opsx-design/SKILL.md +12 -1
  17. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +3 -1
  18. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +9 -14
  19. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +83 -109
  20. package/templates/skills/kld-sdd/opsx-ontology-query/phase-1-prechange.md +276 -0
  21. package/templates/skills/kld-sdd/opsx-ontology-query/phase-2-during.md +354 -0
  22. package/templates/skills/kld-sdd/opsx-ontology-query/phase-3-postchange.md +223 -0
  23. package/templates/skills/kld-sdd/opsx-ontology-query/phase-4-explore.md +240 -0
  24. package/templates/skills/kld-sdd/opsx-ontology-query/phase-5-governance.md +232 -0
  25. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +92 -4
  26. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +48 -4
  27. package/templates/skills/kld-sdd/opsx-propose/checklist.md +1 -0
  28. package/templates/skills/kld-sdd/opsx-rules/SKILL.md +3 -1
  29. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +36 -4
  30. package/templates/skills/kld-sdd/opsx-task/SKILL.md +12 -1
  31. package/templates/skills/kld-sdd/opsx-test/SKILL.md +3 -1
@@ -17,6 +17,7 @@ const namingDiagnose = require('./naming-diagnose.cjs');
17
17
  const listChanges = require('./list-changes.cjs');
18
18
  const sddConfig = require('./sdd-config.cjs');
19
19
  const activeChanges = require('./active-changes.cjs');
20
+ const { resolveProjectArg } = require('./resolve-spec-root.cjs');
20
21
 
21
22
  function parseArgs(argv) {
22
23
  const result = { _: [] };
@@ -70,6 +71,7 @@ function showHelp() {
70
71
  node skywalk-sdd/ontology/cli.cjs active-change --register --change=<key> [--title=] [--summary=] [--module=]
71
72
  node skywalk-sdd/ontology/cli.cjs active-change --remove --change=<key>
72
73
  node skywalk-sdd/ontology/cli.cjs active-change --list [--json]
74
+ node skywalk-sdd/ontology/cli.cjs spec-root [--project=.] [--json]
73
75
  node skywalk-sdd/ontology/cli.cjs reconcile --project=. --change=<name> [--profile=...]
74
76
  node skywalk-sdd/ontology/cli.cjs check --project=. --change=<name> [--profile=...]
75
77
  node skywalk-sdd/ontology/cli.cjs status --project=. --change=<name>
@@ -79,6 +81,10 @@ function showHelp() {
79
81
  observe 只负责采集和同步;check/archive 前仍必须执行 reconcile。`);
80
82
  }
81
83
 
84
+ function resolveCliProject(args, options = {}) {
85
+ return resolveProjectArg(args.project || '.', process.cwd(), options);
86
+ }
87
+
82
88
  function main(argv = process.argv.slice(2)) {
83
89
  const args = parseArgs(argv);
84
90
  const command = args._[0];
@@ -109,7 +115,7 @@ function main(argv = process.argv.slice(2)) {
109
115
  }
110
116
  if (command === 'change-key') {
111
117
  if (args.generate || args._[1] === 'generate') {
112
- const projectRoot = path.resolve(args.project || '.');
118
+ const projectRoot = resolveCliProject(args);
113
119
  const registry = modules.loadModulesYaml(projectRoot);
114
120
  const existing = listChanges.listActiveChanges(projectRoot);
115
121
  const built = changeKey.buildChangeKey(args.module, args.slug, {
@@ -123,7 +129,7 @@ function main(argv = process.argv.slice(2)) {
123
129
  }
124
130
  const key = args.validate || args._[1];
125
131
  if (!key) throw new Error('用法: change-key --validate <key> | --generate --module= --slug=');
126
- const projectRoot = path.resolve(args.project || '.');
132
+ const projectRoot = resolveCliProject(args);
127
133
  const registry = modules.loadModulesYaml(projectRoot);
128
134
  const result = changeKey.validate(key, {
129
135
  registeredModules: registry.ok ? registry.codes : null,
@@ -140,7 +146,7 @@ function main(argv = process.argv.slice(2)) {
140
146
  return;
141
147
  }
142
148
  if (command === 'modules') {
143
- const projectRoot = path.resolve(args.project || '.');
149
+ const projectRoot = resolveCliProject(args);
144
150
  const result = modules.loadModulesYaml(projectRoot);
145
151
  console.log(JSON.stringify({
146
152
  ok: result.ok,
@@ -170,7 +176,7 @@ function main(argv = process.argv.slice(2)) {
170
176
  return;
171
177
  }
172
178
  if (command === 'list-changes') {
173
- const projectRoot = path.resolve(args.project || '.');
179
+ const projectRoot = resolveCliProject(args);
174
180
  const result = listChanges.groupChanges(projectRoot);
175
181
  if (args.json) {
176
182
  console.log(JSON.stringify(result, null, 2));
@@ -179,8 +185,29 @@ function main(argv = process.argv.slice(2)) {
179
185
  }
180
186
  return;
181
187
  }
188
+ if (command === 'spec-root') {
189
+ const start = path.resolve(args.project || '.');
190
+ const { resolveSpecProjectRoot, isSpecPackageRoot } = require('./resolve-spec-root.cjs');
191
+ const specRoot = resolveSpecProjectRoot(start);
192
+ const payload = {
193
+ start,
194
+ specRoot,
195
+ resolved: specRoot !== start || isSpecPackageRoot(start),
196
+ openspecShim: 'node skywalk-sdd/openspec-shim.cjs <openspec-args>',
197
+ };
198
+ if (args.json) {
199
+ console.log(JSON.stringify(payload, null, 2));
200
+ } else {
201
+ console.log(`start: ${payload.start}`);
202
+ console.log(`specRoot: ${payload.specRoot}`);
203
+ console.log(`resolved: ${payload.resolved}`);
204
+ console.log(`openspec: cd "${payload.specRoot}" && openspec list`);
205
+ console.log(`shim: ${payload.openspecShim}`);
206
+ }
207
+ return;
208
+ }
182
209
  if (command === 'link-spec') {
183
- const projectRoot = path.resolve(args.project || '.');
210
+ const projectRoot = resolveCliProject(args, { forCodeRepo: true });
184
211
  const specPath = args.path || args._[1];
185
212
  if (!specPath) throw new Error('用法: link-spec --path=<spec-clone> [--project=.]');
186
213
  const result = sddConfig.setSpecPath(projectRoot, specPath);
@@ -189,7 +216,7 @@ function main(argv = process.argv.slice(2)) {
189
216
  return;
190
217
  }
191
218
  if (command === 'active-change') {
192
- const specRoot = path.resolve(args.project || '.');
219
+ const specRoot = resolveCliProject(args);
193
220
  if (args.list || args._[1] === 'list') {
194
221
  const config = activeChanges.loadSddConfig(specRoot);
195
222
  if (args.json) {
@@ -227,7 +254,7 @@ function main(argv = process.argv.slice(2)) {
227
254
  if (!result.ok) process.exitCode = 1;
228
255
  return;
229
256
  }
230
- const projectRoot = path.resolve(args.project || '.');
257
+ const projectRoot = resolveCliProject(args);
231
258
  const changeName = args.change;
232
259
  if (!changeName) throw new Error('缺少 --change 参数');
233
260
  const profile = args.profile || 'auto';
@@ -57,6 +57,7 @@ function readProposalFrontmatter(changeDir) {
57
57
  }
58
58
 
59
59
  function detectEntryMode(projectRoot) {
60
+ if (sddConfig.isMonoLayout(projectRoot)) return 'mono-repo';
60
61
  const hasModules = fs.existsSync(path.join(projectRoot, 'modules.yaml'));
61
62
  const hasOpenspecChanges = fs.existsSync(path.join(projectRoot, 'openspec', 'changes'));
62
63
  const hasSddYaml = fs.existsSync(path.join(projectRoot, '.sdd.yaml'));
@@ -281,40 +282,94 @@ function diagnoseSpecRepo(projectRoot, changeName) {
281
282
  ...diagnoseActiveChanges(projectRoot, changeName),
282
283
  });
283
284
 
284
- // 单仓(openspec 与代码同仓):Hook 装在本仓,应就地检查
285
- if (isMonoRepoLayout(projectRoot)) {
285
+ checks.push({
286
+ id: 'code-repo-association',
287
+ status: STATUS.SKIP,
288
+ message: '当前位于 spec 仓库:代码仓 .sdd.yaml / Hook 请在各代码仓库或工作目录诊断',
289
+ });
290
+
291
+ return {
292
+ mode: 'spec-repo',
293
+ checks,
294
+ summary: summarize(checks),
295
+ };
296
+ }
297
+
298
+ /**
299
+ * 单仓:Git 根有 layout: mono;SDD 内容在仓内统一包裹目录。
300
+ */
301
+ function diagnoseMonoRepo(gitRoot, changeName) {
302
+ const root = path.resolve(gitRoot);
303
+ const specRoot = sddConfig.resolveMonoSpecRoot(root);
304
+ const checks = [];
305
+
306
+ checks.push({
307
+ id: 'spec-git',
308
+ status: workspaceLayout.isGitRepo(root) ? STATUS.PASS : STATUS.FAIL,
309
+ message: workspaceLayout.isGitRepo(root)
310
+ ? '单仓 Git 根可用'
311
+ : '单仓目录不是 Git 仓库',
312
+ });
313
+
314
+ const remote = sddConfig.gitRemoteUrl(root);
315
+ checks.push({
316
+ id: 'spec-remote',
317
+ status: remote ? STATUS.PASS : STATUS.NEEDS_INPUT,
318
+ message: remote ? `remote 可识别: ${remote}` : '无法识别 git remote',
319
+ remote,
320
+ });
321
+
322
+ const registry = modules.loadModulesYaml(specRoot);
323
+ checks.push({
324
+ id: 'modules-yaml',
325
+ status: registry.ok ? STATUS.PASS : (registry.code === modules.CODES.MISSING ? STATUS.NEEDS_INPUT : STATUS.FAIL),
326
+ message: registry.ok
327
+ ? `modules.yaml 合法(${registry.codes.size} 个模块,路径 ${path.relative(root, specRoot) || '.'})`
328
+ : registry.message,
329
+ registry,
330
+ });
331
+
332
+ if (changeName) {
333
+ const naming = diagnoseChangeNaming(specRoot, changeName, registry.ok ? registry : null);
286
334
  checks.push({
287
- id: 'commit-hook',
288
- ...checkHookInstalled(projectRoot),
335
+ id: 'change-naming',
336
+ status: naming.status,
337
+ message: naming.message,
338
+ detail: naming,
289
339
  });
340
+ } else {
290
341
  checks.push({
291
- id: 'code-repo-association',
292
- status: STATUS.PASS,
293
- message: '单仓布局(.sdd.yaml layout: mono):commit 写 Spec-Change,不写 Spec-Revision',
342
+ id: 'change-naming',
343
+ status: STATUS.SKIP,
344
+ message: '未指定 --change,跳过命名一致性检查',
294
345
  });
295
- return {
296
- mode: 'mono-repo',
297
- checks,
298
- summary: summarize(checks),
299
- };
300
346
  }
301
347
 
348
+ checks.push({
349
+ id: 'active-changes',
350
+ ...diagnoseActiveChanges(specRoot, changeName),
351
+ });
352
+
353
+ checks.push({
354
+ id: 'commit-hook',
355
+ ...checkHookInstalled(root),
356
+ });
302
357
  checks.push({
303
358
  id: 'code-repo-association',
304
- status: STATUS.SKIP,
305
- message: '当前位于 spec 仓库:代码仓 .sdd.yaml / Hook 请在各代码仓库或工作目录诊断',
359
+ status: STATUS.PASS,
360
+ message: `单仓布局(layout: mono,包裹包 ${path.relative(root, specRoot) || '.'}):commit Spec-Change,不写 Spec-Revision`,
306
361
  });
307
362
 
308
363
  return {
309
- mode: 'spec-repo',
364
+ mode: 'mono-repo',
310
365
  checks,
311
366
  summary: summarize(checks),
367
+ specRoot,
312
368
  };
313
369
  }
314
370
 
315
371
  /**
316
- * 单仓:openspec 与代码同仓,由 .sdd.yaml 的 layout: mono 显式声明。
317
- * 不用启发式,否则独立的 spec 仓 clone 会被误判成单仓。
372
+ * 单仓:由 .sdd.yaml 的 layout: mono 显式声明。
318
373
  */
319
374
  function isMonoRepoLayout(projectRoot) {
320
375
  return sddConfig.isMonoLayout(path.resolve(projectRoot));
@@ -532,6 +587,10 @@ function diagnose(projectRoot, options = {}) {
532
587
  };
533
588
  }
534
589
 
590
+ if (mode === 'mono-repo' || isMonoRepoLayout(root)) {
591
+ return diagnoseMonoRepo(root, options.change);
592
+ }
593
+
535
594
  if (mode === 'code-repo') {
536
595
  return diagnoseCodeRepo(root);
537
596
  }
@@ -586,6 +645,7 @@ module.exports = {
586
645
  diagnoseChangeNaming,
587
646
  diagnoseActiveChanges,
588
647
  diagnoseWorkspaceRepos,
648
+ diagnoseMonoRepo,
589
649
  isMonoRepoLayout,
590
650
  diagnose,
591
651
  diagnoseTrailer,
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const sddConfig = require('./sdd-config.cjs');
6
+ const workspaceLayout = require('./workspace-layout.cjs');
7
+
8
+ function isSpecPackageRoot(dir) {
9
+ const root = path.resolve(dir);
10
+ return (
11
+ fs.existsSync(path.join(root, 'openspec', 'changes'))
12
+ || fs.existsSync(path.join(root, 'modules.yaml'))
13
+ || fs.existsSync(path.join(root, 'sdd.config.yaml'))
14
+ );
15
+ }
16
+
17
+ /**
18
+ * 解析 SDD 文档根(*-sdd-specs 包裹包或旧平铺 spec 根)。
19
+ * openspec CLI 只认 cwd 下的 openspec/;本函数供 log.cjs / ontology cli 自动定位包裹包。
20
+ *
21
+ * 解析顺序:
22
+ * 1. start 本身已是 spec 包根
23
+ * 2. KLD_SDD_SPEC_ROOT(绝对或相对 start)
24
+ * 3. 单仓 layout: mono → spec_path / 子目录 *-sdd-specs
25
+ * 4. 工作区 layout → specRepo
26
+ * 5. start 下嵌套包裹包
27
+ * 6. 代码仓 git config sdd.specPath(含仓内 nested 包裹)
28
+ * 7. 向上最多 3 层找 mono / 嵌套包(Git 根、工作区根常见)
29
+ * 8. 回退 start
30
+ */
31
+ function resolveSpecProjectRoot(startDir = process.cwd(), options = {}) {
32
+ const start = path.resolve(startDir || process.cwd());
33
+ if (options.skipResolve || options.forCodeRepo) return start;
34
+ if (isSpecPackageRoot(start)) return start;
35
+
36
+ const envRoot = process.env.KLD_SDD_SPEC_ROOT && String(process.env.KLD_SDD_SPEC_ROOT).trim();
37
+ if (envRoot) {
38
+ const candidate = path.isAbsolute(envRoot) ? envRoot : path.resolve(start, envRoot);
39
+ if (fs.existsSync(candidate)) return candidate;
40
+ }
41
+
42
+ if (sddConfig.isMonoLayout(start)) {
43
+ const monoRoot = sddConfig.resolveMonoSpecRoot(start);
44
+ if (fs.existsSync(monoRoot)) return monoRoot;
45
+ }
46
+
47
+ const layout = workspaceLayout.detectWorkspaceLayout(start);
48
+ if (layout.specRepo && fs.existsSync(layout.specRepo.abs)) {
49
+ return layout.specRepo.abs;
50
+ }
51
+
52
+ const nested = workspaceLayout.findNestedSpecPackage(start);
53
+ if (nested) return nested.abs;
54
+
55
+ const specPathResult = sddConfig.resolveSpecPath(start);
56
+ if (specPathResult.ok) {
57
+ const valid = sddConfig.validateSpecPath(specPathResult.path, { allowNestedPackage: true });
58
+ if (valid.ok) return valid.path;
59
+ }
60
+
61
+ let dir = start;
62
+ for (let depth = 0; depth < 3; depth += 1) {
63
+ const parent = path.dirname(dir);
64
+ if (parent === dir) break;
65
+ if (sddConfig.isMonoLayout(parent)) {
66
+ const monoRoot = sddConfig.resolveMonoSpecRoot(parent);
67
+ if (fs.existsSync(monoRoot)) return monoRoot;
68
+ }
69
+ const parentNested = workspaceLayout.findNestedSpecPackage(parent);
70
+ if (parentNested) return parentNested.abs;
71
+ const parentLayout = workspaceLayout.detectWorkspaceLayout(parent);
72
+ if (parentLayout.specRepo && fs.existsSync(parentLayout.specRepo.abs)) {
73
+ return parentLayout.specRepo.abs;
74
+ }
75
+ dir = parent;
76
+ }
77
+
78
+ return start;
79
+ }
80
+
81
+ /**
82
+ * CLI / skills 用:解析 --project 参数。
83
+ * '.' 或未显式绝对 spec 根时走 resolve;link-spec 等代码仓命令传 forCodeRepo:true。
84
+ */
85
+ function resolveProjectArg(projectArg, startDir = process.cwd(), options = {}) {
86
+ const raw = projectArg == null || projectArg === '' ? '.' : String(projectArg);
87
+ const resolved = path.resolve(startDir, raw);
88
+ if (options.forCodeRepo) return resolved;
89
+ if (raw !== '.' && raw !== './' && path.isAbsolute(raw)) {
90
+ return isSpecPackageRoot(resolved) ? resolved : resolveSpecProjectRoot(resolved, options);
91
+ }
92
+ if (raw !== '.' && raw !== './') {
93
+ if (isSpecPackageRoot(resolved)) return resolved;
94
+ }
95
+ return resolveSpecProjectRoot(startDir, options);
96
+ }
97
+
98
+ module.exports = {
99
+ isSpecPackageRoot,
100
+ resolveSpecProjectRoot,
101
+ resolveProjectArg,
102
+ };
@@ -35,15 +35,17 @@ function stripYamlQuotes(value) {
35
35
  * version: 1
36
36
  * spec_repository: git@host:group/repo.git
37
37
  *
38
- * 单仓:openspec 与代码同仓,无外部 spec 可指
38
+ * 单仓:同一 Git 仓内用统一包裹目录存放 openspec / 配置
39
39
  * version: 1
40
40
  * layout: mono
41
+ * spec_path: myapp-sdd-specs
41
42
  */
42
43
  function parseSddYaml(text) {
43
44
  const lines = String(text || '').split(/\r?\n/);
44
45
  let version = null;
45
46
  let specRepository = null;
46
47
  let layout = null;
48
+ let specPathRel = null;
47
49
 
48
50
  for (let index = 0; index < lines.length; index += 1) {
49
51
  const raw = lines[index];
@@ -76,6 +78,12 @@ function parseSddYaml(text) {
76
78
  continue;
77
79
  }
78
80
 
81
+ const pathMatch = /^spec_path:\s*(.+?)\s*$/.exec(line);
82
+ if (pathMatch) {
83
+ specPathRel = stripYamlQuotes(pathMatch[1]);
84
+ continue;
85
+ }
86
+
79
87
  return {
80
88
  ok: false,
81
89
  code: CODES.FORMAT_INVALID,
@@ -108,6 +116,7 @@ function parseSddYaml(text) {
108
116
  version,
109
117
  layout: resolvedLayout,
110
118
  spec_repository: specRepository || '',
119
+ spec_path: specPathRel || '',
111
120
  };
112
121
  }
113
122
 
@@ -132,6 +141,29 @@ function isMonoLayout(repoRoot) {
132
141
  return loaded.ok && loaded.layout === LAYOUTS.MONO;
133
142
  }
134
143
 
144
+ /**
145
+ * 单仓 SDD 包裹根:优先 .sdd.yaml spec_path → 子目录 *-sdd-specs → 兼容旧平铺根目录。
146
+ */
147
+ function resolveMonoSpecRoot(repoRoot) {
148
+ const root = path.resolve(repoRoot);
149
+ const loaded = loadSddYaml(root);
150
+ if (loaded.ok && loaded.spec_path) {
151
+ return path.resolve(root, loaded.spec_path);
152
+ }
153
+ const workspaceLayout = require('./workspace-layout.cjs');
154
+ const nested = workspaceLayout.findNestedSpecPackage(root);
155
+ if (nested) return nested.abs;
156
+ return root;
157
+ }
158
+
159
+ function looksLikeSpecPackageContent(dir) {
160
+ return (
161
+ fs.existsSync(path.join(dir, 'modules.yaml'))
162
+ || fs.existsSync(path.join(dir, 'sdd.config.yaml'))
163
+ || fs.existsSync(path.join(dir, 'openspec', 'changes'))
164
+ );
165
+ }
166
+
135
167
  function gitConfig(cwd, key) {
136
168
  try {
137
169
  return execFileSync('git', ['config', '--local', '--get', key], {
@@ -203,7 +235,7 @@ function resolveSpecPath(codeRepoRoot, env = process.env) {
203
235
  };
204
236
  }
205
237
 
206
- function validateSpecPath(specPath) {
238
+ function validateSpecPath(specPath, options = {}) {
207
239
  if (!specPath || !fs.existsSync(specPath)) {
208
240
  return {
209
241
  ok: false,
@@ -211,25 +243,37 @@ function validateSpecPath(specPath) {
211
243
  message: `spec 路径不存在: ${specPath}`,
212
244
  };
213
245
  }
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
- };
246
+ if (fs.existsSync(path.join(specPath, '.git'))) {
247
+ return { ok: true, path: specPath, nested: false };
248
+ }
249
+ // 单仓内部包裹包:不是独立 Git,但内容齐全且落在某个 Git 仓内
250
+ if (options.allowNestedPackage && looksLikeSpecPackageContent(specPath)) {
251
+ return { ok: true, path: specPath, nested: true };
220
252
  }
221
- return { ok: true, path: specPath };
253
+ return {
254
+ ok: false,
255
+ code: CODES.SPEC_PATH_INVALID,
256
+ message: `spec 路径不是 Git 仓库: ${specPath}`,
257
+ };
222
258
  }
223
259
 
224
- function setSpecPath(codeRepoRoot, specPath) {
260
+ function setSpecPath(codeRepoRoot, specPath, options = {}) {
225
261
  const absolute = path.resolve(specPath);
226
- const valid = validateSpecPath(absolute);
262
+ const valid = validateSpecPath(absolute, options);
227
263
  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 };
264
+ try {
265
+ execFileSync('git', ['config', '--local', 'sdd.specPath', absolute], {
266
+ cwd: codeRepoRoot,
267
+ stdio: ['ignore', 'pipe', 'pipe'],
268
+ });
269
+ } catch (error) {
270
+ return {
271
+ ok: false,
272
+ code: CODES.SPEC_PATH_INVALID,
273
+ message: `无法写入 git config sdd.specPath(请确认目录是真实 Git 仓库): ${error.message}`,
274
+ };
275
+ }
276
+ return { ok: true, path: absolute, nested: Boolean(valid.nested) };
233
277
  }
234
278
 
235
279
  function resolveCodeRepoContext(codeRepoRoot, env = process.env) {
@@ -320,6 +364,8 @@ module.exports = {
320
364
  LAYOUTS,
321
365
  parseSddYaml,
322
366
  isMonoLayout,
367
+ resolveMonoSpecRoot,
368
+ looksLikeSpecPackageContent,
323
369
  loadSddYaml,
324
370
  gitConfig,
325
371
  gitRemoteUrl,
@@ -181,14 +181,95 @@ function findNewCodeRepos(workspaceRoot) {
181
181
  };
182
182
  }
183
183
 
184
+ /**
185
+ * 推导统一 SDD 包裹目录名:`<parentBasename>-sdd-specs`
186
+ */
187
+ function deriveSpecPackageName(parentDir) {
188
+ const base = path.basename(path.resolve(parentDir)).toLowerCase();
189
+ if (!base || base === '.' || base === '/' || base === '\\') return 'project-sdd-specs';
190
+ if (base.endsWith('sdd-specs')) return path.basename(path.resolve(parentDir));
191
+ return `${path.basename(path.resolve(parentDir))}-sdd-specs`;
192
+ }
193
+
194
+ function listChildDirectories(parentDir) {
195
+ const root = path.resolve(parentDir);
196
+ if (!fs.existsSync(root)) return [];
197
+ return fs.readdirSync(root)
198
+ .filter((name) => !name.startsWith('.') && !IGNORE_DIR_NAMES.has(name))
199
+ .map((name) => ({ name, abs: path.join(root, name) }))
200
+ .filter((entry) => {
201
+ try {
202
+ return fs.statSync(entry.abs).isDirectory();
203
+ } catch {
204
+ return false;
205
+ }
206
+ })
207
+ .sort((a, b) => a.name.localeCompare(b.name));
208
+ }
209
+
210
+ /**
211
+ * 在父目录下查找已有的 SDD 包裹包(不必是独立 Git)。
212
+ * 优先:modules.yaml → 目录名 *-sdd-specs → openspec/changes。
213
+ */
214
+ function findNestedSpecPackage(parentDir) {
215
+ const children = listChildDirectories(parentDir);
216
+ const candidates = children.filter((c) => looksLikeSpecRepo(c.abs));
217
+ if (candidates.length === 0) return null;
218
+ return (
219
+ candidates.find((c) => fs.existsSync(path.join(c.abs, 'modules.yaml')))
220
+ || candidates.find((c) => /-sdd-specs$/i.test(c.name))
221
+ || candidates[0]
222
+ );
223
+ }
224
+
225
+ /**
226
+ * 确保父目录下存在统一的 SDD 包裹包目录。
227
+ * @param {{ name?: string, initGit?: boolean }} options
228
+ * initGit=true:多仓兄弟目录,包裹包本身是独立 Git 仓
229
+ * initGit=false:单仓内部子目录,共用外层 Git
230
+ */
231
+ function ensureSpecPackage(parentDir, options = {}) {
232
+ const { execFileSync } = require('child_process');
233
+ const root = path.resolve(parentDir);
234
+ const existing = findNestedSpecPackage(root);
235
+ if (existing) {
236
+ return {
237
+ name: existing.name,
238
+ abs: existing.abs,
239
+ created: false,
240
+ isGit: isGitRepo(existing.abs),
241
+ };
242
+ }
243
+
244
+ const name = options.name || deriveSpecPackageName(root);
245
+ const abs = path.join(root, name);
246
+ const existed = fs.existsSync(abs);
247
+ fs.mkdirSync(abs, { recursive: true });
248
+
249
+ let isGit = isGitRepo(abs);
250
+ if (options.initGit && !isGit) {
251
+ execFileSync('git', ['init'], {
252
+ cwd: abs,
253
+ stdio: ['ignore', 'pipe', 'pipe'],
254
+ });
255
+ isGit = true;
256
+ }
257
+
258
+ return { name, abs, created: !existed, isGit };
259
+ }
260
+
184
261
  module.exports = {
185
262
  WORKSPACE_FILE,
186
263
  isGitRepo,
187
264
  looksLikeSpecRepo,
188
265
  listChildGitRepos,
266
+ listChildDirectories,
189
267
  detectWorkspaceLayout,
190
268
  parseWorkspaceYaml,
191
269
  loadWorkspaceFile,
192
270
  writeWorkspaceFile,
193
271
  findNewCodeRepos,
272
+ deriveSpecPackageName,
273
+ findNestedSpecPackage,
274
+ ensureSpecPackage,
194
275
  };
@@ -60,7 +60,7 @@ function resolveLayout(repoRoot, env = process.env) {
60
60
  return { mode: 'unlinked' };
61
61
  }
62
62
  if (sddConfig.isMonoLayout(repoRoot)) {
63
- return { mode: 'mono', specPath: repoRoot };
63
+ return { mode: 'mono', specPath: sddConfig.resolveMonoSpecRoot(repoRoot) };
64
64
  }
65
65
 
66
66
  const context = sddConfig.resolveCodeRepoContext(repoRoot, env);
@@ -70,9 +70,14 @@ function resolveLayout(repoRoot, env = process.env) {
70
70
  if (context.code === sddConfig.CODES.SPEC_PATH_INVALID) {
71
71
  fail(context.message);
72
72
  }
73
- // specPath 指回本仓:等同单仓,不能按外部 spec 校验干净度
74
- if (path.resolve(context.specPath) === path.resolve(repoRoot)) {
75
- return { mode: 'mono', specPath: repoRoot, context };
73
+ // specPath 指回本仓根:等同单仓(兼容旧平铺);若指向仓内包裹包仍按 mono
74
+ const resolvedSpec = path.resolve(context.specPath);
75
+ const resolvedRoot = path.resolve(repoRoot);
76
+ if (resolvedSpec === resolvedRoot) {
77
+ return { mode: 'mono', specPath: sddConfig.resolveMonoSpecRoot(repoRoot), context };
78
+ }
79
+ if (resolvedSpec.startsWith(`${resolvedRoot}${path.sep}`)) {
80
+ return { mode: 'mono', specPath: resolvedSpec, context };
76
81
  }
77
82
  return { mode: 'multi', specPath: context.specPath, context };
78
83
  }