onto-mcp 0.3.2 → 0.4.0

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 (300) hide show
  1. package/.onto/processes/reconstruct/actionable-ontology-seed-recomposition-design.md +447 -0
  2. package/.onto/processes/reconstruct/foundry-style-ontology-seed-contract.md +934 -0
  3. package/.onto/processes/reconstruct/reconstruct-boundary-contract.md +303 -725
  4. package/.onto/processes/reconstruct/reconstruct-contract-registry.yaml +1645 -0
  5. package/.onto/processes/reconstruct/reconstruct-execution-ux-contract.md +26 -22
  6. package/.onto/processes/reconstruct/source-profile-contract.md +49 -23
  7. package/.onto/processes/reconstruct/source-profiles/code.md +6 -3
  8. package/.onto/processes/reconstruct/source-profiles/database.md +5 -2
  9. package/.onto/processes/reconstruct/source-profiles/document.md +5 -2
  10. package/.onto/processes/reconstruct/source-profiles/spreadsheet.md +5 -4
  11. package/.onto/processes/review/review-execution-ux-contract.md +40 -0
  12. package/.onto/processes/shared/pipeline-execution-ledger-contract.md +26 -10
  13. package/.onto/processes/shared/target-material-kind-contract.md +29 -16
  14. package/AGENTS.md +6 -4
  15. package/README.md +135 -76
  16. package/dist/cli.js +8 -8
  17. package/dist/core-api/reconstruct-api.js +117 -31
  18. package/dist/core-api/review-api.js +47 -0
  19. package/dist/core-runtime/cli/codex-review-unit-executor.js +39 -2
  20. package/dist/core-runtime/cli/complete-review-session.js +2 -2
  21. package/dist/core-runtime/cli/mock-review-unit-executor.js +1 -1
  22. package/dist/core-runtime/cli/review-invoke.js +9 -9
  23. package/dist/core-runtime/cli/run-review-prompt-execution.js +39 -5
  24. package/dist/core-runtime/cli/spawn-watcher.js +266 -47
  25. package/dist/core-runtime/cli/start-review-session.js +3 -3
  26. package/dist/core-runtime/llm/llm-caller.js +11 -0
  27. package/dist/core-runtime/llm/llm-tool-loop.js +2 -0
  28. package/dist/core-runtime/observability/runtime-stream-observation.js +118 -0
  29. package/dist/core-runtime/onboard/cli-host.js +149 -0
  30. package/dist/core-runtime/onboard/host-target.js +22 -0
  31. package/dist/core-runtime/onboard/json-config-host.js +122 -0
  32. package/dist/core-runtime/onboard/path-scan.js +26 -0
  33. package/dist/core-runtime/onboard/prompt.js +51 -0
  34. package/dist/core-runtime/onboard/register.js +207 -0
  35. package/dist/core-runtime/onboard/types.js +27 -0
  36. package/dist/core-runtime/reconstruct/actionable-seed-validation.js +1777 -0
  37. package/dist/core-runtime/reconstruct/artifact-types.js +10 -4
  38. package/dist/core-runtime/reconstruct/contract-registry.js +623 -0
  39. package/dist/core-runtime/reconstruct/domain-id.js +10 -0
  40. package/dist/core-runtime/reconstruct/governing-snapshot.js +716 -0
  41. package/dist/core-runtime/reconstruct/material-profile-validation.js +191 -0
  42. package/dist/core-runtime/reconstruct/materialize-preparation.js +49 -11
  43. package/dist/core-runtime/reconstruct/pipeline-execution-ledger.js +269 -79
  44. package/dist/core-runtime/reconstruct/post-seed-validation.js +1194 -51
  45. package/dist/core-runtime/reconstruct/record.js +104 -20
  46. package/dist/core-runtime/reconstruct/run.js +2107 -413
  47. package/dist/core-runtime/reconstruct/seed-claim-projections.js +268 -0
  48. package/dist/core-runtime/reconstruct/source-profiles.js +93 -4
  49. package/dist/core-runtime/reconstruct/terminal-validation.js +807 -0
  50. package/dist/core-runtime/review/review-invocation-runner.js +4 -4
  51. package/dist/mcp/server.js +110 -38
  52. package/dist/mcp/tool-schemas.js +20 -6
  53. package/package.json +8 -17
  54. package/scripts/onto-review-watch.sh +486 -0
  55. package/scripts/onto-runtime-watch.sh +122 -0
  56. package/scripts/postinstall-hint.js +22 -0
  57. package/.onto/processes/reconstruct/top-level-concept-discovery-contract.md +0 -387
  58. package/dist/core-runtime/cli/bootstrap-review-binding.js +0 -186
  59. package/dist/core-runtime/cli/codex-nested-dispatch.test.js +0 -390
  60. package/dist/core-runtime/cli/codex-nested-teamlead-executor.test.js +0 -335
  61. package/dist/core-runtime/cli/coordinator-helpers.js +0 -583
  62. package/dist/core-runtime/cli/coordinator-state-machine-deliberation.test.js +0 -167
  63. package/dist/core-runtime/cli/coordinator-state-machine.js +0 -794
  64. package/dist/core-runtime/cli/e2e-codex-multi-agent-fixes.test.js +0 -615
  65. package/dist/core-runtime/cli/e2e-start-review-session.test.js +0 -312
  66. package/dist/core-runtime/cli/health.js +0 -44
  67. package/dist/core-runtime/cli/inline-http-review-unit-executor.test.js +0 -567
  68. package/dist/core-runtime/cli/materialize-review-execution-preparation.js +0 -104
  69. package/dist/core-runtime/cli/migrate-session-roots.js +0 -118
  70. package/dist/core-runtime/cli/repo-layout-migration-replace.smoke.test.js +0 -106
  71. package/dist/core-runtime/cli/review-invoke-auto-resolution.test.js +0 -268
  72. package/dist/core-runtime/cli/review-invoke-coordinator-topology.test.js +0 -136
  73. package/dist/core-runtime/cli/review-invoke-resolver-caching.test.js +0 -201
  74. package/dist/core-runtime/cli/review-invoke-topology-dispatch.test.js +0 -192
  75. package/dist/core-runtime/cli/session-root-guard.js +0 -168
  76. package/dist/core-runtime/cli/spawn-watcher.test.js +0 -457
  77. package/dist/core-runtime/cli/strip-wrapping-code-fence.test.js +0 -79
  78. package/dist/core-runtime/cli/teamcreate-lens-deliberation-executor.js +0 -412
  79. package/dist/core-runtime/cli/teamcreate-lens-deliberation-executor.test.js +0 -351
  80. package/dist/core-runtime/cli/topology-executor-mapping.js +0 -139
  81. package/dist/core-runtime/cli/topology-executor-mapping.test.js +0 -173
  82. package/dist/core-runtime/cli/write-review-interpretation.js +0 -81
  83. package/dist/core-runtime/config/onto-config-cli.js +0 -278
  84. package/dist/core-runtime/config/onto-config-key-path.js +0 -288
  85. package/dist/core-runtime/config/onto-config-key-path.test.js +0 -195
  86. package/dist/core-runtime/config/onto-config-preview.js +0 -108
  87. package/dist/core-runtime/config/onto-config-preview.test.js +0 -132
  88. package/dist/core-runtime/discovery/config-chain.js +0 -118
  89. package/dist/core-runtime/discovery/config-chain.test.js +0 -103
  90. package/dist/core-runtime/discovery/config-profile.js +0 -199
  91. package/dist/core-runtime/discovery/config-profile.test.js +0 -233
  92. package/dist/core-runtime/discovery/host-detection.test.js +0 -186
  93. package/dist/core-runtime/discovery/installation-paths.test.js +0 -65
  94. package/dist/core-runtime/discovery/lens-registry.test.js +0 -81
  95. package/dist/core-runtime/discovery/path-normalization.test.js +0 -22
  96. package/dist/core-runtime/discovery/plugin-path.js +0 -72
  97. package/dist/core-runtime/discovery/plugin-path.test.js +0 -95
  98. package/dist/core-runtime/evolve/adapters/code-product/compile/compile-defense.js +0 -344
  99. package/dist/core-runtime/evolve/adapters/code-product/compile/compile-defense.test.js +0 -915
  100. package/dist/core-runtime/evolve/adapters/code-product/compile/compile.js +0 -564
  101. package/dist/core-runtime/evolve/adapters/code-product/compile/compile.test.js +0 -708
  102. package/dist/core-runtime/evolve/adapters/code-product/parsers/brief-parser.js +0 -165
  103. package/dist/core-runtime/evolve/adapters/code-product/parsers/brief-parser.test.js +0 -227
  104. package/dist/core-runtime/evolve/adapters/code-product/validators/validate.js +0 -59
  105. package/dist/core-runtime/evolve/adapters/code-product/validators/validate.test.js +0 -205
  106. package/dist/core-runtime/evolve/adapters/methodology/adapter.js +0 -16
  107. package/dist/core-runtime/evolve/adapters/methodology/adapter.test.js +0 -9
  108. package/dist/core-runtime/evolve/adapters/methodology/perspectives/authority-consistency.js +0 -298
  109. package/dist/core-runtime/evolve/adapters/methodology/perspectives/authority-consistency.test.js +0 -70
  110. package/dist/core-runtime/evolve/adapters/methodology/scope-types/process.js +0 -46
  111. package/dist/core-runtime/evolve/adapters/methodology/scope-types/process.test.js +0 -73
  112. package/dist/core-runtime/evolve/adapters/registry.js +0 -47
  113. package/dist/core-runtime/evolve/adapters/registry.test.js +0 -67
  114. package/dist/core-runtime/evolve/cli.js +0 -256
  115. package/dist/core-runtime/evolve/commands/align.js +0 -194
  116. package/dist/core-runtime/evolve/commands/align.test.js +0 -82
  117. package/dist/core-runtime/evolve/commands/apply.js +0 -161
  118. package/dist/core-runtime/evolve/commands/apply.test.js +0 -138
  119. package/dist/core-runtime/evolve/commands/close.js +0 -39
  120. package/dist/core-runtime/evolve/commands/close.test.js +0 -99
  121. package/dist/core-runtime/evolve/commands/defer.js +0 -40
  122. package/dist/core-runtime/evolve/commands/defer.test.js +0 -134
  123. package/dist/core-runtime/evolve/commands/draft.js +0 -323
  124. package/dist/core-runtime/evolve/commands/draft.test.js +0 -178
  125. package/dist/core-runtime/evolve/commands/e2e-evolve-full-cycle.test.js +0 -208
  126. package/dist/core-runtime/evolve/commands/error-messages.js +0 -125
  127. package/dist/core-runtime/evolve/commands/error-messages.test.js +0 -167
  128. package/dist/core-runtime/evolve/commands/propose-align.js +0 -222
  129. package/dist/core-runtime/evolve/commands/propose-align.test.js +0 -136
  130. package/dist/core-runtime/evolve/commands/reconstruct.js +0 -330
  131. package/dist/core-runtime/evolve/commands/reconstruct.test.js +0 -278
  132. package/dist/core-runtime/evolve/commands/shared.js +0 -22
  133. package/dist/core-runtime/evolve/commands/stale-check.js +0 -103
  134. package/dist/core-runtime/evolve/commands/stale-check.test.js +0 -84
  135. package/dist/core-runtime/evolve/commands/start.js +0 -887
  136. package/dist/core-runtime/evolve/commands/start.test.js +0 -396
  137. package/dist/core-runtime/evolve/config/project-config.js +0 -99
  138. package/dist/core-runtime/evolve/config/project-config.test.js +0 -170
  139. package/dist/core-runtime/evolve/renderers/align-packet.js +0 -280
  140. package/dist/core-runtime/evolve/renderers/align-packet.test.js +0 -332
  141. package/dist/core-runtime/evolve/renderers/draft-packet.js +0 -303
  142. package/dist/core-runtime/evolve/renderers/draft-packet.test.js +0 -377
  143. package/dist/core-runtime/evolve/renderers/format.js +0 -5
  144. package/dist/core-runtime/evolve/renderers/scope-md.js +0 -237
  145. package/dist/core-runtime/evolve/renderers/scope-md.test.js +0 -306
  146. package/dist/core-runtime/govern/cli.js +0 -369
  147. package/dist/core-runtime/govern/cli.test.js +0 -314
  148. package/dist/core-runtime/govern/drift-engine.js +0 -103
  149. package/dist/core-runtime/govern/drift-engine.test.js +0 -319
  150. package/dist/core-runtime/govern/promote-principle.js +0 -206
  151. package/dist/core-runtime/govern/promote-principle.test.js +0 -368
  152. package/dist/core-runtime/govern/queue.js +0 -81
  153. package/dist/core-runtime/govern/types.js +0 -16
  154. package/dist/core-runtime/install/cli.js +0 -530
  155. package/dist/core-runtime/install/detect.js +0 -128
  156. package/dist/core-runtime/install/detect.test.js +0 -155
  157. package/dist/core-runtime/install/gitignore-update.js +0 -74
  158. package/dist/core-runtime/install/gitignore-update.test.js +0 -64
  159. package/dist/core-runtime/install/install-integration.test.js +0 -373
  160. package/dist/core-runtime/install/prompts.js +0 -389
  161. package/dist/core-runtime/install/prompts.test.js +0 -293
  162. package/dist/core-runtime/install/types.js +0 -26
  163. package/dist/core-runtime/install/validation.js +0 -295
  164. package/dist/core-runtime/install/validation.test.js +0 -313
  165. package/dist/core-runtime/install/writer.js +0 -254
  166. package/dist/core-runtime/install/writer.test.js +0 -218
  167. package/dist/core-runtime/learning/extractor.js +0 -461
  168. package/dist/core-runtime/learning/feedback.js +0 -179
  169. package/dist/core-runtime/learning/health-report.js +0 -165
  170. package/dist/core-runtime/learning/health-report.test.js +0 -169
  171. package/dist/core-runtime/learning/loader.js +0 -388
  172. package/dist/core-runtime/learning/loader.test.js +0 -102
  173. package/dist/core-runtime/learning/promote/apply-state.js +0 -240
  174. package/dist/core-runtime/learning/promote/audit-obligation.js +0 -195
  175. package/dist/core-runtime/learning/promote/collector.js +0 -432
  176. package/dist/core-runtime/learning/promote/degraded-state.js +0 -125
  177. package/dist/core-runtime/learning/promote/domain-doc-proposer.js +0 -166
  178. package/dist/core-runtime/learning/promote/e2e-promote.test.js +0 -6385
  179. package/dist/core-runtime/learning/promote/health-snapshot.js +0 -150
  180. package/dist/core-runtime/learning/promote/insight-reclassifier.js +0 -544
  181. package/dist/core-runtime/learning/promote/judgment-auditor.js +0 -517
  182. package/dist/core-runtime/learning/promote/panel-reviewer.js +0 -1158
  183. package/dist/core-runtime/learning/promote/promote-executor.js +0 -1675
  184. package/dist/core-runtime/learning/promote/promoter.js +0 -307
  185. package/dist/core-runtime/learning/promote/retirement.js +0 -122
  186. package/dist/core-runtime/learning/promote/types.js +0 -23
  187. package/dist/core-runtime/learning/prompt-sections.js +0 -51
  188. package/dist/core-runtime/learning/shared/artifact-registry-init.js +0 -45
  189. package/dist/core-runtime/learning/shared/artifact-registry.js +0 -254
  190. package/dist/core-runtime/learning/shared/audit-obligation-kernel.js +0 -73
  191. package/dist/core-runtime/learning/shared/audit-state.js +0 -99
  192. package/dist/core-runtime/learning/shared/duplicate-check.js +0 -28
  193. package/dist/core-runtime/learning/shared/llm-caller.js +0 -831
  194. package/dist/core-runtime/learning/shared/llm-caller.test.js +0 -601
  195. package/dist/core-runtime/learning/shared/llm-tool-loop.js +0 -393
  196. package/dist/core-runtime/learning/shared/mode.js +0 -25
  197. package/dist/core-runtime/learning/shared/paths.js +0 -84
  198. package/dist/core-runtime/learning/shared/paths.test.js +0 -79
  199. package/dist/core-runtime/learning/shared/patterns.js +0 -37
  200. package/dist/core-runtime/learning/shared/recoverability.js +0 -355
  201. package/dist/core-runtime/learning/shared/recovery-context.js +0 -374
  202. package/dist/core-runtime/learning/shared/scope.js +0 -1
  203. package/dist/core-runtime/learning/shared/semantic-classifier.js +0 -94
  204. package/dist/core-runtime/learning/shared/specs/apply-execution-state-spec.js +0 -42
  205. package/dist/core-runtime/learning/shared/specs/audit-state-spec.js +0 -37
  206. package/dist/core-runtime/learning/shared/specs/backup-metadata-spec.js +0 -39
  207. package/dist/core-runtime/learning/shared/specs/emergency-log-spec.js +0 -41
  208. package/dist/core-runtime/learning/shared/specs/layout-version-spec.js +0 -38
  209. package/dist/core-runtime/learning/shared/specs/promote-decisions-spec.js +0 -43
  210. package/dist/core-runtime/learning/shared/specs/promote-report-spec.js +0 -113
  211. package/dist/core-runtime/learning/shared/specs/prune-log-spec.js +0 -36
  212. package/dist/core-runtime/learning/shared/specs/recovery-resolution-spec.js +0 -48
  213. package/dist/core-runtime/learning/shared/specs/restore-manifest-spec.js +0 -43
  214. package/dist/core-runtime/learning/shared/specs/spec-helpers.js +0 -64
  215. package/dist/core-runtime/learning/usage-tracker.js +0 -190
  216. package/dist/core-runtime/learning/usage-tracker.test.js +0 -176
  217. package/dist/core-runtime/onboard/detect-review-axes.js +0 -122
  218. package/dist/core-runtime/onboard/detect-review-axes.test.js +0 -127
  219. package/dist/core-runtime/onboard/write-review-block.js +0 -188
  220. package/dist/core-runtime/onboard/write-review-block.test.js +0 -240
  221. package/dist/core-runtime/readers/brownfield-builder.js +0 -150
  222. package/dist/core-runtime/readers/brownfield-builder.test.js +0 -136
  223. package/dist/core-runtime/readers/code-chunk-collector.js +0 -53
  224. package/dist/core-runtime/readers/code-chunk-collector.test.js +0 -136
  225. package/dist/core-runtime/readers/file-utils.js +0 -240
  226. package/dist/core-runtime/readers/file-utils.test.js +0 -146
  227. package/dist/core-runtime/readers/lexicon-citation-check.js +0 -93
  228. package/dist/core-runtime/readers/lexicon-citation-check.test.js +0 -77
  229. package/dist/core-runtime/readers/mcp-figma.js +0 -30
  230. package/dist/core-runtime/readers/mcp-figma.test.js +0 -82
  231. package/dist/core-runtime/readers/mcp-generic.js +0 -31
  232. package/dist/core-runtime/readers/mcp-generic.test.js +0 -76
  233. package/dist/core-runtime/readers/ontology-index.js +0 -148
  234. package/dist/core-runtime/readers/ontology-index.test.js +0 -245
  235. package/dist/core-runtime/readers/ontology-query.js +0 -168
  236. package/dist/core-runtime/readers/ontology-query.test.js +0 -311
  237. package/dist/core-runtime/readers/ontology-resolve.js +0 -48
  238. package/dist/core-runtime/readers/ontology-resolve.test.js +0 -48
  239. package/dist/core-runtime/readers/patterns/index.js +0 -7
  240. package/dist/core-runtime/readers/review-log.js +0 -213
  241. package/dist/core-runtime/readers/review-log.test.js +0 -313
  242. package/dist/core-runtime/readers/scan-local.js +0 -102
  243. package/dist/core-runtime/readers/scan-local.test.js +0 -102
  244. package/dist/core-runtime/readers/scan-tarball.js +0 -121
  245. package/dist/core-runtime/readers/scan-tarball.test.js +0 -283
  246. package/dist/core-runtime/readers/scan-vault.js +0 -34
  247. package/dist/core-runtime/readers/scan-vault.test.js +0 -81
  248. package/dist/core-runtime/readers/types.js +0 -42
  249. package/dist/core-runtime/readers/types.test.js +0 -94
  250. package/dist/core-runtime/readers/viewpoint-collectors.js +0 -229
  251. package/dist/core-runtime/reconstruct/seed-candidate-validation.js +0 -385
  252. package/dist/core-runtime/review/citation-audit.test.js +0 -165
  253. package/dist/core-runtime/review/execution-plan-resolver.js +0 -247
  254. package/dist/core-runtime/review/execution-plan-resolver.test.js +0 -243
  255. package/dist/core-runtime/review/execution-topology-resolver-axis-first.test.js +0 -246
  256. package/dist/core-runtime/review/execution-topology-resolver.js +0 -401
  257. package/dist/core-runtime/review/execution-topology-resolver.test.js +0 -315
  258. package/dist/core-runtime/review/inline-context-embedder.test.js +0 -154
  259. package/dist/core-runtime/review/legacy-mode-policy.js +0 -88
  260. package/dist/core-runtime/review/materializers-effort-persist.test.js +0 -79
  261. package/dist/core-runtime/review/ontology-path-classifier.js +0 -179
  262. package/dist/core-runtime/review/ontology-path-classifier.test.js +0 -216
  263. package/dist/core-runtime/review/packet-boundary-policy.test.js +0 -107
  264. package/dist/core-runtime/review/participating-lens-paths.test.js +0 -73
  265. package/dist/core-runtime/review/review-config-legacy-translate.js +0 -244
  266. package/dist/core-runtime/review/review-config-legacy-translate.test.js +0 -161
  267. package/dist/core-runtime/review/review-config-validator.js +0 -289
  268. package/dist/core-runtime/review/review-config-validator.test.js +0 -236
  269. package/dist/core-runtime/review/shape-pipeline-audit.test.js +0 -311
  270. package/dist/core-runtime/review/shape-to-topology-id.js +0 -117
  271. package/dist/core-runtime/review/shape-to-topology-id.test.js +0 -132
  272. package/dist/core-runtime/review/topology-shape-derivation.js +0 -155
  273. package/dist/core-runtime/review/topology-shape-derivation.test.js +0 -195
  274. package/dist/core-runtime/scope-runtime/constants.js +0 -12
  275. package/dist/core-runtime/scope-runtime/constraint-pool.js +0 -166
  276. package/dist/core-runtime/scope-runtime/constraint-pool.test.js +0 -674
  277. package/dist/core-runtime/scope-runtime/domain-validation-log.js +0 -135
  278. package/dist/core-runtime/scope-runtime/domain-validation-log.test.js +0 -156
  279. package/dist/core-runtime/scope-runtime/eval-persistence.js +0 -65
  280. package/dist/core-runtime/scope-runtime/eval-persistence.test.js +0 -84
  281. package/dist/core-runtime/scope-runtime/event-pipeline.js +0 -64
  282. package/dist/core-runtime/scope-runtime/event-pipeline.test.js +0 -450
  283. package/dist/core-runtime/scope-runtime/event-store.js +0 -39
  284. package/dist/core-runtime/scope-runtime/event-store.test.js +0 -95
  285. package/dist/core-runtime/scope-runtime/gate-guard.js +0 -348
  286. package/dist/core-runtime/scope-runtime/gate-guard.test.js +0 -1047
  287. package/dist/core-runtime/scope-runtime/hash.js +0 -4
  288. package/dist/core-runtime/scope-runtime/hash.test.js +0 -33
  289. package/dist/core-runtime/scope-runtime/id.js +0 -4
  290. package/dist/core-runtime/scope-runtime/id.test.js +0 -17
  291. package/dist/core-runtime/scope-runtime/reducer.js +0 -297
  292. package/dist/core-runtime/scope-runtime/reducer.test.js +0 -759
  293. package/dist/core-runtime/scope-runtime/scope-manager.js +0 -161
  294. package/dist/core-runtime/scope-runtime/state-machine.js +0 -309
  295. package/dist/core-runtime/scope-runtime/state-machine.test.js +0 -704
  296. package/dist/core-runtime/scope-runtime/types.js +0 -116
  297. package/dist/core-runtime/scope-runtime/types.test.js +0 -69
  298. package/dist/core-runtime/translate/render-for-user.js +0 -169
  299. package/dist/core-runtime/translate/render-for-user.test.js +0 -122
  300. package/dist/providers/capability-contract.js +0 -1
