@geraldmaron/construct 1.0.23 → 1.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 (106) hide show
  1. package/README.md +0 -2
  2. package/bin/construct +189 -207
  3. package/lib/agent-instructions/inject.mjs +25 -4
  4. package/lib/audit-rules.mjs +127 -0
  5. package/lib/audit-skills.mjs +43 -1
  6. package/lib/beads-client.mjs +9 -0
  7. package/lib/beads-optimistic.mjs +23 -71
  8. package/lib/bridges/copilot-proxy.mjs +116 -0
  9. package/lib/cli-commands.mjs +5 -1
  10. package/lib/comment-lint.mjs +1 -1
  11. package/lib/config/schema.mjs +1 -1
  12. package/lib/document-extract/docling-client.mjs +16 -6
  13. package/lib/document-extract/docling-sidecar.py +32 -2
  14. package/lib/document-extract.mjs +37 -10
  15. package/lib/document-ingest.mjs +90 -5
  16. package/lib/embed/inbox.mjs +6 -3
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/embed/roadmap.mjs +16 -1
  20. package/lib/engine/consolidate.mjs +160 -3
  21. package/lib/engine/contradiction-judge.mjs +71 -0
  22. package/lib/engine/contradiction.mjs +74 -0
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/session-reflect.mjs +5 -1
  25. package/lib/host-capabilities.mjs +30 -0
  26. package/lib/ingest/docling-remote.mjs +90 -0
  27. package/lib/ingest/strategy.mjs +1 -1
  28. package/lib/init-unified.mjs +9 -13
  29. package/lib/intake/git-queue.mjs +195 -0
  30. package/lib/intake/queue.mjs +9 -16
  31. package/lib/logging/rotate.mjs +18 -0
  32. package/lib/mcp/server.mjs +124 -12
  33. package/lib/mcp/tool-budget.mjs +53 -0
  34. package/lib/mcp/tools/storage.mjs +2 -3
  35. package/lib/mcp-catalog.json +4 -4
  36. package/lib/mcp-manager.mjs +59 -3
  37. package/lib/observation-store.mjs +38 -166
  38. package/lib/ollama/capability-store.mjs +78 -0
  39. package/lib/ollama/provision-context.mjs +344 -0
  40. package/lib/opencode-config.mjs +148 -0
  41. package/lib/opencode-telemetry.mjs +7 -0
  42. package/lib/orchestration/runtime.mjs +3 -2
  43. package/lib/orchestration-policy.mjs +41 -6
  44. package/lib/platforms/capabilities.mjs +100 -0
  45. package/lib/prompt-composer.js +12 -8
  46. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  47. package/lib/reconcile/index.mjs +0 -2
  48. package/lib/reflect/extractor.mjs +14 -1
  49. package/lib/reflect/salience.mjs +65 -0
  50. package/lib/rules-delivery.mjs +122 -0
  51. package/lib/runtime/uv-bootstrap.mjs +32 -17
  52. package/lib/service-manager.mjs +79 -259
  53. package/lib/setup.mjs +44 -425
  54. package/lib/specialists/prompt-schema.mjs +162 -0
  55. package/lib/specialists/scaffold.mjs +109 -0
  56. package/lib/status.mjs +3 -6
  57. package/lib/storage/admin.mjs +48 -325
  58. package/lib/storage/backend.mjs +10 -57
  59. package/lib/storage/embeddings-engine.mjs +19 -5
  60. package/lib/storage/hybrid-query.mjs +15 -196
  61. package/lib/storage/sync.mjs +36 -177
  62. package/lib/storage/vector-client.mjs +256 -235
  63. package/lib/strategy-store.mjs +35 -286
  64. package/lib/telemetry/beads-fallback.mjs +40 -0
  65. package/lib/telemetry/hook-calls.mjs +138 -0
  66. package/lib/worker/entrypoint.mjs +6 -14
  67. package/package.json +6 -5
  68. package/personas/construct.md +1 -1
  69. package/platforms/capabilities.json +76 -0
  70. package/platforms/claude/settings.template.json +0 -7
  71. package/platforms/opencode/sync-config.mjs +121 -25
  72. package/rules/common/neurodivergent-output.md +1 -1
  73. package/rules/web/coding-style.md +8 -0
  74. package/rules/web/design-quality.md +8 -0
  75. package/rules/web/hooks.md +8 -0
  76. package/rules/web/patterns.md +8 -0
  77. package/rules/web/performance.md +8 -0
  78. package/rules/web/security.md +8 -0
  79. package/rules/web/testing.md +8 -0
  80. package/scripts/sync-specialists.mjs +174 -40
  81. package/specialists/prompts/cx-architect.md +20 -0
  82. package/specialists/prompts/cx-qa.md +1 -1
  83. package/specialists/prompts/cx-test-automation.md +12 -0
  84. package/specialists/registry.json +0 -8
  85. package/templates/docs/construct_guide.md +1 -1
  86. package/db/schema/001_init.sql +0 -40
  87. package/db/schema/002_pgvector.sql +0 -182
  88. package/db/schema/003_intake.sql +0 -47
  89. package/db/schema/003_observation_reconciliation.sql +0 -14
  90. package/db/schema/004_recommendations.sql +0 -46
  91. package/db/schema/005_strategy.sql +0 -21
  92. package/db/schema/006_graph.sql +0 -24
  93. package/db/schema/007_tags.sql +0 -30
  94. package/db/schema/008_skill_usage.sql +0 -24
  95. package/db/schema/009_scheduler.sql +0 -14
  96. package/db/schema/010_cx_scores.sql +0 -51
  97. package/lib/intake/postgres-queue.mjs +0 -240
  98. package/lib/reconcile/postgres-namespace.mjs +0 -102
  99. package/lib/services/local-postgres.mjs +0 -15
  100. package/lib/storage/backup.mjs +0 -347
  101. package/lib/storage/migrations.mjs +0 -187
  102. package/lib/storage/postgres-backup.mjs +0 -124
  103. package/lib/storage/sql-store.mjs +0 -45
  104. package/lib/storage/store-version.mjs +0 -115
  105. package/lib/storage/unified-storage.mjs +0 -550
  106. package/lib/storage/vector-store.mjs +0 -100
