@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
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * lib/service-manager.mjs — start, stop, and describe Construct runtime services.
3
3
  *
4
- * Manages the dashboard process, local Postgres (Docker), memory server (cm),
5
- * and OpenCode. startServices() is the single entry point for `construct up` —
4
+ * Manages the dashboard process, memory server (cm), and OpenCode.
5
+ * startServices() is the single entry point for `construct up` —
6
6
  * spawns whatever is available and returns a result per service.
7
7
  */
8
8
  import { spawn, spawnSync } from 'node:child_process';
@@ -14,37 +14,14 @@ import { fileURLToPath } from 'node:url';
14
14
 
15
15
  import { findAvailablePort } from './host-capabilities.mjs';
16
16
  import { getUserEnvPath, loadConstructEnv, parseEnvFile, writeEnvValues } from './env-config.mjs';
17
- import { detectDockerCompose } from './setup.mjs';
18
- import { stashConstructDb, restoreConstructDb } from './storage/postgres-backup.mjs';
19
17
  import { runPressureRelease } from './runtime-pressure.mjs';
20
- import {
21
- pruneStashDir,
22
- verifyTelemetryKeys,
23
- isRemoteTelemetry,
24
- } from './services/local-postgres.mjs';
25
18
  import { resolveTraceBackend, telemetryProviderLabel } from './telemetry/client.mjs';
26
- import { postgresPort, postgresContainerName, memoryPort as derivedMemoryPort } from './home-namespace.mjs';
27
-
28
- const CONSTRUCT_PG_COMPOSE_DIR = 'services/postgres';
29
- const CONSTRUCT_PG_CONTAINER = postgresContainerName();
30
- const CONSTRUCT_PG_PORT = postgresPort();
31
- const CONSTRUCT_PG_HEALTH_RETRIES = 12;
32
- const CONSTRUCT_PG_HEALTH_INTERVAL_MS = 2000;
19
+ import { memoryPort as derivedMemoryPort } from './home-namespace.mjs';
33
20
 
34
21
  const DASHBOARD_STATE_FILE = 'dashboard.json';
35
22
 
36
- // The caller's `rootDir` is the project (for compose/context/traces), but the
37
- // dashboard and doctor entrypoints ship with the install, not the project. They
38
- // must resolve against the install root — derived from this module's own
39
- // location — or `construct init` spawns `<project>/lib/server/index.mjs` and the
40
- // process dies with MODULE_NOT_FOUND while still advertising the URL.
41
-
42
23
  const INSTALL_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
43
24
 
44
- // pruneStashDir and verifyTelemetryKeys now live in lib/services/telemetry-backend.mjs.
45
- // Re-exported below at module bottom for tests that already import from here.
46
-
47
-
48
25
  function runtimeStateDir(homeDir = os.homedir()) {
49
26
  return path.join(homeDir, '.construct', 'runtime');
50
27
  }
