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
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pre-plan grounding for HeySpex MCP (/hs-ground).
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* North-star/pillar hooks, architecture placement, stress-test prompts,
|
|
4
|
+
* enhancement seeds — read-only.
|
|
5
|
+
*
|
|
6
|
+
* RETRIEVAL IS BOUNDED AND REMOTE ([[HS-YIR1-8KK5-TASK-planning-grounding-retrieves-through]]
|
|
7
|
+
* of [[HS-FI33-8KK5-EPIC-lexical-retrieval-in-postgres-generated]]). This module
|
|
8
|
+
* used to walk `specs/**` from disk on every call, parse every markdown file and
|
|
9
|
+
* build an in-process BM25 corpus + backlink graph — work proportional to vault
|
|
10
|
+
* size on a request path. It now issues a fixed, small number of bounded
|
|
11
|
+
* `consoleFetch` calls to `GET /api/search` (top-k per angle, k <=
|
|
12
|
+
* {@link SEARCH_ANGLE_LIMIT}, paged with the endpoint's `next_cursor`), so
|
|
13
|
+
* grounding a plan costs the same at six thousand specs and at a million.
|
|
14
|
+
*
|
|
15
|
+
* NOTHING in this file may read the filesystem — `plan-ground.guard.test.mjs`
|
|
16
|
+
* greps the source for `readFileSync` / `readdirSync` / `walkMdFiles` /
|
|
17
|
+
* `fs.glob` / `globSync` to keep that true.
|
|
5
18
|
*/
|
|
6
19
|
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
20
|
+
import { basename } from 'node:path';
|
|
21
|
+
import { consoleFetch as defaultConsoleFetch } from './console-transport.mjs';
|
|
9
22
|
import {
|
|
10
23
|
analyzeGroundingConflicts,
|
|
11
24
|
computeSafeContextSpecs,
|
|
@@ -27,27 +40,89 @@ const ANCHOR_SPECS = [
|
|
|
27
40
|
// WIP ledger (.heyspex/wip-state.json, surfaced in the /hs-next brief), not a markdown anchor.
|
|
28
41
|
];
|
|
29
42
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Frontmatter `type:` → discovery bucket. Replaces the old `SCAN_DIRS` folder
|
|
45
|
+
* walk: the search endpoint returns a row's `type`, so the bucket a candidate
|
|
46
|
+
* lands in is read off the row instead of off the directory it was walked from.
|
|
47
|
+
* Captures stay in the corpus — excluding them was the [[HS-RES-2QX6]] root
|
|
48
|
+
* cause (an agent reinvented an already-designed concept because /hs-ground
|
|
49
|
+
* could not see [[HS-CAP-UIBF]]); milestones + diagnoses round out the in-flight
|
|
50
|
+
* surfaces a plan can collide with.
|
|
51
|
+
*/
|
|
52
|
+
const TYPE_BUCKETS = {
|
|
53
|
+
epic: 'epics',
|
|
54
|
+
task: 'tasks',
|
|
55
|
+
protocol: 'protocols',
|
|
56
|
+
adr: 'adrs',
|
|
57
|
+
'use-case': 'use_cases',
|
|
58
|
+
use_case: 'use_cases',
|
|
59
|
+
'memory-lesson': 'memories',
|
|
60
|
+
'memory-decision': 'memories',
|
|
61
|
+
'memory-reference': 'memories',
|
|
62
|
+
'memory-ui-pattern': 'memories',
|
|
63
|
+
memory: 'memories',
|
|
64
|
+
research: 'research',
|
|
65
|
+
capture: 'captures',
|
|
66
|
+
milestone: 'milestones',
|
|
67
|
+
diagnose: 'diagnoses',
|
|
68
|
+
diagnosis: 'diagnoses',
|
|
69
|
+
'future-consideration': 'future_considerations',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The `type=` filter values for the bucket-targeted recall angles (CSV, pushed into SQL). */
|
|
73
|
+
const PROTOCOL_TYPE_FILTER = 'protocol';
|
|
74
|
+
const MEMORY_TYPE_FILTER = 'memory-decision,memory-lesson,memory-reference,memory-ui-pattern';
|
|
75
|
+
const WORK_TYPE_FILTER = 'epic,task,milestone';
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The only two numbers that bound retrieval cost. `SEARCH_ANGLE_LIMIT` is the
|
|
79
|
+
* per-call `?limit=` (the task contract: k <= 20); a wider recall window is
|
|
80
|
+
* assembled by following the endpoint's `next_cursor` for at most
|
|
81
|
+
* `MAX_PAGES_PER_ANGLE` pages, never by asking for the whole vault.
|
|
82
|
+
*/
|
|
83
|
+
const SEARCH_ANGLE_LIMIT = 20;
|
|
84
|
+
const MAX_PAGES_PER_ANGLE = 3;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* How many of the top-ranked candidates get a bounded metadata read
|
|
88
|
+
* (`GET /api/specs/<id>`) for their basename, tags, authored summary and
|
|
89
|
+
* `updated` stamp. Constant, so enrichment cost never tracks vault size. Rows
|
|
90
|
+
* outside the cap keep the endpoint's `id`/`name`/`type`/`status`/`rank`/
|
|
91
|
+
* `snippet` and score on rank + status alone.
|
|
92
|
+
*/
|
|
93
|
+
const ENRICH_CAP = 12;
|
|
94
|
+
|
|
95
|
+
/** Known-tags page size for the focus resolver's strict-tag vocabulary lookup. */
|
|
96
|
+
const KNOWN_TAGS_PAGE = 200;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How many of the per-row calls may be in flight at once. Bounded work is not
|
|
100
|
+
* the same as polite work: firing the whole enrichment batch plus the anchor
|
|
101
|
+
* resolves at a single-process dev console at once made every one of them time
|
|
102
|
+
* out (measured on the disk read tier, where each `/api/specs/:id` is a vault
|
|
103
|
+
* read). Small and fixed — the wall-clock cost of the extra rounds is noise
|
|
104
|
+
* against one timeout.
|
|
105
|
+
*/
|
|
106
|
+
const FETCH_CONCURRENCY = 4;
|
|
107
|
+
|
|
108
|
+
/** `Promise.all`-with-a-lid: run `fn` over `items`, at most {@link FETCH_CONCURRENCY} at a time. */
|
|
109
|
+
async function mapWithConcurrency(items, fn, concurrency = FETCH_CONCURRENCY) {
|
|
110
|
+
const results = new Array(items.length);
|
|
111
|
+
let cursor = 0;
|
|
112
|
+
const worker = async () => {
|
|
113
|
+
for (;;) {
|
|
114
|
+
const index = cursor++;
|
|
115
|
+
if (index >= items.length) return;
|
|
116
|
+
// eslint-disable-next-line no-await-in-loop -- that is the point: one at a time per worker.
|
|
117
|
+
results[index] = await fn(items[index], index);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
|
121
|
+
return results;
|
|
122
|
+
}
|
|
48
123
|
|
|
49
124
|
const AREA_BOOSTS = {
|
|
50
|
-
console: { paths: ['spex-console/'], tags: ['console', 'ui'
|
|
125
|
+
console: { paths: ['spex-console/'], tags: ['console', 'ui'], surfaces: ['console'] },
|
|
51
126
|
companion: { paths: ['companion/'], tags: ['companion', 'windows'], surfaces: ['companion'] },
|
|
52
127
|
mcp: { paths: ['vendor/heyspex-mcp/'], tags: ['mcp', 'slash'], surfaces: ['mcp'] },
|
|
53
128
|
vault: { paths: ['specs/'], tags: ['vault', 'specs'], surfaces: ['vault'] },
|
|
@@ -71,24 +146,22 @@ const STRESS_TEST_PROMPTS = [
|
|
|
71
146
|
'Is this in-scope for current milestones, or should it be deferred to horizon?',
|
|
72
147
|
];
|
|
73
148
|
|
|
74
|
-
const SKIP_DIRS = new Set([
|
|
75
|
-
'node_modules', '.git', '.next', 'dist', 'build', 'coverage', '.heyspex',
|
|
76
|
-
]);
|
|
77
|
-
|
|
78
149
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
79
|
-
//
|
|
150
|
+
// Reranking over a bounded retrieval window (Postgres lexical recall + signals)
|
|
80
151
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
81
152
|
//
|
|
82
|
-
// ## Algorithm Overview (HS-EPIC-74YP)
|
|
153
|
+
// ## Algorithm Overview (HS-EPIC-74YP, retrieval retired to Postgres by HS-YIR1)
|
|
83
154
|
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
155
|
+
// Retrieval is `GET /api/search` — `search_vector @@ websearch_to_tsquery`,
|
|
156
|
+
// ordered by `ts_rank_cd`, GIN-index-backed, keyset-paged. This module no longer
|
|
157
|
+
// computes BM25 itself; it RERANKS the bounded window the endpoint returns,
|
|
158
|
+
// combining the endpoint's rank with the signals that live outside the tsvector:
|
|
86
159
|
//
|
|
87
|
-
// ### Signal 1:
|
|
88
|
-
// -
|
|
89
|
-
// -
|
|
90
|
-
//
|
|
91
|
-
// -
|
|
160
|
+
// ### Signal 1: Retrieval recall (primary signal, weight: 3.0)
|
|
161
|
+
// - `ts_rank_cd` from the endpoint (title weight A over body weight B)
|
|
162
|
+
// - plus a positional term, so the disk read tier (which returns rank 0 for
|
|
163
|
+
// every row) still ranks by the order the endpoint handed rows back
|
|
164
|
+
// - plus a small bonus for a candidate that surfaced on more than one angle
|
|
92
165
|
//
|
|
93
166
|
// ### Signal 2: Title Match Boost (weight: 2.0)
|
|
94
167
|
// - Full phrase match in title: 10 points
|
|
@@ -109,204 +182,317 @@ const SKIP_DIRS = new Set([
|
|
|
109
182
|
// - in_review: 1.0 (pending completion)
|
|
110
183
|
// - closed: 0 (completed, less relevant for planning)
|
|
111
184
|
//
|
|
112
|
-
// ### Signal 6:
|
|
113
|
-
// - Specs with more inbound wikilinks are more "authoritative"
|
|
114
|
-
// - Uses log scale: log1p(count), capped at 3
|
|
115
|
-
// - Prevents heavily-linked specs from dominating while still boosting them
|
|
116
|
-
//
|
|
117
|
-
// ### Signal 7: Recency (weight: 0.6)
|
|
185
|
+
// ### Signal 6: Recency (weight: 0.6)
|
|
118
186
|
// - Recently updated specs are more relevant for planning
|
|
119
187
|
// - Decay tiers: <7d=2, <30d=1.5, <90d=1, <365d=0.5, older=0
|
|
188
|
+
// - Applies to candidates whose bounded metadata read returned an `updated`
|
|
189
|
+
// stamp; a row outside ENRICH_CAP simply scores 0 here (never a file read).
|
|
120
190
|
//
|
|
121
191
|
// ### Formula
|
|
122
|
-
// Score = (
|
|
123
|
-
// + (status × 0.8) + (
|
|
192
|
+
// Score = (recall × 3.0) + (title × 2.0) + (tag × 1.5) + (area × 1.0)
|
|
193
|
+
// + (status × 0.8) + (recency × 0.6)
|
|
194
|
+
//
|
|
195
|
+
// ### Retired signal: backlink importance (was weight 1.2)
|
|
196
|
+
// The inbound-wikilink boost needed a whole-vault walk to build the backlink
|
|
197
|
+
// graph — exactly the cost this task exists to remove — so it is GONE, not
|
|
198
|
+
// approximated. A per-spec backlink count can return later through
|
|
199
|
+
// `/api/links/:id` / `/api/backlinks/:id`, which reads `spec_links` in one
|
|
200
|
+
// indexed query. ([[HS-YIR1-8KK5-TASK-planning-grounding-retrieves-through]])
|
|
124
201
|
//
|
|
125
202
|
// ### Tuning
|
|
126
203
|
// - Weights chosen empirically to balance precision vs recall
|
|
127
|
-
// -
|
|
204
|
+
// - recall is the primary signal (3.0): it is the endpoint's ranked relevance
|
|
128
205
|
// - title gets 2.0 because explicit matches are high confidence
|
|
129
206
|
// - status/recency are lower (0.6-0.8) as tiebreakers, not primary filters
|
|
130
207
|
//
|
|
131
208
|
// See: [[hs-ground-search-algorithm]] for design decision documentation
|
|
132
|
-
// Tests: vendor/heyspex-mcp/plan-ground.test.mjs
|
|
209
|
+
// Tests: vendor/heyspex-mcp/plan-ground.test.mjs
|
|
133
210
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
134
211
|
|
|
135
|
-
/** Lazy index singleton with cache invalidation */
|
|
136
|
-
let cachedIndex = null;
|
|
137
|
-
let cacheTimestamp = 0;
|
|
138
|
-
let cachedVaultRoot = null;
|
|
139
|
-
const CACHE_TTL_MS = 30_000; // 30 seconds
|
|
140
|
-
|
|
141
212
|
/**
|
|
142
213
|
* Scoring weights for combining relevance signals.
|
|
143
214
|
*
|
|
144
215
|
* These weights were tuned empirically to balance:
|
|
145
|
-
* -
|
|
216
|
+
* - rank (3.0): Primary signal — the search endpoint's ranked recall
|
|
146
217
|
* - title (2.0): High-confidence explicit matches
|
|
147
218
|
* - tag (1.5): User-defined categorization
|
|
148
219
|
* - area (1.0): Code area relevance
|
|
149
220
|
* - status (0.8): Prioritize actionable specs
|
|
150
|
-
* - backlink (1.2): Graph-based importance
|
|
151
221
|
* - recency (0.6): Time decay as tiebreaker
|
|
152
222
|
*
|
|
153
223
|
* @see [[hs-ground-search-algorithm]] for rationale
|
|
154
224
|
*/
|
|
155
225
|
const SCORE_WEIGHTS = {
|
|
156
|
-
|
|
226
|
+
rank: 3.0, // Retrieval recall from /api/search (primary signal)
|
|
157
227
|
title: 2.0, // Title match boost
|
|
158
228
|
tag: 1.5, // Tag match
|
|
159
229
|
area: 1.0, // Area/surface match
|
|
160
230
|
status: 0.8, // Status boost
|
|
161
|
-
backlink: 1.2, // Graph importance
|
|
162
231
|
recency: 0.6, // Time decay
|
|
163
232
|
};
|
|
164
233
|
|
|
234
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
235
|
+
// Bounded console retrieval — every read below is one HTTP call with an
|
|
236
|
+
// explicit `limit`, optionally continued with the endpoint's opaque cursor.
|
|
237
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
165
239
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* - Controls how quickly TF contribution saturates
|
|
170
|
-
* - Range: 1.2-2.0 typical; 1.5 is a balanced default
|
|
171
|
-
* - Higher = TF matters more; lower = saturates faster
|
|
172
|
-
*
|
|
173
|
-
* b (0.75): Document length normalization parameter
|
|
174
|
-
* - Controls how much long documents are penalized
|
|
175
|
-
* - Range: 0.0-1.0; 0.75 is the standard default
|
|
176
|
-
* - Higher = more normalization; 0 = no length penalty
|
|
177
|
-
*
|
|
178
|
-
* These are the Robertson/Walker defaults from the original BM25 paper.
|
|
240
|
+
* Raised when the grounding path cannot retrieve at all (console unreachable,
|
|
241
|
+
* not signed in, 5xx). Callers turn this into a typed degraded/error result —
|
|
242
|
+
* grounding NEVER falls back to reading the vault off disk.
|
|
179
243
|
*/
|
|
180
|
-
|
|
181
|
-
|
|
244
|
+
class ConsoleRetrievalError extends Error {
|
|
245
|
+
constructor(message, { code = 'CONSOLE_UNREACHABLE', status = 0, url = null } = {}) {
|
|
246
|
+
super(message);
|
|
247
|
+
this.name = 'ConsoleRetrievalError';
|
|
248
|
+
this.code = code;
|
|
249
|
+
this.status = status;
|
|
250
|
+
this.url = url;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function clampInt(value, min, max, fallback) {
|
|
255
|
+
const n = Number(value);
|
|
256
|
+
if (!Number.isFinite(n)) return fallback;
|
|
257
|
+
return Math.min(Math.max(Math.trunc(n), min), max);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** `<mark>` markers from `ts_headline` are for the console UI, not for an agent. */
|
|
261
|
+
function stripHeadlineMarks(snippet) {
|
|
262
|
+
return String(snippet || '')
|
|
263
|
+
.replace(/<\/?mark>/g, '')
|
|
264
|
+
.replace(/\s+/g, ' ')
|
|
265
|
+
.trim();
|
|
266
|
+
}
|
|
182
267
|
|
|
183
268
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
269
|
+
* Normalize one search row. The Neon tier answers `{ rows: [...] }` with a
|
|
270
|
+
* `ts_rank_cd` rank and a `ts_headline` snippet; the disk tier answers the same
|
|
271
|
+
* `rows` shape with `rank: 0` (and a legacy `results` alias). Read either.
|
|
186
272
|
*/
|
|
187
|
-
function
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
273
|
+
function normalizeSearchRow(row) {
|
|
274
|
+
if (!row || typeof row !== 'object') return null;
|
|
275
|
+
const id = String(row.id ?? '').trim();
|
|
276
|
+
if (!id) return null;
|
|
277
|
+
const rank = Number(row.rank);
|
|
278
|
+
return {
|
|
279
|
+
id,
|
|
280
|
+
name: String(row.name ?? id),
|
|
281
|
+
type: String(row.type ?? 'unknown'),
|
|
282
|
+
status: String(row.status ?? 'open').toLowerCase(),
|
|
283
|
+
rank: Number.isFinite(rank) && rank > 0 ? rank : 0,
|
|
284
|
+
snippet: stripHeadlineMarks(row.snippet),
|
|
194
285
|
};
|
|
195
|
-
|
|
286
|
+
}
|
|
196
287
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
288
|
+
/**
|
|
289
|
+
* ONE bounded page of `GET /api/search`. `limit` is hard-capped at
|
|
290
|
+
* {@link SEARCH_ANGLE_LIMIT} here, not by the caller — no code path in this
|
|
291
|
+
* module can ask the endpoint for an unbounded page.
|
|
292
|
+
*/
|
|
293
|
+
async function fetchSearchPage(fetchImpl, { q, limit, type, status, after }) {
|
|
294
|
+
const params = new URLSearchParams();
|
|
295
|
+
params.set('q', q);
|
|
296
|
+
params.set('limit', String(clampInt(limit, 1, SEARCH_ANGLE_LIMIT, SEARCH_ANGLE_LIMIT)));
|
|
297
|
+
if (type) params.set('type', type);
|
|
298
|
+
if (status) params.set('status', status);
|
|
299
|
+
if (after) params.set('after', after);
|
|
300
|
+
const res = await fetchImpl(`/api/search?${params.toString()}`);
|
|
301
|
+
if (!res || !res.ok) {
|
|
302
|
+
const status = res?.status ?? 0;
|
|
303
|
+
// Split "the console is not there" from "the console answered badly": the
|
|
304
|
+
// first sends an agent to heyspex_dev_server_status, the second is a bug or
|
|
305
|
+
// a sign-in problem and no amount of restarting fixes it.
|
|
306
|
+
const code = status === 0
|
|
307
|
+
? 'CONSOLE_UNREACHABLE'
|
|
308
|
+
: status === 401
|
|
309
|
+
? 'CONSOLE_NOT_SIGNED_IN'
|
|
310
|
+
: 'SEARCH_FAILED';
|
|
311
|
+
throw new ConsoleRetrievalError(
|
|
312
|
+
`GET /api/search returned ${status || 'no response'}`,
|
|
313
|
+
{ code, status, url: res?.url ?? null },
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const body = res.body && typeof res.body === 'object' ? res.body : {};
|
|
317
|
+
const raw = Array.isArray(body.rows)
|
|
318
|
+
? body.rows
|
|
319
|
+
: Array.isArray(body.results)
|
|
320
|
+
? body.results
|
|
321
|
+
: [];
|
|
322
|
+
return {
|
|
323
|
+
rows: raw.map(normalizeSearchRow).filter(Boolean),
|
|
324
|
+
next_cursor: typeof body.next_cursor === 'string' && body.next_cursor ? body.next_cursor : null,
|
|
325
|
+
mode: typeof body.mode === 'string' ? body.mode : null,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
201
328
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
329
|
+
/**
|
|
330
|
+
* One recall ANGLE: up to `want` rows assembled from at most
|
|
331
|
+
* {@link MAX_PAGES_PER_ANGLE} bounded pages, following the endpoint's keyset
|
|
332
|
+
* cursor. Never `OFFSET`, never a total count, never a second query per row.
|
|
333
|
+
*/
|
|
334
|
+
async function fetchSearchAngle(fetchImpl, { label, q, want, type, status }) {
|
|
335
|
+
const target = clampInt(want, 1, SEARCH_ANGLE_LIMIT * MAX_PAGES_PER_ANGLE, SEARCH_ANGLE_LIMIT);
|
|
336
|
+
const rows = [];
|
|
337
|
+
let after = null;
|
|
338
|
+
let mode = null;
|
|
339
|
+
for (let page = 0; page < MAX_PAGES_PER_ANGLE && rows.length < target; page++) {
|
|
340
|
+
const remaining = target - rows.length;
|
|
341
|
+
// eslint-disable-next-line no-await-in-loop -- keyset paging is inherently sequential.
|
|
342
|
+
const res = await fetchSearchPage(fetchImpl, {
|
|
343
|
+
q,
|
|
344
|
+
limit: Math.min(remaining, SEARCH_ANGLE_LIMIT),
|
|
345
|
+
type,
|
|
346
|
+
status,
|
|
347
|
+
after,
|
|
348
|
+
});
|
|
349
|
+
mode = mode ?? res.mode;
|
|
350
|
+
rows.push(...res.rows);
|
|
351
|
+
if (!res.next_cursor || res.rows.length === 0) break;
|
|
352
|
+
after = res.next_cursor;
|
|
353
|
+
}
|
|
354
|
+
return { label, mode, rows: rows.slice(0, target) };
|
|
355
|
+
}
|
|
218
356
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
357
|
+
/**
|
|
358
|
+
* Merge the angles into one deduplicated candidate window. A candidate keeps
|
|
359
|
+
* its best rank and best position across angles, plus the labels of every angle
|
|
360
|
+
* that surfaced it (the multi-angle agreement bonus, and the `why_matched`
|
|
361
|
+
* provenance the reranking agent reads).
|
|
362
|
+
*/
|
|
363
|
+
function mergeAngles(angles) {
|
|
364
|
+
const byId = new Map();
|
|
365
|
+
for (const angle of angles) {
|
|
366
|
+
if (!angle) continue;
|
|
367
|
+
angle.rows.forEach((row, position) => {
|
|
368
|
+
const existing = byId.get(row.id);
|
|
369
|
+
if (existing) {
|
|
370
|
+
existing.rank = Math.max(existing.rank, row.rank);
|
|
371
|
+
existing.bestPosition = Math.min(existing.bestPosition, position);
|
|
372
|
+
if (!existing.snippet && row.snippet) existing.snippet = row.snippet;
|
|
373
|
+
if (!existing.angles.includes(angle.label)) existing.angles.push(angle.label);
|
|
374
|
+
return;
|
|
227
375
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
docLength: allTokens.length,
|
|
247
|
-
backlinkCount: 0, // computed in phase 3
|
|
248
|
-
forwardRefCount: outgoingLinks.size,
|
|
249
|
-
updatedAt,
|
|
250
|
-
createdAt,
|
|
376
|
+
byId.set(row.id, {
|
|
377
|
+
id: row.id,
|
|
378
|
+
// `basename` is unknown until the bounded metadata read below; the id is
|
|
379
|
+
// a resolvable stand-in ([[HS-YIR1]] — `/api/specs/:id` takes either).
|
|
380
|
+
basename: row.id,
|
|
381
|
+
name: row.name,
|
|
382
|
+
type: row.type,
|
|
383
|
+
status: row.status,
|
|
384
|
+
rank: row.rank,
|
|
385
|
+
snippet: row.snippet,
|
|
386
|
+
bestPosition: position,
|
|
387
|
+
angles: [angle.label],
|
|
388
|
+
// Filled by enrichCandidates; defaults keep every scorer total.
|
|
389
|
+
tags: [],
|
|
390
|
+
relPath: '',
|
|
391
|
+
updatedAt: 0,
|
|
392
|
+
summary: null,
|
|
393
|
+
enriched: false,
|
|
251
394
|
});
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
return [...byId.values()];
|
|
398
|
+
}
|
|
252
399
|
|
|
253
|
-
|
|
254
|
-
|
|
400
|
+
/**
|
|
401
|
+
* Bounded metadata read for the top {@link ENRICH_CAP} candidates: basename (so
|
|
402
|
+
* the report can emit a resolving `[[wikilink]]`), tags (tag weight + the focus
|
|
403
|
+
* resolver's strict-tag test), authored `summary:` and the `updated` stamp
|
|
404
|
+
* (recency weight). Best-effort and parallel — a miss leaves the row scored on
|
|
405
|
+
* rank + status, it never falls back to a file read.
|
|
406
|
+
*/
|
|
407
|
+
async function enrichCandidates(fetchImpl, candidates, cap = ENRICH_CAP) {
|
|
408
|
+
const targets = candidates.slice(0, Math.max(0, cap));
|
|
409
|
+
await mapWithConcurrency(targets, async (candidate) => {
|
|
410
|
+
try {
|
|
411
|
+
const res = await fetchImpl(`/api/specs/${encodeURIComponent(candidate.id)}`);
|
|
412
|
+
if (!res || !res.ok) return;
|
|
413
|
+
const spec = res.body && typeof res.body === 'object' ? res.body.spec : null;
|
|
414
|
+
if (!spec || typeof spec !== 'object') return;
|
|
415
|
+
applySpecMetadata(candidate, spec);
|
|
255
416
|
} catch {
|
|
256
|
-
//
|
|
417
|
+
// Best-effort: an enrichment miss degrades the row's signals, never the call.
|
|
257
418
|
}
|
|
258
|
-
}
|
|
419
|
+
});
|
|
420
|
+
return candidates;
|
|
421
|
+
}
|
|
259
422
|
|
|
260
|
-
|
|
423
|
+
/** Fold a `SpecSummary`-shaped record (list row or `/api/specs/:id`) onto a candidate. */
|
|
424
|
+
function applySpecMetadata(candidate, spec) {
|
|
425
|
+
candidate.enriched = true;
|
|
426
|
+
if (spec.basename) candidate.basename = String(spec.basename);
|
|
427
|
+
if (spec.relPath) candidate.relPath = String(spec.relPath);
|
|
428
|
+
if (spec.name) candidate.name = String(spec.name);
|
|
429
|
+
if (spec.type) candidate.type = String(spec.type);
|
|
430
|
+
if (spec.status) candidate.status = String(spec.status).toLowerCase();
|
|
431
|
+
candidate.tags = normalizeTags(spec.tags);
|
|
432
|
+
candidate.updatedAt = parseIsoDate(spec.updatedAt || spec.updated);
|
|
433
|
+
const authored = spec.frontmatter && typeof spec.frontmatter.summary === 'string'
|
|
434
|
+
? spec.frontmatter.summary.trim()
|
|
435
|
+
: typeof spec.summary === 'string'
|
|
436
|
+
? spec.summary.trim()
|
|
437
|
+
: '';
|
|
438
|
+
if (authored) candidate.summary = authored;
|
|
439
|
+
return candidate;
|
|
440
|
+
}
|
|
261
441
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
442
|
+
/**
|
|
443
|
+
* `GET /api/specs?tag=…` — ONE bounded, tag-filtered list page. The filter is
|
|
444
|
+
* pushed into SQL by the endpoint, so this is the strict-tag lookup without a
|
|
445
|
+
* corpus: the rows it returns are exactly the specs carrying that tag, with the
|
|
446
|
+
* metadata (tags, basename, updated) the scorer needs already on them.
|
|
447
|
+
*/
|
|
448
|
+
async function fetchTagPage(fetchImpl, tag, limit) {
|
|
449
|
+
const params = new URLSearchParams({
|
|
450
|
+
tag,
|
|
451
|
+
limit: String(clampInt(limit, 1, SEARCH_ANGLE_LIMIT, SEARCH_ANGLE_LIMIT)),
|
|
452
|
+
fields: 'id,name,type,status,tags,basename,relPath,updated,updatedAt',
|
|
453
|
+
});
|
|
454
|
+
const res = await fetchImpl(`/api/specs?${params.toString()}`);
|
|
455
|
+
if (!res || !res.ok) return [];
|
|
456
|
+
const body = res.body && typeof res.body === 'object' ? res.body : {};
|
|
457
|
+
return Array.isArray(body.specs) ? body.specs : [];
|
|
458
|
+
}
|
|
272
459
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
460
|
+
/** `GET /api/scheduler/known-tags` — the closed tag vocabulary, one bounded page. */
|
|
461
|
+
async function fetchKnownTags(fetchImpl, limit = KNOWN_TAGS_PAGE) {
|
|
462
|
+
try {
|
|
463
|
+
const res = await fetchImpl(`/api/scheduler/known-tags?limit=${clampInt(limit, 1, 500, KNOWN_TAGS_PAGE)}`);
|
|
464
|
+
if (!res || !res.ok) return [];
|
|
465
|
+
const body = res.body && typeof res.body === 'object' ? res.body : {};
|
|
466
|
+
return (Array.isArray(body.tags) ? body.tags : [])
|
|
467
|
+
.map((t) => (typeof t === 'string' ? t : t && t.name))
|
|
468
|
+
.filter((t) => typeof t === 'string' && t.trim())
|
|
469
|
+
.map((t) => t.trim().toLowerCase());
|
|
470
|
+
} catch {
|
|
471
|
+
return [];
|
|
278
472
|
}
|
|
279
|
-
|
|
280
|
-
const buildTimeMs = Date.now() - startMs;
|
|
281
|
-
|
|
282
|
-
return {
|
|
283
|
-
corpus,
|
|
284
|
-
specs,
|
|
285
|
-
backlinkGraph,
|
|
286
|
-
lastBuilt: Date.now(),
|
|
287
|
-
buildTimeMs,
|
|
288
|
-
};
|
|
289
473
|
}
|
|
290
474
|
|
|
291
475
|
/**
|
|
292
|
-
*
|
|
476
|
+
* `GET /api/specs/resolve?q=…` — the one bounded call that turns an anchor
|
|
477
|
+
* basename into `{ basename, id, relPath }`. Used only for the small static
|
|
478
|
+
* anchor list; returns null when the anchor is absent from this vault.
|
|
293
479
|
*/
|
|
294
|
-
function
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
480
|
+
async function resolveSpecRef(fetchImpl, token) {
|
|
481
|
+
try {
|
|
482
|
+
const res = await fetchImpl(`/api/specs/resolve?q=${encodeURIComponent(token)}`);
|
|
483
|
+
if (!res || !res.ok) return null;
|
|
484
|
+
const resolved = res.body && typeof res.body === 'object' ? res.body.resolved : null;
|
|
485
|
+
return resolved && typeof resolved === 'object' ? resolved : null;
|
|
486
|
+
} catch {
|
|
487
|
+
return null;
|
|
301
488
|
}
|
|
302
|
-
cachedIndex = buildVaultIndex(vaultRoot);
|
|
303
|
-
cacheTimestamp = now;
|
|
304
|
-
cachedVaultRoot = vaultRoot;
|
|
305
|
-
return cachedIndex;
|
|
306
489
|
}
|
|
307
490
|
|
|
308
491
|
/**
|
|
309
|
-
* Extract wikilinks from body text and frontmatter.
|
|
492
|
+
* Extract wikilinks from body text and frontmatter. Retained as a pure helper
|
|
493
|
+
* (the backlink GRAPH it used to feed is gone with the vault walk) — callers
|
|
494
|
+
* still use it to read a spec's declared edges out of a record they already
|
|
495
|
+
* hold, without touching disk.
|
|
310
496
|
*/
|
|
311
497
|
function extractWikilinks(body, frontmatter) {
|
|
312
498
|
const links = new Set();
|
|
@@ -364,30 +550,29 @@ function parseIsoDate(dateStr) {
|
|
|
364
550
|
}
|
|
365
551
|
|
|
366
552
|
/**
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
553
|
+
* Retrieval recall for one candidate — the primary signal, replacing the
|
|
554
|
+
* in-process BM25 that needed a whole-vault corpus.
|
|
555
|
+
*
|
|
556
|
+
* Three bounded terms, each in [0, 1]-ish so the weighted sum stays comparable
|
|
557
|
+
* to the old tfidf term:
|
|
558
|
+
* - `rank`: the endpoint's `ts_rank_cd`, clamped at 1 (values are small and
|
|
559
|
+
* long-tailed; the clamp stops one lucky row from dominating).
|
|
560
|
+
* - positional: how near the top of its best angle the row came back. This is
|
|
561
|
+
* what carries the DISK read tier, whose in-memory search returns `rank: 0`
|
|
562
|
+
* for every row but still returns them in relevance order.
|
|
563
|
+
* - agreement: +0.25 per extra angle that surfaced the same spec (capped),
|
|
564
|
+
* because a spec found by both the broad query and a type-filtered angle is
|
|
565
|
+
* more likely to be the one the planner wants.
|
|
370
566
|
*/
|
|
371
|
-
function
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
// IDF with smoothing to handle rare terms
|
|
381
|
-
const idf = Math.log((totalDocs - df + 0.5) / (df + 0.5) + 1);
|
|
382
|
-
|
|
383
|
-
// Length-normalized term frequency
|
|
384
|
-
const lengthNorm = 1 - BM25_B + BM25_B * (spec.docLength / (avgDocLength || 1));
|
|
385
|
-
const tfNorm = (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * lengthNorm);
|
|
386
|
-
|
|
387
|
-
score += idf * tfNorm;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
return score;
|
|
567
|
+
function computeRetrievalScore(candidate) {
|
|
568
|
+
const rank = Number.isFinite(candidate?.rank) && candidate.rank > 0
|
|
569
|
+
? Math.min(candidate.rank, 1)
|
|
570
|
+
: 0;
|
|
571
|
+
const position = Number.isFinite(candidate?.bestPosition) ? candidate.bestPosition : 0;
|
|
572
|
+
const positional = Math.max(0, 1 - position / SEARCH_ANGLE_LIMIT);
|
|
573
|
+
const angles = Array.isArray(candidate?.angles) ? candidate.angles.length : 1;
|
|
574
|
+
const agreement = Math.min(Math.max(angles - 1, 0), 3) * 0.25;
|
|
575
|
+
return rank + positional + agreement;
|
|
391
576
|
}
|
|
392
577
|
|
|
393
578
|
/**
|
|
@@ -408,17 +593,6 @@ function computeTitleBoost(queryTerms, spec) {
|
|
|
408
593
|
return presentCount * 2;
|
|
409
594
|
}
|
|
410
595
|
|
|
411
|
-
/**
|
|
412
|
-
* Backlink importance - specs linked by many others are more authoritative.
|
|
413
|
-
* Uses log scale to prevent runaway scores.
|
|
414
|
-
*/
|
|
415
|
-
function computeBacklinkScore(spec) {
|
|
416
|
-
const count = spec.backlinkCount;
|
|
417
|
-
if (count === 0) return 0;
|
|
418
|
-
// log1p handles 0 gracefully: 1 link ~= 0.69, 5 ~= 1.79, 10 ~= 2.40, 30 ~= 3.43
|
|
419
|
-
return Math.min(Math.log1p(count), 3);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
596
|
/**
|
|
423
597
|
* Recency score - recently updated specs are more relevant.
|
|
424
598
|
* Decay function: full score for last 7 days, decays over 90 days.
|
|
@@ -490,136 +664,59 @@ function computeAreaScore(areas, spec) {
|
|
|
490
664
|
}
|
|
491
665
|
|
|
492
666
|
/**
|
|
493
|
-
*
|
|
494
|
-
*
|
|
667
|
+
* Rerank ONE candidate from the bounded retrieval window. The endpoint already
|
|
668
|
+
* did the lexical work (`ts_rank_cd` over a weighted tsvector); this adds the
|
|
669
|
+
* signals that do not live in the tsvector — the caller's explicit tags, the
|
|
670
|
+
* code area, the spec's status and how recently it moved.
|
|
671
|
+
*
|
|
672
|
+
* `candidate` is the shape {@link mergeAngles} mints: `{ name, status, tags,
|
|
673
|
+
* relPath, updatedAt, rank, bestPosition, angles }`. Tags / relPath / updatedAt
|
|
674
|
+
* are present only for rows the bounded metadata read covered; the untouched
|
|
675
|
+
* defaults (`[]`, `''`, `0`) make every scorer total, so a row outside
|
|
676
|
+
* {@link ENRICH_CAP} scores on recall + status rather than erroring.
|
|
495
677
|
*/
|
|
496
|
-
function
|
|
497
|
-
const now = Date.now();
|
|
678
|
+
function scoreCandidate(queryTokens, explicitTags, areas, candidate, now = Date.now()) {
|
|
498
679
|
const reasons = [];
|
|
499
680
|
|
|
500
|
-
|
|
501
|
-
const
|
|
502
|
-
const
|
|
503
|
-
const
|
|
504
|
-
const
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if (
|
|
681
|
+
const recall = computeRetrievalScore(candidate);
|
|
682
|
+
const titleBoost = computeTitleBoost(queryTokens, candidate);
|
|
683
|
+
const tagScore = computeTagScore(queryTokens, explicitTags, candidate);
|
|
684
|
+
const areaScore = computeAreaScore(areas, candidate);
|
|
685
|
+
const statusBoost = computeStatusBoost(candidate.status);
|
|
686
|
+
const recencyScore = computeRecencyScore(candidate, now);
|
|
687
|
+
|
|
688
|
+
// Build reasons for debugging/display — these become `why_matched` on the card.
|
|
689
|
+
if (candidate.rank > 0) reasons.push(`rank:${candidate.rank.toFixed(3)}`);
|
|
690
|
+
else if (recall > 0) reasons.push(`recall:${recall.toFixed(2)}`);
|
|
691
|
+
if (Array.isArray(candidate.angles) && candidate.angles.length > 1) {
|
|
692
|
+
reasons.push(`angles:${candidate.angles.length}`);
|
|
693
|
+
}
|
|
511
694
|
if (titleBoost >= 5) reasons.push(`title:${titleBoost}`);
|
|
512
695
|
if (tagScore > 0) reasons.push(`tag:${tagScore}`);
|
|
513
|
-
if (backlinkScore > 1) reasons.push(`links:${spec.backlinkCount}`);
|
|
514
696
|
if (recencyScore > 1) reasons.push('recent');
|
|
515
|
-
if (areaScore > 0) reasons.push(
|
|
697
|
+
if (areaScore > 0) reasons.push('area');
|
|
516
698
|
|
|
517
699
|
// Weighted combination
|
|
518
700
|
const score =
|
|
519
|
-
(
|
|
701
|
+
(recall * SCORE_WEIGHTS.rank) +
|
|
520
702
|
(titleBoost * SCORE_WEIGHTS.title) +
|
|
521
703
|
(tagScore * SCORE_WEIGHTS.tag) +
|
|
522
704
|
(areaScore * SCORE_WEIGHTS.area) +
|
|
523
705
|
(statusBoost * SCORE_WEIGHTS.status) +
|
|
524
|
-
(backlinkScore * SCORE_WEIGHTS.backlink) +
|
|
525
706
|
(recencyScore * SCORE_WEIGHTS.recency);
|
|
526
707
|
|
|
527
708
|
return {
|
|
528
709
|
score,
|
|
529
710
|
reasons,
|
|
530
|
-
|
|
711
|
+
recall,
|
|
531
712
|
titleBoost,
|
|
532
713
|
tagScore,
|
|
533
714
|
areaScore,
|
|
534
715
|
statusBoost,
|
|
535
|
-
backlinkScore,
|
|
536
716
|
recencyScore,
|
|
537
717
|
};
|
|
538
718
|
}
|
|
539
719
|
|
|
540
|
-
function parseFrontmatter(content) {
|
|
541
|
-
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
542
|
-
if (!match) return { frontmatter: {}, body: content };
|
|
543
|
-
const yamlBlock = match[1];
|
|
544
|
-
const body = content.slice(match[0].length).trim();
|
|
545
|
-
return { frontmatter: parseSimpleYaml(yamlBlock), body };
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
function parseSimpleYaml(yaml) {
|
|
549
|
-
const result = {};
|
|
550
|
-
const lines = yaml.split(/\r?\n/);
|
|
551
|
-
let currentArray = null;
|
|
552
|
-
// Block-scalar collection state (`key: |` / `key: >`). The vast majority of
|
|
553
|
-
// authored `summary:` values in this vault are literal block scalars, so the
|
|
554
|
-
// brief consumer must read them as strings — not drop them — to agree with the
|
|
555
|
-
// index consumer (which uses a real YAML parser). [[HS-TASK-ECOI]]
|
|
556
|
-
let blockKey = null;
|
|
557
|
-
let blockLines = null;
|
|
558
|
-
let blockFold = false;
|
|
559
|
-
|
|
560
|
-
const flushBlock = () => {
|
|
561
|
-
if (blockKey === null) return;
|
|
562
|
-
// Strip the common leading indentation of the block's content lines, then
|
|
563
|
-
// join: literal (`|`) keeps line breaks; folded (`>`) joins with spaces.
|
|
564
|
-
const contentful = blockLines.filter((l) => l.trim() !== '');
|
|
565
|
-
const minIndent = contentful.length
|
|
566
|
-
? Math.min(...contentful.map((l) => l.match(/^(\s*)/)[1].length))
|
|
567
|
-
: 0;
|
|
568
|
-
const stripped = blockLines.map((l) => l.slice(minIndent));
|
|
569
|
-
result[blockKey] = blockFold
|
|
570
|
-
? stripped.map((l) => l.trim()).filter(Boolean).join(' ')
|
|
571
|
-
: stripped.join('\n').replace(/\n+$/, '');
|
|
572
|
-
blockKey = null;
|
|
573
|
-
blockLines = null;
|
|
574
|
-
blockFold = false;
|
|
575
|
-
};
|
|
576
|
-
|
|
577
|
-
for (const line of lines) {
|
|
578
|
-
if (blockKey !== null) {
|
|
579
|
-
// Blank or indented lines belong to the active block scalar; a line at the
|
|
580
|
-
// key column (non-indented, non-blank) ends it and is parsed normally.
|
|
581
|
-
if (line.trim() === '' || /^\s/.test(line)) {
|
|
582
|
-
blockLines.push(line);
|
|
583
|
-
continue;
|
|
584
|
-
}
|
|
585
|
-
flushBlock();
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
589
|
-
|
|
590
|
-
const arrayItemMatch = line.match(/^(\s+)-\s*(.*)$/);
|
|
591
|
-
if (arrayItemMatch && currentArray) {
|
|
592
|
-
const value = arrayItemMatch[2].trim().replace(/^["']|["']$/g, '');
|
|
593
|
-
currentArray.push(value);
|
|
594
|
-
continue;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
const keyMatch = line.match(/^(\w[\w_-]*):\s*(.*)$/);
|
|
598
|
-
if (keyMatch) {
|
|
599
|
-
const key = keyMatch[1];
|
|
600
|
-
const value = keyMatch[2].trim();
|
|
601
|
-
currentArray = null;
|
|
602
|
-
|
|
603
|
-
if (/^[|>][-+\d]*$/.test(value)) {
|
|
604
|
-
// Block scalar: `|`, `>`, or a header variant (`|-`, `>+`, `|2`, …).
|
|
605
|
-
blockKey = key;
|
|
606
|
-
blockLines = [];
|
|
607
|
-
blockFold = value.startsWith('>');
|
|
608
|
-
result[key] = '';
|
|
609
|
-
} else if (value === '') {
|
|
610
|
-
result[key] = [];
|
|
611
|
-
currentArray = result[key];
|
|
612
|
-
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
613
|
-
result[key] = value.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, ''));
|
|
614
|
-
} else {
|
|
615
|
-
result[key] = value.replace(/^["']|["']$/g, '');
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
flushBlock();
|
|
620
|
-
return result;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
720
|
function tokenize(text) {
|
|
624
721
|
if (!text) return [];
|
|
625
722
|
return text
|
|
@@ -629,123 +726,12 @@ function tokenize(text) {
|
|
|
629
726
|
.filter((t) => t.length > 2 && !STOPWORDS.has(t));
|
|
630
727
|
}
|
|
631
728
|
|
|
632
|
-
function walkMdFiles(dir, out = []) {
|
|
633
|
-
if (!existsSync(dir)) return out;
|
|
634
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
635
|
-
if (SKIP_DIRS.has(entry.name)) continue;
|
|
636
|
-
const full = join(dir, entry.name);
|
|
637
|
-
if (entry.isDirectory()) walkMdFiles(full, out);
|
|
638
|
-
else if (entry.name.endsWith('.md')) out.push(full);
|
|
639
|
-
}
|
|
640
|
-
return out;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
function resolveSpecPath(vaultRoot, anchor) {
|
|
644
|
-
const specsRoot = join(vaultRoot, 'specs');
|
|
645
|
-
const candidates = [
|
|
646
|
-
join(specsRoot, `${anchor.basename}.md`),
|
|
647
|
-
anchor.subdir ? join(specsRoot, anchor.subdir, `${anchor.basename}.md`) : null,
|
|
648
|
-
].filter(Boolean);
|
|
649
|
-
|
|
650
|
-
for (const p of candidates) {
|
|
651
|
-
if (existsSync(p)) return relative(vaultRoot, p).replace(/\\/g, '/');
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
const all = walkMdFiles(specsRoot);
|
|
655
|
-
const found = all.find((f) => basename(f, '.md') === anchor.basename);
|
|
656
|
-
return found ? relative(vaultRoot, found).replace(/\\/g, '/') : null;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
729
|
function normalizeTags(tags) {
|
|
660
730
|
if (!tags) return [];
|
|
661
731
|
if (Array.isArray(tags)) return tags.map((t) => String(t).toLowerCase());
|
|
662
732
|
return [String(tags).toLowerCase()];
|
|
663
733
|
}
|
|
664
734
|
|
|
665
|
-
/**
|
|
666
|
-
* Score a spec using the TF-IDF index if available, otherwise fall back to simple scoring.
|
|
667
|
-
* This function bridges the old API (file-based) with the new index-based scoring.
|
|
668
|
-
*/
|
|
669
|
-
function scoreSpec(filePath, content, tokens, explicitTags, areas, index = null) {
|
|
670
|
-
const fileBase = basename(filePath, '.md');
|
|
671
|
-
const key = fileBase.toLowerCase();
|
|
672
|
-
|
|
673
|
-
// If we have an index, use enhanced TF-IDF scoring
|
|
674
|
-
if (index && index.specs.has(key)) {
|
|
675
|
-
const spec = index.specs.get(key);
|
|
676
|
-
const result = scoreSpecWithIndex(tokens, explicitTags, areas, spec, index.corpus);
|
|
677
|
-
|
|
678
|
-
return {
|
|
679
|
-
score: result.score,
|
|
680
|
-
reason: result.reasons.slice(0, 4).join(', ') || 'low relevance',
|
|
681
|
-
frontmatter: {
|
|
682
|
-
id: spec.id,
|
|
683
|
-
name: spec.name,
|
|
684
|
-
type: spec.type,
|
|
685
|
-
status: spec.status,
|
|
686
|
-
tags: spec.tags,
|
|
687
|
-
summary: spec.summary ?? null,
|
|
688
|
-
},
|
|
689
|
-
basename: spec.basename,
|
|
690
|
-
status: spec.status,
|
|
691
|
-
// Extra debug info for index-based scoring
|
|
692
|
-
_indexed: true,
|
|
693
|
-
_backlinkCount: spec.backlinkCount,
|
|
694
|
-
_bm25: result.bm25,
|
|
695
|
-
};
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// Fallback: simple scoring for unindexed files (should be rare)
|
|
699
|
-
const { frontmatter, body } = parseFrontmatter(content);
|
|
700
|
-
const name = String(frontmatter.name || frontmatter.title || fileBase).toLowerCase();
|
|
701
|
-
const specTags = normalizeTags(frontmatter.tags);
|
|
702
|
-
const status = String(frontmatter.status || 'open').toLowerCase();
|
|
703
|
-
|
|
704
|
-
let score = 0;
|
|
705
|
-
const reasons = [];
|
|
706
|
-
|
|
707
|
-
for (const tok of tokens) {
|
|
708
|
-
if (name.includes(tok)) {
|
|
709
|
-
score += 2;
|
|
710
|
-
reasons.push(`title:${tok}`);
|
|
711
|
-
}
|
|
712
|
-
const bodyHits = (body.toLowerCase().match(new RegExp(`\\b${tok}\\b`, 'g')) || []).length;
|
|
713
|
-
if (bodyHits > 0) {
|
|
714
|
-
score += Math.min(bodyHits, 3);
|
|
715
|
-
reasons.push(`body:${tok}`);
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
for (const tag of explicitTags) {
|
|
720
|
-
if (specTags.includes(tag.toLowerCase())) {
|
|
721
|
-
score += 3;
|
|
722
|
-
reasons.push(`tag:${tag}`);
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
for (const area of areas) {
|
|
727
|
-
const boost = AREA_BOOSTS[area];
|
|
728
|
-
if (!boost) continue;
|
|
729
|
-
const pathLower = filePath.replace(/\\/g, '/').toLowerCase();
|
|
730
|
-
if (boost.paths.some((p) => pathLower.includes(p)) || boost.tags.some((t) => specTags.includes(t))) {
|
|
731
|
-
score += 2;
|
|
732
|
-
reasons.push(`area:${area}`);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
if (status === 'open' || status === 'in_progress') {
|
|
737
|
-
score += 1;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
return {
|
|
741
|
-
score,
|
|
742
|
-
reason: reasons.slice(0, 4).join(', ') || 'low relevance',
|
|
743
|
-
frontmatter,
|
|
744
|
-
basename: fileBase,
|
|
745
|
-
status,
|
|
746
|
-
_indexed: false,
|
|
747
|
-
};
|
|
748
|
-
}
|
|
749
735
|
|
|
750
736
|
function detectPillars(text) {
|
|
751
737
|
const lower = text.toLowerCase();
|
|
@@ -767,32 +753,6 @@ function detectSurfaces(text, areas) {
|
|
|
767
753
|
return [...surfaces];
|
|
768
754
|
}
|
|
769
755
|
|
|
770
|
-
function findLargeMarkdownFiles(vaultRoot, limit = 8) {
|
|
771
|
-
const roots = [vaultRoot];
|
|
772
|
-
const large = [];
|
|
773
|
-
|
|
774
|
-
for (const root of roots) {
|
|
775
|
-
if (!existsSync(root)) continue;
|
|
776
|
-
const files = walkMdFiles(root);
|
|
777
|
-
for (const f of files) {
|
|
778
|
-
if (f.includes('node_modules')) continue;
|
|
779
|
-
try {
|
|
780
|
-
const content = readFileSync(f, 'utf8');
|
|
781
|
-
const lines = content.split(/\r?\n/).length;
|
|
782
|
-
if (lines >= 500) {
|
|
783
|
-
large.push({
|
|
784
|
-
path: relative(vaultRoot, f).replace(/\\/g, '/'),
|
|
785
|
-
lines,
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
} catch {
|
|
789
|
-
/* skip */
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
return large.sort((a, b) => b.lines - a.lines).slice(0, limit);
|
|
795
|
-
}
|
|
796
756
|
|
|
797
757
|
function buildCodebaseHints(topic, summary, areas, tokens) {
|
|
798
758
|
const queries = [...new Set([...tokens.slice(0, 6), topic].filter(Boolean))];
|
|
@@ -811,23 +771,6 @@ function buildCodebaseHints(topic, summary, areas, tokens) {
|
|
|
811
771
|
};
|
|
812
772
|
}
|
|
813
773
|
|
|
814
|
-
function guessManifestPhase(vaultRoot, tokens) {
|
|
815
|
-
const roadmapPath = join(vaultRoot, 'specs/roadmaps/HS-ROADMAP-product-master.md');
|
|
816
|
-
if (!existsSync(roadmapPath) || tokens.length === 0) return null;
|
|
817
|
-
|
|
818
|
-
try {
|
|
819
|
-
const content = readFileSync(roadmapPath, 'utf8').toLowerCase();
|
|
820
|
-
const phases = content.match(/phase[_\s-]*id[:\s]+["']?(P\d+)/gi) || [];
|
|
821
|
-
if (phases.length === 0) return 'See [[HS-ROADMAP-product-master]] — agent should read manifest';
|
|
822
|
-
for (const tok of tokens) {
|
|
823
|
-
if (content.includes(tok)) return 'Match found in product roadmap — read manifest for phase';
|
|
824
|
-
}
|
|
825
|
-
} catch {
|
|
826
|
-
return null;
|
|
827
|
-
}
|
|
828
|
-
return null;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
774
|
function buildPlanContextSection(topic, landscape, hooks, placement, suggestedRelated, conflictAnalysis) {
|
|
832
775
|
const lines = [
|
|
833
776
|
'## Grounding',
|
|
@@ -927,50 +870,6 @@ function buildOptimizedOutline(topic) {
|
|
|
927
870
|
].join('\n');
|
|
928
871
|
}
|
|
929
872
|
|
|
930
|
-
/**
|
|
931
|
-
* Extract a one-line summary from a spec's markdown: the first meaningful prose
|
|
932
|
-
* line after the frontmatter block, with heading/callout/link syntax stripped
|
|
933
|
-
* and capped at `maxLen` chars. Used to build body-free candidate cards — the
|
|
934
|
-
* LLM triages on these, then deep-reads only the shortlist it selects
|
|
935
|
-
* ([[retrieval-intake-architecture]] §How the LLM-as-reranker works).
|
|
936
|
-
* @param {string} content
|
|
937
|
-
* @param {number} [maxLen]
|
|
938
|
-
* @returns {string}
|
|
939
|
-
*/
|
|
940
|
-
function extractSummaryLine(content, maxLen = 140) {
|
|
941
|
-
let body = String(content || '');
|
|
942
|
-
// Drop a leading YAML frontmatter block.
|
|
943
|
-
if (body.startsWith('---')) {
|
|
944
|
-
const close = body.indexOf('\n---', 3);
|
|
945
|
-
if (close !== -1) {
|
|
946
|
-
const nl = body.indexOf('\n', close + 1);
|
|
947
|
-
body = nl !== -1 ? body.slice(nl + 1) : '';
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
const cap = (s) => (s.length > maxLen ? `${s.slice(0, maxLen - 1).trimEnd()}…` : s);
|
|
951
|
-
let firstHeading = ''; // fallback if the doc is heading-only (title echoes anyway)
|
|
952
|
-
for (let raw of body.split('\n')) {
|
|
953
|
-
let line = raw.trim();
|
|
954
|
-
if (!line) continue;
|
|
955
|
-
if (line.startsWith('|') || line.startsWith('<!--') || line.startsWith('---') || line.startsWith('```')) continue;
|
|
956
|
-
const isHeading = /^#{1,6}\s/.test(line);
|
|
957
|
-
line = line.replace(/^#{1,6}\s+/, ''); // de-head
|
|
958
|
-
line = line.replace(/^>+\s?/, '').replace(/^\[!\w+\][-+]?\s*/, ''); // de-callout
|
|
959
|
-
line = line.replace(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g, '$1'); // [[wikilink]] → text
|
|
960
|
-
line = line.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // [md](link) → text
|
|
961
|
-
line = line.replace(/[*_`]/g, '').trim(); // strip emphasis
|
|
962
|
-
if (!line) continue;
|
|
963
|
-
// Prefer the first prose/callout line; the leading H1 usually just echoes
|
|
964
|
-
// the title, so skip headings unless that is all the doc has.
|
|
965
|
-
if (isHeading) {
|
|
966
|
-
if (!firstHeading) firstHeading = line;
|
|
967
|
-
continue;
|
|
968
|
-
}
|
|
969
|
-
return cap(line);
|
|
970
|
-
}
|
|
971
|
-
return cap(firstHeading);
|
|
972
|
-
}
|
|
973
|
-
|
|
974
873
|
/**
|
|
975
874
|
* @param {string} vaultRoot
|
|
976
875
|
* @param {object} args
|
|
@@ -999,7 +898,7 @@ function buildGroundReport(ctx) {
|
|
|
999
898
|
|
|
1000
899
|
L.push('', '## 1. Scope');
|
|
1001
900
|
L.push(`- Workspace: \`${basename(vaultRoot)}\` · surfaces: ${surfaces.join(', ') || '—'} · pillars: ${pillarIds.join(', ') || '—'}`);
|
|
1002
|
-
L.push(`-
|
|
901
|
+
L.push(`- Retrieval: bounded lexical search via \`GET /api/search\` (top-k per angle, reranked) · grounded ${groundedAt}`);
|
|
1003
902
|
|
|
1004
903
|
L.push('', '## 2. Relevant specs');
|
|
1005
904
|
L.push('_Lexical wide-recall — YOU are the reranker. Open only the shortlist you judge relevant; do not read them all._');
|
|
@@ -1057,7 +956,7 @@ function buildGroundReport(ctx) {
|
|
|
1057
956
|
return { report_markdown: report, token_estimate: estTokens(report), truncated };
|
|
1058
957
|
}
|
|
1059
958
|
|
|
1060
|
-
export function executeGroundPlan(vaultRoot, args = {}) {
|
|
959
|
+
export async function executeGroundPlan(vaultRoot, args = {}, { fetchImpl = defaultConsoleFetch } = {}) {
|
|
1061
960
|
const topic = String(args.topic || '').trim();
|
|
1062
961
|
if (!topic) {
|
|
1063
962
|
return { error: 'MISSING_TOPIC', message: 'topic is required (short title of feature/request)' };
|
|
@@ -1066,10 +965,7 @@ export function executeGroundPlan(vaultRoot, args = {}) {
|
|
|
1066
965
|
const summary = String(args.summary || '').trim();
|
|
1067
966
|
const explicitTags = normalizeTags(args.tags || []);
|
|
1068
967
|
const areas = Array.isArray(args.areas)
|
|
1069
|
-
? args.areas.map((a) =>
|
|
1070
|
-
const v = String(a).toLowerCase();
|
|
1071
|
-
return v === 'cockpit' ? 'console' : v; // legacy-cockpit area alias
|
|
1072
|
-
})
|
|
968
|
+
? args.areas.map((a) => String(a).toLowerCase())
|
|
1073
969
|
: [];
|
|
1074
970
|
// Wide-recall window: the user's own LLM is the reranker, so HeySpex retrieves
|
|
1075
971
|
// WIDE (Recall@N is the metric, not nDCG@10 ordering). Default 30, max 50 — per
|
|
@@ -1085,17 +981,65 @@ export function executeGroundPlan(vaultRoot, args = {}) {
|
|
|
1085
981
|
const combinedText = `${topic} ${summary}`;
|
|
1086
982
|
const tokens = [...new Set([...tokenize(topic), ...tokenize(summary)])];
|
|
1087
983
|
|
|
1088
|
-
//
|
|
1089
|
-
|
|
984
|
+
// ── Retrieval: a fixed set of bounded angles against /api/search ───────────
|
|
985
|
+
// The query text the endpoint parses (`websearch_to_tsquery`) is the topic and
|
|
986
|
+
// summary as typed; the type-filtered angles guarantee the buckets a plan needs
|
|
987
|
+
// (governing protocols, memory decisions/lessons) are represented even when the
|
|
988
|
+
// broad angle is crowded by epics and tasks. Every call is `?limit=` bounded.
|
|
989
|
+
const searchQuery = combinedText.trim() || topic;
|
|
990
|
+
const tagQuery = explicitTags.join(' ').trim();
|
|
991
|
+
const angleSpecs = [
|
|
992
|
+
{ label: 'broad', q: searchQuery, want: limit },
|
|
993
|
+
{ label: 'work', q: searchQuery, want: 10, type: WORK_TYPE_FILTER },
|
|
994
|
+
{ label: 'protocols', q: searchQuery, want: 8, type: PROTOCOL_TYPE_FILTER },
|
|
995
|
+
{ label: 'memories', q: searchQuery, want: 8, type: MEMORY_TYPE_FILTER },
|
|
996
|
+
...(tagQuery && tagQuery !== searchQuery
|
|
997
|
+
? [{ label: 'tags', q: tagQuery, want: 10 }]
|
|
998
|
+
: []),
|
|
999
|
+
];
|
|
1000
|
+
|
|
1001
|
+
let angles;
|
|
1002
|
+
try {
|
|
1003
|
+
angles = await Promise.all(angleSpecs.map((spec) => fetchSearchAngle(fetchImpl, spec)));
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
// Retrieval is remote and there is NO disk fallback ([[no-fallbacks-by-default]]):
|
|
1006
|
+
// a grounding report built from a stale local walk is exactly the failure this
|
|
1007
|
+
// task removed. Say the console is unreachable and stop.
|
|
1008
|
+
return {
|
|
1009
|
+
error: err instanceof ConsoleRetrievalError ? err.code : 'CONSOLE_UNREACHABLE',
|
|
1010
|
+
message:
|
|
1011
|
+
`Pre-plan grounding retrieves through the console search endpoint (GET /api/search) and it did not answer (${err?.message ?? err}). `
|
|
1012
|
+
+ 'Call heyspex_dev_server_status before concluding the console is down, then re-run heyspex_ground_plan. '
|
|
1013
|
+
+ 'Do NOT hand-walk the vault to substitute for grounding.',
|
|
1014
|
+
topic,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1090
1017
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1018
|
+
// ── Rerank: two passes over the SAME bounded window ────────────────────────
|
|
1019
|
+
// Pass 1 ranks on what every row carries (rank/position/status/title) so the
|
|
1020
|
+
// enrichment cap lands on the rows most likely to matter; pass 2 re-scores with
|
|
1021
|
+
// the tags and `updated` stamps the bounded metadata read added.
|
|
1022
|
+
const candidates = mergeAngles(angles);
|
|
1023
|
+
const rank = (list) => {
|
|
1024
|
+
const now = Date.now();
|
|
1025
|
+
for (const c of list) c._scored = scoreCandidate(tokens, explicitTags, areas, c, now);
|
|
1026
|
+
list.sort((a, b) => b._scored.score - a._scored.score || a.id.localeCompare(b.id));
|
|
1027
|
+
return list;
|
|
1028
|
+
};
|
|
1029
|
+
rank(candidates);
|
|
1030
|
+
await enrichCandidates(fetchImpl, candidates, ENRICH_CAP);
|
|
1031
|
+
rank(candidates);
|
|
1032
|
+
|
|
1033
|
+
// Anchors: the small static constitution list. One bounded resolve per anchor
|
|
1034
|
+
// (5 calls, never a folder scan) so `found` still tells the truth per vault.
|
|
1035
|
+
const anchors = await mapWithConcurrency(ANCHOR_SPECS, async (a) => {
|
|
1036
|
+
const resolved = await resolveSpecRef(fetchImpl, a.basename);
|
|
1093
1037
|
return {
|
|
1094
|
-
basename: a.basename,
|
|
1095
|
-
path:
|
|
1038
|
+
basename: resolved?.basename || a.basename,
|
|
1039
|
+
path: resolved?.relPath || `specs/${a.subdir ? `${a.subdir}/` : ''}${a.basename}.md`,
|
|
1096
1040
|
why: a.why,
|
|
1097
1041
|
tier: 'anchor',
|
|
1098
|
-
found: Boolean(
|
|
1042
|
+
found: Boolean(resolved),
|
|
1099
1043
|
};
|
|
1100
1044
|
});
|
|
1101
1045
|
|
|
@@ -1113,54 +1057,29 @@ export function executeGroundPlan(vaultRoot, args = {}) {
|
|
|
1113
1057
|
future_considerations: [],
|
|
1114
1058
|
};
|
|
1115
1059
|
|
|
1116
|
-
const scored =
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
name: frontmatter.name || fileBase,
|
|
1138
|
-
path: relative(vaultRoot, filePath).replace(/\\/g, '/'),
|
|
1139
|
-
status,
|
|
1140
|
-
relevance: score,
|
|
1141
|
-
reason,
|
|
1142
|
-
type: frontmatter.type || bucket,
|
|
1143
|
-
// Authored frontmatter.summary, carried so the candidate card can
|
|
1144
|
-
// prefer it over the body-derived extractSummaryLine() fallback
|
|
1145
|
-
// (same precedence as the index/brief consumers: authored wins).
|
|
1146
|
-
summary: typeof frontmatter.summary === 'string' ? frontmatter.summary : null,
|
|
1147
|
-
};
|
|
1148
|
-
|
|
1149
|
-
scored.push({ bucket, entry });
|
|
1150
|
-
|
|
1151
|
-
if (discovered[bucket]) {
|
|
1152
|
-
discovered[bucket].push(entry);
|
|
1153
|
-
}
|
|
1154
|
-
} catch {
|
|
1155
|
-
/* skip unreadable */
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
scored.sort((a, b) => b.entry.relevance - a.entry.relevance);
|
|
1060
|
+
const scored = candidates.map((c) => {
|
|
1061
|
+
const bucket = TYPE_BUCKETS[c.type] || null;
|
|
1062
|
+
const entry = {
|
|
1063
|
+
basename: c.basename,
|
|
1064
|
+
id: c.id,
|
|
1065
|
+
name: c.name,
|
|
1066
|
+
// Known only for rows the bounded metadata read covered; `null` elsewhere —
|
|
1067
|
+
// a candidate is addressed by id, never by a path this module guessed.
|
|
1068
|
+
path: c.relPath || null,
|
|
1069
|
+
status: c.status,
|
|
1070
|
+
relevance: c._scored.score,
|
|
1071
|
+
reason: c._scored.reasons.slice(0, 4).join(', ') || 'low relevance',
|
|
1072
|
+
type: c.type,
|
|
1073
|
+
tags: c.tags,
|
|
1074
|
+
// Authored `summary:` when the metadata read returned one; the endpoint's
|
|
1075
|
+
// `ts_headline` snippet otherwise (same job — a body-free triage line).
|
|
1076
|
+
summary: c.summary || c.snippet || null,
|
|
1077
|
+
};
|
|
1078
|
+
if (bucket && discovered[bucket]) discovered[bucket].push(entry);
|
|
1079
|
+
return { bucket, entry };
|
|
1080
|
+
});
|
|
1161
1081
|
|
|
1162
1082
|
for (const key of Object.keys(discovered)) {
|
|
1163
|
-
discovered[key].sort((a, b) => b.relevance - a.relevance);
|
|
1164
1083
|
discovered[key] = discovered[key].slice(0, limit);
|
|
1165
1084
|
}
|
|
1166
1085
|
|
|
@@ -1183,18 +1102,12 @@ export function executeGroundPlan(vaultRoot, args = {}) {
|
|
|
1183
1102
|
// one-line summary, and the why-matched lexical signals. Bodies are NOT
|
|
1184
1103
|
// included — the agent deep-reads only the shortlist it selects (two-stage).
|
|
1185
1104
|
const candidateCards = topEntries.map((e) => {
|
|
1186
|
-
// Precedence: authored frontmatter.summary
|
|
1187
|
-
//
|
|
1188
|
-
// consumers so the same spec reads identically across surfaces.
|
|
1189
|
-
// fallback is
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
try {
|
|
1193
|
-
summary = extractSummaryLine(readFileSync(join(vaultRoot, e.path), 'utf8'));
|
|
1194
|
-
} catch {
|
|
1195
|
-
/* unreadable — leave summary blank */
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1105
|
+
// Precedence: authored frontmatter.summary (from the bounded metadata read)
|
|
1106
|
+
// wins over the endpoint's `ts_headline` snippet, matching the index (U2) and
|
|
1107
|
+
// /hs-next brief consumers so the same spec reads identically across surfaces.
|
|
1108
|
+
// The old body-derived fallback is gone with the file walk — the snippet is
|
|
1109
|
+
// the endpoint's own answer to the same question, computed over the page only.
|
|
1110
|
+
const summary = e.summary && String(e.summary).trim() ? String(e.summary).trim() : '';
|
|
1198
1111
|
return {
|
|
1199
1112
|
id: e.id,
|
|
1200
1113
|
basename: e.basename,
|
|
@@ -1231,8 +1144,13 @@ export function executeGroundPlan(vaultRoot, args = {}) {
|
|
|
1231
1144
|
const possibleDuplicates = openWork.filter((e) => e.relevance >= 4).slice(0, 5);
|
|
1232
1145
|
|
|
1233
1146
|
const protocols = discovered.protocols.slice(0, 8);
|
|
1147
|
+
// Decisions and lessons are the memories that CONSTRAIN a plan (references and
|
|
1148
|
+
// ui-patterns inform it). Selected on the row's `type:` — the folder-path test
|
|
1149
|
+
// this replaces needed a walked path, and `path` is now null for any row the
|
|
1150
|
+
// bounded metadata read did not cover.
|
|
1234
1151
|
const decisionsToHeed = discovered.memories
|
|
1235
|
-
.filter((m) => m.
|
|
1152
|
+
.filter((m) => m.type === 'memory-decision' || m.type === 'memory-lesson'
|
|
1153
|
+
|| (m.path || '').includes('/decisions/') || (m.path || '').includes('/lessons/'))
|
|
1236
1154
|
.slice(0, 8);
|
|
1237
1155
|
|
|
1238
1156
|
const horizonItems = discovered.future_considerations.slice(0, 5);
|
|
@@ -1262,7 +1180,11 @@ export function executeGroundPlan(vaultRoot, args = {}) {
|
|
|
1262
1180
|
const architecturePlacement = {
|
|
1263
1181
|
surfaces,
|
|
1264
1182
|
dependency_specs: dependencySpecs,
|
|
1265
|
-
|
|
1183
|
+
// Retired with the file walk ([[HS-YIR1-8KK5-TASK-planning-grounding-retrieves-through]]):
|
|
1184
|
+
// the hint was a substring scan of specs/roadmaps/HS-ROADMAP-product-master.md
|
|
1185
|
+
// read from disk on every call, and it only ever said "go read the manifest".
|
|
1186
|
+
// The roadmap is one bounded read away for an agent that wants it.
|
|
1187
|
+
manifest_phase_hint: null,
|
|
1266
1188
|
};
|
|
1267
1189
|
|
|
1268
1190
|
const northStarPillarHooks = {
|
|
@@ -1343,12 +1265,13 @@ export function executeGroundPlan(vaultRoot, args = {}) {
|
|
|
1343
1265
|
}
|
|
1344
1266
|
enhancementSuggestions.push('Add child tasks for each implementation phase after stress-test.');
|
|
1345
1267
|
|
|
1268
|
+
// `large_markdown_files` is GONE, not emptied-in-place: it walked every .md in
|
|
1269
|
+
// the workspace to measure line counts, and the field itself was the
|
|
1270
|
+
// read-everything anti-pattern the bounded report exists to retire
|
|
1271
|
+
// ([[hs-ground-as-built-mcp-failure-modes]]).
|
|
1346
1272
|
const codebaseHints = includeCodebaseHints
|
|
1347
|
-
?
|
|
1348
|
-
|
|
1349
|
-
large_markdown_files: findLargeMarkdownFiles(vaultRoot),
|
|
1350
|
-
}
|
|
1351
|
-
: { grep_queries: [], path_globs: [], large_markdown_files: [] };
|
|
1273
|
+
? buildCodebaseHints(topic, summary, areas, tokens)
|
|
1274
|
+
: { grep_queries: [], path_globs: [] };
|
|
1352
1275
|
|
|
1353
1276
|
const landscapeSummary = {
|
|
1354
1277
|
open_work: openWork.map((e) => ({ basename: e.basename, id: e.id, reason: e.reason })),
|
|
@@ -1497,7 +1420,6 @@ export function executeGroundPlan(vaultRoot, args = {}) {
|
|
|
1497
1420
|
? {
|
|
1498
1421
|
grep_queries: codebaseHints.grep_queries?.slice(0, 5) || [],
|
|
1499
1422
|
path_globs: codebaseHints.path_globs?.slice(0, 4) || [],
|
|
1500
|
-
large_markdown_files: (codebaseHints.large_markdown_files || []).slice(0, 5),
|
|
1501
1423
|
}
|
|
1502
1424
|
: null,
|
|
1503
1425
|
|
|
@@ -1562,18 +1484,30 @@ export function executeGroundPlan(vaultRoot, args = {}) {
|
|
|
1562
1484
|
|
|
1563
1485
|
/**
|
|
1564
1486
|
* `heyspex_resolve_focus_intent` lexical resolver — [[HS-TASK-JZEJ]] /
|
|
1565
|
-
* [[HS-EPIC-OOPW]] Phase B. Given a free-text focus utterance,
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1568
|
-
*
|
|
1487
|
+
* [[HS-EPIC-OOPW]] Phase B. Given a free-text focus utterance, retrieve a WIDE
|
|
1488
|
+
* candidate window through the console search endpoint and hand it to the
|
|
1489
|
+
* calling agent's LLM to rerank. HeySpex hosts no reranker — the metric is
|
|
1490
|
+
* Recall@N, not nDCG@10 ordering
|
|
1569
1491
|
* ([[llm-as-reranker-lexical-recall-companion-architecture]]). No embeddings /
|
|
1570
1492
|
* vector store (Gate B = NO,
|
|
1571
1493
|
* [[retrieval-substrate-lexical-not-embeddings-gate-b-no]]); consumes the same
|
|
1572
|
-
*
|
|
1494
|
+
* bounded retrieval substrate as hs-ground
|
|
1495
|
+
* ([[HS-EPIC-G7R2-hs-ground-three-tier-grounding-report]]).
|
|
1496
|
+
*
|
|
1497
|
+
* Retrieval is TWO bounded angle families, never a vault walk
|
|
1498
|
+
* ([[HS-YIR1-8KK5-TASK-planning-grounding-retrieves-through]]):
|
|
1499
|
+
* 1. `GET /api/search?q=<utterance>` — the lexical window, `?limit=`-bounded
|
|
1500
|
+
* and continued with the endpoint's cursor up to `top_k` rows.
|
|
1501
|
+
* 2. For each tag of the closed vocabulary the utterance names EXACTLY
|
|
1502
|
+
* (`GET /api/scheduler/known-tags`, one page), one bounded
|
|
1503
|
+
* `GET /api/specs?tag=…` page. Those rows are the strict-tag matches, and
|
|
1504
|
+
* they arrive carrying their own tags — so the strict/lexical split is now
|
|
1505
|
+
* an indexed filter instead of a scan over every spec's frontmatter.
|
|
1573
1506
|
*
|
|
1574
1507
|
* The utterance need not contain a spec's exact tag word: utterance tokens match
|
|
1575
|
-
*
|
|
1576
|
-
* tag-relevant specs still surface — the core Phase-B
|
|
1508
|
+
* titles/bodies through the tsvector and tags through computeTagScore's
|
|
1509
|
+
* substring pass, so tag-relevant specs still surface — the core Phase-B
|
|
1510
|
+
* behavior.
|
|
1577
1511
|
*
|
|
1578
1512
|
* Returns an empty window (NOT an error) for an empty/stopword-only utterance,
|
|
1579
1513
|
* an empty vault, or no matches — the caller's documented fallback is
|
|
@@ -1585,11 +1519,12 @@ export function executeGroundPlan(vaultRoot, args = {}) {
|
|
|
1585
1519
|
* counts (plus a ready-to-read `beat`) the confirmation copy reads back
|
|
1586
1520
|
* ([[HS-TASK-5DB2]] / focus-intent-routing §5–6).
|
|
1587
1521
|
*
|
|
1588
|
-
* @param {string} vaultRoot -
|
|
1522
|
+
* @param {string} vaultRoot - Retained for signature parity; nothing here reads it.
|
|
1589
1523
|
* @param {{ utterance?: string, top_k?: number }} args
|
|
1590
|
-
* @
|
|
1524
|
+
* @param {{ fetchImpl?: Function }} [deps] - Console transport (stubbed in tests).
|
|
1525
|
+
* @returns {Promise<{ utterance: string, candidates: Array<{ spec: string, tags: string[], match_kind: 'strict-tag' | 'lexical', why: string }>, total: number, justification: { strict_tag_matches: number, lexically_adjacent: number, beat: string } }>}
|
|
1591
1526
|
*/
|
|
1592
|
-
export function resolveFocusIntentLexical(vaultRoot, args = {}) {
|
|
1527
|
+
export async function resolveFocusIntentLexical(vaultRoot, args = {}, { fetchImpl = defaultConsoleFetch } = {}) {
|
|
1593
1528
|
const utterance = String(args.utterance || '').trim();
|
|
1594
1529
|
// Wide-recall window: default 30, capped at 50 — same Recall@N bounds as
|
|
1595
1530
|
// executeGroundPlan's limit. top_k narrows the window when the caller asks.
|
|
@@ -1600,64 +1535,97 @@ export function resolveFocusIntentLexical(vaultRoot, args = {}) {
|
|
|
1600
1535
|
return { utterance, candidates: [], total: 0, justification: buildFocusJustification(0, 0) };
|
|
1601
1536
|
}
|
|
1602
1537
|
|
|
1603
|
-
// Graceful degradation ([[HS-TASK-09I3]] / focus-intent-routing §6). The
|
|
1604
|
-
// lexical recall primitive can be unreachable in a constrained environment —
|
|
1605
|
-
// no vault index, an IO error while walking specs/, a corpus that fails to
|
|
1606
|
-
// load. When the index build or scoring throws, the resolver must NOT raise:
|
|
1607
|
-
// it degrades to an empty window flagged `degraded`, routing the caller to the
|
|
1608
|
-
// Phase-A exact-tag NLU floor (heyspex_list_known_tags + closed-vocab mapping)
|
|
1609
|
-
// instead of erroring out. An empty *result* (no matches, empty vault) is a
|
|
1610
|
-
// success and is handled by the normal path below — only a thrown primitive
|
|
1611
|
-
// trips degradation.
|
|
1612
|
-
let index;
|
|
1613
|
-
try {
|
|
1614
|
-
index = getOrBuildIndex(vaultRoot);
|
|
1615
|
-
} catch (err) {
|
|
1616
|
-
return buildDegradedFocusResult(utterance, err);
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
1538
|
// A candidate is a STRICT-TAG match when the user named one of its tags
|
|
1620
1539
|
// exactly — every token of some tag is present in the utterance (so multi-word
|
|
1621
1540
|
// tags like `focus-areas` match "focus areas"). Everything else that still
|
|
1622
1541
|
// surfaced matched on title/body/substring only — "lexically adjacent". The
|
|
1623
1542
|
// split feeds the confirmation copy ([[HS-TASK-5DB2]]).
|
|
1624
1543
|
const utteranceSet = new Set(tokens);
|
|
1625
|
-
const
|
|
1626
|
-
(
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1544
|
+
const namesTagExactly = (tag) => {
|
|
1545
|
+
const t = String(tag || '').toLowerCase();
|
|
1546
|
+
if (!t) return false;
|
|
1547
|
+
if (utteranceSet.has(t)) return true;
|
|
1548
|
+
const parts = tokenize(t);
|
|
1549
|
+
return parts.length > 0 && parts.every((p) => utteranceSet.has(p));
|
|
1550
|
+
};
|
|
1551
|
+
const isStrictTagHit = (specTags) => (specTags || []).some(namesTagExactly);
|
|
1632
1552
|
|
|
1553
|
+
// Graceful degradation ([[HS-TASK-09I3]] / focus-intent-routing §6). The
|
|
1554
|
+
// recall primitive is now REMOTE, so the unreachable case is a dead console,
|
|
1555
|
+
// a 401, or a 5xx rather than an IO error walking specs/. The resolver must
|
|
1556
|
+
// NOT raise: it degrades to an empty window flagged `degraded`, routing the
|
|
1557
|
+
// caller to the Phase-A exact-tag NLU floor (heyspex_list_known_tags +
|
|
1558
|
+
// closed-vocab mapping). An empty *result* (no matches, empty vault) is a
|
|
1559
|
+
// success and is handled by the normal path below.
|
|
1633
1560
|
let candidates;
|
|
1634
1561
|
try {
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
// Gate on the LEXICAL signal (BM25 + title + tag), not the full weighted
|
|
1642
|
-
// score: status/backlink/recency boosts are unconditional, so the full
|
|
1643
|
-
// score is > 0 for every open spec even with no term match. A candidate
|
|
1644
|
-
// must actually match the utterance; the boosts only tie-break among
|
|
1645
|
-
// matches.
|
|
1646
|
-
const lexical = result.bm25 + result.titleBoost + result.tagScore;
|
|
1647
|
-
if (lexical <= 0) continue;
|
|
1648
|
-
scored.push({ spec, result });
|
|
1649
|
-
}
|
|
1562
|
+
// Angle 1 — the lexical window, bounded per call and cursor-paged to top_k.
|
|
1563
|
+
const lexicalAngle = await fetchSearchAngle(fetchImpl, {
|
|
1564
|
+
label: 'utterance',
|
|
1565
|
+
q: utterance,
|
|
1566
|
+
want: topK,
|
|
1567
|
+
});
|
|
1650
1568
|
|
|
1651
|
-
//
|
|
1652
|
-
|
|
1653
|
-
|
|
1569
|
+
// Angle 2 — strict-tag lookup over the closed vocabulary. Best-effort: a
|
|
1570
|
+
// known-tags failure costs the strict/lexical distinction, not the window.
|
|
1571
|
+
const knownTags = await fetchKnownTags(fetchImpl);
|
|
1572
|
+
const namedTags = knownTags.filter(namesTagExactly).slice(0, 3);
|
|
1573
|
+
const tagPages = await Promise.all(
|
|
1574
|
+
namedTags.map((tag) => fetchTagPage(fetchImpl, tag, Math.min(topK, SEARCH_ANGLE_LIMIT))),
|
|
1654
1575
|
);
|
|
1655
1576
|
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1577
|
+
const byId = new Map(mergeAngles([lexicalAngle]).map((c) => [c.id, c]));
|
|
1578
|
+
// Fold the tag rows in: they carry tags/basename/updated already, so they
|
|
1579
|
+
// both enrich a row the search angle found and add rows it did not.
|
|
1580
|
+
tagPages.forEach((rows, tagIndex) => {
|
|
1581
|
+
rows.forEach((row, position) => {
|
|
1582
|
+
const id = String((row && row.id) || '').trim();
|
|
1583
|
+
if (!id) return;
|
|
1584
|
+
let candidate = byId.get(id);
|
|
1585
|
+
if (!candidate) {
|
|
1586
|
+
candidate = {
|
|
1587
|
+
id,
|
|
1588
|
+
basename: id,
|
|
1589
|
+
name: String(row.name || id),
|
|
1590
|
+
type: String(row.type || 'unknown'),
|
|
1591
|
+
status: String(row.status || 'open').toLowerCase(),
|
|
1592
|
+
rank: 0,
|
|
1593
|
+
snippet: '',
|
|
1594
|
+
bestPosition: position,
|
|
1595
|
+
angles: [],
|
|
1596
|
+
tags: [],
|
|
1597
|
+
relPath: '',
|
|
1598
|
+
updatedAt: 0,
|
|
1599
|
+
summary: null,
|
|
1600
|
+
enriched: false,
|
|
1601
|
+
};
|
|
1602
|
+
byId.set(id, candidate);
|
|
1603
|
+
}
|
|
1604
|
+
const label = `tag:${namedTags[tagIndex]}`;
|
|
1605
|
+
if (!candidate.angles.includes(label)) candidate.angles.push(label);
|
|
1606
|
+
candidate.bestPosition = Math.min(candidate.bestPosition, position);
|
|
1607
|
+
applySpecMetadata(candidate, row);
|
|
1608
|
+
});
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
const window = [...byId.values()];
|
|
1612
|
+
const now = Date.now();
|
|
1613
|
+
const rescore = () => {
|
|
1614
|
+
for (const c of window) c._scored = scoreCandidate(tokens, [], [], c, now);
|
|
1615
|
+
window.sort((a, b) => b._scored.score - a._scored.score || a.basename.localeCompare(b.basename));
|
|
1616
|
+
};
|
|
1617
|
+
// Enrich the top of the window so `tags` are real for the rows the caller
|
|
1618
|
+
// will actually read back, bounded by ENRICH_CAP (constant, never N).
|
|
1619
|
+
rescore();
|
|
1620
|
+
await enrichCandidates(fetchImpl, window.filter((c) => !c.enriched), ENRICH_CAP);
|
|
1621
|
+
// Deterministic order: score desc, basename asc as a stable tiebreak.
|
|
1622
|
+
rescore();
|
|
1623
|
+
|
|
1624
|
+
candidates = window.slice(0, topK).map((c) => ({
|
|
1625
|
+
spec: c.id,
|
|
1626
|
+
tags: c.tags,
|
|
1627
|
+
match_kind: isStrictTagHit(c.tags) ? 'strict-tag' : 'lexical',
|
|
1628
|
+
why: c._scored.reasons.slice(0, 4).join(', ') || 'low relevance',
|
|
1661
1629
|
}));
|
|
1662
1630
|
} catch (err) {
|
|
1663
1631
|
return buildDegradedFocusResult(utterance, err);
|
|
@@ -1877,22 +1845,23 @@ function detectTagDrift(tags, options = {}) {
|
|
|
1877
1845
|
|
|
1878
1846
|
// Export scoring functions for unit testing (HS-TASK-9HIW)
|
|
1879
1847
|
export {
|
|
1880
|
-
|
|
1848
|
+
computeRetrievalScore,
|
|
1881
1849
|
computeTitleBoost,
|
|
1882
|
-
computeBacklinkScore,
|
|
1883
1850
|
computeRecencyScore,
|
|
1884
1851
|
computeStatusBoost,
|
|
1885
1852
|
computeTagScore,
|
|
1886
1853
|
computeAreaScore,
|
|
1887
|
-
|
|
1854
|
+
scoreCandidate,
|
|
1888
1855
|
tokenize,
|
|
1889
1856
|
detectTagDrift,
|
|
1890
1857
|
extractWikilinks,
|
|
1891
1858
|
normalizeWikilinkTarget,
|
|
1892
1859
|
parseIsoDate,
|
|
1860
|
+
ConsoleRetrievalError,
|
|
1893
1861
|
SCORE_WEIGHTS,
|
|
1894
|
-
|
|
1895
|
-
|
|
1862
|
+
SEARCH_ANGLE_LIMIT,
|
|
1863
|
+
MAX_PAGES_PER_ANGLE,
|
|
1864
|
+
ENRICH_CAP,
|
|
1896
1865
|
STOPWORDS,
|
|
1897
1866
|
AREA_BOOSTS,
|
|
1898
1867
|
};
|
|
@@ -1913,7 +1882,7 @@ export function buildGroundWorkflowMarkdown(options = {}) {
|
|
|
1913
1882
|
'',
|
|
1914
1883
|
'1. Call MCP tool **`heyspex_ground_plan`** with `topic`, `summary`, `tags`, `areas`.',
|
|
1915
1884
|
'2. Call **`heyspex_recall_memories`** (`limit: 10`, tags from tool `recall_memories_hint`).',
|
|
1916
|
-
'3. Read **all anchors** +
|
|
1885
|
+
'3. Read **all anchors** + only the shortlist you rerank out of `candidate_cards` — never every discovered spec.',
|
|
1917
1886
|
'4. Explore codebase via `codebase_hints` (`rg`, Glob) — not README-only ([[audit-read-plan-files-not-just-readmes]]).',
|
|
1918
1887
|
'5. **Deliverable:** `## Landscape scan` — table of specs/code with relevance; duplicates and surprises.',
|
|
1919
1888
|
'',
|