kld-sdd 2.6.17 → 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.
@@ -1375,30 +1375,35 @@ function appendEventUnlocked(dataDir, changeName, event) {
1375
1375
  appendMarkdownLog(dataDir, changeName, event);
1376
1376
  }
1377
1377
 
1378
- /** 追加一行 JSONL 到事件文件;同一 change 的事实写入和日志投影共享跨进程锁。 */
1379
- function appendEvent(dataDir, changeName, event, options = {}) {
1380
- if (options.lockHeld) {
1381
- return appendEventUnlocked(dataDir, changeName, event);
1382
- }
1383
- return withEventWriteLock(dataDir, changeName, () => {
1384
- if (event?.type === 'stage_end') {
1385
- const existing = readAllEventEndsById(dataDir, event.event_id, changeName);
1386
- if (existing.length > 0) {
1387
- const latest = existing.sort((a, b) => timestampMs(b) - timestampMs(a))[0];
1388
- if (latest.result !== event.result) {
1389
- const error = new Error(
1390
- `E_STAGE_END_CONFLICT: event_id "${event.event_id}" 已记录 result=${latest.result},不能改为 ${event.result}`,
1391
- );
1392
- error.code = 'E_STAGE_END_CONFLICT';
1393
- throw error;
1394
- }
1395
- return { idempotent: true, event: latest };
1396
- }
1397
- }
1398
- appendEventUnlocked(dataDir, changeName, event);
1399
- return { idempotent: false, event };
1400
- });
1401
- }
1378
+ /** 追加一行 JSONL 到事件文件;同一 change 的事实写入和日志投影共享跨进程锁。
1379
+ * kld-T06:Progress Runtime 已删除,appendEvent 仅负责本地持久化,不再做事件富化、
1380
+ * agent 拉起、metadata refresh 或 artifact snapshot。
1381
+ */
1382
+ function appendEvent(dataDir, changeName, event, options = {}) {
1383
+ if (options.lockHeld) {
1384
+ appendEventUnlocked(dataDir, changeName, event);
1385
+ return { idempotent: false, event };
1386
+ }
1387
+ const result = withEventWriteLock(dataDir, changeName, () => {
1388
+ if (event?.type === 'stage_end') {
1389
+ const existing = readAllEventEndsById(dataDir, event.event_id, changeName);
1390
+ if (existing.length > 0) {
1391
+ const latest = existing.sort((a, b) => timestampMs(b) - timestampMs(a))[0];
1392
+ if (latest.result !== event.result) {
1393
+ const error = new Error(
1394
+ `E_STAGE_END_CONFLICT: event_id "${event.event_id}" 已记录 result=${latest.result},不能改为 ${event.result}`,
1395
+ );
1396
+ error.code = 'E_STAGE_END_CONFLICT';
1397
+ throw error;
1398
+ }
1399
+ return { idempotent: true, event: latest };
1400
+ }
1401
+ }
1402
+ appendEventUnlocked(dataDir, changeName, event);
1403
+ return { idempotent: false, event };
1404
+ });
1405
+ return result;
1406
+ }
1402
1407
 
1403
1408
  /** 把 ISO 时间戳格式化为本地时区 `YYYY-MM-DD HH:mm:ss (UTC±HH:MM)`。
1404
1409
  * execution-log 面向人读,统一用本地时区 + 显式偏移标注,避免 UTC 裸值与用户体感差 8 小时(N9)。 */
