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.
- package/.claude-plugin/marketplace.json +14 -0
- package/bin/hs.mjs +181 -15
- package/dist/cli/hs-check.mjs +88 -46
- package/dist/cli/hs-claim.mjs +84 -39
- package/dist/cli/hs-connect.mjs +583 -129
- package/dist/cli/hs-defer.mjs +251 -30
- package/dist/cli/hs-dev.mjs +20 -8
- package/dist/cli/hs-env.mjs +62 -34
- package/dist/cli/hs-index-rebuild.mjs +288 -83
- package/dist/cli/hs-index-reconcile.mjs +397 -111
- package/dist/cli/hs-init.mjs +2143 -91
- package/dist/cli/hs-install.mjs +628 -106
- package/dist/cli/hs-join.mjs +686 -236
- package/dist/cli/hs-vault-pull.mjs +3189 -179
- package/dist/cli/hs-vault-push.mjs +6433 -0
- package/dist/cli/hs-vault.mjs +396 -178
- package/dist/cli/hs-workspace.mjs +289 -151
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
- package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
- package/docs/copilot-plugin-install.md +177 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/package.json +21 -2
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
- package/plugins/heyspex/README.md +312 -0
- package/plugins/heyspex/bin/run-hook.mjs +20 -2
- package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
- package/plugins/heyspex/hooks/hooks.json +94 -37
- package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
- package/scripts/claude-hooks/_run.mjs +316 -23
- package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
- package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
- package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
- package/scripts/claude-hooks/session-end.mjs +2 -0
- package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
- package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
- package/scripts/cursor-hooks/manifest.mjs +46 -0
- package/scripts/heyspex-mode.mjs +20 -0
- package/scripts/hs-mode.mjs +1 -1
- package/scripts/hs-view.mjs +180 -17
- package/scripts/install-claude-code-hooks.mjs +3 -1
- package/scripts/install-copilot-hooks.mjs +288 -0
- package/scripts/lib/console-url-resolver.mjs +3 -2
- package/scripts/lib/customer-kit.mjs +327 -10
- package/scripts/lib/dev-mode.mjs +7 -6
- package/scripts/lib/directive-obligations.mjs +349 -0
- package/scripts/lib/hook-dispositions.mjs +210 -0
- package/scripts/lib/hook-vault-header.mjs +30 -0
- package/scripts/lib/hs-source-branch.mjs +196 -0
- package/scripts/lib/hs-source-log.mjs +2 -1
- package/scripts/lib/hs-source-preflight.mjs +18 -4
- package/scripts/lib/hs-source-push.mjs +386 -356
- package/scripts/lib/window-placement.mjs +109 -0
- package/spexharness/src/adapters/claude-code.mjs +11 -3
- package/spexharness/src/adapters/copilot.mjs +236 -0
- package/spexharness/src/adapters/detect-harness.mjs +34 -0
- package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
- package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
- package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
- package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
- package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
- package/spexharness/src/intent-classifier.mjs +4 -2
- package/spexharness/src/intent-directive.mjs +626 -541
- package/spexharness/src/rule-table.mjs +31 -1
- package/vendor/heyspex-mcp/account-switch.mjs +6 -5
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
- package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
- package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
- package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
- package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
- package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
- package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
- package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
- package/vendor/heyspex-mcp/cli.mjs +7 -1
- package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
- package/vendor/heyspex-mcp/console-transport.mjs +7 -1
- package/vendor/heyspex-mcp/credentials.mjs +9 -8
- package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
- package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
- package/vendor/heyspex-mcp/http-transport.mjs +2 -0
- package/vendor/heyspex-mcp/list-tools.mjs +1 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
- package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
- package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
- package/vendor/heyspex-mcp/plan-push.mjs +3 -2
- package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
- package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
- package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
- package/vendor/heyspex-mcp/promote-research.mjs +6 -0
- package/vendor/heyspex-mcp/read-path.mjs +162 -6
- package/vendor/heyspex-mcp/request-context.mjs +25 -1
- package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
- package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
- package/vendor/heyspex-mcp/session-owner.mjs +3 -2
- package/vendor/heyspex-mcp/session-tools.mjs +10 -0
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
- package/vendor/heyspex-mcp/split-research.mjs +3 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
- package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
- package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
- package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
- package/vendor/heyspex-mcp/vault-io.mjs +720 -724
- package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
- package/vendor/heyspex-mcp/whoami.mjs +2 -1
- 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
|
-
|
|
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
|
}
|