heyspex 0.1.0 → 0.2.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/.claude-plugin/marketplace.json +14 -0
- package/README.md +21 -0
- package/bin/hs.mjs +1089 -104
- 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 +1127 -103
- package/dist/cli/hs-claim.mjs +981 -63
- package/dist/cli/hs-connect.mjs +10059 -1729
- package/dist/cli/hs-defer.mjs +5507 -3072
- package/dist/cli/hs-dev.mjs +576 -0
- package/dist/cli/hs-env.mjs +416 -302
- package/dist/cli/hs-index-rebuild.mjs +7334 -0
- package/dist/cli/hs-index-reconcile.mjs +8138 -0
- package/dist/cli/hs-init.mjs +2840 -149
- package/dist/cli/hs-install.mjs +7214 -594
- package/dist/cli/hs-join.mjs +10640 -2421
- package/dist/cli/hs-vault-pull.mjs +8157 -6370
- package/dist/cli/hs-vault-push.mjs +6433 -0
- package/dist/cli/hs-vault.mjs +4029 -1555
- package/dist/cli/hs-workspace.mjs +2702 -538
- 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 +30 -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 +28 -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 +33 -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 +32 -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/copilot-plugin-install.md +162 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/docs/mcp-client-config.md +259 -6
- package/package.json +249 -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/README.md +309 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
- package/plugins/heyspex/bin/run-hook.mjs +80 -0
- package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
- package/plugins/heyspex/hooks/hooks.json +418 -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 +456 -0
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
- package/scripts/claude-hooks/_run.mjs +413 -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-execution-gate.mjs +51 -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 +577 -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/diagnose-capture-reminder.mjs +11 -0
- package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -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 +86 -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 +279 -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 +451 -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/vault-mirror-catchup.mjs +11 -0
- package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
- package/scripts/cursor-hooks/manifest.mjs +281 -0
- package/scripts/dev-server-status.mjs +34 -13
- package/scripts/dev-server-stop.mjs +313 -0
- package/scripts/heyspex-mode.mjs +288 -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 +496 -0
- package/scripts/hs-worktree.mjs +201 -0
- package/scripts/install-claude-code-hooks.mjs +272 -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 +199 -61
- package/scripts/lib/customer-kit.mjs +1203 -0
- package/scripts/lib/dev-mode.mjs +249 -32
- package/scripts/lib/directive-obligations.mjs +349 -0
- package/scripts/lib/heyspex-home.mjs +72 -0
- package/scripts/lib/hook-dispositions.mjs +210 -0
- package/scripts/lib/hook-vault-header.mjs +30 -0
- package/scripts/lib/hs-command.mjs +277 -0
- package/scripts/lib/hs-companion-settings.mjs +472 -0
- package/scripts/lib/hs-source-branch.mjs +196 -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 +13 -22
- package/scripts/lib/hs-source-preflight.mjs +18 -4
- package/scripts/lib/hs-source-push.mjs +386 -307
- 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/lib/window-placement.mjs +109 -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 +449 -0
- package/spexharness/src/adapters/copilot.mjs +236 -0
- package/spexharness/src/adapters/detect-harness.mjs +34 -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/_shell-write-targets.mjs +77 -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 +275 -0
- package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
- package/spexharness/src/gates/directive-obligation-gate.mjs +183 -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/gates/vault-mirror-catchup.mjs +124 -0
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
- package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
- package/spexharness/src/git-reflog-audit.mjs +294 -0
- package/spexharness/src/intent-classifier.mjs +196 -0
- package/spexharness/src/intent-directive.mjs +626 -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 +849 -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 +96 -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 +77 -8
- package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
- package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
- package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
- package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
- package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
- package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
- package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
- package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
- package/vendor/heyspex-mcp/cli.mjs +207 -15524
- package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
- package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -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 +822 -17
- package/vendor/heyspex-mcp/console-transport.mjs +172 -0
- package/vendor/heyspex-mcp/credentials.mjs +585 -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 +131 -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 +723 -0
- package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
- package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
- package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
- package/vendor/heyspex-mcp/http-transport.mjs +302 -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 +107 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
- package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
- package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -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 +908 -22
- 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 +327 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
- package/vendor/heyspex-mcp/promote-capture.mjs +388 -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 +666 -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 +191 -15
- 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 +83 -0
- package/vendor/heyspex-mcp/research-focus.mjs +207 -4
- package/vendor/heyspex-mcp/research-handlers.mjs +625 -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/roadmap-default.mjs +70 -0
- 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 +109 -0
- package/vendor/heyspex-mcp/session-tools.mjs +162 -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 +453 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
- package/vendor/heyspex-mcp/split-research.mjs +304 -56
- package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
- package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
- package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
- package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
- package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -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 +720 -0
- package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
- package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
- package/vendor/heyspex-mcp/whoami.mjs +92 -0
- package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
|
@@ -25,11 +25,82 @@
|
|
|
25
25
|
* it to every outbound request — `consoleFetch` AND `postClaimEndpoint` share
|
|
26
26
|
* one transport, so both get it for free. No token resolvable → no header,
|
|
27
27
|
* exactly today's (unauthenticated) behavior against a local dev console.
|
|
28
|
+
*
|
|
29
|
+
* SIGN-IN vs OUTAGE ([[HS-9R6S-N8VP-DIAG-capture-writes-report-the-console-as]]):
|
|
30
|
+
* a refused connection is NOT proof the console is down — it is equally the
|
|
31
|
+
* symptom of talking to the wrong console. After recovery has failed, a
|
|
32
|
+
* status-0 result is re-classified against the resolved reliance
|
|
33
|
+
* (scripts/lib/dev-mode.mjs) + the resolved bearer token:
|
|
34
|
+
*
|
|
35
|
+
* - cloud reliance + loopback URL → `CONSOLE_LOCAL_URL_IN_CLOUD_MODE`
|
|
36
|
+
* - cloud reliance + no token → `CONSOLE_NOT_SIGNED_IN`
|
|
37
|
+
* - anything else → `CONSOLE_UNREACHABLE` (unchanged)
|
|
38
|
+
* - an answered HTTP 401 → `CONSOLE_NOT_SIGNED_IN`
|
|
39
|
+
*
|
|
40
|
+
* Both new classes carry `authProblem` on the result. Callers MUST NOT treat
|
|
41
|
+
* an `authProblem` result as bufferable: nothing becomes reachable until the
|
|
42
|
+
* user runs `hs login`, so stashing to the offline buffer would hide the real
|
|
43
|
+
* cause behind a false "your words will sync" promise. The 4xx →
|
|
44
|
+
* CAPTURE_CONFLICT / 5xx → PATCH_FAILED contract is untouched — both still
|
|
45
|
+
* mean "the server ANSWERED", and neither buffers; 401 only gets a more
|
|
46
|
+
* specific code and an actionable message inside that same never-buffer family.
|
|
47
|
+
*
|
|
48
|
+
* EXPLICIT SCOPE ([[HS-MVG4-WW9R-TASK-mcp-and-hs-name-account-vault-on-every]],
|
|
49
|
+
* invariant 2 of [[HS-BIWR-WW9R-EPIC-explicit-vault-scope-on-every-console]]):
|
|
50
|
+
* every vault-scoped request names `{ account, vault }` on the wire —
|
|
51
|
+
* `x-heyspex-vault-id` from the resolver (ANY source, the registry pointer
|
|
52
|
+
* included) and `x-heyspex-account-id` from the one account reader
|
|
53
|
+
* (session-owner.mjs). A cwd that resolves to an UNREGISTERED root, or a
|
|
54
|
+
* machine with no configured account, is refused LOCALLY before any fetch:
|
|
55
|
+
* `consoleFetch` / `postClaimEndpoint` return a status-0 result carrying
|
|
56
|
+
* `refusal: VAULT_NOT_REGISTERED | ACCOUNT_NOT_CONFIGURED` (also stamped as
|
|
57
|
+
* `authProblem`, so every existing never-buffer path treats it the same way a
|
|
58
|
+
* sign-in problem is treated) and a message naming `hs connect` / `hs login`.
|
|
59
|
+
* The endpoints in {@link VAULT_LESS_ENDPOINTS} (pairing, session identity,
|
|
60
|
+
* creating/listing vaults and workspaces, dev-mode) are exempt: headers are
|
|
61
|
+
* attached when they resolve, and nothing refuses when they do not.
|
|
62
|
+
*
|
|
63
|
+
* NO PIN, NO POINTER (diagnose "unpinned repo silently follows the
|
|
64
|
+
* account-wide active-vault pointer"; client half of
|
|
65
|
+
* [[HS-W3JF-PEGM-DIAG-one-account-wide-active-vault-pointer]] fix 2, "fail
|
|
66
|
+
* closed on a missing binding"): a repo that names no vault — no heyspex.yaml,
|
|
67
|
+
* no specs/, no satellite pin in its MCP manifest, no HEYSPEX_VAULT_ROOT/DIR —
|
|
68
|
+
* resolves at tier 4 to the registry's `active_vault_id` (`source:
|
|
69
|
+
* 'account-pointer'`), which mirrors whatever the owner last clicked in the
|
|
70
|
+
* console's vault switcher. On 2026-10-10 a session in the code repo resolved
|
|
71
|
+
* that way to a throwaway test vault, and every claim/file/submit would have
|
|
72
|
+
* landed there silently. So a pointer-sourced result is a THIRD local
|
|
73
|
+
* refusal, `VAULT_NOT_PINNED` ({@link VaultNotPinnedError}): the message names
|
|
74
|
+
* the vault the pointer would have used and the runnable pin (`hs install
|
|
75
|
+
* --attach-vault <id> --target <repo>` for a code repo, `hs connect` for a
|
|
76
|
+
* vault). The resolver's tier ORDER is untouched (decision memory
|
|
77
|
+
* `vault-resolver-precedence`) — only what the transport does with the
|
|
78
|
+
* result changes. Deliberate pointer use is the env hatch
|
|
79
|
+
* `HEYSPEX_ALLOW_POINTER_VAULT=1` ({@link pointerVaultAllowed}), which
|
|
80
|
+
* restores the old behaviour and announces the vault once per process on
|
|
81
|
+
* stderr ({@link announcePointerVaultOnce}).
|
|
28
82
|
*/
|
|
83
|
+
import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
|
|
29
84
|
import { readFileSync } from 'node:fs';
|
|
30
85
|
import { homedir } from 'node:os';
|
|
31
86
|
import { join } from 'node:path';
|
|
32
87
|
|
|
88
|
+
import { readBearer } from './credentials.mjs';
|
|
89
|
+
import { resolveSessionOwnerUncached } from './session-owner.mjs';
|
|
90
|
+
import { isVaultLessEndpoint, VAULT_LESS_ENDPOINTS } from './vault-less-endpoints.mjs';
|
|
91
|
+
|
|
92
|
+
export { isVaultLessEndpoint, VAULT_LESS_ENDPOINTS };
|
|
93
|
+
|
|
94
|
+
// The runnable form of a HeySpex command on THIS machine — `hs login` when the
|
|
95
|
+
// bin is on PATH, else an absolute-path `node "<install>/bin/hs.mjs" login`.
|
|
96
|
+
// Never hard-code the bare `hs …` spelling in text a human reads, and never an
|
|
97
|
+
// `npx` form: npx resolves from the READER's working directory, and outside a
|
|
98
|
+
// checkout `npx hs` installs the unrelated public package hs@2.0.1
|
|
99
|
+
// ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
|
|
100
|
+
import { hsCommand } from '../../scripts/lib/hs-command.mjs';
|
|
101
|
+
import { maybeDrainOnFirstSuccess } from './write-outbox.mjs';
|
|
102
|
+
import { getActiveConfig, registryAccountIdForRoot, UNREGISTERED_VAULT_ID } from './vault-resolve.mjs';
|
|
103
|
+
|
|
33
104
|
export const DEFAULT_CONSOLE_TIMEOUT_MS = 30000;
|
|
34
105
|
|
|
35
106
|
/** Backoff when Next dev restarts (next.config change, etc.) — ECONNREFUSED is transient. */
|
|
@@ -39,6 +110,26 @@ function sleep(ms) {
|
|
|
39
110
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
40
111
|
}
|
|
41
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Cold cloud lambda ([[HS-2FQY]] read side): the console answers 503
|
|
115
|
+
* `vault_scope_unresolved` (+ `Retry-After`) while it has not resolved its
|
|
116
|
+
* active vault yet, instead of a 200 empty bucket that reads as "the vault is
|
|
117
|
+
* empty". Transient by contract — the console kicks its own self-heal — so
|
|
118
|
+
* the client waits the advertised window (capped) and tries once more.
|
|
119
|
+
*/
|
|
120
|
+
export const VAULT_SCOPE_RETRY_CAP_MS = 5000;
|
|
121
|
+
export const VAULT_SCOPE_RETRY_DEFAULT_MS = 3000;
|
|
122
|
+
|
|
123
|
+
export function isVaultScopeUnresolved(result) {
|
|
124
|
+
return result?.status === 503 && result?.body?.error === 'vault_scope_unresolved';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function retryAfterMs(result) {
|
|
128
|
+
const raw = Number(result?.retryAfterSeconds);
|
|
129
|
+
if (!Number.isFinite(raw) || raw <= 0) return VAULT_SCOPE_RETRY_DEFAULT_MS;
|
|
130
|
+
return Math.min(raw * 1000, VAULT_SCOPE_RETRY_CAP_MS);
|
|
131
|
+
}
|
|
132
|
+
|
|
42
133
|
/**
|
|
43
134
|
* Detect AbortError from a thrown fetch error. Covers the standard
|
|
44
135
|
* `AbortError` name and the substring fallback some runtimes use
|
|
@@ -98,26 +189,553 @@ export function rewriteUrlOrigin(urlString, originUrl) {
|
|
|
98
189
|
return target.href;
|
|
99
190
|
}
|
|
100
191
|
|
|
192
|
+
/** Local refusal: the repo records an account this machine holds no token for. */
|
|
193
|
+
export const ACCOUNT_TOKEN_MISSING = 'account_token_missing';
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Thrown by {@link getAuthHeader} when the repo's heyspex.yaml records
|
|
197
|
+
* `account: <A>` and this machine stores no token for A
|
|
198
|
+
* ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]], invariant 4). The
|
|
199
|
+
* transport refuses every console call with it rather than sending the
|
|
200
|
+
* CURRENT account's token — that fallback is exactly how a repo connected as
|
|
201
|
+
* A wrote as B after `hs login --switch-account B`. Names `hs login --account A`.
|
|
202
|
+
*/
|
|
203
|
+
export class AccountTokenMissingError extends Error {
|
|
204
|
+
/** @param {string} accountId the account the repo records */
|
|
205
|
+
constructor(accountId) {
|
|
206
|
+
super(
|
|
207
|
+
`This repo is connected under HeySpex account ${accountId} (its heyspex.yaml \`account:\`), `
|
|
208
|
+
+ 'but this machine holds no sign-in for that account, so the console call was refused before it was sent. '
|
|
209
|
+
+ `Run \`${hsCommand(`login --account ${accountId}`)}\` to sign in as it, then try again. `
|
|
210
|
+
+ 'Nothing was sent, and the current account was NOT used in its place.',
|
|
211
|
+
);
|
|
212
|
+
this.name = 'AccountTokenMissingError';
|
|
213
|
+
this.code = ACCOUNT_TOKEN_MISSING;
|
|
214
|
+
this.accountId = accountId;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// `account:` line of a repo's heyspex.yaml — the account `hs connect` recorded
|
|
219
|
+
// the repo under. Same line-anchored shape as {@link MARKER_VAULT_ID_RE} and
|
|
220
|
+
// as init.ts `parseAccountId` / install-identity.ts `parseMarkerAccount`.
|
|
221
|
+
const MARKER_ACCOUNT_RE = /^account:[ \t]*["']?([^"'\r\n#]+)/m;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The `account` a repo's `heyspex.yaml` records, or `null` (no marker, no
|
|
225
|
+
* key, unreadable). Never throws. `readFile` is a test seam.
|
|
226
|
+
* @param {string | null | undefined} repoRootAbs
|
|
227
|
+
* @param {{ readFile?: (p: string) => string }} [opts]
|
|
228
|
+
*/
|
|
229
|
+
export function readMarkerAccount(repoRootAbs, { readFile } = {}) {
|
|
230
|
+
if (typeof repoRootAbs !== 'string' || !repoRootAbs.trim()) return null;
|
|
231
|
+
const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
|
|
232
|
+
try {
|
|
233
|
+
const raw = read(join(repoRootAbs, 'heyspex.yaml'));
|
|
234
|
+
const value = String(raw ?? '').match(MARKER_ACCOUNT_RE)?.[1]?.trim() ?? '';
|
|
235
|
+
return value || null;
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The account THIS process's repo records: the resolver's root (memoized
|
|
243
|
+
* `getActiveConfig`) → its heyspex.yaml `account:`. Read per call, so an `hs
|
|
244
|
+
* connect` backfill is seen without a restart. `null` when the resolver
|
|
245
|
+
* fails or the marker records none (a legacy marker: the current account
|
|
246
|
+
* applies, unchanged).
|
|
247
|
+
* @returns {string | null}
|
|
248
|
+
*/
|
|
249
|
+
export function getRepoAccountId({ homeDir } = {}) {
|
|
250
|
+
let resolved;
|
|
251
|
+
try {
|
|
252
|
+
resolved = getActiveConfig();
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
// Machine-local registry row, NOT the committed marker: a legacy
|
|
257
|
+
// `account:` line in heyspex.yaml is ignored (owner ruling 2026-10-16).
|
|
258
|
+
return registryAccountIdForRoot(
|
|
259
|
+
resolved?.repoRootAbs,
|
|
260
|
+
homeDir ? { registryDir: join(homeDir, '.heyspex') } : {},
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
101
264
|
/**
|
|
102
265
|
* Resolve the outbound `Authorization` header, or `{}` when no token
|
|
103
266
|
* resolves (no header at all — matches today's unauthenticated behavior
|
|
104
267
|
* against a local dev console). Precedence: `HEYSPEX_MCP_TOKEN` env →
|
|
105
|
-
* `~/.heyspex/credentials.yaml`
|
|
106
|
-
*
|
|
268
|
+
* `~/.heyspex/credentials.yaml` — read by the ONE shared bearer reader
|
|
269
|
+
* (`./credentials.mjs`, [[HS-HL0D-WW9R-TASK-share-one-bearer-reader-and-one-console]]),
|
|
270
|
+
* never re-implemented here.
|
|
271
|
+
*
|
|
272
|
+
* Which stored token: the REPO's account when its heyspex.yaml records one
|
|
273
|
+
* (`repoAccountId`, default {@link getRepoAccountId}), else the current
|
|
274
|
+
* account ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]]). A recorded
|
|
275
|
+
* account with no stored token throws {@link AccountTokenMissingError} — never
|
|
276
|
+
* a silent fall back to the current account. Injectable `env`/`homeDir`/
|
|
277
|
+
* `repoAccountId` for tests (`null` = "the repo records none"); an unreadable
|
|
278
|
+
* credentials file is just "no token".
|
|
279
|
+
* @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, repoAccountId?: string | null }} [opts]
|
|
280
|
+
*/
|
|
281
|
+
export function getAuthHeader({ env = process.env, homeDir = heyspexHome(), repoAccountId } = {}) {
|
|
282
|
+
const accountId = repoAccountId === undefined ? getRepoAccountId({ homeDir }) : repoAccountId;
|
|
283
|
+
const { token } = readBearer({ env, homeDir, accountId });
|
|
284
|
+
if (!token && accountId) throw new AccountTokenMissingError(accountId);
|
|
285
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* VAULT SCOPING ([[HS-M95C]]): a cloud console scopes every request to the
|
|
290
|
+
* account's active-vault POINTER unless the request names a vault. The MCP
|
|
291
|
+
* server resolves its vault locally (vault-resolve.mjs, five tiers) but never
|
|
292
|
+
* put that id on the wire, so a session started in repo A read/wrote vault B.
|
|
293
|
+
*
|
|
294
|
+
* Contract (agreed with the console side, revised by
|
|
295
|
+
* [[HS-MVG4-WW9R-TASK-mcp-and-hs-name-account-vault-on-every]]): send
|
|
296
|
+
* `x-heyspex-vault-id: <id>` for EVERY registered id a REPO-DERIVED tier
|
|
297
|
+
* produced (`explicit`, `env-vault-root`, `env-vault-dir`, `satellite-pin`,
|
|
298
|
+
* `cwd-marker`, `marker-registry-mismatch`, `cwd-heuristic`). The registry's
|
|
299
|
+
* active pointer (`account-pointer`) used to send nothing so the cloud console
|
|
300
|
+
* could fall back to ITS pointer; HS-MVG4 then put the pointer's id on the
|
|
301
|
+
* wire too, because the two pointers drift and a silent fallback is exactly
|
|
302
|
+
* what served repo A's session from vault B. Both shapes share the same
|
|
303
|
+
* defect — the repo never said which vault it meant — so a pointer-sourced
|
|
304
|
+
* result is now REFUSED locally ({@link VaultNotPinnedError}) unless the
|
|
305
|
+
* `HEYSPEX_ALLOW_POINTER_VAULT=1` hatch is set, in which case the HS-MVG4
|
|
306
|
+
* behaviour (pointer id on the wire) applies. An UNREGISTERED root is not a
|
|
307
|
+
* "no header" case either: it throws {@link VaultNotRegisteredError} so the
|
|
308
|
+
* transport refuses before any fetch.
|
|
309
|
+
*/
|
|
310
|
+
export const VAULT_ID_HEADER = 'x-heyspex-vault-id';
|
|
311
|
+
/** The account the client believes it acts as; the console refuses a mismatch with 409 `ACCOUNT_MISMATCH`. */
|
|
312
|
+
export const ACCOUNT_ID_HEADER = 'x-heyspex-account-id';
|
|
313
|
+
|
|
314
|
+
/** Local refusal: the cwd resolves to a root no registry row names. */
|
|
315
|
+
export const VAULT_NOT_REGISTERED = 'VAULT_NOT_REGISTERED';
|
|
316
|
+
/** Local refusal: this machine has no configured account (`hs login` never ran). */
|
|
317
|
+
export const ACCOUNT_NOT_CONFIGURED = 'ACCOUNT_NOT_CONFIGURED';
|
|
318
|
+
/** Local refusal: the repo names no vault, so only the account-wide pointer answered. */
|
|
319
|
+
export const VAULT_NOT_PINNED = 'VAULT_NOT_PINNED';
|
|
320
|
+
|
|
321
|
+
/** The resolver tier that reads the registry's account-wide `active_vault_id` (vault-resolve.mjs tier 4). */
|
|
322
|
+
export const POINTER_VAULT_SOURCE = 'account-pointer';
|
|
323
|
+
/** Env hatch: `1`/`true` lets a pointer-sourced vault go on the wire (old behaviour), announced once per process. */
|
|
324
|
+
export const ALLOW_POINTER_VAULT_ENV = 'HEYSPEX_ALLOW_POINTER_VAULT';
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Is the deliberate-pointer hatch set? Pure over `env`.
|
|
328
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
329
|
+
*/
|
|
330
|
+
export function pointerVaultAllowed(env = process.env) {
|
|
331
|
+
const raw = env?.[ALLOW_POINTER_VAULT_ENV];
|
|
332
|
+
if (typeof raw !== 'string') return false;
|
|
333
|
+
const v = raw.trim().toLowerCase();
|
|
334
|
+
return v === '1' || v === 'true' || v === 'yes';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Thrown by {@link vaultIdHeaderFor} / {@link getVaultIdHeader} when the
|
|
339
|
+
* resolver answered from the account-wide pointer (`source:
|
|
340
|
+
* 'account-pointer'`) and the hatch is off: the repo the session runs in
|
|
341
|
+
* names no vault, so the only thing that chose one was whatever the owner
|
|
342
|
+
* last selected in the console. `code` is stable for machine matching; the
|
|
343
|
+
* message names the vault the pointer WOULD have used (id + root), the repo
|
|
344
|
+
* that needs a pin, and the runnable pin for each repo kind.
|
|
345
|
+
*/
|
|
346
|
+
export class VaultNotPinnedError extends Error {
|
|
347
|
+
/**
|
|
348
|
+
* @param {{ vaultId?: string | null, repoRootAbs?: string | null, source?: string | null } | null | undefined} resolved
|
|
349
|
+
* the pointer-sourced resolver result (its `repoRootAbs` is the POINTER vault's root)
|
|
350
|
+
* @param {{ cwd?: string | null }} [detail] `cwd`: the unpinned repo the session runs in
|
|
351
|
+
*/
|
|
352
|
+
constructor(resolved, { cwd = null } = {}) {
|
|
353
|
+
const clean = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
354
|
+
const vaultId = clean(resolved?.vaultId);
|
|
355
|
+
const vaultRoot = clean(resolved?.repoRootAbs);
|
|
356
|
+
const repo = clean(cwd);
|
|
357
|
+
const here = repo ? ` (${repo})` : '';
|
|
358
|
+
const wouldUse = vaultId
|
|
359
|
+
? `vault ${vaultId}${vaultRoot ? ` (${vaultRoot})` : ''}`
|
|
360
|
+
: 'an unknown vault';
|
|
361
|
+
// Both commands render through `hsCommand` so they run on the machine
|
|
362
|
+
// reading them ([[HS-I6HT]]); `<repo>` stands in when the cwd is unknown.
|
|
363
|
+
const attach = hsCommand(`install --attach-vault ${vaultId ?? '<vault-id>'} --target ${repo ?? '<repo>'}`);
|
|
364
|
+
const connect = hsCommand('connect');
|
|
365
|
+
super(
|
|
366
|
+
`This repo${here} names no HeySpex vault — no heyspex.yaml, no specs/ dir, and no HEYSPEX_VAULT_ROOT pin in its .mcp.json — `
|
|
367
|
+
+ 'so the console call was refused before it was sent. Without a pin the session would silently follow the '
|
|
368
|
+
+ 'account-wide active-vault pointer (whatever vault was last selected in the console or by `hs vault use`), '
|
|
369
|
+
+ `which right now names ${wouldUse}. `
|
|
370
|
+
+ 'Pin a vault, then restart the MCP server: '
|
|
371
|
+
+ `for a code repo run \`${attach}\`; for a vault repo run \`${connect}\` inside it. `
|
|
372
|
+
+ `To follow the pointer on purpose, start the server with ${ALLOW_POINTER_VAULT_ENV}=1. Nothing was sent.`,
|
|
373
|
+
);
|
|
374
|
+
this.name = 'VaultNotPinnedError';
|
|
375
|
+
this.code = VAULT_NOT_PINNED;
|
|
376
|
+
this.vaultId = vaultId;
|
|
377
|
+
this.vaultRootAbs = vaultRoot;
|
|
378
|
+
this.repoRootAbs = repo;
|
|
379
|
+
this.source = clean(resolved?.source);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** One line for stderr naming the pointer vault the hatch admitted. Pure. */
|
|
384
|
+
export function pointerVaultNotice(resolved) {
|
|
385
|
+
const id = typeof resolved?.vaultId === 'string' && resolved.vaultId.trim() ? resolved.vaultId.trim() : 'unknown';
|
|
386
|
+
const root = typeof resolved?.repoRootAbs === 'string' && resolved.repoRootAbs.trim() ? ` (${resolved.repoRootAbs.trim()})` : '';
|
|
387
|
+
return `[heyspex-mcp] ${ALLOW_POINTER_VAULT_ENV} is set: this repo names no vault, so console calls follow the account-wide active-vault pointer — vault ${id}${root}. Every claim/file/submit this session lands there.\n`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let _pointerVaultAnnounced = false;
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Write {@link pointerVaultNotice} to stderr ONCE per process, and only when
|
|
394
|
+
* `resolved` is pointer-sourced AND the hatch is on. Called at stdio-server
|
|
395
|
+
* start (cli.mjs) and lazily by {@link vaultIdHeaderFor} when the hatch
|
|
396
|
+
* admits a pointer header (covers the `cli.mjs call …` subprocesses), so
|
|
397
|
+
* every process that acts on the pointer says so exactly once. Returns
|
|
398
|
+
* `true` iff it wrote. Never throws.
|
|
399
|
+
* @param {{ vaultId?: string, repoRootAbs?: string, source?: string } | null | undefined} resolved
|
|
400
|
+
* @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, write?: (line: string) => unknown }} [opts]
|
|
401
|
+
*/
|
|
402
|
+
export function announcePointerVaultOnce(resolved, { env = process.env, write } = {}) {
|
|
403
|
+
if (_pointerVaultAnnounced) return false;
|
|
404
|
+
if (resolved?.source !== POINTER_VAULT_SOURCE || !pointerVaultAllowed(env)) return false;
|
|
405
|
+
_pointerVaultAnnounced = true;
|
|
406
|
+
try {
|
|
407
|
+
(write ?? ((line) => process.stderr.write(line)))(pointerVaultNotice(resolved));
|
|
408
|
+
} catch {
|
|
409
|
+
/* a stderr failure never becomes a transport failure */
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Test-only: forget that the pointer notice was written, so the next process-start path writes it again. */
|
|
415
|
+
export function resetPointerVaultNoticeForTests() {
|
|
416
|
+
_pointerVaultAnnounced = false;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Thrown by {@link vaultIdHeaderFor} / {@link getVaultIdHeader} when the
|
|
421
|
+
* resolver landed on the `unregistered` sentinel (`reason: 'unregistered'`),
|
|
422
|
+
* or on a registered row whose id is a LEGACY slug with no cloud UUID to
|
|
423
|
+
* stand in for it (`reason: 'legacy-id'`, see {@link cloudVaultIdFor}).
|
|
424
|
+
* `code` is stable for machine matching; the message names the resolved root
|
|
425
|
+
* and the runnable `hs connect` form for this machine.
|
|
426
|
+
*/
|
|
427
|
+
export class VaultNotRegisteredError extends Error {
|
|
428
|
+
/**
|
|
429
|
+
* @param {string | null | undefined} repoRootAbs the root the resolver settled on
|
|
430
|
+
* @param {string | null | undefined} source the resolver tier that produced it
|
|
431
|
+
* @param {{ reason?: 'unregistered' | 'legacy-id', legacyId?: string | null }} [detail]
|
|
432
|
+
*/
|
|
433
|
+
constructor(repoRootAbs, source, { reason = 'unregistered', legacyId = null } = {}) {
|
|
434
|
+
const root = typeof repoRootAbs === 'string' && repoRootAbs.trim() ? repoRootAbs.trim() : null;
|
|
435
|
+
const where = root ? ` (${root})` : '';
|
|
436
|
+
// Names the verb (`hs connect`) AND the form that runs on this machine
|
|
437
|
+
// (`hsCommand`, [[HS-I6HT]]) — identical when the bin is on PATH.
|
|
438
|
+
const why =
|
|
439
|
+
reason === 'legacy-id'
|
|
440
|
+
? `This repo${where} is registered on this machine under a legacy id (\`${legacyId}\`), not the vault id the console knows, and its heyspex.yaml carries no \`vault_id\` to stand in for it, `
|
|
441
|
+
: `This repo${where} is not connected to a HeySpex vault on this machine (\`hs connect\` has not registered it), `;
|
|
442
|
+
super(
|
|
443
|
+
why
|
|
444
|
+
+ 'so the console call was refused before it was sent. '
|
|
445
|
+
+ `Run \`${hsCommand('connect')}\`${root ? ` in ${root}` : ''} to ${reason === 'legacy-id' ? 're-register' : 'connect'} it, then try again. Nothing was sent.`,
|
|
446
|
+
);
|
|
447
|
+
this.name = 'VaultNotRegisteredError';
|
|
448
|
+
this.code = VAULT_NOT_REGISTERED;
|
|
449
|
+
this.reason = reason;
|
|
450
|
+
this.repoRootAbs = root;
|
|
451
|
+
this.source = typeof source === 'string' ? source : null;
|
|
452
|
+
this.legacyId = legacyId;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The shape of every vault id the console mints (`randomUUID()`); a registry row that is not one is a legacy slug. */
|
|
457
|
+
const CLOUD_VAULT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
458
|
+
|
|
459
|
+
export function isCloudVaultId(value) {
|
|
460
|
+
return typeof value === 'string' && CLOUD_VAULT_ID_RE.test(value.trim());
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// `vault_id:` line of a repo's heyspex.yaml — the id the console minted and
|
|
464
|
+
// `hs init` / `hs connect` wrote back ([[HS-6CFU-TASK-write-minted-vault-id-back-into-heyspex]]).
|
|
465
|
+
// Same minimal line-anchored shape as session-owner.mjs's `account_id:` reader.
|
|
466
|
+
const MARKER_VAULT_ID_RE = /^vault_id:[ \t]*["']?([^"'\r\n#]+)/m;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The `vault_id` a repo's `heyspex.yaml` carries, or `null` (no marker, no
|
|
470
|
+
* key, unreadable). Never throws. `readFile` is a test seam.
|
|
471
|
+
* @param {string | null | undefined} repoRootAbs
|
|
472
|
+
* @param {{ readFile?: (p: string) => string }} [opts]
|
|
473
|
+
*/
|
|
474
|
+
export function readMarkerVaultId(repoRootAbs, { readFile } = {}) {
|
|
475
|
+
if (typeof repoRootAbs !== 'string' || !repoRootAbs.trim()) return null;
|
|
476
|
+
const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
|
|
477
|
+
try {
|
|
478
|
+
const raw = read(join(repoRootAbs, 'heyspex.yaml'));
|
|
479
|
+
const value = String(raw ?? '').match(MARKER_VAULT_ID_RE)?.[1]?.trim() ?? '';
|
|
480
|
+
return value || null;
|
|
481
|
+
} catch {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The id to put on the wire for a REGISTERED resolver result: the registry
|
|
488
|
+
* row's id when it is a cloud UUID; otherwise (a legacy slug row such as the
|
|
489
|
+
* pre-UUID `heyspex-specs` registration) the UUID the repo's `heyspex.yaml`
|
|
490
|
+
* `vault_id` names. The committed marker is a first-class routing fact now:
|
|
491
|
+
* the resolver's cwd tier already returns a marker's `vault_id` itself
|
|
492
|
+
* (`cwd-marker`, and `marker-registry-mismatch` when a registry row
|
|
493
|
+
* disagrees — the marker wins, [[HS-VVNQ]]), and its `account:` picks the
|
|
494
|
+
* token ({@link getAuthHeader}, [[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]]);
|
|
495
|
+
* this fallback covers the remaining tiers that land on a slug row. Neither a UUID →
|
|
496
|
+
* throws {@link VaultNotRegisteredError} (`reason: 'legacy-id'`): the console
|
|
497
|
+
* can only scope by the id it minted, and a slug on the wire is a guaranteed
|
|
498
|
+
* 409, so refusing locally is the honest answer.
|
|
499
|
+
* @param {{ vaultId: string, repoRootAbs?: string, source?: string }} resolved
|
|
500
|
+
* @param {{ readMarkerVaultId?: (repoRootAbs: string) => string | null }} [opts]
|
|
501
|
+
*/
|
|
502
|
+
export function cloudVaultIdFor(resolved, { readMarkerVaultId: readMarker = readMarkerVaultId } = {}) {
|
|
503
|
+
const rowId = resolved.vaultId.trim();
|
|
504
|
+
if (isCloudVaultId(rowId)) return rowId;
|
|
505
|
+
const marker = readMarker(resolved.repoRootAbs);
|
|
506
|
+
if (isCloudVaultId(marker)) return marker.trim();
|
|
507
|
+
throw new VaultNotRegisteredError(resolved.repoRootAbs, resolved.source, { reason: 'legacy-id', legacyId: rowId });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Thrown by {@link accountIdHeaderFor} / {@link getAccountIdHeader} when no
|
|
512
|
+
* account resolves (no `HS_ACCOUNT_ID`, no `account_id:` in
|
|
513
|
+
* `~/.heyspex/config.yaml`). Names `hs login`, the only thing that fixes it.
|
|
514
|
+
*/
|
|
515
|
+
export class AccountNotConfiguredError extends Error {
|
|
516
|
+
constructor() {
|
|
517
|
+
super(
|
|
518
|
+
'This machine has no HeySpex account configured (`hs login` has not been run), '
|
|
519
|
+
+ 'so the console call was refused before it was sent. '
|
|
520
|
+
+ `Run \`${hsCommand('login')}\` on this machine, then try again. Nothing was sent.`,
|
|
521
|
+
);
|
|
522
|
+
this.name = 'AccountNotConfiguredError';
|
|
523
|
+
this.code = ACCOUNT_NOT_CONFIGURED;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** True for the three local scope refusals this module throws. */
|
|
528
|
+
export function isLocalScopeRefusal(err) {
|
|
529
|
+
return (
|
|
530
|
+
err instanceof VaultNotRegisteredError
|
|
531
|
+
|| err instanceof AccountNotConfiguredError
|
|
532
|
+
|| err instanceof VaultNotPinnedError
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Gate: the vault header for a resolver result. A registered id from a
|
|
538
|
+
* REPO-DERIVED tier → the header naming the CLOUD id ({@link cloudVaultIdFor});
|
|
539
|
+
* the `unregistered` sentinel → throws {@link VaultNotRegisteredError}; a
|
|
540
|
+
* pointer-sourced result (`source: 'account-pointer'`) → throws
|
|
541
|
+
* {@link VaultNotPinnedError} unless the hatch is on ({@link pointerVaultAllowed}
|
|
542
|
+
* over `opts.env`), in which case the pointer's id goes on the wire and the
|
|
543
|
+
* process announces it once ({@link announcePointerVaultOnce}); no result /
|
|
544
|
+
* blank id → `{}` (nothing to name — the resolver itself failed, which is not
|
|
545
|
+
* the same as "unregistered"). Pure given `opts.readMarkerVaultId`, `opts.env`
|
|
546
|
+
* and `opts.write`.
|
|
547
|
+
* @param {{ vaultId?: string, source?: string, repoRootAbs?: string } | null | undefined} resolved
|
|
548
|
+
* @param {{ readMarkerVaultId?: (repoRootAbs: string) => string | null, env?: NodeJS.ProcessEnv | Record<string, string | undefined>, cwd?: string | null, write?: (line: string) => unknown }} [opts]
|
|
549
|
+
* `cwd`: the unpinned repo named in the refusal (default: the resolver's own
|
|
550
|
+
* cwd base, `CLAUDE_PROJECT_DIR` then `process.cwd()`).
|
|
551
|
+
*/
|
|
552
|
+
export function vaultIdHeaderFor(resolved, opts = {}) {
|
|
553
|
+
const vaultId = typeof resolved?.vaultId === 'string' ? resolved.vaultId.trim() : '';
|
|
554
|
+
if (!vaultId) return {};
|
|
555
|
+
if (vaultId === UNREGISTERED_VAULT_ID) {
|
|
556
|
+
throw new VaultNotRegisteredError(resolved?.repoRootAbs, resolved?.source);
|
|
557
|
+
}
|
|
558
|
+
if (resolved?.source === POINTER_VAULT_SOURCE) {
|
|
559
|
+
const env = opts.env ?? process.env;
|
|
560
|
+
if (!pointerVaultAllowed(env)) {
|
|
561
|
+
throw new VaultNotPinnedError(resolved, { cwd: opts.cwd === undefined ? resolverCwdBase() : opts.cwd });
|
|
562
|
+
}
|
|
563
|
+
announcePointerVaultOnce(resolved, { env, write: opts.write });
|
|
564
|
+
}
|
|
565
|
+
return { [VAULT_ID_HEADER]: cloudVaultIdFor({ ...resolved, vaultId }, opts) };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The folder the resolver's walk started from — the repo that needs a pin.
|
|
570
|
+
* Mirrors vault-resolve.mjs `cwdBase` (`CLAUDE_PROJECT_DIR`, then `process.cwd()`).
|
|
571
|
+
*/
|
|
572
|
+
function resolverCwdBase() {
|
|
573
|
+
const fromEnv = process.env.CLAUDE_PROJECT_DIR;
|
|
574
|
+
return typeof fromEnv === 'string' && fromEnv.trim() ? fromEnv.trim() : process.cwd();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* The vault-id header for THIS process, from the memoized resolver
|
|
579
|
+
* (`getActiveConfig`, resolved once per process; tests reset it via
|
|
580
|
+
* `resetActiveConfigCache`). A resolver FAILURE is "no header" (`{}`); an
|
|
581
|
+
* `unregistered` RESULT throws {@link VaultNotRegisteredError} and a
|
|
582
|
+
* pointer-sourced one throws {@link VaultNotPinnedError} (hatch off) so the
|
|
583
|
+
* caller refuses before any fetch.
|
|
584
|
+
*/
|
|
585
|
+
export function getVaultIdHeader() {
|
|
586
|
+
let resolved;
|
|
587
|
+
try {
|
|
588
|
+
resolved = getActiveConfig();
|
|
589
|
+
} catch {
|
|
590
|
+
return {};
|
|
591
|
+
}
|
|
592
|
+
return vaultIdHeaderFor(resolved);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Pure gate: the account header for a configured account id, or throws
|
|
597
|
+
* {@link AccountNotConfiguredError} when none is configured.
|
|
598
|
+
* @param {string | null | undefined} accountId
|
|
599
|
+
*/
|
|
600
|
+
export function accountIdHeaderFor(accountId) {
|
|
601
|
+
const id = typeof accountId === 'string' ? accountId.trim() : '';
|
|
602
|
+
if (!id) throw new AccountNotConfiguredError();
|
|
603
|
+
return { [ACCOUNT_ID_HEADER]: id };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* The account-id header for THIS process: the repo's recorded `account:`
|
|
608
|
+
* when its heyspex.yaml names one — the account whose token
|
|
609
|
+
* {@link getAuthHeader} sends, so the two headers can never name different
|
|
610
|
+
* accounts ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]]) — else
|
|
611
|
+
* the one account reader (session-owner.mjs: `HS_ACCOUNT_ID` env, then the
|
|
612
|
+
* credentials store's current account). Read per call, like the bearer —
|
|
613
|
+
* `hs login` can re-pair the machine while a server is up. Injectable
|
|
614
|
+
* `env`/`homeDir`/`repoAccountId` for tests (`null` = "the repo records none").
|
|
615
|
+
* @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, repoAccountId?: string | null }} [opts]
|
|
616
|
+
*/
|
|
617
|
+
export function getAccountIdHeader({ repoAccountId, ...opts } = {}) {
|
|
618
|
+
const recorded = repoAccountId === undefined ? getRepoAccountId({ homeDir: opts.homeDir }) : repoAccountId;
|
|
619
|
+
return accountIdHeaderFor(recorded || resolveSessionOwnerUncached(opts));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* The transport result for a local scope refusal — the shape every caller of
|
|
624
|
+
* `consoleFetch` already handles (`ok: false`, `status: 0`), with the code on
|
|
625
|
+
* `refusal` AND on `authProblem` so `consoleErrorCode` / the never-buffer
|
|
626
|
+
* contract pick it up with no per-caller change. Pure.
|
|
627
|
+
* @param {VaultNotRegisteredError | AccountNotConfiguredError | VaultNotPinnedError | AccountTokenMissingError} err
|
|
628
|
+
* @param {string} url the request that was NOT sent
|
|
107
629
|
*/
|
|
108
|
-
export function
|
|
109
|
-
|
|
110
|
-
|
|
630
|
+
export function scopeRefusalResult(err, url) {
|
|
631
|
+
return {
|
|
632
|
+
ok: false,
|
|
633
|
+
status: 0,
|
|
634
|
+
body: null,
|
|
635
|
+
url,
|
|
636
|
+
error: err.message,
|
|
637
|
+
errorKind: 'refused',
|
|
638
|
+
refusal: err.code,
|
|
639
|
+
authProblem: err.code,
|
|
640
|
+
authMessage: err.message,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Cloud mode, but the resolved console URL points at this machine. */
|
|
645
|
+
export const CONSOLE_LOCAL_URL_IN_CLOUD_MODE = 'CONSOLE_LOCAL_URL_IN_CLOUD_MODE';
|
|
646
|
+
/** Cloud mode, but no bearer token is stored on this machine (or a 401 came back). */
|
|
647
|
+
export const CONSOLE_NOT_SIGNED_IN = 'CONSOLE_NOT_SIGNED_IN';
|
|
111
648
|
|
|
649
|
+
/**
|
|
650
|
+
* Plain-language, action-naming text for the two sign-in classes. Written for
|
|
651
|
+
* the repo owner to read directly: short sentences, no jargon, the exact
|
|
652
|
+
* command to run, and an explicit "do not start a local server" so an agent
|
|
653
|
+
* does not fall back to `npm run dev` — a customer has no local server.
|
|
654
|
+
*
|
|
655
|
+
* The command is rendered by `hsCommand`, never typed out, so the message
|
|
656
|
+
* names a form that actually runs on the machine reading it
|
|
657
|
+
* ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
|
|
658
|
+
*/
|
|
659
|
+
export function consoleAuthErrorMessage(code, url) {
|
|
660
|
+
const where = url ? ` (${url})` : '';
|
|
661
|
+
const login = hsCommand('login');
|
|
662
|
+
if (code === CONSOLE_LOCAL_URL_IN_CLOUD_MODE) {
|
|
663
|
+
return (
|
|
664
|
+
`HeySpex is set to cloud mode, but this machine is pointed at a local console${where}. `
|
|
665
|
+
+ `Nothing was saved. Run \`${login}\` to point this machine at the HeySpex cloud, then try again. `
|
|
666
|
+
+ 'Do not start a local dev server — cloud is the normal mode.'
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
if (code === CONSOLE_NOT_SIGNED_IN) {
|
|
670
|
+
return (
|
|
671
|
+
`This machine is not signed in to HeySpex, so the call${url ? ` to ${url}` : ''} could not go through. `
|
|
672
|
+
+ `Nothing was saved. Run \`${login}\` on this machine, then try again. `
|
|
673
|
+
+ 'The server is not down — do not start a local dev server.'
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Message for an answered HTTP 401 (the console replied; it rejected the token). */
|
|
680
|
+
export function consoleUnauthorizedMessage(url) {
|
|
681
|
+
const where = url ? ` at ${url}` : '';
|
|
682
|
+
return (
|
|
683
|
+
`HeySpex${where} rejected this request because this machine is not signed in (HTTP 401). `
|
|
684
|
+
+ `Nothing was saved. Run \`${hsCommand('login')}\` on this machine, then try again.`
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Lazy, cached, never-throwing load of the shared dev/prod mode core. Kept
|
|
690
|
+
* dynamic (and optional) so a vendored copy of this directory without the
|
|
691
|
+
* repo's `scripts/` tree simply falls back to today's behavior instead of
|
|
692
|
+
* failing to import. Mirrors cli.mjs's handleDevMode self-containment note.
|
|
693
|
+
*/
|
|
694
|
+
let devModeCorePromise;
|
|
695
|
+
async function loadDevModeCore() {
|
|
696
|
+
if (!devModeCorePromise) {
|
|
697
|
+
devModeCorePromise = import('../../scripts/lib/dev-mode.mjs').catch(() => null);
|
|
698
|
+
}
|
|
699
|
+
return devModeCorePromise;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Resolve the effective reliance ("cloud" | "local"), or `null` when it cannot
|
|
704
|
+
* be determined (no dev-mode core available). Never reimplements the contract:
|
|
705
|
+
* `resolveDevMode` (env → machine-local record → default prod/cloud) when the
|
|
706
|
+
* core exports it, else `readLocalDevModeRecord` + `deriveSemantics`.
|
|
707
|
+
*/
|
|
708
|
+
export async function resolveConsoleReliance({ env = process.env, homeDir = heyspexHome() } = {}) {
|
|
709
|
+
const core = await loadDevModeCore();
|
|
710
|
+
if (!core) return null;
|
|
112
711
|
try {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
712
|
+
if (typeof core.resolveDevMode === 'function') {
|
|
713
|
+
return core.resolveDevMode({ env, homeDir }).reliance ?? null;
|
|
714
|
+
}
|
|
715
|
+
const { record } = core.readLocalDevModeRecord({ homeDir });
|
|
716
|
+
const mode = record?.mode ?? 'prod';
|
|
717
|
+
return core.deriveSemantics(mode, record?.reliance ?? null).reliance ?? null;
|
|
117
718
|
} catch {
|
|
118
|
-
|
|
719
|
+
return null;
|
|
119
720
|
}
|
|
120
|
-
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Decide whether a transport failure is really a sign-in problem. Pure.
|
|
725
|
+
*
|
|
726
|
+
* @param {{ url: string, reliance: string|null, hasToken: boolean }} state
|
|
727
|
+
* @returns {string|null} one of the two auth codes, or null (= genuine outage)
|
|
728
|
+
*/
|
|
729
|
+
export function classifyConsoleAuthProblem({ url, reliance, hasToken }) {
|
|
730
|
+
if (reliance !== 'cloud') return null; // local dev / unknown → today's behavior
|
|
731
|
+
if (isLoopbackUrl(url)) return CONSOLE_LOCAL_URL_IN_CLOUD_MODE;
|
|
732
|
+
if (!hasToken) return CONSOLE_NOT_SIGNED_IN;
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function headerHasToken(header) {
|
|
737
|
+
const value = header?.Authorization;
|
|
738
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
121
739
|
}
|
|
122
740
|
|
|
123
741
|
/**
|
|
@@ -129,6 +747,9 @@ export function getAuthHeader({ env = process.env, homeDir = homedir() } = {}) {
|
|
|
129
747
|
* @param {number} [deps.defaultTimeoutMs]
|
|
130
748
|
* @param {(failedResult: object, ctx: { url: string, init: object, timeoutMs: number }) => Promise<{ retryUrl?: string, reason?: string, healedUrl?: string, discoveryHealed?: boolean, devServerStatus?: object } | null | undefined>} [deps.recoverFromTransportFailure]
|
|
131
749
|
* @param {() => object} [deps.getAuthHeader] Resolves the outbound Authorization header (default: {@link getAuthHeader}).
|
|
750
|
+
* @param {() => object} [deps.getVaultIdHeader] Resolves the outbound `x-heyspex-vault-id` header (default: {@link getVaultIdHeader}); throws {@link VaultNotRegisteredError} for an unregistered root, {@link VaultNotPinnedError} for a pointer-sourced one.
|
|
751
|
+
* @param {() => object} [deps.getAccountIdHeader] Resolves the outbound `x-heyspex-account-id` header (default: {@link getAccountIdHeader}); throws {@link AccountNotConfiguredError} when no account is configured.
|
|
752
|
+
* @param {() => Promise<{ reliance: string|null, hasToken: boolean }>} [deps.getConsoleAuthState] Resolves the reliance + token state used to tell "not signed in" from "server down" (default: dev-mode core + {@link getAuthHeader}).
|
|
132
753
|
*/
|
|
133
754
|
export function makeConsoleFetch({
|
|
134
755
|
getConsoleUrl,
|
|
@@ -136,7 +757,72 @@ export function makeConsoleFetch({
|
|
|
136
757
|
defaultTimeoutMs = DEFAULT_CONSOLE_TIMEOUT_MS,
|
|
137
758
|
recoverFromTransportFailure,
|
|
138
759
|
getAuthHeader: getAuthHeaderDep = getAuthHeader,
|
|
760
|
+
getVaultIdHeader: getVaultIdHeaderDep = getVaultIdHeader,
|
|
761
|
+
getAccountIdHeader: getAccountIdHeaderDep = getAccountIdHeader,
|
|
762
|
+
getConsoleAuthState,
|
|
139
763
|
}) {
|
|
764
|
+
function safeAuthHeader() {
|
|
765
|
+
try {
|
|
766
|
+
return getAuthHeaderDep() || {};
|
|
767
|
+
} catch {
|
|
768
|
+
return {};
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* One header resolver, two policies. A resolver that throws a local scope
|
|
774
|
+
* refusal ({@link isLocalScopeRefusal}) is surfaced as `{ refusal }` for a
|
|
775
|
+
* vault-scoped endpoint and swallowed to `{}` for a vault-less one; any
|
|
776
|
+
* OTHER throw is "no header" either way (a broken resolver must not become
|
|
777
|
+
* a new failure mode — unchanged from before).
|
|
778
|
+
*/
|
|
779
|
+
function headerOrRefusal(resolveHeader, vaultLess) {
|
|
780
|
+
try {
|
|
781
|
+
return { headers: resolveHeader() || {} };
|
|
782
|
+
} catch (err) {
|
|
783
|
+
if (!vaultLess && isLocalScopeRefusal(err)) return { refusal: err };
|
|
784
|
+
return { headers: {} };
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* The `{ account, vault }` scope headers for one request, or the local
|
|
790
|
+
* refusal that stops it before any fetch. Vault-less endpoints never refuse.
|
|
791
|
+
* @returns {{ headers: object } | { refusal: Error }}
|
|
792
|
+
*/
|
|
793
|
+
function scopeHeadersFor(method, path) {
|
|
794
|
+
const vaultLess = isVaultLessEndpoint(method, path);
|
|
795
|
+
const vault = headerOrRefusal(getVaultIdHeaderDep, vaultLess);
|
|
796
|
+
if (vault.refusal) return vault;
|
|
797
|
+
const account = headerOrRefusal(getAccountIdHeaderDep, vaultLess);
|
|
798
|
+
if (account.refusal) return account;
|
|
799
|
+
return { headers: { ...vault.headers, ...account.headers } };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* The Authorization header for one request, or the local refusal that stops
|
|
804
|
+
* it: a repo that records an account this machine holds no token for
|
|
805
|
+
* ({@link AccountTokenMissingError}) refuses EVERY console call, vault-less
|
|
806
|
+
* ones included — sending no token, or the current account's, would both be
|
|
807
|
+
* wrong. Any other throw is "no header" (unchanged).
|
|
808
|
+
* @returns {{ headers: object } | { refusal: Error }}
|
|
809
|
+
*/
|
|
810
|
+
function authOrRefusal() {
|
|
811
|
+
try {
|
|
812
|
+
return { headers: getAuthHeaderDep() || {} };
|
|
813
|
+
} catch (err) {
|
|
814
|
+
if (err instanceof AccountTokenMissingError) return { refusal: err };
|
|
815
|
+
return { headers: {} };
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const resolveAuthState =
|
|
820
|
+
getConsoleAuthState
|
|
821
|
+
|| (async () => ({
|
|
822
|
+
reliance: await resolveConsoleReliance(),
|
|
823
|
+
hasToken: headerHasToken(safeAuthHeader()),
|
|
824
|
+
}));
|
|
825
|
+
|
|
140
826
|
async function attempt(url, init, timeoutMs) {
|
|
141
827
|
const controller = new AbortController();
|
|
142
828
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -149,7 +835,14 @@ export function makeConsoleFetch({
|
|
|
149
835
|
} catch {
|
|
150
836
|
parsed = { raw: text };
|
|
151
837
|
}
|
|
152
|
-
|
|
838
|
+
const retryAfterRaw = response.headers?.get?.('retry-after');
|
|
839
|
+
return {
|
|
840
|
+
ok: response.ok,
|
|
841
|
+
status: response.status,
|
|
842
|
+
body: parsed,
|
|
843
|
+
url,
|
|
844
|
+
...(retryAfterRaw != null && retryAfterRaw !== '' ? { retryAfterSeconds: retryAfterRaw } : {}),
|
|
845
|
+
};
|
|
153
846
|
} catch (err) {
|
|
154
847
|
const errorKind = isAbortError(err) ? 'timeout' : 'network';
|
|
155
848
|
return {
|
|
@@ -244,15 +937,52 @@ export function makeConsoleFetch({
|
|
|
244
937
|
return result;
|
|
245
938
|
}
|
|
246
939
|
|
|
940
|
+
/**
|
|
941
|
+
* Last word on a status-0 failure: was the console really down, or were we
|
|
942
|
+
* talking to the wrong console / with no sign-in? Runs only AFTER every
|
|
943
|
+
* recovery path has failed, so a genuinely recoverable local dev server
|
|
944
|
+
* still heals exactly as before ([[HS-9R6S-N8VP-DIAG]]).
|
|
945
|
+
*/
|
|
946
|
+
async function stampAuthProblem(result, url) {
|
|
947
|
+
let state;
|
|
948
|
+
try {
|
|
949
|
+
state = await resolveAuthState({ url });
|
|
950
|
+
} catch {
|
|
951
|
+
return result; // never let classification turn into a new failure mode
|
|
952
|
+
}
|
|
953
|
+
const authProblem = classifyConsoleAuthProblem({
|
|
954
|
+
url: result.url || url,
|
|
955
|
+
reliance: state?.reliance ?? null,
|
|
956
|
+
hasToken: state?.hasToken === true,
|
|
957
|
+
});
|
|
958
|
+
if (!authProblem) return result;
|
|
959
|
+
return {
|
|
960
|
+
...result,
|
|
961
|
+
authProblem,
|
|
962
|
+
reliance: state?.reliance ?? null,
|
|
963
|
+
authMessage: consoleAuthErrorMessage(authProblem, result.url || url),
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
|
|
247
967
|
async function withRetry(url, init, timeoutMs, { retryOnTimeout = true } = {}) {
|
|
248
968
|
let result = await attempt(url, init, timeoutMs);
|
|
249
969
|
if (!result.ok && result.errorKind === 'timeout' && retryOnTimeout) {
|
|
250
970
|
result = await attempt(url, init, timeoutMs);
|
|
251
971
|
}
|
|
972
|
+
// Cold cloud lambda: wait the advertised window, then the same URL once more.
|
|
973
|
+
if (!result.ok && isVaultScopeUnresolved(result)) {
|
|
974
|
+
await sleep(retryAfterMs(result));
|
|
975
|
+
const retry = await attempt(url, init, timeoutMs);
|
|
976
|
+
if (retry.ok) return { ...retry, recoveredVia: 'vault_scope_unresolved', attemptedUrl: url };
|
|
977
|
+
result = retry;
|
|
978
|
+
}
|
|
252
979
|
if (result.ok) return result;
|
|
253
980
|
if (result.errorKind === 'network') {
|
|
254
981
|
result = await recoverNetworkFailure(result, { url, init, timeoutMs });
|
|
255
982
|
}
|
|
983
|
+
if (!result.ok && result.status === 0) {
|
|
984
|
+
result = await stampAuthProblem(result, url);
|
|
985
|
+
}
|
|
256
986
|
return result;
|
|
257
987
|
}
|
|
258
988
|
|
|
@@ -261,10 +991,15 @@ export function makeConsoleFetch({
|
|
|
261
991
|
*/
|
|
262
992
|
async function postClaimEndpoint(method, specId, bodyJson) {
|
|
263
993
|
const consoleUrl = getConsoleUrl();
|
|
264
|
-
const
|
|
994
|
+
const path = `/api/specs/${encodeURIComponent(specId)}/claim`;
|
|
995
|
+
const url = `${consoleUrl}${path}`;
|
|
996
|
+
const auth = authOrRefusal();
|
|
997
|
+
if (auth.refusal) return scopeRefusalResult(auth.refusal, url);
|
|
998
|
+
const scope = scopeHeadersFor(method, path);
|
|
999
|
+
if (scope.refusal) return scopeRefusalResult(scope.refusal, url);
|
|
265
1000
|
const init = {
|
|
266
1001
|
method,
|
|
267
|
-
headers: { 'content-type': 'application/json', ...
|
|
1002
|
+
headers: { 'content-type': 'application/json', ...auth.headers, ...scope.headers },
|
|
268
1003
|
};
|
|
269
1004
|
if (bodyJson !== undefined) {
|
|
270
1005
|
init.body = JSON.stringify(bodyJson);
|
|
@@ -287,16 +1022,30 @@ export function makeConsoleFetch({
|
|
|
287
1022
|
async function consoleFetch(path, options = {}) {
|
|
288
1023
|
const consoleUrl = getConsoleUrl();
|
|
289
1024
|
const url = `${consoleUrl}${path}`;
|
|
1025
|
+
const method = options.method || 'GET';
|
|
1026
|
+
const auth = authOrRefusal();
|
|
1027
|
+
if (auth.refusal) return scopeRefusalResult(auth.refusal, url);
|
|
1028
|
+
const scope = scopeHeadersFor(method, path);
|
|
1029
|
+
if (scope.refusal) return scopeRefusalResult(scope.refusal, url);
|
|
290
1030
|
const init = {
|
|
291
|
-
method
|
|
292
|
-
headers: {
|
|
1031
|
+
method,
|
|
1032
|
+
headers: {
|
|
1033
|
+
'content-type': 'application/json',
|
|
1034
|
+
...auth.headers,
|
|
1035
|
+
...scope.headers,
|
|
1036
|
+
...options.headers,
|
|
1037
|
+
},
|
|
293
1038
|
};
|
|
294
1039
|
if (options.body !== undefined) {
|
|
295
1040
|
init.body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body);
|
|
296
1041
|
}
|
|
297
1042
|
const timeoutMs = options.timeout || defaultTimeoutMs;
|
|
298
1043
|
const retryOnTimeout = options.retryOnTimeout === false ? isLoopbackUrl(url) : true;
|
|
299
|
-
|
|
1044
|
+
const outcome = await withRetry(url, init, timeoutMs, { retryOnTimeout });
|
|
1045
|
+
// Degrade-don't-die: the first successful console call of the process kicks
|
|
1046
|
+
// a background replay of any queued writes (non-blocking, failure-safe).
|
|
1047
|
+
if (outcome?.ok) maybeDrainOnFirstSuccess(consoleFetch);
|
|
1048
|
+
return outcome;
|
|
300
1049
|
}
|
|
301
1050
|
|
|
302
1051
|
return { postClaimEndpoint, consoleFetch };
|
|
@@ -308,7 +1057,51 @@ export function makeConsoleFetch({
|
|
|
308
1057
|
* actually down). Caller supplies the HTTP-error fallback used when the
|
|
309
1058
|
* transport succeeded but the console returned a 4xx/5xx.
|
|
310
1059
|
*/
|
|
1060
|
+
/**
|
|
1061
|
+
* The console's three TYPED vault-scope refusals
|
|
1062
|
+
* ([[HS-T5J5-WW9R-TASK-typed-vault-refusals-vault-not]]), one envelope
|
|
1063
|
+
* `{ error, reason, requested, hint }`. They name WHICH vault was refused and
|
|
1064
|
+
* why; the `hint` names the account's active vault when it is known. The
|
|
1065
|
+
* transport renders them by name so an agent reads two vault ids, not a bare
|
|
1066
|
+
* status ([[HS-N0VS-WW9R-TASK-hs-doctor-and-the-mcp-transport-name]] for
|
|
1067
|
+
* [[HS-W3JF-PEGM-DIAG-one-account-wide-active-vault-pointer]] fix 3).
|
|
1068
|
+
*/
|
|
1069
|
+
export const SCOPE_REFUSAL_CODES = Object.freeze(['VAULT_SCOPE_MISMATCH', 'ACCOUNT_MISMATCH', 'VAULT_NOT_REGISTERED']);
|
|
1070
|
+
|
|
1071
|
+
/** The typed refusal envelope on a failed transport result, or `null`. Pure. */
|
|
1072
|
+
export function scopeRefusalEnvelope(result) {
|
|
1073
|
+
const body = result?.body;
|
|
1074
|
+
if (!body || typeof body !== 'object' || !SCOPE_REFUSAL_CODES.includes(body.error)) return null;
|
|
1075
|
+
return body;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Agent-facing text for a typed refusal: code, reason, the vault the request
|
|
1080
|
+
* named, and the console's hint (which carries the account's active vault when
|
|
1081
|
+
* known) — the "both vaults side by side" the incident lacked. Pure.
|
|
1082
|
+
*/
|
|
1083
|
+
export function scopeRefusalMessage(envelope, url) {
|
|
1084
|
+
const where = url ? ` from ${url}` : '';
|
|
1085
|
+
const reason = typeof envelope.reason === 'string' && envelope.reason ? envelope.reason : 'no reason given';
|
|
1086
|
+
const requested =
|
|
1087
|
+
typeof envelope.requested === 'string' && envelope.requested
|
|
1088
|
+
? `vault ${envelope.requested}`
|
|
1089
|
+
: `no vault (no \`${VAULT_ID_HEADER}\` header)`;
|
|
1090
|
+
const hint = typeof envelope.hint === 'string' && envelope.hint ? ` ${envelope.hint}` : '';
|
|
1091
|
+
return `${envelope.error} (${reason})${where}: this request named ${requested}.${hint}`.trim();
|
|
1092
|
+
}
|
|
1093
|
+
|
|
311
1094
|
export function consoleErrorCode(result, httpFailureCode) {
|
|
1095
|
+
// A local scope refusal never reached the wire: the code IS the answer.
|
|
1096
|
+
if (result.refusal) return result.refusal;
|
|
1097
|
+
// A sign-in problem outranks every transport story: no amount of retrying,
|
|
1098
|
+
// port discovery or waiting fixes it, and "the server is down" sends the
|
|
1099
|
+
// agent off to start a local dev server the customer does not have.
|
|
1100
|
+
if (result.authProblem) return result.authProblem;
|
|
1101
|
+
// A typed vault-scope refusal IS the code — never the caller's generic one.
|
|
1102
|
+
const scoped = scopeRefusalEnvelope(result);
|
|
1103
|
+
if (scoped) return scoped.error;
|
|
1104
|
+
if (result.status === 401) return CONSOLE_NOT_SIGNED_IN;
|
|
312
1105
|
if (result.urlMismatch) return 'CONSOLE_URL_MISMATCH';
|
|
313
1106
|
if (result.errorKind === 'timeout') return 'CONSOLE_TIMEOUT';
|
|
314
1107
|
if (result.status === 0) return 'CONSOLE_UNREACHABLE';
|
|
@@ -321,6 +1114,18 @@ export function consoleErrorCode(result, httpFailureCode) {
|
|
|
321
1114
|
* server on network error.
|
|
322
1115
|
*/
|
|
323
1116
|
export function consoleErrorMessage(result, httpFailureMessage) {
|
|
1117
|
+
if (result.refusal) return result.error || result.authMessage || httpFailureMessage;
|
|
1118
|
+
if (result.authProblem) {
|
|
1119
|
+
return (
|
|
1120
|
+
result.authMessage
|
|
1121
|
+
|| consoleAuthErrorMessage(result.authProblem, result.url)
|
|
1122
|
+
|| httpFailureMessage
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
// A typed vault-scope refusal names both vaults; the generic text names none.
|
|
1126
|
+
const scoped = scopeRefusalEnvelope(result);
|
|
1127
|
+
if (scoped) return scopeRefusalMessage(scoped, result.url);
|
|
1128
|
+
if (result.status === 401) return consoleUnauthorizedMessage(result.url);
|
|
324
1129
|
if (result.urlMismatch) {
|
|
325
1130
|
const live =
|
|
326
1131
|
result.suggestedUrl
|