package/lib/setup.mjs CHANGED
@@ -2,15 +2,18 @@
2
2
  /**
3
3
  * lib/setup.mjs — Machine-scoped first-run setup wizard for Construct.
4
4
  *
5
- * Installs cm/cass, configures managed defaults, starts local Postgres
6
- * (consent-driven), pre-warms the embedding model, wires MCP integrations,
7
- * and writes ~/.construct/config.env. Invoked by `construct install` and
8
- * by lib/install/first-invocation.mjs when a TTY user is missing resources.
5
+ * Installs cm/cass, configures managed defaults, pre-warms the embedding model,
6
+ * wires MCP integrations, and writes ~/.construct/config.env.
7
+ * Invoked by `construct install` and by lib/install/first-invocation.mjs
8
+ * when a TTY user is missing resources.
9
9
  *
10
10
  * Project-scoped scaffolding (`.cx/`, AGENTS.md, plan.md, adapters) lives
11
11
  * in lib/init-unified.mjs — invoked by `construct init`. These two flows
12
12
  * are intentionally separate: install runs once per machine, init runs
13
13
  * once per repo.
14
+ *
15
+ * Construct now uses embedded LanceDB and Git-backed state, removing the
16
+ * dependency on local Docker-managed Postgres.
14
17
  */
15
18
 
16
19
  import fs from 'node:fs';
@@ -26,51 +29,37 @@ import {
26
29
  getDeploymentMode,
27
30
  } from './deployment-mode.mjs';
28
31
  import { getCanonicalOpenCodeConfigPath, readOpenCodeConfig, writeOpenCodeConfig } from './opencode-config.mjs';
29
- import { syncFileStateToSql } from './storage/sync.mjs';
30
- import { runMigrations } from './storage/migrations.mjs';
31
- import { createSqlClient, closeSqlClient } from './storage/backend.mjs';
32
32
  import { getEmbeddingModelInfo, warmupEmbeddingModel } from './storage/embeddings-engine.mjs';
33
- import { restoreConstructDb } from './storage/postgres-backup.mjs';
34
33
  import { buildPressureGuardValues, installPressureGuardLaunchAgent, loadPressureGuardLaunchAgent } from './runtime-pressure.mjs';
35
34
  import { consentToInstall } from './setup-prompts.mjs';
36
- import { postgresPort, postgresContainerName } from './home-namespace.mjs';
37
35
 
38
36
  const ROOT_DIR = path.resolve(import.meta.dirname, '..');
39
37
  const HOME = os.homedir();
40
- const LOCAL_POSTGRES_USER = 'construct';
41
- const LOCAL_POSTGRES_PASSWORD = 'construct';
42
- const LOCAL_POSTGRES_DB = 'construct';
43
-
44
- // The local DB URL's port is derived per home (construct-lb7b), so build it at
45
- // the call site from the active homeDir rather than a single module-level const.
46
-
47
- function localDatabaseUrl(env = process.env, home = HOME) {
48
- return `postgresql://${LOCAL_POSTGRES_USER}:${LOCAL_POSTGRES_PASSWORD}@127.0.0.1:${postgresPort(env, home)}/${LOCAL_POSTGRES_DB}`;
49
- }
50
38
 