@@ -1,1158 +0,0 @@
1
- /**
2
- * Phase 3 Promote — Panel Reviewer (Step 8a).
3
- *
4
- * Design authority:
5
- * - learn-phase3-design-v4.md DD-2 (3-agent panel composition + 축소 규칙)
6
- * - learn-phase3-design-v5.md DD-7 (validator criteria 1~5)
7
- * - learn-phase3-design-v4.md DD-7 (array validator signature)
8
- * - learn-phase3-design-v4.md DD-12 (hard gate: valid_member_count < 2)
9
- * - learn-phase3-design-v2.md DD-2 (per-member LLM call, API-based)
10
- * - .onto/processes/learn/promote.md Step 3 (criteria 1~6 definitions)
11
- *
12
- * Responsibility:
13
- * - DD-2 Panel composition: originator + axiology + auto_selected
14
- * (축소 규칙: 관련 agent 부재 / 원본==auto_selected → 2-agent)
15
- * - Per-member LLM call (1 batch for all candidates per member) via
16
- * shared/llm-caller.ts. 1회 retry on validator failure.
17
- * - DD-7 validator: criteria 1~5 array length + judgment coherence
18
- * + duplicate_of coherence + consolidation_recommendation coherence
19
- * - DD-12 hard gate: `consensus = panel_minimum_unmet` when valid
20
- * member count drops below 2
21
- * - Consensus aggregation: 3/3 / 2/3 / defer / reject / split
22
- *
23
- * Scope boundary:
24
- * - Criteria 1~5 only. Criterion 6 (cross-agent dedup) is a separate
25
- * single-agent sequential pass in a sibling helper.
26
- * - Phase A (source-read-only). No mutation. No state persistence.
27
- *
28
- * Failure model:
29
- * - LLM call failure → member becomes unreachable, consensus denominator
30
- * shrinks dynamically. If every member is unreachable or
31
- * contract_invalid → panel_minimum_unmet (DD-12).
32
- */
33
- import crypto from "node:crypto";
34
- import fs from "node:fs";
35
- import os from "node:os";
36
- import path from "node:path";
37
- import { callLlm, hashPrompt } from "../shared/llm-caller.js";
38
- // ---------------------------------------------------------------------------
39
- // Panel composition — DD-2
40
- // ---------------------------------------------------------------------------
41
- /**
42
- * Canonical axiology agent id. Mirrors the axiology learning file at
43
- * `~/.onto/learnings/axiology.md` and the `axiology` lens in the review
44
- * runtime. Exported so tests and higher-level helpers can reference it.
45
- */
46
- export const AXIOLOGY_AGENT_ID = "axiology";
47
- /**
48
- * Enumerate known agent ids by listing `{ontoHome}/learnings/*.md`.
49
- *
50
- * The filename (without `.md`) is the agent id. We keep this derivation local
51
- * to panel-reviewer so the collector's own path handling is not spilled into
52
- * the public API — both modules agree on the `<home>/.onto/learnings/` layout
53
- * via shared/paths.ts conventions.
54
- */
55
- function listKnownAgents(ontoHome) {
56
- const home = ontoHome ?? os.homedir();
57
- const dir = path.join(home, ".onto", "learnings");
58
- if (!fs.existsSync(dir))
59
- return [];
60
- return fs
61
- .readdirSync(dir)
62
- .filter((f) => f.endsWith(".md"))
63
- .map((f) => f.slice(0, -3))
64
- .map((id) => (id.startsWith("onto_") ? id.slice(5) : id))
65
- .sort();
66
- }
67
- /**
68
- * Compose the 3-agent panel for a given candidate.
69
- *
70
- * DD-2 rules:
71
- * 1. Panel member 1: originator (candidate.agent_id)
72
- * 2. Panel member 2: axiology (always)
73
- * 3. Panel member 3: auto_selected (first known agent that is neither
74
- * originator nor axiology). If none can be selected, degrade to
75
- * 2-agent.
76
- */
77
- export function composePanel(candidate, config = {}) {
78
- const members = [];
79
- members.push({
80
- agent_id: candidate.agent_id,
81
- role: "originator",
82
- reachable: true,
83
- });
84
- const axiologySelf = candidate.agent_id === AXIOLOGY_AGENT_ID;
85
- if (!axiologySelf) {
86
- members.push({
87
- agent_id: AXIOLOGY_AGENT_ID,
88
- role: "axiology",
89
- reachable: true,
90
- });
91
- }
92
- // Auto-selected: first known lens that isn't originator or axiology.
93
- const candidates = listKnownAgents(config.ontoHome).filter((id) => id !== candidate.agent_id && id !== AXIOLOGY_AGENT_ID);
94
- if (candidates.length > 0) {
95
- members.push({
96
- agent_id: candidates[0],
97
- role: "auto_selected",
98
- reachable: true,
99
- });
100
- }
101
- // If no auto_selected agent exists we degrade gracefully to 2-agent.
102
- // If the originator IS axiology we also naturally fall back.
103
- return members;
104
- }
105
- // ---------------------------------------------------------------------------
106
- // Candidate identification
107
- // ---------------------------------------------------------------------------
108
- /**
109
- * Stable identifier for a panel candidate. Uses learning_id when present so
110
- * Phase 2-written items are addressable by their durable id. Falls back to a
111
- * short content hash for legacy items — the hash is stable across runs
112
- * because it's derived from raw_line only.
113
- */
114
- export function candidateIdOf(item) {
115
- if (item.learning_id)
116
- return item.learning_id;
117
- return crypto
118
- .createHash("sha256")
119
- .update(item.raw_line)
120
- .digest("hex")
121
- .slice(0, 12);
122
- }
123
- // ---------------------------------------------------------------------------
124
- // Prompt building
125
- // ---------------------------------------------------------------------------
126
- const PANEL_SYSTEM_PROMPT_TEMPLATE = `You are reviewing promotion candidates for a learning management system as an expert in the role of {ROLE_AGENT_ID} ({ROLE_LABEL}).
127
-
128
- Your task is to evaluate each candidate learning against 5 criteria AND recommend an axis tag adjustment. Output ONE JSON object per candidate in an "items" array. NO markdown fences, NO commentary, JSON only.
129
-
130
- Criteria (.onto/processes/learn/promote.md):
131
- 1. Generalizability — is it valid across projects, or only in this project?
132
- 2. Accuracy — is it based on facts or a coincidence from a unique situation?
133
- 3. Contradiction handling — if it contradicts an existing global entry, which is more correct?
134
- 4. Axis tag appropriateness — use the 2+1 stage test on the candidate's tags.
135
- 5. Deduplication vs global — is this a domain variant of an existing principle?
136
-
137
- For each candidate, return:
138
- {
139
- "candidate_id": "<the id you were given>",
140
- "verdict": "promote" | "defer" | "reject",
141
- "criteria": [
142
- {"criterion": 1, "judgment": "yes" | "no" | "uncertain", "reasoning": "<one sentence>"},
143
- {"criterion": 2, "judgment": "yes" | "no" | "uncertain", "reasoning": "<one sentence>"},
144
- {"criterion": 3, "judgment": "yes" | "no" | "uncertain", "reasoning": "<one sentence>"},
145
- {"criterion": 4, "judgment": "yes" | "no" | "uncertain", "reasoning": "<one sentence>"},
146
- {"criterion": 5, "judgment": "yes" | "no" | "uncertain", "reasoning": "<one sentence>"}
147
- ],
148
- "axis_tag_recommendation": "retain" | "add_methodology" | "remove_methodology" | "modify" | "no_recommendation",
149
- "axis_tag_note": "<brief rationale>",
150
- "contradiction_resolution": "replace" | "defer" | "n/a",
151
- "reason": "<one-sentence summary of the verdict>"
152
- }
153
-
154
- Coherence rules (your output will be rejected if violated):
155
- - criteria array MUST contain exactly 5 entries, one per criterion 1..5
156
- - If every criterion 1..3 judgment is "yes" and criterion 4 is "yes"/"uncertain" and criterion 5 is "yes" (no duplicate), the verdict should be "promote"
157
- - If any criterion 1..3 judgment is "no", the verdict MUST NOT be "promote"
158
- - If criterion 5 judgment is "no" (is a duplicate of existing), the verdict MUST NOT be "promote" — recommend defer or reject
159
- - If you provide a consolidation recommendation (via reason), the verdict MUST NOT be "promote"
160
- `;
161
- const PANEL_USER_PROMPT_TEMPLATE = `Review the promotion candidates below against the existing global learnings and return ONE JSON object with an "items" array — one entry per candidate.
162
-
163
- [Candidates] {CANDIDATE_COUNT}
164
- {CANDIDATE_BLOCK}
165
-
166
- [Existing Global Learnings] {GLOBAL_COUNT}
167
- {GLOBAL_BLOCK}
168
-
169
- Respond ONLY with valid JSON shaped as:
170
- {"items":[{...}, {...}]}
171
- `;
172
- function formatCandidate(candidate, index) {
173
- const id = candidateIdOf(candidate);
174
- const tags = candidate.applicability_tags.join(" ");
175
- const role = candidate.role ?? "<no-role>";
176
- return `${index + 1}. candidate_id=${id} type=${candidate.type} tags=[${tags}] role=${role} agent=${candidate.agent_id}\n content: ${candidate.content}`;
177
- }
178
- function formatGlobal(item, index) {
179
- const tags = item.applicability_tags.join(" ");
180
- const role = item.role ?? "<no-role>";
181
- return `${index + 1}. [${item.type}] tags=[${tags}] role=${role} agent=${item.agent_id}\n content: ${item.content}`;
182
- }
183
- export function buildPanelPrompt(config) {
184
- const maxGlobal = config.maxGlobalItems ?? 80;
185
- const sortedGlobals = [...config.globalItems]
186
- .sort((a, b) => {
187
- const pathCmp = a.source_path.localeCompare(b.source_path);
188
- if (pathCmp !== 0)
189
- return pathCmp;
190
- return a.line_number - b.line_number;
191
- })
192
- .slice(0, maxGlobal);
193
- const candidateBlock = config.candidates
194
- .map((c, i) => formatCandidate(c, i))
195
- .join("\n");
196
- const globalBlock = sortedGlobals
197
- .map((g, i) => formatGlobal(g, i))
198
- .join("\n");
199
- let system_prompt = PANEL_SYSTEM_PROMPT_TEMPLATE.replace("{ROLE_AGENT_ID}", config.member.agent_id).replace("{ROLE_LABEL}", config.member.role);
200
- if (config.retryFeedback && config.retryFeedback.length > 0) {
201
- system_prompt +=
202
- "\nPrevious attempt was rejected. Validator feedback:\n" +
203
- config.retryFeedback.map((f) => ` - ${f}`).join("\n") +
204
- "\nFix these issues and respond again.";
205
- }
206
- const user_prompt = PANEL_USER_PROMPT_TEMPLATE.replace("{CANDIDATE_COUNT}", String(config.candidates.length))
207
- .replace("{CANDIDATE_BLOCK}", candidateBlock || "(none)")
208
- .replace("{GLOBAL_COUNT}", String(sortedGlobals.length))
209
- .replace("{GLOBAL_BLOCK}", globalBlock || "(none)");
210
- return {
211
- system_prompt,
212
- user_prompt,
213
- prompt_hash: hashPrompt(system_prompt + "\n" + user_prompt),
214
- };
215
- }
216
- /**
217
- * Extract the first JSON object from an LLM response string.
218
- *
219
- * Models sometimes wrap JSON in markdown fences despite instructions;
220
- * stripping triple backticks is enough in practice. If parsing fails we
221
- * surface the error — the caller decides whether to retry.
222
- */
223
- function parsePanelJson(text) {
224
- let cleaned = text.trim();
225
- if (cleaned.startsWith("```")) {
226
- cleaned = cleaned.replace(/^```(?:json)?\s*/, "").replace(/```\s*$/, "");
227
- }
228
- const parsed = JSON.parse(cleaned);
229
- if (!parsed || !Array.isArray(parsed.items)) {
230
- throw new Error(`panel response missing "items" array (got keys: ${Object.keys(parsed ?? {}).join(",")})`);
231
- }
232
- return { items: parsed.items };
233
- }
234
- const VALID_VERDICTS = ["promote", "defer", "reject"];
235
- const VALID_AXIS_RECOS = [
236
- "retain",
237
- "add_methodology",
238
- "remove_methodology",
239
- "modify",
240
- "no_recommendation",
241
- ];
242
- function validateCriteriaArray(criteria) {
243
- const failures = [];
244
- if (criteria.length !== 5) {
245
- failures.push(`criteria array length ${criteria.length}, expected exactly 5`);
246
- return failures;
247
- }
248
- const seen = new Set();
249
- for (const c of criteria) {
250
- if (c.criterion < 1 || c.criterion > 5) {
251
- failures.push(`criterion ${c.criterion} out of range 1..5`);
252
- continue;
253
- }
254
- if (seen.has(c.criterion)) {
255
- failures.push(`criterion ${c.criterion} repeated`);
256
- continue;
257
- }
258
- seen.add(c.criterion);
259
- if (!["yes", "no", "uncertain"].includes(c.judgment)) {
260
- failures.push(`criterion ${c.criterion}: invalid judgment "${c.judgment}"`);
261
- }
262
- }
263
- for (let i = 1; i <= 5; i++) {
264
- if (!seen.has(i))
265
- failures.push(`criterion ${i} missing`);
266
- }
267
- return failures;
268
- }
269
- /**
270
- * DD-7 per-member validator. Applies criteria array structural checks plus
271
- * judgment coherence checks (criteria 1~3 vs verdict, criterion 5 vs
272
- * verdict).
273
- */
274
- export function validatePanelMemberReview(review) {
275
- const failures = [];
276
- if (!VALID_VERDICTS.includes(review.verdict)) {
277
- failures.push(`verdict "${review.verdict}" not in ${VALID_VERDICTS.join("|")}`);
278
- }
279
- if (!VALID_AXIS_RECOS.includes(review.axis_tag_recommendation)) {
280
- failures.push(`axis_tag_recommendation "${review.axis_tag_recommendation}" not valid`);
281
- }
282
- failures.push(...validateCriteriaArray(review.criteria));
283
- if (failures.length > 0) {
284
- return { passed: false, failures };
285
- }
286
- // Criteria 1~5 vs verdict coherence (M-C fix: c4 is now in the all-yes
287
- // gate, matching the prompt at line 196 which says promote is required when
288
- // criteria 1..3 are yes AND criterion 4 is yes/uncertain AND criterion 5 is
289
- // yes. Without c4 in the check, the validator wrongly rejected coherent
290
- // responses like "c1-3 yes, c4 no, c5 yes, verdict defer" — the model
291
- // correctly avoided promoting an item with bad axis tags, but the validator
292
- // demanded promote anyway).
293
- const c = new Map(review.criteria.map((x) => [x.criterion, x.judgment]));
294
- const c1 = c.get(1);
295
- const c2 = c.get(2);
296
- const c3 = c.get(3);
297
- const c4 = c.get(4);
298
- const c5 = c.get(5);
299
- const c4PassesGate = c4 === "yes" || c4 === "uncertain";
300
- if (c1 === "yes" &&
301
- c2 === "yes" &&
302
- c3 === "yes" &&
303
- c4PassesGate &&
304
- c5 === "yes") {
305
- if (review.verdict !== "promote") {
306
- failures.push("criteria 1,2,3 yes + c4 yes/uncertain + c5 yes but verdict is not promote");
307
- }
308
- }
309
- if ((c1 === "no" || c2 === "no" || c3 === "no") && review.verdict === "promote") {
310
- failures.push("a criterion 1..3 judgment is no but verdict is promote");
311
- }
312
- if (c5 === "no" && review.verdict === "promote") {
313
- failures.push("criterion 5 is no (duplicate of existing) but verdict is promote");
314
- }
315
- return { passed: failures.length === 0, failures };
316
- }
317
- /**
318
- * Convert a raw LLM item object into a typed PanelMemberReview. Returns null
319
- * when the raw object is structurally malformed — callers treat this as a
320
- * validator failure.
321
- */
322
- function normalizeRawItem(raw, ctx) {
323
- if (typeof raw.candidate_id !== "string")
324
- return null;
325
- const candidate = ctx.candidateById.get(raw.candidate_id);
326
- if (!candidate)
327
- return null;
328
- const verdict = raw.verdict;
329
- const rawCriteria = Array.isArray(raw.criteria) ? raw.criteria : [];
330
- const criteria = [];
331
- for (const r of rawCriteria) {
332
- const rr = r;
333
- const cnum = rr.criterion;
334
- if (typeof cnum !== "number")
335
- continue;
336
- if (cnum < 1 || cnum > 5)
337
- continue;
338
- const judgment = rr.judgment;
339
- if (judgment !== "yes" && judgment !== "no" && judgment !== "uncertain") {
340
- continue;
341
- }
342
- criteria.push({
343
- criterion: cnum,
344
- judgment,
345
- reasoning: typeof rr.reasoning === "string" ? rr.reasoning : "",
346
- });
347
- }
348
- const review = {
349
- member: ctx.member,
350
- verdict,
351
- criteria,
352
- axis_tag_recommendation: raw.axis_tag_recommendation ??
353
- "no_recommendation",
354
- axis_tag_note: typeof raw.axis_tag_note === "string" ? raw.axis_tag_note : "",
355
- reason: typeof raw.reason === "string" ? raw.reason : "",
356
- llm_model_id: ctx.llm_model_id,
357
- llm_prompt_hash: ctx.llm_prompt_hash,
358
- };
359
- if (raw.contradiction_resolution === "replace" ||
360
- raw.contradiction_resolution === "defer" ||
361
- raw.contradiction_resolution === "n/a") {
362
- review.contradiction_resolution = raw.contradiction_resolution;
363
- }
364
- return { review, candidate };
365
- }
366
- export async function callPanelMember(config) {
367
- const candidateById = new Map(config.candidates.map((c) => [candidateIdOf(c), c]));
368
- const globalsByLine = new Map(config.globalItems.map((g) => [g.raw_line, g]));
369
- const tryOnce = async (retryFeedback) => {
370
- const promptCfg = {
371
- member: config.member,
372
- candidates: config.candidates,
373
- globalItems: config.globalItems,
374
- };
375
- if (config.maxGlobalItems !== undefined) {
376
- promptCfg.maxGlobalItems = config.maxGlobalItems;
377
- }
378
- if (retryFeedback !== undefined) {
379
- promptCfg.retryFeedback = retryFeedback;
380
- }
381
- const prompt = buildPanelPrompt(promptCfg);
382
- let llmText;
383
- let modelId;
384
- try {
385
- const result = await callLlm(prompt.system_prompt, prompt.user_prompt, {
386
- max_tokens: config.maxTokens ?? 4096,
387
- ...(config.modelId ? { model_id: config.modelId } : {}),
388
- });
389
- llmText = result.text;
390
- modelId = result.model_id;
391
- }
392
- catch (error) {
393
- return {
394
- member: { ...config.member, reachable: false },
395
- reviews: new Map(),
396
- failures: [],
397
- status: "unreachable",
398
- unreachable_reason: error instanceof Error ? error.message : String(error),
399
- };
400
- }
401
- let parsed;
402
- try {
403
- parsed = parsePanelJson(llmText);
404
- }
405
- catch (error) {
406
- return {
407
- member: config.member,
408
- reviews: new Map(),
409
- failures: [
410
- `panel response not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
411
- ],
412
- status: "contract_invalid",
413
- };
414
- }
415
- const ctx = {
416
- member: config.member,
417
- candidateById,
418
- globalsByLine,
419
- llm_model_id: modelId,
420
- llm_prompt_hash: prompt.prompt_hash,
421
- };
422
- const reviews = new Map();
423
- const allFailures = [];
424
- for (const raw of parsed.items) {
425
- const normalized = normalizeRawItem(raw, ctx);
426
- if (!normalized) {
427
- allFailures.push(`item candidate_id=${String(raw.candidate_id)} could not be normalized (unknown id or malformed)`);
428
- continue;
429
- }
430
- const validation = validatePanelMemberReview(normalized.review);
431
- if (!validation.passed) {
432
- const id = candidateIdOf(normalized.candidate);
433
- for (const f of validation.failures) {
434
- allFailures.push(`[${id}] ${f}`);
435
- }
436
- continue;
437
- }
438
- reviews.set(candidateIdOf(normalized.candidate), normalized.review);
439
- }
440
- // Missing candidates: LLM silently dropped some items
441
- for (const c of config.candidates) {
442
- const id = candidateIdOf(c);
443
- if (!reviews.has(id) && !allFailures.some((f) => f.includes(id))) {
444
- allFailures.push(`[${id}] no review returned for this candidate`);
445
- }
446
- }
447
- if (allFailures.length === 0) {
448
- return {
449
- member: config.member,
450
- reviews,
451
- failures: [],
452
- status: "passed",
453
- };
454
- }
455
- return {
456
- member: config.member,
457
- reviews,
458
- failures: allFailures,
459
- status: "contract_invalid",
460
- };
461
- };
462
- const first = await tryOnce();
463
- if (first.status === "passed" || first.status === "unreachable") {
464
- return first;
465
- }
466
- const second = await tryOnce(first.failures);
467
- if (second.status === "passed") {
468
- return {
469
- ...second,
470
- status: "retried_passed",
471
- };
472
- }
473
- return second;
474
- }
475
- // ---------------------------------------------------------------------------
476
- // Consensus aggregation — DD-12 hard gate included
477
- // ---------------------------------------------------------------------------
478
- function tallyVerdicts(reviews) {
479
- const tally = { promote: 0, defer: 0, reject: 0 };
480
- for (const r of reviews) {
481
- tally[r.verdict] += 1;
482
- }
483
- return tally;
484
- }
485
- /**
486
- * DD-12 hard gate + DD-7 consensus mapping.
487
- *
488
- * Rules:
489
- * - valid_member_count < 2 → panel_minimum_unmet (hard gate)
490
- * - 3-agent panel + all promote → promote_3_3
491
- * - 2-agent degraded panel + both promote → promote_2_3
492
- * (m-1 fix: previously the n===2 unanimous case fell into the
493
- * promote_3_3 branch because tally.promote === n. The label was
494
- * misleading because there were only 2 members, not 3.)
495
- * - strict majority promote (>= 2) → promote_2_3
496
- * - strict majority defer (>= 2) → defer_majority
497
- * - strict majority reject (>= 2) → reject_majority
498
- * - otherwise → split
499
- */
500
- export function aggregateConsensus(validReviews) {
501
- if (validReviews.length < 2)
502
- return "panel_minimum_unmet";
503
- const tally = tallyVerdicts(validReviews);
504
- const n = validReviews.length;
505
- // m-1 fix: 2-member degraded panel with both promoting maps to promote_2_3,
506
- // not promote_3_3 (which implies a 3-member panel).
507
- if (n === 2 && tally.promote === 2)
508
- return "promote_2_3";
509
- if (tally.promote === n)
510
- return "promote_3_3";
511
- if (tally.promote >= 2)
512
- return "promote_2_3";
513
- if (tally.defer >= 2)
514
- return "defer_majority";
515
- if (tally.reject >= 2)
516
- return "reject_majority";
517
- return "split";
518
- }
519
- function computeMinorityOpinion(consensus, validReviews) {
520
- if (consensus !== "promote_2_3")
521
- return undefined;
522
- const dissenters = validReviews.filter((r) => r.verdict !== "promote");
523
- if (dissenters.length === 0)
524
- return undefined;
525
- return dissenters
526
- .map((r) => `${r.member.agent_id} (${r.verdict}): ${r.reason}`)
527
- .join("; ");
528
- }
529
- /**
530
- * Compose a panel per candidate and run parallel LLM reviews.
531
- *
532
- * Design note: a single panel composition is shared across batched candidates
533
- * from the same originator. For minimal Phase A correctness we compose per
534
- * candidate — candidates from the same originator will redundantly construct
535
- * the same panel member list, but each member's LLM call is per-candidate
536
- * anyway, so there's no cost saving from merging here.
537
- *
538
- * Future optimization: group candidates by originator and issue one LLM call
539
- * per (originator, axiology, auto_selected) tuple to save N×3 calls. The
540
- * current shape correctly implements criterion 1~5 per candidate; batching
541
- * is a cost improvement, not a correctness gap.
542
- */
543
- export async function reviewPanel(config) {
544
- const verdicts = [];
545
- const degraded_states = [];
546
- const isContradiction = (id) => config.contradictionCandidates?.has(id) ?? false;
547
- for (const candidate of config.candidates) {
548
- const candidateId = candidateIdOf(candidate);
549
- const members = composePanel(candidate, config.ontoHome !== undefined ? { ontoHome: config.ontoHome } : {});
550
- // One LLM call per member over a single-candidate batch. Batching across
551
- // candidates per originator is a Step 13 optimization.
552
- const memberResults = await Promise.all(members.map((member) => callPanelMember({
553
- member,
554
- candidates: [candidate],
555
- globalItems: config.globalItems,
556
- ...(config.maxTokens !== undefined
557
- ? { maxTokens: config.maxTokens }
558
- : {}),
559
- ...(config.modelId !== undefined ? { modelId: config.modelId } : {}),
560
- ...(config.maxGlobalItems !== undefined
561
- ? { maxGlobalItems: config.maxGlobalItems }
562
- : {}),
563
- })));
564
- // Collect member_reviews that survived validation for this candidate.
565
- const memberReviews = [];
566
- const effectiveMembers = [];
567
- for (const result of memberResults) {
568
- if (result.status === "unreachable") {
569
- effectiveMembers.push({
570
- ...result.member,
571
- reachable: false,
572
- unreachable_reason: result.unreachable_reason ?? "unknown",
573
- });
574
- degraded_states.push({
575
- kind: "member_unreachable",
576
- detail: `${result.member.agent_id}: ${result.unreachable_reason ?? "unknown"}`,
577
- affected_candidates: [candidateId],
578
- affected_agents: [result.member.agent_id],
579
- occurred_at: new Date().toISOString(),
580
- });
581
- continue;
582
- }
583
- effectiveMembers.push(result.member);
584
- if (result.status === "contract_invalid") {
585
- degraded_states.push({
586
- kind: "panel_contract_invalid",
587
- detail: `${result.member.agent_id}: ${result.failures.join("; ")}`,
588
- affected_candidates: [candidateId],
589
- affected_agents: [result.member.agent_id],
590
- occurred_at: new Date().toISOString(),
591
- });
592
- continue;
593
- }
594
- // passed or retried_passed
595
- const review = result.reviews.get(candidateId);
596
- if (!review) {
597
- degraded_states.push({
598
- kind: "panel_contract_invalid",
599
- detail: `${result.member.agent_id}: validated call returned no review for candidate`,
600
- affected_candidates: [candidateId],
601
- affected_agents: [result.member.agent_id],
602
- occurred_at: new Date().toISOString(),
603
- });
604
- continue;
605
- }
606
- memberReviews.push(review);
607
- }
608
- const consensus = aggregateConsensus(memberReviews);
609
- if (consensus === "panel_minimum_unmet") {
610
- degraded_states.push({
611
- kind: "panel_minimum_unmet",
612
- detail: `candidate ${candidateId}: valid_member_count=${memberReviews.length}`,
613
- affected_candidates: [candidateId],
614
- occurred_at: new Date().toISOString(),
615
- });
616
- }
617
- const verdict = {
618
- candidate_id: candidateId,
619
- candidate,
620
- panel_members: effectiveMembers,
621
- member_reviews: memberReviews,
622
- consensus,
623
- is_contradiction: isContradiction(candidateId),
624
- matched_existing_line: null,
625
- };
626
- const minority = computeMinorityOpinion(consensus, memberReviews);
627
- if (minority !== undefined) {
628
- verdict.minority_opinion = minority;
629
- }
630
- verdicts.push(verdict);
631
- }
632
- return { verdicts, degraded_states };
633
- }
634
- // ---------------------------------------------------------------------------
635
- // Criterion 6 — cross-agent dedup (LLM-driven)
636
- // ---------------------------------------------------------------------------
637
- /**
638
- * Cross-agent deduplication (criterion 6) — single-reviewer sequential path
639
- * with bi-directional removal protection.
640
- *
641
- * Algorithm:
642
- * 1. Pre-filter via Jaccard token overlap on significant content tokens.
643
- * Cross-agent pairs (different agent_id) with similarity ≥ JACCARD_THRESHOLD
644
- * become edges in a union-find structure.
645
- * 2. Each resulting connected component with ≥ 2 distinct agents becomes a
646
- * "shortlist" candidate for LLM confirmation.
647
- * 3. The shortlist is capped at MAX_ITEMS_PER_SHORTLIST and the total number
648
- * of shortlists at MAX_SHORTLISTS_PER_RUN to bound LLM cost.
649
- * 4. Per shortlist, one LLM call applies the same-principle test with
650
- * agent-specific framing removed (not domain terms). The model returns
651
- * a structured JSON: primary owner + consolidated principle + cases.
652
- * 5. Confirmed clusters become CrossAgentDedupCluster entries.
653
- *
654
- * Single-reviewer semantics (not 3-agent): promote.md §3 criterion 6 notes
655
- * that parallel 3-agent review risks bi-directional deletion (agent A removes
656
- * B while agent B removes A). The discovery path is intentionally one voice.
657
- *
658
- * Pre-filter rationale: an O(N²) naive LLM pass over candidates + globals is
659
- * cost-prohibitive at production scale (117 candidates × 1000 globals).
660
- * Jaccard is cheap, deterministic, and keeps the LLM load bounded.
661
- *
662
- * Failure model:
663
- * - LLM unreachable or returns malformed JSON for a shortlist → that shortlist
664
- * is dropped. Other shortlists proceed. (Recording degraded_states for
665
- * dropped discovery shortlists is a follow-up — the current caller
666
- * doesn't propagate them.)
667
- */
668
- const CROSS_AGENT_DEDUP_SYSTEM_PROMPT = `You are detecting cross-agent principle duplication in a learning management system.
669
-
670
- You will receive 2 or more learnings from DIFFERENT agents. Apply the same-principle test to decide whether they express the same underlying principle once agent-specific framing is removed:
671
-
672
- (a) Remove agent-specific framing (e.g. "the axiology lens asks...", "structurally...") from both items.
673
- (b) Do the remaining sentences prescribe the same action?
674
- (c) Can you identify a situation where one applies but the other does not? If yes, they are different principles.
675
-
676
- If they ARE the same principle:
677
- - Pick a primary_owner_agent: the agent closest to the verification dimension of the principle.
678
- Tiebreaker: the agent of the earliest-created learning (oldest source_date).
679
- - Write a consolidated_principle statement that generalizes over the agents.
680
- - Pick up to 3 representative_cases that maximize agent diversity.
681
- - Compose a consolidated_line in the flat inline format:
682
- "- [{type}] [{axis tags}] [{purpose type}] General principle statement. (Representative cases: agent-A에서 X; agent-B에서 Y; agent-C에서 Z) (source: consolidated from [sources])"
683
-
684
- Output ONE JSON object:
685
- {
686
- "same_principle": true | false,
687
- "primary_owner_agent": "<agent_id>" | null,
688
- "primary_owner_reason": "<string>",
689
- "consolidated_principle": "<string>",
690
- "representative_cases": ["<case 1>", "<case 2>", "<case 3>"],
691
- "consolidated_line": "<inline format line>"
692
- }
693
-
694
- NO markdown fences, JSON only.`;
695
- const JACCARD_THRESHOLD = 0.3;
696
- const MAX_SHORTLISTS_PER_RUN = 20;
697
- const MAX_ITEMS_PER_SHORTLIST = 10;
698
- const MIN_TOKEN_LENGTH = 4;
699
- const MIN_CJK_TOKEN_LENGTH = 2;
700
- const STOPWORDS = new Set([
701
- "with",
702
- "that",
703
- "this",
704
- "from",
705
- "into",
706
- "when",
707
- "where",
708
- "what",
709
- "which",
710
- "these",
711
- "those",
712
- "have",
713
- "been",
714
- "being",
715
- "should",
716
- "would",
717
- "could",
718
- "will",
719
- "must",
720
- "does",
721
- "they",
722
- "them",
723
- "their",
724
- "there",
725
- "then",
726
- "than",
727
- "some",
728
- "about",
729
- "also",
730
- "because",
731
- "such",
732
- "each",
733
- "while",
734
- "after",
735
- "before",
736
- ]);
737
- /**
738
- * U4 fix: Unicode-aware tokenization. Previously the splitter was
739
- * `[^a-z0-9]+`, which stripped every Korean (and other non-Latin)
740
- * character. On a Korean-heavy corpus (like this repo's own learnings),
741
- * that made criterion 6 effectively blind — no shortlist ever formed.
742
- *
743
- * New behavior:
744
- * - Split on characters that are NOT Unicode letters or numbers
745
- * (`\p{L}` / `\p{N}` with the `u` flag).
746
- * - Latin tokens still require MIN_TOKEN_LENGTH (4) to avoid matching
747
- * on short words like "with" or "that".
748
- * - Korean tokens (CJK ideographs and Hangul) use a lower threshold
749
- * (MIN_CJK_TOKEN_LENGTH = 2) because one-syllable Korean words carry
750
- * content ("코드", "검증", etc.).
751
- * - English stopwords still filtered.
752
- */
753
- function significantTokens(content) {
754
- const tokens = new Set();
755
- // Match runs of Unicode letters/numbers rather than splitting on
756
- // ASCII punctuation only.
757
- const matches = content.toLowerCase().match(/[\p{L}\p{N}]+/gu);
758
- if (!matches)
759
- return tokens;
760
- for (const word of matches) {
761
- if (STOPWORDS.has(word))
762
- continue;
763
- if (isCjkWord(word)) {
764
- if (word.length < MIN_CJK_TOKEN_LENGTH)
765
- continue;
766
- }
767
- else if (word.length < MIN_TOKEN_LENGTH) {
768
- continue;
769
- }
770
- tokens.add(word);
771
- }
772
- return tokens;
773
- }
774
- // Hangul syllables + jamo + CJK unified ideographs cover Korean content.
775
- const CJK_RE = /[\u3040-\u30ff\u3130-\u318f\uac00-\ud7af\u4e00-\u9fff]/;
776
- function isCjkWord(word) {
777
- return CJK_RE.test(word);
778
- }
779
- function jaccard(a, b) {
780
- if (a.size === 0 || b.size === 0)
781
- return 0;
782
- let inter = 0;
783
- for (const t of a)
784
- if (b.has(t))
785
- inter += 1;
786
- const union = a.size + b.size - inter;
787
- return union === 0 ? 0 : inter / union;
788
- }
789
- /**
790
- * Minimal union-find with path compression. Indices are the slot positions in
791
- * the flattened item pool (candidates ++ globals).
792
- */
793
- class UnionFind {
794
- parent;
795
- constructor(size) {
796
- this.parent = Array.from({ length: size }, (_, i) => i);
797
- }
798
- find(x) {
799
- let cur = x;
800
- while (this.parent[cur] !== cur) {
801
- this.parent[cur] = this.parent[this.parent[cur]];
802
- cur = this.parent[cur];
803
- }
804
- return cur;
805
- }
806
- union(a, b) {
807
- const ra = this.find(a);
808
- const rb = this.find(b);
809
- if (ra !== rb)
810
- this.parent[ra] = rb;
811
- }
812
- }
813
- function buildShortlists(items) {
814
- const empty = {
815
- shortlists: [],
816
- total_valid_groups: 0,
817
- shortlists_truncated_count: 0,
818
- members_truncated_total: 0,
819
- shortlists_cap_dropped_count: 0,
820
- };
821
- if (items.length < 2)
822
- return empty;
823
- const tokens = items.map((it) => significantTokens(it.content));
824
- const uf = new UnionFind(items.length);
825
- for (let i = 0; i < items.length; i++) {
826
- for (let j = i + 1; j < items.length; j++) {
827
- if (items[i].agent_id === items[j].agent_id)
828
- continue;
829
- const sim = jaccard(tokens[i], tokens[j]);
830
- if (sim >= JACCARD_THRESHOLD)
831
- uf.union(i, j);
832
- }
833
- }
834
- // Group indices by root
835
- const groups = new Map();
836
- for (let i = 0; i < items.length; i++) {
837
- const root = uf.find(i);
838
- const bucket = groups.get(root);
839
- if (bucket)
840
- bucket.push(i);
841
- else
842
- groups.set(root, [i]);
843
- }
844
- // First pass: filter to VALID groups (≥2 items, ≥2 distinct agents) and
845
- // record total valid group count so cap-drop tallies are accurate.
846
- const sortedRoots = [...groups.keys()].sort((a, b) => a - b);
847
- const validGroups = [];
848
- for (const root of sortedRoots) {
849
- const indices = groups.get(root);
850
- if (indices.length < 2)
851
- continue;
852
- const agents = new Set(indices.map((idx) => items[idx].agent_id));
853
- if (agents.size < 2)
854
- continue;
855
- validGroups.push(indices);
856
- }
857
- // Second pass: apply the per-shortlist size cap and the total shortlist
858
- // count cap while recording bounded-loss metrics for C4.
859
- const shortlists = [];
860
- let shortlistsTruncatedCount = 0;
861
- let membersTruncatedTotal = 0;
862
- for (const indices of validGroups) {
863
- if (shortlists.length >= MAX_SHORTLISTS_PER_RUN)
864
- break;
865
- let capped = indices;
866
- if (indices.length > MAX_ITEMS_PER_SHORTLIST) {
867
- capped = indices.slice(0, MAX_ITEMS_PER_SHORTLIST);
868
- shortlistsTruncatedCount += 1;
869
- membersTruncatedTotal += indices.length - MAX_ITEMS_PER_SHORTLIST;
870
- }
871
- shortlists.push(capped.map((idx) => items[idx]));
872
- }
873
- const shortlistsCapDroppedCount = Math.max(0, validGroups.length - shortlists.length);
874
- return {
875
- shortlists,
876
- total_valid_groups: validGroups.length,
877
- shortlists_truncated_count: shortlistsTruncatedCount,
878
- members_truncated_total: membersTruncatedTotal,
879
- shortlists_cap_dropped_count: shortlistsCapDroppedCount,
880
- };
881
- }
882
- function buildCrossAgentDedupUserPrompt(items) {
883
- const lines = [
884
- "Learnings from different agents to compare:",
885
- "",
886
- ];
887
- items.forEach((item, i) => {
888
- lines.push(`${i + 1}. agent_id=${item.agent_id}`, ` role=${item.role ?? "null"}`, ` tags=[${item.applicability_tags.join(" ")}]`, ` source=${item.source_project ?? "?"}/${item.source_domain ?? "?"}/${item.source_date ?? "?"}`, ` content: ${item.content}`, "");
889
- });
890
- lines.push("Apply the same-principle test and respond with the JSON object.");
891
- return lines.join("\n");
892
- }
893
- async function llmConfirmCluster(items, modelId) {
894
- let responseText;
895
- try {
896
- const result = await callLlm(CROSS_AGENT_DEDUP_SYSTEM_PROMPT, buildCrossAgentDedupUserPrompt(items), {
897
- max_tokens: 1024,
898
- ...(modelId ? { model_id: modelId } : {}),
899
- });
900
- responseText = result.text;
901
- }
902
- catch (error) {
903
- return {
904
- ok: false,
905
- failure: {
906
- kind: "provider_error",
907
- detail: error instanceof Error ? error.message : String(error),
908
- },
909
- };
910
- }
911
- let parsed;
912
- try {
913
- let cleaned = responseText.trim();
914
- if (cleaned.startsWith("```")) {
915
- cleaned = cleaned.replace(/^```(?:json)?\s*/, "").replace(/```\s*$/, "");
916
- }
917
- parsed = JSON.parse(cleaned);
918
- }
919
- catch (error) {
920
- return {
921
- ok: false,
922
- failure: {
923
- kind: "malformed_json",
924
- detail: error instanceof Error ? error.message : String(error),
925
- },
926
- };
927
- }
928
- if (parsed.same_principle !== true) {
929
- return { ok: false, failure: { kind: "same_principle_false" } };
930
- }
931
- if (typeof parsed.primary_owner_agent !== "string") {
932
- return { ok: false, failure: { kind: "missing_field", field: "primary_owner_agent" } };
933
- }
934
- if (typeof parsed.consolidated_principle !== "string") {
935
- return { ok: false, failure: { kind: "missing_field", field: "consolidated_principle" } };
936
- }
937
- if (typeof parsed.consolidated_line !== "string") {
938
- return { ok: false, failure: { kind: "missing_field", field: "consolidated_line" } };
939
- }
940
- if (!Array.isArray(parsed.representative_cases)) {
941
- return { ok: false, failure: { kind: "missing_field", field: "representative_cases" } };
942
- }
943
- // C2 fix: primary_owner_agent must be one of the shortlist members. The
944
- // LLM can hallucinate an agent_id or pick an unrelated one; we fail closed
945
- // so approval never routes to an off-shortlist file.
946
- const shortlistAgents = new Set(items.map((it) => it.agent_id));
947
- if (!shortlistAgents.has(parsed.primary_owner_agent)) {
948
- return {
949
- ok: false,
950
- failure: {
951
- kind: "primary_owner_not_in_shortlist",
952
- declared: parsed.primary_owner_agent,
953
- },
954
- };
955
- }
956
- return {
957
- ok: true,
958
- verdict: {
959
- primary_owner_agent: parsed.primary_owner_agent,
960
- primary_owner_reason: typeof parsed.primary_owner_reason === "string"
961
- ? parsed.primary_owner_reason
962
- : "",
963
- consolidated_principle: parsed.consolidated_principle,
964
- representative_cases: parsed.representative_cases.filter((c) => typeof c === "string"),
965
- consolidated_line: parsed.consolidated_line,
966
- },
967
- };
968
- }
969
- /**
970
- * Stable id derived from member identity so repeat runs against unchanged
971
- * inputs emit the same cluster_id.
972
- *
973
- * Stability caveat (review CC2):
974
- * cluster_id is derived from the SHORTLIST members, not the full valid
975
- * group. The shortlist may have been truncated by MAX_ITEMS_PER_SHORTLIST
976
- * or the corpus may have grown between runs. Both conditions change the
977
- * hashed member set and therefore the cluster_id.
978
- *
979
- * This is the intentional trade-off: cluster_id is meant to identify
980
- * "the cluster the LLM reviewed in THIS run," not "the canonical cluster
981
- * for this principle across all runs." The applicator uses cluster_id
982
- * for within-run apply-state idempotency (matching a cluster_id marker
983
- * in the target file when re-applying the same report). It is NOT a
984
- * durable operator-facing identity across independent runs; treat
985
- * cluster_id as session-scoped and re-derive it from the report JSON
986
- * when you need to match an existing apply.
987
- *
988
- * If a future consumer needs cross-run identity, derive it from the
989
- * consolidated_principle text + primary_owner_agent instead, and keep
990
- * cluster_id as the run-local id.
991
- */
992
- function hashCluster(items) {
993
- const canonical = items
994
- .map((it) => `${it.agent_id}|${it.content}`)
995
- .sort()
996
- .join("\n");
997
- return crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 12);
998
- }
999
- function emptyMetrics(poolSize) {
1000
- return {
1001
- pool_size: poolSize,
1002
- total_valid_groups: 0,
1003
- shortlists_processed: 0,
1004
- shortlists_cap_dropped_count: 0,
1005
- shortlists_truncated_count: 0,
1006
- members_truncated_total: 0,
1007
- same_principle_rejected: 0,
1008
- llm_failures: {
1009
- provider_error: 0,
1010
- malformed_json: 0,
1011
- missing_field: 0,
1012
- primary_owner_not_in_shortlist: 0,
1013
- },
1014
- };
1015
- }
1016
- export async function discoverCrossAgentDedupClusters(candidates, globalItems, config = {}) {
1017
- // Candidates and globals are folded into a single pool so cross-scope
1018
- // duplicates surface along with cross-agent duplicates inside either pool.
1019
- const pool = [...candidates, ...globalItems];
1020
- const metrics = emptyMetrics(pool.length);
1021
- const built = buildShortlists(pool);
1022
- metrics.total_valid_groups = built.total_valid_groups;
1023
- metrics.shortlists_cap_dropped_count = built.shortlists_cap_dropped_count;
1024
- metrics.shortlists_truncated_count = built.shortlists_truncated_count;
1025
- metrics.members_truncated_total = built.members_truncated_total;
1026
- if (built.shortlists.length === 0) {
1027
- return { clusters: [], metrics };
1028
- }
1029
- const clusters = [];
1030
- for (const shortlist of built.shortlists) {
1031
- metrics.shortlists_processed += 1;
1032
- const outcome = await llmConfirmCluster(shortlist, config.modelId);
1033
- if (!outcome.ok) {
1034
- switch (outcome.failure.kind) {
1035
- case "provider_error":
1036
- metrics.llm_failures.provider_error += 1;
1037
- break;
1038
- case "malformed_json":
1039
- metrics.llm_failures.malformed_json += 1;
1040
- break;
1041
- case "same_principle_false":
1042
- // UF2: not an llm_failure — valid negative classification.
1043
- metrics.same_principle_rejected += 1;
1044
- break;
1045
- case "missing_field":
1046
- metrics.llm_failures.missing_field += 1;
1047
- break;
1048
- case "primary_owner_not_in_shortlist":
1049
- metrics.llm_failures.primary_owner_not_in_shortlist += 1;
1050
- break;
1051
- }
1052
- continue;
1053
- }
1054
- const verdict = outcome.verdict;
1055
- // CG1 + SYN-C1: Select the specific primary MEMBER INDEX (not raw_line
1056
- // or agent_id) among shortlist members sharing the LLM-chosen
1057
- // primary_owner_agent. An index is the only unambiguous identity when
1058
- // multiple shortlist members share identical content.
1059
- const primaryIndex = pickPrimaryMemberIndex(shortlist, verdict.primary_owner_agent);
1060
- clusters.push({
1061
- cluster_id: hashCluster(shortlist),
1062
- primary_owner_agent: verdict.primary_owner_agent,
1063
- primary_owner_reason: verdict.primary_owner_reason,
1064
- primary_member_index: primaryIndex,
1065
- consolidated_principle: verdict.consolidated_principle,
1066
- representative_cases: verdict.representative_cases,
1067
- member_items: shortlist,
1068
- consolidated_line: verdict.consolidated_line,
1069
- user_approval_required: true,
1070
- });
1071
- }
1072
- return { clusters, metrics };
1073
- }
1074
- /**
1075
- * Pick the specific shortlist MEMBER INDEX that acts as the primary owner.
1076
- *
1077
- * Precondition: the shortlist was LLM-confirmed AND the owner agent was
1078
- * validated against shortlist membership (the C2 guard), so at least one
1079
- * member with `primary_owner_agent` is guaranteed to exist.
1080
- *
1081
- * Selection rule (promote.md §3 criterion 6 tiebreaker: "먼저 생성된 학습"):
1082
- * 1. Filter shortlist to members whose `agent_id === primaryOwnerAgent`.
1083
- * 2. Among those, prefer the member with the EARLIEST `source_date` in
1084
- * ISO-8601 lexicographic order.
1085
- * 3. Tiebreakers beyond source_date:
1086
- * a. Dated members ALWAYS outrank null-dated members. A dated entry
1087
- * carries verifiable provenance; a null-dated entry is a legacy
1088
- * line with unknown age. When a timestamped and an untimed member
1089
- * share the primary_owner_agent, the timestamped one wins.
1090
- * b. Within all-null-dated members, the original shortlist ordering
1091
- * is preserved (stable sort on equal keys).
1092
- * c. Within equal source_date members, the original shortlist
1093
- * ordering is preserved.
1094
- * 4. The FIRST entry after sort is the winner; its ORIGINAL index in the
1095
- * unsorted shortlist is returned (so downstream apply filters by slot
1096
- * identity, not by content).
1097
- *
1098
- * Contract note (SYN-D1): the "null dated sorts AFTER dated" rule is
1099
- * deliberate — "earliest known provenance" is a stronger signal than
1100
- * "appears first in shortlist." If every owner candidate is null-dated,
1101
- * the winner is the first one encountered, which is also stable and
1102
- * deterministic (shortlist order is already deterministic per
1103
- * buildShortlists).
1104
- */
1105
- function pickPrimaryMemberIndex(shortlist, primaryOwnerAgent) {
1106
- // Collect (index, item) pairs for owner candidates so we can sort by
1107
- // source_date while preserving original slot positions.
1108
- const ownerPairs = [];
1109
- for (let i = 0; i < shortlist.length; i++) {
1110
- const item = shortlist[i];
1111
- if (item.agent_id === primaryOwnerAgent) {
1112
- ownerPairs.push({ index: i, item });
1113
- }
1114
- }
1115
- // Guaranteed non-empty by the C2 precondition.
1116
- //
1117
- // 4-Rec3: Explicit slot tiebreaker. Previously this relied on
1118
- // Array.prototype.sort's stability (ECMAScript 2019+ / Node 12+) to
1119
- // preserve first-seen ordering on equal sort keys. That implicit
1120
- // dependency is documented-but-fragile — a code reader inspecting this
1121
- // function shouldn't have to know the Node engine floor to predict
1122
- // tie-break behavior. We now use original slot index as the explicit
1123
- // last tiebreaker in the comparator, so the selection is deterministic
1124
- // regardless of the runtime's sort stability guarantees.
1125
- ownerPairs.sort((a, b) => {
1126
- const aDate = a.item.source_date;
1127
- const bDate = b.item.source_date;
1128
- // Rule 1: dated BEFORE null-dated (stronger provenance wins).
1129
- if (aDate === null && bDate === null) {
1130
- // Rule 3 (tiebreaker): lower slot index wins — explicit, no
1131
- // stability reliance.
1132
- return a.index - b.index;
1133
- }
1134
- if (aDate === null)
1135
- return 1;
1136
- if (bDate === null)
1137
- return -1;
1138
- // Rule 2: both dated — ascending lexicographic (earliest first).
1139
- const cmp = aDate.localeCompare(bDate);
1140
- if (cmp !== 0)
1141
- return cmp;
1142
- // Rule 3 (tiebreaker): equal dates → lower slot index wins.
1143
- return a.index - b.index;
1144
- });
1145
- return ownerPairs[0].index;
1146
- }
1147
- // Test-only exports for unit coverage. Production imports go through
1148
- // discoverCrossAgentDedupClusters.
1149
- export const __testExports = {
1150
- significantTokens,
1151
- jaccard,
1152
- buildShortlists,
1153
- pickPrimaryMemberIndex,
1154
- JACCARD_THRESHOLD,
1155
- MAX_SHORTLISTS_PER_RUN,
1156
- MAX_ITEMS_PER_SHORTLIST,
1157
- MIN_CJK_TOKEN_LENGTH,
1158
- };