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,361 @@
|
|
|
1
|
+
// spexharness/src/gates/git-stash-gate.decide.mjs
|
|
2
|
+
//
|
|
3
|
+
// Pure classifier functions relocated from scripts/claude-hooks/git-stash-gate.mjs
|
|
4
|
+
// (HS-IQBF, per PORTING.md's "move the pure decision logic into the gate
|
|
5
|
+
// module itself or a sibling pure module" — the original hook now delegates
|
|
6
|
+
// to this file too, so ./git-stash-gate.mjs (the gate) is the only place this
|
|
7
|
+
// logic lives; nothing here is a second copy that can drift). No stdin/
|
|
8
|
+
// stdout/process.exit anywhere in this file. See ./git-stash-gate.mjs for the
|
|
9
|
+
// gate's own orchestration (worktree bypass, sub-agent lock, protected-
|
|
10
|
+
// action-intent) built on top of these primitives.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
// `isWorktreePaths` moved out of scripts/claude-hooks/dev-server-task-reminder.mjs
|
|
18
|
+
// into the shared ../dev-server-control.mjs when that hook became a thin shim
|
|
19
|
+
// over its own engine gate (wave 6 of [[HS-AZ61-NYPR]]). Same function, same
|
|
20
|
+
// behavior — only the import path moved.
|
|
21
|
+
import { isWorktreePaths } from '../dev-server-control.mjs';
|
|
22
|
+
import { checkSubagentLock } from '../../../scripts/lib/subagent-git-lock.mjs';
|
|
23
|
+
import {
|
|
24
|
+
readAndValidateIntent,
|
|
25
|
+
consumeIntent,
|
|
26
|
+
buildNoIntentDenyMessage,
|
|
27
|
+
buildIntentAskMessage,
|
|
28
|
+
buildLockDenyMessage,
|
|
29
|
+
} from '../../../scripts/claude-hooks/protected-action-intent.mjs';
|
|
30
|
+
|
|
31
|
+
/** Shell out to git to answer "is this cwd a linked worktree (not the main checkout)?" */
|
|
32
|
+
export function detectWorktreeIsolation(cwd = process.cwd()) {
|
|
33
|
+
try {
|
|
34
|
+
const dir = spawnSync('git', ['rev-parse', '--git-dir'], { cwd, encoding: 'utf8', timeout: 3000 });
|
|
35
|
+
const common = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8', timeout: 3000 });
|
|
36
|
+
if (dir.status !== 0 || common.status !== 0 || !dir.stdout?.trim() || !common.stdout?.trim()) return false;
|
|
37
|
+
return isWorktreePaths(path.resolve(cwd, dir.stdout.trim()), path.resolve(cwd, common.stdout.trim()));
|
|
38
|
+
} catch {
|
|
39
|
+
return false; // undetectable → stay gated (fail toward protection, not bypass)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Read-only stash subcommands — inspecting a stash never discards anything.
|
|
44
|
+
const READ_ONLY_SUBCOMMANDS = new Set(['list', 'show']);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Find every `git stash <subcommand>` invocation in a (possibly compound)
|
|
48
|
+
* shell command, returning the lowercased subcommand for each (bare `git
|
|
49
|
+
* stash` with no subcommand normalizes to 'push', matching git's own default).
|
|
50
|
+
* Exported for tests.
|
|
51
|
+
*/
|
|
52
|
+
export function stashInvocations(command) {
|
|
53
|
+
const invocations = [];
|
|
54
|
+
const re = /\bgit\s+stash\b(?:\s+([A-Za-z-]+))?/gi;
|
|
55
|
+
let m;
|
|
56
|
+
while ((m = re.exec(command))) {
|
|
57
|
+
invocations.push((m[1] || 'push').toLowerCase());
|
|
58
|
+
}
|
|
59
|
+
return invocations;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Classify a tool call's git-stash content:
|
|
64
|
+
* null — no `git stash` invocation at all (not applicable)
|
|
65
|
+
* 'read-only' — every invocation present is `list`/`show` (pass silently)
|
|
66
|
+
* 'gated' — at least one invocation is a real stash action (ask, always)
|
|
67
|
+
* Exported for tests.
|
|
68
|
+
*/
|
|
69
|
+
export function classifyGitStash(toolName, toolInput) {
|
|
70
|
+
if (toolName !== 'Bash' && toolName !== 'PowerShell') return null;
|
|
71
|
+
const command = toolInput?.command;
|
|
72
|
+
if (typeof command !== 'string' || !command.trim()) return null;
|
|
73
|
+
const invocations = stashInvocations(command);
|
|
74
|
+
if (invocations.length === 0) return null;
|
|
75
|
+
const allReadOnly = invocations.every((sub) => READ_ONLY_SUBCOMMANDS.has(sub));
|
|
76
|
+
return allReadOnly ? 'read-only' : 'gated';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Generalized destructive-verb gate (HS-IXEY) ─────────────────────────────
|
|
80
|
+
// Every rule here mirrors stashInvocations/classifyGitStash's technique: a
|
|
81
|
+
// whole-command regex scan (not anchored to the start of the string), so a
|
|
82
|
+
// `cd X && ` chain never hides the verb. `segment` is the raw text following
|
|
83
|
+
// `git <verb>` up to the next shell control operator (&&, ||, ;, |, newline)
|
|
84
|
+
// or end of string — bounded the same way, so a LATER command in the same
|
|
85
|
+
// chain never leaks into this verb's own flag check.
|
|
86
|
+
const VERB_RULES = [
|
|
87
|
+
{
|
|
88
|
+
verb: 'reset',
|
|
89
|
+
pattern: 'reset',
|
|
90
|
+
// Scope: only --hard/--merge discard work; plain/--mixed/--soft reset does not.
|
|
91
|
+
classify: (seg) => (/(^|\s)--hard\b/.test(seg) || /(^|\s)--merge\b/.test(seg) ? 'gated' : null),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
verb: 'checkout',
|
|
95
|
+
pattern: 'checkout',
|
|
96
|
+
classify: (seg) => {
|
|
97
|
+
if (/(^|\s)--\s+\S/.test(seg)) return 'gated'; // `checkout -- <path>` (incl. `checkout <ref> -- <path>`)
|
|
98
|
+
if (/^\s*\.(\s|$)/.test(seg)) return 'gated'; // bare `checkout .`
|
|
99
|
+
return null; // plain branch switch (`checkout <branch>`) is out of scope
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
verb: 'clean',
|
|
104
|
+
pattern: 'clean',
|
|
105
|
+
classify: (seg) => (/(^|\s)-[a-zA-Z]*f[a-zA-Z]*(\s|$)/.test(seg) || /--force\b/.test(seg) ? 'gated' : null),
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
verb: 'restore',
|
|
109
|
+
pattern: 'restore',
|
|
110
|
+
// Both the worktree form (default) and --staged rewrite state — always gated.
|
|
111
|
+
classify: () => 'gated',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
verb: 'rebase',
|
|
115
|
+
pattern: 'rebase',
|
|
116
|
+
// Rewrites history on a shared branch unconditionally — always gated.
|
|
117
|
+
classify: () => 'gated',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
verb: 'push',
|
|
121
|
+
pattern: 'push',
|
|
122
|
+
classify: (seg) => (/--force(-with-lease)?\b/.test(seg) || /(^|\s)-f(\s|$)/.test(seg) ? 'gated' : null),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
verb: 'branch',
|
|
126
|
+
pattern: 'branch',
|
|
127
|
+
classify: (seg) =>
|
|
128
|
+
/(^|\s)-D\b/.test(seg) || (/--delete\b/.test(seg) && /--force\b/.test(seg)) ? 'gated' : null,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
verb: 'commit',
|
|
132
|
+
pattern: 'commit',
|
|
133
|
+
classify: (seg) => (/--amend\b/.test(seg) ? 'gated' : null),
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
const VERB_RULES_BY_NAME = new Map(VERB_RULES.map((rule) => [rule.verb, rule]));
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Find every non-stash destructive-candidate invocation in a (possibly
|
|
141
|
+
* compound) shell command. Returns [{ verb, segment }] — `segment` is the raw
|
|
142
|
+
* text after `git <verb>` up to the next shell control operator or end of
|
|
143
|
+
* string. Exported for tests.
|
|
144
|
+
*/
|
|
145
|
+
export function destructiveVerbInvocations(command) {
|
|
146
|
+
const invocations = [];
|
|
147
|
+
for (const rule of VERB_RULES) {
|
|
148
|
+
const re = new RegExp(`\\bgit\\s+${rule.pattern}\\b([^;&|\\n]*)`, 'gi');
|
|
149
|
+
let m;
|
|
150
|
+
while ((m = re.exec(command))) {
|
|
151
|
+
invocations.push({ verb: rule.verb, segment: m[1] || '' });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return invocations;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Classify a tool call's non-stash destructive-verb content:
|
|
159
|
+
* null — no gated invocation of any of these verbs is present
|
|
160
|
+
* array — the list of verbs that matched a gated flag/form (non-empty)
|
|
161
|
+
* Exported for tests.
|
|
162
|
+
*/
|
|
163
|
+
export function classifyGitVerbs(toolName, toolInput) {
|
|
164
|
+
if (toolName !== 'Bash' && toolName !== 'PowerShell') return null;
|
|
165
|
+
const command = toolInput?.command;
|
|
166
|
+
if (typeof command !== 'string' || !command.trim()) return null;
|
|
167
|
+
const gated = destructiveVerbInvocations(command)
|
|
168
|
+
.map(({ verb, segment }) => (VERB_RULES_BY_NAME.get(verb).classify(segment) === 'gated' ? verb : null))
|
|
169
|
+
.filter(Boolean);
|
|
170
|
+
return gated.length > 0 ? gated : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Unified classifier across stash AND every other gated verb — what the gate
|
|
175
|
+
* driver AND the test file both call.
|
|
176
|
+
* null — nothing gated or read-only present (not applicable — pass)
|
|
177
|
+
* 'read-only' — only read-only forms present (stash list/show)
|
|
178
|
+
* 'gated' — at least one gated invocation of any verb — always ask
|
|
179
|
+
* Exported for tests.
|
|
180
|
+
*/
|
|
181
|
+
export function classifyGitDestructive(toolName, toolInput) {
|
|
182
|
+
const stashKind = classifyGitStash(toolName, toolInput);
|
|
183
|
+
const gatedVerbs = classifyGitVerbs(toolName, toolInput);
|
|
184
|
+
if (stashKind === 'gated' || gatedVerbs) return 'gated';
|
|
185
|
+
if (stashKind === 'read-only') return 'read-only';
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Restored below: runGitDestructiveGate and its private support ──────────
|
|
190
|
+
// These were dropped (not carried forward) by the HS-IQBF relocation — the
|
|
191
|
+
// engine port at ./git-stash-gate.mjs deliberately reconstructs only the
|
|
192
|
+
// ORCHESTRATION using these primitives (see that file's own header) and does
|
|
193
|
+
// NOT reproduce the original hook's stdout-emitting / audit-writing side
|
|
194
|
+
// effects. scripts/claude-hooks/protected-action-intent.test.mjs still needs
|
|
195
|
+
// the original side-effecting `runGitDestructiveGate` (it asserts on
|
|
196
|
+
// captured stdout writes), so it is restored here verbatim from git history
|
|
197
|
+
// (scripts/claude-hooks/git-stash-gate.mjs pre-cutover) — same behavior, same
|
|
198
|
+
// stdout/audit side effects, no logic changes.
|
|
199
|
+
|
|
200
|
+
function repoRoot() {
|
|
201
|
+
const fromEnv = process.env.CLAUDE_PROJECT_DIR?.trim();
|
|
202
|
+
if (fromEnv) return fromEnv.replace(/[\\/]+$/, '');
|
|
203
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* NAMED PLACEHOLDER for the not-yet-built governing process
|
|
208
|
+
* (protected-shared-actions-gate.md "Future placeholder"). Deliberately
|
|
209
|
+
* inert: always returns null (no routing target), so the ask always goes to
|
|
210
|
+
* the user. Exported so a future patch (and a self-test proving it stays a
|
|
211
|
+
* no-op today) can target it directly.
|
|
212
|
+
*/
|
|
213
|
+
export function routeToGoverningProcess() {
|
|
214
|
+
return null; // NOT BUILT — see protected-shared-actions-gate.md. Ask always goes to the user.
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function auditHistory(repoRootPath, record) {
|
|
218
|
+
try {
|
|
219
|
+
const dir = path.join(repoRootPath, '.heyspex');
|
|
220
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
221
|
+
fs.appendFileSync(
|
|
222
|
+
path.join(dir, 'dev-server-history.jsonl'),
|
|
223
|
+
JSON.stringify({ timestamp: new Date().toISOString(), ...record }) + '\n',
|
|
224
|
+
);
|
|
225
|
+
} catch {
|
|
226
|
+
/* observability must never break the gate */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function emitAsk(reason) {
|
|
231
|
+
process.stdout.write(
|
|
232
|
+
JSON.stringify({
|
|
233
|
+
hookSpecificOutput: {
|
|
234
|
+
hookEventName: 'PreToolUse',
|
|
235
|
+
permissionDecision: 'ask',
|
|
236
|
+
permissionDecisionReason: reason,
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// HS-H8KI: deny is the one decision stronger than ask — used only to
|
|
243
|
+
// ESCALATE (no valid intent, or an active sub-agent lock), never to soften
|
|
244
|
+
// anything toward allow (specs/memory/decisions/protected-shared-tree-actions-assisted.md).
|
|
245
|
+
function emitDeny(reason) {
|
|
246
|
+
process.stdout.write(
|
|
247
|
+
JSON.stringify({
|
|
248
|
+
hookSpecificOutput: {
|
|
249
|
+
hookEventName: 'PreToolUse',
|
|
250
|
+
permissionDecision: 'deny',
|
|
251
|
+
permissionDecisionReason: reason,
|
|
252
|
+
},
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const ASK_REASON =
|
|
258
|
+
'git stash (any form) is a PROTECTED action on a shared working tree ' +
|
|
259
|
+
'(protected-shared-actions-gate) — it sweeps EVERY uncommitted change in the tree, not just ' +
|
|
260
|
+
'yours, and has already caused a real incident (git-stash-sweeps-parallel-agent-work: 266 ' +
|
|
261
|
+
'modified + 173 untracked files swept, then 266 left with conflict markers on pop). This ' +
|
|
262
|
+
'ALWAYS requires explicit user permission in BOTH Assisted and Autonomous front — there is no ' +
|
|
263
|
+
'fleet-queue exception (a claim only proves exclusivity over one named resource; stash\'s blast ' +
|
|
264
|
+
'radius is not scoped to one resource). Approving IS the permission. Prefer a non-destructive ' +
|
|
265
|
+
'alternative first: `git worktree add ../verify-clean <commit>` to test a clean tree, ' +
|
|
266
|
+
'`git show <commit>:<path>` to read a prior revision, or `git diff` / `git log -p` to compare ' +
|
|
267
|
+
'without touching the tree.';
|
|
268
|
+
|
|
269
|
+
const VERB_ASK_REASON =
|
|
270
|
+
'This git command is a PROTECTED action on a shared working tree (protected-shared-actions-gate) — ' +
|
|
271
|
+
'it can permanently discard uncommitted work or rewrite history other agents may be relying on (the ' +
|
|
272
|
+
'same class of action that wiped 36 files via an unguarded `git reset --hard` with no gate at all: ' +
|
|
273
|
+
'never-chain-cd-with-destructive-git-a). This ALWAYS requires explicit user permission in BOTH ' +
|
|
274
|
+
'Assisted and Autonomous front — approving IS the permission. Prefer a non-destructive alternative ' +
|
|
275
|
+
'first: `git worktree add ../verify-clean <commit>` to test a clean tree, `git show <commit>:<path>` ' +
|
|
276
|
+
'to read a prior revision, or `git diff` / `git log -p` to compare without touching the tree.';
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Run the gate for one PreToolUse payload. Returns true when this hook call
|
|
280
|
+
* was fully handled (an ask was emitted, or a read-only/bypass pass was
|
|
281
|
+
* logged) — false when nothing gated/read-only applies at all (not a
|
|
282
|
+
* destructive-git invocation), matching the runHardGate() convention in
|
|
283
|
+
* dev-server-task-reminder.mjs.
|
|
284
|
+
*
|
|
285
|
+
* HS-H8KI adds two checks BEFORE the always-ask floor, each one an
|
|
286
|
+
* escalation from ask to deny (never a softening toward allow — the locked
|
|
287
|
+
* decision protected-shared-tree-actions-assisted.md forbids that):
|
|
288
|
+
* 1. A sub-agent hard lock on this session (scripts/lib/subagent-git-lock.mjs)
|
|
289
|
+
* → deny outright, no intent can override it.
|
|
290
|
+
* 2. No fresh, command-matched, correctly-shaped protected-action intent
|
|
291
|
+
* on disk (scripts/claude-hooks/protected-action-intent.mjs) → deny
|
|
292
|
+
* with instructions naming the file path and required fields.
|
|
293
|
+
* Only once both checks pass does the gate fall through to its prior
|
|
294
|
+
* behavior: emit an ask (now enriched with the intent's expected_breakage
|
|
295
|
+
* and peer list), and — because the intent is single-use — consume it so a
|
|
296
|
+
* later, different gated command can't ride the same written assessment.
|
|
297
|
+
*/
|
|
298
|
+
export function runGitDestructiveGate(
|
|
299
|
+
toolName,
|
|
300
|
+
toolInput,
|
|
301
|
+
{ repoRootPath = repoRoot(), cwd = process.cwd(), sessionId = null } = {},
|
|
302
|
+
) {
|
|
303
|
+
const kind = classifyGitDestructive(toolName, toolInput);
|
|
304
|
+
if (!kind) return false;
|
|
305
|
+
|
|
306
|
+
if (kind === 'read-only') {
|
|
307
|
+
auditHistory(repoRootPath, { event_kind: 'git_destructive.read_only_pass', tool: toolName });
|
|
308
|
+
return true; // explicitly exempt — never a silent "not applicable", always logged
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (detectWorktreeIsolation(cwd)) {
|
|
312
|
+
auditHistory(repoRootPath, { event_kind: 'git_destructive.worktree_bypass', tool: toolName });
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// HS-H8KI check 1: an active sub-agent hard lock always wins — deny, full
|
|
317
|
+
// stop. No intent file, however fresh or well-shaped, can override it.
|
|
318
|
+
const lock = checkSubagentLock({ root: repoRootPath, sessionId });
|
|
319
|
+
if (lock) {
|
|
320
|
+
auditHistory(repoRootPath, { event_kind: 'git_destructive.lock_deny', tool: toolName, sessionId, lock });
|
|
321
|
+
emitDeny(buildLockDenyMessage(lock));
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// HS-H8KI check 2: no valid, fresh, command-matched intent → deny with
|
|
326
|
+
// exactly what to write and where.
|
|
327
|
+
const command = toolInput?.command;
|
|
328
|
+
const intentResult = readAndValidateIntent({ root: repoRootPath, command });
|
|
329
|
+
if (!intentResult.ok) {
|
|
330
|
+
auditHistory(repoRootPath, {
|
|
331
|
+
event_kind: 'git_destructive.no_intent_deny',
|
|
332
|
+
tool: toolName,
|
|
333
|
+
reason: intentResult.reason,
|
|
334
|
+
});
|
|
335
|
+
emitDeny(buildNoIntentDenyMessage({ root: repoRootPath, reason: intentResult.reason }));
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Single-use — consume now so a later gated command (even a re-run of this
|
|
340
|
+
// exact one) can't ride the same written assessment.
|
|
341
|
+
consumeIntent(repoRootPath);
|
|
342
|
+
|
|
343
|
+
// Reserved for a future governing process — always null today (see
|
|
344
|
+
// routeToGoverningProcess's doc comment). Recorded in the audit line so a
|
|
345
|
+
// later activation is visible in history, not silently different.
|
|
346
|
+
const routed = routeToGoverningProcess();
|
|
347
|
+
|
|
348
|
+
// Stash keeps its own detailed incident-specific reason text; every other
|
|
349
|
+
// gated verb gets the generic one above. The intent's breakage assessment
|
|
350
|
+
// and peer list are always appended on top (AC2).
|
|
351
|
+
const baseReason = classifyGitStash(toolName, toolInput) === 'gated' ? ASK_REASON : VERB_ASK_REASON;
|
|
352
|
+
|
|
353
|
+
auditHistory(repoRootPath, {
|
|
354
|
+
event_kind: 'git_destructive.ask',
|
|
355
|
+
tool: toolName,
|
|
356
|
+
routedTo: routed,
|
|
357
|
+
claim_spec_id: intentResult.intent.claim_spec_id,
|
|
358
|
+
});
|
|
359
|
+
emitAsk(buildIntentAskMessage({ intent: intentResult.intent, baseReason }));
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// spexharness/src/gates/git-stash-gate.mjs
|
|
2
|
+
//
|
|
3
|
+
// Engine port of scripts/claude-hooks/git-stash-gate.mjs — the PreToolUse
|
|
4
|
+
// guard over every destructive git verb on a SHARED working tree (stash,
|
|
5
|
+
// reset --hard, checkout -- <path>, clean -f, restore, rebase, push --force,
|
|
6
|
+
// branch -D, commit --amend — see the original's own header for the full
|
|
7
|
+
// incident history: HS-IXEY / HS-H8KI, epic HS-GG01-P3TJ). See ./PORTING.md
|
|
8
|
+
// for the general recipe this follows.
|
|
9
|
+
//
|
|
10
|
+
// JUDGMENT CALL — read before touching this file: the original script does
|
|
11
|
+
// NOT export a pure decision function. Its full decision (worktree bypass,
|
|
12
|
+
// sub-agent lock, protected-action-intent check, ask/deny composition) lives
|
|
13
|
+
// inside `runGitDestructiveGate`, which writes its verdict straight to
|
|
14
|
+
// standard output as a side effect of deciding. PORTING.md's step-1 default
|
|
15
|
+
// remedy is to extract a pure function INSIDE the original script — but this
|
|
16
|
+
// porting task's own brief forbids touching anything under
|
|
17
|
+
// scripts/claude-hooks/ in this pass ("shells come later"). So this module
|
|
18
|
+
// reconstructs ONLY the orchestration (the order the checks run in, and
|
|
19
|
+
// which one wins) using every decision PRIMITIVE the original already
|
|
20
|
+
// exports or keeps in a separate pure module: classifyGitDestructive /
|
|
21
|
+
// classifyGitStash / detectWorktreeIsolation (relocated to
|
|
22
|
+
// ./git-stash-gate.decide.mjs, HS-IQBF — see that module's header),
|
|
23
|
+
// checkSubagentLock (scripts/lib/subagent-git-lock.mjs), and
|
|
24
|
+
// readAndValidateIntent / consumeIntent / buildNoIntentDenyMessage /
|
|
25
|
+
// buildIntentAskMessage / buildLockDenyMessage
|
|
26
|
+
// (scripts/claude-hooks/protected-action-intent.mjs). No regex/rule/
|
|
27
|
+
// validation logic is re-derived anywhere in this file — every
|
|
28
|
+
// classification and every deny/ask message body is imported, not
|
|
29
|
+
// rewritten. The one piece of text this module owns that the original does
|
|
30
|
+
// NOT export is the base "why this is protected" sentence — the original
|
|
31
|
+
// keeps it in two private (non-exported) consts, ASK_REASON / VERB_ASK_REASON.
|
|
32
|
+
// This module carries an equivalent-substance summary
|
|
33
|
+
// (STASH_BASE_REASON / VERB_BASE_REASON below) instead of the byte-identical
|
|
34
|
+
// string. That is safe because no golden fixture exercises the ask/rewrite
|
|
35
|
+
// path for this gate (the recorded examples cover only deny-for-no-intent,
|
|
36
|
+
// allow-for-read-only, and not-applicable) — the summary is asserted for
|
|
37
|
+
// substance in this module's own test, never against a golden.
|
|
38
|
+
//
|
|
39
|
+
// Event mapping: PreToolUse (Bash|PowerShell) → before_tool, same as every
|
|
40
|
+
// other gate here.
|
|
41
|
+
// Decision mapping: not-applicable / read-only → allow; a sub-agent lock or
|
|
42
|
+
// a missing/invalid protected-action intent → deny (the original's rule may
|
|
43
|
+
// only ESCALATE toward deny, never soften toward allow — see
|
|
44
|
+
// specs/memory/decisions/protected-shared-tree-actions-assisted.md); a
|
|
45
|
+
// valid, fresh, command-matched intent → the original's `ask` outcome,
|
|
46
|
+
// carried forward as a `rewrite` envelope (`{ ask: true, reason }`) — the
|
|
47
|
+
// same mapping env-vault-guard.mjs's reference port uses for its own `ask`
|
|
48
|
+
// case, since the engine has no synchronous-approval primitive either.
|
|
49
|
+
//
|
|
50
|
+
// Env hatch (unchanged): HEYSPEX_STASH_GATE_OFF=1.
|
|
51
|
+
//
|
|
52
|
+
// This gate deliberately does NOT write the original hook's audit trail
|
|
53
|
+
// (.heyspex/dev-server-history.jsonl) — that writer is private to the
|
|
54
|
+
// original hook, and audit logging is a hook-specific side effect, not part
|
|
55
|
+
// of this module's decision contract. A future front that wants an audit
|
|
56
|
+
// line for the engine's decision can write one at the call site.
|
|
57
|
+
//
|
|
58
|
+
// `ctx` contract (all optional):
|
|
59
|
+
// ctx.env - env var overrides (falls back to process.env).
|
|
60
|
+
// ctx.cwd - the tool call's working directory, used ONLY for the
|
|
61
|
+
// worktree-isolation check (detectWorktreeIsolation).
|
|
62
|
+
// Defaults to process.cwd().
|
|
63
|
+
// ctx.repoRoot - where the protected-action-intent file and sub-agent
|
|
64
|
+
// lock directory live. Defaults to CLAUDE_PROJECT_DIR, else
|
|
65
|
+
// this repo's root — same resolution as the original's
|
|
66
|
+
// own repoRoot().
|
|
67
|
+
// ctx.sessionId - session id for the sub-agent lock lookup. Defaults to
|
|
68
|
+
// null (matches an unresolvable session in the original).
|
|
69
|
+
|
|
70
|
+
import path from 'node:path';
|
|
71
|
+
import { fileURLToPath } from 'node:url';
|
|
72
|
+
|
|
73
|
+
import {
|
|
74
|
+
classifyGitDestructive,
|
|
75
|
+
classifyGitStash,
|
|
76
|
+
detectWorktreeIsolation,
|
|
77
|
+
} from './git-stash-gate.decide.mjs';
|
|
78
|
+
import { checkSubagentLock } from '../../../scripts/lib/subagent-git-lock.mjs';
|
|
79
|
+
import {
|
|
80
|
+
readAndValidateIntent,
|
|
81
|
+
consumeIntent,
|
|
82
|
+
buildNoIntentDenyMessage,
|
|
83
|
+
buildIntentAskMessage,
|
|
84
|
+
buildLockDenyMessage,
|
|
85
|
+
} from '../../../scripts/claude-hooks/protected-action-intent.mjs';
|
|
86
|
+
|
|
87
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
88
|
+
const REPO_ROOT = path.resolve(HERE, '..', '..', '..');
|
|
89
|
+
|
|
90
|
+
// Equivalent-substance summaries of the original's private ASK_REASON /
|
|
91
|
+
// VERB_ASK_REASON consts (see the judgment-call note above) — not asserted
|
|
92
|
+
// against any golden fixture, only against this module's own test.
|
|
93
|
+
const STASH_BASE_REASON =
|
|
94
|
+
"git stash (any form) is a PROTECTED action on a shared working tree (protected-shared-actions-gate) " +
|
|
95
|
+
"— it sweeps every uncommitted change in the tree, not just the caller's own, and has already caused " +
|
|
96
|
+
'a real incident. This always requires explicit user permission — approving IS the permission.';
|
|
97
|
+
const VERB_BASE_REASON =
|
|
98
|
+
'This git command is a PROTECTED action on a shared working tree (protected-shared-actions-gate) — it ' +
|
|
99
|
+
'can permanently discard uncommitted work or rewrite history other agents may be relying on. This ' +
|
|
100
|
+
'always requires explicit user permission — approving IS the permission.';
|
|
101
|
+
|
|
102
|
+
/** The git-destructive-verbs gate's id — unique across the built-in registry. */
|
|
103
|
+
export const id = 'git-stash-gate';
|
|
104
|
+
/** Runs pre-tool, same as its PreToolUse Claude Code hook registration. */
|
|
105
|
+
export const event = 'before_tool';
|
|
106
|
+
/** Caller-defined severity label; this gate can deny, so it is "block". */
|
|
107
|
+
export const severity = 'block';
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {{ point: string, toolName?: string, toolInput?: Record<string, unknown> }} evt
|
|
111
|
+
* @param {{ cwd?: string, repoRoot?: string, sessionId?: string | null,
|
|
112
|
+
* env?: Record<string, string | undefined> }} [ctx]
|
|
113
|
+
* @returns {{ decision: 'allow'|'deny'|'rewrite', reason?: string, rewrite?: object }}
|
|
114
|
+
*/
|
|
115
|
+
export function run(evt, ctx = {}) {
|
|
116
|
+
const env = ctx.env ?? process.env;
|
|
117
|
+
if (String(env.HEYSPEX_STASH_GATE_OFF ?? '') === '1') {
|
|
118
|
+
return { decision: 'allow' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const toolName = evt?.toolName;
|
|
122
|
+
const toolInput = evt?.toolInput ?? {};
|
|
123
|
+
const kind = classifyGitDestructive(toolName, toolInput);
|
|
124
|
+
if (!kind || kind === 'read-only') {
|
|
125
|
+
return { decision: 'allow' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
129
|
+
if (detectWorktreeIsolation(cwd)) {
|
|
130
|
+
return { decision: 'allow' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const root = ctx.repoRoot ?? process.env.CLAUDE_PROJECT_DIR ?? REPO_ROOT;
|
|
134
|
+
|
|
135
|
+
// HS-H8KI check 1: an active sub-agent hard lock always wins — deny, full
|
|
136
|
+
// stop. No intent file, however fresh or well-shaped, can override it.
|
|
137
|
+
const lock = checkSubagentLock({ root, sessionId: ctx.sessionId ?? null });
|
|
138
|
+
if (lock) {
|
|
139
|
+
return { decision: 'deny', reason: buildLockDenyMessage(lock) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// HS-H8KI check 2: no valid, fresh, command-matched intent → deny with
|
|
143
|
+
// exactly what to write and where.
|
|
144
|
+
const command = toolInput?.command;
|
|
145
|
+
const intentResult = readAndValidateIntent({ root, command });
|
|
146
|
+
if (!intentResult.ok) {
|
|
147
|
+
return { decision: 'deny', reason: buildNoIntentDenyMessage({ root, reason: intentResult.reason }) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Single-use, same as the original — consume now so a later gated command
|
|
151
|
+
// (even a re-run of this exact one) can't ride the same written assessment.
|
|
152
|
+
consumeIntent(root);
|
|
153
|
+
|
|
154
|
+
const baseReason = classifyGitStash(toolName, toolInput) === 'gated' ? STASH_BASE_REASON : VERB_BASE_REASON;
|
|
155
|
+
const reason = buildIntentAskMessage({ intent: intentResult.intent, baseReason });
|
|
156
|
+
return { decision: 'rewrite', reason, rewrite: { ask: true, reason } };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default { id, event, severity, run };
|