heyspex 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -0
- package/bin/hs.mjs +163 -11
- 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 +162 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/package.json +20 -2
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
- package/plugins/heyspex/README.md +309 -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/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,349 @@
|
|
|
1
|
+
// scripts/lib/directive-obligations.mjs
|
|
2
|
+
//
|
|
3
|
+
// [[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]] - "a directive was issued and still
|
|
4
|
+
// owes a capture" as DURABLE state. The capture_on_idea directive injected at UserPromptSubmit is
|
|
5
|
+
// advisory text; nothing recorded that it was issued, so when the agent skipped the capture every
|
|
6
|
+
// downstream gate failed open (they all key on a capture that exists). This module is the pure
|
|
7
|
+
// core of the fix: read / arm / satisfy / waive / isOutstanding over ONE state file,
|
|
8
|
+
// `.heyspex/directive-obligations.json`, scoped by session id:
|
|
9
|
+
//
|
|
10
|
+
// { session_id, prompt_count, claims: [spec ids], build_waived,
|
|
11
|
+
// obligations: [ { id, kind: "capture", prompt_n, issued_at, prompt_sha,
|
|
12
|
+
// satisfied: false, waived: null, stop_blocked_at? } ] }
|
|
13
|
+
//
|
|
14
|
+
// Root convention: the same as `.heyspex/capture-gate-state.json` (scripts/lib/capture-execution-guard.mjs):
|
|
15
|
+
// the project root the hook runs in (CLAUDE_PROJECT_DIR, else the payload cwd, else this checkout).
|
|
16
|
+
//
|
|
17
|
+
// Consumers:
|
|
18
|
+
// - scripts/lib/capture-verbatim-append.mjs (UserPromptSubmit: arms)
|
|
19
|
+
// - spexharness/src/gates/directive-obligation-gate.mjs (PreToolUse deny, PostToolUse satisfy, Stop re-drive)
|
|
20
|
+
//
|
|
21
|
+
// DEPENDENCY-FREE on purpose (node:* only): this ships in the customer hook kit (customer-kit.mjs
|
|
22
|
+
// scope 'customer'), so it must not import any platform-only module.
|
|
23
|
+
//
|
|
24
|
+
// FAIL OPEN everywhere: an unreadable or malformed state file, a missing session id, an expired
|
|
25
|
+
// obligation all read as "nothing owed". Only a fresh, this-session, unsatisfied, unwaived
|
|
26
|
+
// obligation can ever refuse anything.
|
|
27
|
+
//
|
|
28
|
+
// Several agents may share one project root: the top level is the most recent writer's record and the others
|
|
29
|
+
// are parked under `others` (see the state-file section), so no session erases another's obligation.
|
|
30
|
+
|
|
31
|
+
import crypto from 'node:crypto';
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
|
|
35
|
+
import { hasFreshNoClaimSignal } from '../claude-hooks/claim-liveness-shared.mjs';
|
|
36
|
+
|
|
37
|
+
export const OBLIGATIONS_REL = path.join('.heyspex', 'directive-obligations.json');
|
|
38
|
+
/** Same ceiling as capture-execution-guard's STATE_MAX_AGE_MS: an obligation older than this stops gating. */
|
|
39
|
+
export const OBLIGATION_TTL_MS = 6 * 60 * 60 * 1000;
|
|
40
|
+
export const MAX_OBLIGATIONS = 20;
|
|
41
|
+
|
|
42
|
+
/** Owner/env kill switch; the config twin is agent_behaviors.directive_obligation_gate.enabled. */
|
|
43
|
+
export const GATE_OFF_ENV = 'HEYSPEX_DIRECTIVE_OBLIGATION_GATE_OFF';
|
|
44
|
+
export const GATE_CONFIG_KEY = 'agent_behaviors.directive_obligation_gate.enabled';
|
|
45
|
+
/** Narrower config key: turns off ONLY the claim-before-build rule. */
|
|
46
|
+
export const CLAIM_RULE_CONFIG_KEY = 'agent_behaviors.directive_obligation_gate.claim_before_build.enabled';
|
|
47
|
+
|
|
48
|
+
// ── tool-name classification (suffix match: bare, mcp__heyspex__ and plugin-scoped spellings) ──
|
|
49
|
+
|
|
50
|
+
/** A tool call that discharges a capture obligation. */
|
|
51
|
+
const SATISFYING_TOOL_RE = /(?:^|__)heyspex_(?:update_capture|promote_capture\w*|file_spec)$/;
|
|
52
|
+
const CLAIM_ACQUIRE_RE = /(?:^|__)heyspex_(?:claim_spec|claim_acquire|claim_file)$/;
|
|
53
|
+
const CLAIM_RELEASE_RE = /(?:^|__)heyspex_(?:release_spec|claim_release|defer_spec)$/;
|
|
54
|
+
|
|
55
|
+
export function isSatisfyingTool(toolName, toolInput) {
|
|
56
|
+
const name = String(toolName ?? '');
|
|
57
|
+
if (SATISFYING_TOOL_RE.test(name)) return true;
|
|
58
|
+
if (name === 'Skill') {
|
|
59
|
+
const skill = String(toolInput?.skill ?? toolInput?.name ?? '');
|
|
60
|
+
return /(?:^|:)hs-capture$/.test(skill.trim().replace(/^\//, ''));
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
export const isClaimAcquireTool = (toolName) => CLAIM_ACQUIRE_RE.test(String(toolName ?? ''));
|
|
65
|
+
export const isClaimReleaseTool = (toolName) => CLAIM_RELEASE_RE.test(String(toolName ?? ''));
|
|
66
|
+
|
|
67
|
+
/** True when a tool response says the call failed (a failed capture write must not discharge anything). */
|
|
68
|
+
export function toolResultFailed(toolResult) {
|
|
69
|
+
if (!toolResult || typeof toolResult !== 'object') return false;
|
|
70
|
+
return toolResult.is_error === true || toolResult.isError === true || toolResult.success === false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── negation ────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
// "don't capture this", "no need to log it", "without capturing", "skip the capture". Prompts that say
|
|
76
|
+
// this must arm nothing: the owner's word stands (DISCARD_RE in capture-verbatim-append covers the
|
|
77
|
+
// line-initial forms; this covers the mid-sentence ones).
|
|
78
|
+
const CAPTURE_NEGATED_RE =
|
|
79
|
+
/\b(?:don'?t|do\s+not|dont|never|no\s+need\s+to|no\s+need\s+for|without|skip(?:ping)?|stop)\b[^.!?\n]{0,40}\b(?:captur\w*|log(?:ging)?\s+(?:it|this|that)|record(?:ing)?\s+(?:it|this|that))/i;
|
|
80
|
+
const NO_CAPTURE_RE = /\bno\s+captures?\b/i;
|
|
81
|
+
|
|
82
|
+
export function isCaptureNegated(prompt) {
|
|
83
|
+
const s = String(prompt ?? '').replace(/’/g, "'");
|
|
84
|
+
return CAPTURE_NEGATED_RE.test(s) || NO_CAPTURE_RE.test(s);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── path classification ─────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
const CODE_EXT_RE =
|
|
90
|
+
/\.(?:mjs|cjs|js|jsx|ts|tsx|mts|cts|cs|py|go|rs|rb|java|kt|swift|sql|css|scss|html|ps1|sh|vue|svelte|php|c|cc|cpp|h|hpp|json|ya?ml|toml|xaml|csproj|sln)$/i;
|
|
91
|
+
const EXEMPT_DIR_RE = /(?:^|\/)(?:specs|\.heyspex|\.claude|\.cursor|\.git|node_modules)(?:\/|$)/;
|
|
92
|
+
|
|
93
|
+
function norm(p) {
|
|
94
|
+
return String(p ?? '').replace(/\\/g, '/');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function underRoot(file, root) {
|
|
98
|
+
if (!root) return false;
|
|
99
|
+
const f = norm(path.resolve(file));
|
|
100
|
+
const r = norm(path.resolve(root)).replace(/\/+$/, '');
|
|
101
|
+
const eq = process.platform === 'win32' ? (a, b) => a.toLowerCase() === b.toLowerCase() : (a, b) => a === b;
|
|
102
|
+
return eq(f, r) || (f.length > r.length && eq(f.slice(0, r.length), r) && f[r.length] === '/');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Is `p` a PRODUCT-CODE path - a code/config file outside the vault container, the vault root,
|
|
107
|
+
* `.heyspex/`, `.claude/`, `.git/` and `node_modules/`? Writes to those are never gated.
|
|
108
|
+
* @param {string} p
|
|
109
|
+
* @param {{ cwd?: string, vaultRoot?: string }} [opts]
|
|
110
|
+
*/
|
|
111
|
+
export function isProductCodePath(p, { cwd, vaultRoot } = {}) {
|
|
112
|
+
const raw = String(p ?? '').trim();
|
|
113
|
+
if (!raw) return false;
|
|
114
|
+
const abs = path.isAbsolute(raw) ? raw : path.resolve(cwd ?? process.cwd(), raw);
|
|
115
|
+
const n = norm(abs);
|
|
116
|
+
if (EXEMPT_DIR_RE.test(n)) return false;
|
|
117
|
+
// outside the project tree (a scratchpad, the sibling vault checkout, a temp dir) is never product code
|
|
118
|
+
if (cwd && !underRoot(abs, cwd)) return false;
|
|
119
|
+
if (vaultRoot && underRoot(abs, vaultRoot)) return false;
|
|
120
|
+
return CODE_EXT_RE.test(n);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── state file ──────────────────────────────────────────────────────────────
|
|
124
|
+
//
|
|
125
|
+
// SHAPE: the top level is the MOST RECENT writing session's record (`session_id`, `obligations`, ...) - the
|
|
126
|
+
// documented shape. When a different session writes, the previous top-level record is parked under `others`
|
|
127
|
+
// (keyed by session id, TTL-pruned, capped) and restored when that session writes or reads again, so two
|
|
128
|
+
// agents sharing one project root never erase each other's obligations.
|
|
129
|
+
|
|
130
|
+
const SESSION_FIELDS = ['prompt_count', 'claims', 'build_waived', 'captured_at', 'obligations', 'updated_at'];
|
|
131
|
+
export const MAX_PARKED_SESSIONS = 8;
|
|
132
|
+
|
|
133
|
+
function emptyRecord() {
|
|
134
|
+
return { prompt_count: 0, claims: [], build_waived: false, obligations: [] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function pickRecord(full) {
|
|
138
|
+
const rec = {};
|
|
139
|
+
for (const k of SESSION_FIELDS) if (full[k] !== undefined) rec[k] = full[k];
|
|
140
|
+
return rec;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function loadFull(root) {
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(root, OBLIGATIONS_REL), 'utf8'));
|
|
146
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
147
|
+
if (parsed.obligations !== undefined && !Array.isArray(parsed.obligations)) return null;
|
|
148
|
+
return parsed;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* This session's record (with `session_id` set), or - when the file holds no record for it - the raw
|
|
156
|
+
* top-level object (whose `session_id` then differs, so every `outstanding(state, sessionId)` check reads
|
|
157
|
+
* "nothing owed"). Null when the file is absent / unreadable / malformed: every caller fails open on null.
|
|
158
|
+
* @param {string} root
|
|
159
|
+
* @param {string} [sessionId]
|
|
160
|
+
*/
|
|
161
|
+
export function readObligations(root, sessionId) {
|
|
162
|
+
const full = loadFull(root);
|
|
163
|
+
if (!full || !sessionId || full.session_id === sessionId) return full;
|
|
164
|
+
const parked = full.others?.[sessionId];
|
|
165
|
+
return parked && typeof parked === 'object' ? { ...emptyRecord(), ...parked, session_id: sessionId } : full;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Atomic best-effort write (a failed write only means the gate fails open). */
|
|
169
|
+
export function writeObligations(root, state) {
|
|
170
|
+
try {
|
|
171
|
+
const full = path.join(root, OBLIGATIONS_REL);
|
|
172
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
173
|
+
const tmp = `${full}.${process.pid}.tmp`;
|
|
174
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...state, written_at: new Date().toISOString() }, null, 2) + '\n', 'utf8');
|
|
175
|
+
fs.renameSync(tmp, full);
|
|
176
|
+
return true;
|
|
177
|
+
} catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Read-modify-write ONE session's record. `fn(record)` mutates it; the file is rotated so this session is the
|
|
184
|
+
* top-level record and the previous one is parked. Returns whatever `fn` returns, or null when the write failed.
|
|
185
|
+
*/
|
|
186
|
+
function mutate(root, sessionId, fn, now = Date.now()) {
|
|
187
|
+
if (!root || typeof sessionId !== 'string' || !sessionId.trim()) return null;
|
|
188
|
+
const sid = sessionId.trim();
|
|
189
|
+
const full = loadFull(root);
|
|
190
|
+
let others = {};
|
|
191
|
+
let rec = emptyRecord();
|
|
192
|
+
if (full) {
|
|
193
|
+
others = full.others && typeof full.others === 'object' ? { ...full.others } : {};
|
|
194
|
+
if (full.session_id === sid) {
|
|
195
|
+
rec = { ...rec, ...pickRecord(full) };
|
|
196
|
+
} else {
|
|
197
|
+
if (typeof full.session_id === 'string' && full.session_id) others[full.session_id] = pickRecord(full);
|
|
198
|
+
if (others[sid]) rec = { ...rec, ...others[sid] };
|
|
199
|
+
delete others[sid];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!Array.isArray(rec.obligations)) rec.obligations = [];
|
|
203
|
+
if (!Array.isArray(rec.claims)) rec.claims = [];
|
|
204
|
+
const out = fn(rec);
|
|
205
|
+
rec.updated_at = new Date(now).toISOString();
|
|
206
|
+
const keep = Object.entries(others)
|
|
207
|
+
.filter(([, o]) => now - Date.parse(o?.updated_at ?? 0) <= OBLIGATION_TTL_MS)
|
|
208
|
+
.sort((a, b) => Date.parse(b[1].updated_at) - Date.parse(a[1].updated_at))
|
|
209
|
+
.slice(0, MAX_PARKED_SESSIONS);
|
|
210
|
+
const next = { session_id: sid, ...rec, others: Object.fromEntries(keep) };
|
|
211
|
+
return writeObligations(root, next) ? (out === undefined ? true : out) : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function promptSha(prompt) {
|
|
215
|
+
return crypto.createHash('sha256').update(String(prompt ?? '')).digest('hex').slice(0, 16);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Record "a capture directive was issued for this prompt and still owes a capture".
|
|
220
|
+
* Idempotent per (session, prompt): re-arming the same prompt text adds nothing.
|
|
221
|
+
* @returns {object|null} the obligation, or null (no session id / write failed)
|
|
222
|
+
*/
|
|
223
|
+
export function armCapture({ root, sessionId, prompt, now = Date.now() }) {
|
|
224
|
+
return mutate(root, sessionId, (rec) => {
|
|
225
|
+
const sha = promptSha(prompt);
|
|
226
|
+
const existing = rec.obligations.find((o) => o.prompt_sha === sha && o.kind === 'capture' && !o.satisfied && !o.waived);
|
|
227
|
+
if (existing) return existing;
|
|
228
|
+
rec.prompt_count = (Number(rec.prompt_count) || 0) + 1;
|
|
229
|
+
const ob = {
|
|
230
|
+
id: `cap-${rec.prompt_count}-${sha.slice(0, 8)}`,
|
|
231
|
+
kind: 'capture',
|
|
232
|
+
prompt_n: rec.prompt_count,
|
|
233
|
+
issued_at: new Date(now).toISOString(),
|
|
234
|
+
prompt_sha: sha,
|
|
235
|
+
satisfied: false,
|
|
236
|
+
waived: null,
|
|
237
|
+
};
|
|
238
|
+
rec.obligations = [...rec.obligations, ob].slice(-MAX_OBLIGATIONS);
|
|
239
|
+
return ob;
|
|
240
|
+
}, now);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A capture tool call was seen: discharge every outstanding obligation of this session and remember
|
|
245
|
+
* that this session has captured (a road to code for the claim-before-build rule).
|
|
246
|
+
*/
|
|
247
|
+
export function satisfyAll({ root, sessionId, now = Date.now() }) {
|
|
248
|
+
return mutate(root, sessionId, (rec) => {
|
|
249
|
+
for (const o of rec.obligations) {
|
|
250
|
+
if (!o.satisfied && !o.waived) {
|
|
251
|
+
o.satisfied = true;
|
|
252
|
+
o.satisfied_at = new Date(now).toISOString();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
rec.captured_at = new Date(now).toISOString();
|
|
256
|
+
}, now) !== null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Owner-visible waiver of every outstanding obligation (the break-glass in data form). */
|
|
260
|
+
export function waiveAll({ root, sessionId, reason = 'waived', now = Date.now() }) {
|
|
261
|
+
const cur = readObligations(root, sessionId);
|
|
262
|
+
if (!isOutstanding(cur, sessionId, now)) return false;
|
|
263
|
+
return mutate(root, sessionId, (rec) => {
|
|
264
|
+
for (const o of rec.obligations) {
|
|
265
|
+
if (!o.satisfied && !o.waived) o.waived = { reason: String(reason), at: new Date(now).toISOString() };
|
|
266
|
+
}
|
|
267
|
+
}, now) !== null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The outstanding (unsatisfied, unwaived, unexpired) obligations of `sessionId` in `state`. */
|
|
271
|
+
export function outstanding(state, sessionId, now = Date.now()) {
|
|
272
|
+
if (!state || !sessionId || state.session_id !== sessionId || !Array.isArray(state.obligations)) return [];
|
|
273
|
+
return state.obligations.filter((o) => {
|
|
274
|
+
if (!o || o.satisfied || o.waived) return false;
|
|
275
|
+
const issued = Date.parse(o.issued_at ?? '');
|
|
276
|
+
return Number.isFinite(issued) && now - issued <= OBLIGATION_TTL_MS;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function isOutstanding(state, sessionId, now = Date.now()) {
|
|
281
|
+
return outstanding(state, sessionId, now).length > 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Mark the Stop re-drive as spent so a second Stop never loops. */
|
|
285
|
+
export function markStopBlocked({ root, sessionId, now = Date.now() }) {
|
|
286
|
+
return mutate(root, sessionId, (rec) => {
|
|
287
|
+
for (const o of outstanding({ session_id: sessionId, obligations: rec.obligations }, sessionId, now)) {
|
|
288
|
+
o.stop_blocked_at = new Date(now).toISOString();
|
|
289
|
+
}
|
|
290
|
+
}, now) !== null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Record a claim acquire / release observed in PostToolUse (the claim-before-build rule reads it). */
|
|
294
|
+
export function trackClaim({ root, sessionId, specId, release = false }) {
|
|
295
|
+
return mutate(root, sessionId, (rec) => {
|
|
296
|
+
const id = String(specId ?? '*');
|
|
297
|
+
const set = new Set(rec.claims);
|
|
298
|
+
if (release) {
|
|
299
|
+
if (id === '*') set.clear();
|
|
300
|
+
else { set.delete(id); set.delete('*'); }
|
|
301
|
+
} else {
|
|
302
|
+
set.add(id);
|
|
303
|
+
}
|
|
304
|
+
rec.claims = [...set];
|
|
305
|
+
}) !== null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── "does this session already have a road to code?" (claim-before-build) ────
|
|
309
|
+
|
|
310
|
+
function readJson(file) {
|
|
311
|
+
try {
|
|
312
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
313
|
+
} catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Does `.heyspex/active-claims.json` (or HEYSPEX_CLAIMS_PATH) carry a claim for this session? */
|
|
319
|
+
export function sessionHoldsClaimOnDisk(root, sessionId, env = process.env) {
|
|
320
|
+
const p = String(env.HEYSPEX_CLAIMS_PATH ?? '').trim() || path.join(root, '.heyspex', 'active-claims.json');
|
|
321
|
+
const claims = readJson(p)?.claims;
|
|
322
|
+
if (!claims || typeof claims !== 'object') return false;
|
|
323
|
+
return Object.values(claims).some((c) => c?.sessionId === sessionId);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Is a capture on deck for THIS session (fresh capture-gate-state.json naming one)? */
|
|
327
|
+
export function sessionHasCaptureOnDeck(root, sessionId, now = Date.now()) {
|
|
328
|
+
const s = readJson(path.join(root, '.heyspex', 'capture-gate-state.json'));
|
|
329
|
+
if (!s || !s.capture_id || s.session_id !== sessionId) return false;
|
|
330
|
+
const at = Date.parse(s.written_at ?? '');
|
|
331
|
+
return Number.isFinite(at) && now - at <= OBLIGATION_TTL_MS;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Has this session a road to code? Aligned with claim-before-code-gate: claim-before-build denies ONLY when this
|
|
336
|
+
* session's claim-liveness heartbeat has CONFIRMED there is no active claim (a fresh `noClaimUntil` in
|
|
337
|
+
* .heyspex/claim-liveness-cache.json) - in prod mode claims live in the cloud, so "no local claim record" proves
|
|
338
|
+
* nothing and a heartbeat that has not spoken fails OPEN. Positive signals (an explicit waiver, a claim recorded
|
|
339
|
+
* by the PostToolUse half, a capture write, a claim on disk, an on-deck capture) open the road regardless.
|
|
340
|
+
*/
|
|
341
|
+
export function hasRoadToCode({ root, sessionId, state, now = Date.now(), env = process.env }) {
|
|
342
|
+
if (state?.session_id === sessionId) {
|
|
343
|
+
if (state.build_waived === true || state.captured_at) return true;
|
|
344
|
+
if (Array.isArray(state.claims) && state.claims.length > 0) return true;
|
|
345
|
+
if (Array.isArray(state.obligations) && state.obligations.some((o) => o?.satisfied === true || o?.waived)) return true;
|
|
346
|
+
}
|
|
347
|
+
if (sessionHoldsClaimOnDisk(root, sessionId, env) || sessionHasCaptureOnDeck(root, sessionId, now)) return true;
|
|
348
|
+
return !hasFreshNoClaimSignal(sessionId, root);
|
|
349
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// hook-dispositions.mjs - ONE table: what each HeySpex hook becomes on each host.
|
|
2
|
+
//
|
|
3
|
+
// Source: [[HS-0GBS-CTF8-RES-copilot-plugin-parity-with-the-spexcode]] ("Enforcement parity matrix"
|
|
4
|
+
// and "Every hook, with event and matcher"), plus scripts/cursor-hooks/manifest.mjs for Cursor.
|
|
5
|
+
// Parity is not identity: this table is the published statement of where a host enforces a gate
|
|
6
|
+
// as a hard deny / re-drive, where it only advises, and where it cannot run the gate at all.
|
|
7
|
+
//
|
|
8
|
+
// Disposition vocabulary (per hook, per host):
|
|
9
|
+
// mapped the host has a trigger and the gate keeps its meaning (hard deny, hard re-drive,
|
|
10
|
+
// or an advisory/side effect that IS delivered).
|
|
11
|
+
// advisory-degraded the hook is registered (or registrable) but its effect is weaker than on Claude
|
|
12
|
+
// Code (output dropped, channel changed, dormant until armed). `note` says how.
|
|
13
|
+
// unportable the host has no trigger. `control` names what covers the gap instead.
|
|
14
|
+
// not-shipped platform-scoped hook (heyspex-app development machinery); never installed in a
|
|
15
|
+
// customer repo, so it has no customer-host disposition.
|
|
16
|
+
//
|
|
17
|
+
// This module is dependency-free on purpose (customer-kit.mjs and the tests import it; it must not
|
|
18
|
+
// import them back). The parity test (hook-dispositions.test.mjs) ties it to CLAUDE_HOOK_ROWS, the
|
|
19
|
+
// generated plugin hooks.json, the Cursor manifest and the cloud-agent manifest.
|
|
20
|
+
|
|
21
|
+
/** @typedef {'claude-code' | 'cursor' | 'copilot'} Host */
|
|
22
|
+
/** @typedef {'mapped' | 'advisory-degraded' | 'unportable' | 'not-shipped'} Disposition */
|
|
23
|
+
/** @typedef {{ disposition: Disposition, note?: string, control?: string }} Entry */
|
|
24
|
+
|
|
25
|
+
export const HOSTS = Object.freeze(['claude-code', 'cursor', 'copilot']);
|
|
26
|
+
export const DISPOSITIONS = Object.freeze(['mapped', 'advisory-degraded', 'unportable', 'not-shipped']);
|
|
27
|
+
|
|
28
|
+
/** @param {string} [note] */
|
|
29
|
+
const mapped = (note) => Object.freeze({ disposition: 'mapped', ...(note ? { note } : {}) });
|
|
30
|
+
/** @param {string} note */
|
|
31
|
+
const degraded = (note) => Object.freeze({ disposition: 'advisory-degraded', note });
|
|
32
|
+
/** @param {string} control */
|
|
33
|
+
const unportable = (control) => Object.freeze({ disposition: 'unportable', control });
|
|
34
|
+
const notShipped = Object.freeze({ disposition: 'not-shipped' });
|
|
35
|
+
|
|
36
|
+
const CC = mapped();
|
|
37
|
+
|
|
38
|
+
// Shared wordings.
|
|
39
|
+
const COPILOT_PROMPT_DROPPED =
|
|
40
|
+
'Copilot drops command-hook output on userPromptSubmitted (no prompt-time context channel); the text moves to sessionStart / .github/copilot-instructions.md.';
|
|
41
|
+
const CURSOR_PROMPT_USER_MESSAGE =
|
|
42
|
+
'Cursor beforeSubmitPrompt honors user_message only (no additional_context): the text surfaces in the transcript instead of as invisible context.';
|
|
43
|
+
const COPILOT_NO_PLAN_TOOL =
|
|
44
|
+
'MCP preflight refusals (heyspex_claim_spec / heyspex_submit_for_review / heyspex_save_research) + .github/copilot-instructions.md plan rule (state the plan in chat, claim the spec before editing) + HS-20SW directive-obligation-gate claim-before-build deny';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* hook name -> per-host entry. Keyed by the token after `_run.mjs` in CLAUDE_HOOK_ROWS.
|
|
48
|
+
* @type {Readonly<Record<string, Readonly<Record<Host, Entry>>>>}
|
|
49
|
+
*/
|
|
50
|
+
export const HOOK_DISPOSITIONS = Object.freeze({
|
|
51
|
+
// ── customer: session start / compaction ──────────────────────────────────
|
|
52
|
+
'wip-checkpoint': {
|
|
53
|
+
'claude-code': CC,
|
|
54
|
+
cursor: mapped('preCompact is relayed as SessionStart(compact).'),
|
|
55
|
+
copilot: mapped('sessionStart additionalContext is delivered; the preCompact leg writes WIP only (no output channel), re-injected at the next sessionStart.'),
|
|
56
|
+
},
|
|
57
|
+
'inject-doctrine': { 'claude-code': CC, cursor: mapped(), copilot: mapped('sessionStart additionalContext.') },
|
|
58
|
+
'check-auto-memory-budget': { 'claude-code': CC, cursor: mapped(), copilot: mapped('sessionStart additionalContext.') },
|
|
59
|
+
'check-claude-md-budget': { 'claude-code': CC, cursor: mapped(), copilot: mapped('sessionStart additionalContext.') },
|
|
60
|
+
'git-reflog-audit': { 'claude-code': CC, cursor: mapped(), copilot: mapped('sessionStart additionalContext and agentStop.') },
|
|
61
|
+
'vault-mirror-catchup': { 'claude-code': CC, cursor: mapped(), copilot: mapped('Detached side effect on sessionStart.') },
|
|
62
|
+
|
|
63
|
+
// ── customer: every user turn ─────────────────────────────────────────────
|
|
64
|
+
'preflight-reminder': {
|
|
65
|
+
'claude-code': CC,
|
|
66
|
+
cursor: degraded(CURSOR_PROMPT_USER_MESSAGE),
|
|
67
|
+
copilot: degraded(`${COPILOT_PROMPT_DROPPED} Real enforcement is the MCP preflight refusal.`),
|
|
68
|
+
},
|
|
69
|
+
'intent-classifier-trigger': {
|
|
70
|
+
'claude-code': CC,
|
|
71
|
+
cursor: degraded(CURSOR_PROMPT_USER_MESSAGE),
|
|
72
|
+
copilot: degraded(
|
|
73
|
+
`${COPILOT_PROMPT_DROPPED} The hook still runs and arms obligation state; the HS-20SW directive-obligation-gate turns that state into a hard deny.`,
|
|
74
|
+
),
|
|
75
|
+
},
|
|
76
|
+
'inject-communication-style': {
|
|
77
|
+
'claude-code': CC,
|
|
78
|
+
cursor: degraded(CURSOR_PROMPT_USER_MESSAGE),
|
|
79
|
+
copilot: degraded(COPILOT_PROMPT_DROPPED),
|
|
80
|
+
},
|
|
81
|
+
'inject-vault-context': {
|
|
82
|
+
'claude-code': CC,
|
|
83
|
+
cursor: degraded(CURSOR_PROMPT_USER_MESSAGE),
|
|
84
|
+
copilot: degraded(`${COPILOT_PROMPT_DROPPED} Per-prompt vault retrieval has no Copilot channel at all.`),
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// ── customer: before a tool call (hard gates) ─────────────────────────────
|
|
88
|
+
'git-identity-guard': { 'claude-code': CC, cursor: mapped('beforeShellExecution. Always on.'), copilot: mapped('preToolUse on bash/powershell. Always on.') },
|
|
89
|
+
'vault-write-tier-guard': { 'claude-code': CC, cursor: mapped(), copilot: mapped('preToolUse on create/edit/bash/powershell.') },
|
|
90
|
+
'settings-write-gate': { 'claude-code': CC, cursor: mapped(), copilot: mapped('preToolUse on create/edit/bash/powershell and the MCP settings tool.') },
|
|
91
|
+
'emit-claim-status': { 'claude-code': CC, cursor: mapped(), copilot: mapped('Telemetry on preToolUse/postToolUse/agentStop; the cloud agent needs a network allow rule for the console.') },
|
|
92
|
+
'enforce-vault-naming': { 'claude-code': CC, cursor: mapped(), copilot: mapped('preToolUse on create/bash/powershell.') },
|
|
93
|
+
'guard-auto-memory-writes': { 'claude-code': CC, cursor: mapped(), copilot: mapped('preToolUse on create/bash/powershell.') },
|
|
94
|
+
'directive-obligation-gate': {
|
|
95
|
+
'claude-code': CC,
|
|
96
|
+
cursor: mapped(),
|
|
97
|
+
copilot: mapped('HS-20SW: hard deny on task/create/edit/write-shaped shell while a capture obligation is unmet; the only hard capture-on-idea guarantee on Copilot.'),
|
|
98
|
+
},
|
|
99
|
+
'claim-before-code-gate': {
|
|
100
|
+
'claude-code': CC,
|
|
101
|
+
cursor: degraded('Armed only by plan-exit-ingest-gate, which has no Cursor trigger, so it stays dormant; covered by the HS-20SW claim-before-build rule.'),
|
|
102
|
+
copilot: degraded('Armed only by plan-exit-ingest-gate, which has no Copilot trigger (no ExitPlanMode tool), so it stays dormant; covered by the HS-20SW claim-before-build rule.'),
|
|
103
|
+
},
|
|
104
|
+
'capture-workflow-gate': {
|
|
105
|
+
'claude-code': CC,
|
|
106
|
+
cursor: mapped(),
|
|
107
|
+
copilot: mapped('preToolUse on the MCP heyspex_file_spec tool; MCP tool naming in hook payloads is unverified live (matched by suffix).'),
|
|
108
|
+
},
|
|
109
|
+
'diagnose-capture-reminder': {
|
|
110
|
+
'claude-code': CC,
|
|
111
|
+
cursor: mapped('preToolUse agent_message delivers the reminder.'),
|
|
112
|
+
copilot: degraded('Copilot preToolUse has no context channel for an inject, so this advisory is silent (lost).'),
|
|
113
|
+
},
|
|
114
|
+
'git-stash-gate': {
|
|
115
|
+
'claude-code': CC,
|
|
116
|
+
cursor: mapped(),
|
|
117
|
+
copilot: mapped('The Claude "ask" becomes a deny where no user is present (cloud agent, CLI autopilot).'),
|
|
118
|
+
},
|
|
119
|
+
'research-protocol-gate': { 'claude-code': CC, cursor: mapped('Always on.'), copilot: mapped('preToolUse on web_fetch/web_search and the MCP save_research tool. Always on; the MCP refusal duplicates it.') },
|
|
120
|
+
'research-method-guard': {
|
|
121
|
+
'claude-code': CC,
|
|
122
|
+
cursor: mapped('preToolUse with a Skill|Workflow tool filter. Always on.'),
|
|
123
|
+
copilot: unportable('MCP preflight refusal on heyspex_save_research (hard floor, hook-independent) + .github/copilot-instructions.md research rule; Copilot has no Skill or Workflow tool to hook.'),
|
|
124
|
+
},
|
|
125
|
+
'deep-research-cost-gate': {
|
|
126
|
+
'claude-code': CC,
|
|
127
|
+
cursor: mapped('preToolUse with a Workflow|Skill tool filter.'),
|
|
128
|
+
copilot: unportable('MCP preflight refusal on heyspex_save_research + .github/copilot-instructions.md cost-confirmation rule; Copilot has no Skill or Workflow tool to hook.'),
|
|
129
|
+
},
|
|
130
|
+
'plan-review-gate': {
|
|
131
|
+
'claude-code': CC,
|
|
132
|
+
cursor: unportable('.cursor/rules/plan-review-required.mdc (always-on rule)'),
|
|
133
|
+
copilot: unportable(COPILOT_NO_PLAN_TOOL),
|
|
134
|
+
},
|
|
135
|
+
'plan-exit-ingest-gate': {
|
|
136
|
+
'claude-code': CC,
|
|
137
|
+
cursor: unportable('.cursor/rules/plan-review-required.mdc + spec-creation-linkage.mdc'),
|
|
138
|
+
copilot: unportable(COPILOT_NO_PLAN_TOOL),
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
// ── customer: end of turn / session ───────────────────────────────────────
|
|
142
|
+
'naughty-list-gate': { 'claude-code': CC, cursor: mapped('stop followup_message re-drive.'), copilot: mapped('agentStop {decision:"block"} re-drive, capped at 8 consecutive blocks.') },
|
|
143
|
+
'id-display-format-gate': { 'claude-code': CC, cursor: mapped('stop followup_message re-drive.'), copilot: mapped('agentStop re-drive; reads the transcript (format is not a stable API).') },
|
|
144
|
+
'epic-milestone-phrasing-gate': { 'claude-code': CC, cursor: mapped('stop followup_message re-drive.'), copilot: mapped('agentStop re-drive; reads the transcript (format is not a stable API).') },
|
|
145
|
+
'enforce-session-sequence': { 'claude-code': CC, cursor: mapped('stop followup_message re-drive.'), copilot: mapped('agentStop re-drive, capped at 8 consecutive blocks.') },
|
|
146
|
+
'notify-status-flip': { 'claude-code': CC, cursor: mapped(), copilot: mapped('Side effect on postToolUse.') },
|
|
147
|
+
'close-terminal-on-ship': { 'claude-code': CC, cursor: mapped(), copilot: mapped('Side effect on postToolUse / sessionEnd.') },
|
|
148
|
+
'scripts/stamp-provenance-on-edit.mjs': { 'claude-code': CC, cursor: mapped(), copilot: mapped('Side effect on postToolUse.') },
|
|
149
|
+
'session-end': { 'claude-code': CC, cursor: mapped(), copilot: mapped('sessionEnd.') },
|
|
150
|
+
|
|
151
|
+
// ── platform: heyspex-app development machinery (never installed in a customer repo) ──
|
|
152
|
+
'inject-dev-server-awareness': { 'claude-code': CC, cursor: mapped(), copilot: notShipped },
|
|
153
|
+
'inject-coo-seat': { 'claude-code': CC, cursor: mapped(), copilot: notShipped },
|
|
154
|
+
'coo-guard-check': { 'claude-code': CC, cursor: mapped(), copilot: notShipped },
|
|
155
|
+
'coo-denylist-gate': { 'claude-code': CC, cursor: mapped(), copilot: notShipped },
|
|
156
|
+
'agent-spawn-delegation-gate': { 'claude-code': CC, cursor: mapped('subagentStart.'), copilot: notShipped },
|
|
157
|
+
'capture-execution-gate': { 'claude-code': CC, cursor: mapped('beforeShellExecution + subagentStart.'), copilot: notShipped },
|
|
158
|
+
'check-mcp-freshness-hook': { 'claude-code': CC, cursor: mapped(), copilot: notShipped },
|
|
159
|
+
'dev-server-task-reminder': { 'claude-code': CC, cursor: mapped(), copilot: notShipped },
|
|
160
|
+
'env-vault-guard': { 'claude-code': CC, cursor: mapped('Always on.'), copilot: notShipped },
|
|
161
|
+
'log-session-activity': { 'claude-code': CC, cursor: mapped(), copilot: notShipped },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The entry for one hook on one host, or null when the hook is not in the table.
|
|
166
|
+
* @param {string} hook
|
|
167
|
+
* @param {Host} host
|
|
168
|
+
* @returns {Entry | null}
|
|
169
|
+
*/
|
|
170
|
+
export function dispositionFor(hook, host) {
|
|
171
|
+
return HOOK_DISPOSITIONS[hook]?.[host] ?? null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Every hook name whose disposition on `host` is `kind`, in table order.
|
|
176
|
+
* @param {Host} host
|
|
177
|
+
* @param {Disposition} kind
|
|
178
|
+
* @returns {string[]}
|
|
179
|
+
*/
|
|
180
|
+
export function listByDisposition(host, kind) {
|
|
181
|
+
return Object.keys(HOOK_DISPOSITIONS).filter((h) => HOOK_DISPOSITIONS[h][host]?.disposition === kind);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Per-host counts, for reports and docs. */
|
|
185
|
+
export function dispositionCounts() {
|
|
186
|
+
const out = {};
|
|
187
|
+
for (const host of HOSTS) {
|
|
188
|
+
out[host] = Object.fromEntries(DISPOSITIONS.map((k) => [k, listByDisposition(host, k).length]));
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const escCell = (s) => String(s).replace(/\|/g, '\\|');
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The published list of customer gates that are weaker than, or absent from, a host, as a markdown table.
|
|
197
|
+
* Generated here so the README / docs can be checked against it (see hook-dispositions.test.mjs).
|
|
198
|
+
* @param {Host} host
|
|
199
|
+
* @param {(hook: string) => boolean} isCustomer
|
|
200
|
+
*/
|
|
201
|
+
export function renderGapTable(host, isCustomer) {
|
|
202
|
+
const rows = [];
|
|
203
|
+
for (const kind of /** @type {const} */ (['unportable', 'advisory-degraded'])) {
|
|
204
|
+
for (const hook of listByDisposition(host, kind).filter(isCustomer)) {
|
|
205
|
+
const e = HOOK_DISPOSITIONS[hook][host];
|
|
206
|
+
rows.push(`| \`${hook}\` | ${kind} | ${escCell(kind === 'unportable' ? e.control : e.note)} |`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return ['| Gate | Disposition | Compensating control / what is lost |', '| --- | --- | --- |', ...rows].join('\n');
|
|
210
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hook-vault-header.mjs — the repo-bound `x-heyspex-vault-id` header for hook
|
|
3
|
+
* and script HTTP calls to the console (owner principles P1/P2, 2026-10-16).
|
|
4
|
+
*
|
|
5
|
+
* A hook runs inside the repo an agent is opened in; the vault it talks about
|
|
6
|
+
* is that repo's bound vault (heyspex.yaml / satellite marker / manifest pin),
|
|
7
|
+
* never the account-wide active pointer. A bearer call that names no vault is
|
|
8
|
+
* refused by the console (409 vault-scope-required), which several hooks
|
|
9
|
+
* swallowed and then silently served stale disk data (diag HS-5M0G).
|
|
10
|
+
*
|
|
11
|
+
* `hookVaultHeaders()` returns `{ 'x-heyspex-vault-id': <id> }` for a bound,
|
|
12
|
+
* registered repo and `{}` when the repo is unbound (account-pointer-only,
|
|
13
|
+
* unregistered, or the resolver throws): the hook then sends no vault id, the
|
|
14
|
+
* console refuses, and the hook degrades exactly as it did before - it NEVER
|
|
15
|
+
* substitutes the pointer vault. Never throws.
|
|
16
|
+
*/
|
|
17
|
+
import { getVaultIdHeader } from '../../vendor/heyspex-mcp/console-fetch.mjs';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{ resolve?: () => Record<string, string> }} [opts] test seam
|
|
21
|
+
* @returns {Record<string, string>}
|
|
22
|
+
*/
|
|
23
|
+
export function hookVaultHeaders({ resolve = getVaultIdHeader } = {}) {
|
|
24
|
+
try {
|
|
25
|
+
const header = resolve();
|
|
26
|
+
return header && typeof header === 'object' ? { ...header } : {};
|
|
27
|
+
} catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|