kld-sdd 2.7.8 → 2.8.1

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 (75) hide show
  1. package/README.md +26 -0
  2. package/USABILITY.md +72 -0
  3. package/bin/kld-sdd-init.js +718 -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 +507 -726
  10. package/lib/maintenance.js +221 -0
  11. package/lib/managed-hook-hashes.json +24 -0
  12. package/lib/settings-merge.js +102 -25
  13. package/lib/skills-bundle.js +5 -5
  14. package/lib/spec-choice.js +85 -0
  15. package/lib/tool-profiles.js +2 -2
  16. package/lib/uninstall.js +453 -0
  17. package/package.json +7 -4
  18. package/skywalk-sdd/context-client.cjs +38 -7
  19. package/skywalk-sdd/index.cjs +153 -27
  20. package/skywalk-sdd/kb-sync-identity.cjs +1 -1
  21. package/skywalk-sdd/kb-upload.cjs +242 -85
  22. package/skywalk-sdd/lib/check-review.cjs +41 -0
  23. package/skywalk-sdd/lib/shared.cjs +62 -41
  24. package/skywalk-sdd/lib/test-execution.cjs +130 -0
  25. package/skywalk-sdd/lib/usage-reporter.cjs +17 -8
  26. package/skywalk-sdd/ontology/archive-package.cjs +141 -0
  27. package/skywalk-sdd/ontology/artifact-parser.cjs +24 -3
  28. package/skywalk-sdd/ontology/cli.cjs +53 -0
  29. package/skywalk-sdd/ontology/id.cjs +1 -1
  30. package/skywalk-sdd/ontology/identity-index.cjs +5 -1
  31. package/skywalk-sdd/ontology/resolve-spec-root.cjs +49 -0
  32. package/skywalk-sdd/ontology/schema.cjs +6 -0
  33. package/skywalk-sdd/ontology/sdd-config.cjs +33 -5
  34. package/skywalk-sdd/ontology/spec-detect.cjs +421 -0
  35. package/skywalk-sdd/ontology/spec-ontology.cjs +507 -0
  36. package/skywalk-sdd/ontology/workspace-layout.cjs +65 -17
  37. package/templates/git-hooks/commit-msg +17 -85
  38. package/templates/git-hooks/commit-msg-runner.cjs +44 -0
  39. package/templates/git-hooks/commit-msg-sdd-trailer.cjs +60 -69
  40. package/templates/git-hooks/consistency-check-core.cjs +92 -261
  41. package/templates/git-hooks/delivery-check.cjs +231 -0
  42. package/templates/git-hooks/delivery-snapshot.cjs +181 -0
  43. package/templates/git-hooks/hooks.config +8 -6
  44. package/templates/git-hooks/pre-commit +19 -5
  45. package/templates/git-hooks/pre-commit-consistency-check.cjs +13 -31
  46. package/templates/git-hooks/pre-push +19 -5
  47. package/templates/git-hooks/pre-push-consistency-check.cjs +36 -83
  48. package/templates/hooks/claude/hooks/sdd-post-tool.cjs +2 -2
  49. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +7 -8
  50. package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +2 -2
  51. package/templates/openspec/proposal.md +1 -1
  52. package/templates/openspec/spec.md +8 -0
  53. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +28 -24
  54. package/templates/skills/kld-sdd/opsx-apply/checklist.md +12 -3
  55. package/templates/skills/kld-sdd/opsx-apply/reference.md +21 -1
  56. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +20 -18
  57. package/templates/skills/kld-sdd/opsx-check/SKILL.md +18 -13
  58. package/templates/skills/kld-sdd/opsx-check/reference.md +3 -1
  59. package/templates/skills/kld-sdd/opsx-check/review-template.json +22 -0
  60. package/templates/skills/kld-sdd/opsx-check/reviewer.md +27 -6
  61. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +34 -112
  62. package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +15 -14
  63. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +93 -61
  64. package/templates/skills/kld-sdd/opsx-kb-config/reference.md +14 -12
  65. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +16 -5
  66. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +13 -5
  67. package/templates/skills/kld-sdd/opsx-kb-ingest/state.example.json +18 -0
  68. package/templates/skills/kld-sdd/opsx-ontology-ingest/SKILL.md +77 -0
  69. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +5 -4
  70. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +14 -1
  71. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +1 -1
  72. package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +9 -0
  73. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +5 -3
  74. package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -2
  75. package/index.js +0 -13
