heyspex 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +10 -0
  2. package/bin/hs-vault-binding.mjs +157 -0
  3. package/bin/hs.mjs +99 -81
  4. package/bin/spexcode.mjs +91 -0
  5. package/dev-factory/engine/worktree.mjs +36 -7
  6. package/dev-factory/lib/epic-detect.mjs +2 -2
  7. package/dev-factory/lib/fleet-readiness.mjs +3 -5
  8. package/dev-factory/lib/fleet-state.mjs +2 -2
  9. package/dev-factory/lib/sufficiency-score.mjs +3 -5
  10. package/dist/cli/hs-check.mjs +76 -46
  11. package/dist/cli/hs-claim.mjs +63 -33
  12. package/dist/cli/hs-connect.mjs +520 -214
  13. package/dist/cli/hs-defer.mjs +161 -19
  14. package/dist/cli/hs-dev.mjs +11 -5
  15. package/dist/cli/hs-env.mjs +187 -161
  16. package/dist/cli/hs-index-rebuild.mjs +123 -78
  17. package/dist/cli/hs-index-reconcile.mjs +203 -107
  18. package/dist/cli/hs-init.mjs +426 -179
  19. package/dist/cli/hs-install.mjs +759 -437
  20. package/dist/cli/hs-join.mjs +590 -383
  21. package/dist/cli/hs-vault-pull.mjs +359 -254
  22. package/dist/cli/hs-vault-push.mjs +151 -113
  23. package/dist/cli/hs-vault.mjs +271 -104
  24. package/dist/cli/hs-workspace.mjs +90 -57
  25. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
  26. package/docs/copilot-plugin-install.md +189 -162
  27. package/docs/driving-heyspex-from-copilot.md +174 -165
  28. package/docs/mcp-client-config.md +93 -25
  29. package/package.json +255 -249
  30. package/plugins/heyspex/.mcp.json +3 -4
  31. package/plugins/heyspex/README.md +323 -309
  32. package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
  33. package/plugins/heyspex/mcp.json +12 -13
  34. package/scripts/check-mcp-freshness.mjs +6 -4
  35. package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
  36. package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
  37. package/scripts/claude-hooks/research-method-guard.mjs +2 -2
  38. package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
  39. package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
  40. package/scripts/claude-statusline/statusline-command.mjs +1058 -0
  41. package/scripts/dev-server-status.mjs +3 -2
  42. package/scripts/dev-server-stop.mjs +3 -2
  43. package/scripts/hs-capture-cli.mjs +2 -2
  44. package/scripts/hs-settings.mjs +2 -2
  45. package/scripts/hs-view.mjs +3 -2
  46. package/scripts/install-copilot-hooks.mjs +318 -0
  47. package/scripts/lib/copilot-cli.mjs +108 -0
  48. package/scripts/lib/customer-kit.mjs +223 -30
  49. package/scripts/lib/dev-mode.mjs +28 -2
  50. package/scripts/lib/shared-path-helpers.mjs +32 -0
  51. package/scripts/lib/standalone-doctor.mjs +131 -32
  52. package/scripts/wip-ledger-health.mjs +3 -2
  53. package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
  54. package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
  55. package/spexharness/src/intent-directive.mjs +839 -626
  56. package/spexharness/src/rule-table.mjs +1 -1
  57. package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
  58. package/vendor/heyspex-mcp/cli.mjs +20 -4
  59. package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
  60. package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
  61. package/vendor/heyspex-mcp/events-tools.mjs +1 -1
  62. package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
  63. package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
  64. package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
  65. package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
  66. package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
  67. package/vendor/heyspex-mcp/tool-dispatch.mjs +59 -23
  68. package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
  69. package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
  70. package/vendor/heyspex-mcp/vault-resolve.mjs +353 -245
