audit-tools 0.34.41 → 0.35.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 (328) hide show
  1. package/dist/audit/cli/args.d.ts +6 -0
  2. package/dist/audit/cli/args.d.ts.map +1 -1
  3. package/dist/audit/cli/args.js +21 -4
  4. package/dist/audit/cli/args.js.map +1 -1
  5. package/dist/audit/cli/auditStep.d.ts +6 -15
  6. package/dist/audit/cli/auditStep.d.ts.map +1 -1
  7. package/dist/audit/cli/auditStep.js +1 -1
  8. package/dist/audit/cli/auditStep.js.map +1 -1
  9. package/dist/audit/cli/dispatch/hostFanoutGate.d.ts +21 -61
  10. package/dist/audit/cli/dispatch/hostFanoutGate.d.ts.map +1 -1
  11. package/dist/audit/cli/dispatch/hostFanoutGate.js +18 -167
  12. package/dist/audit/cli/dispatch/hostFanoutGate.js.map +1 -1
  13. package/dist/audit/cli/dispatch/packetFilter.d.ts +2 -2
  14. package/dist/audit/cli/dispatch/packetFilter.d.ts.map +1 -1
  15. package/dist/audit/cli/dispatch/packetFilter.js +3 -1
  16. package/dist/audit/cli/dispatch/packetFilter.js.map +1 -1
  17. package/dist/audit/cli/dispatch/quotaPool.d.ts +2 -14
  18. package/dist/audit/cli/dispatch/quotaPool.d.ts.map +1 -1
  19. package/dist/audit/cli/dispatch/quotaPool.js +16 -6
  20. package/dist/audit/cli/dispatch/quotaPool.js.map +1 -1
  21. package/dist/audit/cli/dispatch/tierRouting.d.ts +2 -2
  22. package/dist/audit/cli/dispatch/tokenUsageObservation.d.ts.map +1 -1
  23. package/dist/audit/cli/dispatch/tokenUsageObservation.js +10 -2
  24. package/dist/audit/cli/dispatch/tokenUsageObservation.js.map +1 -1
  25. package/dist/audit/cli/dispatch.d.ts +14 -3
  26. package/dist/audit/cli/dispatch.d.ts.map +1 -1
  27. package/dist/audit/cli/dispatch.js +87 -65
  28. package/dist/audit/cli/dispatch.js.map +1 -1
  29. package/dist/audit/cli/forceSynthesisCommand.d.ts.map +1 -1
  30. package/dist/audit/cli/forceSynthesisCommand.js +9 -1
  31. package/dist/audit/cli/forceSynthesisCommand.js.map +1 -1
  32. package/dist/audit/cli/hybridDispatch.d.ts +4 -9
  33. package/dist/audit/cli/hybridDispatch.d.ts.map +1 -1
  34. package/dist/audit/cli/hybridDispatch.js +1 -1
  35. package/dist/audit/cli/hybridDispatch.js.map +1 -1
  36. package/dist/audit/cli/lineIndex.d.ts +2 -0
  37. package/dist/audit/cli/lineIndex.d.ts.map +1 -1
  38. package/dist/audit/cli/lineIndex.js +4 -0
  39. package/dist/audit/cli/lineIndex.js.map +1 -1
  40. package/dist/audit/cli/nextStepCommand.d.ts +0 -26
  41. package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
  42. package/dist/audit/cli/nextStepCommand.js +14 -358
  43. package/dist/audit/cli/nextStepCommand.js.map +1 -1
  44. package/dist/audit/cli/nextStepHelpers.d.ts +5 -16
  45. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  46. package/dist/audit/cli/nextStepHelpers.js +27 -157
  47. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  48. package/dist/audit/cli/quotaCommand.d.ts.map +1 -1
  49. package/dist/audit/cli/quotaCommand.js +1 -5
  50. package/dist/audit/cli/quotaCommand.js.map +1 -1
  51. package/dist/audit/cli/reviewRun.d.ts.map +1 -1
  52. package/dist/audit/cli/reviewRun.js +58 -23
  53. package/dist/audit/cli/reviewRun.js.map +1 -1
  54. package/dist/audit/cli/rollingAuditDispatch.d.ts +1 -1
  55. package/dist/audit/cli/semanticReviewStep.d.ts.map +1 -1
  56. package/dist/audit/cli/semanticReviewStep.js +35 -54
  57. package/dist/audit/cli/semanticReviewStep.js.map +1 -1
  58. package/dist/audit/cli/steps.d.ts +4 -4
  59. package/dist/audit/cli/steps.d.ts.map +1 -1
  60. package/dist/audit/cli/steps.js +0 -1
  61. package/dist/audit/cli/steps.js.map +1 -1
  62. package/dist/audit/cli/workPartitionRuntime.d.ts +11 -0
  63. package/dist/audit/cli/workPartitionRuntime.d.ts.map +1 -0
  64. package/dist/audit/cli/workPartitionRuntime.js +39 -0
  65. package/dist/audit/cli/workPartitionRuntime.js.map +1 -0
  66. package/dist/audit/contracts/wrapperResponse.d.ts +6 -6
  67. package/dist/audit/io/artifacts.d.ts +1 -3
  68. package/dist/audit/io/artifacts.d.ts.map +1 -1
  69. package/dist/audit/io/artifacts.js +0 -3
  70. package/dist/audit/io/artifacts.js.map +1 -1
  71. package/dist/audit/orchestrator/advance.d.ts.map +1 -1
  72. package/dist/audit/orchestrator/advance.js +0 -35
  73. package/dist/audit/orchestrator/advance.js.map +1 -1
  74. package/dist/audit/orchestrator/advanceTypes.d.ts +4 -63
  75. package/dist/audit/orchestrator/advanceTypes.d.ts.map +1 -1
  76. package/dist/audit/orchestrator/executorRunners.d.ts.map +1 -1
  77. package/dist/audit/orchestrator/executorRunners.js +9 -12
  78. package/dist/audit/orchestrator/executorRunners.js.map +1 -1
  79. package/dist/audit/orchestrator/executors.d.ts.map +1 -1
  80. package/dist/audit/orchestrator/executors.js +0 -5
  81. package/dist/audit/orchestrator/executors.js.map +1 -1
  82. package/dist/audit/orchestrator/hostInputPause.d.ts +1 -1
  83. package/dist/audit/orchestrator/hostInputPause.js +1 -1
  84. package/dist/audit/orchestrator/ingestionExecutors.d.ts.map +1 -1
  85. package/dist/audit/orchestrator/ingestionExecutors.js +4 -1
  86. package/dist/audit/orchestrator/ingestionExecutors.js.map +1 -1
  87. package/dist/audit/orchestrator/intakeExecutors.d.ts +1 -65
  88. package/dist/audit/orchestrator/intakeExecutors.d.ts.map +1 -1
  89. package/dist/audit/orchestrator/intakeExecutors.js +3 -425
  90. package/dist/audit/orchestrator/intakeExecutors.js.map +1 -1
  91. package/dist/audit/orchestrator/nextStep.d.ts +0 -13
  92. package/dist/audit/orchestrator/nextStep.d.ts.map +1 -1
  93. package/dist/audit/orchestrator/nextStep.js +0 -7
  94. package/dist/audit/orchestrator/nextStep.js.map +1 -1
  95. package/dist/audit/orchestrator/planningExecutors.d.ts.map +1 -1
  96. package/dist/audit/orchestrator/planningExecutors.js +4 -1
  97. package/dist/audit/orchestrator/planningExecutors.js.map +1 -1
  98. package/dist/audit/orchestrator/rollingDispatch.d.ts +1 -1
  99. package/dist/audit/orchestrator/rollingDispatch.d.ts.map +1 -1
  100. package/dist/audit/orchestrator/rollingDispatch.js +5 -4
  101. package/dist/audit/orchestrator/rollingDispatch.js.map +1 -1
  102. package/dist/audit/orchestrator/state.d.ts +0 -39
  103. package/dist/audit/orchestrator/state.d.ts.map +1 -1
  104. package/dist/audit/orchestrator/state.js +3 -29
  105. package/dist/audit/orchestrator/state.js.map +1 -1
  106. package/dist/audit/orchestrator/synthesisExecutors.d.ts +8 -2
  107. package/dist/audit/orchestrator/synthesisExecutors.d.ts.map +1 -1
  108. package/dist/audit/orchestrator/synthesisExecutors.js +7 -5
  109. package/dist/audit/orchestrator/synthesisExecutors.js.map +1 -1
  110. package/dist/audit/reporting/findingIdentity.d.ts +2 -2
  111. package/dist/audit/reporting/findingIdentity.js +2 -2
  112. package/dist/audit/reporting/synthesis.d.ts +7 -1
  113. package/dist/audit/reporting/synthesis.d.ts.map +1 -1
  114. package/dist/audit/reporting/synthesis.js +27 -7
  115. package/dist/audit/reporting/synthesis.js.map +1 -1
  116. package/dist/audit/reporting/workBlocks.d.ts +14 -3
  117. package/dist/audit/reporting/workBlocks.d.ts.map +1 -1
  118. package/dist/audit/reporting/workBlocks.js +163 -73
  119. package/dist/audit/reporting/workBlocks.js.map +1 -1
  120. package/dist/audit/types/activeDispatch.d.ts +8 -7
  121. package/dist/audit/types/activeDispatch.d.ts.map +1 -1
  122. package/dist/audit/types/auditState.d.ts +6 -6
  123. package/dist/audit/types/externalAnalyzer.d.ts +6 -6
  124. package/dist/audit/validation/auditResults.d.ts.map +1 -1
  125. package/dist/audit/validation/auditResults.js +6 -1
  126. package/dist/audit/validation/auditResults.js.map +1 -1
  127. package/dist/remediate/contractPipeline/phaseCut.d.ts +8 -0
  128. package/dist/remediate/contractPipeline/phaseCut.d.ts.map +1 -1
  129. package/dist/remediate/contractPipeline/phaseCut.js +63 -0
  130. package/dist/remediate/contractPipeline/phaseCut.js.map +1 -1
  131. package/dist/remediate/contractPipeline/phaseCutArtifact.d.ts.map +1 -1
  132. package/dist/remediate/contractPipeline/phaseCutArtifact.js +5 -3
  133. package/dist/remediate/contractPipeline/phaseCutArtifact.js.map +1 -1
  134. package/dist/remediate/phases/plan.d.ts.map +1 -1
  135. package/dist/remediate/phases/plan.js +35 -12
  136. package/dist/remediate/phases/plan.js.map +1 -1
  137. package/dist/remediate/steps/contractPipeline.d.ts +6 -2
  138. package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
  139. package/dist/remediate/steps/contractPipeline.js +90 -6
  140. package/dist/remediate/steps/contractPipeline.js.map +1 -1
  141. package/dist/remediate/steps/contractPipelinePrompts.d.ts.map +1 -1
  142. package/dist/remediate/steps/contractPipelinePrompts.js +4 -2
  143. package/dist/remediate/steps/contractPipelinePrompts.js.map +1 -1
  144. package/dist/remediate/steps/dispatch/marshal.d.ts +6 -0
  145. package/dist/remediate/steps/dispatch/marshal.d.ts.map +1 -1
  146. package/dist/remediate/steps/dispatch/marshal.js +16 -17
  147. package/dist/remediate/steps/dispatch/marshal.js.map +1 -1
  148. package/dist/remediate/steps/dispatch/waveScheduling.d.ts +7 -15
  149. package/dist/remediate/steps/dispatch/waveScheduling.d.ts.map +1 -1
  150. package/dist/remediate/steps/dispatch/waveScheduling.js +26 -19
  151. package/dist/remediate/steps/dispatch/waveScheduling.js.map +1 -1
  152. package/dist/remediate/steps/nextStep.d.ts +5 -7
  153. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  154. package/dist/remediate/steps/nextStep.js +100 -82
  155. package/dist/remediate/steps/nextStep.js.map +1 -1
  156. package/dist/remediate/steps/rollingSession.d.ts +2 -1
  157. package/dist/remediate/steps/rollingSession.d.ts.map +1 -1
  158. package/dist/remediate/steps/rollingSession.js +14 -8
  159. package/dist/remediate/steps/rollingSession.js.map +1 -1
  160. package/dist/remediate/steps/sessionConfigLoad.d.ts +3 -2
  161. package/dist/remediate/steps/sessionConfigLoad.d.ts.map +1 -1
  162. package/dist/remediate/steps/sessionConfigLoad.js +4 -10
  163. package/dist/remediate/steps/sessionConfigLoad.js.map +1 -1
  164. package/dist/remediate/validation/contractPipeline.d.ts +1 -1
  165. package/dist/remediate/validation/contractPipeline.d.ts.map +1 -1
  166. package/dist/remediate/validation/contractPipeline.js +7 -1
  167. package/dist/remediate/validation/contractPipeline.js.map +1 -1
  168. package/dist/remediate/validation/contractPipelineGates.d.ts +8 -0
  169. package/dist/remediate/validation/contractPipelineGates.d.ts.map +1 -1
  170. package/dist/remediate/validation/contractPipelineGates.js +89 -0
  171. package/dist/remediate/validation/contractPipelineGates.js.map +1 -1
  172. package/dist/shared/config/resolveSessionConfig.d.ts.map +1 -1
  173. package/dist/shared/config/resolveSessionConfig.js +6 -5
  174. package/dist/shared/config/resolveSessionConfig.js.map +1 -1
  175. package/dist/shared/decompose/workPartition.d.ts +66 -0
  176. package/dist/shared/decompose/workPartition.d.ts.map +1 -0
  177. package/dist/shared/decompose/workPartition.js +523 -0
  178. package/dist/shared/decompose/workPartition.js.map +1 -0
  179. package/dist/shared/dispatch/admissionLoop.d.ts +5 -44
  180. package/dist/shared/dispatch/admissionLoop.d.ts.map +1 -1
  181. package/dist/shared/dispatch/admissionLoop.js +20 -82
  182. package/dist/shared/dispatch/admissionLoop.js.map +1 -1
  183. package/dist/shared/dispatch/coordinator.d.ts +5 -5
  184. package/dist/shared/dispatch/coordinator.d.ts.map +1 -1
  185. package/dist/shared/dispatch/coordinator.js +12 -11
  186. package/dist/shared/dispatch/coordinator.js.map +1 -1
  187. package/dist/shared/dispatch/costRank.d.ts +5 -155
  188. package/dist/shared/dispatch/costRank.d.ts.map +1 -1
  189. package/dist/shared/dispatch/costRank.js +2 -179
  190. package/dist/shared/dispatch/costRank.js.map +1 -1
  191. package/dist/shared/dispatch/dispatchQuotaContract.d.ts +37 -38
  192. package/dist/shared/dispatch/dispatchQuotaContract.d.ts.map +1 -1
  193. package/dist/shared/dispatch/dispatchQuotaContract.js +0 -1
  194. package/dist/shared/dispatch/dispatchQuotaContract.js.map +1 -1
  195. package/dist/shared/dispatch/hybridDispatch.d.ts +6 -2
  196. package/dist/shared/dispatch/hybridDispatch.d.ts.map +1 -1
  197. package/dist/shared/dispatch/hybridDispatch.js +1 -1
  198. package/dist/shared/dispatch/hybridDispatch.js.map +1 -1
  199. package/dist/shared/dispatch/rollingDispatch.d.ts +4 -4
  200. package/dist/shared/dispatch/rollingDispatch.d.ts.map +1 -1
  201. package/dist/shared/dispatch/rollingDispatch.js +13 -11
  202. package/dist/shared/dispatch/rollingDispatch.js.map +1 -1
  203. package/dist/shared/dispatch/unifiedRolling.d.ts +2 -2
  204. package/dist/shared/dispatch/unifiedRolling.d.ts.map +1 -1
  205. package/dist/shared/dispatch/unifiedRolling.js +1 -1
  206. package/dist/shared/dispatch/unifiedRolling.js.map +1 -1
  207. package/dist/shared/engine/obligationEngine.d.ts +2 -2
  208. package/dist/shared/friction/stepBoundaryCapture.d.ts +5 -59
  209. package/dist/shared/friction/stepBoundaryCapture.d.ts.map +1 -1
  210. package/dist/shared/friction/stepBoundaryCapture.js +11 -107
  211. package/dist/shared/friction/stepBoundaryCapture.js.map +1 -1
  212. package/dist/shared/index.d.ts +13 -16
  213. package/dist/shared/index.d.ts.map +1 -1
  214. package/dist/shared/index.js +9 -13
  215. package/dist/shared/index.js.map +1 -1
  216. package/dist/shared/io/stateDir.d.ts +2 -2
  217. package/dist/shared/io/stateDir.js +2 -2
  218. package/dist/shared/providers/auditorSources.d.ts +10 -84
  219. package/dist/shared/providers/auditorSources.d.ts.map +1 -1
  220. package/dist/shared/providers/auditorSources.js +42 -261
  221. package/dist/shared/providers/auditorSources.js.map +1 -1
  222. package/dist/shared/providers/dispatchExclusion.d.ts +22 -0
  223. package/dist/shared/providers/dispatchExclusion.d.ts.map +1 -0
  224. package/dist/shared/providers/dispatchExclusion.js +23 -0
  225. package/dist/shared/providers/dispatchExclusion.js.map +1 -0
  226. package/dist/shared/providers/identity.d.ts +2 -66
  227. package/dist/shared/providers/identity.d.ts.map +1 -1
  228. package/dist/shared/providers/identity.js +2 -72
  229. package/dist/shared/providers/identity.js.map +1 -1
  230. package/dist/shared/providers/providerFactory.d.ts +0 -3
  231. package/dist/shared/providers/providerFactory.d.ts.map +1 -1
  232. package/dist/shared/providers/providerFactory.js +5 -32
  233. package/dist/shared/providers/providerFactory.js.map +1 -1
  234. package/dist/shared/providers/providerPathGuard.d.ts +3 -3
  235. package/dist/shared/providers/providerPathGuard.js +7 -7
  236. package/dist/shared/providers/providerPathGuard.js.map +1 -1
  237. package/dist/shared/quota/apiPool.d.ts +19 -22
  238. package/dist/shared/quota/apiPool.d.ts.map +1 -1
  239. package/dist/shared/quota/apiPool.js +66 -47
  240. package/dist/shared/quota/apiPool.js.map +1 -1
  241. package/dist/shared/quota/capacity.d.ts +29 -24
  242. package/dist/shared/quota/capacity.d.ts.map +1 -1
  243. package/dist/shared/quota/capacity.js +30 -4
  244. package/dist/shared/quota/capacity.js.map +1 -1
  245. package/dist/shared/quota/codexHostConfig.d.ts +6 -13
  246. package/dist/shared/quota/codexHostConfig.d.ts.map +1 -1
  247. package/dist/shared/quota/codexHostConfig.js +8 -14
  248. package/dist/shared/quota/codexHostConfig.js.map +1 -1
  249. package/dist/shared/quota/fileLock.d.ts +7 -0
  250. package/dist/shared/quota/fileLock.d.ts.map +1 -1
  251. package/dist/shared/quota/fileLock.js +32 -4
  252. package/dist/shared/quota/fileLock.js.map +1 -1
  253. package/dist/shared/quota/hostLimits.d.ts +1 -1
  254. package/dist/shared/quota/hostLimits.d.ts.map +1 -1
  255. package/dist/shared/quota/hostLimits.js +8 -11
  256. package/dist/shared/quota/hostLimits.js.map +1 -1
  257. package/dist/shared/quota/hostPool.d.ts +1 -1
  258. package/dist/shared/quota/hostPool.js +2 -2
  259. package/dist/shared/quota/hostPool.js.map +1 -1
  260. package/dist/shared/quota/limits.d.ts.map +1 -1
  261. package/dist/shared/quota/limits.js +30 -26
  262. package/dist/shared/quota/limits.js.map +1 -1
  263. package/dist/shared/quota/scheduler.d.ts +2 -3
  264. package/dist/shared/quota/scheduler.d.ts.map +1 -1
  265. package/dist/shared/quota/scheduler.js +1 -1
  266. package/dist/shared/quota/scheduler.js.map +1 -1
  267. package/dist/shared/quota/types.d.ts +10 -10
  268. package/dist/shared/quota/types.d.ts.map +1 -1
  269. package/dist/shared/quota/types.js +4 -7
  270. package/dist/shared/quota/types.js.map +1 -1
  271. package/dist/shared/repair/brokeredDispatch.d.ts.map +1 -1
  272. package/dist/shared/repair/brokeredDispatch.js +3 -0
  273. package/dist/shared/repair/brokeredDispatch.js.map +1 -1
  274. package/dist/shared/reporting/auditDeliverable.d.ts.map +1 -1
  275. package/dist/shared/reporting/auditDeliverable.js +1 -0
  276. package/dist/shared/reporting/auditDeliverable.js.map +1 -1
  277. package/dist/shared/rolling/pausedState.d.ts +7 -7
  278. package/dist/shared/rolling/pausedState.js +3 -3
  279. package/dist/shared/tokens.d.ts +2 -5
  280. package/dist/shared/tokens.d.ts.map +1 -1
  281. package/dist/shared/tokens.js +15 -15
  282. package/dist/shared/tokens.js.map +1 -1
  283. package/dist/shared/types/auditorDescriptor.d.ts +3 -2
  284. package/dist/shared/types/auditorDescriptor.d.ts.map +1 -1
  285. package/dist/shared/types/auditorDescriptor.js +3 -2
  286. package/dist/shared/types/auditorDescriptor.js.map +1 -1
  287. package/dist/shared/types/finding.d.ts +80 -0
  288. package/dist/shared/types/finding.d.ts.map +1 -1
  289. package/dist/shared/types/finding.js +17 -0
  290. package/dist/shared/types/finding.js.map +1 -1
  291. package/dist/shared/types/sessionConfig.d.ts +5 -5
  292. package/dist/shared/types/sessionConfig.js +1 -1
  293. package/dist/shared/validation/findingsReport.d.ts.map +1 -1
  294. package/dist/shared/validation/findingsReport.js +3 -0
  295. package/dist/shared/validation/findingsReport.js.map +1 -1
  296. package/docs/audit-pkg/development.md +3 -2
  297. package/docs/audit-pkg/operator-guide.md +13 -106
  298. package/docs/audit-pkg/product.md +8 -7
  299. package/docs/audit-pkg/release.md +38 -29
  300. package/opencode.json +2 -2
  301. package/package.json +7 -4
  302. package/skills/audit-code/SKILL.md +2 -1
  303. package/skills/audit-code/audit-code.prompt.md +4 -3
  304. package/skills/remediate-code/remediate-code.prompt.md +2 -1
  305. package/dist/audit/cli/providerConfirmationStep.d.ts +0 -106
  306. package/dist/audit/cli/providerConfirmationStep.d.ts.map +0 -1
  307. package/dist/audit/cli/providerConfirmationStep.js +0 -396
  308. package/dist/audit/cli/providerConfirmationStep.js.map +0 -1
  309. package/dist/audit/orchestrator/providerConfirmation.d.ts +0 -25
  310. package/dist/audit/orchestrator/providerConfirmation.d.ts.map +0 -1
  311. package/dist/audit/orchestrator/providerConfirmation.js +0 -60
  312. package/dist/audit/orchestrator/providerConfirmation.js.map +0 -1
  313. package/dist/shared/providers/providerConfirmation.d.ts +0 -141
  314. package/dist/shared/providers/providerConfirmation.d.ts.map +0 -1
  315. package/dist/shared/providers/providerConfirmation.js +0 -420
  316. package/dist/shared/providers/providerConfirmation.js.map +0 -1
  317. package/dist/shared/providers/proxyCatalog.d.ts +0 -158
  318. package/dist/shared/providers/proxyCatalog.d.ts.map +0 -1
  319. package/dist/shared/providers/proxyCatalog.js +0 -658
  320. package/dist/shared/providers/proxyCatalog.js.map +0 -1
  321. package/dist/shared/providers/sharedProviderConfirmation.d.ts +0 -802
  322. package/dist/shared/providers/sharedProviderConfirmation.d.ts.map +0 -1
  323. package/dist/shared/providers/sharedProviderConfirmation.js +0 -1865
  324. package/dist/shared/providers/sharedProviderConfirmation.js.map +0 -1
  325. package/dist/shared/types/providerConfirmation.d.ts +0 -334
  326. package/dist/shared/types/providerConfirmation.d.ts.map +0 -1
  327. package/dist/shared/types/providerConfirmation.js +0 -35
  328. package/dist/shared/types/providerConfirmation.js.map +0 -1
