kld-sdd 2.8.1 → 2.8.3

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.
@@ -5,7 +5,7 @@
5
5
  * kb-upload.cjs — 知识库上传脚本
6
6
  *
7
7
  * 支持两种上传模式:
8
- * 1. 文件夹上传:自动跑语义管线 → 生成元数据 → 打包 zip → 上传
8
+ * 1. 文件夹上传:读取本地 Spec → 生成本体 JSON → 直接上传(无 ZIP、无 AI)
9
9
  * 2. Zip 上传:直接上传已有 zip 包
10
10
  *
11
11
  * 用法:
@@ -16,11 +16,13 @@
16
16
  *
17
17
  * 历史产物(缺 entity-id / version-id):
18
18
  * 自动旁路物化 spec-ontology.json,无 semantic_key 冲突时自动确认并发布为 current;
19
- * 有冲突或 --require-review 时只上传候选,需人工 approve-spec-ontology 后再上传。
19
+ * --require-review 仅保存候选;明确审阅后用 --confirm-review 发布同一原文。
20
+ * 并发基线冲突整体拒绝,不覆盖团队 current。
20
21
  *
21
22
  * project_id 解析:
22
23
  * 1. $SPEC_ROOT/skywalk-sdd/project-identity.json 的 project_id
23
- * 2. 都没有 → 报错:请运行 /opsx-kb-config 配置知识库
24
+ * 2. 缺少身份时自动创建稳定的 Spec 来源标识,不依赖上传库
25
+ * 3. 已有身份(含旧离线身份)原样保留,上传目标只由 uploadTarget 决定
24
26
  */
25
27
 
26
28
  const fs = require('fs');
@@ -37,16 +39,27 @@ const {
37
39
  isKbStateHostDir,
38
40
  uploadFile,
39
41
  httpGetJson,
42
+ resolveKbParams,
40
43
  } = require('./lib/shared.cjs');
41
44
 
