kld-sdd 2.6.16 → 2.6.21

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 (70) hide show
  1. package/bin/kld-sdd-init.js +10 -0
  2. package/kld-sdd-guide.html +22 -0
  3. package/lib/device-auth-cli.js +130 -0
  4. package/lib/device-auth.js +345 -0
  5. package/lib/init-account-binding.js +108 -0
  6. package/lib/init.js +238 -55
  7. package/lib/skills-bundle.js +20 -1
  8. package/lib/tool-profiles.js +8 -0
  9. package/package.json +7 -3
  10. package/skywalk-sdd/apply-worktree-finish.cjs +2 -23
  11. package/skywalk-sdd/context-client.cjs +38 -87
  12. package/skywalk-sdd/index.cjs +860 -132
  13. package/skywalk-sdd/kb-sync-identity.cjs +780 -0
  14. package/skywalk-sdd/kb-upload.cjs +505 -0
  15. package/skywalk-sdd/lib/shared.cjs +811 -0
  16. package/skywalk-sdd/lib/usage-contract.cjs +276 -0
  17. package/skywalk-sdd/lib/usage-reporter.cjs +354 -0
  18. package/skywalk-sdd/lib/user-config.cjs +157 -0
  19. package/skywalk-sdd/metrics-v3.cjs +138 -8
  20. package/skywalk-sdd/ontology/archive-package.cjs +19 -34
  21. package/skywalk-sdd/ontology/change-lock.cjs +3 -7
  22. package/skywalk-sdd/ontology/external-key.cjs +18 -4
  23. package/skywalk-sdd/ontology/id.cjs +26 -5
  24. package/skywalk-sdd/ontology/identity-index.cjs +3 -7
  25. package/skywalk-sdd/ontology/resolve-spec-root.cjs +20 -6
  26. package/skywalk-sdd/ontology/runtime.cjs +16 -12
  27. package/skywalk-sdd/ontology/traceability-validator.cjs +7 -4
  28. package/skywalk-sdd/reporting/change-report-markdown.cjs +137 -19
  29. package/skywalk-sdd/reporting/change-report-model.cjs +993 -14
  30. package/skywalk-sdd/reporting/change-report-renderer.cjs +106 -41
  31. package/skywalk-sdd/reporting/change-report-view-model.cjs +272 -43
  32. package/skywalk-sdd/reporting/core-metric-definitions.cjs +192 -0
  33. package/skywalk-sdd/spec-root.cjs +31 -0
  34. package/templates/git-hooks/pre-commit-consistency-check.cjs +271 -116
  35. package/templates/git-hooks/pre-push-consistency-check.cjs +252 -123
  36. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +327 -0
  37. package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +54 -9
  38. package/templates/hooks/codebuddy/hooks/sdd-mid-checkpoint.cjs +63 -6
  39. package/templates/hooks/codebuddy/hooks/sdd-tdd-rhythm-gate.cjs +113 -65
  40. package/templates/openspec/proposal.md +7 -3
  41. package/templates/openspec/spec.md +3 -3
  42. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +8 -6
  43. package/templates/skills/kld-sdd/opsx-apply/checklist.md +2 -0
  44. package/templates/skills/kld-sdd/opsx-apply/reference.md +29 -7
  45. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +6 -5
  46. package/templates/skills/kld-sdd/opsx-check/SKILL.md +49 -17
  47. package/templates/skills/kld-sdd/opsx-check/checklist.md +4 -2
  48. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +187 -257
  49. package/templates/skills/kld-sdd/opsx-consistency-check/reference.md +129 -0
  50. package/templates/skills/kld-sdd/opsx-design/SKILL.md +2 -2
  51. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +2 -2
  52. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +185 -0
  53. package/templates/skills/kld-sdd/opsx-kb-config/reference.md +127 -0
  54. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +218 -53
  55. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +51 -9
  56. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +12 -50
  57. package/templates/skills/kld-sdd/opsx-ontology-query/phase-3-postchange.md +2 -2
  58. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +1 -1
  59. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +35 -23
  60. package/templates/skills/kld-sdd/opsx-propose/checklist.md +2 -0
  61. package/templates/skills/kld-sdd/opsx-propose/reference.md +22 -17
  62. package/templates/skills/kld-sdd/opsx-rules/SKILL.md +2 -2
  63. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +19 -15
  64. package/templates/skills/kld-sdd/opsx-spec/checklist.md +2 -0
  65. package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -4
  66. package/templates/skills/kld-sdd/opsx-test/SKILL.md +2 -2
  67. package/templates/skills/kld-sdd/tdd-core/reference.md +1 -1
  68. package/templates/skills/kld-sdd/tdd-rules/rules/test-skeleton-telemetry.md +1 -1
  69. package/templates/skills/kld-sdd/opsx-kb-ingest/state.example.json +0 -7
  70. package/templates/skills/kld-sdd/opsx-ontology-query/state.example.json +0 -7