@@ -118,17 +95,8 @@ export function buildRuntimeRecoverySummary({ rootDir, homeDir = os.homedir(), r
118
95
  }
119
96
 
120
97
  export function isManagedConstructPostgresUrl(databaseUrl = '') {
121
- if (!databaseUrl) return false;
122
- try {
123
- const parsed = new URL(databaseUrl);
124
- const host = parsed.hostname;
125
- return (
126
- parsed.port === String(CONSTRUCT_PG_PORT) &&
127
- ['127.0.0.1', 'localhost', '::1'].includes(host)
128
- ) || host === CONSTRUCT_PG_CONTAINER;
129
- } catch {
130
- return databaseUrl.includes(`:${CONSTRUCT_PG_PORT}/`);
131
- }
98
+ // Construct no longer manages local Postgres containers.
99
+ return false;
132
100
  }
133
101
 
134
102
  function processExists(pid) {
@@ -191,10 +159,15 @@ async function isMemoryRunning(port) {
191
159
  return probeRuntimePort(port);
192
160
  }
193
161
 
194
- async function isOpenCodeRunning(port) {
162
+ async function isBridgeRunning(port) {
195
163
  return probeRuntimeHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 1000 });
196
164
  }
197
165
 
166
+ async function isCopilotBridgeRunning(port) {
167
+ // Check if the port is reachable.
168
+ return probeRuntimePort(port);
169
+ }
170
+
198
171
  function spawnDetached(command, args, homeDir, logFile, options = {}) {
199
172
  const logPath = path.join(runtimeStateDir(homeDir), logFile);
200
173
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
@@ -296,11 +269,6 @@ export async function startDashboard({ rootDir, homeDir = os.homedir(), preferre
296
269
  });
297
270
  child.unref();
298
271
 
299
- // Confirm the process actually bound the port before persisting state or
300
- // advertising the URL. Without this the caller reports a dead PID/URL when the
301
- // server crashes on boot — the readiness gate turns a silent failure into an
302
- // honest, actionable one (the log tail names the cause).
303
-
304
272
  let ready = false;
305
273
  for (let i = 0; i < 30; i += 1) {
306
274
  if (await probeRuntimePort(port)) { ready = true; break; }
@@ -324,14 +292,10 @@ export async function startDashboard({ rootDir, homeDir = os.homedir(), preferre
324
292
  return { started: true, reused: false, ...state };
325
293
  }
326
294
 
327
- /**
328
- * Resolve runtime ports. Re-uses ports from config.env when the service is
329
- * already listening there (avoids port drift on repeated `construct up`).
330
- */
331
295
  export async function getRuntimePorts(homeDir = os.homedir(), {
332
296
  dashboardProbeFn = isDashboardRunning,
333
297
  memoryProbeFn = isMemoryRunning,
334
- openCodeProbeFn = isOpenCodeRunning,
298
+ openCodeProbeFn = isBridgeRunning,
335
299
  findAvailablePortFn = findAvailablePort,
336
300
  } = {}) {
337
301
  const dashboard = readDashboardState(homeDir);
@@ -349,6 +313,7 @@ export async function getRuntimePorts(homeDir = os.homedir(), {
349
313
  dashboard: dashboard?.port ?? await resolvePort('DASHBOARD_PORT', 4242, dashboardProbeFn),
350
314
  memory: await resolvePort('MEMORY_PORT', derivedMemoryPort(), memoryProbeFn),
351
315
  bridge: await resolvePort('BRIDGE_PORT', 5173, openCodeProbeFn),
316
+ copilotBridge: await resolvePort('COPILOT_BRIDGE_PORT', 5174, probeRuntimePort),
352
317
  };
353
318
  }
354
319
 
@@ -363,122 +328,18 @@ export async function describeRuntimeSupport() {
363
328
  }
364
329
  return {
365
330
  tmux: commandExists('tmux'),
366
- docker: commandExists('docker'),
367
331
  cm: commandExists('cm'),
368
332
  opencode: commandExists('opencode'),
333
+ gh: commandExists('gh'),
369
334
  };
370
335
  }
371
336
 
372
- // ── Construct Postgres management ──────────────────────────────────────────
373
-
374
- function constructPgComposePath(rootDir) {
375
- return path.join(rootDir, CONSTRUCT_PG_COMPOSE_DIR, 'docker-compose.yml');
376
- }
377
-
378
- function isConstructPostgresRunning(spawnSyncFn = spawnSync) {
379
- const result = spawnSyncFn('docker', ['inspect', '--format', '{{.State.Running}}', CONSTRUCT_PG_CONTAINER], {
380
- encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
381
- });
382
- return result.stdout?.trim() === 'true';
383
- }
384
-
385
- function isConstructPostgresHealthy(spawnSyncFn = spawnSync) {
386
- const result = spawnSyncFn('docker', ['exec', CONSTRUCT_PG_CONTAINER, 'pg_isready', '-U', 'construct'], {
387
- encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
388
- });
389
- return result.status === 0;
390
- }
391
-
392
- async function waitForConstructPostgresHealthy({
393
- spawnSyncFn = spawnSync,
394
- maxRetries = CONSTRUCT_PG_HEALTH_RETRIES,
395
- intervalMs = CONSTRUCT_PG_HEALTH_INTERVAL_MS,
396
- } = {}) {
397
- for (let i = 0; i < maxRetries; i++) {
398
- if (isConstructPostgresHealthy(spawnSyncFn)) return true;
399
- await new Promise((r) => setTimeout(r, intervalMs));
400
- }
401
- return false;
402
- }
403
-
404
- function startConstructPostgres({ rootDir, homeDir = os.homedir(), spawnSyncFn = spawnSync, detectDockerComposeFn = detectDockerCompose } = {}) {
405
- const composeRunner = detectDockerComposeFn({ autoStart: true });
406
- if (!composeRunner) return { status: 'unavailable', note: 'Docker not available' };
407
-
408
- const composeFile = constructPgComposePath(rootDir);
409
- if (!fs.existsSync(composeFile)) return { status: 'unavailable', note: 'Postgres compose file not found — run construct init first' };
410
-
411
- const args = [...composeRunner.argsPrefix, '-p', CONSTRUCT_PG_CONTAINER, '-f', composeFile, 'up', '-d'];
412
- const r = spawnSyncFn(composeRunner.command, args, { stdio: 'pipe', encoding: 'utf8' });
413
- if (r.status === 0) return { status: 'started' };
414
- return { status: 'error', note: (r.stderr || '').trim().split('\n')[0] || 'compose up failed' };
415
- }
416
-
417
- function stopConstructPostgres({ rootDir, homeDir = os.homedir(), spawnSyncFn = spawnSync, detectDockerComposeFn = detectDockerCompose } = {}) {
418
- const composeRunner = detectDockerComposeFn();
419
- if (!composeRunner) return { status: 'skipped', note: 'Docker not available' };
420
-
421
- const composeFile = constructPgComposePath(rootDir);
422
- if (!fs.existsSync(composeFile)) return { status: 'skipped', note: 'no compose file' };
423
-
424
- const args = [...composeRunner.argsPrefix, '-p', CONSTRUCT_PG_CONTAINER, '-f', composeFile, 'down'];
425
- const r = spawnSyncFn(composeRunner.command, args, { stdio: 'pipe', encoding: 'utf8' });
426
- if (r.status === 0) return { status: 'stopped' };
427
- return { status: 'error', note: (r.stderr || '').trim().split('\n')[0] || 'compose down failed' };
428
- }
429
-
430
- function checkPgvectorEnabled(spawnSyncFn = spawnSync) {
431
- const result = spawnSyncFn('docker', [
432
- 'exec', CONSTRUCT_PG_CONTAINER, 'psql', '-U', 'construct', '-d', 'construct',
433
- '-tAc', "SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'vector')",
434
- ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
435
- return result.stdout?.trim() === 't';
436
- }
437
-
438
- /**
439
- * On macOS, when the Docker CLI is installed but the daemon is down,
440
- * launch Docker Desktop and poll `docker info` up to `timeoutMs` for
441
- * the daemon to come up. Opt-out via `CONSTRUCT_DISABLE_DOCKER_AUTOSTART=1`
442
- * so users who keep Docker off aren't forced into it. No-ops on Linux
443
- * (systemd needs sudo) and Windows (different launcher).
444
- */
445
- async function tryStartDockerDaemon({
446
- env = process.env,
447
- spawnSyncFn = spawnSync,
448
- timeoutMs = 60_000,
449
- pollMs = 1500,
450
- } = {}) {
451
- if (env.CONSTRUCT_DISABLE_DOCKER_AUTOSTART === '1') return false;
452
- if (process.platform !== 'darwin') return false;
453
-
454
- // CLI must be present; without it, nothing to start.
455
- if (spawnSyncFn('command -v docker', { stdio: 'ignore', shell: true }).status !== 0) return false;
456
-
457
- // If the daemon is already up, no work needed.
458
- if (spawnSyncFn('docker', ['info'], { stdio: 'ignore' }).status === 0) return true;
459
-
460
- // Best-effort launch. `open -a Docker` returns immediately; the daemon
461
- // takes 5-30s to come fully up so we poll.
462
- spawnSyncFn('open', ['-a', 'Docker'], { stdio: 'ignore' });
463
-
464
- const deadline = Date.now() + timeoutMs;
465
- while (Date.now() < deadline) {
466
- await new Promise((r) => setTimeout(r, pollMs));
467
- if (spawnSyncFn('docker', ['info'], { stdio: 'ignore' }).status === 0) return true;
468
- }
469
- return false;
470
- }
471
-
472
- // The optional services a user can pick from with `construct dev --select` /
473
- // `--only=`. Pressure Guard (cleanup) and Doctor (the L0 supervisor daemon)
474
- // are infrastructure, always on — they are intentionally not selectable.
475
-
476
337
  export const SELECTABLE_SERVICES = Object.freeze([
477
- { key: 'postgres', label: 'Postgres', description: 'Managed pgvector container (or external DATABASE_URL).' },
478
338
  { key: 'dashboard', label: 'Dashboard', description: 'Local operations dashboard on http://127.0.0.1:4242.' },
479
339
  { key: 'telemetry', label: 'Telemetry', description: 'Trace export / local JSONL traces.' },
480
340
  { key: 'memory', label: 'Memory (cm)', description: 'Persistent memory service (cm).' },
481
341
  { key: 'opencode', label: 'OpenCode', description: 'OpenCode bridge server.' },
342
+ { key: 'copilot-bridge', label: 'Copilot Bridge', description: 'Host-native Copilot bridge proxy (requires gh auth).' },
482
343
  ]);
483
344
 
484
345
  export async function startServices({
@@ -488,38 +349,31 @@ export async function startServices({
488
349
  describeRuntimeSupportFn = describeRuntimeSupport,
489
350
  getRuntimePortsFn = getRuntimePorts,
490
351
  startDashboardFn = startDashboard,
491
- detectDockerComposeFn = detectDockerCompose,
492
352
  loadConstructEnvFn = loadConstructEnv,
493
353
  spawnDetachedFn = spawnDetached,
494
- verifyTelemetryKeysFn = verifyTelemetryKeys,
495
354
  memoryProbeFn = isMemoryRunning,
496
- openCodeProbeFn = isOpenCodeRunning,
355
+ openCodeProbeFn = isBridgeRunning,
497
356
  runPressureReleaseFn = runPressureRelease,
498
- tryStartDockerDaemonFn = tryStartDockerDaemon,
499
357
  } = {}) {
500
358
  const support = await describeRuntimeSupportFn();
501
359
  const ports = await getRuntimePortsFn(homeDir);
502
360
  const envPath = getUserEnvPath(homeDir);
503
361
 
504
- // A null selection means "start everything" (the default). A Set restricts
505
- // startup to the chosen optional services; always-on infrastructure ignores it.
506
-
507
362
  const wants = (key) => selected === null || selected.has(key);
508
363
 
509
- // On macOS, auto-launch Docker Desktop when the CLI is present but the
510
- // daemon is down. Polls up to 60s so downstream Postgres startup sees a working
511
- // compose runner instead of "Docker not available".
512
- await tryStartDockerDaemonFn({ env: { ...process.env, HOME: homeDir }, timeoutMs: 5000 });
513
-
514
364
  writeEnvValues(envPath, {
515
365
  DASHBOARD_PORT: String(ports.dashboard),
516
366
  MEMORY_PORT: String(ports.memory),
517
367
  BRIDGE_PORT: String(ports.bridge),
368
+ COPILOT_BRIDGE_PORT: String(ports.copilotBridge),
518
369
  });
519
370
 
520
- // Construct Postgres — start if DATABASE_URL points to managed container
521
371
  const liveEnv = loadConstructEnvFn({ rootDir, homeDir });
522
372
  const results = [];
373
+
374
+ // Set Ollama persistence for local inference performance
375
+ liveEnv.OLLAMA_KEEP_ALIVE = "-1";
376
+
523
377
  const pressureReport = runPressureReleaseFn({ env: { ...liveEnv, HOME: homeDir } });
524
378
  if (pressureReport?.killed?.length) {
525
379
  results.push({
@@ -529,40 +383,6 @@ export async function startServices({
529
383
  note: `terminated ${pressureReport.killed.length} stale process(es)`,
530
384
  });
531
385
  }
532
- const databaseUrl = liveEnv.DATABASE_URL || '';
533
- const usesManagedPostgres = isManagedConstructPostgresUrl(databaseUrl);
534
-
535
- if (wants('postgres') && usesManagedPostgres) {
536
- if (!isConstructPostgresRunning()) {
537
- const pgStart = startConstructPostgres({ rootDir, homeDir, spawnSyncFn: spawnSync, detectDockerComposeFn: detectDockerComposeFn });
538
- if (pgStart.status === 'started') {
539
- const healthy = await waitForConstructPostgresHealthy();
540
- if (healthy) {
541
- const pgvector = checkPgvectorEnabled();
542
- results.push({
543
- name: 'Postgres',
544
- url: `postgresql://127.0.0.1:${CONSTRUCT_PG_PORT}/construct`,
545
- status: pgvector ? 'started' : 'degraded',
546
- note: pgvector ? 'pgvector enabled' : 'pgvector not installed — semantic search unavailable',
547
- });
548
- } else {
549
- results.push({ name: 'Postgres', status: 'degraded', note: 'container started but health check timed out' });
550
- }
551
- } else {
552
- results.push({ name: 'Postgres', status: 'error', note: pgStart.note });
553
- }
554
- } else {
555
- const pgvector = checkPgvectorEnabled();
556
- results.push({
557
- name: 'Postgres',
558
- url: `postgresql://127.0.0.1:${CONSTRUCT_PG_PORT}/construct`,
559
- status: 'reused',
560
- note: pgvector ? 'pgvector enabled' : 'pgvector not installed',
561
- });
562
- }
563
- } else if (wants('postgres') && databaseUrl) {
564
- results.push({ name: 'Postgres', url: databaseUrl, status: 'configured', note: 'external database' });
565
- }
566
386
 
567
387
  // Dashboard
568
388
  if (wants('dashboard')) {
@@ -575,38 +395,38 @@ export async function startServices({
575
395
  const telemetryUrl = liveEnv.CONSTRUCT_TELEMETRY_URL ?? '';
576
396
  const traceBackend = resolveTraceBackend(liveEnv);
577
397
  if (wants('telemetry')) {
578
- if (traceBackend === 'local') {
579
- results.push({
580
- name: 'Telemetry',
581
- url: path.join(rootDir, '.cx', 'traces'),
582
- status: 'configured',
583
- note: 'local JSONL traces; remote export not configured',
584
- });
585
- } else if (traceBackend === 'none') {
586
- results.push({
587
- name: 'Telemetry',
588
- url: path.join(rootDir, '.cx', 'traces'),
589
- status: 'configured',
590
- note: 'remote export disabled; local JSONL traces preserved',
591
- });
592
- } else if (traceBackend === 'otel') {
593
- const endpoint = liveEnv.CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT || '';
594
- results.push({
595
- name: 'Telemetry',
596
- url: endpoint || undefined,
597
- status: endpoint ? 'configured' : 'unavailable',
598
- note: endpoint ? `OTLP export (${telemetryProviderLabel(liveEnv)})` : 'CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT not set',
599
- });
600
- } else if (telemetryUrl && (isRemoteTelemetry(telemetryUrl) || traceBackend === 'langfuse' || traceBackend === 'http')) {
601
- results.push({
602
- name: 'Telemetry',
603
- url: telemetryUrl,
604
- status: 'configured',
605
- note: `${telemetryProviderLabel(liveEnv)} export — ${telemetryUrl}`,
606
- });
607
- } else {
608
- results.push({ name: 'Telemetry', status: 'unavailable', note: 'remote export requested but endpoint is not configured' });
609
- }
398
+ if (traceBackend === 'local') {
399
+ results.push({
400
+ name: 'Telemetry',
401
+ url: path.join(rootDir, '.cx', 'traces'),
402
+ status: 'configured',
403
+ note: 'local JSONL traces; remote export not configured',
404
+ });
405
+ } else if (traceBackend === 'none') {
406
+ results.push({
407
+ name: 'Telemetry',
408
+ url: path.join(rootDir, '.cx', 'traces'),
409
+ status: 'configured',
410
+ note: 'remote export disabled; local JSONL traces preserved',
411
+ });
412
+ } else if (traceBackend === 'otel') {
413
+ const endpoint = liveEnv.CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT || '';
414
+ results.push({
415
+ name: 'Telemetry',
416
+ url: endpoint || undefined,
417
+ status: endpoint ? 'configured' : 'unavailable',
418
+ note: endpoint ? `OTLP export (${telemetryProviderLabel(liveEnv)})` : 'CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT not set',
419
+ });
420
+ } else if (telemetryUrl && (traceBackend === 'langfuse' || traceBackend === 'http')) {
421
+ results.push({
422
+ name: 'Telemetry',
423
+ url: telemetryUrl,
424
+ status: 'configured',
425
+ note: `${telemetryProviderLabel(liveEnv)} export — ${telemetryUrl}`,
426
+ });
427
+ } else {
428
+ results.push({ name: 'Telemetry', status: 'unavailable', note: 'remote export requested but endpoint is not configured' });
429
+ }
610
430
  }
611
431
 
612
432
  // Memory (cm)
@@ -631,6 +451,27 @@ export async function startServices({
631
451
  }
632
452
  }
633
453
 
454
+ // Copilot Bridge
455
+ if (wants('copilot-bridge') && support.gh) {
456
+ const { detectActiveSessions } = await import('./host-capabilities.mjs');
457
+ const sessions = detectActiveSessions();
458
+ if (sessions.includes('github-copilot')) {
459
+ if (await probeRuntimePort(ports.copilotBridge)) {
460
+ results.push({ name: 'Copilot Bridge', url: `http://127.0.0.1:${ports.copilotBridge}`, status: 'reused' });
461
+ } else {
462
+ const proxyPath = path.join(INSTALL_ROOT, 'lib', 'bridges', 'copilot-proxy.mjs');
463
+ if (fs.existsSync(proxyPath)) {
464
+ spawnDetachedFn('node', [proxyPath, '--port', String(ports.copilotBridge)], homeDir, 'copilot-bridge.log');
465
+ results.push({ name: 'Copilot Bridge', url: `http://127.0.0.1:${ports.copilotBridge}`, status: 'started' });
466
+ } else {
467
+ results.push({ name: 'Copilot Bridge', status: 'unavailable', note: 'copilot-proxy binary missing in lib/bridges/' });
468
+ }
469
+ }
470
+ } else {
471
+ results.push({ name: 'Copilot Bridge', status: 'unavailable', note: 'gh auth status shows no active GitHub session' });
472
+ }
473
+ }
474
+
634
475
  const doctor = startDoctor({ rootDir, homeDir });
635
476
  if (doctor.started) {
636
477
  results.push({ name: 'Doctor', status: 'started', note: `L0 daemon · logs: ${doctor.logPath}` });
@@ -650,9 +491,6 @@ export async function startServices({
650
491
  };
651
492
  }
652
493
 
653
- // Surface service probe failures into the role-framework event bus so cx-sre
654
- // can be invoked when a critical service is down. Non-blocking, best effort.
655
-
656
494
  async function emitServiceFailures(results) {
657
495
  if (process.env.CONSTRUCT_ROLES === 'off') return;
658
496
  const failed = results.filter((r) => r.status === 'error');
@@ -670,17 +508,9 @@ async function emitServiceFailures(results) {
670
508
  } catch { /* best effort */ }
671
509
  }
672
510
 
673
- // Exported for testing only
674
- export { verifyTelemetryKeys as _verifyTelemetryKeys, pruneStashDir as _pruneStashDir };
675
-
676
- /**
677
- * Kill every process listening on a given TCP port (best-effort, non-fatal).
678
- * Returns true if at least one PID was killed.
679
- */
680
511
  function killPortOwners(port, spawnSyncFn = spawnSync) {
681
512
  if (!port || !Number.isInteger(port) || port <= 0) return false;
682
513
  try {
683
- // lsof works on macOS and Linux; -t returns just PIDs
684
514
  const result = spawnSyncFn('lsof', ['-t', `-i:${port}`], { encoding: 'utf8' });
685
515
  const pids = (result.stdout || '').trim().split(/\s+/).filter(Boolean).map(Number).filter((n) => n > 0);
686
516
  if (pids.length === 0) return false;
@@ -695,21 +525,10 @@ function killPortOwners(port, spawnSyncFn = spawnSync) {
695
525
 
696
526
  export async function stopServices({
697
527
  homeDir = os.homedir(),
698
- rootDir,
699
528
  spawnSyncFn = spawnSync,
700
- detectDockerComposeFn = detectDockerCompose,
701
529
  } = {}) {
702
530
  const results = [];
703
531
 
704
- // Stash construct Postgres data before any container is stopped so data
705
- // survives Docker restarts and machine reboots.
706
- const constructDbStash = stashConstructDb({ homeDir, spawnSyncFn });
707
-
708
- // Construct Postgres
709
- const pgStop = stopConstructPostgres({ rootDir, homeDir, spawnSyncFn, detectDockerComposeFn });
710
- results.push({ name: 'Postgres', status: pgStop.status });
711
-
712
- // Doctor (L0 daemon)
713
532
  const doctor = stopDoctor(homeDir);
714
533
  if (doctor.stopped) {
715
534
  results.push({ name: 'Doctor', status: 'stopped', note: `pid ${doctor.pid}` });
@@ -717,7 +536,6 @@ export async function stopServices({
717
536
  results.push({ name: 'Doctor', status: 'cleaned', note: 'stale state file removed' });
718
537
  }
719
538
 
720
- // Dashboard
721
539
  const dashboard = stopDashboard(homeDir);
722
540
  if (dashboard.stopped) {
723
541
  results.push({ name: 'Dashboard', status: 'stopped', note: `pid ${dashboard.pid}, port ${dashboard.port}` });
@@ -727,18 +545,20 @@ export async function stopServices({
727
545
  results.push({ name: 'Dashboard', status: 'not-running' });
728
546
  }
729
547
 
730
- // Memory (cm) — find port from config.env or fall back to default
731
548
  const envPath = getUserEnvPath(homeDir);
732
549
  const envValues = parseEnvFile(envPath);
733
550
  const memoryPort = Number(envValues.MEMORY_PORT) || derivedMemoryPort();
734
551
  const cmKilled = killPortOwners(memoryPort, spawnSyncFn);
735
552
  results.push({ name: 'Memory (cm)', status: cmKilled ? 'stopped' : 'not-running' });
736
553
 
737
- // OpenCode — find port from config.env or fall back to default
738
554
  const bridgePort = Number(envValues.BRIDGE_PORT) || 5173;
739
555
  const openCodeKilled = killPortOwners(bridgePort, spawnSyncFn);
740
556
  results.push({ name: 'OpenCode', status: openCodeKilled ? 'stopped' : 'not-running' });
741
557
 
558
+ const copilotBridgePort = Number(envValues.COPILOT_BRIDGE_PORT) || 5174;
559
+ const copilotBridgeKilled = killPortOwners(copilotBridgePort, spawnSyncFn);
560
+ results.push({ name: 'Copilot Bridge', status: copilotBridgeKilled ? 'stopped' : 'not-running' });
561
+
742
562
  const stopped = results.filter((r) => r.status === 'stopped' || r.status === 'cleaned').map((r) => r.name);
743
- return { stopped, results, constructDbStash };
563
+ return { stopped, results };
744
564
  }