@geraldmaron/construct 1.0.3 → 1.0.5

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 (157) hide show
  1. package/README.md +51 -42
  2. package/agents/prompts/cx-ai-engineer.md +6 -26
  3. package/agents/prompts/cx-architect.md +1 -0
  4. package/agents/prompts/cx-business-strategist.md +2 -0
  5. package/agents/prompts/cx-data-analyst.md +6 -26
  6. package/agents/prompts/cx-docs-keeper.md +1 -31
  7. package/agents/prompts/cx-explorer.md +1 -0
  8. package/agents/prompts/cx-orchestrator.md +40 -112
  9. package/agents/prompts/cx-platform-engineer.md +2 -22
  10. package/agents/prompts/cx-product-manager.md +2 -1
  11. package/agents/prompts/cx-qa.md +0 -20
  12. package/agents/prompts/cx-rd-lead.md +2 -0
  13. package/agents/prompts/cx-researcher.md +77 -31
  14. package/agents/prompts/cx-security.md +11 -49
  15. package/agents/prompts/cx-sre.md +9 -43
  16. package/agents/prompts/cx-ux-researcher.md +1 -0
  17. package/agents/role-manifests.json +4 -4
  18. package/bin/construct +279 -4
  19. package/commands/understand/research.md +5 -3
  20. package/db/schema/004_recommendations.sql +46 -0
  21. package/db/schema/005_strategy.sql +21 -0
  22. package/lib/auto-docs.mjs +3 -4
  23. package/lib/beads-automation.mjs +16 -7
  24. package/lib/cli-commands.mjs +52 -2
  25. package/lib/comment-lint.mjs +7 -1
  26. package/lib/config/schema.mjs +3 -0
  27. package/lib/embed/conflict-detection.mjs +26 -9
  28. package/lib/embed/customer-profiles.mjs +37 -17
  29. package/lib/embed/daemon.mjs +10 -8
  30. package/lib/embed/recommendation-store.mjs +213 -14
  31. package/lib/embed/workspaces.mjs +53 -18
  32. package/lib/flavors/loader.mjs +136 -0
  33. package/lib/gates-audit.mjs +3 -3
  34. package/lib/health-check.mjs +1 -1
  35. package/lib/hooks/agent-tracker.mjs +22 -3
  36. package/lib/hooks/pre-compact.mjs +3 -0
  37. package/lib/hooks/pre-push-gate.mjs +14 -1
  38. package/lib/hooks/read-tracker.mjs +10 -101
  39. package/lib/hooks/session-optimize.mjs +3 -2
  40. package/lib/hooks/session-reflect.mjs +68 -0
  41. package/lib/host-capabilities.mjs +90 -1
  42. package/lib/init-unified.mjs +25 -2
  43. package/lib/init-update.mjs +246 -131
  44. package/lib/intake/classify.mjs +61 -183
  45. package/lib/intake/prepare.mjs +7 -0
  46. package/lib/intake/tables/creative.mjs +94 -0
  47. package/lib/intake/tables/operations.mjs +85 -0
  48. package/lib/intake/tables/research.mjs +85 -0
  49. package/lib/intake/tables/rnd.mjs +175 -0
  50. package/lib/intent-classifier.mjs +1 -0
  51. package/lib/knowledge/layout.mjs +10 -0
  52. package/lib/knowledge/research-store.mjs +109 -0
  53. package/lib/mcp/tools/telemetry.mjs +30 -78
  54. package/lib/model-router.mjs +61 -1
  55. package/lib/observation-store.mjs +19 -0
  56. package/lib/ollama-manager.mjs +1 -1
  57. package/lib/opencode-telemetry.mjs +4 -5
  58. package/lib/orchestration-policy.mjs +9 -0
  59. package/lib/outcomes/aggregate.mjs +104 -0
  60. package/lib/outcomes/record.mjs +115 -0
  61. package/lib/parity.mjs +121 -21
  62. package/lib/profiles/lifecycle.mjs +388 -0
  63. package/lib/profiles/loader.mjs +123 -0
  64. package/lib/profiles/validate-custom.mjs +114 -0
  65. package/lib/prompt-composer.js +106 -29
  66. package/lib/read-tracker-store.mjs +149 -0
  67. package/lib/reflect/extractor.mjs +193 -0
  68. package/lib/reflect.mjs +89 -2
  69. package/lib/sandbox.mjs +102 -0
  70. package/lib/server/index.mjs +76 -0
  71. package/lib/server/telemetry-login.mjs +17 -25
  72. package/lib/service-manager.mjs +30 -22
  73. package/lib/services/local-postgres.mjs +15 -0
  74. package/lib/services/telemetry-backend.mjs +1 -2
  75. package/lib/setup.mjs +8 -43
  76. package/lib/status.mjs +51 -5
  77. package/lib/storage/backend.mjs +12 -2
  78. package/lib/strategy-store.mjs +371 -0
  79. package/lib/telemetry/backends/local.mjs +6 -4
  80. package/lib/telemetry/client.mjs +185 -0
  81. package/lib/telemetry/ingest.mjs +13 -5
  82. package/lib/telemetry/team-rollup.mjs +9 -2
  83. package/lib/worker/trace.mjs +17 -27
  84. package/package.json +10 -2
  85. package/personas/construct.md +20 -20
  86. package/platforms/claude/settings.template.json +13 -0
  87. package/rules/common/research.md +44 -12
  88. package/scripts/sync-agents.mjs +11 -0
  89. package/skills/docs/backlog-proposal-workflow.md +2 -2
  90. package/skills/docs/customer-profile-workflow.md +1 -1
  91. package/skills/docs/evidence-ingest-workflow.md +5 -5
  92. package/skills/docs/prfaq-workflow.md +1 -1
  93. package/skills/docs/product-intelligence-review.md +1 -1
  94. package/skills/docs/product-intelligence-workflow.md +3 -3
  95. package/skills/docs/product-signal-workflow.md +48 -18
  96. package/skills/docs/research-workflow.md +26 -14
  97. package/skills/docs/strategy-workflow.md +36 -0
  98. package/skills/roles/architect.ai-systems.md +4 -2
  99. package/skills/roles/architect.data.md +4 -2
  100. package/skills/roles/architect.enterprise.md +4 -2
  101. package/skills/roles/architect.integration.md +4 -2
  102. package/skills/roles/architect.md +7 -5
  103. package/skills/roles/architect.platform.md +4 -2
  104. package/skills/roles/data-analyst.experiment.md +4 -2
  105. package/skills/roles/data-analyst.md +9 -7
  106. package/skills/roles/data-analyst.product-intelligence.md +5 -3
  107. package/skills/roles/data-analyst.product.md +4 -2
  108. package/skills/roles/data-analyst.telemetry.md +4 -2
  109. package/skills/roles/data-engineer.pipeline.md +4 -2
  110. package/skills/roles/data-engineer.vector-retrieval.md +4 -2
  111. package/skills/roles/data-engineer.warehouse.md +4 -2
  112. package/skills/roles/debugger.md +7 -5
  113. package/skills/roles/designer.accessibility.md +4 -2
  114. package/skills/roles/designer.md +10 -8
  115. package/skills/roles/engineer.ai.md +4 -2
  116. package/skills/roles/engineer.data.md +5 -3
  117. package/skills/roles/engineer.md +14 -12
  118. package/skills/roles/engineer.platform.md +5 -3
  119. package/skills/roles/operator.docs.md +6 -4
  120. package/skills/roles/operator.md +6 -4
  121. package/skills/roles/operator.release.md +4 -2
  122. package/skills/roles/operator.sre.md +5 -3
  123. package/skills/roles/orchestrator.md +5 -3
  124. package/skills/roles/product-manager.ai-product.md +4 -2
  125. package/skills/roles/product-manager.business-strategy.md +4 -2
  126. package/skills/roles/product-manager.enterprise.md +4 -2
  127. package/skills/roles/product-manager.growth.md +4 -2
  128. package/skills/roles/product-manager.md +6 -4
  129. package/skills/roles/product-manager.platform.md +4 -2
  130. package/skills/roles/product-manager.product.md +4 -2
  131. package/skills/roles/qa.ai-eval.md +4 -2
  132. package/skills/roles/qa.api-contract.md +4 -2
  133. package/skills/roles/qa.data-pipeline.md +4 -2
  134. package/skills/roles/qa.md +7 -5
  135. package/skills/roles/qa.test-automation.md +5 -3
  136. package/skills/roles/qa.web-ui.md +4 -2
  137. package/skills/roles/researcher.explorer.md +4 -2
  138. package/skills/roles/researcher.md +35 -20
  139. package/skills/roles/researcher.ux.md +4 -2
  140. package/skills/roles/reviewer.devil-advocate.md +4 -2
  141. package/skills/roles/reviewer.evaluator.md +4 -2
  142. package/skills/roles/reviewer.md +14 -12
  143. package/skills/roles/reviewer.trace.md +4 -2
  144. package/skills/roles/security.ai.md +4 -2
  145. package/skills/roles/security.appsec.md +4 -2
  146. package/skills/roles/security.cloud.md +4 -2
  147. package/skills/roles/security.legal-compliance.md +4 -2
  148. package/skills/roles/security.md +7 -5
  149. package/skills/roles/security.privacy.md +4 -2
  150. package/skills/roles/security.supply-chain.md +4 -2
  151. package/skills/routing.md +8 -1
  152. package/templates/docs/persona-artifact.md +36 -0
  153. package/templates/docs/research-brief.md +63 -9
  154. package/templates/docs/research-finding.md +26 -0
  155. package/templates/docs/skill-artifact.md +27 -0
  156. package/templates/docs/strategy.md +36 -0
  157. package/templates/homebrew/construct.rb +6 -6
