kld-sdd 2.5.0 → 2.5.2

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 (48) hide show
  1. package/README.md +95 -8
  2. package/kld-sdd-guide.html +1109 -0
  3. package/lib/command-bridge.js +156 -0
  4. package/lib/deploy-codebuddy-hooks.js +99 -0
  5. package/lib/hook-gate-core.js +333 -0
  6. package/lib/init.js +137 -82
  7. package/lib/settings-merge.js +85 -0
  8. package/lib/skills-bundle.js +142 -5
  9. package/lib/tool-profiles.js +270 -0
  10. package/package.json +4 -2
  11. package/skywalk-sdd/index.cjs +329 -24
  12. package/skywalk-sdd/ontology/artifact-observer.cjs +91 -0
  13. package/skywalk-sdd/ontology/artifact-parser.cjs +621 -0
  14. package/skywalk-sdd/ontology/change-lock.cjs +126 -0
  15. package/skywalk-sdd/ontology/cli.cjs +146 -0
  16. package/skywalk-sdd/ontology/effective-graph.cjs +158 -0
  17. package/skywalk-sdd/ontology/id.cjs +126 -0
  18. package/skywalk-sdd/ontology/identity-index.cjs +262 -0
  19. package/skywalk-sdd/ontology/normalizer.cjs +107 -0
  20. package/skywalk-sdd/ontology/runtime.cjs +341 -0
  21. package/skywalk-sdd/ontology/schema.cjs +139 -0
  22. package/skywalk-sdd/ontology/structural-identity.cjs +77 -0
  23. package/skywalk-sdd/ontology/traceability-validator.cjs +610 -0
  24. package/templates/commands/kunlunzhima/skill-bridge.md +23 -0
  25. package/templates/hooks/codebuddy/hooks/sdd-apply-gate.cjs +16 -0
  26. package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +395 -0
  27. package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +123 -0
  28. package/templates/hooks/codebuddy/hooks/sdd-pre-tool.cjs +16 -0
  29. package/templates/hooks/codebuddy/hooks/sdd-prompt.cjs +48 -0
  30. package/templates/hooks/codebuddy/hooks/sdd-skill-apply-gate.cjs +16 -0
  31. package/templates/hooks/codebuddy/hooks/sdd-stop.cjs +70 -0
  32. package/templates/hooks/codebuddy/settings.json +72 -0
  33. package/templates/openspec/design.md +18 -0
  34. package/templates/openspec/proposal.md +19 -6
  35. package/templates/openspec/spec.md +62 -8
  36. package/templates/openspec/tasks.md +28 -6
  37. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +5 -5
  38. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +9 -0
  39. package/templates/skills/kld-sdd/opsx-check/SKILL.md +17 -1
  40. package/templates/skills/kld-sdd/opsx-design/SKILL.md +10 -1
  41. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +1 -1
  42. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +11 -1
  43. package/templates/skills/kld-sdd/opsx-rules/SKILL.md +131 -0
  44. package/templates/skills/kld-sdd/opsx-rules/checklist.md +27 -0
  45. package/templates/skills/kld-sdd/opsx-rules/reference.md +124 -0
  46. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +12 -1
  47. package/templates/skills/kld-sdd/opsx-task/SKILL.md +10 -1
  48. package/templates/skills/kld-sdd/opsx-test/SKILL.md +1 -1
@@ -520,16 +520,101 @@ function syncArchivedSpecs(projectRoot, archiveDir, changeName) {
520
520
  return copiedSpecs;
521
521
  }
522
522
 
