@geraldmaron/construct 1.0.2 → 1.0.4

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 (97) hide show
  1. package/.env.example +1 -1
  2. package/README.md +4 -4
  3. package/agents/prompts/cx-ai-engineer.md +6 -26
  4. package/agents/prompts/cx-architect.md +1 -0
  5. package/agents/prompts/cx-business-strategist.md +2 -0
  6. package/agents/prompts/cx-data-analyst.md +6 -26
  7. package/agents/prompts/cx-docs-keeper.md +1 -31
  8. package/agents/prompts/cx-explorer.md +1 -0
  9. package/agents/prompts/cx-orchestrator.md +40 -112
  10. package/agents/prompts/cx-platform-engineer.md +2 -22
  11. package/agents/prompts/cx-product-manager.md +2 -1
  12. package/agents/prompts/cx-qa.md +0 -20
  13. package/agents/prompts/cx-rd-lead.md +2 -0
  14. package/agents/prompts/cx-researcher.md +77 -31
  15. package/agents/prompts/cx-security.md +11 -49
  16. package/agents/prompts/cx-sre.md +9 -43
  17. package/agents/prompts/cx-ux-researcher.md +1 -0
  18. package/agents/role-manifests.json +4 -4
  19. package/bin/construct +72 -11
  20. package/bin/construct-postinstall.mjs +1 -1
  21. package/db/schema/004_recommendations.sql +46 -0
  22. package/db/schema/005_strategy.sql +21 -0
  23. package/lib/auto-docs.mjs +1 -2
  24. package/lib/beads-automation.mjs +16 -7
  25. package/lib/bootstrap/resources.mjs +2 -2
  26. package/lib/cli-commands.mjs +8 -2
  27. package/lib/document-ingest.mjs +6 -5
  28. package/lib/embed/cli.mjs +16 -3
  29. package/lib/embed/conflict-detection.mjs +26 -9
  30. package/lib/embed/customer-profiles.mjs +38 -18
  31. package/lib/embed/daemon.mjs +59 -50
  32. package/lib/embed/inbox.mjs +30 -0
  33. package/lib/embed/recommendation-store.mjs +214 -15
  34. package/lib/embed/workspaces.mjs +53 -18
  35. package/lib/evaluator-optimizer.mjs +0 -2
  36. package/lib/features.mjs +11 -0
  37. package/lib/gates-audit.mjs +3 -3
  38. package/lib/health-check.mjs +3 -5
  39. package/lib/hooks/pre-compact.mjs +3 -0
  40. package/lib/hooks/read-tracker.mjs +10 -101
  41. package/lib/host-capabilities.mjs +90 -1
  42. package/lib/init-unified.mjs +119 -3
  43. package/lib/init-update.mjs +246 -131
  44. package/lib/install/first-invocation.mjs +4 -4
  45. package/lib/intake/queue.mjs +1 -1
  46. package/lib/integrations/intake-integrations.mjs +4 -5
  47. package/lib/intent-classifier.mjs +1 -0
  48. package/lib/knowledge/layout.mjs +10 -0
  49. package/lib/knowledge/rag.mjs +16 -0
  50. package/lib/mcp/tools/telemetry.mjs +30 -78
  51. package/lib/model-cheapest-provider.mjs +231 -0
  52. package/lib/model-router.mjs +68 -9
  53. package/lib/ollama-manager.mjs +1 -1
  54. package/lib/opencode-telemetry.mjs +4 -5
  55. package/lib/orchestration-policy.mjs +9 -0
  56. package/lib/parity.mjs +124 -21
  57. package/lib/project-profile.mjs +1 -1
  58. package/lib/prompt-composer.js +106 -29
  59. package/lib/read-tracker-store.mjs +149 -0
  60. package/lib/roles/catalog.mjs +133 -0
  61. package/lib/roles/preference.mjs +74 -0
  62. package/lib/server/index.mjs +109 -47
  63. package/lib/server/insights.mjs +1 -1
  64. package/lib/server/telemetry-login.mjs +17 -25
  65. package/lib/service-manager.mjs +32 -24
  66. package/lib/services/local-postgres.mjs +15 -0
  67. package/lib/services/telemetry-backend.mjs +2 -3
  68. package/lib/setup-prompts.mjs +2 -2
  69. package/lib/setup.mjs +55 -46
  70. package/lib/status.mjs +56 -8
  71. package/lib/storage/backend.mjs +12 -2
  72. package/lib/storage/postgres-backup.mjs +1 -1
  73. package/lib/strategy-store.mjs +371 -0
  74. package/lib/telemetry/backends/local.mjs +6 -4
  75. package/lib/telemetry/client.mjs +185 -0
  76. package/lib/telemetry/ingest.mjs +13 -5
  77. package/lib/telemetry/team-rollup.mjs +9 -2
  78. package/lib/uninstall/uninstall.mjs +1 -1
  79. package/lib/worker/trace.mjs +17 -27
  80. package/package.json +5 -2
  81. package/rules/common/research.md +44 -12
  82. package/skills/docs/backlog-proposal-workflow.md +2 -2
  83. package/skills/docs/customer-profile-workflow.md +1 -1
  84. package/skills/docs/evidence-ingest-workflow.md +5 -5
  85. package/skills/docs/prfaq-workflow.md +1 -1
  86. package/skills/docs/product-intelligence-review.md +1 -1
  87. package/skills/docs/product-intelligence-workflow.md +3 -3
  88. package/skills/docs/product-signal-workflow.md +48 -18
  89. package/skills/docs/research-workflow.md +26 -14
  90. package/skills/docs/strategy-workflow.md +36 -0
  91. package/skills/roles/data-analyst.product-intelligence.md +1 -1
  92. package/skills/roles/researcher.md +28 -15
  93. package/skills/routing.md +8 -1
  94. package/templates/docs/construct_guide.md +2 -2
  95. package/templates/docs/research-brief.md +63 -9
  96. package/templates/docs/strategy.md +36 -0
  97. package/templates/homebrew/construct.rb +7 -7
