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.
Files changed (79) hide show
  1. package/.claude-plugin/marketplace.json +2 -1
  2. package/README.md +11 -0
  3. package/bin/hs-vault-binding.mjs +157 -0
  4. package/bin/hs.mjs +83 -79
  5. package/bin/spexcode.mjs +91 -0
  6. package/dev-factory/engine/worktree.mjs +36 -7
  7. package/dev-factory/lib/epic-detect.mjs +2 -2
  8. package/dev-factory/lib/fleet-readiness.mjs +3 -5
  9. package/dev-factory/lib/fleet-state.mjs +2 -2
  10. package/dev-factory/lib/sufficiency-score.mjs +3 -5
  11. package/dist/cli/hs-check.mjs +76 -46
  12. package/dist/cli/hs-claim.mjs +63 -33
  13. package/dist/cli/hs-connect.mjs +520 -214
  14. package/dist/cli/hs-defer.mjs +161 -19
  15. package/dist/cli/hs-dev.mjs +11 -5
  16. package/dist/cli/hs-env.mjs +187 -161
  17. package/dist/cli/hs-index-rebuild.mjs +123 -78
  18. package/dist/cli/hs-index-reconcile.mjs +203 -107
  19. package/dist/cli/hs-init.mjs +426 -179
  20. package/dist/cli/hs-install.mjs +759 -437
  21. package/dist/cli/hs-join.mjs +590 -383
  22. package/dist/cli/hs-vault-pull.mjs +359 -254
  23. package/dist/cli/hs-vault-push.mjs +151 -113
  24. package/dist/cli/hs-vault.mjs +271 -104
  25. package/dist/cli/hs-workspace.mjs +90 -57
  26. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
  27. package/docs/copilot-plugin-install.md +200 -177
  28. package/docs/driving-heyspex-from-copilot.md +174 -165
  29. package/docs/mcp-client-config.md +93 -25
  30. package/package.json +257 -250
  31. package/plugins/heyspex/.claude-plugin/plugin.json +13 -12
  32. package/plugins/heyspex/.cursor-plugin/plugin.json +16 -15
  33. package/plugins/heyspex/.mcp.json +3 -4
  34. package/plugins/heyspex/README.md +323 -312
  35. package/plugins/heyspex/assets/heyspex-glyph-128.png +0 -0
  36. package/plugins/heyspex/assets/heyspex-glyph-256.png +0 -0
  37. package/plugins/heyspex/assets/heyspex-glyph.svg +12 -0
  38. package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
  39. package/plugins/heyspex/mcp.json +12 -13
  40. package/scripts/check-mcp-freshness.mjs +6 -4
  41. package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
  42. package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
  43. package/scripts/claude-hooks/research-method-guard.mjs +2 -2
  44. package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
  45. package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
  46. package/scripts/claude-statusline/statusline-command.mjs +1073 -0
  47. package/scripts/copilot-statusline.mjs +130 -0
  48. package/scripts/dev-server-status.mjs +3 -2
  49. package/scripts/dev-server-stop.mjs +3 -2
  50. package/scripts/hs-capture-cli.mjs +2 -2
  51. package/scripts/hs-settings.mjs +2 -2
  52. package/scripts/hs-view.mjs +3 -2
  53. package/scripts/install-copilot-hooks.mjs +152 -36
  54. package/scripts/lib/copilot-cli.mjs +108 -0
  55. package/scripts/lib/customer-kit.mjs +223 -30
  56. package/scripts/lib/dev-mode.mjs +28 -2
  57. package/scripts/lib/shared-path-helpers.mjs +32 -0
  58. package/scripts/lib/standalone-doctor.mjs +131 -32
  59. package/scripts/wip-ledger-health.mjs +3 -2
  60. package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
  61. package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
  62. package/spexharness/src/intent-directive.mjs +839 -626
  63. package/spexharness/src/rule-table.mjs +1 -1
  64. package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
  65. package/vendor/heyspex-mcp/cli.mjs +20 -4
  66. package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
  67. package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
  68. package/vendor/heyspex-mcp/events-tools.mjs +1 -1
  69. package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
  70. package/vendor/heyspex-mcp/http-transport.mjs +6 -1
  71. package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
  72. package/vendor/heyspex-mcp/server-identity.mjs +13 -0
  73. package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
  74. package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
  75. package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
  76. package/vendor/heyspex-mcp/tool-dispatch.mjs +63 -23
  77. package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
  78. package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
  79. 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
