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,122 @@
|
|
|
1
|
+
// spexharness/src/seat-context.mjs
|
|
2
|
+
//
|
|
3
|
+
// The harness's seat-context loader: a seat's procedures, skills and memories, for any
|
|
4
|
+
// front (a chat window, an overnight seat run, the Claude Code plugin shell) — not just
|
|
5
|
+
// the Claude Code "SessionStart injection" text dev-factory/lib/seat-context.mjs produces.
|
|
6
|
+
//
|
|
7
|
+
// Built ON dev-factory/lib/seat-context.mjs (its buildSeatContext + repoRoot are the single
|
|
8
|
+
// source of truth for the "DEV-FACTORY SEAT: <title> — <mandate>" line and for reading
|
|
9
|
+
// dev-factory/seats.json — see that file's header for why three consumers already depend
|
|
10
|
+
// on it staying byte-identical), NOT reimplemented here. This module wraps that seat line
|
|
11
|
+
// in a front-agnostic, structured shape and layers on the vault-sourced context — the
|
|
12
|
+
// procedures (specs/protocols/*.md), skills (.claude/skills/*), and memories
|
|
13
|
+
// (specs/memory/**/*.md) a seat has available — none of which dev-factory/lib/seat-context.mjs
|
|
14
|
+
// carries, since its only job is the Claude-Code-shaped injection string.
|
|
15
|
+
//
|
|
16
|
+
// Every listing here is best-effort: a missing directory (e.g. a stripped-down worktree
|
|
17
|
+
// with no specs/ junction, or a front with no .claude/skills) yields an empty array, never
|
|
18
|
+
// a throw — the same fail-soft posture buildSeatContext itself uses for a missing/corrupt
|
|
19
|
+
// seats.json.
|
|
20
|
+
|
|
21
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { buildSeatContext, repoRoot } from '../../dev-factory/lib/seat-context.mjs';
|
|
24
|
+
|
|
25
|
+
export { repoRoot };
|
|
26
|
+
|
|
27
|
+
/** List `.md` basenames (no extension) directly under `dir`, sorted. [] if `dir` doesn't exist. */
|
|
28
|
+
function listMarkdownSlugs(dir) {
|
|
29
|
+
try {
|
|
30
|
+
return readdirSync(dir)
|
|
31
|
+
.filter((f) => f.endsWith('.md'))
|
|
32
|
+
.map((f) => f.slice(0, -3))
|
|
33
|
+
.sort();
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** List every `.md` slug under `dir`, one level of subdirectories deep (specs/memory/{lessons,decisions,...}/*.md). [] if `dir` doesn't exist. */
|
|
40
|
+
function listMarkdownSlugsRecursiveOneLevel(dir) {
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const slugs = [];
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const full = path.join(dir, entry.name);
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
slugs.push(...listMarkdownSlugs(full).map((slug) => `${entry.name}/${slug}`));
|
|
52
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
53
|
+
slugs.push(entry.name.slice(0, -3));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return slugs.sort();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** List skill directory/file names under a skills root (each entry is a skill id). [] if the root doesn't exist. */
|
|
60
|
+
function listSkillIds(dir) {
|
|
61
|
+
let entries;
|
|
62
|
+
try {
|
|
63
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
return entries
|
|
68
|
+
.filter((e) => e.isDirectory() || e.name.endsWith('.md'))
|
|
69
|
+
.map((e) => (e.isDirectory() ? e.name : e.name.slice(0, -3)))
|
|
70
|
+
.sort();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Load one seat's full context: the seat line (built on dev-factory/lib/seat-context.mjs),
|
|
75
|
+
* plus the procedures, skills and memories available to it — front-agnostic, so a chat
|
|
76
|
+
* front, a headless seat, or a Claude Code hook can all consume the same shape and format
|
|
77
|
+
* it however that front needs (text injection, a system-prompt block, a tool result…).
|
|
78
|
+
*
|
|
79
|
+
* @param {{ root?: string, seatId?: string|null, vaultRoot?: string|null }} [opts]
|
|
80
|
+
* `root` — repo root (defaults to repoRoot()): where dev-factory/seats.json and
|
|
81
|
+
* .claude/skills live. `vaultRoot` — the SPEC vault root, where specs/protocols and
|
|
82
|
+
* specs/memory live; defaults to `root`, but the platform checkout and its vault are
|
|
83
|
+
* separate directories for the owner (HS_VAULT_ROOT names the vault — see hs-chat.ts's
|
|
84
|
+
* own REPO_ROOT note), so a front that knows its vault passes it here or every
|
|
85
|
+
* vault-sourced piece silently reads as empty. `seatId` — when supplied and
|
|
86
|
+
* dev-factory/seats.json has a matching seat, that seat's own record is returned under
|
|
87
|
+
* `seat`; omit to get the same information buildSeatContext already exposes (the combined
|
|
88
|
+
* `seatLine` for every active seat) without a single-seat lookup.
|
|
89
|
+
* @returns {{
|
|
90
|
+
* seatLine: string|null,
|
|
91
|
+
* seat: object|null,
|
|
92
|
+
* procedures: string[],
|
|
93
|
+
* skills: string[],
|
|
94
|
+
* memories: string[],
|
|
95
|
+
* }}
|
|
96
|
+
*/
|
|
97
|
+
export function loadSeatContext({ root = repoRoot(), seatId = null, vaultRoot = null } = {}) {
|
|
98
|
+
const seatLine = buildSeatContext(root);
|
|
99
|
+
const specsRoot = typeof vaultRoot === 'string' && vaultRoot.length > 0 ? vaultRoot : root;
|
|
100
|
+
|
|
101
|
+
let seat = null;
|
|
102
|
+
if (seatId) {
|
|
103
|
+
try {
|
|
104
|
+
// Reuse the exact seats.json dev-factory/lib/seat-context.mjs already reads — no
|
|
105
|
+
// second parse-and-guess of the registry shape here.
|
|
106
|
+
const seatsPath = path.join(root, 'dev-factory', 'seats.json');
|
|
107
|
+
const registry = JSON.parse(readFileSync(seatsPath, 'utf8'));
|
|
108
|
+
const seats = Array.isArray(registry?.seats) ? registry.seats : [];
|
|
109
|
+
seat = seats.find((s) => s && s.id === seatId) ?? null;
|
|
110
|
+
} catch {
|
|
111
|
+
seat = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
seatLine,
|
|
117
|
+
seat,
|
|
118
|
+
procedures: listMarkdownSlugs(path.join(specsRoot, 'specs', 'protocols')),
|
|
119
|
+
skills: listSkillIds(path.join(root, '.claude', 'skills')),
|
|
120
|
+
memories: listMarkdownSlugsRecursiveOneLevel(path.join(specsRoot, 'specs', 'memory')),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
// spexharness/src/session-bootstrap.mjs
|
|
2
|
+
//
|
|
3
|
+
// The harness's ONE session bootstrap: the single in-process path that loads everything a
|
|
4
|
+
// seat needs to start — the seat context, the WIP block, the memories and the preflight
|
|
5
|
+
// procedures — exactly once per session, and hands them back as one structured block.
|
|
6
|
+
//
|
|
7
|
+
// Why this module exists (epic HS-Y646, task HS-QEXH): today the seat block arrives three
|
|
8
|
+
// ways (the Claude Code SessionStart hook, spexharness/src/callers/headless.mjs, and each
|
|
9
|
+
// front's own ad-hoc read), so a DeepSeek seat and a local-model seat neither start the
|
|
10
|
+
// same way nor start once. This module is the one path: a front — `hs chat`, an overnight
|
|
11
|
+
// headless seat, a DeepSeek seat entry point, a 64k local-model seat entry point — calls
|
|
12
|
+
// startSession()/bootstrapSession() and gets the same six pieces in the same shape. No
|
|
13
|
+
// front loads a piece itself, and nothing here reloads a piece a second time.
|
|
14
|
+
//
|
|
15
|
+
// Loading is NOT reinvented here. The seat context comes from
|
|
16
|
+
// spexharness/src/seat-context.mjs's loadSeatContext (itself built directly on
|
|
17
|
+
// dev-factory/lib/seat-context.mjs's buildSeatContext/repoRoot — see that module's header
|
|
18
|
+
// for why that stays the single source of truth), and the three vault-sourced pieces are
|
|
19
|
+
// derived from THAT already-loaded context rather than re-walking specs/ per piece. That is
|
|
20
|
+
// the whole point of the "exactly once" criterion: one seats.json parse, one specs/ walk.
|
|
21
|
+
//
|
|
22
|
+
// Every loader is injectable (`loaders`), which is how a front that has a live lane swaps
|
|
23
|
+
// the fail-soft on-disk defaults for the real thing — most importantly the WIP block, whose
|
|
24
|
+
// system of record is the Neon lane, not a file. The defaults keep an offline/stripped-down
|
|
25
|
+
// worktree (no specs/ junction, no lane file) starting successfully with empty pieces rather
|
|
26
|
+
// than throwing, the same fail-soft posture loadSeatContext and decide.mjs already take.
|
|
27
|
+
//
|
|
28
|
+
// I/O: only the default loaders touch disk (via loadSeatContext and best-effort reads).
|
|
29
|
+
// Injecting all six loaders makes this module pure.
|
|
30
|
+
|
|
31
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
32
|
+
import { randomUUID } from 'node:crypto';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { loadSeatContext, repoRoot } from './seat-context.mjs';
|
|
35
|
+
import { loadDoctrine, renderDoctrineBlock, DOCTRINE_MAX_TOKENS, defaultDoctrinePath } from './doctrine/inject.mjs';
|
|
36
|
+
import { defaultProfilePath, defaultProfileStatePath, loadProfileState, renderProfileDigest, PROFILE_MAX_TOKENS } from './profile/learn.mjs';
|
|
37
|
+
import { estimateTokens } from './token-inventory.mjs';
|
|
38
|
+
|
|
39
|
+
export { repoRoot };
|
|
40
|
+
|
|
41
|
+
/** The intent a seat preflights for when neither the caller nor the seat row names one. */
|
|
42
|
+
export const DEFAULT_INTENT = 'execute_spec';
|
|
43
|
+
|
|
44
|
+
/** The six pieces a session bootstrap loads, in load order. Used for the load-count ledger.
|
|
45
|
+
* HS-X3S1 (DR3) added `doctrine`: the SpexCode Doctrine block (../doctrine/inject.mjs), so
|
|
46
|
+
* every seat's bootstrap — not just the chat front's system message — carries it. HS-3H6E
|
|
47
|
+
* (P4) added `profile`, LAST: the SpexCode Profile block (../profile/learn.mjs), layered
|
|
48
|
+
* AFTER `doctrine` — enforced SpexDoctrine, then learned SpexCode Doctrine, then
|
|
49
|
+
* per-developer SpexCode Profile, per the three-layers decision memory. */
|
|
50
|
+
export const BOOTSTRAP_PIECES = Object.freeze(['seatContext', 'wip', 'memories', 'procedures', 'doctrine', 'profile']);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Preflight intent → the protocol slugs that intent binds, mirroring the intents
|
|
54
|
+
* `heyspex_preflight` accepts. Only slugs that actually exist under specs/protocols for the
|
|
55
|
+
* given root are returned (a stripped-down worktree yields fewer, never a throw), and an
|
|
56
|
+
* unknown intent yields none rather than guessing.
|
|
57
|
+
* @type {Readonly<Record<string, ReadonlyArray<string>>>}
|
|
58
|
+
*/
|
|
59
|
+
export const DEFAULT_INTENT_PROCEDURES = Object.freeze({
|
|
60
|
+
execute_spec: Object.freeze(['pre-execution-alignment-gate', 'session-close-protocol']),
|
|
61
|
+
claim_spec: Object.freeze(['pre-execution-alignment-gate']),
|
|
62
|
+
file_milestone: Object.freeze(['plan-ingest-agent-workflow', 'roadmap-protocol', 'spec-summary-authoring']),
|
|
63
|
+
file_epic: Object.freeze(['plan-ingest-agent-workflow', 'roadmap-protocol', 'spec-summary-authoring']),
|
|
64
|
+
file_task: Object.freeze(['spec-summary-authoring', 'checks-manifest-authoring']),
|
|
65
|
+
memory_creation: Object.freeze(['memory-protocol']),
|
|
66
|
+
close_session: Object.freeze(['session-close-protocol']),
|
|
67
|
+
destructive_action: Object.freeze(['destructive-action-checklist']),
|
|
68
|
+
protocol_edit: Object.freeze(['rule-placement-protocol']),
|
|
69
|
+
server_impacting_action: Object.freeze(['dev-server-lifecycle-and-db-safety']),
|
|
70
|
+
restructure_content: Object.freeze(['internal-linking-protocol']),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** Read a JSON file, or return null for missing/unreadable/corrupt — never throws. */
|
|
74
|
+
function readJsonSoft(filePath) {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pull `tags:` out of a markdown file's YAML frontmatter — both the block form
|
|
84
|
+
* (`tags:\n - ui\n - console`) and the inline form (`tags: [ui, console]`).
|
|
85
|
+
* Best-effort and allocation-light: this runs once per memory file at session start, so it
|
|
86
|
+
* stays a frontmatter scan rather than a YAML parse. Unreadable file → [].
|
|
87
|
+
*
|
|
88
|
+
* @param {string} filePath
|
|
89
|
+
* @returns {string[]}
|
|
90
|
+
*/
|
|
91
|
+
function readFrontmatterTags(filePath) {
|
|
92
|
+
let text;
|
|
93
|
+
try {
|
|
94
|
+
text = readFileSync(filePath, 'utf8');
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
if (!text.startsWith('---')) return [];
|
|
99
|
+
const end = text.indexOf('\n---', 3);
|
|
100
|
+
const frontmatter = end === -1 ? text : text.slice(0, end);
|
|
101
|
+
const lines = frontmatter.split('\n');
|
|
102
|
+
const tags = [];
|
|
103
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
104
|
+
const match = /^tags:\s*(.*)$/.exec(lines[i]);
|
|
105
|
+
if (!match) continue;
|
|
106
|
+
const inline = match[1].trim();
|
|
107
|
+
if (inline.startsWith('[')) {
|
|
108
|
+
for (const raw of inline.replace(/^\[|\]$/g, '').split(',')) {
|
|
109
|
+
const tag = raw.trim().replace(/^['"]|['"]$/g, '');
|
|
110
|
+
if (tag) tags.push(tag);
|
|
111
|
+
}
|
|
112
|
+
} else if (inline) {
|
|
113
|
+
tags.push(inline.replace(/^['"]|['"]$/g, ''));
|
|
114
|
+
}
|
|
115
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
116
|
+
const item = /^\s*-\s*(.+?)\s*$/.exec(lines[j]);
|
|
117
|
+
if (!item) break;
|
|
118
|
+
tags.push(item[1].replace(/^['"]|['"]$/g, ''));
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
return tags;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The tags a seat recalls memories by: the seat row's own `tags` when it has them, else the
|
|
127
|
+
* identity it does have (its id and group) — so a seat with no curated tag list still
|
|
128
|
+
* recalls something targeted instead of everything.
|
|
129
|
+
*
|
|
130
|
+
* @param {object|null} seat a dev-factory/seats.json seat record.
|
|
131
|
+
* @returns {string[]}
|
|
132
|
+
*/
|
|
133
|
+
export function seatMemoryTags(seat) {
|
|
134
|
+
if (Array.isArray(seat?.tags) && seat.tags.length > 0) {
|
|
135
|
+
return seat.tags.filter((t) => typeof t === 'string' && t.length > 0);
|
|
136
|
+
}
|
|
137
|
+
return [seat?.id, seat?.group].filter((t) => typeof t === 'string' && t.length > 0);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The intent a session preflights for: the caller's, else the seat row's, else the default. */
|
|
141
|
+
function resolveIntent(explicitIntent, seat) {
|
|
142
|
+
return explicitIntent ?? seat?.defaultIntent ?? DEFAULT_INTENT;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* ------------------------------------------------------------------ default loaders --- */
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Default seat-context loader: spexharness/src/seat-context.mjs's loadSeatContext, which is
|
|
149
|
+
* itself the shared dev-factory builder. Not reimplemented, and called exactly once.
|
|
150
|
+
*/
|
|
151
|
+
function defaultSeatContextLoader({ root, seatId, vaultRoot }) {
|
|
152
|
+
return loadSeatContext({ root, seatId, vaultRoot });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Default WIP-block loader. The system of record for a WIP block is the Neon lane, which a
|
|
157
|
+
* front with a live connection injects as `loaders.wip`; this default is the offline
|
|
158
|
+
* fallback — a best-effort read of a JSON lane file, keyed by seat when the file is a map of
|
|
159
|
+
* seats. Missing/corrupt/absent seat → null, never a throw, so a seat with no prior WIP
|
|
160
|
+
* starts clean instead of failing to start.
|
|
161
|
+
*/
|
|
162
|
+
function defaultWipLoader({ root, seatId, wipLanePath }) {
|
|
163
|
+
const lanePath = wipLanePath ?? path.join(root, '.heyspex', 'session-wip.json');
|
|
164
|
+
const lane = readJsonSoft(lanePath);
|
|
165
|
+
if (!lane || typeof lane !== 'object') return null;
|
|
166
|
+
if (seatId && Object.prototype.hasOwnProperty.call(lane, seatId)) return lane[seatId] ?? null;
|
|
167
|
+
if (Object.prototype.hasOwnProperty.call(lane, 'wip')) return lane.wip ?? null;
|
|
168
|
+
return lane;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Default memories loader — derived from the ALREADY-LOADED seat context, so this does not
|
|
173
|
+
* re-walk specs/memory. Returns `{ slug, tags }` for each memory whose frontmatter tags
|
|
174
|
+
* intersect the seat's tags; a seat that resolves no tags (no seat row at all) recalls the
|
|
175
|
+
* full list unfiltered, matching loadSeatContext's own untargeted view.
|
|
176
|
+
*
|
|
177
|
+
* A seat whose tags match NO memory at all (the common shape today: seats.json rows carry
|
|
178
|
+
* no `tags`, so the fallback identity tags — the seat id and group — match nothing in a
|
|
179
|
+
* vault whose memories are tagged by domain) also falls back to the full list rather than
|
|
180
|
+
* reporting an empty recall: the memories exist and are the seat's to read, the seat merely
|
|
181
|
+
* has no curated subset yet. `targeted: false` on each entry says which case produced it.
|
|
182
|
+
*/
|
|
183
|
+
function defaultMemoriesLoader({ root, vaultRoot, seatContext }) {
|
|
184
|
+
const slugs = Array.isArray(seatContext?.memories) ? seatContext.memories : [];
|
|
185
|
+
const wanted = seatMemoryTags(seatContext?.seat);
|
|
186
|
+
const specsRoot = typeof vaultRoot === 'string' && vaultRoot.length > 0 ? vaultRoot : root;
|
|
187
|
+
const memoryRoot = path.join(specsRoot, 'specs', 'memory');
|
|
188
|
+
const all = slugs.map((slug) => ({ slug, tags: readFrontmatterTags(path.join(memoryRoot, `${slug}.md`)) }));
|
|
189
|
+
if (wanted.length === 0) return all;
|
|
190
|
+
const wantedSet = new Set(wanted);
|
|
191
|
+
const targeted = all.filter((m) => m.tags.some((tag) => wantedSet.has(tag)));
|
|
192
|
+
if (targeted.length > 0) return targeted.map((m) => ({ ...m, targeted: true }));
|
|
193
|
+
return all.map((m) => ({ ...m, targeted: false }));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Default preflight-procedure loader — also derived from the already-loaded seat context.
|
|
198
|
+
* Returns the slugs {@link DEFAULT_INTENT_PROCEDURES} binds to the session's intent, kept
|
|
199
|
+
* only when specs/protocols actually carries them. Unknown intent → [].
|
|
200
|
+
*/
|
|
201
|
+
function defaultProceduresLoader({ intent, seatContext, intentProcedures }) {
|
|
202
|
+
const table = intentProcedures ?? DEFAULT_INTENT_PROCEDURES;
|
|
203
|
+
const wanted = table[intent] ?? [];
|
|
204
|
+
const available = new Set(Array.isArray(seatContext?.procedures) ? seatContext.procedures : []);
|
|
205
|
+
return wanted.filter((slug) => available.has(slug));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Default Doctrine loader (HS-X3S1, DR3): reads `<root>/spexharness/doctrine/
|
|
210
|
+
* SPEXCODE-DOCTRINE.json` (../doctrine/inject.mjs's `loadDoctrine`, cached, tolerant of
|
|
211
|
+
* absence) and renders it within its own documented budget (`renderDoctrineBlock`,
|
|
212
|
+
* DOCTRINE_MAX_TOKENS) — never the full unbounded document. A repo with no Doctrine yet
|
|
213
|
+
* still starts: the render degrades to the absence notice rather than throwing.
|
|
214
|
+
*/
|
|
215
|
+
function defaultDoctrineLoader({ root }) {
|
|
216
|
+
const doctrine = loadDoctrine({ path: defaultDoctrinePath(root) });
|
|
217
|
+
return renderDoctrineBlock(doctrine, { maxTokens: DOCTRINE_MAX_TOKENS });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The profile is "ready" to condition a turn only once BOTH are true: the rendered file
|
|
222
|
+
* exists on disk, AND P2's incremental-state mark (`.heyspex/profile-state.json`'s
|
|
223
|
+
* `lastLearnedAt`, ../profile/learn.mjs's `loadProfileState`) says a learning pass actually
|
|
224
|
+
* completed. `buildProfile` (learn.mjs) and `accept` (proposals.mjs, P6) both write the
|
|
225
|
+
* profile file BEFORE saving state, so checking only file-existence would read a mid-write
|
|
226
|
+
* (or stale, pre-first-run) file as done; requiring the state mark too closes that race.
|
|
227
|
+
*
|
|
228
|
+
* Exported (P8, HS-NRCK — plugin parity): the Claude Code plugin's own injection hook
|
|
229
|
+
* (scripts/claude-hooks/inject-vault-context.mjs, via its gate
|
|
230
|
+
* spexharness/src/gates/inject-vault-context.mjs) is a thin shell over THIS function and
|
|
231
|
+
* {@link defaultProfileLoader} below — it never re-implements the readiness check or the
|
|
232
|
+
* rendering, so a plugin session and a SpexCode seat's own bootstrap see byte-identical
|
|
233
|
+
* profile content.
|
|
234
|
+
*
|
|
235
|
+
* @param {{ root: string }} args
|
|
236
|
+
* @returns {boolean}
|
|
237
|
+
*/
|
|
238
|
+
export function isProfileReady({ root }) {
|
|
239
|
+
if (!existsSync(defaultProfilePath(root))) return false;
|
|
240
|
+
const state = loadProfileState(defaultProfileStatePath(root));
|
|
241
|
+
return typeof state.lastLearnedAt === 'string' && state.lastLearnedAt.length > 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Render the profile file's markdown into the same shape {@link defaultDoctrineLoader}
|
|
246
|
+
* renders Doctrine into — `{ text, tokens, chars, learnedAt }` — measured through
|
|
247
|
+
* ../token-inventory.mjs's `estimateTokens` (the same chars/4 estimate every other piece's
|
|
248
|
+
* size comes from), so a caller that records this block through
|
|
249
|
+
* ../context-budget.mjs's `recordContextInjection` (P2's `registerProfileContextUsage`
|
|
250
|
+
* call shape) gets a real token count, not a guess.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} markdown
|
|
253
|
+
* @param {{ learnedAt?: string|null, maxTokens?: number }} [opts] - `maxTokens` defaults to
|
|
254
|
+
* ../profile/learn.mjs's {@link PROFILE_MAX_TOKENS}, mirroring how the doctrine loader
|
|
255
|
+
* above passes DOCTRINE_MAX_TOKENS to `renderDoctrineBlock`.
|
|
256
|
+
* @returns {{ text: string, tokens: number, chars: number, learnedAt: string|null, truncated: boolean }}
|
|
257
|
+
*/
|
|
258
|
+
export function renderProfileBlock(markdown, { learnedAt = null, maxTokens = PROFILE_MAX_TOKENS } = {}) {
|
|
259
|
+
const full = String(markdown ?? '').replace(/\n+$/, '');
|
|
260
|
+
const budget = Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : Infinity;
|
|
261
|
+
|
|
262
|
+
// Tail cut on WHOLE lines, the same convention renderDoctrineBlock uses: the first line
|
|
263
|
+
// that would overflow, and every line after it, are dropped together, so the block never
|
|
264
|
+
// ends mid-citation and a cheap later line never jumps the queue past a dropped one.
|
|
265
|
+
//
|
|
266
|
+
// This is the load-bearing containment: it bounds what reaches a seat's context REGARDLESS
|
|
267
|
+
// of what is on disk. The profile file on this machine had grown to 4.2MB (~1.06M tokens)
|
|
268
|
+
// and was injected whole, on EVERY user prompt, which is what pushed a 1M-window model
|
|
269
|
+
// past its limit before the developer's own message was even appended.
|
|
270
|
+
const lines = full.split('\n');
|
|
271
|
+
const kept = [];
|
|
272
|
+
let usedTokens = 0;
|
|
273
|
+
let truncated = false;
|
|
274
|
+
for (const line of lines) {
|
|
275
|
+
const lineTokens = estimateTokens(`${line}\n`);
|
|
276
|
+
if (usedTokens + lineTokens > budget) {
|
|
277
|
+
truncated = true;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
usedTokens += lineTokens;
|
|
281
|
+
kept.push(line);
|
|
282
|
+
}
|
|
283
|
+
if (truncated) kept.push('', `_(profile truncated to its ${budget}-token injection budget)_`);
|
|
284
|
+
|
|
285
|
+
const text = kept.join('\n').replace(/\n+$/, '');
|
|
286
|
+
return { text, tokens: estimateTokens(text), chars: text.length, learnedAt, truncated };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Default Profile loader (HS-3H6E, P4 — Conditioning, epic HS-HEBV scope item 4): once
|
|
291
|
+
* {@link isProfileReady} is true, reads `.heyspex/spexcode-profile.md`
|
|
292
|
+
* (../profile/learn.mjs's `defaultProfilePath`) — written only by `buildProfile`
|
|
293
|
+
* (learn.mjs, direct fold) or `accept` (proposals.mjs, P6's curated accept/decline gate),
|
|
294
|
+
* so every line already on disk is an accepted, non-pending entry — and renders it.
|
|
295
|
+
*
|
|
296
|
+
* Returns `null`, never a placeholder block, until the learner has completed for this
|
|
297
|
+
* machine: a turn whose bootstrap resolves before then must carry NO profile section at
|
|
298
|
+
* all (absent, not an empty one) — unlike {@link defaultDoctrineLoader}, which always
|
|
299
|
+
* renders something (an absence notice) because Doctrine is expected to exist from the
|
|
300
|
+
* start of a vault's life, while a fresh machine's Profile legitimately has not learned
|
|
301
|
+
* anything yet.
|
|
302
|
+
*/
|
|
303
|
+
export function defaultProfileLoader({ root }) {
|
|
304
|
+
if (!isProfileReady({ root })) return null;
|
|
305
|
+
let markdown;
|
|
306
|
+
try {
|
|
307
|
+
markdown = readFileSync(defaultProfilePath(root), 'utf8');
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
if (!markdown || !markdown.trim()) return null;
|
|
312
|
+
const state = loadProfileState(defaultProfileStatePath(root));
|
|
313
|
+
|
|
314
|
+
// Prefer the structured digest (../profile/learn.mjs's `renderProfileDigest`): it budgets
|
|
315
|
+
// FAIRLY across sections, so "Recurring corrections" and "Tools approved or refused" still
|
|
316
|
+
// reach the seat instead of being starved by a long first section. The markdown file itself
|
|
317
|
+
// stays the complete browsable record and is deliberately NOT what gets injected.
|
|
318
|
+
//
|
|
319
|
+
// The markdown path below remains as the fallback for a state file that is missing or
|
|
320
|
+
// carries no sections (an older build, a hand-written profile) — and `renderProfileBlock`
|
|
321
|
+
// clamps that path too, so neither route can put an unbounded file into a context window.
|
|
322
|
+
const hasSections = Object.values(state?.sections ?? {}).some((s) => Array.isArray(s) && s.length > 0);
|
|
323
|
+
if (hasSections) {
|
|
324
|
+
const digest = renderProfileDigest(state.sections, { maxTokens: PROFILE_MAX_TOKENS, estimate: estimateTokens });
|
|
325
|
+
// On this path the digest is the authority on the budget — it already selected entries to
|
|
326
|
+
// fit. The outer clamp stays on as a BACKSTOP (a bug in the digest still cannot put an
|
|
327
|
+
// unbounded block into context) but is given headroom so it does not fight the digest's
|
|
328
|
+
// own accounting: the two round differently (the clamp sums a ceil per line, the digest
|
|
329
|
+
// sums a ceil per entry), and a clamp set to the same number trims the digest's closing
|
|
330
|
+
// footer every time purely on rounding drift.
|
|
331
|
+
return renderProfileBlock(digest.text, { learnedAt: state.lastLearnedAt, maxTokens: PROFILE_MAX_TOKENS * 2 });
|
|
332
|
+
}
|
|
333
|
+
return renderProfileBlock(markdown, { learnedAt: state.lastLearnedAt });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const DEFAULT_LOADERS = Object.freeze({
|
|
337
|
+
seatContext: defaultSeatContextLoader,
|
|
338
|
+
wip: defaultWipLoader,
|
|
339
|
+
memories: defaultMemoriesLoader,
|
|
340
|
+
procedures: defaultProceduresLoader,
|
|
341
|
+
doctrine: defaultDoctrineLoader,
|
|
342
|
+
profile: defaultProfileLoader,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
/* ---------------------------------------------------------------------- the bootstrap --- */
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* @typedef {object} SessionBootstrapBlock
|
|
349
|
+
* @property {string} sessionId this session's id (generated when the caller has none).
|
|
350
|
+
* @property {string|null} seatId the seat this session runs as.
|
|
351
|
+
* @property {string|null} front the entry point that started it ('deepseek', 'local-model', 'chat', …) — recorded, never branched on.
|
|
352
|
+
* @property {string} intent the preflight intent the procedures were loaded for.
|
|
353
|
+
* @property {string} startedAt ISO timestamp of the bootstrap.
|
|
354
|
+
* @property {object} seatContext loadSeatContext()'s result: seatLine, seat, procedures, skills, memories.
|
|
355
|
+
* @property {unknown} wip the WIP block for this seat (null when there is none).
|
|
356
|
+
* @property {Array<{slug: string, tags: string[]}>} memories memories recalled by seat tag.
|
|
357
|
+
* @property {string[]} procedures preflight procedure slugs bound to `intent`.
|
|
358
|
+
* @property {{ text: string, tokens: number, chars: number, principleCount: number,
|
|
359
|
+
* includedCount: number, learnedAt: string|null, truncated: boolean }} doctrine
|
|
360
|
+
* the rendered SpexCode Doctrine block (HS-X3S1/DR3, ../doctrine/inject.mjs's
|
|
361
|
+
* `renderDoctrineBlock`); absence renders as a notice, never a throw.
|
|
362
|
+
* @property {{ text: string, tokens: number, chars: number, learnedAt: string|null }|null} profile
|
|
363
|
+
* the rendered SpexCode Profile block (HS-3H6E/P4, ../profile/learn.mjs), injected AFTER
|
|
364
|
+
* `doctrine` — enforced SpexDoctrine, then learned SpexCode Doctrine, then per-developer
|
|
365
|
+
* SpexCode Profile. `null` — ABSENT, not an empty placeholder — until the learner has
|
|
366
|
+
* completed for this machine; a turn whose bootstrap resolves before then never carries a
|
|
367
|
+
* profile section at all.
|
|
368
|
+
* @property {Readonly<Record<string, number>>} loadCounts how many times each piece was loaded — 1 each, per HS-QEXH AC1.
|
|
369
|
+
* @property {Array<{piece: string, error: string}>} errors loaders that failed (the piece degrades to empty; a session still starts).
|
|
370
|
+
*/
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Create a session bootstrap handle. The handle memoizes: `load()` runs the six loaders on
|
|
374
|
+
* the first call and returns the SAME block (and the same load counts) on every later call,
|
|
375
|
+
* which is what makes "exactly once per session" true even when several parts of a front ask
|
|
376
|
+
* for the session block independently.
|
|
377
|
+
*
|
|
378
|
+
* @param {{
|
|
379
|
+
* root?: string,
|
|
380
|
+
* vaultRoot?: string|null,
|
|
381
|
+
* seatId?: string|null,
|
|
382
|
+
* sessionId?: string|null,
|
|
383
|
+
* front?: string|null,
|
|
384
|
+
* intent?: string|null,
|
|
385
|
+
* wipLanePath?: string|null,
|
|
386
|
+
* intentProcedures?: Record<string, ReadonlyArray<string>>|null,
|
|
387
|
+
* loaders?: Partial<{ seatContext: Function, wip: Function, memories: Function, procedures: Function, doctrine: Function, profile: Function }>,
|
|
388
|
+
* }} [options]
|
|
389
|
+
* `vaultRoot` — the SPEC vault root (specs/protocols, specs/memory) when it is not the
|
|
390
|
+
* repo root; forwarded to every loader and to loadSeatContext. Omitted → `root`.
|
|
391
|
+
* @returns {{
|
|
392
|
+
* sessionId: string,
|
|
393
|
+
* seatId: string|null,
|
|
394
|
+
* front: string|null,
|
|
395
|
+
* load: () => Promise<SessionBootstrapBlock>,
|
|
396
|
+
* loaded: () => SessionBootstrapBlock|null,
|
|
397
|
+
* loadCounts: () => Readonly<Record<string, number>>,
|
|
398
|
+
* }}
|
|
399
|
+
*/
|
|
400
|
+
export function createSessionBootstrap(options = {}) {
|
|
401
|
+
const {
|
|
402
|
+
root = repoRoot(),
|
|
403
|
+
vaultRoot = null,
|
|
404
|
+
seatId = null,
|
|
405
|
+
sessionId = null,
|
|
406
|
+
front = null,
|
|
407
|
+
intent = null,
|
|
408
|
+
wipLanePath = null,
|
|
409
|
+
intentProcedures = null,
|
|
410
|
+
loaders = {},
|
|
411
|
+
} = options;
|
|
412
|
+
|
|
413
|
+
const resolvedSessionId = sessionId ?? randomUUID();
|
|
414
|
+
const loaderSet = { ...DEFAULT_LOADERS, ...loaders };
|
|
415
|
+
const counts = Object.fromEntries(BOOTSTRAP_PIECES.map((piece) => [piece, 0]));
|
|
416
|
+
const errors = [];
|
|
417
|
+
|
|
418
|
+
/** @type {SessionBootstrapBlock|null} */
|
|
419
|
+
let block = null;
|
|
420
|
+
/** @type {Promise<SessionBootstrapBlock>|null} */
|
|
421
|
+
let inFlight = null;
|
|
422
|
+
|
|
423
|
+
/** Run one loader, counting the call and folding a throw into a recorded error + fallback. */
|
|
424
|
+
async function runLoader(piece, args, fallback) {
|
|
425
|
+
counts[piece] += 1;
|
|
426
|
+
try {
|
|
427
|
+
return await loaderSet[piece](args);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
errors.push({ piece, error: err instanceof Error ? err.message : String(err) });
|
|
430
|
+
return fallback;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function loadOnce() {
|
|
435
|
+
const base = { root, vaultRoot, seatId, sessionId: resolvedSessionId, front, wipLanePath, intentProcedures };
|
|
436
|
+
|
|
437
|
+
// Seat context first and once — the other three are derived from it rather than
|
|
438
|
+
// re-reading seats.json / specs/ per piece. This is the "arrives three ways" fix.
|
|
439
|
+
const seatContext = (await runLoader('seatContext', base, null)) ?? {
|
|
440
|
+
seatLine: null,
|
|
441
|
+
seat: null,
|
|
442
|
+
procedures: [],
|
|
443
|
+
skills: [],
|
|
444
|
+
memories: [],
|
|
445
|
+
};
|
|
446
|
+
const resolvedIntent = resolveIntent(intent, seatContext.seat);
|
|
447
|
+
const withContext = { ...base, seatContext, intent: resolvedIntent };
|
|
448
|
+
|
|
449
|
+
const [wip, memories, procedures, doctrine, profile] = await Promise.all([
|
|
450
|
+
runLoader('wip', withContext, null),
|
|
451
|
+
runLoader('memories', withContext, []),
|
|
452
|
+
runLoader('procedures', withContext, []),
|
|
453
|
+
// A failed doctrine loader degrades to the SAME absence-notice shape a missing file
|
|
454
|
+
// renders to (renderDoctrineBlock(null)) — never `null` itself, so every consumer can
|
|
455
|
+
// read `block.doctrine.text` unconditionally, exactly like the other three pieces.
|
|
456
|
+
runLoader('doctrine', withContext, renderDoctrineBlock(null)),
|
|
457
|
+
// A failed (or not-yet-ready) profile loader degrades to `null` — ABSENCE, not a
|
|
458
|
+
// placeholder block, matching {@link defaultProfileLoader}'s own not-ready return.
|
|
459
|
+
runLoader('profile', withContext, null),
|
|
460
|
+
]);
|
|
461
|
+
|
|
462
|
+
block = Object.freeze({
|
|
463
|
+
sessionId: resolvedSessionId,
|
|
464
|
+
seatId,
|
|
465
|
+
front,
|
|
466
|
+
intent: resolvedIntent,
|
|
467
|
+
startedAt: new Date().toISOString(),
|
|
468
|
+
seatContext,
|
|
469
|
+
wip: wip ?? null,
|
|
470
|
+
memories: memories ?? [],
|
|
471
|
+
procedures: procedures ?? [],
|
|
472
|
+
doctrine: doctrine ?? renderDoctrineBlock(null),
|
|
473
|
+
profile: profile ?? null,
|
|
474
|
+
loadCounts: Object.freeze({ ...counts }),
|
|
475
|
+
errors: Object.freeze(errors.slice()),
|
|
476
|
+
});
|
|
477
|
+
return block;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
sessionId: resolvedSessionId,
|
|
482
|
+
seatId,
|
|
483
|
+
front,
|
|
484
|
+
/** Load (or return the already-loaded) session block. Concurrent callers share one load. */
|
|
485
|
+
load() {
|
|
486
|
+
if (!inFlight) inFlight = loadOnce();
|
|
487
|
+
return inFlight;
|
|
488
|
+
},
|
|
489
|
+
/** The block if it has already loaded, else null — for a front that must not trigger a load. */
|
|
490
|
+
loaded() {
|
|
491
|
+
return block;
|
|
492
|
+
},
|
|
493
|
+
/** Live per-piece load-count ledger — every entry is 1 once a session has started. */
|
|
494
|
+
loadCounts() {
|
|
495
|
+
return Object.freeze({ ...counts });
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* One-shot form: bootstrap a session and return its block. This is what a seat entry point
|
|
502
|
+
* calls — a DeepSeek seat, a 64k local-model seat, `hs chat`, or a headless overnight run all
|
|
503
|
+
* call this same function with a different `front`/`seatId` and nothing else different.
|
|
504
|
+
*
|
|
505
|
+
* @param {Parameters<typeof createSessionBootstrap>[0]} [options]
|
|
506
|
+
* @returns {Promise<SessionBootstrapBlock>}
|
|
507
|
+
*/
|
|
508
|
+
export function bootstrapSession(options = {}) {
|
|
509
|
+
return createSessionBootstrap(options).load();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export default { createSessionBootstrap, bootstrapSession };
|