heyspex 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +10 -0
  2. package/bin/hs-vault-binding.mjs +157 -0
  3. package/bin/hs.mjs +99 -81
  4. package/bin/spexcode.mjs +91 -0
  5. package/dev-factory/engine/worktree.mjs +36 -7
  6. package/dev-factory/lib/epic-detect.mjs +2 -2
  7. package/dev-factory/lib/fleet-readiness.mjs +3 -5
  8. package/dev-factory/lib/fleet-state.mjs +2 -2
  9. package/dev-factory/lib/sufficiency-score.mjs +3 -5
  10. package/dist/cli/hs-check.mjs +76 -46
  11. package/dist/cli/hs-claim.mjs +63 -33
  12. package/dist/cli/hs-connect.mjs +520 -214
  13. package/dist/cli/hs-defer.mjs +161 -19
  14. package/dist/cli/hs-dev.mjs +11 -5
  15. package/dist/cli/hs-env.mjs +187 -161
  16. package/dist/cli/hs-index-rebuild.mjs +123 -78
  17. package/dist/cli/hs-index-reconcile.mjs +203 -107
  18. package/dist/cli/hs-init.mjs +426 -179
  19. package/dist/cli/hs-install.mjs +759 -437
  20. package/dist/cli/hs-join.mjs +590 -383
  21. package/dist/cli/hs-vault-pull.mjs +359 -254
  22. package/dist/cli/hs-vault-push.mjs +151 -113
  23. package/dist/cli/hs-vault.mjs +271 -104
  24. package/dist/cli/hs-workspace.mjs +90 -57
  25. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
  26. package/docs/copilot-plugin-install.md +189 -162
  27. package/docs/driving-heyspex-from-copilot.md +174 -165
  28. package/docs/mcp-client-config.md +93 -25
  29. package/package.json +255 -249
  30. package/plugins/heyspex/.mcp.json +3 -4
  31. package/plugins/heyspex/README.md +323 -309
  32. package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
  33. package/plugins/heyspex/mcp.json +12 -13
  34. package/scripts/check-mcp-freshness.mjs +6 -4
  35. package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
  36. package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
  37. package/scripts/claude-hooks/research-method-guard.mjs +2 -2
  38. package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
  39. package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
  40. package/scripts/claude-statusline/statusline-command.mjs +1058 -0
  41. package/scripts/dev-server-status.mjs +3 -2
  42. package/scripts/dev-server-stop.mjs +3 -2
  43. package/scripts/hs-capture-cli.mjs +2 -2
  44. package/scripts/hs-settings.mjs +2 -2
  45. package/scripts/hs-view.mjs +3 -2
  46. package/scripts/install-copilot-hooks.mjs +318 -0
  47. package/scripts/lib/copilot-cli.mjs +108 -0
  48. package/scripts/lib/customer-kit.mjs +223 -30
  49. package/scripts/lib/dev-mode.mjs +28 -2
  50. package/scripts/lib/shared-path-helpers.mjs +32 -0
  51. package/scripts/lib/standalone-doctor.mjs +131 -32
  52. package/scripts/wip-ledger-health.mjs +3 -2
  53. package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
  54. package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
  55. package/spexharness/src/intent-directive.mjs +839 -626
  56. package/spexharness/src/rule-table.mjs +1 -1
  57. package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
  58. package/vendor/heyspex-mcp/cli.mjs +20 -4
  59. package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
  60. package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
  61. package/vendor/heyspex-mcp/events-tools.mjs +1 -1
  62. package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
  63. package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
  64. package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
  65. package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
  66. package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
  67. package/vendor/heyspex-mcp/tool-dispatch.mjs +59 -23
  68. package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
  69. package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
  70. package/vendor/heyspex-mcp/vault-resolve.mjs +353 -245