package/lib/reflect.mjs CHANGED
@@ -5,11 +5,13 @@
5
5
  * knowledge base with actionable insights for continuous self-improvement.
6
6
  */
7
7
 
8
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
9
- import { join, resolve } from 'node:path';
8
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
9
+ import { join, resolve, basename } from 'node:path';
10
+ import { execSync } from 'node:child_process';
10
11
  import { addObservation } from './observation-store.mjs';
11
12
  import { loadConstructEnv } from './env-config.mjs';
12
13
  import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS, inferKnowledgeTarget } from './knowledge/layout.mjs';
14
+ import { extractSessionObservation } from './reflect/extractor.mjs';
13
15
 
14
16
  const HOME = process.env.HOME || process.env.USERPROFILE;
15
17
  const USER_ENV_PATH = join(HOME, '.construct', 'config.env');
@@ -125,6 +127,91 @@ export async function runReflectCli(args) {
125
127
  console.log(` Use 'construct ingest --target=${target} <file>' to manually add similar feedback in the future`);
126
128
  }
127
129
 
130
+ /**
131
+ * Auto-reflect from a Claude Code Stop hook payload.
132
+ *
133
+ * Reads the transcript JSONL, extracts a deterministic session summary, and
134
+ * persists it via the standard observation store. Contract: exit fast, fail
135
+ * silent, never throw — every error path returns null so the hook always
136
+ * exits 0.
137
+ *
138
+ * Returns the recorded observation id on success, null on no-op.
139
+ *
140
+ * @param {object} args
141
+ * @param {string} args.transcriptPath — path to the Stop hook's transcript_path
142
+ * @param {string} args.cwd — session cwd (the project root)
143
+ * @param {string} [args.sessionId]
144
+ * @param {number} [args.durationMs]
145
+ * @returns {Promise<string|null>}
146
+ */
147
+ export async function runReflectAuto({ transcriptPath, cwd, sessionId, durationMs }) {
148
+ if (!transcriptPath || !cwd) return null;
149
+ if (!existsSync(transcriptPath)) return null;
150
+
151
+ const entries = parseTranscript(transcriptPath);
152
+ if (entries.length === 0) return null;
153
+
154
+ const observation = extractSessionObservation({ entries, cwd, sessionId, durationMs });
155
+ if (!observation) return null;
156
+
157
+ // Skip trivial sessions: no tool calls AND no substantive final text. Avoids
158
+ // polluting the observation store with one-line acknowledgements.
159
+ const trivial =
160
+ observation.extras?.toolCallCount === 0 &&
161
+ (observation.content?.length || 0) < 120;
162
+ if (trivial) return null;
163
+
164
+ const project = safeProject(cwd);
165
+ const gitSha = safeGitSha(cwd);
166
+
167
+ // Session-id stays in tags so the index can filter by it without parsing.
168
+ // Structured metadata goes into the new extras field, not into content.
169
+ const sessionTag = sessionId ? `session:${sessionId.slice(0, 24)}` : null;
170
+ const tags = [...observation.tags];
171
+ if (sessionTag) tags.push(sessionTag);
172
+
173
+ const result = await addObservation(cwd, {
174
+ role: observation.role,
175
+ category: observation.category,
176
+ summary: observation.summary,
177
+ content: observation.content,
178
+ tags,
179
+ project,
180
+ gitSha,
181
+ confidence: observation.confidence,
182
+ source: observation.source,
183
+ extras: observation.extras ?? null,
184
+ });
185
+
186
+ return result?.id ?? null;
187
+ }
188
+
189
+ function safeProject(cwd) {
190
+ try { return basename(cwd) || null; } catch { return null; }
191
+ }
192
+
193
+ function safeGitSha(cwd) {
194
+ try {
195
+ return execSync('git rev-parse HEAD', { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 200 })
196
+ .toString().trim().slice(0, 40) || null;
197
+ } catch { return null; }
198
+ }
199
+
200
+ // Transcripts are JSONL; one JSON object per line. Be permissive — a single
201
+ // malformed line shouldn't lose the rest of the session.
202
+
203
+ function parseTranscript(path) {
204
+ let raw = '';
205
+ try { raw = readFileSync(path, 'utf8'); } catch { return []; }
206
+ const out = [];
207
+ for (const line of raw.split('\n')) {
208
+ const trimmed = line.trim();
209
+ if (!trimmed) continue;
210
+ try { out.push(JSON.parse(trimmed)); } catch { /* skip malformed */ }
211
+ }
212
+ return out;
213
+ }
214
+
128
215
  /**
129
216
  * CLI entry point for direct execution.
130
217
  */
