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,215 @@
|
|
|
1
|
+
// spexharness/src/gates/enforce-vault-naming.mjs
|
|
2
|
+
//
|
|
3
|
+
// Engine port of scripts/claude-hooks/enforce-vault-naming.mjs — the
|
|
4
|
+
// PreToolUse guard that blocks file creation violating the HeySpex vault
|
|
5
|
+
// naming conventions (per-directory filename patterns + slug-only zones)
|
|
6
|
+
// BEFORE the file lands on disk. See ./PORTING.md for the recipe this
|
|
7
|
+
// follows and ./env-vault-guard.mjs for the reference port.
|
|
8
|
+
//
|
|
9
|
+
// This module does NOT reimplement the naming rule itself. The actual
|
|
10
|
+
// decision — is this relative path shaped correctly for its directory? —
|
|
11
|
+
// comes straight from the shared, already-pure loader (AC5's own words: "read
|
|
12
|
+
// their word lists and id shapes from loadRuleTable, not a second copy"):
|
|
13
|
+
// - scripts/claude-hooks/_guardrail-loader.mjs — loadGuardrails,
|
|
14
|
+
// rulesForSurface, namingViolation. namingViolation reads the
|
|
15
|
+
// `id-suffix-shape` rule's `naming` payload (compiled from
|
|
16
|
+
// vault-id-assignment-protocol.md's `enforcement:` block via
|
|
17
|
+
// spexharness/src/rule-table.mjs, imported by _guardrail-loader.mjs
|
|
18
|
+
// itself — so this module is already two layers removed from any
|
|
19
|
+
// hand-coded pattern).
|
|
20
|
+
// - vendor/heyspex-mcp/vault-resolve.mjs — getActiveConfig(), for the
|
|
21
|
+
// same optional second vault-root base the original's `vaultRootSafe()`
|
|
22
|
+
// helper resolves (specs living in a vault split out from the code
|
|
23
|
+
// repo).
|
|
24
|
+
// Both are dependency-free / no import hazard, matching enforce-session-
|
|
25
|
+
// sequence.mjs's port.
|
|
26
|
+
//
|
|
27
|
+
// JUDGMENT CALL — the original script does NOT export a pure decision
|
|
28
|
+
// function; its logic is tangled inside main()'s stdin read. Per PORTING.md
|
|
29
|
+
// step 1's "otherwise" branch this would normally move into the original
|
|
30
|
+
// script itself, but this task's Do-nots forbid touching anything under
|
|
31
|
+
// scripts/claude-hooks/. What is re-derived here is NOT the naming rule
|
|
32
|
+
// itself (that stays imported, per AC5) — it is candidate-path EXTRACTION
|
|
33
|
+
// glue that carries no rule content of its own: `extractBashTargets` (pulls
|
|
34
|
+
// file-creation targets out of common shell idioms — mv/cp/touch/install/
|
|
35
|
+
// redirects/tee), `normalizeRel` (relativizes a path against the repo root or
|
|
36
|
+
// the resolved vault root), and `formatReason` (pure string formatting of a
|
|
37
|
+
// namingViolation() descriptor) — all three copied verbatim from the
|
|
38
|
+
// original's own (unexported) helpers of the same name, so a future change
|
|
39
|
+
// to the original's extraction heuristics is the only thing that can drift
|
|
40
|
+
// this module, never the naming rule itself.
|
|
41
|
+
//
|
|
42
|
+
// Event mapping: PreToolUse (Write|Edit|MultiEdit|NotebookEdit|Bash|
|
|
43
|
+
// PowerShell) → before_tool. Edit/MultiEdit always allow (unchanged — the
|
|
44
|
+
// original never re-validates an existing file at edit time).
|
|
45
|
+
//
|
|
46
|
+
// Decision mapping: no violations found (including "not our concern" cases —
|
|
47
|
+
// unmanaged tool, path outside both repo bases, no naming rule compiled) →
|
|
48
|
+
// allow. One or more violations → deny, with the SAME joined multi-violation
|
|
49
|
+
// reason text the original wrote to stderr before exit(2).
|
|
50
|
+
//
|
|
51
|
+
// This gate has NO env-var kill switch — the original script documents none
|
|
52
|
+
// (unlike its siblings), so none is ported here. AC4 ("every environment
|
|
53
|
+
// escape hatch the original documented ... covered by a test per hatch")
|
|
54
|
+
// has nothing to cover for this gate; its own test file says so explicitly.
|
|
55
|
+
//
|
|
56
|
+
// Not ported: the original's `appendGuardrailEvent(...)` audit-log write on
|
|
57
|
+
// a block — matching every other port in this set.
|
|
58
|
+
//
|
|
59
|
+
// `ctx` contract (all optional; every field defaults to a real fs/env read):
|
|
60
|
+
// ctx.cwd - repo root to resolve candidate paths against.
|
|
61
|
+
// Defaults to CLAUDE_PROJECT_DIR, else this repo's
|
|
62
|
+
// root.
|
|
63
|
+
// ctx.getActiveConfig - override for the vault-root resolver (tests can
|
|
64
|
+
// pin a second base without touching real vault
|
|
65
|
+
// config). Defaults to the real getActiveConfig.
|
|
66
|
+
// ctx.existsSync - override for the requireNew (new-file) probe.
|
|
67
|
+
// Defaults to a real fs.existsSync.
|
|
68
|
+
|
|
69
|
+
import fs from 'node:fs';
|
|
70
|
+
import path from 'node:path';
|
|
71
|
+
import { fileURLToPath } from 'node:url';
|
|
72
|
+
|
|
73
|
+
import { loadGuardrails, rulesForSurface, namingViolation } from '../../../scripts/claude-hooks/_guardrail-loader.mjs';
|
|
74
|
+
import { getActiveConfig } from '../../../vendor/heyspex-mcp/vault-resolve.mjs';
|
|
75
|
+
|
|
76
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
77
|
+
const REPO_ROOT = path.resolve(HERE, '..', '..', '..');
|
|
78
|
+
|
|
79
|
+
// Copied verbatim from the original's own (unexported) vaultRootSafe.
|
|
80
|
+
function vaultRootSafe(getActiveConfigFn) {
|
|
81
|
+
try {
|
|
82
|
+
return getActiveConfigFn()?.repoRootAbs ?? null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null; // fail-safe: the code-repo base still applies
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Copied verbatim from the original's own (unexported) normalizeRel.
|
|
89
|
+
function normalizeRel(absOrRel, root, getActiveConfigFn) {
|
|
90
|
+
if (!absOrRel || typeof absOrRel !== 'string') return null;
|
|
91
|
+
const abs = path.isAbsolute(absOrRel) ? absOrRel : path.resolve(root, absOrRel);
|
|
92
|
+
const bases = [root];
|
|
93
|
+
const vault = vaultRootSafe(getActiveConfigFn);
|
|
94
|
+
if (vault && vault !== root) bases.push(vault);
|
|
95
|
+
for (const base of bases) {
|
|
96
|
+
const rel = path.relative(base, abs);
|
|
97
|
+
if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
|
|
98
|
+
return { rel: rel.split(path.sep).join('/'), abs }; // POSIX for matching
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null; // outside both repos — not our concern
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Copied verbatim from the original's own (unexported) formatReason.
|
|
105
|
+
function formatReason(v) {
|
|
106
|
+
const allocHint = v.kind
|
|
107
|
+
? ` IDs are auto-minted — do NOT hand-author the filename or id. File via the minter (heyspex_file_spec for agents, or POST /api/specs) so the server generates a conforming '${v.kind}' id from the canonical template.`
|
|
108
|
+
: ` Reuse the parent spec's id suffix — review docs are named HS-<parent-key>-REV-<fileKey>-<slug>.md.`;
|
|
109
|
+
const fileKeyHint = v.strictNew
|
|
110
|
+
? ` NEW files require a per-file FILE KEY (4 uppercase alphanumerics, at least one letter) between the type label and the title slug — minted by the server, never hand-picked (owner ruling 2026-07-10).`
|
|
111
|
+
: null;
|
|
112
|
+
const exampleLine = v.example ? ` Example: ${v.dir}/${v.example}` : null;
|
|
113
|
+
return [
|
|
114
|
+
`HeySpex naming violation: ${v.relPath}`,
|
|
115
|
+
` Required pattern: ${v.requiredPattern}`,
|
|
116
|
+
fileKeyHint,
|
|
117
|
+
exampleLine,
|
|
118
|
+
allocHint,
|
|
119
|
+
` See specs/protocols/vault-id-assignment-protocol.md.`,
|
|
120
|
+
]
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
.join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Copied verbatim from the original's own (unexported) extractBashTargets —
|
|
126
|
+
// a conservative shell-idiom scanner, not a naming rule.
|
|
127
|
+
function extractBashTargets(command) {
|
|
128
|
+
if (!command || typeof command !== 'string') return [];
|
|
129
|
+
const targets = new Set();
|
|
130
|
+
|
|
131
|
+
const redirectRe = /(?:>>?|\|\s*tee(?:\s+-a)?)\s+("([^"]+)"|'([^']+)'|(\S+))/g;
|
|
132
|
+
for (const m of command.matchAll(redirectRe)) {
|
|
133
|
+
targets.add(m[2] ?? m[3] ?? m[4]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const cmdRe = /\b(?:mv|cp|touch|install|rename|Move-Item|Copy-Item|New-Item)\s+([^|;&\n]+)/g;
|
|
137
|
+
for (const m of command.matchAll(cmdRe)) {
|
|
138
|
+
const args = m[1]
|
|
139
|
+
.trim()
|
|
140
|
+
.split(/\s+/)
|
|
141
|
+
.filter((a) => a && !a.startsWith('-'));
|
|
142
|
+
if (args.length === 0) continue;
|
|
143
|
+
if (/^(?:mv|cp|install|rename|Move-Item|Copy-Item)$/.test(m[0].split(/\s/)[0])) {
|
|
144
|
+
targets.add(args[args.length - 1]);
|
|
145
|
+
} else {
|
|
146
|
+
for (const a of args) targets.add(a);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return [...targets]
|
|
151
|
+
.map((t) => t.replace(/^['"]|['"]$/g, ''))
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
.filter((t) => !/[$`(){}]/.test(t));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The vault-naming gate's id — unique across the built-in registry. */
|
|
157
|
+
export const id = 'enforce-vault-naming';
|
|
158
|
+
/** Runs pre-tool, same as its PreToolUse Claude Code hook registration. */
|
|
159
|
+
export const event = 'before_tool';
|
|
160
|
+
/** Caller-defined severity label; this gate can deny, so it is "block". */
|
|
161
|
+
export const severity = 'block';
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @param {{ point: string, toolName?: string, toolInput?: Record<string, unknown> }} evt
|
|
165
|
+
* @param {{ cwd?: string, getActiveConfig?: () => { repoRootAbs?: string } | null,
|
|
166
|
+
* existsSync?: (p: string) => boolean }} [ctx]
|
|
167
|
+
* @returns {{ decision: 'allow'|'deny', reason?: string }}
|
|
168
|
+
*/
|
|
169
|
+
export function run(evt, ctx = {}) {
|
|
170
|
+
const toolName = evt?.toolName;
|
|
171
|
+
const input = evt?.toolInput ?? {};
|
|
172
|
+
|
|
173
|
+
const candidates = [];
|
|
174
|
+
if (toolName === 'Write') {
|
|
175
|
+
candidates.push(input.file_path);
|
|
176
|
+
} else if (toolName === 'Edit' || toolName === 'MultiEdit') {
|
|
177
|
+
return { decision: 'allow' };
|
|
178
|
+
} else if (toolName === 'NotebookEdit') {
|
|
179
|
+
candidates.push(input.notebook_path);
|
|
180
|
+
} else if (toolName === 'Bash' || toolName === 'PowerShell') {
|
|
181
|
+
for (const t of extractBashTargets(input.command)) candidates.push(t);
|
|
182
|
+
} else {
|
|
183
|
+
return { decision: 'allow' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const root = ctx.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? REPO_ROOT;
|
|
187
|
+
const rule = rulesForSurface(loadGuardrails(root), 'pretooluse').find((r) => r && r.naming);
|
|
188
|
+
if (!rule) return { decision: 'allow' };
|
|
189
|
+
|
|
190
|
+
const getActiveConfigFn = ctx.getActiveConfig ?? getActiveConfig;
|
|
191
|
+
const existsSyncFn = ctx.existsSync ?? fs.existsSync;
|
|
192
|
+
|
|
193
|
+
const violations = [];
|
|
194
|
+
for (const c of candidates) {
|
|
195
|
+
const norm = normalizeRel(c, root, getActiveConfigFn);
|
|
196
|
+
if (!norm) continue;
|
|
197
|
+
const { rel, abs } = norm;
|
|
198
|
+
let requireNew = true;
|
|
199
|
+
try {
|
|
200
|
+
requireNew = !existsSyncFn(abs);
|
|
201
|
+
} catch {
|
|
202
|
+
requireNew = false; // fs probe failed → lenient (fail-safe, never over-block)
|
|
203
|
+
}
|
|
204
|
+
const v = namingViolation(rule, rel, { requireNew });
|
|
205
|
+
if (v) violations.push(v);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (violations.length > 0) {
|
|
209
|
+
return { decision: 'deny', reason: violations.map(formatReason).join('\n\n') };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { decision: 'allow' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export default { id, event, severity, run };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// spexharness/src/gates/epic-milestone-phrasing-gate.mjs
|
|
2
|
+
//
|
|
3
|
+
// Engine port of scripts/claude-hooks/epic-milestone-phrasing-gate.mjs — the
|
|
4
|
+
// Stop-hook gate that catches an agent telling the owner to "promote X to an
|
|
5
|
+
// epic", a sentence that is always wrong (an epic can never exist without a
|
|
6
|
+
// milestone parent; the console's own write path refuses it with
|
|
7
|
+
// `error: "milestone_required"`, and this gate is about the SPOKEN sentence the
|
|
8
|
+
// write gate never sees). See ./PORTING.md for the recipe this follows and
|
|
9
|
+
// ./env-vault-guard.mjs for the reference port.
|
|
10
|
+
//
|
|
11
|
+
// WHY IT IS PORTED (the whole point of [[HS-AZ61-NYPR]] wave 6): before this
|
|
12
|
+
// module the rule lived only in the Claude Code plugin, so a DeepSeek seat or a
|
|
13
|
+
// local-model seat could say the wrong sentence all day and nothing noticed.
|
|
14
|
+
// The gate now runs on the engine's `stop` event, which every front emits
|
|
15
|
+
// through spexharness/src/session-lifecycle.mjs — so the rule reaches every
|
|
16
|
+
// seat, not just the one front that happened to have a hook directory.
|
|
17
|
+
//
|
|
18
|
+
// The original does NOT export a pure decision function (its logic lives inside
|
|
19
|
+
// `main()` alongside `readStdinJson`/`process.stdout.write`), so per PORTING.md
|
|
20
|
+
// step 1's "otherwise" branch the decision logic is re-derived here — but only
|
|
21
|
+
// the GLUE. Every piece carrying rule CONTENT is imported unchanged from the
|
|
22
|
+
// same canonical, dependency-free modules the original itself imports:
|
|
23
|
+
// - scripts/claude-hooks/_guardrail-loader.mjs — loadGuardrails,
|
|
24
|
+
// rulesForSurface, isRuleEnabled, compilePatterns, proseOnly,
|
|
25
|
+
// matchContextAround. The detection pattern, the severity and the toggle
|
|
26
|
+
// all come from the compiled registry (.heyspex/guardrails.json, authored
|
|
27
|
+
// in communication-style-protocol.md's `enforcement:` block), so this port
|
|
28
|
+
// and the original cannot drift on what "the bad shape" IS.
|
|
29
|
+
// - scripts/claude-hooks/_transcript-text.mjs — lastAssistantText, the same
|
|
30
|
+
// reader naughty-list-gate.mjs and id-display-format-gate.mjs use for the
|
|
31
|
+
// Claude Code path.
|
|
32
|
+
// The one string authored HERE is the block reason / warn banner, carried over
|
|
33
|
+
// byte-for-byte from the original's `blockReason()` and its stderr write so the
|
|
34
|
+
// generated shell reproduces the original's output exactly (proved by
|
|
35
|
+
// spexharness/fixtures/ported/).
|
|
36
|
+
//
|
|
37
|
+
// Event/field mapping: a Stop hook → the engine's `stop` event point.
|
|
38
|
+
// stdin.transcript_path → evt.transcriptPath
|
|
39
|
+
// FRONT-AGNOSTIC READ (the port's actual new capability): the reply text is
|
|
40
|
+
// taken from `ctx.reply` / `evt.text` FIRST — the fields
|
|
41
|
+
// spexharness/src/session-lifecycle.mjs's `stop()` already carries for every
|
|
42
|
+
// front that holds its own answer text — and only falls back to reading a
|
|
43
|
+
// transcript file when neither is present. A DeepSeek seat has no Claude Code
|
|
44
|
+
// transcript; it has the reply in hand, and that is enough for this gate.
|
|
45
|
+
//
|
|
46
|
+
// Decision mapping — the original is a TWO-RUNG gate, same shape as
|
|
47
|
+
// ./enforce-session-sequence.mjs:
|
|
48
|
+
// real block (unattended CLAUDE_AGENT=true, or the interactive rung raised
|
|
49
|
+
// with HEYSPEX_EPIC_MILESTONE_GATE_INTERACTIVE=block)
|
|
50
|
+
// → `deny` + the original's `blockReason(hits)` text verbatim,
|
|
51
|
+
// which the adapter turns back into the Stop contract's
|
|
52
|
+
// `{ decision: "block", reason }` stdout.
|
|
53
|
+
// warn only (interactive default — the rollout discipline the original
|
|
54
|
+
// documents: a new mechanical gate ships warn-first)
|
|
55
|
+
// → `rewrite` with `{ warn: true, reason, stderrText }`, the same
|
|
56
|
+
// mapping enforce-session-sequence.mjs uses for its own
|
|
57
|
+
// warn rung rather than silently downgrading it to `allow`
|
|
58
|
+
// (which would drop the reason a caller may still want) or
|
|
59
|
+
// upgrading it to `deny` (a real behavior change).
|
|
60
|
+
// otherwise → `allow`.
|
|
61
|
+
//
|
|
62
|
+
// Env hatches (unchanged):
|
|
63
|
+
// HEYSPEX_EPIC_MILESTONE_GATE_OFF=1 Hard kill switch.
|
|
64
|
+
// HEYSPEX_EPIC_MILESTONE_GATE_INTERACTIVE=block Raise interactive to a real block.
|
|
65
|
+
// CLAUDE_AGENT=true Unattended session → real block.
|
|
66
|
+
// The rule also honors its registry `configKey`
|
|
67
|
+
// (agent_behaviors.epic_milestone_phrasing_gate.enabled) via isRuleEnabled.
|
|
68
|
+
//
|
|
69
|
+
// Not ported HERE: the original's `appendGuardrailEvent(...)` audit-log write.
|
|
70
|
+
// A gate module stays a pure decision function; the audit trail is restored by
|
|
71
|
+
// the front — see spexharness/src/adapters/audit.mjs's
|
|
72
|
+
// `epic-milestone-phrasing-gate` case, which reads the `rule` and `hits` this
|
|
73
|
+
// run() carries on both its `deny` and `rewrite` results so it never has to
|
|
74
|
+
// re-derive which rule fired from `reason` text.
|
|
75
|
+
//
|
|
76
|
+
// `ctx` contract (all optional; every field defaults to a real fs/env read):
|
|
77
|
+
// ctx.reply - the reply text the front already holds (preferred over any
|
|
78
|
+
// transcript read). session-lifecycle.mjs's stop() sets it.
|
|
79
|
+
// ctx.cwd - repo root the guardrail registry is resolved from. Defaults
|
|
80
|
+
// to CLAUDE_PROJECT_DIR, else this repo's root. When given it
|
|
81
|
+
// is a self-contained fixture root and the rule's config
|
|
82
|
+
// toggle is read from it too.
|
|
83
|
+
// ctx.configRoot - the directory holding `.heyspex/config.yaml` (the rule's
|
|
84
|
+
// configKey toggle); wins over ctx.cwd for that read. With
|
|
85
|
+
// neither, the VAULT root (../config-root.mjs
|
|
86
|
+
// `defaultConfigRoot()`) — never CLAUDE_PROJECT_DIR, which
|
|
87
|
+
// under Claude Code is the app checkout, not where the
|
|
88
|
+
// settings file lives
|
|
89
|
+
// ([[HS-4C76-9W86-DIAG-heyspex-config-yaml-has-no-single-home]]).
|
|
90
|
+
// ctx.readText - override for the transcript reader (defaults to
|
|
91
|
+
// lastAssistantText); tests inject reply text directly instead.
|
|
92
|
+
// ctx.env - env var overrides (tests use this instead of mutating
|
|
93
|
+
// process.env).
|
|
94
|
+
|
|
95
|
+
import path from 'node:path';
|
|
96
|
+
import { fileURLToPath } from 'node:url';
|
|
97
|
+
|
|
98
|
+
import {
|
|
99
|
+
loadGuardrails,
|
|
100
|
+
rulesForSurface,
|
|
101
|
+
isRuleEnabled,
|
|
102
|
+
compilePatterns,
|
|
103
|
+
proseOnly,
|
|
104
|
+
matchContextAround,
|
|
105
|
+
} from '../../../scripts/claude-hooks/_guardrail-loader.mjs';
|
|
106
|
+
import { lastAssistantText } from '../../../scripts/claude-hooks/_transcript-text.mjs';
|
|
107
|
+
import { defaultConfigRoot } from '../config-root.mjs';
|
|
108
|
+
|
|
109
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
110
|
+
const REPO_ROOT = path.resolve(HERE, '..', '..', '..');
|
|
111
|
+
|
|
112
|
+
/** The compiled-registry rule this gate enforces (authored in communication-style-protocol.md). */
|
|
113
|
+
export const RULE_ID = 'epic-milestone-phrasing-gate';
|
|
114
|
+
const OFF_ENV = 'HEYSPEX_EPIC_MILESTONE_GATE_OFF';
|
|
115
|
+
const INTERACTIVE_ENV = 'HEYSPEX_EPIC_MILESTONE_GATE_INTERACTIVE';
|
|
116
|
+
|
|
117
|
+
/** Real block vs. warn-only, mirroring the original's `isAgent`/`interactiveRung()` pair. */
|
|
118
|
+
function shouldBlockForReal(env) {
|
|
119
|
+
if (String(env.CLAUDE_AGENT ?? '') === 'true') return true;
|
|
120
|
+
return String(env[INTERACTIVE_ENV] ?? '').trim().toLowerCase() === 'block';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Byte-for-byte the original hook's `blockReason(hits)`.
|
|
125
|
+
* @param {Array<{ excerpt: string }>} hits
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
export function blockReason(hits) {
|
|
129
|
+
const lines = hits.map((h) => ` • "${h.excerpt}"`);
|
|
130
|
+
return (
|
|
131
|
+
`Epic/milestone phrasing violation: your last message described promoting ` +
|
|
132
|
+
`something directly "to an epic." An epic can never exist without a milestone ` +
|
|
133
|
+
`parent — the correct next step is always "promote to a milestone" (which ` +
|
|
134
|
+
`spawns epic stubs underneath it). Rewrite and resend:\n${lines.join('\n')}\n` +
|
|
135
|
+
`(See specs/memory/lessons/say-promote-to-a-milestone-never.md. Break-glass: ` +
|
|
136
|
+
`HEYSPEX_EPIC_MILESTONE_GATE_OFF=1.)`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Byte-for-byte the original hook's interactive-warn stderr write. Kept here
|
|
142
|
+
* (rather than left to the adapter's generic warn formatter) for the same
|
|
143
|
+
* reason enforce-session-sequence.mjs keeps its own: the banner names THIS
|
|
144
|
+
* gate, and the generated shell must reproduce the original's stderr exactly.
|
|
145
|
+
*
|
|
146
|
+
* @param {Array<{ excerpt: string }>} hits
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
export function warnStderrText(hits) {
|
|
150
|
+
return (
|
|
151
|
+
`[epic-milestone-phrasing] warning — "promote ... to an epic" in your last message (not blocked):\n` +
|
|
152
|
+
hits.map((h) => ` • "${h.excerpt}"`).join('\n') +
|
|
153
|
+
'\n'
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Every registry-pattern hit in the PROSE of `text` (a match inside a code
|
|
159
|
+
* span, fenced block, wikilink, URL or blockquote is exempt — proseOnly's job,
|
|
160
|
+
* shared with every other stop gate). One hit per compiled pattern, same as the
|
|
161
|
+
* original's loop.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} text
|
|
164
|
+
* @param {string} root - where `.heyspex/guardrails.json` (the rule row) is read from.
|
|
165
|
+
* @param {string} [configRoot] - where `.heyspex/config.yaml` (the toggle) is read
|
|
166
|
+
* from; defaults to `root` so a self-contained fixture root still works as one unit.
|
|
167
|
+
* @returns {Array<{ excerpt: string }>}
|
|
168
|
+
*/
|
|
169
|
+
export function phrasingHits(text, root, configRoot = root) {
|
|
170
|
+
const rule = rulesForSurface(loadGuardrails(root), 'stop').find((r) => r && r.rule === RULE_ID);
|
|
171
|
+
if (!rule || !isRuleEnabled(rule, configRoot)) return [];
|
|
172
|
+
const patterns = compilePatterns(rule);
|
|
173
|
+
if (!patterns.length) return [];
|
|
174
|
+
const prose = proseOnly(text);
|
|
175
|
+
const hits = [];
|
|
176
|
+
for (const re of patterns) {
|
|
177
|
+
const m = re.exec(prose);
|
|
178
|
+
if (m) hits.push({ excerpt: matchContextAround(prose, m.index, m[0].length) });
|
|
179
|
+
}
|
|
180
|
+
return hits;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The gate's id — unique across the registry. */
|
|
184
|
+
export const id = 'epic-milestone-phrasing-gate';
|
|
185
|
+
/** Runs on the Stop event, same as its Stop Claude Code hook registration. */
|
|
186
|
+
export const event = 'stop';
|
|
187
|
+
/** Caller-defined severity label; the registry rule's own severity is "warn". */
|
|
188
|
+
export const severity = 'warn';
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {{ point: string, text?: string, transcriptPath?: string }} evt
|
|
192
|
+
* @param {{ reply?: string, cwd?: string, configRoot?: string, readText?: (p: string) => string|null,
|
|
193
|
+
* env?: Record<string, string|undefined> }} [ctx]
|
|
194
|
+
* @returns {{ decision: 'allow'|'deny'|'rewrite', reason?: string, rule?: string,
|
|
195
|
+
* hits?: Array<{ excerpt: string }>, rewrite?: object }}
|
|
196
|
+
*/
|
|
197
|
+
export function run(evt, ctx = {}) {
|
|
198
|
+
const env = ctx.env ?? process.env;
|
|
199
|
+
if (String(env[OFF_ENV] ?? '') === '1') return { decision: 'allow' };
|
|
200
|
+
|
|
201
|
+
// Front-agnostic read: the reply the front already holds wins; a transcript
|
|
202
|
+
// is only read when there is no reply text to check (the Claude Code path).
|
|
203
|
+
const readText = ctx.readText ?? lastAssistantText;
|
|
204
|
+
let text = typeof ctx.reply === 'string' && ctx.reply ? ctx.reply : null;
|
|
205
|
+
if (!text && typeof evt?.text === 'string' && evt.text) text = evt.text;
|
|
206
|
+
if (!text && evt?.transcriptPath) text = readText(evt.transcriptPath) ?? null;
|
|
207
|
+
if (!text) return { decision: 'allow' };
|
|
208
|
+
|
|
209
|
+
const root = ctx.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? REPO_ROOT;
|
|
210
|
+
const configRoot = ctx.configRoot ?? (ctx.cwd ? ctx.cwd : defaultConfigRoot());
|
|
211
|
+
|
|
212
|
+
let hits;
|
|
213
|
+
try {
|
|
214
|
+
hits = phrasingHits(text, root, configRoot);
|
|
215
|
+
} catch {
|
|
216
|
+
return { decision: 'allow' }; // fail-safe: a broken registry never blocks a stop
|
|
217
|
+
}
|
|
218
|
+
if (!hits.length) return { decision: 'allow' };
|
|
219
|
+
|
|
220
|
+
if (shouldBlockForReal(env)) {
|
|
221
|
+
return { decision: 'deny', reason: blockReason(hits), rule: RULE_ID, hits };
|
|
222
|
+
}
|
|
223
|
+
const reason = blockReason(hits);
|
|
224
|
+
return {
|
|
225
|
+
decision: 'rewrite',
|
|
226
|
+
reason,
|
|
227
|
+
rule: RULE_ID,
|
|
228
|
+
hits,
|
|
229
|
+
rewrite: { warn: true, reason, rule: RULE_ID, hits, stderrText: warnStderrText(hits) },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export default { id, event, severity, run };
|