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