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
@@ -59,7 +59,28 @@
59
59
  * The endpoints in {@link VAULT_LESS_ENDPOINTS} (pairing, session identity,
60
60
  * creating/listing vaults and workspaces, dev-mode) are exempt: headers are
61
61
  * attached when they resolve, and nothing refuses when they do not.
62
+ *
63
+ * NO PIN, NO POINTER (diagnose "unpinned repo silently follows the
64
+ * account-wide active-vault pointer"; client half of
65
+ * [[HS-W3JF-PEGM-DIAG-one-account-wide-active-vault-pointer]] fix 2, "fail
66
+ * closed on a missing binding"): a repo that names no vault — no heyspex.yaml,
67
+ * no specs/, no satellite pin in its MCP manifest, no HEYSPEX_VAULT_ROOT/DIR —
68
+ * resolves at tier 4 to the registry's `active_vault_id` (`source:
69
+ * 'account-pointer'`), which mirrors whatever the owner last clicked in the
70
+ * console's vault switcher. On 2026-10-10 a session in the code repo resolved
71
+ * that way to a throwaway test vault, and every claim/file/submit would have
72
+ * landed there silently. So a pointer-sourced result is a THIRD local
73
+ * refusal, `VAULT_NOT_PINNED` ({@link VaultNotPinnedError}): the message names
74
+ * the vault the pointer would have used and the runnable pin (`hs install
75
+ * --attach-vault <id> --target <repo>` for a code repo, `hs connect` for a
76
+ * vault). The resolver's tier ORDER is untouched (decision memory
77
+ * `vault-resolver-precedence`) — only what the transport does with the
78
+ * result changes. Deliberate pointer use is the env hatch
79
+ * `HEYSPEX_ALLOW_POINTER_VAULT=1` ({@link pointerVaultAllowed}), which
80
+ * restores the old behaviour and announces the vault once per process on
81
+ * stderr ({@link announcePointerVaultOnce}).
62
82
  */
83
+ import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
63
84
  import { readFileSync } from 'node:fs';
64
85
  import { homedir } from 'node:os';
65
86
  import { join } from 'node:path';
@@ -77,7 +98,8 @@ export { isVaultLessEndpoint, VAULT_LESS_ENDPOINTS };
77
98
  // checkout `npx hs` installs the unrelated public package hs@2.0.1
78
99
  // ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
79
100
  import { hsCommand } from '../../scripts/lib/hs-command.mjs';
80
- import { getActiveConfig, UNREGISTERED_VAULT_ID } from './vault-resolve.mjs';
101
+ import { maybeDrainOnFirstSuccess } from './write-outbox.mjs';
102
+ import { getActiveConfig, registryAccountIdForRoot, UNREGISTERED_VAULT_ID } from './vault-resolve.mjs';
81
103
 
82
104
  export const DEFAULT_CONSOLE_TIMEOUT_MS = 30000;
83
105
 
@@ -224,14 +246,19 @@ export function readMarkerAccount(repoRootAbs, { readFile } = {}) {
224
246
  * applies, unchanged).
225
247
  * @returns {string | null}
226
248
  */
227
- export function getRepoAccountId() {
249
+ export function getRepoAccountId({ homeDir } = {}) {
228
250
  let resolved;
229
251
  try {
230
252
  resolved = getActiveConfig();
231
253
  } catch {
232
254
  return null;
233
255
  }
234
- return readMarkerAccount(resolved?.repoRootAbs);
256
+ // Machine-local registry row, NOT the committed marker: a legacy
257
+ // `account:` line in heyspex.yaml is ignored (owner ruling 2026-10-16).
258
+ return registryAccountIdForRoot(
259
+ resolved?.repoRootAbs,
260
+ homeDir ? { registryDir: join(homeDir, '.heyspex') } : {},
261
+ );
235
262
  }
236
263
 
237
264
  /**
@@ -251,8 +278,8 @@ export function getRepoAccountId() {
251
278
  * credentials file is just "no token".
252
279
  * @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, repoAccountId?: string | null }} [opts]
253
280
  */
254
- export function getAuthHeader({ env = process.env, homeDir = homedir(), repoAccountId } = {}) {
255
- const accountId = repoAccountId === undefined ? getRepoAccountId() : repoAccountId;
281
+ export function getAuthHeader({ env = process.env, homeDir = heyspexHome(), repoAccountId } = {}) {
282
+ const accountId = repoAccountId === undefined ? getRepoAccountId({ homeDir }) : repoAccountId;
256
283
  const { token } = readBearer({ env, homeDir, accountId });
257
284
  if (!token && accountId) throw new AccountTokenMissingError(accountId);
258
285
  return token ? { Authorization: `Bearer ${token}` } : {};
@@ -266,14 +293,19 @@ export function getAuthHeader({ env = process.env, homeDir = homedir(), repoAcco
266
293
  *
267
294
  * Contract (agreed with the console side, revised by
268
295
  * [[HS-MVG4-WW9R-TASK-mcp-and-hs-name-account-vault-on-every]]): send
269
- * `x-heyspex-vault-id: <id>` for EVERY registered id the resolver produced,
270
- * whatever its source — a repo-derived tier (`explicit`, `env-vault-root`,
271
- * `env-vault-dir`, `satellite-pin`, `cwd-heuristic`) AND the registry's active
272
- * pointer (`account-pointer`). The pointer used to send nothing so the cloud
273
- * console could fall back to ITS pointer; the two pointers drift, and a silent
274
- * fallback is exactly what served repo A's session from vault B. An
275
- * UNREGISTERED root is not a "no header" case either: it throws
276
- * {@link VaultNotRegisteredError} so the transport refuses before any fetch.
296
+ * `x-heyspex-vault-id: <id>` for EVERY registered id a REPO-DERIVED tier
297
+ * produced (`explicit`, `env-vault-root`, `env-vault-dir`, `satellite-pin`,
298
+ * `cwd-marker`, `marker-registry-mismatch`, `cwd-heuristic`). The registry's
299
+ * active pointer (`account-pointer`) used to send nothing so the cloud console
300
+ * could fall back to ITS pointer; HS-MVG4 then put the pointer's id on the
301
+ * wire too, because the two pointers drift and a silent fallback is exactly
302
+ * what served repo A's session from vault B. Both shapes share the same
303
+ * defect — the repo never said which vault it meant — so a pointer-sourced
304
+ * result is now REFUSED locally ({@link VaultNotPinnedError}) unless the
305
+ * `HEYSPEX_ALLOW_POINTER_VAULT=1` hatch is set, in which case the HS-MVG4
306
+ * behaviour (pointer id on the wire) applies. An UNREGISTERED root is not a
307
+ * "no header" case either: it throws {@link VaultNotRegisteredError} so the
308
+ * transport refuses before any fetch.
277
309
  */
278
310
  export const VAULT_ID_HEADER = 'x-heyspex-vault-id';
279
311
  /** The account the client believes it acts as; the console refuses a mismatch with 409 `ACCOUNT_MISMATCH`. */
@@ -283,6 +315,106 @@ export const ACCOUNT_ID_HEADER = 'x-heyspex-account-id';
283
315
  export const VAULT_NOT_REGISTERED = 'VAULT_NOT_REGISTERED';
284
316
  /** Local refusal: this machine has no configured account (`hs login` never ran). */
285
317
  export const ACCOUNT_NOT_CONFIGURED = 'ACCOUNT_NOT_CONFIGURED';
318
+ /** Local refusal: the repo names no vault, so only the account-wide pointer answered. */
319
+ export const VAULT_NOT_PINNED = 'VAULT_NOT_PINNED';
320
+
321
+ /** The resolver tier that reads the registry's account-wide `active_vault_id` (vault-resolve.mjs tier 4). */
322
+ export const POINTER_VAULT_SOURCE = 'account-pointer';
323
+ /** Env hatch: `1`/`true` lets a pointer-sourced vault go on the wire (old behaviour), announced once per process. */
324
+ export const ALLOW_POINTER_VAULT_ENV = 'HEYSPEX_ALLOW_POINTER_VAULT';
325
+
326
+ /**
327
+ * Is the deliberate-pointer hatch set? Pure over `env`.
328
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
329
+ */
330
+ export function pointerVaultAllowed(env = process.env) {
331
+ const raw = env?.[ALLOW_POINTER_VAULT_ENV];
332
+ if (typeof raw !== 'string') return false;
333
+ const v = raw.trim().toLowerCase();
334
+ return v === '1' || v === 'true' || v === 'yes';
335
+ }
336
+
337
+ /**
338
+ * Thrown by {@link vaultIdHeaderFor} / {@link getVaultIdHeader} when the
339
+ * resolver answered from the account-wide pointer (`source:
340
+ * 'account-pointer'`) and the hatch is off: the repo the session runs in
341
+ * names no vault, so the only thing that chose one was whatever the owner
342
+ * last selected in the console. `code` is stable for machine matching; the
343
+ * message names the vault the pointer WOULD have used (id + root), the repo
344
+ * that needs a pin, and the runnable pin for each repo kind.
345
+ */
346
+ export class VaultNotPinnedError extends Error {
347
+ /**
348
+ * @param {{ vaultId?: string | null, repoRootAbs?: string | null, source?: string | null } | null | undefined} resolved
349
+ * the pointer-sourced resolver result (its `repoRootAbs` is the POINTER vault's root)
350
+ * @param {{ cwd?: string | null }} [detail] `cwd`: the unpinned repo the session runs in
351
+ */
352
+ constructor(resolved, { cwd = null } = {}) {
353
+ const clean = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
354
+ const vaultId = clean(resolved?.vaultId);
355
+ const vaultRoot = clean(resolved?.repoRootAbs);
356
+ const repo = clean(cwd);
357
+ const here = repo ? ` (${repo})` : '';
358
+ const wouldUse = vaultId
359
+ ? `vault ${vaultId}${vaultRoot ? ` (${vaultRoot})` : ''}`
360
+ : 'an unknown vault';
361
+ // Both commands render through `hsCommand` so they run on the machine
362
+ // reading them ([[HS-I6HT]]); `<repo>` stands in when the cwd is unknown.
363
+ const attach = hsCommand(`install --attach-vault ${vaultId ?? '<vault-id>'} --target ${repo ?? '<repo>'}`);
364
+ const connect = hsCommand('connect');
365
+ super(
366
+ `This repo${here} names no HeySpex vault — no heyspex.yaml, no specs/ dir, and no HEYSPEX_VAULT_ROOT pin in its .mcp.json — `
367
+ + 'so the console call was refused before it was sent. Without a pin the session would silently follow the '
368
+ + 'account-wide active-vault pointer (whatever vault was last selected in the console or by `hs vault use`), '
369
+ + `which right now names ${wouldUse}. `
370
+ + 'Pin a vault, then restart the MCP server: '
371
+ + `for a code repo run \`${attach}\`; for a vault repo run \`${connect}\` inside it. `
372
+ + `To follow the pointer on purpose, start the server with ${ALLOW_POINTER_VAULT_ENV}=1. Nothing was sent.`,
373
+ );
374
+ this.name = 'VaultNotPinnedError';
375
+ this.code = VAULT_NOT_PINNED;
376
+ this.vaultId = vaultId;
377
+ this.vaultRootAbs = vaultRoot;
378
+ this.repoRootAbs = repo;
379
+ this.source = clean(resolved?.source);
380
+ }
381
+ }
382
+
383
+ /** One line for stderr naming the pointer vault the hatch admitted. Pure. */
384
+ export function pointerVaultNotice(resolved) {
385
+ const id = typeof resolved?.vaultId === 'string' && resolved.vaultId.trim() ? resolved.vaultId.trim() : 'unknown';
386
+ const root = typeof resolved?.repoRootAbs === 'string' && resolved.repoRootAbs.trim() ? ` (${resolved.repoRootAbs.trim()})` : '';
387
+ return `[heyspex-mcp] ${ALLOW_POINTER_VAULT_ENV} is set: this repo names no vault, so console calls follow the account-wide active-vault pointer — vault ${id}${root}. Every claim/file/submit this session lands there.\n`;
388
+ }
389
+
390
+ let _pointerVaultAnnounced = false;
391
+
392
+ /**
393
+ * Write {@link pointerVaultNotice} to stderr ONCE per process, and only when
394
+ * `resolved` is pointer-sourced AND the hatch is on. Called at stdio-server
395
+ * start (cli.mjs) and lazily by {@link vaultIdHeaderFor} when the hatch
396
+ * admits a pointer header (covers the `cli.mjs call …` subprocesses), so
397
+ * every process that acts on the pointer says so exactly once. Returns
398
+ * `true` iff it wrote. Never throws.
399
+ * @param {{ vaultId?: string, repoRootAbs?: string, source?: string } | null | undefined} resolved
400
+ * @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, write?: (line: string) => unknown }} [opts]
401
+ */
402
+ export function announcePointerVaultOnce(resolved, { env = process.env, write } = {}) {
403
+ if (_pointerVaultAnnounced) return false;
404
+ if (resolved?.source !== POINTER_VAULT_SOURCE || !pointerVaultAllowed(env)) return false;
405
+ _pointerVaultAnnounced = true;
406
+ try {
407
+ (write ?? ((line) => process.stderr.write(line)))(pointerVaultNotice(resolved));
408
+ } catch {
409
+ /* a stderr failure never becomes a transport failure */
410
+ }
411
+ return true;
412
+ }
413
+
414
+ /** Test-only: forget that the pointer notice was written, so the next process-start path writes it again. */
415
+ export function resetPointerVaultNoticeForTests() {
416
+ _pointerVaultAnnounced = false;
417
+ }
286
418
 