@@ -12,6 +12,11 @@
12
12
  * node skywalk-sdd/kb-upload.cjs --folder=<path> [--minimum-level=propose-spec] [--validate-only]
13
13
  * node skywalk-sdd/kb-upload.cjs --package=<zip-path>
14
14
  * node skywalk-sdd/kb-upload.cjs --folder=<path> --project=<spec-root> --change=<name>
15
+ * node skywalk-sdd/kb-upload.cjs --folder=<path> --require-review
16
+ *
17
+ * 历史产物(缺 entity-id / version-id):
18
+ * 自动旁路物化 spec-ontology.json,无 semantic_key 冲突时自动确认并发布为 current;
19
+ * 有冲突或 --require-review 时只上传候选,需人工 approve-spec-ontology 后再上传。
15
20
  *
16
21
  * project_id 解析:
17
22
  * 1. $SPEC_ROOT/skywalk-sdd/project-identity.json 的 project_id
@@ -28,18 +33,34 @@ const {
28
33
  ensureDir,
29
34
  copyDirRecursive,
30
35
  resolveSpecRootFast,
31
- loadKbState,
36
+ KB_STATE_FILENAME,
37
+ isKbStateHostDir,
32
38
  uploadFile,
33
39
  httpGetJson,
34
40
  } = require('./lib/shared.cjs');
35
41
 
36
42
  function resolveUploadTarget(state, options = {}) {
37
43
  if (Boolean(options.spaceId) !== Boolean(options.kbId)) throw new Error('必须同时指定 --space-id 和 --kb-id');
38
- const targets = state.targets || [];
44
+ const targets = Array.isArray(state?.targets) ? state.targets : [];
39
45
  if (options.spaceId && options.kbId) {
40
46
  return targets.find(t => t.spaceId === options.spaceId && t.kbId === options.kbId) || options;
41
47
  }
42
- if (targets.length !== 1) throw new Error('请明确上传目标:配置一个知识库,或同时指定 --space-id 和 --kb-id;不会自动分发到多个 KB');
48
+
49
+ const configured = state?.uploadTarget;
50
+ if (configured != null) {
51
+ if (!configured.spaceId || !configured.kbId) {
52
+ throw new Error('kb-state.json 的 uploadTarget 不完整,请在 SDD 助手中重新选择 Spec 上传知识库');
53
+ }
54
+ const selected = targets.find(t => t.spaceId === configured.spaceId && t.kbId === configured.kbId);
55
+ if (!selected) {
56
+ throw new Error('kb-state.json 的 uploadTarget 已不在 targets 读取范围中,请在 SDD 助手中重新选择 Spec 上传知识库');
57
+ }
58
+ return selected;
59
+ }
60
+
61
+ if (targets.length !== 1) {
62
+ throw new Error('请明确上传目标:在 SDD 助手中选择“Spec 上传知识库”,或同时指定 --space-id 和 --kb-id;不会自动分发到多个 KB');
63
+ }
43
64
  if (!targets[0].spaceId || !targets[0].kbId) throw new Error('知识库目标缺少 spaceId/kbId');
44
65
  return targets[0];
45
66
  }
@@ -49,18 +70,33 @@ function cacheKey(changeName, context) {
49
70
  return JSON.stringify([context.api.replace(/\/+$/, ''), context.spaceId, context.kbId, changeName]);
50
71
  }
51
72
 
