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,348 @@
|
|
|
1
|
+
// Byte budget for every MCP READ tool
|
|
2
|
+
// ([[HS-4H8T-8KK5-TASK-every-mcp-read-tool-declares-a-byte]] under
|
|
3
|
+
// [[HS-49AL-8KK5-EPIC-hot-row-cache-and-bounded-agent-tools]]).
|
|
4
|
+
//
|
|
5
|
+
// Before this module no byte budget existed anywhere in the MCP server: memory
|
|
6
|
+
// recall bounded by COUNT and sliced, list tools that page
|
|
7
|
+
// ([[HS-E66W-8KK5-TASK-mcp-list-tools-page-with-a-cursor-and-a]], `list-tools.mjs`)
|
|
8
|
+
// fit their own 8 KB page, and everything else returned whatever it had —
|
|
9
|
+
// `heyspex_list_known_tags` once handed an agent 138 KB in one call
|
|
10
|
+
// ([[HS-7QM6-1Q1C-DIAG-heyspex-list-known-tags-response-busts-t]]).
|
|
11
|
+
//
|
|
12
|
+
// The contract:
|
|
13
|
+
// - every read-shaped catalog tool (name matching {@link READ_TOOL_NAME_PATTERN})
|
|
14
|
+
// declares `budget: { max_bytes }` in `tool-catalog.mjs`
|
|
15
|
+
// ({@link DEFAULT_TOOL_BUDGET_BYTES} unless the tool says otherwise);
|
|
16
|
+
// - `tool-dispatch.mjs` runs every declared budget through
|
|
17
|
+
// {@link applyToolBudget} on the way out, so a tool that pages natively
|
|
18
|
+
// (list-tools, known-tags) is merely re-checked, and a tool that does NOT
|
|
19
|
+
// page is bounded anyway;
|
|
20
|
+
// - {@link enforceByteBudget} measures `Buffer.byteLength(JSON.stringify(result))`
|
|
21
|
+
// — the whole MCP result envelope, a strict superset of the text the agent
|
|
22
|
+
// reads — and when over budget drops trailing items from the payload's list,
|
|
23
|
+
// re-points `next_cursor` at the FIRST DROPPED item, and sets
|
|
24
|
+
// `truncated: true`. It never returns more than `max_bytes`: a payload with
|
|
25
|
+
// no list (or whose single remaining item is itself too big) has its
|
|
26
|
+
// longest strings cut with a marker, and as the last resort the payload is
|
|
27
|
+
// replaced by a tiny `RESPONSE_OVER_BUDGET` envelope.
|
|
28
|
+
//
|
|
29
|
+
// The generic `next_cursor` is a BUDGET cursor: an opaque base64url token the
|
|
30
|
+
// dispatcher recognises on the way IN (`applyToolBudget` strips it, re-issues
|
|
31
|
+
// the tool's own `after`, and resumes from the first dropped item), so a tool
|
|
32
|
+
// with no native paging is still walkable to exhaustion one bounded page at a
|
|
33
|
+
// time, and a tool WITH native paging keeps its own cursor untouched (the
|
|
34
|
+
// budget cursor carries it inside).
|
|
35
|
+
|
|
36
|
+
/** 48 000 bytes ≈ 12k tokens at ~4 bytes/token — the default ceiling for one read-tool reply. */
|
|
37
|
+
export const DEFAULT_TOOL_BUDGET_BYTES = 48_000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read-shaped tool names: the verb segment (after any namespace segment such as
|
|
41
|
+
* `design_`) is one of list / get / recall / search / next / ground.
|
|
42
|
+
* `heyspex_get_procedure`, `heyspex_design_get_pattern`, `heyspex_list_known_tags`,
|
|
43
|
+
* `heyspex_next_on_deck`, `heyspex_ground_plan` all match; `heyspex_claim_spec`
|
|
44
|
+
* does not.
|
|
45
|
+
*/
|
|
46
|
+
export const READ_TOOL_NAME_PATTERN = /^heyspex_(?:[a-z0-9]+_)*?(?:list|get|recall|search|next|ground)_/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Suffix-shaped reads (`heyspex_claim_list`, `heyspex_events_show`,
|
|
50
|
+
* `heyspex_settings_get`, …) — not in the task's prefix list, but every one of
|
|
51
|
+
* them returns rows or a record to an agent and so declares a budget too.
|
|
52
|
+
*/
|
|
53
|
+
export const READ_TOOL_SUFFIX_PATTERN = /_(?:list|show|get|load)$/;
|
|
54
|
+
|
|
55
|
+
export function isReadShapedToolName(name) {
|
|
56
|
+
return READ_TOOL_NAME_PATTERN.test(name) || READ_TOOL_SUFFIX_PATTERN.test(name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const TRUNCATION_MARKER = '…[truncated: byte budget]';
|
|
60
|
+
|
|
61
|
+
/** The measure every budget is checked against — the whole MCP result envelope. */
|
|
62
|
+
export function measureResultBytes(result) {
|
|
63
|
+
return Buffer.byteLength(JSON.stringify(result), 'utf8');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Budget cursor ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `{ key, at, index, after }` → opaque base64url token.
|
|
70
|
+
* key — the payload field holding the list that was trimmed
|
|
71
|
+
* at — identity ({@link itemIdentity}) of the FIRST DROPPED item, or null
|
|
72
|
+
* index — absolute position of that item in the tool's untrimmed list
|
|
73
|
+
* after — the tool's OWN `after` argument for this page (null when none),
|
|
74
|
+
* re-issued verbatim when the walker comes back with this cursor
|
|
75
|
+
*/
|
|
76
|
+
export function encodeBudgetCursor({ key, at, index, after }) {
|
|
77
|
+
return Buffer.from(
|
|
78
|
+
JSON.stringify({ budget: 1, key: String(key), at: at == null ? null : String(at), index: Number(index) || 0, after: after ?? null }),
|
|
79
|
+
'utf8',
|
|
80
|
+
).toString('base64url');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Decode a budget cursor; `null` for anything that is not one (a tool's own cursor, garbage, non-strings). */
|
|
84
|
+
export function decodeBudgetCursor(cursor) {
|
|
85
|
+
if (typeof cursor !== 'string' || cursor.length === 0) return null;
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
88
|
+
if (parsed && typeof parsed === 'object' && parsed.budget === 1 && typeof parsed.key === 'string') {
|
|
89
|
+
return {
|
|
90
|
+
key: parsed.key,
|
|
91
|
+
at: typeof parsed.at === 'string' ? parsed.at : null,
|
|
92
|
+
index: Number.isFinite(parsed.index) && parsed.index >= 0 ? Math.floor(parsed.index) : 0,
|
|
93
|
+
after: typeof parsed.after === 'string' && parsed.after ? parsed.after : null,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// not a budget cursor
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Stable identity for a list item — the field a resume can find it by again. Null when nothing fits. */
|
|
103
|
+
export function itemIdentity(item) {
|
|
104
|
+
if (item == null) return null;
|
|
105
|
+
if (typeof item !== 'object') return String(item);
|
|
106
|
+
for (const field of ['id', 'name', 'tag', 'key', 'path', 'spec_id']) {
|
|
107
|
+
const v = item[field];
|
|
108
|
+
if (typeof v === 'string' && v) return v;
|
|
109
|
+
if (typeof v === 'number') return String(v);
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The top-level array field carrying the most bytes — the list a trim should shorten. */
|
|
115
|
+
export function pickListKey(payload) {
|
|
116
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
|
|
117
|
+
let best = null;
|
|
118
|
+
let bestBytes = -1;
|
|
119
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
120
|
+
if (!Array.isArray(value) || value.length === 0) continue;
|
|
121
|
+
const bytes = Buffer.byteLength(JSON.stringify(value), 'utf8');
|
|
122
|
+
if (bytes > bestBytes) {
|
|
123
|
+
best = key;
|
|
124
|
+
bestBytes = bytes;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return best;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parsePayload(result) {
|
|
131
|
+
const block = result?.content?.[0];
|
|
132
|
+
if (!block || block.type !== 'text' || typeof block.text !== 'string') return { block: null, payload: null };
|
|
133
|
+
try {
|
|
134
|
+
const payload = JSON.parse(block.text);
|
|
135
|
+
return { block, payload: payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : null };
|
|
136
|
+
} catch {
|
|
137
|
+
return { block, payload: null };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function withPayload(result, block, payload, pretty) {
|
|
142
|
+
const text = pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload);
|
|
143
|
+
return { ...result, content: [{ ...block, text }, ...result.content.slice(1)] };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isPretty(text) {
|
|
147
|
+
return /^\s*[{[]\s*\n/.test(text);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resume a tool's untrimmed result from a budget cursor: drop every item before
|
|
152
|
+
* the first-dropped item the cursor names. Returns the sliced result plus the
|
|
153
|
+
* absolute `offset` the slice started at (so the NEXT budget cursor's `index`
|
|
154
|
+
* stays absolute). A cursor whose list key does not exist in the payload
|
|
155
|
+
* resumes nothing (offset 0) rather than failing — the walker still terminates
|
|
156
|
+
* because the page is re-fitted below.
|
|
157
|
+
*/
|
|
158
|
+
export function resumeFromBudgetCursor(result, cursor) {
|
|
159
|
+
const { block, payload } = parsePayload(result);
|
|
160
|
+
if (!block || !payload || !Array.isArray(payload[cursor.key])) return { result, offset: 0 };
|
|
161
|
+
const list = payload[cursor.key];
|
|
162
|
+
let offset = -1;
|
|
163
|
+
if (cursor.at != null) offset = list.findIndex((item) => itemIdentity(item) === cursor.at);
|
|
164
|
+
if (offset === -1) offset = Math.min(cursor.index, list.length);
|
|
165
|
+
const next = { ...payload, [cursor.key]: list.slice(offset) };
|
|
166
|
+
return { result: withPayload(result, block, next, isPretty(block.text)), offset };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Enforcement ──────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/** Largest `n` in `[lo, hi]` for which `fits(n)` holds (`fits` is monotone: smaller n ⇒ fewer bytes). */
|
|
172
|
+
function largestFitting(lo, hi, fits) {
|
|
173
|
+
let best = lo - 1;
|
|
174
|
+
while (lo <= hi) {
|
|
175
|
+
const mid = (lo + hi) >>> 1;
|
|
176
|
+
if (fits(mid)) {
|
|
177
|
+
best = mid;
|
|
178
|
+
lo = mid + 1;
|
|
179
|
+
} else {
|
|
180
|
+
hi = mid - 1;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return best;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Walk a payload for its string leaves (depth-limited) — `[path, length]`, longest first. */
|
|
187
|
+
function longestStrings(value, path = [], depth = 0, out = []) {
|
|
188
|
+
if (depth > 4 || value == null) return out;
|
|
189
|
+
if (typeof value === 'string') {
|
|
190
|
+
out.push({ path, length: value.length });
|
|
191
|
+
} else if (Array.isArray(value)) {
|
|
192
|
+
value.forEach((v, i) => longestStrings(v, [...path, i], depth + 1, out));
|
|
193
|
+
} else if (typeof value === 'object') {
|
|
194
|
+
for (const [k, v] of Object.entries(value)) longestStrings(v, [...path, k], depth + 1, out);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getAt(obj, path) {
|
|
200
|
+
return path.reduce((acc, k) => (acc == null ? undefined : acc[k]), obj);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function setAt(obj, path, value) {
|
|
204
|
+
const clone = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
205
|
+
if (path.length === 1) {
|
|
206
|
+
clone[path[0]] = value;
|
|
207
|
+
return clone;
|
|
208
|
+
}
|
|
209
|
+
clone[path[0]] = setAt(obj[path[0]], path.slice(1), value);
|
|
210
|
+
return clone;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function overBudgetEnvelope(result, { tool, bytes, maxBytes }) {
|
|
214
|
+
const text = JSON.stringify({
|
|
215
|
+
error: 'RESPONSE_OVER_BUDGET',
|
|
216
|
+
tool: tool ?? null,
|
|
217
|
+
truncated: true,
|
|
218
|
+
bytes,
|
|
219
|
+
max_bytes: maxBytes,
|
|
220
|
+
message: `The tool reply was ${bytes} bytes against a ${maxBytes}-byte budget and could not be trimmed to fit. Narrow the call (filters, a smaller limit, compact mode) and retry.`,
|
|
221
|
+
});
|
|
222
|
+
return { ...(result?.isError ? { isError: true } : {}), content: [{ type: 'text', text }] };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Bound one MCP tool result to `maxBytes` (measured on the whole envelope).
|
|
227
|
+
*
|
|
228
|
+
* @param result the MCP result `{ content: [{ type: 'text', text }], isError? }`
|
|
229
|
+
* @param options.maxBytes the budget ({@link DEFAULT_TOOL_BUDGET_BYTES} when omitted)
|
|
230
|
+
* @param options.listKey the payload field to trim; auto-detected ({@link pickListKey}) when omitted
|
|
231
|
+
* @param options.tool tool name, recorded in the trim envelope
|
|
232
|
+
* @param options.after the tool's own `after` arg for this page — carried inside the budget cursor
|
|
233
|
+
* @param options.indexOffset absolute index of the payload list's first item (a resumed page)
|
|
234
|
+
* @returns {{ result: object, bytes: number, truncated: boolean, dropped: number }}
|
|
235
|
+
*/
|
|
236
|
+
export function enforceByteBudget(result, options = {}) {
|
|
237
|
+
const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0 ? options.maxBytes : DEFAULT_TOOL_BUDGET_BYTES;
|
|
238
|
+
const tool = options.tool ?? null;
|
|
239
|
+
if (!result || !Array.isArray(result.content)) {
|
|
240
|
+
return { result, bytes: measureResultBytes(result ?? null), truncated: false, dropped: 0 };
|
|
241
|
+
}
|
|
242
|
+
let bytes = measureResultBytes(result);
|
|
243
|
+
if (bytes <= maxBytes) return { result, bytes, truncated: false, dropped: 0 };
|
|
244
|
+
|
|
245
|
+
const { block, payload } = parsePayload(result);
|
|
246
|
+
let dropped = 0;
|
|
247
|
+
|
|
248
|
+
if (block && payload) {
|
|
249
|
+
const pretty = isPretty(block.text);
|
|
250
|
+
let current = payload;
|
|
251
|
+
|
|
252
|
+
// 1. Drop trailing list items; the cursor points at the first dropped one.
|
|
253
|
+
const key = options.listKey && Array.isArray(payload[options.listKey]) ? options.listKey : pickListKey(payload);
|
|
254
|
+
if (key && Array.isArray(current[key]) && current[key].length > 1) {
|
|
255
|
+
const list = current[key];
|
|
256
|
+
const offset = Number(options.indexOffset) || 0;
|
|
257
|
+
const shaped = (n) => ({
|
|
258
|
+
...current,
|
|
259
|
+
[key]: list.slice(0, n),
|
|
260
|
+
next_cursor: encodeBudgetCursor({ key, at: itemIdentity(list[n]), index: offset + n, after: options.after ?? null }),
|
|
261
|
+
truncated: true,
|
|
262
|
+
budget: { max_bytes: maxBytes, list_key: key, returned: n, dropped: list.length - n },
|
|
263
|
+
});
|
|
264
|
+
const fits = (n) => measureResultBytes(withPayload(result, block, shaped(n), pretty)) <= maxBytes;
|
|
265
|
+
// Never an empty page with a cursor: at least one item stays, and if that
|
|
266
|
+
// lone item still does not fit the string cut below handles it.
|
|
267
|
+
const keep = Math.max(1, largestFitting(1, list.length - 1, fits));
|
|
268
|
+
current = shaped(keep);
|
|
269
|
+
dropped = list.length - keep;
|
|
270
|
+
bytes = measureResultBytes(withPayload(result, block, current, pretty));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 2. Still over: cut the longest strings, longest first, until it fits.
|
|
274
|
+
if (bytes > maxBytes) {
|
|
275
|
+
current = { ...current, truncated: true, budget: { ...(current.budget ?? {}), max_bytes: maxBytes, strings_cut: 0 } };
|
|
276
|
+
let guard = 0;
|
|
277
|
+
while (bytes > maxBytes && guard < 64) {
|
|
278
|
+
guard += 1;
|
|
279
|
+
const candidates = longestStrings(current).filter((c) => c.length > TRUNCATION_MARKER.length + 8 && c.path[0] !== 'next_cursor');
|
|
280
|
+
if (candidates.length === 0) break;
|
|
281
|
+
candidates.sort((a, b) => b.length - a.length);
|
|
282
|
+
const target = candidates[0];
|
|
283
|
+
const value = getAt(current, target.path);
|
|
284
|
+
const overshoot = bytes - maxBytes;
|
|
285
|
+
// Cut generously: the overshoot plus the marker, but never below 8 chars of the original.
|
|
286
|
+
const keepChars = Math.max(8, value.length - overshoot - TRUNCATION_MARKER.length);
|
|
287
|
+
current = setAt(current, target.path, value.slice(0, keepChars) + TRUNCATION_MARKER);
|
|
288
|
+
current.budget = { ...current.budget, strings_cut: (current.budget.strings_cut ?? 0) + 1 };
|
|
289
|
+
bytes = measureResultBytes(withPayload(result, block, current, pretty));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (bytes <= maxBytes) {
|
|
294
|
+
return { result: withPayload(result, block, current, pretty), bytes, truncated: true, dropped };
|
|
295
|
+
}
|
|
296
|
+
} else if (block) {
|
|
297
|
+
// 3. Non-JSON text: cut the text itself to fit.
|
|
298
|
+
const text = block.text;
|
|
299
|
+
const fitsText = (n) => measureResultBytes({ ...result, content: [{ ...block, text: text.slice(0, n) + TRUNCATION_MARKER }, ...result.content.slice(1)] }) <= maxBytes;
|
|
300
|
+
const keep = largestFitting(0, text.length, fitsText);
|
|
301
|
+
if (keep >= 0) {
|
|
302
|
+
const cut = { ...result, content: [{ ...block, text: text.slice(0, keep) + TRUNCATION_MARKER }, ...result.content.slice(1)] };
|
|
303
|
+
return { result: cut, bytes: measureResultBytes(cut), truncated: true, dropped: 0 };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 4. Last resort — the envelope is a few hundred bytes and always fits.
|
|
308
|
+
const envelope = overBudgetEnvelope(result, { tool, bytes, maxBytes });
|
|
309
|
+
return { result: envelope, bytes: measureResultBytes(envelope), truncated: true, dropped };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The dispatcher's hook: run `invoke(args)` for a tool that declares `budget`,
|
|
314
|
+
* resuming from a budget cursor on the way in and enforcing the budget on the
|
|
315
|
+
* way out. A tool without a budget is invoked untouched. `invoke` may return a
|
|
316
|
+
* Promise (several handlers are async) — the result is unwrapped either way.
|
|
317
|
+
*/
|
|
318
|
+
export function applyToolBudget(name, args, invoke, budget) {
|
|
319
|
+
const maxBytes = Number(budget?.max_bytes);
|
|
320
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return invoke(args);
|
|
321
|
+
|
|
322
|
+
let effectiveArgs = args ?? {};
|
|
323
|
+
let resume = null;
|
|
324
|
+
if (typeof effectiveArgs.after === 'string') {
|
|
325
|
+
resume = decodeBudgetCursor(effectiveArgs.after);
|
|
326
|
+
if (resume) {
|
|
327
|
+
effectiveArgs = { ...effectiveArgs };
|
|
328
|
+
if (resume.after) effectiveArgs.after = resume.after;
|
|
329
|
+
else delete effectiveArgs.after;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const finish = (raw) => {
|
|
334
|
+
let result = raw;
|
|
335
|
+
let indexOffset = 0;
|
|
336
|
+
if (resume) ({ result, offset: indexOffset } = resumeFromBudgetCursor(result, resume));
|
|
337
|
+
return enforceByteBudget(result, {
|
|
338
|
+
maxBytes,
|
|
339
|
+
listKey: budget.list_key,
|
|
340
|
+
tool: name,
|
|
341
|
+
after: typeof effectiveArgs.after === 'string' ? effectiveArgs.after : null,
|
|
342
|
+
indexOffset,
|
|
343
|
+
}).result;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const raw = invoke(effectiveArgs);
|
|
347
|
+
return raw && typeof raw.then === 'function' ? raw.then(finish) : finish(raw);
|
|
348
|
+
}
|
|
@@ -5,6 +5,23 @@
|
|
|
5
5
|
* Mirrors `.heyspex/focus.yaml` (build pipeline) and `.heyspex/research-focus.yaml`
|
|
6
6
|
* (research pipeline). Honored unconditionally when present, same semantics as
|
|
7
7
|
* [[focus-yaml-always-wins-in-hs-next]].
|
|
8
|
+
*
|
|
9
|
+
* CLOUD WRITE MODE ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]):
|
|
10
|
+
* this module is deliberately NOT routed through the console, and that is a
|
|
11
|
+
* decision rather than an omission. The pin it writes is LOCAL MACHINE STATE,
|
|
12
|
+
* not vault content — it lives in the repo's dot-directory, never under
|
|
13
|
+
* `specs/`, carries no id/type/frontmatter contract, and is not indexed by
|
|
14
|
+
* anything. It is therefore not a split brain the way a stray `specs/*.md`
|
|
15
|
+
* would be: a pin is per-workstation by design, and two machines are SUPPOSED
|
|
16
|
+
* to be able to hold different ones. HS-KR16 §Scope made exactly this call for
|
|
17
|
+
* the promote-research review-gate markers in the same dot-directory.
|
|
18
|
+
*
|
|
19
|
+
* The corollary: because it is not vault content, it also has no spec `type`
|
|
20
|
+
* and therefore no `POST /api/specs` route it could be filed through — routing
|
|
21
|
+
* it would mean inventing a type, which is the thing
|
|
22
|
+
* `CLOUD_MODE_UNSUPPORTED_WRITE` exists to refuse. Keeping the disk write is
|
|
23
|
+
* the correct behaviour in BOTH modes; it is not degraded and does not need to
|
|
24
|
+
* be reported.
|
|
8
25
|
*/
|
|
9
26
|
|
|
10
27
|
import {
|
|
@@ -14,8 +31,10 @@ import {
|
|
|
14
31
|
mkdirSync,
|
|
15
32
|
rmSync,
|
|
16
33
|
} from 'node:fs';
|
|
17
|
-
import { join } from 'node:path';
|
|
34
|
+
import { join, isAbsolute, resolve, sep } from 'node:path';
|
|
18
35
|
import { parseSimpleFocusYaml } from './capture-focus.mjs';
|
|
36
|
+
import { assertPathUnderRoot } from '../../scripts/lib/shared-path-helpers.mjs';
|
|
37
|
+
|
|
19
38
|
|
|
20
39
|
export function reviewFocusPath(vaultRoot) {
|
|
21
40
|
return join(vaultRoot, '.heyspex', 'review-focus.yaml');
|
|
@@ -36,6 +55,11 @@ export function writeReviewFocus(vaultRoot, { specId, reason }) {
|
|
|
36
55
|
return { error: 'MISSING_SPEC_ID', message: 'spec_id is required' };
|
|
37
56
|
}
|
|
38
57
|
const dir = join(vaultRoot, '.heyspex');
|
|
58
|
+
try {
|
|
59
|
+
assertPathUnderRoot(vaultRoot, dir, 'review-focus write');
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return { error: 'BAD_VAULT_ROOT', message: err.message };
|
|
62
|
+
}
|
|
39
63
|
mkdirSync(dir, { recursive: true });
|
|
40
64
|
const lines = [
|
|
41
65
|
'# HeySpex review-on-deck — MCP /hs-review ([[HS-TASK-RVDC-heyspex-review-on-deck-mcp-and-hs-review]])',
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search-specs.mjs — `heyspex_search_specs`, the vault's text search for agents
|
|
3
|
+
* ([[HS-YFLS]] fix 2).
|
|
4
|
+
*
|
|
5
|
+
* THE DEFECT THIS CLOSES
|
|
6
|
+
* ----------------------
|
|
7
|
+
* The bug-filing protocol tells an agent to "search specs/diagnoses/ before
|
|
8
|
+
* filing" — and no MCP tool searched specs by text. In PROD mode the vault is
|
|
9
|
+
* CLOUD-primary and the local mirror is incomplete (537 files on disk against
|
|
10
|
+
* 559 in the system of record), so a `grep` over the checkout is not a search
|
|
11
|
+
* of the vault: it answers "not on this disk", which is not the same claim.
|
|
12
|
+
* `heyspex_resolve_focus_intent` is the nearest thing and it is built for a
|
|
13
|
+
* different job — given the AC2 symptom sentence it returns 20 specs matched on
|
|
14
|
+
* a broad `security` tag and neither of the two that actually describe it.
|
|
15
|
+
*
|
|
16
|
+
* WHAT THIS IS
|
|
17
|
+
* ------------
|
|
18
|
+
* A duplicate check. It reads the CLOUD vault through `GET /api/search` — the
|
|
19
|
+
* system of record, never local disk — asks a handful of bounded angles
|
|
20
|
+
* ({@link ./symptom-query.mjs}, see that module for why one query cannot work),
|
|
21
|
+
* merges the windows, and returns enough per row to JUDGE a duplicate without a
|
|
22
|
+
* second call: id, name, type, status, a marked-up snippet, and which angles
|
|
23
|
+
* matched.
|
|
24
|
+
*
|
|
25
|
+
* A NEGATIVE MUST MEAN "NOT THERE", NEVER "COULD NOT LOOK" ([[HS-YFLS]] AC3)
|
|
26
|
+
* -------------------------------------------------------------------------
|
|
27
|
+
* Every way of not finding something is reported as a DIFFERENT thing:
|
|
28
|
+
*
|
|
29
|
+
* result: "no_match" — the corpus WAS searched and holds nothing. `searched`
|
|
30
|
+
* is true and `queries_tried` lists exactly what was
|
|
31
|
+
* asked. This is the only shape an agent may act on by
|
|
32
|
+
* filing a new spec.
|
|
33
|
+
* result: "no_query" — nothing searchable was extracted from the input.
|
|
34
|
+
* isError envelopes — CONSOLE_UNREACHABLE / CONSOLE_NOT_SIGNED_IN /
|
|
35
|
+
* VAULT_SCOPE_UNRESOLVED (a transient 503, retry) /
|
|
36
|
+
* VAULT_SCOPE_UNAUTHORIZED (the caller's vault could
|
|
37
|
+
* not be resolved, so the endpoint returned an empty
|
|
38
|
+
* page that means nothing about the corpus) /
|
|
39
|
+
* SEARCH_FAILED / SEARCH_RESPONSE_UNRECOGNIZED.
|
|
40
|
+
*
|
|
41
|
+
* An angle that fails fails the WHOLE call. A partial answer is indistinguishable
|
|
42
|
+
* from a complete one at the call site, and a silently partial duplicate check is
|
|
43
|
+
* exactly how the duplicate gets filed.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { consoleFetch as defaultConsoleFetch, controlOk } from './console-transport.mjs';
|
|
47
|
+
import { buildSymptomQueries } from './symptom-query.mjs';
|
|
48
|
+
|
|
49
|
+
/** Rows requested per angle. Bounded: this tool never walks the vault. */
|
|
50
|
+
const ANGLE_LIMIT = 20;
|
|
51
|
+
|
|
52
|
+
/** Default / maximum rows returned to the agent. */
|
|
53
|
+
const DEFAULT_TOP_K = 10;
|
|
54
|
+
const MAX_TOP_K = 30;
|
|
55
|
+
|
|
56
|
+
/** Snippets are for judging a duplicate, not for reading the spec. */
|
|
57
|
+
const MAX_SNIPPET_CHARS = 320;
|
|
58
|
+
|
|
59
|
+
function errorResult(payload) {
|
|
60
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Classify a non-OK `/api/search` response. Each code sends the agent somewhere
|
|
65
|
+
* different, which is the entire point of not collapsing them into "no results".
|
|
66
|
+
*/
|
|
67
|
+
function searchFailure(res, query) {
|
|
68
|
+
const status = res?.status ?? 0;
|
|
69
|
+
const code =
|
|
70
|
+
status === 0
|
|
71
|
+
? 'CONSOLE_UNREACHABLE'
|
|
72
|
+
: status === 401
|
|
73
|
+
? 'CONSOLE_NOT_SIGNED_IN'
|
|
74
|
+
: status === 503
|
|
75
|
+
? 'VAULT_SCOPE_UNRESOLVED'
|
|
76
|
+
: 'SEARCH_FAILED';
|
|
77
|
+
const message =
|
|
78
|
+
code === 'CONSOLE_UNREACHABLE'
|
|
79
|
+
? 'The console did not answer, so the vault was NOT searched. Check heyspex_dev_server_status (or console reachability in cloud mode) — do NOT read this as "no matching spec".'
|
|
80
|
+
: code === 'CONSOLE_NOT_SIGNED_IN'
|
|
81
|
+
? 'The console refused this caller (401), so the vault was NOT searched. Re-pair the MCP bearer — do NOT read this as "no matching spec".'
|
|
82
|
+
: code === 'VAULT_SCOPE_UNRESOLVED'
|
|
83
|
+
? 'This console instance has not resolved its active vault yet (transient). Retry shortly — the vault was NOT searched.'
|
|
84
|
+
: `GET /api/search returned ${status}, so the vault was NOT searched.`;
|
|
85
|
+
return errorResult({
|
|
86
|
+
error: code,
|
|
87
|
+
message,
|
|
88
|
+
searched: false,
|
|
89
|
+
status,
|
|
90
|
+
url: res?.url ?? null,
|
|
91
|
+
attempted_query: query ?? null,
|
|
92
|
+
console_response: res?.body ?? null,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `<mark>`s are kept — they show WHERE the match landed — but the row is capped. */
|
|
97
|
+
function normalizeSnippet(value) {
|
|
98
|
+
if (typeof value !== 'string') return '';
|
|
99
|
+
const flat = value.replace(/\s+/g, ' ').trim();
|
|
100
|
+
return flat.length > MAX_SNIPPET_CHARS ? `${flat.slice(0, MAX_SNIPPET_CHARS)}…` : flat;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeRow(row) {
|
|
104
|
+
const id = typeof row?.id === 'string' ? row.id : null;
|
|
105
|
+
if (!id) return null;
|
|
106
|
+
const rank = Number(row?.rank);
|
|
107
|
+
return {
|
|
108
|
+
id,
|
|
109
|
+
name: String(row?.name ?? id),
|
|
110
|
+
type: String(row?.type ?? 'unknown'),
|
|
111
|
+
status: String(row?.status ?? 'unknown').toLowerCase(),
|
|
112
|
+
rank: Number.isFinite(rank) && rank > 0 ? rank : 0,
|
|
113
|
+
snippet: normalizeSnippet(row?.snippet),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One bounded page for one angle. Returns `{ rows, mode }` on success, or a
|
|
119
|
+
* ready-to-return error envelope under `{ failure }` — never an empty page
|
|
120
|
+
* standing in for a failure.
|
|
121
|
+
*/
|
|
122
|
+
async function fetchAngle(fetchImpl, { q, limit, type, status }) {
|
|
123
|
+
const params = new URLSearchParams();
|
|
124
|
+
params.set('q', q);
|
|
125
|
+
params.set('limit', String(limit));
|
|
126
|
+
params.set('scope', 'body');
|
|
127
|
+
if (type) params.set('type', type);
|
|
128
|
+
if (status) params.set('status', status);
|
|
129
|
+
|
|
130
|
+
let res;
|
|
131
|
+
try {
|
|
132
|
+
res = await fetchImpl(`/api/search?${params.toString()}`);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return {
|
|
135
|
+
failure: errorResult({
|
|
136
|
+
error: 'CONSOLE_UNREACHABLE',
|
|
137
|
+
message: `The console search endpoint threw (${err?.message ?? err}), so the vault was NOT searched. Do NOT read this as "no matching spec".`,
|
|
138
|
+
searched: false,
|
|
139
|
+
attempted_query: q,
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (!res || !res.ok) return { failure: searchFailure(res, q) };
|
|
144
|
+
|
|
145
|
+
const body = res.body && typeof res.body === 'object' ? res.body : {};
|
|
146
|
+
// The route answers a caller whose vault scope it could not authorize with a
|
|
147
|
+
// 200 and an EMPTY page carrying `vaultScope.authorized: false`. That page is
|
|
148
|
+
// a permission verdict, not a statement about the corpus — the one shape that
|
|
149
|
+
// would otherwise read as a clean "no duplicate exists".
|
|
150
|
+
if (body.vaultScope && body.vaultScope.authorized === false) {
|
|
151
|
+
return {
|
|
152
|
+
failure: errorResult({
|
|
153
|
+
error: 'VAULT_SCOPE_UNAUTHORIZED',
|
|
154
|
+
message: `The console could not resolve a vault for this caller (${body.vaultScope.reason ?? 'unknown'}), so the empty page it returned says nothing about the vault. Do NOT read this as "no matching spec".`,
|
|
155
|
+
searched: false,
|
|
156
|
+
reason: body.vaultScope.reason ?? null,
|
|
157
|
+
attempted_query: q,
|
|
158
|
+
}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const rows = Array.isArray(body.rows) ? body.rows : Array.isArray(body.results) ? body.results : null;
|
|
162
|
+
// `mode` ("fts" | "bm25" | "trgm" | "memory") is the endpoint's proof that it
|
|
163
|
+
// actually ran a query. Its absence alongside no rows is an unrecognized
|
|
164
|
+
// shape, and an unrecognized shape is never a negative result.
|
|
165
|
+
if (rows === null || typeof body.mode !== 'string') {
|
|
166
|
+
return {
|
|
167
|
+
failure: errorResult({
|
|
168
|
+
error: 'SEARCH_RESPONSE_UNRECOGNIZED',
|
|
169
|
+
message: 'GET /api/search answered 200 with a body this tool cannot read as a search result (no `rows`/`mode`), so the vault was NOT searched.',
|
|
170
|
+
searched: false,
|
|
171
|
+
attempted_query: q,
|
|
172
|
+
console_response: body,
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { rows: rows.map(normalizeRow).filter(Boolean), mode: body.mode };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `heyspex_search_specs` — find specs in the CLOUD vault by symptom text.
|
|
181
|
+
*
|
|
182
|
+
* args: { query, top_k?, type?, status? }
|
|
183
|
+
* deps: { fetchImpl } (tests inject; production uses the shared consoleFetch)
|
|
184
|
+
*/
|
|
185
|
+
export async function handleSearchSpecs(args = {}, { fetchImpl = defaultConsoleFetch } = {}) {
|
|
186
|
+
const query = typeof args.query === 'string' ? args.query.trim() : '';
|
|
187
|
+
if (!query) {
|
|
188
|
+
return errorResult({
|
|
189
|
+
error: 'SEARCH_QUERY_REQUIRED',
|
|
190
|
+
message: '`query` is required — pass the symptom sentence (or the concept) you would otherwise grep for.',
|
|
191
|
+
searched: false,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
const topKRaw = Number(args.top_k);
|
|
195
|
+
const topK = Number.isFinite(topKRaw) && topKRaw > 0 ? Math.min(MAX_TOP_K, Math.trunc(topKRaw)) : DEFAULT_TOP_K;
|
|
196
|
+
const type = typeof args.type === 'string' && args.type.trim() ? args.type.trim() : undefined;
|
|
197
|
+
const status = typeof args.status === 'string' && args.status.trim() ? args.status.trim() : undefined;
|
|
198
|
+
|
|
199
|
+
const { terms, queries } = buildSymptomQueries(query);
|
|
200
|
+
if (queries.length === 0) {
|
|
201
|
+
return controlOk({
|
|
202
|
+
query,
|
|
203
|
+
terms,
|
|
204
|
+
queries_tried: [],
|
|
205
|
+
searched: false,
|
|
206
|
+
result: 'no_query',
|
|
207
|
+
match_count: 0,
|
|
208
|
+
matches: [],
|
|
209
|
+
message: 'No searchable term survived normalization (the input was punctuation or stopwords only) — the vault was NOT searched. Rephrase with the concrete nouns from the symptom.',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** id → merged candidate. */
|
|
214
|
+
const byId = new Map();
|
|
215
|
+
const queriesTried = [];
|
|
216
|
+
let mode = null;
|
|
217
|
+
|
|
218
|
+
for (const angle of queries) {
|
|
219
|
+
// eslint-disable-next-line no-await-in-loop -- a handful of bounded angles, deliberately sequential so a failure stops the walk.
|
|
220
|
+
const page = await fetchAngle(fetchImpl, { q: angle.q, limit: ANGLE_LIMIT, type, status });
|
|
221
|
+
if (page.failure) return page.failure;
|
|
222
|
+
mode = mode ?? page.mode;
|
|
223
|
+
queriesTried.push({ label: angle.label, q: angle.q, hits: page.rows.length, mode: page.mode });
|
|
224
|
+
page.rows.forEach((row, position) => {
|
|
225
|
+
const existing = byId.get(row.id);
|
|
226
|
+
if (!existing) {
|
|
227
|
+
byId.set(row.id, { ...row, matched_by: [angle.label], best_position: position });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
// Keep the strongest evidence each angle produced for this spec.
|
|
231
|
+
existing.rank = Math.max(existing.rank, row.rank);
|
|
232
|
+
existing.best_position = Math.min(existing.best_position, position);
|
|
233
|
+
if (!existing.snippet && row.snippet) existing.snippet = row.snippet;
|
|
234
|
+
if (!existing.matched_by.includes(angle.label)) existing.matched_by.push(angle.label);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Agreement across angles first (a spec the quoted concept AND the loose term
|
|
239
|
+
// window both surfaced is the likeliest duplicate), then the endpoint's own
|
|
240
|
+
// `ts_rank_cd`, then the order it handed rows back — which is the only signal
|
|
241
|
+
// left on the disk read tier, where every rank is 0.
|
|
242
|
+
const matches = [...byId.values()]
|
|
243
|
+
.sort(
|
|
244
|
+
(a, b) =>
|
|
245
|
+
b.matched_by.length - a.matched_by.length ||
|
|
246
|
+
b.rank - a.rank ||
|
|
247
|
+
a.best_position - b.best_position ||
|
|
248
|
+
a.id.localeCompare(b.id),
|
|
249
|
+
)
|
|
250
|
+
.slice(0, topK)
|
|
251
|
+
.map(({ best_position: _position, ...row }) => row);
|
|
252
|
+
|
|
253
|
+
if (matches.length === 0) {
|
|
254
|
+
return controlOk({
|
|
255
|
+
query,
|
|
256
|
+
terms,
|
|
257
|
+
queries_tried: queriesTried,
|
|
258
|
+
searched: true,
|
|
259
|
+
corpus: 'cloud-vault (GET /api/search — the system of record, not local disk)',
|
|
260
|
+
result: 'no_match',
|
|
261
|
+
match_count: 0,
|
|
262
|
+
matches: [],
|
|
263
|
+
mode,
|
|
264
|
+
message: `Searched the vault on ${queriesTried.length} angle(s) and found NOTHING. This is an explicit negative: the corpus was read and holds no spec matching these terms — it is safe to file.`,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return controlOk({
|
|
269
|
+
query,
|
|
270
|
+
terms,
|
|
271
|
+
queries_tried: queriesTried,
|
|
272
|
+
searched: true,
|
|
273
|
+
corpus: 'cloud-vault (GET /api/search — the system of record, not local disk)',
|
|
274
|
+
result: 'match',
|
|
275
|
+
match_count: matches.length,
|
|
276
|
+
mode,
|
|
277
|
+
matches,
|
|
278
|
+
message: 'Read each match before filing: a spec that already describes this symptom must be commented on or reopened, not duplicated.',
|
|
279
|
+
});
|
|
280
|
+
}
|