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,221 @@
|
|
|
1
|
+
// scripts/claude-hooks/protected-action-intent.mjs
|
|
2
|
+
//
|
|
3
|
+
// HS-H8KI-P3TJ (epic HS-GG01-P3TJ): before ANY destructive git command can
|
|
4
|
+
// even get an ASK from git-stash-gate.mjs's runGitDestructiveGate, a written
|
|
5
|
+
// protected-action intent must exist naming the command, what it could break,
|
|
6
|
+
// and which other agents currently hold claims — measured from the claims
|
|
7
|
+
// system (the console's live peer_agents shape), never from agent memory.
|
|
8
|
+
//
|
|
9
|
+
// This module is pure read/validate — no stdout, no process.exit — so it can
|
|
10
|
+
// be unit-tested by import (protected-action-intent.test.mjs) with fixtures
|
|
11
|
+
// written to temp dirs, never the real .heyspex/. git-stash-gate.mjs wires
|
|
12
|
+
// these functions into runGitDestructiveGate.
|
|
13
|
+
//
|
|
14
|
+
// Schema (.heyspex/protected-action-intent.json):
|
|
15
|
+
// { command, expected_breakage, peers, claim_spec_id, written_at }
|
|
16
|
+
// - valid for INTENT_TTL_MS (10 minutes) from written_at
|
|
17
|
+
// - single-use: consumeIntent() renames it after a decision is reached
|
|
18
|
+
// (ask OR deny both count as "a decision" — a denied attempt must not let a
|
|
19
|
+
// later, different command ride the same stale-but-still-fresh intent file)
|
|
20
|
+
// - `peers` MUST be shaped like the claims API's live peer_agents response
|
|
21
|
+
// (vendor/heyspex-mcp/peer-presence.mjs buildPeerAgentsBlock): an array of
|
|
22
|
+
// objects, each at least { agent: string, session: string, spec_ids: array }
|
|
23
|
+
// (extra fields — stage, last_active, files_touched, dirs, dirs_more — are
|
|
24
|
+
// allowed and passed through untouched, never required).
|
|
25
|
+
// - `command` must match, verbatim (trimmed), the command actually being
|
|
26
|
+
// gated — an intent written for one command is not license for a different
|
|
27
|
+
// one, even a same-verb one.
|
|
28
|
+
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
|
|
32
|
+
export const INTENT_FILE_NAME = 'protected-action-intent.json';
|
|
33
|
+
export const CONSUMED_FILE_NAME = 'protected-action-intent.consumed.json';
|
|
34
|
+
export const INTENT_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
35
|
+
export const REQUIRED_FIELDS = ['command', 'expected_breakage', 'peers', 'claim_spec_id', 'written_at'];
|
|
36
|
+
// A written_at more than this far in the future is not a legitimate intent
|
|
37
|
+
// (clock skew tolerance, not a loophole) — it can never itself be "fresh"
|
|
38
|
+
// under the TTL check since nowMs - writtenAtMs would be negative.
|
|
39
|
+
const FUTURE_SKEW_TOLERANCE_MS = 60_000;
|
|
40
|
+
|
|
41
|
+
export function intentPath(root) {
|
|
42
|
+
return path.join(root, '.heyspex', INTENT_FILE_NAME);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function consumedIntentPath(root) {
|
|
46
|
+
return path.join(root, '.heyspex', CONSUMED_FILE_NAME);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* True when `peers` is shaped like the console claims API's live peer_agents
|
|
51
|
+
* response: an array (possibly empty — "no other live sessions" is a
|
|
52
|
+
* legitimate, checked result) of objects each carrying at least
|
|
53
|
+
* `agent` (non-empty string), `session` (non-empty string), and `spec_ids`
|
|
54
|
+
* (array). Exported for tests.
|
|
55
|
+
*/
|
|
56
|
+
export function isValidPeersShape(peers) {
|
|
57
|
+
if (!Array.isArray(peers)) return false;
|
|
58
|
+
return peers.every(
|
|
59
|
+
(p) =>
|
|
60
|
+
p !== null &&
|
|
61
|
+
typeof p === 'object' &&
|
|
62
|
+
!Array.isArray(p) &&
|
|
63
|
+
typeof p.agent === 'string' &&
|
|
64
|
+
p.agent.trim().length > 0 &&
|
|
65
|
+
typeof p.session === 'string' &&
|
|
66
|
+
p.session.trim().length > 0 &&
|
|
67
|
+
Array.isArray(p.spec_ids),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate one parsed intent object's shape + freshness (does NOT check it
|
|
73
|
+
* against a specific command being run — see readAndValidateIntent for that).
|
|
74
|
+
* Returns `{ ok: true, intent }` or `{ ok: false, reason }`. Exported for tests.
|
|
75
|
+
*/
|
|
76
|
+
export function validateIntent(intent, { nowMs = Date.now() } = {}) {
|
|
77
|
+
if (!intent || typeof intent !== 'object' || Array.isArray(intent)) {
|
|
78
|
+
return { ok: false, reason: 'intent is not an object' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const missing = REQUIRED_FIELDS.filter((f) => !(f in intent));
|
|
82
|
+
if (missing.length > 0) {
|
|
83
|
+
return { ok: false, reason: `missing required field(s): ${missing.join(', ')}` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof intent.command !== 'string' || !intent.command.trim()) {
|
|
87
|
+
return { ok: false, reason: '"command" must be a non-empty string' };
|
|
88
|
+
}
|
|
89
|
+
if (typeof intent.expected_breakage !== 'string' || !intent.expected_breakage.trim()) {
|
|
90
|
+
return { ok: false, reason: '"expected_breakage" must be a non-empty string' };
|
|
91
|
+
}
|
|
92
|
+
if (typeof intent.claim_spec_id !== 'string' || !intent.claim_spec_id.trim()) {
|
|
93
|
+
return { ok: false, reason: '"claim_spec_id" must be a non-empty string' };
|
|
94
|
+
}
|
|
95
|
+
if (typeof intent.written_at !== 'string' || !intent.written_at.trim()) {
|
|
96
|
+
return { ok: false, reason: '"written_at" must be an ISO timestamp string' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const writtenAtMs = Date.parse(intent.written_at);
|
|
100
|
+
if (Number.isNaN(writtenAtMs)) {
|
|
101
|
+
return { ok: false, reason: '"written_at" is not a parseable timestamp' };
|
|
102
|
+
}
|
|
103
|
+
if (writtenAtMs > nowMs + FUTURE_SKEW_TOLERANCE_MS) {
|
|
104
|
+
return { ok: false, reason: '"written_at" is in the future' };
|
|
105
|
+
}
|
|
106
|
+
const ageMs = nowMs - writtenAtMs;
|
|
107
|
+
if (ageMs > INTENT_TTL_MS) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: `intent is stale — written ${Math.round(ageMs / 1000)}s ago, valid for ${INTENT_TTL_MS / 60_000} minutes`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!isValidPeersShape(intent.peers)) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
reason:
|
|
118
|
+
'"peers" must be an array shaped like the claims API\'s live peer_agents response ' +
|
|
119
|
+
'(each item: { agent: string, session: string, spec_ids: array, ... }) — absent, ' +
|
|
120
|
+
'not-an-array, or missing/mis-typed fields on any item are all rejected',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { ok: true, intent };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Exact-match (trimmed) comparison between the intent's recorded command and
|
|
129
|
+
* the command actually being gated. Deliberately strict — an intent written
|
|
130
|
+
* for one command is not license for a different one. Exported for tests.
|
|
131
|
+
*/
|
|
132
|
+
export function commandMatches(intentCommand, actualCommand) {
|
|
133
|
+
if (typeof intentCommand !== 'string' || typeof actualCommand !== 'string') return false;
|
|
134
|
+
return intentCommand.trim() === actualCommand.trim() && intentCommand.trim().length > 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read + fully validate the on-disk intent file against the command actually
|
|
139
|
+
* being gated. Returns `{ ok: true, intent }` or `{ ok: false, reason }`.
|
|
140
|
+
* Never throws — a missing/corrupt/unreadable file is just "no intent", with
|
|
141
|
+
* `reason` saying why.
|
|
142
|
+
*/
|
|
143
|
+
export function readAndValidateIntent({ root, command, nowMs = Date.now() }) {
|
|
144
|
+
let raw;
|
|
145
|
+
try {
|
|
146
|
+
raw = fs.readFileSync(intentPath(root), 'utf8');
|
|
147
|
+
} catch {
|
|
148
|
+
return { ok: false, reason: 'no intent file found' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let parsed;
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(raw);
|
|
154
|
+
} catch {
|
|
155
|
+
return { ok: false, reason: 'intent file is not valid JSON' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const validated = validateIntent(parsed, { nowMs });
|
|
159
|
+
if (!validated.ok) return validated;
|
|
160
|
+
|
|
161
|
+
if (!commandMatches(validated.intent.command, command)) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
reason: `intent's "command" does not match the command being run (intent names: "${validated.intent.command}")`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { ok: true, intent: validated.intent };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Single-use: rename the intent file after a decision is reached (ask OR
|
|
173
|
+
* deny) so it can never be read again for a subsequent gated command.
|
|
174
|
+
* Best-effort — a rename failure never blocks the gate's own decision (the
|
|
175
|
+
* decision has already been made by the time this runs); returns a boolean
|
|
176
|
+
* so callers/tests can observe it without it ever throwing.
|
|
177
|
+
*/
|
|
178
|
+
export function consumeIntent(root) {
|
|
179
|
+
try {
|
|
180
|
+
fs.renameSync(intentPath(root), consumedIntentPath(root));
|
|
181
|
+
return true;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Denial message for AC1 — names the intent file path and every required field. */
|
|
188
|
+
export function buildNoIntentDenyMessage({ root, reason }) {
|
|
189
|
+
return (
|
|
190
|
+
`DENIED — no valid protected-action intent (${reason}). This git command is a PROTECTED action ` +
|
|
191
|
+
`(protected-shared-actions-gate) and, per HS-H8KI, cannot even be ASKED about without a fresh, ` +
|
|
192
|
+
`written breakage assessment. Write ${intentPath(root)} first, with these required fields: ` +
|
|
193
|
+
`${REQUIRED_FIELDS.join(', ')}. "peers" must be the LIVE peer_agents result from the claims API ` +
|
|
194
|
+
`(the same shape the MCP tools print as PEER AGENTS — array of { agent, session, spec_ids, ... }), ` +
|
|
195
|
+
`never recalled from memory. "written_at" is an ISO timestamp; the intent is valid for ` +
|
|
196
|
+
`${INTENT_TTL_MS / 60_000} minutes and single-use. "command" must exactly match the command you ` +
|
|
197
|
+
`are about to run.`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Ask message for AC2 — surfaces the expected_breakage text and the peer list from the intent. */
|
|
202
|
+
export function buildIntentAskMessage({ intent, baseReason }) {
|
|
203
|
+
return (
|
|
204
|
+
`${baseReason} A written protected-action intent is on file (HS-H8KI): expected breakage — ` +
|
|
205
|
+
`"${intent.expected_breakage}". Peers at claim time (from the claims API, claim_spec_id ` +
|
|
206
|
+
`${intent.claim_spec_id}): ${JSON.stringify(intent.peers)}. Approving IS the permission.`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Deny message for AC3 — cites the orchestrator's sub-agent lock. */
|
|
211
|
+
export function buildLockDenyMessage(lock) {
|
|
212
|
+
const who = lock && typeof lock.setBy === 'string' && lock.setBy.trim() ? ` by ${lock.setBy}` : '';
|
|
213
|
+
const reason = lock && typeof lock.reason === 'string' && lock.reason.trim() ? ` — ${lock.reason}` : '';
|
|
214
|
+
return (
|
|
215
|
+
`DENIED — a sub-agent hard lock is set on this session${who}${reason} ` +
|
|
216
|
+
`(scripts/lib/subagent-git-lock.mjs, orchestrator rule: destructive git is barred for the ` +
|
|
217
|
+
`duration of delegated sub-agent work). No intent file can override an active lock — deny is ` +
|
|
218
|
+
`stronger than ask. The orchestrator clears the lock (clearSubagentLock()) when its delegated ` +
|
|
219
|
+
`work window ends.`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// research-method-guard.mjs — PreToolUse hook that structurally blocks the
|
|
3
|
+
// built-in Claude Code `deep-research-quick|medium|thorough` skills/workflows
|
|
4
|
+
// from firing in this repo. HeySpex's own research method is `/hs-res`
|
|
5
|
+
// (dev-factory/spexcrawl/{spexcrawl-cli,firecrawl-cli,synthesize-cli}.mjs
|
|
6
|
+
// — SpexCrawl via the Companion + Firecrawl-as-permission-only-fallback for
|
|
7
|
+
// gather, DeepSeek for synthesis). Sonnet is a fallback that requires the
|
|
8
|
+
// user's explicit per-run permission granted directly in the current chat,
|
|
9
|
+
// never a silent default ([[research-routing-hard-rule-sonnet]]).
|
|
10
|
+
//
|
|
11
|
+
// The rule already existed TWICE in prose — `.commandcode/skills/hs-res/
|
|
12
|
+
// SKILL.md` "## Hard prohibitions" and the research-routing-hard-rule-sonnet
|
|
13
|
+
// memory decision — and a Claude Code session still called
|
|
14
|
+
// `Skill({ skill: "deep-research-medium" })` → `Workflow(...)` under time
|
|
15
|
+
// pressure. Prose alone does not stop a wrong tool call; this hook does.
|
|
16
|
+
//
|
|
17
|
+
// What is blocked:
|
|
18
|
+
// - `Skill` calls whose `skill` input is EXACTLY one of deep-research-quick /
|
|
19
|
+
// deep-research-medium / deep-research-thorough.
|
|
20
|
+
// - `Workflow` calls whose `name` input is one of those three strings, OR
|
|
21
|
+
// whose `scriptPath` input CONTAINS one of those three strings as a
|
|
22
|
+
// substring (catches `.claude/workflows/deep-research-thorough.js` etc,
|
|
23
|
+
// any launch directory prefix).
|
|
24
|
+
// What is allowed: everything else — `Skill(hs-res)`, any other skill name,
|
|
25
|
+
// any Workflow launch that doesn't name one of the three forbidden
|
|
26
|
+
// profiles, and every non-Skill/Workflow tool.
|
|
27
|
+
//
|
|
28
|
+
// This guard is DELIBERATELY narrower and stricter than the pre-existing
|
|
29
|
+
// deep-research-cost-gate.mjs (same `Skill|Workflow` matcher): that gate
|
|
30
|
+
// treats a deep-research launch as costly-but-permittable and only ASKs for
|
|
31
|
+
// approval (plus a grounding check) — it never actually stops the built-in
|
|
32
|
+
// profiles from running once approved. The rule in hs-res/SKILL.md and
|
|
33
|
+
// research-routing-hard-rule-sonnet is "never fall back to these", not "ask
|
|
34
|
+
// first" — so this hook denies outright, no ask, no cost table.
|
|
35
|
+
//
|
|
36
|
+
// Env: HEYSPEX_RESEARCH_GUARD_OFF=1 kill switch — never blocks. An ENV VAR,
|
|
37
|
+
// not a file, mirroring env-vault-guard's HEYSPEX_ENV_VAULT_GUARD_OFF — a
|
|
38
|
+
// file an agent can write is a disarm chain.
|
|
39
|
+
// Stdin: Claude Code hook JSON ({tool_name, tool_input, ...}).
|
|
40
|
+
// Exit 0 = allow. Exit 2 = block (stderr surfaces back to the model) — same
|
|
41
|
+
// mechanism as env-vault-guard.mjs.
|
|
42
|
+
// FAIL-SAFE: any internal error → exit 0 (house style: never strand the agent).
|
|
43
|
+
//
|
|
44
|
+
// REGISTERED: .claude/settings.json PreToolUse, matcher `Skill|Workflow`,
|
|
45
|
+
// routed through _run.mjs, and added to _run.mjs's ALWAYS_ON set (mirroring
|
|
46
|
+
// env-vault-guard) — a research-routing rule that can be silenced by an
|
|
47
|
+
// agent-writable `.heyspex/mode.json` flag would reopen exactly the bypass
|
|
48
|
+
// this hook exists to close. Registered LIVE (not owner-gated/inert): the
|
|
49
|
+
// user explicitly asked for this fix to prevent a recurrence of the incident
|
|
50
|
+
// above, so shipping it inert would not satisfy the request.
|
|
51
|
+
//
|
|
52
|
+
// Self-test: `node scripts/claude-hooks/research-method-guard.mjs --self-test`
|
|
53
|
+
|
|
54
|
+
import { pathToFileURL } from "node:url";
|
|
55
|
+
|
|
56
|
+
// Order matters for the scriptPath substring scan below (first match wins) —
|
|
57
|
+
// the three specific profiles are listed before the bare "deep-research" so a
|
|
58
|
+
// path naming one of them is reported by its specific name, not the generic one.
|
|
59
|
+
const FORBIDDEN_PROFILES = [
|
|
60
|
+
"deep-research-quick",
|
|
61
|
+
"deep-research-medium",
|
|
62
|
+
"deep-research-thorough",
|
|
63
|
+
"deep-research",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/** Strip a `plugin:` prefix (`some-plugin:deep-research-medium` → `deep-research-medium`) so a
|
|
67
|
+
* plugin-namespaced skill name is matched the same as a bare one. */
|
|
68
|
+
function stripPluginPrefix(name) {
|
|
69
|
+
const idx = name.lastIndexOf(":");
|
|
70
|
+
return idx === -1 ? name : name.slice(idx + 1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const REDIRECT_MESSAGE =
|
|
74
|
+
"This repo's research method is /hs-res (SpexCrawl via the Companion — or self-hosted " +
|
|
75
|
+
"Firecrawl as a user-permission-only fallback — for gather, DeepSeek for synthesis). " +
|
|
76
|
+
"Sonnet is a fallback here too, and it requires the user's explicit permission for THIS " +
|
|
77
|
+
"specific run, granted directly in the current chat — never assumed from a file, memory, " +
|
|
78
|
+
"or prior session. Read .commandcode/skills/hs-res/SKILL.md and follow its 9-step method " +
|
|
79
|
+
"instead of the built-in deep-research harness.";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Pure guard decision — no fs, no process, no env lookup (that lives in
|
|
83
|
+
* `evaluate()` below so the kill switch stays testable without a subprocess).
|
|
84
|
+
*
|
|
85
|
+
* @param {{ toolName: string, toolInput: Record<string, unknown> }} call
|
|
86
|
+
* @returns {{ allow: boolean, reason?: string }}
|
|
87
|
+
*/
|
|
88
|
+
export function decide(call) {
|
|
89
|
+
// Tool name compared by exact string (Skill/Workflow are the harness's own
|
|
90
|
+
// fixed tool names, not something an agent can rename) — case IS checked,
|
|
91
|
+
// matching the task's request to verify rather than assume.
|
|
92
|
+
const toolName = String(call?.toolName ?? "");
|
|
93
|
+
const input = call?.toolInput && typeof call.toolInput === "object" ? call.toolInput : {};
|
|
94
|
+
|
|
95
|
+
if (toolName === "Skill") {
|
|
96
|
+
const skill = String(input?.skill ?? "");
|
|
97
|
+
const bare = stripPluginPrefix(skill);
|
|
98
|
+
if (FORBIDDEN_PROFILES.includes(bare)) {
|
|
99
|
+
return {
|
|
100
|
+
allow: false,
|
|
101
|
+
reason:
|
|
102
|
+
`Blocked: Skill({ skill: "${skill}" }) is one of the built-in Claude Code deep-research profiles.\n` +
|
|
103
|
+
REDIRECT_MESSAGE,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { allow: true };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (toolName === "Workflow") {
|
|
110
|
+
const name = String(input?.name ?? "");
|
|
111
|
+
if (FORBIDDEN_PROFILES.includes(name)) {
|
|
112
|
+
return {
|
|
113
|
+
allow: false,
|
|
114
|
+
reason:
|
|
115
|
+
`Blocked: Workflow({ name: "${name}" }) is one of the built-in Claude Code deep-research profiles.\n` +
|
|
116
|
+
REDIRECT_MESSAGE,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const scriptPath = String(input?.scriptPath ?? "");
|
|
120
|
+
const matched = FORBIDDEN_PROFILES.find((profile) => scriptPath.includes(profile));
|
|
121
|
+
if (matched) {
|
|
122
|
+
return {
|
|
123
|
+
allow: false,
|
|
124
|
+
reason:
|
|
125
|
+
`Blocked: Workflow({ scriptPath: "${scriptPath}" }) targets the built-in deep-research profile "${matched}".\n` +
|
|
126
|
+
REDIRECT_MESSAGE,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { allow: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { allow: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `decide()` plus the kill switch, so the switch is covered by the same
|
|
137
|
+
* fixture table as the guard logic instead of needing a subprocess spawn.
|
|
138
|
+
* @param {{ tool_name?: string, tool_input?: Record<string, unknown> }} payload
|
|
139
|
+
* @param {NodeJS.ProcessEnv} env
|
|
140
|
+
*/
|
|
141
|
+
export function evaluate(payload, env) {
|
|
142
|
+
if ((env ?? {}).HEYSPEX_RESEARCH_GUARD_OFF === "1") return { allow: true };
|
|
143
|
+
return decide({ toolName: payload?.tool_name, toolInput: payload?.tool_input ?? {} });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
async function readStdinJson() {
|
|
149
|
+
if (process.stdin.isTTY) return null;
|
|
150
|
+
let buf = "";
|
|
151
|
+
process.stdin.setEncoding("utf8");
|
|
152
|
+
for await (const chunk of process.stdin) buf += chunk;
|
|
153
|
+
if (!buf.trim()) return null;
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(buf);
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const SELF_TEST_CASES = [
|
|
162
|
+
// --- Skill: forbidden profiles → block --------------------------------
|
|
163
|
+
{
|
|
164
|
+
tag: "skill-block",
|
|
165
|
+
name: 'Skill(skill: "deep-research-quick") → block',
|
|
166
|
+
call: { toolName: "Skill", toolInput: { skill: "deep-research-quick" } },
|
|
167
|
+
want: false,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
tag: "skill-block",
|
|
171
|
+
name: 'Skill(skill: "deep-research-medium") → block',
|
|
172
|
+
call: { toolName: "Skill", toolInput: { skill: "deep-research-medium" } },
|
|
173
|
+
want: false,
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
tag: "skill-block",
|
|
177
|
+
name: 'Skill(skill: "deep-research-thorough") → block',
|
|
178
|
+
call: { toolName: "Skill", toolInput: { skill: "deep-research-thorough" } },
|
|
179
|
+
want: false,
|
|
180
|
+
},
|
|
181
|
+
// --- Workflow name: forbidden profiles → block -------------------------
|
|
182
|
+
{
|
|
183
|
+
tag: "workflow-name-block",
|
|
184
|
+
name: 'Workflow(name: "deep-research-quick") → block',
|
|
185
|
+
call: { toolName: "Workflow", toolInput: { name: "deep-research-quick" } },
|
|
186
|
+
want: false,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
tag: "workflow-name-block",
|
|
190
|
+
name: 'Workflow(name: "deep-research-medium") → block',
|
|
191
|
+
call: { toolName: "Workflow", toolInput: { name: "deep-research-medium" } },
|
|
192
|
+
want: false,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
tag: "workflow-name-block",
|
|
196
|
+
name: 'Workflow(name: "deep-research-thorough") → block',
|
|
197
|
+
call: { toolName: "Workflow", toolInput: { name: "deep-research-thorough" } },
|
|
198
|
+
want: false,
|
|
199
|
+
},
|
|
200
|
+
// --- Workflow scriptPath: forbidden profiles as substring → block ------
|
|
201
|
+
{
|
|
202
|
+
tag: "workflow-scriptpath-block",
|
|
203
|
+
name: "Workflow(scriptPath: .../deep-research-quick.js) → block",
|
|
204
|
+
call: {
|
|
205
|
+
toolName: "Workflow",
|
|
206
|
+
toolInput: { scriptPath: ".claude/workflows/deep-research-quick.js", args: "q" },
|
|
207
|
+
},
|
|
208
|
+
want: false,
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
tag: "workflow-scriptpath-block",
|
|
212
|
+
name: "Workflow(scriptPath: .../deep-research-medium.js) → block",
|
|
213
|
+
call: {
|
|
214
|
+
toolName: "Workflow",
|
|
215
|
+
toolInput: { scriptPath: ".claude/workflows/deep-research-medium.js", args: "q" },
|
|
216
|
+
},
|
|
217
|
+
want: false,
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
tag: "workflow-scriptpath-block",
|
|
221
|
+
name: "Workflow(scriptPath: .../deep-research-thorough.js) → block",
|
|
222
|
+
call: {
|
|
223
|
+
toolName: "Workflow",
|
|
224
|
+
toolInput: { scriptPath: ".claude/workflows/deep-research-thorough.js", args: "q" },
|
|
225
|
+
},
|
|
226
|
+
want: false,
|
|
227
|
+
},
|
|
228
|
+
// --- Bare "deep-research" and plugin-prefixed names → block ------------
|
|
229
|
+
{
|
|
230
|
+
tag: "skill-block",
|
|
231
|
+
name: 'Skill(skill: "deep-research") (bare profile) → block',
|
|
232
|
+
call: { toolName: "Skill", toolInput: { skill: "deep-research" } },
|
|
233
|
+
want: false,
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
tag: "skill-block",
|
|
237
|
+
name: 'Skill(skill: "some-plugin:deep-research-medium") (plugin-prefixed) → block',
|
|
238
|
+
call: { toolName: "Skill", toolInput: { skill: "some-plugin:deep-research-medium" } },
|
|
239
|
+
want: false,
|
|
240
|
+
},
|
|
241
|
+
// --- Negative controls: the real /hs-res path and unrelated calls ------
|
|
242
|
+
{
|
|
243
|
+
tag: "allow",
|
|
244
|
+
name: "Skill(skill: hs-res) → allow (the real method)",
|
|
245
|
+
call: { toolName: "Skill", toolInput: { skill: "hs-res" } },
|
|
246
|
+
want: true,
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
tag: "allow",
|
|
250
|
+
name: "Skill with an unrelated skill name → allow",
|
|
251
|
+
call: { toolName: "Skill", toolInput: { skill: "prototype" } },
|
|
252
|
+
want: true,
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
tag: "allow",
|
|
256
|
+
name: "Workflow with an unrelated name → allow",
|
|
257
|
+
call: { toolName: "Workflow", toolInput: { name: "some-other-workflow" } },
|
|
258
|
+
want: true,
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
tag: "allow",
|
|
262
|
+
name: "Workflow with an unrelated scriptPath → allow",
|
|
263
|
+
call: {
|
|
264
|
+
toolName: "Workflow",
|
|
265
|
+
toolInput: { scriptPath: ".claude/workflows/some-other-workflow.js" },
|
|
266
|
+
},
|
|
267
|
+
want: true,
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
tag: "allow",
|
|
271
|
+
name: "Workflow with neither name nor scriptPath → allow (nothing to match)",
|
|
272
|
+
call: { toolName: "Workflow", toolInput: { args: "q" } },
|
|
273
|
+
want: true,
|
|
274
|
+
},
|
|
275
|
+
// --- Unrelated tools pass through untouched -----------------------------
|
|
276
|
+
{
|
|
277
|
+
tag: "allow-unrelated-tool",
|
|
278
|
+
name: "Read tool → allow",
|
|
279
|
+
call: { toolName: "Read", toolInput: { file_path: "C:/dev-projects/workflow-protocol/README.md" } },
|
|
280
|
+
want: true,
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
tag: "allow-unrelated-tool",
|
|
284
|
+
name: "Bash tool → allow",
|
|
285
|
+
call: { toolName: "Bash", toolInput: { command: "npm test" } },
|
|
286
|
+
want: true,
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
tag: "allow-unrelated-tool",
|
|
290
|
+
name: "Edit tool → allow",
|
|
291
|
+
call: { toolName: "Edit", toolInput: { file_path: "foo.mjs", old_string: "a", new_string: "b" } },
|
|
292
|
+
want: true,
|
|
293
|
+
},
|
|
294
|
+
// --- Case sensitivity is deliberate: a differently-cased name is NOT a
|
|
295
|
+
// known forbidden profile string, so it is not matched here. ------------
|
|
296
|
+
{
|
|
297
|
+
tag: "case-sensitivity",
|
|
298
|
+
name: 'Skill(skill: "Deep-Research-Medium") (different case) → allow (exact-match only)',
|
|
299
|
+
call: { toolName: "Skill", toolInput: { skill: "Deep-Research-Medium" } },
|
|
300
|
+
want: true,
|
|
301
|
+
},
|
|
302
|
+
// --- Malformed / empty input → fail-safe allow --------------------------
|
|
303
|
+
{
|
|
304
|
+
tag: "fail-safe",
|
|
305
|
+
name: "Skill with no skill field → allow",
|
|
306
|
+
call: { toolName: "Skill", toolInput: {} },
|
|
307
|
+
want: true,
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
tag: "fail-safe",
|
|
311
|
+
name: "missing toolInput entirely → allow",
|
|
312
|
+
call: { toolName: "Skill" },
|
|
313
|
+
want: true,
|
|
314
|
+
},
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
// Kill-switch cases run through `evaluate()` (payload + env), not `decide()`.
|
|
318
|
+
const KILL_SWITCH_CASES = [
|
|
319
|
+
{
|
|
320
|
+
name: "kill switch ON: Skill(deep-research-medium) → allow (HEYSPEX_RESEARCH_GUARD_OFF=1)",
|
|
321
|
+
payload: { tool_name: "Skill", tool_input: { skill: "deep-research-medium" } },
|
|
322
|
+
env: { HEYSPEX_RESEARCH_GUARD_OFF: "1" },
|
|
323
|
+
want: true,
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
name: "kill switch OFF: Skill(deep-research-medium) → block (no env var)",
|
|
327
|
+
payload: { tool_name: "Skill", tool_input: { skill: "deep-research-medium" } },
|
|
328
|
+
env: {},
|
|
329
|
+
want: false,
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: 'kill switch value "0" does NOT disarm → block',
|
|
333
|
+
payload: { tool_name: "Skill", tool_input: { skill: "deep-research-medium" } },
|
|
334
|
+
env: { HEYSPEX_RESEARCH_GUARD_OFF: "0" },
|
|
335
|
+
want: false,
|
|
336
|
+
},
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
function selfTest() {
|
|
340
|
+
let failed = 0;
|
|
341
|
+
let total = 0;
|
|
342
|
+
|
|
343
|
+
for (const c of SELF_TEST_CASES) {
|
|
344
|
+
total += 1;
|
|
345
|
+
const got = decide(c.call);
|
|
346
|
+
const ok = got.allow === c.want;
|
|
347
|
+
if (!ok) failed += 1;
|
|
348
|
+
process.stdout.write(
|
|
349
|
+
`${ok ? "ok " : "FAIL"} [${c.tag}] ${c.name}` +
|
|
350
|
+
`${ok ? "" : ` → got allow=${got.allow} want=${c.want}`}\n`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
for (const c of KILL_SWITCH_CASES) {
|
|
355
|
+
total += 1;
|
|
356
|
+
const got = evaluate(c.payload, c.env);
|
|
357
|
+
const ok = got.allow === c.want;
|
|
358
|
+
if (!ok) failed += 1;
|
|
359
|
+
process.stdout.write(
|
|
360
|
+
`${ok ? "ok " : "FAIL"} [kill-switch] ${c.name}` +
|
|
361
|
+
`${ok ? "" : ` → got allow=${got.allow} want=${c.want}`}\n`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
process.stdout.write(
|
|
366
|
+
failed === 0
|
|
367
|
+
? `\nresearch-method-guard self-test: ${total} passed\n`
|
|
368
|
+
: `\n${failed}/${total} FAILED\n`,
|
|
369
|
+
);
|
|
370
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function main() {
|
|
374
|
+
if (process.argv.includes("--self-test")) return selfTest();
|
|
375
|
+
|
|
376
|
+
const payload = await readStdinJson();
|
|
377
|
+
if (!payload) return;
|
|
378
|
+
if (payload.hook_event_name && payload.hook_event_name !== "PreToolUse") return;
|
|
379
|
+
|
|
380
|
+
const verdict = evaluate(payload, process.env);
|
|
381
|
+
if (!verdict.allow) {
|
|
382
|
+
process.stderr.write(`\n[HeySpex research-method guard]\n${verdict.reason}\n`);
|
|
383
|
+
process.exit(2);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Only run the hook entrypoint (including its stdin read) when this file is
|
|
388
|
+
// executed directly — mirrors env-vault-guard.mjs's guard so importing this
|
|
389
|
+
// module (e.g. from a future regression test) never blocks on stdin.
|
|
390
|
+
const isDirectExecution =
|
|
391
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
392
|
+
|
|
393
|
+
if (isDirectExecution) {
|
|
394
|
+
main().catch(() => {
|
|
395
|
+
// Fail-open: never block legitimate work on a hook crash.
|
|
396
|
+
process.exit(0);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GENERATED by scripts/generate-hook-shells.mjs (HS-IQBF) — do not hand-edit.
|
|
3
|
+
// This file only wires Claude Code's hook stdin/stdout contract to the
|
|
4
|
+
// spexharness engine port of this gate. To change what this gate DECIDES,
|
|
5
|
+
// edit spexharness/src/gates/research-protocol-gate.mjs. To change the SHAPE of this shell,
|
|
6
|
+
// edit the generator, then rerun: node scripts/generate-hook-shells.mjs
|
|
7
|
+
|
|
8
|
+
import { runGateAsHook } from '../../spexharness/src/adapters/claude-code.mjs';
|
|
9
|
+
import gate from '../../spexharness/src/gates/research-protocol-gate.mjs';
|
|
10
|
+
|
|
11
|
+
await runGateAsHook(gate);
|