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,733 @@
|
|
|
1
|
+
// spexharness/src/doctrine/corpus.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]]) — the streaming,
|
|
5
|
+
// memory-bounded extractor over every source sources.mjs enumerates. Never
|
|
6
|
+
// `readFileSync`s a whole session file (some run past 100 MB): every JSONL/markdown
|
|
7
|
+
// file is read line-by-line via `node:readline` over a `createReadStream`.
|
|
8
|
+
//
|
|
9
|
+
// One record per signal, with provenance: `{ id, source, project, session, ts, seq,
|
|
10
|
+
// kind, text, context?, confidence? }`. `kind` is one of:
|
|
11
|
+
// - `instruction` — a user turn ≥ 12 words that directs work (imperative/preference
|
|
12
|
+
// language; approximated here as "12+ words, not already a ruling/correction").
|
|
13
|
+
// - `correction` — a user turn that immediately follows an assistant turn and
|
|
14
|
+
// contains a reversal/reprimand marker ("no,", "not that", "wrong", "don't",
|
|
15
|
+
// "stop", "instead", "i said", "why did you"); carries a 240-char `context` of
|
|
16
|
+
// the assistant text it corrects.
|
|
17
|
+
// - `ruling` — contains an explicit-decision marker ("always", "never", "from
|
|
18
|
+
// now on", "owner ruling", "yes -", "no -", "rule:", "decision:"), any length.
|
|
19
|
+
// - `approval`/`refusal` — a permission-mode record (Claude Code) or a tool-confirm
|
|
20
|
+
// answer.
|
|
21
|
+
// - `taste` — one `- <text> Confidence: <n>` line from a Command Code
|
|
22
|
+
// taste.md, `confidence` carried.
|
|
23
|
+
//
|
|
24
|
+
// Every `text`/`context` is redacted via spexharness/src/redact.mjs BEFORE it is
|
|
25
|
+
// written anywhere — this module never even holds an unredacted string past the
|
|
26
|
+
// point of extraction. Assistant text is kept only transiently (as `context` for a
|
|
27
|
+
// following correction), never emitted as its own observation. Pasted
|
|
28
|
+
// screenshots/images and raw tool payloads are skipped (never even reach the
|
|
29
|
+
// classifier — a `tool_result`/`tool_reference`-only content array is dropped whole).
|
|
30
|
+
//
|
|
31
|
+
// OWNER-AUTHORSHIP (2026-09-20): a `tool_result`-block check is not enough — machine text
|
|
32
|
+
// also arrives as an ordinary `type:"text"` block in a `type:"user"` record. Two screens now
|
|
33
|
+
// run before classification: `isOwnerAuthoredClaudeCodeRecord` (record shape) and
|
|
34
|
+
// `machineTextMarker` (text shape). See the "owner-authored vs machine-authored" section.
|
|
35
|
+
//
|
|
36
|
+
// Incremental: a per-session state entry `{ mtime, lines, lastAssistantText,
|
|
37
|
+
// lastRecordType }` lets a re-run (a) skip a session outright when its file's mtime
|
|
38
|
+
// is unchanged, and (b) resume a grown session from the line it left off at, so only
|
|
39
|
+
// the NEW lines are parsed and only the NEW observations are appended/returned.
|
|
40
|
+
|
|
41
|
+
import { closeSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeSync } from 'node:fs';
|
|
42
|
+
import { createInterface } from 'node:readline';
|
|
43
|
+
import { dirname, join } from 'node:path';
|
|
44
|
+
|
|
45
|
+
import { redactText } from '../redact.mjs';
|
|
46
|
+
import { enumerateSources as defaultEnumerateSources, sourceKey } from './sources.mjs';
|
|
47
|
+
|
|
48
|
+
export { sourceKey };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {{
|
|
52
|
+
* id: string,
|
|
53
|
+
* source: string,
|
|
54
|
+
* project: string,
|
|
55
|
+
* session: string,
|
|
56
|
+
* ts: string|null,
|
|
57
|
+
* seq: number,
|
|
58
|
+
* kind: 'instruction'|'correction'|'ruling'|'approval'|'refusal'|'taste',
|
|
59
|
+
* text: string,
|
|
60
|
+
* context?: string,
|
|
61
|
+
* confidence?: number,
|
|
62
|
+
* }} DoctrineObservation
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
// ── kind classification ────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const RULING_MARKERS = [
|
|
68
|
+
/\balways\b/i,
|
|
69
|
+
/\bnever\b/i,
|
|
70
|
+
/\bfrom now on\b/i,
|
|
71
|
+
/\bowner ruling\b/i,
|
|
72
|
+
/\byes\s*-/i,
|
|
73
|
+
/\bno\s*-/i,
|
|
74
|
+
/\brule:/i,
|
|
75
|
+
/\bdecision:/i,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const CORRECTION_MARKERS = [
|
|
79
|
+
/\bno,/i,
|
|
80
|
+
/\bnot that\b/i,
|
|
81
|
+
/\bwrong\b/i,
|
|
82
|
+
/\bdon'?t\b/i,
|
|
83
|
+
/\bstop\b/i,
|
|
84
|
+
/\binstead\b/i,
|
|
85
|
+
/\bi said\b/i,
|
|
86
|
+
/\bwhy did you\b/i,
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const MIN_INSTRUCTION_WORDS = 12;
|
|
90
|
+
|
|
91
|
+
function wordCount(text) {
|
|
92
|
+
const trimmed = text.trim();
|
|
93
|
+
if (!trimmed) return 0;
|
|
94
|
+
return trimmed.split(/\s+/).filter(Boolean).length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Classify one user-authored turn's text. Precedence: a CORRECTION only fires when
|
|
99
|
+
* the turn immediately follows an assistant turn (`precededByAssistant`) AND carries
|
|
100
|
+
* a reversal/reprimand marker; else a RULING marker (any length) wins; else 12+ words
|
|
101
|
+
* makes it an INSTRUCTION; otherwise the turn carries no signal and is dropped.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} text
|
|
104
|
+
* @param {{ precededByAssistant?: boolean }} [opts]
|
|
105
|
+
* @returns {'correction'|'ruling'|'instruction'|null}
|
|
106
|
+
*/
|
|
107
|
+
export function classifyUserText(text, { precededByAssistant = false } = {}) {
|
|
108
|
+
if (typeof text !== 'string' || !text.trim()) return null;
|
|
109
|
+
if (precededByAssistant && CORRECTION_MARKERS.some((re) => re.test(text))) return 'correction';
|
|
110
|
+
if (RULING_MARKERS.some((re) => re.test(text))) return 'ruling';
|
|
111
|
+
if (wordCount(text) >= MIN_INSTRUCTION_WORDS) return 'instruction';
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── content-shape helpers (shared by Claude Code + Command Code, same block shape) ──
|
|
116
|
+
|
|
117
|
+
function extractTextFromContent(content) {
|
|
118
|
+
if (typeof content === 'string') return content;
|
|
119
|
+
if (!Array.isArray(content)) return '';
|
|
120
|
+
return content
|
|
121
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
122
|
+
.map((b) => b.text)
|
|
123
|
+
.join('\n');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** True when every block in an array content is a tool result/reference — never the owner's words. */
|
|
127
|
+
function isToolOnlyContent(content) {
|
|
128
|
+
if (!Array.isArray(content) || content.length === 0) return false;
|
|
129
|
+
return content.every((b) => b && (b.type === 'tool_result' || b.type === 'tool_reference'));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── owner-authored vs machine-authored text ────────────────────────────────────────
|
|
133
|
+
//
|
|
134
|
+
// `isToolOnlyContent` above only catches a literal `tool_result`/`tool_reference` BLOCK.
|
|
135
|
+
// The far larger contamination channel is machine text that arrives as an ordinary
|
|
136
|
+
// `type:"text"` block inside a `type:"user"` record, which sails straight through to the
|
|
137
|
+
// classifier. Measured on this machine 2026-09-20: 862 of 5,328 stored observations (16.2%)
|
|
138
|
+
// were not the owner's words at all — 483 copies of the sidecar title-generator's own system
|
|
139
|
+
// prompt, 376 `<task-notification>` blobs, plus hook-injected context.
|
|
140
|
+
//
|
|
141
|
+
// Two independent screens, because each catches what the other cannot:
|
|
142
|
+
// 1. RECORD SHAPE (`isOwnerAuthoredClaudeCodeRecord`) — Claude Code's own `promptSource`
|
|
143
|
+
// discriminator, the Claude Code counterpart of the `meta.source !== 'user'` check
|
|
144
|
+
// `handleCommandCodeRecord` has always had. Precise, but only present on newer records.
|
|
145
|
+
// 2. TEXT SHAPE (`machineTextMarker`) — a small, documented denylist of the control
|
|
146
|
+
// envelopes and injected preambles the harness and host actually emit. Catches older
|
|
147
|
+
// records that predate `promptSource`, and already-stored observations (see
|
|
148
|
+
// `filterObservations`, the corpus-hygiene path).
|
|
149
|
+
//
|
|
150
|
+
// DELIBERATELY CONSERVATIVE. A genuine owner line dropped here costs one habit citation; a
|
|
151
|
+
// machine line learned here is laundered into a confident-looking committed principle that
|
|
152
|
+
// ships to every clone. When in doubt, drop it.
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `promptSource` values that mean "the owner put this text here". Verified on this machine
|
|
156
|
+
* 2026-09-20 over 5,803 Claude Code user records: `typed` (201), `queued` (11),
|
|
157
|
+
* `suggestion_accepted` (6) — against `system` (171: hook injections and task
|
|
158
|
+
* notifications) and, in the sidecar SDK sessions, `sdk`. `paste`/`slash_command` are
|
|
159
|
+
* listed defensively; they are owner-originated shapes the host may emit.
|
|
160
|
+
*/
|
|
161
|
+
export const OWNER_PROMPT_SOURCES = Object.freeze([
|
|
162
|
+
'typed',
|
|
163
|
+
'queued',
|
|
164
|
+
'paste',
|
|
165
|
+
'pasted',
|
|
166
|
+
'slash_command',
|
|
167
|
+
'suggestion_accepted',
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* True when a Claude Code `type:"user"` record is a turn the OWNER authored, as opposed to
|
|
172
|
+
* one the host or a hook injected into the transcript in user position.
|
|
173
|
+
*
|
|
174
|
+
* A record with NO `promptSource` is allowed through (5,414 of 5,803 on this machine — the
|
|
175
|
+
* field is recent, and most of those are tool-result relays already dropped by
|
|
176
|
+
* `isToolOnlyContent`); those are caught by the text screen instead. A record WITH a
|
|
177
|
+
* `promptSource` must name an owner origin — an UNRECOGNISED value is rejected, which is the
|
|
178
|
+
* conservative direction: a future host adding a new owner-typed source costs us some genuine
|
|
179
|
+
* lines until it is listed here, whereas allowing unknowns re-opens exactly this bug.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} rec
|
|
182
|
+
* @returns {boolean}
|
|
183
|
+
*/
|
|
184
|
+
export function isOwnerAuthoredClaudeCodeRecord(rec) {
|
|
185
|
+
if (!rec || typeof rec !== 'object') return false;
|
|
186
|
+
const promptSource = rec.promptSource;
|
|
187
|
+
if (typeof promptSource === 'string' && promptSource && !OWNER_PROMPT_SOURCES.includes(promptSource)) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
// Transcript scaffolding the host writes in user position, never a typed turn.
|
|
191
|
+
if (rec.isMeta === true) return false;
|
|
192
|
+
if (rec.isCompactSummary === true) return false;
|
|
193
|
+
// A sidechain is a subagent's own transcript: its "user" turns are the PARENT AGENT's
|
|
194
|
+
// prompts, not the owner's.
|
|
195
|
+
if (rec.isSidechain === true) return false;
|
|
196
|
+
if (typeof rec.userType === 'string' && rec.userType !== 'external') return false;
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Control-envelope tag names. Machine content delivered in user position is wrapped in one of
|
|
202
|
+
* these XML-ish envelopes by the host or by this harness; the owner does not type them.
|
|
203
|
+
* Matched as a tag (`<name`, `<name>`, `</name>`) anywhere in the text — an envelope appended
|
|
204
|
+
* AFTER an owner sentence still means the blob as a whole is not a clean habit citation.
|
|
205
|
+
*/
|
|
206
|
+
export const CONTROL_ENVELOPE_TAGS = Object.freeze([
|
|
207
|
+
'task-notification',
|
|
208
|
+
'system-reminder',
|
|
209
|
+
'local-command-stdout',
|
|
210
|
+
'local-command-stderr',
|
|
211
|
+
'command-name',
|
|
212
|
+
'command-message',
|
|
213
|
+
'command-args',
|
|
214
|
+
'command-contents',
|
|
215
|
+
'user-prompt-submit-hook',
|
|
216
|
+
'session-start-hook',
|
|
217
|
+
'post-tool-use-hook',
|
|
218
|
+
'bash-stdout',
|
|
219
|
+
'bash-stderr',
|
|
220
|
+
'ide_selection',
|
|
221
|
+
'ide_opened_file',
|
|
222
|
+
'ide_diagnostics',
|
|
223
|
+
'channel',
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
function envelopePattern(tag) {
|
|
227
|
+
return new RegExp(`<\\/?${tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=[\\s/>])`, 'i');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The denylist, one row per marker, each with the shape it anchors on and why it is here.
|
|
232
|
+
* Data-driven on purpose: a single unreadable mega-regex is how the next marker gets added
|
|
233
|
+
* wrong. Anchored on shapes the harness/host EMIT (an envelope tag, a rendered block header,
|
|
234
|
+
* a fixed generator prompt) rather than on incidental wording, so a genuine owner sentence
|
|
235
|
+
* that happens to mention "SpexCode Profile" in prose is not swept up.
|
|
236
|
+
*
|
|
237
|
+
* @type {ReadonlyArray<{ id: string, pattern: RegExp, why: string }>}
|
|
238
|
+
*/
|
|
239
|
+
export const MACHINE_TEXT_MARKERS = Object.freeze([
|
|
240
|
+
...CONTROL_ENVELOPE_TAGS.map((tag) => ({
|
|
241
|
+
id: `envelope:${tag}`,
|
|
242
|
+
pattern: envelopePattern(tag),
|
|
243
|
+
why: `<${tag}> is a host/harness control envelope delivered in user position`,
|
|
244
|
+
})),
|
|
245
|
+
{
|
|
246
|
+
id: 'sticky-title-sidecar',
|
|
247
|
+
// The single largest contaminant measured: 483 copies of the session-title generator's own
|
|
248
|
+
// system prompt, harvested out of the sidecar SDK sessions under a Local\Temp cwd.
|
|
249
|
+
pattern: /You maintain a short, sticky title for an ongoing coding-assistant session/i,
|
|
250
|
+
why: "the session-title generator's system prompt, replayed into its own sidecar transcript",
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: 'hook-additional-context',
|
|
254
|
+
pattern: /UserPromptSubmit hook additional context:/i,
|
|
255
|
+
why: 'the literal preamble Claude Code prefixes to a UserPromptSubmit hook injection',
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: 'local-command-caveat',
|
|
259
|
+
pattern: /Caveat: The messages below were generated by the user while running local commands/i,
|
|
260
|
+
why: 'the host preamble wrapping local slash-command output, not a typed turn',
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
id: 'spexcode-profile-block',
|
|
264
|
+
// session-bootstrap.mjs's `renderProfileBlock` — the profile we WRITE, injected every user
|
|
265
|
+
// turn. Learning it back is a direct feedback loop. Line-anchored so prose that merely
|
|
266
|
+
// names the profile survives.
|
|
267
|
+
pattern: /^#[ \t]*SpexCode Profile\b/m,
|
|
268
|
+
why: "the injected SpexCode Profile block's own header — re-learning it feeds the profile back into itself",
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: 'spexcode-doctrine-block',
|
|
272
|
+
pattern: /^#[ \t]*SpexCode Doctrine\b/m,
|
|
273
|
+
why: 'the injected SpexCode Doctrine block header — same feedback loop as the profile block',
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
id: 'doctrine-injection',
|
|
277
|
+
// doctrine/inject.mjs's `renderDoctrineBlock` header: `Doctrine (learned <iso>, N principles):`
|
|
278
|
+
pattern: /^Doctrine \(learned .*\bprinciples?\):/m,
|
|
279
|
+
why: "the injected Doctrine block's rendered header line",
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: 'vault-context-injection',
|
|
283
|
+
pattern: /VAULT CONTEXT \(retrieved/i,
|
|
284
|
+
why: 'the inject-vault-context gate\'s retrieved-references block',
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: 'heyspex-gate-check',
|
|
288
|
+
pattern: /HEYSPEX GATE CHECK/i,
|
|
289
|
+
why: 'a HeySpex gate-check preamble injected ahead of the turn',
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
id: 'dev-factory-seat',
|
|
293
|
+
pattern: /^DEV-FACTORY SEAT:/m,
|
|
294
|
+
why: "dev-factory/lib/seat-context.mjs's seat mandate line, injected at session start",
|
|
295
|
+
},
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The id of the first {@link MACHINE_TEXT_MARKERS} row that matches `text`, or `null`.
|
|
300
|
+
* Returning the id (not just a boolean) is what lets the hygiene path report WHY each row
|
|
301
|
+
* was rejected.
|
|
302
|
+
* @param {unknown} text
|
|
303
|
+
* @returns {string|null}
|
|
304
|
+
*/
|
|
305
|
+
export function machineTextMarker(text) {
|
|
306
|
+
if (typeof text !== 'string' || !text) return null;
|
|
307
|
+
for (const marker of MACHINE_TEXT_MARKERS) {
|
|
308
|
+
if (marker.pattern.test(text)) return marker.id;
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** True when `text` carries any machine-authorship marker — see {@link MACHINE_TEXT_MARKERS}. */
|
|
314
|
+
export function isMachineAuthoredText(text) {
|
|
315
|
+
return machineTextMarker(text) !== null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function parseJsonLine(raw) {
|
|
319
|
+
try {
|
|
320
|
+
return JSON.parse(raw);
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── per-source-shape record handlers — each returns zero or more candidate
|
|
327
|
+
// observations `{ kind, text, ts?, context?, confidence? }` and may mutate `ctx`
|
|
328
|
+
// (the session's running "what came before" state, carried across incremental runs). ──
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Claude Code JSONL record shapes (verified on this machine 2026-09-11):
|
|
332
|
+
* {"type":"user","message":{"role":"user","content": string | Array<block>}, "timestamp", ...}
|
|
333
|
+
* {"type":"assistant","message":{"content":[{type:"text"|"tool_use"|"thinking"}]}}
|
|
334
|
+
* {"type":"permission-mode","permissionMode": string, "sessionId"}
|
|
335
|
+
* {"type":"mode", ...} — session UI state, not an observation; skipped.
|
|
336
|
+
* Any other `type` (ai-title, summary, environment, ...) is likewise skipped.
|
|
337
|
+
*/
|
|
338
|
+
export function handleClaudeCodeRecord(rec, ctx) {
|
|
339
|
+
const results = [];
|
|
340
|
+
if (!rec || typeof rec !== 'object') return results;
|
|
341
|
+
|
|
342
|
+
if (rec.type === 'assistant') {
|
|
343
|
+
const text = extractTextFromContent(rec.message?.content);
|
|
344
|
+
ctx.lastRecordType = 'assistant';
|
|
345
|
+
if (text) ctx.lastAssistantText = text;
|
|
346
|
+
return results;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (rec.type === 'user') {
|
|
350
|
+
const content = rec.message?.content;
|
|
351
|
+
const precededByAssistant = ctx.lastRecordType === 'assistant';
|
|
352
|
+
|
|
353
|
+
// The two authorship screens run BEFORE `ctx.lastRecordType` is touched, so an injected
|
|
354
|
+
// machine record is TRANSPARENT to the correction chain. Otherwise a task notification
|
|
355
|
+
// landing between the assistant's reply and the owner's "No, stop — ..." made that
|
|
356
|
+
// correction look like it followed a user turn, and it was silently dropped: the host
|
|
357
|
+
// interrupting the owner cost us exactly the observations worth the most.
|
|
358
|
+
//
|
|
359
|
+
// Record-shape screen — the Claude Code counterpart of handleCommandCodeRecord's
|
|
360
|
+
// `meta.source !== 'user'` discriminator.
|
|
361
|
+
if (!isOwnerAuthoredClaudeCodeRecord(rec)) return results;
|
|
362
|
+
const text = isToolOnlyContent(content) ? '' : extractTextFromContent(content);
|
|
363
|
+
// Text-shape screen — catches the records that predate `promptSource`.
|
|
364
|
+
if (text && isMachineAuthoredText(text)) return results;
|
|
365
|
+
|
|
366
|
+
ctx.lastRecordType = 'user';
|
|
367
|
+
if (!text) return results;
|
|
368
|
+
const kind = classifyUserText(text, { precededByAssistant });
|
|
369
|
+
if (!kind) return results;
|
|
370
|
+
const obs = { kind, text, ts: rec.timestamp ?? null };
|
|
371
|
+
if (kind === 'correction' && ctx.lastAssistantText) obs.context = ctx.lastAssistantText.slice(0, 240);
|
|
372
|
+
results.push(obs);
|
|
373
|
+
return results;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (rec.type === 'permission-mode') {
|
|
377
|
+
results.push({ kind: 'approval', text: `permission mode: ${rec.permissionMode ?? 'unknown'}`, ts: null });
|
|
378
|
+
return results;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return results;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Command Code JSONL record shapes (verified on this machine 2026-09-11):
|
|
386
|
+
* {"type":"session", ...} — one per file, not an observation.
|
|
387
|
+
* {"type":"message","message":{"role":"user"|"assistant","content":[block...],
|
|
388
|
+
* "meta":{"source":"user"|"tool"|...}}}
|
|
389
|
+
* `meta.source === "user"` is the discriminator for a genuinely owner-typed turn — a
|
|
390
|
+
* `role:"user"` turn whose content is a tool_result relay carries `meta.source ===
|
|
391
|
+
* "tool"` and is never the owner's words.
|
|
392
|
+
*/
|
|
393
|
+
export function handleCommandCodeRecord(rec, ctx) {
|
|
394
|
+
const results = [];
|
|
395
|
+
if (!rec || rec.type !== 'message') return results;
|
|
396
|
+
const role = rec.message?.role;
|
|
397
|
+
const content = rec.message?.content;
|
|
398
|
+
|
|
399
|
+
if (role === 'assistant') {
|
|
400
|
+
const text = extractTextFromContent(content);
|
|
401
|
+
ctx.lastRecordType = 'assistant';
|
|
402
|
+
if (text) ctx.lastAssistantText = text;
|
|
403
|
+
return results;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (role === 'user') {
|
|
407
|
+
const precededByAssistant = ctx.lastRecordType === 'assistant';
|
|
408
|
+
ctx.lastRecordType = 'user';
|
|
409
|
+
if (rec.message?.meta?.source !== 'user') return results; // tool-result relay, not the owner
|
|
410
|
+
if (isToolOnlyContent(content)) return results;
|
|
411
|
+
const text = extractTextFromContent(content);
|
|
412
|
+
if (!text) return results;
|
|
413
|
+
if (isMachineAuthoredText(text)) return results;
|
|
414
|
+
const kind = classifyUserText(text, { precededByAssistant });
|
|
415
|
+
if (!kind) return results;
|
|
416
|
+
const obs = { kind, text, ts: rec.timestamp ?? null };
|
|
417
|
+
if (kind === 'correction' && ctx.lastAssistantText) obs.context = ctx.lastAssistantText.slice(0, 240);
|
|
418
|
+
results.push(obs);
|
|
419
|
+
return results;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return results;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const TASTE_LINE_RE = /^-\s+(.*?)\s+Confidence:\s*([0-9]*\.?[0-9]+)\s*$/;
|
|
426
|
+
|
|
427
|
+
/** One `- <text> Confidence: <n>` line from a Command Code taste.md. */
|
|
428
|
+
export function handleTasteLine(rawLine) {
|
|
429
|
+
const m = TASTE_LINE_RE.exec(rawLine.trim());
|
|
430
|
+
if (!m) return null;
|
|
431
|
+
return { kind: 'taste', text: m[1].trim(), confidence: Number(m[2]), ts: null };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* spexharness's own session-end export (session-record.mjs / session-end.mjs). Its
|
|
436
|
+
* current shape (`{ sessionId, exportedAt, events, toolCalls }`) carries no user-
|
|
437
|
+
* authored text at all — only gate decisions and tool-call name/input/output — so
|
|
438
|
+
* there is nothing to classify from it TODAY. This handler is forward-compatible: a
|
|
439
|
+
* future sink that adds a string `ask` field (mirroring session-record.mjs's
|
|
440
|
+
* `createSessionRecord({ ask })`) is picked up automatically, classified exactly like
|
|
441
|
+
* a Claude Code user turn, with no prior-assistant context available in this file.
|
|
442
|
+
*/
|
|
443
|
+
export function handleSpexcodeRecord(rec) {
|
|
444
|
+
if (!rec || typeof rec.ask !== 'string') return [];
|
|
445
|
+
if (isMachineAuthoredText(rec.ask)) return [];
|
|
446
|
+
const kind = classifyUserText(rec.ask, { precededByAssistant: false });
|
|
447
|
+
if (!kind) return [];
|
|
448
|
+
return [{ kind, text: rec.ask, ts: rec.exportedAt ?? null }];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── streaming line reader ────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
/** Yield `{ n, line }` for every line of `filePath` past line number `skip` (1-indexed). */
|
|
454
|
+
async function* streamNewLines(filePath, skip) {
|
|
455
|
+
const rl = createInterface({ input: createReadStream(filePath, { encoding: 'utf8' }), crlfDelay: Infinity });
|
|
456
|
+
let n = 0;
|
|
457
|
+
try {
|
|
458
|
+
for await (const line of rl) {
|
|
459
|
+
n++;
|
|
460
|
+
if (n <= skip) continue;
|
|
461
|
+
yield { n, line };
|
|
462
|
+
}
|
|
463
|
+
} finally {
|
|
464
|
+
rl.close();
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function dispatchRecord(src, rec, rawLine, ctx) {
|
|
469
|
+
if (src.source === 'claude-code') return handleClaudeCodeRecord(rec, ctx);
|
|
470
|
+
if (src.source === 'command-code') return handleCommandCodeRecord(rec, ctx);
|
|
471
|
+
if (src.source === 'spexcode') return handleSpexcodeRecord(rec);
|
|
472
|
+
return [];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Extract new observations from ONE source (one session file), resuming from its
|
|
477
|
+
* prior state. Returns the per-session outcome; observations are pushed to `sink` as
|
|
478
|
+
* they are produced (never buffered whole here) so a caller can stream them straight
|
|
479
|
+
* to disk.
|
|
480
|
+
*/
|
|
481
|
+
async function extractFromSource(src, priorState, redact, sink) {
|
|
482
|
+
const key = sourceKey(src);
|
|
483
|
+
const prior = priorState[key] ?? { mtime: null, lines: 0, lastAssistantText: null, lastRecordType: null };
|
|
484
|
+
|
|
485
|
+
if (prior.mtime === src.mtime) {
|
|
486
|
+
return { key, newState: prior, added: 0, skipped: true };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const ctx = { lastAssistantText: prior.lastAssistantText ?? null, lastRecordType: prior.lastRecordType ?? null };
|
|
490
|
+
let added = 0;
|
|
491
|
+
let lastLine = prior.lines;
|
|
492
|
+
|
|
493
|
+
if (src.source === 'taste') {
|
|
494
|
+
for await (const { n, line } of streamNewLines(src.path, prior.lines)) {
|
|
495
|
+
lastLine = n;
|
|
496
|
+
const trimmed = line.trim();
|
|
497
|
+
if (!trimmed) continue;
|
|
498
|
+
const cand = handleTasteLine(line);
|
|
499
|
+
if (!cand) continue;
|
|
500
|
+
emit(src, n, cand, redact, sink);
|
|
501
|
+
added++;
|
|
502
|
+
}
|
|
503
|
+
} else {
|
|
504
|
+
for await (const { n, line } of streamNewLines(src.path, prior.lines)) {
|
|
505
|
+
lastLine = n;
|
|
506
|
+
const trimmed = line.trim();
|
|
507
|
+
if (!trimmed) continue;
|
|
508
|
+
const rec = parseJsonLine(trimmed);
|
|
509
|
+
if (!rec) continue;
|
|
510
|
+
const candidates = dispatchRecord(src, rec, trimmed, ctx);
|
|
511
|
+
for (const cand of candidates) {
|
|
512
|
+
emit(src, n, cand, redact, sink);
|
|
513
|
+
added++;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
key,
|
|
520
|
+
newState: {
|
|
521
|
+
mtime: src.mtime,
|
|
522
|
+
lines: Math.max(lastLine, prior.lines),
|
|
523
|
+
lastAssistantText: ctx.lastAssistantText,
|
|
524
|
+
lastRecordType: ctx.lastRecordType,
|
|
525
|
+
},
|
|
526
|
+
added,
|
|
527
|
+
skipped: false,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function emit(src, seq, cand, redact, sink) {
|
|
532
|
+
const obs = {
|
|
533
|
+
id: `${src.source}:${src.project}:${src.session}:${seq}`,
|
|
534
|
+
source: src.source,
|
|
535
|
+
project: src.project,
|
|
536
|
+
session: src.session,
|
|
537
|
+
ts: cand.ts ?? null,
|
|
538
|
+
seq,
|
|
539
|
+
kind: cand.kind,
|
|
540
|
+
text: redact(cand.text),
|
|
541
|
+
};
|
|
542
|
+
if (cand.context) obs.context = redact(cand.context);
|
|
543
|
+
if (typeof cand.confidence === 'number') obs.confidence = cand.confidence;
|
|
544
|
+
sink(obs);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ── top-level entrypoint ────────────────────────────────────────────────────────
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Stream every source and extract observations, resuming from `state`.
|
|
551
|
+
*
|
|
552
|
+
* @param {{
|
|
553
|
+
* sources?: Array<{source:string,project:string,session:string,path:string,mtime:number}>,
|
|
554
|
+
* state?: object,
|
|
555
|
+
* redact?: (text: string) => string,
|
|
556
|
+
* since?: string|null,
|
|
557
|
+
* project?: string|null,
|
|
558
|
+
* onObservation?: ((obs: object) => void)|null,
|
|
559
|
+
* onSourceDone?: ((result: { source: string, project: string, session: string, added: number, skipped: boolean }) => void)|null,
|
|
560
|
+
* }} [opts]
|
|
561
|
+
* @returns {Promise<{ observations: DoctrineObservation[], newState: object, countsBySource: object, countsByKind: object, sessionsProcessed: number, sessionsSkipped: number }>}
|
|
562
|
+
*/
|
|
563
|
+
export async function extractObservations({
|
|
564
|
+
sources = defaultEnumerateSources(),
|
|
565
|
+
state = {},
|
|
566
|
+
redact = redactText,
|
|
567
|
+
since = null,
|
|
568
|
+
project = null,
|
|
569
|
+
onObservation = null,
|
|
570
|
+
onSourceDone = null,
|
|
571
|
+
} = {}) {
|
|
572
|
+
const sinceMs = since ? Date.parse(since) : NaN;
|
|
573
|
+
const filtered = sources.filter((s) => {
|
|
574
|
+
if (project && s.project !== project) return false;
|
|
575
|
+
if (Number.isFinite(sinceMs) && s.mtime < sinceMs) return false;
|
|
576
|
+
return true;
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const observations = [];
|
|
580
|
+
const sink = (obs) => {
|
|
581
|
+
observations.push(obs);
|
|
582
|
+
if (onObservation) onObservation(obs);
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const newState = { ...state };
|
|
586
|
+
const countsBySource = {};
|
|
587
|
+
const countsByKind = {};
|
|
588
|
+
let sessionsProcessed = 0;
|
|
589
|
+
let sessionsSkipped = 0;
|
|
590
|
+
|
|
591
|
+
for (const src of filtered) {
|
|
592
|
+
const result = await extractFromSource(src, state, redact, sink);
|
|
593
|
+
newState[result.key] = result.newState;
|
|
594
|
+
if (result.skipped) sessionsSkipped++;
|
|
595
|
+
else sessionsProcessed++;
|
|
596
|
+
countsBySource[src.source] = (countsBySource[src.source] ?? 0) + result.added;
|
|
597
|
+
if (onSourceDone) {
|
|
598
|
+
onSourceDone({ source: src.source, project: src.project, session: src.session, added: result.added, skipped: result.skipped });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
for (const obs of observations) {
|
|
603
|
+
countsByKind[obs.kind] = (countsByKind[obs.kind] ?? 0) + 1;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return { observations, newState, countsBySource, countsByKind, sessionsProcessed, sessionsSkipped };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ── incremental state + observation-file persistence ────────────────────────────
|
|
610
|
+
|
|
611
|
+
export function defaultDoctrineDir(repoRoot) {
|
|
612
|
+
return join(repoRoot, '.heyspex', 'doctrine');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function defaultStatePath(repoRoot) {
|
|
616
|
+
return join(defaultDoctrineDir(repoRoot), 'state.json');
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export function defaultObservationsPath(repoRoot) {
|
|
620
|
+
return join(defaultDoctrineDir(repoRoot), 'observations.jsonl');
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Read the incremental state file; missing/corrupt → `{}` (a fresh full read). */
|
|
624
|
+
export function loadDoctrineState(path) {
|
|
625
|
+
if (!existsSync(path)) return {};
|
|
626
|
+
try {
|
|
627
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
628
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
629
|
+
} catch {
|
|
630
|
+
return {};
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Persist the incremental state file. */
|
|
635
|
+
export function saveDoctrineState(path, state) {
|
|
636
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
637
|
+
writeFileSync_(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// writeFileSync isn't imported above (this module prefers streaming appends for the
|
|
641
|
+
// observation log); a tiny local wrapper keeps the state-file write a plain one-shot
|
|
642
|
+
// without adding a second import line for a single call site.
|
|
643
|
+
function writeFileSync_(path, contents) {
|
|
644
|
+
const fd = openSync(path, 'w');
|
|
645
|
+
try {
|
|
646
|
+
writeSync(fd, contents);
|
|
647
|
+
} finally {
|
|
648
|
+
closeSync(fd);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Append observations to the observation JSONL log — line-atomic (open "a" + one writeSync + close). */
|
|
653
|
+
export function appendObservations(path, observations) {
|
|
654
|
+
if (!observations || observations.length === 0) return;
|
|
655
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
656
|
+
const fd = openSync(path, 'a');
|
|
657
|
+
try {
|
|
658
|
+
for (const obs of observations) writeSync(fd, `${JSON.stringify(obs)}\n`);
|
|
659
|
+
} finally {
|
|
660
|
+
closeSync(fd);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* CORPUS HYGIENE — re-apply the machine-text screen to observations ALREADY stored in
|
|
666
|
+
* `observations.jsonl`, so a corpus harvested by an older build can be cleaned without
|
|
667
|
+
* re-reading every session file (and without discarding rows whose sessions have since been
|
|
668
|
+
* deleted). Pure: reads nothing, writes nothing — the caller decides what to do with `kept`.
|
|
669
|
+
*
|
|
670
|
+
* The record-shape screen cannot run here (a stored observation no longer carries its source
|
|
671
|
+
* record's `promptSource`), so this is the text screen plus, optionally, the project scope.
|
|
672
|
+
*
|
|
673
|
+
* `claudeCodeProjects` mirrors sources.mjs exactly: it scopes ONLY `source: 'claude-code'`
|
|
674
|
+
* rows, because that is the only source the cross-project harvest applies to. Command Code,
|
|
675
|
+
* taste and spexcode rows are left alone — their `project` is derived differently (taste and
|
|
676
|
+
* spexcode carry `basename(repoRoot)`, not the Claude Code directory slug), so scoping them
|
|
677
|
+
* by the same list would silently delete this repo's own taste profile.
|
|
678
|
+
*
|
|
679
|
+
* @param {Array<object>} observations
|
|
680
|
+
* @param {{ claudeCodeProjects?: string[]|null }} [opts]
|
|
681
|
+
* @returns {{
|
|
682
|
+
* kept: Array<object>,
|
|
683
|
+
* rejected: Array<{ observation: object, reason: string }>,
|
|
684
|
+
* rejectedByReason: Record<string, number>,
|
|
685
|
+
* }}
|
|
686
|
+
*/
|
|
687
|
+
export function filterObservations(observations, { claudeCodeProjects = null } = {}) {
|
|
688
|
+
const allow = Array.isArray(claudeCodeProjects) ? new Set(claudeCodeProjects) : null;
|
|
689
|
+
const kept = [];
|
|
690
|
+
const rejected = [];
|
|
691
|
+
const rejectedByReason = {};
|
|
692
|
+
const reject = (observation, reason) => {
|
|
693
|
+
rejected.push({ observation, reason });
|
|
694
|
+
rejectedByReason[reason] = (rejectedByReason[reason] ?? 0) + 1;
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
for (const obs of observations ?? []) {
|
|
698
|
+
if (!obs || typeof obs !== 'object') continue;
|
|
699
|
+
if (allow && obs.source === 'claude-code' && !allow.has(obs.project)) {
|
|
700
|
+
reject(obs, 'out-of-scope-project');
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
const marker = machineTextMarker(obs.text) ?? machineTextMarker(obs.context);
|
|
704
|
+
if (marker) {
|
|
705
|
+
reject(obs, marker);
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
kept.push(obs);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return { kept, rejected, rejectedByReason };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Read all observations from the JSONL log, oldest first. Missing file → []. Corrupt lines are skipped.
|
|
716
|
+
* @param {string} path
|
|
717
|
+
* @returns {DoctrineObservation[]}
|
|
718
|
+
*/
|
|
719
|
+
export function readObservations(path) {
|
|
720
|
+
if (!existsSync(path)) return [];
|
|
721
|
+
const text = readFileSync(path, 'utf8');
|
|
722
|
+
const out = [];
|
|
723
|
+
for (const line of text.split('\n')) {
|
|
724
|
+
const trimmed = line.trim();
|
|
725
|
+
if (!trimmed) continue;
|
|
726
|
+
try {
|
|
727
|
+
out.push(JSON.parse(trimmed));
|
|
728
|
+
} catch {
|
|
729
|
+
// corrupt/partial line — skip, don't throw
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return out;
|
|
733
|
+
}
|