package/README.md CHANGED
@@ -4,6 +4,16 @@
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
+ ```
14
+
15
+ 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).
16
+
7
17
  ## Machine setup
8
18
 
9
19
  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";
@@ -60,6 +61,7 @@ const PKG_ROOT = path.resolve(__dirname, "..");
60
61
  export const SPAWN_TARGETS = {
61
62
  "setup:claude": { path: "scripts/install-claude-code-hooks.mjs" },
62
63
  "setup:cursor": { path: "scripts/install-cursor-hooks.mjs" },
64
+ "setup:copilot": { path: "scripts/install-copilot-hooks.mjs" },
63
65
  "skills:sync": {
64
66
  path: "scripts/sync-skills.mjs",
65
67
  devOnly: true,
@@ -315,21 +317,11 @@ export function findVaultRoot(start, stopAt) {
315
317
  }
316
318
 
317
319
  /**
318
- * Registry fallback for when the specs/ walk-up finds nothing — since the
319
- * repo split (2026-08-31) the vault lives outside the code repo, so `hs`
320
- * run from the code repo would otherwise exit "no vault detected".
321
- * Delegates to the shared resolver (same one the MCP server trusts) but
322
- * only accepts a result that names a real vault: the resolver's last-resort
323
- * cwd fallback is rejected so the "no vault detected" error below still
324
- * fires when there is genuinely no vault anywhere.
325
- *
326
- * The test is deliberately "anything but `cwd-heuristic`", so every real
327
- * tier passes it: `explicit`, `account-pointer`, `env-vault-root`,
328
- * `env-vault-dir`, and `satellite-pin` (an on-disk manifest attachment found
329
- * by the resolver's tier-3 walk -- that tier reported the untrue
330
- * `env-vault-root` until 2026-09-18; both labels are accepted here, so the
331
- * rename changed nothing). `cwd-marker` and `marker-registry-mismatch` (a
332
- * 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.
333
325
  */
334
326
  export function findRegistryVaultRoot(options = {}) {
335
327
  let resolved;
@@ -338,7 +330,7 @@ export function findRegistryVaultRoot(options = {}) {
338
330
  } catch {
339
331
  return null;
340
332
  }
341
- if (!resolved || resolved.source === "cwd-heuristic") return null;
333
+ if (!resolved || resolved.source === "cwd-heuristic" || resolved.source === "unbound") return null;
342
334
  if (!existsSync(resolved.specsDirAbs)) return null;
343
335
  return resolved.repoRootAbs;
344
336
  }
@@ -411,56 +403,69 @@ export function pointerOnlyRefusalLine(cwd, vaultRoot) {
411
403
 
412
404
  /**
413
405
  * The vault-resolution ladder `main()` runs at the top of every invocation,
414
- * factored out so it is unit-testable without spawning a child process
415
- * ([[HS-0I1G-B0IT-TASK-track-and-announce-the-registry-active]]).
406
+ * factored out so it is unit-testable without spawning a child process.
416
407
  *
417
- * Reports `usedRegistryFallback: true` only when NONE of `HS_VAULT_ROOT`, an
418
- * explicit `--target`-equivalent, or a marker/satellite pin found walking up
419
- * from `cwd` answered, and `findRegistryVaultRoot()` (the machine's
420
- * last-active vault, `~/.heyspex/registry.json`) is what resolved the root.
421
- * `hs install` uses that flag to warn (always) and — when `--register-vault`
422
- * is given without `--dry-run` — refuse, so a customer onboarding an empty
423
- * directory doesn't silently write into whatever vault this machine last had
424
- * 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.
425
416
  *
426
- * `stopAt` is a TEST-ONLY walk-boundary fence, threaded through to both
427
- * `findVaultRoot`'s own walk and `resolveActiveVault`'s inner cwd walk (via
428
- * its `cwdWalkRoot` option) — production callers (`main()`) omit it.
417
+ * `stopAt` is a TEST-ONLY walk-boundary fence (the resolver's `cwdWalkRoot`).
429
418
  *
430
419
  * @param {string} cwd
431
420
  * @param {NodeJS.ProcessEnv} env
432
421
  * @param {string} [stopAt]
433
- * @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.
434
426
  */
435
- export function resolveVaultRootForMain(cwd, env, stopAt) {
427
+ export function resolveVaultRootForMain(cwd, env, stopAt, opts = {}) {
436
428
  if (env.HS_VAULT_ROOT) {
437
- return { vaultRoot: env.HS_VAULT_ROOT, usedRegistryFallback: false };
429
+ return { vaultRoot: env.HS_VAULT_ROOT, usedRegistryFallback: false, source: "HS_VAULT_ROOT", error: null };
438
430
  }
439
- // A pin that cannot be honoured is a REFUSAL, never a reason to fall
440
- // 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).
441
433
  const brokenPin = findBrokenSatellitePin(cwd, stopAt);
442
434
  if (brokenPin) {
443
- return { vaultRoot: null, usedRegistryFallback: false, brokenPin };
444
- }
445
- const walkedRoot = findVaultRoot(cwd, stopAt);
446
- if (walkedRoot) {
447
- return { vaultRoot: walkedRoot, usedRegistryFallback: false };
448
- }
449
- // Same ONE order as both resolvers ([[HS-VVNQ]], shared fixture
450
- // spex-console/src/lib/vault-resolve/precedence-fixtures.json):
451
- // `HS_VAULT_ROOT` (this CLI's explicit tier) → the walk above (satellite
452
- // pin, then marker) → `HEYSPEX_VAULT_ROOT`/`HEYSPEX_VAULT_DIR` env → the
453
- // registry pointer, the last two via the shared resolver. `cwd` is passed
454
- // explicitly so the resolver's own walk starts where ours did — left to
455
- // its default it would start from `CLAUDE_PROJECT_DIR`, and a marker found
456
- // THERE (a different folder) would be mislabelled a registry fallback.
457
- const registryRoot = findRegistryVaultRoot(
458
- 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"
459
468
  );
460
- return {
461
- vaultRoot: registryRoot,
462
- usedRegistryFallback: Boolean(registryRoot),
463
- };
464
469
  }
465
470
 
466
471
  /** True when `argv` carries `--attach-vault` (bare or `=value` form). */
@@ -558,6 +563,9 @@ function usage() {
558
563
  " hs join <code> Join with a pairing code (LAN auto-find; no IP to type)",
559
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)",
560
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",
561
569
  " hs workspace list|create|switch Manage registered workspaces (company containers)",
562
570
  " hs connect [--dry-run] [--json] | --check [--json] Detect+scaffold+register this repo (or read-only check)",
563
571
  " hs --vault <id> <subcommand> One-shot vault override for this invocation",
@@ -565,7 +573,7 @@ function usage() {
565
573
  " hs gateway validate [slug] [--json] Validate user-authored gateway skills (.heyspex/skills/user/)",
566
574
  " hs mode on|off|status|toggle Master switch: HeySpex layer on or off (restart Claude Code)",
567
575
  " hs dev [mode:local|mode:cloud|mode:prod] Dev/prod mode + local/cloud reliance (status when bare)",
568
- " hs setup claude|cursor [--check|--uninstall] Install agent lifecycle hooks",
576
+ " hs setup claude|cursor|copilot [--check|--uninstall] Install agent lifecycle hooks (copilot also: --print, --hooks)",
569
577
  " hs skills sync|check Propagate skills to Cursor/Codex/Claude (cwd-safe)",
570
578
  " hs skills add <module-path> --name <n> --trigger </slash> [--export <n>] [--description <t>] Register a skill (extension seam)",
571
579
  " hs skills remove <name> Remove a customer-registered skill",
@@ -944,19 +952,15 @@ function main(argv) {
944
952
  const command = argv[1];
945
953
  const rest = argv.slice(2);
946
954
 
947
- const resolvedMain = resolveVaultRootForMain(process.cwd(), process.env);
948
- const { usedRegistryFallback, brokenPin } = resolvedMain;
949
- // P1: the pointer is not a binding. A verb that acts on a vault runs with
950
- // NO vault (and is refused at the gate) rather than on whatever vault the
951
- // console last selected; onboarding/registry verbs keep the old fallback.
952
- const pointerOnly =
953
- usedRegistryFallback &&
954
- !pointerVaultAllowed(process.env) &&
955
- !pointerFallbackAllowedFor(namespace, command);
956
- const vaultRoot = pointerOnly ? null : resolvedMain.vaultRoot;
957
- if (pointerOnly) {
958
- process.stderr.write(pointerOnlyRefusalLine(process.cwd(), resolvedMain.vaultRoot));
959
- }
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.
960
964
  if (
961
965
  brokenPin &&
962
966
  namespace !== "doctor" &&
@@ -1049,11 +1053,7 @@ function main(argv) {
1049
1053
  // (optional name defaults to "peer"). Needs a vault (this machine owns it).
1050
1054
  if (namespace === "invite") {
1051
1055
  if (!vaultRoot) {
1052
- process.stderr.write(
1053
- "hs: no vault detected — could not find a heyspex.yaml marker or a " +
1054
- "specs/ folder walking up from the current directory. " +
1055
- "Set HS_VAULT_ROOT or run from inside a vault.\n",
1056
- );
1056
+ process.stderr.write(noVaultMessage(vaultError));
1057
1057
  process.exit(1);
1058
1058
  }
1059
1059
  dispatchSpexConsoleEntry(
@@ -1070,20 +1070,33 @@ function main(argv) {
1070
1070
  const SPAWN_KEYS = {
1071
1071
  claude: "setup:claude",
1072
1072
  cursor: "setup:cursor",
1073
+ copilot: "setup:copilot",
1073
1074
  };
1074
1075
  if (!agent || !Object.hasOwn(SPAWN_KEYS, agent)) {
1075
1076
  process.stderr.write(
1076
- `hs setup: expected \`claude\` or \`cursor\` (got \`${command ?? ""}\`)\n` +
1077
+ `hs setup: expected \`claude\`, \`cursor\` or \`copilot\` (got \`${command ?? ""}\`)\n` +
1077
1078
  " hs setup claude [--check|--uninstall] Claude Code lifecycle hooks\n" +
