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,294 @@
|
|
|
1
|
+
// spexharness/src/git-reflog-audit.mjs
|
|
2
|
+
//
|
|
3
|
+
// The SHARED session-close git audit — moved here verbatim from
|
|
4
|
+
// scripts/claude-hooks/git-reflog-audit.mjs (task HS-610I-P3TJ, epic
|
|
5
|
+
// HS-GG01-P3TJ) as wave 6 of [[HS-AZ61-NYPR]] turned that hook into a thin
|
|
6
|
+
// shim over ./gates/git-reflog-audit.mjs. There is exactly ONE copy of the
|
|
7
|
+
// reflog parsing in the tree: this one. The gate imports it; the Claude Code
|
|
8
|
+
// shim reaches it through the gate; scripts/claude-hooks/git-reflog-audit.test.mjs
|
|
9
|
+
// (the original regression suite, unchanged apart from its import path) drives
|
|
10
|
+
// it directly.
|
|
11
|
+
//
|
|
12
|
+
// WHY the rule exists (kept from the original header): every other gate in this
|
|
13
|
+
// family (git-stash-gate, protected-action-intent) works by INTERCEPTING a
|
|
14
|
+
// destructive command as it is issued — bypassed, disabled, or routed around a
|
|
15
|
+
// gap in coverage, the action leaves no independent trace. This is a WITNESS,
|
|
16
|
+
// not a gate: it snapshots git state at session start and diffs it at session
|
|
17
|
+
// close, so a stash or a reset/checkout/rebase that slipped past every other
|
|
18
|
+
// control still shows up in .heyspex/guardrail-history.jsonl — a record
|
|
19
|
+
// independent of the acting agent's honesty.
|
|
20
|
+
//
|
|
21
|
+
// SCOPE: warn only. Never blocks, never denies.
|
|
22
|
+
//
|
|
23
|
+
// Snapshot (session start): writes `{ stash_count, reflog_head_entry,
|
|
24
|
+
// written_at }` to a SESSION-SCOPED file — the filename carries the session id
|
|
25
|
+
// so concurrent sessions on the same working tree never clobber each other's
|
|
26
|
+
// baseline — at `.heyspex/git-reflog-audit/<sessionId>.json`.
|
|
27
|
+
//
|
|
28
|
+
// Diff (stop / session end):
|
|
29
|
+
// - stash: any INCREASE in `git stash list`'s entry count since the snapshot
|
|
30
|
+
// names the new top entries (a push always lands at stash@{0}).
|
|
31
|
+
// - reflog: any HEAD reflog entry newer than the snapshot's recorded head
|
|
32
|
+
// entry whose action is reset/checkout/rebase is named. Plain commits newer
|
|
33
|
+
// than the snapshot are NOT flagged — that's normal work. Matching strips
|
|
34
|
+
// the `HEAD@{N}:` index label before comparing (that label shifts every time
|
|
35
|
+
// a new entry lands, even when the underlying hash+message didn't change) —
|
|
36
|
+
// see [[never-chain-cd-with-destructive-git-a]] for why a naive hash-only
|
|
37
|
+
// compare is not enough: `git stash push` logs its own internal
|
|
38
|
+
// `reset: moving to HEAD` entry against the SAME hash as the just-committed
|
|
39
|
+
// HEAD, so hash equality alone can't tell old from new.
|
|
40
|
+
// A clean session (no stash growth, no reset/checkout/rebase reflog entries)
|
|
41
|
+
// appends nothing.
|
|
42
|
+
//
|
|
43
|
+
// TEST ISOLATION: every git call here takes an explicit repoRootPath and shells
|
|
44
|
+
// out as `git -C <repoRootPath> ...` — never a bare `git` relying on cwd, and
|
|
45
|
+
// never a `cd X && git ...` chain.
|
|
46
|
+
//
|
|
47
|
+
// FAIL-SAFE: every exported function degrades to an inert value (null / [] /
|
|
48
|
+
// false) on any git or filesystem failure rather than throwing — this must
|
|
49
|
+
// never break a session start or a session close on its own bug.
|
|
50
|
+
|
|
51
|
+
import fs from 'node:fs';
|
|
52
|
+
import path from 'node:path';
|
|
53
|
+
import { spawnSync } from 'node:child_process';
|
|
54
|
+
import { fileURLToPath } from 'node:url';
|
|
55
|
+
|
|
56
|
+
import { appendGuardrailEvent } from '../../scripts/claude-hooks/_guardrail-events.mjs';
|
|
57
|
+
|
|
58
|
+
/** The `rule` id every row this module appends is stamped with. */
|
|
59
|
+
export const RULE_ID = 'git-reflog-audit';
|
|
60
|
+
|
|
61
|
+
/** spexharness/src/ → repo root is two levels up. */
|
|
62
|
+
const DEFAULT_REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
63
|
+
|
|
64
|
+
/** Env-first repo-root resolution, matching every pre-cutover hook's own helper. */
|
|
65
|
+
export function repoRoot(env = process.env) {
|
|
66
|
+
const fromEnv = env.CLAUDE_PROJECT_DIR?.trim();
|
|
67
|
+
if (fromEnv) return fromEnv.replace(/[\\/]+$/, '');
|
|
68
|
+
return DEFAULT_REPO_ROOT;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function git(repoRootPath, args) {
|
|
72
|
+
try {
|
|
73
|
+
const res = spawnSync('git', ['-C', repoRootPath, ...args], { encoding: 'utf8', timeout: 5000 });
|
|
74
|
+
if (res.status !== 0) return null;
|
|
75
|
+
return typeof res.stdout === 'string' ? res.stdout : '';
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `git stash list`, newest first (`stash@{0}` is the top). [] on any failure
|
|
83
|
+
* (not a repo, git unavailable, etc.) — never throws.
|
|
84
|
+
*/
|
|
85
|
+
export function readStashList(repoRootPath) {
|
|
86
|
+
const out = git(repoRootPath, ['stash', 'list']);
|
|
87
|
+
if (out === null) return [];
|
|
88
|
+
return out.split(/\r?\n/).filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `git reflog -n <limit>`, newest first. [] on any failure (including a
|
|
93
|
+
* brand-new repo with no commits yet, where HEAD has no reflog at all).
|
|
94
|
+
*/
|
|
95
|
+
export function readReflogLines(repoRootPath, limit = 50) {
|
|
96
|
+
const out = git(repoRootPath, ['reflog', '-n', String(limit)]);
|
|
97
|
+
if (out === null) return [];
|
|
98
|
+
return out.split(/\r?\n/).filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Strip the `HEAD@{N}:` index label from a reflog line before comparing two
|
|
103
|
+
* lines captured at different times — the label shifts by one every time a new
|
|
104
|
+
* entry is pushed, even when the underlying hash+message is unchanged, so
|
|
105
|
+
* comparing raw lines would never match a genuinely-unchanged entry.
|
|
106
|
+
*/
|
|
107
|
+
export function normalizeReflogLine(line) {
|
|
108
|
+
return typeof line === 'string' ? line.replace(/HEAD@\{\d+\}:/, '').trim() : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The reflog "action" word for a line — the text right after `HEAD@{N}: ` and
|
|
113
|
+
* before the next space/colon/paren (`reset: moving to ...` → `reset`;
|
|
114
|
+
* `rebase (finish): ...` → `rebase`). null when the line doesn't have the
|
|
115
|
+
* expected shape — never throws.
|
|
116
|
+
*/
|
|
117
|
+
export function reflogAction(line) {
|
|
118
|
+
if (typeof line !== 'string') return null;
|
|
119
|
+
const m = /HEAD@\{\d+\}:\s*([a-zA-Z-]+)/.exec(line);
|
|
120
|
+
return m ? m[1].toLowerCase() : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const FLAGGED_REFLOG_ACTIONS = new Set(['reset', 'checkout', 'rebase']);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Snapshot the git state this audit cares about. Never throws — a repo with no
|
|
127
|
+
* commits yet (empty reflog) or no stash entries yields zeros/nulls.
|
|
128
|
+
*/
|
|
129
|
+
export function captureGitState(repoRootPath) {
|
|
130
|
+
const stashLines = readStashList(repoRootPath);
|
|
131
|
+
const reflogLines = readReflogLines(repoRootPath, 1);
|
|
132
|
+
return {
|
|
133
|
+
stash_count: stashLines.length,
|
|
134
|
+
reflog_head_entry: reflogLines[0] ?? null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sanitizeSessionId(sessionId) {
|
|
139
|
+
const raw =
|
|
140
|
+
typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : 'unknown-session';
|
|
141
|
+
return raw.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Default session-scoped snapshot path — `.heyspex/git-reflog-audit/<sessionId>.json`. */
|
|
145
|
+
export function snapshotPathFor(repoRootPath, sessionId, snapshotDir = 'git-reflog-audit') {
|
|
146
|
+
return path.join(repoRootPath, '.heyspex', snapshotDir, `${sanitizeSessionId(sessionId)}.json`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Session-start half: write the snapshot file. Returns the written snapshot
|
|
151
|
+
* object, or null on any failure (fail-safe — a write failure here must never
|
|
152
|
+
* break session start).
|
|
153
|
+
*/
|
|
154
|
+
export function writeSnapshot({ repoRootPath = repoRoot(), sessionId, snapshotPath } = {}) {
|
|
155
|
+
try {
|
|
156
|
+
const state = captureGitState(repoRootPath);
|
|
157
|
+
const snapshot = { ...state, written_at: new Date().toISOString() };
|
|
158
|
+
const filePath = snapshotPath || snapshotPathFor(repoRootPath, sessionId);
|
|
159
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
160
|
+
fs.writeFileSync(filePath, JSON.stringify(snapshot));
|
|
161
|
+
return snapshot;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Read a previously-written snapshot. null on any failure (missing/corrupt file). */
|
|
168
|
+
export function readSnapshot(snapshotPath) {
|
|
169
|
+
try {
|
|
170
|
+
const raw = fs.readFileSync(snapshotPath, 'utf8');
|
|
171
|
+
const parsed = JSON.parse(raw);
|
|
172
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Diff current git state against a snapshot. Returns
|
|
180
|
+
* { newStashEntries: string[], flaggedReflogEntries: string[] }
|
|
181
|
+
* — both empty on a clean session.
|
|
182
|
+
*/
|
|
183
|
+
export function diffAgainstSnapshot(repoRootPath, snapshot) {
|
|
184
|
+
const result = { newStashEntries: [], flaggedReflogEntries: [] };
|
|
185
|
+
if (!snapshot || typeof snapshot !== 'object') return result;
|
|
186
|
+
|
|
187
|
+
// --- stash growth -------------------------------------------------------
|
|
188
|
+
const currentStash = readStashList(repoRootPath);
|
|
189
|
+
const priorCount = Number.isFinite(snapshot.stash_count) ? snapshot.stash_count : 0;
|
|
190
|
+
const grown = currentStash.length - priorCount;
|
|
191
|
+
if (grown > 0) {
|
|
192
|
+
result.newStashEntries = currentStash.slice(0, grown);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- reflog: reset/checkout/rebase entries newer than the snapshot ------
|
|
196
|
+
const priorEntry =
|
|
197
|
+
typeof snapshot.reflog_head_entry === 'string' ? snapshot.reflog_head_entry : null;
|
|
198
|
+
const currentReflog = readReflogLines(repoRootPath, 100);
|
|
199
|
+
if (currentReflog.length && currentReflog[0] !== priorEntry) {
|
|
200
|
+
const priorNormalized = normalizeReflogLine(priorEntry);
|
|
201
|
+
const idx =
|
|
202
|
+
priorNormalized === null
|
|
203
|
+
? -1
|
|
204
|
+
: currentReflog.findIndex((line) => normalizeReflogLine(line) === priorNormalized);
|
|
205
|
+
// idx === -1 means no baseline (fresh repo) or the old entry fell outside
|
|
206
|
+
// the fetch window — bounded fallback: everything fetched is "new".
|
|
207
|
+
const newLines = idx === -1 ? currentReflog : currentReflog.slice(0, idx);
|
|
208
|
+
result.flaggedReflogEntries = newLines.filter((line) =>
|
|
209
|
+
FLAGGED_REFLOG_ACTIONS.has(reflogAction(line)),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The human-readable half of a flagged diff — `new stash: … ; reflog: …`, the
|
|
218
|
+
* exact `matchContext` string the guardrail-history row carries. Pure.
|
|
219
|
+
*
|
|
220
|
+
* @param {{ newStashEntries?: string[], flaggedReflogEntries?: string[] }} diff
|
|
221
|
+
* @returns {string} '' when nothing was flagged.
|
|
222
|
+
*/
|
|
223
|
+
export function describeDiff(diff) {
|
|
224
|
+
const parts = [];
|
|
225
|
+
if (diff?.newStashEntries?.length) parts.push(`new stash: ${diff.newStashEntries.join(' | ')}`);
|
|
226
|
+
if (diff?.flaggedReflogEntries?.length) {
|
|
227
|
+
parts.push(`reflog: ${diff.flaggedReflogEntries.join(' | ')}`);
|
|
228
|
+
}
|
|
229
|
+
return parts.join(' ; ');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** True when a diff has anything worth warning about. Pure. */
|
|
233
|
+
export function isFlagged(diff) {
|
|
234
|
+
return Boolean(diff?.newStashEntries?.length || diff?.flaggedReflogEntries?.length);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Stop / session-end half: read the snapshot, diff, and — only if something was
|
|
239
|
+
* flagged — append ONE warn row to guardrail-history.jsonl naming it. Returns
|
|
240
|
+
* the diff result (so a caller/test can assert on it), or null when there was
|
|
241
|
+
* no snapshot to compare against (nothing to do, no false-flag risk).
|
|
242
|
+
*
|
|
243
|
+
* @param {{ repoRootPath?: string, sessionId?: string, snapshotPath?: string,
|
|
244
|
+
* append?: (row: object, repoRoot: string) => unknown }} [opts]
|
|
245
|
+
* `append` — override the guardrail-history writer (tests, and any front that
|
|
246
|
+
* wants the decision without the file write).
|
|
247
|
+
*/
|
|
248
|
+
export function runSessionEnd({
|
|
249
|
+
repoRootPath = repoRoot(),
|
|
250
|
+
sessionId,
|
|
251
|
+
snapshotPath,
|
|
252
|
+
append = appendGuardrailEvent,
|
|
253
|
+
} = {}) {
|
|
254
|
+
const filePath = snapshotPath || snapshotPathFor(repoRootPath, sessionId);
|
|
255
|
+
const snapshot = readSnapshot(filePath);
|
|
256
|
+
if (!snapshot) return null;
|
|
257
|
+
|
|
258
|
+
const diff = diffAgainstSnapshot(repoRootPath, snapshot);
|
|
259
|
+
if (!isFlagged(diff)) return diff;
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
append(
|
|
263
|
+
{
|
|
264
|
+
rule: RULE_ID,
|
|
265
|
+
surface: 'session-close',
|
|
266
|
+
severity: 'warn',
|
|
267
|
+
decision: 'warn',
|
|
268
|
+
matchContext: describeDiff(diff),
|
|
269
|
+
},
|
|
270
|
+
repoRootPath,
|
|
271
|
+
);
|
|
272
|
+
} catch {
|
|
273
|
+
// observability must never break the session close
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return diff;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export default {
|
|
280
|
+
RULE_ID,
|
|
281
|
+
captureGitState,
|
|
282
|
+
describeDiff,
|
|
283
|
+
diffAgainstSnapshot,
|
|
284
|
+
isFlagged,
|
|
285
|
+
normalizeReflogLine,
|
|
286
|
+
readReflogLines,
|
|
287
|
+
readSnapshot,
|
|
288
|
+
readStashList,
|
|
289
|
+
reflogAction,
|
|
290
|
+
repoRoot,
|
|
291
|
+
runSessionEnd,
|
|
292
|
+
snapshotPathFor,
|
|
293
|
+
writeSnapshot,
|
|
294
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// spexharness/src/intent-classifier.mjs
|
|
2
|
+
//
|
|
3
|
+
// Intent classification as an engine module on the prompt_submit event (HS-5ISS, epic
|
|
4
|
+
// HS-1YDJ), replacing scripts/claude-hooks/_intent-classifier-router.mjs's per-prompt prose
|
|
5
|
+
// injection with a mechanical lexical check against the engine's own compiled rule table
|
|
6
|
+
// (./rule-table.mjs's loadRuleTable — the same registry _guardrail-loader.mjs reads, so this
|
|
7
|
+
// module and the Claude Code hook agree about what a rule's pattern set and inject text are;
|
|
8
|
+
// neither re-derives the other's regex).
|
|
9
|
+
//
|
|
10
|
+
// THE COST FIX (why this task exists): the original hook's Layer 2 ("self_check" semantic
|
|
11
|
+
// mode) injects a multi-behavior "gate" block — one section per classifier behavior, several
|
|
12
|
+
// KB of behavior descriptions — on every prompt that MISSES every lexical pattern and is at
|
|
13
|
+
// least 12 characters long. That fires on almost every substantive prompt (an ordinary work
|
|
14
|
+
// order matches none of the five behaviors' regex), which is the ~6.5 KB/prompt the epic
|
|
15
|
+
// measured. This module drops that speculative layer entirely: classification here is
|
|
16
|
+
// LEXICAL ONLY. A rule's `inject` text is resolved and returned only when one of its own
|
|
17
|
+
// compiled patterns actually matches the prompt (AC2, "the directive is fetched only when a
|
|
18
|
+
// gate fires") — a prompt that matches nothing produces `{ decision: 'allow' }` with no
|
|
19
|
+
// injected text at all, which is the <500-byte target on a plain prompt (AC3).
|
|
20
|
+
//
|
|
21
|
+
// SCOPE NOTE: `classifyPrompt` is the LEXICAL core and stays pure — it returns the matched
|
|
22
|
+
// rule's raw inject, nothing more. The per-behavior enrichment the plugin applies on top —
|
|
23
|
+
// capture-on-idea's {{CONFIRM_MODE}} substitution, the capture-kind and thread-split-mode
|
|
24
|
+
// hints, research-before-decision's appended source hint, the env/config kill switches, the
|
|
25
|
+
// injected-content guard and the CONFIRMED MATCH marker — used to live only in
|
|
26
|
+
// scripts/claude-hooks/_intent-classifier-router.mjs, so a SpexCode seat saw a different
|
|
27
|
+
// (and, with the literal `{{CONFIRM_MODE}}` token, visibly unfinished) directive from the
|
|
28
|
+
// one Claude Code injects for the same prompt. HS-N01T moved that assembly into
|
|
29
|
+
// ./intent-directive.mjs, which BOTH the router and this gate's `run()` call — so the inject
|
|
30
|
+
// this gate returns is now byte-identical to the plugin's. The semantic (Layer 2 self-check /
|
|
31
|
+
// opt-in model-classify) layer stays in the router, unported.
|
|
32
|
+
//
|
|
33
|
+
// Pure: no I/O of its own beyond loadRuleTable's fs.readFileSync (itself fail-safe — see
|
|
34
|
+
// rule-table.mjs's header). No process.stdin/stdout/exit.
|
|
35
|
+
|
|
36
|
+
import { loadRuleTable } from './rule-table.mjs';
|
|
37
|
+
import { buildIntentDirective } from './intent-directive.mjs';
|
|
38
|
+
|
|
39
|
+
/** This gate's id, for a registry or an audit row. */
|
|
40
|
+
export const GATE_ID = 'intent-classifier';
|
|
41
|
+
/** Runs on the prompt-submit event, mirroring the retired hook's UserPromptSubmit wiring. */
|
|
42
|
+
export const EVENT = 'prompt_submit';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The rule table carries every `userpromptsubmit` rule, including
|
|
46
|
+
* `communication-style-prime` (HS-XIYJ's rule — an unconditional `[\s\S]` pattern that would
|
|
47
|
+
* "match" every prompt) and severity `prime` rules with no patterns at all. Intent
|
|
48
|
+
* classification is specifically the five mutually-exclusive INTENT rules the original
|
|
49
|
+
* router's `CLASSIFIER_BEHAVIORS` catalog named — restricting to these ids is what keeps
|
|
50
|
+
* this module from reinventing the always-on communication-style injection XIYJ retires
|
|
51
|
+
* separately.
|
|
52
|
+
* @type {ReadonlyArray<string>}
|
|
53
|
+
*/
|
|
54
|
+
export const CLASSIFIER_RULE_IDS = Object.freeze([
|
|
55
|
+
'capture-on-idea',
|
|
56
|
+
'research-before-decision',
|
|
57
|
+
'settings-intent',
|
|
58
|
+
'defer-intent',
|
|
59
|
+
'spec-reference-lookup',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/** Compile a rule's string patterns into case-insensitive RegExps, skipping any that fail
|
|
63
|
+
* to compile (a corrupt pattern degrades to "never matches", never a throw). */
|
|
64
|
+
function compilePatterns(rule) {
|
|
65
|
+
const patterns = Array.isArray(rule?.patterns) ? rule.patterns : [];
|
|
66
|
+
const compiled = [];
|
|
67
|
+
for (const source of patterns) {
|
|
68
|
+
try {
|
|
69
|
+
compiled.push(new RegExp(source, 'i'));
|
|
70
|
+
} catch {
|
|
71
|
+
// corrupt pattern in the table — skip it, fail-safe.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return compiled;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The classifier rules eligible to fire, in {@link CLASSIFIER_RULE_IDS} order (the
|
|
78
|
+
* deterministic lexical tiebreak, mirroring the original router's BEHAVIOR_ORDER). */
|
|
79
|
+
function classifierRules(table) {
|
|
80
|
+
const byId = new Map((table?.rules ?? []).map((r) => [r?.rule, r]));
|
|
81
|
+
const rules = [];
|
|
82
|
+
for (const id of CLASSIFIER_RULE_IDS) {
|
|
83
|
+
const rule = byId.get(id);
|
|
84
|
+
if (rule && typeof rule.inject === 'string' && rule.inject.trim() && rule.surfaces?.includes('userpromptsubmit')) {
|
|
85
|
+
rules.push(rule);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return rules;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @typedef {object} IntentClassification
|
|
93
|
+
* @property {string} rule the matched rule's id (e.g. 'capture-on-idea').
|
|
94
|
+
* @property {string} sourceProtocol the protocol that authored the rule.
|
|
95
|
+
* @property {string} inject the rule's raw inject text (unmodified — see the
|
|
96
|
+
* SCOPE NOTE above; per-behavior enrichment/substitution is not applied here).
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Classify a prompt against the engine's compiled rule table. First matching rule (in
|
|
101
|
+
* {@link CLASSIFIER_RULE_IDS} order) wins; a full miss returns null — no directive is
|
|
102
|
+
* resolved, matching AC2/AC3.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} prompt
|
|
105
|
+
* @param {{ vaultRoot?: string, ruleTable?: { rules: object[] } }} [opts]
|
|
106
|
+
* `ruleTable` is injectable so a test never depends on the live
|
|
107
|
+
* `.heyspex/guardrails.json`/built-in table; omitted, it loads the real one via
|
|
108
|
+
* {@link loadRuleTable}.
|
|
109
|
+
* @returns {IntentClassification|null}
|
|
110
|
+
*/
|
|
111
|
+
export function classifyPrompt(prompt, { vaultRoot, ruleTable } = {}) {
|
|
112
|
+
if (typeof prompt !== 'string' || !prompt.trim()) return null;
|
|
113
|
+
const table = ruleTable ?? loadRuleTable({ vaultRoot });
|
|
114
|
+
for (const rule of classifierRules(table)) {
|
|
115
|
+
const patterns = compilePatterns(rule);
|
|
116
|
+
if (patterns.some((re) => re.test(prompt))) {
|
|
117
|
+
return { rule: rule.rule, sourceProtocol: rule.sourceProtocol ?? null, inject: rule.inject };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The engine gate: runs on `prompt_submit`, classifies the prompt, and only when a rule
|
|
125
|
+
* actually fires does it return an `inject` decision carrying that rule's directive. A miss
|
|
126
|
+
* — including the common "plain prompt, matches nothing" case — is a plain `allow` with no
|
|
127
|
+
* injected text, satisfying AC3's <500-byte-on-a-plain-prompt target by construction (an
|
|
128
|
+
* `allow` decision carries no `inject` field at all).
|
|
129
|
+
*
|
|
130
|
+
* HS-N01T: a hit is then run through ./intent-directive.mjs's `buildIntentDirective` — the
|
|
131
|
+
* SAME eligibility check (env kill switch, config toggle, injected-content guard) and the
|
|
132
|
+
* SAME directive assembly (confirm-mode substitution, kind/routing hints, CONFIRMED MATCH
|
|
133
|
+
* marker) the Claude Code hook applies — so `inject.additionalContext` is what the plugin
|
|
134
|
+
* would have injected, never the raw inject with a literal `{{CONFIRM_MODE}}`. An
|
|
135
|
+
* ineligible behavior is an `allow`. A rule the behavior catalog does not know (or a row
|
|
136
|
+
* the plugin router would never fire) keeps the pre-N01T raw passthrough.
|
|
137
|
+
*
|
|
138
|
+
* `ctx` (all optional): `vaultRoot`/`repoRoot` — the app root holding `.heyspex/`;
|
|
139
|
+
* `ruleTable` — an injected table (tests); `env` — env overrides instead of `process.env`;
|
|
140
|
+
* `getSetting(repoRoot, key)` — an injected settings reader so a test never reads the real
|
|
141
|
+
* config (see ./intent-directive.mjs's header for the accepted shapes).
|
|
142
|
+
*
|
|
143
|
+
* @param {{ point: string, prompt?: string }} evt
|
|
144
|
+
* @param {{ vaultRoot?: string, repoRoot?: string, ruleTable?: object, env?: object, getSetting?: Function }} [ctx]
|
|
145
|
+
* @returns {{ decision: 'allow' }|{ decision: 'inject', reason: string, inject: { additionalContext: string, rule: string, behavior: string|null } }}
|
|
146
|
+
*/
|
|
147
|
+
export function run(evt, ctx = {}) {
|
|
148
|
+
const repoRoot = ctx.repoRoot ?? ctx.vaultRoot;
|
|
149
|
+
// One table load for both the lexical match and the directive resolution.
|
|
150
|
+
const ruleTable = ctx.ruleTable ?? loadRuleTable({ vaultRoot: repoRoot });
|
|
151
|
+
const hit = classifyPrompt(evt?.prompt, { ruleTable });
|
|
152
|
+
if (!hit) return { decision: 'allow' };
|
|
153
|
+
let built;
|
|
154
|
+
try {
|
|
155
|
+
built = buildIntentDirective(hit.rule, evt.prompt, {
|
|
156
|
+
repoRoot,
|
|
157
|
+
ruleTable,
|
|
158
|
+
env: ctx.env,
|
|
159
|
+
getSetting: ctx.getSetting,
|
|
160
|
+
});
|
|
161
|
+
} catch {
|
|
162
|
+
// The directive assembly is best-effort enrichment; a crash in it must never turn a
|
|
163
|
+
// confirmed lexical fire into silence. Degrade to the raw inject.
|
|
164
|
+
built = { behavior: null, additionalContext: null };
|
|
165
|
+
}
|
|
166
|
+
if (built === null) return { decision: 'allow' }; // killed by env/config/injected-content guard
|
|
167
|
+
return {
|
|
168
|
+
decision: 'inject',
|
|
169
|
+
reason: `intent-classifier: ${hit.rule} fired`,
|
|
170
|
+
inject: {
|
|
171
|
+
additionalContext: built.additionalContext ?? hit.inject,
|
|
172
|
+
rule: hit.rule,
|
|
173
|
+
behavior: built.behavior,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Caller-defined severity label. `prime` is the severity every rule in
|
|
180
|
+
* {@link CLASSIFIER_RULE_IDS} carries in the compiled registry — this gate never
|
|
181
|
+
* denies, it only primes a turn with the matched rule's directive.
|
|
182
|
+
*
|
|
183
|
+
* Added in wave 6 of [[HS-AZ61-NYPR]]: the module was written as a gate but was
|
|
184
|
+
* registered by nothing, so the classification it owns ran on no front at all.
|
|
185
|
+
* spexharness/src/gates/index.mjs now registers it (a registry row needs a
|
|
186
|
+
* severity for `hs harness gates` to print, hence this export), and
|
|
187
|
+
* spexharness/src/plugin-hook-coverage.test.mjs pins that registration so the
|
|
188
|
+
* Claude Code `intent-classifier-trigger` hook's coverage cannot silently lapse
|
|
189
|
+
* again.
|
|
190
|
+
*/
|
|
191
|
+
export const SEVERITY = 'prime';
|
|
192
|
+
|
|
193
|
+
const gate = { id: GATE_ID, event: EVENT, severity: SEVERITY, run };
|
|
194
|
+
export default gate;
|