@@ -0,0 +1,102 @@
1
+ /**
2
+ * lib/sandbox.mjs — Lightweight isolated environment for QA validation.
3
+ *
4
+ * Spins up a fresh tmpdir with a minimal `.cx/` so a specialist (or operator)
5
+ * can run end-to-end checks against a candidate change without touching the
6
+ * working repo. Designed to be the cheapest possible alternative to a full
7
+ * Docker container: when Docker is unavailable, an isolated tmpdir is enough
8
+ * for almost every validation Construct needs.
9
+ *
10
+ * Layout created:
11
+ * <sandbox>/.cx/
12
+ * context.md stub
13
+ * observations/ empty
14
+ * intake/pending/ empty
15
+ * <sandbox>/.git/ empty repo init, so git-aware tools work
16
+ *
17
+ * Tear-down is opt-in via `construct sandbox prune` or by hand. Each sandbox
18
+ * lives under `~/.cx/sandboxes/<id>/` so they survive across CLI sessions.
19
+ */
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { spawnSync } from 'node:child_process';
24
+
25
+ const ROOT = path.join(os.homedir(), '.cx', 'sandboxes');
26
+
27
+ function ensureRoot() {
28
+ fs.mkdirSync(ROOT, { recursive: true });
29
+ }
30
+
31
+ function newSandboxId() {
32
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
33
+ const rand = Math.random().toString(36).slice(2, 8);
34
+ return `${stamp}-${rand}`;
35
+ }
36
+
37
+ /**
38
+ * Create a new sandbox. Returns its absolute path.
39
+ *
40
+ * @param {object} [opts]
41
+ * @param {string} [opts.profile] - profile id to write into construct.config.json
42
+ * @param {string} [opts.seedContext] - text to drop into .cx/context.md
43
+ * @returns {{ id: string, path: string }}
44
+ */
45
+ export function createSandbox({ profile = null, seedContext = null } = {}) {
46
+ ensureRoot();
47
+ const id = newSandboxId();
48
+ const root = path.join(ROOT, id);
49
+ fs.mkdirSync(root, { recursive: true });
50
+ fs.mkdirSync(path.join(root, '.cx', 'observations'), { recursive: true });
51
+ fs.mkdirSync(path.join(root, '.cx', 'intake', 'pending'), { recursive: true });
52
+
53
+ const ctx = seedContext || `# Sandbox ${id}\n\nIsolated environment for QA validation. Created ${new Date().toISOString()}.\n`;
54
+ fs.writeFileSync(path.join(root, '.cx', 'context.md'), ctx);
55
+
56
+ const cfg = { version: 1, alias: 'Construct', deployment: { mode: 'solo' } };
57
+ if (profile) cfg.profile = profile;
58
+ fs.writeFileSync(path.join(root, 'construct.config.json'), JSON.stringify(cfg, null, 2) + '\n');
59
+
60
+ spawnSync('git', ['init', '--quiet'], { cwd: root, stdio: 'ignore' });
61
+
62
+ return { id, path: root };
63
+ }
64
+
65
+ /**
66
+ * List existing sandboxes, newest first.
67
+ */
68
+ export function listSandboxes() {
69
+ if (!fs.existsSync(ROOT)) return [];
70
+ const entries = fs.readdirSync(ROOT, { withFileTypes: true })
71
+ .filter((d) => d.isDirectory())
72
+ .map((d) => {
73
+ const full = path.join(ROOT, d.name);
74
+ const stat = fs.statSync(full);
75
+ return { id: d.name, path: full, createdAt: stat.birthtime.toISOString() };
76
+ });
77
+ return entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
78
+ }
79
+
80
+ /**
81
+ * Delete a sandbox by id. Returns true if removed, false if not found.
82
+ */
83
+ export function deleteSandbox(id) {
84
+ const full = path.join(ROOT, id);
85
+ if (!fs.existsSync(full)) return false;
86
+ fs.rmSync(full, { recursive: true, force: true });
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Prune sandboxes older than N days. Returns the count removed.
92
+ */
93
+ export function pruneSandboxes({ olderThanDays = 7 } = {}) {
94
+ const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
95
+ let removed = 0;
96
+ for (const s of listSandboxes()) {
97
+ if (Date.parse(s.createdAt) < cutoff) {
98
+ if (deleteSandbox(s.id)) removed++;
99
+ }
100
+ }
101
+ return removed;
102
+ }
@@ -1500,6 +1500,82 @@ const server = createServer(async (req, res) => {
1500
1500
  return;
1501
1501
  }
1502
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
+
1503
1579
  if (url.pathname === '/api/snapshots') {
1504
1580
  handleSnapshots(req, res);
1505
1581
  return;
@@ -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';
@@ -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)
@@ -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;
@@ -175,4 +175,3 @@ export async function startManagedServices({
175
175
  };
176
176
  }
177
177
 
178
-
package/lib/setup.mjs CHANGED
@@ -25,10 +25,6 @@ import { createSqlClient, closeSqlClient } from './storage/backend.mjs';
25
25
  import { getEmbeddingModelInfo, warmupEmbeddingModel } from './storage/embeddings-engine.mjs';
26
26
  import { restoreConstructDb } from './storage/postgres-backup.mjs';
27
27
  import { buildPressureGuardValues, installPressureGuardLaunchAgent, loadPressureGuardLaunchAgent } from './runtime-pressure.mjs';
28
- import {
29
- isRemoteTelemetry,
30
- startManagedServices,
31
- } from './services/telemetry-backend.mjs';
32
28
  import { consentToInstall } from './setup-prompts.mjs';
33
29
 
34
30
  const ROOT_DIR = path.resolve(import.meta.dirname, '..');
@@ -86,9 +82,8 @@ export function defaultVectorIndexPath(homeDir = HOME) {
86
82
  export async function buildManagedSetupValues({ homeDir = HOME, env = process.env, databaseUrl = '' } = {}) {
87
83
  const modelInfo = await getEmbeddingModelInfo({ env });
88
84
 
89
- // Local telemetry backend
90
85
  const values = {
91
- CONSTRUCT_TRACE_BACKEND: env.CONSTRUCT_TRACE_BACKEND || 'remote',
86
+ CONSTRUCT_TRACE_BACKEND: env.CONSTRUCT_TRACE_BACKEND || 'local',
92
87
  CONSTRUCT_VECTOR_INDEX_PATH: env.CONSTRUCT_VECTOR_INDEX_PATH || defaultVectorIndexPath(homeDir),
93
88
  CONSTRUCT_VECTOR_MODEL: env.CONSTRUCT_VECTOR_MODEL || modelInfo.model,
94
89
  ...buildPressureGuardValues({ env }),
@@ -533,37 +528,10 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
533
528
  console.log(` ${dockerInstallHint()}`);
534
529
  }
535
530
 
536
- // Local telemetry backend — starts Postgres via services/docker-compose.yml.
537
- // Skipped automatically when CONSTRUCT_TELEMETRY_URL points to a remote instance.
538
-
539
- let telemetryResult = { status: 'skipped', note: 'Docker not available' };
540
- if (dockerAvailable) {
541
- const svcConsent = await consentToInstall({
542
- name: 'telemetry',
543
- isYes,
544
- alreadyConfigured: isRemoteTelemetry(process.env.CONSTRUCT_TELEMETRY_URL || ''),
545
- alreadyConfiguredNote: 'CONSTRUCT_TELEMETRY_URL points to a remote instance — local stack not started.',
546
- envPath,
547
- });
548
- if (svcConsent.decision) {
549
- try {
550
- telemetryResult = await startManagedServices({
551
- rootDir,
552
- homeDir,
553
- env: process.env,
554
- composeRunner: dockerRunner,
555
- spawnDetached: spawnDetachedForSetup,
556
- });
557
- console.log(`Telemetry: ${telemetryResult.status} — ${telemetryResult.note || ''}`);
558
- } catch (err) {
559
- telemetryResult = { status: 'error', note: err?.message || 'spin-up failed' };
560
- console.log(`Telemetry: error — ${telemetryResult.note}`);
561
- }
562
- } else {
563
- telemetryResult = { status: 'skipped', note: svcConsent.note };
564
- console.log(`Telemetry: skipped (${svcConsent.note})`);
565
- }
566
- }
531
+ const telemetryResult = process.env.CONSTRUCT_TELEMETRY_URL
532
+ ? { status: 'configured', note: `remote export configured (${process.env.CONSTRUCT_TELEMETRY_URL})` }
533
+ : { status: 'local', note: 'local JSONL traces in .cx/traces; remote export optional' };
534
+ console.log(`Telemetry: ${telemetryResult.note}`);
567
535
 
568
536
  fs.mkdirSync(path.dirname(defaultVectorIndexPath(homeDir)), { recursive: true });
569
537
 
@@ -790,18 +758,15 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
790
758
  // Local-service summary — surfaces running local services. Same pattern
791
759
  // as `supabase start`, which prints all local URLs + keys after spin-up.
792
760
 
793
- const hasLocalTelemetry = telemetryResult?.status === 'started' || telemetryResult?.status === 'degraded';
794
761
  const hasLocalPostgres = serviceResult?.status === 'ok' && serviceResult?.databaseUrl?.includes('127.0.0.1');
795
762
  const vectorBackend = hasLocalPostgres
796
763
  ? { label: 'Postgres + pgvector', detail: `${serviceResult.databaseUrl} (384d embeddings, ${managedValues.CONSTRUCT_VECTOR_MODEL})` }
797
764
  : { label: 'JSON fallback', detail: `${managedValues.CONSTRUCT_VECTOR_INDEX_PATH} (${managedValues.CONSTRUCT_VECTOR_MODEL})` };
798
765
 
799
766
  console.log('\nLocal services:');
800
- if (hasLocalTelemetry) {
801
- console.log(` Telemetry: ${telemetryResult.note || 'started'}`);
802
- } else {
803
- console.log(` Telemetry: not started`);
804
- }
767
+ console.log(' Traces: local JSONL (.cx/traces)');
768
+ if (process.env.CONSTRUCT_TELEMETRY_URL) console.log(` Telemetry: remote export (${process.env.CONSTRUCT_TELEMETRY_URL})`);
769
+ else console.log(' Telemetry: remote export not configured');
805
770
  if (hasLocalPostgres) {
806
771
  console.log(` Postgres: ${serviceResult.databaseUrl}`);
807
772
  } else if (process.env.DATABASE_URL) {