heyspex 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -0
- package/README.md +21 -0
- package/bin/hs.mjs +1089 -104
- package/bin/spexcode.mjs +262 -0
- package/dev-factory/engine/worktree.mjs +740 -0
- package/dev-factory/lib/code-repo-root.mjs +67 -0
- package/dev-factory/lib/context-budget.mjs +84 -0
- package/dev-factory/lib/epic-detect.mjs +190 -0
- package/dev-factory/lib/fleet-queue-file.mjs +846 -0
- package/dev-factory/lib/fleet-queue.mjs +426 -0
- package/dev-factory/lib/fleet-state.mjs +14 -18
- package/dev-factory/lib/model-catalog.mjs +338 -0
- package/dev-factory/lib/seat-context.mjs +199 -0
- package/dev-factory/lib/spec-io.mjs +93 -0
- package/dev-factory/lib/sufficiency-score.mjs +8 -2
- package/dev-factory/lib/worktree-allocator.mjs +744 -0
- package/dev-factory/model-catalog.json +222 -0
- package/dist/cli/hs-check.mjs +1127 -103
- package/dist/cli/hs-claim.mjs +981 -63
- package/dist/cli/hs-connect.mjs +10059 -1729
- package/dist/cli/hs-defer.mjs +5507 -3072
- package/dist/cli/hs-dev.mjs +576 -0
- package/dist/cli/hs-env.mjs +416 -302
- package/dist/cli/hs-index-rebuild.mjs +7334 -0
- package/dist/cli/hs-index-reconcile.mjs +8138 -0
- package/dist/cli/hs-init.mjs +2840 -149
- package/dist/cli/hs-install.mjs +7214 -594
- package/dist/cli/hs-join.mjs +10640 -2421
- package/dist/cli/hs-vault-pull.mjs +8157 -6370
- package/dist/cli/hs-vault-push.mjs +6433 -0
- package/dist/cli/hs-vault.mjs +4029 -1555
- package/dist/cli/hs-workspace.mjs +2702 -538
- package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
- package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
- package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -0
- package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
- package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -0
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
- package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
- package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +33 -0
- package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
- package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
- package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
- package/docs/copilot-plugin-install.md +162 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/docs/mcp-client-config.md +259 -6
- package/package.json +249 -124
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
- package/plugins/heyspex/.mcp.json +12 -0
- package/plugins/heyspex/README.md +309 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
- package/plugins/heyspex/bin/run-hook.mjs +80 -0
- package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
- package/plugins/heyspex/hooks/hooks.json +418 -0
- package/plugins/heyspex/mcp.json +13 -0
- package/scripts/check-mcp-freshness.mjs +17 -9
- package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
- package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
- package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
- package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -0
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
- package/scripts/claude-hooks/_run.mjs +413 -0
- package/scripts/claude-hooks/_transcript-text.mjs +60 -0
- package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
- package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
- package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
- package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
- package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
- package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
- package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
- package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +577 -0
- package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
- package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
- package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
- package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
- package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
- package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
- package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
- package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
- package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
- package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
- package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
- package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
- package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
- package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
- package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
- package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
- package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +86 -0
- package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
- package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
- package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
- package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -0
- package/scripts/claude-hooks/log-session-activity.mjs +341 -0
- package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
- package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
- package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
- package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
- package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
- package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
- package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
- package/scripts/claude-hooks/research-method-guard.mjs +398 -0
- package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
- package/scripts/claude-hooks/session-end.mjs +451 -0
- package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
- package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
- package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
- package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
- package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
- package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
- package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
- package/scripts/cursor-hooks/manifest.mjs +281 -0
- package/scripts/dev-server-status.mjs +34 -13
- package/scripts/dev-server-stop.mjs +313 -0
- package/scripts/heyspex-mode.mjs +288 -0
- package/scripts/hs-auto-resolve.mjs +240 -0
- package/scripts/hs-auto-source.mjs +398 -0
- package/scripts/hs-auto.mjs +134 -0
- package/scripts/hs-capture-cli.mjs +326 -0
- package/scripts/hs-classifier.mjs +136 -0
- package/scripts/hs-companion.mjs +88 -0
- package/scripts/hs-gateway.mjs +115 -0
- package/scripts/hs-mode.mjs +125 -0
- package/scripts/hs-next.mjs +441 -0
- package/scripts/hs-settings.mjs +338 -0
- package/scripts/hs-view.mjs +496 -0
- package/scripts/hs-worktree.mjs +201 -0
- package/scripts/install-claude-code-hooks.mjs +272 -0
- package/scripts/install-cursor-hooks.mjs +471 -0
- package/scripts/lib/agent-env-markers.mjs +38 -0
- package/scripts/lib/console-url-resolver.mjs +199 -61
- package/scripts/lib/customer-kit.mjs +1203 -0
- package/scripts/lib/dev-mode.mjs +249 -32
- package/scripts/lib/directive-obligations.mjs +349 -0
- package/scripts/lib/heyspex-home.mjs +72 -0
- package/scripts/lib/hook-dispositions.mjs +210 -0
- package/scripts/lib/hook-vault-header.mjs +30 -0
- package/scripts/lib/hs-command.mjs +277 -0
- package/scripts/lib/hs-companion-settings.mjs +472 -0
- package/scripts/lib/hs-source-branch.mjs +196 -0
- package/scripts/lib/hs-source-commit.mjs +59 -5
- package/scripts/lib/hs-source-identity.mjs +526 -0
- package/scripts/lib/hs-source-log.mjs +13 -22
- package/scripts/lib/hs-source-preflight.mjs +18 -4
- package/scripts/lib/hs-source-push.mjs +386 -307
- package/scripts/lib/hs-worktree-args.mjs +115 -0
- package/scripts/lib/shared-path-helpers.mjs +134 -0
- package/scripts/lib/standalone-doctor.mjs +540 -3
- package/scripts/lib/subagent-git-lock.mjs +109 -0
- package/scripts/lib/template-pin.mjs +34 -0
- package/scripts/lib/window-placement.mjs +109 -0
- package/scripts/provenance-lib.mjs +91 -0
- package/scripts/stamp-provenance-on-edit.mjs +222 -0
- package/scripts/wip-ledger-health.mjs +653 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
- package/spexharness/src/adapters/audit.mjs +197 -0
- package/spexharness/src/adapters/claude-code.mjs +449 -0
- package/spexharness/src/adapters/copilot.mjs +236 -0
- package/spexharness/src/adapters/detect-harness.mjs +34 -0
- package/spexharness/src/chat/tui/ansi.mjs +448 -0
- package/spexharness/src/chat/tui/palette.mjs +22 -0
- package/spexharness/src/chat/tui/select.mjs +208 -0
- package/spexharness/src/config-root.mjs +54 -0
- package/spexharness/src/context-budget.mjs +141 -0
- package/spexharness/src/dev-server-control.mjs +389 -0
- package/spexharness/src/doctrine/behaviour.mjs +371 -0
- package/spexharness/src/doctrine/corpus.mjs +733 -0
- package/spexharness/src/doctrine/inject.mjs +338 -0
- package/spexharness/src/doctrine/merge.mjs +184 -0
- package/spexharness/src/doctrine/sources.mjs +252 -0
- package/spexharness/src/doctrine/synthesize.mjs +401 -0
- package/spexharness/src/doctrine/vault-source.mjs +574 -0
- package/spexharness/src/doctrine/writer.mjs +176 -0
- package/spexharness/src/events.mjs +84 -0
- package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
- package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
- package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
- package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +275 -0
- package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
- package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
- package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
- package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
- package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
- package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
- package/spexharness/src/gates/git-identity-guard.mjs +61 -0
- package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
- package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
- package/spexharness/src/gates/git-stash-gate.mjs +159 -0
- package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
- package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
- package/spexharness/src/gates/inject-vault-context.mjs +239 -0
- package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
- package/spexharness/src/gates/plan-review-gate.mjs +233 -0
- package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
- package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
- package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
- package/spexharness/src/gates/settings-write-gate.mjs +104 -0
- package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
- package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
- package/spexharness/src/git-reflog-audit.mjs +294 -0
- package/spexharness/src/intent-classifier.mjs +196 -0
- package/spexharness/src/intent-directive.mjs +626 -0
- package/spexharness/src/profile/learn.mjs +551 -0
- package/spexharness/src/profile/proposals.mjs +357 -0
- package/spexharness/src/profile/vault-inclusion.mjs +386 -0
- package/spexharness/src/redact.mjs +151 -0
- package/spexharness/src/rule-table.mjs +849 -0
- package/spexharness/src/seat-context.mjs +122 -0
- package/spexharness/src/session-bootstrap.mjs +512 -0
- package/spexharness/src/session-record.mjs +815 -0
- package/spexharness/src/token-inventory.mjs +207 -0
- package/vendor/heyspex-mcp/account-switch.mjs +96 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
- package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
- package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +77 -8
- package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
- package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
- package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
- package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
- package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
- package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
- package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
- package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
- package/vendor/heyspex-mcp/cli.mjs +207 -15524
- package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
- package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -0
- package/vendor/heyspex-mcp/config-seed.yaml +15 -0
- package/vendor/heyspex-mcp/config-settings.mjs +290 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +822 -17
- package/vendor/heyspex-mcp/console-transport.mjs +172 -0
- package/vendor/heyspex-mcp/credentials.mjs +585 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
- package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
- package/vendor/heyspex-mcp/design-tools.mjs +4 -1
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
- package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
- package/vendor/heyspex-mcp/dev-tools.mjs +131 -0
- package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
- package/vendor/heyspex-mcp/events-tools.mjs +207 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +723 -0
- package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
- package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
- package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
- package/vendor/heyspex-mcp/http-transport.mjs +302 -0
- package/vendor/heyspex-mcp/list-tools.mjs +257 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +107 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
- package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
- package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -0
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
- package/vendor/heyspex-mcp/package-root.mjs +32 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
- package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
- package/vendor/heyspex-mcp/plan-push.mjs +908 -22
- package/vendor/heyspex-mcp/plan-review.mjs +124 -30
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
- package/vendor/heyspex-mcp/promote-capture.mjs +388 -69
- package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
- package/vendor/heyspex-mcp/promote-research.mjs +666 -53
- package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
- package/vendor/heyspex-mcp/prompts.mjs +1460 -0
- package/vendor/heyspex-mcp/read-path.mjs +191 -15
- package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
- package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
- package/vendor/heyspex-mcp/register-spec.mjs +266 -54
- package/vendor/heyspex-mcp/request-context.mjs +83 -0
- package/vendor/heyspex-mcp/research-focus.mjs +207 -4
- package/vendor/heyspex-mcp/research-handlers.mjs +625 -0
- package/vendor/heyspex-mcp/research-tools.mjs +4 -4
- package/vendor/heyspex-mcp/response-budget.mjs +348 -0
- package/vendor/heyspex-mcp/review-focus.mjs +25 -1
- package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
- package/vendor/heyspex-mcp/search-specs.mjs +280 -0
- package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
- package/vendor/heyspex-mcp/session-owner.mjs +109 -0
- package/vendor/heyspex-mcp/session-tools.mjs +162 -0
- package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +453 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
- package/vendor/heyspex-mcp/split-research.mjs +304 -56
- package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
- package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
- package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
- package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
- package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -0
- package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
- package/vendor/heyspex-mcp/vault-io.mjs +720 -0
- package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
- package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
- package/vendor/heyspex-mcp/whoami.mjs +92 -0
- package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// spexharness/src/token-inventory.mjs
|
|
2
|
+
//
|
|
3
|
+
// The harness's session-start token inventory: measure every piece a session injects at
|
|
4
|
+
// open (seat context, WIP block, memories, preflight procedures…), order them by value,
|
|
5
|
+
// cut from the BOTTOM of that order when the budget is hit, and produce the one-line
|
|
6
|
+
// inventory the front prints on session open — "what was injected and how much is left".
|
|
7
|
+
//
|
|
8
|
+
// Budget is a PARAMETER, never a constant: a seat opened on a 64k local model and the same
|
|
9
|
+
// seat opened on a long-window hosted model run the same bootstrap and differ only in the
|
|
10
|
+
// number resolved here. The seat row is the source of that number (`tokenBudget`), and 64k
|
|
11
|
+
// is the default when the row doesn't carry one — the smallest window the fleet targets, so
|
|
12
|
+
// a seat that never states a budget is still planned to fit the local model.
|
|
13
|
+
//
|
|
14
|
+
// Sizing is NOT reimplemented here: `estimateTokens` (chars/4) comes from
|
|
15
|
+
// dev-factory/lib/context-budget.mjs, which is already THE prompt-token estimate for the
|
|
16
|
+
// factory (see that file's header for why a second copy of the arithmetic is a defect, not
|
|
17
|
+
// a convenience). Because chars/4 is an approximation, callers that must also reserve room
|
|
18
|
+
// for a model's OUTPUT should resolve their budget with that module's `promptBudgetTokens`
|
|
19
|
+
// and pass the result in as `budget` — this module deliberately does not re-derive it.
|
|
20
|
+
//
|
|
21
|
+
// Pure: no I/O, no console. `printTokenInventory` takes the log function, so a front (or a
|
|
22
|
+
// test) decides where the line goes. Nothing here reads or writes the bootstrap — pieces
|
|
23
|
+
// arrive as an argument, so the caller assembling them and this measurement stay separable.
|
|
24
|
+
|
|
25
|
+
import { estimateTokens } from '../../dev-factory/lib/context-budget.mjs';
|
|
26
|
+
|
|
27
|
+
export { estimateTokens };
|
|
28
|
+
|
|
29
|
+
/** Default session-start token budget: 64k — the smallest window the local fleet targets. */
|
|
30
|
+
export const DEFAULT_TOKEN_BUDGET = 65536;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Seat-row keys carrying a token budget, in precedence order. `tokenBudget` is the
|
|
34
|
+
* canonical one; the snake_case spelling and the model-window names are accepted because
|
|
35
|
+
* seat rows are hand-edited JSON (dev-factory/seats.json) and a near-miss key silently
|
|
36
|
+
* falling back to 64k is worse than reading it.
|
|
37
|
+
*/
|
|
38
|
+
const SEAT_BUDGET_KEYS = ['tokenBudget', 'token_budget', 'contextTokens', 'numCtx', 'num_ctx'];
|
|
39
|
+
|
|
40
|
+
/** A usable budget is a finite, positive number; anything else (null, "", NaN, 0, -1) is not. */
|
|
41
|
+
function usableBudget(value) {
|
|
42
|
+
const n = Number(value);
|
|
43
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the token budget for a seat: the seat row's own budget, else {@link DEFAULT_TOKEN_BUDGET}.
|
|
48
|
+
* A missing seat, a seat with no budget key, or an unusable value (0, negative, non-numeric)
|
|
49
|
+
* all fall back to the default rather than throwing — a seat must never fail to open because
|
|
50
|
+
* its row is missing an optional field.
|
|
51
|
+
*
|
|
52
|
+
* @param {object|null} [seat] a seat row (dev-factory/seats.json shape), or null.
|
|
53
|
+
* @returns {number} a positive integer token budget.
|
|
54
|
+
*/
|
|
55
|
+
export function seatTokenBudget(seat = null) {
|
|
56
|
+
if (!seat || typeof seat !== 'object') return DEFAULT_TOKEN_BUDGET;
|
|
57
|
+
for (const key of SEAT_BUDGET_KEYS) {
|
|
58
|
+
const budget = usableBudget(seat[key]);
|
|
59
|
+
if (budget !== null) return budget;
|
|
60
|
+
}
|
|
61
|
+
return DEFAULT_TOKEN_BUDGET;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Measure one piece. `tokens` (a size the caller already knows — e.g. a tokenizer's count)
|
|
66
|
+
* wins over `text`; otherwise the text is sized with the repo-wide chars/4 estimate.
|
|
67
|
+
*
|
|
68
|
+
* @param {{ name?: string, text?: string, tokens?: number, priority?: number }} piece
|
|
69
|
+
* @param {number} [index] used to name an unnamed piece so the inventory line never has a blank slot.
|
|
70
|
+
* @returns {{ name: string, tokens: number, chars: number, priority: number, estimated: boolean }}
|
|
71
|
+
*/
|
|
72
|
+
export function measurePiece(piece, index = 0) {
|
|
73
|
+
const { name, text, tokens, priority } = piece ?? {};
|
|
74
|
+
// An explicit `tokens: 0` is a real measurement (an empty piece), while null/undefined
|
|
75
|
+
// means "not measured yet" — so absence, not falsiness, is what falls back to the text.
|
|
76
|
+
const explicit = tokens == null ? null : (usableBudget(tokens) ?? (Number(tokens) === 0 ? 0 : null));
|
|
77
|
+
const body = text == null ? '' : String(text);
|
|
78
|
+
return {
|
|
79
|
+
name: name ? String(name) : `piece-${index + 1}`,
|
|
80
|
+
tokens: explicit === null ? estimateTokens(body) : explicit,
|
|
81
|
+
chars: body.length,
|
|
82
|
+
priority: Number.isFinite(Number(priority)) ? Number(priority) : 0,
|
|
83
|
+
estimated: explicit === null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Measure every piece and order it by value: descending `priority`, ties broken by the
|
|
89
|
+
* order the caller supplied (a stable sort), so a caller that already hands pieces over
|
|
90
|
+
* most-valuable-first gets exactly its own order back without stating a priority at all.
|
|
91
|
+
*
|
|
92
|
+
* @param {Array<object>} pieces
|
|
93
|
+
* @returns {Array<ReturnType<typeof measurePiece>>}
|
|
94
|
+
*/
|
|
95
|
+
export function measurePieces(pieces = []) {
|
|
96
|
+
const list = Array.isArray(pieces) ? pieces : [];
|
|
97
|
+
return list
|
|
98
|
+
.map((piece, index) => ({ measured: measurePiece(piece, index), index }))
|
|
99
|
+
.sort((a, b) => b.measured.priority - a.measured.priority || a.index - b.index)
|
|
100
|
+
.map(({ measured }) => measured);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the session-start token inventory: measure each piece, keep them down the value
|
|
105
|
+
* order while they fit the budget, and cut from the BOTTOM once one doesn't. The cut is a
|
|
106
|
+
* true tail cut — the first piece that overflows and everything below it are dropped, so a
|
|
107
|
+
* cheap low-value piece never leapfrogs an expensive higher-value one it was ranked under
|
|
108
|
+
* (that would silently reorder the seat's context by size instead of by value).
|
|
109
|
+
*
|
|
110
|
+
* @param {{
|
|
111
|
+
* pieces?: Array<{ name?: string, text?: string, tokens?: number, priority?: number }>,
|
|
112
|
+
* seat?: object|null,
|
|
113
|
+
* budget?: number|null,
|
|
114
|
+
* }} [opts]
|
|
115
|
+
* `budget` — an explicit budget, which wins over the seat row (a front that must also
|
|
116
|
+
* reserve output tokens resolves that itself and passes the result). `seat` — the seat
|
|
117
|
+
* row to read the budget from when `budget` is absent.
|
|
118
|
+
* @returns {{
|
|
119
|
+
* budget: number,
|
|
120
|
+
* injected: Array<{ name: string, tokens: number, chars: number, priority: number, estimated: boolean }>,
|
|
121
|
+
* dropped: Array<{ name: string, tokens: number, chars: number, priority: number, estimated: boolean }>,
|
|
122
|
+
* measured: Array<object>,
|
|
123
|
+
* injectedTokens: number,
|
|
124
|
+
* droppedTokens: number,
|
|
125
|
+
* totalTokens: number,
|
|
126
|
+
* remainingTokens: number,
|
|
127
|
+
* withinBudget: boolean,
|
|
128
|
+
* line: string,
|
|
129
|
+
* }}
|
|
130
|
+
*/
|
|
131
|
+
export function buildTokenInventory({ pieces = [], seat = null, budget = null } = {}) {
|
|
132
|
+
const resolvedBudget = usableBudget(budget) ?? seatTokenBudget(seat);
|
|
133
|
+
const measured = measurePieces(pieces);
|
|
134
|
+
|
|
135
|
+
const injected = [];
|
|
136
|
+
const dropped = [];
|
|
137
|
+
let injectedTokens = 0;
|
|
138
|
+
let cutting = false;
|
|
139
|
+
for (const piece of measured) {
|
|
140
|
+
// Once the budget is hit, everything below stays cut — bottom-of-the-order, not
|
|
141
|
+
// best-fit packing.
|
|
142
|
+
if (cutting || injectedTokens + piece.tokens > resolvedBudget) {
|
|
143
|
+
cutting = true;
|
|
144
|
+
dropped.push(piece);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
injected.push(piece);
|
|
148
|
+
injectedTokens += piece.tokens;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const droppedTokens = dropped.reduce((sum, piece) => sum + piece.tokens, 0);
|
|
152
|
+
const inventory = {
|
|
153
|
+
budget: resolvedBudget,
|
|
154
|
+
injected,
|
|
155
|
+
dropped,
|
|
156
|
+
measured,
|
|
157
|
+
injectedTokens,
|
|
158
|
+
droppedTokens,
|
|
159
|
+
totalTokens: injectedTokens + droppedTokens,
|
|
160
|
+
remainingTokens: resolvedBudget - injectedTokens,
|
|
161
|
+
withinBudget: dropped.length === 0,
|
|
162
|
+
line: '',
|
|
163
|
+
};
|
|
164
|
+
inventory.line = formatInventoryLine(inventory);
|
|
165
|
+
return inventory;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Render the one line a front prints on session open: what was injected, at what size, and
|
|
170
|
+
* how much of the budget is left — plus what was cut, when anything was. Always exactly one
|
|
171
|
+
* line (no newline characters), so it can sit in a status bar, a log line, or a chat header.
|
|
172
|
+
*
|
|
173
|
+
* @param {ReturnType<typeof buildTokenInventory>} inventory
|
|
174
|
+
* @returns {string}
|
|
175
|
+
*/
|
|
176
|
+
export function formatInventoryLine(inventory) {
|
|
177
|
+
const {
|
|
178
|
+
budget = DEFAULT_TOKEN_BUDGET,
|
|
179
|
+
injected = [],
|
|
180
|
+
dropped = [],
|
|
181
|
+
injectedTokens = 0,
|
|
182
|
+
remainingTokens = budget - injectedTokens,
|
|
183
|
+
} = inventory ?? {};
|
|
184
|
+
|
|
185
|
+
const used = budget > 0 ? Math.round((injectedTokens / budget) * 100) : 100;
|
|
186
|
+
const parts = [
|
|
187
|
+
`context ${injectedTokens}/${budget} tokens (${used}%)`,
|
|
188
|
+
`${remainingTokens} left`,
|
|
189
|
+
`in: ${injected.length ? injected.map((p) => `${p.name} ${p.tokens}`).join(', ') : '(none)'}`,
|
|
190
|
+
];
|
|
191
|
+
if (dropped.length) parts.push(`cut: ${dropped.map((p) => `${p.name} ${p.tokens}`).join(', ')}`);
|
|
192
|
+
return parts.join(' · ').replace(/[\r\n]+/g, ' ');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Print the inventory line on session open. The log function is an argument so a front
|
|
197
|
+
* chooses the sink (stdout, a chat header, a log file) and a test can capture it.
|
|
198
|
+
*
|
|
199
|
+
* @param {ReturnType<typeof buildTokenInventory>} inventory
|
|
200
|
+
* @param {(line: string) => void} [log]
|
|
201
|
+
* @returns {string} the line that was printed.
|
|
202
|
+
*/
|
|
203
|
+
export function printTokenInventory(inventory, log = console.log) {
|
|
204
|
+
const line = inventory?.line ?? formatInventoryLine(inventory);
|
|
205
|
+
log(line);
|
|
206
|
+
return line;
|
|
207
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// account-switch.mjs — the ONE function that moves "which account is current"
|
|
2
|
+
// on this machine: the credentials store's `current_account_id` (and its token
|
|
3
|
+
// mirror) AND `~/.heyspex/registry.json`'s `current_account_id` move together,
|
|
4
|
+
// so the bearer and the active-vault pointer can never name different accounts
|
|
5
|
+
// ([[HS-UA9S-WW9R-TASK-store-credentials-per-account-login]], invariant 4).
|
|
6
|
+
//
|
|
7
|
+
// The task offered two shapes: a hidden `hs vault current-account` sub (edits
|
|
8
|
+
// `spex-console/src/lib/cli/vault.ts`, held by a sibling task) or one function
|
|
9
|
+
// that writes both files. This is the second. It lives beside credentials.mjs
|
|
10
|
+
// rather than under scripts/lib/ because `hs login` / `hs logout` (cli-verbs.mjs)
|
|
11
|
+
// ship in the npm package, whose `files` list already carries every
|
|
12
|
+
// vendor/heyspex-mcp/*.mjs but would need a package.json entry for a new
|
|
13
|
+
// scripts/lib/ file.
|
|
14
|
+
//
|
|
15
|
+
// The registry half edits ONLY `current_account_id` of a schema-v2 file and
|
|
16
|
+
// keeps every other byte of the JSON value (accounts' pointers, rows) as is. A
|
|
17
|
+
// missing file has no pointers to move; a v1 file is re-keyed only by the
|
|
18
|
+
// explicit `hs vault migrate-registry`; an unreadable file is left for
|
|
19
|
+
// `hs doctor`. Each of those is reported, never "fixed" here.
|
|
20
|
+
//
|
|
21
|
+
// Plain Node ESM, zero dependencies. Never logs token material.
|
|
22
|
+
|
|
23
|
+
import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { dirname, join } from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { assertNotRealHomeUnderTest, removeAccount, setCurrentAccount } from './credentials.mjs';
|
|
29
|
+
|
|
30
|
+
/** `~/.heyspex/registry.json` for `homeDir`. */
|
|
31
|
+
export function registryFilePath(homeDir = heyspexHome()) {
|
|
32
|
+
return join(homeDir, '.heyspex', 'registry.json');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Set registry.json's `current_account_id` (or clear it with `null`).
|
|
37
|
+
* @param {{ homeDir?: string, accountId: string | null }} input
|
|
38
|
+
* @returns {{ status: 'updated' | 'unchanged' | 'absent' | 'v1' | 'unreadable', path: string }}
|
|
39
|
+
*/
|
|
40
|
+
export function setRegistryCurrentAccount({ homeDir = heyspexHome(), accountId }) {
|
|
41
|
+
const path = registryFilePath(homeDir);
|
|
42
|
+
if (!existsSync(path)) return { status: 'absent', path };
|
|
43
|
+
let json;
|
|
44
|
+
try {
|
|
45
|
+
json = JSON.parse(readFileSync(path, 'utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return { status: 'unreadable', path };
|
|
48
|
+
}
|
|
49
|
+
if (!json || typeof json !== 'object' || Array.isArray(json)) return { status: 'unreadable', path };
|
|
50
|
+
if (json.schema_version !== 2) return { status: 'v1', path };
|
|
51
|
+
const next = accountId ?? null;
|
|
52
|
+
if ((json.current_account_id ?? null) === next) return { status: 'unchanged', path };
|
|
53
|
+
json.current_account_id = next;
|
|
54
|
+
assertNotRealHomeUnderTest(path);
|
|
55
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
56
|
+
const tmp = `${path}.tmp`;
|
|
57
|
+
writeFileSync(tmp, `${JSON.stringify(json, null, 2)}\n`, 'utf8');
|
|
58
|
+
renameSync(tmp, path);
|
|
59
|
+
return { status: 'updated', path };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Make `accountId` the machine's current account in BOTH stores. The account
|
|
64
|
+
* must already hold a token (a switch never pairs). Throws when it does not;
|
|
65
|
+
* the registry is touched only after the credentials half succeeded.
|
|
66
|
+
* @param {{ homeDir?: string, accountId: string }} input
|
|
67
|
+
*/
|
|
68
|
+
export function switchCurrentAccount({ homeDir = heyspexHome(), accountId }) {
|
|
69
|
+
const credentialsPath = setCurrentAccount({ homeDir, accountId });
|
|
70
|
+
const registry = setRegistryCurrentAccount({ homeDir, accountId });
|
|
71
|
+
return { accountId, credentialsPath, registry };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sign one account out (default: the current one). When the current account is
|
|
76
|
+
* removed, both stores' `current_account_id` are cleared; removing another
|
|
77
|
+
* account leaves the registry alone.
|
|
78
|
+
* @param {{ homeDir?: string, accountId?: string | null }} [input]
|
|
79
|
+
*/
|
|
80
|
+
export function signOutAccount({ homeDir = heyspexHome(), accountId = null } = {}) {
|
|
81
|
+
const result = removeAccount({ homeDir, accountId });
|
|
82
|
+
const registry =
|
|
83
|
+
result.removed && result.wasCurrent
|
|
84
|
+
? setRegistryCurrentAccount({ homeDir, accountId: null })
|
|
85
|
+
: { status: 'unchanged', path: registryFilePath(homeDir) };
|
|
86
|
+
return { ...result, registry };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* After a fresh pairing stored `accountId` as current in the credentials
|
|
91
|
+
* store, carry the registry's pointer along with it.
|
|
92
|
+
* @param {{ homeDir?: string, accountId: string }} input
|
|
93
|
+
*/
|
|
94
|
+
export function followCurrentAccountInRegistry({ homeDir = heyspexHome(), accountId }) {
|
|
95
|
+
return setRegistryCurrentAccount({ homeDir, accountId });
|
|
96
|
+
}
|
|
@@ -29,11 +29,57 @@
|
|
|
29
29
|
import { execFileSync } from 'node:child_process';
|
|
30
30
|
import { readFileSync, existsSync } from 'node:fs';
|
|
31
31
|
import { wikilinkTarget } from './blocker-resolver.mjs';
|
|
32
|
+
import { readViaSelector } from './read-path.mjs';
|
|
32
33
|
import { spexConsoleSettingsCandidates } from './vault-settings.mjs';
|
|
33
34
|
|
|
34
35
|
export const ALIGNMENT_FUSE_DAYS = 7;
|
|
35
36
|
export const DEFAULT_FOCUS_PIN_EXPIRATION_DAYS = 7;
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Marker carried by every result this module returns when a read the DISK tier
|
|
40
|
+
* performs has no equivalent in the `neon` read tier.
|
|
41
|
+
*
|
|
42
|
+
* The rule it exists to enforce: **a cloud-mode gate never fails OPEN
|
|
43
|
+
* silently.** In the `neon` tier there may be no git checkout of the vault at
|
|
44
|
+
* all, so `git log` against the vault root is not "slow" or "empty" — it is
|
|
45
|
+
* meaningless. The honest verdict is "cannot verify in cloud mode", carried in
|
|
46
|
+
* the result itself, NOT a thrown ENOENT and NOT a quiet `skip: true`.
|
|
47
|
+
*/
|
|
48
|
+
export const CLOUD_MODE_UNSUPPORTED_READ = 'CLOUD_MODE_UNSUPPORTED_READ';
|
|
49
|
+
|
|
50
|
+
/** Build one clearly-marked "this could not be sourced in cloud mode" record. */
|
|
51
|
+
export function cloudModeUnsupportedRead({ what, why, routeLabel = null, consoleUrl = null }) {
|
|
52
|
+
return {
|
|
53
|
+
unsupported: CLOUD_MODE_UNSUPPORTED_READ,
|
|
54
|
+
what,
|
|
55
|
+
why,
|
|
56
|
+
route_label: routeLabel,
|
|
57
|
+
console_url: consoleUrl ?? null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True for any value produced by {@link cloudModeUnsupportedRead}. */
|
|
62
|
+
export function isCloudModeUnsupportedRead(value) {
|
|
63
|
+
return Boolean(value) && value.unsupported === CLOUD_MODE_UNSUPPORTED_READ;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Why the git-time reads below have no cloud twin, stated once.
|
|
68
|
+
*
|
|
69
|
+
* The console serves no git-history route (audited 2026-09-14 across
|
|
70
|
+
* `spex-console/src/app/api/`): `/api/activity` and `/api/specs/[id]/activity`
|
|
71
|
+
* serve the agent tool-call log, not vault history; `POST /api/snapshot`
|
|
72
|
+
* TRIGGERS a git export drain and reads nothing back; `/api/specs` carries only
|
|
73
|
+
* frontmatter `created:` / `updated:` — the hand-editable fields the commit
|
|
74
|
+
* timestamp was deliberately chosen to be immune to (see the section header
|
|
75
|
+
* above). Substituting them would silently swap a spoof-proof signal for a
|
|
76
|
+
* spoofable one inside a gate, which is worse than reporting the gap.
|
|
77
|
+
*/
|
|
78
|
+
const GIT_HISTORY_UNSOURCEABLE_WHY =
|
|
79
|
+
'the neon read tier may hold no git checkout of the vault, and the console exposes no git-history route — '
|
|
80
|
+
+ '/api/specs carries only frontmatter `created:`/`updated:`, the hand-editable fields the commit timestamp is chosen to be immune to, '
|
|
81
|
+
+ 'and /api/activity is the agent tool-call log, not vault history';
|
|
82
|
+
|
|
37
83
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
84
|
// Git-time helpers — SSOT for "when did this spec enter the vault?" The
|
|
39
85
|
// commit timestamp (`%ct`, seconds-since-epoch in committer's local timezone)
|
|
@@ -149,6 +195,100 @@ export function computeAlignmentSignature({ childIds, epicUpdated, worldStateHas
|
|
|
149
195
|
return djb2(`${sortedChildren}|${epicUpdated || ''}|${worldStateHash || ''}`);
|
|
150
196
|
}
|
|
151
197
|
|
|
198
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
199
|
+
// Tier-aware git-time reads ([[HS-T6GS-MCPC-EPIC]] — every vault read the MCP
|
|
200
|
+
// server makes goes through `readViaSelector`).
|
|
201
|
+
//
|
|
202
|
+
// The tier is RESOLVED ONCE by the handler that owns the request and threaded
|
|
203
|
+
// in as `deps.tier`; nothing in this module calls `resolveReadTier()` itself.
|
|
204
|
+
// `tier` defaults to `'disk'` so every existing caller — and every existing
|
|
205
|
+
// test — keeps today's behavior byte-for-byte: the disk arm of
|
|
206
|
+
// `readViaSelector` is literally `diskRead()`, and `diskRead` here is the
|
|
207
|
+
// untouched synchronous function above.
|
|
208
|
+
//
|
|
209
|
+
// The `neon` arm does NOT call the console: nothing there serves git history
|
|
210
|
+
// (see GIT_HISTORY_UNSOURCEABLE_WHY). It returns a clearly-marked
|
|
211
|
+
// CLOUD_MODE_UNSUPPORTED_READ record instead — kept inside `neonRead` so that
|
|
212
|
+
// the day a `/api/vault/git-history`-shaped route exists, swapping this body
|
|
213
|
+
// for a real fetch also gets the CONSOLE_UNREACHABLE_CLOUD_MODE failure
|
|
214
|
+
// envelope for free.
|
|
215
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/** Console URL to NAME in cloud-mode diagnostics; never resolved on the disk path. */
|
|
218
|
+
function diagnosticConsoleUrl(tier, deps) {
|
|
219
|
+
return tier === 'disk' ? null : (deps?.consoleUrl ?? null);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Tier-aware {@link gitFirstCommitTime}. `neon` answers an unsupported-read record. */
|
|
223
|
+
export async function gitFirstCommitTimeViaTier(vaultRoot, path, deps = {}) {
|
|
224
|
+
const { tier = 'disk' } = deps;
|
|
225
|
+
const consoleUrl = diagnosticConsoleUrl(tier, deps);
|
|
226
|
+
return readViaSelector({
|
|
227
|
+
tier,
|
|
228
|
+
diskRead: () => gitFirstCommitTime(vaultRoot, path),
|
|
229
|
+
neonRead: async () =>
|
|
230
|
+
cloudModeUnsupportedRead({
|
|
231
|
+
what: `the first-commit timestamp of \`${path}\` (\`git log --diff-filter=A --follow --format=%ct -- ${path}\`)`,
|
|
232
|
+
why: GIT_HISTORY_UNSOURCEABLE_WHY,
|
|
233
|
+
routeLabel: 'git log --diff-filter=A --follow',
|
|
234
|
+
consoleUrl,
|
|
235
|
+
}),
|
|
236
|
+
routeLabel: 'git log --diff-filter=A --follow',
|
|
237
|
+
consoleUrl,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Tier-aware {@link gitLastCommitTime}. `neon` answers an unsupported-read record. */
|
|
242
|
+
export async function gitLastCommitTimeViaTier(vaultRoot, path, deps = {}) {
|
|
243
|
+
const { tier = 'disk' } = deps;
|
|
244
|
+
const consoleUrl = diagnosticConsoleUrl(tier, deps);
|
|
245
|
+
return readViaSelector({
|
|
246
|
+
tier,
|
|
247
|
+
diskRead: () => gitLastCommitTime(vaultRoot, path),
|
|
248
|
+
neonRead: async () =>
|
|
249
|
+
cloudModeUnsupportedRead({
|
|
250
|
+
what: `the last-commit timestamp of \`${path}\` (\`git log -1 --format=%ct -- ${path}\`)`,
|
|
251
|
+
why: GIT_HISTORY_UNSOURCEABLE_WHY,
|
|
252
|
+
routeLabel: 'git log -1 --format=%ct',
|
|
253
|
+
consoleUrl,
|
|
254
|
+
}),
|
|
255
|
+
routeLabel: 'git log -1 --format=%ct',
|
|
256
|
+
consoleUrl,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Tier-aware {@link computeWorldStateDelta}.
|
|
262
|
+
*
|
|
263
|
+
* The `neon` answer keeps the `{ addedPaths, hash }` SHAPE so a caller that
|
|
264
|
+
* only reads those two fields cannot crash — but it is additionally stamped
|
|
265
|
+
* `unsupported: CLOUD_MODE_UNSUPPORTED_READ` and carries the full
|
|
266
|
+
* `unsupported_read` record, so a caller that treats the empty delta as "no
|
|
267
|
+
* new specs" is the one making that (wrong) choice explicitly.
|
|
268
|
+
* {@link canSkipAlignmentViaTier} refuses to.
|
|
269
|
+
*/
|
|
270
|
+
export async function computeWorldStateDeltaViaTier(vaultRoot, sinceTs, deps = {}) {
|
|
271
|
+
const { tier = 'disk' } = deps;
|
|
272
|
+
const consoleUrl = diagnosticConsoleUrl(tier, deps);
|
|
273
|
+
return readViaSelector({
|
|
274
|
+
tier,
|
|
275
|
+
diskRead: () => computeWorldStateDelta(vaultRoot, sinceTs),
|
|
276
|
+
neonRead: async () => ({
|
|
277
|
+
addedPaths: [],
|
|
278
|
+
hash: djb2(''),
|
|
279
|
+
unsupported: CLOUD_MODE_UNSUPPORTED_READ,
|
|
280
|
+
unsupported_read: cloudModeUnsupportedRead({
|
|
281
|
+
what: 'the set of `specs/` files first committed since the last alignment review (`git log --since=… --diff-filter=A --name-only -- specs/`)',
|
|
282
|
+
why: GIT_HISTORY_UNSOURCEABLE_WHY,
|
|
283
|
+
routeLabel: 'git log --since --diff-filter=A -- specs/',
|
|
284
|
+
consoleUrl,
|
|
285
|
+
}),
|
|
286
|
+
}),
|
|
287
|
+
routeLabel: 'git log --since --diff-filter=A -- specs/',
|
|
288
|
+
consoleUrl,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
152
292
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
153
293
|
// Settings-driven fuse — read spex-console/settings.json (or .heyspex/ mirror) so
|
|
154
294
|
// disabling `focusPinExpirationEnabled` in the UI also disables the alignment
|
|
@@ -156,6 +296,25 @@ export function computeAlignmentSignature({ childIds, epicUpdated, worldStateHas
|
|
|
156
296
|
// drop fuse_expired from the skip-reason ladder.
|
|
157
297
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
158
298
|
|
|
299
|
+
/**
|
|
300
|
+
* LOCAL CACHE — stays on disk in BOTH read tiers, deliberately.
|
|
301
|
+
*
|
|
302
|
+
* Both call sites (`readAlignmentFuseSettings`, `readReadinessFloorSettings`)
|
|
303
|
+
* point this at `spexConsoleSettingsCandidates(vaultRoot)`, i.e.
|
|
304
|
+
* `spex-console/settings.json` and its read-only `.heyspex/` mirrors. That is
|
|
305
|
+
* gitignored machine-local CONFIG, not vault content: it never lives under
|
|
306
|
+
* `specs/`, it is not a document with an id, frontmatter or a wikilink target,
|
|
307
|
+
* and `PUT /api/settings` explicitly refuses to persist it outside local mode
|
|
308
|
+
* (HS-29MO). So there is nothing in Neon to read it from, and nothing about a
|
|
309
|
+
* cloud-reliant session that should change where a local knob is read.
|
|
310
|
+
*
|
|
311
|
+
* This is the same call that `read-path-wiring.test.mjs` AC5 makes for
|
|
312
|
+
* credentials, the scheduler cache and caller-supplied args files: "still read
|
|
313
|
+
* from disk in both reliances." Both call sites therefore keep this function
|
|
314
|
+
* untouched — no tier argument, no console route, no degrade marker; a missing
|
|
315
|
+
* file already means "use the shipped default", which is the correct answer on
|
|
316
|
+
* a machine with no console settings file, cloud-reliant or not.
|
|
317
|
+
*/
|
|
159
318
|
function readJsonSafe(path) {
|
|
160
319
|
try {
|
|
161
320
|
if (!existsSync(path)) return null;
|
|
@@ -165,6 +324,10 @@ function readJsonSafe(path) {
|
|
|
165
324
|
}
|
|
166
325
|
}
|
|
167
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Read tier: DISK in both tiers — machine-local console config, not vault
|
|
329
|
+
* content. See {@link readJsonSafe} for the full call-site conclusion.
|
|
330
|
+
*/
|
|
168
331
|
export function readAlignmentFuseSettings(vaultRoot) {
|
|
169
332
|
const fallback = { enabled: true, days: DEFAULT_FOCUS_PIN_EXPIRATION_DAYS };
|
|
170
333
|
for (const p of spexConsoleSettingsCandidates(vaultRoot)) {
|
|
@@ -261,6 +424,101 @@ export function canSkipAlignment(epic, vaultRoot, options = {}) {
|
|
|
261
424
|
return { skip: true, reason: 'binary_skip_rule', signature: currentSignature };
|
|
262
425
|
}
|
|
263
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Tier-aware {@link canSkipAlignment}.
|
|
429
|
+
*
|
|
430
|
+
* `disk` → `canSkipAlignment(epic, vaultRoot, options)`, byte-identical (the
|
|
431
|
+
* selector's disk arm is literally `diskRead()`). `tier` defaults to `'disk'`,
|
|
432
|
+
* so an existing caller that passes no deps is unchanged.
|
|
433
|
+
*
|
|
434
|
+
* `neon` → the gate FAILS CLOSED and SAYS SO. Two of the rule's four
|
|
435
|
+
* components are git-truth (`older_children` needs each child's first-commit
|
|
436
|
+
* time; `new_specs_since_breakdown` needs the added-files delta) and the third
|
|
437
|
+
* — `signature_drift` — mixes the world-state hash into its signature, so a
|
|
438
|
+
* synthesized empty delta would make it compare a signature against inputs it
|
|
439
|
+
* never actually read. Rather than pass on unverified inputs (fail open) or
|
|
440
|
+
* throw ENOENT from `git` against a directory that does not exist, the verdict
|
|
441
|
+
* is `{ skip: false, reason: 'cloud_mode_unverifiable' }` carrying
|
|
442
|
+
* {@link CLOUD_MODE_UNSUPPORTED_READ} and a per-read record naming exactly
|
|
443
|
+
* what could not be sourced and why.
|
|
444
|
+
*
|
|
445
|
+
* Two reasons survive cloud mode intact and are returned as themselves,
|
|
446
|
+
* because neither reads git or the vault: `no_checkpoint` (the epic's own
|
|
447
|
+
* frontmatter carries no `alignment_reviewed_at`) and `fuse_expired` (that
|
|
448
|
+
* timestamp against a local settings knob — see {@link readJsonSafe}). Both
|
|
449
|
+
* already say `skip: false`, so returning them changes nothing about failing
|
|
450
|
+
* closed; it just gives the more specific reason.
|
|
451
|
+
*
|
|
452
|
+
* A caller that HAS sourced the world-state delta by some other means may pass
|
|
453
|
+
* it as `options.worldStateDelta`; it is then trusted and not listed as
|
|
454
|
+
* unsupported, exactly as on disk.
|
|
455
|
+
*/
|
|
456
|
+
export async function canSkipAlignmentViaTier(epic, vaultRoot, options = {}, deps = {}) {
|
|
457
|
+
const { tier = 'disk' } = deps;
|
|
458
|
+
if (tier !== 'neon') {
|
|
459
|
+
return readViaSelector({
|
|
460
|
+
tier,
|
|
461
|
+
diskRead: () => canSkipAlignment(epic, vaultRoot, options),
|
|
462
|
+
routeLabel: 'canSkipAlignment',
|
|
463
|
+
consoleUrl: null,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const consoleUrl = deps.consoleUrl ?? null;
|
|
468
|
+
const fm = epic?.frontmatter || {};
|
|
469
|
+
const reviewedAt = fm.alignment_reviewed_at;
|
|
470
|
+
const reviewedMs = reviewedAt ? Date.parse(String(reviewedAt)) : NaN;
|
|
471
|
+
if (!Number.isFinite(reviewedMs)) {
|
|
472
|
+
// Frontmatter-only verdict — as true in cloud mode as on disk.
|
|
473
|
+
return { skip: false, reason: 'no_checkpoint' };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const unsupportedReads = [
|
|
477
|
+
cloudModeUnsupportedRead({
|
|
478
|
+
what: 'the first-commit timestamp of the epic and of each child, which the `older_children` check compares',
|
|
479
|
+
why: GIT_HISTORY_UNSOURCEABLE_WHY,
|
|
480
|
+
routeLabel: 'git log --diff-filter=A --follow',
|
|
481
|
+
consoleUrl,
|
|
482
|
+
}),
|
|
483
|
+
];
|
|
484
|
+
|
|
485
|
+
let worldStateDelta = options.worldStateDelta;
|
|
486
|
+
if (!worldStateDelta) {
|
|
487
|
+
worldStateDelta = await computeWorldStateDeltaViaTier(vaultRoot, reviewedMs, { tier, consoleUrl });
|
|
488
|
+
if (worldStateDelta.unsupported_read) unsupportedReads.push(worldStateDelta.unsupported_read);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Re-run the shared rule with the git-backed inputs NEUTRALIZED rather than
|
|
492
|
+
// re-implementing the ladder: `epicPath`/`childResolver` are nulled so the
|
|
493
|
+
// older_children walk is skipped (it is already a no-op without them), and
|
|
494
|
+
// the delta is whatever we could honestly source.
|
|
495
|
+
const inner = canSkipAlignment(epic, vaultRoot, {
|
|
496
|
+
...options,
|
|
497
|
+
worldStateDelta,
|
|
498
|
+
epicPath: null,
|
|
499
|
+
childResolver: null,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Reasons that stand on their own in cloud mode: they read no git history.
|
|
503
|
+
if (inner.reason === 'fuse_expired' || inner.reason === 'new_specs_since_breakdown') {
|
|
504
|
+
return { ...inner, cloud_mode_unsupported_reads: unsupportedReads };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
skip: false,
|
|
509
|
+
reason: 'cloud_mode_unverifiable',
|
|
510
|
+
unsupported: CLOUD_MODE_UNSUPPORTED_READ,
|
|
511
|
+
cloud_mode_unsupported_reads: unsupportedReads,
|
|
512
|
+
message:
|
|
513
|
+
`${CLOUD_MODE_UNSUPPORTED_READ}: the alignment skip rule cannot be verified in the neon read tier — `
|
|
514
|
+
+ `${unsupportedReads.map((u) => u.what).join('; ')}. `
|
|
515
|
+
+ 'The gate stays CLOSED (skip: false) rather than passing on inputs it never read.',
|
|
516
|
+
// What the rule WOULD have said from the components that were readable.
|
|
517
|
+
// Reported for transparency only — never as the verdict.
|
|
518
|
+
unverified_inner_verdict: inner,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
264
522
|
/** Epic children live in `children:`; milestone children live in `epics:`. */
|
|
265
523
|
export function childRefsField(grain) {
|
|
266
524
|
return grain === 'milestone' ? 'epics' : 'children';
|
|
@@ -306,6 +564,39 @@ export function extractSection(body, headingRe) {
|
|
|
306
564
|
return out.join('\n').trim();
|
|
307
565
|
}
|
|
308
566
|
|
|
567
|
+
/**
|
|
568
|
+
* Does the body carry a level-2 heading whose title matches `headingRe`? Same
|
|
569
|
+
* scan as `extractSection`, but it answers "is the heading THERE" rather than
|
|
570
|
+
* "what is under it" — an empty section and an absent one look identical to
|
|
571
|
+
* `extractSection` (both `''`), and the `## Done when` alias below has to tell
|
|
572
|
+
* them apart. Exported for the alias's own tests.
|
|
573
|
+
*/
|
|
574
|
+
export function hasHeading(body, headingRe) {
|
|
575
|
+
if (typeof body !== 'string') return false;
|
|
576
|
+
for (const line of body.split(/\r?\n/)) {
|
|
577
|
+
const h2 = line.match(/^##\s+(.+?)\s*$/);
|
|
578
|
+
if (h2 && !line.startsWith('###') && headingRe.test(h2[1])) return true;
|
|
579
|
+
}
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** The canonical acceptance section title this parser extracts from. */
|
|
584
|
+
const ACCEPTANCE_HEADING_TITLE = /^acceptance(?:\s+criteria)?$/i;
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Deliberately BROADER than `ACCEPTANCE_HEADING_TITLE`, and used only to decide
|
|
588
|
+
* whether the `## Done when` alias may fire. The vault carries many suffixed
|
|
589
|
+
* Acceptance headings this parser does not extract from (`## Acceptance
|
|
590
|
+
* verification`, `## Acceptance gate — epic close`, `## Acceptance (epic-level)`,
|
|
591
|
+
* …). A doc that has ANY of them has an acceptance section, so the legacy alias
|
|
592
|
+
* must not fire there — otherwise the alias would newly disagree with the
|
|
593
|
+
* console parser, which matches `## Acceptance…` with arbitrary suffixes.
|
|
594
|
+
*/
|
|
595
|
+
const ACCEPTANCE_HEADING_TITLE_ANY = /^acceptance/i;
|
|
596
|
+
|
|
597
|
+
/** Legacy back-compat alias for the acceptance section — see HS-VDJH-LBUA below. */
|
|
598
|
+
const DONE_WHEN_HEADING_TITLE_ALIAS = /^done\s+when\b/i;
|
|
599
|
+
|
|
309
600
|
/** Bullet lines (`-`/`*`, checkbox, or numbered `1.` / `1)`) → trimmed text array. */
|
|
310
601
|
export function extractBullets(text) {
|
|
311
602
|
if (typeof text !== 'string') return [];
|
|
@@ -332,7 +623,27 @@ export function parseAcceptanceSubsections(body) {
|
|
|
332
623
|
// heading variant (~48 vault specs use the latter — and the console parser
|
|
333
624
|
// already accepts it, so anchoring exactly here broke cross-surface parity and
|
|
334
625
|
// wrongly routed those specs to the Tier-3 clarification floor). [[HS-TASK-I2Y8]]
|
|
335
|
-
|
|
626
|
+
let section = extractSection(body, ACCEPTANCE_HEADING_TITLE);
|
|
627
|
+
// HS-VDJH-LBUA — legacy `## Done when` BACK-COMPAT ALIAS (read side only; no
|
|
628
|
+
// vault file is ever rewritten). `## Acceptance criteria` stays canonical, but
|
|
629
|
+
// 138 vault files (measured 2026-09-01: 111 captures, 16 diagnoses, 8 tasks,
|
|
630
|
+
// 2 milestones, 1 research) carry the same section under `## Done when` and no
|
|
631
|
+
// `## Acceptance…` heading at all — the derived execution brief read zero
|
|
632
|
+
// criteria out of every one of them, so a dispatched diagnose could not be
|
|
633
|
+
// graded and the critic returned `uncertain` by construction.
|
|
634
|
+
//
|
|
635
|
+
// Tried ONLY when the document has no `## Acceptance…` heading, so the two
|
|
636
|
+
// vault files carrying BOTH headings still resolve to the canonical one
|
|
637
|
+
// regardless of document order — and a present-but-empty Acceptance section
|
|
638
|
+
// still reads as zero criteria rather than falling through. `\b` after `when`
|
|
639
|
+
// keeps the match tight: `## Done whenever …` is not a criteria section.
|
|
640
|
+
//
|
|
641
|
+
// Mirrors dev-factory/lib/acceptance-criteria.mjs and the console parser
|
|
642
|
+
// (spex-console/src/lib/acceptance-criteria.ts); the three are pinned together
|
|
643
|
+
// by spex-console/src/lib/__tests__/acceptance-criteria-parity.test.ts.
|
|
644
|
+
if (!section && !hasHeading(body, ACCEPTANCE_HEADING_TITLE_ANY)) {
|
|
645
|
+
section = extractSection(body, DONE_WHEN_HEADING_TITLE_ALIAS);
|
|
646
|
+
}
|
|
336
647
|
if (!section) return { sections: [], hasSubsections: false };
|
|
337
648
|
|
|
338
649
|
const lines = section.split(/\r?\n/);
|
|
@@ -1094,6 +1405,9 @@ export const DEFAULT_READINESS_FLOOR = { minAcceptanceBullets: 1, minGoalChars:
|
|
|
1094
1405
|
* .heyspex mirror under a `briefReadinessFloor: { minAcceptanceBullets,
|
|
1095
1406
|
* minGoalChars }` key. Missing / malformed values fall back per-field to
|
|
1096
1407
|
* DEFAULT_READINESS_FLOOR. Mirrors readAlignmentFuseSettings' lookup order.
|
|
1408
|
+
*
|
|
1409
|
+
* Read tier: DISK in both tiers — this is machine-local config, not vault
|
|
1410
|
+
* content. See {@link readJsonSafe} for the full call-site conclusion.
|
|
1097
1411
|
*/
|
|
1098
1412
|
export function readReadinessFloorSettings(vaultRoot) {
|
|
1099
1413
|
for (const p of spexConsoleSettingsCandidates(vaultRoot)) {
|