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,1036 @@
1
+ import { randomBytes } from "node:crypto";
2
+ // Research gate — route fan-out web research through `foo research`.
3
+ //
4
+ // Why this exists: `foo research` was documented as the research front door and
5
+ // listed under Signals ("advisory; never an automatic stop"). An agent with
6
+ // AGENTS.md in context, a SessionStart reminder, and a memory note all pointing
7
+ // at it still spawned four raw web-research subagents on the first try — not to
8
+ // evade the rule, but because the reflex fired before the rule did. Advisory
9
+ // loses to reflex. The gates in this repo that hold are the ones that deny.
10
+ //
11
+ // The policy lives here, in foo, so any harness can enforce it. `.claude/settings.json`
12
+ // carries only a thin PreToolUse shim that calls `foo research gate`.
13
+ //
14
+ // Scope (deliberately narrow): fan-out — delegating to subagents to go read the web
15
+ // and report findings. A single WebSearch to check a package version or a stack trace
16
+ // is not research and stays free. The tell is delegation PLUS the web, not the web
17
+ // alone. A determined agent can still hand-roll research one search at a time; that is
18
+ // accepted. The target is the reflex, and the reflex is a fan-out.
19
+ import { existsSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
20
+ import path from "node:path";
21
+ /**
22
+ * A tool name reduced to its bare letters — lowercased, with the separators harnesses
23
+ * pick differently (`_`, `-`, spaces) removed. `web_fetch`, `WebFetch` and `web-fetch`
24
+ * are the SAME tool wearing three harnesses' punctuation, and a gate that matches raw
25
+ * strings enforces on one and silently allows the other two — the exact silent-allow this
26
+ * whole boundary exists to prevent. Compare canonically so the name the harness sends is
27
+ * matched however it is spelled.
28
+ */
29
+ const canonicalTool = (name) => (name ?? "").trim().toLowerCase().replace(/[\s_-]+/g, "");
30
+ /**
31
+ * Tools that delegate to a subagent. Harnesses disagree on the name — Claude/Cursor say
32
+ * `Task` or `Agent`, Codex fires on `spawn_agent` — so these are stored canonically and
33
+ * matched through `canonicalTool`.
34
+ */
35
+ const DELEGATION_TOOLS = new Set(["agent", "task", "spawnagent"]);
36
+ /**
37
+ * The subagent is being sent to the open web, and no amount of repo scope excuses it.
38
+ *
39
+ * This list used to name only the TOOLS ("websearch", "webfetch"). That was a guess at
40
+ * phrasing, and it lost: a fan-out that said "Fetch PRIMARY sources (vendor docs, GitHub
41
+ * repos, changelogs). Do NOT trust search snippets." matched nothing here and sailed
42
+ * through. It was unmistakably web research; it just never said "web".
43
+ *
44
+ * So the markers name the WEB ITSELF. But a marker list has a second failure mode, and foo
45
+ * hit it too: half the old list was ordinary product vocabulary. Those words moved to
46
+ * WEAK_WEB_MARKERS below. What stays here is language that has no innocent reading in a
47
+ * delegation prompt — you do not "scrape" or consult a "vendor doc" while searching src/.
48
+ */
49
+ const STRONG_WEB_MARKERS = [
50
+ "websearch",
51
+ "webfetch",
52
+ "web search",
53
+ "web_search",
54
+ "search the web",
55
+ "browse the web",
56
+ "search online",
57
+ "google for",
58
+ // verbs that only ever mean the open web
59
+ "crawl",
60
+ "scrape",
61
+ "look up online",
62
+ // objects that only ever live out there
63
+ "http://",
64
+ "https://",
65
+ "pricing page",
66
+ // how people describe reading it
67
+ "primary source",
68
+ "vendor doc",
69
+ "official doc",
70
+ "search snippet",
71
+ ];
72
+ /**
73
+ * Words that MIGHT mean the web, and in this repo usually do not.
74
+ *
75
+ * `docs`, `published`, `changelog`, `fetch`, `url` are everyday product vocabulary. They sat
76
+ * in the strong list, where a single hit denied outright — and the REPO_MARKERS rescue below
77
+ * was unreachable, because the web check returned first. So the gate refused a spec-swarm
78
+ * lens whose prompt named `src/`, `this repo` and `grep`, on the strength of the sentence
79
+ * "productfoo's published surface is ~8 commands". One ordinary word beat three explicit repo
80
+ * signals, and the comment above REPO_MARKERS promised the opposite of what the code did.
81
+ *
82
+ * These still deny on their own — an unscoped findings run that says "read the changelog" is
83
+ * presumed web, as before. They simply lose to a prompt that says where it is going.
84
+ */
85
+ const WEAK_WEB_MARKERS = [
86
+ "fetch",
87
+ "url",
88
+ ".com",
89
+ ".io",
90
+ "docs",
91
+ "documentation",
92
+ "changelog",
93
+ "release notes",
94
+ "blog",
95
+ "spec at",
96
+ "publicly",
97
+ "published",
98
+ ];
99
+ /**
100
+ * A web name mentioned ONLY to forbid it.
101
+ *
102
+ * `do not use WebSearch` is the strongest evidence a prompt has that it stays off the web,
103
+ * and the gate read it as proof of the opposite: it matched the tool name and never saw the
104
+ * `not`. Seven of productfoo's own spec-swarm lenses were denied for saying it — the instruction
105
+ * that proves a prompt is safe was the instruction that blocked it. An agent cannot reassure
106
+ * a gate that reads reassurance as intent, so it learns to stop saying true things.
107
+ *
108
+ * Only NAMES are negatable — the tools, and the web itself. The verbs (`crawl`, `scrape`,
109
+ * `fetch`) are deliberately absent: they name a behaviour, and "do not use WebSearch, crawl
110
+ * the vendor docs instead" must keep its `crawl`.
111
+ *
112
+ * Known hole, named rather than rounded up: negation is scoped to the clause, so a single
113
+ * clause that negates one thing and does another — "do not summarize, use WebSearch" — has
114
+ * its tool name stripped. It survives only if the clause carries no other web signal, which
115
+ * no real sweep manages; the Nth-read trip and `foo research validate` both still sit behind
116
+ * this. Widening it further costs more false denials than it buys.
117
+ */
118
+ const NEGATION_TRIGGER = /\b(?:do not|don'?t|does not|doesn'?t|never|avoid|without|must not|cannot|can'?t)\b/i;
119
+ const NEGATABLE_WEB = /\b(?:websearch|web[ _-]?search|webfetch|web[ _-]?fetch|the web|the internet|the network|online)\b/gi;
120
+ /** Drop web names that appear inside a clause which forbids them. */
121
+ const dropNegatedWeb = (text) => text
122
+ .split(/([.!?;\n])/)
123
+ .map((clause) => (NEGATION_TRIGGER.test(clause) ? clause.replace(NEGATABLE_WEB, " ") : clause))
124
+ .join("");
125
+ /**
126
+ * A spawn that cannot reach the web is not a web fan-out.
127
+ *
128
+ * The fifth arrival of the false-positive lesson (#521): a synthetic-customer
129
+ * rehearsal spawned ONE subagent to role-play a persona in isolation — the
130
+ * skill's anti-sycophancy rule — with "Do not use any tools" in the prompt and
131
+ * no web anywhere. It asked for hypothesis verdicts, named no location (a
132
+ * persona has no file path), and the default-deny branch presumed the web. The
133
+ * one instruction that proves the spawn stays offline was invisible to the
134
+ * gate, so the isolation the skill designed for moved inline and was lost.
135
+ *
136
+ * Two declarations count, and both are DECLARATIONS the caller makes, not
137
+ * phrasing the gate guesses at: a structured tool surface that names no
138
+ * web-capable tool, and a prompt-level prohibition on tool use entirely. A
139
+ * prompt that positively names a web tool has already returned true above —
140
+ * a spawn that says both "no tools" and "use WebSearch" is a contradiction,
141
+ * and contradictions block.
142
+ *
143
+ * Why this widens nothing: the prompt line is advisory to the MODEL, but the
144
+ * web is enforced at the TOOL — a subagent that disobeys and calls WebSearch
145
+ * anyway hits this same hook on that call, where the pending marker and the
146
+ * Nth-read trip still hold. The spawn-level check is a pre-filter; the tool
147
+ * level is the promise.
148
+ */
149
+ const NO_TOOLS_DECLARATIONS = [
150
+ "do not use any tools",
151
+ "don't use any tools",
152
+ "do not use tools",
153
+ "don't use tools",
154
+ "use no tools",
155
+ "without using any tools",
156
+ "without using tools",
157
+ "no tool use",
158
+ "no tool calls",
159
+ "tool use is not allowed",
160
+ "you have no tools",
161
+ ];
162
+ /**
163
+ * The sixth arrival of the same lesson (#614), and the sharpest, because this
164
+ * time the gate refused productfoo's own emitted contract.
165
+ *
166
+ * `foo ideate` at standard depth MANDATES five ideator cells, blind to each
167
+ * other — blindness is only achievable by spawning them separately. Every cell
168
+ * read two local files and named no web tool. Four ran; the fifth was denied,
169
+ * because the ideate contract's own vocabulary ("dissent must **cite** the
170
+ * observation", "the evidence **brief**") trips FINDINGS_MARKERS, and a cell
171
+ * whose prompt happened not to name a `src/`-shaped path then fell through to
172
+ * default-deny. Which cell died was decided by prompt wording, not by anything
173
+ * about where the cell was pointed — so dogfooding foo tripped a boundary that
174
+ * foo had just told the agent to cross.
175
+ *
176
+ * A declared tool surface would have settled it, but Claude Code's `Task`
177
+ * payload carries no `tools` array, so `declaredToolSurface` is unreachable in
178
+ * the harness where this fired. This is the same escape hatch in a form the
179
+ * prompt can carry.
180
+ *
181
+ * Why this widens nothing, for the same reason as the no-tools case above: the
182
+ * declaration is advisory to the MODEL, and the web is enforced at the TOOL. A
183
+ * subagent that declares local-only and then reaches for WebSearch hits this
184
+ * same hook on that call, where the pending marker and the Nth-read trip still
185
+ * hold. Deciding a spawn is local is a pre-filter; staying local is the promise,
186
+ * and the promise is still checked.
187
+ *
188
+ * Phrases here deliberately contain no negatable web word — `dropNegatedWeb`
189
+ * runs first and would eat "do not read the web" down to "do not read",
190
+ * silently unmatching the declaration the caller made.
191
+ */
192
+ const LOCAL_ONLY_DECLARATIONS = [
193
+ "local sources only",
194
+ "read only local sources",
195
+ "reads only local sources",
196
+ "local files only",
197
+ "read only local files",
198
+ "reads only local files",
199
+ "only sources already on this machine",
200
+ ];
201
+ /** A declared tool surface, when the harness passes one on the spawn. */
202
+ const declaredToolSurface = (input) => {
203
+ const tools = input.tools;
204
+ if (!Array.isArray(tools) || tools.length === 0 || !tools.every((entry) => typeof entry === "string"))
205
+ return null;
206
+ return tools;
207
+ };
208
+ /** Web-capable by NAME, judged canonically and broadly — misjudging a web tool as local is the silent-allow this boundary exists to prevent, so anything that smells of the network stays web-capable. */
209
+ const WEB_CAPABLE_TOOL = /web|browser|fetch|curl|http|search|request/;
210
+ /**
211
+ * A URL pointing at THIS machine. It is the single most important non-web signal there is,
212
+ * and the gate used to read it as the opposite.
213
+ *
214
+ * `http://localhost:4321/` is not the open web. It is the thing you just built, being
215
+ * served to you. But the web markers name "http://" and "url", so an Impeccable design
216
+ * critique — "inspect the live page at http://localhost:4321 and report your findings" —
217
+ * matched "the web" on a loopback address and was refused as a raw research sweep. Nobody
218
+ * researches the open web through 127.0.0.1; a loopback URL is a local artifact wearing a
219
+ * URL's punctuation, and reading it is inspection, not discovery.
220
+ */
221
+ const LOCAL_HOST_PATTERN = /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[?::1\]?|[\w.-]+\.(?:local|localhost|test|internal))$/;
222
+ /** Every URL in the text, so each can be judged local or remote before anything is matched. */
223
+ const URL_PATTERN = /\b(?:https?:\/\/|file:\/\/)[^\s"'`)>\]]+|\blocalhost:\d+[^\s"'`)>\]]*/gi;
224
+ /**
225
+ * A path to a file on disk. The other half of "the prompt named a local target".
226
+ *
227
+ * Deliberately shape-checked rather than existence-checked: it must have a real extension
228
+ * or a trailing slash, so `site/index.html`, `src/`, and `.impeccable/critique/` are paths
229
+ * while `P0/P1/P2` — which a critique prompt says constantly — is not. Existence on disk
230
+ * would be a stronger signal and a more brittle one (a prompt may name a file in another
231
+ * worktree), and this detector only ever moves a call toward "local", never toward "web".
232
+ */
233
+ const LOCAL_PATH_PATTERN = /(?:^|\s|["'`(])((?:\.{0,2}\/)?[\w.@-]+\/[\w.@/-]*(?:\.[a-z]{1,6}|\/))/gi;
234
+ /**
235
+ * A path whose first segment is a domain (`example.com/blog/`, `vendor.io/docs/x.html`)
236
+ * is a web reference wearing a slash, not a file on disk. LOCAL_PATH_PATTERN matches it —
237
+ * a dotted word, a slash, a trailing slash or extension — and stripping it would delete
238
+ * the very `.com`/`.io`/`docs` tokens that mark it as the web. Leave those in `rest`.
239
+ */
240
+ const DOMAIN_FIRST_SEGMENT = /^[\w-]+(?:\.[\w-]+)*\.(?:com|io|net|org|dev|ai|co|app|gov|edu|xyz|sh|so|me|info|page|tools?)$/i;
241
+ const looksLikeDomain = (captured) => DOMAIN_FIRST_SEGMENT.test(captured.replace(/^\.{0,2}\//, "").split("/")[0] ?? "");
242
+ /**
243
+ * A path that is where the run WRITES ("write your findings to docs/report.md", "save the
244
+ * results to out/notes.md") is an output destination, not something being read. It is not a
245
+ * local target the run inspects, and it must never launder a web sweep into local work: the
246
+ * subject of the research is still the open web, the file is just where the prose lands.
247
+ */
248
+ const OUTPUT_DESTINATION = /\b(?:write|writes|writing|wrote|save|saves|saving|saved|output|outputs|dump|dumped|store|stored|export|exported|persist|persisted|append|appended|record|recorded|put|log)\b[^.!?;\n]*\bto\s*["'`(]?$/i;
249
+ /**
250
+ * Take the local out, then look for the web in what is left.
251
+ *
252
+ * This inversion is the whole repair. The gate used to match web markers against the raw
253
+ * prompt, so any mention of a local artifact that happened to be spelled like the web
254
+ * ("http://", "url", "docs") read as a trip to the open web — and `docs/` is a directory
255
+ * in this very repo. Strip what is demonstrably local FIRST and the remaining markers mean
256
+ * what they say: a prompt with a real web signal still has one, and a prompt whose only
257
+ * "web" was its own dev server no longer does.
258
+ */
259
+ function scopeOf(text) {
260
+ let remoteUrl = false;
261
+ let localTarget = false;
262
+ let rest = text.replace(URL_PATTERN, (url) => {
263
+ const host = /^\w+:\/\//.test(url)
264
+ ? (url.split("//")[1] ?? "").split(/[/:?#]/)[0]
265
+ : url.split(":")[0];
266
+ if (url.toLowerCase().startsWith("file://") || LOCAL_HOST_PATTERN.test(host)) {
267
+ localTarget = true;
268
+ }
269
+ else {
270
+ remoteUrl = true;
271
+ }
272
+ return " ";
273
+ });
274
+ rest = rest.replace(LOCAL_PATH_PATTERN, (match, captured, offset, whole) => {
275
+ // A bare domain with a path is the open web, not a file on disk — keep it so its
276
+ // .com/.io/docs tokens survive to be matched as web markers.
277
+ if (looksLikeDomain(captured))
278
+ return match;
279
+ // A path the run WRITES to is an output destination, not an inspection target. Naming
280
+ // one must not flip a web sweep to "local": leave it in place so the default-deny (and
281
+ // any web token it carries, like `docs`) still fires.
282
+ const before = whole.slice(0, offset + match.length - captured.length);
283
+ if (OUTPUT_DESTINATION.test(before))
284
+ return match;
285
+ localTarget = true;
286
+ return match.startsWith(" ") ? " " : "";
287
+ });
288
+ return { rest, remoteUrl, localTarget };
289
+ }
290
+ /**
291
+ * ...unless the subagent is being pointed at THIS repo. Code search is explicitly not
292
+ * gated, and it is the thing most likely to trip a wider web-marker list ("fetch",
293
+ * "docs", and "url" all appear in perfectly ordinary code-reading prompts).
294
+ *
295
+ * A repo-scoped delegation names the repo: a path, a grep, a symbol, the codebase. When one
296
+ * of these is present, the call beats any WEAK_WEB_MARKERS hit and passes as code search.
297
+ *
298
+ * This comment used to describe a rescue the code did not have: the web check returned deny
299
+ * before this list was ever consulted, so a repo-scoped prompt carrying one ordinary word
300
+ * ("published") was refused, and the promise here was decorative. A strong marker or a
301
+ * remote URL still beats repo scope — that direction was always real.
302
+ */
303
+ const REPO_MARKERS = [
304
+ "src/",
305
+ "test/",
306
+ "this repo",
307
+ "the repo",
308
+ "repository at /",
309
+ "codebase",
310
+ "grep",
311
+ "ripgrep",
312
+ "file:line",
313
+ "read the file",
314
+ "local git",
315
+ "on disk",
316
+ ];
317
+ /** Naming a web TOOL is unambiguous — no repo scope can excuse it. */
318
+ const EXPLICIT_WEB_TOOLS = ["websearch", "webfetch", "web_search", "web search"];
319
+ /**
320
+ * The tools that read the open web directly, without delegating. Stored canonically and
321
+ * matched through `canonicalTool`, because the harnesses foo wires the gate into name the
322
+ * same tool differently: native tools use `WebSearch`/`WebFetch`, while connectors may
323
+ * use `google_web_search` or `web_fetch`. The gate must recognize every one — declaring
324
+ * `webRead: true` for a harness (see harnessGates.ts) and then not matching its tool name
325
+ * is a boundary the user is told they have and does not.
326
+ */
327
+ const WEB_TOOLS = new Set(["websearch", "webfetch", "googlewebsearch"]);
328
+ /**
329
+ * The human asked for RESEARCH — not a lookup, not a code question.
330
+ *
331
+ * The fan-out gate catches "spawn agents to sweep the web". It does not catch the other
332
+ * half of the reflex: reading a few pages yourself and writing confident prose. Same
333
+ * ungoverned evidence, no subagent involved. So the routing has to start at the task, not
334
+ * at the tool call.
335
+ *
336
+ * These markers are deliberately about the SHAPE of the ask — survey, compare, evaluate,
337
+ * find out what exists — not about any topic. "Fix this bug", "read src/cli.ts", "what
338
+ * version of node is this" are none of them, and stay free.
339
+ */
340
+ const RESEARCH_REQUEST_MARKERS = [
341
+ "research",
342
+ "look into",
343
+ "find out about",
344
+ "what's the landscape",
345
+ "what is the landscape",
346
+ "state of the art",
347
+ "prior art",
348
+ "competitive",
349
+ "competitors",
350
+ "compare",
351
+ "vs",
352
+ "versus",
353
+ "evaluate options",
354
+ "what are the options",
355
+ "survey",
356
+ "market",
357
+ "best practice",
358
+ "who else is",
359
+ "how do others",
360
+ "what's out there",
361
+ "landscape of",
362
+ ];
363
+ /**
364
+ * ...but a question about THIS codebase is not research, however it is phrased.
365
+ *
366
+ * Two kinds of local signal, and the second is the one #719 was missing.
367
+ *
368
+ * The original list was deictic — it recognised a *pointer* at the code ("this file",
369
+ * "src/"). But most local work never points; it just says what to do. "Fix the #607
370
+ * research pair", "rename the research route marker list", "survey the failing tests"
371
+ * are all ordinary repo tasks that name no path and no `this`, so nothing vetoed them
372
+ * and a topic word carried them straight into the research route.
373
+ *
374
+ * So the second kind is the ACT: a repair or edit performed on the repo. A turn that
375
+ * says fix/debug/rename/close is a turn about work in front of us, whatever nouns it
376
+ * also happens to contain.
377
+ *
378
+ * These match with a leading boundary only (see `has`), which is what we want for verbs:
379
+ * `fix` must also catch fixes/fixing/fixed.
380
+ */
381
+ const LOCAL_QUESTION_MARKERS = [
382
+ // Pointing at the code in front of us.
383
+ "src/",
384
+ "test/",
385
+ "scripts/",
386
+ "skills/",
387
+ "specs/",
388
+ "dist/",
389
+ "docs/",
390
+ "research/",
391
+ "web/",
392
+ ".github/",
393
+ "bin/foo",
394
+ "this repo",
395
+ "this file",
396
+ "this function",
397
+ "this bug",
398
+ "this branch",
399
+ "the codebase",
400
+ "our code",
401
+ "stack trace",
402
+ // Acting on it. A task, not a question about the world.
403
+ "fix",
404
+ "debug",
405
+ "repair",
406
+ "patch",
407
+ "refactor",
408
+ "rename",
409
+ "revert",
410
+ "rebase",
411
+ "implement",
412
+ "triage",
413
+ "reopen",
414
+ "close",
415
+ "update",
416
+ "delete",
417
+ "remove",
418
+ "bump",
419
+ // Local artifacts a dev task operates on.
420
+ "the tests",
421
+ "the test suite",
422
+ "the failing",
423
+ "the build",
424
+ "the diff",
425
+ "the branch",
426
+ "the linter",
427
+ "pull request",
428
+ "implementation",
429
+ ];
430
+ /**
431
+ * A turn that names an issue or PR by number is about work in this repo. `#719` is not
432
+ * a topic anyone researches. Kept as a pattern rather than a marker because the number
433
+ * is the point and no fixed string can carry it.
434
+ */
435
+ const LOCAL_ISSUE_REFERENCE = /(?<![\w-])#\d+\b/;
436
+ /**
437
+ * Match a research marker as a WHOLE word — both ends.
438
+ *
439
+ * `has()` anchors only the leading boundary on purpose: `synthesi` must reach synthesis,
440
+ * `cite` must reach cited. That is right for the fan-out gates that share it, and wrong
441
+ * here, because this list is read on EVERY human turn. Leading-only is why `market` fired
442
+ * inside "run the marketing swarm" — a workflow AGENTS.md actually requires — and why
443
+ * `research` fired inside every mention of the research/ directory, the research route,
444
+ * or a bug titled research (#719).
445
+ *
446
+ * Nothing in this list wants prefix behaviour, so both ends are anchored, and the hyphen
447
+ * is treated as a word character for the same reason `has` does: "go-to-market" is one
448
+ * compound word, not an ask to go read the market.
449
+ */
450
+ const RESEARCH_MARKER_CACHE = new Map();
451
+ function researchMarkerPattern(needle) {
452
+ const cached = RESEARCH_MARKER_CACHE.get(needle);
453
+ if (cached)
454
+ return cached;
455
+ const re = new RegExp(`(?<![\\w-])${escapeRe(needle)}(?![\\w-])`, "i");
456
+ RESEARCH_MARKER_CACHE.set(needle, re);
457
+ return re;
458
+ }
459
+ const hasResearchMarker = (haystack) => RESEARCH_REQUEST_MARKERS.some((n) => researchMarkerPattern(n).test(haystack));
460
+ /**
461
+ * Did the human OPEN with "research ..."?
462
+ *
463
+ * This is the one position where the word is unambiguously the ask rather than a noun in
464
+ * it, so it overrides the local veto: "research how others fix flaky tests" is a research
465
+ * request that happens to contain "fix", and must still route. Anywhere else in the
466
+ * sentence, `research` is just as likely to be naming a file, a route, or a bug title.
467
+ */
468
+ const RESEARCH_IMPERATIVE = /^(?:(?:please|can you|could you|go|now|first|also|and|then)[,\s]+)*research(?![\w-])/i;
469
+ /**
470
+ * Should this human turn be routed through `foo research` before the agent does anything?
471
+ *
472
+ * Used by the UserPromptSubmit adapter. Returns the topic when it fires, so the directive
473
+ * can name the exact command to run.
474
+ */
475
+ /**
476
+ * Not a human turn.
477
+ *
478
+ * `UserPromptSubmit` is documented as the human's own words, and foo believed it. It is
479
+ * not only that: harnesses deliver their own machinery through the same door — background
480
+ * task-notifications, system reminders, tool-result envelopes. Those are not asks, and
481
+ * nobody typed them.
482
+ *
483
+ * This mattered, concretely. A background agent finished, its completion notification
484
+ * arrived on this hook, the notification's *body* happened to contain the word "research",
485
+ * and the router wrote an entire `<task-notification>` block into the pending marker as
486
+ * the human's "topic" — after which the PreToolUse gate refused **every** delegation in
487
+ * the session. Each subsequent agent completion re-armed it. A router that reads the
488
+ * harness's own chatter as a human ask can wedge a session shut and be *right* every time
489
+ * by its own logic.
490
+ *
491
+ * So the rule is: route the human, never the harness. Recognising an envelope by its
492
+ * opening tag is a guess at shape, and B5's history says phrasing guesses lose — but this
493
+ * guess is safe in the direction that matters. A missed envelope routes a turn that maybe
494
+ * should not have been routed (recoverable, visible). Treating a notification as an ask
495
+ * closes the web on work nobody asked to gate (invisible, and it compounds).
496
+ */
497
+ const HARNESS_ENVELOPE = /^\s*(?:<(?:task-notification|system-reminder|function_(?:calls|results)|local-command-[a-z]+|command-(?:name|message|args))\b|\[SYSTEM NOTIFICATION)/i;
498
+ export function isHarnessEnvelope(prompt) {
499
+ return HARNESS_ENVELOPE.test(prompt ?? "");
500
+ }
501
+ export function researchRequestTopic(prompt) {
502
+ const text = (prompt ?? "").trim();
503
+ if (!text)
504
+ return null;
505
+ // The harness talking to itself is not a research ask, whatever words it contains.
506
+ if (isHarnessEnvelope(text))
507
+ return null;
508
+ // Strip a leading slash-command before reading the ask: "/foo research X" opens with
509
+ // research just as plainly as "research X" does.
510
+ const ask = text.replace(/^\/\S+\s*/, "").trim();
511
+ if (!ask)
512
+ return null;
513
+ const lower = ask.toLowerCase();
514
+ // An opening "research ..." is the ask itself and outranks everything below.
515
+ if (!RESEARCH_IMPERATIVE.test(lower)) {
516
+ // Otherwise a local-work signal wins, and the asymmetry is not close.
517
+ //
518
+ // A miss costs a suggestion. A false fire costs the session: this route does not only
519
+ // advise, it calls markResearchRequested, and a pending marker makes evaluateToolCall
520
+ // return allow:false for every web tool AND every fan-out until `foo research` runs.
521
+ // That is the same wedge the harness-envelope guard above was written for, reached
522
+ // through the front door — on a prompt that merely said "fix" and happened to name
523
+ // research (#719). So when the turn looks local, stay quiet.
524
+ if (LOCAL_ISSUE_REFERENCE.test(lower))
525
+ return null;
526
+ if (has(lower, LOCAL_QUESTION_MARKERS))
527
+ return null;
528
+ if (!hasResearchMarker(lower))
529
+ return null;
530
+ }
531
+ // Keep the human's own words as the topic. productfoo's job is to govern the run, not to
532
+ // reinterpret the ask.
533
+ return ask;
534
+ }
535
+ /** ...and told to come back with findings. Research, not a lookup. */
536
+ const FINDINGS_MARKERS = [
537
+ "research",
538
+ "sources",
539
+ "cite",
540
+ "citation",
541
+ "findings",
542
+ "brief",
543
+ "landscape",
544
+ "competitive",
545
+ "market",
546
+ "state of the art",
547
+ "prior art",
548
+ "survey",
549
+ "synthesi", // synthesis / synthesize
550
+ ];
551
+ /**
552
+ * Skills that are themselves research front doors. They are good tools; they are
553
+ * not productfoo's, so their output arrives untyped, unpanelled, and with no packet — which
554
+ * is precisely the evidence you should not act on.
555
+ */
556
+ const RIVAL_RESEARCH_SKILLS = new Set(["deep-research"]);
557
+ /**
558
+ * Does the text contain one of these markers *as a word*?
559
+ *
560
+ * This used to be a bare `includes()`, and that is a substring test, not a word test —
561
+ * so `cite` matched **ex·cite·d** and `market` matched **go-to-market**. An ideation
562
+ * fan-out that described a founder as "genuinely excited" was refused as a web sweep,
563
+ * and, having named no repo path, was refused by *default-deny* — the widest possible
564
+ * failure. Three of five ideators died on a run that reads no web at all.
565
+ *
566
+ * The repair is a **leading** boundary, and only a leading one: several markers are
567
+ * deliberate prefixes (`synthesi` → synthesis/synthesize, `research ` carries its own
568
+ * trailing space), so pinning the tail would break them. The marker still matches "cited"
569
+ * and "cites" and no longer matches "excited", which is exactly the distinction.
570
+ *
571
+ * The boundary is `(?<![\w-])`, not `\b`, because `\b` counts a hyphen as a separator —
572
+ * so `\bmarket` still fired inside **go-to-market**, a lens name, not an ask to go read
573
+ * the market. A hyphen joins a compound word; it does not start a new one.
574
+ *
575
+ * Markers that begin with punctuation (`.com`, `http://`) have no boundary to anchor and
576
+ * fall back to a plain substring test — `(?<![\w-])\.com` would misfire on its own dot.
577
+ *
578
+ * This is the fourth arrival of B5's own lesson: a gate that fires on the *mention* of a
579
+ * risk rather than the risk is one people learn to override on sight. Here it did not
580
+ * even fire on a mention — it fired on four letters inside an unrelated word.
581
+ */
582
+ const has = (haystack, needles) => needles.some((n) => markerPattern(n).test(haystack));
583
+ const MARKER_CACHE = new Map();
584
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
585
+ function markerPattern(needle) {
586
+ const cached = MARKER_CACHE.get(needle);
587
+ if (cached)
588
+ return cached;
589
+ // Anchor only when the marker actually starts with a word character; `.com` and
590
+ // `http://` cannot carry a boundary and must stay substring matches.
591
+ const anchored = /^\w/.test(needle) ? `(?<![\\w-])${escapeRe(needle)}` : escapeRe(needle);
592
+ const re = new RegExp(anchored, "i");
593
+ MARKER_CACHE.set(needle, re);
594
+ return re;
595
+ }
596
+ /**
597
+ * Is this call a fan-out to the web for findings?
598
+ *
599
+ * Both signals are required. "Spawn a subagent to grep for callers of parseSpec"
600
+ * has delegation but no web. "WebSearch for the current tsc flag" has web but no
601
+ * delegation. Neither is research; neither is gated.
602
+ */
603
+ export function isFanOutResearch(call) {
604
+ const tool = canonicalTool(call.tool_name);
605
+ const input = call.tool_input ?? {};
606
+ if (tool === "skill") {
607
+ const skill = String(input.skill ?? "").trim().toLowerCase();
608
+ return RIVAL_RESEARCH_SKILLS.has(skill);
609
+ }
610
+ if (!DELEGATION_TOOLS.has(tool))
611
+ return false;
612
+ // Read every field the harness might carry the intent in. Prompt is the real one;
613
+ // description and subagent_type are cheap corroboration.
614
+ const raw = [input.prompt, input.description, input.subagent_type]
615
+ .filter((v) => typeof v === "string")
616
+ .join("\n")
617
+ .toLowerCase();
618
+ if (!raw)
619
+ return false;
620
+ // A web name the prompt only mentions in order to refuse it is evidence FOR the prompt,
621
+ // not against it. Drop those before anything is matched.
622
+ const text = dropNegatedWeb(raw);
623
+ // Asking for findings is the constant: a subagent told to come back with sources,
624
+ // citations, or a synthesis is doing research wherever it reads.
625
+ if (!has(text, FINDINGS_MARKERS))
626
+ return false;
627
+ // Naming a web tool settles it — a prompt that says "use WebSearch" is not code search
628
+ // no matter how many file paths it also mentions.
629
+ if (has(text, EXPLICIT_WEB_TOOLS))
630
+ return true;
631
+ // Cannot-reach-the-web beats presume-the-web. A declared web-free tool
632
+ // surface or an explicit no-tools prompt is the caller's own statement that
633
+ // this spawn stays offline (#521) — and the tool-level gate still catches a
634
+ // subagent that breaks the promise.
635
+ const surface = declaredToolSurface(input);
636
+ if (surface && !surface.some((tool) => WEB_CAPABLE_TOOL.test(canonicalTool(tool))))
637
+ return false;
638
+ if (has(text, NO_TOOLS_DECLARATIONS))
639
+ return false;
640
+ // Separate what is on this machine from what is on the web BEFORE matching either. A
641
+ // dev-server URL and a path on disk are local targets; only what survives that removal
642
+ // gets to argue that this prompt reaches the open web.
643
+ const scope = scopeOf(text);
644
+ if (scope.remoteUrl || has(scope.rest, STRONG_WEB_MARKERS))
645
+ return true;
646
+ // A spawn that says where it reads is not ambiguous, and ambiguity is the only
647
+ // thing the default-deny below is entitled to punish (#614). This sits AFTER
648
+ // the remote-target check on purpose: unlike "no tools" — which is a statement
649
+ // the subagent CANNOT reach the web — this is a statement that it WILL not,
650
+ // and a prompt that says "local sources only" while naming a vendor's URL has
651
+ // contradicted itself. Contradictions block. Put this any earlier and the
652
+ // sentence becomes a skeleton key: prepend one line, keep the sweep.
653
+ if (has(text, LOCAL_ONLY_DECLARATIONS))
654
+ return false;
655
+ // Now — and only now — the rescue the REPO_MARKERS comment has always promised. A prompt
656
+ // that says where it is going ("this repo", "grep", "the codebase") beats a word that only
657
+ // might mean the web. Strong markers above are already past; nothing here can excuse them.
658
+ const repoScoped = has(scope.rest, REPO_MARKERS);
659
+ if (has(scope.rest, WEAK_WEB_MARKERS))
660
+ return !repoScoped;
661
+ // No web signal left. A findings run pointed at a local target — a file on disk, a page
662
+ // on a loopback port — is inspection, not discovery: a design critique, a code search, a
663
+ // review of the thing you just built. That is the work this gate must never touch.
664
+ if (scope.localTarget)
665
+ return false;
666
+ // Nothing local, nothing overtly web, and it wants findings. Presume the web, because
667
+ // that is where an agent asked for "sources" and given no target will go. Default-deny
668
+ // on ambiguity is the whole point — the previous default-allow is what let a vendor-docs
669
+ // sweep through.
670
+ return !repoScoped;
671
+ }
672
+ /**
673
+ * How many fan-out lanes one `foo research` run authorizes.
674
+ *
675
+ * The standard depth contract bounds a run at 4 source classes plus a panel, and the agent
676
+ * spawns roughly one subagent per lane. This is that budget, with headroom — not a number
677
+ * to tune upward when a run feels cramped. If a run genuinely needs more lanes, it needs a
678
+ * deeper depth, which prices itself.
679
+ */
680
+ const DEFAULT_LANES = 8;
681
+ function readMarker(root) {
682
+ const marker = markerPath(root);
683
+ if (!existsSync(marker))
684
+ return null;
685
+ try {
686
+ const raw = JSON.parse(readFileSync(marker, "utf8"));
687
+ const at = Number(raw.at ?? 0);
688
+ if (!Number.isFinite(at) || at <= 0)
689
+ return null;
690
+ // A marker written before lanes existed authorized everything forever. Treat a missing
691
+ // count as spent, not as unlimited — the whole bug was an exemption that outlived its run.
692
+ const lanes = Number.isFinite(Number(raw.lanes)) ? Number(raw.lanes) : 0;
693
+ return { at, topic: String(raw.topic ?? ""), lanes };
694
+ }
695
+ catch {
696
+ // Unreadable marker: treat as absent and gate. Never fail open on a parse error.
697
+ return null;
698
+ }
699
+ }
700
+ /**
701
+ * Is this fan-out one that a live `foo research` run actually planned?
702
+ *
703
+ * `foo research` plans the fan-out itself (source classes, page budget, panel) and the agent
704
+ * executes it by spawning subagents — so without an exemption the gate would block the very
705
+ * command it routes to.
706
+ *
707
+ * The exemption used to be time-only: any `foo research` run held the door open for TWO HOURS,
708
+ * for ANY topic. It stored the topic and never read it. So one governed run on one subject
709
+ * bought two hours of ungoverned research on every other subject — which is exactly how a
710
+ * vendor-docs sweep for an unrelated spec walked straight through a boundary that was working
711
+ * as designed.
712
+ *
713
+ * Now the exemption is CONSUMED. A run authorizes the lanes it planned; each fan-out spends
714
+ * one; when they are gone the gate closes, whatever the clock says. The TTL stays as a
715
+ * backstop for the run that is abandoned half-executed.
716
+ */
717
+ export function researchActive(root, now = Date.now()) {
718
+ const marker = readMarker(root);
719
+ if (!marker)
720
+ return false;
721
+ if (marker.lanes <= 0)
722
+ return false;
723
+ const ttlSeconds = Number(process.env.FOO_RESEARCH_TTL_SECONDS ?? 7200);
724
+ const ttl = Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : 7200;
725
+ return now - marker.at < ttl * 1000;
726
+ }
727
+ /** True when a run is live but has spent every lane it planned. Earns its own message. */
728
+ export function researchLanesSpent(root, now = Date.now()) {
729
+ const marker = readMarker(root);
730
+ if (!marker || marker.lanes > 0)
731
+ return false;
732
+ const ttlSeconds = Number(process.env.FOO_RESEARCH_TTL_SECONDS ?? 7200);
733
+ const ttl = Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : 7200;
734
+ return now - marker.at < ttl * 1000;
735
+ }
736
+ /** Spend one authorized lane. Called when the gate lets a fan-out through on a live run. */
737
+ export function consumeResearchLane(root) {
738
+ const marker = readMarker(root);
739
+ if (!marker || marker.lanes <= 0)
740
+ return;
741
+ writeFileSync(markerPath(root), `${JSON.stringify({ at: marker.at, topic: marker.topic, lanes: marker.lanes - 1 }, null, 2)}\n`);
742
+ }
743
+ // ---------------------------------------------------------------------------
744
+ // The run receipt — the only layer that binds in a harness productfoo cannot hook.
745
+ //
746
+ // Tool hooks enforce only the coverage declared for a supported, wired host.
747
+ // An unwired or partially covered host can still read outside that path, so
748
+ // evidence validation must remain independent of hook availability.
749
+ //
750
+ // What foo CAN do in every harness is refuse to believe the result. `foo research` mints a
751
+ // receipt; a paper must carry that run id; `foo research validate` refuses a paper whose
752
+ // run it cannot find. The reading is not blocked — it cannot be blocked — but the evidence
753
+ // does not count, and evidence that does not count cannot enter a packet or a decision.
754
+ //
755
+ // This is the honest shape of the guarantee: productfoo cannot always stop you reading the web.
756
+ // It can always stop what you read from becoming proof. And it matters that this is a
757
+ // REFUSAL rather than a warning, because productfoo has already been burned by a hand-written
758
+ // paper full of invented citations that passed every check it had.
759
+ // ---------------------------------------------------------------------------
760
+ // A receipt is the portable proof that a governed run happened. Two stores, because
761
+ // minting and publishing pull in opposite directions:
762
+ // - the WORKING store, gitignored `.foo/research-runs/`, is where every `foo research`
763
+ // run mints. It must not be tracked: exploratory runs (and the test suite, which
764
+ // spawns the CLI dozens of times) would otherwise litter the tree with a receipt per
765
+ // invocation, most for papers that are never written.
766
+ // - the PUBLISHED store, tracked `research/.runs/`, holds only the receipts of papers
767
+ // that were committed. Committing a paper's one receipt here makes its proof travel
768
+ // with it, so a governed paper's receipt row verifies for a reviewer on a fresh clone
769
+ // — not only on the machine that ran it. Publish with `foo research seal <paper>`.
770
+ // `verify` reads the published store first, then the working store, so a receipt resolves
771
+ // whether it was minted-here or shipped-with-the-repo. This split exists because putting
772
+ // the mint target under `research/` tracked 28 test-run receipts in one commit — proof
773
+ // that "mint here, publish here" cannot be the same directory.
774
+ const runsDir = (root) => path.join(root, ".foo", "research-runs");
775
+ const publishedRunsDir = (root) => path.join(root, "research", ".runs");
776
+ /** Stable, collision-resistant, and derivable from nothing but the run itself. */
777
+ const runId = (topic, at) => `r-${at.toString(36)}-${randomBytes(12).toString("hex")}`;
778
+ /** Called by `foo research` when it plans a run. The paper must carry the id it returns. */
779
+ export function mintResearchRun(root, topic, now = Date.now()) {
780
+ const receipt = { run_id: runId(topic, now), topic, at: now };
781
+ mkdirSync(runsDir(root), { recursive: true });
782
+ writeFileSync(path.join(runsDir(root), `${receipt.run_id}.json`), `${JSON.stringify(receipt, null, 2)}\n`);
783
+ return receipt;
784
+ }
785
+ /** Does this paper's claimed run actually exist? A forged id has no receipt on disk.
786
+ * Checks the published (tracked) store first, then the working `.foo/` store, so a receipt
787
+ * resolves whether it shipped with the repo or was minted here. The `/^r-.../` guard also
788
+ * stops an id from escaping the dir. */
789
+ export function verifyResearchRun(root, id) {
790
+ if (!id || !/^r-[a-z0-9]+-[a-z0-9_-]+$/i.test(id))
791
+ return null;
792
+ for (const dir of [publishedRunsDir(root), runsDir(root)]) {
793
+ const file = path.join(dir, `${id}.json`);
794
+ if (!existsSync(file))
795
+ continue;
796
+ try {
797
+ const raw = JSON.parse(readFileSync(file, "utf8"));
798
+ if (!raw.run_id || raw.run_id !== id)
799
+ continue;
800
+ return { run_id: String(raw.run_id), topic: String(raw.topic ?? ""), at: Number(raw.at ?? 0) };
801
+ }
802
+ catch {
803
+ continue;
804
+ }
805
+ }
806
+ return null;
807
+ }
808
+ /** Publish a working-store receipt into the tracked store so its proof travels with the
809
+ * committed paper. Idempotent. Returns what it did so the caller can speak plainly. */
810
+ export function sealResearchRun(root, id) {
811
+ if (!id || !/^r-[a-z0-9]+-[a-z0-9_-]+$/i.test(id))
812
+ return "no-receipt";
813
+ const published = path.join(publishedRunsDir(root), `${id}.json`);
814
+ if (existsSync(published))
815
+ return "already-sealed";
816
+ const working = path.join(runsDir(root), `${id}.json`);
817
+ if (!existsSync(working))
818
+ return "no-receipt";
819
+ mkdirSync(publishedRunsDir(root), { recursive: true });
820
+ writeFileSync(published, readFileSync(working, "utf8"));
821
+ return "sealed";
822
+ }
823
+ /** Pull the run id a paper claims. Absent = the paper was not produced by a governed run. */
824
+ export function paperRunId(paperText) {
825
+ const m = /^research_run:\s*(\S+)\s*$/m.exec(paperText ?? "");
826
+ return m ? m[1] : null;
827
+ }
828
+ /** Called by `foo research` when it hands the agent a research contract to execute. */
829
+ export function markResearchActive(root, topic, now = Date.now(), lanes = DEFAULT_LANES) {
830
+ const marker = markerPath(root);
831
+ mkdirSync(path.dirname(marker), { recursive: true });
832
+ writeFileSync(marker, `${JSON.stringify({ at: now, topic, lanes }, null, 2)}\n`);
833
+ // The ask has now been routed. Clear the pending flag so ordinary web lookups are free
834
+ // again once the governed run is under way — and reset the read counter, because a
835
+ // governed run reads the web a great deal and must never trip its own behavioural gate.
836
+ clearResearchRequest(root);
837
+ clearWebReads(root);
838
+ }
839
+ // ---------------------------------------------------------------------------
840
+ // Pending research request — set at the PROMPT, cleared when `foo research` runs.
841
+ //
842
+ // This is what makes "if productfoo is installed, a research task always goes through foo" true
843
+ // rather than aspirational. The UserPromptSubmit adapter injects a directive naming the
844
+ // command; the directive is advice, and advice loses to reflex. So it ALSO drops this
845
+ // marker, and while it is set the gate refuses the web outright — no fan-out required.
846
+ // You cannot read the web on a research task until productfoo has been run.
847
+ // ---------------------------------------------------------------------------
848
+ // ---------------------------------------------------------------------------
849
+ // The behavioural trip — the part that does not depend on anybody saying the magic word.
850
+ //
851
+ // Every layer of this gate so far has been a KEYWORD list: web markers, then findings
852
+ // markers, then research-request markers. Each one was a guess at phrasing, and each one
853
+ // lost to phrasing it did not guess. The third guess was the prompt router: it catches
854
+ // "research identity platforms" and sails right past "which auth provider should we use?"
855
+ // — same task, same ungoverned reading, no magic word.
856
+ //
857
+ // You cannot fix that with a longer list. Phrasing is unbounded; behaviour is not.
858
+ //
859
+ // Research has a signature no wording can hide: the agent reads the web, and reads it
860
+ // again, and accumulates an answer. One read is a lookup (a stack trace, a package
861
+ // version) and must stay free, or the override goes on permanently and the gate is dead.
862
+ // Three reads in a few minutes is not a lookup. It is a research run that nobody routed.
863
+ //
864
+ // So: count the reads. The Nth trips the gate regardless of what anyone called the task.
865
+ // This is the layer that makes "if the task is research, it goes through foo" true for
866
+ // the user who never says the word "research" — which is most of them.
867
+ // ---------------------------------------------------------------------------
868
+ /** Reads allowed before the gate calls it research. 1-2 is a lookup; 3 is a pattern. */
869
+ const LOOKUP_ALLOWANCE = 3;
870
+ /** Reads older than this stop counting — a lookup this morning is not this run. */
871
+ const READ_WINDOW_SECONDS = 600;
872
+ const readsPath = (root) => path.join(root, ".foo", "research-reads.json");
873
+ const readCount = (root, now) => {
874
+ const file = readsPath(root);
875
+ if (!existsSync(file))
876
+ return [];
877
+ try {
878
+ const raw = JSON.parse(readFileSync(file, "utf8"));
879
+ const windowMs = (Number(process.env.FOO_RESEARCH_READ_WINDOW_SECONDS ?? READ_WINDOW_SECONDS) || READ_WINDOW_SECONDS) * 1000;
880
+ return (Array.isArray(raw.reads) ? raw.reads : [])
881
+ .map(Number)
882
+ .filter((t) => Number.isFinite(t) && now - t < windowMs);
883
+ }
884
+ catch {
885
+ return [];
886
+ }
887
+ };
888
+ /** Record one web read. Called when the gate lets a lookup through. */
889
+ export function recordWebRead(root, now = Date.now()) {
890
+ const kept = readCount(root, now);
891
+ kept.push(now);
892
+ const file = readsPath(root);
893
+ mkdirSync(path.dirname(file), { recursive: true });
894
+ writeFileSync(file, `${JSON.stringify({ reads: kept }, null, 2)}\n`);
895
+ }
896
+ /** Cleared when a governed run starts — its own reading must not trip its own gate. */
897
+ export function clearWebReads(root) {
898
+ const file = readsPath(root);
899
+ if (existsSync(file))
900
+ rmSync(file, { force: true });
901
+ }
902
+ /** Has the agent read the web enough times that this is plainly research, unnamed? */
903
+ export function webReadsLookLikeResearch(root, now = Date.now()) {
904
+ const allowance = Number(process.env.FOO_RESEARCH_LOOKUP_ALLOWANCE ?? LOOKUP_ALLOWANCE) || LOOKUP_ALLOWANCE;
905
+ return readCount(root, now).length >= allowance - 1;
906
+ }
907
+ const requestPath = (root) => path.join(root, ".foo", "research-requested.json");
908
+ export function markResearchRequested(root, topic, now = Date.now()) {
909
+ const file = requestPath(root);
910
+ mkdirSync(path.dirname(file), { recursive: true });
911
+ writeFileSync(file, `${JSON.stringify({ at: now, topic }, null, 2)}\n`);
912
+ }
913
+ export function clearResearchRequest(root) {
914
+ const file = requestPath(root);
915
+ if (existsSync(file))
916
+ rmSync(file, { force: true });
917
+ }
918
+ /** A research ask is on the table and `foo research` has not been run for it yet. */
919
+ export function researchRequested(root, now = Date.now()) {
920
+ const file = requestPath(root);
921
+ if (!existsSync(file))
922
+ return null;
923
+ try {
924
+ const raw = JSON.parse(readFileSync(file, "utf8"));
925
+ const at = Number(raw.at ?? 0);
926
+ if (!Number.isFinite(at) || at <= 0)
927
+ return null;
928
+ // Short-lived on purpose: this governs the turn the ask arrived on, not the afternoon.
929
+ // A stale flag that blocks an unrelated lookup an hour later is a gate people delete.
930
+ const ttlSeconds = Number(process.env.FOO_RESEARCH_REQUEST_TTL_SECONDS ?? 900);
931
+ const ttl = Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : 900;
932
+ if (now - at >= ttl * 1000)
933
+ return null;
934
+ return String(raw.topic ?? "") || null;
935
+ }
936
+ catch {
937
+ return null;
938
+ }
939
+ }
940
+ const markerPath = (root) => path.join(root, ".foo", "research-active.json");
941
+ // The user's line and the agent's are not long and short versions of one text. They are two
942
+ // sentences for two readers: the human learns what took over and why it is better, in their
943
+ // own words; the agent gets the command, the contract and the override. Nothing the user
944
+ // cannot act on belongs in their line — they are not the one about to run `bin/foo research`.
945
+ const DENY_REASON = {
946
+ user: "Research runs through foo — it cites its sources and says what it couldn't reach.",
947
+ agent: "productfoo is installed: do not fan out raw web-research subagents. Research in this repo runs through " +
948
+ "`bin/foo research \"<topic>\"` — the governed front door. It types the evidence, reports every source " +
949
+ "class (including the ones it could NOT reach, so a gap is never silently dropped), bounds the fan-out " +
950
+ "and prices it, runs an adversarial panel, and writes a paper you can cite. A raw subagent sweep returns " +
951
+ "confident prose with no confidence levels, which is the kind of research that gets acted on and " +
952
+ "shouldn't be. Run `bin/foo research \"<topic>\"` first, then execute the contract it prints. " +
953
+ "Looking one thing up (a stack trace, a package version) is not research and is not gated — a single " +
954
+ "WebSearch call passes. Deliberately going around foo? Set FOO_ALLOW_RAW_RESEARCH=1.",
955
+ };
956
+ const SPENT_REASON = {
957
+ user: "This research run has used the search budget it planned — a new subject needs a new run.",
958
+ agent: "this `foo research` run has spent every fan-out lane it planned. A run authorizes the lanes " +
959
+ "it budgeted — not a window of free rein — so a new subject needs a new run: " +
960
+ "`bin/foo research \"<the new topic>\"`. (The old gate lifted for two hours on ANY topic, which " +
961
+ "is how an unrelated vendor-docs sweep walked through a boundary that was working as designed.) " +
962
+ "If this lane really is part of the current run and the budget was too tight, the run needed a " +
963
+ "deeper depth — which prices itself — not a bigger exemption. Deliberately going around foo? " +
964
+ "Set FOO_ALLOW_RAW_RESEARCH=1.",
965
+ };
966
+ /**
967
+ * The whole policy. Pure except for the marker read, so it is testable and any harness
968
+ * adapter can call it.
969
+ *
970
+ * Note the order: `researchLanesSpent` is checked before the generic deny, so an agent that
971
+ * over-fans-out inside a real run is told THAT, rather than being told to run the command it
972
+ * is already executing.
973
+ */
974
+ export function evaluateResearchGate(call, root, env = process.env) {
975
+ if (env.FOO_ALLOW_RAW_RESEARCH)
976
+ return { allow: true, note: "override" };
977
+ const tool = canonicalTool(call.tool_name);
978
+ // A research ask is live and productfoo has not been run for it. Now even a bare WebSearch is
979
+ // part of an ungoverned research run, so it is refused — the ONE case where a single web
980
+ // lookup is gated. Outside a pending research ask, lookups stay free, which is what keeps
981
+ // this narrow enough to survive.
982
+ if (WEB_TOOLS.has(tool)) {
983
+ // A governed run is under way: its own reading is the point. Never gate it.
984
+ if (researchActive(root))
985
+ return { allow: true, note: "foo-research-read" };
986
+ // The human phrased it as research. Route it before a single page is read.
987
+ const pending = researchRequested(root);
988
+ if (pending)
989
+ return { allow: false, reason: pendingReason(pending) };
990
+ // Nobody called it research — and it is research anyway. This is the layer that does
991
+ // not care what anyone said: the agent has already read the web LOOKUP_ALLOWANCE-1
992
+ // times in the last few minutes and is reaching for another. That is not a lookup.
993
+ if (webReadsLookLikeResearch(root))
994
+ return { allow: false, reason: UNNAMED_REASON };
995
+ return { allow: true, note: "lookup" };
996
+ }
997
+ if (!isFanOutResearch(call))
998
+ return { allow: true, note: "not-research" };
999
+ if (researchActive(root))
1000
+ return { allow: true, note: "foo-research-lane" };
1001
+ if (researchLanesSpent(root))
1002
+ return { allow: false, reason: SPENT_REASON };
1003
+ const pending = researchRequested(root);
1004
+ if (pending)
1005
+ return { allow: false, reason: pendingReason(pending) };
1006
+ return { allow: false, reason: DENY_REASON };
1007
+ }
1008
+ const UNNAMED_REASON = {
1009
+ user: "That's several pages read — this is research now, so productfoo is taking it from here.",
1010
+ agent: "nobody called this research, and it is research: you have read the open web several times in the " +
1011
+ "last few minutes and are reaching for another page. One lookup is a lookup; this is a run. " +
1012
+ "It goes through foo — always:\n\n bin/foo research \"<what you are actually trying to find out>\"\n\n" +
1013
+ "then execute the contract it prints. foo bounds and prices the fan-out, types every claim by " +
1014
+ "source class, reports the classes it could NOT reach so a gap is never silently dropped, runs an " +
1015
+ "adversarial panel, and writes a paper you can cite. What you are doing instead produces the same " +
1016
+ "shape of answer with no confidence levels and no record of what you missed — and that is exactly " +
1017
+ "the kind of evidence that gets acted on.\n\n" +
1018
+ "This fires on BEHAVIOUR, not on wording, because a gate that reads phrasing loses to phrasing it " +
1019
+ "did not anticipate — this one lost twice before it stopped guessing. Deliberately going around " +
1020
+ "foo? Set FOO_ALLOW_RAW_RESEARCH=1.",
1021
+ };
1022
+ // The topic goes to the agent only. It is the user's own phrasing, so it would read fine —
1023
+ // but it is arbitrarily long, and a deny line is not the place to reflect a paragraph of
1024
+ // their prompt back at them in red.
1025
+ const pendingReason = (topic) => ({
1026
+ user: "Research runs through foo — it cites its sources and says what it couldn't reach.",
1027
+ agent: `this is a research task and productfoo is installed, so it runs through foo — always. ` +
1028
+ `Run this first, then execute the contract it prints:\n\n bin/foo research "${topic}"\n\n` +
1029
+ `foo plans and prices the fan-out, types every claim by source class, reports the classes it ` +
1030
+ `could NOT reach (so a gap is never silently dropped), runs an adversarial panel, and writes a ` +
1031
+ `paper you can cite. Reading a few pages yourself and writing confident prose produces the same ` +
1032
+ `shape of answer with none of that — and it is the kind of evidence that gets acted on. ` +
1033
+ `Until productfoo has been run for this ask, the web is closed, including single lookups: ` +
1034
+ `the reflex this stops is "just start reading", not only "spawn subagents". ` +
1035
+ `Deliberately going around foo? Set FOO_ALLOW_RAW_RESEARCH=1.`,
1036
+ });