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,3436 @@
1
+ import { createRequire as __hsCreateRequire } from 'node:module';
2
+ const require = __hsCreateRequire(import.meta.url);
3
+
4
+ // spex-console/bin/hs-connect.ts
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "node:fs";
7
+ import path4 from "node:path";
8
+ import process2 from "node:process";
9
+
10
+ // spex-console/src/lib/cli/connect.ts
11
+ import { readdirSync } from "node:fs";
12
+
13
+ // spex-console/src/lib/vault-registry/registry-file.ts
14
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+
18
+ // spex-console/src/lib/vault-registry/types.ts
19
+ var VAULT_REGISTRY_SCHEMA_VERSION = 1;
20
+ function emptyVaultRegistry() {
21
+ return {
22
+ schema_version: VAULT_REGISTRY_SCHEMA_VERSION,
23
+ workspaces: [],
24
+ vaults: [],
25
+ active_workspace_id: null,
26
+ active_vault_id: null
27
+ };
28
+ }
29
+
30
+ // spex-console/src/lib/vault-registry/registry-file.ts
31
+ function vaultRegistryDir(opts = {}) {
32
+ return opts.registryDir ?? join(homedir(), ".heyspex");
33
+ }
34
+ function vaultRegistryFilePath(opts = {}) {
35
+ return join(vaultRegistryDir(opts), "registry.json");
36
+ }
37
+ function isNonEmptyString(v) {
38
+ return typeof v === "string" && v.trim().length > 0;
39
+ }
40
+ function normalizeWorkspace(raw) {
41
+ if (!raw || typeof raw !== "object") return null;
42
+ const o = raw;
43
+ if (!isNonEmptyString(o.id)) return null;
44
+ if (!isNonEmptyString(o.display_name)) return null;
45
+ if (!isNonEmptyString(o.created_at)) return null;
46
+ const record = {
47
+ id: o.id,
48
+ display_name: o.display_name,
49
+ created_at: o.created_at,
50
+ archived: o.archived === true
51
+ };
52
+ if (isNonEmptyString(o.github_org)) record.github_org = o.github_org;
53
+ return record;
54
+ }
55
+ function normalizeVault(raw) {
56
+ if (!raw || typeof raw !== "object") return null;
57
+ const o = raw;
58
+ if (!isNonEmptyString(o.id)) return null;
59
+ if (!isNonEmptyString(o.workspace_id)) return null;
60
+ if (!isNonEmptyString(o.display_name)) return null;
61
+ if (!isNonEmptyString(o.repo_root_abs)) return null;
62
+ if (typeof o.schema_version !== "number") return null;
63
+ if (!isNonEmptyString(o.registered_at)) return null;
64
+ const record = {
65
+ id: o.id,
66
+ workspace_id: o.workspace_id,
67
+ display_name: o.display_name,
68
+ repo_root_abs: o.repo_root_abs,
69
+ schema_version: o.schema_version,
70
+ registered_at: o.registered_at,
71
+ archived: o.archived === true
72
+ };
73
+ if (isNonEmptyString(o.github_remote)) record.github_remote = o.github_remote;
74
+ return record;
75
+ }
76
+ function parseVaultRegistryFile(json) {
77
+ const fallback = emptyVaultRegistry();
78
+ if (!json || typeof json !== "object") return fallback;
79
+ const o = json;
80
+ const workspacesRaw = Array.isArray(o.workspaces) ? o.workspaces : [];
81
+ const workspaces2 = workspacesRaw.map(normalizeWorkspace).filter((w) => w !== null);
82
+ const vaultsRaw = Array.isArray(o.vaults) ? o.vaults : [];
83
+ const vaults2 = vaultsRaw.map(normalizeVault).filter((v) => v !== null);
84
+ const active_workspace_id = isNonEmptyString(o.active_workspace_id) ? o.active_workspace_id : null;
85
+ const active_vault_id = isNonEmptyString(o.active_vault_id) ? o.active_vault_id : null;
86
+ return {
87
+ schema_version: VAULT_REGISTRY_SCHEMA_VERSION,
88
+ workspaces: workspaces2,
89
+ vaults: vaults2,
90
+ active_workspace_id,
91
+ active_vault_id
92
+ };
93
+ }
94
+ function loadVaultRegistry(opts = {}) {
95
+ const filePath = vaultRegistryFilePath(opts);
96
+ if (!existsSync(filePath)) return emptyVaultRegistry();
97
+ try {
98
+ const raw = readFileSync(filePath, "utf8");
99
+ return parseVaultRegistryFile(JSON.parse(raw));
100
+ } catch {
101
+ return emptyVaultRegistry();
102
+ }
103
+ }
104
+ function writeVaultRegistry(registry, opts = {}) {
105
+ const filePath = vaultRegistryFilePath(opts);
106
+ mkdirSync(dirname(filePath), { recursive: true });
107
+ const tmpPath = `${filePath}.tmp`;
108
+ writeFileSync(tmpPath, `${JSON.stringify(registry, null, 2)}
109
+ `, "utf8");
110
+ renameSync(tmpPath, filePath);
111
+ }
112
+ function isValidId(id) {
113
+ if (!isNonEmptyString(id)) return false;
114
+ if (id.includes("/") || id.includes("\\")) return false;
115
+ if (id === "." || id === "..") return false;
116
+ return true;
117
+ }
118
+ function registerWorkspace(record, opts = {}) {
119
+ if (!isValidId(record.id)) {
120
+ throw new Error(`registerWorkspace: invalid workspace id "${record.id}"`);
121
+ }
122
+ const registry = loadVaultRegistry(opts);
123
+ const idx = registry.workspaces.findIndex((w) => w.id === record.id);
124
+ if (idx >= 0) {
125
+ registry.workspaces[idx] = { ...record };
126
+ } else {
127
+ registry.workspaces.push({ ...record });
128
+ }
129
+ writeVaultRegistry(registry, opts);
130
+ return registry;
131
+ }
132
+ function registerVault(record, opts = {}) {
133
+ if (!isValidId(record.id)) {
134
+ throw new Error(`registerVault: invalid vault id "${record.id}"`);
135
+ }
136
+ if (!isValidId(record.workspace_id)) {
137
+ throw new Error(`registerVault: invalid workspace_id "${record.workspace_id}"`);
138
+ }
139
+ if (!isNonEmptyString(record.repo_root_abs)) {
140
+ throw new Error("registerVault: repo_root_abs is required");
141
+ }
142
+ const registry = loadVaultRegistry(opts);
143
+ const isSameVault = (v) => v.id === record.id || v.repo_root_abs === record.repo_root_abs;
144
+ const idx = registry.vaults.findIndex(isSameVault);
145
+ if (idx >= 0) {
146
+ registry.vaults[idx] = { ...record };
147
+ registry.vaults = registry.vaults.filter((v, i) => i === idx || !isSameVault(v));
148
+ } else {
149
+ registry.vaults.push({ ...record });
150
+ }
151
+ writeVaultRegistry(registry, opts);
152
+ return registry;
153
+ }
154
+
155
+ // spex-console/src/lib/cli/vault-workspace-shared.ts
156
+ import { readFileSync as readFileSync2 } from "node:fs";
157
+ import os from "node:os";
158
+ import path2 from "node:path";
159
+
160
+ // dist/cli/._sql-orm-inert.cli-shim.mjs
161
+ var inert = new Proxy(function inert2() {
162
+ return inert2;
163
+ }, {
164
+ get(_target, prop) {
165
+ if (prop === "then" || prop === Symbol.toPrimitive || prop === Symbol.iterator) return void 0;
166
+ return inert;
167
+ },
168
+ apply() {
169
+ return inert;
170
+ },
171
+ construct() {
172
+ return inert;
173
+ }
174
+ });
175
+ var sql = inert;
176
+ var eq = inert;
177
+ var bigint = inert;
178
+ var bigserial = inert;
179
+ var boolean = inert;
180
+ var foreignKey = inert;
181
+ var index = inert;
182
+ var integer = inert;
183
+ var jsonb = inert;
184
+ var pgEnum = inert;
185
+ var pgTable = inert;
186
+ var primaryKey = inert;
187
+ var text = inert;
188
+ var timestamp = inert;
189
+ var uniqueIndex = inert;
190
+
191
+ // dist/cli/._postgres-client.cli-shim.mjs
192
+ function isPostgresConfigured() {
193
+ return false;
194
+ }
195
+ function getPostgresDb() {
196
+ throw new Error(
197
+ "getPostgresDb() is unavailable in a published install -- this build ships no direct database connection."
198
+ );
199
+ }
200
+
201
+ // spex-console/src/lib/db/schema/interfaces.ts
202
+ var SINGLE_USER_ID = "pwher";
203
+ var DEFAULT_VAULT_ID = "default";
204
+
205
+ // spex-console/src/lib/db/schema/postgres.ts
206
+ var edgeTypeEnum = pgEnum("edge_type", [
207
+ "blocks",
208
+ "related",
209
+ "parent_child",
210
+ "discovered_from"
211
+ ]);
212
+ var validationSeverityEnum = pgEnum("validation_severity", ["error", "warning"]);
213
+ var outboxOpEnum = pgEnum("outbox_op", ["upsert", "delete"]);
214
+ var specs = pgTable(
215
+ "specs",
216
+ {
217
+ id: text("id").notNull(),
218
+ name: text("name").notNull(),
219
+ type: text("type").notNull(),
220
+ status: text("status").notNull(),
221
+ priority: integer("priority"),
222
+ owner: text("owner"),
223
+ created: text("created").notNull(),
224
+ // ISO date YYYY-MM-DD
225
+ updated: text("updated").notNull(),
226
+ // ISO date YYYY-MM-DD
227
+ relPath: text("rel_path").notNull(),
228
+ // Vault-relative POSIX path
229
+ bodyHash: text("body_hash").notNull(),
230
+ // SHA-256 of full file (frontmatter + body) for change detection
231
+ // Canonical authored body — re-added in 0020 ([[HS-TASK-UGUD]]); reverses the 0005 drop.
232
+ // Nullable until the write path ([[HS-TASK-X724]]) always populates it; UGUD backfills
233
+ // existing rows from the disk vault. NO read-path change yet (reads stay on disk until 4PH9).
234
+ bodyText: text("body_text"),
235
+ indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(),
236
+ // NOTE: `body_text` + `body_tsvector` were dropped in migration 0005
237
+ // ([[HS-EPIC-NLOC]]); the `embedding vector(1536)` column was dropped in
238
+ // 0004 (HS-EPIC-VSTR). HeySpex retrieval is lexical-only and LOCAL — no read
239
+ // tier in Neon — per [[retrieval-substrate-lexical-not-embeddings-gate-b-no]].
240
+ // WRT3 §Schema — single-user sentinel today; future multi-user routing.
241
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
242
+ // Index-isolation scope ([[HS-Z2KC-TASK]]): every index-table row is scoped
243
+ // by vault so >1 vault can share one Neon database without colliding on the
244
+ // specs PK or the (rel_path, user_id) unique index. DEFAULT keeps the ALTER
245
+ // safe on populated tables and single-vault installs under one bucket.
246
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
247
+ // WRT3 §Soft-delete — null = active. No hard-delete API is exposed.
248
+ deletedAt: timestamp("deleted_at", { withTimezone: true }),
249
+ // Explicit family key (Naming V3 step 1, [[HS-8O69-EPIC-LKF9-relational-family-graph-authoring]]):
250
+ // nullable until the backfill script runs; the indexer writes the frontmatter
251
+ // `family:` field when present, else derives it via `familyOf` (parse
252
+ // fallback from `id`/basename). Nothing should string-parse family from `id`
253
+ // once this column is populated — see `dev-factory/handoffs/NAMING-V3-MIGRATION-BRIEF.md`.
254
+ family: text("family")
255
+ },
256
+ (table) => [
257
+ // Composite PK (vault_id, id): the same HS-XXXX id may exist once PER VAULT.
258
+ primaryKey({ columns: [table.vaultId, table.id] }),
259
+ // SpexConsole main list views filter by type + status
260
+ index("specs_type_status_idx").on(table.type, table.status),
261
+ // Family-graph lookups (Naming V3 step 1) — group specs by family key.
262
+ index("specs_family_idx").on(table.family),
263
+ // Per-user + per-vault lookups + filtering out soft-deleted rows in the hot path
264
+ index("specs_user_active_idx").on(table.userId, table.vaultId).where(sql`${table.deletedAt} IS NULL`),
265
+ // One-file-one-row guarantee: at most one ACTIVE row per (rel_path, user,
266
+ // vault). Without this two rows could share a rel_path under different id
267
+ // forms (bare vs full-slug) and render as a duplicate-key error. Partial
268
+ // (deleted_at IS NULL) so soft-deleted orphans don't block re-indexing. The
269
+ // upsert path soft-deletes any conflicting row first. Scoped by vault so the
270
+ // same rel_path can be indexed independently under two vaults.
271
+ uniqueIndex("specs_relpath_user_uq").on(table.relPath, table.userId, table.vaultId).where(sql`${table.deletedAt} IS NULL`)
272
+ ]
273
+ );
274
+ var tags = pgTable(
275
+ "tags",
276
+ {
277
+ specId: text("spec_id").notNull(),
278
+ tag: text("tag").notNull(),
279
+ inFrontmatter: boolean("in_frontmatter").notNull(),
280
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
281
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID)
282
+ },
283
+ (table) => [
284
+ primaryKey({ columns: [table.vaultId, table.specId, table.tag] }),
285
+ foreignKey({
286
+ columns: [table.vaultId, table.specId],
287
+ foreignColumns: [specs.vaultId, specs.id]
288
+ }).onDelete("cascade")
289
+ ]
290
+ );
291
+ var edges = pgTable(
292
+ "edges",
293
+ {
294
+ fromId: text("from_id").notNull(),
295
+ toId: text("to_id").notNull(),
296
+ edgeType: edgeTypeEnum("edge_type").notNull().$type(),
297
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
298
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID)
299
+ },
300
+ (table) => [
301
+ primaryKey({ columns: [table.vaultId, table.fromId, table.toId, table.edgeType] }),
302
+ foreignKey({
303
+ columns: [table.vaultId, table.fromId],
304
+ foreignColumns: [specs.vaultId, specs.id]
305
+ }).onDelete("cascade"),
306
+ foreignKey({
307
+ columns: [table.vaultId, table.toId],
308
+ foreignColumns: [specs.vaultId, specs.id]
309
+ }).onDelete("cascade"),
310
+ // Forward dependency lookups (what does this spec block?)
311
+ index("edges_from_type_idx").on(table.fromId, table.edgeType),
312
+ // Reverse dependency lookups (what blocks this spec?)
313
+ index("edges_to_type_idx").on(table.toId, table.edgeType)
314
+ ]
315
+ );
316
+ var wikilinks = pgTable(
317
+ "wikilinks",
318
+ {
319
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
320
+ fromId: text("from_id").notNull(),
321
+ targetBasename: text("target_basename").notNull(),
322
+ resolvedId: text("resolved_id"),
323
+ inFrontmatter: boolean("in_frontmatter").notNull(),
324
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
325
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID)
326
+ },
327
+ (table) => [
328
+ // Surrogate PK `id` stays; the composite FKs scope both link ends to the
329
+ // owning vault's specs. resolvedId is nullable, so its composite FK is only
330
+ // enforced when BOTH columns are non-null (MATCH SIMPLE — Postgres default),
331
+ // which lets an unresolved forward reference keep resolvedId NULL.
332
+ foreignKey({
333
+ columns: [table.vaultId, table.fromId],
334
+ foreignColumns: [specs.vaultId, specs.id]
335
+ }).onDelete("cascade"),
336
+ // CAUTION (migration 0049): in the live DB this constraint carries a PG-15+
337
+ // column list — ON DELETE SET NULL ("resolved_id") — because a plain
338
+ // composite SET NULL nulls vault_id too (NOT NULL → 23502 on any delete of
339
+ // a spec with inbound resolved links). Drizzle cannot express the column
340
+ // list, so this declaration is the closest approximation: do NOT let a
341
+ // future drizzle-kit generate "correct" the constraint back to plain
342
+ // SET NULL — that reintroduces the delete-blocking bug.
343
+ foreignKey({
344
+ columns: [table.vaultId, table.resolvedId],
345
+ foreignColumns: [specs.vaultId, specs.id]
346
+ }).onDelete("set null"),
347
+ // Backlink queries: find all specs linking to a target
348
+ index("wikilinks_target_idx").on(table.targetBasename)
349
+ ]
350
+ );
351
+ var aliases = pgTable(
352
+ "aliases",
353
+ {
354
+ specId: text("spec_id").notNull(),
355
+ alias: text("alias").notNull(),
356
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
357
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID)
358
+ },
359
+ (table) => [
360
+ primaryKey({ columns: [table.vaultId, table.specId, table.alias] }),
361
+ foreignKey({
362
+ columns: [table.vaultId, table.specId],
363
+ foreignColumns: [specs.vaultId, specs.id]
364
+ }).onDelete("cascade")
365
+ ]
366
+ );
367
+ var validationIssues = pgTable(
368
+ "validation_issues",
369
+ {
370
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
371
+ specId: text("spec_id").notNull(),
372
+ severity: validationSeverityEnum("severity").notNull().$type(),
373
+ code: text("code").notNull(),
374
+ // e.g. "unresolved_wikilink", "missing_frontmatter"
375
+ message: text("message").notNull(),
376
+ detectedAt: timestamp("detected_at", { withTimezone: true }).notNull(),
377
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
378
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID)
379
+ },
380
+ (table) => [
381
+ // Surrogate PK `id` stays; the composite FK scopes issues to the owning
382
+ // vault's specs.
383
+ foreignKey({
384
+ columns: [table.vaultId, table.specId],
385
+ foreignColumns: [specs.vaultId, specs.id]
386
+ }).onDelete("cascade"),
387
+ // Query issues by spec
388
+ index("validation_issues_spec_idx").on(table.specId),
389
+ // Query issues by severity (e.g., show all errors)
390
+ index("validation_issues_severity_idx").on(table.severity)
391
+ ]
392
+ );
393
+ var indexMeta = pgTable(
394
+ "index_meta",
395
+ {
396
+ key: text("key").notNull(),
397
+ // e.g. "schema_version", "last_indexed_commit"
398
+ value: text("value").notNull(),
399
+ // Scope indexer state (last_indexed_commit, schema_version, last_rebuild_at,
400
+ // and the progress_relay:<userId> KV) per vault so two vaults keep
401
+ // independent commit pointers. Composite PK (vault_id, key).
402
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID)
403
+ },
404
+ (table) => [primaryKey({ columns: [table.vaultId, table.key] })]
405
+ );
406
+ var frontmatterFields = pgTable(
407
+ "frontmatter_fields",
408
+ {
409
+ specId: text("spec_id").notNull(),
410
+ field: text("field").notNull(),
411
+ value: jsonb("value"),
412
+ // JSON value for flexible storage
413
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
414
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID)
415
+ },
416
+ (table) => [
417
+ primaryKey({ columns: [table.vaultId, table.specId, table.field] }),
418
+ foreignKey({
419
+ columns: [table.vaultId, table.specId],
420
+ foreignColumns: [specs.vaultId, specs.id]
421
+ }).onDelete("cascade")
422
+ ]
423
+ );
424
+ var eventOutbox = pgTable(
425
+ "event_outbox",
426
+ {
427
+ seq: bigserial("seq", { mode: "number" }).primaryKey(),
428
+ userId: text("user_id").notNull(),
429
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
430
+ // Additive: `seq` (bigserial) stays the PK; consumers that need to filter
431
+ // by owning vault (e.g. a per-vault snapshot job) can now do so — the
432
+ // global PartyKit / snapshot drains stay unfiltered by design (they drain
433
+ // every vault's outbox).
434
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
435
+ specId: text("spec_id").notNull(),
436
+ op: outboxOpEnum("op").notNull().$type(),
437
+ payload: jsonb("payload").notNull(),
438
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
439
+ publishedAt: timestamp("published_at", { withTimezone: true }),
440
+ snapshottedAt: timestamp("snapshotted_at", { withTimezone: true })
441
+ },
442
+ (table) => [
443
+ // PartyKit fan-out drain — unpublished rows in monotonic order
444
+ index("event_outbox_unpublished_idx").on(table.seq).where(sql`${table.publishedAt} IS NULL`),
445
+ // Snapshot-to-git drain — unsnapshotted rows in monotonic order
446
+ index("event_outbox_unsnapshotted_idx").on(table.seq).where(sql`${table.snapshottedAt} IS NULL`),
447
+ // Per-vault-per-user catch-up (`WHERE vault_id = ? AND user_id = ? AND seq > ?`)
448
+ index("event_outbox_user_seq_idx").on(table.vaultId, table.userId, table.seq)
449
+ ]
450
+ );
451
+ var captureSessions = pgTable(
452
+ "capture_sessions",
453
+ {
454
+ id: text("id").primaryKey(),
455
+ // HS-CAP-XXXX, minted by the writer
456
+ ownerUserId: text("owner_user_id").notNull(),
457
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]] of [[HS-TNTI-EPIC-saas-multi-tenant-isolation]]).
458
+ // Mirrors `specs.vault_id` EXACTLY (text, NOT NULL, DEFAULT 'default', no FK
459
+ // — `vaults` rows can be provisioned in Neon after an index row exists, so a
460
+ // hard FK would break the boot order; the id is the isolation key, not an
461
+ // entity reference). The PK stays the globally-minted `id` (capture ids are
462
+ // random tokens, not per-vault sequences); `vault_id` is the READ/WRITE
463
+ // filter so one user's two vaults never see each other's captures.
464
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
465
+ openedBy: text("opened_by").$type().notNull(),
466
+ openedBySession: text("opened_by_session").notNull(),
467
+ mode: text("mode").$type().notNull().default("attended"),
468
+ status: text("status").$type().notNull().default("pending"),
469
+ specPath: text("spec_path"),
470
+ contentBuffer: jsonb("content_buffer").notNull().default(sql`'{}'::jsonb`),
471
+ attachedAgents: jsonb("attached_agents").notNull().default(sql`'[]'::jsonb`),
472
+ captureChannel: text("capture_channel"),
473
+ traceId: text("trace_id"),
474
+ sourceUrl: text("source_url"),
475
+ /**
476
+ * Provenance columns for a capture created from the Agent Mirror canvas
477
+ * ([[HS-1ROV-EPIC-session-mirror-platform-claude-code]] §Stage 6a). Both
478
+ * nullable — every pre-existing writer (modal/agent/companion) leaves
479
+ * them unset. No FK (session/launch rows can expire or predate this
480
+ * column); `agent_session_id` is `agent_sessions.session_id`,
481
+ * `launch_id` is `launch_intents.id` by convention only.
482
+ */
483
+ agentSessionId: text("agent_session_id"),
484
+ launchId: text("launch_id"),
485
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
486
+ lastActivityAt: timestamp("last_activity_at", { withTimezone: true }).notNull().default(sql`now()`),
487
+ materializedAt: timestamp("materialized_at", { withTimezone: true }),
488
+ abandonedAt: timestamp("abandoned_at", { withTimezone: true })
489
+ },
490
+ (table) => [
491
+ // Per-vault + per-user lookup of in-flight captures (e.g. `/hs-capture` attach)
492
+ index("capture_sessions_pending_by_user").on(table.vaultId, table.ownerUserId, table.status).where(sql`${table.status} = 'pending'`),
493
+ // Companion `heyspex.capture/0.1` trace correlation
494
+ index("capture_sessions_companion_by_trace").on(table.traceId).where(sql`${table.traceId} IS NOT NULL`)
495
+ ]
496
+ );
497
+ var handoffContext = pgTable(
498
+ "handoff_context",
499
+ {
500
+ laneKey: text("lane_key").notNull(),
501
+ // agent/lane identifier — upsert key
502
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
503
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
504
+ // Part of the widened upsert key below: two vaults must keep independent
505
+ // CURRENT rows for the same (user, lane_key), not fight over one row.
506
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
507
+ agentId: text("agent_id").notNull(),
508
+ specId: text("spec_id"),
509
+ // current spec; plain text, no FK (id-form may be bare vs full-slug)
510
+ phase: text("phase"),
511
+ blockers: jsonb("blockers").notNull().default(sql`'[]'::jsonb`),
512
+ estimatedTime: text("estimated_time"),
513
+ links: jsonb("links").notNull().default(sql`'[]'::jsonb`),
514
+ schemaVersion: integer("schema_version").notNull().default(1),
515
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
516
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`),
517
+ // TTL — server-set on every upsert; reads filter `expires_at > now()`.
518
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull()
519
+ },
520
+ (table) => [
521
+ // Self-purge upsert key: one CURRENT row per (vault, user, lane). Widened
522
+ // ([[HS-ZP5H-TASK]] GAP 2) from (user, lane) — two vaults sharing the
523
+ // owner's user_id must not fight over one lane row.
524
+ uniqueIndex("handoff_context_user_lane_uq").on(table.vaultId, table.userId, table.laneKey),
525
+ // List-active-lanes read path (parallel-agent off-on-deck — PA7H).
526
+ index("handoff_context_user_expiry_idx").on(table.vaultId, table.userId, table.expiresAt)
527
+ ]
528
+ );
529
+ var claimEventKindEnum = pgEnum("claim_event_kind", [
530
+ "acquired",
531
+ "released",
532
+ "expired",
533
+ "stolen",
534
+ "status_updated",
535
+ // Sequencer decision log ([[HS-DO05-4U1E-EPIC-session-sequencer-in-the-console]]
536
+ // AC4): appended in the same transaction as the sequence assignment so the
537
+ // replay epic can reconstruct the order from events alone.
538
+ "sequence_assigned"
539
+ ]);
540
+ var activeClaimStatusEnum = pgEnum("active_claim_status", [
541
+ "working",
542
+ "pending",
543
+ "blocked"
544
+ ]);
545
+ var pendingReasonEnum = pgEnum("pending_reason", [
546
+ "question",
547
+ "permission",
548
+ "planning",
549
+ "response"
550
+ ]);
551
+ var focusEventKindEnum = pgEnum("focus_event_kind", ["set", "cleared"]);
552
+ var specEventKindEnum = pgEnum("spec_event_kind", [
553
+ "status_change",
554
+ "wikilink_added",
555
+ "wikilink_removed",
556
+ "review_requested",
557
+ "review_resolved",
558
+ "priority_change",
559
+ "parent_change",
560
+ "claim_acquired",
561
+ "claim_released"
562
+ ]);
563
+ var focusPin = pgTable(
564
+ "focus_pin",
565
+ {
566
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]]) — mirrors `specs.vault_id`. The
567
+ // singleton is per (vault, user), not per user: one user with two vaults
568
+ // pins independently in each.
569
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
570
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
571
+ specId: text("spec_id").notNull(),
572
+ pinnedBy: text("pinned_by").notNull(),
573
+ // agent identifier
574
+ pinnedAt: timestamp("pinned_at", { withTimezone: true }).notNull().default(sql`now()`),
575
+ // Silent fuse — null means no expiry; reads treat `expires_at <= now()` as cleared.
576
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
577
+ // Cross-milestone "do the prerequisite first, then come back" return target
578
+ // ([[HS-EPIC-QQBD]]): when the spec named by `return_on_close_of` closes, the
579
+ // status route re-pins `return_to_spec_id` and clears both. Both null = an
580
+ // ordinary pin with no auto-return. Nullable + additive (no data backfill).
581
+ returnToSpecId: text("return_to_spec_id"),
582
+ returnOnCloseOf: text("return_on_close_of"),
583
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
584
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
585
+ },
586
+ (table) => [primaryKey({ columns: [table.vaultId, table.userId] })]
587
+ );
588
+ var captureFocusPin = pgTable(
589
+ "capture_focus_pin",
590
+ {
591
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
592
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
593
+ specId: text("spec_id").notNull(),
594
+ pinnedBy: text("pinned_by").notNull(),
595
+ pinnedAt: timestamp("pinned_at", { withTimezone: true }).notNull().default(sql`now()`),
596
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
597
+ // Kept structurally identical to {@link focusPin} (pins.ts casts one table to
598
+ // the other); the capture flow never populates these return-target columns.
599
+ returnToSpecId: text("return_to_spec_id"),
600
+ returnOnCloseOf: text("return_on_close_of"),
601
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
602
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
603
+ },
604
+ (table) => [primaryKey({ columns: [table.vaultId, table.userId] })]
605
+ );
606
+ var captureFocusSelection = pgTable(
607
+ "capture_focus_selection",
608
+ {
609
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
610
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
611
+ // The resolved spec path of the current capture on deck (layer-2 core).
612
+ specPath: text("spec_path"),
613
+ // Capture kind — "idea" today; text, validated app-side, kept open.
614
+ kind: text("kind"),
615
+ // Free-form note for why this capture is pinned (e.g. "Pinned from console").
616
+ reason: text("reason"),
617
+ // The agent display name currently claiming the capture (doc-claim-badge).
618
+ claimedBy: text("claimed_by"),
619
+ // The claiming agent's session id (claim refresh surface).
620
+ claimSession: text("claim_session"),
621
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
622
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
623
+ },
624
+ (table) => [primaryKey({ columns: [table.vaultId, table.userId] })]
625
+ );
626
+ var focusSelection = pgTable(
627
+ "focus_selection",
628
+ {
629
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
630
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
631
+ // The resolved spec path of the current focus (layer-2 core; scheduler tier #2).
632
+ specPath: text("spec_path"),
633
+ // Grain: "milestone" | "epic" | "task" (FocusKind). Text, not an enum — the
634
+ // vocabulary is validated app-side and kept open for forward grains.
635
+ kind: text("kind"),
636
+ // Provenance: "human" | "algorithm" | "human_switched_from_blocker" (FocusProvenance).
637
+ pickedBy: text("picked_by"),
638
+ pickedAt: timestamp("picked_at", { withTimezone: true }),
639
+ // The FocusReason object (trigger / formula / score / runners_up[]) as JSONB.
640
+ pickedReason: jsonb("picked_reason"),
641
+ // The blocked epic switched away from (only when picked_by = human_switched_from_blocker).
642
+ blockerOrigin: text("blocker_origin"),
643
+ // Free-form note when a human overrides the algorithm pick (Why-Now panel).
644
+ overrideReason: text("override_reason"),
645
+ // Legacy FocusState fields kept for backcompat with pre-Phase-3 writers.
646
+ reason: text("reason"),
647
+ expires: timestamp("expires", { withTimezone: true }),
648
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
649
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
650
+ },
651
+ (table) => [primaryKey({ columns: [table.vaultId, table.userId] })]
652
+ );
653
+ var focusSkips = pgTable(
654
+ "focus_skips",
655
+ {
656
+ // Insertion-order key — reads order by this so the skip set keeps file order.
657
+ id: bigserial("id", { mode: "number" }).notNull().unique(),
658
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]]) — mirrors `specs.vault_id`.
659
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
660
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
661
+ specId: text("spec_id").notNull(),
662
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
663
+ },
664
+ (table) => [
665
+ // PK widened to (vault_id, user_id, spec_id): the same HS-XXXX id exists once
666
+ // PER VAULT, so a skip in vault A must not collide with (or hide) the same id
667
+ // in vault B. Strict superset of the old (user_id, spec_id) key — existing
668
+ // rows stay unique under it.
669
+ primaryKey({ columns: [table.vaultId, table.userId, table.specId] }),
670
+ // "this vault+user's skips in insertion order" — the list query.
671
+ index("focus_skips_user_order_idx").on(table.vaultId, table.userId, table.id)
672
+ ]
673
+ );
674
+ var activeClaims = pgTable(
675
+ "active_claims",
676
+ {
677
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]]) — mirrors `specs.vault_id`. Part
678
+ // of the PK, so the atomic-acquire UNIQUE is per (vault, user, spec): vault A
679
+ // holding HS-XXXX no longer blocks vault B from claiming ITS OWN HS-XXXX.
680
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
681
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
682
+ specId: text("spec_id").notNull(),
683
+ agentId: text("agent_id").notNull(),
684
+ sessionId: text("session_id").notNull(),
685
+ claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().default(sql`now()`),
686
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
687
+ // Live agent-lifecycle signals — mirror the file-side `ActiveClaim` shape from
688
+ // [[HS-TASK-0LU3]] so the On-Deck v2 surface can distinguish Working / Pending /
689
+ // Stale / Blocked when claims migrate off `.heyspex/active-claims.json` ([[HS-TASK-ACLM]]).
690
+ // `effectiveStatus` + `staleAt` stay derived at read time (not persisted).
691
+ status: activeClaimStatusEnum("status").notNull().default("working"),
692
+ lastActivityAt: timestamp("last_activity_at", { withTimezone: true }).notNull().default(sql`now()`),
693
+ /** Fast-death heartbeat ([[HS-TASK-A3FE]]); distinct from minutes-scale `expires_at` lease. */
694
+ heartbeatAt: timestamp("heartbeat_at", { withTimezone: true }),
695
+ pendingReason: pendingReasonEnum("pending_reason"),
696
+ pendingExcerpt: text("pending_excerpt"),
697
+ errorMessage: text("error_message"),
698
+ // Live agent work-stage ([[HS-EPIC-... What's Next Phase 2]]): the
699
+ // present-progressive verb the agent reports as it works (one of WORK_STAGES,
700
+ // e.g. "Drafting") and the 0-based acceptance-criterion index it is currently
701
+ // on. Both nullable — a claim need not report progress. Plain text/integer (not
702
+ // a pg enum) so the verb vocabulary can evolve without an enum migration; the
703
+ // closed list is enforced in code (src/lib/work-stages.ts) at the write route.
704
+ currentStage: text("current_stage"),
705
+ acProgress: integer("ac_progress"),
706
+ // Durable agent identity↔session binding ([[HS-EPIC-96YK]] AC#2): the
707
+ // identity `name` slug this claim is operating as (e.g. "code-reviewer"),
708
+ // so the Agents surface binds presence to the named identity instead of the
709
+ // best-effort agent-string match. Nullable — a claim need not declare one.
710
+ agentProfile: text("agent_profile"),
711
+ // Close-out verification gate ([[HS-EPIC-P2TR]]). `close_permitted` blocks the
712
+ // `in_review` flip until an independent verifier (or the deterministic manifest
713
+ // runner) grants it via the verification API — the implementer's self-reported
714
+ // `ac_progress` is demoted to a display-only claim. `close_granted_by` records
715
+ // the granting authority (agent id or "human"); `verification_ac_state` holds
716
+ // the per-criterion `AcCriterionState[]` provenance array.
717
+ closePermitted: boolean("close_permitted").notNull().default(false),
718
+ closeGrantedAt: timestamp("close_granted_at", { withTimezone: true }),
719
+ closeGrantedBy: text("close_granted_by"),
720
+ verificationAcState: jsonb("verification_ac_state").$type().notNull().default(sql`'[]'::jsonb`),
721
+ // Session-sequencer global order ([[HS-DO05-4U1E-EPIC-session-sequencer-in-the-console]] AC1):
722
+ // database-minted, strictly increasing across the table — therefore strictly
723
+ // increasing within every vault (gaps allowed; ordering is the contract).
724
+ sequence: bigserial("sequence", { mode: "number" }).notNull().unique(),
725
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
726
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
727
+ },
728
+ (table) => [primaryKey({ columns: [table.vaultId, table.userId, table.specId] })]
729
+ );
730
+ var claimWaiters = pgTable(
731
+ "claim_waiters",
732
+ {
733
+ id: bigserial("id", { mode: "number" }).primaryKey(),
734
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
735
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
736
+ specId: text("spec_id").notNull(),
737
+ agentId: text("agent_id").notNull(),
738
+ sessionId: text("session_id").notNull(),
739
+ // Shared global order: DEFAULT nextval on the claims sequence, not a
740
+ // per-table bigserial (which would mint a second, unrelated counter).
741
+ sequence: bigint("sequence", { mode: "number" }).notNull().default(sql`nextval('active_claims_sequence_seq')`),
742
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
743
+ },
744
+ (table) => [
745
+ // "who is queued behind this spec, in order" — the grant + position query.
746
+ index("claim_waiters_spec_seq_idx").on(
747
+ table.vaultId,
748
+ table.userId,
749
+ table.specId,
750
+ table.sequence
751
+ )
752
+ ]
753
+ );
754
+ var claimEvents = pgTable(
755
+ "claim_events",
756
+ {
757
+ id: bigserial("id", { mode: "number" }).primaryKey(),
758
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
759
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
760
+ // Additive: the `id` PK is untouched (append-only log, no upsert key).
761
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
762
+ eventKind: claimEventKindEnum("event_kind").notNull(),
763
+ specId: text("spec_id").notNull(),
764
+ agentId: text("agent_id").notNull(),
765
+ timestamp: timestamp("timestamp", { withTimezone: true }).notNull().default(sql`now()`),
766
+ metadata: jsonb("metadata").notNull().default(sql`'{}'::jsonb`),
767
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
768
+ },
769
+ (table) => [
770
+ // "events for this spec, newest first" — the common query (NCPS §indexes).
771
+ index("claim_events_spec_ts_idx").on(
772
+ table.vaultId,
773
+ table.userId,
774
+ table.specId,
775
+ table.timestamp.desc()
776
+ )
777
+ ]
778
+ );
779
+ var focusEvents = pgTable(
780
+ "focus_events",
781
+ {
782
+ id: bigserial("id", { mode: "number" }).primaryKey(),
783
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
784
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
785
+ // Additive: the `id` PK is untouched (append-only log, no upsert key).
786
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
787
+ eventKind: focusEventKindEnum("event_kind").notNull(),
788
+ specId: text("spec_id"),
789
+ // null for `cleared`
790
+ actor: text("actor").notNull(),
791
+ timestamp: timestamp("timestamp", { withTimezone: true }).notNull().default(sql`now()`),
792
+ metadata: jsonb("metadata").notNull().default(sql`'{}'::jsonb`),
793
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
794
+ },
795
+ (table) => [
796
+ index("focus_events_spec_ts_idx").on(
797
+ table.vaultId,
798
+ table.userId,
799
+ table.specId,
800
+ table.timestamp.desc()
801
+ )
802
+ ]
803
+ );
804
+ var focusAreas = pgTable(
805
+ "focus_areas",
806
+ {
807
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]]) — mirrors `specs.vault_id`. The
808
+ // band is per (vault, user): a tag band set in vault A must not lift specs
809
+ // in vault B.
810
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
811
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
812
+ version: integer("version").notNull().default(1),
813
+ rationale: text("rationale"),
814
+ // The tag set the focus-band pipeline stage matches against (jsonb string[]).
815
+ tags: jsonb("tags").notNull().default(sql`'[]'::jsonb`),
816
+ updatedBy: text("updated_by"),
817
+ // agent · session that last wrote the band
818
+ // Reserved fuse — null means no expiry. A2 writes it; enforcement is deferred.
819
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
820
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
821
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
822
+ },
823
+ (table) => [primaryKey({ columns: [table.vaultId, table.userId] })]
824
+ );
825
+ var focusAreasEvents = pgTable(
826
+ "focus_areas_events",
827
+ {
828
+ id: bigserial("id", { mode: "number" }).primaryKey(),
829
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
830
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
831
+ // Additive: the `id` PK is untouched (append-only log, no upsert key).
832
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
833
+ eventKind: focusEventKindEnum("event_kind").notNull(),
834
+ actor: text("actor").notNull(),
835
+ timestamp: timestamp("timestamp", { withTimezone: true }).notNull().default(sql`now()`),
836
+ metadata: jsonb("metadata").notNull().default(sql`'{}'::jsonb`),
837
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
838
+ },
839
+ (table) => [
840
+ index("focus_areas_events_ts_idx").on(table.vaultId, table.userId, table.timestamp.desc())
841
+ ]
842
+ );
843
+ var specEvents = pgTable(
844
+ "spec_events",
845
+ {
846
+ id: bigserial("id", { mode: "number" }).primaryKey(),
847
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
848
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
849
+ // Additive: the `id` PK is untouched (append-only log, no upsert key).
850
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
851
+ eventKind: specEventKindEnum("event_kind").notNull(),
852
+ specId: text("spec_id").notNull(),
853
+ actor: text("actor").notNull(),
854
+ timestamp: timestamp("timestamp", { withTimezone: true }).notNull().default(sql`now()`),
855
+ metadata: jsonb("metadata").notNull().default(sql`'{}'::jsonb`),
856
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
857
+ },
858
+ (table) => [
859
+ // High-traffic "events for spec X" query (<50ms target — NCPS AC #5).
860
+ index("spec_events_spec_ts_idx").on(
861
+ table.vaultId,
862
+ table.userId,
863
+ table.specId,
864
+ table.timestamp.desc()
865
+ ),
866
+ // `?kind=` filtered query.
867
+ index("spec_events_spec_kind_ts_idx").on(
868
+ table.vaultId,
869
+ table.userId,
870
+ table.specId,
871
+ table.eventKind,
872
+ table.timestamp.desc()
873
+ )
874
+ ]
875
+ );
876
+ var rateLimitBuckets = pgTable(
877
+ "rate_limit_buckets",
878
+ {
879
+ userId: text("user_id").notNull(),
880
+ bucket: text("bucket").notNull(),
881
+ windowStart: timestamp("window_start", { withTimezone: true }).notNull(),
882
+ count: integer("count").notNull().default(0),
883
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
884
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
885
+ },
886
+ (table) => [
887
+ primaryKey({ columns: [table.userId, table.bucket, table.windowStart] }),
888
+ // Background reaper / dashboards: enumerate stale rows by `window_start`
889
+ // older than the longest live window. The hot path uses the composite PK.
890
+ index("rate_limit_buckets_window_idx").on(table.windowStart)
891
+ ]
892
+ );
893
+ var userVaults = pgTable("user_vaults", {
894
+ userId: text("user_id").primaryKey(),
895
+ vaultRepo: text("vault_repo").notNull(),
896
+ /** GitHub App installation id — set by `/api/github/callback` before a vault is linked. */
897
+ installationId: integer("installation_id"),
898
+ vaultLocalPath: text("vault_local_path"),
899
+ repoName: text("repo_name"),
900
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
901
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
902
+ });
903
+ var milestoneSnapshots = pgTable(
904
+ "milestone_snapshots",
905
+ {
906
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]]) — mirrors `specs.vault_id`. Part
907
+ // of the PK: two vaults each hold their own frozen rank for the same
908
+ // milestone id.
909
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
910
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
911
+ milestoneId: text("milestone_id").notNull(),
912
+ snapshotVersion: integer("snapshot_version").notNull().default(1),
913
+ snapshotAt: timestamp("snapshot_at", { withTimezone: true }).notNull().defaultNow(),
914
+ schedulerInputDigest: text("scheduler_input_digest"),
915
+ epicOrder: jsonb("epic_order").$type().notNull(),
916
+ appendedAfterSnapshot: jsonb("appended_after_snapshot").$type().notNull().default(sql`'{"epics":[],"tasks_by_epic":{}}'::jsonb`),
917
+ dormantAt: timestamp("dormant_at", { withTimezone: true })
918
+ },
919
+ (table) => [
920
+ primaryKey({ columns: [table.vaultId, table.userId, table.milestoneId] }),
921
+ // "active snapshot for vault+user" hot lookup — only non-dormant rows.
922
+ index("ms_user_active_idx").on(table.vaultId, table.userId).where(sql`${table.dormantAt} IS NULL`)
923
+ ]
924
+ );
925
+ var modelRegistryEntries = pgTable(
926
+ "model_registry_entries",
927
+ {
928
+ id: text("id").notNull(),
929
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
930
+ model: text("model").notNull(),
931
+ agent: text("agent").notNull(),
932
+ shell: text("shell").notNull(),
933
+ enabled: boolean("enabled").notNull().default(true),
934
+ isDefault: boolean("is_default").notNull().default(false),
935
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
936
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
937
+ },
938
+ (table) => [
939
+ primaryKey({ columns: [table.userId, table.id] }),
940
+ index("model_registry_entries_user_default_idx").on(table.userId, table.isDefault)
941
+ ]
942
+ );
943
+ var launchIntentStatusEnum = pgEnum("launch_intent_status", [
944
+ "pending",
945
+ // emitted by the browser, not yet claimed by a companion
946
+ "claimed",
947
+ // a companion poller took it (claim-on-read) and is spawning
948
+ "done",
949
+ // the companion ACKed after spawning the local agent
950
+ "failed"
951
+ // the companion claimed it but the local spawn threw (see last_error)
952
+ ]);
953
+ var launchIntents = pgTable(
954
+ "launch_intents",
955
+ {
956
+ id: text("id").primaryKey(),
957
+ // uuid, client-opaque intent id
958
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
959
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
960
+ // Additive: `id` (a caller-minted uuid) stays the PK; `vault_id` is the
961
+ // READ/WRITE filter so the SAME owner's two vaults never drain each
962
+ // other's spawn/mint/mirror-send intents.
963
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
964
+ // The `/hs-next <id>` target spec (idempotency key for the optimistic
965
+ // "Launching…" face); null for a bare `/hs-next` launch.
966
+ specId: text("spec_id"),
967
+ prompt: text("prompt").notNull(),
968
+ // the `/hs-next …` prompt to seed the agent
969
+ registryEntryId: text("registry_entry_id"),
970
+ agent: text("agent"),
971
+ /** When false, companion uses `cmd /c` on Windows so the tab closes when the agent exits. */
972
+ keepTerminalOpen: boolean("keep_terminal_open").notNull().default(true),
973
+ // Resolved launch target ([[HS-EPIC-JLTL]] model+effort assignment). Nullable
974
+ // and additive: an old companion simply ignores them and spawns with CLI
975
+ // defaults — exactly the pre-column behavior.
976
+ model: text("model"),
977
+ reasoningEffort: text("reasoning_effort"),
978
+ // Companion-reported spawn failure detail (status `failed`) — the write-back
979
+ // channel that lets the cloud UI stop toasting optimistic success.
980
+ lastError: text("last_error"),
981
+ status: launchIntentStatusEnum("status").notNull().default("pending"),
982
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
983
+ // Set when a companion claims the intent (pending → claimed).
984
+ claimedAt: timestamp("claimed_at", { withTimezone: true }),
985
+ // TTL fuse — server-set on emit; the drain filters `expires_at > now()` and
986
+ // emit reaps already-expired rows so a dead browser tab can't strand work.
987
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull()
988
+ },
989
+ (table) => [
990
+ // Drain query: "oldest pending (non-expired) intents for this vault+user".
991
+ index("launch_intents_user_status_idx").on(
992
+ table.vaultId,
993
+ table.userId,
994
+ table.status,
995
+ table.createdAt
996
+ )
997
+ ]
998
+ );
999
+ var companionRelayPresence = pgTable(
1000
+ "companion_relay_presence",
1001
+ {
1002
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
1003
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
1004
+ lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull()
1005
+ },
1006
+ (table) => [primaryKey({ columns: [table.userId, table.vaultId] })]
1007
+ );
1008
+ var companionSettingsStore = pgTable(
1009
+ "companion_settings",
1010
+ {
1011
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
1012
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
1013
+ settings: jsonb("settings").notNull(),
1014
+ telemetry: jsonb("telemetry"),
1015
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
1016
+ },
1017
+ (table) => [primaryKey({ columns: [table.userId, table.vaultId] })]
1018
+ );
1019
+ var wipLedger = pgTable(
1020
+ "wip_ledger",
1021
+ {
1022
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
1023
+ /** Epic/spec id the hand-off belongs to (short id, e.g. "HS-COYG"). */
1024
+ specId: text("spec_id").notNull(),
1025
+ /** The full WipEntry blob — see `WipEntry` in `src/lib/wip-state.ts`. */
1026
+ payload: jsonb("payload").notNull(),
1027
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
1028
+ },
1029
+ (table) => [primaryKey({ columns: [table.vaultId, table.specId] })]
1030
+ );
1031
+ var gatherResults = pgTable(
1032
+ "gather_results",
1033
+ {
1034
+ jobId: text("job_id").notNull(),
1035
+ idempotencyKey: text("idempotency_key").notNull(),
1036
+ /** Per-page result entries — `[{ url, state: "cleaned"|"blocked"|"failed", ... }]`. */
1037
+ pages: jsonb("pages").notNull(),
1038
+ /**
1039
+ * A research job's engine-level search account — `{ query,
1040
+ * enginesAttempted, enginesAnswered, resultsFound, resultsGathered,
1041
+ * summary, engines: [{ engine, answered, results, reason?, httpStatus? }] }`.
1042
+ * Null for plain gather/crawl jobs (nothing was searched) and for uploads
1043
+ * from Companions built before the report existed. This is what lets a
1044
+ * zero-page search result say WHY it is empty
1045
+ * ([[HS-RIZN-DIL5-DIAG-spexcrawl-search-returns-zero-pages-on]]).
1046
+ */
1047
+ search: jsonb("search"),
1048
+ createdAt: timestamp("created_at", { withTimezone: true }).default(sql`now()`)
1049
+ },
1050
+ (table) => [primaryKey({ columns: [table.jobId, table.idempotencyKey] })]
1051
+ );
1052
+ var agentSessions = pgTable(
1053
+ "agent_sessions",
1054
+ {
1055
+ sessionId: text("session_id").primaryKey(),
1056
+ // FK-by-convention to launch_intents.id — no hard FK, intents expire/sweep
1057
+ // while the session row (and its usage history) must outlive them.
1058
+ launchId: text("launch_id"),
1059
+ ownerUserId: text("owner_user_id").notNull().default(SINGLE_USER_ID),
1060
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]]) — mirrors `specs.vault_id`. The
1061
+ // PK stays the vendor-minted `session_id` (a globally-unique uuid, not a
1062
+ // per-vault sequence); `vault_id` is the READ/WRITE filter.
1063
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
1064
+ vendor: text("vendor").notNull(),
1065
+ // "claude-code" now; enum grows later (read-only viewer vendors)
1066
+ machine: text("machine"),
1067
+ cwd: text("cwd"),
1068
+ transcriptPath: text("transcript_path"),
1069
+ // Session-chain lineage after /clear, /branch, /rewind, --fork-session.
1070
+ parentSessionId: text("parent_session_id"),
1071
+ status: text("status").notNull().default("live"),
1072
+ // live | ended | stale
1073
+ startedAt: timestamp("started_at", { withTimezone: true }).notNull().default(sql`now()`),
1074
+ endedAt: timestamp("ended_at", { withTimezone: true }),
1075
+ lastEventAt: timestamp("last_event_at", { withTimezone: true }).notNull().default(sql`now()`)
1076
+ },
1077
+ (table) => [
1078
+ // Launch drill-down: "sessions bound to this launch".
1079
+ index("agent_sessions_launch_idx").on(table.launchId),
1080
+ // "Live sessions on this machine" attach flow, scoped per vault.
1081
+ index("agent_sessions_owner_status_idx").on(table.vaultId, table.ownerUserId, table.status)
1082
+ ]
1083
+ );
1084
+ var usageEvents = pgTable(
1085
+ "usage_events",
1086
+ {
1087
+ id: bigserial("id", { mode: "number" }).primaryKey(),
1088
+ sessionId: text("session_id").notNull(),
1089
+ launchId: text("launch_id"),
1090
+ requestId: text("request_id").notNull(),
1091
+ model: text("model").notNull(),
1092
+ lane: text("lane").notNull(),
1093
+ // subscription | api | bundled_local
1094
+ inputTokens: integer("input_tokens").notNull(),
1095
+ outputTokens: integer("output_tokens").notNull(),
1096
+ cacheReadTokens: integer("cache_read_tokens").notNull(),
1097
+ cacheCreationTokens: integer("cache_creation_tokens").notNull(),
1098
+ cacheCreation5m: integer("cache_creation_5m"),
1099
+ cacheCreation1h: integer("cache_creation_1h"),
1100
+ isSidechain: boolean("is_sidechain").notNull().default(false),
1101
+ at: timestamp("at", { withTimezone: true }).notNull()
1102
+ },
1103
+ (table) => [
1104
+ // Keep-last dedupe key — the ingest route upserts on this so a re-POST of
1105
+ // the same request_id overwrites rather than duplicates.
1106
+ uniqueIndex("usage_events_session_request_idx").on(table.sessionId, table.requestId),
1107
+ // Launch drill-down: "usage rows for this launch, in order".
1108
+ index("usage_events_launch_at_idx").on(table.launchId, table.at)
1109
+ ]
1110
+ );
1111
+ var streamEvents = pgTable(
1112
+ "stream_events",
1113
+ {
1114
+ id: bigserial("id", { mode: "number" }).primaryKey(),
1115
+ streamId: text("stream_id").notNull(),
1116
+ // "mirror_session" now; "build" | "api" | "council" join later (HS-31Z5).
1117
+ streamKind: text("stream_kind").notNull(),
1118
+ // Producer-assigned dedupe key — stable across retries/transport hot-swaps.
1119
+ eventUuid: text("event_uuid").notNull(),
1120
+ eventType: text("event_type").notNull(),
1121
+ // e.g. "turn" | "status"
1122
+ payload: jsonb("payload").notNull(),
1123
+ at: timestamp("at", { withTimezone: true }).notNull(),
1124
+ // Short-retention TTL fuse — default now()+6h at insert; swept by cron.
1125
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull()
1126
+ },
1127
+ (table) => [
1128
+ // Producer dedupe — replays/hot-swaps land exactly once per (stream, uuid).
1129
+ uniqueIndex("stream_events_stream_uuid_idx").on(table.streamId, table.eventUuid),
1130
+ // Snapshot-on-connect: "rows for this stream, in order, after a cursor".
1131
+ index("stream_events_stream_id_idx").on(table.streamId, table.id)
1132
+ ]
1133
+ );
1134
+ var jobStreams = pgTable(
1135
+ "job_streams",
1136
+ {
1137
+ jobId: text("job_id").primaryKey(),
1138
+ kind: text("kind").notNull(),
1139
+ // build | api | council
1140
+ // FK-by-convention to launch_intents.id — no hard FK, same reasoning as
1141
+ // agent_sessions.launch_id (intents expire/sweep, the job row must outlive them).
1142
+ launchId: text("launch_id"),
1143
+ provider: text("provider"),
1144
+ // e.g. "deepseek" — the tile's `source`
1145
+ model: text("model"),
1146
+ seat: text("seat"),
1147
+ // routing-table seat name (dev-factory/routing-table.json)
1148
+ machine: text("machine"),
1149
+ status: text("status").notNull().default("queued"),
1150
+ // queued | running | done | failed
1151
+ startedAt: timestamp("started_at", { withTimezone: true }).notNull().default(sql`now()`),
1152
+ endedAt: timestamp("ended_at", { withTimezone: true }),
1153
+ lastEventAt: timestamp("last_event_at", { withTimezone: true }).notNull().default(sql`now()`)
1154
+ },
1155
+ (table) => [
1156
+ // "Live-ish jobs" read for the unioned stream list.
1157
+ index("job_streams_status_idx").on(table.status),
1158
+ // Launch drill-down: "job streams bound to this launch".
1159
+ index("job_streams_launch_idx").on(table.launchId)
1160
+ ]
1161
+ );
1162
+ var resolutionStatusEnum = pgEnum("resolution_status", [
1163
+ "open",
1164
+ "resolved",
1165
+ "dismissed",
1166
+ "deferred"
1167
+ ]);
1168
+ var resolutionFocusPin = pgTable(
1169
+ "resolution_focus_pin",
1170
+ {
1171
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
1172
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
1173
+ itemKey: text("item_key").notNull(),
1174
+ pinnedBy: text("pinned_by").notNull(),
1175
+ pinnedAt: timestamp("pinned_at", { withTimezone: true }).notNull().default(sql`now()`),
1176
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
1177
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
1178
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
1179
+ },
1180
+ (table) => [primaryKey({ columns: [table.vaultId, table.userId] })]
1181
+ );
1182
+ var resolutionLedger = pgTable(
1183
+ "resolution_ledger",
1184
+ {
1185
+ id: bigserial("id", { mode: "number" }).primaryKey(),
1186
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
1187
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]] GAP 2) — mirrors `specs.vault_id`.
1188
+ // Additive: the `id` PK is untouched (append-only log, no upsert key).
1189
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
1190
+ itemKey: text("item_key").notNull(),
1191
+ status: resolutionStatusEnum("status").$type().notNull(),
1192
+ reason: text("reason"),
1193
+ deferUntil: timestamp("defer_until", { withTimezone: true }),
1194
+ at: timestamp("at", { withTimezone: true }).notNull().default(sql`now()`),
1195
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
1196
+ },
1197
+ (table) => [
1198
+ // Fold-on-read hot path: newest status row per (vault, user, item_key).
1199
+ index("resolution_ledger_user_item_at_idx").on(
1200
+ table.vaultId,
1201
+ table.userId,
1202
+ table.itemKey,
1203
+ table.at.desc()
1204
+ )
1205
+ ]
1206
+ );
1207
+ var workspaces = pgTable("workspaces", {
1208
+ id: text("id").primaryKey(),
1209
+ accountId: text("account_id").notNull(),
1210
+ displayName: text("display_name").notNull(),
1211
+ githubOrg: text("github_org"),
1212
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
1213
+ /**
1214
+ * Reversible soft-remove flag (Close-is-not-Destroy,
1215
+ * [[HS-NBV9-EKLJ-TASK-archive-restore-and-the-close-is-not]]) — distinct
1216
+ * from `DELETE /api/workspaces/:id` unregister, which drops the row
1217
+ * entirely. An archived workspace stays in Neon/registry.json but is
1218
+ * excluded from the default active list.
1219
+ */
1220
+ archived: boolean("archived").notNull().default(false)
1221
+ });
1222
+ var vaults = pgTable("vaults", {
1223
+ id: text("id").primaryKey(),
1224
+ workspaceId: text("workspace_id").notNull().references(() => workspaces.id, { onDelete: "cascade" }),
1225
+ accountId: text("account_id").notNull(),
1226
+ displayName: text("display_name").notNull(),
1227
+ repoRootAbs: text("repo_root_abs"),
1228
+ githubRemote: text("github_remote"),
1229
+ schemaVersion: integer("schema_version").notNull().default(1),
1230
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
1231
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`),
1232
+ /** Vault equivalent of `workspaces.archived` — see that column's doc comment. */
1233
+ archived: boolean("archived").notNull().default(false)
1234
+ });
1235
+ var accountSettings = pgTable("account_settings", {
1236
+ accountId: text("account_id").primaryKey(),
1237
+ activeWorkspaceId: text("active_workspace_id").references(() => workspaces.id, {
1238
+ onDelete: "set null"
1239
+ }),
1240
+ activeVaultId: text("active_vault_id").references(() => vaults.id, { onDelete: "set null" }),
1241
+ // Admin dev/prod mode control (epic HS-Q6M2): the CLOUD rung of the dev-mode
1242
+ // resolution order (env → ~/.heyspex/dev-mode.json → THIS → default prod).
1243
+ // Shape: { mode: "prod"|"dev", reliance: "local"|"cloud", updated_at, source }.
1244
+ // Readers/writers MUST tolerate the column being absent (migration 0050 may
1245
+ // not have run yet on a given database) — catch 42703 and treat as unset.
1246
+ devMode: jsonb("dev_mode"),
1247
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
1248
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
1249
+ });
1250
+ var vaultGrants = pgTable(
1251
+ "vault_grants",
1252
+ {
1253
+ vaultId: text("vault_id").notNull().references(() => vaults.id, { onDelete: "cascade" }),
1254
+ accountId: text("account_id").notNull(),
1255
+ access: text("access").notNull().default("read"),
1256
+ grantedBy: text("granted_by").notNull(),
1257
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
1258
+ },
1259
+ (table) => [primaryKey({ columns: [table.vaultId, table.accountId] })]
1260
+ );
1261
+ var githubInstallations = pgTable(
1262
+ "github_installations",
1263
+ {
1264
+ accountId: text("account_id").notNull(),
1265
+ githubAccountLogin: text("github_account_login").notNull(),
1266
+ installationId: integer("installation_id").notNull(),
1267
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
1268
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
1269
+ },
1270
+ (table) => [primaryKey({ columns: [table.accountId, table.githubAccountLogin] })]
1271
+ );
1272
+ var fleetMachines = pgTable(
1273
+ "fleet_machines",
1274
+ {
1275
+ machineKey: text("machine_key").primaryKey(),
1276
+ label: text("label"),
1277
+ gpu: text("gpu"),
1278
+ vramMb: integer("vram_mb"),
1279
+ ramMb: integer("ram_mb"),
1280
+ architecture: text("architecture"),
1281
+ // cuda | rocm | cpu
1282
+ os: text("os"),
1283
+ companionVersion: text("companion_version"),
1284
+ /**
1285
+ * Owning account, stamped from the redeemed join code's issuer at
1286
+ * `POST /api/fleet/join` ([[HS-DOD0-DIAG-fleet-tables-have-no-account-linkage]]).
1287
+ * Nullable: bearer-only `POST /api/fleet/register` registrations and
1288
+ * pre-migration machines have no attributable account ("unattributed" is the
1289
+ * honest state, not a fabricated owner default). The upsert COALESCEs so a
1290
+ * bearer re-register never overwrites a set account_id with null. Prerequisite
1291
+ * for per-account fleet isolation ([[HS-TNTI-EPIC-saas-multi-tenant-isolation]]).
1292
+ */
1293
+ accountId: text("account_id"),
1294
+ /**
1295
+ * Task kinds this machine is willing to run ([[HS-60Z1-EPIC]]). NULL = any kind
1296
+ * (the pre-existing behavior, preserved for machines registered before this
1297
+ * column existed). Eligibility used to be model-only, so a build box could claim
1298
+ * a `research` task it had no way to execute — `claimNextTask` now also filters
1299
+ * on this list when it is set.
1300
+ */
1301
+ eligibleKinds: text("eligible_kinds").array(),
1302
+ lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
1303
+ registeredAt: timestamp("registered_at", { withTimezone: true }).default(sql`now()`)
1304
+ },
1305
+ (table) => [index("fleet_machines_account_idx").on(table.accountId)]
1306
+ );
1307
+ var fleetTasks = pgTable(
1308
+ "fleet_tasks",
1309
+ {
1310
+ taskId: text("task_id").primaryKey(),
1311
+ kind: text("kind").notNull(),
1312
+ // build | breakdown | research | ...
1313
+ modelId: text("model_id"),
1314
+ spec: jsonb("spec").notNull(),
1315
+ target: text("target"),
1316
+ budgetMaxSeconds: integer("budget_max_seconds"),
1317
+ status: text("status").notNull().default("queued"),
1318
+ // queued|claimed|running|done|failed|cancelled
1319
+ claimedBy: text("claimed_by").references(() => fleetMachines.machineKey),
1320
+ claimedAt: timestamp("claimed_at", { withTimezone: true }),
1321
+ lastProgressAt: timestamp("last_progress_at", { withTimezone: true }),
1322
+ /**
1323
+ * The fleet-queue lane ([[HS-60Z1-EPIC]]) — a free-form name for a gated resource
1324
+ * whose work waits its turn (`research-firecrawl`, `build-gpu0`, …). Deliberately
1325
+ * NOT folded into `kind`: `kind` is an existing taxonomy (build|breakdown|research)
1326
+ * that the companion's model-eligibility matching already consumes, and overloading
1327
+ * it would change what shipped callers claim. NULL = a plain dispatch task with no
1328
+ * lane, i.e. every task written before this column existed.
1329
+ */
1330
+ lane: text("lane"),
1331
+ /** Who asked for this work (agent id / session). First-class so the queue can be filtered by requester. */
1332
+ requestedBy: text("requested_by"),
1333
+ /**
1334
+ * Stale-claim provenance. The stall sweep resets `claimed_by` to NULL so a healthy
1335
+ * machine can re-claim; without these two columns the identity of the machine that
1336
+ * dropped the task is erased, and "who had it before it went stale" is unanswerable.
1337
+ * The sweep copies claimed_by/claimed_at here before nulling them, so a reclaim is
1338
+ * visible (`reclaimed: true`) rather than silent.
1339
+ */
1340
+ lastClaimant: text("last_claimant"),
1341
+ lastClaimedAt: timestamp("last_claimed_at", { withTimezone: true }),
1342
+ createdAt: timestamp("created_at", { withTimezone: true }).default(sql`now()`)
1343
+ },
1344
+ (table) => [
1345
+ index("fleet_tasks_status_created_idx").on(table.status, table.createdAt),
1346
+ // Lane-scoped peek / rank / list — the fleet-queue read path ([[HS-60Z1-EPIC]]).
1347
+ index("fleet_tasks_lane_status_created_idx").on(table.lane, table.status, table.createdAt)
1348
+ ]
1349
+ );
1350
+ var fleetResults = pgTable(
1351
+ "fleet_results",
1352
+ {
1353
+ taskId: text("task_id").references(() => fleetTasks.taskId),
1354
+ idempotencyKey: text("idempotency_key").notNull(),
1355
+ machineKey: text("machine_key").references(() => fleetMachines.machineKey),
1356
+ status: text("status").notNull(),
1357
+ // done | failed
1358
+ reason: text("reason"),
1359
+ result: jsonb("result"),
1360
+ artifacts: jsonb("artifacts"),
1361
+ commitSha: text("commit_sha"),
1362
+ reportedAt: timestamp("reported_at", { withTimezone: true }).default(sql`now()`)
1363
+ },
1364
+ (table) => [primaryKey({ columns: [table.taskId, table.idempotencyKey] })]
1365
+ );
1366
+ var userApiKeys = pgTable(
1367
+ "user_api_keys",
1368
+ {
1369
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
1370
+ /** Provider id — validated app-side against `API_KEY_PROVIDERS`. */
1371
+ provider: text("provider").notNull(),
1372
+ // Envelope-encryption fields (all base64). See lib/keys/crypto.ts.
1373
+ ciphertext: text("ciphertext").notNull(),
1374
+ iv: text("iv").notNull(),
1375
+ authTag: text("auth_tag").notNull(),
1376
+ wrappedDek: text("wrapped_dek").notNull(),
1377
+ wrapIv: text("wrap_iv").notNull(),
1378
+ wrapAuthTag: text("wrap_auth_tag").notNull(),
1379
+ /** Which KEK version wrapped the data key (rotation selector). */
1380
+ keyVersion: integer("key_version").notNull().default(1),
1381
+ /** Trailing 4 chars of the plaintext — masked-display projection only. */
1382
+ last4: text("last4").notNull(),
1383
+ /** Best-effort verify-on-save result: unverified | valid | invalid. */
1384
+ verificationStatus: text("verification_status").notNull().default("unverified"),
1385
+ verifiedAt: timestamp("verified_at", { withTimezone: true }),
1386
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
1387
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
1388
+ },
1389
+ (table) => [primaryKey({ columns: [table.userId, table.provider] })]
1390
+ );
1391
+ var apiKeys = pgTable(
1392
+ "api_keys",
1393
+ {
1394
+ id: text("id").primaryKey(),
1395
+ // opaque key id (uuid), minted at creation — never the raw key
1396
+ keyHash: text("key_hash").notNull(),
1397
+ // SHA-256 hex digest of the raw key; the raw key itself is never stored
1398
+ keyPrefix: text("key_prefix").notNull(),
1399
+ // short non-secret prefix for masked display + lookup
1400
+ scopes: text("scopes").array().notNull(),
1401
+ accountId: text("account_id").notNull().default(SINGLE_USER_ID),
1402
+ status: text("status").notNull().default("active"),
1403
+ // active | expiring | revoked
1404
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
1405
+ lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
1406
+ expiresAt: timestamp("expires_at", { withTimezone: true })
1407
+ },
1408
+ (table) => [
1409
+ // Presented-key lookup path: verifyApiKey resolves by prefix, then
1410
+ // compares the hash. One key can never collide with another's prefix.
1411
+ uniqueIndex("api_keys_prefix_uq").on(table.keyPrefix),
1412
+ // Per-account listing + the max-active-keys-per-account enforcement scan.
1413
+ index("api_keys_account_status_idx").on(table.accountId, table.status)
1414
+ ]
1415
+ );
1416
+ var mcpSessions = pgTable(
1417
+ "mcp_sessions",
1418
+ {
1419
+ id: text("id").primaryKey(),
1420
+ // first 12 hex chars of the token hash — list/revoke/audit reference
1421
+ tokenHash: text("token_hash").notNull(),
1422
+ // SHA-256 hex digest of the raw `hsmcp_...` token
1423
+ userId: text("user_id").notNull(),
1424
+ // Clerk userId (or SINGLE_USER_ID) the token authenticates as
1425
+ label: text("label"),
1426
+ // optional human label ("laptop MCP", "CI runner", …)
1427
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
1428
+ lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
1429
+ revokedAt: timestamp("revoked_at", { withTimezone: true })
1430
+ },
1431
+ (table) => [
1432
+ // Presented-token lookup: verify hashes the FULL presented token and scans
1433
+ // for a match — this index is for per-account listing, not the verify path
1434
+ // (the verify path has no indexable prefix to look up by; the sessions
1435
+ // table is expected to stay small — one row per paired machine/agent).
1436
+ index("mcp_sessions_user_idx").on(table.userId)
1437
+ ]
1438
+ );
1439
+ var pairingCodes = pgTable(
1440
+ "pairing_codes",
1441
+ {
1442
+ id: text("id").primaryKey(),
1443
+ // first 8 hex chars of the code hash — audit/revoke reference
1444
+ hash: text("hash").notNull(),
1445
+ // SHA-256 hex digest of the plaintext code
1446
+ userId: text("user_id").notNull(),
1447
+ // Clerk userId (or SINGLE_USER_ID) that minted the code
1448
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
1449
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
1450
+ usedAt: timestamp("used_at", { withTimezone: true }),
1451
+ usedBy: text("used_by"),
1452
+ // machineKey that redeemed the code, if supplied
1453
+ revokedAt: timestamp("revoked_at", { withTimezone: true }),
1454
+ note: text("note")
1455
+ // display/audit hint only ("mcp", …) — no effect on redemption
1456
+ },
1457
+ (table) => [index("pairing_codes_user_idx").on(table.userId)]
1458
+ );
1459
+ var companionSessions = pgTable(
1460
+ "companion_sessions",
1461
+ {
1462
+ id: text("id").primaryKey(),
1463
+ // first 12 hex chars of the token hash — list/revoke/audit reference
1464
+ tokenHash: text("token_hash").notNull(),
1465
+ // SHA-256 hex digest of the raw `hscmp_...` token
1466
+ userId: text("user_id").notNull(),
1467
+ machineKey: text("machine_key"),
1468
+ // machineKey presented at pairing, if any
1469
+ label: text("label"),
1470
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
1471
+ lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
1472
+ revokedAt: timestamp("revoked_at", { withTimezone: true })
1473
+ },
1474
+ (table) => [index("companion_sessions_user_idx").on(table.userId)]
1475
+ );
1476
+ var inviteRequests = pgTable(
1477
+ "invite_requests",
1478
+ {
1479
+ id: text("id").primaryKey(),
1480
+ // uuid minted by the webhook receiver
1481
+ email: text("email").notNull(),
1482
+ name: text("name"),
1483
+ /** Plan the requester expects to need: free | pro | team. A planning signal, not a commitment. */
1484
+ tier: text("tier").notNull().default("free"),
1485
+ /** Optional free-text context: what they are building / how they use agents. */
1486
+ building: text("building"),
1487
+ status: text("status").notNull().default("new"),
1488
+ // new | invited | declined
1489
+ /** Clerk invitation id, set when an approval actually issues one. */
1490
+ clerkInvitationId: text("clerk_invitation_id"),
1491
+ /** Account id of the admin who approved/declined — audit trail for the decision. */
1492
+ reviewedBy: text("reviewed_by"),
1493
+ reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
1494
+ /** Intake metadata forwarded from the Worker, for spam triage. */
1495
+ sourceIp: text("source_ip"),
1496
+ userAgent: text("user_agent"),
1497
+ /**
1498
+ * Double opt-in completion. Rows only reach this table AFTER the requester
1499
+ * clicked the confirmation link, so in practice this is always set — it is
1500
+ * stored anyway because "when did they verify?" is part of the consent
1501
+ * evidence, and a future intake path might relax that ordering.
1502
+ */
1503
+ confirmedAt: timestamp("confirmed_at", { withTimezone: true }),
1504
+ /**
1505
+ * The OPTIONAL marketing opt-in — deliberately distinct from the required
1506
+ * privacy agreement, which is a condition of using the form and therefore
1507
+ * not evidence of marketing consent. Only this flag may be used to justify
1508
+ * sending marketing email.
1509
+ */
1510
+ marketingConsent: boolean("marketing_consent").notNull().default(false),
1511
+ /**
1512
+ * Version tag of the exact on-page wording the requester agreed to
1513
+ * (`CONSENT_TEXT_VERSION` in heyspex-com/src/lib/consent-text.ts). Without
1514
+ * this, editing the copy would silently rewrite what every historical row
1515
+ * appears to have consented to.
1516
+ */
1517
+ consentTextVersion: text("consent_text_version"),
1518
+ /** When the marketing site recorded it (NOT when the webhook landed). */
1519
+ requestedAt: timestamp("requested_at", { withTimezone: true }).notNull().default(sql`now()`),
1520
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
1521
+ },
1522
+ (table) => [
1523
+ // The upsert key — one row per requester, re-submits update in place.
1524
+ uniqueIndex("invite_requests_email_uq").on(table.email),
1525
+ // The admin surface's default read: pending first, newest first.
1526
+ index("invite_requests_status_requested_idx").on(table.status, table.requestedAt)
1527
+ ]
1528
+ );
1529
+ var notifications = pgTable(
1530
+ "notifications",
1531
+ {
1532
+ id: text("id").primaryKey(),
1533
+ // uuid
1534
+ /** Recipient. Every read path filters on this — notifications are never global. */
1535
+ accountId: text("account_id").notNull().default(SINGLE_USER_ID),
1536
+ /** Event type, e.g. `invite_request`. Open vocabulary by design (see above). */
1537
+ kind: text("kind").notNull(),
1538
+ title: text("title").notNull(),
1539
+ /** One-line supporting detail shown under the title in the dropdown. */
1540
+ body: text("body"),
1541
+ /** Where clicking the notification should take the user (in-app path). */
1542
+ href: text("href"),
1543
+ /**
1544
+ * Kind-specific structured data (e.g. `{ inviteRequestId, email, tier }`).
1545
+ * Keeps the table stable as new kinds arrive.
1546
+ */
1547
+ payload: jsonb("payload"),
1548
+ /**
1549
+ * Idempotency key for the producing event (e.g. `invite_request:<email>`).
1550
+ * A webhook retry must surface ONE bell item, not one per delivery attempt.
1551
+ */
1552
+ dedupeKey: text("dedupe_key"),
1553
+ readAt: timestamp("read_at", { withTimezone: true }),
1554
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`)
1555
+ },
1556
+ (table) => [
1557
+ // The bell's two queries: unread count, and the recent list — both scoped
1558
+ // to the recipient and ordered newest-first.
1559
+ index("notifications_account_created_idx").on(table.accountId, table.createdAt),
1560
+ // Retry-safe producers: one row per (recipient, event).
1561
+ uniqueIndex("notifications_account_dedupe_uq").on(table.accountId, table.dedupeKey)
1562
+ ]
1563
+ );
1564
+ var webhookIngestions = pgTable(
1565
+ "webhook_ingestions",
1566
+ {
1567
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
1568
+ repo: text("repo").notNull(),
1569
+ // `owner/repo`, from `repository.full_name`
1570
+ branch: text("branch").notNull(),
1571
+ // from `ref`, `refs/heads/` stripped
1572
+ commitSha: text("commit_sha").notNull(),
1573
+ message: text("message").notNull(),
1574
+ authorName: text("author_name").notNull(),
1575
+ authorEmail: text("author_email").notNull(),
1576
+ path: text("path").notNull(),
1577
+ // changed file's repo-relative path
1578
+ ingestedAt: timestamp("ingested_at", { withTimezone: true }).notNull().default(sql`now()`)
1579
+ },
1580
+ (table) => [
1581
+ // A retried/duplicate delivery for the same (repo, commit, path) must not
1582
+ // record twice — GitHub does redeliver on timeout/5xx.
1583
+ uniqueIndex("webhook_ingestions_repo_commit_path_uq").on(
1584
+ table.repo,
1585
+ table.commitSha,
1586
+ table.path
1587
+ )
1588
+ ]
1589
+ );
1590
+ var specSettlements = pgTable(
1591
+ "spec_settlements",
1592
+ {
1593
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
1594
+ specId: text("spec_id").notNull(),
1595
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
1596
+ repo: text("repo").notNull(),
1597
+ // `owner/name`
1598
+ branch: text("branch").notNull(),
1599
+ commitSha: text("commit_sha").notNull(),
1600
+ settledAt: timestamp("settled_at", { withTimezone: true }).notNull().default(sql`now()`)
1601
+ },
1602
+ (table) => [
1603
+ // "latest settlement for this spec" — the read path's only query.
1604
+ index("spec_settlements_spec_settled_idx").on(table.specId, table.settledAt.desc())
1605
+ ]
1606
+ );
1607
+
1608
+ // spex-console/src/lib/vault-registry/neon-probe.ts
1609
+ var NEON_PROBE_TIMEOUT_MS = 1500;
1610
+ var SUSPENSION_ELAPSED_FACTOR = 3;
1611
+ var SUSPENSION_MIN_OVERSHOOT_MS = 5e3;
1612
+ var MAX_SUSPENSION_RETRIES = 2;
1613
+ function withTimeout(promise, ms, label) {
1614
+ return new Promise((resolve, reject) => {
1615
+ const timer = setTimeout(() => reject(new NeonProbeTimeoutError(label, ms)), ms);
1616
+ promise.then(
1617
+ (value) => {
1618
+ clearTimeout(timer);
1619
+ resolve(value);
1620
+ },
1621
+ (err) => {
1622
+ clearTimeout(timer);
1623
+ reject(err instanceof Error ? err : new Error(String(err)));
1624
+ }
1625
+ );
1626
+ });
1627
+ }
1628
+ var NeonProbeTimeoutError = class _NeonProbeTimeoutError extends Error {
1629
+ timeoutMs;
1630
+ constructor(label, timeoutMs) {
1631
+ super(`${label} timed out after ${timeoutMs}ms`);
1632
+ this.name = "NeonProbeTimeoutError";
1633
+ this.timeoutMs = timeoutMs;
1634
+ Object.setPrototypeOf(this, _NeonProbeTimeoutError.prototype);
1635
+ }
1636
+ };
1637
+ function sleep(ms) {
1638
+ return new Promise((resolve) => setTimeout(resolve, ms));
1639
+ }
1640
+ function emitProbeFailure(options, failure) {
1641
+ try {
1642
+ options?.onFailure?.(failure);
1643
+ } catch {
1644
+ }
1645
+ }
1646
+ async function runNeonReachabilityProbe(attempt, options = {}, label = "Neon reachability probe") {
1647
+ const timeoutMs = options.timeoutMs ?? NEON_PROBE_TIMEOUT_MS;
1648
+ const backoffMs = options.backoffMs ?? [];
1649
+ const deadlineMs = options.deadlineMs ?? Number.POSITIVE_INFINITY;
1650
+ const attempts = backoffMs.length + 1;
1651
+ let startedAt = Date.now();
1652
+ let suspensions = 0;
1653
+ for (let i = 0; i < attempts; i++) {
1654
+ const attemptStartedAt = Date.now();
1655
+ try {
1656
+ await withTimeout(Promise.resolve().then(attempt), timeoutMs, label);
1657
+ return true;
1658
+ } catch (err) {
1659
+ const elapsedMs = Date.now() - attemptStartedAt;
1660
+ const suspended = elapsedMs >= timeoutMs * SUSPENSION_ELAPSED_FACTOR && elapsedMs - timeoutMs >= SUSPENSION_MIN_OVERSHOOT_MS;
1661
+ if (suspended && suspensions < MAX_SUSPENSION_RETRIES) {
1662
+ suspensions += 1;
1663
+ emitProbeFailure(options, {
1664
+ reason: "suspended",
1665
+ attempt: i + 1,
1666
+ attempts,
1667
+ timeoutMs,
1668
+ elapsedMs,
1669
+ willRetry: true,
1670
+ error: err
1671
+ });
1672
+ startedAt = Date.now();
1673
+ i -= 1;
1674
+ continue;
1675
+ }
1676
+ const isLast = i === attempts - 1;
1677
+ const pastDeadline = Date.now() - startedAt >= deadlineMs;
1678
+ const willRetry = !isLast && !pastDeadline;
1679
+ emitProbeFailure(options, {
1680
+ reason: err instanceof NeonProbeTimeoutError ? "timeout" : "error",
1681
+ attempt: i + 1,
1682
+ attempts,
1683
+ timeoutMs,
1684
+ elapsedMs,
1685
+ willRetry,
1686
+ error: err
1687
+ });
1688
+ if (!willRetry) return false;
1689
+ await sleep(backoffMs[i]);
1690
+ }
1691
+ }
1692
+ return false;
1693
+ }
1694
+
1695
+ // spex-console/src/lib/vault-registry/repo-identity.ts
1696
+ import path from "node:path";
1697
+ function normalizeRepoRoot(p) {
1698
+ let resolved = path.resolve(p);
1699
+ while (resolved.length > 1 && (resolved.endsWith("/") || resolved.endsWith("\\"))) {
1700
+ resolved = resolved.slice(0, -1);
1701
+ }
1702
+ return resolved.toLowerCase();
1703
+ }
1704
+ function repoRootCandidates(p) {
1705
+ const norm = normalizeRepoRoot(p);
1706
+ const candidates = [norm];
1707
+ if (path.basename(norm) === "specs") {
1708
+ const parent = path.dirname(norm);
1709
+ if (parent !== norm) candidates.push(parent);
1710
+ }
1711
+ return candidates;
1712
+ }
1713
+ function repoRootsEquivalent(a, b) {
1714
+ if (!a || !b) return false;
1715
+ const aCands = repoRootCandidates(a);
1716
+ const bCands = new Set(repoRootCandidates(b));
1717
+ return aCands.some((c) => bCands.has(c));
1718
+ }
1719
+ function normalizeGithubRemote(remote) {
1720
+ let r = remote.trim().toLowerCase();
1721
+ r = r.replace(/\.git$/, "");
1722
+ const hosted = r.match(/github\.com[/:]([^/\s]+\/[^/\s]+)$/);
1723
+ if (hosted) return hosted[1];
1724
+ return r.replace(/^\/+|\/+$/g, "");
1725
+ }
1726
+ function githubRemotesEquivalent(a, b) {
1727
+ if (!a || !b) return false;
1728
+ return normalizeGithubRemote(a) === normalizeGithubRemote(b);
1729
+ }
1730
+ function matchesRepoIdentity(row2, probe) {
1731
+ return repoRootsEquivalent(row2.repoRootAbs, probe.repoRootAbs) || githubRemotesEquivalent(row2.githubRemote, probe.githubRemote);
1732
+ }
1733
+
1734
+ // spex-console/src/lib/cli/vault-workspace-shared.ts
1735
+ var DEFAULT_ACCOUNT_ID = SINGLE_USER_ID;
1736
+ function resolveCliAccountId(opts = {}) {
1737
+ const env = opts.env ?? process.env;
1738
+ const fromEnv = env.HS_ACCOUNT_ID?.trim();
1739
+ if (fromEnv) return fromEnv;
1740
+ try {
1741
+ const homeDir = opts.homeDir ?? os.homedir();
1742
+ const readFile2 = opts.readFile ?? ((p) => readFileSync2(p, "utf-8"));
1743
+ const config = readFile2(path2.join(homeDir, ".heyspex", "config.yaml"));
1744
+ const match = config.match(/^account_id:\s*["']?([^"'\r\n#]+)/m);
1745
+ const value = match?.[1]?.trim();
1746
+ if (value && value.length > 0) return value;
1747
+ } catch {
1748
+ }
1749
+ return DEFAULT_ACCOUNT_ID;
1750
+ }
1751
+ async function cloudHttpRequest(http, method, path5, body) {
1752
+ try {
1753
+ const res = await http.fetchImpl(`${http.consoleUrl}${path5}`, {
1754
+ method,
1755
+ headers: {
1756
+ "content-type": "application/json",
1757
+ ...http.token ? { authorization: `Bearer ${http.token}` } : {}
1758
+ },
1759
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
1760
+ });
1761
+ let json = null;
1762
+ try {
1763
+ json = await res.json();
1764
+ } catch {
1765
+ }
1766
+ return { ok: res.ok, status: res.status, json };
1767
+ } catch {
1768
+ return { ok: false, status: 0, json: null };
1769
+ }
1770
+ }
1771
+ function shouldUseCloudHttp(deps) {
1772
+ return deps.db === void 0 && deps.http !== void 0 && !isPostgresConfigured();
1773
+ }
1774
+ async function upsertWorkspaceHttp(record, http) {
1775
+ const res = await cloudHttpRequest(http, "POST", "/api/workspaces", {
1776
+ id: record.id,
1777
+ display_name: record.displayName,
1778
+ ...record.githubOrg ? { github_org: record.githubOrg } : {}
1779
+ });
1780
+ return res.status === 201 || res.status === 409;
1781
+ }
1782
+ async function insertWorkspaceStrictHttp(record, http) {
1783
+ const res = await cloudHttpRequest(http, "POST", "/api/workspaces", {
1784
+ id: record.id,
1785
+ display_name: record.displayName,
1786
+ ...record.githubOrg ? { github_org: record.githubOrg } : {}
1787
+ });
1788
+ if (res.status === 201) return "inserted";
1789
+ if (res.status === 409) return "conflict";
1790
+ return "unreachable";
1791
+ }
1792
+ async function upsertVaultHttp(record, http) {
1793
+ const res = await cloudHttpRequest(http, "POST", "/api/vaults", {
1794
+ id: record.id,
1795
+ workspace_id: record.workspaceId,
1796
+ display_name: record.displayName,
1797
+ repo_root_abs: record.repoRootAbs,
1798
+ schema_version: record.schemaVersion,
1799
+ ...record.githubRemote ? { github_remote: record.githubRemote } : {}
1800
+ });
1801
+ return res.ok;
1802
+ }
1803
+ async function findVaultByRepoIdentityHttp(probe, http) {
1804
+ if (!probe.githubRemote) return null;
1805
+ const qs = new URLSearchParams({ remote: probe.githubRemote });
1806
+ const res = await cloudHttpRequest(http, "GET", `/api/vaults?${qs.toString()}`);
1807
+ if (!res.ok) return null;
1808
+ const body = res.json;
1809
+ const row2 = body?.vaults?.[0];
1810
+ return row2 ? {
1811
+ id: row2.id,
1812
+ workspaceId: row2.workspace_id,
1813
+ repoRootAbs: row2.repo_root_abs ?? null,
1814
+ githubRemote: row2.github_remote ?? probe.githubRemote,
1815
+ displayName: row2.display_name,
1816
+ schemaVersion: row2.schema_version
1817
+ } : null;
1818
+ }
1819
+ async function isNeonReachableForWrite(deps) {
1820
+ if (deps.neonReachable !== void 0) return deps.neonReachable;
1821
+ const usingInjectedDb = deps.db !== void 0;
1822
+ if (!usingInjectedDb && !isPostgresConfigured()) {
1823
+ emitProbeFailure(deps.probe, {
1824
+ reason: "not-configured",
1825
+ attempt: 1,
1826
+ attempts: 1,
1827
+ timeoutMs: 0,
1828
+ elapsedMs: 0,
1829
+ willRetry: false,
1830
+ error: new Error(
1831
+ "isPostgresConfigured() is false \u2014 no DATABASE_URL env var and no usable ~/.heyspex/credentials.yaml. Neon was never contacted."
1832
+ )
1833
+ });
1834
+ return false;
1835
+ }
1836
+ return runNeonReachabilityProbe(async () => {
1837
+ const db = deps.db ?? getPostgresDb();
1838
+ await db.execute(sql`select 1`);
1839
+ }, deps.probe);
1840
+ }
1841
+ async function upsertWorkspaceNeon(record, accountId, deps = {}) {
1842
+ if (shouldUseCloudHttp(deps)) return upsertWorkspaceHttp(record, deps.http);
1843
+ if (!await isNeonReachableForWrite(deps)) return false;
1844
+ try {
1845
+ const db = deps.db ?? getPostgresDb();
1846
+ await db.insert(workspaces).values({
1847
+ id: record.id,
1848
+ accountId,
1849
+ displayName: record.displayName,
1850
+ ...record.githubOrg ? { githubOrg: record.githubOrg } : {}
1851
+ }).onConflictDoUpdate({
1852
+ target: workspaces.id,
1853
+ set: {
1854
+ displayName: record.displayName,
1855
+ ...record.githubOrg ? { githubOrg: record.githubOrg } : {}
1856
+ }
1857
+ });
1858
+ return true;
1859
+ } catch {
1860
+ return false;
1861
+ }
1862
+ }
1863
+ async function insertWorkspaceNeonStrict(record, accountId, deps = {}) {
1864
+ if (shouldUseCloudHttp(deps)) return insertWorkspaceStrictHttp(record, deps.http);
1865
+ if (!await isNeonReachableForWrite(deps)) return "unreachable";
1866
+ try {
1867
+ const db = deps.db ?? getPostgresDb();
1868
+ const rows = await db.insert(workspaces).values({
1869
+ id: record.id,
1870
+ accountId,
1871
+ displayName: record.displayName,
1872
+ ...record.githubOrg ? { githubOrg: record.githubOrg } : {}
1873
+ }).onConflictDoNothing({ target: workspaces.id }).returning({ id: workspaces.id });
1874
+ return rows.length > 0 ? "inserted" : "conflict";
1875
+ } catch {
1876
+ return "unreachable";
1877
+ }
1878
+ }
1879
+ async function upsertVaultNeon(record, accountId, deps = {}) {
1880
+ if (shouldUseCloudHttp(deps)) return upsertVaultHttp(record, deps.http);
1881
+ if (!await isNeonReachableForWrite(deps)) return false;
1882
+ try {
1883
+ const db = deps.db ?? getPostgresDb();
1884
+ await db.insert(vaults).values({
1885
+ id: record.id,
1886
+ workspaceId: record.workspaceId,
1887
+ accountId,
1888
+ displayName: record.displayName,
1889
+ repoRootAbs: record.repoRootAbs,
1890
+ schemaVersion: record.schemaVersion,
1891
+ ...record.githubRemote ? { githubRemote: record.githubRemote } : {}
1892
+ }).onConflictDoUpdate({
1893
+ target: vaults.id,
1894
+ set: {
1895
+ workspaceId: record.workspaceId,
1896
+ displayName: record.displayName,
1897
+ repoRootAbs: record.repoRootAbs,
1898
+ schemaVersion: record.schemaVersion,
1899
+ updatedAt: /* @__PURE__ */ new Date(),
1900
+ ...record.githubRemote ? { githubRemote: record.githubRemote } : {}
1901
+ }
1902
+ });
1903
+ return true;
1904
+ } catch {
1905
+ return false;
1906
+ }
1907
+ }
1908
+ async function findVaultByRepoIdentityNeon(accountId, probe, deps = {}) {
1909
+ if (!probe.repoRootAbs && !probe.githubRemote) return null;
1910
+ if (shouldUseCloudHttp(deps)) return findVaultByRepoIdentityHttp(probe, deps.http);
1911
+ if (!await isNeonReachableForWrite(deps)) return null;
1912
+ try {
1913
+ const db = deps.db ?? getPostgresDb();
1914
+ const rows = await db.select({
1915
+ id: vaults.id,
1916
+ workspaceId: vaults.workspaceId,
1917
+ repoRootAbs: vaults.repoRootAbs,
1918
+ githubRemote: vaults.githubRemote,
1919
+ displayName: vaults.displayName,
1920
+ schemaVersion: vaults.schemaVersion
1921
+ }).from(vaults).where(eq(vaults.accountId, accountId));
1922
+ const row2 = rows.find(
1923
+ (r) => matchesRepoIdentity({ repoRootAbs: r.repoRootAbs, githubRemote: r.githubRemote }, probe)
1924
+ );
1925
+ return row2 ? {
1926
+ id: row2.id,
1927
+ workspaceId: row2.workspaceId,
1928
+ repoRootAbs: row2.repoRootAbs,
1929
+ githubRemote: row2.githubRemote,
1930
+ displayName: row2.displayName,
1931
+ schemaVersion: row2.schemaVersion
1932
+ } : null;
1933
+ } catch {
1934
+ return null;
1935
+ }
1936
+ }
1937
+
1938
+ // spex-console/src/lib/vault-registry/workspace-create.ts
1939
+ var MAX_DISPLAY_NAME_LEN = 60;
1940
+ var MAX_SLUG_LEN = 40;
1941
+ var MAX_SLUG_SUFFIX = 9;
1942
+ function isValidExplicitId(id) {
1943
+ if (typeof id !== "string" || id.trim() === "") return false;
1944
+ if (id.includes("/") || id.includes("\\")) return false;
1945
+ if (id === "." || id === "..") return false;
1946
+ return true;
1947
+ }
1948
+ function truncateSlugAtWordBoundary(slug, maxLen) {
1949
+ if (slug.length <= maxLen) return slug;
1950
+ const cut = slug.slice(0, maxLen);
1951
+ const lastDash = cut.lastIndexOf("-");
1952
+ return lastDash > 0 ? cut.slice(0, lastDash) : cut.replace(/-$/, "");
1953
+ }
1954
+ function deriveWorkspaceSlug(displayName) {
1955
+ const slug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1956
+ return truncateSlugAtWordBoundary(slug || "workspace", MAX_SLUG_LEN);
1957
+ }
1958
+ function derivedCandidates(trimmedName) {
1959
+ const base = deriveWorkspaceSlug(trimmedName);
1960
+ return {
1961
+ base,
1962
+ candidates: [
1963
+ base,
1964
+ ...Array.from({ length: MAX_SLUG_SUFFIX - 1 }, (_, i) => `${base}-${i + 2}`)
1965
+ ]
1966
+ };
1967
+ }
1968
+ function exhaustedSlugError(trimmedName, base) {
1969
+ return {
1970
+ ok: false,
1971
+ code: "duplicate_workspace",
1972
+ message: `could not find a free slug for "${trimmedName}" -- ${base} through ${base}-${MAX_SLUG_SUFFIX} are all taken`
1973
+ };
1974
+ }
1975
+ function createOffline(input, trimmedName, now, deps) {
1976
+ const existingIds = new Set(loadVaultRegistry(deps.registryOpts).workspaces.map((w) => w.id));
1977
+ let id;
1978
+ if (input.id !== void 0) {
1979
+ if (existingIds.has(input.id)) {
1980
+ return {
1981
+ ok: false,
1982
+ code: "duplicate_workspace",
1983
+ message: `workspace "${input.id}" already exists`
1984
+ };
1985
+ }
1986
+ id = input.id;
1987
+ } else {
1988
+ const { base, candidates } = derivedCandidates(trimmedName);
1989
+ const free = candidates.find((candidate) => !existingIds.has(candidate));
1990
+ if (!free) return exhaustedSlugError(trimmedName, base);
1991
+ id = free;
1992
+ }
1993
+ registerWorkspace({ id, display_name: trimmedName, created_at: now }, deps.registryOpts);
1994
+ return {
1995
+ ok: true,
1996
+ workspace: { id, display_name: trimmedName, neon_written: false, registry_written: true }
1997
+ };
1998
+ }
1999
+ async function createWorkspace(input, deps = {}) {
2000
+ const trimmedName = input.displayName.trim();
2001
+ if (trimmedName.length < 1 || trimmedName.length > MAX_DISPLAY_NAME_LEN) {
2002
+ return {
2003
+ ok: false,
2004
+ code: "invalid_display_name",
2005
+ message: `display name must be 1-${MAX_DISPLAY_NAME_LEN} characters (trimmed)`
2006
+ };
2007
+ }
2008
+ const now = (deps.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
2009
+ if (deps.allowIdUpsert && input.id !== void 0) {
2010
+ if (!isValidExplicitId(input.id)) {
2011
+ return {
2012
+ ok: false,
2013
+ code: "invalid_workspace_id",
2014
+ message: `invalid workspace id "${input.id}" -- must be non-empty with no path separators`
2015
+ };
2016
+ }
2017
+ const id = input.id;
2018
+ registerWorkspace({ id, display_name: trimmedName, created_at: now }, deps.registryOpts);
2019
+ const neonWritten = await upsertWorkspaceNeon(
2020
+ { id, displayName: trimmedName },
2021
+ input.accountId,
2022
+ deps.neon
2023
+ );
2024
+ return {
2025
+ ok: true,
2026
+ workspace: {
2027
+ id,
2028
+ display_name: trimmedName,
2029
+ neon_written: neonWritten,
2030
+ registry_written: true
2031
+ }
2032
+ };
2033
+ }
2034
+ if (input.id !== void 0 && !isValidExplicitId(input.id)) {
2035
+ return {
2036
+ ok: false,
2037
+ code: "invalid_workspace_id",
2038
+ message: `invalid workspace id "${input.id}" -- must be non-empty with no path separators`
2039
+ };
2040
+ }
2041
+ const { base, candidates } = input.id !== void 0 ? { base: input.id, candidates: [input.id] } : derivedCandidates(trimmedName);
2042
+ for (const candidate of candidates) {
2043
+ const result = await insertWorkspaceNeonStrict(
2044
+ { id: candidate, displayName: trimmedName },
2045
+ input.accountId,
2046
+ deps.neon ?? {}
2047
+ );
2048
+ if (result === "inserted") {
2049
+ registerWorkspace(
2050
+ { id: candidate, display_name: trimmedName, created_at: now },
2051
+ deps.registryOpts
2052
+ );
2053
+ return {
2054
+ ok: true,
2055
+ workspace: {
2056
+ id: candidate,
2057
+ display_name: trimmedName,
2058
+ neon_written: true,
2059
+ registry_written: true
2060
+ }
2061
+ };
2062
+ }
2063
+ if (result === "unreachable") {
2064
+ return createOffline(input, trimmedName, now, deps);
2065
+ }
2066
+ if (input.id !== void 0) {
2067
+ return {
2068
+ ok: false,
2069
+ code: "duplicate_workspace",
2070
+ message: `workspace "${input.id}" already exists`
2071
+ };
2072
+ }
2073
+ }
2074
+ return exhaustedSlugError(trimmedName, base);
2075
+ }
2076
+
2077
+ // scripts/lib/console-url-resolver.mjs
2078
+ import { readFileSync as readFileSync3 } from "node:fs";
2079
+ import { homedir as homedir2 } from "node:os";
2080
+ import { join as join2 } from "node:path";
2081
+ var DEFAULT_CONSOLE_URL = "https://app.heyspex.com";
2082
+ function trimTrailingSlash(url) {
2083
+ return url.replace(/\/+$/, "");
2084
+ }
2085
+ function readCredentialsConsoleUrl({ homeDir = homedir2() } = {}) {
2086
+ try {
2087
+ const raw = readFileSync3(join2(homeDir, ".heyspex", "credentials.yaml"), "utf8");
2088
+ const match = raw.match(/^console_url:\s*["']?([^"'\r\n#]+)/m);
2089
+ const value = match?.[1]?.trim();
2090
+ return value && value.length > 0 ? trimTrailingSlash(value) : null;
2091
+ } catch {
2092
+ return null;
2093
+ }
2094
+ }
2095
+ function readDiscoveryFileConsoleUrl(repoRoot) {
2096
+ if (!repoRoot) return null;
2097
+ for (const name of [
2098
+ "console-url",
2099
+ "cockpit-url"
2100
+ /* legacy-cockpit */
2101
+ ]) {
2102
+ try {
2103
+ const discovery = readFileSync3(join2(repoRoot, ".heyspex", name), "utf8").trim();
2104
+ if (discovery.length > 0) return trimTrailingSlash(discovery);
2105
+ } catch {
2106
+ }
2107
+ }
2108
+ return null;
2109
+ }
2110
+ var ConsoleUrlUnresolvedError = class extends Error {
2111
+ constructor() {
2112
+ super(
2113
+ `resolveConsoleUrl: no HEYSPEX_CONSOLE_URL/HEYSPEX_COCKPIT_URL env, no console_url: in ~/.heyspex/credentials.yaml, and no .heyspex/console-url discovery file \u2014 refusing to silently fall back to the ${DEFAULT_CONSOLE_URL} default in strict mode.`
2114
+ );
2115
+ this.name = "ConsoleUrlUnresolvedError";
2116
+ }
2117
+ };
2118
+ function resolveConsoleUrl({
2119
+ repoRoot,
2120
+ env = process.env,
2121
+ homeDir = homedir2(),
2122
+ strict = false
2123
+ } = {}) {
2124
+ const envUrl = (env.HEYSPEX_CONSOLE_URL ?? env.HEYSPEX_COCKPIT_URL)?.trim();
2125
+ if (envUrl) return trimTrailingSlash(envUrl);
2126
+ const credentialsUrl = readCredentialsConsoleUrl({ homeDir });
2127
+ if (credentialsUrl) return credentialsUrl;
2128
+ const discoveryUrl = readDiscoveryFileConsoleUrl(repoRoot);
2129
+ if (discoveryUrl) return discoveryUrl;
2130
+ if (strict) throw new ConsoleUrlUnresolvedError();
2131
+ return DEFAULT_CONSOLE_URL;
2132
+ }
2133
+
2134
+ // spex-console/src/lib/cli/init.ts
2135
+ import { randomUUID } from "node:crypto";
2136
+
2137
+ // spex-console/src/lib/cli/control-shared.ts
2138
+ import { readFileSync as readFileSync4 } from "node:fs";
2139
+ import { homedir as homedir3 } from "node:os";
2140
+ import { join as join3 } from "node:path";
2141
+ var EXIT = {
2142
+ /** Success. */
2143
+ OK: 0,
2144
+ /** Usage error — unknown subcommand/flag, or a missing required argument. */
2145
+ USAGE: 1,
2146
+ /** Claim race lost — the spec is already claimed by another agent (409). */
2147
+ CONFLICT: 9,
2148
+ /** Target not found — release of an unheld claim, etc. (404). */
2149
+ NOT_FOUND: 10,
2150
+ /** Invalid argument — bad `--ttl`, unknown `--kind`, unparseable date. */
2151
+ INVALID_ARG: 11,
2152
+ /** SpexConsole unreachable (network error reaching the dev server). */
2153
+ CONSOLE_UNREACHABLE: 12,
2154
+ /** Server failure — a non-OK response that isn't 400/402/404/409. */
2155
+ WRITE_FAILED: 13,
2156
+ /** Neon control plane absent — the endpoint returned 402 (upgrade required). */
2157
+ NEON_REQUIRED: 14,
2158
+ /**
2159
+ * A cross-account `--account <id>` override was refused — the caller is
2160
+ * not a builder/admin (`resolveScopedAccountId`'s `forbidden_account_override`,
2161
+ * [[HS-X3GB-EKLJ-TASK-hs-cli-account-flag-for-cross-account]]). Distinct
2162
+ * from `USAGE`/`INVALID_ARG` — this is an authorization refusal, not a
2163
+ * malformed command.
2164
+ */
2165
+ FORBIDDEN: 15
2166
+ };
2167
+ function readPairedToken() {
2168
+ try {
2169
+ const raw = readFileSync4(join3(homedir3(), ".heyspex", "credentials.yaml"), "utf-8");
2170
+ const match = raw.match(/\b(hsmcp_[A-Za-z0-9_-]+|hscmp_[A-Za-z0-9_-]+)\b/);
2171
+ return match?.[1];
2172
+ } catch {
2173
+ return void 0;
2174
+ }
2175
+ }
2176
+
2177
+ // spex-console/src/lib/cli/init.ts
2178
+ var HEYSPEX_YAML_SCHEMA_VERSION = 2;
2179
+ var DEFAULT_CONSOLE_URL2 = resolveConsoleUrl();
2180
+ function mintVaultId() {
2181
+ return randomUUID();
2182
+ }
2183
+ function parseVaultId(content) {
2184
+ const m = content.match(/^vault_id:\s*(.+?)\s*$/m);
2185
+ if (!m) return null;
2186
+ const raw = m[1].trim();
2187
+ const unquoted = raw.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
2188
+ return unquoted.length > 0 ? unquoted : null;
2189
+ }
2190
+ function appendVaultIdLine(content, vaultId) {
2191
+ const trimmed = content.replace(/\n+$/, "");
2192
+ return `${trimmed}
2193
+ vault_id: ${vaultId}
2194
+ `;
2195
+ }
2196
+ function renderHeyspexYaml(args) {
2197
+ return [
2198
+ `schema_version: ${HEYSPEX_YAML_SCHEMA_VERSION}`,
2199
+ `console_url: ${args.consoleUrl}`,
2200
+ `vault_root: ${args.vaultRoot}`,
2201
+ `vault_id: ${args.vaultId}`,
2202
+ ""
2203
+ ].join("\n");
2204
+ }
2205
+ function readExistingContent(deps, markerPath) {
2206
+ if (!deps.readFile) return null;
2207
+ try {
2208
+ return deps.readFile(markerPath);
2209
+ } catch {
2210
+ return null;
2211
+ }
2212
+ }
2213
+ function runInit(args, deps) {
2214
+ const markerPath = deps.joinPath(deps.repoRoot, "heyspex.yaml");
2215
+ const exists = deps.fileExists(markerPath);
2216
+ const existingContent = exists ? readExistingContent(deps, markerPath) : null;
2217
+ const existingVaultId = existingContent ? parseVaultId(existingContent) : null;
2218
+ if (exists && !args.force) {
2219
+ if (existingVaultId) {
2220
+ return {
2221
+ code: EXIT.OK,
2222
+ message: `heyspex.yaml already exists at ${markerPath} \u2014 vault already initialized (use --force to overwrite).`,
2223
+ payload: {
2224
+ ok: true,
2225
+ created: false,
2226
+ path: markerPath,
2227
+ reason: "already_initialized",
2228
+ vault_id: existingVaultId
2229
+ },
2230
+ isError: false
2231
+ };
2232
+ }
2233
+ if (existingContent === null) {
2234
+ return {
2235
+ code: EXIT.OK,
2236
+ message: `heyspex.yaml already exists at ${markerPath} \u2014 vault already initialized (use --force to overwrite).`,
2237
+ payload: { ok: true, created: false, path: markerPath, reason: "already_initialized" },
2238
+ isError: false
2239
+ };
2240
+ }
2241
+ const mintedId = args.adoptedVaultId ?? mintVaultId();
2242
+ const updatedContent = appendVaultIdLine(existingContent, mintedId);
2243
+ try {
2244
+ deps.writeFile(markerPath, updatedContent);
2245
+ } catch (err) {
2246
+ const message = err instanceof Error ? err.message : String(err);
2247
+ return {
2248
+ code: EXIT.WRITE_FAILED,
2249
+ message: `failed to write heyspex.yaml: ${message}`,
2250
+ payload: { ok: false, error: "write_failed", path: markerPath, message },
2251
+ isError: true
2252
+ };
2253
+ }
2254
+ return {
2255
+ code: EXIT.OK,
2256
+ message: `Added vault_id to existing heyspex.yaml at ${markerPath} (schema_version unchanged).`,
2257
+ payload: {
2258
+ ok: true,
2259
+ created: false,
2260
+ backfilled: true,
2261
+ path: markerPath,
2262
+ vault_id: mintedId
2263
+ },
2264
+ isError: false
2265
+ };
2266
+ }
2267
+ const vaultId = existingVaultId ?? args.adoptedVaultId ?? mintVaultId();
2268
+ const content = renderHeyspexYaml({ ...args, vaultId });
2269
+ try {
2270
+ deps.writeFile(markerPath, content);
2271
+ } catch (err) {
2272
+ const message = err instanceof Error ? err.message : String(err);
2273
+ return {
2274
+ code: EXIT.WRITE_FAILED,
2275
+ message: `failed to write heyspex.yaml: ${message}`,
2276
+ payload: { ok: false, error: "write_failed", path: markerPath, message },
2277
+ isError: true
2278
+ };
2279
+ }
2280
+ return {
2281
+ code: EXIT.OK,
2282
+ message: `${exists ? "Overwrote" : "Wrote"} ${markerPath} (schema_version ${HEYSPEX_YAML_SCHEMA_VERSION}).`,
2283
+ payload: {
2284
+ ok: true,
2285
+ created: true,
2286
+ overwrote: exists,
2287
+ path: markerPath,
2288
+ schema_version: HEYSPEX_YAML_SCHEMA_VERSION,
2289
+ console_url: args.consoleUrl,
2290
+ vault_root: args.vaultRoot,
2291
+ vault_id: vaultId
2292
+ },
2293
+ isError: false
2294
+ };
2295
+ }
2296
+ var HELP = [
2297
+ "Usage:",
2298
+ " hs init [--console-url <url>] [--vault-root <path>] [--force] [--json]",
2299
+ " hs init --customer-vault <dir> [--console-url <url>] [--principles <all|none|a,b>]",
2300
+ " [--vault-name <name>] [--no-git] [--json]",
2301
+ "",
2302
+ "Write the top-level heyspex.yaml vault marker (schema_version, console_url,",
2303
+ "vault_root, vault_id) \u2014 the committed file the console / web companion read",
2304
+ "to bootstrap a vault. Idempotent: an existing marker is left untouched unless",
2305
+ "--force, EXCEPT a missing vault_id is backfilled in place either way (backward",
2306
+ "compatible \u2014 no schema_version bump). vault_id is reused exactly when already",
2307
+ "present; a fresh one is minted only when absent.",
2308
+ "Does not modify .heyspex/config.yaml or its templates (ADR-0009).",
2309
+ "",
2310
+ "--customer-vault <dir> scaffolds a REAL customer vault at <dir> instead: the",
2311
+ "specs/ folder tree, heyspex.yaml, and every row of the template shipping",
2312
+ "manifest (spex-console/src/lib/cli/template-manifest.ts) \u2014 universal docs",
2313
+ "copied verbatim, template-shape docs written as author-prompt skeletons \u2014",
2314
+ "plus a generated CLAUDE.md, the kept-principles index, and (unless --no-git)",
2315
+ "one initial `git init` + commit pinning the template version. Refuses a",
2316
+ "non-empty <dir> with a plain error. See init.customer-vault.ts.",
2317
+ "",
2318
+ "Flags:",
2319
+ " --console-url <url> console_url value (default: $HEYSPEX_CONSOLE_URL or",
2320
+ " https://console.heyspex.local).",
2321
+ " --vault-root <path> vault_root value (default: specs/).",
2322
+ " --customer-vault <dir> Scaffold a new customer vault at <dir> (see above).",
2323
+ " --principles <all|none|a,b> Which universal principle docs to ship",
2324
+ " (--customer-vault only). Omitted + a TTY: interactive",
2325
+ " keep/skip prompt per principle. Omitted + no TTY: all.",
2326
+ " --vault-name <name> Display name for the scaffolded vault (--customer-vault",
2327
+ " only; default: <dir>'s basename).",
2328
+ " --no-git Skip git init + the initial commit (--customer-vault only).",
2329
+ " --force Overwrite an existing heyspex.yaml.",
2330
+ " --json Emit a machine-readable JSON result.",
2331
+ " --help, -h Show this help.",
2332
+ "",
2333
+ "Exit codes: 0 ok \xB7 1 usage \xB7 11 bad-arg \xB7 13 write-failed"
2334
+ ].join("\n");
2335
+
2336
+ // spex-console/src/lib/cli/connect-checks.ts
2337
+ var PLUGIN_SURFACE_DIRS = [
2338
+ [".claude", "commands"],
2339
+ [".cursor", "commands"],
2340
+ [".cursor", "skills"],
2341
+ [".commandcode", "skills"],
2342
+ [".agents", "skills"]
2343
+ ];
2344
+ function row(id, label, present, detail) {
2345
+ return {
2346
+ id,
2347
+ label,
2348
+ status: present ? "present" : "missing",
2349
+ ...detail ? { detail } : {}
2350
+ };
2351
+ }
2352
+ function readIfExists(deps, path5) {
2353
+ return deps.fileExists(path5) ? deps.readFile(path5) : null;
2354
+ }
2355
+ var PRE_COMMIT_HOOK_MARKER = "run-pre-commit-checks.mjs";
2356
+ function hasHeyspexMcpEntry(raw) {
2357
+ if (!raw) return false;
2358
+ try {
2359
+ const parsed = JSON.parse(raw);
2360
+ return Boolean(parsed.mcpServers && Object.hasOwn(parsed.mcpServers, "heyspex"));
2361
+ } catch {
2362
+ return false;
2363
+ }
2364
+ }
2365
+ function runConnectChecks(repoRoot, deps) {
2366
+ const checks = [];
2367
+ const yamlPath = deps.joinPath(repoRoot, "heyspex.yaml");
2368
+ const yamlContent = readIfExists(deps, yamlPath);
2369
+ const vaultId = yamlContent ? parseVaultId(yamlContent) : null;
2370
+ checks.push(
2371
+ row(
2372
+ "heyspex_yaml",
2373
+ "heyspex.yaml vault marker",
2374
+ yamlContent !== null,
2375
+ vaultId ? `vault_id: ${vaultId}` : void 0
2376
+ )
2377
+ );
2378
+ const specsDir = deps.joinPath(repoRoot, "specs");
2379
+ checks.push(row("specs_dir", "specs/ directory", deps.fileExists(specsDir)));
2380
+ const registry = loadVaultRegistry(deps.registryOpts);
2381
+ const vaultRecord = registry.vaults.find((v) => v.repo_root_abs === repoRoot);
2382
+ checks.push(
2383
+ row(
2384
+ "vault_registered",
2385
+ "vault registered in ~/.heyspex/registry.json",
2386
+ Boolean(vaultRecord),
2387
+ vaultRecord ? `id: ${vaultRecord.id}` : void 0
2388
+ )
2389
+ );
2390
+ const workspaceRecord = vaultRecord ? registry.workspaces.find((w) => w.id === vaultRecord.workspace_id) : void 0;
2391
+ checks.push(
2392
+ row(
2393
+ "workspace_registered",
2394
+ "workspace registered in ~/.heyspex/registry.json",
2395
+ Boolean(workspaceRecord),
2396
+ workspaceRecord ? `id: ${workspaceRecord.id}` : vaultRecord ? `vault's workspace "${vaultRecord.workspace_id}" not found in registry` : "no vault registered for this repo"
2397
+ )
2398
+ );
2399
+ const claudeMcpPath = deps.joinPath(repoRoot, ".mcp.json");
2400
+ const cursorMcpPath = deps.joinPath(repoRoot, ".cursor", "mcp.json");
2401
+ const claudeMcp = hasHeyspexMcpEntry(readIfExists(deps, claudeMcpPath));
2402
+ const cursorMcp = hasHeyspexMcpEntry(readIfExists(deps, cursorMcpPath));
2403
+ const mcpFound = [claudeMcp ? ".mcp.json" : null, cursorMcp ? ".cursor/mcp.json" : null].filter(
2404
+ (v) => v !== null
2405
+ );
2406
+ checks.push(
2407
+ row(
2408
+ "mcp_entry",
2409
+ "MCP server entry (Claude Code / Cursor)",
2410
+ claudeMcp || cursorMcp,
2411
+ mcpFound.length > 0 ? mcpFound.join(", ") : void 0
2412
+ )
2413
+ );
2414
+ const presentPluginDirs = PLUGIN_SURFACE_DIRS.map(
2415
+ (rel) => deps.joinPath(repoRoot, ...rel)
2416
+ ).filter((p) => deps.fileExists(p));
2417
+ checks.push(
2418
+ row(
2419
+ "plugin_surface",
2420
+ "plugin surface trees (.claude/.cursor/.commandcode/.agents commands+skills)",
2421
+ presentPluginDirs.length > 0,
2422
+ presentPluginDirs.length > 0 ? presentPluginDirs.join(", ") : void 0
2423
+ )
2424
+ );
2425
+ const preCommitHookPath = deps.joinPath(repoRoot, ".git", "hooks", "pre-commit");
2426
+ const preCommitHookContent = readIfExists(deps, preCommitHookPath);
2427
+ const gitHooksPresent = preCommitHookContent !== null && preCommitHookContent.includes(PRE_COMMIT_HOOK_MARKER);
2428
+ checks.push(
2429
+ row(
2430
+ "git_hooks",
2431
+ "git hooks (pre-commit)",
2432
+ gitHooksPresent,
2433
+ preCommitHookContent !== null && !gitHooksPresent ? "pre-commit exists but is not HeySpex-managed (no marker)" : void 0
2434
+ )
2435
+ );
2436
+ const registryVaultId = vaultRecord?.id ?? null;
2437
+ const idsAgree = Boolean(vaultId) && Boolean(registryVaultId) && vaultId === registryVaultId;
2438
+ checks.push(
2439
+ row(
2440
+ "id_agreement",
2441
+ "heyspex.yaml vault_id matches the registered vault id",
2442
+ idsAgree,
2443
+ vaultId || registryVaultId ? `marker: ${vaultId ?? "(none)"}, registry: ${registryVaultId ?? "(none)"}` : void 0
2444
+ )
2445
+ );
2446
+ const connected = checks.every((c) => c.status === "present");
2447
+ return { repo_root: repoRoot, connected, checks };
2448
+ }
2449
+
2450
+ // spex-console/src/lib/cli/install.ts
2451
+ var MCP_REL = ["vendor", "heyspex-mcp", "cli.mjs"];
2452
+ function mcpCliPath(platformRoot, joinPath) {
2453
+ return joinPath(platformRoot, ...MCP_REL);
2454
+ }
2455
+ function renderCursorMcpJson(cliAbsPath) {
2456
+ return {
2457
+ mcpServers: {
2458
+ heyspex: {
2459
+ type: "stdio",
2460
+ command: "node",
2461
+ args: [cliAbsPath],
2462
+ env: {
2463
+ HEYSPEX_VAULT_ROOT: "${workspaceFolder}"
2464
+ }
2465
+ }
2466
+ }
2467
+ };
2468
+ }
2469
+ function renderClaudeMcpJson(cliAbsPath) {
2470
+ return {
2471
+ mcpServers: {
2472
+ heyspex: {
2473
+ type: "stdio",
2474
+ command: "node",
2475
+ args: [cliAbsPath],
2476
+ env: {
2477
+ HEYSPEX_VAULT_ROOT: "${CLAUDE_PROJECT_DIR:-.}"
2478
+ }
2479
+ }
2480
+ }
2481
+ };
2482
+ }
2483
+ var PLUGIN_MARKETPLACE_NAME = "heyspex-marketplace";
2484
+ var PLUGIN_INSTALL_SPEC = `heyspex@${PLUGIN_MARKETPLACE_NAME}`;
2485
+ function resolvePluginSpec(platformRoot, deps) {
2486
+ const fallback = {
2487
+ marketplaceName: PLUGIN_MARKETPLACE_NAME,
2488
+ pluginName: "heyspex",
2489
+ spec: PLUGIN_INSTALL_SPEC
2490
+ };
2491
+ const raw = deps.readFile(pluginMarketplaceManifestPath(platformRoot, deps.joinPath));
2492
+ if (!raw) return fallback;
2493
+ try {
2494
+ const parsed = JSON.parse(raw);
2495
+ const marketplaceName = typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : fallback.marketplaceName;
2496
+ const firstNamed = Array.isArray(parsed.plugins) ? parsed.plugins.find((p) => typeof p?.name === "string" && p.name.trim()) : void 0;
2497
+ const pluginName = firstNamed ? firstNamed.name.trim() : fallback.pluginName;
2498
+ return { marketplaceName, pluginName, spec: `${pluginName}@${marketplaceName}` };
2499
+ } catch {
2500
+ return fallback;
2501
+ }
2502
+ }
2503
+ function pluginMarketplaceManifestPath(platformRoot, joinPath) {
2504
+ return joinPath(platformRoot, ".claude-plugin", "marketplace.json");
2505
+ }
2506
+ var INSTALL_HELP = [
2507
+ "Usage:",
2508
+ " hs install [--target <dir>] [--platform <dir>] [--force] [--dry-run] [--json]",
2509
+ " [--no-cursor] [--no-claude] [--claude-apply]",
2510
+ " [--plugin | --plugin-apply] [--plugin-scope user|project|local]",
2511
+ " [--no-statusline]",
2512
+ " [--cc | --command-code] [--register-vault [--workspace <id>]]",
2513
+ "",
2514
+ "Bootstrap a project vault for HeySpex agent tooling:",
2515
+ " \u2022 specs/ + heyspex.yaml (via hs init)",
2516
+ " \u2022 .cursor/mcp.json \u2014 Cursor MCP (gitignored, machine-local path to platform MCP)",
2517
+ " \u2022 .mcp.json \u2014 Claude Code project MCP (absolute platform path)",
2518
+ " \u2022 CLAUDE.md / AGENTS.md \u2014 thin agent contract pointing at the HeySpex MCP server",
2519
+ "",
2520
+ "Conflict scan: every install checks the target repo against the known-conflict",
2521
+ "registry (beads, workflow-pm, bd hooks, \u2026). See:",
2522
+ " specs/protocols/customer-vault-local-install-protocol.md",
2523
+ "",
2524
+ "Run from the HeySpex platform repo, or pass --platform to its checkout.",
2525
+ "Target defaults to the current directory.",
2526
+ "",
2527
+ "Flags:",
2528
+ " --target <dir> Project repo to bootstrap (default: cwd).",
2529
+ " --platform <dir> HeySpex platform checkout (default: HS_INSTALL_PLATFORM or dispatcher root).",
2530
+ " --claude-apply Run `claude mcp add --scope project` after writing files.",
2531
+ " --plugin Print the Claude Code plugin registration block (self-hosted",
2532
+ " marketplace at the platform root \u2014 HS-QTBK prototype).",
2533
+ " --plugin-apply Run `claude plugin marketplace add` + `claude plugin install`",
2534
+ " after writing files. Marketplace + plugin names are read from",
2535
+ ` the platform's .claude-plugin/marketplace.json (fallback: ${PLUGIN_INSTALL_SPEC}).`,
2536
+ " --plugin-scope <s> Plugin install scope: user, project (default), or local.",
2537
+ " Implies --plugin.",
2538
+ " --no-statusline Skip the branded statusline (statusLine entry in the target's",
2539
+ " .claude/settings.json + machine-level glyph install).",
2540
+ " --cc, --command-code Print `cmdc mcp add` PowerShell one-liner for Command Code.",
2541
+ " --register-vault Register this repo's vault into ~/.heyspex/registry.json during install.",
2542
+ " --workspace <id> With --register-vault: ensure this workspace exists, make it active,",
2543
+ ' and associate the vault with it (default workspace: "default").',
2544
+ " --no-cursor Skip .cursor/mcp.json.",
2545
+ " --no-claude Skip .mcp.json and Claude instructions.",
2546
+ " --force Overwrite existing MCP manifests and heyspex.yaml.",
2547
+ " --dry-run Print the plan without writing.",
2548
+ " --skip-conflict-check Skip the known-conflict registry scan.",
2549
+ " --json Machine-readable result.",
2550
+ " --help, -h Show this help.",
2551
+ "",
2552
+ "Exit codes: 0 ok \xB7 1 usage \xB7 11 bad-arg \xB7 13 write-failed"
2553
+ ].join("\n");
2554
+
2555
+ // spex-console/src/lib/cli/connect.ts
2556
+ function cloudWriteDetail(written, neon) {
2557
+ if (written) return " (registry + cloud)";
2558
+ const url = neon?.http?.consoleUrl;
2559
+ return url ? ` (registry.json only \u2014 cloud unreachable at ${url})` : " (registry.json only \u2014 cloud unreachable)";
2560
+ }
2561
+ async function safeAdoptVaultByRepoIdentity(accountId, probe, neon) {
2562
+ try {
2563
+ return await findVaultByRepoIdentityNeon(accountId, probe, neon);
2564
+ } catch {
2565
+ return null;
2566
+ }
2567
+ }
2568
+ function parseConnectArgs(argv) {
2569
+ let check = false;
2570
+ let dryRun = false;
2571
+ let json = false;
2572
+ let fleet = false;
2573
+ let noDoctor = false;
2574
+ let workspace;
2575
+ for (let i = 0; i < argv.length; i++) {
2576
+ const arg = argv[i];
2577
+ if (arg === "--help" || arg === "-h") return { help: true };
2578
+ else if (arg === "--check") check = true;
2579
+ else if (arg === "--dry-run") dryRun = true;
2580
+ else if (arg === "--json") json = true;
2581
+ else if (arg === "--fleet") fleet = true;
2582
+ else if (arg === "--no-doctor") noDoctor = true;
2583
+ else if (arg === "--workspace" || arg.startsWith("--workspace=")) {
2584
+ const v = arg === "--workspace" ? argv[++i] : arg.slice("--workspace=".length);
2585
+ if (v === void 0 || v.trim() === "") {
2586
+ return { error: { code: EXIT.INVALID_ARG, message: "--workspace requires a value" } };
2587
+ }
2588
+ workspace = v;
2589
+ } else if (arg.startsWith("-")) {
2590
+ return { error: { code: EXIT.USAGE, message: `unknown flag: ${arg}` } };
2591
+ } else {
2592
+ return { error: { code: EXIT.USAGE, message: `unexpected argument: ${arg}` } };
2593
+ }
2594
+ }
2595
+ if (check) {
2596
+ if (dryRun) {
2597
+ return {
2598
+ error: {
2599
+ code: EXIT.USAGE,
2600
+ message: "--check is read-only and does not take --dry-run (--check never writes)."
2601
+ }
2602
+ };
2603
+ }
2604
+ if (fleet) {
2605
+ return {
2606
+ error: {
2607
+ code: EXIT.USAGE,
2608
+ message: "--check is read-only and does not take --fleet (--check never writes)."
2609
+ }
2610
+ };
2611
+ }
2612
+ if (noDoctor) {
2613
+ return {
2614
+ error: {
2615
+ code: EXIT.USAGE,
2616
+ message: "--check is read-only and does not take --no-doctor (--check never writes)."
2617
+ }
2618
+ };
2619
+ }
2620
+ if (workspace !== void 0) {
2621
+ return {
2622
+ error: {
2623
+ code: EXIT.USAGE,
2624
+ message: "--check is read-only and does not take --workspace (--check never writes)."
2625
+ }
2626
+ };
2627
+ }
2628
+ return { args: { sub: "check", json } };
2629
+ }
2630
+ return {
2631
+ args: {
2632
+ sub: "run",
2633
+ json,
2634
+ dryRun,
2635
+ fleet,
2636
+ noDoctor,
2637
+ ...workspace !== void 0 ? { workspace } : {}
2638
+ }
2639
+ };
2640
+ }
2641
+ function formatReport(report) {
2642
+ const lines = [
2643
+ `hs connect --check \u2014 ${report.repo_root}`,
2644
+ "",
2645
+ ...report.checks.map(
2646
+ (c) => ` [${c.status === "present" ? "x" : " "}] ${c.label}${c.detail ? ` (${c.detail})` : ""}`
2647
+ ),
2648
+ "",
2649
+ report.connected ? "Connected." : "Not connected \u2014 run `hs connect` to wire this repo up to HeySpex."
2650
+ ];
2651
+ return lines.join("\n");
2652
+ }
2653
+ function resolveConnectCwd(env, fallbackCwd) {
2654
+ const invokedCwd = env.HS_INVOKED_CWD?.trim();
2655
+ return invokedCwd ? invokedCwd : fallbackCwd;
2656
+ }
2657
+ function detectRepoRoot(startDir, deps) {
2658
+ let dir = startDir;
2659
+ for (let i = 0; i < 512; i++) {
2660
+ if (deps.fileExists(deps.joinPath(dir, ".git"))) return dir;
2661
+ const parent = deps.joinPath(dir, "..");
2662
+ if (parent === dir) break;
2663
+ dir = parent;
2664
+ }
2665
+ return startDir;
2666
+ }
2667
+ function detectGitRemote(repoRoot, deps) {
2668
+ const configPath = deps.joinPath(repoRoot, ".git", "config");
2669
+ const raw = deps.fileExists(configPath) ? deps.readFile(configPath) : null;
2670
+ if (!raw) return null;
2671
+ const match = raw.match(/\[remote\s+"origin"\][^[]*?\burl\s*=\s*(\S+)/);
2672
+ return match ? match[1].trim() : null;
2673
+ }
2674
+ var CANONICAL_VAULT_SUBFOLDERS = [
2675
+ "tasks",
2676
+ "epics",
2677
+ "research",
2678
+ "memory",
2679
+ "captures",
2680
+ "protocols",
2681
+ "milestones"
2682
+ ];
2683
+ function bareSpecsDirWarning(specsDir, deps) {
2684
+ let entries = [];
2685
+ try {
2686
+ entries = readdirSync(specsDir);
2687
+ } catch {
2688
+ return null;
2689
+ }
2690
+ if (entries.length === 0) return null;
2691
+ const vaultShaped = CANONICAL_VAULT_SUBFOLDERS.some(
2692
+ (sub) => deps.fileExists(deps.joinPath(specsDir, sub))
2693
+ );
2694
+ if (vaultShaped) return null;
2695
+ return "specs/ exists but does not look like a HeySpex vault (no tasks/epics/... folders) - continuing, this folder becomes the repo's vault";
2696
+ }
2697
+ function slugFromPath(deps, p) {
2698
+ return deps.basename(p).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2699
+ }
2700
+ function summarizeDoctorReport(report, ok, exitCode) {
2701
+ if (!report) {
2702
+ return `doctor exited ${exitCode ?? "?"} (no parseable --json output)`;
2703
+ }
2704
+ const results = Array.isArray(report.results) ? report.results : [];
2705
+ const failedCount = results.filter((r) => r.status === "fail").length;
2706
+ const warnCount = results.filter((r) => r.status === "warn").length;
2707
+ const mode = typeof report.mode === "string" ? report.mode : void 0;
2708
+ const modeSuffix = mode ? ` (mode: ${mode})` : "";
2709
+ return ok ? `all required surfaces wired up${warnCount ? ` \u2014 ${warnCount} optional warning${warnCount === 1 ? "" : "s"}` : ""}${modeSuffix}` : `${failedCount} required surface${failedCount === 1 ? "" : "s"} not ready${modeSuffix}`;
2710
+ }
2711
+ async function runConnectFullRun(args, deps) {
2712
+ const repoRoot = detectRepoRoot(deps.cwd ?? deps.repoRoot, deps);
2713
+ const gitRemote = detectGitRemote(repoRoot, deps);
2714
+ const report = runConnectChecks(repoRoot, deps);
2715
+ const isPresent = (id) => report.checks.find((c) => c.id === id)?.status === "present";
2716
+ const steps = [];
2717
+ let failedStep;
2718
+ let failMessage = "";
2719
+ const warnings = [];
2720
+ const registryBefore = loadVaultRegistry(deps.registryOpts);
2721
+ const existingVault = registryBefore.vaults.find((v) => v.repo_root_abs === repoRoot);
2722
+ const accountId = deps.accountId ?? resolveCliAccountId();
2723
+ const specsDir = deps.joinPath(repoRoot, "specs");
2724
+ if (isPresent("specs_dir")) {
2725
+ if (!isPresent("heyspex_yaml")) {
2726
+ const warning = bareSpecsDirWarning(specsDir, deps);
2727
+ if (warning) warnings.push(warning);
2728
+ }
2729
+ steps.push({
2730
+ id: "specs_dir",
2731
+ label: "specs/ directory",
2732
+ status: "skipped",
2733
+ detail: "already exists"
2734
+ });
2735
+ } else if (args.dryRun) {
2736
+ steps.push({
2737
+ id: "specs_dir",
2738
+ label: "specs/ directory",
2739
+ status: "run",
2740
+ detail: `would create ${specsDir}`
2741
+ });
2742
+ } else {
2743
+ try {
2744
+ deps.mkdir(specsDir);
2745
+ steps.push({
2746
+ id: "specs_dir",
2747
+ label: "specs/ directory",
2748
+ status: "run",
2749
+ detail: `created ${specsDir}`
2750
+ });
2751
+ } catch (err) {
2752
+ failedStep = "specs_dir";
2753
+ failMessage = err instanceof Error ? err.message : String(err);
2754
+ steps.push({
2755
+ id: "specs_dir",
2756
+ label: "specs/ directory",
2757
+ status: "failed",
2758
+ detail: failMessage
2759
+ });
2760
+ }
2761
+ }
2762
+ const yamlPath = deps.joinPath(repoRoot, "heyspex.yaml");
2763
+ let vaultId;
2764
+ if (!failedStep) {
2765
+ if (isPresent("heyspex_yaml")) {
2766
+ const content = deps.fileExists(yamlPath) ? deps.readFile(yamlPath) : null;
2767
+ vaultId = content ? parseVaultId(content) ?? void 0 : void 0;
2768
+ steps.push({
2769
+ id: "heyspex_yaml",
2770
+ label: "heyspex.yaml vault marker",
2771
+ status: "skipped",
2772
+ detail: "already initialized"
2773
+ });
2774
+ } else if (args.dryRun) {
2775
+ steps.push({
2776
+ id: "heyspex_yaml",
2777
+ label: "heyspex.yaml vault marker",
2778
+ status: "run",
2779
+ detail: `would write ${yamlPath}`
2780
+ });
2781
+ } else {
2782
+ let adoptedVaultId = existingVault?.id;
2783
+ if (!adoptedVaultId) {
2784
+ const probe = {};
2785
+ if (repoRoot) probe.repoRootAbs = repoRoot;
2786
+ if (gitRemote) probe.githubRemote = gitRemote;
2787
+ if (probe.repoRootAbs || probe.githubRemote) {
2788
+ const neonMatch = await safeAdoptVaultByRepoIdentity(accountId, probe, deps.neon);
2789
+ if (neonMatch) adoptedVaultId = neonMatch.id;
2790
+ }
2791
+ }
2792
+ const consoleUrl = resolveConsoleUrl({ repoRoot });
2793
+ const initDeps = {
2794
+ repoRoot,
2795
+ fileExists: deps.fileExists,
2796
+ readFile: deps.readFile,
2797
+ writeFile: deps.writeFile,
2798
+ joinPath: deps.joinPath
2799
+ };
2800
+ const initResult = runInit(
2801
+ { consoleUrl, vaultRoot: "specs/", force: false, json: false, adoptedVaultId },
2802
+ initDeps
2803
+ );
2804
+ if (initResult.isError) {
2805
+ failedStep = "heyspex_yaml";
2806
+ failMessage = initResult.message;
2807
+ steps.push({
2808
+ id: "heyspex_yaml",
2809
+ label: "heyspex.yaml vault marker",
2810
+ status: "failed",
2811
+ detail: failMessage
2812
+ });
2813
+ } else {
2814
+ vaultId = initResult.payload.vault_id;
2815
+ steps.push({
2816
+ id: "heyspex_yaml",
2817
+ label: "heyspex.yaml vault marker",
2818
+ status: "run",
2819
+ detail: `wrote ${yamlPath}`
2820
+ });
2821
+ }
2822
+ }
2823
+ }
2824
+ if (vaultId === void 0) {
2825
+ const existingContent = deps.fileExists(yamlPath) ? deps.readFile(yamlPath) : null;
2826
+ if (existingContent) vaultId = parseVaultId(existingContent) ?? void 0;
2827
+ }
2828
+ let workspaceId = existingVault?.workspace_id ?? args.workspace ?? registryBefore.active_workspace_id ?? "default";
2829
+ const now = deps.now();
2830
+ if (!failedStep) {
2831
+ if (isPresent("workspace_registered")) {
2832
+ steps.push({
2833
+ id: "workspace_registered",
2834
+ label: `workspace "${workspaceId}"`,
2835
+ status: "skipped",
2836
+ detail: "already registered"
2837
+ });
2838
+ } else if (args.dryRun) {
2839
+ steps.push({
2840
+ id: "workspace_registered",
2841
+ label: `workspace "${workspaceId}"`,
2842
+ status: "run",
2843
+ detail: `would register workspace "${workspaceId}"`
2844
+ });
2845
+ } else {
2846
+ try {
2847
+ const isNewWorkspaceRequest = args.workspace !== void 0 && args.workspace === workspaceId && !registryBefore.workspaces.some((w) => w.id === args.workspace);
2848
+ if (isNewWorkspaceRequest) {
2849
+ const created = await createWorkspace(
2850
+ { accountId, displayName: args.workspace },
2851
+ { registryOpts: deps.registryOpts, neon: deps.neon, now: () => now }
2852
+ );
2853
+ if (!created.ok) throw new Error(created.message);
2854
+ workspaceId = created.workspace.id;
2855
+ steps.push({
2856
+ id: "workspace_registered",
2857
+ label: `workspace "${workspaceId}"`,
2858
+ status: "run",
2859
+ detail: `created workspace "${workspaceId}" (from "${args.workspace}")${cloudWriteDetail(created.workspace.neon_written, deps.neon)}`
2860
+ });
2861
+ } else {
2862
+ registerWorkspace(
2863
+ { id: workspaceId, display_name: workspaceId, created_at: now },
2864
+ deps.registryOpts
2865
+ );
2866
+ const neonWritten = await upsertWorkspaceNeon(
2867
+ { id: workspaceId, displayName: workspaceId },
2868
+ accountId,
2869
+ deps.neon
2870
+ );
2871
+ steps.push({
2872
+ id: "workspace_registered",
2873
+ label: `workspace "${workspaceId}"`,
2874
+ status: "run",
2875
+ detail: `registered${cloudWriteDetail(neonWritten, deps.neon)}`
2876
+ });
2877
+ }
2878
+ } catch (err) {
2879
+ failedStep = "workspace_registered";
2880
+ failMessage = err instanceof Error ? err.message : String(err);
2881
+ steps.push({
2882
+ id: "workspace_registered",
2883
+ label: `workspace "${workspaceId}"`,
2884
+ status: "failed",
2885
+ detail: failMessage
2886
+ });
2887
+ }
2888
+ }
2889
+ }
2890
+ if (!failedStep) {
2891
+ if (isPresent("vault_registered")) {
2892
+ steps.push({
2893
+ id: "vault_registered",
2894
+ label: "vault registration",
2895
+ status: "skipped",
2896
+ detail: existingVault ? `id: ${existingVault.id}` : "already registered"
2897
+ });
2898
+ } else if (args.dryRun) {
2899
+ steps.push({
2900
+ id: "vault_registered",
2901
+ label: "vault registration",
2902
+ status: "run",
2903
+ detail: vaultId ? `would register vault (id: ${vaultId})` : "would register vault (id resolved once heyspex.yaml is written)"
2904
+ });
2905
+ } else {
2906
+ const resolvedVaultId = vaultId ?? slugFromPath(deps, repoRoot);
2907
+ const displayName = deps.basename(repoRoot);
2908
+ let schemaVersion = HEYSPEX_YAML_SCHEMA_VERSION;
2909
+ const yamlContentAfter = deps.fileExists(yamlPath) ? deps.readFile(yamlPath) : null;
2910
+ if (yamlContentAfter) {
2911
+ const versionMatch = yamlContentAfter.match(/^schema_version:\s*(\d+)\s*$/m);
2912
+ if (versionMatch) schemaVersion = Number(versionMatch[1]);
2913
+ }
2914
+ try {
2915
+ registerVault(
2916
+ {
2917
+ id: resolvedVaultId,
2918
+ workspace_id: workspaceId,
2919
+ display_name: displayName,
2920
+ repo_root_abs: repoRoot,
2921
+ schema_version: schemaVersion,
2922
+ registered_at: now,
2923
+ // Persist the detected remote into the local registry write, too
2924
+ // ([[HS-8XB6-P1EC-DIAG-connect-never-persists-the-git-remote]]
2925
+ // fix (a)) — the exact field the repo-identity reconciliation
2926
+ // helpers match on; a connect that never writes it makes every
2927
+ // vault it creates unmatchable by remote on the next machine.
2928
+ ...gitRemote ? { github_remote: gitRemote } : {}
2929
+ },
2930
+ deps.registryOpts
2931
+ );
2932
+ const neonWritten = await upsertVaultNeon(
2933
+ {
2934
+ id: resolvedVaultId,
2935
+ workspaceId,
2936
+ displayName,
2937
+ repoRootAbs: repoRoot,
2938
+ schemaVersion,
2939
+ ...gitRemote ? { githubRemote: gitRemote } : {}
2940
+ },
2941
+ accountId,
2942
+ deps.neon
2943
+ );
2944
+ vaultId = resolvedVaultId;
2945
+ steps.push({
2946
+ id: "vault_registered",
2947
+ label: "vault registration",
2948
+ status: "run",
2949
+ detail: `registered vault "${resolvedVaultId}"${cloudWriteDetail(neonWritten, deps.neon)}`
2950
+ });
2951
+ } catch (err) {
2952
+ failedStep = "vault_registered";
2953
+ failMessage = err instanceof Error ? err.message : String(err);
2954
+ steps.push({
2955
+ id: "vault_registered",
2956
+ label: "vault registration",
2957
+ status: "failed",
2958
+ detail: failMessage
2959
+ });
2960
+ }
2961
+ }
2962
+ }
2963
+ const platformRoot = deps.platformRoot ?? repoRoot;
2964
+ const runSpawned = (command, spawnArgs, opts) => {
2965
+ if (!deps.spawn) return { ok: false, message: "no spawn capability in this caller" };
2966
+ try {
2967
+ const res = deps.spawn(command, spawnArgs, opts);
2968
+ const output = `${res.stderr ?? ""}${res.stdout ?? ""}`;
2969
+ if (res.status === 0 || /already/i.test(output)) {
2970
+ return { ok: true, message: (res.stdout || res.stderr || "").trim() };
2971
+ }
2972
+ return {
2973
+ ok: false,
2974
+ message: (res.stderr || res.stdout || `exit ${res.status ?? "?"}`).trim()
2975
+ };
2976
+ } catch (err) {
2977
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
2978
+ }
2979
+ };
2980
+ if (!failedStep) {
2981
+ const claudeMcpPath = deps.joinPath(repoRoot, ".mcp.json");
2982
+ const cursorMcpPath = deps.joinPath(repoRoot, ".cursor", "mcp.json");
2983
+ const label = "MCP server entry (Claude Code / Cursor)";
2984
+ const claudeMissing = !deps.fileExists(claudeMcpPath);
2985
+ const cursorMissing = !deps.fileExists(cursorMcpPath);
2986
+ if (!claudeMissing && !cursorMissing) {
2987
+ steps.push({ id: "mcp_entry", label, status: "skipped", detail: "already installed" });
2988
+ } else if (args.dryRun) {
2989
+ const toWrite = [
2990
+ claudeMissing ? claudeMcpPath : null,
2991
+ cursorMissing ? cursorMcpPath : null
2992
+ ].filter((v) => v !== null);
2993
+ steps.push({
2994
+ id: "mcp_entry",
2995
+ label,
2996
+ status: "run",
2997
+ detail: `would write ${toWrite.join(" + ")}`
2998
+ });
2999
+ } else {
3000
+ try {
3001
+ const cliPath = mcpCliPath(platformRoot, deps.joinPath);
3002
+ const written = [];
3003
+ if (claudeMissing) {
3004
+ deps.writeFile(
3005
+ claudeMcpPath,
3006
+ `${JSON.stringify(renderClaudeMcpJson(cliPath), null, 2)}
3007
+ `
3008
+ );
3009
+ written.push(claudeMcpPath);
3010
+ }
3011
+ if (cursorMissing) {
3012
+ deps.mkdir(deps.joinPath(repoRoot, ".cursor"));
3013
+ deps.writeFile(
3014
+ cursorMcpPath,
3015
+ `${JSON.stringify(renderCursorMcpJson(cliPath), null, 2)}
3016
+ `
3017
+ );
3018
+ written.push(cursorMcpPath);
3019
+ }
3020
+ steps.push({
3021
+ id: "mcp_entry",
3022
+ label,
3023
+ status: "run",
3024
+ detail: written.length === 2 ? `wrote ${written.join(", ")}` : `backfilled missing surface: wrote ${written.join(", ")}`
3025
+ });
3026
+ } catch (err) {
3027
+ failedStep = "mcp_entry";
3028
+ failMessage = err instanceof Error ? err.message : String(err);
3029
+ steps.push({ id: "mcp_entry", label, status: "failed", detail: failMessage });
3030
+ }
3031
+ }
3032
+ }
3033
+ if (!failedStep) {
3034
+ const label = "Claude plugin surfaces (commands + skills)";
3035
+ const marketplaceManifest = pluginMarketplaceManifestPath(platformRoot, deps.joinPath);
3036
+ const pluginAvailable = deps.fileExists(marketplaceManifest);
3037
+ if (isPresent("plugin_surface")) {
3038
+ steps.push({ id: "plugin_surface", label, status: "skipped", detail: "already installed" });
3039
+ } else if (!pluginAvailable) {
3040
+ steps.push({
3041
+ id: "plugin_surface",
3042
+ label,
3043
+ status: "skipped",
3044
+ detail: "platform checkout has no .claude-plugin/marketplace.json"
3045
+ });
3046
+ } else if (args.dryRun) {
3047
+ const spec = resolvePluginSpec(platformRoot, deps);
3048
+ steps.push({
3049
+ id: "plugin_surface",
3050
+ label,
3051
+ status: "run",
3052
+ detail: `would run: claude plugin marketplace add + claude plugin install ${spec.spec}`
3053
+ });
3054
+ } else {
3055
+ const spec = resolvePluginSpec(platformRoot, deps);
3056
+ const marketplace = runSpawned("claude", ["plugin", "marketplace", "add", platformRoot], {
3057
+ cwd: repoRoot
3058
+ });
3059
+ const install = marketplace.ok ? runSpawned("claude", ["plugin", "install", spec.spec, "--scope", "project"], {
3060
+ cwd: repoRoot
3061
+ }) : marketplace;
3062
+ steps.push({
3063
+ id: "plugin_surface",
3064
+ label,
3065
+ status: install.ok ? "run" : "skipped",
3066
+ detail: install.ok ? `installed plugin ${spec.spec}` : install.message
3067
+ });
3068
+ }
3069
+ }
3070
+ if (!failedStep) {
3071
+ const label = "git hooks (pre-commit / pre-push)";
3072
+ const hooksScript = deps.joinPath(repoRoot, "scripts", "install-git-hooks.mjs");
3073
+ const preCommitHookPath = deps.joinPath(repoRoot, ".git", "hooks", "pre-commit");
3074
+ const foreignHookExists = deps.fileExists(preCommitHookPath);
3075
+ if (isPresent("git_hooks")) {
3076
+ steps.push({ id: "git_hooks", label, status: "skipped", detail: "already installed" });
3077
+ } else if (!deps.fileExists(hooksScript)) {
3078
+ steps.push({
3079
+ id: "git_hooks",
3080
+ label,
3081
+ status: "skipped",
3082
+ detail: "scripts/install-git-hooks.mjs not found in this repo"
3083
+ });
3084
+ } else if (args.dryRun) {
3085
+ steps.push({
3086
+ id: "git_hooks",
3087
+ label,
3088
+ status: "run",
3089
+ detail: foreignHookExists ? `would repair ${preCommitHookPath} (foreign hook, no HeySpex marker) via: node ${hooksScript} --force` : `would run: node ${hooksScript}`
3090
+ });
3091
+ } else {
3092
+ const result = runSpawned(
3093
+ "node",
3094
+ foreignHookExists ? [hooksScript, "--force"] : [hooksScript],
3095
+ { cwd: repoRoot }
3096
+ );
3097
+ steps.push({
3098
+ id: "git_hooks",
3099
+ label,
3100
+ status: result.ok ? "run" : "skipped",
3101
+ detail: result.ok ? foreignHookExists ? "repaired foreign pre-commit hook via scripts/install-git-hooks.mjs --force" : "installed via scripts/install-git-hooks.mjs" : result.message
3102
+ });
3103
+ }
3104
+ }
3105
+ if (!failedStep && args.fleet) {
3106
+ const repoKey = deps.basename(repoRoot);
3107
+ const label = `fleet dispatch registration ("${repoKey}")`;
3108
+ const routingTablePath = deps.joinPath(platformRoot, "dev-factory", "routing-table.json");
3109
+ const routingUpdaterScript = deps.joinPath(
3110
+ platformRoot,
3111
+ "dev-factory",
3112
+ "lib",
3113
+ "routing-updater.mjs"
3114
+ );
3115
+ let alreadyRegistered = false;
3116
+ const routingTableRaw = deps.fileExists(routingTablePath) ? deps.readFile(routingTablePath) : null;
3117
+ if (routingTableRaw) {
3118
+ try {
3119
+ const parsed = JSON.parse(routingTableRaw);
3120
+ alreadyRegistered = Boolean(parsed.repos && Object.hasOwn(parsed.repos, repoKey));
3121
+ } catch {
3122
+ alreadyRegistered = false;
3123
+ }
3124
+ }
3125
+ if (alreadyRegistered) {
3126
+ steps.push({
3127
+ id: "fleet_repo",
3128
+ label,
3129
+ status: "skipped",
3130
+ detail: `repo "${repoKey}" already registered in dev-factory/routing-table.json`
3131
+ });
3132
+ } else if (!deps.fileExists(routingUpdaterScript)) {
3133
+ steps.push({
3134
+ id: "fleet_repo",
3135
+ label,
3136
+ status: "skipped",
3137
+ detail: "dev-factory/lib/routing-updater.mjs not found in this repo"
3138
+ });
3139
+ } else if (args.dryRun) {
3140
+ steps.push({
3141
+ id: "fleet_repo",
3142
+ label,
3143
+ status: "run",
3144
+ detail: `would register repo "${repoKey}" via routing-updater.mjs --register-repo --apply`
3145
+ });
3146
+ } else {
3147
+ const result = runSpawned(
3148
+ "node",
3149
+ [
3150
+ routingUpdaterScript,
3151
+ "--register-repo",
3152
+ "--repo-key",
3153
+ repoKey,
3154
+ "--root",
3155
+ repoRoot,
3156
+ "--apply"
3157
+ ],
3158
+ { cwd: platformRoot }
3159
+ );
3160
+ steps.push({
3161
+ id: "fleet_repo",
3162
+ label,
3163
+ status: result.ok ? "run" : "skipped",
3164
+ detail: result.ok ? `registered repo "${repoKey}"` : result.message
3165
+ });
3166
+ }
3167
+ }
3168
+ let doctorOutcome;
3169
+ if (!failedStep) {
3170
+ const label = "hs doctor acceptance pass";
3171
+ const doctorScript = deps.joinPath(repoRoot, "scripts", "heyspex-doctor.mjs");
3172
+ if (args.dryRun) {
3173
+ steps.push({
3174
+ id: "doctor_acceptance",
3175
+ label,
3176
+ status: "run",
3177
+ detail: `would run: node ${doctorScript} --json`
3178
+ });
3179
+ } else if (args.noDoctor) {
3180
+ steps.push({
3181
+ id: "doctor_acceptance",
3182
+ label,
3183
+ status: "skipped",
3184
+ detail: "doctor skipped (caller runs it)"
3185
+ });
3186
+ } else if (!deps.spawn) {
3187
+ steps.push({
3188
+ id: "doctor_acceptance",
3189
+ label,
3190
+ status: "skipped",
3191
+ detail: "no spawn capability in this caller"
3192
+ });
3193
+ } else if (!deps.fileExists(doctorScript)) {
3194
+ steps.push({
3195
+ id: "doctor_acceptance",
3196
+ label,
3197
+ status: "skipped",
3198
+ detail: "scripts/heyspex-doctor.mjs not found in this repo"
3199
+ });
3200
+ } else {
3201
+ try {
3202
+ const res = deps.spawn("node", [doctorScript, "--json"], { cwd: repoRoot });
3203
+ const rawOut = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim();
3204
+ let report2 = null;
3205
+ try {
3206
+ report2 = JSON.parse(res.stdout ?? "");
3207
+ } catch {
3208
+ report2 = null;
3209
+ }
3210
+ const exitCode = res.status;
3211
+ const doctorOk = report2 ? Boolean(report2.ok) : exitCode === 0;
3212
+ doctorOutcome = {
3213
+ ok: doctorOk,
3214
+ exit_code: exitCode,
3215
+ report: report2,
3216
+ ...report2 ? {} : { raw: rawOut }
3217
+ };
3218
+ const summary = summarizeDoctorReport(report2, doctorOk, exitCode);
3219
+ if (doctorOk) {
3220
+ steps.push({ id: "doctor_acceptance", label, status: "run", detail: summary });
3221
+ } else {
3222
+ failedStep = "doctor_acceptance";
3223
+ failMessage = summary;
3224
+ steps.push({ id: "doctor_acceptance", label, status: "failed", detail: summary });
3225
+ }
3226
+ } catch (err) {
3227
+ const message = err instanceof Error ? err.message : String(err);
3228
+ steps.push({
3229
+ id: "doctor_acceptance",
3230
+ label,
3231
+ status: "skipped",
3232
+ detail: `doctor spawn failed: ${message}`
3233
+ });
3234
+ }
3235
+ }
3236
+ }
3237
+ const ok = failedStep === void 0;
3238
+ const payload = {
3239
+ ok,
3240
+ repo_root: repoRoot,
3241
+ git_remote: gitRemote,
3242
+ dry_run: args.dryRun,
3243
+ steps,
3244
+ workspace_id: workspaceId,
3245
+ ...vaultId ? { vault_id: vaultId } : {},
3246
+ ...failedStep ? { failed_step: failedStep, error: "step_failed" } : {},
3247
+ ...doctorOutcome ? { doctor: doctorOutcome } : {},
3248
+ ...warnings.length > 0 ? { warnings } : {}
3249
+ };
3250
+ const lines = [
3251
+ args.dryRun ? `hs connect (dry run) \u2014 ${repoRoot}` : `hs connect \u2014 ${repoRoot}`,
3252
+ `git remote: ${gitRemote ?? "(none)"}`,
3253
+ "",
3254
+ ...steps.map(
3255
+ (s) => ` [${s.status === "skipped" ? "-" : s.status === "failed" ? "!" : "x"}] ${s.label}: ${s.status}${s.detail ? ` (${s.detail})` : ""}`
3256
+ ),
3257
+ ...warnings.length > 0 ? warnings.map((w) => `[!] ${w}`) : [],
3258
+ "",
3259
+ ok ? args.dryRun ? "Dry run only \u2014 nothing was changed." : "hs connect complete." : `hs connect failed at step "${failedStep}": ${failMessage}`
3260
+ ];
3261
+ return {
3262
+ code: ok ? EXIT.OK : EXIT.WRITE_FAILED,
3263
+ message: lines.join("\n"),
3264
+ payload,
3265
+ isError: !ok
3266
+ };
3267
+ }
3268
+ async function runConnect(args, deps) {
3269
+ if (args.sub === "check") {
3270
+ const report = runConnectChecks(deps.repoRoot, deps);
3271
+ return {
3272
+ code: report.connected ? EXIT.OK : 1,
3273
+ message: formatReport(report),
3274
+ payload: { ok: true, ...report },
3275
+ isError: false
3276
+ };
3277
+ }
3278
+ return runConnectFullRun(args, deps);
3279
+ }
3280
+ var HELP2 = [
3281
+ "Usage:",
3282
+ " hs connect [--dry-run] [--json] [--fleet] [--no-doctor] [--workspace <id>]",
3283
+ " Detect + scaffold +",
3284
+ " register + install + verify",
3285
+ " hs connect --check [--json] Read-only report: is this repo connected?",
3286
+ "",
3287
+ "`hs connect` (no flags) walks up from the current directory to the repo root (.git)",
3288
+ "and reads its git remote (no prompts), then runs each step below \u2014 skipping any",
3289
+ "artifact that's already present, driven by the same report --check prints:",
3290
+ " \u2022 specs/ directory",
3291
+ " \u2022 heyspex.yaml vault marker (via hs init's existing logic \u2014 never a second writer)",
3292
+ " \u2022 workspace registration (~/.heyspex/registry.json always, Neon best-effort) \u2014 for a NEW",
3293
+ " vault the workspace id resolves as: explicit --workspace <id>, else the ACTIVE",
3294
+ " workspace from ~/.heyspex/registry.json (set by `hs workspace switch`), else",
3295
+ ' "default" as a last resort. An already-registered vault keeps its own workspace,',
3296
+ " ignoring --workspace.",
3297
+ " \u2022 vault registration (~/.heyspex/registry.json always, Neon best-effort)",
3298
+ " \u2022 MCP server entry for Claude Code / Cursor (via hs install's existing render logic)",
3299
+ " \u2022 Claude plugin surfaces \u2014 commands + skills (via hs install's plugin registration)",
3300
+ " \u2022 git hooks \u2014 pre-commit / pre-push (via scripts/install-git-hooks.mjs)",
3301
+ " \u2022 with --fleet: fleet dispatch registration in dev-factory/routing-table.json",
3302
+ " (via routing-updater.mjs --register-repo --apply; skipped without --fleet)",
3303
+ " \u2022 doctor acceptance pass \u2014 runs `node scripts/heyspex-doctor.mjs --json` in the",
3304
+ " target repo once everything above succeeds; connect's own exit code IS doctor's",
3305
+ " (non-zero if doctor fails, even when every earlier step succeeded). Skipped by",
3306
+ " --dry-run (still listed as the planned final step) and by --no-doctor (for a",
3307
+ " caller like `hs join` that runs doctor itself right after connect).",
3308
+ "",
3309
+ "The installer steps never hard-fail the run on a missing/failing external CLI",
3310
+ "(claude not on PATH, script not vendored into this repo) \u2014 they report skipped",
3311
+ "with the reason, same best-effort philosophy as hs install's --claude-apply.",
3312
+ "",
3313
+ "Safe to run twice \u2014 a second run reports every step skipped. --dry-run computes",
3314
+ "the same ordered step plan and prints it without writing anything. Exits 0 on",
3315
+ "success, non-zero with the failing step named in the JSON payload's failed_step",
3316
+ "(which is 'doctor_acceptance' when every step ran but the acceptance pass failed).",
3317
+ "",
3318
+ "Flags:",
3319
+ " --check Run the read-only connection check instead of the full run.",
3320
+ " --dry-run Print the step plan without writing anything (full run only).",
3321
+ " --fleet Also register this repo for fleet dispatch (dev-factory/routing-table.json).",
3322
+ " --no-doctor Skip the final doctor acceptance pass (the caller runs it instead).",
3323
+ " --workspace <id> Workspace id for a NEW vault (full run only; an already-registered",
3324
+ " vault keeps its own workspace and ignores this flag).",
3325
+ " --json Emit a machine-readable JSON result.",
3326
+ " --help, -h Show this help.",
3327
+ "",
3328
+ "Exit codes: 0 ok (or connected, for --check) \xB7 1 usage / not connected (--check) \xB7 13 write-failed"
3329
+ ].join("\n");
3330
+
3331
+ // spex-console/src/lib/cli/defer.ts
3332
+ import { readFileSync as readFileSync5 } from "node:fs";
3333
+ import os2 from "node:os";
3334
+ import path3 from "node:path";
3335
+ function resolveSpexConsoleUrl(vaultRoot, readDiscovery, opts = {}) {
3336
+ const env = opts.env ?? process.env;
3337
+ const raw = (env.HEYSPEX_CONSOLE_URL ?? env.HEYSPEX_COCKPIT_URL)?.trim();
3338
+ if (raw && raw.length > 0) return raw.replace(/\/+$/, "");
3339
+ try {
3340
+ const homeDir = opts.homeDir ?? os2.homedir();
3341
+ const readFile2 = opts.readFile ?? ((p) => readFileSync5(p, "utf-8"));
3342
+ const credentials = readFile2(path3.join(homeDir, ".heyspex", "credentials.yaml"));
3343
+ const match = credentials.match(/^console_url:\s*["']?([^"'\r\n#]+)/m);
3344
+ const value = match?.[1]?.trim();
3345
+ if (value && value.length > 0) return value.replace(/\/+$/, "");
3346
+ } catch {
3347
+ }
3348
+ for (const name of ["console-url", "cockpit-url"]) {
3349
+ try {
3350
+ const discovery = readDiscovery(`${vaultRoot}/.heyspex/${name}`);
3351
+ if (discovery && discovery.trim().length > 0) {
3352
+ return discovery.trim().replace(/\/+$/, "");
3353
+ }
3354
+ } catch {
3355
+ }
3356
+ }
3357
+ return DEFAULT_CONSOLE_URL;
3358
+ }
3359
+ var HELP3 = [
3360
+ "Usage:",
3361
+ " hs defer <spec-id> [--until <when>] [--json] [--no-daemon]",
3362
+ " hs undefer <spec-id> [--json] [--no-daemon]",
3363
+ "",
3364
+ "Time-shift a spec via its `defer_until` frontmatter field. A deferred spec is",
3365
+ "scheduler-invisible until the date passes, then auto-revives.",
3366
+ "",
3367
+ "Arguments:",
3368
+ " <spec-id> The spec to defer/undefer (e.g. HS-TASK-EWL5).",
3369
+ "",
3370
+ "Flags:",
3371
+ " --until <when> ISO date, or relative offset +Nd / +Nw / +Nm / +Ny.",
3372
+ " Defaults to +1y (matches the console Defer button).",
3373
+ " Ignored by `undefer` (which always clears the field).",
3374
+ " --json Emit a machine-readable JSON result.",
3375
+ " --no-daemon Write straight to the vault (no console needed; CI path).",
3376
+ " --help, -h Show this help.",
3377
+ "",
3378
+ "Exit codes: 0 ok \xB7 1 usage \xB7 10 not-found \xB7 11 bad-arg \xB7 12 console-unreachable \xB7 13 write-failed"
3379
+ ].join("\n");
3380
+
3381
+ // spex-console/bin/hs-connect.ts
3382
+ function readFile(p) {
3383
+ try {
3384
+ return readFileSync6(p, "utf8");
3385
+ } catch {
3386
+ return null;
3387
+ }
3388
+ }
3389
+ async function main() {
3390
+ const parsed = parseConnectArgs(process2.argv.slice(3));
3391
+ if (parsed.help) {
3392
+ process2.stdout.write(`${HELP2}
3393
+ `);
3394
+ process2.exit(EXIT.OK);
3395
+ }
3396
+ if (parsed.error) {
3397
+ process2.stderr.write(`hs connect: ${parsed.error.message}
3398
+ `);
3399
+ process2.exit(parsed.error.code);
3400
+ }
3401
+ const cwd = resolveConnectCwd(process2.env, process2.cwd());
3402
+ const repoRoot = process2.env.HS_VAULT_ROOT ? path4.resolve(process2.env.HS_VAULT_ROOT) : cwd;
3403
+ const platformRoot = (process2.env.HS_INSTALL_PLATFORM?.trim() || process2.env.HEYSPEX_PLATFORM_ROOT?.trim() || process2.env.HS_PKG_ROOT?.trim() || repoRoot).trim();
3404
+ const consoleUrl = resolveSpexConsoleUrl(repoRoot, readFile);
3405
+ const result = await runConnect(parsed.args, {
3406
+ repoRoot,
3407
+ cwd,
3408
+ platformRoot: path4.resolve(platformRoot),
3409
+ fileExists: (p) => existsSync2(p),
3410
+ readFile,
3411
+ writeFile: (p, c) => writeFileSync2(p, c, "utf8"),
3412
+ mkdir: (p) => mkdirSync2(p, { recursive: true }),
3413
+ joinPath: (...s) => path4.join(...s),
3414
+ basename: (p) => path4.basename(p),
3415
+ now: () => (/* @__PURE__ */ new Date()).toISOString(),
3416
+ spawn: (command, args, opts) => {
3417
+ const res = spawnSync(command, args, {
3418
+ cwd: opts?.cwd,
3419
+ encoding: "utf8",
3420
+ shell: process2.platform === "win32"
3421
+ });
3422
+ return { status: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
3423
+ },
3424
+ neon: { http: { fetchImpl: fetch, consoleUrl, token: readPairedToken() } }
3425
+ });
3426
+ const sink = result.isError ? process2.stderr : process2.stdout;
3427
+ sink.write(`${parsed.args.json ? JSON.stringify(result.payload) : result.message}
3428
+ `);
3429
+ process2.exit(result.code);
3430
+ }
3431
+ main().catch((err) => {
3432
+ const message = err instanceof Error ? err.message : String(err);
3433
+ process2.stderr.write(`hs connect: unhandled: ${message}
3434
+ `);
3435
+ process2.exit(EXIT.WRITE_FAILED);
3436
+ });