heyspex 0.2.1 → 0.2.3

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 (70) hide show
  1. package/README.md +10 -0
  2. package/bin/hs-vault-binding.mjs +157 -0
  3. package/bin/hs.mjs +99 -81
  4. package/bin/spexcode.mjs +91 -0
  5. package/dev-factory/engine/worktree.mjs +36 -7
  6. package/dev-factory/lib/epic-detect.mjs +2 -2
  7. package/dev-factory/lib/fleet-readiness.mjs +3 -5
  8. package/dev-factory/lib/fleet-state.mjs +2 -2
  9. package/dev-factory/lib/sufficiency-score.mjs +3 -5
  10. package/dist/cli/hs-check.mjs +76 -46
  11. package/dist/cli/hs-claim.mjs +63 -33
  12. package/dist/cli/hs-connect.mjs +520 -214
  13. package/dist/cli/hs-defer.mjs +161 -19
  14. package/dist/cli/hs-dev.mjs +11 -5
  15. package/dist/cli/hs-env.mjs +187 -161
  16. package/dist/cli/hs-index-rebuild.mjs +123 -78
  17. package/dist/cli/hs-index-reconcile.mjs +203 -107
  18. package/dist/cli/hs-init.mjs +426 -179
  19. package/dist/cli/hs-install.mjs +759 -437
  20. package/dist/cli/hs-join.mjs +590 -383
  21. package/dist/cli/hs-vault-pull.mjs +359 -254
  22. package/dist/cli/hs-vault-push.mjs +151 -113
  23. package/dist/cli/hs-vault.mjs +271 -104
  24. package/dist/cli/hs-workspace.mjs +90 -57
  25. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
  26. package/docs/copilot-plugin-install.md +189 -162
  27. package/docs/driving-heyspex-from-copilot.md +174 -165
  28. package/docs/mcp-client-config.md +93 -25
  29. package/package.json +255 -249
  30. package/plugins/heyspex/.mcp.json +3 -4
  31. package/plugins/heyspex/README.md +323 -309
  32. package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
  33. package/plugins/heyspex/mcp.json +12 -13
  34. package/scripts/check-mcp-freshness.mjs +6 -4
  35. package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
  36. package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
  37. package/scripts/claude-hooks/research-method-guard.mjs +2 -2
  38. package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
  39. package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
  40. package/scripts/claude-statusline/statusline-command.mjs +1058 -0
  41. package/scripts/dev-server-status.mjs +3 -2
  42. package/scripts/dev-server-stop.mjs +3 -2
  43. package/scripts/hs-capture-cli.mjs +2 -2
  44. package/scripts/hs-settings.mjs +2 -2
  45. package/scripts/hs-view.mjs +3 -2
  46. package/scripts/install-copilot-hooks.mjs +318 -0
  47. package/scripts/lib/copilot-cli.mjs +108 -0
  48. package/scripts/lib/customer-kit.mjs +223 -30
  49. package/scripts/lib/dev-mode.mjs +28 -2
  50. package/scripts/lib/shared-path-helpers.mjs +32 -0
  51. package/scripts/lib/standalone-doctor.mjs +131 -32
  52. package/scripts/wip-ledger-health.mjs +3 -2
  53. package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
  54. package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
  55. package/spexharness/src/intent-directive.mjs +839 -626
  56. package/spexharness/src/rule-table.mjs +1 -1
  57. package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
  58. package/vendor/heyspex-mcp/cli.mjs +20 -4
  59. package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
  60. package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
  61. package/vendor/heyspex-mcp/events-tools.mjs +1 -1
  62. package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
  63. package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
  64. package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
  65. package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
  66. package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
  67. package/vendor/heyspex-mcp/tool-dispatch.mjs +59 -23
  68. package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
  69. package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
  70. 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
