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,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Env-vault audit writer (HS-JYTT / HS-I52T) — append one AuditEventRow to
|
|
3
|
+
// `.heyspex/env-vault-history.jsonl` each time something touches (or is
|
|
4
|
+
// stopped from touching) the env vault's secrets.
|
|
5
|
+
//
|
|
6
|
+
// Two writers share this ledger file, distinguished by event_kind:
|
|
7
|
+
// - the PreToolUse guard hook (this module's caller) writes
|
|
8
|
+
// `env_vault.policy_decision` rows — a policy deny/ask on an agent-issued
|
|
9
|
+
// secret-touching command;
|
|
10
|
+
// - the `hs env` CLI (spex-console/src/lib/env-vault/audit.ts, HS-N2UP)
|
|
11
|
+
// writes `env_vault.secret_touch` rows — which keys were actually
|
|
12
|
+
// injected into which command.
|
|
13
|
+
//
|
|
14
|
+
// Same design rules as ./_guardrail-events.mjs (the pattern this follows):
|
|
15
|
+
// dependency-free (hooks have no Neon access), row shape is the canonical
|
|
16
|
+
// AuditEventRow { timestamp, event_kind, actor, spec_id, metadata }, and
|
|
17
|
+
// observability must NEVER break the hook — every error is swallowed.
|
|
18
|
+
// Metadata carries names and dispositions only, never a secret value.
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_REPO_ROOT = path.resolve(
|
|
25
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
26
|
+
'..',
|
|
27
|
+
'..',
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
function repoRootFromEnv() {
|
|
31
|
+
const fromEnv = process.env.CLAUDE_PROJECT_DIR?.trim();
|
|
32
|
+
if (fromEnv) return fromEnv.replace(/[\\/]+$/, '');
|
|
33
|
+
return DEFAULT_REPO_ROOT;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cleanMeta(meta) {
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
39
|
+
if (v !== undefined && v !== null) out[k] = v;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Append one env-vault policy-decision event. Returns true on a successful
|
|
46
|
+
* write, false on any (swallowed) failure. `decision` is required; the rest
|
|
47
|
+
* is best-effort context.
|
|
48
|
+
*/
|
|
49
|
+
export function appendEnvVaultEvent(
|
|
50
|
+
{ eventKind = 'env_vault.policy_decision', decision, verb = null, profile = null, source = null, tool = null } = {},
|
|
51
|
+
repoRoot = repoRootFromEnv(),
|
|
52
|
+
) {
|
|
53
|
+
try {
|
|
54
|
+
if (!decision) return false;
|
|
55
|
+
const row = {
|
|
56
|
+
timestamp: new Date().toISOString(),
|
|
57
|
+
event_kind: eventKind,
|
|
58
|
+
actor: process.env.HEYSPEX_GUARDRAIL_ACTOR?.trim() || 'Claude Code',
|
|
59
|
+
spec_id: null,
|
|
60
|
+
metadata: cleanMeta({ decision, verb, profile, source, tool }),
|
|
61
|
+
};
|
|
62
|
+
const file = path.join(repoRoot, '.heyspex', 'env-vault-history.jsonl');
|
|
63
|
+
fs.appendFileSync(file, JSON.stringify(row) + '\n');
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false; // fail-safe: never let observability break a hook
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Guardrail observability writer ([[HS-EPIC-ZGWR]] Phase 6) — append one
|
|
3
|
+
// `guardrail.fired` audit row to `.heyspex/guardrail-history.jsonl` each time a
|
|
4
|
+
// guardrail hook fires (a block, a rewrite, a prime). This is the file-based event
|
|
5
|
+
// log the audit export folds into specs/events/YYYY-MM.jsonl (shared monitoring seam
|
|
6
|
+
// with [[HS-EPIC-0016]]).
|
|
7
|
+
//
|
|
8
|
+
// WHY A LOCAL FILE (both tiers): the Claude Code hooks are dependency-free and have NO
|
|
9
|
+
// Neon access — they cannot write spec_events directly. So the guardrail log is always
|
|
10
|
+
// local; the server-side export reads it as an ALWAYS-ON source (regardless of tier),
|
|
11
|
+
// alongside the Neon tables / free-tier file logs. Same `.heyspex/*-history.jsonl`
|
|
12
|
+
// runtime-file family as claim-history / focus-history, and like them GIT-TRACKED
|
|
13
|
+
// (the free tier's live event store — a clone IS the audit trail;
|
|
14
|
+
// [[free-tier-makes-runtime-files-non-retireable]]). The deduped, month-partitioned
|
|
15
|
+
// export copy lives in committed specs/events/YYYY-MM.jsonl.
|
|
16
|
+
//
|
|
17
|
+
// ROW SHAPE: already the canonical AuditEventRow
|
|
18
|
+
// { timestamp, event_kind:"guardrail.fired", actor, spec_id, metadata }
|
|
19
|
+
// so the reader (free-event-readers.ts loadGuardrailEventRows) only validates the
|
|
20
|
+
// timestamp and passes through. metadata carries { rule, surface, severity, decision,
|
|
21
|
+
// relPath?, match_context? } — the fired rule, where it fired, the disposition, and
|
|
22
|
+
// a bounded window of the prose that matched.
|
|
23
|
+
//
|
|
24
|
+
// FAIL-SAFE: observability must NEVER break a guardrail hook. Every error is swallowed;
|
|
25
|
+
// a missing .heyspex/ dir, an unwritable disk, anything → silent no-op.
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
/** Kept in step with _guardrail-loader.mjs; inlined so this stays dependency-free. */
|
|
32
|
+
const MATCH_CONTEXT_MAX_CHARS = 200;
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_REPO_ROOT = path.resolve(
|
|
35
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
36
|
+
'..',
|
|
37
|
+
'..',
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
function repoRootFromEnv() {
|
|
41
|
+
const fromEnv = process.env.CLAUDE_PROJECT_DIR?.trim();
|
|
42
|
+
if (fromEnv) return fromEnv.replace(/[\\/]+$/, '');
|
|
43
|
+
return DEFAULT_REPO_ROOT;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cleanMeta(meta) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
49
|
+
if (v !== undefined && v !== null) out[k] = v;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Append a guardrail-fired event. All fields optional except `rule`; unknown ones are
|
|
56
|
+
* dropped. Returns true on a successful write, false on any (swallowed) failure — so a
|
|
57
|
+
* caller can branch in a test, but a hook can ignore the result.
|
|
58
|
+
*/
|
|
59
|
+
export function appendGuardrailEvent(
|
|
60
|
+
{
|
|
61
|
+
rule,
|
|
62
|
+
surface = null,
|
|
63
|
+
severity = null,
|
|
64
|
+
decision = null,
|
|
65
|
+
specId = null,
|
|
66
|
+
relPath = null,
|
|
67
|
+
matchContext = null,
|
|
68
|
+
} = {},
|
|
69
|
+
repoRoot = repoRootFromEnv(),
|
|
70
|
+
) {
|
|
71
|
+
try {
|
|
72
|
+
if (!rule) return false;
|
|
73
|
+
// `match_context` is the bounded window of prose the rule matched in
|
|
74
|
+
// ([[HS-N71O-P3XQ-DIAG-six-new-qc-gates-fail-cold-verification]] item 10).
|
|
75
|
+
// Without it a row said only WHICH word fired, and a reviewer could not tell a
|
|
76
|
+
// false positive from a real hit — which is the evidence needed before raising
|
|
77
|
+
// a gate from warn to block. Collapsed to one line and hard-capped here as
|
|
78
|
+
// well as at the source, so an oversized value cannot bloat the log.
|
|
79
|
+
const context =
|
|
80
|
+
typeof matchContext === 'string' && matchContext.trim()
|
|
81
|
+
? matchContext.replace(/\s+/g, ' ').trim().slice(0, MATCH_CONTEXT_MAX_CHARS)
|
|
82
|
+
: null;
|
|
83
|
+
const row = {
|
|
84
|
+
timestamp: new Date().toISOString(),
|
|
85
|
+
event_kind: 'guardrail.fired',
|
|
86
|
+
actor: process.env.HEYSPEX_GUARDRAIL_ACTOR?.trim() || 'Claude Code',
|
|
87
|
+
spec_id: typeof specId === 'string' ? specId : null,
|
|
88
|
+
metadata: cleanMeta({ rule, surface, severity, decision, relPath, match_context: context }),
|
|
89
|
+
};
|
|
90
|
+
const file = path.join(repoRoot, '.heyspex', 'guardrail-history.jsonl');
|
|
91
|
+
fs.appendFileSync(file, JSON.stringify(row) + '\n');
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return false; // fail-safe: never let observability break a hook
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Shared loader for the HeySpex guardrail registry (.heyspex/guardrails.json).
|
|
3
|
+
//
|
|
4
|
+
// The registry is compiled from protocol `enforcement:` frontmatter by
|
|
5
|
+
// scripts/build-guardrails.mjs ([[HS-EPIC-ZGWR]] phase 1, design [[HS-RES-GHKO]]
|
|
6
|
+
// §6/§8). This module is the runtime read side that the Claude Code hooks import
|
|
7
|
+
// instead of hand-coding their own rule tables — one authored source, no
|
|
8
|
+
// prose↔JS drift.
|
|
9
|
+
//
|
|
10
|
+
// DELIBERATELY DEPENDENCY-FREE. Hooks fire on every prompt/tool call and must run
|
|
11
|
+
// on a fresh clone with no `npm install` (spex-console/node_modules absent), so
|
|
12
|
+
// this reads the *compiled JSON* with the stdlib only — never js-yaml, never the
|
|
13
|
+
// protocol YAML. (build-guardrails.mjs is the one allowed to use js-yaml; it is a
|
|
14
|
+
// build step, not a per-prompt hook.)
|
|
15
|
+
//
|
|
16
|
+
// FAIL-SAFE CONTRACT (non-negotiable, [[HS-RES-GHKO]] §7): every function degrades
|
|
17
|
+
// to a safe empty/enabled result on any error. A corrupt or missing registry must
|
|
18
|
+
// never throw into a hook — the worst outcome is a guardrail going silent, never a
|
|
19
|
+
// stranded agent. Callers wrap their own bodies in try/catch and exit 0 too.
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
import { loadRuleTable } from '../../spexharness/src/rule-table.mjs';
|
|
26
|
+
import { defaultConfigRoot } from '../../spexharness/src/config-root.mjs';
|
|
27
|
+
|
|
28
|
+
// TWO ROOTS, split by what lives under `.heyspex/` at each
|
|
29
|
+
// ([[HS-4C76-9W86-DIAG-heyspex-config-yaml-has-no-single-home]]):
|
|
30
|
+
//
|
|
31
|
+
// - DEFAULT_REPO_ROOT — the CODE checkout (scripts/claude-hooks/ → two levels
|
|
32
|
+
// up). The guardrail registry (`.heyspex/guardrails.json`, compiled by
|
|
33
|
+
// scripts/build-guardrails.mjs) and the per-user terminology override are
|
|
34
|
+
// code-repo artifacts and stay here. `loadGuardrails` / `resolveVocabulary`
|
|
35
|
+
// default to it.
|
|
36
|
+
// - the CONFIG root — `.heyspex/config.yaml` is the workflow-settings file and
|
|
37
|
+
// per ADR-0009 lives at the VAULT root, which since the 2026-08-31 repo split
|
|
38
|
+
// is a separate checkout. `isConfigKeyEnabled` / `readConfigLeafRaw` /
|
|
39
|
+
// `resolveConfirmMode` / `isRuleEnabled` default to `defaultConfigRoot()`
|
|
40
|
+
// (spexharness/src/config-root.mjs → vault-resolve's `repoRootAbs`, falling
|
|
41
|
+
// back to the code root only if resolution throws). Resolved lazily at call
|
|
42
|
+
// time, never at import, so a hook's startup cost is unchanged.
|
|
43
|
+
//
|
|
44
|
+
// Every function keeps its `repoRoot` parameter: an explicit argument always
|
|
45
|
+
// wins over either default (tests and callers with a fixture root rely on it).
|
|
46
|
+
export const DEFAULT_REPO_ROOT = path.resolve(
|
|
47
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
48
|
+
'..',
|
|
49
|
+
'..',
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Sentinels that mark harness-injected content arriving through the
|
|
53
|
+
// UserPromptSubmit channel (background-task notifications, system reminders, slash-
|
|
54
|
+
// command envelopes). They can contain a rule's trigger phrase verbatim but are NOT
|
|
55
|
+
// a genuine human prompt, so a rule with `ignoreInjectedContent` must not fire on
|
|
56
|
+
// them. Shared here so every surface uses one guard list.
|
|
57
|
+
export const INJECTED_CONTENT_SENTINELS = [
|
|
58
|
+
'<task-notification>',
|
|
59
|
+
'<system-reminder>',
|
|
60
|
+
'[SYSTEM NOTIFICATION',
|
|
61
|
+
'<command-message>',
|
|
62
|
+
'<command-name>',
|
|
63
|
+
'<local-command-stdout>',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export function containsInjectedContent(text) {
|
|
67
|
+
if (typeof text !== 'string') return false;
|
|
68
|
+
return INJECTED_CONTENT_SENTINELS.some((s) => text.includes(s));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Stricter, false-positive-safe variant: is the message a genuine harness ENVELOPE
|
|
72
|
+
// (a task-notification / system-reminder / slash-command envelope delivered AS the
|
|
73
|
+
// prompt), rather than user text that merely QUOTES a sentinel? A real envelope leads
|
|
74
|
+
// with its tag; a user pasting a transcript or terminal logs leads with their own
|
|
75
|
+
// words. `containsInjectedContent` (substring anywhere) wrongly suppresses the latter
|
|
76
|
+
// — which silently kills an autonomous trigger the moment the user pastes logs, the
|
|
77
|
+
// exact time they are offloading an idea (evidence: the HeySpex Intent Classifier
|
|
78
|
+
// capture no-fire, 2026-07-11). Intent-classifier surfaces should prefer THIS check.
|
|
79
|
+
export function startsWithInjectedContent(text) {
|
|
80
|
+
if (typeof text !== 'string') return false;
|
|
81
|
+
const head = text.trimStart();
|
|
82
|
+
return INJECTED_CONTENT_SENTINELS.some((s) => head.startsWith(s));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Load the guardrail rule table. Returns `{ rules: [...] }` — ALWAYS the full
|
|
87
|
+
* built-in rule set at minimum (spexharness/src/rule-table.mjs `loadRuleTable`),
|
|
88
|
+
* merged with whatever overrides `<repoRoot>/.heyspex/guardrails.json` supplies.
|
|
89
|
+
*
|
|
90
|
+
* This used to fail-safe to `{ rules: [] }` (every guardrail silently OFF) on a
|
|
91
|
+
* missing or corrupt file — that was the exact "fresh customer vaults ship with
|
|
92
|
+
* zero naming enforcement" gap [[HS-RY2N-91GR-DIAG-fresh-customer-vaults-ship-with-zero]]
|
|
93
|
+
* named. `loadRuleTable` fixes the failure mode at its source: the standard
|
|
94
|
+
* rules live in code, so a missing/corrupt file can no longer turn any of them
|
|
95
|
+
* off. This function keeps that guarantee and only adds the stderr visibility
|
|
96
|
+
* a present-but-broken file deserves (loadRuleTable itself is silent/pure).
|
|
97
|
+
*/
|
|
98
|
+
export function loadGuardrails(repoRoot = DEFAULT_REPO_ROOT) {
|
|
99
|
+
const { rules, warnings } = loadRuleTable({ vaultRoot: repoRoot });
|
|
100
|
+
|
|
101
|
+
for (const warning of warnings) {
|
|
102
|
+
if (warning.code === 'corrupt_file') {
|
|
103
|
+
process.stderr.write(
|
|
104
|
+
`[guardrail-loader] .heyspex/guardrails.json is corrupt — falling back to the built-in rule set ` +
|
|
105
|
+
`(nothing is disabled). ${warning.message} Rebuild with \`node scripts/build-guardrails.mjs\`.\n`,
|
|
106
|
+
);
|
|
107
|
+
} else if (warning.code === 'unknown_rule') {
|
|
108
|
+
process.stderr.write(`[guardrail-loader] ${warning.message}\n`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// HS-RY2N: a MISSING registry file used to mean every guardrail was OFF; now
|
|
113
|
+
// it just means the on-disk overrides are unavailable — the built-in rule set
|
|
114
|
+
// still runs. Still worth a loud note in a real HeySpex vault (heyspex.yaml
|
|
115
|
+
// present) so a missing compiled file doesn't go unnoticed, but it is no
|
|
116
|
+
// longer an enforcement outage.
|
|
117
|
+
if (warnings.length === 0 && !fs.existsSync(path.join(repoRoot, '.heyspex', 'guardrails.json'))) {
|
|
118
|
+
if (fs.existsSync(path.join(repoRoot, 'heyspex.yaml'))) {
|
|
119
|
+
process.stderr.write(
|
|
120
|
+
'[guardrail-loader] heyspex.yaml marks this as a HeySpex vault, but .heyspex/guardrails.json ' +
|
|
121
|
+
'does not exist — running on the built-in rule set only (no on-disk severity/configKey overrides). ' +
|
|
122
|
+
'Platform repo: run `node scripts/build-guardrails.mjs`. Customer vault: see ' +
|
|
123
|
+
'[[HS-RY2N-91GR-DIAG-fresh-customer-vaults-ship-with-zero]] / customer-vault-local-install-protocol.\n',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { rules };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* All rules whose `surfaces` include the given surface (e.g. "userpromptsubmit").
|
|
133
|
+
* Optionally narrow by severity. Order-preserving; never throws.
|
|
134
|
+
*/
|
|
135
|
+
export function rulesForSurface(guardrails, surface, severity = null) {
|
|
136
|
+
const rules = guardrails && Array.isArray(guardrails.rules) ? guardrails.rules : [];
|
|
137
|
+
return rules.filter(
|
|
138
|
+
(r) =>
|
|
139
|
+
r &&
|
|
140
|
+
Array.isArray(r.surfaces) &&
|
|
141
|
+
r.surfaces.includes(surface) &&
|
|
142
|
+
(severity == null || r.severity === severity),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Compile a rule's pattern strings to case-insensitive RegExp. Any individual
|
|
148
|
+
* pattern that fails to compile is skipped (logged) rather than throwing — a single
|
|
149
|
+
* malformed pattern must not disarm the whole hook.
|
|
150
|
+
*/
|
|
151
|
+
export function compilePatterns(rule) {
|
|
152
|
+
const patterns = rule && Array.isArray(rule.patterns) ? rule.patterns : [];
|
|
153
|
+
const compiled = [];
|
|
154
|
+
for (const p of patterns) {
|
|
155
|
+
try {
|
|
156
|
+
compiled.push(new RegExp(p, 'i'));
|
|
157
|
+
} catch {
|
|
158
|
+
process.stderr.write(
|
|
159
|
+
`[guardrail-loader] rule "${rule?.rule}" has an uncompilable pattern; skipping it: ${p}\n`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return compiled;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read a boolean leaf at a dotted `config_key` (e.g.
|
|
168
|
+
* "agent_behaviors.research_before_decision.enabled") from .heyspex/config.yaml,
|
|
169
|
+
* using a dependency-free, indentation-scoped scan (generalized from the original
|
|
170
|
+
* single-rule reader in the now-retired research-trigger.mjs shim, HS-IYU1). Semantics
|
|
171
|
+
* match the Tier-1 contract:
|
|
172
|
+
* the behavior is ON unless the leaf is explicitly `false`; absent / unreadable /
|
|
173
|
+
* malformed / no config_key all default to ON, so a rule works out of the box and
|
|
174
|
+
* only an explicit opt-out silences it.
|
|
175
|
+
*
|
|
176
|
+
* `repoRoot` is the directory holding `.heyspex/config.yaml`; it defaults to the
|
|
177
|
+
* VAULT root (see the two-roots note at the top of this file), not DEFAULT_REPO_ROOT.
|
|
178
|
+
*/
|
|
179
|
+
export function isConfigKeyEnabled(configKey, repoRoot = defaultConfigRoot()) {
|
|
180
|
+
if (!configKey || typeof configKey !== 'string') return true; // no toggle → enabled
|
|
181
|
+
const segments = configKey.split('.').filter(Boolean);
|
|
182
|
+
if (segments.length === 0) return true;
|
|
183
|
+
|
|
184
|
+
let raw;
|
|
185
|
+
try {
|
|
186
|
+
raw = fs.readFileSync(path.join(repoRoot, '.heyspex', 'config.yaml'), 'utf8');
|
|
187
|
+
} catch {
|
|
188
|
+
return true; // no config file → default enabled
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Maintain the current key path via an indent stack. For each non-blank,
|
|
192
|
+
// non-comment `key:` line, pop stack frames whose indent is >= this line's, then
|
|
193
|
+
// push this key. When the path matches the target, read the leaf value.
|
|
194
|
+
const stack = []; // { key, indent }
|
|
195
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
196
|
+
const trimmed = line.trim();
|
|
197
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
198
|
+
const m = trimmed.match(/^([A-Za-z0-9_]+)\s*:(.*)$/);
|
|
199
|
+
if (!m) continue; // list items / flow content — irrelevant to a scalar leaf path
|
|
200
|
+
const indent = line.length - line.trimStart().length;
|
|
201
|
+
while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
|
|
202
|
+
stack.push({ key: m[1], indent });
|
|
203
|
+
const pathHere = stack.map((f) => f.key);
|
|
204
|
+
if (pathHere.length === segments.length && pathHere.every((k, i) => k === segments[i])) {
|
|
205
|
+
// Reached the leaf — ON unless explicitly false.
|
|
206
|
+
return m[2].trim().toLowerCase() !== 'false';
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return true; // path absent → default enabled
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Back-compat / ergonomic wrapper: enabled-check straight off a rule object.
|
|
213
|
+
// `repoRoot` defaults to the vault (config) root, same as isConfigKeyEnabled.
|
|
214
|
+
export function isRuleEnabled(rule, repoRoot = defaultConfigRoot()) {
|
|
215
|
+
return isConfigKeyEnabled(rule && rule.configKey, repoRoot);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Read the RAW scalar value at a dotted `configKey` from .heyspex/config.yaml,
|
|
220
|
+
* returning the trimmed string after the colon, or `undefined` when the path is
|
|
221
|
+
* absent / the file is unreadable. Unlike isConfigKeyEnabled (which collapses to a
|
|
222
|
+
* boolean with an enabled-by-default bias), this preserves the three-state
|
|
223
|
+
* distinction the confirm-mode resolver needs: explicit value vs. absent/inherit.
|
|
224
|
+
* Dependency-free indent-scoped scan, mirroring isConfigKeyEnabled. FAIL-SAFE.
|
|
225
|
+
* `repoRoot` defaults to the vault (config) root — see the two-roots note above.
|
|
226
|
+
*/
|
|
227
|
+
export function readConfigLeafRaw(configKey, repoRoot = defaultConfigRoot()) {
|
|
228
|
+
if (!configKey || typeof configKey !== 'string') return undefined;
|
|
229
|
+
const segments = configKey.split('.').filter(Boolean);
|
|
230
|
+
if (segments.length === 0) return undefined;
|
|
231
|
+
|
|
232
|
+
let raw;
|
|
233
|
+
try {
|
|
234
|
+
raw = fs.readFileSync(path.join(repoRoot, '.heyspex', 'config.yaml'), 'utf8');
|
|
235
|
+
} catch {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const stack = []; // { key, indent }
|
|
240
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
241
|
+
const trimmed = line.trim();
|
|
242
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
243
|
+
const m = trimmed.match(/^([A-Za-z0-9_]+)\s*:(.*)$/);
|
|
244
|
+
if (!m) continue;
|
|
245
|
+
const indent = line.length - line.trimStart().length;
|
|
246
|
+
while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
|
|
247
|
+
stack.push({ key: m[1], indent });
|
|
248
|
+
const pathHere = stack.map((f) => f.key);
|
|
249
|
+
if (pathHere.length === segments.length && pathHere.every((k, i) => k === segments[i])) {
|
|
250
|
+
return m[2].trim();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Resolve the CONFIRM MODE for an autonomous agent behavior — the two-layer
|
|
258
|
+
* permission model the owner specified (2026-07-11):
|
|
259
|
+
* 'auto' = act immediately, user can undo/discard afterward (forgiveness)
|
|
260
|
+
* 'ask' = ask the user for confirmation before acting (permission)
|
|
261
|
+
*
|
|
262
|
+
* Resolution order (per-behavior override wins over the global default):
|
|
263
|
+
* 1. agent_behaviors.<behaviorKey>.ask_first
|
|
264
|
+
* true|yes|ask → 'ask' · false|no|auto → 'auto' · null/absent → inherit
|
|
265
|
+
* 2. (inherit) global agent_behaviors.ask_forgiveness_not_permission
|
|
266
|
+
* true|yes|absent → 'auto' (forgiveness — the owner's default)
|
|
267
|
+
* false|no → 'ask'
|
|
268
|
+
*
|
|
269
|
+
* So EVERY behavior honors the one global "ask forgiveness, not permission" switch
|
|
270
|
+
* unless it opts out with its own ask_first. FAIL-SAFE: any unreadable config →
|
|
271
|
+
* 'auto' (the stated default). Shared here so any behavior hook resolves it identically.
|
|
272
|
+
* `repoRoot` defaults to the vault (config) root — see the two-roots note above.
|
|
273
|
+
*/
|
|
274
|
+
export function resolveConfirmMode(behaviorKey, repoRoot = defaultConfigRoot()) {
|
|
275
|
+
if (behaviorKey && typeof behaviorKey === 'string') {
|
|
276
|
+
const per = readConfigLeafRaw(`agent_behaviors.${behaviorKey}.ask_first`, repoRoot);
|
|
277
|
+
if (per !== undefined) {
|
|
278
|
+
const v = per.toLowerCase();
|
|
279
|
+
if (v === 'true' || v === 'yes' || v === 'ask') return 'ask';
|
|
280
|
+
if (v === 'false' || v === 'no' || v === 'auto') return 'auto';
|
|
281
|
+
// 'null' / '' / anything else → fall through to the global default (inherit)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const global = readConfigLeafRaw('agent_behaviors.ask_forgiveness_not_permission', repoRoot);
|
|
285
|
+
if (global !== undefined) {
|
|
286
|
+
const g = global.toLowerCase();
|
|
287
|
+
if (g === 'false' || g === 'no') return 'ask'; // forgiveness OFF → ask first
|
|
288
|
+
}
|
|
289
|
+
return 'auto'; // forgiveness ON / absent → act, don't ask
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Path/filename-shape evaluator for a `naming`-payload rule (the `id-suffix-shape`
|
|
294
|
+
* rule, lifted from enforce-vault-naming.mjs's old hand-coded validatePath). Given a
|
|
295
|
+
* compiled rule carrying
|
|
296
|
+
* naming: { managed: [{ dir, kind, pattern, example }], slugOnlyDirs: [...] }
|
|
297
|
+
* and a repo-relative POSIX path, returns null when the path is fine, or a violation
|
|
298
|
+
* descriptor { relPath, dir, kind, requiredPattern, example } when it breaks the rule.
|
|
299
|
+
* The caller formats the human message and decides the exit code.
|
|
300
|
+
*
|
|
301
|
+
* Lives in the loader (not the hook) so the future pre-commit / server consumers share
|
|
302
|
+
* one rule module — GHKO §6.2 "one rule module, many surfaces". FAIL-SAFE: a malformed
|
|
303
|
+
* rule, an uncompilable pattern, or any unexpected shape resolves to null (allow), never
|
|
304
|
+
* throws — a broken rule must never strand the agent.
|
|
305
|
+
*/
|
|
306
|
+
// --- Vocabulary rules (the glossary / naughty-list family, [[HS-PROTO-terminology-governance]]) ---
|
|
307
|
+
//
|
|
308
|
+
// A `kind: vocabulary` rule carries `vocabulary: { terms: [{word, fix, nounOnly?,
|
|
309
|
+
// caseSensitive?}], allow: [...] }`, compiled from the doc table by build-guardrails.mjs
|
|
310
|
+
// (the doc IS the source of truth). The runtime merges a gitignored, per-user override
|
|
311
|
+
// at LOAD time (not compile time) so personal preferences never enter the committed
|
|
312
|
+
// registry, and the Stop hook + server gate apply the identical resolved list.
|
|
313
|
+
|
|
314
|
+
function escapeRegexLiteral(s) {
|
|
315
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Strip the parts of a message that are NOT prose — code spans, fenced code blocks,
|
|
319
|
+
// [[wikilinks]], URLs, and blockquote lines — so a banned word quoted/defined/in-code
|
|
320
|
+
// is never flagged. To refer to a banned word AS a word, wrap it in backticks.
|
|
321
|
+
export function proseOnly(text) {
|
|
322
|
+
return String(text)
|
|
323
|
+
.replace(/```[\s\S]*?```/g, ' ') // fenced code blocks
|
|
324
|
+
.replace(/`[^`]*`/g, ' ') // inline code
|
|
325
|
+
.replace(/\[\[[^\]]*\]\]/g, ' ') // [[wikilinks]]
|
|
326
|
+
.replace(/https?:\/\/\S+/g, ' ') // bare URLs
|
|
327
|
+
.replace(/^\s{0,3}>.*$/gm, ' '); // blockquote lines
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Resolve a vocabulary rule's effective term list by merging the compiled (vendor-
|
|
332
|
+
* default) terms with the per-user `.heyspex/terminology.local.json` override
|
|
333
|
+
* ({ add, allow, remove }). User wins: `allow`/`remove` drop a default term, `add`
|
|
334
|
+
* appends a personal one. FAIL-SAFE: a missing/corrupt override → defaults only.
|
|
335
|
+
* Returns { terms: [{word, fix, nounOnly?, caseSensitive?}], allow: Set<string> }.
|
|
336
|
+
*/
|
|
337
|
+
export function resolveVocabulary(rule, repoRoot = DEFAULT_REPO_ROOT) {
|
|
338
|
+
const v = rule && rule.vocabulary ? rule.vocabulary : {};
|
|
339
|
+
let terms = Array.isArray(v.terms) ? v.terms.slice() : [];
|
|
340
|
+
const allow = new Set((Array.isArray(v.allow) ? v.allow : []).map((s) => String(s).toLowerCase()));
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
const raw = fs.readFileSync(path.join(repoRoot, '.heyspex', 'terminology.local.json'), 'utf8');
|
|
344
|
+
const cfg = JSON.parse(raw);
|
|
345
|
+
for (const w of cfg?.allow ?? []) allow.add(String(w).toLowerCase());
|
|
346
|
+
const remove = new Set((cfg?.remove ?? []).map((w) => String(w).toLowerCase()));
|
|
347
|
+
if (remove.size) terms = terms.filter((t) => !remove.has(String(t.word).toLowerCase()));
|
|
348
|
+
for (const w of cfg?.add ?? []) {
|
|
349
|
+
const word = String(w).trim();
|
|
350
|
+
if (word && !terms.some((t) => String(t.word).toLowerCase() === word.toLowerCase())) {
|
|
351
|
+
terms.push({ word, fix: 'plain literal language (user-added)' });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
// no override / unreadable → defaults only
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// `allow` (default keep-list + user allow) suppresses any matching term.
|
|
359
|
+
terms = terms.filter((t) => !allow.has(String(t.word).toLowerCase()));
|
|
360
|
+
return { terms, allow };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Longest match window carried on a hit — enough to judge, small enough to log. */
|
|
364
|
+
export const MATCH_CONTEXT_MAX_CHARS = 200;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* A bounded window of prose around a match, for the audit row
|
|
368
|
+
* ([[HS-N71O-P3XQ-DIAG-six-new-qc-gates-fail-cold-verification]] item 10).
|
|
369
|
+
*
|
|
370
|
+
* Prefers the sentence the match sits in; falls back to a character window when
|
|
371
|
+
* the sentence is longer than the cap or has no obvious boundaries. Whitespace is
|
|
372
|
+
* collapsed so a row stays one line. A reviewer reading the log needs to tell a
|
|
373
|
+
* false positive ("ladder", the owner's own term in the confidence-ladder method)
|
|
374
|
+
* from a real hit, and the word alone cannot do that.
|
|
375
|
+
*/
|
|
376
|
+
export function matchContextAround(prose, index, matchLength, maxChars = MATCH_CONTEXT_MAX_CHARS) {
|
|
377
|
+
try {
|
|
378
|
+
if (typeof prose !== 'string' || index < 0) return '';
|
|
379
|
+
// Sentence bounds around the match, then trimmed to the cap around the match
|
|
380
|
+
// itself so a long sentence still shows the word rather than its opening.
|
|
381
|
+
let start = prose.lastIndexOf('.', index) + 1;
|
|
382
|
+
const nextStop = prose.indexOf('.', index + matchLength);
|
|
383
|
+
let end = nextStop === -1 ? prose.length : nextStop + 1;
|
|
384
|
+
if (end - start > maxChars) {
|
|
385
|
+
const slack = Math.max(0, Math.floor((maxChars - matchLength) / 2));
|
|
386
|
+
start = Math.max(0, index - slack);
|
|
387
|
+
end = Math.min(prose.length, index + matchLength + slack);
|
|
388
|
+
}
|
|
389
|
+
return prose.slice(start, end).replace(/\s+/g, ' ').trim().slice(0, maxChars);
|
|
390
|
+
} catch {
|
|
391
|
+
return '';
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Scan `text` for a vocabulary rule's banned terms, returning hits
|
|
397
|
+
* [{word, fix, context}]. `context` is a bounded window of the prose the term
|
|
398
|
+
* matched in — see `matchContextAround`.
|
|
399
|
+
* Prose-only (code/quotes excluded); word-boundary match; case-insensitive unless the
|
|
400
|
+
* term is `caseSensitive` (abbreviations like "AC"). FAIL-SAFE: any error → [] (allow).
|
|
401
|
+
*/
|
|
402
|
+
export function vocabularyViolations(rule, text, repoRoot = DEFAULT_REPO_ROOT) {
|
|
403
|
+
try {
|
|
404
|
+
if (typeof text !== 'string' || !text) return [];
|
|
405
|
+
const { terms } = resolveVocabulary(rule, repoRoot);
|
|
406
|
+
if (!terms.length) return [];
|
|
407
|
+
const prose = proseOnly(text);
|
|
408
|
+
const hits = [];
|
|
409
|
+
for (const t of terms) {
|
|
410
|
+
const w = escapeRegexLiteral(t.word);
|
|
411
|
+
// (?<![\w-]) … (?![\w-]) is a word boundary that also excludes hyphenated
|
|
412
|
+
// compounds, so a noun-only ban on "grain" never matches "fine-grained".
|
|
413
|
+
const re = new RegExp(`(?<![\\w-])${w}(?![\\w-])`, t.caseSensitive ? '' : 'i');
|
|
414
|
+
const m = re.exec(prose);
|
|
415
|
+
if (m) {
|
|
416
|
+
hits.push({
|
|
417
|
+
word: t.word,
|
|
418
|
+
fix: t.fix,
|
|
419
|
+
context: matchContextAround(prose, m.index, m[0].length),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return hits;
|
|
424
|
+
} catch {
|
|
425
|
+
return [];
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* @param {object} [opts]
|
|
431
|
+
* @param {boolean} [opts.requireNew] Apply the managed entry's strict
|
|
432
|
+
* `newFilePattern` (file key REQUIRED — owner ruling 2026-07-10) instead of the
|
|
433
|
+
* lenient `pattern`. Callers pass true when the path is being CREATED (the
|
|
434
|
+
* target does not exist yet); existing/legacy files are validated leniently,
|
|
435
|
+
* honoring the no-backfill ruling. Entries without a `newFilePattern` fall back
|
|
436
|
+
* to `pattern`.
|
|
437
|
+
*/
|
|
438
|
+
export function namingViolation(rule, relPath, opts = {}) {
|
|
439
|
+
try {
|
|
440
|
+
if (!rule || typeof relPath !== 'string') return null;
|
|
441
|
+
const naming = rule.naming;
|
|
442
|
+
if (!naming || !Array.isArray(naming.managed)) return null;
|
|
443
|
+
if (!relPath.startsWith('specs/')) return null;
|
|
444
|
+
// Only .md files are spec entities; .json/.yaml/etc under specs/ are not.
|
|
445
|
+
if (!relPath.endsWith('.md')) return null;
|
|
446
|
+
|
|
447
|
+
// POSIX dirname/basename (relPath is already POSIX-normalized by the caller).
|
|
448
|
+
const lastSlash = relPath.lastIndexOf('/');
|
|
449
|
+
const dir = lastSlash === -1 ? '' : relPath.slice(0, lastSlash);
|
|
450
|
+
const basename = lastSlash === -1 ? relPath : relPath.slice(lastSlash + 1);
|
|
451
|
+
|
|
452
|
+
// Slug-only zones: any kebab-case .md is allowed through.
|
|
453
|
+
const slugOnly = Array.isArray(naming.slugOnlyDirs) ? naming.slugOnlyDirs : [];
|
|
454
|
+
for (const z of slugOnly) {
|
|
455
|
+
if (dir === z || dir.startsWith(z + '/')) return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Find the managed rule for this directory. A file can match BOTH a parent dir
|
|
459
|
+
// and a more-specific subdir (e.g. specs/agents AND specs/agents/golden-cases);
|
|
460
|
+
// the MOST-SPECIFIC match (longest `r.dir`) must win, else the parent rule's
|
|
461
|
+
// pattern shadows the child's. A plain `.find()` returns registry order, which
|
|
462
|
+
// mis-validates a golden-case file against the AGENT pattern when specs/agents is
|
|
463
|
+
// listed first.
|
|
464
|
+
let m = null;
|
|
465
|
+
for (const r of naming.managed) {
|
|
466
|
+
if (!r || typeof r.dir !== 'string') continue;
|
|
467
|
+
if (dir === r.dir || dir.startsWith(r.dir + '/')) {
|
|
468
|
+
if (!m || r.dir.length > m.dir.length) m = r;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (!m) return null; // unmanaged subdir under specs/ — don't block
|
|
472
|
+
|
|
473
|
+
const strictNew = opts.requireNew === true && typeof m.newFilePattern === 'string';
|
|
474
|
+
const activePattern = strictNew ? m.newFilePattern : m.pattern;
|
|
475
|
+
let re;
|
|
476
|
+
try {
|
|
477
|
+
re = new RegExp(activePattern);
|
|
478
|
+
} catch {
|
|
479
|
+
return null; // uncompilable pattern → fail-safe allow
|
|
480
|
+
}
|
|
481
|
+
if (re.test(basename)) return null; // matches the required shape → fine
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
relPath,
|
|
485
|
+
dir: m.dir,
|
|
486
|
+
kind: m.kind ?? null,
|
|
487
|
+
requiredPattern: activePattern,
|
|
488
|
+
example: m.example ?? null,
|
|
489
|
+
strictNew,
|
|
490
|
+
};
|
|
491
|
+
} catch {
|
|
492
|
+
return null; // any unexpected error → fail-safe allow
|
|
493
|
+
}
|
|
494
|
+
}
|