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,895 @@
|
|
|
1
|
+
// spexharness/src/gates/enforce-session-sequence.mjs
|
|
2
|
+
//
|
|
3
|
+
// Engine port of scripts/claude-hooks/enforce-session-sequence.mjs — the
|
|
4
|
+
// Stop-hook L3 SEQUENCING gate ([[HS-EPIC-ZGWR]] AC#4): once a session has
|
|
5
|
+
// flipped a spec to `status: in_review`, the structured handoff
|
|
6
|
+
// (heyspex_record_wip) and a progress checkpoint (heyspex_report_progress)
|
|
7
|
+
// are hard close-out steps. See ./PORTING.md for the recipe this follows and
|
|
8
|
+
// ./env-vault-guard.mjs for the reference port.
|
|
9
|
+
//
|
|
10
|
+
// The original script does NOT export a pure decision function — its logic
|
|
11
|
+
// (`scanTranscript`, `toolUseIsFlip`, `isUnderSpecsTaskOrEpic`,
|
|
12
|
+
// `shouldBlockForReal`, `fireRule`) lives as un-exported helpers inside the
|
|
13
|
+
// file, tangled with `main()`'s stdin read. Per PORTING.md step 1's
|
|
14
|
+
// "otherwise" branch that logic would normally be extracted into the
|
|
15
|
+
// original script itself, but this task's Do-nots forbid touching anything
|
|
16
|
+
// under scripts/claude-hooks/. This port therefore re-derives those helpers
|
|
17
|
+
// (transcript scanning, the in_review flip detector, the interactive-vs-
|
|
18
|
+
// unattended rung check) here, verbatim in behavior, while importing the
|
|
19
|
+
// REAL shared/canonical dependencies unchanged rather than re-deriving them:
|
|
20
|
+
// - scripts/claude-hooks/_guardrail-loader.mjs — loadGuardrails,
|
|
21
|
+
// rulesForSurface, isRuleEnabled. This is a dependency-free shared
|
|
22
|
+
// loader (not one of the twelve hook scripts themselves; no stdin read,
|
|
23
|
+
// no unconditional main()), so importing it carries no import hazard and
|
|
24
|
+
// keeps the toggle/reason-text source of truth identical to the
|
|
25
|
+
// original (the compiled `.heyspex/guardrails.json`, authored from
|
|
26
|
+
// session-close-protocol.md's `enforcement:` block).
|
|
27
|
+
// - vendor/heyspex-mcp/vault-resolve.mjs — getActiveConfig(), for the
|
|
28
|
+
// same optional second vault-root base the original's `vaultRoot()`
|
|
29
|
+
// helper resolves (specs living in a vault split out from the code
|
|
30
|
+
// repo). Also dependency-free / no import hazard.
|
|
31
|
+
// Nothing here re-derives the wip/progress reason TEXT or the enable/disable
|
|
32
|
+
// toggle logic — both come from the same compiled registry the original
|
|
33
|
+
// reads, so the two cannot drift on THAT axis; what's re-derived is the
|
|
34
|
+
// transcript-scanning glue, which carries no rule content of its own.
|
|
35
|
+
//
|
|
36
|
+
// IMPORT HAZARD NOTE (why the original script itself is never imported,
|
|
37
|
+
// unlike settings-write-gate.mjs which imports its original lazily):
|
|
38
|
+
// enforce-session-sequence.mjs's `main()` is also unguarded (no
|
|
39
|
+
// `isDirectExecution` check) and also reads stdin unconditionally — but it
|
|
40
|
+
// exports no pure function worth reaching for in the first place (see
|
|
41
|
+
// above), so there is nothing to gain by importing it at all, hazardous or
|
|
42
|
+
// otherwise. It is simply never imported here.
|
|
43
|
+
//
|
|
44
|
+
// Event/field mapping: a Stop hook → the engine's `stop` event point.
|
|
45
|
+
// stdin.transcript_path → evt.transcriptPath
|
|
46
|
+
// stdin.stop_hook_active → evt.stopHookActive
|
|
47
|
+
// (`events.mjs`'s EVENT_FIELDS documents `stop` as carrying no fields beyond
|
|
48
|
+
// the common ones; createEvent does not validate field names, so a front
|
|
49
|
+
// passes these two straight through.)
|
|
50
|
+
//
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// FRONT-AGNOSTIC (wave 6 of [[HS-AZ61-NYPR]]): the session-record path
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// The port above reads a CLAUDE CODE TRANSCRIPT, which is the only place a
|
|
55
|
+
// Claude Code session records what it did. Every other front has no such file —
|
|
56
|
+
// so on `hs chat`, an overnight headless seat, a DeepSeek seat or a local-model
|
|
57
|
+
// seat this gate used to short-circuit on `if (!transcriptPath) return allow`,
|
|
58
|
+
// i.e. it registered and then allowed, unconditionally. The rule reached
|
|
59
|
+
// exactly one front.
|
|
60
|
+
//
|
|
61
|
+
// It no longer does. Those fronts DO have a record of what the session did —
|
|
62
|
+
// spexharness/src/session-record.mjs, which spexharness/src/session-lifecycle.mjs
|
|
63
|
+
// hands to every `stop` event as BOTH `evt.sessionRecord` and `ctx.sessionRecord`.
|
|
64
|
+
// Its `toolCalls` array carries `{ name, input, output }` per call in order,
|
|
65
|
+
// which is precisely the information `scanTranscript` digs out of a transcript's
|
|
66
|
+
// assistant `tool_use` blocks: heyspex_submit_for_review (or an Edit/Write/
|
|
67
|
+
// MultiEdit that introduces `status: in_review` under specs/tasks|epics) is the
|
|
68
|
+
// FLIP, heyspex_record_wip is the handoff, heyspex_report_progress is the
|
|
69
|
+
// checkpoint. `scanSessionRecord` below reads exactly those three facts off the
|
|
70
|
+
// record, reusing the SAME `toolUseIsFlip` helper the transcript scan uses — one
|
|
71
|
+
// flip detector, two carriers.
|
|
72
|
+
//
|
|
73
|
+
// Precedence is deliberate and preserves every existing golden: a transcript,
|
|
74
|
+
// when present, still wins. The record path is what runs when there is no
|
|
75
|
+
// transcript, which is every non-Claude-Code front. Both paths feed the identical
|
|
76
|
+
// rule evaluation below (the same two registry rows, the same two rungs, the same
|
|
77
|
+
// reason text), so the two fronts cannot enforce different rules.
|
|
78
|
+
//
|
|
79
|
+
// Decision mapping — the builder brief's own instruction: "map its current
|
|
80
|
+
// `{ decision: 'block', reason }` stdout shape onto the engine's deny
|
|
81
|
+
// decision". The original is actually a TWO-RUNG gate, not a single
|
|
82
|
+
// block/allow switch — HEYSPEX_SESSION_SEQUENCE_INTERACTIVE and
|
|
83
|
+
// CLAUDE_AGENT decide whether an unmet check is a real BLOCK (stdout JSON,
|
|
84
|
+
// `{decision:"block", reason}`) or an interactive WARN-ONLY (empty stdout —
|
|
85
|
+
// the stop is still allowed — with the same reason text only on stderr).
|
|
86
|
+
// The warn-only rung is a third, richer-than-binary outcome the engine has
|
|
87
|
+
// no native slot for either (same shape as `env-vault-guard.mjs`'s "ask" →
|
|
88
|
+
// rewrite mapping): mapped here to `rewrite` with `{ warn: true, reason,
|
|
89
|
+
// rule }` rather than silently downgrading it to a plain `allow` (which
|
|
90
|
+
// would drop the reason text a caller might still want to surface) or
|
|
91
|
+
// upgrading it to `deny` (a real behavior change — the original explicitly
|
|
92
|
+
// does NOT block the stop in this rung). This also gives this one gate
|
|
93
|
+
// natural coverage of all three engine decisions by itself (AC3: allow when
|
|
94
|
+
// nothing is missing or the check doesn't apply, deny on a real block, and
|
|
95
|
+
// rewrite on the warn-only rung) — see ./enforce-session-sequence.test.mjs.
|
|
96
|
+
//
|
|
97
|
+
// AC5 (the blocking case is preserved exactly): a real block's `reason` is
|
|
98
|
+
// `wipRule.inject.trim()` / `progressRule.inject.trim()` — the SAME compiled
|
|
99
|
+
// registry text the original emits, verbatim, not a re-authored string.
|
|
100
|
+
//
|
|
101
|
+
// Env hatches (unchanged):
|
|
102
|
+
// HEYSPEX_SESSION_SEQUENCE_OFF=1 Hard kill switch (both rungs).
|
|
103
|
+
// HEYSPEX_SESSION_SEQUENCE_INTERACTIVE=block Raises the interactive rung to a real block.
|
|
104
|
+
// CLAUDE_AGENT=true Unattended session → real block (unchanged).
|
|
105
|
+
// The rule also honors its registry `configKey`
|
|
106
|
+
// (agent_behaviors.session_wip_before_stop.enabled /
|
|
107
|
+
// agent_behaviors.session_progress_before_stop.enabled) via isRuleEnabled.
|
|
108
|
+
//
|
|
109
|
+
// Not ported HERE: the original's `appendGuardrailEvent(...)` audit-log
|
|
110
|
+
// write on fire, and the loop-guard/no-op fail-safe paths' silence — a gate
|
|
111
|
+
// module stays a pure decision function (AC1: no stdout writes; same
|
|
112
|
+
// spirit, no other observability side effects), matching env-vault-guard.mjs's
|
|
113
|
+
// port. The `rule` field this run() carries on both its `deny` and `rewrite`
|
|
114
|
+
// results exists so a FRONT can restore that same audit trail without
|
|
115
|
+
// re-deriving which rule fired from `reason` text — see
|
|
116
|
+
// spexharness/src/adapters/audit.mjs, called from
|
|
117
|
+
// spexharness/src/adapters/claude-code.mjs's runGateAsHook after this
|
|
118
|
+
// gate's decision comes back.
|
|
119
|
+
//
|
|
120
|
+
// `ctx` contract (all optional; every field defaults to a real fs/env read):
|
|
121
|
+
// ctx.cwd - repo root to resolve the guardrail registry + flip-path
|
|
122
|
+
// checks from. Defaults to CLAUDE_PROJECT_DIR, else this
|
|
123
|
+
// repo's root. When given it is a self-contained fixture
|
|
124
|
+
// root and both rules' config toggles are read from it too.
|
|
125
|
+
// ctx.configRoot - the directory holding `.heyspex/config.yaml` (both rules'
|
|
126
|
+
// configKey toggles); wins over ctx.cwd for that read. With
|
|
127
|
+
// neither, the VAULT root (../config-root.mjs
|
|
128
|
+
// `defaultConfigRoot()`) — never CLAUDE_PROJECT_DIR, which
|
|
129
|
+
// under Claude Code is the app checkout, not where the
|
|
130
|
+
// settings file lives
|
|
131
|
+
// ([[HS-4C76-9W86-DIAG-heyspex-config-yaml-has-no-single-home]]).
|
|
132
|
+
// Distinct from ctx.vaultRoot below, which is only the
|
|
133
|
+
// flip-path second base and may be a function.
|
|
134
|
+
// ctx.readFile(path) - reads the transcript file's content, or null if
|
|
135
|
+
// unreadable. Defaults to a real fs.readFileSync. Tests
|
|
136
|
+
// inject the fixture's transcript text directly instead
|
|
137
|
+
// of writing a real file.
|
|
138
|
+
// ctx.vaultRoot - string, or a zero-arg function returning one (or
|
|
139
|
+
// throwing) — the second base `isUnderSpecsTaskOrEpic`
|
|
140
|
+
// checks a flip path against, mirroring the original's
|
|
141
|
+
// `vaultRoot()` (getActiveConfig().repoRootAbs, cached
|
|
142
|
+
// process-lifetime by vault-resolve.mjs). Defaults to a
|
|
143
|
+
// fail-safe-wrapped call to the real getActiveConfig().
|
|
144
|
+
// None of this port's own fixtures need it (every fixture
|
|
145
|
+
// flip path already resolves under ctx.cwd itself), but
|
|
146
|
+
// it is available for a caller/tester that does.
|
|
147
|
+
// ctx.env - env var overrides (tests use this instead of mutating
|
|
148
|
+
// process.env).
|
|
149
|
+
|
|
150
|
+
import fs from 'node:fs';
|
|
151
|
+
import path from 'node:path';
|
|
152
|
+
import { fileURLToPath } from 'node:url';
|
|
153
|
+
|
|
154
|
+
import {
|
|
155
|
+
loadGuardrails,
|
|
156
|
+
rulesForSurface,
|
|
157
|
+
isRuleEnabled,
|
|
158
|
+
vocabularyViolations,
|
|
159
|
+
compilePatterns,
|
|
160
|
+
proseOnly,
|
|
161
|
+
matchContextAround,
|
|
162
|
+
} from '../../../scripts/claude-hooks/_guardrail-loader.mjs';
|
|
163
|
+
import { lastAssistantText } from '../../../scripts/claude-hooks/_transcript-text.mjs';
|
|
164
|
+
import { getActiveConfig } from '../../../vendor/heyspex-mcp/vault-resolve.mjs';
|
|
165
|
+
import { defaultConfigRoot } from '../config-root.mjs';
|
|
166
|
+
|
|
167
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
168
|
+
const REPO_ROOT = path.resolve(HERE, '..', '..', '..');
|
|
169
|
+
|
|
170
|
+
const RULE_ID = 'session-wip-before-stop';
|
|
171
|
+
const PROGRESS_RULE_ID = 'session-progress-before-review';
|
|
172
|
+
const STATUS_REVIEW_RE = /^\s*status:\s*in_review\s*$/m;
|
|
173
|
+
const SUBMIT_FOR_REVIEW_RE = /heyspex_submit_for_review/;
|
|
174
|
+
const RECORD_WIP_RE = /heyspex_record_wip/;
|
|
175
|
+
const REPORT_PROGRESS_RE = /heyspex_report_progress/;
|
|
176
|
+
const OFF_ENV = 'HEYSPEX_SESSION_SEQUENCE_OFF';
|
|
177
|
+
const INTERACTIVE_ENV = 'HEYSPEX_SESSION_SEQUENCE_INTERACTIVE';
|
|
178
|
+
|
|
179
|
+
function defaultReadFile(p) {
|
|
180
|
+
try {
|
|
181
|
+
return fs.readFileSync(p, 'utf8');
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resolveVaultRootAbs(ctx) {
|
|
188
|
+
if (typeof ctx.vaultRoot === 'string') return ctx.vaultRoot;
|
|
189
|
+
if (typeof ctx.vaultRoot === 'function') {
|
|
190
|
+
try {
|
|
191
|
+
return ctx.vaultRoot();
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
return getActiveConfig()?.repoRootAbs ?? null;
|
|
198
|
+
} catch {
|
|
199
|
+
return null; // fail-safe: the code-repo base still applies
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// True when a file path resolves under specs/tasks or specs/epics (the spec
|
|
204
|
+
// kinds that carry a reviewable lifecycle). Mirrors the original hook's
|
|
205
|
+
// local helper of the same name (itself mirroring notify-status-flip.mjs).
|
|
206
|
+
function isUnderSpecsTaskOrEpic(filePath, root, vaultRootAbs) {
|
|
207
|
+
if (typeof filePath !== 'string' || filePath.length === 0) return false;
|
|
208
|
+
const abs = path.isAbsolute(filePath) ? filePath : path.resolve(root, filePath);
|
|
209
|
+
const bases = [root];
|
|
210
|
+
if (vaultRootAbs && vaultRootAbs !== root) bases.push(vaultRootAbs);
|
|
211
|
+
for (const base of bases) {
|
|
212
|
+
for (const sub of ['specs/tasks', 'specs/epics']) {
|
|
213
|
+
const rel = path.relative(path.join(base, sub), abs);
|
|
214
|
+
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Did THIS Edit/Write/MultiEdit tool_use introduce `status: in_review`?
|
|
221
|
+
// Mirrors the original hook's local helper of the same name.
|
|
222
|
+
function toolUseIsFlip(name, input, root, vaultRootAbs) {
|
|
223
|
+
if (!input || typeof input !== 'object') return false;
|
|
224
|
+
if (!isUnderSpecsTaskOrEpic(input.file_path, root, vaultRootAbs)) return false;
|
|
225
|
+
if (name === 'Edit') {
|
|
226
|
+
const next = typeof input.new_string === 'string' ? input.new_string : '';
|
|
227
|
+
const prev = typeof input.old_string === 'string' ? input.old_string : '';
|
|
228
|
+
return STATUS_REVIEW_RE.test(next) && !STATUS_REVIEW_RE.test(prev);
|
|
229
|
+
}
|
|
230
|
+
if (name === 'MultiEdit') {
|
|
231
|
+
const edits = Array.isArray(input.edits) ? input.edits : [];
|
|
232
|
+
return edits.some((e) => {
|
|
233
|
+
const next = typeof e?.new_string === 'string' ? e.new_string : '';
|
|
234
|
+
const prev = typeof e?.old_string === 'string' ? e.old_string : '';
|
|
235
|
+
return STATUS_REVIEW_RE.test(next) && !STATUS_REVIEW_RE.test(prev);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (name === 'Write') {
|
|
239
|
+
const content = typeof input.content === 'string' ? input.content : '';
|
|
240
|
+
return STATUS_REVIEW_RE.test(content);
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Walk the whole transcript JSONL once, returning whether the session (a)
|
|
246
|
+
// flipped a spec to in_review, (b) called heyspex_record_wip, and (c) called
|
|
247
|
+
// heyspex_report_progress at least once — by scanning every assistant
|
|
248
|
+
// tool_use block. Mirrors the original hook's local helper of the same name,
|
|
249
|
+
// except it takes the transcript's TEXT directly (via ctx.readFile) rather
|
|
250
|
+
// than a path + its own fs.readFileSync call.
|
|
251
|
+
function scanTranscript(raw, root, vaultRootAbs) {
|
|
252
|
+
const result = { flip: false, wip: false, progress: false };
|
|
253
|
+
if (typeof raw !== 'string') return result;
|
|
254
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
255
|
+
if (!line) continue;
|
|
256
|
+
let evt;
|
|
257
|
+
try {
|
|
258
|
+
evt = JSON.parse(line);
|
|
259
|
+
} catch {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const content = evt?.message?.content;
|
|
263
|
+
if (!Array.isArray(content)) continue;
|
|
264
|
+
for (const block of content) {
|
|
265
|
+
if (!block || block.type !== 'tool_use' || typeof block.name !== 'string') continue;
|
|
266
|
+
if (RECORD_WIP_RE.test(block.name)) result.wip = true;
|
|
267
|
+
if (REPORT_PROGRESS_RE.test(block.name)) result.progress = true;
|
|
268
|
+
if (SUBMIT_FOR_REVIEW_RE.test(block.name)) result.flip = true;
|
|
269
|
+
if (toolUseIsFlip(block.name, block.input, root, vaultRootAbs)) result.flip = true;
|
|
270
|
+
}
|
|
271
|
+
if (result.flip && result.wip && result.progress) break; // nothing left to decide
|
|
272
|
+
}
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The same three facts {@link scanTranscript} extracts, read off a
|
|
278
|
+
* spexharness/src/session-record.mjs record instead of a Claude Code
|
|
279
|
+
* transcript — the front-agnostic carrier (see this module's header).
|
|
280
|
+
*
|
|
281
|
+
* Only `record.toolCalls` is read: `record.events` carries an event's `point`
|
|
282
|
+
* and its decision but NOT the tool name (see session-record.mjs's
|
|
283
|
+
* `recordEvent`), so it cannot answer "was heyspex_record_wip called". The flip
|
|
284
|
+
* detector is the SAME `toolUseIsFlip` the transcript scan uses, so a
|
|
285
|
+
* status-flipping Write/Edit/MultiEdit is recognized identically on both paths.
|
|
286
|
+
*
|
|
287
|
+
* @param {{ toolCalls?: Array<{ name?: string|null, input?: unknown }> }|null|undefined} record
|
|
288
|
+
* @param {string} root
|
|
289
|
+
* @param {string|null} vaultRootAbs
|
|
290
|
+
* @returns {{ flip: boolean, wip: boolean, progress: boolean }}
|
|
291
|
+
*/
|
|
292
|
+
export function scanSessionRecord(record, root, vaultRootAbs) {
|
|
293
|
+
const result = { flip: false, wip: false, progress: false };
|
|
294
|
+
const calls = Array.isArray(record?.toolCalls) ? record.toolCalls : [];
|
|
295
|
+
for (const call of calls) {
|
|
296
|
+
const name = typeof call?.name === 'string' ? call.name : '';
|
|
297
|
+
if (!name) continue;
|
|
298
|
+
if (RECORD_WIP_RE.test(name)) result.wip = true;
|
|
299
|
+
if (REPORT_PROGRESS_RE.test(name)) result.progress = true;
|
|
300
|
+
if (SUBMIT_FOR_REVIEW_RE.test(name)) result.flip = true;
|
|
301
|
+
if (toolUseIsFlip(name, call?.input, root, vaultRootAbs)) result.flip = true;
|
|
302
|
+
if (result.flip && result.wip && result.progress) break; // nothing left to decide
|
|
303
|
+
}
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Real block (unattended, or an interactive session explicitly raised the
|
|
308
|
+
// rung) vs. warn-only (interactive default). Mirrors the original hook's
|
|
309
|
+
// local helper of the same name.
|
|
310
|
+
function shouldBlockForReal(env) {
|
|
311
|
+
return env.CLAUDE_AGENT === 'true' || env[INTERACTIVE_ENV] === 'block';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Byte-for-byte the original hook's own interactive-warn stderr banner (its
|
|
315
|
+
// unexported fireRule()'s `else` branch) — kept here rather than left to the
|
|
316
|
+
// adapter's generic warn formatter (spexharness/src/adapters/claude-code.mjs's
|
|
317
|
+
// `warnStderrText`) because the second line names THIS gate's own env var,
|
|
318
|
+
// same convention as claim-before-code-gate.mjs's warnReason.
|
|
319
|
+
function warnStderrText(reason) {
|
|
320
|
+
return (
|
|
321
|
+
`[enforce-session-sequence] warning — ${reason}\n` +
|
|
322
|
+
'(Interactive session: this only warns. Set HEYSPEX_SESSION_SEQUENCE_INTERACTIVE=block to enforce it here too.)\n'
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The session-sequence gate's id — unique across the built-in registry. */
|
|
327
|
+
export const id = 'enforce-session-sequence';
|
|
328
|
+
/** Runs on the Stop event, same as its Stop Claude Code hook registration. */
|
|
329
|
+
export const event = 'stop';
|
|
330
|
+
/** Caller-defined severity label; this gate can deny (the unattended/raised rung). */
|
|
331
|
+
export const severity = 'block';
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* @param {{ point: string, transcriptPath?: string, stopHookActive?: boolean,
|
|
335
|
+
* sessionRecord?: object }} evt
|
|
336
|
+
* @param {{ cwd?: string, readFile?: (p: string) => string | null,
|
|
337
|
+
* sessionRecord?: object,
|
|
338
|
+
* vaultRoot?: string | (() => string | null),
|
|
339
|
+
* env?: Record<string, string | undefined> }} [ctx]
|
|
340
|
+
* `sessionRecord` (on either the event or the ctx — session-lifecycle.mjs
|
|
341
|
+
* supplies both) is the front-agnostic carrier read when no transcript exists.
|
|
342
|
+
* @returns {{ decision: 'allow'|'deny'|'rewrite', reason?: string, rewrite?: object }}
|
|
343
|
+
*/
|
|
344
|
+
export function run(evt, ctx = {}) {
|
|
345
|
+
const env = ctx.env ?? process.env;
|
|
346
|
+
if (String(env[OFF_ENV] ?? '') === '1') return { decision: 'allow' };
|
|
347
|
+
|
|
348
|
+
// Already inside a stop-hook continuation — one reminder is enough; never loop.
|
|
349
|
+
if (evt?.stopHookActive === true) return { decision: 'allow' };
|
|
350
|
+
|
|
351
|
+
// A transcript (Claude Code) or a session record (every other front). With
|
|
352
|
+
// neither there is nothing to judge — that is the only remaining allow-by-
|
|
353
|
+
// absence, and it is now a genuinely empty session rather than "this front
|
|
354
|
+
// isn't Claude Code".
|
|
355
|
+
const transcriptPath = evt?.transcriptPath;
|
|
356
|
+
const sessionRecord = evt?.sessionRecord ?? ctx.sessionRecord ?? null;
|
|
357
|
+
if (!transcriptPath && !sessionRecord) return { decision: 'allow' };
|
|
358
|
+
|
|
359
|
+
const root = ctx.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? REPO_ROOT;
|
|
360
|
+
const configRoot = ctx.configRoot ?? (ctx.cwd ? ctx.cwd : defaultConfigRoot());
|
|
361
|
+
const readFile = ctx.readFile ?? defaultReadFile;
|
|
362
|
+
|
|
363
|
+
const guardrails = loadGuardrails(root);
|
|
364
|
+
const stopRules = rulesForSurface(guardrails, 'stop');
|
|
365
|
+
|
|
366
|
+
// Load both rules independently — toggle + reason text each. Missing/
|
|
367
|
+
// disabled rule → that check is skipped (fail-open per-rule, matching
|
|
368
|
+
// every other registry-driven gate in this repo), not a reason to skip the
|
|
369
|
+
// other check.
|
|
370
|
+
const wipRule = stopRules.find((r) => r && r.rule === RULE_ID);
|
|
371
|
+
const progressRule = stopRules.find((r) => r && r.rule === PROGRESS_RULE_ID);
|
|
372
|
+
if ((!wipRule || !wipRule.inject) && (!progressRule || !progressRule.inject)) {
|
|
373
|
+
return { decision: 'allow' };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const vaultRootAbs = resolveVaultRootAbs(ctx);
|
|
377
|
+
// Transcript first when there is one (every existing golden is recorded on
|
|
378
|
+
// this path and must keep matching byte for byte); the session record is the
|
|
379
|
+
// carrier every other front actually has.
|
|
380
|
+
const { flip, wip, progress } = transcriptPath
|
|
381
|
+
? scanTranscript(readFile(transcriptPath) ?? '', root, vaultRootAbs)
|
|
382
|
+
: scanSessionRecord(sessionRecord, root, vaultRootAbs);
|
|
383
|
+
if (!flip) return { decision: 'allow' };
|
|
384
|
+
|
|
385
|
+
const forReal = shouldBlockForReal(env);
|
|
386
|
+
|
|
387
|
+
// Checked in this order so a turn missing BOTH reports the wip gate first
|
|
388
|
+
// (the more foundational of the two — no handoff at all vs. an incomplete
|
|
389
|
+
// checklist) — same ordering as the original.
|
|
390
|
+
if (wipRule && wipRule.inject && !wip && isRuleEnabled(wipRule, configRoot)) {
|
|
391
|
+
const reason = wipRule.inject.trim();
|
|
392
|
+
// `rule` rides at the top level (not just inside `rewrite`) so a caller
|
|
393
|
+
// observing only the deny branch — e.g. the audit-log writer in
|
|
394
|
+
// spexharness/src/adapters/audit.mjs — can still name which of the two
|
|
395
|
+
// rules fired without re-deriving it from `reason` text.
|
|
396
|
+
return forReal
|
|
397
|
+
? { decision: 'deny', reason, rule: RULE_ID }
|
|
398
|
+
: { decision: 'rewrite', reason, rule: RULE_ID, rewrite: { warn: true, reason, rule: RULE_ID, stderrText: warnStderrText(reason) } };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (progressRule && progressRule.inject && !progress && isRuleEnabled(progressRule, configRoot)) {
|
|
402
|
+
const reason = progressRule.inject.trim();
|
|
403
|
+
return forReal
|
|
404
|
+
? { decision: 'deny', reason, rule: PROGRESS_RULE_ID }
|
|
405
|
+
: { decision: 'rewrite', reason, rule: PROGRESS_RULE_ID, rewrite: { warn: true, reason, rule: PROGRESS_RULE_ID, stderrText: warnStderrText(reason) } };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return { decision: 'allow' };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export default { id, event, severity, run };
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// HS-RCIT — the stop-time correction gate (bounded, one-shot).
|
|
415
|
+
//
|
|
416
|
+
// [[HS-Y646-NYPR-EPIC-session-bootstrap-and-stop-for-every]] acceptance
|
|
417
|
+
// criterion 3: "A stop-time violation is rewritten once with the specific
|
|
418
|
+
// correction and the second reply is shown with a flag if it still violates."
|
|
419
|
+
// The epic's "What you will see" says the same in user words: "A reply that
|
|
420
|
+
// breaks a stop-time rule is rewritten once and shown with a one-line note,
|
|
421
|
+
// never silently passed and never looped."
|
|
422
|
+
//
|
|
423
|
+
// This section adds that bounded correction ON TOP of the session-sequence
|
|
424
|
+
// port above — nothing above this line changed shape, so the registry entry
|
|
425
|
+
// (./index.mjs), the golden fixtures and every existing caller of `run()`
|
|
426
|
+
// keep behaving exactly as recorded.
|
|
427
|
+
//
|
|
428
|
+
// The three stop-time rules checked, all read from the SAME compiled
|
|
429
|
+
// registry (`.heyspex/guardrails.json`) the sibling Stop gates read — never a
|
|
430
|
+
// second copy of a word list or a pattern:
|
|
431
|
+
// 1. naughty list → vocabularyViolations (naughty-list-gate.mjs's source)
|
|
432
|
+
// 2. id display format → compilePatterns + proseOnly (id-display-format-gate.mjs's source)
|
|
433
|
+
// 3. session sequence → this module's own run(), given the stop event
|
|
434
|
+
//
|
|
435
|
+
// The bound (never loops): a reply is corrected AT MOST ONCE
|
|
436
|
+
// (STOP_TIME_MAX_REWRITES = 1) and re-checked AT MOST ONCE. If the corrected
|
|
437
|
+
// reply still violates, it is returned UNCHANGED carrying `flagged: true`
|
|
438
|
+
// plus a one-line note — never corrected a second time, never re-checked a
|
|
439
|
+
// third time, and never downgraded to a plain `allow` (which is what
|
|
440
|
+
// "silently passes a violation" would mean). Two loop bounds exist so a
|
|
441
|
+
// misbehaving caller cannot defeat either:
|
|
442
|
+
// - in-process: enforceStopTimeRules() does the single correction pass
|
|
443
|
+
// itself and terminates on its own;
|
|
444
|
+
// - out-of-process (the model does the rewrite between calls): the caller
|
|
445
|
+
// passes `attempt`, and createStopTimeCorrector() holds that counter for
|
|
446
|
+
// callers that would otherwise keep passing 0.
|
|
447
|
+
//
|
|
448
|
+
// Decision mapping onto decide.mjs: a clean reply is `allow`; a corrected
|
|
449
|
+
// reply and a flagged reply are BOTH `rewrite` (the caller must show the
|
|
450
|
+
// payload's `reply`, not the one it handed in) — never `deny`, because a
|
|
451
|
+
// stop-time correction shows the answer with its note rather than swallowing
|
|
452
|
+
// it. `deny` stays what the session-sequence rung above uses.
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
/** Break-glass for the whole stop-time correction path (per-rule hatches still apply). */
|
|
456
|
+
export const STOP_TIME_OFF_ENV = 'HEYSPEX_STOP_TIME_OFF';
|
|
457
|
+
/** The bound: a violating reply is corrected at most this many times. Never raise it. */
|
|
458
|
+
export const STOP_TIME_MAX_REWRITES = 1;
|
|
459
|
+
|
|
460
|
+
const NAUGHTY_RULE_ID = 'naughty-list';
|
|
461
|
+
const ID_DISPLAY_RULE_ID = 'id-display-shape-gate';
|
|
462
|
+
/** The stop-time rule families this gate checks a reply against (acceptance criterion 1). */
|
|
463
|
+
export const STOP_TIME_RULES = Object.freeze([NAUGHTY_RULE_ID, ID_DISPLAY_RULE_ID, 'session-sequence']);
|
|
464
|
+
|
|
465
|
+
// The same non-prose spans proseOnly() blanks out (code spans, fenced blocks,
|
|
466
|
+
// [[wikilinks]], URLs, blockquotes) — kept as an ordered split rather than a
|
|
467
|
+
// blanking replace so a mechanical correction can rewrite the prose parts and
|
|
468
|
+
// put the protected parts back byte-for-byte.
|
|
469
|
+
const NON_PROSE_RE = /```[\s\S]*?```|`[^`]*`|\[\[[^\]]*\]\]|https?:\/\/\S+|^[ \t]{0,3}>.*$/gm;
|
|
470
|
+
|
|
471
|
+
function proseSegments(text) {
|
|
472
|
+
const segments = [];
|
|
473
|
+
const re = new RegExp(NON_PROSE_RE.source, 'gm');
|
|
474
|
+
let last = 0;
|
|
475
|
+
let m;
|
|
476
|
+
while ((m = re.exec(text)) !== null) {
|
|
477
|
+
if (m.index > last) segments.push({ text: text.slice(last, m.index), prose: true });
|
|
478
|
+
segments.push({ text: m[0], prose: false });
|
|
479
|
+
last = m.index + m[0].length;
|
|
480
|
+
if (m[0].length === 0) re.lastIndex += 1; // guard against a zero-width match spinning
|
|
481
|
+
}
|
|
482
|
+
if (last < text.length) segments.push({ text: text.slice(last), prose: true });
|
|
483
|
+
return segments;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function escapeRe(s) {
|
|
487
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The literal drop-in a naughty-list term's `fix` offers, when it offers one.
|
|
492
|
+
* The registry writes fixes as prose, some of which lead with a quoted
|
|
493
|
+
* replacement ("knob" → `"setting," "parameter," "control"`); those can be
|
|
494
|
+
* applied mechanically. A fix with no quoted candidate ("seam" → `name what
|
|
495
|
+
* the boundary or integration actually is`) has no mechanical form — the
|
|
496
|
+
* correction still names it, but only a real rewriter can clear it, which is
|
|
497
|
+
* exactly the case that exercises the flagged second-violation path.
|
|
498
|
+
*
|
|
499
|
+
* @param {string} fix
|
|
500
|
+
* @returns {string|null}
|
|
501
|
+
*/
|
|
502
|
+
export function suggestedReplacement(fix) {
|
|
503
|
+
if (typeof fix !== 'string') return null;
|
|
504
|
+
const m = /["“]([^"“”]{1,60})["”]/.exec(fix);
|
|
505
|
+
if (!m) return null;
|
|
506
|
+
const candidate = m[1].trim().replace(/[,.;:]+$/, '').trim();
|
|
507
|
+
return candidate.length > 0 ? candidate : null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function stopTimeRoot(options) {
|
|
511
|
+
return options.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? REPO_ROOT;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Same code-root vs config-root split as run() — see the ctx contract in the header. */
|
|
515
|
+
function stopTimeConfigRoot(options) {
|
|
516
|
+
return options.configRoot ?? (options.cwd ? options.cwd : defaultConfigRoot());
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function naughtyListViolations(reply, root, env, configRoot = root) {
|
|
520
|
+
if (String(env.HEYSPEX_NAUGHTY_LIST_OFF ?? '') === '1') return [];
|
|
521
|
+
const rules = rulesForSurface(loadGuardrails(root), 'stop').filter((r) => r && r.kind === 'vocabulary');
|
|
522
|
+
const found = [];
|
|
523
|
+
for (const rule of rules) {
|
|
524
|
+
if (!isRuleEnabled(rule, configRoot)) continue;
|
|
525
|
+
for (const hit of vocabularyViolations(rule, reply, root)) {
|
|
526
|
+
found.push({
|
|
527
|
+
rule: rule.rule ?? NAUGHTY_RULE_ID,
|
|
528
|
+
kind: 'vocabulary',
|
|
529
|
+
severity: rule.severity ?? 'block',
|
|
530
|
+
word: hit.word,
|
|
531
|
+
fix: hit.fix,
|
|
532
|
+
context: hit.context,
|
|
533
|
+
replacement: suggestedReplacement(hit.fix),
|
|
534
|
+
correction: `"${hit.word}" → ${hit.fix}`,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return found;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function idDisplayViolations(reply, root, env, configRoot = root) {
|
|
542
|
+
if (String(env.HEYSPEX_ID_DISPLAY_GATE_OFF ?? '') === '1') return [];
|
|
543
|
+
const rule = rulesForSurface(loadGuardrails(root), 'stop').find((r) => r && r.rule === ID_DISPLAY_RULE_ID);
|
|
544
|
+
if (!rule || !isRuleEnabled(rule, configRoot)) return [];
|
|
545
|
+
const patterns = compilePatterns(rule);
|
|
546
|
+
if (!patterns.length) return [];
|
|
547
|
+
const prose = proseOnly(reply);
|
|
548
|
+
const found = [];
|
|
549
|
+
for (const re of patterns) {
|
|
550
|
+
const m = re.exec(prose);
|
|
551
|
+
if (!m) continue;
|
|
552
|
+
const excerpt = matchContextAround(prose, m.index, m[0].length);
|
|
553
|
+
found.push({
|
|
554
|
+
rule: ID_DISPLAY_RULE_ID,
|
|
555
|
+
kind: 'pattern',
|
|
556
|
+
severity: rule.severity ?? 'warn',
|
|
557
|
+
excerpt,
|
|
558
|
+
replacement: null, // no mechanical form: the type must be read off the spec, never guessed
|
|
559
|
+
correction:
|
|
560
|
+
`"${excerpt}" → write the id suffixed (HS-OFUB-CVOB-EPIC) or prose-prefixed (Epic: HS-OFUB-CVOB), ` +
|
|
561
|
+
`never annotated in parentheses`,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
return found;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function sequenceViolations(evt, options) {
|
|
568
|
+
if (!evt) return [];
|
|
569
|
+
const result = run(evt, options);
|
|
570
|
+
if (!result || result.decision === 'allow') return [];
|
|
571
|
+
const reason = typeof result.reason === 'string' ? result.reason : '';
|
|
572
|
+
return [
|
|
573
|
+
{
|
|
574
|
+
rule: result.rule ?? 'session-sequence',
|
|
575
|
+
kind: 'sequence',
|
|
576
|
+
severity: result.decision === 'deny' ? 'block' : 'warn',
|
|
577
|
+
// The registry's own inject text, verbatim — same words the deny rung
|
|
578
|
+
// above emits, not a re-authored string.
|
|
579
|
+
correction: reason,
|
|
580
|
+
reason,
|
|
581
|
+
},
|
|
582
|
+
];
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Check one reply against all three stop-time rule families (acceptance
|
|
587
|
+
* criterion 1). Pure with respect to the reply: it never modifies it.
|
|
588
|
+
*
|
|
589
|
+
* @param {string} reply - the assistant reply about to be shown.
|
|
590
|
+
* @param {{ cwd?: string, configRoot?: string, env?: Record<string, string|undefined>, event?: object,
|
|
591
|
+
* readFile?: (p: string) => string|null, vaultRoot?: string|(() => string|null) }} [options]
|
|
592
|
+
* @returns {{ violations: Array<object>, checked: string[], off: boolean }}
|
|
593
|
+
*/
|
|
594
|
+
export function checkStopTimeRules(reply, options = {}) {
|
|
595
|
+
const env = options.env ?? process.env;
|
|
596
|
+
if (String(env[STOP_TIME_OFF_ENV] ?? '') === '1') {
|
|
597
|
+
return { violations: [], checked: [], off: true };
|
|
598
|
+
}
|
|
599
|
+
const root = stopTimeRoot(options);
|
|
600
|
+
const configRoot = stopTimeConfigRoot(options);
|
|
601
|
+
const text = typeof reply === 'string' ? reply : '';
|
|
602
|
+
const checked = [NAUGHTY_RULE_ID, ID_DISPLAY_RULE_ID];
|
|
603
|
+
const violations = [
|
|
604
|
+
...naughtyListViolations(text, root, env, configRoot),
|
|
605
|
+
...idDisplayViolations(text, root, env, configRoot),
|
|
606
|
+
];
|
|
607
|
+
// The sequence rule is transcript state, not reply text — it is only
|
|
608
|
+
// checkable when the caller hands over the stop event it belongs to.
|
|
609
|
+
if (options.event) {
|
|
610
|
+
checked.push('session-sequence');
|
|
611
|
+
violations.push(...sequenceViolations(options.event, options));
|
|
612
|
+
}
|
|
613
|
+
return { violations, checked, off: false };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The SPECIFIC correction for a set of violations — the per-rule fix text,
|
|
618
|
+
* never a generic "you broke a rule" line.
|
|
619
|
+
*
|
|
620
|
+
* @param {Array<object>} violations
|
|
621
|
+
* @returns {string}
|
|
622
|
+
*/
|
|
623
|
+
export function correctionFor(violations) {
|
|
624
|
+
const lines = [];
|
|
625
|
+
const seen = new Set();
|
|
626
|
+
for (const v of violations ?? []) {
|
|
627
|
+
const line = ` • ${v.rule}: ${v.correction}`;
|
|
628
|
+
if (seen.has(line)) continue;
|
|
629
|
+
seen.add(line);
|
|
630
|
+
lines.push(line);
|
|
631
|
+
}
|
|
632
|
+
if (!lines.length) return '';
|
|
633
|
+
return `Stop-time correction — rewrite the reply and resend:\n${lines.join('\n')}`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* The one-line note a flagged (still-violating) reply is shown with.
|
|
638
|
+
*
|
|
639
|
+
* @param {Array<object>} violations
|
|
640
|
+
* @returns {string}
|
|
641
|
+
*/
|
|
642
|
+
export function stopTimeFlagNote(violations) {
|
|
643
|
+
const parts = [];
|
|
644
|
+
for (const v of violations ?? []) {
|
|
645
|
+
const label = v.word ? `${v.rule} ("${v.word}")` : v.rule;
|
|
646
|
+
if (!parts.includes(label)) parts.push(label);
|
|
647
|
+
}
|
|
648
|
+
return `[stop-time] shown with a flag — still violates after one correction: ${parts.join(', ')}`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The default, mechanical correction pass: swap each banned term that carries
|
|
653
|
+
* a literal replacement candidate, in the PROSE parts of the reply only (a
|
|
654
|
+
* banned word inside backticks, a wikilink, a URL or a blockquote is exempt,
|
|
655
|
+
* same as every other stop gate). Violations with no mechanical form are left
|
|
656
|
+
* alone — deliberately, so they surface on the re-check as a flagged reply
|
|
657
|
+
* instead of being silently dropped.
|
|
658
|
+
*
|
|
659
|
+
* A caller with a real rewriter (a model) passes it as `options.rewrite`
|
|
660
|
+
* instead; this is the fallback so the gate corrects something on its own.
|
|
661
|
+
*
|
|
662
|
+
* Takes the violations as either the second or the third argument, so it can
|
|
663
|
+
* be handed straight to `options.rewrite` (whose rewriter signature is
|
|
664
|
+
* `(reply, correction, violations)`) as well as called directly.
|
|
665
|
+
*
|
|
666
|
+
* @param {string} reply
|
|
667
|
+
* @param {Array<object>|string} violations - the violations, or the correction
|
|
668
|
+
* text when called in rewriter position (then `alsoViolations` carries them).
|
|
669
|
+
* @param {Array<object>} [alsoViolations]
|
|
670
|
+
* @returns {string} the corrected reply (=== the input when nothing was mechanically fixable)
|
|
671
|
+
*/
|
|
672
|
+
export function applyCorrection(reply, violations, alsoViolations) {
|
|
673
|
+
if (typeof reply !== 'string' || !reply) return typeof reply === 'string' ? reply : '';
|
|
674
|
+
const list = Array.isArray(violations) ? violations : Array.isArray(alsoViolations) ? alsoViolations : [];
|
|
675
|
+
const swaps = list.filter((v) => v && v.kind === 'vocabulary' && v.word && v.replacement);
|
|
676
|
+
if (!swaps.length) return reply;
|
|
677
|
+
return proseSegments(reply)
|
|
678
|
+
.map((seg) => {
|
|
679
|
+
if (!seg.prose) return seg.text;
|
|
680
|
+
let out = seg.text;
|
|
681
|
+
for (const swap of swaps) {
|
|
682
|
+
const re = new RegExp(`(?<![\\w-])${escapeRe(swap.word)}(?![\\w-])`, 'gi');
|
|
683
|
+
out = out.replace(re, (match) =>
|
|
684
|
+
/^[A-Z]/.test(match) ? swap.replacement.charAt(0).toUpperCase() + swap.replacement.slice(1) : swap.replacement,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
return out;
|
|
688
|
+
})
|
|
689
|
+
.join('');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* The stop-time gate itself (acceptance criteria 2, 3 and 4).
|
|
694
|
+
*
|
|
695
|
+
* @param {string} reply - the reply about to be shown.
|
|
696
|
+
* @param {{
|
|
697
|
+
* cwd?: string,
|
|
698
|
+
* env?: Record<string, string|undefined>,
|
|
699
|
+
* event?: object,
|
|
700
|
+
* readFile?: (p: string) => string|null,
|
|
701
|
+
* vaultRoot?: string|(() => string|null),
|
|
702
|
+
* attempt?: number,
|
|
703
|
+
* rewrite?: false | ((reply: string, correction: string, violations: Array<object>) => string),
|
|
704
|
+
* }} [options]
|
|
705
|
+
* `attempt` — how many corrections this reply already went through
|
|
706
|
+
* upstream; `>= STOP_TIME_MAX_REWRITES` means this IS the second reply, so
|
|
707
|
+
* it is flagged rather than corrected again. `rewrite` — a rewriter to run
|
|
708
|
+
* the single correction pass with (defaults to {@link applyCorrection});
|
|
709
|
+
* pass `false` for verdict-only mode, where the caller performs the rewrite
|
|
710
|
+
* between calls and comes back with `attempt: 1`.
|
|
711
|
+
* @returns {{
|
|
712
|
+
* decision: 'allow'|'rewrite',
|
|
713
|
+
* reply: string, originalReply: string,
|
|
714
|
+
* violations: Array<object>, initialViolations: Array<object>,
|
|
715
|
+
* correction: string|null, reason?: string,
|
|
716
|
+
* rewritten: boolean, flagged: boolean, needsRewrite: boolean,
|
|
717
|
+
* attempts: number, checked: string[],
|
|
718
|
+
* flag?: { note: string, rules: string[], violations: Array<object> },
|
|
719
|
+
* rewrite?: object,
|
|
720
|
+
* }}
|
|
721
|
+
*/
|
|
722
|
+
export function enforceStopTimeRules(reply, options = {}) {
|
|
723
|
+
const original = typeof reply === 'string' ? reply : '';
|
|
724
|
+
const attemptIn = Number.isInteger(options.attempt) && options.attempt > 0 ? options.attempt : 0;
|
|
725
|
+
const first = checkStopTimeRules(original, options);
|
|
726
|
+
|
|
727
|
+
const clean = (attempts) => ({
|
|
728
|
+
decision: 'allow',
|
|
729
|
+
reply: original,
|
|
730
|
+
originalReply: original,
|
|
731
|
+
violations: [],
|
|
732
|
+
initialViolations: first.violations,
|
|
733
|
+
correction: null,
|
|
734
|
+
rewritten: false,
|
|
735
|
+
flagged: false,
|
|
736
|
+
needsRewrite: false,
|
|
737
|
+
attempts,
|
|
738
|
+
checked: first.checked,
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
if (!first.violations.length) return clean(attemptIn);
|
|
742
|
+
|
|
743
|
+
const correction = correctionFor(first.violations);
|
|
744
|
+
|
|
745
|
+
// `attempts` is the correction budget SPENT overall (never above the
|
|
746
|
+
// bound); `rewritten` says whether THIS call did the rewriting, so a caller
|
|
747
|
+
// re-asking about an already-corrected reply never double-counts it.
|
|
748
|
+
const flaggedVerdict = (shownReply, violations, attempts, rewritten) => {
|
|
749
|
+
const note = stopTimeFlagNote(violations);
|
|
750
|
+
const flag = { note, rules: [...new Set(violations.map((v) => v.rule))], violations };
|
|
751
|
+
return {
|
|
752
|
+
// Still a rewrite decision, never `allow` — a flagged reply is shown
|
|
753
|
+
// WITH its note, which is not the same thing as silently passing it.
|
|
754
|
+
decision: 'rewrite',
|
|
755
|
+
reply: shownReply,
|
|
756
|
+
originalReply: original,
|
|
757
|
+
violations,
|
|
758
|
+
initialViolations: first.violations,
|
|
759
|
+
correction,
|
|
760
|
+
reason: note,
|
|
761
|
+
rewritten,
|
|
762
|
+
flagged: true,
|
|
763
|
+
needsRewrite: false, // the single correction is spent; never ask for another
|
|
764
|
+
attempts,
|
|
765
|
+
checked: first.checked,
|
|
766
|
+
flag,
|
|
767
|
+
rewrite: { reply: shownReply, correction, violations, flagged: true, note, attempts },
|
|
768
|
+
};
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
// The reply already went through its one correction upstream: show it
|
|
772
|
+
// UNCHANGED with the flag. No second correction, no third check.
|
|
773
|
+
if (attemptIn >= STOP_TIME_MAX_REWRITES) {
|
|
774
|
+
return flaggedVerdict(original, first.violations, STOP_TIME_MAX_REWRITES, false);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Verdict-only mode: hand the specific correction back and let the caller
|
|
778
|
+
// do the single rewrite. It must return with `attempt: 1`, which lands on
|
|
779
|
+
// the flagged branch above if the new reply still violates.
|
|
780
|
+
if (options.rewrite === false) {
|
|
781
|
+
return {
|
|
782
|
+
decision: 'rewrite',
|
|
783
|
+
reply: original,
|
|
784
|
+
originalReply: original,
|
|
785
|
+
violations: first.violations,
|
|
786
|
+
initialViolations: first.violations,
|
|
787
|
+
correction,
|
|
788
|
+
reason: correction,
|
|
789
|
+
rewritten: false,
|
|
790
|
+
flagged: false,
|
|
791
|
+
needsRewrite: true,
|
|
792
|
+
attempts: 0,
|
|
793
|
+
checked: first.checked,
|
|
794
|
+
rewrite: { reply: original, correction, violations: first.violations, flagged: false, needsRewrite: true, attempts: 0 },
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const rewriter = typeof options.rewrite === 'function' ? options.rewrite : applyCorrection;
|
|
799
|
+
let corrected;
|
|
800
|
+
try {
|
|
801
|
+
corrected = rewriter(original, correction, first.violations);
|
|
802
|
+
} catch {
|
|
803
|
+
corrected = original; // a broken rewriter must not lose the reply
|
|
804
|
+
}
|
|
805
|
+
if (typeof corrected !== 'string') corrected = original;
|
|
806
|
+
|
|
807
|
+
// Exactly one re-check. Whatever it says, this call terminates here.
|
|
808
|
+
const second = checkStopTimeRules(corrected, options);
|
|
809
|
+
if (second.violations.length) {
|
|
810
|
+
return flaggedVerdict(corrected, second.violations, STOP_TIME_MAX_REWRITES, true);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return {
|
|
814
|
+
decision: 'rewrite',
|
|
815
|
+
reply: corrected,
|
|
816
|
+
originalReply: original,
|
|
817
|
+
violations: [],
|
|
818
|
+
initialViolations: first.violations,
|
|
819
|
+
correction,
|
|
820
|
+
reason: correction,
|
|
821
|
+
rewritten: true,
|
|
822
|
+
flagged: false,
|
|
823
|
+
needsRewrite: false,
|
|
824
|
+
attempts: STOP_TIME_MAX_REWRITES,
|
|
825
|
+
checked: first.checked,
|
|
826
|
+
rewrite: { reply: corrected, correction, violations: [], flagged: false, note: null, attempts: STOP_TIME_MAX_REWRITES },
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* A session-scoped holder of the one-correction budget, for the
|
|
832
|
+
* out-of-process shape where the caller (not this module) does the rewriting
|
|
833
|
+
* and could otherwise keep asking forever. It carries the attempt counter so
|
|
834
|
+
* a caller that always passes `attempt: 0` still gets exactly one correction
|
|
835
|
+
* and a flag after that.
|
|
836
|
+
*
|
|
837
|
+
* @param {object} [baseOptions] - the options every review() call inherits.
|
|
838
|
+
* @returns {{ attempts: number, done: boolean, review: (reply: string, extra?: object) => object }}
|
|
839
|
+
*/
|
|
840
|
+
export function createStopTimeCorrector(baseOptions = {}) {
|
|
841
|
+
let attempts = 0;
|
|
842
|
+
let done = false;
|
|
843
|
+
|
|
844
|
+
return {
|
|
845
|
+
get attempts() {
|
|
846
|
+
return attempts;
|
|
847
|
+
},
|
|
848
|
+
get done() {
|
|
849
|
+
return done;
|
|
850
|
+
},
|
|
851
|
+
review(reply, extra = {}) {
|
|
852
|
+
const verdict = enforceStopTimeRules(reply, { ...baseOptions, ...extra, attempt: attempts });
|
|
853
|
+
if (verdict.rewritten || verdict.needsRewrite) {
|
|
854
|
+
attempts = Math.min(STOP_TIME_MAX_REWRITES, attempts + 1);
|
|
855
|
+
}
|
|
856
|
+
if (verdict.decision === 'allow' || verdict.flagged) done = true;
|
|
857
|
+
return verdict;
|
|
858
|
+
},
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* decide.mjs-shaped adapter, so a front can register the stop-time gate
|
|
864
|
+
* alongside the other stop gates. The reply comes from `ctx.reply` when the
|
|
865
|
+
* front already holds it, else from the transcript's last assistant text (the
|
|
866
|
+
* same reader naughty-list-gate.mjs and id-display-format-gate.mjs use).
|
|
867
|
+
*
|
|
868
|
+
* @param {{ point: string, transcriptPath?: string, stopHookActive?: boolean }} evt
|
|
869
|
+
* @param {object} [ctx] - the same option bag {@link enforceStopTimeRules} takes.
|
|
870
|
+
* @returns {{ decision: 'allow'|'rewrite', reason?: string, rewrite?: object, verdict: object }}
|
|
871
|
+
*/
|
|
872
|
+
export function runStopTime(evt, ctx = {}) {
|
|
873
|
+
const reply =
|
|
874
|
+
typeof ctx.reply === 'string'
|
|
875
|
+
? ctx.reply
|
|
876
|
+
: evt?.transcriptPath
|
|
877
|
+
? (lastAssistantText(evt.transcriptPath) ?? '')
|
|
878
|
+
: '';
|
|
879
|
+
const verdict = enforceStopTimeRules(reply, { ...ctx, event: evt });
|
|
880
|
+
if (verdict.decision === 'allow') return { decision: 'allow', verdict };
|
|
881
|
+
return { decision: 'rewrite', reason: verdict.reason, rewrite: verdict.rewrite, verdict };
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* The stop-time correction gate as a registry.mjs gate object. Kept as a
|
|
886
|
+
* named export rather than registered in ./index.mjs — that file's twelve-gate
|
|
887
|
+
* set is asserted by fixtures/goldens.test.mjs and index.test.mjs, so a front
|
|
888
|
+
* that wants this gate registers it itself.
|
|
889
|
+
*/
|
|
890
|
+
export const stopTimeGate = {
|
|
891
|
+
id: 'stop-time-correction',
|
|
892
|
+
event: 'stop',
|
|
893
|
+
severity: 'block',
|
|
894
|
+
run: runStopTime,
|
|
895
|
+
};
|