heyspex 0.2.0 → 0.2.1

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 (145) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/bin/hs.mjs +163 -11
  3. package/dist/cli/hs-check.mjs +88 -46
  4. package/dist/cli/hs-claim.mjs +84 -39
  5. package/dist/cli/hs-connect.mjs +583 -129
  6. package/dist/cli/hs-defer.mjs +251 -30
  7. package/dist/cli/hs-dev.mjs +20 -8
  8. package/dist/cli/hs-env.mjs +62 -34
  9. package/dist/cli/hs-index-rebuild.mjs +288 -83
  10. package/dist/cli/hs-index-reconcile.mjs +397 -111
  11. package/dist/cli/hs-init.mjs +2143 -91
  12. package/dist/cli/hs-install.mjs +628 -106
  13. package/dist/cli/hs-join.mjs +686 -236
  14. package/dist/cli/hs-vault-pull.mjs +3189 -179
  15. package/dist/cli/hs-vault-push.mjs +6433 -0
  16. package/dist/cli/hs-vault.mjs +396 -178
  17. package/dist/cli/hs-workspace.mjs +289 -151
  18. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  19. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  20. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  21. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
  22. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  23. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  24. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  25. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  26. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  27. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
  28. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
  29. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  30. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  31. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  32. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  33. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  34. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
  35. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  36. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  37. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  38. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  39. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  40. package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
  41. package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
  42. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  43. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  44. package/docs/copilot-plugin-install.md +162 -0
  45. package/docs/driving-heyspex-from-copilot.md +165 -0
  46. package/package.json +20 -2
  47. package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
  48. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
  49. package/plugins/heyspex/README.md +309 -0
  50. package/plugins/heyspex/bin/run-hook.mjs +20 -2
  51. package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
  52. package/plugins/heyspex/hooks/hooks.json +94 -37
  53. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
  54. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
  55. package/scripts/claude-hooks/_run.mjs +316 -23
  56. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  57. package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
  58. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  59. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  60. package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
  61. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
  62. package/scripts/claude-hooks/session-end.mjs +2 -0
  63. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  64. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  65. package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
  66. package/scripts/cursor-hooks/manifest.mjs +46 -0
  67. package/scripts/heyspex-mode.mjs +20 -0
  68. package/scripts/hs-mode.mjs +1 -1
  69. package/scripts/hs-view.mjs +180 -17
  70. package/scripts/install-claude-code-hooks.mjs +3 -1
  71. package/scripts/lib/console-url-resolver.mjs +3 -2
  72. package/scripts/lib/customer-kit.mjs +327 -10
  73. package/scripts/lib/dev-mode.mjs +7 -6
  74. package/scripts/lib/directive-obligations.mjs +349 -0
  75. package/scripts/lib/hook-dispositions.mjs +210 -0
  76. package/scripts/lib/hook-vault-header.mjs +30 -0
  77. package/scripts/lib/hs-source-branch.mjs +196 -0
  78. package/scripts/lib/hs-source-log.mjs +2 -1
  79. package/scripts/lib/hs-source-preflight.mjs +18 -4
  80. package/scripts/lib/hs-source-push.mjs +386 -356
  81. package/scripts/lib/window-placement.mjs +109 -0
  82. package/spexharness/src/adapters/claude-code.mjs +11 -3
  83. package/spexharness/src/adapters/copilot.mjs +236 -0
  84. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  85. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  86. package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
  87. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  88. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  89. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  90. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  91. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  92. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  93. package/spexharness/src/intent-classifier.mjs +4 -2
  94. package/spexharness/src/intent-directive.mjs +626 -541
  95. package/spexharness/src/rule-table.mjs +31 -1
  96. package/vendor/heyspex-mcp/account-switch.mjs +6 -5
  97. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
  98. package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
  99. package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
  100. package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
  101. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  102. package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
  103. package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
  104. package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
  105. package/vendor/heyspex-mcp/cli.mjs +7 -1
  106. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  107. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
  108. package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
  109. package/vendor/heyspex-mcp/console-transport.mjs +7 -1
  110. package/vendor/heyspex-mcp/credentials.mjs +9 -8
  111. package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
  112. package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
  113. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
  114. package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
  115. package/vendor/heyspex-mcp/http-transport.mjs +2 -0
  116. package/vendor/heyspex-mcp/list-tools.mjs +1 -1
  117. package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
  118. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  119. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  120. package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
  121. package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
  122. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  123. package/vendor/heyspex-mcp/plan-push.mjs +3 -2
  124. package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
  125. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
  126. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
  127. package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
  128. package/vendor/heyspex-mcp/promote-research.mjs +6 -0
  129. package/vendor/heyspex-mcp/read-path.mjs +162 -6
  130. package/vendor/heyspex-mcp/request-context.mjs +25 -1
  131. package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
  132. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  133. package/vendor/heyspex-mcp/session-owner.mjs +3 -2
  134. package/vendor/heyspex-mcp/session-tools.mjs +10 -0
  135. package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
  136. package/vendor/heyspex-mcp/split-research.mjs +3 -0
  137. package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
  138. package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
  139. package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
  140. package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
  141. package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
  142. package/vendor/heyspex-mcp/vault-io.mjs +720 -724
  143. package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
  144. package/vendor/heyspex-mcp/whoami.mjs +2 -1
  145. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "heyspex-marketplace",