1078
- " hs setup cursor [--check|--uninstall] Cursor CLI lifecycle hooks\n",
1079
+ " hs setup cursor [--check|--uninstall] Cursor CLI lifecycle hooks\n" +
1080
+ " hs setup copilot [--check|--uninstall|--print|--hooks] GitHub Copilot (plugin; --hooks for the cloud agent)\n",
1079
1081
  );
1080
1082
  process.exit(2);
1081
1083
  }
1082
1084
  const setupFlags = rest.filter(
1083
1085
  (a) => a === "--check" || a === "--uninstall",
1084
1086
  );
1087
+ if (agent === "copilot") {
1088
+ // Copilot takes more flags: --print, --hooks, --json, --target <dir>.
1089
+ setupFlags.length = 0;
1090
+ for (let i = 0; i < rest.length; i++) {
1091
+ const a = rest[i];
1092
+ if (["--check", "--uninstall", "--print", "--hooks", "--json"].includes(a)) setupFlags.push(a);
1093
+ else if (a === "--target" && rest[i + 1]) setupFlags.push(a, rest[++i]);
1094
+ else if (a.startsWith("--target=")) setupFlags.push(a);
1095
+ }
1096
+ }
1085
1097
  const script = spawnTargetPath(SPAWN_KEYS[agent]);
