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
@@ -1,356 +1,386 @@
1
- #!/usr/bin/env node
2
- /**
3
- * hs-source-push.mjs — `hs source push`: credential-hang prevention and
4
- * destructive-verb refusal (HS-XFB3-CWPX, epic
5
- * [[HS-Q0K8-CWPX-EPIC-spexsource-local-git-preflight-commit]]).
6
- *
7
- * Fixes the exact failure mode in
8
- * [[specs/memory/lessons/an-agent-shell-git-push-hangs-on-the.md]]: `git
9
- * push` over HTTPS on Windows falling through to the invisible GUI
10
- * credential-manager prompt and hanging forever with no output. The fix is
11
- * BOTH of: (1) substituting `gh`'s own credential helper
12
- * (`-c credential.helper= -c "credential.helper=!gh auth git-credential"`)
13
- * and (2) `GIT_TERMINAL_PROMPT=0` in the child env — the lesson is explicit
14
- * that #2 alone does not stop the GUI-prompt path, so both are always
15
- * applied together, on every invocation, unconditionally.
16
- *
17
- * Force-push and protected-ref refusal DEFERS to the existing destructive-git
18
- * guardrail (`classifyGitDestructive` in
19
- * spexharness/src/gates/git-stash-gate.decide.mjs — relocated out of
20
- * scripts/claude-hooks/git-stash-gate.mjs by HS-IQBF; that hook script is now
21
- * a generated shell over the spexharness/src/gates/git-stash-gate.mjs port)
22
- * and the protected-ref list already used by scripts/hs-night-branch.mjs —
23
- * this module does not ship a second, competing allowlist.
24
- *
25
- * Both of those modules (plus git-stash-gate.decide.mjs's own hook-chain
26
- * dependencies) are dev-only — never in the package's `files`
27
- * (RUNTIME_FILES / `package.json`) — so a static import of either one crashed
28
- * EVERY `hs source push` invocation with a raw `ERR_MODULE_NOT_FOUND` on a
29
- * published npm install. They are loaded via a guarded dynamic import instead
30
- * (see `loadDestructiveGitGuard` below); `pushBranch` needs them on every
31
- * call (the force-push/protected-ref check is not behind an opt-in flag), so
32
- * a resolution failure refuses the ENTIRE push — zero `git push` subprocess
33
- * calls — with a clear message, never a stack trace.
34
- */
35
- import { spawn, execFileSync } from 'node:child_process';
36
- import path from 'node:path';
37
- import { fileURLToPath } from 'node:url';
38
-
39
- import { scanPaths } from './hs-source-secret-scan.mjs';
40
-
41
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
- export const REPO_ROOT = path.resolve(__dirname, '..', '..');
43
-
44
- export const DEFAULT_TIMEOUT_MS = 60_000;
45
-
46
- export const DESTRUCTIVE_GIT_GUARD_UNAVAILABLE_MESSAGE =
47
- 'hs source push: refusing — the destructive-git safety check ' +
48
- '(scripts/hs-night-branch.mjs, scripts/claude-hooks/git-stash-gate.mjs) is not available on ' +
49
- 'this install. Those modules ship only inside a full source checkout of the HeySpex repo, not ' +
50
- 'a published npm install, and `hs source push` requires them to classify force-push / ' +
51
- 'protected-ref requests before it ever touches git — so it refuses rather than pushing ' +
52
- 'without that check.';
53
-
54
- let destructiveGitGuardPromise;
55
-
56
- /**
57
- * Guarded, on-demand, cached load of the two dev-only modules `pushBranch`
58
- * depends on for every call. Never throws — a resolution failure (module
59
- * absent, as on a published install) comes back as `{ ok: false }` so the
60
- * caller can refuse gracefully instead of letting `ERR_MODULE_NOT_FOUND`
61
- * propagate as an unhandled rejection / stack trace.
62
- * @returns {Promise<{ ok: true, PROTECTED_REFS: string[], classifyGitDestructive: Function } | { ok: false, error: unknown }>}
63
- */
64
- export function loadDestructiveGitGuard() {
65
- if (!destructiveGitGuardPromise) {
66
- destructiveGitGuardPromise = Promise.all([
67
- import('../hs-night-branch.mjs'),
68
- import('../../spexharness/src/gates/git-stash-gate.decide.mjs'),
69
- ]).then(
70
- ([nightBranch, stashGate]) => ({
71
- ok: true,
72
- PROTECTED_REFS: nightBranch.PROTECTED_REFS,
73
- classifyGitDestructive: stashGate.classifyGitDestructive,
74
- }),
75
- (error) => ({ ok: false, error }),
76
- );
77
- }
78
- return destructiveGitGuardPromise;
79
- }
80
-
81
- /**
82
- * Pushes `branch` to `remote` through the credential-hang-safe invocation,
83
- * refusing outright (zero subprocess calls) on a force-push flag or a
84
- * protected-ref target, and killing the child if it exceeds `timeoutMs`.
85
- *
86
- * @param {{
87
- * remote?: string,
88
- * branch: string,
89
- * extraArgs?: string[],
90
- * timeoutMs?: number,
91
- * cwd?: string,
92
- * spawnFn?: typeof spawn,
93
- * preScan?: (ctx: { remote: string, branch: string, cwd: string }) =>
94
- * ({ ok: boolean, message?: string } | null | undefined),
95
- * }} opts
96
- * @returns {Promise<{ ok: boolean, code: string, message?: string }>}
97
- */
98
- export async function pushBranch({
99
- remote = 'origin',
100
- branch,
101
- extraArgs = [],
102
- timeoutMs = DEFAULT_TIMEOUT_MS,
103
- cwd = process.cwd(),
104
- spawnFn = spawn,
105
- preScan = null,
106
- } = {}) {
107
- if (!branch || typeof branch !== 'string' || branch.trim() === '') {
108
- return {
109
- ok: false,
110
- code: 'NO_BRANCH',
111
- message: 'hs source push: a branch name is required (e.g. `hs source push origin my-branch`).',
112
- };
113
- }
114
-
115
- // ---- destructive-git guard load — every push needs it, not just an
116
- // opt-in flag, so a resolution failure (module absent, as on a published
117
- // install) refuses the entire push before any git subprocess is spawned ----
118
- const guard = await loadDestructiveGitGuard();
119
- if (!guard.ok) {
120
- return { ok: false, code: 'PACKAGE_INCOMPLETE', message: DESTRUCTIVE_GIT_GUARD_UNAVAILABLE_MESSAGE };
121
- }
122
- const { PROTECTED_REFS, classifyGitDestructive } = guard;
123
-
124
- // ---- protected-ref refusal — reuses hs-night-branch.mjs's own list ----
125
- if (PROTECTED_REFS.includes(branch)) {
126
- return {
127
- ok: false,
128
- code: 'PROTECTED_REF',
129
- message: `hs source push: refusing to push to protected ref \`${branch}\` (${PROTECTED_REFS.join(', ')}). This is never allowed through hs source push.`,
130
- };
131
- }
132
-
133
- // ---- force-push refusal — defers to the destructive-git guardrail's
134
- // own classifier rather than a second, competing allowlist ----
135
- const syntheticCommand = ['git', 'push', ...extraArgs, remote, branch].join(' ');
136
- if (classifyGitDestructive('Bash', { command: syntheticCommand }) === 'gated') {
137
- return {
138
- ok: false,
139
- code: 'FORCE_REFUSED',
140
- message:
141
- 'hs source push: refusing — this invocation is classified as a gated destructive git action (force-push or similar) by the existing destructive-git guardrail. Force-push is never allowed through hs source push; use the guardrail\'s own owner-authorized path if this is truly intended.',
142
- };
143
- }
144
-
145
- // ---- secret-scan extension point (implemented by sibling task
146
- // HS-5NXX-CWPX; inert here — a caller that passes no preScan gets no
147
- // scanning, exactly today's behavior) ----
148
- if (typeof preScan === 'function') {
149
- let scanResult;
150
- try {
151
- scanResult = preScan({ remote, branch, cwd });
152
- } catch (err) {
153
- return {
154
- ok: false,
155
- code: 'PRESCAN_ERROR',
156
- message: `hs source push: the preScan hook threw — ${err instanceof Error ? err.message : String(err)}`,
157
- };
158
- }
159
- if (scanResult && scanResult.ok === false) {
160
- return {
161
- ok: false,
162
- code: 'PRESCAN_BLOCKED',
163
- message: scanResult.message || 'hs source push: blocked by preScan (likely secret detected).',
164
- };
165
- }
166
- }
167
-
168
- return new Promise((resolve) => {
169
- // ---- the actual push: credential-helper override + GIT_TERMINAL_PROMPT=0,
170
- // both together, unconditionally, per the recorded lesson ----
171
- const argv = [
172
- '-c',
173
- 'credential.helper=',
174
- '-c',
175
- 'credential.helper=!gh auth git-credential',
176
- 'push',
177
- ...extraArgs,
178
- remote,
179
- branch,
180
- ];
181
- const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
182
-
183
- let settled = false;
184
- let timedOut = false;
185
-
186
- const child = spawnFn('git', argv, { cwd, env, stdio: 'inherit' });
187
-
188
- // Deliberately NOT unref'd: the timer is always cleared the moment the
189
- // child exits/errors (see finish() below), so it never outlives the
190
- // child in practice — and staying ref'd is what lets a hung child
191
- // actually be observed and killed rather than letting the process (or
192
- // a test's event loop) end early while the timer is still outstanding.
193
- const timer = setTimeout(() => {
194
- timedOut = true;
195
- try {
196
- child.kill();
197
- } catch {
198
- /* best effort — the exit/error handler below still resolves */
199
- }
200
- }, timeoutMs);
201
-
202
- const finish = (result) => {
203
- if (settled) return;
204
- settled = true;
205
- clearTimeout(timer);
206
- resolve(result);
207
- };
208
-
209
- child.on('error', (err) => {
210
- finish({ ok: false, code: 'SPAWN_ERROR', message: `hs source push: failed to run git — ${err.message}` });
211
- });
212
-
213
- child.on('exit', (code, signal) => {
214
- if (timedOut) {
215
- finish({
216
- ok: false,
217
- code: 'TIMEOUT',
218
- message: `hs source push: push exceeded the ${timeoutMs}ms wall-clock timeout and was killed. This is a HUNG PUSH (most likely the Windows credential-manager GUI prompt — [[specs/memory/lessons/an-agent-shell-git-push-hangs-on-the.md]]), not a normal git error. Re-check \`gh auth status\` and retry.`,
219
- });
220
- return;
221
- }
222
- if (code === 0) {
223
- finish({ ok: true, code: 'OK' });
224
- return;
225
- }
226
- finish({
227
- ok: false,
228
- code: 'GIT_EXIT_NONZERO',
229
- message: `hs source push: git push exited with code ${code}${signal ? ` (signal ${signal})` : ''}.`,
230
- });
231
- });
232
- });
233
- }
234
-
235
- /**
236
- * Lists the paths touched by the commit(s) this push is about to send —
237
- * the diff between the remote-tracking ref and HEAD when one exists, or
238
- * just HEAD's own files for a branch never pushed before. Read-only: only
239
- * `git rev-parse`/`git diff`/`git show --name-only` are ever run. Injectable
240
- * `run` so tests never touch a real repo.
241
- * @param {{ remote: string, branch: string, cwd: string, run?: (cmd:string,args:string[],opts:object)=>{status:number,stdout?:string} }} args
242
- * @returns {string[]}
243
- */
244
- export function pathsInOutgoingCommits({ remote, branch, cwd, run = defaultDiffRun }) {
245
- const remoteRef = `${remote}/${branch}`;
246
- const check = run('git', ['rev-parse', '--verify', '--quiet', remoteRef], { cwd });
247
- const hasRemoteRef = check.status === 0;
248
- const diffArgs = hasRemoteRef
249
- ? ['diff', '--name-only', `${remoteRef}..HEAD`]
250
- : ['show', '--name-only', '--pretty=format:', 'HEAD'];
251
- const result = run('git', diffArgs, { cwd });
252
- if (result.status !== 0) return [];
253
- return String(result.stdout ?? '')
254
- .split(/\r?\n/)
255
- .map((l) => l.trim())
256
- .filter(Boolean);
257
- }
258
-
259
- function defaultDiffRun(cmd, args, opts = {}) {
260
- try {
261
- const stdout = execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
262
- return { status: 0, stdout };
263
- } catch (err) {
264
- return { status: typeof err.status === 'number' ? err.status : 1, stdout: err.stdout ? String(err.stdout) : '' };
265
- }
266
- }
267
-
268
- /**
269
- * CLI entry for `hs source push`. Parses `[remote] <branch> [--force] [-f]
270
- * [--force-with-lease] [--timeout <ms>]` from argv. Any force flag is
271
- * forwarded into extraArgs so the guardrail classifier sees it and refuses —
272
- * never silently dropped.
273
- *
274
- * Wires the secret-scan gate (HS-5NXX-CWPX) as the `preScan` passed into
275
- * `pushBranch`: it lists the paths in the commits about to be pushed and
276
- * scans them, blocking the push (with zero `git push` subprocess calls) on
277
- * any finding.
278
- *
279
- * @param {string[]} argv (verb-stripped: args after `push`)
280
- * @param {{
281
- * cwd?: string,
282
- * pushBranchFn?: typeof pushBranch,
283
- * scanPathsFn?: typeof scanPaths,
284
- * listOutgoingPaths?: typeof pathsInOutgoingCommits,
285
- * }} [opts]
286
- * @returns {Promise<number>} process exit code
287
- */
288
- export async function runPushCommand(
289
- argv,
290
- { cwd = process.cwd(), pushBranchFn = pushBranch, scanPathsFn = scanPaths, listOutgoingPaths = pathsInOutgoingCommits } = {},
291
- ) {
292
- const positional = [];
293
- const extraArgs = [];
294
- let timeoutMs = DEFAULT_TIMEOUT_MS;
295
-
296
- for (let i = 0; i < argv.length; i++) {
297
- const a = argv[i];
298
- if (a === '--timeout' && argv[i + 1] !== undefined) {
299
- const parsed = Number(argv[i + 1]);
300
- if (Number.isFinite(parsed) && parsed > 0) timeoutMs = parsed;
301
- i++;
302
- continue;
303
- }
304
- if (a === '--force' || a === '-f' || a === '--force-with-lease' || a === '--delete') {
305
- extraArgs.push(a);
306
- continue;
307
- }
308
- positional.push(a);
309
- }
310
-
311
- const remote = positional.length > 1 ? positional[0] : 'origin';
312
- const branch = positional.length > 1 ? positional[1] : positional[0];
313
-
314
- const preScan = ({ remote: r, branch: b, cwd: c }) => {
315
- let paths;
316
- try {
317
- paths = listOutgoingPaths({ remote: r, branch: b, cwd: c });
318
- } catch {
319
- // Read-only listing failed (e.g. no commits yet) — nothing to scan,
320
- // never block the push on a listing failure.
321
- return { ok: true };
322
- }
323
- const findings = scanPathsFn(paths, { cwd: c });
324
- if (findings.length > 0) {
325
- const names = findings.map((f) => `${f.path} (${f.rule})`).join(', ');
326
- return {
327
- ok: false,
328
- message: `hs source push: secret scan blocked the push — likely secret(s) found: ${names}`,
329
- };
330
- }
331
- return { ok: true };
332
- };
333
-
334
- const result = await pushBranchFn({ remote, branch, extraArgs, timeoutMs, cwd, preScan });
335
- if (!result.ok) {
336
- console.error(result.message);
337
- return 1;
338
- }
339
- console.log(`hs source push: pushed ${remote}/${branch}.`);
340
- return 0;
341
- }
342
-
343
- // ---- direct-invocation entry --------------------------------------------
344
-
345
- function invokedDirectly() {
346
- try {
347
- return path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1] ?? '');
348
- } catch {
349
- return false;
350
- }
351
- }
352
-
353
- if (invokedDirectly()) {
354
- // argv: [node, script, "push", ...flags] — drop the leading verb token.
355
- runPushCommand(process.argv.slice(3)).then((code) => process.exit(code));
356
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hs-source-push.mjs — `hs source push`: credential-hang prevention and
4
+ * destructive-verb refusal (HS-XFB3-CWPX, epic
5
+ * [[HS-Q0K8-CWPX-EPIC-spexsource-local-git-preflight-commit]]).
6
+ *
7
+ * Fixes the exact failure mode in
8
+ * [[specs/memory/lessons/an-agent-shell-git-push-hangs-on-the.md]]: `git
9
+ * push` over HTTPS on Windows falling through to the invisible GUI
10
+ * credential-manager prompt and hanging forever with no output. The fix is
11
+ * BOTH of: (1) substituting `gh`'s own credential helper
12
+ * (`-c credential.helper= -c "credential.helper=!gh auth git-credential"`)
13
+ * and (2) `GIT_TERMINAL_PROMPT=0` in the child env — the lesson is explicit
14
+ * that #2 alone does not stop the GUI-prompt path, so both are always
15
+ * applied together, on every invocation, unconditionally.
16
+ *
17
+ * Force-push and protected-ref refusal DEFERS to the existing destructive-git
18
+ * guardrail (`classifyGitDestructive` in
19
+ * spexharness/src/gates/git-stash-gate.decide.mjs — relocated out of
20
+ * scripts/claude-hooks/git-stash-gate.mjs by HS-IQBF; that hook script is now
21
+ * a generated shell over the spexharness/src/gates/git-stash-gate.mjs port)
22
+ * and the protected-ref list already used by scripts/hs-night-branch.mjs —
23
+ * this module does not ship a second, competing allowlist.
24
+ *
25
+ * Both of those modules (plus git-stash-gate.decide.mjs's own hook-chain
26
+ * dependencies) are dev-only — never in the package's `files`
27
+ * (RUNTIME_FILES / `package.json`) — so a static import of either one crashed
28
+ * EVERY `hs source push` invocation with a raw `ERR_MODULE_NOT_FOUND` on a
29
+ * published npm install. They are loaded via a guarded dynamic import instead
30
+ * (see `loadDestructiveGitGuard` below); `pushBranch` needs them on every
31
+ * call (the force-push/protected-ref check is not behind an opt-in flag), so
32
+ * a resolution failure refuses the ENTIRE push — zero `git push` subprocess
33
+ * calls — with a clear message, never a stack trace.
34
+ */
35
+ import { spawn, execFileSync } from 'node:child_process';
36
+ import path from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+
39
+ import { scanPaths } from './hs-source-secret-scan.mjs';
40
+
41
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
+ export const REPO_ROOT = path.resolve(__dirname, '..', '..');
43
+
44
+ export const DEFAULT_TIMEOUT_MS = 60_000;
45
+
46
+ export const DESTRUCTIVE_GIT_GUARD_UNAVAILABLE_MESSAGE =
47
+ 'hs source push: refusing — the destructive-git safety check ' +
48
+ '(scripts/hs-night-branch.mjs, scripts/claude-hooks/git-stash-gate.mjs) is not available on ' +
49
+ 'this install. Those modules ship only inside a full source checkout of the HeySpex repo, not ' +
50
+ 'a published npm install, and `hs source push` requires them to classify force-push / ' +
51
+ 'protected-ref requests before it ever touches git — so it refuses rather than pushing ' +
52
+ 'without that check.';
53
+
54
+ let destructiveGitGuardPromise;
55
+
56
+ /**
57
+ * Guarded, on-demand, cached load of the two dev-only modules `pushBranch`
58
+ * depends on for every call. Never throws — a resolution failure (module
59
+ * absent, as on a published install) comes back as `{ ok: false }` so the
60
+ * caller can refuse gracefully instead of letting `ERR_MODULE_NOT_FOUND`
61
+ * propagate as an unhandled rejection / stack trace.
62
+ * @returns {Promise<{ ok: true, PROTECTED_REFS: string[], classifyGitDestructive: Function } | { ok: false, error: unknown }>}
63
+ */
64
+ export function loadDestructiveGitGuard() {
65
+ if (!destructiveGitGuardPromise) {
66
+ destructiveGitGuardPromise = Promise.all([
67
+ import('../hs-night-branch.mjs'),
68
+ import('../../spexharness/src/gates/git-stash-gate.decide.mjs'),
69
+ ]).then(
70
+ ([nightBranch, stashGate]) => ({
71
+ ok: true,
72
+ PROTECTED_REFS: nightBranch.PROTECTED_REFS,
73
+ classifyGitDestructive: stashGate.classifyGitDestructive,
74
+ }),
75
+ (error) => ({ ok: false, error }),
76
+ );
77
+ }
78
+ return destructiveGitGuardPromise;
79
+ }
80
+
81
+ /**
82
+ * Pushes `branch` to `remote` through the credential-hang-safe invocation,
83
+ * refusing outright (zero subprocess calls) on a force-push flag or a
84
+ * protected-ref target, and killing the child if it exceeds `timeoutMs`.
85
+ *
86
+ * @param {{
87
+ * remote?: string,
88
+ * branch: string,
89
+ * extraArgs?: string[],
90
+ * timeoutMs?: number,
91
+ * cwd?: string,
92
+ * spawnFn?: typeof spawn,
93
+ * preScan?: (ctx: { remote: string, branch: string, cwd: string }) =>
94
+ * ({ ok: boolean, message?: string } | null | undefined),
95
+ * }} opts
96
+ * @returns {Promise<{ ok: boolean, code: string, message?: string }>}
97
+ */
98
+ export async function pushBranch({
99
+ remote = 'origin',
100
+ branch,
101
+ extraArgs = [],
102
+ timeoutMs = DEFAULT_TIMEOUT_MS,
103
+ cwd = process.cwd(),
104
+ spawnFn = spawn,
105
+ preScan = null,
106
+ } = {}) {
107
+ if (!branch || typeof branch !== 'string' || branch.trim() === '') {
108
+ return {
109
+ ok: false,
110
+ code: 'NO_BRANCH',
111
+ message: 'hs source push: a branch name is required (e.g. `hs source push origin my-branch`).',
112
+ };
113
+ }
114
+
115
+ // ---- destructive-git guard load — every push needs it, not just an
116
+ // opt-in flag, so a resolution failure (module absent, as on a published
117
+ // install) refuses the entire push before any git subprocess is spawned ----
118
+ const guard = await loadDestructiveGitGuard();
119
+ if (!guard.ok) {
120
+ return { ok: false, code: 'PACKAGE_INCOMPLETE', message: DESTRUCTIVE_GIT_GUARD_UNAVAILABLE_MESSAGE };
121
+ }
122
+ const { PROTECTED_REFS, classifyGitDestructive } = guard;
123
+
124
+ // ---- protected-ref refusal — reuses hs-night-branch.mjs's own list ----
125
+ if (PROTECTED_REFS.includes(branch)) {
126
+ return {
127
+ ok: false,
128
+ code: 'PROTECTED_REF',
129
+ message: `hs source push: refusing to push to protected ref \`${branch}\` (${PROTECTED_REFS.join(', ')}). This is never allowed through hs source push.`,
130
+ };
131
+ }
132
+
133
+ // ---- force-push refusal — defers to the destructive-git guardrail's
134
+ // own classifier rather than a second, competing allowlist ----
135
+ const syntheticCommand = ['git', 'push', ...extraArgs, remote, branch].join(' ');
136
+ if (classifyGitDestructive('Bash', { command: syntheticCommand }) === 'gated') {
137
+ return {
138
+ ok: false,
139
+ code: 'FORCE_REFUSED',
140
+ message:
141
+ 'hs source push: refusing — this invocation is classified as a gated destructive git action (force-push or similar) by the existing destructive-git guardrail. Force-push is never allowed through hs source push; use the guardrail\'s own owner-authorized path if this is truly intended.',
142
+ };
143
+ }
144
+
145
+ // ---- secret-scan extension point (implemented by sibling task
146
+ // HS-5NXX-CWPX; inert here — a caller that passes no preScan gets no
147
+ // scanning, exactly today's behavior) ----
148
+ if (typeof preScan === 'function') {
149
+ let scanResult;
150
+ try {
151
+ scanResult = preScan({ remote, branch, cwd });
152
+ } catch (err) {
153
+ return {
154
+ ok: false,
155
+ code: 'PRESCAN_ERROR',
156
+ message: `hs source push: the preScan hook threw — ${err instanceof Error ? err.message : String(err)}`,
157
+ };
158
+ }
159
+ if (scanResult && scanResult.ok === false) {
160
+ return {
161
+ ok: false,
162
+ code: 'PRESCAN_BLOCKED',
163
+ message: scanResult.message || 'hs source push: blocked by preScan (likely secret detected).',
164
+ };
165
+ }
166
+ }
167
+
168
+ return new Promise((resolve) => {
169
+ // ---- the actual push: credential-helper override + GIT_TERMINAL_PROMPT=0,
170
+ // both together, unconditionally, per the recorded lesson ----
171
+ const argv = [
172
+ '-c',
173
+ 'credential.helper=',
174
+ '-c',
175
+ 'credential.helper=!gh auth git-credential',
176
+ 'push',
177
+ ...extraArgs,
178
+ remote,
179
+ branch,
180
+ ];
181
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
182
+
183
+ let settled = false;
184
+ let timedOut = false;
185
+
186
+ const child = spawnFn('git', argv, { cwd, env, stdio: 'inherit' });
187
+
188
+ // Deliberately NOT unref'd: the timer is always cleared the moment the
189
+ // child exits/errors (see finish() below), so it never outlives the
190
+ // child in practice — and staying ref'd is what lets a hung child
191
+ // actually be observed and killed rather than letting the process (or
192
+ // a test's event loop) end early while the timer is still outstanding.
193
+ const timer = setTimeout(() => {
194
+ timedOut = true;
195
+ try {
196
+ child.kill();
197
+ } catch {
198
+ /* best effort — the exit/error handler below still resolves */
199
+ }
200
+ }, timeoutMs);
201
+
202
+ const finish = (result) => {
203
+ if (settled) return;
204
+ settled = true;
205
+ clearTimeout(timer);
206
+ resolve(result);
207
+ };
208
+
209
+ child.on('error', (err) => {
210
+ finish({ ok: false, code: 'SPAWN_ERROR', message: `hs source push: failed to run git — ${err.message}` });
211
+ });
212
+
213
+ child.on('exit', (code, signal) => {
214
+ if (timedOut) {
215
+ finish({
216
+ ok: false,
217
+ code: 'TIMEOUT',
218
+ message: `hs source push: push exceeded the ${timeoutMs}ms wall-clock timeout and was killed. This is a HUNG PUSH (most likely the Windows credential-manager GUI prompt — [[specs/memory/lessons/an-agent-shell-git-push-hangs-on-the.md]]), not a normal git error. Re-check \`gh auth status\` and retry.`,
219
+ });
220
+ return;
221
+ }
222
+ if (code === 0) {
223
+ finish({ ok: true, code: 'OK' });
224
+ return;
225
+ }
226
+ finish({
227
+ ok: false,
228
+ code: 'GIT_EXIT_NONZERO',
229
+ message: `hs source push: git push exited with code ${code}${signal ? ` (signal ${signal})` : ''}.`,
230
+ });
231
+ });
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Lists the paths touched by the commit(s) this push is about to send —
237
+ * the diff between the remote-tracking ref and HEAD when one exists, or
238
+ * just HEAD's own files for a branch never pushed before. Read-only: only
239
+ * `git rev-parse`/`git diff`/`git show --name-only` are ever run. Injectable
240
+ * `run` so tests never touch a real repo.
241
+ * @param {{ remote: string, branch: string, cwd: string, run?: (cmd:string,args:string[],opts:object)=>{status:number,stdout?:string} }} args
242
+ * @returns {string[]}
243
+ */
244
+ export function pathsInOutgoingCommits({ remote, branch, cwd, run = defaultDiffRun }) {
245
+ const remoteRef = `${remote}/${branch}`;
246
+ const check = run('git', ['rev-parse', '--verify', '--quiet', remoteRef], { cwd });
247
+ const hasRemoteRef = check.status === 0;
248
+ const diffArgs = hasRemoteRef
249
+ ? ['diff', '--name-only', `${remoteRef}..HEAD`]
250
+ : ['show', '--name-only', '--pretty=format:', 'HEAD'];
251
+ const result = run('git', diffArgs, { cwd });
252
+ if (result.status !== 0) return [];
253
+ return String(result.stdout ?? '')
254
+ .split(/\r?\n/)
255
+ .map((l) => l.trim())
256
+ .filter(Boolean);
257
+ }
258
+
259
+ function defaultDiffRun(cmd, args, opts = {}) {
260
+ try {
261
+ const stdout = execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
262
+ return { status: 0, stdout };
263
+ } catch (err) {
264
+ return { status: typeof err.status === 'number' ? err.status : 1, stdout: err.stdout ? String(err.stdout) : '' };
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Default no-branch resolver. Imported lazily so the (console-HTTP) resolver
270
+ * module graph loads ONLY when no branch was typed — an explicit-branch push
271
+ * keeps its original, minimal import surface.
272
+ */
273
+ async function lazyResolvePushBranch(opts) {
274
+ const { resolvePushBranch } = await import('./hs-source-branch.mjs');
275
+ return resolvePushBranch(opts);
276
+ }
277
+
278
+ /**
279
+ * CLI entry for `hs source push`. Parses `[remote] <branch> [--force] [-f]
280
+ * [--force-with-lease] [--timeout <ms>]` from argv. Any force flag is
281
+ * forwarded into extraArgs so the guardrail classifier sees it and refuses —
282
+ * never silently dropped.
283
+ *
284
+ * Wires the secret-scan gate (HS-5NXX-CWPX) as the `preScan` passed into
285
+ * `pushBranch`: it lists the paths in the commits about to be pushed and
286
+ * scans them, blocking the push (with zero `git push` subprocess calls) on
287
+ * any finding.
288
+ *
289
+ * @param {string[]} argv (verb-stripped: args after `push`)
290
+ * @param {{
291
+ * cwd?: string,
292
+ * pushBranchFn?: typeof pushBranch,
293
+ * scanPathsFn?: typeof scanPaths,
294
+ * listOutgoingPaths?: typeof pathsInOutgoingCommits,
295
+ * }} [opts]
296
+ * @returns {Promise<number>} process exit code
297
+ */
298
+ export async function runPushCommand(
299
+ argv,
300
+ {
301
+ cwd = process.cwd(),
302
+ pushBranchFn = pushBranch,
303
+ scanPathsFn = scanPaths,
304
+ listOutgoingPaths = pathsInOutgoingCommits,
305
+ resolvePushBranchFn = lazyResolvePushBranch,
306
+ } = {},
307
+ ) {
308
+ const positional = [];
309
+ const extraArgs = [];
310
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
311
+
312
+ for (let i = 0; i < argv.length; i++) {
313
+ const a = argv[i];
314
+ if (a === '--timeout' && argv[i + 1] !== undefined) {
315
+ const parsed = Number(argv[i + 1]);
316
+ if (Number.isFinite(parsed) && parsed > 0) timeoutMs = parsed;
317
+ i++;
318
+ continue;
319
+ }
320
+ if (a === '--force' || a === '-f' || a === '--force-with-lease' || a === '--delete') {
321
+ extraArgs.push(a);
322
+ continue;
323
+ }
324
+ positional.push(a);
325
+ }
326
+
327
+ const remote = positional.length > 1 ? positional[0] : 'origin';
328
+ let branch = positional.length > 1 ? positional[1] : positional[0];
329
+
330
+ // No branch typed: ask the ONE shared branch resolver (the same function the
331
+ // cloud-write drain calls) via the console; offline it falls back to the
332
+ // checked-out branch and says so. An explicit branch is never second-guessed.
333
+ if (!branch) {
334
+ const resolved = await resolvePushBranchFn({ cwd });
335
+ if (!resolved.branch) {
336
+ console.error(`hs source push: ${resolved.note}. Pass a branch explicitly.`);
337
+ return 1;
338
+ }
339
+ if (resolved.note) console.error(`hs source push: ${resolved.note}.`);
340
+ else console.log(`hs source push: target branch \`${resolved.branch}\` (${resolved.source === 'session' ? 'declared session branch' : 'resolver fallback'}).`);
341
+ branch = resolved.branch;
342
+ }
343
+
344
+ const preScan = ({ remote: r, branch: b, cwd: c }) => {
345
+ let paths;
346
+ try {
347
+ paths = listOutgoingPaths({ remote: r, branch: b, cwd: c });
348
+ } catch {
349
+ // Read-only listing failed (e.g. no commits yet) — nothing to scan,
350
+ // never block the push on a listing failure.
351
+ return { ok: true };
352
+ }
353
+ const findings = scanPathsFn(paths, { cwd: c });
354
+ if (findings.length > 0) {
355
+ const names = findings.map((f) => `${f.path} (${f.rule})`).join(', ');
356
+ return {
357
+ ok: false,
358
+ message: `hs source push: secret scan blocked the push — likely secret(s) found: ${names}`,
359
+ };
360
+ }
361
+ return { ok: true };
362
+ };
363
+
364
+ const result = await pushBranchFn({ remote, branch, extraArgs, timeoutMs, cwd, preScan });
365
+ if (!result.ok) {
366
+ console.error(result.message);
367
+ return 1;
368
+ }
369
+ console.log(`hs source push: pushed ${remote}/${branch}.`);
370
+ return 0;
371
+ }
372
+
373
+ // ---- direct-invocation entry --------------------------------------------
374
+
375
+ function invokedDirectly() {
376
+ try {
377
+ return path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1] ?? '');
378
+ } catch {
379
+ return false;
380
+ }
381
+ }
382
+
383
+ if (invokedDirectly()) {
384
+ // argv: [node, script, "push", ...flags] — drop the leading verb token.
385
+ runPushCommand(process.argv.slice(3)).then((code) => process.exit(code));
386
+ }