productfoo 0.0.0-stage → 1.0.1

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 (498) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +338 -2
  3. package/bin/foo +3 -0
  4. package/bin/foo.mjs +42 -0
  5. package/commands/bug.md +31 -0
  6. package/commands/discovery.md +28 -0
  7. package/commands/ideate.md +27 -0
  8. package/commands/init.md +28 -0
  9. package/commands/pitch.md +27 -0
  10. package/commands/preview.md +25 -0
  11. package/commands/research.md +31 -0
  12. package/commands/roast.md +27 -0
  13. package/commands/spec.md +30 -0
  14. package/config/pricing.yaml +198 -0
  15. package/config/routing-policy.yaml +171 -0
  16. package/dist-public/src/activeModel.d.ts +83 -0
  17. package/dist-public/src/activeModel.js +331 -0
  18. package/dist-public/src/agentAdapters.d.ts +122 -0
  19. package/dist-public/src/agentAdapters.js +178 -0
  20. package/dist-public/src/agentState.d.ts +43 -0
  21. package/dist-public/src/agentState.js +177 -0
  22. package/dist-public/src/bugCapsule.d.ts +94 -0
  23. package/dist-public/src/bugCapsule.js +227 -0
  24. package/dist-public/src/bugCapture.d.ts +26 -0
  25. package/dist-public/src/bugCapture.js +251 -0
  26. package/dist-public/src/bugOwnerLog.d.ts +93 -0
  27. package/dist-public/src/bugOwnerLog.js +304 -0
  28. package/dist-public/src/bugReportStore.d.ts +48 -0
  29. package/dist-public/src/bugReportStore.js +860 -0
  30. package/dist-public/src/bugSpecContext.d.ts +7 -0
  31. package/dist-public/src/bugSpecContext.js +129 -0
  32. package/dist-public/src/channelFreshness.d.ts +25 -0
  33. package/dist-public/src/channelFreshness.js +194 -0
  34. package/dist-public/src/cli.d.ts +30 -0
  35. package/dist-public/src/cli.js +1667 -0
  36. package/dist-public/src/cliArgs.d.ts +7 -0
  37. package/dist-public/src/cliArgs.js +44 -0
  38. package/dist-public/src/codeCatalog.d.ts +41 -0
  39. package/dist-public/src/codeCatalog.js +197 -0
  40. package/dist-public/src/commandContract.d.ts +84 -0
  41. package/dist-public/src/commandContract.js +390 -0
  42. package/dist-public/src/commandUsage.d.ts +3 -0
  43. package/dist-public/src/commandUsage.js +30 -0
  44. package/dist-public/src/commands/agent.d.ts +60 -0
  45. package/dist-public/src/commands/agent.js +1353 -0
  46. package/dist-public/src/commands/asp.d.ts +8 -0
  47. package/dist-public/src/commands/asp.js +669 -0
  48. package/dist-public/src/commands/evalCmd.d.ts +4 -0
  49. package/dist-public/src/commands/evalCmd.js +405 -0
  50. package/dist-public/src/commands/login.d.ts +25 -0
  51. package/dist-public/src/commands/login.js +94 -0
  52. package/dist-public/src/commands/loop.d.ts +16 -0
  53. package/dist-public/src/commands/loop.js +619 -0
  54. package/dist-public/src/commands/reconcile.d.ts +41 -0
  55. package/dist-public/src/commands/reconcile.js +292 -0
  56. package/dist-public/src/commands/runtime.d.ts +1 -0
  57. package/dist-public/src/commands/runtime.js +36 -0
  58. package/dist-public/src/commands/scanRedact.d.ts +8 -0
  59. package/dist-public/src/commands/scanRedact.js +266 -0
  60. package/dist-public/src/commands/setup.d.ts +28 -0
  61. package/dist-public/src/commands/setup.js +398 -0
  62. package/dist-public/src/commands/shared.d.ts +17 -0
  63. package/dist-public/src/commands/shared.js +147 -0
  64. package/dist-public/src/commands/source.d.ts +1 -0
  65. package/dist-public/src/commands/source.js +134 -0
  66. package/dist-public/src/commands/specCmd.d.ts +24 -0
  67. package/dist-public/src/commands/specCmd.js +794 -0
  68. package/dist-public/src/commands/update.d.ts +61 -0
  69. package/dist-public/src/commands/update.js +244 -0
  70. package/dist-public/src/comprehensionSignal.d.ts +20 -0
  71. package/dist-public/src/comprehensionSignal.js +166 -0
  72. package/dist-public/src/conductor/diversity.d.ts +48 -0
  73. package/dist-public/src/conductor/diversity.js +423 -0
  74. package/dist-public/src/conductor/engine.d.ts +7 -0
  75. package/dist-public/src/conductor/engine.js +295 -0
  76. package/dist-public/src/conductor/families/ideate.d.ts +2 -0
  77. package/dist-public/src/conductor/families/ideate.js +255 -0
  78. package/dist-public/src/conductor/families/interview.d.ts +2 -0
  79. package/dist-public/src/conductor/families/interview.js +39 -0
  80. package/dist-public/src/conductor/families/specSwarm.d.ts +2 -0
  81. package/dist-public/src/conductor/families/specSwarm.js +146 -0
  82. package/dist-public/src/conductor/familyDelivery.d.ts +6 -0
  83. package/dist-public/src/conductor/familyDelivery.js +37 -0
  84. package/dist-public/src/conductor/familyPackArtifact.d.ts +4 -0
  85. package/dist-public/src/conductor/familyPackArtifact.js +260 -0
  86. package/dist-public/src/conductor/ideateDecision.d.ts +6 -0
  87. package/dist-public/src/conductor/ideateDecision.js +237 -0
  88. package/dist-public/src/conductor/pack.d.ts +26 -0
  89. package/dist-public/src/conductor/pack.js +75 -0
  90. package/dist-public/src/conductor/types.d.ts +111 -0
  91. package/dist-public/src/conductor/types.js +7 -0
  92. package/dist-public/src/configPath.d.ts +2 -0
  93. package/dist-public/src/configPath.js +7 -0
  94. package/dist-public/src/discoveryArtifacts.d.ts +54 -0
  95. package/dist-public/src/discoveryArtifacts.js +467 -0
  96. package/dist-public/src/discoveryCommand.d.ts +127 -0
  97. package/dist-public/src/discoveryCommand.js +2148 -0
  98. package/dist-public/src/discoveryConnections.d.ts +339 -0
  99. package/dist-public/src/discoveryConnections.js +613 -0
  100. package/dist-public/src/discoveryDocs.d.ts +129 -0
  101. package/dist-public/src/discoveryDocs.js +577 -0
  102. package/dist-public/src/discoveryEvidence.d.ts +59 -0
  103. package/dist-public/src/discoveryEvidence.js +198 -0
  104. package/dist-public/src/discoveryFireflies.d.ts +25 -0
  105. package/dist-public/src/discoveryFireflies.js +551 -0
  106. package/dist-public/src/discoveryGmail.d.ts +12 -0
  107. package/dist-public/src/discoveryGmail.js +466 -0
  108. package/dist-public/src/discoveryGong.d.ts +12 -0
  109. package/dist-public/src/discoveryGong.js +491 -0
  110. package/dist-public/src/discoveryLearningRouting.d.ts +90 -0
  111. package/dist-public/src/discoveryLearningRouting.js +405 -0
  112. package/dist-public/src/discoveryMatching.d.ts +26 -0
  113. package/dist-public/src/discoveryMatching.js +73 -0
  114. package/dist-public/src/discoveryOutlook.d.ts +12 -0
  115. package/dist-public/src/discoveryOutlook.js +492 -0
  116. package/dist-public/src/discoveryProcess.d.ts +221 -0
  117. package/dist-public/src/discoveryProcess.js +703 -0
  118. package/dist-public/src/discoveryProof.d.ts +143 -0
  119. package/dist-public/src/discoveryProof.js +529 -0
  120. package/dist-public/src/discoveryProviders.d.ts +152 -0
  121. package/dist-public/src/discoveryProviders.js +756 -0
  122. package/dist-public/src/discoveryReadiness.d.ts +219 -0
  123. package/dist-public/src/discoveryReadiness.js +672 -0
  124. package/dist-public/src/discoveryReconcile.d.ts +57 -0
  125. package/dist-public/src/discoveryReconcile.js +424 -0
  126. package/dist-public/src/discoverySlack.d.ts +12 -0
  127. package/dist-public/src/discoverySlack.js +452 -0
  128. package/dist-public/src/discoverySources.d.ts +56 -0
  129. package/dist-public/src/discoverySources.js +322 -0
  130. package/dist-public/src/discoverySync.d.ts +52 -0
  131. package/dist-public/src/discoverySync.js +151 -0
  132. package/dist-public/src/discoverySynthesis.d.ts +57 -0
  133. package/dist-public/src/discoverySynthesis.js +215 -0
  134. package/dist-public/src/discoveryTeams.d.ts +12 -0
  135. package/dist-public/src/discoveryTeams.js +516 -0
  136. package/dist-public/src/discoveryZoom.d.ts +12 -0
  137. package/dist-public/src/discoveryZoom.js +523 -0
  138. package/dist-public/src/elicitationSurface.d.ts +13 -0
  139. package/dist-public/src/elicitationSurface.js +77 -0
  140. package/dist-public/src/errorReport.d.ts +99 -0
  141. package/dist-public/src/errorReport.js +443 -0
  142. package/dist-public/src/errorTaxonomy.d.ts +84 -0
  143. package/dist-public/src/errorTaxonomy.js +189 -0
  144. package/dist-public/src/evalCalibration.d.ts +82 -0
  145. package/dist-public/src/evalCalibration.js +169 -0
  146. package/dist-public/src/evalChecks.d.ts +21 -0
  147. package/dist-public/src/evalChecks.js +295 -0
  148. package/dist-public/src/evalCompile.d.ts +56 -0
  149. package/dist-public/src/evalCompile.js +58 -0
  150. package/dist-public/src/evalImprove.d.ts +60 -0
  151. package/dist-public/src/evalImprove.js +175 -0
  152. package/dist-public/src/evalJudge.d.ts +47 -0
  153. package/dist-public/src/evalJudge.js +143 -0
  154. package/dist-public/src/evalLabels.d.ts +100 -0
  155. package/dist-public/src/evalLabels.js +287 -0
  156. package/dist-public/src/evalRecord.d.ts +68 -0
  157. package/dist-public/src/evalRecord.js +165 -0
  158. package/dist-public/src/evalRoast.d.ts +88 -0
  159. package/dist-public/src/evalRoast.js +249 -0
  160. package/dist-public/src/evidenceIntake.d.ts +66 -0
  161. package/dist-public/src/evidenceIntake.js +356 -0
  162. package/dist-public/src/feedback.d.ts +78 -0
  163. package/dist-public/src/feedback.js +294 -0
  164. package/dist-public/src/firefliesClient.d.ts +106 -0
  165. package/dist-public/src/firefliesClient.js +258 -0
  166. package/dist-public/src/firstMoveTensions.d.ts +10 -0
  167. package/dist-public/src/firstMoveTensions.js +93 -0
  168. package/dist-public/src/gitRun.d.ts +21 -0
  169. package/dist-public/src/gitRun.js +50 -0
  170. package/dist-public/src/githubReconcile.d.ts +94 -0
  171. package/dist-public/src/githubReconcile.js +205 -0
  172. package/dist-public/src/gmailNango.d.ts +131 -0
  173. package/dist-public/src/gmailNango.js +329 -0
  174. package/dist-public/src/gongNango.d.ts +118 -0
  175. package/dist-public/src/gongNango.js +308 -0
  176. package/dist-public/src/grammarGate.d.ts +13 -0
  177. package/dist-public/src/grammarGate.js +72 -0
  178. package/dist-public/src/harnessGates.d.ts +65 -0
  179. package/dist-public/src/harnessGates.js +168 -0
  180. package/dist-public/src/humanSurfaceEval.d.ts +19 -0
  181. package/dist-public/src/humanSurfaceEval.js +156 -0
  182. package/dist-public/src/ideate.d.ts +43 -0
  183. package/dist-public/src/ideate.js +255 -0
  184. package/dist-public/src/ideateContext.d.ts +11 -0
  185. package/dist-public/src/ideateContext.js +83 -0
  186. package/dist-public/src/index.d.ts +2 -0
  187. package/dist-public/src/index.js +4 -0
  188. package/dist-public/src/initCommand.d.ts +25 -0
  189. package/dist-public/src/initCommand.js +558 -0
  190. package/dist-public/src/interviewGate.d.ts +25 -0
  191. package/dist-public/src/interviewGate.js +126 -0
  192. package/dist-public/src/jsonFile.d.ts +2 -0
  193. package/dist-public/src/jsonFile.js +22 -0
  194. package/dist-public/src/loopBudget.d.ts +182 -0
  195. package/dist-public/src/loopBudget.js +494 -0
  196. package/dist-public/src/loopContracts.d.ts +35 -0
  197. package/dist-public/src/loopContracts.js +364 -0
  198. package/dist-public/src/machineState.d.ts +1 -0
  199. package/dist-public/src/machineState.js +9 -0
  200. package/dist-public/src/notionNango.d.ts +93 -0
  201. package/dist-public/src/notionNango.js +279 -0
  202. package/dist-public/src/orchestrationResult.d.ts +11 -0
  203. package/dist-public/src/orchestrationResult.js +73 -0
  204. package/dist-public/src/outlookNango.d.ts +124 -0
  205. package/dist-public/src/outlookNango.js +327 -0
  206. package/dist-public/src/packetCorrections.d.ts +40 -0
  207. package/dist-public/src/packetCorrections.js +172 -0
  208. package/dist-public/src/packetGlossary.d.ts +6 -0
  209. package/dist-public/src/packetGlossary.js +42 -0
  210. package/dist-public/src/packetIndex.d.ts +54 -0
  211. package/dist-public/src/packetIndex.js +744 -0
  212. package/dist-public/src/packetIndexEntry.d.ts +3 -0
  213. package/dist-public/src/packetIndexEntry.js +128 -0
  214. package/dist-public/src/packetIndexServe.d.ts +13 -0
  215. package/dist-public/src/packetIndexServe.js +113 -0
  216. package/dist-public/src/packetIndexWorker.d.ts +7 -0
  217. package/dist-public/src/packetIndexWorker.js +45 -0
  218. package/dist-public/src/packetLifecycle.d.ts +30 -0
  219. package/dist-public/src/packetLifecycle.js +127 -0
  220. package/dist-public/src/packetReconcile.d.ts +73 -0
  221. package/dist-public/src/packetReconcile.js +386 -0
  222. package/dist-public/src/packetStatus.d.ts +125 -0
  223. package/dist-public/src/packetStatus.js +770 -0
  224. package/dist-public/src/packetValidation.d.ts +17 -0
  225. package/dist-public/src/packetValidation.js +333 -0
  226. package/dist-public/src/packetVerify.d.ts +73 -0
  227. package/dist-public/src/packetVerify.js +303 -0
  228. package/dist-public/src/packetWrite.d.ts +52 -0
  229. package/dist-public/src/packetWrite.js +448 -0
  230. package/dist-public/src/pitch.d.ts +38 -0
  231. package/dist-public/src/pitch.js +154 -0
  232. package/dist-public/src/processMemo.d.ts +8 -0
  233. package/dist-public/src/processMemo.js +23 -0
  234. package/dist-public/src/productDetect.d.ts +30 -0
  235. package/dist-public/src/productDetect.js +376 -0
  236. package/dist-public/src/profile.d.ts +62 -0
  237. package/dist-public/src/profile.js +334 -0
  238. package/dist-public/src/questionModel.d.ts +46 -0
  239. package/dist-public/src/questionModel.js +74 -0
  240. package/dist-public/src/questionText.d.ts +7 -0
  241. package/dist-public/src/questionText.js +38 -0
  242. package/dist-public/src/redactConfig.d.ts +2 -0
  243. package/dist-public/src/redactConfig.js +11 -0
  244. package/dist-public/src/redactEngine.d.ts +59 -0
  245. package/dist-public/src/redactEngine.js +509 -0
  246. package/dist-public/src/redactGate.d.ts +63 -0
  247. package/dist-public/src/redactGate.js +209 -0
  248. package/dist-public/src/repoRead.d.ts +29 -0
  249. package/dist-public/src/repoRead.js +160 -0
  250. package/dist-public/src/research.d.ts +242 -0
  251. package/dist-public/src/research.js +1314 -0
  252. package/dist-public/src/researchCompletion.d.ts +34 -0
  253. package/dist-public/src/researchCompletion.js +123 -0
  254. package/dist-public/src/researchExecution.d.ts +47 -0
  255. package/dist-public/src/researchExecution.js +309 -0
  256. package/dist-public/src/researchFormat.d.ts +21 -0
  257. package/dist-public/src/researchFormat.js +77 -0
  258. package/dist-public/src/researchGate.d.ts +86 -0
  259. package/dist-public/src/researchGate.js +1036 -0
  260. package/dist-public/src/researchReceipt.d.ts +76 -0
  261. package/dist-public/src/researchReceipt.js +148 -0
  262. package/dist-public/src/researchRender.d.ts +2 -0
  263. package/dist-public/src/researchRender.js +267 -0
  264. package/dist-public/src/researchSigningKey.d.ts +19 -0
  265. package/dist-public/src/researchSigningKey.js +95 -0
  266. package/dist-public/src/riskClassify.d.ts +42 -0
  267. package/dist-public/src/riskClassify.js +149 -0
  268. package/dist-public/src/routines.d.ts +42 -0
  269. package/dist-public/src/routines.js +269 -0
  270. package/dist-public/src/routingPolicy.d.ts +172 -0
  271. package/dist-public/src/routingPolicy.js +646 -0
  272. package/dist-public/src/secretInput.d.ts +8 -0
  273. package/dist-public/src/secretInput.js +89 -0
  274. package/dist-public/src/selfCheck.d.ts +7 -0
  275. package/dist-public/src/selfCheck.js +473 -0
  276. package/dist-public/src/selfCheckCache.d.ts +29 -0
  277. package/dist-public/src/selfCheckCache.js +147 -0
  278. package/dist-public/src/severityClass.d.ts +15 -0
  279. package/dist-public/src/severityClass.js +22 -0
  280. package/dist-public/src/skillClientPolicy.d.ts +8 -0
  281. package/dist-public/src/skillClientPolicy.js +16 -0
  282. package/dist-public/src/skillLocal.d.ts +98 -0
  283. package/dist-public/src/skillLocal.js +463 -0
  284. package/dist-public/src/skillTreeDigest.d.ts +9 -0
  285. package/dist-public/src/skillTreeDigest.js +64 -0
  286. package/dist-public/src/slackNango.d.ts +62 -0
  287. package/dist-public/src/slackNango.js +208 -0
  288. package/dist-public/src/social/arxiv.d.ts +19 -0
  289. package/dist-public/src/social/arxiv.js +96 -0
  290. package/dist-public/src/social/github.d.ts +2 -0
  291. package/dist-public/src/social/github.js +78 -0
  292. package/dist-public/src/social/hackernews.d.ts +2 -0
  293. package/dist-public/src/social/hackernews.js +67 -0
  294. package/dist-public/src/social/index.d.ts +10 -0
  295. package/dist-public/src/social/index.js +79 -0
  296. package/dist-public/src/social/reddit.d.ts +2 -0
  297. package/dist-public/src/social/reddit.js +125 -0
  298. package/dist-public/src/social/types.d.ts +73 -0
  299. package/dist-public/src/social/types.js +61 -0
  300. package/dist-public/src/social/xml.d.ts +10 -0
  301. package/dist-public/src/social/xml.js +49 -0
  302. package/dist-public/src/social/youtube.d.ts +7 -0
  303. package/dist-public/src/social/youtube.js +217 -0
  304. package/dist-public/src/sourceIngress.d.ts +488 -0
  305. package/dist-public/src/sourceIngress.js +1651 -0
  306. package/dist-public/src/specAfter.d.ts +187 -0
  307. package/dist-public/src/specAfter.js +814 -0
  308. package/dist-public/src/specBudget.d.ts +124 -0
  309. package/dist-public/src/specBudget.js +424 -0
  310. package/dist-public/src/specDecisionSignals.d.ts +38 -0
  311. package/dist-public/src/specDecisionSignals.js +121 -0
  312. package/dist-public/src/specLinkedCommits.d.ts +7 -0
  313. package/dist-public/src/specLinkedCommits.js +107 -0
  314. package/dist-public/src/specSections.d.ts +105 -0
  315. package/dist-public/src/specSections.js +718 -0
  316. package/dist-public/src/statusGlyphs.d.ts +18 -0
  317. package/dist-public/src/statusGlyphs.js +75 -0
  318. package/dist-public/src/swarmCommand.d.ts +10 -0
  319. package/dist-public/src/swarmCommand.js +96 -0
  320. package/dist-public/src/teamsNango.d.ts +114 -0
  321. package/dist-public/src/teamsNango.js +301 -0
  322. package/dist-public/src/tokenStore.d.ts +10 -0
  323. package/dist-public/src/tokenStore.js +77 -0
  324. package/dist-public/src/trustEnvelope.d.ts +78 -0
  325. package/dist-public/src/trustEnvelope.js +392 -0
  326. package/dist-public/src/untrustedText.d.ts +6 -0
  327. package/dist-public/src/untrustedText.js +20 -0
  328. package/dist-public/src/updateNotice.d.ts +41 -0
  329. package/dist-public/src/updateNotice.js +290 -0
  330. package/dist-public/src/vendorYaml.d.ts +1 -0
  331. package/dist-public/src/vendorYaml.js +7374 -0
  332. package/dist-public/src/workflowRunActuals.d.ts +67 -0
  333. package/dist-public/src/workflowRunActuals.js +184 -0
  334. package/dist-public/src/zendeskDiscovery.d.ts +12 -0
  335. package/dist-public/src/zendeskDiscovery.js +513 -0
  336. package/dist-public/src/zendeskNango.d.ts +131 -0
  337. package/dist-public/src/zendeskNango.js +344 -0
  338. package/dist-public/src/zoomNango.d.ts +106 -0
  339. package/dist-public/src/zoomNango.js +351 -0
  340. package/docs/CHANGELOG.md +32 -0
  341. package/docs/THIRD_PARTY_NOTICES.md +36 -0
  342. package/package.json +71 -4
  343. package/schemas/bug-capsule-receipt.schema.json +54 -0
  344. package/schemas/bug-capsule.schema.json +189 -0
  345. package/schemas/discovery-evidence-envelope.schema.json +79 -0
  346. package/schemas/discovery-processing-receipt.schema.json +71 -0
  347. package/schemas/discovery-service-contract.schema.json +189 -0
  348. package/skills/AGENTS.md +32 -0
  349. package/skills/README.md +287 -0
  350. package/skills/bug/SKILL.md +49 -0
  351. package/skills/build-preview-spec/SKILL.md +103 -0
  352. package/skills/capture-external-artifact/SKILL.md +23 -0
  353. package/skills/capture-external-artifact/agents/openai.yaml +4 -0
  354. package/skills/capture-external-artifact/references/extractor-libraries.md +27 -0
  355. package/skills/check-spec-health/SKILL.md +45 -0
  356. package/skills/check-spec-health/agents/openai.yaml +4 -0
  357. package/skills/competitive-positioning/SKILL.md +86 -0
  358. package/skills/competitive-positioning/agents/openai.yaml +4 -0
  359. package/skills/cpo-lens/SKILL.md +398 -0
  360. package/skills/cpo-lens/agents/openai.yaml +4 -0
  361. package/skills/create-prd/SKILL.md +26 -0
  362. package/skills/create-prd/agents/openai.yaml +4 -0
  363. package/skills/customer-discovery/SKILL.md +192 -0
  364. package/skills/design-system/SKILL.md +49 -0
  365. package/skills/design-system/agents/openai.yaml +4 -0
  366. package/skills/discussion-guide/SKILL.md +75 -0
  367. package/skills/discussion-guide/agents/openai.yaml +4 -0
  368. package/skills/firecrawl-research-papers/SKILL.md +96 -0
  369. package/skills/firecrawl-research-papers/agents/openai.yaml +4 -0
  370. package/skills/foo-roast/SKILL.md +325 -0
  371. package/skills/generate-gtm-brief/SKILL.md +41 -0
  372. package/skills/generate-gtm-brief/agents/openai.yaml +4 -0
  373. package/skills/generate-gtm-brief/references/gtm-brief-template.md +56 -0
  374. package/skills/gong-quotes/SKILL.md +43 -0
  375. package/skills/gong-quotes/agents/openai.yaml +4 -0
  376. package/skills/grill-me/SKILL.md +27 -0
  377. package/skills/grill-me/agents/openai.yaml +4 -0
  378. package/skills/ideate/SKILL.md +180 -0
  379. package/skills/ideate/references/family-packs.md +5 -0
  380. package/skills/initiative-tracker/SKILL.md +23 -0
  381. package/skills/initiative-tracker/agents/openai.yaml +4 -0
  382. package/skills/interop-review/SKILL.md +25 -0
  383. package/skills/interop-review/agents/openai.yaml +4 -0
  384. package/skills/interview-me/SKILL.md +404 -0
  385. package/skills/interview-me/agents/openai.yaml +4 -0
  386. package/skills/loop-library-audit/SKILL.md +123 -0
  387. package/skills/pitch/SKILL.md +110 -0
  388. package/skills/pitch/references/opportunity-assessment.md +136 -0
  389. package/skills/pitch/references/prfaq-template.md +54 -0
  390. package/skills/pmm-release-briefing/SKILL.md +27 -0
  391. package/skills/pmm-release-briefing/agents/openai.yaml +4 -0
  392. package/skills/pmm-release-briefing/references/variant-generalized.md +16 -0
  393. package/skills/positioning-messaging/SKILL.md +70 -0
  394. package/skills/positioning-messaging/agents/openai.yaml +4 -0
  395. package/skills/positioning-messaging/references/guest-insights.md +1400 -0
  396. package/skills/preview-swarm/SKILL.md +418 -0
  397. package/skills/preview-swarm/agents/openai.yaml +4 -0
  398. package/skills/preview-swarm/references/html-effectiveness-build-previews.md +168 -0
  399. package/skills/product-copywriting/SKILL.md +127 -0
  400. package/skills/product-copywriting/agents/openai.yaml +4 -0
  401. package/skills/product-copywriting/references/foo-voice.md +26 -0
  402. package/skills/product-shape-interview/SKILL.md +219 -0
  403. package/skills/product-shape-interview/agents/openai.yaml +4 -0
  404. package/skills/ready-for-release/SKILL.md +39 -0
  405. package/skills/ready-for-release/agents/openai.yaml +4 -0
  406. package/skills/redact-transcripts/SKILL.md +23 -0
  407. package/skills/redact-transcripts/agents/openai.yaml +4 -0
  408. package/skills/research-brief/SKILL.md +48 -0
  409. package/skills/research-brief/agents/openai.yaml +4 -0
  410. package/skills/research-refresh/SKILL.md +63 -0
  411. package/skills/research-refresh/references/worked-cases.md +44 -0
  412. package/skills/review-loop/SKILL.md +200 -0
  413. package/skills/review-spec/SKILL.md +32 -0
  414. package/skills/review-spec/agents/openai.yaml +4 -0
  415. package/skills/rfc-from-research/SKILL.md +42 -0
  416. package/skills/rfc-from-research/agents/openai.yaml +4 -0
  417. package/skills/rfc-from-research/references/rfc-pattern.md +69 -0
  418. package/skills/skill-registry.yaml +810 -0
  419. package/skills/spec-cleanup/SKILL.md +103 -0
  420. package/skills/spec-cleanup/agents/openai.yaml +4 -0
  421. package/skills/spec-swarm/SKILL.md +87 -0
  422. package/skills/spec-swarm/agents/openai.yaml +4 -0
  423. package/skills/spec-swarm/template/SKILL.template.md +94 -0
  424. package/skills/spec-swarm/template/personas/accessibility-inclusion.md +40 -0
  425. package/skills/spec-swarm/template/personas/ai-technical-pm.md +69 -0
  426. package/skills/spec-swarm/template/personas/analytics-engineering.md +39 -0
  427. package/skills/spec-swarm/template/personas/applied-ai-eval.md +64 -0
  428. package/skills/spec-swarm/template/personas/business-gm-pm.md +67 -0
  429. package/skills/spec-swarm/template/personas/consumer-craft-pm.md +68 -0
  430. package/skills/spec-swarm/template/personas/content-design.md +40 -0
  431. package/skills/spec-swarm/template/personas/cpo.md +42 -0
  432. package/skills/spec-swarm/template/personas/data-science.md +42 -0
  433. package/skills/spec-swarm/template/personas/developer-experience.md +65 -0
  434. package/skills/spec-swarm/template/personas/dx-product.md +70 -0
  435. package/skills/spec-swarm/template/personas/field.md +43 -0
  436. package/skills/spec-swarm/template/personas/finance-cfo.md +40 -0
  437. package/skills/spec-swarm/template/personas/growth-pm.md +67 -0
  438. package/skills/spec-swarm/template/personas/platform-pm.md +67 -0
  439. package/skills/spec-swarm/template/personas/pmm.md +43 -0
  440. package/skills/spec-swarm/template/personas/principal-product-designer.md +42 -0
  441. package/skills/spec-swarm/template/personas/product-ops-program.md +41 -0
  442. package/skills/spec-swarm/template/personas/qa-release.md +41 -0
  443. package/skills/spec-swarm/template/personas/responsible-ai-safety.md +64 -0
  444. package/skills/spec-swarm/template/personas/security-privacy-legal.md +40 -0
  445. package/skills/spec-swarm/template/personas/strategy-competitive.md +41 -0
  446. package/skills/spec-swarm/template/personas/technical-architect.md +42 -0
  447. package/skills/spec-swarm/template/personas/technical-lead.md +43 -0
  448. package/skills/spec-swarm/template/personas/user-research.md +41 -0
  449. package/skills/spec-swarm-accessibility-inclusion/SKILL.md +95 -0
  450. package/skills/spec-swarm-accessibility-inclusion/agents/openai.yaml +4 -0
  451. package/skills/spec-swarm-ai-technical-pm/SKILL.md +124 -0
  452. package/skills/spec-swarm-analytics-engineering/SKILL.md +94 -0
  453. package/skills/spec-swarm-analytics-engineering/agents/openai.yaml +4 -0
  454. package/skills/spec-swarm-applied-ai-eval/SKILL.md +120 -0
  455. package/skills/spec-swarm-business-gm-pm/SKILL.md +122 -0
  456. package/skills/spec-swarm-consumer-craft-pm/SKILL.md +122 -0
  457. package/skills/spec-swarm-content-design/SKILL.md +95 -0
  458. package/skills/spec-swarm-content-design/agents/openai.yaml +4 -0
  459. package/skills/spec-swarm-cpo/SKILL.md +94 -0
  460. package/skills/spec-swarm-cpo/agents/openai.yaml +4 -0
  461. package/skills/spec-swarm-data-science/SKILL.md +95 -0
  462. package/skills/spec-swarm-data-science/agents/openai.yaml +4 -0
  463. package/skills/spec-swarm-developer-experience/SKILL.md +121 -0
  464. package/skills/spec-swarm-dx-product/SKILL.md +126 -0
  465. package/skills/spec-swarm-field/SKILL.md +96 -0
  466. package/skills/spec-swarm-field/agents/openai.yaml +4 -0
  467. package/skills/spec-swarm-finance-cfo/SKILL.md +95 -0
  468. package/skills/spec-swarm-finance-cfo/agents/openai.yaml +4 -0
  469. package/skills/spec-swarm-growth-pm/SKILL.md +122 -0
  470. package/skills/spec-swarm-platform-pm/SKILL.md +122 -0
  471. package/skills/spec-swarm-pmm/SKILL.md +96 -0
  472. package/skills/spec-swarm-pmm/agents/openai.yaml +4 -0
  473. package/skills/spec-swarm-principal-product-designer/SKILL.md +95 -0
  474. package/skills/spec-swarm-principal-product-designer/agents/openai.yaml +4 -0
  475. package/skills/spec-swarm-product-ops-program/SKILL.md +96 -0
  476. package/skills/spec-swarm-product-ops-program/agents/openai.yaml +4 -0
  477. package/skills/spec-swarm-qa-release/SKILL.md +96 -0
  478. package/skills/spec-swarm-qa-release/agents/openai.yaml +4 -0
  479. package/skills/spec-swarm-responsible-ai-safety/SKILL.md +120 -0
  480. package/skills/spec-swarm-security-privacy-legal/SKILL.md +95 -0
  481. package/skills/spec-swarm-security-privacy-legal/agents/openai.yaml +4 -0
  482. package/skills/spec-swarm-strategy-competitive/SKILL.md +96 -0
  483. package/skills/spec-swarm-strategy-competitive/agents/openai.yaml +4 -0
  484. package/skills/spec-swarm-technical-architect/SKILL.md +95 -0
  485. package/skills/spec-swarm-technical-architect/agents/openai.yaml +4 -0
  486. package/skills/spec-swarm-technical-lead/SKILL.md +96 -0
  487. package/skills/spec-swarm-technical-lead/agents/openai.yaml +4 -0
  488. package/skills/spec-swarm-user-research/SKILL.md +95 -0
  489. package/skills/spec-swarm-user-research/agents/openai.yaml +4 -0
  490. package/skills/strategy-check/SKILL.md +170 -0
  491. package/skills/strategy-check/agents/openai.yaml +4 -0
  492. package/skills/study-digest/SKILL.md +41 -0
  493. package/skills/synthetic-customer/SKILL.md +68 -0
  494. package/skills/update-cli-spec/SKILL.md +69 -0
  495. package/skills/update-cli-spec/agents/openai.yaml +4 -0
  496. package/skills/voc-researcher/SKILL.md +45 -0
  497. package/skills/voc-researcher/agents/openai.yaml +4 -0
  498. package/skills-lock.json +35 -0
