kld-sdd 2.7.3 → 2.7.8

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 (65) hide show
  1. package/README.md +12 -5
  2. package/USABILITY.md +84 -0
  3. package/kld-sdd-guide.html +16 -4
  4. package/lib/hook-gate-core.js +327 -0
  5. package/lib/init.js +221 -37
  6. package/lib/scale-thresholds.json +19 -0
  7. package/lib/skills-bundle.js +2 -1
  8. package/package.json +5 -3
  9. package/skywalk-sdd/context-client.cjs +50 -30
  10. package/skywalk-sdd/index.cjs +36 -16
  11. package/skywalk-sdd/kb-sync-identity.cjs +99 -451
  12. package/skywalk-sdd/kb-upload.cjs +67 -44
  13. package/skywalk-sdd/lib/usage-contract.cjs +3 -2
  14. package/skywalk-sdd/lib/usage-reporter.cjs +27 -2
  15. package/skywalk-sdd/metrics-v3.cjs +2 -2
  16. package/skywalk-sdd/ontology/active-changes.cjs +2 -1
  17. package/skywalk-sdd/ontology/archive-package.cjs +1 -1
  18. package/skywalk-sdd/ontology/artifact-parser.cjs +7 -4
  19. package/skywalk-sdd/ontology/id.cjs +5 -4
  20. package/skywalk-sdd/ontology/identity-index.cjs +4 -3
  21. package/skywalk-sdd/ontology/list-changes.cjs +1 -1
  22. package/skywalk-sdd/ontology/runtime.cjs +7 -3
  23. package/skywalk-sdd/ontology/schema.cjs +2 -0
  24. package/skywalk-sdd/ontology/traceability-validator.cjs +74 -4
  25. package/skywalk-sdd/ontology/workspace-layout.cjs +25 -5
  26. package/skywalk-sdd/reporting/change-report-model.cjs +4 -3
  27. package/templates/git-hooks/commit-msg +22 -21
  28. package/templates/git-hooks/consistency-check-core.cjs +1087 -0
  29. package/templates/git-hooks/hooks.config +20 -1
  30. package/templates/git-hooks/pre-commit +22 -21
  31. package/templates/git-hooks/pre-commit-consistency-check.cjs +29 -332
  32. package/templates/git-hooks/pre-commit-sdd-check.cjs +98 -0
  33. package/templates/git-hooks/pre-push +22 -21
  34. package/templates/git-hooks/pre-push-consistency-check.cjs +58 -406
  35. package/templates/hooks/codebuddy/hooks/sdd-tdd-rhythm-gate.cjs +1 -1
  36. package/templates/openspec/tasks.md +3 -3
  37. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +43 -6
  38. package/templates/skills/kld-sdd/opsx-apply/checklist.md +1 -1
  39. package/templates/skills/kld-sdd/opsx-apply/reference.md +1 -1
  40. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +9 -13
  41. package/templates/skills/kld-sdd/opsx-check/SKILL.md +54 -328
  42. package/templates/skills/kld-sdd/opsx-check/checklist.md +7 -4
  43. package/templates/skills/kld-sdd/opsx-check/reference.md +58 -0
  44. package/templates/skills/kld-sdd/opsx-check/result-template.json +52 -0
  45. package/templates/skills/kld-sdd/opsx-check/reviewer.md +100 -0
  46. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +82 -451
  47. package/templates/skills/kld-sdd/opsx-consistency-check/{reference.md → references/reference.md} +0 -1
  48. package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +517 -0
  49. package/templates/skills/kld-sdd/opsx-design/SKILL.md +10 -30
  50. package/templates/skills/kld-sdd/opsx-design/checklist.md +3 -4
  51. package/templates/skills/kld-sdd/opsx-design/reference.md +1 -1
  52. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +12 -74
  53. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +7 -5
  54. package/templates/skills/kld-sdd/opsx-ontology-query/phase-1-prechange.md +2 -2
  55. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +1 -1
  56. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +28 -73
  57. package/templates/skills/kld-sdd/opsx-propose/checklist.md +8 -8
  58. package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +28 -0
  59. package/templates/skills/kld-sdd/opsx-propose/reference.md +12 -46
  60. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +14 -61
  61. package/templates/skills/kld-sdd/opsx-spec/checklist.md +3 -3
  62. package/templates/skills/kld-sdd/opsx-task/SKILL.md +38 -32
  63. package/templates/skills/kld-sdd/opsx-task/checklist.md +5 -6
  64. package/templates/skills/kld-sdd/opsx-test/SKILL.md +2 -0
  65. package/templates/skills/kld-sdd/tdd-rules/rules/tdd-strategy-selection.md +1 -1
@@ -33,19 +33,48 @@ const {
33
33
  httpGetJson,
34
34
  } = require('./lib/shared.cjs');
35
35
 
