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.
- package/LICENSE +201 -0
- package/README.md +338 -2
- package/bin/foo +3 -0
- package/bin/foo.mjs +42 -0
- package/commands/bug.md +31 -0
- package/commands/discovery.md +28 -0
- package/commands/ideate.md +27 -0
- package/commands/init.md +28 -0
- package/commands/pitch.md +27 -0
- package/commands/preview.md +25 -0
- package/commands/research.md +31 -0
- package/commands/roast.md +27 -0
- package/commands/spec.md +30 -0
- package/config/pricing.yaml +198 -0
- package/config/routing-policy.yaml +171 -0
- package/dist-public/src/activeModel.d.ts +83 -0
- package/dist-public/src/activeModel.js +331 -0
- package/dist-public/src/agentAdapters.d.ts +122 -0
- package/dist-public/src/agentAdapters.js +178 -0
- package/dist-public/src/agentState.d.ts +43 -0
- package/dist-public/src/agentState.js +177 -0
- package/dist-public/src/bugCapsule.d.ts +94 -0
- package/dist-public/src/bugCapsule.js +227 -0
- package/dist-public/src/bugCapture.d.ts +26 -0
- package/dist-public/src/bugCapture.js +251 -0
- package/dist-public/src/bugOwnerLog.d.ts +93 -0
- package/dist-public/src/bugOwnerLog.js +304 -0
- package/dist-public/src/bugReportStore.d.ts +48 -0
- package/dist-public/src/bugReportStore.js +860 -0
- package/dist-public/src/bugSpecContext.d.ts +7 -0
- package/dist-public/src/bugSpecContext.js +129 -0
- package/dist-public/src/channelFreshness.d.ts +25 -0
- package/dist-public/src/channelFreshness.js +194 -0
- package/dist-public/src/cli.d.ts +30 -0
- package/dist-public/src/cli.js +1667 -0
- package/dist-public/src/cliArgs.d.ts +7 -0
- package/dist-public/src/cliArgs.js +44 -0
- package/dist-public/src/codeCatalog.d.ts +41 -0
- package/dist-public/src/codeCatalog.js +197 -0
- package/dist-public/src/commandContract.d.ts +84 -0
- package/dist-public/src/commandContract.js +390 -0
- package/dist-public/src/commandUsage.d.ts +3 -0
- package/dist-public/src/commandUsage.js +30 -0
- package/dist-public/src/commands/agent.d.ts +60 -0
- package/dist-public/src/commands/agent.js +1353 -0
- package/dist-public/src/commands/asp.d.ts +8 -0
- package/dist-public/src/commands/asp.js +669 -0
- package/dist-public/src/commands/evalCmd.d.ts +4 -0
- package/dist-public/src/commands/evalCmd.js +405 -0
- package/dist-public/src/commands/login.d.ts +25 -0
- package/dist-public/src/commands/login.js +94 -0
- package/dist-public/src/commands/loop.d.ts +16 -0
- package/dist-public/src/commands/loop.js +619 -0
- package/dist-public/src/commands/reconcile.d.ts +41 -0
- package/dist-public/src/commands/reconcile.js +292 -0
- package/dist-public/src/commands/runtime.d.ts +1 -0
- package/dist-public/src/commands/runtime.js +36 -0
- package/dist-public/src/commands/scanRedact.d.ts +8 -0
- package/dist-public/src/commands/scanRedact.js +266 -0
- package/dist-public/src/commands/setup.d.ts +28 -0
- package/dist-public/src/commands/setup.js +398 -0
- package/dist-public/src/commands/shared.d.ts +17 -0
- package/dist-public/src/commands/shared.js +147 -0
- package/dist-public/src/commands/source.d.ts +1 -0
- package/dist-public/src/commands/source.js +134 -0
- package/dist-public/src/commands/specCmd.d.ts +24 -0
- package/dist-public/src/commands/specCmd.js +794 -0
- package/dist-public/src/commands/update.d.ts +61 -0
- package/dist-public/src/commands/update.js +244 -0
- package/dist-public/src/comprehensionSignal.d.ts +20 -0
- package/dist-public/src/comprehensionSignal.js +166 -0
- package/dist-public/src/conductor/diversity.d.ts +48 -0
- package/dist-public/src/conductor/diversity.js +423 -0
- package/dist-public/src/conductor/engine.d.ts +7 -0
- package/dist-public/src/conductor/engine.js +295 -0
- package/dist-public/src/conductor/families/ideate.d.ts +2 -0
- package/dist-public/src/conductor/families/ideate.js +255 -0
- package/dist-public/src/conductor/families/interview.d.ts +2 -0
- package/dist-public/src/conductor/families/interview.js +39 -0
- package/dist-public/src/conductor/families/specSwarm.d.ts +2 -0
- package/dist-public/src/conductor/families/specSwarm.js +146 -0
- package/dist-public/src/conductor/familyDelivery.d.ts +6 -0
- package/dist-public/src/conductor/familyDelivery.js +37 -0
- package/dist-public/src/conductor/familyPackArtifact.d.ts +4 -0
- package/dist-public/src/conductor/familyPackArtifact.js +260 -0
- package/dist-public/src/conductor/ideateDecision.d.ts +6 -0
- package/dist-public/src/conductor/ideateDecision.js +237 -0
- package/dist-public/src/conductor/pack.d.ts +26 -0
- package/dist-public/src/conductor/pack.js +75 -0
- package/dist-public/src/conductor/types.d.ts +111 -0
- package/dist-public/src/conductor/types.js +7 -0
- package/dist-public/src/configPath.d.ts +2 -0
- package/dist-public/src/configPath.js +7 -0
- package/dist-public/src/discoveryArtifacts.d.ts +54 -0
- package/dist-public/src/discoveryArtifacts.js +467 -0
- package/dist-public/src/discoveryCommand.d.ts +127 -0
- package/dist-public/src/discoveryCommand.js +2148 -0
- package/dist-public/src/discoveryConnections.d.ts +339 -0
- package/dist-public/src/discoveryConnections.js +613 -0
- package/dist-public/src/discoveryDocs.d.ts +129 -0
- package/dist-public/src/discoveryDocs.js +577 -0
- package/dist-public/src/discoveryEvidence.d.ts +59 -0
- package/dist-public/src/discoveryEvidence.js +198 -0
- package/dist-public/src/discoveryFireflies.d.ts +25 -0
- package/dist-public/src/discoveryFireflies.js +551 -0
- package/dist-public/src/discoveryGmail.d.ts +12 -0
- package/dist-public/src/discoveryGmail.js +466 -0
- package/dist-public/src/discoveryGong.d.ts +12 -0
- package/dist-public/src/discoveryGong.js +491 -0
- package/dist-public/src/discoveryLearningRouting.d.ts +90 -0
- package/dist-public/src/discoveryLearningRouting.js +405 -0
- package/dist-public/src/discoveryMatching.d.ts +26 -0
- package/dist-public/src/discoveryMatching.js +73 -0
- package/dist-public/src/discoveryOutlook.d.ts +12 -0
- package/dist-public/src/discoveryOutlook.js +492 -0
- package/dist-public/src/discoveryProcess.d.ts +221 -0
- package/dist-public/src/discoveryProcess.js +703 -0
- package/dist-public/src/discoveryProof.d.ts +143 -0
- package/dist-public/src/discoveryProof.js +529 -0
- package/dist-public/src/discoveryProviders.d.ts +152 -0
- package/dist-public/src/discoveryProviders.js +756 -0
- package/dist-public/src/discoveryReadiness.d.ts +219 -0
- package/dist-public/src/discoveryReadiness.js +672 -0
- package/dist-public/src/discoveryReconcile.d.ts +57 -0
- package/dist-public/src/discoveryReconcile.js +424 -0
- package/dist-public/src/discoverySlack.d.ts +12 -0
- package/dist-public/src/discoverySlack.js +452 -0
- package/dist-public/src/discoverySources.d.ts +56 -0
- package/dist-public/src/discoverySources.js +322 -0
- package/dist-public/src/discoverySync.d.ts +52 -0
- package/dist-public/src/discoverySync.js +151 -0
- package/dist-public/src/discoverySynthesis.d.ts +57 -0
- package/dist-public/src/discoverySynthesis.js +215 -0
- package/dist-public/src/discoveryTeams.d.ts +12 -0
- package/dist-public/src/discoveryTeams.js +516 -0
- package/dist-public/src/discoveryZoom.d.ts +12 -0
- package/dist-public/src/discoveryZoom.js +523 -0
- package/dist-public/src/elicitationSurface.d.ts +13 -0
- package/dist-public/src/elicitationSurface.js +77 -0
- package/dist-public/src/errorReport.d.ts +99 -0
- package/dist-public/src/errorReport.js +443 -0
- package/dist-public/src/errorTaxonomy.d.ts +84 -0
- package/dist-public/src/errorTaxonomy.js +189 -0
- package/dist-public/src/evalCalibration.d.ts +82 -0
- package/dist-public/src/evalCalibration.js +169 -0
- package/dist-public/src/evalChecks.d.ts +21 -0
- package/dist-public/src/evalChecks.js +295 -0
- package/dist-public/src/evalCompile.d.ts +56 -0
- package/dist-public/src/evalCompile.js +58 -0
- package/dist-public/src/evalImprove.d.ts +60 -0
- package/dist-public/src/evalImprove.js +175 -0
- package/dist-public/src/evalJudge.d.ts +47 -0
- package/dist-public/src/evalJudge.js +143 -0
- package/dist-public/src/evalLabels.d.ts +100 -0
- package/dist-public/src/evalLabels.js +287 -0
- package/dist-public/src/evalRecord.d.ts +68 -0
- package/dist-public/src/evalRecord.js +165 -0
- package/dist-public/src/evalRoast.d.ts +88 -0
- package/dist-public/src/evalRoast.js +249 -0
- package/dist-public/src/evidenceIntake.d.ts +66 -0
- package/dist-public/src/evidenceIntake.js +356 -0
- package/dist-public/src/feedback.d.ts +78 -0
- package/dist-public/src/feedback.js +294 -0
- package/dist-public/src/firefliesClient.d.ts +106 -0
- package/dist-public/src/firefliesClient.js +258 -0
- package/dist-public/src/firstMoveTensions.d.ts +10 -0
- package/dist-public/src/firstMoveTensions.js +93 -0
- package/dist-public/src/gitRun.d.ts +21 -0
- package/dist-public/src/gitRun.js +50 -0
- package/dist-public/src/githubReconcile.d.ts +94 -0
- package/dist-public/src/githubReconcile.js +205 -0
- package/dist-public/src/gmailNango.d.ts +131 -0
- package/dist-public/src/gmailNango.js +329 -0
- package/dist-public/src/gongNango.d.ts +118 -0
- package/dist-public/src/gongNango.js +308 -0
- package/dist-public/src/grammarGate.d.ts +13 -0
- package/dist-public/src/grammarGate.js +72 -0
- package/dist-public/src/harnessGates.d.ts +65 -0
- package/dist-public/src/harnessGates.js +168 -0
- package/dist-public/src/humanSurfaceEval.d.ts +19 -0
- package/dist-public/src/humanSurfaceEval.js +156 -0
- package/dist-public/src/ideate.d.ts +43 -0
- package/dist-public/src/ideate.js +255 -0
- package/dist-public/src/ideateContext.d.ts +11 -0
- package/dist-public/src/ideateContext.js +83 -0
- package/dist-public/src/index.d.ts +2 -0
- package/dist-public/src/index.js +4 -0
- package/dist-public/src/initCommand.d.ts +25 -0
- package/dist-public/src/initCommand.js +558 -0
- package/dist-public/src/interviewGate.d.ts +25 -0
- package/dist-public/src/interviewGate.js +126 -0
- package/dist-public/src/jsonFile.d.ts +2 -0
- package/dist-public/src/jsonFile.js +22 -0
- package/dist-public/src/loopBudget.d.ts +182 -0
- package/dist-public/src/loopBudget.js +494 -0
- package/dist-public/src/loopContracts.d.ts +35 -0
- package/dist-public/src/loopContracts.js +364 -0
- package/dist-public/src/machineState.d.ts +1 -0
- package/dist-public/src/machineState.js +9 -0
- package/dist-public/src/notionNango.d.ts +93 -0
- package/dist-public/src/notionNango.js +279 -0
- package/dist-public/src/orchestrationResult.d.ts +11 -0
- package/dist-public/src/orchestrationResult.js +73 -0
- package/dist-public/src/outlookNango.d.ts +124 -0
- package/dist-public/src/outlookNango.js +327 -0
- package/dist-public/src/packetCorrections.d.ts +40 -0
- package/dist-public/src/packetCorrections.js +172 -0
- package/dist-public/src/packetGlossary.d.ts +6 -0
- package/dist-public/src/packetGlossary.js +42 -0
- package/dist-public/src/packetIndex.d.ts +54 -0
- package/dist-public/src/packetIndex.js +744 -0
- package/dist-public/src/packetIndexEntry.d.ts +3 -0
- package/dist-public/src/packetIndexEntry.js +128 -0
- package/dist-public/src/packetIndexServe.d.ts +13 -0
- package/dist-public/src/packetIndexServe.js +113 -0
- package/dist-public/src/packetIndexWorker.d.ts +7 -0
- package/dist-public/src/packetIndexWorker.js +45 -0
- package/dist-public/src/packetLifecycle.d.ts +30 -0
- package/dist-public/src/packetLifecycle.js +127 -0
- package/dist-public/src/packetReconcile.d.ts +73 -0
- package/dist-public/src/packetReconcile.js +386 -0
- package/dist-public/src/packetStatus.d.ts +125 -0
- package/dist-public/src/packetStatus.js +770 -0
- package/dist-public/src/packetValidation.d.ts +17 -0
- package/dist-public/src/packetValidation.js +333 -0
- package/dist-public/src/packetVerify.d.ts +73 -0
- package/dist-public/src/packetVerify.js +303 -0
- package/dist-public/src/packetWrite.d.ts +52 -0
- package/dist-public/src/packetWrite.js +448 -0
- package/dist-public/src/pitch.d.ts +38 -0
- package/dist-public/src/pitch.js +154 -0
- package/dist-public/src/processMemo.d.ts +8 -0
- package/dist-public/src/processMemo.js +23 -0
- package/dist-public/src/productDetect.d.ts +30 -0
- package/dist-public/src/productDetect.js +376 -0
- package/dist-public/src/profile.d.ts +62 -0
- package/dist-public/src/profile.js +334 -0
- package/dist-public/src/questionModel.d.ts +46 -0
- package/dist-public/src/questionModel.js +74 -0
- package/dist-public/src/questionText.d.ts +7 -0
- package/dist-public/src/questionText.js +38 -0
- package/dist-public/src/redactConfig.d.ts +2 -0
- package/dist-public/src/redactConfig.js +11 -0
- package/dist-public/src/redactEngine.d.ts +59 -0
- package/dist-public/src/redactEngine.js +509 -0
- package/dist-public/src/redactGate.d.ts +63 -0
- package/dist-public/src/redactGate.js +209 -0
- package/dist-public/src/repoRead.d.ts +29 -0
- package/dist-public/src/repoRead.js +160 -0
- package/dist-public/src/research.d.ts +242 -0
- package/dist-public/src/research.js +1314 -0
- package/dist-public/src/researchCompletion.d.ts +34 -0
- package/dist-public/src/researchCompletion.js +123 -0
- package/dist-public/src/researchExecution.d.ts +47 -0
- package/dist-public/src/researchExecution.js +309 -0
- package/dist-public/src/researchFormat.d.ts +21 -0
- package/dist-public/src/researchFormat.js +77 -0
- package/dist-public/src/researchGate.d.ts +86 -0
- package/dist-public/src/researchGate.js +1036 -0
- package/dist-public/src/researchReceipt.d.ts +76 -0
- package/dist-public/src/researchReceipt.js +148 -0
- package/dist-public/src/researchRender.d.ts +2 -0
- package/dist-public/src/researchRender.js +267 -0
- package/dist-public/src/researchSigningKey.d.ts +19 -0
- package/dist-public/src/researchSigningKey.js +95 -0
- package/dist-public/src/riskClassify.d.ts +42 -0
- package/dist-public/src/riskClassify.js +149 -0
- package/dist-public/src/routines.d.ts +42 -0
- package/dist-public/src/routines.js +269 -0
- package/dist-public/src/routingPolicy.d.ts +172 -0
- package/dist-public/src/routingPolicy.js +646 -0
- package/dist-public/src/secretInput.d.ts +8 -0
- package/dist-public/src/secretInput.js +89 -0
- package/dist-public/src/selfCheck.d.ts +7 -0
- package/dist-public/src/selfCheck.js +473 -0
- package/dist-public/src/selfCheckCache.d.ts +29 -0
- package/dist-public/src/selfCheckCache.js +147 -0
- package/dist-public/src/severityClass.d.ts +15 -0
- package/dist-public/src/severityClass.js +22 -0
- package/dist-public/src/skillClientPolicy.d.ts +8 -0
- package/dist-public/src/skillClientPolicy.js +16 -0
- package/dist-public/src/skillLocal.d.ts +98 -0
- package/dist-public/src/skillLocal.js +463 -0
- package/dist-public/src/skillTreeDigest.d.ts +9 -0
- package/dist-public/src/skillTreeDigest.js +64 -0
- package/dist-public/src/slackNango.d.ts +62 -0
- package/dist-public/src/slackNango.js +208 -0
- package/dist-public/src/social/arxiv.d.ts +19 -0
- package/dist-public/src/social/arxiv.js +96 -0
- package/dist-public/src/social/github.d.ts +2 -0
- package/dist-public/src/social/github.js +78 -0
- package/dist-public/src/social/hackernews.d.ts +2 -0
- package/dist-public/src/social/hackernews.js +67 -0
- package/dist-public/src/social/index.d.ts +10 -0
- package/dist-public/src/social/index.js +79 -0
- package/dist-public/src/social/reddit.d.ts +2 -0
- package/dist-public/src/social/reddit.js +125 -0
- package/dist-public/src/social/types.d.ts +73 -0
- package/dist-public/src/social/types.js +61 -0
- package/dist-public/src/social/xml.d.ts +10 -0
- package/dist-public/src/social/xml.js +49 -0
- package/dist-public/src/social/youtube.d.ts +7 -0
- package/dist-public/src/social/youtube.js +217 -0
- package/dist-public/src/sourceIngress.d.ts +488 -0
- package/dist-public/src/sourceIngress.js +1651 -0
- package/dist-public/src/specAfter.d.ts +187 -0
- package/dist-public/src/specAfter.js +814 -0
- package/dist-public/src/specBudget.d.ts +124 -0
- package/dist-public/src/specBudget.js +424 -0
- package/dist-public/src/specDecisionSignals.d.ts +38 -0
- package/dist-public/src/specDecisionSignals.js +121 -0
- package/dist-public/src/specLinkedCommits.d.ts +7 -0
- package/dist-public/src/specLinkedCommits.js +107 -0
- package/dist-public/src/specSections.d.ts +105 -0
- package/dist-public/src/specSections.js +718 -0
- package/dist-public/src/statusGlyphs.d.ts +18 -0
- package/dist-public/src/statusGlyphs.js +75 -0
- package/dist-public/src/swarmCommand.d.ts +10 -0
- package/dist-public/src/swarmCommand.js +96 -0
- package/dist-public/src/teamsNango.d.ts +114 -0
- package/dist-public/src/teamsNango.js +301 -0
- package/dist-public/src/tokenStore.d.ts +10 -0
- package/dist-public/src/tokenStore.js +77 -0
- package/dist-public/src/trustEnvelope.d.ts +78 -0
- package/dist-public/src/trustEnvelope.js +392 -0
- package/dist-public/src/untrustedText.d.ts +6 -0
- package/dist-public/src/untrustedText.js +20 -0
- package/dist-public/src/updateNotice.d.ts +41 -0
- package/dist-public/src/updateNotice.js +290 -0
- package/dist-public/src/vendorYaml.d.ts +1 -0
- package/dist-public/src/vendorYaml.js +7374 -0
- package/dist-public/src/workflowRunActuals.d.ts +67 -0
- package/dist-public/src/workflowRunActuals.js +184 -0
- package/dist-public/src/zendeskDiscovery.d.ts +12 -0
- package/dist-public/src/zendeskDiscovery.js +513 -0
- package/dist-public/src/zendeskNango.d.ts +131 -0
- package/dist-public/src/zendeskNango.js +344 -0
- package/dist-public/src/zoomNango.d.ts +106 -0
- package/dist-public/src/zoomNango.js +351 -0
- package/docs/CHANGELOG.md +32 -0
- package/docs/THIRD_PARTY_NOTICES.md +36 -0
- package/package.json +71 -4
- package/schemas/bug-capsule-receipt.schema.json +54 -0
- package/schemas/bug-capsule.schema.json +189 -0
- package/schemas/discovery-evidence-envelope.schema.json +79 -0
- package/schemas/discovery-processing-receipt.schema.json +71 -0
- package/schemas/discovery-service-contract.schema.json +189 -0
- package/skills/AGENTS.md +32 -0
- package/skills/README.md +287 -0
- package/skills/bug/SKILL.md +49 -0
- package/skills/build-preview-spec/SKILL.md +103 -0
- package/skills/capture-external-artifact/SKILL.md +23 -0
- package/skills/capture-external-artifact/agents/openai.yaml +4 -0
- package/skills/capture-external-artifact/references/extractor-libraries.md +27 -0
- package/skills/check-spec-health/SKILL.md +45 -0
- package/skills/check-spec-health/agents/openai.yaml +4 -0
- package/skills/competitive-positioning/SKILL.md +86 -0
- package/skills/competitive-positioning/agents/openai.yaml +4 -0
- package/skills/cpo-lens/SKILL.md +398 -0
- package/skills/cpo-lens/agents/openai.yaml +4 -0
- package/skills/create-prd/SKILL.md +26 -0
- package/skills/create-prd/agents/openai.yaml +4 -0
- package/skills/customer-discovery/SKILL.md +192 -0
- package/skills/design-system/SKILL.md +49 -0
- package/skills/design-system/agents/openai.yaml +4 -0
- package/skills/discussion-guide/SKILL.md +75 -0
- package/skills/discussion-guide/agents/openai.yaml +4 -0
- package/skills/firecrawl-research-papers/SKILL.md +96 -0
- package/skills/firecrawl-research-papers/agents/openai.yaml +4 -0
- package/skills/foo-roast/SKILL.md +325 -0
- package/skills/generate-gtm-brief/SKILL.md +41 -0
- package/skills/generate-gtm-brief/agents/openai.yaml +4 -0
- package/skills/generate-gtm-brief/references/gtm-brief-template.md +56 -0
- package/skills/gong-quotes/SKILL.md +43 -0
- package/skills/gong-quotes/agents/openai.yaml +4 -0
- package/skills/grill-me/SKILL.md +27 -0
- package/skills/grill-me/agents/openai.yaml +4 -0
- package/skills/ideate/SKILL.md +180 -0
- package/skills/ideate/references/family-packs.md +5 -0
- package/skills/initiative-tracker/SKILL.md +23 -0
- package/skills/initiative-tracker/agents/openai.yaml +4 -0
- package/skills/interop-review/SKILL.md +25 -0
- package/skills/interop-review/agents/openai.yaml +4 -0
- package/skills/interview-me/SKILL.md +404 -0
- package/skills/interview-me/agents/openai.yaml +4 -0
- package/skills/loop-library-audit/SKILL.md +123 -0
- package/skills/pitch/SKILL.md +110 -0
- package/skills/pitch/references/opportunity-assessment.md +136 -0
- package/skills/pitch/references/prfaq-template.md +54 -0
- package/skills/pmm-release-briefing/SKILL.md +27 -0
- package/skills/pmm-release-briefing/agents/openai.yaml +4 -0
- package/skills/pmm-release-briefing/references/variant-generalized.md +16 -0
- package/skills/positioning-messaging/SKILL.md +70 -0
- package/skills/positioning-messaging/agents/openai.yaml +4 -0
- package/skills/positioning-messaging/references/guest-insights.md +1400 -0
- package/skills/preview-swarm/SKILL.md +418 -0
- package/skills/preview-swarm/agents/openai.yaml +4 -0
- package/skills/preview-swarm/references/html-effectiveness-build-previews.md +168 -0
- package/skills/product-copywriting/SKILL.md +127 -0
- package/skills/product-copywriting/agents/openai.yaml +4 -0
- package/skills/product-copywriting/references/foo-voice.md +26 -0
- package/skills/product-shape-interview/SKILL.md +219 -0
- package/skills/product-shape-interview/agents/openai.yaml +4 -0
- package/skills/ready-for-release/SKILL.md +39 -0
- package/skills/ready-for-release/agents/openai.yaml +4 -0
- package/skills/redact-transcripts/SKILL.md +23 -0
- package/skills/redact-transcripts/agents/openai.yaml +4 -0
- package/skills/research-brief/SKILL.md +48 -0
- package/skills/research-brief/agents/openai.yaml +4 -0
- package/skills/research-refresh/SKILL.md +63 -0
- package/skills/research-refresh/references/worked-cases.md +44 -0
- package/skills/review-loop/SKILL.md +200 -0
- package/skills/review-spec/SKILL.md +32 -0
- package/skills/review-spec/agents/openai.yaml +4 -0
- package/skills/rfc-from-research/SKILL.md +42 -0
- package/skills/rfc-from-research/agents/openai.yaml +4 -0
- package/skills/rfc-from-research/references/rfc-pattern.md +69 -0
- package/skills/skill-registry.yaml +810 -0
- package/skills/spec-cleanup/SKILL.md +103 -0
- package/skills/spec-cleanup/agents/openai.yaml +4 -0
- package/skills/spec-swarm/SKILL.md +87 -0
- package/skills/spec-swarm/agents/openai.yaml +4 -0
- package/skills/spec-swarm/template/SKILL.template.md +94 -0
- package/skills/spec-swarm/template/personas/accessibility-inclusion.md +40 -0
- package/skills/spec-swarm/template/personas/ai-technical-pm.md +69 -0
- package/skills/spec-swarm/template/personas/analytics-engineering.md +39 -0
- package/skills/spec-swarm/template/personas/applied-ai-eval.md +64 -0
- package/skills/spec-swarm/template/personas/business-gm-pm.md +67 -0
- package/skills/spec-swarm/template/personas/consumer-craft-pm.md +68 -0
- package/skills/spec-swarm/template/personas/content-design.md +40 -0
- package/skills/spec-swarm/template/personas/cpo.md +42 -0
- package/skills/spec-swarm/template/personas/data-science.md +42 -0
- package/skills/spec-swarm/template/personas/developer-experience.md +65 -0
- package/skills/spec-swarm/template/personas/dx-product.md +70 -0
- package/skills/spec-swarm/template/personas/field.md +43 -0
- package/skills/spec-swarm/template/personas/finance-cfo.md +40 -0
- package/skills/spec-swarm/template/personas/growth-pm.md +67 -0
- package/skills/spec-swarm/template/personas/platform-pm.md +67 -0
- package/skills/spec-swarm/template/personas/pmm.md +43 -0
- package/skills/spec-swarm/template/personas/principal-product-designer.md +42 -0
- package/skills/spec-swarm/template/personas/product-ops-program.md +41 -0
- package/skills/spec-swarm/template/personas/qa-release.md +41 -0
- package/skills/spec-swarm/template/personas/responsible-ai-safety.md +64 -0
- package/skills/spec-swarm/template/personas/security-privacy-legal.md +40 -0
- package/skills/spec-swarm/template/personas/strategy-competitive.md +41 -0
- package/skills/spec-swarm/template/personas/technical-architect.md +42 -0
- package/skills/spec-swarm/template/personas/technical-lead.md +43 -0
- package/skills/spec-swarm/template/personas/user-research.md +41 -0
- package/skills/spec-swarm-accessibility-inclusion/SKILL.md +95 -0
- package/skills/spec-swarm-accessibility-inclusion/agents/openai.yaml +4 -0
- package/skills/spec-swarm-ai-technical-pm/SKILL.md +124 -0
- package/skills/spec-swarm-analytics-engineering/SKILL.md +94 -0
- package/skills/spec-swarm-analytics-engineering/agents/openai.yaml +4 -0
- package/skills/spec-swarm-applied-ai-eval/SKILL.md +120 -0
- package/skills/spec-swarm-business-gm-pm/SKILL.md +122 -0
- package/skills/spec-swarm-consumer-craft-pm/SKILL.md +122 -0
- package/skills/spec-swarm-content-design/SKILL.md +95 -0
- package/skills/spec-swarm-content-design/agents/openai.yaml +4 -0
- package/skills/spec-swarm-cpo/SKILL.md +94 -0
- package/skills/spec-swarm-cpo/agents/openai.yaml +4 -0
- package/skills/spec-swarm-data-science/SKILL.md +95 -0
- package/skills/spec-swarm-data-science/agents/openai.yaml +4 -0
- package/skills/spec-swarm-developer-experience/SKILL.md +121 -0
- package/skills/spec-swarm-dx-product/SKILL.md +126 -0
- package/skills/spec-swarm-field/SKILL.md +96 -0
- package/skills/spec-swarm-field/agents/openai.yaml +4 -0
- package/skills/spec-swarm-finance-cfo/SKILL.md +95 -0
- package/skills/spec-swarm-finance-cfo/agents/openai.yaml +4 -0
- package/skills/spec-swarm-growth-pm/SKILL.md +122 -0
- package/skills/spec-swarm-platform-pm/SKILL.md +122 -0
- package/skills/spec-swarm-pmm/SKILL.md +96 -0
- package/skills/spec-swarm-pmm/agents/openai.yaml +4 -0
- package/skills/spec-swarm-principal-product-designer/SKILL.md +95 -0
- package/skills/spec-swarm-principal-product-designer/agents/openai.yaml +4 -0
- package/skills/spec-swarm-product-ops-program/SKILL.md +96 -0
- package/skills/spec-swarm-product-ops-program/agents/openai.yaml +4 -0
- package/skills/spec-swarm-qa-release/SKILL.md +96 -0
- package/skills/spec-swarm-qa-release/agents/openai.yaml +4 -0
- package/skills/spec-swarm-responsible-ai-safety/SKILL.md +120 -0
- package/skills/spec-swarm-security-privacy-legal/SKILL.md +95 -0
- package/skills/spec-swarm-security-privacy-legal/agents/openai.yaml +4 -0
- package/skills/spec-swarm-strategy-competitive/SKILL.md +96 -0
- package/skills/spec-swarm-strategy-competitive/agents/openai.yaml +4 -0
- package/skills/spec-swarm-technical-architect/SKILL.md +95 -0
- package/skills/spec-swarm-technical-architect/agents/openai.yaml +4 -0
- package/skills/spec-swarm-technical-lead/SKILL.md +96 -0
- package/skills/spec-swarm-technical-lead/agents/openai.yaml +4 -0
- package/skills/spec-swarm-user-research/SKILL.md +95 -0
- package/skills/spec-swarm-user-research/agents/openai.yaml +4 -0
- package/skills/strategy-check/SKILL.md +170 -0
- package/skills/strategy-check/agents/openai.yaml +4 -0
- package/skills/study-digest/SKILL.md +41 -0
- package/skills/synthetic-customer/SKILL.md +68 -0
- package/skills/update-cli-spec/SKILL.md +69 -0
- package/skills/update-cli-spec/agents/openai.yaml +4 -0
- package/skills/voc-researcher/SKILL.md +45 -0
- package/skills/voc-researcher/agents/openai.yaml +4 -0
- 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
|
+
});
|