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
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { spawnSync } from "node:child_process";
26
26
  import { existsSync, readFileSync } from "node:fs";
27
- import { join } from "node:path";
27
+ import { join, resolve } from "node:path";
28
28
 
29
29
  import { TEMPLATE_CORE_DESTS, TEMPLATE_VERSION } from "./template-pin.mjs";
30
30
 
@@ -180,15 +180,49 @@ function readTemplatePin(yamlText) {
180
180
  return v.length > 0 ? v : null;
181
181
  }
182
182
 
183
+ /** Same-path test, case-insensitive on Windows, separator-agnostic. */
184
+ function samePathLoose(a, b) {
185
+ if (typeof a !== "string" || typeof b !== "string") return false;
186
+ const norm = (p) => {
187
+ const s = resolve(p).replace(/\\/g, "/").replace(/\/+$/, "");
188
+ return process.platform === "win32" ? s.toLowerCase() : s;
189
+ };
190
+ return norm(a) === norm(b);
191
+ }
192
+
193
+ /**
194
+ * The `hs init --customer-vault <dir>` form that repairs `vaultRoot` from
195
+ * where the doctor was invoked: `.` only when the caller already stands in
196
+ * the vault; otherwise the vault's absolute path, double-quoted when it
197
+ * carries a backslash or whitespace (Git Bash eats bare backslashes). A
198
+ * satellite repo's doctor audits `../<vault>`, so `.` there would scaffold
199
+ * the wrong directory ([[HS-NXNP-36X3-DIAG-the-hs-launcher-loses-the-caller-s]]).
200
+ * Pure; exported for the full doctor to render through `hsCommand`.
201
+ */
202
+ export function templateRepairCommand(vaultRoot, invokedCwd) {
203
+ if (typeof vaultRoot !== "string" || vaultRoot.length === 0) {
204
+ return "hs init --customer-vault .";
205
+ }
206
+ if (invokedCwd === undefined || samePathLoose(vaultRoot, invokedCwd)) {
207
+ return "hs init --customer-vault .";
208
+ }
209
+ const target = /[\\\s]/.test(vaultRoot) ? `"${vaultRoot}"` : vaultRoot;
210
+ return `hs init --customer-vault ${target}`;
211
+ }
212
+
183
213
  /**
184
214
  * The `vault-template` doctor row: does a customer vault carry the current
185
215
  * HeySpex template (HS-KH0Q)? Pure over injectable `exists` / `readFile`
186
216
  * (default node:fs; `readFile` returns the text or null), same row shape as
187
- * {@link vaultConfigCheckRow}.
217
+ * {@link vaultConfigCheckRow}. `opts.invokedCwd` (the caller's directory)
218
+ * decides whether the repair names `.` or the vault's absolute path — see
219
+ * {@link templateRepairCommand}; the row carries that command as `repair`.
188
220
  * - platform vault (marker protocol present) -> ok: it is the template source;
189
- * - any core dest missing -> fail, naming the files, repair `hs init --customer-vault .`;
221
+ * - any core dest missing -> fail, naming the files, with the repair;
190
222
  * - `heyspex.yaml` pins {@link TEMPLATE_VERSION} -> ok;
191
- * - pin older/newer/absent while the files exist -> warn.
223
+ * - pin older/newer/absent while the files exist -> warn, same repair.
224
+ * The repair is `hs init --customer-vault` on an EXISTING vault, which is a
225
+ * REFRESH (adds only missing shipped files, writes the pin, never overwrites).
192
226
  */
193
227
  export function templateCheckRow(vaultRoot, opts = {}) {
194
228
  const exists = opts.exists ?? existsSync;
@@ -202,12 +236,16 @@ export function templateCheckRow(vaultRoot, opts = {}) {
202
236
  }
203
237
  });
204
238
  const id = "vault-template";
205
- const repair = "hs init --customer-vault .";
239
+ // The published-install doctor (`bin/hs.mjs` runStandaloneDoctor) never
240
+ // chdirs, so its own cwd IS the caller's directory; the full doctor is
241
+ // spawned at the platform root and passes HS_INVOKED_CWD explicitly.
242
+ const repair = templateRepairCommand(vaultRoot, opts.invokedCwd ?? process.cwd());
206
243
  if (typeof vaultRoot !== "string" || vaultRoot.length === 0) {
207
244
  return {
208
245
  id,
209
246
  status: "fail",
210
247
  detail: "template not checked — no vault root resolved",
248
+ repair,
211
249
  human: `No vault was found. Run \`${repair}\` in the directory that should become the vault.`,
212
250
  };
213
251
  }
