kld-sdd 2.6.9 → 2.6.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kld-sdd",
3
- "version": "2.6.9",
3
+ "version": "2.6.11",
4
4
  "description": "KLD SDD OpenSpec 项目初始化工具 - 一键部署 SDD skills",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  "kld-sdd-init": "bin/kld-sdd-init.js"
9
9
  },
10
10
  "scripts": {
11
- "test": "node test/external-key.cjs && node test/change-key.cjs && node test/modules-and-sdd-config.cjs && node test/active-changes.cjs && node test/hook-layouts.cjs && node test/workspace-layout.cjs && node test/ontology-release-blockers.cjs && node test/ontology-semantic-core.cjs && node test/ontology-identity-versioning.cjs && node test/ontology-identity-continuity.cjs && node test/ontology-state-transaction.cjs && node test/ontology-process-concurrency.cjs && node test/ontology-observer-convergence.cjs && node test/ontology-working-runtime.cjs && node test/ontology-stage-materialization.cjs && node test/ontology-template-contract.cjs && node test/ontology-cli-archive.cjs && node test/archive-package-producer.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/settings-merge.cjs && node test/command-bridge.cjs && node test/codebuddy-hooks.cjs && node test/skill-content-contract.cjs && node test/init-agent-profiles.cjs"
11
+ "test": "node test/external-key.cjs && node test/change-key.cjs && node test/modules-and-sdd-config.cjs && node test/active-changes.cjs && node test/hook-layouts.cjs && node test/spec-package-init.cjs && node test/workspace-layout.cjs && node test/ontology-release-blockers.cjs && node test/ontology-semantic-core.cjs && node test/ontology-identity-versioning.cjs && node test/ontology-identity-continuity.cjs && node test/ontology-state-transaction.cjs && node test/ontology-process-concurrency.cjs && node test/ontology-observer-convergence.cjs && node test/ontology-working-runtime.cjs && node test/ontology-stage-materialization.cjs && node test/ontology-template-contract.cjs && node test/ontology-cli-archive.cjs && node test/archive-package-producer.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/settings-merge.cjs && node test/command-bridge.cjs && node test/codebuddy-hooks.cjs && node test/skill-content-contract.cjs && node test/init-agent-profiles.cjs"
12
12
  },
