heyspex 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/bin/hs.mjs +931 -98
- package/bin/spexcode.mjs +262 -0
- package/dev-factory/engine/worktree.mjs +740 -0
- package/dev-factory/lib/code-repo-root.mjs +67 -0
- package/dev-factory/lib/context-budget.mjs +84 -0
- package/dev-factory/lib/epic-detect.mjs +190 -0
- package/dev-factory/lib/fleet-queue-file.mjs +846 -0
- package/dev-factory/lib/fleet-queue.mjs +426 -0
- package/dev-factory/lib/fleet-state.mjs +14 -18
- package/dev-factory/lib/model-catalog.mjs +338 -0
- package/dev-factory/lib/seat-context.mjs +199 -0
- package/dev-factory/lib/spec-io.mjs +93 -0
- package/dev-factory/lib/sufficiency-score.mjs +8 -2
- package/dev-factory/lib/worktree-allocator.mjs +744 -0
- package/dev-factory/model-catalog.json +222 -0
- package/dist/cli/hs-check.mjs +1085 -103
- package/dist/cli/hs-claim.mjs +936 -63
- package/dist/cli/hs-connect.mjs +9786 -1910
- package/dist/cli/hs-defer.mjs +5267 -3053
- package/dist/cli/hs-dev.mjs +564 -0
- package/dist/cli/hs-env.mjs +385 -299
- package/dist/cli/hs-index-rebuild.mjs +7129 -0
- package/dist/cli/hs-index-reconcile.mjs +7852 -0
- package/dist/cli/hs-init.mjs +777 -138
- package/dist/cli/hs-install.mjs +6583 -485
- package/dist/cli/hs-join.mjs +10137 -2368
- package/dist/cli/hs-vault-pull.mjs +4659 -5882
- package/dist/cli/hs-vault.mjs +3793 -1537
- package/dist/cli/hs-workspace.mjs +2550 -524
- package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
- package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
- package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
- package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
- package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
- package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
- package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
- package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
- package/docs/mcp-client-config.md +259 -6
- package/package.json +231 -124
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
- package/plugins/heyspex/.mcp.json +12 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
- package/plugins/heyspex/bin/run-hook.mjs +62 -0
- package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
- package/plugins/heyspex/hooks/hooks.json +361 -0
- package/plugins/heyspex/mcp.json +13 -0
- package/scripts/check-mcp-freshness.mjs +17 -9
- package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
- package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
- package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
- package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
- package/scripts/claude-hooks/_run.mjs +120 -0
- package/scripts/claude-hooks/_transcript-text.mjs +60 -0
- package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
- package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
- package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
- package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
- package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
- package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
- package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
- package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
- package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
- package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
- package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
- package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
- package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
- package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
- package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
- package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
- package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
- package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
- package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
- package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
- package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
- package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
- package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
- package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
- package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
- package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
- package/scripts/claude-hooks/log-session-activity.mjs +341 -0
- package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
- package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
- package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
- package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
- package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
- package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
- package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
- package/scripts/claude-hooks/research-method-guard.mjs +398 -0
- package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
- package/scripts/claude-hooks/session-end.mjs +449 -0
- package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
- package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
- package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
- package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
- package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
- package/scripts/cursor-hooks/manifest.mjs +235 -0
- package/scripts/dev-server-status.mjs +34 -13
- package/scripts/dev-server-stop.mjs +313 -0
- package/scripts/heyspex-mode.mjs +268 -0
- package/scripts/hs-auto-resolve.mjs +240 -0
- package/scripts/hs-auto-source.mjs +398 -0
- package/scripts/hs-auto.mjs +134 -0
- package/scripts/hs-capture-cli.mjs +326 -0
- package/scripts/hs-classifier.mjs +136 -0
- package/scripts/hs-companion.mjs +88 -0
- package/scripts/hs-gateway.mjs +115 -0
- package/scripts/hs-mode.mjs +125 -0
- package/scripts/hs-next.mjs +441 -0
- package/scripts/hs-settings.mjs +338 -0
- package/scripts/hs-view.mjs +333 -0
- package/scripts/hs-worktree.mjs +201 -0
- package/scripts/install-claude-code-hooks.mjs +270 -0
- package/scripts/install-cursor-hooks.mjs +471 -0
- package/scripts/lib/agent-env-markers.mjs +38 -0
- package/scripts/lib/console-url-resolver.mjs +197 -60
- package/scripts/lib/customer-kit.mjs +886 -0
- package/scripts/lib/dev-mode.mjs +244 -28
- package/scripts/lib/heyspex-home.mjs +72 -0
- package/scripts/lib/hs-command.mjs +277 -0
- package/scripts/lib/hs-companion-settings.mjs +472 -0
- package/scripts/lib/hs-source-commit.mjs +59 -5
- package/scripts/lib/hs-source-identity.mjs +526 -0
- package/scripts/lib/hs-source-log.mjs +11 -21
- package/scripts/lib/hs-source-push.mjs +107 -58
- package/scripts/lib/hs-worktree-args.mjs +115 -0
- package/scripts/lib/shared-path-helpers.mjs +134 -0
- package/scripts/lib/standalone-doctor.mjs +540 -3
- package/scripts/lib/subagent-git-lock.mjs +109 -0
- package/scripts/lib/template-pin.mjs +34 -0
- package/scripts/provenance-lib.mjs +91 -0
- package/scripts/stamp-provenance-on-edit.mjs +222 -0
- package/scripts/wip-ledger-health.mjs +653 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
- package/spexharness/src/adapters/audit.mjs +197 -0
- package/spexharness/src/adapters/claude-code.mjs +441 -0
- package/spexharness/src/chat/tui/ansi.mjs +448 -0
- package/spexharness/src/chat/tui/palette.mjs +22 -0
- package/spexharness/src/chat/tui/select.mjs +208 -0
- package/spexharness/src/config-root.mjs +54 -0
- package/spexharness/src/context-budget.mjs +141 -0
- package/spexharness/src/dev-server-control.mjs +389 -0
- package/spexharness/src/doctrine/behaviour.mjs +371 -0
- package/spexharness/src/doctrine/corpus.mjs +733 -0
- package/spexharness/src/doctrine/inject.mjs +338 -0
- package/spexharness/src/doctrine/merge.mjs +184 -0
- package/spexharness/src/doctrine/sources.mjs +252 -0
- package/spexharness/src/doctrine/synthesize.mjs +401 -0
- package/spexharness/src/doctrine/vault-source.mjs +574 -0
- package/spexharness/src/doctrine/writer.mjs +176 -0
- package/spexharness/src/events.mjs +84 -0
- package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
- package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
- package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
- package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
- package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
- package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
- package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
- package/spexharness/src/gates/git-identity-guard.mjs +61 -0
- package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
- package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
- package/spexharness/src/gates/git-stash-gate.mjs +159 -0
- package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
- package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
- package/spexharness/src/gates/inject-vault-context.mjs +239 -0
- package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
- package/spexharness/src/gates/plan-review-gate.mjs +233 -0
- package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
- package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
- package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
- package/spexharness/src/gates/settings-write-gate.mjs +104 -0
- package/spexharness/src/git-reflog-audit.mjs +294 -0
- package/spexharness/src/intent-classifier.mjs +194 -0
- package/spexharness/src/intent-directive.mjs +541 -0
- package/spexharness/src/profile/learn.mjs +551 -0
- package/spexharness/src/profile/proposals.mjs +357 -0
- package/spexharness/src/profile/vault-inclusion.mjs +386 -0
- package/spexharness/src/redact.mjs +151 -0
- package/spexharness/src/rule-table.mjs +819 -0
- package/spexharness/src/seat-context.mjs +122 -0
- package/spexharness/src/session-bootstrap.mjs +512 -0
- package/spexharness/src/session-record.mjs +815 -0
- package/spexharness/src/token-inventory.mjs +207 -0
- package/vendor/heyspex-mcp/account-switch.mjs +95 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
- package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
- package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
- package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
- package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
- package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
- package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
- package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
- package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
- package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
- package/vendor/heyspex-mcp/cli.mjs +201 -15524
- package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
- package/vendor/heyspex-mcp/config-seed.yaml +15 -0
- package/vendor/heyspex-mcp/config-settings.mjs +290 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
- package/vendor/heyspex-mcp/console-transport.mjs +166 -0
- package/vendor/heyspex-mcp/credentials.mjs +584 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
- package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
- package/vendor/heyspex-mcp/design-tools.mjs +4 -1
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
- package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
- package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
- package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
- package/vendor/heyspex-mcp/events-tools.mjs +207 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
- package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
- package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
- package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
- package/vendor/heyspex-mcp/http-transport.mjs +300 -0
- package/vendor/heyspex-mcp/list-tools.mjs +257 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
- package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
- package/vendor/heyspex-mcp/package-root.mjs +32 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
- package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
- package/vendor/heyspex-mcp/plan-push.mjs +906 -21
- package/vendor/heyspex-mcp/plan-review.mjs +124 -30
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
- package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
- package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
- package/vendor/heyspex-mcp/promote-research.mjs +660 -53
- package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
- package/vendor/heyspex-mcp/prompts.mjs +1460 -0
- package/vendor/heyspex-mcp/read-path.mjs +29 -9
- package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
- package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
- package/vendor/heyspex-mcp/register-spec.mjs +266 -54
- package/vendor/heyspex-mcp/request-context.mjs +59 -0
- package/vendor/heyspex-mcp/research-focus.mjs +207 -4
- package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
- package/vendor/heyspex-mcp/research-tools.mjs +4 -4
- package/vendor/heyspex-mcp/response-budget.mjs +348 -0
- package/vendor/heyspex-mcp/review-focus.mjs +25 -1
- package/vendor/heyspex-mcp/search-specs.mjs +280 -0
- package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
- package/vendor/heyspex-mcp/session-owner.mjs +108 -0
- package/vendor/heyspex-mcp/session-tools.mjs +152 -0
- package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
- package/vendor/heyspex-mcp/split-research.mjs +301 -56
- package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
- package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
- package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
- package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
- package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
- package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
- package/vendor/heyspex-mcp/vault-io.mjs +724 -0
- package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
- package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
- package/vendor/heyspex-mcp/whoami.mjs +91 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* model-catalog.mjs — typed accessors over dev-factory/model-catalog.json, the ONE place model
|
|
4
|
+
* ids, aliases, retirements, context windows, capabilities, endpoints and list prices are
|
|
5
|
+
* declared for every cloud provider (DeepSeek, Anthropic, OpenAI, Gemini, OpenRouter, GitHub
|
|
6
|
+
* Models) and every local runtime (Ollama, vLLM).
|
|
7
|
+
*
|
|
8
|
+
* Why: vendors ship and retire models every few weeks (DeepSeek retired `deepseek-v4-flash`
|
|
9
|
+
* on 2026-09-10 and the rename touched 110 files). With this module an upgrade is a JSON edit
|
|
10
|
+
* plus three self-tests; code imports the role constants below instead of spelling ids.
|
|
11
|
+
*
|
|
12
|
+
* import { DEEPSEEK, OLLAMA, ANTHROPIC, resolveModelId, contextWindowFor } from "./lib/model-catalog.mjs";
|
|
13
|
+
* runToolLoop with DEEPSEEK.CHEAP ("deepseek-flash" today)
|
|
14
|
+
* resolveModelId("deepseek-v4-flash") // "deepseek-flash" (alias → current)
|
|
15
|
+
* isRetiredModelId("deepseek-v4-flash") // true
|
|
16
|
+
* contextWindowFor("qwen3-coder:30b") // 262144
|
|
17
|
+
* providerEntries() // the whole cloud-clients PROVIDERS table
|
|
18
|
+
*
|
|
19
|
+
* node dev-factory/lib/model-catalog.mjs --self-test
|
|
20
|
+
* node dev-factory/lib/model-catalog.mjs --print # resolved constants as JSON
|
|
21
|
+
*
|
|
22
|
+
* ROUTING is not here: which seat runs which model lives in routing-table.json; this file says
|
|
23
|
+
* what a model IS and how it is reached. Pure module — reads the JSON once at import, no
|
|
24
|
+
* network, no other I/O. When dev-factory merges into SpexFleet this file and the JSON move
|
|
25
|
+
* together; every importer uses a relative path (the console goes through ONE shim,
|
|
26
|
+
* spex-console/src/lib/model-catalog.ts) so the move is a path edit, not a sweep.
|
|
27
|
+
*/
|
|
28
|
+
// A STATIC JSON import, not `readFileSync`: this module is reached from the console's CLIENT
|
|
29
|
+
// bundles (through spex-console/src/lib/model-catalog.ts — `store/agent-store.ts` and the
|
|
30
|
+
// dev-factory pipeline tab are "use client" files), and a `node:fs` / `node:path` / `node:url`
|
|
31
|
+
// import there makes Turbopack fail every page with "the chunking context does not support
|
|
32
|
+
// external modules (request: node:fs)" (2026-09-18: every route on the local console 500'd).
|
|
33
|
+
// Bundlers inline the JSON; Node reads it once at import — the same "read once at import, no
|
|
34
|
+
// other I/O" contract as before.
|
|
35
|
+
import catalogJson from "../model-catalog.json" with { type: "json" };
|
|
36
|
+
|
|
37
|
+
/** Where the catalog lives, as a `file:` URL string (informational — nothing reads it back). */
|
|
38
|
+
export const MODEL_CATALOG_PATH = new URL("../model-catalog.json", import.meta.url).href;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Return a catalog document (default: the bundled JSON). Exported so tests can feed a fixture.
|
|
42
|
+
* Typed `any` on purpose: `JSON.parse` used to hand TS an `any` here, and every consumer's
|
|
43
|
+
* inferred type (role blocks, provider entries, the console's typed shim) was built on that.
|
|
44
|
+
* A static JSON import would otherwise leak the literal JSON shape into those inferences.
|
|
45
|
+
* @param {any} [doc]
|
|
46
|
+
* @returns {any}
|
|
47
|
+
*/
|
|
48
|
+
export function loadModelCatalog(doc = /** @type {any} */ (catalogJson)) {
|
|
49
|
+
return doc;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const MODEL_CATALOG = Object.freeze(loadModelCatalog());
|
|
53
|
+
|
|
54
|
+
// ── flattened index: id → descriptor ─────────────────────────────────────────────────────────
|
|
55
|
+
function buildIndex(catalog) {
|
|
56
|
+
const byId = new Map();
|
|
57
|
+
const aliases = new Map();
|
|
58
|
+
const retired = new Map();
|
|
59
|
+
const providers = new Map();
|
|
60
|
+
for (const [locality, block] of Object.entries({ cloud: catalog.cloud ?? {}, local: catalog.local ?? {} })) {
|
|
61
|
+
for (const [provider, p] of Object.entries(block)) {
|
|
62
|
+
if (provider.startsWith("_")) continue;
|
|
63
|
+
// `p.kind` (from the JSON) is the wire PROTOCOL — "openai" (chat-completions-compatible),
|
|
64
|
+
// "anthropic" (Messages API) or "ollama" — and cloud-clients.mjs branches on it
|
|
65
|
+
// (`p.kind === "anthropic"`). `locality` is the cloud/local classification the outer loop
|
|
66
|
+
// walks. Keep them distinct: overwriting the provider's protocol kind with "cloud"/"local"
|
|
67
|
+
// broke the Anthropic call branch silently (providerEntries() emitted kind: "cloud" for
|
|
68
|
+
// every provider, so `callModel` always took the OpenAI-compat path).
|
|
69
|
+
providers.set(provider, { ...p, provider, locality, kind: p.kind ?? locality });
|
|
70
|
+
for (const [id, m] of Object.entries(p.models ?? {})) {
|
|
71
|
+
byId.set(id, Object.freeze({ id, kind: locality, provider, tier: m.tier ?? (locality === "local" ? "local" : null), ...m }));
|
|
72
|
+
}
|
|
73
|
+
for (const [alias, target] of Object.entries(p.aliases ?? {})) aliases.set(alias, { target, provider, kind: locality });
|
|
74
|
+
for (const r of p.retired ?? []) retired.set(r.id, { ...r, provider, kind: locality });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { byId, aliases, retired, providers };
|
|
78
|
+
}
|
|
79
|
+
const INDEX = buildIndex(MODEL_CATALOG);
|
|
80
|
+
|
|
81
|
+
/** Every current (non-alias, non-retired) model id in the catalog. */
|
|
82
|
+
export const ALL_MODEL_IDS = Object.freeze([...INDEX.byId.keys()]);
|
|
83
|
+
/** alias → current id, across every provider. */
|
|
84
|
+
export const MODEL_ALIASES = Object.freeze(Object.fromEntries([...INDEX.aliases].map(([a, v]) => [a, v.target])));
|
|
85
|
+
/** Retired ids (still routed by the vendor or not) — live code must not spell these. */
|
|
86
|
+
export const RETIRED_MODEL_IDS = Object.freeze([...INDEX.retired.keys()]);
|
|
87
|
+
/** Provider names, cloud and local. */
|
|
88
|
+
export const PROVIDER_NAMES = Object.freeze([...INDEX.providers.keys()]);
|
|
89
|
+
|
|
90
|
+
/** Alias → current id; unknown ids pass through unchanged (a local Ollama tag we don't list). */
|
|
91
|
+
export function resolveModelId(id) {
|
|
92
|
+
if (typeof id !== "string" || !id) return id;
|
|
93
|
+
const hit = INDEX.aliases.get(id);
|
|
94
|
+
return hit ? hit.target : id;
|
|
95
|
+
}
|
|
96
|
+
export function isRetiredModelId(id) { return INDEX.retired.has(id); }
|
|
97
|
+
export function isKnownModelId(id) { return INDEX.byId.has(id) || INDEX.aliases.has(id); }
|
|
98
|
+
/** Descriptor for a current id or an alias (resolved); null when the catalog has never heard of it. */
|
|
99
|
+
export function modelInfo(id) { return INDEX.byId.get(resolveModelId(id)) ?? null; }
|
|
100
|
+
/** The provider block (endpoint, key env, roles…) a model id belongs to, or null. */
|
|
101
|
+
export function providerOf(id) { const m = modelInfo(id); return m ? INDEX.providers.get(m.provider) ?? null : null; }
|
|
102
|
+
export function providerInfo(name) { return INDEX.providers.get(name) ?? null; }
|
|
103
|
+
export function isLocalModel(id) { return modelInfo(id)?.kind === "local"; }
|
|
104
|
+
export function isDeepSeekModel(id) { return modelInfo(id)?.provider === "deepseek"; }
|
|
105
|
+
export function isAnthropicModel(id) { return modelInfo(id)?.provider === "anthropic"; }
|
|
106
|
+
export function hasCapability(id, cap) { return (modelInfo(id)?.capabilities ?? []).includes(cap); }
|
|
107
|
+
/** Context window in tokens for a known id/alias, else `fallback`. */
|
|
108
|
+
export function contextWindowFor(id, fallback = null) { return modelInfo(id)?.contextWindow ?? fallback; }
|
|
109
|
+
/** Max output tokens for a known id/alias, else `fallback`. */
|
|
110
|
+
export function maxOutputTokensFor(id, fallback = null) { return modelInfo(id)?.maxOutputTokens ?? fallback; }
|
|
111
|
+
|
|
112
|
+
// ── per-provider role constants ──────────────────────────────────────────────────────────────
|
|
113
|
+
/**
|
|
114
|
+
* Per-provider role-constant block (DEEPSEEK, ANTHROPIC, OLLAMA, …). The fixed props below are
|
|
115
|
+
* typed explicitly; role names vary per provider (roles.chatDefault → CHAT_DEFAULT, roles.cheap →
|
|
116
|
+
* CHEAP, …) so TS consumers (spex-console) need an index signature to reach them —
|
|
117
|
+
* `DEEPSEEK.CHAT_DEFAULT`, `DEEPSEEK.JUDGE`, `OLLAMA.JUDGE_FALLBACK`, etc.
|
|
118
|
+
* @returns {{ provider: string, kind: string|null, company: string, baseUrl: string|null,
|
|
119
|
+
* keyEnv: string|null, keyEnvFallback: string|null, verifiedOn: string,
|
|
120
|
+
* roles: Record<string, string>, ids: readonly string[], aliases: Record<string, string>,
|
|
121
|
+
* retired: readonly string[], [roleConstant: string]: any }}
|
|
122
|
+
*/
|
|
123
|
+
function roleBlock(name, extra = {}) {
|
|
124
|
+
const p = INDEX.providers.get(name) ?? {};
|
|
125
|
+
const roles = p.roles ?? {};
|
|
126
|
+
const upper = Object.fromEntries(Object.entries(roles).map(([k, v]) => [k.replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase(), v]));
|
|
127
|
+
return Object.freeze({
|
|
128
|
+
provider: name,
|
|
129
|
+
kind: p.kind ?? null,
|
|
130
|
+
company: p.company ?? name,
|
|
131
|
+
baseUrl: p.baseUrl ?? null,
|
|
132
|
+
keyEnv: p.keyEnv ?? null,
|
|
133
|
+
keyEnvFallback: p.keyEnvFallback ?? null,
|
|
134
|
+
verifiedOn: p.verifiedOn ?? MODEL_CATALOG.verified,
|
|
135
|
+
roles: Object.freeze({ ...roles }),
|
|
136
|
+
ids: Object.freeze(Object.keys(p.models ?? {})),
|
|
137
|
+
aliases: Object.freeze({ ...(p.aliases ?? {}) }),
|
|
138
|
+
retired: Object.freeze((p.retired ?? []).map((r) => r.id)),
|
|
139
|
+
...upper, // e.g. roles.chatDefault → CHAT_DEFAULT, roles.cheap → CHEAP
|
|
140
|
+
...extra,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const DS = MODEL_CATALOG.cloud?.deepseek ?? {};
|
|
145
|
+
/** DeepSeek: FLASH/PRO are the cheap/strong roles; CHAT_DEFAULT, JUDGE, LEGACY_JUDGE from roles. */
|
|
146
|
+
export const DEEPSEEK = roleBlock("deepseek", {
|
|
147
|
+
FLASH: DS.roles?.cheap,
|
|
148
|
+
PRO: DS.roles?.strong,
|
|
149
|
+
anthropicBaseUrl: DS.anthropicBaseUrl ?? null,
|
|
150
|
+
peakHoursUtc: DS.peakHoursUtc ?? null,
|
|
151
|
+
});
|
|
152
|
+
/** Anthropic: DEFAULT/STRONG/MID/CHEAP/MAX/PREVIOUS_STRONG/PREVIOUS_MID + Claude Code CLI alias words. */
|
|
153
|
+
export const ANTHROPIC = roleBlock("anthropic", { cliAliases: Object.freeze({ ...(MODEL_CATALOG.cloud?.anthropic?.cliAliases ?? {}) }) });
|
|
154
|
+
export const OPENAI = roleBlock("openai");
|
|
155
|
+
export const GEMINI = roleBlock("gemini");
|
|
156
|
+
export const OPENROUTER = roleBlock("openrouter");
|
|
157
|
+
export const GITHUB_MODELS = roleBlock("github-models");
|
|
158
|
+
/** Ollama: BUILDER/CODER/JUDGE_FALLBACK/VISION/SMALL. */
|
|
159
|
+
export const OLLAMA = roleBlock("ollama");
|
|
160
|
+
export const VLLM = roleBlock("vllm");
|
|
161
|
+
|
|
162
|
+
// ── DeepSeek pricing corners ─────────────────────────────────────────────────────────────────
|
|
163
|
+
/** True when `date` falls inside DeepSeek's published peak window (UTC, weekdays). */
|
|
164
|
+
export function isDeepSeekPeak(date = new Date()) {
|
|
165
|
+
const p = DEEPSEEK.peakHoursUtc;
|
|
166
|
+
if (!p) return true;
|
|
167
|
+
const day = date.getUTCDay();
|
|
168
|
+
if (Array.isArray(p.days) && !p.days.includes(day)) return false;
|
|
169
|
+
const mins = date.getUTCHours() * 60 + date.getUTCMinutes();
|
|
170
|
+
const toMin = (hhmm) => { const [h, m] = String(hhmm).split(":").map(Number); return h * 60 + (m || 0); };
|
|
171
|
+
return (p.ranges ?? []).some(([a, b]) => mins >= toMin(a) && mins < toMin(b));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* $/1M for a DeepSeek id at a given (cacheHit, peak) corner. `peak` defaults to the clock via
|
|
176
|
+
* {@link isDeepSeekPeak}; pass `{ peak: true }` for the conservative upper bound.
|
|
177
|
+
* Returns { input, output } or null for a non-DeepSeek id.
|
|
178
|
+
*/
|
|
179
|
+
export function deepseekPricePer1M(id, { cacheHit = false, peak = null, at = new Date() } = {}) {
|
|
180
|
+
const m = modelInfo(id);
|
|
181
|
+
if (!m || m.provider !== "deepseek" || !m.pricePer1M?.peak) return null;
|
|
182
|
+
const corner = (peak ?? isDeepSeekPeak(at)) ? m.pricePer1M.peak : m.pricePer1M.offPeak;
|
|
183
|
+
return { input: cacheHit ? corner.cacheHit : corner.cacheMiss, output: corner.output };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Flat list price { input, output } $/1M for any catalog id: DeepSeek → PEAK CACHE-MISS (the
|
|
188
|
+
* conservative upper bound until cache-hit/off-peak accounting lands in cost()); other cloud →
|
|
189
|
+
* the provider's flat sheet; local → { 0, 0 }. null for an unknown id.
|
|
190
|
+
*/
|
|
191
|
+
export function listPricePer1M(id) {
|
|
192
|
+
const m = modelInfo(id);
|
|
193
|
+
if (!m) return null;
|
|
194
|
+
if (m.kind === "local") return { input: 0, output: 0 };
|
|
195
|
+
if (m.provider === "deepseek") return deepseekPricePer1M(id, { peak: true });
|
|
196
|
+
if (m.pricePer1M && Number.isFinite(m.pricePer1M.input)) return { input: m.pricePer1M.input, output: m.pricePer1M.output };
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── cloud-clients PROVIDERS derivation ───────────────────────────────────────────────────────
|
|
201
|
+
/**
|
|
202
|
+
* The full PROVIDERS table for cloud-clients.mjs, derived from every provider's `providerKeys`
|
|
203
|
+
* (plus, where `emitAliasesAsProviderKeys` is set, one entry per alias with `legacyAliasOf` so
|
|
204
|
+
* an old `/model deepseek-v4-flash` or stale COO_MODEL still resolves). Entry shape is what
|
|
205
|
+
* callModel/buildOpenAIBody/cost() already read: { kind, baseUrl, keyEnv, keyEnvFallback?,
|
|
206
|
+
* company?, model, priceIn, priceOut, tier, local?, noTemp?, legacyAliasOf? }.
|
|
207
|
+
*/
|
|
208
|
+
export function providerEntries() {
|
|
209
|
+
const out = {};
|
|
210
|
+
for (const [name, p] of INDEX.providers) {
|
|
211
|
+
if (!p.providerKeys || !p.baseUrl) continue;
|
|
212
|
+
const base = { kind: p.kind ?? "openai", baseUrl: p.baseUrl, keyEnv: p.keyEnv ?? null };
|
|
213
|
+
if (p.keyEnvFallback) base.keyEnvFallback = p.keyEnvFallback;
|
|
214
|
+
if (p.company && p.company !== name) base.company = p.company;
|
|
215
|
+
// Optional wire details cloud-clients.mjs honours when present: the strict-tools beta host
|
|
216
|
+
// and the per-seat isolation field name (DeepSeek declares both; other providers neither).
|
|
217
|
+
if (p.betaBaseUrl) base.betaBaseUrl = p.betaBaseUrl;
|
|
218
|
+
if (p.userIdField) base.userIdField = p.userIdField;
|
|
219
|
+
const entryFor = (modelId, overrides = {}) => {
|
|
220
|
+
const target = resolveModelId(modelId);
|
|
221
|
+
const m = modelInfo(target);
|
|
222
|
+
const price = listPricePer1M(target) ?? { input: 0, output: 0 };
|
|
223
|
+
const e = { ...base, model: modelId, priceIn: price.input, priceOut: price.output, tier: m?.tier ?? (p.locality === "local" ? "local" : "strong") };
|
|
224
|
+
if (m?.kind === "local" || overrides.local) e.local = true;
|
|
225
|
+
if (m?.noTemp || overrides.noTemp) e.noTemp = true;
|
|
226
|
+
if (overrides.legacyAliasOf) e.legacyAliasOf = overrides.legacyAliasOf;
|
|
227
|
+
return e;
|
|
228
|
+
};
|
|
229
|
+
for (const [key, spec] of Object.entries(p.providerKeys)) out[key] = entryFor(spec.model, spec);
|
|
230
|
+
if (p.emitAliasesAsProviderKeys) {
|
|
231
|
+
for (const [alias, target] of Object.entries(p.aliases ?? {})) {
|
|
232
|
+
if (out[alias] || Object.values(p.providerKeys).some((s) => s.model === alias)) continue;
|
|
233
|
+
out[alias] = entryFor(target, { legacyAliasOf: target });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
/** Back-compat name: the DeepSeek slice of {@link providerEntries}. */
|
|
240
|
+
export function deepseekProviderEntries() {
|
|
241
|
+
return Object.fromEntries(Object.entries(providerEntries()).filter(([, e]) => e.baseUrl === DEEPSEEK.baseUrl));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── fleet-manifest derivation ────────────────────────────────────────────────────────────────
|
|
245
|
+
/**
|
|
246
|
+
* The rows fleet-manifest.mjs used to hand-maintain: `{ contextWindows, costEstimates }` keyed
|
|
247
|
+
* by id, for EVERY catalog model (cloud rows carry vramRequiredMb: null and "api"/"metered").
|
|
248
|
+
*/
|
|
249
|
+
export function fleetManifestModelRows() {
|
|
250
|
+
const contextWindows = {};
|
|
251
|
+
const costEstimates = {};
|
|
252
|
+
for (const id of ALL_MODEL_IDS) {
|
|
253
|
+
const m = modelInfo(id);
|
|
254
|
+
contextWindows[id] = {
|
|
255
|
+
contextWindow: m.contextWindow,
|
|
256
|
+
vramRequiredMb: m.vramRequiredMb ?? null,
|
|
257
|
+
architecture: m.architecture ?? m.provider,
|
|
258
|
+
capabilities: m.capabilities ?? [],
|
|
259
|
+
};
|
|
260
|
+
const price = listPricePer1M(id);
|
|
261
|
+
if (m.kind === "local") {
|
|
262
|
+
costEstimates[id] = { kind: "free", note: `local model, zero API cost.${m.note ? " " + m.note : ""}` };
|
|
263
|
+
} else if (m.provider === "deepseek") {
|
|
264
|
+
costEstimates[id] = { kind: "metered", usdPerMInput: price.input, usdPerMOutput: price.output,
|
|
265
|
+
note: `${m.displayName ?? id}: peak cache-miss list rates (model-catalog.json, verified ${providerInfo("deepseek")?.verifiedOn}); off-peak halves, cache hits ~50x cheaper` };
|
|
266
|
+
} else if (price) {
|
|
267
|
+
costEstimates[id] = { kind: "metered", usdPerMInput: price.input, usdPerMOutput: price.output,
|
|
268
|
+
note: `${m.displayName ?? id}: list rates (model-catalog.json, verified ${providerOf(id)?.verifiedOn ?? MODEL_CATALOG.verified})` };
|
|
269
|
+
} else {
|
|
270
|
+
costEstimates[id] = { kind: "metered", note: m.note ?? "pricing not recorded in model-catalog.json" };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { contextWindows, costEstimates };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The subset of `ids` that are retired — for router/seat self-tests and the lint. */
|
|
277
|
+
export function findRetiredIds(ids) {
|
|
278
|
+
return [...new Set(ids)].filter((id) => isRetiredModelId(id));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── self-test ────────────────────────────────────────────────────────────────────────────────
|
|
282
|
+
export function runSelfTest({ log = console.log } = {}) {
|
|
283
|
+
let ok = true;
|
|
284
|
+
const check = (name, cond) => { log(` ${cond ? "✓" : "✗"} ${name}`); if (!cond) ok = false; };
|
|
285
|
+
log("== model-catalog self-test ==\n");
|
|
286
|
+
check("catalog has a verified date", /^\d{4}-\d{2}-\d{2}$/.test(String(MODEL_CATALOG.verified)));
|
|
287
|
+
check("every provider role points at a current id of that provider",
|
|
288
|
+
[...INDEX.providers.values()].every((p) => Object.values(p.roles ?? {}).every((id) => id in (p.models ?? {}) || (p.aliases ?? {})[id])));
|
|
289
|
+
check("every providerKeys.model is a current id or a declared alias of that provider",
|
|
290
|
+
[...INDEX.providers.values()].every((p) => Object.values(p.providerKeys ?? {}).every((s) => s.model in (p.models ?? {}) || (p.aliases ?? {})[s.model])));
|
|
291
|
+
check("no alias target is itself an alias or retired",
|
|
292
|
+
Object.values(MODEL_ALIASES).every((t) => INDEX.byId.has(t) && !INDEX.retired.has(t)));
|
|
293
|
+
check("every retired id has an alias to its successor",
|
|
294
|
+
RETIRED_MODEL_IDS.every((id) => MODEL_ALIASES[id]));
|
|
295
|
+
check("no current id is also listed as retired",
|
|
296
|
+
ALL_MODEL_IDS.every((id) => !INDEX.retired.has(id)));
|
|
297
|
+
check("every model has a positive context window",
|
|
298
|
+
ALL_MODEL_IDS.every((id) => modelInfo(id).contextWindow > 0));
|
|
299
|
+
check("every cloud model has a list price",
|
|
300
|
+
ALL_MODEL_IDS.filter((id) => !isLocalModel(id)).every((id) => listPricePer1M(id)?.input > 0 && listPricePer1M(id)?.output > 0));
|
|
301
|
+
check("DeepSeek prices are four-corner, positive, and off-peak is half of peak",
|
|
302
|
+
DEEPSEEK.ids.every((id) => { const p = modelInfo(id).pricePer1M; return ["cacheHit", "cacheMiss", "output"].every((k) => p?.peak?.[k] > 0 && Math.abs(p.peak[k] - 2 * p.offPeak[k]) < 1e-9); }));
|
|
303
|
+
check("every local model has a VRAM figure",
|
|
304
|
+
ALL_MODEL_IDS.filter(isLocalModel).every((id) => Number.isFinite(modelInfo(id).vramRequiredMb)));
|
|
305
|
+
check("resolveModelId maps a retired id to its successor",
|
|
306
|
+
RETIRED_MODEL_IDS.every((id) => ALL_MODEL_IDS.includes(resolveModelId(id))));
|
|
307
|
+
check("providerEntries covers every providerKey and every DeepSeek alias, with prices", (() => {
|
|
308
|
+
const e = providerEntries();
|
|
309
|
+
const keys = [...INDEX.providers.values()].flatMap((p) => Object.keys(p.providerKeys ?? {}));
|
|
310
|
+
return keys.every((k) => e[k]?.model && e[k].baseUrl && Number.isFinite(e[k].priceIn))
|
|
311
|
+
&& Object.keys(DEEPSEEK.aliases).every((a) => e[a]?.legacyAliasOf || e.deepseek?.model === a);
|
|
312
|
+
})());
|
|
313
|
+
check("isDeepSeekPeak: Tue 02:30 UTC is peak, Sat 02:30 UTC is not, Tue 12:00 UTC is not",
|
|
314
|
+
isDeepSeekPeak(new Date("2026-09-15T02:30:00Z")) && !isDeepSeekPeak(new Date("2026-09-19T02:30:00Z")) && !isDeepSeekPeak(new Date("2026-09-15T12:00:00Z")));
|
|
315
|
+
check("fleetManifestModelRows covers every catalog id",
|
|
316
|
+
Object.keys(fleetManifestModelRows().contextWindows).length === ALL_MODEL_IDS.length);
|
|
317
|
+
check("Claude CLI alias words resolve to current Anthropic ids",
|
|
318
|
+
Object.values(ANTHROPIC.cliAliases).every((id) => ANTHROPIC.ids.includes(id)));
|
|
319
|
+
log(`\n${ok ? "✓ self-test PASS — catalog invariants verified." : "✗ self-test FAIL"}`);
|
|
320
|
+
return ok ? 0 : 1;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// `process.argv` is undefined in a browser bundle (Next only shims `process.env`), so guard the
|
|
324
|
+
// whole CLI branch instead of indexing into it.
|
|
325
|
+
const isMain =
|
|
326
|
+
typeof process !== "undefined" &&
|
|
327
|
+
Array.isArray(process.argv) &&
|
|
328
|
+
Boolean(process.argv[1]) &&
|
|
329
|
+
/model-catalog\.mjs$/.test(String(process.argv[1]).replace(/\\/g, "/"));
|
|
330
|
+
if (isMain) {
|
|
331
|
+
const argv = process.argv.slice(2);
|
|
332
|
+
if (argv.includes("--self-test")) process.exit(runSelfTest());
|
|
333
|
+
if (argv.includes("--print")) {
|
|
334
|
+
console.log(JSON.stringify({ DEEPSEEK, ANTHROPIC, OPENAI, GEMINI, OPENROUTER, GITHUB_MODELS, OLLAMA, VLLM, ALL_MODEL_IDS, MODEL_ALIASES, RETIRED_MODEL_IDS, providerEntries: providerEntries() }, null, 2));
|
|
335
|
+
process.exit(0);
|
|
336
|
+
}
|
|
337
|
+
if (argv.length) { console.error("usage: node dev-factory/lib/model-catalog.mjs --self-test | --print"); process.exit(64); }
|
|
338
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Shared dev-factory seat-context builder — the single source of truth for the
|
|
2
|
+
// "DEV-FACTORY SEAT: <title> — <mandate>" lines that give a session its COO
|
|
3
|
+
// identity and delegation protocol.
|
|
4
|
+
//
|
|
5
|
+
// THE ONE REGISTRY (epic HS-RJVM AC 2, task HS-DZOR): every front's seat text is
|
|
6
|
+
// produced HERE, from dev-factory/seats.json and nowhere else. No front carries a
|
|
7
|
+
// second copy of any seat's mandate; the two always-loaded instruction files
|
|
8
|
+
// (COMMANDCODE.md, .cursor/rules/dev-factory-seats.mdc) hold an AUTO-GENERATED
|
|
9
|
+
// managed block regenerated from this builder by `npm run seats:sync`, which is
|
|
10
|
+
// derived output, not an authored copy.
|
|
11
|
+
//
|
|
12
|
+
// Consumed by:
|
|
13
|
+
// - scripts/claude-hooks/inject-coo-seat.mjs → SessionStart context injection (Claude Code plugin)
|
|
14
|
+
// - scripts/sync-seat-context.mjs → writes the managed blocks into COMMANDCODE.md
|
|
15
|
+
// and .cursor/rules/dev-factory-seats.mdc
|
|
16
|
+
// (Command Code + Cursor, which have no
|
|
17
|
+
// session-start context-injection hook)
|
|
18
|
+
// - spexharness/src/seat-context.mjs → the harness's front-agnostic loader: the
|
|
19
|
+
// chat front, the dispatcher and the headless
|
|
20
|
+
// seats all go through it, so they inherit this
|
|
21
|
+
// builder's text rather than reimplementing it
|
|
22
|
+
//
|
|
23
|
+
// Keeping every surface on this one function is what guarantees DeepSeek
|
|
24
|
+
// (Command Code), Composer (Cursor), a headless seat and Claude Code all receive
|
|
25
|
+
// byte-identical seat context — no drift from seats.json.
|
|
26
|
+
//
|
|
27
|
+
// SESSION-CONTEXT BUDGET (registry v3). The registry declares, per row, how much of
|
|
28
|
+
// the seat belongs in a session's opening context:
|
|
29
|
+
//
|
|
30
|
+
// contract.sessionContext = "inject" → the seat's full mandate is injected
|
|
31
|
+
// contract.sessionContext = "reference" → the seat is only NAMED; its mandate stays
|
|
32
|
+
// in dev-factory/seats.json, to be read on demand
|
|
33
|
+
//
|
|
34
|
+
// Honouring that field is what keeps session-start injection from growing linearly
|
|
35
|
+
// with the roster: when the registry went live with 27 rows, injecting every active
|
|
36
|
+
// row unconditionally pushed the block from 12 lines to 22 (5,237 → ~9,190 bytes).
|
|
37
|
+
// Rows whose contract says "reference" (the sub-agent seats, whose mandates the
|
|
38
|
+
// sub-agent definitions already carry at dispatch time) are now named on one line
|
|
39
|
+
// instead of injected in full.
|
|
40
|
+
//
|
|
41
|
+
// A row with no contract.sessionContext defaults to "inject" — v1/v2 registries and
|
|
42
|
+
// hand-written fixtures keep their existing behaviour.
|
|
43
|
+
|
|
44
|
+
import { readFileSync } from 'node:fs';
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
import { fileURLToPath } from 'node:url';
|
|
47
|
+
import { resolveCooModeEnabled } from './fleet-readiness.mjs';
|
|
48
|
+
|
|
49
|
+
/** Repo root: honor CLAUDE_PROJECT_DIR, else two dirs up from dev-factory/lib. */
|
|
50
|
+
export function repoRoot() {
|
|
51
|
+
if (process.env.CLAUDE_PROJECT_DIR) return process.env.CLAUDE_PROJECT_DIR;
|
|
52
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The single registry file every front reads. Relative to the repo root. */
|
|
56
|
+
export const SEATS_REGISTRY_RELATIVE_PATH = path.join('dev-factory', 'seats.json');
|
|
57
|
+
|
|
58
|
+
/** The two session-context modes a seat contract may declare. */
|
|
59
|
+
export const SESSION_CONTEXT_MODES = Object.freeze(['inject', 'reference']);
|
|
60
|
+
|
|
61
|
+
/** A row with no declared mode behaves exactly as it did before v3. */
|
|
62
|
+
export const DEFAULT_SESSION_CONTEXT_MODE = 'inject';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The marker that identifies an AUTO-GENERATED seat block (written by
|
|
66
|
+
* scripts/sync-seat-context.mjs from this builder). Seat text found in a file
|
|
67
|
+
* carrying this marker is derived output of the one registry, not a second copy.
|
|
68
|
+
*/
|
|
69
|
+
export const GENERATED_SEAT_BLOCK_MARKER = 'BEGIN DEV-FACTORY SEATS';
|
|
70
|
+
|
|
71
|
+
/** Absolute path of the single registry file for `root`. */
|
|
72
|
+
export function seatsRegistryPath(root = repoRoot()) {
|
|
73
|
+
return path.join(root, SEATS_REGISTRY_RELATIVE_PATH);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read + parse the single registry. NEVER throws — a missing or corrupt
|
|
78
|
+
* seats.json is `null`, the same fail-soft posture every consumer relies on.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} [root]
|
|
81
|
+
* @returns {object|null}
|
|
82
|
+
*/
|
|
83
|
+
export function loadSeatRegistry(root = repoRoot()) {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(readFileSync(seatsRegistryPath(root), 'utf8'));
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Every seat row in a registry, or [] for a missing/odd-shaped one. */
|
|
92
|
+
export function registrySeats(registry) {
|
|
93
|
+
return Array.isArray(registry?.seats) ? registry.seats.filter((s) => s && typeof s === 'object') : [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One seat row by id, or null. The single-registry lookup every front shares. */
|
|
97
|
+
export function findSeat(registry, seatId) {
|
|
98
|
+
if (!seatId) return null;
|
|
99
|
+
return registrySeats(registry).find((s) => s.id === seatId) ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The session-context mode a seat's CONTRACT declares: 'inject' (full mandate in
|
|
104
|
+
* the opening context) or 'reference' (named only). Unknown/missing → the default.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} seat
|
|
107
|
+
* @returns {'inject'|'reference'}
|
|
108
|
+
*/
|
|
109
|
+
export function seatSessionContextMode(seat) {
|
|
110
|
+
const declared = seat?.contract?.sessionContext;
|
|
111
|
+
return SESSION_CONTEXT_MODES.includes(declared) ? declared : DEFAULT_SESSION_CONTEXT_MODE;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A seat is injected/named at session start only while it is FILLED AND ENFORCED. */
|
|
115
|
+
function isActive(seat) {
|
|
116
|
+
return seat?.active === true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The one line a seat in "inject" mode contributes. */
|
|
120
|
+
export function seatInjectLine(seat) {
|
|
121
|
+
return `DEV-FACTORY SEAT: ${seat.title} — ${seat.mandate}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The single line that NAMES every "reference" seat without carrying its mandate. */
|
|
125
|
+
export function seatReferenceLine(seats) {
|
|
126
|
+
const named = seats.map((seat) => `${seat.title} (${seat.id})`).join(', ');
|
|
127
|
+
return `DEV-FACTORY SEATS (reference only — full mandate in ${SEATS_REGISTRY_RELATIVE_PATH.replace(/\\/g, '/')}): ${named}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The structured seat context — the shape every front formats from, so a chat
|
|
132
|
+
* front, the dispatcher, a headless seat and the Claude Code hook all agree on
|
|
133
|
+
* WHICH seats are in play before anyone turns them into text.
|
|
134
|
+
*
|
|
135
|
+
* @param {{ root?: string, registry?: object|null }} [opts]
|
|
136
|
+
* `registry` (when supplied) is used as-is — no disk read — so callers and tests
|
|
137
|
+
* can drive the builder from an in-memory roster.
|
|
138
|
+
* @returns {{
|
|
139
|
+
* registry: object|null,
|
|
140
|
+
* enabled: boolean,
|
|
141
|
+
* reason: string,
|
|
142
|
+
* inject: object[],
|
|
143
|
+
* reference: object[],
|
|
144
|
+
* text: string|null,
|
|
145
|
+
* }}
|
|
146
|
+
*/
|
|
147
|
+
export function buildSeatContextModel({ root = repoRoot(), registry } = {}) {
|
|
148
|
+
const reg = registry === undefined ? loadSeatRegistry(root) : registry;
|
|
149
|
+
if (!reg) {
|
|
150
|
+
return { registry: null, enabled: false, reason: 'registry missing or corrupt', inject: [], reference: [], text: null };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const coo = resolveCooModeEnabled(reg?.settings?.cooMode);
|
|
154
|
+
if (!coo.enabled) {
|
|
155
|
+
return { registry: reg, enabled: false, reason: coo.reason, inject: [], reference: [], text: null };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const active = registrySeats(reg).filter(isActive);
|
|
159
|
+
const inject = active.filter(
|
|
160
|
+
(seat) =>
|
|
161
|
+
seatSessionContextMode(seat) === 'inject' &&
|
|
162
|
+
typeof seat.title === 'string' &&
|
|
163
|
+
typeof seat.mandate === 'string',
|
|
164
|
+
);
|
|
165
|
+
const reference = active.filter(
|
|
166
|
+
(seat) => seatSessionContextMode(seat) === 'reference' && typeof seat.title === 'string',
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const lines = inject.map(seatInjectLine);
|
|
170
|
+
if (reference.length > 0) lines.push(seatReferenceLine(reference));
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
registry: reg,
|
|
174
|
+
enabled: true,
|
|
175
|
+
reason: coo.reason,
|
|
176
|
+
inject,
|
|
177
|
+
reference,
|
|
178
|
+
text: lines.length > 0 ? lines.join('\n') : null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build the seat-injection text, or null when nothing should be injected.
|
|
184
|
+
*
|
|
185
|
+
* Rules (MUST match the MCP server + the Claude SessionStart hook so every
|
|
186
|
+
* surface stays byte-identical — epic HS-CXO0 AC, epic HS-RJVM AC 2):
|
|
187
|
+
* - missing/corrupt seats.json → null
|
|
188
|
+
* - resolveCooModeEnabled(...) === false → null (cooMode false, or "auto" and fleet not ready)
|
|
189
|
+
* - no active seat that yields a line → null
|
|
190
|
+
* - otherwise → one "DEV-FACTORY SEAT: <title> — <mandate>" line per ACTIVE seat whose
|
|
191
|
+
* contract.sessionContext is "inject" (the default), followed — when the roster has
|
|
192
|
+
* any — by a single line NAMING the active "reference" seats.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} [root] repo root (defaults to repoRoot())
|
|
195
|
+
* @returns {string|null}
|
|
196
|
+
*/
|
|
197
|
+
export function buildSeatContext(root = repoRoot()) {
|
|
198
|
+
return buildSeatContextModel({ root }).text;
|
|
199
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec lookup + frontmatter split for the standalone reviewer.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately tiny: it finds a spec file under specs/ by id and splits the
|
|
5
|
+
* YAML frontmatter from the markdown body. It does NOT do a full YAML parse —
|
|
6
|
+
* the acceptance-criteria parser only needs the body in the common case (task
|
|
7
|
+
* specs carry criteria as a `## Acceptance criteria` checklist). The rare
|
|
8
|
+
* `acceptance:` frontmatter array is detected with a minimal scan.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
15
|
+
|
|
16
|
+
/** Recursively collect every .md file under a directory.
|
|
17
|
+
* Dot-directories (specs/.trash, .obsidian, …) are SKIPPED: regen-replaced tasks keep a
|
|
18
|
+
* same-named retired body under specs/.trash/, and because ".trash" sorts first the
|
|
19
|
+
* retired copy SHADOWED the live spec — the 2026-07-09 HS-ATW1 re-dispatch built the
|
|
20
|
+
* retired task (wrong target, 4 stale ACs) instead of the amended live one. */
|
|
21
|
+
function walkMarkdown(dir, out = []) {
|
|
22
|
+
for (const entry of readdirSync(dir)) {
|
|
23
|
+
if (entry.startsWith(".")) continue; // never resolve into .trash / dot-dirs
|
|
24
|
+
const full = join(dir, entry);
|
|
25
|
+
const st = statSync(full);
|
|
26
|
+
if (st.isDirectory()) walkMarkdown(full, out);
|
|
27
|
+
else if (entry.endsWith(".md")) out.push(full);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Find the spec file for an id. Matches the id anywhere in the basename
|
|
34
|
+
* (case-insensitive), so it tolerates both legacy `HS-TASK-XXXX-slug` and
|
|
35
|
+
* pipeline-first `HS-XXXX-TASK-slug` filenames during the MB2M migration.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} specsDir absolute path to the vault's specs/ directory
|
|
38
|
+
* @param {string} specId e.g. "HS-TASK-9SHA" or a bare "9SHA"
|
|
39
|
+
* @returns {string|null} absolute file path, or null if not found
|
|
40
|
+
*/
|
|
41
|
+
export function findSpecFile(specsDir, specId) {
|
|
42
|
+
const needle = specId.toLowerCase();
|
|
43
|
+
const files = walkMarkdown(specsDir);
|
|
44
|
+
const basenames = files.map((f) => f.split(/[\\/]/).pop().toLowerCase());
|
|
45
|
+
const exact = files.find((f) => f.split(/[\\/]/).pop().toLowerCase() === `${needle}.md`);
|
|
46
|
+
if (exact) return exact;
|
|
47
|
+
const prefixMatches = files.filter((f) =>
|
|
48
|
+
f.split(/[\\/]/).pop().toLowerCase().startsWith(`${needle}-`),
|
|
49
|
+
);
|
|
50
|
+
if (prefixMatches.length === 1) return prefixMatches[0];
|
|
51
|
+
if (prefixMatches.length > 1) return null;
|
|
52
|
+
const loose = files.find((f) => basenames.includes(needle) || f.split(/[\\/]/).pop().toLowerCase().includes(needle));
|
|
53
|
+
return loose ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read + split a spec file.
|
|
58
|
+
* @param {string} filePath
|
|
59
|
+
* @returns {{ id: string|null, repo: string|null, frontmatter: { acceptance?: string[] }|null, body: string, raw: string }}
|
|
60
|
+
*/
|
|
61
|
+
export function parseSpec(filePath) {
|
|
62
|
+
const raw = readFileSync(filePath, "utf8");
|
|
63
|
+
const m = raw.match(FRONTMATTER);
|
|
64
|
+
const fmText = m?.[1] ?? "";
|
|
65
|
+
const body = m ? raw.slice(m[0].length) : raw;
|
|
66
|
+
|
|
67
|
+
const id = fmText.match(/^id:\s*["']?([^"'\r\n]+)/m)?.[1]?.trim() ?? null;
|
|
68
|
+
|
|
69
|
+
// HS-OM28 multi-repo builds: a task may declare WHICH repo its build runs in via a
|
|
70
|
+
// `repo:` frontmatter key (a named-repo table key, resolved through
|
|
71
|
+
// dev-factory/routing-table.json `repos` by lib/build-root.mjs — never a path).
|
|
72
|
+
// Absent → null → the vault itself, byte-identical single-repo behavior.
|
|
73
|
+
const repo = fmText.match(/^repo:\s*["']?([^"'\r\n]+)/m)?.[1]?.trim() ?? null;
|
|
74
|
+
|
|
75
|
+
// Minimal detection of an `acceptance:` frontmatter list (block form).
|
|
76
|
+
let acceptance;
|
|
77
|
+
const accBlock = fmText.match(/^acceptance:\s*\r?\n((?:\s*-\s+.+\r?\n?)+)/m);
|
|
78
|
+
if (accBlock) {
|
|
79
|
+
acceptance = accBlock[1]
|
|
80
|
+
.split(/\r?\n/)
|
|
81
|
+
.map((l) => l.match(/^\s*-\s+(.+?)\s*$/)?.[1])
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.map((s) => s.replace(/^["']|["']$/g, ""));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
id,
|
|
88
|
+
repo,
|
|
89
|
+
frontmatter: acceptance ? { acceptance } : null,
|
|
90
|
+
body,
|
|
91
|
+
raw,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -125,8 +125,14 @@ const USER_STORIES_RE =
|
|
|
125
125
|
const DONE_CRITERIA_RE =
|
|
126
126
|
/##[ \t]*(?:Done[ \t]+When|Acceptance[ \t]+Criteria(?:\s*\([^)\n]*\))?|Definition[ \t]+of[ \t]+Done)[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i;
|
|
127
127
|
|
|
128
|
-
/**
|
|
129
|
-
|
|
128
|
+
/**
|
|
129
|
+
* "As a <role>, I want …" — a user story with a real, named role.
|
|
130
|
+
* Also accepts "As the <role>, …" (e.g. "As the owner, I want …") — the
|
|
131
|
+
* dominant phrasing across this vault's single-owner captures, which "as a/an"
|
|
132
|
+
* alone silently failed to recognize as a role
|
|
133
|
+
* ([[HS-K4JN-NG84-DIAG-the-capture-sufficiency-gate-reports]]).
|
|
134
|
+
*/
|
|
135
|
+
const USER_STORY_WITH_ROLE_RE = /\bas\s+(?:an?|the)\s+\S+/i;
|
|
130
136
|
/**
|
|
131
137
|
* A real (not AI-illustrative) Example is labeled by provenance
|
|
132
138
|
* (capture-guardrails §Example). Tolerates markdown emphasis around the label
|