heyspex 0.2.2 → 0.2.4

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 (79) hide show
  1. package/.claude-plugin/marketplace.json +2 -1
  2. package/README.md +11 -0
  3. package/bin/hs-vault-binding.mjs +157 -0
  4. package/bin/hs.mjs +83 -79
  5. package/bin/spexcode.mjs +91 -0
  6. package/dev-factory/engine/worktree.mjs +36 -7
  7. package/dev-factory/lib/epic-detect.mjs +2 -2
  8. package/dev-factory/lib/fleet-readiness.mjs +3 -5
  9. package/dev-factory/lib/fleet-state.mjs +2 -2
  10. package/dev-factory/lib/sufficiency-score.mjs +3 -5
  11. package/dist/cli/hs-check.mjs +76 -46
  12. package/dist/cli/hs-claim.mjs +63 -33
  13. package/dist/cli/hs-connect.mjs +520 -214
  14. package/dist/cli/hs-defer.mjs +161 -19
  15. package/dist/cli/hs-dev.mjs +11 -5
  16. package/dist/cli/hs-env.mjs +187 -161
  17. package/dist/cli/hs-index-rebuild.mjs +123 -78
  18. package/dist/cli/hs-index-reconcile.mjs +203 -107
  19. package/dist/cli/hs-init.mjs +426 -179
  20. package/dist/cli/hs-install.mjs +759 -437
  21. package/dist/cli/hs-join.mjs +590 -383
  22. package/dist/cli/hs-vault-pull.mjs +359 -254
  23. package/dist/cli/hs-vault-push.mjs +151 -113
  24. package/dist/cli/hs-vault.mjs +271 -104
  25. package/dist/cli/hs-workspace.mjs +90 -57
  26. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
  27. package/docs/copilot-plugin-install.md +200 -177
  28. package/docs/driving-heyspex-from-copilot.md +174 -165
  29. package/docs/mcp-client-config.md +93 -25
  30. package/package.json +257 -250
  31. package/plugins/heyspex/.claude-plugin/plugin.json +13 -12
  32. package/plugins/heyspex/.cursor-plugin/plugin.json +16 -15
  33. package/plugins/heyspex/.mcp.json +3 -4
  34. package/plugins/heyspex/README.md +323 -312
  35. package/plugins/heyspex/assets/heyspex-glyph-128.png +0 -0
  36. package/plugins/heyspex/assets/heyspex-glyph-256.png +0 -0
  37. package/plugins/heyspex/assets/heyspex-glyph.svg +12 -0
  38. package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
  39. package/plugins/heyspex/mcp.json +12 -13
  40. package/scripts/check-mcp-freshness.mjs +6 -4
  41. package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
  42. package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
  43. package/scripts/claude-hooks/research-method-guard.mjs +2 -2
  44. package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
  45. package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
  46. package/scripts/claude-statusline/statusline-command.mjs +1073 -0
  47. package/scripts/copilot-statusline.mjs +130 -0
  48. package/scripts/dev-server-status.mjs +3 -2
  49. package/scripts/dev-server-stop.mjs +3 -2
  50. package/scripts/hs-capture-cli.mjs +2 -2
  51. package/scripts/hs-settings.mjs +2 -2
  52. package/scripts/hs-view.mjs +3 -2
  53. package/scripts/install-copilot-hooks.mjs +152 -36
  54. package/scripts/lib/copilot-cli.mjs +108 -0
  55. package/scripts/lib/customer-kit.mjs +223 -30
  56. package/scripts/lib/dev-mode.mjs +28 -2
  57. package/scripts/lib/shared-path-helpers.mjs +32 -0
  58. package/scripts/lib/standalone-doctor.mjs +131 -32
  59. package/scripts/wip-ledger-health.mjs +3 -2
  60. package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
  61. package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
  62. package/spexharness/src/intent-directive.mjs +839 -626
  63. package/spexharness/src/rule-table.mjs +1 -1
  64. package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
  65. package/vendor/heyspex-mcp/cli.mjs +20 -4
  66. package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
  67. package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
  68. package/vendor/heyspex-mcp/events-tools.mjs +1 -1
  69. package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
  70. package/vendor/heyspex-mcp/http-transport.mjs +6 -1
  71. package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
  72. package/vendor/heyspex-mcp/server-identity.mjs +13 -0
  73. package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
  74. package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
  75. package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
  76. package/vendor/heyspex-mcp/tool-dispatch.mjs +63 -23
  77. package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
  78. package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
  79. package/vendor/heyspex-mcp/vault-resolve.mjs +353 -245
