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.
- package/README.md +10 -0
- package/bin/hs-vault-binding.mjs +157 -0
- package/bin/hs.mjs +99 -81
- 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 -162
- package/docs/driving-heyspex-from-copilot.md +174 -165
- package/docs/mcp-client-config.md +93 -25
- package/package.json +255 -249
- package/plugins/heyspex/.mcp.json +3 -4
- package/plugins/heyspex/README.md +323 -309
- 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 +318 -0
- 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
|
@@ -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,
|
|
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
|
-
|
|
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}\`
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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: "
|
|
344
|
-
detail:
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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 {
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
93
|
-
const
|
|
94
|
-
|
|
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:
|
|
177
|
+
stdio: 'ignore',
|
|
99
178
|
windowsHide: true,
|
|
100
|
-
shell:
|
|
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)
|
|
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
|
+
});
|