@@ -5385,6 +5390,20 @@ function cmdStart(args) {
5385
5390
  appendEvent(dataDir, event.change, event);
5386
5391
  writeActiveStage(projectRoot, event);
5387
5392
 
5393
+ // kld-T05: 七值命令成功写入 stage_start 后,异步上报使用事件。
5394
+ // test/explore 与失败路径不上报;上报失败不影响本地事件与退出码。
5395
+ const usagePromise = (() => {
5396
+ try {
5397
+ const { mapCommandToUsageSkill } = require('./lib/usage-contract.cjs');
5398
+ const skill = mapCommandToUsageSkill(command);
5399
+ if (!skill) return null;
5400
+ const { reportUsageOnce } = require('./lib/usage-reporter.cjs');
5401
+ return reportUsageOnce({ skill, startedAt: timestamp }).catch(() => null);
5402
+ } catch {
5403
+ return null;
5404
+ }
5405
+ })();
5406
+
5388
5407
  // 输出 JSON,方便 AI Agent 解析 event_id
5389
5408
  const output = {
5390
5409
  event_id: eventId,
@@ -5392,6 +5411,9 @@ function cmdStart(args) {
5392
5411
  message: `SDD ${command} 阶段开始记录(change: ${event.change})`,
5393
5412
  };
5394
5413
  console.log(JSON.stringify(output, null, 2));
5414
+ // 返回 usage 上报 promise(可能为 null),便于测试或调用方 await;
5415
+ // 现有 main() 不读返回值,行为兼容。
5416
+ return usagePromise;
5395
5417
  }
5396
5418
 
5397
5419
  const ONTOLOGY_AUTHORING_STAGES = new Set(['propose', 'spec', 'design', 'task', 'check']);
@@ -5738,6 +5760,9 @@ function cmdEnd(args, options = {}) {
5738
5760
  ...event.details.archive_result,
5739
5761
  ...actualPaths,
5740
5762
  };
5763
+ if (event.details.archive_result.archive_id && !event.archive_id) {
5764
+ event.archive_id = event.details.archive_result.archive_id;
5765
+ }
5741
5766
  }
5742
5767
 
5743
5768
  appendEvent(dataDir, event.change, event);
@@ -5745,6 +5770,37 @@ function cmdEnd(args, options = {}) {
5745
5770
  clearActiveStage(projectRoot, startEvent);
5746
5771
  }
5747
5772
 
5773
+ // Archive stage_end is remapped to archive_linked on the wire. Also emit an explicit
5774
+ // run_ended so monitors that only listen for terminal kinds still close the run.
5775
+ if (command === 'archive' && (result === 'success' || result === 'partial' || result === 'failed' || result === 'failure')) {
5776
+ try {
5777
+ const terminalResult = result === 'failure' ? 'failed' : result;
5778
+ appendEvent(dataDir, event.change, cleanOptionalFields({
5779
+ schema_version: SCHEMA_VERSION,
5780
+ event_id: generateEventId(),
5781
+ type: 'stage_end',
5782
+ event_kind: 'run_ended',
5783
+ source: event.source,
5784
+ command: 'archive',
5785
+ stage: 'archive',
5786
+ change: event.change,
5787
+ agent_type: event.agent_type,
5788
+ project_root: projectRoot,
5789
+ session_id: event.session_id,
5790
+ git_sha: event.git_sha,
5791
+ timestamp: nowISO(),
5792
+ result: terminalResult,
5793
+ summary: event.summary || '变更归档完成,运行已结束',
5794
+ details: {
5795
+ terminal_of: event.event_id,
5796
+ archive_id: event.details?.archive_result?.archive_id,
5797
+ },
5798
+ }));
5799
+ } catch (err) {
5800
+ try { console.error(`[telemetry] run_ended 补发失败(不阻塞): ${err.message}`); } catch {}
5801
+ }
5802
+ }
5803
+
5748
5804
  // 终态摘要必须基于已经落库的 archive stage_end。
