heyspex 0.1.0

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 (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,297 @@
1
+ /**
2
+ * vault-resolve.mjs — the MCP-side port of the console's active-vault resolver.
3
+ *
4
+ * Mirrors `spex-console/src/lib/vault-resolve/index.ts` (`resolveActiveVaultRoot`)
5
+ * so that, given the same registry/env state, the MCP face and the console face
6
+ * agree on which vault is active (same repo root; specs dir = repoRoot/specs).
7
+ * Replaces the divergent one-line resolution that used to live in `cli.mjs`
8
+ * `getConfig()` (`HEYSPEX_VAULT_ROOT || CLAUDE_PROJECT_DIR || cwd`) and the
9
+ * `hooks-engine.mjs` fallback (`HEYSPEX_VAULT_ROOT || cwd`). Convergence tracked
10
+ * under [[HS-ICDI-TASK]] / [[HS-PNH2-RES]].
11
+ *
12
+ * Pure Node ESM — only node:fs / node:path / node:os. NO import from
13
+ * spex-console, no TypeScript. The MCP process is standalone (Neon /
14
+ * DATABASE_URL may be absent), so this port does NOT make a network/account
15
+ * lookup. Instead it reads the offline registry file
16
+ * (`~/.heyspex/registry.json`, same shape as
17
+ * `spex-console/src/lib/vault-registry/registry-file.ts`) for tier-1
18
+ * (explicit vaultId) and tier-4 (the offline active-vault pointer). That
19
+ * offline pointer is how an MCP restart picks up a CLI `hs vault use` switch
20
+ * without Neon.
21
+ *
22
+ * Precedence stack (highest wins) — REORDERED by [[HS-C87T-0JNK-TASK]]: a
23
+ * packaged server started with cwd inside an empty target vault used to
24
+ * resolve to whatever vault another terminal/console last marked active on
25
+ * the machine (old tier order put the registry pointer above cwd), silently
26
+ * writing its bookkeeping into the WRONG repo. Now the folder the process was
27
+ * actually started in/under wins over the registry's stale-by-comparison
28
+ * pointer:
29
+ * 1. explicit vaultId -> registry lookup (vaults[].id)
30
+ * 2. HEYSPEX_VAULT_ROOT env -> repo root (legacy escape hatch)
31
+ * 2. HEYSPEX_VAULT_DIR env -> the specs/ dir itself (repo root = parent)
32
+ * 3. cwd-contained vault -> walk UP from cwd through every ancestor
33
+ * looking for a heyspex.yaml marker or a
34
+ * specs/ dir; the nearest match wins. Logs
35
+ * one stderr notice iff this differs from
36
+ * the registry's active vault (tier 4) —
37
+ * silent when they agree (the common case:
38
+ * the owner's own sessions start inside the
39
+ * platform repo, which is also the
40
+ * registry's active vault).
41
+ * 4. registry active_vault_id -> the offline active pointer (vaults[].id)
42
+ * — reached only when cwd sits inside no
43
+ * vault at all (tier 3 found nothing)
44
+ * 5. cwd fallback -> cwd itself (no marker found anywhere;
45
+ * preserves the pre-existing default)
46
+ *
47
+ * Resolution is SYNCHRONOUS (all inputs are fs/env) and, via getActiveConfig(),
48
+ * memoized once per process. If the registry can't be read, tier 4 simply
49
+ * finds nothing and resolution falls through to tier 5 — i.e. today's
50
+ * behavior, so nothing regresses when no registry exists.
51
+ */
52
+
53
+ import { existsSync, readFileSync } from 'node:fs';
54
+ import { homedir } from 'node:os';
55
+ import { dirname, join, resolve } from 'node:path';
56
+
57
+ /**
58
+ * Placeholder vaultId for tiers 3-5, which resolve a filesystem path but have
59
+ * no registry row to source a real id from. Mirrors UNREGISTERED_VAULT_ID in
60
+ * the TS resolver.
61
+ */
62
+ export const UNREGISTERED_VAULT_ID = 'unregistered';
63
+
64
+ /**
65
+ * Registry directory. Mirrors `registry-file.ts` `vaultRegistryDir`: default
66
+ * `~/.heyspex`, overridable via `opts.registryDir` (tests only — registry-file.ts
67
+ * uses an opts override, not an env var, so we do the same).
68
+ *
69
+ * @param {{ registryDir?: string }} [opts]
70
+ * @returns {string}
71
+ */
72
+ function vaultRegistryDir(opts = {}) {
73
+ return opts.registryDir ?? join(homedir(), '.heyspex');
74
+ }
75
+
76
+ /**
77
+ * Full path to `registry.json` under the resolved registry directory.
78
+ * @param {{ registryDir?: string }} [opts]
79
+ * @returns {string}
80
+ */
81
+ function vaultRegistryFilePath(opts = {}) {
82
+ return join(vaultRegistryDir(opts), 'registry.json');
83
+ }
84
+
85
+ function isNonEmptyString(v) {
86
+ return typeof v === 'string' && v.trim().length > 0;
87
+ }
88
+
89
+ /**
90
+ * Read + defensively parse `~/.heyspex/registry.json`. A missing or malformed
91
+ * file returns `null` (never throws) so callers fall through to env/cwd tiers —
92
+ * mirroring registry-file.ts `loadVaultRegistry`'s fail-closed contract.
93
+ *
94
+ * @param {{ registryDir?: string }} [opts]
95
+ * @returns {{ vaults: Array<{ id: string, repo_root_abs: string }>, active_vault_id: string|null }|null}
96
+ */
97
+ function loadRegistry(opts = {}) {
98
+ const filePath = vaultRegistryFilePath(opts);
99
+ if (!existsSync(filePath)) return null;
100
+ let json;
101
+ try {
102
+ json = JSON.parse(readFileSync(filePath, 'utf8'));
103
+ } catch {
104
+ return null;
105
+ }
106
+ if (!json || typeof json !== 'object') return null;
107
+
108
+ const vaultsRaw = Array.isArray(json.vaults) ? json.vaults : [];
109
+ const vaults = [];
110
+ for (const raw of vaultsRaw) {
111
+ if (!raw || typeof raw !== 'object') continue;
112
+ if (!isNonEmptyString(raw.id)) continue;
113
+ if (!isNonEmptyString(raw.repo_root_abs)) continue;
114
+ vaults.push({ id: raw.id, repo_root_abs: raw.repo_root_abs });
115
+ }
116
+ const active_vault_id = isNonEmptyString(json.active_vault_id) ? json.active_vault_id : null;
117
+ return { vaults, active_vault_id };
118
+ }
119
+
120
+ /** Find a vault row by id. Returns null when absent. */
121
+ function findVaultById(registry, vaultId) {
122
+ if (!registry || !isNonEmptyString(vaultId)) return null;
123
+ return registry.vaults.find((v) => v.id === vaultId) ?? null;
124
+ }
125
+
126
+ /**
127
+ * Walk UP from `startDir` through every ancestor directory looking for a
128
+ * `heyspex.yaml` marker file or a `specs/` dir — the two on-disk signals that
129
+ * a folder IS a vault root (mirrors `hs init`'s marker + the entity-model's
130
+ * folder layout). Returns the nearest matching ancestor (closest to
131
+ * `startDir` wins), or `null` if the walk reaches the filesystem root (or
132
+ * `stopAt`, inclusive) with no match. Bounded to guard against any path
133
+ * pathology; real filesystems never nest this deep.
134
+ *
135
+ * @param {string} startDir
136
+ * @param {string|undefined} stopAt - test-only walk boundary (checked, then
137
+ * the walk stops — never climbs past it). Unset in production, so the walk
138
+ * always reaches the real filesystem root; tests use it to fence the walk
139
+ * inside a disposable tmp fixture so ambient/unrelated directories higher up
140
+ * the real tree (e.g. a stray `specs` folder under the OS temp dir) can't
141
+ * produce a false match.
142
+ * @returns {string|null}
143
+ */
144
+ function findCwdVaultRoot(startDir, stopAt) {
145
+ let dir = startDir;
146
+ const boundary = isNonEmptyString(stopAt) ? resolve(stopAt) : null;
147
+ for (let i = 0; i < 256; i += 1) {
148
+ if (existsSync(join(dir, 'heyspex.yaml')) || existsSync(join(dir, 'specs'))) {
149
+ return dir;
150
+ }
151
+ if (boundary && dir === boundary) break; // hit the test-only fence
152
+ const parent = dirname(dir);
153
+ if (parent === dir) break; // reached filesystem root
154
+ dir = parent;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * When the cwd-contained vault (tier 3) wins, tell the operator if that
161
+ * silently overrode the registry's active vault — the customer-visible
162
+ * failure this task fixes (packaged server writing bookkeeping into the
163
+ * WRONG repo with no warning). Prints nothing when there is no registry
164
+ * pointer, the pointer is stale/unresolvable, or the two vaults are the same
165
+ * repo (the owner's own sessions: cwd = the platform repo = the registry's
166
+ * active vault too — must stay silent and unaffected).
167
+ *
168
+ * @param {string} cwdVaultRoot
169
+ * @param {{ vaults: Array<{id:string, repo_root_abs:string}>, active_vault_id: string|null }|null} registry
170
+ */
171
+ function maybeLogCwdOverrideNotice(cwdVaultRoot, registry) {
172
+ if (!registry || !isNonEmptyString(registry.active_vault_id)) return;
173
+ const activeRecord = findVaultById(registry, registry.active_vault_id);
174
+ if (!activeRecord) return;
175
+ const registryRoot = resolve(activeRecord.repo_root_abs);
176
+ const resolvedCwdRoot = resolve(cwdVaultRoot);
177
+ if (registryRoot === resolvedCwdRoot) return; // same vault — no notice
178
+ process.stderr.write(
179
+ `[heyspex-mcp] vault resolution: started inside ${resolvedCwdRoot} — overriding the registry's active vault ${registryRoot}, using the cwd vault.\n`,
180
+ );
181
+ }
182
+
183
+ /**
184
+ * Build the resolved shape from a repo root. specsDirAbs is ALWAYS repoRoot/specs
185
+ * regardless of which tier resolved the root — matching the TS resolver.
186
+ *
187
+ * @param {string} vaultId
188
+ * @param {string} repoRootAbs
189
+ * @param {'explicit'|'account-pointer'|'env-vault-root'|'env-vault-dir'|'cwd-heuristic'} source
190
+ */
191
+ function finalize(vaultId, repoRootAbs, source) {
192
+ const resolvedRoot = resolve(repoRootAbs);
193
+ return {
194
+ vaultId,
195
+ repoRootAbs: resolvedRoot,
196
+ specsDirAbs: join(resolvedRoot, 'specs'),
197
+ source,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Resolve the active vault synchronously via the 5-tier precedence stack.
203
+ *
204
+ * @param {{ vaultId?: string, cwd?: string, registryDir?: string, cwdWalkRoot?: string }} [options]
205
+ * - `vaultId`: explicit override (tier 1).
206
+ * - `cwd`: working dir for the cwd-contained-vault walk-up (tier 3; defaults
207
+ * to `CLAUDE_PROJECT_DIR` then `process.cwd()`).
208
+ * - `registryDir`: registry dir override (tests only; defaults to ~/.heyspex).
209
+ * - `cwdWalkRoot`: test-only fence for the tier-3 walk-up (see
210
+ * {@link findCwdVaultRoot}); production code never sets this.
211
+ * @returns {{ vaultId: string, repoRootAbs: string, specsDirAbs: string, source: string }}
212
+ */
213
+ export function resolveActiveVault(options = {}) {
214
+ const registry = loadRegistry(options);
215
+
216
+ // Tier 1 — explicit vaultId (CLI --vault / caller override). Unchanged:
217
+ // an explicit ask always wins over everything else.
218
+ if (isNonEmptyString(options.vaultId)) {
219
+ const record = findVaultById(registry, options.vaultId);
220
+ if (record) return finalize(record.id, record.repo_root_abs, 'explicit');
221
+ // Unknown explicit id: cascade rather than fail closed (matches TS resolver).
222
+ }
223
+
224
+ // Tier 2 — HEYSPEX_VAULT_ROOT / HEYSPEX_VAULT_DIR env (legacy escape hatch).
225
+ // [[HS-C87T-0JNK-TASK]]: moved ABOVE both the cwd-contained vault and the
226
+ // registry pointer — an explicit env override must win "regardless" of
227
+ // where the process was started or what the registry says is active.
228
+ const envRoot = process.env.HEYSPEX_VAULT_ROOT;
229
+ if (isNonEmptyString(envRoot)) {
230
+ return finalize(UNREGISTERED_VAULT_ID, resolve(envRoot), 'env-vault-root');
231
+ }
232
+ const envDir = process.env.HEYSPEX_VAULT_DIR;
233
+ if (isNonEmptyString(envDir)) {
234
+ // Historically points AT the specs/ dir itself, so repo root = its parent
235
+ // (specsDirAbs then round-trips back).
236
+ const specsDirFromEnv = resolve(envDir);
237
+ return finalize(UNREGISTERED_VAULT_ID, dirname(specsDirFromEnv), 'env-vault-dir');
238
+ }
239
+
240
+ // Tier 3 — cwd-contained vault (HS-C87T). Backward-compat note: the OLD
241
+ // getConfig() fell back to CLAUDE_PROJECT_DIR then process.cwd() as a bare
242
+ // repo root. We keep honoring CLAUDE_PROJECT_DIR as the cwd base (an
243
+ // explicit `options.cwd` caller/test override still takes precedence over
244
+ // it), but now WALK UP from that base looking for a heyspex.yaml marker or
245
+ // a specs/ dir at any ancestor level — not just the base and its immediate
246
+ // parent — so the vault the process was actually started inside/under wins
247
+ // over a stale registry pointer from a different terminal/console tab.
248
+ const cwdBase = resolve(
249
+ isNonEmptyString(options.cwd)
250
+ ? options.cwd
251
+ : (isNonEmptyString(process.env.CLAUDE_PROJECT_DIR)
252
+ ? process.env.CLAUDE_PROJECT_DIR
253
+ : process.cwd()),
254
+ );
255
+ const cwdVaultRoot = findCwdVaultRoot(cwdBase, options.cwdWalkRoot);
256
+ if (cwdVaultRoot) {
257
+ maybeLogCwdOverrideNotice(cwdVaultRoot, registry);
258
+ return finalize(UNREGISTERED_VAULT_ID, cwdVaultRoot, 'cwd-heuristic');
259
+ }
260
+
261
+ // Tier 4 — registry active_vault_id (the OFFLINE active pointer). Reached
262
+ // only when cwd sits inside no vault at all (tier 3 found neither marker
263
+ // anywhere up the tree) — e.g. an ad-hoc script dir. This is how an MCP
264
+ // restart picks up a CLI `hs vault use` switch without Neon. Mirrors the TS
265
+ // resolver's account-pointer tier, sourced from the registry file here.
266
+ if (registry && isNonEmptyString(registry.active_vault_id)) {
267
+ const record = findVaultById(registry, registry.active_vault_id);
268
+ if (record) return finalize(record.id, record.repo_root_abs, 'account-pointer');
269
+ // Stale/corrupt pointer (no matching row): cascade rather than throw.
270
+ }
271
+
272
+ // Tier 5 — last-resort cwd fallback: no marker found anywhere up the tree,
273
+ // no registry pointer either. Preserves the pre-existing default value old
274
+ // getConfig() would have produced.
275
+ return finalize(UNREGISTERED_VAULT_ID, cwdBase, 'cwd-heuristic');
276
+ }
277
+
278
+ let _cached = null;
279
+
280
+ /**
281
+ * Cached, process-lifetime resolution. Resolves once (default options) and
282
+ * memoizes — the MCP process is spawned once at session start, so re-resolving
283
+ * per call buys nothing. Use {@link resolveActiveVault} directly for per-call
284
+ * overrides (e.g. an explicit --vault), or {@link resetActiveConfigCache} in
285
+ * tests.
286
+ *
287
+ * @returns {{ vaultId: string, repoRootAbs: string, specsDirAbs: string, source: string }}
288
+ */
289
+ export function getActiveConfig() {
290
+ if (_cached === null) _cached = resolveActiveVault();
291
+ return _cached;
292
+ }
293
+
294
+ /** Test-only: clear the getActiveConfig() memo so a controlled env re-resolves. */
295
+ export function resetActiveConfigCache() {
296
+ _cached = null;
297
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Headless vault knob resolution for MCP — mirrors spex-console resolvers
3
+ * ([[HS-TASK-9PJV]]). Reads `spex-console/settings.json` first, then env, then
4
+ * shipped defaults. Repo root is the directory containing `spex-console/`.
5
+ *
6
+ * Headless mirror seam ([[HS-TASK-UEBN]]): optional `.heyspex/` JSON files are
7
+ * read-only fallbacks when the primary store is absent (free-tier headless MCP).
8
+ * Nothing in the codebase writes the mirror — the console persists only to
9
+ * `spex-console/settings.json` via PUT /api/settings. To sync manually:
10
+ * `cp spex-console/settings.json .heyspex/spex-console-settings.json`.
11
+ */
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ export const DEFAULT_USE_INDEXER = true;
16
+ export const DEFAULT_VAULT_FRONTMATTER_AUTO_REPAIR = true;
17
+
18
+ /** Repo-relative primary settings store (gitignored). */
19
+ export const SPEX_CONSOLE_SETTINGS_REL = join('spex-console', 'settings.json');
20
+
21
+ /** Canonical headless mirror filename under `.heyspex/`. */
22
+ export const SPEX_CONSOLE_SETTINGS_MIRROR_REL = join(
23
+ '.heyspex',
24
+ 'spex-console-settings.json',
25
+ );
26
+
27
+ /** Pre-rename mirror — read during transition only; do not write. */
28
+ export const SPEX_CONSOLE_SETTINGS_MIRROR_LEGACY_REL = join(
29
+ '.heyspex',
30
+ 'console-settings.json',
31
+ );
32
+
33
+ function readJsonSafe(filePath) {
34
+ try {
35
+ if (!existsSync(filePath)) return null;
36
+ return JSON.parse(readFileSync(filePath, 'utf8'));
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Candidate settings paths in precedence order: primary store, canonical mirror,
44
+ * legacy mirror (back-compat). First existing readable JSON wins.
45
+ */
46
+ export function spexConsoleSettingsCandidates(repoRoot) {
47
+ if (!repoRoot) return [];
48
+ return [
49
+ join(repoRoot, SPEX_CONSOLE_SETTINGS_REL),
50
+ join(repoRoot, SPEX_CONSOLE_SETTINGS_MIRROR_REL),
51
+ join(repoRoot, SPEX_CONSOLE_SETTINGS_MIRROR_LEGACY_REL),
52
+ ];
53
+ }
54
+
55
+ export function readSpexConsoleSettingsRaw(repoRoot) {
56
+ for (const p of spexConsoleSettingsCandidates(repoRoot)) {
57
+ const raw = readJsonSafe(p);
58
+ if (raw && typeof raw === 'object') return raw;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function parseEnvTriState(raw) {
64
+ if (typeof raw !== 'string') return undefined;
65
+ const v = raw.trim().toLowerCase();
66
+ if (v === 'false' || v === '0' || v === 'off') return false;
67
+ if (v === 'true' || v === '1' || v === 'on') return true;
68
+ return undefined;
69
+ }
70
+
71
+ export function resolveUseIndexerEnabled(repoRoot) {
72
+ const raw = readSpexConsoleSettingsRaw(repoRoot);
73
+ if (raw && typeof raw.useIndexer === 'boolean') return raw.useIndexer;
74
+ const env = parseEnvTriState(process.env.HEYSPEX_USE_INDEX);
75
+ if (env !== undefined) return env;
76
+ return DEFAULT_USE_INDEXER;
77
+ }
78
+
79
+ export function resolveVaultFrontmatterAutoRepairEnabled(repoRoot) {
80
+ const raw = readSpexConsoleSettingsRaw(repoRoot);
81
+ if (raw && typeof raw.vaultFrontmatterAutoRepair === 'boolean') {
82
+ return raw.vaultFrontmatterAutoRepair;
83
+ }
84
+ const env = parseEnvTriState(process.env.HEYSPEX_VAULT_AUTO_REPAIR);
85
+ if (env !== undefined) return env;
86
+ return DEFAULT_VAULT_FRONTMATTER_AUTO_REPAIR;
87
+ }