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.
- package/README.md +10 -0
- package/bin/hs-vault-binding.mjs +157 -0
- package/bin/hs.mjs +81 -77
- package/bin/spexcode.mjs +91 -0
- package/dev-factory/engine/worktree.mjs +36 -7
- package/dev-factory/lib/epic-detect.mjs +2 -2
- package/dev-factory/lib/fleet-readiness.mjs +3 -5
- package/dev-factory/lib/fleet-state.mjs +2 -2
- package/dev-factory/lib/sufficiency-score.mjs +3 -5
- package/dist/cli/hs-check.mjs +76 -46
- package/dist/cli/hs-claim.mjs +63 -33
- package/dist/cli/hs-connect.mjs +520 -214
- package/dist/cli/hs-defer.mjs +161 -19
- package/dist/cli/hs-dev.mjs +11 -5
- package/dist/cli/hs-env.mjs +187 -161
- package/dist/cli/hs-index-rebuild.mjs +123 -78
- package/dist/cli/hs-index-reconcile.mjs +203 -107
- package/dist/cli/hs-init.mjs +426 -179
- package/dist/cli/hs-install.mjs +759 -437
- package/dist/cli/hs-join.mjs +590 -383
- package/dist/cli/hs-vault-pull.mjs +359 -254
- package/dist/cli/hs-vault-push.mjs +151 -113
- package/dist/cli/hs-vault.mjs +271 -104
- package/dist/cli/hs-workspace.mjs +90 -57
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
- package/docs/copilot-plugin-install.md +189 -177
- package/docs/driving-heyspex-from-copilot.md +174 -165
- package/docs/mcp-client-config.md +93 -25
- package/package.json +255 -250
- package/plugins/heyspex/.mcp.json +3 -4
- package/plugins/heyspex/README.md +323 -312
- package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
- package/plugins/heyspex/mcp.json +12 -13
- package/scripts/check-mcp-freshness.mjs +6 -4
- package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
- package/scripts/claude-hooks/research-method-guard.mjs +2 -2
- package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
- package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
- package/scripts/claude-statusline/statusline-command.mjs +1058 -0
- package/scripts/dev-server-status.mjs +3 -2
- package/scripts/dev-server-stop.mjs +3 -2
- package/scripts/hs-capture-cli.mjs +2 -2
- package/scripts/hs-settings.mjs +2 -2
- package/scripts/hs-view.mjs +3 -2
- package/scripts/install-copilot-hooks.mjs +64 -34
- package/scripts/lib/copilot-cli.mjs +108 -0
- package/scripts/lib/customer-kit.mjs +223 -30
- package/scripts/lib/dev-mode.mjs +28 -2
- package/scripts/lib/shared-path-helpers.mjs +32 -0
- package/scripts/lib/standalone-doctor.mjs +131 -32
- package/scripts/wip-ledger-health.mjs +3 -2
- package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
- package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
- package/spexharness/src/intent-directive.mjs +839 -626
- package/spexharness/src/rule-table.mjs +1 -1
- package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
- package/vendor/heyspex-mcp/cli.mjs +20 -4
- package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
- package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
- package/vendor/heyspex-mcp/events-tools.mjs +1 -1
- package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
- package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
- package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +59 -23
- package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
- package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
- 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`)
|
|
18
|
-
* (explicit
|
|
19
|
-
*
|
|
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)
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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.
|
|
64
|
-
*
|
|
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 {
|
|
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
|
|
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 }>,
|
|
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
|
-
|
|
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
|
|
461
|
-
* code repo (e.g. mlc-app) commits a `heyspex.yaml` carrying its
|
|
462
|
-
* plus a RELATIVE `vault_path` (e.g. `../mlc-specs`) to where the
|
|
463
|
-
* vault is usually cloned.
|
|
464
|
-
*
|
|
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
|
|
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
|
|
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) &&
|
|
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
|
-
//
|
|
547
|
-
//
|
|
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
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
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
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
|
597
|
-
*
|
|
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'|'
|
|
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
|
|
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
|
-
* - `
|
|
622
|
-
*
|
|
623
|
-
*
|
|
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
|
|
626
|
-
*
|
|
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).
|
|
633
|
-
// an explicit ask
|
|
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
|
-
|
|
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 —
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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'
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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]])
|
|
693
|
-
//
|
|
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):
|
|
709
|
-
//
|
|
710
|
-
|
|
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
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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;
|