heyspex 0.2.0 → 0.2.2

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.
Files changed (146) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/bin/hs.mjs +181 -15
  3. package/dist/cli/hs-check.mjs +88 -46
  4. package/dist/cli/hs-claim.mjs +84 -39
  5. package/dist/cli/hs-connect.mjs +583 -129
  6. package/dist/cli/hs-defer.mjs +251 -30
  7. package/dist/cli/hs-dev.mjs +20 -8
  8. package/dist/cli/hs-env.mjs +62 -34
  9. package/dist/cli/hs-index-rebuild.mjs +288 -83
  10. package/dist/cli/hs-index-reconcile.mjs +397 -111
  11. package/dist/cli/hs-init.mjs +2143 -91
  12. package/dist/cli/hs-install.mjs +628 -106
  13. package/dist/cli/hs-join.mjs +686 -236
  14. package/dist/cli/hs-vault-pull.mjs +3189 -179
  15. package/dist/cli/hs-vault-push.mjs +6433 -0
  16. package/dist/cli/hs-vault.mjs +396 -178
  17. package/dist/cli/hs-workspace.mjs +289 -151
  18. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  19. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  20. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  21. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
  22. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  23. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  24. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  25. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  26. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  27. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
  28. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
  29. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  30. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  31. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  32. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  33. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  34. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
  35. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  36. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  37. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  38. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  39. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  40. package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
  41. package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
  42. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  43. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  44. package/docs/copilot-plugin-install.md +177 -0
  45. package/docs/driving-heyspex-from-copilot.md +165 -0
  46. package/package.json +21 -2
  47. package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
  48. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
  49. package/plugins/heyspex/README.md +312 -0
  50. package/plugins/heyspex/bin/run-hook.mjs +20 -2
  51. package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
  52. package/plugins/heyspex/hooks/hooks.json +94 -37
  53. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
  54. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
  55. package/scripts/claude-hooks/_run.mjs +316 -23
  56. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  57. package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
  58. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  59. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  60. package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
  61. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
  62. package/scripts/claude-hooks/session-end.mjs +2 -0
  63. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  64. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  65. package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
  66. package/scripts/cursor-hooks/manifest.mjs +46 -0
  67. package/scripts/heyspex-mode.mjs +20 -0
  68. package/scripts/hs-mode.mjs +1 -1
  69. package/scripts/hs-view.mjs +180 -17
  70. package/scripts/install-claude-code-hooks.mjs +3 -1
  71. package/scripts/install-copilot-hooks.mjs +288 -0
  72. package/scripts/lib/console-url-resolver.mjs +3 -2
  73. package/scripts/lib/customer-kit.mjs +327 -10
  74. package/scripts/lib/dev-mode.mjs +7 -6
  75. package/scripts/lib/directive-obligations.mjs +349 -0
  76. package/scripts/lib/hook-dispositions.mjs +210 -0
  77. package/scripts/lib/hook-vault-header.mjs +30 -0
  78. package/scripts/lib/hs-source-branch.mjs +196 -0
  79. package/scripts/lib/hs-source-log.mjs +2 -1
  80. package/scripts/lib/hs-source-preflight.mjs +18 -4
  81. package/scripts/lib/hs-source-push.mjs +386 -356
  82. package/scripts/lib/window-placement.mjs +109 -0
  83. package/spexharness/src/adapters/claude-code.mjs +11 -3
  84. package/spexharness/src/adapters/copilot.mjs +236 -0
  85. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  86. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  87. package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
  88. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  89. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  90. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  91. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  92. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  93. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  94. package/spexharness/src/intent-classifier.mjs +4 -2
  95. package/spexharness/src/intent-directive.mjs +626 -541
  96. package/spexharness/src/rule-table.mjs +31 -1
  97. package/vendor/heyspex-mcp/account-switch.mjs +6 -5
  98. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
  99. package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
  100. package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
  101. package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
  102. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  103. package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
  104. package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
  105. package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
  106. package/vendor/heyspex-mcp/cli.mjs +7 -1
  107. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  108. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
  109. package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
  110. package/vendor/heyspex-mcp/console-transport.mjs +7 -1
  111. package/vendor/heyspex-mcp/credentials.mjs +9 -8
  112. package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
  113. package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
  114. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
  115. package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
  116. package/vendor/heyspex-mcp/http-transport.mjs +2 -0
  117. package/vendor/heyspex-mcp/list-tools.mjs +1 -1
  118. package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
  119. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  120. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  121. package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
  122. package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
  123. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  124. package/vendor/heyspex-mcp/plan-push.mjs +3 -2
  125. package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
  126. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
  127. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
  128. package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
  129. package/vendor/heyspex-mcp/promote-research.mjs +6 -0
  130. package/vendor/heyspex-mcp/read-path.mjs +162 -6
  131. package/vendor/heyspex-mcp/request-context.mjs +25 -1
  132. package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
  133. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  134. package/vendor/heyspex-mcp/session-owner.mjs +3 -2
  135. package/vendor/heyspex-mcp/session-tools.mjs +10 -0
  136. package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
  137. package/vendor/heyspex-mcp/split-research.mjs +3 -0
  138. package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
  139. package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
  140. package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
  141. package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
  142. package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
  143. package/vendor/heyspex-mcp/vault-io.mjs +720 -724
  144. package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
  145. package/vendor/heyspex-mcp/whoami.mjs +2 -1
  146. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -0,0 +1,132 @@
