heyspex 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/bin/hs-vault-binding.mjs +157 -0
- package/bin/hs.mjs +99 -81
- package/bin/spexcode.mjs +91 -0
- package/dev-factory/engine/worktree.mjs +36 -7
- package/dev-factory/lib/epic-detect.mjs +2 -2
- package/dev-factory/lib/fleet-readiness.mjs +3 -5
- package/dev-factory/lib/fleet-state.mjs +2 -2
- package/dev-factory/lib/sufficiency-score.mjs +3 -5
- package/dist/cli/hs-check.mjs +76 -46
- package/dist/cli/hs-claim.mjs +63 -33
- package/dist/cli/hs-connect.mjs +520 -214
- package/dist/cli/hs-defer.mjs +161 -19
- package/dist/cli/hs-dev.mjs +11 -5
- package/dist/cli/hs-env.mjs +187 -161
- package/dist/cli/hs-index-rebuild.mjs +123 -78
- package/dist/cli/hs-index-reconcile.mjs +203 -107
- package/dist/cli/hs-init.mjs +426 -179
- package/dist/cli/hs-install.mjs +759 -437
- package/dist/cli/hs-join.mjs +590 -383
- package/dist/cli/hs-vault-pull.mjs +359 -254
- package/dist/cli/hs-vault-push.mjs +151 -113
- package/dist/cli/hs-vault.mjs +271 -104
- package/dist/cli/hs-workspace.mjs +90 -57
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
- package/docs/copilot-plugin-install.md +189 -162
- package/docs/driving-heyspex-from-copilot.md +174 -165
- package/docs/mcp-client-config.md +93 -25
- package/package.json +255 -249
- package/plugins/heyspex/.mcp.json +3 -4
- package/plugins/heyspex/README.md +323 -309
- package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
- package/plugins/heyspex/mcp.json +12 -13
- package/scripts/check-mcp-freshness.mjs +6 -4
- package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
- package/scripts/claude-hooks/research-method-guard.mjs +2 -2
- package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
- package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
- package/scripts/claude-statusline/statusline-command.mjs +1058 -0
- package/scripts/dev-server-status.mjs +3 -2
- package/scripts/dev-server-stop.mjs +3 -2
- package/scripts/hs-capture-cli.mjs +2 -2
- package/scripts/hs-settings.mjs +2 -2
- package/scripts/hs-view.mjs +3 -2
- package/scripts/install-copilot-hooks.mjs +318 -0
- package/scripts/lib/copilot-cli.mjs +108 -0
- package/scripts/lib/customer-kit.mjs +223 -30
- package/scripts/lib/dev-mode.mjs +28 -2
- package/scripts/lib/shared-path-helpers.mjs +32 -0
- package/scripts/lib/standalone-doctor.mjs +131 -32
- package/scripts/wip-ledger-health.mjs +3 -2
- package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
- package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
- package/spexharness/src/intent-directive.mjs +839 -626
- package/spexharness/src/rule-table.mjs +1 -1
- package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
- package/vendor/heyspex-mcp/cli.mjs +20 -4
- package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
- package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
- package/vendor/heyspex-mcp/events-tools.mjs +1 -1
- package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
- package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
- package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +59 -23
- package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
- package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +353 -245
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
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
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
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
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
|
|
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
|
-
* @
|
|
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
|
|
440
|
-
//
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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 \`
|
|
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
|
-
|
|
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
|
|