51
39
  function printHelp() {
52
40
  console.log(`Construct install — machine setup (once per machine)
53
41
 
54
42
  Usage:
55
- construct install [--scope=project|user|both] [--yes] [--no-docker] [--no-launch-agent] [--reconfigure]
43
+ construct install [--scope=project|user|both] [--yes] [--no-launch-agent] [--reconfigure] [--with-docling]
56
44
 
57
45
  Flags:
58
46
  --scope=<s> project | user | both (default: project — see ADR-0029)
59
47
  project: no-op + scope guidance; Construct's project artifacts
60
48
  are written by \`construct init\` inside a repo.
61
49
  user: write machine-scope state (~/.construct/, MCP configs,
62
- local Postgres, ~/.claude/* via consent-gated sync).
50
+ ~/.claude/* via consent-gated sync).
63
51
  both: print project guidance, then run user-scope install.
64
52
  --yes accept detected defaults without prompting
65
- --no-docker skip local Postgres / Docker service setup
66
53
  --no-launch-agent skip macOS LaunchAgent background service registration
67
54
  --reconfigure re-prompt for service consent, ignoring cached answers
55
+ --with-docling eagerly provision the docling document-extraction venv now
56
+ (heavy, ~10 min via uv; otherwise provisioned lazily on
57
+ first document ingest)
68
58
 
69
59
  What --scope=user does:
70
60
  - creates ~/.construct/config.env
71
61
  - ensures OpenCode config exists
72
- - configures managed defaults for local vector retrieval
73
- - starts local Postgres with Docker when available
62
+ - configures managed defaults for local vector retrieval (LanceDB)
74
63
  - checks required runtime tools and installs cm and cass when available
75
64
  - wires Memory, GitHub, and telemetry configuration
76
65
  - runs construct sync --global (which writes ~/.claude/* per consent)
@@ -100,23 +89,20 @@ function findCommand(command) {
100
89
  }
101
90
 
102
91
  export function defaultVectorIndexPath(homeDir = HOME) {
103
- return path.join(homeDir, '.construct', 'vector', 'index.json');
92
+ // LanceDB storage path
93
+ return path.join(homeDir, '.construct', 'vector', 'lancedb');
104
94
  }
105
95
 
106
- export async function buildManagedSetupValues({ homeDir = HOME, env = process.env, databaseUrl = '' } = {}) {
96
+ export async function buildManagedSetupValues({ homeDir = HOME, env = process.env } = {}) {
107
97
  const modelInfo = await getEmbeddingModelInfo({ env });
108
98
 
109
99
  const values = {
110
100
  CONSTRUCT_TRACE_BACKEND: env.CONSTRUCT_TRACE_BACKEND || 'local',
111
- CONSTRUCT_VECTOR_INDEX_PATH: env.CONSTRUCT_VECTOR_INDEX_PATH || defaultVectorIndexPath(homeDir),
101
+ CONSTRUCT_LANCEDB_PATH: env.CONSTRUCT_LANCEDB_PATH || defaultVectorIndexPath(homeDir),
112
102
  CONSTRUCT_VECTOR_MODEL: env.CONSTRUCT_VECTOR_MODEL || modelInfo.model,
113
103
  ...buildPressureGuardValues({ env }),
114
104
  };
115
- // Deployment mode is persisted in construct.config.json — setup never writes
116
- // CONSTRUCT_DEPLOYMENT_MODE to ~/.construct/config.env. The env var is
117
- // reserved for ephemeral runtime overrides (CI/scripts via `export …`).
118
- // Forwarding it here pinned the env value and silently masked any JSON
119
- // setter that ran later.
105
+
120
106
  if (env[DEPLOYMENT_MODE_ENV_KEY] && getDeploymentMode(env) !== DEFAULT_DEPLOYMENT_MODE) {
121
107
  values[DEPLOYMENT_MODE_ENV_KEY] = getDeploymentMode(env);
122
108
  }
@@ -125,53 +111,10 @@ export async function buildManagedSetupValues({ homeDir = HOME, env = process.en
125
111
  if (env.CONSTRUCT_TELEMETRY_PUBLIC_KEY) values.CONSTRUCT_TELEMETRY_PUBLIC_KEY = env.CONSTRUCT_TELEMETRY_PUBLIC_KEY;
126
112
  if (env.CONSTRUCT_TELEMETRY_SECRET_KEY) values.CONSTRUCT_TELEMETRY_SECRET_KEY = env.CONSTRUCT_TELEMETRY_SECRET_KEY;
127
113
 
128
- const resolvedDatabaseUrl = databaseUrl || env.DATABASE_URL || '';
129
- if (resolvedDatabaseUrl) values.DATABASE_URL = resolvedDatabaseUrl;
130
- if (env.CONSTRUCT_VECTOR_URL) values.CONSTRUCT_VECTOR_URL = env.CONSTRUCT_VECTOR_URL;
114
+ if (env.DATABASE_URL) values.DATABASE_URL = env.DATABASE_URL;
131
115
  return values;
132
116
  }
133
117
 
134
- export function localPostgresComposePath(homeDir = HOME) {
135
- return path.join(homeDir, '.construct', 'services', 'postgres', 'docker-compose.yml');
136
- }
137
-
138
- export function writeLocalPostgresCompose(homeDir = HOME) {
139
- const composePath = localPostgresComposePath(homeDir);
140
- fs.mkdirSync(path.dirname(composePath), { recursive: true });
141
-
142
- // Container, port, and volume are derived per home so isolated HOMEs do not
143
- // collide (construct-lb7b). The runtime (service-manager) derives the same
144
- // values from the same home, so they match.
145
-
146
- const container = postgresContainerName(process.env, homeDir);
147
- const port = postgresPort(process.env, homeDir);
148
- const volume = `${container}-data`;
149
- const content = `services:
150
- postgres:
151
- image: pgvector/pgvector:pg16
152
- container_name: ${container}
153
- restart: unless-stopped
154
- environment:
155
- POSTGRES_USER: ${LOCAL_POSTGRES_USER}
156
- POSTGRES_PASSWORD: ${LOCAL_POSTGRES_PASSWORD}
157
- POSTGRES_DB: ${LOCAL_POSTGRES_DB}
158
- ports:
159
- - "127.0.0.1:${port}:5432"
160
- volumes:
161
- - ${volume}:/var/lib/postgresql/data
162
- healthcheck:
163
- test: ["CMD-SHELL", "pg_isready -U ${LOCAL_POSTGRES_USER} -d ${LOCAL_POSTGRES_DB}"]
164
- interval: 5s
165
- timeout: 5s
166
- retries: 20
167
-
168
- volumes:
169
- ${volume}:
170
- `;
171
- fs.writeFileSync(composePath, content, 'utf8');
172
- return composePath;
173
- }
174
-
175
118
  function runQuiet(command, args, { env = process.env, spawn = spawnSync } = {}) {
176
119
  const result = spawn(command, args, {
177
120
  env,
@@ -180,26 +123,6 @@ function runQuiet(command, args, { env = process.env, spawn = spawnSync } = {})
180
123
  return result;
181
124
  }
182
125
 
183
- async function runAsyncQuiet(command, args, { env = process.env, spawn = spawn } = {}) {
184
- return new Promise((resolve) => {
185
- const timeout = setTimeout(() => {
186
- resolve({ status: -1, signal: 'SIGTERM' }); // treat timeout as error
187
- }, 5000);
188
- const child = spawn(command, args, {
189
- env,
190
- stdio: ['ignore', 'pipe', 'pipe'],
191
- });
192
- let stdout = '';
193
- let stderr = '';
194
- child.stdout.on('data', (data) => { stdout += data.toString(); });
195
- child.stderr.on('data', (data) => { stderr += data.toString(); });
196
- child.on('close', (code, signal) => {
197
- clearTimeout(timeout);
198
- resolve({ status: code || 0, signal, stdout, stderr });
199
- });
200
- });
201
- }
202
-
203
126
  export function commandExists(command, { env = process.env, spawn = spawnSync } = {}) {
204
127
  const checker = process.platform === 'win32' ? 'where' : 'which';
205
128
  return runQuiet(checker, [command], { env, spawn }).status === 0;
@@ -233,8 +156,6 @@ export function ensureCmInstalled({ env = process.env, spawn = spawnSync } = {})
233
156
  };
234
157
  }
235
158
 
236
- // Version 0.3.0 has a known issue where the FTS-rebuild loop can OOM on large corpora and fail to converge.
237
- // Version 0.4.0 resolves this. Below this version, we fail closed to avoid the problem.
238
159
  const CASS_MIN_VERSION = '0.4.0';
239
160
 
240
161
  function parseSemver(s) {
@@ -268,7 +189,7 @@ export function ensureCassInstalled({ env = process.env, spawn = spawnSync } = {
268
189
  }
269
190
  return {
270
191
  status: 'outdated',
271
- message: `cass ${version || 'unknown'} is older than required ${CASS_MIN_VERSION}. Older versions hit a frankensqlite FTS-rebuild OOM loop on large session corpora (upstream #168, #186, #110, #155).`,
192
+ message: `cass ${version || 'unknown'} is older than required ${CASS_MIN_VERSION}.`,
272
193
  installCommand: 'brew upgrade dicklesworthstone/tap/cass || brew install dicklesworthstone/tap/cass',
273
194
  };
274
195
  }
@@ -306,121 +227,11 @@ export function ensureCassInstalled({ env = process.env, spawn = spawnSync } = {
306
227
  };
307
228
  }
308
229
 
309
- /**
310
- * Platform-aware install hint shown when Docker isn't on PATH. Mirrors
311
- * what `supabase start` / `prisma init` print: don't auto-install, but
312
- * point at the canonical option for the user's OS so they have a one-line
313
- * command to copy.
314
- */
315
- export function dockerInstallHint(platform = process.platform) {
316
- if (platform === 'darwin') {
317
- return 'Install Docker Desktop (https://www.docker.com/products/docker-desktop) or a lighter alternative like OrbStack (`brew install orbstack`) or Colima (`brew install colima && colima start`).';
318
- }
319
- if (platform === 'win32') {
320
- return 'Install Docker Desktop for Windows (https://www.docker.com/products/docker-desktop).';
321
- }
322
- return 'Install Docker Engine (https://docs.docker.com/engine/install/) — your distribution likely has a `docker` package (apt/dnf/pacman).';
323
- }
324
-
325
- export function detectDockerCompose({ env = process.env, spawn = spawnSync } = {}) {
326
- // Use a timeout wrapper so we don't block forever if Docker daemon is hung/missing
327
- const timeoutWrap = (fn) => {
328
- let timedOut = false;
329
- const timer = setTimeout(() => { timedOut = true; }, 5000);
330
- try {
331
- const result = fn();
332
- clearTimeout(timer);
333
- return timedOut ? null : result;
334
- } catch {
335
- clearTimeout(timer);
336
- return null;
337
- }
338
- };
339
-
340
- const docker = timeoutWrap(() => runQuiet('docker', ['info'], { env, spawn }));
341
- if (!docker || docker.status !== 0) return null;
342
- const compose = timeoutWrap(() => runQuiet('docker', ['compose', 'version'], { env, spawn }));
343
- if (compose && compose.status === 0) return { command: 'docker', argsPrefix: ['compose'] };
344
- const dockerCompose = timeoutWrap(() => runQuiet('docker-compose', ['version'], { env, spawn }));
345
- if (dockerCompose && dockerCompose.status === 0) return { command: 'docker-compose', argsPrefix: [] };
346
- return null;
347
- }
348
-
349
- // Duplicated from service-manager.spawnDetached to avoid the import cycle
350
- // setup ↔ service-manager (service-manager already imports detectDockerCompose
351
- // from setup, so the reverse path can't exist).
352
-
353
- function spawnDetachedForSetup(command, args, homeDir, logFile, options = {}) {
354
- const logDir = path.join(homeDir, '.construct', 'runtime');
355
- fs.mkdirSync(logDir, { recursive: true });
356
- const logPath = path.join(logDir, logFile);
357
- const fd = fs.openSync(logPath, 'a');
358
- const child = spawn(command, args, {
359
- detached: true,
360
- stdio: ['ignore', fd, fd],
361
- cwd: options.cwd,
362
- env: options.env,
363
- });
364
- child.unref();
365
- return { child, logPath };
366
- }
367
-
368
- export function startManagedPostgres({ homeDir = HOME, env = process.env, spawn = spawnSync } = {}) {
369
- const composeRunner = detectDockerCompose({ env, spawn });
370
- if (!composeRunner) {
371
- return {
372
- status: 'skipped',
373
- databaseUrl: env.DATABASE_URL || '',
374
- message: 'Docker is not available; using existing DATABASE_URL if configured.',
375
- };
376
- }
377
-
378
- const composePath = writeLocalPostgresCompose(homeDir);
379
- const result = runQuiet(
380
- composeRunner.command,
381
- [...composeRunner.argsPrefix, '-f', composePath, 'up', '-d', 'postgres'],
382
- { env, spawn },
383
- );
384
-
385
- if (result.status !== 0) {
386
- return {
387
- status: 'degraded',
388
- databaseUrl: env.DATABASE_URL || '',
389
- composePath,
390
- message: (result.stderr || result.stdout || 'Docker compose failed').trim(),
391
- };
392
- }
393
-
394
- return {
395
- status: 'ok',
396
- databaseUrl: localDatabaseUrl(env, homeDir),
397
- composePath,
398
- message: 'Managed local Postgres is running.',
399
- };
400
- }
401
-
402
- async function waitForSqlReady(env, { attempts = 20, delayMs = 500 } = {}) {
403
- for (let attempt = 0; attempt < attempts; attempt += 1) {
404
- const client = createSqlClient(env);
405
- try {
406
- await client`select 1 as ok`;
407
- await closeSqlClient(client);
408
- return true;
409
- } catch {
410
- await closeSqlClient(client).catch(() => {});
411
- await new Promise((resolve) => setTimeout(resolve, delayMs));
412
- }
413
- }
414
- return false;
415
- }
416
-
417
230
  function warnIfGlobalCommandIsUnavailable() {
418
231
  const globalConstruct = findCommand('construct');
419
232
  if (!globalConstruct) {
420
233
  console.log('\nInstall warning: `construct` is not on PATH yet.');
421
234
  console.log(' From this checkout, run: npm install -g .');
422
- console.log(' Without cloning, run: npm install -g github:geraldmaron/construct');
423
- console.log(' Do not use `npm install -g construct`; that npm name belongs to another project.');
424
235
  return;
425
236
  }
426
237
 
@@ -431,7 +242,6 @@ function warnIfGlobalCommandIsUnavailable() {
431
242
  if (version.status !== 0 || !/^construct v\d+\./.test(version.stdout.trim())) {
432
243
  console.log(`\nInstall warning: PATH resolves \`construct\` to ${globalConstruct}, but it does not look like this CLI.`);
433
244
  console.log(' Reinstall from this checkout with: npm install -g .');
434
- console.log(' Or install from GitHub with: npm install -g github:geraldmaron/construct');
435
245
  }
436
246
  }
437
247
 
@@ -446,25 +256,8 @@ function ensureOpenCodeConfig() {
446
256
  return getCanonicalOpenCodeConfigPath();
447
257
  }
448
258
 
449
- // Wire core.hooksPath to .beads/hooks so the tracked hook scripts (ECC
450
- // secret-scan, Construct policy gates, BEADS dispatcher) actually fire on
451
- // git commit/push. Without this, .beads/hooks/* are inert — present in the
452
- // repo but never executed by git. Idempotent: leaves a user-set value alone
453
- // rather than clobbering it.
454
-
455
- // Project-scoped git-hooks wiring lives in its own module and is owned by
456
- // `construct init`. Re-exported here for importers that reference it via
457
- // setup.mjs (e.g. tests/setup-git-hooks-path.test.mjs). `construct install`
458
- // must never mutate the cwd repo (ADR-0027 §3), so the install flow leaves
459
- // hooks wiring to init.
460
-
461
259
  export { ensureGitHooksPath } from './git-hooks-path.mjs';
462
260
 
463
- // $HOME/.construct/lib/hooks/* is the resolution path for Claude Code
464
- // Stop-hook commands. Absent symlink → every Stop hook fails silently,
465
- // including stop-notify (dashboard cost feed). Idempotent: matching
466
- // symlink left alone, stale symlink replaced, real dir refused.
467
-
468
261
  export function ensureLibSymlink({ homeDir = HOME, rootDir = ROOT_DIR } = {}) {
469
262
  const target = path.join(homeDir, '.construct', 'lib');
470
263
  const source = path.join(rootDir, 'lib');
@@ -485,15 +278,10 @@ export function ensureLibSymlink({ homeDir = HOME, rootDir = ROOT_DIR } = {}) {
485
278
  return {
486
279
  status: 'conflict',
487
280
  target,
488
- message: `${target} exists and is not a symlink — leaving alone. Move or remove it, then re-run \`construct install\` to wire the hooks.`,
281
+ message: `${target} exists and is not a symlink — leaving alone.`,
489
282
  };
490
283
  }
491
284
 
492
- // ADR-0029: install is explicitly scoped. Default is `project`, which writes
493
- // nothing and prints scope guidance — silent global writes are the failure mode
494
- // that ADR retires. `user` runs the machine-state body below; `both` prints the
495
- // project guidance and then runs user-scope.
496
-
497
285
  const VALID_SCOPES = new Set(['project', 'user', 'both']);
498
286
  const DEFAULT_SCOPE = 'project';
499
287
 
@@ -516,13 +304,11 @@ function printProjectScopeGuidance() {
516
304
  console.log(' construct install --scope=user');
517
305
  console.log('To do both project (guidance) and user-scope install:');
518
306
  console.log(' construct install --scope=both');
519
- console.log('\nSee ADR-0029 (docs/adr/0029-install-scopes-and-hook-budgets.md) for the contract.');
520
307
  }
521
308
 
522
309
  export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME } = {}) {
523
310
  const argSet = new Set(args);
524
311
  const isYes = argSet.has('--yes');
525
- const skipDocker = argSet.has('--no-docker');
526
312
  const reconfigure = argSet.has('--reconfigure');
527
313
 
528
314
  if (argSet.has('--help') || argSet.has('-h')) {
@@ -530,10 +316,8 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
530
316
  return;
531
317
  }
532
318
 
533
- // Reject unknown flags loudly so a typo (e.g. --reconfig) fails fast with the
534
- // help text instead of silently running defaults.
535
-
536
- const KNOWN_FLAGS = new Set(['--yes', '--no-docker', '--no-launch-agent', '--reconfigure', '--help', '-h']);
319
+ const KNOWN_FLAGS = new Set(['--yes', '--no-launch-agent', '--reconfigure', '--with-docling', '--help', '-h']);
320
+ const withDocling = argSet.has('--with-docling');
537
321
  const unknownFlags = args.filter((a) => {
538
322
  if (!a.startsWith('-')) return false;
539
323
  if (a.startsWith('--scope=') || a === '--scope') return false;
@@ -575,68 +359,25 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
575
359
  console.log(`OpenCode config: ${opencodePath}`);
576
360
  if (libLink.status === 'created' || libLink.status === 'replaced') {
577
361
  console.log(`Hook lib link: ${libLink.target} → ${libLink.source} (${libLink.status})`);
578
- } else if (libLink.status === 'kept') {
579
- console.log(`Hook lib link: ${libLink.target} already in place`);
580
- } else if (libLink.status === 'conflict') {
581
- console.log(`Hook lib link: ${libLink.message}`);
582
362
  }
583
363
  warnIfGlobalCommandIsUnavailable();
584
364
 
585
365
  const cmInstall = ensureCmInstalled({ env: process.env });
586
- if (cmInstall.status === 'installed') {
587
- console.log('Memory CLI: installed cm via Homebrew');
588
- } else if (cmInstall.status === 'available') {
589
- console.log('Memory CLI: cm available');
366
+ if (cmInstall.status === 'installed' || cmInstall.status === 'available') {
367
+ console.log(`Memory CLI: ${cmInstall.message}`);
590
368
  } else {
591
369
  console.log(`Memory CLI: ${cmInstall.message}`);
592
370
  if (cmInstall.installCommand) console.log(` Install with: ${cmInstall.installCommand}`);
593
371
  }
594
372
 
595
373
  const cassInstall = ensureCassInstalled({ env: process.env });
596
- if (cassInstall.status === 'installed') {
374
+ if (cassInstall.status === 'installed' || cassInstall.status === 'available') {
597
375
  console.log(`Session search: ${cassInstall.message}`);
598
- } else if (cassInstall.status === 'available') {
599
- console.log('Session search: cass available');
600
376
  } else {
601
377
  console.log(`Session search: ${cassInstall.message}`);
602
378
  if (cassInstall.installCommand) console.log(` Install with: ${cassInstall.installCommand}`);
603
379
  }
604
380
 
605
- // Local Postgres — consent-driven. Interactive default-yes; --yes accepts
606
- // without prompting. Skipped when --no-docker, when DATABASE_URL is already
607
- // set (caller has an external DB), or when user declines.
608
-
609
- const dockerRunner = detectDockerCompose();
610
- const dockerAvailable = !skipDocker && Boolean(dockerRunner);
611
- let serviceResult = {
612
- status: 'skipped',
613
- databaseUrl: process.env.DATABASE_URL || '',
614
- message: skipDocker ? 'Docker service setup skipped by flag.' : 'Docker not detected — skipping local Postgres.',
615
- };
616
-
617
- if (dockerAvailable) {
618
- const pgConsent = await consentToInstall({
619
- name: 'postgres',
620
- isYes,
621
- force: reconfigure,
622
- alreadyConfigured: Boolean(process.env.DATABASE_URL),
623
- alreadyConfiguredNote: 'DATABASE_URL already set — using external database.',
624
- envPath,
625
- });
626
- if (pgConsent.decision) {
627
- writeLocalPostgresCompose(homeDir);
628
- serviceResult = startManagedPostgres({ homeDir, env: process.env });
629
- console.log(`Postgres: ${serviceResult.status === 'ok' ? 'started locally' : serviceResult.message}`);
630
- } else {
631
- console.log(`Postgres: skipped (${pgConsent.note})`);
632
- }
633
- } else if (skipDocker) {
634
- console.log('Postgres: skipped (--no-docker)');
635
- } else {
636
- console.log('Postgres: skipped (Docker not detected — local Postgres unavailable)');
637
- console.log(` ${dockerInstallHint()}`);
638
- }
639
-
640
381
  const telemetryResult = process.env.CONSTRUCT_TELEMETRY_URL
641
382
  ? { status: 'configured', note: `remote export configured (${process.env.CONSTRUCT_TELEMETRY_URL})` }
642
383
  : { status: 'local', note: 'local JSONL traces in .cx/traces; remote export optional' };
@@ -644,13 +385,6 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
644
385
 
645
386
  fs.mkdirSync(path.dirname(defaultVectorIndexPath(homeDir)), { recursive: true });
646
387
 
647
- // Pre-warm the embedding model so the first agent query doesn't stall on
648
- // a one-time ONNX download. Same pattern as `supabase start` pre-pulling
649
- // images and `playwright install` pre-fetching browsers — make the wait
650
- // visible during setup, not during user-facing work. Best-effort: a
651
- // download failure here degrades gracefully via the engine's hash fallback
652
- // and is non-fatal to setup.
653
-
654
388
  try {
655
389
  const warm = await warmupEmbeddingModel({ env: process.env });
656
390
  if (warm.degraded) {
@@ -662,12 +396,22 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
662
396
  console.log(`Embeddings: warmup skipped (${err?.message || 'unknown error'}) — model will load on first use`);
663
397
  }
664
398
 
665
- // Cheapest provider selection opt-out by default. When user consents,
666
- // evaluate all configured providers, pick the lowest-cost model per tier,
667
- // and write to config.env. On subsequent runs the preference is persisted
668
- // so the prompt is skipped.
399
+ // Docling (document/PDF extraction) provisions a pinned Python venv via uv,
400
+ // which is heavy (~10 min) and only needed for document ingest — so it stays
401
+ // lazy by default and is provisioned eagerly only when the user opts in with
402
+ // --with-docling. First document ingest still auto-provisions if skipped.
403
+ if (withDocling) {
404
+ try {
405
+ const { ensureDoclingVenv } = await import('./runtime/uv-bootstrap.mjs');
406
+ console.log('Docling: provisioning Python venv (uv) — this can take several minutes…');
407
+ const docling = await ensureDoclingVenv();
408
+ console.log(`Docling: ready (${docling.fresh ? 'provisioned' : 'already present'} at ${docling.venvDir})`);
409
+ } catch (err) {
410
+ console.log(`Docling: provisioning skipped (${err?.message || 'unknown error'}) — will provision on first document ingest`);
411
+ }
412
+ }
669
413
 
670
- const { isCheapestProviderEnabled, selectCheapestForAllTiers, setCheapestProviderPreference, formatCheapestProviderMessage } =
414
+ const { isCheapestProviderEnabled, selectCheapestForAllTiers, setCheapestProviderPreference } =
671
415
  await import('./model-cheapest-provider.mjs');
672
416
  const cheapestAlreadyEnabled = isCheapestProviderEnabled(envPath, { env: process.env });
673
417
  if (!cheapestAlreadyEnabled) {
@@ -694,29 +438,22 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
694
438
  const label = selections[tier]?.providerLabel || '';
695
439
  console.log(` ${tier.padEnd(11)} ${model} (${label})`);
696
440
  }
697
- } else {
698
- console.log('\nCheapest provider: no configured providers found — nothing to apply.');
699
441
  }
700
442
  setCheapestProviderPreference(envPath, true);
701
443
  } catch (err) {
702
444
  console.log(`Cheapest provider: skipped (${err?.message || 'unknown error'})`);
703
445
  }
704
- } else {
705
- console.log(`Cheapest provider: skipped (${cheapestConsent.note})`);
706
446
  }
707
- } else {
708
- console.log('Cheapest provider: already enabled — skipping prompt.');
709
447
  }
710
448
 
711
- // Ensure workspace directory with docs lanes exists
712
449
  ensureWorkspace(homeDir);
713
450
 
714
451
  const managedValues = await buildManagedSetupValues({
715
452
  homeDir,
716
453
  env: process.env,
717
- databaseUrl: serviceResult.databaseUrl,
718
454
  });
719
455
  writeEnvValues(envPath, managedValues);
456
+
720
457
  let pressureGuardAgent = null;
721
458
  let pressureGuardLoad = null;
722
459
  if (process.platform === 'darwin' && !argSet.has('--no-launch-agent')) {
@@ -737,110 +474,18 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
737
474
  nodePath: process.execPath,
738
475
  });
739
476
  pressureGuardLoad = loadPressureGuardLaunchAgent({ plistPath: pressureGuardAgent.plistPath });
740
- } else {
741
- console.log(`LaunchAgent: skipped (${laConsent.note})`);
742
- }
743
- } else if (argSet.has('--no-launch-agent')) {
744
- console.log('LaunchAgent: skipped (--no-launch-agent)');
745
- }
746
-
747
- if (isYes) {
748
- console.log('\nManaged setup:');
749
- console.log(` Deployment mode: ${getDeploymentMode(process.env) || DEFAULT_DEPLOYMENT_MODE} (set in construct.config.json — runtime env override available via ${DEPLOYMENT_MODE_ENV_KEY})`);
750
- console.log(` Vector index: ${managedValues.CONSTRUCT_VECTOR_INDEX_PATH}`);
751
- console.log(` Vector model: ${managedValues.CONSTRUCT_VECTOR_MODEL}`);
752
- console.log(` Trace backend: ${managedValues.CONSTRUCT_TRACE_BACKEND}`);
753
- if (managedValues.CONSTRUCT_TELEMETRY_URL) console.log(` Trace URL: ${managedValues.CONSTRUCT_TELEMETRY_URL}`);
754
- console.log(` Pressure guard: every ${managedValues.CONSTRUCT_PRESSURE_GUARD_INTERVAL_SECONDS}s, swap threshold ${managedValues.CONSTRUCT_PRESSURE_GUARD_SWAP_GB} GiB`);
755
- if (serviceResult.status === 'ok') {
756
- console.log(` Postgres: ${serviceResult.databaseUrl}`);
757
- console.log(` Compose file: ${serviceResult.composePath}`);
758
- } else {
759
- console.log(` Postgres: ${serviceResult.message}`);
760
- }
761
- if (pressureGuardAgent?.plistPath) {
762
- console.log(` LaunchAgent: ${pressureGuardAgent.plistPath}`);
763
- console.log(` LaunchAgent load: ${pressureGuardLoad?.loaded ? 'active' : pressureGuardLoad?.reason || 'pending manual load'}`);
764
- }
765
- }
766
-
767
- // Schema init runs whenever postgres is reachable — pgvector tables must exist before any
768
- // retrieval call. Idempotent via construct_schema_migrations hash tracking.
769
-
770
- if (serviceResult.status === 'ok') {
771
- try {
772
- await waitForSqlReady({ ...process.env, ...managedValues });
773
- const readyClient = createSqlClient({ ...process.env, ...managedValues });
774
- if (readyClient) {
775
- try {
776
- const { applied } = await runMigrations(readyClient);
777
- if (applied.length) console.log(`Postgres schema: applied ${applied.length} migration(s) — ${applied.join(', ')}`);
778
- else console.log('Postgres schema: up to date');
779
- } finally {
780
- await closeSqlClient(readyClient).catch(() => {});
781
- }
782
- }
783
- } catch (error) {
784
- console.log(`Postgres schema init failed: ${error?.message || 'unknown error'}`);
785
- }
786
- }
787
-
788
- if (isYes && serviceResult.status === 'ok') {
789
- const sqlClient = createSqlClient({ ...process.env, ...managedValues });
790
- if (sqlClient) {
791
- try {
792
- const restore = restoreConstructDb({ homeDir });
793
- if (restore.status === 'restored') {
794
- console.log(`Construct DB restored from stash: ${restore.stashPath}`);
795
- } else if (restore.status === 'no-stash') {
796
- console.log('No stash found — starting with empty construct DB.');
797
- } else {
798
- console.log(`Construct DB restore: ${restore.status}`);
799
- }
800
-
801
- // Index Construct's own package docs (rootDir, machine-scoped) into the
802
- // shared hybrid store. Seeding a downstream project's corpus is project
803
- // state and belongs to `construct init` / `construct ingest`, never to
804
- // machine install (ADR-0027 §3): install reads no cwd.
805
-
806
- const syncResult = await syncFileStateToSql(rootDir, { env: { ...process.env, ...managedValues }, project: 'construct' });
807
- console.log(`Hybrid storage sync: ${syncResult.status}`);
808
- if (syncResult.embeddingModel) console.log(`Embedding model: ${syncResult.embeddingModel}`);
809
- } catch (error) {
810
- console.log(`Hybrid storage sync failed: ${error?.message || 'unknown error'}`);
811
- } finally {
812
- await closeSqlClient(sqlClient).catch(() => {});
813
- }
814
477
  }
815
478
  }