523
+ function prepareArchivedSpecSync(projectRoot, archiveDir, changeName, token) {
524
+ const operations = discoverFullSpecFiles(archiveDir, changeName).map((spec) => {
525
+ const target = path.join(projectRoot, 'openspec', 'specs', spec.capability, 'spec.md');
526
+ ensureDir(path.dirname(target));
527
+ const temp = `${target}.${token}.archive-tmp`;
528
+ const backup = `${target}.${token}.archive-bak`;
529
+ fs.copyFileSync(spec.source, temp);
530
+ if (fs.existsSync(target)) fs.copyFileSync(target, backup);
531
+ return {
532
+ capability: spec.capability,
533
+ source: spec.source,
534
+ target,
535
+ temp,
536
+ backup,
537
+ had_original: fs.existsSync(target),
538
+ committed: false,
539
+ };
540
+ });
541
+ return {
542
+ operations,
543
+ copiedSpecs: operations.map((operation) => ({
544
+ capability: operation.capability,
545
+ source: toProjectRelative(projectRoot, operation.source),
546
+ target: toProjectRelative(projectRoot, operation.target),
547
+ })),
548
+ };
549
+ }
550
+
551
+ function commitArchivedSpecSync(transaction, failAt = '') {
552
+ for (const operation of transaction.operations) {
553
+ if (fs.existsSync(operation.target)) fs.rmSync(operation.target, { force: true });
554
+ fs.renameSync(operation.temp, operation.target);
555
+ operation.committed = true;
556
+ if (failAt === 'during-spec-sync') throw new Error('during-spec-sync');
557
+ }
558
+ }
559
+
560
+ function rollbackArchivedSpecSync(transaction) {
561
+ if (!transaction || !transaction.operations) return;
562
+ for (const operation of [...transaction.operations].reverse()) {
563
+ if (fs.existsSync(operation.backup)) {
564
+ fs.rmSync(operation.target, { force: true });
565
+ fs.renameSync(operation.backup, operation.target);
566
+ } else if (operation.committed) {
567
+ fs.rmSync(operation.target, { force: true });
568
+ }
569
+ fs.rmSync(operation.temp, { force: true });
570
+ fs.rmSync(operation.backup, { force: true });
571
+ }
572
+ }
573
+
574
+ function cleanupArchivedSpecSync(transaction) {
575
+ if (!transaction || !transaction.operations) return;
576
+ for (const operation of transaction.operations) {
577
+ fs.rmSync(operation.temp, { force: true });
578
+ fs.rmSync(operation.backup, { force: true });
579
+ }
580
+ }
581
+
582
+ function assertConfirmedArchive(projectRoot, changeName, archiveDir, options = {}) {
583
+ const { parseChangeArtifacts } = require('./ontology/artifact-parser.cjs');
584
+ const { normalizeFacts } = require('./ontology/normalizer.cjs');
585
+ const { validateArchiveCandidate } = require('./ontology/identity-index.cjs');
586
+ const facts = normalizeFacts(parseChangeArtifacts(projectRoot, changeName, {
587
+ changeDir: archiveDir,
588
+ persistStructuralIdentities: false,
589
+ }));
590
+ const validation = validateArchiveCandidate({
591
+ change: path.basename(archiveDir),
592
+ changeDir: archiveDir,
593
+ expectedArchiveDir: options.expectedArchiveDir,
594
+ }, facts);
595
+ if (!validation.eligible) {
596
+ const codes = validation.diagnostics.map((item) => item.code).join(', ');
597
+ throw new Error(`Archive 未通过 confirmed 事实校验: ${codes}`);
598
+ }
599
+ return { facts, snapshot: validation.snapshot, manifest: validation.manifest };
600
+ }
601
+
523
602
  function ensureArchiveManifest(projectRoot, changeName, archiveDir, options = {}) {
524
603
  const manifestPath = path.join(archiveDir, 'archive-manifest.json');
525
- const copiedSpecs = syncArchivedSpecs(projectRoot, archiveDir, changeName);
604
+ const copiedSpecs = Array.isArray(options.copiedSpecs)
605
+ ? options.copiedSpecs
606
+ : syncArchivedSpecs(projectRoot, archiveDir, changeName);
607
+ const committedArchiveDir = options.archivePath || archiveDir;
526
608
  // U4: 归档时拷 events jsonl 进 archive/evidence/events/,报告可从归档目录重建(源数据与产物不再分离)
527
609
  const eventsSrcDir = path.join(getDataDir(projectRoot), 'events', safeChangeName(changeName));
528
610
  let evidenceEventsPath = null;
529
611
  if (fs.existsSync(eventsSrcDir)) {
530
612
  const eventsDstDir = path.join(archiveDir, 'evidence', 'events');
531
613
  copyDirSync(eventsSrcDir, eventsDstDir);
532
- evidenceEventsPath = toProjectRelative(projectRoot, eventsDstDir);
614
+ evidenceEventsPath = toProjectRelative(
615
+ projectRoot,
616
+ path.join(committedArchiveDir, 'evidence', 'events'),
617
+ );
533
618
  }
534
619
  const manifest = {
535
620
  change: changeName,
@@ -537,7 +622,7 @@ function ensureArchiveManifest(projectRoot, changeName, archiveDir, options = {}
537
622
  reason: options.reason || '变更已完成实施',
538
623
  method: options.method || 'skywalk-full-spec-archive',
539
624
  source_path: `openspec/changes/${changeName}`,
540
- archive_path: toProjectRelative(projectRoot, archiveDir),
625
+ archive_path: toProjectRelative(projectRoot, committedArchiveDir),
541
626
  copied_specs: copiedSpecs,
542
627
  evidence_events_path: evidenceEventsPath,
543
628
  };
@@ -574,6 +659,8 @@ function ensureArchiveSuccessArtifacts(projectRoot, changeName, details = {}, op
574
659
  };
575
660
  }
576
661
 
662
+ assertConfirmedArchive(normalizedRoot, changeName, archiveDir);
663
+
577
664
  // 显式 reportOutput 优先;否则默认落归档后 archive 目录的 reports/ 子目录
578
665
  const reportPath = options.reportOutput
579
666
  ? (path.isAbsolute(options.reportOutput) ? options.reportOutput : path.resolve(normalizedRoot, options.reportOutput))
@@ -632,30 +719,110 @@ function archiveChangeDocs(projectRoot, changeName, options = {}) {
632
719
  ensureDir(archiveRoot);
633
720
  const archiveDate = options.date || today();
634
721
  const archiveDir = nextAvailableDir(archiveRoot, `${archiveDate}-${changeName}`);
722
+ const { acquireChangeLock, releaseChangeLock } = require('./ontology/change-lock.cjs');
723
+ const ontologyRuntime = require('./ontology/runtime.cjs');
724
+ const lock = acquireChangeLock(normalizedRoot, changeName, options.lockOptions);
725
+ const token = lock.token.replace(/-/g, '');
726
+ const stagingDir = path.join(archiveRoot, `.archive-staging-${token}`);
727
+ const sourceBackup = path.join(path.dirname(sourceDir), `.archive-source-${token}`);
728
+ let specTransaction = null;
729
+ let sourceMoved = false;
730
+ let archiveCommitted = false;
731
+ try {
732
+ const semanticResult = ontologyRuntime.reconcileChange(normalizedRoot, changeName, {
733
+ profile: options.profile || 'auto',
734
+ markPending: true,
735
+ lock,
736
+ });
737
+ if (!semanticResult.valid) {
738
+ const codes = [...new Set(semanticResult.diagnostics
739
+ .filter(item => item.severity === 'error')
740
+ .map(item => item.code))];
741
+ throw new Error(`本体语义校验失败: ${codes.join(', ')}`);
742
+ }
635
743
 
636
- copyDirSync(sourceDir, archiveDir);
637
- // P2-2: 根据 task_completion 调整归档原因,has_incomplete 时不写"已完成实施"
638
- const tcForReason = scanTaskCompletionForArchiveDir(normalizedRoot, changeName, archiveDir);
639
- let archiveReason = options.reason || '变更已完成实施';
640
- if (tcForReason?.has_incomplete) {
641
- archiveReason = `部分完成(${tcForReason.incomplete} 项验收未勾选)`;
642
- }
643
- const manifestInfo = ensureArchiveManifest(normalizedRoot, changeName, archiveDir, {
644
- reason: archiveReason,
645
- method: 'skywalk-full-spec-archive',
646
- });
647
- const manifest = manifestInfo.manifest;
744
+ fs.rmSync(stagingDir, { recursive: true, force: true });
745
+ copyDirSync(sourceDir, stagingDir);
746
+ if (options.failAt === 'after-copy') throw new Error('after-copy');
747
+
748
+ ontologyRuntime.createArchiveSnapshot(
749
+ normalizedRoot,
750
+ changeName,
751
+ stagingDir,
752
+ semanticResult.state,
753
+ );
754
+ if (options.failAt === 'after-snapshot') throw new Error('after-snapshot');
755
+
756
+ const tcForReason = scanTaskCompletionForArchiveDir(normalizedRoot, changeName, stagingDir);
757
+ let archiveReason = options.reason || '变更已完成实施';
758
+ if (tcForReason?.has_incomplete) {
759
+ archiveReason = `部分完成(${tcForReason.incomplete} 项验收未勾选)`;
760
+ }
761
+ specTransaction = prepareArchivedSpecSync(
762
+ normalizedRoot,
763
+ stagingDir,
764
+ changeName,
765
+ token,
766
+ );
767
+ const manifestInfo = ensureArchiveManifest(normalizedRoot, changeName, stagingDir, {
768
+ reason: archiveReason,
769
+ method: 'skywalk-full-spec-archive',
770
+ archivePath: archiveDir,
771
+ copiedSpecs: specTransaction.copiedSpecs,
772
+ });
773
+ const confirmed = assertConfirmedArchive(normalizedRoot, changeName, stagingDir, {
774
+ expectedArchiveDir: archiveDir,
775
+ });
776
+ if (confirmed.snapshot.source_revision !== semanticResult.revision) {
777
+ throw new Error('confirmed snapshot 的 source_revision 与工作态 revision 不一致');
778
+ }
779
+ if (options.failAt === 'after-manifest') throw new Error('after-manifest');
780
+ if (typeof options._testBeforeFinalize === 'function') {
781
+ options._testBeforeFinalize({ sourceDir, stagingDir, archiveDir });
782
+ }
648
783
 
649
- if (!options.keepActive) {
650
- fs.rmSync(sourceDir, { recursive: true, force: true });
651
- }
784
+ const activeFacts = require('./ontology/normalizer.cjs').normalizeFacts(
785
+ require('./ontology/artifact-parser.cjs').parseChangeArtifacts(
786
+ normalizedRoot,
787
+ changeName,
788
+ { changeDir: sourceDir, persistStructuralIdentities: false },
789
+ ),
790
+ );
791
+ if (activeFacts.facts_hash !== semanticResult.state.facts_hash) {
792
+ throw new Error('活动 Change 在归档事务期间发生变化');
793
+ }
652
794
 
653
- return {
654
- ...manifest,
655
- project_root: normalizedRoot,
656
- archive_path: archiveDir,
657
- active_change_exists: fs.existsSync(sourceDir),
658
- };
795
+ if (!options.keepActive) {
796
+ fs.renameSync(sourceDir, sourceBackup);
797
+ sourceMoved = true;
798
+ }
799
+ if (options.failAt === 'after-source-move') throw new Error('after-source-move');
800
+ fs.renameSync(stagingDir, archiveDir);
801
+ archiveCommitted = true;
802
+ commitArchivedSpecSync(specTransaction, options.failAt);
803
+ if (sourceMoved) fs.rmSync(sourceBackup, { recursive: true, force: true });
804
+ cleanupArchivedSpecSync(specTransaction);
805
+
806
+ return {
807
+ ...manifestInfo.manifest,
808
+ project_root: normalizedRoot,
809
+ archive_path: archiveDir,
810
+ active_change_exists: fs.existsSync(sourceDir),
811
+ };
812
+ } catch (error) {
813
+ rollbackArchivedSpecSync(specTransaction);
814
+ if (archiveCommitted) fs.rmSync(archiveDir, { recursive: true, force: true });
815
+ fs.rmSync(stagingDir, { recursive: true, force: true });
816
+ if (sourceMoved && fs.existsSync(sourceBackup) && !fs.existsSync(sourceDir)) {
817
+ fs.renameSync(sourceBackup, sourceDir);
818
+ }
819
+ const wrapped = new Error(`归档事务失败,活动 Change 保持不变: ${error.message}`);
820
+ wrapped.code = error.code || 'SEM_ARCHIVE_TRANSACTION_FAILED';
821
+ wrapped.cause = error;
822
+ throw wrapped;
823
+ } finally {
824
+ releaseChangeLock(lock);
825
+ }
659
826
  }
660
827
 
661
828
  /** 追加一行 JSONL 到事件文件(写入失败时抛出异常) */
@@ -4133,6 +4300,106 @@ function cmdTasksStatus(args) {
4133
4300
  }
4134
4301
  }
