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,369 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dev-mode.mjs — shared dev/prod mode + local/cloud reliance core
4
+ * (epic HS-Q6M2 "Admin dev/prod mode control").
5
+ *
6
+ * The ONE plain-Node implementation of the mode contract for every non-TS
7
+ * consumer (vendor MCP server via dynamic import — the handleDevServerStatus
8
+ * precedent — plus any hook/dev-factory script). The console-side TypeScript
9
+ * twin is `spex-console/src/lib/dev-mode.ts`; the two share the SAME contract
10
+ * and the SAME machine-local record file — change one, change both.
11
+ *
12
+ * Model:
13
+ * mode: "prod" | "dev"; when dev, reliance: "local" | "cloud".
14
+ * prod => cloud console (app.heyspex.com), Neon reads, cloud writes
15
+ * dev+cloud => cloud console, Neon reads, cloud writes
16
+ * dev+local => localhost:3000 console, disk reads, local writes
17
+ *
18
+ * Resolution order (owner-ruled): env override → machine-local override
19
+ * (~/.heyspex/dev-mode.json) → cloud admin setting (account_settings.dev_mode,
20
+ * injected by the caller — this module never talks to Neon) → default prod.
21
+ *
22
+ * MACHINE-LOCAL RECORD — CROSS-COMPONENT CONTRACT (the Windows Companion
23
+ * reads the same file; keep the shape exactly this):
24
+ * ~/.heyspex/dev-mode.json
25
+ * { "mode": "dev"|"prod", "reliance": "local"|"cloud",
26
+ * "console_url": "<url>", "updated_at": "<ISO>", "source": "<who wrote it>" }
27
+ *
28
+ * Env overrides:
29
+ * HEYSPEX_DEV_MODE = prod | dev | local | cloud (local/cloud imply dev)
30
+ * HEYSPEX_DEV_RELIANCE = local | cloud (refines HEYSPEX_DEV_MODE=dev)
31
+ *
32
+ * Zero deps beyond node builtins (same rule as console-url-resolver.mjs).
33
+ */
34
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
35
+ import { homedir } from 'node:os';
36
+ import { join } from 'node:path';
37
+
38
+ export const CLOUD_CONSOLE_URL = 'https://app.heyspex.com';
39
+ export const LOCAL_CONSOLE_URL = 'http://localhost:3000';
40
+ export const DEV_MODE_FILENAME = 'dev-mode.json';
41
+
42
+ /** Absolute path of the machine-local record. */
43
+ export function devModeFilePath(homeDir = homedir()) {
44
+ return join(homeDir, '.heyspex', DEV_MODE_FILENAME);
45
+ }
46
+
47
+ function normalizeMode(raw) {
48
+ const v = String(raw ?? '').trim().toLowerCase();
49
+ if (v === 'prod' || v === 'production') return { mode: 'prod', reliance: 'cloud' };
50
+ if (v === 'dev') return { mode: 'dev', reliance: null };
51
+ if (v === 'local') return { mode: 'dev', reliance: 'local' };
52
+ if (v === 'cloud') return { mode: 'dev', reliance: 'cloud' };
53
+ return null;
54
+ }
55
+
56
+ function normalizeReliance(raw) {
57
+ const v = String(raw ?? '').trim().toLowerCase();
58
+ return v === 'local' || v === 'cloud' ? v : null;
59
+ }
60
+
61
+ /** Map mode+reliance to the derived semantics. prod forces cloud reliance. */
62
+ export function deriveSemantics(mode, reliance) {
63
+ const effectiveReliance = mode === 'prod' ? 'cloud' : (reliance ?? 'local');
64
+ const local = mode === 'dev' && effectiveReliance === 'local';
65
+ return {
66
+ reliance: effectiveReliance,
67
+ consoleUrl: local ? LOCAL_CONSOLE_URL : CLOUD_CONSOLE_URL,
68
+ readTier: local ? 'disk' : 'neon',
69
+ vaultWriteMode: local ? 'local' : 'cloud',
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Read + validate ~/.heyspex/dev-mode.json. Never throws:
75
+ * returns { record, parseError } — record null when missing/invalid.
76
+ */
77
+ export function readLocalDevModeRecord({ homeDir = homedir() } = {}) {
78
+ let raw;
79
+ try {
80
+ raw = readFileSync(devModeFilePath(homeDir), 'utf8');
81
+ } catch {
82
+ return { record: null, parseError: null }; // absent — not an error
83
+ }
84
+ try {
85
+ const parsed = JSON.parse(raw);
86
+ const mode = parsed?.mode === 'prod' || parsed?.mode === 'dev' ? parsed.mode : null;
87
+ const reliance = normalizeReliance(parsed?.reliance);
88
+ if (!mode || (mode === 'dev' && !reliance)) {
89
+ return { record: null, parseError: `invalid dev-mode record: ${JSON.stringify(parsed)}` };
90
+ }
91
+ return {
92
+ record: {
93
+ mode,
94
+ reliance: mode === 'prod' ? 'cloud' : reliance,
95
+ console_url: typeof parsed.console_url === 'string' ? parsed.console_url : null,
96
+ updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : null,
97
+ source: typeof parsed.source === 'string' ? parsed.source : null,
98
+ },
99
+ parseError: null,
100
+ };
101
+ } catch (err) {
102
+ return { record: null, parseError: `unparseable dev-mode.json: ${String(err?.message ?? err)}` };
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Resolve the effective mode. `cloudSetting` is injected by the caller:
108
+ * { mode, reliance } when the account_settings.dev_mode value is known,
109
+ * null when known-empty, undefined when unreachable/unknown.
110
+ * Returns the full resolved envelope with per-field sources so a stale
111
+ * local override is visible, never silent.
112
+ */
113
+ export function resolveDevMode({
114
+ env = process.env,
115
+ homeDir = homedir(),
116
+ cloudSetting = undefined,
117
+ } = {}) {
118
+ const local = readLocalDevModeRecord({ homeDir });
119
+ const cloudKnown = cloudSetting !== undefined;
120
+ const cloudValue =
121
+ cloudKnown && cloudSetting
122
+ ? {
123
+ mode: cloudSetting.mode === 'dev' ? 'dev' : 'prod',
124
+ reliance: normalizeReliance(cloudSetting.reliance) ?? 'cloud',
125
+ }
126
+ : null;
127
+
128
+ let mode;
129
+ let reliance;
130
+ let source;
131
+ const envParsed = normalizeMode(env.HEYSPEX_DEV_MODE);
132
+ if (envParsed) {
133
+ mode = envParsed.mode;
134
+ reliance = envParsed.reliance ?? normalizeReliance(env.HEYSPEX_DEV_RELIANCE) ?? 'local';
135
+ source = 'env';
136
+ } else if (local.record) {
137
+ mode = local.record.mode;
138
+ reliance = local.record.reliance;
139
+ source = 'local-override';
140
+ } else if (cloudValue) {
141
+ mode = cloudValue.mode;
142
+ reliance = cloudValue.reliance;
143
+ source = 'cloud-setting';
144
+ } else {
145
+ mode = 'prod';
146
+ reliance = 'cloud';
147
+ source = 'default';
148
+ }
149
+
150
+ const semantics = deriveSemantics(mode, reliance);
151
+ // Honor a console_url pinned in the local record only when the record won.
152
+ const consoleUrl =
153
+ source === 'local-override' && local.record?.console_url
154
+ ? local.record.console_url.replace(/\/+$/, '')
155
+ : semantics.consoleUrl;
156
+
157
+ const staleAgainstCloud =
158
+ source === 'local-override' &&
159
+ cloudValue !== null &&
160
+ (cloudValue.mode !== mode || (mode === 'dev' && cloudValue.reliance !== semantics.reliance));
161
+
162
+ return {
163
+ mode,
164
+ reliance: semantics.reliance,
165
+ consoleUrl,
166
+ readTier: semantics.readTier,
167
+ vaultWriteMode: semantics.vaultWriteMode,
168
+ sources: {
169
+ mode: source,
170
+ reliance: source,
171
+ consoleUrl:
172
+ source === 'local-override' && local.record?.console_url ? 'local-override' : 'derived',
173
+ readTier: 'derived',
174
+ vaultWriteMode: 'derived',
175
+ },
176
+ localOverride: {
177
+ present: local.record !== null,
178
+ record: local.record,
179
+ parseError: local.parseError,
180
+ staleAgainstCloud,
181
+ },
182
+ cloudSetting: { known: cloudKnown, value: cloudValue },
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Upsert top-level `key: value` lines in ~/.heyspex/credentials.yaml without a
188
+ * full YAML round-trip (same targeted approach as the vendor MCP `pair`
189
+ * writer and console-url-resolver's targeted read — the file carries
190
+ * hand-maintained blocks a naive parse/dump could reformat). Creates the
191
+ * directory/file with restrictive permissions when absent. Never repo files.
192
+ */
193
+ export function upsertCredentialsYamlKeys(homeDir, updates) {
194
+ const dir = join(homeDir, '.heyspex');
195
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
196
+ const filePath = join(dir, 'credentials.yaml');
197
+ let content = '';
198
+ try {
199
+ content = readFileSync(filePath, 'utf8');
200
+ } catch {
201
+ content = '';
202
+ }
203
+ const lines = content.length > 0 ? content.split(/\r?\n/) : [];
204
+ for (const [key, value] of Object.entries(updates)) {
205
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
206
+ const pattern = new RegExp(`^${escapedKey}:.*$`);
207
+ const serialized = `${key}: ${value}`;
208
+ const idx = lines.findIndex((line) => pattern.test(line));
209
+ if (idx >= 0) lines[idx] = serialized;
210
+ else lines.push(serialized);
211
+ }
212
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
213
+ writeFileSync(filePath, `${lines.join('\n')}\n`, { mode: 0o600 });
214
+ return filePath;
215
+ }
216
+
217
+ /** Targeted read of `mcp_token:` from credentials.yaml (bearer for cloud calls). */
218
+ export function readCredentialsMcpToken({ homeDir = homedir() } = {}) {
219
+ try {
220
+ const raw = readFileSync(join(homeDir, '.heyspex', 'credentials.yaml'), 'utf8');
221
+ const match = raw.match(/^mcp_token:\s*["']?([^"'\r\n#]+)/m);
222
+ return match?.[1]?.trim() || null;
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ /** Best-effort GET of the cloud admin setting via the console API. */
229
+ export async function fetchCloudDevMode({
230
+ consoleUrl,
231
+ fetchImpl = fetch,
232
+ homeDir = homedir(),
233
+ timeoutMs = 4000,
234
+ } = {}) {
235
+ if (!consoleUrl) return { ok: false, setting: undefined, error: 'no console url' };
236
+ try {
237
+ const token = readCredentialsMcpToken({ homeDir });
238
+ const res = await fetchImpl(`${consoleUrl.replace(/\/+$/, '')}/api/admin/dev-mode`, {
239
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
240
+ signal: AbortSignal.timeout(timeoutMs),
241
+ });
242
+ if (!res.ok) return { ok: false, setting: undefined, error: `HTTP ${res.status}` };
243
+ const body = await res.json();
244
+ return { ok: true, setting: body?.cloud ?? null, error: null };
245
+ } catch (err) {
246
+ return { ok: false, setting: undefined, error: String(err?.message ?? err) };
247
+ }
248
+ }
249
+
250
+ /** Raised by {@link applyDevMode} when the cloud confirms the caller is not an admin. */
251
+ export class DevModeNotAdminError extends Error {
252
+ constructor() {
253
+ super('This account runs on HeySpex Cloud. Switching to local-disk mode is reserved for the HeySpex team\'s own build machines.');
254
+ this.name = 'DevModeNotAdminError';
255
+ }
256
+ }
257
+
258
+ /**
259
+ * THE SWITCHER — apply a mode change end to end:
260
+ * 0. LOCAL RELIANCE ONLY, and only when this machine is paired to a cloud
261
+ * account (a stored mcp_token): confirm admin server-side via
262
+ * POST /api/admin/dev-mode BEFORE touching anything on disk. A 403
263
+ * throws {@link DevModeNotAdminError} — the whole switch is refused, the
264
+ * local record is never written. An unpaired machine (no token — the
265
+ * HeySpex team's own local checkout) has nothing to check against and
266
+ * behaves exactly as before. A network failure here is NOT a role
267
+ * verdict, so it falls through to step 3's best-effort behavior instead
268
+ * of blocking the switch.
269
+ * 1. write ~/.heyspex/dev-mode.json (the cross-component contract file)
270
+ * 2. upsert `console_url:` in ~/.heyspex/credentials.yaml — rung 2 of the
271
+ * shared console-url resolver, so MCP / hooks / dev-factory flip with it
272
+ * 3. best-effort write of the cloud account_settings row via the target
273
+ * console's /api/admin/dev-mode (skipped/reported when unreachable, or
274
+ * already done by step 0's precheck — never POSTed twice)
275
+ * Never edits repo-tracked files. Returns what happened, field by field.
276
+ *
277
+ * `target`: { mode: "prod"|"dev"|"local"|"cloud", reliance? } — local/cloud
278
+ * shorthands imply dev with that reliance.
279
+ */
280
+ export async function applyDevMode(
281
+ target,
282
+ {
283
+ homeDir = homedir(),
284
+ fetchImpl = fetch,
285
+ source = 'cli',
286
+ now = () => new Date().toISOString(),
287
+ syncCloud = true,
288
+ } = {},
289
+ ) {
290
+ const parsed = normalizeMode(target?.mode);
291
+ if (!parsed) {
292
+ throw new Error(
293
+ `applyDevMode: invalid mode "${target?.mode}" (expected prod | dev | local | cloud)`,
294
+ );
295
+ }
296
+ const reliance =
297
+ parsed.reliance ?? normalizeReliance(target?.reliance) ?? 'local';
298
+ const mode = parsed.mode;
299
+ const semantics = deriveSemantics(mode, reliance);
300
+
301
+ async function postCloud(token) {
302
+ const res = await fetchImpl(`${semantics.consoleUrl}/api/admin/dev-mode`, {
303
+ method: 'POST',
304
+ headers: {
305
+ 'content-type': 'application/json',
306
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
307
+ },
308
+ body: JSON.stringify({ mode, reliance: semantics.reliance, source }),
309
+ signal: AbortSignal.timeout(5000),
310
+ });
311
+ return res;
312
+ }
313
+
314
+ // 0. Authorization precheck — see the doc comment above.
315
+ let cloud = { attempted: false, updated: false, error: null };
316
+ if (syncCloud && semantics.reliance === 'local') {
317
+ const token = readCredentialsMcpToken({ homeDir });
318
+ if (token) {
319
+ try {
320
+ const res = await postCloud(token);
321
+ if (res.status === 403) throw new DevModeNotAdminError();
322
+ cloud = { attempted: true, updated: res.ok, error: res.ok ? null : `HTTP ${res.status}` };
323
+ } catch (err) {
324
+ if (err instanceof DevModeNotAdminError) throw err;
325
+ cloud = { attempted: true, updated: false, error: String(err?.message ?? err) };
326
+ }
327
+ }
328
+ }
329
+
330
+ // 1. machine-local record — EXACT contract shape, do not add/rename keys.
331
+ const record = {
332
+ mode,
333
+ reliance: semantics.reliance,
334
+ console_url: semantics.consoleUrl,
335
+ updated_at: now(),
336
+ source,
337
+ };
338
+ const dir = join(homeDir, '.heyspex');
339
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
340
+ const recordPath = devModeFilePath(homeDir);
341
+ writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
342
+
343
+ // 2. credentials.yaml console_url (rung 2 of the shared resolver).
344
+ const credentialsPath = upsertCredentialsYamlKeys(homeDir, {
345
+ console_url: semantics.consoleUrl,
346
+ });
347
+
348
+ // 3. cloud account_settings row — best effort, never blocks the switch.
349
+ // Skipped when step 0 already attempted it (same endpoint, same payload).
350
+ if (syncCloud && !cloud.attempted) {
351
+ cloud = { attempted: true, updated: false, error: null };
352
+ try {
353
+ const token = readCredentialsMcpToken({ homeDir });
354
+ const res = await postCloud(token);
355
+ if (res.ok) cloud.updated = true;
356
+ else cloud.error = `HTTP ${res.status}`;
357
+ } catch (err) {
358
+ cloud.error = String(err?.message ?? err);
359
+ }
360
+ }
361
+
362
+ return {
363
+ applied: { mode, reliance: semantics.reliance, ...deriveSemantics(mode, reliance) },
364
+ record,
365
+ recordPath,
366
+ credentialsPath,
367
+ cloud,
368
+ };
369
+ }
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hs-link.mjs — "does `hs` on PATH actually run THIS checkout's bin/hs.mjs?"
4
+ *
5
+ * On a fresh clone nothing puts `hs` / `heyspex` / `heyspex-mcp` on PATH: the
6
+ * only thing that does is `npm link`, run once per machine from the repo root
7
+ * ([[HS-BFXF-WBJU-DIAG-hs-never-resolves-on-a-fresh-machine]]). `npm install`
8
+ * does not, and `npx hs` is actively harmful — `hs` is an unrelated package on
9
+ * the public registry, so `npx hs login` silently runs SOMEBODY ELSE'S tool and
10
+ * looks like nothing happened.
11
+ *
12
+ * Mere existence of a binary named `hs` therefore proves nothing. This module
13
+ * resolves the shim the shell would actually run and follows it to the script
14
+ * it executes, so three distinct failures read differently:
15
+ *
16
+ * missing — nothing named `hs` anywhere on PATH
17
+ * foreign — an `hs` that is not a HeySpex checkout at all (the npm
18
+ * package, or any other tool that grabbed the name)
19
+ * other-checkout — a HeySpex `bin/hs.mjs`, but a DIFFERENT clone's. The most
20
+ * dangerous case: everything appears to work while `hs`
21
+ * reads another vault.
22
+ *
23
+ * Read-only by construction. Nothing here runs `npm link` — that mutates global
24
+ * machine state and is a human's call; callers report the remedy instead.
25
+ */
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+
29
+ /**
30
+ * Filenames a shell would try for `name`, most-preferred first.
31
+ *
32
+ * Windows resolves via PATHEXT, and npm writes three shims per bin (`hs.cmd`
33
+ * for cmd/PowerShell, `hs.ps1`, and an extensionless `hs` sh script for Git
34
+ * Bash / MSYS). All three point at the same target, so any of them answers the
35
+ * question — `.cmd` leads because that is what a stock terminal runs.
36
+ */
37
+ function candidateNames(name) {
38
+ return process.platform === 'win32'
39
+ ? [`${name}.cmd`, `${name}.exe`, `${name}.bat`, `${name}.ps1`, name]
40
+ : [name];
41
+ }
42
+
43
+ /**
44
+ * The file a shell would run for `name`, or null. Deliberately a PATH walk
45
+ * rather than shelling out to `where` / `which`: those are not on every box,
46
+ * and spawning a shell to answer "is this wired up" invites the quoting bugs
47
+ * `run()` in heyspex-doctor.mjs already documents.
48
+ */
49
+ export function resolveOnPath(name, env = process.env) {
50
+ const dirs = (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean);
51
+ for (const rawDir of dirs) {
52
+ const dir = rawDir.replace(/^"|"$/g, '');
53
+ for (const candidate of candidateNames(name)) {
54
+ const full = path.join(dir, candidate);
55
+ try {
56
+ if (fs.statSync(full).isFile()) return full;
57
+ } catch {
58
+ // Unreadable or nonexistent PATH entry — skip it, never throw.
59
+ }
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * The script a shim executes. npm's shims embed the target as a quoted,
67
+ * shim-relative path (`%dp0%\node_modules\<pkg>\bin\hs.mjs` in the .cmd,
68
+ * `$basedir/node_modules/...` in the sh and .ps1 forms); on POSIX npm instead
69
+ * symlinks the bin straight at the script. Handle both, and fall back to the
70
+ * shim itself so an unrelated `hs.exe` still yields a path worth printing.
71
+ */
72
+ export function shimTarget(shimPath) {
73
+ try {
74
+ if (fs.lstatSync(shimPath).isSymbolicLink()) return fs.realpathSync(shimPath);
75
+ } catch {
76
+ return null;
77
+ }
78
+ let body;
79
+ try {
80
+ body = fs.readFileSync(shimPath, 'utf8');
81
+ } catch {
82
+ return shimPath; // a binary we cannot read is still "some other hs"
83
+ }
84
+ const dir = path.dirname(shimPath);
85
+ const quoted = body.match(/"[^"\r\n]*\.(?:mjs|cjs|js)"|'[^'\r\n]*\.(?:mjs|cjs|js)'/g) ?? [];
86
+ for (const raw of quoted) {
87
+ const expanded = raw
88
+ .slice(1, -1)
89
+ // A function replacement, not a string one: a shim directory containing
90
+ // `$` would otherwise be re-read as a `$&`-style replacement pattern.
91
+ .replace(/%~?dp0%?|\$basedir|\$\{?PSScriptRoot\}?/gi, () => dir)
92
+ .replace(/\//g, path.sep);
93
+ const abs = path.isAbsolute(expanded) ? expanded : path.resolve(dir, expanded);
94
+ if (fs.existsSync(abs)) return abs;
95
+ }
96
+ return shimPath;
97
+ }
98
+
99
+ /** Same file on disk? Resolves symlinks (npm link IS a symlink) and, on Windows, case. */
100
+ export function samePath(a, b) {
101
+ const norm = (p) => {
102
+ let resolved;
103
+ try {
104
+ resolved = fs.realpathSync(p);
105
+ } catch {
106
+ resolved = path.resolve(p);
107
+ }
108
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
109
+ };
110
+ return norm(a) === norm(b);
111
+ }
112
+
113
+ /** Does `target` look like SOME HeySpex checkout's bin/hs.mjs (just not ours)? */
114
+ function looksLikeAHeySpexCheckout(target) {
115
+ if (path.basename(target).toLowerCase() !== 'hs.mjs') return false;
116
+ const pkgPath = path.join(path.dirname(path.dirname(target)), 'package.json');
117
+ try {
118
+ const name = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))?.name;
119
+ // `workflow-pm` is this package's former name — a link made before the
120
+ // rename still resolves here and is genuinely this repo, not a stranger.
121
+ return name === 'heyspex' || name === 'workflow-pm';
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * The whole answer, as data. `status` is one of:
129
+ * linked | missing | foreign | other-checkout | unreadable
130
+ *
131
+ * `repoRoot` is the checkout being asked about — the one `npm link` would need
132
+ * to be run from.
133
+ */
134
+ export function describeHsLink(repoRoot, { name = 'hs', env = process.env } = {}) {
135
+ const own = path.join(repoRoot, 'bin', 'hs.mjs');
136
+ const shim = resolveOnPath(name, env);
137
+ if (!shim) return { status: 'missing', name, shim: null, target: null, own };
138
+
139
+ const target = shimTarget(shim);
140
+ if (!target) return { status: 'unreadable', name, shim, target: null, own };
141
+ if (samePath(target, own)) return { status: 'linked', name, shim, target, own };
142
+ return {
143
+ status: looksLikeAHeySpexCheckout(target) ? 'other-checkout' : 'foreign',
144
+ name,
145
+ shim,
146
+ target,
147
+ own,
148
+ };
149
+ }
150
+
151
+ /** `npm link` is per-machine and links all three bins at once; report the strays. */
152
+ export function missingSiblingBins(env = process.env) {
153
+ return ['heyspex', 'heyspex-mcp'].filter((n) => !resolveOnPath(n, env));
154
+ }