heyspex 0.1.0 → 0.2.1
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/.claude-plugin/marketplace.json +14 -0
- package/README.md +21 -0
- package/bin/hs.mjs +1089 -104
- 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 +1127 -103
- package/dist/cli/hs-claim.mjs +981 -63
- package/dist/cli/hs-connect.mjs +10059 -1729
- package/dist/cli/hs-defer.mjs +5507 -3072
- package/dist/cli/hs-dev.mjs +576 -0
- package/dist/cli/hs-env.mjs +416 -302
- package/dist/cli/hs-index-rebuild.mjs +7334 -0
- package/dist/cli/hs-index-reconcile.mjs +8138 -0
- package/dist/cli/hs-init.mjs +2840 -149
- package/dist/cli/hs-install.mjs +7214 -594
- package/dist/cli/hs-join.mjs +10640 -2421
- package/dist/cli/hs-vault-pull.mjs +8157 -6370
- package/dist/cli/hs-vault-push.mjs +6433 -0
- package/dist/cli/hs-vault.mjs +4029 -1555
- package/dist/cli/hs-workspace.mjs +2702 -538
- 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 +30 -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 +28 -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 +33 -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 +32 -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/copilot-plugin-install.md +162 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/docs/mcp-client-config.md +259 -6
- package/package.json +249 -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/README.md +309 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
- package/plugins/heyspex/bin/run-hook.mjs +80 -0
- package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
- package/plugins/heyspex/hooks/hooks.json +418 -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 +456 -0
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
- package/scripts/claude-hooks/_run.mjs +413 -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-execution-gate.mjs +51 -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 +577 -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/diagnose-capture-reminder.mjs +11 -0
- package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -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 +86 -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 +279 -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 +451 -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/vault-mirror-catchup.mjs +11 -0
- package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
- package/scripts/cursor-hooks/manifest.mjs +281 -0
- package/scripts/dev-server-status.mjs +34 -13
- package/scripts/dev-server-stop.mjs +313 -0
- package/scripts/heyspex-mode.mjs +288 -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 +496 -0
- package/scripts/hs-worktree.mjs +201 -0
- package/scripts/install-claude-code-hooks.mjs +272 -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 +199 -61
- package/scripts/lib/customer-kit.mjs +1203 -0
- package/scripts/lib/dev-mode.mjs +249 -32
- package/scripts/lib/directive-obligations.mjs +349 -0
- package/scripts/lib/heyspex-home.mjs +72 -0
- package/scripts/lib/hook-dispositions.mjs +210 -0
- package/scripts/lib/hook-vault-header.mjs +30 -0
- package/scripts/lib/hs-command.mjs +277 -0
- package/scripts/lib/hs-companion-settings.mjs +472 -0
- package/scripts/lib/hs-source-branch.mjs +196 -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 +13 -22
- package/scripts/lib/hs-source-preflight.mjs +18 -4
- package/scripts/lib/hs-source-push.mjs +386 -307
- 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/lib/window-placement.mjs +109 -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 +449 -0
- package/spexharness/src/adapters/copilot.mjs +236 -0
- package/spexharness/src/adapters/detect-harness.mjs +34 -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/_shell-write-targets.mjs +77 -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 +275 -0
- package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
- package/spexharness/src/gates/directive-obligation-gate.mjs +183 -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/gates/vault-mirror-catchup.mjs +124 -0
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
- package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
- package/spexharness/src/git-reflog-audit.mjs +294 -0
- package/spexharness/src/intent-classifier.mjs +196 -0
- package/spexharness/src/intent-directive.mjs +626 -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 +849 -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 +96 -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 +77 -8
- package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
- package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
- package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
- package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
- package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
- package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
- package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
- package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
- package/vendor/heyspex-mcp/cli.mjs +207 -15524
- package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
- package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -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 +822 -17
- package/vendor/heyspex-mcp/console-transport.mjs +172 -0
- package/vendor/heyspex-mcp/credentials.mjs +585 -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 +131 -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 +723 -0
- package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
- package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
- package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
- package/vendor/heyspex-mcp/http-transport.mjs +302 -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 +107 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
- package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
- package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -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 +908 -22
- 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 +327 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
- package/vendor/heyspex-mcp/promote-capture.mjs +388 -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 +666 -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 +191 -15
- 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 +83 -0
- package/vendor/heyspex-mcp/research-focus.mjs +207 -4
- package/vendor/heyspex-mcp/research-handlers.mjs +625 -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/roadmap-default.mjs +70 -0
- 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 +109 -0
- package/vendor/heyspex-mcp/session-tools.mjs +162 -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 +453 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
- package/vendor/heyspex-mcp/split-research.mjs +304 -56
- package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
- package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
- package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
- package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
- package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -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 +720 -0
- package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
- package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
- package/vendor/heyspex-mcp/whoami.mjs +92 -0
- package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// spexharness/src/gates/vault-mirror-catchup.mjs
|
|
2
|
+
//
|
|
3
|
+
// SessionStart catch-up for the cloud -> local vault mirror (diagnose HS-XJFQ, acceptance criterion 3:
|
|
4
|
+
// "a Companion or MCP session start against a clone that is behind the cloud closes the gap before the
|
|
5
|
+
// first tool call"). In cloud write mode the console is the system of record and the disk clone is a
|
|
6
|
+
// mirror; this gate starts `hs pull --apply` in the BACKGROUND so the hook returns immediately.
|
|
7
|
+
//
|
|
8
|
+
// Fail-open and silent on every skip: local reliance, no vault root / no heyspex.yaml, kill switch
|
|
9
|
+
// HEYSPEX_VAULT_MIRROR_CATCHUP_OFF=1, or a catch-up that already ran in the last 10 minutes (stamp file).
|
|
10
|
+
// Never denies. Emits one line of additionalContext when it spawns.
|
|
11
|
+
//
|
|
12
|
+
// ctx (all optional, for tests): env, vaultRoot, writeMode, now, exists(p), statMtimeMs(p), mkdirp(p),
|
|
13
|
+
// writeFile(p, s), openLog(p) -> fd, spawnDetached(cmd, args, opts), hsCommand() -> { command, args, shell }.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
import { defaultConfigRoot } from '../config-root.mjs';
|
|
20
|
+
import { resolveDevMode } from '../../../scripts/lib/dev-mode.mjs';
|
|
21
|
+
import { describeHsCommand } from '../../../scripts/lib/hs-command.mjs';
|
|
22
|
+
|
|
23
|
+
export const id = 'vault-mirror-catchup';
|
|
24
|
+
export const event = 'session_start';
|
|
25
|
+
export const severity = 'info';
|
|
26
|
+
|
|
27
|
+
export const KILL_ENV = 'HEYSPEX_VAULT_MIRROR_CATCHUP_OFF';
|
|
28
|
+
export const FRESH_WINDOW_MS = 10 * 60 * 1000;
|
|
29
|
+
export const LOG_REL = '.heyspex/vault-mirror-catchup.log';
|
|
30
|
+
export const STAMP_REL = '.heyspex/vault-mirror-catchup.stamp';
|
|
31
|
+
|
|
32
|
+
/** Resolve how to launch `hs pull --apply`: the PATH shim (shell on Windows) or `node <dispatcher>`. */
|
|
33
|
+
export function defaultHsLaunch() {
|
|
34
|
+
const d = describeHsCommand();
|
|
35
|
+
if (d.onPath) return { command: 'hs', args: ['pull', '--apply'], shell: process.platform === 'win32' };
|
|
36
|
+
if (d.dispatcherPath) return { command: process.execPath, args: [d.dispatcherPath, 'pull', '--apply'], shell: false };
|
|
37
|
+
return { command: 'hs', args: ['pull', '--apply'], shell: process.platform === 'win32' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pure-ish decision: returns { spawn: false } or { spawn: true, vaultRoot, command, args, ... }.
|
|
42
|
+
* All I/O goes through injected deps.
|
|
43
|
+
*/
|
|
44
|
+
export function decideCatchup(deps = {}) {
|
|
45
|
+
const env = deps.env ?? process.env;
|
|
46
|
+
if (String(env[KILL_ENV] ?? '') === '1') return { spawn: false, reason: 'kill-switch' };
|
|
47
|
+
let writeMode = deps.writeMode;
|
|
48
|
+
if (!writeMode) {
|
|
49
|
+
try {
|
|
50
|
+
writeMode = resolveDevMode({ env }).vaultWriteMode;
|
|
51
|
+
} catch {
|
|
52
|
+
return { spawn: false, reason: 'dev-mode-unresolved' };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (writeMode !== 'cloud') return { spawn: false, reason: 'local-reliance' };
|
|
56
|
+
let vaultRoot = deps.vaultRoot;
|
|
57
|
+
if (vaultRoot === undefined) {
|
|
58
|
+
try {
|
|
59
|
+
vaultRoot = defaultConfigRoot();
|
|
60
|
+
} catch {
|
|
61
|
+
vaultRoot = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!vaultRoot) return { spawn: false, reason: 'no-vault-root' };
|
|
65
|
+
const exists = deps.exists ?? fs.existsSync;
|
|
66
|
+
if (!exists(path.join(vaultRoot, 'heyspex.yaml'))) return { spawn: false, reason: 'no-heyspex-yaml' };
|
|
67
|
+
|
|
68
|
+
const now = (deps.now ?? Date.now)();
|
|
69
|
+
const stampPath = path.join(vaultRoot, STAMP_REL);
|
|
70
|
+
const mtime = (deps.statMtimeMs ?? ((p) => {
|
|
71
|
+
try {
|
|
72
|
+
return fs.statSync(p).mtimeMs;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}))(stampPath);
|
|
77
|
+
if (typeof mtime === 'number' && now - mtime >= 0 && now - mtime < FRESH_WINDOW_MS) {
|
|
78
|
+
return { spawn: false, reason: 'stamp-fresh' };
|
|
79
|
+
}
|
|
80
|
+
const launch = (deps.hsCommand ?? defaultHsLaunch)();
|
|
81
|
+
return { spawn: true, vaultRoot, stampPath, logPath: path.join(vaultRoot, LOG_REL), ...launch };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Perform the detached spawn for a { spawn: true } decision. Throws on failure (caller fails open). */
|
|
85
|
+
export function startCatchup(d, deps = {}) {
|
|
86
|
+
const mkdirp = deps.mkdirp ?? ((p) => fs.mkdirSync(p, { recursive: true }));
|
|
87
|
+
const writeFile = deps.writeFile ?? ((p, s) => fs.writeFileSync(p, s));
|
|
88
|
+
const openLog = deps.openLog ?? ((p) => fs.openSync(p, 'a'));
|
|
89
|
+
const spawnDetached = deps.spawnDetached ?? ((cmd, args, opts) => spawn(cmd, args, opts));
|
|
90
|
+
const env = deps.env ?? process.env;
|
|
91
|
+
mkdirp(path.dirname(d.logPath));
|
|
92
|
+
writeFile(d.stampPath, new Date((deps.now ?? Date.now)()).toISOString() + '\n');
|
|
93
|
+
const fd = openLog(d.logPath);
|
|
94
|
+
const child = spawnDetached(d.command, d.args, {
|
|
95
|
+
cwd: d.vaultRoot,
|
|
96
|
+
env: { ...env, HS_VAULT_ROOT: d.vaultRoot },
|
|
97
|
+
detached: true,
|
|
98
|
+
stdio: ['ignore', fd, fd],
|
|
99
|
+
windowsHide: true,
|
|
100
|
+
shell: d.shell === true,
|
|
101
|
+
});
|
|
102
|
+
child?.on?.('error', () => {});
|
|
103
|
+
child?.unref?.();
|
|
104
|
+
return child;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function run(evt, ctx = {}) {
|
|
108
|
+
try {
|
|
109
|
+
const d = decideCatchup(ctx);
|
|
110
|
+
if (!d.spawn) return { decision: 'allow' };
|
|
111
|
+
startCatchup(d, ctx);
|
|
112
|
+
return {
|
|
113
|
+
decision: 'inject',
|
|
114
|
+
inject: {
|
|
115
|
+
additionalContext: `vault mirror catch-up: \`hs pull --apply\` started in the background for ${d.vaultRoot} (log: ${LOG_REL})`,
|
|
116
|
+
severity: 'info',
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
} catch {
|
|
120
|
+
return { decision: 'allow' };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default { id, event, severity, run };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// spexharness/src/gates/vault-write-tier-guard.decide.mjs
|
|
2
|
+
//
|
|
3
|
+
// Pure decision for the vault-write-tier-guard PreToolUse gate (HS-FCNH).
|
|
4
|
+
//
|
|
5
|
+
// Problem: on a machine in prod / cloud reliance (`hs dev: prod`) the console
|
|
6
|
+
// reads Neon, and the vault clone on disk is a MIRROR the console never reads
|
|
7
|
+
// back. An agent that hand-edits `<vault>/specs/**/*.md` because a console
|
|
8
|
+
// prompt said so writes into a place nobody reads. This gate denies those disk
|
|
9
|
+
// writes in cloud write mode and names the cloud-routed tool to use instead.
|
|
10
|
+
//
|
|
11
|
+
// No process.exit / stdout here. `fs.existsSync` is the only I/O and is
|
|
12
|
+
// injectable (`deps.exists`).
|
|
13
|
+
//
|
|
14
|
+
// call = { toolName, toolInput }
|
|
15
|
+
// deps = { vaultRoot, writeMode, env, cwd?, exists?, platform? }
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { extractShellTargets } from './_shell-write-targets.mjs';
|
|
21
|
+
|
|
22
|
+
export const KILL_ENV = 'HEYSPEX_VAULT_WRITE_TIER_GUARD_OFF';
|
|
23
|
+
|
|
24
|
+
const FILE_TOOLS = new Set(['write', 'edit', 'multiedit', 'notebookedit']);
|
|
25
|
+
const SHELL_TOOLS = new Set(['bash', 'powershell']);
|
|
26
|
+
|
|
27
|
+
function norm(p, platform) {
|
|
28
|
+
const r = String(p);
|
|
29
|
+
return platform === 'win32' ? r.toLowerCase() : r;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** True when `p` resolves to `<vaultRoot>/specs/**\/*.md` outside `.trash/`. */
|
|
33
|
+
export function isGuardedVaultPath(p, { vaultRoot, cwd, platform = process.platform }) {
|
|
34
|
+
if (!p || typeof p !== 'string') return false;
|
|
35
|
+
let abs;
|
|
36
|
+
try {
|
|
37
|
+
abs = path.resolve(cwd ?? process.cwd(), p);
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const root = norm(path.resolve(vaultRoot), platform);
|
|
42
|
+
const target = norm(abs, platform);
|
|
43
|
+
const rel = path.relative(root, target);
|
|
44
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
|
45
|
+
const segs = rel.split(/[\\/]+/);
|
|
46
|
+
if (segs[0] !== 'specs' || segs.length < 2) return false;
|
|
47
|
+
if (segs.includes('.trash')) return false;
|
|
48
|
+
return path.extname(target) === '.md';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function editTexts(toolInput) {
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const k of ['new_string', 'content', 'new_source', 'command']) {
|
|
54
|
+
if (typeof toolInput?.[k] === 'string') out.push(toolInput[k]);
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(toolInput?.edits)) {
|
|
57
|
+
for (const e of toolInput.edits) if (typeof e?.new_string === 'string') out.push(e.new_string);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pickTool(toolInput) {
|
|
63
|
+
return editTexts(toolInput).some((t) => /^summary:/m.test(t))
|
|
64
|
+
? 'heyspex_author_summary'
|
|
65
|
+
: 'heyspex_edit_file';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ROWS = [
|
|
69
|
+
['summary: field', 'heyspex_author_summary { spec_id, summary } (load via heyspex_load_tools)', 'heyspex_author_summary'],
|
|
70
|
+
['body / other frontmatter', 'heyspex_edit_file { file_path, content } (cloud-routed in this mode)', 'heyspex_edit_file'],
|
|
71
|
+
['status', 'heyspex_submit_for_review / heyspex_claim_spec — never hand-edit status', null],
|
|
72
|
+
['new spec', 'heyspex_file_spec', null],
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
export function denyReason(target, tool) {
|
|
76
|
+
const rows = ROWS.map(([label, use, t]) => {
|
|
77
|
+
const mark = t === tool ? '=> ' : ' ';
|
|
78
|
+
return `${mark}${label.padEnd(24)} → ${use}`;
|
|
79
|
+
}).join('\n');
|
|
80
|
+
return (
|
|
81
|
+
`[vault-write-tier-guard] This machine writes the vault to the CLOUD (hs dev: prod). ` +
|
|
82
|
+
`${target} is a disk mirror the console never reads back (HS-FCNH).\n${rows}\n` +
|
|
83
|
+
`Break-glass for a human: ${KILL_ENV}=1.`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {{ toolName: string, toolInput: Record<string, unknown> }} call
|
|
89
|
+
* @param {{ vaultRoot?: string|null, writeMode?: string, env?: Record<string,string|undefined>,
|
|
90
|
+
* cwd?: string, exists?: (p: string) => boolean, platform?: string }} deps
|
|
91
|
+
* @returns {{ allow: boolean, reason?: string, tool?: string }}
|
|
92
|
+
*/
|
|
93
|
+
export function decide(call, deps = {}) {
|
|
94
|
+
const env = deps.env ?? process.env;
|
|
95
|
+
if (deps.writeMode !== 'cloud') return { allow: true };
|
|
96
|
+
if (String(env[KILL_ENV] ?? '') === '1') return { allow: true };
|
|
97
|
+
const vaultRoot = deps.vaultRoot;
|
|
98
|
+
if (!vaultRoot || typeof vaultRoot !== 'string') return { allow: true };
|
|
99
|
+
const exists = deps.exists ?? ((p) => fs.existsSync(p));
|
|
100
|
+
if (!exists(path.join(vaultRoot, 'heyspex.yaml'))) return { allow: true };
|
|
101
|
+
|
|
102
|
+
const platform = deps.platform ?? process.platform;
|
|
103
|
+
const ctx = { vaultRoot, cwd: deps.cwd, platform };
|
|
104
|
+
const tool = String(call?.toolName ?? '').toLowerCase();
|
|
105
|
+
const input = call?.toolInput ?? {};
|
|
106
|
+
|
|
107
|
+
if (FILE_TOOLS.has(tool)) {
|
|
108
|
+
const fp = input.file_path ?? input.notebook_path ?? input.filePath;
|
|
109
|
+
if (isGuardedVaultPath(fp, ctx)) {
|
|
110
|
+
return { allow: false, tool: pickTool(input), reason: denyReason(String(fp), pickTool(input)) };
|
|
111
|
+
}
|
|
112
|
+
return { allow: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (SHELL_TOOLS.has(tool)) {
|
|
116
|
+
const command = String(input.command ?? '');
|
|
117
|
+
if (!command) return { allow: true };
|
|
118
|
+
for (const t of extractShellTargets(command)) {
|
|
119
|
+
if (isGuardedVaultPath(t, ctx)) {
|
|
120
|
+
return { allow: false, tool: pickTool(input), reason: denyReason(t, pickTool(input)) };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { allow: true };
|
|
125
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// spexharness/src/gates/vault-write-tier-guard.mjs
|
|
2
|
+
//
|
|
3
|
+
// Engine gate for HS-FCNH: in cloud write mode (`hs dev: prod`), deny agent
|
|
4
|
+
// disk writes to `<vault>/specs/**/*.md` — the disk copy is a mirror the console
|
|
5
|
+
// never reads back. Decision logic lives in ./vault-write-tier-guard.decide.mjs.
|
|
6
|
+
//
|
|
7
|
+
// Kill switch (human break-glass): HEYSPEX_VAULT_WRITE_TIER_GUARD_OFF=1.
|
|
8
|
+
// Deliberately NOT in LEGACY_EXIT2_BANNER: it emits the modern
|
|
9
|
+
// permissionDecision: deny JSON via the adapter.
|
|
10
|
+
//
|
|
11
|
+
// `ctx` (all optional): vaultRoot, writeMode, env, cwd, exists.
|
|
12
|
+
|
|
13
|
+
import { defaultConfigRoot } from '../config-root.mjs';
|
|
14
|
+
import { resolveDevMode } from '../../../scripts/lib/dev-mode.mjs';
|
|
15
|
+
import { decide as legacyDecide, KILL_ENV } from './vault-write-tier-guard.decide.mjs';
|
|
16
|
+
|
|
17
|
+
export const id = 'vault-write-tier-guard';
|
|
18
|
+
export const event = 'before_tool';
|
|
19
|
+
export const severity = 'block';
|
|
20
|
+
|
|
21
|
+
export function run(evt, ctx = {}) {
|
|
22
|
+
const env = ctx.env ?? process.env;
|
|
23
|
+
if (String(env[KILL_ENV] ?? '') === '1') return { decision: 'allow' };
|
|
24
|
+
let writeMode = ctx.writeMode;
|
|
25
|
+
if (!writeMode) {
|
|
26
|
+
try {
|
|
27
|
+
writeMode = resolveDevMode({ env }).vaultWriteMode;
|
|
28
|
+
} catch {
|
|
29
|
+
return { decision: 'allow' };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (writeMode !== 'cloud') return { decision: 'allow' };
|
|
33
|
+
const vaultRoot = ctx.vaultRoot ?? defaultConfigRoot();
|
|
34
|
+
const verdict = legacyDecide(
|
|
35
|
+
{ toolName: evt?.toolName, toolInput: evt?.toolInput ?? {} },
|
|
36
|
+
{ vaultRoot, writeMode, env, cwd: ctx.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd(), exists: ctx.exists },
|
|
37
|
+
);
|
|
38
|
+
if (verdict.allow) return { decision: 'allow' };
|
|
39
|
+
return { decision: 'deny', reason: verdict.reason };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default { id, event, severity, run };
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// spexharness/src/git-reflog-audit.mjs
|
|
2
|
+
//
|
|
3
|
+
// The SHARED session-close git audit — moved here verbatim from
|
|
4
|
+
// scripts/claude-hooks/git-reflog-audit.mjs (task HS-610I-P3TJ, epic
|
|
5
|
+
// HS-GG01-P3TJ) as wave 6 of [[HS-AZ61-NYPR]] turned that hook into a thin
|
|
6
|
+
// shim over ./gates/git-reflog-audit.mjs. There is exactly ONE copy of the
|
|
7
|
+
// reflog parsing in the tree: this one. The gate imports it; the Claude Code
|
|
8
|
+
// shim reaches it through the gate; scripts/claude-hooks/git-reflog-audit.test.mjs
|
|
9
|
+
// (the original regression suite, unchanged apart from its import path) drives
|
|
10
|
+
// it directly.
|
|
11
|
+
//
|
|
12
|
+
// WHY the rule exists (kept from the original header): every other gate in this
|
|
13
|
+
// family (git-stash-gate, protected-action-intent) works by INTERCEPTING a
|
|
14
|
+
// destructive command as it is issued — bypassed, disabled, or routed around a
|
|
15
|
+
// gap in coverage, the action leaves no independent trace. This is a WITNESS,
|
|
16
|
+
// not a gate: it snapshots git state at session start and diffs it at session
|
|
17
|
+
// close, so a stash or a reset/checkout/rebase that slipped past every other
|
|
18
|
+
// control still shows up in .heyspex/guardrail-history.jsonl — a record
|
|
19
|
+
// independent of the acting agent's honesty.
|
|
20
|
+
//
|
|
21
|
+
// SCOPE: warn only. Never blocks, never denies.
|
|
22
|
+
//
|
|
23
|
+
// Snapshot (session start): writes `{ stash_count, reflog_head_entry,
|
|
24
|
+
// written_at }` to a SESSION-SCOPED file — the filename carries the session id
|
|
25
|
+
// so concurrent sessions on the same working tree never clobber each other's
|
|
26
|
+
// baseline — at `.heyspex/git-reflog-audit/<sessionId>.json`.
|
|
27
|
+
//
|
|
28
|
+
// Diff (stop / session end):
|
|
29
|
+
// - stash: any INCREASE in `git stash list`'s entry count since the snapshot
|
|
30
|
+
// names the new top entries (a push always lands at stash@{0}).
|
|
31
|
+
// - reflog: any HEAD reflog entry newer than the snapshot's recorded head
|
|
32
|
+
// entry whose action is reset/checkout/rebase is named. Plain commits newer
|
|
33
|
+
// than the snapshot are NOT flagged — that's normal work. Matching strips
|
|
34
|
+
// the `HEAD@{N}:` index label before comparing (that label shifts every time
|
|
35
|
+
// a new entry lands, even when the underlying hash+message didn't change) —
|
|
36
|
+
// see [[never-chain-cd-with-destructive-git-a]] for why a naive hash-only
|
|
37
|
+
// compare is not enough: `git stash push` logs its own internal
|
|
38
|
+
// `reset: moving to HEAD` entry against the SAME hash as the just-committed
|
|
39
|
+
// HEAD, so hash equality alone can't tell old from new.
|
|
40
|
+
// A clean session (no stash growth, no reset/checkout/rebase reflog entries)
|
|
41
|
+
// appends nothing.
|
|
42
|
+
//
|
|
43
|
+
// TEST ISOLATION: every git call here takes an explicit repoRootPath and shells
|
|
44
|
+
// out as `git -C <repoRootPath> ...` — never a bare `git` relying on cwd, and
|
|
45
|
+
// never a `cd X && git ...` chain.
|
|
46
|
+
//
|
|
47
|
+
// FAIL-SAFE: every exported function degrades to an inert value (null / [] /
|
|
48
|
+
// false) on any git or filesystem failure rather than throwing — this must
|
|
49
|
+
// never break a session start or a session close on its own bug.
|
|
50
|
+
|
|
51
|
+
import fs from 'node:fs';
|
|
52
|
+
import path from 'node:path';
|
|
53
|
+
import { spawnSync } from 'node:child_process';
|
|
54
|
+
import { fileURLToPath } from 'node:url';
|
|
55
|
+
|
|
56
|
+
import { appendGuardrailEvent } from '../../scripts/claude-hooks/_guardrail-events.mjs';
|
|
57
|
+
|
|
58
|
+
/** The `rule` id every row this module appends is stamped with. */
|
|
59
|
+
export const RULE_ID = 'git-reflog-audit';
|
|
60
|
+
|
|
61
|
+
/** spexharness/src/ → repo root is two levels up. */
|
|
62
|
+
const DEFAULT_REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
63
|
+
|
|
64
|
+
/** Env-first repo-root resolution, matching every pre-cutover hook's own helper. */
|
|
65
|
+
export function repoRoot(env = process.env) {
|
|
66
|
+
const fromEnv = env.CLAUDE_PROJECT_DIR?.trim();
|
|
67
|
+
if (fromEnv) return fromEnv.replace(/[\\/]+$/, '');
|
|
68
|
+
return DEFAULT_REPO_ROOT;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function git(repoRootPath, args) {
|
|
72
|
+
try {
|
|
73
|
+
const res = spawnSync('git', ['-C', repoRootPath, ...args], { encoding: 'utf8', timeout: 5000 });
|
|
74
|
+
if (res.status !== 0) return null;
|
|
75
|
+
return typeof res.stdout === 'string' ? res.stdout : '';
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `git stash list`, newest first (`stash@{0}` is the top). [] on any failure
|
|
83
|
+
* (not a repo, git unavailable, etc.) — never throws.
|
|
84
|
+
*/
|
|
85
|
+
export function readStashList(repoRootPath) {
|
|
86
|
+
const out = git(repoRootPath, ['stash', 'list']);
|
|
87
|
+
if (out === null) return [];
|
|
88
|
+
return out.split(/\r?\n/).filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `git reflog -n <limit>`, newest first. [] on any failure (including a
|
|
93
|
+
* brand-new repo with no commits yet, where HEAD has no reflog at all).
|
|
94
|
+
*/
|
|
95
|
+
export function readReflogLines(repoRootPath, limit = 50) {
|
|
96
|
+
const out = git(repoRootPath, ['reflog', '-n', String(limit)]);
|
|
97
|
+
if (out === null) return [];
|
|
98
|
+
return out.split(/\r?\n/).filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Strip the `HEAD@{N}:` index label from a reflog line before comparing two
|
|
103
|
+
* lines captured at different times — the label shifts by one every time a new
|
|
104
|
+
* entry is pushed, even when the underlying hash+message is unchanged, so
|
|
105
|
+
* comparing raw lines would never match a genuinely-unchanged entry.
|
|
106
|
+
*/
|
|
107
|
+
export function normalizeReflogLine(line) {
|
|
108
|
+
return typeof line === 'string' ? line.replace(/HEAD@\{\d+\}:/, '').trim() : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The reflog "action" word for a line — the text right after `HEAD@{N}: ` and
|
|
113
|
+
* before the next space/colon/paren (`reset: moving to ...` → `reset`;
|
|
114
|
+
* `rebase (finish): ...` → `rebase`). null when the line doesn't have the
|
|
115
|
+
* expected shape — never throws.
|
|
116
|
+
*/
|
|
117
|
+
export function reflogAction(line) {
|
|
118
|
+
if (typeof line !== 'string') return null;
|
|
119
|
+
const m = /HEAD@\{\d+\}:\s*([a-zA-Z-]+)/.exec(line);
|
|
120
|
+
return m ? m[1].toLowerCase() : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const FLAGGED_REFLOG_ACTIONS = new Set(['reset', 'checkout', 'rebase']);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Snapshot the git state this audit cares about. Never throws — a repo with no
|
|
127
|
+
* commits yet (empty reflog) or no stash entries yields zeros/nulls.
|
|
128
|
+
*/
|
|
129
|
+
export function captureGitState(repoRootPath) {
|
|
130
|
+
const stashLines = readStashList(repoRootPath);
|
|
131
|
+
const reflogLines = readReflogLines(repoRootPath, 1);
|
|
132
|
+
return {
|
|
133
|
+
stash_count: stashLines.length,
|
|
134
|
+
reflog_head_entry: reflogLines[0] ?? null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sanitizeSessionId(sessionId) {
|
|
139
|
+
const raw =
|
|
140
|
+
typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : 'unknown-session';
|
|
141
|
+
return raw.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Default session-scoped snapshot path — `.heyspex/git-reflog-audit/<sessionId>.json`. */
|
|
145
|
+
export function snapshotPathFor(repoRootPath, sessionId, snapshotDir = 'git-reflog-audit') {
|
|
146
|
+
return path.join(repoRootPath, '.heyspex', snapshotDir, `${sanitizeSessionId(sessionId)}.json`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Session-start half: write the snapshot file. Returns the written snapshot
|
|
151
|
+
* object, or null on any failure (fail-safe — a write failure here must never
|
|
152
|
+
* break session start).
|
|
153
|
+
*/
|
|
154
|
+
export function writeSnapshot({ repoRootPath = repoRoot(), sessionId, snapshotPath } = {}) {
|
|
155
|
+
try {
|
|
156
|
+
const state = captureGitState(repoRootPath);
|
|
157
|
+
const snapshot = { ...state, written_at: new Date().toISOString() };
|
|
158
|
+
const filePath = snapshotPath || snapshotPathFor(repoRootPath, sessionId);
|
|
159
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
160
|
+
fs.writeFileSync(filePath, JSON.stringify(snapshot));
|
|
161
|
+
return snapshot;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Read a previously-written snapshot. null on any failure (missing/corrupt file). */
|
|
168
|
+
export function readSnapshot(snapshotPath) {
|
|
169
|
+
try {
|
|
170
|
+
const raw = fs.readFileSync(snapshotPath, 'utf8');
|
|
171
|
+
const parsed = JSON.parse(raw);
|
|
172
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Diff current git state against a snapshot. Returns
|
|
180
|
+
* { newStashEntries: string[], flaggedReflogEntries: string[] }
|
|
181
|
+
* — both empty on a clean session.
|
|
182
|
+
*/
|
|
183
|
+
export function diffAgainstSnapshot(repoRootPath, snapshot) {
|
|
184
|
+
const result = { newStashEntries: [], flaggedReflogEntries: [] };
|
|
185
|
+
if (!snapshot || typeof snapshot !== 'object') return result;
|
|
186
|
+
|
|
187
|
+
// --- stash growth -------------------------------------------------------
|
|
188
|
+
const currentStash = readStashList(repoRootPath);
|
|
189
|
+
const priorCount = Number.isFinite(snapshot.stash_count) ? snapshot.stash_count : 0;
|
|
190
|
+
const grown = currentStash.length - priorCount;
|
|
191
|
+
if (grown > 0) {
|
|
192
|
+
result.newStashEntries = currentStash.slice(0, grown);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- reflog: reset/checkout/rebase entries newer than the snapshot ------
|
|
196
|
+
const priorEntry =
|
|
197
|
+
typeof snapshot.reflog_head_entry === 'string' ? snapshot.reflog_head_entry : null;
|
|
198
|
+
const currentReflog = readReflogLines(repoRootPath, 100);
|
|
199
|
+
if (currentReflog.length && currentReflog[0] !== priorEntry) {
|
|
200
|
+
const priorNormalized = normalizeReflogLine(priorEntry);
|
|
201
|
+
const idx =
|
|
202
|
+
priorNormalized === null
|
|
203
|
+
? -1
|
|
204
|
+
: currentReflog.findIndex((line) => normalizeReflogLine(line) === priorNormalized);
|
|
205
|
+
// idx === -1 means no baseline (fresh repo) or the old entry fell outside
|
|
206
|
+
// the fetch window — bounded fallback: everything fetched is "new".
|
|
207
|
+
const newLines = idx === -1 ? currentReflog : currentReflog.slice(0, idx);
|
|
208
|
+
result.flaggedReflogEntries = newLines.filter((line) =>
|
|
209
|
+
FLAGGED_REFLOG_ACTIONS.has(reflogAction(line)),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The human-readable half of a flagged diff — `new stash: … ; reflog: …`, the
|
|
218
|
+
* exact `matchContext` string the guardrail-history row carries. Pure.
|
|
219
|
+
*
|
|
220
|
+
* @param {{ newStashEntries?: string[], flaggedReflogEntries?: string[] }} diff
|
|
221
|
+
* @returns {string} '' when nothing was flagged.
|
|
222
|
+
*/
|
|
223
|
+
export function describeDiff(diff) {
|
|
224
|
+
const parts = [];
|
|
225
|
+
if (diff?.newStashEntries?.length) parts.push(`new stash: ${diff.newStashEntries.join(' | ')}`);
|
|
226
|
+
if (diff?.flaggedReflogEntries?.length) {
|
|
227
|
+
parts.push(`reflog: ${diff.flaggedReflogEntries.join(' | ')}`);
|
|
228
|
+
}
|
|
229
|
+
return parts.join(' ; ');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** True when a diff has anything worth warning about. Pure. */
|
|
233
|
+
export function isFlagged(diff) {
|
|
234
|
+
return Boolean(diff?.newStashEntries?.length || diff?.flaggedReflogEntries?.length);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Stop / session-end half: read the snapshot, diff, and — only if something was
|
|
239
|
+
* flagged — append ONE warn row to guardrail-history.jsonl naming it. Returns
|
|
240
|
+
* the diff result (so a caller/test can assert on it), or null when there was
|
|
241
|
+
* no snapshot to compare against (nothing to do, no false-flag risk).
|
|
242
|
+
*
|
|
243
|
+
* @param {{ repoRootPath?: string, sessionId?: string, snapshotPath?: string,
|
|
244
|
+
* append?: (row: object, repoRoot: string) => unknown }} [opts]
|
|
245
|
+
* `append` — override the guardrail-history writer (tests, and any front that
|
|
246
|
+
* wants the decision without the file write).
|
|
247
|
+
*/
|
|
248
|
+
export function runSessionEnd({
|
|
249
|
+
repoRootPath = repoRoot(),
|
|
250
|
+
sessionId,
|
|
251
|
+
snapshotPath,
|
|
252
|
+
append = appendGuardrailEvent,
|
|
253
|
+
} = {}) {
|
|
254
|
+
const filePath = snapshotPath || snapshotPathFor(repoRootPath, sessionId);
|
|
255
|
+
const snapshot = readSnapshot(filePath);
|
|
256
|
+
if (!snapshot) return null;
|
|
257
|
+
|
|
258
|
+
const diff = diffAgainstSnapshot(repoRootPath, snapshot);
|
|
259
|
+
if (!isFlagged(diff)) return diff;
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
append(
|
|
263
|
+
{
|
|
264
|
+
rule: RULE_ID,
|
|
265
|
+
surface: 'session-close',
|
|
266
|
+
severity: 'warn',
|
|
267
|
+
decision: 'warn',
|
|
268
|
+
matchContext: describeDiff(diff),
|
|
269
|
+
},
|
|
270
|
+
repoRootPath,
|
|
271
|
+
);
|
|
272
|
+
} catch {
|
|
273
|
+
// observability must never break the session close
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return diff;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export default {
|
|
280
|
+
RULE_ID,
|
|
281
|
+
captureGitState,
|
|
282
|
+
describeDiff,
|
|
283
|
+
diffAgainstSnapshot,
|
|
284
|
+
isFlagged,
|
|
285
|
+
normalizeReflogLine,
|
|
286
|
+
readReflogLines,
|
|
287
|
+
readSnapshot,
|
|
288
|
+
readStashList,
|
|
289
|
+
reflogAction,
|
|
290
|
+
repoRoot,
|
|
291
|
+
runSessionEnd,
|
|
292
|
+
snapshotPathFor,
|
|
293
|
+
writeSnapshot,
|
|
294
|
+
};
|