287
419
  /**
288
420
  * Thrown by {@link vaultIdHeaderFor} / {@link getVaultIdHeader} when the
@@ -392,19 +524,30 @@ export class AccountNotConfiguredError extends Error {
392
524
  }
393
525
  }
394
526
 
395
- /** True for the two local scope refusals this module throws. */
527
+ /** True for the three local scope refusals this module throws. */
396
528
  export function isLocalScopeRefusal(err) {
397
- return err instanceof VaultNotRegisteredError || err instanceof AccountNotConfiguredError;
529
+ return (
530
+ err instanceof VaultNotRegisteredError
531
+ || err instanceof AccountNotConfiguredError
532
+ || err instanceof VaultNotPinnedError
533
+ );
398
534
  }
399
535
 
400
536
  /**
401
- * Gate: the vault header for a resolver result. A registered id → the header
402
- * naming the CLOUD id ({@link cloudVaultIdFor}), whatever `source` says; the
403
- * `unregistered` sentinel → throws {@link VaultNotRegisteredError}; no result
404
- * / blank id → `{}` (nothing to name — the resolver itself failed, which is
405
- * not the same as "unregistered"). Pure given `opts.readMarkerVaultId`.
537
+ * Gate: the vault header for a resolver result. A registered id from a
538
+ * REPO-DERIVED tier → the header naming the CLOUD id ({@link cloudVaultIdFor});
539
+ * the `unregistered` sentinel → throws {@link VaultNotRegisteredError}; a
540
+ * pointer-sourced result (`source: 'account-pointer'`) → throws
541
+ * {@link VaultNotPinnedError} unless the hatch is on ({@link pointerVaultAllowed}
542
+ * over `opts.env`), in which case the pointer's id goes on the wire and the
543
+ * process announces it once ({@link announcePointerVaultOnce}); no result /
544
+ * blank id → `{}` (nothing to name — the resolver itself failed, which is not
545
+ * the same as "unregistered"). Pure given `opts.readMarkerVaultId`, `opts.env`
546
+ * and `opts.write`.
406
547
  * @param {{ vaultId?: string, source?: string, repoRootAbs?: string } | null | undefined} resolved
407
- * @param {{ readMarkerVaultId?: (repoRootAbs: string) => string | null }} [opts]
548
+ * @param {{ readMarkerVaultId?: (repoRootAbs: string) => string | null, env?: NodeJS.ProcessEnv | Record<string, string | undefined>, cwd?: string | null, write?: (line: string) => unknown }} [opts]
549
+ * `cwd`: the unpinned repo named in the refusal (default: the resolver's own
550
+ * cwd base, `CLAUDE_PROJECT_DIR` then `process.cwd()`).
408
551
  */
