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.
package/README.md CHANGED
@@ -6,6 +6,21 @@ KLD SDD OpenSpec 工程增强工具:一键初始化 AI 编辑器技能、语
6
6
 
7
7
  日常提交默认只保留本地关联 Hook;Apply 自动执行代码/Spec 检查、真实测试与交付验证。旧项目升级、备份回退、多仓和 CI 边界见 [Git Hook 调整说明](USABILITY.md#2026-09-10git-hook-收缩与交付验证)。
8
8
 
9
+ ## Spec 直接入库(2.8.2)
10
+
11
+ 日常入库直接读取选中的本地 Change 文档,通过 `/ingestions/direct` 发送本体 JSON,不生成 ZIP、不调用托盘模型,也不要求先完成归档。旧 `kb-upload.cjs --folder` 使用同一实现;`--package` 仅兼容已有归档包。Archive 的离线 ZIP 产物仍保留。
12
+
13
+ ```bash
14
+ node skywalk-sdd/kb-ingest.cjs --project=<spec-root> --list
15
+ node skywalk-sdd/kb-ingest.cjs --project=<spec-root> --change=<change目录名>
16
+ ```
17
+
18
+ `lib/kb-config.cjs` 统一合并 `kb.config.json`(共享地址、读取范围、唯一上传库)和 `kb-state.json`(个人 Key、个人补充读取范围)。旧项目仅有个人文件仍兼容;团队地址改变后需重新登录。入库回执在 `skywalk-sdd/state/ingestions.json`,个人文件与回执自动忽略 Git。
19
+
20
+ 每次核对远端内容,相同内容跳过,更新生成新快照,旧版本保留;并发基线变化整体拒绝,先同步团队 Spec。历史产物缺 UUID 自动旁路适配,不改写业务 Markdown。无业务事实、候选待审核、服务端索引异常分别说明,不冒充发布成功。需人工候选审核时用 `--require-review`;审阅同一原文并明确授权后用 `--confirm-review` 发布。
21
+
22
+ 正式服务需先部署直接入库与基线接口。服务尚未升级时明确失败,本地 SDD 开发可继续;不会改投其他知识库或静默降级到 ZIP。
23
+
9
24
  ## 工具更新与卸载
10
25
 
11
26
  **更新不需要先卸载。** 在原工作目录重新运行安装器,覆盖运行脚本、Skills 和只读模板;Spec 文档、KB 绑定、项目身份、历史数据、用户配置和自定义 Hook 保留。先确认目标编辑器及 Spec 目录,完成后检查实际部署版本。下载失败不启动安装;执行中断后可以重跑补齐,不承诺整个项目的事务回滚。
package/lib/init.js CHANGED
@@ -890,7 +890,7 @@ function deployTelemetryDataDir(targetCwd = process.cwd()) {
890
890
  if (!fs.existsSync(sharedLibDir)) {
891
891
  fs.mkdirSync(sharedLibDir, { recursive: true });
892
892
  }
893
- for (const name of ['test-execution.cjs', 'check-review.cjs']) {
893
+ for (const name of ['test-execution.cjs', 'check-review.cjs', 'kb-config.cjs']) {
894
894
  const src = path.join(pkgPath, 'skywalk-sdd', 'lib', name);
895
895
  if (!fs.existsSync(src)) throw new Error(`E_INIT_MISSING_CRITICAL: lib/${name} 源文件缺失`);
896
896
  fs.copyFileSync(src, path.join(sharedLibDir, name));
@@ -978,6 +978,8 @@ function deployTelemetryDataDir(targetCwd = process.cwd()) {
978
978
  deployedModules += 1;
979
979
  }
980
980
 
981
+ if (deployRuntimeModule(path.join(pkgPath, 'skywalk-sdd/kb-ingest.cjs'), path.join(dataDir, 'kb-ingest.cjs'), 'Spec 文档直接入库', '缺少直接入库运行文件')) deployedModules += 1;
982
+
981
983
  // 部署 kb-upload.cjs(知识库上传脚本,支持文件夹/zip 双模式随时入库)
982
984
  const kbUploadSrc = path.join(pkgPath, 'skywalk-sdd', 'kb-upload.cjs');
983
985
  const kbUploadDst = path.join(dataDir, 'kb-upload.cjs');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kld-sdd",
3
- "version": "2.8.1",
3
+ "version": "2.8.3",
4
4
  "description": "KLD SDD OpenSpec 项目初始化工具 - 一键部署 SDD skills",
5
5
  "main": "lib/init.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "bench:check": "node test/benchmark-check.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/spec-ontology-compatibility.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 test/maintenance.test.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"
12
+ "test": "node --test test/kb-direct.test.cjs && 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/spec-ontology-compatibility.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 test/maintenance.test.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",
@@ -45,7 +45,8 @@
45
45
  "skywalk-sdd/kb-upload.cjs",
46
46
  "skywalk-sdd/kb-sync-identity.cjs",
47
47
  "README.md",
48
- "USABILITY.md"
48
+ "USABILITY.md",
49
+ "skywalk-sdd/kb-ingest.cjs"
49
50
  ],
50
51
  "sddMaintenance": {
51
52
  "toolOnly": 1
@@ -119,7 +119,8 @@ async function retrieveContext(args = parseArgs(process.argv), env = process.env
119
119
  if (args.offline) return unavailableContext('engineering_kb_offline', '本次使用本地上下文,未验证远程基线');
120
120
  // 优先从共享 state 文件读取配置(与 opsx-ontology-query / opsx-kb-ingest 共用)
121
121
  let stateConfig = {};
122
- const stateFile = args['state-file'] || env.SDD_KB_STATE_FILE;
122
+ const stateFile = args['state-file'];
123
+ if (!stateFile && env.SDD_KB_STATE_FILE) console.error('⚠️ SDD_KB_STATE_FILE 已忽略;默认读取当前 Spec 根目录的 kb-state.json,需要临时读取其他配置时请显式传 --state-file');
123
124
 
124
125
  if (stateFile) {
125
126
  // 显式指定路径,直接读取
@@ -127,7 +128,9 @@ async function retrieveContext(args = parseArgs(process.argv), env = process.env
127
128
  if (fs.existsSync(stateFile)) {
128
129
  let raw = fs.readFileSync(stateFile, 'utf8');
129
130
  raw = stripBom(raw);
130
- stateConfig = JSON.parse(raw);
131
+ stateConfig = /^(kb-state|kb.config)\.json$/.test(path.basename(stateFile))
132
+ ? require('./lib/kb-config.cjs').readProjectConfig(path.dirname(path.resolve(stateFile))).config
133
+ : JSON.parse(raw);
131
134
  }
132
135
  } catch (e) {
133
136
  console.error(`⚠️ kb-state.json 解析失败 (${stateFile}): ${e.message}`);
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Local documents -> structured JSON -> transactional KB ingestion. No ZIP and no model required.
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+ const configStore = require('./lib/kb-config.cjs');
8
+ const { parseChangeArtifacts } = require('./ontology/artifact-parser.cjs');
9
+ const { normalizeFacts } = require('./ontology/normalizer.cjs');
10
+ const { buildSpecOntology, validateSpecOntology, deterministicUuid } = require('./ontology/spec-ontology.cjs');
11
+ const { readKbState, resolveUploadTarget, prepareUploadConfig, assertIngestionResult } = require('./kb-upload.cjs');
12
+ const sha = text => crypto.createHash('sha256').update(text).digest('hex');
13
+ const jsonText = value => JSON.stringify(value, null, 2) + '\n';
14
+ function documents(dir) {
15
+ const result = {};
16
+ const visit = (current, prefix = '') => {
17
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
18
+ if (entry.isSymbolicLink()) throw new Error('Spec 中存在符号链接,请移到实际目录再入库');
19
+ if (entry.name.startsWith('.') || ['reports', 'logs', 'ontology'].includes(entry.name)) continue;
20
+ const relative = prefix + entry.name;
21
+ if (entry.isDirectory()) visit(path.join(current, entry.name), relative + '/');
22
+ else if (/^(proposal|spec|design|tasks?)\.md$/i.test(entry.name)) {
23
+ if (fs.statSync(path.join(current, entry.name)).size > 4 * 1024 * 1024) throw new Error('单份 Spec 文档过大,请拆分后入库');
24
+ result[relative] = fs.readFileSync(path.join(current, entry.name), 'utf8');
25
+ }
26
+ }
27
+ };
28
+ visit(dir);
29
+ return result;
30
+ }
31
+ function sourceFingerprint(files) {
32
+ return 'sha256:' + sha(Object.keys(files).sort().map(name => name + '\0' + sha(files[name].replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'))).join('\n'));
33
+ }
34
+ function locate(root, key) {
35
+ if (typeof key !== 'string' || !/^(?:archive\/)?[^/\\.][^/\\]*$/.test(key) || key.includes('..')) throw new Error('无效的 Change 目录');
36
+ const base = path.resolve(root, 'openspec/changes'), dir = path.resolve(base, key);
37
+ if (!fs.realpathSync(dir).startsWith(fs.realpathSync(base) + path.sep) || fs.lstatSync(dir).isSymbolicLink()) throw new Error('Change 必须位于当前 Spec 目录中');
38
+ return dir;
39
+ }
40
+ function localChange(root, key) {
41
+ const dir = locate(root, key), files = documents(dir);
42
+ if (!Object.keys(files).some(f => /(^|\/)proposal\.md$/i.test(f)) || !Object.keys(files).some(f => /(^|\/)spec\.md$/i.test(f))) throw new Error('请先完成 proposal.md 和 spec.md,再入库');
43
+ // OpenSpec archive adds a calendar prefix; it must not create a second Change identity.
44
+ const name = key.startsWith('archive/') ? path.basename(key).replace(/^\d{4}-\d{2}-\d{2}-/, '') : path.basename(key);
45
+ const facts = normalizeFacts(parseChangeArtifacts(root, name, { changeDir: dir, persistStructuralIdentities: false }));
46
+ const changeId = String(facts.change_id || `CHG-LEGACY-${sha(name).slice(0, 12)}`).toUpperCase();
47
+ const legacyChangeId = !facts.change_id && name !== path.basename(key) ? `CHG-LEGACY-${sha(path.basename(key)).slice(0, 12)}`.toUpperCase() : null;
48
+ const updatedAt = new Date(Math.max(...Object.keys(files).map(f => fs.statSync(path.join(dir, f)).mtimeMs))).toISOString();
49
+ return { key, dir, files, facts, changeId, legacyChangeId, sourceFingerprint: sourceFingerprint(files), updatedAt };
50
+ }
51
+ function connection(root, initialize = false, uploadOptions = {}) {
52
+ const loaded = configStore.readProjectConfig(root);
53
+ const state = readKbState(root), target = resolveUploadTarget(state, uploadOptions);
54
+ if (!state.apiKey) throw new Error('请先登录 Spec 知识库并保存连接');
55
+ const api = configStore.endpoint(state.api), base = `${api}/v1/spaces/${encodeURIComponent(target.spaceId)}/knowledge-bases/${encodeURIComponent(target.kbId)}/ingestions`;
56
+ const projectId = initialize ? prepareUploadConfig(root, uploadOptions).projectId : null;
57
+ return { state, target, base, api, projectId, revision: loaded.revision };
58
+ }
59
+ async function request(c, suffix, data, options = {}) {
60
+ let result;
61
+ if (!options.fetcher && typeof fetch !== 'function') {
62
+ try { result = await require('./lib/shared.cjs').requestJson(c.base + suffix, data, c.state.apiKey, 120000); }
63
+ catch (error) { if (/Not Found|404/.test(error.message)) throw new Error('知识库服务尚未升级到直接入库接口,请先部署新版服务'); throw error; }
64
+ } else {
65
+ const response = await (options.fetcher || fetch)(c.base + suffix, { method: 'POST', redirect: 'error', headers: { Authorization: `Bearer ${c.state.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data), signal: options.signal || (typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(120000) : undefined) });
66
+ if (response.status === 404) throw new Error('知识库服务尚未升级到直接入库接口,请先部署新版服务');
67
+ if (!response.ok) throw new Error(`知识库请求失败(HTTP ${response.status}),请检查连接或权限`);
68
+ result = await response.json();
69
+ }
70
+ if (result.code != null && result.code !== 0) throw new Error(result.message || '知识库拒绝请求');
71
+ return result.data || result;
72
+ }
73
+ function receiptKey(c, changeId) { return JSON.stringify([c.api, c.target.spaceId, c.target.kbId, changeId]); }
74
+ function classify(local, remote, receipt) {
75
+ if (!remote?.snapshotId) return { status: 'new', label: '未入库', selected: true };
76
+ if (remote.status === 'needs_review') return { status: 'review', label: '已保存待审核,尚未供后续需求使用', selected: false };
77
+ if (remote.sourceFingerprint === local.sourceFingerprint && ['failed', 'degraded'].includes(remote.projectionStatus)) return { status: 'saved', label: '内容已保存,服务端检索索引未完成;请联系知识库管理员处理', selected: false };
78
+ if (remote.sourceFingerprint === local.sourceFingerprint) return { status: 'uploaded', label: remote.status === 'needs_review' ? '已保存,待审核' : '已入库', selected: false };
79
+ if (receipt?.snapshotId === remote.snapshotId) return { status: 'updated', label: '有更新', selected: true };
80
+ return { status: 'conflict', label: '请先同步团队 Spec', selected: false };
81
+ }
82
+ async function listChanges(root, options = {}) {
83
+ const c = connection(root, false, options), base = path.join(root, 'openspec/changes');
84
+ const keys = [];
85
+ for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
86
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
87
+ if (entry.name === 'archive') {
88
+ for (const child of fs.readdirSync(path.join(base, 'archive'), { withFileTypes: true })) if (child.isDirectory() && !child.name.startsWith('.')) keys.push('archive/' + child.name);
89
+ } else keys.push(entry.name);
90
+ }
91
+ const local = keys.map(key => { try { return localChange(root, key); } catch (error) { return { key, error: error.message }; } });
92
+ const remote = new Map();
93
+ const ids = [...new Set(local.filter(x => !x.error).flatMap(x => [x.changeId, x.legacyChangeId].filter(Boolean)))];
94
+ for (let i = 0; i < ids.length; i += 200) {
95
+ const result = await request(c, '/spec-status', { changeIds: ids.slice(i, i + 200) }, options);
96
+ for (const item of result.items || []) remote.set(item.changeId, item);
97
+ if (ids.slice(i, i + 200).some(id => !remote.has(id))) throw new Error('知识库未完整返回入库状态,请稍后刷新;本次未上传');
98
+ }
99
+ const receipts = configStore.readReceipts(root).receipts || {};
100
+ return { configRevision: c.revision, uploadTarget: { spaceId: c.target.spaceId, kbId: c.target.kbId }, destination: [c.target.spaceName, c.target.kbName || c.target.kbId].filter(Boolean).join(' / '), items: local.map(item => {
101
+ if (item.error) return { key: item.key, status: 'invalid', label: item.error, selected: false };
102
+ let current = remote.get(item.changeId);
103
+ if (!current?.snapshotId && item.legacyChangeId && remote.get(item.legacyChangeId)?.snapshotId) { item.changeId = item.legacyChangeId; current = remote.get(item.changeId); }
104
+ if (current?.snapshotId && current.sourceFingerprint === item.sourceFingerprint && current.status !== 'needs_review') configStore.writeReceipt(root, receiptKey(c, item.changeId), { snapshotId: current.snapshotId, sourceFingerprint: item.sourceFingerprint, jobId: current.jobId, uploadedAt: new Date().toISOString() });
105
+ return { key: item.key, changeId: item.changeId, sourceFingerprint: item.sourceFingerprint, updatedAt: item.updatedAt, snapshotId: current?.snapshotId || null, ...classify(item, current, receipts[receiptKey(c, item.changeId)]) };
106
+ }).sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')) };
107
+ }
108
+ function extractOntology(local, projectId, requireReview = false) {
109
+ const facts = { ...local.facts, change_id: local.changeId };
110
+ const reviewStatus = requireReview ? 'pending' : 'confirmed';
111
+ let ontology = buildSpecOntology(facts, { projectId, reviewStatus });
112
+ if (!validateSpecOntology(ontology).valid) ontology = buildSpecOntology(facts, { projectId, compatibilityMode: 'LEGACY_MATERIALIZED', reviewStatus });
113
+ return ontology;
114
+ }
115
+ function directPayload(local, projectId, baseline, options = {}) {
116
+ // Reuse the existing ontology extractor, including legacy identity repair. It reads only this Change.
117
+ const ontology = extractOntology(local, projectId, options.requireReview);
118
+ if (!ontology.entities.length) throw new Error('尚未识别到业务对象、规则或场景,请补充结构化 Spec 后再入库');
119
+ for (const entity of ontology.entities) {
120
+ entity.entity_version_id = deterministicUuid(`${projectId}\n${entity.semantic_key}\n${entity.content_hash}`);
121
+ const source = entity.source, lines = (local.files[source.file] || '').replace(/\r\n?/g, '\n').split('\n');
122
+ const start = Math.max(0, Number(source.line || 1) - 1);
123
+ let end = start + 1;
124
+ while (end < lines.length && !/^#{1,6}\s/.test(lines[end])) end++;
125
+ source.quote = lines.slice(start, end).join('\n').trim();
126
+ const body = lines.slice(start + 1, end).join('\n').trim();
127
+ if (!entity.attributes.description && !entity.attributes.summary && body) entity.summary = body;
128
+ }
129
+ for (const mapping of ontology.identity_map) mapping.entity_version_id = ontology.entities.find(e => e.semantic_key === mapping.semantic_key).entity_version_id;
130
+ const archiveId = `urn:kld:sdd:archive:${sha(projectId + '\n' + local.changeId + '\n' + local.sourceFingerprint + '\n' + (baseline.change?.snapshotId || 'initial') + '\n' + ontology.review_status)}`;
131
+ ontology.archive_id = archiveId;
132
+ delete ontology.ontology_hash; ontology.ontology_hash = 'sha256:' + sha(JSON.stringify(ontology));
133
+ const validation = validateSpecOntology(ontology);
134
+ if (!validation.valid) throw new Error(validation.errors.join('; '));
135
+ const files = { ...local.files, 'spec-ontology.json': jsonText(ontology), 'conversion-report.json': jsonText({ schema_version: 'kld-sdd-conversion-report/v1', producer: require('./runtime-metadata.cjs').resolveRuntimeMetadata().producer, target_file: 'spec-ontology.json', target_schema: 'kld-sdd-spec-ontology/v1', status: options.requireReview ? 'materialized_pending_review' : 'materialized_reviewed', entity_count: ontology.entities.length, relation_count: ontology.relations.length }) };
136
+ const expected = Object.fromEntries(ontology.entities.map(e => [e.semantic_key, baseline.facts?.[e.semantic_key]?.revisionId || null]));
137
+ files['archive-manifest.json'] = jsonText({ schema_version: 'kld-sdd-archive-manifest/v2', producer: require('./runtime-metadata.cjs').resolveRuntimeMetadata().producer, project_id: projectId, archive_id: archiveId, change_id: local.changeId, content_hash: 'sha256:' + sha(files['spec-ontology.json']), spec_ontology_path: 'spec-ontology.json', conversion_report_path: 'conversion-report.json', created_at: new Date().toISOString(), source_fingerprint: local.sourceFingerprint, expected_snapshot_id: baseline.change?.snapshotId || null, expected_fact_revisions: expected, files: Object.entries(files).map(([name, content]) => ({ path: name, content_hash: 'sha256:' + sha(content) })) });
138
+ return { schemaVersion: 'kld-sdd-direct-ingest/v1', files };
139
+ }
140
+ async function uploadChanges(root, selections, options = {}) {
141
+ if (options.requireReview && options.confirmReview) throw new Error('待审核和确认发布不能同时选择');
142
+ const c = connection(root, true, options), results = [];
143
+ if (options.configRevision && options.configRevision !== c.revision) throw new Error('知识库配置已变化,请重新读取入库列表');
144
+ const wanted = [...new Map(selections.map(s => [s.key, s])).values()];
145
+ for (const selected of wanted) {
146
+ try {
147
+ const local = localChange(root, selected.key);
148
+ if (selected.sourceFingerprint && local.sourceFingerprint !== selected.sourceFingerprint) throw new Error('文档已修改,请刷新入库列表');
149
+ const ontology = extractOntology(local, c.projectId);
150
+ if (selected.changeId === local.legacyChangeId) local.changeId = local.legacyChangeId;
151
+ let baseline = await request(c, '/prepare', { changeId: local.changeId, semanticKeys: ontology.entities.map(e => e.semantic_key), entityIds: [] }, options);
152
+ if (!baseline.change?.snapshotId && local.legacyChangeId && local.legacyChangeId !== local.changeId) {
153
+ const legacy = await request(c, '/prepare', { changeId: local.legacyChangeId, semanticKeys: ontology.entities.map(e => e.semantic_key), entityIds: [] }, options);
154
+ if (legacy.change?.snapshotId) { local.changeId = local.legacyChangeId; baseline = legacy; }
155
+ }
156
+ const remote = baseline.change;
157
+ const confirming = options.confirmReview && remote?.status === 'needs_review' && remote.sourceFingerprint === local.sourceFingerprint;
158
+ if (remote?.snapshotId && remote.sourceFingerprint === local.sourceFingerprint && !confirming) {
159
+ if (remote.status === 'needs_review') throw new Error('REVIEW_REQUIRED: 相同内容已保存待审核,请先在知识库确认,未重复上传');
160
+ configStore.writeReceipt(root, receiptKey(c, local.changeId), { snapshotId: remote.snapshotId, sourceFingerprint: local.sourceFingerprint, jobId: remote.jobId, uploadedAt: new Date().toISOString() });
161
+ const state = classify(local, remote, null);
162
+ results.push({ key: selected.key, status: state.status, message: state.status === 'uploaded' ? '相同内容已入库,跳过上传' : state.label }); continue;
163
+ }
164
+ const receipts = configStore.readReceipts(root).receipts || {}, receipt = receipts[receiptKey(c, local.changeId)];
165
+ const expectedSnapshot = confirming ? remote.snapshotId : Object.hasOwn(selected, 'snapshotId') ? selected.snapshotId : receipt?.snapshotId || null;
166
+ if ((remote?.snapshotId || null) !== (expectedSnapshot || null)) throw new Error('团队已有新版本,请同步 Spec 后重试');
167
+ if (remote?.snapshotId && !receipt && !confirming) throw new Error('此内容在团队已有版本,请先同步 Spec 并核对');
168
+ const payload = directPayload(local, c.projectId, baseline, options);
169
+ const result = await request(c, '/direct', payload, options);
170
+ const job = assertIngestionResult(result, { allowNeedsReview: options.requireReview });
171
+ if (job.report?.publication_status === 'needs_review') {
172
+ results.push({ key: selected.key, status: 'review', message: '已保存待审核,尚未供后续需求使用', jobId: job.jobId }); continue;
173
+ }
174
+ configStore.writeReceipt(root, receiptKey(c, local.changeId), { snapshotId: job.report?.snapshot_id, sourceFingerprint: local.sourceFingerprint, archiveId: job.archiveId, jobId: job.jobId, uploadedAt: new Date().toISOString() });
175
+ const state = classify(local, { snapshotId: job.report?.snapshot_id, sourceFingerprint: local.sourceFingerprint, projectionStatus: job.projectionStatus }, null);
176
+ results.push({ key: selected.key, status: state.status, message: state.label, jobId: job.jobId });
177
+ } catch (error) { results.push({ key: selected.key, status: 'failed', message: error.message }); }
178
+ options.onProgress?.(results[results.length - 1]);
179
+ }
180
+ return { items: results };
181
+ }
182
+ async function main() {
183
+ const { parseArgs, resolveSpecRootFast } = require('./lib/shared.cjs');
184
+ const args = parseArgs(process.argv.slice(2)), root = args.project ? path.resolve(args.project) : resolveSpecRootFast(__dirname);
185
+ const selections = args.selection ? JSON.parse(fs.readFileSync(args.selection, 'utf8')).items : [{ key: args.change }];
186
+ const options = { spaceId: args['space-id'], kbId: args['kb-id'], overrideUploadTarget: Boolean(args['override-upload-target']), configRevision: args.revision, requireReview: Boolean(args['require-review']), confirmReview: Boolean(args['confirm-review']) };
187
+ const result = args.list ? await listChanges(root, options) : await uploadChanges(root, selections, options);
188
+ console.log('SDD_INGEST_RESULT:' + JSON.stringify(result));
189
+ if (result.items.some(x => x.status === 'failed')) process.exitCode = 1;
190
+ }
191
+ if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; });
192
+ module.exports = { documents, sourceFingerprint, locate, localChange, connection, classify, listChanges, directPayload, uploadChanges, main };
@@ -44,7 +44,6 @@ const {
44
44
  readJson,
45
45
  writeTextAtomic,
46
46
  resolveSpecRootFast,
47
- loadKbState,
48
47
  resolveKbParams,
49
48
  httpGetJson,
50
49
  requestJson,
@@ -230,36 +229,11 @@ function filterRegistryKeys(registry, filePath) {
230
229
  * @returns {{ kbState: object, projectId: string, kbParams: { apiBase, token, spaceId, kbId } }}
231
230
  */
232
231
  function initKbConfig(specRoot, args = {}) {
233
- // 1. 读取 kb-state.json
234
- const { config: kbState, warnings } = loadKbState(specRoot);
235
- if (warnings && warnings.length > 0) {
236
- for (const w of warnings) console.warn(` ⚠️ ${w}`);
237
- }
238
- if (!kbState) {
239
- throw new Error(
240
- 'KB 配置不存在(kb-state.json 缺失)。\n' +
241
- '请运行 /opsx-kb-config 配置知识库后再使用本脚本,\n' +
242
- '或使用 --skip-kb-verify 跳过 KB 验证(不推荐)。'
243
- );
244
- }
245
-
246
- // 2. 读取 project-identity.json
247
- const identityPath = path.join(specRoot, 'skywalk-sdd', 'project-identity.json');
248
- let projectId = null;
249
- if (fs.existsSync(identityPath)) {
250
- const identity = readJson(identityPath);
251
- projectId = identity.project_id || null;
252
- }
253
- if (!projectId) {
254
- throw new Error(
255
- '无法确定 project_id。project-identity.json 不存在或无 project_id 字段。\n' +
256
- '请运行 /opsx-kb-config 配置知识库。'
257
- );
258
- }
259
-
260
- // 3. 解析 KB 参数
232
+ const { state: kbState, target, projectId } = require('./kb-upload.cjs').prepareUploadConfig(specRoot, {
233
+ spaceId: args['space-id'], kbId: args['kb-id'], overrideUploadTarget: Boolean(args['override-upload-target']),
234
+ readOnlyIdentity: Boolean(args['dry-run']),
235
+ });
261
236
  const kbParams = resolveKbParams(kbState);
262
- const target = require('./kb-upload.cjs').resolveUploadTarget(kbState, { spaceId: args['space-id'], kbId: args['kb-id'] });
263
237
  kbParams.spaceId = target.spaceId; kbParams.kbId = target.kbId;
264
238
  kbParams.apiBase = kbParams.apiBase.replace(/\/+$/, '');
265
239
  if (!kbParams.token) {
@@ -269,18 +243,6 @@ function initKbConfig(specRoot, args = {}) {
269
243
  throw new Error('KB 上传目标未配置(请检查 kb-state.json 的 targets 与 uploadTarget)。');
270
244
  }
271
245
 
272
- // 4. 校验 project_id 与 spaceKey 一致性
273
- if (target.spaceKey) {
274
- if (projectId !== target.spaceKey) {
275
- throw new Error(
276
- `project_id 不匹配:\n` +
277
- ` project-identity.json: ${projectId}\n` +
278
- ` kb-state.json spaceKey: ${target.spaceKey}\n` +
279
- `请运行 /opsx-kb-config 更新配置。`
280
- );
281
- }
282
- }
283
-
284
246
  return { kbState, projectId, kbParams };
285
247
  }
286
248