- "description": "SpexCode plugin (for Claude Code and GitHub Copilot CLI): HeySpex vault integration, MCP server (spec vault tools + hs-* prompts) and lifecycle hooks. Prototype — see plugins/heyspex/README.md before enabling."
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
- * Registry fallback for when the specs/ walk-up finds nothing — since the
320
- * repo split (2026-08-31) the vault lives outside the code repo, so `hs`
321
- * run from the code repo would otherwise exit "no vault detected".
322
- * Delegates to the shared resolver (same one the MCP server trusts) but
323
- * only accepts a result that names a real vault: the resolver's last-resort
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
- * Reports `usedRegistryFallback: true` only when NONE of `HS_VAULT_ROOT`, an
419
- * explicit `--target`-equivalent, or a marker/satellite pin found walking up
420
- * from `cwd` answered, and `findRegistryVaultRoot()` (the machine's
421
- * last-active vault, `~/.heyspex/registry.json`) is what resolved the root.
422
- * `hs install` uses that flag to warn (always) and — when `--register-vault`
423
- * is given without `--dry-run` — refuse, so a customer onboarding an empty
424
- * directory doesn't silently write into whatever vault this machine last had
425
- * active in some other terminal/console tab.
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, threaded through to both
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
- * @returns {{ vaultRoot: string|null, usedRegistryFallback: boolean }}
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 fall
441
- // through to the account-wide pointer (owner principle P1, 2026-10-16).
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
- const walkedRoot = findVaultRoot(cwd, stopAt);
447
- if (walkedRoot) {
448
- return { vaultRoot: walkedRoot, usedRegistryFallback: false };
449
- }
450
- // Same ONE order as both resolvers ([[HS-VVNQ]], shared fixture
451
- // spex-console/src/lib/vault-resolve/precedence-fixtures.json):
452
- // `HS_VAULT_ROOT` (this CLI's explicit tier) → the walk above (satellite
453
- // pin, then marker) → `HEYSPEX_VAULT_ROOT`/`HEYSPEX_VAULT_DIR` env → the
454
- // registry pointer, the last two via the shared resolver. `cwd` is passed
455
- // explicitly so the resolver's own walk starts where ours did — left to
456
- // its default it would start from `CLAUDE_PROJECT_DIR`, and a marker found
457
- // THERE (a different folder) would be mislabelled a registry fallback.
458
- const registryRoot = findRegistryVaultRoot(
459
- stopAt ? { cwd, cwdWalkRoot: stopAt } : { cwd },
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 resolvedMain = resolveVaultRootForMain(process.cwd(), process.env);
949
- const { usedRegistryFallback, brokenPin } = resolvedMain;
950
- // P1: the pointer is not a binding. A verb that acts on a vault runs with
951
- // NO vault (and is refused at the gate) rather than on whatever vault the
952
- // console last selected; onboarding/registry verbs keep the old fallback.
953
- const pointerOnly =
954
- usedRegistryFallback &&
955
- !pointerVaultAllowed(process.env) &&
956
- !pointerFallbackAllowedFor(namespace, command);
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
- /** Run a scoped vitest file in <worktree>/<spex>. Returns { pass, output, timedOut }. */
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 r = spawnSync(`npx --no-install vitest run ${testRel}`, {
103
- cwd: join(worktree, ctx.spex), shell: true, encoding: "utf8", timeout: ctx.testTimeout,
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, testRel) {
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, testRel) {
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
- return runScopedTest(ctx, worktree, testRel);
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 { pathToFileURL } from "node:url";
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 (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
188
+ if (isMainModule(import.meta.url)) {
189
189
  if (process.argv.includes("--self-test")) process.exit(runSelfTest());
190
190
  }