@@ -0,0 +1,133 @@
1
+ /**
2
+ * lib/roles/catalog.mjs — role listing and display helpers.
3
+ *
4
+ * Reads agents/registry.json to produce a list of available roles with
5
+ * optional department grouping and consolidated-role views. Consumed by the
6
+ * `roles:list` CLI command.
7
+ */
8
+
9
+ import { readFileSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const REGISTRY_PATH = join(__dirname, '..', '..', 'agents', 'registry.json');
15
+
16
+ let cached = null;
17
+
18
+ function loadRegistry() {
19
+ if (cached) return cached;
20
+ cached = JSON.parse(readFileSync(REGISTRY_PATH, 'utf8'));
21
+ return cached;
22
+ }
23
+
24
+ /**
25
+ * Return a list of role descriptors.
26
+ *
27
+ * @param {object} opts
28
+ * @param {boolean} opts.departments Group by department when true.
29
+ * @param {boolean} opts.consolidated Return consolidated 12-role view when true.
30
+ * @returns {Array<object>}
31
+ */
32
+ export function listRoles({ departments = false, consolidated = false } = {}) {
33
+ const registry = loadRegistry();
34
+
35
+ if (consolidated) {
36
+ const c = registry.consolidatedRoles || {};
37
+ return (c.roles || []).map((r) => ({
38
+ id: r.id,
39
+ absorbs: r.absorbs || [],
40
+ whenToUse: r.whenToUse || '',
41
+ }));
42
+ }
43
+
44
+ const agents = Array.isArray(registry.agents)
45
+ ? registry.agents
46
+ : Object.values(registry.agents || {});
47
+
48
+ if (!departments) {
49
+ return agents.map((a) => ({
50
+ id: a.name,
51
+ name: `cx-${a.name}`,
52
+ description: a.description || '',
53
+ internal: !!a.internal,
54
+ }));
55
+ }
56
+
57
+ // Group by department based on subDepartment membership in registry.departments
58
+ const depts = registry.departments || {};
59
+ const grouped = {};
60
+ for (const [deptId, dept] of Object.entries(depts)) {
61
+ grouped[deptId] = {
62
+ name: dept.name || deptId,
63
+ roles: [],
64
+ };
65
+ }
66
+ grouped._ungrouped = { name: 'Other', roles: [] };
67
+
68
+ for (const agent of agents) {
69
+ let placed = false;
70
+ for (const [deptId, dept] of Object.entries(depts)) {
71
+ const subs = dept.subDepartments || {};
72
+ for (const sub of Object.values(subs)) {
73
+ if ((sub.roles || []).includes(agent.name) || (sub.roles || []).includes(`cx-${agent.name}`)) {
74
+ grouped[deptId].roles.push({ id: agent.name, name: `cx-${agent.name}`, description: agent.description || '' });
75
+ placed = true;
76
+ break;
77
+ }
78
+ }
79
+ if (placed) break;
80
+ }
81
+ if (!placed) {
82
+ grouped._ungrouped.roles.push({ id: agent.name, name: `cx-${agent.name}`, description: agent.description || '' });
83
+ }
84
+ }
85
+
86
+ return Object.entries(grouped)
87
+ .filter(([, g]) => g.roles.length > 0)
88
+ .map(([id, g]) => ({ departmentId: id, departmentName: g.name, roles: g.roles }));
89
+ }
90
+
91
+ /**
92
+ * Format the role list as a human-readable string.
93
+ *
94
+ * @param {object} opts Same options as listRoles.
95
+ * @returns {string}
96
+ */
97
+ export function formatRoleList({ departments = false, consolidated = false } = {}) {
98
+ const roles = listRoles({ departments, consolidated });
99
+ const lines = [];
100
+
101
+ if (consolidated) {
102
+ lines.push('Consolidated Roles (12-role simplified structure)');
103
+ lines.push('=================================================');
104
+ for (const r of roles) {
105
+ lines.push(`\n${r.id}`);
106
+ lines.push(` Absorbs: ${r.absorbs.join(', ')}`);
107
+ lines.push(` Use when: ${r.whenToUse}`);
108
+ }
109
+ lines.push('');
110
+ return lines.join('\n');
111
+ }
112
+
113
+ if (departments) {
114
+ lines.push('Available Roles by Department');
115
+ lines.push('=============================');
116
+ for (const group of roles) {
117
+ lines.push(`\n${group.departmentName}`);
118
+ for (const role of group.roles) {
119
+ lines.push(` ${role.name.padEnd(30)} ${role.description}`);
120
+ }
121
+ }
122
+ lines.push('');
123
+ return lines.join('\n');
124
+ }
125
+
126
+ lines.push('Available Roles');
127
+ lines.push('===============');
128
+ for (const role of roles) {
129
+ lines.push(` ${role.name.padEnd(30)} ${role.description}`);
130
+ }
131
+ lines.push('');
132
+ return lines.join('\n');
133
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * lib/roles/preference.mjs — per-user primary/secondary role preferences.
3
+ *
4
+ * Persists role preferences to ~/.construct/config.env under the keys
5
+ * CONSTRUCT_ROLE_PRIMARY and CONSTRUCT_ROLE_SECONDARY. Consumed by the
6
+ * `roles:set` CLI command and the orchestration layer.
7
+ */
8
+
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ import { getUserEnvPath, ensureUserConfigDir, writeEnvValues, parseEnvFile } from '../env-config.mjs';
13
+
14
+ const ENV_KEYS = {
15
+ primary: 'CONSTRUCT_ROLE_PRIMARY',
16
+ secondary: 'CONSTRUCT_ROLE_SECONDARY',
17
+ };
18
+
19
+ /**
20
+ * Read the stored role preference for the given slot.
21
+ *
22
+ * @param {'primary'|'secondary'} slot
23
+ * @returns {string|null} The stored role id, or null if unset.
24
+ */
25
+ export function getRolePreference(slot) {
26
+ const envKey = ENV_KEYS[slot];
27
+ if (!envKey) return null;
28
+
29
+ // Env var takes priority (allows per-session override)
30
+ if (process.env[envKey]) return process.env[envKey];
31
+
32
+ const envPath = getUserEnvPath(os.homedir());
33
+ const stored = parseEnvFile(envPath);
34
+ return stored[envKey] || null;
35
+ }
36
+
37
+ /**
38
+ * Persist a role preference for the given slot.
39
+ *
40
+ * @param {'primary'|'secondary'} slot
41
+ * @param {string} role Role id (e.g. "cx-engineer" or "engineer").
42
+ * @returns {{ slot: string, role: string }}
43
+ */
44
+ export function setRolePreference(slot, role) {
45
+ const envKey = ENV_KEYS[slot];
46
+ if (!envKey) throw new Error(`Unknown role slot "${slot}". Use "primary" or "secondary".`);
47
+
48
+ // Normalise: accept both "engineer" and "cx-engineer"
49
+ const normalised = role.startsWith('cx-') ? role : `cx-${role}`;
50
+
51
+ ensureUserConfigDir(os.homedir());
52
+ const envPath = getUserEnvPath(os.homedir());
53
+ writeEnvValues(envPath, { [envKey]: normalised });
54
+
55
+ // Also set in current process so the change is visible immediately
56
+ process.env[envKey] = normalised;
57
+
58
+ return { slot, role: normalised };
59
+ }
60
+
61
+ /**
62
+ * Clear a stored role preference.
63
+ *
64
+ * @param {'primary'|'secondary'} slot
65
+ */
66
+ export function clearRolePreference(slot) {
67
+ const envKey = ENV_KEYS[slot];
68
+ if (!envKey) return;
69
+
70
+ delete process.env[envKey];
71
+
72
+ const envPath = getUserEnvPath(os.homedir());
73
+ writeEnvValues(envPath, { [envKey]: '' });
74
+ }
@@ -250,6 +250,25 @@ const MIME = {
250
250
  '.js': 'text/javascript; charset=utf-8',
251
251
  '.css': 'text/css; charset=utf-8',
252
252
  '.json': 'application/json',
253
+ '.svg': 'image/svg+xml',
254
+ '.ico': 'image/x-icon',
255
+ '.png': 'image/png',
256
+ '.jpg': 'image/jpeg',
257
+ '.jpeg': 'image/jpeg',
258
+ '.gif': 'image/gif',
259
+ '.webp': 'image/webp',
260
+ '.woff': 'font/woff',
261
+ '.woff2': 'font/woff2',
262
+ '.ttf': 'font/ttf',
263
+ '.xml': 'application/xml',
264
+ '.txt': 'text/plain; charset=utf-8',
265
+ '.manifest': 'text/cache-manifest',
266
+ '.map': 'application/json',
267
+ '.eot': 'application/vnd.ms-fontobject',
268
+ '.otf': 'font/otf',
269
+ '.mp4': 'video/mp4',
270
+ '.mp3': 'audio/mpeg',
271
+ '.pdf': 'application/pdf',
253
272
  };
254
273
 
255
274
  function listCommands() {
@@ -1062,56 +1081,10 @@ const server = createServer(async (req, res) => {
1062
1081
  try {
1063
1082
  const env = loadConstructEnv();
1064
1083
  const instanceId = env.CONSTRUCT_INSTANCE_ID || 'default';
1065
-
1066
- // Check if we're running inside another Construct instance
1067
- const parentConstruct = env.CONSTRUCT_PARENT_INSTANCE || null;
1068
- const parentUrl = env.CONSTRUCT_PARENT_URL || null;
1069
-
1070
- // Determine embedding boundary status
1071
- const isEmbedded = !!parentConstruct;
1072
- const boundaryStatus = isEmbedded ? 'embedded' : 'standalone';
1073
-
1074
- // Get current embed status
1075
- const embedStatus = resolveEmbedStatus(env);
1076
-
1077
- res.writeHead(200, { 'Content-Type': 'application/json' });
1078
- res.end(JSON.stringify({
1079
- boundaryStatus,
1080
- instanceId,
1081
- parentConstruct,
1082
- parentUrl,
1083
- isEmbedded,
1084
- embedStatus: embedStatus.level,
1085
- embedConfigExists: existsSync(join(HOME, '.construct', 'embed.yaml')),
1086
- // Boundary capabilities that could be exposed to parent
1087
- capabilities: {
1088
- modeDetection: true,
1089
- snapshotStatus: true,
1090
- approvalQueue: true,
1091
- configManagement: true
1092
- }
1093
- }));
1094
- } catch (err) {
1095
- res.writeHead(500, { 'Content-Type': 'application/json' });
1096
- res.end(JSON.stringify({ error: err.message }));
1097
- }
1098
- return;
1099
- }
1100
-
1101
- if (url.pathname === '/api/embed/boundary' && req.method === 'GET') {
1102
- try {
1103
- const env = loadConstructEnv();
1104
- const instanceId = env.CONSTRUCT_INSTANCE_ID || 'default';
1105
-
1106
- // Check if we're running inside another Construct instance
1107
1084
  const parentConstruct = env.CONSTRUCT_PARENT_INSTANCE || null;
1108
1085
  const parentUrl = env.CONSTRUCT_PARENT_URL || null;
1109
-
1110
- // Determine embedding boundary status
1111
1086
  const isEmbedded = !!parentConstruct;
1112
1087
  const boundaryStatus = isEmbedded ? 'embedded' : 'standalone';
1113
-
1114
- // Get current embed status
1115
1088
  const embedStatus = resolveEmbedStatus(env);
1116
1089
 
1117
1090
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1123,7 +1096,6 @@ const server = createServer(async (req, res) => {
1123
1096
  isEmbedded,
1124
1097
  embedStatus: embedStatus.level,
1125
1098
  embedConfigExists: existsSync(join(HOME, '.construct', 'embed.yaml')),
1126
- // Boundary capabilities that could be exposed to parent
1127
1099
  capabilities: {
1128
1100
  modeDetection: true,
1129
1101
  snapshotStatus: true,
@@ -1358,6 +1330,20 @@ const server = createServer(async (req, res) => {
1358
1330
  return;
1359
1331
  }
1360
1332
 
1333
+ if (url.pathname === '/api/intake/list' && req.method === 'GET') {
1334
+ try {
1335
+ const { createIntakeQueue } = await import('../intake/queue.mjs');
1336
+ const queue = createIntakeQueue(ROOT_DIR, process.env);
1337
+ const items = queue.listPending();
1338
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1339
+ res.end(JSON.stringify({ items, total: items.length }));
1340
+ } catch (err) {
1341
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1342
+ res.end(JSON.stringify({ error: err.message }));
1343
+ }
1344
+ return;
1345
+ }
1346
+
1361
1347
  if (url.pathname === '/api/providers' && req.method === 'GET') {
1362
1348
  try {
1363
1349
  const probe = url.searchParams.get('probe') === '1';
@@ -1514,6 +1500,82 @@ const server = createServer(async (req, res) => {
1514
1500
  return;
1515
1501
  }
1516
1502
 
1503
+ // ── Recommendations API ────────────────────────────────────────────────────
1504
+ if (url.pathname === '/api/recommendations') {
1505
+ if (req.method === 'GET') {
1506
+ const { listActiveRecommendations, recommendationStats } = await import('../embed/recommendation-store.mjs');
1507
+ const priority = url.searchParams.get('priority') || undefined;
1508
+ const type = url.searchParams.get('type') || undefined;
1509
+ const limit = Number(url.searchParams.get('limit') || 50);
1510
+ const items = listActiveRecommendations({ priority, type, limit });
1511
+ const stats = recommendationStats();
1512
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1513
+ res.end(JSON.stringify({ items, stats }));
1514
+ return;
1515
+ }
1516
+ if (req.method === 'PATCH' || req.method === 'POST') {
1517
+ const chunks = [];
1518
+ await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
1519
+ let body;
1520
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
1521
+ const action = body.action || url.searchParams.get('action');
1522
+ const dedupKey = body.dedupKey || url.searchParams.get('dedupKey');
1523
+ if (!action || !dedupKey) {
1524
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1525
+ res.end(JSON.stringify({ error: 'action and dedupKey are required' }));
1526
+ return;
1527
+ }
1528
+ const { dismissRecommendation, reviveRecommendation } = await import('../embed/recommendation-store.mjs');
1529
+ try {
1530
+ if (action === 'dismiss') {
1531
+ const result = dismissRecommendation(dedupKey, { reason: body.reason, suppressDays: body.suppressDays });
1532
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1533
+ res.end(JSON.stringify(result));
1534
+ } else if (action === 'revive') {
1535
+ const result = reviveRecommendation(dedupKey, { signalCount: body.signalCount, sourceSignalIds: body.sourceSignalIds });
1536
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1537
+ res.end(JSON.stringify(result));
1538
+ } else {
1539
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1540
+ res.end(JSON.stringify({ error: `unknown action: ${action}` }));
1541
+ }
1542
+ } catch (err) {
1543
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1544
+ res.end(JSON.stringify({ error: err?.message || 'not found' }));
1545
+ }
1546
+ return;
1547
+ }
1548
+ }
1549
+
1550
+ // ── Strategy API ───────────────────────────────────────────────────────────
1551
+ if (url.pathname === '/api/strategy') {
1552
+ if (req.method === 'GET') {
1553
+ const { readAllStrategies } = await import('../strategy-store.mjs');
1554
+ const result = await readAllStrategies(process.env);
1555
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1556
+ res.end(JSON.stringify({ scopes: Object.fromEntries(result) }));
1557
+ return;
1558
+ }
1559
+ if (req.method === 'PUT' || req.method === 'POST') {
1560
+ const chunks = [];
1561
+ await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
1562
+ let body;
1563
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
1564
+ const content = (body.content || '').trim();
1565
+ const scope = (body.scope || 'product').trim();
1566
+ if (!content) {
1567
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1568
+ res.end(JSON.stringify({ error: 'content is required' }));
1569
+ return;
1570
+ }
1571
+ const { writeStrategy } = await import('../strategy-store.mjs');
1572
+ await writeStrategy(content, scope, { updatedBy: 'dashboard', env: process.env });
1573
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1574
+ res.end(JSON.stringify({ ok: true }));
1575
+ return;
1576
+ }
1577
+ }
1578
+
1517
1579
  if (url.pathname === '/api/snapshots') {
1518
1580
  handleSnapshots(req, res);
1519
1581
  return;
@@ -42,7 +42,7 @@ async function fetchTelemetrySummary(env, { limit = 100 } = {}) {
42
42
  });
43
43
  clearTimeout(timer);
44
44
  if (res.status === 401 || res.status === 403) {
45
- return { state: 'misconfigured', message: `Telemetry credentials rejected (HTTP ${res.status}). Run construct setup.` };
45
+ return { state: 'misconfigured', message: `Telemetry credentials rejected (HTTP ${res.status}). Run construct init.` };
46
46
  }
47
47
  if (!res.ok) return { state: 'unreachable', message: `Telemetry HTTP ${res.status}` };
48
48
  const json = await res.json();
@@ -1,21 +1,15 @@
1
1
  /**
2
- * lib/server/telemetry-login.mjs — magic-link bridge for a local telemetry backend.
2
+ * lib/server/telemetry-login.mjs — magic-link bridge for a configured telemetry UI.
3
3
  *
4
- * When a self-hosted telemetry backend (e.g. a self-hosted or cloud telemetry backend)
5
- * is running locally, this bridge turns the "Open Telemetry" link in the
6
- * dashboard into a one-click sign-in. The server fetches a CSRF token from
7
- * the backend, then returns an HTML auto-submit form that POSTs the seeded
8
- * credentials to NextAuth's callback.
9
- *
10
- * The seeded creds are NOT secret — they are deterministic local defaults
11
- * for zero-touch local development. Not used when CONSTRUCT_TELEMETRY_URL
12
- * points at a remote backend.
4
+ * CONSTRUCT_TELEMETRY_URL may point at a Langfuse-compatible deployment.
5
+ * The dashboard link can use this bridge for one-click sign-in by fetching a
6
+ * CSRF token and returning an HTML auto-submit form for NextAuth's callback.
13
7
  */
14
8
 
15
9
  const HTML_HEADERS = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' };
16
10
 
17
11
  /**
18
- * Resolve the telemetry backend URL from env with backward compat.
12
+ * Resolve the configured telemetry UI URL from env.
19
13
  */
20
14
  function resolveTelemetryUrl(env = process.env) {
21
15
  return (
@@ -45,10 +39,10 @@ function escapeHtml(value) {
45
39
  */
46
40
  export function renderAutoSubmitForm({ csrfToken, callbackUrl, redirectAfter, email, password }) {
47
41
  return `<!doctype html>
48
- <html><head><meta charset="utf-8"><title>Signing in to telemetry backend…</title>
42
+ <html><head><meta charset="utf-8"><title>Signing in to telemetry UI…</title>
49
43
  <style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;color:#666;background:#fff}</style>
50
44
  </head><body>
51
- <p>Signing you in to telemetry backend…</p>
45
+ <p>Signing you in to telemetry UI…</p>
52
46
  <form id="tf" method="POST" action="${escapeHtml(callbackUrl)}">
53
47
  <input type="hidden" name="csrfToken" value="${escapeHtml(csrfToken)}">
54
48
  <input type="hidden" name="email" value="${escapeHtml(email)}">
@@ -63,8 +57,8 @@ export function renderAutoSubmitForm({ csrfToken, callbackUrl, redirectAfter, em
63
57
  /**
64
58
  * HTTP handler: GET /api/services/telemetry/login
65
59
  *
66
- * Returns an HTML page that immediately POSTs credentials to the backend's
67
- * NextAuth callback. Returns 502 when the backend is unreachable.
60
+ * Returns an HTML page that immediately POSTs credentials to the configured
61
+ * NextAuth callback. Returns 502 when the endpoint is unreachable.
68
62
  */
69
63
  export async function handleTelemetryLogin(req, res, {
70
64
  baseUrl,
@@ -76,8 +70,8 @@ export async function handleTelemetryLogin(req, res, {
76
70
 
77
71
  if (!resolvedBaseUrl) {
78
72
  res.writeHead(503, HTML_HEADERS);
79
- res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry backend not configured</title>` +
80
- `<pre>CONSTRUCT_TELEMETRY_URL is not set. Configure a telemetry backend to use this feature.</pre>`);
73
+ res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry UI not configured</title>` +
74
+ `<pre>CONSTRUCT_TELEMETRY_URL is not set. Configure a Langfuse-compatible telemetry UI to use this feature.</pre>`);
81
75
  return;
82
76
  }
83
77
 
@@ -87,9 +81,9 @@ export async function handleTelemetryLogin(req, res, {
87
81
  });
88
82
  if (!csrfResponse.ok) {
89
83
  res.writeHead(502, HTML_HEADERS);
90
- res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry backend unreachable</title>` +
91
- `<pre>Could not reach telemetry backend at ${escapeHtml(resolvedBaseUrl)}. Status: ${csrfResponse.status}. ` +
92
- `Try \`construct up\` first.</pre>`);
84
+ res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry UI unreachable</title>` +
85
+ `<pre>Could not reach telemetry UI at ${escapeHtml(resolvedBaseUrl)}. Status: ${csrfResponse.status}. ` +
86
+ `Check CONSTRUCT_TELEMETRY_URL and the configured service.</pre>`);
93
87
  return;
94
88
  }
95
89
  const { csrfToken } = await csrfResponse.json();
@@ -99,10 +93,8 @@ export async function handleTelemetryLogin(req, res, {
99
93
  res.end(renderAutoSubmitForm({ csrfToken, callbackUrl, redirectAfter, email, password }));
100
94
  } catch (err) {
101
95
  res.writeHead(502, HTML_HEADERS);
102
- res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry backend unreachable</title>` +
103
- `<pre>Could not reach telemetry backend: ${escapeHtml(err?.message || 'unknown error')}. ` +
104
- `Try \`construct up\` first.</pre>`);
96
+ res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry UI unreachable</title>` +
97
+ `<pre>Could not reach telemetry UI: ${escapeHtml(err?.message || 'unknown error')}. ` +
98
+ `Check CONSTRUCT_TELEMETRY_URL and the configured service.</pre>`);
105
99
  }
106
100
  }
107
-
108
-
@@ -20,8 +20,8 @@ import {
20
20
  pruneStashDir,
21
21
  verifyTelemetryKeys,
22
22
  isRemoteTelemetry,
23
- startManagedServices,
24
- } from './services/telemetry-backend.mjs';
23
+ } from './services/local-postgres.mjs';
24
+ import { resolveTraceBackend, telemetryProviderLabel } from './telemetry/client.mjs';
25
25
 
26
26
  const CONSTRUCT_PG_COMPOSE_DIR = 'services/postgres';
27
27
  const CONSTRUCT_PG_CONTAINER = 'construct-postgres';
@@ -379,7 +379,7 @@ function startConstructPostgres({ rootDir, homeDir = os.homedir(), spawnSyncFn =
379
379
  if (!composeRunner) return { status: 'unavailable', note: 'Docker not available' };
380
380
 
381
381
  const composeFile = constructPgComposePath(rootDir);
382
- if (!fs.existsSync(composeFile)) return { status: 'unavailable', note: 'Postgres compose file not found — run construct setup first' };
382
+ if (!fs.existsSync(composeFile)) return { status: 'unavailable', note: 'Postgres compose file not found — run construct init first' };
383
383
 
384
384
  const args = [...composeRunner.argsPrefix, '-p', 'construct-postgres', '-f', composeFile, 'up', '-d'];
385
385
  const r = spawnSyncFn(composeRunner.command, args, { stdio: 'pipe', encoding: 'utf8' });
@@ -464,7 +464,7 @@ export async function startServices({
464
464
  // On macOS, auto-launch Docker Desktop when the CLI is present but the
465
465
  // daemon is down. Polls up to 60s so downstream Postgres startup sees a working
466
466
  // compose runner instead of "Docker not available".
467
- await tryStartDockerDaemonFn({ env: process.env });
467
+ await tryStartDockerDaemonFn({ env: { ...process.env, HOME: homeDir }, timeoutMs: 5000 });
468
468
 
469
469
  writeEnvValues(envPath, {
470
470
  DASHBOARD_PORT: String(ports.dashboard),
@@ -475,7 +475,7 @@ export async function startServices({
475
475
  // Construct Postgres — start if DATABASE_URL points to managed container
476
476
  const liveEnv = loadConstructEnvFn({ rootDir, homeDir });
477
477
  const results = [];
478
- const pressureReport = runPressureReleaseFn({ env: liveEnv });
478
+ const pressureReport = runPressureReleaseFn({ env: { ...liveEnv, HOME: homeDir } });
479
479
  if (pressureReport?.killed?.length) {
480
480
  results.push({
481
481
  name: 'Pressure Guard',
@@ -527,31 +527,39 @@ export async function startServices({
527
527
  status: dashboard.reused ? 'reused' : 'started',
528
528
  });
529
529
 
530
- // Telemetry backend — delegated to lib/services/telemetry-backend.mjs.
531
- // Remote URLs short-circuit inside startManagedServices and return `configured`.
532
-
533
530
  const telemetryUrl = liveEnv.CONSTRUCT_TELEMETRY_URL ?? '';
534
- if (telemetryUrl && isRemoteTelemetry(telemetryUrl)) {
531
+ const traceBackend = resolveTraceBackend(liveEnv);
532
+ if (traceBackend === 'local') {
533
+ results.push({
534
+ name: 'Telemetry',
535
+ url: path.join(rootDir, '.cx', 'traces'),
536
+ status: 'configured',
537
+ note: 'local JSONL traces; remote export not configured',
538
+ });
539
+ } else if (traceBackend === 'none') {
540
+ results.push({
541
+ name: 'Telemetry',
542
+ url: path.join(rootDir, '.cx', 'traces'),
543
+ status: 'configured',
544
+ note: 'remote export disabled; local JSONL traces preserved',
545
+ });
546
+ } else if (traceBackend === 'otel') {
547
+ const endpoint = liveEnv.CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT || '';
548
+ results.push({
549
+ name: 'Telemetry',
550
+ url: endpoint || undefined,
551
+ status: endpoint ? 'configured' : 'unavailable',
552
+ note: endpoint ? `OTLP export (${telemetryProviderLabel(liveEnv)})` : 'CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT not set',
553
+ });
554
+ } else if (telemetryUrl && (isRemoteTelemetry(telemetryUrl) || traceBackend === 'langfuse' || traceBackend === 'http')) {
535
555
  results.push({
536
556
  name: 'Telemetry',
537
557
  url: telemetryUrl,
538
558
  status: 'configured',
539
- note: `remote — ${telemetryUrl}`,
559
+ note: `${telemetryProviderLabel(liveEnv)} export — ${telemetryUrl}`,
540
560
  });
541
561
  } else {
542
- const composeRunner = detectDockerComposeFn();
543
- if (composeRunner) {
544
- const svc = await startManagedServices({
545
- rootDir,
546
- homeDir,
547
- env: liveEnv,
548
- composeRunner,
549
- spawnDetached: spawnDetachedFn,
550
- });
551
- results.push({ name: 'Telemetry', url: svc.url, status: svc.status, note: svc.note });
552
- } else {
553
- results.push({ name: 'Telemetry', status: 'unavailable', note: 'Docker not available' });
554
- }
562
+ results.push({ name: 'Telemetry', status: 'unavailable', note: 'remote export requested but endpoint is not configured' });
555
563
  }
556
564
 
557
565
  // Memory (cm)
@@ -563,7 +571,7 @@ export async function startServices({
563
571
  results.push({ name: 'Memory (cm)', url: `http://127.0.0.1:${ports.memory}`, status: 'started' });
564
572
  }
565
573
  } else {
566
- results.push({ name: 'Memory (cm)', status: 'unavailable', note: 'cm not installed — run: construct setup or brew install dicklesworthstone/tap/cm' });
574
+ results.push({ name: 'Memory (cm)', status: 'unavailable', note: 'cm not installed — run: construct init or brew install dicklesworthstone/tap/cm' });
567
575
  }
568
576
 
569
577
  // OpenCode (optional)
@@ -0,0 +1,15 @@
1
+ /**
2
+ * lib/services/local-postgres.mjs — local Postgres helper exports.
3
+ *
4
+ * Compatibility wrappers live in telemetry-backend.mjs until downstream imports
5
+ * move, but new service-management code should use this name.
6
+ */
7
+ export {
8
+ POSTGRES_LOCAL_PORT,
9
+ isRemoteTelemetry,
10
+ servicesComposePath,
11
+ pruneStashDir,
12
+ verifyPostgresHealth,
13
+ verifyTelemetryKeys,
14
+ startManagedServices,
15
+ } from './telemetry-backend.mjs';
@@ -18,7 +18,7 @@ import path from 'node:path';
18
18
  import { getUserEnvPath, writeEnvValues } from '../env-config.mjs';
19
19
 
20
20
  /** Local Postgres port (within Construct's 54329-54339 reserved block). */
21
- export const POSTGRES_LOCAL_PORT = 54332;
21
+ export const POSTGRES_LOCAL_PORT = 54329;
22
22
 
23
23
  export function isRemoteTelemetry(url = '') {
24
24
  if (!url) return false;
@@ -53,7 +53,7 @@ export async function verifyPostgresHealth({
53
53
  maxRetries = 5,
54
54
  intervalMs = 2000,
55
55
  } = {}) {
56
- // We can't use fetch against Postgres (it's not HTTP), so just check pg_isready via docker
56
+ // Postgres is not HTTP health is checked via pg_isready in docker instead
57
57
  for (let attempt = 0; attempt < maxRetries; attempt++) {
58
58
  try {
59
59
  const result = spawnSync('docker', [
@@ -175,4 +175,3 @@ export async function startManagedServices({
175
175
  };
176
176
  }
177
177
 
178
-
@@ -1,5 +1,5 @@
1
1
  /**
2
- * lib/setup-prompts.mjs — yes/no consent helper for `construct setup`.
2
+ * lib/setup-prompts.mjs — yes/no consent helper for `construct init`.
3
3
  *
4
4
  * Centralises the "should we install this service?" decision so Postgres,
5
5
  * telemetry, and any future service share the same prompt + consent-caching
@@ -14,7 +14,7 @@
14
14
  * 5. interactive → prompt [Y/n], cache the answer
15
15
  *
16
16
  * Consent is stored as BOOTSTRAP_<NAME>=yes|no in ~/.construct/config.env
17
- * so subsequent runs of `construct setup` don't re-prompt for the same
17
+ * so subsequent runs of `construct init` don't re-prompt for the same
18
18
  * services. Pass `force = true` to skip the cache and re-prompt.
19
19
  */
20
20