@@ -225,9 +263,10 @@ export function templateCheckRow(vaultRoot, opts = {}) {
225
263
  id,
226
264
  status: "fail",
227
265
  detail: `missing template file(s): ${missing.join(", ")}`,
266
+ repair,
228
267
  human:
229
268
  `The vault is missing ${missing.length} shipped template file(s): ${missing.join(", ")}. ` +
230
- `Run \`${repair}\` in the vault directory — it adds only what is missing and never overwrites an existing file.`,
269
+ `Run \`${repair}\` — on an existing vault it adds only what is missing, writes the template_version pin, and never overwrites an existing file.`,
231
270
  };
232
271
  }
233
272
  const pin = readTemplatePin(readFile(at("heyspex.yaml")));
@@ -244,7 +283,8 @@ export function templateCheckRow(vaultRoot, opts = {}) {
244
283
  detail: pin
245
284
  ? `heyspex.yaml pins template v${pin}, current is v${TEMPLATE_VERSION}`
246
285
  : `heyspex.yaml has no template_version pin (current is v${TEMPLATE_VERSION})`,
247
- human: `Core template files are present but the pin is not current. Run \`${repair}\` to refresh the pin and add any newer shipped files.`,
286
+ repair,
287
+ human: `Core template files are present but the pin is not current. Run \`${repair}\` — it refreshes the pin in heyspex.yaml, adds any newer shipped files, and never overwrites an existing file.`,
248
288
  };
249
289
  }
250
290
 
@@ -289,25 +329,56 @@ export function classifyRegistryFile(rawText) {
289
329
  ];
290
330
  const accounts =
291
331
  json.accounts && typeof json.accounts === "object" ? json.accounts : {};
332
+ const legacyRows = rows.filter(
333
+ (r) => r && typeof r === "object" && (r.account_id ?? LEGACY) === LEGACY,
334
+ );
292
335
  const legacy =
293
336
  Object.prototype.hasOwnProperty.call(accounts, LEGACY) ||
294
337
  json.current_account_id === LEGACY ||