36
+ function resolveUploadTarget(state, options = {}) {
37
+ if (Boolean(options.spaceId) !== Boolean(options.kbId)) throw new Error('必须同时指定 --space-id 和 --kb-id');
38
+ const targets = state.targets || [];
39
+ if (options.spaceId && options.kbId) {
40
+ return targets.find(t => t.spaceId === options.spaceId && t.kbId === options.kbId) || options;
41
+ }
42
+ if (targets.length !== 1) throw new Error('请明确上传目标:配置一个知识库,或同时指定 --space-id 和 --kb-id;不会自动分发到多个 KB');
43
+ if (!targets[0].spaceId || !targets[0].kbId) throw new Error('知识库目标缺少 spaceId/kbId');
44
+ return targets[0];
45
+ }
46
+
47
+ function cacheKey(changeName, context) {
48
+ if (!context?.api || !context?.spaceId || !context?.kbId) return null;
49
+ return JSON.stringify([context.api.replace(/\/+$/, ''), context.spaceId, context.kbId, changeName]);
50
+ }
51
+
52
+ function assertIngestionResult(result) {
53
+ if (result?.code != null && result.code !== 0) throw new Error(result.message || 'KB API 拒绝入库');
54
+ const job = result?.data || result;
55
+ if (!job || !['projecting', 'succeeded'].includes(job.status) || job.relationStatus !== 'committed') {
56
+ throw new Error(`INGEST_NOT_COMMITTED: ${job?.errorCode || job?.status || 'unknown'} ${job?.errorMessage || ''} job=${job?.jobId || 'unknown'}`);
57
+ }
58
+ if (Number(job.report?.version_conflicts || 0) > 0 || job.report?.publication_status === 'needs_review') {
59
+ throw new Error(`VERSION_CONFLICT: 事实包已保存为候选,当前事实未更新;请合并基线后重新发布。job=${job.jobId}`);
60
+ }
61
+ return job;
62
+ }
63
+
36
64
  /**
37
65
  * 读取 kb-state.json 中指定 change 的 archive 缓存。
38
66
  * @param {string} specRoot
39
67
  * @param {string} changeName
40
68
  * @returns {object|null} { archive_id, content_hash, uploaded_at }
41
69
  */