816
479
 
817
480
  if (isYes) {
818
481
  runConstruct(['mcp', 'add', 'memory', '--auto'], { optional: true });
819
482
  runConstruct(['mcp', 'add', 'github', '--auto'], { optional: true });
820
- } else {
821
- console.log('\nManaged defaults written:');
822
- console.log(` Deployment mode: ${getDeploymentMode(process.env) || DEFAULT_DEPLOYMENT_MODE} (set in construct.config.json — runtime env override available via ${DEPLOYMENT_MODE_ENV_KEY})`);
823
- console.log(` Vector index: ${managedValues.CONSTRUCT_VECTOR_INDEX_PATH}`);
824
- console.log(` Trace backend: ${managedValues.CONSTRUCT_TRACE_BACKEND}${managedValues.CONSTRUCT_TELEMETRY_URL ? ` (${managedValues.CONSTRUCT_TELEMETRY_URL})` : ''}`);
825
- console.log(` Pressure guard: swap ${managedValues.CONSTRUCT_PRESSURE_GUARD_SWAP_GB} GiB, opencode max ${managedValues.CONSTRUCT_PRESSURE_GUARD_MAX_OPENCODE}`);
826
- console.log('\nFor unattended setup, including local Postgres when Docker is running:');
827
- console.log(' construct install --yes');
828
483
  }