1086
- const target = vaultRoot || process.cwd();
1098
+ // Copilot sets up the repo you are standing in, never the (possibly sibling) vault root.
1099
+ const target = agent === "copilot" ? process.cwd() : vaultRoot || process.cwd();
1087
1100
  const child = spawn(process.execPath, [script, ...setupFlags], {
1088
1101
  cwd: target,
1089
1102
  stdio: "inherit",
@@ -1159,6 +1172,15 @@ function main(argv) {
1159
1172
  // May run outside any vault (registering a fresh directory, or listing/
1160
1173
  // switching from anywhere), so it bypasses the hard vault gate below like
1161
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
+ }
1162
1184
  if (namespace === "vault") {
1163
1185
  dispatchSpexConsoleEntry("hs-vault.ts", argv, vaultRoot || process.cwd(), {
1164
1186
  ...(vaultIdOverride ? { HS_VAULT_ID_OVERRIDE: vaultIdOverride } : {}),
@@ -1294,11 +1316,7 @@ function main(argv) {
1294
1316
  }
1295
1317
 
1296
1318
  if (!vaultRoot) {
1297
- process.stderr.write(
1298
- "hs: no vault detected — could not find a heyspex.yaml marker or a " +
1299
- "specs/ folder walking up from the current directory. " +
1300
- "Set HS_VAULT_ROOT or run from inside a vault.\n",
1301
- );
1319
+ process.stderr.write(noVaultMessage(vaultError));
1302
1320
  process.exit(1);
1303
1321
  }
1304
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