heyspex 0.2.2 → 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 +81 -77
  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 -177
  27. package/docs/driving-heyspex-from-copilot.md +174 -165
  28. package/docs/mcp-client-config.md +93 -25
  29. package/package.json +255 -250
  30. package/plugins/heyspex/.mcp.json +3 -4
  31. package/plugins/heyspex/README.md +323 -312
  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 +64 -34
  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
@@ -14,61 +14,67 @@
14
14
  * DATABASE_URL may be absent), so this port does NOT make a network/account
15
15
  * lookup. Instead it reads the offline registry file
16
16
  * (`~/.heyspex/registry.json`, same shape as
17
- * `spex-console/src/lib/vault-registry/registry-file.ts`) for tier-1
18
- * (explicit vaultId) and tier-4 (the offline active-vault pointer). That
19
- * offline pointer is how an MCP restart picks up a CLI `hs vault use` switch
20
- * without Neon.
17
+ * `spex-console/src/lib/vault-registry/registry-file.ts`) only to map ids to
18
+ * checkout paths (explicit ids, env roots, repo bindings, markers). It never
19
+ * reads the file's active-vault pointer.
21
20
  *
22
- * Precedence stack (highest wins) — ONE order shared with the TS resolver
23
- * (`spex-console/src/lib/vault-resolve/index.ts`), pinned row-by-row by the
24
- * shared fixture `spex-console/src/lib/vault-resolve/precedence-fixtures.json`
25
- * ([[HS-VVNQ]], decision memory `vault-resolver-precedence`). History: HS-C87T
26
- * put the folder the process started in above the registry pointer; HS-VVNQ
27
- * then put it above the env vars too, because the plugin manifest
28
- * (`plugins/heyspex/.mcp.json`) sets `HEYSPEX_VAULT_ROOT=${CLAUDE_PROJECT_DIR}`
29
- * on EVERY session — an env tier above the walk silently outranked every
30
- * on-disk pin and marker:
31
- * 1. explicit vaultId -> registry lookup (vaults[].id)
32
- * 2. cwd-contained vault OR a -> walk UP from cwd through every ancestor
33
- * satellite attachment looking for a heyspex.yaml marker, a
34
- * specs/ dir, or a satellite repo's MCP
35
- * manifest pin (readSatellitePin); the
36
- * nearest match wins, attachment first.
37
- * Sources: 'satellite-pin' (attachment);
38
- * 'cwd-marker' (heyspex.yaml carries a
39
- * `vault_id` — that id IS the vault id,
40
- * registry row or not); 'marker-registry-
41
- * mismatch' (the marker id disagrees with
42
- * the registry row for that root — the
43
- * marker id wins and stderr names
44
- * `hs connect --check`); 'cwd-heuristic'
45
- * (a marker without `vault_id`, or a bare
46
- * specs/ dir: id from the registry row, else
47
- * the `unregistered` sentinel). Logs one
48
- * stderr notice iff the cwd vault differs
49
- * from the registry's active vault — silent
50
- * when they agree.
51
- * 3. HEYSPEX_VAULT_ROOT env -> repo root (legacy escape hatch)
52
- * 3. HEYSPEX_VAULT_DIR env -> the specs/ dir itself (repo root = parent)
53
- * 4. registry active_vault_id -> the offline active pointer (vaults[].id);
54
- * schema v2: the current account's row
55
- * (`accounts[current_account_id]`), v1:
56
- * the machine-global field
57
- * — reached only when cwd sits inside no
58
- * vault and no env var names one
59
- * 5. cwd fallback -> cwd itself (no marker found anywhere;
60
- * preserves the pre-existing default)
21
+ * Precedence stack (highest wins). Owner ruling 2026-10-14 ([[HS-TYJA]],
22
+ * amending HS-VVNQ / decision `vault-resolver-precedence-explicit-then`):
23
+ * "there shouldn't be one active vault ... Vault is active when user is
24
+ * launched an agent in the repo folder belonging to a vault." Full contract
25
+ * (inputs, every failure code, what each caller does): `vault-binding.mjs`.
26
+ * 1. explicit vaultId -> its registry row 'explicit'
27
+ * (unknown id -> VAULT_NOT_FOUND, never a cascade)
28
+ * 2. HEYSPEX_VAULT_ROOT env -> a vault (true override) 'env-vault-root'
29
+ * HEYSPEX_VAULT_DIR env -> a vault's specs/ dir 'env-vault-dir'
30
+ * (a vault = a checkout on disk or a registered vault root;
31
+ * anything else, e.g. an unregistered code repo -> VAULT_NOT_FOUND)
32
+ * 3. walk UP from the cwd (realpath'd) to the filesystem root, nearest
33
+ * folder wins; at each level, in order:
34
+ * satellite marker: heyspex.yaml relative `vault_path` 'satellite-pin'
35
+ * (-> the clone at vault_path; not cloned here -> the satellite
36
+ * dir itself with `vaultCloned: false`, which disk writers refuse)
37
+ * satellite pin (.mcp.json / .cursor/mcp.json) 'satellite-pin'
38
+ * vault checkout: heyspex.yaml `vault_id` 'cwd-marker'
39
+ * (disagreeing with the registry row for that root) 'marker-registry-mismatch'
40
+ * vault checkout without vault_id / bare specs/ 'cwd-heuristic'
41
+ * heyspex.yaml `kind: repo-binding` (a bound code repo) 'repo-binding'
42
+ * a linked git worktree that matched nothing walks again from its
43
+ * main checkout (`worktreeOf` on the result).
44
+ * 4. nothing -> sentinel id, `error` 'unbound'
45
+ * (VAULT_UNBOUND, or VAULT_BINDING_BROKEN when a binding names a
46
+ * vault this machine cannot find). `resolveVaultOrThrow` throws it.
47
+ * The registry `active_vault_id` / per-account pointers are NEVER consulted.
61
48
  *
62
49
  * Resolution is SYNCHRONOUS (all inputs are fs/env) and, via getActiveConfig(),
63
- * memoized once per process. If the registry can't be read, tier 4 simply
64
- * finds nothing and resolution falls through to tier 5 — i.e. today's
65
- * behavior, so nothing regresses when no registry exists.
50
+ * memoized once per process. It never throws: an unreadable registry only
51
+ * means explicit ids and bindings cannot be mapped to folders.
66
52
  */
67
53
 
68
54
  import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
69
- import { existsSync, readFileSync } from 'node:fs';
55
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
70
56
  import { homedir, tmpdir } from 'node:os';
71
- import { dirname, join, resolve } from 'node:path';
57
+ import { withoutUnusableDirEnv } from './usable-dir-env.mjs';
58
+ import { basename, dirname, join, resolve } from 'node:path';
59
+
60
+ import {
61
+ findGitCheckout,
62
+ isRepoBindingDir,
63
+ locateBoundVault,
64
+ readRepoBinding,
65
+ registryVaults,
66
+ unboundError,
67
+ VAULT_NOT_FOUND,
68
+ VaultResolutionError,
69
+ } from './vault-binding.mjs';
70
+
71
+ export {
72
+ isVaultResolutionError,
73
+ VAULT_BINDING_BROKEN,
74
+ VAULT_NOT_FOUND,
75
+ VAULT_UNBOUND,
76
+ VaultResolutionError,
77
+ } from './vault-binding.mjs';
72
78
 
73
79
  /**
74
80
  * Placeholder vaultId for tiers 3-5, which resolve a filesystem path but have
@@ -106,11 +112,10 @@ function isNonEmptyString(v) {
106
112
  * Read + defensively parse `~/.heyspex/registry.json`. A missing or malformed
107
113
  * file returns `null` (never throws) so callers fall through to env/cwd tiers —
108
114
  * mirroring registry-file.ts `loadVaultRegistry`'s fail-closed contract.
109
- * Reads schema v1 and v2 alike; `active_vault_id` is the pointer
110
- * {@link registryPointerVaultId} picks.
115
+ * Reads schema v1 and v2 alike. The active pointer is NOT read.
111
116
  *
112
117
  * @param {{ registryDir?: string }} [opts]
113
- * @returns {{ vaults: Array<{ id: string, repo_root_abs: string }>, active_vault_id: string|null }|null}
118
+ * @returns {{ vaults: Array<{ id: string, repo_root_abs: string }>, raw: Record<string, unknown> }|null}
114
119
  */
115
120
  function loadRegistry(opts = {}) {
116
121
  const filePath = vaultRegistryFilePath(opts);
@@ -131,7 +136,10 @@ function loadRegistry(opts = {}) {
131
136
  if (!isNonEmptyString(raw.repo_root_abs)) continue;
132
137
  vaults.push({ id: raw.id, repo_root_abs: raw.repo_root_abs });
133
138
  }
134
- return { vaults, active_vault_id: registryPointerVaultId(json) };
139
+ // `raw` feeds the repo-binding tier (display names, git remotes). The
140
+ // active pointer is deliberately NOT read: local resolution never consults
141
+ // it ([[HS-TYJA]]); registryPointerVaultId stays exported for the console.
142
+ return { vaults, raw: json };
135
143
  }
136
144
 
137
145
  /** Mirrors registry-file.ts `LEGACY_REGISTRY_ACCOUNT_ID`. */
@@ -419,49 +427,23 @@ export function findBrokenSatellitePin(startDir, stopAt) {
419
427
  */
420
428
  export function isVaultRoot(dir) {
421
429
  if (normalizeRoot(dir) === normalizeRoot(tmpdir())) return false;
430
+ // A bound CODE repo (heyspex.yaml `vault:` binding, no specs/) names a vault;
431
+ // it is not one ([[HS-TYJA]]).
432
+ if (isRepoBindingDir(dir)) return false;
433
+ // Nor is a SATELLITE code repo (heyspex.yaml `vault_path:`), even when a
434
+ // stray specs/ sits beside it - that is exactly the junk a mis-targeted
435
+ // `hs pull` left in mlc-app.
436
+ if (readMarkerVaultPath(dir) !== null) return false;
422
437
  return existsSync(join(dir, 'heyspex.yaml')) || existsSync(join(dir, 'specs'));
423
438
  }
424
439
 
425
- /**
426
- * Extract `vault_id: <value>` from raw heyspex.yaml text, or `null` if absent.
427
- * Same semantics as `spex-console/src/lib/cli/init.ts` `parseVaultId` (the
428
- * writer's own reader): first line-anchored `vault_id:` key, value trimmed,
429
- * one matching pair of surrounding quotes stripped, empty -> `null`. Kept to
430
- * this one line-regex on purpose — `hs init` writes the marker as flat
431
- * `key: value` lines, so a YAML parser buys nothing here.
432
- *
433
- * @param {string} content
434
- * @returns {string|null}
435
- */
436
- export function parseMarkerVaultId(content) {
437
- const m = String(content ?? '').match(/^vault_id:\s*(.+?)\s*$/m);
438
- if (!m) return null;
439
- const unquoted = m[1].trim().replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1');
440
- return unquoted.length > 0 ? unquoted : null;
441
- }
442
-
443
- /**
444
- * The `vault_id` written in `<dir>/heyspex.yaml`, or `null` (no marker, no
445
- * key, unreadable). Never throws.
446
- *
447
- * @param {string} dir
448
- * @returns {string|null}
449
- */
450
- function readMarkerVaultId(dir) {
451
- try {
452
- return parseMarkerVaultId(readFileSync(join(dir, 'heyspex.yaml'), 'utf8'));
453
- } catch {
454
- return null;
455
- }
456
- }
457
-
458
440
  /**
459
441
  * Extract `vault_path: <value>` from raw heyspex.yaml text, or `null`.
460
- * The PORTABLE satellite declaration (owner principles P1/P3, 2026-10-16): a
461
- * code repo (e.g. mlc-app) commits a `heyspex.yaml` carrying its `vault_id`
462
- * plus a RELATIVE `vault_path` (e.g. `../mlc-specs`) to where the attached
463
- * vault is usually cloned. No absolute path is ever committed. An absolute,
464
- * empty or unexpanded `${...}` value is not a declaration (returns null).
442
+ * The PORTABLE satellite declaration (owner principles 2026-10-16, HS-FWB6 on
443
+ * main): a code repo (e.g. mlc-app) commits a `heyspex.yaml` carrying its
444
+ * `vault_id` plus a RELATIVE `vault_path` (e.g. `../mlc-specs`) to where the
445
+ * attached vault is usually cloned. An absolute, empty or unexpanded `${...}`
446
+ * value is not a declaration (returns null). Same parse as main's twin.
465
447
  *
466
448
  * @param {string} content
467
449
  * @returns {string|null}
@@ -474,17 +456,24 @@ export function parseMarkerVaultPath(content) {
474
456
  return v;
475
457
  }
476
458
 
459
+ /** The `vault_path` of `<dir>/heyspex.yaml`, or null. Never throws. */
460
+ function readMarkerVaultPath(dir) {
461
+ try {
462
+ return parseMarkerVaultPath(readFileSync(join(dir, 'heyspex.yaml'), 'utf8'));
463
+ } catch {
464
+ return null;
465
+ }
466
+ }
467
+
477
468
  /**
478
469
  * Is `<dir>/heyspex.yaml` a committed SATELLITE marker (it carries a relative
479
470
  * `vault_path`)? Returns the attachment it declares, or `null`.
480
471
  *
481
- * - `vaultId`: the marker's `vault_id` (the vault this repo is bound to; the
482
- * ONLY thing a fresh machine without the vault clone needs, because
483
- * cloud-routed writes name the vault by id).
472
+ * - `vaultId`: the marker's `vault_id` (the vault this repo is bound to).
484
473
  * - `vaultRootAbs`: `resolve(dir, vault_path)` when that directory exists on
485
- * THIS machine and is a vault root whose own marker does not name a
474
+ * THIS machine and is a vault checkout whose own marker does not name a
486
475
  * DIFFERENT vault id; otherwise `null` (not cloned here, or a different
487
- * vault sits at that path - never read the wrong vault's disk).
476
+ * vault sits at that path - never read or write the wrong vault's disk).
488
477
  *
489
478
  * @param {string} dir
490
479
  * @returns {{ vaultId: string|null, vaultPath: string, vaultRootAbs: string|null }|null}
@@ -501,13 +490,46 @@ export function readMarkerSatellite(dir) {
501
490
  const vaultId = parseMarkerVaultId(raw);
502
491
  const candidate = resolve(dir, vaultPath);
503
492
  let vaultRootAbs = null;
504
- if (normalizeRoot(candidate) !== normalizeRoot(dir) && isVaultRoot(candidate)) {
493
+ if (normalizeRoot(candidate) !== normalizeRoot(dir) && isVaultCheckoutDir(candidate)) {
505
494
  const theirs = readMarkerVaultId(candidate);
506
495
  if (!vaultId || !theirs || theirs === vaultId) vaultRootAbs = candidate;
507
496
  }
508
497
  return { vaultId, vaultPath, vaultRootAbs };
509
498
  }
510
499
 
500
+ /**
501
+ * Extract `vault_id: <value>` from raw heyspex.yaml text, or `null` if absent.
502
+ * Same semantics as `spex-console/src/lib/cli/init.ts` `parseVaultId` (the
503
+ * writer's own reader): first line-anchored `vault_id:` key, value trimmed,
504
+ * one matching pair of surrounding quotes stripped, empty -> `null`. Kept to
505
+ * this one line-regex on purpose — `hs init` writes the marker as flat
506
+ * `key: value` lines, so a YAML parser buys nothing here.
507
+ *
508
+ * @param {string} content
509
+ * @returns {string|null}
510
+ */
511
+ export function parseMarkerVaultId(content) {
512
+ const m = String(content ?? '').match(/^vault_id:\s*(.+?)\s*$/m);
513
+ if (!m) return null;
514
+ const unquoted = m[1].trim().replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1');
515
+ return unquoted.length > 0 ? unquoted : null;
516
+ }
517
+
518
+ /**
519
+ * The `vault_id` written in `<dir>/heyspex.yaml`, or `null` (no marker, no
520
+ * key, unreadable). Never throws.
521
+ *
522
+ * @param {string} dir
523
+ * @returns {string|null}
524
+ */
525
+ function readMarkerVaultId(dir) {
526
+ try {
527
+ return parseMarkerVaultId(readFileSync(join(dir, 'heyspex.yaml'), 'utf8'));
528
+ } catch {
529
+ return null;
530
+ }
531
+ }
532
+
511
533
  /**
512
534
  * The marker id and the registry row for the same root disagree. The marker
513
535
  * wins (it is the repo's own on-disk declaration, written by `hs init` /
@@ -543,15 +565,19 @@ function findCwdVaultOrAttachment(startDir, stopAt) {
543
565
  let dir = startDir;
544
566
  const boundary = isNonEmptyString(stopAt) ? resolve(stopAt) : null;
545
567
  for (let i = 0; i < 256; i += 1) {
546
- // Precedence (see inspectSatellitePin): committed satellite marker, then
547
- // any valid manifest pin, then the generic vault-root test.
568
+ // A committed satellite marker (heyspex.yaml `vault_path`) outranks any
569
+ // manifest pin at the same level (same precedence as main's HS-FWB6).
570
+ // Uncloned here -> the satellite dir is reported (cloud tools still work by
571
+ // the marker id) and flagged `vaultCloned: false` so disk writers refuse.
548
572
  const marked = readMarkerSatellite(dir);
549
- if (marked) {
550
- return { repoRootAbs: marked.vaultRootAbs ?? dir, satellite: true, markerSatellite: marked };
551
- }
573
+ if (marked) return { repoRootAbs: marked.vaultRootAbs ?? dir, satellite: true, markerSatellite: marked, satelliteDir: dir };
552
574
  const pinned = readSatellitePin(dir);
553
575
  if (pinned) return { repoRootAbs: pinned, satellite: true };
576
+ // A vault checkout at this level beats a binding at this level (a file
577
+ // that is a vault marker is never parsed as a binding anyway).
554
578
  if (isVaultRoot(dir)) return { repoRootAbs: dir, satellite: false };
579
+ const binding = readRepoBinding(dir);
580
+ if (binding) return { repoRootAbs: dir, satellite: false, binding };
555
581
  if (boundary && dir === boundary) break;
556
582
  const parent = dirname(dir);
557
583
  if (parent === dir) break;
@@ -561,27 +587,32 @@ function findCwdVaultOrAttachment(startDir, stopAt) {
561
587
  }
562
588
 
563
589
  /**
564
- * When the cwd-contained vault (tier 2) wins, tell the operator if that
565
- * silently overrode the registry's active vault — the customer-visible
566
- * failure this task fixes (packaged server writing bookkeeping into the
567
- * WRONG repo with no warning). Prints nothing when there is no registry
568
- * pointer, the pointer is stale/unresolvable, or the two vaults are the same
569
- * repo (the owner's own sessions: cwd = the platform repo = the registry's
570
- * active vault too — must stay silent and unaffected).
571
- *
572
- * @param {string} cwdVaultRoot
573
- * @param {{ vaults: Array<{id:string, repo_root_abs:string}>, active_vault_id: string|null }|null} registry
590
+ * Canonical start folder for the walk: absolute, with symlinks / Windows
591
+ * junctions resolved (so a link into a bound repo resolves like the repo).
592
+ * A missing folder keeps its lexical form.
593
+ * @param {string} p
574
594
  */
575
- function maybeLogCwdOverrideNotice(cwdVaultRoot, registry) {
576
- if (!registry || !isNonEmptyString(registry.active_vault_id)) return;
577
- const activeRecord = findVaultById(registry, registry.active_vault_id);
578
- if (!activeRecord) return;
579
- const registryRoot = resolve(activeRecord.repo_root_abs);
580
- const resolvedCwdRoot = resolve(cwdVaultRoot);
581
- if (registryRoot === resolvedCwdRoot) return; // same vault — no notice
582
- process.stderr.write(
583
- `[heyspex-mcp] vault resolution: started inside ${resolvedCwdRoot} — overriding the registry's active vault ${registryRoot}, using the cwd vault.\n`,
584
- );
595
+ function canonicalDir(p) {
596
+ const abs = resolve(p);
597
+ try {
598
+ return realpathSync(abs);
599
+ } catch {
600
+ return abs;
601
+ }
602
+ }
603
+
604
+ /**
605
+ * The main checkout of a LINKED git worktree containing `dir` (its `.git` is a
606
+ * file whose gitdir has a `commondir`), or null. Worktrees outside the main
607
+ * repo's tree resolve like their repo when their own walk finds nothing.
608
+ * @param {string} dir
609
+ */
610
+ function mainCheckoutOfWorktree(dir) {
611
+ const co = findGitCheckout(dir);
612
+ if (!co || resolve(co.commonDir) === resolve(co.gitDir)) return null;
613
+ if (basename(co.commonDir).toLowerCase() !== '.git') return null; // bare repo: no main checkout
614
+ const main = dirname(co.commonDir);
615
+ return resolve(main) === resolve(co.root) ? null : main;
585
616
  }
586
617
 
587
618
  /**
@@ -593,16 +624,10 @@ function maybeLogCwdOverrideNotice(cwdVaultRoot, registry) {
593
624
  * `source` names WHICH tier answered, and callers gate on it — `bin/hs.mjs`'s
594
625
  * `findRegistryVaultRoot`, `dev-factory/judge.mjs`'s `resolveVaultRoot`,
595
626
  * `dev-factory/lib/vault-root.mjs` and `dev-factory/lib/worker-lifecycle.mjs`
596
- * all accept anything that is not the last-resort `'cwd-heuristic'`. So the
597
- * label has to be true: a satellite attachment found by the walk (tier 2) is
598
- * `'satellite-pin'`, NOT `'env-vault-root'` (which it reported until
599
- * 2026-09-18, even with no env var set anywhere). Both are accepted by every
600
- * gate above, so the rename is behavior-preserving.
601
- *
602
- * `'cwd-marker'` and `'marker-registry-mismatch'` (a heyspex.yaml `vault_id`
603
- * named the vault) are likewise real answers, accepted by the same gates.
627
+ * all accept anything that is not `'unbound'` or the last-resort
628
+ * `'cwd-heuristic'`.
604
629
  *
605
- * @param {'explicit'|'account-pointer'|'env-vault-root'|'env-vault-dir'|'satellite-pin'|'cwd-marker'|'marker-registry-mismatch'|'cwd-heuristic'} source
630
+ * @param {'explicit'|'env-vault-root'|'env-vault-dir'|'satellite-pin'|'repo-binding'|'cwd-marker'|'marker-registry-mismatch'|'cwd-heuristic'|'unbound'} source
606
631
  */
607
632
  function finalize(vaultId, repoRootAbs, source) {
608
633
  const resolvedRoot = resolve(repoRootAbs);
@@ -614,151 +639,234 @@ function finalize(vaultId, repoRootAbs, source) {
614
639
  };
615
640
  }
616
641
 
642
+ /** Is `dir` an existing directory that is a vault checkout (see {@link isVaultRoot})? */
643
+ function isVaultCheckoutDir(dir) {
644
+ try {
645
+ if (!statSync(dir).isDirectory()) return false;
646
+ } catch {
647
+ return false;
648
+ }
649
+ return isVaultRoot(dir);
650
+ }
651
+
617
652
  /**
618
- * Resolve the active vault synchronously via the 5-tier precedence stack.
653
+ * Resolve the vault for this process. Order (owner ruling 2026-10-14,
654
+ * [[HS-TYJA]]; full contract in vault-binding.mjs): explicit id -> env
655
+ * override -> walk up from the launch folder -> unbound. Never throws: a
656
+ * failure returns source `'unbound'` with a typed `error`
657
+ * (VAULT_UNBOUND / VAULT_BINDING_BROKEN / VAULT_NOT_FOUND).
619
658
  *
620
- * @param {{ vaultId?: string, cwd?: string, registryDir?: string, cwdWalkRoot?: string }} [options]
621
- * - `vaultId`: explicit override (tier 1).
622
- * - `cwd`: working dir for the cwd-contained-vault walk-up (tier 2; defaults
623
- * to `CLAUDE_PROJECT_DIR` then `process.cwd()`).
659
+ * @param {{ vaultId?: string, cwd?: string, registryDir?: string, cwdWalkRoot?: string, env?: Record<string, string|undefined> }} [options]
660
+ * - `env`: environment to read HEYSPEX_VAULT_ROOT / HEYSPEX_VAULT_DIR /
661
+ * CLAUDE_PROJECT_DIR from (default `process.env`; tests inject).
662
+ * - `vaultId`: explicit override (tier 1). Must name a registry row whose
663
+ * folder exists, else VAULT_NOT_FOUND (an explicit ask never cascades to a
664
+ * different vault).
665
+ * - `cwd`: where the walk starts (defaults to `CLAUDE_PROJECT_DIR` then
666
+ * `process.cwd()`).
624
667
  * - `registryDir`: registry dir override (tests only; defaults to ~/.heyspex).
625
- * - `cwdWalkRoot`: test-only fence for the tier-2 walk-up (see
626
- * {@link findCwdVaultOrAttachment}); production code never sets this.
627
- * @returns {{ vaultId: string, repoRootAbs: string, specsDirAbs: string, source: string }}
668
+ * - `cwdWalkRoot`: test-only fence for the walk; production never sets it.
669
+ * @returns {{ vaultId: string, repoRootAbs: string, specsDirAbs: string, source: string, error?: VaultResolutionError, boundRepoAbs?: string, worktreeOf?: string }}
628
670
  */
629
671
  export function resolveActiveVault(options = {}) {
630
672
  const registry = loadRegistry(options);
673
+ // An env value that is still an unexpanded ${...} placeholder, a relative path or (for the
674
+ // project-dir vars) a missing folder is UNSET, never a path (HS-C0NW: Copilot does not expand
675
+ // ${CLAUDE_PROJECT_DIR}). A registered vault root with no local checkout stays valid, so
676
+ // HEYSPEX_VAULT_ROOT skips the exists check.
677
+ const env = withoutUnusableDirEnv(options.env ?? process.env, { requireExisting: { HEYSPEX_VAULT_ROOT: false } });
678
+ const cwdBase = canonicalDir(
679
+ isNonEmptyString(options.cwd)
680
+ ? options.cwd
681
+ : (isNonEmptyString(env.CLAUDE_PROJECT_DIR)
682
+ ? env.CLAUDE_PROJECT_DIR
683
+ : process.cwd()),
684
+ );
631
685
 
632
- // Tier 1 — explicit vaultId (CLI --vault / caller override). Unchanged:
633
- // an explicit ask always wins over everything else.
686
+ // Tier 1 — explicit vaultId (CLI --vault / caller override). An explicit ask
687
+ // always wins, and an explicit ask for a vault this machine cannot find is
688
+ // an ERROR, never a silent fall-through to some other vault.
634
689
  if (isNonEmptyString(options.vaultId)) {
690
+ // A registered id resolves, even when this machine has no checkout of it
691
+ // (cloud reliance: reads/writes go to the console by vault id; the
692
+ // disk-tier dispatch gate refuses an absent root). An id this machine has
693
+ // no registry row for is VAULT_NOT_FOUND.
635
694
  const record = findVaultById(registry, options.vaultId);
636
695
  if (record) return finalize(record.id, record.repo_root_abs, 'explicit');
637
- // Unknown explicit id: cascade rather than fail closed (matches TS resolver).
696
+ return unresolved(cwdBase, new VaultResolutionError(
697
+ VAULT_NOT_FOUND,
698
+ `--vault ${options.vaultId}: no registered vault has that id on this machine. List them with \`hs vault list\`; register a checkout with \`hs vault register <path>\`.`,
699
+ { fix: 'hs vault list', from: cwdBase, details: { vaultId: options.vaultId } },
700
+ ));
638
701
  }
639
702
 
640
- // Tier 2 — cwd-contained vault (HS-C87T). Backward-compat note: the OLD
641
- // getConfig() fell back to CLAUDE_PROJECT_DIR then process.cwd() as a bare
642
- // repo root. We keep honoring CLAUDE_PROJECT_DIR as the cwd base (an
643
- // explicit `options.cwd` caller/test override still takes precedence over
644
- // it), but now WALK UP from that base looking for a heyspex.yaml marker or
645
- // a specs/ dir at any ancestor level — not just the base and its immediate
646
- // parent — so the vault the process was actually started inside/under wins
647
- // over a stale registry pointer from a different terminal/console tab.
648
- const cwdBase = resolve(
649
- isNonEmptyString(options.cwd)
650
- ? options.cwd
651
- : (isNonEmptyString(process.env.CLAUDE_PROJECT_DIR)
652
- ? process.env.CLAUDE_PROJECT_DIR
653
- : process.cwd()),
654
- );
655
- //
656
- // A SATELLITE repo ([[satellite.ts]]) has no marker of its own — it records
657
- // an explicit attachment in its MCP manifests instead — so the walk checks
658
- // BOTH signals at each level and takes the nearest: an explicit attachment
659
- // in this repo beats a vault that merely happens to sit further up the
660
- // tree (e.g. two sibling checkouts under one parent that has a stray
661
- // `specs/`), and both still beat the machine-global pointer at tier 4.
662
- const cwdMatch = findCwdVaultOrAttachment(cwdBase, options.cwdWalkRoot);
703
+ // Tier 2 — HEYSPEX_VAULT_ROOT / HEYSPEX_VAULT_DIR env: a TRUE override, above
704
+ // the walk (owner ruling 2026-10-14, [[HS-TYJA]]). It must name a vault
705
+ // checkout: a stale pin at a CODE repo (the old plugin manifest's
706
+ // HEYSPEX_VAULT_ROOT=${CLAUDE_PROJECT_DIR}) is refused with VAULT_NOT_FOUND
707
+ // instead of silently becoming "the vault".
708
+ const envRoot = env.HEYSPEX_VAULT_ROOT;
709
+ if (isNonEmptyString(envRoot)) {
710
+ const root = resolve(envRoot.trim());
711
+ // Reverse-resolve the pinned root to its registered id when the registry
712
+ // knows that repo ([[HS-XOZ3-DIAG-NWG6-vault-identity-split-reconciled-to]]).
713
+ const envRecord = findVaultByRoot(registry, root);
714
+ // Accepted: a vault checkout on disk, or a root the registry registers as
715
+ // a vault (with or without a local checkout - cloud reliance). Refused:
716
+ // anything else, e.g. an unregistered code repo (the old plugin pin
717
+ // HEYSPEX_VAULT_ROOT=${CLAUDE_PROJECT_DIR}) or a missing unknown path.
718
+ if (!isVaultCheckoutDir(root) && !envRecord) {
719
+ return unresolved(cwdBase, envNotVaultError('HEYSPEX_VAULT_ROOT', envRoot, root, cwdBase));
720
+ }
721
+ const envMarkerId = envRecord ? null : readMarkerVaultId(root);
722
+ return finalize(envRecord?.id ?? envMarkerId ?? UNREGISTERED_VAULT_ID, root, 'env-vault-root');
723
+ }
724
+ const envDir = env.HEYSPEX_VAULT_DIR;
725
+ if (isNonEmptyString(envDir)) {
726
+ // Historically points AT the specs/ dir itself, so repo root = its parent
727
+ // (specsDirAbs then round-trips back).
728
+ const specsDirFromEnv = resolve(envDir.trim());
729
+ const root = dirname(specsDirFromEnv);
730
+ const envDirRecord = findVaultByRoot(registry, root);
731
+ let ok = false;
732
+ try {
733
+ ok = statSync(specsDirFromEnv).isDirectory() && basename(specsDirFromEnv).toLowerCase() === 'specs';
734
+ } catch {
735
+ ok = false;
736
+ }
737
+ // A registered vault root names a vault even without a local specs/.
738
+ if (!ok && envDirRecord) ok = true;
739
+ if (!ok) return unresolved(cwdBase, envNotVaultError('HEYSPEX_VAULT_DIR', envDir, specsDirFromEnv, cwdBase));
740
+ return finalize(envDirRecord?.id ?? readMarkerVaultId(root) ?? UNREGISTERED_VAULT_ID, root, 'env-vault-dir');
741
+ }
742
+
743
+ // Tier 3 — the folder the process was launched in. Walk UP from the cwd base
744
+ // to the filesystem root; at each level the nearest of: a satellite pin, a
745
+ // vault checkout, a heyspex.yaml repo binding. A linked git worktree whose
746
+ // own walk finds nothing resolves like its main checkout.
747
+ let cwdMatch = findCwdVaultOrAttachment(cwdBase, options.cwdWalkRoot);
748
+ let worktreeOf = null;
749
+ if (!cwdMatch && !isNonEmptyString(options.cwdWalkRoot)) {
750
+ const main = mainCheckoutOfWorktree(cwdBase);
751
+ if (main) {
752
+ cwdMatch = findCwdVaultOrAttachment(canonicalDir(main));
753
+ if (cwdMatch) worktreeOf = resolve(main);
754
+ }
755
+ }
756
+ const tag = (r) => (worktreeOf ? { ...r, worktreeOf } : r);
757
+ if (cwdMatch?.binding) {
758
+ // A bound CODE repo ([[HS-3PEQ]] `heyspex.yaml` `vault:`). The binding's id
759
+ // is the vault; this machine's registry row for that id says where its
760
+ // checkout is.
761
+ try {
762
+ const located = locateBoundVault(cwdMatch.binding, { vaults: registryVaults(registry?.raw) }, {
763
+ bindingDir: cwdMatch.repoRootAbs,
764
+ from: cwdBase,
765
+ });
766
+ if (located.notice) noticeOnce(`[heyspex] vault resolution: ${located.notice}\n`);
767
+ return tag({
768
+ ...finalize(located.vaultId, located.repoRootAbs, 'repo-binding'),
769
+ boundRepoAbs: resolve(cwdMatch.repoRootAbs),
770
+ });
771
+ } catch (err) {
772
+ return unresolved(cwdBase, err);
773
+ }
774
+ }
663
775
  if (cwdMatch) {
664
- // The override notice is for an ACCIDENT of where the process started —
665
- // "you are inside vault X, the registry says Y". A satellite attachment
666
- // is not an accident: the repo names its vault on disk, deliberately, and
667
- // that pin is meant to outrank the machine-global pointer. Warning on it
668
- // would fire on every single satellite session. Stays suppressed.
669
- if (!cwdMatch.satellite) maybeLogCwdOverrideNotice(cwdMatch.repoRootAbs, registry);
670
776
  if (cwdMatch.satellite) {
671
- // `'satellite-pin'`, not `'env-vault-root'`: nothing set an env var —
672
- // the walk read an on-disk manifest pin. See finalize()'s doc comment
673
- // for the gate sites that consume this (all of them reject only
674
- // `'cwd-heuristic'`, so both labels are accepted identically).
777
+ // `'satellite-pin'`: the walk read an on-disk manifest pin (no env var).
675
778
  const pinnedRecord = findVaultByRoot(registry, cwdMatch.repoRootAbs);
676
- // A committed satellite marker names the vault by id: that id IS the
677
- // binding (works on a fresh machine with no registry row and no clone).
678
- const markerId = cwdMatch.markerSatellite?.vaultId ?? null;
679
- const bound = finalize(
680
- markerId ?? pinnedRecord?.id ?? UNREGISTERED_VAULT_ID,
779
+ const marked = cwdMatch.markerSatellite;
780
+ if (marked) {
781
+ // A committed satellite marker names the vault by id: that id IS the
782
+ // binding. `boundRepoAbs` is the satellite (a code repo, never a
783
+ // vault); `vaultCloned: false` means `repoRootAbs` is that satellite,
784
+ // NOT a vault checkout - disk writers (`hs pull --apply`, the mirror
785
+ // catch-up hook) must refuse it.
786
+ const bound = {
787
+ ...finalize(marked.vaultId ?? pinnedRecord?.id ?? UNREGISTERED_VAULT_ID, cwdMatch.repoRootAbs, 'satellite-pin'),
788
+ boundRepoAbs: resolve(cwdMatch.satelliteDir),
789
+ vaultPath: marked.vaultPath,
790
+ };
791
+ return tag(marked.vaultRootAbs ? bound : { ...bound, vaultCloned: false });
792
+ }
793
+ return tag(finalize(
794
+ pinnedRecord?.id ?? UNREGISTERED_VAULT_ID,
681
795
  cwdMatch.repoRootAbs,
682
796
  'satellite-pin',
683
- );
684
- // The marker names a vault that is NOT cloned on this machine: the
685
- // binding is cloud-only. Disk readers must not report an empty vault -
686
- // they answer from the cloud and say so (read-path.mjs).
687
- if (cwdMatch.markerSatellite && !cwdMatch.markerSatellite.vaultRootAbs) {
688
- return { ...bound, vaultCloned: false, vaultPath: cwdMatch.markerSatellite.vaultPath };
689
- }
690
- return bound;
797
+ ));
691
798
  }
692
- // A heyspex.yaml `vault_id` IS the vault's id ([[HS-VVNQ]]): `hs init` /
693
- // `hs connect` wrote it there. Before this, the id came ONLY from a
694
- // registry row keyed by this root, so a connected repo on a machine
695
- // whose registry lacked (or mis-keyed) that row resolved to the
696
- // `unregistered` sentinel and every console call was refused. The
697
- // registry row is still consulted — as the thing the marker is checked
698
- // against, not as the source.
799
+ // A heyspex.yaml `vault_id` IS the vault's id ([[HS-VVNQ]]); the registry
800
+ // row is the thing the marker is checked against, not the source.
699
801
  const markerRecord = findVaultByRoot(registry, cwdMatch.repoRootAbs);
700
802
  const markerVaultId = readMarkerVaultId(cwdMatch.repoRootAbs);
701
803
  if (markerVaultId) {
702
804
  if (markerRecord && markerRecord.id !== markerVaultId) {
703
805
  logMarkerRegistryMismatch(cwdMatch.repoRootAbs, markerVaultId, markerRecord.id);
704
- return finalize(markerVaultId, cwdMatch.repoRootAbs, 'marker-registry-mismatch');
806
+ return tag(finalize(markerVaultId, cwdMatch.repoRootAbs, 'marker-registry-mismatch'));
705
807
  }
706
- return finalize(markerVaultId, cwdMatch.repoRootAbs, 'cwd-marker');
808
+ return tag(finalize(markerVaultId, cwdMatch.repoRootAbs, 'cwd-marker'));
707
809
  }
708
- // No `vault_id` on the marker (or a bare specs/ dir): same reverse lookup
709
- // as the satellite and env tiers — a vault the registry knows reports its
710
- // REGISTERED id, so the console-scoping header (console-fetch.mjs
711
- // `getVaultIdHeader`) can name it ([[HS-M95C]]).
712
- return finalize(markerRecord?.id ?? UNREGISTERED_VAULT_ID, cwdMatch.repoRootAbs, 'cwd-heuristic');
810
+ // No `vault_id` on the marker (or a bare specs/ dir): the registry row id
811
+ // for this root, else the `unregistered` sentinel.
812
+ return tag(finalize(markerRecord?.id ?? UNREGISTERED_VAULT_ID, cwdMatch.repoRootAbs, 'cwd-heuristic'));
713
813
  }
714
814
 
715
- // Tier 3 — HEYSPEX_VAULT_ROOT / HEYSPEX_VAULT_DIR env (legacy escape hatch).
716
- // [[HS-VVNQ]]: BELOW the walk. The plugin manifest sets
717
- // HEYSPEX_VAULT_ROOT=${CLAUDE_PROJECT_DIR} on every session, so an env tier
718
- // above the walk outranked every satellite pin and heyspex.yaml marker on
719
- // disk. It still outranks the machine-global registry pointer.
720
- const envRoot = process.env.HEYSPEX_VAULT_ROOT;
721
- if (isNonEmptyString(envRoot)) {
722
- // Reverse-resolve the pinned root to its registered id when the registry
723
- // knows that repo ([[HS-XOZ3-DIAG-NWG6-vault-identity-split-reconciled-to]]).
724
- const envRecord = findVaultByRoot(registry, envRoot);
725
- return finalize(envRecord?.id ?? UNREGISTERED_VAULT_ID, resolve(envRoot), 'env-vault-root');
726
- }
727
- const envDir = process.env.HEYSPEX_VAULT_DIR;
728
- if (isNonEmptyString(envDir)) {
729
- // Historically points AT the specs/ dir itself, so repo root = its parent
730
- // (specsDirAbs then round-trips back).
731
- // Same reverse lookup as HEYSPEX_VAULT_ROOT, matching the TS resolver.
732
- const specsDirFromEnv = resolve(envDir);
733
- const envDirRecord = findVaultByRoot(registry, dirname(specsDirFromEnv));
734
- return finalize(
735
- envDirRecord?.id ?? UNREGISTERED_VAULT_ID,
736
- dirname(specsDirFromEnv),
737
- 'env-vault-dir',
738
- );
739
- }
815
+ // Tier 4 — nothing names a vault: UNBOUND. The registry's active pointer is
816
+ // retired (owner ruling 2026-10-14: "there shouldn't be one active vault");
817
+ // it is never read here. Non-throwing API: callers gate on `source` (see
818
+ // {@link isResolvedVault}); `error` carries the typed VAULT_UNBOUND.
819
+ return unresolved(cwdBase, unboundError(cwdBase, registryVaults(registry?.raw)));
820
+ }
740
821
 
741
- // Tier 4 — registry active_vault_id (the OFFLINE active pointer). Reached
742
- // only when cwd sits inside no vault at all (the walk found neither marker
743
- // anywhere up the tree) and no env var names one. This is how an MCP
744
- // restart picks up a CLI `hs vault use` switch without Neon. Mirrors the TS
745
- // resolver's account-pointer tier, sourced from the registry file here.
746
- // The ORDER is pinned (decision memory `vault-resolver-precedence`); what a
747
- // consumer does with `source: 'account-pointer'` is its own call — the
748
- // console transport (console-fetch.mjs `vaultIdHeaderFor`) REFUSES
749
- // vault-scoped requests on it (`VAULT_NOT_PINNED`) unless
750
- // HEYSPEX_ALLOW_POINTER_VAULT=1, because the pointer mirrors whatever the
751
- // owner last clicked in the console, not anything this repo declared.
752
- if (registry && isNonEmptyString(registry.active_vault_id)) {
753
- const record = findVaultById(registry, registry.active_vault_id);
754
- if (record) return finalize(record.id, record.repo_root_abs, 'account-pointer');
755
- // Stale/corrupt pointer (no matching row): cascade rather than throw.
822
+ /** VAULT_NOT_FOUND for an env override that names no vault checkout. */
823
+ function envNotVaultError(name, raw, abs, cwdBase) {
824
+ const shown = abs.replace(/\\/g, '/');
825
+ return new VaultResolutionError(
826
+ VAULT_NOT_FOUND,
827
+ `${name}=${raw} does not name a vault checkout (${shown} ${name === 'HEYSPEX_VAULT_DIR' ? 'is not an existing specs/ folder' : 'has no specs/ folder and no vault heyspex.yaml'}). ` +
828
+ `Unset ${name} so the vault bound to this repo is used (\`hs vault resolve\` shows it), or point it at a vault checkout.`,
829
+ { fix: `unset ${name}`, from: cwdBase, details: { env: name, value: raw, path: shown } },
830
+ );
831
+ }
832
+
833
+ /** The non-throwing failure shape: sentinel id, source 'unbound', typed `error`. */
834
+ function unresolved(cwdBase, error) {
835
+ return { ...finalize(UNREGISTERED_VAULT_ID, cwdBase, 'unbound'), error };
836
+ }
837
+
838
+ const _noticed = new Set();
839
+ function noticeOnce(line) {
840
+ if (_noticed.has(line)) return;
841
+ _noticed.add(line);
842
+ try {
843
+ process.stderr.write(line);
844
+ } catch {
845
+ /* a stderr failure never becomes a resolution failure */
756
846
  }
847
+ }
848
+
849
+ /**
850
+ * Did resolution name a vault? False only for `'unbound'` (nothing named one,
851
+ * or a repo binding points at a vault this machine cannot find). A
852
+ * `'cwd-heuristic'` result is a real checkout (a bare specs/ dir); callers that
853
+ * also refuse unregistered roots check `vaultId` themselves.
854
+ * @param {{ source?: string } | null | undefined} resolved
855
+ */
856
+ export function isResolvedVault(resolved) {
857
+ return Boolean(resolved) && resolved.source !== 'unbound';
858
+ }
757
859
 
758
- // Tier 5 — last-resort cwd fallback: no marker found anywhere up the tree,
759
- // no registry pointer either. Preserves the pre-existing default value old
760
- // getConfig() would have produced.
761
- return finalize(UNREGISTERED_VAULT_ID, cwdBase, 'cwd-heuristic');
860
+ /**
861
+ * Strict form of {@link resolveActiveVault}: returns the same shape, or THROWS
862
+ * the typed {@link VaultResolutionError} (VAULT_UNBOUND / VAULT_BINDING_BROKEN)
863
+ * when nothing names a vault. Use from commands that must stop (spexcode, `hs`).
864
+ * @param {Parameters<typeof resolveActiveVault>[0]} [options]
865
+ */
866
+ export function resolveVaultOrThrow(options = {}) {
867
+ const resolved = resolveActiveVault(options);
868
+ if (resolved.source === 'unbound') throw resolved.error;
869
+ return resolved;
762
870
  }
763
871
 
764
872
  let _cached = null;