409
552
  export function vaultIdHeaderFor(resolved, opts = {}) {
410
553
  const vaultId = typeof resolved?.vaultId === 'string' ? resolved.vaultId.trim() : '';
@@ -412,15 +555,32 @@ export function vaultIdHeaderFor(resolved, opts = {}) {
412
555
  if (vaultId === UNREGISTERED_VAULT_ID) {
413
556
  throw new VaultNotRegisteredError(resolved?.repoRootAbs, resolved?.source);
414
557
  }
558
+ if (resolved?.source === POINTER_VAULT_SOURCE) {
559
+ const env = opts.env ?? process.env;
560
+ if (!pointerVaultAllowed(env)) {
561
+ throw new VaultNotPinnedError(resolved, { cwd: opts.cwd === undefined ? resolverCwdBase() : opts.cwd });
562
+ }
563
+ announcePointerVaultOnce(resolved, { env, write: opts.write });
564
+ }
415
565
  return { [VAULT_ID_HEADER]: cloudVaultIdFor({ ...resolved, vaultId }, opts) };
416
566
  }
417
567
 
568
+ /**
569
+ * The folder the resolver's walk started from — the repo that needs a pin.
570
+ * Mirrors vault-resolve.mjs `cwdBase` (`CLAUDE_PROJECT_DIR`, then `process.cwd()`).
571
+ */
572
+ function resolverCwdBase() {
573
+ const fromEnv = process.env.CLAUDE_PROJECT_DIR;
574
+ return typeof fromEnv === 'string' && fromEnv.trim() ? fromEnv.trim() : process.cwd();
575
+ }
576
+
418
577
  /**
419
578
  * The vault-id header for THIS process, from the memoized resolver
420
579
  * (`getActiveConfig`, resolved once per process; tests reset it via
421
580
  * `resetActiveConfigCache`). A resolver FAILURE is "no header" (`{}`); an
422
- * `unregistered` RESULT throws {@link VaultNotRegisteredError} so the caller
423
- * refuses before any fetch.
581
+ * `unregistered` RESULT throws {@link VaultNotRegisteredError} and a
582
+ * pointer-sourced one throws {@link VaultNotPinnedError} (hatch off) so the
583
+ * caller refuses before any fetch.
424
584
  */
425
585
  export function getVaultIdHeader() {
426
586
  let resolved;
@@ -455,7 +615,7 @@ export function accountIdHeaderFor(accountId) {
455
615
  * @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, repoAccountId?: string | null }} [opts]
456
616
  */
457
617
  export function getAccountIdHeader({ repoAccountId, ...opts } = {}) {
458
- const recorded = repoAccountId === undefined ? getRepoAccountId() : repoAccountId;
618
+ const recorded = repoAccountId === undefined ? getRepoAccountId({ homeDir: opts.homeDir }) : repoAccountId;
459
619
  return accountIdHeaderFor(recorded || resolveSessionOwnerUncached(opts));
460
620
  }
461
621
 
@@ -464,7 +624,7 @@ export function getAccountIdHeader({ repoAccountId, ...opts } = {}) {
464
624
  * `consoleFetch` already handles (`ok: false`, `status: 0`), with the code on
465
625
  * `refusal` AND on `authProblem` so `consoleErrorCode` / the never-buffer
466
626
  * contract pick it up with no per-caller change. Pure.
467
- * @param {VaultNotRegisteredError | AccountNotConfiguredError} err
627
+ * @param {VaultNotRegisteredError | AccountNotConfiguredError | VaultNotPinnedError | AccountTokenMissingError} err
468
628
  * @param {string} url the request that was NOT sent
469
629
  */
470
630
  export function scopeRefusalResult(err, url) {
@@ -545,7 +705,7 @@ async function loadDevModeCore() {
545
705
  * `resolveDevMode` (env → machine-local record → default prod/cloud) when the
546
706
  * core exports it, else `readLocalDevModeRecord` + `deriveSemantics`.
547
707
  */
548
- export async function resolveConsoleReliance({ env = process.env, homeDir = homedir() } = {}) {
708
+ export async function resolveConsoleReliance({ env = process.env, homeDir = heyspexHome() } = {}) {
549
709
  const core = await loadDevModeCore();
550
710
  if (!core) return null;
551
711
  try {
@@ -587,7 +747,7 @@ function headerHasToken(header) {
587
747
  * @param {number} [deps.defaultTimeoutMs]
588
748
  * @param {(failedResult: object, ctx: { url: string, init: object, timeoutMs: number }) => Promise<{ retryUrl?: string, reason?: string, healedUrl?: string, discoveryHealed?: boolean, devServerStatus?: object } | null | undefined>} [deps.recoverFromTransportFailure]
589
749
  * @param {() => object} [deps.getAuthHeader] Resolves the outbound Authorization header (default: {@link getAuthHeader}).
590
- * @param {() => object} [deps.getVaultIdHeader] Resolves the outbound `x-heyspex-vault-id` header (default: {@link getVaultIdHeader}); throws {@link VaultNotRegisteredError} for an unregistered root.
750
+ * @param {() => object} [deps.getVaultIdHeader] Resolves the outbound `x-heyspex-vault-id` header (default: {@link getVaultIdHeader}); throws {@link VaultNotRegisteredError} for an unregistered root, {@link VaultNotPinnedError} for a pointer-sourced one.
591
751
  * @param {() => object} [deps.getAccountIdHeader] Resolves the outbound `x-heyspex-account-id` header (default: {@link getAccountIdHeader}); throws {@link AccountNotConfiguredError} when no account is configured.
592
752
  * @param {() => Promise<{ reliance: string|null, hasToken: boolean }>} [deps.getConsoleAuthState] Resolves the reliance + token state used to tell "not signed in" from "server down" (default: dev-mode core + {@link getAuthHeader}).
593
753
  */
@@ -881,7 +1041,11 @@ export function makeConsoleFetch({
881
1041
  }
882
1042
  const timeoutMs = options.timeout || defaultTimeoutMs;
883
1043
  const retryOnTimeout = options.retryOnTimeout === false ? isLoopbackUrl(url) : true;
884
- return withRetry(url, init, timeoutMs, { retryOnTimeout });
1044
+ const outcome = await withRetry(url, init, timeoutMs, { retryOnTimeout });
1045
+ // Degrade-don't-die: the first successful console call of the process kicks
1046
+ // a background replay of any queued writes (non-blocking, failure-safe).
1047
+ if (outcome?.ok) maybeDrainOnFirstSuccess(consoleFetch);
1048
+ return outcome;
885
1049
  }
886
1050
 
887
1051
  return { postClaimEndpoint, consoleFetch };
@@ -6,13 +6,15 @@
6
6
  import {
7
7
  consoleErrorCode,
8
8
  consoleErrorMessage,
9
+ getAccountIdHeader,
9
10
  getAuthHeader,
11
+ getVaultIdHeader,
10
12
  makeConsoleFetch,
11
13
  resolveConsoleReliance,
12
14
  rewriteUrlOrigin,
13
15
  } from './console-fetch.mjs';
14
16
  import { effectiveConsoleUrl, getConfig } from './mcp-config.mjs';
15
- import { contextAuthHeader } from './request-context.mjs';
17
+ import { contextAccountIdHeader, contextAuthHeader, contextVaultIdHeader } from './request-context.mjs';
16
18
  import { pathToFileURL } from 'node:url';
17
19
  import { join } from 'node:path';
18
20
  import { existsSync } from 'node:fs';
@@ -145,6 +147,10 @@ export async function enrichConsoleFailurePayloadAsync(result, base) {
145
147
  export const { postClaimEndpoint, consoleFetch } = makeConsoleFetch({
146
148
  getConsoleUrl: effectiveConsoleUrl,
147
149
  getAuthHeader: () => contextAuthHeader() ?? getAuthHeader(),
150
+ // Remote endpoint: the vault is the CALLER's `x-heyspex-vault-id`, never the
151
+ // server process's own resolution (P1/P3).
152
+ getVaultIdHeader: () => contextVaultIdHeader() ?? getVaultIdHeader(),
153
+ getAccountIdHeader: () => contextAccountIdHeader() ?? getAccountIdHeader(),
148
154
  recoverFromTransportFailure: recoverConsoleTransportFailure,
149
155
  });
150
156
 
@@ -56,6 +56,7 @@
56
56
  // `hs login` / `hs login --migrate` (an explicit verb, with `--dry-run`)
57
57
  // re-keys the legacy token under the account the console confirms for it.
58
58
 
59
+ import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
59
60
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
60
61
  import os, { homedir } from 'node:os';
61
62
  import { dirname, join, resolve } from 'node:path';
@@ -69,7 +70,7 @@ export const MCP_TOKEN_ENV = 'HEYSPEX_MCP_TOKEN';
69
70
  */
70
71
 
71
72
  /** `~/.heyspex/credentials.yaml` for `homeDir`. */
72
- export function credentialsFilePath(homeDir = homedir()) {
73
+ export function credentialsFilePath(homeDir = heyspexHome()) {
73
74
  return join(homeDir, '.heyspex', 'credentials.yaml');
74
75
  }
75
76
 
@@ -284,7 +285,7 @@ function readLegacyConfigAccountId(read, homeDir) {
284
285
  * @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, readFile?: (p: string) => string | null, accountId?: string | null }} [opts]
285
286
  * @returns {Bearer}
286
287
  */
287
- export function readBearer({ env = process.env, homeDir = homedir(), readFile, accountId } = {}) {
288
+ export function readBearer({ env = process.env, homeDir = heyspexHome(), readFile, accountId } = {}) {
288
289
  const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
289
290
  const credentialsText = readTextOrNull(read, credentialsFilePath(homeDir));
290
291
  const named = typeof accountId === 'string' && accountId.trim() ? accountId.trim() : null;
@@ -302,7 +303,7 @@ export function readBearer({ env = process.env, homeDir = homedir(), readFile, a
302
303
  * @param {{ homeDir?: string, readFile?: (p: string) => string | null }} [opts]
303
304
  * @returns {CredentialsStore & { legacyAccountId: string | null }}
304
305
  */
305
- export function readCredentialsStore({ homeDir = homedir(), readFile } = {}) {
306
+ export function readCredentialsStore({ homeDir = heyspexHome(), readFile } = {}) {
306
307
  const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
307
308
  const store = parseCredentialsStore(readTextOrNull(read, credentialsFilePath(homeDir)));
308
309
  const legacyAccountId = store.schemaVersion === 1 ? readLegacyConfigAccountId(read, homeDir) : null;
@@ -320,7 +321,7 @@ export function readCredentialsStore({ homeDir = homedir(), readFile } = {}) {
320
321
  * @param {{ homeDir?: string, readFile?: (p: string) => string | null }} [opts]
321
322
  * @returns {string | null}
322
323
  */
323
- export function readCurrentAccountId({ homeDir = homedir(), readFile } = {}) {
324
+ export function readCurrentAccountId({ homeDir = heyspexHome(), readFile } = {}) {
324
325
  const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
325
326
  const store = parseCredentialsStore(readTextOrNull(read, credentialsFilePath(homeDir)));
326
327
  if (store.schemaVersion === 2 && store.currentAccountId && store.currentAccountId !== LEGACY_ACCOUNT_ID) {
@@ -462,7 +463,7 @@ function requireAccountId(fn, accountId) {
462
463
  * @param {{ homeDir?: string, accountId: string, mcpToken: string, consoleUrl?: string | null, email?: string | null, pairedAt?: string | null, makeCurrent?: boolean, rekeyLegacyAs?: string | null }} input
463
464
  */
464
465
  export function writeAccountCredentials({
465
- homeDir = homedir(),
466
+ homeDir = heyspexHome(),
466
467
  accountId,
467
468
  mcpToken,
468
469
  consoleUrl = null,
@@ -504,7 +505,7 @@ function rekeyLegacy(store, rekeyAs) {
504
505
  * @param {{ homeDir?: string, confirmedAccountId: string, email?: string | null, apply?: boolean }} input
505
506
  * @returns {{ action: 'rekey' | 'already-v2' | 'nothing-to-migrate', accountId: string | null, path: string }}
506
507
  */
507
- export function migrateLegacyCredentials({ homeDir = homedir(), confirmedAccountId, email = null, apply = false }) {
508
+ export function migrateLegacyCredentials({ homeDir = heyspexHome(), confirmedAccountId, email = null, apply = false }) {
508
509
  const path = credentialsFilePath(homeDir);
509
510
  const { store } = loadForWrite(homeDir);
510
511
  if (store.schemaVersion === 2) return { action: 'already-v2', accountId: store.currentAccountId, path };
@@ -527,7 +528,7 @@ export function migrateLegacyCredentials({ homeDir = homedir(), confirmedAccount
527
528
  * pairs). Returns the file path.
528
529
  * @param {{ homeDir?: string, accountId: string }} input
529
530
  */
530
- export function setCurrentAccount({ homeDir = homedir(), accountId }) {
531
+ export function setCurrentAccount({ homeDir = heyspexHome(), accountId }) {
531
532
  requireAccountId('setCurrentAccount', accountId);
532
533
  const { store } = loadForWrite(homeDir);
533
534
  if (!store.accounts[accountId]?.mcp_token) {
@@ -545,7 +546,7 @@ export function setCurrentAccount({ homeDir = homedir(), accountId }) {
545
546
  * @param {{ homeDir?: string, accountId?: string | null }} [input]
546
547
  * @returns {{ removed: string | null, wasCurrent: boolean, currentAccountId: string | null, path: string }}
547
548
  */
548
- export function removeAccount({ homeDir = homedir(), accountId = null } = {}) {
549
+ export function removeAccount({ homeDir = heyspexHome(), accountId = null } = {}) {
549
550
  const path = credentialsFilePath(homeDir);
550
551
  const { store } = loadForWrite(homeDir);
551
552
  const target = accountId ?? store.currentAccountId;
@@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url';
5
5
  import { join } from 'node:path';
6
6
  import { existsSync } from 'node:fs';
7
7
  import { PACKAGE_ROOT } from './package-root.mjs';
8
+ import { listQuarantinedOutbox, outboxCount } from './write-outbox.mjs';
8
9
  import { resolveCodeRoot } from '../../scripts/lib/shared-path-helpers.mjs';
9
10
 
10
11
  // scripts/dev-server-status.mjs and scripts/lib/dev-mode.mjs are CODE-repo
@@ -26,6 +27,12 @@ export async function handleDevServerStatus(vaultRoot) {
26
27
  const coreUrl = pathToFileURL(scriptPath).href;
27
28
  const mod = await import(coreUrl);
28
29
  const status = await mod.getDevServerStatus({ vaultRoot });
30
+ // Degrade-don't-die visibility: queued / quarantined cloud writes.
31
+ try {
32
+ status.write_outbox = { pending: outboxCount(vaultRoot), quarantined: listQuarantinedOutbox(vaultRoot).length };
33
+ } catch {
34
+ /* best effort */
35
+ }
29
36
  return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] };
30
37
  } catch (err) {
31
38
  return {
@@ -18,6 +18,13 @@ import { randomUUID } from 'node:crypto';
18
18
  import { consoleFetch, enrichConsoleFailurePayloadAsync } from './console-transport.mjs';
19
19
  import { consoleErrorCode, consoleErrorMessage } from './console-fetch.mjs';
20
20
  import { resolveReadTier } from './read-path.mjs';
21
+ import {
22
+ buildDeferredPayload,
23
+ deferredToolResponse,
24
+ drainAndSummarize,
25
+ isOutageClassResult,
26
+ stashIfOutage,
27
+ } from './write-outbox.mjs';
21
28
  import { join } from 'node:path';
22
29
  import { statSync, existsSync } from 'node:fs';
23
30
  import { importDevModeCore } from './dev-mode-core.mjs';
@@ -456,6 +463,9 @@ export async function handleFileSpec(args = {}, vaultRoot = null) {
456
463
  // shared `init`), so a slow-console retry replays the first create server-side
457
464
  // instead of double-allocating a second byte-identical spec.
458
465
  const idempotencyKey = randomUUID();
466
+ // Degrade-don't-die: replay anything queued by an earlier outage first (the
467
+ // drain never stashes, and is failure-safe).
468
+ const outboxNote = vaultRoot ? await drainAndSummarize(vaultRoot, consoleFetch) : {};
459
469
  const result = await consoleFetch('/api/specs', {
460
470
  method: 'POST',
461
471
  body,
@@ -469,6 +479,30 @@ export async function handleFileSpec(args = {}, vaultRoot = null) {
469
479
  retryOnTimeout: false,
470
480
  });
471
481
  if (!result.ok) {
482
+ // Outage class (status 0 / timeout / 5xx) in CLOUD reliance: queue the create
483
+ // with its idempotency key and answer truthfully "deferred" — never isError,
484
+ // never a claim it landed. 4xx / 401 / sign-in problems keep their loud envelope.
485
+ if (vaultRoot && isOutageClassResult(result) && (await resolveReadTier()) === 'neon') {
486
+ const stashed = stashIfOutage(
487
+ result,
488
+ {
489
+ op: 'create',
490
+ route: '/api/specs',
491
+ method: 'POST',
492
+ request_body: body,
493
+ idempotency_key: idempotencyKey,
494
+ acting_agent: actingAgent,
495
+ session_id: args.session_id ?? null,
496
+ },
497
+ vaultRoot,
498
+ );
499
+ if (stashed) {
500
+ return deferredToolResponse({
501
+ ...buildDeferredPayload(stashed, vaultRoot, { attempted: { type: args.type, name: args.name } }),
502
+ ...(outboxNote.drained ? { drained: outboxNote.drained } : {}),
503
+ });
504
+ }
505
+ }
472
506
  const payload = await enrichConsoleFailurePayloadAsync(result, {
473
507
  error: consoleErrorCode(result, 'FILE_SPEC_FAILED'),
474
508
  message: consoleErrorMessage(result, `POST /api/specs returned ${result.status}`),
@@ -523,6 +557,7 @@ export async function handleFileSpec(args = {}, vaultRoot = null) {
523
557
  landed,
524
558
  spec,
525
559
  console_response: result.body,
560
+ ...outboxNote,
526
561
  };
527
562
  if (landingError) {
528
563
  return {
@@ -24,6 +24,7 @@
24
24
  * call these tools with no extra setup.
25
25
  */
26
26
 
27
+ import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
27
28
  import { execFileSync } from 'node:child_process';
28
29
  import { readFileSync } from 'node:fs';
29
30
  import { homedir, platform } from 'node:os';
@@ -40,7 +41,7 @@ export function resolveFleetToken() {
40
41
  const env = process.env.HEYSPEX_FLEET_TOKEN;
41
42
  if (env && env.trim()) return env.trim();
42
43
  try {
43
- const yaml = readFileSync(join(homedir(), '.heyspex', 'credentials.yaml'), 'utf8');
44
+ const yaml = readFileSync(join(heyspexHome(), '.heyspex', 'credentials.yaml'), 'utf8');
44
45
  const clerk = yaml.match(/^clerk:\s*$([\s\S]*?)(?=^\S|\s*$(?![\s\S]))/m)?.[1] ?? yaml;
45
46
  const m = clerk.match(/^\s*companion_token:\s*["']?([^"'\r\n#]+)/m);
46
47
  if (m?.[1]?.trim()) return m[1].trim();
@@ -17,6 +17,7 @@
17
17
  * own header; this file only carries the read/write/list primitives, not the trust decision
18
18
  * logic itself.
19
19
  */
20
+ import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
20
21
  import fs from 'node:fs';
21
22
  import os from 'node:os';
22
23
  import path from 'node:path';
@@ -25,7 +26,7 @@ import path from 'node:path';
25
26
  export const TRUST_STORE_REL = path.join('.heyspex', 'trusted-folders.json');
26
27
 
27
28
  /** Resolve the real trust-store path for a given home directory (defaults to `os.homedir()`). */
28
- export function trustStorePath(home = os.homedir()) {
29
+ export function trustStorePath(home = heyspexHome()) {
29
30
  return path.join(home, TRUST_STORE_REL);
30
31
  }
31
32
 
@@ -125,6 +125,7 @@ export function toolNotAvailableRemotelyError(id, name) {
125
125
  * @property {string|null} [sessionId] The client's `Mcp-Session-Id`, if any.
126
126
  * @property {string} [consoleUrl] Origin the in-process handlers should call back to (the deployment itself).
127
127
  * @property {Record<string, string>} [authHeader] The caller's own credential to forward on those calls.
128
+ * @property {string|null} [vaultId] The caller's own `x-heyspex-vault-id`, forwarded so the call is scoped to the vault the CALLER named.
128
129
  * @property {string} [agentName] Default `agent` for tools whose schema declares one.
129
130
  */
130
131
 
@@ -204,6 +205,7 @@ export function createRemoteMcpHandler(deps) {
204
205
  const requestContext = {
205
206
  ...(ctx?.consoleUrl ? { consoleUrl: ctx.consoleUrl } : {}),
206
207
  ...(ctx?.authHeader ? { authHeader: ctx.authHeader } : {}),
208
+ ...(ctx?.vaultId ? { vaultId: ctx.vaultId } : {}),
207
209
  };
208
210
  try {
209
211
  const result = await runWithRequestContext(requestContext, () => dispatch(name, args, vaultRoot, config));
@@ -245,7 +245,7 @@ export async function walkSpecListPages(consoleFetch, path, { limit = 500, maxPa
245
245
  const pagePath = `${url.pathname}${url.search}`;
246
246
  const res = await consoleFetch(pagePath);
247
247
  if (!res.ok) {
248
- throw new Error(`GET ${pagePath} returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`);
248
+ throw Object.assign(new Error(`GET ${pagePath} returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`), { status: res.status, errorKind: res.errorKind, authProblem: res.authProblem || res.refusal ? true : undefined });
249
249
  }
250
250
  const specs = Array.isArray(res.body?.specs) ? res.body.specs : [];
251
251
  rows.push(...specs);
@@ -4,8 +4,11 @@
4
4
 
5
5
  import { contextConsoleUrl } from './request-context.mjs';
6
6
  import { getActiveConfig } from './vault-resolve.mjs';
7
+ import { resolveDefaultRoadmap } from './roadmap-default.mjs';
7
8
  import { resolveConsoleUrl } from '../../scripts/lib/console-url-resolver.mjs';
8
9
 
10
+ const warnedMultiRoadmap = new Set();
11
+
9
12
  export function getConfig() {
10
13
  // Vault resolution is delegated to the shared 5-tier resolver
11
14
  // (vault-resolve.mjs) so the MCP face agrees with the console face on which
@@ -19,7 +22,17 @@ export function getConfig() {
19
22
  const { repoRootAbs, vaultId, source: vaultSource } = getActiveConfig();
20
23
  const vaultRoot = repoRootAbs;
21
24
 
22
- const defaultRoadmapId = process.env.HEYSPEX_ROADMAP_ID || 'HS-ROADMAP-PRODUCT';
25
+ // Per-vault default roadmap (env > the vault's own `type: roadmap` spec >
26
+ // platform constant) — never the platform id for a customer vault.
27
+ const roadmap = resolveDefaultRoadmap(vaultRoot);
28
+ if (roadmap.source === 'vault-multiple' && !warnedMultiRoadmap.has(vaultRoot)) {
29
+ warnedMultiRoadmap.add(vaultRoot);
30
+ process.stderr.write?.(
31
+ `[heyspex-mcp] vault ${vaultRoot} has ${roadmap.candidates.length} roadmaps (${roadmap.candidates.join(', ')}); defaulting to ${roadmap.id}. Pass roadmap_id to choose.
32
+ `,
33
+ );
34
+ }
35
+ const defaultRoadmapId = roadmap.id;
23
36
 
24
37
  return { vaultRoot, vaultId, vaultSource, defaultRoadmapId };
25
38
  }