kld-sdd 2.7.8-2 → 2.7.8-4

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 (41) hide show
  1. package/README.md +2 -0
  2. package/USABILITY.md +52 -0
  3. package/bin/kld-sdd-init.js +700 -34
  4. package/lib/command-bridge.js +26 -17
  5. package/lib/deploy-codebuddy-hooks.js +19 -26
  6. package/lib/deploy-strategy.js +293 -0
  7. package/lib/hook-gate-core.js +7 -8
  8. package/lib/hook-install.js +75 -0
  9. package/lib/init.js +450 -711
  10. package/lib/managed-hook-hashes.json +24 -0
  11. package/lib/settings-merge.js +102 -25
  12. package/lib/skills-bundle.js +2 -4
  13. package/lib/spec-choice.js +85 -0
  14. package/lib/tool-profiles.js +2 -2
  15. package/lib/uninstall.js +764 -0
  16. package/package.json +3 -3
  17. package/skywalk-sdd/index.cjs +1 -1
  18. package/skywalk-sdd/kb-upload.cjs +64 -49
  19. package/skywalk-sdd/lib/shared.cjs +62 -41
  20. package/skywalk-sdd/lib/test-execution.cjs +20 -0
  21. package/skywalk-sdd/lib/usage-reporter.cjs +17 -8
  22. package/skywalk-sdd/ontology/resolve-spec-root.cjs +49 -0
  23. package/skywalk-sdd/ontology/sdd-config.cjs +33 -5
  24. package/skywalk-sdd/ontology/spec-detect.cjs +421 -0
  25. package/skywalk-sdd/ontology/workspace-layout.cjs +46 -16
  26. package/templates/git-hooks/commit-msg +17 -99
  27. package/templates/git-hooks/commit-msg-runner.cjs +44 -0
  28. package/templates/git-hooks/commit-msg-sdd-trailer.cjs +60 -69
  29. package/templates/git-hooks/consistency-check-core.cjs +61 -240
  30. package/templates/git-hooks/delivery-check.cjs +231 -0
  31. package/templates/git-hooks/delivery-snapshot.cjs +181 -0
  32. package/templates/git-hooks/hooks.config +8 -6
  33. package/templates/git-hooks/pre-commit-consistency-check.cjs +13 -31
  34. package/templates/git-hooks/pre-push-consistency-check.cjs +36 -83
  35. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +7 -8
  36. package/templates/openspec/proposal.md +1 -1
  37. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +24 -22
  38. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +34 -112
  39. package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +15 -14
  40. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +157 -11
  41. package/index.js +0 -13
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "kld-sdd",
3
- "version": "2.7.8-2",
3
+ "version": "2.7.8-4",
4
4
  "description": "KLD SDD OpenSpec 项目初始化工具 - 一键部署 SDD skills",
5
- "main": "index.js",
5
+ "main": "lib/init.js",
6
6
  "bin": {
7
7
  "kld-sdd": "bin/kld-sdd-init.js",
8
8
  "kld-sdd-init": "bin/kld-sdd-init.js"
9
9
  },
10
10
  "scripts": {
11
11
  "bench:check": "node test/benchmark-check.cjs",
12
- "test": "node --test test/execution-friction.test.cjs test/kb-fact-lifecycle.test.cjs test/workflow-usability.test.cjs test/check-efficiency.test.cjs && 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/prompt-spec-path.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 test/evidence-integrity-oracle.cjs test/evidence-integrity-wiring.cjs test/change-report-correctness.cjs test/report-metrics-correctness.cjs test/change-report-model-ui.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 && node test/init-report-runtime.cjs && node test/init-output-verbosity.cjs && node --test test/usage-contract.test.cjs test/user-config.test.cjs test/usage-runtime-removal.test.cjs skywalk-sdd/usage-reporter.test.cjs skywalk-sdd/usage-reporting.test.cjs && node test/progress-package-artifact.test.cjs && node --test test/progress-package-install.e2e.cjs && node test/consistency-check-core.cjs"
12
+ "test": "node --test test/delivery-hooks.test.cjs test/execution-friction.test.cjs test/kb-fact-lifecycle.test.cjs test/workflow-usability.test.cjs test/check-efficiency.test.cjs && 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/prompt-spec-path.cjs && node test/spec-detect.cjs && node test/workspace-layout.cjs && node test/sync-repos-spec-choice.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 test/evidence-integrity-oracle.cjs test/evidence-integrity-wiring.cjs test/change-report-correctness.cjs test/report-metrics-correctness.cjs test/change-report-model-ui.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/deploy-strategy.cjs && node test/uninstall.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 && node test/init-report-runtime.cjs && node test/init-output-verbosity.cjs && node --test test/usage-contract.test.cjs test/user-config.test.cjs test/usage-runtime-removal.test.cjs test/runtime/usage-reporter.test.cjs test/runtime/usage-reporting.test.cjs test/runtime/git-identity.test.cjs test/runtime/kb-state-location.test.cjs && node test/runtime/metrics-v3.test.cjs && node test/runtime/fixback.test.cjs && node test/progress-package-artifact.test.cjs && node --test test/progress-package-install.e2e.cjs && node test/consistency-check-core.cjs"
13
13
  },
