heyspex 0.2.5 → 0.2.6
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 +1 -1
- package/bin/hs.mjs +281 -30
- package/dev-factory/lib/dispatch-guard.mjs +75 -0
- package/dev-factory/lib/runner-path.mjs +51 -0
- package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
- package/dist/cli/hs-connect.mjs +20 -1
- package/dist/cli/hs-defer.mjs +3802 -3744
- package/dist/cli/hs-events.mjs +1296 -0
- package/dist/cli/hs-export-events.mjs +1252 -0
- package/dist/cli/hs-handoff.mjs +2576 -0
- package/dist/cli/hs-index-rebuild.mjs +209 -36
- package/dist/cli/hs-index-reconcile.mjs +212 -39
- package/dist/cli/hs-init.mjs +20 -1
- package/dist/cli/hs-install.mjs +20 -1
- package/dist/cli/hs-join.mjs +20 -1
- package/dist/cli/hs-next-launch.mjs +2698 -0
- package/dist/cli/hs-pin.mjs +1336 -0
- package/dist/cli/hs-prereq.mjs +1380 -0
- package/dist/cli/hs-revalidate.mjs +3257 -0
- package/dist/cli/hs-spawn.mjs +12377 -0
- package/dist/cli/hs-test.mjs +1046 -0
- package/dist/cli/hs-trash.mjs +362 -0
- package/dist/cli/hs-trust.mjs +197 -0
- package/dist/cli/hs-vault-pull.mjs +214 -41
- package/dist/cli/hs-vault-push.mjs +212 -39
- package/dist/cli-templates/harness-commands/claude/hs-res.md +71 -63
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +66 -58
- package/docs/copilot-plugin-install.md +2 -2
- package/package.json +261 -257
- package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
- package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
- package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
- package/scripts/claude-statusline/statusline-command.mjs +287 -27
- package/scripts/hs-companion.mjs +8 -0
- package/scripts/install-claude-code-hooks.mjs +104 -3
- package/scripts/install-copilot-hooks.mjs +21 -1
- package/scripts/install-cursor-hooks.mjs +4 -3
- package/scripts/lib/capture-execution-guard.mjs +221 -0
- package/scripts/lib/customer-kit.mjs +48 -0
- package/scripts/lib/hs-companion-settings.mjs +25 -0
- package/scripts/record-grounding.mjs +38 -0
- package/vendor/heyspex-mcp/http-transport.mjs +1 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
- package/bin/postinstall.mjs +0 -88
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// scripts/lib/capture-execution-guard.mjs
|
|
2
|
+
//
|
|
3
|
+
// [[HS-KNJ8-BQZC-DIAG-an-agent-session-executed-past-capture]] — the deterministic
|
|
4
|
+
// "do not execute past an ungrounded capture" guard. Pure decision + tiny fs helpers.
|
|
5
|
+
// Consumed by:
|
|
6
|
+
// - scripts/claude-hooks/capture-execution-gate.mjs (PreToolUse, refuses)
|
|
7
|
+
// - scripts/lib/capture-verbatim-append.mjs (UserPromptSubmit, writes the state)
|
|
8
|
+
// - scripts/record-grounding.mjs (/hs-ground helper, writes the record)
|
|
9
|
+
//
|
|
10
|
+
// WHERE THE GATE LEARNS CAPTURE STATE (cheap + offline): ONE local file,
|
|
11
|
+
// `.heyspex/capture-gate-state.json` under the project root:
|
|
12
|
+
// { capture_id, session_id, expanded, written_at }
|
|
13
|
+
// It is written ONLY by the UserPromptSubmit stage after a LIVE resolve of the on-deck
|
|
14
|
+
// capture (console/Neon is the system of record; the offline capture-focus.yaml rots —
|
|
15
|
+
// HS-O4U6 — so the gate never trusts it) and by record-grounding.mjs. The PreToolUse gate
|
|
16
|
+
// reads nothing but that file and `.heyspex/research-grounding.json`: no network, no
|
|
17
|
+
// MCP, no capture-focus.mjs import (a Bash-time hook must stay fast).
|
|
18
|
+
//
|
|
19
|
+
// FAIL OPEN whenever state is unknowable: no hook session id, no state file, unreadable or
|
|
20
|
+
// malformed JSON, a state written by ANOTHER session, a state older than STATE_MAX_AGE_MS,
|
|
21
|
+
// a state with no capture_id (the prompt stage writes capture_id:null when it verified
|
|
22
|
+
// nothing is on deck for this session). Only a fresh, this-session, named, not-yet-cleared
|
|
23
|
+
// capture can ever refuse anything.
|
|
24
|
+
//
|
|
25
|
+
// Self-clearing: while a state exists the prompt stage re-verifies it on EVERY substantive
|
|
26
|
+
// prompt, so a promoted/closed capture stops gating at the next message; STATE_MAX_AGE_MS
|
|
27
|
+
// bounds the worst case when no prompt arrives.
|
|
28
|
+
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
GROUNDING_ARTIFACT_REL,
|
|
34
|
+
GROUNDING_FIELDS,
|
|
35
|
+
defaultReadGroundingArtifact,
|
|
36
|
+
} from '../../spexharness/src/gates/deep-research-cost-gate.mjs';
|
|
37
|
+
|
|
38
|
+
export const STATE_REL = path.join('.heyspex', 'capture-gate-state.json');
|
|
39
|
+
export const STATE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
40
|
+
/** Owner/env kill switch for the gate; the config twin is agent_behaviors.capture_execution_gate.enabled. */
|
|
41
|
+
export const GATE_OFF_ENV = 'HEYSPEX_CAPTURE_EXECUTION_GATE_OFF';
|
|
42
|
+
export const GATE_CONFIG_KEY = 'agent_behaviors.capture_execution_gate.enabled';
|
|
43
|
+
|
|
44
|
+
// ── state file ─────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export function readGateState(root) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(root, STATE_REL), 'utf8'));
|
|
49
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function writeGateState(root, state) {
|
|
56
|
+
try {
|
|
57
|
+
const full = path.join(root, STATE_REL);
|
|
58
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
59
|
+
fs.writeFileSync(
|
|
60
|
+
full,
|
|
61
|
+
JSON.stringify({ ...state, written_at: new Date().toISOString() }, null, 2) + '\n',
|
|
62
|
+
'utf8',
|
|
63
|
+
);
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false; // best effort — a failed write only means the gate fails open
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Does this capture body carry an agent expansion? (same two shapes capture-focus.mjs reads) */
|
|
71
|
+
export function bodyHasExpansion(body) {
|
|
72
|
+
const text = String(body ?? '').replace(/\r\n/g, '\n');
|
|
73
|
+
if (/##[ \t]*Expanded[ \t]+Concept[ \t]*\n\s*\S/i.test(text)) return true;
|
|
74
|
+
return /<details>\s*\n<summary>\s*(?:Details|Expanded idea \(agent\))[^<\n]*<\/summary>\s*\n+\s*\S/i.test(text);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── grounding record ───────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/** Is `artifact` a complete grounding record for `captureId`? Same predicate as deep-research-cost-gate's groundingBlock. */
|
|
80
|
+
export function isGroundedFor(artifact, captureId) {
|
|
81
|
+
return Boolean(
|
|
82
|
+
artifact &&
|
|
83
|
+
typeof artifact === 'object' &&
|
|
84
|
+
captureId &&
|
|
85
|
+
artifact.source_idea_id === captureId &&
|
|
86
|
+
GROUNDING_FIELDS.every((k) => typeof artifact[k] === 'string' && artifact[k].trim()),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The /hs-ground helper's write: `.heyspex/research-grounding.json` in the EXACT shape
|
|
92
|
+
* deep-research-cost-gate reads, keyed to the capture, plus marks the gate state expanded
|
|
93
|
+
* (recording the grounding IS the expansion step of the Ground=Expand flow).
|
|
94
|
+
*/
|
|
95
|
+
export function recordGrounding(root, { source_idea_id, already_built, already_decided, owner_reference, residual_delta }, { sessionId } = {}) {
|
|
96
|
+
const record = { source_idea_id, already_built, already_decided, owner_reference, residual_delta };
|
|
97
|
+
const missing = ['source_idea_id', ...GROUNDING_FIELDS].filter((k) => typeof record[k] !== 'string' || !record[k].trim());
|
|
98
|
+
if (missing.length) return { ok: false, error: `missing or empty: ${missing.join(', ')}` };
|
|
99
|
+
const full = path.join(root, GROUNDING_ARTIFACT_REL);
|
|
100
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
101
|
+
fs.writeFileSync(full, JSON.stringify({ ...record, recorded_at: new Date().toISOString() }, null, 2) + '\n', 'utf8');
|
|
102
|
+
const prior = readGateState(root);
|
|
103
|
+
if (prior?.capture_id === source_idea_id) {
|
|
104
|
+
writeGateState(root, { ...prior, session_id: sessionId ?? prior.session_id, expanded: true });
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, path: full };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── surface classification ─────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
const PAID_SCRIPTS = ['firecrawl-cli', 'spexcrawl-cli', 'synthesize-cli', 'audit-via-deepseek', 'deepseek-build'];
|
|
112
|
+
const SCRIPT_RE = new RegExp(`(^|[\\s/\\\\"'=])(?:${PAID_SCRIPTS.join('|')})(?:\\.mjs|\\.js|\\.ts)?(?=$|[\\s"';|&)])`, 'i');
|
|
113
|
+
// :3002 on loopback OR any dotted-quad host (the owner's Firecrawl runs on the LAN, e.g. 192.168.1.252:3002).
|
|
114
|
+
const FIRECRAWL_URL_RE = /(?:127\.0\.0\.1|localhost|\d{1,3}(?:\.\d{1,3}){3}):3002|api\.firecrawl\.dev/i;
|
|
115
|
+
// A segment whose FIRST word is one of these only reads/inspects; never refused.
|
|
116
|
+
const READ_ONLY_HEAD = new Set([
|
|
117
|
+
'cat', 'head', 'tail', 'less', 'more', 'grep', 'rg', 'egrep', 'fgrep', 'ls', 'dir', 'find', 'wc', 'type', 'echo',
|
|
118
|
+
'git', 'sed', 'awk', 'stat', 'file', 'diff', 'tree', 'pwd', 'cd', 'which', 'where', 'get-content', 'select-string',
|
|
119
|
+
'get-childitem', 'test-path', 'printf', 'sort', 'cut', 'jq',
|
|
120
|
+
]);
|
|
121
|
+
// Offline verification of the tools themselves — tests, self-tests, help.
|
|
122
|
+
const OFFLINE_FLAG_RE = /(?:^|\s)(?:--test|--self-test|--help|-h)(?=\s|$)|\.(?:test|smoke)\.m?js\b/i;
|
|
123
|
+
|
|
124
|
+
function splitSegments(command) {
|
|
125
|
+
return String(command).split(/&&|\|\||;|\||\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function headWord(segment) {
|
|
129
|
+
// skip leading env assignments (FOO=1 node ...) and a leading "cd x" is its own segment already
|
|
130
|
+
const words = segment.split(/\s+/);
|
|
131
|
+
let i = 0;
|
|
132
|
+
while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i])) i++;
|
|
133
|
+
return (words[i] ?? '').replace(/^["']|["']$/g, '').toLowerCase();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Which gated Bash surface (if any) does this command invoke? */
|
|
137
|
+
export function classifyBash(command) {
|
|
138
|
+
if (typeof command !== 'string' || !command.trim()) return null;
|
|
139
|
+
for (const seg of splitSegments(command)) {
|
|
140
|
+
if (READ_ONLY_HEAD.has(headWord(seg))) continue;
|
|
141
|
+
if (OFFLINE_FLAG_RE.test(seg)) continue;
|
|
142
|
+
if (FIRECRAWL_URL_RE.test(seg)) return { surface: 'firecrawl-api', detail: seg.slice(0, 120) };
|
|
143
|
+
if (SCRIPT_RE.test(seg)) {
|
|
144
|
+
const name = PAID_SCRIPTS.find((s) => seg.toLowerCase().includes(s));
|
|
145
|
+
return { surface: name ?? 'paid-research-script', detail: seg.slice(0, 120) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// An Agent/Task prompt that tells a sub-agent to CHANGE research routing / the model catalog / a protocol.
|
|
152
|
+
const ROUTING_TARGET_RE =
|
|
153
|
+
/routing[\s_-]*table|model[\s_-]*catalog|dev-factory-delegation-routing|research[\s_-]*(?:routing|seat)|routing[\s_-]*(?:pins?|rules?|seats?|defaults?)|seats\.json|specs[\\/]protocols[\\/]|protocol (?:file|edit)|edit(?:ing)? (?:the )?protocol|(?:default|always)[^.\n]{0,60}\b(?:research|synthesis)\b/i;
|
|
154
|
+
const EDIT_VERB_RE =
|
|
155
|
+
/\b(?:edit|modify|change|update|rewrite|patch|set|make sure|ensure|always use|add|fix|implement|wire|configure|pin|route)\b/i;
|
|
156
|
+
|
|
157
|
+
export function classifyAgent(input) {
|
|
158
|
+
const text = `${typeof input?.prompt === 'string' ? input.prompt : ''}\n${typeof input?.description === 'string' ? input.description : ''}`;
|
|
159
|
+
if (!text.trim()) return null;
|
|
160
|
+
if (ROUTING_TARGET_RE.test(text) && EDIT_VERB_RE.test(text)) {
|
|
161
|
+
return { surface: 'routing-edit-agent', detail: text.trim().slice(0, 120) };
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function classifySurface(toolName, toolInput) {
|
|
167
|
+
const name = String(toolName ?? '').trim();
|
|
168
|
+
if (name === 'Bash' || name === 'PowerShell') return classifyBash(toolInput?.command);
|
|
169
|
+
if (name === 'Agent' || name === 'Task') return classifyAgent(toolInput);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── decision ───────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {{ toolName?: string, toolInput?: object, sessionId?: string }} evt
|
|
177
|
+
* @param {{ root: string, env?: object, now?: number, readState?: Function, readGrounding?: Function, configEnabled?: boolean }} ctx
|
|
178
|
+
* @returns {{ decision: 'allow'|'deny', reason?: string }}
|
|
179
|
+
*/
|
|
180
|
+
export function decideCaptureExecution(evt, ctx) {
|
|
181
|
+
const env = ctx.env ?? process.env;
|
|
182
|
+
if (String(env[GATE_OFF_ENV] ?? '') === '1' || ctx.configEnabled === false) return { decision: 'allow' };
|
|
183
|
+
const hit = classifySurface(evt?.toolName, evt?.toolInput);
|
|
184
|
+
if (!hit) return { decision: 'allow' };
|
|
185
|
+
const sessionId = typeof evt?.sessionId === 'string' ? evt.sessionId.trim() : '';
|
|
186
|
+
if (!sessionId) return { decision: 'allow' }; // cannot prove it is THIS session's capture
|
|
187
|
+
|
|
188
|
+
const state = (ctx.readState ?? readGateState)(ctx.root);
|
|
189
|
+
const now = ctx.now ?? Date.now();
|
|
190
|
+
const writtenAt = Date.parse(state?.written_at ?? '');
|
|
191
|
+
if (
|
|
192
|
+
!state ||
|
|
193
|
+
!state.capture_id ||
|
|
194
|
+
state.session_id !== sessionId ||
|
|
195
|
+
!Number.isFinite(writtenAt) ||
|
|
196
|
+
now - writtenAt > STATE_MAX_AGE_MS
|
|
197
|
+
) {
|
|
198
|
+
return { decision: 'allow' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const grounded = isGroundedFor((ctx.readGrounding ?? defaultReadGroundingArtifact)(ctx.root), state.capture_id);
|
|
202
|
+
const expanded = state.expanded === true;
|
|
203
|
+
if (expanded && grounded) return { decision: 'allow' };
|
|
204
|
+
|
|
205
|
+
const why = [!expanded && 'it is still unexpanded', !grounded && `no grounding record for ${state.capture_id} exists`]
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
.join(' and ');
|
|
208
|
+
return {
|
|
209
|
+
decision: 'deny',
|
|
210
|
+
reason:
|
|
211
|
+
`Execution blocked (${hit.surface}): capture ${state.capture_id} is on deck for this session but ${why}. ` +
|
|
212
|
+
`Capture, ground and expand FIRST, then execute: (1) the owner's words must be in the capture verbatim; ` +
|
|
213
|
+
`(2) run /hs-ground on it (route-1 plans, route-2 code, route-3 the owner's own working reference); ` +
|
|
214
|
+
`(3) record the result with node scripts/record-grounding.mjs --capture ${state.capture_id} --built "..." --decided "..." --reference "..." --delta "..." ` +
|
|
215
|
+
`(writes ${GROUNDING_ARTIFACT_REL} and marks the capture expanded); (4) retry this exact call. ` +
|
|
216
|
+
`If the owner said to discard the capture or to just do it, promote/close the capture first. ` +
|
|
217
|
+
`Read-only tools, capture writes, /hs-ground, git and test runs are never gated. ` +
|
|
218
|
+
`Only the owner can lift this gate wholesale: set ${GATE_OFF_ENV}=1 in their own environment ` +
|
|
219
|
+
`(or agent_behaviors.capture_execution_gate.enabled: false).`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -1326,6 +1326,54 @@ export function auditCustomerKit({ targetRoot, platformRoot, deps = fsDeps }) {
|
|
|
1326
1326
|
return report;
|
|
1327
1327
|
}
|
|
1328
1328
|
|
|
1329
|
+
// ---------------------------------------------------------------------------
|
|
1330
|
+
// Which repo `hs setup <agent>` writes into
|
|
1331
|
+
// ---------------------------------------------------------------------------
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* The project root a setup verb acts on: the nearest directory at/above
|
|
1335
|
+
* `start` holding `.git` or `heyspex.yaml`, else `start` itself. Same walk
|
|
1336
|
+
* `hs setup copilot` uses.
|
|
1337
|
+
* @param {string} start
|
|
1338
|
+
*/
|
|
1339
|
+
export function findProjectRoot(start) {
|
|
1340
|
+
let dir = path.resolve(start);
|
|
1341
|
+
for (;;) {
|
|
1342
|
+
if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, 'heyspex.yaml'))) return dir;
|
|
1343
|
+
const up = path.dirname(dir);
|
|
1344
|
+
if (up === dir) return path.resolve(start);
|
|
1345
|
+
dir = up;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* True when `platformRoot` is an installed `heyspex` package (under a
|
|
1351
|
+
* node_modules/ folder, or missing the source-checkout-only
|
|
1352
|
+
* scripts/heyspex-doctor.mjs) rather than a HeySpex source checkout.
|
|
1353
|
+
* @param {string} platformRoot
|
|
1354
|
+
* @param {(p: string) => boolean} [exists]
|
|
1355
|
+
*/
|
|
1356
|
+
export function isInstalledPackageRoot(platformRoot, exists = fs.existsSync) {
|
|
1357
|
+
if (toPosixPath(path.resolve(platformRoot)).includes('/node_modules/')) return true;
|
|
1358
|
+
return !exists(path.join(platformRoot, 'scripts', 'heyspex-doctor.mjs'));
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* The repo a hook installer (`install-claude-code-hooks.mjs`,
|
|
1363
|
+
* `install-cursor-hooks.mjs`) writes into. An explicit HS_SETUP_TARGET (what
|
|
1364
|
+
* `hs setup` / `hs doctor --fix` pass) wins; with none, a source checkout
|
|
1365
|
+
* targets itself (the dev `npm install` / `npm run hooks:*` path) but an
|
|
1366
|
+
* INSTALLED package targets the customer's project found from cwd -- never
|
|
1367
|
+
* its own package folder under node_modules/.
|
|
1368
|
+
* @param {{ platformRoot: string, env?: Record<string, string | undefined>, cwd?: string, exists?: (p: string) => boolean }} opts
|
|
1369
|
+
*/
|
|
1370
|
+
export function resolveSetupTarget({ platformRoot, env = process.env, cwd = process.cwd(), exists = fs.existsSync }) {
|
|
1371
|
+
const explicit = env.HS_SETUP_TARGET?.trim();
|
|
1372
|
+
if (explicit) return path.resolve(explicit);
|
|
1373
|
+
if (isInstalledPackageRoot(platformRoot, exists)) return findProjectRoot(cwd);
|
|
1374
|
+
return path.resolve(platformRoot);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1329
1377
|
// ---------------------------------------------------------------------------
|
|
1330
1378
|
// CLI: regenerate plugins/heyspex/hooks/hooks.json
|
|
1331
1379
|
// ---------------------------------------------------------------------------
|
|
@@ -105,6 +105,31 @@ export const COMPANION_ALIASES = {
|
|
|
105
105
|
|
|
106
106
|
export const HOST_COMMANDS = ["start", "stop", "status", "build"];
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* What `hs companion start|stop|status|build` prints when run from an
|
|
110
|
+
* installed `heyspex` npm package: the tray app's source (companion/) is not
|
|
111
|
+
* in the tarball, so there is no start.ps1 to run. Says plainly that the
|
|
112
|
+
* Companion is not distributed through npm yet (a Windows installer is
|
|
113
|
+
* planned, research HS-AUY9) and what degrades without it, with the
|
|
114
|
+
* workaround for each. The dev (repo checkout) path never reaches this.
|
|
115
|
+
* @param {string} sub the host subcommand the caller asked for
|
|
116
|
+
*/
|
|
117
|
+
export function renderCompanionNotDistributed(sub) {
|
|
118
|
+
return [
|
|
119
|
+
`hs companion ${sub}: the HeySpex Companion (desktop tray app) is not distributed through the`,
|
|
120
|
+
"heyspex npm package yet; a Windows installer is planned (research HS-AUY9).",
|
|
121
|
+
"",
|
|
122
|
+
"Everything else in this package works without it. What you lose until then:",
|
|
123
|
+
" - Automatic vault mirror (cloud <-> disk sync): run `npx hs pull --apply` to bring",
|
|
124
|
+
" cloud-written specs down (and `npx hs push --apply` to send local edits up).",
|
|
125
|
+
" - SpexVoice (voice capture and spoken commands): not available.",
|
|
126
|
+
" - SpexCrawl research gather (/hs-res): its gather jobs need the Companion, so they",
|
|
127
|
+
" report SPEXCRAWL_UNAVAILABLE.",
|
|
128
|
+
"",
|
|
129
|
+
"`hs companion settings ...` (console-backed) still works.",
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
108
133
|
const TRUE_WORDS = new Set(["true", "on", "yes", "1", "enabled"]);
|
|
109
134
|
const FALSE_WORDS = new Set(["false", "off", "no", "0", "disabled"]);
|
|
110
135
|
const NULL_WORDS = new Set(["null", "none"]);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// /hs-ground helper: record the grounding result so the execution gate and the deep-research
|
|
3
|
+
// gate can see it. [[HS-KNJ8-BQZC-DIAG-an-agent-session-executed-past-capture]]
|
|
4
|
+
//
|
|
5
|
+
// node scripts/record-grounding.mjs --capture HS-CAP-XXXX --built "..." --decided "..." \
|
|
6
|
+
// --reference "..." --delta "..." [--root <project root>] [--session <id>]
|
|
7
|
+
//
|
|
8
|
+
// Writes .heyspex/research-grounding.json as
|
|
9
|
+
// { source_idea_id, already_built, already_decided, owner_reference, residual_delta, recorded_at }
|
|
10
|
+
// (the shape deep-research-cost-gate.mjs already reads) and marks the capture expanded in
|
|
11
|
+
// .heyspex/capture-gate-state.json. Never calls a model or the network.
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { recordGrounding } from './lib/capture-execution-guard.mjs';
|
|
15
|
+
|
|
16
|
+
export function parseArgs(argv) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const a = argv[i];
|
|
20
|
+
if (a.startsWith('--')) out[a.slice(2)] = argv[++i];
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
26
|
+
const a = parseArgs(process.argv.slice(2));
|
|
27
|
+
const root = a.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
28
|
+
const res = recordGrounding(
|
|
29
|
+
root,
|
|
30
|
+
{ source_idea_id: a.capture, already_built: a.built, already_decided: a.decided, owner_reference: a.reference, residual_delta: a.delta },
|
|
31
|
+
{ sessionId: a.session || process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID },
|
|
32
|
+
);
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
process.stderr.write(`record-grounding: ${res.error}\nusage: --capture <id> --built --decided --reference --delta (all required)\n`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
process.stdout.write(`grounding recorded: ${res.path}\n`);
|
|
38
|
+
}
|
|
@@ -111,7 +111,7 @@ export function toolNotAvailableRemotelyError(id, name) {
|
|
|
111
111
|
* @property {(name: string) => boolean} [isKnownTool]
|
|
112
112
|
* Whether a name exists in the FULL catalog — distinguishes "needs the local
|
|
113
113
|
* server" from "no such tool" (default: same as `isToolAllowed`).
|
|
114
|
-
* @property {{ name: string, version: string }} serverInfo
|
|
114
|
+
* @property {{ name: string, version: string, title?: string, icons?: ReadonlyArray<{ src: string, mimeType?: string, sizes?: ReadonlyArray<string> }> }} serverInfo
|
|
115
115
|
* @property {string} [instructions]
|
|
116
116
|
* @property {string} vaultRoot
|
|
117
117
|
* Passed through to `dispatch`. For the remote endpoint this is a path that
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
|
-
import { join } from 'node:path';
|
|
8
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
9
9
|
|
|
10
10
|
import { resetActiveConfigCache } from './vault-resolve.mjs';
|
|
11
11
|
|
|
@@ -134,35 +134,49 @@ export function installScopedConsoleEnv({
|
|
|
134
134
|
* part of `scripts/`) also imports `vendor/heyspex-mcp/vault-resolve.mjs` by a
|
|
135
135
|
* relative path OUTSIDE `scripts/` — that file must be mirrored too, or the
|
|
136
136
|
* copied validator dangles with MODULE_NOT_FOUND the moment it computes its
|
|
137
|
-
* own default root
|
|
138
|
-
*
|
|
137
|
+
* own default root, and that file in turn imports `vault-binding.mjs` →
|
|
138
|
+
* `usable-dir-env.mjs` (a chain, not a single copy).
|
|
139
139
|
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
140
|
+
* The mirror is DERIVED, not hand-listed: a hardcoded list rotted three times
|
|
141
|
+
* as modules grew imports (last miss: `usable-dir-env.mjs`, HS-PNBC). We walk
|
|
142
|
+
* relative import specifiers transitively from the validator and copy every
|
|
143
|
+
* reached file that lives outside `scripts/` (which is copied wholesale).
|
|
142
144
|
*
|
|
143
145
|
* @param {string} repoRoot Absolute path to the real repo root.
|
|
144
146
|
* @param {string} root Absolute path to the temp vault being assembled.
|
|
145
147
|
*/
|
|
146
148
|
export function mirrorValidatorDeps(repoRoot, root) {
|
|
147
149
|
cpSync(join(repoRoot, 'scripts'), join(root, 'scripts'), { recursive: true });
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
cpSync(
|
|
150
|
+
for (const abs of localImportClosure(join(repoRoot, 'scripts', 'validate-vault-ids.mjs'))) {
|
|
151
|
+
const rel = relative(repoRoot, abs);
|
|
152
|
+
if (rel.startsWith('..') || isAbsolute(rel)) continue;
|
|
153
|
+
if (rel.split(sep)[0] === 'scripts') continue; // already copied wholesale
|
|
154
|
+
mkdirSync(dirname(join(root, rel)), { recursive: true });
|
|
155
|
+
cpSync(abs, join(root, rel));
|
|
154
156
|
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Relative specifiers in `from '…'`, side-effect `import '…'`, and literal `import('…')`.
|
|
160
|
+
const LOCAL_IMPORT_RE = /(?:\bfrom\s*|\bimport\s*\(?\s*)(['"])(\.{1,2}\/[^'"]+)\1/g;
|
|
155
161
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Every local file transitively reachable from `entry` via relative import
|
|
164
|
+
* specifiers (bare/builtin specifiers ignored). A missing target is skipped so
|
|
165
|
+
* the copied script fails loudly with MODULE_NOT_FOUND instead of here.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} entry Absolute path to the entry module.
|
|
168
|
+
* @returns {Set<string>} Absolute paths, entry included.
|
|
169
|
+
*/
|
|
170
|
+
export function localImportClosure(entry) {
|
|
171
|
+
const seen = new Set();
|
|
172
|
+
const stack = [entry];
|
|
173
|
+
while (stack.length) {
|
|
174
|
+
const file = stack.pop();
|
|
175
|
+
if (seen.has(file) || !existsSync(file)) continue;
|
|
176
|
+
seen.add(file);
|
|
177
|
+
for (const m of readFileSync(file, 'utf8').matchAll(LOCAL_IMPORT_RE)) {
|
|
178
|
+
stack.push(resolve(dirname(file), m[2]));
|
|
179
|
+
}
|
|
167
180
|
}
|
|
181
|
+
return seen;
|
|
168
182
|
}
|
package/bin/postinstall.mjs
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Root postinstall guard. The dev-machine setup steps (PM control-center deps,
|
|
3
|
-
// Claude Code hooks, CommandCode update) live under scripts/, which
|
|
4
|
-
// .vercelignore excludes from the Vercel upload — so on Vercel (or any CI, or
|
|
5
|
-
// any environment where scripts/ is absent) this must exit 0 instead of
|
|
6
|
-
// crashing `npm install` with MODULE_NOT_FOUND. Lives in bin/ (uploaded to
|
|
7
|
-
// Vercel) precisely so the guard itself always resolves.
|
|
8
|
-
import { existsSync } from "node:fs";
|
|
9
|
-
import { spawnSync } from "node:child_process";
|
|
10
|
-
import { dirname, join } from "node:path";
|
|
11
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
|
-
|
|
13
|
-
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
14
|
-
|
|
15
|
-
// Installed as somebody else's dependency (HS-ZJSP): the published `heyspex`
|
|
16
|
-
// package ships this file because npm runs the `postinstall` script for every
|
|
17
|
-
// consumer, but NONE of the dev-machine setup below belongs in a customer's
|
|
18
|
-
// install — and printing a "skipping" line at them is noise, not information.
|
|
19
|
-
// A package installed from the registry always lands under a node_modules/
|
|
20
|
-
// directory; a clone of this repo never does. Exit silently in the former case.
|
|
21
|
-
if (repoRoot.replace(/\\/g, "/").includes("/node_modules/")) {
|
|
22
|
-
process.exit(0);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const steps = [
|
|
26
|
-
["scripts/ensure-pm-cc-deps.mjs"],
|
|
27
|
-
["scripts/install-claude-code-hooks.mjs"],
|
|
28
|
-
// Cursor's two install surfaces. `.cursor/mcp.json` is gitignored (it is
|
|
29
|
-
// machine-local), so without this step a fresh clone opens in Cursor with no
|
|
30
|
-
// heyspex MCP server at all; `.cursor/hooks.json` is tracked but merges
|
|
31
|
-
// idempotently, and the installer also retires superseded registrations.
|
|
32
|
-
["scripts/install-cursor-mcp.mjs"],
|
|
33
|
-
["scripts/install-cursor-hooks.mjs"],
|
|
34
|
-
["scripts/install-commandcode.mjs", "--update"],
|
|
35
|
-
["scripts/sync-skills.mjs"],
|
|
36
|
-
// Git hooks (.git/hooks/ is never cloned) — installs pre-commit + pre-push so a
|
|
37
|
-
// single `npm install` on a fresh clone is enough, with no second `npm run setup`.
|
|
38
|
-
// No --force: an idempotent re-run no-ops on the HeySpex hook and refuses to
|
|
39
|
-
// clobber a foreign pre-commit (exit 1 surfaces that rather than silently replacing).
|
|
40
|
-
["scripts/install-git-hooks.mjs"],
|
|
41
|
-
];
|
|
42
|
-
|
|
43
|
-
if (process.env.VERCEL || process.env.CI) {
|
|
44
|
-
console.log("[postinstall] VERCEL/CI detected — skipping dev-machine setup.");
|
|
45
|
-
process.exit(0);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
for (const [script, ...args] of steps) {
|
|
49
|
-
const abs = join(repoRoot, script);
|
|
50
|
-
if (!existsSync(abs)) {
|
|
51
|
-
console.log(`[postinstall] ${script} not present — skipping dev-machine setup.`);
|
|
52
|
-
process.exit(0);
|
|
53
|
-
}
|
|
54
|
-
const { status } = spawnSync(process.execPath, [abs, ...args], {
|
|
55
|
-
cwd: repoRoot,
|
|
56
|
-
stdio: "inherit",
|
|
57
|
-
});
|
|
58
|
-
if (status !== 0) process.exit(status ?? 1);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Advisory only, never fatal, and deliberately NOT `npm link` itself: a
|
|
62
|
-
// postinstall that mutates the machine's global npm prefix would repoint `hs`
|
|
63
|
-
// away from whatever clone owned it, silently, on every install. `npm install`
|
|
64
|
-
// completing clean while `hs` resolves nowhere is exactly the fresh-machine
|
|
65
|
-
// failure in [[HS-BFXF-WBJU-DIAG-hs-never-resolves-on-a-fresh-machine]] — so
|
|
66
|
-
// say so here, where a new operator is already looking, and let `npm run
|
|
67
|
-
// doctor` carry the full check. Imported dynamically so the published package
|
|
68
|
-
// (which ships this file but not scripts/) can never fail to resolve it.
|
|
69
|
-
const hsLinkLib = join(repoRoot, "scripts/lib/hs-link.mjs");
|
|
70
|
-
if (existsSync(hsLinkLib)) {
|
|
71
|
-
try {
|
|
72
|
-
const { describeHsLink } = await import(pathToFileURL(hsLinkLib).href);
|
|
73
|
-
const link = describeHsLink(repoRoot);
|
|
74
|
-
if (link.status !== "linked") {
|
|
75
|
-
const what = {
|
|
76
|
-
missing: "does not resolve on PATH",
|
|
77
|
-
foreign: `resolves to an unrelated tool (${link.target})`,
|
|
78
|
-
"other-checkout": `resolves to a DIFFERENT checkout (${link.target})`,
|
|
79
|
-
unreadable: `resolves to ${link.shim}, whose target could not be read`,
|
|
80
|
-
}[link.status];
|
|
81
|
-
console.log(`[postinstall] heads up: \`hs\` ${what}.`);
|
|
82
|
-
console.log("[postinstall] Run `npm link` once from this repo root to put hs/heyspex/heyspex-mcp on PATH.");
|
|
83
|
-
console.log("[postinstall] Until then use `node bin/hs.mjs …` — never `npx hs` (unrelated package). Details: `npm run doctor`.");
|
|
84
|
-
}
|
|
85
|
-
} catch {
|
|
86
|
-
// An advisory that crashes the install would be worse than no advisory.
|
|
87
|
-
}
|
|
88
|
-
}
|