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,176 @@
|
|
|
1
|
+
// spexharness/src/doctrine/writer.mjs
|
|
2
|
+
//
|
|
3
|
+
// DR2 (epic [[HS-KNHZ-8CMW-EPIC-spexcode-doctrine-v0-the-foundational]], task
|
|
4
|
+
// [[HS-YMPY-8CMW-TASK-dr2-doctrine-synthesis-a-routed]]) — renders the merged
|
|
5
|
+
// principle set (merge.mjs's output) into the two committed product artifacts:
|
|
6
|
+
// `spexharness/doctrine/SPEXCODE-DOCTRINE.json` (schema-versioned, machine-readable,
|
|
7
|
+
// cited) and `SPEXCODE-DOCTRINE.md` (the human-readable, sectioned document). Pure
|
|
8
|
+
// rendering — {@link renderDoctrineMarkdown}/{@link buildDoctrineJson} do no I/O, so
|
|
9
|
+
// the golden Markdown test never touches disk; {@link writeDoctrineFiles} is the one
|
|
10
|
+
// function that does.
|
|
11
|
+
|
|
12
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
export const DOCTRINE_SCHEMA_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
/** Section order the Markdown writer renders in — matches the epic's own category list. */
|
|
18
|
+
export const CATEGORY_ORDER = [
|
|
19
|
+
'delegation',
|
|
20
|
+
'verification',
|
|
21
|
+
'communication',
|
|
22
|
+
'naming',
|
|
23
|
+
'safety-permissions',
|
|
24
|
+
'research-grounding',
|
|
25
|
+
'session-hygiene',
|
|
26
|
+
'product-taste',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const CATEGORY_LABELS = {
|
|
30
|
+
delegation: 'Delegation',
|
|
31
|
+
verification: 'Verification',
|
|
32
|
+
communication: 'Communication',
|
|
33
|
+
naming: 'Naming',
|
|
34
|
+
'safety-permissions': 'Safety & Permissions',
|
|
35
|
+
'research-grounding': 'Research & Grounding',
|
|
36
|
+
'session-hygiene': 'Session Hygiene',
|
|
37
|
+
'product-taste': 'Product Taste',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const DOCTRINE_HEADER =
|
|
41
|
+
'This is SpexCode Doctrine — the foundational operating principles distilled from the owner\'s ' +
|
|
42
|
+
'Claude Code and Command Code sessions and the Command Code taste profile. It is layer 2 of three: ' +
|
|
43
|
+
'1) SpexDoctrine (the enforced gates in spexharness/src/gates/, never learned), 2) SpexCode Doctrine ' +
|
|
44
|
+
'(this document — the base every developer profile is built on), 3) SpexCode Profile (the per-' +
|
|
45
|
+
'developer layer on top, which may refine a principle here but may not silently contradict one).';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Assemble the schema-versioned JSON document. Pure — `learnedAt` must be supplied by
|
|
49
|
+
* the caller (never `Date.now()` internally) so the golden test is deterministic.
|
|
50
|
+
*
|
|
51
|
+
* @param {{ principles: object[], conflicts?: object[], sources?: object, learnedAt: string }} input
|
|
52
|
+
* @returns {{ schemaVersion: number, learnedAt: string, sources: object, principles: object[], conflicts: object[] }}
|
|
53
|
+
*/
|
|
54
|
+
export function buildDoctrineJson({ principles, conflicts = [], sources = {}, learnedAt }) {
|
|
55
|
+
return {
|
|
56
|
+
schemaVersion: DOCTRINE_SCHEMA_VERSION,
|
|
57
|
+
learnedAt,
|
|
58
|
+
sources,
|
|
59
|
+
principles,
|
|
60
|
+
conflicts,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `claude-code ×5, taste ×1, command-code ×1` — counts an evidence-id list by its `<source>:` prefix. */
|
|
65
|
+
function evidenceBreakdown(evidence) {
|
|
66
|
+
const counts = new Map();
|
|
67
|
+
for (const id of evidence ?? []) {
|
|
68
|
+
const src = typeof id === 'string' && id.includes(':') ? id.slice(0, id.indexOf(':')) : 'unknown';
|
|
69
|
+
counts.set(src, (counts.get(src) ?? 0) + 1);
|
|
70
|
+
}
|
|
71
|
+
return [...counts.entries()].map(([k, v]) => `${k} ×${v}`).join(', ');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function renderPrincipleLine(p) {
|
|
75
|
+
const conf = typeof p.confidence === 'number' ? p.confidence.toFixed(2) : '0.00';
|
|
76
|
+
const evidence = Array.isArray(p.evidence) ? p.evidence : [];
|
|
77
|
+
return `- ${p.text} — confidence ${conf} · evidence ${evidence.length} (${evidenceBreakdown(evidence)})`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Render the sectioned Markdown document from a JSON-shaped doctrine object
|
|
82
|
+
* ({@link buildDoctrineJson}'s output, or anything with the same shape). Deterministic:
|
|
83
|
+
* the same `doc` always renders the same string (no clock reads, no random order —
|
|
84
|
+
* principles within a category render in their given array order).
|
|
85
|
+
*
|
|
86
|
+
* @param {{ learnedAt: string, principles: object[], conflicts?: object[], sources?: object }} doc
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
export function renderDoctrineMarkdown(doc) {
|
|
90
|
+
const lines = [];
|
|
91
|
+
lines.push('# SpexCode Doctrine');
|
|
92
|
+
lines.push('');
|
|
93
|
+
lines.push(`_Learned ${doc.learnedAt}_`);
|
|
94
|
+
lines.push('');
|
|
95
|
+
lines.push(DOCTRINE_HEADER);
|
|
96
|
+
lines.push('');
|
|
97
|
+
|
|
98
|
+
const byCategory = new Map();
|
|
99
|
+
for (const p of doc.principles ?? []) {
|
|
100
|
+
const cat = p.category ?? 'product-taste';
|
|
101
|
+
const list = byCategory.get(cat) ?? [];
|
|
102
|
+
list.push(p);
|
|
103
|
+
byCategory.set(cat, list);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const renderSection = (cat, list) => {
|
|
107
|
+
lines.push(`## ${CATEGORY_LABELS[cat] ?? cat}`);
|
|
108
|
+
lines.push('');
|
|
109
|
+
for (const p of list) lines.push(renderPrincipleLine(p));
|
|
110
|
+
lines.push('');
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
for (const cat of CATEGORY_ORDER) {
|
|
114
|
+
const list = byCategory.get(cat);
|
|
115
|
+
if (list && list.length) renderSection(cat, list);
|
|
116
|
+
byCategory.delete(cat);
|
|
117
|
+
}
|
|
118
|
+
// Defensive: any category the seat invented outside CATEGORY_ORDER still renders,
|
|
119
|
+
// in the stable order it was first seen, rather than being silently dropped.
|
|
120
|
+
for (const [cat, list] of byCategory) renderSection(cat, list);
|
|
121
|
+
|
|
122
|
+
lines.push('## Conflicts');
|
|
123
|
+
lines.push('');
|
|
124
|
+
const conflicts = doc.conflicts ?? [];
|
|
125
|
+
if (!conflicts.length) {
|
|
126
|
+
lines.push('_None recorded._');
|
|
127
|
+
} else {
|
|
128
|
+
for (const c of conflicts) lines.push(`- **${c.category}**: "${c.a}" vs "${c.b}" — headline: ${c.headline}`);
|
|
129
|
+
}
|
|
130
|
+
lines.push('');
|
|
131
|
+
|
|
132
|
+
lines.push('## Sources');
|
|
133
|
+
lines.push('');
|
|
134
|
+
const sources = doc.sources ?? {};
|
|
135
|
+
lines.push(`- Observations: ${sources.totalObservations ?? 0}`);
|
|
136
|
+
const bySource = sources.bySource ?? {};
|
|
137
|
+
for (const [k, v] of Object.entries(bySource)) lines.push(` - ${k}: ${v}`);
|
|
138
|
+
// DR5 ([[HS-7Z9E-8CMW-TASK-dr5-vault-knowledge-as-a-doctrine]]) — the vault sets
|
|
139
|
+
// (decisions, lessons, protocol enforcement, ADRs, the naughty list, CLAUDE.md, ...)
|
|
140
|
+
// that fed the `vault` row above, broken out with their own per-set counts.
|
|
141
|
+
const vaultSets = sources.vaultSets ?? {};
|
|
142
|
+
if (Object.keys(vaultSets).length) {
|
|
143
|
+
lines.push(' - vault sets:');
|
|
144
|
+
for (const [k, v] of Object.entries(vaultSets)) lines.push(` - ${k}: ${v}`);
|
|
145
|
+
}
|
|
146
|
+
lines.push('');
|
|
147
|
+
|
|
148
|
+
return lines.join('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function defaultDoctrineOutDir(repoRoot) {
|
|
152
|
+
return join(repoRoot, 'spexharness', 'doctrine');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function defaultDoctrineMdPath(repoRoot) {
|
|
156
|
+
return join(defaultDoctrineOutDir(repoRoot), 'SPEXCODE-DOCTRINE.md');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function defaultDoctrineJsonPath(repoRoot) {
|
|
160
|
+
return join(defaultDoctrineOutDir(repoRoot), 'SPEXCODE-DOCTRINE.json');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Write both product artifacts for `repoRoot`. The only I/O in this module.
|
|
165
|
+
* @param {string} repoRoot
|
|
166
|
+
* @param {ReturnType<typeof buildDoctrineJson>} doc
|
|
167
|
+
* @returns {{ jsonPath: string, mdPath: string }}
|
|
168
|
+
*/
|
|
169
|
+
export function writeDoctrineFiles(repoRoot, doc) {
|
|
170
|
+
mkdirSync(defaultDoctrineOutDir(repoRoot), { recursive: true });
|
|
171
|
+
const jsonPath = defaultDoctrineJsonPath(repoRoot);
|
|
172
|
+
const mdPath = defaultDoctrineMdPath(repoRoot);
|
|
173
|
+
writeFileSync(jsonPath, `${JSON.stringify(doc, null, 2)}\n`);
|
|
174
|
+
writeFileSync(mdPath, `${renderDoctrineMarkdown(doc)}\n`);
|
|
175
|
+
return { jsonPath, mdPath };
|
|
176
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// spexharness/src/events.mjs
|
|
2
|
+
//
|
|
3
|
+
// The harness's normalized event model. Every front (Claude Code plugin, `hs
|
|
4
|
+
// chat`, a headless seat) translates whatever its own runtime hands it into
|
|
5
|
+
// one of these six shapes before calling into the gate registry / decider —
|
|
6
|
+
// so a gate is written once against this shape and never against a
|
|
7
|
+
// front-specific payload.
|
|
8
|
+
//
|
|
9
|
+
// This module is pure: no I/O, no process.stdin/stdout, no imports outside
|
|
10
|
+
// node:* stdlib.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The six event points a gate may run on, in the order they occur during a
|
|
14
|
+
* session:
|
|
15
|
+
* 1. session_start — a session begins.
|
|
16
|
+
* 2. prompt_submit — the user (or caller) submits a prompt.
|
|
17
|
+
* 3. before_tool — a tool is about to run (pre-flight; may deny/rewrite).
|
|
18
|
+
* 4. after_tool — a tool finished running (post-flight; may inject).
|
|
19
|
+
* 5. stop — the agent is about to stop / hand control back.
|
|
20
|
+
* 6. session_end — a session ends.
|
|
21
|
+
* @type {ReadonlyArray<string>}
|
|
22
|
+
*/
|
|
23
|
+
export const EVENT_POINTS = Object.freeze([
|
|
24
|
+
'session_start',
|
|
25
|
+
'prompt_submit',
|
|
26
|
+
'before_tool',
|
|
27
|
+
'after_tool',
|
|
28
|
+
'stop',
|
|
29
|
+
'session_end',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const EVENT_POINT_SET = new Set(EVENT_POINTS);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Documented field set per event point. `sessionId` and `ts` are common to
|
|
36
|
+
* every event (added by {@link createEvent} itself); the arrays below are
|
|
37
|
+
* the additional fields each event point's `fields` argument is expected to
|
|
38
|
+
* carry. This is documentation, not a runtime schema — createEvent does not
|
|
39
|
+
* reject extra or missing fields, so a front can pass whatever its own
|
|
40
|
+
* runtime already has without a translation step per gate.
|
|
41
|
+
*
|
|
42
|
+
* - session_start: cwd, agent
|
|
43
|
+
* - prompt_submit: prompt
|
|
44
|
+
* - before_tool: toolName, toolInput
|
|
45
|
+
* - after_tool: toolName, toolInput, toolResult
|
|
46
|
+
* - stop: (none beyond the common fields)
|
|
47
|
+
* - session_end: reason
|
|
48
|
+
* @type {Readonly<Record<string, ReadonlyArray<string>>>}
|
|
49
|
+
*/
|
|
50
|
+
export const EVENT_FIELDS = Object.freeze({
|
|
51
|
+
session_start: Object.freeze(['cwd', 'agent']),
|
|
52
|
+
prompt_submit: Object.freeze(['prompt']),
|
|
53
|
+
before_tool: Object.freeze(['toolName', 'toolInput']),
|
|
54
|
+
after_tool: Object.freeze(['toolName', 'toolInput', 'toolResult']),
|
|
55
|
+
stop: Object.freeze([]),
|
|
56
|
+
session_end: Object.freeze(['reason']),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build one normalized event object for a gate to run against.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} point - one of {@link EVENT_POINTS}. Throws on any other
|
|
63
|
+
* value, including a Claude-Code-shaped name like `"PreToolUse"` — callers
|
|
64
|
+
* translate into this vocabulary at the edge, not inside a gate.
|
|
65
|
+
* @param {Record<string, unknown>} [fields] - the event point's documented
|
|
66
|
+
* fields (see {@link EVENT_FIELDS}), plus any extra context a front wants
|
|
67
|
+
* to carry through to its own gates. Not validated against the field list;
|
|
68
|
+
* the list is documentation for gate authors, not a runtime schema.
|
|
69
|
+
* @returns {Readonly<{ point: string, ts: number } & Record<string, unknown>>}
|
|
70
|
+
* A frozen, normalized event object. `ts` defaults to `Date.now()` when
|
|
71
|
+
* `fields.ts` is not supplied.
|
|
72
|
+
*/
|
|
73
|
+
export function createEvent(point, fields = {}) {
|
|
74
|
+
if (!EVENT_POINT_SET.has(point)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`spexharness: unknown event point "${point}" — expected one of: ${EVENT_POINTS.join(', ')}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return Object.freeze({
|
|
80
|
+
ts: Date.now(),
|
|
81
|
+
...fields,
|
|
82
|
+
point,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// spexharness/src/gates/capture-workflow-gate.mjs
|
|
2
|
+
//
|
|
3
|
+
// Engine port of scripts/claude-hooks/capture-workflow-gate.mjs — the
|
|
4
|
+
// PreToolUse EARLY-WARNING gate for filing an implementation spec
|
|
5
|
+
// (epic/task/milestone) that references the capture currently on deck. See
|
|
6
|
+
// ./PORTING.md for the recipe this follows and ./env-vault-guard.mjs for the
|
|
7
|
+
// reference port.
|
|
8
|
+
//
|
|
9
|
+
// THIS HOOK NEVER DENIES (by design — see the original's header). The real
|
|
10
|
+
// refusal lives in heyspex_file_spec / vendor/heyspex-mcp/capture-stage-gate.mjs,
|
|
11
|
+
// which reads the LIVE capture-on-deck pin (the console / Neon control
|
|
12
|
+
// plane); this hook can only see the OFFLINE file pin
|
|
13
|
+
// (.heyspex/capture-focus.yaml), so it is kept as a narrow, always-silent-
|
|
14
|
+
// unless-matched early warning, never a blocker.
|
|
15
|
+
//
|
|
16
|
+
// The original script does NOT export a pure decision function — its logic
|
|
17
|
+
// is tangled inside `main()` (stdin read, env checks, then the match logic).
|
|
18
|
+
// Per PORTING.md step 1's "otherwise" branch that logic would normally be
|
|
19
|
+
// extracted into the original script itself, but this task's Do-nots forbid
|
|
20
|
+
// touching anything under scripts/claude-hooks/. Rather than hand-copy the
|
|
21
|
+
// original's rule/regex logic (the thing PORTING.md warns will drift), this
|
|
22
|
+
// port imports the SAME shared, already-pure library modules the original
|
|
23
|
+
// hook itself imports for the actual decision surface:
|
|
24
|
+
// - vendor/heyspex-mcp/capture-stage-gate.mjs — BLOCKED_FILE_SPEC_TYPES,
|
|
25
|
+
// ALLOWED_FILE_SPEC_TYPES, referencesCapture(). This is the canonical
|
|
26
|
+
// module the original hook's header says it shares "so the two can
|
|
27
|
+
// never disagree about which filings count" — importing it here keeps
|
|
28
|
+
// that same guarantee for the port.
|
|
29
|
+
// - vendor/heyspex-mcp/capture-focus.mjs — resolveActiveCapture(), which
|
|
30
|
+
// reads the offline pin + the pinned capture's frontmatter/body.
|
|
31
|
+
// The only code re-derived here is the original's small, non-security
|
|
32
|
+
// glue: stripping the MCP tool-name prefix (bareToolName), and the tiny
|
|
33
|
+
// offline frontmatter scanner + spec loader the original defines locally
|
|
34
|
+
// (parseFrontmatter / loadSpec) purely to satisfy resolveActiveCapture's
|
|
35
|
+
// callback-shaped contract — neither is a rule/regex guard, both are
|
|
36
|
+
// deterministic parsing utilities with no security surface of their own.
|
|
37
|
+
//
|
|
38
|
+
// Decision mapping (documented judgment call, same shape as
|
|
39
|
+
// env-vault-guard.mjs's "ask" → rewrite mapping): a match produces a WARNING
|
|
40
|
+
// carrying the capture id and the sanctioned-exit message — richer than a
|
|
41
|
+
// plain allow, and the original never denies, so silently collapsing this to
|
|
42
|
+
// `allow` would drop information a caller might want to surface (e.g. show
|
|
43
|
+
// the same warning text the Claude Code hook would have written to stderr).
|
|
44
|
+
// Mapped to `rewrite` with `{ warn: true, reason, captureId, type }` rather
|
|
45
|
+
// than `deny` (the hook's own header is explicit: "it never denies") or a
|
|
46
|
+
// silent `allow` (loses the warning). No match, or any early-out (kill
|
|
47
|
+
// switch, wrong tool, allowed/unmatched type, no capture on deck, unrelated
|
|
48
|
+
// filing) → plain `allow`.
|
|
49
|
+
//
|
|
50
|
+
// This gate never hard-denies (matching the original) — "deny" coverage for
|
|
51
|
+
// AC3 ("allow, deny and rewrite each covered by at least one test per gate",
|
|
52
|
+
// checked as one combined `node --test` run across all three ported gates in
|
|
53
|
+
// this task) comes from the sibling enforce-session-sequence gate. See
|
|
54
|
+
// ./enforce-session-sequence.test.mjs.
|
|
55
|
+
//
|
|
56
|
+
// Env hatches (unchanged, either one silences the warning — matching the
|
|
57
|
+
// original, which honors the tool's own break-glass too so one variable
|
|
58
|
+
// covers both layers):
|
|
59
|
+
// HEYSPEX_CAPTURE_WORKFLOW_GATE_OFF=1
|
|
60
|
+
// HEYSPEX_CAPTURE_STAGE_GATE_OFF=1
|
|
61
|
+
//
|
|
62
|
+
// Not ported HERE: the original's `appendGuardrailEvent(...)` audit-log
|
|
63
|
+
// write on a match. A gate module stays a pure decision function (AC1: no
|
|
64
|
+
// stdout writes; by the same spirit, no other observability side effects
|
|
65
|
+
// either) — matching env-vault-guard.mjs's port. The `rewrite.captureId` /
|
|
66
|
+
// `rewrite.type` fields below already carry everything a front needs to
|
|
67
|
+
// rebuild that same audit row — see spexharness/src/adapters/audit.mjs,
|
|
68
|
+
// called from spexharness/src/adapters/claude-code.mjs's runGateAsHook.
|
|
69
|
+
//
|
|
70
|
+
// `ctx` contract (all optional; every field defaults to a real fs read, same
|
|
71
|
+
// as the original hook and as spexharness/fixtures/lib/setups.mjs's
|
|
72
|
+
// captureActiveVault — the golden/regression tests below point ctx.cwd at a
|
|
73
|
+
// real scratch vault rather than injecting a fake fs):
|
|
74
|
+
// ctx.cwd - repo/vault root to resolve the offline capture pin from.
|
|
75
|
+
// Defaults to CLAUDE_PROJECT_DIR, else this repo's root.
|
|
76
|
+
// ctx.env - env var overrides (tests use this instead of mutating
|
|
77
|
+
// process.env).
|
|
78
|
+
|
|
79
|
+
import fs from 'node:fs';
|
|
80
|
+
import path from 'node:path';
|
|
81
|
+
import { fileURLToPath } from 'node:url';
|
|
82
|
+
|
|
83
|
+
import { resolveActiveCapture } from '../../../vendor/heyspex-mcp/capture-focus.mjs';
|
|
84
|
+
import {
|
|
85
|
+
ALLOWED_FILE_SPEC_TYPES,
|
|
86
|
+
BLOCKED_FILE_SPEC_TYPES,
|
|
87
|
+
referencesCapture,
|
|
88
|
+
} from '../../../vendor/heyspex-mcp/capture-stage-gate.mjs';
|
|
89
|
+
|
|
90
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
91
|
+
const REPO_ROOT = path.resolve(HERE, '..', '..', '..');
|
|
92
|
+
|
|
93
|
+
// The bare (server-stripped) MCP tool name this gate acts on.
|
|
94
|
+
const FILE_SPEC_TOOL = 'heyspex_file_spec';
|
|
95
|
+
const OFF_ENV_WORKFLOW = 'HEYSPEX_CAPTURE_WORKFLOW_GATE_OFF';
|
|
96
|
+
const OFF_ENV_STAGE = 'HEYSPEX_CAPTURE_STAGE_GATE_OFF';
|
|
97
|
+
|
|
98
|
+
// Minimal, sync, offline frontmatter parser — mirrors the original hook's
|
|
99
|
+
// local helper of the same name byte-for-byte. Enough for
|
|
100
|
+
// resolveActiveCapture's callbacks (it only reads frontmatter.id / status /
|
|
101
|
+
// promoted_to and the body). Not a full YAML parser; capture frontmatter is
|
|
102
|
+
// flat scalars + simple arrays.
|
|
103
|
+
function parseFrontmatter(content) {
|
|
104
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
105
|
+
if (!m) return { frontmatter: {}, body: content };
|
|
106
|
+
const frontmatter = {};
|
|
107
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
108
|
+
const trimmed = line.trim();
|
|
109
|
+
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('- ')) continue;
|
|
110
|
+
const colon = trimmed.indexOf(':');
|
|
111
|
+
if (colon === -1) continue;
|
|
112
|
+
const key = trimmed.slice(0, colon).trim();
|
|
113
|
+
let val = trimmed.slice(colon + 1).trim();
|
|
114
|
+
val = val.replace(/^["']|["']$/g, '');
|
|
115
|
+
frontmatter[key] = val;
|
|
116
|
+
}
|
|
117
|
+
return { frontmatter, body: m[2] ?? '' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// readFileSync loader for resolveActiveCapture — returns { frontmatter, body }
|
|
121
|
+
// or null when the pinned capture file is missing/unreadable. Mirrors the
|
|
122
|
+
// original hook's local helper of the same name.
|
|
123
|
+
function loadSpec(vaultRoot, specPath) {
|
|
124
|
+
try {
|
|
125
|
+
const full = path.join(vaultRoot, specPath);
|
|
126
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
127
|
+
return parseFrontmatter(content);
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Strip the Claude Code MCP tool prefix (mcp__<server>__<tool>) → bare tool
|
|
134
|
+
// name. Mirrors the original hook's local helper of the same name.
|
|
135
|
+
function bareToolName(name) {
|
|
136
|
+
if (typeof name !== 'string') return '';
|
|
137
|
+
const m = name.match(/^mcp__[^_]+(?:_[^_]+)*?__(.+)$/);
|
|
138
|
+
if (m) return m[1];
|
|
139
|
+
const idx = name.lastIndexOf('__');
|
|
140
|
+
return idx === -1 ? name : name.slice(idx + 2);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function warnText(captureId) {
|
|
144
|
+
return (
|
|
145
|
+
`[capture-workflow-gate] warning — this filing links the open capture ${captureId}, ` +
|
|
146
|
+
`which a capture must not become. The sanctioned exit is promote-to-research (\`/hs-res\`). ` +
|
|
147
|
+
`This hook only warns. heyspex_file_spec is where the rule is enforced, and it ` +
|
|
148
|
+
`reads the live capture pin rather than the offline file this hook can see.`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The capture-workflow gate's id — unique across the built-in registry. */
|
|
153
|
+
export const id = 'capture-workflow-gate';
|
|
154
|
+
/** Runs pre-tool, same as its PreToolUse Claude Code hook registration. */
|
|
155
|
+
export const event = 'before_tool';
|
|
156
|
+
/** Caller-defined severity label; this gate only ever warns (rewrites) or allows. */
|
|
157
|
+
export const severity = 'warn';
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {{ point: string, toolName?: string, toolInput?: Record<string, unknown> }} evt
|
|
161
|
+
* @param {{ cwd?: string, env?: Record<string, string | undefined> }} [ctx]
|
|
162
|
+
* @returns {{ decision: 'allow'|'rewrite', reason?: string, rewrite?: object }}
|
|
163
|
+
*/
|
|
164
|
+
export function run(evt, ctx = {}) {
|
|
165
|
+
const env = ctx.env ?? process.env;
|
|
166
|
+
if (String(env[OFF_ENV_WORKFLOW] ?? '') === '1') return { decision: 'allow' };
|
|
167
|
+
if (String(env[OFF_ENV_STAGE] ?? '') === '1') return { decision: 'allow' };
|
|
168
|
+
|
|
169
|
+
const tool = bareToolName(evt?.toolName);
|
|
170
|
+
if (tool !== FILE_SPEC_TOOL) return { decision: 'allow' };
|
|
171
|
+
|
|
172
|
+
const toolInput = evt?.toolInput ?? {};
|
|
173
|
+
const type = String(toolInput?.type ?? '').trim().toLowerCase();
|
|
174
|
+
|
|
175
|
+
// Only implementation specs are the premature-promotion case. Research /
|
|
176
|
+
// diagnose / memory / pillar / review filings are sanctioned exits or
|
|
177
|
+
// unrelated — never warned about.
|
|
178
|
+
if (!BLOCKED_FILE_SPEC_TYPES.has(type) || ALLOWED_FILE_SPEC_TYPES.has(type)) {
|
|
179
|
+
return { decision: 'allow' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const root = ctx.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? REPO_ROOT;
|
|
183
|
+
let resolved;
|
|
184
|
+
try {
|
|
185
|
+
resolved = resolveActiveCapture(root, parseFrontmatter, loadSpec);
|
|
186
|
+
} catch {
|
|
187
|
+
return { decision: 'allow' }; // discovery failure → stay quiet, fail-safe
|
|
188
|
+
}
|
|
189
|
+
if (!resolved || resolved.error || !resolved.spec_id) return { decision: 'allow' };
|
|
190
|
+
|
|
191
|
+
// Arg-targeted: only warn when the spec being filed references THIS capture.
|
|
192
|
+
if (!referencesCapture(toolInput, resolved.spec_id)) return { decision: 'allow' };
|
|
193
|
+
|
|
194
|
+
const reason = warnText(resolved.spec_id);
|
|
195
|
+
return {
|
|
196
|
+
decision: 'rewrite',
|
|
197
|
+
reason,
|
|
198
|
+
rewrite: { warn: true, reason, captureId: resolved.spec_id, type },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export default { id, event, severity, run };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// spexharness/src/gates/claim-before-code-gate.decide.mjs
|
|
2
|
+
//
|
|
3
|
+
// Pure decision logic relocated from scripts/claude-hooks/claim-before-code-gate.mjs
|
|
4
|
+
// (HS-IQBF, per PORTING.md's "move the pure decision logic into the gate
|
|
5
|
+
// module itself or a sibling pure module" — the original hook now delegates
|
|
6
|
+
// to this file too, so ./claim-before-code-gate.mjs (the gate) and the
|
|
7
|
+
// original hook's own smoke test both import the SAME code; nothing here is
|
|
8
|
+
// a second copy that can drift). No stdin/stdout/process.exit anywhere in
|
|
9
|
+
// this file.
|
|
10
|
+
|
|
11
|
+
const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit']);
|
|
12
|
+
|
|
13
|
+
// Paths this gate never blocks: the vault itself (filing the epic IS the
|
|
14
|
+
// required step), hook/state machinery, and anything outside the repo tree.
|
|
15
|
+
// Everything else counts as "code" for this gate's purposes.
|
|
16
|
+
const EXEMPT_PATH_RE = /(?:^|[\\/])(?:specs|\.heyspex|\.claude|\.git)(?:[\\/]|$)/;
|
|
17
|
+
|
|
18
|
+
function isCodeEditPath(filePath) {
|
|
19
|
+
if (typeof filePath !== 'string' || !filePath.trim()) return false;
|
|
20
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
21
|
+
return !EXEMPT_PATH_RE.test(normalized);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pure decision function (exported for the smoke test and the gate): given
|
|
26
|
+
* the resolved inputs, should this edit be denied?
|
|
27
|
+
*/
|
|
28
|
+
export function shouldDenyEdit({ toolName, filePath, hasMarker, hasNoClaimSignal }) {
|
|
29
|
+
if (!EDIT_TOOL_NAMES.has(toolName)) return false;
|
|
30
|
+
if (!isCodeEditPath(filePath)) return false;
|
|
31
|
+
if (!hasMarker) return false;
|
|
32
|
+
return hasNoClaimSignal;
|
|
33
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// spexharness/src/gates/claim-before-code-gate.mjs
|
|
2
|
+
//
|
|
3
|
+
// Engine port of scripts/claude-hooks/claim-before-code-gate.mjs — the
|
|
4
|
+
// PreToolUse gate that denies the first post-plan CODE edit made by a
|
|
5
|
+
// session that exited plan mode with no active claim
|
|
6
|
+
// ([[HS-38J5-2BXD-DIAG-plan-execute-boundary-has-no-ingest-clai]] item 2).
|
|
7
|
+
// See ./PORTING.md for the recipe this follows and ./env-vault-guard.mjs for
|
|
8
|
+
// the reference port.
|
|
9
|
+
//
|
|
10
|
+
// This module does NOT reimplement the guard's decision logic. The pure
|
|
11
|
+
// decision function — `shouldDenyEdit({ toolName, filePath, hasMarker,
|
|
12
|
+
// hasNoClaimSignal })` — with no stdin/stdout/process-exit of its own, lives
|
|
13
|
+
// in ./claim-before-code-gate.decide.mjs (relocated out of the original hook
|
|
14
|
+
// script, HS-IQBF; see PORTING.md and that sibling module's own header for
|
|
15
|
+
// why) and is IMPORTED here unchanged: it already encapsulates the
|
|
16
|
+
// tool-name filter (EDIT_TOOL_NAMES) and the code-path filter
|
|
17
|
+
// (isCodeEditPath) internally, so this module never needs its own copy of
|
|
18
|
+
// either.
|
|
19
|
+
//
|
|
20
|
+
// The two SIGNAL readers (hasMarker / hasNoClaimSignal) are also imported
|
|
21
|
+
// unchanged, not re-derived:
|
|
22
|
+
// - scripts/claude-hooks/plan-exit-ingest-gate.mjs — hasFreshPlanExitMarker,
|
|
23
|
+
// repoRoot. Also guarded by its own INVOKED_DIRECTLY check.
|
|
24
|
+
// - scripts/claude-hooks/claim-liveness-shared.mjs — resolveSessionId,
|
|
25
|
+
// hasFreshNoClaimSignal. A dependency-free shared module with no
|
|
26
|
+
// unconditional main() at all (its own async orchestration function is
|
|
27
|
+
// exported, never self-invoked at import time).
|
|
28
|
+
//
|
|
29
|
+
// Event mapping: PreToolUse (Edit|Write|MultiEdit) → before_tool.
|
|
30
|
+
// stdin.session_id / stdin.conversation_id → evt.sessionId (resolveSessionId
|
|
31
|
+
// also falls back to the real process env, unchanged).
|
|
32
|
+
// stdin.tool_input.file_path (or .path) → evt.toolInput.file_path/.path.
|
|
33
|
+
//
|
|
34
|
+
// Decision mapping — TWO rungs, same shape as plan-review-gate.mjs's port:
|
|
35
|
+
// CLAUDE_AGENT=true or HEYSPEX_CLAIM_GATE_INTERACTIVE=block ("forReal") maps
|
|
36
|
+
// a `shouldDenyEdit` true to a real `deny`; the interactive default maps the
|
|
37
|
+
// SAME finding to a `rewrite` envelope (`{ warn: true, reason, rule }`) — the
|
|
38
|
+
// edit still proceeds, matching the original's stderr-only warn path
|
|
39
|
+
// exactly. `shouldDenyEdit` false (no marker, no fresh no-claim signal, wrong
|
|
40
|
+
// tool, or a specs/.heyspex/.claude/.git path) → plain `allow`.
|
|
41
|
+
//
|
|
42
|
+
// Not ported: the original's `appendGuardrailEvent(...)` audit-log write on
|
|
43
|
+
// a fire — matching every other port in this set.
|
|
44
|
+
//
|
|
45
|
+
// Env hatches (unchanged):
|
|
46
|
+
// HEYSPEX_CLAIM_GATE_OFF=1 Hard kill switch — silences both rungs.
|
|
47
|
+
// HEYSPEX_CLAIM_GATE_INTERACTIVE=block Raise the interactive rung to a real deny.
|
|
48
|
+
// CLAUDE_AGENT=true Unattended session → real deny (unchanged).
|
|
49
|
+
//
|
|
50
|
+
// `ctx` contract (all optional; every field defaults to a real fs/env read):
|
|
51
|
+
// ctx.cwd - repo root to resolve the plan-exit marker and
|
|
52
|
+
// claim-liveness cache from. Defaults to
|
|
53
|
+
// plan-exit-ingest-gate.mjs's own repoRoot()
|
|
54
|
+
// (CLAUDE_PROJECT_DIR, else this repo's root).
|
|
55
|
+
// ctx.sessionId - override for the resolved session id (tests
|
|
56
|
+
// pin this directly instead of an env var).
|
|
57
|
+
// Defaults to resolveSessionId() against
|
|
58
|
+
// `{ session_id: evt.sessionId }`.
|
|
59
|
+
// ctx.env - env var overrides (tests use this instead of
|
|
60
|
+
// mutating process.env).
|
|
61
|
+
|
|
62
|
+
import { resolveSessionId, hasFreshNoClaimSignal } from '../../../scripts/claude-hooks/claim-liveness-shared.mjs';
|
|
63
|
+
import { hasFreshPlanExitMarker, repoRoot as planExitRepoRoot } from '../../../scripts/claude-hooks/plan-exit-ingest-gate.mjs';
|
|
64
|
+
import { shouldDenyEdit } from './claim-before-code-gate.decide.mjs';
|
|
65
|
+
|
|
66
|
+
// Copied verbatim from the original's own (unexported) denyReason.
|
|
67
|
+
function denyReason() {
|
|
68
|
+
return (
|
|
69
|
+
'HEYSPEX claim gate: this session exited plan mode and has no active claim — ' +
|
|
70
|
+
'the plan→execute boundary requires filing the epic (if not yet filed) and calling ' +
|
|
71
|
+
'heyspex_claim_spec before the first code edit ([[HS-38J5-2BXD-DIAG-plan-execute-boundary-has-no-ingest-clai]]). ' +
|
|
72
|
+
'Claim the spec you are working, then retry this edit. ' +
|
|
73
|
+
'(Break-glass: set HEYSPEX_CLAIM_GATE_OFF=1 for this session.)'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function shouldBlockForReal(env) {
|
|
78
|
+
return env.CLAUDE_AGENT === 'true' || env.HEYSPEX_CLAIM_GATE_INTERACTIVE === 'block';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The claim-before-code gate's id — unique across the built-in registry. */
|
|
82
|
+
export const id = 'claim-before-code-gate';
|
|
83
|
+
/** Runs pre-tool, same as its PreToolUse Claude Code hook registration. */
|
|
84
|
+
export const event = 'before_tool';
|
|
85
|
+
/** Caller-defined severity label; this gate can deny, so it is "block". */
|
|
86
|
+
export const severity = 'block';
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {{ point: string, toolName?: string, toolInput?: Record<string, unknown>, sessionId?: string }} evt
|
|
90
|
+
* @param {{ cwd?: string, sessionId?: string, env?: Record<string, string | undefined> }} [ctx]
|
|
91
|
+
* @returns {{ decision: 'allow'|'deny'|'rewrite', reason?: string, rewrite?: object }}
|
|
92
|
+
*/
|
|
93
|
+
export function run(evt, ctx = {}) {
|
|
94
|
+
const env = ctx.env ?? process.env;
|
|
95
|
+
if (String(env.HEYSPEX_CLAIM_GATE_OFF ?? '') === '1') {
|
|
96
|
+
return { decision: 'allow' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const toolName = evt?.toolName;
|
|
100
|
+
const toolInput = evt?.toolInput ?? {};
|
|
101
|
+
const filePath = toolInput.file_path ?? toolInput.path;
|
|
102
|
+
|
|
103
|
+
const sessionId = ctx.sessionId ?? resolveSessionId({ session_id: evt?.sessionId });
|
|
104
|
+
if (!sessionId) return { decision: 'allow' }; // can't scope the check — fail open
|
|
105
|
+
|
|
106
|
+
const root = ctx.cwd ?? planExitRepoRoot();
|
|
107
|
+
const hasMarker = hasFreshPlanExitMarker(sessionId, root);
|
|
108
|
+
const hasNoClaimSignal = hasFreshNoClaimSignal(sessionId, root);
|
|
109
|
+
|
|
110
|
+
if (!shouldDenyEdit({ toolName, filePath, hasMarker, hasNoClaimSignal })) {
|
|
111
|
+
return { decision: 'allow' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const reason = denyReason();
|
|
115
|
+
const forReal = shouldBlockForReal(env);
|
|
116
|
+
if (forReal) return { decision: 'deny', reason };
|
|
117
|
+
|
|
118
|
+
// Interactive warn path: byte-for-byte the original hook's fireDeny()
|
|
119
|
+
// stderr banner ([claim-before-code-gate] warning — ...\n(Interactive
|
|
120
|
+
// session: ...)\n) — kept here (not left to a generic adapter formatter)
|
|
121
|
+
// because it is gate-specific text, same convention as naughty-list-gate's
|
|
122
|
+
// and capture-workflow-gate's own self-contained warn reasons.
|
|
123
|
+
const warnReason =
|
|
124
|
+
`[claim-before-code-gate] warning — ${reason}\n` +
|
|
125
|
+
'(Interactive session: this only warns. Set HEYSPEX_CLAIM_GATE_INTERACTIVE=block to enforce it here too.)\n';
|
|
126
|
+
return { decision: 'rewrite', reason: warnReason, rewrite: { warn: true, reason: warnReason, rule: 'claim-before-code-gate' } };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export default { id, event, severity, run };
|