@@ -172,7 +172,7 @@ export const BUILT_IN_RULES = Object.freeze(
172
172
  "configKey": "agent_behaviors.coo_delegation_guard.enabled",
173
173
  "ignoreInjectedContent": false,
174
174
  "patterns": [],
175
- "inject": "DEV-FACTORY DELEGATION GUARD (dev-factory-delegation-routing) — as COO you do not do a\nphase's reasoning in your own context. Before the breakdown phase for the on-deck epic,\nthe guard checks the seat-audit record: if that phase has NOT been delegated to the seat\nthe routing table names for it, the exact delegation command is injected and you run it\nfirst. Delegation is table-driven — consult dev-factory/routing-table.json and route to\nthe calibrated seat model (build → free local fleet; breakdown/critic/synthesis →\nDeepSeek; cloud/Opus is escalation-only). \"Delegate-first\" NEVER means spawning a\nsubagent on the session's own tier. If you have already delegated this breakdown,\nproceed. The guard fails open: a missing or unreadable seat registry / audit record\nnever blocks session start or a claim.\n",
175
+ "inject": "DEV-FACTORY DELEGATION GUARD (dev-factory-delegation-routing) — as COO you do not do a\nphase's reasoning in your own context. Before the breakdown phase for the on-deck epic,\nthe guard checks the seat-audit record: if that phase has NOT been delegated to the seat\nthe routing table names for it, the exact delegation command is injected and you run it\nfirst. Delegation is table-driven — consult dev-factory/routing-table.json and route to\nthe calibrated seat model (build → cloud DeepSeek builder via controller.mjs, local\nfleet paused by owner ruling 2026-10-15; breakdown/critic/synthesis → DeepSeek;\ncloud/Opus is escalation-only). \"Delegate-first\" NEVER means spawning a\nsubagent on the session's own tier. If you have already delegated this breakdown,\nproceed. The guard fails open: a missing or unreadable seat registry / audit record\nnever blocks session start or a claim.\n",
176
176
  "naming": null
177
177
  },
178
178
  {
@@ -17,10 +17,14 @@ import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
17
17
  import { DEFAULT_LOGIN_TIMEOUT_MS, buildConnectUrl, describeCallbackFailure, describeRedeemFailure, generateLoginState, openBrowser as openBrowserDefault, startCallbackListener as startCallbackListenerDefault } from './login-handoff.mjs';
18
18
  import { getConfig, getConsoleUrl } from './mcp-config.mjs';
19
19
  import { homedir, hostname } from 'node:os';
20
- import { join } from 'node:path';
20
+ import { dirname, join } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
21
22
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
23
  import { handleToolsCall, handleToolsList } from './tool-dispatch.mjs';
23
24
  import { writeBuildRecord } from '../../scripts/check-mcp-freshness.mjs';
25
+
26
+ /** This server's own code directory (vendor/heyspex-mcp of the checkout it runs from). */
27
+ const SERVER_CODE_DIR = dirname(fileURLToPath(import.meta.url));
24
28
  import { registerSpec } from './register-spec.mjs';
25
29
  import { validateLinkage } from './validate-linkage.mjs';
26
30
  import { execFileSync } from 'node:child_process';
@@ -1615,9 +1619,19 @@ stdio parent so tool schemas stay current without reconnecting Cursor).
1615
1619
  */