1
+ // spexharness/src/gates/directive-obligation-gate.decide.mjs
2
+ //
3
+ // Pure decision logic for the directive-obligation-gate ([[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]]).
4
+ // No stdin/stdout/process.exit and no fs: every input arrives as an argument, so the gate module
5
+ // (./directive-obligation-gate.mjs) owns all I/O and a test drives these functions directly.
6
+
7
+ import { extractShellTargets } from './_shell-write-targets.mjs';
8
+ import { isProductCodePath } from '../../../scripts/lib/directive-obligations.mjs';
9
+
10
+ const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'write_file', 'edit_file']);
11
+ const SHELL_TOOLS = new Set(['Bash', 'PowerShell', 'Shell']);
12
+ const SPAWN_TOOLS = new Set(['Agent', 'Task']);
13
+
14
+ /** Sub-agent types that only read / plan / triage: spawning one never builds anything. */
15
+ export const READ_ONLY_AGENT_TYPES = new Set([
16
+ 'explore',
17
+ 'plan',
18
+ 'claude-code-guide',
19
+ 'capture-triager',
20
+ 'plan-grounder',
21
+ 'research-synthesizer',
22
+ 'resolution-triager',
23
+ 'statusline-setup',
24
+ ]);
25
+
26
+ // Commands that build, install, serve or publish product code. Test runners are deliberately absent.
27
+ const BUILD_CMD_RE = new RegExp(
28
+ '(?:^|[\\s;&|(])(?:' +
29
+ '(?:npm|pnpm|yarn|bun)\\s+(?:run\\s+)?(?:build|dev|start|install|i|add|ci|publish|deploy)\\b' +
30
+ '|(?:npx\\s+)?(?:next|vite|webpack|turbo|esbuild)\\s+(?:build|dev)\\b' +
31
+ '|dotnet\\s+(?:build|publish|run)\\b' +
32
+ '|cargo\\s+(?:build|run|install)\\b' +
33
+ '|make\\b' +
34
+ '|vercel\\s+(?:deploy|--prod)\\b' +
35
+ '|(?:claude|spexcode)\\s+(?:-p|--print)\\b' +
36
+ ')',
37
+ 'i',
38
+ );
39
+
40
+ /** Is this a heyspex_* MCP call (any spelling)? Those are never gated. */
41
+ export function isHeyspexMcpTool(name) {
42
+ return /(?:^|__)heyspex_/.test(String(name ?? ''));
43
+ }
44
+
45
+ function pathOf(toolInput) {
46
+ return toolInput?.file_path ?? toolInput?.notebook_path ?? toolInput?.path ?? toolInput?.filePath ?? '';
47
+ }
48
+
49
+ /**
50
+ * Classify one tool call into the surface this gate cares about.
51
+ * @returns {{ kind: 'exempt'|'spawn'|'code_write'|'shell_build'|'shell_write', detail?: string }}
52
+ */
53
+ export function classifyToolCall({ toolName, toolInput }, { cwd, vaultRoot } = {}) {
54
+ const name = String(toolName ?? '');
55
+ if (!name || isHeyspexMcpTool(name)) return { kind: 'exempt' };
56
+ const input = toolInput ?? {};
57
+
58
+ if (SPAWN_TOOLS.has(name)) {
59
+ const type = String(input.subagent_type ?? '').trim().toLowerCase();
60
+ if (type && READ_ONLY_AGENT_TYPES.has(type)) return { kind: 'exempt' };
61
+ return { kind: 'spawn', detail: type || 'general-purpose' };
62
+ }
63
+
64
+ if (WRITE_TOOLS.has(name)) {
65
+ const target = pathOf(input);
66
+ return isProductCodePath(target, { cwd, vaultRoot }) ? { kind: 'code_write', detail: String(target) } : { kind: 'exempt' };
67
+ }
68
+
69
+ if (SHELL_TOOLS.has(name)) {
70
+ const command = String(input.command ?? '');
71
+ const product = extractShellTargets(command).find((t) => isProductCodePath(t, { cwd, vaultRoot }));
72
+ if (product) return { kind: 'shell_write', detail: product };
73
+ if (BUILD_CMD_RE.test(command)) return { kind: 'shell_build', detail: command.trim().slice(0, 80) };
74
+ return { kind: 'exempt' };
75
+ }
76
+
77
+ return { kind: 'exempt' };
78
+ }
79
+
80
+ const HOW_TO_DISCHARGE =
81
+ 'Capture the owner\'s words first: call heyspex_update_capture (or heyspex_file_spec / the /hs-capture skill) with their exact ' +
82
+ 'message, then retry. Reads, greps, git, test runs, vault and .heyspex writes and every heyspex_* tool stay open meanwhile.';
83
+
84
+ /**
85
+ * Rule 1: while a capture obligation is outstanding for this session, deny a sub-agent spawn, a
86
+ * product-code write and a build/product-write shell command.
87
+ * @returns {{ deny: boolean, reason?: string, rule?: string }}
88
+ */
89
+ export function decideObligation(call, { outstandingCount, cwd, vaultRoot }) {
90
+ if (!outstandingCount) return { deny: false };
91
+ const c = classifyToolCall(call, { cwd, vaultRoot });
92
+ if (c.kind === 'exempt') return { deny: false };
93
+ const what =
94
+ c.kind === 'spawn' ? `spawn a ${c.detail} sub-agent`
95
+ : c.kind === 'code_write' ? `write product code (${c.detail})`
96
+ : c.kind === 'shell_write' ? `write product code from the shell (${c.detail})`
97
+ : `run a build command (${c.detail})`;
98
+ return {
99
+ deny: true,
100
+ rule: 'directive-obligation',
101
+ reason:
102
+ `HEYSPEX directive-obligation-gate: the capture_on_idea directive was issued for this session and the capture has not happened, so you may not ${what}. ` +
103
+ `${HOW_TO_DISCHARGE} (Break-glass, owner only: HEYSPEX_DIRECTIVE_OBLIGATION_GATE_OFF=1. [[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]])`,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Rule 2 (claim-before-build): deny a build sub-agent spawn or a product-code write when the session
109
+ * has no claimed spec, no on-deck capture and no waiver. Independent of the plan-exit marker and CLAUDE_AGENT.
110
+ */
111
+ export function decideClaimBeforeBuild(call, { hasRoadToCode, cwd, vaultRoot }) {
112
+ if (hasRoadToCode) return { deny: false };
113
+ const c = classifyToolCall(call, { cwd, vaultRoot });
114
+ if (c.kind !== 'spawn' && c.kind !== 'code_write') return { deny: false };
115
+ const what = c.kind === 'spawn' ? `spawn a ${c.detail} sub-agent` : `edit product code (${c.detail})`;
116
+ return {
117
+ deny: true,
118
+ rule: 'claim-before-build',
119
+ reason:
120
+ `HEYSPEX claim-before-build: this session holds no claimed spec (claim heartbeat confirmed), has no capture on deck and no waiver, so you may not ${what}. ` +
121
+ 'Claim the spec you are building (heyspex_claim_spec) or capture the idea (heyspex_update_capture / /hs-capture), then retry. ' +
122
+ '(Break-glass, owner only: HEYSPEX_DIRECTIVE_OBLIGATION_GATE_OFF=1. [[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]])',
123
+ };
124
+ }
125
+
126
+ export function stopReason(count) {
127
+ return (
128
+ `HEYSPEX directive-obligation-gate: you are about to stop with ${count} capture obligation(s) unmet - the capture_on_idea directive ` +
129
+ 'was issued this session and no capture write was seen. Capture the owner\'s words now (heyspex_update_capture with their exact message, ' +
130
+ 'or /hs-capture), then finish. This re-drive fires once; it will not block again. [[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]]'
131
+ );
132
+ }
@@ -0,0 +1,183 @@
1
+ // spexharness/src/gates/directive-obligation-gate.mjs
2
+ //
3
+ // [[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]]. The capture_on_idea directive is advisory
4
+ // text; nothing recorded "a directive was issued and still owes a capture", so a model that skipped the
5
+ // capture met no gate (every capture gate keys on a capture that exists). This gate turns that unmet
6
+ // obligation into a deny. State: `.heyspex/directive-obligations.json` (../../../scripts/lib/directive-obligations.mjs),
7
+ // ARMED by the UserPromptSubmit verbatim stage (scripts/lib/capture-verbatim-append.mjs).
8
+ //
9
+ // ONE hook script, THREE Claude Code events (the coo-guard-gate / git-reflog-audit multi-event form):
10
+ // PreToolUse (before_tool) Agent|Task|Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell
11
+ // rule 1 `directive-obligation`: deny a sub-agent spawn, a product-code write and a build / product-write
12
+ // shell command while THIS session has an unsatisfied, unwaived, unexpired capture obligation.
13
+ // rule 2 `claim-before-build`: deny a build sub-agent spawn or a product-code Edit/Write when the session
14
+ // has no claimed spec, no on-deck capture and no waiver - independent of the plan-exit marker
15
+ // (claim-before-code-gate) and of CLAUDE_AGENT.
16
+ // PostToolUse (after_tool) discharges the obligation on heyspex_update_capture / heyspex_promote_capture* /
17
+ // heyspex_file_spec / the hs-capture Skill (suffix match: bare and mcp__*__ spellings), and tracks
18
+ // heyspex_claim_spec / release_spec for rule 2. Never denies.
19
+ // Stop (stop) blocks ONCE with a re-drive reason while the obligation is unmet, stamps
20
+ // `stop_blocked_at`, then never loops (and honours stop_hook_active).
21
+ //
22
+ // Always allowed: Read / Grep / Glob, git, test runs, writes under the vault container / vault root / .heyspex /
23
+ // .claude, and every heyspex_* MCP call.
24
+ //
25
+ // Event mapping: PreToolUse -> before_tool (tool_name -> toolName, tool_input -> toolInput); PostToolUse ->
26
+ // after_tool (+ tool_response -> toolResult); Stop -> stop. Decision mapping: deny -> permissionDecision deny
27
+ // (before_tool) or { decision: "block", reason } (stop); everything else allow. No `rewrite`/`ask` rung: a
28
+ // directive that can be ignored is exactly the failure this gate exists to remove.
29
+ //
30
+ // FAIL OPEN: no session id, an unreadable / foreign-session state file, any thrown error -> allow.
31
+ // Kill switches: env HEYSPEX_DIRECTIVE_OBLIGATION_GATE_OFF=1; config agent_behaviors.directive_obligation_gate.enabled
32
+ // (false = off; read from the VAULT root, PORTING.md section 3); config agent_behaviors.directive_obligation_gate.claim_before_build.enabled: false
33
+ // turns off rule 2 only. HEYSPEX_MODE=off mutes it (this gate is NOT always-on; scripts/claude-hooks/_run.mjs also skips it).
34
+ //
35
+ // GitHub Copilot (owner scope change 2026-10-16: must be Copilot-compatible, [[HS-0GBS-CTF8-RES-copilot-plugin-parity-with-the-spexcode]]):
36
+ // every decision lives in ./directive-obligation-gate.decide.mjs over a harness-agnostic shape (toolName, toolInput,
37
+ // sessionId, cwd); this module never builds a Claude `hookSpecificOutput` (the adapter shapes output); ANY missing or
38
+ // malformed field fails OPEN. The Copilot adapter + hook rows follow once the Copilot hook contract is pinned.
39
+ // Cursor: scripts/cursor-hooks/manifest.mjs.
40
+ //
41
+ // `ctx` (all optional): env, cwd (project root for the state files; default CLAUDE_PROJECT_DIR, else this checkout),
42
+ // configRoot, vaultRoot, sessionId, now, configEnabled (false = off), claimRuleEnabled (false = rule 2 off).
43
+
44
+ import path from 'node:path';
45
+ import { fileURLToPath } from 'node:url';
46
+
47
+ import { defaultConfigRoot } from '../config-root.mjs';
48
+ import { hasFreshNoClaimSignal } from '../../../scripts/claude-hooks/claim-liveness-shared.mjs';
49
+ import { isConfigKeyEnabled } from '../../../scripts/claude-hooks/_guardrail-loader.mjs';
50
+ import {
51
+ CLAIM_RULE_CONFIG_KEY,
52
+ GATE_CONFIG_KEY,
53
+ GATE_OFF_ENV,
54
+ hasRoadToCode,
55
+ sessionHoldsClaimOnDisk,
56
+ isClaimAcquireTool,
57
+ isClaimReleaseTool,
58
+ isSatisfyingTool,
59
+ markStopBlocked,
60
+ outstanding,
61
+ readObligations,
62
+ satisfyAll,
63
+ toolResultFailed,
64
+ trackClaim,
65
+ } from '../../../scripts/lib/directive-obligations.mjs';
66
+ import { classifyToolCall, decideClaimBeforeBuild, decideObligation, stopReason } from './directive-obligation-gate.decide.mjs';
67
+
68
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
69
+
70
+ export const id = 'directive-obligation-gate';
71
+ export const event = 'before_tool';
72
+ export const severity = 'block';
73
+
74
+ function projectRoot(ctx) {
75
+ if (ctx.cwd) return ctx.cwd;
76
+ const fromEnv = process.env.CLAUDE_PROJECT_DIR?.trim();
77
+ if (fromEnv) return fromEnv.replace(/[\\/]+$/, '');
78
+ // Installed from npm (node_modules/heyspex) with no CLAUDE_PROJECT_DIR (GitHub Copilot sets none): this
79
+ // checkout is the PACKAGE, not the project. The hook process runs with cwd = the repo.
80
+ if (/[\\/]node_modules[\\/]/.test(REPO_ROOT + path.sep)) return process.cwd();
81
+ return REPO_ROOT;
82
+ }
83
+
84
+ function enabled(ctx, key, override) {
85
+ if (override === false) return false;
86
+ try {
87
+ const configRoot = ctx.configRoot ?? (ctx.cwd ? ctx.cwd : defaultConfigRoot());
88
+ return isConfigKeyEnabled(key, configRoot);
89
+ } catch {
90
+ return true;
91
+ }
92
+ }
93
+
94
+ function claimedSpecId(toolInput) {
95
+ const v = toolInput?.spec_id ?? toolInput?.specId ?? toolInput?.id;
96
+ return typeof v === 'string' && v.trim() ? v.trim() : '*';
97
+ }
98
+
99
+ /**
100
+ * Rule 2 denies only on POSITIVE evidence of no claim (a fresh `noClaimUntil` heartbeat for this session).
101
+ * When a gated call is let through only because that evidence is absent (missing / stale cache, cloud claims),
102
+ * say so on stderr so the fail-open is visible. Never throws.
103
+ */
104
+ function noteFailOpen(call, { root, sessionId, vaultRoot, env, stderr }) {
105
+ try {
106
+ const kind = classifyToolCall(call, { cwd: root, vaultRoot }).kind;
107
+ if (kind !== 'spawn' && kind !== 'code_write') return;
108
+ if (hasFreshNoClaimSignal(sessionId, root) || sessionHoldsClaimOnDisk(root, sessionId, env)) return;
109
+ (stderr ?? ((m) => process.stderr.write(m)))('HEYSPEX claim-before-build: claim liveness unconfirmed for this session; failing open (allow).\n');
110
+ } catch {
111
+ /* never throw */
112
+ }
113
+ }
114
+
115
+ /**
116
+ * @param {{ point?: string, toolName?: string, toolInput?: object, toolResult?: object, sessionId?: string, stopHookActive?: boolean }} evt
117
+ * @param {object} [ctx]
118
+ * @returns {{ decision: 'allow'|'deny', reason?: string, rule?: string }}
119
+ */
120
+ export function run(evt, ctx = {}) {
121
+ try {
122
+ const env = ctx.env ?? process.env;
123
+ if (String(env[GATE_OFF_ENV] ?? '') === '1') return { decision: 'allow' };
124
+ if (String(env.HEYSPEX_MODE ?? '').trim().toLowerCase() === 'off') return { decision: 'allow' };
125
+ const sessionId = String(evt?.sessionId ?? ctx.sessionId ?? '').trim();
126
+ if (!sessionId) return { decision: 'allow' };
127
+ if (!enabled(ctx, GATE_CONFIG_KEY, ctx.configEnabled)) return { decision: 'allow' };
128
+
129
+ const root = projectRoot(ctx);
130
+ const now = ctx.now ?? Date.now();
131
+ const point = evt?.point;
132
+
133
+ if (point === 'after_tool') {
134
+ const name = String(evt?.toolName ?? '');
135
+ if (isSatisfyingTool(name, evt?.toolInput) && !toolResultFailed(evt?.toolResult)) {
136
+ satisfyAll({ root, sessionId, now });
137
+ } else if (isClaimAcquireTool(name) && !toolResultFailed(evt?.toolResult)) {
138
+ trackClaim({ root, sessionId, specId: claimedSpecId(evt?.toolInput) });
139
+ } else if (isClaimReleaseTool(name) && !toolResultFailed(evt?.toolResult)) {
140
+ trackClaim({ root, sessionId, specId: claimedSpecId(evt?.toolInput), release: true });
141
+ }
142
+ return { decision: 'allow' };
143
+ }
144
+
145
+ const state = readObligations(root, sessionId);
146
+ const open = outstanding(state, sessionId, now);
147
+
148
+ if (point === 'stop') {
149
+ if (evt?.stopHookActive === true) return { decision: 'allow' };
150
+ const unspent = open.filter((o) => !o.stop_blocked_at);
151
+ if (!unspent.length) return { decision: 'allow' };
152
+ markStopBlocked({ root, sessionId, now });
153
+ return { decision: 'deny', rule: 'directive-obligation', reason: stopReason(unspent.length) };
154
+ }
155
+
156
+ if (point !== 'before_tool') return { decision: 'allow' };
157
+
158
+ const call = { toolName: evt?.toolName, toolInput: evt?.toolInput ?? {} };
159
+ const vaultRoot = ctx.vaultRoot ?? defaultConfigRoot();
160
+
161
+ const v1 = decideObligation(call, { outstandingCount: open.length, cwd: root, vaultRoot });
162
+ if (v1.deny) return { decision: 'deny', reason: v1.reason, rule: v1.rule };
163
+
164
+ if (enabled(ctx, CLAIM_RULE_CONFIG_KEY, ctx.claimRuleEnabled)) {
165
+ const road = hasRoadToCode({ root, sessionId, state, now, env });
166
+ const v2 = decideClaimBeforeBuild(call, { hasRoadToCode: road, cwd: root, vaultRoot });
167
+ if (v2.deny) return { decision: 'deny', reason: v2.reason, rule: v2.rule };
168
+ if (road && !state?.claims?.length && !state?.captured_at) noteFailOpen(call, { root, sessionId, vaultRoot, env, stderr: ctx.stderr });
169
+ }
170
+ return { decision: 'allow' };
171
+ } catch {
172
+ return { decision: 'allow' }; // fail open
173
+ }
174
+ }
175
+
176
+ export const beforeToolGate = { id: 'directive-obligation-before-tool', event: 'before_tool', severity: 'block', run };
177
+ export const afterToolGate = { id: 'directive-obligation-after-tool', event: 'after_tool', severity: 'warn', run };
178
+ export const stopGate = { id: 'directive-obligation-stop', event: 'stop', severity: 'block', run };
179
+
180
+ /** The multi-event form the Claude Code shell hands the adapter (registry.mjs registers the three rungs above). */
181
+ export const hookGate = { id, event: ['before_tool', 'after_tool', 'stop'], severity, run };
182
+
183
+ export default { id, event, severity, run, beforeToolGate, afterToolGate, stopGate, hookGate };
@@ -0,0 +1,124 @@
1
+ // spexharness/src/gates/vault-mirror-catchup.mjs
2
+ //
3
+ // SessionStart catch-up for the cloud -> local vault mirror (diagnose HS-XJFQ, acceptance criterion 3:
4
+ // "a Companion or MCP session start against a clone that is behind the cloud closes the gap before the
5
+ // first tool call"). In cloud write mode the console is the system of record and the disk clone is a
6
+ // mirror; this gate starts `hs pull --apply` in the BACKGROUND so the hook returns immediately.
7
+ //
8
+ // Fail-open and silent on every skip: local reliance, no vault root / no heyspex.yaml, kill switch
9
+ // HEYSPEX_VAULT_MIRROR_CATCHUP_OFF=1, or a catch-up that already ran in the last 10 minutes (stamp file).
10
+ // Never denies. Emits one line of additionalContext when it spawns.
11
+ //
12
+ // ctx (all optional, for tests): env, vaultRoot, writeMode, now, exists(p), statMtimeMs(p), mkdirp(p),
13
+ // writeFile(p, s), openLog(p) -> fd, spawnDetached(cmd, args, opts), hsCommand() -> { command, args, shell }.
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { spawn } from 'node:child_process';
18
+
19
+ import { defaultConfigRoot } from '../config-root.mjs';
20
+ import { resolveDevMode } from '../../../scripts/lib/dev-mode.mjs';
21
+ import { describeHsCommand } from '../../../scripts/lib/hs-command.mjs';
22
+
23
+ export const id = 'vault-mirror-catchup';
24
+ export const event = 'session_start';
25
+ export const severity = 'info';
26
+
27
+ export const KILL_ENV = 'HEYSPEX_VAULT_MIRROR_CATCHUP_OFF';
28
+ export const FRESH_WINDOW_MS = 10 * 60 * 1000;
29
+ export const LOG_REL = '.heyspex/vault-mirror-catchup.log';
30
+ export const STAMP_REL = '.heyspex/vault-mirror-catchup.stamp';
31
+
32
+ /** Resolve how to launch `hs pull --apply`: the PATH shim (shell on Windows) or `node <dispatcher>`. */
33
+ export function defaultHsLaunch() {
34
+ const d = describeHsCommand();
35
+ if (d.onPath) return { command: 'hs', args: ['pull', '--apply'], shell: process.platform === 'win32' };
36
+ if (d.dispatcherPath) return { command: process.execPath, args: [d.dispatcherPath, 'pull', '--apply'], shell: false };
37
+ return { command: 'hs', args: ['pull', '--apply'], shell: process.platform === 'win32' };
38
+ }
39
+
40
+ /**
41
+ * Pure-ish decision: returns { spawn: false } or { spawn: true, vaultRoot, command, args, ... }.
42
+ * All I/O goes through injected deps.
43
+ */
44
+ export function decideCatchup(deps = {}) {
45
+ const env = deps.env ?? process.env;
46
+ if (String(env[KILL_ENV] ?? '') === '1') return { spawn: false, reason: 'kill-switch' };
47
+ let writeMode = deps.writeMode;
48
+ if (!writeMode) {
49
+ try {
50
+ writeMode = resolveDevMode({ env }).vaultWriteMode;
51
+ } catch {
52
+ return { spawn: false, reason: 'dev-mode-unresolved' };
53
+ }
54
+ }
55
+ if (writeMode !== 'cloud') return { spawn: false, reason: 'local-reliance' };
56
+ let vaultRoot = deps.vaultRoot;
57
+ if (vaultRoot === undefined) {
58
+ try {
59
+ vaultRoot = defaultConfigRoot();
60
+ } catch {
61
+ vaultRoot = null;
62
+ }
63
+ }
64
+ if (!vaultRoot) return { spawn: false, reason: 'no-vault-root' };
65
+ const exists = deps.exists ?? fs.existsSync;
66
+ if (!exists(path.join(vaultRoot, 'heyspex.yaml'))) return { spawn: false, reason: 'no-heyspex-yaml' };
67
+
68
+ const now = (deps.now ?? Date.now)();
69
+ const stampPath = path.join(vaultRoot, STAMP_REL);
70
+ const mtime = (deps.statMtimeMs ?? ((p) => {
71
+ try {
72
+ return fs.statSync(p).mtimeMs;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }))(stampPath);
77
+ if (typeof mtime === 'number' && now - mtime >= 0 && now - mtime < FRESH_WINDOW_MS) {
78
+ return { spawn: false, reason: 'stamp-fresh' };
79
+ }
80
+ const launch = (deps.hsCommand ?? defaultHsLaunch)();
81
+ return { spawn: true, vaultRoot, stampPath, logPath: path.join(vaultRoot, LOG_REL), ...launch };
82
+ }
83
+
84
+ /** Perform the detached spawn for a { spawn: true } decision. Throws on failure (caller fails open). */
85
+ export function startCatchup(d, deps = {}) {
86
+ const mkdirp = deps.mkdirp ?? ((p) => fs.mkdirSync(p, { recursive: true }));
87
+ const writeFile = deps.writeFile ?? ((p, s) => fs.writeFileSync(p, s));
88
+ const openLog = deps.openLog ?? ((p) => fs.openSync(p, 'a'));
89
+ const spawnDetached = deps.spawnDetached ?? ((cmd, args, opts) => spawn(cmd, args, opts));
90
+ const env = deps.env ?? process.env;
91
+ mkdirp(path.dirname(d.logPath));
92
+ writeFile(d.stampPath, new Date((deps.now ?? Date.now)()).toISOString() + '\n');
93
+ const fd = openLog(d.logPath);
94
+ const child = spawnDetached(d.command, d.args, {
95
+ cwd: d.vaultRoot,
96
+ env: { ...env, HS_VAULT_ROOT: d.vaultRoot },
97
+ detached: true,
98
+ stdio: ['ignore', fd, fd],
99
+ windowsHide: true,
100
+ shell: d.shell === true,
101
+ });
102
+ child?.on?.('error', () => {});
103
+ child?.unref?.();
104
+ return child;
105
+ }
106
+
107
+ export function run(evt, ctx = {}) {
108
+ try {
109
+ const d = decideCatchup(ctx);
110
+ if (!d.spawn) return { decision: 'allow' };
111
+ startCatchup(d, ctx);
112
+ return {
113
+ decision: 'inject',
114
+ inject: {
115
+ additionalContext: `vault mirror catch-up: \`hs pull --apply\` started in the background for ${d.vaultRoot} (log: ${LOG_REL})`,
116
+ severity: 'info',
117
+ },
118
+ };
119
+ } catch {
120
+ return { decision: 'allow' };
121
+ }
122
+ }
123
+
124
+ export default { id, event, severity, run };
@@ -0,0 +1,125 @@
1
+ // spexharness/src/gates/vault-write-tier-guard.decide.mjs
2
+ //
3
+ // Pure decision for the vault-write-tier-guard PreToolUse gate (HS-FCNH).
4
+ //
5
+ // Problem: on a machine in prod / cloud reliance (`hs dev: prod`) the console
6
+ // reads Neon, and the vault clone on disk is a MIRROR the console never reads
7
+ // back. An agent that hand-edits `<vault>/specs/**/*.md` because a console
8
+ // prompt said so writes into a place nobody reads. This gate denies those disk
9
+ // writes in cloud write mode and names the cloud-routed tool to use instead.
10
+ //
11
+ // No process.exit / stdout here. `fs.existsSync` is the only I/O and is
12
+ // injectable (`deps.exists`).
13
+ //
14
+ // call = { toolName, toolInput }
15
+ // deps = { vaultRoot, writeMode, env, cwd?, exists?, platform? }
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+
20
+ import { extractShellTargets } from './_shell-write-targets.mjs';
21
+
22
+ export const KILL_ENV = 'HEYSPEX_VAULT_WRITE_TIER_GUARD_OFF';
23
+
24
+ const FILE_TOOLS = new Set(['write', 'edit', 'multiedit', 'notebookedit']);
25
+ const SHELL_TOOLS = new Set(['bash', 'powershell']);
26
+
27
+ function norm(p, platform) {
28
+ const r = String(p);
29
+ return platform === 'win32' ? r.toLowerCase() : r;
30
+ }
31
+
32
+ /** True when `p` resolves to `<vaultRoot>/specs/**\/*.md` outside `.trash/`. */
33
+ export function isGuardedVaultPath(p, { vaultRoot, cwd, platform = process.platform }) {
34
+ if (!p || typeof p !== 'string') return false;
35
+ let abs;
36
+ try {
37
+ abs = path.resolve(cwd ?? process.cwd(), p);
38
+ } catch {
39
+ return false;
40
+ }
41
+ const root = norm(path.resolve(vaultRoot), platform);
42
+ const target = norm(abs, platform);
43
+ const rel = path.relative(root, target);
44
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
45
+ const segs = rel.split(/[\\/]+/);
46
+ if (segs[0] !== 'specs' || segs.length < 2) return false;
47
+ if (segs.includes('.trash')) return false;
48
+ return path.extname(target) === '.md';
49
+ }
50
+
51
+ function editTexts(toolInput) {
52
+ const out = [];
53
+ for (const k of ['new_string', 'content', 'new_source', 'command']) {
54
+ if (typeof toolInput?.[k] === 'string') out.push(toolInput[k]);
55
+ }
56
+ if (Array.isArray(toolInput?.edits)) {
57
+ for (const e of toolInput.edits) if (typeof e?.new_string === 'string') out.push(e.new_string);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function pickTool(toolInput) {
63
+ return editTexts(toolInput).some((t) => /^summary:/m.test(t))
64
+ ? 'heyspex_author_summary'
65
+ : 'heyspex_edit_file';
66
+ }
67
+
68
+ const ROWS = [
69
+ ['summary: field', 'heyspex_author_summary { spec_id, summary } (load via heyspex_load_tools)', 'heyspex_author_summary'],
70
+ ['body / other frontmatter', 'heyspex_edit_file { file_path, content } (cloud-routed in this mode)', 'heyspex_edit_file'],
71
+ ['status', 'heyspex_submit_for_review / heyspex_claim_spec — never hand-edit status', null],
72
+ ['new spec', 'heyspex_file_spec', null],
73
+ ];
74
+
75
+ export function denyReason(target, tool) {
76
+ const rows = ROWS.map(([label, use, t]) => {
77
+ const mark = t === tool ? '=> ' : ' ';
78
+ return `${mark}${label.padEnd(24)} → ${use}`;
79
+ }).join('\n');
80
+ return (
81
+ `[vault-write-tier-guard] This machine writes the vault to the CLOUD (hs dev: prod). ` +
82
+ `${target} is a disk mirror the console never reads back (HS-FCNH).\n${rows}\n` +
83
+ `Break-glass for a human: ${KILL_ENV}=1.`
84
+ );
85
+ }
86
+
87
+ /**
88
+ * @param {{ toolName: string, toolInput: Record<string, unknown> }} call
89
+ * @param {{ vaultRoot?: string|null, writeMode?: string, env?: Record<string,string|undefined>,
90
+ * cwd?: string, exists?: (p: string) => boolean, platform?: string }} deps
91
+ * @returns {{ allow: boolean, reason?: string, tool?: string }}
92
+ */
93
+ export function decide(call, deps = {}) {
94
+ const env = deps.env ?? process.env;
95
+ if (deps.writeMode !== 'cloud') return { allow: true };
96
+ if (String(env[KILL_ENV] ?? '') === '1') return { allow: true };
97
+ const vaultRoot = deps.vaultRoot;
98
+ if (!vaultRoot || typeof vaultRoot !== 'string') return { allow: true };
99
+ const exists = deps.exists ?? ((p) => fs.existsSync(p));
100
+ if (!exists(path.join(vaultRoot, 'heyspex.yaml'))) return { allow: true };
101
+
102
+ const platform = deps.platform ?? process.platform;
103
+ const ctx = { vaultRoot, cwd: deps.cwd, platform };
104
+ const tool = String(call?.toolName ?? '').toLowerCase();
105
+ const input = call?.toolInput ?? {};
106
+
107
+ if (FILE_TOOLS.has(tool)) {
108
+ const fp = input.file_path ?? input.notebook_path ?? input.filePath;
109
+ if (isGuardedVaultPath(fp, ctx)) {
110
+ return { allow: false, tool: pickTool(input), reason: denyReason(String(fp), pickTool(input)) };
111
+ }
112
+ return { allow: true };
113
+ }
114
+
115
+ if (SHELL_TOOLS.has(tool)) {
116
+ const command = String(input.command ?? '');
117
+ if (!command) return { allow: true };
118
+ for (const t of extractShellTargets(command)) {
119
+ if (isGuardedVaultPath(t, ctx)) {
120
+ return { allow: false, tool: pickTool(input), reason: denyReason(t, pickTool(input)) };
121
+ }
122
+ }
123
+ }
124
+ return { allow: true };
125
+ }
@@ -0,0 +1,42 @@
1
+ // spexharness/src/gates/vault-write-tier-guard.mjs
2
+ //
3
+ // Engine gate for HS-FCNH: in cloud write mode (`hs dev: prod`), deny agent
4
+ // disk writes to `<vault>/specs/**/*.md` — the disk copy is a mirror the console
5
+ // never reads back. Decision logic lives in ./vault-write-tier-guard.decide.mjs.
6
+ //
7
+ // Kill switch (human break-glass): HEYSPEX_VAULT_WRITE_TIER_GUARD_OFF=1.
8
+ // Deliberately NOT in LEGACY_EXIT2_BANNER: it emits the modern
9
+ // permissionDecision: deny JSON via the adapter.
10
+ //
11
+ // `ctx` (all optional): vaultRoot, writeMode, env, cwd, exists.
12
+
13
+ import { defaultConfigRoot } from '../config-root.mjs';
14
+ import { resolveDevMode } from '../../../scripts/lib/dev-mode.mjs';
15
+ import { decide as legacyDecide, KILL_ENV } from './vault-write-tier-guard.decide.mjs';
16
+
17
+ export const id = 'vault-write-tier-guard';
18
+ export const event = 'before_tool';
19
+ export const severity = 'block';
20
+
21
+ export function run(evt, ctx = {}) {
22
+ const env = ctx.env ?? process.env;
23
+ if (String(env[KILL_ENV] ?? '') === '1') return { decision: 'allow' };
24
+ let writeMode = ctx.writeMode;
25
+ if (!writeMode) {
26
+ try {
27
+ writeMode = resolveDevMode({ env }).vaultWriteMode;
28
+ } catch {
29
+ return { decision: 'allow' };
30
+ }
31
+ }
32
+ if (writeMode !== 'cloud') return { decision: 'allow' };
33
+ const vaultRoot = ctx.vaultRoot ?? defaultConfigRoot();
34
+ const verdict = legacyDecide(
35
+ { toolName: evt?.toolName, toolInput: evt?.toolInput ?? {} },
36
+ { vaultRoot, writeMode, env, cwd: ctx.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd(), exists: ctx.exists },
37
+ );
38
+ if (verdict.allow) return { decision: 'allow' };
39
+ return { decision: 'deny', reason: verdict.reason };
40
+ }
41
+
42
+ export default { id, event, severity, run };
@@ -34,7 +34,7 @@
34
34
  // rule-table.mjs's header). No process.stdin/stdout/exit.
35
35
 
36
36
  import { loadRuleTable } from './rule-table.mjs';
37
- import { buildIntentDirective } from './intent-directive.mjs';
37
+ import { buildIntentDirective, ruleMatchesPrompt } from './intent-directive.mjs';
38
38
 
39
39
  /** This gate's id, for a registry or an audit row. */
40
40
  export const GATE_ID = 'intent-classifier';
@@ -56,6 +56,7 @@ export const CLASSIFIER_RULE_IDS = Object.freeze([
56
56
  'research-before-decision',
57
57
  'settings-intent',
58
58
  'defer-intent',
59
+ 'handoff-intent',
59
60
  'spec-reference-lookup',
60
61
  ]);
61
62
 
@@ -113,7 +114,8 @@ export function classifyPrompt(prompt, { vaultRoot, ruleTable } = {}) {
113
114
  const table = ruleTable ?? loadRuleTable({ vaultRoot });
114
115
  for (const rule of classifierRules(table)) {
115
116
  const patterns = compilePatterns(rule);
116
- if (patterns.some((re) => re.test(prompt))) {
117
+ // HS-XDH8: a negation-guarded rule (defer-intent) needs an un-negated occurrence.
118
+ if (ruleMatchesPrompt(rule, patterns, prompt)) {
117
119
  return { rule: rule.rule, sourceProtocol: rule.sourceProtocol ?? null, inject: rule.inject };
118
120
  }
119
121
  }