3
+ "owner": {
4
+ "name": "pwher",
5
+ "email": "paul@monstergrowth.com"
6
+ },
7
+ "plugins": [
8
+ {
9
+ "name": "heyspex",
10
+ "source": "./plugins/heyspex",
11
+ "description": "SpexCode plugin (for Claude Code and GitHub Copilot CLI): HeySpex vault integration, MCP server (spec vault tools + hs-* prompts) and lifecycle hooks. Prototype — see plugins/heyspex/README.md before enabling."
12
+ }
13
+ ]
14
+ }
package/bin/hs.mjs CHANGED
@@ -25,7 +25,9 @@ import {
25
25
  vaultConfigCheckRow,
26
26
  } from "../scripts/lib/standalone-doctor.mjs";
27
27
  import {
28
+ findBrokenSatellitePin,
28
29
  isVaultRoot,
30
+ readMarkerSatellite,
29
31
  readSatellitePin,
30
32
  resolveActiveVault,
31
33
  } from "../vendor/heyspex-mcp/vault-resolve.mjs";
@@ -85,6 +87,7 @@ export const SPAWN_TARGETS = {
85
87
  "source:preflight": { path: "scripts/lib/hs-source-preflight.mjs" },
86
88
  "source:commit": { path: "scripts/lib/hs-source-commit.mjs" },
87
89
  "source:push": { path: "scripts/lib/hs-source-push.mjs" },
90
+ "source:branch": { path: "scripts/lib/hs-source-branch.mjs" },
88
91
  "source:log": { path: "scripts/lib/hs-source-log.mjs" },
89
92
  // `hs source identity` — the ONE sanctioned git-identity writer (human-only
90
93
  // set/clear, read-only show). Incident 2026-09-19, vault lesson
@@ -175,6 +178,8 @@ export const BUILTIN_VERBS = [
175
178
  "doctor",
176
179
  "index",
177
180
  "pull",
181
+ "push",
182
+ "import",
178
183
  "milestone",
179
184
  "factory",
180
185
  "code-watcher",
@@ -286,6 +291,13 @@ export function findVaultRoot(start, stopAt) {
286
291
  if (pinned) {
287
292
  return pinned;
288
293
  }
294
+ // A committed, portable satellite marker (heyspex.yaml with a relative
295
+ // `vault_path`): the attached vault's clone when it is on this machine,
296
+ // else this repo itself (cloud-only; the marker's vault_id binds it).
297
+ const marked = readMarkerSatellite(dir);
298
+ if (marked) {
299
+ return marked.vaultRootAbs ?? dir;
300
+ }
289
301
  if (isVaultRoot(dir)) {
290
302
  return dir;
291
303
  }
@@ -331,6 +343,72 @@ export function findRegistryVaultRoot(options = {}) {
331
343
  return resolved.repoRootAbs;
332
344
  }
333
345
 
346
+ /** Env hatch: follow the account-wide active-vault pointer on purpose (same name the MCP transport uses). */
347
+ export function pointerVaultAllowed(env = process.env) {
348
+ const v = String(env.HEYSPEX_ALLOW_POINTER_VAULT ?? "").trim().toLowerCase();
349
+ return v === "1" || v === "true" || v === "yes";
350
+ }
351
+
352
+ /**
353
+ * Verbs that may legitimately run with only the machine's last-active vault
354
+ * (or none): they onboard, sign in, or manage the registry itself. EVERY other
355
+ * verb acts on a vault, and P1 (owner, 2026-10-16) says that vault is the one
356
+ * bound to the repo the agent runs in -- never the account-wide pointer.
357
+ */
358
+ const POINTER_FALLBACK_NAMESPACES = new Set([
359
+ "init", "install", "join", "setup", "skills", "vault", "workspace",
360
+ "login", "whoami", "logout", "doctor", "registry", "companion", "trust",
361
+ ]);
362
+
363
+ /** True when `hs <namespace> <command>` may run on the pointer-only fallback. */
364
+ export function pointerFallbackAllowedFor(namespace, command) {
365
+ if (namespace === "mcp") return command === "pair" || command === "login" || command === "install";
366
+ return POINTER_FALLBACK_NAMESPACES.has(namespace);
367
+ }
368
+
369
+ /** The refusal printed when a satellite's pin / marker cannot be honoured. */
370
+ export function brokenPinRefusalLine(broken) {
371
+ return (
372
+ `hs: this repo's vault pin cannot be honoured, so nothing was run. ${broken.dir}/${broken.source} ` +
373
+ `names \`${broken.raw}\`, but ${broken.reason}. ` +
374
+ "Fix the pin (a relative path such as ../<vault-repo> or ${CLAUDE_PROJECT_DIR:-.}/../<vault-repo> is portable), " +
375
+ "or commit a heyspex.yaml with vault_id and vault_path; hs will NOT fall back to the account-wide active vault.\n"
376
+ );
377
+ }
378
+
379
+ /** Nearest directory at/above `start` whose heyspex.yaml is a satellite marker (has vault_path), else null. */
380
+ export function markerSatelliteDirFrom(start) {
381
+ let dir = path.resolve(start);
382
+ for (let i = 0; i < 256; i += 1) {
383
+ if (readMarkerSatellite(dir)) return dir;
384
+ const parent = path.dirname(dir);
385
+ if (parent === dir) return null;
386
+ dir = parent;
387
+ }
388
+ return null;
389
+ }
390
+
391
+ /** The refusal for disk-writing verbs in a satellite whose vault clone is missing. */
392
+ export function unclonedVaultRefusalLine(repo) {
393
+ const m = readMarkerSatellite(repo);
394
+ return (
395
+ `hs: ${repo} is bound to vault ${m?.vaultId ?? "(no vault_id)"} by its heyspex.yaml, but the vault ` +
396
+ `is not cloned at ${m?.vaultPath ?? "its vault_path"} on this machine, so this verb has no local vault to act on. ` +
397
+ "Clone the vault repo to that relative path (agents and cloud-routed MCP writes work without it).\n"
398
+ );
399
+ }
400
+
401
+ /** The refusal printed when only the account-wide pointer answered and the verb acts on a vault. */
402
+ export function pointerOnlyRefusalLine(cwd, vaultRoot) {
403
+ return (
404
+ `hs: ${cwd} is not bound to a HeySpex vault (no heyspex.yaml, no specs/, no vault pin in .mcp.json), ` +
405
+ `so \`hs\` refused to follow the account-wide active vault${vaultRoot ? ` (${vaultRoot})` : ""}. ` +
406
+ "Bind this repo: run `hs connect` in a vault repo, `hs install --attach-vault <id> --target <repo>` for a code repo, " +
407
+ "or commit a heyspex.yaml with vault_id and a relative vault_path. " +
408
+ "To follow the pointer on purpose set HEYSPEX_ALLOW_POINTER_VAULT=1.\n"
409
+ );
410
+ }
411
+
334
412
  /**
335
413
  * The vault-resolution ladder `main()` runs at the top of every invocation,
336
414
  * factored out so it is unit-testable without spawning a child process
@@ -358,6 +436,12 @@ export function resolveVaultRootForMain(cwd, env, stopAt) {
358
436
  if (env.HS_VAULT_ROOT) {
359
437
  return { vaultRoot: env.HS_VAULT_ROOT, usedRegistryFallback: false };
360
438
  }
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).
441
+ const brokenPin = findBrokenSatellitePin(cwd, stopAt);
442
+ if (brokenPin) {
443
+ return { vaultRoot: null, usedRegistryFallback: false, brokenPin };
444
+ }
361
445
  const walkedRoot = findVaultRoot(cwd, stopAt);
362
446
  if (walkedRoot) {
363
447
  return { vaultRoot: walkedRoot, usedRegistryFallback: false };
@@ -488,8 +572,10 @@ function usage() {
488
572
  " hs skills list [--json] List registered skills (built-ins first, locked)",
489
573
  " hs index rebuild [--verbose] [--dry-run] Full idempotent rebuild of the index",
490
574
  " hs index reconcile [--apply] [--cap-docs keep-out|index] Per-spec newest-wins between disk and cloud (reports unless --apply)",
491
- " hs pull [--apply] [--only <id>] [--overwrite] Bring cloud-written specs down to the disk vault (reports unless --apply)",
492
- " hs source status|preflight|commit|push Local git: tree status, path-scoped checks, commit, safe push",
575
+ " hs pull [--apply] [--only <id>] [--overwrite] Bring cloud-written specs down to the disk vault (reports unless --apply; --watch --push adds the disk -> cloud leg)",
576
+ " hs push [--apply] [--only <id>] [--force] Send local spec edits up to the cloud vault (reports unless --apply)",
577
+ " hs import <source-dir> [--folders a,b] [--plan f] [--dry-run] [--limit N] Import a doc tree as GROUNDED spec-import captures (never raw, never research)",
578
+ " hs source status|preflight|commit|push|branch Local git: tree status, path-scoped checks, commit, safe push, session branch",
493
579
  " hs source identity [show|set|clear] Git author identity: show (read-only) / set, clear (HUMAN-ONLY)",
494
580
  " hs milestone show <id> [--json] Show a milestone (flags needs-breakdown when epicless)",
495
581
  " hs factory status|fleet|results [--json] Dev-Factory read-only status (fleet, routing, calibrations, results)",
@@ -858,10 +944,27 @@ function main(argv) {
858
944
  const command = argv[1];
859
945
  const rest = argv.slice(2);
860
946
 
861
- const { vaultRoot, usedRegistryFallback } = resolveVaultRootForMain(
862
- process.cwd(),
863
- process.env,
864
- );
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
+ }
960
+ if (
961
+ brokenPin &&
962
+ namespace !== "doctor" &&
963
+ !pointerFallbackAllowedFor(namespace, command)
964
+ ) {
965
+ process.stderr.write(brokenPinRefusalLine(brokenPin));
966
+ process.exit(2);
967
+ }
865
968
 
866
969
  // `hs init` writes the top-level heyspex.yaml vault marker AND scaffolds
867
970
  // specs/ ([[HS-EPIC-AEHR-audit-export-heyspex-yaml-and-heyspex-retirement]]
@@ -920,7 +1023,9 @@ function main(argv) {
920
1023
  dispatchSpexConsoleEntry(
921
1024
  "hs-install.ts",
922
1025
  argv,
923
- vaultRoot || process.cwd(),
1026
+ // In a committed-marker satellite the install target is the CODE repo
1027
+ // (MCP entry, hooks, commands), never the vault clone the marker names.
1028
+ markerSatelliteDirFrom(INVOKED_CWD) ?? (vaultRoot || process.cwd()),
924
1029
  {
925
1030
  HS_PKG_ROOT: PKG_ROOT,
926
1031
  },
@@ -1197,6 +1302,18 @@ function main(argv) {
1197
1302
  process.exit(1);
1198
1303
  }
1199
1304
 
1305
+ // A committed satellite marker whose vault clone is NOT on this machine
1306
+ // resolves to the code repo itself (cloud-only binding). Verbs that write the
1307
+ // vault's files to disk must never scaffold specs/ into a code repo.
1308
+ if (
1309
+ ["pull", "push", "index"].includes(namespace) &&
1310
+ readMarkerSatellite(vaultRoot) &&
1311
+ !existsSync(path.join(vaultRoot, "specs"))
1312
+ ) {
1313
+ process.stderr.write(unclonedVaultRefusalLine(vaultRoot));
1314
+ process.exit(2);
1315
+ }
1316
+
1200
1317
  if (namespace === "index" && command === "rebuild") {
1201
1318
  dispatchSpexConsoleEntry("hs-index-rebuild.ts", rest, vaultRoot, {
1202
1319
  ...(vaultIdOverride ? { HS_VAULT_ID_OVERRIDE: vaultIdOverride } : {}),
@@ -1228,6 +1345,31 @@ function main(argv) {
1228
1345
  return;
1229
1346
  }
1230
1347
 
1348
+ // `hs import <source-dir>` — a doc tree becomes GROUNDED spec-import captures
1349
+ // in the cwd's vault (heyspex.yaml marker, never the account pointer): group-
1350
+ // intent pass, foundational-to-specific order, sequential Ground=Expand with
1351
+ // a running batch ledger, one intent-map doc, source links; never promotes
1352
+ // ([[HS-0GOV-43G9-CAP-the-spec-import-function-should-not-stop]]). Forwards
1353
+ // the full argv; the entrypoint drops the leading `import` token and resolves
1354
+ // a relative <source-dir> against HS_INVOKED_CWD.
1355
+ if (namespace === "import") {
1356
+ dispatchSpexConsoleEntry("hs-import.ts", argv, vaultRoot, {
1357
+ HS_INVOKED_CWD: INVOKED_CWD,
1358
+ ...(vaultIdOverride ? { HS_VAULT_ID_OVERRIDE: vaultIdOverride } : {}),
1359
+ });
1360
+ return;
1361
+ }
1362
+
1363
+ // `hs push` — disk -> cloud (SpexRelay local leg, HS-FCNH). Report-only
1364
+ // unless --apply; never creates specs, never pushes status, refuses over a
1365
+ // newer cloud copy. Forwards the full argv; the entrypoint drops `push`.
1366
+ if (namespace === "push") {
1367
+ dispatchSpexConsoleEntry("hs-vault-push.ts", argv, vaultRoot, {
1368
+ ...(vaultIdOverride ? { HS_VAULT_ID_OVERRIDE: vaultIdOverride } : {}),
1369
+ });
1370
+ return;
1371
+ }
1372
+
1231
1373
  // `hs milestone show <id>` — print a milestone, flagging needs-breakdown when
1232
1374
  // it has no epics ([[HS-TASK-HMSE]] / [[HS-EPIC-MNBD]]). Forwards the full
1233
1375
  // argv; the entrypoint drops the leading `milestone` token.
@@ -1357,7 +1499,9 @@ function main(argv) {
1357
1499
  " hs source status Read-only tree status + path-scoped stage plan\n" +
1358
1500
  " hs source preflight Alias of status\n" +
1359
1501
  " hs source commit Stage + commit per the preflight plan\n" +
1360
- " hs source push Push the committed branch\n" +
1502
+ " hs source push [branch] Push the committed branch (no branch: the shared session-branch resolver's target)\n" +
1503
+ " hs source branch declare [<branch>] | show\n" +
1504
+ " Declare the branch this session works on (cloud spec saves then land on it) / show the target\n" +
1361
1505
  " hs source log <spec-id> Commit-history lookup for a spec (console settlement,\n" +
1362
1506
  " falling back to local git log)\n" +
1363
1507
  " hs source identity [show] [--json]\n" +
@@ -1378,6 +1522,7 @@ function main(argv) {
1378
1522
  preflight: "source:preflight",
1379
1523
  commit: "source:commit",
1380
1524
  push: "source:push",
1525
+ branch: "source:branch",
1381
1526
  log: "source:log",
1382
1527
  identity: "source:identity",
1383
1528
  };
@@ -1648,13 +1793,20 @@ function main(argv) {
1648
1793
  process.exit(2);
1649
1794
  }
1650
1795
  const cliPath = spawnTargetPath("heyspex-mcp-cli");
1796
+ // The MCP engine resolves its vault by walking UP from ITS cwd. Launching
1797
+ // it with cwd = the HeySpex checkout made every `hs mcp call` read the
1798
+ // HeySpex vault no matter where the agent stood (found 2026-10-16 from
1799
+ // mlc-specs / mlc-app). Run it from the repo the caller is in; an explicit
1800
+ // HS_VAULT_ROOT keeps winning (the engine is started inside that vault).
1801
+ const mcpCwd = process.env.HS_VAULT_ROOT && vaultRoot ? vaultRoot : INVOKED_CWD;
1651
1802
  const child = spawn(process.execPath, [cliPath, command, ...rest], {
1652
- cwd: PKG_ROOT,
1803
+ cwd: mcpCwd,
1653
1804
  stdio: "inherit",
1654
1805
  env: {
1655
1806
  ...process.env,
1656
- HEYSPEX_VAULT_ROOT: vaultRoot,
1657
- HS_VAULT_ROOT: vaultRoot,
1807
+ CLAUDE_PROJECT_DIR: mcpCwd,
1808
+ HEYSPEX_VAULT_ROOT: vaultRoot ?? "",
1809
+ HS_VAULT_ROOT: vaultRoot ?? "",
1658
1810
  // One-shot MCP tool — no stdio loop, no stderr brief-tier noise in PS.
1659
1811
  HEYSPEX_DISABLE_STDIO_BOOTSTRAP: "1",
1660
1812
  },
@@ -5,11 +5,26 @@ var __esm = (fn, res) => function __init() {
5
5
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
6
  };
7
7
 
8
+ // scripts/lib/heyspex-home.mjs
9
+ import { homedir } from "node:os";
10
+ function heyspexHome(env = process.env) {
11
+ const override = env?.HEYSPEX_HOME;
12
+ if (typeof override === "string" && override.trim().length > 0) {
13
+ return override.trim();
14
+ }
15
+ return homedir();
16
+ }
17
+ var init_heyspex_home = __esm({
18
+ "scripts/lib/heyspex-home.mjs"() {
19
+ "use strict";
20
+ }
21
+ });
22
+
8
23
  // vendor/heyspex-mcp/credentials.mjs
9
24
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
- import os, { homedir } from "node:os";
25
+ import os, { homedir as homedir2 } from "node:os";
11
26
  import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
12
- function credentialsFilePath(homeDir = homedir()) {
27
+ function credentialsFilePath(homeDir = heyspexHome()) {
13
28
  return join2(homeDir, ".heyspex", "credentials.yaml");
14
29
  }
15
30
  function parseMcpToken(raw) {
@@ -122,14 +137,14 @@ function readLegacyConfigAccountId(read, homeDir) {
122
137
  const value = raw?.match(CONFIG_ACCOUNT_ID_RE)?.[1]?.trim() ?? "";
123
138
  return value || null;
124
139
  }
125
- function readBearer({ env = process.env, homeDir = homedir(), readFile, accountId } = {}) {
140
+ function readBearer({ env = process.env, homeDir = heyspexHome(), readFile, accountId } = {}) {
126
141
  const read = readFile ?? ((p) => readFileSync(p, "utf8"));
127
142
  const credentialsText = readTextOrNull(read, credentialsFilePath(homeDir));
128
143
  const named = typeof accountId === "string" && accountId.trim() ? accountId.trim() : null;
129
144
  const legacyAccountId = named && parseCredentialsStore(credentialsText).schemaVersion === 1 ? readLegacyConfigAccountId(read, homeDir) : null;
130
145
  return resolveBearer({ envToken: env?.[MCP_TOKEN_ENV], credentialsText, accountId: named, legacyAccountId });
131
146
  }
132
- function readCurrentAccountId({ homeDir = homedir(), readFile } = {}) {
147
+ function readCurrentAccountId({ homeDir = heyspexHome(), readFile } = {}) {
133
148
  const read = readFile ?? ((p) => readFileSync(p, "utf8"));
134
149
  const store = parseCredentialsStore(readTextOrNull(read, credentialsFilePath(homeDir)));
135
150
  if (store.schemaVersion === 2 && store.currentAccountId && store.currentAccountId !== LEGACY_ACCOUNT_ID) {
@@ -142,6 +157,7 @@ var MCP_TOKEN_ENV, MCP_TOKEN_LINE_RE, CREDENTIALS_SCHEMA_VERSION, LEGACY_ACCOUNT
142
157
  var init_credentials = __esm({
143
158
  "vendor/heyspex-mcp/credentials.mjs"() {
144
159
  "use strict";
160
+ init_heyspex_home();
145
161
  MCP_TOKEN_ENV = "HEYSPEX_MCP_TOKEN";
146
162
  MCP_TOKEN_LINE_RE = /^mcp_token:[ \t]*["']?([^"'\r\n#]+)/m;
147
163
  CREDENTIALS_SCHEMA_VERSION = 2;
@@ -153,11 +169,11 @@ var init_credentials = __esm({
153
169
  });
154
170
 
155
171
  // scripts/lib/dev-mode.mjs
156
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "node:fs";
157
- import { homedir as homedir6 } from "node:os";
158
- import { join as join7 } from "node:path";
159
- function devModeFilePath(homeDir = homedir6()) {
160
- return join7(homeDir, ".heyspex", DEV_MODE_FILENAME);
172
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
173
+ import { homedir as homedir7 } from "node:os";
174
+ import { join as join8 } from "node:path";
175
+ function devModeFilePath(homeDir = heyspexHome()) {
176
+ return join8(homeDir, ".heyspex", DEV_MODE_FILENAME);
161
177
  }
162
178
  function normalizeMode(raw) {
163
179
  const v = String(raw ?? "").trim().toLowerCase();
@@ -181,7 +197,7 @@ function deriveSemantics(mode, reliance) {
181
197
  vaultWriteMode: local ? "local" : "cloud"
182
198
  };
183
199
  }
184
- function readLocalDevModeRecord({ homeDir = homedir6(), readFile = defaultReadFile } = {}) {
200
+ function readLocalDevModeRecord({ homeDir = heyspexHome(), readFile = defaultReadFile } = {}) {
185
201
  let raw;
186
202
  try {
187
203
  raw = readFile(devModeFilePath(homeDir));
@@ -211,7 +227,7 @@ function readLocalDevModeRecord({ homeDir = homedir6(), readFile = defaultReadFi
211
227
  }
212
228
  function resolveDevMode({
213
229
  env = process.env,
214
- homeDir = homedir6(),
230
+ homeDir = heyspexHome(),
215
231
  readFile = defaultReadFile,
216
232
  cloudSetting = void 0
217
233
  } = {}) {
@@ -274,17 +290,18 @@ var CLOUD_CONSOLE_URL, LOCAL_CONSOLE_URL, DEV_MODE_FILENAME, defaultReadFile;
274
290
  var init_dev_mode = __esm({
275
291
  "scripts/lib/dev-mode.mjs"() {
276
292
  "use strict";
293
+ init_heyspex_home();
277
294
  init_credentials();
278
295
  CLOUD_CONSOLE_URL = "https://app.heyspex.com";
279
296
  LOCAL_CONSOLE_URL = "http://localhost:3000";
280
297
  DEV_MODE_FILENAME = "dev-mode.json";
281
- defaultReadFile = (p) => readFileSync6(p, "utf8");
298
+ defaultReadFile = (p) => readFileSync7(p, "utf8");
282
299
  }
283
300
  });
284
301
 
285
302
  // spex-console/bin/hs-check.ts
286
303
  import { spawn, spawnSync } from "node:child_process";
287
- import { existsSync as existsSync4, readFileSync as readFileSync8 } from "node:fs";
304
+ import { existsSync as existsSync5, readFileSync as readFileSync9 } from "node:fs";
288
305
  import path2 from "node:path";
289
306
  import process3 from "node:process";
290
307
 
@@ -822,17 +839,19 @@ function hsCommand(args = "", options = {}) {
822
839
  }
823
840
 
824
841
  // vendor/heyspex-mcp/console-fetch.mjs
842
+ init_heyspex_home();
825
843
  init_credentials();
826
- import { readFileSync as readFileSync4 } from "node:fs";
827
- import { homedir as homedir4 } from "node:os";
828
- import { join as join5 } from "node:path";
844
+ import { readFileSync as readFileSync5 } from "node:fs";
845
+ import { homedir as homedir5 } from "node:os";
846
+ import { join as join6 } from "node:path";
829
847
 
830
848
  // vendor/heyspex-mcp/session-owner.mjs
849
+ init_heyspex_home();
831
850
  init_credentials();
832
851
  import { readFileSync as readFileSync2 } from "node:fs";
833
- import { homedir as homedir2 } from "node:os";
852
+ import { homedir as homedir3 } from "node:os";
834
853
  import { join as join3 } from "node:path";
835
- function resolveSessionOwnerUncached({ env = process.env, homeDir = homedir2(), readFile } = {}) {
854
+ function resolveSessionOwnerUncached({ env = process.env, homeDir = heyspexHome(), readFile } = {}) {
836
855
  const fromEnv = typeof env?.HS_ACCOUNT_ID === "string" ? env.HS_ACCOUNT_ID.trim() : "";
837
856
  if (fromEnv) return fromEnv;
838
857
  return readCurrentAccountId({ homeDir, readFile });
@@ -862,10 +881,23 @@ function isVaultLessEndpoint(method, path3) {
862
881
  });
863
882
  }
864
883
 
884
+ // vendor/heyspex-mcp/write-outbox.mjs
885
+ import {
886
+ readdirSync,
887
+ readFileSync as readFileSync3,
888
+ writeFileSync as writeFileSync2,
889
+ mkdirSync as mkdirSync2,
890
+ existsSync as existsSync2,
891
+ renameSync as renameSync2,
892
+ rmSync
893
+ } from "node:fs";
894
+ import { basename, join as join4, isAbsolute as isAbsolute2 } from "node:path";
895
+
865
896
  // vendor/heyspex-mcp/vault-resolve.mjs
866
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
867
- import { homedir as homedir3, tmpdir } from "node:os";
868
- import { dirname as dirname3, join as join4, resolve as resolve3 } from "node:path";
897
+ init_heyspex_home();
898
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
899
+ import { homedir as homedir4, tmpdir } from "node:os";
900
+ import { dirname as dirname3, join as join5, resolve as resolve3 } from "node:path";
869
901
 
870
902
  // vendor/heyspex-mcp/console-fetch.mjs
871
903
  var VAULT_NOT_REGISTERED = "VAULT_NOT_REGISTERED";
@@ -897,9 +929,9 @@ function isCloudVaultId(value) {
897
929
  var MARKER_VAULT_ID_RE = /^vault_id:[ \t]*["']?([^"'\r\n#]+)/m;
898
930
  function readMarkerVaultId(repoRootAbs, { readFile } = {}) {
899
931
  if (typeof repoRootAbs !== "string" || !repoRootAbs.trim()) return null;
900
- const read = readFile ?? ((p) => readFileSync4(p, "utf8"));
932
+ const read = readFile ?? ((p) => readFileSync5(p, "utf8"));
901
933
  try {
902
- const raw = read(join5(repoRootAbs, "heyspex.yaml"));
934
+ const raw = read(join6(repoRootAbs, "heyspex.yaml"));
903
935
  const value = String(raw ?? "").match(MARKER_VAULT_ID_RE)?.[1]?.trim() ?? "";
904
936
  return value || null;
905
937
  } catch {
@@ -919,9 +951,18 @@ var SCOPE_REFUSAL_CODES = Object.freeze(["VAULT_SCOPE_MISMATCH", "ACCOUNT_MISMAT
919
951
  init_credentials();
920
952
 
921
953
  // spex-console/src/lib/vault-registry/registry-file.ts
922
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync5, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
923
- import { homedir as homedir5 } from "node:os";
924
- import { dirname as dirname4, join as join6, resolve as resolve4 } from "node:path";
954
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync6, renameSync as renameSync3, writeFileSync as writeFileSync3 } from "node:fs";
955
+ import { dirname as dirname4, join as join7, resolve as resolve4 } from "node:path";
956
+
957
+ // spex-console/src/lib/heyspex-home.ts
958
+ import { homedir as homedir6 } from "node:os";
959
+ function heyspexHome2(env = process.env) {
960
+ const override = env?.HEYSPEX_HOME;
961
+ if (typeof override === "string" && override.trim().length > 0) {
962
+ return override.trim();
963
+ }
964
+ return homedir6();
965
+ }
925
966
 
926
967
  // spex-console/src/lib/vault-registry/types.ts
927
968
  var LEGACY_REGISTRY_ACCOUNT_ID = "legacy";
@@ -938,10 +979,10 @@ function emptyVaultRegistry() {
938
979
 
939
980
  // spex-console/src/lib/vault-registry/registry-file.ts
940
981
  function vaultRegistryDir(opts = {}) {
941
- return opts.registryDir ?? join6(homedir5(), ".heyspex");
982
+ return opts.registryDir ?? join7(heyspexHome2(), ".heyspex");
942
983
  }
943
984
  function vaultRegistryFilePath(opts = {}) {
944
- return join6(vaultRegistryDir(opts), "registry.json");
985
+ return join7(vaultRegistryDir(opts), "registry.json");
945
986
  }
946
987
  function isNonEmptyString(v) {
947
988
  return typeof v === "string" && v.trim().length > 0;
@@ -1042,9 +1083,9 @@ function parseVaultRegistryFile(json) {
1042
1083
  }
1043
1084
  function readRegistryJson(opts) {
1044
1085
  const filePath = vaultRegistryFilePath(opts);
1045
- if (!existsSync3(filePath)) return { kind: "missing" };
1086
+ if (!existsSync4(filePath)) return { kind: "missing" };
1046
1087
  try {
1047
- const raw = readFileSync5(filePath, "utf8");
1088
+ const raw = readFileSync6(filePath, "utf8");
1048
1089
  const json = JSON.parse(raw);
1049
1090
  if (registryFileSchemaOf(json) === null) return { kind: "unreadable" };
1050
1091
  return { kind: "ok", raw, json };
@@ -1319,10 +1360,11 @@ function resolveControlScopeFromMachine(vaultRoot, opts = {}) {
1319
1360
  }
1320
1361
 
1321
1362
  // scripts/lib/console-url-resolver.mjs
1363
+ init_heyspex_home();
1322
1364
  init_dev_mode();
1323
- import { readFileSync as readFileSync7 } from "node:fs";
1324
- import { homedir as homedir7 } from "node:os";
1325
- import { join as join8 } from "node:path";
1365
+ import { readFileSync as readFileSync8 } from "node:fs";
1366
+ import { homedir as homedir8 } from "node:os";
1367
+ import { join as join9 } from "node:path";
1326
1368
  var DEFAULT_CONSOLE_URL = LOCAL_CONSOLE_URL;
1327
1369
  function trimTrailingSlash(url) {
1328
1370
  return url.replace(/\/+$/, "");
@@ -1348,9 +1390,9 @@ function warnStaleLoopbackCredentialsOnce(credentialsUrl, derivedUrl, { log }) {
1348
1390
  } catch {
1349
1391
  }
1350
1392
  }
1351
- function readCredentialsConsoleUrl({ homeDir = homedir7(), readFile = defaultReadFile2 } = {}) {
1393
+ function readCredentialsConsoleUrl({ homeDir = heyspexHome(), readFile = defaultReadFile2 } = {}) {
1352
1394
  try {
1353
- const raw = readFile(join8(homeDir, ".heyspex", "credentials.yaml"));
1395
+ const raw = readFile(join9(homeDir, ".heyspex", "credentials.yaml"));
1354
1396
  const match = String(raw ?? "").match(/^console_url:\s*["']?([^"'\r\n#]+)/m);
1355
1397
  const value = match?.[1]?.trim();
1356
1398
  return value && value.length > 0 ? trimTrailingSlash(value) : null;
@@ -1359,12 +1401,12 @@ function readCredentialsConsoleUrl({ homeDir = homedir7(), readFile = defaultRea
1359
1401
  }
1360
1402
  }
1361
1403
  function defaultReadFile2(p) {
1362
- return readFileSync7(p, "utf8");
1404
+ return readFileSync8(p, "utf8");
1363
1405
  }
1364
1406
  function readDiscoveryFileConsoleUrl(repoRoot, { readDiscovery = defaultReadFile2 } = {}) {
1365
1407
  if (!repoRoot) return null;
1366
1408
  try {
1367
- const discovery = String(readDiscovery(join8(repoRoot, ".heyspex", "console-url")) ?? "").trim();
1409
+ const discovery = String(readDiscovery(join9(repoRoot, ".heyspex", "console-url")) ?? "").trim();
1368
1410
  if (discovery.length > 0) return trimTrailingSlash(discovery);
1369
1411
  } catch {
1370
1412
  }
@@ -1392,7 +1434,7 @@ function resolveModeEnvelope({ env, homeDir }) {
1392
1434
  function resolveConsoleUrl({
1393
1435
  repoRoot,
1394
1436
  env = process.env,
1395
- homeDir = homedir7(),
1437
+ homeDir = heyspexHome(),
1396
1438
  strict = false,
1397
1439
  devMode,
1398
1440
  log = (line) => process.stderr.write(line),
@@ -1489,10 +1531,10 @@ function manifestForSpecId(vaultRoot, specId) {
1489
1531
  }
1490
1532
  function resolveManifestPath(target, vaultRoot, invokedCwd) {
1491
1533
  const literal = path2.resolve(invokedCwd, target);
1492
- if (existsSync4(literal)) return literal;
1534
+ if (existsSync5(literal)) return literal;
1493
1535
  if (target.endsWith(".json")) return null;
1494
1536
  const byId = manifestForSpecId(vaultRoot, target);
1495
- return existsSync4(byId) ? byId : null;
1537
+ return existsSync5(byId) ? byId : null;
1496
1538
  }
1497
1539
  function countTscErrors(output) {
1498
1540
  const matches = output.match(/error TS\d+/g);
@@ -1506,12 +1548,12 @@ function resolveWindowsPosixShell() {
1506
1548
  "C:\\Program Files\\Git\\bin\\bash.exe"
1507
1549
  ].filter((c) => typeof c === "string" && c.length > 0);
1508
1550
  for (const candidate of candidates) {
1509
- if (existsSync4(candidate)) return cachedPosixShell = candidate;
1551
+ if (existsSync5(candidate)) return cachedPosixShell = candidate;
1510
1552
  }
1511
1553
  const where = spawnSync("where", ["bash"], { encoding: "utf8", windowsHide: true });
1512
1554
  if (where.status === 0 && where.stdout) {
1513
1555
  const hit = where.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).find((line) => !/\\system32\\/i.test(line));
1514
- if (hit && existsSync4(hit)) return cachedPosixShell = hit;
1556
+ if (hit && existsSync5(hit)) return cachedPosixShell = hit;
1515
1557
  }
1516
1558
  return cachedPosixShell = null;
1517
1559
  }
@@ -1600,7 +1642,7 @@ async function main() {
1600
1642
  if (process3.argv[3] === "judge") {
1601
1643
  const pkgRoot = resolveCodeRoot({ vaultRoot });
1602
1644
  const judgeScript = path2.join(pkgRoot, "dev-factory", "judge.mjs");
1603
- if (!existsSync4(judgeScript)) {
1645
+ if (!existsSync5(judgeScript)) {
1604
1646
  process3.stderr.write(
1605
1647
  `hs check judge: script not found at ${judgeScript} (source checkout only \u2014 not available in a published install).
1606
1648
  `
@@ -1642,7 +1684,7 @@ async function main() {
1642
1684
  process3.exitCode = EXIT.NOT_FOUND;
1643
1685
  return;
1644
1686
  }
1645
- const { manifest, error } = parseChecksManifest(readFileSync8(manifestPath, "utf-8"));
1687
+ const { manifest, error } = parseChecksManifest(readFileSync9(manifestPath, "utf-8"));
1646
1688
  if (error) {
1647
1689
  process3.stderr.write(`hs check: ${manifestPath}: ${error}
1648
1690
  `);
@@ -1674,7 +1716,7 @@ async function main() {
1674
1716
  process3.exitCode = reportExitCode(report);
1675
1717
  }
1676
1718
  async function reportVerdict(target, report, vaultRoot, unattended = false) {
1677
- if (existsSync4(target) || target.endsWith(".json")) {
1719
+ if (existsSync5(target) || target.endsWith(".json")) {
1678
1720
  process3.stderr.write(
1679
1721
  "hs check: --report needs a spec-id target (to identify the claim), not a manifest path.\n"
1680
1722
  );
@@ -1682,7 +1724,7 @@ async function reportVerdict(target, report, vaultRoot, unattended = false) {
1682
1724
  }
1683
1725
  const consoleUrl = resolveSpexConsoleUrl(vaultRoot, (p) => {
1684
1726
  try {
1685
- return readFileSync8(p, "utf-8");
1727
+ return readFileSync9(p, "utf-8");
1686
1728
  } catch {
1687
1729
  return null;
1688
1730
  }