heyspex 0.2.2 → 0.2.4
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/.claude-plugin/marketplace.json +2 -1
- package/README.md +11 -0
- package/bin/hs-vault-binding.mjs +157 -0
- package/bin/hs.mjs +83 -79
- 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 +200 -177
- package/docs/driving-heyspex-from-copilot.md +174 -165
- package/docs/mcp-client-config.md +93 -25
- package/package.json +257 -250
- package/plugins/heyspex/.claude-plugin/plugin.json +13 -12
- package/plugins/heyspex/.cursor-plugin/plugin.json +16 -15
- package/plugins/heyspex/.mcp.json +3 -4
- package/plugins/heyspex/README.md +323 -312
- package/plugins/heyspex/assets/heyspex-glyph-128.png +0 -0
- package/plugins/heyspex/assets/heyspex-glyph-256.png +0 -0
- package/plugins/heyspex/assets/heyspex-glyph.svg +12 -0
- 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 +1073 -0
- package/scripts/copilot-statusline.mjs +130 -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 +152 -36
- 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/http-transport.mjs +6 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
- package/vendor/heyspex-mcp/server-identity.mjs +13 -0
- 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 +63 -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
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
{
|
|
9
9
|
"name": "heyspex",
|
|
10
10
|
"source": "./plugins/heyspex",
|
|
11
|
-
"
|
|
11
|
+
"displayName": "HeySpex",
|
|
12
|
+
"description": "HeySpex plugin for Claude Code and GitHub Copilot CLI: vault integration, MCP server (spec vault tools + hs-* prompts) and lifecycle hooks. Prototype — see plugins/heyspex/README.md before enabling."
|
|
12
13
|
}
|
|
13
14
|
]
|
|
14
15
|
}
|
package/README.md
CHANGED
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
HeySpex sits between "I have an idea" and "an AI agent writes code" - it distills ideas into context-window-sized plans that agents build correctly the first time. This page is the team-member onboarding walk-through: it takes you from an empty computer to your first claimed spec, and links the authoritative doc for every step instead of duplicating it.
|
|
6
6
|
|
|
7
|
+
## Using the published package with GitHub Copilot
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install heyspex
|
|
11
|
+
npx heyspex setup copilot # registers the plugin with the Copilot CLI
|
|
12
|
+
npx heyspex setup copilot --check # confirm; exit 0 = installed
|
|
13
|
+
npx heyspex setup copilot --statusline # optional: HeySpex status line in Copilot's settings.json (--uninstall to remove)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Prerequisite: the GitHub Copilot CLI. Either install the GitHub Copilot app (its bundled CLI at `%LOCALAPPDATA%\github-copilot-sdk\cli\<version>\copilot.exe` is auto-detected, newest version wins), or `npm install -g @github/copilot`, or `winget install GitHub.Copilot`. If none is found, `setup copilot` installs nothing and exits non-zero. `COPILOT_HOME` is honoured. Full guide: [docs/copilot-plugin-install.md](docs/copilot-plugin-install.md).
|
|
17
|
+
|
|
7
18
|
## Machine setup
|
|
8
19
|
|
|
9
20
|
Prerequisites: Node.js `>=20 <25` (tested on 22.x), npm 10.x, git. No local database - HeySpex uses a cloud Postgres (Neon). Only if you will work on the Windows Companion tray app: the [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) and [PowerShell 7](https://learn.microsoft.com/powershell/scripting/install/installing-powershell-on-windows) (`pwsh`).
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hs vault bind | bindings | resolve` — the repo-binding verbs ([[HS-TYJA]]).
|
|
3
|
+
*
|
|
4
|
+
* hs vault bind [--vault <name|id|path>] [--repo <dir>] [--force] [--dry-run] [--json]
|
|
5
|
+
* Bind a code repo (default: the git checkout containing cwd) to a
|
|
6
|
+
* registered vault: writes the repo's heyspex.yaml `vault:` block and the
|
|
7
|
+
* registry `linked_repos[]` row. Idempotent; refuses to re-point a bound
|
|
8
|
+
* repo without --force (prints the current binding); refuses a vault
|
|
9
|
+
* checkout (it already resolves to itself). Without --vault it re-asserts
|
|
10
|
+
* the repo's existing binding (refreshes path, remote and registry row).
|
|
11
|
+
* hs vault bindings [--json]
|
|
12
|
+
* Every binding on this machine, health-checked, sorted by repo path,
|
|
13
|
+
* plus how the CURRENT folder resolves. Read-only.
|
|
14
|
+
* hs vault resolve [--json]
|
|
15
|
+
* Which vault an agent launched here writes to, and why. Read-only;
|
|
16
|
+
* exits 1 with the typed error when unbound.
|
|
17
|
+
*
|
|
18
|
+
* Exit codes: 0 ok · 1 typed resolution/binding error · 2 usage error.
|
|
19
|
+
* `--json` prints exactly one JSON object on stdout (errors included:
|
|
20
|
+
* `{ ok: false, error: <CODE>, code: <CODE>, message, from, fix, details }`).
|
|
21
|
+
*
|
|
22
|
+
* One implementation: the logic lives in vendor/heyspex-mcp/vault-binding.mjs
|
|
23
|
+
* (shared with the MCP server and spexcode); this file is argv + printing.
|
|
24
|
+
*/
|
|
25
|
+
import {
|
|
26
|
+
bindRepo,
|
|
27
|
+
defaultRegistryDir,
|
|
28
|
+
isVaultResolutionError,
|
|
29
|
+
listBindings,
|
|
30
|
+
} from '../vendor/heyspex-mcp/vault-binding.mjs';
|
|
31
|
+
import { resolveActiveVault } from '../vendor/heyspex-mcp/vault-resolve.mjs';
|
|
32
|
+
|
|
33
|
+
export const BINDING_VERBS = new Set(['bind', 'bindings', 'resolve']);
|
|
34
|
+
|
|
35
|
+
function parseFlags(args) {
|
|
36
|
+
const out = { force: false, dryRun: false, json: false, repo: null, unknown: [] };
|
|
37
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
38
|
+
const a = args[i];
|
|
39
|
+
if (a === '--force') out.force = true;
|
|
40
|
+
else if (a === '--dry-run') out.dryRun = true;
|
|
41
|
+
else if (a === '--json') out.json = true;
|
|
42
|
+
else if (a === '--repo') out.repo = args[++i] ?? null;
|
|
43
|
+
else if (a.startsWith('--repo=')) out.repo = a.slice('--repo='.length);
|
|
44
|
+
else out.unknown.push(a);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How the folder `cwd` resolves, as a plain JSON-safe object. STABLE shape
|
|
51
|
+
* (`hs vault resolve --json`; scripts match on it):
|
|
52
|
+
* ok: { ok: true, cwd, source, vault_id, vault_root, bound_repo?, worktree_of? }
|
|
53
|
+
* failed: { ok: false, cwd, source: 'unbound', error, code, message, from, fix, details }
|
|
54
|
+
* (`error` and `code` are the same typed code; `error` is kept for older readers.)
|
|
55
|
+
*/
|
|
56
|
+
export function describeResolution(cwd, { env = process.env, registryDir } = {}) {
|
|
57
|
+
const r = resolveActiveVault({ cwd, env, ...(registryDir ? { registryDir } : {}) });
|
|
58
|
+
const slash = (p) => String(p).replace(/\\/g, '/');
|
|
59
|
+
if (r.source === 'unbound') {
|
|
60
|
+
const e = r.error && typeof r.error.toJSON === 'function'
|
|
61
|
+
? r.error.toJSON()
|
|
62
|
+
: { ok: false, error: 'VAULT_UNBOUND', code: 'VAULT_UNBOUND', message: null, from: slash(cwd), fix: null, details: {} };
|
|
63
|
+
return { ok: false, cwd: slash(cwd), source: 'unbound', error: e.error, code: e.code, message: e.message, from: e.from ?? slash(cwd), fix: e.fix, details: e.details ?? {} };
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
cwd: slash(cwd),
|
|
68
|
+
source: r.source,
|
|
69
|
+
vault_id: r.vaultId,
|
|
70
|
+
vault_root: slash(r.repoRootAbs),
|
|
71
|
+
...(r.boundRepoAbs ? { bound_repo: slash(r.boundRepoAbs) } : {}),
|
|
72
|
+
...(r.worktreeOf ? { worktree_of: slash(r.worktreeOf) } : {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const SOURCE_TEXT = {
|
|
77
|
+
'repo-binding': 'bound to this repo (heyspex.yaml kind: repo-binding)',
|
|
78
|
+
'satellite-pin': 'bound to this repo (satellite pin in .mcp.json)',
|
|
79
|
+
'cwd-marker': 'this folder is inside the vault checkout',
|
|
80
|
+
'marker-registry-mismatch': 'this folder is inside the vault checkout (marker id differs from the registry row - run `hs connect --check`)',
|
|
81
|
+
'cwd-heuristic': 'this folder is inside a vault checkout (specs/)',
|
|
82
|
+
'env-vault-root': 'HEYSPEX_VAULT_ROOT override',
|
|
83
|
+
'env-vault-dir': 'HEYSPEX_VAULT_DIR override',
|
|
84
|
+
explicit: 'explicit --vault',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} verb bind | bindings | resolve
|
|
89
|
+
* @param {string[]} args argv after the verb (global --vault already extracted)
|
|
90
|
+
* @param {{ vaultSelector?: string|null, cwd?: string, env?: object, registryDir?: string, stdout?: { write(s: string): unknown }, stderr?: { write(s: string): unknown } }} [ctx]
|
|
91
|
+
* @returns {number} exit code
|
|
92
|
+
*/
|
|
93
|
+
export function runVaultBindingCommand(verb, args, {
|
|
94
|
+
vaultSelector = null,
|
|
95
|
+
cwd = process.cwd(),
|
|
96
|
+
env = process.env,
|
|
97
|
+
registryDir = defaultRegistryDir(),
|
|
98
|
+
stdout = process.stdout,
|
|
99
|
+
stderr = process.stderr,
|
|
100
|
+
} = {}) {
|
|
101
|
+
const flags = parseFlags(args);
|
|
102
|
+
const emitJson = (obj) => stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
|
|
103
|
+
if (flags.unknown.length) {
|
|
104
|
+
const msg = `hs vault ${verb}: unknown argument(s): ${flags.unknown.join(' ')}`;
|
|
105
|
+
if (flags.json) emitJson({ ok: false, error: 'USAGE', message: msg });
|
|
106
|
+
else stderr.write(`${msg}\n`);
|
|
107
|
+
return 2;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
if (verb === 'bind') {
|
|
111
|
+
const res = bindRepo({ repo: flags.repo ?? undefined, cwd, vault: vaultSelector, force: flags.force, dryRun: flags.dryRun, registryDir });
|
|
112
|
+
if (flags.json) emitJson(res);
|
|
113
|
+
else {
|
|
114
|
+
const what = { bound: 'Bound', rebound: 'Re-bound', refreshed: 'Refreshed binding of', unchanged: 'Already bound:' }[res.status] ?? res.status;
|
|
115
|
+
stdout.write(`${res.dryRun ? '(dry run) ' : ''}${what} ${res.repo} -> vault ${res.vault.name} (${res.vault.id}) at ${res.vault.path}\n`);
|
|
116
|
+
if (res.previous) stdout.write(` previously bound to ${res.previous.name ?? res.previous.id} (${res.previous.id})\n`);
|
|
117
|
+
if (res.movedFrom) stdout.write(` replaced the stale registry row for ${res.movedFrom} (same git remote; the repo moved)\n`);
|
|
118
|
+
for (const f of res.wrote) stdout.write(` wrote ${f}\n`);
|
|
119
|
+
if (res.status === 'bound' || res.status === 'rebound') stdout.write(' Restart agent sessions launched in this repo so their MCP server re-resolves.\n');
|
|
120
|
+
}
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
if (verb === 'bindings') {
|
|
124
|
+
const list = listBindings({ registryDir });
|
|
125
|
+
const here = describeResolution(cwd, { env, registryDir });
|
|
126
|
+
if (flags.json) {
|
|
127
|
+
emitJson({ ok: true, here, ...list });
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
stdout.write(here.ok
|
|
131
|
+
? `This folder (${here.cwd}) -> vault ${here.vault_id} at ${here.vault_root} [${SOURCE_TEXT[here.source] ?? here.source}]\n`
|
|
132
|
+
: `This folder (${here.cwd}) -> UNBOUND (${here.error}). Fix: ${here.fix}\n`);
|
|
133
|
+
stdout.write(`\nRepo bindings (${list.bindings.length}):\n`);
|
|
134
|
+
if (!list.bindings.length) stdout.write(' (none - run `hs vault bind --vault <name|id>` inside a code repo)\n');
|
|
135
|
+
for (const b of list.bindings) {
|
|
136
|
+
stdout.write(` ${b.status === 'ok' ? 'ok ' : '!! '} ${b.repo} -> ${b.vault_name ?? '?'} (${b.vault_id})${b.status === 'ok' ? '' : ` [${b.status}]`}\n`);
|
|
137
|
+
}
|
|
138
|
+
stdout.write(`\nRegistered vaults (${list.vaults.length}) - each resolves for agents launched inside its own checkout:\n`);
|
|
139
|
+
for (const v of list.vaults) stdout.write(` ${v.name} ${v.id} ${v.path}\n`);
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
if (verb === 'resolve') {
|
|
143
|
+
const here = describeResolution(cwd, { env, registryDir });
|
|
144
|
+
if (flags.json) emitJson(here);
|
|
145
|
+
else if (here.ok) stdout.write(`${here.vault_root} (${here.vault_id}; ${SOURCE_TEXT[here.source] ?? here.source})\n`);
|
|
146
|
+
else stderr.write(`${here.code}: ${here.message}\n fix: ${here.fix ?? '(see above)'}\n`);
|
|
147
|
+
return here.ok ? 0 : 1;
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (!isVaultResolutionError(err)) throw err;
|
|
151
|
+
if (flags.json) emitJson(err.toJSON());
|
|
152
|
+
else stderr.write(`hs vault ${verb}: ${err.code}: ${err.message}\n${err.fix ? ` fix: ${err.fix}\n` : ''}`);
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
stderr.write(`hs vault: unknown verb ${verb}\n`);
|
|
156
|
+
return 2;
|
|
157
|
+
}
|
package/bin/hs.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* service directly. tsx (a workspace devDep, hoisted to the repo-root
|
|
12
12
|
* node_modules) executes the .ts entrypoints.
|
|
13
13
|
*/
|
|
14
|
+
import { BINDING_VERBS, runVaultBindingCommand } from "./hs-vault-binding.mjs";
|
|
14
15
|
import { spawn } from "node:child_process";
|
|
15
16
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
16
17
|
import path from "node:path";
|
|
@@ -316,21 +317,11 @@ export function findVaultRoot(start, stopAt) {
|
|
|
316
317
|
}
|
|
317
318
|
|
|
318
319
|
/**
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
* cwd fallback is rejected so the "no vault detected" error below still
|
|
325
|
-
* fires when there is genuinely no vault anywhere.
|
|
326
|
-
*
|
|
327
|
-
* The test is deliberately "anything but `cwd-heuristic`", so every real
|
|
328
|
-
* tier passes it: `explicit`, `account-pointer`, `env-vault-root`,
|
|
329
|
-
* `env-vault-dir`, and `satellite-pin` (an on-disk manifest attachment found
|
|
330
|
-
* by the resolver's tier-3 walk -- that tier reported the untrue
|
|
331
|
-
* `env-vault-root` until 2026-09-18; both labels are accepted here, so the
|
|
332
|
-
* rename changed nothing). `cwd-marker` and `marker-registry-mismatch` (a
|
|
333
|
-
* heyspex.yaml `vault_id` named the vault, [[HS-VVNQ]]) pass too.
|
|
320
|
+
* The shared resolver's answer (vendor/heyspex-mcp/vault-resolve.mjs) as a
|
|
321
|
+
* vault root, or null when it names none. Despite the historical name it
|
|
322
|
+
* never reads a registry pointer any more ([[HS-TYJA]]): it returns explicit,
|
|
323
|
+
* env, satellite-pin, repo-binding and marker results; `unbound` and the
|
|
324
|
+
* bare `cwd-heuristic` label return null. Kept for existing importers.
|
|
334
325
|
*/
|
|
335
326
|
export function findRegistryVaultRoot(options = {}) {
|
|
336
327
|
let resolved;
|
|
@@ -339,7 +330,7 @@ export function findRegistryVaultRoot(options = {}) {
|
|
|
339
330
|
} catch {
|
|
340
331
|
return null;
|
|
341
332
|
}
|
|
342
|
-
if (!resolved || resolved.source === "cwd-heuristic") return null;
|
|
333
|
+
if (!resolved || resolved.source === "cwd-heuristic" || resolved.source === "unbound") return null;
|
|
343
334
|
if (!existsSync(resolved.specsDirAbs)) return null;
|
|
344
335
|
return resolved.repoRootAbs;
|
|
345
336
|
}
|
|
@@ -412,56 +403,69 @@ export function pointerOnlyRefusalLine(cwd, vaultRoot) {
|
|
|
412
403
|
|
|
413
404
|
/**
|
|
414
405
|
* The vault-resolution ladder `main()` runs at the top of every invocation,
|
|
415
|
-
* factored out so it is unit-testable without spawning a child process
|
|
416
|
-
* ([[HS-0I1G-B0IT-TASK-track-and-announce-the-registry-active]]).
|
|
406
|
+
* factored out so it is unit-testable without spawning a child process.
|
|
417
407
|
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
408
|
+
* Owner ruling 2026-10-14 ([[HS-TYJA]]): one shared order, the same as the MCP
|
|
409
|
+
* server and spexcode -- `HS_VAULT_ROOT` (this CLI's explicit tier) ->
|
|
410
|
+
* `HEYSPEX_VAULT_ROOT` / `HEYSPEX_VAULT_DIR` (a true override) -> the walk up
|
|
411
|
+
* from `cwd` (satellite pin, heyspex.yaml `vault:` repo binding, vault
|
|
412
|
+
* checkout) -> nothing (`vaultRoot: null`). The registry's active pointer is
|
|
413
|
+
* never consulted, so `usedRegistryFallback` is always `false` now: the
|
|
414
|
+
* HS-0I1G "silently bootstrapping into the last-active vault" failure it
|
|
415
|
+
* guarded against can no longer happen. The field stays for callers.
|
|
426
416
|
*
|
|
427
|
-
* `stopAt` is a TEST-ONLY walk-boundary fence
|
|
428
|
-
* `findVaultRoot`'s own walk and `resolveActiveVault`'s inner cwd walk (via
|
|
429
|
-
* its `cwdWalkRoot` option) — production callers (`main()`) omit it.
|
|
417
|
+
* `stopAt` is a TEST-ONLY walk-boundary fence (the resolver's `cwdWalkRoot`).
|
|
430
418
|
*
|
|
431
419
|
* @param {string} cwd
|
|
432
420
|
* @param {NodeJS.ProcessEnv} env
|
|
433
421
|
* @param {string} [stopAt]
|
|
434
|
-
* @
|
|
422
|
+
* @param {{ registryDir?: string }} [opts] test-only registry dir override
|
|
423
|
+
* @returns {{ vaultRoot: string|null, usedRegistryFallback: boolean, source: string|null, error: Error|null }}
|
|
424
|
+
* `error`: the resolver's typed VaultResolutionError (VAULT_UNBOUND /
|
|
425
|
+
* VAULT_BINDING_BROKEN / VAULT_NOT_FOUND) when `vaultRoot` is null.
|
|
435
426
|
*/
|
|
436
|
-
export function resolveVaultRootForMain(cwd, env, stopAt) {
|
|
427
|
+
export function resolveVaultRootForMain(cwd, env, stopAt, opts = {}) {
|
|
437
428
|
if (env.HS_VAULT_ROOT) {
|
|
438
|
-
return { vaultRoot: env.HS_VAULT_ROOT, usedRegistryFallback: false };
|
|
429
|
+
return { vaultRoot: env.HS_VAULT_ROOT, usedRegistryFallback: false, source: "HS_VAULT_ROOT", error: null };
|
|
439
430
|
}
|
|
440
|
-
// A pin that cannot be honoured is a REFUSAL, never a reason to
|
|
441
|
-
//
|
|
431
|
+
// A pin that cannot be honoured is a REFUSAL, never a reason to walk on
|
|
432
|
+
// past it to some other vault (owner principle P1, 2026-10-16).
|
|
442
433
|
const brokenPin = findBrokenSatellitePin(cwd, stopAt);
|
|
443
434
|
if (brokenPin) {
|
|
444
|
-
return { vaultRoot: null, usedRegistryFallback: false, brokenPin };
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
435
|
+
return { vaultRoot: null, usedRegistryFallback: false, source: null, error: null, brokenPin };
|
|
436
|
+
}
|
|
437
|
+
let resolved;
|
|
438
|
+
try {
|
|
439
|
+
resolved = resolveActiveVault({
|
|
440
|
+
cwd,
|
|
441
|
+
env,
|
|
442
|
+
...(stopAt ? { cwdWalkRoot: stopAt } : {}),
|
|
443
|
+
...(opts.registryDir ? { registryDir: opts.registryDir } : {}),
|
|
444
|
+
});
|
|
445
|
+
} catch {
|
|
446
|
+
resolved = null;
|
|
447
|
+
}
|
|
448
|
+
if (!resolved || resolved.source === "unbound") {
|
|
449
|
+
return { vaultRoot: null, usedRegistryFallback: false, source: resolved?.source ?? null, error: resolved?.error ?? null };
|
|
450
|
+
}
|
|
451
|
+
return { vaultRoot: resolved.repoRootAbs, usedRegistryFallback: false, source: resolved.source, error: null };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The stderr text `hs` prints when a command needs a vault and none resolves:
|
|
456
|
+
* the resolver's typed code, message and exact fix ([[HS-TYJA]]), or the
|
|
457
|
+
* generic line when there is no typed error.
|
|
458
|
+
* @param {{ code?: string, message?: string, fix?: string|null } | null | undefined} error
|
|
459
|
+
*/
|
|
460
|
+
export function noVaultMessage(error) {
|
|
461
|
+
if (error && typeof error.code === "string" && error.message) {
|
|
462
|
+
return `hs: ${error.code}: ${error.message}\n${error.fix ? ` fix: ${error.fix}\n` : ""}`;
|
|
463
|
+
}
|
|
464
|
+
return (
|
|
465
|
+
"hs: no vault detected — could not find a heyspex.yaml marker or a " +
|
|
466
|
+
"specs/ folder walking up from the current directory. " +
|
|
467
|
+
"Set HS_VAULT_ROOT or run from inside a vault.\n"
|
|
460
468
|
);
|
|
461
|
-
return {
|
|
462
|
-
vaultRoot: registryRoot,
|
|
463
|
-
usedRegistryFallback: Boolean(registryRoot),
|
|
464
|
-
};
|
|
465
469
|
}
|
|
466
470
|
|
|
467
471
|
/** True when `argv` carries `--attach-vault` (bare or `=value` form). */
|
|
@@ -559,6 +563,9 @@ function usage() {
|
|
|
559
563
|
" hs join <code> Join with a pairing code (LAN auto-find; no IP to type)",
|
|
560
564
|
" hs join [<repo>] [--dir d] [--code c] [--at url] [--profile p] [--skip steps] [--json] [--yes] Fresh-machine onboarding (clone → toolchain → deps → doctor → agent layer → secrets → verify)",
|
|
561
565
|
" hs vault list|register|switch|active Manage registered vaults + active pointer",
|
|
566
|
+
" hs vault bind [--vault <name|id>] [--repo <dir>] [--force] [--dry-run] [--json] Bind this repo to a vault (agents launched here write to it)",
|
|
567
|
+
" hs vault bindings [--json] List repo bindings + how this folder resolves",
|
|
568
|
+
" hs vault resolve [--json] Which vault an agent launched here writes to",
|
|
562
569
|
" hs workspace list|create|switch Manage registered workspaces (company containers)",
|
|
563
570
|
" hs connect [--dry-run] [--json] | --check [--json] Detect+scaffold+register this repo (or read-only check)",
|
|
564
571
|
" hs --vault <id> <subcommand> One-shot vault override for this invocation",
|
|
@@ -945,19 +952,15 @@ function main(argv) {
|
|
|
945
952
|
const command = argv[1];
|
|
946
953
|
const rest = argv.slice(2);
|
|
947
954
|
|
|
948
|
-
const
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
const vaultRoot = pointerOnly ? null : resolvedMain.vaultRoot;
|
|
958
|
-
if (pointerOnly) {
|
|
959
|
-
process.stderr.write(pointerOnlyRefusalLine(process.cwd(), resolvedMain.vaultRoot));
|
|
960
|
-
}
|
|
955
|
+
const {
|
|
956
|
+
vaultRoot,
|
|
957
|
+
usedRegistryFallback,
|
|
958
|
+
error: vaultError,
|
|
959
|
+
brokenPin,
|
|
960
|
+
} = resolveVaultRootForMain(process.cwd(), process.env);
|
|
961
|
+
// The registry pointer is never consulted ([[HS-TYJA]]), so the only P1
|
|
962
|
+
// refusal left is a satellite pin that cannot be honoured; onboarding /
|
|
963
|
+
// registry verbs and `hs doctor` still run so the pin can be repaired.
|
|
961
964
|
if (
|
|
962
965
|
brokenPin &&
|
|
963
966
|
namespace !== "doctor" &&
|
|
@@ -1050,11 +1053,7 @@ function main(argv) {
|
|
|
1050
1053
|
// (optional name defaults to "peer"). Needs a vault (this machine owns it).
|
|
1051
1054
|
if (namespace === "invite") {
|
|
1052
1055
|
if (!vaultRoot) {
|
|
1053
|
-
process.stderr.write(
|
|
1054
|
-
"hs: no vault detected — could not find a heyspex.yaml marker or a " +
|
|
1055
|
-
"specs/ folder walking up from the current directory. " +
|
|
1056
|
-
"Set HS_VAULT_ROOT or run from inside a vault.\n",
|
|
1057
|
-
);
|
|
1056
|
+
process.stderr.write(noVaultMessage(vaultError));
|
|
1058
1057
|
process.exit(1);
|
|
1059
1058
|
}
|
|
1060
1059
|
dispatchSpexConsoleEntry(
|
|
@@ -1078,7 +1077,7 @@ function main(argv) {
|
|
|
1078
1077
|
`hs setup: expected \`claude\`, \`cursor\` or \`copilot\` (got \`${command ?? ""}\`)\n` +
|
|
1079
1078
|
" hs setup claude [--check|--uninstall] Claude Code lifecycle hooks\n" +
|
|
1080
1079
|
" hs setup cursor [--check|--uninstall] Cursor CLI lifecycle hooks\n" +
|
|
1081
|
-
" hs setup copilot [--check|--uninstall|--print|--hooks] GitHub Copilot (plugin; --hooks for the cloud agent)\n",
|
|
1080
|
+
" hs setup copilot [--check|--uninstall|--print|--hooks|--statusline [--force]] GitHub Copilot (plugin; --hooks for the cloud agent; --statusline opt-in status line)\n",
|
|
1082
1081
|
);
|
|
1083
1082
|
process.exit(2);
|
|
1084
1083
|
}
|
|
@@ -1090,7 +1089,7 @@ function main(argv) {
|
|
|
1090
1089
|
setupFlags.length = 0;
|
|
1091
1090
|
for (let i = 0; i < rest.length; i++) {
|
|
1092
1091
|
const a = rest[i];
|
|
1093
|
-
if (["--check", "--uninstall", "--print", "--hooks", "--json"].includes(a)) setupFlags.push(a);
|
|
1092
|
+
if (["--check", "--uninstall", "--print", "--hooks", "--json", "--statusline", "--force"].includes(a)) setupFlags.push(a);
|
|
1094
1093
|
else if (a === "--target" && rest[i + 1]) setupFlags.push(a, rest[++i]);
|
|
1095
1094
|
else if (a.startsWith("--target=")) setupFlags.push(a);
|
|
1096
1095
|
}
|
|
@@ -1173,6 +1172,15 @@ function main(argv) {
|
|
|
1173
1172
|
// May run outside any vault (registering a fresh directory, or listing/
|
|
1174
1173
|
// switching from anywhere), so it bypasses the hard vault gate below like
|
|
1175
1174
|
// init/install/setup.
|
|
1175
|
+
if (namespace === "vault" && BINDING_VERBS.has(command)) {
|
|
1176
|
+
// `hs vault bind|bindings|resolve` - repo bindings ([[HS-TYJA]]). Plain
|
|
1177
|
+
// .mjs over the shared resolver (no tsx), cwd = where the user typed it.
|
|
1178
|
+
process.exitCode = runVaultBindingCommand(command, rest, {
|
|
1179
|
+
vaultSelector: vaultIdOverride ?? null,
|
|
1180
|
+
cwd: INVOKED_CWD,
|
|
1181
|
+
});
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1176
1184
|
if (namespace === "vault") {
|
|
1177
1185
|
dispatchSpexConsoleEntry("hs-vault.ts", argv, vaultRoot || process.cwd(), {
|
|
1178
1186
|
...(vaultIdOverride ? { HS_VAULT_ID_OVERRIDE: vaultIdOverride } : {}),
|
|
@@ -1308,11 +1316,7 @@ function main(argv) {
|
|
|
1308
1316
|
}
|
|
1309
1317
|
|
|
1310
1318
|
if (!vaultRoot) {
|
|
1311
|
-
process.stderr.write(
|
|
1312
|
-
"hs: no vault detected — could not find a heyspex.yaml marker or a " +
|
|
1313
|
-
"specs/ folder walking up from the current directory. " +
|
|
1314
|
-
"Set HS_VAULT_ROOT or run from inside a vault.\n",
|
|
1315
|
-
);
|
|
1319
|
+
process.stderr.write(noVaultMessage(vaultError));
|
|
1316
1320
|
process.exit(1);
|
|
1317
1321
|
}
|
|
1318
1322
|
|
package/bin/spexcode.mjs
CHANGED
|
@@ -57,6 +57,81 @@ const SEATS_JSON_PATH = path.resolve(__dirname, "..", "dev-factory", "seats.json
|
|
|
57
57
|
*/
|
|
58
58
|
export const SPEXCODE_BACKEND_KIND = "spexcode";
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Verbs SpexCode handles ITSELF, in-process, instead of forwarding to `hs chat` (HS-WF12,
|
|
62
|
+
* owner ruling 2026-10-14 "it all operates inside a single harness application"):
|
|
63
|
+
* seat run one harness-hosted seat process (spexharness/src/seats/run-seat.mjs) — what the
|
|
64
|
+
* orchestrator launches for every api seat (`spexcode seat --role coo --brief …`)
|
|
65
|
+
* go the guided run wizard (spexharness/src/orchestrator/go.mjs): checks, presets, one confirm, then `run`
|
|
66
|
+
* watch the run as plain sentences: new seat events as they land through the narrative renderer
|
|
67
|
+
* (spexharness/src/orchestrator/render/seat-narrative.mjs) + a one-line footer, until Ctrl+C
|
|
68
|
+
* run | status | stop | plan the deterministic orchestrator (spexharness/src/orchestrator/cli.mjs;
|
|
69
|
+
* `node dev-factory/coo-dispatcher.mjs …` is its thin alias)
|
|
70
|
+
* status and watch take --verbose (every tool step), --trace (the raw event lines, the pre-narrative
|
|
71
|
+
* format) and --seat <id> (one seat; a seat id or a spec id like HS-MP6H). Harness verbs are routed
|
|
72
|
+
* before the chat --seat guard below, so `status --seat` is a trail filter, never a seat launch check.
|
|
73
|
+
* Anything else is a chat session, forwarded unchanged to `hs chat` as before.
|
|
74
|
+
*/
|
|
75
|
+
export const HARNESS_VERBS = Object.freeze({
|
|
76
|
+
seat: "../spexharness/src/seats/run-seat.mjs",
|
|
77
|
+
run: "../spexharness/src/orchestrator/cli.mjs",
|
|
78
|
+
status: "../spexharness/src/orchestrator/cli.mjs",
|
|
79
|
+
stop: "../spexharness/src/orchestrator/cli.mjs",
|
|
80
|
+
plan: "../spexharness/src/orchestrator/cli.mjs",
|
|
81
|
+
rearm: "../spexharness/src/orchestrator/cli.mjs", // clear strike rows before a long run (spexcode rearm halted)
|
|
82
|
+
go: "../spexharness/src/orchestrator/go.mjs", // the guided wizard: checks, presets, confirm, then the same `run` path
|
|
83
|
+
watch: "../spexharness/src/orchestrator/go.mjs", // narrative tail of the audit file + footer, until Ctrl+C
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** The harness verb argv names, or null when argv is a chat session. */
|
|
87
|
+
export function harnessVerb(argv) {
|
|
88
|
+
const first = argv[0];
|
|
89
|
+
return typeof first === "string" && Object.prototype.hasOwnProperty.call(HARNESS_VERBS, first) ? first : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Run a harness verb in-process. `importFn` is injectable so tests never load the real modules.
|
|
94
|
+
* @returns {Promise<number>} exit code
|
|
95
|
+
*/
|
|
96
|
+
export async function runHarnessVerb(argv, { importFn, stderr = process.stderr } = {}) {
|
|
97
|
+
const verb = harnessVerb(argv);
|
|
98
|
+
const injected = typeof importFn === "function";
|
|
99
|
+
if (!injected) importFn = (spec) => import(spec);
|
|
100
|
+
const rel = HARNESS_VERBS[verb];
|
|
101
|
+
// Pin the vault root BEFORE the orchestrator loads: the dispatcher resolves its vault at import time, and
|
|
102
|
+
// the owner should never have to set it by hand. Discovery (dev-factory/lib/vault-root.mjs resolveRunVaultRoot):
|
|
103
|
+
// HEYSPEX_VAULT_ROOT, else the vault bound to the repo spexcode was launched in (heyspex.yaml `vault:`,
|
|
104
|
+
// a satellite pin, or a vault checkout — the same shared resolver as `hs` and the MCP server). There is no
|
|
105
|
+
// "active" vault (owner ruling 2026-10-14, HS-TYJA): unbound is a typed VAULT_UNBOUND ERROR for a verb that
|
|
106
|
+
// spawns or plans (run/plan/rearm). status/stop/watch only warn (they read run state, not the vault).
|
|
107
|
+
if (!injected && verb !== "seat" && verb !== "go") { // go pins it itself and reports where it came from
|
|
108
|
+
let pinned;
|
|
109
|
+
try {
|
|
110
|
+
const { ensureVaultRootEnv } = await import("../spexharness/src/orchestrator/go.mjs");
|
|
111
|
+
pinned = await ensureVaultRootEnv(process.env);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
pinned = { path: null, error: String(err && err.message ? err.message : err) };
|
|
114
|
+
}
|
|
115
|
+
if (!pinned.path) {
|
|
116
|
+
const docsOnly = argv.some((a) => a === "--help" || a === "-h" || a === "--self-test");
|
|
117
|
+
stderr.write(`spexcode ${verb}: ${pinned.error}\n`);
|
|
118
|
+
if (["run", "plan", "rearm"].includes(verb) && !docsOnly) return 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
let mod;
|
|
122
|
+
try {
|
|
123
|
+
mod = await importFn(rel);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
stderr.write(`spexcode ${verb}: could not load ${rel}: ${err && err.message ? err.message : err}
|
|
126
|
+
`);
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
if (verb === "seat") return mod.runSeatCli(argv.slice(1));
|
|
130
|
+
if (verb === "go") return mod.runGoCli(argv.slice(1));
|
|
131
|
+
if (verb === "watch") return mod.runWatchCli(argv.slice(1));
|
|
132
|
+
return mod.runOrchestratorCli(argv);
|
|
133
|
+
}
|
|
134
|
+
|
|
60
135
|
/** Every argument `spexcode` receives is forwarded unchanged to `hs chat`. */
|
|
61
136
|
export function buildForwardArgs(argv) {
|
|
62
137
|
return ["chat", ...argv];
|
|
@@ -189,6 +264,9 @@ export async function runSpexcode(argv, deps = {}) {
|
|
|
189
264
|
stderr = process.stderr,
|
|
190
265
|
} = deps;
|
|
191
266
|
|
|
267
|
+
// Harness verbs (seat / run / status / stop / plan) never go to chat — one application.
|
|
268
|
+
if (harnessVerb(argv)) return runHarnessVerb(argv, { importFn: deps.importFn, stderr });
|
|
269
|
+
|
|
192
270
|
const seatId = parseSeatArg(argv);
|
|
193
271
|
if (seatId !== null) {
|
|
194
272
|
let registry;
|
|
@@ -212,8 +290,21 @@ export async function runSpexcode(argv, deps = {}) {
|
|
|
212
290
|
return runForward(argv, { hsPath, spawnFn });
|
|
213
291
|
}
|
|
214
292
|
|
|
293
|
+
/** Help text only (HS-JH41): the orchestrator verbs and run flags, shown above the chat help for a bare `spexcode --help`. */
|
|
294
|
+
const RUN_HELP_ADDENDUM = [
|
|
295
|
+
"SpexCode run (the overnight orchestrator): spexcode go | run | plan | status | watch | stop | rearm (spexcode run --help for all flags)",
|
|
296
|
+
" --windows default: one visible viewer window per COO, owned and closed by the launcher",
|
|
297
|
+
" --windows-minimized the same window, minimized (Overnight)",
|
|
298
|
+
" --headless windows off (CI / scripted); the seat still runs as an attached, piped process",
|
|
299
|
+
" --milestone-seats default: one COO per milestone --epic-seats a seat for each epic (older shape)",
|
|
300
|
+
" --verbose / --trace more run output (every tool step / raw seat-event lines)",
|
|
301
|
+
"",
|
|
302
|
+
"",
|
|
303
|
+
].join("\n");
|
|
304
|
+
|
|
215
305
|
async function main() {
|
|
216
306
|
const argv = process.argv.slice(2);
|
|
307
|
+
if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) process.stdout.write(RUN_HELP_ADDENDUM);
|
|
217
308
|
process.exitCode = await runSpexcode(argv);
|
|
218
309
|
}
|
|
219
310
|
|
|
@@ -34,6 +34,8 @@ import { spawnSync } from "node:child_process";
|
|
|
34
34
|
import { join, resolve, dirname } from "node:path";
|
|
35
35
|
import { fileURLToPath } from "node:url";
|
|
36
36
|
import { tmpdir } from "node:os";
|
|
37
|
+
import { toRunnerRelativePath } from "../lib/runner-path.mjs";
|
|
38
|
+
import { isNoTestFilesOutput } from "../lib/dispatch-guard.mjs";
|
|
37
39
|
|
|
38
40
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
39
41
|
|
|
@@ -97,13 +99,24 @@ export function runTsc(ctx, worktree) {
|
|
|
97
99
|
return { count: (out.match(/error TS\d+/g) || []).length, status: r.status, output: out.trim().slice(0, 1200), timedOut: r.error?.code === "ETIMEDOUT" };
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
/**
|
|
102
|
+
/** The vitest filter for `testRel` as seen from vitest's cwd (<worktree>/<spex>). Callers pass
|
|
103
|
+
* the test REPO-relative ("spex-console/src/x.test.ts", same convention as task.target) or
|
|
104
|
+
* already spex-relative ("src/x.test.ts"); vitest only matches the latter from its cwd —
|
|
105
|
+
* the repo-relative form printed "No test files found" and was misread as a red baseline
|
|
106
|
+
* (HS-7BYJ, 2026-10-15). */
|
|
107
|
+
export function vitestFilter(ctx, worktree, testRel) {
|
|
108
|
+
return toRunnerRelativePath(testRel, { cwdRel: ctx.spex, roots: [worktree, ctx.repo] });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Run a scoped vitest file in <worktree>/<spex>. Returns { pass, output, timedOut, cwd, filter }. */
|
|
101
112
|
export function runVitest(ctx, worktree, testRel) {
|
|
102
|
-
const
|
|
103
|
-
|
|
113
|
+
const cwd = join(worktree, ctx.spex);
|
|
114
|
+
const filter = vitestFilter(ctx, worktree, testRel);
|
|
115
|
+
const r = spawnSync(`npx --no-install vitest run ${filter}`, {
|
|
116
|
+
cwd, shell: true, encoding: "utf8", timeout: ctx.testTimeout,
|
|
104
117
|
});
|
|
105
118
|
const out = ((r.stdout || "") + (r.stderr || ""));
|
|
106
|
-
return { pass: r.status === 0 && !r.error, output: out.trim().slice(-1200), timedOut: r.error?.code === "ETIMEDOUT" };
|
|
119
|
+
return { pass: r.status === 0 && !r.error, output: out.trim().slice(-1200), timedOut: r.error?.code === "ETIMEDOUT", cwd, filter };
|
|
107
120
|
}
|
|
108
121
|
|
|
109
122
|
// HS-AY5K: the deterministic gate used to run EVERY test file through scoped
|
|
@@ -191,7 +204,10 @@ export function runPlainSelfTestScript(ctx, worktree, testRel) {
|
|
|
191
204
|
* runGate and baselineTest both call through, so the routing rule lives in exactly
|
|
192
205
|
* one place. Order matters: node:test and the plain-self-test conventions are both
|
|
193
206
|
* narrow, cheap-to-check positives; vitest is the default fallback for everything else. */
|
|
194
|
-
function runScopedTest(ctx, worktree,
|
|
207
|
+
function runScopedTest(ctx, worktree, testPath) {
|
|
208
|
+
// Root-relative for the node runners (cwd = worktree root); an absolute or backslashed
|
|
209
|
+
// path is re-rooted here, and runVitest re-relativizes it to its own spex cwd.
|
|
210
|
+
const testRel = toRunnerRelativePath(testPath, { cwdRel: "", roots: [worktree, ctx.repo] });
|
|
195
211
|
const abs = join(worktree, testRel);
|
|
196
212
|
if (isNodeTestFile(abs)) return runNodeTest(ctx, worktree, testRel);
|
|
197
213
|
if (isPlainSelfTestScript(abs)) return runPlainSelfTestScript(ctx, worktree, testRel);
|
|
@@ -290,7 +306,8 @@ export function runLanguageGate(ctx, worktree, task, code, { buildCmd, testCmd,
|
|
|
290
306
|
// relative against `worktree`; vitest resolves it spex-relative against
|
|
291
307
|
// `worktree/ctx.spex`) — a NEW-FILE test could land under either once the builder
|
|
292
308
|
// creates it.
|
|
293
|
-
function testExistsAtBaseline(ctx, worktree,
|
|
309
|
+
function testExistsAtBaseline(ctx, worktree, testPath) {
|
|
310
|
+
const testRel = toRunnerRelativePath(testPath, { cwdRel: "", roots: [worktree, ctx.repo] });
|
|
294
311
|
return existsSync(join(worktree, testRel)) || existsSync(join(worktree, ctx.spex, testRel));
|
|
295
312
|
}
|
|
296
313
|
|
|
@@ -302,7 +319,19 @@ export function baselineTest(ctx, worktree, testRel) {
|
|
|
302
319
|
output: `test file does not exist at baseline (${testRel}) — expected NEW-FILE condition, not a gate/environment fault`,
|
|
303
320
|
};
|
|
304
321
|
}
|
|
305
|
-
|
|
322
|
+
const r = runScopedTest(ctx, worktree, testRel);
|
|
323
|
+
// The file EXISTS (checked above), so a runner that still reports "No test files found"
|
|
324
|
+
// is a path/filter mismatch between the caller and the runner's cwd — never a silent
|
|
325
|
+
// environment fault. Name the cwd and the filter so the fix is obvious (HS-7BYJ).
|
|
326
|
+
if (!r.pass && isNoTestFilesOutput(r.output)) {
|
|
327
|
+
return {
|
|
328
|
+
...r,
|
|
329
|
+
runnerPathMismatch: true,
|
|
330
|
+
output: `runner found no test files although ${testRel} exists — runner cwd: ${r.cwd ?? worktree}, filter: ${r.filter ?? testRel}. `
|
|
331
|
+
+ `The test path is not relative to the runner's cwd (or the runner's include globs exclude it); fix the path convention, not the code.\n${r.output}`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return r;
|
|
306
335
|
}
|
|
307
336
|
|
|
308
337
|
// HS-L9BX: `setupWorktree` builds the gate's worktree from `git worktree add --detach
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* exact -EPIC-suffix regression that motivated the module.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import {
|
|
29
|
+
import { isMainModule } from "../../scripts/lib/shared-path-helpers.mjs";
|
|
30
30
|
|
|
31
31
|
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
32
32
|
|
|
@@ -185,6 +185,6 @@ function runSelfTest() {
|
|
|
185
185
|
return ok ? 0 : 1;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
if (
|
|
188
|
+
if (isMainModule(import.meta.url)) {
|
|
189
189
|
if (process.argv.includes("--self-test")) process.exit(runSelfTest());
|
|
190
190
|
}
|