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,2148 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { flagValue, hasFlag } from "./cliArgs.js";
4
+ import { loadDiscoveryRun, locateStudy, transitionDiscoveryRun, validateDiscoveryPath } from "./discoveryArtifacts.js";
5
+ import { validateDiscoveryEvidence } from "./discoveryEvidence.js";
6
+ import { matchDiscoverySessions, sourceFingerprint } from "./discoveryMatching.js";
7
+ import { assertOpenedDiscoveryProof, authorizeDiscoverySource, consumeDiscoveryProof, markDiscoveryProofOpened, purgeDiscoveryLocalFingerprint, purgeDiscoveryLocalState, purgeDiscoveryRetention } from "./discoveryProof.js";
8
+ import { applyDiscoveryEvidence, applyInboxDiscoveryEvidence, applyReceiptVerifiedDiscoveryBundle } from "./discoveryReconcile.js";
9
+ import { buildLearningDestinationCatalog, clusterUnmatchedLearnings, routeLearning, validateHypothesisSynthesis, } from "./discoveryLearningRouting.js";
10
+ import { buildSignalBugIntake, defaultGhExec, newBugLogSession, runBugLog } from "./bugOwnerLog.js";
11
+ import { assertNormalizedSessionShape, contactHintsFromDisplayLabel, dedupeContactHints, isValidLinkedinUrl } from "./discoverySources.js";
12
+ import { revoke as revokeSource } from "./sourceIngress.js";
13
+ import { fanOutDiscoveryTargetSuspension } from "./discoveryConnections.js";
14
+ import { planDiscoverySync } from "./discoverySync.js";
15
+ import { parse, stringify } from "./vendorYaml.js";
16
+ export const DISCOVERY_COMMAND_SCHEMA_VERSION = "0.1";
17
+ function result(status, options = {}) {
18
+ return {
19
+ schema_version: "0.1",
20
+ status,
21
+ writes: options.writes ?? [],
22
+ errors: options.errors ?? [],
23
+ handoff: options.handoff ?? null,
24
+ next_actions: options.next_actions ?? [],
25
+ coverage: options.coverage ?? {},
26
+ ...(options.study_id ? { study_id: options.study_id } : {}),
27
+ };
28
+ }
29
+ function exitCodeFor(status) {
30
+ return ["ambiguous_match", "needs_source_approval", "needs_provider_access", "needs_provider_connector", "needs_research_proof", "needs_service_verification", "invalid", "budget_exhausted"].includes(status) ? 1 : 0;
31
+ }
32
+ function humanOutput(value) {
33
+ const lines = [`Discovery: ${value.status.replaceAll("_", " ")}.`];
34
+ if (value.handoff)
35
+ lines.push(value.handoff);
36
+ for (const action of value.next_actions)
37
+ lines.push(`- ${action}`);
38
+ return `${lines.join("\n")}\n`;
39
+ }
40
+ function outcome(value, json) {
41
+ return { result: value, exitCode: exitCodeFor(value.status), output: json ? `${JSON.stringify(value, null, 2)}\n` : humanOutput(value) };
42
+ }
43
+ function invalid(message, json, code = "E_DISCOVERY_VALIDATION", studyId) {
44
+ return outcome(result("invalid", { study_id: studyId, errors: [{ code, message: "Discovery input or state is invalid; no private source content was accepted." }], handoff: message }), json);
45
+ }
46
+ export function study(root, studyId) {
47
+ const found = locateStudy(root, studyId);
48
+ if (!found)
49
+ throw new Error(`Discovery study not found: ${studyId}`);
50
+ return found;
51
+ }
52
+ export function isInboxTarget(targetId) {
53
+ return /^inbox:[a-z0-9_]+$/.test(targetId);
54
+ }
55
+ export function inboxDir(root) {
56
+ return path.join(root, "discovery", "inbox");
57
+ }
58
+ // The inbox's derived artifacts are content-free scaffolds: the study path gets
59
+ // theirs created with the study, but nothing created the inbox's, so a clean
60
+ // install that authored scope.yaml alone hit a swallowed ENOENT at apply — a
61
+ // fail-closed message that never named the missing file. scope.yaml is the
62
+ // consent boundary and is operator-authored; this only creates the three
63
+ // derived files, and only when absent. (R-006: discovery runs in a clean
64
+ // public install, and a missing input is named, not swallowed.)
65
+ export function ensureInboxScaffold(root) {
66
+ const dir = inboxDir(root);
67
+ mkdirSync(dir, { recursive: true });
68
+ const scaffold = [
69
+ ["findings.yaml", 'schema_version: "0.1"\nfindings: []\n'],
70
+ ["proposals.yaml", 'schema_version: "0.1"\nproposals: []\n'],
71
+ ["events.jsonl", ""],
72
+ ];
73
+ for (const [name, content] of scaffold) {
74
+ const file = path.join(dir, name);
75
+ if (!existsSync(file))
76
+ writeFileSync(file, content);
77
+ }
78
+ }
79
+ export function discoveryTargetDir(root, targetId) {
80
+ return isInboxTarget(targetId) ? inboxDir(root) : study(root, targetId);
81
+ }
82
+ export function localTargetName(targetId) {
83
+ return targetId.replace(":", "_");
84
+ }
85
+ export function inboxScope(root, targetId) {
86
+ if (!isInboxTarget(targetId))
87
+ throw new Error("invalid inbox target");
88
+ const scopeId = targetId.slice("inbox:".length);
89
+ const doc = parse(readFileSync(path.join(inboxDir(root), "scope.yaml"), "utf8"));
90
+ const scope = (doc.scopes ?? []).find((candidate) => candidate.scope_id === scopeId);
91
+ if (!scope)
92
+ throw new Error("inbox scope not found");
93
+ return scope;
94
+ }
95
+ export function gongScopeValidationError(scope) {
96
+ const start = Date.parse(scope.window?.start ?? "");
97
+ const end = Date.parse(scope.window?.end ?? "");
98
+ if (!Number.isFinite(start) || !Number.isFinite(end))
99
+ return "a gong scope needs a bounded time window with a valid start and end";
100
+ if (start > end)
101
+ return "a gong scope's time window must end after it starts";
102
+ const selectorKinds = [
103
+ ["named external participants", scope.named_external_participants],
104
+ ["external companies or email domains", scope.external_companies_or_domains],
105
+ ["title tokens", scope.title_tokens],
106
+ ["call owners", scope.call_owners],
107
+ ];
108
+ const hasSelector = selectorKinds.some(([, list]) => Array.isArray(list) && list.some((entry) => typeof entry === "string" && entry.trim().length > 0));
109
+ if (!hasSelector) {
110
+ return "a gong scope needs at least one customer selector (a named external participant, an external company or email domain, a title token, or a call owner); a time window alone is never enough, and a Gong workspace narrows the read but never substitutes for a customer selector";
111
+ }
112
+ return null;
113
+ }
114
+ export function zendeskScopeValidationError(scope) {
115
+ const start = Date.parse(scope.window?.start ?? "");
116
+ const end = Date.parse(scope.window?.end ?? "");
117
+ if (!Number.isFinite(start) || !Number.isFinite(end))
118
+ return "a zendesk scope needs a bounded time window with a valid start and end";
119
+ if (start > end)
120
+ return "a zendesk scope's time window must end after it starts";
121
+ const selectorKinds = [
122
+ ["external requester domains", scope.external_domains],
123
+ ["ticket organizations", scope.organizations],
124
+ ["brands", scope.brands],
125
+ ];
126
+ const hasSelector = selectorKinds.some(([, list]) => Array.isArray(list) && list.some((entry) => typeof entry === "string" && entry.trim().length > 0));
127
+ if (!hasSelector) {
128
+ return "a zendesk scope needs at least one customer selector (an external requester domain, a ticket organization, or a brand); a time window alone is never enough, and a Zendesk subdomain narrows the read but never substitutes for a customer selector";
129
+ }
130
+ // Internal and agent-only tickets are excluded by default (R-004); a scope
131
+ // asking to include them is refused in V0 rather than silently honored.
132
+ if (scope.include_internal_only === true) {
133
+ return "a zendesk scope cannot include internal or agent-only tickets; they are excluded by default and the exclusion is not configurable in V0";
134
+ }
135
+ return null;
136
+ }
137
+ export function gmailScopeValidationError(scope) {
138
+ const start = Date.parse(scope.window?.start ?? "");
139
+ const end = Date.parse(scope.window?.end ?? "");
140
+ if (!Number.isFinite(start) || !Number.isFinite(end))
141
+ return "a gmail scope needs a bounded time window with a valid start and end";
142
+ if (start > end)
143
+ return "a gmail scope's time window must end after it starts";
144
+ const labels = (scope.labels ?? []).filter((label) => typeof label === "string" && label.trim().length > 0);
145
+ if (labels.length === 0)
146
+ return "a gmail scope needs at least one explicit Gmail label; the mailbox itself is never a scope (R-004)";
147
+ const selectorKinds = [
148
+ ["external sender domains", scope.external_domains],
149
+ ["named external participants", scope.external_names],
150
+ ];
151
+ const hasSelector = selectorKinds.some(([, list]) => Array.isArray(list) && list.some((entry) => typeof entry === "string" && entry.trim().length > 0));
152
+ if (!hasSelector) {
153
+ return "a gmail scope needs at least one customer selector (an external sender domain or a named external participant); a label alone is never enough, and reads stay gated on it";
154
+ }
155
+ if (scope.include_internal_only === true)
156
+ return "a gmail scope cannot include the connector's own internal mailbox; the internal mailbox is excluded by default (R-004)";
157
+ return null;
158
+ }
159
+ export function zoomScopeValidationError(scope) {
160
+ const start = Date.parse(scope.window?.start ?? "");
161
+ const end = Date.parse(scope.window?.end ?? "");
162
+ if (!Number.isFinite(start) || !Number.isFinite(end))
163
+ return "a zoom scope needs a bounded time window with a valid start and end";
164
+ if (start > end)
165
+ return "a zoom scope's time window must end after it starts";
166
+ const selectorKinds = [
167
+ ["named external participants", scope.external_names],
168
+ ["external companies or email domains", scope.external_domains],
169
+ ["title tokens", scope.title_tokens],
170
+ ];
171
+ const hasSelector = selectorKinds.some(([, list]) => Array.isArray(list) && list.some((entry) => typeof entry === "string" && entry.trim().length > 0));
172
+ if (!hasSelector) {
173
+ return "a zoom scope needs at least one customer selector (a named external participant, an external company or email domain, or a title token); a time window alone is never enough";
174
+ }
175
+ if (scope.include_internal_only) {
176
+ return "including internal-only recordings needs a separate approval V0 does not grant; internal-only recordings stay excluded by default";
177
+ }
178
+ return null;
179
+ }
180
+ export function teamsScopeValidationError(scope) {
181
+ const start = Date.parse(scope.window?.start ?? "");
182
+ const end = Date.parse(scope.window?.end ?? "");
183
+ if (!Number.isFinite(start) || !Number.isFinite(end))
184
+ return "a Teams scope needs a bounded time window with a valid start and end";
185
+ if (start > end)
186
+ return "a Teams scope's time window must end after it starts";
187
+ const selectorKinds = [
188
+ ["named external participants", scope.named_external_participants],
189
+ ["external companies or email domains", scope.external_companies_or_domains],
190
+ ["organizers", scope.organizers],
191
+ ];
192
+ const hasSelector = selectorKinds.some(([, list]) => Array.isArray(list) && list.some((entry) => typeof entry === "string" && entry.trim().length > 0));
193
+ if (!hasSelector) {
194
+ return "a Teams scope needs at least one customer selector (a named external participant, an external company or email domain, or an organizer); a time window alone is never enough, and the tenant narrows the read but never substitutes for a customer selector";
195
+ }
196
+ return null;
197
+ }
198
+ export function outlookScopeValidationError(scope) {
199
+ const start = Date.parse(scope.window?.start ?? "");
200
+ const end = Date.parse(scope.window?.end ?? "");
201
+ if (!Number.isFinite(start) || !Number.isFinite(end))
202
+ return "an outlook scope needs a bounded time window with a valid start and end";
203
+ if (start > end)
204
+ return "an outlook scope's time window must end after it starts";
205
+ const folders = (scope.folders ?? []).filter((folder) => typeof folder === "string" && folder.trim().length > 0);
206
+ if (folders.length === 0)
207
+ return "an outlook scope needs at least one explicit Outlook folder; the mailbox itself is never a scope (R-004)";
208
+ const selectorKinds = [
209
+ ["external sender domains", scope.external_domains],
210
+ ["named external participants", scope.external_names],
211
+ ];
212
+ const hasSelector = selectorKinds.some(([, list]) => Array.isArray(list) && list.some((entry) => typeof entry === "string" && entry.trim().length > 0));
213
+ if (!hasSelector) {
214
+ return "an outlook scope needs at least one customer selector (an external sender domain or a named external participant); a folder alone is never enough, and reads stay gated on it";
215
+ }
216
+ if (scope.include_internal_only === true)
217
+ return "an outlook scope cannot include the connector's own internal mailbox; the internal mailbox is excluded by default (R-004)";
218
+ return null;
219
+ }
220
+ function normalized(value) {
221
+ return value.trim().toLowerCase().replace(/\s+/g, " ");
222
+ }
223
+ // Plain Levenshtein over short labels. Scopes are written from memory of a
224
+ // name while providers render canonical display names ("Exampletn" vs
225
+ // "Exampleton"), so a one-edit variant is the common case, not the edge case.
226
+ function editDistance(a, b) {
227
+ const dist = Array.from({ length: a.length + 1 }, (_, i) => {
228
+ const row = new Array(b.length + 1).fill(0);
229
+ row[0] = i;
230
+ return row;
231
+ });
232
+ for (let j = 0; j <= b.length; j++)
233
+ dist[0][j] = j;
234
+ for (let i = 1; i <= a.length; i++) {
235
+ for (let j = 1; j <= b.length; j++) {
236
+ dist[i][j] = Math.min(dist[i - 1][j] + 1, dist[i][j - 1] + 1, dist[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
237
+ }
238
+ }
239
+ return dist[a.length][b.length];
240
+ }
241
+ // The verdict names what missed instead of collapsing to a boolean: the
242
+ // operator submitted the session metadata and approved the scope, so echoing
243
+ // their own labels back leaks nothing while turning "outside the approved
244
+ // scope" from a source-dive into a one-line fix (#524). A one-edit participant
245
+ // variant is a near-miss — flagged for the human, never auto-opened.
246
+ function inboxSessionMatchVerdict(scope, session) {
247
+ if (scope.state !== "approved")
248
+ return { status: "none", misses: ["the inbox scope is not approved"] };
249
+ if (session.completion_state !== "completed")
250
+ return { status: "none", misses: ["the session is not completed"] };
251
+ if (normalized(session.provider) !== normalized(scope.provider) || normalized(session.connection_alias) !== normalized(scope.connection_alias)) {
252
+ return { status: "none", misses: [`provider/connection: the session is ${session.provider}/${session.connection_alias}, the approved scope is ${scope.provider}/${scope.connection_alias}`] };
253
+ }
254
+ const completedAt = Date.parse(session.completed_at);
255
+ const start = Date.parse(String(scope.time_window?.start ?? ""));
256
+ const end = Date.parse(String(scope.time_window?.end ?? ""));
257
+ if (![completedAt, start, end].every(Number.isFinite) || completedAt < start || completedAt > end) {
258
+ return { status: "none", misses: [`time_window: completed_at ${session.completed_at} is outside the approved window ${scope.time_window?.start ?? "?"}..${scope.time_window?.end ?? "?"}`] };
259
+ }
260
+ const participantAllowlist = (scope.participant_allowlist ?? []).map(normalized);
261
+ const domainAllowlist = (scope.domain_allowlist ?? []).map((domain) => normalized(domain).replace(/^@/, ""));
262
+ const titleTokens = (scope.title_tokens ?? []).map(normalized);
263
+ const participants = session.participants.map(normalized);
264
+ const participantMatch = participantAllowlist.some((allowed) => participants.includes(allowed));
265
+ const domainMatch = domainAllowlist.some((domain) => participants.some((participant) => participant.endsWith(`@${domain}`)));
266
+ const title = normalized(session.title);
267
+ const titleMatch = titleTokens.length > 0 && titleTokens.every((token) => title.includes(token));
268
+ if (participantMatch || domainMatch || titleMatch)
269
+ return { status: "match" };
270
+ for (const [allowedIndex, allowed] of participantAllowlist.entries()) {
271
+ for (const [participantIndex, participant] of participants.entries()) {
272
+ if (editDistance(allowed, participant) <= 1) {
273
+ return {
274
+ status: "near_miss",
275
+ session_participant: session.participants[participantIndex],
276
+ approved_label: (scope.participant_allowlist ?? [])[allowedIndex],
277
+ };
278
+ }
279
+ }
280
+ }
281
+ const misses = [];
282
+ if (participantAllowlist.length > 0)
283
+ misses.push(`participant_allowlist: none of the session's ${participants.length} participant label(s) matched the ${participantAllowlist.length} approved label(s)`);
284
+ if (domainAllowlist.length > 0)
285
+ misses.push(`domain_allowlist: no session participant is at an approved domain`);
286
+ if (titleTokens.length > 0)
287
+ misses.push(`title_tokens: the session title does not contain every approved token`);
288
+ if (misses.length === 0)
289
+ misses.push("the scope configures no participant, domain, or title selector, so no session can match");
290
+ return { status: "none", misses };
291
+ }
292
+ export function localRoot(root) {
293
+ return path.join(root, ".foo", "discovery");
294
+ }
295
+ function relative(root, file, action = "updated") {
296
+ return { path: path.relative(root, file).split(path.sep).join("/"), action };
297
+ }
298
+ function transitionIfNeeded(studyDir, next, now) {
299
+ const current = loadDiscoveryRun(studyDir).state;
300
+ if (current === next)
301
+ return [];
302
+ return transitionDiscoveryRun(studyDir, next, { type: "agent", id: "foo-discovery" }, now);
303
+ }
304
+ function failureReason(error) {
305
+ return error instanceof Error ? error.message : "unknown validation failure";
306
+ }
307
+ function planCommand(root, args, json) {
308
+ if (hasFlag(args, "--inbox")) {
309
+ ensureInboxScaffold(root);
310
+ const validation = validateDiscoveryPath(root);
311
+ if (!validation.inbox)
312
+ return invalid("The discovery inbox is missing.", json);
313
+ if (!validation.ok)
314
+ return invalid(`Discovery artifacts are invalid; fix these before planning provider access — ${validation.errors.join("; ")}`, json);
315
+ const planned = planDiscoverySync(root, new Date().toISOString());
316
+ const activeScopes = planned.lanes.filter((lane) => lane.kind === "inbox").length;
317
+ return outcome(result(activeScopes ? "ok" : "clean_no_op", {
318
+ next_actions: activeScopes ? ["List only sessions inside each approved inbox scope."] : ["No approved unassigned-call scope with matching source approval is active."],
319
+ coverage: { lane: "unassigned_call_evidence", active_scopes: activeScopes },
320
+ }), json);
321
+ }
322
+ const studyId = flagValue(args, "--study");
323
+ if (!studyId)
324
+ return invalid("Use --study <id> or --inbox.", json);
325
+ let run;
326
+ try {
327
+ run = loadDiscoveryRun(study(root, studyId));
328
+ }
329
+ catch {
330
+ return invalid(`Study ${studyId} was not found.`, json, "E_DISCOVERY_VALIDATION", studyId);
331
+ }
332
+ const actions = [];
333
+ if (run.state === "awaiting_interview")
334
+ actions.push("Complete or schedule the discovery interview; no connector read is allowed yet.");
335
+ else if (["awaiting_match", "ambiguous_match", "needs_source_approval", "needs_provider_access", "needs_provider_connector", "needs_research_proof"].includes(run.state))
336
+ actions.push(`List completed ${run.source.provider} sessions, normalize metadata in the provider adapter, then run foo discovery match ${studyId} --sessions-stdin.`);
337
+ else if (run.state === "ready_to_synthesize")
338
+ actions.push("Open only the proof-authorized selected transcript in runtime memory, synthesize paraphrases, then run foo discovery apply with the sanitized envelope on stdin.");
339
+ else if (run.state === "needs_review")
340
+ actions.push("Review findings, dissent, hypothesis outcomes, mappings, and proposals before completing the study.");
341
+ return outcome(result(run.state === "awaiting_match" ? "awaiting_match" : run.state === "needs_review" ? "needs_review" : "ok", { study_id: studyId, next_actions: actions, coverage: { lifecycle: run.state, connector_read: false } }), json);
342
+ }
343
+ function matchCommand(root, args, stdin, json, now) {
344
+ const studyId = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
345
+ if (!studyId || !hasFlag(args, "--sessions-stdin"))
346
+ return invalid("Usage: foo discovery match <study-id> --sessions-stdin", json, "E_DISCOVERY_VALIDATION", studyId);
347
+ let sessions;
348
+ try {
349
+ sessions = JSON.parse(stdin || "[]");
350
+ }
351
+ catch {
352
+ return invalid("Session metadata stdin must be JSON.", json, "E_DISCOVERY_VALIDATION", studyId);
353
+ }
354
+ if (!Array.isArray(sessions))
355
+ return invalid("Session metadata stdin must be an array.", json, "E_DISCOVERY_VALIDATION", studyId);
356
+ try {
357
+ sessions.forEach(assertNormalizedSessionShape);
358
+ }
359
+ catch (error) {
360
+ return invalid(`Session metadata must use the closed normalized shape; transcript fields are refused — ${failureReason(error)}.`, json, "E_DISCOVERY_INVALID_ENVELOPE", studyId);
361
+ }
362
+ if (isInboxTarget(studyId)) {
363
+ ensureInboxScaffold(root);
364
+ let scope;
365
+ try {
366
+ scope = inboxScope(root, studyId);
367
+ }
368
+ catch {
369
+ return invalid(`Inbox scope ${studyId} was not found.`, json, "E_DISCOVERY_VALIDATION", studyId);
370
+ }
371
+ if (scope.state !== "approved")
372
+ return invalid(`Inbox scope ${studyId} is not approved.`, json, "E_DISCOVERY_INVALID_TRANSITION", studyId);
373
+ if (sessions.length !== 1)
374
+ return invalid("Inbox matching accepts exactly one normalized completed session at a time.", json, "E_DISCOVERY_VALIDATION", studyId);
375
+ const session = sessions[0];
376
+ const verdict = inboxSessionMatchVerdict(scope, session);
377
+ if (verdict.status === "near_miss") {
378
+ return outcome(result("ambiguous_match", {
379
+ study_id: studyId,
380
+ errors: [{ code: "E_DISCOVERY_AMBIGUOUS_MATCH", message: `Participant "${verdict.session_participant}" is one edit from the approved label "${verdict.approved_label}"; nothing was opened or recorded.` }],
381
+ handoff: `If "${verdict.session_participant}" is the approved person, add that spelling to the scope's participant_allowlist (or correct the label) and re-run match.`,
382
+ coverage: { candidates: 0, transcript_opened: false, lane: "unassigned_call_evidence" },
383
+ }), json);
384
+ }
385
+ if (verdict.status === "none") {
386
+ return outcome(result("awaiting_match", {
387
+ study_id: studyId,
388
+ next_actions: [
389
+ "The completed session is outside the approved inbox scope; it was not opened or recorded.",
390
+ ...verdict.misses.map((miss) => `Selector missed — ${miss}`),
391
+ ],
392
+ coverage: { candidates: 0, transcript_opened: false },
393
+ }), json);
394
+ }
395
+ const matchDir = path.join(localRoot(root), localTargetName(studyId));
396
+ mkdirSync(matchDir, { recursive: true });
397
+ writeFileSync(path.join(matchDir, "match.json"), `${JSON.stringify({ schema_version: "0.1", candidates: [{ candidate_index: 1, source_fingerprint: sourceFingerprint(session), session }] }, null, 2)}\n`, { mode: 0o600 });
398
+ // Contact capture at ingest (discovery-contact-capture-v0 R-003/D-004):
399
+ // the matched session's own metadata seeds the scope's contact-card
400
+ // candidates. Scope approval is the consent boundary this metadata already
401
+ // passed; the transcript prerequisites below gate the transcript, not the
402
+ // metadata. Raw emails never survive the derivation (R-004).
403
+ const captured = recordContactCandidates(root, scope.scope_id, sessionContactCandidates(session), now);
404
+ const authorized = authorizeDiscoverySource({ study_dir: inboxDir(root), local_root: localRoot(root), study_id: studyId, study_state: "awaiting_match", match_status: "unique", session, now });
405
+ if (authorized.status === "ready_to_synthesize") {
406
+ return outcome(result("ok", { study_id: studyId, writes: captured.writes, next_actions: ["Open this source only through the authorized transcript wrapper, synthesize an unassigned-call envelope without hypotheses, then apply it on stdin."], coverage: { candidates: 1, proof: "ready", transcript_opened: false, lane: "unassigned_call_evidence", contact_candidates_recorded: captured.added } }), json);
407
+ }
408
+ const status = authorized.status;
409
+ if (status === "invalid")
410
+ purgeDiscoveryLocalState(localRoot(root), studyId);
411
+ const code = {
412
+ needs_source_approval: "E_DISCOVERY_SOURCE_APPROVAL",
413
+ needs_provider_access: "E_DISCOVERY_PROVIDER_ACCESS",
414
+ needs_provider_connector: "E_DISCOVERY_PROVIDER_CONNECTOR",
415
+ needs_research_proof: "E_DISCOVERY_RESEARCH_PROOF",
416
+ };
417
+ return outcome(result(status, { study_id: studyId, writes: captured.writes, errors: [{ code: code[status] ?? "E_DISCOVERY_VALIDATION", message: "An inbox transcript prerequisite is missing; no transcript was opened." }], handoff: authorized.handoff ?? null, coverage: { candidates: 1, transcript_opened: false, lane: "unassigned_call_evidence", contact_candidates_recorded: captured.added } }), json);
418
+ }
419
+ let studyDir;
420
+ let run;
421
+ try {
422
+ studyDir = study(root, studyId);
423
+ run = loadDiscoveryRun(studyDir);
424
+ }
425
+ catch {
426
+ return invalid(`Study ${studyId} was not found.`, json, "E_DISCOVERY_VALIDATION", studyId);
427
+ }
428
+ if (!["awaiting_match", "ambiguous_match", "needs_source_approval", "needs_provider_access", "needs_provider_connector", "needs_research_proof"].includes(run.state))
429
+ return invalid(`Study ${studyId} is not ready for matching.`, json, "E_DISCOVERY_INVALID_TRANSITION", studyId);
430
+ const matched = matchDiscoverySessions(run.match, sessions);
431
+ if (matched.status === "none")
432
+ return outcome(result("awaiting_match", { study_id: studyId, next_actions: ["No completed session matched every configured field; check the planned date, participant labels, and title tokens."], coverage: { candidates: 0, transcript_opened: false } }), json);
433
+ const matchDir = path.join(localRoot(root), studyId);
434
+ mkdirSync(matchDir, { recursive: true });
435
+ writeFileSync(path.join(matchDir, "match.json"), `${JSON.stringify({ schema_version: "0.1", candidates: matched.matches.map((session, index) => ({ candidate_index: index + 1, source_fingerprint: matched.candidates[index].source_fingerprint, session })) }, null, 2)}\n`, { mode: 0o600 });
436
+ if (matched.status === "ambiguous") {
437
+ const writes = transitionIfNeeded(studyDir, "ambiguous_match", now);
438
+ return outcome(result("ambiguous_match", { study_id: studyId, writes, errors: [{ code: "E_DISCOVERY_AMBIGUOUS_MATCH", message: "Several sessions matched; none was opened." }], handoff: `Select one of ${matched.matches.length} locally recorded candidates.`, coverage: { candidates: matched.matches.length, transcript_opened: false } }), json);
439
+ }
440
+ const authorized = authorizeDiscoverySource({ study_dir: studyDir, local_root: localRoot(root), study_id: studyId, study_state: run.state, match_status: "unique", session: matched.matches[0], now });
441
+ if (authorized.status === "ready_to_synthesize") {
442
+ const writes = transitionIfNeeded(studyDir, "ready_to_synthesize", now);
443
+ return outcome(result("ok", { study_id: studyId, writes, next_actions: ["Open candidate 1 only through the authorized transcript wrapper, synthesize a sanitized evidence envelope in memory, then apply it on stdin."], coverage: { candidates: 1, proof: "ready", transcript_opened: false } }), json);
444
+ }
445
+ const status = authorized.status;
446
+ const lifecycle = status;
447
+ const writes = status !== "invalid" ? transitionIfNeeded(studyDir, lifecycle, now) : [];
448
+ const code = {
449
+ needs_source_approval: "E_DISCOVERY_SOURCE_APPROVAL",
450
+ needs_provider_access: "E_DISCOVERY_PROVIDER_ACCESS",
451
+ needs_provider_connector: "E_DISCOVERY_PROVIDER_CONNECTOR",
452
+ needs_research_proof: "E_DISCOVERY_RESEARCH_PROOF",
453
+ };
454
+ return outcome(result(status, { study_id: studyId, writes, errors: [{ code: code[status] ?? "E_DISCOVERY_VALIDATION", message: "A transcript prerequisite is missing; no transcript was opened." }], handoff: authorized.handoff ?? null, coverage: { candidates: 1, transcript_opened: false } }), json);
455
+ }
456
+ export function existingFingerprint(studyDir, fingerprint) {
457
+ // A workspace whose inbox has never received evidence has no findings file
458
+ // yet; that is a first apply, not a replay. (autoTie treats the same state
459
+ // as "the discovery inbox is missing" rather than crashing.)
460
+ const findingsPath = path.join(studyDir, "findings.yaml");
461
+ if (!existsSync(findingsPath))
462
+ return false;
463
+ const doc = parse(readFileSync(findingsPath, "utf8"));
464
+ return (doc.findings ?? []).some((finding) => finding.source_fingerprint === fingerprint);
465
+ }
466
+ function nextFindingId(findings) {
467
+ const highest = findings.reduce((max, finding) => {
468
+ const match = /^F-(\d+)$/.exec(String(finding.finding_id ?? ""));
469
+ return match ? Math.max(max, Number(match[1])) : max;
470
+ }, 0);
471
+ return `F-${String(highest + 1).padStart(3, "0")}`;
472
+ }
473
+ function nextNumberedId(records, key, prefix) {
474
+ const highest = records.reduce((max, record) => {
475
+ const match = new RegExp(`^${prefix}-(\\d+)$`).exec(String(record[key] ?? ""));
476
+ return match ? Math.max(max, Number(match[1])) : max;
477
+ }, 0);
478
+ return `${prefix}-${String(highest + 1).padStart(3, "0")}`;
479
+ }
480
+ async function applyCommand(root, args, stdin, json, now) {
481
+ const studyId = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
482
+ if (!studyId || !hasFlag(args, "--evidence-stdin"))
483
+ return invalid("Usage: foo discovery apply <study-id> --evidence-stdin", json, "E_DISCOVERY_INVALID_ENVELOPE", studyId);
484
+ let parsed;
485
+ try {
486
+ parsed = JSON.parse(stdin);
487
+ }
488
+ catch {
489
+ return invalid("Evidence stdin must be valid JSON.", json, "E_DISCOVERY_INVALID_ENVELOPE", studyId);
490
+ }
491
+ let studyDir;
492
+ try {
493
+ studyDir = discoveryTargetDir(root, studyId);
494
+ }
495
+ catch {
496
+ return invalid(`Discovery target ${studyId} was not found.`, json, "E_DISCOVERY_VALIDATION", studyId);
497
+ }
498
+ if (isInboxTarget(studyId))
499
+ ensureInboxScaffold(root);
500
+ // A hosted portable bundle (needs_local_apply output, T-004) takes the
501
+ // receipt-verified boundary — bundle shape, signature, key, bindings, the
502
+ // current local approval/revocation state, then an online
503
+ // discovery_receipt_status check immediately before any write. Local
504
+ // envelopes keep the one-time-proof boundary below (R-005 vs R-007).
505
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && parsed.kind === "discovery_portable_bundle") {
506
+ const bundleApplied = await applyReceiptVerifiedDiscoveryBundle({ root, target_id: studyId, studyDir, bundle: parsed, now });
507
+ if (bundleApplied.status === "needs_service_verification") {
508
+ return outcome(result("needs_service_verification", {
509
+ study_id: studyId,
510
+ errors: [{ code: "E_DISCOVERY_RECEIPT", message: "Discovery service verification failed; nothing was written." }],
511
+ handoff: bundleApplied.reason,
512
+ next_actions: ["Verify the connection and receipt again when the discovery service is reachable; the receipt is not consumed and no evidence was written."],
513
+ coverage: { receipt_status: "unverified", writes_blocked: true },
514
+ }), json);
515
+ }
516
+ if (bundleApplied.status === "invalid")
517
+ return invalid(bundleApplied.reason, json, "E_DISCOVERY_RECEIPT", studyId);
518
+ return outcome(result(bundleApplied.status, {
519
+ study_id: studyId,
520
+ writes: bundleApplied.writes,
521
+ next_actions: bundleApplied.status === "needs_review" ? ["Review the derived findings, dissent, mappings, and proposals. Automation stops here."] : [],
522
+ coverage: { receipt_verified: true },
523
+ }), json);
524
+ }
525
+ let envelope;
526
+ const lane = isInboxTarget(studyId) ? "unassigned_call_evidence" : "study_linked";
527
+ try {
528
+ envelope = validateDiscoveryEvidence(parsed, { expected_study_id: studyId, expected_lane: lane });
529
+ }
530
+ catch (error) {
531
+ return invalid(`The evidence envelope failed the closed privacy/schema contract — ${failureReason(error)}.`, json, "E_DISCOVERY_INVALID_ENVELOPE", studyId);
532
+ }
533
+ const replayingExistingSource = existingFingerprint(studyDir, envelope.source.source_fingerprint);
534
+ if (!replayingExistingSource) {
535
+ try {
536
+ assertOpenedDiscoveryProof(localRoot(root), studyId, envelope.source.source_fingerprint, now);
537
+ }
538
+ catch {
539
+ return outcome(result("needs_research_proof", { study_id: studyId, errors: [{ code: "E_DISCOVERY_RESEARCH_PROOF", message: "An opened, matching discovery proof is required; no evidence was written." }], handoff: "Repeat metadata matching and open the selected transcript only through the authorized runtime path.", coverage: { transcript_proof: "missing_or_invalid" } }), json);
540
+ }
541
+ }
542
+ try {
543
+ const applied = isInboxTarget(studyId)
544
+ ? applyInboxDiscoveryEvidence(root, studyDir, envelope, now)
545
+ : applyDiscoveryEvidence(root, studyDir, envelope, now);
546
+ if (!replayingExistingSource)
547
+ consumeDiscoveryProof(localRoot(root), studyId, envelope.source.source_fingerprint, now);
548
+ // Transcript-derived contact candidates (discovery-contact-capture-v0
549
+ // R-005): validated name/company/title statements land on the inbox
550
+ // scope's card as inferred suggestions. Study-linked envelopes have no
551
+ // per-scope card, so their candidates are reported, not silently dropped.
552
+ const envelopeCandidates = envelope.contact_candidates ?? [];
553
+ let contactRecorded = 0;
554
+ let contactWrites = [];
555
+ const contactNotes = [];
556
+ if (envelopeCandidates.length > 0) {
557
+ if (isInboxTarget(studyId)) {
558
+ const proposed = [];
559
+ for (const candidate of envelopeCandidates) {
560
+ if (typeof candidate.name === "string" && candidate.name.trim())
561
+ proposed.push({ field: "full_name", value: candidate.name.trim(), inferred: true, source: "envelope:transcript_synthesis" });
562
+ if (typeof candidate.company === "string" && candidate.company.trim())
563
+ proposed.push({ field: "company", value: candidate.company.trim(), inferred: true, source: "envelope:transcript_synthesis" });
564
+ if (typeof candidate.title === "string" && candidate.title.trim())
565
+ proposed.push({ field: "title", value: candidate.title.trim(), inferred: true, source: "envelope:transcript_synthesis" });
566
+ }
567
+ const recorded = recordContactCandidates(root, studyId.slice("inbox:".length), proposed, now);
568
+ contactRecorded = recorded.added;
569
+ contactWrites = recorded.writes;
570
+ if (recorded.added > 0)
571
+ contactNotes.push(`Recorded ${recorded.added} inferred contact candidate(s) on this scope's card; a person confirms them with foo discovery contact ${studyId.slice("inbox:".length)} --infer.`);
572
+ }
573
+ else {
574
+ contactNotes.push("Contact candidates were validated but not recorded: cards are per inbox call scope, and this is a study-linked apply.");
575
+ }
576
+ }
577
+ return outcome(result(applied.status, { study_id: studyId, writes: [...applied.writes, ...contactWrites], next_actions: [...(applied.status === "needs_review" ? ["Review the derived findings, dissent, mappings, and proposals. Automation stops here."] : []), ...contactNotes], coverage: { findings: envelope.findings.length, transcript_complete: envelope.coverage.transcript_complete, ...(envelopeCandidates.length > 0 ? { contact_candidates: envelopeCandidates.length, contact_candidates_recorded: contactRecorded } : {}) } }), json);
578
+ }
579
+ catch {
580
+ return invalid("Evidence could not be applied; no partial discovery update was accepted.", json, "E_DISCOVERY_VALIDATION", studyId);
581
+ }
582
+ }
583
+ function findYamlRecords(root, targetId, within) {
584
+ const discovery = path.join(root, "discovery");
585
+ if (!existsSync(discovery))
586
+ return [];
587
+ let dirs = readdirSync(discovery, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(discovery, entry.name));
588
+ if (within) {
589
+ if (within === "inbox" || isInboxTarget(within))
590
+ dirs = [inboxDir(root)];
591
+ else {
592
+ const scopedStudy = locateStudy(root, within);
593
+ dirs = scopedStudy ? [scopedStudy] : dirs.filter((dir) => path.basename(dir) === within);
594
+ }
595
+ }
596
+ const found = [];
597
+ for (const dir of dirs) {
598
+ for (const [fileName, key, idKey] of [["findings.yaml", "findings", "finding_id"], ["proposals.yaml", "proposals", "proposal_id"]]) {
599
+ const file = path.join(dir, fileName);
600
+ if (!existsSync(file))
601
+ continue;
602
+ const doc = parse(readFileSync(file, "utf8"));
603
+ const records = (doc[key] ?? []).filter((entry) => entry[idKey] === targetId);
604
+ for (const record of records)
605
+ found.push({ file, key, doc, record });
606
+ }
607
+ }
608
+ return found;
609
+ }
610
+ // Key-order-independent equality for the human-edit check (detach D-002): the
611
+ // YAML round trip may reorder keys, and reordering alone is not a human edit.
612
+ function canonicalJson(value) {
613
+ if (Array.isArray(value))
614
+ return `[${value.map(canonicalJson).join(",")}]`;
615
+ if (value && typeof value === "object") {
616
+ const entries = Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
617
+ return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJson(entryValue)}`).join(",")}}`;
618
+ }
619
+ return JSON.stringify(value) ?? "null";
620
+ }
621
+ // A real spec packet, never created implicitly by attach (D-005).
622
+ function specPacketDir(root, packetName) {
623
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(packetName))
624
+ return null;
625
+ const dir = path.join(root, "specs", packetName);
626
+ return existsSync(path.join(dir, "spec.md")) ? dir : null;
627
+ }
628
+ function tieKey(tie) {
629
+ return `${tie.type}:${tie.ref}`;
630
+ }
631
+ function recordTies(record) {
632
+ if (!Array.isArray(record.ties))
633
+ record.ties = [];
634
+ return record.ties;
635
+ }
636
+ // Read ties without materializing the field. Used where a record may end up
637
+ // with no tie so a skipped record never gains a noisy empty `ties: []`.
638
+ function peekTies(record) {
639
+ return Array.isArray(record.ties) ? record.ties : [];
640
+ }
641
+ function tieUrlFor(root, type, ref) {
642
+ if (type === "spec")
643
+ return `specs/${ref}/spec.md`;
644
+ if (type === "research" || type === "idea")
645
+ return ref;
646
+ if (type === "study") {
647
+ const dir = locateStudy(root, ref);
648
+ return dir ? `${path.relative(root, dir).split(path.sep).join("/")}/study.md` : null;
649
+ }
650
+ return null;
651
+ }
652
+ // Resolves a research/idea document reference: bare names resolve into the
653
+ // directory, `.md` is optional, and the file must exist — ties never invent
654
+ // documents.
655
+ function resolveDocRef(root, dirName, given) {
656
+ const base = given.includes("/") ? given : `${dirName}/${given}`;
657
+ if (!base.startsWith(`${dirName}/`) || base.includes(".."))
658
+ return null;
659
+ for (const candidate of [base, `${base}.md`]) {
660
+ if (existsSync(path.join(root, candidate)))
661
+ return candidate;
662
+ }
663
+ return null;
664
+ }
665
+ // R-002 migration: fold the superseded single-pointer fields into ties so no
666
+ // pointer from the assignment era is stranded. Lazy on every tie-touching
667
+ // verb, plus one sweep in the auto pass.
668
+ function migrateRecordTies(root, record) {
669
+ const keys = new Set(peekTies(record).map(tieKey));
670
+ let changed = false;
671
+ const add = (type, ref, url, by, actor, at) => {
672
+ if (keys.has(`${type}:${ref}`))
673
+ return;
674
+ recordTies(record).push({ type, ref, url, by, actor, at });
675
+ keys.add(`${type}:${ref}`);
676
+ changed = true;
677
+ };
678
+ const assignment = (record.assignment ?? null);
679
+ const by = String(assignment?.by ?? "migration");
680
+ const actor = assignment?.actor === "agent" ? "agent" : "human";
681
+ const at = String(assignment?.at ?? "1970-01-01T00:00:00.000Z");
682
+ const pointer = typeof record.assigned_to === "string" && record.assigned_to ? record.assigned_to : null;
683
+ if (pointer) {
684
+ if (pointer.startsWith("packet:"))
685
+ add("spec", pointer.slice("packet:".length), tieUrlFor(root, "spec", pointer.slice("packet:".length)), by, actor, at);
686
+ else if (pointer.startsWith("bug:")) {
687
+ const basis = assignment?.basis;
688
+ add("bug", pointer.slice("bug:".length), typeof basis?.issue_url === "string" ? basis.issue_url : null, by, actor, at);
689
+ }
690
+ else
691
+ add("study", pointer, tieUrlFor(root, "study", pointer), by, actor, at);
692
+ }
693
+ const attached = typeof record.attached_to === "string" && record.attached_to ? record.attached_to : null;
694
+ if (attached && !attached.startsWith("bug:")) {
695
+ const disposition = record.disposition;
696
+ const attachBy = String(disposition?.by ?? by);
697
+ const attachAt = String(disposition?.at ?? at);
698
+ if (attached.startsWith("packet:"))
699
+ add("spec", attached.slice("packet:".length), tieUrlFor(root, "spec", attached.slice("packet:".length)), attachBy, "human", attachAt);
700
+ else
701
+ add("study", attached, tieUrlFor(root, "study", attached), attachBy, "human", attachAt);
702
+ }
703
+ if ("assigned_to" in record) {
704
+ delete record.assigned_to;
705
+ changed = true;
706
+ }
707
+ if ("assignment" in record) {
708
+ delete record.assignment;
709
+ changed = true;
710
+ }
711
+ return changed;
712
+ }
713
+ // Placement fields live on the signal side and may change after a filing; the
714
+ // filed copy is compared without them so a later tie never reads as a human
715
+ // edit of the copy (detach D-002 of discovery-review-verbs-v0).
716
+ function stripPlacement(record) {
717
+ const { ties: _ties, assigned_to: _assignedTo, assignment: _assignment, primary_home: _primaryHome, ...rest } = record;
718
+ return rest;
719
+ }
720
+ function atomicWriteYaml(file, value) {
721
+ const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
722
+ writeFileSync(temporary, stringify(value), { mode: 0o600, flag: "wx" });
723
+ renameSync(temporary, file);
724
+ }
725
+ function atomicWriteYamlBatch(entries) {
726
+ const staged = entries.map(({ file, value }, index) => {
727
+ const temporary = `${file}.tmp-${process.pid}-${Date.now()}-${index}`;
728
+ writeFileSync(temporary, stringify(value), { mode: 0o600, flag: "wx" });
729
+ return { file, temporary };
730
+ });
731
+ for (const entry of staged)
732
+ renameSync(entry.temporary, entry.file);
733
+ }
734
+ function learningClusterFinding(record, proposal) {
735
+ const findingId = typeof record.finding_id === "string" ? record.finding_id : "";
736
+ const inboxScopeId = typeof record.inbox_scope_id === "string" ? record.inbox_scope_id.trim() : "";
737
+ const headline = typeof record.headline === "string" ? record.headline.trim() : "";
738
+ const paraphrase = typeof record.paraphrase === "string" ? record.paraphrase.trim() : "";
739
+ if (!/^F-\d{3}$/.test(findingId) || !inboxScopeId || (!headline && !paraphrase))
740
+ return null;
741
+ return {
742
+ finding_id: findingId,
743
+ inbox_scope_id: inboxScopeId,
744
+ headline,
745
+ paraphrase,
746
+ dissent: typeof record.dissent === "string" ? record.dissent.trim() : undefined,
747
+ proposal_text: typeof proposal?.proposed_text === "string" ? proposal.proposed_text.trim() : undefined,
748
+ };
749
+ }
750
+ function clusterSummary(cluster) {
751
+ return {
752
+ cluster_key: cluster.cluster_key,
753
+ source_finding_ids: cluster.source_finding_ids,
754
+ independent_call_count: cluster.source_scope_ids.length,
755
+ signals: cluster.findings.map((finding) => ({
756
+ finding_id: finding.finding_id,
757
+ headline: finding.headline,
758
+ paraphrase: finding.paraphrase,
759
+ dissent: finding.dissent ?? "",
760
+ proposal_text: finding.proposal_text ?? "",
761
+ })),
762
+ };
763
+ }
764
+ function parseHypothesisSynthesis(stdin) {
765
+ let parsed;
766
+ try {
767
+ parsed = JSON.parse(stdin);
768
+ }
769
+ catch {
770
+ return null;
771
+ }
772
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
773
+ return null;
774
+ const value = parsed;
775
+ if (!Array.isArray(value.source_finding_ids) || !value.source_finding_ids.every((id) => typeof id === "string"))
776
+ return null;
777
+ if (!["claim", "dissent", "falsifier", "next_evidence"].every((key) => typeof value[key] === "string"))
778
+ return null;
779
+ if (value.starting_confidence !== "low" && value.starting_confidence !== "medium")
780
+ return null;
781
+ return {
782
+ source_finding_ids: value.source_finding_ids,
783
+ claim: value.claim,
784
+ dissent: value.dissent,
785
+ falsifier: value.falsifier,
786
+ next_evidence: value.next_evidence,
787
+ starting_confidence: value.starting_confidence,
788
+ };
789
+ }
790
+ function routeReceipt(decision, now, hypothesisId) {
791
+ if (decision.kind === "home") {
792
+ return {
793
+ status: "proposed",
794
+ type: decision.destination.type,
795
+ subtype: null,
796
+ ref: decision.destination.ref,
797
+ path: decision.destination.path,
798
+ title: decision.destination.title,
799
+ basis: decision.basis,
800
+ placement_confidence: decision.placement_confidence,
801
+ why: decision.why,
802
+ candidates: [],
803
+ by: "foo-discovery",
804
+ actor: "agent",
805
+ at: now,
806
+ };
807
+ }
808
+ if (decision.kind === "hypothesis") {
809
+ return {
810
+ status: "proposed",
811
+ type: "backlog",
812
+ subtype: "hypothesis",
813
+ ref: hypothesisId,
814
+ path: "discovery/inbox/hypotheses.yaml",
815
+ title: decision.claim,
816
+ basis: "new_testable_claim",
817
+ placement_confidence: decision.starting_confidence,
818
+ why: decision.why,
819
+ candidates: [],
820
+ by: "foo-discovery",
821
+ actor: "agent",
822
+ at: now,
823
+ };
824
+ }
825
+ return {
826
+ status: "unverified",
827
+ type: null,
828
+ subtype: null,
829
+ ref: null,
830
+ path: null,
831
+ title: null,
832
+ basis: decision.kind === "unmatched" ? "emerging_cluster" : decision.reason,
833
+ placement_confidence: "low",
834
+ why: decision.why,
835
+ candidates: decision.candidates.map(({ destination, score }) => ({
836
+ type: destination.type,
837
+ ref: destination.ref,
838
+ path: destination.path,
839
+ title: destination.title,
840
+ score,
841
+ })),
842
+ by: "foo-discovery",
843
+ actor: "agent",
844
+ at: now,
845
+ };
846
+ }
847
+ function eventHome(value) {
848
+ if (!value || typeof value !== "object" || Array.isArray(value))
849
+ return null;
850
+ const home = value;
851
+ return {
852
+ status: home.status ?? null,
853
+ type: home.type ?? null,
854
+ subtype: home.subtype ?? null,
855
+ ref: home.ref ?? null,
856
+ path: home.path ?? null,
857
+ basis: home.basis ?? null,
858
+ };
859
+ }
860
+ function routeCommand(root, args, stdin, json, now) {
861
+ const auto = hasFlag(args, "--auto");
862
+ const synthesisStdin = hasFlag(args, "--synthesis-stdin");
863
+ if (auto === synthesisStdin)
864
+ return invalid("Usage: foo discovery route (--auto | --synthesis-stdin) [--within inbox] [--finding F-123].", json, "E_DISCOVERY_VALIDATION");
865
+ const within = flagValue(args, "--within");
866
+ if (within && within !== "inbox")
867
+ return invalid("Learning routing only walks the approved inbox; use --within inbox or omit the flag.", json, "E_DISCOVERY_VALIDATION");
868
+ const findingFilter = flagValue(args, "--finding");
869
+ if (synthesisStdin && findingFilter)
870
+ return invalid("Synthesis takes its exact source finding set on stdin; do not combine it with --finding.", json, "E_DISCOVERY_VALIDATION");
871
+ if (findingFilter && !/^F-\d{3}$/.test(findingFilter))
872
+ return invalid("--finding must be a canonical F-123 finding id.", json, "E_DISCOVERY_VALIDATION");
873
+ const dir = inboxDir(root);
874
+ const findingsFile = path.join(dir, "findings.yaml");
875
+ const proposalsFile = path.join(dir, "proposals.yaml");
876
+ const hypothesesFile = path.join(dir, "hypotheses.yaml");
877
+ const hypothesesExisted = existsSync(hypothesesFile);
878
+ if (!existsSync(findingsFile))
879
+ return invalid("The discovery inbox is missing.", json, "E_DISCOVERY_VALIDATION");
880
+ const findingsDoc = parse(readFileSync(findingsFile, "utf8"));
881
+ const proposalsDoc = existsSync(proposalsFile)
882
+ ? parse(readFileSync(proposalsFile, "utf8"))
883
+ : { proposals: [] };
884
+ const hypothesesDoc = existsSync(hypothesesFile)
885
+ ? parse(readFileSync(hypothesesFile, "utf8"))
886
+ : { schema_version: "0.1", hypotheses: [] };
887
+ findingsDoc.findings = Array.isArray(findingsDoc.findings) ? findingsDoc.findings : [];
888
+ hypothesesDoc.hypotheses = Array.isArray(hypothesesDoc.hypotheses) ? hypothesesDoc.hypotheses : [];
889
+ let repairedHypotheses = 0;
890
+ let repairedReceipts = 0;
891
+ let hypothesesChanged = false;
892
+ if (auto) {
893
+ const findingById = new Map(findingsDoc.findings.map((record) => [String(record.finding_id ?? ""), record]));
894
+ const invalidAgentHypothesisIds = new Set();
895
+ hypothesesDoc.hypotheses = hypothesesDoc.hypotheses.filter((hypothesis) => {
896
+ if (hypothesis.created_by !== "foo-discovery")
897
+ return true;
898
+ const sourceIds = Array.isArray(hypothesis.source_finding_ids) ? [...new Set(hypothesis.source_finding_ids.map(String))] : [];
899
+ const sourceScopes = new Set(sourceIds.map((id) => findingById.get(id)?.inbox_scope_id).filter((id) => typeof id === "string" && id));
900
+ if (sourceIds.length >= 2 && sourceScopes.size >= 2)
901
+ return true;
902
+ const hypothesisId = String(hypothesis.hypothesis_id ?? "");
903
+ if (/^H-\d{3}$/.test(hypothesisId))
904
+ invalidAgentHypothesisIds.add(hypothesisId);
905
+ repairedHypotheses += 1;
906
+ hypothesesChanged = true;
907
+ return false;
908
+ });
909
+ for (const record of findingsDoc.findings) {
910
+ const home = record.primary_home;
911
+ if (home?.actor !== "agent" || home.subtype !== "hypothesis" || !invalidAgentHypothesisIds.has(String(home.ref ?? "")))
912
+ continue;
913
+ delete record.primary_home;
914
+ repairedReceipts += 1;
915
+ }
916
+ }
917
+ const proposalByFinding = new Map();
918
+ for (const proposal of Array.isArray(proposalsDoc.proposals) ? proposalsDoc.proposals : []) {
919
+ for (const findingId of Array.isArray(proposal.source_finding_ids) ? proposal.source_finding_ids.map(String) : []) {
920
+ if (!proposalByFinding.has(findingId))
921
+ proposalByFinding.set(findingId, proposal);
922
+ }
923
+ }
924
+ const destinations = buildLearningDestinationCatalog(root);
925
+ const records = findingsDoc.findings.filter((record) => (!findingFilter || record.finding_id === findingFilter)
926
+ && record.review_state === "needs_review");
927
+ if (findingFilter && records.length === 0)
928
+ return invalid(`Review target ${findingFilter} was not found in the active inbox.`, json, "E_DISCOVERY_VALIDATION");
929
+ const sharedTargetCounts = new Map();
930
+ for (const record of records) {
931
+ if (record.primary_home)
932
+ continue;
933
+ const findingId = String(record.finding_id ?? "");
934
+ const decision = routeLearning({ finding: record, proposal: proposalByFinding.get(findingId), destinations });
935
+ if (decision.kind === "home" && decision.basis === "semantic") {
936
+ sharedTargetCounts.set(decision.destination.path, (sharedTargetCounts.get(decision.destination.path) ?? 0) + 1);
937
+ }
938
+ }
939
+ const decisions = new Map();
940
+ const clusterFindings = [];
941
+ for (const record of records) {
942
+ const findingId = String(record.finding_id ?? "");
943
+ const proposal = proposalByFinding.get(findingId);
944
+ const decision = routeLearning({ finding: record, proposal, destinations, sharedTargetCounts });
945
+ decisions.set(findingId, decision);
946
+ if (decision.kind !== "unmatched")
947
+ continue;
948
+ const clusterFinding = learningClusterFinding(record, proposal);
949
+ if (clusterFinding)
950
+ clusterFindings.push(clusterFinding);
951
+ }
952
+ const clusters = clusterUnmatchedLearnings(clusterFindings);
953
+ if (synthesisStdin) {
954
+ const synthesis = parseHypothesisSynthesis(stdin);
955
+ if (!synthesis)
956
+ return invalid("Hypothesis synthesis stdin must be one bounded JSON object with source IDs, claim, dissent, falsifier, next evidence, and low or medium confidence.", json, "E_DISCOVERY_VALIDATION");
957
+ const requestedSources = [...new Set(synthesis.source_finding_ids)].sort();
958
+ const cluster = clusters.find((candidate) => candidate.source_finding_ids.join("\n") === requestedSources.join("\n"));
959
+ if (!cluster)
960
+ return invalid("The source set does not name one eligible canonical signal cluster.", json, "E_DISCOVERY_VALIDATION");
961
+ const validation = validateHypothesisSynthesis({ cluster, synthesis });
962
+ if (!validation.ok)
963
+ return invalid(`Hypothesis synthesis was refused: ${validation.reason}.`, json, "E_DISCOVERY_VALIDATION");
964
+ const existing = hypothesesDoc.hypotheses.find((hypothesis) => {
965
+ const sources = Array.isArray(hypothesis.source_finding_ids) ? [...new Set(hypothesis.source_finding_ids.map(String))].sort() : [];
966
+ return sources.join("\n") === validation.value.source_finding_ids.join("\n");
967
+ });
968
+ if (existing) {
969
+ const unchanged = existing.claim === validation.value.claim
970
+ && existing.dissent === validation.value.dissent
971
+ && existing.falsifier === validation.value.falsifier
972
+ && existing.next_evidence === validation.value.next_evidence
973
+ && existing.starting_confidence === validation.value.starting_confidence;
974
+ if (!unchanged)
975
+ return invalid("That corroborated source set already has a different Hypothesis; review it instead of overwriting history.", json, "E_DISCOVERY_INVALID_TRANSITION");
976
+ return outcome(result("clean_no_op", {
977
+ next_actions: ["This exact synthesized Hypothesis already exists in Backlog."],
978
+ coverage: { hypothesis_id: existing.hypothesis_id, source_finding_ids: validation.value.source_finding_ids, actor: "agent" },
979
+ }), json);
980
+ }
981
+ const sourceRecords = validation.value.source_finding_ids.map((findingId) => records.find((record) => record.finding_id === findingId));
982
+ if (sourceRecords.some((record) => !record))
983
+ return invalid("Every Hypothesis source must remain in the active inbox.", json, "E_DISCOVERY_VALIDATION");
984
+ const protectedHome = sourceRecords.some((record) => {
985
+ const home = record?.primary_home;
986
+ return home && (home.actor === "human" || typeof home.type === "string");
987
+ });
988
+ if (protectedHome) {
989
+ return invalid("A contributing signal already has a human or verified primary home; preserve prior routing judgment.", json, "E_DISCOVERY_INVALID_TRANSITION");
990
+ }
991
+ const hypothesisId = nextNumberedId(hypothesesDoc.hypotheses, "hypothesis_id", "H");
992
+ hypothesesDoc.hypotheses.push({
993
+ hypothesis_id: hypothesisId,
994
+ status: "untested",
995
+ claim: validation.value.claim,
996
+ source_finding_ids: validation.value.source_finding_ids,
997
+ source_scope_ids: validation.value.source_scope_ids,
998
+ why_new: `Synthesized from ${validation.value.source_scope_ids.length} independent calls that surfaced a shared unresolved pattern.`,
999
+ dissent: validation.value.dissent,
1000
+ falsifier: validation.value.falsifier,
1001
+ next_evidence: validation.value.next_evidence,
1002
+ starting_confidence: validation.value.starting_confidence,
1003
+ created_by: "foo-discovery",
1004
+ created_at: now,
1005
+ });
1006
+ hypothesesDoc.hypotheses.sort((left, right) => String(left.hypothesis_id).localeCompare(String(right.hypothesis_id)));
1007
+ for (const record of sourceRecords) {
1008
+ record.primary_home = {
1009
+ status: "proposed",
1010
+ type: "backlog",
1011
+ subtype: "hypothesis",
1012
+ ref: hypothesisId,
1013
+ path: "discovery/inbox/hypotheses.yaml",
1014
+ url: `/admin?item=${hypothesisId}`,
1015
+ title: validation.value.claim,
1016
+ basis: "corroborated_signal_cluster",
1017
+ placement_confidence: validation.value.starting_confidence,
1018
+ why: `This finding contributes to a synthesis supported by ${validation.value.source_scope_ids.length} independent calls.`,
1019
+ candidates: [],
1020
+ by: "foo-discovery",
1021
+ actor: "agent",
1022
+ at: now,
1023
+ };
1024
+ }
1025
+ atomicWriteYamlBatch([
1026
+ { file: hypothesesFile, value: { schema_version: String(hypothesesDoc.schema_version ?? "0.1"), hypotheses: hypothesesDoc.hypotheses } },
1027
+ { file: findingsFile, value: { schema_version: String(findingsDoc.schema_version ?? "0.1"), findings: findingsDoc.findings } },
1028
+ ]);
1029
+ appendAgentEvent(dir, "com.adaptivespec.discovery.hypothesis_synthesized", `hypothesis:${hypothesisId}`, {
1030
+ hypothesis_id: hypothesisId,
1031
+ source_finding_ids: validation.value.source_finding_ids,
1032
+ source_scope_count: validation.value.source_scope_ids.length,
1033
+ }, now);
1034
+ return outcome(result("ok", {
1035
+ writes: [relative(root, hypothesesFile, hypothesesExisted ? "updated" : "created"), relative(root, findingsFile), relative(root, path.join(dir, "events.jsonl"))],
1036
+ next_actions: [`Open ${hypothesisId} in the existing Backlog and test the next evidence step.`],
1037
+ coverage: { hypothesis_id: hypothesisId, source_finding_ids: validation.value.source_finding_ids, independent_call_count: validation.value.source_scope_ids.length, actor: "agent" },
1038
+ }), json);
1039
+ }
1040
+ const emergingClusters = clusters.filter((cluster) => cluster.status === "emerging");
1041
+ const synthesizedSourceSets = new Set(hypothesesDoc.hypotheses.map((hypothesis) => (Array.isArray(hypothesis.source_finding_ids) ? [...new Set(hypothesis.source_finding_ids.map(String))].sort() : []).join("\n")));
1042
+ const synthesisCandidates = clusters
1043
+ .filter((cluster) => cluster.status === "eligible_for_synthesis" && !synthesizedSourceSets.has(cluster.source_finding_ids.join("\n")))
1044
+ .map(clusterSummary);
1045
+ const counts = {
1046
+ home: 0,
1047
+ emerging: emergingClusters.reduce((count, cluster) => count + cluster.source_finding_ids.length, 0),
1048
+ synthesis_candidates: synthesisCandidates,
1049
+ unverified: 0,
1050
+ already_routed: 0,
1051
+ human_preserved: 0,
1052
+ repaired_hypotheses: repairedHypotheses,
1053
+ repaired_receipts: repairedReceipts,
1054
+ actor: "agent",
1055
+ };
1056
+ const eventRows = [];
1057
+ for (const record of records) {
1058
+ const current = record.primary_home;
1059
+ if (current) {
1060
+ if (current.actor === "human")
1061
+ counts.human_preserved += 1;
1062
+ else
1063
+ counts.already_routed += 1;
1064
+ continue;
1065
+ }
1066
+ const findingId = String(record.finding_id ?? "");
1067
+ let decision = decisions.get(findingId);
1068
+ if (decision.kind === "unmatched") {
1069
+ if (learningClusterFinding(record, proposalByFinding.get(findingId)))
1070
+ continue;
1071
+ decision = {
1072
+ kind: "unverified",
1073
+ reason: "insufficient_evidence",
1074
+ candidates: decision.candidates,
1075
+ why: "This unmatched finding lacks the call identity or bounded signal text required for safe clustering.",
1076
+ };
1077
+ }
1078
+ if (decision.kind === "home") {
1079
+ counts.home += 1;
1080
+ }
1081
+ else {
1082
+ counts.unverified += 1;
1083
+ }
1084
+ const receipt = routeReceipt(decision, now);
1085
+ record.primary_home = receipt;
1086
+ eventRows.push({ findingId, receipt });
1087
+ }
1088
+ if (eventRows.length === 0 && repairedHypotheses === 0 && repairedReceipts === 0) {
1089
+ return outcome(result("clean_no_op", {
1090
+ next_actions: ["Every selected finding already has a route receipt; human corrections were preserved."],
1091
+ coverage: counts,
1092
+ }), json);
1093
+ }
1094
+ if (eventRows.length > 0 || repairedReceipts > 0) {
1095
+ atomicWriteYaml(findingsFile, { schema_version: String(findingsDoc.schema_version ?? "0.1"), findings: findingsDoc.findings });
1096
+ }
1097
+ if (hypothesesChanged) {
1098
+ atomicWriteYaml(hypothesesFile, { schema_version: String(hypothesesDoc.schema_version ?? "0.1"), hypotheses: hypothesesDoc.hypotheses });
1099
+ }
1100
+ for (const row of eventRows) {
1101
+ appendAgentEvent(dir, "com.adaptivespec.discovery.home_routed", `finding:${row.findingId}`, {
1102
+ finding_id: row.findingId,
1103
+ home: eventHome(row.receipt),
1104
+ }, now);
1105
+ }
1106
+ if (repairedHypotheses > 0 || repairedReceipts > 0) {
1107
+ appendAgentEvent(dir, "com.adaptivespec.discovery.hypothesis_routing_repaired", "inbox:hypotheses", {
1108
+ removed_single_source_hypotheses: repairedHypotheses,
1109
+ removed_agent_receipts: repairedReceipts,
1110
+ }, now);
1111
+ }
1112
+ return outcome(result("ok", {
1113
+ writes: [
1114
+ ...((eventRows.length > 0 || repairedReceipts > 0) ? [relative(root, findingsFile)] : []),
1115
+ ...(hypothesesChanged ? [relative(root, hypothesesFile, hypothesesExisted ? "updated" : "created")] : []),
1116
+ relative(root, path.join(dir, "events.jsonl")),
1117
+ ],
1118
+ next_actions: counts.unverified > 0
1119
+ ? [`${counts.unverified} route(s) remain Home not verified; review only those exceptions.`]
1120
+ : synthesisCandidates.length > 0
1121
+ ? [`Synthesize ${synthesisCandidates.length} corroborated cluster(s) in fresh language through --synthesis-stdin.`]
1122
+ : ["Unmatched single-call signals remain emerging until another independent call corroborates them."],
1123
+ coverage: counts,
1124
+ }), json);
1125
+ }
1126
+ function appendHumanEvent(dir, type, subject, by, data, now) {
1127
+ const event = { specversion: "1.0", id: `evt_discovery_review_${Date.parse(now)}_${type.replace(/\W/g, "_")}`, source: "agent://foo/discovery", type, subject, time: now, actor: { type: "human", id: by }, data };
1128
+ appendFileSync(path.join(dir, "events.jsonl"), `${JSON.stringify(event)}\n`);
1129
+ }
1130
+ // Machine placement is not human judgment: auto-assignment events carry the
1131
+ // agent actor so the audit trail says who decided (discovery-assign-verbs-v0
1132
+ // D-002). The subject rides in the id because one batch writes many events at
1133
+ // the same timestamp.
1134
+ function appendAgentEvent(dir, type, subject, data, now) {
1135
+ const event = { specversion: "1.0", id: `evt_discovery_${Date.parse(now)}_${type.replace(/\W/g, "_")}_${subject.replace(/\W/g, "_")}`, source: "agent://foo/discovery", type, subject, time: now, actor: { type: "agent", id: "foo-discovery" }, data };
1136
+ appendFileSync(path.join(dir, "events.jsonl"), `${JSON.stringify(event)}\n`);
1137
+ }
1138
+ // The dedupe-first bug engine, seeded from a signal (bug-owner-autolog-v0):
1139
+ // reproduced ties to THAT issue, filed ties to the new one, anything else is
1140
+ // an honest refusal message for the caller to surface.
1141
+ function runSignalBugEngine(root, record) {
1142
+ const engine = runBugLog({ env: process.env, gh: defaultGhExec, session: newBugLogSession(process.env, root), intake: buildSignalBugIntake(record), root });
1143
+ if (engine.status !== "reproduced" && engine.status !== "filed") {
1144
+ const detail = engine.errors[0]?.message ?? "the bug queue refused the filing";
1145
+ const resume = engine.next_actions[0] ?? "";
1146
+ return { ok: false, message: `The owner bug queue did not accept this signal: ${detail} ${resume}`.trim() };
1147
+ }
1148
+ if (engine.issue_number === null) {
1149
+ return { ok: false, message: `The bug queue accepted the filing but returned no issue number; check ${engine.issue_url ?? engine.repo ?? "the owner repository"} and rerun once gh output is readable.` };
1150
+ }
1151
+ return {
1152
+ ok: true,
1153
+ tie: { ref: String(engine.issue_number), url: engine.issue_url },
1154
+ note: engine.status === "reproduced"
1155
+ ? `This signal reproduces existing issue ${engine.issue_url}; the evidence was added there as a comment — nothing new was filed.`
1156
+ : `New issue filed in the owner bug queue: ${engine.issue_url} (dedupe searched open and closed issues first).`,
1157
+ };
1158
+ }
1159
+ // R-004: dismissal is not a dead end. A tie means the insight matters after
1160
+ // all, so it clears the dismissal — on the ledger, with the human actor.
1161
+ function undismissOnTie(dir, record, target, by, key, now, notes) {
1162
+ if (record.review_state !== "dismissed")
1163
+ return;
1164
+ record.review_state = "needs_review";
1165
+ record.disposition = { by, at: now, action: "tie" };
1166
+ appendHumanEvent(dir, "com.adaptivespec.discovery.undismissed", `findings:${target}`, by, { target, reason: "tied", tie: key }, now);
1167
+ notes.push("This record was dismissed; tying it cleared the dismissal (it is back in needs_review).");
1168
+ }
1169
+ // Maps a reconciliation target_path to an assignable destination — a study id
1170
+ // or `packet:<name>` — or null when the target is not a place work happens
1171
+ // (research papers and loose documents are advice, not destinations; D-006).
1172
+ function assignableDestination(root, targetPath) {
1173
+ const packetMatch = /^specs\/([^/]+)\//.exec(targetPath);
1174
+ if (packetMatch)
1175
+ return specPacketDir(root, packetMatch[1]) ? `packet:${packetMatch[1]}` : null;
1176
+ const studyMatch = /^discovery\/([^/]+)\//.exec(targetPath);
1177
+ if (studyMatch && studyMatch[1] !== "inbox") {
1178
+ try {
1179
+ return loadDiscoveryRun(path.join(root, "discovery", studyMatch[1])).study_id;
1180
+ }
1181
+ catch {
1182
+ return null;
1183
+ }
1184
+ }
1185
+ return null;
1186
+ }
1187
+ // The signal inbox's catch-all discrimination, verbatim (customer-signal-inbox-v0
1188
+ // D-010): a target claimed by more than five records is a catch-all attractor,
1189
+ // not a relationship, and must not auto-assign. One threshold, two surfaces.
1190
+ const CATCH_ALL_SHARED_MAX = 5;
1191
+ // Maps a reconciliation target_path to a TIE (discovery-insight-ties-v0
1192
+ // R-005): specs/ packets tie as `spec`, discovery studies as `study`, and —
1193
+ // new under the ties model — research and idea documents are first-class tie
1194
+ // targets. The catch-all shared-count gate still applies before this runs.
1195
+ function autoTieFor(root, targetPath) {
1196
+ const destination = assignableDestination(root, targetPath);
1197
+ if (destination) {
1198
+ return destination.startsWith("packet:")
1199
+ ? { type: "spec", ref: destination.slice("packet:".length), url: tieUrlFor(root, "spec", destination.slice("packet:".length)) }
1200
+ : { type: "study", ref: destination, url: tieUrlFor(root, "study", destination) };
1201
+ }
1202
+ if (/^research\/[^/]+\.md$/.test(targetPath) && existsSync(path.join(root, targetPath)))
1203
+ return { type: "research", ref: targetPath, url: targetPath };
1204
+ if (/^ideas\/[^/]+\.md$/.test(targetPath) && existsSync(path.join(root, targetPath)))
1205
+ return { type: "idea", ref: targetPath, url: targetPath };
1206
+ return null;
1207
+ }
1208
+ function autoTieCommand(root, args, json, now) {
1209
+ if (!hasFlag(args, "--auto"))
1210
+ return invalid("Usage: foo discovery tie --auto [--within inbox]. The automatic pass is the only top-level mode; a person ties one record with foo discovery review <F-id> tie --bug|--spec <name>|--research <doc>|--idea <doc> --by <person>.", json, "E_DISCOVERY_VALIDATION");
1211
+ const within = flagValue(args, "--within");
1212
+ if (within && within !== "inbox")
1213
+ return invalid("Auto-tie walks the unassigned-call inbox; rerun with --within inbox or drop the flag.", json, "E_DISCOVERY_VALIDATION");
1214
+ const findingsFile = path.join(inboxDir(root), "findings.yaml");
1215
+ if (!existsSync(findingsFile))
1216
+ return invalid("The discovery inbox is missing.", json, "E_DISCOVERY_VALIDATION");
1217
+ const doc = parse(readFileSync(findingsFile, "utf8"));
1218
+ const findings = doc.findings ?? [];
1219
+ // Shared-count per target across the WHOLE file, mirroring the signal-inbox
1220
+ // server: a catch-all is a property of the batch, not of one record.
1221
+ const inboxSelfPath = "discovery/inbox/findings.yaml";
1222
+ const targetCounts = new Map();
1223
+ for (const record of findings) {
1224
+ const reconciliation = record.reconciliation;
1225
+ const targetPath = typeof reconciliation?.target_path === "string" ? reconciliation.target_path : null;
1226
+ if (targetPath && targetPath !== inboxSelfPath)
1227
+ targetCounts.set(targetPath, (targetCounts.get(targetPath) ?? 0) + 1);
1228
+ }
1229
+ const counts = { tied: 0, migrated: 0, already_placed: 0, weak: 0, catch_all: 0, no_home: 0 };
1230
+ for (const record of findings) {
1231
+ // R-002: the auto pass doubles as the migration sweep for every record.
1232
+ if (migrateRecordTies(root, record))
1233
+ counts.migrated += 1;
1234
+ if (record.review_state !== "needs_review" || record.attached_to || peekTies(record).length > 0) {
1235
+ counts.already_placed += 1;
1236
+ continue;
1237
+ }
1238
+ const reconciliation = record.reconciliation;
1239
+ const targetPath = typeof reconciliation?.target_path === "string" ? reconciliation.target_path : null;
1240
+ if (!targetPath || targetPath === inboxSelfPath) {
1241
+ counts.no_home += 1;
1242
+ continue;
1243
+ }
1244
+ const confidence = typeof reconciliation?.confidence === "string" ? reconciliation.confidence : null;
1245
+ if (confidence !== "medium" && confidence !== "high") {
1246
+ counts.weak += 1;
1247
+ continue;
1248
+ }
1249
+ const shared = targetCounts.get(targetPath) ?? 0;
1250
+ if (shared > CATCH_ALL_SHARED_MAX) {
1251
+ counts.catch_all += 1;
1252
+ continue;
1253
+ }
1254
+ const target = autoTieFor(root, targetPath);
1255
+ if (!target) {
1256
+ counts.no_home += 1;
1257
+ continue;
1258
+ }
1259
+ recordTies(record).push({ type: target.type, ref: target.ref, url: target.url, by: "foo-discovery", actor: "agent", at: now });
1260
+ appendAgentEvent(inboxDir(root), "com.adaptivespec.discovery.tied", `finding:${String(record.finding_id)}`, { type: target.type, ref: target.ref, url: target.url, target_path: targetPath, confidence, shared_count: shared }, now);
1261
+ counts.tied += 1;
1262
+ }
1263
+ const wrote = counts.tied > 0 || counts.migrated > 0;
1264
+ const writes = [];
1265
+ if (wrote) {
1266
+ writeFileSync(findingsFile, stringify(doc));
1267
+ writes.push(relative(root, findingsFile));
1268
+ if (counts.tied > 0)
1269
+ writes.push(relative(root, path.join(inboxDir(root), "events.jsonl")));
1270
+ }
1271
+ return outcome(result(wrote ? "ok" : "clean_no_op", {
1272
+ writes,
1273
+ next_actions: counts.tied > 0
1274
+ ? ["Ties are links, not filings: add one with foo discovery review <F-id> tie --bug|--spec <name>|--research <doc>|--idea <doc> --by <person>, remove one with untie, or file evidence with attach."]
1275
+ : counts.migrated > 0
1276
+ ? [`Legacy pointers on ${counts.migrated} record(s) were folded into ties (findings.yaml was rewritten); no unplaced record had a genuine match, so nothing new was tied.`]
1277
+ : ["No unplaced record has a genuine match; weak, catch-all, and no-home signals stay untied for a person."],
1278
+ coverage: { ...counts, actor: "agent", catch_all_shared_max: CATCH_ALL_SHARED_MAX, evidence_copied: false },
1279
+ }), json);
1280
+ }
1281
+ function contactsFile(root) {
1282
+ return path.join(inboxDir(root), "contacts.yaml");
1283
+ }
1284
+ function loadContacts(root) {
1285
+ if (!existsSync(contactsFile(root)))
1286
+ return { schema_version: "0.1", contacts: [] };
1287
+ const doc = parse(readFileSync(contactsFile(root), "utf8"));
1288
+ return { schema_version: String(doc.schema_version ?? "0.1"), contacts: Array.isArray(doc.contacts) ? doc.contacts : [] };
1289
+ }
1290
+ export { isValidLinkedinUrl } from "./discoverySources.js";
1291
+ // "And if you give one, can infer the rest" — from LOCAL call context only:
1292
+ // scope metadata and this scope's findings text. No web lookups (D-008).
1293
+ function inferContactCandidates(scope, scopeFindings) {
1294
+ const candidates = [];
1295
+ const seen = new Set();
1296
+ const add = (candidate) => {
1297
+ const key = `${candidate.field}:${candidate.value.toLowerCase()}`;
1298
+ if (seen.has(key))
1299
+ return;
1300
+ seen.add(key);
1301
+ candidates.push(candidate);
1302
+ };
1303
+ const namePattern = /\b([A-Z][a-z]+(?: [A-Z][a-zA-Z'’-]+)+)\s+(?:of|at|from)\s+([A-Z][A-Za-z0-9&.'’-]*(?: [A-Z][A-Za-z0-9&.'’-]*){0,3})/g;
1304
+ // Title statements the call content itself makes ("technical co-founder",
1305
+ // "VP of operations") — a conservative vocabulary, because every candidate
1306
+ // is a suggestion a person reads (discovery-contact-capture-v0 R-005/R-006).
1307
+ const titlePattern = /\b(technical co-founder|co-founder|founder|chief (?:executive|technology|product|operating|financial) officer|CEO|CTO|CPO|COO|CFO|president|general manager|managing director|(?:vp|vice president) of [a-z]+|head of [a-z]+|director of [a-z]+|product manager|engineering manager|technology lead)\b/gi;
1308
+ for (const record of scopeFindings) {
1309
+ for (const [fieldName, text] of [["paraphrase", record.paraphrase], ["dissent", record.dissent]]) {
1310
+ if (typeof text !== "string")
1311
+ continue;
1312
+ for (const match of text.matchAll(namePattern)) {
1313
+ const source = `finding:${String(record.finding_id)} ${fieldName}`;
1314
+ add({ field: "full_name", value: match[1], inferred: true, source });
1315
+ add({ field: "company", value: match[2].replace(/[.,;:]$/, ""), inferred: true, source });
1316
+ }
1317
+ for (const match of text.matchAll(titlePattern)) {
1318
+ add({ field: "title", value: match[1], inferred: true, source: `finding:${String(record.finding_id)} ${fieldName}` });
1319
+ }
1320
+ for (const url of text.match(/https:\/\/[^\s"'<>)]+/g) ?? []) {
1321
+ if (isValidLinkedinUrl(url))
1322
+ add({ field: "linkedin", value: url, inferred: true, source: `finding:${String(record.finding_id)} ${fieldName}` });
1323
+ }
1324
+ }
1325
+ }
1326
+ for (const participant of scope.participant_allowlist ?? []) {
1327
+ if (/^[A-Z][a-z]+(?: [A-Z][a-zA-Z'’-]+)+$/.test(participant)) {
1328
+ add({ field: "full_name", value: participant, inferred: true, source: "scope:participant_allowlist" });
1329
+ }
1330
+ }
1331
+ // Scope ids carry name tokens between the provider prefix and date tokens
1332
+ // (granola_chris_exampletn_2026_07 -> "Chris Exampletn").
1333
+ const idTokens = scope.scope_id.split("_").slice(1).filter((token) => !/^\d+$/.test(token) && !/^q\d$/.test(token));
1334
+ if (idTokens.length >= 1 && candidates.every((candidate) => candidate.field !== "full_name")) {
1335
+ add({ field: "full_name", value: idTokens.map((token) => token.charAt(0).toUpperCase() + token.slice(1)).join(" "), inferred: true, source: "scope:scope_id" });
1336
+ }
1337
+ for (const token of scope.title_tokens ?? []) {
1338
+ if (/^[A-Za-z][A-Za-z0-9&.-]+$/.test(token) && token.length >= 3) {
1339
+ add({ field: "company", value: token, inferred: true, source: "scope:title_tokens" });
1340
+ }
1341
+ }
1342
+ for (const domain of (scope.domain_allowlist ?? [])) {
1343
+ const base = domain.replace(/^@/, "").split(".")[0];
1344
+ if (base)
1345
+ add({ field: "company", value: base.charAt(0).toUpperCase() + base.slice(1), inferred: true, source: "scope:domain_allowlist" });
1346
+ }
1347
+ const missing = [];
1348
+ for (const field of ["full_name", "company", "title", "linkedin"]) {
1349
+ if (candidates.some((candidate) => candidate.field === field))
1350
+ continue;
1351
+ missing.push({
1352
+ field,
1353
+ reason: field === "linkedin"
1354
+ ? "no linkedin.com URL appears in this scope's local call context; the CLI never looks it up on the web (web enrichment is a deliberate future decision owned by the research boundary)"
1355
+ : field === "title"
1356
+ ? "nothing in this scope's local call context states a title"
1357
+ : `nothing in this scope's local call context names a ${field === "full_name" ? "person" : "company"}`,
1358
+ });
1359
+ }
1360
+ return { candidates, missing };
1361
+ }
1362
+ // --- Persisted candidates (discovery-contact-capture-v0 R-002/R-004) ----------
1363
+ // Ingest-time and sweep capture land here: inferred suggestions stored on the
1364
+ // card BENEATH the human-confirmed fields. Never auto-confirmed, never
1365
+ // overwriting, deduped by (field, value), and scrubbed one more time so no
1366
+ // email or phone number can reach the committed file.
1367
+ const CANDIDATE_EMAIL = /[\w.+-]+@[\w-]+\.[\w.-]+/;
1368
+ const CANDIDATE_PHONE = /\+?\d[\d\s().-]{7,}\d/;
1369
+ function safeContactCandidate(candidate) {
1370
+ const value = candidate.value.trim();
1371
+ if (!value)
1372
+ return false;
1373
+ if (CANDIDATE_EMAIL.test(value) || CANDIDATE_PHONE.test(value))
1374
+ return false;
1375
+ if (candidate.field === "linkedin" && !isValidLinkedinUrl(value))
1376
+ return false;
1377
+ return true;
1378
+ }
1379
+ function emptyContactCard(scopeId) {
1380
+ return { scope_id: scopeId, full_name: null, linkedin: null, company: null, title: null, confirmed_by: null, confirmed_at: null };
1381
+ }
1382
+ function saveContacts(root, contacts) {
1383
+ contacts.sort((a, b) => (a.scope_id < b.scope_id ? -1 : a.scope_id > b.scope_id ? 1 : 0));
1384
+ writeFileSync(contactsFile(root), stringify({ schema_version: "0.1", contacts }));
1385
+ }
1386
+ // Returns the number of candidates actually added (0 = clean no-op, nothing
1387
+ // written). Confirmed fields are never touched by this path.
1388
+ function recordContactCandidates(root, scopeId, proposed, now) {
1389
+ const safe = proposed.filter(safeContactCandidate);
1390
+ if (safe.length === 0)
1391
+ return { added: 0, writes: [] };
1392
+ const existing = loadContacts(root);
1393
+ const card = existing.contacts.find((candidate) => candidate.scope_id === scopeId) ?? null;
1394
+ const target = card ?? emptyContactCard(scopeId);
1395
+ const candidates = Array.isArray(target.candidates) ? target.candidates : [];
1396
+ const seen = new Set(candidates.map((candidate) => `${candidate.field}:${candidate.value.trim().toLowerCase()}`));
1397
+ let added = 0;
1398
+ for (const candidate of safe) {
1399
+ const key = `${candidate.field}:${candidate.value.trim().toLowerCase()}`;
1400
+ if (seen.has(key))
1401
+ continue;
1402
+ seen.add(key);
1403
+ candidates.push({ field: candidate.field, value: candidate.value.trim(), inferred: true, source: candidate.source });
1404
+ added += 1;
1405
+ }
1406
+ if (added === 0)
1407
+ return { added: 0, writes: [] };
1408
+ target.candidates = candidates;
1409
+ const contactsExisted = existsSync(contactsFile(root));
1410
+ const contacts = existing.contacts.filter((candidate) => candidate.scope_id !== scopeId);
1411
+ contacts.push(target);
1412
+ saveContacts(root, contacts);
1413
+ const fields = [...new Set(safe.map((candidate) => candidate.field))];
1414
+ appendAgentEvent(inboxDir(root), "com.adaptivespec.discovery.contact_candidates_recorded", `scope:${scopeId}`, { scope_id: scopeId, added, fields }, now);
1415
+ return { added, writes: [relative(root, contactsFile(root), contactsExisted ? "updated" : "created"), relative(root, path.join(inboxDir(root), "events.jsonl"))] };
1416
+ }
1417
+ // Everything a matched session's metadata states about the contact: hints the
1418
+ // adapter derived (display strings, email DOMAINS, provider titles/profiles)
1419
+ // plus person-name participant labels. Email-shaped labels never survive —
1420
+ // contactHintsFromDisplayLabel drops them and safeContactCandidate scrubs
1421
+ // again (R-004).
1422
+ function sessionContactCandidates(session) {
1423
+ const hints = [...(session.contact_hints ?? [])];
1424
+ for (const participant of session.participants) {
1425
+ hints.push(...contactHintsFromDisplayLabel(participant, `${session.provider}:participant`));
1426
+ }
1427
+ return dedupeContactHints(hints).map((hint) => ({ field: hint.field, value: hint.value, inferred: true, source: hint.source }));
1428
+ }
1429
+ function contactCommand(root, args, json, now) {
1430
+ if (hasFlag(args, "--sweep")) {
1431
+ // R-006 (discovery-contact-capture-v0): one-shot backfill over data
1432
+ // already on disk — scope labels, scope-id tokens, title tokens, domain
1433
+ // allowlists, findings attribution text. No provider calls; rerun is a
1434
+ // clean no-op; confirmed fields are never touched.
1435
+ const scopesDoc = parse(readFileSync(path.join(inboxDir(root), "scope.yaml"), "utf8"));
1436
+ const scopes = scopesDoc.scopes ?? [];
1437
+ const findingsDoc = parse(readFileSync(path.join(inboxDir(root), "findings.yaml"), "utf8"));
1438
+ const allFindings = findingsDoc.findings ?? [];
1439
+ let candidatesAdded = 0;
1440
+ let scopesUpdated = 0;
1441
+ const sweepWrites = new Map();
1442
+ for (const scope of scopes) {
1443
+ const scopeFindings = allFindings.filter((record) => record.inbox_scope_id === scope.scope_id);
1444
+ const inference = inferContactCandidates(scope, scopeFindings);
1445
+ const recorded = recordContactCandidates(root, scope.scope_id, inference.candidates, now);
1446
+ if (recorded.added > 0) {
1447
+ candidatesAdded += recorded.added;
1448
+ scopesUpdated += 1;
1449
+ for (const write of recorded.writes)
1450
+ sweepWrites.set(write.path, write);
1451
+ }
1452
+ }
1453
+ return outcome(result(candidatesAdded > 0 ? "ok" : "clean_no_op", {
1454
+ writes: [...sweepWrites.values()],
1455
+ next_actions: candidatesAdded > 0
1456
+ ? [`Swept ${scopes.length} scope(s): ${candidatesAdded} inferred candidate(s) recorded on ${scopesUpdated} contact card(s). Nothing was confirmed — a person confirms a value with foo discovery contact <scope-id> --name|--company|--title|--linkedin <value> --by <person>.`]
1457
+ : ["Every contact card already carries its local candidates; nothing new on disk to sweep."],
1458
+ coverage: { scopes: scopes.length, scopes_updated: scopesUpdated, candidates_added: candidatesAdded, provider_calls: 0, confirmed_fields_touched: 0, sources: "local_disk_only" },
1459
+ }), json);
1460
+ }
1461
+ const scopeId = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
1462
+ if (!scopeId)
1463
+ return invalid("Usage: foo discovery contact <scope-id> [--name <text>] [--linkedin <url>] [--company <text>] [--title <text>] --by <person> | --infer [--json], or foo discovery contact --sweep [--json]", json, "E_DISCOVERY_VALIDATION");
1464
+ let scope;
1465
+ try {
1466
+ scope = inboxScope(root, `inbox:${scopeId}`);
1467
+ }
1468
+ catch {
1469
+ return invalid(`Scope ${scopeId} was not found in discovery/inbox/scope.yaml. Check the scope id, then rerun foo discovery contact <scope-id>.`, json, "E_DISCOVERY_VALIDATION");
1470
+ }
1471
+ const existing = loadContacts(root);
1472
+ const card = existing.contacts.find((candidate) => candidate.scope_id === scopeId) ?? null;
1473
+ if (hasFlag(args, "--infer")) {
1474
+ const findingsDoc = parse(readFileSync(path.join(inboxDir(root), "findings.yaml"), "utf8"));
1475
+ const scopeFindings = (findingsDoc.findings ?? []).filter((record) => record.inbox_scope_id === scopeId);
1476
+ const inference = inferContactCandidates(scope, scopeFindings);
1477
+ // Stored candidates — captured at ingest (provider metadata), apply
1478
+ // (envelope), or by the sweep — surface beside freshly derived ones.
1479
+ const merged = [];
1480
+ const seen = new Set();
1481
+ for (const candidate of [...(card?.candidates ?? []), ...inference.candidates]) {
1482
+ const key = `${candidate.field}:${candidate.value.trim().toLowerCase()}`;
1483
+ if (seen.has(key))
1484
+ continue;
1485
+ seen.add(key);
1486
+ merged.push(candidate);
1487
+ }
1488
+ const missing = inference.missing.filter((entry) => !merged.some((candidate) => candidate.field === entry.field));
1489
+ return outcome(result("ok", {
1490
+ next_actions: [
1491
+ ...merged.map((candidate) => `Candidate ${candidate.field}: "${candidate.value}" (inferred from ${candidate.source}). Confirm it: foo discovery contact ${scopeId} --${candidate.field === "full_name" ? "name" : candidate.field} "${candidate.value}" --by <person>`),
1492
+ ...missing.map((entry) => `No ${entry.field} candidate: ${entry.reason}.`),
1493
+ ],
1494
+ coverage: { scope_id: scopeId, card, candidates: merged, missing, sources: "local_call_context_only", written: false },
1495
+ }), json);
1496
+ }
1497
+ const by = flagValue(args, "--by");
1498
+ if (!by?.trim())
1499
+ return invalid(`Editing the contact card requires --by <person>: foo discovery contact ${scopeId} --name <text> --by <person>.`, json, "E_DISCOVERY_VALIDATION");
1500
+ const name = flagValue(args, "--name");
1501
+ const linkedin = flagValue(args, "--linkedin");
1502
+ const company = flagValue(args, "--company");
1503
+ const title = flagValue(args, "--title");
1504
+ if (!name && !linkedin && !company && !title)
1505
+ return invalid(`Nothing to update: pass at least one of --name <text>, --linkedin <url>, --company <text>, --title <text> (or --infer to see candidates).`, json, "E_DISCOVERY_VALIDATION");
1506
+ if (linkedin && !isValidLinkedinUrl(linkedin)) {
1507
+ return invalid(`The LinkedIn value must be an https URL on a linkedin.com host. Fix it, then rerun foo discovery contact ${scopeId} --linkedin <url> --by ${by}.`, json, "E_DISCOVERY_VALIDATION");
1508
+ }
1509
+ const updated = card ?? emptyContactCard(scopeId);
1510
+ const fields = [];
1511
+ if (name) {
1512
+ updated.full_name = name;
1513
+ fields.push("full_name");
1514
+ }
1515
+ if (linkedin) {
1516
+ updated.linkedin = linkedin;
1517
+ fields.push("linkedin");
1518
+ }
1519
+ if (company) {
1520
+ updated.company = company;
1521
+ fields.push("company");
1522
+ }
1523
+ if (title) {
1524
+ updated.title = title;
1525
+ fields.push("title");
1526
+ }
1527
+ // Human confirmation stamp: the card is the corrected identity layer; the
1528
+ // attribution basis of the call match is never touched by this file.
1529
+ updated.confirmed_by = by;
1530
+ updated.confirmed_at = now;
1531
+ const contacts = existing.contacts.filter((candidate) => candidate.scope_id !== scopeId);
1532
+ contacts.push(updated);
1533
+ contacts.sort((a, b) => (a.scope_id < b.scope_id ? -1 : a.scope_id > b.scope_id ? 1 : 0));
1534
+ writeFileSync(contactsFile(root), stringify({ schema_version: "0.1", contacts }));
1535
+ appendHumanEvent(inboxDir(root), "com.adaptivespec.discovery.contact_updated", `scope:${scopeId}`, by, { scope_id: scopeId, fields }, now);
1536
+ return outcome(result("ok", {
1537
+ writes: [relative(root, contactsFile(root), card ? "updated" : "created"), relative(root, path.join(inboxDir(root), "events.jsonl"))],
1538
+ next_actions: [`Contact card for ${scopeId} updated (${fields.join(", ")}). The call's original attribution basis is unchanged; this card is the human-corrected identity.`],
1539
+ coverage: { scope_id: scopeId, fields_updated: fields, confirmed_by: by },
1540
+ }), json);
1541
+ }
1542
+ function reviewCommand(root, args, json, now) {
1543
+ const target = args[1];
1544
+ // `assign` on one finding is the same human correction as `reassign` —
1545
+ // the owner's phrasing ("assigned as a bug") is accepted verbatim.
1546
+ const action = args[2] === "assign" ? "reassign" : args[2];
1547
+ const by = flagValue(args, "--by");
1548
+ if (!target || !action || !by?.trim())
1549
+ return invalid("Discovery review requires a target, action, and --by <person>.", json, "E_DISCOVERY_INVALID_TRANSITION");
1550
+ if (action === "complete") {
1551
+ try {
1552
+ const dir = study(root, target);
1553
+ const writes = transitionDiscoveryRun(dir, "complete", { type: "human", id: by }, now);
1554
+ return outcome(result("ok", { study_id: target, writes, next_actions: ["Study completed by a person."], coverage: { human_transition: true } }), json);
1555
+ }
1556
+ catch {
1557
+ return invalid("Only a needs-review study can be completed.", json, "E_DISCOVERY_INVALID_TRANSITION", target);
1558
+ }
1559
+ }
1560
+ if (action === "select-source") {
1561
+ const studyDir = locateStudy(root, target);
1562
+ if (studyDir) {
1563
+ const fingerprint = flagValue(args, "--fingerprint");
1564
+ const matchFile = path.join(localRoot(root), localTargetName(target), "match.json");
1565
+ if (!fingerprint || !existsSync(matchFile))
1566
+ return invalid("Study source selection requires one locally listed --fingerprint.", json, "E_DISCOVERY_INVALID_TRANSITION", target);
1567
+ let candidates = [];
1568
+ try {
1569
+ const matchDoc = JSON.parse(readFileSync(matchFile, "utf8"));
1570
+ candidates = Array.isArray(matchDoc.candidates) ? matchDoc.candidates : [];
1571
+ }
1572
+ catch {
1573
+ return invalid("The local ambiguous-match record is invalid; repeat metadata matching.", json, "E_DISCOVERY_VALIDATION", target);
1574
+ }
1575
+ const selected = candidates.find((candidate) => candidate.source_fingerprint === fingerprint);
1576
+ if (!selected)
1577
+ return invalid("The selected source fingerprint is not one of the ambiguous candidates.", json, "E_DISCOVERY_INVALID_TRANSITION", target);
1578
+ const run = loadDiscoveryRun(studyDir);
1579
+ if (!["ambiguous_match", "needs_source_approval", "needs_provider_access", "needs_provider_connector", "needs_research_proof"].includes(run.state))
1580
+ return invalid("This study is not awaiting source selection.", json, "E_DISCOVERY_INVALID_TRANSITION", target);
1581
+ writeFileSync(matchFile, `${JSON.stringify({ schema_version: "0.1", candidates: [selected] }, null, 2)}\n`, { mode: 0o600 });
1582
+ appendHumanEvent(studyDir, "com.adaptivespec.discovery.source_selected", `study:${target}`, by, { source_fingerprint: fingerprint }, now);
1583
+ const authorized = authorizeDiscoverySource({ study_dir: studyDir, local_root: localRoot(root), study_id: target, study_state: run.state, match_status: "unique", session: selected.session, now });
1584
+ const eventWrite = relative(root, path.join(studyDir, "events.jsonl"));
1585
+ const matchWrite = relative(root, matchFile);
1586
+ if (authorized.status === "ready_to_synthesize") {
1587
+ const writes = [...transitionIfNeeded(studyDir, "ready_to_synthesize", now), eventWrite, matchWrite];
1588
+ return outcome(result("ok", { study_id: target, writes, next_actions: ["Open only the selected source through the authorized runtime path."], coverage: { human_source_selection: true, proof: "ready", transcript_opened: false } }), json);
1589
+ }
1590
+ const status = authorized.status;
1591
+ if (status === "invalid")
1592
+ purgeDiscoveryLocalState(localRoot(root), target);
1593
+ const code = {
1594
+ needs_source_approval: "E_DISCOVERY_SOURCE_APPROVAL",
1595
+ needs_provider_access: "E_DISCOVERY_PROVIDER_ACCESS",
1596
+ needs_provider_connector: "E_DISCOVERY_PROVIDER_CONNECTOR",
1597
+ needs_research_proof: "E_DISCOVERY_RESEARCH_PROOF",
1598
+ };
1599
+ const writes = status !== "invalid" ? [...transitionIfNeeded(studyDir, status, now), eventWrite, matchWrite] : [eventWrite];
1600
+ return outcome(result(status, { study_id: target, writes, errors: [{ code: code[status] ?? "E_DISCOVERY_VALIDATION", message: "The selected source is recorded, but a transcript prerequisite is missing." }], handoff: authorized.handoff ?? null, coverage: { human_source_selection: true, transcript_opened: false } }), json);
1601
+ }
1602
+ }
1603
+ const locatedRecords = findYamlRecords(root, target, flagValue(args, "--within"));
1604
+ if (locatedRecords.length === 0)
1605
+ return invalid(`Review target ${target} was not found.`, json, "E_DISCOVERY_VALIDATION");
1606
+ if (locatedRecords.length > 1)
1607
+ return invalid(`Review target ${target} is ambiguous; rerun with --within <study-id|inbox>.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1608
+ const located = locatedRecords[0];
1609
+ const extraWrites = [];
1610
+ let reassignFrom = null;
1611
+ let reassignTo = null;
1612
+ let reassignIssueUrl = null;
1613
+ let tieEvent = null;
1614
+ const reassignNotes = [];
1615
+ if (action === "promote") {
1616
+ const destination = flagValue(args, "--destination");
1617
+ if (located.key !== "proposals" || !destination || !/^(?:R|D)-\d{3}$/.test(destination))
1618
+ return invalid("Promotion requires a proposal and --destination <R-000|D-000>.", json, "E_DISCOVERY_INVALID_TRANSITION");
1619
+ Object.assign(located.record, { state: "promoted", destination, disposition: { by, at: now, action: "promote" } });
1620
+ }
1621
+ else if (action === "dismiss") {
1622
+ if (located.key === "proposals")
1623
+ Object.assign(located.record, { state: "dismissed", disposition: { by, at: now, action: "dismiss" } });
1624
+ else
1625
+ Object.assign(located.record, { review_state: "dismissed", disposition: { by, at: now, action: "dismiss" } });
1626
+ }
1627
+ else if (action === "attach") {
1628
+ let studyId = flagValue(args, "--study");
1629
+ let packetName = flagValue(args, "--packet");
1630
+ if (located.key !== "findings" || path.basename(path.dirname(located.file)) !== "inbox" || located.record.lane !== "unassigned_call_evidence")
1631
+ return invalid("Attachment requires an inbox finding and --study <id> or --packet <packet-name>.", json, "E_DISCOVERY_INVALID_TRANSITION");
1632
+ if (studyId && packetName)
1633
+ return invalid(`Attach takes one destination: rerun with either --study ${studyId} or --packet ${packetName}, not both.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1634
+ if (!studyId && !packetName) {
1635
+ // Ties are the default state; attach with no destination is the
1636
+ // deliberate confirmation that files to the record's one fileable tie.
1637
+ migrateRecordTies(root, located.record);
1638
+ const ties = recordTies(located.record);
1639
+ const fileable = ties.filter((tie) => tie.type === "spec" || tie.type === "study");
1640
+ if (fileable.length === 0) {
1641
+ const bugTies = ties.filter((tie) => tie.type === "bug");
1642
+ if (bugTies.length > 0)
1643
+ return invalid(`This finding is filed in the owner bug queue (${bugTies.map(tieKey).join(", ")}); there is no attach step for bug ties. To file it into repository work too, run foo discovery review ${target} tie --spec <name> --by ${by} (the GitHub issue is not closed by anything here), then rerun attach.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1644
+ return invalid(`This finding has no spec or study tie to file to, so attach needs a destination: foo discovery review ${target} attach --study <id> --by ${by} or --packet <packet-name> — or tie it first with foo discovery review ${target} tie --spec <name> --by ${by}.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1645
+ }
1646
+ if (fileable.length > 1)
1647
+ return invalid(`This finding is tied to several fileable destinations (${fileable.map(tieKey).join(", ")}); name the one to file into: foo discovery review ${target} attach --study <id> --by ${by} or --packet <packet-name>.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1648
+ if (fileable[0].type === "spec")
1649
+ packetName = fileable[0].ref;
1650
+ else
1651
+ studyId = fileable[0].ref;
1652
+ }
1653
+ if (located.record.attached_to)
1654
+ return invalid(`This inbox finding is already attached to ${String(located.record.attached_to)}. Undo it first: foo discovery review ${target} detach --by ${by}.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1655
+ const originalFindingId = String(located.record.finding_id);
1656
+ if (packetName) {
1657
+ const packetDir = specPacketDir(root, packetName);
1658
+ if (!packetDir)
1659
+ return invalid(`Spec packet ${packetName} was not found under specs/. Attach never creates a packet; create it first (foo asp init specs/${packetName}), then rerun foo discovery review ${target} attach --packet ${packetName} --by ${by}.`, json, "E_DISCOVERY_VALIDATION");
1660
+ Object.assign(located.record, { attached_to: `packet:${packetName}`, review_state: "attached", disposition: { by, at: now, action: "attach" } });
1661
+ migrateRecordTies(root, located.record); // the filing confirms the tie (R-004 of the assign packet, ties edition)
1662
+ const evidenceFile = path.join(packetDir, "evidence.yaml");
1663
+ const evidenceExisted = existsSync(evidenceFile);
1664
+ const evidenceDoc = evidenceExisted
1665
+ ? parse(readFileSync(evidenceFile, "utf8"))
1666
+ : { schema_version: "0.1", findings: [] };
1667
+ evidenceDoc.findings = evidenceDoc.findings ?? [];
1668
+ const attachedFindingId = nextFindingId(evidenceDoc.findings);
1669
+ evidenceDoc.findings.push({ ...stripPlacement(located.record), finding_id: attachedFindingId, original_finding_id: originalFindingId, original_lane: "unassigned_call_evidence", review_state: "needs_review" });
1670
+ writeFileSync(evidenceFile, stringify(evidenceDoc));
1671
+ appendHumanEvent(packetDir, "com.adaptivespec.discovery.inbox_attached", `finding:${attachedFindingId}`, by, { original_finding_id: originalFindingId, inbox: "discovery/inbox", packet: packetName }, now);
1672
+ extraWrites.push(relative(root, evidenceFile, evidenceExisted ? "updated" : "created"), relative(root, path.join(packetDir, "events.jsonl")));
1673
+ }
1674
+ else {
1675
+ Object.assign(located.record, { attached_to: studyId, review_state: "attached", disposition: { by, at: now, action: "attach" } });
1676
+ migrateRecordTies(root, located.record); // the filing confirms the tie
1677
+ const targetDir = study(root, studyId);
1678
+ const targetFile = path.join(targetDir, "findings.yaml");
1679
+ const targetDoc = parse(readFileSync(targetFile, "utf8"));
1680
+ const attachedFindingId = nextFindingId(targetDoc.findings);
1681
+ targetDoc.findings.push({ ...stripPlacement(located.record), finding_id: attachedFindingId, original_finding_id: originalFindingId, original_lane: "unassigned_call_evidence", review_state: "needs_review" });
1682
+ writeFileSync(targetFile, stringify(targetDoc));
1683
+ appendHumanEvent(targetDir, "com.adaptivespec.discovery.inbox_attached", `finding:${attachedFindingId}`, by, { original_finding_id: originalFindingId, inbox: "discovery/inbox" }, now);
1684
+ extraWrites.push(relative(root, targetFile), relative(root, path.join(targetDir, "events.jsonl")));
1685
+ }
1686
+ }
1687
+ else if (action === "detach") {
1688
+ if (located.key !== "findings" || path.basename(path.dirname(located.file)) !== "inbox")
1689
+ return invalid("Detach undoes a prior attach of an inbox finding; the target must be an inbox finding ID.", json, "E_DISCOVERY_INVALID_TRANSITION");
1690
+ const attachedTo = typeof located.record.attached_to === "string" && located.record.attached_to ? located.record.attached_to : null;
1691
+ if (!attachedTo)
1692
+ return invalid(`This inbox finding is not attached, so there is nothing to undo. To file it, run foo discovery review ${target} attach --study <id> --by ${by} or foo discovery review ${target} attach --packet <packet-name> --by ${by}.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1693
+ const isPacketDestination = attachedTo.startsWith("packet:");
1694
+ let targetDir;
1695
+ let workingFile;
1696
+ if (isPacketDestination) {
1697
+ const packetName = attachedTo.slice("packet:".length);
1698
+ const packetDir = specPacketDir(root, packetName);
1699
+ if (!packetDir)
1700
+ return invalid(`Spec packet ${packetName} is missing under specs/, so its attached copy cannot be removed. Restore the packet directory, then rerun foo discovery review ${target} detach --by ${by}.`, json, "E_DISCOVERY_VALIDATION");
1701
+ targetDir = packetDir;
1702
+ workingFile = path.join(packetDir, "evidence.yaml");
1703
+ }
1704
+ else {
1705
+ const studyDir = locateStudy(root, attachedTo);
1706
+ if (!studyDir)
1707
+ return invalid(`Study ${attachedTo} is missing, so its attached copy cannot be removed. Restore the study directory, then rerun foo discovery review ${target} detach --by ${by}.`, json, "E_DISCOVERY_VALIDATION");
1708
+ targetDir = studyDir;
1709
+ workingFile = path.join(studyDir, "findings.yaml");
1710
+ }
1711
+ const workingLabel = `${path.basename(path.dirname(workingFile))}/${path.basename(workingFile)}`;
1712
+ if (!existsSync(workingFile))
1713
+ return invalid(`The attached copy of ${target} was not found: ${workingLabel} is missing. Resolve manually — mark the inbox record yourself (foo discovery review ${target} dismiss --by ${by} --within inbox) or restore ${workingLabel} from history, then rerun foo discovery review ${target} detach --by ${by}.`, json, "E_DISCOVERY_MANUAL_RESOLUTION");
1714
+ const workingDoc = parse(readFileSync(workingFile, "utf8"));
1715
+ const copies = (workingDoc.findings ?? []).filter((finding) => finding.original_finding_id === String(located.record.finding_id));
1716
+ if (copies.length === 0)
1717
+ return invalid(`The attached copy of ${target} is no longer in ${workingLabel}. Resolve manually — mark the inbox record yourself (foo discovery review ${target} dismiss --by ${by} --within inbox) or restore the copy from history, then rerun foo discovery review ${target} detach --by ${by}.`, json, "E_DISCOVERY_MANUAL_RESOLUTION");
1718
+ const copy = copies[0];
1719
+ // Provenance comparison (D-002): the copy was written as the inbox record
1720
+ // plus exactly these provenance fields. Any other difference is a human
1721
+ // edit, and undo must not destroy human edits.
1722
+ const expectedCopy = { ...stripPlacement(located.record), finding_id: copy.finding_id, original_finding_id: String(located.record.finding_id), original_lane: "unassigned_call_evidence", review_state: "needs_review" };
1723
+ if (canonicalJson(stripPlacement(copy)) !== canonicalJson(expectedCopy)) {
1724
+ return invalid(`The attached copy in ${workingLabel} was edited by a person after it was attached; detach will not destroy those edits. Resolve manually — keep the edits and mark the inbox record yourself (foo discovery review ${target} dismiss --by ${by} --within inbox), or restore the copy to its attached form and rerun foo discovery review ${target} detach --by ${by}.`, json, "E_DISCOVERY_MANUAL_RESOLUTION");
1725
+ }
1726
+ workingDoc.findings = (workingDoc.findings ?? []).filter((finding) => finding !== copy);
1727
+ writeFileSync(workingFile, stringify(workingDoc));
1728
+ appendHumanEvent(targetDir, "com.adaptivespec.discovery.inbox_detached", `finding:${String(copy.finding_id)}`, by, { original_finding_id: String(located.record.finding_id), detached_from: attachedTo, restored_to: "discovery/inbox" }, now);
1729
+ Object.assign(located.record, { attached_to: null, review_state: "needs_review", disposition: { by, at: now, action: "detach" } });
1730
+ extraWrites.push(relative(root, workingFile), relative(root, path.join(targetDir, "events.jsonl")));
1731
+ }
1732
+ else if (action === "move-home") {
1733
+ if (located.key !== "findings" || path.basename(path.dirname(located.file)) !== "inbox")
1734
+ return invalid("Move changes an inbox finding's primary home; the target must be an inbox finding ID.", json, "E_DISCOVERY_INVALID_TRANSITION");
1735
+ const idea = flagValue(args, "--idea");
1736
+ const research = flagValue(args, "--research");
1737
+ const backlog = flagValue(args, "--backlog");
1738
+ const hypothesis = flagValue(args, "--hypothesis");
1739
+ if ([idea, research, backlog, hypothesis].filter(Boolean).length !== 1) {
1740
+ return invalid(`Move takes exactly one primary home: --idea <doc>, --research <doc>, --backlog <packet>, or --hypothesis <H-id>.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1741
+ }
1742
+ let destination;
1743
+ let subtype = null;
1744
+ if (idea) {
1745
+ const ref = resolveDocRef(root, "ideas", idea);
1746
+ if (ref)
1747
+ destination = buildLearningDestinationCatalog(root).find((candidate) => candidate.type === "idea" && candidate.path === ref);
1748
+ }
1749
+ else if (research) {
1750
+ const ref = resolveDocRef(root, "research", research);
1751
+ if (ref)
1752
+ destination = buildLearningDestinationCatalog(root).find((candidate) => candidate.type === "research" && candidate.path === ref);
1753
+ }
1754
+ else if (backlog) {
1755
+ if (specPacketDir(root, backlog))
1756
+ destination = buildLearningDestinationCatalog(root).find((candidate) => candidate.type === "backlog" && candidate.ref === backlog);
1757
+ }
1758
+ else if (hypothesis) {
1759
+ const hypothesisFile = path.join(inboxDir(root), "hypotheses.yaml");
1760
+ if (existsSync(hypothesisFile)) {
1761
+ const hypothesisDoc = parse(readFileSync(hypothesisFile, "utf8"));
1762
+ const found = (hypothesisDoc.hypotheses ?? []).find((candidate) => candidate.hypothesis_id === hypothesis);
1763
+ if (found) {
1764
+ destination = {
1765
+ type: "backlog",
1766
+ ref: hypothesis,
1767
+ path: "discovery/inbox/hypotheses.yaml",
1768
+ title: String(found.claim ?? hypothesis),
1769
+ text: String(found.claim ?? ""),
1770
+ blocks: [String(found.claim ?? "")],
1771
+ };
1772
+ subtype = "hypothesis";
1773
+ }
1774
+ }
1775
+ }
1776
+ if (!destination)
1777
+ return invalid("That primary-home destination does not exist on this machine; no route was changed.", json, "E_DISCOVERY_VALIDATION");
1778
+ const previous = eventHome(located.record.primary_home);
1779
+ located.record.primary_home = {
1780
+ status: "moved",
1781
+ type: destination.type,
1782
+ subtype,
1783
+ ref: destination.ref,
1784
+ path: destination.path,
1785
+ title: destination.title,
1786
+ basis: "human_move",
1787
+ placement_confidence: "high",
1788
+ why: `Moved by ${by}.`,
1789
+ candidates: [],
1790
+ by,
1791
+ actor: "human",
1792
+ at: now,
1793
+ };
1794
+ tieEvent = {
1795
+ type: "com.adaptivespec.discovery.home_moved",
1796
+ data: { target, previous, current: eventHome(located.record.primary_home) },
1797
+ };
1798
+ reassignNotes.push("Primary home moved. Also-related ties were left unchanged.");
1799
+ }
1800
+ else if (action === "undo-home") {
1801
+ if (located.key !== "findings" || path.basename(path.dirname(located.file)) !== "inbox")
1802
+ return invalid("Undo changes an inbox finding's primary home; the target must be an inbox finding ID.", json, "E_DISCOVERY_INVALID_TRANSITION");
1803
+ if (!located.record.primary_home)
1804
+ return invalid("This finding has no primary-home receipt to undo.", json, "E_DISCOVERY_INVALID_TRANSITION");
1805
+ const previous = eventHome(located.record.primary_home);
1806
+ delete located.record.primary_home;
1807
+ tieEvent = { type: "com.adaptivespec.discovery.home_undone", data: { target, previous } };
1808
+ reassignNotes.push("Primary home undone. The finding can be routed again; Also-related ties were left unchanged.");
1809
+ }
1810
+ else if (action === "reassign") {
1811
+ // A human correction to the pointer (R-003): moves or clears assigned_to
1812
+ // with human attribution. Never touches the destination — pointers move
1813
+ // freely; only filings write destination artifacts. `--bug` is the one
1814
+ // exception by owner ruling (bug-owner-autolog-v0 D-002): it runs the
1815
+ // dedupe-first bug-log engine and points the record at the GitHub issue.
1816
+ if (located.key !== "findings" || path.basename(path.dirname(located.file)) !== "inbox")
1817
+ return invalid("Reassign moves an inbox finding's ties; the target must be an inbox finding ID.", json, "E_DISCOVERY_INVALID_TRANSITION");
1818
+ migrateRecordTies(root, located.record);
1819
+ const studyId = flagValue(args, "--study");
1820
+ const packetName = flagValue(args, "--packet");
1821
+ const none = hasFlag(args, "--none");
1822
+ const bug = hasFlag(args, "--bug");
1823
+ if ([studyId, packetName, none ? "none" : undefined, bug ? "bug" : undefined].filter(Boolean).length !== 1) {
1824
+ return invalid(`Reassign takes exactly one destination: foo discovery review ${target} reassign --study <id> --by ${by}, --packet <packet-name>, --bug to file it in the owner bug queue, or --none to clear every tie.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1825
+ }
1826
+ if (located.record.attached_to)
1827
+ return invalid(`This finding is filed in ${String(located.record.attached_to)}; ties cannot be replaced while a copy is filed. Detach first (foo discovery review ${target} detach --by ${by}), then rerun reassign.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1828
+ let newTie = null;
1829
+ if (packetName) {
1830
+ if (!specPacketDir(root, packetName))
1831
+ return invalid(`Spec packet ${packetName} was not found under specs/. Reassign never creates a packet; create it first (foo asp init specs/${packetName}), then rerun foo discovery review ${target} reassign --packet ${packetName} --by ${by}.`, json, "E_DISCOVERY_VALIDATION");
1832
+ newTie = { type: "spec", ref: packetName, url: tieUrlFor(root, "spec", packetName), by, actor: "human", at: now };
1833
+ }
1834
+ else if (studyId) {
1835
+ if (!locateStudy(root, studyId))
1836
+ return invalid(`Study ${studyId} was not found. Check the study id, then rerun foo discovery review ${target} reassign --study ${studyId} --by ${by}.`, json, "E_DISCOVERY_VALIDATION");
1837
+ newTie = { type: "study", ref: studyId, url: tieUrlFor(root, "study", studyId), by, actor: "human", at: now };
1838
+ }
1839
+ else if (bug) {
1840
+ const engine = runSignalBugEngine(root, located.record);
1841
+ if (!engine.ok)
1842
+ return invalid(engine.message, json, "E_DISCOVERY_VALIDATION");
1843
+ newTie = { type: "bug", ref: engine.tie.ref, url: engine.tie.url, by, actor: "human", at: now };
1844
+ reassignIssueUrl = engine.tie.url;
1845
+ reassignNotes.push(engine.note);
1846
+ }
1847
+ const previous = recordTies(located.record);
1848
+ // Departing bug ties never close their issues — say where each lives on.
1849
+ for (const prior of previous) {
1850
+ if (prior.type === "bug" && (!newTie || tieKey(prior) !== tieKey(newTie))) {
1851
+ reassignNotes.push(`The GitHub issue behind ${tieKey(prior)} is NOT closed by this; it lives on${prior.url ? ` at ${prior.url}` : " in the owner bug queue"}.`);
1852
+ }
1853
+ }
1854
+ reassignFrom = previous.length ? previous.map(tieKey).join(",") : null;
1855
+ reassignTo = newTie ? tieKey(newTie) : null;
1856
+ located.record.ties = newTie ? [newTie] : [];
1857
+ // A tie clears dismissal (R-004); --none clears every tie and is not a tie,
1858
+ // so it must not revive a dismissed record.
1859
+ if (newTie)
1860
+ undismissOnTie(path.dirname(located.file), located.record, target, by, reassignTo, now, reassignNotes);
1861
+ reassignNotes.push(`reassign is deprecated: ties are 1-to-many now — prefer foo discovery review ${target} tie/untie.`);
1862
+ }
1863
+ else if (action === "tie") {
1864
+ // R-003: add one typed link in the owner's language. Repeat to accumulate.
1865
+ if (located.key !== "findings" || path.basename(path.dirname(located.file)) !== "inbox")
1866
+ return invalid("Tie links an inbox finding to what it is about; the target must be an inbox finding ID.", json, "E_DISCOVERY_INVALID_TRANSITION");
1867
+ migrateRecordTies(root, located.record);
1868
+ const specName = flagValue(args, "--spec");
1869
+ const researchDoc = flagValue(args, "--research");
1870
+ const ideaDoc = flagValue(args, "--idea");
1871
+ const bug = hasFlag(args, "--bug");
1872
+ if ([specName, researchDoc, ideaDoc, bug ? "bug" : undefined].filter(Boolean).length !== 1) {
1873
+ return invalid(`Tie takes exactly one destination per call: foo discovery review ${target} tie --bug --by ${by}, --spec <name>, --research <doc>, or --idea <doc>. Run it again to add another tie.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1874
+ }
1875
+ let newTie;
1876
+ if (specName) {
1877
+ if (!specPacketDir(root, specName))
1878
+ return invalid(`Spec ${specName} was not found under specs/. Tie never creates one; create it first (foo asp init specs/${specName}), then rerun foo discovery review ${target} tie --spec ${specName} --by ${by}.`, json, "E_DISCOVERY_VALIDATION");
1879
+ newTie = { type: "spec", ref: specName, url: tieUrlFor(root, "spec", specName), by, actor: "human", at: now };
1880
+ }
1881
+ else if (researchDoc) {
1882
+ const ref = resolveDocRef(root, "research", researchDoc);
1883
+ if (!ref)
1884
+ return invalid(`Research document ${researchDoc} was not found under research/. Check the name, then rerun foo discovery review ${target} tie --research <doc> --by ${by}.`, json, "E_DISCOVERY_VALIDATION");
1885
+ newTie = { type: "research", ref, url: ref, by, actor: "human", at: now };
1886
+ }
1887
+ else if (ideaDoc) {
1888
+ const ref = resolveDocRef(root, "ideas", ideaDoc);
1889
+ if (!ref)
1890
+ return invalid(`Idea document ${ideaDoc} was not found under ideas/. Check the name, then rerun foo discovery review ${target} tie --idea <doc> --by ${by}.`, json, "E_DISCOVERY_VALIDATION");
1891
+ newTie = { type: "idea", ref, url: ref, by, actor: "human", at: now };
1892
+ }
1893
+ else {
1894
+ const engine = runSignalBugEngine(root, located.record);
1895
+ if (!engine.ok)
1896
+ return invalid(engine.message, json, "E_DISCOVERY_VALIDATION");
1897
+ newTie = { type: "bug", ref: engine.tie.ref, url: engine.tie.url, by, actor: "human", at: now };
1898
+ reassignNotes.push(engine.note);
1899
+ }
1900
+ const ties = recordTies(located.record);
1901
+ if (ties.some((tie) => tieKey(tie) === tieKey(newTie))) {
1902
+ return invalid(`This finding is already tied to ${tieKey(newTie)}. Remove it first (foo discovery review ${target} untie --${newTie.type}${newTie.type === "bug" ? "" : ` ${newTie.ref}`} --by ${by}) or tie a different destination.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1903
+ }
1904
+ ties.push(newTie);
1905
+ undismissOnTie(path.dirname(located.file), located.record, target, by, tieKey(newTie), now, reassignNotes);
1906
+ tieEvent = { type: "com.adaptivespec.discovery.tied", data: { target, type: newTie.type, ref: newTie.ref, url: newTie.url } };
1907
+ }
1908
+ else if (action === "untie") {
1909
+ // R-003 mirror: remove one tie. Bugs live on in GitHub; filed copies need
1910
+ // detach first; the record's review state is never re-dismissed (R-004).
1911
+ if (located.key !== "findings" || path.basename(path.dirname(located.file)) !== "inbox")
1912
+ return invalid("Untie removes one of an inbox finding's ties; the target must be an inbox finding ID.", json, "E_DISCOVERY_INVALID_TRANSITION");
1913
+ migrateRecordTies(root, located.record);
1914
+ const ties = recordTies(located.record);
1915
+ const specName = flagValue(args, "--spec");
1916
+ const researchDoc = flagValue(args, "--research");
1917
+ const ideaDoc = flagValue(args, "--idea");
1918
+ const bug = hasFlag(args, "--bug");
1919
+ const rawBugRef = flagValue(args, "--bug");
1920
+ const bugRef = rawBugRef && !rawBugRef.startsWith("--") ? rawBugRef : undefined;
1921
+ if ([specName, researchDoc, ideaDoc, bug ? "bug" : undefined].filter(Boolean).length !== 1) {
1922
+ return invalid(`Untie takes exactly one tie: foo discovery review ${target} untie --bug [<issue>] --by ${by}, --spec <name>, --research <doc>, or --idea <doc>.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1923
+ }
1924
+ let removed;
1925
+ if (bug) {
1926
+ const bugTies = ties.filter((tie) => tie.type === "bug" && (!bugRef || tie.ref === bugRef));
1927
+ if (bugTies.length === 0)
1928
+ return invalid(`This finding has no ${bugRef ? `bug:${bugRef}` : "bug"} tie; nothing to untie. Current ties: ${ties.map(tieKey).join(", ") || "none"}.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1929
+ if (bugTies.length > 1)
1930
+ return invalid(`This finding has several bug ties (${bugTies.map(tieKey).join(", ")}); name one: foo discovery review ${target} untie --bug <issue> --by ${by}.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1931
+ removed = bugTies[0];
1932
+ reassignNotes.push(`The GitHub issue behind ${tieKey(removed)} is NOT closed by this; it lives on${removed.url ? ` at ${removed.url}` : " in the owner bug queue"}.`);
1933
+ }
1934
+ else {
1935
+ const wanted = [];
1936
+ if (specName)
1937
+ wanted.push({ type: "spec", ref: specName });
1938
+ if (researchDoc)
1939
+ wanted.push({ type: "research", ref: resolveDocRef(root, "research", researchDoc) ?? researchDoc });
1940
+ if (ideaDoc)
1941
+ wanted.push({ type: "idea", ref: resolveDocRef(root, "ideas", ideaDoc) ?? ideaDoc });
1942
+ removed = ties.find((tie) => wanted.some((candidate) => tieKey(candidate) === tieKey(tie)));
1943
+ if (!removed)
1944
+ return invalid(`This finding is not tied to ${wanted.map(tieKey).join(" or ")}; nothing to untie. Current ties: ${ties.map(tieKey).join(", ") || "none"}.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1945
+ if (removed.type === "spec" && located.record.attached_to === `packet:${removed.ref}`) {
1946
+ return invalid(`The copy of this finding is filed in specs/${removed.ref}/evidence.yaml; detach owns that reversal. Run foo discovery review ${target} detach --by ${by} first, then rerun untie.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1947
+ }
1948
+ }
1949
+ located.record.ties = ties.filter((tie) => tie !== removed);
1950
+ tieEvent = { type: "com.adaptivespec.discovery.untied", data: { target, type: removed.type, ref: removed.ref, url: removed.url } };
1951
+ }
1952
+ else if (action === "restore") {
1953
+ // discovery-restore-verb-v0 R-001: the destination-free way back from
1954
+ // archived. Ties are not read, added, or removed — a restored record
1955
+ // keeps every tie it had. ("Archived" is the surface word; the stored
1956
+ // state stays `dismissed` — plumbing invisible.)
1957
+ if (located.key !== "findings" || path.basename(path.dirname(located.file)) !== "inbox")
1958
+ return invalid("Restore brings an archived inbox finding back to unreviewed; the target must be an inbox finding ID.", json, "E_DISCOVERY_INVALID_TRANSITION");
1959
+ // A filed record is refused regardless of review_state: attach → dismiss
1960
+ // leaves both attached_to set AND review_state dismissed, and restoring it
1961
+ // would strand the filed copy while the record claims to be unreviewed.
1962
+ if (located.record.attached_to) {
1963
+ return invalid(`This finding is filed in ${String(located.record.attached_to)} — detach owns that reversal. Run foo discovery review ${target} detach --by ${by} first; restore only brings archived, unfiled records back to the queue.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1964
+ }
1965
+ if (located.record.review_state !== "dismissed") {
1966
+ const state = String(located.record.review_state ?? "unknown");
1967
+ const detail = state === "needs_review"
1968
+ ? "it is already unreviewed and waiting in the queue"
1969
+ : `its current state is ${state}`;
1970
+ return invalid(`This finding is not archived — ${detail}. Restore only brings archived records back; to archive one first, run foo discovery review ${target} dismiss --by ${by} --within inbox.`, json, "E_DISCOVERY_INVALID_TRANSITION");
1971
+ }
1972
+ Object.assign(located.record, { review_state: "needs_review", disposition: { by, at: now, action: "restore" } });
1973
+ tieEvent = { type: "com.adaptivespec.discovery.restored", data: { target, from: "dismissed", to: "needs_review" } };
1974
+ reassignNotes.push("Restored: this record is back in the unreviewed queue. Its ties are unchanged.");
1975
+ }
1976
+ else if (action === "select-source") {
1977
+ const fingerprint = flagValue(args, "--fingerprint");
1978
+ const candidates = Array.isArray(located.record.source_fingerprints) ? located.record.source_fingerprints : [];
1979
+ if (located.key !== "proposals" || located.record.rationale !== "probable_duplicate_source" || !fingerprint || !candidates.includes(fingerprint)) {
1980
+ return invalid("Source selection requires a duplicate-source proposal and one listed --fingerprint.", json, "E_DISCOVERY_INVALID_TRANSITION");
1981
+ }
1982
+ Object.assign(located.record, { state: "promoted", selected_source_fingerprint: fingerprint, disposition: { by, at: now, action: "select-source" } });
1983
+ }
1984
+ else {
1985
+ return invalid("Review action must be complete, tie, untie, attach, detach, restore, move-home, undo-home, reassign, promote, dismiss, or select-source.", json, "E_DISCOVERY_INVALID_TRANSITION");
1986
+ }
1987
+ writeFileSync(located.file, stringify(located.doc));
1988
+ const dir = path.dirname(located.file);
1989
+ if (action === "reassign")
1990
+ appendHumanEvent(dir, "com.adaptivespec.discovery.reassigned", `${located.key}:${target}`, by, { target, action, from: reassignFrom, to: reassignTo, ...(reassignIssueUrl ? { issue_url: reassignIssueUrl } : {}) }, now);
1991
+ else if (tieEvent)
1992
+ appendHumanEvent(dir, tieEvent.type, `${located.key}:${target}`, by, tieEvent.data, now);
1993
+ else
1994
+ appendHumanEvent(dir, `com.adaptivespec.discovery.${action}`, `${located.key}:${target}`, by, { target, action }, now);
1995
+ return outcome(result("ok", { writes: [relative(root, located.file), relative(root, path.join(dir, "events.jsonl")), ...extraWrites], next_actions: reassignNotes, coverage: { human_transition: true, canonical_destination_edited: false } }), json);
1996
+ }
1997
+ function revokeFingerprint(root, fingerprint, by, now, json) {
1998
+ if (!/^sha256:[a-f0-9]{64}$/i.test(fingerprint))
1999
+ return invalid("Source revocation requires a full sha256 fingerprint.", json, "E_DISCOVERY_VALIDATION", fingerprint);
2000
+ const discoveryRoot = path.join(root, "discovery");
2001
+ const dirs = readdirSync(discoveryRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(discoveryRoot, entry.name));
2002
+ const writes = [];
2003
+ let removedFindings = 0;
2004
+ for (const dir of dirs) {
2005
+ const findingsPath = path.join(dir, "findings.yaml");
2006
+ const proposalsPath = path.join(dir, "proposals.yaml");
2007
+ if (!existsSync(findingsPath) || !existsSync(proposalsPath))
2008
+ continue;
2009
+ const findingsDoc = parse(readFileSync(findingsPath, "utf8"));
2010
+ const findings = findingsDoc.findings ?? [];
2011
+ const removedIds = new Set(findings.filter((finding) => finding.source_fingerprint === fingerprint).map((finding) => String(finding.finding_id)));
2012
+ if (removedIds.size === 0)
2013
+ continue;
2014
+ removedFindings += removedIds.size;
2015
+ findingsDoc.findings = findings.filter((finding) => finding.source_fingerprint !== fingerprint);
2016
+ const proposalsDoc = parse(readFileSync(proposalsPath, "utf8"));
2017
+ proposalsDoc.proposals = (proposalsDoc.proposals ?? []).filter((proposal) => {
2018
+ const sourceIds = Array.isArray(proposal.source_finding_ids) ? proposal.source_finding_ids.map(String) : [];
2019
+ return !sourceIds.some((id) => removedIds.has(id));
2020
+ });
2021
+ writeFileSync(findingsPath, stringify(findingsDoc));
2022
+ writeFileSync(proposalsPath, stringify(proposalsDoc));
2023
+ const reportPath = path.join(dir, "report.md");
2024
+ if (existsSync(reportPath)) {
2025
+ writeFileSync(reportPath, "# Discovery report\n\nNeeds regeneration after source revocation. Active structured findings remain available for human review. Prior Git history is not erased.\n");
2026
+ writes.push(relative(root, reportPath));
2027
+ }
2028
+ appendHumanEvent(dir, "com.adaptivespec.discovery.source_revoked", `source:${fingerprint}`, by, { source_fingerprint: fingerprint, active_findings_removed: removedIds.size }, now);
2029
+ writes.push(relative(root, findingsPath), relative(root, proposalsPath), relative(root, path.join(dir, "events.jsonl")));
2030
+ }
2031
+ const localRemoved = purgeDiscoveryLocalFingerprint(localRoot(root), fingerprint);
2032
+ if (removedFindings === 0 && localRemoved.length === 0)
2033
+ return invalid("Discovery source fingerprint was not found.", json, "E_DISCOVERY_VALIDATION", fingerprint);
2034
+ return outcome(result("ok", { writes, next_actions: ["Active evidence for this source was removed and future ingestion is blocked. Git history was not erased."], coverage: { active_findings_removed: removedFindings, local_records_removed: localRemoved.length, git_history_erased: false } }), json);
2035
+ }
2036
+ async function revokeCommand(root, args, json, now) {
2037
+ const target = args[1];
2038
+ const by = flagValue(args, "--by");
2039
+ if (!target || !by?.trim())
2040
+ return invalid("Discovery revoke requires a study and --by <person>.", json, "E_DISCOVERY_INVALID_TRANSITION", target);
2041
+ if (target.startsWith("sha256:"))
2042
+ return revokeFingerprint(root, target, by, now, json);
2043
+ let dir;
2044
+ try {
2045
+ dir = study(root, target);
2046
+ }
2047
+ catch {
2048
+ return invalid("Discovery revoke target was not found.", json, "E_DISCOVERY_VALIDATION", target);
2049
+ }
2050
+ const run = loadDiscoveryRun(dir);
2051
+ if (run.state === "revoked")
2052
+ return outcome(result("clean_no_op", { study_id: target, coverage: { revoked: true } }), json);
2053
+ const revoked = revokeSource(dir, { ingress_path: "mcp", provider: run.source.provider, connection_id: run.source.connection_alias }, { packet: run.study_id, by, at: now });
2054
+ // #898: a hosted target registered from this study gets the same
2055
+ // privilege-reducing discovery_suspend_target fan-out `foo source revoke`
2056
+ // sends (nango-discovery-connector-v0 R-008), bound to this revoke's event
2057
+ // digest and limited to this study's own target. The local revoke above is
2058
+ // already applied; a service failure records revocation_pending, never a
2059
+ // claimed suspension.
2060
+ const hosted = await fanOutDiscoveryTargetSuspension({
2061
+ localRoot: root,
2062
+ provider: run.source.provider,
2063
+ connection_id: run.source.connection_alias,
2064
+ target_id: run.study_id,
2065
+ revocation_event_digest: revoked.event_digest,
2066
+ requested_by: by,
2067
+ at: now,
2068
+ });
2069
+ const findings = path.join(dir, "findings.yaml");
2070
+ const proposals = path.join(dir, "proposals.yaml");
2071
+ const report = path.join(dir, "report.md");
2072
+ writeFileSync(findings, 'schema_version: "0.1"\nfindings: []\n');
2073
+ writeFileSync(proposals, 'schema_version: "0.1"\nproposals: []\n');
2074
+ writeFileSync(report, "# Discovery report\n\nRevoked. Active derived evidence was removed from the working record. Prior Git history is not erased.\n");
2075
+ // The purge removes this study's local state, which includes its hosted-target
2076
+ // registration. While a suspension is still pending, the registration is kept
2077
+ // (with its revocation_pending mark) so a later `foo source revoke` can retry
2078
+ // the fan-out instead of finding nothing (#898).
2079
+ const registration = path.join(localRoot(root), localTargetName(run.study_id), "target.json");
2080
+ const keptRegistration = hosted.pending > 0 && existsSync(registration) ? readFileSync(registration) : null;
2081
+ purgeDiscoveryLocalState(localRoot(root), run.study_id);
2082
+ if (keptRegistration) {
2083
+ mkdirSync(path.dirname(registration), { recursive: true });
2084
+ writeFileSync(registration, keptRegistration, { mode: 0o600 });
2085
+ }
2086
+ const writes = [relative(root, findings), relative(root, proposals), relative(root, report), ...transitionDiscoveryRun(dir, "revoked", { type: "human", id: by }, now)];
2087
+ const next = ["Active evidence is removed and future reads are blocked. Git history was not erased."];
2088
+ if (hosted.pending > 0)
2089
+ next.push(`Hosted suspension is REVOCATION_PENDING for ${hosted.pending} registered target(s): the service has not acknowledged it, and hosted reads may continue until it does. Rerun \`foo source revoke\` to retry.`);
2090
+ return outcome(result("ok", { study_id: target, writes, next_actions: next, coverage: { active_evidence_removed: true, git_history_erased: false, hosted } }), json);
2091
+ }
2092
+ export async function runDiscoveryCommand(root, args, stdin = "", now = new Date().toISOString()) {
2093
+ const json = hasFlag(args, "--json");
2094
+ const command = args[0];
2095
+ try {
2096
+ if (command === "plan")
2097
+ return planCommand(root, args, json);
2098
+ if (command === "match")
2099
+ return matchCommand(root, args, stdin, json, now);
2100
+ if (command === "apply")
2101
+ return await applyCommand(root, args, stdin, json, now);
2102
+ if (command === "validate") {
2103
+ const target = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
2104
+ const validation = validateDiscoveryPath(root, target);
2105
+ return outcome(result(validation.ok ? "ok" : "invalid", { errors: validation.errors.map((message) => ({ code: "E_DISCOVERY_VALIDATION", message })), handoff: validation.ok ? null : "Fix discovery validation errors before using evidence.", coverage: { studies: validation.studies, inbox: validation.inbox } }), json);
2106
+ }
2107
+ if (command === "tie" || command === "assign")
2108
+ return autoTieCommand(root, args, json, now);
2109
+ if (command === "route")
2110
+ return routeCommand(root, args, stdin, json, now);
2111
+ if (command === "contact")
2112
+ return contactCommand(root, args, json, now);
2113
+ if (command === "review")
2114
+ return reviewCommand(root, args, json, now);
2115
+ if (command === "revoke")
2116
+ return await revokeCommand(root, args, json, now);
2117
+ if (command === "proof-opened") {
2118
+ const studyId = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
2119
+ const fingerprint = flagValue(args, "--fingerprint");
2120
+ if (!studyId || !fingerprint)
2121
+ return invalid("Runtime proof-opened requires a study and source fingerprint.", json, "E_DISCOVERY_RESEARCH_PROOF", studyId);
2122
+ try {
2123
+ markDiscoveryProofOpened({ study_dir: discoveryTargetDir(root, studyId), local_root: localRoot(root), study_id: studyId, source_fingerprint: fingerprint, now });
2124
+ return outcome(result("ok", { study_id: studyId, next_actions: ["Synthesize the sanitized evidence envelope in memory, then apply it before proof expiry."], coverage: { transcript_open_attested: true } }), json);
2125
+ }
2126
+ catch {
2127
+ return outcome(result("needs_research_proof", { study_id: studyId, errors: [{ code: "E_DISCOVERY_RESEARCH_PROOF", message: "The transcript-open attestation did not match an active approved proof." }], handoff: "Repeat strict matching; do not reuse the transcript or a stale receipt." }), json);
2128
+ }
2129
+ }
2130
+ if (command === "sync") {
2131
+ const retention = purgeDiscoveryRetention(root, now);
2132
+ const plan = planDiscoverySync(root, now);
2133
+ const syncErrors = [...plan.errors, ...retention.failures];
2134
+ const status = retention.failures.length ? "invalid" : plan.lanes.length ? "ok" : plan.errors.length ? "invalid" : "clean_no_op";
2135
+ return outcome(result(status, {
2136
+ writes: retention.removed.map((removedPath) => ({ path: removedPath, action: "removed" })),
2137
+ errors: syncErrors.map((message) => ({ code: "E_DISCOVERY_VALIDATION", message })),
2138
+ handoff: syncErrors.length ? "Fix invalid discovery lanes or retained local state; valid providers remain isolated." : null,
2139
+ next_actions: plan.lanes.map((lane) => `${lane.lane_id}: ${lane.allowed_actions.length ? lane.allowed_actions.join(" → ") : "no automated action"}`),
2140
+ coverage: { lanes: plan.lanes.length, connector_reads: plan.connector_reads, provider_failures_isolated: true, retention_removed: retention.removed.length, retention_retained: retention.retained.length, writes_allowed: ["discovery/", ".foo/discovery/"] },
2141
+ }), json);
2142
+ }
2143
+ return invalid("Try: plan | match | apply | validate | tie | contact | review | revoke | sync", json);
2144
+ }
2145
+ catch {
2146
+ return invalid("Discovery failed closed without exposing private source content.", json);
2147
+ }
2148
+ }