@@ -0,0 +1,839 @@
1
+ /**
2
+ * vault-binding.mjs — which vault a CODE repo writes to: the repo binding.
3
+ *
4
+ * Owner ruling 2026-10-14 ([[HS-TYJA]], amends decision
5
+ * `vault-resolver-precedence-explicit-then`): "there shouldn't be one active
6
+ * vault. All vaults are active at the same time. Vault is active when user is
7
+ * launched an agent in the repo folder belonging to a vault. Then that agent
8
+ * will write to that vault by default." So there is NO machine-global or
9
+ * account-global "active vault" in local resolution any more. A process finds
10
+ * its vault from the folder it was launched in, nothing else.
11
+ *
12
+ * ── The binding (HS-3PEQ: `heyspex.yaml` `vault:` is the home of "which vault
13
+ * this repo is") ─────────────────────────────────────────────────────────
14
+ * A code repo that is not itself a vault carries, at its root, a COMMITTABLE
15
+ * file (no machine path in it, so every clone on every machine shares it):
16
+ *
17
+ * kind: repo-binding
18
+ * binding_version: 1
19
+ * vault:
20
+ * id: 800f39cb-9245-412e-b957-e42fb80e2d8b
21
+ * name: heyspex-specs
22
+ * remote: github.com/pwhermanson/heyspex-specs
23
+ *
24
+ * - `id` is the vault's identity (the cloud vault id). `name` is for humans.
25
+ * `remote` is the vault repo's git identity (where to clone it from).
26
+ * - WHERE the vault is checked out is per machine: the registry row
27
+ * `~/.heyspex/registry.json` vaults[id].repo_root_abs. Resolution is
28
+ * heyspex.yaml id -> registry row by id -> its folder; no row (or no
29
+ * folder) on this machine -> VAULT_BINDING_BROKEN.
30
+ * - A vault checkout's OWN heyspex.yaml is a different file (top-level
31
+ * `vault_id:` / `vault_root:`, written by `hs init`); see
32
+ * {@link isVaultMarkerText}. The two can never be read as each other.
33
+ * - `linked_repos[]` in the registry is the per-machine REVERSE index (which
34
+ * folders on this machine are bound to which vault), written by the same
35
+ * command. Resolution never needs it: the walk finds the repo's own
36
+ * heyspex.yaml, so a moved or renamed CODE repo keeps resolving.
37
+ *
38
+ * ── Resolution contract (vault-resolve.mjs `resolveActiveVault`) ───────────
39
+ * Inputs: an explicit vault id (`--vault`, `options.vaultId`), the env
40
+ * (`HEYSPEX_VAULT_ROOT`, `HEYSPEX_VAULT_DIR`), and the cwd (`options.cwd`, else
41
+ * `CLAUDE_PROJECT_DIR`, else `process.cwd()`). Order, first match wins:
42
+ * 1. explicit vault id (must name a registry row, else
43
+ * VAULT_NOT_FOUND - never a silent cascade) 'explicit'
44
+ * 2. HEYSPEX_VAULT_ROOT / HEYSPEX_VAULT_DIR (a vault checkout on disk
45
+ * or a registered vault root, else VAULT_NOT_FOUND) 'env-vault-root' / 'env-vault-dir'
46
+ * 3. walk up from the cwd (symlinks/junctions resolved), nearest folder
47
+ * wins; at each level:
48
+ * satellite pin in .mcp.json / .cursor/mcp.json 'satellite-pin'
49
+ * vault checkout (specs/ or a vault-marker heyspex.yaml) 'cwd-marker' | 'marker-registry-mismatch' | 'cwd-heuristic'
50
+ * heyspex.yaml repo binding (this module) 'repo-binding'
51
+ * then, if the cwd is a linked git worktree and nothing matched,
52
+ * the same walk from its main checkout (worktrees resolve like their repo)
53
+ * 4. nothing -> source 'unbound' + a {@link VaultResolutionError} VAULT_UNBOUND
54
+ * on `result.error` (non-throwing API), or thrown by
55
+ * `resolveVaultOrThrow` (strict API).
56
+ * The registry's `active_vault_id` (and v2 per-account pointers) is NEVER read.
57
+ *
58
+ * ── Failure modes (stable `code`; each error carries message, `from` = the
59
+ * folder resolution walked from, and `fix` = the exact repair command) ───
60
+ * VAULT_UNBOUND cwd is in no vault checkout and no bound repo.
61
+ * Fix: `hs vault bind --vault <id|name>` in the repo.
62
+ * VAULT_BINDING_BROKEN heyspex.yaml binds vault <id>, but this machine's
63
+ * registry has no row for it. (A row whose folder
64
+ * is missing still resolves - cloud tools work by
65
+ * id - with a notice; the disk-tier gate refuses.)
66
+ * Fix: clone/`hs pull` + `hs vault register <path>`,
67
+ * then `hs vault bind --vault <id>`.
68
+ * VAULT_NOT_FOUND an explicit --vault / HEYSPEX_VAULT_ROOT /
69
+ * HEYSPEX_VAULT_DIR names no vault checkout, or
70
+ * `hs vault bind --vault X` names no registered vault.
71
+ * VAULT_BINDING_CONFLICT `hs vault bind` would re-point a repo that is
72
+ * already bound to another vault. Fix: `--force`.
73
+ * VAULT_BIND_TARGET_IS_VAULT `hs vault bind` run inside a vault checkout -
74
+ * it already resolves to itself; nothing to bind.
75
+ * REPO_NOT_FOUND the bind target folder does not exist.
76
+ * REGISTRY_UNREADABLE `hs vault bind` found an unparseable registry.json
77
+ * (it never overwrites one). Fix: `hs doctor --fix`.
78
+ * Callers: the MCP transport (console-fetch.mjs) returns these as a typed
79
+ * local refusal (`refusal: code`, nothing sent) per call - the server itself
80
+ * always boots; `hs` prints the message and exits 1 (`--json`: the
81
+ * {@link VaultResolutionError#toJSON} shape); spexcode / dev-factory print the
82
+ * message and stop before any run.
83
+ *
84
+
85
+ * Pure Node ESM, node builtins only (the MCP server and dev-factory keep their
86
+ * no-install contract). Every function that touches disk takes injectable
87
+ * paths so tests run on temp fixtures and never touch the real ~/.heyspex.
88
+ */
89
+
90
+ import {
91
+ copyFileSync,
92
+ existsSync,
93
+ mkdirSync,
94
+ readFileSync,
95
+ realpathSync,
96
+ renameSync,
97
+ statSync,
98
+ unlinkSync,
99
+ writeFileSync,
100
+ } from 'node:fs';
101
+ import { homedir } from 'node:os';
102
+ import path from 'node:path';
103
+
104
+ // ── Errors ──────────────────────────────────────────────────────────────────
105
+
106
+ export const VAULT_UNBOUND = 'VAULT_UNBOUND';
107
+ export const VAULT_BINDING_BROKEN = 'VAULT_BINDING_BROKEN';
108
+ export const VAULT_BINDING_CONFLICT = 'VAULT_BINDING_CONFLICT';
109
+ export const VAULT_NOT_FOUND = 'VAULT_NOT_FOUND';
110
+ export const VAULT_BIND_TARGET_IS_VAULT = 'VAULT_BIND_TARGET_IS_VAULT';
111
+ export const REPO_NOT_FOUND = 'REPO_NOT_FOUND';
112
+ export const REGISTRY_UNREADABLE = 'REGISTRY_UNREADABLE';
113
+
114
+ /**
115
+ * The one typed error for "which vault does this process write to" failures,
116
+ * shared by the MCP server, `hs` and spexcode. `code` is stable for machine
117
+ * matching; `fix` is the runnable repair command; `details` is JSON-safe.
118
+ */
119
+ export class VaultResolutionError extends Error {
120
+ /**
121
+ * @param {string} code one of the codes exported above
122
+ * @param {string} message human sentence(s), already naming the fix
123
+ * @param {{ fix?: string|null, from?: string|null, details?: Record<string, unknown> }} [extra]
124
+ * `fix`: the exact command that repairs it. `from`: the folder resolution
125
+ * walked from (or the folder the command acted on), forward slashes.
126
+ */
127
+ constructor(code, message, { fix = null, from = null, details = {} } = {}) {
128
+ super(message);
129
+ this.name = 'VaultResolutionError';
130
+ this.code = code;
131
+ this.fix = fix;
132
+ this.from = typeof from === 'string' && from ? displayPath(from) : null;
133
+ this.details = details;
134
+ }
135
+
136
+ /**
137
+ * Stable machine shape (`hs vault resolve --json`, MCP refusals). Keys are
138
+ * fixed: ok, error (= code, kept for older readers), code, message, from,
139
+ * fix, details.
140
+ */
141
+ toJSON() {
142
+ return { ok: false, error: this.code, code: this.code, message: this.message, from: this.from, fix: this.fix, details: this.details };
143
+ }
144
+ }
145
+
146
+ /** Duck-typed check that survives two copies of this module (vendored bundles). */
147
+ export function isVaultResolutionError(err) {
148
+ return Boolean(err) && (err instanceof VaultResolutionError || err.name === 'VaultResolutionError') && typeof err.code === 'string';
149
+ }
150
+
151
+ // ── Paths ───────────────────────────────────────────────────────────────────
152
+
153
+ /**
154
+ * Canonical comparison key for a filesystem path. Resolves junctions/symlinks
155
+ * when the path exists (`realpathSync.native`, so `C:\link` and its target
156
+ * compare equal), then: absolute, forward slashes, no trailing separator, and
157
+ * on case-insensitive platforms (win32, darwin) lower-cased — `C:\Dev\` and
158
+ * `c:/dev` name one directory there.
159
+ *
160
+ * @param {string} p
161
+ * @param {{ platform?: NodeJS.Platform, followLinks?: boolean }} [opts]
162
+ * @returns {string}
163
+ */
164
+ export function normalizeRepoPath(p, { platform = process.platform, followLinks = true } = {}) {
165
+ if (typeof p !== 'string' || !p.trim()) return '';
166
+ const win = platform === 'win32';
167
+ let abs = win ? path.win32.resolve(p.trim()) : path.resolve(p.trim());
168
+ if (followLinks) {
169
+ try {
170
+ abs = realpathSync.native(abs);
171
+ } catch {
172
+ /* missing path: compare the lexical form */
173
+ }
174
+ }
175
+ let out = abs.replace(/[\\/]+/g, '/');
176
+ if (out.length > 1) out = out.replace(/\/+$/, '');
177
+ if (/^[a-zA-Z]:$/.test(out)) out += '/';
178
+ return win || platform === 'darwin' ? out.toLowerCase() : out;
179
+ }
180
+
181
+ /** Same folder under {@link normalizeRepoPath}. */
182
+ export function samePath(a, b, opts) {
183
+ const na = normalizeRepoPath(a, opts);
184
+ return na !== '' && na === normalizeRepoPath(b, opts);
185
+ }
186
+
187
+ /** Absolute path in the form written to disk (forward slashes, no trailing slash). */
188
+ export function displayPath(p) {
189
+ return path.resolve(p).replace(/\\/g, '/').replace(/\/+$/, '');
190
+ }
191
+
192
+ function isDir(p) {
193
+ try {
194
+ return statSync(p).isDirectory();
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ // ── Git identity (filesystem only: no `git` spawn, so hooks stay fast) ─────
201
+
202
+ /**
203
+ * The git checkout containing `startDir`: walks up to the nearest `.git` (a
204
+ * directory, or a worktree/submodule `.git` FILE pointing at its git dir).
205
+ *
206
+ * @param {string} startDir
207
+ * @returns {{ root: string, gitDir: string, commonDir: string } | null}
208
+ */
209
+ export function findGitCheckout(startDir) {
210
+ let dir = path.resolve(startDir);
211
+ for (let i = 0; i < 256; i += 1) {
212
+ const dotGit = path.join(dir, '.git');
213
+ if (existsSync(dotGit)) {
214
+ let gitDir = dotGit;
215
+ if (!isDir(dotGit)) {
216
+ try {
217
+ const m = readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m);
218
+ if (m) gitDir = path.resolve(dir, m[1]);
219
+ } catch {
220
+ /* unreadable .git file: treat the dir as the root anyway */
221
+ }
222
+ }
223
+ let commonDir = gitDir;
224
+ try {
225
+ const rel = readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim();
226
+ if (rel) commonDir = path.resolve(gitDir, rel);
227
+ } catch {
228
+ /* not a linked worktree */
229
+ }
230
+ return { root: dir, gitDir, commonDir };
231
+ }
232
+ const parent = path.dirname(dir);
233
+ if (parent === dir) return null;
234
+ dir = parent;
235
+ }
236
+ return null;
237
+ }
238
+
239
+ /**
240
+ * Canonical identity of a git remote URL: `host/owner/repo`, lower-cased,
241
+ * without scheme, credentials, port or `.git`. `git@github.com:a/b.git`,
242
+ * `https://user:tok@github.com/A/b` and `ssh://git@github.com/a/b` all map to
243
+ * `github.com/a/b`. Returns null for anything unparseable.
244
+ *
245
+ * @param {string|null|undefined} url
246
+ * @returns {string|null}
247
+ */
248
+ export function normalizeRemote(url) {
249
+ if (typeof url !== 'string' || !url.trim()) return null;
250
+ let s = url.trim();
251
+ const scp = s.match(/^[^@/\s]+@([^:/\s]+):(.+)$/); // git@host:owner/repo
252
+ if (scp && !/^[a-z]+:\/\//i.test(s)) s = `${scp[1]}/${scp[2]}`;
253
+ else s = s.replace(/^[a-z+]+:\/\//i, '').replace(/^[^@/]*@/, '');
254
+ s = s.replace(/^([^/:]+):\d+\//, '$1/').replace(/\.git\/?$/i, '').replace(/\/+$/, '');
255
+ return s.includes('/') ? s.toLowerCase() : null;
256
+ }
257
+
258
+ /**
259
+ * The `origin` remote identity of the checkout containing `dir` (falls back
260
+ * to the first remote), or null.
261
+ * @param {string} dir
262
+ * @returns {string|null}
263
+ */
264
+ export function readGitRemoteIdentity(dir) {
265
+ const co = findGitCheckout(dir);
266
+ if (!co) return null;
267
+ let text;
268
+ try {
269
+ text = readFileSync(path.join(co.commonDir, 'config'), 'utf8');
270
+ } catch {
271
+ return null;
272
+ }
273
+ const remotes = [];
274
+ let current = null;
275
+ for (const line of text.split(/\r?\n/)) {
276
+ const sec = line.match(/^\s*\[\s*remote\s+"([^"]+)"\s*\]/);
277
+ if (sec) {
278
+ current = sec[1];
279
+ continue;
280
+ }
281
+ if (/^\s*\[/.test(line)) {
282
+ current = null;
283
+ continue;
284
+ }
285
+ const url = current && line.match(/^\s*url\s*=\s*(.+?)\s*$/);
286
+ if (url) remotes.push({ name: current, url: url[1] });
287
+ }
288
+ const pick = remotes.find((r) => r.name === 'origin') ?? remotes[0];
289
+ return pick ? normalizeRemote(pick.url) : null;
290
+ }
291
+
292
+ // ── heyspex.yaml `vault:` binding ────────────────────────────────────────────
293
+ //
294
+ // TWO different files are both named heyspex.yaml, and they must never be
295
+ // confused (a code repo would otherwise resolve as a vault, or a vault as a
296
+ // bound repo):
297
+ //
298
+ // VAULT MARKER (a vault checkout's own root, written by `hs init`):
299
+ // schema_version: 2
300
+ // vault_root: specs/
301
+ // vault_id: <uuid>
302
+ // Identified by the top-level `vault_id:` / `vault_root:` keys.
303
+ //
304
+ // REPO BINDING (a code repo, written by `hs vault bind`; COMMITTABLE):
305
+ // kind: repo-binding
306
+ // binding_version: 1
307
+ // vault:
308
+ // id: <uuid>
309
+ // name: <display name>
310
+ // remote: <host/owner/repo of the vault's git remote>
311
+ // Identified by `kind: repo-binding` + the `vault:` block. It carries the
312
+ // vault's stable identity ONLY - never a machine path. Where the vault is
313
+ // checked out on THIS machine is the registry's business
314
+ // (`~/.heyspex/registry.json` vaults[].repo_root_abs, per machine).
315
+ //
316
+ // A file carrying BOTH a vault-marker key and a `vault:` block is a vault
317
+ // marker (the checkout wins); its `vault:` block is ignored.
318
+
319
+ /** `kind:` value of a repo-binding heyspex.yaml. */
320
+ export const REPO_BINDING_KIND = 'repo-binding';
321
+ /** Bump when the binding block's shape changes. */
322
+ export const REPO_BINDING_VERSION = 1;
323
+
324
+ const BINDING_KEYS = ['id', 'name', 'remote'];
325
+ const MARKER_KEY_RE = /^(vault_id|vault_root):/m;
326
+ const OWN_TOP_KEYS_RE = /^(kind|binding_version|schema_version):/;
327
+ const OWN_HEADER_RE = /^# HeySpex repo binding/;
328
+ const OWN_HEADER_CONT_RE = /^# (write to the vault below|Agents launched in this repo write|checked out is per machine)/;
329
+
330
+ /** True when heyspex.yaml text is a VAULT MARKER (top-level vault_id / vault_root). */
331
+ export function isVaultMarkerText(text) {
332
+ return MARKER_KEY_RE.test(String(text ?? ''));
333
+ }
334
+
335
+ /** Top-level `kind:` of heyspex.yaml text, or null. */
336
+ function topLevelKind(text) {
337
+ const m = String(text ?? '').match(/^kind:\s*(.+?)\s*$/m);
338
+ return m ? m[1].replace(/^["']|["']$/g, '').trim() || null : null;
339
+ }
340
+
341
+ /**
342
+ * Parse the `vault:` binding out of heyspex.yaml text. Accepts the block form
343
+ * written by {@link renderRepoBinding} and the scalar shorthand `vault: <id>`.
344
+ * Returns null when there is no `vault:` key, it carries no id, the file is a
345
+ * vault marker ({@link isVaultMarkerText}), or `kind:` names something other
346
+ * than `repo-binding`. A legacy `path:` key (stage-1 files) is ignored.
347
+ *
348
+ * @param {string} text
349
+ * @returns {{ id: string, name: string|null, remote: string|null } | null}
350
+ */
351
+ export function parseRepoBinding(text) {
352
+ const src = String(text ?? '');
353
+ if (isVaultMarkerText(src)) return null;
354
+ const kind = topLevelKind(src);
355
+ if (kind !== null && kind !== REPO_BINDING_KIND) return null;
356
+ const lines = src.split(/\r?\n/);
357
+ const start = lines.findIndex((l) => /^vault:(\s|$)/.test(l));
358
+ if (start === -1) return null;
359
+ const unq = (v) => v.trim().replace(/\s+#.*$/, '').replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1').trim();
360
+ const scalar = lines[start].slice('vault:'.length).trim();
361
+ if (scalar && !scalar.startsWith('#')) {
362
+ const id = unq(scalar);
363
+ return id ? { id, name: null, remote: null } : null;
364
+ }
365
+ const out = { id: null, name: null, remote: null };
366
+ for (let i = start + 1; i < lines.length; i += 1) {
367
+ const l = lines[i];
368
+ if (!l.trim() || /^\s*#/.test(l)) continue;
369
+ if (!/^\s+/.test(l)) break; // next top-level key
370
+ const m = l.match(/^\s+([a-z_]+):\s*(.*)$/);
371
+ if (m && BINDING_KEYS.includes(m[1])) out[m[1]] = unq(m[2]) || null;
372
+ }
373
+ return out.id ? out : null;
374
+ }
375
+
376
+ /** heyspex.yaml path of a folder. */
377
+ function markerPath(dir) {
378
+ return path.join(dir, 'heyspex.yaml');
379
+ }
380
+
381
+ function readTextOrNull(file) {
382
+ try {
383
+ return readFileSync(file, 'utf8');
384
+ } catch {
385
+ return null;
386
+ }
387
+ }
388
+
389
+ /**
390
+ * The `vault:` binding in `<dir>/heyspex.yaml`, or null (no file, no block,
391
+ * a vault marker, unreadable). Never throws.
392
+ * @param {string} dir
393
+ */
394
+ export function readRepoBinding(dir) {
395
+ const text = readTextOrNull(markerPath(dir));
396
+ return text === null ? null : parseRepoBinding(text);
397
+ }
398
+
399
+ /**
400
+ * Is `dir` a bound CODE repo (a repo-binding heyspex.yaml and no `specs/` of
401
+ * its own)? Such a folder is never a vault root.
402
+ * @param {string} dir
403
+ */
404
+ export function isRepoBindingDir(dir) {
405
+ return !existsSync(path.join(dir, 'specs')) && readRepoBinding(dir) !== null;
406
+ }
407
+
408
+ /**
409
+ * New heyspex.yaml text for a repo binding. The canonical header, `kind`,
410
+ * `binding_version` and the `vault:` block are (re)written; any OTHER
411
+ * top-level content an existing file carries is preserved after them. Pure:
412
+ * the same inputs always give byte-identical output (idempotent re-binds).
413
+ *
414
+ * @param {string|null} existing current file text (null = no file)
415
+ * @param {{ id: string, name?: string|null, remote?: string|null }} binding
416
+ */
417
+ export function renderRepoBinding(existing, binding) {
418
+ const fmt = (v) => (/[:#'"\s{}[\],&*!|>%@`]/.test(v) ? JSON.stringify(v) : v);
419
+ const block = ['vault:'];
420
+ for (const k of BINDING_KEYS) {
421
+ const v = binding[k];
422
+ if (typeof v === 'string' && v.trim()) block.push(` ${k}: ${fmt(v.trim())}`);
423
+ }
424
+ const head = [
425
+ '# HeySpex repo binding - written by `hs vault bind`; commit this file.',
426
+ '# Agents launched in this repo write to the vault below. Where that vault is',
427
+ '# checked out is per machine (~/.heyspex/registry.json), never recorded here.',
428
+ `kind: ${REPO_BINDING_KIND}`,
429
+ `binding_version: ${REPO_BINDING_VERSION}`,
430
+ ...block,
431
+ ];
432
+ const eol = existing && existing.includes('\r\n') ? '\r\n' : '\n';
433
+ const rest = [];
434
+ if (existing && existing.trim()) {
435
+ const lines = existing.split(/\r?\n/);
436
+ let inVault = false;
437
+ let leading = true;
438
+ for (const l of lines) {
439
+ // Our own header comment (this and earlier wordings) leads the file;
440
+ // it is regenerated above, so drop it here.
441
+ if (leading && (OWN_HEADER_RE.test(l) || OWN_HEADER_CONT_RE.test(l))) continue;
442
+ leading = false;
443
+ if (/^vault:(\s|$)/.test(l)) {
444
+ inVault = true;
445
+ continue;
446
+ }
447
+ if (inVault) {
448
+ if (l === '' || /^\s+/.test(l)) continue;
449
+ inVault = false;
450
+ }
451
+ if (OWN_TOP_KEYS_RE.test(l)) continue;
452
+ rest.push(l);
453
+ }
454
+ }
455
+ while (rest.length && !rest[rest.length - 1].trim()) rest.pop();
456
+ while (rest.length && !rest[0].trim()) rest.shift();
457
+ return [...head, ...(rest.length ? ['', ...rest] : []), ''].join(eol);
458
+ }
459
+
460
+ /**
461
+ * Write a file atomically: temp file in the same folder, then rename over the
462
+ * target (a crash leaves either the old or the new file, never half of one).
463
+ */
464
+ function writeFileAtomic(file, text) {
465
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
466
+ writeFileSync(tmp, text, 'utf8');
467
+ try {
468
+ renameSync(tmp, file);
469
+ } catch (err) {
470
+ try {
471
+ unlinkSync(tmp);
472
+ } catch {
473
+ /* best effort */
474
+ }
475
+ throw err;
476
+ }
477
+ }
478
+
479
+ // ── Registry (~/.heyspex/registry.json) ────────────────────────────────────
480
+
481
+ /** Current shape of a `linked_repos[]` row; bump when the row shape changes. */
482
+ export const LINKED_REPO_ROW_VERSION = 1;
483
+
484
+ export function defaultRegistryDir() {
485
+ return path.join(homedir(), '.heyspex');
486
+ }
487
+
488
+ /**
489
+ * Read registry.json for the binding commands. Unlike the resolver's
490
+ * fail-open reader, a writer must not clobber a file it could not parse.
491
+ *
492
+ * @param {string} registryDir
493
+ * @returns {{ kind: 'missing' } | { kind: 'ok', json: Record<string, any>, text: string } | { kind: 'unreadable', error: string }}
494
+ */
495
+ export function readRegistryFile(registryDir) {
496
+ const file = path.join(registryDir, 'registry.json');
497
+ if (!existsSync(file)) return { kind: 'missing' };
498
+ let text;
499
+ try {
500
+ text = readFileSync(file, 'utf8');
501
+ const json = JSON.parse(text);
502
+ if (!json || typeof json !== 'object' || Array.isArray(json)) return { kind: 'unreadable', error: 'not a JSON object' };
503
+ return { kind: 'ok', json, text };
504
+ } catch (err) {
505
+ return { kind: 'unreadable', error: err instanceof Error ? err.message : String(err) };
506
+ }
507
+ }
508
+
509
+ /** Registered, non-archived vault rows: `{ id, name, root, remote }`. */
510
+ export function registryVaults(json) {
511
+ const rows = Array.isArray(json?.vaults) ? json.vaults : [];
512
+ return rows
513
+ .filter((v) => v && typeof v.id === 'string' && v.id.trim() && typeof v.repo_root_abs === 'string' && v.repo_root_abs.trim() && !v.archived)
514
+ .map((v) => ({
515
+ id: v.id,
516
+ name: typeof v.display_name === 'string' && v.display_name.trim() ? v.display_name : v.id,
517
+ root: path.resolve(v.repo_root_abs),
518
+ remote: normalizeRemote(v.github_remote),
519
+ }));
520
+ }
521
+
522
+ /** Normalized `linked_repos[]` rows (unknown fields preserved under `raw`). */
523
+ export function registryLinkedRepos(json) {
524
+ const rows = Array.isArray(json?.linked_repos) ? json.linked_repos : [];
525
+ return rows.filter((r) => r && typeof r.repo_root_abs === 'string' && typeof r.vault_id === 'string');
526
+ }
527
+
528
+ /**
529
+ * Upgrade a registry object in place to carry `linked_repos[]`. Additive and
530
+ * idempotent: `schema_version` is left as is (HS-HHLU: the key is optional, old
531
+ * readers ignore it; both loaders already tolerate unknown keys), and rows
532
+ * missing `row_version` are stamped. Returns true when anything changed.
533
+ */
534
+ export function migrateRegistryForBindings(json) {
535
+ let changed = false;
536
+ if (!Array.isArray(json.linked_repos)) {
537
+ json.linked_repos = [];
538
+ changed = true;
539
+ }
540
+ for (const r of json.linked_repos) {
541
+ if (r && typeof r === 'object' && r.row_version !== LINKED_REPO_ROW_VERSION) {
542
+ r.row_version = LINKED_REPO_ROW_VERSION;
543
+ changed = true;
544
+ }
545
+ }
546
+ return changed;
547
+ }
548
+
549
+ /**
550
+ * Write registry.json atomically (temp file + rename). Before the FIRST write
551
+ * that introduces `linked_repos[]` it copies the original to
552
+ * `registry.json.bak-pre-linked-repos` (once; never overwritten), so the
553
+ * migration is reversible.
554
+ */
555
+ function writeRegistryFile(registryDir, json, { originalText, migrated }) {
556
+ const file = path.join(registryDir, 'registry.json');
557
+ if (migrated && typeof originalText === 'string') {
558
+ const bak = path.join(registryDir, 'registry.json.bak-pre-linked-repos');
559
+ if (!existsSync(bak)) copyFileSync(file, bak);
560
+ }
561
+ mkdirSync(registryDir, { recursive: true });
562
+ writeFileAtomic(file, `${JSON.stringify(json, null, 2)}\n`);
563
+ }
564
+
565
+ // ── Resolving a binding to a vault checkout ────────────────────────────────
566
+
567
+ /** `hs vault bind --vault <id>` for the error/fix text (id quoted only when needed). */
568
+ function bindCommand(id, { force = false } = {}) {
569
+ const sel = /^[A-Za-z0-9._-]+$/.test(String(id)) ? id : JSON.stringify(String(id));
570
+ return `hs vault bind --vault ${sel}${force ? ' --force' : ''}`;
571
+ }
572
+
573
+ /**
574
+ * Where the vault named by `binding` lives on THIS machine: the registry row
575
+ * whose id is `binding.id` (a missing folder still resolves - the id is known,
576
+ * so cloud tools work - with a notice; the disk-tier gate refuses it). That is
577
+ * the only lookup -
578
+ * the binding file carries no path, and a registry row with the same git
579
+ * remote under a DIFFERENT id is a different vault identity, never silently
580
+ * substituted (it is named in the error instead). Throws VAULT_BINDING_BROKEN
581
+ * when this machine has no usable checkout of the bound vault.
582
+ *
583
+ * @param {{ id: string, name?: string|null, remote?: string|null }} binding
584
+ * @param {{ vaults: Array<{ id: string, name: string, root: string, remote: string|null }> }} registry
585
+ * @param {{ bindingDir: string, from?: string }} ctx `from`: the folder the walk started at
586
+ * @returns {{ vaultId: string, repoRootAbs: string, via: 'registry', notice: string|null }}
587
+ */
588
+ export function locateBoundVault(binding, registry, { bindingDir, from = bindingDir }) {
589
+ const vaults = registry?.vaults ?? [];
590
+ const row = vaults.find((v) => v.id === binding.id);
591
+ if (row && isDir(row.root)) {
592
+ return { vaultId: row.id, repoRootAbs: row.root, via: 'registry', notice: null };
593
+ }
594
+ if (row && !existsSync(row.root)) {
595
+ // Registered on this machine, no local checkout: the vault id is known,
596
+ // so cloud reads/writes work; the disk-tier gate refuses the absent root.
597
+ return {
598
+ vaultId: row.id,
599
+ repoRootAbs: row.root,
600
+ via: 'registry',
601
+ notice: `vault ${row.name} (${row.id}) bound in ${displayPath(bindingDir)}/heyspex.yaml has no local checkout at ${displayPath(row.root)}; cloud tools work, disk tools will refuse until it is cloned there.`,
602
+ };
603
+ }
604
+ const label = binding.name ? `${binding.name} (${binding.id})` : binding.id;
605
+ const sameRemote = binding.remote ? vaults.filter((v) => v.remote === binding.remote && v.id !== binding.id) : [];
606
+ const why = row
607
+ ? `the registry row for it points at ${displayPath(row.root)}, which is not a vault checkout`
608
+ : 'this machine has no registry row for that vault id';
609
+ const clone = binding.remote ? `git clone https://${binding.remote}.git` : 'clone the vault repo';
610
+ const hint = sameRemote.length
611
+ ? ` Note: registered vault ${sameRemote.map((v) => `${v.name} (${v.id})`).join(', ')} has the same git remote under a different id - if that is the same vault, re-bind with \`${bindCommand(sameRemote[0].id, { force: true })}\`.`
612
+ : '';
613
+ throw new VaultResolutionError(
614
+ VAULT_BINDING_BROKEN,
615
+ `${displayPath(bindingDir)}/heyspex.yaml binds this repo to vault ${label}, but ${why}. ` +
616
+ `Get a checkout of the vault on this machine (${clone}, or \`hs pull\` inside an existing checkout) and register it (\`hs vault register <path>\`), ` +
617
+ `then run \`${bindCommand(binding.id)}\` in ${displayPath(bindingDir)}.${hint}`,
618
+ {
619
+ fix: bindCommand(binding.id),
620
+ from,
621
+ details: { bindingDir: displayPath(bindingDir), binding, registryRoot: row ? displayPath(row.root) : null },
622
+ },
623
+ );
624
+ }
625
+
626
+ /**
627
+ * The VAULT_UNBOUND error for a cwd that resolved to nothing. Lists every
628
+ * registered vault so the message carries the exact bind command.
629
+ *
630
+ * @param {string} cwd
631
+ * @param {Array<{ id: string, name: string, root: string }>} vaults
632
+ */
633
+ export function unboundError(cwd, vaults) {
634
+ const repo = findGitCheckout(cwd)?.root ?? path.resolve(cwd);
635
+ const list = vaults.length
636
+ ? vaults.map((v) => ` ${v.name} ${v.id} ${displayPath(v.root)}`).join('\n')
637
+ : ' (none registered on this machine - clone a vault and run `hs vault register <path>`)';
638
+ const fix = vaults.length === 1 ? bindCommand(vaults[0].id) : 'hs vault bind --vault <name|id>';
639
+ return new VaultResolutionError(
640
+ VAULT_UNBOUND,
641
+ `No HeySpex vault is bound to ${displayPath(repo)}, and it is not inside a vault checkout. ` +
642
+ 'There is no default "active" vault: an agent writes to the vault bound to the repo it was launched in.\n' +
643
+ ` registered vaults:\n${list}\n` +
644
+ ` bind this repo: ${fix} (run in ${displayPath(repo)})\n` +
645
+ ' or override once: HEYSPEX_VAULT_ROOT=<vault checkout>',
646
+ { fix, from: cwd, details: { cwd: displayPath(cwd), repo: displayPath(repo), registered: vaults.map((v) => ({ id: v.id, name: v.name, root: displayPath(v.root) })) } },
647
+ );
648
+ }
649
+
650
+ // ── The commands (`hs vault bind` / `hs vault bindings`) ───────────────────
651
+
652
+ /**
653
+ * Find the registered vault a user named: id, display name (case-insensitive)
654
+ * or the path of its checkout.
655
+ */
656
+ export function findVaultBySelector(vaults, selector) {
657
+ if (typeof selector !== 'string' || !selector.trim()) return null;
658
+ const s = selector.trim();
659
+ return (
660
+ vaults.find((v) => v.id === s) ??
661
+ vaults.find((v) => v.name.toLowerCase() === s.toLowerCase()) ??
662
+ vaults.find((v) => samePath(v.root, s)) ??
663
+ null
664
+ );
665
+ }
666
+
667
+ /**
668
+ * Bind a code repo to a vault: write `<repo>/heyspex.yaml` (kind: repo-binding,
669
+ * the vault's id/name/remote - no path) and the registry `linked_repos[]` row
670
+ * (this machine's path for the repo). Idempotent (re-binding the same vault
671
+ * rewrites nothing and reports `unchanged`); refuses to re-point a bound repo
672
+ * without `force` (VAULT_BINDING_CONFLICT, naming the current binding); refuses
673
+ * a vault checkout (VAULT_BIND_TARGET_IS_VAULT). A registry row for the same
674
+ * git remote whose folder no longer exists is replaced (the repo was
675
+ * moved/renamed) and reported in `movedFrom`. Both writes are atomic; the
676
+ * registry is backed up once before `linked_repos[]` is first introduced.
677
+ *
678
+ * @param {{ repo?: string, cwd?: string, vault?: string|null, force?: boolean, dryRun?: boolean, registryDir?: string, now?: () => Date }} opts
679
+ * `repo`: folder to bind (default: the git checkout containing `cwd`, else `cwd`).
680
+ * `vault`: id, name or checkout path of a registered vault; when omitted the
681
+ * repo's existing binding is re-asserted (refreshes name/remote + registry row).
682
+ */
683
+ export function bindRepo({
684
+ repo,
685
+ cwd = process.cwd(),
686
+ vault = null,
687
+ force = false,
688
+ dryRun = false,
689
+ registryDir = defaultRegistryDir(),
690
+ now = () => new Date(),
691
+ } = {}) {
692
+ const repoRoot = path.resolve(repo ?? findGitCheckout(cwd)?.root ?? cwd);
693
+ if (!isDir(repoRoot)) {
694
+ throw new VaultResolutionError(REPO_NOT_FOUND, `The folder to bind does not exist: ${displayPath(repoRoot)}.`, {
695
+ fix: 'hs vault bind --repo <existing folder> --vault <name|id>',
696
+ from: cwd,
697
+ details: { repo: displayPath(repoRoot) },
698
+ });
699
+ }
700
+ const reg = readRegistryFile(registryDir);
701
+ if (reg.kind === 'unreadable') {
702
+ throw new VaultResolutionError(
703
+ REGISTRY_UNREADABLE,
704
+ `~/.heyspex/registry.json could not be parsed (${reg.error}); nothing was written. Repair it (or run \`hs doctor --fix\`) and retry.`,
705
+ { fix: 'hs doctor --fix', from: repoRoot, details: { registryDir: displayPath(registryDir) } },
706
+ );
707
+ }
708
+ const json = reg.kind === 'ok' ? reg.json : { schema_version: 1, workspaces: [], vaults: [] };
709
+ const vaults = registryVaults(json);
710
+
711
+ const existingText = readTextOrNull(markerPath(repoRoot));
712
+ if (isVaultMarkerText(existingText) || existsSync(path.join(repoRoot, 'specs'))) {
713
+ const self = vaults.find((v) => samePath(v.root, repoRoot));
714
+ throw new VaultResolutionError(
715
+ VAULT_BIND_TARGET_IS_VAULT,
716
+ `${displayPath(repoRoot)} is itself a vault checkout${self ? ` (${self.name}, ${self.id})` : ''}; agents launched here already write to it. Nothing to bind.`,
717
+ { fix: 'hs vault resolve', from: repoRoot, details: { repo: displayPath(repoRoot), vaultId: self?.id ?? null } },
718
+ );
719
+ }
720
+
721
+ const current = existingText === null ? null : parseRepoBinding(existingText);
722
+ const selector = vault ?? current?.id ?? null;
723
+ if (!selector) {
724
+ throw unboundError(repoRoot, vaults);
725
+ }
726
+ const target = findVaultBySelector(vaults, selector);
727
+ if (!target) {
728
+ const list = vaults.map((v) => ` ${v.name} ${v.id} ${displayPath(v.root)}`).join('\n') || ' (none)';
729
+ throw new VaultResolutionError(
730
+ VAULT_NOT_FOUND,
731
+ `No registered vault matches "${selector}" (by id, name or path).\n registered vaults:\n${list}\n Register the vault first: hs vault register <path>`,
732
+ { fix: 'hs vault register <path>', from: repoRoot, details: { selector } },
733
+ );
734
+ }
735
+ if (current && current.id !== target.id && !force) {
736
+ throw new VaultResolutionError(
737
+ VAULT_BINDING_CONFLICT,
738
+ `${displayPath(repoRoot)} is already bound to vault ${current.name ?? current.id} (${current.id}). ` +
739
+ `Re-run with --force to bind it to ${target.name} (${target.id}) instead.`,
740
+ { fix: bindCommand(target.id, { force: true }), from: repoRoot, details: { repo: displayPath(repoRoot), current, requested: { id: target.id, name: target.name } } },
741
+ );
742
+ }
743
+
744
+ const binding = { id: target.id, name: target.name, remote: target.remote ?? readGitRemoteIdentity(target.root) };
745
+ const nextText = renderRepoBinding(existingText, binding);
746
+ const yamlChanged = nextText !== existingText;
747
+
748
+ const migrated = migrateRegistryForBindings(json);
749
+ const repoNorm = normalizeRepoPath(repoRoot);
750
+ const repoRemote = readGitRemoteIdentity(repoRoot);
751
+ const rows = json.linked_repos;
752
+ const existingIdx = rows.findIndex((r) => r && typeof r.repo_root_abs === 'string' && normalizeRepoPath(r.repo_root_abs) === repoNorm);
753
+ let movedFrom = null;
754
+ if (existingIdx === -1 && repoRemote) {
755
+ const moved = rows.findIndex((r) => r && r.git_remote === repoRemote && typeof r.repo_root_abs === 'string' && !isDir(r.repo_root_abs));
756
+ if (moved !== -1) {
757
+ movedFrom = rows[moved].repo_root_abs;
758
+ rows.splice(moved, 1);
759
+ }
760
+ }
761
+ const prev = existingIdx === -1 ? null : rows[existingIdx];
762
+ const row = {
763
+ repo_root_abs: displayPath(repoRoot),
764
+ vault_id: target.id,
765
+ git_remote: repoRemote,
766
+ bound_at: prev && prev.vault_id === target.id && prev.bound_at ? prev.bound_at : now().toISOString(),
767
+ row_version: LINKED_REPO_ROW_VERSION,
768
+ };
769
+ const rowChanged = !prev || JSON.stringify(prev) !== JSON.stringify({ ...prev, ...row });
770
+ // Collapse duplicate rows for the same folder (e.g. `C:\x` and `c:/x/`).
771
+ const dupes = rows.filter((r) => r && typeof r.repo_root_abs === 'string' && normalizeRepoPath(r.repo_root_abs) === repoNorm);
772
+ const deduped = dupes.length > 1;
773
+ const kept = rows.filter((r) => !(r && typeof r.repo_root_abs === 'string' && normalizeRepoPath(r.repo_root_abs) === repoNorm));
774
+ kept.push(prev ? { ...prev, ...row } : row);
775
+ json.linked_repos = kept;
776
+
777
+ const registryChanged = rowChanged || migrated || Boolean(movedFrom) || deduped;
778
+ const status = !yamlChanged && !registryChanged ? 'unchanged' : current && current.id !== target.id ? 'rebound' : current ? 'refreshed' : 'bound';
779
+ if (!dryRun) {
780
+ if (yamlChanged) writeFileAtomic(markerPath(repoRoot), nextText);
781
+ if (registryChanged) writeRegistryFile(registryDir, json, { originalText: reg.kind === 'ok' ? reg.text : null, migrated });
782
+ }
783
+ return {
784
+ ok: true,
785
+ status,
786
+ dryRun,
787
+ repo: displayPath(repoRoot),
788
+ vault: { id: target.id, name: target.name, path: displayPath(target.root), remote: binding.remote },
789
+ previous: current && current.id !== target.id ? current : null,
790
+ movedFrom,
791
+ wrote: dryRun ? [] : [...(yamlChanged ? [displayPath(markerPath(repoRoot))] : []), ...(registryChanged ? [displayPath(path.join(registryDir, 'registry.json'))] : [])],
792
+ };
793
+ }
794
+
795
+ /**
796
+ * Every binding this machine knows (`linked_repos[]`), each health-checked
797
+ * against the repo's own heyspex.yaml. Status per row:
798
+ * ok heyspex.yaml binds the same vault and the vault is registered
799
+ * repo-missing the repo folder is gone (moved/renamed: re-run `hs vault bind` in the new folder)
800
+ * marker-missing the folder exists but its heyspex.yaml carries no binding
801
+ * marker-differs heyspex.yaml binds a different vault (it wins at resolution time)
802
+ * vault-unknown the bound vault id has no registry row
803
+ * Output is sorted by repo path so it is stable across runs.
804
+ *
805
+ * @param {{ registryDir?: string }} [opts]
806
+ */
807
+ export function listBindings({ registryDir = defaultRegistryDir() } = {}) {
808
+ const reg = readRegistryFile(registryDir);
809
+ const json = reg.kind === 'ok' ? reg.json : {};
810
+ const vaults = registryVaults(json);
811
+ const rows = registryLinkedRepos(json).map((r) => {
812
+ const v = vaults.find((x) => x.id === r.vault_id) ?? null;
813
+ let status = 'ok';
814
+ let marker = null;
815
+ if (!isDir(r.repo_root_abs)) status = 'repo-missing';
816
+ else {
817
+ marker = readRepoBinding(r.repo_root_abs);
818
+ if (!marker) status = 'marker-missing';
819
+ else if (marker.id !== r.vault_id) status = 'marker-differs';
820
+ else if (!v) status = 'vault-unknown';
821
+ }
822
+ return {
823
+ repo: displayPath(r.repo_root_abs),
824
+ vault_id: r.vault_id,
825
+ vault_name: v?.name ?? null,
826
+ vault_path: v ? displayPath(v.root) : null,
827
+ git_remote: r.git_remote ?? null,
828
+ bound_at: r.bound_at ?? null,
829
+ status,
830
+ ...(status === 'marker-differs' ? { marker_vault_id: marker.id } : {}),
831
+ };
832
+ });
833
+ rows.sort((a, b) => (normalizeRepoPath(a.repo, { followLinks: false }) < normalizeRepoPath(b.repo, { followLinks: false }) ? -1 : 1));
834
+ return {
835
+ registry: reg.kind,
836
+ vaults: vaults.map((v) => ({ id: v.id, name: v.name, path: displayPath(v.root) })).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
837
+ bindings: rows,
838
+ };
839
+ }