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,252 @@
|
|
|
1
|
+
// spexharness/src/doctrine/sources.mjs
|
|
2
|
+
//
|
|
3
|
+
// DR1 (epic [[HS-KNHZ-8CMW-EPIC-spexcode-doctrine-v0-the-foundational]], task
|
|
4
|
+
// [[HS-TWTC-8CMW-TASK-dr1-doctrine-corpus-reader-stream-every]]) — enumerates every
|
|
5
|
+
// source the Doctrine corpus reader draws from, verified on this machine 2026-09-11:
|
|
6
|
+
//
|
|
7
|
+
// - Claude Code sessions: ~/.claude/projects/<project>/*.jsonl — SCOPED TO THIS REPO'S
|
|
8
|
+
// project directory by default since 2026-09-20 (see `claudeCodeProjectSlug`); other
|
|
9
|
+
// projects only on the explicit `allClaudeProjects` / `claudeProjects` opt-in.
|
|
10
|
+
// - Command Code sessions: ~/.commandcode/projects/<project>/<id>.jsonl — the
|
|
11
|
+
// sibling `<id>.checkpoints.jsonl` is a DIFFERENT, denser log of the same
|
|
12
|
+
// session (checkpoint snapshots) and is deliberately excluded: reading it too
|
|
13
|
+
// would double-count every Command Code session.
|
|
14
|
+
// - The Command Code taste profile(s): <repo>/.commandcode/taste/taste/taste.md
|
|
15
|
+
// (this repo, and the sibling spexvr checkout named in the task brief).
|
|
16
|
+
// - This harness's own session-end export: .heyspex/session-end-events.jsonl
|
|
17
|
+
// (written by scripts/claude-hooks/session-end.mjs's createLocalEventsExporter,
|
|
18
|
+
// itself a thin wrapper over session-record.mjs's appendSessionRecord).
|
|
19
|
+
//
|
|
20
|
+
// Pure enumeration: fs.readdirSync + fs.statSync only, never a file read — corpus.mjs
|
|
21
|
+
// does the streaming content read. Every root is injectable so a test never touches
|
|
22
|
+
// the real machine's ~/.claude or ~/.commandcode directories.
|
|
23
|
+
|
|
24
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { basename, join } from 'node:path';
|
|
27
|
+
|
|
28
|
+
/** Default Claude Code projects root. */
|
|
29
|
+
export function defaultClaudeProjectsDir() {
|
|
30
|
+
return join(homedir(), '.claude', 'projects');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The Claude Code project-directory name for a working directory. Claude Code names each
|
|
35
|
+
* subdirectory of `~/.claude/projects/` after the session's cwd with every non-alphanumeric
|
|
36
|
+
* character replaced by `-` (verified on this machine 2026-09-20: `C:\dev-projects\heyspex-app`
|
|
37
|
+
* → `C--dev-projects-heyspex-app`).
|
|
38
|
+
*
|
|
39
|
+
* This exists because the corpus reader is SCOPED TO THIS REPO by default. Harvesting every
|
|
40
|
+
* project under `~/.claude/projects/` mixed five other checkouts — and a `C:\Users\...\Temp`
|
|
41
|
+
* scratch directory used by a sidecar SDK prompt — into a Doctrine document that is COMMITTED
|
|
42
|
+
* TO GIT, so one machine's unrelated work shipped to every clone. Only 33% of the 5,328-row
|
|
43
|
+
* corpus measured on 2026-09-20 came from this repo.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} repoRoot
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function claudeCodeProjectSlug(repoRoot) {
|
|
49
|
+
return String(repoRoot ?? '').replace(/[^A-Za-z0-9]/g, '-');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Default Command Code projects root. */
|
|
53
|
+
export function defaultCommandCodeProjectsDir() {
|
|
54
|
+
return join(homedir(), '.commandcode', 'projects');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Default taste.md candidates — this repo's own compiled profile plus the sibling
|
|
59
|
+
* spexvr checkout named in the task brief. A path that does not exist is silently
|
|
60
|
+
* skipped by {@link enumerateTasteSources}, so listing a candidate that is absent on
|
|
61
|
+
* a given machine is harmless.
|
|
62
|
+
*/
|
|
63
|
+
export function defaultTasteFiles(repoRoot) {
|
|
64
|
+
return [
|
|
65
|
+
join(repoRoot, '.commandcode', 'taste', 'taste', 'taste.md'),
|
|
66
|
+
join(repoRoot, '..', 'spexvr', '.commandcode', 'taste', 'taste', 'taste.md'),
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Default harness session-end export path — see scripts/claude-hooks/session-end.mjs
|
|
72
|
+
* `sessionEventsExportPath`. This module does not import that script (it lives in
|
|
73
|
+
* scripts/, this module in spexharness/) so the default is re-derived here; the two
|
|
74
|
+
* must stay in step (both are `<root>/.heyspex/session-end-events.jsonl`).
|
|
75
|
+
*/
|
|
76
|
+
export function defaultSpexcodeEventsPath(repoRoot) {
|
|
77
|
+
return join(repoRoot, '.heyspex', 'session-end-events.jsonl');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function safeReaddir(dir) {
|
|
81
|
+
try {
|
|
82
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function safeStat(path) {
|
|
89
|
+
try {
|
|
90
|
+
return statSync(path);
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Enumerate every Claude Code session file under `dir` (one subdirectory per project).
|
|
98
|
+
*
|
|
99
|
+
* `projects` restricts the walk to the named project directories. Omitted (or `null`) it
|
|
100
|
+
* walks every subdirectory — the historical behaviour, kept so a caller that has already
|
|
101
|
+
* decided which roots to read (and every existing test) is unaffected. {@link enumerateSources}
|
|
102
|
+
* is where the SCOPED-TO-THIS-REPO default lives.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} dir
|
|
105
|
+
* @param {{ projects?: string[]|null }} [opts]
|
|
106
|
+
* @returns {Array<{ source: 'claude-code', project: string, session: string, path: string, mtime: number }>}
|
|
107
|
+
*/
|
|
108
|
+
export function enumerateClaudeCodeSources(dir, { projects = null } = {}) {
|
|
109
|
+
const out = [];
|
|
110
|
+
const allow = Array.isArray(projects) ? new Set(projects) : null;
|
|
111
|
+
for (const projectEnt of safeReaddir(dir)) {
|
|
112
|
+
if (!projectEnt.isDirectory()) continue;
|
|
113
|
+
const project = projectEnt.name;
|
|
114
|
+
if (allow && !allow.has(project)) continue;
|
|
115
|
+
const projectDir = join(dir, project);
|
|
116
|
+
for (const fileEnt of safeReaddir(projectDir)) {
|
|
117
|
+
if (!fileEnt.isFile() || !fileEnt.name.endsWith('.jsonl')) continue;
|
|
118
|
+
const filePath = join(projectDir, fileEnt.name);
|
|
119
|
+
const stat = safeStat(filePath);
|
|
120
|
+
if (!stat) continue;
|
|
121
|
+
out.push({
|
|
122
|
+
source: 'claude-code',
|
|
123
|
+
project,
|
|
124
|
+
session: fileEnt.name.replace(/\.jsonl$/, ''),
|
|
125
|
+
path: filePath,
|
|
126
|
+
mtime: stat.mtimeMs,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Enumerate every Command Code session file under `dir`, excluding the
|
|
135
|
+
* `.checkpoints.jsonl` sidecar (see module header).
|
|
136
|
+
* @param {string} dir
|
|
137
|
+
* @returns {Array<{ source: 'command-code', project: string, session: string, path: string, mtime: number }>}
|
|
138
|
+
*/
|
|
139
|
+
export function enumerateCommandCodeSources(dir) {
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const projectEnt of safeReaddir(dir)) {
|
|
142
|
+
if (!projectEnt.isDirectory()) continue;
|
|
143
|
+
const project = projectEnt.name;
|
|
144
|
+
const projectDir = join(dir, project);
|
|
145
|
+
for (const fileEnt of safeReaddir(projectDir)) {
|
|
146
|
+
if (!fileEnt.isFile()) continue;
|
|
147
|
+
if (!fileEnt.name.endsWith('.jsonl') || fileEnt.name.endsWith('.checkpoints.jsonl')) continue;
|
|
148
|
+
const filePath = join(projectDir, fileEnt.name);
|
|
149
|
+
const stat = safeStat(filePath);
|
|
150
|
+
if (!stat) continue;
|
|
151
|
+
out.push({
|
|
152
|
+
source: 'command-code',
|
|
153
|
+
project,
|
|
154
|
+
session: fileEnt.name.replace(/\.jsonl$/, ''),
|
|
155
|
+
path: filePath,
|
|
156
|
+
mtime: stat.mtimeMs,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Enumerate taste-profile sources — each existing file in `files` yields one source.
|
|
165
|
+
* `project` is read off the path itself (the directory just above `.commandcode`),
|
|
166
|
+
* falling back to `'unknown'` for a path shaped unlike the documented layout.
|
|
167
|
+
* @param {string[]} files
|
|
168
|
+
* @returns {Array<{ source: 'taste', project: string, session: 'taste', path: string, mtime: number }>}
|
|
169
|
+
*/
|
|
170
|
+
export function enumerateTasteSources(files) {
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const filePath of files) {
|
|
173
|
+
const stat = safeStat(filePath);
|
|
174
|
+
if (!stat || !stat.isFile()) continue;
|
|
175
|
+
// .../<repo>/.commandcode/taste/taste/taste.md — the repo name sits one segment
|
|
176
|
+
// above `.commandcode`.
|
|
177
|
+
const parts = filePath.split(/[\\/]/);
|
|
178
|
+
const idx = parts.lastIndexOf('.commandcode');
|
|
179
|
+
const project = idx > 0 ? parts[idx - 1] : 'unknown';
|
|
180
|
+
out.push({ source: 'taste', project, session: 'taste', path: filePath, mtime: stat.mtimeMs });
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Enumerate the harness's own session-end export, when present. Zero entries until a
|
|
187
|
+
* SessionEnd hook has actually appended to it on this machine — that file carries no
|
|
188
|
+
* `ask` text today (only events + tool-call log; see corpus.mjs's handler for the
|
|
189
|
+
* documented, forward-compatible handling of that shape).
|
|
190
|
+
* @param {string} filePath
|
|
191
|
+
* @param {{ project?: string }} [opts]
|
|
192
|
+
* @returns {Array<{ source: 'spexcode', project: string, session: string, path: string, mtime: number }>}
|
|
193
|
+
*/
|
|
194
|
+
export function enumerateSpexcodeSources(filePath, { project = 'heyspex-app' } = {}) {
|
|
195
|
+
const stat = safeStat(filePath);
|
|
196
|
+
if (!stat || !stat.isFile()) return [];
|
|
197
|
+
return [
|
|
198
|
+
{
|
|
199
|
+
source: 'spexcode',
|
|
200
|
+
project,
|
|
201
|
+
session: basename(filePath).replace(/\.jsonl$/, ''),
|
|
202
|
+
path: filePath,
|
|
203
|
+
mtime: stat.mtimeMs,
|
|
204
|
+
},
|
|
205
|
+
];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Enumerate every source the Doctrine corpus reader draws from. Every root is
|
|
210
|
+
* injectable (tests pass fixture dirs); omitted, each defaults to the real machine
|
|
211
|
+
* path / repo-relative path documented above.
|
|
212
|
+
*
|
|
213
|
+
* ── Claude Code scope (2026-09-20) ──────────────────────────────────────────────
|
|
214
|
+
* Claude Code sessions default to THIS REPO'S project directory only
|
|
215
|
+
* ({@link claudeCodeProjectSlug}). Other projects are opt-in and never implicit:
|
|
216
|
+
* - `allClaudeProjects: true` — every project directory under the root (the old behaviour).
|
|
217
|
+
* - `claudeProjects: ['C--dev-projects-spexvr', ...]` — an explicit list.
|
|
218
|
+
* `allClaudeProjects` wins over `claudeProjects` when both are given.
|
|
219
|
+
*
|
|
220
|
+
* @param {{
|
|
221
|
+
* claudeProjectsDir?: string,
|
|
222
|
+
* claudeProjects?: string[]|null,
|
|
223
|
+
* allClaudeProjects?: boolean,
|
|
224
|
+
* commandCodeProjectsDir?: string,
|
|
225
|
+
* tasteFiles?: string[],
|
|
226
|
+
* spexcodeEventsPath?: string,
|
|
227
|
+
* repoRoot?: string,
|
|
228
|
+
* }} [opts]
|
|
229
|
+
* @returns {Array<{ source: string, project: string, session: string, path: string, mtime: number }>}
|
|
230
|
+
*/
|
|
231
|
+
export function enumerateSources(opts = {}) {
|
|
232
|
+
const repoRoot = opts.repoRoot ?? process.cwd();
|
|
233
|
+
const claudeProjectsDir = opts.claudeProjectsDir ?? defaultClaudeProjectsDir();
|
|
234
|
+
const commandCodeProjectsDir = opts.commandCodeProjectsDir ?? defaultCommandCodeProjectsDir();
|
|
235
|
+
const tasteFiles = opts.tasteFiles ?? defaultTasteFiles(repoRoot);
|
|
236
|
+
const spexcodeEventsPath = opts.spexcodeEventsPath ?? defaultSpexcodeEventsPath(repoRoot);
|
|
237
|
+
const claudeProjects = opts.allClaudeProjects
|
|
238
|
+
? null
|
|
239
|
+
: (opts.claudeProjects ?? [claudeCodeProjectSlug(repoRoot)]);
|
|
240
|
+
|
|
241
|
+
return [
|
|
242
|
+
...enumerateClaudeCodeSources(claudeProjectsDir, { projects: claudeProjects }),
|
|
243
|
+
...enumerateCommandCodeSources(commandCodeProjectsDir),
|
|
244
|
+
...enumerateTasteSources(tasteFiles),
|
|
245
|
+
...enumerateSpexcodeSources(spexcodeEventsPath, { project: basename(repoRoot) }),
|
|
246
|
+
];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Stable per-session key used by corpus.mjs's incremental state. */
|
|
250
|
+
export function sourceKey(src) {
|
|
251
|
+
return `${src.source}:${src.project}:${src.session}`;
|
|
252
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// spexharness/src/doctrine/synthesize.mjs
|
|
2
|
+
//
|
|
3
|
+
// DR2 (epic [[HS-KNHZ-8CMW-EPIC-spexcode-doctrine-v0-the-foundational]], task
|
|
4
|
+
// [[HS-YMPY-8CMW-TASK-dr2-doctrine-synthesis-a-routed]]) — batches the observation
|
|
5
|
+
// stream corpus.mjs produced and asks the `research_synthesis` seat (DeepSeek V4
|
|
6
|
+
// Flash, per dev-factory/routing-table.json `purposes.research_synthesis` /
|
|
7
|
+
// `lockedPrimaries.research_synthesis`) for candidate Doctrine principles.
|
|
8
|
+
//
|
|
9
|
+
// Routing (owner ruling [[owner-ruling-a-routed-model-seat]]): every model call goes
|
|
10
|
+
// through spexharness/src/loop.mjs `runToolLoop` — NEVER a bare `callModel` — even
|
|
11
|
+
// though this seat needs no tools; that keeps the harness's gates, redaction and
|
|
12
|
+
// error-recycling on the call path. No tools are offered (`tools` is omitted), so
|
|
13
|
+
// `runToolLoop`'s `withTools` is false and the loop resolves on the model's own first
|
|
14
|
+
// reply, gated by `hooks.onFinal` for the STRICT-JSON contract:
|
|
15
|
+
// { "principles": [ { "text", "category", "confidence" (0-1), "evidence": [id,...], "stance" } ] }
|
|
16
|
+
// A reply that is not valid JSON, or that cites an evidence id NOT in the batch it was
|
|
17
|
+
// given, is REJECTED and the seat is asked to retry — once (`maxFinalRetries: 1`,
|
|
18
|
+
// runToolLoop's own Stop-hook-shaped refusal/retry path).
|
|
19
|
+
//
|
|
20
|
+
// Cost is recorded through the existing cost meter (spexharness/src/chat/cost-meter.mjs)
|
|
21
|
+
// when a caller supplies one, one row per model call (one per batch, or two on a retry) —
|
|
22
|
+
// the SAME ledger `dev-factory/cost-tracker.mjs` and the chat margin already read.
|
|
23
|
+
//
|
|
24
|
+
// DR5 ([[HS-7Z9E-8CMW-TASK-dr5-vault-knowledge-as-a-doctrine]]): vault-source.mjs's
|
|
25
|
+
// `source: 'vault'` observations are ALREADY rules (curated by the owner) and are
|
|
26
|
+
// NEVER sent to this seat — {@link batchObservations}/{@link synthesizeDoctrine} drop
|
|
27
|
+
// them before batching, as a hard invariant independent of what a caller passes in.
|
|
28
|
+
// They are instead seeded straight into principles by merge.mjs's
|
|
29
|
+
// `seedVaultRulesAsPrinciples`. This module's only awareness of them is contextual: a
|
|
30
|
+
// caller may pass `vaultContextLines` (see {@link buildVaultRuleContextLines}) — a
|
|
31
|
+
// short "here is what's already established" list folded into the system prompt so a
|
|
32
|
+
// session-derived principle is phrased consistently with the vault and not restated
|
|
33
|
+
// as if it were new.
|
|
34
|
+
|
|
35
|
+
import { PROVIDERS, callModel } from '../../../dev-factory/cloud-clients.mjs';
|
|
36
|
+
import { DEEPSEEK } from '../../../dev-factory/lib/model-catalog.mjs';
|
|
37
|
+
import { runToolLoop } from '../loop.mjs';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {{
|
|
41
|
+
* text: string,
|
|
42
|
+
* category: string,
|
|
43
|
+
* confidence: number,
|
|
44
|
+
* evidence: string[],
|
|
45
|
+
* stance: 'do'|'avoid',
|
|
46
|
+
* supersedes?: string[],
|
|
47
|
+
* }} DoctrinePrincipleCandidate
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/** The `research_synthesis` seat's locked primary (dev-factory/routing-table.json). */
|
|
51
|
+
export const SYNTHESIS_MODEL = DEEPSEEK.FLASH;
|
|
52
|
+
|
|
53
|
+
/** Categories a candidate principle may be filed under (epic scope). */
|
|
54
|
+
export const CATEGORIES = [
|
|
55
|
+
'delegation',
|
|
56
|
+
'verification',
|
|
57
|
+
'communication',
|
|
58
|
+
'naming',
|
|
59
|
+
'safety-permissions',
|
|
60
|
+
'research-grounding',
|
|
61
|
+
'session-hygiene',
|
|
62
|
+
'product-taste',
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The ESCAPE VALVE. Without it the seat had to file every observation under one of
|
|
67
|
+
* {@link CATEGORIES} — there was no way to say "this yields no principle" — so off-topic input
|
|
68
|
+
* (a bug report, a paste, a machine blob) was laundered into a confident-looking principle
|
|
69
|
+
* rather than declined. A principle carrying this sentinel category is parsed successfully
|
|
70
|
+
* and then DROPPED: it never reaches merge.mjs, the writer, or the committed document.
|
|
71
|
+
*
|
|
72
|
+
* It is deliberately NOT a member of `CATEGORIES`, so nothing downstream has to learn about
|
|
73
|
+
* it and `parseSynthesisReply` still rejects a genuinely unknown category.
|
|
74
|
+
*/
|
|
75
|
+
export const NO_PRINCIPLE_CATEGORY = 'none';
|
|
76
|
+
|
|
77
|
+
// Approximation stated in the task brief: observations ≈ 4 chars/token.
|
|
78
|
+
const CHARS_PER_TOKEN = 4;
|
|
79
|
+
const TARGET_BATCH_TOKENS = 40_000;
|
|
80
|
+
|
|
81
|
+
/** The row shape a batch sends the seat — the ONLY fields it ever sees per observation. */
|
|
82
|
+
function batchRow(obs) {
|
|
83
|
+
return { id: obs.id, kind: obs.kind, project: obs.project, date: obs.ts, text: obs.text };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function approxChars(obs) {
|
|
87
|
+
return JSON.stringify(batchRow(obs)).length;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Estimate the token count of a set of observations (≈ 4 chars/token, per the batch
|
|
92
|
+
* row shape actually sent to the seat) — call BEFORE synthesizing so the real-run cost
|
|
93
|
+
* can be printed ahead of any dispatch.
|
|
94
|
+
* @param {Array<object>} observations
|
|
95
|
+
* @returns {number}
|
|
96
|
+
*/
|
|
97
|
+
export function estimateTokens(observations) {
|
|
98
|
+
const eligible = (observations ?? []).filter((o) => o?.source !== 'vault');
|
|
99
|
+
const chars = eligible.reduce((sum, obs) => sum + approxChars(obs), 0);
|
|
100
|
+
return Math.ceil(chars / CHARS_PER_TOKEN);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Estimate the $ cost of synthesizing `observations` at `model`'s DeepSeek pricing —
|
|
105
|
+
* input tokens from the batch content, output tokens estimated generously (principles
|
|
106
|
+
* are short JSON; ~15% of input is a safe ceiling for a well-bounded batch).
|
|
107
|
+
* @param {Array<object>} observations
|
|
108
|
+
* @param {string} [model]
|
|
109
|
+
* @returns {{ estimatedInputTokens: number, estimatedOutputTokens: number, estimatedCostUSD: number }}
|
|
110
|
+
*/
|
|
111
|
+
export function estimateSynthesisCostUSD(observations, model = SYNTHESIS_MODEL) {
|
|
112
|
+
const p = PROVIDERS[model];
|
|
113
|
+
const estimatedInputTokens = estimateTokens(observations);
|
|
114
|
+
const estimatedOutputTokens = Math.ceil(estimatedInputTokens * 0.15);
|
|
115
|
+
const estimatedCostUSD = p
|
|
116
|
+
? +(((estimatedInputTokens * p.priceIn) + (estimatedOutputTokens * p.priceOut)) / 1e6).toFixed(6)
|
|
117
|
+
: 0;
|
|
118
|
+
return { estimatedInputTokens, estimatedOutputTokens, estimatedCostUSD };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Batch observations into ~40k-token groups: grouped by project, oldest-first within
|
|
123
|
+
* a project ("newest-last" per the task brief), a batch closed once adding the next
|
|
124
|
+
* observation would exceed `targetTokens`.
|
|
125
|
+
*
|
|
126
|
+
* Project order defaults to alphabetical (deterministic). A caller running against a
|
|
127
|
+
* spend cap that cannot fit the whole corpus may pass `projectOrder` — an explicit
|
|
128
|
+
* priority list (e.g. `['heyspex-app', ...otherProjectsNewestFirst]`) — so the
|
|
129
|
+
* highest-priority projects land in the EARLIEST batches, and a `maxBatches` cutoff
|
|
130
|
+
* then naturally keeps the highest-priority material and drops the rest.
|
|
131
|
+
*
|
|
132
|
+
* @param {Array<object>} observations
|
|
133
|
+
* @param {{ targetTokens?: number, projectOrder?: string[] }} [opts]
|
|
134
|
+
* @returns {Array<Array<object>>}
|
|
135
|
+
*/
|
|
136
|
+
export function batchObservations(observations, { targetTokens = TARGET_BATCH_TOKENS, projectOrder = null } = {}) {
|
|
137
|
+
const targetChars = targetTokens * CHARS_PER_TOKEN;
|
|
138
|
+
const eligible = (observations ?? []).filter((o) => o?.source !== 'vault');
|
|
139
|
+
const byProject = new Map();
|
|
140
|
+
for (const obs of eligible) {
|
|
141
|
+
const list = byProject.get(obs.project) ?? [];
|
|
142
|
+
list.push(obs);
|
|
143
|
+
byProject.set(obs.project, list);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const priorityIndex = new Map((projectOrder ?? []).map((p, i) => [p, i]));
|
|
147
|
+
const projects = [...byProject.keys()].sort((a, b) => {
|
|
148
|
+
const ia = priorityIndex.has(a) ? priorityIndex.get(a) : Number.POSITIVE_INFINITY;
|
|
149
|
+
const ib = priorityIndex.has(b) ? priorityIndex.get(b) : Number.POSITIVE_INFINITY;
|
|
150
|
+
if (ia !== ib) return ia - ib;
|
|
151
|
+
return a.localeCompare(b);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const ordered = [];
|
|
155
|
+
for (const project of projects) {
|
|
156
|
+
const list = byProject.get(project);
|
|
157
|
+
list.sort((a, b) => String(a.ts ?? '').localeCompare(String(b.ts ?? '')) || (a.seq ?? 0) - (b.seq ?? 0));
|
|
158
|
+
ordered.push(...list);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const batches = [];
|
|
162
|
+
let current = [];
|
|
163
|
+
let currentChars = 0;
|
|
164
|
+
for (const obs of ordered) {
|
|
165
|
+
const chars = approxChars(obs);
|
|
166
|
+
if (current.length && currentChars + chars > targetChars) {
|
|
167
|
+
batches.push(current);
|
|
168
|
+
current = [];
|
|
169
|
+
currentChars = 0;
|
|
170
|
+
}
|
|
171
|
+
current.push(obs);
|
|
172
|
+
currentChars += chars;
|
|
173
|
+
}
|
|
174
|
+
if (current.length) batches.push(current);
|
|
175
|
+
return batches;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The top vault-rule principle texts per category, for prompt context — see the module
|
|
180
|
+
* header. Accepts merge.mjs's `seedVaultRulesAsPrinciples` output shape
|
|
181
|
+
* (`{ text, category, confidence }`); sorts each category's candidates by confidence
|
|
182
|
+
* descending and keeps the top `perCategory`. Pure, deterministic.
|
|
183
|
+
*
|
|
184
|
+
* @param {Array<{ text: string, category: string, confidence: number }>} vaultPrinciples
|
|
185
|
+
* @param {{ perCategory?: number }} [opts]
|
|
186
|
+
* @returns {string[]} lines shaped `[category] text`
|
|
187
|
+
*/
|
|
188
|
+
export function buildVaultRuleContextLines(vaultPrinciples, { perCategory = 3 } = {}) {
|
|
189
|
+
const byCategory = new Map();
|
|
190
|
+
for (const p of vaultPrinciples ?? []) {
|
|
191
|
+
if (!p || typeof p.text !== 'string' || !p.text.trim()) continue;
|
|
192
|
+
const list = byCategory.get(p.category) ?? [];
|
|
193
|
+
list.push(p);
|
|
194
|
+
byCategory.set(p.category, list);
|
|
195
|
+
}
|
|
196
|
+
const lines = [];
|
|
197
|
+
for (const [category, list] of byCategory) {
|
|
198
|
+
const top = [...list].sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0)).slice(0, perCategory);
|
|
199
|
+
for (const p of top) lines.push(`[${category}] ${p.text}`);
|
|
200
|
+
}
|
|
201
|
+
return lines;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The system prompt every synthesis call sends — states the Doctrine's purpose and the hard rules. */
|
|
205
|
+
export function buildSynthesisSystemPrompt({ vaultContextLines = [] } = {}) {
|
|
206
|
+
const base = [
|
|
207
|
+
'You distill SpexCode Doctrine: the base operating principles every developer profile is built on,',
|
|
208
|
+
'learned from how the owner directs coding agents across many real sessions.',
|
|
209
|
+
'',
|
|
210
|
+
'You will receive one batch of observations, each shaped { id, kind, project, date, text }.',
|
|
211
|
+
'Most observations (kind instruction/correction/ruling/approval/refusal/taste) are the owner\'s own',
|
|
212
|
+
'words. Some observations have kind "behaviour" — a compact, aggregated summary of how a real',
|
|
213
|
+
'session actually ran (tools called, guardrail refusals/blocks, claims and specs advanced), built',
|
|
214
|
+
'from HeySpex\'s own logs, not something the owner said. Treat "behaviour" observations as lower-',
|
|
215
|
+
'weight supporting evidence for verification/safety-permissions/session-hygiene principles — useful',
|
|
216
|
+
'to corroborate a pattern, never sufficient on their own to justify a high-confidence principle.',
|
|
217
|
+
'Reply with STRICT JSON ONLY — no prose, no markdown code fences — matching exactly this shape:',
|
|
218
|
+
'{ "principles": [ { "text": string, "category": one of ' +
|
|
219
|
+
JSON.stringify(CATEGORIES) +
|
|
220
|
+
', "confidence": number between 0 and 1, "evidence": [observation id, ...], "stance": "do" | "avoid" } ] }',
|
|
221
|
+
'',
|
|
222
|
+
'Rules:',
|
|
223
|
+
'- Prefer a rule that recurs across multiple sessions/observations over a one-off remark.',
|
|
224
|
+
'- Never quote more than 12 words verbatim from any single observation.',
|
|
225
|
+
'- Every id in "evidence" MUST be one of the ids given in THIS batch — never invent an id, and never cite one not present here.',
|
|
226
|
+
'- Never invent a principle or evidence unsupported by this batch.',
|
|
227
|
+
'- NOT EVERY OBSERVATION YIELDS A PRINCIPLE. An observation that is off-topic, is a one-off',
|
|
228
|
+
' bug report, is pasted material, or is not the owner expressing a working preference must',
|
|
229
|
+
' NOT be forced into a category. Decline it: either leave it out entirely, or emit an entry',
|
|
230
|
+
' with "category": ' + JSON.stringify(NO_PRINCIPLE_CATEGORY) + ' citing it in "evidence". A declined entry is discarded.',
|
|
231
|
+
'- Returning { "principles": [] } for a batch that yields nothing is a CORRECT answer, not a failure.',
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
if (vaultContextLines.length) {
|
|
235
|
+
base.push(
|
|
236
|
+
'',
|
|
237
|
+
"These rules are ALREADY established from the owner's own curated vault (decisions, protocols, north star, CLAUDE.md) —",
|
|
238
|
+
'phrase any new principle consistently with these, and do NOT re-emit one of them as if it were a new principle:',
|
|
239
|
+
...vaultContextLines.map((l) => `- ${l}`),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return base.join('\n');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function buildBatchUserMessage(batch) {
|
|
247
|
+
const rows = batch.map(batchRow);
|
|
248
|
+
return `Observations batch (${rows.length} items):\n${JSON.stringify(rows)}\n\nReturn ONLY the strict JSON object described in the system prompt — no other text.`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function stripCodeFence(text) {
|
|
252
|
+
const trimmed = String(text ?? '').trim();
|
|
253
|
+
const fence = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed);
|
|
254
|
+
return fence ? fence[1].trim() : trimmed;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Validate + parse one seat reply against the strict-JSON contract for `batch`. Never
|
|
259
|
+
* throws — returns `{ error }` for anything not conforming, so the caller can decide
|
|
260
|
+
* to reject-and-retry.
|
|
261
|
+
*
|
|
262
|
+
* An entry whose category is {@link NO_PRINCIPLE_CATEGORY} is the seat DECLINING the
|
|
263
|
+
* observations it cites (see the constant's note). It is validated only as far as its
|
|
264
|
+
* evidence ids — no text, confidence or stance is demanded for something being thrown away —
|
|
265
|
+
* and is returned separately on `declined`, never on `principles`.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} text
|
|
268
|
+
* @param {string[]} validIds
|
|
269
|
+
* @returns {{ principles: DoctrinePrincipleCandidate[], declined: string[] } | { error: string }}
|
|
270
|
+
*/
|
|
271
|
+
export function parseSynthesisReply(text, validIds) {
|
|
272
|
+
let parsed;
|
|
273
|
+
try {
|
|
274
|
+
parsed = JSON.parse(stripCodeFence(text));
|
|
275
|
+
} catch {
|
|
276
|
+
return { error: 'reply is not valid JSON' };
|
|
277
|
+
}
|
|
278
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.principles)) {
|
|
279
|
+
return { error: 'reply is missing a "principles" array' };
|
|
280
|
+
}
|
|
281
|
+
const idSet = new Set(validIds);
|
|
282
|
+
const principles = [];
|
|
283
|
+
const declined = [];
|
|
284
|
+
for (const p of parsed.principles) {
|
|
285
|
+
if (!p || typeof p !== 'object') return { error: 'a principle entry is not an object' };
|
|
286
|
+
if (p.category === NO_PRINCIPLE_CATEGORY) {
|
|
287
|
+
for (const id of Array.isArray(p.evidence) ? p.evidence : []) {
|
|
288
|
+
if (!idSet.has(id)) return { error: `principle cites an evidence id not in this batch: "${id}"` };
|
|
289
|
+
declined.push(id);
|
|
290
|
+
}
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (typeof p.text !== 'string' || !p.text.trim()) return { error: 'a principle is missing "text"' };
|
|
294
|
+
if (!CATEGORIES.includes(p.category)) return { error: `principle has an unknown category "${p.category}"` };
|
|
295
|
+
if (typeof p.confidence !== 'number' || Number.isNaN(p.confidence) || p.confidence < 0 || p.confidence > 1) {
|
|
296
|
+
return { error: 'a principle has a "confidence" outside 0..1' };
|
|
297
|
+
}
|
|
298
|
+
if (!Array.isArray(p.evidence) || p.evidence.length === 0) return { error: 'a principle is missing "evidence"' };
|
|
299
|
+
for (const id of p.evidence) {
|
|
300
|
+
if (!idSet.has(id)) return { error: `principle cites an evidence id not in this batch: "${id}"` };
|
|
301
|
+
}
|
|
302
|
+
if (p.stance !== 'do' && p.stance !== 'avoid') return { error: 'a principle has a "stance" other than do/avoid' };
|
|
303
|
+
principles.push(p);
|
|
304
|
+
}
|
|
305
|
+
return { principles, declined };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Synthesize ONE batch through the routed seat. Always goes through `runToolLoop` —
|
|
310
|
+
* never a bare `callModelFn` — per [[owner-ruling-a-routed-model-seat]]; no tools are
|
|
311
|
+
* offered. A reply that fails {@link parseSynthesisReply} is rejected via
|
|
312
|
+
* `hooks.onFinal` and retried once (`maxFinalRetries`).
|
|
313
|
+
*
|
|
314
|
+
* @param {Array<object>} batch
|
|
315
|
+
* @param {{ model?: string, callModelFn?: Function, maxFinalRetries?: number, costMeter?: { recordTurn: Function } | null, vaultContextLines?: string[] }} [opts]
|
|
316
|
+
* @returns {Promise<{ principles: DoctrinePrincipleCandidate[], costUSD: number, error: string|null }>}
|
|
317
|
+
*/
|
|
318
|
+
export async function synthesizeBatch(batch, { model = SYNTHESIS_MODEL, callModelFn = callModel, maxFinalRetries = 1, costMeter = null, vaultContextLines = [] } = {}) {
|
|
319
|
+
const validIds = batch.map((o) => o.id);
|
|
320
|
+
const system = buildSynthesisSystemPrompt({ vaultContextLines });
|
|
321
|
+
const messages = [{ role: 'user', content: buildBatchUserMessage(batch) }];
|
|
322
|
+
|
|
323
|
+
let lastParsed = null;
|
|
324
|
+
const result = await runToolLoop({
|
|
325
|
+
callModelFn,
|
|
326
|
+
model,
|
|
327
|
+
messages,
|
|
328
|
+
execute: async () => ({ content: JSON.stringify({ error: 'the doctrine synthesis seat has no tools available' }) }),
|
|
329
|
+
maxToolTurns: 1,
|
|
330
|
+
callOpts: { system, maxTokens: 8192, temp: 0 },
|
|
331
|
+
maxFinalRetries,
|
|
332
|
+
onTurn: costMeter
|
|
333
|
+
? ({ res }) => {
|
|
334
|
+
if (res && !res.error) costMeter.recordTurn({ model: res.model ?? model, usage: res.usage, costUSD: res.costUSD, seconds: res.seconds });
|
|
335
|
+
}
|
|
336
|
+
: null,
|
|
337
|
+
hooks: {
|
|
338
|
+
onFinal: async (text) => {
|
|
339
|
+
const parsed = parseSynthesisReply(text, validIds);
|
|
340
|
+
lastParsed = parsed;
|
|
341
|
+
if (parsed.error) {
|
|
342
|
+
return {
|
|
343
|
+
accept: false,
|
|
344
|
+
feedback: `Your reply was rejected: ${parsed.error}. Reply again with ONLY the strict JSON object described in the system prompt, citing only ids present in this batch.`,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
return { accept: true };
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
if (!result.ok || !lastParsed || lastParsed.error) {
|
|
353
|
+
return { principles: [], declined: [], costUSD: result.costUSD ?? 0, error: lastParsed?.error ?? result.error ?? 'synthesis failed with no reply' };
|
|
354
|
+
}
|
|
355
|
+
return { principles: lastParsed.principles, declined: lastParsed.declined ?? [], costUSD: result.costUSD ?? 0, error: null };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Synthesize the whole observation set: batch it, run each batch (up to `maxBatches`)
|
|
360
|
+
* through {@link synthesizeBatch}, and total the cost. Batches beyond `maxBatches` are
|
|
361
|
+
* left unrun (`batchesSkipped`) rather than silently dropped from the count — a caller
|
|
362
|
+
* prioritizing which observations to include should slice `observations` BEFORE
|
|
363
|
+
* calling this, so the skipped batches are the ones that mattered least.
|
|
364
|
+
*
|
|
365
|
+
* @param {Array<object>} observations
|
|
366
|
+
* @param {{ model?: string, callModelFn?: Function, maxBatches?: number, costMeter?: object|null, onBatch?: Function|null, projectOrder?: string[], vaultContextLines?: string[] }} [opts]
|
|
367
|
+
* @returns {Promise<{ principles: DoctrinePrincipleCandidate[], batchesRun: number, batchesTotal: number, batchesSkipped: number, costUSD: number, results: Array<{ batchIndex: number, size: number, principlesReturned: number, error: string|null, costUSD: number }> }>}
|
|
368
|
+
*/
|
|
369
|
+
export async function synthesizeDoctrine(observations, { model = SYNTHESIS_MODEL, callModelFn = callModel, maxBatches = Infinity, costMeter = null, onBatch = null, projectOrder = null, vaultContextLines = [] } = {}) {
|
|
370
|
+
// Vault observations are never sent to the seat (see module header) — batchObservations
|
|
371
|
+
// already drops them, this filter is belt-and-suspenders for anything that inspects
|
|
372
|
+
// `observations` directly before batching.
|
|
373
|
+
const batches = batchObservations(observations, { projectOrder });
|
|
374
|
+
const limit = Number.isFinite(maxBatches) ? Math.max(0, maxBatches) : batches.length;
|
|
375
|
+
const toRun = batches.slice(0, limit);
|
|
376
|
+
|
|
377
|
+
const allPrinciples = [];
|
|
378
|
+
const allDeclined = [];
|
|
379
|
+
const results = [];
|
|
380
|
+
let costUSD = 0;
|
|
381
|
+
|
|
382
|
+
for (let i = 0; i < toRun.length; i++) {
|
|
383
|
+
const batch = toRun[i];
|
|
384
|
+
const r = await synthesizeBatch(batch, { model, callModelFn, costMeter, vaultContextLines });
|
|
385
|
+
costUSD += r.costUSD;
|
|
386
|
+
results.push({ batchIndex: i, size: batch.length, principlesReturned: r.principles.length, declinedCount: (r.declined ?? []).length, error: r.error, costUSD: r.costUSD });
|
|
387
|
+
allPrinciples.push(...r.principles);
|
|
388
|
+
allDeclined.push(...(r.declined ?? []));
|
|
389
|
+
if (onBatch) onBatch({ index: i, of: toRun.length, batch, result: r });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
principles: allPrinciples,
|
|
394
|
+
declined: allDeclined,
|
|
395
|
+
batchesRun: toRun.length,
|
|
396
|
+
batchesTotal: batches.length,
|
|
397
|
+
batchesSkipped: batches.length - toRun.length,
|
|
398
|
+
costUSD,
|
|
399
|
+
results,
|
|
400
|
+
};
|
|
401
|
+
}
|