heyspex 0.2.0 → 0.2.2

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 (146) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/bin/hs.mjs +181 -15
  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 +177 -0
  45. package/docs/driving-heyspex-from-copilot.md +165 -0
  46. package/package.json +21 -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 +312 -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/install-copilot-hooks.mjs +288 -0
  72. package/scripts/lib/console-url-resolver.mjs +3 -2
  73. package/scripts/lib/customer-kit.mjs +327 -10
  74. package/scripts/lib/dev-mode.mjs +7 -6
  75. package/scripts/lib/directive-obligations.mjs +349 -0
  76. package/scripts/lib/hook-dispositions.mjs +210 -0
  77. package/scripts/lib/hook-vault-header.mjs +30 -0
  78. package/scripts/lib/hs-source-branch.mjs +196 -0
  79. package/scripts/lib/hs-source-log.mjs +2 -1
  80. package/scripts/lib/hs-source-preflight.mjs +18 -4
  81. package/scripts/lib/hs-source-push.mjs +386 -356
  82. package/scripts/lib/window-placement.mjs +109 -0
  83. package/spexharness/src/adapters/claude-code.mjs +11 -3
  84. package/spexharness/src/adapters/copilot.mjs +236 -0
  85. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  86. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  87. package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
  88. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  89. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  90. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  91. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  92. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  93. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  94. package/spexharness/src/intent-classifier.mjs +4 -2
  95. package/spexharness/src/intent-directive.mjs +626 -541
  96. package/spexharness/src/rule-table.mjs +31 -1
  97. package/vendor/heyspex-mcp/account-switch.mjs +6 -5
  98. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
  99. package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
  100. package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
  101. package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
  102. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  103. package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
  104. package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
  105. package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
  106. package/vendor/heyspex-mcp/cli.mjs +7 -1
  107. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  108. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
  109. package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
  110. package/vendor/heyspex-mcp/console-transport.mjs +7 -1
  111. package/vendor/heyspex-mcp/credentials.mjs +9 -8
  112. package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
  113. package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
  114. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
  115. package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
  116. package/vendor/heyspex-mcp/http-transport.mjs +2 -0
  117. package/vendor/heyspex-mcp/list-tools.mjs +1 -1
  118. package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
  119. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  120. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  121. package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
  122. package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
  123. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  124. package/vendor/heyspex-mcp/plan-push.mjs +3 -2
  125. package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
  126. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
  127. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
  128. package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
  129. package/vendor/heyspex-mcp/promote-research.mjs +6 -0
  130. package/vendor/heyspex-mcp/read-path.mjs +162 -6
  131. package/vendor/heyspex-mcp/request-context.mjs +25 -1
  132. package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
  133. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  134. package/vendor/heyspex-mcp/session-owner.mjs +3 -2
  135. package/vendor/heyspex-mcp/session-tools.mjs +10 -0
  136. package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
  137. package/vendor/heyspex-mcp/split-research.mjs +3 -0
  138. package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
  139. package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
  140. package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
  141. package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
  142. package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
  143. package/vendor/heyspex-mcp/vault-io.mjs +720 -724
  144. package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
  145. package/vendor/heyspex-mcp/whoami.mjs +2 -1
  146. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -55,10 +55,13 @@ export const HOOKS = [
55
55
  { event: 'sessionStart', hook: 'wip-checkpoint', why: 'Surface the previous session WIP handoff before work resumes.' },
56
56
  { event: 'sessionStart', hook: 'inject-dev-server-awareness', why: 'Once-per-session reminder that dev-server status is a tool call, not a guess.' },
57
57
  { event: 'sessionStart', hook: 'inject-coo-seat', why: 'Inject the dev-factory COO seat mandate when the COO window is open.' },
58
+ { event: 'sessionStart', hook: 'inject-doctrine', why: 'Inject the baseline SpexCode Doctrine edition at session start.' },
58
59
  { event: 'sessionStart', hook: 'coo-guard-check', why: 'Warn at session start when a breakdown has not been delegated.' },
59
60
  { event: 'sessionStart', hook: 'check-auto-memory-budget', why: 'Self-heal and budget-check the auto-memory index.' },
60
61
  { event: 'sessionStart', hook: 'check-claude-md-budget', why: 'Advisory budget check on the always-loaded agent contract.' },
61
62
  { event: 'sessionStart', hook: 'check-mcp-freshness-hook', why: 'Warn when the running MCP server is stale versus vendor.' },
63
+ { event: 'sessionStart', hook: 'git-reflog-audit', why: 'Session-start git audit (stash/reset/rebase witness).' },
64
+ { event: 'sessionStart', hook: 'vault-mirror-catchup', why: 'Cloud write mode: detached hs pull --apply closes a stale disk mirror (HS-XJFQ).' },
62
65
 
63
66
  // ── Compaction ──────────────────────────────────────────────────────────
64
67
  // Claude re-runs SessionStart(source: compact) after compaction; Cursor fires
@@ -74,6 +77,7 @@ export const HOOKS = [
74
77
  { event: 'beforeSubmitPrompt', hook: 'preflight-reminder', why: 'Name the required procedure before a filing/destructive/protocol turn.' },
75
78
  { event: 'beforeSubmitPrompt', hook: 'intent-classifier-trigger', why: 'Route capture / research / settings / defer intents to the right skill.' },
76
79
  { event: 'beforeSubmitPrompt', hook: 'inject-communication-style', why: 'Re-assert the plain-language communication rules each turn.' },
80
+ { event: 'beforeSubmitPrompt', hook: 'inject-vault-context', timeout: 10, why: 'Lexical vault retrieval for the prompt (surfaced as a user message).' },
77
81
 
78
82
  // ── Before every tool call (any tool) ───────────────────────────────────
79
83
  { event: 'preToolUse', matcher: '.*', hook: 'emit-claim-status', args: ['--event=PreToolUse'], why: 'Claim liveness heartbeat to the console.' },
@@ -86,22 +90,28 @@ export const HOOKS = [
86
90
  // the vault guard because removing a profile file is the loss it exists to
87
91
  // prevent, and Claude had no separate delete tool to list.
88
92
  { event: 'preToolUse', matcher: 'Write|Delete', hook: 'env-vault-guard', why: 'Block destructive writes to the encrypted env vault (ALWAYS ON).' },
93
+ { event: 'preToolUse', matcher: 'Write', hook: 'vault-write-tier-guard', why: 'Deny disk writes to vault specs in cloud write mode (HS-FCNH).' },
89
94
  { event: 'preToolUse', matcher: 'Write', hook: 'settings-write-gate', why: 'Require approval before .heyspex/config.yaml changes.' },
90
95
  { event: 'preToolUse', matcher: 'Write', hook: 'enforce-vault-naming', why: 'Block vault filenames that violate the naming registry.' },
91
96
  { event: 'preToolUse', matcher: 'Write', hook: 'guard-auto-memory-writes', why: 'Block new topic files in the auto-memory directory.' },
92
97
  { event: 'preToolUse', matcher: 'Write', hook: 'preflight-reminder', why: 'Preflight reminder on the edit path itself.' },
93
98
  { event: 'preToolUse', matcher: 'Write', hook: 'claim-before-code-gate', why: 'Deny the first code edit after plan exit with no claim.' },
99
+ { event: 'preToolUse', matcher: 'Write', hook: 'directive-obligation-gate', why: 'Deny product-code writes while a capture_on_idea obligation is unmet, and (claim-before-build) with no claim/capture (HS-20SW).' },
94
100
 
95
101
  // ── Before a shell command ──────────────────────────────────────────────
96
102
  // beforeShellExecution, not preToolUse/Shell: it is the only event guaranteed
97
103
  // to carry the command string, and Cursor honors allow/deny/ask on it.
98
104
  { event: 'beforeShellExecution', hook: 'env-vault-guard', why: 'Block shell writes/exfiltration targeting the env vault (ALWAYS ON).' },
105
+ { event: 'beforeShellExecution', hook: 'vault-write-tier-guard', why: 'Deny shell writes to vault specs in cloud write mode (HS-FCNH).' },
99
106
  { event: 'beforeShellExecution', hook: 'settings-write-gate', why: 'Catch shell writes to .heyspex/config.yaml.' },
100
107
  { event: 'beforeShellExecution', hook: 'enforce-vault-naming', why: 'Catch vault files created via the shell.' },
101
108
  { event: 'beforeShellExecution', hook: 'guard-auto-memory-writes', why: 'Catch auto-memory topic files created via the shell.' },
102
109
  { event: 'beforeShellExecution', hook: 'preflight-reminder', why: 'Preflight reminder on rm -rf / force-push shaped commands.' },
103
110
  { event: 'beforeShellExecution', hook: 'dev-server-task-reminder', timeout: 10, why: 'Dev-server start/stop hard gate plus DB-safety reminder.' },
104
111
  { event: 'beforeShellExecution', hook: 'git-stash-gate', timeout: 10, why: 'Always ask before a mutating git stash.' },
112
+ { event: 'beforeShellExecution', hook: 'git-identity-guard', why: 'Agents never write git identity (owner ruling 2026-09-19, ALWAYS ON).' },
113
+ { event: 'beforeShellExecution', hook: 'capture-execution-gate', why: 'Block dev-factory research/paid-model/routing shell commands past an ungrounded capture (HS-KNJ8, platform).' },
114
+ { event: 'beforeShellExecution', hook: 'directive-obligation-gate', why: 'Deny build commands and product-code shell writes while a capture obligation is unmet (HS-20SW).' },
105
115
 
106
116
  // ── Before specific MCP tools ───────────────────────────────────────────
107
117
  // Cursor fires preToolUse for MCP tools too (matcher `MCP:<name>`), so these
@@ -112,6 +122,8 @@ export const HOOKS = [
112
122
  { event: 'preToolUse', matcher: '.*', tool: 'heyspex_file_spec$', hook: 'capture-workflow-gate', timeout: 8, why: 'Deny filing an implementation spec straight out of an open capture.' },
113
123
  { event: 'preToolUse', matcher: '.*', tool: 'heyspex_settings_set$', hook: 'settings-write-gate', why: 'Require approval for settings changes made through MCP.' },
114
124
  { event: 'preToolUse', matcher: '.*', tool: '[Nn]eon__(prepare_database_migration|complete_database_migration|reset_from_parent|delete_branch)$', hook: 'dev-server-task-reminder', timeout: 10, why: 'DB-safety gate on Neon migration/reset/delete tools.' },
125
+ { event: 'preToolUse', matcher: '.*', tool: 'heyspex_file_spec$', hook: 'diagnose-capture-reminder', why: 'Warn when a diagnose is filed with no capture holding the owner words (HS-IK2D).' },
126
+ { event: 'preToolUse', matcher: '.*', tool: '^(Workflow|Skill)$', hook: 'research-method-guard', why: 'Research method guard on Skill/Workflow launches (HS-EN4F, ALWAYS ON).' },
115
127
  { event: 'preToolUse', matcher: '.*', tool: '^(Workflow|Skill)$', hook: 'deep-research-cost-gate', why: 'Cost confirmation before a paid deep-research harness launch.' },
116
128
  { event: 'preToolUse', matcher: '.*', tool: '^(WebSearch|WebFetch|web_search|web_fetch)$|heyspex_save_research$', hook: 'research-protocol-gate', why: 'WebSearch/WebFetch/save_research refused until the research preflight ran (HS-K0ZP, HS-EN4F).' },
117
129
 
@@ -119,10 +131,13 @@ export const HOOKS = [
119
131
  // subagentStart over preToolUse/Task: it carries subagent_type and the model,
120
132
  // which is exactly what the routing gate decides on, and it can deny.
121
133
  { event: 'subagentStart', hook: 'agent-spawn-delegation-gate', why: 'Block a build/breakdown/critic subagent on an off-routing model.' },
134
+ { event: 'subagentStart', hook: 'capture-execution-gate', why: 'Block a dev-factory subagent spawn past an ungrounded capture (HS-KNJ8, platform).' },
135
+ { event: 'subagentStart', hook: 'directive-obligation-gate', why: 'Deny a build subagent while a capture obligation is unmet or the session has no claim/capture (HS-20SW).' },
122
136
 
123
137
  // ── After every tool call ───────────────────────────────────────────────
124
138
  { event: 'postToolUse', matcher: '.*', hook: 'emit-claim-status', args: ['--event=PostToolUse'], why: 'Claim liveness heartbeat after the call resolves.' },
125
139
  { event: 'postToolUse', matcher: '.*', hook: 'log-session-activity', args: ['--event=PostToolUse'], why: 'Append the tool result row to the session log.' },
140
+ { event: 'postToolUse', matcher: '.*', hook: 'directive-obligation-gate', why: 'Discharge the capture obligation on a capture write; track claim acquire/release (HS-20SW).' },
126
141
  { event: 'postToolUse', matcher: '.*', hook: 'close-terminal-on-ship', why: 'Close the Launch terminal once the spec ships.' },
127
142
  { event: 'postToolUse', matcher: 'Write', hook: 'notify-status-flip', why: 'Sound on an in_review status flip.' },
128
143
  { event: 'postToolUse', matcher: 'Write', hook: 'scripts/stamp-provenance-on-edit.mjs', why: 'Stamp assisted_by provenance on direct spec edits.' },
@@ -137,7 +152,11 @@ export const HOOKS = [
137
152
  // hook re-drives the agent — the same effect as Claude's exit-2 block.
138
153
  { event: 'stop', hook: 'emit-claim-status', args: ['--event=Stop'], why: 'Mark the claim pending-on-response.' },
139
154
  { event: 'stop', hook: 'naughty-list-gate', why: 'Re-drive the turn when the final message uses banned jargon.' },
155
+ { event: 'stop', hook: 'id-display-format-gate', why: 'Re-drive the turn when a reply breaks the Naming V3 display-id format.' },
156
+ { event: 'stop', hook: 'epic-milestone-phrasing-gate', why: 'Re-drive the turn when a reply breaks the epic/milestone phrasing rules.' },
157
+ { event: 'stop', hook: 'git-reflog-audit', why: 'Session-close git audit backstop (stash/reset/rebase witness).' },
140
158
  { event: 'stop', hook: 'enforce-session-sequence', why: 'Re-drive the turn when work shipped without record_wip.' },
159
+ { event: 'stop', hook: 'directive-obligation-gate', why: 'Re-drive the turn ONCE (followup_message) when a capture obligation is still unmet (HS-20SW).' },
141
160
 
142
161
  // ── Session end ─────────────────────────────────────────────────────────
143
162
  { event: 'sessionEnd', hook: 'session-end', why: 'Release the claim and notify the console.' },
@@ -164,6 +183,33 @@ export const UNPORTABLE = [
164
183
  },
165
184
  ];
166
185
 
186
+ /**
187
+ * Hooks that ARE registered above but run weaker than on Claude Code. Kept in code (like UNPORTABLE)
188
+ * so scripts/lib/hook-dispositions.test.mjs can hold the three-host table to it.
189
+ */
190
+ export const DEGRADED = [
191
+ {
192
+ hook: 'preflight-reminder',
193
+ reason: 'beforeSubmitPrompt honors user_message only: the reminder surfaces in the transcript, not as invisible context.',
194
+ },
195
+ {
196
+ hook: 'intent-classifier-trigger',
197
+ reason: 'Same channel limit; the routing directive reaches the agent as a visible user message.',
198
+ },
199
+ {
200
+ hook: 'inject-communication-style',
201
+ reason: 'Same channel limit; re-asserted each turn as a visible user message.',
202
+ },
203
+ {
204
+ hook: 'inject-vault-context',
205
+ reason: 'Same channel limit; retrieved vault context appears as a visible user message.',
206
+ },
207
+ {
208
+ hook: 'claim-before-code-gate',
209
+ reason: 'Armed by plan-exit-ingest-gate, which has no Cursor trigger (UNPORTABLE), so it stays dormant.',
210
+ },
211
+ ];
212
+
167
213
  /**
168
214
  * The command string Cursor runs for one entry.
169
215
  *
@@ -25,6 +25,26 @@ import { fileURLToPath } from 'node:url';
25
25
 
26
26
  export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
27
 
28
+ /**
29
+ * The root the mode switch reads and writes. In the platform checkout that is REPO_ROOT (unchanged). When this file
30
+ * runs from an INSTALLED package (node_modules/heyspex/...) REPO_ROOT is the package, not the project the dev is
31
+ * working in: the switch used to write inside node_modules and the hook runner never saw it. So: the project
32
+ * (CLAUDE_PROJECT_DIR, else the nearest ancestor of cwd holding a state dir or .git, else cwd).
33
+ */
34
+ export function resolveModeRoot(repoRoot = REPO_ROOT, env = process.env, cwd = process.cwd()) {
35
+ if (!repoRoot.split(path.sep).includes('node_modules')) return repoRoot;
36
+ const fromEnv = String(env.CLAUDE_PROJECT_DIR ?? '').trim();
37
+ if (fromEnv) return fromEnv;
38
+ let d = cwd;
39
+ for (;;) {
40
+ if (fs.existsSync(path.join(d, '.hey' + 'spex')) || fs.existsSync(path.join(d, '.git'))) return d;
41
+ const up = path.dirname(d);
42
+ if (up === d) return cwd;
43
+ d = up;
44
+ }
45
+ }
46
+ export const MODE_ROOT = resolveModeRoot();
47
+
28
48
  const STATE_REL = '.heyspex/mode.json';
29
49
  const BACKUP_DIR_REL = '.heyspex/mode-backup';
30
50
  const CLAUDE_MD_REL = 'CLAUDE.md';
@@ -9,7 +9,7 @@
9
9
  // Flags: --json (machine-readable), --quiet (exit code only).
10
10
  // Exit codes: 0 ok · 1 usage · 2 flip failed.
11
11
 
12
- import { REPO_ROOT, resolveMode, setMode, mcpEnabled, claudeMdStubbed } from './heyspex-mode.mjs';
12
+ import { MODE_ROOT as REPO_ROOT, resolveMode, setMode, mcpEnabled, claudeMdStubbed } from './heyspex-mode.mjs';
13
13
 
14
14
  const argv = process.argv.slice(2).filter((a) => a !== 'mode');
15
15
  const flags = new Set(argv.filter((a) => a.startsWith('--')));
@@ -12,9 +12,11 @@
12
12
  * Open = run it; close = close the window; re-run = reopen (idempotent).
13
13
  *
14
14
  * Usage:
15
- * hs view pipeline # follow the focus pin
16
- * hs view pipeline HS-I87U-CAP # fixed anchor spec
17
- * hs view docs HS-NP1Y # single-doc reader, live-refreshing
15
+ * hs view # inspector, Pipeline tab, following the focus pin
16
+ * hs view HLFJ # same, anchored on HS-HLFJ (any case, HS- optional)
17
+ * hs view pipeline HS-I87U-CAP # same — "pipeline" is the inspector's Pipeline tab
18
+ * hs view inspector HS-I87U-CAP # inspector on its default tab
19
+ * hs view docs HS-NP1Y # doc + inspector Pipeline tab side by side
18
20
  * hs view pipeline --follow capture # track the capture-on-deck pin
19
21
  * hs view pipeline --follow capture --url https://console.example.com
20
22
  * hs view pipeline --width 510 --height 900 --browser edge|chrome
@@ -31,6 +33,13 @@ import { resolveConsoleUrl, DEFAULT_CONSOLE_URL } from './lib/console-url-resolv
31
33
  // checkout `npx hs` installs the unrelated public package hs@2.0.1
32
34
  // ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
33
35
  import { hsCommand } from './lib/hs-command.mjs';
36
+ import {
37
+ complementarySide,
38
+ computeWindowPlacement,
39
+ getPrimaryWorkArea,
40
+ parsePlaceSide,
41
+ placementFlags,
42
+ } from './lib/window-placement.mjs';
34
43
  import { collectOccupiedFamilyKeysFromVault, parseSpecId } from '../vendor/heyspex-mcp/spec-id-lib.mjs';
35
44
 
36
45
  function defaultVaultRoot() {
@@ -107,6 +116,71 @@ async function resolveSpec(base, rawSpec, vaultRoot) {
107
116
  return { ok: false, kind: 'not_found', suggestions: suggestFamilyKeys(rawSpec, vaultRoot) };
108
117
  }
109
118
 
119
+ /**
120
+ * The solo surfaces `/solo/<surface>` mounts — mirror of the registry in
121
+ * spex-console/src/app/solo/[surface]/page.tsx. A first positional that is not
122
+ * one of these is read as a spec id, opened on the pipeline surface.
123
+ */
124
+ export const SOLO_SURFACES = new Set([
125
+ 'pipeline',
126
+ 'docs',
127
+ 'inspector',
128
+ 'whats-next',
129
+ 'captures',
130
+ 'session-log',
131
+ 'settings',
132
+ ]);
133
+
134
+ /**
135
+ * The view a bare `hs view` / `hs view <spec-id>` opens. There is no
136
+ * standalone pipeline window: the inspector already carries the Pipeline tab,
137
+ * so "pipeline" resolves to the inspector opened on that tab (owner ruling
138
+ * 2026-10-10) — see {@link INSPECTOR_TAB_ALIASES}.
139
+ */
140
+ export const DEFAULT_SPEC_SURFACE = 'pipeline';
141
+
142
+ /**
143
+ * Surface words that are really an inspector tab. `hs view pipeline <id>` and
144
+ * every older caller still work — they land on the inspector, that tab open.
145
+ */
146
+ export const INSPECTOR_TAB_ALIASES = { pipeline: 'pipeline' };
147
+
148
+ /**
149
+ * Canonicalise a typed spec token: `hlfj`, `hs-hlfj` and `HS-HLFJ` all become
150
+ * `HS-HLFJ`. A missing `HS-` prefix is added (the console's family-token
151
+ * fallback only matches `HS-<token>-…` prefixes, so a bare `hlfj` found
152
+ * nothing). Only a short key form (`HLFJ`, `HLFJ-AB12`, `HLFJ-TASK`) is
153
+ * upper-cased; a full basename keeps its lower-case slug so it still matches
154
+ * exactly.
155
+ */
156
+ export function normalizeSpecToken(raw) {
157
+ const token = String(raw ?? '').trim();
158
+ if (!token) return token;
159
+ const body = token.replace(/^hs-/i, '');
160
+ if (/^[a-z0-9]{4}(?:-[a-z0-9]{2,8}){0,2}$/i.test(body)) return `HS-${body.toUpperCase()}`;
161
+ return `HS-${body}`;
162
+ }
163
+
164
+ /**
165
+ * Split positionals into `{ surface, panel, spec }`. `hs view <surface> [spec]`
166
+ * keeps its surface; `hs view <spec>` and bare `hs view` open the inspector on
167
+ * its Pipeline tab, as does the `pipeline` word. The spec token is normalised.
168
+ */
169
+ export function resolveSurfaceAndSpec(positionals) {
170
+ let [surface = null, spec = null] = positionals;
171
+ if (surface && !SOLO_SURFACES.has(surface.toLowerCase()) && !spec) {
172
+ spec = surface;
173
+ surface = null;
174
+ }
175
+ surface = (surface ?? DEFAULT_SPEC_SURFACE).toLowerCase();
176
+ let panel = null;
177
+ if (INSPECTOR_TAB_ALIASES[surface]) {
178
+ panel = INSPECTOR_TAB_ALIASES[surface];
179
+ surface = 'inspector';
180
+ }
181
+ return { surface, panel, spec: spec ? normalizeSpecToken(spec) : null };
182
+ }
183
+
110
184
  /** Anchor sources the window can follow live. `pin` is the default behavior. */
111
185
  const FOLLOW_MODES = new Set(['pin', 'capture']);
112
186
 
@@ -117,11 +191,14 @@ export const DEFAULT_WINDOW_WIDTH = 510;
117
191
  * Surfaces that want a different opening width than the default.
118
192
  *
119
193
  * `inspector` is one narrow pane meant to sit beside a terminal — at 510 it
120
- * takes screen it does not need, and its one-pane layout is designed around
121
- * 350 ([[HS-LO2G-AQVI-TASK-one-pane-inspector-window-doc-takes-the]]).
194
+ * takes screen it does not need. It opens at 350 and grows leftward to the
195
+ * `docs` width when a doc pane opens
196
+ * ([[HS-I2YN-ST07-EPIC-two-pane-layout-pipeline-view-on-the]]).
122
197
  */
123
198
  export const SURFACE_WINDOW_WIDTH = {
124
199
  inspector: 350,
200
+ // Two panes: the doc, then the inspector rail (360) to its right.
201
+ docs: 960,
125
202
  };
126
203
 
127
204
  /**
@@ -139,8 +216,30 @@ export function resolveWindowWidth(surface, explicitWidth) {
139
216
  return SURFACE_WINDOW_WIDTH[surface] ?? DEFAULT_WINDOW_WIDTH;
140
217
  }
141
218
 
219
+ /**
220
+ * Which half of the primary monitor's work area the window takes.
221
+ *
222
+ * Explicit `--place` wins, then `HEYSPEX_VIEW_PLACE`, then the default: the
223
+ * two-pane capture view (`docs --follow capture`) sits on the RIGHT so the
224
+ * SpexCode agent window can take the left
225
+ * ([[HS-I2YN-ST07-EPIC-two-pane-layout-pipeline-view-on-the]]). Every other
226
+ * window keeps the OS's own placement (`none`), as before.
227
+ */
228
+ export function resolvePlaceSide({ place, surface, follow }, env = process.env) {
229
+ const explicit = parsePlaceSide(place);
230
+ if (explicit) return explicit;
231
+ const fromEnv = parsePlaceSide(env.HEYSPEX_VIEW_PLACE);
232
+ if (fromEnv) return fromEnv;
233
+ return surface === 'docs' && follow === 'capture' ? 'right' : 'none';
234
+ }
235
+
236
+ /** The side the paired agent (terminal) window should take for this view. */
237
+ export const agentSideFor = (viewSide) => complementarySide(viewSide);
238
+
142
239
  function parseArgs(argv) {
143
240
  const args = {
241
+ place: null,
242
+ printOnly: false,
144
243
  surface: null,
145
244
  spec: null,
146
245
  // null until `--width` is seen; resolved per-surface once the surface is known.
@@ -150,11 +249,23 @@ function parseArgs(argv) {
150
249
  follow: null,
151
250
  url: null,
152
251
  };
252
+ const positionals = [];
153
253
  for (let i = 0; i < argv.length; i++) {
154
254
  const a = argv[i];
155
255
  if (a === '--help' || a === '-h') return { help: true };
156
256
  if (a === '--width') { args.width = Number(argv[++i]) || args.width; continue; }
157
- if (a === '--height') { args.height = Number(argv[++i]) || args.height; continue; }
257
+ if (a === '--height') { args.height = Number(argv[++i]) || args.height; args.heightExplicit = true; continue; }
258
+ if (a === '--place') {
259
+ const side = parsePlaceSide(argv[++i]);
260
+ if (!side) {
261
+ process.stderr.write('hs view: --place expects left, right or none\n');
262
+ process.exitCode = 2;
263
+ return { help: true };
264
+ }
265
+ args.place = side;
266
+ continue;
267
+ }
268
+ if (a === '--print' || a === '--dry-run') { args.printOnly = true; continue; }
158
269
  if (a === '--browser') { args.browser = String(argv[++i] || 'edge'); continue; }
159
270
  if (a === '--url') {
160
271
  const raw = String(argv[++i] || '').trim();
@@ -183,10 +294,9 @@ function parseArgs(argv) {
183
294
  process.exitCode = 2;
184
295
  return { help: true };
185
296
  }
186
- if (!args.surface) args.surface = a;
187
- else if (!args.spec) args.spec = a;
297
+ positionals.push(a);
188
298
  }
189
- return args;
299
+ return { ...args, ...resolveSurfaceAndSpec(positionals) };
190
300
  }
191
301
 
192
302
  function usage() {
@@ -194,15 +304,25 @@ function usage() {
194
304
  [
195
305
  'hs view <surface> [spec-id] [--follow pin|capture] [--url https://…]',
196
306
  ' [--width N] [--height N] [--browser edge|chrome]',
307
+ ' [--place left|right|none] [--print]',
308
+ 'hs view [spec-id] # inspector on its Pipeline tab',
197
309
  '',
198
310
  'Open one console surface in a chromeless app-mode browser window.',
199
- 'Surfaces: pipeline, docs, inspector, whats-next, captures, session-log, settings',
311
+ 'Surfaces: inspector, docs, whats-next, captures, session-log, settings',
312
+ ' ("pipeline" = the inspector opened on its Pipeline tab; there is no',
313
+ ' standalone pipeline window.)',
200
314
  ' (registry: src/app/solo/[surface]/page.tsx).',
201
- 'Without a spec-id the pipeline and docs views follow the focus pin live.',
315
+ 'Spec ids are forgiving: hlfj, hs-hlfj and HS-HLFJ are the same spec.',
316
+ 'Without a spec-id the inspector and docs views follow the focus pin live.',
202
317
  '--follow capture tracks the capture-on-deck pin instead, so the window',
203
318
  ' re-anchors itself onto each new capture rather than opening another.',
204
319
  '--url opens against a named console (a hosted one) instead of probing for',
205
320
  ' a local dev server — the cloud-relay path the desktop companion uses.',
321
+ '--place pins the window to the left or right half of the primary monitor',
322
+ ' work area (taskbar excluded; never another monitor). Default: right for',
323
+ ' `docs --follow capture` (the two-pane capture view, leaving the left half',
324
+ ' for the SpexCode agent window), none otherwise. Env: HEYSPEX_VIEW_PLACE.',
325
+ '--print shows the browser command line and does not open a window.',
206
326
  '',
207
327
  ].join('\n'),
208
328
  );
@@ -223,9 +343,25 @@ function isLocalConsoleUrl(url) {
223
343
  }
224
344
  }
225
345
 
226
- function launchAppWindow(url, { width, height, browser }) {
346
+ /**
347
+ * The exact command line a launch would run, as `{ file, args }`. Pure, so
348
+ * `--print` and the tests see precisely what `launchAppWindow` spawns.
349
+ */
350
+ export function buildLaunchCommand(url, { width, height, browser }, placement = null, platform = process.platform) {
351
+ const exe = browser === 'chrome' ? 'chrome' : 'msedge';
352
+ const flags = [`--app=${url}`, ...(placement ? placementFlags(placement) : [`--window-size=${width},${height}`])];
353
+ if (platform === 'win32') return { file: 'cmd.exe', args: ['/c', 'start', '', exe, ...flags] };
354
+ if (platform === 'darwin') {
355
+ const app = browser === 'chrome' ? 'Google Chrome' : 'Microsoft Edge';
356
+ return { file: 'open', args: ['-na', app, '--args', ...flags] };
357
+ }
358
+ return { file: browser === 'chrome' ? 'google-chrome' : 'microsoft-edge', args: flags };
359
+ }
360
+
361
+ function launchAppWindow(url, opts, placement = null) {
362
+ const { width, height, browser } = opts;
227
363
  const exe = browser === 'chrome' ? 'chrome' : 'msedge';
228
- const flags = [`--app=${url}`, `--window-size=${width},${height}`];
364
+ const flags = [`--app=${url}`, ...(placement ? placementFlags(placement) : [`--window-size=${width},${height}`])];
229
365
  let child;
230
366
  if (process.platform === 'win32') {
231
367
  // `start` resolves msedge/chrome via the App Paths registry; the empty ''
@@ -246,9 +382,8 @@ function launchAppWindow(url, { width, height, browser }) {
246
382
 
247
383
  async function main() {
248
384
  const args = parseArgs(process.argv.slice(2));
249
- if (args.help || !args.surface) {
385
+ if (args.help) {
250
386
  usage();
251
- if (!args.help) process.exitCode = 2;
252
387
  return;
253
388
  }
254
389
 
@@ -312,14 +447,42 @@ async function main() {
312
447
  const params = new URLSearchParams();
313
448
  if (args.spec) params.set('spec', args.spec);
314
449
  else if (args.follow) params.set('follow', args.follow);
450
+ if (args.panel) params.set('panel', args.panel);
315
451
  const search = params.toString();
316
452
  const query = search ? `?${search}` : '';
317
453
  const url = `${base}/solo/${encodeURIComponent(args.surface)}${query}`;
318
454
 
319
- launchAppWindow(url, args);
455
+ // Placement is best-effort: no work area (non-Windows, probe failure) means
456
+ // no position flags and today's behavior.
457
+ const side = resolvePlaceSide(args);
458
+ const placement =
459
+ side === 'none'
460
+ ? null
461
+ : computeWindowPlacement({
462
+ workArea: getPrimaryWorkArea(),
463
+ side,
464
+ width: args.width,
465
+ // Full work-area height unless --height was passed.
466
+ height: args.heightExplicit ? args.height : undefined,
467
+ });
468
+
469
+ if (args.printOnly) {
470
+ const cmd = buildLaunchCommand(url, args, placement);
471
+ process.stdout.write(
472
+ `hs view (--print, nothing opened): place=${side}${placement ? '' : ' (no placement)'}\n` +
473
+ ` ${cmd.file} ${cmd.args.join(' ')}\n` +
474
+ (placement ? ` agent window should take: ${agentSideFor(side)}\n` : ''),
475
+ );
476
+ return;
477
+ }
478
+
479
+ launchAppWindow(url, args, placement);
480
+ const size = placement
481
+ ? `${placement.width}x${placement.height} at ${placement.x},${placement.y}`
482
+ : `${args.width}x${args.height}`;
320
483
  process.stdout.write(
321
484
  `hs view: opened ${url} in an app-mode ${args.browser === 'chrome' ? 'Chrome' : 'Edge'} window ` +
322
- `(${args.width}x${args.height}). Close the window to close; re-run to reopen.\n`,
485
+ `(${size}). Close the window to close; re-run to reopen.\n`,
323
486
  );
324
487
  }
325
488
 
@@ -42,11 +42,13 @@ const SESSION_ACTIVITY_MARKER = 'scripts/claude-hooks/log-session-activity.mjs';
42
42
  // makes every hook exit 0 without the settings file changing). See
43
43
  // scripts/claude-hooks/_run.mjs and docs/claude-code-setup.md.
44
44
  const SHIM = 'scripts/claude-hooks/_run.mjs';
45
+ // Self-locating, env-independent, fail-open command (Copilot compatibility): scripts/lib/customer-kit.mjs buildBootstrap().
46
+ import { PLATFORM_RUNNER_PREFIX } from './lib/customer-kit.mjs';
45
47
 
46
48
  /** Canonical command string for a hook: `node "$CLAUDE_PROJECT_DIR/_run.mjs" <name> [args]`. */
47
49
  function shimCommand(scriptMarker, args = '') {
48
50
  const name = scriptMarker.replace('scripts/claude-hooks/', '').replace(/\.mjs$/, '');
49
- return `node "$CLAUDE_PROJECT_DIR/${SHIM}" ${name}${args}`;
51
+ return `${PLATFORM_RUNNER_PREFIX} ${name}${args}`;
50
52
  }
51
53
 
52
54
  /**