13
13
  "keywords": [
14
14
  "kld",
@@ -13,6 +13,7 @@
13
13
 
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
+ const { resolveProjectArg } = require('./ontology/resolve-spec-root.cjs');
16
17
  const crypto = require('crypto');
17
18
  const { execFileSync } = require('child_process');
18
19
 
@@ -73,11 +74,11 @@ function nowISO() {
73
74
  }
74
75
 
75
76
  function normalizeProjectRoot(projectRoot) {
76
- const rawProjectRoot = projectRoot || process.cwd();
77
+ const rawProjectRoot = projectRoot || '.';
77
78
  if (process.platform === 'win32' && /^[a-zA-Z]:[^\\/]/.test(String(rawProjectRoot))) {
78
79
  fail(`Windows 项目路径格式不安全: ${rawProjectRoot}。请使用 --project=.,或使用正斜杠路径如 D:/project/demo,或给路径加引号。`);
79
80
  }
80
- return path.resolve(rawProjectRoot);
81
+ return resolveProjectArg(rawProjectRoot, process.cwd());
81
82
  }
82
83
 
83
84
  function inferAgentType(args) {
@@ -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'));
@@ -66,17 +67,40 @@ function detectEntryMode(projectRoot) {
66
67
  return 'unknown';
67
68
  }
68
69
 
70
+ function resolveCommitMsgScriptPath(projectRoot) {
71
+ const localScript = path.join(projectRoot, 'skywalk-sdd', 'git-hooks', 'commit-msg-sdd-trailer.cjs');
72
+ if (fs.existsSync(localScript)) return localScript;
73
+ // 多仓代码仓:脚本在 spec 包裹包,不在本仓 skywalk-sdd/
74
+ try {
75
+ const context = sddConfig.resolveCodeRepoContext(projectRoot);
76
+ if (context && context.specPath) {
77
+ const remote = path.join(context.specPath, 'skywalk-sdd', 'git-hooks', 'commit-msg-sdd-trailer.cjs');
78
+ if (fs.existsSync(remote)) return remote;
79
+ }
80
+ } catch {
81
+ // ignore
82
+ }
83
+ if (sddConfig.isMonoLayout(projectRoot)) {
84
+ const monoSpec = sddConfig.resolveMonoSpecRoot(projectRoot);
85
+ if (monoSpec) {
86
+ const nested = path.join(monoSpec, 'skywalk-sdd', 'git-hooks', 'commit-msg-sdd-trailer.cjs');
87
+ if (fs.existsSync(nested)) return nested;
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+
69
93
  function checkHookInstalled(projectRoot) {
70
94
  const hookPath = path.join(projectRoot, '.git', 'hooks', 'commit-msg');
71
- const scriptPath = path.join(projectRoot, 'skywalk-sdd', 'git-hooks', 'commit-msg-sdd-trailer.cjs');
95
+ const scriptPath = resolveCommitMsgScriptPath(projectRoot);
72
96
  if (!fs.existsSync(path.join(projectRoot, '.git'))) {
73
97
  return { status: STATUS.SKIP, message: '非 Git 仓库,跳过 Hook 检查' };
74
98
  }
75
- if (!fs.existsSync(scriptPath)) {
99
+ if (!scriptPath) {
76
100
  return {
77
101
  status: STATUS.FIXABLE,
78
102
  code: 'HOOK_SCRIPT_MISSING',
79
- message: 'commit-msg 脚本未部署,可重新执行 kld-sdd-init 或手动复制模板',
103
+ message: 'commit-msg 脚本未找到(本仓或 sdd.specPath 下均无 skywalk-sdd/git-hooks/),可重新执行 kld-sdd-init',
80
104
  fix: { type: 'deploy-hook-script' },
81
105
  };
82
106
  }
@@ -281,40 +305,94 @@ function diagnoseSpecRepo(projectRoot, changeName) {
281
305
  ...diagnoseActiveChanges(projectRoot, changeName),
282
306
  });
283
307
 
284
- // 单仓(openspec 与代码同仓):Hook 装在本仓,应就地检查
285
- if (isMonoRepoLayout(projectRoot)) {
308
+ checks.push({
309
+ id: 'code-repo-association',
310
+ status: STATUS.SKIP,
311
+ message: '当前位于 spec 仓库:代码仓 .sdd.yaml / Hook 请在各代码仓库或工作目录诊断',
312
+ });
313
+
314
+ return {
315
+ mode: 'spec-repo',
316
+ checks,
317
+ summary: summarize(checks),
318
+ };
319
+ }
320
+
321
+ /**
322
+ * 单仓:Git 根有 layout: mono;SDD 内容在仓内统一包裹目录。
323
+ */
324
+ function diagnoseMonoRepo(gitRoot, changeName) {
325
+ const root = path.resolve(gitRoot);
326
+ const specRoot = sddConfig.resolveMonoSpecRoot(root);
327
+ const checks = [];
328
+
329
+ checks.push({
330
+ id: 'spec-git',
331
+ status: workspaceLayout.isGitRepo(root) ? STATUS.PASS : STATUS.FAIL,
332
+ message: workspaceLayout.isGitRepo(root)
333
+ ? '单仓 Git 根可用'
334
+ : '单仓目录不是 Git 仓库',
335
+ });
336
+
337
+ const remote = sddConfig.gitRemoteUrl(root);
338
+ checks.push({
339
+ id: 'spec-remote',
340
+ status: remote ? STATUS.PASS : STATUS.NEEDS_INPUT,
341
+ message: remote ? `remote 可识别: ${remote}` : '无法识别 git remote',
342
+ remote,
343
+ });
344
+
345
+ const registry = modules.loadModulesYaml(specRoot);
346
+ checks.push({
347
+ id: 'modules-yaml',
348
+ status: registry.ok ? STATUS.PASS : (registry.code === modules.CODES.MISSING ? STATUS.NEEDS_INPUT : STATUS.FAIL),
349
+ message: registry.ok
350
+ ? `modules.yaml 合法(${registry.codes.size} 个模块,路径 ${path.relative(root, specRoot) || '.'})`
351
+ : registry.message,
352
+ registry,
353
+ });
354
+
355
+ if (changeName) {
356
+ const naming = diagnoseChangeNaming(specRoot, changeName, registry.ok ? registry : null);
286
357
  checks.push({
287
- id: 'commit-hook',
288
- ...checkHookInstalled(projectRoot),
358
+ id: 'change-naming',
359
+ status: naming.status,
360
+ message: naming.message,
361
+ detail: naming,
289
362
  });
363
+ } else {
290
364
  checks.push({
291
- id: 'code-repo-association',
292
- status: STATUS.PASS,
293
- message: '单仓布局(.sdd.yaml layout: mono):commit 写 Spec-Change,不写 Spec-Revision',
365
+ id: 'change-naming',
366
+ status: STATUS.SKIP,
367
+ message: '未指定 --change,跳过命名一致性检查',
294
368
  });
295
- return {
296
- mode: 'mono-repo',
297
- checks,
298
- summary: summarize(checks),
299
- };
300
369
  }
301
370
 
371
+ checks.push({
372
+ id: 'active-changes',
373
+ ...diagnoseActiveChanges(specRoot, changeName),
374
+ });
375
+
376
+ checks.push({
377
+ id: 'commit-hook',
378
+ ...checkHookInstalled(root),
379
+ });
302
380
  checks.push({
303
381
  id: 'code-repo-association',
304
- status: STATUS.SKIP,
305
- message: '当前位于 spec 仓库:代码仓 .sdd.yaml / Hook 请在各代码仓库或工作目录诊断',
382
+ status: STATUS.PASS,
383
+ message: `单仓布局(layout: mono,包裹包 ${path.relative(root, specRoot) || '.'}):commit Spec-Change,不写 Spec-Revision`,
306
384
  });
307
385
 
308
386
  return {
309
- mode: 'spec-repo',
387
+ mode: 'mono-repo',
310
388
  checks,
311
389
  summary: summarize(checks),
390
+ specRoot,
312
391
  };
313
392
  }
314
393
 
315
394
  /**
316
- * 单仓:openspec 与代码同仓,由 .sdd.yaml 的 layout: mono 显式声明。
317
- * 不用启发式,否则独立的 spec 仓 clone 会被误判成单仓。
395
+ * 单仓:由 .sdd.yaml 的 layout: mono 显式声明。
318
396
  */
319
397
  function isMonoRepoLayout(projectRoot) {
320
398
  return sddConfig.isMonoLayout(path.resolve(projectRoot));
@@ -532,6 +610,10 @@ function diagnose(projectRoot, options = {}) {
532
610
  };
533
611
  }
534
612
 
613
+ if (mode === 'mono-repo' || isMonoRepoLayout(root)) {
614
+ return diagnoseMonoRepo(root, options.change);
615
+ }
616
+
535
617
  if (mode === 'code-repo') {
536
618
  return diagnoseCodeRepo(root);
537
619
  }
@@ -586,6 +668,7 @@ module.exports = {
586
668
  diagnoseChangeNaming,
587
669
  diagnoseActiveChanges,
588
670
  diagnoseWorkspaceRepos,
671
+ diagnoseMonoRepo,
589
672
  isMonoRepoLayout,
590
673
  diagnose,
591
674
  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
  };