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.
Files changed (146) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/bin/hs.mjs +181 -15
  3. package/dist/cli/hs-check.mjs +88 -46
  4. package/dist/cli/hs-claim.mjs +84 -39
  5. package/dist/cli/hs-connect.mjs +583 -129
  6. package/dist/cli/hs-defer.mjs +251 -30
  7. package/dist/cli/hs-dev.mjs +20 -8
  8. package/dist/cli/hs-env.mjs +62 -34
  9. package/dist/cli/hs-index-rebuild.mjs +288 -83
  10. package/dist/cli/hs-index-reconcile.mjs +397 -111
  11. package/dist/cli/hs-init.mjs +2143 -91
  12. package/dist/cli/hs-install.mjs +628 -106
  13. package/dist/cli/hs-join.mjs +686 -236
  14. package/dist/cli/hs-vault-pull.mjs +3189 -179
  15. package/dist/cli/hs-vault-push.mjs +6433 -0
  16. package/dist/cli/hs-vault.mjs +396 -178
  17. package/dist/cli/hs-workspace.mjs +289 -151
  18. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  19. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  20. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  21. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
  22. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  23. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  24. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  25. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  26. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  27. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
  28. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
  29. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  30. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  31. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  32. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  33. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  34. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
  35. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  36. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  37. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  38. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  39. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  40. package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
  41. package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
  42. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  43. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  44. package/docs/copilot-plugin-install.md +177 -0
  45. package/docs/driving-heyspex-from-copilot.md +165 -0
  46. package/package.json +21 -2
  47. package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
  48. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
  49. package/plugins/heyspex/README.md +312 -0
  50. package/plugins/heyspex/bin/run-hook.mjs +20 -2
  51. package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
  52. package/plugins/heyspex/hooks/hooks.json +94 -37
  53. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
  54. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
  55. package/scripts/claude-hooks/_run.mjs +316 -23
  56. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  57. package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
  58. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  59. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  60. package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
  61. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
  62. package/scripts/claude-hooks/session-end.mjs +2 -0
  63. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  64. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  65. package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
  66. package/scripts/cursor-hooks/manifest.mjs +46 -0
  67. package/scripts/heyspex-mode.mjs +20 -0
  68. package/scripts/hs-mode.mjs +1 -1
  69. package/scripts/hs-view.mjs +180 -17
  70. package/scripts/install-claude-code-hooks.mjs +3 -1
  71. package/scripts/install-copilot-hooks.mjs +288 -0
  72. package/scripts/lib/console-url-resolver.mjs +3 -2
  73. package/scripts/lib/customer-kit.mjs +327 -10
  74. package/scripts/lib/dev-mode.mjs +7 -6
  75. package/scripts/lib/directive-obligations.mjs +349 -0
  76. package/scripts/lib/hook-dispositions.mjs +210 -0
  77. package/scripts/lib/hook-vault-header.mjs +30 -0
  78. package/scripts/lib/hs-source-branch.mjs +196 -0
  79. package/scripts/lib/hs-source-log.mjs +2 -1
  80. package/scripts/lib/hs-source-preflight.mjs +18 -4
  81. package/scripts/lib/hs-source-push.mjs +386 -356
  82. package/scripts/lib/window-placement.mjs +109 -0
  83. package/spexharness/src/adapters/claude-code.mjs +11 -3
  84. package/spexharness/src/adapters/copilot.mjs +236 -0
  85. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  86. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  87. package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
  88. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  89. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  90. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  91. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  92. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  93. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  94. package/spexharness/src/intent-classifier.mjs +4 -2
  95. package/spexharness/src/intent-directive.mjs +626 -541
  96. package/spexharness/src/rule-table.mjs +31 -1
  97. package/vendor/heyspex-mcp/account-switch.mjs +6 -5
  98. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
  99. package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
  100. package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
  101. package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
  102. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  103. package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
  104. package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
  105. package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
  106. package/vendor/heyspex-mcp/cli.mjs +7 -1
  107. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  108. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
  109. package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
  110. package/vendor/heyspex-mcp/console-transport.mjs +7 -1
  111. package/vendor/heyspex-mcp/credentials.mjs +9 -8
  112. package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
  113. package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
  114. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
  115. package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
  116. package/vendor/heyspex-mcp/http-transport.mjs +2 -0
  117. package/vendor/heyspex-mcp/list-tools.mjs +1 -1
  118. package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
  119. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  120. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  121. package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
  122. package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
  123. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  124. package/vendor/heyspex-mcp/plan-push.mjs +3 -2
  125. package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
  126. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
  127. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
  128. package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
  129. package/vendor/heyspex-mcp/promote-research.mjs +6 -0
  130. package/vendor/heyspex-mcp/read-path.mjs +162 -6
  131. package/vendor/heyspex-mcp/request-context.mjs +25 -1
  132. package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
  133. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  134. package/vendor/heyspex-mcp/session-owner.mjs +3 -2
  135. package/vendor/heyspex-mcp/session-tools.mjs +10 -0
  136. package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
  137. package/vendor/heyspex-mcp/split-research.mjs +3 -0
  138. package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
  139. package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
  140. package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
  141. package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
  142. package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
  143. package/vendor/heyspex-mcp/vault-io.mjs +720 -724
  144. package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
  145. package/vendor/heyspex-mcp/whoami.mjs +2 -1
  146. 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 payload = deps.payload ?? (await readStdinJson());
409
- if (!payload) return;
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
- const GROUNDING_ARTIFACT_REL = path.join('.heyspex', 'research-grounding.json');
71
- const GROUNDING_FIELDS = ['already_built', 'already_decided', 'owner_reference', 'residual_delta'];
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 };