heyspex 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -0
- package/bin/hs.mjs +181 -15
- package/dist/cli/hs-check.mjs +88 -46
- package/dist/cli/hs-claim.mjs +84 -39
- package/dist/cli/hs-connect.mjs +583 -129
- package/dist/cli/hs-defer.mjs +251 -30
- package/dist/cli/hs-dev.mjs +20 -8
- package/dist/cli/hs-env.mjs +62 -34
- package/dist/cli/hs-index-rebuild.mjs +288 -83
- package/dist/cli/hs-index-reconcile.mjs +397 -111
- package/dist/cli/hs-init.mjs +2143 -91
- package/dist/cli/hs-install.mjs +628 -106
- package/dist/cli/hs-join.mjs +686 -236
- package/dist/cli/hs-vault-pull.mjs +3189 -179
- package/dist/cli/hs-vault-push.mjs +6433 -0
- package/dist/cli/hs-vault.mjs +396 -178
- package/dist/cli/hs-workspace.mjs +289 -151
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
- package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
- package/docs/copilot-plugin-install.md +177 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/package.json +21 -2
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
- package/plugins/heyspex/README.md +312 -0
- package/plugins/heyspex/bin/run-hook.mjs +20 -2
- package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
- package/plugins/heyspex/hooks/hooks.json +94 -37
- package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
- package/scripts/claude-hooks/_run.mjs +316 -23
- package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
- package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
- package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
- package/scripts/claude-hooks/session-end.mjs +2 -0
- package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
- package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
- package/scripts/cursor-hooks/manifest.mjs +46 -0
- package/scripts/heyspex-mode.mjs +20 -0
- package/scripts/hs-mode.mjs +1 -1
- package/scripts/hs-view.mjs +180 -17
- package/scripts/install-claude-code-hooks.mjs +3 -1
- package/scripts/install-copilot-hooks.mjs +288 -0
- package/scripts/lib/console-url-resolver.mjs +3 -2
- package/scripts/lib/customer-kit.mjs +327 -10
- package/scripts/lib/dev-mode.mjs +7 -6
- package/scripts/lib/directive-obligations.mjs +349 -0
- package/scripts/lib/hook-dispositions.mjs +210 -0
- package/scripts/lib/hook-vault-header.mjs +30 -0
- package/scripts/lib/hs-source-branch.mjs +196 -0
- package/scripts/lib/hs-source-log.mjs +2 -1
- package/scripts/lib/hs-source-preflight.mjs +18 -4
- package/scripts/lib/hs-source-push.mjs +386 -356
- package/scripts/lib/window-placement.mjs +109 -0
- package/spexharness/src/adapters/claude-code.mjs +11 -3
- package/spexharness/src/adapters/copilot.mjs +236 -0
- package/spexharness/src/adapters/detect-harness.mjs +34 -0
- package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
- package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
- package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
- package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
- package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
- package/spexharness/src/intent-classifier.mjs +4 -2
- package/spexharness/src/intent-directive.mjs +626 -541
- package/spexharness/src/rule-table.mjs +31 -1
- package/vendor/heyspex-mcp/account-switch.mjs +6 -5
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
- package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
- package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
- package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
- package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
- package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
- package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
- package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
- package/vendor/heyspex-mcp/cli.mjs +7 -1
- package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
- package/vendor/heyspex-mcp/console-transport.mjs +7 -1
- package/vendor/heyspex-mcp/credentials.mjs +9 -8
- package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
- package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
- package/vendor/heyspex-mcp/http-transport.mjs +2 -0
- package/vendor/heyspex-mcp/list-tools.mjs +1 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
- package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
- package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
- package/vendor/heyspex-mcp/plan-push.mjs +3 -2
- package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
- package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
- package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
- package/vendor/heyspex-mcp/promote-research.mjs +6 -0
- package/vendor/heyspex-mcp/read-path.mjs +162 -6
- package/vendor/heyspex-mcp/request-context.mjs +25 -1
- package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
- package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
- package/vendor/heyspex-mcp/session-owner.mjs +3 -2
- package/vendor/heyspex-mcp/session-tools.mjs +10 -0
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
- package/vendor/heyspex-mcp/split-research.mjs +3 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
- package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
- package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
- package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
- package/vendor/heyspex-mcp/vault-io.mjs +720 -724
- package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
- package/vendor/heyspex-mcp/whoami.mjs +2 -1
- package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Window placement for the two-pane capture layout
|
|
3
|
+
* ([[HS-I2YN-ST07-EPIC-two-pane-layout-pipeline-view-on-the]]).
|
|
4
|
+
*
|
|
5
|
+
* The owner wants the `hs view` window (pipeline + capture doc) and the
|
|
6
|
+
* PowerShell SpexCode agent window both visible at once. Neither launcher used
|
|
7
|
+
* to place its window, so they stacked wherever the OS put them. This module is
|
|
8
|
+
* the ONE place that decides the geometry; both launchers call it:
|
|
9
|
+
*
|
|
10
|
+
* - `scripts/hs-view.mjs` -> side "right" (this window)
|
|
11
|
+
* - the agent launch (HS-TXFW) -> `complementarySide("right")` = "left"
|
|
12
|
+
*
|
|
13
|
+
* Pure geometry (`computeWindowPlacement`) is separated from the one
|
|
14
|
+
* side-effecting probe (`getPrimaryWorkArea`) so the maths is unit-testable.
|
|
15
|
+
*
|
|
16
|
+
* Safety: placement is always BEST EFFORT. Any failure (non-Windows, probe
|
|
17
|
+
* timeout, nonsense numbers) yields `null` and the caller simply omits the
|
|
18
|
+
* position flags, i.e. today's behavior. Multi-monitor: only the primary
|
|
19
|
+
* monitor's work area is used by default (taskbar excluded), so a window can
|
|
20
|
+
* never be pushed onto a monitor that was unplugged or a negative coordinate
|
|
21
|
+
* the OS would clamp unpredictably.
|
|
22
|
+
*/
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
|
|
25
|
+
/** Sides a window can take. `none` disables placement entirely. */
|
|
26
|
+
export const PLACE_SIDES = new Set(['left', 'right', 'none']);
|
|
27
|
+
|
|
28
|
+
/** The other half of the screen - what the sibling window should take. */
|
|
29
|
+
export function complementarySide(side) {
|
|
30
|
+
if (side === 'left') return 'right';
|
|
31
|
+
if (side === 'right') return 'left';
|
|
32
|
+
return 'none';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Parse `left|right|none` (case-insensitive); anything else -> null. */
|
|
36
|
+
export function parsePlaceSide(value) {
|
|
37
|
+
const v = String(value ?? '').trim().toLowerCase();
|
|
38
|
+
return PLACE_SIDES.has(v) ? v : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validWorkArea(w) {
|
|
42
|
+
return (
|
|
43
|
+
w &&
|
|
44
|
+
[w.x, w.y, w.width, w.height].every((n) => Number.isFinite(n)) &&
|
|
45
|
+
w.width >= 400 &&
|
|
46
|
+
w.height >= 300
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Geometry for one window on one half of a work area.
|
|
52
|
+
*
|
|
53
|
+
* Width is the requested width capped at half the work area, so the two
|
|
54
|
+
* windows can never overlap; height fills the work area. Returns
|
|
55
|
+
* `{ x, y, width, height }` or null when placement should be skipped.
|
|
56
|
+
*/
|
|
57
|
+
export function computeWindowPlacement({ workArea, side, width, height }) {
|
|
58
|
+
if (side !== 'left' && side !== 'right') return null;
|
|
59
|
+
if (!validWorkArea(workArea)) return null;
|
|
60
|
+
const half = Math.floor(workArea.width / 2);
|
|
61
|
+
const w = Math.max(1, Math.min(Number.isFinite(width) && width > 0 ? width : half, half));
|
|
62
|
+
const h = Math.max(
|
|
63
|
+
1,
|
|
64
|
+
Math.min(Number.isFinite(height) && height > 0 ? height : workArea.height, workArea.height),
|
|
65
|
+
);
|
|
66
|
+
// Full-height halves unless the caller asked for something shorter.
|
|
67
|
+
const x = side === 'left' ? workArea.x : workArea.x + workArea.width - w;
|
|
68
|
+
return { x, y: workArea.y, width: w, height: h };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The Chromium app-mode flags for a placement. */
|
|
72
|
+
export function placementFlags(p) {
|
|
73
|
+
return p ? [`--window-position=${p.x},${p.y}`, `--window-size=${p.width},${p.height}`] : [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Primary monitor work area (taskbar excluded) in the units Chromium's
|
|
78
|
+
* `--window-position` uses. Windows only; null elsewhere or on any failure.
|
|
79
|
+
* Override for tests / unusual setups with `HEYSPEX_VIEW_WORKAREA=x,y,w,h`.
|
|
80
|
+
*/
|
|
81
|
+
export function getPrimaryWorkArea(env = process.env, platform = process.platform) {
|
|
82
|
+
const override = String(env.HEYSPEX_VIEW_WORKAREA ?? '').trim();
|
|
83
|
+
if (override) {
|
|
84
|
+
const [x, y, width, height] = override.split(',').map((s) => Number(s.trim()));
|
|
85
|
+
const w = { x, y, width, height };
|
|
86
|
+
return validWorkArea(w) ? w : null;
|
|
87
|
+
}
|
|
88
|
+
if (platform !== 'win32') return null;
|
|
89
|
+
try {
|
|
90
|
+
const r = spawnSync(
|
|
91
|
+
'powershell.exe',
|
|
92
|
+
[
|
|
93
|
+
'-NoProfile',
|
|
94
|
+
'-NonInteractive',
|
|
95
|
+
'-Command',
|
|
96
|
+
'Add-Type -AssemblyName System.Windows.Forms; ' +
|
|
97
|
+
'$a=[System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea; ' +
|
|
98
|
+
'"$($a.X),$($a.Y),$($a.Width),$($a.Height)"',
|
|
99
|
+
],
|
|
100
|
+
{ encoding: 'utf8', timeout: 4000, windowsHide: true },
|
|
101
|
+
);
|
|
102
|
+
if (r.status !== 0) return null;
|
|
103
|
+
const [x, y, width, height] = String(r.stdout).trim().split(',').map(Number);
|
|
104
|
+
const w = { x, y, width, height };
|
|
105
|
+
return validWorkArea(w) ? w : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -52,6 +52,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
52
52
|
|
|
53
53
|
import { createEvent } from '../events.mjs';
|
|
54
54
|
import { recordGuardrailAudit } from './audit.mjs';
|
|
55
|
+
import { detectHarness } from './detect-harness.mjs';
|
|
56
|
+
import { normalizeCopilotPayload, toCopilotResponse } from './copilot.mjs';
|
|
55
57
|
|
|
56
58
|
/** Claude Code `hook_event_name` → spexharness/src/events.mjs event point.
|
|
57
59
|
* Deliberately does NOT map `SubagentStop` to `stop` — SubagentStop is a
|
|
@@ -115,6 +117,8 @@ function fieldsForPoint(point, payload) {
|
|
|
115
117
|
case 'before_tool':
|
|
116
118
|
fields.toolName = payload?.tool_name;
|
|
117
119
|
fields.toolInput = payload?.tool_input ?? {};
|
|
120
|
+
// 2026-10-10: carried so a before_tool gate can scan the session so far (diagnose-capture-reminder).
|
|
121
|
+
if (payload?.transcript_path) fields.transcriptPath = payload.transcript_path;
|
|
118
122
|
break;
|
|
119
123
|
case 'after_tool':
|
|
120
124
|
fields.toolName = payload?.tool_name;
|
|
@@ -405,8 +409,12 @@ export async function runGatesAsHook(gates, deps = {}) {
|
|
|
405
409
|
|
|
406
410
|
export async function runGateAsHook(gate, deps = {}) {
|
|
407
411
|
try {
|
|
408
|
-
const
|
|
409
|
-
if (!
|
|
412
|
+
const rawPayload = deps.payload ?? (await readStdinJson());
|
|
413
|
+
if (!rawPayload) return;
|
|
414
|
+
// GitHub Copilot runs these same hooks (HS-20SW): positive-signature detection, then the Copilot dialect
|
|
415
|
+
// (top-level permissionDecision JSON, exit 0 - never exit 2, Copilot would drop the reason).
|
|
416
|
+
const harness = detectHarness(rawPayload, process.env);
|
|
417
|
+
const payload = harness === 'copilot' ? normalizeCopilotPayload(rawPayload) : rawPayload;
|
|
410
418
|
|
|
411
419
|
const engineEvent = resolveEventForGate(payload, gate);
|
|
412
420
|
if (!engineEvent) return; // not this gate's event — allow
|
|
@@ -430,7 +438,7 @@ export async function runGateAsHook(gate, deps = {}) {
|
|
|
430
438
|
}
|
|
431
439
|
}
|
|
432
440
|
|
|
433
|
-
const { exitCode, stdout, stderr } = toHookResponse(gate, engineEvent, decision);
|
|
441
|
+
const { exitCode, stdout, stderr } = (harness === 'copilot' ? toCopilotResponse : toHookResponse)(gate, engineEvent, decision);
|
|
434
442
|
if (stdout) process.stdout.write(stdout);
|
|
435
443
|
if (stderr) process.stderr.write(stderr);
|
|
436
444
|
process.exitCode = exitCode;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// spexharness/src/adapters/copilot.mjs
|
|
2
|
+
//
|
|
3
|
+
// GitHub Copilot CLI front for the shared hook gates ([[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]];
|
|
4
|
+
// contract: GitHub Docs "Hooks configuration" + "CLI plugin reference", fetched 2026-10-16;
|
|
5
|
+
// [[HS-0GBS-CTF8-RES-copilot-plugin-parity-with-the-spexcode]]). Pure functions, no I/O.
|
|
6
|
+
//
|
|
7
|
+
// What Copilot does that Claude Code does not:
|
|
8
|
+
// INPUT two shapes. camelCase config events `{ sessionId, timestamp, cwd, toolName, toolArgs }` and the
|
|
9
|
+
// PascalCase shape `.claude/settings.json` rows receive `{ hook_event_name, session_id, timestamp,
|
|
10
|
+
// cwd, tool_name, tool_input }` (tool names already mapped to Claude's: bash/powershell -> Bash,
|
|
11
|
+
// create -> Write, edit/apply_patch -> Edit, view -> Read, task -> Agent). No `transcript_path`.
|
|
12
|
+
// OUTPUT ONE top-level JSON object on stdout: `{ permissionDecision: allow|deny|ask, permissionDecisionReason }`.
|
|
13
|
+
// It does NOT read `hookSpecificOutput`. Unparseable stdout is ignored.
|
|
14
|
+
// EXIT 0 -> stdout parsed. 2 -> always deny (stderr reason lost). ANY other non-zero -> deny ("hook
|
|
15
|
+
// errored"). Timeout -> fail open. So a Copilot hook prints its deny as JSON and exits 0, never 2.
|
|
16
|
+
//
|
|
17
|
+
// This module maps both directions; ./detect-harness.mjs decides WHEN it applies.
|
|
18
|
+
|
|
19
|
+
// HeySpex MCP tool names arrive in several spellings depending on the Copilot host (the payload spelling is not
|
|
20
|
+
// documented; [[HS-0GBS-CTF8-RES-copilot-plugin-parity-with-the-spexcode]] residual gap). Every spelling below maps
|
|
21
|
+
// to the Claude-side `mcp__heyspex__<tool>` the gates and `.claude/settings.json` matchers already use:
|
|
22
|
+
// mcp__heyspex__heyspex_x mcp__plugin_heyspex_heyspex__heyspex_x heyspex/heyspex_x heyspex.heyspex_x heyspex_x
|
|
23
|
+
const HEYSPEX_MCP_NAME = /^(?:mcp__plugin_heyspex_heyspex__|mcp__heyspex__|heyspex[/.:])?(heyspex_[A-Za-z0-9_]+)$/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {unknown} name
|
|
27
|
+
* @returns {{ name: string, suffix: string } | null} null when `name` is not a HeySpex MCP tool in a known spelling
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeMcpToolName(name) {
|
|
30
|
+
if (typeof name !== 'string') return null;
|
|
31
|
+
const m = HEYSPEX_MCP_NAME.exec(name.trim());
|
|
32
|
+
return m ? { name: `mcp__heyspex__${m[1]}`, suffix: m[1] } : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const COPILOT_TOOL_TO_CLAUDE = {
|
|
36
|
+
bash: 'Bash',
|
|
37
|
+
powershell: 'PowerShell',
|
|
38
|
+
create: 'Write',
|
|
39
|
+
edit: 'Edit',
|
|
40
|
+
apply_patch: 'Edit',
|
|
41
|
+
view: 'Read',
|
|
42
|
+
task: 'Agent',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function parseArgs(value) {
|
|
46
|
+
if (value && typeof value === 'object') return value;
|
|
47
|
+
if (typeof value !== 'string' || !value.trim()) return {};
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(value);
|
|
50
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
51
|
+
} catch {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Either Copilot payload shape -> the Claude-shaped payload every hook already reads. Never throws; a payload
|
|
58
|
+
* that is already Claude-shaped passes through with its `hook_event_name`, and unknown fields stay untouched.
|
|
59
|
+
* @param {unknown} payload
|
|
60
|
+
* @returns {Record<string, unknown>}
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeCopilotPayload(payload) {
|
|
63
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return {};
|
|
64
|
+
const p = /** @type {Record<string, any>} */ (payload);
|
|
65
|
+
const out = { ...p };
|
|
66
|
+
if (out.session_id === undefined && typeof p.sessionId === 'string') out.session_id = p.sessionId;
|
|
67
|
+
if (out.tool_name === undefined && typeof p.toolName === 'string') {
|
|
68
|
+
out.tool_name = COPILOT_TOOL_TO_CLAUDE[p.toolName] ?? p.toolName;
|
|
69
|
+
}
|
|
70
|
+
if (out.tool_input === undefined && p.toolArgs !== undefined) {
|
|
71
|
+
const args = { ...parseArgs(p.toolArgs) };
|
|
72
|
+
if (args.file_path === undefined && typeof args.path === 'string') args.file_path = args.path;
|
|
73
|
+
out.tool_input = args;
|
|
74
|
+
}
|
|
75
|
+
if (typeof out.tool_name === 'string') {
|
|
76
|
+
const mcp = normalizeMcpToolName(out.tool_name);
|
|
77
|
+
if (mcp) {
|
|
78
|
+
if (mcp.name !== out.tool_name && out.tool_name_original === undefined) out.tool_name_original = out.tool_name;
|
|
79
|
+
out.tool_name = mcp.name;
|
|
80
|
+
out.tool_name_suffix = mcp.suffix;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (out.tool_response === undefined && p.toolResult !== undefined) out.tool_response = p.toolResult;
|
|
84
|
+
if (typeof out.hook_event_name !== 'string') {
|
|
85
|
+
if (out.tool_response !== undefined) out.hook_event_name = 'PostToolUse';
|
|
86
|
+
else if (out.tool_name !== undefined) out.hook_event_name = 'PreToolUse';
|
|
87
|
+
else if (typeof p.prompt === 'string') out.hook_event_name = 'UserPromptSubmit';
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const body = (decision, reason) => JSON.stringify({ permissionDecision: decision, permissionDecisionReason: reason });
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A gate decision (decide.mjs shape) -> what a Copilot hook process emits. A deny is ALWAYS exit 0 with the
|
|
96
|
+
* JSON on stdout (exit 2 would lose the reason). `allow`, `inject` and warn-only rewrites print nothing on
|
|
97
|
+
* stdout (Copilot has no additionalContext channel for tool hooks).
|
|
98
|
+
* @param {{ id?: string, event?: unknown }} _gate
|
|
99
|
+
* @param {{ point?: string }} engineEvent
|
|
100
|
+
* @param {{ decision?: string, reason?: string, rewrite?: { ask?: boolean, warn?: boolean } }} decision
|
|
101
|
+
* @returns {{ exitCode: 0, stdout: string, stderr: string }}
|
|
102
|
+
*/
|
|
103
|
+
export function toCopilotResponse(_gate, engineEvent, decision) {
|
|
104
|
+
const kind = decision?.decision ?? 'allow';
|
|
105
|
+
const reason = String(decision?.reason ?? 'Denied by a HeySpex hook.');
|
|
106
|
+
if (kind === 'deny') {
|
|
107
|
+
const o = { permissionDecision: 'deny', permissionDecisionReason: reason };
|
|
108
|
+
// a Stop-time deny is a re-drive: carry the Claude stop shape too so either reader acts on it
|
|
109
|
+
if (engineEvent?.point === 'stop') Object.assign(o, { decision: 'block', reason });
|
|
110
|
+
return { exitCode: 0, stdout: JSON.stringify(o), stderr: '' };
|
|
111
|
+
}
|
|
112
|
+
if (kind === 'rewrite' && decision.rewrite?.ask) return { exitCode: 0, stdout: body('ask', reason), stderr: '' };
|
|
113
|
+
if (kind === 'rewrite' && decision.rewrite?.warn) {
|
|
114
|
+
return { exitCode: 0, stdout: '', stderr: reason.endsWith('\n') ? reason : `${reason}\n` };
|
|
115
|
+
}
|
|
116
|
+
return { exitCode: 0, stdout: '', stderr: '' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Rewrite a Claude-dialect stdout blob (`{"hookSpecificOutput":{"permissionDecision":...}}`) into Copilot's
|
|
121
|
+
* top-level object. Anything else (already top-level, Stop `{decision:"block"}`, additionalContext, plain
|
|
122
|
+
* text) is returned unchanged. Used by the shared runner for hand-written hooks that print Claude JSON.
|
|
123
|
+
* @param {string} text
|
|
124
|
+
* @returns {string}
|
|
125
|
+
*/
|
|
126
|
+
export function rewriteStdoutForCopilot(text) {
|
|
127
|
+
try {
|
|
128
|
+
const o = JSON.parse(text);
|
|
129
|
+
const h = o?.hookSpecificOutput;
|
|
130
|
+
if (h && typeof h.permissionDecision === 'string') {
|
|
131
|
+
return body(h.permissionDecision, h.permissionDecisionReason ?? 'Denied by a HeySpex hook.');
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
/* not JSON: pass through */
|
|
135
|
+
}
|
|
136
|
+
return text;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A hand-written hook that decided "deny" the legacy way (exit 2, reason on stderr) -> the Copilot JSON deny.
|
|
141
|
+
* @param {string} stderrText
|
|
142
|
+
* @returns {string}
|
|
143
|
+
*/
|
|
144
|
+
export function denyFromLegacyExit(stderrText) {
|
|
145
|
+
const reason = String(stderrText ?? '').replace(/\r/g, '').trim() || 'Denied by a HeySpex hook.';
|
|
146
|
+
return body('deny', reason);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* VS Code's Local harness ignores hook matchers, so the runner re-applies them. True when ANY matcher admits the
|
|
151
|
+
* tool (an empty / `*` / `.*` matcher admits everything; an invalid regex fails open to "admit"). Matchers are
|
|
152
|
+
* Claude Code's: a regex that must match the WHOLE tool name.
|
|
153
|
+
* @param {string[]} matchers
|
|
154
|
+
* @param {string[]} toolNames every spelling of the tool (normalized first, original after)
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
157
|
+
export function toolMatchesAnyMatcher(matchers, toolNames) {
|
|
158
|
+
if (!Array.isArray(matchers) || matchers.length === 0) return true;
|
|
159
|
+
const names = toolNames.filter((n) => typeof n === 'string' && n);
|
|
160
|
+
for (const m of matchers) {
|
|
161
|
+
if (typeof m !== 'string' || m === '' || m === '*' || m === '**') return true;
|
|
162
|
+
try {
|
|
163
|
+
const re = new RegExp(`^(?:${m})$`);
|
|
164
|
+
if (names.some((n) => re.test(n))) return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** All spellings a gate matcher might name for this (normalized) payload's tool. */
|
|
173
|
+
export function toolNameCandidates(payload) {
|
|
174
|
+
const names = [];
|
|
175
|
+
const add = (n) => { if (typeof n === 'string' && n && !names.includes(n)) names.push(n); };
|
|
176
|
+
add(payload?.tool_name);
|
|
177
|
+
add(payload?.tool_name_original);
|
|
178
|
+
if (typeof payload?.tool_name_suffix === 'string') add(`mcp__plugin_heyspex_heyspex__${payload.tool_name_suffix}`);
|
|
179
|
+
return names;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const CONTEXT_EVENTS = new Set(['SessionStart', 'PostToolUse', 'SubagentStart']);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The ONE place the runner decides what a Copilot hook process may print. Guarantees: the result is '' or a
|
|
186
|
+
* single valid JSON object. Non-JSON output is dropped (Copilot ignores unparseable stdout anyway). Claude's
|
|
187
|
+
* `hookSpecificOutput` permission form becomes the top-level form. Copilot drops command-hook output on
|
|
188
|
+
* `UserPromptSubmit`, so a context-only result there prints nothing (a permission / block result still prints).
|
|
189
|
+
* For events that DO accept context (sessionStart, postToolUse, subagentStart) the Claude
|
|
190
|
+
* `hookSpecificOutput.additionalContext` is mirrored to the top-level `additionalContext` Copilot documents.
|
|
191
|
+
* @param {string} text raw buffered stdout
|
|
192
|
+
* @param {string} [eventName] normalized hook_event_name
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
export function finalizeCopilotStdout(text, eventName) {
|
|
196
|
+
let o;
|
|
197
|
+
try {
|
|
198
|
+
o = JSON.parse(rewriteStdoutForCopilot(String(text ?? '')));
|
|
199
|
+
} catch {
|
|
200
|
+
return '';
|
|
201
|
+
}
|
|
202
|
+
if (!o || typeof o !== 'object' || Array.isArray(o)) return '';
|
|
203
|
+
const decided = typeof o.permissionDecision === 'string' || o.decision === 'block';
|
|
204
|
+
if (eventName === 'UserPromptSubmit' && !decided) return '';
|
|
205
|
+
const ctx = o.hookSpecificOutput?.additionalContext;
|
|
206
|
+
if (typeof ctx === 'string' && o.additionalContext === undefined && CONTEXT_EVENTS.has(String(eventName))) {
|
|
207
|
+
o = { ...o, additionalContext: ctx };
|
|
208
|
+
}
|
|
209
|
+
return JSON.stringify(o);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Append `extra` to a finished Copilot stdout blob's additionalContext (creating the object when stdout is
|
|
214
|
+
* empty). A permission decision is never touched. Returns the new stdout.
|
|
215
|
+
* @param {string} finalText '' or one JSON object
|
|
216
|
+
* @param {string} extra
|
|
217
|
+
* @returns {string}
|
|
218
|
+
*/
|
|
219
|
+
export function mergeAdditionalContext(finalText, extra) {
|
|
220
|
+
if (!extra) return finalText;
|
|
221
|
+
let o = {};
|
|
222
|
+
if (finalText) {
|
|
223
|
+
try { o = JSON.parse(finalText); } catch { return finalText; }
|
|
224
|
+
if (!o || typeof o !== 'object' || Array.isArray(o)) return finalText;
|
|
225
|
+
}
|
|
226
|
+
if (typeof o.permissionDecision === 'string') return finalText;
|
|
227
|
+
const prev = typeof o.additionalContext === 'string' ? o.additionalContext : o.hookSpecificOutput?.additionalContext;
|
|
228
|
+
const merged = typeof prev === 'string' && prev ? `${prev}
|
|
229
|
+
|
|
230
|
+
${extra}` : extra;
|
|
231
|
+
o.additionalContext = merged;
|
|
232
|
+
if (o.hookSpecificOutput && typeof o.hookSpecificOutput === 'object') {
|
|
233
|
+
o.hookSpecificOutput = { ...o.hookSpecificOutput, additionalContext: merged };
|
|
234
|
+
}
|
|
235
|
+
return JSON.stringify(o);
|
|
236
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// spexharness/src/adapters/detect-harness.mjs
|
|
2
|
+
//
|
|
3
|
+
// Which agent harness is invoking this hook? ([[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]] Copilot
|
|
4
|
+
// compatibility; contract: GitHub Docs "Hooks configuration", fetched 2026-10-16.) GitHub Copilot CLI runs the
|
|
5
|
+
// hooks registered in `.claude/settings.json` too, but it speaks a different output dialect (ONE top-level
|
|
6
|
+
// `{permissionDecision, permissionDecisionReason}` object, not `hookSpecificOutput`) and treats every
|
|
7
|
+
// non-zero exit as a deny, so the shared runner must know when it is talking to Copilot.
|
|
8
|
+
//
|
|
9
|
+
// DETECTION IS POSITIVE-SIGNATURE ONLY - never "session_id missing". Pure: payload + env in, a string out.
|
|
10
|
+
// - explicit override HEYSPEX_HARNESS = copilot | claude-code | cursor (wins)
|
|
11
|
+
// - 'copilot' the payload carries `timestamp` (absent from Claude Code payloads) AND no Cursor marker; or it
|
|
12
|
+
// is the camelCase config shape ({ sessionId, toolName, toolArgs } with no snake_case twins).
|
|
13
|
+
// - 'cursor' `conversation_id` / `workspace_roots` / `cursor_version` present.
|
|
14
|
+
// - 'claude-code' a `hook_event_name` + `session_id` payload with none of the above.
|
|
15
|
+
// - 'unknown' anything else (including a non-object payload). Callers treat unknown like claude-code.
|
|
16
|
+
|
|
17
|
+
const OVERRIDES = new Set(['copilot', 'claude-code', 'cursor']);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {unknown} payload parsed hook stdin (or null)
|
|
21
|
+
* @param {Record<string, string | undefined>} [env]
|
|
22
|
+
* @returns {'claude-code' | 'copilot' | 'cursor' | 'unknown'}
|
|
23
|
+
*/
|
|
24
|
+
export function detectHarness(payload, env = process.env) {
|
|
25
|
+
const forced = String(env?.HEYSPEX_HARNESS ?? '').trim().toLowerCase();
|
|
26
|
+
if (OVERRIDES.has(forced)) return forced;
|
|
27
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return 'unknown';
|
|
28
|
+
const p = /** @type {Record<string, unknown>} */ (payload);
|
|
29
|
+
if ('conversation_id' in p || 'workspace_roots' in p || 'cursor_version' in p) return 'cursor';
|
|
30
|
+
const camel = ('toolName' in p || 'toolArgs' in p) && !('tool_name' in p);
|
|
31
|
+
if ('timestamp' in p || camel) return 'copilot';
|
|
32
|
+
if (typeof p.hook_event_name === 'string' && typeof p.session_id === 'string') return 'claude-code';
|
|
33
|
+
return 'unknown';
|
|
34
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// spexharness/src/gates/_shell-write-targets.mjs
|
|
2
|
+
//
|
|
3
|
+
// Shared conservative shell-write scanner. Extracted from
|
|
4
|
+
// env-vault-guard.decide.mjs (originally the same scanner as
|
|
5
|
+
// guard-auto-memory-writes.mjs) so env-vault-guard and vault-write-tier-guard
|
|
6
|
+
// (HS-FCNH) import ONE implementation instead of drifting copies.
|
|
7
|
+
//
|
|
8
|
+
// Pure: no fs, no process. It only recognises write-SHAPED commands whose
|
|
9
|
+
// targets it can positively identify; callers pair it with a fail-closed
|
|
10
|
+
// text scan (see `mentionsWriteVerb`) for everything it cannot parse
|
|
11
|
+
// (heredocs, `python -c`, `node -e`, variable-expanded targets).
|
|
12
|
+
|
|
13
|
+
const REDIRECT_RE = /(?:>>?|\|\s*tee(?:\s+-a)?)\s+("([^"]+)"|'([^']+)'|(\S+))/g;
|
|
14
|
+
const CMD_RE =
|
|
15
|
+
/\b(?:mv|cp|touch|install|rename|tee|rm|del|Remove-Item|Move-Item|Copy-Item|New-Item|Set-Content|Out-File|Add-Content|Clear-Content)\s+([^|;&\n]+)/g;
|
|
16
|
+
// In-place editors: `sed -i`, `sed -ibak`, `sed --in-place`, `perl -pi -e`, `perl -i.bak`.
|
|
17
|
+
const INPLACE_RE = /\b(?:sed|perl)\b([^|;&\n]*)/g;
|
|
18
|
+
|
|
19
|
+
function isInPlaceFlag(token) {
|
|
20
|
+
return (
|
|
21
|
+
token === '--in-place' ||
|
|
22
|
+
token.startsWith('--in-place=') ||
|
|
23
|
+
/^-[A-Za-z]*i[A-Za-z0-9.]*$/.test(token)
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Same conservative shell scanner as guard-auto-memory-writes.mjs, plus in-place editors. */
|
|
28
|
+
export function extractShellTargets(command) {
|
|
29
|
+
if (!command || typeof command !== 'string') return [];
|
|
30
|
+
const targets = new Set();
|
|
31
|
+
for (const m of command.matchAll(REDIRECT_RE)) targets.add(m[2] ?? m[3] ?? m[4]);
|
|
32
|
+
for (const m of command.matchAll(CMD_RE)) {
|
|
33
|
+
const head = m[0].split(/\s/)[0];
|
|
34
|
+
const args = m[1]
|
|
35
|
+
.trim()
|
|
36
|
+
.split(/\s+/)
|
|
37
|
+
.filter((a) => a && !a.startsWith('-'));
|
|
38
|
+
if (args.length === 0) continue;
|
|
39
|
+
if (/^(?:mv|cp|install|rename|Move-Item|Copy-Item)$/.test(head)) {
|
|
40
|
+
targets.add(args[args.length - 1]);
|
|
41
|
+
} else {
|
|
42
|
+
for (const a of args) targets.add(a);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// sed -i / perl -i / --in-place: every non-flag operand is a candidate
|
|
46
|
+
// (the script operand rides along harmlessly — callers match by path).
|
|
47
|
+
for (const m of command.matchAll(INPLACE_RE)) {
|
|
48
|
+
const tokens = m[1].trim().split(/\s+/).filter(Boolean);
|
|
49
|
+
if (!tokens.some(isInPlaceFlag)) continue;
|
|
50
|
+
for (const t of tokens) if (!t.startsWith('-')) targets.add(t);
|
|
51
|
+
}
|
|
52
|
+
// PowerShell: -Path / -FilePath / -LiteralPath <target> on a write cmdlet.
|
|
53
|
+
if (/\b(?:New-Item|Set-Content|Out-File|Add-Content|Clear-Content)\b/i.test(command)) {
|
|
54
|
+
for (const m of command.matchAll(/-(?:Path|FilePath|LiteralPath)\s+("([^"]+)"|'([^']+)'|(\S+))/gi))
|
|
55
|
+
targets.add(m[2] ?? m[3] ?? m[4]);
|
|
56
|
+
}
|
|
57
|
+
for (const t of extractInterpreterWriteTargets(command)) targets.add(t);
|
|
58
|
+
return [...targets]
|
|
59
|
+
.map((t) => t.replace(/^['"]|['"]$/g, ''))
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.filter((t) => !/[$`(){}]/.test(t));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Literal-path writes inside inline interpreter bodies: python `open('p','w')`,
|
|
66
|
+
* node `writeFileSync('p', ...)`. Only a literal path that is the write call's
|
|
67
|
+
* FIRST argument counts; a body that merely reads or prints is not a write.
|
|
68
|
+
*/
|
|
69
|
+
export function extractInterpreterWriteTargets(command) {
|
|
70
|
+
if (!command || typeof command !== 'string') return [];
|
|
71
|
+
const out = [];
|
|
72
|
+
const pyOpen = /\bopen\s*\(\s*(['"])([^'"]+)\1\s*,\s*['"][wax]/g;
|
|
73
|
+
const nodeWrite = /\b(?:writeFile|writeFileSync|appendFile|appendFileSync)\s*\(\s*(['"`])([^'"`]+)\1/g;
|
|
74
|
+
for (const m of command.matchAll(pyOpen)) out.push(m[2]);
|
|
75
|
+
for (const m of command.matchAll(nodeWrite)) out.push(m[2]);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
@@ -67,8 +67,9 @@ const REPO_ROOT = path.resolve(HERE, '..', '..', '..');
|
|
|
67
67
|
|
|
68
68
|
// Copied verbatim from the original (private consts there) — see the
|
|
69
69
|
// judgment-call note above.
|
|
70
|
-
|
|
71
|
-
const
|
|
70
|
+
// Exported (additive, HS-KNJ8) so scripts/lib/capture-execution-guard.mjs reads the SAME record with the SAME shape.
|
|
71
|
+
export const GROUNDING_ARTIFACT_REL = path.join('.heyspex', 'research-grounding.json');
|
|
72
|
+
export const GROUNDING_FIELDS =['already_built', 'already_decided', 'owner_reference', 'residual_delta'];
|
|
72
73
|
|
|
73
74
|
const COST = {
|
|
74
75
|
'deep-research': {
|
|
@@ -141,7 +142,7 @@ function isGroundOnlyResearchLaunch(toolName, input) {
|
|
|
141
142
|
return typeof input.skill === 'string' && /^hs-res$/i.test(input.skill.trim());
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
function defaultReadGroundingArtifact(root) {
|
|
145
|
+
export function defaultReadGroundingArtifact(root) {
|
|
145
146
|
try {
|
|
146
147
|
const full = path.join(root, GROUNDING_ARTIFACT_REL);
|
|
147
148
|
if (!fs.existsSync(full)) return null;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// spexharness/src/gates/diagnose-capture-reminder.mjs
|
|
2
|
+
//
|
|
3
|
+
// PreToolUse WARN-ONLY reminder (diagnose HS-IK2D, 2026-10-10). A diagnose (or research) filed straight
|
|
4
|
+
// through heyspex_file_spec passes every other gate: capture-workflow-gate and
|
|
5
|
+
// vendor/heyspex-mcp/capture-stage-gate.mjs deliberately ALLOW `type: diagnose|research` because they are
|
|
6
|
+
// the sanctioned exits. That left a hole — an owner's complaint could become a diagnose with NO capture
|
|
7
|
+
// ever holding their verbatim words. This gate closes the hole without blocking: when a diagnose/research
|
|
8
|
+
// is filed without `frontmatter.promoted_from` and the session transcript shows no capture tool call
|
|
9
|
+
// before it, it injects a reminder (additionalContext) telling the agent to capture first.
|
|
10
|
+
//
|
|
11
|
+
// Never denies. Fail-open on every unknowable (no transcript, unreadable, bad JSON, wrong tool).
|
|
12
|
+
// Kill switch: HEYSPEX_DIAGNOSE_CAPTURE_REMINDER_OFF=1.
|
|
13
|
+
//
|
|
14
|
+
// ctx (all optional): ctx.env, ctx.readFile(path) -> string|null, ctx.sessionRecord (non-Claude fronts:
|
|
15
|
+
// { toolCalls: [{ name }] }, used when no transcript path exists).
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
|
|
19
|
+
export const id = 'diagnose-capture-reminder';
|
|
20
|
+
export const event = 'before_tool';
|
|
21
|
+
export const severity = 'warn';
|
|
22
|
+
|
|
23
|
+
const OFF_ENV = 'HEYSPEX_DIAGNOSE_CAPTURE_REMINDER_OFF';
|
|
24
|
+
const FILE_SPEC_TOOL = 'heyspex_file_spec';
|
|
25
|
+
const REMINDED_TYPES = new Set(['diagnose', 'research']);
|
|
26
|
+
// A capture tool call anywhere earlier in the session counts as "the capture happened".
|
|
27
|
+
const CAPTURE_TOOL_RE = /heyspex_(?:active_capture|update_capture|promote_capture_to_diagnose|promote_capture)\b/;
|
|
28
|
+
|
|
29
|
+
export const REMINDER_TEXT =
|
|
30
|
+
"No capture precedes this diagnose — run heyspex_active_capture + heyspex_update_capture with the owner's " +
|
|
31
|
+
'verbatim words, then heyspex_promote_capture_to_diagnose. Direct filing is for bugs found mid-work only.';
|
|
32
|
+
|
|
33
|
+
function bareToolName(name) {
|
|
34
|
+
if (typeof name !== 'string') return '';
|
|
35
|
+
const idx = name.lastIndexOf('__');
|
|
36
|
+
return idx === -1 ? name : name.slice(idx + 2);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function defaultReadFile(p) {
|
|
40
|
+
try {
|
|
41
|
+
return fs.readFileSync(p, 'utf8');
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when the transcript JSONL holds an assistant tool_use of a capture tool. */
|
|
48
|
+
export function transcriptHasCaptureCall(raw) {
|
|
49
|
+
if (typeof raw !== 'string') return false;
|
|
50
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
51
|
+
if (!line || !line.includes('capture')) continue; // cheap pre-filter
|
|
52
|
+
let evt;
|
|
53
|
+
try {
|
|
54
|
+
evt = JSON.parse(line);
|
|
55
|
+
} catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const content = evt?.message?.content;
|
|
59
|
+
if (!Array.isArray(content)) continue;
|
|
60
|
+
for (const block of content) {
|
|
61
|
+
if (block?.type === 'tool_use' && typeof block.name === 'string' && CAPTURE_TOOL_RE.test(block.name)) return true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function recordHasCaptureCall(record) {
|
|
68
|
+
const calls = Array.isArray(record?.toolCalls) ? record.toolCalls : [];
|
|
69
|
+
return calls.some((c) => typeof c?.name === 'string' && CAPTURE_TOOL_RE.test(c.name));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {{ point: string, toolName?: string, toolInput?: object, transcriptPath?: string, sessionRecord?: object }} evt
|
|
74
|
+
* @param {{ env?: object, readFile?: (p: string) => string|null, sessionRecord?: object }} [ctx]
|
|
75
|
+
* @returns {{ decision: 'allow'|'inject', reason?: string, inject?: { additionalContext: string } }}
|
|
76
|
+
*/
|
|
77
|
+
export function run(evt, ctx = {}) {
|
|
78
|
+
try {
|
|
79
|
+
const env = ctx.env ?? process.env;
|
|
80
|
+
if (String(env[OFF_ENV] ?? '') === '1') return { decision: 'allow' };
|
|
81
|
+
if (bareToolName(evt?.toolName) !== FILE_SPEC_TOOL) return { decision: 'allow' };
|
|
82
|
+
|
|
83
|
+
const input = evt?.toolInput ?? {};
|
|
84
|
+
const type = String(input?.type ?? '').trim().toLowerCase();
|
|
85
|
+
if (!REMINDED_TYPES.has(type)) return { decision: 'allow' };
|
|
86
|
+
|
|
87
|
+
const promotedFrom = input?.frontmatter?.promoted_from ?? input?.promoted_from;
|
|
88
|
+
if (promotedFrom != null && String(Array.isArray(promotedFrom) ? promotedFrom.join('') : promotedFrom).trim()) {
|
|
89
|
+
return { decision: 'allow' }; // came through a capture promotion
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const transcriptPath = evt?.transcriptPath;
|
|
93
|
+
const record = evt?.sessionRecord ?? ctx.sessionRecord ?? null;
|
|
94
|
+
if (!transcriptPath && !record) return { decision: 'allow' }; // unknowable -> fail open
|
|
95
|
+
|
|
96
|
+
let captured;
|
|
97
|
+
if (transcriptPath) {
|
|
98
|
+
const raw = (ctx.readFile ?? defaultReadFile)(transcriptPath);
|
|
99
|
+
if (raw == null) return { decision: 'allow' }; // unreadable -> fail open
|
|
100
|
+
captured = transcriptHasCaptureCall(raw);
|
|
101
|
+
} else {
|
|
102
|
+
captured = recordHasCaptureCall(record);
|
|
103
|
+
}
|
|
104
|
+
if (captured) return { decision: 'allow' };
|
|
105
|
+
|
|
106
|
+
return { decision: 'inject', reason: REMINDER_TEXT, inject: { additionalContext: REMINDER_TEXT } };
|
|
107
|
+
} catch {
|
|
108
|
+
return { decision: 'allow' };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default { id, event, severity, run };
|