heyspex 0.1.0

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 (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,307 @@
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
+ * scripts/claude-hooks/git-stash-gate.mjs) and the protected-ref list already
20
+ * used by scripts/hs-night-branch.mjs — this module does not ship a second,
21
+ * competing allowlist.
22
+ */
23
+ import { spawn, execFileSync } from 'node:child_process';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ import { PROTECTED_REFS } from '../hs-night-branch.mjs';
28
+ import { classifyGitDestructive } from '../claude-hooks/git-stash-gate.mjs';
29
+ import { scanPaths } from './hs-source-secret-scan.mjs';
30
+
31
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
+ export const REPO_ROOT = path.resolve(__dirname, '..', '..');
33
+
34
+ export const DEFAULT_TIMEOUT_MS = 60_000;
35
+
36
+ /**
37
+ * Pushes `branch` to `remote` through the credential-hang-safe invocation,
38
+ * refusing outright (zero subprocess calls) on a force-push flag or a
39
+ * protected-ref target, and killing the child if it exceeds `timeoutMs`.
40
+ *
41
+ * @param {{
42
+ * remote?: string,
43
+ * branch: string,
44
+ * extraArgs?: string[],
45
+ * timeoutMs?: number,
46
+ * cwd?: string,
47
+ * spawnFn?: typeof spawn,
48
+ * preScan?: (ctx: { remote: string, branch: string, cwd: string }) =>
49
+ * ({ ok: boolean, message?: string } | null | undefined),
50
+ * }} opts
51
+ * @returns {Promise<{ ok: boolean, code: string, message?: string }>}
52
+ */
53
+ export function pushBranch({
54
+ remote = 'origin',
55
+ branch,
56
+ extraArgs = [],
57
+ timeoutMs = DEFAULT_TIMEOUT_MS,
58
+ cwd = process.cwd(),
59
+ spawnFn = spawn,
60
+ preScan = null,
61
+ } = {}) {
62
+ return new Promise((resolve) => {
63
+ if (!branch || typeof branch !== 'string' || branch.trim() === '') {
64
+ resolve({
65
+ ok: false,
66
+ code: 'NO_BRANCH',
67
+ message: 'hs source push: a branch name is required (e.g. `hs source push origin my-branch`).',
68
+ });
69
+ return;
70
+ }
71
+
72
+ // ---- protected-ref refusal — reuses hs-night-branch.mjs's own list ----
73
+ if (PROTECTED_REFS.includes(branch)) {
74
+ resolve({
75
+ ok: false,
76
+ code: 'PROTECTED_REF',
77
+ message: `hs source push: refusing to push to protected ref \`${branch}\` (${PROTECTED_REFS.join(', ')}). This is never allowed through hs source push.`,
78
+ });
79
+ return;
80
+ }
81
+
82
+ // ---- force-push refusal — defers to the destructive-git guardrail's
83
+ // own classifier rather than a second, competing allowlist ----
84
+ const syntheticCommand = ['git', 'push', ...extraArgs, remote, branch].join(' ');
85
+ if (classifyGitDestructive('Bash', { command: syntheticCommand }) === 'gated') {
86
+ resolve({
87
+ ok: false,
88
+ code: 'FORCE_REFUSED',
89
+ message:
90
+ '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.',
91
+ });
92
+ return;
93
+ }
94
+
95
+ // ---- secret-scan extension point (implemented by sibling task
96
+ // HS-5NXX-CWPX; inert here — a caller that passes no preScan gets no
97
+ // scanning, exactly today's behavior) ----
98
+ if (typeof preScan === 'function') {
99
+ let scanResult;
100
+ try {
101
+ scanResult = preScan({ remote, branch, cwd });
102
+ } catch (err) {
103
+ resolve({
104
+ ok: false,
105
+ code: 'PRESCAN_ERROR',
106
+ message: `hs source push: the preScan hook threw — ${err instanceof Error ? err.message : String(err)}`,
107
+ });
108
+ return;
109
+ }
110
+ if (scanResult && scanResult.ok === false) {
111
+ resolve({
112
+ ok: false,
113
+ code: 'PRESCAN_BLOCKED',
114
+ message: scanResult.message || 'hs source push: blocked by preScan (likely secret detected).',
115
+ });
116
+ return;
117
+ }
118
+ }
119
+
120
+ // ---- the actual push: credential-helper override + GIT_TERMINAL_PROMPT=0,
121
+ // both together, unconditionally, per the recorded lesson ----
122
+ const argv = [
123
+ '-c',
124
+ 'credential.helper=',
125
+ '-c',
126
+ 'credential.helper=!gh auth git-credential',
127
+ 'push',
128
+ ...extraArgs,
129
+ remote,
130
+ branch,
131
+ ];
132
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
133
+
134
+ let settled = false;
135
+ let timedOut = false;
136
+
137
+ const child = spawnFn('git', argv, { cwd, env, stdio: 'inherit' });
138
+
139
+ // Deliberately NOT unref'd: the timer is always cleared the moment the
140
+ // child exits/errors (see finish() below), so it never outlives the
141
+ // child in practice — and staying ref'd is what lets a hung child
142
+ // actually be observed and killed rather than letting the process (or
143
+ // a test's event loop) end early while the timer is still outstanding.
144
+ const timer = setTimeout(() => {
145
+ timedOut = true;
146
+ try {
147
+ child.kill();
148
+ } catch {
149
+ /* best effort — the exit/error handler below still resolves */
150
+ }
151
+ }, timeoutMs);
152
+
153
+ const finish = (result) => {
154
+ if (settled) return;
155
+ settled = true;
156
+ clearTimeout(timer);
157
+ resolve(result);
158
+ };
159
+
160
+ child.on('error', (err) => {
161
+ finish({ ok: false, code: 'SPAWN_ERROR', message: `hs source push: failed to run git — ${err.message}` });
162
+ });
163
+
164
+ child.on('exit', (code, signal) => {
165
+ if (timedOut) {
166
+ finish({
167
+ ok: false,
168
+ code: 'TIMEOUT',
169
+ 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.`,
170
+ });
171
+ return;
172
+ }
173
+ if (code === 0) {
174
+ finish({ ok: true, code: 'OK' });
175
+ return;
176
+ }
177
+ finish({
178
+ ok: false,
179
+ code: 'GIT_EXIT_NONZERO',
180
+ message: `hs source push: git push exited with code ${code}${signal ? ` (signal ${signal})` : ''}.`,
181
+ });
182
+ });
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Lists the paths touched by the commit(s) this push is about to send —
188
+ * the diff between the remote-tracking ref and HEAD when one exists, or
189
+ * just HEAD's own files for a branch never pushed before. Read-only: only
190
+ * `git rev-parse`/`git diff`/`git show --name-only` are ever run. Injectable
191
+ * `run` so tests never touch a real repo.
192
+ * @param {{ remote: string, branch: string, cwd: string, run?: (cmd:string,args:string[],opts:object)=>{status:number,stdout?:string} }} args
193
+ * @returns {string[]}
194
+ */
195
+ export function pathsInOutgoingCommits({ remote, branch, cwd, run = defaultDiffRun }) {
196
+ const remoteRef = `${remote}/${branch}`;
197
+ const check = run('git', ['rev-parse', '--verify', '--quiet', remoteRef], { cwd });
198
+ const hasRemoteRef = check.status === 0;
199
+ const diffArgs = hasRemoteRef
200
+ ? ['diff', '--name-only', `${remoteRef}..HEAD`]
201
+ : ['show', '--name-only', '--pretty=format:', 'HEAD'];
202
+ const result = run('git', diffArgs, { cwd });
203
+ if (result.status !== 0) return [];
204
+ return String(result.stdout ?? '')
205
+ .split(/\r?\n/)
206
+ .map((l) => l.trim())
207
+ .filter(Boolean);
208
+ }
209
+
210
+ function defaultDiffRun(cmd, args, opts = {}) {
211
+ try {
212
+ const stdout = execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
213
+ return { status: 0, stdout };
214
+ } catch (err) {
215
+ return { status: typeof err.status === 'number' ? err.status : 1, stdout: err.stdout ? String(err.stdout) : '' };
216
+ }
217
+ }
218
+
219
+ /**
220
+ * CLI entry for `hs source push`. Parses `[remote] <branch> [--force] [-f]
221
+ * [--force-with-lease] [--timeout <ms>]` from argv. Any force flag is
222
+ * forwarded into extraArgs so the guardrail classifier sees it and refuses —
223
+ * never silently dropped.
224
+ *
225
+ * Wires the secret-scan gate (HS-5NXX-CWPX) as the `preScan` passed into
226
+ * `pushBranch`: it lists the paths in the commits about to be pushed and
227
+ * scans them, blocking the push (with zero `git push` subprocess calls) on
228
+ * any finding.
229
+ *
230
+ * @param {string[]} argv (verb-stripped: args after `push`)
231
+ * @param {{
232
+ * cwd?: string,
233
+ * pushBranchFn?: typeof pushBranch,
234
+ * scanPathsFn?: typeof scanPaths,
235
+ * listOutgoingPaths?: typeof pathsInOutgoingCommits,
236
+ * }} [opts]
237
+ * @returns {Promise<number>} process exit code
238
+ */
239
+ export async function runPushCommand(
240
+ argv,
241
+ { cwd = process.cwd(), pushBranchFn = pushBranch, scanPathsFn = scanPaths, listOutgoingPaths = pathsInOutgoingCommits } = {},
242
+ ) {
243
+ const positional = [];
244
+ const extraArgs = [];
245
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
246
+
247
+ for (let i = 0; i < argv.length; i++) {
248
+ const a = argv[i];
249
+ if (a === '--timeout' && argv[i + 1] !== undefined) {
250
+ const parsed = Number(argv[i + 1]);
251
+ if (Number.isFinite(parsed) && parsed > 0) timeoutMs = parsed;
252
+ i++;
253
+ continue;
254
+ }
255
+ if (a === '--force' || a === '-f' || a === '--force-with-lease' || a === '--delete') {
256
+ extraArgs.push(a);
257
+ continue;
258
+ }
259
+ positional.push(a);
260
+ }
261
+
262
+ const remote = positional.length > 1 ? positional[0] : 'origin';
263
+ const branch = positional.length > 1 ? positional[1] : positional[0];
264
+
265
+ const preScan = ({ remote: r, branch: b, cwd: c }) => {
266
+ let paths;
267
+ try {
268
+ paths = listOutgoingPaths({ remote: r, branch: b, cwd: c });
269
+ } catch {
270
+ // Read-only listing failed (e.g. no commits yet) — nothing to scan,
271
+ // never block the push on a listing failure.
272
+ return { ok: true };
273
+ }
274
+ const findings = scanPathsFn(paths, { cwd: c });
275
+ if (findings.length > 0) {
276
+ const names = findings.map((f) => `${f.path} (${f.rule})`).join(', ');
277
+ return {
278
+ ok: false,
279
+ message: `hs source push: secret scan blocked the push — likely secret(s) found: ${names}`,
280
+ };
281
+ }
282
+ return { ok: true };
283
+ };
284
+
285
+ const result = await pushBranchFn({ remote, branch, extraArgs, timeoutMs, cwd, preScan });
286
+ if (!result.ok) {
287
+ console.error(result.message);
288
+ return 1;
289
+ }
290
+ console.log(`hs source push: pushed ${remote}/${branch}.`);
291
+ return 0;
292
+ }
293
+
294
+ // ---- direct-invocation entry --------------------------------------------
295
+
296
+ function invokedDirectly() {
297
+ try {
298
+ return path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1] ?? '');
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ if (invokedDirectly()) {
305
+ // argv: [node, script, "push", ...flags] — drop the leading verb token.
306
+ runPushCommand(process.argv.slice(3)).then((code) => process.exit(code));
307
+ }
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hs-source-secret-scan.mjs — secret-scan gate wired into `hs source
4
+ * preflight` and `hs source push` (HS-5NXX-CWPX, epic
5
+ * [[HS-Q0K8-CWPX-EPIC-spexsource-local-git-preflight-commit]]).
6
+ *
7
+ * Nothing in this repo scanned for secrets before this task. `scanPaths`
8
+ * prefers a real `gitleaks` binary when one is already resolvable on PATH
9
+ * (detected, never auto-installed — this repo's hard rule is no npm/network
10
+ * installs from an agent session, and `npx gitleaks` would silently attempt
11
+ * to download+run an arbitrary binary on first use, which is worse than just
12
+ * not having it); otherwise it falls back to an in-repo regex/entropy
13
+ * scanner covering AWS keys, GitHub tokens, private-key blocks, `.env`
14
+ * files, and high-entropy values assigned to key/secret/token/password-named
15
+ * variables. Either way the public shape is the same: an array of findings
16
+ * (empty = clean) with a `.engine` property naming which backend ran.
17
+ *
18
+ * NEVER logs a matched secret's actual value — findings carry only the
19
+ * file path and the rule name that matched.
20
+ */
21
+ import { execFileSync } from 'node:child_process';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+
25
+ // ---- engine detection ----------------------------------------------------
26
+
27
+ /**
28
+ * Resolves a binary's location via `where`/`which` — detection only, never
29
+ * an install/download. Injectable so tests can simulate gitleaks being
30
+ * present without it actually being on this machine.
31
+ * @param {string} bin
32
+ * @returns {string | null}
33
+ */
34
+ export function defaultWhich(bin) {
35
+ try {
36
+ const finder = process.platform === 'win32' ? 'where' : 'which';
37
+ const out = execFileSync(finder, [bin], { encoding: 'utf8' });
38
+ const first = out
39
+ .split(/\r?\n/)
40
+ .map((l) => l.trim())
41
+ .find(Boolean);
42
+ return first || null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * @param {{ which?: typeof defaultWhich }} [opts]
50
+ * @returns {string | null} the gitleaks binary path, or null if not on PATH
51
+ */
52
+ export function detectGitleaksBinary({ which = defaultWhich } = {}) {
53
+ return which('gitleaks');
54
+ }
55
+
56
+ // ---- built-in fallback scanner --------------------------------------------
57
+
58
+ const RULES = [
59
+ { name: 'aws-access-key-id', re: /AKIA[0-9A-Z]{16}/g },
60
+ { name: 'github-token', re: /(?:ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,})/g },
61
+ {
62
+ name: 'private-key-block',
63
+ re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----/g,
64
+ },
65
+ ];
66
+
67
+ const DOTENV_FILENAME_RE = /(^|[\\/])\.env(\..+)?$/i;
68
+
69
+ // key/secret/token/password assigned to a long, base64/url-safe-ish looking
70
+ // value — the "generic high-entropy string" fallback family. Captures the
71
+ // identifier too (group 1) so obviously-non-secret identifiers (charset /
72
+ // alphabet constants that merely CONTAIN "key") can be excluded.
73
+ const ASSIGNMENT_RE =
74
+ /([A-Za-z_][A-Za-z0-9_]*(?:key|secret|token|passwd|password)[A-Za-z0-9_]*)\s*[:=]\s*["']([A-Za-z0-9+/_.\-]{20,})["']/gi;
75
+ const ENTROPY_MIN_LENGTH = 24;
76
+ const ENTROPY_THRESHOLD_BITS_PER_CHAR = 3.6;
77
+
78
+ // Identifiers that merely contain "key"/"secret"/etc but name something
79
+ // structurally unrelated (a charset/alphabet constant, not a credential).
80
+ const NON_SECRET_IDENTIFIER_RE = /charset|alphabet/i;
81
+
82
+ // Values that are obviously placeholders/fixtures, not real secrets — kept
83
+ // deliberately short and generic (this is a heuristic fallback, not
84
+ // gitleaks; some false positives are expected and acceptable, but these
85
+ // words are unambiguous enough to skip without losing real coverage).
86
+ const PLACEHOLDER_VALUE_RE = /test|fake|dummy|example|placeholder|changeme|not-?real|sample|xxx|todo/i;
87
+
88
+ /** @param {string} s @returns {number} Shannon entropy, bits/char */
89
+ function shannonEntropy(s) {
90
+ const counts = new Map();
91
+ for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1);
92
+ let entropy = 0;
93
+ for (const count of counts.values()) {
94
+ const p = count / s.length;
95
+ entropy -= p * Math.log2(p);
96
+ }
97
+ return entropy;
98
+ }
99
+
100
+ /**
101
+ * Scans one file's content for the built-in rule set. Never includes the
102
+ * matched text in the returned finding.
103
+ * @param {string} relPath
104
+ * @param {string} content
105
+ * @returns {{ path: string, rule: string }[]}
106
+ */
107
+ export function scanContent(relPath, content) {
108
+ const findings = [];
109
+ const seenRules = new Set();
110
+
111
+ for (const rule of RULES) {
112
+ rule.re.lastIndex = 0;
113
+ if (rule.re.test(content) && !seenRules.has(rule.name)) {
114
+ findings.push({ path: relPath, rule: rule.name });
115
+ seenRules.add(rule.name);
116
+ }
117
+ }
118
+
119
+ if (DOTENV_FILENAME_RE.test(relPath.split(path.sep).join('/')) && !seenRules.has('dotenv-file')) {
120
+ findings.push({ path: relPath, rule: 'dotenv-file' });
121
+ seenRules.add('dotenv-file');
122
+ }
123
+
124
+ ASSIGNMENT_RE.lastIndex = 0;
125
+ let m;
126
+ while ((m = ASSIGNMENT_RE.exec(content))) {
127
+ const identifier = m[1];
128
+ const value = m[2];
129
+ if (NON_SECRET_IDENTIFIER_RE.test(identifier)) continue;
130
+ if (PLACEHOLDER_VALUE_RE.test(value)) continue;
131
+ if (value.length >= ENTROPY_MIN_LENGTH && shannonEntropy(value) >= ENTROPY_THRESHOLD_BITS_PER_CHAR) {
132
+ if (!seenRules.has('high-entropy-assignment')) {
133
+ findings.push({ path: relPath, rule: 'high-entropy-assignment' });
134
+ seenRules.add('high-entropy-assignment');
135
+ }
136
+ }
137
+ }
138
+
139
+ return findings;
140
+ }
141
+
142
+ /**
143
+ * @param {string[]} paths
144
+ * @param {{ cwd?: string }} [opts]
145
+ * @returns {{ path: string, rule: string }[]}
146
+ */
147
+ // Test files legitimately contain secret-shaped fixture strings (this
148
+ // scanner's own test proves detection with a fake AWS key and a fake RSA
149
+ // key block) — scanning them against real commits would block on the
150
+ // scanner's own fixtures forever. Real test suites don't need literal
151
+ // production secrets to pass, so this costs no real coverage.
152
+ const TEST_FILE_RE = /\.test\.[cm]?[jt]sx?$/;
153
+
154
+ function builtinScan(paths, { cwd = process.cwd() } = {}) {
155
+ const findings = [];
156
+ for (const relPath of paths) {
157
+ if (TEST_FILE_RE.test(relPath.split(path.sep).join('/'))) continue;
158
+ const abs = path.isAbsolute(relPath) ? relPath : path.join(cwd, relPath);
159
+ let content;
160
+ try {
161
+ const stat = fs.statSync(abs);
162
+ if (!stat.isFile()) continue;
163
+ content = fs.readFileSync(abs, 'utf8');
164
+ } catch {
165
+ continue; // deleted/unreadable/binary — skip, never crash the gate
166
+ }
167
+ findings.push(...scanContent(relPath, content));
168
+ }
169
+ return findings;
170
+ }
171
+
172
+ // ---- real-gitleaks backend (used only when the binary is already on PATH) --
173
+
174
+ /**
175
+ * Default real-gitleaks invocation. Injectable so tests can simulate its
176
+ * JSON report shape without the binary actually being installed. Any
177
+ * failure here (spawn error, unparsable report) propagates as a thrown
178
+ * error — `scanPaths` catches it and falls back to the built-in scanner
179
+ * rather than letting a flaky external binary break the gate.
180
+ * @param {{ bin: string, cwd: string, paths: string[] }} args
181
+ * @returns {{ path: string, rule: string }[]}
182
+ */
183
+ export function defaultRunGitleaks({ bin, cwd, paths }) {
184
+ const reportPath = path.join(cwd, `.hs-source-gitleaks-${process.pid}-${Date.now()}.json`);
185
+ try {
186
+ execFileSync(
187
+ bin,
188
+ ['detect', '--no-git', '--source', cwd, '--report-format', 'json', '--report-path', reportPath, '--exit-code', '0'],
189
+ { cwd, stdio: ['ignore', 'ignore', 'ignore'] },
190
+ );
191
+ const raw = fs.readFileSync(reportPath, 'utf8');
192
+ const report = JSON.parse(raw || '[]');
193
+ const targetSet = new Set(paths.map((p) => p.split(path.sep).join('/')));
194
+ const findings = [];
195
+ for (const entry of Array.isArray(report) ? report : []) {
196
+ const file = String(entry.File ?? entry.file ?? '').split(path.sep).join('/');
197
+ if (targetSet.size > 0 && !targetSet.has(file)) continue;
198
+ findings.push({ path: file, rule: String(entry.RuleID ?? entry.rule ?? 'gitleaks-finding') });
199
+ }
200
+ return findings;
201
+ } finally {
202
+ try {
203
+ fs.unlinkSync(reportPath);
204
+ } catch {
205
+ /* best effort cleanup */
206
+ }
207
+ }
208
+ }
209
+
210
+ // ---- public entry point ----------------------------------------------------
211
+
212
+ /**
213
+ * Scans `paths` for likely secrets. Prefers a real `gitleaks` binary already
214
+ * on PATH; falls back to the built-in scanner otherwise (or if the real
215
+ * invocation throws for any reason). Returns an array of findings (empty =
216
+ * clean) with a non-enumerable-looking-but-plain `.engine` property naming
217
+ * which backend ran ('gitleaks' | 'builtin').
218
+ *
219
+ * @param {string[]} paths
220
+ * @param {{
221
+ * cwd?: string,
222
+ * which?: typeof defaultWhich,
223
+ * runGitleaks?: typeof defaultRunGitleaks,
224
+ * }} [opts]
225
+ * @returns {{ path: string, rule: string }[] & { engine: 'gitleaks' | 'builtin' }}
226
+ */
227
+ export function scanPaths(paths, { cwd = process.cwd(), which = defaultWhich, runGitleaks = defaultRunGitleaks } = {}) {
228
+ const targets = Array.isArray(paths) ? paths.filter(Boolean) : [];
229
+ if (targets.length === 0) {
230
+ const empty = [];
231
+ empty.engine = 'builtin';
232
+ return empty;
233
+ }
234
+
235
+ const gitleaksBin = detectGitleaksBinary({ which });
236
+ if (gitleaksBin) {
237
+ try {
238
+ const findings = runGitleaks({ bin: gitleaksBin, cwd, paths: targets });
239
+ findings.engine = 'gitleaks';
240
+ return findings;
241
+ } catch {
242
+ // Fall through to the built-in scanner — a flaky/misbehaving external
243
+ // binary must never break the gate or crash the caller.
244
+ }
245
+ }
246
+
247
+ const findings = builtinScan(targets, { cwd });
248
+ findings.engine = 'builtin';
249
+ return findings;
250
+ }
251
+
252
+ /**
253
+ * Human-readable one-line summary of a findings array, for CLI reports.
254
+ * Never prints the matched secret value — file + rule only.
255
+ * @param {ReturnType<typeof scanPaths>} findings
256
+ * @returns {string}
257
+ */
258
+ export function formatFindings(findings) {
259
+ if (!findings || findings.length === 0) return 'Secret scan: clean (0 findings)';
260
+ const lines = [`Secret scan: BLOCKED — ${findings.length} likely secret(s) found (engine: ${findings.engine ?? 'unknown'}):`];
261
+ for (const f of findings) lines.push(` - ${f.path} (${f.rule})`);
262
+ return lines.join('\n');
263
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * standalone-doctor.mjs — the `runtime-files` check for `hs doctor` in
3
+ * published-install mode (no `scripts/heyspex-doctor.mjs` on disk — that
4
+ * script only ships inside a source clone; see `bin/hs.mjs`'s dispatch site).
5
+ *
6
+ * Extracted out of `bin/hs.mjs`'s `runStandaloneDoctor()` so it is testable
7
+ * as a pure function over an injected fs seam, rather than only reachable by
8
+ * spawning the whole CLI ([[HS-67ES-0JNK-TASK-fresh-install-honesty-statusline-only]]
9
+ * AC3). Before this, a published install's health check only verified
10
+ * version + sibling-bins-on-PATH — it never noticed that a runtime file the
11
+ * server dynamic-imports or spawns (e.g. `scripts/lib/dev-mode.mjs`, missing
12
+ * from `package.json` `files` until [[HS-PO09-0JNK-TASK-packed-mcp-server-runs-the-agent-loop]])
13
+ * was simply absent from the install. `hs doctor` — the FIRST command a
14
+ * customer would reasonably run to sanity-check a fresh install — reported
15
+ * everything green while `heyspex_preflight` threw `MODULE_NOT_FOUND` on the
16
+ * very next command.
17
+ *
18
+ * `RUNTIME_FILES` is the same set `scripts/lib/packed-runtime-imports.test.mjs`
19
+ * proves ships in the published tarball (its two explicitly-diagnosed
20
+ * dynamic-import targets, the `hs source` entrypoints it spawns, and the two
21
+ * `dist/cli/*.mjs` bundle entries `hs init`/`hs install` dispatch to) — this
22
+ * is deliberately the SAME list, not a re-derivation, so the two checks can
23
+ * never silently drift apart (one says "ships", the other says "present").
24
+ */
25
+ import { existsSync } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ /**
29
+ * Runtime files a published install must have next to `bin/hs.mjs` — each
30
+ * entry is a path-segment array (portable across `path.join`/posix).
31
+ */
32
+ export const RUNTIME_FILES = Object.freeze([
33
+ ["vendor", "heyspex-mcp", "cli.mjs"],
34
+ ["scripts", "lib", "dev-mode.mjs"],
35
+ ["scripts", "dev-server-status.mjs"],
36
+ ["scripts", "lib", "hs-source-preflight.mjs"],
37
+ ["scripts", "lib", "hs-source-commit.mjs"],
38
+ ["scripts", "lib", "hs-source-push.mjs"],
39
+ ["scripts", "lib", "hs-source-secret-scan.mjs"],
40
+ ["dist", "cli", "hs-init.mjs"],
41
+ ["dist", "cli", "hs-install.mjs"],
42
+ ]);
43
+
44
+ /**
45
+ * Check every {@link RUNTIME_FILES} entry against `pkgRoot`. Returns
46
+ * `{ ok, missing, present }` — `missing`/`present` are POSIX-joined relative
47
+ * paths (e.g. `"scripts/lib/dev-mode.mjs"`), never absolute, so a report can
48
+ * name the missing file without leaking this machine's install path.
49
+ *
50
+ * `exists` is injectable (defaults to the real `node:fs.existsSync`) so tests
51
+ * can point this at a real temp directory (never a mocked `node:fs`) with one
52
+ * file deliberately removed.
53
+ */
54
+ export function checkRuntimeFiles(pkgRoot, { exists = existsSync } = {}) {
55
+ const missing = [];
56
+ const present = [];
57
+ for (const rel of RUNTIME_FILES) {
58
+ const relPosix = rel.join("/");
59
+ const abs = join(pkgRoot, ...rel);
60
+ if (exists(abs)) present.push(relPosix);
61
+ else missing.push(relPosix);
62
+ }
63
+ return { ok: missing.length === 0, missing, present };
64
+ }
65
+
66
+ /**
67
+ * Render {@link checkRuntimeFiles}'s result as one `hs doctor` check row —
68
+ * same shape `runStandaloneDoctor` pushes onto `report.checks` for its other
69
+ * checks (`id`/`status`/`detail`/optional `human`).
70
+ */
71
+ export function runtimeFilesCheckRow(pkgRoot, opts = {}) {
72
+ const { ok, missing } = checkRuntimeFiles(pkgRoot, opts);
73
+ if (ok) {
74
+ return {
75
+ id: "runtime-files",
76
+ status: "ok",
77
+ detail: "every packaged runtime file is present",
78
+ };
79
+ }
80
+ return {
81
+ id: "runtime-files",
82
+ status: "fail",
83
+ detail: `missing: ${missing.join(", ")}`,
84
+ human:
85
+ `This install is missing ${missing.length} runtime file${missing.length === 1 ? "" : "s"} ` +
86
+ `the server needs at call time (${missing.join(", ")}). Re-run \`npm install heyspex\` — ` +
87
+ "if it's still missing after a clean reinstall, the published package itself is broken.",
88
+ };
89
+ }