@@ -1,1865 +0,0 @@
1
- /**
2
- * DC-2 — shared, session-scoped provider confirmation (Gate-0).
3
- *
4
- * The design wants ONE provider confirmation spanning an audit→remediate run:
5
- * the first tool to run writes the operator's confirmed route DECISION to a
6
- * SHARED artifact at `<root>/.audit-tools/provider-confirmation.json` (NOT the
7
- * per-tool audit artifacts dir); the second tool reads and honors it. "Session" =
8
- * the shared `.audit-tools` dir for that repo+run, so no new identity scheme is
9
- * needed.
10
- *
11
- * **What this artifact carries is POLICY, not reach (G3).** The operator's
12
- * decision — exclusions, cost order, λ — is a set of *rules*, valid for any
13
- * auditor. What is *reachable* is per-auditor capability and is re-resolved from
14
- * live env/PATH at the moment of use, never inherited from whoever wrote this
15
- * file. So every read here is reach-free: it returns the persisted decision and
16
- * nothing else.
17
- *
18
- * INV-DC1-6 (never-block) is the only invariant left in tension, and it now
19
- * resolves to a plain two-valued read: a remediate run standalone with no prior
20
- * audit resolves its provider independently — absence or corruption of the
21
- * artifact is NOT an error, it is `null`.
22
- *
23
- * The former roster-staleness check (and its CE-012 three-valued `reconfirm`
24
- * state) is GONE. It compared the *writing* auditor's roster against the reader's
25
- * — meaningless cross-auditor by construction — and answered a real event (a
26
- * backend the operator never confirmed became reachable) by silently discarding
27
- * the operator's cost order and λ, while reaching no obligation at all. The
28
- * `autonomous_mode`-keyed reconciliation gate
29
- * ({@link computeNewlyReachableBackends}) replaces it: it compares the operator's
30
- * DECISION against *this* auditor's freshly-resolved reach, which is well-defined
31
- * across auditors, and it actually fires.
32
- *
33
- * CE-003 (lockless read races the writer rename): writes go through the shared
34
- * atomic writer (temp + atomic rename) under `withFileLock`, so a lockless
35
- * reader always observes either the complete old file or the complete new file —
36
- * never a torn intermediate.
37
- *
38
- * PB-1 (opencode opt-in): the confirmed pool is derived from `discoverProviders`,
39
- * which already withholds a bare-PATH opencode unless it is explicitly
40
- * configured, so the shared confirmation inherits that opt-in for free.
41
- */
42
- import { mkdir, unlink } from "node:fs/promises";
43
- import { join } from "node:path";
44
- import { PROVIDER_NAMES } from "../types/sessionConfig.js";
45
- import { isSelfSpawnBlocked } from "./providerPathGuard.js";
46
- import { auditToolsDir } from "../io/auditToolsPaths.js";
47
- import { readJsonFile, writeJsonFile } from "../io/json.js";
48
- import { withFileLock } from "../quota/fileLock.js";
49
- import { discoverProviders, annotateConfirmedPool, representativeModelId, } from "./providerConfirmation.js";
50
- import { backendIdentity, exclusionPattern, serviceExclusionPattern, } from "./identity.js";
51
- import { resolveConfirmedCostPositions } from "../dispatch/costRank.js";
52
- import { gatherDispatchableSources } from "../quota/apiPool.js";
53
- import { resolveFreshSessionProviderName } from "./providerFactory.js";
54
- import { PROVIDER_CONFIRMATION_INPUT_VERSION } from "../types/providerConfirmation.js";
55
- // ---------------------------------------------------------------------------
56
- // Version + on-disk location
57
- // ---------------------------------------------------------------------------
58
- /**
59
- * Schema version for the shared confirmation artifact. Bumped independently of
60
- * the per-tool seam contract (PROVIDER_CONFIRMATION_RESULT_VERSION) — this is
61
- * the cross-tool session artifact carrying the operator's route DECISION
62
- * (exclusions, cost order, λ), a distinct shape from the seam's pool snapshot.
63
- */
64
- export const SHARED_PROVIDER_CONFIRMATION_VERSION = "1.0.0";
65
- /**
66
- * Clamp an operator-supplied cost↔speed dispatch bias (λ) to [0, 1], or `undefined`
67
- * when it is absent/non-finite. Single-sourced so parse-time and read-time agree.
68
- * (spec/dispatch-quota.md).
69
- */
70
- export function clampDispatchBias(value) {
71
- if (typeof value !== "number" || !Number.isFinite(value))
72
- return undefined;
73
- return Math.min(1, Math.max(0, value));
74
- }
75
- /** File name of the shared session-level confirmation under `.audit-tools/`. */
76
- export const SHARED_PROVIDER_CONFIRMATION_FILENAME = "provider-confirmation.json";
77
- /** `<root>/.audit-tools/provider-confirmation.json` (absolute). */
78
- export function sharedProviderConfirmationPath(root) {
79
- return join(auditToolsDir(root), SHARED_PROVIDER_CONFIRMATION_FILENAME);
80
- }
81
- /**
82
- * Project the render DTO down to what actually gets PERSISTED: the operator's
83
- * decision, with this auditor's reach assessment dropped (G3 B+D).
84
- *
85
- * The PRODUCER is split — not the write site. `writeSharedProviderConfirmation`
86
- * receives an already-typed value, so projecting THERE would leave the reach fields
87
- * representable on the persisted type and a future caller could put them back.
88
- * Projecting here makes the persisted shape carry no reach BY CONSTRUCTION.
89
- */
90
- export function buildSharedProviderConfirmation(...args) {
91
- const rendered = buildProviderConfirmationRender(...args);
92
- return {
93
- ...rendered,
94
- provider_pool: rendered.provider_pool.map(toPersistedPoolEntry),
95
- };
96
- }
97
- /** The decision half of one pool entry. Reach is dropped, never persisted. */
98
- function toPersistedPoolEntry(entry) {
99
- return {
100
- name: entry.name,
101
- ...(entry.model_id !== undefined ? { model_id: entry.model_id } : {}),
102
- ...(entry.cost_order !== undefined ? { cost_order: entry.cost_order } : {}),
103
- // The capability-evidence decision persists (it is a DECISION, not reach) — and it
104
- // must be carried explicitly: this builder reconstructs field-by-field, so a field
105
- // absent here is silently dropped on every round-trip.
106
- ...(entry.capability_rank !== undefined ? { capability_rank: entry.capability_rank } : {}),
107
- };
108
- }
109
- function sortNames(names) {
110
- // Deduplicate + sort so a persisted list is order-insensitive and stable.
111
- return [...new Set(names)].sort();
112
- }
113
- function sortStrings(values) {
114
- // Deduplicate + sort so a persisted list is order-insensitive and stable.
115
- return [...new Set(values)].sort();
116
- }
117
- // ---------------------------------------------------------------------------
118
- // Build
119
- // ---------------------------------------------------------------------------
120
- /**
121
- * Build a fresh shared confirmation from auto-discovery. Guarantees the
122
- * always-available `worker-command` fallback is present in the pool (it blocks
123
- * auto-dispatch and so is never PATH-detected, but the pool must always be able
124
- * to fall back to it) and stamps the schema version, session-level flag, and
125
- * confirmation timestamp.
126
- *
127
- * SECURITY (self-spawn exclusion): a provider that `discoverProviders` flags as
128
- * `selfSpawnBlocked` (claude-code under `CLAUDECODE`, codex under `CODEX`) is set
129
- * `excluded: true` AND carries the machine-readable `self_spawn_blocked` flag, so
130
- * it is OUT of the dispatchable pool by default — launching it would self-spawn a
131
- * fresh agent from inside an active session of the same agent. The operator can
132
- * deliberately re-include it by naming it in `include`; that overrides the
133
- * exclusion (the host still always retains the worker-command fallback).
134
- *
135
- * @param sessionConfig - Current session config; may be an empty `{}`.
136
- * @param env - Process env snapshot; defaults to `process.env`.
137
- * @param exclude - {@link DispatchExclusionPattern}s to pre-exclude (from a
138
- * prior gate). A `provider:model` pattern marks a pool entry excluded only when
139
- * that entry's `representativeModelId` IS that model — the same key the routing
140
- * filter matches on, so display and dispatch cannot disagree.
141
- * @param include - Provider names the operator explicitly opts back IN,
142
- * overriding the default self-spawn-blocked exclusion for those names.
143
- * @param detectCommand - Injectable PATH-detection hook, forwarded to
144
- * `discoverProviders` so tests can drive discovery deterministically.
145
- * @param input - Operator's Gate-0 submission (interactive path): its
146
- * `cost_order` overrides the suggested ordering and its `host_models` become
147
- * priced, orderable host-native tiers (`host_model_cost_order`). Omit for the
148
- * headless / no-operator path — the tool then emits its price-ascending
149
- * suggestion with no host models, exactly as before. `exclude`/`include` are
150
- * passed via the dedicated params above (the executor forwards them from the
151
- * same input), so this arg governs ordering + host roster only.
152
- */
153
- export function buildProviderConfirmationRender(sessionConfig = {}, env = process.env, exclude = [], include = [], detectCommand, input, sources = [],
154
- /**
155
- * Gate-authored fail-closed patterns, kept SEPARATE from the operator's `exclude`
156
- * so provenance survives to disk (see {@link ConfirmedDispatchPolicy.auto_exclude}).
157
- * Both kinds mark a pool entry excluded in the render — the split governs lifetime,
158
- * not enforcement — so the two are unioned for the display/routing decision below
159
- * and only split again when the policy is persisted.
160
- */
161
- autoExclude = [],
162
- /**
163
- * R3-3: the FINAL `capability_order_llm_ranked` set for this promotion (already
164
- * advanced across rule 1/2 by the caller — {@link advanceCapabilityOrderLlmRanked}).
165
- * Persisted verbatim into the policy; empty for the display-only "suggested"
166
- * render nextStepCommand builds (that call never persists, so provenance is moot).
167
- */
168
- capabilityOrderLlmRanked = []) {
169
- const discovered = discoverProviders(sessionConfig, env, detectCommand);
170
- const operatorExcluded = buildExclusion(migrateExclusionPatterns([...exclude, ...autoExclude]));
171
- const includeSet = new Set(include);
172
- // Evaluate a pool entry against the operator's rules at the SAME key the routing
173
- // filter uses — `representativeModelId` is what `computeNewlyReachableBackends`
174
- // and `annotateConfirmedPool` key a provider by, so a `provider:model` rule marks
175
- // exactly the entry it will later filter.
176
- //
177
- // ⚠ The provider + model tiers ONLY. `provider_pool` is provider-granular and an
178
- // entry has no endpoint, so an endpoint-host rule can never mark one — that tier
179
- // addresses SOURCES, which this pool does not enumerate. The direction is safe
180
- // (the rule is still honored at `buildSourcePools`; the Gate-0 table merely
181
- // under-reports it as "included"), but it is a real display/routing gap and it is
182
- // NOT closed here. Backlog: the Gate-0 sources table carries no status column at
183
- // all, so no tier is reflected for a source today.
184
- const ruledOut = (name) => operatorExcluded.excludes({
185
- transport: name,
186
- model: representativeModelId(name, sessionConfig),
187
- });
188
- const pool = [];
189
- // Always include worker-command as a fallback — it's always available and is
190
- // never surfaced by PATH discovery (it blocks auto-dispatch by design).
191
- if (!discovered.some((p) => p.name === "worker-command")) {
192
- pool.push({
193
- name: "worker-command",
194
- capability_tier: "unknown",
195
- excluded: ruledOut("worker-command"),
196
- });
197
- }
198
- for (const provider of discovered) {
199
- // Self-spawn-blocked providers are excluded from the dispatchable pool by
200
- // default; the operator can opt one back in via `include`. An operator-named
201
- // `exclude` always wins.
202
- const blocked = provider.selfSpawnBlocked === true;
203
- const operatorIncluded = includeSet.has(provider.name);
204
- const excluded = ruledOut(provider.name) || (blocked && !operatorIncluded);
205
- pool.push({
206
- name: provider.name,
207
- capability_tier: provider.capabilityTier,
208
- excluded,
209
- ...(blocked ? { self_spawn_blocked: true } : {}),
210
- });
211
- }
212
- // Cost-first routing: annotate with representative model price + cost_order,
213
- // read at dispatch as rung 1 of costRank (spec/dispatch-quota.md). When an
214
- // operator input is present its ordering wins and its host roster is priced.
215
- // `env` is threaded rather than left to `process.env`: host-provider resolution
216
- // falls through to env detection when `host_provider` is unset, and an injected-env
217
- // caller must not derive provider DISCOVERY and HOST IDENTITY from two different
218
- // environments.
219
- const annotated = annotateConfirmedPool(pool, sessionConfig, input, sources, env);
220
- return {
221
- schema_version: SHARED_PROVIDER_CONFIRMATION_VERSION,
222
- session_level: true,
223
- confirmed_at: new Date().toISOString(),
224
- provider_pool: annotated.provider_pool,
225
- // NOTE: the FULL entries. This is the render DTO; `buildSharedProviderConfirmation`
226
- // projects them to `PersistedPoolEntry[]` on the way to disk.
227
- ...(annotated.host_model_cost_order.length > 0
228
- ? { host_model_cost_order: annotated.host_model_cost_order }
229
- : {}),
230
- ...(annotated.source_pool_cost_order.length > 0
231
- ? { source_pool_cost_order: annotated.source_pool_cost_order }
232
- : {}),
233
- ...(clampDispatchBias(input?.dispatch_bias) != null
234
- ? { dispatch_bias: clampDispatchBias(input?.dispatch_bias) }
235
- : {}),
236
- ...(buildConfirmedDispatchPolicy(exclude, include, autoExclude,
237
- // The operator's RAW capability answer, persisted verbatim so the next
238
- // carry-forward reads back what they SAID rather than re-deriving it from the
239
- // ranks it produced (which cannot distinguish it from external evidence).
240
- input?.capability_order ?? [], capabilityOrderLlmRanked) ?? {}),
241
- };
242
- }
243
- /**
244
- * Lift the operator's explicit route decision out of their Gate-0 input into the
245
- * persisted policy half. Returns `undefined` when the operator named neither list
246
- * (so the field stays absent rather than persisting an empty shell).
247
- */
248
- function buildConfirmedDispatchPolicy(exclude, include, autoExclude = [], capabilityOrder = [], capabilityOrderLlmRanked = []) {
249
- if (exclude.length === 0 &&
250
- include.length === 0 &&
251
- autoExclude.length === 0 &&
252
- capabilityOrder.length === 0 &&
253
- capabilityOrderLlmRanked.length === 0) {
254
- return undefined;
255
- }
256
- return {
257
- policy: {
258
- ...(exclude.length > 0 ? { exclude: sortStrings(exclude) } : {}),
259
- // Sorted like its sibling — an auto-exclusion is a set membership, order-free.
260
- ...(autoExclude.length > 0 ? { auto_exclude: sortStrings(autoExclude) } : {}),
261
- ...(include.length > 0 ? { include: sortNames(include) } : {}),
262
- // NOT sorted, NOT deduped by sortStrings: this is a positional ORDERING, and
263
- // sorting it would destroy the operator's answer outright.
264
- ...(capabilityOrder.length > 0 ? { capability_order: [...capabilityOrder] } : {}),
265
- // An AUTHORSHIP SET, not an ordering — sorted like `exclude`/`auto_exclude` so
266
- // an incidentally-ordered array never churns the artifact's content hash.
267
- ...(capabilityOrderLlmRanked.length > 0
268
- ? { capability_order_llm_ranked: sortStrings(capabilityOrderLlmRanked) }
269
- : {}),
270
- },
271
- };
272
- }
273
- /**
274
- * Keep every non-empty pattern verbatim — **no membership check.** Unlike
275
- * {@link parseProviderNameList}, this list is an open grammar: a pattern whose head
276
- * is not a provider name is a legitimate endpoint-host rule, so "unknown ⇒ drop"
277
- * would silently delete the operator's endpoint tier. An unmatchable pattern is
278
- * inert (it simply matches nothing), which is the safe direction for a filter.
279
- *
280
- * Returns `undefined` for a non-array or an all-empty array, so the field stays
281
- * absent rather than persisting an empty shell.
282
- */
283
- function parseExclusionPatterns(value) {
284
- if (!Array.isArray(value))
285
- return undefined;
286
- const patterns = value.filter((v) => typeof v === "string" && v.trim().length > 0);
287
- return patterns.length > 0 ? patterns.map((p) => p.trim()) : undefined;
288
- }
289
- /**
290
- * Keep only real provider names, dropping anything unknown. Returns `undefined` for a
291
- * non-array or an array with no recognizable name, so an unknown entry degrades that
292
- * entry — never the whole list.
293
- *
294
- * Retained for `include` ONLY: that list opts a *self-spawn-blocked provider* back in,
295
- * and self-spawn-blockedness is a property of a provider (`isSelfSpawnBlocked` keys on
296
- * the provider name), so its keyspace is genuinely the closed name set — not the open
297
- * exclusion grammar.
298
- */
299
- function parseProviderNameList(value) {
300
- if (!Array.isArray(value))
301
- return undefined;
302
- const names = value.filter((v) => typeof v === "string" && RESOLVED_PROVIDER_NAMES.includes(v));
303
- return names.length > 0 ? names : undefined;
304
- }
305
- /** Every concrete provider name — `auto` is a resolution directive, not a backend. */
306
- const RESOLVED_PROVIDER_NAMES = PROVIDER_NAMES.filter((name) => name !== "auto");
307
- // ---------------------------------------------------------------------------
308
- // The reconciliation gate (G3): operator DECISION vs THIS auditor's reach
309
- // ---------------------------------------------------------------------------
310
- /**
311
- * The keys of the operator's persisted DECISION — the CONFIRMED half of the gate.
312
- *
313
- * All three pools contribute, and each is load-bearing: `annotateConfirmedPool`
314
- * folds a source away when its model is already claimed by a provider entry, so a
315
- * source can be represented ONLY by `provider_pool[].model_id`; and a host tier
316
- * appears only in `host_model_cost_order`. Reading fewer than all three would
317
- * manufacture a phantom delta for an already-confirmed backend.
318
- *
319
- * A host tier with no `provider` contributes NOTHING, and that is the deliberate
320
- * fail-SAFE degradation for a confirmation written before the field existed. The
321
- * alternative — falling back to the bare `model_id` — is precisely the bypass this
322
- * identity exists to close: a confirmed *host* model would silently approve an
323
- * identically-named model on some other provider. Contributing no key can only ever
324
- * cause the gate to ASK about a backend again (loud, and the operator's answer then
325
- * records the provider); it can never approve one unseen.
326
- */
327
- export function confirmedBackendKeys(confirmation) {
328
- const keys = new Set();
329
- for (const entry of confirmation.provider_pool) {
330
- keys.add(backendIdentity(entry.model_id, entry.name));
331
- }
332
- for (const entry of confirmation.source_pool_cost_order ?? []) {
333
- keys.add(backendIdentity(entry.model_id, entry.service ?? entry.transport));
334
- }
335
- for (const entry of confirmation.host_model_cost_order ?? []) {
336
- if (entry.provider === undefined)
337
- continue;
338
- keys.add(backendIdentity(entry.model_id, entry.provider));
339
- }
340
- return keys;
341
- }
342
- /**
343
- * DELTA = **REACH-NOW \ CONFIRMED**: the backends this auditor can reach *right
344
- * now* that the operator's persisted decision never mentions. Sorted by key, so the
345
- * result is stable for prompt rendering + comparison.
346
- *
347
- * This is a **set difference — a FILTER over fresh reach, never additive.** The
348
- * opposite direction (CONFIRMED \ REACH-NOW: a backend the operator confirmed that
349
- * this auditor cannot reach) is the harmless *subset* case and is deliberately
350
- * silent — it is also why the synthetic `worker-command` entry and
351
- * `host_model_cost_order` need no special-casing here.
352
- *
353
- * @param confirmation - The persisted decision (CONFIRMED).
354
- * @param sessionConfig - The EFFECTIVE config, so `representativeModelId` derives
355
- * keys identically to the write side.
356
- * @param sources - REACH-NOW's source half. MUST come from the
357
- * `gatherDispatchableSources` chokepoint — the single async source-gather point
358
- * both `buildSourcePools` and the Gate-0 surface consume, so what the operator
359
- * confirms is exactly what routes. Re-deriving it from `resolveAmbientSources`
360
- * would reintroduce the display/dispatch drift that invariant forbids, and is
361
- * structurally blind to descriptor-supplied sources, the demoted primary, and the
362
- * legacy `openai_compatible` fold.
363
- * @param env - Process env, for `discoverProviders` (REACH-NOW's provider half).
364
- * @param detectCommand - Injectable PATH-detection hook so tests drive discovery
365
- * deterministically instead of shelling out.
366
- */
367
- export function computeNewlyReachableBackends(confirmation, sessionConfig, sources = [], env = process.env, detectCommand) {
368
- const confirmed = confirmedBackendKeys(confirmation);
369
- const reachNow = new Map();
370
- // `provider` is the TRANSPORT (what spawns / what the routing filter matches);
371
- // `backendProvider` is the BACKEND ACTUALLY SERVING the model, and only a proxied
372
- // source distinguishes them. The identity keys on the backend, the rule on the
373
- // transport — see `backendIdentity`.
374
- const record = (modelId, provider, backendProvider) => {
375
- const service = backendProvider ?? provider;
376
- const identity = backendIdentity(modelId, service);
377
- // First writer wins: when a proxied lane and a direct lane resolve to the SAME
378
- // backend identity, they are one backend reached two ways, and the rule kept is
379
- // the one that rules out the lane already recorded.
380
- if (reachNow.has(identity))
381
- return;
382
- reachNow.set(identity, {
383
- key: identity,
384
- provider,
385
- service,
386
- exclusion_pattern: exclusionPattern(modelId, provider),
387
- service_exclusion_pattern: serviceExclusionPattern(modelId, service),
388
- });
389
- };
390
- for (const provider of discoverProviders(sessionConfig, env, detectCommand)) {
391
- // A discovered provider IS its own backend — no proxy indirection to unwrap.
392
- record(representativeModelId(provider.name, sessionConfig), provider.name);
393
- }
394
- for (const source of sources) {
395
- record(source.model, source.transport, source.service);
396
- }
397
- return [...reachNow.values()]
398
- .filter((backend) => !confirmed.has(backend.key))
399
- .sort((a, b) => a.key.localeCompare(b.key));
400
- }
401
- /**
402
- * The dispatchable-pool exclusion rules for THIS process: the operator's explicit
403
- * {@link DispatchExclusionPattern}s, plus every provider that is self-spawn-blocked
404
- * *in this process's env* and was not explicitly opted back in.
405
- *
406
- * Reach is recomputed here rather than read from the artifact's derived `excluded`
407
- * flag — that flag encodes the WRITING auditor's env, and an auditor for whom a
408
- * provider is perfectly spawnable must not inherit another's block. The operator's
409
- * decision is inherited (it is a rule); the reach assessment is not.
410
- *
411
- * ⚠ **These rules are only safe to apply to SOURCE pools.** Inside any agent session
412
- * the self-spawn half ALWAYS names that agent (`CLAUDECODE` ⇒ `claude-code`, `CODEX`
413
- * ⇒ `codex`) — i.e. the conversation host itself. Applying them to HOST pools would
414
- * zero out dispatch entirely: the driver would exclude itself. It is harmless at
415
- * `buildSourcePools` only because a host can never BE a source — `claude-code` is
416
- * structurally absent from `DISPATCHABLE_TRANSPORTS`, so in a Claude Code
417
- * session the filter is a no-op. Honoring an operator exclusion of the host/primary
418
- * provider therefore is NOT a matter of passing these rules to the host-pool builder;
419
- * it needs a separate decision about what excluding your own driver should even mean.
420
- */
421
- export function resolveDispatchExclusion(policy, env = process.env) {
422
- const included = new Set(policy?.include ?? []);
423
- // The local reach half: a self-spawn-blocked provider is ruled out at the
424
- // TRANSPORT axis (blockedness is a property of the process, not the vendor),
425
- // recomputed against THIS process's env rather than inherited.
426
- const blocked = RESOLVED_PROVIDER_NAMES.filter((name) => !included.has(name) && isSelfSpawnBlocked(name, env));
427
- // Both provenances are enforced identically — the split is about LIFETIME (which
428
- // survives the next submission), never about which patterns bite at dispatch.
429
- return buildExclusion(migrateExclusionPatterns([
430
- ...(policy?.exclude ?? []),
431
- ...(policy?.auto_exclude ?? []),
432
- ...blocked.map((name) => `transport:${name}`),
433
- ]));
434
- }
435
- /**
436
- * The pure-policy matcher: the operator's patterns and nothing else. Split out
437
- * because the Gate-0 pool BUILDER must evaluate the operator's rules WITHOUT the
438
- * local self-spawn fold — it derives `self_spawn_blocked` from `discoverProviders`
439
- * on its own and would otherwise conflate the two into one indistinguishable
440
- * `excluded` verdict.
441
- */
442
- function buildExclusion(patterns) {
443
- // Keep each rule beside the pattern it came from: the capacity guard reports the
444
- // operator's ORIGINAL text, not a re-rendering of the parsed rule (a round-trip
445
- // through `ExclusionRule` would show them a string they never wrote).
446
- const rules = patterns.map((pattern) => ({ pattern, rule: parseExclusionRule(pattern) }));
447
- const excludedBy = (backend) => rules.find(({ rule }) => ruleMatches(rule, backend))?.pattern ?? null;
448
- // `excludes` is DERIVED, never a parallel implementation — the two verdicts cannot drift.
449
- return { excludes: (backend) => excludedBy(backend) !== null, excludedBy };
450
- }
451
- /** The three recognized axis prefixes. */
452
- const VALID_EXCLUSION_AXES = new Set(["transport", "service", "host"]);
453
- function parseExclusionRule(pattern) {
454
- const colon = pattern.indexOf(":");
455
- if (colon === -1) {
456
- // No axis prefix — invalid under the axis-explicit grammar.
457
- return { kind: "invalid", raw: pattern };
458
- }
459
- const axis = pattern.slice(0, colon);
460
- const rest = pattern.slice(colon + 1);
461
- if (!VALID_EXCLUSION_AXES.has(axis) || rest.length === 0) {
462
- return { kind: "invalid", raw: pattern };
463
- }
464
- switch (axis) {
465
- case "transport": {
466
- const slash = rest.indexOf("/");
467
- if (slash === -1)
468
- return { kind: "transport", transport: rest };
469
- if (slash === rest.length - 1)
470
- return { kind: "transport", transport: rest.slice(0, slash) };
471
- return { kind: "transport_model", transport: rest.slice(0, slash), model: rest.slice(slash + 1) };
472
- }
473
- case "service": {
474
- const slash = rest.indexOf("/");
475
- if (slash === -1)
476
- return { kind: "service", service: rest };
477
- if (slash === rest.length - 1)
478
- return { kind: "service", service: rest.slice(0, slash) };
479
- return { kind: "service_model", service: rest.slice(0, slash), model: rest.slice(slash + 1) };
480
- }
481
- case "host":
482
- return { kind: "host", host: rest.toLowerCase() };
483
- default:
484
- return { kind: "invalid", raw: pattern };
485
- }
486
- }
487
- /**
488
- * Migrate persisted bare-form exclusion patterns (pre-stage-4) to the
489
- * axis-explicit grammar. The old grammar was unambiguous within its own rules
490
- * (head token against the closed provider set), so the migration reproduces
491
- * exactly what the old parser would have inferred, then emits the explicit form.
492
- *
493
- * Applied at read time in {@link resolveDispatchExclusion}. A re-confirmation
494
- * (any Gate-0 delta) persists new-form patterns naturally because the pattern
495
- * generator now emits prefixed strings.
496
- */
497
- function migrateExclusionPatterns(patterns) {
498
- return patterns.map(migrateExclusionPattern);
499
- }
500
- function migrateExclusionPattern(pattern) {
501
- // Already axis-explicit — no migration needed.
502
- if (VALID_EXCLUSION_AXES.has(pattern.slice(0, pattern.indexOf(":"))) && pattern.indexOf(":") > 0) {
503
- return pattern;
504
- }
505
- const colon = pattern.indexOf(":");
506
- if (colon === -1) {
507
- // Bare token: `codex` → `transport:codex`, `localhost` → `host:localhost`
508
- return isResolvedProviderName(pattern)
509
- ? `transport:${pattern}`
510
- : `host:${pattern}`;
511
- }
512
- const head = pattern.slice(0, colon);
513
- const tail = pattern.slice(colon + 1);
514
- if (isResolvedProviderName(head)) {
515
- // `openai-compatible:model-a` → `transport:openai-compatible/model-a`
516
- // `codex:` (empty tail) → `transport:codex`
517
- return tail.length > 0
518
- ? `transport:${head}/${tail}`
519
- : `transport:${head}`;
520
- }
521
- // `integrate.api.nvidia.com` / `localhost:8000` → `host:<pattern>`
522
- return `host:${pattern}`;
523
- }
524
- function isResolvedProviderName(value) {
525
- return RESOLVED_PROVIDER_NAMES.includes(value);
526
- }
527
- /** One DNS-style label: `[a-z0-9]`, internal `-` allowed, never leading/trailing. */
528
- const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
529
- /**
530
- * Is `value` shaped like an endpoint ADDRESS — the only bare form that
531
- * {@link migrateExclusionPattern} routes to the `host:` axis?
532
- *
533
- * **THE RULE**, stated here because the whole value of this predicate is that an
534
- * operator can predict it: strip one optional trailing `:<port>` (1–5 digits),
535
- * then accept what remains **iff** it is the literal `localhost` **or** a DOTTED
536
- * name — two or more `.`-separated {@link HOST_LABEL}s. An IPv4 literal satisfies
537
- * the dotted rule and needs no special case.
538
- *
539
- * Host-shaped: `integrate.api.nvidia.com`, `nim.invalid`, `nim.invalid:8443`,
540
- * `localhost`, `localhost:8000`, `127.0.0.1:8791`.
541
- * NOT host-shaped: `model-a`, `model:model-a`, `foo:bar`, `` (empty).
542
- *
543
- * **Why a dot (or the single `localhost` exception) is REQUIRED.** The bare legacy
544
- * grammar had exactly two tiers — a known provider name, or an endpoint address —
545
- * so a single bare label that is not a provider was never a rule anyone could have
546
- * meant. That is precisely the typo class, and admitting it is what let a typo
547
- * launder into a valid-but-inert `host:` rule.
548
- *
549
- * **Deliberately NOT accepted:** a bare launcher COMMAND PATH (`c:\tools\codex.cmd`),
550
- * which the `host:` matcher does support against a non-URL endpoint. Bare, it is
551
- * indistinguishable from `<typo>:<typo>`, so it must be written axis-explicitly
552
- * (`host:c:\tools\codex.cmd`) — which {@link isGrammaticalExclusionPattern} accepts.
553
- *
554
- * **Known over-acceptance, and why it is fine:** a dotted MODEL id (`qwen2.5`)
555
- * reads as host-shaped. This is grammar only — the zero-match case is caught,
556
- * non-blocking, by {@link unmatchedExclusionPatterns}.
557
- */
558
- function isHostShapedExclusionValue(value) {
559
- const lower = value.toLowerCase();
560
- const withPort = /^(.*):(\d{1,5})$/.exec(lower);
561
- const address = withPort ? withPort[1] : lower;
562
- if (address.length === 0)
563
- return false;
564
- if (address === "localhost")
565
- return true;
566
- const labels = address.split(".");
567
- return labels.length >= 2 && labels.every((label) => HOST_LABEL.test(label));
568
- }
569
- /**
570
- * Is `pattern` a rule an operator could actually have MEANT — i.e. does it parse
571
- * to something under the grammar, either directly or via the read-time migration?
572
- *
573
- * ⚠ **AUTHORSHIP-ONLY.** Applied where the operator TYPES a rule
574
- * ({@link parseProviderConfirmationInput}), never at read time.
575
- * {@link migrateExclusionPattern} must keep absorbing ALREADY-SAVED bare forms —
576
- * deleting it would silently un-exclude a backend an operator ruled out under the
577
- * old grammar. So both layers accept the SAME language; this one only refuses to
578
- * let a NEW member of the laundered class be written in the first place.
579
- *
580
- * **The defect this closes.** Migrate-on-read rewrites any unrecognized head into
581
- * `host:<pattern>`, so `model:model-a` became a structurally VALID host rule that
582
- * matches zero backends and never reached {@link parseExclusionRule}'s `invalid`
583
- * branch. A typo persisted as policy and silently excluded nothing.
584
- *
585
- * **GRAMMAR ONLY — a zero-match rule is NOT an error and is never refused here.**
586
- * The tool itself authors zero-match rules on every run (`auto_exclude` for a
587
- * backend that then goes unreachable), and an operator may legitimately pre-declare
588
- * an exclusion for a backend that is not reachable right now. So
589
- * `openai-compatible:model-typo` is ACCEPTED: the head IS a real provider and the
590
- * model segment is an open string. That case is reported instead, without blocking,
591
- * by {@link unmatchedExclusionPatterns}.
592
- */
593
- function isGrammaticalExclusionPattern(pattern) {
594
- const colon = pattern.indexOf(":");
595
- if (colon > 0) {
596
- const head = pattern.slice(0, colon);
597
- // Axis-explicit — the form the Gate-0 prompt teaches. A non-empty remainder is
598
- // required: `transport:` is the one ungrammatical form the axis branch can
599
- // produce, and it is what `parseExclusionRule` already calls `invalid`.
600
- if (VALID_EXCLUSION_AXES.has(head))
601
- return colon + 1 < pattern.length;
602
- // Legacy `<provider>:<model>` → migrates to `transport:<provider>/<model>`.
603
- if (isResolvedProviderName(head))
604
- return true;
605
- }
606
- // Legacy bare forms → `transport:<provider>` / `host:<address>`.
607
- return isResolvedProviderName(pattern) || isHostShapedExclusionValue(pattern);
608
- }
609
- /**
610
- * The operator-authored patterns that no rule grammar can account for — see
611
- * {@link isGrammaticalExclusionPattern}. Empty ⇒ every pattern is writable.
612
- *
613
- * Order is the operator's own submission order (deduplicated, first occurrence
614
- * kept), so the refusal names their patterns back in the order they wrote them.
615
- */
616
- export function ungrammaticalExclusionPatterns(patterns) {
617
- const seen = new Set();
618
- return patterns.filter((pattern) => !seen.has(pattern) &&
619
- (seen.add(pattern), !isGrammaticalExclusionPattern(pattern)));
620
- }
621
- /**
622
- * The patterns that matched NOTHING in `backends` — the ADVISORY half, and
623
- * deliberately not an error.
624
- *
625
- * A zero-match rule is legitimate: `auto_exclude` entries go zero-match the moment
626
- * their backend stops being reachable, and an operator may pre-declare an exclusion
627
- * for a backend they have not configured yet. It is also the only signal available
628
- * for the grammatically-valid typo `isGrammaticalExclusionPattern` cannot catch
629
- * (`openai-compatible:model-typo` — real provider head, open model segment). So it
630
- * is REPORTED at the confirmation summary and never refused anywhere.
631
- *
632
- * Patterns are migrated before matching, so an already-saved bare form is evaluated
633
- * exactly as it will actually apply at dispatch — not as the string it was typed as.
634
- *
635
- * **Empty `backends` ⇒ empty result.** With nothing gathered there is no evidence
636
- * that any rule failed to match, only that there was nothing to match against, and
637
- * reporting every rule there would be noise the operator learns to read past.
638
- *
639
- * Order is the operator's own submission order (deduplicated, first occurrence kept).
640
- */
641
- export function unmatchedExclusionPatterns(patterns, backends) {
642
- if (backends.length === 0)
643
- return [];
644
- const seen = new Set();
645
- const unmatched = [];
646
- for (const pattern of patterns) {
647
- if (seen.has(pattern))
648
- continue;
649
- seen.add(pattern);
650
- const rule = parseExclusionRule(migrateExclusionPattern(pattern));
651
- if (!backends.some((backend) => ruleMatches(rule, backend))) {
652
- unmatched.push(pattern);
653
- }
654
- }
655
- return unmatched;
656
- }
657
- function ruleMatches(rule, backend) {
658
- switch (rule.kind) {
659
- case "transport":
660
- return backend.transport === rule.transport;
661
- case "transport_model":
662
- // A model-granular rule matches ONLY that model. A backend of the same
663
- // transport carrying no model (a CLI whose model arrives at the dispatch
664
- // handshake) is NOT matched: the operator ruled out one model, not the
665
- // backend — the coarse `transport` tier is how they rule out the backend.
666
- return backend.transport === rule.transport && backend.model === rule.model;
667
- case "service":
668
- return (backend.service ?? backend.transport) === rule.service;
669
- case "service_model":
670
- return ((backend.service ?? backend.transport) === rule.service &&
671
- backend.model === rule.model);
672
- case "host":
673
- return endpointHosts(backend.endpoint).includes(rule.host);
674
- case "invalid":
675
- return false;
676
- }
677
- }
678
- /**
679
- * The forms of a source endpoint an operator pattern may name: `hostname` (port-
680
- * agnostic — `integrate.api.nvidia.com` rules out that host on any port) and
681
- * `host:port` (port-specific — `localhost:8000` rules out one of several local
682
- * endpoints). Both are offered so the pattern's own specificity decides.
683
- *
684
- * An endpoint that is not a URL (a CLI launcher command) degrades to the raw
685
- * lowercased string, which then only ever matches an identical literal pattern —
686
- * never a false positive against a real host.
687
- *
688
- * ⚠ The authority check is load-bearing, not defensive: `new URL()` accepts ANY
689
- * scheme-shaped string, so it does NOT throw on `localhost:8000` (protocol
690
- * `localhost:`) or on a Windows command path like `C:\tools\codex.cmd` (protocol
691
- * `c:`) — both parse to an EMPTY hostname. Relying on the `catch` alone would
692
- * therefore silently yield no hosts for exactly those endpoints, making an
693
- * operator's literal-identical rule match nothing.
694
- */
695
- function endpointHosts(endpoint) {
696
- if (!endpoint)
697
- return [];
698
- const raw = endpoint.toLowerCase();
699
- if (endpoint.includes("//")) {
700
- try {
701
- const url = new URL(endpoint);
702
- if (url.hostname.length > 0) {
703
- return [url.hostname.toLowerCase(), url.host.toLowerCase()];
704
- }
705
- }
706
- catch {
707
- // Not a URL after all — fall through to the raw literal.
708
- }
709
- }
710
- return [raw];
711
- }
712
- /**
713
- * Read the operator's confirmed route policy from the shared Gate-0 confirmation.
714
- *
715
- * Deliberately reads the artifact DIRECTLY rather than going through
716
- * {@link readSharedProviderConfirmation}, so that **a corrupt sibling field cannot
717
- * discard the decision**: `parseSharedProviderConfirmation` returns `null` wholesale
718
- * on any malformed required field or a `schema_version` mismatch. Routing policy
719
- * through it would make an unrelated corruption (or a future version bump) silently
720
- * lift the operator's exclusions — failing OPEN on the one field that must fail
721
- * closed. Parsing `policy` on its own keeps that blast radius out.
722
- *
723
- * (Before G3 this bypass carried a second rationale — dodging the roster-freshness
724
- * gate. That gate is gone: no read of this artifact is reach-gated any more, so the
725
- * remaining reason is blast radius alone.)
726
- *
727
- * **Honest limit — this is not absolutely fail-closed.** An absent or unparseable
728
- * artifact yields `null` (no operator policy). That residue is irreducible here: with
729
- * no readable decision on disk there is nothing to fail closed ON. Self-spawn-blocked
730
- * providers are still excluded locally by {@link resolveDispatchExclusion}, which
731
- * needs no artifact.
732
- */
733
- export async function readConfirmedDispatchPolicy(root) {
734
- if (!root)
735
- return null;
736
- let raw;
737
- try {
738
- raw = await readJsonFile(sharedProviderConfirmationPath(root));
739
- }
740
- catch {
741
- // Absent (ENOENT) / unreadable / invalid JSON — never-block, same contract as
742
- // every other read of this artifact.
743
- return null;
744
- }
745
- if (raw === null || typeof raw !== "object" || Array.isArray(raw))
746
- return null;
747
- return parseConfirmedDispatchPolicy(raw.policy) ?? null;
748
- }
749
- // ---------------------------------------------------------------------------
750
- // Write (audit writes it)
751
- // ---------------------------------------------------------------------------
752
- /**
753
- * Atomically write the shared confirmation to
754
- * `<root>/.audit-tools/provider-confirmation.json`. The durable write goes
755
- * through the shared atomic writer (temp + atomic rename) and the whole
756
- * operation is guarded by `withFileLock` so a concurrent writer can never
757
- * interleave — and a lockless reader (see `readSharedProviderConfirmation`)
758
- * never observes a torn file (CE-003).
759
- */
760
- export async function writeSharedProviderConfirmation(root, confirmation, logger) {
761
- const path = sharedProviderConfirmationPath(root);
762
- const lockPath = `${path}.lock`;
763
- // Ensure the `.audit-tools` dir exists BEFORE acquiring the lock — the lock is
764
- // a sibling file, so its atomic `wx` create would otherwise ENOENT on a fresh
765
- // root (mirrors StateStore.saveState mkdir-then-lock).
766
- await mkdir(auditToolsDir(root), { recursive: true });
767
- await withFileLock(lockPath, async () => {
768
- await writeJsonFile(path, confirmation);
769
- }, undefined, logger);
770
- }
771
- // ---------------------------------------------------------------------------
772
- // Validation
773
- // ---------------------------------------------------------------------------
774
- /**
775
- * The persisted pool entry's gate: `name` ONLY.
776
- *
777
- * B+D: this gate previously hard-required `capability_tier` AND `excluded` — the
778
- * exact reach fields B removes from the persisted shape. That coupling is why B and
779
- * D are ONE commit: a post-B artifact failing a pre-B gate parses to `null`, which
780
- * degrades SILENTLY to empty cost positions and λ=0. Requiring only `name` also
781
- * makes the gate forward-tolerant of a confirmation written before B (its extra
782
- * reach fields are simply ignored on read, never re-persisted).
783
- */
784
- function isPersistedPoolEntry(value) {
785
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
786
- return false;
787
- }
788
- return typeof value.name === "string";
789
- }
790
- function isHostModelCostEntry(value) {
791
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
792
- return false;
793
- }
794
- const obj = value;
795
- return (typeof obj.model_id === "string" &&
796
- // Optional (pre-field confirmations parse), but when present it must be a real
797
- // provider name — `confirmedBackendKeys` builds a gate key from it, and the
798
- // validator guards every field its callers read.
799
- (obj.provider === undefined ||
800
- (typeof obj.provider === "string" && isResolvedProviderName(obj.provider))) &&
801
- (obj.blended_price_usd_per_mtok === null ||
802
- typeof obj.blended_price_usd_per_mtok === "number") &&
803
- typeof obj.cost_order === "number");
804
- }
805
- function isSourcePoolCostEntry(value) {
806
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
807
- return false;
808
- }
809
- const obj = value;
810
- return (typeof obj.source_id === "string" &&
811
- typeof obj.transport === "string" &&
812
- // Optional, but guarded: `confirmedBackendKeys` builds a gate key from it.
813
- (obj.service === undefined ||
814
- typeof obj.service === "string") &&
815
- (obj.model_id === undefined || typeof obj.model_id === "string") &&
816
- (obj.blended_price_usd_per_mtok === null ||
817
- typeof obj.blended_price_usd_per_mtok === "number") &&
818
- typeof obj.cost_order === "number");
819
- }
820
- /**
821
- * Validate a parsed value as a SharedProviderConfirmation. Returns the typed
822
- * value or `null` when any required field is missing or malformed — a corrupt
823
- * artifact must degrade to the never-block path, never throw.
824
- */
825
- function parseSharedProviderConfirmation(value) {
826
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
827
- return null;
828
- }
829
- const obj = value;
830
- if (obj.schema_version !== SHARED_PROVIDER_CONFIRMATION_VERSION)
831
- return null;
832
- if (obj.session_level !== true)
833
- return null;
834
- if (typeof obj.confirmed_at !== "string")
835
- return null;
836
- if (!Array.isArray(obj.provider_pool) ||
837
- !obj.provider_pool.every(isPersistedPoolEntry)) {
838
- return null;
839
- }
840
- // host_model_cost_order is optional + additive; a malformed value degrades to
841
- // absent (the field never blocks parsing — INV-DC1-6 never-block spirit).
842
- const hostModels = Array.isArray(obj.host_model_cost_order) &&
843
- obj.host_model_cost_order.every(isHostModelCostEntry)
844
- ? obj.host_model_cost_order
845
- : undefined;
846
- // source_pool_cost_order is optional + additive; a malformed value degrades to absent.
847
- const sourcePools = Array.isArray(obj.source_pool_cost_order) &&
848
- obj.source_pool_cost_order.every(isSourcePoolCostEntry)
849
- ? obj.source_pool_cost_order
850
- : undefined;
851
- // dispatch_bias is optional + additive; a malformed/out-of-range value degrades to
852
- // the cost-first default (clamp → undefined only when non-finite), never blocking.
853
- const dispatchBias = clampDispatchBias(obj.dispatch_bias);
854
- // policy is optional + additive; a malformed value degrades to absent. Note the
855
- // asymmetry with the fields above: degrading policy to absent fails OPEN (the
856
- // operator's exclusions stop applying), so each list is validated independently —
857
- // a malformed `include` must not silently discard a well-formed `exclude`.
858
- const policy = parseConfirmedDispatchPolicy(obj.policy);
859
- return {
860
- schema_version: SHARED_PROVIDER_CONFIRMATION_VERSION,
861
- session_level: true,
862
- confirmed_at: obj.confirmed_at,
863
- provider_pool: obj.provider_pool,
864
- ...(hostModels && hostModels.length > 0
865
- ? { host_model_cost_order: hostModels }
866
- : {}),
867
- ...(sourcePools && sourcePools.length > 0
868
- ? { source_pool_cost_order: sourcePools }
869
- : {}),
870
- ...(dispatchBias != null ? { dispatch_bias: dispatchBias } : {}),
871
- ...(policy ? { policy } : {}),
872
- };
873
- }
874
- /**
875
- * Parse the optional policy half. Each list is validated on its own so one
876
- * malformed list cannot discard the other — degrading a well-formed `exclude` to
877
- * absent would fail OPEN and route to a backend the operator ruled out.
878
- */
879
- function parseConfirmedDispatchPolicy(value) {
880
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
881
- return undefined;
882
- }
883
- const obj = value;
884
- // `exclude` is the OPEN exclusion grammar (a pattern's head need not be a provider
885
- // name — the endpoint tier's never is), so it is kept verbatim; an unmatchable
886
- // pattern is inert, which is the safe direction for a filter. `include` is the
887
- // CLOSED provider-name set and stays membership-checked, so an unknown name cannot
888
- // type-assert its way into overriding a self-spawn block.
889
- const exclude = parseExclusionPatterns(obj.exclude);
890
- const autoExclude = parseExclusionPatterns(obj.auto_exclude);
891
- const include = parseProviderNameList(obj.include);
892
- // Same open-grammar treatment as `exclude`: a capability key is a model id, not a
893
- // member of any closed set, so it is kept verbatim and an unmatchable key is inert.
894
- const capabilityOrder = Array.isArray(obj.capability_order)
895
- ? obj.capability_order.filter((v) => typeof v === "string" && v.trim().length > 0)
896
- : undefined;
897
- // R3-3 provenance — same open-grammar, verbatim treatment: a set of model ids, not
898
- // itself a positional ordering, so order within the parsed array is immaterial (the
899
- // writer sorts it anyway). Must be reconstructed here explicitly like every other
900
- // field on this parser: an unlisted field is silently dropped on every round-trip,
901
- // and dropping this one is not a degrade — it reverts every LLM-ranked id to
902
- // "unattributed" (still a valid rank, just an authorship the next operator
903
- // repositioning would then wrongly discard-and-report as a moved anchor instead of
904
- // silently honoring — see `capabilityOrderNonAnchors`).
905
- const capabilityOrderLlmRanked = Array.isArray(obj.capability_order_llm_ranked)
906
- ? obj.capability_order_llm_ranked.filter((v) => typeof v === "string" && v.trim().length > 0)
907
- : undefined;
908
- if (!exclude?.length &&
909
- !autoExclude?.length &&
910
- !include?.length &&
911
- !capabilityOrder?.length &&
912
- !capabilityOrderLlmRanked?.length) {
913
- return undefined;
914
- }
915
- return {
916
- ...(exclude?.length ? { exclude } : {}),
917
- ...(autoExclude?.length ? { auto_exclude: autoExclude } : {}),
918
- ...(include?.length ? { include } : {}),
919
- ...(capabilityOrder?.length ? { capability_order: capabilityOrder } : {}),
920
- ...(capabilityOrderLlmRanked?.length
921
- ? { capability_order_llm_ranked: capabilityOrderLlmRanked }
922
- : {}),
923
- };
924
- }
925
- // ---------------------------------------------------------------------------
926
- // Read (remediate gains this)
927
- // ---------------------------------------------------------------------------
928
- /**
929
- * Read + parse the shared confirmation for `root`. TWO-valued:
930
- *
931
- * - returns `null` when the artifact is ABSENT or MALFORMED — the caller then
932
- * resolves its provider independently, exactly as today (INV-DC1-6
933
- * never-block). Absence is the standalone-remediate case and is not an error.
934
- * - returns the parsed confirmation otherwise — the operator's persisted route
935
- * DECISION, honored as-is.
936
- *
937
- * **Reach-free by construction (G3).** This read does NOT check whether the
938
- * reachable backend set still matches whatever the writing auditor saw. It cannot
939
- * meaningfully: a *different* auditor legitimately has different reach, so that
940
- * comparison was noise cross-auditor — and answering it by discarding the
941
- * operator's decision fails OPEN on a policy question. A backend becoming newly
942
- * reachable is a real event, handled by the reconciliation gate
943
- * ({@link computeNewlyReachableBackends}), which compares the DECISION against
944
- * *this* auditor's reach and is keyed on `autonomous_mode`.
945
- *
946
- * Never throws: a read/parse failure is treated as absent/malformed → `null`.
947
- * The read is lockless (no lock needed: the writer's atomic rename guarantees a
948
- * complete file either way — CE-003) and so cannot deadlock against a writer.
949
- */
950
- export async function readSharedProviderConfirmation(root) {
951
- let raw;
952
- try {
953
- raw = await readJsonFile(sharedProviderConfirmationPath(root));
954
- }
955
- catch (error) {
956
- // Both degrade to the never-block path, but only ABSENCE is legitimately silent.
957
- //
958
- // A file that EXISTS and cannot be read (truncated, invalid JSON, permissions) used
959
- // to return `null` with no warning, and that composed with two other
960
- // individually-justified silences into TOTAL silence: no pool gets a rank ⇒
961
- // `anyBanded === false`, which by design suppresses the capability fail-open
962
- // reporter; and `resolveUnevidencedCapabilityPools` returns `[]` on a null
963
- // confirmation ⇒ the obligation reports SATISFIED. Net effect on a corrupt file: the
964
- // capability floor is globally inert, every `deep` packet routes anywhere, and not
965
- // one path says a word — the exact case the loud path was built for.
966
- if (error?.code !== "ENOENT") {
967
- warnConfirmationUnreadable(error, root);
968
- }
969
- return null;
970
- }
971
- // Malformed (wrong shape / version drift) → never-block, but never SILENT (D).
972
- const parsed = parseSharedProviderConfirmation(raw);
973
- if (parsed === null)
974
- warnConfirmationRejected(raw, root);
975
- return parsed;
976
- }
977
- /**
978
- * D — loud rejection. `null` from the parser is indistinguishable at the call sites
979
- * from "no confirmation exists", and every consumer treats that as "no operator
980
- * decision": empty cost positions, λ=0, no exclusions. So a `schema_version` bump —
981
- * or any shape drift — would SILENTLY discard the operator's whole route decision
982
- * and quietly re-route the run. Absence is legitimately silent; a file that EXISTS
983
- * and was rejected is not.
984
- *
985
- * A warning, not a throw: INV-DC1-6 (never-block) is the standing invariant here —
986
- * the same loud-degrade shape as `readQuotaStateOrDegrade` and the blind-dispatch
987
- * warning. Reached only on the rejection path, so it cannot become hot.
988
- */
989
- /**
990
- * The sibling of {@link warnConfirmationRejected} for a file that exists but cannot be
991
- * READ (truncated write, invalid JSON, permissions) — the case that used to degrade
992
- * silently. Same never-block contract, same loud-degrade shape.
993
- *
994
- * The message names the CAPABILITY consequence explicitly because that is the one a
995
- * corrupt file silences most dangerously: with no ranks, nothing bands, the fail-open
996
- * reporter self-suppresses (it fires only when something else banded), and the
997
- * capability obligation simultaneously reports satisfied. Nothing else would tell the
998
- * operator their capability floor stopped existing.
999
- */
1000
- function warnConfirmationUnreadable(error, root) {
1001
- const why = error instanceof Error ? error.message : String(error);
1002
- process.stderr.write(`WARNING: the provider confirmation at ${sharedProviderConfirmationPath(root)} exists ` +
1003
- `but could NOT be read (${why}). It is being treated as absent: the operator's ` +
1004
- `confirmed cost order, dispatch bias, and CAPABILITY RANKS are all unavailable, so ` +
1005
- `the admission capability floor is inert for this run and packets may route to pools ` +
1006
- `above their capability. Repair or delete the file and re-confirm at Gate-0.\n`);
1007
- }
1008
- function warnConfirmationRejected(raw, root) {
1009
- const version = raw !== null && typeof raw === "object" && !Array.isArray(raw)
1010
- ? raw.schema_version
1011
- : undefined;
1012
- const why = version !== undefined && version !== SHARED_PROVIDER_CONFIRMATION_VERSION
1013
- ? `schema_version "${String(version)}" != expected "${SHARED_PROVIDER_CONFIRMATION_VERSION}"`
1014
- : "malformed shape";
1015
- // Scoped to what the rejection ACTUALLY discards. Exclusions deliberately survive:
1016
- // `readConfirmedDispatchPolicy` bypasses this parser (and its version check) and reads
1017
- // `policy` straight off the raw JSON, so an exclusion keeps failing CLOSED through a
1018
- // schema drift — the correct direction, and the reason this message must NOT claim the
1019
- // pool is unfiltered. Saying otherwise sends the operator hunting a routing change that
1020
- // did not happen.
1021
- process.stderr.write(`WARNING: ignoring the provider confirmation at ${sharedProviderConfirmationPath(root)} ` +
1022
- `(${why}). The operator's confirmed COST ORDER and DISPATCH BIAS are NOT being applied ` +
1023
- `to this run — dispatch falls back to price-then-tier at the default bias. ` +
1024
- `(Exclusions are read separately and still apply.) Re-run the provider-confirmation ` +
1025
- `gate to rewrite it.
1026
- `);
1027
- }
1028
- /**
1029
- * Read the operator-confirmed cost ordering (rung 1 of costRank; see
1030
- * spec/dispatch-quota.md) from the shared Gate-0 confirmation as a model-keyed
1031
- * `Map<model_id, cost_order>` for the dispatch build sites. Single-sourced so audit
1032
- * and remediate honor it identically. Best-effort and never throws: an absent
1033
- * `root` or a missing/malformed confirmation yields an empty map — dispatch then
1034
- * falls to real price then tier.
1035
- *
1036
- * **Not gated on reach (G3 step 1).** The cost order is the operator's POLICY —
1037
- * "the operator may reorder" — so a shift in what happens to be reachable must not
1038
- * silently discard it. The former roster-freshness gate did exactly that, on the
1039
- * false premise that these positions are reach-derived; they are not, and it was
1040
- * the live defect this fixes.
1041
- */
1042
- export async function readConfirmedCostPositions(root) {
1043
- if (!root)
1044
- return new Map();
1045
- const confirmation = await readSharedProviderConfirmation(root);
1046
- if (!confirmation)
1047
- return new Map();
1048
- // Provider-pool positions (configured models) PLUS any host-native tiers the
1049
- // operator confirmed at Gate-0 (follow-up c). Both are model-keyed; a host tier
1050
- // and a configured pool thread to dispatch identically. Host entries are already
1051
- // in the single unified cost order, so a plain merge preserves the total order.
1052
- const positions = resolveConfirmedCostPositions(confirmation.provider_pool);
1053
- for (const entry of confirmation.host_model_cost_order ?? []) {
1054
- if (entry.model_id &&
1055
- Number.isFinite(entry.cost_order) &&
1056
- entry.cost_order >= 0) {
1057
- positions.set(entry.model_id, entry.cost_order);
1058
- }
1059
- }
1060
- // Source pools (explicit sources[] + proxy expansion) route by their confirmed
1061
- // position keyed on the source's model id — the SAME model-keyed lookup a proxy
1062
- // dispatch pool resolves against (pool.model = the namespaced `provider/model`). An entry
1063
- // without a model_id is display-only and contributes no dispatch position.
1064
- for (const entry of confirmation.source_pool_cost_order ?? []) {
1065
- if (entry.model_id &&
1066
- Number.isFinite(entry.cost_order) &&
1067
- entry.cost_order >= 0 &&
1068
- // FIRST-WINS, and this guard is load-bearing. This map is keyed by BARE model
1069
- // id (costRank looks positions up with no service in hand), so two backends on
1070
- // different services sharing a model string necessarily collide here. The
1071
- // source fold used to prevent that by DROPPING such a source outright — which
1072
- // silently cost the confirmed set that source's identity and livelocked its
1073
- // Gate-0 confirmation. The fold is now identity-keyed and keeps the source, so
1074
- // the collision surfaces here instead and is resolved in favor of the
1075
- // provider/host entry, preserving the original "a source must not overwrite a
1076
- // configured pool's position" intent without discarding the source.
1077
- !positions.has(entry.model_id)) {
1078
- positions.set(entry.model_id, entry.cost_order);
1079
- }
1080
- }
1081
- return positions;
1082
- }
1083
- /**
1084
- * THE enumeration of every persisted entry that can carry a `capability_rank`, across
1085
- * all three confirmation arrays.
1086
- *
1087
- * Single-sourced deliberately, and this is the fix for the round-3 critical defect: the
1088
- * rank JOIN read three arrays while the evidence OBLIGATION enumerated only two, so
1089
- * `provider_pool` was a rank SOURCE but never a delta SUBJECT — the conversation-first
1090
- * default (host pool, no volunteered roster) therefore banded `null` forever and the
1091
- * gate never once asked about it. Two independent walks over "the same" set is exactly
1092
- * the drift this project keeps paying for; with one walk, a new rank-bearing array is
1093
- * added HERE and both consumers follow automatically. Do not re-inline either walk.
1094
- */
1095
- function* capabilitySubjects(confirmation) {
1096
- for (const entry of confirmation.provider_pool ?? []) {
1097
- yield { modelId: entry.model_id, rank: entry.capability_rank, excluded: entry.excluded === true };
1098
- }
1099
- for (const entry of confirmation.host_model_cost_order ?? []) {
1100
- yield { modelId: entry.model_id, rank: entry.capability_rank, excluded: false };
1101
- }
1102
- // `SourcePoolCostEntry.capability_rank` has existed and been WRITTEN since the Gate-0
1103
- // source fold (providerConfirmation.ts) with no reader at all. This is that reader.
1104
- for (const entry of confirmation.source_pool_cost_order ?? []) {
1105
- yield { modelId: entry.model_id, rank: entry.capability_rank, excluded: false };
1106
- }
1107
- }
1108
- /**
1109
- * Is this a usable capability rank? A rank is a position in a relative ordering: finite
1110
- * and non-negative. Shared by the join and the delta so "evidenced" means the same thing
1111
- * to both — a predicate they disagreed on would re-create the drift above in miniature.
1112
- */
1113
- function isUsableRank(rank) {
1114
- return rank !== undefined && Number.isFinite(rank) && rank >= 0;
1115
- }
1116
- export async function readConfirmedCapabilityRanks(root) {
1117
- if (!root)
1118
- return new Map();
1119
- const confirmation = await readSharedProviderConfirmation(root);
1120
- if (!confirmation)
1121
- return new Map();
1122
- const ranks = new Map();
1123
- for (const subject of capabilitySubjects(confirmation)) {
1124
- // An entry without a model_id is display-only and contributes no dispatch rank (it
1125
- // is unjoinable by the model-keyed lookup — the infinite-re-prompt trap).
1126
- if (!subject.modelId || !isUsableRank(subject.rank))
1127
- continue;
1128
- ranks.set(subject.modelId, subject.rank);
1129
- }
1130
- return ranks;
1131
- }
1132
- /**
1133
- * The capability-evidence delta: dispatchable models with NO resolvable capability
1134
- * rank. Computed once per invocation (it reads the confirmation + gathers sources)
1135
- * and threaded by reference on the gate, exactly like the reach delta.
1136
- *
1137
- * "Evidenced" is deliberately defined as **the dispatch join resolves** — the same
1138
- * lookup the pool constructors take ({@link readConfirmedCapabilityRanks} keyed on the
1139
- * pool's model), never a parallel predicate. Two consequences, both load-bearing:
1140
- * - a pool with NO model is skipped entirely. It is unjoinable, so pinning it could
1141
- * never clear the delta and it would re-prompt forever.
1142
- * - external evidence (`source.capability_rank`) counts, so a fully-ranked roster
1143
- * never fires the gate at all.
1144
- *
1145
- * Returns [] when no confirmation exists yet — the first-time `missing` case already
1146
- * pauses for the operator, and reporting a delta against a pool they have never seen
1147
- * would fold a second question into a prompt that has not asked the first one yet.
1148
- *
1149
- * Lives HERE, beside {@link readConfirmedCapabilityRanks}, rather than in the audit CLI
1150
- * command it is called from: its failure mode is a LIVELOCK (wrongly admitting an
1151
- * unrankable pool re-prompts `provider_confirmation` forever), and a delta computation
1152
- * with that failure mode must be reachable by a test.
1153
- */
1154
- export async function resolveUnevidencedCapabilityPools(root, effectiveConfig) {
1155
- const confirmation = await readSharedProviderConfirmation(root);
1156
- if (!confirmation)
1157
- return [];
1158
- const primaryProviderName = resolveFreshSessionProviderName(undefined, effectiveConfig, { env: process.env });
1159
- const { sources } = await gatherDispatchableSources(effectiveConfig, primaryProviderName);
1160
- const confirmedRanks = await readConfirmedCapabilityRanks(root);
1161
- const unevidenced = new Set();
1162
- for (const source of sources) {
1163
- // Unjoinable ⇒ unpinnable ⇒ never admitted to the delta (see above).
1164
- if (!source.model)
1165
- continue;
1166
- if (source.capability_rank != null)
1167
- continue;
1168
- if (confirmedRanks.has(source.model))
1169
- continue;
1170
- unevidenced.add(source.model);
1171
- }
1172
- // Every PERSISTED rank-bearing entry, walked through the SAME enumeration the join
1173
- // uses ({@link capabilitySubjects}) so the two can never disagree about what the
1174
- // subjects are. Host models are ranked exactly like any other model — the host is not
1175
- // a special case, it was simply never looked up — and `provider_pool` is included
1176
- // here, which it previously was not (the round-3 critical defect: the default
1177
- // conversation-first pool was unrankable AND unpinnable, so the fail-open it caused
1178
- // had no road to a fix).
1179
- for (const subject of capabilitySubjects(confirmation)) {
1180
- // Unjoinable ⇒ unpinnable ⇒ never admitted to the delta, same rule as sources.
1181
- if (!subject.modelId)
1182
- continue;
1183
- // An excluded pool never dispatches, so it needs no capability evidence — asking
1184
- // about it would be a question whose answer changes nothing.
1185
- if (subject.excluded)
1186
- continue;
1187
- if (isUsableRank(subject.rank))
1188
- continue;
1189
- if (confirmedRanks.has(subject.modelId))
1190
- continue;
1191
- unevidenced.add(subject.modelId);
1192
- }
1193
- // Stable, content-derived order (never gather/iteration order) — this string list
1194
- // reaches the obligation's reason text and the prompt, and an incidentally-ordered
1195
- // array churns downstream content hashes.
1196
- return [...unevidenced].sort();
1197
- }
1198
- /**
1199
- * Read the operator-confirmed cost↔speed dispatch bias (λ ∈ [0,1]) from the shared
1200
- * Gate-0 confirmation for the dispatch build sites (spec/dispatch-quota.md).
1201
- * Single-sourced so audit and remediate apply the identical operating point.
1202
- * Best-effort and never throws: an absent `root`, a missing/malformed confirmation,
1203
- * or an absent field all yield the cost-first default `0`.
1204
- *
1205
- * **Not gated on reach (G3 step 1)** — λ is the operator's durable operating point
1206
- * on the cost-vs-throughput frontier, i.e. POLICY. See
1207
- * {@link readConfirmedCostPositions}.
1208
- */
1209
- export async function readConfirmedDispatchBias(root) {
1210
- if (!root)
1211
- return 0;
1212
- const confirmation = await readSharedProviderConfirmation(root);
1213
- if (!confirmation)
1214
- return 0;
1215
- return clampDispatchBias(confirmation.dispatch_bias) ?? 0;
1216
- }
1217
- // ---------------------------------------------------------------------------
1218
- // Interactive Gate-0 operator input (spec/dispatch-quota.md — Gate-0)
1219
- // ---------------------------------------------------------------------------
1220
- /** File name of the host-written Gate-0 input under the audit artifacts dir. */
1221
- export const PROVIDER_CONFIRMATION_INPUT_FILENAME = "provider-confirmation.input.json";
1222
- /**
1223
- * Validate a parsed value as a ProviderConfirmationInput. Degrade-safe: returns
1224
- * `null` for absent/malformed so a missing or corrupt input is never an error
1225
- * (the executor then falls back to the tool's suggested ordering). Only the
1226
- * version is required; every other field is optional and validated to its
1227
- * expected shape (a malformed field is dropped, not fatal).
1228
- *
1229
- * ⚠ **One deliberate exception: an ungrammatical `exclude` rule THROWS.** Every
1230
- * other field degrades because dropping it costs the operator only that field's
1231
- * effect; dropping an exclusion costs them a backend they ruled out, dispatchable
1232
- * and silent. See the `exclude` branch below for the full argument.
1233
- */
1234
- export function parseProviderConfirmationInput(value) {
1235
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
1236
- return null;
1237
- }
1238
- const obj = value;
1239
- if (obj.schema_version !== PROVIDER_CONFIRMATION_INPUT_VERSION)
1240
- return null;
1241
- const stringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string")
1242
- ? v
1243
- : undefined;
1244
- const costOrder = stringArray(obj.cost_order);
1245
- // The capability-evidence answer. MUST be reconstructed here explicitly: this parser
1246
- // is field-by-field, so an unlisted field is silently dropped — and dropping THIS one
1247
- // is not a degrade, it is a livelock. The operator answers the prompt, the answer
1248
- // never reaches `annotateConfirmedPool`, no `capability_rank` is written, the delta
1249
- // recomputes identical, and `provider_confirmation` (PRIORITY[0]) re-prompts the same
1250
- // question forever. Any future field on ProviderConfirmationInput needs a line here.
1251
- const capabilityOrder = stringArray(obj.capability_order);
1252
- // No cast: `exclude` is the OPEN exclusion grammar, so asserting the operator's
1253
- // raw strings into the closed provider-name union would be a lie — and the exact
1254
- // type-assert-your-way-in move the policy parser refuses for `include`.
1255
- //
1256
- // Open is not the same as unchecked. THIS is the authorship seam — the one place
1257
- // an operator TYPES a rule — and it is where an ungrammatical one has to be
1258
- // refused, because nothing downstream can: `migrateExclusionPattern` rewrites any
1259
- // unrecognized head into `host:<pattern>`, so `model:model-a` becomes a
1260
- // structurally VALID host rule that matches zero backends and never reaches
1261
- // `parseExclusionRule`'s `invalid` branch. Read-time migration STAYS (it is what
1262
- // keeps an operator's already-saved bare rules excluded); validation is added
1263
- // here, where a new one is written.
1264
- const exclude = stringArray(obj.exclude);
1265
- if (exclude) {
1266
- // THROWS — the one field in this otherwise degrade-safe parser that refuses
1267
- // rather than drops, and the asymmetry is the whole point. Dropping a bad rule
1268
- // fails OPEN: the backend the operator meant to rule out stays dispatchable,
1269
- // silently. Returning `null` for the whole file is worse still — indistinguishable
1270
- // from "no submission", so the same prompt re-emits forever. Throwing names the
1271
- // exact pattern and the fix, and the submission file is NOT consumed (it is
1272
- // unlinked only after a successful promotion), so the operator edits the typo and
1273
- // re-runs. Loud and resumable, never silent and lossy.
1274
- //
1275
- // Authorship-blind on purpose: an autonomous run's LLM-authored submission is held
1276
- // to the identical grammar. Its `exclude` would be stripped a step later anyway
1277
- // (it may only answer `capability_order`), so a rule that cannot even parse is a
1278
- // malformed submission on either path — one grammar, one seam, no branch to drift.
1279
- const ungrammatical = ungrammaticalExclusionPatterns(exclude);
1280
- if (ungrammatical.length > 0) {
1281
- throw new Error(`Refused the Gate-0 submission: ${ungrammatical.length} \`exclude\` rule(s) ` +
1282
- `are not valid exclusion patterns — ${ungrammatical
1283
- .map((p) => JSON.stringify(p))
1284
- .join(", ")}. ` +
1285
- `A rule names its axis: \`transport:<provider>\` or ` +
1286
- `\`transport:<provider>/<model>\` (one adapter), \`service:<vendor>\` or ` +
1287
- `\`service:<vendor>/<model>\` (one vendor, however reached), or ` +
1288
- `\`host:<endpoint-address>\` (e.g. \`host:integrate.api.nvidia.com\`, ` +
1289
- `\`host:localhost:8000\`). There is no \`model:\` axis. ` +
1290
- `Fix the rule(s) in ${PROVIDER_CONFIRMATION_INPUT_FILENAME} and re-run — ` +
1291
- `nothing was promoted, and no other field of this submission was lost.`);
1292
- }
1293
- }
1294
- const include = stringArray(obj.include);
1295
- const dispatchBias = clampDispatchBias(obj.dispatch_bias);
1296
- // An EXPLICIT empty array is preserved, not dropped to absent. `[]` is the
1297
- // operator deliberately emptying their host roster; omission is them saying
1298
- // nothing about it. `carryForwardConfirmationInput` reseeds only the second case,
1299
- // so collapsing the two here would resurrect a roster the operator deleted — and
1300
- // there would be no way left to express the deletion at all.
1301
- const hostModels = Array.isArray(obj.host_models)
1302
- ? obj.host_models
1303
- .filter((m) => m !== null &&
1304
- typeof m === "object" &&
1305
- typeof m.model_id === "string")
1306
- .map((m) => ({ model_id: m.model_id }))
1307
- : undefined;
1308
- return {
1309
- schema_version: PROVIDER_CONFIRMATION_INPUT_VERSION,
1310
- ...(costOrder ? { cost_order: costOrder } : {}),
1311
- ...(capabilityOrder ? { capability_order: capabilityOrder } : {}),
1312
- ...(exclude ? { exclude } : {}),
1313
- ...(include ? { include } : {}),
1314
- ...(hostModels ? { host_models: hostModels } : {}),
1315
- ...(dispatchBias != null ? { dispatch_bias: dispatchBias } : {}),
1316
- };
1317
- }
1318
- /**
1319
- * Seed an incoming Gate-0 submission from the PRIOR confirmation, field by field.
1320
- *
1321
- * **The defect class this closes.** `buildProviderConfirmationRender` rebuilds the
1322
- * whole confirmation from the submission ALONE — every operator decision it persists
1323
- * (`cost_order`, `capability_rank`, the host roster, λ, and the `policy` exclusions) is
1324
- * reconstructed from `input` and from nothing else. So *any* field a submission omits
1325
- * is not "left alone", it is DESTROYED. That is one defect with six faces, and fixing
1326
- * it per-field is what let three of them survive a review round: the prompt's capability
1327
- * example is `{ "capability_order": [...] }`, so an operator answering exactly what was
1328
- * asked omits all five other fields and silently wipes them.
1329
- *
1330
- * Two rules, both load-bearing:
1331
- *
1332
- * 1. **`undefined` means "said nothing"; an explicit empty array means "delete".** A
1333
- * submission that never mentions host models is not a decision to remove them; an
1334
- * explicit `"host_models": []` is. `parseProviderConfirmationInput` therefore
1335
- * PRESERVES an empty array rather than dropping it to absent — without that the two
1336
- * cases are indistinguishable here and the carry-forward resurrects a roster the
1337
- * operator deleted.
1338
- * 2. **It applies with `input === null` too.** The autonomous/headless path promotes
1339
- * with no submission at all, and the capability delta is a brand-new trigger for
1340
- * that path — so short-circuiting on `input &&` would let an unattended re-promotion
1341
- * wipe the operator's entire persisted decision and then report convergence.
1342
- *
1343
- * Returns `null` only when there is nothing on either side. A prior confirmation with
1344
- * no incoming submission still yields a synthesized input carrying it forward.
1345
- */
1346
- /**
1347
- * The gate-authored exclusions that SURVIVE this promotion.
1348
- *
1349
- * Round-3 defect (high, fail-OPEN): `auto_exclude` was rebuilt on every promotion from
1350
- * `gate.newlyReachable` alone. But `confirmedBackendKeys` counts an excluded entry as
1351
- * CONFIRMED, so once the gate fail-closed-excludes backend X and folds it into the pool,
1352
- * the reach delta is empty forever — and the very next promotion rebuilt `auto_exclude`
1353
- * from that empty delta and dropped X, making a backend the operator never confirmed
1354
- * dispatchable. The docstring's "a submission supersedes it" was true; the code
1355
- * superseded it on EVERY promotion, including the no-submission one.
1356
- *
1357
- * The rule is therefore narrower than "any submission clears it". A submission
1358
- * supersedes an auto-exclusion only when it actually ADDRESSES that backend:
1359
- * - the operator re-stated the pattern in `exclude` — it is now operator-authored and
1360
- * lives there, so retaining an `auto_exclude` copy would double-record it; or
1361
- * - the operator named that provider in `include` — an explicit opt-back-IN.
1362
- * Anything else is SILENCE, and silence is not confirmation ("the operator confirms
1363
- * model choices"). A capability-only answer must not lift an exclusion the operator was
1364
- * never even shown — the reach section does not render once the backend is a confirmed
1365
- * key, so they cannot see what they would be lifting.
1366
- *
1367
- * Fail-CLOSED by construction: the uncertain case retains the exclusion.
1368
- */
1369
- export function retainAutoExclusions(priorAuto, input) {
1370
- if (priorAuto.length === 0)
1371
- return [];
1372
- const restated = new Set(input?.exclude ?? []);
1373
- const optedIn = input?.include ?? [];
1374
- const addressed = (pattern) => {
1375
- if (restated.has(pattern))
1376
- return true;
1377
- // Match the opt-in against the pattern's TRANSPORT, parsed — not against its raw
1378
- // text. Auto-authored patterns are axis-prefixed (`transport:codex`,
1379
- // `transport:codex/model`), so the old raw `pattern.startsWith(provider + ":")`
1380
- // test stopped matching the moment the grammar became axis-explicit: an operator
1381
- // opting `codex` back in never cleared `transport:codex`, their explicit include
1382
- // was silently ignored, and no submission could ever supersede an auto-exclusion.
1383
- const rule = parseExclusionRule(migrateExclusionPattern(pattern));
1384
- const transport = rule.kind === "transport"
1385
- ? rule.transport
1386
- : rule.kind === "transport_model"
1387
- ? rule.transport
1388
- : null;
1389
- if (transport === null)
1390
- return false;
1391
- // Both tiers (`transport:x` and `transport:x/model`) belong to the named provider.
1392
- return optedIn.some((provider) => provider === transport);
1393
- };
1394
- return priorAuto.filter((pattern) => !addressed(pattern));
1395
- }
1396
- export function carryForwardConfirmationInput(input,
1397
- // `undefined` is a first-class absent-prior: the `if (!prior)` guard below is
1398
- // a tested tolerance, and the type states it rather than leaving `undefined`
1399
- // reachable only from non-TS callers.
1400
- prior,
1401
- /**
1402
- * R3-3: true when THIS submission is LLM-authored (tool-derived — see
1403
- * `runProviderConfirmationAutoComplete`'s `authoredByLlm`). Governs which
1404
- * previously-ranked ids the capability merge below treats as fixed anchors:
1405
- * an LLM submission anchors every previously-ranked id (operator's and prior
1406
- * LLM's alike — rule 1); an operator submission does NOT anchor ids already in
1407
- * `capability_order_llm_ranked` (rule 2 — they interpolate like new models).
1408
- * Also gates the total-replacement escape in `mergeCapabilityOrder` (operator
1409
- * only — see its docstring). Defaults `false` (today's operator behavior) so
1410
- * every pre-existing call site is unaffected.
1411
- */
1412
- authoredByLlm = false) {
1413
- if (!prior)
1414
- return input;
1415
- const base = input ?? {
1416
- schema_version: PROVIDER_CONFIRMATION_INPUT_VERSION,
1417
- };
1418
- const priorHostModels = (prior.host_model_cost_order ?? []).map((entry) => ({
1419
- model_id: entry.model_id,
1420
- }));
1421
- const priorCostOrder = priorConfirmedCostOrder(prior);
1422
- // The operator's RAW answer, read back verbatim — never reconstructed from the
1423
- // resulting `capability_rank`s. Reconstruction could not tell an operator-authored
1424
- // rank from EXTERNAL evidence, so it laundered external numbers into the operator's
1425
- // ordering and made the laundered model read as evidenced forever.
1426
- const priorCapabilityOrder = prior.policy?.capability_order ?? [];
1427
- // `exclude` ONLY — `auto_exclude` is deliberately NOT carried. It is the gate's
1428
- // placeholder for an answer the operator never gave, and a submission supersedes it.
1429
- const priorExclude = prior.policy?.exclude ?? [];
1430
- const priorInclude = prior.policy?.include ?? [];
1431
- return {
1432
- ...base,
1433
- // `=== undefined` at every field, never a truthiness/length test: that is the
1434
- // said-nothing-vs-delete distinction, and a `!length` test collapses them.
1435
- ...(base.cost_order === undefined && priorCostOrder.length > 0
1436
- ? { cost_order: priorCostOrder }
1437
- : {}),
1438
- // The capability answer is the ONE field that does not follow the plain
1439
- // said-nothing/carry rule, because its PROMPT is delta-scoped: it renders only the
1440
- // unevidenced models, so a submission is a partial answer BY CONSTRUCTION and
1441
- // taking it as the whole ordering erases every rank the operator gave before —
1442
- // the `PRIORITY[0]` livelock. It is therefore MERGED by anchored insertion rather
1443
- // than replacing or being replaced (see {@link mergeCapabilityOrder}).
1444
- //
1445
- // The MERGED order is what gets persisted, not the raw submission:
1446
- // `buildConfirmedDispatchPolicy` stores whatever `input.capability_order` holds, and
1447
- // `policy.capability_order` is the only thing the NEXT promotion reads back. Keeping
1448
- // the raw answer there instead would mean the prior answers exist nowhere on disk and
1449
- // the merge would have nothing to merge against on the third promotion — the livelock
1450
- // one round-trip later. This does NOT undo "store the operator's answer verbatim":
1451
- // that rule exists to keep EXTERNAL evidence out of the operator's ordering, and both
1452
- // operands here are operator answers. Nothing derived from a `capability_rank` (which
1453
- // cannot distinguish operator from external provenance) enters this list.
1454
- ...(base.capability_order === undefined
1455
- ? priorCapabilityOrder.length > 0
1456
- ? { capability_order: priorCapabilityOrder }
1457
- : {}
1458
- : {
1459
- capability_order: mergeCapabilityOrder(priorCapabilityOrder, base.capability_order, capabilityOrderNonAnchors(prior.policy?.capability_order_llm_ranked ?? [], authoredByLlm), authoredByLlm),
1460
- }),
1461
- ...(base.host_models === undefined && priorHostModels.length > 0
1462
- ? { host_models: priorHostModels }
1463
- : {}),
1464
- ...(base.exclude === undefined && priorExclude.length > 0
1465
- ? { exclude: [...priorExclude] }
1466
- : {}),
1467
- ...(base.include === undefined && priorInclude.length > 0
1468
- ? { include: [...priorInclude] }
1469
- : {}),
1470
- ...(base.dispatch_bias === undefined && prior.dispatch_bias !== undefined
1471
- ? { dispatch_bias: prior.dispatch_bias }
1472
- : {}),
1473
- };
1474
- }
1475
- /** How many already-ranked models the capability prompt shows as fixed reference points. */
1476
- export const DEFAULT_CAPABILITY_ANCHOR_COUNT = 5;
1477
- /**
1478
- * Pick a BOUNDED, spread sample of an already-confirmed capability ordering to show
1479
- * beside the unevidenced models as fixed reference points.
1480
- *
1481
- * The roster may be HUNDREDS of models, so the prompt must be O(new + constant) — it
1482
- * can never render the whole ordering. First, last, and evenly-spaced interior picks
1483
- * give the operator a usable coordinate space (top / middle / bottom of the confirmed
1484
- * ranking) at constant cost, which is exactly what {@link mergeCapabilityOrder}
1485
- * interpolates against.
1486
- *
1487
- * @param priorOrder - The confirmed ordering, most-capable-first.
1488
- * @param exclude - Models already being asked about (the unevidenced delta); an
1489
- * anchor must be a model whose rank is settled, never one under question.
1490
- * @param max - Ceiling on the sample size.
1491
- */
1492
- export function selectCapabilityAnchors(priorOrder, exclude = [], max = DEFAULT_CAPABILITY_ANCHOR_COUNT) {
1493
- const excluded = new Set(exclude);
1494
- const unique = [...new Set(priorOrder)].filter((id) => !excluded.has(id));
1495
- if (max <= 0)
1496
- return [];
1497
- if (unique.length <= max || max === 1)
1498
- return unique.slice(0, max);
1499
- // Evenly spaced, endpoints included. `Set` absorbs a repeated index when the
1500
- // ordering is barely longer than `max`, so the result is never padded with dupes.
1501
- const picks = new Set();
1502
- for (let i = 0; i < max; i++) {
1503
- picks.add(Math.round((i * (unique.length - 1)) / (max - 1)));
1504
- }
1505
- return [...picks].sort((a, b) => a - b).map((i) => unique[i]);
1506
- }
1507
- /**
1508
- * Whether `submitted` restates EVERY id in `priorOrder` — the one case
1509
- * {@link mergeCapabilityOrder} treats as a total, verbatim replacement rather than an
1510
- * anchored partial answer (OPERATOR path only — see that function's docstring for why
1511
- * the escape is authorship-gated). Single-sourced so it and
1512
- * {@link detectDiscardedCapabilityReorder} can never disagree about which case they
1513
- * are in (a mismatch there reports a phantom discard, or misses a real one).
1514
- */
1515
- function isTotalCapabilitySubmission(priorOrder, submitted) {
1516
- const prior = [...new Set(priorOrder)];
1517
- if (prior.length === 0)
1518
- return false;
1519
- const answerSet = new Set(submitted);
1520
- return prior.every((id) => answerSet.has(id));
1521
- }
1522
- /**
1523
- * R3-3 — the submission's ANCHOR EXCLUSION set, single-sourced so
1524
- * {@link mergeCapabilityOrder} and {@link detectDiscardedCapabilityReorder} can never
1525
- * disagree about what counts as a fixed reference point for a given submission:
1526
- *
1527
- * - LLM-authored (`authoredByLlm`): every previously-ranked id — the operator's and
1528
- * any prior LLM's alike — stays a fixed anchor (rule 1). Nothing is excluded.
1529
- * - Operator-authored: ids already in `capability_order_llm_ranked` are NOT anchors
1530
- * (rule 2) — the operator may reposition them exactly like new models, and doing
1531
- * so removes them from that set (see {@link advanceCapabilityOrderLlmRanked}).
1532
- */
1533
- export function capabilityOrderNonAnchors(priorLlmRanked, authoredByLlm) {
1534
- return authoredByLlm ? new Set() : new Set(priorLlmRanked);
1535
- }
1536
- /**
1537
- * Advance the R3-3 `capability_order_llm_ranked` authorship SET across one
1538
- * promotion. Always a subset of the resulting `capability_order` — see
1539
- * {@link ConfirmedDispatchPolicy.capability_order_llm_ranked}.
1540
- *
1541
- * - LLM-authored submission: every id this submission newly ranked — i.e. not
1542
- * already in `priorOrder` — is ADDED. `mergeCapabilityOrder` never lets an
1543
- * LLM-authored submission move a previously-ranked id (its total-replacement
1544
- * escape is operator-only), so a prior id is never repositioned on this path and
1545
- * this function does not need to special-case "total" either: it is simply
1546
- * `answer \ priorOrder`.
1547
- * - Operator-authored submission: every id the submission NAMES is REMOVED — naming
1548
- * a previously LLM-ranked id (and, via {@link capabilityOrderNonAnchors}'s
1549
- * non-anchor treatment, freely repositioning it) makes it operator-authored from
1550
- * here on, exactly like a first-time rank.
1551
- * - No submission at all (`submitted` absent/empty): the running set is unchanged.
1552
- */
1553
- export function advanceCapabilityOrderLlmRanked(priorLlmRanked, priorOrder, submitted, authoredByLlm) {
1554
- const running = new Set(priorLlmRanked);
1555
- const answer = submitted ? [...new Set(submitted)] : [];
1556
- if (answer.length === 0)
1557
- return [...running].sort();
1558
- if (!authoredByLlm) {
1559
- for (const id of answer)
1560
- running.delete(id);
1561
- return [...running].sort();
1562
- }
1563
- const priorSet = new Set(priorOrder);
1564
- for (const id of answer.filter((id) => !priorSet.has(id)))
1565
- running.add(id);
1566
- return [...running].sort();
1567
- }
1568
- /**
1569
- * The anchor ids whose relative order the submission changed but the merge will NOT
1570
- * honor — i.e. an operator reorder that is about to be silently discarded.
1571
- *
1572
- * {@link mergeCapabilityOrder} treats every submitted id already present in
1573
- * `priorOrder` as a FIXED reference point, so a submission that swaps two of them
1574
- * returns the prior order unchanged. Without this, that is invisible: the promotion
1575
- * succeeds, the artifact is byte-identical, and nothing anywhere says the operator's
1576
- * decision was dropped. `unrankedOnPromotion` cannot catch it either — a reordered id
1577
- * IS present in `capability_order`, so it reports nothing.
1578
- *
1579
- * An accepted-then-discarded operator decision is the same defect class as laundering a
1580
- * tool guess into operator policy: not corruption, but SILENCE. The standing rule is
1581
- * that the operator must never have to notice — so the caller reports this loudly.
1582
- *
1583
- * Returns `[]` when the reorder will actually be honored: an OPERATOR-authored TOTAL
1584
- * submission (every prior id restated) is applied verbatim, and a submission with
1585
- * fewer than two anchors cannot express a reorder at all.
1586
- *
1587
- * NOTE this reports the LIMITATION, it does not lift it. Making a repositioning
1588
- * expressible without restating the whole roster needs the anchor-provenance split
1589
- * tracked in `docs/backlog.md`; this only ensures the drop is never silent.
1590
- */
1591
- export function detectDiscardedCapabilityReorder(priorOrder, submitted,
1592
- /**
1593
- * R3-3: ids to exclude from anchor treatment — must be the SAME set passed to
1594
- * {@link mergeCapabilityOrder} for this submission ({@link capabilityOrderNonAnchors}),
1595
- * or the two disagree about what an "anchor" is and this reports a discard the
1596
- * merge actually honored (or misses one it silently dropped).
1597
- */
1598
- nonAnchorIds = new Set(),
1599
- /**
1600
- * R3-3: mirrors the authorship-gated condition in {@link mergeCapabilityOrder} — the
1601
- * total-submission escape is OPERATOR-only, so an LLM-authored submission that
1602
- * happens to restate every prior id (a small roster's anchor sample can cover the
1603
- * whole ordering) still has its anchor reorder detected and reported, never
1604
- * silently honored via the escape.
1605
- */
1606
- authoredByLlm = false) {
1607
- const prior = [...new Set(priorOrder)];
1608
- const answer = [...new Set(submitted)];
1609
- if (prior.length === 0 || answer.length === 0)
1610
- return [];
1611
- const priorPos = new Map(prior.map((id, index) => [id, index]));
1612
- // A total submission is honored verbatim — nothing is discarded. Mirrors the same
1613
- // authorship-gated condition in `mergeCapabilityOrder`; the two must agree or this
1614
- // reports phantoms.
1615
- if (!authoredByLlm && isTotalCapabilitySubmission(prior, answer))
1616
- return [];
1617
- const anchors = answer.filter((id) => priorPos.has(id) && !nonAnchorIds.has(id));
1618
- if (anchors.length < 2)
1619
- return [];
1620
- // Discarded iff the anchors' order in the SUBMISSION differs from their order in the
1621
- // PRIOR ordering — compare against the anchors sorted by prior position.
1622
- const asConfirmed = [...anchors].sort((a, b) => priorPos.get(a) - priorPos.get(b));
1623
- // Report only the anchors that actually MOVED, not every anchor in the submission.
1624
- // `["a","c","b"]` against `["a","b","c","d"]` moves `c` and `b`; naming `a` too would
1625
- // tell the operator their unchanged entry was dropped, which is false — and a warning
1626
- // that over-reports is one the operator learns to discount.
1627
- return anchors.filter((id, i) => id !== asConfirmed[i]);
1628
- }
1629
- /**
1630
- * Merge an operator's capability answer into the previously confirmed ordering by
1631
- * **ANCHORED INSERTION**.
1632
- *
1633
- * **The livelock this closes.** The capability prompt is DELTA-SCOPED — it renders only
1634
- * the models with no evidence — while `annotateConfirmedPool` built its positions from
1635
- * the submission ALONE, i.e. total replacement. So each answer erased the last: rank A,
1636
- * the delta asks C, rank C, A loses its rank, the delta asks A, forever. `PRIORITY[0]`
1637
- * never converges. Reproduced across three promotions.
1638
- *
1639
- * The fix cannot be "render the whole ordering" (the roster may be hundreds of models —
1640
- * the prompt must stay O(new + constant)) and it cannot be an absolute score or tier
1641
- * (only a RELATIVE ordering is representable, by standing decision). Anchored insertion
1642
- * is what remains: show a bounded, spread sample of the confirmed ordering
1643
- * ({@link selectCapabilityAnchors}) as fixed reference points, and interpolate the new
1644
- * models into the coordinate space those points define.
1645
- *
1646
- * Semantics, exactly:
1647
- *
1648
- * - **Anchors** = submitted entries that already appear in `priorOrder`. They are
1649
- * REFERENCE POINTS: their prior positions define the coordinate space, and **a
1650
- * reordering of anchors relative to each other is deliberately NOT honored** on a
1651
- * partial submission. The operator saw at most a handful of them out of a possibly
1652
- * enormous ordering, so a swap between two anchors carries no information about the
1653
- * models BETWEEN them — honoring it would silently reshuffle models the operator
1654
- * never saw.
1655
- * - **Exception — an OPERATOR-authored TOTAL submission is a total replacement.**
1656
- * When the submission mentions every model in `priorOrder` there are no unmentioned
1657
- * models, so the coordinate space is fully respecified and the answer is honored
1658
- * verbatim. This is the only case where "reorder what you already confirmed" is a
1659
- * well-defined request, and it is the pre-existing behavior for a complete
1660
- * re-ranking. **R3-3: this escape is OPERATOR-only** (`authoredByLlm: false`,
1661
- * the default) — on a SMALL roster the bounded anchor sample
1662
- * ({@link selectCapabilityAnchors}) can cover the entire prior ordering, so an
1663
- * LLM's answer over "new models + all the anchors it was shown" becomes total BY
1664
- * ACCIDENT, not by the LLM's intent to reorder. Honoring it verbatim would let an
1665
- * LLM silently reorder ranks an operator (or a prior LLM) set; an LLM-authored
1666
- * submission therefore NEVER takes this exception — every previously-ranked id
1667
- * stays a fixed anchor regardless of coverage, and an attempted reorder is
1668
- * discarded and reported exactly like a partial submission's would be (see
1669
- * {@link detectDiscardedCapabilityReorder}).
1670
- * - **New models** (not in `priorOrder`) interpolate to a fractional position between
1671
- * the prior positions of the nearest preceding and following anchors IN THE SUBMITTED
1672
- * LIST. Before the first anchor ⇒ just below it (more capable); after the last ⇒ just
1673
- * above it. Consecutive new models keep their submitted relative order.
1674
- * - **Every model in `priorOrder` the submission does not mention keeps its prior
1675
- * position.** THIS IS THE LIVELOCK FIX.
1676
- * - **No anchors at all** (a partial submission naming only unknown models): there is no
1677
- * coordinate to interpolate against, so the new models are appended AFTER the whole
1678
- * prior ordering — the conservative direction, since a higher rank is less capable and
1679
- * therefore trusted with less.
1680
- * - **Duplicates**: first occurrence wins, matching `annotateConfirmedPool`'s rule that a
1681
- * positional list is the operator's ordering and a later repeat must not re-rank it.
1682
- * - **Result** is every model sorted by resolved position, ties broken by model id.
1683
- * Deterministic by construction: an incidentally-ordered array here would churn the
1684
- * confirmation's content hash on every promotion and cascade phantom staleness.
1685
- *
1686
- * Degenerate cases: an empty `priorOrder` (the first-ever answer) returns the submission;
1687
- * an empty submission returns the prior ordering unchanged (an omitted answer is
1688
- * "said nothing" — there is no way to express "delete the whole ranking", and the
1689
- * un-delete direction is the one that cannot livelock).
1690
- *
1691
- * Pure — no I/O, no clock, no config. Exported so the merge that decides whether the
1692
- * gate converges is directly testable.
1693
- */
1694
- export function mergeCapabilityOrder(priorOrder, submitted,
1695
- /**
1696
- * R3-3: ids to treat as NOT anchored even though they already appear in
1697
- * `priorOrder` — the operator-authored path passes the current
1698
- * `capability_order_llm_ranked` set here ({@link capabilityOrderNonAnchors}) so an
1699
- * operator may freely reposition a previously LLM-ranked id, exactly like a new
1700
- * model. Empty by default (today's behavior: every previously-ranked id anchors).
1701
- */
1702
- nonAnchorIds = new Set(),
1703
- /**
1704
- * R3-3: true when THIS submission is LLM-authored. Gates the total-replacement
1705
- * escape below (OPERATOR-only — see the docstring's exception): an LLM-authored
1706
- * submission never takes it, so a previously-ranked id is NEVER repositioned on
1707
- * this path, regardless of how much of `priorOrder` the submission happens to
1708
- * cover.
1709
- */
1710
- authoredByLlm = false) {
1711
- const prior = [...new Set(priorOrder)];
1712
- const answer = [...new Set(submitted)];
1713
- if (prior.length === 0)
1714
- return answer;
1715
- if (answer.length === 0)
1716
- return prior;
1717
- const priorPos = new Map(prior.map((id, index) => [id, index]));
1718
- // TOTAL submission ⇒ total replacement (see the docstring's exception) — OPERATOR
1719
- // path only. An LLM-authored submission never takes this escape, even when it
1720
- // happens to cover every prior id (a small roster's anchor sample can do that by
1721
- // accident): it would otherwise silently reorder ranks the LLM was never asked to
1722
- // move.
1723
- if (!authoredByLlm && isTotalCapabilitySubmission(prior, answer))
1724
- return answer;
1725
- // A non-anchor id still HOLDS its prior position (seeded below, so an unmentioned
1726
- // one keeps it) but cannot anchor a run boundary — it is swept into the
1727
- // surrounding run of new models and repositioned exactly like one (R3-3 rule 2).
1728
- const isAnchor = (id) => priorPos.has(id) && !nonAnchorIds.has(id);
1729
- /** Resolved position per model — seeded with every prior model, so an unmentioned one keeps its rank. */
1730
- const positions = new Map(priorPos);
1731
- const anchorCount = answer.filter(isAnchor).length;
1732
- if (anchorCount === 0) {
1733
- answer.forEach((id, index) => positions.set(id, prior.length + index));
1734
- }
1735
- else {
1736
- let i = 0;
1737
- while (i < answer.length) {
1738
- if (isAnchor(answer[i])) {
1739
- i++;
1740
- continue;
1741
- }
1742
- // A maximal run of NEW/non-anchor models, [i, j). `answer[i - 1]` is
1743
- // necessarily an anchor when `i > 0` — otherwise the run would have started
1744
- // earlier.
1745
- let j = i;
1746
- while (j < answer.length && !isAnchor(answer[j]))
1747
- j++;
1748
- const before = i > 0 ? priorPos.get(answer[i - 1]) : undefined;
1749
- const after = j < answer.length ? priorPos.get(answer[j]) : undefined;
1750
- let lo;
1751
- let hi;
1752
- if (before === undefined) {
1753
- hi = after;
1754
- lo = hi - 1;
1755
- }
1756
- else if (after === undefined) {
1757
- lo = before;
1758
- hi = lo + 1;
1759
- }
1760
- else {
1761
- lo = before;
1762
- // A reordered anchor pair yields an inverted or empty span. Anchor reordering
1763
- // is not honored, so degrade to "insert just after the preceding anchor"
1764
- // rather than emitting descending positions.
1765
- hi = after > lo ? after : lo + 1;
1766
- }
1767
- const run = j - i;
1768
- for (let t = 0; t < run; t++) {
1769
- positions.set(answer[i + t], lo + ((hi - lo) * (t + 1)) / (run + 1));
1770
- }
1771
- i = j;
1772
- }
1773
- }
1774
- return [...positions.entries()]
1775
- .sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]))
1776
- .map(([id]) => id);
1777
- }
1778
- /**
1779
- * Reconstruct the operator's confirmed cost ordering as the `cost_order` KEY list a
1780
- * fresh submission would carry — i.e. the inverse of `resolveFinalCostOrder`'s
1781
- * index⇒position mapping.
1782
- *
1783
- * Keyspace is the CANDIDATE key (`annotateConfirmedPool`'s `CostCandidate.key`):
1784
- * provider NAME for a provider pool, `model_id` for a host tier, `source_id` for a
1785
- * source pool. Deliberately NOT the MODEL keyspace the capability ordering
1786
- * ({@link mergeCapabilityOrder}) uses — the two genuinely key differently on the write
1787
- * side (a provider entry is capability-ranked by its `model_id`, never by its provider
1788
- * name), and unifying
1789
- * them here would silently drop every provider pool's position.
1790
- */
1791
- function priorConfirmedCostOrder(prior) {
1792
- const ranked = [];
1793
- for (const entry of prior.provider_pool ?? []) {
1794
- if (typeof entry.cost_order === "number") {
1795
- ranked.push({ key: entry.name, order: entry.cost_order });
1796
- }
1797
- }
1798
- for (const entry of prior.host_model_cost_order ?? []) {
1799
- ranked.push({ key: entry.model_id, order: entry.cost_order });
1800
- }
1801
- for (const entry of prior.source_pool_cost_order ?? []) {
1802
- ranked.push({ key: entry.source_id, order: entry.cost_order });
1803
- }
1804
- return sortRankedKeys(ranked);
1805
- }
1806
- /**
1807
- * Rank-ascending key list, de-duplicated first-occurrence-wins. The key tiebreak keeps
1808
- * the result deterministic when two pools share a position (a host tier defaulting to
1809
- * `cost_order: 0`, say) — an incidentally-ordered list here would churn the artifact's
1810
- * content hash on every promotion and cascade phantom staleness downstream.
1811
- */
1812
- function sortRankedKeys(ranked) {
1813
- const seen = new Set();
1814
- return ranked
1815
- .sort((a, b) => a.order - b.order || a.key.localeCompare(b.key))
1816
- .filter((entry) => (seen.has(entry.key) ? false : (seen.add(entry.key), true)))
1817
- .map((entry) => entry.key);
1818
- }
1819
- /**
1820
- * Read the operator's Gate-0 input from `<artifactsDir>/provider-confirmation.input.json`.
1821
- * Returns `null` when the file is absent, unreadable, or malformed — the "operator
1822
- * has not acted yet" signal the gate uses to decide emit-vs-consume.
1823
- *
1824
- * ⚠ Throws on exactly one condition, inherited from
1825
- * {@link parseProviderConfirmationInput}: an ungrammatical `exclude` rule. That is a
1826
- * REFUSAL, not a degrade — swallowing it here would turn a typo'd exclusion into
1827
- * "operator has not acted yet" and re-emit the same prompt forever, which is the
1828
- * silent failure the check exists to prevent. Every other malformed input still
1829
- * yields `null`.
1830
- */
1831
- export async function readProviderConfirmationInput(artifactsDir) {
1832
- const path = join(artifactsDir, PROVIDER_CONFIRMATION_INPUT_FILENAME);
1833
- let raw;
1834
- try {
1835
- raw = await readJsonFile(path);
1836
- }
1837
- catch {
1838
- return null;
1839
- }
1840
- return parseProviderConfirmationInput(raw);
1841
- }
1842
- /**
1843
- * Invalidate a CONSUMED Gate-0 input by deleting it — the second half of
1844
- * consume-and-invalidate, paired here with {@link readProviderConfirmationInput} so
1845
- * the two cannot drift apart.
1846
- *
1847
- * The input's presence is the "operator has acted" signal the gate reads to decide
1848
- * emit-vs-consume. Once promoted into the canonical artifacts it is SPENT: leaving it
1849
- * on disk means a later reconciliation delta silently re-consumes a submission that
1850
- * answered an older question, auto-satisfying the gate instead of asking the
1851
- * operator. Deleting it is what makes the gate able to fire a second time at all.
1852
- *
1853
- * Best-effort and never throws: an already-absent file is the desired end state, and
1854
- * a failed unlink must not break the in-flight obligation (the promotion itself
1855
- * already succeeded).
1856
- */
1857
- export async function unlinkProviderConfirmationInput(artifactsDir) {
1858
- try {
1859
- await unlink(join(artifactsDir, PROVIDER_CONFIRMATION_INPUT_FILENAME));
1860
- }
1861
- catch {
1862
- // Absent / locked / read-only — nothing to invalidate, or nothing we can do.
1863
- }
1864
- }
1865
- //# sourceMappingURL=sharedProviderConfirmation.js.map