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,197 @@
|
|
|
1
|
+
// spexharness/src/adapters/audit.mjs
|
|
2
|
+
//
|
|
3
|
+
// Restores the `.heyspex/guardrail-history.jsonl` audit-log side effect the
|
|
4
|
+
// ported gates under spexharness/src/gates/ deliberately left out — each
|
|
5
|
+
// one's header comment says "Not ported HERE" and points here. A gate stays
|
|
6
|
+
// a pure decision function (no fs writes); THIS module is the front-side
|
|
7
|
+
// observer that turns a gate's decision back into the exact row(s) its
|
|
8
|
+
// pre-cutover Claude Code hook wrote for the same firing, so
|
|
9
|
+
// scripts/claude-hooks/<gate>.smoke.mjs — the spec of that original
|
|
10
|
+
// behavior — keeps passing now that the shells route through the ported
|
|
11
|
+
// gates instead of the original scripts.
|
|
12
|
+
//
|
|
13
|
+
// Row shape is byte-compatible with the original writer
|
|
14
|
+
// (scripts/claude-hooks/_guardrail-events.mjs's appendGuardrailEvent):
|
|
15
|
+
// `{ timestamp, event_kind: "guardrail.fired", actor, spec_id, metadata }`.
|
|
16
|
+
// This module calls that function directly rather than reimplementing it —
|
|
17
|
+
// same "import the shared, already-pure library" precedent this porting
|
|
18
|
+
// effort already set for _guardrail-loader.mjs / _transcript-text.mjs (see
|
|
19
|
+
// e.g. enforce-session-sequence.mjs's own header) — so the JSONL rows the
|
|
20
|
+
// console's guardrail-history reader sees are identical whichever writer
|
|
21
|
+
// produced them.
|
|
22
|
+
//
|
|
23
|
+
// Per-gate mapping: only the three gates whose pre-cutover hooks called
|
|
24
|
+
// appendGuardrailEvent AND whose smoke tests assert on the resulting log
|
|
25
|
+
// (capture-workflow-gate, enforce-session-sequence, naughty-list-gate) are
|
|
26
|
+
// mapped in {@link auditRowsFor} today. Each case reads its exact original
|
|
27
|
+
// call shape straight from `git show HEAD:scripts/claude-hooks/<gate>.mjs`
|
|
28
|
+
// (pre-cutover) and reconstructs it from the fields the ported gate's
|
|
29
|
+
// decision object carries forward for exactly this purpose (see each gate's
|
|
30
|
+
// own header comment for which fields and why). A gate/decision this module
|
|
31
|
+
// has no case for returns no rows — this NEVER guesses a shape for a gate it
|
|
32
|
+
// doesn't recognize.
|
|
33
|
+
//
|
|
34
|
+
// FAIL-SAFE (same contract as every hook and as _guardrail-events.mjs
|
|
35
|
+
// itself): a write failure here must never break the caller. Every row
|
|
36
|
+
// write is wrapped; recordGuardrailAudit() never throws.
|
|
37
|
+
|
|
38
|
+
import { appendGuardrailEvent as defaultAppendGuardrailEvent } from '../../../scripts/claude-hooks/_guardrail-events.mjs';
|
|
39
|
+
import { redactRecord } from '../redact.mjs';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the zero-or-more guardrail-history rows implied by one gate's
|
|
43
|
+
* decision, matching what its pre-cutover Claude Code hook wrote for the
|
|
44
|
+
* same firing. Pure — no I/O. Returns `[]` for a plain `allow`, for any gate
|
|
45
|
+
* this module has no mapping for, or for a decision shape the mapping
|
|
46
|
+
* doesn't recognize (fail-safe: never guess).
|
|
47
|
+
*
|
|
48
|
+
* Each returned row is an `appendGuardrailEvent()`-shaped options object
|
|
49
|
+
* (`{ rule, surface, severity, decision, specId?, matchContext? }`) — the
|
|
50
|
+
* caller still supplies `repoRoot`.
|
|
51
|
+
*
|
|
52
|
+
* @param {{ id?: string }} gate
|
|
53
|
+
* @param {{ decision: string, reason?: string, rewrite?: Record<string, unknown>, rule?: string, blockHits?: Array<{ word: string, context?: string }> }} decision
|
|
54
|
+
* @returns {Array<Record<string, unknown>>}
|
|
55
|
+
*/
|
|
56
|
+
export function auditRowsFor(gate, decision) {
|
|
57
|
+
const kind = decision?.decision ?? 'allow';
|
|
58
|
+
if (kind === 'allow' || !decision) return [];
|
|
59
|
+
|
|
60
|
+
switch (gate?.id) {
|
|
61
|
+
// Original (scripts/claude-hooks/capture-workflow-gate.mjs, pre-cutover):
|
|
62
|
+
// appendGuardrailEvent({ rule: `capture-workflow-gate:${type}`,
|
|
63
|
+
// surface: 'pretooluse', severity: 'warn', decision: 'warn',
|
|
64
|
+
// specId: resolved.spec_id }, root);
|
|
65
|
+
// Fired on every match — this gate NEVER denies (see its header), so
|
|
66
|
+
// only the `rewrite.warn` shape is mapped.
|
|
67
|
+
case 'capture-workflow-gate': {
|
|
68
|
+
if (kind !== 'rewrite' || !decision.rewrite?.warn) return [];
|
|
69
|
+
const captureId = decision.rewrite?.captureId;
|
|
70
|
+
if (!captureId) return [];
|
|
71
|
+
const type = decision.rewrite?.type ?? '';
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
rule: `capture-workflow-gate:${type}`,
|
|
75
|
+
surface: 'pretooluse',
|
|
76
|
+
severity: 'warn',
|
|
77
|
+
decision: 'warn',
|
|
78
|
+
specId: captureId,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Original (scripts/claude-hooks/enforce-session-sequence.mjs, pre-cutover)
|
|
84
|
+
// fireRule():
|
|
85
|
+
// appendGuardrailEvent({ rule: ruleId, surface: 'stop',
|
|
86
|
+
// severity: forReal ? (rule.severity ?? 'block') : 'warn',
|
|
87
|
+
// decision: forReal ? 'block' : 'warn' }, root);
|
|
88
|
+
// Fired exactly once per Stop — either the wip rule or the progress
|
|
89
|
+
// rule, whichever the gate reported (`decision.rule`, carried on both
|
|
90
|
+
// the `deny` and `rewrite` results — see the gate's own header). The
|
|
91
|
+
// registry's `session-wip-before-stop` / `session-progress-before-review`
|
|
92
|
+
// rules are both severity "block" today, so `rule.severity ?? 'block'`
|
|
93
|
+
// and the `'block'` literal here agree; this reads the literal (matching
|
|
94
|
+
// the fallback) rather than re-loading the registry, keeping this module
|
|
95
|
+
// free of any guardrail-loader dependency of its own.
|
|
96
|
+
case 'enforce-session-sequence': {
|
|
97
|
+
const ruleId = decision.rule;
|
|
98
|
+
if (!ruleId) return []; // no rule id carried → nothing to log (fail-safe)
|
|
99
|
+
if (kind === 'deny') {
|
|
100
|
+
return [{ rule: ruleId, surface: 'stop', severity: 'block', decision: 'block' }];
|
|
101
|
+
}
|
|
102
|
+
if (kind === 'rewrite' && decision.rewrite?.warn) {
|
|
103
|
+
return [{ rule: ruleId, surface: 'stop', severity: 'warn', decision: 'warn' }];
|
|
104
|
+
}
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Original (scripts/claude-hooks/naughty-list-gate.mjs, pre-cutover):
|
|
109
|
+
// for (const hit of blockHits) {
|
|
110
|
+
// appendGuardrailEvent({ rule: `naughty-list:${hit.word}`,
|
|
111
|
+
// surface: 'stop', severity: 'block',
|
|
112
|
+
// decision: warnOnly ? 'warn' : 'block', matchContext: hit.context });
|
|
113
|
+
// }
|
|
114
|
+
// One row PER block-severity hit (not deduped by word — a repeated hit
|
|
115
|
+
// logs a repeated row, same as the original's own loop), fired on both
|
|
116
|
+
// rungs; warn-severity-only hits are never logged (the original's loop
|
|
117
|
+
// only ever iterates `blockHits`). `decision.blockHits` carries exactly
|
|
118
|
+
// that array on both `deny` and `rewrite` — see the gate's own header.
|
|
119
|
+
case 'naughty-list-gate': {
|
|
120
|
+
const hits = Array.isArray(decision.blockHits) ? decision.blockHits : [];
|
|
121
|
+
if (!hits.length) return [];
|
|
122
|
+
const rowDecision = kind === 'deny' ? 'block' : 'warn';
|
|
123
|
+
return hits.map((hit) => ({
|
|
124
|
+
rule: `naughty-list:${hit?.word ?? ''}`,
|
|
125
|
+
surface: 'stop',
|
|
126
|
+
severity: 'block',
|
|
127
|
+
decision: rowDecision,
|
|
128
|
+
matchContext: hit?.context,
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Original (scripts/claude-hooks/epic-milestone-phrasing-gate.mjs,
|
|
133
|
+
// pre-cutover):
|
|
134
|
+
// appendGuardrailEvent({ rule: RULE_ID, surface: 'stop',
|
|
135
|
+
// severity: 'warn', decision: warnOnly ? 'warn' : 'block',
|
|
136
|
+
// matchContext: hits[0].excerpt });
|
|
137
|
+
// Exactly ONE row per firing (the original logs the FIRST hit's excerpt,
|
|
138
|
+
// not one row per hit — unlike naughty-list-gate above), at the literal
|
|
139
|
+
// severity 'warn' on BOTH rungs, because this gate ships warn-first by
|
|
140
|
+
// design and the registry row's own severity is 'warn' too. `decision.hits`
|
|
141
|
+
// carries the excerpts on both the `deny` and `rewrite` results — see the
|
|
142
|
+
// gate's own header.
|
|
143
|
+
case 'epic-milestone-phrasing-gate': {
|
|
144
|
+
const hits = Array.isArray(decision.hits) ? decision.hits : [];
|
|
145
|
+
if (!hits.length) return [];
|
|
146
|
+
if (kind !== 'deny' && !(kind === 'rewrite' && decision.rewrite?.warn)) return [];
|
|
147
|
+
return [
|
|
148
|
+
{
|
|
149
|
+
rule: 'epic-milestone-phrasing-gate',
|
|
150
|
+
surface: 'stop',
|
|
151
|
+
severity: 'warn',
|
|
152
|
+
decision: kind === 'deny' ? 'block' : 'warn',
|
|
153
|
+
matchContext: hits[0]?.excerpt,
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
default:
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Write every audit row {@link auditRowsFor} derives from one gate's
|
|
165
|
+
* decision. Injectable: pass `deps.append` to swap out the real
|
|
166
|
+
* `_guardrail-events.mjs` writer (e.g. so a unit test can assert the call
|
|
167
|
+
* without touching the real file, or so an in-process caller that wants no
|
|
168
|
+
* file writes passes a no-op). Never throws — a row write failure is
|
|
169
|
+
* swallowed per row, matching `appendGuardrailEvent`'s own fail-safe
|
|
170
|
+
* contract (and this module's own header).
|
|
171
|
+
*
|
|
172
|
+
* @param {{ id?: string }} gate
|
|
173
|
+
* @param {{ decision: string }} decision
|
|
174
|
+
* @param {string} repoRoot
|
|
175
|
+
* @param {{ append?: (row: Record<string, unknown>, repoRoot: string) => unknown }} [deps]
|
|
176
|
+
* @returns {number} how many rows were written (best-effort; a swallowed
|
|
177
|
+
* per-row failure still counts as attempted, not written — callers that
|
|
178
|
+
* need the true count should inspect their own `deps.append`'s return).
|
|
179
|
+
*/
|
|
180
|
+
export function recordGuardrailAudit(gate, decision, repoRoot, deps = {}) {
|
|
181
|
+
const append = deps.append ?? defaultAppendGuardrailEvent;
|
|
182
|
+
const rows = auditRowsFor(gate, decision);
|
|
183
|
+
let written = 0;
|
|
184
|
+
for (const row of rows) {
|
|
185
|
+
try {
|
|
186
|
+
// Redacted before it ever reaches `.heyspex/guardrail-history.jsonl`
|
|
187
|
+
// (HS-Y424) — `matchContext` (naughty-list-gate) is literally a
|
|
188
|
+
// snippet of whatever text tripped a rule, the field most likely to
|
|
189
|
+
// carry ingested prose or a leaked key verbatim.
|
|
190
|
+
append(redactRecord(row), repoRoot);
|
|
191
|
+
written += 1;
|
|
192
|
+
} catch {
|
|
193
|
+
// fail-safe: observability must never break the caller.
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return written;
|
|
197
|
+
}
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
// spexharness/src/adapters/claude-code.mjs
|
|
2
|
+
//
|
|
3
|
+
// The Claude Code front's translation layer (HS-IQBF, wave-5 of
|
|
4
|
+
// [[HS-AZ61-NYPR]]). Every one of the twelve ported gates under
|
|
5
|
+
// spexharness/src/gates/ is now driven through THIS module by a generated
|
|
6
|
+
// shell (scripts/generate-hook-shells.mjs) rather than by its own
|
|
7
|
+
// stdin-reading, stdout-writing main() — this file is the only place that
|
|
8
|
+
// speaks the Claude Code hook stdin/stdout contract.
|
|
9
|
+
//
|
|
10
|
+
// Two directions, both pure except the tiny stdin/stdout glue at the bottom:
|
|
11
|
+
// 1. toEngineEvent(payload) — Claude Code hook stdin JSON → an
|
|
12
|
+
// spexharness/src/events.mjs event.
|
|
13
|
+
// 2. toHookResponse(point, id, decision) — a gate's decide.mjs-shaped
|
|
14
|
+
// decision → { exitCode, stdout, stderr }
|
|
15
|
+
// in the exact shape Claude Code expects
|
|
16
|
+
// back, one of four answer forms:
|
|
17
|
+
// a. bare exit code (0 allow / 2 deny)
|
|
18
|
+
// plus a stderr banner — the
|
|
19
|
+
// LEGACY style two of the twelve
|
|
20
|
+
// originals used (env-vault-guard,
|
|
21
|
+
// enforce-vault-naming).
|
|
22
|
+
// b. hookSpecificOutput.permissionDecision
|
|
23
|
+
// allow|deny|ask (+ reason) on
|
|
24
|
+
// stdout, exit 0 — the MODERN style
|
|
25
|
+
// the other before_tool gates use.
|
|
26
|
+
// c. hookSpecificOutput.additionalContext
|
|
27
|
+
// on stdout, exit 0 — an `inject`
|
|
28
|
+
// decision (no built-in gate uses
|
|
29
|
+
// this yet; it's here so the
|
|
30
|
+
// adapter's own contract is complete
|
|
31
|
+
// per this task's AC1).
|
|
32
|
+
// d. { decision: "block", reason } on
|
|
33
|
+
// stdout, exit 0 — the Stop hook
|
|
34
|
+
// contract (enforce-session-sequence,
|
|
35
|
+
// naughty-list-gate,
|
|
36
|
+
// id-display-format-gate).
|
|
37
|
+
//
|
|
38
|
+
// `runGateAsHook(gate)` at the bottom is the one impure entry point: it reads
|
|
39
|
+
// stdin, calls the two pure functions above, restores the guardrail-history
|
|
40
|
+
// audit-log write each ported gate deliberately left out of its own pure
|
|
41
|
+
// decide() (see ./audit.mjs and each gate's "Not ported HERE" header), writes
|
|
42
|
+
// stdout/stderr, and sets process.exitCode. This is what every generated
|
|
43
|
+
// shell calls — see scripts/generate-hook-shells.mjs and PORTING.md.
|
|
44
|
+
//
|
|
45
|
+
// FAIL-SAFE (house rule every original hook already followed): any internal
|
|
46
|
+
// error here — bad JSON on stdin, an unknown hook_event_name, a gate that
|
|
47
|
+
// throws — must never deny. It falls through to allow (exit 0, no output),
|
|
48
|
+
// same as every original script's own top-level `.catch(() => process.exit(0))`.
|
|
49
|
+
|
|
50
|
+
import path from 'node:path';
|
|
51
|
+
import { fileURLToPath } from 'node:url';
|
|
52
|
+
|
|
53
|
+
import { createEvent } from '../events.mjs';
|
|
54
|
+
import { recordGuardrailAudit } from './audit.mjs';
|
|
55
|
+
|
|
56
|
+
/** Claude Code `hook_event_name` → spexharness/src/events.mjs event point.
|
|
57
|
+
* Deliberately does NOT map `SubagentStop` to `stop` — SubagentStop is a
|
|
58
|
+
* distinct Claude Code hook event none of the twelve gates in this task are
|
|
59
|
+
* registered for (see claim-liveness-shared.mjs for where it IS used); an
|
|
60
|
+
* explicit `hook_event_name: "SubagentStop"` on stdin must read as "not this
|
|
61
|
+
* gate's event" (→ allow), never silently fold into `stop`. */
|
|
62
|
+
const HOOK_EVENT_TO_POINT = {
|
|
63
|
+
SessionStart: 'session_start',
|
|
64
|
+
UserPromptSubmit: 'prompt_submit',
|
|
65
|
+
PreToolUse: 'before_tool',
|
|
66
|
+
PostToolUse: 'after_tool',
|
|
67
|
+
Stop: 'stop',
|
|
68
|
+
SessionEnd: 'session_end',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The inverse of {@link HOOK_EVENT_TO_POINT}, for building output JSON's
|
|
72
|
+
* `hookEventName` field from an engine event's `point`. */
|
|
73
|
+
const POINT_TO_HOOK_EVENT = {
|
|
74
|
+
session_start: 'SessionStart',
|
|
75
|
+
prompt_submit: 'UserPromptSubmit',
|
|
76
|
+
before_tool: 'PreToolUse',
|
|
77
|
+
after_tool: 'PostToolUse',
|
|
78
|
+
stop: 'Stop',
|
|
79
|
+
session_end: 'SessionEnd',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} hookEventName - Claude Code's `hook_event_name` stdin field.
|
|
84
|
+
* @returns {string | null} the matching spexharness event point, or null for
|
|
85
|
+
* an event this adapter has no mapping for (a front-end concern, not a
|
|
86
|
+
* gate concern — the caller should fail open, never guess).
|
|
87
|
+
*/
|
|
88
|
+
export function hookEventNameToPoint(hookEventName) {
|
|
89
|
+
return HOOK_EVENT_TO_POINT[hookEventName] ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Field mapping (Claude Code stdin → engine event fields), shared by
|
|
94
|
+
* {@link toEngineEvent} and the lenient per-gate resolution
|
|
95
|
+
* {@link runGateAsHook} uses:
|
|
96
|
+
* session_id → sessionId (every point)
|
|
97
|
+
* tool_name → toolName (before_tool / after_tool)
|
|
98
|
+
* tool_input → toolInput (before_tool / after_tool)
|
|
99
|
+
* tool_response → toolResult (after_tool)
|
|
100
|
+
* prompt → prompt (prompt_submit)
|
|
101
|
+
* cwd → cwd (session_start)
|
|
102
|
+
* source → agent (session_start — Claude Code's own field
|
|
103
|
+
* name for "how the session started")
|
|
104
|
+
* transcript_path → transcriptPath (stop)
|
|
105
|
+
* stop_hook_active → stopHookActive (stop)
|
|
106
|
+
* reason → reason (session_end)
|
|
107
|
+
*
|
|
108
|
+
* @param {string} point
|
|
109
|
+
* @param {Record<string, unknown>} payload
|
|
110
|
+
* @returns {Record<string, unknown>}
|
|
111
|
+
*/
|
|
112
|
+
function fieldsForPoint(point, payload) {
|
|
113
|
+
const fields = { sessionId: payload?.session_id };
|
|
114
|
+
switch (point) {
|
|
115
|
+
case 'before_tool':
|
|
116
|
+
fields.toolName = payload?.tool_name;
|
|
117
|
+
fields.toolInput = payload?.tool_input ?? {};
|
|
118
|
+
break;
|
|
119
|
+
case 'after_tool':
|
|
120
|
+
fields.toolName = payload?.tool_name;
|
|
121
|
+
fields.toolInput = payload?.tool_input ?? {};
|
|
122
|
+
fields.toolResult = payload?.tool_response;
|
|
123
|
+
break;
|
|
124
|
+
case 'prompt_submit':
|
|
125
|
+
fields.prompt = payload?.prompt;
|
|
126
|
+
break;
|
|
127
|
+
case 'session_start':
|
|
128
|
+
fields.cwd = payload?.cwd;
|
|
129
|
+
fields.agent = payload?.source;
|
|
130
|
+
break;
|
|
131
|
+
case 'stop':
|
|
132
|
+
fields.transcriptPath = payload?.transcript_path;
|
|
133
|
+
fields.stopHookActive = payload?.stop_hook_active;
|
|
134
|
+
break;
|
|
135
|
+
case 'session_end':
|
|
136
|
+
fields.reason = payload?.reason;
|
|
137
|
+
break;
|
|
138
|
+
default:
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
return fields;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Map a Claude Code hook stdin payload (already JSON.parse'd) to a
|
|
146
|
+
* spexharness engine event. Returns null when `hook_event_name` is missing
|
|
147
|
+
* or unrecognized — the caller's job to fail open on that, not this
|
|
148
|
+
* function's (this module has no I/O of its own).
|
|
149
|
+
*
|
|
150
|
+
* @param {Record<string, unknown>} payload
|
|
151
|
+
* @returns {ReturnType<typeof createEvent> | null}
|
|
152
|
+
*/
|
|
153
|
+
export function toEngineEvent(payload) {
|
|
154
|
+
const point = hookEventNameToPoint(payload?.hook_event_name);
|
|
155
|
+
if (!point) return null;
|
|
156
|
+
return createEvent(point, fieldsForPoint(point, payload));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Two of the twelve original hooks used the LEGACY "bare exit code + stderr
|
|
160
|
+
// banner" style (exit 2, empty stdout, a `\n[<banner>]\n<reason>\n` stderr
|
|
161
|
+
// message) instead of the modern permissionDecision JSON every other
|
|
162
|
+
// before_tool gate here uses. Keyed by gate id so a future gate defaults to
|
|
163
|
+
// the modern style without needing to opt out of anything.
|
|
164
|
+
const LEGACY_EXIT2_BANNER = {
|
|
165
|
+
'env-vault-guard': 'HeySpex env-vault guard',
|
|
166
|
+
'enforce-vault-naming': 'HeySpex vault-naming hook',
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function jsonStdout(obj) {
|
|
170
|
+
return JSON.stringify(obj);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Map one gate's decide.mjs-shaped result — `{ decision: 'allow'|'deny'|
|
|
175
|
+
* 'rewrite'|'inject', reason?, rewrite?, inject? }` — onto the Claude Code
|
|
176
|
+
* hook contract for the event it ran against.
|
|
177
|
+
*
|
|
178
|
+
* @param {{ id: string, event: string }} gate - the gate's own `id`/`event`
|
|
179
|
+
* (used for the legacy-banner lookup and as a fallback hookEventName).
|
|
180
|
+
* @param {{ point: string }} engineEvent - the event the gate ran against
|
|
181
|
+
* (its `point` decides which of the four answer shapes applies).
|
|
182
|
+
* @param {{ decision: string, reason?: string, rewrite?: object, inject?: object|string }} decision
|
|
183
|
+
* @returns {{ exitCode: number, stdout: string, stderr: string }}
|
|
184
|
+
*/
|
|
185
|
+
export function toHookResponse(gate, engineEvent, decision) {
|
|
186
|
+
const point = engineEvent?.point ?? gate?.event;
|
|
187
|
+
const hookEventName = POINT_TO_HOOK_EVENT[point] ?? 'PreToolUse';
|
|
188
|
+
const kind = decision?.decision ?? 'allow';
|
|
189
|
+
|
|
190
|
+
// ── Stop hook contract: { decision: "block", reason } or nothing. ────────
|
|
191
|
+
if (point === 'stop') {
|
|
192
|
+
if (kind === 'deny') {
|
|
193
|
+
return { exitCode: 0, stdout: jsonStdout({ decision: 'block', reason: decision.reason }), stderr: '' };
|
|
194
|
+
}
|
|
195
|
+
if (kind === 'rewrite' && decision.rewrite?.warn) {
|
|
196
|
+
const text = decision.rewrite.stderrText ?? warnStderrText(decision.reason ?? decision.rewrite.reason);
|
|
197
|
+
return { exitCode: 0, stdout: '', stderr: text };
|
|
198
|
+
}
|
|
199
|
+
return { exitCode: 0, stdout: '', stderr: '' };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── before_tool / after_tool: permissionDecision, or the legacy exit-2 style. ──
|
|
203
|
+
if (kind === 'deny') {
|
|
204
|
+
const banner = LEGACY_EXIT2_BANNER[gate?.id];
|
|
205
|
+
if (banner) {
|
|
206
|
+
return { exitCode: 2, stdout: '', stderr: `\n[${banner}]\n${decision.reason}\n` };
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
exitCode: 0,
|
|
210
|
+
stdout: jsonStdout({
|
|
211
|
+
hookSpecificOutput: { hookEventName, permissionDecision: 'deny', permissionDecisionReason: decision.reason },
|
|
212
|
+
}),
|
|
213
|
+
stderr: '',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (kind === 'rewrite') {
|
|
218
|
+
if (decision.rewrite?.ask) {
|
|
219
|
+
return {
|
|
220
|
+
exitCode: 0,
|
|
221
|
+
stdout: jsonStdout({
|
|
222
|
+
hookSpecificOutput: { hookEventName, permissionDecision: 'ask', permissionDecisionReason: decision.reason },
|
|
223
|
+
}),
|
|
224
|
+
stderr: '',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (decision.rewrite?.warn) {
|
|
228
|
+
const text = decision.rewrite.stderrText ?? warnStderrText(decision.reason ?? decision.rewrite.reason);
|
|
229
|
+
return { exitCode: 0, stdout: '', stderr: text };
|
|
230
|
+
}
|
|
231
|
+
// A rewrite with neither ask nor warn (not produced by any of the twelve
|
|
232
|
+
// gates today) — fail open rather than guess at a shape.
|
|
233
|
+
return { exitCode: 0, stdout: '', stderr: '' };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (kind === 'inject') {
|
|
237
|
+
const additionalContext =
|
|
238
|
+
typeof decision.inject === 'string' ? decision.inject : decision.inject?.additionalContext;
|
|
239
|
+
if (!additionalContext) return { exitCode: 0, stdout: '', stderr: '' };
|
|
240
|
+
return {
|
|
241
|
+
exitCode: 0,
|
|
242
|
+
stdout: jsonStdout({ hookSpecificOutput: { hookEventName, additionalContext } }),
|
|
243
|
+
stderr: '',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 'allow' (and any unrecognized decision — fail open, never guess a deny).
|
|
248
|
+
return { exitCode: 0, stdout: '', stderr: '' };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Default stderr formatter for a `rewrite.warn` decision that carries no
|
|
252
|
+
* explicit `rewrite.stderrText` override: append exactly one trailing
|
|
253
|
+
* newline, unless `reason` already ends with one (several gates' own warn
|
|
254
|
+
* text already carries its own trailing newline — see naughty-list-gate.mjs
|
|
255
|
+
* / id-display-format-gate.mjs's `warnReason()`). */
|
|
256
|
+
function warnStderrText(reason) {
|
|
257
|
+
const text = String(reason ?? '');
|
|
258
|
+
return text.endsWith('\n') ? text : `${text}\n`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── stdin / process glue — the only impure code in this module ─────────────
|
|
262
|
+
|
|
263
|
+
/** Read and JSON.parse Claude Code's hook stdin payload. Mirrors every
|
|
264
|
+
* original hook's own `readStdinJson()`: a TTY (no piped stdin) or
|
|
265
|
+
* unparseable input both resolve to null, never throw. */
|
|
266
|
+
async function readStdinJson() {
|
|
267
|
+
if (process.stdin.isTTY) return null;
|
|
268
|
+
let buf = '';
|
|
269
|
+
process.stdin.setEncoding('utf8');
|
|
270
|
+
for await (const chunk of process.stdin) buf += chunk;
|
|
271
|
+
if (!buf.trim()) return null;
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(buf);
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Resolve the engine event a gate should run against, for one gate's shell
|
|
281
|
+
* specifically — LENIENT where {@link toEngineEvent} is strict: when
|
|
282
|
+
* `hook_event_name` is present, it must map to this gate's OWN `event`
|
|
283
|
+
* (anything else, including a recognized-but-different event like
|
|
284
|
+
* `SubagentStop` or `PostToolUse`, is "not this gate's turn" → null, allow).
|
|
285
|
+
* When `hook_event_name` is absent altogether (no key on the payload at
|
|
286
|
+
* all — every real Claude Code hook call always includes it; only a
|
|
287
|
+
* hand-built test payload omits it, e.g. `{ tool_name, tool_input }` with no
|
|
288
|
+
* envelope), this assumes the payload is already shaped for THIS gate's own
|
|
289
|
+
* registered event and maps its fields accordingly — a shell is always
|
|
290
|
+
* wired to exactly one event, so there is nothing to guess here.
|
|
291
|
+
*
|
|
292
|
+
* @param {Record<string, unknown>} payload
|
|
293
|
+
* @param {{ event: string }} gate
|
|
294
|
+
* @returns {ReturnType<typeof createEvent> | null}
|
|
295
|
+
*/
|
|
296
|
+
function resolveEventForGate(payload, gate) {
|
|
297
|
+
const hasHookEventName = payload?.hook_event_name !== undefined;
|
|
298
|
+
// A hook script registered on SEVERAL Claude Code events (coo-guard-check is
|
|
299
|
+
// registered on both SessionStart and PreToolUse in .claude/settings.json)
|
|
300
|
+
// hands this function a gate whose `event` is the ARRAY of points it answers
|
|
301
|
+
// for — see spexharness/src/gates/coo-guard-gate.mjs's `hookGate`. Only this
|
|
302
|
+
// adapter understands that form; registry.mjs stays one-event-per-gate by
|
|
303
|
+
// construction, so such a hook registers its rungs there as separate gates.
|
|
304
|
+
const multi = Array.isArray(gate?.event);
|
|
305
|
+
// With no `hook_event_name` at all (a hand-built test payload), a single-event
|
|
306
|
+
// gate's own event is the only sensible reading; a multi-event gate has
|
|
307
|
+
// nothing to guess from, so it declines rather than picking one.
|
|
308
|
+
const point = hasHookEventName ? hookEventNameToPoint(payload.hook_event_name) : multi ? null : gate.event;
|
|
309
|
+
if (!point) return null;
|
|
310
|
+
if (multi ? !gate.event.includes(point) : point !== gate.event) return null;
|
|
311
|
+
return createEvent(point, fieldsForPoint(point, payload));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** This adapter's own repo root (spexharness/src/adapters/claude-code.mjs is
|
|
315
|
+
* three levels below it) — the same depth every ported gate's own REPO_ROOT
|
|
316
|
+
* constant resolves from, so {@link auditRepoRoot}'s env-first fallback
|
|
317
|
+
* agrees with whatever root a gate itself used to resolve its decision. */
|
|
318
|
+
const ADAPTER_REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
319
|
+
|
|
320
|
+
/** Same env-first resolution every pre-cutover hook's own `repoRoot()`
|
|
321
|
+
* helper used (CLAUDE_PROJECT_DIR, trailing slash stripped, else the
|
|
322
|
+
* repo-relative fallback) — kept here rather than imported so this module
|
|
323
|
+
* has no dependency on any one gate's own copy of the same three lines. */
|
|
324
|
+
function auditRepoRoot() {
|
|
325
|
+
const fromEnv = process.env.CLAUDE_PROJECT_DIR?.trim();
|
|
326
|
+
if (fromEnv) return fromEnv.replace(/[\\/]+$/, '');
|
|
327
|
+
return ADAPTER_REPO_ROOT;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Run ONE gate as a Claude Code hook process: read stdin, map it to an
|
|
332
|
+
* engine event, run the gate, restore the guardrail-history audit-log side
|
|
333
|
+
* effect the gate itself deliberately left out (see audit.mjs and each
|
|
334
|
+
* ported gate's own "Not ported HERE" header), map the decision back to the
|
|
335
|
+
* hook contract, write stdout/stderr, and set `process.exitCode`. This is
|
|
336
|
+
* the whole body of every generated shell under scripts/claude-hooks/ — see
|
|
337
|
+
* scripts/generate-hook-shells.mjs.
|
|
338
|
+
*
|
|
339
|
+
* Fail-safe throughout: any error anywhere in this pipeline is swallowed and
|
|
340
|
+
* treated as allow (exit 0, no output) — matching every original hook's own
|
|
341
|
+
* `.catch(() => process.exit(0))`. The audit write is additionally never
|
|
342
|
+
* allowed to affect the hook response either way — see
|
|
343
|
+
* `recordGuardrailAudit`'s own fail-safe contract.
|
|
344
|
+
*
|
|
345
|
+
* @param {{ id: string, event: string, run: (event: object, ctx: object) => unknown }} gate
|
|
346
|
+
* @param {{ audit?: false | ((gate: object, decision: object, repoRoot: string) => unknown),
|
|
347
|
+
* append?: (row: Record<string, unknown>, repoRoot: string) => unknown,
|
|
348
|
+
* payload?: Record<string, unknown> }} [deps]
|
|
349
|
+
* `deps.payload` — an ALREADY-READ stdin payload. Only {@link runGatesAsHook}
|
|
350
|
+
* passes it: stdin can be consumed exactly once, so a shell wired to several
|
|
351
|
+
* gates reads it, picks the gate whose event matches, and hands the payload
|
|
352
|
+
* on rather than letting this function read an empty stream.
|
|
353
|
+
* `deps.audit` — full override of the audit step; pass `false` to disable
|
|
354
|
+
* it entirely (an in-process caller that wants no `.heyspex/guardrail-
|
|
355
|
+
* history.jsonl` writes at all). `deps.append` — override just the
|
|
356
|
+
* low-level row writer (recordGuardrailAudit's own `deps.append`), while
|
|
357
|
+
* keeping this module's row-shape mapping — what the adapter's own unit
|
|
358
|
+
* test uses to assert the call without touching the real file. Neither
|
|
359
|
+
* set → the real audit trail is written, restoring the pre-cutover
|
|
360
|
+
* behavior unconditionally (every generated shell calls `runGateAsHook(gate)`
|
|
361
|
+
* with no `deps`, so this is what a live Claude Code session gets).
|
|
362
|
+
* @returns {Promise<void>}
|
|
363
|
+
*/
|
|
364
|
+
/**
|
|
365
|
+
* Run the ONE gate out of `gates` that is registered for the incoming hook
|
|
366
|
+
* event, for a Claude Code hook script wired to MORE THAN ONE event.
|
|
367
|
+
*
|
|
368
|
+
* Why it exists (wave 6 of [[HS-AZ61-NYPR]]): `.claude/settings.json` registers
|
|
369
|
+
* `git-reflog-audit` on BOTH SessionStart and Stop, and the original script
|
|
370
|
+
* branched on `hook_event_name` inside its own `main()`. The engine registry
|
|
371
|
+
* keys a gate on exactly one event point (registry.mjs's `assertValidGate`), so
|
|
372
|
+
* that rule ships as a PAIR of gates and its shell needs to pick between them.
|
|
373
|
+
* `runGateAsHook` cannot simply be called twice: stdin can be consumed once, so
|
|
374
|
+
* the second call would read an empty stream. This reads it once and hands it
|
|
375
|
+
* on via `deps.payload`.
|
|
376
|
+
*
|
|
377
|
+
* Fail-safe throughout, exactly like {@link runGateAsHook}: a payload that maps
|
|
378
|
+
* to none of these gates' events writes nothing and exits 0.
|
|
379
|
+
*
|
|
380
|
+
* @param {Array<{ id: string, event: string, run: Function }>|{ id: string, event: string, run: Function }} gates
|
|
381
|
+
* @param {object} [deps] - forwarded to {@link runGateAsHook}.
|
|
382
|
+
* @returns {Promise<void>}
|
|
383
|
+
*/
|
|
384
|
+
export async function runGatesAsHook(gates, deps = {}) {
|
|
385
|
+
let payload = null;
|
|
386
|
+
try {
|
|
387
|
+
payload = await readStdinJson();
|
|
388
|
+
} catch {
|
|
389
|
+
return; // fail-open, same as runGateAsHook's own catch
|
|
390
|
+
}
|
|
391
|
+
if (!payload) return;
|
|
392
|
+
|
|
393
|
+
const list = Array.isArray(gates) ? gates : [gates];
|
|
394
|
+
const match = list.find((gate) => {
|
|
395
|
+
try {
|
|
396
|
+
return resolveEventForGate(payload, gate) !== null;
|
|
397
|
+
} catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
if (!match) return; // none of these gates runs on this event — allow
|
|
402
|
+
|
|
403
|
+
await runGateAsHook(match, { ...deps, payload });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function runGateAsHook(gate, deps = {}) {
|
|
407
|
+
try {
|
|
408
|
+
const payload = deps.payload ?? (await readStdinJson());
|
|
409
|
+
if (!payload) return;
|
|
410
|
+
|
|
411
|
+
const engineEvent = resolveEventForGate(payload, gate);
|
|
412
|
+
if (!engineEvent) return; // not this gate's event — allow
|
|
413
|
+
|
|
414
|
+
const raw = await gate.run(engineEvent, {});
|
|
415
|
+
const decision = typeof raw === 'string' ? { decision: raw } : raw ?? { decision: 'allow' };
|
|
416
|
+
|
|
417
|
+
if (deps.audit === false) {
|
|
418
|
+
// in-process caller opt-out — no audit write at all.
|
|
419
|
+
} else if (typeof deps.audit === 'function') {
|
|
420
|
+
try {
|
|
421
|
+
await deps.audit(gate, decision, auditRepoRoot());
|
|
422
|
+
} catch {
|
|
423
|
+
/* fail-safe: the audit step must never affect the hook response */
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
try {
|
|
427
|
+
recordGuardrailAudit(gate, decision, auditRepoRoot(), deps.append ? { append: deps.append } : {});
|
|
428
|
+
} catch {
|
|
429
|
+
/* fail-safe: the audit step must never affect the hook response */
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const { exitCode, stdout, stderr } = toHookResponse(gate, engineEvent, decision);
|
|
434
|
+
if (stdout) process.stdout.write(stdout);
|
|
435
|
+
if (stderr) process.stderr.write(stderr);
|
|
436
|
+
process.exitCode = exitCode;
|
|
437
|
+
} catch {
|
|
438
|
+
// Fail-open: never strand the agent on an adapter/gate crash.
|
|
439
|
+
process.exitCode = 0;
|
|
440
|
+
}
|
|
441
|
+
}
|