heyspex 0.2.5 → 0.2.7
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 +349 -38
- package/bin/spexcode.mjs +31 -0
- package/dev-factory/lib/dispatch-guard.mjs +75 -0
- package/dev-factory/lib/local-firecrawl.mjs +294 -0
- package/dev-factory/lib/runner-path.mjs +51 -0
- package/dev-factory/lib/seat-context.mjs +20 -4
- package/dev-factory/model-catalog.json +222 -222
- package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
- package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
- package/dist/cli/hs-connect.mjs +40 -3
- package/dist/cli/hs-defer.mjs +3817 -3741
- 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 +228 -37
- package/dist/cli/hs-index-reconcile.mjs +231 -40
- package/dist/cli/hs-init.mjs +22 -3
- package/dist/cli/hs-install.mjs +22 -3
- package/dist/cli/hs-join.mjs +40 -3
- package/dist/cli/hs-next-launch.mjs +2777 -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 +12403 -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 +233 -42
- package/dist/cli/hs-vault-push.mjs +231 -40
- package/dist/cli/hs-vault.mjs +21 -7
- package/dist/cli/hs-workspace.mjs +19 -1
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
- 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 -30
- 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-res.md +83 -63
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
- 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 -24
- 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-res.md +78 -58
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
- 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 +9 -2
- package/docs/mcp-client-config.md +6 -0
- package/package.json +266 -257
- package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
- package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
- package/plugins/heyspex/README.md +6 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
- package/plugins/heyspex/hooks/hooks.json +418 -418
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
- package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
- package/scripts/claude-statusline/statusline-command.mjs +287 -27
- package/scripts/hs-companion.mjs +41 -3
- 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/companion-install.mjs +768 -0
- package/scripts/lib/customer-kit.mjs +53 -3
- package/scripts/lib/hs-companion-settings.mjs +26 -0
- package/scripts/lib/hs-source-push.mjs +386 -386
- package/scripts/record-grounding.mjs +38 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
- package/spexharness/src/adapters/copilot-models.mjs +179 -0
- package/spexharness/src/adapters/harness-detect.mjs +148 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
- package/spexharness/src/seat-context.mjs +10 -5
- package/spexharness/src/session-bootstrap.mjs +6 -3
- package/vendor/heyspex-mcp/cli.mjs +6 -1
- package/vendor/heyspex-mcp/config-seed.yaml +15 -15
- package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
- package/vendor/heyspex-mcp/http-transport.mjs +1 -1
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
- package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
- package/vendor/heyspex-mcp/research-tools.mjs +22 -2
- package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
- package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
- package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
- package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
- package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
- package/vendor/heyspex-mcp/vault-io.mjs +720 -720
- package/bin/postinstall.mjs +0 -88
|
@@ -86,6 +86,7 @@ function parseArgs(argv) {
|
|
|
86
86
|
else if (x === '--check') a.check = true;
|
|
87
87
|
else if (x === '--uninstall') a.uninstall = true;
|
|
88
88
|
else if (x === '--print') a.print = true;
|
|
89
|
+
else if (x === '--help' || x === '-h') a.help = true;
|
|
89
90
|
else if (x === '--json') a.json = true;
|
|
90
91
|
else if (x === '--target') {
|
|
91
92
|
a.target = argv[++i] ?? null;
|
|
@@ -238,10 +239,25 @@ function manualCommands(spec) {
|
|
|
238
239
|
];
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
/** This package's own version (package.json), or null when unreadable. */
|
|
243
|
+
function packageVersion() {
|
|
244
|
+
try {
|
|
245
|
+
const v = JSON.parse(fsDeps.readFile(path.join(PACKAGE_ROOT, 'package.json')) ?? 'null')?.version;
|
|
246
|
+
return typeof v === 'string' && v.trim() ? v.trim() : null;
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The install doc as printed: its `heyspex-<version>.tgz` placeholder names this package's real version. */
|
|
253
|
+
export function renderInstallDoc(doc, version) {
|
|
254
|
+
return version ? doc.replace(/heyspex-<version>\.tgz/g, `heyspex-${version}.tgz`) : doc;
|
|
255
|
+
}
|
|
256
|
+
|
|
241
257
|
function doPrint(out) {
|
|
242
258
|
out(`Next: run \`${nextCommand()}\` (plugin) or \`${nextCommand()} --hooks\` (cloud agent) in this repo, then /restart.`);
|
|
243
259
|
const doc = fsDeps.readFile(path.join(PACKAGE_ROOT, 'docs', 'copilot-plugin-install.md'));
|
|
244
|
-
if (doc !== null) { out(''); out(doc.replace(/\s+$/, '')); return 0; }
|
|
260
|
+
if (doc !== null) { out(''); out(renderInstallDoc(doc, packageVersion()).replace(/\s+$/, '')); return 0; }
|
|
245
261
|
const spec = resolvePluginSpec(PACKAGE_ROOT);
|
|
246
262
|
out('');
|
|
247
263
|
out('# Installing HeySpex in GitHub Copilot CLI (built-in fallback; docs/copilot-plugin-install.md not found)');
|
|
@@ -385,6 +401,10 @@ export function main(argv = process.argv.slice(2), io = {}) {
|
|
|
385
401
|
err('usage: hs setup copilot [--hooks] [--statusline [--force]] [--check [--json]] [--uninstall] [--print] [--target <dir>]');
|
|
386
402
|
return 2;
|
|
387
403
|
}
|
|
404
|
+
if (a.help) {
|
|
405
|
+
out('usage: hs setup copilot [--hooks] [--statusline [--force]] [--check [--json]] [--uninstall] [--print] [--target <dir>]');
|
|
406
|
+
return 0;
|
|
407
|
+
}
|
|
388
408
|
if (a.print) return doPrint(out);
|
|
389
409
|
const target = a.target ? path.resolve(a.target) : findRepoRoot(process.env.HS_SETUP_TARGET?.trim() || process.cwd());
|
|
390
410
|
if (a.check) return doCheck(a, target, out);
|
|
@@ -42,13 +42,14 @@ import {
|
|
|
42
42
|
hookScope,
|
|
43
43
|
invokedDirectly,
|
|
44
44
|
planCustomerLauncherWrite,
|
|
45
|
+
resolveSetupTarget,
|
|
45
46
|
samePlatformPath,
|
|
46
47
|
} from './lib/customer-kit.mjs';
|
|
47
48
|
|
|
48
49
|
const PLATFORM_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// HS_SETUP_TARGET, else this checkout -- or, from an installed package, the
|
|
51
|
+
// customer project found from cwd (never the package folder itself).
|
|
52
|
+
const REPO_ROOT = resolveSetupTarget({ platformRoot: PLATFORM_ROOT });
|
|
52
53
|
const SETTINGS_PATH = path.join(REPO_ROOT, '.cursor', 'hooks.json');
|
|
53
54
|
|
|
54
55
|
// CUSTOMER TARGET (`hs setup cursor` / `hs doctor --fix` with HS_SETUP_TARGET
|
|
@@ -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
|
+
}
|