+ }
@@ -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
@@ -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';
@@ -339,6 +340,8 @@ function dispatchHeySpexToolUnbudgeted(name, args, vaultRoot, config) {
339
340
  return handleDevServerStatus(vaultRoot);
340
341
  case 'heyspex_dev_mode':
341
342
  return handleDevMode(vaultRoot, args);
343
+ case 'heyspex_factory_run_status':
344
+ return handleFactoryRunStatus(args);
342
345
  case 'heyspex_settings_list':
343
346
  return wrapDesignResult(executeSettingsList(vaultRoot, args));
344
347
  case 'heyspex_settings_get':
@@ -390,8 +393,14 @@ function dispatchHeySpexToolUnbudgeted(name, args, vaultRoot, config) {
390
393
  * BYPASSES this gate on purpose — it calls the `*ViaTier` helpers directly —
391
394
  * so it proves the handlers, not the gate; keep both.
392
395
  */
393
- export function vaultRootGateVerdict(vaultRoot, { env = process.env } = {}) {
394
- if (typeof vaultRoot === 'string' && vaultRoot && existsSync(vaultRoot)) return null;
396
+ export function vaultRootGateVerdict(vaultRoot, { env = process.env, vaultSource = null, vaultError = null } = {}) {
397
+ // Unbound ([[HS-TYJA]]): `vaultRoot` is only the launch folder, never a
398
+ // vault, so its existing on disk proves nothing. Treat it as absent: the
399
+ // disk tier refuses with the resolver's typed code + fix; the neon tier
400
+ // proceeds and every vault-scoped console call is refused per call
401
+ // (console-fetch.mjs) while vault-less calls still work.
402
+ const unbound = vaultSource === 'unbound';
403
+ if (!unbound && typeof vaultRoot === 'string' && vaultRoot && existsSync(vaultRoot)) return null;
395
404
  let readTier = 'disk';
396
405
  try {
397
406
  readTier = resolveDevMode({ env }).readTier;
@@ -399,6 +408,14 @@ export function vaultRootGateVerdict(vaultRoot, { env = process.env } = {}) {
399
408
  readTier = 'disk';
400
409
  }
401
410
  if (readTier === 'neon') return null;
411
+ if (unbound && vaultError && typeof vaultError.code === 'string') {
412
+ return {
413
+ error: vaultError.code,
414
+ message: vaultError.message,
415
+ ...(vaultError.fix ? { fix: vaultError.fix } : {}),
416
+ ...(vaultError.from ? { from: vaultError.from } : {}),
417
+ };
418
+ }
402
419
  return { error: 'VAULT_NOT_FOUND', message: `Vault root not found: ${vaultRoot}` };
403
420
  }
404
421
 
@@ -407,7 +424,7 @@ export function handleToolsCall(params) {
407
424
  const config = getConfig();
408
425
  const vaultRoot = config.vaultRoot;
409
426
 
410
- const gate = vaultRootGateVerdict(vaultRoot);
427
+ const gate = vaultRootGateVerdict(vaultRoot, { vaultSource: config.vaultSource, vaultError: config.vaultError });
411
428
  if (gate) {
412
429
  return {
413
430
  isError: true,
@@ -444,26 +461,8 @@ export function handleToolsCall(params) {
444
461
  // PreToolUse hook: hooks fail open and `.heyspex/mode.json` can disarm them,
445
462
  // whereas this runs on every call in every session. `refuse` returns instead
446
463
  // 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
- }
464
+ const preflightVerdict = preflightVerdictFor(vaultRoot, name, args);
465
+ if (preflightVerdict && preflightVerdict.level === 'refuse') return preflightRefusal(preflightVerdict);
467
466
 
468
467
  // Capture-obligation backstop ([[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]]): holds on surfaces where
469
468
  // no hook loads. Fail open; break-glass HEYSPEX_OBLIGATION_BACKSTOP_OFF=1.
@@ -485,6 +484,43 @@ export function handleToolsCall(params) {
485
484
  : finalized;
486
485
  }
487
486
 
487
+ /** The preflight proof-of-use verdict for one call — the SAME session id the marker write uses. */
488
+ export function preflightVerdictFor(vaultRoot, name, args = {}) {
489
+ return evaluatePreflightGate(vaultRoot, name, args, detectAgentIdentity(args.agent, args.session_id).sessionId);
490
+ }
491
+
492
+ /** The refusal result a `refuse`-level verdict returns instead of dispatching. */
493
+ export function preflightRefusal(verdict) {
494
+ return {
495
+ isError: true,
496
+ content: [{
497
+ type: 'text',
498
+ text: JSON.stringify({
499
+ error: verdict.error,
500
+ message: verdict.message,
501
+ required_intents: verdict.intents,
502
+ tool: verdict.tool,
503
+ }, null, 2),
504
+ }],
505
+ };
506
+ }
507
+
508
+ /**
509
+ * The in-process seat path (spexharness tool-catalog createMcpInvoker) calls this, not the bare
510
+ * dispatchHeySpexTool, so heyspex_claim_spec / heyspex_submit_for_review refuse with
511
+ * PREFLIGHT_NOT_RUN there exactly as over stdio (B4, 2026-10-14). The bare function stays
512
+ * ungated for the console's POST /api/mcp, whose serverless instances cannot share markers.
513
+ */
514
+ export function dispatchHeySpexToolGated(name, args = {}, vaultRoot, config, { dispatch = dispatchHeySpexTool } = {}) {
515
+ const verdict = preflightVerdictFor(vaultRoot, name, args);
516
+ if (verdict && verdict.level === 'refuse') return preflightRefusal(verdict);
517
+ const result = dispatch(name, args, vaultRoot, config);
518
+ if (!verdict || verdict.level !== 'warn') return result;
519
+ return result && typeof result.then === 'function'
520
+ ? result.then((r) => attachPreflightWarning(r, verdict))
521
+ : attachPreflightWarning(result, verdict);
522
+ }
523
+
488
524
  /**
489
525
  * Ride a warn-level preflight verdict along on an otherwise successful result.
490
526
  * 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
+ }