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,575 @@
|
|
|
1
|
+
// Shared claim-liveness helpers for Claude Code + Cursor CLI hooks.
|
|
2
|
+
// Resolves session id from host env / hook payload and PATCHes the tier-dispatched
|
|
3
|
+
// heartbeat API — never reads `.heyspex/active-claims.json` ([[HS-TASK-A3FE]]).
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import http from 'node:http';
|
|
8
|
+
import https from 'node:https';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
import { resolveConsoleUrl as resolveConsoleUrlShared } from '../lib/console-url-resolver.mjs';
|
|
12
|
+
// Bearer for the cloud-gated heartbeat PATCH — same reader every other consumer uses, never
|
|
13
|
+
// re-implemented and never printed.
|
|
14
|
+
import { readCredentialsMcpToken } from '../lib/dev-mode.mjs';
|
|
15
|
+
// Read-only reuse of the resource governor's OWN liveness rule + config resolver — never a
|
|
16
|
+
// second, hand-invented rule at this layer. See the "Lease liveness" section below.
|
|
17
|
+
import { describeLivenessRule, resolveGovernorConfig } from '../../dev-factory/lib/fleet-queue-file.mjs';
|
|
18
|
+
|
|
19
|
+
// The console's heartbeat PATCH is a WAN round-trip to Neon (measured 420–1400ms
|
|
20
|
+
// on a home connection). The old 800ms budget sat *inside* that spread, so a
|
|
21
|
+
// large fraction of heartbeats were killed mid-flight by `req.destroy()`, which
|
|
22
|
+
// RSTs the socket and surfaces on the dev server as
|
|
23
|
+
// `⨯ uncaughtException: [Error: aborted] { code: 'ECONNRESET' }`. Because the
|
|
24
|
+
// gating below cuts request volume by ~10x, we can now afford a budget that sits
|
|
25
|
+
// *above* the real latency spread, so a heartbeat completes instead of resetting.
|
|
26
|
+
// Still well under the 5s hook timeout in `.claude/settings.json`.
|
|
27
|
+
export const REQUEST_TIMEOUT_MS = 2500;
|
|
28
|
+
export const PENDING_EXCERPT_MAX = 140;
|
|
29
|
+
export const ERROR_MESSAGE_MAX = 280;
|
|
30
|
+
|
|
31
|
+
// PreToolUse is registered with matcher `.*`, so without gating every single tool
|
|
32
|
+
// call fires a blocking PATCH. Claims go stale after 10 minutes
|
|
33
|
+
// (`staleTimeoutMs()`), so re-sending an *unchanged* status more than once per
|
|
34
|
+
// 20s buys nothing. Status *transitions* (working→pending→blocked) always send
|
|
35
|
+
// immediately — this only collapses repeats.
|
|
36
|
+
export const HEARTBEAT_THROTTLE_MS = 20_000;
|
|
37
|
+
// A session that holds no claim 404s on every tool call, and each 404 still costs
|
|
38
|
+
// a full Neon round-trip server-side. Remember the negative answer and stop
|
|
39
|
+
// asking. Safe to make this long because acquiring a claim invalidates it
|
|
40
|
+
// immediately (see `CLAIM_ACQUIRE_TOOL_RE`) and acquire already stamps
|
|
41
|
+
// `heartbeat_at` server-side — so a suppressed heartbeat cannot make a live
|
|
42
|
+
// claim look stale (staleness is a 10-minute window).
|
|
43
|
+
export const NO_CLAIM_TTL_MS = 180_000;
|
|
44
|
+
// Console unreachable / timed out: back off briefly so we don't burn the full
|
|
45
|
+
// request budget on every tool call while the dev server is down or restarting.
|
|
46
|
+
export const UNREACHABLE_BACKOFF_MS = 15_000;
|
|
47
|
+
const CACHE_MAX_SESSIONS = 32;
|
|
48
|
+
|
|
49
|
+
// ── Lease liveness (HS-JM67, [[HS-AU7I-2U3R-EPIC-resource-governor-leases-and-wait]]) ───────────
|
|
50
|
+
//
|
|
51
|
+
// This hook is the client for the governor's `spec:HS-XXXX` resource kind (see the epic's
|
|
52
|
+
// resource-key table): each session's cached entry below IS a holder record, so it must obey
|
|
53
|
+
// the SAME liveness rule the governor states for every other resource kind — never a second,
|
|
54
|
+
// hand-invented one. THE RULE is owned by dev-factory/lib/fleet-queue-file.mjs's
|
|
55
|
+
// createResourceGovernor (isDead()) and imported here via describeLivenessRule(), not restated
|
|
56
|
+
// by hand, so the two layers cannot drift apart:
|
|
57
|
+
//
|
|
58
|
+
// a holder is DEAD (its lease reclaimable) iff it is NOT paused AND
|
|
59
|
+
// now - lastLivenessAt > leaseTtlMs. A PAUSED holder is NEVER reclaimed by liveness — only a
|
|
60
|
+
// resume (a tool call settling) or an administrator's forceBreak ends a pause.
|
|
61
|
+
//
|
|
62
|
+
// THE GAP THIS CLOSES (named in the task's Scope): PreToolUse fires once, then this hook goes
|
|
63
|
+
// silent for the tool call's ENTIRE duration — a long Bash/build/model call can easily outlast
|
|
64
|
+
// leaseTtlMs, and until now nothing distinguished that silence from a crashed session. Fix:
|
|
65
|
+
// PreToolUse marks the session PAUSED (with a fresh liveness stamp — starting a call is itself
|
|
66
|
+
// proof of life); PostToolUse/Stop/SubagentStop resume it (also a fresh liveness stamp, per the
|
|
67
|
+
// governor's gov-resumed semantics). A session that pauses and then truly crashes (no further
|
|
68
|
+
// hook calls at all) stays paused=true forever and is correctly exempt from reclaim under the
|
|
69
|
+
// SAME rule the governor applies elsewhere — this module never invents a "paused too long"
|
|
70
|
+
// second rule.
|
|
71
|
+
//
|
|
72
|
+
// CONFIGURATION, NOT A BARE CONSTANT ([[dev-factory-rules-are-configuration-not]]): leaseTtlMs
|
|
73
|
+
// below is resolveGovernorConfig()'s own resolution — explicit override >
|
|
74
|
+
// HEYSPEX_GOVERNOR_LEASE_TTL_MS env > the governor's GOVERNOR_DEFAULTS — the exact same
|
|
75
|
+
// resolution the governor itself uses, so an owner can retune both layers with one env var.
|
|
76
|
+
|
|
77
|
+
/** Same config source as the governor: explicit override > HEYSPEX_GOVERNOR_* env > defaults. */
|
|
78
|
+
export function resolveLivenessConfig(overrides = {}) {
|
|
79
|
+
return resolveGovernorConfig(overrides);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The governor's own rule text, unchanged — never hand-restated at this layer. */
|
|
83
|
+
export function describeHookLivenessRule(config = resolveLivenessConfig()) {
|
|
84
|
+
return describeLivenessRule(config);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* THE liveness predicate, applied to one cached session entry. Mirrors
|
|
89
|
+
* createResourceGovernor()'s internal `isDead()` exactly: a paused holder is NEVER dead; an
|
|
90
|
+
* unpaused holder is dead once its last liveness stamp is older than `leaseTtlMs`. An entry
|
|
91
|
+
* with no liveness stamp at all reads as "unknown", not dead — same fail-open posture as every
|
|
92
|
+
* other read in this module (a session this hook has never heard a liveness signal from yet is
|
|
93
|
+
* not evidence of death).
|
|
94
|
+
*/
|
|
95
|
+
export function isHolderDead(entry, nowMs = Date.now(), config = resolveLivenessConfig()) {
|
|
96
|
+
if (!entry) return false;
|
|
97
|
+
if (entry.paused) return false;
|
|
98
|
+
const lastLivenessAt = entry.lastLivenessAt ?? entry.sentAt ?? entry.touchedAt;
|
|
99
|
+
if (typeof lastLivenessAt !== 'number' || !Number.isFinite(lastLivenessAt)) return false;
|
|
100
|
+
return nowMs - lastLivenessAt > config.leaseTtlMs;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* PreToolUse: a tool call is starting whose duration is unknown and may outlast leaseTtlMs —
|
|
105
|
+
* mark the session PAUSED so it is never reclaimed mid-call, per the governor's rule. Starting
|
|
106
|
+
* the call is itself a fresh liveness signal, so lastLivenessAt is stamped too.
|
|
107
|
+
*/
|
|
108
|
+
export function markToolInFlight(cache, sessionId, nowMs) {
|
|
109
|
+
const entry = { ...(cache[sessionId] ?? {}), paused: true, lastLivenessAt: nowMs, touchedAt: nowMs };
|
|
110
|
+
cache[sessionId] = entry;
|
|
111
|
+
return entry;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* PostToolUse / Stop / SubagentStop: no tool is in flight any more — resume, which (per the
|
|
116
|
+
* governor's gov-resumed handling) is itself a fresh liveness signal.
|
|
117
|
+
*/
|
|
118
|
+
export function markToolSettled(cache, sessionId, nowMs) {
|
|
119
|
+
const entry = { ...(cache[sessionId] ?? {}), paused: false, lastLivenessAt: nowMs, touchedAt: nowMs };
|
|
120
|
+
cache[sessionId] = entry;
|
|
121
|
+
return entry;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Enforce the rule across every cached session — the cache file
|
|
126
|
+
* (`.heyspex/claim-liveness-cache.json`) is shared by every session/process on this machine, so
|
|
127
|
+
* this mirrors the governor's reclaimAndPromote() running at the top of every mutating call: ANY
|
|
128
|
+
* hook invocation from ANY still-live session sweeps every OTHER session's dead entries too. A
|
|
129
|
+
* live-but-paused holder's entry is left completely untouched. Returns the list of reclaimed
|
|
130
|
+
* session ids (empty when nothing was dead).
|
|
131
|
+
*/
|
|
132
|
+
export function reclaimDeadSessions(cache, nowMs = Date.now(), config = resolveLivenessConfig()) {
|
|
133
|
+
const reclaimed = [];
|
|
134
|
+
for (const sessionId of Object.keys(cache)) {
|
|
135
|
+
if (isHolderDead(cache[sessionId], nowMs, config)) {
|
|
136
|
+
delete cache[sessionId];
|
|
137
|
+
reclaimed.push(sessionId);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return reclaimed;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function repoRoot(fromUrl = import.meta.url) {
|
|
144
|
+
// claim-liveness-shared.mjs lives in scripts/claude-hooks/, so up two levels.
|
|
145
|
+
return path.resolve(path.dirname(fileURLToPath(fromUrl)), '..', '..');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Delegates to the ONE shared resolver (scripts/lib/console-url-resolver.mjs,
|
|
149
|
+
// [[HS-GD45-MCPC-TASK]]) — both MCP claims and this hook's PATCHes must hit
|
|
150
|
+
// the same console, so they share one precedence implementation instead of
|
|
151
|
+
// two hand-duplicated copies.
|
|
152
|
+
export function resolveConsoleUrl(root = repoRoot()) {
|
|
153
|
+
return resolveConsoleUrlShared({ repoRoot: root });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function readStdin() {
|
|
157
|
+
if (process.stdin.isTTY) return null;
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
let buf = '';
|
|
160
|
+
let resolved = false;
|
|
161
|
+
const finish = (value) => {
|
|
162
|
+
if (resolved) return;
|
|
163
|
+
resolved = true;
|
|
164
|
+
resolve(value);
|
|
165
|
+
};
|
|
166
|
+
const timer = setTimeout(() => finish(buf || null), 200);
|
|
167
|
+
process.stdin.setEncoding('utf8');
|
|
168
|
+
process.stdin.on('data', (chunk) => {
|
|
169
|
+
buf += chunk;
|
|
170
|
+
});
|
|
171
|
+
process.stdin.on('end', () => {
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
finish(buf || null);
|
|
174
|
+
});
|
|
175
|
+
process.stdin.on('error', () => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
finish(buf || null);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function parseArgs(argv) {
|
|
183
|
+
const out = {};
|
|
184
|
+
for (const arg of argv.slice(2)) {
|
|
185
|
+
const m = /^--([^=]+)=(.*)$/.exec(arg);
|
|
186
|
+
if (m) out[m[1]] = m[2];
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Normalize Claude (`PreToolUse`) and Cursor (`preToolUse`) event names. */
|
|
192
|
+
export function normalizeHookEvent(event) {
|
|
193
|
+
if (!event || typeof event !== 'string') return null;
|
|
194
|
+
const key = event.trim();
|
|
195
|
+
const map = {
|
|
196
|
+
PreToolUse: 'PreToolUse',
|
|
197
|
+
preToolUse: 'PreToolUse',
|
|
198
|
+
PostToolUse: 'PostToolUse',
|
|
199
|
+
postToolUse: 'PostToolUse',
|
|
200
|
+
Stop: 'Stop',
|
|
201
|
+
stop: 'Stop',
|
|
202
|
+
SubagentStop: 'SubagentStop',
|
|
203
|
+
subagentStop: 'SubagentStop',
|
|
204
|
+
};
|
|
205
|
+
return map[key] ?? key;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolve the agent session id from hook stdin payload + host env.
|
|
210
|
+
* Claude: `session_id` / CLAUDE_CODE_SESSION_ID. Cursor: `conversation_id` /
|
|
211
|
+
* CURSOR_TRACE_ID (same id MCP `heyspex_claim_spec` writes on acquire).
|
|
212
|
+
*/
|
|
213
|
+
export function resolveSessionId(payload) {
|
|
214
|
+
const fromPayload =
|
|
215
|
+
(typeof payload?.session_id === 'string' && payload.session_id.trim()) ||
|
|
216
|
+
(typeof payload?.conversation_id === 'string' && payload.conversation_id.trim()) ||
|
|
217
|
+
'';
|
|
218
|
+
if (fromPayload) return fromPayload;
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
process.env.CLAUDE_CODE_SESSION_ID?.trim() ||
|
|
222
|
+
process.env.CLAUDE_SESSION_ID?.trim() ||
|
|
223
|
+
process.env.CURSOR_TRACE_ID?.trim() ||
|
|
224
|
+
process.env.CURSOR_SESSION_ID?.trim() ||
|
|
225
|
+
process.env.COMPOSER_SESSION_ID?.trim() ||
|
|
226
|
+
process.env.HEYSPEX_SESSION_ID?.trim() ||
|
|
227
|
+
''
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Claim lifecycle tools observed through the hook payload's `tool_name`. Seeing
|
|
232
|
+
// one is authoritative news about whether this session holds a claim, so it
|
|
233
|
+
// resets the negative cache rather than waiting out NO_CLAIM_TTL_MS.
|
|
234
|
+
const CLAIM_ACQUIRE_TOOL_RE = /heyspex_(claim_spec|claim_acquire|claim_file)$/;
|
|
235
|
+
const CLAIM_RELEASE_TOOL_RE = /heyspex_(release_spec|claim_release|defer_spec)$/;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Classify a hook payload's tool name as a claim lifecycle event.
|
|
239
|
+
* Returns `"acquire"`, `"release"`, or `null`.
|
|
240
|
+
*/
|
|
241
|
+
export function claimLifecycleEvent(toolName) {
|
|
242
|
+
if (typeof toolName !== 'string' || !toolName) return null;
|
|
243
|
+
if (CLAIM_ACQUIRE_TOOL_RE.test(toolName)) return 'acquire';
|
|
244
|
+
if (CLAIM_RELEASE_TOOL_RE.test(toolName)) return 'release';
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function truncate(value, limit) {
|
|
249
|
+
if (typeof value !== 'string') return undefined;
|
|
250
|
+
const trimmed = value.trim();
|
|
251
|
+
if (!trimmed) return undefined;
|
|
252
|
+
return trimmed.length > limit ? trimmed.slice(0, limit) : trimmed;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Build the PATCH body from the event + payload. Returns null when no update
|
|
256
|
+
// should be emitted (e.g. a successful PostToolUse — we don't downgrade from
|
|
257
|
+
// pending to working on every tool result; PreToolUse already covers that).
|
|
258
|
+
export function buildPatch(event, payload) {
|
|
259
|
+
if (event === 'PreToolUse') {
|
|
260
|
+
const toolName = payload?.tool_name;
|
|
261
|
+
if (toolName === 'AskUserQuestion') {
|
|
262
|
+
const questions = payload?.tool_input?.questions;
|
|
263
|
+
const first = Array.isArray(questions) && questions.length > 0 ? questions[0] : null;
|
|
264
|
+
const excerpt = truncate(first?.question, PENDING_EXCERPT_MAX);
|
|
265
|
+
return {
|
|
266
|
+
status: 'pending',
|
|
267
|
+
pendingReason: 'question',
|
|
268
|
+
...(excerpt ? { pendingExcerpt: excerpt } : {}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return { status: 'working' };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (event === 'Stop' || event === 'SubagentStop') {
|
|
275
|
+
return { status: 'pending', pendingReason: 'response' };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (event === 'PostToolUse') {
|
|
279
|
+
const response = payload?.tool_response;
|
|
280
|
+
let errorText;
|
|
281
|
+
if (response && typeof response === 'object') {
|
|
282
|
+
if (response.is_error === true) {
|
|
283
|
+
errorText =
|
|
284
|
+
truncate(response.error, ERROR_MESSAGE_MAX) ??
|
|
285
|
+
truncate(response.message, ERROR_MESSAGE_MAX) ??
|
|
286
|
+
truncate(response.content, ERROR_MESSAGE_MAX) ??
|
|
287
|
+
'Tool returned is_error=true';
|
|
288
|
+
} else if (typeof response.error === 'string' && response.error.trim()) {
|
|
289
|
+
errorText = truncate(response.error, ERROR_MESSAGE_MAX);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (errorText) {
|
|
293
|
+
return { status: 'blocked', errorMessage: errorText };
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* PATCH the heartbeat endpoint. Resolves to `{ status }` where `status` is the
|
|
303
|
+
* HTTP status code, or `null` when the request never got an answer (bad URL,
|
|
304
|
+
* connection refused, timeout).
|
|
305
|
+
*
|
|
306
|
+
* `Connection: close` is deliberate: the hook is a one-shot process, so keeping
|
|
307
|
+
* the socket alive only means the socket dies with the process — which the
|
|
308
|
+
* server sees as a reset. Asking for a clean close lets the server tear the
|
|
309
|
+
* connection down on its own terms.
|
|
310
|
+
*
|
|
311
|
+
* `token` (from `readCredentialsMcpToken()`) is sent as `Authorization: Bearer
|
|
312
|
+
* <token>` when present — the endpoint is bearer-gated server-side, so a hook
|
|
313
|
+
* running against the cloud console with no resolvable token 401s every time.
|
|
314
|
+
*/
|
|
315
|
+
export function patchHeartbeat(consoleUrl, sessionId, body, token) {
|
|
316
|
+
return new Promise((resolve) => {
|
|
317
|
+
let url;
|
|
318
|
+
try {
|
|
319
|
+
url = new URL('/api/control/active-claims/heartbeat', consoleUrl);
|
|
320
|
+
} catch {
|
|
321
|
+
resolve(null);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
325
|
+
const payload = JSON.stringify({ session_id: sessionId, ...body });
|
|
326
|
+
let settled = false;
|
|
327
|
+
const finish = (value) => {
|
|
328
|
+
if (settled) return;
|
|
329
|
+
settled = true;
|
|
330
|
+
resolve(value);
|
|
331
|
+
};
|
|
332
|
+
const req = transport.request(
|
|
333
|
+
{
|
|
334
|
+
method: 'PATCH',
|
|
335
|
+
hostname: url.hostname,
|
|
336
|
+
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
337
|
+
path: `${url.pathname}${url.search}`,
|
|
338
|
+
headers: {
|
|
339
|
+
'content-type': 'application/json',
|
|
340
|
+
'content-length': Buffer.byteLength(payload),
|
|
341
|
+
connection: 'close',
|
|
342
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
343
|
+
},
|
|
344
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
345
|
+
},
|
|
346
|
+
(res) => {
|
|
347
|
+
const status = res.statusCode ?? 0;
|
|
348
|
+
// Drain the body before settling: bailing early would leave unread bytes
|
|
349
|
+
// on the socket, and destroying a socket with unread data is exactly what
|
|
350
|
+
// produces a server-side ECONNRESET.
|
|
351
|
+
res.on('data', () => {});
|
|
352
|
+
res.on('end', () => finish({ status }));
|
|
353
|
+
res.on('error', () => finish({ status }));
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
req.on('error', () => finish(null));
|
|
357
|
+
req.on('timeout', () => {
|
|
358
|
+
req.destroy();
|
|
359
|
+
finish(null);
|
|
360
|
+
});
|
|
361
|
+
req.write(payload);
|
|
362
|
+
req.end();
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Where the per-session send-gate state lives. Best-effort; never load-bearing. */
|
|
367
|
+
export function livenessCachePath(root = repoRoot()) {
|
|
368
|
+
return path.join(root, '.heyspex', 'claim-liveness-cache.json');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function readLivenessCache(cachePath) {
|
|
372
|
+
try {
|
|
373
|
+
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
374
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
375
|
+
} catch {
|
|
376
|
+
return {};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Read-only check: does the negative-cache CURRENTLY say this session holds no
|
|
382
|
+
* claim (a heartbeat 404'd and `NO_CLAIM_TTL_MS` hasn't elapsed)? Local-file-only
|
|
383
|
+
* — no network round-trip — so callers with a tight PreToolUse budget (e.g. the
|
|
384
|
+
* claim-before-code hard gate, HS-38J5) can use it without adding request latency.
|
|
385
|
+
*
|
|
386
|
+
* Deliberately conservative in both directions:
|
|
387
|
+
* - `true` only on a FRESH negative signal (this session's own heartbeat
|
|
388
|
+
* recently 404'd) — never inferred from "no entry at all", since an absent
|
|
389
|
+
* entry just means no heartbeat has round-tripped yet (console cold-start,
|
|
390
|
+
* offline dev, or this is the session's very first tool call).
|
|
391
|
+
* - Never throws; a missing/corrupt cache file reads as "unknown" (false),
|
|
392
|
+
* same fail-open posture as every other read in this module.
|
|
393
|
+
*
|
|
394
|
+
* @param {string} sessionId
|
|
395
|
+
* @param {string} [root]
|
|
396
|
+
* @returns {boolean}
|
|
397
|
+
*/
|
|
398
|
+
export function hasFreshNoClaimSignal(sessionId, root = repoRoot()) {
|
|
399
|
+
const trimmed = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
400
|
+
if (!trimmed) return false;
|
|
401
|
+
const cache = readLivenessCache(livenessCachePath(root));
|
|
402
|
+
const entry = cache[trimmed];
|
|
403
|
+
return typeof entry?.noClaimUntil === 'number' && entry.noClaimUntil > Date.now();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function writeLivenessCache(cachePath, cache) {
|
|
407
|
+
// Drop the oldest sessions past the cap so the file cannot grow without bound.
|
|
408
|
+
const entries = Object.entries(cache).sort(
|
|
409
|
+
(a, b) => (b[1]?.touchedAt ?? 0) - (a[1]?.touchedAt ?? 0),
|
|
410
|
+
);
|
|
411
|
+
const trimmed = Object.fromEntries(entries.slice(0, CACHE_MAX_SESSIONS));
|
|
412
|
+
try {
|
|
413
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
414
|
+
// Write-then-rename: concurrent hook processes must never observe a
|
|
415
|
+
// half-written file. A lost race just costs one extra heartbeat.
|
|
416
|
+
const tmp = `${cachePath}.${process.pid}.tmp`;
|
|
417
|
+
fs.writeFileSync(tmp, `${JSON.stringify(trimmed, null, 2)}\n`, 'utf8');
|
|
418
|
+
fs.renameSync(tmp, cachePath);
|
|
419
|
+
} catch {
|
|
420
|
+
// Read-only checkout / racing rename — gating degrades to "always send".
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Decide whether this heartbeat is worth a network round-trip.
|
|
426
|
+
* Returns `{ send: boolean, reason: string }`.
|
|
427
|
+
*
|
|
428
|
+
* Skips when the session is negative-cached (no claim), when the console was
|
|
429
|
+
* just unreachable, or when an identical status was sent within the throttle
|
|
430
|
+
* window. A status *change* always sends.
|
|
431
|
+
*/
|
|
432
|
+
export function shouldSendHeartbeat(entry, signature, now = Date.now()) {
|
|
433
|
+
if (!entry) return { send: true, reason: 'first-send' };
|
|
434
|
+
if (typeof entry.noClaimUntil === 'number' && entry.noClaimUntil > now) {
|
|
435
|
+
return { send: false, reason: 'no-claim-cached' };
|
|
436
|
+
}
|
|
437
|
+
if (typeof entry.retryAfter === 'number' && entry.retryAfter > now) {
|
|
438
|
+
return { send: false, reason: 'console-unreachable-backoff' };
|
|
439
|
+
}
|
|
440
|
+
if (entry.signature !== signature) return { send: true, reason: 'status-changed' };
|
|
441
|
+
if (typeof entry.sentAt !== 'number' || now - entry.sentAt >= HEARTBEAT_THROTTLE_MS) {
|
|
442
|
+
return { send: true, reason: 'throttle-window-elapsed' };
|
|
443
|
+
}
|
|
444
|
+
return { send: false, reason: 'throttled' };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Forget the "this session holds no claim" answer for `sessionId`. Call after a
|
|
449
|
+
* claim is acquired so the very next hook heartbeats instead of waiting out the
|
|
450
|
+
* negative-cache TTL.
|
|
451
|
+
*/
|
|
452
|
+
export function clearNoClaimCache(sessionId, root = repoRoot()) {
|
|
453
|
+
const trimmed = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
454
|
+
if (!trimmed) return;
|
|
455
|
+
const cachePath = livenessCachePath(root);
|
|
456
|
+
const cache = readLivenessCache(cachePath);
|
|
457
|
+
if (!cache[trimmed]) return;
|
|
458
|
+
delete cache[trimmed].noClaimUntil;
|
|
459
|
+
delete cache[trimmed].retryAfter;
|
|
460
|
+
writeLivenessCache(cachePath, cache);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export async function emitClaimLivenessFromHook({
|
|
464
|
+
argv = process.argv,
|
|
465
|
+
eventArg,
|
|
466
|
+
payload,
|
|
467
|
+
// Injectable so the smoke test can exercise the real gate against a temp repo
|
|
468
|
+
// instead of the developer's live .heyspex/ state.
|
|
469
|
+
root = repoRoot(),
|
|
470
|
+
}) {
|
|
471
|
+
const event = normalizeHookEvent(eventArg ?? payload?.hook_event_name);
|
|
472
|
+
if (!event) return;
|
|
473
|
+
|
|
474
|
+
const sessionId = resolveSessionId(payload);
|
|
475
|
+
if (!sessionId) return;
|
|
476
|
+
|
|
477
|
+
const cachePath = livenessCachePath(root);
|
|
478
|
+
const cache = readLivenessCache(cachePath);
|
|
479
|
+
const now = Date.now();
|
|
480
|
+
|
|
481
|
+
// Enforce the liveness rule FIRST (governor order: reclaimAndPromote() runs at the top of
|
|
482
|
+
// every mutating call) — this session's own hook invocation is itself proof of life for
|
|
483
|
+
// itself, and doubles as the poller that sweeps every OTHER cached session's dead entries too
|
|
484
|
+
// (the cache file is shared across every session/process on this machine). A live-but-paused
|
|
485
|
+
// holder is left untouched by this sweep.
|
|
486
|
+
reclaimDeadSessions(cache, now);
|
|
487
|
+
|
|
488
|
+
// Close the long-tool-call gap: PreToolUse marks this session PAUSED (exempt from reclaim)
|
|
489
|
+
// for the call's unknown duration; PostToolUse/Stop/SubagentStop resume it. Both edges are a
|
|
490
|
+
// fresh liveness stamp, matching the governor's gov-resumed semantics.
|
|
491
|
+
if (event === 'PreToolUse') {
|
|
492
|
+
markToolInFlight(cache, sessionId, now);
|
|
493
|
+
} else if (event === 'PostToolUse' || event === 'Stop' || event === 'SubagentStop') {
|
|
494
|
+
markToolSettled(cache, sessionId, now);
|
|
495
|
+
}
|
|
496
|
+
// Persist the reclaim sweep + pause/resume state now — several paths below return early
|
|
497
|
+
// (no lifecycle patch, no patch at all, throttled) without reaching the write at the bottom
|
|
498
|
+
// of this function, and the liveness state must never depend on a heartbeat actually sending.
|
|
499
|
+
writeLivenessCache(cachePath, cache);
|
|
500
|
+
|
|
501
|
+
// Claim lifecycle news arrives on the PostToolUse edge (the tool has actually
|
|
502
|
+
// run by then). Record it and stop — a lifecycle call carries no status patch
|
|
503
|
+
// of its own; the next PreToolUse sends against the corrected cache.
|
|
504
|
+
const toolErrored = payload?.tool_response?.is_error === true;
|
|
505
|
+
const lifecycle =
|
|
506
|
+
event === 'PostToolUse' && !toolErrored ? claimLifecycleEvent(payload?.tool_name) : null;
|
|
507
|
+
if (lifecycle) {
|
|
508
|
+
const entry = { ...(cache[sessionId] ?? {}), touchedAt: now };
|
|
509
|
+
if (lifecycle === 'acquire') {
|
|
510
|
+
delete entry.noClaimUntil;
|
|
511
|
+
delete entry.retryAfter;
|
|
512
|
+
// Force the next heartbeat through: acquire may have set a status the
|
|
513
|
+
// throttle would otherwise suppress as "unchanged".
|
|
514
|
+
delete entry.signature;
|
|
515
|
+
delete entry.sentAt;
|
|
516
|
+
} else {
|
|
517
|
+
entry.noClaimUntil = now + NO_CLAIM_TTL_MS;
|
|
518
|
+
}
|
|
519
|
+
cache[sessionId] = entry;
|
|
520
|
+
writeLivenessCache(cachePath, cache);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const patch = buildPatch(event, payload);
|
|
525
|
+
if (!patch) return;
|
|
526
|
+
|
|
527
|
+
const signature = JSON.stringify(patch);
|
|
528
|
+
const entry = cache[sessionId];
|
|
529
|
+
|
|
530
|
+
const { send } = shouldSendHeartbeat(entry, signature, now);
|
|
531
|
+
if (!send) return;
|
|
532
|
+
|
|
533
|
+
const token = readCredentialsMcpToken();
|
|
534
|
+
const result = await patchHeartbeat(resolveConsoleUrl(root), sessionId, patch, token);
|
|
535
|
+
|
|
536
|
+
const next = { ...(entry ?? {}), touchedAt: now };
|
|
537
|
+
if (result === null) {
|
|
538
|
+
// No answer at all — console down or mid-restart. Back off; leave the last
|
|
539
|
+
// successful signature in place so the next send still counts as a change.
|
|
540
|
+
next.retryAfter = now + UNREACHABLE_BACKOFF_MS;
|
|
541
|
+
} else if (result.status === 404) {
|
|
542
|
+
// The endpoint answered "no active claim for this session". Stop asking.
|
|
543
|
+
next.noClaimUntil = now + NO_CLAIM_TTL_MS;
|
|
544
|
+
delete next.retryAfter;
|
|
545
|
+
} else if (result.status === 401 || result.status === 403) {
|
|
546
|
+
// The console rejected this machine's token — never cache that as success
|
|
547
|
+
// (a fixed/re-paired token must retry, not wait out the throttle window),
|
|
548
|
+
// and warn the operator exactly once per session. Still fails open: no
|
|
549
|
+
// throw, the tool call is never blocked — just back off so a persistently
|
|
550
|
+
// bad token can't turn into a per-tool-call flood.
|
|
551
|
+
if (!entry?.authWarned) {
|
|
552
|
+
console.error(
|
|
553
|
+
`HeySpex: console rejected this machine's token (${result.status}) — run \`hs login\`.`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
next.authWarned = true;
|
|
557
|
+
next.retryAfter = now + UNREACHABLE_BACKOFF_MS;
|
|
558
|
+
delete next.signature;
|
|
559
|
+
delete next.sentAt;
|
|
560
|
+
} else if (result.status >= 200 && result.status < 300) {
|
|
561
|
+
next.signature = signature;
|
|
562
|
+
next.sentAt = now;
|
|
563
|
+
delete next.noClaimUntil;
|
|
564
|
+
delete next.retryAfter;
|
|
565
|
+
} else {
|
|
566
|
+
// 4xx/5xx other than 404/401/403 — a malformed patch or a server fault.
|
|
567
|
+
// Throttle it like a success so a persistent error can't turn into a
|
|
568
|
+
// per-tool-call flood.
|
|
569
|
+
next.signature = signature;
|
|
570
|
+
next.sentAt = now;
|
|
571
|
+
delete next.retryAfter;
|
|
572
|
+
}
|
|
573
|
+
cache[sessionId] = next;
|
|
574
|
+
writeLivenessCache(cachePath, cache);
|
|
575
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Hook entrypoint for auto-close terminal (Claude Code + Cursor).
|
|
3
|
+
// See terminal-close-shared.mjs for detection/kill logic.
|
|
4
|
+
//
|
|
5
|
+
// Arms on ship; closes after heyspex_record_wip updates wip-state.json
|
|
6
|
+
// (or on SessionEnd backstop). Opt-in debug trace (off by default):
|
|
7
|
+
// HEYSPEX_TERMINAL_CLOSE_DEBUG=1 → .heyspex/terminal-close-debug.jsonl
|
|
8
|
+
// Mute: HEYSPEX_CLOSE_TERMINAL_ON_SHIP_OFF=1
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
armPendingTerminalClose,
|
|
12
|
+
isRecordWipSuccess,
|
|
13
|
+
isSessionEndEvent,
|
|
14
|
+
isShipEvent,
|
|
15
|
+
readStdin,
|
|
16
|
+
resolveCloseTerminalOnTaskCompleteFromSettings,
|
|
17
|
+
tryCloseIfReady,
|
|
18
|
+
} from './terminal-close-shared.mjs';
|
|
19
|
+
|
|
20
|
+
const KILL_SWITCH = 'HEYSPEX_CLOSE_TERMINAL_ON_SHIP_OFF';
|
|
21
|
+
|
|
22
|
+
function resolveSessionId(payload) {
|
|
23
|
+
return (
|
|
24
|
+
payload?.session_id ??
|
|
25
|
+
payload?.conversation_id ??
|
|
26
|
+
process.env.CLAUDE_CODE_SESSION_ID ??
|
|
27
|
+
process.env.CURSOR_TRACE_ID ??
|
|
28
|
+
null
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
if (process.env[KILL_SWITCH] === '1') return;
|
|
34
|
+
|
|
35
|
+
const stdinRaw = await readStdin();
|
|
36
|
+
if (!stdinRaw) return;
|
|
37
|
+
|
|
38
|
+
let payload;
|
|
39
|
+
try {
|
|
40
|
+
payload = JSON.parse(stdinRaw);
|
|
41
|
+
} catch {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!resolveCloseTerminalOnTaskCompleteFromSettings()) return;
|
|
46
|
+
|
|
47
|
+
if (isSessionEndEvent(payload)) {
|
|
48
|
+
tryCloseIfReady('session_end');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (isShipEvent(payload)) {
|
|
53
|
+
armPendingTerminalClose(resolveSessionId(payload), 'ship');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (isRecordWipSuccess(payload)) {
|
|
58
|
+
tryCloseIfReady('record_wip');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
main().catch(() => {
|
|
63
|
+
// Swallow — never break the host agent.
|
|
64
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GENERATED by scripts/generate-hook-shells.mjs (HS-IQBF) — do not hand-edit.
|
|
3
|
+
// This file only wires Claude Code's hook stdin/stdout contract to the
|
|
4
|
+
// spexharness engine port of this gate. To change what this gate DECIDES,
|
|
5
|
+
// edit spexharness/src/gates/coo-denylist-gate.mjs. To change the SHAPE of this shell,
|
|
6
|
+
// edit the generator, then rerun: node scripts/generate-hook-shells.mjs
|
|
7
|
+
|
|
8
|
+
import { runGateAsHook } from '../../spexharness/src/adapters/claude-code.mjs';
|
|
9
|
+
import gate from '../../spexharness/src/gates/coo-denylist-gate.mjs';
|
|
10
|
+
|
|
11
|
+
await runGateAsHook(gate);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GENERATED by scripts/generate-hook-shells.mjs (HS-IQBF) — do not hand-edit.
|
|
3
|
+
// This file only wires Claude Code's hook stdin/stdout contract to the
|
|
4
|
+
// spexharness engine port of this gate. To change what this gate DECIDES,
|
|
5
|
+
// edit spexharness/src/gates/coo-guard-gate.mjs. To change the SHAPE of this shell,
|
|
6
|
+
// edit the generator, then rerun: node scripts/generate-hook-shells.mjs
|
|
7
|
+
|
|
8
|
+
import { runGateAsHook } from '../../spexharness/src/adapters/claude-code.mjs';
|
|
9
|
+
import { hookGate as gate } from '../../spexharness/src/gates/coo-guard-gate.mjs';
|
|
10
|
+
|
|
11
|
+
await runGateAsHook(gate);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GENERATED by scripts/generate-hook-shells.mjs (HS-IQBF) — do not hand-edit.
|
|
3
|
+
// This file only wires Claude Code's hook stdin/stdout contract to the
|
|
4
|
+
// spexharness engine port of this gate. To change what this gate DECIDES,
|
|
5
|
+
// edit spexharness/src/gates/deep-research-cost-gate.mjs. To change the SHAPE of this shell,
|
|
6
|
+
// edit the generator, then rerun: node scripts/generate-hook-shells.mjs
|
|
7
|
+
|
|
8
|
+
import { runGateAsHook } from '../../spexharness/src/adapters/claude-code.mjs';
|
|
9
|
+
import gate from '../../spexharness/src/gates/deep-research-cost-gate.mjs';
|
|
10
|
+
|
|
11
|
+
await runGateAsHook(gate);
|