heyspex 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/bin/hs.mjs +281 -30
- package/dev-factory/lib/dispatch-guard.mjs +75 -0
- package/dev-factory/lib/runner-path.mjs +51 -0
- package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
- package/dist/cli/hs-connect.mjs +20 -1
- package/dist/cli/hs-defer.mjs +3802 -3744
- package/dist/cli/hs-events.mjs +1296 -0
- package/dist/cli/hs-export-events.mjs +1252 -0
- package/dist/cli/hs-handoff.mjs +2576 -0
- package/dist/cli/hs-index-rebuild.mjs +209 -36
- package/dist/cli/hs-index-reconcile.mjs +212 -39
- package/dist/cli/hs-init.mjs +20 -1
- package/dist/cli/hs-install.mjs +20 -1
- package/dist/cli/hs-join.mjs +20 -1
- package/dist/cli/hs-next-launch.mjs +2698 -0
- package/dist/cli/hs-pin.mjs +1336 -0
- package/dist/cli/hs-prereq.mjs +1380 -0
- package/dist/cli/hs-revalidate.mjs +3257 -0
- package/dist/cli/hs-spawn.mjs +12377 -0
- package/dist/cli/hs-test.mjs +1046 -0
- package/dist/cli/hs-trash.mjs +362 -0
- package/dist/cli/hs-trust.mjs +197 -0
- package/dist/cli/hs-vault-pull.mjs +214 -41
- package/dist/cli/hs-vault-push.mjs +212 -39
- package/dist/cli-templates/harness-commands/claude/hs-log.md +1 -1
- package/dist/cli-templates/harness-commands/claude/hs-res.md +71 -63
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-log.md +1 -1
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +66 -58
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +18 -18
- package/docs/copilot-plugin-install.md +2 -2
- package/package.json +9 -5
- package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
- package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
- package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
- package/scripts/claude-statusline/statusline-command.mjs +287 -27
- package/scripts/hs-companion.mjs +8 -0
- package/scripts/install-claude-code-hooks.mjs +104 -3
- package/scripts/install-copilot-hooks.mjs +21 -1
- package/scripts/install-cursor-hooks.mjs +4 -3
- package/scripts/lib/capture-execution-guard.mjs +221 -0
- package/scripts/lib/customer-kit.mjs +48 -0
- package/scripts/lib/hs-companion-settings.mjs +25 -0
- package/scripts/record-grounding.mjs +38 -0
- package/vendor/heyspex-mcp/http-transport.mjs +1 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
- package/bin/postinstall.mjs +0 -88
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"name": "heyspex",
|
|
10
10
|
"source": "./plugins/heyspex",
|
|
11
11
|
"displayName": "HeySpex",
|
|
12
|
-
"description": "
|
|
12
|
+
"description": "SpexCode plugin (for Claude Code), also installable in GitHub Copilot CLI: HeySpex vault integration, MCP server (spec vault tools + hs-* prompts) and lifecycle hooks. Prototype — see plugins/heyspex/README.md before enabling."
|
|
13
13
|
}
|
|
14
14
|
]
|
|
15
15
|
}
|
package/bin/hs.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* node_modules) executes the .ts entrypoints.
|
|
13
13
|
*/
|
|
14
14
|
import { BINDING_VERBS, runVaultBindingCommand } from "./hs-vault-binding.mjs";
|
|
15
|
+
import { findProjectRoot } from "../scripts/lib/customer-kit.mjs";
|
|
15
16
|
import { spawn } from "node:child_process";
|
|
16
17
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
17
18
|
import path from "node:path";
|
|
@@ -36,6 +37,73 @@ import {
|
|
|
36
37
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
37
38
|
const PKG_ROOT = path.resolve(__dirname, "..");
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* True when this `hs` runs from an installed `heyspex` npm package rather than
|
|
42
|
+
* a HeySpex source checkout. Same signal `hs doctor` already dispatches on:
|
|
43
|
+
* scripts/heyspex-doctor.mjs only exists in a source clone (never in `files`).
|
|
44
|
+
*/
|
|
45
|
+
const IS_INSTALLED_PACKAGE = !existsSync(path.join(PKG_ROOT, "scripts", "heyspex-doctor.mjs"));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Help lines for verbs that only work inside a HeySpex source checkout (dev
|
|
49
|
+
* server, Dev-Factory, seat registry, Companion build, the SpexCrawl dev path).
|
|
50
|
+
* `hs --help` from an installed package drops them and says so once.
|
|
51
|
+
*/
|
|
52
|
+
const DEV_ONLY_HELP_PREFIXES = [
|
|
53
|
+
" hs server ",
|
|
54
|
+
" hs factory ",
|
|
55
|
+
" hs seat ",
|
|
56
|
+
// Platform-operator verbs whose implementations are not in the npm package:
|
|
57
|
+
// test-customer minting (admin), the Neon fleet queue, SpexCode Doctrine
|
|
58
|
+
// synthesis over this checkout's spexharness/ tree.
|
|
59
|
+
" hs admin ",
|
|
60
|
+
" hs queue ",
|
|
61
|
+
" hs doctrine ",
|
|
62
|
+
// Verbs listed in the source-checkout help whose implementation the npm
|
|
63
|
+
// package does not carry (see NOT_IN_PACKAGE_VERBS below for each reason).
|
|
64
|
+
// Invoking one from an installed package prints a clear "not available in
|
|
65
|
+
// the npm package" line instead of "install is incomplete".
|
|
66
|
+
" hs agent ",
|
|
67
|
+
" hs chat ",
|
|
68
|
+
" hs code-watcher ",
|
|
69
|
+
" hs harness ",
|
|
70
|
+
" hs hooks ",
|
|
71
|
+
" hs import ",
|
|
72
|
+
" hs milestone ",
|
|
73
|
+
" hs model ",
|
|
74
|
+
" hs registry ",
|
|
75
|
+
" hs skills ",
|
|
76
|
+
" hs voice ",
|
|
77
|
+
" hs check judge ",
|
|
78
|
+
" hs bench ",
|
|
79
|
+
" node dev-factory/",
|
|
80
|
+
" search/scrape/crawl CLI",
|
|
81
|
+
" not an `hs` verb",
|
|
82
|
+
" Companion)",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
function helpForThisInstall(lines) {
|
|
86
|
+
if (!IS_INSTALLED_PACKAGE) return lines;
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
if (DEV_ONLY_HELP_PREFIXES.some((p) => line.startsWith(p))) continue;
|
|
90
|
+
if (line.startsWith(" hs companion start|stop|status|build")) {
|
|
91
|
+
out.push(" hs companion start|stop|status Desktop companion tray app (not in the npm package yet)");
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
out.push(line);
|
|
95
|
+
}
|
|
96
|
+
out.push(
|
|
97
|
+
"HeySpex developer-only verbs (hs server, hs factory, hs seat, hs admin, hs queue,",
|
|
98
|
+
"hs doctrine, hs companion build) need a HeySpex source checkout and are not",
|
|
99
|
+
"listed here. Neither are verbs whose implementation the npm package does not",
|
|
100
|
+
"carry (hs agent, hs chat, hs code-watcher, hs harness, hs hooks, hs import,",
|
|
101
|
+
"hs milestone, hs model, hs registry, hs skills, hs voice, hs check judge).",
|
|
102
|
+
"",
|
|
103
|
+
);
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
39
107
|
/**
|
|
40
108
|
* Every script path `hs` spawns directly by filesystem path (as opposed to
|
|
41
109
|
* `dispatchSpexConsoleEntry`'s own three-case tsx/bundle/missing resolution,
|
|
@@ -545,8 +613,23 @@ function extractGlobalVaultFlag(argv) {
|
|
|
545
613
|
}
|
|
546
614
|
|
|
547
615
|
function usage() {
|
|
548
|
-
process.stdout.write(
|
|
549
|
-
|
|
616
|
+
process.stdout.write(usageLines().join("\n"));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* `hs <namespace> --help` for a namespace whose own handler has no help text
|
|
621
|
+
* (its first token must be a sub-verb): print this install's `hs --help`
|
|
622
|
+
* lines for that namespace and exit 0, instead of "unknown verb --help".
|
|
623
|
+
*/
|
|
624
|
+
const HELP_FROM_USAGE_NAMESPACES = new Set(["index", "mcp", "wip", "worktree", "skills"]);
|
|
625
|
+
|
|
626
|
+
/** @param {string} namespace */
|
|
627
|
+
export function namespaceHelpLines(namespace, lines = usageLines()) {
|
|
628
|
+
return lines.filter((l) => l.startsWith(` hs ${namespace} `));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function usageLines() {
|
|
632
|
+
return helpForThisInstall([
|
|
550
633
|
"hs — HeySpex vault CLI",
|
|
551
634
|
"",
|
|
552
635
|
"Usage:",
|
|
@@ -573,7 +656,7 @@ function usage() {
|
|
|
573
656
|
" hs gateway validate [slug] [--json] Validate user-authored gateway skills (.heyspex/skills/user/)",
|
|
574
657
|
" hs mode on|off|status|toggle Master switch: HeySpex layer on or off (restart Claude Code)",
|
|
575
658
|
" hs dev [mode:local|mode:cloud|mode:prod] Dev/prod mode + local/cloud reliance (status when bare)",
|
|
576
|
-
" hs setup claude|cursor|copilot [--check|--uninstall] Install agent lifecycle hooks (copilot also: --print
|
|
659
|
+
" hs setup claude|cursor|copilot [--check|--uninstall] Install agent lifecycle hooks (claude, copilot also: --print; copilot: --hooks)",
|
|
577
660
|
" hs skills sync|check Propagate skills to Cursor/Codex/Claude (cwd-safe)",
|
|
578
661
|
" hs skills add <module-path> --name <n> --trigger </slash> [--export <n>] [--description <t>] Register a skill (extension seam)",
|
|
579
662
|
" hs skills remove <name> Remove a customer-registered skill",
|
|
@@ -674,8 +757,7 @@ function usage() {
|
|
|
674
757
|
"`hs install --register-vault` without --dry-run in that case refuses instead of",
|
|
675
758
|
"registering, asking for --target. --attach-vault (satellite mode) is exempt.",
|
|
676
759
|
"",
|
|
677
|
-
]
|
|
678
|
-
);
|
|
760
|
+
]);
|
|
679
761
|
}
|
|
680
762
|
|
|
681
763
|
/**
|
|
@@ -689,8 +771,11 @@ function usage() {
|
|
|
689
771
|
* 2. Published install of a BUNDLED verb — currently `hs-init.ts`,
|
|
690
772
|
* `hs-install.ts`, `hs-claim.ts`, `hs-check.ts`, `hs-env.ts`,
|
|
691
773
|
* `hs-connect.ts`, `hs-join.ts`, `hs-vault.ts`, `hs-workspace.ts`,
|
|
692
|
-
* `hs-defer.ts`, `hs-vault-pull.ts`, `hs-
|
|
693
|
-
* `hs-index-
|
|
774
|
+
* `hs-defer.ts`, `hs-vault-pull.ts`, `hs-vault-push.ts`, `hs-dev.ts`,
|
|
775
|
+
* `hs-index-rebuild.ts`, `hs-index-reconcile.ts`, `hs-handoff.ts`,
|
|
776
|
+
* `hs-pin.ts`, `hs-events.ts`, `hs-export-events.ts`, `hs-prereq.ts`,
|
|
777
|
+
* `hs-trash.ts`, `hs-trust.ts`, `hs-next-launch.ts`, `hs-revalidate.ts`,
|
|
778
|
+
* `hs-test.ts`, `hs-spawn.ts` — see `scripts/build-cli-bundle.mjs`'s
|
|
694
779
|
* `ENTRIES` array and doc comment for the authoritative list and why
|
|
695
780
|
* each qualifies: neither the source nor tsx exists, but
|
|
696
781
|
* `dist/cli/<name>.mjs` does — run the plain-JS bundle directly with
|
|
@@ -778,6 +863,10 @@ function dispatchSpexConsoleEntry(entryFile, args, vaultRoot, extraEnv = {}) {
|
|
|
778
863
|
* version.
|
|
779
864
|
*/
|
|
780
865
|
function runStandaloneDoctor(args, vaultRoot) {
|
|
866
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
867
|
+
process.stdout.write(`Usage:\n${namespaceHelpLines("doctor").join("\n")}\n`);
|
|
868
|
+
process.exit(0);
|
|
869
|
+
}
|
|
781
870
|
const jsonOut = args.includes("--json");
|
|
782
871
|
let version = "0.0.0";
|
|
783
872
|
try {
|
|
@@ -891,6 +980,45 @@ function runStandaloneDoctor(args, vaultRoot) {
|
|
|
891
980
|
printStandaloneDoctorReport(report, jsonOut);
|
|
892
981
|
}
|
|
893
982
|
|
|
983
|
+
/** Usage for `hs setup`; printed by --help/-h (exit 0) and on a bad target (exit 2). */
|
|
984
|
+
const SETUP_USAGE =
|
|
985
|
+
"Usage:\n" +
|
|
986
|
+
" hs setup claude [--check|--uninstall|--print] Claude Code lifecycle hooks (--print: setup steps; alias claude-code)\n" +
|
|
987
|
+
" hs setup cursor [--check|--uninstall] Cursor CLI lifecycle hooks\n" +
|
|
988
|
+
" hs setup copilot [--check|--uninstall|--print|--hooks|--statusline [--force]] GitHub Copilot (plugin; --hooks for the cloud agent; --statusline opt-in status line)\n" +
|
|
989
|
+
" hs setup <target> --help This help (never installs anything)\n";
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* `hs setup claude --print` (alias `claude-code`): the Claude Code steps for a
|
|
993
|
+
* repo that uses the heyspex npm package — the sibling of `hs setup copilot
|
|
994
|
+
* --print`. Text only; writes nothing. The one-source-of-hooks note is backed
|
|
995
|
+
* by the `yields-to-plugin` marker in the committed launcher
|
|
996
|
+
* (scripts/lib/customer-kit.mjs LAUNCHER_YIELD_MARKER).
|
|
997
|
+
*/
|
|
998
|
+
function claudeCodeSetupSteps() {
|
|
999
|
+
return [
|
|
1000
|
+
"# Using HeySpex in Claude Code",
|
|
1001
|
+
"",
|
|
1002
|
+
"1. Open this repo in Claude Code (run `claude` from the repo root).",
|
|
1003
|
+
"2. On first open Claude Code asks whether to use the project MCP server `heyspex`",
|
|
1004
|
+
" (declared in this repo's .mcp.json). Approve it. No .mcp.json yet? Run `npx hs install`.",
|
|
1005
|
+
" Not signed in on this machine? Run `npx hs login` first.",
|
|
1006
|
+
"3. Check: ask Claude to run `heyspex_preflight`, or run `npx hs doctor`.",
|
|
1007
|
+
"",
|
|
1008
|
+
"Optional: the SpexCode plugin (for Claude Code), from the copy inside node_modules:",
|
|
1009
|
+
" claude plugin marketplace add ./node_modules/heyspex",
|
|
1010
|
+
" claude plugin install heyspex@heyspex-marketplace",
|
|
1011
|
+
" (or in a session: /plugin marketplace add ./node_modules/heyspex, then",
|
|
1012
|
+
" /plugin install heyspex@heyspex-marketplace)",
|
|
1013
|
+
"",
|
|
1014
|
+
"One source of hooks: the plugin and this repo's .claude/heyspex/run-hook.mjs yield to",
|
|
1015
|
+
"each other. When the plugin is enabled on this machine the committed launcher exits",
|
|
1016
|
+
"silently and the plugin runs every hook, so each hook fires once. Set",
|
|
1017
|
+
"HEYSPEX_REPO_HOOKS=1 to keep the repo hooks as the source instead.",
|
|
1018
|
+
"",
|
|
1019
|
+
].join("\n");
|
|
1020
|
+
}
|
|
1021
|
+
|
|
894
1022
|
function printStandaloneDoctorReport(report, jsonOut) {
|
|
895
1023
|
if (jsonOut) {
|
|
896
1024
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
@@ -913,14 +1041,86 @@ function printStandaloneDoctorReport(report, jsonOut) {
|
|
|
913
1041
|
if (check.human) process.stdout.write(` ${check.human}\n`);
|
|
914
1042
|
}
|
|
915
1043
|
process.stdout.write(
|
|
916
|
-
"\nNote: the
|
|
917
|
-
"
|
|
918
|
-
"
|
|
919
|
-
"applies to a published install.\n",
|
|
1044
|
+
"\nNote: this is the setup check for a published install. The full platform\n" +
|
|
1045
|
+
"doctor (git hooks, console secrets and other source-checkout checks) is for\n" +
|
|
1046
|
+
"HeySpex developers only and is not part of the npm package.\n",
|
|
920
1047
|
);
|
|
921
1048
|
process.exit(report.ok ? 0 : 1);
|
|
922
1049
|
}
|
|
923
1050
|
|
|
1051
|
+
/** The tray-host subcommands of `hs companion` (bare `hs companion` means start). */
|
|
1052
|
+
const COMPANION_HOST_COMMANDS = new Set(["start", "stop", "status", "build"]);
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* The `hs companion` host subcommand to answer with "not distributed through
|
|
1056
|
+
* the npm package yet", or null. Non-null only for start|stop|status|build (or
|
|
1057
|
+
* a bare `hs companion`) when `pkgRoot` has no companion/ source -- an
|
|
1058
|
+
* installed `heyspex` package. Pure apart from the injectable `exists`.
|
|
1059
|
+
* @param {string | undefined} namespace
|
|
1060
|
+
* @param {string | undefined} command
|
|
1061
|
+
* @param {{ pkgRoot?: string, exists?: (p: string) => boolean }} [opts]
|
|
1062
|
+
* @returns {string | null}
|
|
1063
|
+
*/
|
|
1064
|
+
export function companionHostWithoutSource(namespace, command, { pkgRoot = PKG_ROOT, exists = existsSync } = {}) {
|
|
1065
|
+
if (namespace !== "companion") return null;
|
|
1066
|
+
const sub = command === undefined ? "start" : command;
|
|
1067
|
+
if (!COMPANION_HOST_COMMANDS.has(sub)) return null;
|
|
1068
|
+
return exists(path.join(pkgRoot, "companion")) ? null : sub;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Verbs (`"<namespace>"` or `"<namespace> <sub>"`) a source checkout runs but
|
|
1073
|
+
* the npm package deliberately does not carry, each with the reason a
|
|
1074
|
+
* customer sees. `hs --help` from an installed package hides their lines
|
|
1075
|
+
* (DEV_ONLY_HELP_PREFIXES); invoking one exits 1 with
|
|
1076
|
+
* {@link notInPackageMessage} instead of dispatchSpexConsoleEntry's
|
|
1077
|
+
* "install is incomplete". A verb that IS bundled (scripts/build-cli-bundle.mjs
|
|
1078
|
+
* ENTRIES) never belongs here.
|
|
1079
|
+
*/
|
|
1080
|
+
export const NOT_IN_PACKAGE_VERBS = Object.freeze({
|
|
1081
|
+
admin: "platform-operator test-customer minting.",
|
|
1082
|
+
queue: "the SpexFleet build queue runs only on the HeySpex platform.",
|
|
1083
|
+
seat: "SpexFleet seat registry, platform build system only.",
|
|
1084
|
+
agent: "the vendored agent collections it imports from are not shipped.",
|
|
1085
|
+
chat: "the SpexCode chat front needs the SpexCode engine, which is not in the npm package.",
|
|
1086
|
+
"code-watcher": "it needs the HeySpex source tree and its TypeScript toolchain.",
|
|
1087
|
+
harness: "SpexCode gate fixtures live in the SpexCode engine, which is not in the npm package.",
|
|
1088
|
+
hooks: "the SpexCode extension seam is read by the SpexCode engine, which is not in the npm package.",
|
|
1089
|
+
import: "it uses the SpexFleet model router, which is not in the npm package.",
|
|
1090
|
+
milestone: "use the console or the heyspex_* MCP tools to read a milestone.",
|
|
1091
|
+
model: "the SpexFleet seat routing table is not in the npm package.",
|
|
1092
|
+
registry: "the launch registry is managed from the console.",
|
|
1093
|
+
skills: "skill sync and the SpexCode skill registry need a HeySpex source checkout.",
|
|
1094
|
+
voice: "voice profiles feed the SpexVoice Companion, which is not in the npm package yet.",
|
|
1095
|
+
"check judge": "the decorrelated judge seat runs on the HeySpex platform.",
|
|
1096
|
+
factory: "SpexFleet/dev-factory internals are platform-only.",
|
|
1097
|
+
doctrine: "SpexCode Doctrine synthesis internals are platform-only.",
|
|
1098
|
+
bench: "SpexCode bench internals are platform-only.",
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* The NOT_IN_PACKAGE_VERBS key this invocation hits from an installed
|
|
1103
|
+
* package, or null. Pure apart from `installed`.
|
|
1104
|
+
* @param {string | undefined} namespace
|
|
1105
|
+
* @param {string | undefined} command
|
|
1106
|
+
* @param {{ installed?: boolean }} [opts]
|
|
1107
|
+
* @returns {string | null}
|
|
1108
|
+
*/
|
|
1109
|
+
export function notInPackageVerb(namespace, command, { installed = IS_INSTALLED_PACKAGE } = {}) {
|
|
1110
|
+
if (!installed || !namespace) return null;
|
|
1111
|
+
const two = command ? `${namespace} ${command}` : null;
|
|
1112
|
+
if (two && Object.hasOwn(NOT_IN_PACKAGE_VERBS, two)) return two;
|
|
1113
|
+
return Object.hasOwn(NOT_IN_PACKAGE_VERBS, namespace) ? namespace : null;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** @param {string} key a NOT_IN_PACKAGE_VERBS key */
|
|
1117
|
+
export function notInPackageMessage(key) {
|
|
1118
|
+
return (
|
|
1119
|
+
`hs ${key}: not available in the npm package — ${NOT_IN_PACKAGE_VERBS[key]}\n` +
|
|
1120
|
+
` It runs only inside a HeySpex source checkout. \`hs --help\` lists every verb this install can run.\n`
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
924
1124
|
function main(argv) {
|
|
925
1125
|
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
926
1126
|
usage();
|
|
@@ -952,6 +1152,43 @@ function main(argv) {
|
|
|
952
1152
|
const command = argv[1];
|
|
953
1153
|
const rest = argv.slice(2);
|
|
954
1154
|
|
|
1155
|
+
// A source-checkout-only verb from an installed package: say so before vault
|
|
1156
|
+
// resolution can refuse with an unrelated binding error.
|
|
1157
|
+
const missingVerb = notInPackageVerb(namespace, command);
|
|
1158
|
+
if (missingVerb) {
|
|
1159
|
+
process.stderr.write(notInPackageMessage(missingVerb));
|
|
1160
|
+
process.exit(1);
|
|
1161
|
+
}
|
|
1162
|
+
if (
|
|
1163
|
+
HELP_FROM_USAGE_NAMESPACES.has(namespace) &&
|
|
1164
|
+
(command === "--help" || command === "-h")
|
|
1165
|
+
) {
|
|
1166
|
+
const lines = namespaceHelpLines(namespace);
|
|
1167
|
+
if (lines.length) {
|
|
1168
|
+
process.stdout.write(`Usage:\n${lines.join("\n")}\n`);
|
|
1169
|
+
process.exit(0);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// `hs companion start|stop|status|build` from an installed package: the tray
|
|
1174
|
+
// app's source (companion/) is not in the tarball, so say THAT first -- before
|
|
1175
|
+
// vault resolution can refuse with "not bound to a vault", which sends a
|
|
1176
|
+
// customer to fix a binding that would not help.
|
|
1177
|
+
const companionSub = companionHostWithoutSource(namespace, command);
|
|
1178
|
+
if (companionSub) {
|
|
1179
|
+
import("../scripts/lib/hs-companion-settings.mjs").then(
|
|
1180
|
+
(m) => {
|
|
1181
|
+
process.stderr.write(`${m.renderCompanionNotDistributed(companionSub)}\n`);
|
|
1182
|
+
process.exit(1);
|
|
1183
|
+
},
|
|
1184
|
+
(err) => {
|
|
1185
|
+
process.stderr.write(`hs companion: ${err.message}\n`);
|
|
1186
|
+
process.exit(1);
|
|
1187
|
+
},
|
|
1188
|
+
);
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
955
1192
|
const {
|
|
956
1193
|
vaultRoot,
|
|
957
1194
|
usedRegistryFallback,
|
|
@@ -1066,7 +1303,18 @@ function main(argv) {
|
|
|
1066
1303
|
|
|
1067
1304
|
// `hs setup claude|cursor` — install agent lifecycle hooks into the vault root.
|
|
1068
1305
|
if (namespace === "setup") {
|
|
1069
|
-
const agent = command?.toLowerCase();
|
|
1306
|
+
const agent = command?.toLowerCase() === "claude-code" ? "claude" : command?.toLowerCase();
|
|
1307
|
+
// --help/-h anywhere (`hs setup --help`, `hs setup copilot --help`) prints usage and
|
|
1308
|
+
// exits 0 BEFORE any installer is spawned: the installers ignore unknown flags, so a
|
|
1309
|
+
// forwarded --help used to run the real install (Copilot: into ~/.copilot).
|
|
1310
|
+
if ([command, ...rest].some((a) => a === "--help" || a === "-h")) {
|
|
1311
|
+
process.stdout.write(SETUP_USAGE);
|
|
1312
|
+
process.exit(0);
|
|
1313
|
+
}
|
|
1314
|
+
if (agent === "claude" && rest.includes("--print")) {
|
|
1315
|
+
process.stdout.write(claudeCodeSetupSteps());
|
|
1316
|
+
process.exit(0);
|
|
1317
|
+
}
|
|
1070
1318
|
const SPAWN_KEYS = {
|
|
1071
1319
|
claude: "setup:claude",
|
|
1072
1320
|
cursor: "setup:cursor",
|
|
@@ -1074,10 +1322,7 @@ function main(argv) {
|
|
|
1074
1322
|
};
|
|
1075
1323
|
if (!agent || !Object.hasOwn(SPAWN_KEYS, agent)) {
|
|
1076
1324
|
process.stderr.write(
|
|
1077
|
-
`hs setup: expected \`claude\`, \`cursor\` or \`copilot\` (got \`${command ?? ""}\`)\n` +
|
|
1078
|
-
" hs setup claude [--check|--uninstall] Claude Code lifecycle hooks\n" +
|
|
1079
|
-
" hs setup cursor [--check|--uninstall] Cursor CLI lifecycle hooks\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",
|
|
1325
|
+
`hs setup: expected \`claude\`, \`cursor\` or \`copilot\` (got \`${command ?? ""}\`)\n` + SETUP_USAGE,
|
|
1081
1326
|
);
|
|
1082
1327
|
process.exit(2);
|
|
1083
1328
|
}
|
|
@@ -1095,8 +1340,10 @@ function main(argv) {
|
|
|
1095
1340
|
}
|
|
1096
1341
|
}
|
|
1097
1342
|
const script = spawnTargetPath(SPAWN_KEYS[agent]);
|
|
1098
|
-
//
|
|
1099
|
-
|
|
1343
|
+
// Every agent sets up the PROJECT you are standing in (nearest .git /
|
|
1344
|
+
// heyspex.yaml at or above cwd) -- never the (possibly sibling) vault
|
|
1345
|
+
// root, and never this package's own folder under node_modules/.
|
|
1346
|
+
const target = findProjectRoot(INVOKED_CWD);
|
|
1100
1347
|
const child = spawn(process.execPath, [script, ...setupFlags], {
|
|
1101
1348
|
cwd: target,
|
|
1102
1349
|
stdio: "inherit",
|
|
@@ -1315,6 +1562,22 @@ function main(argv) {
|
|
|
1315
1562
|
return;
|
|
1316
1563
|
}
|
|
1317
1564
|
|
|
1565
|
+
// `hs trust list` / `hs trust revoke [path]` — standalone management of the machine-local
|
|
1566
|
+
// folder-trust store `hs chat`'s first-run prompt writes to
|
|
1567
|
+
// ([[HS-25AJ-8CMW-TASK-folder-trust-revoke-through-the]], deferred from
|
|
1568
|
+
// [[HS-ZBEB-8CMW-TASK-folder-trust-for-spexcode-first-run]], which shipped only `hs chat
|
|
1569
|
+
// --revoke-trust`). Thin CLI verb — same shell shape as `chat`/`hooks`/`bench` below; the real
|
|
1570
|
+
// logic lives in `spex-console/src/lib/cli/trust-cli.ts` over `folder-trust.ts`'s pure
|
|
1571
|
+
// functions. Forwards the full argv; the entrypoint drops the leading `trust` token.
|
|
1572
|
+
// Machine-local, never acts on a vault, so it runs ABOVE the hard vault gate
|
|
1573
|
+
// (it is in POINTER_FALLBACK_NAMESPACES): since HS-TYJA (a2bbb0698) dropped the
|
|
1574
|
+
// registry-pointer fallback, `hs trust revoke` from an unbound dir hit
|
|
1575
|
+
// VAULT_UNBOUND here.
|
|
1576
|
+
if (namespace === "trust") {
|
|
1577
|
+
dispatchSpexConsoleEntry("hs-trust.ts", argv, vaultRoot || process.cwd());
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1318
1581
|
if (!vaultRoot) {
|
|
1319
1582
|
process.stderr.write(noVaultMessage(vaultError));
|
|
1320
1583
|
process.exit(1);
|
|
@@ -1936,18 +2199,6 @@ function main(argv) {
|
|
|
1936
2199
|
return;
|
|
1937
2200
|
}
|
|
1938
2201
|
|
|
1939
|
-
// `hs trust list` / `hs trust revoke [path]` — standalone management of the machine-local
|
|
1940
|
-
// folder-trust store `hs chat`'s first-run prompt writes to
|
|
1941
|
-
// ([[HS-25AJ-8CMW-TASK-folder-trust-revoke-through-the]], deferred from
|
|
1942
|
-
// [[HS-ZBEB-8CMW-TASK-folder-trust-for-spexcode-first-run]], which shipped only `hs chat
|
|
1943
|
-
// --revoke-trust`). Thin CLI verb — same shell shape as `chat`/`hooks`/`bench` above; the real
|
|
1944
|
-
// logic lives in `spex-console/src/lib/cli/trust-cli.ts` over `folder-trust.ts`'s pure
|
|
1945
|
-
// functions. Forwards the full argv; the entrypoint drops the leading `trust` token.
|
|
1946
|
-
if (namespace === "trust") {
|
|
1947
|
-
dispatchSpexConsoleEntry("hs-trust.ts", argv, vaultRoot || process.cwd());
|
|
1948
|
-
return;
|
|
1949
|
-
}
|
|
1950
|
-
|
|
1951
2202
|
// `hs doctrine extract|learn|show` — SpexCode Doctrine corpus reader + synthesis
|
|
1952
2203
|
// ([[HS-TWTC-8CMW-TASK-dr1-doctrine-corpus-reader-stream-every]],
|
|
1953
2204
|
// [[HS-YMPY-8CMW-TASK-dr2-doctrine-synthesis-a-routed]] of epic
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dispatch-guard.mjs — pre-dispatch checks the controller runs BEFORE its first builder
|
|
4
|
+
* call, so a dispatch that can never pass its own gate is refused up front with the fix
|
|
5
|
+
* named (exit 3, gate_environment_fault) instead of burning iterations on it.
|
|
6
|
+
*
|
|
7
|
+
* Born 2026-08-26 (HS-VHQG): the frozen check ran a test file that did not exist, the
|
|
8
|
+
* single-target builder only writes its target, and the controller spent 3 iterations x
|
|
9
|
+
* (best-of-3 + self-repair) + 3 DeepSeek diagnoses before escalating "no progress:
|
|
10
|
+
* identical failure signature x3" — a harness fault reported as a model fault.
|
|
11
|
+
*
|
|
12
|
+
* node dev-factory/lib/dispatch-guard.mjs --self-test
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Normalize a repo path for loose comparison (posix separators, no leading ./). */
|
|
16
|
+
function normRel(p) {
|
|
17
|
+
return String(p ?? "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True when the builder's --target set (comma-separated) can WRITE the scoped test file —
|
|
21
|
+
* i.e. the test is itself a target. The builder only ever writes its targets. */
|
|
22
|
+
export function buildCanCreateTest(targetArg, testArg) {
|
|
23
|
+
const test = normRel(testArg);
|
|
24
|
+
if (!test) return true; // no scoped test → nothing to guard
|
|
25
|
+
const targets = String(targetArg ?? "").split(",").map((s) => normRel(s.trim())).filter(Boolean);
|
|
26
|
+
return targets.some((t) => t === test || t.endsWith(`/${test}`) || test.endsWith(`/${t}`));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Plain-language abort reason for the pre-dispatch guard (rides the exit-3 path). */
|
|
30
|
+
export function impossibleTestReason(targetArg, testArg) {
|
|
31
|
+
return `the scoped test does not exist yet and this build cannot create it — the frozen check runs ${testArg}, `
|
|
32
|
+
+ `but this controller builds ONE target (--target ${targetArg}) and the builder only writes its target. `
|
|
33
|
+
+ `No candidate can ever turn this gate green, so no builder was called and no iteration was spent. `
|
|
34
|
+
+ `This task needs two files (the implementation + a new test), which is the routing table's "multi-file implementation" shape: `
|
|
35
|
+
+ `route it to the multi-file seat (a harness sub-agent with an explicit cheap model — never the single-target fleet build), `
|
|
36
|
+
+ `or split the spec so the test file lands as its own greenfield task first (--target ${testArg}) and this task's gate can then find it.`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** vitest's "No test files found" banner (it prints its include/exclude globs after it) —
|
|
40
|
+
* a gate that found nothing to run, never a red test. */
|
|
41
|
+
export function isNoTestFilesOutput(output) {
|
|
42
|
+
const s = String(output ?? "");
|
|
43
|
+
if (/No test files found/i.test(s)) return true;
|
|
44
|
+
// Older vitest prints only the include/exclude listing; treat it as no-test-files ONLY when
|
|
45
|
+
// nothing in the output looks like a test result.
|
|
46
|
+
// No \b here: vitest wraps the labels in dim ANSI codes ("[2minclude:") that glue to the word.
|
|
47
|
+
return /include:\s/.test(s) && /exclude:\s/.test(s)
|
|
48
|
+
&& !/\b(passed|failed)\b.*\btests?\b/i.test(s) && !/✓|✗|×|FAIL\s+src/.test(s);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── self-test ────────────────────────────────────────────────────────────────────────
|
|
52
|
+
function runSelfTest() {
|
|
53
|
+
let ok = true;
|
|
54
|
+
const check = (name, cond) => { console.log(` ${cond ? "✓" : "✗"} ${name}`); if (!cond) ok = false; };
|
|
55
|
+
console.log("== dispatch-guard.mjs self-test ==\n");
|
|
56
|
+
check("impl target + new test (HS-VHQG shape) → cannot create", !buildCanCreateTest("spex-console/src/lib/cli/init.ts", "spex-console/src/lib/cli/init.customer-vault.test.ts"));
|
|
57
|
+
check("test IS the target (greenfield test task) → can create", buildCanCreateTest("spex-console/src/w.test.ts", "spex-console/src/w.test.ts"));
|
|
58
|
+
check("comma target set containing the test → can create", buildCanCreateTest("a/x.ts,a/x.test.ts", "a/x.test.ts"));
|
|
59
|
+
check("runner-relative test vs repo-relative target still matches", buildCanCreateTest("spex-console/src/w.test.ts", "src/w.test.ts"));
|
|
60
|
+
check("backslash paths normalize", buildCanCreateTest("spex-console\\src\\w.test.ts", "spex-console/src/w.test.ts"));
|
|
61
|
+
check("no scoped test → nothing to guard", buildCanCreateTest("a.ts", ""));
|
|
62
|
+
const reason = impossibleTestReason("a.ts", "a.test.ts");
|
|
63
|
+
check("reason names both files, the multi-file seat, and the greenfield split", /a\.test\.ts/.test(reason) && /--target a\.ts/.test(reason) && /multi-file seat/.test(reason) && /greenfield/.test(reason));
|
|
64
|
+
check("vitest no-test-files banner → environmental", isNoTestFilesOutput("No test files found, exiting with code 1\ninclude: src/**/*.test.ts\nexclude: **/node_modules/**"));
|
|
65
|
+
check("include/exclude listing with no results → environmental", isNoTestFilesOutput("[2minclude: [22msrc/**/*.test.ts[2m\nexclude: **/node_modules/**"));
|
|
66
|
+
check("a real failing test run is NOT environmental", !isNoTestFilesOutput("FAIL src/lib/x.test.ts > does thing\nTests 1 failed | 2 passed (3)\ninclude: src/**/*.test.ts exclude: **/node_modules/**"));
|
|
67
|
+
check("a passing run is NOT environmental", !isNoTestFilesOutput("✓ src/x.test.ts (3 tests)\nTests 3 passed (3)"));
|
|
68
|
+
check("empty output is NOT environmental", !isNoTestFilesOutput(""));
|
|
69
|
+
console.log(ok ? "\n✓ dispatch-guard self-test passed" : "\n✗ dispatch-guard self-test FAILED");
|
|
70
|
+
return ok ? 0 : 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (process.argv.includes("--self-test") && process.argv[1] && /dispatch-guard\.mjs$/.test(process.argv[1].replace(/\\/g, "/"))) {
|
|
74
|
+
process.exit(runSelfTest());
|
|
75
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runner-path.mjs — make a scoped test/target path relative to the cwd its runner
|
|
3
|
+
* actually executes in.
|
|
4
|
+
*
|
|
5
|
+
* Born 2026-10-15 (HS-7BYJ baseline): the controller's red-baseline pre-check ran scoped
|
|
6
|
+
* vitest from `<worktree>/spex-console` (where the vitest config lives) but handed it the
|
|
7
|
+
* REPO-relative test path `spex-console/src/lib/x.test.ts`. vitest's file filter then
|
|
8
|
+
* matched nothing ("No test files found, exiting with code 1") and the run was classified
|
|
9
|
+
* as gate_environment_fault before any builder ran. One helper, used at every call site
|
|
10
|
+
* that runs a tool from a package subdirectory, instead of patching one site.
|
|
11
|
+
*/
|
|
12
|
+
import { isAbsolute, relative } from "node:path";
|
|
13
|
+
|
|
14
|
+
const toPosix = (p) => String(p ?? "").replace(/\\/g, "/");
|
|
15
|
+
const stripDot = (p) => p.replace(/^(\.\/)+/, "");
|
|
16
|
+
const caseFold = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
|
|
17
|
+
|
|
18
|
+
/** `child` is `base` or under it (posix strings, case-insensitive on Windows). */
|
|
19
|
+
function isUnder(child, base) {
|
|
20
|
+
const c = caseFold(child), b = caseFold(base).replace(/\/+$/, "");
|
|
21
|
+
return c === b || c.startsWith(`${b}/`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Convert `path` to a path relative to the runner's cwd.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} path repo-relative ("spex-console/src/x.test.ts"), already
|
|
28
|
+
* cwd-relative ("src/x.test.ts"), absolute, or backslashed.
|
|
29
|
+
* @param {object} [opts]
|
|
30
|
+
* @param {string} [opts.cwdRel=""] the runner's cwd, relative to the repo/worktree root
|
|
31
|
+
* ("spex-console", "companion", "vendor/heyspex-mcp"; "" = root).
|
|
32
|
+
* @param {string[]} [opts.roots=[]] absolute roots an absolute `path` may live under (the
|
|
33
|
+
* gate worktree, the main repo) — it is re-rooted against the first
|
|
34
|
+
* match. An absolute path under none of them is returned posix-ified.
|
|
35
|
+
* @returns {string} posix path relative to `cwdRel`. A repo-relative path OUTSIDE `cwdRel`
|
|
36
|
+
* (e.g. vendor/… while the runner sits in spex-console) is returned
|
|
37
|
+
* unchanged — it is either already cwd-relative or not this runner's file.
|
|
38
|
+
*/
|
|
39
|
+
export function toRunnerRelativePath(path, { cwdRel = "", roots = [] } = {}) {
|
|
40
|
+
let p = stripDot(toPosix(path).trim());
|
|
41
|
+
if (!p) return p;
|
|
42
|
+
const cwd = stripDot(toPosix(cwdRel)).replace(/^\/+|\/+$/g, "");
|
|
43
|
+
if (isAbsolute(p) || /^[A-Za-z]:\//.test(p)) {
|
|
44
|
+
const root = roots.map((r) => toPosix(r).replace(/\/+$/, "")).find((r) => r && isUnder(p, r));
|
|
45
|
+
if (!root) return p;
|
|
46
|
+
p = p.slice(root.length).replace(/^\/+/, "");
|
|
47
|
+
}
|
|
48
|
+
if (!cwd) return p;
|
|
49
|
+
if (isUnder(p, cwd)) return toPosix(relative(cwd, p)) || ".";
|
|
50
|
+
return p;
|
|
51
|
+
}
|