14
14
  "keywords": [
15
15
  "kld",
@@ -8610,7 +8610,7 @@ SDD Telemetry CLI - 流程度量采集工具
8610
8610
  check-task 扫描变更目录 tasks.md 并勾选指定 task_id
8611
8611
  archive-docs 将 Simple/Full spec 变更真实移动到 openspec/changes/archive/,并可结束 archive 阶段生成报告
8612
8612
  kb-upload 知识库上传(文件夹自动打包 / zip 直传),支持随时入库
8613
- semantic-identity 生成或复用实体/版本 ID(8 位十六进制);无需网络和中央 ID 服务
8613
+ semantic-identity 生成或复用实体/版本 ID(完整 UUID,界面可显示短标识);无需网络和中央 ID 服务
8614
8614
  semantic-scan 只读解析并校验当前 Change
8615
8615
  semantic-reconcile 全量对账文件与本地工作态本体实例
8616
8616
  semantic-check 全量对账并在通过时标记 pending;--include-tasks 同时返回任务状态、评审输入清单和程序耗时
@@ -28,7 +28,8 @@ const {
28
28
  ensureDir,
29
29
  copyDirRecursive,
30
30
  resolveSpecRootFast,
31
- loadKbState,
31
+ KB_STATE_FILENAME,
32
+ isKbStateHostDir,
32
33
  uploadFile,
33
34
  httpGetJson,
34
35
  } = require('./lib/shared.cjs');
@@ -61,6 +62,18 @@ function assertIngestionResult(result) {
61
62
  return job;
62
63
  }
63
64
 
65
+ /**
66
+ * 解析 kb-state.json 的权威路径:`<specRoot>/kb-state.json`。
67
+ *
68
+ * 位置校验已由 `resolveSpecRoot` 在入口完成(本函数只做路径拼接)。
69
+ *
70
+ * @param {string} specRoot spec 包裹包根目录
71
+ * @returns {string} kb-state.json 绝对路径
72
+ */
73
+ function resolveKbStatePath(specRoot) {
74
+ return path.join(path.resolve(specRoot), KB_STATE_FILENAME);
75
+ }
76
+
64
77
  /**
65
78
  * 读取 kb-state.json 中指定 change 的 archive 缓存。
66
79
  * @param {string} specRoot
@@ -68,9 +81,7 @@ function assertIngestionResult(result) {
68
81
  * @returns {object|null} { archive_id, content_hash, uploaded_at }
69
82
  */
70
83
  function readArchiveCache(specRoot, changeName, context) {
71
- const directPath = path.join(specRoot, 'kb-state.json');
72
- const legacyPath = path.join(specRoot, '..', '.shared', 'kb-state.json');
73
- const statePath = fs.existsSync(directPath) ? directPath : legacyPath;
84
+ const statePath = resolveKbStatePath(specRoot);
74
85
  const state = readJsonSafe(statePath);
75
86
  if (!state || !state.archiveCache) return null;
76
87
  const key = cacheKey(changeName, context);
@@ -85,9 +96,7 @@ function readArchiveCache(specRoot, changeName, context) {
85
96
  * @param {string} contentHash
86
97
  */
87
98
  function updateKbStateCache(specRoot, changeName, archiveId, contentHash, context) {
88
- const directPath = path.join(specRoot, 'kb-state.json');
89
- const legacyPath = path.join(specRoot, '..', '.shared', 'kb-state.json');
90
- const statePath = fs.existsSync(directPath) ? directPath : legacyPath;
99
+ const statePath = resolveKbStatePath(specRoot);
91
100
  const state = readJsonSafe(statePath) || {};
92
101
  if (!state.archiveCache) state.archiveCache = {};
93
102
  const key = cacheKey(changeName, context);
@@ -116,8 +125,18 @@ const DEFAULT_LEVEL = 'propose-spec';
116
125
  // ── 工具函数 ──────────────────────────────────────────
117
126
 
118
127
  function resolveSpecRoot(explicit) {
119
- if (explicit) return path.resolve(explicit);
120
- return resolveSpecRootFast(__dirname);
128
+ const root = explicit ? path.resolve(explicit) : resolveSpecRootFast(__dirname);
129
+ // 位置校验前移:specRoot 必须是 spec 包裹包根,后续所有 kb-state.json /
130
+ // project-identity.json 读取都建立在它之上。在此 fail-fast,避免流程走到
131
+ // 中途才因「文件不存在」报出误导性错误(真正原因是路径指错了地方)。
132
+ if (!isKbStateHostDir(root)) {
133
+ throw new Error(
134
+ `kb-state.json 位置校验失败:${root} 不是 spec 包裹包根目录。\n` +
135
+ 'kb-state.json 只允许位于 spec 包裹包根(由 /opsx-kb-config 写入)。\n' +
136
+ '请确认 --project 指向 *-sdd-specs 包裹包,或运行 /opsx-kb-config 重新配置。'
137
+ );
138
+ }
139
+ return root;
121
140
  }
122
141
 
123
142
  /**
@@ -139,36 +158,28 @@ function resolveProjectId(specRoot) {
139
158
  }
140
159
 
141
160
  /**
142
- * 读取 KB 配置(api, apiKey, tenantKey, targets)
143
- * 使用 shared.cjs 的 loadKbState,支持 BOM 剥离和多路径搜索
161
+ * 读取 KB 配置(api, apiKey, tenantKey, targets)。
162
+ *
163
+ * 位置校验:只读 `<specRoot>/kb-state.json`(specRoot 的 spec 根身份已由
164
+ * resolveSpecRoot 在入口校验)。
144
165
  */
145
166
  function readKbState(specRoot) {
146
- // 优先尝试 specRoot 根目录下的 kb-state.json(新路径)
147
- const directPath = path.join(specRoot, 'kb-state.json');
148
- // 兼容旧路径:specRoot 同级 .shared/kb-state.json
149
- const legacyPath = path.join(specRoot, '..', '.shared', 'kb-state.json');
150
- if (fs.existsSync(directPath)) {
151
- try {
152
- return readJson(directPath);
153
- } catch (e) {
154
- throw new Error(
155
- `KB 配置解析失败 (${directPath}): ${e.message}\n` +
156
- '请运行 /opsx-kb-config 重新配置知识库。'
157
- );
158
- }
159
- }
160
- // 退回到多路径搜索(从 specRoot 向上查找)
161
- const { config, path: foundPath, warnings } = loadKbState(specRoot);
162
- if (warnings.length > 0) {
163
- for (const w of warnings) console.warn(`⚠️ ${w}`);
164
- }
165
- if (!config) {
167
+ const statePath = resolveKbStatePath(specRoot);
168
+ if (!fs.existsSync(statePath)) {
166
169
  throw new Error(
167
170
  'KB 配置不存在(kb-state.json 缺失)。\n' +
171
+ `期望位置: ${statePath}\n` +
168
172
  '请运行 /opsx-kb-config 配置知识库。'
169
173
  );
170
174
  }
171
- return config;
175
+ try {
176
+ return readJson(statePath);
177
+ } catch (e) {
178
+ throw new Error(
179
+ `KB 配置解析失败 (${statePath}): ${e.message}\n` +
180
+ '请运行 /opsx-kb-config 重新配置知识库。'
181
+ );
182
+ }
172
183
  }
173
184
 
174
185
  /**
@@ -332,6 +343,7 @@ async function main() {
332
343
  try {
333
344
  // ── Zip 模式 ──
334
345
  if (args.package) {
346
+ if (args['validate-only']) throw new Error('--validate-only 仅支持 --folder/--change 本地语义校验;未执行归档上传');
335
347
  const zipPath = path.resolve(args.package);
336
348
  if (!fs.existsSync(zipPath)) {
337
349
  throw new Error(`zip 文件不存在: ${zipPath}`);
@@ -370,9 +382,12 @@ async function main() {
370
382
  changeName = path.basename(folderPath);
371
383
  }
372
384
 
373
- const expectedFolder = path.resolve(specRoot, 'openspec', 'changes', changeName);
374
- if (path.resolve(folderPath) !== expectedFolder || !expectedFolder.startsWith(path.resolve(specRoot, 'openspec', 'changes') + path.sep)) {
375
- throw new Error('文件夹上传仅接受当前 Spec 仓 openspec/changes/<change>;已归档内容请用 --zip 指定归档包');
385
+ const changesRoot = fs.realpathSync(path.resolve(specRoot, 'openspec', 'changes'));
386
+ const expectedPath = path.resolve(changesRoot, changeName);
387
+ const expectedFolder = fs.existsSync(expectedPath) ? fs.realpathSync(expectedPath) : expectedPath;
388
+ const suppliedFolder = fs.existsSync(folderPath) ? fs.realpathSync(folderPath) : path.resolve(folderPath);
389
+ if (suppliedFolder !== expectedFolder || !expectedFolder.startsWith(changesRoot + path.sep)) {
390
+ throw new Error('文件夹上传仅接受当前 Spec 仓 openspec/changes/<change>;已归档内容请用 --package 指定归档包');
376
391
  }
377
392
 
378
393
  if (!fs.existsSync(folderPath)) {
@@ -389,19 +404,6 @@ async function main() {
389
404
  // 1. 校验必需文件
390
405
  validateRequiredFiles(folderPath, minimumLevel);
391
406
 
392
- // 2. 解析 project_id
393
- const projectId = resolveProjectId(specRoot);
394
- console.log(`✓ project_id: ${projectId}`);
395
-
396
- // 3. 读取 KB 配置 + 预检
397
- const state = readKbState(specRoot);
398
- if (!state.apiKey) {
399
- throw new Error('KB 配置中无 apiKey,请运行 /opsx-kb-config 配置知识库。');
400
- }
401
- const target = resolveUploadTarget(state, { spaceId: args['space-id'], kbId: args['kb-id'] });
402
- preCheckProjectId(projectId, state, target);
403
- const cacheContext = { api: state.api || 'http://localhost:8090/api', spaceId: target.spaceId, kbId: target.kbId };
404
-
405
407
  // 4. 仅校验模式(含语义校验)
406
408
  if (args['validate-only']) {
407
409
  const runtime = require('./ontology/runtime.cjs');
@@ -417,10 +419,23 @@ async function main() {
417
419
  throw new Error(`语义校验失败:\n${errors.join('\n')}`);
418
420
  }
419
421
  console.log(`✓ 语义校验通过 (profile: ${semanticResult.profile}, revision: ${semanticResult.revision})`);
420
- console.log('\n✓ 校验完成(--validate-only,未上传)');
422
+ console.log('\n✓ 本地语义校验完成(--validate-only,未联网;不代表权限、发布基线或入库已验证)');
421
423
  return;
422
424
  }
423
425
 
426
+ // 2. 解析 project_id
427
+ const projectId = resolveProjectId(specRoot);
428
+ console.log(`✓ project_id: ${projectId}`);
429
+
430
+ // 3. 读取 KB 配置 + 预检
431
+ const state = readKbState(specRoot);
432
+ if (!state.apiKey) {
433
+ throw new Error('KB 配置中无 apiKey,请运行 /opsx-kb-config 配置知识库。');
434
+ }
435
+ const target = resolveUploadTarget(state, { spaceId: args['space-id'], kbId: args['kb-id'] });
436
+ preCheckProjectId(projectId, state, target);
437
+ const cacheContext = { api: state.api || 'http://localhost:8090/api', spaceId: target.spaceId, kbId: target.kbId };
438
+
424
439
  // 5. 构建 Archive Package
425
440
  console.log('');
426
441
  const { zipPath, manifest, stagingDir } = buildPackageFromFolder(specRoot, changeName, {
@@ -289,61 +289,81 @@ function resolveSpecRootFast(anchorDir = __dirname) {
289
289
  // 7. KB 配置加载
290
290
  // ═══════════════════════════════════════════════════════════════
291
291
 
292
- /** 已知的 IDE skills 目录名 */
293
- const IDE_DIRS = [
294
- '.codebuddy', '.claude', '.cursor', '.vscode',
295
- '.kunlunzhima', '.opencode', '.workbuddy', '.codex',
296
- ];
292
+ /** kb-state.json 文件名(唯一权威位置:spec 包裹包根目录) */
293
+ const KB_STATE_FILENAME = 'kb-state.json';
294
+
295
+ /** 向上搜索 kb-state.json 的最大层数(从 skywalk-sdd/lib/ 到 spec 根最多 3 层) */
296
+ const MAX_KB_STATE_LEVELS = 6;
297
+
298
+ let _isSpecPackageRoot = null;
299
+
300
+ /**
301
+ * 惰性加载 resolve-spec-root.cjs 的 isSpecPackageRoot(避免加载期循环依赖)。
302
+ */
303
+ function getIsSpecPackageRoot() {
304
+ if (!_isSpecPackageRoot) {
305
+ const mod = require(path.join(__dirname, '..', 'ontology', 'resolve-spec-root.cjs'));
306
+ _isSpecPackageRoot = mod.isSpecPackageRoot;
307
+ }
308
+ return _isSpecPackageRoot;
309
+ }
310
+
311
+ /**
312
+ * kb-state.json 位置校验:宿主目录必须是 spec 包裹包根。
313
+ *
314
+ * 判据复用 `ontology/resolve-spec-root.cjs#isSpecPackageRoot`(与全仓 spec 识别
315
+ * 单一真相源对齐),不在本模块另立一套结构判据。
316
+ *
317
+ * @param {string} dir 候选宿主目录
318
+ * @returns {boolean}
319
+ */
320
+ function isKbStateHostDir(dir) {
321
+ try {
322
+ return Boolean(getIsSpecPackageRoot()(dir));
323
+ } catch {
324
+ // ontology 层不可用(理论上部署布局下不会发生):保守判否,避免误用未知位置的配置
325
+ return false;
326
+ }
327
+ }
297
328
 
298
329
  /**
299
330
  * 加载 KB 配置(kb-state.json)。
300
331
  *
301
- * 从 anchorDir 逐层向上搜索(优先新路径,兼容旧路径):
302
- * - <dir>/kb-state.json ← 新路径(spec 仓根目录)
303
- * - <dir>/<ide>/skills/kb-state.json ← 新路径(IDE skills 目录)
304
- * - <dir>/.shared/kb-state.json ← 旧路径(向后兼容)
305
- * - <dir>/<ide>/skills/.shared/kb-state.json ← 旧路径(向后兼容)
332
+ * **唯一权威位置**:spec 包裹包根目录 `<spec-root>/kb-state.json`
333
+ * —— 由 `/opsx-kb-config` 写入(见 opsx-kb-config/reference.md §kb-state.json),
334
+ * spec 仓根、已 gitignore。
335
+ *
336
+ * 位置校验(本函数同时承担「位置正确性」判定,而不只是「能否读到」):
337
+ * 从 anchorDir 逐级向上查找同名文件,但**只接受宿主目录为 spec 包裹包根**的命中
338
+ * (isKbStateHostDir)。因此从 `<spec-root>/skywalk-sdd/`、`<spec-root>/skywalk-sdd/lib/`
339
+ * 等子目录调用都能正确命中 spec 根;而落在工作区根、代码仓根等错误位置的文件
340
+ * 不会被误用 —— 读到「不该读的配置」比读不到更危险。
306
341
  *
307
342
  * @param {string} [anchorDir=__dirname] — 搜索起点
308
- * @param {object} [env=process.env] — 环境变量(用于 fallback)
343
+ * @param {object} [env=process.env] — 保留参数(环境变量兜底由 resolveKbParams 处理)
309
344
  * @returns {{ config: object|null, path: string|null, warnings: string[] }}
310
345
  */
311
346
  function loadKbState(anchorDir = __dirname, env = process.env) {
347
+ void env;
312
348
  const warnings = [];
313
- const candidatePaths = [];
314
-
315
- const MAX_LEVELS = 6;
316
- let current = path.resolve(anchorDir);
317
- const fileRoot = path.parse(current).root;
318
- for (let level = 0; level < MAX_LEVELS; level++) {
319
- // 新路径(优先):直接放在目录下
320
- candidatePaths.push(path.join(current, 'kb-state.json'));
321
- // 新路径:IDE skills 目录下
322
- for (const ide of IDE_DIRS) {
323
- candidatePaths.push(path.join(current, ide, 'skills', 'kb-state.json'));
324
- }
325
- // 旧路径(向后兼容):.shared/ 子目录
326
- candidatePaths.push(path.join(current, '.shared', 'kb-state.json'));
327
- for (const ide of IDE_DIRS) {
328
- candidatePaths.push(path.join(current, ide, 'skills', '.shared', 'kb-state.json'));
349
+ const start = path.resolve(anchorDir);
350
+ const fileRoot = path.parse(start).root;
351
+
352
+ let current = start;
353
+ for (let level = 0; level < MAX_KB_STATE_LEVELS; level += 1) {
354
+ const candidate = path.join(current, KB_STATE_FILENAME);
355
+ if (fs.existsSync(candidate) && isKbStateHostDir(current)) {
356
+ try {
357
+ const config = JSON.parse(stripBom(fs.readFileSync(candidate, 'utf8')));
358
+ return { config, path: candidate, warnings };
359
+ } catch (e) {
360
+ warnings.push(`kb-state.json 解析失败 (${candidate}): ${e.message}`);
361
+ }
329
362
  }
330
363
  if (current === fileRoot) break;
331
364
  current = path.dirname(current);
332
365
  }
333
366
 
334
- for (const p of candidatePaths) {
335
- try {
336
- if (fs.existsSync(p)) {
337
- let raw = fs.readFileSync(p, 'utf8');
338
- raw = stripBom(raw);
339
- const config = JSON.parse(raw);
340
- return { config, path: p, warnings };
341
- }
342
- } catch (e) {
343
- warnings.push(`kb-state.json 解析失败 (${p}): ${e.message}`);
344
- }
345
- }
346
-
347
367
  return { config: null, path: null, warnings };
348
368
  }
349
369
 
@@ -797,7 +817,8 @@ module.exports = {
797
817
  // Spec root
798
818
  resolveSpecRootFast,
799
819
  // KB state
800
- IDE_DIRS,
820
+ KB_STATE_FILENAME,
821
+ isKbStateHostDir,
801
822
  loadKbState,
802
823
  resolveKbParams,
803
824
  resolveKbScopes,
@@ -17,6 +17,26 @@ function parseCounts(output) {
17
17
  && total === passed + failed + (skipped || 0) + (todo || 0)) {
18
18
  return { counts_known: true, passed, failed, skipped: (skipped || 0) + (todo || 0) };
19
19
  }
20
+ // Surefire/Failsafe print per-class counts and a Results section. Count only
21
+ // the latter, once per module/execution, to avoid double-counting classes.
22
+ const summaries = [];
23
+ let inResults = false;
24
+ for (const raw of output.replace(/\u001b\[[0-9;]*m/g, '').split(/\r?\n/)) {
25
+ const line = raw.replace(/^\[(?:INFO|WARNING|ERROR)\]\s*/, '').trim();
26
+ if (line === 'Results:') { inResults = true; continue; }
27
+ if (!inResults) continue;
28
+ const match = /^Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)\b/.exec(line);
29
+ if (!match) continue;
30
+ const [, count, failures, errors, ignored] = match.map(Number);
31
+ if (count < failures + errors + ignored) return { counts_known: false };
32
+ summaries.push({ passed: count - failures - errors - ignored, failed: failures + errors, skipped: ignored });
33
+ inResults = false;
34
+ }
35
+ if (summaries.length) {
36
+ return summaries.reduce((sum, item) => ({ counts_known: true, passed: sum.passed + item.passed,
37
+ failed: sum.failed + item.failed, skipped: sum.skipped + item.skipped }),
38
+ { counts_known: true, passed: 0, failed: 0, skipped: 0 });
39
+ }
20
40
  return { counts_known: false };
21
41
  }
22
42
 
@@ -23,6 +23,8 @@ const {
23
23
  readUserConfig,
24
24
  } = require('./user-config.cjs');
25
25
  const gitIdentity = require('./git-identity.cjs');
26
+ // kb-state.json 的唯一读取入口(含「必须位于 spec 包裹包根」的位置校验)
27
+ const { loadKbState } = require('./shared.cjs');
26
28
 
27
29
  const PENDING_FILE_NAME = 'pending-usage.jsonl';
28
30
  const PENDING_MAX_BYTES = 1024 * 1024; // 1 MB
@@ -93,6 +95,16 @@ function _resetGitIdentityCache() {
93
95
  * 四级解析上报地址:config.server → 项目 kb-state.json api → env KLD_SDD_SERVER → 内置默认。
94
96
  * 每级均经 normalizeServerUrl;结果永远非空(默认地址兜底)。
95
97
  *
98
+ * kb-state.json 的读取走 `loadKbState`(唯一 kb-state 读取入口),因此沿用
99
+ * 其位置校验:只有位于 spec 包裹包根的那份才被采用。位置不符时行为与
100
+ * 「文件不存在」一致 —— 静默落到下一级(env → 内置默认)。
101
+ *
102
+ * 这里刻意**不**把 loadKbState 的 warnings 打到 stderr:上报链路是
103
+ * 「永不抛错、静默兜底」的后台路径,配置告警应由 KB 技能链路(kb-config /
104
+ * ontology-query / kb-ingest)呈现,避免每次 log start 都刷屏。
105
+ * 且宁可上报到默认地址,也不能因为某处残留的陈旧 kb-state.json
106
+ * 把使用数据发到错误服务器。
107
+ *
96
108
  * @param {{homeDir?:string, projectRoot?:string, env?:object}} [options]
97
109
  * @returns {string}
98
110
  */
@@ -107,14 +119,11 @@ function resolveReportServer(options = {}) {
107
119
  }
108
120
  // 2. 项目级 kb-state.json 的 api(kb-state.api 是唯一带 /api 契约的来源,先剥离尾部 /api 再标准化)
109
121
  try {
110
- const kbStatePath = path.join(projectRoot, 'kb-state.json');
111
- if (fs.existsSync(kbStatePath)) {
112
- const kbState = JSON.parse(fs.readFileSync(kbStatePath, 'utf8'));
113
- if (kbState && typeof kbState.api === 'string') {
114
- const stripped = kbState.api.trim().replace(/\/api(\/v\d+)?\/?$/i, '');
115
- const normalized = normalizeServerUrl(stripped);
116
- if (normalized) return normalized;
117
- }
122
+ const { config: kbState } = loadKbState(projectRoot);
123
+ if (kbState && typeof kbState.api === 'string') {
124
+ const stripped = kbState.api.trim().replace(/\/api(\/v\d+)?\/?$/i, '');
125
+ const normalized = normalizeServerUrl(stripped);
126
+ if (normalized) return normalized;
118
127
  }
119
128
  } catch {
120
129
  // kb-state 读取失败时静默,继续下一级
@@ -4,6 +4,8 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const sddConfig = require('./sdd-config.cjs');
6
6
  const workspaceLayout = require('./workspace-layout.cjs');
7
+ // spec 判据的唯一真相源(spec-detect 对本模块无依赖,不构成循环)
8
+ const specDetect = require('./spec-detect.cjs');
7
9
 
8
10
  /**
9
11
  * 从 anchorRoot 向上遍历目录树,查找 .sdd-spec-root 配置文件。
@@ -28,6 +30,34 @@ function resolveHint(anchorRoot) {
28
30
  return null;
29
31
  }
30
32
 
33
+ /**
34
+ * 从 `.sdd-workspace.yaml` 的 `spec_path` 解析 spec 根(向上逐级查找)。
35
+ *
36
+ * 委托 `spec-detect.resolveWorkspaceYamlUpward` —— 该解析在 spec-detect 中
37
+ * 同时用于「候选枚举的 tier 0 来源」,两处共用一份实现,避免漂移。
38
+ */
39
+ function resolveWorkspaceYamlSpecPath(anchorRoot) {
40
+ return specDetect.resolveWorkspaceYamlUpward(anchorRoot);
41
+ }
42
+
43
+ /**
44
+ * 解析 spec 包裹包根目录。
45
+ *
46
+ * 优先级(权威声明 → 结构判据 → 回落):
47
+ * 1. `.sdd-spec-root` 指针文件(init / 运行时写入)
48
+ * 2. `.sdd.yaml` layout: mono(单仓,仓内包裹包)
49
+ * 3. `.sdd-workspace.yaml` spec_path(多仓工作区编排)
50
+ * 4. 工作区探测:子仓结构判据命中的 spec 包
51
+ * 5. 非 Git 子目录包裹包
52
+ * 6. 回落为入参本身(调用方用 isSpecPackageRoot 区分「就是 spec 根」与「解析失败」)
53
+ *
54
+ * 第 3 步是**新增**的:多仓工作区根执行文档类命令时,权威链此前不读
55
+ * `.sdd-workspace.yaml`,只能靠第 4 步的结构判据猜;而 skills 的
56
+ * `opsx-consistency-check/scripts.cjs` 早已自己解析该文件 —— 两处不一致。
57
+ * 现由权威链直接支持,该文件成为与 `.sdd.yaml` 同级的显式声明来源。
58
+ *
59
+ * 前 3 步均**向上逐级查找**,故从工作区子目录(如某个代码仓内)调用也能命中。
60
+ */
31
61
  function resolveSpecProjectRoot(projectRoot = process.cwd()) {
32
62
  const root = path.resolve(projectRoot);
33
63
  const hinted = resolveHint(root);
@@ -37,6 +67,9 @@ function resolveSpecProjectRoot(projectRoot = process.cwd()) {
37
67
  return sddConfig.resolveMonoSpecRoot(root);
38
68
  }
39
69
 
70
+ const fromWorkspaceYaml = resolveWorkspaceYamlSpecPath(root);
71
+ if (fromWorkspaceYaml) return fromWorkspaceYaml;
72
+
40
73
  const layout = workspaceLayout.detectWorkspaceLayout(root);
41
74
  if (layout.specRepo) return layout.specRepo.abs;
42
75
 
@@ -50,7 +83,23 @@ function resolveProjectArg(projectArg = '.', cwd = process.cwd()) {
50
83
  return resolveSpecProjectRoot(path.resolve(cwd, projectArg));
51
84
  }
52
85
 
86
+ /**
87
+ * 判断 dir 本身是否就是一个 SDD spec 包裹包根。
88
+ *
89
+ * 用于 `log spec-root` 的 resolved 判定:当 resolveSpecProjectRoot 因找不到
90
+ * 任何线索而回落为入参本身时,需区分「dir 就是 spec 根」与「解析失败」。
91
+ *
92
+ * ⚠️ 判据已收敛到 `spec-detect.cjs#isSpecPackageRoot`(全仓唯一判据入口)。
93
+ * 该判据比 `isSpecCandidate` 宽一档 —— 额外接受仅含 `openspec/` 的半成品
94
+ * (`--skip-openspec` 产物),因为本函数的语义是「能否作为 spec 根使用」
95
+ * 而非「是否是当前项目的 spec 仓」。
96
+ */
97
+ function isSpecPackageRoot(dir) {
98
+ return specDetect.isSpecPackageRoot(dir);
99
+ }
100
+
53
101
  module.exports = {
54
102
  resolveProjectArg,
55
103
  resolveSpecProjectRoot,
104
+ isSpecPackageRoot,
56
105
  };
@@ -3,6 +3,8 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { execFileSync } = require('child_process');
6
+ // spec 判据的唯一真相源(spec-detect 对本模块的依赖是惰性,不构成循环)
7
+ const specDetect = require('./spec-detect.cjs');
6
8
 
7
9
  const CODES = {
8
10
  MISSING: 'SDD_YAML_MISSING',
@@ -156,12 +158,18 @@ function resolveMonoSpecRoot(repoRoot) {
156
158
  return root;
157
159
  }
158
160
 
161
+ /**
162
+ * 目录内容是否"像 spec 包"(任一产物标记存在)。
163
+ *
164
+ * ⚠️ 判据已收敛到 `spec-detect.cjs#hasSpecPackageContent`(全仓唯一判据入口)。
165
+ * 本函数保留仅为向后兼容既有调用点。
166
+ *
167
+ * 语义:仅用于用户**显式给定**路径的校验(`validateSpecPath` 的
168
+ * allowNestedPackage 分支)。不可用于检测 —— 单仓代码仓根可能有 modules.yaml
169
+ * 而无 openspec,仅凭内容无法区分。
170
+ */
159
171
  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
- );
172
+ return specDetect.hasSpecPackageContent(dir);
165
173
  }
166
174
 
167
175
  function gitConfig(cwd, key) {
@@ -176,6 +184,25 @@ function gitConfig(cwd, key) {
176
184
  }
177
185
  }
178
186
 
187
+ /**
188
+ * 删除 git config --local sdd.specPath(卸载用)。
189
+ * 配置项不存在时视为成功(幂等)。
190
+ */
191
+ function unsetSpecPath(codeRepoRoot) {
192
+ if (!gitConfig(codeRepoRoot, 'sdd.specPath')) {
193
+ return { ok: true, removed: false };
194
+ }
195
+ try {
196
+ execFileSync('git', ['config', '--local', '--unset', 'sdd.specPath'], {
197
+ cwd: codeRepoRoot,
198
+ stdio: ['ignore', 'pipe', 'pipe'],
199
+ });
200
+ } catch (error) {
201
+ return { ok: false, message: `无法删除 git config sdd.specPath: ${error.message}` };
202
+ }
203
+ return { ok: true, removed: true };
204
+ }
205
+
179
206
  function gitRemoteUrl(cwd, remote = 'origin') {
180
207
  try {
181
208
  return execFileSync('git', ['remote', 'get-url', remote], {
@@ -374,6 +401,7 @@ module.exports = {
374
401
  resolveSpecPath,
375
402
  validateSpecPath,
376
403
  setSpecPath,
404
+ unsetSpecPath,
377
405
  resolveCodeRepoContext,
378
406
  gitHeadSha,
379
407
  gitWorkingTreeClean,