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
|
@@ -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
|
+
}
|