829
484
 
830
- // Install is machine setup: the global front-door agent must land on every
831
- // user-scope surface (opencode/claude/codex/copilot) or a fresh machine fails
832
- // cross-surface parity. Both `sync` and `sync --global` are kept to preserve
833
- // every write across the project-detected and forced-global paths; in a
834
- // non-project install cwd both resolve to the same global branch and would
835
- // print the "Synced … to global scope" summary twice. The first call runs
836
- // --quiet so the canonical summary prints exactly once from `sync --global`,
837
- // with no change to what either writes.
838
-
839
485
  runConstruct(['sync', '--quiet']);
840
486
  runConstruct(['sync', '--global']);
841
487
  runConstruct(['doctor']);
842
488
 
843
- // ── Summary panel ────────────────────────────────────────────────────────
844
489
  const setupTs = new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-');
845
490
  const setupLogPath = path.join(HOME, '.cx', `setup-${setupTs}.log`);
846
491
  try {
@@ -850,42 +495,21 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
850
495
  `Config: ${getUserEnvPath(HOME)}`,
851
496
  `OpenCode: ${getCanonicalOpenCodeConfigPath(HOME)}`,
852
497
  ];
853
- if (managedValues?.DATABASE_URL) {
854
- logLines.push(`Database: ${managedValues.DATABASE_URL.replace(/:\/\/[^@]+@/, '://<credentials>@')}`);
855
- }
856
498
  fs.writeFileSync(setupLogPath, logLines.join('\n') + '\n');