42
- function readArchiveCache(specRoot, changeName) {
70
+ function readArchiveCache(specRoot, changeName, context) {
43
71
  const directPath = path.join(specRoot, 'kb-state.json');
44
72
  const legacyPath = path.join(specRoot, '..', '.shared', 'kb-state.json');
45
73
  const statePath = fs.existsSync(directPath) ? directPath : legacyPath;
46
74
  const state = readJsonSafe(statePath);
47
75
  if (!state || !state.archiveCache) return null;
48
- return state.archiveCache[changeName] || null;
76
+ const key = cacheKey(changeName, context);
77
+ return key ? state.archiveCache[key] || null : null;
49
78
  }
50
79
 
51
80
  /**
@@ -55,16 +84,19 @@ function readArchiveCache(specRoot, changeName) {
55
84
  * @param {string} archiveId
56
85
  * @param {string} contentHash
57
86
  */
58
- function updateKbStateCache(specRoot, changeName, archiveId, contentHash) {
87
+ function updateKbStateCache(specRoot, changeName, archiveId, contentHash, context) {
59
88
  const directPath = path.join(specRoot, 'kb-state.json');
60
89
  const legacyPath = path.join(specRoot, '..', '.shared', 'kb-state.json');
61
90
  const statePath = fs.existsSync(directPath) ? directPath : legacyPath;
62
91
  const state = readJsonSafe(statePath) || {};
63
92
  if (!state.archiveCache) state.archiveCache = {};
64
- state.archiveCache[changeName] = {
93
+ const key = cacheKey(changeName, context);
94
+ if (!key) throw new Error('缓存必须包含 API、Space 和 KB');
95
+ state.archiveCache[key] = {
65
96
  archive_id: archiveId,
66
97
  content_hash: contentHash,
67
98
  uploaded_at: new Date().toISOString(),
99
+ target: context,
68
100
  };
69
101
  writeJsonAtomic(statePath, state);
70
102
  console.log(`✓ 已更新本地缓存: ${changeName} → ${archiveId.slice(0, 24)}...`);
@@ -144,7 +176,11 @@ function readKbState(specRoot) {
144
176
  */
145
177
  function validateRequiredFiles(folderPath, minimumLevel) {
146
178
  const required = MINIMUM_LEVELS[minimumLevel] || MINIMUM_LEVELS[DEFAULT_LEVEL];
147
- const missing = required.filter(f => !fs.existsSync(path.join(folderPath, f)));
179
+ const specsDir = path.join(folderPath, 'specs');
180
+ const capabilityDirs = fs.existsSync(specsDir) ? fs.readdirSync(specsDir, { withFileTypes: true })
181
+ .filter(e => e.isDirectory()).map(e => path.join(specsDir, e.name)) : [];
182
+ const missing = required.filter(f => !fs.existsSync(path.join(folderPath, f))
183
+ && !(f !== 'proposal.md' && capabilityDirs.length > 0 && capabilityDirs.every(d => fs.existsSync(path.join(d, f)))));
148
184
  if (missing.length > 0) {
149
185
  throw new Error(
150
186
  `[${minimumLevel}] 缺少必需文件: ${missing.join(', ')}\n` +
@@ -162,11 +198,11 @@ function validateRequiredFiles(folderPath, minimumLevel) {
162
198
  /**
163
199
  * 本地预检 project_id === spaceKey
164
200
  */
165
- function preCheckProjectId(projectId, state) {
201
+ function preCheckProjectId(projectId, state, selectedTarget) {
166
202
  if (!state.targets || !state.targets[0]) {
167
203
  throw new Error('KB 配置中无 targets,请运行 /opsx-kb-config 配置知识库。');
168
204
  }
169
- const spaceKey = state.targets[0].spaceKey;
205
+ const spaceKey = (selectedTarget || resolveUploadTarget(state)).spaceKey;
170
206
  if (!spaceKey) {
171
207
  console.warn('⚠️ kb-state.json targets[0].spaceKey 为空,跳过本地预检');
172
208
  return;
@@ -255,15 +291,9 @@ function buildPackageFromFolder(specRoot, changeName, options) {
255
291
  * 上传 zip 到 KB(使用 Node.js HTTP,替代 curl)
256
292
  */
257
293
  async function uploadZip(zipPath, state, options) {
258
- const target = options.spaceId && options.kbId
259
- ? { spaceId: options.spaceId, kbId: options.kbId }
260
- : state.targets[0];
294
+ const target = resolveUploadTarget(state, options);
261
295
 
262
- if (!target || !target.spaceId || !target.kbId) {
263
- throw new Error('未指定目标 Space/KB,请运行 /opsx-kb-config 配置知识库。');
264
- }
265
-
266
- const apiUrl = state.api || 'http://localhost:8090/api';
296
+ const apiUrl = (state.api || 'http://localhost:8090/api').replace(/\/+$/, '');
267
297
  const uploadUrl = `${apiUrl}/v1/spaces/${target.spaceId}/knowledge-bases/${target.kbId}/ingestions`;
268
298
 
269
299
  console.log(`→ 上传到 KB: ${target.spaceName || target.spaceId}/${target.kbName || target.kbId}`);
@@ -272,6 +302,7 @@ async function uploadZip(zipPath, state, options) {
272
302
 
273
303
  try {
274
304
  const result = await uploadFile(uploadUrl, zipPath, state.apiKey);
305
+ assertIngestionResult(result);
275
306
  return result;
276
307
  } catch (err) {
277
308
  throw new Error(`上传失败: ${err.message}`);
@@ -339,6 +370,11 @@ async function main() {
339
370
  changeName = path.basename(folderPath);
340
371
  }
341
372
 
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 指定归档包');
376
+ }
377
+
342
378
  if (!fs.existsSync(folderPath)) {
343
379
  throw new Error(`变更目录不存在: ${folderPath}`);
344
380
  }
@@ -362,7 +398,9 @@ async function main() {
362
398
  if (!state.apiKey) {
363
399
  throw new Error('KB 配置中无 apiKey,请运行 /opsx-kb-config 配置知识库。');
364
400
  }
365
- preCheckProjectId(projectId, state);
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 };
366
404
 
367
405
  // 4. 仅校验模式(含语义校验)
368
406
  if (args['validate-only']) {
@@ -390,26 +428,8 @@ async function main() {
390
428
  });
391
429
 
392
430
  try {
393
- // === Step 5.1: 本地缓存快速检查 ===
394
- const cache = readArchiveCache(specRoot, changeName);
395
- if (cache && cache.archive_id === manifest.archive_id) {
396
- console.log('\n✓ 内容无变更(archive_id 与上次成功上传一致),跳过上传');
397
- console.log(` cached archive_id: ${cache.archive_id}`);
398
- console.log(` uploaded_at: ${cache.uploaded_at}`);
399
- console.log('\n✓ 完成(本地缓存命中)');
400
- return;
401
- }
402
-
403
- // 解析上传目标
404
- const target = (args['space-id'] && args['kb-id'])
405
- ? { spaceId: args['space-id'], kbId: args['kb-id'] }
406
- : state.targets[0];
407
-
408
- if (!target || !target.spaceId || !target.kbId) {
409
- throw new Error('未指定目标 Space/KB,请运行 /opsx-kb-config 配置知识库。');
410
- }
411
-
412
- const apiUrl = state.api || 'http://localhost:8090/api';
431
+ // Local receipts are informational only. Always verify the selected destination remotely.
432
+ const apiUrl = (state.api || 'http://localhost:8090/api').replace(/\/+$/, '');
413
433
 
414
434
  // === Step 5.5: KB 预检(网络级幂等确认)===
415
435
  const archiveId = manifest.archive_id;
@@ -424,11 +444,13 @@ async function main() {
424
444
  try {
425
445
  const checkResult = await httpGetJson(checkUrl, state.apiKey);
426
446
 
427
- if (checkResult && checkResult.data && checkResult.data.idempotent) {
447
+ if (checkResult?.data?.idempotent && checkResult.data.publicationStatus === 'published') {
428
448
  console.log('\n✓ KB 中已存在相同 archive(幂等命中),跳过上传');
429
449
  console.log(` snapshot_id: ${checkResult.data.snapshotId}`);
430
- updateKbStateCache(specRoot, changeName, archiveId, contentHash);
431
- console.log('\n✓ 完成(KB 预检命中)');
450
+ updateKbStateCache(specRoot, changeName, archiveId, contentHash, cacheContext);
451
+ console.log(` publication: ${checkResult.data.publicationStatus}; projection: ${checkResult.data.projectionStatus}`);
452
+ if (['failed', 'degraded'].includes(checkResult.data.projectionStatus)) console.log('⚠️ 检索准备尚未完成,请在知识库概览重试该任务的投影');
453
+ console.log('\n✓ 归档已存在(检索状态以上述回执为准)');
432
454
  return;
433
455
  }
434
456
 
@@ -467,8 +489,8 @@ async function main() {
467
489
  console.log(` change_id: ${manifest.change_id}`);
468
490
  console.log(` files: ${manifest.files.length}`);
469
491
 
470
- // === 上传成功后更新本地缓存 ===
471
- updateKbStateCache(specRoot, changeName, archiveId, contentHash);
492
+ // Canonical commit has been verified; a receipt does not imply projection completion.
493
+ updateKbStateCache(specRoot, changeName, archiveId, contentHash, cacheContext);
472
494
 
473
495
  // === 投影状态提示 ===
474
496
  // 上传成功后,KB 后端异步执行 vector embedding + AGE graph 投影。
@@ -479,9 +501,9 @@ async function main() {
479
501
  if (projStatus === 'complete') {
480
502
  console.log('\n✓ 投影已完成,向量/图检索可用');
481
503
  } else if (projStatus === 'degraded') {
482
- console.log('\n⚠️ 投影完成但有降级(部分通道不可用,FTS 仍可用)');
504
+ console.log('\n⚠️ 投影完成但有降级(部分通道不可用,召回结果会标记降级)');
483
505
  } else if (projStatus === 'disabled') {
484
- console.log('\nℹ️ 投影已禁用(FTS-only 模式)');
506
+ console.log('\nℹ️ 投影已禁用(仅可使用当前已就绪的检索通道)');
485
507
  } else if (projStatus && projStatus !== 'complete') {
486
508
  console.log(`\nℹ️ 投影进行中 (${projStatus}),向量/图检索暂不可用`);
487
509
  console.log(' → 正常 SDD 流程中阶段间间隔通常足够投影完成');
@@ -502,4 +524,5 @@ async function main() {
502
524
  }
503
525
  }
504
526
 
505
- main();
527
+ if (require.main === module) main();
528
+ module.exports = { resolveUploadTarget, readArchiveCache, updateKbStateCache, assertIngestionResult, uploadZip, buildPackageFromFolder, validateRequiredFiles };
@@ -112,7 +112,7 @@ function classifyUsageSubmitResponse(statusCode) {
112
112
  * data: 若为 ApiResponse 信封且 code=0,则为解包后的 data;否则为 undefined
113
113
  * error: 仅在 statusCode=null 时携带 Error;否则为 null
114
114
  */
115
- function postJson(url, payload, options = {}) {
115
+ function postJson(url, payload = {}, options = {}) {
116
116
  return new Promise((resolve, reject) => {
117
117
  let target;
118
118
  try {
@@ -132,7 +132,8 @@ function postJson(url, payload, options = {}) {
132
132
 
133
133
  let body;
134
134
  try {
135
- body = JSON.stringify(payload === undefined ? {} : payload);
135
+ // payload 缺省为 {}(默认参数),null 仍按 "null" 序列化,与原三元行为一致
136
+ body = JSON.stringify(payload);
136
137
  } catch (err) {
137
138
  reject(new Error(`payload serialization error: ${err.message}`));
138
139
  return;
@@ -31,6 +31,27 @@ const SHARED_DEADLINE_MS = 3000;
31
31
  // 单行事件 JSON 的安全上限(小于 4KB 以保证 appendFileSync 在 POSIX/Windows 都原子)
32
32
  const SINGLE_EVENT_MAX_BYTES = 4096;
33
33
 
34
+ // drop 警告每进程最多一次的模块级标记
35
+ let dropWarned = false;
36
+
37
+ /**
38
+ * drop(含 401)时输出一次 stderr 警告;每进程最多一次,简短带 [kld-sdd] 前缀。
39
+ */
40
+ function warnUsageDropOnce() {
41
+ if (dropWarned) return;
42
+ dropWarned = true;
43
+ try {
44
+ process.stderr.write('[kld-sdd] usage 事件上报被拒绝,本次事件已丢弃\n');
45
+ } catch { /* stderr 不可用时静默 */ }
46
+ }
47
+
48
+ /**
49
+ * 测试专用:重置 drop 警告的每进程一次标记。
50
+ */
51
+ function _resetDropWarned() {
52
+ dropWarned = false;
53
+ }
54
+
34
55
  function pendingPathFor(homeDir) {
35
56
  const envHome = typeof process !== 'undefined' && process && process.env
36
57
  ? process.env.KLD_SDD_HOME
@@ -84,13 +105,14 @@ function resolveReportServer(options = {}) {
84
105
  const normalized = normalizeServerUrl(config.server);
85
106
  if (normalized) return normalized;
86
107
  }
87
- // 2. 项目级 kb-state.json 的 api
108
+ // 2. 项目级 kb-state.json 的 api(kb-state.api 是唯一带 /api 契约的来源,先剥离尾部 /api 再标准化)
88
109
  try {
89
110
  const kbStatePath = path.join(projectRoot, 'kb-state.json');
90
111
  if (fs.existsSync(kbStatePath)) {
91
112
  const kbState = JSON.parse(fs.readFileSync(kbStatePath, 'utf8'));
92
113
  if (kbState && typeof kbState.api === 'string') {
93
- const normalized = normalizeServerUrl(kbState.api);
114
+ const stripped = kbState.api.trim().replace(/\/api(\/v\d+)?\/?$/i, '');
115
+ const normalized = normalizeServerUrl(stripped);
94
116
  if (normalized) return normalized;
95
117
  }
96
118
  }
@@ -300,6 +322,7 @@ async function flushPendingQueue(server, identity, pendingFile, deadlineStart) {
300
322
  }
301
323
  if (classification === 'drop') {
302
324
  // drop 丢弃该条(含 401),不 retained、不 break,继续下一条
325
+ warnUsageDropOnce();
303
326
  if (ev.id) droppedIds.add(ev.id);
304
327
  continue;
305
328
  }
@@ -421,6 +444,7 @@ async function reportUsageOnce(options = {}) {
421
444
  currentOutcome = 'confirmed';
422
445
  } else if (classification === 'drop') {
423
446
  // 当前事件 drop(含 401)直接丢弃不入队,不清任何本地状态
447
+ warnUsageDropOnce();
424
448
  currentOutcome = 'dropped';
425
449
  } else {
426
450
  currentOutcome = 'enqueued';
@@ -453,6 +477,7 @@ module.exports = {
453
477
  readGitIdentity,
454
478
  resolveReportServer,
455
479
  _resetGitIdentityCache,
480
+ _resetDropWarned,
456
481
  PENDING_FILE_NAME,
457
482
  PENDING_MAX_BYTES,
458
483
  FLUSH_MAX_COUNT,
@@ -1036,7 +1036,7 @@ function buildSchemeCompliance(input = {}) {
1036
1036
  const semanticParity = input.semantic_parity_status || input.semantic_parity || null;
1037
1037
  const multiFormatStatus = semanticParity === 'pass'
1038
1038
  ? 'implemented'
1039
- : (['md', 'html', 'json'].every(format => formats.includes(format)) ? 'provisional' : 'provisional');
1039
+ : 'provisional';
1040
1040
  const alertLoopReady = Boolean(
1041
1041
  input.alert_loop
1042
1042
  && input.alert_loop.root_cause
@@ -1050,7 +1050,7 @@ function buildSchemeCompliance(input = {}) {
1050
1050
  || null;
1051
1051
  const reproducibleStatus = eventContractHealth === 'healthy' && Number(input.event_count) > 0
1052
1052
  ? 'implemented'
1053
- : (Number(input.event_count) > 0 ? 'provisional' : 'provisional');
1053
+ : 'provisional';
1054
1054
  const statusLabel = status => ({
1055
1055
  implemented: '已实现',
1056
1056
  provisional: '部分可用',
@@ -42,7 +42,8 @@ function stripQuotes(value) {
42
42
  }
43
43
 
44
44
  function needsQuotes(value) {
45
- return /^[\s]|[:#]|[\s]$/.test(value);
45
+ // 非捕获分组显式锚定各分支优先级:以空白开头、含 : 或 #、以空白结尾
46
+ return /(?:^[\s])|(?:[:#])|(?:[\s]$)/.test(value);
46
47
  }
47
48
 
48
49
  function quoteIfNeeded(value) {
@@ -571,7 +571,7 @@ function materializeArchivePackage(options = {}) {
571
571
  const projectIdentity = ensureProjectIdentity(projectRoot);
572
572
  const changeId = requiredText(snapshot.change_id, 'change_id', ONTOLOGY_PATH);
573
573
  const archiveId = `urn:kld:sdd:archive:${sha256(
574
- `${projectIdentity.project_id}\n${changeId}\n${snapshot.facts_hash}`,
574
+ `${projectIdentity.project_id}\n${changeId}\n${snapshot.facts_hash}\n${snapshot.source_revision || snapshot.facts_hash}`,
575
575
  )}`;
576
576
  const { canonicalFacts, warnings } = buildCanonicalFacts(
577
577
  archiveDir,
@@ -246,7 +246,8 @@ function parseStructuredFrontmatter(lines) {
246
246
  const nested = line.match(/^\s{2,}([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
247
247
 
248
248
  if (topKey && !/^\s/.test(line)) {
249
- currentRef = flushRequirementRef(requirementRefs, currentRef);
249
+ flushRequirementRef(requirementRefs, currentRef);
250
+ currentRef = null;
250
251
  section = topKey[1].toLowerCase();
251
252
  const value = unwrapScalar(stripInlineYamlComment(topKey[2]));
252
253
  flat[section] = value;
@@ -263,7 +264,7 @@ function parseStructuredFrontmatter(lines) {
263
264
  }
264
265
 
265
266
  if (section === 'requirement-refs' && listItem) {
266
- currentRef = flushRequirementRef(requirementRefs, currentRef) || {};
267
+ flushRequirementRef(requirementRefs, currentRef);
267
268
  currentRef = { [listItem[1].toLowerCase()]: unwrapScalar(stripInlineYamlComment(listItem[2])) };
268
269
  continue;
269
270
  }
@@ -668,7 +669,8 @@ function addStructuralEntities(target, files, options = {}) {
668
669
  name: descriptor.file.relativePath,
669
670
  entity_id: identity.entity_id,
670
671
  version_id: identity.version_id,
671
- delta_state: 'added',
672
+ delta_state: identity.delta_state || 'added',
673
+ predecessor_version_id: identity.predecessor_version_id || undefined,
672
674
  content_hash: descriptor.content_hash,
673
675
  attributes: { artifact_type: artifactType(descriptor.file.relativePath), path: descriptor.file.relativePath },
674
676
  source: sourceFor(descriptor.file, 1, identity.anchor_id),
@@ -684,7 +686,8 @@ function addStructuralEntities(target, files, options = {}) {
684
686
  name: `${owner.source.file}#${owner.id}`,
685
687
  entity_id: identity.entity_id,
686
688
  version_id: identity.version_id,
687
- delta_state: 'added',
689
+ delta_state: identity.delta_state || 'added',
690
+ predecessor_version_id: identity.predecessor_version_id || undefined,
688
691
  content_hash: descriptor.content_hash,
689
692
  attributes: { artifact_path: owner.source.file, entity_anchor: owner.id },
690
693
  source: { ...owner.source, anchor_id: identity.anchor_id },
@@ -3,7 +3,7 @@
3
3
  const crypto = require('crypto');
4
4
  const { ID_PREFIX_TYPES, entityTypeForId } = require('./schema.cjs');
5
5
 
6
- const ID_BYTE_LENGTH = 4;
6
+ const ID_BYTE_LENGTH = 16;
7
7
  const ID_HEX_LENGTH = ID_BYTE_LENGTH * 2;
8
8
 
9
9
  function normalizeSlug(value) {
@@ -52,15 +52,16 @@ function isValidEntityId(entityId, expectedType = '') {
52
52
  }
53
53
 
54
54
  function generateIdentityId() {
55
- return crypto.randomBytes(ID_BYTE_LENGTH).toString('hex');
55
+ return crypto.randomUUID();
56
56
  }
57
57
 
58
58
  function generateUuidV7() {
59
+ // Compatibility name retained for existing callers; new identities are standard UUIDs.
59
60
  return generateIdentityId();
60
61
  }
61
62
 
62
63
  /**
63
- * Normalize a UUID value to the internal 8-hex short format.
64
+ * Preserve full UUIDs; collapse only the legacy zero-padded 8-hex representation.
64
65
  *
65
66
  * KB backend (PostgreSQL) serializes UUID columns as full 36-char format
66
67
  * (e.g. "3518f551-0000-0000-0000-000000000000"). The SDD toolchain uses
@@ -69,7 +70,7 @@ function generateUuidV7() {
69
70
  */
70
71
  function normalizeUuid(value) {
71
72
  const v = String(value || '').trim().toLowerCase();
72
- const fullUuidMatch = v.match(/^([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
73
+ const fullUuidMatch = v.match(/^([0-9a-f]{8})-0000-0000-0000-000000000000$/);
73
74
  if (fullUuidMatch) {
74
75
  return fullUuidMatch[1];
75
76
  }
@@ -168,11 +168,11 @@ function buildCatalogDiagnostics(records) {
168
168
  const byPredecessor = groupBy(records, 'predecessor_version_id');
169
169
 
170
170
  for (const [entityId, items] of byEntityId.entries()) {
171
- const signatures = new Set(items.map((item) => `${item.anchor_id}|${item.type}`));
171
+ const signatures = new Set(items.map((item) => item.type));
172
172
  if (signatures.size > 1) {
173
173
  diagnostics.push(catalogDiagnostic(
174
174
  DIAGNOSTIC_CODES.ENTITY_IDENTITY_CONFLICT,
175
- `历史集合中同一 entity-id 对应多个锚点或类型: ${entityId}`,
175
+ `历史集合中同一 entity-id 对应多个类型: ${entityId}`,
176
176
  { entity_id: items[0].anchor_id, file: items[0].source && items[0].source.file },
177
177
  ));
178
178
  }
@@ -206,7 +206,8 @@ function buildCatalogDiagnostics(records) {
206
206
  diagnostics.push(catalogDiagnostic(
207
207
  DIAGNOSTIC_CODES.VERSION_LINEAGE_CONFLICT,
208
208
  `历史集合中同一 predecessor 存在多个后继: ${predecessorId}`,
209
- { entity_id: items[0].anchor_id, file: items[0].source && items[0].source.file },
209
+ { severity: items.every(item => item.scope === "active") ? "warning" : "error",
210
+ entity_id: items[0].anchor_id, file: items[0].source && items[0].source.file },
210
211
  ));
211
212
  }
212
213
  }
@@ -15,7 +15,7 @@ function listActiveChanges(projectRoot) {
15
15
  const full = path.join(changesDir, name);
16
16
  return fs.statSync(full).isDirectory();
17
17
  })
18
- .sort();
18
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
19
19
  }
20
20
 
21
21
  function describeChange(projectRoot, changeName, registry) {
@@ -417,8 +417,10 @@ function createArchiveSnapshot(projectRoot, changeName, archiveDir, inputState)
417
417
  }
418
418
  const snapshotId = `SNAP-${sha256(`${changeName}:${state.revision}`).slice(0, 16).toUpperCase()}`;
419
419
  const targetPath = path.join(targetDir, 'archive-ontology.json');
420
- const previous = fs.existsSync(targetPath)
421
- ? JSON.parse(fs.readFileSync(targetPath, 'utf8'))
420
+ const cachePath = path.join(statePaths(projectRoot, changeName).dir, 'snapshots', `${state.revision}.json`);
421
+ const previousPath = fs.existsSync(cachePath) ? cachePath : targetPath;
422
+ const previous = fs.existsSync(previousPath)
423
+ ? JSON.parse(fs.readFileSync(previousPath, 'utf8'))
422
424
  : null;
423
425
  const previousEntity = previous && previous.source_revision === state.revision
424
426
  ? (previous.entities || []).find((entity) => entity.type === 'OntologySnapshot')
@@ -458,12 +460,14 @@ function createArchiveSnapshot(projectRoot, changeName, archiveDir, inputState)
458
460
  snapshot_id: snapshotId,
459
461
  snapshot_entity_id: snapshotEntity.entity_id,
460
462
  source_revision: state.revision,
461
- confirmed_at: new Date().toISOString(),
463
+ confirmed_at: previous && previous.source_revision === state.revision
464
+ ? previous.confirmed_at : new Date().toISOString(),
462
465
  entities: [
463
466
  ...(state.entities || []).filter((entity) => entity.type !== 'OntologySnapshot'),
464
467
  snapshotEntity,
465
468
  ],
466
469
  };
470
+ atomicWriteIfChanged(cachePath, snapshot);
467
471
  atomicWriteIfChanged(targetPath, snapshot);
468
472
  return snapshot;
469
473
  }
@@ -114,6 +114,8 @@ const DIAGNOSTIC_CODES = Object.freeze({
114
114
  NUMBERING_WAIVER_ACTIVE: 'NUMBERING_WAIVER_ACTIVE',
115
115
  CONTINUITY_DECISION_REQUIRED: 'CONTINUITY_DECISION_REQUIRED',
116
116
  CONTINUITY_IDENTITY_MISMATCH: 'CONTINUITY_IDENTITY_MISMATCH',
117
+ TASK_DEPENDENCY_LAYER_GAP: 'SEM_TASK_DEPENDENCY_LAYER_GAP',
118
+ TASK_DEPENDENCY_MISSING_EDGE: 'SEM_TASK_DEPENDENCY_MISSING_EDGE',
117
119
  });
118
120
 
119
121
  function normalizeProfile(profile, inferredProfile = 'simple') {
@@ -113,7 +113,7 @@ function validateIdentitySemantics(facts, diagnostics, identityHistory) {
113
113
  }
114
114
 
115
115
  const entityOwner = currentByEntityId.get(entity.entity_id);
116
- if (entityOwner && (entityOwner.id !== entity.id || entityOwner.type !== entity.type)) {
116
+ if (entityOwner && (entityOwner.type !== entity.type)) {
117
117
  diagnostics.push(diagnostic(
118
118
  DIAGNOSTIC_CODES.ENTITY_IDENTITY_CONFLICT,
119
119
  'error',
@@ -165,7 +165,7 @@ function validateIdentitySemantics(facts, diagnostics, identityHistory) {
165
165
  const priorAnchors = historyByAnchor.get(entity.anchor_id || entity.id) || [];
166
166
  const priorVersion = historyByVersionId.get(entity.version_id);
167
167
 
168
- if (priorEntities.some((record) => record.anchor_id !== entity.id || record.type !== entity.type)
168
+ if (priorEntities.some((record) => record.type !== entity.type)
169
169
  || priorAnchors.some((record) => record.entity_id !== entity.entity_id)) {
170
170
  diagnostics.push(diagnostic(
171
171
  DIAGNOSTIC_CODES.ENTITY_IDENTITY_CONFLICT,
@@ -220,8 +220,8 @@ function validateIdentitySemantics(facts, diagnostics, identityHistory) {
220
220
  if (knownSuccessor) {
221
221
  diagnostics.push(diagnostic(
222
222
  DIAGNOSTIC_CODES.VERSION_LINEAGE_CONFLICT,
223
- 'error',
224
- `同一 predecessor 已存在后继版本,禁止并行或陈旧分支: ${entity.id}`,
223
+ knownSuccessor.scope === 'active' ? 'warning' : 'error',
224
+ `同一 predecessor 存在其他后继;草稿可并行,发布前必须解决基线冲突: ${entity.id}`,
225
225
  { ...context, suggestion: `以 ${knownSuccessor.change} 的版本为直接前序;不要再次继承已消费的 predecessor` },
226
226
  ));
227
227
  }
@@ -922,6 +922,8 @@ function validateTraceability(facts, options = {}) {
922
922
  ));
923
923
  }
924
924
 
925
+ validateTaskLayerSemantics(graphEntities, graphRelations, diagnostics);
926
+
925
927
  diagnostics.sort((left, right) => (
926
928
  String(left.code).localeCompare(String(right.code))
927
929
  || String(left.file || '').localeCompare(String(right.file || ''))
@@ -943,10 +945,78 @@ function validateTraceability(facts, options = {}) {
943
945
  };
944
946
  }
945
947
 
948
+ function inferTaskLayer(task) {
949
+ const attrs = task.attributes || {};
950
+ if (typeof attrs.layer === 'number') return attrs.layer;
951
+
952
+ const id = String(task.id || '').toUpperCase();
953
+ const desc = String(task.description || task.title || '').toUpperCase();
954
+ const text = `${id} ${desc}`;
955
+
956
+ const patterns = [
957
+ { layer: 0, keywords: ['DEPS', 'DEPEND', 'CONFIG', 'POM', 'GRADLE', 'CONST'] },
958
+ { layer: 1, keywords: ['ENTITY', 'DTO', 'ENUM', 'VO', 'MODEL'] },
959
+ { layer: 2, keywords: ['MAPPER', 'REPO', 'REPOSITORY', 'DAO'] },
960
+ { layer: 3, keywords: ['SERVICE-IF', 'SERVICE_IF', 'INTERFACE'] },
961
+ { layer: 4, keywords: ['IMPL', 'SERVICE', 'HANDLER', 'PROCESSOR'] },
962
+ { layer: 5, keywords: ['CONTROLLER', 'API', 'RESOURCE', 'ENDPOINT'] },
963
+ { layer: 6, keywords: ['TEST', 'VERIFY', 'VALIDATE', 'SPEC'] },
964
+ ];
965
+
966
+ for (const { layer, keywords } of patterns) {
967
+ if (keywords.some((kw) => text.includes(kw))) return layer;
968
+ }
969
+ return null;
970
+ }
971
+
972
+ function validateTaskLayerSemantics(graphEntities, graphRelations, diagnostics) {
973
+ const tasks = graphEntities.filter((entity) => entity.type === 'Task' && entity.delta_state !== 'removed');
974
+ if (tasks.length === 0) return;
975
+
976
+ const taskById = new Map();
977
+ for (const task of tasks) taskById.set(task.id, task);
978
+
979
+ const dependsOnMap = new Map();
980
+ for (const relation of graphRelations) {
981
+ if (relation.type !== 'dependsOn') continue;
982
+ if (!dependsOnMap.has(relation.from)) dependsOnMap.set(relation.from, new Set());
983
+ dependsOnMap.get(relation.from).add(relation.to);
984
+ }
985
+
986
+ const layerByTask = new Map();
987
+ for (const task of tasks) {
988
+ const layer = inferTaskLayer(task);
989
+ if (layer !== null) layerByTask.set(task.id, layer);
990
+ }
991
+
992
+ for (const [taskId, layer] of layerByTask.entries()) {
993
+ if (layer <= 0) continue;
994
+ const deps = dependsOnMap.get(taskId) || new Set();
995
+
996
+ for (const [otherId, otherLayer] of layerByTask.entries()) {
997
+ if (otherId === taskId) continue;
998
+ if (otherLayer >= layer) continue;
999
+ if (!deps.has(otherId)) {
1000
+ diagnostics.push(diagnostic(
1001
+ DIAGNOSTIC_CODES.TASK_DEPENDENCY_LAYER_GAP,
1002
+ 'warning',
1003
+ `Task ${taskId} (Layer ${layer}) 可能缺少对 Layer ${otherLayer} 任务 ${otherId} 的依赖`,
1004
+ {
1005
+ entity_id: taskId,
1006
+ suggestion: `若 ${taskId} 引用或依赖 ${otherId} 的定义,建议添加 dependsOn: ${otherId}`,
1007
+ },
1008
+ ));
1009
+ }
1010
+ }
1011
+ }
1012
+ }
1013
+
946
1014
  module.exports = {
947
1015
  diagnostic,
948
1016
  findTaskCycles,
949
1017
  validateIdentitySemantics,
950
1018
  validateInheritedReferences,
951
1019
  validateTraceability,
1020
+ validateTaskLayerSemantics,
1021
+ inferTaskLayer,
952
1022
  };