@@ -0,0 +1,157 @@
1
+ // kld-T02 — ~/.kld-sdd/config.json 用户级配置管理
2
+ // 职责:读取、规范化、原子写、0600/Windows ACL、401 token 清除、敏感信息脱敏。
3
+ // 与 kb-sdd 跨仓契约对齐:cli_token 仅存于此,不进入 Archive/Knowledge 请求。
4
+ 'use strict';
5
+
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+
10
+ const USER_CONFIG_VERSION = 1;
11
+ const CONFIG_DIR_NAME = '.kld-sdd';
12
+ const CONFIG_FILE_NAME = 'config.json';
13
+
14
+ /**
15
+ * 返回用户配置目录路径。
16
+ * @param {string} [homeDir] — 注入的 home 目录(测试用);缺省取 env.KLD_SDD_HOME 或 os.homedir()
17
+ */
18
+ function getConfigDir(homeDir) {
19
+ const envHome = typeof process !== 'undefined' && process && process.env
20
+ ? process.env.KLD_SDD_HOME
21
+ : undefined;
22
+ const home = (typeof homeDir === 'string' && homeDir)
23
+ || (typeof envHome === 'string' && envHome)
24
+ || os.homedir();
25
+ return path.join(home, CONFIG_DIR_NAME);
26
+ }
27
+
28
+ /**
29
+ * 返回用户配置文件完整路径。
30
+ */
31
+ function getConfigPath(homeDir) {
32
+ return path.join(getConfigDir(homeDir), CONFIG_FILE_NAME);
33
+ }
34
+
35
+ /**
36
+ * 读取用户配置。
37
+ * - 文件不存在 / 损坏 JSON / 非对象 → 返回 null(不抛)
38
+ * - 返回新引用;调用方修改不影响后续读取
39
+ *
40
+ * @param {string} [homeDir]
41
+ * @returns {object|null}
42
+ */
43
+ function readUserConfig(homeDir) {
44
+ const file = getConfigPath(homeDir);
45
+ let raw;
46
+ try {
47
+ raw = fs.readFileSync(file, 'utf8');
48
+ } catch {
49
+ return null;
50
+ }
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ } catch {
55
+ return null;
56
+ }
57
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
58
+ // 深拷贝隔离
59
+ try {
60
+ return JSON.parse(JSON.stringify(parsed));
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * POSIX 平台设置 0600/0700;Windows 平台尝试通过 icacls 收紧 ACL(best-effort)。
68
+ * @param {string} target — 文件或目录路径
69
+ * @param {'file'|'dir'} kind
70
+ */
71
+ function hardenPermissions(target, kind) {
72
+ if (process.platform === 'win32') {
73
+ // Windows:依赖 Node 默认 ACL 通常已限当前用户,无需显式 icacls;
74
+ // 实际收紧在更高层以同步子进程完成,避免引入异步依赖。
75
+ return;
76
+ }
77
+ try {
78
+ fs.chmodSync(target, kind === 'dir' ? 0o700 : 0o600);
79
+ } catch {
80
+ // best-effort:无法 chmod 时静默(例如某些 FS 不支持)
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 原子写入用户配置:先 temp 后 rename,目录与文件均设置权限。
86
+ * @param {object} config — 必须含 version 字段
87
+ * @param {string} [homeDir]
88
+ * @throws {Error} config 非法 / 缺 version
89
+ */
90
+ function writeUserConfig(config, homeDir) {
91
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
92
+ throw new Error('user config must be an object');
93
+ }
94
+ if (typeof config.version !== 'number') {
95
+ throw new Error('user config requires numeric version');
96
+ }
97
+ const dir = getConfigDir(homeDir);
98
+ const file = getConfigPath(homeDir);
99
+ fs.mkdirSync(dir, { recursive: true });
100
+ hardenPermissions(dir, 'dir');
101
+
102
+ const tmp = path.join(dir, `.${CONFIG_FILE_NAME}.${process.pid}.${Date.now()}.tmp`);
103
+ const payload = JSON.stringify(config, null, 2);
104
+ fs.writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600 });
105
+ hardenPermissions(tmp, 'file');
106
+ try {
107
+ fs.renameSync(tmp, file);
108
+ } catch (err) {
109
+ try { fs.unlinkSync(tmp); } catch { /* ignore */ }
110
+ throw err;
111
+ }
112
+ hardenPermissions(file, 'file');
113
+ }
114
+
115
+ /**
116
+ * 原子清除 token 字段,保留其他所有字段。
117
+ * - 文件不存在 / 损坏 / 已无 token → 幂等返回 false
118
+ * - 成功清除返回 true
119
+ *
120
+ * @param {string} [homeDir]
121
+ * @returns {boolean}
122
+ */
123
+ function clearUserToken(homeDir) {
124
+ const file = getConfigPath(homeDir);
125
+ if (!fs.existsSync(file)) return false;
126
+ const current = readUserConfig(homeDir);
127
+ if (!current) return false;
128
+ if (!Object.prototype.hasOwnProperty.call(current, 'token')) return false;
129
+ delete current.token;
130
+ writeUserConfig(current, homeDir);
131
+ return true;
132
+ }
133
+
134
+ /**
135
+ * 敏感信息脱敏:任何非空字符串 token 一律返回 '***'。
136
+ * - 空字符串保持空字符串
137
+ * - 非字符串原样返回
138
+ * - 永不抛出
139
+ *
140
+ * @param {*} value
141
+ * @returns {*}
142
+ */
143
+ function redactToken(value) {
144
+ if (typeof value !== 'string') return value;
145
+ if (value.length === 0) return '';
146
+ return '***';
147
+ }
148
+
149
+ module.exports = {
150
+ USER_CONFIG_VERSION,
151
+ getConfigDir,
152
+ getConfigPath,
153
+ readUserConfig,
154
+ writeUserConfig,
155
+ clearUserToken,
156
+ redactToken,
157
+ };
@@ -4,7 +4,8 @@ const REPORT_SCHEMA_VERSION = 'sdd-efficiency-report/v4';
4
4
  const METRICS_CONTRACT_VERSION = '4.0';