4135
4302
 
4303
+ function semanticCommandContext(args) {
4304
+ const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
4305
+ const changeName = args.change || args['change-name'];
4306
+ if (!changeName) {
4307
+ throw new Error('缺少 --change 参数');
4308
+ }
4309
+ return {
4310
+ projectRoot,
4311
+ changeName,
4312
+ profile: args.profile || 'auto',
4313
+ };
4314
+ }
4315
+
4316
+ function cmdSemanticIdentity(args) {
4317
+ const identity = require('./ontology/id.cjs').allocateIdentity(args);
4318
+ console.log(JSON.stringify(identity, null, 2));
4319
+ return identity;
4320
+ }
4321
+
4322
+ function semanticResultPayload(result) {
4323
+ return {
4324
+ change: result.state.change,
4325
+ profile: result.profile,
4326
+ valid: result.valid,
4327
+ review_status: result.state.review_status,
4328
+ revision: result.revision,
4329
+ changed: Boolean(result.changed),
4330
+ counts: result.counts,
4331
+ paths: result.paths,
4332
+ diagnostics: result.diagnostics,
4333
+ };
4334
+ }
4335
+
4336
+ function cmdSemanticScan(args) {
4337
+ const context = semanticCommandContext(args);
4338
+ const result = require('./ontology/runtime.cjs').scanChange(
4339
+ context.projectRoot,
4340
+ context.changeName,
4341
+ { profile: context.profile },
4342
+ );
4343
+ console.log(JSON.stringify(semanticResultPayload({ ...result, changed: false, paths: undefined }), null, 2));
4344
+ if (!result.valid) process.exitCode = 1;
4345
+ return result;
4346
+ }
4347
+
4348
+ function cmdSemanticReconcile(args, options = {}) {
4349
+ const context = semanticCommandContext(args);
4350
+ const result = require('./ontology/runtime.cjs').reconcileChange(
4351
+ context.projectRoot,
4352
+ context.changeName,
4353
+ { profile: context.profile, markPending: Boolean(options.markPending) },
4354
+ );
4355
+ console.log(JSON.stringify(semanticResultPayload(result), null, 2));
4356
+ if (!result.valid) process.exitCode = 1;
4357
+ return result;
4358
+ }
4359
+
4360
+ function cmdSemanticCheck(args) {
4361
+ return cmdSemanticReconcile(args, { markPending: true });
4362
+ }
4363
+
4364
+ function cmdSemanticStatus(args) {
4365
+ const context = semanticCommandContext(args);
4366
+ const state = require('./ontology/runtime.cjs').readWorkingState(context.projectRoot, context.changeName);
4367
+ if (!state) throw new Error(`不存在工作态本体实例: ${context.changeName}`);
4368
+ console.log(JSON.stringify(state, null, 2));
4369
+ return state;
4370
+ }
4371
+
4372
+ function cmdSemanticObserve(args) {
4373
+ const context = semanticCommandContext(args);
4374
+ const observer = require('./ontology/artifact-observer.cjs').observeChangeArtifacts(
4375
+ context.projectRoot,
4376
+ context.changeName,
4377
+ {
4378
+ profile: context.profile,
4379
+ pollIntervalMs: Number(args.interval || 1500),
4380
+ onReconciled(result) {
4381
+ console.log(JSON.stringify(semanticResultPayload(result)));
4382
+ },
4383
+ onError(error) {
4384
+ console.error(`[ontology-observer] ${error.message}`);
4385
+ },
4386
+ },
4387
+ );
4388
+ console.log(JSON.stringify({
4389
+ change: context.changeName,
4390
+ mode: observer.mode,
4391
+ change_dir: observer.changeDir,
4392
+ boundary: 'observe-and-reconcile-only',
4393
+ }));
4394
+ const close = () => {
4395
+ observer.close();
4396
+ process.exit(0);
4397
+ };
4398
+ process.once('SIGINT', close);
4399
+ process.once('SIGTERM', close);
4400
+ return observer;
4401
+ }
4402
+
4136
4403
  /**
4137
4404
  * log archive-docs: 真实归档 Simple/Full spec,结束 archive 阶段并生成最终报告
4138
4405
  */
