heyspex 0.1.0 → 0.2.0
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/README.md +21 -0
- package/bin/hs.mjs +931 -98
- package/bin/spexcode.mjs +262 -0
- package/dev-factory/engine/worktree.mjs +740 -0
- package/dev-factory/lib/code-repo-root.mjs +67 -0
- package/dev-factory/lib/context-budget.mjs +84 -0
- package/dev-factory/lib/epic-detect.mjs +190 -0
- package/dev-factory/lib/fleet-queue-file.mjs +846 -0
- package/dev-factory/lib/fleet-queue.mjs +426 -0
- package/dev-factory/lib/fleet-state.mjs +14 -18
- package/dev-factory/lib/model-catalog.mjs +338 -0
- package/dev-factory/lib/seat-context.mjs +199 -0
- package/dev-factory/lib/spec-io.mjs +93 -0
- package/dev-factory/lib/sufficiency-score.mjs +8 -2
- package/dev-factory/lib/worktree-allocator.mjs +744 -0
- package/dev-factory/model-catalog.json +222 -0
- package/dist/cli/hs-check.mjs +1085 -103
- package/dist/cli/hs-claim.mjs +936 -63
- package/dist/cli/hs-connect.mjs +9786 -1910
- package/dist/cli/hs-defer.mjs +5267 -3053
- package/dist/cli/hs-dev.mjs +564 -0
- package/dist/cli/hs-env.mjs +385 -299
- package/dist/cli/hs-index-rebuild.mjs +7129 -0
- package/dist/cli/hs-index-reconcile.mjs +7852 -0
- package/dist/cli/hs-init.mjs +777 -138
- package/dist/cli/hs-install.mjs +6583 -485
- package/dist/cli/hs-join.mjs +10137 -2368
- package/dist/cli/hs-vault-pull.mjs +4659 -5882
- package/dist/cli/hs-vault.mjs +3793 -1537
- package/dist/cli/hs-workspace.mjs +2550 -524
- package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
- package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
- package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
- package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
- package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
- package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
- package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
- package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
- package/docs/mcp-client-config.md +259 -6
- package/package.json +231 -124
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
- package/plugins/heyspex/.mcp.json +12 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
- package/plugins/heyspex/bin/run-hook.mjs +62 -0
- package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
- package/plugins/heyspex/hooks/hooks.json +361 -0
- package/plugins/heyspex/mcp.json +13 -0
- package/scripts/check-mcp-freshness.mjs +17 -9
- package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
- package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
- package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
- package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
- package/scripts/claude-hooks/_run.mjs +120 -0
- package/scripts/claude-hooks/_transcript-text.mjs +60 -0
- package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
- package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
- package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
- package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
- package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
- package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
- package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
- package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
- package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
- package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
- package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
- package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
- package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
- package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
- package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
- package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
- package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
- package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
- package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
- package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
- package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
- package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
- package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
- package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
- package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
- package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
- package/scripts/claude-hooks/log-session-activity.mjs +341 -0
- package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
- package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
- package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
- package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
- package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
- package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
- package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
- package/scripts/claude-hooks/research-method-guard.mjs +398 -0
- package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
- package/scripts/claude-hooks/session-end.mjs +449 -0
- package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
- package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
- package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
- package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
- package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
- package/scripts/cursor-hooks/manifest.mjs +235 -0
- package/scripts/dev-server-status.mjs +34 -13
- package/scripts/dev-server-stop.mjs +313 -0
- package/scripts/heyspex-mode.mjs +268 -0
- package/scripts/hs-auto-resolve.mjs +240 -0
- package/scripts/hs-auto-source.mjs +398 -0
- package/scripts/hs-auto.mjs +134 -0
- package/scripts/hs-capture-cli.mjs +326 -0
- package/scripts/hs-classifier.mjs +136 -0
- package/scripts/hs-companion.mjs +88 -0
- package/scripts/hs-gateway.mjs +115 -0
- package/scripts/hs-mode.mjs +125 -0
- package/scripts/hs-next.mjs +441 -0
- package/scripts/hs-settings.mjs +338 -0
- package/scripts/hs-view.mjs +333 -0
- package/scripts/hs-worktree.mjs +201 -0
- package/scripts/install-claude-code-hooks.mjs +270 -0
- package/scripts/install-cursor-hooks.mjs +471 -0
- package/scripts/lib/agent-env-markers.mjs +38 -0
- package/scripts/lib/console-url-resolver.mjs +197 -60
- package/scripts/lib/customer-kit.mjs +886 -0
- package/scripts/lib/dev-mode.mjs +244 -28
- package/scripts/lib/heyspex-home.mjs +72 -0
- package/scripts/lib/hs-command.mjs +277 -0
- package/scripts/lib/hs-companion-settings.mjs +472 -0
- package/scripts/lib/hs-source-commit.mjs +59 -5
- package/scripts/lib/hs-source-identity.mjs +526 -0
- package/scripts/lib/hs-source-log.mjs +11 -21
- package/scripts/lib/hs-source-push.mjs +107 -58
- package/scripts/lib/hs-worktree-args.mjs +115 -0
- package/scripts/lib/shared-path-helpers.mjs +134 -0
- package/scripts/lib/standalone-doctor.mjs +540 -3
- package/scripts/lib/subagent-git-lock.mjs +109 -0
- package/scripts/lib/template-pin.mjs +34 -0
- package/scripts/provenance-lib.mjs +91 -0
- package/scripts/stamp-provenance-on-edit.mjs +222 -0
- package/scripts/wip-ledger-health.mjs +653 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
- package/spexharness/src/adapters/audit.mjs +197 -0
- package/spexharness/src/adapters/claude-code.mjs +441 -0
- package/spexharness/src/chat/tui/ansi.mjs +448 -0
- package/spexharness/src/chat/tui/palette.mjs +22 -0
- package/spexharness/src/chat/tui/select.mjs +208 -0
- package/spexharness/src/config-root.mjs +54 -0
- package/spexharness/src/context-budget.mjs +141 -0
- package/spexharness/src/dev-server-control.mjs +389 -0
- package/spexharness/src/doctrine/behaviour.mjs +371 -0
- package/spexharness/src/doctrine/corpus.mjs +733 -0
- package/spexharness/src/doctrine/inject.mjs +338 -0
- package/spexharness/src/doctrine/merge.mjs +184 -0
- package/spexharness/src/doctrine/sources.mjs +252 -0
- package/spexharness/src/doctrine/synthesize.mjs +401 -0
- package/spexharness/src/doctrine/vault-source.mjs +574 -0
- package/spexharness/src/doctrine/writer.mjs +176 -0
- package/spexharness/src/events.mjs +84 -0
- package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
- package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
- package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
- package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
- package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
- package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
- package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
- package/spexharness/src/gates/git-identity-guard.mjs +61 -0
- package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
- package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
- package/spexharness/src/gates/git-stash-gate.mjs +159 -0
- package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
- package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
- package/spexharness/src/gates/inject-vault-context.mjs +239 -0
- package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
- package/spexharness/src/gates/plan-review-gate.mjs +233 -0
- package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
- package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
- package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
- package/spexharness/src/gates/settings-write-gate.mjs +104 -0
- package/spexharness/src/git-reflog-audit.mjs +294 -0
- package/spexharness/src/intent-classifier.mjs +194 -0
- package/spexharness/src/intent-directive.mjs +541 -0
- package/spexharness/src/profile/learn.mjs +551 -0
- package/spexharness/src/profile/proposals.mjs +357 -0
- package/spexharness/src/profile/vault-inclusion.mjs +386 -0
- package/spexharness/src/redact.mjs +151 -0
- package/spexharness/src/rule-table.mjs +819 -0
- package/spexharness/src/seat-context.mjs +122 -0
- package/spexharness/src/session-bootstrap.mjs +512 -0
- package/spexharness/src/session-record.mjs +815 -0
- package/spexharness/src/token-inventory.mjs +207 -0
- package/vendor/heyspex-mcp/account-switch.mjs +95 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
- package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
- package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
- package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
- package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
- package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
- package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
- package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
- package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
- package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
- package/vendor/heyspex-mcp/cli.mjs +201 -15524
- package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
- package/vendor/heyspex-mcp/config-seed.yaml +15 -0
- package/vendor/heyspex-mcp/config-settings.mjs +290 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
- package/vendor/heyspex-mcp/console-transport.mjs +166 -0
- package/vendor/heyspex-mcp/credentials.mjs +584 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
- package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
- package/vendor/heyspex-mcp/design-tools.mjs +4 -1
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
- package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
- package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
- package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
- package/vendor/heyspex-mcp/events-tools.mjs +207 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
- package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
- package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
- package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
- package/vendor/heyspex-mcp/http-transport.mjs +300 -0
- package/vendor/heyspex-mcp/list-tools.mjs +257 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
- package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
- package/vendor/heyspex-mcp/package-root.mjs +32 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
- package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
- package/vendor/heyspex-mcp/plan-push.mjs +906 -21
- package/vendor/heyspex-mcp/plan-review.mjs +124 -30
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
- package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
- package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
- package/vendor/heyspex-mcp/promote-research.mjs +660 -53
- package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
- package/vendor/heyspex-mcp/prompts.mjs +1460 -0
- package/vendor/heyspex-mcp/read-path.mjs +29 -9
- package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
- package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
- package/vendor/heyspex-mcp/register-spec.mjs +266 -54
- package/vendor/heyspex-mcp/request-context.mjs +59 -0
- package/vendor/heyspex-mcp/research-focus.mjs +207 -4
- package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
- package/vendor/heyspex-mcp/research-tools.mjs +4 -4
- package/vendor/heyspex-mcp/response-budget.mjs +348 -0
- package/vendor/heyspex-mcp/review-focus.mjs +25 -1
- package/vendor/heyspex-mcp/search-specs.mjs +280 -0
- package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
- package/vendor/heyspex-mcp/session-owner.mjs +108 -0
- package/vendor/heyspex-mcp/session-tools.mjs +152 -0
- package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
- package/vendor/heyspex-mcp/split-research.mjs +301 -56
- package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
- package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
- package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
- package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
- package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
- package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
- package/vendor/heyspex-mcp/vault-io.mjs +724 -0
- package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
- package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
- package/vendor/heyspex-mcp/whoami.mjs +91 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http-transport.mjs — MCP Streamable HTTP transport, JSON-response mode.
|
|
3
|
+
*
|
|
4
|
+
* A thin, dependency-free JSON-RPC 2.0 front for the EXISTING tool dispatch
|
|
5
|
+
* (`tool-dispatch.mjs#dispatchHeySpexTool`), built for the first remote MCP
|
|
6
|
+
* endpoint (`POST /api/mcp` on the console, WP6 of the cloud-first track).
|
|
7
|
+
* `cli.mjs` remains the stdio wiring; this module is the HTTP wiring. Neither
|
|
8
|
+
* knows about tools — both hand every `tools/call` to the same dispatch.
|
|
9
|
+
*
|
|
10
|
+
* Scope (deliberately small):
|
|
11
|
+
* - methods: initialize, notifications/*, ping, tools/list, tools/call
|
|
12
|
+
* - single messages AND batches (JSON-RPC arrays)
|
|
13
|
+
* - stateless: `initialize` mints a session id, later calls echo the
|
|
14
|
+
* client's `Mcp-Session-Id`; nothing is kept server-side
|
|
15
|
+
* - JSON responses only (`application/json`). SSE streaming is NOT
|
|
16
|
+
* implemented: the server never pushes notifications or progress, so a
|
|
17
|
+
* plain JSON reply is a complete, spec-valid response for every method
|
|
18
|
+
* here. Clients that accept only `text/event-stream` still get JSON.
|
|
19
|
+
*
|
|
20
|
+
* The handler is a factory (`createRemoteMcpHandler`) fed its dependencies —
|
|
21
|
+
* `dispatch`, the visible `tools`, the allowlist predicate — so this file
|
|
22
|
+
* never imports the heavy dispatch graph and stays unit-testable in
|
|
23
|
+
* isolation. Every `tools/call` runs inside `runWithRequestContext`, which is
|
|
24
|
+
* how the caller's own console origin + bearer reach `console-transport.mjs`
|
|
25
|
+
* without touching `process.env` (see request-context.mjs).
|
|
26
|
+
*/
|
|
27
|
+
import { randomUUID } from 'node:crypto';
|
|
28
|
+
|
|
29
|
+
import { runWithRequestContext } from './request-context.mjs';
|
|
30
|
+
|
|
31
|
+
export const JSONRPC_VERSION = '2.0';
|
|
32
|
+
|
|
33
|
+
/** Protocol revisions this transport speaks; the client's pick is echoed when listed. */
|
|
34
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
35
|
+
export const DEFAULT_PROTOCOL_VERSION = '2025-03-26';
|
|
36
|
+
|
|
37
|
+
export const JSONRPC_ERROR = Object.freeze({
|
|
38
|
+
PARSE_ERROR: -32700,
|
|
39
|
+
INVALID_REQUEST: -32600,
|
|
40
|
+
METHOD_NOT_FOUND: -32601,
|
|
41
|
+
INVALID_PARAMS: -32602,
|
|
42
|
+
INTERNAL_ERROR: -32603,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Error code for a tool that exists in the catalog but is not served remotely. */
|
|
46
|
+
export const TOOL_NOT_AVAILABLE_REMOTELY = 'TOOL_NOT_AVAILABLE_REMOTELY';
|
|
47
|
+
|
|
48
|
+
export function negotiateProtocolVersion(requested) {
|
|
49
|
+
return typeof requested === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
|
|
50
|
+
? requested
|
|
51
|
+
: DEFAULT_PROTOCOL_VERSION;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function newSessionId() {
|
|
55
|
+
return randomUUID();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function jsonRpcError(id, code, message, data) {
|
|
59
|
+
return {
|
|
60
|
+
jsonrpc: JSONRPC_VERSION,
|
|
61
|
+
id: id === undefined ? null : id,
|
|
62
|
+
error: { code, message, ...(data !== undefined ? { data } : {}) },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function jsonRpcResult(id, result) {
|
|
67
|
+
return { jsonrpc: JSONRPC_VERSION, id, result };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The body to send when the request body was not valid JSON at all. */
|
|
71
|
+
export function parseErrorBody() {
|
|
72
|
+
return jsonRpcError(null, JSONRPC_ERROR.PARSE_ERROR, 'Parse error: request body is not valid JSON');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isPlainObject(value) {
|
|
76
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isValidId(id) {
|
|
80
|
+
return typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Strip the catalog-only `budget` field: it is a server contract, not MCP wire shape. */
|
|
84
|
+
export function toWireTool(tool) {
|
|
85
|
+
const { budget: _budget, ...wire } = tool;
|
|
86
|
+
return wire;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Human-readable refusal for a catalog tool that is not on the remote surface.
|
|
91
|
+
* Returned as a JSON-RPC `-32602` (the MCP convention for an unknown tool) so
|
|
92
|
+
* the client surfaces it as a protocol error rather than a tool result.
|
|
93
|
+
*/
|
|
94
|
+
export function toolNotAvailableRemotelyError(id, name) {
|
|
95
|
+
return jsonRpcError(
|
|
96
|
+
id,
|
|
97
|
+
JSONRPC_ERROR.INVALID_PARAMS,
|
|
98
|
+
`Tool "${name}" is not available on the remote HeySpex MCP endpoint. It reads or writes the local vault checkout (disk/git), so it needs the local stdio server: install the heyspex package and add it with \`claude mcp add heyspex heyspex-mcp\` (see docs/mcp-client-config.md).`,
|
|
99
|
+
{ error: TOOL_NOT_AVAILABLE_REMOTELY, tool: name },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @typedef {object} RemoteMcpDeps
|
|
105
|
+
* @property {(name: string, args: Record<string, unknown>, vaultRoot: string, config: object) => unknown} dispatch
|
|
106
|
+
* The tool dispatch (`dispatchHeySpexTool`). May return a value or a Promise.
|
|
107
|
+
* @property {Array<{ name: string, [k: string]: unknown }>} tools
|
|
108
|
+
* The tools to advertise from `tools/list` — already reduced to the remote surface.
|
|
109
|
+
* @property {(name: string) => boolean} [isToolAllowed]
|
|
110
|
+
* Allowlist predicate for `tools/call` (default: membership in `tools`).
|
|
111
|
+
* @property {(name: string) => boolean} [isKnownTool]
|
|
112
|
+
* Whether a name exists in the FULL catalog — distinguishes "needs the local
|
|
113
|
+
* server" from "no such tool" (default: same as `isToolAllowed`).
|
|
114
|
+
* @property {{ name: string, version: string }} serverInfo
|
|
115
|
+
* @property {string} [instructions]
|
|
116
|
+
* @property {string} vaultRoot
|
|
117
|
+
* Passed through to `dispatch`. For the remote endpoint this is a path that
|
|
118
|
+
* does not exist: every allowlisted handler works via the console API in the
|
|
119
|
+
* neon read tier and never touches it.
|
|
120
|
+
* @property {object} [config] Passed through to `dispatch` (default: `{ vaultRoot }`).
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @typedef {object} RemoteMcpCallContext
|
|
125
|
+
* @property {string|null} [sessionId] The client's `Mcp-Session-Id`, if any.
|
|
126
|
+
* @property {string} [consoleUrl] Origin the in-process handlers should call back to (the deployment itself).
|
|
127
|
+
* @property {Record<string, string>} [authHeader] The caller's own credential to forward on those calls.
|
|
128
|
+
* @property {string} [agentName] Default `agent` for tools whose schema declares one.
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the remote handler. Returns `{ handleMessage, handleBody }`:
|
|
133
|
+
* - `handleMessage(message, ctx)` → one JSON-RPC response object, or `null`
|
|
134
|
+
* for a notification / client response (nothing to send back)
|
|
135
|
+
* - `handleBody(parsedBody, ctx)` → `{ status, body, sessionId }` for a
|
|
136
|
+
* single message or a batch; `status` 202 + `body` null when there is
|
|
137
|
+
* nothing to send (notification-only input)
|
|
138
|
+
*
|
|
139
|
+
* @param {RemoteMcpDeps} deps
|
|
140
|
+
*/
|
|
141
|
+
export function createRemoteMcpHandler(deps) {
|
|
142
|
+
const {
|
|
143
|
+
dispatch,
|
|
144
|
+
tools,
|
|
145
|
+
serverInfo,
|
|
146
|
+
instructions,
|
|
147
|
+
vaultRoot,
|
|
148
|
+
} = deps;
|
|
149
|
+
if (typeof dispatch !== 'function') throw new TypeError('createRemoteMcpHandler: dispatch is required');
|
|
150
|
+
if (!Array.isArray(tools)) throw new TypeError('createRemoteMcpHandler: tools must be an array');
|
|
151
|
+
if (typeof vaultRoot !== 'string' || !vaultRoot) throw new TypeError('createRemoteMcpHandler: vaultRoot is required');
|
|
152
|
+
|
|
153
|
+
const toolsByName = new Map(tools.map((t) => [t.name, t]));
|
|
154
|
+
const isToolAllowed = deps.isToolAllowed ?? ((name) => toolsByName.has(name));
|
|
155
|
+
const isKnownTool = deps.isKnownTool ?? isToolAllowed;
|
|
156
|
+
const config = deps.config ?? { vaultRoot };
|
|
157
|
+
const wireTools = tools.map(toWireTool);
|
|
158
|
+
|
|
159
|
+
function initializeResult(params) {
|
|
160
|
+
return {
|
|
161
|
+
protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
|
|
162
|
+
// No listChanged: the remote surface is fixed (no progressive loader),
|
|
163
|
+
// and JSON-response mode has no channel to push a notification anyway.
|
|
164
|
+
capabilities: { tools: { listChanged: false } },
|
|
165
|
+
serverInfo: { name: serverInfo?.name ?? 'heyspex', version: serverInfo?.version ?? '0.0.0-unknown' },
|
|
166
|
+
...(instructions ? { instructions } : {}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Fill `session_id` / `agent` from the transport when the tool's schema
|
|
172
|
+
* declares them and the caller left them out — a remote client has no
|
|
173
|
+
* CLAUDE_CODE_SESSION_ID env for `detectAgentIdentity` to find, so without
|
|
174
|
+
* this every remote claim would read as "External agent · local".
|
|
175
|
+
*/
|
|
176
|
+
function withIdentityDefaults(tool, args, ctx) {
|
|
177
|
+
const props = tool?.inputSchema?.properties;
|
|
178
|
+
if (!isPlainObject(props)) return args;
|
|
179
|
+
const out = { ...args };
|
|
180
|
+
if ('session_id' in props && out.session_id === undefined && ctx?.sessionId) {
|
|
181
|
+
out.session_id = ctx.sessionId;
|
|
182
|
+
}
|
|
183
|
+
if ('agent' in props && out.agent === undefined && ctx?.agentName) {
|
|
184
|
+
out.agent = ctx.agentName;
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function callTool(id, params, ctx) {
|
|
190
|
+
const name = params?.name;
|
|
191
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
192
|
+
return jsonRpcError(id, JSONRPC_ERROR.INVALID_PARAMS, 'tools/call requires params.name (string)');
|
|
193
|
+
}
|
|
194
|
+
if (!isToolAllowed(name)) {
|
|
195
|
+
return isKnownTool(name)
|
|
196
|
+
? toolNotAvailableRemotelyError(id, name)
|
|
197
|
+
: jsonRpcError(id, JSONRPC_ERROR.INVALID_PARAMS, `Unknown tool: ${name}`, { tool: name });
|
|
198
|
+
}
|
|
199
|
+
const rawArgs = params?.arguments;
|
|
200
|
+
if (rawArgs !== undefined && !isPlainObject(rawArgs)) {
|
|
201
|
+
return jsonRpcError(id, JSONRPC_ERROR.INVALID_PARAMS, 'tools/call params.arguments must be an object');
|
|
202
|
+
}
|
|
203
|
+
const args = withIdentityDefaults(toolsByName.get(name), rawArgs ?? {}, ctx);
|
|
204
|
+
const requestContext = {
|
|
205
|
+
...(ctx?.consoleUrl ? { consoleUrl: ctx.consoleUrl } : {}),
|
|
206
|
+
...(ctx?.authHeader ? { authHeader: ctx.authHeader } : {}),
|
|
207
|
+
};
|
|
208
|
+
try {
|
|
209
|
+
const result = await runWithRequestContext(requestContext, () => dispatch(name, args, vaultRoot, config));
|
|
210
|
+
if (!isPlainObject(result) || !Array.isArray(result.content)) {
|
|
211
|
+
return jsonRpcError(id, JSONRPC_ERROR.INTERNAL_ERROR, `Tool "${name}" returned a malformed result`);
|
|
212
|
+
}
|
|
213
|
+
return jsonRpcResult(id, result);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return jsonRpcError(
|
|
216
|
+
id,
|
|
217
|
+
JSONRPC_ERROR.INTERNAL_ERROR,
|
|
218
|
+
`Tool "${name}" failed: ${err?.message ?? String(err)}`,
|
|
219
|
+
{ tool: name },
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {unknown} message
|
|
226
|
+
* @param {RemoteMcpCallContext} [ctx]
|
|
227
|
+
* @returns {Promise<object|null>}
|
|
228
|
+
*/
|
|
229
|
+
async function handleMessage(message, ctx = {}) {
|
|
230
|
+
if (!isPlainObject(message) || message.jsonrpc !== JSONRPC_VERSION) {
|
|
231
|
+
return jsonRpcError(null, JSONRPC_ERROR.INVALID_REQUEST, 'Invalid Request: expected a JSON-RPC 2.0 message');
|
|
232
|
+
}
|
|
233
|
+
const { id, method, params } = message;
|
|
234
|
+
|
|
235
|
+
// A response from the client (to a server request) — we never send
|
|
236
|
+
// requests, so there is nothing to correlate. Ignore.
|
|
237
|
+
if (method === undefined && ('result' in message || 'error' in message)) return null;
|
|
238
|
+
|
|
239
|
+
if (typeof method !== 'string') {
|
|
240
|
+
return jsonRpcError(isValidId(id) ? id : null, JSONRPC_ERROR.INVALID_REQUEST, 'Invalid Request: method must be a string');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Notifications carry no id and get no response (notifications/initialized, cancelled, ...).
|
|
244
|
+
if (id === undefined || id === null) return null;
|
|
245
|
+
|
|
246
|
+
if (!isValidId(id)) {
|
|
247
|
+
return jsonRpcError(null, JSONRPC_ERROR.INVALID_REQUEST, 'Invalid Request: id must be a string or number');
|
|
248
|
+
}
|
|
249
|
+
if (params !== undefined && !isPlainObject(params)) {
|
|
250
|
+
return jsonRpcError(id, JSONRPC_ERROR.INVALID_PARAMS, 'params must be an object');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
switch (method) {
|
|
254
|
+
case 'initialize':
|
|
255
|
+
return jsonRpcResult(id, initializeResult(params));
|
|
256
|
+
case 'ping':
|
|
257
|
+
return jsonRpcResult(id, {});
|
|
258
|
+
case 'tools/list':
|
|
259
|
+
return jsonRpcResult(id, { tools: wireTools });
|
|
260
|
+
case 'tools/call':
|
|
261
|
+
return callTool(id, params, ctx);
|
|
262
|
+
default:
|
|
263
|
+
return jsonRpcError(id, JSONRPC_ERROR.METHOD_NOT_FOUND, `Method not found: ${method}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @param {unknown} parsed The JSON-decoded request body (object or array).
|
|
269
|
+
* @param {RemoteMcpCallContext} [ctx]
|
|
270
|
+
* @returns {Promise<{ status: number, body: object|object[]|null, sessionId: string|null }>}
|
|
271
|
+
*/
|
|
272
|
+
async function handleBody(parsed, ctx = {}) {
|
|
273
|
+
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
274
|
+
if (Array.isArray(parsed) && parsed.length === 0) {
|
|
275
|
+
return {
|
|
276
|
+
status: 400,
|
|
277
|
+
body: jsonRpcError(null, JSONRPC_ERROR.INVALID_REQUEST, 'Invalid Request: empty batch'),
|
|
278
|
+
sessionId: ctx.sessionId ?? null,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// `initialize` mints the session id the client will echo from then on;
|
|
283
|
+
// any other message just echoes what the client sent (stateless server).
|
|
284
|
+
const initializes = messages.some((m) => isPlainObject(m) && m.method === 'initialize');
|
|
285
|
+
const sessionId = initializes ? newSessionId() : (ctx.sessionId ?? null);
|
|
286
|
+
const callCtx = { ...ctx, sessionId };
|
|
287
|
+
|
|
288
|
+
const responses = [];
|
|
289
|
+
for (const message of messages) {
|
|
290
|
+
const response = await handleMessage(message, callCtx);
|
|
291
|
+
if (response !== null) responses.push(response);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (responses.length === 0) return { status: 202, body: null, sessionId };
|
|
295
|
+
if (Array.isArray(parsed)) return { status: 200, body: responses, sessionId };
|
|
296
|
+
return { status: 200, body: responses[0], sessionId };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { handleMessage, handleBody };
|
|
300
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Keyset paging + byte budget for every MCP tool that lists specs
|
|
2
|
+
// ([[HS-E66W-8KK5-TASK-mcp-list-tools-page-with-a-cursor-and-a]] under
|
|
3
|
+
// [[HS-0R2X-8KK5-EPIC-keyset-paging-on-every-list-and-agent]]).
|
|
4
|
+
//
|
|
5
|
+
// The contract every list-shaped tool in the registry follows:
|
|
6
|
+
// - accepts `limit` (clamped to the tool's max) plus an opaque `after`
|
|
7
|
+
// cursor, and hands back ONE page under `<rows_key>` plus `next_cursor`
|
|
8
|
+
// (a string when more rows exist, `null` on the last page — the field is
|
|
9
|
+
// always present);
|
|
10
|
+
// - never a total count — the response says what it returned, not how many
|
|
11
|
+
// rows exist ([[mcp-tool-response-size-budget]]: ~2k tokens per list call);
|
|
12
|
+
// - the serialized response stays under the tool's byte budget: when a page
|
|
13
|
+
// would exceed it, rows are dropped from the END of the page and
|
|
14
|
+
// `next_cursor` is re-pointed at the last row kept, so the caller loses
|
|
15
|
+
// nothing — it just follows the cursor. The cursor is never dropped.
|
|
16
|
+
//
|
|
17
|
+
// `LIST_SPEC_TOOLS` is the registry `list-tools.byte-budget.test.mjs` walks:
|
|
18
|
+
// every tool named here must declare `limit` + `after` in its catalog schema
|
|
19
|
+
// and prove the page + budget behaviour against a fixture vault. A tool
|
|
20
|
+
// whose name looks list-shaped but whose rows are NOT specs is classified in
|
|
21
|
+
// `NON_SPEC_LIST_TOOLS` with a reason, so a new list tool that is neither
|
|
22
|
+
// fails the registry test until someone classifies it.
|
|
23
|
+
//
|
|
24
|
+
// Cursor shape mirrors the console's keyset endpoint
|
|
25
|
+
// (`spex-console/src/lib/specs/list-query.ts`): base64url JSON over the sort
|
|
26
|
+
// key + the id tie-breaker, opaque to callers, fails closed on garbage.
|
|
27
|
+
|
|
28
|
+
/** ~2k tokens ([[mcp-tool-response-size-budget]]) at ~4 bytes per token. */
|
|
29
|
+
export const LIST_TOOL_BYTE_BUDGET = 8 * 1024;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Every MCP tool whose rows are specs. Keys are catalog tool names.
|
|
33
|
+
* rows_key — the response field carrying the page
|
|
34
|
+
* default_limit — page size when the caller passes no `limit`
|
|
35
|
+
* max_limit — hard clamp on a caller's `limit`
|
|
36
|
+
* byte_budget — ceiling on `Buffer.byteLength(content[0].text)`
|
|
37
|
+
*/
|
|
38
|
+
export const LIST_SPEC_TOOLS = Object.freeze({
|
|
39
|
+
heyspex_recall_memories: Object.freeze({
|
|
40
|
+
rows_key: 'memories',
|
|
41
|
+
default_limit: 10,
|
|
42
|
+
max_limit: 30,
|
|
43
|
+
byte_budget: LIST_TOOL_BYTE_BUDGET,
|
|
44
|
+
}),
|
|
45
|
+
heyspex_list_procedures: Object.freeze({
|
|
46
|
+
rows_key: 'procedures',
|
|
47
|
+
default_limit: 10,
|
|
48
|
+
max_limit: 50,
|
|
49
|
+
byte_budget: LIST_TOOL_BYTE_BUDGET,
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Tools whose NAME reads as a list but whose rows are not specs — exempt from
|
|
55
|
+
* the spec-paging contract, each with the reason on record. The registry test
|
|
56
|
+
* requires every list-looking catalog tool to appear in exactly one of the two
|
|
57
|
+
* maps.
|
|
58
|
+
*/
|
|
59
|
+
export const NON_SPEC_LIST_TOOLS = Object.freeze({
|
|
60
|
+
heyspex_list_known_tags: 'tag vocabulary with per-tag counts (GET /api/scheduler/known-tags) — not spec rows',
|
|
61
|
+
heyspex_claim_list: 'active claim rows from the Neon control plane — not spec rows',
|
|
62
|
+
heyspex_design_list_patterns: 'design-system registry entries — not spec rows',
|
|
63
|
+
heyspex_list_recipes: 'workflow-recipe entries — not spec rows',
|
|
64
|
+
heyspex_fleet_queue_list: 'fleet-queue task rows (bearer-authed) — not spec rows',
|
|
65
|
+
heyspex_settings_list: 'settings registry entries — not spec rows',
|
|
66
|
+
heyspex_hooks_list: 'active hook configuration — not spec rows',
|
|
67
|
+
heyspex_events_show: 'per-spec audit-event rows, already `limit`-capped — not spec rows',
|
|
68
|
+
heyspex_milestone_show: 'ONE milestone plus its needs-breakdown shape (GET /api/specs/:id) — a single spec, not a list',
|
|
69
|
+
heyspex_evidence_show: 'evidence rows attached to ONE spec — not a list of specs',
|
|
70
|
+
heyspex_load_tools: 'progressive-disclosure tool-surface loader — not a list of anything in the vault',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** The tool names the registry test treats as "list-shaped by name". */
|
|
74
|
+
export const LIST_SHAPED_NAME_PATTERN = /^heyspex_(list_|recall_|load_)|_list$|_show$/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Cursors are opaque to callers by design — malformed input fails closed as
|
|
78
|
+
* "bad cursor", never a silent restart from page 1 or a full re-scan.
|
|
79
|
+
*/
|
|
80
|
+
export class InvalidListCursorError extends Error {
|
|
81
|
+
constructor() {
|
|
82
|
+
super('invalid or corrupted `after` cursor — pass a `next_cursor` value from a prior page, or omit it for page 1');
|
|
83
|
+
this.name = 'INVALID_CURSOR';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `{ k, id }` → opaque base64url string. `k` is the sort key, `id` the tie-breaker. */
|
|
88
|
+
export function encodeListCursor({ k, id }) {
|
|
89
|
+
return Buffer.from(JSON.stringify({ k: String(k ?? ''), id: String(id ?? '') }), 'utf8').toString('base64url');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function decodeListCursor(cursor) {
|
|
93
|
+
if (typeof cursor !== 'string' || cursor.length === 0) throw new InvalidListCursorError();
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
96
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.k === 'string' && typeof parsed.id === 'string') {
|
|
97
|
+
return { k: parsed.k, id: parsed.id };
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// fall through
|
|
101
|
+
}
|
|
102
|
+
throw new InvalidListCursorError();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Clamp a caller's `limit` into `[1, max_limit]`, defaulting when absent/invalid. */
|
|
106
|
+
export function resolvePageLimit(raw, { default_limit, max_limit }) {
|
|
107
|
+
const n = Number(raw);
|
|
108
|
+
const wanted = Number.isFinite(n) && n > 0 ? Math.floor(n) : default_limit;
|
|
109
|
+
return Math.max(1, Math.min(wanted, max_limit));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function compareKeyset(a, b) {
|
|
113
|
+
return a.k.localeCompare(b.k) || a.id.localeCompare(b.id);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* One page of an in-memory row set on the keyset contract.
|
|
118
|
+
*
|
|
119
|
+
* Rows are sorted by `(keyOf(row), idOf(row))` — both must be immutable for
|
|
120
|
+
* the row's lifetime, or an edit would move it between pages — then the page
|
|
121
|
+
* is the first `limit` rows strictly AFTER the cursor position. Fetches
|
|
122
|
+
* nothing beyond what the caller already loaded; the disk tier has no index
|
|
123
|
+
* to seek (retiring that whole-vault read is
|
|
124
|
+
* [[HS-49AL-8KK5-EPIC-hot-row-cache-and-bounded-agent-tools]]'s job).
|
|
125
|
+
*
|
|
126
|
+
* @returns {{ rows: any[], next_cursor: string|null }}
|
|
127
|
+
*/
|
|
128
|
+
export function paginateRowsKeyset(rows, { limit, after, keyOf, idOf }) {
|
|
129
|
+
const keyed = rows.map((row) => ({ row, k: String(keyOf(row) ?? ''), id: String(idOf(row) ?? '') }));
|
|
130
|
+
keyed.sort(compareKeyset);
|
|
131
|
+
let start = 0;
|
|
132
|
+
if (after) {
|
|
133
|
+
const cursor = decodeListCursor(after);
|
|
134
|
+
start = keyed.findIndex((entry) => compareKeyset(entry, cursor) > 0);
|
|
135
|
+
if (start === -1) return { rows: [], next_cursor: null };
|
|
136
|
+
}
|
|
137
|
+
const page = keyed.slice(start, start + limit);
|
|
138
|
+
const hasMore = start + limit < keyed.length;
|
|
139
|
+
const last = page[page.length - 1];
|
|
140
|
+
return {
|
|
141
|
+
rows: page.map((entry) => entry.row),
|
|
142
|
+
next_cursor: hasMore && last ? encodeListCursor({ k: last.k, id: last.id }) : null,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The serializer every list tool's `content[0].text` goes through — the budget is measured on exactly this. */
|
|
147
|
+
export function serializeListResult(result) {
|
|
148
|
+
return JSON.stringify(result, null, 2);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Shrink a page until its serialized form fits `byte_budget`.
|
|
153
|
+
*
|
|
154
|
+
* Drops rows from the END of `result[rows_key]` and re-points `next_cursor`
|
|
155
|
+
* at the last row kept (via `cursorFor(row)`), so the dropped rows are the
|
|
156
|
+
* first rows of the NEXT page — nothing is lost, the cursor is never dropped.
|
|
157
|
+
* `page.budget_trimmed: true` marks a trim. The last row is never dropped:
|
|
158
|
+
* a single row that alone exceeds the budget is returned as-is (better one
|
|
159
|
+
* over-budget row than an empty page with no way forward).
|
|
160
|
+
*
|
|
161
|
+
* @returns {{ result: object, text: string, bytes: number }}
|
|
162
|
+
*/
|
|
163
|
+
export function fitPageToByteBudget(result, { rows_key, byte_budget, cursorFor }) {
|
|
164
|
+
let current = result;
|
|
165
|
+
let text = serializeListResult(current);
|
|
166
|
+
let bytes = Buffer.byteLength(text, 'utf8');
|
|
167
|
+
while (bytes > byte_budget && Array.isArray(current[rows_key]) && current[rows_key].length > 1) {
|
|
168
|
+
const kept = current[rows_key].slice(0, -1);
|
|
169
|
+
const last = kept[kept.length - 1];
|
|
170
|
+
current = {
|
|
171
|
+
...current,
|
|
172
|
+
[rows_key]: kept,
|
|
173
|
+
next_cursor: cursorFor(last),
|
|
174
|
+
page: { ...(current.page ?? {}), returned: kept.length, budget_trimmed: true },
|
|
175
|
+
};
|
|
176
|
+
text = serializeListResult(current);
|
|
177
|
+
bytes = Buffer.byteLength(text, 'utf8');
|
|
178
|
+
}
|
|
179
|
+
return { result: current, text, bytes };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The whole contract in one call, for a tool that already has its filtered
|
|
184
|
+
* rows in memory: clamp `limit`, decode `after`, page, attach the `page`
|
|
185
|
+
* envelope, then fit to the byte budget.
|
|
186
|
+
*
|
|
187
|
+
* Returns `{ result, text, bytes }` on success or `{ error }` (an
|
|
188
|
+
* `INVALID_CURSOR` payload shaped like every other tool error) when `after`
|
|
189
|
+
* does not decode. `extra` fields are spread onto the result AFTER the page
|
|
190
|
+
* (e.g. `filters_applied`), so a tool keeps its existing companion fields.
|
|
191
|
+
*/
|
|
192
|
+
export function pageListToolResult(toolName, rows, args = {}, { keyOf, idOf, extra = {} }) {
|
|
193
|
+
const spec = LIST_SPEC_TOOLS[toolName];
|
|
194
|
+
if (!spec) throw new Error(`pageListToolResult: "${toolName}" is not in LIST_SPEC_TOOLS`);
|
|
195
|
+
const limit = resolvePageLimit(args.limit, spec);
|
|
196
|
+
let page;
|
|
197
|
+
try {
|
|
198
|
+
page = paginateRowsKeyset(rows, { limit, after: args.after, keyOf, idOf });
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (err instanceof InvalidListCursorError) {
|
|
201
|
+
return { error: { error: 'INVALID_CURSOR', message: err.message, tool: toolName } };
|
|
202
|
+
}
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
return finishPagedListToolResult(toolName, { rows: page.rows, next_cursor: page.next_cursor, limit }, { keyOf, idOf, extra });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The envelope + byte-budget half of {@link pageListToolResult}, for a tool
|
|
210
|
+
* that paged EARLIER (e.g. it paged summary rows, then fetched bodies for
|
|
211
|
+
* just that page) and must not re-page — re-paging an already-paged set
|
|
212
|
+
* would report `next_cursor: null` because the set fits in one page.
|
|
213
|
+
*/
|
|
214
|
+
export function finishPagedListToolResult(toolName, { rows, next_cursor, limit }, { keyOf, idOf, extra = {} }) {
|
|
215
|
+
const spec = LIST_SPEC_TOOLS[toolName];
|
|
216
|
+
if (!spec) throw new Error(`finishPagedListToolResult: "${toolName}" is not in LIST_SPEC_TOOLS`);
|
|
217
|
+
const result = {
|
|
218
|
+
[spec.rows_key]: rows,
|
|
219
|
+
next_cursor: next_cursor ?? null,
|
|
220
|
+
page: { limit, returned: rows.length, byte_budget: spec.byte_budget, budget_trimmed: false },
|
|
221
|
+
...extra,
|
|
222
|
+
};
|
|
223
|
+
const cursorFor = (row) => encodeListCursor({ k: keyOf(row), id: idOf(row) });
|
|
224
|
+
return fitPageToByteBudget(result, { rows_key: spec.rows_key, byte_budget: spec.byte_budget, cursorFor });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Follow `GET /api/specs` pages to exhaustion for an INTERNAL scan that
|
|
229
|
+
* genuinely needs every row of a projection (today: the on-deck amendment
|
|
230
|
+
* scan over `fields=amends,basename,type`). The console route now always
|
|
231
|
+
* answers a bounded page ([[HS-C6FO-8KK5-TASK-keyset-spec-list-endpoint-query-layer]]),
|
|
232
|
+
* so a caller that reads `body.specs` once silently sees only the first 100
|
|
233
|
+
* rows. This walks `next_cursor` at the route's max page size; `maxPages`
|
|
234
|
+
* bounds the walk so a cursor that never terminates cannot loop forever.
|
|
235
|
+
*
|
|
236
|
+
* Not for tool output — a tool that lists specs pages to the CALLER instead.
|
|
237
|
+
*/
|
|
238
|
+
export async function walkSpecListPages(consoleFetch, path, { limit = 500, maxPages = 200 } = {}) {
|
|
239
|
+
const rows = [];
|
|
240
|
+
let after = null;
|
|
241
|
+
for (let i = 0; i < maxPages; i += 1) {
|
|
242
|
+
const url = new URL(path, 'http://console.invalid');
|
|
243
|
+
url.searchParams.set('limit', String(limit));
|
|
244
|
+
if (after) url.searchParams.set('after', after);
|
|
245
|
+
const pagePath = `${url.pathname}${url.search}`;
|
|
246
|
+
const res = await consoleFetch(pagePath);
|
|
247
|
+
if (!res.ok) {
|
|
248
|
+
throw new Error(`GET ${pagePath} returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`);
|
|
249
|
+
}
|
|
250
|
+
const specs = Array.isArray(res.body?.specs) ? res.body.specs : [];
|
|
251
|
+
rows.push(...specs);
|
|
252
|
+
const next = typeof res.body?.next_cursor === 'string' && res.body.next_cursor ? res.body.next_cursor : null;
|
|
253
|
+
if (!next || next === after || specs.length === 0) break;
|
|
254
|
+
after = next;
|
|
255
|
+
}
|
|
256
|
+
return rows;
|
|
257
|
+
}
|
|
@@ -96,7 +96,10 @@ export function statesMatch(received, expected) {
|
|
|
96
96
|
*
|
|
97
97
|
* <consoleUrl>/companion/connect?port=<port>&state=<nonce>&client=cli[&label=<text>]
|
|
98
98
|
*/
|
|
99
|
-
export function buildConnectUrl({ consoleUrl, port, state, client = LOGIN_CLIENT, label } = {}) {
|
|
99
|
+
export function buildConnectUrl({ consoleUrl, port, state, client = LOGIN_CLIENT, label, prompt } = {}) {
|
|
100
|
+
if (prompt !== undefined && prompt !== null && !LOGIN_PROMPTS.includes(prompt)) {
|
|
101
|
+
throw new Error(`buildConnectUrl: prompt must be one of ${LOGIN_PROMPTS.join(', ')}`);
|
|
102
|
+
}
|
|
100
103
|
if (typeof consoleUrl !== 'string' || !consoleUrl.trim()) {
|
|
101
104
|
throw new Error('buildConnectUrl: consoleUrl is required');
|
|
102
105
|
}
|
|
@@ -116,9 +119,20 @@ export function buildConnectUrl({ consoleUrl, port, state, client = LOGIN_CLIENT
|
|
|
116
119
|
url.searchParams.set('state', state);
|
|
117
120
|
url.searchParams.set('client', client || LOGIN_CLIENT);
|
|
118
121
|
if (typeof label === 'string' && label.trim()) url.searchParams.set('label', label.trim());
|
|
122
|
+
if (prompt) url.searchParams.set('prompt', prompt);
|
|
119
123
|
return url.toString();
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
/**
|
|
127
|
+
* `prompt=` values the console's /companion/connect page understands
|
|
128
|
+
* (spex-console/src/app/companion/connect/connect-flow.ts `CONNECT_PROMPTS`).
|
|
129
|
+
* With a prompt the page stops minting silently and asks "Signed in as X —
|
|
130
|
+
* continue / use a different account / create a new account" first
|
|
131
|
+
* ([[HS-HAQI-KODY-DIAG-hs-install-and-hs-connect-bind-a-new]]). Same names
|
|
132
|
+
* as OpenID Connect's `prompt` parameter.
|
|
133
|
+
*/
|
|
134
|
+
export const LOGIN_PROMPTS = Object.freeze(['select_account', 'create']);
|
|
135
|
+
|
|
122
136
|
/**
|
|
123
137
|
* Reasons arrive from a browser redirect, i.e. from outside this process.
|
|
124
138
|
* Clamp them to a short opaque token before they reach a terminal, a log, or
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Resolved MCP config: vault root, console URL, agent identity. Extracted from
|
|
2
|
+
// cli.mjs under [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]]
|
|
3
|
+
// (no behavior change).
|
|
4
|
+
|
|
5
|
+
import { contextConsoleUrl } from './request-context.mjs';
|
|
6
|
+
import { getActiveConfig } from './vault-resolve.mjs';
|
|
7
|
+
import { resolveConsoleUrl } from '../../scripts/lib/console-url-resolver.mjs';
|
|
8
|
+
|
|
9
|
+
export function getConfig() {
|
|
10
|
+
// Vault resolution is delegated to the shared 5-tier resolver
|
|
11
|
+
// (vault-resolve.mjs) so the MCP face agrees with the console face on which
|
|
12
|
+
// vault is active ([[HS-ICDI-TASK]] / [[HS-PNH2-RES]]). vaultRoot stays the
|
|
13
|
+
// repo root — every downstream call site joins `specs/` off it. Backward
|
|
14
|
+
// compatible: with only HEYSPEX_VAULT_ROOT set and no registry, the resolver
|
|
15
|
+
// returns that same repo root (tier 3).
|
|
16
|
+
// vaultId + vaultSource ([[HS-M95C]]): the locally resolved identity, so a
|
|
17
|
+
// cloud console request can be scoped to THIS repo's vault instead of the
|
|
18
|
+
// account's active-vault pointer (see console-fetch.mjs `getVaultIdHeader`).
|
|
19
|
+
const { repoRootAbs, vaultId, source: vaultSource } = getActiveConfig();
|
|
20
|
+
const vaultRoot = repoRootAbs;
|
|
21
|
+
|
|
22
|
+
const defaultRoadmapId = process.env.HEYSPEX_ROADMAP_ID || 'HS-ROADMAP-PRODUCT';
|
|
23
|
+
|
|
24
|
+
return { vaultRoot, vaultId, vaultSource, defaultRoadmapId };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Resolved on every call (no caching): the MCP process is spawned once at
|
|
28
|
+
// session start, but the console dev server may restart on a different port
|
|
29
|
+
// later. Delegates to the ONE shared resolver
|
|
30
|
+
// (scripts/lib/console-url-resolver.mjs, [[HS-GD45-MCPC-TASK]]) — MODE-AWARE
|
|
31
|
+
// precedence ([[HS-VOJ7-YL1C-DIAG-console-url-resolver-ignores-dev-mode]]):
|
|
32
|
+
// HEYSPEX_CONSOLE_URL env → ~/.heyspex/credentials.yaml `console_url` (written
|
|
33
|
+
// by `hs mcp pair` / `hs login` / `hs dev mode:<...>`) → `.heyspex/console-url`
|
|
34
|
+
// discovery file (written by `npm run dev`, see
|
|
35
|
+
// `spex-console/scripts/ensure-next-dev.mjs`) but ONLY in local reliance →
|
|
36
|
+
// the console URL derived from the resolved dev mode (prod / dev+cloud →
|
|
37
|
+
// https://app.heyspex.com, dev+local → http://localhost:3000).
|
|
38
|
+
export function getConsoleUrl() {
|
|
39
|
+
const { vaultRoot } = getConfig();
|
|
40
|
+
return resolveConsoleUrl({ repoRoot: vaultRoot });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The console origin THIS call actually talks to.
|
|
45
|
+
*
|
|
46
|
+
* `getConsoleUrl()` answers the PROCESS-wide question (env → credentials →
|
|
47
|
+
* discovery file → dev-mode default), which is the right answer for the stdio
|
|
48
|
+
* server. The console's remote endpoint (`POST /api/mcp`) runs the same
|
|
49
|
+
* handlers in-process and binds the deployment's OWN origin to the async call
|
|
50
|
+
* chain (request-context.mjs), so there `getConsoleUrl()` names a host the
|
|
51
|
+
* request never contacted — which is how a failure got reported "against
|
|
52
|
+
* console https://app.heyspex.com" when the fetch had gone to `selfOrigin()`.
|
|
53
|
+
* Every diagnostic that NAMES the console (error labels, `attempted`
|
|
54
|
+
* payloads, tier reports) must use this, not `getConsoleUrl()`, so the URL in
|
|
55
|
+
* the message is the URL that was used.
|
|
56
|
+
*/
|
|
57
|
+
export function effectiveConsoleUrl() {
|
|
58
|
+
return contextConsoleUrl() ?? getConsoleUrl();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function detectAgentIdentity(argAgent, argSession) {
|
|
62
|
+
const trimmedAgent = typeof argAgent === 'string' ? argAgent.trim() : '';
|
|
63
|
+
const trimmedSession = typeof argSession === 'string' ? argSession.trim() : '';
|
|
64
|
+
if (trimmedAgent && trimmedSession) {
|
|
65
|
+
return { agent: trimmedAgent, sessionId: trimmedSession };
|
|
66
|
+
}
|
|
67
|
+
const claudeSession =
|
|
68
|
+
(process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || '').trim();
|
|
69
|
+
if (claudeSession) {
|
|
70
|
+
return {
|
|
71
|
+
agent: trimmedAgent || 'Claude Code',
|
|
72
|
+
sessionId: trimmedSession || claudeSession,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const cursorSession =
|
|
76
|
+
(process.env.CURSOR_TRACE_ID
|
|
77
|
+
|| process.env.CURSOR_SESSION_ID
|
|
78
|
+
|| process.env.COMPOSER_SESSION_ID
|
|
79
|
+
|| '').trim();
|
|
80
|
+
if (cursorSession) {
|
|
81
|
+
return {
|
|
82
|
+
agent: trimmedAgent || 'Cursor',
|
|
83
|
+
sessionId: trimmedSession || cursorSession,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
agent: trimmedAgent || process.env.HEYSPEX_AGENT?.trim() || 'External agent',
|
|
88
|
+
sessionId:
|
|
89
|
+
trimmedSession
|
|
90
|
+
|| process.env.HEYSPEX_SESSION_ID?.trim()
|
|
91
|
+
|| process.env.HEYSPEX_AGENT_SESSION?.trim()
|
|
92
|
+
|| 'local',
|
|
93
|
+
};
|
|
94
|
+
}
|