857
499
  } catch { /* best effort */ }
858
500
 
859
501
  console.log('\n────────────────────────────────────');
860
502
  console.log('Setup complete.');
861
503
 
862
- // Local-service summary — surfaces running local services. Same pattern
863
- // as `supabase start`, which prints all local URLs + keys after spin-up.
864
-
865
- const hasLocalPostgres = serviceResult?.status === 'ok' && serviceResult?.databaseUrl?.includes('127.0.0.1');
866
- const vectorBackend = hasLocalPostgres
867
- ? { label: 'Postgres + pgvector', detail: `${serviceResult.databaseUrl} (384d embeddings, ${managedValues.CONSTRUCT_VECTOR_MODEL})` }
868
- : { label: 'JSON fallback', detail: `${managedValues.CONSTRUCT_VECTOR_INDEX_PATH} (${managedValues.CONSTRUCT_VECTOR_MODEL})` };
869
-
870
504
  console.log('\nLocal services:');
871
505
  console.log(' Traces: local JSONL (.cx/traces)');
872
- if (process.env.CONSTRUCT_TELEMETRY_URL) console.log(` Telemetry: remote export (${process.env.CONSTRUCT_TELEMETRY_URL})`);
873
- else console.log(' Telemetry: remote export not configured');
874
- if (hasLocalPostgres) {
875
- console.log(` Postgres: ${serviceResult.databaseUrl}`);
876
- } else if (process.env.DATABASE_URL) {
877
- console.log(` Postgres: external (${process.env.DATABASE_URL.replace(/:\/\/[^@]+@/, '://<credentials>@')})`);
878
- } else {
879
- console.log(` Postgres: not running — vector retrieval will use JSON fallback`);
880
- }
881
- console.log(` Vector: ${vectorBackend.label}`);
882
- console.log(` ${vectorBackend.detail}`);
506
+ console.log(` Vector: LanceDB (embedded)`);
507
+ console.log(` ${managedValues.CONSTRUCT_LANCEDB_PATH}`);
883
508
  console.log(' Credentials are saved to ~/.construct/config.env for later reference.');