5
5
  const SCHEME_VERSION = 'V3.0';
6
6
  /** Writable change types (scheme 5.3). Legacy document/other read as unknown. */
7
- const CHANGE_TYPES = ['config', 'transaction', 'report', 'composite'];
7
+ const BUSINESS_CHANGE_TYPES = ['config', 'transaction', 'report', 'composite'];
8
+ const CHANGE_TYPES = [...BUSINESS_CHANGE_TYPES, 'unknown'];
8
9
  const LEGACY_CHANGE_TYPES = ['document', 'other'];
9
10
  const MINIMUM_METRICS = ['e1', 'q3', 'p4', 'p_r', 'p_h'];
10
11
 
@@ -55,7 +56,7 @@ const AGENT_OVERRIDE_REASONS = new Set([
55
56
  'unspecified',
56
57
  ]);
57
58
 
58
- const SCENARIO_HEADING_RE = /^#####\s+(场景|Scenario)\s*[::]/gm;
59
+ const SCENARIO_HEADING_RE = /^#####\s+(?:场景|Scenario)\s*[::]\s*(.+?)\s*$/gim;
59
60
 
60
61
  function createInvalidArgError(code, message) {
61
62
  const err = new Error(message);
@@ -74,6 +75,26 @@ function round(n, digits = 4) {
74
75
  return Math.round(n * factor) / factor;
75
76
  }
76
77
 
78
+ function computeTelemetryHealthScore(input = {}) {
79
+ const weightedIssueValue = Number(input.weighted_issue_value);
80
+ const stageEventCount = Number(input.stage_event_count);
81
+ const safeWeightedIssueValue = Number.isFinite(weightedIssueValue) && weightedIssueValue >= 0
82
+ ? weightedIssueValue
83
+ : 0;
84
+ const safeStageEventCount = Number.isFinite(stageEventCount) && stageEventCount >= 0
85
+ ? stageEventCount
86
+ : 0;
87
+ const denominator = Math.max(safeStageEventCount, 1);
88
+ const ratio = Math.max(0, round(1 - safeWeightedIssueValue / denominator, 2));
89
+ return {
90
+ weighted_issue_value: safeWeightedIssueValue,
91
+ stage_event_count: safeStageEventCount,
92
+ denominator,
93
+ ratio,
94
+ score: round(ratio * 100, 2),
95
+ };
96
+ }
97
+
77
98
  function percentile(sortedOrUnsortedValues, p) {
78
99
  if (!Array.isArray(sortedOrUnsortedValues)) {
79
100
  throw createInvalidArgError('invalid-arg', 'percentile expects an array');
@@ -137,10 +158,44 @@ function summarizeDistribution(values) {
137
158
  }
138
159
 
139
160
  function countScenariosInMarkdown(text) {
140
- if (typeof text !== 'string' || !text) return 0;
161
+ return extractScenariosInMarkdown(text).length;
162
+ }
163
+
164
+ function maskMarkdownFencedCode(text) {
165
+ const lines = text.split('\n');
166
+ let fence = null;
167
+ return lines.map((line) => {
168
+ const marker = line.match(/^ {0,3}(`{3,}|~{3,})/);
169
+ if (!fence && marker) {
170
+ fence = {
171
+ character: marker[1][0],
172
+ length: marker[1].length,
173
+ };
174
+ return ' '.repeat(line.length);
175
+ }
176
+ if (!fence) return line;
177
+
178
+ const closing = line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/);
179
+ const isClosing = closing
180
+ && closing[1][0] === fence.character
181
+ && closing[1].length >= fence.length;
182
+ if (isClosing) fence = null;
183
+ return ' '.repeat(line.length);
184
+ }).join('\n');
185
+ }
186
+
187
+ function extractScenariosInMarkdown(text) {
188
+ if (typeof text !== 'string' || !text) return [];
141
189
  const normalized = text.replace(/\r\n/g, '\n');
142
- const matches = normalized.match(SCENARIO_HEADING_RE);
143
- return matches ? matches.length : 0;
190
+ const searchable = maskMarkdownFencedCode(normalized);
191
+ const scenarios = [];
192
+ for (const match of searchable.matchAll(SCENARIO_HEADING_RE)) {
193
+ scenarios.push({
194
+ title: String(match[1] || '').trim(),
195
+ line: searchable.slice(0, match.index).split('\n').length,
196
+ });
197
+ }
198
+ return scenarios;
144
199
  }
145
200
 
146
201
  function validateChangeType(value) {
@@ -185,11 +240,33 @@ function resolveReviewerIndependence(meta = {}) {
185
240
  return 'unknown';
186
241
  }
187
242
 
243
+ const REVIEW_IDENTITY_PLACEHOLDERS = new Set([
244
+ 'unknown',
245
+ 'none',
246
+ 'null',
247
+ 'n/a',
248
+ 'na',
249
+ '未记录',
250
+ '未知',
251
+ ]);
252
+
253
+ function isMeaningfulReviewIdentity(value) {
254
+ const normalized = String(value || '').trim();
255
+ if (!normalized) return false;
256
+ if (REVIEW_IDENTITY_PLACEHOLDERS.has(normalized.toLowerCase())) return false;
257
+ if (/^<[^>]+>$/.test(normalized) || /^\[[^\]]+\]$/.test(normalized)) return false;
258
+ return true;
259
+ }
260
+
188
261
  function resolveScoreStatus(reviewerIndependence, meta = {}) {
189
262
  if (reviewerIndependence !== 'independent-review') return 'provisional';
263
+ if (meta.review_execution_mode !== 'subagent') return 'provisional';
264
+ if (!isMeaningfulReviewIdentity(meta.reviewer_agent_id)) return 'provisional';
190
265
  const reviewSession = meta.review_session_id || meta.reviewer_session_id || null;
191
266
  const authorSession = meta.author_session_id || null;
192
- if (reviewSession && authorSession && reviewSession !== authorSession) {
267
+ if (isMeaningfulReviewIdentity(reviewSession)
268
+ && isMeaningfulReviewIdentity(authorSession)
269
+ && reviewSession !== authorSession) {
193
270
  return 'verified';
194
271
  }
195
272
  return 'provisional';
@@ -210,7 +287,7 @@ function computeSpecQualityScore(checkResults, meta = {}) {
210
287
  }
211
288
 
212
289
  const total = Number.isFinite(checkResults.total) ? checkResults.total : null;
213
- const errors = Number.isFinite(checkResults.errors) ? checkResults.errors : 0;
290
+ const errors = Number.isFinite(checkResults.errors) ? checkResults.errors : null;
214
291
  const warnings = Number.isFinite(checkResults.warnings) ? checkResults.warnings : 0;
215
292
  const suggestions = Number.isFinite(checkResults.suggestions) ? checkResults.suggestions : 0;
216
293
 
@@ -227,6 +304,24 @@ function computeSpecQualityScore(checkResults, meta = {}) {
227
304
  suggestions,
228
305
  };
229
306
  }
307
+ if (!Number.isInteger(total)
308
+ || !Number.isInteger(errors)
309
+ || errors < 0
310
+ || errors > total) {
311
+ const reviewerIndependence = resolveReviewerIndependence(meta);
312
+ return {
313
+ q3_spec_quality_score: null,
314
+ category_scores: {},
315
+ reviewer_independence: reviewerIndependence,
316
+ score_status: resolveScoreStatus(reviewerIndependence, meta),
317
+ availability: false,
318
+ availability_reason: 'invalid-counts',
319
+ warnings,
320
+ suggestions,
321
+ total: Number.isInteger(total) ? total : null,
322
+ errors: Number.isInteger(errors) ? errors : null,
323
+ };
324
+ }
230
325
 
231
326
  const categoryScores = {};
232
327
  const categories = checkResults.categories && typeof checkResults.categories === 'object'
@@ -380,6 +475,33 @@ function computeV3ChangeMetrics(context = {}) {
380
475
  q3_score_status: q3Result.score_status,
381
476
  q3_availability: q3Result.availability,
382
477
  q3_availability_reason: q3Result.availability_reason,
478
+ q3_total: q3Result.total ?? null,
479
+ q3_errors: q3Result.errors ?? null,
480
+ q3_warnings: q3Result.warnings ?? 0,
481
+ q3_suggestions: q3Result.suggestions ?? 0,
482
+ q3_review: {
483
+ reviewer_independence: q3Result.reviewer_independence,
484
+ review_execution_mode: q3Result.review_execution_mode
485
+ || context.check_meta?.review_execution_mode
486
+ || null,
487
+ reviewer_agent_id: q3Result.reviewer_agent_id
488
+ || context.check_meta?.reviewer_agent_id
489
+ || null,
490
+ review_session_id: q3Result.review_session_id
491
+ || q3Result.reviewer_session_id
492
+ || context.check_meta?.review_session_id
493
+ || context.check_meta?.reviewer_session_id
494
+ || null,
495
+ author_session_id: q3Result.author_session_id
496
+ || context.check_meta?.author_session_id
497
+ || null,
498
+ fallback_reason_code: q3Result.fallback_reason_code
499
+ || context.check_meta?.fallback_reason_code
500
+ || null,
501
+ fallback_reason: q3Result.fallback_reason
502
+ || context.check_meta?.fallback_reason
503
+ || null,
504
+ },
383
505
  q1_spec_conformance: buildPendingUpstreamMetric(context.q1_spec_conformance, context.q1_source),
384
506
  q4_tdd_coverage: buildPendingUpstreamMetric(context.q4_tdd_coverage, context.q4_source),
385
507
  },
@@ -456,8 +578,12 @@ function aggregateV3Metrics(changeMetrics, options = {}) {
456
578
  const distribution = {};
457
579
 
458
580
  for (const key of metricKeys) {
459
- byChangeType[key] = aggregateByChangeType(changeMetrics, key, options);
581
+ byChangeType[key] = aggregateByChangeType(changeMetrics, key, {
582
+ ...options,
583
+ includeUnknown: false,
584
+ });
460
585
  const allValues = changeMetrics
586
+ .filter(m => validateChangeType(m.change_type) !== 'unknown')
461
587
  .map(m => pickMetricValue(m, key))
462
588
  .filter(v => Number.isFinite(v));
463
589
  distribution[key] = summarizeDistribution(allValues);
@@ -1210,12 +1336,15 @@ module.exports = {
1210
1336
  REPORT_SCHEMA_VERSION,
1211
1337
  METRICS_CONTRACT_VERSION,
1212
1338
  SCHEME_VERSION,
1339
+ BUSINESS_CHANGE_TYPES,
1213
1340
  CHANGE_TYPES,
1214
1341
  LEGACY_CHANGE_TYPES,
1215
1342
  ALERT_THRESHOLDS,
1216
1343
  MINIMUM_METRICS,
1217
1344
  clamp,
1218
1345
  round,
1346
+ computeTelemetryHealthScore,
1347
+ resolveScoreStatus,
1219
1348
  percentile,
1220
1349
  summarizeDistribution,
1221
1350
  detectIqrOutliers,
@@ -1238,4 +1367,5 @@ module.exports = {
1238
1367
  formatArtifactSize,
1239
1368
  createInvalidArgError,
1240
1369
  countScenariosInMarkdown,
1370
+ extractScenariosInMarkdown,
1241
1371
  };
@@ -5,6 +5,14 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { generateUuidV7 } = require('./id.cjs');
7
7
  const { resolveRuntimeMetadata } = require('../runtime-metadata.cjs');
8
+ const {
9
+ readJson,
10
+ writeJsonAtomic,
11
+ writeTextAtomic,
12
+ toPosix,
13
+ sha256,
14
+ ensureDir,
15
+ } = require('../lib/shared.cjs');
8
16
 
9
17
  const RUNTIME_METADATA = resolveRuntimeMetadata();
10
18
  const PRODUCER_VERSION = RUNTIME_METADATA.version;
@@ -19,30 +27,7 @@ const CANONICAL_FACTS_PATH = 'canonical-facts.json';
19
27
  const CONVERSION_REPORT_PATH = 'conversion-report.json';
20
28
  const PACKAGE_SUFFIXES = new Set(['.md', '.html', '.json', '.jsonl', '.yaml', '.yml']);
21
29
 
22
- function sha256(content) {
23
- return crypto.createHash('sha256').update(content).digest('hex');
24
- }
25
-
26
- function readJson(filePath) {
27
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
28
- }
29
-
30
- function atomicWrite(filePath, content) {
31
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
32
- const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
33
- fs.writeFileSync(tempPath, content);
34
- fs.renameSync(tempPath, filePath);
35
- }
36
-
37
- function writeJson(filePath, value) {
38
- const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
39
- atomicWrite(filePath, bytes);
40
- return bytes;
41
- }
42
-
43
- function toPosix(value) {
44
- return String(value || '').replace(/\\/g, '/');
45
- }
30
+ // readJson, atomicWrite, writeJson, toPosix, sha256 已迁移至 lib/shared.cjs
46
31
 
47
32
  function safePackagePath(value) {
48
33
  const input = toPosix(value).trim();
@@ -65,18 +50,18 @@ function ensureProjectIdentity(projectRoot) {
65
50
  }
66
51
  return existing;
67
52
  }
68
- // 兜底:project-identity.json 未通过 opsx-ontology-query 配置 KB 时创建
53
+ // 兜底:project-identity.json 未通过 opsx-kb-config 配置 KB 时创建
69
54
  // 此时 project_id 为随机值,KB 入库时会因 project_id !== spaceKey 而失败
70
- console.warn('⚠️ project-identity.json 不存在 — 未通过 opsx-ontology-query 配置 KB。');
55
+ console.warn('⚠️ project-identity.json 不存在 — 未通过 opsx-kb-config 配置 KB。');
71
56
  console.warn('⚠️ 将生成临时 project_id,KB 入库时需确保与 Space spaceKey 一致。');
72
- console.warn('⚠️ 建议在首次使用 opsx-ontology-query 配置 KB 时自动创建正确的 project-identity.json。');
57
+ console.warn('⚠️ 建议运行 /opsx-kb-config 配置 KB 后更新 project_id 为 Space spaceKey。');
73
58
  const identity = {
74
59
  schema_version: PROJECT_IDENTITY_SCHEMA_VERSION,
75
60
  project_id: `urn:kld:sdd:project:${generateUuidV7()}`,
76
61
  created_at: new Date().toISOString(),
77
- _warning: 'UNCONFIGURED — 请通过 opsx-ontology-query 配置 KB 后更新 project_id 为 Space spaceKey',
62
+ _warning: 'UNCONFIGURED — 请运行 /opsx-kb-config 配置 KB 后更新 project_id 为 Space spaceKey',
78
63
  };
79
- writeJson(identityPath, identity);
64
+ writeJsonAtomic(identityPath, identity);
80
65
  return identity;
81
66
  }
82
67
 
@@ -188,7 +173,7 @@ function assertExternalKeyFinalGate(entities) {
188
173
  const parts = parseScenario(ref.external_id);
189
174
  if (!parts || !packageRequirementIds.has(parts.requirementId)) {
190
175
  throw new Error(
191
- `${CODES.SCOPE_MISMATCH}: 场景键 REQ 前缀不在同包 requirement 绑定集合中: ${ref.external_id} @ ${entity.anchor_id}`,
176
+ `${CODES.SCOPE_MISMATCH}: 场景键需求编号前缀不在同包 requirement 绑定集合中: ${ref.external_id} @ ${entity.anchor_id}`,
192
177
  );
193
178
  }
194
179
  }
@@ -499,7 +484,7 @@ function writeArchiveZip(archiveDir, packagePath, manifest) {
499
484
  end.writeUInt32LE(centralDirectory.length, 12);
500
485
  end.writeUInt32LE(offset, 16);
501
486
  end.writeUInt16LE(0, 20);
502
- atomicWrite(packagePath, Buffer.concat([...localParts, centralDirectory, end]));
487
+ writeTextAtomic(packagePath, Buffer.concat([...localParts, centralDirectory, end]));
503
488
  return packagePath;
504
489
  }
505
490
 
@@ -595,7 +580,7 @@ function materializeArchivePackage(options = {}) {
595
580
  archiveId,
596
581
  );
597
582
  const canonicalPath = path.join(archiveDir, CANONICAL_FACTS_PATH);
598
- const canonicalBytes = writeJson(canonicalPath, canonicalFacts);
583
+ const canonicalBytes = writeJsonAtomic(canonicalPath, canonicalFacts);
599
584
 
600
585
  const conversionReport = {
601
586
  schema_version: CONVERSION_REPORT_SCHEMA_VERSION,
@@ -609,7 +594,7 @@ function materializeArchivePackage(options = {}) {
609
594
  relation_count: canonicalFacts.relations.length,
610
595
  warnings,
611
596
  };
612
- writeJson(path.join(archiveDir, CONVERSION_REPORT_PATH), conversionReport);
597
+ writeJsonAtomic(path.join(archiveDir, CONVERSION_REPORT_PATH), conversionReport);
613
598
 
614
599
  const existingManifestPath = path.join(archiveDir, MANIFEST_PATH);
615
600
  const existingManifest = fs.existsSync(existingManifestPath)
@@ -649,7 +634,7 @@ function materializeArchivePackage(options = {}) {
649
634
  ? (existingManifest.evidence_events_path || null)
650
635
  : options.evidenceEventsPath,
651
636
  };
652
- writeJson(existingManifestPath, manifest);
637
+ writeJsonAtomic(existingManifestPath, manifest);
653
638
 
654
639
  const packagePath = options.packagePath === false
655
640
  ? ''
@@ -4,6 +4,7 @@ const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { safeChangeName } = require('./artifact-parser.cjs');
7
+ const { readJsonSafe } = require('../lib/shared.cjs');
7
8
 
8
9
  const DEFAULT_STALE_MS = 5 * 60 * 1000;
9
10
 
@@ -27,13 +28,8 @@ function isProcessAlive(pid) {
27
28
  }
28
29
  }
29
30
 
30
- function readLock(filePath) {
31
- try {
32
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
33
- } catch (error) {
34
- return null;
35
- }
36
- }
31
+ // readLock 改用 shared.cjs 的 readJsonSafe(带 BOM 剥离)
32
+ const readLock = readJsonSafe;
37
33
 
38
34
  function isStaleLock(lock, staleMs) {
39
35
  if (!lock || !lock.created_at) return true;
@@ -4,20 +4,26 @@
4
4
  * External key grammar for requirement / feature / scenario.
5
5
  * Authority: kb-sdd docs/superpowers/specs/2026-07-24-external-numbering-kb-sdd-design.md §2.
6
6
  * Regex and normalization MUST stay byte-for-byte aligned with kb-sdd ExternalKeyFormat.java.
7
+ *
8
+ * 格式放宽(2026-08-04):requirement / feature 不再强制 REQ-/FEAT- 前缀,
9
+ * 允许外部系统(客户 ERP、Jira 等)的自有编号格式。
10
+ * 白名单:大写字母 + 数字 + 连字符(-) + 下划线(_),1~50 字符。
11
+ * 示例:KLERP-001, KLERP001, 1010000, KLERP_001, REQ-FI-2024-001
7
12
  */
8
13
 
9
14
  const REGEX = {
10
- requirement: /^REQ-[A-Z][A-Z0-9]{1,7}-[0-9]{4}-[0-9]{3,6}$/,
11
- feature: /^FEAT-[A-Z][A-Z0-9]{1,7}-[0-9]{3,6}$/,
12
- scenario: /^REQ-[A-Z][A-Z0-9]{1,7}-[0-9]{4}-[0-9]{3,6}:SCN-[a-z0-9]+(-[a-z0-9]+)*-[0-9]{3}$/,
15
+ requirement: /^[A-Z0-9_-]+$/,
16
+ feature: /^[A-Z0-9_-]+$/,
17
+ scenario: /^[A-Z0-9_-]+:SCN-[a-z0-9]+(-[a-z0-9]+)*-[0-9]{3}$/,
13
18
  };
14
19
 
15
20
  const SCENARIO_PARTS =
16
- /^(REQ-[A-Z][A-Z0-9]{1,7}-[0-9]{4}-[0-9]{3,6}):SCN-([a-z0-9]+(?:-[a-z0-9]+)*)-([0-9]{3})$/;
21
+ /^([A-Z0-9_-]+):SCN-([a-z0-9]+(?:-[a-z0-9]+)*)-([0-9]{3})$/;
17
22
 
18
23
  const SLUG_MAX_LEN = 40;
19
24
  const SCENARIO_KEY_MAX_LEN = 120;
20
25
  const SCN_SEQ_MAX = 999;
26
+ const EXTERNAL_ID_MAX_LEN = 50;
21
27
 
22
28
  const CODES = {
23
29
  FORMAT_INVALID: 'EXTERNAL_KEY_FORMAT_INVALID',
@@ -76,6 +82,13 @@ function validate(id, objectType) {
76
82
  message: `编号格式不合规 (${type}): ${id} → ${normalized}`,
77
83
  };
78
84
  }
85
+ if ((type === 'requirement' || type === 'feature') && normalized.length > EXTERNAL_ID_MAX_LEN) {
86
+ return {
87
+ ok: false,
88
+ code: CODES.FORMAT_INVALID,
89
+ message: `编号超过 ${EXTERNAL_ID_MAX_LEN} 字符: ${normalized}`,
90
+ };
91
+ }
79
92
  if (type === 'scenario') {
80
93
  const parts = parseScenario(normalized);
81
94
  if (!parts) {
@@ -150,6 +163,7 @@ module.exports = {
150
163
  SLUG_MAX_LEN,
151
164
  SCENARIO_KEY_MAX_LEN,
152
165
  SCN_SEQ_MAX,
166
+ EXTERNAL_ID_MAX_LEN,
153
167
  normalize,
154
168
  validate,
155
169
  parseScenario,
@@ -59,19 +59,39 @@ function generateUuidV7() {
59
59
  return generateIdentityId();
60
60
  }
61
61
 
62
+ /**
63
+ * Normalize a UUID value to the internal 8-hex short format.
64
+ *
65
+ * KB backend (PostgreSQL) serializes UUID columns as full 36-char format
66
+ * (e.g. "3518f551-0000-0000-0000-000000000000"). The SDD toolchain uses
67
+ * an 8-hex short format internally (e.g. "3518f551"). This function
68
+ * accepts both and returns the short form.
69
+ */
70
+ function normalizeUuid(value) {
71
+ 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
+ if (fullUuidMatch) {
74
+ return fullUuidMatch[1];
75
+ }
76
+ return v;
77
+ }
78
+
62
79
  function isValidUuid(value) {
63
- return new RegExp(`^[0-9a-f]{${ID_HEX_LENGTH}}$`).test(String(value || '').trim().toLowerCase());
80
+ const v = String(value || '').trim().toLowerCase();
81
+ // Accept 8-hex short format (internal) OR 36-char full UUID (from KB API)
82
+ return /^[0-9a-f]{8}$/.test(v)
83
+ || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(v);
64
84
  }
65
85
 
66
86
  const isValidUuidV7 = isValidUuid;
67
87
 
68
88
  function allocateIdentity(options = {}) {
69
89
  const deltaState = String(options.deltaState || options['delta-state'] || 'added').trim().toLowerCase();
70
- const entityId = String(options.entityId || options['entity-id'] || '').trim().toLowerCase();
71
- const versionId = String(options.versionId || options['version-id'] || '').trim().toLowerCase();
72
- const predecessorVersionId = String(
90
+ const entityId = normalizeUuid(options.entityId || options['entity-id'] || '');
91
+ const versionId = normalizeUuid(options.versionId || options['version-id'] || '');
92
+ const predecessorVersionId = normalizeUuid(
73
93
  options.predecessorVersionId || options['predecessor-version'] || '',
74
- ).trim().toLowerCase();
94
+ );
75
95
 
76
96
  if (!['added', 'modified', 'unchanged', 'removed'].includes(deltaState)) {
77
97
  throw new Error(`不支持的 delta-state: ${deltaState}`);
@@ -119,5 +139,6 @@ module.exports = {
119
139
  generateUuidV7,
120
140
  isValidUuid,
121
141
  isValidUuidV7,
142
+ normalizeUuid,
122
143
  allocateIdentity,
123
144
  };
@@ -9,6 +9,7 @@ const {
9
9
  ONTOLOGY_STATE_FILES,
10
10
  resolveOntologyStateFile,
11
11
  } = require('./ontology-paths.cjs');
12
+ const { readJsonSafe } = require('../lib/shared.cjs');
12
13
 
13
14
  function catalogDiagnostic(code, message, context = {}) {
14
15
  return {
@@ -22,13 +23,8 @@ function catalogDiagnostic(code, message, context = {}) {
22
23
  };
23
24
  }
24
25
 
25
- function readJson(filePath) {
26
- try {
27
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
28
- } catch {
29
- return null;
30
- }
31
- }
26
+ // readJson 已迁移至 lib/shared.cjs(readJsonSafe 版本,返回 null 而非抛出)
27
+ const readJson = readJsonSafe;
32
28
 
33
29
  function identityRoots(projectRoot) {
34
30
  const root = path.resolve(projectRoot || process.cwd());
@@ -5,13 +5,27 @@ const path = require('path');
5
5
  const sddConfig = require('./sdd-config.cjs');
6
6
  const workspaceLayout = require('./workspace-layout.cjs');
7
7
 
8
+ /**
9
+ * 从 anchorRoot 向上遍历目录树,查找 .sdd-spec-root 配置文件。
10
+ * 修复:当 shim 从子目录(如 sdd-sdd-specs/)运行时,cwd 不是 workspace 根目录,
11
+ * 需要向上查找到 .sdd-spec-root 所在的祖先目录。
12
+ */
8
13
  function resolveHint(anchorRoot) {
9
- const hintPath = path.join(anchorRoot, '.sdd-spec-root');
10
- if (!fs.existsSync(hintPath)) return null;
11
- const hint = fs.readFileSync(hintPath, 'utf8').trim();
12
- if (!hint) return null;
13
- const resolved = path.resolve(anchorRoot, hint);
14
- return fs.existsSync(resolved) ? resolved : null;
14
+ let current = path.resolve(anchorRoot);
15
+ const root = path.parse(current).root;
16
+ for (;;) {
17
+ const hintPath = path.join(current, '.sdd-spec-root');
18
+ if (fs.existsSync(hintPath)) {
19
+ const hint = fs.readFileSync(hintPath, 'utf8').trim();
20
+ if (hint) {
21
+ const resolved = path.resolve(current, hint);
22
+ if (fs.existsSync(resolved)) return resolved;
23
+ }
24
+ }
25
+ if (current === root) break;
26
+ current = path.dirname(current);
27
+ }
28
+ return null;
15
29
  }
16
30
 
17
31
  function resolveSpecProjectRoot(projectRoot = process.cwd()) {