@@ -4149,6 +4416,7 @@ function cmdArchiveDocs(args) {
4149
4416
  reason: args.reason || '',
4150
4417
  date: args.date,
4151
4418
  keepActive: Boolean(args['keep-active']),
4419
+ profile: args.profile || 'auto',
4152
4420
  });
4153
4421
  } else {
4154
4422
  const repaired = ensureArchiveSuccessArtifacts(projectRoot, changeName, {
@@ -4257,6 +4525,24 @@ function main() {
4257
4525
  case 'archive-docs':
4258
4526
  cmdArchiveDocs(flags);
4259
4527
  break;
4528
+ case 'semantic-scan':
4529
+ cmdSemanticScan(flags);
4530
+ break;
4531
+ case 'semantic-identity':
4532
+ cmdSemanticIdentity(flags);
4533
+ break;
4534
+ case 'semantic-reconcile':
4535
+ cmdSemanticReconcile(flags);
4536
+ break;
4537
+ case 'semantic-check':
4538
+ cmdSemanticCheck(flags);
4539
+ break;
4540
+ case 'semantic-status':
4541
+ cmdSemanticStatus(flags);
4542
+ break;
4543
+ case 'semantic-observe':
4544
+ cmdSemanticObserve(flags);
4545
+ break;
4260
4546
  case 'check-task':
4261
4547
  cmdCheckTask(flags);
4262
4548
  break;
@@ -4278,6 +4564,13 @@ SDD Telemetry CLI - 流程度量采集工具
4278
4564
  node skywalk-sdd/log.cjs tasks-status --project=<path> --change=<name> [--require-complete]
4279
4565
  node skywalk-sdd/log.cjs check-task --project=<path> --change=<name> --task-id=<id>
4280
4566
  node skywalk-sdd/log.cjs archive-docs --project=<path> --change=<name> [--reason=<text>] [--event-id=<id>] [--report-output=<file>]
4567
+ node skywalk-sdd/log.cjs semantic-identity --delta-state=added
4568
+ node skywalk-sdd/log.cjs semantic-identity --delta-state=modified --entity-id=<uuid> --predecessor-version=<uuid>
4569
+ node skywalk-sdd/log.cjs semantic-identity --delta-state=unchanged --entity-id=<uuid> --version-id=<uuid>
4570
+ node skywalk-sdd/log.cjs semantic-reconcile --project=<path> --change=<name> [--profile=auto|simple|full|strict]
4571
+ node skywalk-sdd/log.cjs semantic-check --project=<path> --change=<name> [--profile=...]
4572
+ node skywalk-sdd/log.cjs semantic-status --project=<path> --change=<name>
4573
+ node skywalk-sdd/log.cjs semantic-observe --project=<path> --change=<name> [--interval=1500]
4281
4574
 
4282
4575
  子命令:
4283
4576
  start 记录 SDD 阶段开始,返回 event_id
@@ -4289,6 +4582,12 @@ SDD Telemetry CLI - 流程度量采集工具
4289
4582
  tasks-status 扫描 Full/Simple 模式 tasks.md 勾选状态
4290
4583
  check-task 扫描变更目录 tasks.md 并勾选指定 task_id
4291
4584
  archive-docs 将 Simple/Full spec 变更真实移动到 openspec/changes/archive/,并可结束 archive 阶段生成报告
4585
+ semantic-identity 生成或复用实体/版本 UUID;无需网络和中央 ID 服务
4586
+ semantic-scan 只读解析并校验当前 Change
4587
+ semantic-reconcile 全量对账文件与本地工作态本体实例
4588
+ semantic-check 全量对账并在通过时标记 pending
4589
+ semantic-status 读取当前工作态本体实例
4590
+ semantic-observe 观察文件变化并同步;不提供 Hook 式控制,Check/Archive 仍会全量对账
4292
4591
 
4293
4592
  示例:
4294
4593
  node skywalk-sdd/log.cjs start --command=propose --project=/my/project --change=user-auth --agent=cursor
@@ -4327,6 +4626,12 @@ module.exports = {
4327
4626
  cmdDoctor,
4328
4627
  cmdTasksStatus,
4329
4628
  cmdArchiveDocs,
4629
+ cmdSemanticIdentity,
4630
+ cmdSemanticScan,
4631
+ cmdSemanticReconcile,
4632
+ cmdSemanticCheck,
4633
+ cmdSemanticStatus,
4634
+ cmdSemanticObserve,
4330
4635
  computeChangeMetrics,
4331
4636
  computeOverviewMetrics,
4332
4637
  computeCapabilityMetrics,
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { safeChangeName, scanArtifactFiles } = require('./artifact-parser.cjs');
7
+ const { reconcileChange } = require('./runtime.cjs');
8
+
9
+ function fingerprint(changeDir) {
10
+ const files = scanArtifactFiles(changeDir).map((file) => ({
11
+ path: file.relativePath,
12
+ content_hash: file.contentHash,
13
+ }));
14
+ return crypto.createHash('sha256').update(JSON.stringify(files)).digest('hex');
15
+ }
16
+
17
+ function observeChangeArtifacts(projectRoot, changeName, options = {}) {
18
+ const root = path.resolve(projectRoot || process.cwd());
19
+ const safeName = safeChangeName(changeName);
20
+ const changeDir = path.join(root, 'openspec', 'changes', safeName);
21
+ if (!fs.existsSync(changeDir)) {
22
+ throw new Error(`变更目录不存在: ${changeDir}`);
23
+ }
24
+
25
+ const pollIntervalMs = Math.max(25, Number(options.pollIntervalMs || 1500));
26
+ const debounceMs = Math.max(0, Number(options.debounceMs || 120));
27
+ // 启动/重启后先把当前文件集合视为待对账,避免停机期间的变化被当作已处理。
28
+ let lastFingerprint = '';
29
+ let debounceTimer = null;
30
+ let closed = false;
31
+ let watcher = null;
32
+
33
+ function reconcileIfChanged(force = false) {
34
+ if (closed) return null;
35
+ const nextFingerprint = fingerprint(changeDir);
36
+ if (!force && nextFingerprint === lastFingerprint) return null;
37
+ const result = reconcileChange(root, safeName, { profile: options.profile || 'auto' });
38
+ lastFingerprint = nextFingerprint;
39
+ if (typeof options.onReconciled === 'function') options.onReconciled(result);
40
+ return result;
41
+ }
42
+
43
+ function schedule() {
44
+ if (closed) return;
45
+ if (debounceTimer) clearTimeout(debounceTimer);
46
+ debounceTimer = setTimeout(() => {
47
+ debounceTimer = null;
48
+ try {
49
+ reconcileIfChanged();
50
+ } catch (error) {
51
+ if (typeof options.onError === 'function') options.onError(error);
52
+ }
53
+ }, debounceMs);
54
+ }
55
+
56
+ try {
57
+ watcher = fs.watch(changeDir, { recursive: true }, schedule);
58
+ watcher.on('error', (error) => {
59
+ if (typeof options.onError === 'function') options.onError(error);
60
+ });
61
+ } catch (error) {
62
+ watcher = null;
63
+ if (typeof options.onError === 'function') options.onError(error);
64
+ }
65
+
66
+ const poller = setInterval(() => {
67
+ try {
68
+ reconcileIfChanged();
69
+ } catch (error) {
70
+ if (typeof options.onError === 'function') options.onError(error);
71
+ }
72
+ }, pollIntervalMs);
73
+
74
+ return {
75
+ mode: watcher ? 'fs-watch+poll-reconcile' : 'poll-reconcile',
76
+ changeDir,
77
+ reconcile: () => reconcileIfChanged(true),
78
+ close() {
79
+ if (closed) return;
80
+ closed = true;
81
+ if (debounceTimer) clearTimeout(debounceTimer);
82
+ clearInterval(poller);
83
+ if (watcher) watcher.close();
84
+ },
85
+ };
86
+ }
87
+
88
+ module.exports = {
89
+ fingerprint,
90
+ observeChangeArtifacts,
91
+ };