@@ -0,0 +1,1651 @@
1
+ // Connector Substrate V0 (nango_connector_substrate_v0): the packet-side
2
+ // evidence ledger. Discovery/access of live MCP servers happen in the agent
3
+ // runtime (productfoo's CLI is pure fs+argv and cannot call mcp__* tools); this module
4
+ // owns everything that persists to the packet regardless of runtime — classify,
5
+ // the fail-closed per-packet gate, approval/revoke provenance, purge, and the
6
+ // coverage-honest receipt. The runtime hands the discovered tool surface in as
7
+ // JSON (the spec's documented CLI-bridge override), so the whole flow is
8
+ // offline-testable against fixtures with no live connector I/O.
9
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { createHash } from "node:crypto";
11
+ import { GateBlock, UsageError } from "./errorTaxonomy.js";
12
+ import path from "node:path";
13
+ import { specIdFromFooSpecTrailer } from "./specLinkedCommits.js";
14
+ import { readJson } from "./jsonFile.js";
15
+ // User-facing one-line glosses for evidence-class tokens (R-006): the raw token
16
+ // must never appear alone on a trust surface.
17
+ export const EVIDENCE_CLASS_GLOSS = {
18
+ system_of_record: "authoritative system of record",
19
+ reference: "supporting reference material",
20
+ communication: "communication / message content",
21
+ support_context: "customer support context",
22
+ decision_context: "decision context — explains why the work changed; it informs and never settles",
23
+ context_only: "background context only",
24
+ approved_source_artifact: "an explicitly selected document, summarized only",
25
+ };
26
+ // The source-category registry (R-002). Adding a category is a registry entry,
27
+ // not a code change. Supabase is the worked app_data example (R-004); Linear is
28
+ // the first managed-connector category (linear-integration-v0 R-001).
29
+ export const SOURCE_CATEGORY_REGISTRY = {
30
+ supabase: {
31
+ source_category: "app_data",
32
+ authority: "authoritative",
33
+ sensitivity: "high",
34
+ evidence_class: "system_of_record",
35
+ model_exposure: "summary_only",
36
+ },
37
+ linear: {
38
+ // Issue/project tracking is supporting reference material, not a system of
39
+ // record for the product's own data — it enters as approved-private-context
40
+ // and must be verified against repo-truth before becoming a requirement
41
+ // (linear-integration-v0 R-003).
42
+ source_category: "issue_tracker",
43
+ authority: "supporting",
44
+ sensitivity: "medium",
45
+ evidence_class: "reference",
46
+ model_exposure: "summary_only",
47
+ },
48
+ github: {
49
+ // A code host is supporting evidence as a whole, so the registry entry stays
50
+ // the conservative `reference` (a real EvidenceClass; github-integration-v0
51
+ // R-001). The split that elevates *typed* facts (PR merge state, check
52
+ // conclusions, commit SHAs/paths) to system_of_record authority is applied at
53
+ // verdict time (R-004), not as a second registry class; free-text (bodies,
54
+ // titles, check-run logs) stays inert reference here.
55
+ source_category: "code_host",
56
+ authority: "supporting",
57
+ sensitivity: "medium",
58
+ evidence_class: "reference",
59
+ model_exposure: "summary_only",
60
+ },
61
+ firecrawl: {
62
+ // Firecrawl is governed web ingress (alpha-dist-repo-v0 R-006): crawl/scrape
63
+ // of PUBLIC web content into LLM-ready form, feeding `foo research` under the
64
+ // B5 read-and-synthesize gates. The open web is supporting reference material
65
+ // — never a system of record — and fetched content stays untrusted: it must
66
+ // never drive a tool call over private or connected-source data, and private
67
+ // or connected content is never sent TO Firecrawl (assertNoPrivateToFirecrawl
68
+ // in research.ts). Sensitivity is low because only public pages and the
69
+ // search terms transit it — by construction, nothing private is in scope.
70
+ source_category: "web_research",
71
+ authority: "supporting",
72
+ sensitivity: "low",
73
+ evidence_class: "reference",
74
+ model_exposure: "summary_only",
75
+ },
76
+ granola: {
77
+ // Meeting transcripts are high-sensitivity communication. Discovery may
78
+ // expose screened paraphrases only after a per-study approval; the source
79
+ // itself is never authoritative product truth.
80
+ source_category: "meeting_transcript",
81
+ authority: "supporting",
82
+ sensitivity: "high",
83
+ evidence_class: "communication",
84
+ model_exposure: "summary_only",
85
+ },
86
+ gong: {
87
+ // Gong call transcripts are the same class as Granola meetings
88
+ // (gong-discovery-connector-v0 T-002): high-sensitivity communication,
89
+ // screened paraphrases only after a per-study approval, never
90
+ // authoritative product truth. Registration is classification, not
91
+ // support — Gong stays contract_only until its live path is proven.
92
+ source_category: "meeting_transcript",
93
+ authority: "supporting",
94
+ sensitivity: "high",
95
+ evidence_class: "communication",
96
+ model_exposure: "summary_only",
97
+ },
98
+ jira: {
99
+ // Jira work-system state is the same class as Linear — supporting issue
100
+ // tracking, never a system of record for the product's own data
101
+ // (jira-source-connector-v0 R-002). Tracker status corroborates packet
102
+ // truth at reconcile time; repo proof still decides (R-006 renders
103
+ // conflicts, it does not let the tracker win — RISK-001). Registration
104
+ // is classification, not support — Jira stays contract_only until its
105
+ // receipt-backed feasibility record exists (R-003).
106
+ source_category: "issue_tracker",
107
+ authority: "supporting",
108
+ sensitivity: "medium",
109
+ evidence_class: "reference",
110
+ model_exposure: "summary_only",
111
+ },
112
+ fireflies: {
113
+ // Fireflies archives cross-platform meetings (Zoom/Meet/Teams) under one
114
+ // keyed account (fireflies-discovery-connector-v0 T-002): the same
115
+ // high-sensitivity communication class as Gong and Granola — screened
116
+ // paraphrases only after a per-scope approval, never authoritative
117
+ // product truth. Registration is classification, not support — Fireflies
118
+ // stays contract_only until the feasibility task proves the API path.
119
+ source_category: "meeting_transcript",
120
+ authority: "supporting",
121
+ sensitivity: "high",
122
+ evidence_class: "communication",
123
+ model_exposure: "summary_only",
124
+ },
125
+ notion: {
126
+ // The first docs source (notion-discovery-connector-v0 R-002/D-001/D-002):
127
+ // this entry DEFINES the docs class that the Google Drive + Docs and
128
+ // Confluence packets later register into. Product intent in documents is
129
+ // supporting evidence — never authoritative product truth — and only
130
+ // explicitly selected pages/databases may be read, exactly once, under
131
+ // the proof boundary, reduced to a PII-minimized summary (the
132
+ // approved_source_artifact model). Documents are not sessions: the docs
133
+ // class never reuses meeting_transcript's classification or lane. A page
134
+ // a workspace can see is not evidence — a page a person selected is.
135
+ source_category: "docs",
136
+ authority: "supporting",
137
+ sensitivity: "high",
138
+ evidence_class: "approved_source_artifact",
139
+ model_exposure: "summary_only",
140
+ },
141
+ zendesk: {
142
+ // Support tickets introduce the `support` class beside the meeting classes
143
+ // (zendesk-discovery-connector-v0 R-002): solved tickets are customers
144
+ // describing pain in their own words — supporting, high-sensitivity,
145
+ // support-context evidence, never reused meeting-transcript
146
+ // classification and never authoritative product truth. Registration is
147
+ // classification, not support — Zendesk stays contract_only until its
148
+ // feasibility record and dogfood run exist.
149
+ source_category: "support",
150
+ authority: "supporting",
151
+ sensitivity: "high",
152
+ evidence_class: "support_context",
153
+ model_exposure: "summary_only",
154
+ },
155
+ gmail: {
156
+ // Email introduces its own category (gmail-discovery-connector-v0 R-002):
157
+ // a mailbox is written customer communication — commitments, objections,
158
+ // and follow-ups — and is never reclassified as a meeting transcript to
159
+ // reuse that lane. Sensitivity is the registry's highest cost: the mailbox
160
+ // is personal even at work, the connection is per-user (D-002), and
161
+ // exposure stays summary_only behind label/thread scoping (R-004).
162
+ // Registration is classification, not support — Gmail stays contract_only
163
+ // until the feasibility report proves the API surface (R-003).
164
+ source_category: "email",
165
+ authority: "supporting",
166
+ sensitivity: "high",
167
+ evidence_class: "communication",
168
+ model_exposure: "summary_only",
169
+ },
170
+ zoom: {
171
+ // Zoom cloud-recording transcripts are the same class as Granola meetings
172
+ // and Gong calls (zoom-discovery-connector-v0 T-002): high-sensitivity
173
+ // communication, screened paraphrases only after a per-study approval,
174
+ // never authoritative product truth. Registration is classification, not
175
+ // support — Zoom stays contract_only until its feasibility report
176
+ // (T-001) and a real company-connected dogfood run exist.
177
+ source_category: "meeting_transcript",
178
+ authority: "supporting",
179
+ sensitivity: "high",
180
+ evidence_class: "communication",
181
+ model_exposure: "summary_only",
182
+ },
183
+ "microsoft-teams": {
184
+ // Teams online-meeting transcripts are the same class as Granola and Gong
185
+ // (microsoft-teams-discovery-connector-v0 T-002): high-sensitivity
186
+ // communication, screened paraphrases only after a per-study approval,
187
+ // never authoritative product truth. Registration is classification, not
188
+ // support — Teams stays contract_only until its feasibility task and
189
+ // dogfood run prove the Graph path.
190
+ source_category: "meeting_transcript",
191
+ authority: "supporting",
192
+ sensitivity: "high",
193
+ evidence_class: "communication",
194
+ model_exposure: "summary_only",
195
+ },
196
+ slack: {
197
+ // Slack introduces the chat class (slack-discovery-connector-v0 R-002) —
198
+ // the strictest defaults of the top ten, because chat carries social
199
+ // noise, ambiguity, and third-party privacy exposure. Chat evidence is
200
+ // supporting DECISION CONTEXT: it explains why the work changed and never
201
+ // settles it (D-001 — summary-only exposure always, even when raw access
202
+ // exists; chat is never an approved_source_artifact). Selection is
203
+ // explicit channels and threads only, and DMs/group DMs are outside V0 by
204
+ // construction, not by policy toggle (D-002). Registration is
205
+ // classification, not support — Slack stays contract_only until its
206
+ // feasibility record (T-001) and company-connected dogfood run (T-007).
207
+ source_category: "chat",
208
+ authority: "supporting",
209
+ sensitivity: "high",
210
+ evidence_class: "decision_context",
211
+ model_exposure: "summary_only",
212
+ },
213
+ outlook: {
214
+ // Outlook registers into the EXISTING email class
215
+ // (outlook-discovery-connector-v0 R-002): identical values to Gmail's
216
+ // entry — the class is shared, not forked, and no Outlook-specific lane is
217
+ // created. Same mailbox sensitivity, same per-user ownership (D-002), same
218
+ // summary_only exposure behind folder/conversation scoping (R-004).
219
+ // Registration is classification, not support — Outlook stays
220
+ // contract_only until its feasibility report proves the Graph surface
221
+ // (R-003).
222
+ source_category: "email",
223
+ authority: "supporting",
224
+ sensitivity: "high",
225
+ evidence_class: "communication",
226
+ model_exposure: "summary_only",
227
+ },
228
+ };
229
+ // Most-restrictive default (R-002): an unrecognized or uncategorized source is
230
+ // context-only, summary-only, read-only, excluded until explicitly approved.
231
+ export const MOST_RESTRICTIVE_DEFAULT = {
232
+ source_category: "unknown",
233
+ authority: "context_only",
234
+ sensitivity: "high",
235
+ evidence_class: "context_only",
236
+ model_exposure: "summary_only",
237
+ registered: false,
238
+ };
239
+ // Fail-closed: any unrecognized provider takes the most-restrictive default.
240
+ export function classifyProvider(provider) {
241
+ const entry = SOURCE_CATEGORY_REGISTRY[provider?.toLowerCase?.() ?? ""];
242
+ if (!entry)
243
+ return { ...MOST_RESTRICTIVE_DEFAULT };
244
+ return { ...entry, registered: true };
245
+ }
246
+ // Notion object ids are 32 hex digits, seen dashed or undashed. Storage and
247
+ // every comparison use the dashed lowercase canonical form.
248
+ const NOTION_UUID = /^[0-9a-f]{32}$/i;
249
+ function canonicalNotionId(raw, label) {
250
+ const hex = raw.trim().replace(/-/g, "");
251
+ if (!NOTION_UUID.test(hex))
252
+ throw new Error(`${label} carries an invalid Notion identifier`);
253
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
254
+ }
255
+ function canonicalIdList(raw, label) {
256
+ if (raw === undefined)
257
+ return undefined;
258
+ if (!Array.isArray(raw) || raw.some((id) => typeof id !== "string"))
259
+ throw new Error(`${label} must be an array of Notion identifiers`);
260
+ const canonical = [...new Set(raw.map((id) => canonicalNotionId(id, label)))];
261
+ return canonical;
262
+ }
263
+ // Parse and canonicalize one Notion approved scope from its stored string form.
264
+ // Throws a bounded, actionable message on every invalid shape — callers who
265
+ // want a validation result use notionScopeValidationError below.
266
+ export function parseNotionScope(raw) {
267
+ const trimmed = (raw ?? "").trim();
268
+ if (!trimmed || trimmed === "*" || /^(?:workspace|all)(?:[-_]wide)?$/i.test(trimmed)) {
269
+ throw new Error("a Notion scope must name explicit pages or databases — workspace-wide access is refused (R-004)");
270
+ }
271
+ let parsed;
272
+ try {
273
+ parsed = JSON.parse(trimmed);
274
+ }
275
+ catch {
276
+ throw new Error("a Notion scope is a JSON object: {page_ids?, database_ids?, window:{from,to}}");
277
+ }
278
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
279
+ throw new Error("a Notion scope is a JSON object: {page_ids?, database_ids?, window:{from,to}}");
280
+ const shape = parsed;
281
+ const SCOPE_KEYS = new Set(["page_ids", "database_ids", "window"]);
282
+ for (const key of Object.keys(shape)) {
283
+ if (!SCOPE_KEYS.has(key))
284
+ throw new Error(`a Notion scope contains unknown field ${key} — workspace-wide or crawler shapes are refused (R-004)`);
285
+ }
286
+ const page_ids = canonicalIdList(shape.page_ids, "page_ids");
287
+ const database_ids = canonicalIdList(shape.database_ids, "database_ids");
288
+ if (!page_ids?.length && !database_ids?.length)
289
+ throw new Error("a Notion scope has no explicit selection: name at least one page or database (R-004)");
290
+ const window = (shape.window ?? {});
291
+ if (typeof window.from !== "string" || typeof window.to !== "string" || !Number.isFinite(Date.parse(window.from)) || !Number.isFinite(Date.parse(window.to)) || Date.parse(window.from) >= Date.parse(window.to)) {
292
+ throw new Error("a Notion scope needs a bounded window {from,to} with from before to");
293
+ }
294
+ return {
295
+ ...(page_ids?.length ? { page_ids } : {}),
296
+ ...(database_ids?.length ? { database_ids } : {}),
297
+ window: { from: new Date(window.from).toISOString(), to: new Date(window.to).toISOString() },
298
+ };
299
+ }
300
+ // Validation-result form of parseNotionScope (null = valid), matching the
301
+ // gongScopeValidationError pattern so pre-flight checks read alike.
302
+ export function notionScopeValidationError(raw) {
303
+ try {
304
+ parseNotionScope(raw);
305
+ return null;
306
+ }
307
+ catch (error) {
308
+ return error instanceof Error ? error.message : "invalid Notion scope";
309
+ }
310
+ }
311
+ // Admission check for ONE content read or metadata listing (R-004/R-005): the
312
+ // selected page or database must be named by THIS stored scope. A stored
313
+ // wildcard admits nothing here, and an unparseable stored scope fails closed.
314
+ export function notionScopeAllowsSelection(scope, selection) {
315
+ if (!scope?.trim())
316
+ return false;
317
+ let approved;
318
+ try {
319
+ approved = parseNotionScope(scope);
320
+ }
321
+ catch {
322
+ return false;
323
+ }
324
+ if (selection.page_id?.trim()) {
325
+ try {
326
+ return (approved.page_ids ?? []).includes(canonicalNotionId(selection.page_id, "page_id"));
327
+ }
328
+ catch {
329
+ return false;
330
+ }
331
+ }
332
+ if (selection.database_id?.trim()) {
333
+ try {
334
+ return (approved.database_ids ?? []).includes(canonicalNotionId(selection.database_id, "database_id"));
335
+ }
336
+ catch {
337
+ return false;
338
+ }
339
+ }
340
+ return false;
341
+ }
342
+ export function sourceKey(id) {
343
+ const base = `${id.provider}::${id.connection_id}`;
344
+ // Per-PR gate keying for GitHub only (github-integration-v0 R-011): GitHub
345
+ // admits per-PR selection, so PR #1 and PR #2 in one repo (same connection_id)
346
+ // must be distinct gate keys, and revoking one must not purge the other. Other
347
+ // providers stay connection-keyed (R-008), so their approve/record flows are
348
+ // unchanged even when a candidate carries an external_id.
349
+ return id.provider === "github" && id.external_id ? `${base}::${id.external_id}` : base;
350
+ }
351
+ export function gongCanonicalSourceIdentity(input) {
352
+ const foo_workspace = input.foo_workspace.trim();
353
+ const canonical_connection_key = input.canonical_connection_key.trim();
354
+ if (!foo_workspace)
355
+ throw new Error("a company-owned Gong connection needs a foo_workspace");
356
+ if (!canonical_connection_key)
357
+ throw new Error("a company-owned Gong connection needs a canonical_connection_key");
358
+ return { foo_workspace, provider: "gong", canonical_connection_key };
359
+ }
360
+ // The substrate identity for the company connection: the workspace scopes the
361
+ // ledger key, so two workspaces sharing one Gong account never share approval
362
+ // state. Approve/revoke keep using the existing per-packet ledger unchanged.
363
+ export function gongWorkspaceSourceId(identity) {
364
+ return { ingress_path: "nango", provider: "gong", connection_id: `${identity.foo_workspace}/${identity.canonical_connection_key}` };
365
+ }
366
+ // The same workspace-scoped identity for the company Zoom connection: two
367
+ // workspaces sharing one Zoom account never share approval state, and the
368
+ // approve/revoke ledger keeps using the substrate SourceId unchanged.
369
+ export function zoomCanonicalSourceIdentity(input) {
370
+ const foo_workspace = input.foo_workspace.trim();
371
+ const canonical_connection_key = input.canonical_connection_key.trim();
372
+ if (!foo_workspace)
373
+ throw new Error("a company-owned Zoom connection needs a foo_workspace");
374
+ if (!canonical_connection_key)
375
+ throw new Error("a company-owned Zoom connection needs a canonical_connection_key");
376
+ return { foo_workspace, provider: "zoom", canonical_connection_key };
377
+ }
378
+ export function zoomWorkspaceSourceId(identity) {
379
+ return { ingress_path: "nango", provider: "zoom", connection_id: `${identity.foo_workspace}/${identity.canonical_connection_key}` };
380
+ }
381
+ // Opaque means not directly identifying: no email addresses, no display names.
382
+ function assertOpaqueRef(value, label) {
383
+ const ref = value.trim();
384
+ if (!ref)
385
+ throw new Error(`${label} is required`);
386
+ if (ref.includes("@"))
387
+ throw new Error(`${label} must be opaque: it must not carry an email address`);
388
+ if (/\s/.test(ref))
389
+ throw new Error(`${label} must be opaque: it must not carry a display name`);
390
+ return ref;
391
+ }
392
+ function companyConnectionReady(input) {
393
+ const connection_ref = assertOpaqueRef(input.connection_ref, "connection_ref");
394
+ const admin_ref = assertOpaqueRef(input.admin_ref, "admin_ref");
395
+ return { event: "discovery_connection_ready", connection_owner: "workspace", admin_ref, connection_ref };
396
+ }
397
+ export function gongConnectionReady(input) {
398
+ return companyConnectionReady(input);
399
+ }
400
+ export function zoomConnectionReady(input) {
401
+ return companyConnectionReady(input);
402
+ }
403
+ // --- Per-user keyed Fireflies connection (fireflies-discovery-connector-v0
404
+ // R-010/D-002) ---------------------------------------------------------------
405
+ // A Fireflies connection belongs to the verified user whose API key it is, per
406
+ // the granola packet rule: a shared team archive is not emulated with one
407
+ // person's credentials. The ready payload therefore carries no admin_ref —
408
+ // there is no accountable administrator over a personal key — and the
409
+ // workspace-archive question stays open (OQ-001), not a V0 promise.
410
+ export function firefliesConnectionReady(input) {
411
+ const connection_ref = assertOpaqueRef(input.connection_ref, "connection_ref");
412
+ return { event: "discovery_connection_ready", connection_owner: "user", connection_ref };
413
+ }
414
+ // Refusal for a member attempting an administrator-only connection lifecycle
415
+ // operation. Disconnect, rotate, and revoke are the accountable
416
+ // administrator's; a member's honest path is to ask them. The provider label
417
+ // comes from the connection's own identity (Gong by default).
418
+ function providerLabel(provider) {
419
+ return provider.charAt(0).toUpperCase() + provider.slice(1);
420
+ }
421
+ export class MemberConnectionRefusal extends Error {
422
+ action;
423
+ actor_ref;
424
+ constructor(action, actor_ref, provider = "Gong") {
425
+ super(`the ${provider} connection is workspace-owned: only its accountable administrator may ${action} it; ask the administrator, then retry`);
426
+ this.action = action;
427
+ this.actor_ref = actor_ref;
428
+ this.name = "MemberConnectionRefusal";
429
+ }
430
+ }
431
+ // Refusal for a personal provider credential (one person's login or API key)
432
+ // presented as the company connection (R-002): productfoo connects the company
433
+ // account only, and says so.
434
+ export class PersonalCredentialRefused extends Error {
435
+ handoff;
436
+ constructor(provider = "Gong") {
437
+ super(`a personal ${provider} credential cannot stand in for the company connection`);
438
+ this.name = "PersonalCredentialRefused";
439
+ this.handoff = `${provider} connects once per workspace: ask your ${provider} administrator to issue the company access key (or install the company app), then connect it through the productfoo-owned connect surface.`;
440
+ }
441
+ }
442
+ export function connectCompanyConnection(input) {
443
+ if (input.presented_credential.issued_for === "personal")
444
+ throw new PersonalCredentialRefused(providerLabel(input.identity.provider));
445
+ const admin_ref = assertOpaqueRef(input.admin_ref, "admin_ref");
446
+ return {
447
+ identity: input.identity,
448
+ connection_ref: assertOpaqueRef(input.connection_ref, "connection_ref"),
449
+ connection_owner: "workspace",
450
+ connection_owner_role: "admin",
451
+ admin_ref,
452
+ connected: true,
453
+ members: [admin_ref],
454
+ session_refs: [],
455
+ grants: [],
456
+ queued_lanes: [],
457
+ unconsumed_receipts: [],
458
+ };
459
+ }
460
+ function assertAdministrator(state, actor_ref, action) {
461
+ if (actor_ref.trim() !== state.admin_ref)
462
+ throw new MemberConnectionRefusal(action, actor_ref, providerLabel(state.identity.provider));
463
+ }
464
+ // R-002: administrator disconnect or revoke is tenant-wide — every member's
465
+ // outstanding session references, grants, queued lanes, and unconsumed
466
+ // receipts die with the connection, because they were all held against it.
467
+ function invalidateTenantWide(state) {
468
+ return { ...state, connected: false, session_refs: [], grants: [], queued_lanes: [], unconsumed_receipts: [] };
469
+ }
470
+ export function disconnectCompanyConnection(state, actor_ref) {
471
+ assertAdministrator(state, actor_ref, "disconnect");
472
+ return invalidateTenantWide(state);
473
+ }
474
+ export function revokeCompanyConnection(state, actor_ref) {
475
+ assertAdministrator(state, actor_ref, "revoke");
476
+ return invalidateTenantWide(state);
477
+ }
478
+ export function rotateCompanyConnection(state, actor_ref, next) {
479
+ assertAdministrator(state, actor_ref, "rotate");
480
+ return { ...state, connection_ref: assertOpaqueRef(next.connection_ref, "connection_ref") };
481
+ }
482
+ // Registering a study or inbox target against the connection is for current
483
+ // workspace members; connection membership never grants call access by itself
484
+ // — every read still needs the packet-local approval and the service grant.
485
+ export function assertCanApproveScope(state, actor_ref) {
486
+ if (!state.members.includes(actor_ref.trim()))
487
+ throw new Error("only a current workspace member may register a target against the company connection; a removed member's approval rights have lapsed");
488
+ }
489
+ // A member who leaves loses their approvals and grants; the connection itself
490
+ // survives because it was never theirs (R-002). Removing the accountable
491
+ // administrator is ownership transfer, which stays deferred (R-002).
492
+ export function removeWorkspaceMember(state, member_ref) {
493
+ const member = member_ref.trim();
494
+ if (member === state.admin_ref)
495
+ throw new Error("the accountable administrator cannot be removed like a member; ownership transfer is out of V0 scope and needs the second administrator's out-of-band confirmation");
496
+ if (!state.members.includes(member))
497
+ throw new Error(`"${member}" is not a member of this workspace connection`);
498
+ const lapses = (items) => items.filter((item) => item.member_ref !== member);
499
+ const lapsed = {
500
+ session_refs: state.session_refs.filter((item) => item.member_ref === member).map((item) => item.session_ref),
501
+ grants: state.grants.filter((item) => item.member_ref === member).map((item) => item.grant_ref),
502
+ queued_lanes: state.queued_lanes.filter((item) => item.member_ref === member).map((item) => item.lane_ref),
503
+ unconsumed_receipts: state.unconsumed_receipts.filter((item) => item.member_ref === member).map((item) => item.receipt_ref),
504
+ };
505
+ return {
506
+ connection: {
507
+ ...state,
508
+ members: state.members.filter((entry) => entry !== member),
509
+ session_refs: lapses(state.session_refs),
510
+ grants: lapses(state.grants),
511
+ queued_lanes: lapses(state.queued_lanes),
512
+ unconsumed_receipts: lapses(state.unconsumed_receipts),
513
+ },
514
+ lapsed,
515
+ };
516
+ }
517
+ export function teamsCanonicalSourceIdentity(input) {
518
+ const foo_workspace = input.foo_workspace.trim();
519
+ const tenant_id = input.tenant_id.trim();
520
+ if (!foo_workspace)
521
+ throw new Error("a tenant-owned Teams connection needs a foo_workspace");
522
+ if (!tenant_id)
523
+ throw new Error("a tenant-owned Teams connection needs a tenant_id");
524
+ return { foo_workspace, provider: "microsoft-teams", tenant_id };
525
+ }
526
+ // The substrate identity for the tenant connection: the workspace scopes the
527
+ // ledger key, so two workspaces sharing one Entra tenant never share approval
528
+ // state. Approve/revoke keep using the existing per-packet ledger unchanged.
529
+ export function teamsTenantSourceId(identity) {
530
+ return { ingress_path: "nango", provider: "microsoft-teams", connection_id: `${identity.foo_workspace}/${identity.tenant_id}` };
531
+ }
532
+ export function teamsConnectionReady(input) {
533
+ const connection_ref = assertOpaqueRef(input.connection_ref, "connection_ref");
534
+ const admin_ref = assertOpaqueRef(input.admin_ref, "admin_ref");
535
+ return { event: "discovery_connection_ready", connection_owner: "workspace", admin_ref, connection_ref };
536
+ }
537
+ // Refusal for a member attempting an administrator-only connection lifecycle
538
+ // operation, ownership transfer included — transfer changes who the
539
+ // accountable administrator IS, so only the current administrator starts it.
540
+ export class TeamsMemberConnectionRefusal extends Error {
541
+ action;
542
+ actor_ref;
543
+ constructor(action, actor_ref) {
544
+ super(`the Teams connection is tenant-owned: only its accountable administrator may ${action} it; ask the administrator, then retry`);
545
+ this.action = action;
546
+ this.actor_ref = actor_ref;
547
+ this.name = "TeamsMemberConnectionRefusal";
548
+ }
549
+ }
550
+ // Refusal for a personal Teams credential (one person's login) presented as
551
+ // the tenant connection (R-002): productfoo connects the organization's consented
552
+ // app only, and says so.
553
+ export class TeamsPersonalCredentialRefused extends Error {
554
+ handoff;
555
+ constructor() {
556
+ super("a personal Teams login cannot stand in for the tenant connection");
557
+ this.name = "TeamsPersonalCredentialRefused";
558
+ this.handoff = "Teams connects once per tenant: ask your Microsoft 365 administrator to consent the productfoo app for the organization, then connect it through the productfoo-owned connect surface.";
559
+ }
560
+ }
561
+ export function connectTeamsTenantConnection(input) {
562
+ if (input.presented_credential.issued_for === "personal")
563
+ throw new TeamsPersonalCredentialRefused();
564
+ const admin_ref = assertOpaqueRef(input.admin_ref, "admin_ref");
565
+ return {
566
+ identity: input.identity,
567
+ connection_ref: assertOpaqueRef(input.connection_ref, "connection_ref"),
568
+ connection_owner: "workspace",
569
+ connection_owner_role: "admin",
570
+ admin_ref,
571
+ connected: true,
572
+ members: [admin_ref],
573
+ session_refs: [],
574
+ grants: [],
575
+ queued_lanes: [],
576
+ unconsumed_receipts: [],
577
+ };
578
+ }
579
+ function assertTeamsAdministrator(state, actor_ref, action) {
580
+ if (actor_ref.trim() !== state.admin_ref)
581
+ throw new TeamsMemberConnectionRefusal(action, actor_ref);
582
+ }
583
+ // R-002: administrator disconnect or revoke is tenant-wide — every member's
584
+ // outstanding session references, grants, queued lanes, and unconsumed
585
+ // receipts die with the connection, because they were all held against it.
586
+ function invalidateTeamsTenantWide(state) {
587
+ return { ...state, connected: false, session_refs: [], grants: [], queued_lanes: [], unconsumed_receipts: [] };
588
+ }
589
+ export function disconnectTeamsTenantConnection(state, actor_ref) {
590
+ assertTeamsAdministrator(state, actor_ref, "disconnect");
591
+ return invalidateTeamsTenantWide(state);
592
+ }
593
+ export function revokeTeamsTenantConnection(state, actor_ref) {
594
+ assertTeamsAdministrator(state, actor_ref, "revoke");
595
+ return invalidateTeamsTenantWide(state);
596
+ }
597
+ export function rotateTeamsTenantConnection(state, actor_ref, next) {
598
+ assertTeamsAdministrator(state, actor_ref, "rotate");
599
+ return { ...state, connection_ref: assertOpaqueRef(next.connection_ref, "connection_ref") };
600
+ }
601
+ // Registering a study or inbox target against the connection is for current
602
+ // workspace members; connection membership never grants call access by itself
603
+ // — every read still needs the packet-local approval and the service grant.
604
+ export function assertCanApproveTeamsScope(state, actor_ref) {
605
+ if (!state.members.includes(actor_ref.trim()))
606
+ throw new Error("only a current workspace member may register a target against the tenant connection; a removed member's approval rights have lapsed");
607
+ }
608
+ // A member who leaves loses their approvals and grants; the connection itself
609
+ // survives because it was never theirs (R-002). Removing the accountable
610
+ // administrator is ownership transfer, which needs its own second-admin path.
611
+ export function removeTeamsWorkspaceMember(state, member_ref) {
612
+ const member = member_ref.trim();
613
+ if (member === state.admin_ref)
614
+ throw new Error("the accountable administrator cannot be removed like a member; ownership transfer needs a second administrator's out-of-band confirmation (transferTeamsTenantConnection)");
615
+ if (!state.members.includes(member))
616
+ throw new Error(`"${member}" is not a member of this tenant connection`);
617
+ const lapses = (items) => items.filter((item) => item.member_ref !== member);
618
+ const lapsed = {
619
+ session_refs: state.session_refs.filter((item) => item.member_ref === member).map((item) => item.session_ref),
620
+ grants: state.grants.filter((item) => item.member_ref === member).map((item) => item.grant_ref),
621
+ queued_lanes: state.queued_lanes.filter((item) => item.member_ref === member).map((item) => item.lane_ref),
622
+ unconsumed_receipts: state.unconsumed_receipts.filter((item) => item.member_ref === member).map((item) => item.receipt_ref),
623
+ };
624
+ return {
625
+ connection: {
626
+ ...state,
627
+ members: state.members.filter((entry) => entry !== member),
628
+ session_refs: lapses(state.session_refs),
629
+ grants: lapses(state.grants),
630
+ queued_lanes: lapses(state.queued_lanes),
631
+ unconsumed_receipts: lapses(state.unconsumed_receipts),
632
+ },
633
+ lapsed,
634
+ };
635
+ }
636
+ // R-002's one delta from the Gong ruling: ownership transfer IS in scope, but
637
+ // it needs a second administrator's out-of-band confirmation. The
638
+ // confirmation is a person other than the outgoing and incoming
639
+ // administrator — one administrator cannot confirm their own transfer, and
640
+ // the incoming administrator cannot either. The connection persists with its
641
+ // members and outstanding state; only the accountable administrator changes.
642
+ export function transferTeamsTenantConnection(state, input) {
643
+ assertTeamsAdministrator(state, input.actor_ref, "transfer");
644
+ const next_admin_ref = assertOpaqueRef(input.next_admin_ref, "next_admin_ref");
645
+ const confirmed_by = assertOpaqueRef(input.second_admin_confirmation.confirmed_by, "confirmed_by");
646
+ if (confirmed_by === input.actor_ref.trim())
647
+ throw new Error("ownership transfer needs a SECOND administrator's confirmation; the outgoing administrator cannot confirm their own transfer");
648
+ if (confirmed_by === next_admin_ref)
649
+ throw new Error("ownership transfer needs a SECOND administrator's confirmation; the incoming administrator cannot confirm their own transfer");
650
+ return {
651
+ ...state,
652
+ admin_ref: next_admin_ref,
653
+ members: state.members.includes(next_admin_ref) ? state.members : [...state.members, next_admin_ref],
654
+ };
655
+ }
656
+ // --- Read-only enforcement (R-007) -----------------------------------------
657
+ export class SourceWriteRefused extends Error {
658
+ constructor(action) {
659
+ super(`read-only V0: write-back to a connected source is refused (attempted: ${action})`);
660
+ this.name = "SourceWriteRefused";
661
+ }
662
+ }
663
+ // Any code path that would mutate a connected source must route through here so
664
+ // the refusal is a single, testable choke point rather than scattered intent.
665
+ export function refuseSourceWrite(action) {
666
+ throw new SourceWriteRefused(action);
667
+ }
668
+ // --- PII minimization for summaries (R-006) --------------------------------
669
+ // Strips the obvious third-party identifiers before a summary is allowed into a
670
+ // local-only ledger. Conservative by design: emails, phones, and long digit
671
+ // runs (account IDs) become typed placeholders.
672
+ export function minimizeSummary(text) {
673
+ // Walk @ delimiters once rather than restarting an email regex at every
674
+ // character of a long non-email token. Preserve the existing match grammar.
675
+ const parts = [];
676
+ let consumed = 0;
677
+ for (let at = text.indexOf("@"); at !== -1; at = text.indexOf("@", at + 1)) {
678
+ let start = at;
679
+ while (start > consumed && /[\w.+-]/.test(text[start - 1]))
680
+ start--;
681
+ let dot = at + 1;
682
+ while (dot < text.length && /[\w-]/.test(text[dot]))
683
+ dot++;
684
+ if (start === at || dot === at + 1 || text[dot] !== ".")
685
+ continue;
686
+ let end = dot + 1;
687
+ while (end < text.length && /[\w.-]/.test(text[end]))
688
+ end++;
689
+ if (end === dot + 1)
690
+ continue;
691
+ parts.push(text.slice(consumed, start), "[email]");
692
+ consumed = end;
693
+ }
694
+ parts.push(text.slice(consumed));
695
+ return parts.join("")
696
+ // Date-shaped runs (T-012 follow-up): an ISO date or timestamp is a date,
697
+ // not contact data — typed [date] token, carved out before the phone
698
+ // window so "2026-09-26" cannot read as a phone number.
699
+ .replace(/\b\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)?\b/g, "[date]")
700
+ .replace(/\+?\d[\d\s().-]{7,}\d/g, "[phone]")
701
+ .replace(/\b\d{6,}\b/g, "[id]");
702
+ }
703
+ const PACKET_CONNECTIONS_SCHEMA = "0.1";
704
+ function packetConnectionsPath(packetDir) {
705
+ return path.join(packetDir, "integration-connections.json");
706
+ }
707
+ function emptyPacketConnections() {
708
+ return { schema_version: PACKET_CONNECTIONS_SCHEMA, kind: "source_connection_state", connections: {} };
709
+ }
710
+ // A connection-state record is a GUARD input: a corrupt or unrecognized file
711
+ // must fail closed — the mutation is refused — rather than silently disabling
712
+ // the ownership guard it would have enforced.
713
+ function readPacketConnections(packetDir) {
714
+ const file = packetConnectionsPath(packetDir);
715
+ if (!existsSync(file))
716
+ return emptyPacketConnections();
717
+ let parsed;
718
+ try {
719
+ parsed = JSON.parse(readFileSync(file, "utf8"));
720
+ }
721
+ catch {
722
+ throw new Error(`packet connection state is not valid JSON (${file}); refusing the guarded mutation rather than bypassing it — re-connect the company connection`);
723
+ }
724
+ const raw = parsed;
725
+ if (!raw || raw.schema_version !== PACKET_CONNECTIONS_SCHEMA || raw.kind !== "source_connection_state" || !raw.connections || typeof raw.connections !== "object") {
726
+ throw new Error(`packet connection state has an unrecognized shape (${file}); refusing the guarded mutation rather than bypassing it — re-connect the company connection`);
727
+ }
728
+ return raw;
729
+ }
730
+ function writePacketConnections(packetDir, file) {
731
+ // 0600: the state pins ownership (admin and member refs) for the workspace's
732
+ // company connections; same mode as the discovery target registry.
733
+ writeFileSync(packetConnectionsPath(packetDir), `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
734
+ }
735
+ // The persisted state must carry the fields the guards read. A malformed
736
+ // record is refused, never trusted and never treated as "no connection".
737
+ function assertConnectionStateShape(state) {
738
+ if (!state || typeof state !== "object" || state.connection_owner !== "workspace" || typeof state.admin_ref !== "string" || !Array.isArray(state.members) || !state.identity?.provider) {
739
+ throw new Error("persisted packet connection state is malformed; refusing the guarded mutation — re-connect the company connection");
740
+ }
741
+ }
742
+ // Load the company/tenant connection state persisted for one substrate source,
743
+ // or null when no connect surface has registered one (the V0 default: the
744
+ // CLI and hosted connect surface do not exist yet, so state is present only
745
+ // where a host seeds it — the seam then enforces; absent state changes
746
+ // nothing, which is the documented current behavior).
747
+ export function loadPacketConnectionState(packetDir, source) {
748
+ const { connections } = readPacketConnections(packetDir);
749
+ const entry = connections[source.provider.trim().toLowerCase()];
750
+ if (!entry || entry.connection_id !== source.connection_id)
751
+ return null;
752
+ assertConnectionStateShape(entry.state);
753
+ return entry.state;
754
+ }
755
+ // Persist connection state created through the existing connect guards
756
+ // (connectCompanyConnection / connectTeamsTenantConnection, which refuse a
757
+ // personal credential at the connect surface itself). The CLI/hosted connect
758
+ // flow that will call this does not exist yet (R-008: the hosted tier stays
759
+ // contract_only); until it does, a host may seed state here directly.
760
+ export function savePacketConnectionState(packetDir, source, state) {
761
+ assertConnectionStateShape(state);
762
+ const file = readPacketConnections(packetDir);
763
+ file.connections[source.provider.trim().toLowerCase()] = { connection_id: source.connection_id, state };
764
+ writePacketConnections(packetDir, file);
765
+ }
766
+ // The seam enforcement itself: when the source being mutated is governed by a
767
+ // persisted company/tenant connection, the ledger mutation runs the existing
768
+ // ownership guard first. Approving (registering a study or inbox target) is
769
+ // for current workspace members; revoking is the accountable administrator's
770
+ // (R-002) and routes through the revocation guard below so the tenant-wide
771
+ // invalidation is persisted with the revoke. Returns the governing state, or
772
+ // null when the source has no company connection (behavior unchanged).
773
+ function connectionStateForMutation(packetDir, id, actor) {
774
+ const state = loadPacketConnectionState(packetDir, id);
775
+ if (!state)
776
+ return null;
777
+ if (isTeamsTenantConnection(state))
778
+ assertCanApproveTeamsScope(state, actor);
779
+ else
780
+ assertCanApproveScope(state, actor);
781
+ return state;
782
+ }
783
+ // Honest narrowing for the two persisted state shapes: TeamsTenantConnection
784
+ // pins identity.provider to the literal "microsoft-teams"; a company
785
+ // connection never does (gong/zoom workspace identities).
786
+ function isTeamsTenantConnection(state) {
787
+ return state.identity.provider === "microsoft-teams";
788
+ }
789
+ // Versioned per-scope ceilings (RISK-003): channel sprawl is the chat class's
790
+ // failure mode, so a scope naming more than the ceiling is invalid — never
791
+ // silently truncated. A listing run that hits a content ceiling stops; it
792
+ // never widens.
793
+ export const SLACK_SCOPE_LIMITS = {
794
+ policy_version: "slack-scope-0.1",
795
+ max_channels_per_scope: 10,
796
+ max_threads_per_scope: 25,
797
+ };
798
+ // Slack channel identifiers: channels — public, and private admitted per the
799
+ // OQ-001 recommendation (explicit ID selection with the approval event as the
800
+ // administrator's attestation) — start with `C`. The DM shape (`D…`) and the
801
+ // group-DM / mpim shape (`G…`) are refused HERE, in the closed shape: there is
802
+ // no user field to name, so a DM could only be smuggled in as a channel ID and
803
+ // the prefix check rejects it (D-002 — refused by construction, not by policy
804
+ // toggle). Identifier shapes are beliefs owned by the feasibility task (T-001).
805
+ const SLACK_DM_LIKE_CHANNEL = /^[DG]/;
806
+ export const SLACK_CHANNEL_ID = /^C[A-Za-z0-9]{8,}$/;
807
+ // Thread bounds are composite `<channel-id>/<ts>` selectors: the binding to
808
+ // the channel that contains the thread is explicit, so a read never has to
809
+ // guess which approved channel to ask. The capture groups are for the runtime
810
+ // splitter; .test() behavior is unchanged. Patterns are shared with the
811
+ // runtime adapter and client so one definition owns the shape.
812
+ export const SLACK_THREAD_SELECTOR = /^(C[A-Za-z0-9]{8,})\/(\d{10}\.\d{6})$/;
813
+ export function slackScopeValidationError(scope) {
814
+ const start = Date.parse(scope.window?.start ?? "");
815
+ const end = Date.parse(scope.window?.end ?? "");
816
+ if (!Number.isFinite(start) || !Number.isFinite(end))
817
+ return "a slack scope needs a bounded time window with a valid start and end; a channel alone without a window is invalid";
818
+ if (start > end)
819
+ return "a slack scope's time window must end after it starts";
820
+ const channels = Array.isArray(scope.channel_ids) ? scope.channel_ids : [];
821
+ if (channels.length === 0)
822
+ return "a slack scope needs explicit channel IDs; an empty channel set is invalid and the workspace as a whole is never in scope";
823
+ if (channels.some((id) => typeof id !== "string" || id !== id.trim() || !id))
824
+ return "a slack scope's channel IDs must be explicit, exact identifiers";
825
+ if (channels.some((id) => id.includes("*")))
826
+ return "a slack scope refuses wildcards: only explicit channel IDs are selectable, never the workspace";
827
+ if (channels.some((id) => SLACK_DM_LIKE_CHANNEL.test(id)))
828
+ return "a slack scope refuses DM and group-DM shapes by construction (D-002): identifiers starting D or G are outside V0";
829
+ if (channels.some((id) => !SLACK_CHANNEL_ID.test(id)))
830
+ return "a slack scope's channel IDs must be Slack channel identifiers (C…); anything else is refused rather than guessed";
831
+ if (channels.length > SLACK_SCOPE_LIMITS.max_channels_per_scope)
832
+ return `a slack scope is limited to ${SLACK_SCOPE_LIMITS.max_channels_per_scope} channels (RISK-003); approve more bounded scopes instead of widening one`;
833
+ // R-004: explicit thread bounds are mandatory — a channel-and-window scope
834
+ // alone can neither list nor read, so a scope without at least one thread
835
+ // selector is invalid rather than a broad channel grant. Bounds are
836
+ // composite `<channel-id>/<ts>` selectors bound to the scope's own channels.
837
+ const threads = Array.isArray(scope.thread_ts) ? scope.thread_ts : [];
838
+ if (threads.length === 0)
839
+ return "a slack scope needs explicit thread bounds (at least one <channel-id>/<ts> selector); a channel-and-window scope alone can neither list nor read";
840
+ const approvedChannels = new Set(channels);
841
+ for (const entry of threads) {
842
+ if (typeof entry !== "string" || entry !== entry.trim() || !SLACK_THREAD_SELECTOR.test(entry))
843
+ return "a slack scope's thread bounds must be explicit <channel-id>/<ts> selectors like C0123456789/1679237238.123456";
844
+ if (!approvedChannels.has(entry.split("/")[0]))
845
+ return "a slack scope's thread bounds must name channels listed in the same scope's channel_ids";
846
+ }
847
+ if (threads.length > SLACK_SCOPE_LIMITS.max_threads_per_scope)
848
+ return `a slack scope is limited to ${SLACK_SCOPE_LIMITS.max_threads_per_scope} threads per approval (RISK-003); approve more bounded scopes instead of widening one`;
849
+ return null;
850
+ }
851
+ function eventsPath(packetDir) {
852
+ return path.join(packetDir, "events.jsonl");
853
+ }
854
+ // Source-derived summaries are local-only and gitignored (B1 / R-006); only
855
+ // payload-free approval/revoke provenance is committed to events.jsonl.
856
+ function integrationLedgerPath(packetDir) {
857
+ return path.join(packetDir, "integration-events.jsonl");
858
+ }
859
+ function readJsonl(file) {
860
+ if (!existsSync(file))
861
+ return [];
862
+ return readFileSync(file, "utf8")
863
+ .split("\n")
864
+ .map((line) => line.trim())
865
+ .filter(Boolean)
866
+ .map((line) => {
867
+ try {
868
+ return JSON.parse(line);
869
+ }
870
+ catch {
871
+ // Fail-closed: an unparseable ledger line is ignored, never trusted.
872
+ return null;
873
+ }
874
+ })
875
+ .filter((v) => v !== null);
876
+ }
877
+ // Resolve the current approval state per source from the committed ledger.
878
+ // Latest approved/revoked event wins; anything else is excluded (fail-closed).
879
+ // Approvals ACCUMULATE scopes (#528): one connection may hold many
880
+ // deliberately-bounded scopes, each granted by its own approval event, so
881
+ // `scopes` collects every scope approved since the last revoke (trimmed,
882
+ // deduped, in ledger order — a missing/null scope contributes ""). `scope`
883
+ // remains the latest event's value for single-approval readers. A revoke
884
+ // clears the accumulation; the event schema is unchanged, so existing
885
+ // ledgers read identically (one approval event => one accumulated scope).
886
+ export function approvalStates(packetDir) {
887
+ const states = new Map();
888
+ for (const event of readJsonl(eventsPath(packetDir))) {
889
+ const type = event.type;
890
+ if (type !== "com.adaptivespec.source.approved" && type !== "com.adaptivespec.source.revoked")
891
+ continue;
892
+ const data = (event.data ?? {});
893
+ const sid = data.source_id;
894
+ if (!sid || !sid.provider || !sid.connection_id)
895
+ continue;
896
+ const key = sourceKey(sid);
897
+ if (type === "com.adaptivespec.source.approved") {
898
+ const prior = states.get(key);
899
+ const scopes = prior?.state === "approved" ? [...prior.scopes] : [];
900
+ const granted = typeof data.scope === "string" ? data.scope.trim() : "";
901
+ if (!scopes.includes(granted))
902
+ scopes.push(granted);
903
+ states.set(key, { state: "approved", scope: data.scope ?? undefined, scopes });
904
+ }
905
+ else {
906
+ states.set(key, { state: "revoked", scopes: [] });
907
+ }
908
+ }
909
+ return states;
910
+ }
911
+ function appendEvent(packetDir, event) {
912
+ const idStem = `${event.type.split(".").pop()}_${sourceKey(event.source_id)}_${Date.now()}`.replace(/[^a-zA-Z0-9_]/g, "_");
913
+ const record = {
914
+ specversion: "0.1",
915
+ id: `evt_source_${idStem}`,
916
+ source: "agent://foo/source",
917
+ type: event.type,
918
+ subject: `source:${event.source_id.provider}/${event.source_id.connection_id}`,
919
+ time: event.at,
920
+ actor: { type: "human", id: event.by, mode: "human-in-loop" },
921
+ // Payload-free provenance only (B1): identity, packet, scope, who — no source content.
922
+ data: event.type === "com.adaptivespec.source.approved"
923
+ ? { source_id: event.source_id, packet: event.packet, scope: event.scope ?? null, approved_by: event.by, at: event.at }
924
+ : { source_id: event.source_id, packet: event.packet, prior_scope: event.prior_scope ?? null, revoked_by: event.by, at: event.at },
925
+ extensions: {},
926
+ };
927
+ appendFileSync(eventsPath(packetDir), `${JSON.stringify(record)}\n`);
928
+ return record;
929
+ }
930
+ export function loadSurface(surfacePath) {
931
+ if (!existsSync(surfacePath))
932
+ throw new Error(`discovered-surface file not found: ${surfacePath}`);
933
+ let parsed;
934
+ try {
935
+ parsed = readJson(surfacePath);
936
+ }
937
+ catch {
938
+ throw new Error(`discovered-surface file is not valid JSON: ${surfacePath}`);
939
+ }
940
+ const list = Array.isArray(parsed) ? parsed : parsed.servers;
941
+ if (!Array.isArray(list))
942
+ throw new Error("discovered-surface must be an array of servers (or {servers: [...]})");
943
+ return list.map((raw) => {
944
+ const s = (raw ?? {});
945
+ if (!s.provider || !s.connection_id)
946
+ throw new Error("each discovered server needs provider and connection_id");
947
+ return { ingress_path: s.ingress_path ?? "mcp", provider: s.provider, connection_id: s.connection_id };
948
+ });
949
+ }
950
+ export function discover(packetDir, servers, at) {
951
+ const states = approvalStates(packetDir);
952
+ // Idempotent + dedupe on {provider, connection_id} (R-008): one entity is one row.
953
+ const seen = new Map();
954
+ for (const server of servers) {
955
+ const ingress = server.ingress_path ?? "mcp";
956
+ let classification;
957
+ try {
958
+ classification = classifyProvider(server.provider);
959
+ }
960
+ catch {
961
+ classification = { ...MOST_RESTRICTIVE_DEFAULT }; // fail-closed on any classify error
962
+ }
963
+ const key = sourceKey(server);
964
+ const approval = states.get(key)?.state ?? "excluded";
965
+ const row = {
966
+ ingress_path: ingress,
967
+ provider: server.provider,
968
+ connection_id: server.connection_id,
969
+ source_category: classification.source_category,
970
+ evidence_class: classification.evidence_class,
971
+ evidence_class_label: EVIDENCE_CLASS_GLOSS[classification.evidence_class],
972
+ sensitivity: classification.sensitivity,
973
+ approval_state: approval,
974
+ registered: classification.registered,
975
+ discovered_at: at,
976
+ };
977
+ seen.set(key, row);
978
+ }
979
+ return [...seen.values()];
980
+ }
981
+ // --- Approve / revoke (R-003, R-005) ---------------------------------------
982
+ // A requested scope is covered by an exact (trimmed) match or by a stored
983
+ // wildcard "*". Deliberately conservative: prefix/alias overlap is NOT treated
984
+ // as covered — the worst case is one extra, harmless accumulating approval
985
+ // event, never a false "already approved" no-op on a consent surface (#528).
986
+ function scopeAlreadyApproved(approvedScopes, requested) {
987
+ const want = requested.trim();
988
+ return approvedScopes.some((granted) => granted === "*" || granted === want);
989
+ }
990
+ export function approve(packetDir, id, opts) {
991
+ // Ownership seam (R-002): when the source is governed by a persisted
992
+ // company/tenant connection, only a current workspace member may register a
993
+ // target against it; a removed member's approval rights have lapsed. No
994
+ // connection state (the V0 default) leaves this path unchanged.
995
+ const governing = connectionStateForMutation(packetDir, id, opts.by);
996
+ // #889: the accountable administrator's revoke is not undone by a member.
997
+ // A revoked connection (connected: false) takes no new approvals until the
998
+ // administrator reconnects it; otherwise the ledger would show the source
999
+ // approved again right after its owner revoked it.
1000
+ if (governing && !governing.connected) {
1001
+ throw new GateBlock(`this connection was revoked by its accountable administrator; it takes no new approvals until the administrator reconnects it (${id.provider} ${id.connection_id})`, "E_GATE_BLOCKED");
1002
+ }
1003
+ const states = approvalStates(packetDir);
1004
+ const existing = states.get(sourceKey(id));
1005
+ // Idempotency is per (source, scope), not per source (#528): re-approving is
1006
+ // a no-op ONLY when the requested scope is already covered by the stored
1007
+ // approval (same scope granted before, or a stored wildcard "*"). Otherwise
1008
+ // the approval is scope-additive — a new event carries the new scope — so one
1009
+ // connection can hold many deliberately-bounded scopes and the CLI never
1010
+ // reports success while writing nothing (R-008 idempotency, narrowed).
1011
+ if (existing?.state === "approved" && scopeAlreadyApproved(existing.scopes, opts.scope)) {
1012
+ return { approved: true, idempotent: true };
1013
+ }
1014
+ appendEvent(packetDir, { type: "com.adaptivespec.source.approved", source_id: id, packet: opts.packet, scope: opts.scope, by: opts.by, at: opts.at });
1015
+ return { approved: true, idempotent: false };
1016
+ }
1017
+ export function revoke(packetDir, id, opts) {
1018
+ // Ownership seam (R-002): revoking a source governed by a company/tenant
1019
+ // connection is the accountable administrator's action — the existing
1020
+ // revocation guard refuses a member (MemberConnectionRefusal) before any
1021
+ // ledger event is written, and the tenant-wide invalidation it returns is
1022
+ // persisted so the connection state cannot claim to outlive its revoke.
1023
+ const connectionState = connectionStateForMutation(packetDir, id, opts.by);
1024
+ if (connectionState) {
1025
+ const invalidated = isTeamsTenantConnection(connectionState) ? revokeTeamsTenantConnection(connectionState, opts.by) : revokeCompanyConnection(connectionState, opts.by);
1026
+ savePacketConnectionState(packetDir, id, invalidated);
1027
+ }
1028
+ const event = appendEvent(packetDir, {
1029
+ type: "com.adaptivespec.source.revoked",
1030
+ source_id: id,
1031
+ packet: opts.packet,
1032
+ prior_scope: opts.priorScope,
1033
+ by: opts.by,
1034
+ at: opts.at,
1035
+ });
1036
+ const purged = purgeSummaries(packetDir, id, opts.at);
1037
+ // The authenticated local revocation event digest (nango-discovery-connector-v0
1038
+ // R-008): binds any discovery_suspend_target fan-out to the exact, content-free
1039
+ // revocation event, so the hosted service can verify the suspension request
1040
+ // against a real local revoke. The immediate local revoke above is already
1041
+ // applied and is never weakened by what callers do with this digest.
1042
+ const event_digest = `sha256:${createHash("sha256").update(JSON.stringify(event)).digest("hex")}`;
1043
+ return { revoked: true, purged, event_digest };
1044
+ }
1045
+ // Tombstone + redaction over the local-only summary ledger, preserving the
1046
+ // append-only line count (R-005): summaries from the revoked source are
1047
+ // rewritten to a payload-free tombstone, never deleted.
1048
+ function purgeSummaries(packetDir, id, at) {
1049
+ const file = integrationLedgerPath(packetDir);
1050
+ if (!existsSync(file))
1051
+ return 0;
1052
+ const key = sourceKey(id);
1053
+ let purged = 0;
1054
+ const rewritten = readJsonl(file).map((entry) => {
1055
+ const sid = entry.source_id;
1056
+ if (sid && sourceKey(sid) === key && entry.tombstoned !== true) {
1057
+ purged += 1;
1058
+ return { source_id: sid, tombstoned: true, revoked_at: at };
1059
+ }
1060
+ return entry;
1061
+ });
1062
+ writeFileSync(file, rewritten.map((e) => JSON.stringify(e)).join("\n") + (rewritten.length ? "\n" : ""));
1063
+ return purged;
1064
+ }
1065
+ // The opaque, stable per-grant ledger key: team-scoped so two teams' grants
1066
+ // never share gate state, and per-grant so a re-connect after revoke is a NEW
1067
+ // source to every packet ledger — stale approvals cannot ride a fresh
1068
+ // credential (the structural half of C-018).
1069
+ export function browserConnectionRef(input) {
1070
+ return assertOpaqueRef(`${assertOpaqueRef(input.team_ref, "team_ref")}/${assertOpaqueRef(input.connection_ref, "connection_ref")}`, "browser connection ref");
1071
+ }
1072
+ export function browserConnectionSourceId(conn) {
1073
+ return { ingress_path: "browser", provider: conn.provider.toLowerCase(), connection_id: browserConnectionRef(conn) };
1074
+ }
1075
+ // The handshake's ONLY emission: payload-free readiness provenance reusing
1076
+ // the nango contract's closed shape. Team connects are workspace-owned; the
1077
+ // accountable ref is the account that completed the OAuth consent. This is a
1078
+ // fact about identity, not content — it never implies approval (G-4).
1079
+ export function browserConnectionReady(conn, admin_ref) {
1080
+ return companyConnectionReady({ connection_ref: browserConnectionRef(conn), admin_ref });
1081
+ }
1082
+ // The gate any browser-path ingress must pass before touching packet content.
1083
+ // Read-only over the ledger — approve()/revoke() remain the only ledger
1084
+ // writers, so no decision path can mint its own permission. Three refusals:
1085
+ // - liveness: a revoked connection refuses even where a stale `approved` row
1086
+ // lingers in some packet's ledger (C-018);
1087
+ // - approval: this packet's own events.jsonl must carry an explicit approved
1088
+ // event for this source key — connecting alone leaves the source `excluded`
1089
+ // (C-017; G-4: connection ≠ approval);
1090
+ // - classification is always computed and reported so callers surface honest
1091
+ // provenance; unregistered providers carry the most-restrictive default.
1092
+ export function browserIngressDecision(packetDir, conn) {
1093
+ const classification = classifyProvider(conn.provider);
1094
+ const id = browserConnectionSourceId(conn);
1095
+ const approval_state = approvalStates(packetDir).get(sourceKey(id))?.state ?? "excluded";
1096
+ if (conn.revoked_at) {
1097
+ return {
1098
+ allowed: false,
1099
+ stage: "liveness",
1100
+ reason: "connection revoked — ingress from this source is stopped (C-018)",
1101
+ classification,
1102
+ approval_state,
1103
+ };
1104
+ }
1105
+ if (approval_state !== "approved") {
1106
+ return {
1107
+ allowed: false,
1108
+ stage: "approval",
1109
+ reason: `source not approved in this packet (state: ${approval_state}) — connecting is not approval (G-4)`,
1110
+ classification,
1111
+ approval_state,
1112
+ };
1113
+ }
1114
+ return { allowed: true, classification, approval_state: "approved" };
1115
+ }
1116
+ // Write a PII-minimized summary for an approved selection into the local-only
1117
+ // ledger. Refuses if the source is not currently approved (fail-closed gate,
1118
+ // R-003). No raw payload ever lands in a committed file (B1).
1119
+ export function recordSummary(packetDir, id, opts) {
1120
+ const state = approvalStates(packetDir).get(sourceKey(id))?.state ?? "excluded";
1121
+ if (state !== "approved")
1122
+ return { recorded: false, reason: `source not approved (state: ${state})` };
1123
+ mkdirSync(path.dirname(integrationLedgerPath(packetDir)), { recursive: true });
1124
+ const entry = {
1125
+ source_id: id,
1126
+ selection: opts.selection,
1127
+ summary: minimizeSummary(opts.summary),
1128
+ evidence_class: classifyProvider(id.provider).evidence_class,
1129
+ at: opts.at,
1130
+ tombstoned: false,
1131
+ };
1132
+ appendFileSync(integrationLedgerPath(packetDir), `${JSON.stringify(entry)}\n`);
1133
+ return { recorded: true };
1134
+ }
1135
+ export function recordClaim(packetDir, id, opts) {
1136
+ const approval = approvalStates(packetDir).get(sourceKey(id));
1137
+ const state = approval?.state ?? "excluded";
1138
+ if (state !== "approved")
1139
+ return { recorded: false, reason: `source not approved (state: ${state})` };
1140
+ // Approvals accumulate per scope (#528): the selection is admitted when ANY
1141
+ // approved scope covers it. An empty stored scope or "*" is unrestricted —
1142
+ // unchanged single-approval semantics.
1143
+ const scopes = approval?.scopes ?? [];
1144
+ const unrestricted = scopes.length === 0 || scopes.some((s) => !s || s === "*");
1145
+ if (!unrestricted && !scopes.some((s) => selectionWithinScope(opts.selection, s))) {
1146
+ const listed = scopes.map((s) => `"${s}"`).join(", ");
1147
+ return { recorded: false, reason: `selection "${opts.selection}" is outside the approved scope${scopes.length === 1 ? "" : "s"} ${listed} — unapproved tables do not surface` };
1148
+ }
1149
+ mkdirSync(path.dirname(integrationLedgerPath(packetDir)), { recursive: true });
1150
+ const entry = {
1151
+ kind: "claim",
1152
+ source_id: id,
1153
+ selection: opts.selection,
1154
+ claim: minimizeSummary(opts.claim),
1155
+ cites_task: opts.cites_task,
1156
+ direction: opts.direction,
1157
+ evidence_class: classifyProvider(id.provider).evidence_class,
1158
+ at: opts.at,
1159
+ tombstoned: false,
1160
+ };
1161
+ appendFileSync(integrationLedgerPath(packetDir), `${JSON.stringify(entry)}\n`);
1162
+ // Committed provenance is ids-only: which source, which selection, which
1163
+ // task, which way — never the claim text (B1/R-008 keeps source-derived
1164
+ // content in the local-only ledger). Same actor shape as the conflict event:
1165
+ // productfoo's deterministic CLI, not an LLM.
1166
+ const record = {
1167
+ specversion: "0.1",
1168
+ id: `evt_source_claim_${sourceKey(id)}_${Date.now()}`.replace(/[^a-zA-Z0-9_]/g, "_"),
1169
+ source: "agent://foo/source",
1170
+ type: "com.adaptivespec.source.claim",
1171
+ subject: `source:${id.provider}/${id.connection_id}`,
1172
+ time: opts.at,
1173
+ actor: { type: "agent", id: "foo-cli", provider: "foo", model: "deterministic", persona: "tool", skills: [], adapter: "cli", mode: "human-in-loop" },
1174
+ data: { source_id: id, selection: opts.selection, cites_task: opts.cites_task, direction: opts.direction, evidence_class: entry.evidence_class },
1175
+ extensions: {},
1176
+ };
1177
+ appendFileSync(eventsPath(packetDir), `${JSON.stringify(record)}\n`);
1178
+ return { recorded: true };
1179
+ }
1180
+ // Scope containment for V0 selections (schema.table strings): the selection is
1181
+ // in scope when it equals the scope or descends from it (scope "app_data"
1182
+ // admits "app_data.orders"; it never admits "analytics.events").
1183
+ function selectionWithinScope(selection, scope) {
1184
+ return selection === scope || selection.startsWith(`${scope}.`);
1185
+ }
1186
+ // Generic payload-free conflict-event writer the GitHub verdict engine reuses
1187
+ // (github-integration-v0 R-005/C-006): a contradicted done_when criterion records
1188
+ // a `com.adaptivespec.conflict.detected` event carrying only ids/kinds the agent
1189
+ // derived — never raw source payload (B1). The actor is productfoo's deterministic CLI,
1190
+ // not an LLM, so its identity carries no model id.
1191
+ export function appendSourceConflictEvent(packetDir, opts, at) {
1192
+ const record = {
1193
+ specversion: "0.1",
1194
+ id: `evt_source_conflict_${opts.source_id.provider}_${sourceKey(opts.source_id)}_${Date.now()}`.replace(/[^a-zA-Z0-9_]/g, "_"),
1195
+ source: "agent://foo/source",
1196
+ type: "com.adaptivespec.conflict.detected",
1197
+ subject: `source:${opts.source_id.provider}/${opts.source_id.connection_id}`,
1198
+ time: at,
1199
+ actor: { type: "agent", id: "foo-cli", provider: "foo", model: "deterministic", persona: "tool", skills: [], adapter: "cli", mode: "human-in-loop" },
1200
+ data: { source_id: opts.source_id, conflicts: opts.conflicts, note: opts.note },
1201
+ extensions: {},
1202
+ };
1203
+ appendFileSync(eventsPath(packetDir), `${JSON.stringify(record)}\n`);
1204
+ }
1205
+ // Append a record to the packet's LOCAL-ONLY, gitignored integration ledger
1206
+ // (`integration-events.jsonl`, B1 / R-008). Source-derived receipts and summaries
1207
+ // live here, never in committed packet files; only payload-free provenance is
1208
+ // committed to events.jsonl.
1209
+ export function appendIntegrationRecord(packetDir, record) {
1210
+ mkdirSync(path.dirname(integrationLedgerPath(packetDir)), { recursive: true });
1211
+ appendFileSync(integrationLedgerPath(packetDir), `${JSON.stringify(record)}\n`);
1212
+ }
1213
+ export function buildReceipt(packetDir, servers, at) {
1214
+ const discovered = discover(packetDir, servers, at);
1215
+ const states = approvalStates(packetDir);
1216
+ const summaries = readJsonl(integrationLedgerPath(packetDir));
1217
+ const selected_and_read = summaries
1218
+ .filter((e) => e.tombstoned !== true)
1219
+ .map((e) => {
1220
+ const sid = e.source_id;
1221
+ return {
1222
+ source_id: sid,
1223
+ selection: String(e.selection ?? ""),
1224
+ evidence_class_label: EVIDENCE_CLASS_GLOSS[e.evidence_class ?? "context_only"],
1225
+ };
1226
+ });
1227
+ const claims = summaries
1228
+ .filter((e) => e.kind === "claim" && e.tombstoned !== true)
1229
+ .map((e) => ({
1230
+ source_id: e.source_id,
1231
+ selection: String(e.selection ?? ""),
1232
+ cites_task: String(e.cites_task ?? ""),
1233
+ direction: e.direction ?? "supports",
1234
+ evidence_class_label: EVIDENCE_CLASS_GLOSS[e.evidence_class ?? "context_only"],
1235
+ }));
1236
+ const excluded_after_evaluation = discovered
1237
+ .filter((row) => (states.get(sourceKey(row))?.state ?? "excluded") !== "approved")
1238
+ .map((row) => ({
1239
+ source_id: { ingress_path: row.ingress_path, provider: row.provider, connection_id: row.connection_id },
1240
+ reason: states.get(sourceKey(row))?.state === "revoked" ? "revoked" : "not approved for this packet",
1241
+ }));
1242
+ const revoked = [...states.entries()]
1243
+ .filter(([, v]) => v.state === "revoked")
1244
+ .map(([key]) => {
1245
+ // Keys may carry a third `external_id` segment for per-PR providers (R-011);
1246
+ // keep it so a revoked GitHub PR reconstructs to the right source.
1247
+ const [provider, connection_id, ...rest] = key.split("::");
1248
+ const external_id = rest.length ? rest.join("::") : undefined;
1249
+ return { ingress_path: "mcp", provider, connection_id, ...(external_id ? { external_id } : {}) };
1250
+ });
1251
+ return {
1252
+ schema_version: "0.1",
1253
+ packet: path.basename(packetDir),
1254
+ generated_at: at,
1255
+ discovered,
1256
+ selected_and_read,
1257
+ claims,
1258
+ // Coverage honesty: the receipt reports only what was selected-and-read and
1259
+ // never implies the full source was read.
1260
+ out_of_selection_scope: "Only explicitly selected items were read; the rest of each source was not accessed.",
1261
+ excluded_after_evaluation,
1262
+ summarized: selected_and_read.length,
1263
+ revoked,
1264
+ coverage_note: "Read-only V0. This receipt covers selected reads only and does not claim full-source coverage.",
1265
+ };
1266
+ }
1267
+ // --- Linear issue/project → seed mapping (linear_integration_v0 R-002/R-004) -
1268
+ // V0 admits Linear evidence at per-issue and per-project granularity only.
1269
+ // Team/label/cycle bulk-pull is a non-goal (a far larger blast radius for one
1270
+ // approval); reaching for it is refused, not silently widened.
1271
+ const LINEAR_ADMITTED_UNITS = new Set(["issue", "project"]);
1272
+ // Parse a `linear:<url>` selector (or a bare linear.app URL) into a selection.
1273
+ // Rejects any unit outside per-issue / per-project (R-004).
1274
+ export function parseLinearSource(spec) {
1275
+ const raw = spec.startsWith("linear:") ? spec.slice("linear:".length) : spec;
1276
+ let url;
1277
+ try {
1278
+ url = new URL(raw);
1279
+ }
1280
+ catch {
1281
+ throw new Error(`invalid Linear URL: ${raw}`);
1282
+ }
1283
+ if (url.hostname !== "linear.app" && !url.hostname.endsWith(".linear.app")) {
1284
+ throw new Error(`not a linear.app URL: ${raw}`);
1285
+ }
1286
+ const parts = url.pathname.split("/").filter(Boolean);
1287
+ const [workspace, unit, externalId] = parts;
1288
+ if (!workspace || !unit || !externalId)
1289
+ throw new Error(`unrecognized Linear URL shape: ${raw}`);
1290
+ if (!LINEAR_ADMITTED_UNITS.has(unit)) {
1291
+ // The caller asked for something productfoo does not admit. A bad invocation, not a defect —
1292
+ // it prints guidance and must never page anyone.
1293
+ throw new UsageError(`Linear selection unit '${unit}' is out of scope for V0 — only per-issue and per-project are admitted (linear-integration-v0 R-004)`, "E_BAD_FLAG");
1294
+ }
1295
+ return { unit: unit, connection_id: workspace, external_id: externalId, url: raw };
1296
+ }
1297
+ export function buildLinearCandidate(selection, payload) {
1298
+ return {
1299
+ source_id: {
1300
+ ingress_path: "mcp",
1301
+ provider: "linear",
1302
+ connection_id: selection.connection_id,
1303
+ external_id: selection.external_id,
1304
+ },
1305
+ evidence_authority: "approved-private-context",
1306
+ title: minimizeSummary(payload.title ?? ""),
1307
+ intent: minimizeSummary(payload.description ?? payload.title ?? ""),
1308
+ url: selection.url,
1309
+ state: payload.state ?? "unknown",
1310
+ };
1311
+ }
1312
+ // Read-only Linear MCP tools the ingest path may call (linear_integration_v0
1313
+ // R-005). This is a DEFAULT-DENY allow-list, not a denylist: a tool not listed
1314
+ // here is refused, so any mutating or outbound Linear tool — including ones not
1315
+ // yet invented — is structurally unreachable from a context holding Linear
1316
+ // issue/comment content, rather than merely discouraged by a prompt.
1317
+ export const LINEAR_READONLY_TOOLS = new Set([
1318
+ "list_issues",
1319
+ "get_issue",
1320
+ "list_projects",
1321
+ "get_project",
1322
+ "list_comments",
1323
+ "get_comment",
1324
+ "list_issue_labels",
1325
+ "search_issues",
1326
+ ]);
1327
+ // The single choke point the Linear-ingest path must route every tool call
1328
+ // through. Refuses anything off the read-only allow-list via the substrate's
1329
+ // read-only guard (R-007), so the refusal is one testable place.
1330
+ export function assertLinearReadOnlyTool(toolName) {
1331
+ const bare = toolName.split("__").pop() ?? toolName;
1332
+ if (!LINEAR_READONLY_TOOLS.has(bare)) {
1333
+ refuseSourceWrite(`linear tool '${toolName}' is not on the read-only allow-list`);
1334
+ }
1335
+ }
1336
+ // Standard Jira Cloud issue key: `<PROJECT KEY>-<number>`. The project key
1337
+ // starts with a letter; the closed raw shape carries no separate project
1338
+ // field, and scope containment (R-004) needs one — it is derived here or the
1339
+ // issue is refused, never guessed from summary text.
1340
+ const JIRA_ISSUE_KEY = /^[A-Z][A-Z0-9]*-\d+$/;
1341
+ export function jiraIssueProject(key) {
1342
+ const m = key.trim().match(JIRA_ISSUE_KEY);
1343
+ if (!m)
1344
+ throw new Error(`not a standard Jira issue key (expected <PROJECT>-<number>): ${key}`);
1345
+ return key.trim().split("-")[0];
1346
+ }
1347
+ // Accepts `acme`, `acme.atlassian.net`, or a site URL; returns the site host.
1348
+ // Jira Cloud only in V0 — Data Center/Server hosts are refused, not adapted.
1349
+ export function jiraSiteHost(site) {
1350
+ const host = site
1351
+ .trim()
1352
+ .replace(/^https?:\/\//, "")
1353
+ .replace(/\/.*$/, "")
1354
+ .toLowerCase();
1355
+ if (!host.endsWith(".atlassian.net")) {
1356
+ throw new Error(`not a Jira Cloud site (expected <site>.atlassian.net): ${site}`);
1357
+ }
1358
+ return host;
1359
+ }
1360
+ // Validates one raw issue into the closed shape. Unknown top-level fields are
1361
+ // REFUSED, not ignored: a new provider field (say, a description body) would
1362
+ // otherwise silently ride past the parse layer into downstream consumers.
1363
+ export function parseJiraSource(raw) {
1364
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1365
+ throw new Error(`jira issue must be a JSON object, got ${raw === null ? "null" : typeof raw}`);
1366
+ }
1367
+ const obj = raw;
1368
+ const allowed = new Set(["key", "summary", "status", "assignee_display", "updated", "remote_links"]);
1369
+ for (const field of Object.keys(obj)) {
1370
+ if (!allowed.has(field)) {
1371
+ throw new Error(`jira issue shape is closed — unknown field '${field}' is refused; only the parse layer reads raw payloads`);
1372
+ }
1373
+ }
1374
+ const str = (v, label) => {
1375
+ if (typeof v !== "string" || !v.trim())
1376
+ throw new Error(`jira issue ${label} is required`);
1377
+ return v.trim();
1378
+ };
1379
+ const key = str(obj.key, "key");
1380
+ jiraIssueProject(key); // refused above if the key is not the standard form
1381
+ const summary = str(obj.summary, "summary");
1382
+ const status = str(obj.status, "status");
1383
+ const updated = str(obj.updated, "updated");
1384
+ if (Number.isNaN(Date.parse(updated)))
1385
+ throw new Error(`jira issue updated is not an ISO timestamp: ${updated}`);
1386
+ let assignee_display;
1387
+ if (obj.assignee_display !== undefined && obj.assignee_display !== null) {
1388
+ assignee_display = str(obj.assignee_display, "assignee_display");
1389
+ }
1390
+ let remote_links;
1391
+ if (obj.remote_links !== undefined && obj.remote_links !== null) {
1392
+ if (!Array.isArray(obj.remote_links))
1393
+ throw new Error("jira issue remote_links must be an array");
1394
+ remote_links = obj.remote_links.map((link) => {
1395
+ if (!link || typeof link !== "object" || Array.isArray(link))
1396
+ throw new Error("jira remote link must be an object");
1397
+ const l = link;
1398
+ for (const field of Object.keys(l)) {
1399
+ if (field !== "url" && field !== "title")
1400
+ throw new Error(`jira remote link shape is closed — unknown field '${field}'`);
1401
+ }
1402
+ const out = {};
1403
+ if (l.url !== undefined && l.url !== null) {
1404
+ const url = str(l.url, "remote link url");
1405
+ if (!/^https:\/\//.test(url))
1406
+ throw new Error(`jira remote link url must be https: ${url}`);
1407
+ out.url = url;
1408
+ }
1409
+ if (l.title !== undefined && l.title !== null)
1410
+ out.title = str(l.title, "remote link title");
1411
+ return out;
1412
+ });
1413
+ }
1414
+ return { key, summary, status, ...(assignee_display ? { assignee_display } : {}), updated, ...(remote_links ? { remote_links } : {}) };
1415
+ }
1416
+ // A bounded window: an explicit ISO date range or a rolling `Nd` window. An
1417
+ // open-ended window is the site-wide blast radius R-004 forbids.
1418
+ const JIRA_WINDOW = /^(?:\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}|\d{1,4}d)$/;
1419
+ // The approver reads the JQL in the scope (RISK-003); the cap keeps it readable.
1420
+ const JIRA_JQL_MAX = 512;
1421
+ const JIRA_PROJECTS_MAX = 25;
1422
+ export function parseJiraScope(raw) {
1423
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1424
+ throw new UsageError(`jira scope must be an object {projects, jql?, window}`, "E_BAD_FLAG");
1425
+ }
1426
+ const obj = raw;
1427
+ for (const field of Object.keys(obj)) {
1428
+ if (field !== "projects" && field !== "jql" && field !== "window") {
1429
+ throw new UsageError(`jira scope shape is closed — unknown field '${field}'`, "E_BAD_FLAG");
1430
+ }
1431
+ }
1432
+ let projects = [];
1433
+ if (obj.projects !== undefined && obj.projects !== null) {
1434
+ if (!Array.isArray(obj.projects))
1435
+ throw new UsageError(`jira scope projects must be an array of project keys`, "E_BAD_FLAG");
1436
+ projects = [...new Set(obj.projects.map((p) => {
1437
+ if (typeof p !== "string" || !p.trim())
1438
+ throw new UsageError(`jira scope projects must be non-empty project keys`, "E_BAD_FLAG");
1439
+ const key = p.trim();
1440
+ if (key === "*")
1441
+ throw new UsageError(`jira scope refuses '*' — a site-wide listing is not approvable (jira-source-connector-v0 R-004); name explicit projects`, "E_BAD_FLAG");
1442
+ return key;
1443
+ }))];
1444
+ }
1445
+ let jql;
1446
+ if (obj.jql !== undefined && obj.jql !== null) {
1447
+ if (typeof obj.jql !== "string" || !obj.jql.trim())
1448
+ throw new UsageError(`jira scope jql must be a non-empty bounded string the approver can read`, "E_BAD_FLAG");
1449
+ jql = obj.jql.trim();
1450
+ if (jql.length > JIRA_JQL_MAX)
1451
+ throw new UsageError(`jira scope jql exceeds ${JIRA_JQL_MAX} chars — the approver must be able to read it (RISK-003)`, "E_BAD_FLAG");
1452
+ }
1453
+ if (projects.length === 0 && !jql) {
1454
+ throw new UsageError(`jira scope is empty — name explicit projects or a bounded jql; a selector-less (site-wide) listing is refused (jira-source-connector-v0 R-004)`, "E_BAD_FLAG");
1455
+ }
1456
+ if (projects.length > JIRA_PROJECTS_MAX)
1457
+ throw new UsageError(`jira scope exceeds ${JIRA_PROJECTS_MAX} projects — narrow the selection (R-004)`, "E_BAD_FLAG");
1458
+ const window = typeof obj.window === "string" ? obj.window.trim() : "";
1459
+ if (!window || !JIRA_WINDOW.test(window)) {
1460
+ throw new UsageError(`jira scope needs a bounded window: 'YYYY-MM-DD..YYYY-MM-DD' or 'Nd' rolling days`, "E_BAD_FLAG");
1461
+ }
1462
+ return { projects, ...(jql ? { jql } : {}), window };
1463
+ }
1464
+ // Canonical ledger string for an approved jira scope: stable, approver-readable
1465
+ // JSON. Approval accumulation is EXACT-match on this string (the substrate never
1466
+ // does fuzzy scope coverage), so the form is pinned here and nowhere else.
1467
+ export function jiraScopeString(scope) {
1468
+ return JSON.stringify({ projects: scope.projects, ...(scope.jql ? { jql: scope.jql } : {}), window: scope.window });
1469
+ }
1470
+ // Read a stored/declared scope string back into the closed shape. An
1471
+ // unparseable scope is fail-closed: it refuses rather than admits anything.
1472
+ export function parseJiraScopeString(scope) {
1473
+ let parsed;
1474
+ try {
1475
+ parsed = JSON.parse(scope);
1476
+ }
1477
+ catch {
1478
+ throw new UsageError(`jira scope is not the canonical shape ({projects, jql?, window} JSON): ${scope}`, "E_BAD_FLAG");
1479
+ }
1480
+ return parseJiraScope(parsed);
1481
+ }
1482
+ // A validated issue falls inside an approved scope: its derived project is in
1483
+ // the scope's explicit projects, or the scope approved the (bounded) JQL the
1484
+ // listing ran through. For a scope with projects, the project check is the
1485
+ // whole rule — the JQL (if any) only narrowed that same selection.
1486
+ export function jiraScopeCoversIssue(scope, issue) {
1487
+ if (scope.projects.length === 0)
1488
+ return true; // jql-only scope: the read IS the approved JQL
1489
+ return scope.projects.includes(jiraIssueProject(issue.key));
1490
+ }
1491
+ export function buildJiraCandidate(input) {
1492
+ const site = jiraSiteHost(input.site);
1493
+ return {
1494
+ source_id: {
1495
+ ingress_path: "nango",
1496
+ provider: "jira",
1497
+ connection_id: site,
1498
+ external_id: input.issue.key,
1499
+ },
1500
+ evidence_authority: "approved-private-context",
1501
+ title: minimizeSummary(input.issue.summary),
1502
+ intent: minimizeSummary(input.issue.summary),
1503
+ url: `https://${site}/browse/${input.issue.key}`,
1504
+ status: input.issue.status,
1505
+ updated: input.issue.updated,
1506
+ };
1507
+ }
1508
+ // Read-only Jira tools the ingest path may call (jira-source-connector-v0
1509
+ // T-004, beside LINEAR_READONLY_TOOLS). DEFAULT-DENY like Linear's: a tool not
1510
+ // listed is refused, so every mutating Jira operation — transitions, comments,
1511
+ // link edits, issue creation, anything added later — is structurally
1512
+ // unreachable (R-005).
1513
+ export const JIRA_READONLY_TOOLS = new Set([
1514
+ "search_issues", // the only listing entry — bounded JQL/project reads (R-004)
1515
+ "get_issue",
1516
+ "list_projects",
1517
+ "get_project",
1518
+ "list_issue_comments",
1519
+ "get_issue_comment",
1520
+ "list_issue_remote_links",
1521
+ "list_fields", // issue-field metadata: status/assignee/updated (R-003)
1522
+ ]);
1523
+ // The single choke point the Jira-ingest path must route every tool call
1524
+ // through; refuses via the substrate's read-only guard (R-005).
1525
+ export function assertJiraReadOnlyTool(toolName) {
1526
+ const bare = toolName.split("__").pop() ?? toolName;
1527
+ if (!JIRA_READONLY_TOOLS.has(bare)) {
1528
+ refuseSourceWrite(`jira tool '${toolName}' is not on the read-only allow-list`);
1529
+ }
1530
+ }
1531
+ // Parse a `github:<url>` selector (or a bare github.com URL) into a selection,
1532
+ // mapping it to the substrate source identity (R-011): connection_id="<owner>/<repo>",
1533
+ // external_id="pr/<n>"|"repo". A `.../pull/<n>` URL selects per-PR; a bare repo
1534
+ // URL selects per-repo. Org/user pages and any other unit (issues, tree, ...) are
1535
+ // refused so one approval cannot silently widen its blast radius (R-003).
1536
+ export function parseGithubSource(spec) {
1537
+ const raw = spec.startsWith("github:") ? spec.slice("github:".length) : spec;
1538
+ let url;
1539
+ try {
1540
+ url = new URL(raw);
1541
+ }
1542
+ catch {
1543
+ throw new Error(`invalid GitHub URL: ${raw}`);
1544
+ }
1545
+ if (url.hostname !== "github.com" && !url.hostname.endsWith(".github.com")) {
1546
+ throw new Error(`not a github.com URL: ${raw}`);
1547
+ }
1548
+ const parts = url.pathname.split("/").filter(Boolean);
1549
+ const [owner, repo, kind, number] = parts;
1550
+ if (!owner || !repo) {
1551
+ throw new Error(`GitHub selection must name a repo or a PR — org/user-level selection is out of scope for V0 (github-integration-v0 R-003): ${raw}`);
1552
+ }
1553
+ const connection_id = `${owner}/${repo}`;
1554
+ if (kind === undefined) {
1555
+ return { unit: "repo", connection_id, external_id: "repo", owner, repo, url: raw };
1556
+ }
1557
+ if (kind === "pull" && number && /^\d+$/.test(number)) {
1558
+ return { unit: "pr", connection_id, external_id: `pr/${number}`, owner, repo, pr: Number(number), url: raw };
1559
+ }
1560
+ throw new Error(`unrecognized GitHub URL shape — admit a repo URL or a .../pull/<n> URL; team/issue/other units are out of scope (github-integration-v0 R-003): ${raw}`);
1561
+ }
1562
+ // The substrate source identity for a GitHub selection (R-011). Ingress is mcp
1563
+ // (MCP-first, R-002); the repo is the connection and the PR/repo is the
1564
+ // external_id, so the gate (sourceKey) distinguishes two PRs in one repo.
1565
+ export function githubSourceId(selection) {
1566
+ return {
1567
+ ingress_path: "mcp",
1568
+ provider: "github",
1569
+ connection_id: selection.connection_id,
1570
+ external_id: selection.external_id,
1571
+ };
1572
+ }
1573
+ export function githubLinkageForSpec(opts) {
1574
+ const linked = opts.commitMessages.some((m) => specIdFromFooSpecTrailer(m) === opts.specId);
1575
+ const corroborating = [];
1576
+ if (opts.branch && opts.branch.includes(opts.specId))
1577
+ corroborating.push(`branch:${opts.branch}`);
1578
+ if (opts.prBody && opts.prBody.includes(opts.specId))
1579
+ corroborating.push("pr-body");
1580
+ return { linked, authority: linked ? "foo-spec-trailer" : "none", corroborating };
1581
+ }
1582
+ // --- GitHub read-only allow-list (github-integration-v0 R-007/R-012) ---------
1583
+ // Read-only GitHub MCP tools the reconcile path may call. Unlike the small Linear
1584
+ // allow-list, GitHub's ~90-tool surface hides side-effecting tools behind
1585
+ // read-looking verbs, so this list is matched on the FULLY-QUALIFIED
1586
+ // `mcp__github__<tool>` name (not a bare verb) and is DEFAULT-DENY: anything not
1587
+ // listed — every create_/update_/merge_/delete_/add_*_comment write, every
1588
+ // `actions_*` CI trigger, `request_copilot_review`, `*_auto_merge`,
1589
+ // `run_secret_scanning`, AND every cross-account `search_*` (a scope break, R-003)
1590
+ // — is refused, including tools the MCP server adds later (drift guard).
1591
+ export const GITHUB_READONLY_TOOLS = new Set([
1592
+ "mcp__github__pull_request_read",
1593
+ "mcp__github__get_commit",
1594
+ "mcp__github__list_commits",
1595
+ "mcp__github__get_check_run",
1596
+ "mcp__github__get_job_logs",
1597
+ "mcp__github__get_file_contents",
1598
+ "mcp__github__list_pull_requests",
1599
+ ]);
1600
+ // The single choke point the GitHub-reconcile path routes every tool call
1601
+ // through. Fully-qualified, default-deny; refuses via the substrate guard (R-007).
1602
+ export function assertGithubReadOnlyTool(toolName) {
1603
+ if (!GITHUB_READONLY_TOOLS.has(toolName)) {
1604
+ refuseSourceWrite(`github tool '${toolName}' is not on the read-only allow-list`);
1605
+ }
1606
+ }
1607
+ function prLinkage(specId, ev) {
1608
+ return githubLinkageForSpec({
1609
+ specId,
1610
+ commitMessages: (ev.commits ?? []).map((c) => c.message ?? ""),
1611
+ branch: ev.branch,
1612
+ prBody: ev.body,
1613
+ });
1614
+ }
1615
+ // Admit a GitHub selection (T-002): per-PR or per-repo only (the parser refuses
1616
+ // org/multi-repo, R-003). A repo selection exposes only packet-linked PRs
1617
+ // (D-009/R-003) — unlinked PRs are excluded, never discovered via search. This
1618
+ // stops at admission + linkage; the done_when verdict is T-003.
1619
+ export function githubSelectionReport(opts) {
1620
+ const linked = [];
1621
+ let unlinked_excluded = 0;
1622
+ if (opts.fixture) {
1623
+ if (opts.selection.unit === "pr") {
1624
+ const lk = prLinkage(opts.specId, opts.fixture);
1625
+ if (lk.linked)
1626
+ linked.push({ external_id: opts.selection.external_id, authority: lk.authority, corroborating: lk.corroborating });
1627
+ else
1628
+ unlinked_excluded += 1;
1629
+ }
1630
+ else {
1631
+ for (const pr of opts.fixture.pulls ?? []) {
1632
+ const ext = pr.external_id ?? (pr.number != null ? `pr/${pr.number}` : "pr/?");
1633
+ const lk = prLinkage(opts.specId, pr);
1634
+ if (lk.linked)
1635
+ linked.push({ external_id: ext, authority: lk.authority, corroborating: lk.corroborating });
1636
+ else
1637
+ unlinked_excluded += 1;
1638
+ }
1639
+ }
1640
+ }
1641
+ return {
1642
+ schema_version: "0.1",
1643
+ packet: opts.packet,
1644
+ source_id: githubSourceId(opts.selection),
1645
+ unit: opts.selection.unit,
1646
+ approval_state: opts.approvalState,
1647
+ linked,
1648
+ unlinked_excluded,
1649
+ note: "Selection admitted; only packet-linked PRs are exposed (Foo-Spec trailer, D-009). The done_when verdict is github-integration-v0 T-003 (next slice).",
1650
+ };
1651
+ }