884
509
 
885
510
  console.log('\nNext steps:');
886
511
  console.log(' construct provider add github # Connect GitHub repository data');
887
512
  console.log(' construct doctor # Verify all systems');
888
- console.log(' construct evals retrieval # Baseline retrieval quality');
889
513
  console.log(`\nSetup log: ${setupLogPath}`);
890
514
  }
891
515
 
@@ -896,17 +520,12 @@ function ensureUserConfig(homeDir = HOME) {
896
520
  return envPath;
897
521
  }
898
522
 
899
- /**
900
- * Create ~/.construct/workspace/ with the standard docs lane structure.
901
- * Fallback target for embed output that isn't repo-specific.
902
- */
903
523
  export function ensureWorkspace(homeDir = HOME) {
904
524
  const wsPath = path.join(homeDir, '.construct', 'workspace');
905
525
  const docsPath = path.join(wsPath, 'docs');
906
526
  for (const lane of WORKSPACE_DOCS_LANES) {
907
527
  fs.mkdirSync(path.join(docsPath, lane), { recursive: true });
908
528
  }
909
- // Ensure top-level workspace files exist
910
529
  const snapshotPath = path.join(wsPath, 'snapshot.md');
911
530
  if (!fs.existsSync(snapshotPath)) fs.writeFileSync(snapshotPath, '# Snapshot\n\nNo snapshot yet.\n');
912
531
  const roadmapPath = path.join(wsPath, 'roadmap.md');