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,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// HeySpex Intent Classifier — SEMANTIC layer.
|
|
3
|
+
//
|
|
4
|
+
// The Intent Classifier has two layers ([[HS-UJVA-EPIC-KKMO-autonomous-skill-routing-context]]):
|
|
5
|
+
// 1. LEXICAL (fast path) — the per-rule regex patterns in the guardrail registry.
|
|
6
|
+
// A regex hit fires instantly and never waits on this module.
|
|
7
|
+
// 2. SEMANTIC — on a regex MISS, resolve intent WITHOUT a second, separate model.
|
|
8
|
+
// DEFAULT (mode: self_check, [[HS-M4PN-TASK]]): the router deterministically
|
|
9
|
+
// injects a SELF-CHECK instruction and lets the agent already running the session
|
|
10
|
+
// judge intent as part of its own turn — no network, no fleet machine, works for
|
|
11
|
+
// every user. The self-check block is built in _intent-classifier-router.mjs;
|
|
12
|
+
// this module is the OPT-IN model fallback for anyone who still wants it.
|
|
13
|
+
// Why the semantic layer exists at all (regex alone is not enough):
|
|
14
|
+
// [[intent-classifier-injected-content]] — 9/10 paraphrased improvement-idea
|
|
15
|
+
// phrasings missed before the patterns were broadened, and paraphrase drift
|
|
16
|
+
// never ends.
|
|
17
|
+
//
|
|
18
|
+
// OPT-IN MODEL FALLBACK (mode: model): classifyIntent() below classifies the prompt
|
|
19
|
+
// with a fast LOCAL model (Ollama) and returns { behavior, confidence } so the caller
|
|
20
|
+
// can fire above a threshold. This is the ORIGINAL mechanism, now DEMOTED to an
|
|
21
|
+
// explicit opt-in — it is NEVER on the default path, so no reachable endpoint is
|
|
22
|
+
// required for the semantic layer to function ([[HS-M4PN-TASK]] AC2).
|
|
23
|
+
//
|
|
24
|
+
// HARD FAIL-SAFE CONTRACT (hook house rules): this module NEVER throws, NEVER
|
|
25
|
+
// hangs past its timeout, and on ANY problem (endpoint down, timeout, bad JSON,
|
|
26
|
+
// unexpected shape) returns { behavior: null, confidence: 0 } so the calling hook
|
|
27
|
+
// proceeds exactly as if the semantic layer did not exist. Dependency-free:
|
|
28
|
+
// Node stdlib + global fetch only (hooks run on a fresh clone with no npm install).
|
|
29
|
+
//
|
|
30
|
+
// Config (.heyspex/config.yaml → agent_behaviors.intent_classifier.*), all optional.
|
|
31
|
+
// The file is read from the VAULT root (the loader's `defaultConfigRoot()`), not this
|
|
32
|
+
// checkout — [[HS-4C76-9W86-DIAG-heyspex-config-yaml-has-no-single-home]]:
|
|
33
|
+
// semantic_enabled: true|false (default true — turns Layer 2 on/off entirely)
|
|
34
|
+
// mode: self_check|model (default self_check — deterministic injection;
|
|
35
|
+
// `model` opts into the Ollama fallback below)
|
|
36
|
+
// endpoint: Ollama base URL (default http://192.168.1.252:11434 — mode:model only)
|
|
37
|
+
// model: model name (default qwen3:8b — mode:model only)
|
|
38
|
+
// timeout_ms: request budget (default 2500 — the hook's overall budget is 5s)
|
|
39
|
+
// min_confidence: fire threshold (default 0.7 — consumed by the calling hook)
|
|
40
|
+
// Env overrides (kill switch first):
|
|
41
|
+
// HEYSPEX_INTENT_SEMANTIC_OFF=1 hard off (disables Layer 2 entirely)
|
|
42
|
+
// HEYSPEX_INTENT_SEMANTIC_MODE=self_check|model
|
|
43
|
+
// HEYSPEX_INTENT_ENDPOINT / HEYSPEX_INTENT_MODEL /
|
|
44
|
+
// HEYSPEX_INTENT_TIMEOUT_MS / HEYSPEX_INTENT_MIN_CONFIDENCE
|
|
45
|
+
//
|
|
46
|
+
// Self-test without a session (exercises the opt-in model fallback):
|
|
47
|
+
// node scripts/claude-hooks/_intent-classifier-semantic.mjs --ping
|
|
48
|
+
// node scripts/claude-hooks/_intent-classifier-semantic.mjs --classify "some prompt"
|
|
49
|
+
|
|
50
|
+
import fs from 'node:fs';
|
|
51
|
+
import path from 'node:path';
|
|
52
|
+
import { fileURLToPath } from 'node:url';
|
|
53
|
+
|
|
54
|
+
import { readConfigLeafRaw } from './_guardrail-loader.mjs';
|
|
55
|
+
import { defaultConfigRoot } from '../../spexharness/src/config-root.mjs';
|
|
56
|
+
|
|
57
|
+
// The CODE checkout — where `.heyspex/intent-classifier-history.jsonl` (an event
|
|
58
|
+
// log, a code-repo artifact) is appended. NOT where config.yaml is read from: the
|
|
59
|
+
// config reads below default to the vault root via `defaultConfigRoot()`
|
|
60
|
+
// ([[HS-4C76-9W86-DIAG-heyspex-config-yaml-has-no-single-home]]).
|
|
61
|
+
export const DEFAULT_REPO_ROOT = path.resolve(
|
|
62
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
63
|
+
'..',
|
|
64
|
+
'..',
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Semantic-layer modes ([[HS-M4PN-TASK]]):
|
|
68
|
+
// 'self_check' — DEFAULT. On a regex miss the router injects a deterministic
|
|
69
|
+
// self-check instruction; the session's own agent judges intent.
|
|
70
|
+
// No network, no fleet machine.
|
|
71
|
+
// 'model' — OPT-IN fallback. classifyIntent() calls the local Ollama endpoint.
|
|
72
|
+
export const SEMANTIC_MODES = Object.freeze(['self_check', 'model']);
|
|
73
|
+
|
|
74
|
+
export const DEFAULTS = Object.freeze({
|
|
75
|
+
semanticEnabled: true,
|
|
76
|
+
semanticMode: 'self_check',
|
|
77
|
+
endpoint: 'http://192.168.1.252:11434',
|
|
78
|
+
model: 'qwen3:8b',
|
|
79
|
+
timeoutMs: 2500,
|
|
80
|
+
minConfidence: 0.7,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// The behavior catalog the classifier chooses from. Descriptions are the actual
|
|
84
|
+
// classification instructions, so keep them about the USER'S INTENT, not the
|
|
85
|
+
// system's response. Any hook can pass its own list; this shared catalog keeps
|
|
86
|
+
// capture-trigger and research-trigger consistent if both adopt the semantic layer.
|
|
87
|
+
export const KNOWN_BEHAVIORS = Object.freeze([
|
|
88
|
+
{
|
|
89
|
+
key: 'capture_on_idea',
|
|
90
|
+
description:
|
|
91
|
+
'The user is sharing a raw idea, suggestion, observation, or improvement thought — a brain-dump about something that could exist or be better — rather than giving a direct work order, asking a question about existing work, or continuing work already in progress.',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: 'research_before_decision',
|
|
95
|
+
description:
|
|
96
|
+
'The user is declining to make a design or architecture decision themselves and is asking for research, best practices, or an expert recommendation instead (e.g. "not a decision I should have to make", "don\'t reinvent the wheel").',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
key: 'settings_intent',
|
|
100
|
+
description:
|
|
101
|
+
'The user wants to READ or CHANGE a HeySpex workflow setting / agent behavior — enable or disable a behavior (auto-capture, the research trigger, the intent classifier), turn something on or off, set/lower/raise a threshold or value (confidence, timeout), or ask what a setting is currently. About the configuration itself, NOT a request to do the underlying work. (e.g. "turn off auto-capture", "lower the classifier confidence to 0.6", "is auto-capture on", "make it stop capturing everything").',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
key: 'defer_intent',
|
|
105
|
+
description:
|
|
106
|
+
'The user wants to DEFER, POSTPONE, or SCHEDULE a follow-up to a later time — "come back to this later", "remind me in a week", "park/table this until X is done", "schedule this for next week", "not now — later, after the migration". About WHEN to revisit something, not a request to do it now.',
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
function num(raw, fallback) {
|
|
111
|
+
const n = Number(raw);
|
|
112
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clamp01(n) {
|
|
116
|
+
const v = Number(n);
|
|
117
|
+
if (!Number.isFinite(v)) return 0;
|
|
118
|
+
return Math.max(0, Math.min(1, v));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve the semantic-layer config: env override → .heyspex/config.yaml →
|
|
123
|
+
* shipped defaults. FAIL-SAFE: any unreadable value falls back to its default.
|
|
124
|
+
* `repoRoot` is the directory holding `.heyspex/config.yaml` — defaults to the
|
|
125
|
+
* vault root; an explicit argument (tests, fixtures) always wins.
|
|
126
|
+
*/
|
|
127
|
+
export function readIntentClassifierConfig(repoRoot = defaultConfigRoot()) {
|
|
128
|
+
const cfg = { ...DEFAULTS };
|
|
129
|
+
try {
|
|
130
|
+
if (process.env.HEYSPEX_INTENT_SEMANTIC_OFF === '1') cfg.semanticEnabled = false;
|
|
131
|
+
else {
|
|
132
|
+
const enabled = readConfigLeafRaw('agent_behaviors.intent_classifier.semantic_enabled', repoRoot);
|
|
133
|
+
if (enabled !== undefined) cfg.semanticEnabled = enabled.trim().toLowerCase() !== 'false';
|
|
134
|
+
}
|
|
135
|
+
// Mode: env override → config → default 'self_check'. Any unrecognized value
|
|
136
|
+
// falls back to the deterministic default rather than silently enabling the model.
|
|
137
|
+
const modeRaw =
|
|
138
|
+
process.env.HEYSPEX_INTENT_SEMANTIC_MODE ||
|
|
139
|
+
unquote(readConfigLeafRaw('agent_behaviors.intent_classifier.mode', repoRoot));
|
|
140
|
+
if (typeof modeRaw === 'string') {
|
|
141
|
+
const m = modeRaw.trim().toLowerCase();
|
|
142
|
+
if (SEMANTIC_MODES.includes(m)) cfg.semanticMode = m;
|
|
143
|
+
}
|
|
144
|
+
const endpoint =
|
|
145
|
+
process.env.HEYSPEX_INTENT_ENDPOINT ||
|
|
146
|
+
unquote(readConfigLeafRaw('agent_behaviors.intent_classifier.endpoint', repoRoot));
|
|
147
|
+
if (endpoint && /^https?:\/\//.test(endpoint)) cfg.endpoint = endpoint.replace(/\/+$/, '');
|
|
148
|
+
const model =
|
|
149
|
+
process.env.HEYSPEX_INTENT_MODEL ||
|
|
150
|
+
unquote(readConfigLeafRaw('agent_behaviors.intent_classifier.model', repoRoot));
|
|
151
|
+
if (model) cfg.model = model;
|
|
152
|
+
cfg.timeoutMs = num(
|
|
153
|
+
process.env.HEYSPEX_INTENT_TIMEOUT_MS ??
|
|
154
|
+
readConfigLeafRaw('agent_behaviors.intent_classifier.timeout_ms', repoRoot),
|
|
155
|
+
DEFAULTS.timeoutMs,
|
|
156
|
+
);
|
|
157
|
+
const minConf = Number(
|
|
158
|
+
process.env.HEYSPEX_INTENT_MIN_CONFIDENCE ??
|
|
159
|
+
readConfigLeafRaw('agent_behaviors.intent_classifier.min_confidence', repoRoot),
|
|
160
|
+
);
|
|
161
|
+
if (Number.isFinite(minConf) && minConf > 0 && minConf <= 1) cfg.minConfidence = minConf;
|
|
162
|
+
} catch {
|
|
163
|
+
return { ...DEFAULTS };
|
|
164
|
+
}
|
|
165
|
+
return cfg;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function unquote(raw) {
|
|
169
|
+
if (typeof raw !== 'string') return undefined;
|
|
170
|
+
const v = raw.trim();
|
|
171
|
+
const m = v.match(/^"(.*)"$/) || v.match(/^'(.*)'$/);
|
|
172
|
+
return m ? m[1] : v || undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildSystemPrompt(behaviors) {
|
|
176
|
+
const rows = behaviors
|
|
177
|
+
.map((b) => `- ${b.key}: ${b.description}`)
|
|
178
|
+
.join('\n');
|
|
179
|
+
return (
|
|
180
|
+
'You classify ONE user message from a coding-agent session into exactly one behavior key, or "none" ' +
|
|
181
|
+
'when no behavior applies. Behaviors:\n' +
|
|
182
|
+
rows +
|
|
183
|
+
'\n- none: a direct work order, a question about existing work, a bug report to fix now, small talk, or anything else.\n' +
|
|
184
|
+
'Reply with STRICT JSON only, no prose: {"behavior":"<one key above or none>","confidence":<number 0..1>}'
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Classify a prompt into one of the given behaviors via the local Ollama endpoint.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} prompt the user's message
|
|
192
|
+
* @param {{key:string, description:string}[]} behaviors candidate behaviors (defaults to KNOWN_BEHAVIORS)
|
|
193
|
+
* @param {object} [opts] { endpoint, model, timeoutMs, repoRoot } — omitted values come from config
|
|
194
|
+
* @returns {Promise<{behavior: string|null, confidence: number, latencyMs?: number, error?: string}>}
|
|
195
|
+
* behavior is null when nothing matched OR anything failed (fail-safe).
|
|
196
|
+
*/
|
|
197
|
+
export async function classifyIntent(prompt, behaviors = KNOWN_BEHAVIORS, opts = {}) {
|
|
198
|
+
const none = { behavior: null, confidence: 0 };
|
|
199
|
+
try {
|
|
200
|
+
if (typeof prompt !== 'string' || !prompt.trim()) return none;
|
|
201
|
+
const list = Array.isArray(behaviors) && behaviors.length ? behaviors : KNOWN_BEHAVIORS;
|
|
202
|
+
const cfg = readIntentClassifierConfig(opts.repoRoot ?? defaultConfigRoot());
|
|
203
|
+
const endpoint = opts.endpoint ?? cfg.endpoint;
|
|
204
|
+
const model = opts.model ?? cfg.model;
|
|
205
|
+
const timeoutMs = num(opts.timeoutMs, cfg.timeoutMs);
|
|
206
|
+
|
|
207
|
+
// Very short prompts carry no classifiable intent; very long ones are truncated
|
|
208
|
+
// to keep the local model inside the latency budget.
|
|
209
|
+
const text = prompt.trim().slice(0, 2000);
|
|
210
|
+
if (text.length < 12) return none;
|
|
211
|
+
|
|
212
|
+
// Optional call counter (proof/observability): when HEYSPEX_INTENT_CALL_LOG names
|
|
213
|
+
// a file, append one byte per model call so a test can assert exactly ≤1 call per
|
|
214
|
+
// prompt. Fail-safe: never let instrumentation break the classifier.
|
|
215
|
+
if (process.env.HEYSPEX_INTENT_CALL_LOG) {
|
|
216
|
+
try {
|
|
217
|
+
fs.appendFileSync(process.env.HEYSPEX_INTENT_CALL_LOG, `${new Date().toISOString()} ${model}\n`);
|
|
218
|
+
} catch {
|
|
219
|
+
/* ignore */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const controller = new AbortController();
|
|
224
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
225
|
+
const t0 = Date.now();
|
|
226
|
+
let res;
|
|
227
|
+
try {
|
|
228
|
+
res = await fetch(`${endpoint}/api/chat`, {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: { 'content-type': 'application/json' },
|
|
231
|
+
signal: controller.signal,
|
|
232
|
+
body: JSON.stringify({
|
|
233
|
+
model,
|
|
234
|
+
stream: false,
|
|
235
|
+
format: 'json',
|
|
236
|
+
think: false, // qwen3: skip the reasoning preamble; harmless no-op elsewhere
|
|
237
|
+
keep_alive: '30m', // keep the classifier warm between prompts
|
|
238
|
+
options: { temperature: 0, num_predict: 120 },
|
|
239
|
+
messages: [
|
|
240
|
+
{ role: 'system', content: buildSystemPrompt(list) },
|
|
241
|
+
{ role: 'user', content: text },
|
|
242
|
+
],
|
|
243
|
+
}),
|
|
244
|
+
});
|
|
245
|
+
} finally {
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
}
|
|
248
|
+
const latencyMs = Date.now() - t0;
|
|
249
|
+
if (!res || !res.ok) return { ...none, latencyMs, error: `http_${res?.status ?? 'no_response'}` };
|
|
250
|
+
|
|
251
|
+
const payload = await res.json().catch(() => null);
|
|
252
|
+
const content = payload?.message?.content;
|
|
253
|
+
if (typeof content !== 'string' || !content.trim()) return { ...none, latencyMs, error: 'empty_response' };
|
|
254
|
+
|
|
255
|
+
let parsed;
|
|
256
|
+
try {
|
|
257
|
+
parsed = JSON.parse(content);
|
|
258
|
+
} catch {
|
|
259
|
+
// Defensive: some models wrap JSON in text — try the first {...} span.
|
|
260
|
+
const m = content.match(/\{[\s\S]*\}/);
|
|
261
|
+
if (!m) return { ...none, latencyMs, error: 'unparseable_response' };
|
|
262
|
+
try {
|
|
263
|
+
parsed = JSON.parse(m[0]);
|
|
264
|
+
} catch {
|
|
265
|
+
return { ...none, latencyMs, error: 'unparseable_response' };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const rawBehavior = typeof parsed?.behavior === 'string' ? parsed.behavior.trim() : '';
|
|
270
|
+
const confidence = clamp01(parsed?.confidence);
|
|
271
|
+
const valid = list.some((b) => b.key === rawBehavior);
|
|
272
|
+
if (!valid || rawBehavior === 'none') return { behavior: null, confidence, latencyMs };
|
|
273
|
+
return { behavior: rawBehavior, confidence, latencyMs };
|
|
274
|
+
} catch (err) {
|
|
275
|
+
const error = err?.name === 'AbortError' ? 'timeout' : 'unreachable';
|
|
276
|
+
return { ...none, error };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Append one classifier-decision row to .heyspex/intent-classifier-history.jsonl.
|
|
282
|
+
* Called by the hooks for FIRES (either layer), semantic NEAR-MISSES (matched
|
|
283
|
+
* behavior below threshold), and semantic ERRORS — never for the ordinary
|
|
284
|
+
* regex-miss/no-match case, so the log stays small. FAIL-SAFE: never throws.
|
|
285
|
+
*/
|
|
286
|
+
export function appendClassifierEvent(row, repoRoot = DEFAULT_REPO_ROOT) {
|
|
287
|
+
try {
|
|
288
|
+
const full = {
|
|
289
|
+
timestamp: new Date().toISOString(),
|
|
290
|
+
event_kind: 'intent_classifier.decision',
|
|
291
|
+
...row,
|
|
292
|
+
};
|
|
293
|
+
fs.appendFileSync(
|
|
294
|
+
path.join(repoRoot, '.heyspex', 'intent-classifier-history.jsonl'),
|
|
295
|
+
JSON.stringify(full) + '\n',
|
|
296
|
+
);
|
|
297
|
+
return true;
|
|
298
|
+
} catch {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── self-test CLI (no session required) ─────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
async function ping() {
|
|
306
|
+
const cfg = readIntentClassifierConfig();
|
|
307
|
+
const out = { config: cfg, reachable: false, version: null, classify: null };
|
|
308
|
+
try {
|
|
309
|
+
const controller = new AbortController();
|
|
310
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs);
|
|
311
|
+
const res = await fetch(`${cfg.endpoint}/api/version`, { signal: controller.signal });
|
|
312
|
+
clearTimeout(timer);
|
|
313
|
+
if (res.ok) {
|
|
314
|
+
out.reachable = true;
|
|
315
|
+
out.version = (await res.json().catch(() => null))?.version ?? null;
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
/* stays unreachable */
|
|
319
|
+
}
|
|
320
|
+
if (out.reachable) {
|
|
321
|
+
out.classify = await classifyIntent(
|
|
322
|
+
"it'd be smart to let the app notice ideas on its own",
|
|
323
|
+
KNOWN_BEHAVIORS,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
|
327
|
+
process.exit(out.reachable && out.classify?.behavior === 'capture_on_idea' ? 0 : 1);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function classifyCli(text) {
|
|
331
|
+
const result = await classifyIntent(text, KNOWN_BEHAVIORS);
|
|
332
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
333
|
+
process.exit(0);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
337
|
+
if (isMain) {
|
|
338
|
+
if (process.argv.includes('--ping')) {
|
|
339
|
+
ping();
|
|
340
|
+
} else {
|
|
341
|
+
const idx = process.argv.indexOf('--classify');
|
|
342
|
+
if (idx !== -1 && process.argv[idx + 1]) {
|
|
343
|
+
classifyCli(process.argv[idx + 1]);
|
|
344
|
+
} else if (process.argv.length > 2) {
|
|
345
|
+
process.stdout.write(
|
|
346
|
+
'Usage: node _intent-classifier-semantic.mjs --ping | --classify "<text>"\n',
|
|
347
|
+
);
|
|
348
|
+
process.exit(2);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// _run.mjs — the single chokepoint every HeySpex Claude Code hook runs through.
|
|
3
|
+
//
|
|
4
|
+
// `.claude/settings.json` never names a hook script directly. It names this
|
|
5
|
+
// shim plus the hook to run:
|
|
6
|
+
//
|
|
7
|
+
// node "$CLAUDE_PROJECT_DIR/scripts/claude-hooks/_run.mjs" wip-checkpoint
|
|
8
|
+
// node "$CLAUDE_PROJECT_DIR/scripts/claude-hooks/_run.mjs" emit-claim-status --event=Stop
|
|
9
|
+
//
|
|
10
|
+
// The shim asks one question — is HeySpex on? — and either exits 0 immediately
|
|
11
|
+
// or hands off to the real hook. One place to reason about, and a hook added
|
|
12
|
+
// tomorrow inherits the off-switch for free.
|
|
13
|
+
//
|
|
14
|
+
// Delegation is a dynamic `import()` in THIS process, not a subprocess, so the
|
|
15
|
+
// hook still owns stdin (Claude Code pipes the event payload there) and still
|
|
16
|
+
// sets this process's exit code. `process.argv` is rewritten first so a hook
|
|
17
|
+
// reading `--event=Stop` sees exactly what it saw before the shim existed.
|
|
18
|
+
//
|
|
19
|
+
// Everything here is fail-open. A shim that throws must never take down a
|
|
20
|
+
// Claude Code session, and it must never be the reason a guardrail silently
|
|
21
|
+
// stops firing — so an unresolvable mode reads as ON, and a genuinely broken
|
|
22
|
+
// hook path reports on stderr and exits 0.
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const HOOK_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const REPO_ROOT = path.resolve(HOOK_DIR, '..', '..');
|
|
30
|
+
|
|
31
|
+
const [target, ...rest] = process.argv.slice(2);
|
|
32
|
+
|
|
33
|
+
if (!target) {
|
|
34
|
+
process.stderr.write('[heyspex] _run.mjs: no hook name given\n');
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the hook to run.
|
|
40
|
+
* `wip-checkpoint` → scripts/claude-hooks/wip-checkpoint.mjs
|
|
41
|
+
* `scripts/stamp-provenance.mjs` → <repo>/scripts/stamp-provenance.mjs
|
|
42
|
+
*
|
|
43
|
+
* A bare name is constrained to a safe charset and must land inside the hook
|
|
44
|
+
* directory; a path form is resolved from the repo root and must stay inside
|
|
45
|
+
* it. Both guards exist so a mangled settings.json can't turn the shim into an
|
|
46
|
+
* arbitrary-script runner.
|
|
47
|
+
*/
|
|
48
|
+
function resolveHook(name) {
|
|
49
|
+
const abs = name.includes('/') || name.includes('\\')
|
|
50
|
+
? path.resolve(REPO_ROOT, name)
|
|
51
|
+
: /^[A-Za-z0-9._-]+$/.test(name)
|
|
52
|
+
? path.join(HOOK_DIR, name.endsWith('.mjs') ? name : `${name}.mjs`)
|
|
53
|
+
: null;
|
|
54
|
+
if (!abs) return null;
|
|
55
|
+
const inRepo = path.relative(REPO_ROOT, abs);
|
|
56
|
+
if (inRepo.startsWith('..') || path.isAbsolute(inRepo)) return null;
|
|
57
|
+
return abs;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- the off-switch -------------------------------------------------------
|
|
61
|
+
// Inlined rather than imported from scripts/heyspex-mode.mjs: this runs on
|
|
62
|
+
// every tool call in every session, and a resolve+parse of another module is
|
|
63
|
+
// real latency for a question answered by one small file read. The two must
|
|
64
|
+
// agree — see the resolution order documented in heyspex-mode.mjs.
|
|
65
|
+
function heyspexIsOff() {
|
|
66
|
+
try {
|
|
67
|
+
const raw = String(process.env.HEYSPEX_MODE ?? '').trim().toLowerCase();
|
|
68
|
+
if (raw) return ['off', '0', 'false', 'disabled', 'disable', 'no'].includes(raw);
|
|
69
|
+
const state = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, '.heyspex', 'mode.json'), 'utf8'));
|
|
70
|
+
return state?.mode === 'off';
|
|
71
|
+
} catch {
|
|
72
|
+
return false; // fail-open: HeySpex is on unless something says otherwise
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- hooks the off-switch may NOT silence ---------------------------------
|
|
77
|
+
// The productivity hooks (reminders, injections, checkpoints) exist to help,
|
|
78
|
+
// so a global "HeySpex is off" is the right answer for them. A SECURITY
|
|
79
|
+
// control is different: the mode is decided by a repo file (.heyspex/mode.json)
|
|
80
|
+
// that any agent can write, and nothing guards that file — so sharing the
|
|
81
|
+
// off-switch would mean "write one JSON file, disarm the guard forever"
|
|
82
|
+
// (the disarm chain, C8). Anything named here runs regardless of mode.
|
|
83
|
+
// Each always-on hook keeps its OWN documented escape hatch, and it must be
|
|
84
|
+
// an ENV VAR (HEYSPEX_ENV_VAULT_GUARD_OFF=1 for env-vault-guard;
|
|
85
|
+
// HEYSPEX_RESEARCH_GUARD_OFF=1 for research-method-guard) — a human setting a
|
|
86
|
+
// variable for their session, not a file an agent can create.
|
|
87
|
+
// research-protocol-gate ([[HS-K0ZP]]) joins the set for the same reason as
|
|
88
|
+
// research-method-guard: its whole point is that the agent cannot switch it
|
|
89
|
+
// off. Escape hatch: HEYSPEX_RESEARCH_PROTOCOL_GATE_OFF=1, owner-set, env only.
|
|
90
|
+
// git-identity-guard (owner ruling 2026-09-19, vault lesson
|
|
91
|
+
// when-git-says-author-identity-unknown) has NO escape hatch at all — not
|
|
92
|
+
// even an env var: the owner's own path is `hs source identity set`, typed by
|
|
93
|
+
// a human, so there is nothing a hatch would enable that the sanctioned form
|
|
94
|
+
// does not already do. Agents read git identity; they never write it.
|
|
95
|
+
const ALWAYS_ON = new Set(['env-vault-guard', 'research-method-guard', 'research-protocol-gate', 'git-identity-guard']);
|
|
96
|
+
|
|
97
|
+
function isAlwaysOn(name) {
|
|
98
|
+
const base = String(name)
|
|
99
|
+
.replace(/^.*[\\/]/, '')
|
|
100
|
+
.replace(/\.mjs$/i, '');
|
|
101
|
+
return ALWAYS_ON.has(base);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!isAlwaysOn(target) && heyspexIsOff()) process.exit(0);
|
|
105
|
+
|
|
106
|
+
const hookPath = resolveHook(target);
|
|
107
|
+
if (!hookPath || !fs.existsSync(hookPath)) {
|
|
108
|
+
process.stderr.write(`[heyspex] _run.mjs: hook not found: ${target}\n`);
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Make the hook believe it was invoked directly.
|
|
113
|
+
process.argv = [process.argv[0], hookPath, ...rest];
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await import(pathToFileURL(hookPath).href);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
process.stderr.write(`[heyspex] hook ${target} failed: ${err?.message ?? err}\n`);
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Shared Stop-hook input helpers: read the hook's stdin JSON payload and pull the
|
|
3
|
+
// agent's own last message out of the transcript JSONL. Split out of
|
|
4
|
+
// naughty-list-gate.mjs (its original sole owner) so a second Stop-hook scanner
|
|
5
|
+
// (id-display-format-gate.mjs) doesn't fork a copy of the same 40 lines.
|
|
6
|
+
//
|
|
7
|
+
// DEPENDENCY-FREE, same contract as _guardrail-loader.mjs: every function degrades
|
|
8
|
+
// to null/'' on any read/parse failure rather than throwing, so a caller's own
|
|
9
|
+
// fail-safe wrapper never has to guard against this module misbehaving.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
|
|
13
|
+
/** Read and parse the hook's stdin JSON payload. Resolves to null on any failure. */
|
|
14
|
+
export async function readStdinJson() {
|
|
15
|
+
if (process.stdin.isTTY) return null;
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
let buf = '';
|
|
18
|
+
let settled = false;
|
|
19
|
+
const finish = (value) => {
|
|
20
|
+
if (settled) return;
|
|
21
|
+
settled = true;
|
|
22
|
+
resolve(value);
|
|
23
|
+
};
|
|
24
|
+
const timer = setTimeout(() => finish(buf || ''), 200);
|
|
25
|
+
process.stdin.setEncoding('utf8');
|
|
26
|
+
process.stdin.on('data', (chunk) => { buf += chunk; });
|
|
27
|
+
process.stdin.on('end', () => { clearTimeout(timer); finish(buf); });
|
|
28
|
+
process.stdin.on('error', () => { clearTimeout(timer); finish(buf); });
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read the last assistant message's text out of a transcript JSONL file. */
|
|
33
|
+
export function lastAssistantText(transcriptPath) {
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = fs.readFileSync(transcriptPath, 'utf8');
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
41
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
42
|
+
let evt;
|
|
43
|
+
try { evt = JSON.parse(lines[i]); } catch { continue; }
|
|
44
|
+
const msg = evt?.message;
|
|
45
|
+
const role = msg?.role ?? evt?.role;
|
|
46
|
+
if (role !== 'assistant') continue;
|
|
47
|
+
const content = msg?.content;
|
|
48
|
+
if (typeof content === 'string') return content;
|
|
49
|
+
if (Array.isArray(content)) {
|
|
50
|
+
const text = content
|
|
51
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
52
|
+
.map((b) => b.text)
|
|
53
|
+
.join('\n')
|
|
54
|
+
.trim();
|
|
55
|
+
return text || null;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|