295
- rows.some(
296
- (r) => r && typeof r === "object" && (r.account_id ?? LEGACY) === LEGACY,
297
- );
298
- return { kind: legacy ? "legacy" : "v2" };
338
+ legacyRows.length > 0;
339
+ return { kind: legacy ? "legacy" : "v2", legacyRows };
340
+ }
341
+
342
+ /** First `account:` / `account_id:` scalar of a heyspex.yaml text, or null. Pure. */
343
+ function markerAccountOf(text) {
344
+ if (typeof text !== "string") return null;
345
+ const m = text.match(/^account(?:_id)?:\s*["']?([^"'\s#]+)/m);
346
+ return m ? m[1] : null;
347
+ }
348
+
349
+ /**
350
+ * Which `legacy` rows `hs vault migrate-registry` could actually re-key —
351
+ * the same two proofs the migration itself accepts (`registry-file.ts`
352
+ * `planRegistryMigration`): the row's repo `heyspex.yaml` records an
353
+ * `account:`, or (vault rows) the signed-in account's cloud roster lists the
354
+ * id. Pure over the injected readers; with none injected nothing is provable.
355
+ * @param {Array<object>} legacyRows
356
+ * @param {{ readMarker?: (root: string) => string | null, cloudVaultIds?: string[] | null }} opts
357
+ */
358
+ export function provableLegacyRows(legacyRows, { readMarker, cloudVaultIds } = {}) {
359
+ const cloud = new Set(Array.isArray(cloudVaultIds) ? cloudVaultIds : []);
360
+ return legacyRows.filter((r) => {
361
+ if (typeof r?.id === "string" && cloud.has(r.id)) return true;
362
+ if (!readMarker || typeof r?.repo_root_abs !== "string") return false;
363
+ const account = markerAccountOf(readMarker(r.repo_root_abs));
364
+ return Boolean(account) && account !== "legacy";
365
+ });
299
366
  }
300
367
 
301
368
  /**
302
369
  * Render {@link classifyRegistryFile} as one `hs doctor` row (`registry-schema`).
303
- * A v1 file (or v2 with `legacy` state) is a `warn` that carries
304
- * {@link REGISTRY_SCHEMA_FIX}, so `hs doctor --fix` runs `hs vault
305
- * migrate-registry`. It is a warn, not a fail: the loader still reads a v1
306
- * file. An unreadable file is a warn with no fix — the migration refuses to
307
- * overwrite it, so a human looks first.
370
+ * A v1 file is a `warn` that carries {@link REGISTRY_SCHEMA_FIX}, so `hs
371
+ * doctor --fix` runs `hs vault migrate-registry`. A v2 file with rows still
372
+ * keyed `legacy` warns ONLY when that migration would actually re-key one of
373
+ * them ({@link provableLegacyRows}, via `opts.readMarker` / `opts.cloudVaultIds`):
374
+ * the migration leaves UNPROVEN rows on `legacy` by design, so warning about
375
+ * them named a repair that is a no-op and the warn never cleared. Unprovable
376
+ * legacy rows are `ok`, with the count in the detail. It is a warn, not a
377
+ * fail: the loader still reads a v1 file. An unreadable file is a warn with
378
+ * no fix — the migration refuses to overwrite it, so a human looks first.
308
379
  */
309
- export function registrySchemaCheckRow(rawText) {
310
- const { kind } = classifyRegistryFile(rawText);
380
+ export function registrySchemaCheckRow(rawText, opts = {}) {
381
+ const { kind, legacyRows = [] } = classifyRegistryFile(rawText);
311
382
  const id = "registry-schema";
312
383
  if (kind === "missing") {
313
384
  return {
@@ -334,20 +405,41 @@ export function registrySchemaCheckRow(rawText) {
334
405
  "(`hs vault migrate-registry` will not overwrite it).",
335
406
  };
336
407
  }
337
- const detail =
338
- kind === "v1"
339
- ? "schema v1 — one machine-global active vault for every account"
340
- : "schema v2, but some rows are still keyed to `legacy` (no account)";
408
+ const human =
409
+ "Run `hs vault migrate-registry --dry-run` to see the plan, then `hs vault migrate-registry` " +
410
+ "(or `hs doctor --fix`) while signed in: it rewrites ~/.heyspex/registry.json as schema v2 " +
411
+ "and keys each row to the account PROVEN for it (its heyspex.yaml `account:`, or your cloud " +
412
+ "vault roster). Unproven rows stay `legacy` and keep resolving. A second run changes nothing.";
413
+ if (kind === "v1") {
414
+ return {
415
+ id,
416
+ status: "warn",
417
+ detail:
418
+ "schema v1 — one machine-global active vault for every account; fix: `hs vault migrate-registry`",
419
+ fix: [...REGISTRY_SCHEMA_FIX],
420
+ human,
421
+ };
422
+ }
423
+ const provable = provableLegacyRows(legacyRows, opts);
424
+ if (provable.length > 0) {
425
+ const ids = provable.map((r) => r.id ?? "?").join(", ");
426
+ return {
427
+ id,
428
+ status: "warn",
429
+ detail:
430
+ `schema v2; ${provable.length} of ${legacyRows.length} legacy row(s) can be re-keyed to an account ` +
431
+ `(${ids}); fix: \`hs vault migrate-registry\``,
432
+ fix: [...REGISTRY_SCHEMA_FIX],
433
+ human,
434
+ };
435
+ }
341
436
  return {
342
437
  id,
343
- status: "warn",
344
- detail: `${detail}; fix: \`hs vault migrate-registry\``,
345
- fix: [...REGISTRY_SCHEMA_FIX],
346
- human:
347
- "Run `hs vault migrate-registry --dry-run` to see the plan, then `hs vault migrate-registry` " +
348
- "(or `hs doctor --fix`) while signed in: it rewrites ~/.heyspex/registry.json as schema v2 " +
349
- "and keys each row to the account PROVEN for it (its heyspex.yaml `account:`, or your cloud " +
350
- "vault roster). Unproven rows stay `legacy` and keep resolving. A second run changes nothing.",
438
+ status: "ok",
439
+ detail:
440
+ legacyRows.length > 0
441
+ ? `schema v2; ${legacyRows.length} row(s) on legacy (unprovable, kept by design — they still resolve)`
442
+ : "schema v2; a legacy pointer remains (nothing to re-key)",
351
443
  };
352
444
  }
353
445
 
@@ -543,11 +635,18 @@ export function foldConnectionReport(
543
635
 
544
636
  let summary;
545
637
  if (failed.length > 0) {
638
+ // Each failing per-row entry already prints its own repair as `human`
639
+ // (a doctor's "Needs you" list shows every row with one), so the summary
640
+ // repeats the list only when some failing row has no repair of its own —
641
+ // otherwise the same command showed up twice.
642
+ const uncovered = failed.filter((r) => !r.human);
546
643
  const repairs = [...new Set(failed.map((r) => r.repair).filter(Boolean))];
547
644
  summary = {
548
645
  status: "fail",
549
- detail: `${failed.length} local-vs-cloud row(s) disagree: ${failed.map((r) => r.id.slice(idPrefix.length + 1)).join(", ")}`,
550
- human: `Run the repair named on each failing row: ${repairs.join("; ")}`,
646
+ detail: `${failed.length} local-vs-cloud row(s) disagree: ${failed.map((r) => r.id.slice(idPrefix.length + 1)).join(", ")} — see each row's repair`,
647
+ ...(uncovered.length > 0
648
+ ? { human: `Run the repair named on each failing row: ${repairs.join("; ")}` }
649
+ : {}),
551
650
  };
552
651
  } else if (!localConnected) {
553
652
  summary = {
@@ -29,7 +29,8 @@
29
29
  */
30
30
  import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
31
31
  import path from "node:path";
32
- import { fileURLToPath, pathToFileURL } from "node:url";
32
+ import { fileURLToPath } from "node:url";
33
+ import { isMainModule } from "./lib/shared-path-helpers.mjs";
33
34
 
34
35
  import { getAuthHeader, makeConsoleFetch } from "../vendor/heyspex-mcp/console-fetch.mjs";
35
36
  import { resolveConsoleUrl } from "./lib/console-url-resolver.mjs";
@@ -647,7 +648,7 @@ export function main(argv = process.argv.slice(2)) {
647
648
  }
648
649
 
649
650
  const invokedDirectly =
650
- process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
651
+ isMainModule(import.meta.url);
651
652
  if (invokedDirectly) {
652
653
  main();
653
654
  }
@@ -5,18 +5,30 @@
5
5
  // first tool call"). In cloud write mode the console is the system of record and the disk clone is a
6
6
  // mirror; this gate starts `hs pull --apply` in the BACKGROUND so the hook returns immediately.
7
7
  //
8
- // Fail-open and silent on every skip: local reliance, no vault root / no heyspex.yaml, kill switch
8
+ // Fail-open on every skip: local reliance, no resolvable vault, kill switch
9
9
  // HEYSPEX_VAULT_MIRROR_CATCHUP_OFF=1, or a catch-up that already ran in the last 10 minutes (stamp file).
10
- // Never denies. Emits one line of additionalContext when it spawns.
10
+ // Never denies. Emits one line of additionalContext when it spawns (or when a satellite's vault is skipped).
11
11
  //
12
- // ctx (all optional, for tests): env, vaultRoot, writeMode, now, exists(p), statMtimeMs(p), mkdirp(p),
13
- // writeFile(p, s), openLog(p) -> fd, spawnDetached(cmd, args, opts), hsCommand() -> { command, args, shell }.
12
+ // TARGET (mlc-app incident, 2026-10-16): the pull target is the VAULT bound to the launch folder, resolved
13
+ // by the shared resolver (vendor/heyspex-mcp/vault-resolve.mjs) - never the hook's cwd / project dir. In a
14
+ // satellite (heyspex.yaml `vault_path`) or a repo-binding code repo that is the bound vault's checkout; a
15
+ // satellite whose vault is not cloned here is SKIPPED with a log line, and a target that is not a vault
16
+ // checkout is refused (`hs pull` refuses too - the hard guard lives in its write path).
17
+ //
18
+ // LOG: the gate writes `<iso> vault-mirror-catchup: target=<root> start: <cmd>` before spawning; the
19
+ // detached runner (./vault-mirror-catchup.runner.mjs) sends the pull's stdout/stderr into the same log and
20
+ // appends `<iso> vault-mirror-catchup: done exit=<code>`. Skips that name a target are logged too.
21
+ //
22
+ // ctx (all optional, for tests): env, vaultRoot, resolveVault() -> resolver result, isVault(p), writeMode,
23
+ // now, exists(p), statMtimeMs(p), mkdirp(p), writeFile(p, s), appendFile(p, s), spawnDetached(cmd, args,
24
+ // opts), hsCommand() -> { command, args, shell }, runnerPath.
14
25
 
15
26
  import fs from 'node:fs';
16
27
  import path from 'node:path';
17
28
  import { spawn } from 'node:child_process';
29
+ import { fileURLToPath } from 'node:url';
18
30
 
19
- import { defaultConfigRoot } from '../config-root.mjs';
31
+ import { isVaultRoot, resolveActiveVault } from '../../../vendor/heyspex-mcp/vault-resolve.mjs';
20
32
  import { resolveDevMode } from '../../../scripts/lib/dev-mode.mjs';
21
33
  import { describeHsCommand } from '../../../scripts/lib/hs-command.mjs';
22
34
 
@@ -28,6 +40,7 @@ export const KILL_ENV = 'HEYSPEX_VAULT_MIRROR_CATCHUP_OFF';
28
40
  export const FRESH_WINDOW_MS = 10 * 60 * 1000;
29
41
  export const LOG_REL = '.heyspex/vault-mirror-catchup.log';
30
42
  export const STAMP_REL = '.heyspex/vault-mirror-catchup.stamp';
43
+ export const RUNNER_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'vault-mirror-catchup.runner.mjs');
31
44
 
32
45
  /** Resolve how to launch `hs pull --apply`: the PATH shim (shell on Windows) or `node <dispatcher>`. */
33
46
  export function defaultHsLaunch() {
@@ -37,9 +50,43 @@ export function defaultHsLaunch() {
37
50
  return { command: 'hs', args: ['pull', '--apply'], shell: process.platform === 'win32' };
38
51
  }
39
52
 
53
+ /** One log line: `<iso> vault-mirror-catchup: <text>`. */
54
+ export function logLine(nowMs, text) {
55
+ return `${new Date(nowMs).toISOString()} vault-mirror-catchup: ${text}\n`;
56
+ }
57
+
58
+ /**
59
+ * Which vault does the catch-up pull into? The shared resolver's answer for the launch folder, never the
60
+ * folder itself. Returns { vaultRoot, boundRepoAbs } or { vaultRoot: null, reason, logDir?, detail? }.
61
+ */
62
+ export function resolveCatchupTarget(deps = {}) {
63
+ if (deps.vaultRoot !== undefined) {
64
+ return deps.vaultRoot ? { vaultRoot: deps.vaultRoot, boundRepoAbs: null } : { vaultRoot: null, reason: 'no-vault-root' };
65
+ }
66
+ let r;
67
+ try {
68
+ r = (deps.resolveVault ?? (() => resolveActiveVault({ env: deps.env ?? process.env })))();
69
+ } catch {
70
+ return { vaultRoot: null, reason: 'no-vault-root' };
71
+ }
72
+ if (!r || r.source === 'unbound') return { vaultRoot: null, reason: 'no-vault-root' };
73
+ if (r.vaultCloned === false) {
74
+ // A satellite whose vault is not cloned at its vault_path: nothing local to mirror into, and the
75
+ // satellite itself must never grow a specs/ tree.
76
+ const where = r.boundRepoAbs ?? r.repoRootAbs;
77
+ return {
78
+ vaultRoot: null,
79
+ reason: 'vault-not-cloned',
80
+ logDir: where,
81
+ detail: `skipped: vault ${r.vaultId} is not cloned at ${r.vaultPath ?? 'its vault_path'} (relative to ${where}); nothing pulled`,
82
+ };
83
+ }
84
+ return { vaultRoot: r.repoRootAbs, boundRepoAbs: r.boundRepoAbs ?? null };
85
+ }
86
+
40
87
  /**
41
- * Pure-ish decision: returns { spawn: false } or { spawn: true, vaultRoot, command, args, ... }.
42
- * All I/O goes through injected deps.
88
+ * Pure-ish decision: returns { spawn: false, reason, ... } or { spawn: true, vaultRoot, command, args, ... }.
89
+ * All I/O goes through injected deps. A skip that knows where to report carries `logPath` + `logText`.
43
90
  */
44
91
  export function decideCatchup(deps = {}) {
45
92
  const env = deps.env ?? process.env;
@@ -53,17 +100,25 @@ export function decideCatchup(deps = {}) {
53
100
  }
54
101
  }
55
102
  if (writeMode !== 'cloud') return { spawn: false, reason: 'local-reliance' };
56
- let vaultRoot = deps.vaultRoot;
57
- if (vaultRoot === undefined) {
58
- try {
59
- vaultRoot = defaultConfigRoot();
60
- } catch {
61
- vaultRoot = null;
62
- }
103
+ const target = resolveCatchupTarget({ ...deps, env });
104
+ const vaultRoot = target.vaultRoot;
105
+ if (!vaultRoot) {
106
+ const out = { spawn: false, reason: target.reason };
107
+ if (target.logDir) Object.assign(out, { logPath: path.join(target.logDir, LOG_REL), logText: target.detail });
108
+ return out;
63
109
  }
64
- if (!vaultRoot) return { spawn: false, reason: 'no-vault-root' };
65
110
  const exists = deps.exists ?? fs.existsSync;
66
111
  if (!exists(path.join(vaultRoot, 'heyspex.yaml'))) return { spawn: false, reason: 'no-heyspex-yaml' };
112
+ // Belt and braces: whatever named it, the target must be a vault checkout - never a satellite or a
113
+ // repo-binding code repo (either would grow an untracked specs/ tree).
114
+ if (!(deps.isVault ?? isVaultRoot)(vaultRoot)) {
115
+ return {
116
+ spawn: false,
117
+ reason: 'target-not-vault',
118
+ logPath: path.join(vaultRoot, LOG_REL),
119
+ logText: `skipped: target=${vaultRoot} is not a vault checkout (satellite or repo-binding marker); nothing pulled`,
120
+ };
121
+ }
67
122
 
68
123
  const now = (deps.now ?? Date.now)();
69
124
  const stampPath = path.join(vaultRoot, STAMP_REL);
@@ -78,26 +133,50 @@ export function decideCatchup(deps = {}) {
78
133
  return { spawn: false, reason: 'stamp-fresh' };
79
134
  }
80
135
  const launch = (deps.hsCommand ?? defaultHsLaunch)();
81
- return { spawn: true, vaultRoot, stampPath, logPath: path.join(vaultRoot, LOG_REL), ...launch };
136
+ return {
137
+ spawn: true,
138
+ vaultRoot,
139
+ boundRepoAbs: target.boundRepoAbs ?? null,
140
+ stampPath,
141
+ logPath: path.join(vaultRoot, LOG_REL),
142
+ ...launch,
143
+ };
82
144
  }
83
145
 
84
- /** Perform the detached spawn for a { spawn: true } decision. Throws on failure (caller fails open). */
146
+ /** Append a skip line to the log a skip decision names - only when that `.heyspex/` already exists. */
147
+ export function logSkip(d, deps = {}) {
148
+ if (!d?.logPath || !d.logText) return false;
149
+ const exists = deps.exists ?? fs.existsSync;
150
+ if (!exists(path.dirname(d.logPath))) return false; // never create .heyspex/ in someone's code repo
151
+ const appendFile = deps.appendFile ?? ((p, s) => fs.appendFileSync(p, s));
152
+ appendFile(d.logPath, logLine((deps.now ?? Date.now)(), d.logText));
153
+ return true;
154
+ }
155
+
156
+ /**
157
+ * Perform the detached spawn for a { spawn: true } decision. Throws on failure (caller fails open).
158
+ * Writes the start line (timestamp + target + command) BEFORE spawning; the runner appends the pull's
159
+ * output and the exit line.
160
+ */
85
161
  export function startCatchup(d, deps = {}) {
86
162
  const mkdirp = deps.mkdirp ?? ((p) => fs.mkdirSync(p, { recursive: true }));
87
163
  const writeFile = deps.writeFile ?? ((p, s) => fs.writeFileSync(p, s));
88
- const openLog = deps.openLog ?? ((p) => fs.openSync(p, 'a'));
164
+ const appendFile = deps.appendFile ?? ((p, s) => fs.appendFileSync(p, s));
89
165
  const spawnDetached = deps.spawnDetached ?? ((cmd, args, opts) => spawn(cmd, args, opts));
90
166
  const env = deps.env ?? process.env;
167
+ const now = (deps.now ?? Date.now)();
91
168
  mkdirp(path.dirname(d.logPath));
92
- writeFile(d.stampPath, new Date((deps.now ?? Date.now)()).toISOString() + '\n');
93
- const fd = openLog(d.logPath);
94
- const child = spawnDetached(d.command, d.args, {
169
+ writeFile(d.stampPath, new Date(now).toISOString() + '\n');
170
+ const bound = d.boundRepoAbs ? ` (bound from ${d.boundRepoAbs})` : '';
171
+ appendFile(d.logPath, logLine(now, `target=${d.vaultRoot}${bound} start: ${[d.command, ...d.args].join(' ')}`));
172
+ const spec = JSON.stringify({ logPath: d.logPath, command: d.command, args: d.args, shell: d.shell === true, cwd: d.vaultRoot });
173
+ const child = spawnDetached(process.execPath, [deps.runnerPath ?? RUNNER_PATH, spec], {
95
174
  cwd: d.vaultRoot,
96
175
  env: { ...env, HS_VAULT_ROOT: d.vaultRoot },
97
176
  detached: true,
98
- stdio: ['ignore', fd, fd],
177
+ stdio: 'ignore',
99
178
  windowsHide: true,
100
- shell: d.shell === true,
179
+ shell: false,
101
180
  });
102
181
  child?.on?.('error', () => {});
103
182
  child?.unref?.();
@@ -107,7 +186,17 @@ export function startCatchup(d, deps = {}) {
107
186
  export function run(evt, ctx = {}) {
108
187
  try {
109
188
  const d = decideCatchup(ctx);
110
- if (!d.spawn) return { decision: 'allow' };
189
+ if (!d.spawn) {
190
+ try {
191
+ logSkip(d, ctx);
192
+ } catch {
193
+ /* a log failure never becomes a hook failure */
194
+ }
195
+ if (d.reason === 'vault-not-cloned') {
196
+ return { decision: 'inject', inject: { additionalContext: `vault mirror catch-up ${d.logText}`, severity: 'info' } };
197
+ }
198
+ return { decision: 'allow' };
199
+ }
111
200
  startCatchup(d, ctx);
112
201
  return {
113
202
  decision: 'inject',
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ // spexharness/src/gates/vault-mirror-catchup.runner.mjs
3
+ //
4
+ // The detached middleman the vault-mirror-catchup gate spawns: it runs `hs pull --apply` with its
5
+ // stdout/stderr going straight into the catch-up log, then appends ONE closing line with the exit code.
6
+ // The gate has already written the start line (timestamp + resolved target), so the log always shows
7
+ // where the pull went and how it ended - even when the pull itself prints nothing (the mlc-app incident
8
+ // left an empty log and no way to tell what had run).
9
+ //
10
+ // argv[2]: JSON { logPath, command, args, shell, cwd }. The environment is inherited from the gate.
11
+
12
+ import fs from 'node:fs';
13
+ import { spawn } from 'node:child_process';
14
+
15
+ function append(logPath, line) {
16
+ try {
17
+ fs.appendFileSync(logPath, `${new Date().toISOString()} ${line}\n`);
18
+ } catch {
19
+ /* nowhere left to report to */
20
+ }
21
+ }
22
+
23
+ let spec;
24
+ try {
25
+ spec = JSON.parse(process.argv[2] ?? '');
26
+ } catch {
27
+ process.exit(2);
28
+ }
29
+ const { logPath, command, args = [], shell = false, cwd } = spec ?? {};
30
+ if (!logPath || !command) process.exit(2);
31
+
32
+ let fd;
33
+ try {
34
+ fd = fs.openSync(logPath, 'a');
35
+ } catch {
36
+ fd = 'ignore';
37
+ }
38
+ let child;
39
+ try {
40
+ child = spawn(command, args, { cwd, env: process.env, stdio: ['ignore', fd, fd], windowsHide: true, shell: shell === true });
41
+ } catch (err) {
42
+ append(logPath, `vault-mirror-catchup: spawn failed: ${err?.message ?? err}`);
43
+ process.exit(1);
44
+ }
45
+ child.on('error', (err) => {
46
+ append(logPath, `vault-mirror-catchup: spawn failed: ${err?.message ?? err}`);
47
+ process.exitCode = 1;
48
+ });
49
+ child.on('close', (code, signal) => {
50
+ append(logPath, `vault-mirror-catchup: done exit=${code ?? 'null'}${signal ? ` signal=${signal}` : ''}`);
51
+ process.exitCode = typeof code === 'number' ? code : 1;
52
+ });