5749
5805
  if (command === 'archive') {
5750
5806
  try { appendChangeSummary(projectRoot, change, readEvents(dataDir, change)); } catch (err) {
@@ -6246,6 +6302,64 @@ function normalizeStrictFinalOutputSnapshot(details) {
6246
6302
  };
6247
6303
  }
6248
6304
 
6305
+ /**
6306
+ * Best-effort per-file byte sizes for apply delivery snapshots.
6307
+ * Looks under projectRoot first, then its parent (split specs/code workspace).
6308
+ * Never fails the record path — missing files simply omit size entries.
6309
+ */
6310
+ function attachFinalOutputFileSizes(details, projectRoot) {
6311
+ const snapshot = details?.final_output_snapshot;
6312
+ if (!snapshot || !Array.isArray(snapshot.files) || snapshot.files.length === 0) {
6313
+ return details;
6314
+ }
6315
+ const existing = (snapshot.file_sizes && typeof snapshot.file_sizes === 'object'
6316
+ && !Array.isArray(snapshot.file_sizes))
6317
+ ? { ...snapshot.file_sizes }
6318
+ : {};
6319
+ const roots = [];
6320
+ const pushRoot = (candidate) => {
6321
+ if (!candidate || typeof candidate !== 'string') return;
6322
+ const resolved = path.resolve(candidate);
6323
+ if (!roots.some((item) => item.toLowerCase?.() === resolved.toLowerCase() || item === resolved)) {
6324
+ roots.push(resolved);
6325
+ }
6326
+ };
6327
+ pushRoot(projectRoot);
6328
+ if (projectRoot) pushRoot(path.dirname(projectRoot));
6329
+
6330
+ let changed = false;
6331
+ for (const relative of snapshot.files) {
6332
+ const rel = String(relative || '').trim().replace(/\\/g, '/');
6333
+ if (!rel || Number.isInteger(existing[rel])) continue;
6334
+ for (const root of roots) {
6335
+ try {
6336
+ const absolute = path.resolve(root, ...rel.split('/'));
6337
+ const rootKey = process.platform === 'win32' ? root.toLowerCase() : root;
6338
+ const absKey = process.platform === 'win32' ? absolute.toLowerCase() : absolute;
6339
+ const prefix = rootKey.endsWith(path.sep) ? rootKey : `${rootKey}${path.sep}`;
6340
+ if (absKey !== rootKey && !absKey.startsWith(prefix)) continue;
6341
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) continue;
6342
+ const bytes = fs.statSync(absolute).size;
6343
+ if (Number.isInteger(bytes) && bytes >= 0) {
6344
+ existing[rel] = bytes;
6345
+ changed = true;
6346
+ break;
6347
+ }
6348
+ } catch {
6349
+ // try next root
6350
+ }
6351
+ }
6352
+ }
6353
+ if (!changed && Object.keys(existing).length === 0) return details;
6354
+ return {
6355
+ ...details,
6356
+ final_output_snapshot: {
6357
+ ...snapshot,
6358
+ file_sizes: existing,
6359
+ },
6360
+ };
6361
+ }
6362
+
6249
6363
  function normalizeStrictTestResult(args, details) {
6250
6364
  const input = details?.test_results;
6251
6365
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
@@ -7362,7 +7476,10 @@ function cmdRecord(args) {
7362
7476
  details = normalizeStrictCheckResult(details, args.result);
7363
7477
  } else if (strict && type === 'final_output_snapshot') {
7364
7478
  strictRunId = requireStrictString(strictRunId, 'EVENT_RUN_ID_REQUIRED', 'run_id');
7365
- details = normalizeStrictFinalOutputSnapshot(details);
7479
+ details = attachFinalOutputFileSizes(
7480
+ normalizeStrictFinalOutputSnapshot(details),
7481
+ projectRoot,
7482
+ );
7366
7483
  } else if (strict) {
7367
7484
  strictRunId = requireStrictString(strictRunId, 'EVENT_RUN_ID_REQUIRED', 'run_id');
7368
7485
  }
@@ -347,12 +347,27 @@ function loadKbState(anchorDir = __dirname, env = process.env) {
347
347
  return { config: null, path: null, warnings };
348
348
  }
349
349
 
350
+ /** 监控项目绑定名长度上限:与 batch schema project_name maxLength 对齐。 */
351
+ const PROJECT_NAME_MAX_LENGTH = 128;
352
+
353
+ /**
354
+ * 归一化监控项目绑定名:非字符串或 trim 后为空 → null。
355
+ *
356
+ * @param {*} value
357
+ * @returns {string|null}
358
+ */
359
+ function normalizeProjectName(value) {
360
+ if (typeof value !== 'string') return null;
361
+ const trimmed = value.trim();
362
+ return trimmed === '' ? null : trimmed;
363
+ }
364
+
350
365
  /**
351
366
  * 从 KB 配置 + 环境变量解析 KB 连接参数。
352
367
  *
353
368
  * @param {object} stateConfig — loadKbState().config
354
369
  * @param {object} [env=process.env]
355
- * @returns {{ apiBase: string, token: string, spaceId: string, kbId: string, targets: array }}
370
+ * @returns {{ apiBase: string, token: string, spaceId: string, kbId: string, targets: array, projectName: string|null }}
356
371
  */
357
372
  function resolveKbParams(stateConfig = {}, env = process.env) {
358
373
  const apiBase = stateConfig.api || env.ENGINEERING_KB_API || 'http://localhost:8090/api';
@@ -361,7 +376,136 @@ function resolveKbParams(stateConfig = {}, env = process.env) {
361
376
  ? stateConfig.targets[0].spaceId : '';
362
377
  const kbId = stateConfig.targets && stateConfig.targets.length > 0
363
378
  ? stateConfig.targets[0].kbId : '';
364
- return { apiBase, token, spaceId, kbId, targets: stateConfig.targets || [] };
379
+ const projectName = normalizeProjectName(stateConfig.projectName)
380
+ || normalizeProjectName(env.ENGINEERING_KB_PROJECT_NAME);
381
+ return { apiBase, token, spaceId, kbId, targets: stateConfig.targets || [], projectName };
382
+ }
383
+
384
+ /**
385
+ * Normalize API client scope list from kb-state or env.
386
+ * @param {object} stateConfig
387
+ * @param {object} [env=process.env]
388
+ * @returns {string[]}
389
+ */
390
+ function resolveKbScopes(stateConfig = {}, env = process.env) {
391
+ const fromState = stateConfig.scopes
392
+ || stateConfig.apiKeyScopes
393
+ || stateConfig.api_client_scopes;
394
+ if (Array.isArray(fromState)) {
395
+ return fromState.map((scope) => String(scope).trim()).filter(Boolean);
396
+ }
397
+ if (typeof fromState === 'string' && fromState.trim()) {
398
+ return fromState.split(/[\s,]+/).map((scope) => scope.trim()).filter(Boolean);
399
+ }
400
+ const fromEnv = env.ENGINEERING_KB_SCOPES || env.ENGINEERING_KB_API_SCOPES;
401
+ if (typeof fromEnv === 'string' && fromEnv.trim()) {
402
+ return fromEnv.split(/[\s,]+/).map((scope) => scope.trim()).filter(Boolean);
403
+ }
404
+ return [];
405
+ }
406
+
407
+ // kld-T06: progress:write 校验、progress binding 读取已随 Progress Runtime 一并删除。
408
+ // Archive/Knowledge 的 api_client 能力由 resolveKbParams / httpGetJson / uploadFile 等保留。
409
+
410
+ /**
411
+ * 通过服务端 dimensions 端点校验当前 API Key 用户是否为指定项目成员。
412
+ *
413
+ * 用于 kb-config 配置期的项目绑定预校验;服务端 A3 仍在上传时做权威裁决
414
+ * (非成员置空、不阻断),因此本函数对端点缺失/网络失败软返回而不抛错。
415
+ *
416
+ * @param {string} specRoot — spec 包根目录(含 kb-state.json)
417
+ * @param {string} projectName — 待校验的项目名称
418
+ * @param {object} [options]
419
+ * @param {object} [options.env=process.env]
420
+ * @param {number} [options.timeoutMs=15000]
421
+ * @returns {Promise<{ ok: boolean, reason?: string, member: boolean|null, matchedProject?: object }>}
422
+ * @throws {Error} E_PROGRESS_BINDING_INCOMPLETE — kb-state 缺失或缺 api/apiKey
423
+ */
424
+ async function verifyProjectMembershipViaServer(specRoot, projectName, options = {}) {
425
+ const env = options.env || process.env;
426
+ const normalized = normalizeProjectName(projectName);
427
+ if (!normalized || normalized.length > PROJECT_NAME_MAX_LENGTH) {
428
+ return { ok: false, reason: 'invalid_input', member: null };
429
+ }
430
+
431
+ const root = path.resolve(specRoot);
432
+ const { config: kbState, path: statePath } = loadKbState(root, env);
433
+ if (!kbState || !statePath) {
434
+ const error = new Error('kb-state.json not found');
435
+ error.code = 'E_PROGRESS_BINDING_INCOMPLETE';
436
+ error.missingFields = ['kb-state.json'];
437
+ throw error;
438
+ }
439
+
440
+ const kbParams = resolveKbParams(kbState, env);
441
+ const missing = [];
442
+ if (!kbParams.apiBase) missing.push('api');
443
+ if (!kbParams.token) missing.push('apiKey');
444
+ if (missing.length > 0) {
445
+ const error = new Error(`Progress binding incomplete: missing ${missing.join(', ')}`);
446
+ error.code = 'E_PROGRESS_BINDING_INCOMPLETE';
447
+ error.missingFields = missing;
448
+ throw error;
449
+ }
450
+
451
+ const apiBase = String(kbParams.apiBase).replace(/\/+$/, '');
452
+ const url = `${apiBase}/v1/sdd-runs/dimensions`;
453
+ let response;
454
+ try {
455
+ response = await httpGetJson(url, kbParams.token, options.timeoutMs || 15000);
456
+ } catch (error) {
457
+ // 鉴权失败(密钥失效/吊销)与端点缺失/网络异常分开归因,避免配置流程误导。
458
+ const statusMatch = error && /^HTTP (\d{3})\b/.exec(String(error.message || ''));
459
+ if (statusMatch && (statusMatch[1] === '401' || statusMatch[1] === '403')) {
460
+ return { ok: false, reason: 'auth_failed', member: null };
461
+ }
462
+ return { ok: false, reason: 'endpoint_unavailable', member: null };
463
+ }
464
+
465
+ const data = response && typeof response === 'object' && response.data != null
466
+ ? response.data
467
+ : response;
468
+ const projects = Array.isArray(data?.projects) ? data.projects : [];
469
+ const matched = projects.find((p) => {
470
+ const name = normalizeProjectName(p && (p.name || p.projectName));
471
+ return name === normalized;
472
+ });
473
+ return {
474
+ ok: true,
475
+ member: Boolean(matched),
476
+ matchedProject: matched
477
+ ? { id: matched.projectId || matched.id || null, name: normalized }
478
+ : undefined,
479
+ };
480
+ }
481
+
482
+ /**
483
+ * 原子写入/清除 kb-state.json 的 projectName(监控项目绑定),保留其余键。
484
+ *
485
+ * @param {string} specRoot — spec 包根目录
486
+ * @param {string|null} projectName — 项目名;null/空白 = 清除绑定
487
+ * @returns {{ ok: true, path: string, projectName: string|null }}
488
+ * @throws {Error} E_PROGRESS_BINDING_INCOMPLETE — kb-state.json 不存在
489
+ */
490
+ function setKbProjectName(specRoot, projectName, options = {}) {
491
+ const env = options.env || process.env;
492
+ const root = path.resolve(specRoot);
493
+ const { config: kbState, path: statePath } = loadKbState(root, env);
494
+ if (!kbState || !statePath) {
495
+ const error = new Error('kb-state.json not found');
496
+ error.code = 'E_PROGRESS_BINDING_INCOMPLETE';
497
+ error.missingFields = ['kb-state.json'];
498
+ throw error;
499
+ }
500
+ const normalized = normalizeProjectName(projectName);
501
+ if (normalized && normalized.length > PROJECT_NAME_MAX_LENGTH) {
502
+ const error = new Error(`projectName 超长(>${PROJECT_NAME_MAX_LENGTH} 字符)`);
503
+ error.code = 'E_PROJECT_NAME_INVALID';
504
+ throw error;
505
+ }
506
+ const next = { ...kbState, projectName: normalized };
507
+ writeJsonAtomic(statePath, next);
508
+ return { ok: true, path: statePath, projectName: next.projectName };
365
509
  }
366
510
 
367
511
  // ═══════════════════════════════════════════════════════════════
@@ -656,6 +800,10 @@ module.exports = {
656
800
  IDE_DIRS,
657
801
  loadKbState,
658
802
  resolveKbParams,
803
+ resolveKbScopes,
804
+ normalizeProjectName,
805
+ verifyProjectMembershipViaServer,
806
+ setKbProjectName,
659
807
  // HTTP
660
808
  requestJson,
661
809
  httpGetJson,
@@ -0,0 +1,276 @@
1
+ // kld-T01 — CLI 使用统计上报契约
2
+ // 集中定义:七值 skill 枚举、Device Flow 信封、ApiResponse 解包、
3
+ // Node 14 兼容的 URL/HTTP 基础函数。与 kb-sdd 跨仓契约保持一致。
4
+ 'use strict';
5
+
6
+ const http = require('node:http');
7
+ const https = require('node:https');
8
+
9
+ /**
10
+ * 七值使用统计 skill 枚举。
11
+ * test / explore 不属于统计范围,永不映射。
12
+ */
13
+ const USAGE_SKILLS = Object.freeze([
14
+ 'propose',
15
+ 'spec',
16
+ 'design',
17
+ 'task',
18
+ 'check',
19
+ 'apply',
20
+ 'archive',
21
+ ]);
22
+
23
+ const USAGE_SKILL_SET = new Set(USAGE_SKILLS);
24
+
25
+ /**
26
+ * 将 opsx 命令映射到使用统计 skill。
27
+ * @param {string} command
28
+ * @returns {string|null} 七值之一;非七值返回 null
29
+ */
30
+ function mapCommandToUsageSkill(command) {
31
+ if (typeof command !== 'string' || !command) return null;
32
+ return USAGE_SKILL_SET.has(command) ? command : null;
33
+ }
34
+
35
+ /**
36
+ * 规范化 server URL:只接受 http/https,去尾斜杠,去首尾空白。
37
+ * @param {string} raw
38
+ * @returns {string|null} 规范化后的 base URL;非法返回 null
39
+ */
40
+ function normalizeServerUrl(raw) {
41
+ if (typeof raw !== 'string') return null;
42
+ const trimmed = raw.trim();
43
+ if (!trimmed) return null;
44
+ let parsed;
45
+ try {
46
+ parsed = new URL(trimmed);
47
+ } catch {
48
+ return null;
49
+ }
50
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
51
+ // 重组:protocol + // + host + pathname(去尾斜杠),忽略 search/hash
52
+ const path = parsed.pathname.replace(/\/+$/, '');
53
+ return `${parsed.protocol}//${parsed.host}${path}`;
54
+ }
55
+
56
+ /**
57
+ * 解开 ApiResponse 信封:{ code, message, data } → data;code 非 0 抛错。
58
+ * @param {object} body — 已 JSON.parse 的响应体
59
+ * @returns {*} data 字段内容
60
+ * @throws {Error} code 非 0、非对象、缺 code 字段
61
+ */
62
+ function unwrapApiResponse(body) {
63
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
64
+ throw new Error('ApiResponse must be an object');
65
+ }
66
+ if (typeof body.code !== 'number') {
67
+ throw new Error('ApiResponse missing numeric code');
68
+ }
69
+ if (body.code !== 0) {
70
+ const msg = typeof body.message === 'string' && body.message
71
+ ? body.message
72
+ : `server error code ${body.code}`;
73
+ throw new Error(msg);
74
+ }
75
+ return body.data === undefined ? null : body.data;
76
+ }
77
+
78
+ /**
79
+ * 解析 Device Flow code 响应。
80
+ * kb-sdd 契约:{ deviceCode, userCode, verificationUri, verificationUriComplete?, expiresIn?, interval? }
81
+ * @param {object} body
82
+ * @returns {{deviceCode:string,userCode:string,verificationUri:string,verificationUriComplete:string|null,expiresIn:number,interval:number}|null}
83
+ */
84
+ function parseDeviceCodeResponse(body) {
85
+ if (!body || typeof body !== 'object') return null;
86
+ if (typeof body.deviceCode !== 'string' || !body.deviceCode) return null;
87
+ if (typeof body.userCode !== 'string' || !body.userCode) return null;
88
+ if (typeof body.verificationUri !== 'string' || !body.verificationUri) return null;
89
+ const expiresIn = Number.isFinite(body.expiresIn) && body.expiresIn > 0
90
+ ? Math.floor(body.expiresIn)
91
+ : 600;
92
+ const interval = Number.isFinite(body.interval) && body.interval > 0
93
+ ? Math.floor(body.interval)
94
+ : 2;
95
+ return {
96
+ deviceCode: body.deviceCode,
97
+ userCode: body.userCode,
98
+ verificationUri: body.verificationUri,
99
+ verificationUriComplete: typeof body.verificationUriComplete === 'string' && body.verificationUriComplete
100
+ ? body.verificationUriComplete
101
+ : null,
102
+ expiresIn,
103
+ interval,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * 分类 Device Flow poll 响应。
109
+ * kb-sdd 主契约:{ status: 'pending'|'authorized'|'expired'|'denied', token?, user? }
110
+ * 兼容 RFC 8628:{ error: 'authorization_pending'|'slow_down'|'expired_token'|'access_denied' }
111
+ *
112
+ * @param {number|null} statusCode — HTTP 状态码;网络错误为 null
113
+ * @param {object|null} body — 已解析 JSON;解析失败为 null
114
+ * @returns {{kind:'authorized',token:string,user:*}|{kind:'pending'}|{kind:'slow_down'}|{kind:'expired'}|{kind:'denied'}|{kind:'error',message?:string}}
115
+ */
116
+ function classifyDevicePollResponse(statusCode, body) {
117
+ const payload = body && typeof body === 'object' ? body : {};
118
+ // kb-sdd 主契约:status 字段
119
+ if (typeof payload.status === 'string') {
120
+ const s = payload.status;
121
+ if (s === 'authorized') {
122
+ if (typeof payload.token === 'string' && payload.token) {
123
+ return { kind: 'authorized', token: payload.token, user: payload.user === undefined ? null : payload.user };
124
+ }
125
+ return { kind: 'error', message: 'authorized but missing token' };
126
+ }
127
+ if (s === 'pending') return { kind: 'pending' };
128
+ if (s === 'expired') return { kind: 'expired' };
129
+ if (s === 'denied') return { kind: 'denied' };
130
+ return { kind: 'error', message: `unknown status: ${s}` };
131
+ }
132
+ // RFC 8628 兼容:error 字段
133
+ if (typeof payload.error === 'string') {
134
+ const e = payload.error;
135
+ if (e === 'authorization_pending') return { kind: 'pending' };
136
+ if (e === 'slow_down') return { kind: 'slow_down' };
137
+ if (e === 'expired_token') return { kind: 'expired' };
138
+ if (e === 'access_denied') return { kind: 'denied' };
139
+ return { kind: 'error', message: e };
140
+ }
141
+ // 无识别字段:网络错误或 HTTP 异常
142
+ if (statusCode == null) return { kind: 'error', message: 'network error' };
143
+ if (statusCode >= 400) return { kind: 'error', message: `HTTP ${statusCode}` };
144
+ return { kind: 'error', message: 'unrecognized response' };
145
+ }
146
+
147
+ /**
148
+ * 分类 usage 上报响应。
149
+ * - confirmed: 2xx/204,服务端已确认,可从 pending 队列移除
150
+ * - unauthorized: 401,token 失效;调用方应原子删除本地 token 并提示一次
151
+ * - retry: 网络错误 / 429 / 5xx / timeout;保留在 pending 队列
152
+ * - drop: 其他 4xx;不再重试但不上抛
153
+ *
154
+ * @param {number|null} statusCode
155
+ * @returns {'confirmed'|'unauthorized'|'retry'|'drop'}
156
+ */
157
+ function classifyUsageSubmitResponse(statusCode) {
158
+ if (statusCode == null || statusCode === 0) return 'retry';
159
+ if (statusCode >= 200 && statusCode < 300) return 'confirmed';
160
+ if (statusCode === 401) return 'unauthorized';
161
+ if (statusCode === 429) return 'retry';
162
+ if (statusCode >= 500) return 'retry';
163
+ return 'drop';
164
+ }
165
+
166
+ /**
167
+ * Node 14 兼容的 POST JSON 实现。
168
+ * - 仅用核心 http/https,不依赖全局 fetch
169
+ * - 任何网络/解析错误均不抛,返回 statusCode=null 让调用方分类
170
+ * - 4xx/5xx 不抛错,返回 statusCode + body 供分类
171
+ *
172
+ * @param {string} url — 完整 URL(必须 http/https)
173
+ * @param {object} payload — 请求体(JSON 序列化)
174
+ * @param {{token?:string,timeoutMs?:number,headers?:object}} [options]
175
+ * @returns {Promise<{statusCode:number|null,body:*,data:*,error:*}>}
176
+ * statusCode: HTTP 状态码,网络错误为 null
177
+ * body: 原始解析后的 JSON 对象(如可解析)
178
+ * data: 若为 ApiResponse 信封且 code=0,则为解包后的 data;否则为 undefined
179
+ * error: 仅在 statusCode=null 时携带 Error;否则为 null
180
+ */
181
+ function postJson(url, payload, options = {}) {
182
+ return new Promise((resolve, reject) => {
183
+ let target;
184
+ try {
185
+ target = new URL(url);
186
+ } catch (err) {
187
+ reject(new Error(`invalid url: ${err.message}`));
188
+ return;
189
+ }
190
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') {
191
+ reject(new Error(`only http/https supported, got ${target.protocol}`));
192
+ return;
193
+ }
194
+ const transport = target.protocol === 'https:' ? https : http;
195
+ const token = options && typeof options.token === 'string' && options.token ? options.token : null;
196
+ const timeoutMs = Number.isFinite(options && options.timeoutMs) && options.timeoutMs > 0
197
+ ? Math.floor(options.timeoutMs)
198
+ : 30000;
199
+
200
+ let body;
201
+ try {
202
+ body = JSON.stringify(payload === undefined ? {} : payload);
203
+ } catch (err) {
204
+ reject(new Error(`payload serialization error: ${err.message}`));
205
+ return;
206
+ }
207
+
208
+ const headers = Object.assign(
209
+ {
210
+ Accept: 'application/json',
211
+ 'Content-Type': 'application/json; charset=utf-8',
212
+ 'Content-Length': Buffer.byteLength(body),
213
+ },
214
+ options && options.headers && typeof options.headers === 'object' ? options.headers : {},
215
+ );
216
+ if (token) headers.Authorization = `Bearer ${token}`;
217
+
218
+ const request = transport.request(
219
+ {
220
+ hostname: target.hostname,
221
+ port: target.port || (target.protocol === 'https:' ? 443 : 80),
222
+ path: target.pathname + target.search,
223
+ method: 'POST',
224
+ headers,
225
+ timeout: timeoutMs,
226
+ },
227
+ (response) => {
228
+ const chunks = [];
229
+ response.on('data', (chunk) => chunks.push(chunk));
230
+ response.on('end', () => {
231
+ const raw = Buffer.concat(chunks).toString('utf8');
232
+ let parsedBody = null;
233
+ if (raw) {
234
+ try {
235
+ parsedBody = JSON.parse(raw);
236
+ } catch {
237
+ parsedBody = null;
238
+ }
239
+ }
240
+ let data;
241
+ if (parsedBody && typeof parsedBody === 'object' && parsedBody.code === 0) {
242
+ try {
243
+ data = unwrapApiResponse(parsedBody);
244
+ } catch {
245
+ data = undefined;
246
+ }
247
+ }
248
+ resolve({
249
+ statusCode: response.statusCode == null ? null : response.statusCode,
250
+ body: parsedBody,
251
+ data,
252
+ error: null,
253
+ });
254
+ });
255
+ },
256
+ );
257
+ request.on('timeout', () => {
258
+ request.destroy(new Error(`request timeout after ${timeoutMs}ms`));
259
+ });
260
+ request.on('error', (err) => {
261
+ resolve({ statusCode: null, body: null, data: undefined, error: err });
262
+ });
263
+ request.end(body);
264
+ });
265
+ }
266
+
267
+ module.exports = {
268
+ USAGE_SKILLS,
269
+ mapCommandToUsageSkill,
270
+ normalizeServerUrl,
271
+ unwrapApiResponse,
272
+ parseDeviceCodeResponse,
273
+ classifyDevicePollResponse,
274
+ classifyUsageSubmitResponse,
275
+ postJson,
276
+ };