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