52
- function assertIngestionResult(result) {
73
+ function assertIngestionResult(result, options = {}) {
53
74
  if (result?.code != null && result.code !== 0) throw new Error(result.message || 'KB API 拒绝入库');
54
75
  const job = result?.data || result;
55
76
  if (!job || !['projecting', 'succeeded'].includes(job.status) || job.relationStatus !== 'committed') {
56
77
  throw new Error(`INGEST_NOT_COMMITTED: ${job?.errorCode || job?.status || 'unknown'} ${job?.errorMessage || ''} job=${job?.jobId || 'unknown'}`);
57
78
  }
58
- if (Number(job.report?.version_conflicts || 0) > 0 || job.report?.publication_status === 'needs_review') {
79
+ if (Number(job.report?.version_conflicts || 0) > 0) {
59
80
  throw new Error(`VERSION_CONFLICT: 事实包已保存为候选,当前事实未更新;请合并基线后重新发布。job=${job.jobId}`);
60
81
  }
82
+ if (job.report?.publication_status === 'needs_review' && !options.allowNeedsReview) {
83
+ throw new Error(`REVIEW_REQUIRED: 事实包已保存为候选,当前事实未更新;请审核本体后重新发布。job=${job.jobId}`);
84
+ }
61
85
  return job;
62
86
  }
63
87
 
88
+ /**
89
+ * 解析 kb-state.json 的权威路径:`<specRoot>/kb-state.json`。
90
+ *
91
+ * 位置校验已由 `resolveSpecRoot` 在入口完成(本函数只做路径拼接)。
92
+ *
93
+ * @param {string} specRoot spec 包裹包根目录
94
+ * @returns {string} kb-state.json 绝对路径
95
+ */
96
+ function resolveKbStatePath(specRoot) {
97
+ return path.join(path.resolve(specRoot), KB_STATE_FILENAME);
98
+ }
99
+
64
100
  /**
65
101
  * 读取 kb-state.json 中指定 change 的 archive 缓存。
66
102
  * @param {string} specRoot
@@ -68,9 +104,7 @@ function assertIngestionResult(result) {
68
104
  * @returns {object|null} { archive_id, content_hash, uploaded_at }
69
105
  */
70
106
  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;
107
+ const statePath = resolveKbStatePath(specRoot);
74
108
  const state = readJsonSafe(statePath);
75
109
  if (!state || !state.archiveCache) return null;
76
110
  const key = cacheKey(changeName, context);
@@ -85,9 +119,7 @@ function readArchiveCache(specRoot, changeName, context) {
85
119
  * @param {string} contentHash
86
120
  */
87
121
  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;
122
+ const statePath = resolveKbStatePath(specRoot);
91
123
  const state = readJsonSafe(statePath) || {};
92
124
  if (!state.archiveCache) state.archiveCache = {};
93
125
  const key = cacheKey(changeName, context);
@@ -116,8 +148,18 @@ const DEFAULT_LEVEL = 'propose-spec';
116
148
  // ── 工具函数 ──────────────────────────────────────────
117
149
 
118
150
  function resolveSpecRoot(explicit) {
119
- if (explicit) return path.resolve(explicit);
120
- return resolveSpecRootFast(__dirname);
151
+ const root = explicit ? path.resolve(explicit) : resolveSpecRootFast(__dirname);
152
+ // 位置校验前移:specRoot 必须是 spec 包裹包根,后续所有 kb-state.json /
153
+ // project-identity.json 读取都建立在它之上。在此 fail-fast,避免流程走到
154
+ // 中途才因「文件不存在」报出误导性错误(真正原因是路径指错了地方)。
155
+ if (!isKbStateHostDir(root)) {
156
+ throw new Error(
157
+ `kb-state.json 位置校验失败:${root} 不是 spec 包裹包根目录。\n` +
158
+ 'kb-state.json 只允许位于 spec 包裹包根(由 /opsx-kb-config 写入)。\n' +
159
+ '请确认 --project 指向 *-sdd-specs 包裹包,或运行 /opsx-kb-config 重新配置。'
160
+ );
161
+ }
162
+ return root;
121
163
  }
122
164
 
123
165
  /**
@@ -139,36 +181,28 @@ function resolveProjectId(specRoot) {
139
181
  }
140
182
 
141
183
  /**
142
- * 读取 KB 配置(api, apiKey, tenantKey, targets)
143
- * 使用 shared.cjs 的 loadKbState,支持 BOM 剥离和多路径搜索
184
+ * 读取 KB 配置(api, apiKey, tenantKey, targets)。
185
+ *
186
+ * 位置校验:只读 `<specRoot>/kb-state.json`(specRoot 的 spec 根身份已由
187
+ * resolveSpecRoot 在入口校验)。
144
188
  */
145
189
  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) {
190
+ const statePath = resolveKbStatePath(specRoot);
191
+ if (!fs.existsSync(statePath)) {
166
192
  throw new Error(
167
193
  'KB 配置不存在(kb-state.json 缺失)。\n' +
194
+ `期望位置: ${statePath}\n` +
168
195
  '请运行 /opsx-kb-config 配置知识库。'
169
196
  );
170
197
  }
171
- return config;
198
+ try {
199
+ return readJson(statePath);
200
+ } catch (e) {
201
+ throw new Error(
202
+ `KB 配置解析失败 (${statePath}): ${e.message}\n` +
203
+ '请运行 /opsx-kb-config 重新配置知识库。'
204
+ );
205
+ }
172
206
  }
173
207
 
174
208
  /**
@@ -204,7 +238,7 @@ function preCheckProjectId(projectId, state, selectedTarget) {
204
238
  }
205
239
  const spaceKey = (selectedTarget || resolveUploadTarget(state)).spaceKey;
206
240
  if (!spaceKey) {
207
- console.warn('⚠️ kb-state.json targets[0].spaceKey 为空,跳过本地预检');
241
+ console.warn('⚠️ kb-state.json 的上传目标缺少 spaceKey,跳过本地预检');
208
242
  return;
209
243
  }
210
244
  if (projectId !== spaceKey) {
@@ -229,9 +263,65 @@ function preCheckProjectId(projectId, state, selectedTarget) {
229
263
  * 5. materializeArchivePackage(生成 manifest + canonical-facts + conversion-report + zip)
230
264
  * 6. 返回 zip 路径
231
265
  */
232
- function buildPackageFromFolder(specRoot, changeName, options) {
266
+ /**
267
+ * 历史产物的 Spec 本体解析:
268
+ * - 已审核且与当前 Markdown 一致 → 直接复用
269
+ * - 否则重新物化。重复锚点、非法 UUID、身份冲突由物化自动改写为稳定身份并确认发布
270
+ * - 只有显式 requireReview,或正文没有结构化事实时,才保留候选
271
+ * 全程不改写 Markdown,只读写 <change>/ontology/spec-ontology.json 与 legacy-mapping.json。
272
+ */
273
+ function resolveLegacySpecOntology(specRoot, changeName, changeDir, facts, options = {}) {
274
+ const {
275
+ buildSpecOntology,
276
+ materializeChangeSpecOntology,
277
+ approveSpecOntologyFile,
278
+ specOntologyContentSignature,
279
+ } = require('./ontology/spec-ontology.cjs');
280
+ const { ensureProjectIdentity } = require('./ontology/archive-package.cjs');
281
+ const identity = ensureProjectIdentity(specRoot);
282
+ const ontologyPath = path.join(changeDir, 'ontology', 'spec-ontology.json');
283
+
284
+ const fresh = buildSpecOntology(facts, {
285
+ projectId: identity.project_id,
286
+ compatibilityMode: 'LEGACY_MATERIALIZED',
287
+ reviewStatus: 'pending',
288
+ });
289
+ const freshSignature = specOntologyContentSignature(fresh);
290
+ const existing = readJsonSafe(ontologyPath);
291
+ const existingApproved = existing
292
+ && existing.compatibility_mode === 'LEGACY_MATERIALIZED'
293
+ && existing.review_status === 'confirmed'
294
+ && existing.publication_eligible === true
295
+ && existing.project_id === identity.project_id;
296
+ if (existingApproved && specOntologyContentSignature(existing) === freshSignature) {
297
+ return { approved: existing, status: 'reused', path: ontologyPath, collisions: [] };
298
+ }
299
+
300
+ const materialized = materializeChangeSpecOntology(specRoot, changeName, {
301
+ changeDir,
302
+ projectId: identity.project_id,
303
+ compatibilityMode: 'LEGACY_MATERIALIZED',
304
+ reviewStatus: 'pending',
305
+ });
306
+ if (!materialized.validation.valid) {
307
+ throw new Error(`历史 Spec 本体物化失败: ${materialized.validation.errors.join('; ')}`);
308
+ }
309
+ const repairs = (materialized.payload.warnings || [])
310
+ .filter((warning) => warning.startsWith('LEGACY_DUPLICATE_ANCHOR:') || warning.startsWith('LEGACY_IDENTITY_REPAIRED:'));
311
+ if (options.requireReview) {
312
+ return { approved: null, status: 'pending_review', path: ontologyPath, collisions: repairs };
313
+ }
314
+ if (materialized.payload.entities.length === 0) {
315
+ return { approved: null, status: 'needs_review', path: ontologyPath, collisions: repairs };
316
+ }
317
+ const approved = approveSpecOntologyFile(materialized.output_path, { allowCollisions: true });
318
+ return { approved: approved.payload, status: 'auto_approved', path: ontologyPath, collisions: repairs };
319
+ }
320
+
321
+ function buildPackageFromFolder(specRoot, changeName, options = {}) {
233
322
  const runtime = require('./ontology/runtime.cjs');
234
323
  const archivePackage = require('./ontology/archive-package.cjs');
324
+ const { classifyLegacyDiagnostics } = require('./ontology/spec-ontology.cjs');
235
325
 
236
326
  // 1. 语义管线
237
327
  console.log('→ 运行语义管线 (semantic-reconcile)...');
@@ -239,13 +329,20 @@ function buildPackageFromFolder(specRoot, changeName, options) {
239
329
  profile: 'auto',
240
330
  markPending: true,
241
331
  });
332
+ const legacy = classifyLegacyDiagnostics(semanticResult.diagnostics);
333
+ const legacyIdentityOnly = legacy.legacyCompatible;
242
334
  if (!semanticResult.valid) {
243
- const errors = semanticResult.diagnostics
244
- .filter(d => d.severity === 'error')
245
- .map(d => `${d.code}: ${d.message}`);
246
- throw new Error(`语义校验失败:\n${errors.join('\n')}`);
335
+ if (!legacyIdentityOnly) {
336
+ const errors = legacy.blocking.map(d => `${d.code}: ${d.message}`);
337
+ throw new Error(`语义校验失败:\n${errors.join('\n')}`);
338
+ }
339
+ console.warn('⚠️ 检测到历史产物缺少身份标识,将生成旁路映射与本体包,不改写 Markdown。');
340
+ for (const item of legacy.tolerated) {
341
+ console.warn(` ↳ 兼容放行 ${item.code}: ${item.message}`);
342
+ }
343
+ } else {
344
+ console.log(`✓ 语义校验通过 (profile: ${semanticResult.profile}, revision: ${semanticResult.revision})`);
247
345
  }
248
- console.log(`✓ 语义校验通过 (profile: ${semanticResult.profile}, revision: ${semanticResult.revision})`);
249
346
 
250
347
  // 2. 创建临时 staging 目录
251
348
  const stagingDir = path.join(
@@ -258,28 +355,63 @@ function buildPackageFromFolder(specRoot, changeName, options) {
258
355
  const changeDir = path.join(specRoot, 'openspec', 'changes', changeName);
259
356
  copyDirRecursive(changeDir, stagingDir);
260
357
 
261
- // 4. 创建 archive snapshot(生成 archive-ontology.json)
262
- runtime.createArchiveSnapshot(specRoot, changeName, stagingDir, semanticResult.state);
263
- console.log('✓ 已生成 archive-ontology.json');
264
-
265
- // 5. 生成 manifest + canonical-facts + conversion-report + zip
266
- const projectIdentity = archivePackage.ensureProjectIdentity(specRoot);
267
- const result = archivePackage.materializeArchivePackage({
268
- projectRoot: specRoot,
269
- archiveDir: stagingDir,
270
- archivePath: stagingDir,
271
- changeName: changeName,
272
- packagePath: zipPath,
273
- reason: options.reason || '随时入库(文件夹上传)',
274
- method: 'kb-upload-folder',
275
- });
358
+ let result;
359
+ let legacyStatus = null;
360
+ if (legacyIdentityOnly) {
361
+ const resolved = resolveLegacySpecOntology(specRoot, changeName, changeDir, semanticResult.state, {
362
+ requireReview: Boolean(options.requireReview),
363
+ });
364
+ legacyStatus = resolved.status;
365
+ const messages = {
366
+ reused: '✓ 已发现审核通过且与当前 Markdown 一致的历史 Spec 本体,直接发布',
367
+ auto_approved: '✓ 历史 Spec 本体已自动物化并确认,身份和重复锚点已在旁路改写,将发布为 current 事实',
368
+ needs_review: '⚠️ 历史正文没有可发布的结构化事实,仅保留为证据候选',
369
+ pending_review: 'ℹ️ 按 --require-review 要求,历史 Spec 本体保留为待审核候选',
370
+ };
371
+ console.log(messages[resolved.status]);
372
+ result = archivePackage.materializeCompatibilityPackage({
373
+ projectRoot: specRoot,
374
+ archiveDir: stagingDir,
375
+ changeName,
376
+ facts: semanticResult.state,
377
+ specOntology: resolved.approved,
378
+ toleratedDiagnostics: legacy.tolerated,
379
+ packagePath: zipPath,
380
+ reason: options.reason || '历史 Spec 兼容入库',
381
+ method: 'kb-upload-legacy-folder',
382
+ });
383
+ console.log(resolved.approved
384
+ ? '✓ 已生成审核通过的 LEGACY_MATERIALIZED 发布包'
385
+ : '✓ 已生成 LEGACY_MATERIALIZED 本体候选包');
386
+ } else {
387
+ // 严格产物继续生成 confirmed snapshot 和双协议事实包。
388
+ runtime.createArchiveSnapshot(specRoot, changeName, stagingDir, semanticResult.state);
389
+ console.log('✓ 已生成 archive-ontology.json');
390
+ archivePackage.ensureProjectIdentity(specRoot);
391
+ result = archivePackage.materializeArchivePackage({
392
+ projectRoot: specRoot,
393
+ archiveDir: stagingDir,
394
+ archivePath: stagingDir,
395
+ changeName: changeName,
396
+ packagePath: zipPath,
397
+ reason: options.reason || '随时入库(文件夹上传)',
398
+ method: 'kb-upload-folder',
399
+ });
400
+ }
276
401
 
277
402
  console.log(`✓ Archive Package 已生成`);
278
403
  console.log(` archive_id: ${result.manifest.archive_id}`);
279
404
  console.log(` project_id: ${result.manifest.project_id}`);
280
405
  console.log(` files: ${result.manifest.files.length}`);
281
406
 
282
- return { zipPath, manifest: result.manifest, stagingDir };
407
+ return {
408
+ zipPath,
409
+ manifest: result.manifest,
410
+ stagingDir,
411
+ requiresReview: result.conversion_report?.status === 'materialized_pending_review',
412
+ legacyStatus,
413
+ toleratedDiagnostics: legacyIdentityOnly ? legacy.tolerated : [],
414
+ };
283
415
  } catch (err) {
284
416
  // 出错时清理 staging
285
417
  fs.rmSync(stagingDir, { recursive: true, force: true });
@@ -302,7 +434,7 @@ async function uploadZip(zipPath, state, options) {
302
434
 
303
435
  try {
304
436
  const result = await uploadFile(uploadUrl, zipPath, state.apiKey);
305
- assertIngestionResult(result);
437
+ assertIngestionResult(result, options);
306
438
  return result;
307
439
  } catch (err) {
308
440
  throw new Error(`上传失败: ${err.message}`);
@@ -319,6 +451,7 @@ async function main() {
319
451
  console.error(' node skywalk-sdd/kb-upload.cjs --folder=<path> [--minimum-level=propose-spec] [--validate-only]');
320
452
  console.error(' node skywalk-sdd/kb-upload.cjs --package=<zip-path>');
321
453
  console.error(' node skywalk-sdd/kb-upload.cjs --folder=<path> --project=<spec-root> --change=<name>');
454
+ console.error(' node skywalk-sdd/kb-upload.cjs --folder=<path> --require-review # 历史产物只上传候选,不自动确认本体');
322
455
  process.exit(1);
323
456
  }
324
457
 
@@ -332,6 +465,7 @@ async function main() {
332
465
  try {
333
466
  // ── Zip 模式 ──
334
467
  if (args.package) {
468
+ if (args['validate-only']) throw new Error('--validate-only 仅支持 --folder/--change 本地语义校验;未执行归档上传');
335
469
  const zipPath = path.resolve(args.package);
336
470
  if (!fs.existsSync(zipPath)) {
337
471
  throw new Error(`zip 文件不存在: ${zipPath}`);
@@ -370,9 +504,12 @@ async function main() {
370
504
  changeName = path.basename(folderPath);
371
505
  }
372
506
 
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 指定归档包');
507
+ const changesRoot = fs.realpathSync(path.resolve(specRoot, 'openspec', 'changes'));
508
+ const expectedPath = path.resolve(changesRoot, changeName);
509
+ const expectedFolder = fs.existsSync(expectedPath) ? fs.realpathSync(expectedPath) : expectedPath;
510
+ const suppliedFolder = fs.existsSync(folderPath) ? fs.realpathSync(folderPath) : path.resolve(folderPath);
511
+ if (suppliedFolder !== expectedFolder || !expectedFolder.startsWith(changesRoot + path.sep)) {
512
+ throw new Error('文件夹上传仅接受当前 Spec 仓 openspec/changes/<change>;已归档内容请用 --package 指定归档包');
376
513
  }
377
514
 
378
515
  if (!fs.existsSync(folderPath)) {
@@ -389,19 +526,6 @@ async function main() {
389
526
  // 1. 校验必需文件
390
527
  validateRequiredFiles(folderPath, minimumLevel);
391
528
 
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
529
  // 4. 仅校验模式(含语义校验)
406
530
  if (args['validate-only']) {
407
531
  const runtime = require('./ontology/runtime.cjs');
@@ -411,20 +535,39 @@ async function main() {
411
535
  markPending: true,
412
536
  });
413
537
  if (!semanticResult.valid) {
414
- const errors = semanticResult.diagnostics
415
- .filter(d => d.severity === 'error')
416
- .map(d => `${d.code}: ${d.message}`);
417
- throw new Error(`语义校验失败:\n${errors.join('\n')}`);
538
+ const { classifyLegacyDiagnostics } = require('./ontology/spec-ontology.cjs');
539
+ const legacy = classifyLegacyDiagnostics(semanticResult.diagnostics);
540
+ if (!legacy.legacyCompatible) {
541
+ const errors = legacy.blocking.map(d => `${d.code}: ${d.message}`);
542
+ throw new Error(`语义校验失败:\n${errors.join('\n')}`);
543
+ }
544
+ console.log('⚠️ 历史产物缺少身份标识,正式上传时将走 LEGACY_MATERIALIZED 旁路物化,不改写 Markdown');
545
+ for (const item of legacy.tolerated) console.log(` ↳ 兼容放行 ${item.code}: ${item.message}`);
546
+ } else {
547
+ console.log(`✓ 语义校验通过 (profile: ${semanticResult.profile}, revision: ${semanticResult.revision})`);
418
548
  }
419
- console.log(`✓ 语义校验通过 (profile: ${semanticResult.profile}, revision: ${semanticResult.revision})`);
420
- console.log('\n✓ 校验完成(--validate-only,未上传)');
549
+ console.log('\n✓ 本地语义校验完成(--validate-only,未联网;不代表权限、发布基线或入库已验证)');
421
550
  return;
422
551
  }
423
552
 
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
+
424
566
  // 5. 构建 Archive Package
425
567
  console.log('');
426
- const { zipPath, manifest, stagingDir } = buildPackageFromFolder(specRoot, changeName, {
568
+ const { zipPath, manifest, stagingDir, requiresReview, legacyStatus } = buildPackageFromFolder(specRoot, changeName, {
427
569
  reason: args.reason,
570
+ requireReview: Boolean(args['require-review']),
428
571
  });
429
572
 
430
573
  try {
@@ -477,6 +620,7 @@ async function main() {
477
620
  const result = await uploadZip(zipPath, state, {
478
621
  spaceId: target.spaceId,
479
622
  kbId: target.kbId,
623
+ allowNeedsReview: requiresReview,
480
624
  });
481
625
 
482
626
  console.log('\n── 上传结果 ──');
@@ -489,8 +633,21 @@ async function main() {
489
633
  console.log(` change_id: ${manifest.change_id}`);
490
634
  console.log(` files: ${manifest.files.length}`);
491
635
 
492
- // Canonical commit has been verified; a receipt does not imply projection completion.
493
- updateKbStateCache(specRoot, changeName, archiveId, contentHash, cacheContext);
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
+ }
494
651
 
495
652
  // === 投影状态提示 ===
496
653
  // 上传成功后,KB 后端异步执行 vector embedding + AGE graph 投影。
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ const CATEGORIES = ['completeness', 'consistency', 'executability', 'tdd_compliance'];
4
+
5
+ function assembleReview(review, taskCompletion, options = {}) {
6
+ if (!review || !Array.isArray(review.items) || !review.items.length) throw new Error('CHECK_ITEMS_REQUIRED: 提供实际评审项,不能提交空模板');
7
+ const categories = Object.fromEntries(CATEGORIES.map(key => [key, { passed: 0, total: 0 }]));
8
+ const warnings = [], suggestions = [], ids = new Set();
9
+ for (const item of review.items) {
10
+ if (!item.id || ids.has(item.id)) throw new Error('CHECK_ITEM_ID_INVALID: 检查项必须有唯一 ID');
11
+ ids.add(item.id);
12
+ if (!CATEGORIES.includes(item.category) || !['passed', 'error', 'warning', 'suggestion', 'na'].includes(item.status)) throw new Error('CHECK_ITEM_STATUS_INVALID');
13
+ if (typeof item.evidence !== 'string' || !item.evidence.trim()) throw new Error(`CHECK_ITEM_EVIDENCE_REQUIRED: ${item.id} 缺少来源证据或不适用原因`);
14
+ if (item.status === 'na') continue;
15
+ categories[item.category].total++;
16
+ if (item.status !== 'error') categories[item.category].passed++;
17
+ if (item.status === 'warning') warnings.push({ category: item.category, warning: item.id, description: item.description || item.evidence, target: item.evidence });
18
+ if (item.status === 'suggestion') suggestions.push(item);
19
+ }
20
+ const total = Object.values(categories).reduce((n, c) => n + c.total, 0);
21
+ const passed = Object.values(categories).reduce((n, c) => n + c.passed, 0);
22
+ if (!total) throw new Error('CHECK_ITEMS_REQUIRED: 至少有一个实际适用的评审项');
23
+ if (taskCompletion.has_incomplete) warnings.push({ category: 'task_completion', warning: 'TASKS_INCOMPLETE', description: `${taskCompletion.incomplete} 个任务尚未完成`, target: 'tasks.md' });
24
+ const result = total !== passed ? 'failure' : (warnings.length ? 'partial' : 'success');
25
+ return {
26
+ result,
27
+ details: { check_results: {
28
+ ...review.reviewer,
29
+ total, errors: total - passed, warnings: warnings.length, suggestions: suggestions.length,
30
+ categories, warning_items: warnings, warning_dispositions: review.warning_dispositions || [],
31
+ // This field means items satisfied before implementation, not defect fixes.
32
+ fixed_before_apply: options.beforeApply ? passed : Math.min(total, options.previousBeforeApply || 0),
33
+ consistency_score: null,
34
+ task_completion: { ...taskCompletion, checked_for_archive_readiness: !options.beforeApply },
35
+ review_items: review.items,
36
+ review_revision: options.revision,
37
+ } },
38
+ };
39
+ }
40
+
41
+ module.exports = { assembleReview };