1616
1620
  export function stampMcpBuildRecord() {
1617
1621
  try {
1618
- const { vaultRoot: fingerprintVaultRoot } = getConfig();
1622
+ const { vaultRoot: fingerprintVaultRoot, vaultSource } = getConfig();
1623
+ // Unbound ([[HS-TYJA]]): vaultRoot is only the launch folder, not a
1624
+ // vault - never create .heyspex/ there.
1625
+ if (vaultSource === 'unbound') return;
1626
+ // HS-C0NW: never mkdir a .heyspex/ into a folder that is not there (a host that passed an
1627
+ // unexpanded `${CLAUDE_PROJECT_DIR}` used to leave a junk directory by that name).
1628
+ if (!existsSync(fingerprintVaultRoot)) return;
1619
1629
  writeBuildRecord({
1620
1630
  vaultRoot: fingerprintVaultRoot,
1631
+ // Fingerprint the code this process actually runs. Since the repo split
1632
+ // the vault holds no vendor/ tree, so `<vaultRoot>/vendor/heyspex-mcp`
1633
+ // fingerprinted nothing (null) for every bound repo.
1634
+ vendorDir: SERVER_CODE_DIR,
1621
1635
  pid: process.pid,
1622
1636
  startedAt: new Date().toISOString(),
1623
1637
  });
@@ -52,6 +52,12 @@ import { filterToolSurface, handleLoadTools } from './tool-catalog.mjs';
52
52
  import { handlePromptsGet, handlePromptsList } from './prompts.mjs';
53
53
  import { SERVER_VERSION, handleInitialize, handleToolsCall, handleToolsList, resolveInjectableSeat, vaultRootGateVerdict } from './tool-dispatch.mjs';
54
54
  import { runCallCli, runExportCli, runLoginCli, runLogoutCli, runMcpInstallCli, runPairCli, runRegisterCli, runValidateCli, runWhoamiCli, stampMcpBuildRecord } from './cli-verbs.mjs';
55
+ import { scrubUnusableDirEnv } from './usable-dir-env.mjs';
56
+
57
+ // HS-C0NW: a host that does not expand `${CLAUDE_PROJECT_DIR}` in the MCP manifest hands us the
58
+ // literal text. Treat such values as unset so every reader (resolver, console-fetch, subprocess
59
+ // children that inherit this env) falls through to the cwd walk instead of mkdir-ing a junk path.
60
+ scrubUnusableDirEnv(process.env, { requireExisting: { HEYSPEX_VAULT_ROOT: false } });
55
61
 
56
62
  // Dev-factory seat identity (epic HS-CXO0): surface each caller's seat on connect.
57
63
  // resolveCooModeEnabled is the SHARED readiness predicate — the SessionStart hook
@@ -191,7 +197,7 @@ function handleRequest(request) {
191
197
  const vaultRoot = config.vaultRoot;
192
198
  // Same mode-aware gate as tools/call: an absent clone only refuses on
193
199
  // the disk tier (see vaultRootGateVerdict in tool-dispatch.mjs).
194
- const promptGate = vaultRootGateVerdict(vaultRoot);
200
+ const promptGate = vaultRootGateVerdict(vaultRoot, { vaultSource: config.vaultSource, vaultError: config.vaultError });
195
201
  if (promptGate) {
196
202
  sendError(id, -32003, promptGate.message);
197
203
  break;
@@ -302,8 +308,10 @@ const INFO_ONLY = SHOW_HELP || SHOW_VERSION;
302
308
  // SV2K: migrate legacy .heyspex/idea-focus.yaml → capture-focus.yaml on startup.
303
309
  if (!INFO_ONLY) {
304
310
  try {
305
- const { vaultRoot: bootVaultRoot } = getConfig();
306
- if (existsSync(bootVaultRoot)) {
311
+ const { vaultRoot: bootVaultRoot, vaultSource: bootSource } = getConfig();
312
+ // Unbound ([[HS-TYJA]]): vaultRoot is only the launch folder, not a vault -
313
+ // never migrate files there.
314
+ if (bootSource !== 'unbound' && existsSync(bootVaultRoot)) {
307
315
  migrateLegacyIdeaFocus(bootVaultRoot);
308
316
  }
309
317
  } catch (err) {
@@ -389,7 +397,15 @@ if (!RUNNING_AS_CLI && !INFO_ONLY && process.env.HEYSPEX_DISABLE_STDIO_BOOTSTRAP
389
397
  // unless HEYSPEX_ALLOW_POINTER_VAULT=1 — in which case say which vault
390
398
  // this process will act on, once, right here at start.
391
399
  announcePointerVaultOnce({ vaultId: bootVaultId, repoRootAbs: bootVaultRoot, source: bootVaultSource });
392
- if (existsSync(bootVaultRoot)) {
400
+ if (bootVaultSource === 'unbound') {
401
+ // No vault for this launch folder ([[HS-TYJA]]). The server still boots
402
+ // (tools/list works); every vault-scoped call is refused per call with
403
+ // the typed code (console-fetch.mjs). Say why once, at start.
404
+ const bootErr = getConfig().vaultError;
405
+ process.stderr.write?.(
406
+ `[heyspex-mcp] ${bootErr?.code ?? 'VAULT_UNBOUND'}: no vault for ${bootVaultRoot}; vault tools will refuse until fixed. Fix: ${bootErr?.fix ?? 'hs vault bind --vault <name|id>'}\n`,
407
+ );
408
+ } else if (existsSync(bootVaultRoot)) {
393
409
  captureChannelWatcher = createCaptureChannelWatcher({
394
410
  vaultRoot: bootVaultRoot,
395
411
  getConsoleUrl,
@@ -99,7 +99,7 @@ export { isVaultLessEndpoint, VAULT_LESS_ENDPOINTS };
99
99
  // ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
100
100
  import { hsCommand } from '../../scripts/lib/hs-command.mjs';
101
101
  import { maybeDrainOnFirstSuccess } from './write-outbox.mjs';
102
- import { getActiveConfig, registryAccountIdForRoot, UNREGISTERED_VAULT_ID } from './vault-resolve.mjs';
102
+ import { getActiveConfig, isVaultResolutionError, registryAccountIdForRoot, UNREGISTERED_VAULT_ID } from './vault-resolve.mjs';
103
103
 
104
104
  export const DEFAULT_CONSOLE_TIMEOUT_MS = 30000;
105
105
 
@@ -524,12 +524,13 @@ export class AccountNotConfiguredError extends Error {
524
524
  }
525
525
  }
526
526
 
527
- /** True for the three local scope refusals this module throws. */
527
+ /** True for the local scope refusals: this module's three, plus the resolver's VaultResolutionError (VAULT_UNBOUND / VAULT_BINDING_BROKEN). */
528
528
  export function isLocalScopeRefusal(err) {
529
529
  return (
530
530
  err instanceof VaultNotRegisteredError
531
531
  || err instanceof AccountNotConfiguredError
532
532
  || err instanceof VaultNotPinnedError
533
+ || isVaultResolutionError(err)
533
534
  );
534
535
  }
535
536
 
@@ -552,6 +553,12 @@ export function isLocalScopeRefusal(err) {
552
553
  export function vaultIdHeaderFor(resolved, opts = {}) {
553
554
  const vaultId = typeof resolved?.vaultId === 'string' ? resolved.vaultId.trim() : '';
554
555
  if (!vaultId) return {};
556
+ // Nothing names a vault for this repo ([[HS-TYJA]]: no default "active"
557
+ // vault exists): refuse with the resolver's typed VAULT_UNBOUND /
558
+ // VAULT_BINDING_BROKEN, whose message carries the `hs vault bind` fix.
559
+ if (resolved?.source === 'unbound' && isVaultResolutionError(resolved.error)) {
560
+ throw resolved.error;
561
+ }
555
562
  if (vaultId === UNREGISTERED_VAULT_ID) {
556
563
  throw new VaultNotRegisteredError(resolved?.repoRootAbs, resolved?.source);
557
564
  }
@@ -624,7 +631,7 @@ export function getAccountIdHeader({ repoAccountId, ...opts } = {}) {
624
631
  * `consoleFetch` already handles (`ok: false`, `status: 0`), with the code on
625
632
  * `refusal` AND on `authProblem` so `consoleErrorCode` / the never-buffer
626
633
  * contract pick it up with no per-caller change. Pure.
627
- * @param {VaultNotRegisteredError | AccountNotConfiguredError | VaultNotPinnedError | AccountTokenMissingError} err
634
+ * @param {VaultNotRegisteredError | AccountNotConfiguredError | VaultNotPinnedError | AccountTokenMissingError | import("./vault-binding.mjs").VaultResolutionError} err
628
635
  * @param {string} url the request that was NOT sent
629
636
  */
630
637
  export function scopeRefusalResult(err, url) {
@@ -638,6 +645,11 @@ export function scopeRefusalResult(err, url) {
638
645
  refusal: err.code,
639
646
  authProblem: err.code,
640
647
  authMessage: err.message,
648
+ // VaultResolutionError ([[HS-TYJA]]): the exact repair command and the
649
+ // folder resolution walked from, so the tool refusal names the same fix
650
+ // `hs vault resolve` prints.
651
+ ...(typeof err.fix === 'string' && err.fix ? { fix: err.fix } : {}),
652
+ ...(typeof err.from === 'string' && err.from ? { from: err.from } : {}),
641
653
  };
642
654
  }
643
655
 
@@ -18,6 +18,10 @@ import { resolveCodeRoot } from '../../scripts/lib/shared-path-helpers.mjs';
18
18
  // elsewhere; same precedence plan-review.mjs's `resolveCodeAuditRoots` uses.
19
19
  const CHECKOUT_ROOT = resolveCodeRoot({ packageRoot: PACKAGE_ROOT });
20
20
 
21
+ // Where the cloud admin dev-mode setting lives. Used when an older
22
+ // scripts/lib/dev-mode.mjs does not export CLOUD_CONSOLE_URL itself.
23
+ export const CLOUD_DEV_MODE_URL = 'https://app.heyspex.com';
24
+
21
25
  export async function handleDevServerStatus(vaultRoot) {
22
26
  try {
23
27
  const local = join(CHECKOUT_ROOT, 'scripts', 'dev-server-status.mjs');
@@ -93,10 +97,13 @@ export async function handleDevMode(vaultRoot, args = {}) {
93
97
  }
94
98
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
95
99
  }
96
- // get (default): two-pass — resolve without the cloud rung to learn which
97
- // console to ask, then fold the fetched cloud setting into the resolution.
98
- const pre = mod.resolveDevMode();
99
- const cloud = await mod.fetchCloudDevMode({ consoleUrl: pre.consoleUrl });
100
+ // get (default): read the cloud admin setting from the CLOUD console —
101
+ // never the resolved console URL, which under a local override is
102
+ // localhost and can never answer for the cloud (staleAgainstCloud then
103
+ // silently read false) — and fold it into the resolution.
104
+ const cloud = await mod.fetchCloudDevMode({
105
+ consoleUrl: mod.CLOUD_CONSOLE_URL ?? CLOUD_DEV_MODE_URL,
106
+ });
100
107
  const resolved = mod.resolveDevMode({
101
108
  cloudSetting: cloud.ok ? cloud.setting : undefined,
102
109
  });
@@ -182,7 +182,7 @@ export async function handleExportEvents(args = {}) {
182
182
  let vault = null;
183
183
  try {
184
184
  vault = resolveActiveVault();
185
- if (vault?.specsDirAbs) body.expected_vault_root = vault.specsDirAbs;
185
+ if (vault?.specsDirAbs && vault.source !== 'unbound') body.expected_vault_root = vault.specsDirAbs;
186
186
  } catch { /* best-effort — the export still runs unguarded when unresolvable */ }
187
187
  const result = await consoleFetch('/api/control/export-events', { method: 'POST', body });
188
188
  if (!result.ok) return controlError(result, 'EXPORT_EVENTS_FAILED');
@@ -0,0 +1,42 @@
1
+ // heyspex_factory_run_status - the MCP face of the SpexCode run-state model (epic HS-P5NU, task HS-8AOE).
2
+ //
3
+ // The SAME snapshot `spexcode status --json` and GET /api/factory/run/status return: run id, lease,
4
+ // heartbeat, current seat, queue positions, stopped reason and the recent audit trail from
5
+ // dev-factory/state/seat-events.jsonl. The reader (dev-factory/lib/run-state-model.mjs) is a code-repo
6
+ // artifact, so it is imported lazily from the checkout (dev-factory-bridge pattern) - a missing
7
+ // dev-factory degrades to a typed error, never a crash of the MCP server.
8
+
9
+ import { existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { PACKAGE_ROOT } from './package-root.mjs';
13
+ import { resolveCodeRoot } from '../../scripts/lib/shared-path-helpers.mjs';
14
+
15
+ const CHECKOUT_ROOT = resolveCodeRoot({ packageRoot: PACKAGE_ROOT });
16
+
17
+ /**
18
+ * @param {object} [args] { trail?: number } how many recent audit events (default 8, max 200)
19
+ * @param {{ codeRoot?: string, isPidAlive?: (pid:number)=>boolean, now?: ()=>number }} [deps]
20
+ */
21
+ export async function handleFactoryRunStatus(args = {}, deps = {}) {
22
+ try {
23
+ const root = deps.codeRoot ?? CHECKOUT_ROOT;
24
+ const modPath = join(root, 'dev-factory', 'lib', 'run-state-model.mjs');
25
+ if (!existsSync(modPath)) {
26
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'FACTORY_RUN_STATUS_UNAVAILABLE', message: `dev-factory run-state model not found at ${modPath}` }, null, 2) }] };
27
+ }
28
+ const mod = await import(pathToFileURL(modPath).href);
29
+ const requested = Number(args?.trail);
30
+ const trail = Number.isFinite(requested) ? Math.min(Math.max(Math.trunc(requested), 0), 200) : 8;
31
+ const model = mod.readRunStateModel({
32
+ stateDir: join(root, 'dev-factory', 'state'),
33
+ queuePath: join(root, 'dev-factory', 'coo-queue.json'),
34
+ trail,
35
+ ...(deps.isPidAlive ? { isPidAlive: deps.isPidAlive } : {}),
36
+ ...(deps.now ? { now: deps.now } : {}),
37
+ });
38
+ return { content: [{ type: 'text', text: JSON.stringify(model, null, 2) }] };
39
+ } catch (err) {
40
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'FACTORY_RUN_STATUS_FAILED', message: String(err?.message ?? err) }, null, 2) }] };
41
+ }
42
+ }
@@ -163,7 +163,12 @@ export function createRemoteMcpHandler(deps) {
163
163
  // No listChanged: the remote surface is fixed (no progressive loader),
164
164
  // and JSON-response mode has no channel to push a notification anyway.
165
165
  capabilities: { tools: { listChanged: false } },
166
- serverInfo: { name: serverInfo?.name ?? 'heyspex', version: serverInfo?.version ?? '0.0.0-unknown' },
166
+ serverInfo: {
167
+ name: serverInfo?.name ?? 'heyspex',
168
+ ...(serverInfo?.title ? { title: serverInfo.title } : {}),
169
+ version: serverInfo?.version ?? '0.0.0-unknown',
170
+ ...(serverInfo?.icons ? { icons: serverInfo.icons } : {}),
171
+ },
167
172
  ...(instructions ? { instructions } : {}),
168
173
  };
169
174
  }
@@ -19,7 +19,9 @@ export function getConfig() {
19
19
  // vaultId + vaultSource ([[HS-M95C]]): the locally resolved identity, so a
20
20
  // cloud console request can be scoped to THIS repo's vault instead of the
21
21
  // account's active-vault pointer (see console-fetch.mjs `getVaultIdHeader`).
22
- const { repoRootAbs, vaultId, source: vaultSource } = getActiveConfig();
22
+ // vaultError: the typed VaultResolutionError when source is 'unbound'
23
+ // ([[HS-TYJA]]); vaultRoot is then only the launch folder, never a vault.
24
+ const { repoRootAbs, vaultId, source: vaultSource, error: vaultError = null } = getActiveConfig();
23
25
  const vaultRoot = repoRootAbs;
24
26
 
25
27
  // Per-vault default roadmap (env > the vault's own `type: roadmap` spec >
@@ -34,7 +36,7 @@ export function getConfig() {
34
36
  }
35
37
  const defaultRoadmapId = roadmap.id;
36
38
 
37
- return { vaultRoot, vaultId, vaultSource, defaultRoadmapId };
39
+ return { vaultRoot, vaultId, vaultSource, vaultError, defaultRoadmapId };
38
40
  }
39
41
 
40
42
  // Resolved on every call (no caching): the MCP process is spawned once at
@@ -0,0 +1,13 @@
1
+ /**
2
+ * MCP server display identity (Implementation.title / Implementation.icons,
3
+ * MCP spec 2025-06-18 / 2025-11-25). `name` stays the stable identifier
4
+ * ("heyspex": tool prefixes mcp__heyspex__* depend on it); only `title` and
5
+ * `icons` are for display. The icon is the 128px PNG of
6
+ * plugins/heyspex/assets/heyspex-glyph-128.png inlined as a data: URI (PNG is
7
+ * the format the spec requires clients to support); a test keeps the two in sync.
8
+ */
9
+ export const SERVER_TITLE = "HeySpex";
10
+ export const SERVER_ICON_DATA_URI = "data:image/png;base64,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";
11
+ export const SERVER_ICONS = Object.freeze([
12
+ Object.freeze({ src: SERVER_ICON_DATA_URI, mimeType: "image/png", sizes: Object.freeze(["128x128"]) }),
13
+ ]);
@@ -10,6 +10,31 @@ import { detectAgentIdentity, getConsoleUrl } from './mcp-config.mjs';
10
10
  import { resolveVaultWriteMode } from './read-path.mjs';
11
11
  import { deferredPayloadFromError, splitRawSpec, updateSpecViaConsole } from './cloud-vault-writes.mjs';
12
12
  import { writeFileSync } from 'node:fs';
13
+
14
+ /**
15
+ * Split a whole spec for the cloud PUT with a FULL YAML parser. splitRawSpec's
16
+ * dependency-free parseSimpleYaml only knows flat keys, string lists, block
17
+ * scalars and two special nested keys, so a protocol's `enforcement:` list of
18
+ * objects reached the console as `['rule: <id>']` and the pull wrote that
19
+ * flattened block back (seen 2026-10-15 on dev-factory-delegation-routing,
20
+ * HS-PGHA; it broke scripts/build-guardrails.mjs). js-yaml loads lazily so the
21
+ * MCP startup stays lean (see yaml-scalar.mjs); CORE_SCHEMA keeps unquoted
22
+ * dates as strings, matching parseSimpleYaml. A YAML error falls back to the
23
+ * simple parser, so no edit that worked before is refused now.
24
+ */
25
+ async function splitRawSpecFullYaml(raw) {
26
+ const text = String(raw ?? '');
27
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
28
+ if (!match) return splitRawSpec(text);
29
+ try {
30
+ const { default: yaml } = await import('js-yaml');
31
+ const parsed = yaml.load(match[1], { schema: yaml.CORE_SCHEMA });
32
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return splitRawSpec(text);
33
+ return { frontmatter: parsed, body: text.slice(match[0].length).trim() };
34
+ } catch {
35
+ return splitRawSpec(text);
36
+ }
37
+ }
13
38
  import { executeShipEpicCloudAware } from './plan-push.mjs';
14
39
  import { executeRegenTask, executeRegenTasksForEpic } from './regen-tasks.mjs';
15
40
  import { loadTask } from './vault-io.mjs';
@@ -137,7 +162,7 @@ export async function handleEditFile(args = {}, deps = {}) {
137
162
  let cloudWrite = null;
138
163
  if (writeMode === 'cloud') {
139
164
  const consoleUrl = deps.consoleUrl ?? getConsoleUrl();
140
- const { frontmatter, body } = splitRawSpec(args.content);
165
+ const { frontmatter, body } = await splitRawSpecFullYaml(args.content);
141
166
  const specId = typeof frontmatter?.id === 'string' ? frontmatter.id.trim() : '';
142
167
  if (!specId) {
143
168
  await releaseClaim();
@@ -146,9 +146,12 @@ export function installScopedConsoleEnv({
146
146
  export function mirrorValidatorDeps(repoRoot, root) {
147
147
  cpSync(join(repoRoot, 'scripts'), join(root, 'scripts'), { recursive: true });
148
148
 
149
- const vaultResolveRel = join('vendor', 'heyspex-mcp', 'vault-resolve.mjs');
150
149
  mkdirSync(join(root, 'vendor', 'heyspex-mcp'), { recursive: true });
151
- cpSync(join(repoRoot, vaultResolveRel), join(root, vaultResolveRel));
150
+ // vault-resolve.mjs imports vault-binding.mjs (the repo binding, HS-TYJA).
151
+ for (const file of ['vault-resolve.mjs', 'vault-binding.mjs']) {
152
+ const rel = join('vendor', 'heyspex-mcp', file);
153
+ cpSync(join(repoRoot, rel), join(root, rel));
154
+ }
152
155
 
153
156
  const indexerRel = join('spex-console', 'src', 'lib', 'indexer');
154
157
  mkdirSync(join(root, indexerRel), { recursive: true });
@@ -2210,6 +2210,18 @@ export const TOOLS = [
2210
2210
  additionalProperties: false,
2211
2211
  },
2212
2212
  },
2213
+ {
2214
+ name: 'heyspex_factory_run_status',
2215
+ description:
2216
+ 'Status of the SpexCode orchestrator run (`spexcode run` / `hs factory run`), read from dev-factory/state: the SAME snapshot `spexcode status --json` and GET /api/factory/run/status return. Returns { schema: "spex.run-state/1", runId, live, front, lease { held, pid, acquiredAt, runId }, heartbeat { pid, beatAt, fresh, runId }, current (the COO seat: epic, milestone, pid, alive), queue [{ position, milestone, branch }], failures, stoppedReason, trail { file, total, events[] } } where trail.events are the last N typed seat events (the audit file seat-events.jsonl). Local disk state, read-only; deferred (load via heyspex_load_tools). Use `trail` to choose how many recent events (default 8, max 200).',
2217
+ inputSchema: {
2218
+ type: 'object',
2219
+ properties: {
2220
+ trail: { type: 'number', description: 'How many recent seat events to include (default 8, max 200; 0 = none).' },
2221
+ },
2222
+ additionalProperties: false,
2223
+ },
2224
+ },
2213
2225
  {
2214
2226
  name: 'heyspex_dev_mode',
2215
2227
  description:
@@ -28,6 +28,7 @@ import { handleGetProcedureViaTier, handleListProceduresViaTier, handleRunPrefli
28
28
  import { handleListRecipes, handleStartRecipe } from './recipe-handlers.mjs';
29
29
  import { handleFleetQueueClaim, handleFleetQueueComplete, handleFleetQueueList, handleFleetQueueSubmit } from './fleet-handlers.mjs';
30
30
  import { handleDevMode, handleDevServerStatus } from './dev-tools.mjs';
31
+ import { handleFactoryRunStatus } from './factory-run-status.mjs';
31
32
  import { wrapDesignResult } from './tool-helpers.mjs';
32
33
  import { executeSettingsGet, executeSettingsList, executeSettingsSet } from './settings-tools.mjs';
33
34
  import { executeFirecrawlResearch, executeResolveResearchPlan } from './research-tools.mjs';
@@ -38,6 +39,7 @@ import { applyToolBudget } from './response-budget.mjs';
38
39
  import { resolveDevMode } from '../../scripts/lib/dev-mode.mjs';
39
40
  import { dirname, join } from 'node:path';
40
41
  import { MODULE_DIR } from './package-root.mjs';
42
+ import { SERVER_TITLE, SERVER_ICONS } from './server-identity.mjs';
41
43
 
42
44
  export const SERVER_NAME = 'heyspex';
43
45
 
@@ -143,7 +145,10 @@ export async function handleInitialize(params) {
143
145
  instructions,
144
146
  serverInfo: {
145
147
  name: SERVER_NAME,
148
+ // Display identity (MCP Implementation.title / .icons); `name` is the stable id.
149
+ title: SERVER_TITLE,
146
150
  version: SERVER_VERSION,
151
+ icons: SERVER_ICONS,
147
152
  },
148
153
  };
149
154
  }
@@ -339,6 +344,8 @@ function dispatchHeySpexToolUnbudgeted(name, args, vaultRoot, config) {
339
344
  return handleDevServerStatus(vaultRoot);
340
345
  case 'heyspex_dev_mode':
341
346
  return handleDevMode(vaultRoot, args);
347
+ case 'heyspex_factory_run_status':
348
+ return handleFactoryRunStatus(args);
342
349
  case 'heyspex_settings_list':
343
350
  return wrapDesignResult(executeSettingsList(vaultRoot, args));
344
351
  case 'heyspex_settings_get':
@@ -390,8 +397,14 @@ function dispatchHeySpexToolUnbudgeted(name, args, vaultRoot, config) {
390
397
  * BYPASSES this gate on purpose — it calls the `*ViaTier` helpers directly —
391
398
  * so it proves the handlers, not the gate; keep both.
392
399
  */
393
- export function vaultRootGateVerdict(vaultRoot, { env = process.env } = {}) {
394
- if (typeof vaultRoot === 'string' && vaultRoot && existsSync(vaultRoot)) return null;
400
+ export function vaultRootGateVerdict(vaultRoot, { env = process.env, vaultSource = null, vaultError = null } = {}) {
401
+ // Unbound ([[HS-TYJA]]): `vaultRoot` is only the launch folder, never a
402
+ // vault, so its existing on disk proves nothing. Treat it as absent: the
403
+ // disk tier refuses with the resolver's typed code + fix; the neon tier
404
+ // proceeds and every vault-scoped console call is refused per call
405
+ // (console-fetch.mjs) while vault-less calls still work.
406
+ const unbound = vaultSource === 'unbound';
407
+ if (!unbound && typeof vaultRoot === 'string' && vaultRoot && existsSync(vaultRoot)) return null;
395
408
  let readTier = 'disk';
396
409
  try {
397
410
  readTier = resolveDevMode({ env }).readTier;
@@ -399,6 +412,14 @@ export function vaultRootGateVerdict(vaultRoot, { env = process.env } = {}) {
399
412
  readTier = 'disk';
400
413
  }
401
414
  if (readTier === 'neon') return null;
415
+ if (unbound && vaultError && typeof vaultError.code === 'string') {
416
+ return {
417
+ error: vaultError.code,
418
+ message: vaultError.message,
419
+ ...(vaultError.fix ? { fix: vaultError.fix } : {}),
420
+ ...(vaultError.from ? { from: vaultError.from } : {}),
421
+ };
422
+ }
402
423
  return { error: 'VAULT_NOT_FOUND', message: `Vault root not found: ${vaultRoot}` };
403
424
  }
404
425
 
@@ -407,7 +428,7 @@ export function handleToolsCall(params) {
407
428
  const config = getConfig();
408
429
  const vaultRoot = config.vaultRoot;
409
430
 
410
- const gate = vaultRootGateVerdict(vaultRoot);
431
+ const gate = vaultRootGateVerdict(vaultRoot, { vaultSource: config.vaultSource, vaultError: config.vaultError });
411
432
  if (gate) {
412
433
  return {
413
434
  isError: true,
@@ -444,26 +465,8 @@ export function handleToolsCall(params) {
444
465
  // PreToolUse hook: hooks fail open and `.heyspex/mode.json` can disarm them,
445
466
  // whereas this runs on every call in every session. `refuse` returns instead
446
467
  // of dispatching; `warn` lets the call through and rides along on the result.
447
- const preflightVerdict = evaluatePreflightGate(
448
- vaultRoot,
449
- name,
450
- args,
451
- detectAgentIdentity(args.agent, args.session_id).sessionId,
452
- );
453
- if (preflightVerdict && preflightVerdict.level === 'refuse') {
454
- return {
455
- isError: true,
456
- content: [{
457
- type: 'text',
458
- text: JSON.stringify({
459
- error: preflightVerdict.error,
460
- message: preflightVerdict.message,
461
- required_intents: preflightVerdict.intents,
462
- tool: preflightVerdict.tool,
463
- }, null, 2),
464
- }],
465
- };
466
- }
468
+ const preflightVerdict = preflightVerdictFor(vaultRoot, name, args);
469
+ if (preflightVerdict && preflightVerdict.level === 'refuse') return preflightRefusal(preflightVerdict);
467
470
 
468
471
  // Capture-obligation backstop ([[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]]): holds on surfaces where
469
472
  // no hook loads. Fail open; break-glass HEYSPEX_OBLIGATION_BACKSTOP_OFF=1.
@@ -485,6 +488,43 @@ export function handleToolsCall(params) {
485
488
  : finalized;
486
489
  }
487
490
 
491
+ /** The preflight proof-of-use verdict for one call — the SAME session id the marker write uses. */
492
+ export function preflightVerdictFor(vaultRoot, name, args = {}) {
493
+ return evaluatePreflightGate(vaultRoot, name, args, detectAgentIdentity(args.agent, args.session_id).sessionId);
494
+ }
495
+
496
+ /** The refusal result a `refuse`-level verdict returns instead of dispatching. */
497
+ export function preflightRefusal(verdict) {
498
+ return {
499
+ isError: true,
500
+ content: [{
501
+ type: 'text',
502
+ text: JSON.stringify({
503
+ error: verdict.error,
504
+ message: verdict.message,
505
+ required_intents: verdict.intents,
506
+ tool: verdict.tool,
507
+ }, null, 2),
508
+ }],
509
+ };
510
+ }
511
+
512
+ /**
513
+ * The in-process seat path (spexharness tool-catalog createMcpInvoker) calls this, not the bare
514
+ * dispatchHeySpexTool, so heyspex_claim_spec / heyspex_submit_for_review refuse with
515
+ * PREFLIGHT_NOT_RUN there exactly as over stdio (B4, 2026-10-14). The bare function stays
516
+ * ungated for the console's POST /api/mcp, whose serverless instances cannot share markers.
517
+ */
518
+ export function dispatchHeySpexToolGated(name, args = {}, vaultRoot, config, { dispatch = dispatchHeySpexTool } = {}) {
519
+ const verdict = preflightVerdictFor(vaultRoot, name, args);
520
+ if (verdict && verdict.level === 'refuse') return preflightRefusal(verdict);
521
+ const result = dispatch(name, args, vaultRoot, config);
522
+ if (!verdict || verdict.level !== 'warn') return result;
523
+ return result && typeof result.then === 'function'
524
+ ? result.then((r) => attachPreflightWarning(r, verdict))
525
+ : attachPreflightWarning(result, verdict);
526
+ }
527
+
488
528
  /**
489
529
  * Ride a warn-level preflight verdict along on an otherwise successful result.
490
530
  * Best-effort by construction — a result whose text is not JSON is left exactly
@@ -0,0 +1,82 @@
1
+ /**
2
+ * usable-dir-env.mjs - is an env var that is supposed to name a directory
3
+ * actually usable as one? (HS-C0NW)
4
+ *
5
+ * Some hosts never expand `${CLAUDE_PROJECT_DIR}`-style placeholders in an MCP
6
+ * manifest's `env` block (GitHub Copilot CLI expands `${CLAUDE_PLUGIN_ROOT}` /
7
+ * `${PLUGIN_ROOT}` but not the project dir), so the server receives the
8
+ * literal text. Resolved relative to the plugin dir it became a path like
9
+ * `<pkg>/plugins/heyspex/${CLAUDE_PROJECT_DIR}`, which then got a `.heyspex/`
10
+ * build record mkdir'd into it. A value like that is "unset", not a path.
11
+ *
12
+ * Pure and dependency-free (node:fs / node:path only) so the plugin launcher
13
+ * (plugins/heyspex/bin/launch-mcp.mjs) and the vault resolver share one rule.
14
+ */
15
+ import { statSync } from 'node:fs';
16
+ import { isAbsolute } from 'node:path';
17
+
18
+ /** Env vars a host may set to name the project / vault folder. */
19
+ export const DIR_ENV_KEYS = ['HEYSPEX_VAULT_ROOT', 'CLAUDE_PROJECT_DIR', 'CURSOR_PROJECT_DIR'];
20
+
21
+ /** `${VAR}`, `${VAR:-x}`, `$VAR`, `%VAR%` anywhere in the value. */
22
+ const PLACEHOLDER = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z_][A-Za-z0-9_]*%/;
23
+
24
+ /** True when `value` still contains an unexpanded variable reference. */
25
+ export function hasUnexpandedPlaceholder(value) {
26
+ return typeof value === 'string' && PLACEHOLDER.test(value);
27
+ }
28
+
29
+ function isExistingDir(p) {
30
+ try {
31
+ return statSync(p).isDirectory();
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Why `value` is not a usable directory, or null when it is.
39
+ * Reasons: 'empty' | 'placeholder' | 'relative' | 'missing'.
40
+ * `requireExisting: false` skips the on-disk check (a registered vault root may
41
+ * legitimately have no local checkout).
42
+ */
43
+ export function unusableDirReason(value, { requireExisting = true, isDir = isExistingDir } = {}) {
44
+ if (typeof value !== 'string' || !value.trim()) return 'empty';
45
+ const v = value.trim();
46
+ if (hasUnexpandedPlaceholder(v)) return 'placeholder';
47
+ if (!isAbsolute(v)) return 'relative';
48
+ if (requireExisting && !isDir(v)) return 'missing';
49
+ return null;
50
+ }
51
+
52
+ /** Is `value` an existing absolute directory with no leftover placeholder? */
53
+ export function isUsableDirValue(value, opts) {
54
+ return unusableDirReason(value, opts) === null;
55
+ }
56
+
57
+ /**
58
+ * Return a COPY of `env` with every unusable DIR_ENV_KEYS entry removed, so
59
+ * resolution falls through to the normal cwd walk. `onDrop(key, value, reason)`
60
+ * is called per removal. `requireExisting` may be a boolean or a per-key map.
61
+ */
62
+ export function withoutUnusableDirEnv(env, { keys = DIR_ENV_KEYS, requireExisting = true, isDir, onDrop } = {}) {
63
+ const out = { ...env };
64
+ for (const key of keys) {
65
+ if (out[key] === undefined) continue;
66
+ const need = typeof requireExisting === 'object' ? requireExisting[key] !== false : requireExisting;
67
+ const reason = unusableDirReason(out[key], { requireExisting: need, ...(isDir ? { isDir } : {}) });
68
+ if (reason) {
69
+ onDrop?.(key, out[key], reason);
70
+ delete out[key];
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** In-place variant for `process.env` (the launcher hands its env to the server). */
77
+ export function scrubUnusableDirEnv(env = process.env, opts = {}) {
78
+ const dropped = [];
79
+ withoutUnusableDirEnv(env, { ...opts, onDrop: (k, v, r) => dropped.push({ key: k, value: v, reason: r }) });
80
+ for (const { key } of dropped) delete env[key];
81
+ return dropped;
82
+ }