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,886 @@
|
|
|
1
|
+
// customer-kit.mjs — what `hs install` ships into a CUSTOMER repo, in one place.
|
|
2
|
+
//
|
|
3
|
+
// Owner decision 2026-10-02 (follow-through of
|
|
4
|
+
// [[HS-EN4F-F4V2-DIAG-a-repo-set-up-with-hs-install-gets-no]]): every customer
|
|
5
|
+
// repo gets the full HeySpex agent kit — the lifecycle hooks and the /hs-*
|
|
6
|
+
// commands. The platform's own `.claude/settings.json` registers every hook as
|
|
7
|
+
// `node "$CLAUDE_PROJECT_DIR/scripts/claude-hooks/_run.mjs" <hook>` and its
|
|
8
|
+
// commands run `node dev-factory/...` / `node bin/hs.mjs ...` repo-relative;
|
|
9
|
+
// both only resolve inside heyspex-app, so in a customer repo they failed with
|
|
10
|
+
// MODULE_NOT_FOUND — a non-blocking error, i.e. every gate silently off.
|
|
11
|
+
//
|
|
12
|
+
// PORTABLE PATHS ([[HS-0AF8-WW9R-TASK-portable-hook-and-command-paths-in]],
|
|
13
|
+
// epic [[HS-O4Y6-WW9R-EPIC-customer-kit-packaging-hooks-in-the-npm]]). The
|
|
14
|
+
// first kit (2026-10-02) addressed the shim by the platform checkout's ABSOLUTE
|
|
15
|
+
// path, which a customer then committed — on any other machine every hook was
|
|
16
|
+
// a dead command (invariant 8). Nothing written into a customer repo names
|
|
17
|
+
// this machine any more:
|
|
18
|
+
// - hooks run `node "$CLAUDE_PROJECT_DIR/.claude/heyspex/run-hook.mjs" <hook>`
|
|
19
|
+
// — a small committed LAUNCHER ({@link renderCustomerLauncher}) that finds
|
|
20
|
+
// the `heyspex` package from the repo (node_modules/heyspex first, a
|
|
21
|
+
// machine-local env override second, the global npm root last) and hands
|
|
22
|
+
// off in-process to the package's shim;
|
|
23
|
+
// - commands call `hs <verb>` or `node node_modules/heyspex/<file>`;
|
|
24
|
+
// - `.mcp.json` / `.cursor/mcp.json` point at `node_modules/heyspex` through
|
|
25
|
+
// the client's own project-dir variable (install.ts).
|
|
26
|
+
// The platform checkout is its own kit and keeps its `$CLAUDE_PROJECT_DIR/
|
|
27
|
+
// scripts/...` form ({@link samePlatformPath}). A legacy absolute row is
|
|
28
|
+
// recognized ({@link ourHookTail}) and REPAIRED in place by the next `hs
|
|
29
|
+
// install` / `hs connect [--fix-commands]`.
|
|
30
|
+
//
|
|
31
|
+
// This module is the reviewable table plus the pure helpers every consumer
|
|
32
|
+
// shares:
|
|
33
|
+
// - spex-console/src/lib/cli/install.ts (`hs install` writes the kit)
|
|
34
|
+
// - spex-console/src/lib/cli/connect-checks.ts / connect.ts (drift check +
|
|
35
|
+
// `hs connect --fix-commands` ship the same rendered commands)
|
|
36
|
+
// - scripts/install-cursor-hooks.mjs (Cursor hooks, same scope table)
|
|
37
|
+
// - scripts/heyspex-doctor.mjs (`customer-kit` check)
|
|
38
|
+
// - plugins/heyspex/hooks/hooks.json (generated: `--write-plugin-hooks`)
|
|
39
|
+
//
|
|
40
|
+
// SCOPE RULE. A hook is `customer` when it acts on the session's project
|
|
41
|
+
// (CLAUDE_PROJECT_DIR / the active vault / the MCP server's state) and makes
|
|
42
|
+
// sense in any repo HeySpex is installed in. It is `platform` when its subject
|
|
43
|
+
// is heyspex-app's own development machinery (the spex-console dev server and
|
|
44
|
+
// Neon, dev-factory seats and the COO window, the platform's env vault, the
|
|
45
|
+
// vendored MCP build) or when it still resolves its state from the platform
|
|
46
|
+
// checkout's own location instead of the project. A new hook in
|
|
47
|
+
// `.claude/settings.json` without a row here fails customer-kit.test.mjs, so
|
|
48
|
+
// the decision is always made explicitly, never by omission.
|
|
49
|
+
|
|
50
|
+
import { createHash } from 'node:crypto';
|
|
51
|
+
import fs from 'node:fs';
|
|
52
|
+
import path from 'node:path';
|
|
53
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
54
|
+
|
|
55
|
+
/** @typedef {'customer' | 'platform'} KitScope */
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One scope decision per hook name (the token after `_run.mjs`).
|
|
59
|
+
* @type {Readonly<Record<string, { scope: KitScope, why: string }>>}
|
|
60
|
+
*/
|
|
61
|
+
export const HOOK_SCOPES = Object.freeze({
|
|
62
|
+
// ── customer: vault / workflow / safety gates ─────────────────────────────
|
|
63
|
+
'wip-checkpoint': { scope: 'customer', why: 'Previous-session WIP handoff, read from the active vault.' },
|
|
64
|
+
'inject-doctrine': {
|
|
65
|
+
scope: 'customer',
|
|
66
|
+
why:
|
|
67
|
+
'Owner ruling 2026-10-03 (HS-A2PO): ships. A customer session gets the baseline edition of SpexCode Doctrine ' +
|
|
68
|
+
'(CUSTOMER_HOOK_DATA), the foundation its own learned layer is built on; never the platform\'s learned file.',
|
|
69
|
+
},
|
|
70
|
+
'check-auto-memory-budget': { scope: 'customer', why: 'Auto-memory index budget for the session project.' },
|
|
71
|
+
'check-claude-md-budget': { scope: 'customer', why: 'Budget check on the project\'s always-loaded agent contract.' },
|
|
72
|
+
'git-reflog-audit': { scope: 'customer', why: 'Session-close git audit backstop (stash/reset/rebase witness).' },
|
|
73
|
+
'preflight-reminder': { scope: 'customer', why: 'Names the required procedure before filing/destructive turns.' },
|
|
74
|
+
'intent-classifier-trigger': { scope: 'customer', why: 'Routes capture/research/settings/defer intents.' },
|
|
75
|
+
'inject-communication-style': { scope: 'customer', why: 'Plain-language communication rules each turn.' },
|
|
76
|
+
'inject-vault-context': { scope: 'customer', why: 'Lexical vault retrieval for the prompt.' },
|
|
77
|
+
'git-identity-guard': { scope: 'customer', why: 'Agents never write git identity (owner ruling 2026-09-19).' },
|
|
78
|
+
'settings-write-gate': { scope: 'customer', why: 'Approval before the vault\'s .heyspex/config.yaml changes.' },
|
|
79
|
+
'emit-claim-status': { scope: 'customer', why: 'Claim liveness heartbeat to the console.' },
|
|
80
|
+
'enforce-vault-naming': { scope: 'customer', why: 'Naming V3 filename guard (HS-RY2N).' },
|
|
81
|
+
'guard-auto-memory-writes': { scope: 'customer', why: 'No new topic files in the auto-memory directory.' },
|
|
82
|
+
'plan-review-gate': { scope: 'customer', why: 'Plan review + QC markers before ExitPlanMode.' },
|
|
83
|
+
'plan-exit-ingest-gate': { scope: 'customer', why: 'Plan ingest on ExitPlanMode; arms claim-before-code.' },
|
|
84
|
+
'claim-before-code-gate': { scope: 'customer', why: 'First code edit after plan exit needs a claim.' },
|
|
85
|
+
'capture-workflow-gate': { scope: 'customer', why: 'No implementation spec straight out of an open capture.' },
|
|
86
|
+
'git-stash-gate': { scope: 'customer', why: 'Always ask before a mutating git stash.' },
|
|
87
|
+
'research-method-guard': { scope: 'customer', why: 'Research method guard (HS-EN4F).' },
|
|
88
|
+
'deep-research-cost-gate': { scope: 'customer', why: 'Cost confirmation before a paid deep-research launch.' },
|
|
89
|
+
'research-protocol-gate': { scope: 'customer', why: 'WebSearch/WebFetch refused until research preflight ran (HS-K0ZP, HS-EN4F).' },
|
|
90
|
+
'naughty-list-gate': { scope: 'customer', why: 'Re-drive a final message that uses banned jargon.' },
|
|
91
|
+
'id-display-format-gate': { scope: 'customer', why: 'Naming V3 display-id format in replies.' },
|
|
92
|
+
'epic-milestone-phrasing-gate': { scope: 'customer', why: 'Epic/milestone phrasing rules in replies.' },
|
|
93
|
+
'enforce-session-sequence': { scope: 'customer', why: 'Re-drive a turn that shipped without record_wip.' },
|
|
94
|
+
'notify-status-flip': { scope: 'customer', why: 'Sound on an in_review status flip.' },
|
|
95
|
+
'close-terminal-on-ship': { scope: 'customer', why: 'Close the launch terminal once the spec ships.' },
|
|
96
|
+
'scripts/stamp-provenance-on-edit.mjs': { scope: 'customer', why: 'Stamp assisted_by provenance on direct spec edits (active vault).' },
|
|
97
|
+
'session-end': { scope: 'customer', why: 'Release the claim and export session events.' },
|
|
98
|
+
|
|
99
|
+
// ── platform: heyspex-app's own development machinery ─────────────────────
|
|
100
|
+
'inject-dev-server-awareness': { scope: 'platform', why: 'About the spex-console dev server in heyspex-app.' },
|
|
101
|
+
'inject-coo-seat': { scope: 'platform', why: 'dev-factory COO seat mandate.' },
|
|
102
|
+
'coo-guard-check': { scope: 'platform', why: 'dev-factory breakdown-delegation guard.' },
|
|
103
|
+
'coo-denylist-gate': { scope: 'platform', why: 'dev-factory COO window deny list.' },
|
|
104
|
+
'agent-spawn-delegation-gate': { scope: 'platform', why: 'dev-factory seat routing for subagents.' },
|
|
105
|
+
'check-mcp-freshness-hook': { scope: 'platform', why: 'Freshness of the vendored MCP build — a platform-dev concern.' },
|
|
106
|
+
'dev-server-task-reminder': { scope: 'platform', why: 'spex-console dev server + Neon DB safety.' },
|
|
107
|
+
'env-vault-guard': { scope: 'platform', why: 'Guards heyspex-app\'s encrypted env vault paths.' },
|
|
108
|
+
'log-session-activity': { scope: 'platform', why: 'Resolves .heyspex state from the platform checkout\'s own location, not the project — would log customer sessions into heyspex-app.' },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Data files (not static imports, so verify-mcp-package's hookTargets closure walk cannot
|
|
113
|
+
* see them) a customer-scoped hook reads at run time from the installed package. Each must
|
|
114
|
+
* be listed in package.json `files` and exist on disk; customer-kit.test.mjs asserts both.
|
|
115
|
+
* The inject-doctrine entry is the ONLY doctrine content that ships: the learned
|
|
116
|
+
* spexharness/doctrine/SPEXCODE-DOCTRINE.json is distilled from the owner's own sessions and
|
|
117
|
+
* must never match a `files` entry (same test).
|
|
118
|
+
* @type {Readonly<Record<string, readonly string[]>>}
|
|
119
|
+
*/
|
|
120
|
+
export const CUSTOMER_HOOK_DATA = Object.freeze({
|
|
121
|
+
'inject-doctrine': Object.freeze(['spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json']),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Every Claude Code hook registration, mirroring the platform's
|
|
126
|
+
* `.claude/settings.json` row for row (customer-kit.test.mjs asserts parity).
|
|
127
|
+
* `matcher` is the platform's own matcher; MCP tool names are widened to the
|
|
128
|
+
* plugin-scoped spelling at render time ({@link widenMcpMatcher}).
|
|
129
|
+
* @type {ReadonlyArray<{ event: string, matcher?: string, hook: string, args?: string[], timeout: number }>}
|
|
130
|
+
*/
|
|
131
|
+
export const CLAUDE_HOOK_ROWS = Object.freeze([
|
|
132
|
+
{ event: 'SessionStart', hook: 'wip-checkpoint', timeout: 5 },
|
|
133
|
+
{ event: 'SessionStart', hook: 'inject-dev-server-awareness', timeout: 5 },
|
|
134
|
+
{ event: 'SessionStart', hook: 'inject-coo-seat', timeout: 5 },
|
|
135
|
+
{ event: 'SessionStart', hook: 'inject-doctrine', timeout: 5 },
|
|
136
|
+
{ event: 'SessionStart', hook: 'coo-guard-check', timeout: 5 },
|
|
137
|
+
{ event: 'SessionStart', hook: 'check-auto-memory-budget', timeout: 5 },
|
|
138
|
+
{ event: 'SessionStart', hook: 'check-claude-md-budget', timeout: 5 },
|
|
139
|
+
{ event: 'SessionStart', hook: 'check-mcp-freshness-hook', timeout: 5 },
|
|
140
|
+
{ event: 'SessionStart', hook: 'git-reflog-audit', timeout: 5 },
|
|
141
|
+
{ event: 'PreCompact', hook: 'wip-checkpoint', timeout: 5 },
|
|
142
|
+
{ event: 'UserPromptSubmit', hook: 'preflight-reminder', timeout: 5 },
|
|
143
|
+
{ event: 'UserPromptSubmit', hook: 'intent-classifier-trigger', timeout: 5 },
|
|
144
|
+
{ event: 'UserPromptSubmit', hook: 'inject-communication-style', timeout: 5 },
|
|
145
|
+
{ event: 'UserPromptSubmit', hook: 'inject-vault-context', timeout: 10 },
|
|
146
|
+
{ event: 'PreToolUse', matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|PowerShell', hook: 'env-vault-guard', timeout: 5 },
|
|
147
|
+
{ event: 'PreToolUse', matcher: 'Bash|PowerShell', hook: 'git-identity-guard', timeout: 5 },
|
|
148
|
+
{ event: 'PreToolUse', matcher: 'Write|Edit|NotebookEdit|Bash|PowerShell|mcp__heyspex__heyspex_settings_set', hook: 'settings-write-gate', timeout: 5 },
|
|
149
|
+
{ event: 'PreToolUse', matcher: '.*', hook: 'emit-claim-status', args: ['--event=PreToolUse'], timeout: 5 },
|
|
150
|
+
{ event: 'PreToolUse', matcher: '.*', hook: 'coo-denylist-gate', timeout: 5 },
|
|
151
|
+
{ event: 'PreToolUse', matcher: 'Agent|Task', hook: 'agent-spawn-delegation-gate', timeout: 5 },
|
|
152
|
+
{ event: 'PreToolUse', matcher: 'mcp__heyspex__heyspex_claim_spec', hook: 'coo-guard-check', timeout: 5 },
|
|
153
|
+
{ event: 'PreToolUse', matcher: 'Write|NotebookEdit|Bash|PowerShell', hook: 'enforce-vault-naming', timeout: 5 },
|
|
154
|
+
{ event: 'PreToolUse', matcher: 'Write|NotebookEdit|Bash|PowerShell', hook: 'guard-auto-memory-writes', timeout: 5 },
|
|
155
|
+
{ event: 'PreToolUse', matcher: 'Edit|MultiEdit|Write|Bash|PowerShell', hook: 'preflight-reminder', timeout: 5 },
|
|
156
|
+
{ event: 'PreToolUse', matcher: 'ExitPlanMode', hook: 'plan-review-gate', timeout: 8 },
|
|
157
|
+
{ event: 'PreToolUse', matcher: 'ExitPlanMode', hook: 'plan-exit-ingest-gate', timeout: 5 },
|
|
158
|
+
{ event: 'PreToolUse', matcher: 'Edit|MultiEdit|Write', hook: 'claim-before-code-gate', timeout: 5 },
|
|
159
|
+
{ event: 'PreToolUse', matcher: 'mcp__heyspex__heyspex_file_spec', hook: 'capture-workflow-gate', timeout: 8 },
|
|
160
|
+
{ event: 'PreToolUse', matcher: 'Bash|PowerShell|mcp__neon__prepare_database_migration|mcp__neon__complete_database_migration|mcp__neon__reset_from_parent|mcp__neon__delete_branch|mcp__Neon__prepare_database_migration|mcp__Neon__complete_database_migration|mcp__Neon__reset_from_parent|mcp__Neon__delete_branch', hook: 'dev-server-task-reminder', timeout: 10 },
|
|
161
|
+
{ event: 'PreToolUse', matcher: 'Bash|PowerShell', hook: 'git-stash-gate', timeout: 10 },
|
|
162
|
+
{ event: 'PreToolUse', matcher: 'Skill|Workflow', hook: 'research-method-guard', timeout: 5 },
|
|
163
|
+
{ event: 'PreToolUse', matcher: 'Workflow|Skill', hook: 'deep-research-cost-gate', timeout: 5 },
|
|
164
|
+
{ event: 'PreToolUse', matcher: 'WebSearch|WebFetch|mcp__heyspex__heyspex_save_research|mcp__plugin_heyspex_heyspex__heyspex_save_research', hook: 'research-protocol-gate', timeout: 5 },
|
|
165
|
+
{ event: 'PreToolUse', matcher: '.*', hook: 'log-session-activity', timeout: 5 },
|
|
166
|
+
{ event: 'Stop', hook: 'emit-claim-status', args: ['--event=Stop'], timeout: 5 },
|
|
167
|
+
{ event: 'Stop', hook: 'naughty-list-gate', timeout: 5 },
|
|
168
|
+
{ event: 'Stop', hook: 'id-display-format-gate', timeout: 5 },
|
|
169
|
+
{ event: 'Stop', hook: 'epic-milestone-phrasing-gate', timeout: 5 },
|
|
170
|
+
{ event: 'Stop', hook: 'git-reflog-audit', timeout: 5 },
|
|
171
|
+
{ event: 'Stop', hook: 'enforce-session-sequence', timeout: 5 },
|
|
172
|
+
{ event: 'PostToolUse', matcher: '.*', hook: 'emit-claim-status', args: ['--event=PostToolUse'], timeout: 5 },
|
|
173
|
+
{ event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', hook: 'notify-status-flip', timeout: 5 },
|
|
174
|
+
{ event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', hook: 'close-terminal-on-ship', timeout: 5 },
|
|
175
|
+
{ event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', hook: 'scripts/stamp-provenance-on-edit.mjs', timeout: 5 },
|
|
176
|
+
{ event: 'PostToolUse', matcher: '.*', hook: 'log-session-activity', timeout: 5 },
|
|
177
|
+
{ event: 'SessionEnd', hook: 'session-end', timeout: 5 },
|
|
178
|
+
{ event: 'SessionEnd', hook: 'close-terminal-on-ship', timeout: 5 },
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* One scope decision per harness command file (`.claude/commands/<name>.md`,
|
|
183
|
+
* mirrored into `.cursor/commands/`). Only `customer` files ship.
|
|
184
|
+
* @type {Readonly<Record<string, { scope: KitScope, why: string }>>}
|
|
185
|
+
*/
|
|
186
|
+
export const COMMAND_SCOPES = Object.freeze({
|
|
187
|
+
'hs-backfill.md': { scope: 'customer', why: 'Backfill completed work into the vault.' },
|
|
188
|
+
'hs-capture.md': { scope: 'customer', why: 'Capture intake.' },
|
|
189
|
+
'hs-defer.md': { scope: 'customer', why: 'Split + defer remaining work.' },
|
|
190
|
+
'hs-doctor.md': { scope: 'customer', why: 'Install-surface repair when heyspex_* tools are missing.' },
|
|
191
|
+
'hs-ground.md': { scope: 'customer', why: 'Pre-plan grounding report.' },
|
|
192
|
+
'hs-handoff.md': { scope: 'customer', why: 'Short-code lane handoff.' },
|
|
193
|
+
'hs-next.md': { scope: 'customer', why: 'On-deck prompt + claim handshake.' },
|
|
194
|
+
'hs-promote.md': { scope: 'customer', why: 'Capture → research → milestone promotion.' },
|
|
195
|
+
'hs-push.md': { scope: 'customer', why: 'Push an epic plan into the vault.' },
|
|
196
|
+
'hs-qc.md': { scope: 'customer', why: 'Post-plan QC gate.' },
|
|
197
|
+
'hs-res.md': { scope: 'customer', why: 'Research method entrypoint (SpexCrawl/DeepSeek).' },
|
|
198
|
+
'hs-resolve.md': { scope: 'customer', why: 'Resolution lane.' },
|
|
199
|
+
'hs-review.md': { scope: 'customer', why: 'Review queue.' },
|
|
200
|
+
'hs-view.md': { scope: 'customer', why: 'Open a console surface.' },
|
|
201
|
+
'hs-auto.md': { scope: 'platform', why: 'Routes work into dev-factory (platform build system).' },
|
|
202
|
+
'hs-dev.md': { scope: 'platform', why: 'Admin/dev-only HeySpex dev/prod mode switch.' },
|
|
203
|
+
'hs-log.md': { scope: 'platform', why: 'Admin/dev-only dev-server log watcher.' },
|
|
204
|
+
'kill-ports.md': { scope: 'platform', why: 'Kills local dev-server ports.' },
|
|
205
|
+
'prototype.md': { scope: 'platform', why: 'Console design-preview prototypes inside spex-console.' },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/** @param {string} hook */
|
|
209
|
+
export function hookScope(hook) {
|
|
210
|
+
return HOOK_SCOPES[hook]?.scope ?? null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** @param {string} file basename, e.g. `hs-res.md` */
|
|
214
|
+
export function isCustomerCommand(file) {
|
|
215
|
+
return COMMAND_SCOPES[file]?.scope === 'customer';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Forward-slash form of an absolute path (node accepts it on Windows; no shell eats it). */
|
|
219
|
+
export function toPosixPath(p) {
|
|
220
|
+
return String(p).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Case-insensitive on Windows, separator-agnostic path equality. */
|
|
224
|
+
export function samePlatformPath(a, b) {
|
|
225
|
+
if (!a || !b) return false;
|
|
226
|
+
const norm = (p) => {
|
|
227
|
+
const s = toPosixPath(path.resolve(p));
|
|
228
|
+
return process.platform === 'win32' ? s.toLowerCase() : s;
|
|
229
|
+
};
|
|
230
|
+
return norm(a) === norm(b);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Claude Code hooks
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
const MCP_PROJECT_PREFIX = 'mcp__heyspex__';
|
|
238
|
+
const MCP_PLUGIN_PREFIX = 'mcp__plugin_heyspex_heyspex__';
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A project-scoped MCP tool name in a matcher also needs its plugin-scoped
|
|
242
|
+
* spelling, so the gate holds under either wiring (.mcp.json or the plugin).
|
|
243
|
+
* @param {string | undefined} matcher
|
|
244
|
+
*/
|
|
245
|
+
export function widenMcpMatcher(matcher) {
|
|
246
|
+
if (!matcher) return matcher;
|
|
247
|
+
const parts = matcher.split('|');
|
|
248
|
+
const out = [];
|
|
249
|
+
for (const part of parts) {
|
|
250
|
+
if (!out.includes(part)) out.push(part);
|
|
251
|
+
if (part.startsWith(MCP_PROJECT_PREFIX)) {
|
|
252
|
+
const plugin = MCP_PLUGIN_PREFIX + part.slice(MCP_PROJECT_PREFIX.length);
|
|
253
|
+
if (!parts.includes(plugin) && !out.includes(plugin)) out.push(plugin);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out.join('|');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The hook rows a customer repo gets. */
|
|
260
|
+
export function customerHookRows() {
|
|
261
|
+
return CLAUDE_HOOK_ROWS.filter((r) => hookScope(r.hook) === 'customer');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** `<hook> [args...]` — the identity of a registration within one event. */
|
|
265
|
+
export function hookTail(row) {
|
|
266
|
+
return [row.hook, ...(row.args ?? [])].join(' ');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// The committed launcher a customer repo carries
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
/** The Claude Code hook shim, relative to the `heyspex` package root. */
|
|
274
|
+
export const CLAUDE_HOOK_SHIM = 'scripts/claude-hooks/_run.mjs';
|
|
275
|
+
/** The Cursor hook shim, relative to the `heyspex` package root. */
|
|
276
|
+
export const CURSOR_HOOK_SHIM = 'scripts/cursor-hooks/_run.mjs';
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Where each harness's launcher lives inside a customer repo, and the
|
|
280
|
+
* project-dir variable that harness expands in a hook command.
|
|
281
|
+
* @type {Readonly<Record<'claude' | 'cursor', { rel: readonly string[], projectDirEnv: string, shim: string, registry: string }>>}
|
|
282
|
+
*/
|
|
283
|
+
export const CUSTOMER_LAUNCHERS = Object.freeze({
|
|
284
|
+
claude: Object.freeze({
|
|
285
|
+
rel: Object.freeze(['.claude', 'heyspex', 'run-hook.mjs']),
|
|
286
|
+
projectDirEnv: 'CLAUDE_PROJECT_DIR',
|
|
287
|
+
shim: CLAUDE_HOOK_SHIM,
|
|
288
|
+
registry: '.claude/settings.json',
|
|
289
|
+
}),
|
|
290
|
+
cursor: Object.freeze({
|
|
291
|
+
rel: Object.freeze(['.cursor', 'heyspex', 'run-hook.mjs']),
|
|
292
|
+
projectDirEnv: 'CURSOR_PROJECT_DIR',
|
|
293
|
+
shim: CURSOR_HOOK_SHIM,
|
|
294
|
+
registry: '.cursor/hooks.json',
|
|
295
|
+
}),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Runner prefix for a customer repo: the committed launcher, addressed from the
|
|
300
|
+
* harness's project-dir variable — never this machine's path. The platform
|
|
301
|
+
* root is accepted (and ignored) so every caller keeps its signature; the
|
|
302
|
+
* launcher file itself is what {@link renderCustomerLauncher} renders.
|
|
303
|
+
* @param {string} [_platformRoot]
|
|
304
|
+
* @param {'claude' | 'cursor'} [surface]
|
|
305
|
+
*/
|
|
306
|
+
export function customerRunnerPrefix(_platformRoot, surface = 'claude') {
|
|
307
|
+
const l = CUSTOMER_LAUNCHERS[surface];
|
|
308
|
+
return `node "$${l.projectDirEnv}/${l.rel.join('/')}"`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Runner prefix inside the plugin: its own launcher, which finds the platform. */
|
|
312
|
+
export const PLUGIN_RUNNER_PREFIX = 'node "${CLAUDE_PLUGIN_ROOT}/bin/run-hook.mjs"';
|
|
313
|
+
|
|
314
|
+
// The launcher is kept as ONE template string (not a sibling file) so the
|
|
315
|
+
// bundled CLI (`dist/cli/*.mjs`, built from install.ts) carries it by
|
|
316
|
+
// construction — a file read relative to this module would miss once bundled.
|
|
317
|
+
// `__TOKENS__` are substituted per surface; no backticks inside, on purpose.
|
|
318
|
+
const LAUNCHER_TEMPLATE = [
|
|
319
|
+
'#!/usr/bin/env node',
|
|
320
|
+
'// HeySpex hook launcher -- GENERATED by hs install / hs connect. Commit it with this repo.',
|
|
321
|
+
'//',
|
|
322
|
+
'// Every HeySpex hook registered in __REGISTRY__ runs',
|
|
323
|
+
'// node "$__ENV__/__LAUNCHER__" <hook> [args]',
|
|
324
|
+
'// This file carries NO machine-specific path. It finds the heyspex package this',
|
|
325
|
+
'// repo depends on (node_modules/heyspex) and hands off in-process to the',
|
|
326
|
+
'// package\'s hook shim (__SHIM__), so the same committed command works on every',
|
|
327
|
+
'// machine the repo is cloned to.',
|
|
328
|
+
'//',
|
|
329
|
+
'// Resolution order (the first root that carries the shim wins):',
|
|
330
|
+
'// 1. HEYSPEX_PLATFORM_ROOT explicit machine-local override',
|
|
331
|
+
'// 2. HEYSPEX_MCP_ENTRY -> its package root machine-local override',
|
|
332
|
+
'// 3. node_modules/heyspex, walking up from this file (the repo-local dependency)',
|
|
333
|
+
'// 4. node_modules/heyspex up from $__ENV__, $CLAUDE_PROJECT_DIR, $CURSOR_PROJECT_DIR, cwd',
|
|
334
|
+
'// 5. <npm root -g>/heyspex a global install, last resort',
|
|
335
|
+
'// Nothing found -> one stderr line and exit 0 (fail-open, like the shim itself).',
|
|
336
|
+
"import { spawnSync } from 'node:child_process';",
|
|
337
|
+
"import { existsSync } from 'node:fs';",
|
|
338
|
+
"import { dirname, resolve } from 'node:path';",
|
|
339
|
+
"import { fileURLToPath, pathToFileURL } from 'node:url';",
|
|
340
|
+
'',
|
|
341
|
+
"const SHIM = '__SHIM__'.split('/');",
|
|
342
|
+
'const here = dirname(fileURLToPath(import.meta.url));',
|
|
343
|
+
'const env = process.env;',
|
|
344
|
+
'const text = (v) => (typeof v === \'string\' && v.trim() ? v.trim() : null);',
|
|
345
|
+
'const hasShim = (root) => Boolean(root) && existsSync(resolve(root, ...SHIM));',
|
|
346
|
+
'',
|
|
347
|
+
'function packageUpFrom(start) {',
|
|
348
|
+
' let dir = resolve(start);',
|
|
349
|
+
' for (;;) {',
|
|
350
|
+
" const candidate = resolve(dir, 'node_modules', 'heyspex');",
|
|
351
|
+
' if (hasShim(candidate)) return candidate;',
|
|
352
|
+
' const parent = dirname(dir);',
|
|
353
|
+
' if (parent === dir) return null;',
|
|
354
|
+
' dir = parent;',
|
|
355
|
+
' }',
|
|
356
|
+
'}',
|
|
357
|
+
'',
|
|
358
|
+
'function globalPackage() {',
|
|
359
|
+
' try {',
|
|
360
|
+
' // One command string through the shell: npm is a .cmd on Windows, and a',
|
|
361
|
+
' // shell + args array is what node deprecates (DEP0190).',
|
|
362
|
+
" const res = spawnSync('npm root -g', { encoding: 'utf8', timeout: 5000, shell: true });",
|
|
363
|
+
" const root = String(res.stdout ?? '').trim();",
|
|
364
|
+
" return root ? resolve(root, 'heyspex') : null;",
|
|
365
|
+
' } catch {',
|
|
366
|
+
' return null;',
|
|
367
|
+
' }',
|
|
368
|
+
'}',
|
|
369
|
+
'',
|
|
370
|
+
'const mcpEntry = text(env.HEYSPEX_MCP_ENTRY);',
|
|
371
|
+
'const starts = [...new Set([here, text(env.__ENV__), text(env.CLAUDE_PROJECT_DIR), text(env.CURSOR_PROJECT_DIR), process.cwd()].filter(Boolean))];',
|
|
372
|
+
'let root =',
|
|
373
|
+
" [text(env.HEYSPEX_PLATFORM_ROOT), mcpEntry && resolve(mcpEntry, '..', '..', '..')].find(hasShim) ??",
|
|
374
|
+
' starts.map(packageUpFrom).find(Boolean) ??',
|
|
375
|
+
' null;',
|
|
376
|
+
'if (!root) {',
|
|
377
|
+
' const global = globalPackage();',
|
|
378
|
+
' if (hasShim(global)) root = global;',
|
|
379
|
+
'}',
|
|
380
|
+
'',
|
|
381
|
+
'if (!root) {',
|
|
382
|
+
' process.stderr.write(',
|
|
383
|
+
" '[heyspex] hook ' + (process.argv[2] ?? '?') + ' skipped: no heyspex package found ' +",
|
|
384
|
+
" '(run \"npm install heyspex\" in this repo, or set HEYSPEX_PLATFORM_ROOT). Looked for __SHIM__ under ' +",
|
|
385
|
+
" 'HEYSPEX_PLATFORM_ROOT, HEYSPEX_MCP_ENTRY, node_modules/heyspex up from ' + starts.join(', ') + ', and npm root -g.\\n',",
|
|
386
|
+
' );',
|
|
387
|
+
' process.exit(0);',
|
|
388
|
+
'}',
|
|
389
|
+
'',
|
|
390
|
+
'// argv is already [node, <this file>, <hook>, ...args] -- the shape the shim',
|
|
391
|
+
'// reads; the shim path replaces this file so a shim guarded by "am I main?"',
|
|
392
|
+
'// (the Cursor one) runs exactly as if Cursor had invoked it directly.',
|
|
393
|
+
'const shim = resolve(root, ...SHIM);',
|
|
394
|
+
'process.argv[1] = shim;',
|
|
395
|
+
'await import(pathToFileURL(shim).href);',
|
|
396
|
+
'',
|
|
397
|
+
].join('\n');
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The launcher file `hs install` / `hs connect` write into a customer repo
|
|
401
|
+
* (`.claude/heyspex/run-hook.mjs`; the Cursor copy at
|
|
402
|
+
* `.cursor/heyspex/run-hook.mjs`). Pure: a string, no machine path in it.
|
|
403
|
+
* @param {'claude' | 'cursor'} [surface]
|
|
404
|
+
*/
|
|
405
|
+
export function renderCustomerLauncher(surface = 'claude') {
|
|
406
|
+
const l = CUSTOMER_LAUNCHERS[surface];
|
|
407
|
+
return LAUNCHER_TEMPLATE.replace(/__(REGISTRY|ENV|LAUNCHER|SHIM)__/g, (_m, token) =>
|
|
408
|
+
token === 'REGISTRY' ? l.registry : token === 'ENV' ? l.projectDirEnv : token === 'LAUNCHER' ? l.rel.join('/') : l.shim,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Whether the launcher on disk needs (re)writing. EOL-insensitive: git's
|
|
414
|
+
* autocrlf may check the committed copy out with CRLF.
|
|
415
|
+
* @param {string | null | undefined} existing
|
|
416
|
+
* @param {'claude' | 'cursor'} [surface]
|
|
417
|
+
* @returns {{ write: boolean, reason: 'missing' | 'unchanged' | 'update', text: string }}
|
|
418
|
+
*/
|
|
419
|
+
export function planCustomerLauncherWrite(existing, surface = 'claude') {
|
|
420
|
+
const text = renderCustomerLauncher(surface);
|
|
421
|
+
if (existing === null || existing === undefined) return { write: true, reason: 'missing', text };
|
|
422
|
+
if (String(existing).replace(/\r\n/g, '\n') === text) return { write: false, reason: 'unchanged', text };
|
|
423
|
+
return { write: true, reason: 'update', text };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* A HeySpex hook command that still names a machine path: a quoted script
|
|
428
|
+
* that starts with a drive letter or a root slash rather than a `$VAR`. The
|
|
429
|
+
* legacy 2026-10-02 kit form; what `hs doctor` reports as a stale path and
|
|
430
|
+
* what the next install/connect repairs.
|
|
431
|
+
* @param {unknown} command
|
|
432
|
+
*/
|
|
433
|
+
export function isAbsoluteHookCommand(command) {
|
|
434
|
+
if (typeof command !== 'string' || ourHookTail(command) === null) return false;
|
|
435
|
+
return /^\s*node\s+"(?:[A-Za-z]:)?[\\/](?!\$)/.test(command) || /^\s*node\s+(?:[A-Za-z]:)?[\\/]\S*(?:_run|run-hook)\.mjs/.test(command);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* `event: <hook> [args]` for every HeySpex row in a parsed settings object
|
|
440
|
+
* whose command is still an absolute machine path.
|
|
441
|
+
* @param {Record<string, unknown>} settings
|
|
442
|
+
* @returns {string[]}
|
|
443
|
+
*/
|
|
444
|
+
export function legacyAbsoluteHookRows(settings) {
|
|
445
|
+
const out = [];
|
|
446
|
+
const hooks = settings?.hooks && typeof settings.hooks === 'object' ? settings.hooks : {};
|
|
447
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
448
|
+
if (!Array.isArray(groups)) continue;
|
|
449
|
+
for (const group of groups) {
|
|
450
|
+
for (const h of Array.isArray(group?.hooks) ? group.hooks : []) {
|
|
451
|
+
if (isAbsoluteHookCommand(h?.command)) out.push(`${event}: ${ourHookTail(h.command)}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Desired registrations for a runner prefix.
|
|
460
|
+
* @param {string} runnerPrefix
|
|
461
|
+
* @returns {{ event: string, matcher?: string, command: string, timeout: number, tail: string }[]}
|
|
462
|
+
*/
|
|
463
|
+
export function renderCustomerHooks(runnerPrefix) {
|
|
464
|
+
return customerHookRows().map((row) => ({
|
|
465
|
+
event: row.event,
|
|
466
|
+
...(row.matcher ? { matcher: widenMcpMatcher(row.matcher) } : {}),
|
|
467
|
+
command: `${runnerPrefix} ${hookTail(row)}`,
|
|
468
|
+
timeout: row.timeout,
|
|
469
|
+
tail: hookTail(row),
|
|
470
|
+
}));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Ours = any command routed through a HeySpex runner: the platform shim
|
|
475
|
+
* (`scripts/claude-hooks/_run.mjs`, by `$CLAUDE_PROJECT_DIR` or a legacy
|
|
476
|
+
* absolute path), the plugin launcher (`bin/run-hook.mjs`) or the committed
|
|
477
|
+
* customer launcher (`.claude/heyspex/run-hook.mjs`).
|
|
478
|
+
*/
|
|
479
|
+
const OUR_RUNNER_RE = /(?:scripts\/claude-hooks\/_run\.mjs|(?:bin|heyspex)\/run-hook\.mjs)"?\s+(.+)$/;
|
|
480
|
+
|
|
481
|
+
/** The `<hook> [args]` tail of one of our commands, or null for a foreign one. */
|
|
482
|
+
export function ourHookTail(command) {
|
|
483
|
+
if (typeof command !== 'string') return null;
|
|
484
|
+
const m = command.replace(/\\/g, '/').match(OUR_RUNNER_RE);
|
|
485
|
+
return m ? m[1].trim() : null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Merge the customer hook kit into a parsed `.claude/settings.json` object, in
|
|
490
|
+
* place. Merge-not-clobber: other keys, other events and foreign hooks
|
|
491
|
+
* survive untouched. An existing HeySpex entry (any `_run.mjs`/`run-hook.mjs`
|
|
492
|
+
* command: an older platform path, a copied `$CLAUDE_PROJECT_DIR` form) is
|
|
493
|
+
* REPAIRED in place when it matches a kit row, and removed when it does not
|
|
494
|
+
* (a platform-only hook, a duplicate, a retired hook) — never duplicated.
|
|
495
|
+
* Idempotent: a second call reports no change.
|
|
496
|
+
*
|
|
497
|
+
* @param {Record<string, unknown>} settings
|
|
498
|
+
* @param {string} runnerPrefix e.g. {@link customerRunnerPrefix}(platformRoot)
|
|
499
|
+
* @returns {{ changed: boolean, added: string[], repaired: string[], removed: string[], total: number }}
|
|
500
|
+
*/
|
|
501
|
+
export function mergeCustomerKitHooks(settings, runnerPrefix) {
|
|
502
|
+
const desired = renderCustomerHooks(runnerPrefix);
|
|
503
|
+
const hooks =
|
|
504
|
+
settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
|
|
505
|
+
? /** @type {Record<string, unknown>} */ (settings.hooks)
|
|
506
|
+
: {};
|
|
507
|
+
const claimed = new Set();
|
|
508
|
+
const added = [];
|
|
509
|
+
const repaired = [];
|
|
510
|
+
const removed = [];
|
|
511
|
+
const key = (event, tail) => `${event}::${tail}`;
|
|
512
|
+
const desiredByKey = new Map(desired.map((d) => [key(d.event, d.tail), d]));
|
|
513
|
+
|
|
514
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
515
|
+
if (!Array.isArray(groups)) continue;
|
|
516
|
+
const keptGroups = [];
|
|
517
|
+
for (const group of groups) {
|
|
518
|
+
if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) {
|
|
519
|
+
keptGroups.push(group);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const before = group.hooks.length;
|
|
523
|
+
const keptHooks = [];
|
|
524
|
+
for (const h of group.hooks) {
|
|
525
|
+
const tail = ourHookTail(h?.command);
|
|
526
|
+
if (tail === null) {
|
|
527
|
+
keptHooks.push(h);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const k = key(event, tail);
|
|
531
|
+
const want = desiredByKey.get(k);
|
|
532
|
+
const matcherOk = want && (group.matcher ?? '') === (want.matcher ?? '');
|
|
533
|
+
if (!want || claimed.has(k) || !matcherOk) {
|
|
534
|
+
removed.push(`${event}: ${tail}`);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
claimed.add(k);
|
|
538
|
+
if (h.command !== want.command || h.timeout !== want.timeout || h.type !== 'command') {
|
|
539
|
+
h.type = 'command';
|
|
540
|
+
h.command = want.command;
|
|
541
|
+
h.timeout = want.timeout;
|
|
542
|
+
repaired.push(`${event}: ${tail}`);
|
|
543
|
+
}
|
|
544
|
+
keptHooks.push(h);
|
|
545
|
+
}
|
|
546
|
+
group.hooks = keptHooks;
|
|
547
|
+
// Drop a group only when THIS merge emptied it.
|
|
548
|
+
if (keptHooks.length > 0 || before === 0) keptGroups.push(group);
|
|
549
|
+
}
|
|
550
|
+
hooks[event] = keptGroups;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
for (const d of desired) {
|
|
554
|
+
const k = key(d.event, d.tail);
|
|
555
|
+
if (claimed.has(k)) continue;
|
|
556
|
+
const list = Array.isArray(hooks[d.event]) ? /** @type {unknown[]} */ (hooks[d.event]) : [];
|
|
557
|
+
list.push({
|
|
558
|
+
...(d.matcher ? { matcher: d.matcher } : {}),
|
|
559
|
+
hooks: [{ type: 'command', command: d.command, timeout: d.timeout }],
|
|
560
|
+
});
|
|
561
|
+
hooks[d.event] = list;
|
|
562
|
+
added.push(`${d.event}: ${d.tail}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const changed = added.length + repaired.length + removed.length > 0;
|
|
566
|
+
if (changed) settings.hooks = hooks;
|
|
567
|
+
return { changed, added, repaired, removed, total: desired.length };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** The plugin's hooks.json document, generated from the same rows. */
|
|
571
|
+
export function renderPluginHooksDocument() {
|
|
572
|
+
const hooks = {};
|
|
573
|
+
for (const d of renderCustomerHooks(PLUGIN_RUNNER_PREFIX)) {
|
|
574
|
+
(hooks[d.event] ??= []).push({
|
|
575
|
+
...(d.matcher ? { matcher: d.matcher } : {}),
|
|
576
|
+
hooks: [{ type: 'command', command: d.command, timeout: d.timeout }],
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
_comment:
|
|
581
|
+
'GENERATED from scripts/lib/customer-kit.mjs (customer-scoped rows only) — do not edit by hand; ' +
|
|
582
|
+
'run `node scripts/lib/customer-kit.mjs --write-plugin-hooks`. Every command goes through ' +
|
|
583
|
+
'bin/run-hook.mjs, which finds the hook runtime (HEYSPEX_PLATFORM_ROOT / HEYSPEX_MCP_ENTRY when set ' +
|
|
584
|
+
'in the machine\'s own env, then the plugin\'s own location, node_modules/heyspex, ' +
|
|
585
|
+
'$CLAUDE_PROJECT_DIR) — never $CLAUDE_PROJECT_DIR/scripts, which only exists in heyspex-app. ' +
|
|
586
|
+
'WARNING: a repo whose .claude/settings.json already carries the hs install kit hooks runs every ' +
|
|
587
|
+
'hook twice if this plugin is also enabled — use one or the other.',
|
|
588
|
+
hooks,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function renderPluginHooksText() {
|
|
593
|
+
return `${JSON.stringify(renderPluginHooksDocument(), null, 2)}\n`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
// Harness commands (.claude/commands, .cursor/commands)
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* The two harness-command trees `scripts/sync-skills.mjs` generates. Each
|
|
602
|
+
* names the INSTALLED tree (what a target repo carries) and, in priority
|
|
603
|
+
* order, where its CANONICAL content is read from: the packaged mirror
|
|
604
|
+
* (`dist/cli-templates/harness-commands/**`, a published install) then the
|
|
605
|
+
* platform checkout's own tree (a dev checkout before the first build).
|
|
606
|
+
* Re-exported by connect-checks.ts — one definition.
|
|
607
|
+
*/
|
|
608
|
+
export const HARNESS_COMMAND_SURFACES = Object.freeze([
|
|
609
|
+
{
|
|
610
|
+
installedDir: Object.freeze(['.claude', 'commands']),
|
|
611
|
+
packagedDir: Object.freeze(['dist', 'cli-templates', 'harness-commands', 'claude']),
|
|
612
|
+
devSourceDir: Object.freeze(['.claude', 'commands']),
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
installedDir: Object.freeze(['.cursor', 'commands']),
|
|
616
|
+
packagedDir: Object.freeze(['dist', 'cli-templates', 'harness-commands', 'cursor']),
|
|
617
|
+
devSourceDir: Object.freeze(['.cursor', 'commands']),
|
|
618
|
+
},
|
|
619
|
+
]);
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Directory holding the canonical content for one surface, or null.
|
|
623
|
+
* @param {string} platformRoot
|
|
624
|
+
* @param {(typeof HARNESS_COMMAND_SURFACES)[number]} surface
|
|
625
|
+
* @param {{ joinPath: (...s: string[]) => string, listDir: (dir: string) => string[] }} deps
|
|
626
|
+
* @returns {string | null}
|
|
627
|
+
*/
|
|
628
|
+
export function resolveCanonicalCommandDir(platformRoot, surface, deps) {
|
|
629
|
+
const hasMd = (dir) => deps.listDir(dir).some((f) => f.endsWith('.md'));
|
|
630
|
+
const packaged = deps.joinPath(platformRoot, ...surface.packagedDir);
|
|
631
|
+
if (hasMd(packaged)) return packaged;
|
|
632
|
+
const devSource = deps.joinPath(platformRoot, ...surface.devSourceDir);
|
|
633
|
+
if (hasMd(devSource)) return devSource;
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Launcher aliases: npm scripts that have an `hs` verb. */
|
|
638
|
+
const NPM_ALIASES = Object.freeze({ doctor: 'doctor', setup: 'doctor --fix' });
|
|
639
|
+
const REPO_DIRS = 'bin|scripts|dev-factory|vendor|spexharness';
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Platform scripts the shipped commands invoke directly that have an `hs`
|
|
643
|
+
* verb (`bin/hs.mjs` SPAWN_TARGETS) — the verb is what a customer runs.
|
|
644
|
+
*/
|
|
645
|
+
const SCRIPT_VERBS = Object.freeze({
|
|
646
|
+
'scripts/heyspex-doctor.mjs': 'doctor',
|
|
647
|
+
'scripts/hs-view.mjs': 'view',
|
|
648
|
+
'scripts/hs-capture-cli.mjs': 'capture',
|
|
649
|
+
'scripts/hs-next.mjs': 'next',
|
|
650
|
+
'scripts/hs-mode.mjs': 'mode',
|
|
651
|
+
'scripts/hs-settings.mjs': 'settings',
|
|
652
|
+
'scripts/hs-companion.mjs': 'companion',
|
|
653
|
+
'scripts/wip-ledger-health.mjs': 'wip',
|
|
654
|
+
'scripts/dev-server-status.mjs': 'server status',
|
|
655
|
+
'scripts/dev-server-stop.mjs': 'server stop',
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
/** Where the `heyspex` package sits in a customer repo, repo-relative. */
|
|
659
|
+
export const PORTABLE_PACKAGE_DIR = 'node_modules/heyspex';
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Rewrite repo-relative platform invocations to PORTABLE ones — forms that
|
|
663
|
+
* resolve from the customer repo on any machine, never this machine's path:
|
|
664
|
+
* `node bin/hs.mjs <args>` → `hs <args>`
|
|
665
|
+
* `node scripts/heyspex-doctor.mjs`→ `hs doctor` (SCRIPT_VERBS)
|
|
666
|
+
* `node dev-factory/x.mjs` → `node node_modules/heyspex/dev-factory/x.mjs`
|
|
667
|
+
* `npm run doctor|setup` → `hs doctor [--fix]`
|
|
668
|
+
* `npm run <script>` → `npm --prefix node_modules/heyspex run <script>`
|
|
669
|
+
* Prose mentions of a path (no `node ` in front) are left alone. The platform
|
|
670
|
+
* root is accepted (and ignored) so every caller keeps its signature.
|
|
671
|
+
* @param {string} content
|
|
672
|
+
* @param {string} [_platformRoot]
|
|
673
|
+
*/
|
|
674
|
+
export function rewritePlatformPaths(content, _platformRoot) {
|
|
675
|
+
return String(content)
|
|
676
|
+
.replace(/\bnode bin\/hs\.mjs\b/g, 'hs')
|
|
677
|
+
.replace(new RegExp(`\\bnode (${REPO_DIRS})/([\\w./-]+\\.m?js)\\b`, 'g'), (_m, dir, rest) => {
|
|
678
|
+
const rel = `${dir}/${rest}`;
|
|
679
|
+
return SCRIPT_VERBS[rel] ? `hs ${SCRIPT_VERBS[rel]}` : `node ${PORTABLE_PACKAGE_DIR}/${rel}`;
|
|
680
|
+
})
|
|
681
|
+
.replace(/\bnpm run ([\w:.-]+)/g, (_m, script) =>
|
|
682
|
+
NPM_ALIASES[script] ? `hs ${NPM_ALIASES[script]}` : `npm --prefix ${PORTABLE_PACKAGE_DIR} run ${script}`,
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const MARKER_RE = /\n<!-- heyspex-kit: managed by hs install \(sha256:([0-9a-f]{16})\)[^\n]*-->\n?$/;
|
|
687
|
+
|
|
688
|
+
function shortHash(text) {
|
|
689
|
+
return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* The customer copy of a canonical command: platform paths made portable,
|
|
694
|
+
* then a trailing marker carrying the hash of the body. The marker is how a
|
|
695
|
+
* re-run tells "ours, untouched, safe to update" from "the customer edited
|
|
696
|
+
* it, leave it alone".
|
|
697
|
+
*/
|
|
698
|
+
export function renderKitCommand(content, platformRoot) {
|
|
699
|
+
const body = `${rewritePlatformPaths(content, platformRoot).replace(/\s+$/, '')}\n`;
|
|
700
|
+
return (
|
|
701
|
+
`${body}\n<!-- heyspex-kit: managed by hs install (sha256:${shortHash(body)}). ` +
|
|
702
|
+
'Edit freely: once edited, hs install leaves this file alone. -->\n'
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* @param {string} content
|
|
708
|
+
* @returns {'managed' | 'customized' | 'unmanaged'}
|
|
709
|
+
*/
|
|
710
|
+
export function kitFileState(content) {
|
|
711
|
+
const m = String(content).match(MARKER_RE);
|
|
712
|
+
if (!m) return 'unmanaged';
|
|
713
|
+
const body = String(content).slice(0, m.index).replace(/\n$/, '');
|
|
714
|
+
return shortHash(`${body}\n`) === m[1] ? 'managed' : 'customized';
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Whether to write one kit file. `raw` is the canonical (unrendered) content:
|
|
719
|
+
* an existing file equal to it is an old verbatim copy (`hs connect
|
|
720
|
+
* --fix-commands` before this change) and is safe to replace.
|
|
721
|
+
* @returns {{ write: boolean, reason: 'missing' | 'unchanged' | 'update' | 'legacy-copy' | 'customized' | 'forced' }}
|
|
722
|
+
*/
|
|
723
|
+
export function planKitFileWrite({ existing, rendered, raw, force }) {
|
|
724
|
+
if (existing === null || existing === undefined) return { write: true, reason: 'missing' };
|
|
725
|
+
if (existing === rendered) return { write: false, reason: 'unchanged' };
|
|
726
|
+
if (kitFileState(existing) === 'managed') return { write: true, reason: 'update' };
|
|
727
|
+
if (existing === raw) return { write: true, reason: 'legacy-copy' };
|
|
728
|
+
return force ? { write: true, reason: 'forced' } : { write: false, reason: 'customized' };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Every canonical harness command file, as `(installedDir, file, content,
|
|
733
|
+
* raw)`. With `targetRoot` set to a repo other than the platform, the list is
|
|
734
|
+
* filtered to customer-scoped commands and `content` is the rendered customer
|
|
735
|
+
* copy; otherwise `content === raw` (the platform's own trees).
|
|
736
|
+
* @param {string} platformRoot
|
|
737
|
+
* @param {{ joinPath: (...s: string[]) => string, readFile: (p: string) => string | null, listDir: (dir: string) => string[] }} deps
|
|
738
|
+
* @param {{ targetRoot?: string, isPlatformTarget?: boolean }} [opts]
|
|
739
|
+
* @returns {{ installedDir: readonly string[], file: string, content: string, raw: string }[]}
|
|
740
|
+
*/
|
|
741
|
+
export function listKitCommandFiles(platformRoot, deps, opts = {}) {
|
|
742
|
+
const customer =
|
|
743
|
+
opts.targetRoot !== undefined &&
|
|
744
|
+
!(opts.isPlatformTarget ?? samePlatformPath(opts.targetRoot, platformRoot));
|
|
745
|
+
const out = [];
|
|
746
|
+
for (const surface of HARNESS_COMMAND_SURFACES) {
|
|
747
|
+
const dir = resolveCanonicalCommandDir(platformRoot, surface, deps);
|
|
748
|
+
if (!dir) continue;
|
|
749
|
+
const files = deps
|
|
750
|
+
.listDir(dir)
|
|
751
|
+
.filter((f) => f.endsWith('.md'))
|
|
752
|
+
.sort();
|
|
753
|
+
for (const file of files) {
|
|
754
|
+
if (customer && !isCustomerCommand(file)) continue;
|
|
755
|
+
const raw = deps.readFile(deps.joinPath(dir, file));
|
|
756
|
+
if (raw === null) continue;
|
|
757
|
+
out.push({
|
|
758
|
+
installedDir: surface.installedDir,
|
|
759
|
+
file,
|
|
760
|
+
raw,
|
|
761
|
+
content: customer ? renderKitCommand(raw, platformRoot) : raw,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return out;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ---------------------------------------------------------------------------
|
|
769
|
+
// Audit (hs doctor)
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
|
|
772
|
+
const fsDeps = {
|
|
773
|
+
joinPath: (...s) => path.join(...s),
|
|
774
|
+
readFile: (p) => {
|
|
775
|
+
try {
|
|
776
|
+
return fs.readFileSync(p, 'utf8');
|
|
777
|
+
} catch {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
},
|
|
781
|
+
listDir: (d) => {
|
|
782
|
+
try {
|
|
783
|
+
return fs.readdirSync(d);
|
|
784
|
+
} catch {
|
|
785
|
+
return [];
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* What a customer repo is missing or carries stale, without writing anything.
|
|
792
|
+
* @param {{ targetRoot: string, platformRoot: string, deps?: typeof fsDeps }} opts
|
|
793
|
+
*/
|
|
794
|
+
export function auditCustomerKit({ targetRoot, platformRoot, deps = fsDeps }) {
|
|
795
|
+
const report = {
|
|
796
|
+
settingsError: null,
|
|
797
|
+
/** `stalePaths`: rows still on the legacy absolute machine path (a subset of `repaired`). */
|
|
798
|
+
hooks: { added: [], repaired: [], removed: [], stalePaths: [], total: 0 },
|
|
799
|
+
/** The committed launcher the hook rows point at. */
|
|
800
|
+
launcher: { path: [...CUSTOMER_LAUNCHERS.claude.rel].join('/'), state: 'missing' },
|
|
801
|
+
commands: { missing: [], stale: [], customized: [] },
|
|
802
|
+
};
|
|
803
|
+
const settingsPath = deps.joinPath(targetRoot, '.claude', 'settings.json');
|
|
804
|
+
const raw = deps.readFile(settingsPath);
|
|
805
|
+
let settings = {};
|
|
806
|
+
if (raw !== null) {
|
|
807
|
+
try {
|
|
808
|
+
const parsed = JSON.parse(raw);
|
|
809
|
+
settings = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
810
|
+
} catch (err) {
|
|
811
|
+
report.settingsError = `${settingsPath}: ${err instanceof Error ? err.message : String(err)}`;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
if (!report.settingsError) {
|
|
815
|
+
const stalePaths = legacyAbsoluteHookRows(settings);
|
|
816
|
+
const r = mergeCustomerKitHooks(structuredClone(settings), customerRunnerPrefix(platformRoot));
|
|
817
|
+
report.hooks = { added: r.added, repaired: r.repaired, removed: r.removed, stalePaths, total: r.total };
|
|
818
|
+
}
|
|
819
|
+
const launcherPlan = planCustomerLauncherWrite(
|
|
820
|
+
deps.readFile(deps.joinPath(targetRoot, ...CUSTOMER_LAUNCHERS.claude.rel)),
|
|
821
|
+
);
|
|
822
|
+
report.launcher.state = launcherPlan.reason === 'update' ? 'stale' : launcherPlan.reason === 'missing' ? 'missing' : 'current';
|
|
823
|
+
for (const f of listKitCommandFiles(platformRoot, deps, { targetRoot, isPlatformTarget: false })) {
|
|
824
|
+
if (f.installedDir[0] !== '.claude') continue; // .cursor is the cursor-hooks/connect checks' business
|
|
825
|
+
const rel = [...f.installedDir, f.file].join('/');
|
|
826
|
+
const existing = deps.readFile(deps.joinPath(targetRoot, ...f.installedDir, f.file));
|
|
827
|
+
const plan = planKitFileWrite({ existing, rendered: f.content, raw: f.raw, force: false });
|
|
828
|
+
if (plan.reason === 'missing') report.commands.missing.push(rel);
|
|
829
|
+
else if (plan.reason === 'update' || plan.reason === 'legacy-copy') report.commands.stale.push(rel);
|
|
830
|
+
else if (plan.reason === 'customized') report.commands.customized.push(rel);
|
|
831
|
+
}
|
|
832
|
+
return report;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ---------------------------------------------------------------------------
|
|
836
|
+
// CLI: regenerate plugins/heyspex/hooks/hooks.json
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* "Was THIS module the script node was started with?" — bundle-safe.
|
|
841
|
+
*
|
|
842
|
+
* The plain comparison every script in this repo used (`import.meta.url ===
|
|
843
|
+
* pathToFileURL(argv[1])`) is wrong inside the esbuild CLI bundle
|
|
844
|
+
* (`dist/cli/*.mjs`, scripts/build-cli-bundle.mjs): there `import.meta.url`
|
|
845
|
+
* is the BUNDLE file's url for every module in it, and the bundle IS argv[1],
|
|
846
|
+
* so the test reads true for all of them at once and this file's CLI entry
|
|
847
|
+
* hijacked `hs connect --check` (it printed the usage line and no report).
|
|
848
|
+
* Requiring the module's own basename in the url rules the bundle out: a
|
|
849
|
+
* bundle is named after its entry (`hs-connect.mjs`), never after a library.
|
|
850
|
+
* Pure (every input injectable) so the three shapes are unit-tested.
|
|
851
|
+
*
|
|
852
|
+
* @param {string} importMetaUrl The module's `import.meta.url`.
|
|
853
|
+
* @param {string} selfBasename The module's own file name, e.g. `customer-kit.mjs`.
|
|
854
|
+
* @param {string | undefined} [argv1] `process.argv[1]` (default: the real one).
|
|
855
|
+
*/
|
|
856
|
+
export function invokedDirectly(importMetaUrl, selfBasename, argv1 = process.argv[1]) {
|
|
857
|
+
try {
|
|
858
|
+
if (!argv1) return false;
|
|
859
|
+
const self = fileURLToPath(importMetaUrl);
|
|
860
|
+
if (path.basename(self) !== selfBasename) return false;
|
|
861
|
+
return pathToFileURL(path.resolve(argv1)).href === pathToFileURL(self).href;
|
|
862
|
+
} catch {
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
868
|
+
if (invokedDirectly(import.meta.url, 'customer-kit.mjs')) {
|
|
869
|
+
const platformRoot = path.resolve(path.dirname(SELF), '..', '..');
|
|
870
|
+
const target = path.join(platformRoot, 'plugins', 'heyspex', 'hooks', 'hooks.json');
|
|
871
|
+
const want = renderPluginHooksText();
|
|
872
|
+
// EOL-insensitive: git's autocrlf may check the file out with CRLF.
|
|
873
|
+
const same = (fsDeps.readFile(target) ?? '').replace(/\r\n/g, '\n') === want;
|
|
874
|
+
if (process.argv.includes('--write-plugin-hooks')) {
|
|
875
|
+
if (!same) fs.writeFileSync(target, want, 'utf8');
|
|
876
|
+
console.log(`customer-kit: ${same ? 'unchanged' : 'wrote'} ${path.relative(platformRoot, target)}`);
|
|
877
|
+
} else if (process.argv.includes('--check-plugin-hooks')) {
|
|
878
|
+
if (!same) {
|
|
879
|
+
console.error('customer-kit: plugins/heyspex/hooks/hooks.json is stale — run `node scripts/lib/customer-kit.mjs --write-plugin-hooks`');
|
|
880
|
+
process.exit(1);
|
|
881
|
+
}
|
|
882
|
+
console.log('customer-kit: plugins/heyspex/hooks/hooks.json up to date');
|
|
883
|
+
} else {
|
|
884
|
+
console.log('usage: node scripts/lib/customer-kit.mjs --write-plugin-hooks | --check-plugin-hooks');
|
|
885
|
+
}
|
|
886
|
+
}
|