42
45
  function resolveUploadTarget(state, options = {}) {
43
46
  if (Boolean(options.spaceId) !== Boolean(options.kbId)) throw new Error('必须同时指定 --space-id 和 --kb-id');
44
47
  const targets = Array.isArray(state?.targets) ? state.targets : [];
48
+ const configured = state?.uploadTarget;
45
49
  if (options.spaceId && options.kbId) {
46
- return targets.find(t => t.spaceId === options.spaceId && t.kbId === options.kbId) || options;
50
+ const differs = Object.prototype.hasOwnProperty.call(state || {}, 'uploadTarget')
51
+ && (!configured || configured.spaceId !== options.spaceId || configured.kbId !== options.kbId);
52
+ if (differs && !options.overrideUploadTarget) {
53
+ throw new Error('UPLOAD_TARGET_CONFLICT: 命令中的上传库与助手保存的上传位置不同;请更新旧 Skill,默认不传目标参数。确需临时改投时,显式增加 --override-upload-target');
54
+ }
55
+ const selected = targets.find(t => t.spaceId === options.spaceId && t.kbId === options.kbId);
56
+ if (options.overrideUploadTarget && !selected) throw new Error('临时上传目标必须在 kb-state.json 的 targets 中');
57
+ return selected || { spaceId: options.spaceId, kbId: options.kbId };
47
58
  }
48
59
 
49
- const configured = state?.uploadTarget;
60
+ if (Object.prototype.hasOwnProperty.call(state || {}, 'uploadTarget') && configured == null) {
61
+ throw new Error('尚未选择 Spec 上传知识库(uploadTarget 已清空);请在助手中选择上传位置,不能用读取库代替');
62
+ }
50
63
  if (configured != null) {
51
64
  if (!configured.spaceId || !configured.kbId) {
52
65
  throw new Error('kb-state.json 的 uploadTarget 不完整,请在 SDD 助手中重新选择 Spec 上传知识库');
@@ -106,9 +119,9 @@ function resolveKbStatePath(specRoot) {
106
119
  function readArchiveCache(specRoot, changeName, context) {
107
120
  const statePath = resolveKbStatePath(specRoot);
108
121
  const state = readJsonSafe(statePath);
109
- if (!state || !state.archiveCache) return null;
122
+ const local = require('./lib/kb-config.cjs').readReceipts(specRoot);
110
123
  const key = cacheKey(changeName, context);
111
- return key ? state.archiveCache[key] || null : null;
124
+ return key ? local.receipts?.[key] || local.legacyArchiveCache?.[key] || state?.archiveCache?.[key] || null : null;
112
125
  }
113
126
 
114
127
  /**
@@ -119,18 +132,14 @@ function readArchiveCache(specRoot, changeName, context) {
119
132
  * @param {string} contentHash
120
133
  */
121
134
  function updateKbStateCache(specRoot, changeName, archiveId, contentHash, context) {
122
- const statePath = resolveKbStatePath(specRoot);
123
- const state = readJsonSafe(statePath) || {};
124
- if (!state.archiveCache) state.archiveCache = {};
125
135
  const key = cacheKey(changeName, context);
126
136
  if (!key) throw new Error('缓存必须包含 API、Space 和 KB');
127
- state.archiveCache[key] = {
137
+ require('./lib/kb-config.cjs').writeReceipt(specRoot, key, {
128
138
  archive_id: archiveId,
129
139
  content_hash: contentHash,
130
140
  uploaded_at: new Date().toISOString(),
131
141
  target: context,
132
- };
133
- writeJsonAtomic(statePath, state);
142
+ });
134
143
  console.log(`✓ 已更新本地缓存: ${changeName} → ${archiveId.slice(0, 24)}...`);
135
144
  }
136
145
 
@@ -164,19 +173,29 @@ function resolveSpecRoot(explicit) {
164
173
 
165
174
  /**
166
175
  * 解析 project_id
167
- * 优先级:project-identity.json → 报错
176
+ * 保留 Spec 来源标识;切换知识库不会改变实体及版本命名空间。
168
177
  */
169
- function resolveProjectId(specRoot) {
178
+ function resolveProjectId(specRoot, target) {
170
179
  const identityPath = path.join(specRoot, 'skywalk-sdd', 'project-identity.json');
180
+ if (!fs.existsSync(identityPath) && target) {
181
+ ensureDir(path.dirname(identityPath));
182
+ try {
183
+ fs.writeFileSync(identityPath, JSON.stringify({
184
+ schema_version: 'kld-sdd-project-identity/v1',
185
+ project_id: 'urn:kld:sdd:project:' + require('crypto').randomBytes(16).toString('hex'),
186
+ created_at: new Date().toISOString(),
187
+ }, null, 2) + '\n', { flag: 'wx' });
188
+ } catch (error) { if (error.code !== 'EEXIST') throw error; }
189
+ }
171
190
  if (fs.existsSync(identityPath)) {
172
191
  const identity = readJson(identityPath);
173
- if (identity.project_id) {
192
+ if (typeof identity?.project_id === 'string' && identity.project_id.trim()) {
174
193
  return identity.project_id;
175
194
  }
176
195
  }
177
196
  throw new Error(
178
197
  '无法确定 project_id。project-identity.json 不存在或无 project_id 字段。\n' +
179
- '请运行 /opsx-kb-config 配置知识库。'
198
+ '请检查该文件;上传目标在知识库配置中单独选择。'
180
199
  );
181
200
  }
182
201
 
@@ -186,9 +205,9 @@ function resolveProjectId(specRoot) {
186
205
  * 位置校验:只读 `<specRoot>/kb-state.json`(specRoot 的 spec 根身份已由
187
206
  * resolveSpecRoot 在入口校验)。
188
207
  */
189
- function readKbState(specRoot) {
208
+ function readKbState(specRoot, env = process.env) {
190
209
  const statePath = resolveKbStatePath(specRoot);
191
- if (!fs.existsSync(statePath)) {
210
+ if (!fs.existsSync(statePath) && !fs.existsSync(path.join(specRoot, 'kb.config.json'))) {
192
211
  throw new Error(
193
212
  'KB 配置不存在(kb-state.json 缺失)。\n' +
194
213
  `期望位置: ${statePath}\n` +
@@ -196,7 +215,14 @@ function readKbState(specRoot) {
196
215
  );
197
216
  }
198
217
  try {
199
- return readJson(statePath);
218
+ const loaded = require('./lib/kb-config.cjs').readProjectConfig(specRoot);
219
+ const state = loaded.config;
220
+ if (!state || typeof state !== 'object' || Array.isArray(state)) throw new Error('配置内容必须是 JSON 对象');
221
+ if (env.SDD_KB_STATE_FILE && path.resolve(env.SDD_KB_STATE_FILE) !== statePath) {
222
+ console.warn('⚠️ SDD_KB_STATE_FILE 已忽略;上传统一读取所选 Spec 根目录的 kb-state.json');
223
+ }
224
+ const params = resolveKbParams(state, env);
225
+ return { ...state, api: params.apiBase, apiKey: params.token };
200
226
  } catch (e) {
201
227
  throw new Error(
202
228
  `KB 配置解析失败 (${statePath}): ${e.message}\n` +
@@ -205,6 +231,16 @@ function readKbState(specRoot) {
205
231
  }
206
232
  }
207
233
 
234
+ // Upload and identity synchronization share this entry; neither may reinterpret read targets.
235
+ function prepareUploadConfig(specRoot, options = {}) {
236
+ const state = readKbState(specRoot);
237
+ if (!state.apiKey) throw new Error('KB 配置中无 apiKey,请在助手中登录知识库或运行 /opsx-kb-config');
238
+ const target = resolveUploadTarget(state, options);
239
+ const projectId = resolveProjectId(specRoot, options.readOnlyIdentity ? undefined : target);
240
+ preCheckProjectId(projectId, state, target);
241
+ return { state, target, projectId };
242
+ }
243
+
208
244
  /**
209
245
  * 校验文件夹必需文件
210
246
  */
@@ -230,26 +266,15 @@ function validateRequiredFiles(folderPath, minimumLevel) {
230
266
  }
231
267
 
232
268
  /**
233
- * 本地预检 project_id === spaceKey
269
+ * 兼容旧调用:只检查来源标识和有效目标,不限制来源与目标空间一致。
234
270
  */
235
271
  function preCheckProjectId(projectId, state, selectedTarget) {
236
272
  if (!state.targets || !state.targets[0]) {
237
273
  throw new Error('KB 配置中无 targets,请运行 /opsx-kb-config 配置知识库。');
238
274
  }
239
- const spaceKey = (selectedTarget || resolveUploadTarget(state)).spaceKey;
240
- if (!spaceKey) {
241
- console.warn('⚠️ kb-state.json 的上传目标缺少 spaceKey,跳过本地预检');
242
- return;
243
- }
244
- if (projectId !== spaceKey) {
245
- throw new Error(
246
- `PROJECT_SPACE_MISMATCH 预检失败:\n` +
247
- ` project_id (project-identity.json): ${projectId}\n` +
248
- ` spaceKey (kb-state.json): ${spaceKey}\n` +
249
- `请运行 /opsx-kb-config 更新配置。`
250
- );
251
- }
252
- console.log(`✓ project_id 预检通过: ${projectId} === ${spaceKey}`);
275
+ if (typeof projectId !== 'string' || !projectId.trim()) throw new Error('Spec 来源标识为空,请修复 project-identity.json');
276
+ const target = selectedTarget || resolveUploadTarget(state);
277
+ if (!target.spaceId || !target.kbId) throw new Error('请选择有效的上传知识库');
253
278
  }
254
279
 
255
280
  /**
@@ -446,7 +471,23 @@ async function uploadZip(zipPath, state, options) {
446
471
  async function main() {
447
472
  const args = parseArgs(process.argv.slice(2));
448
473
 
449
- if (!args.folder && !args.package) {
474
+ if (args['check-config']) {
475
+ try {
476
+ const specRoot = resolveSpecRoot(args.project);
477
+ const state = readKbState(specRoot);
478
+ if (!state.apiKey) throw new Error('尚未配置知识库 API Key');
479
+ const target = resolveUploadTarget(state);
480
+ const identityPath = path.join(specRoot, 'skywalk-sdd/project-identity.json');
481
+ const identity = fs.existsSync(identityPath) ? readJson(identityPath) : null;
482
+ if (fs.existsSync(identityPath) && (!identity || typeof identity.project_id !== 'string' || !identity.project_id.trim())) throw new Error('项目身份文件缺少 project_id,已保留原文件,请先修复');
483
+ if (identity?.project_id) preCheckProjectId(identity.project_id, state, target);
484
+ console.log(JSON.stringify({ configFile: resolveKbStatePath(specRoot), api: state.api,
485
+ uploadTarget: { spaceId: target.spaceId, kbId: target.kbId, spaceKey: target.spaceKey, spaceName: target.spaceName, kbName: target.kbName }, projectId: identity?.project_id || null,
486
+ identityWillBeCreated: !identity, identityWillBeBound: false }, null, 2));
487
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
488
+ return;
489
+ }
490
+ if (!args.folder && !args.package && !args.change) {
450
491
  console.error('用法:');
451
492
  console.error(' node skywalk-sdd/kb-upload.cjs --folder=<path> [--minimum-level=propose-spec] [--validate-only]');
452
493
  console.error(' node skywalk-sdd/kb-upload.cjs --package=<zip-path>');
@@ -483,6 +524,7 @@ async function main() {
483
524
  const result = await uploadZip(zipPath, state, {
484
525
  spaceId: args['space-id'],
485
526
  kbId: args['kb-id'],
527
+ overrideUploadTarget: Boolean(args['override-upload-target']),
486
528
  });
487
529
 
488
530
  console.log('\n── 上传结果 ──');
@@ -550,130 +592,11 @@ async function main() {
550
592
  return;
551
593
  }
552
594
 
553
- // 2. 解析 project_id
554
- const projectId = resolveProjectId(specRoot);
555
- console.log(`✓ project_id: ${projectId}`);
556
-
557
- // 3. 读取 KB 配置 + 预检
558
- const state = readKbState(specRoot);
559
- if (!state.apiKey) {
560
- throw new Error('KB 配置中无 apiKey,请运行 /opsx-kb-config 配置知识库。');
561
- }
562
- const target = resolveUploadTarget(state, { spaceId: args['space-id'], kbId: args['kb-id'] });
563
- preCheckProjectId(projectId, state, target);
564
- const cacheContext = { api: state.api || 'http://localhost:8090/api', spaceId: target.spaceId, kbId: target.kbId };
565
-
566
- // 5. 构建 Archive Package
567
- console.log('');
568
- const { zipPath, manifest, stagingDir, requiresReview, legacyStatus } = buildPackageFromFolder(specRoot, changeName, {
569
- reason: args.reason,
570
- requireReview: Boolean(args['require-review']),
571
- });
572
-
573
- try {
574
- // Local receipts are informational only. Always verify the selected destination remotely.
575
- const apiUrl = (state.api || 'http://localhost:8090/api').replace(/\/+$/, '');
576
-
577
- // === Step 5.5: KB 预检(网络级幂等确认)===
578
- const archiveId = manifest.archive_id;
579
- const contentHash = manifest.content_hash;
580
- console.log(`→ Pre-check: archive_id=${archiveId}`);
581
- console.log(` content_hash=${contentHash}`);
582
-
583
- const checkUrl = `${apiUrl}/v1/spaces/${target.spaceId}/knowledge-bases/${target.kbId}/ingestions/check`
584
- + `?archiveId=${encodeURIComponent(archiveId)}`
585
- + `&contentHash=${encodeURIComponent(contentHash)}`;
586
-
587
- try {
588
- const checkResult = await httpGetJson(checkUrl, state.apiKey);
589
-
590
- if (checkResult?.data?.idempotent && checkResult.data.publicationStatus === 'published') {
591
- console.log('\n✓ KB 中已存在相同 archive(幂等命中),跳过上传');
592
- console.log(` snapshot_id: ${checkResult.data.snapshotId}`);
593
- updateKbStateCache(specRoot, changeName, archiveId, contentHash, cacheContext);
594
- console.log(` publication: ${checkResult.data.publicationStatus}; projection: ${checkResult.data.projectionStatus}`);
595
- if (['failed', 'degraded'].includes(checkResult.data.projectionStatus)) console.log('⚠️ 检索准备尚未完成,请在知识库概览重试该任务的投影');
596
- console.log('\n✓ 归档已存在(检索状态以上述回执为准)');
597
- return;
598
- }
599
-
600
- if (checkResult && checkResult.data && checkResult.data.exists && !checkResult.data.idempotent) {
601
- throw new Error(
602
- `ARCHIVE_IMMUTABILITY_VIOLATION: archive_id 已存在但 content_hash 不同\n` +
603
- ` archive_id: ${archiveId}\n` +
604
- ` 如果内容确实变更,请确认实体身份块已正确更新`
605
- );
606
- }
607
-
608
- console.log('✓ Pre-check 通过(KB 中无此 archive,继续上传)');
609
- } catch (checkErr) {
610
- // 降级:KB 后端未部署 /check 接口(404)→ 继续上传
611
- if (checkErr.message && checkErr.message.includes('HTTP 404')) {
612
- console.log('⚠️ KB 后端未部署 /check 接口,降级为直接上传');
613
- } else {
614
- throw checkErr;
615
- }
616
- }
617
-
618
- // 6. 上传
619
- console.log('');
620
- const result = await uploadZip(zipPath, state, {
621
- spaceId: target.spaceId,
622
- kbId: target.kbId,
623
- allowNeedsReview: requiresReview,
624
- });
625
-
626
- console.log('\n── 上传结果 ──');
627
- console.log(JSON.stringify(result, null, 2));
628
-
629
- // 7. 输出 manifest 摘要
630
- console.log('\n── Archive Package 摘要 ──');
631
- console.log(` archive_id: ${manifest.archive_id}`);
632
- console.log(` project_id: ${manifest.project_id}`);
633
- console.log(` change_id: ${manifest.change_id}`);
634
- console.log(` files: ${manifest.files.length}`);
635
-
636
- const publicationStatus = result?.data?.report?.publication_status
637
- || result?.report?.publication_status
638
- || '';
639
- if (publicationStatus === 'needs_review') {
640
- console.log('\n⚠️ 历史 Spec 已安全保存为候选,尚未进入 current 召回');
641
- console.log(` → 审核 ${path.join(folderPath, 'ontology', 'spec-ontology.json')}`);
642
- if (legacyStatus === 'needs_review') {
643
- console.log(' → 正文没有足够的结构化事实,只能作为证据保留');
644
- } else {
645
- console.log(' → 运行 approve-spec-ontology 后再次上传即可发布;不带 --require-review 上传则自动确认');
646
- }
647
- } else {
648
- // Published commit has been verified; a receipt does not imply projection completion.
649
- updateKbStateCache(specRoot, changeName, archiveId, contentHash, cacheContext);
650
- }
651
-
652
- // === 投影状态提示 ===
653
- // 上传成功后,KB 后端异步执行 vector embedding + AGE graph 投影。
654
- // 正常 SDD 流程中,Spec→Design 等阶段间有用户手动触发的时间间隔,投影通常在此期间完成。
655
- const projStatus = result?.data?.projectionStatus || result?.projectionStatus || '';
656
- const jobStatus = result?.data?.status || result?.status || '';
657
-
658
- if (projStatus === 'complete') {
659
- console.log('\n✓ 投影已完成,向量/图检索可用');
660
- } else if (projStatus === 'degraded') {
661
- console.log('\n⚠️ 投影完成但有降级(部分通道不可用,召回结果会标记降级)');
662
- } else if (projStatus === 'disabled') {
663
- console.log('\nℹ️ 投影已禁用(仅可使用当前已就绪的检索通道)');
664
- } else if (projStatus && projStatus !== 'complete') {
665
- console.log(`\nℹ️ 投影进行中 (${projStatus}),向量/图检索暂不可用`);
666
- console.log(' → 正常 SDD 流程中阶段间间隔通常足够投影完成');
667
- console.log(' → 如后续查询 degraded,可用 `context-client.cjs --check-only` 确认 KB 可用性');
668
- } else if (jobStatus === 'succeeded') {
669
- console.log('\n✓ 入库成功');
670
- }
595
+ // Direct JSON transport reads the existing documents; ZIP remains for --package compatibility.
671
596
 
672
- } finally {
673
- // 清理临时目录
674
- fs.rmSync(stagingDir, { recursive: true, force: true });
675
- console.log('\n✓ 临时文件已清理');
676
- }
597
+ const result = await require('./kb-ingest.cjs').uploadChanges(specRoot, [{ key: changeName }], { spaceId: args['space-id'], kbId: args['kb-id'], overrideUploadTarget: Boolean(args['override-upload-target']), requireReview: Boolean(args['require-review']), confirmReview: Boolean(args['confirm-review']) });
598
+ console.log(JSON.stringify(result, null, 2));
599
+ if (result.items.some(item => item.status === 'failed')) { for (const item of result.items.filter(i => i.status === 'failed')) console.error(item.message); process.exitCode = 1; }
677
600
 
678
601
  } catch (err) {
679
602
  console.error(`\n❌ ${err.message}`);
@@ -681,5 +604,6 @@ async function main() {
681
604
  }
682
605
  }
683
606
 
607
+ module.exports = { resolveUploadTarget, prepareUploadConfig, readKbState, readArchiveCache, updateKbStateCache, assertIngestionResult, uploadZip, buildPackageFromFolder, validateRequiredFiles };
608
+
684
609
  if (require.main === module) main();
685
- module.exports = { resolveUploadTarget, readArchiveCache, updateKbStateCache, assertIngestionResult, uploadZip, buildPackageFromFolder, validateRequiredFiles };
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+ // One resolver for tray, Skills, retrieval, uploads and reporting. Never export a
3
+ // personal file wholesale as team configuration.
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+ const PUBLIC_KEYS = ['api', 'targets', 'uploadTarget'];
8
+ const hash = value => crypto.createHash('sha256').update(value).digest('hex');
9
+ function endpoint(value) {
10
+ const url = new URL(value);
11
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('知识库服务地址无效');
12
+ return url.toString().replace(/\/$/, '').replace(/\/api$/, '') + '/api';
13
+ }
14
+ function objectFile(file) {
15
+ if (!fs.existsSync(file)) return null;
16
+ if (fs.lstatSync(file).isSymbolicLink()) throw new Error('配置文件不能是符号链接');
17
+ const value = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
18
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('配置必须是 JSON 对象');
19
+ return value;
20
+ }
21
+ function paths(root) {
22
+ return { team: path.join(root, 'kb.config.json'), personal: path.join(root, 'kb-state.json'), state: path.join(root, 'skywalk-sdd/state/ingestions.json') };
23
+ }
24
+ function unionTargets(a, b) {
25
+ const result = [];
26
+ for (const t of [...(a || []), ...(b || [])]) {
27
+ if (!t || typeof t.spaceId !== 'string' || typeof t.kbId !== 'string' || !t.spaceId || !t.kbId) throw new Error('知识库目标必须包含空间和知识库');
28
+ if (!result.some(x => x.spaceId === t.spaceId && x.kbId === t.kbId)) result.push(t);
29
+ }
30
+ return result;
31
+ }
32
+ function readProjectConfig(root) {
33
+ const files = paths(root), personal = objectFile(files.personal) || {}, team = objectFile(files.team);
34
+ const revision = hash([files.team, files.personal].map(f => fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '').join('\0'));
35
+ let config = { ...personal }, warnings = [];
36
+ if (team) {
37
+ if (team.schemaVersion !== 1) throw new Error('团队知识库配置版本不兼容,请升级 kld-sdd');
38
+ if (Object.keys(team).some(k => !['schemaVersion', ...PUBLIC_KEYS].includes(k))) throw new Error('团队知识库配置包含非公共字段,请移到个人配置');
39
+ if (!Array.isArray(team.targets) || !Object.hasOwn(team, 'uploadTarget')) throw new Error('团队配置缺少读取范围或上传位置');
40
+ config = { ...personal, api: endpoint(team.api), targets: unionTargets(team.targets, personal.personalReadTargets), uploadTarget: team.uploadTarget };
41
+ }
42
+ const credentialApi = personal.credentialForApi || personal.api;
43
+ if (personal.apiKey && config.api && ((!credentialApi && team) || (credentialApi && credentialApi !== config.api && endpoint(credentialApi) !== endpoint(config.api)))) {
44
+ config.apiKey = ''; config.credentialMismatch = true;
45
+ warnings.push('知识库服务地址已变化,请重新登录;原 Key 已保留在本机');
46
+ }
47
+ // A changed shared server must not inherit a process-wide credential either.
48
+ if (team && !config.apiKey) config.credentialMismatch = true;
49
+ return { config, personal, team, files, revision, warnings, path: fs.existsSync(files.personal) || team ? files.personal : null };
50
+ }
51
+ function atomic(file, value) {
52
+ fs.mkdirSync(path.dirname(file), { recursive: true });
53
+ if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) throw new Error('配置文件不能是符号链接');
54
+ const temp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
55
+ try { fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); fs.renameSync(temp, file); }
56
+ finally { fs.rmSync(temp, { force: true }); }
57
+ }
58
+ function saveProjectConfig(root, config, options = {}) {
59
+ return require('../ontology/change-lock.cjs').withChangeLock(root, 'kb-config', {}, () => {
60
+ const before = readProjectConfig(root);
61
+ if (options.revision != null && options.revision !== before.revision) throw new Error('配置已被其他程序修改,请重新读取');
62
+ const api = endpoint(config.api);
63
+ protectPersonal(root);
64
+ const apiKey = config.credentialMismatch && !config.apiKey ? before.personal.apiKey : config.apiKey;
65
+ const sameKey = apiKey && apiKey === before.personal.apiKey;
66
+ const credentialApi = sameKey && !options.credentialVerified ? (before.personal.credentialForApi || before.personal.api || api) : api;
67
+ let personal = { ...before.personal, ...config, ...(apiKey !== undefined ? { apiKey } : {}), api, credentialForApi: credentialApi };
68
+ if (options.team === true || before.team) {
69
+ const targets = unionTargets(config.targets, []);
70
+ const selected = config.uploadTarget || null;
71
+ if (selected && !targets.some(t => t.spaceId === selected.spaceId && t.kbId === selected.kbId)) throw new Error('上传位置必须在所选知识库中');
72
+ if (options.team === true) {
73
+ const team = { schemaVersion: 1, api, targets: targets.map(t => Object.fromEntries(['spaceId', 'kbId', 'spaceKey', 'spaceName', 'kbName'].filter(k => typeof t[k] === 'string').map(k => [k, t[k]]))), uploadTarget: selected && { spaceId: selected.spaceId, kbId: selected.kbId } };
74
+ atomic(before.files.team, team);
75
+ } else {
76
+ if (api !== endpoint(before.team.api) || JSON.stringify(selected) !== JSON.stringify(before.team.uploadTarget)) throw new Error('修改团队地址或上传位置,请选择「团队共享」保存');
77
+ }
78
+ const defaults = options.team === true ? targets : before.team.targets;
79
+ personal.personalReadTargets = targets.filter(t => !defaults.some(x => x.spaceId === t.spaceId && x.kbId === t.kbId));
80
+ delete personal.targets; delete personal.uploadTarget;
81
+ }
82
+ delete personal.credentialMismatch;
83
+ if (personal.archiveCache) {
84
+ const state = objectFile(before.files.state) || { schemaVersion: 1, receipts: {} };
85
+ state.legacyArchiveCache = { ...personal.archiveCache, ...state.legacyArchiveCache };
86
+ atomic(before.files.state, state); delete personal.archiveCache;
87
+ }
88
+ protectPersonal(root);
89
+ atomic(before.files.personal, personal);
90
+ return readProjectConfig(root);
91
+ });
92
+ }
93
+ function readReceipts(root) { return objectFile(paths(root).state) || { schemaVersion: 1, receipts: {} }; }
94
+ function writeReceipt(root, key, value) {
95
+ return require('../ontology/change-lock.cjs').withChangeLock(root, 'kb-config', {}, () => {
96
+ const state = readReceipts(root); state.receipts ||= {}; state.receipts[key] = value;
97
+ protectPersonal(root); atomic(paths(root).state, state); return value;
98
+ });
99
+ }
100
+ function protectPersonal(root) {
101
+ const file = path.join(root, '.gitignore');
102
+ if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) throw new Error('.gitignore 不能是符号链接');
103
+ const old = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
104
+ const missing = ['/kb-state.json', '/skywalk-sdd/state/'].filter(rule => !old.split(/\r?\n/).includes(rule));
105
+ if (missing.length) fs.writeFileSync(file, old + (old.endsWith('\n') || !old ? '' : '\n') + missing.join('\n') + '\n');
106
+ }
107
+ module.exports = { endpoint, paths, readProjectConfig, saveProjectConfig, readReceipts, writeReceipt, atomic, unionTargets };
@@ -352,12 +352,13 @@ function loadKbState(anchorDir = __dirname, env = process.env) {
352
352
  let current = start;
353
353
  for (let level = 0; level < MAX_KB_STATE_LEVELS; level += 1) {
354
354
  const candidate = path.join(current, KB_STATE_FILENAME);
355
- if (fs.existsSync(candidate) && isKbStateHostDir(current)) {
355
+ if ((fs.existsSync(candidate) || fs.existsSync(path.join(current, 'kb.config.json'))) && isKbStateHostDir(current)) {
356
356
  try {
357
- const config = JSON.parse(stripBom(fs.readFileSync(candidate, 'utf8')));
358
- return { config, path: candidate, warnings };
357
+ return require('./kb-config.cjs').readProjectConfig(current);
359
358
  } catch (e) {
360
359
  warnings.push(`kb-state.json 解析失败 (${candidate}): ${e.message}`);
360
+ // A broken configuration belongs to this Spec. Never borrow another project's credentials.
361
+ return { config: null, path: candidate, warnings };
361
362
  }
362
363
  }
363
364
  if (current === fileRoot) break;
@@ -391,7 +392,7 @@ function normalizeProjectName(value) {
391
392
  */
392
393
  function resolveKbParams(stateConfig = {}, env = process.env) {
393
394
  const apiBase = stateConfig.api || env.ENGINEERING_KB_API || 'http://localhost:8090/api';
394
- const token = stateConfig.apiKey || env.ENGINEERING_KB_TOKEN || '';
395
+ const token = stateConfig.credentialMismatch ? '' : stateConfig.apiKey || env.ENGINEERING_KB_TOKEN || '';
395
396
  const spaceId = stateConfig.targets && stateConfig.targets.length > 0
396
397
  ? stateConfig.targets[0].spaceId : '';
397
398
  const kbId = stateConfig.targets && stateConfig.targets.length > 0
@@ -523,7 +524,8 @@ function setKbProjectName(specRoot, projectName, options = {}) {
523
524
  error.code = 'E_PROJECT_NAME_INVALID';
524
525
  throw error;
525
526
  }
526
- const next = { ...kbState, projectName: normalized };
527
+ const personal = require('./kb-config.cjs').readProjectConfig(root).personal;
528
+ const next = { ...personal, projectName: normalized };
527
529
  writeJsonAtomic(statePath, next);
528
530
  return { ok: true, path: statePath, projectName: next.projectName };
529
531
  }
@@ -57,16 +57,11 @@ function ensureProjectIdentity(projectRoot) {
57
57
  }
58
58
  return existing;
59
59
  }
60
- // 兜底:project-identity.json 未通过 opsx-kb-config 配置 KB 时创建
61
- // 此时 project_id 为随机值,KB 入库时会因 project_id !== spaceKey 而失败
62
- console.warn('⚠️ project-identity.json 不存在 — 未通过 opsx-kb-config 配置 KB。');
63
- console.warn('⚠️ 将生成临时 project_id,KB 入库时需确保与 Space spaceKey 一致。');
64
- console.warn('⚠️ 建议运行 /opsx-kb-config 配置 KB 后更新 project_id 为 Space spaceKey。');
60
+ // Stable source identity is independent of where the package is uploaded.
65
61
  const identity = {
66
62
  schema_version: PROJECT_IDENTITY_SCHEMA_VERSION,
67
63
  project_id: `urn:kld:sdd:project:${generateUuidV7()}`,
68
64
  created_at: new Date().toISOString(),
69
- _warning: 'UNCONFIGURED — 请运行 /opsx-kb-config 配置 KB 后更新 project_id 为 Space spaceKey',
70
65
  };
71
66
  writeJsonAtomic(identityPath, identity);
72
67
  return identity;