@geraldmaron/construct 1.0.7 → 1.0.8

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 (147) hide show
  1. package/README.md +20 -17
  2. package/bin/construct +1199 -69
  3. package/commands/work/optimize-prompts.md +1 -1
  4. package/db/schema/007_tags.sql +30 -0
  5. package/db/schema/008_skill_usage.sql +24 -0
  6. package/db/schema/009_scheduler.sql +14 -0
  7. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  8. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  9. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  10. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  11. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  12. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  13. package/examples/seed-observations/decisions.md +1 -1
  14. package/lib/audit-skills.mjs +3 -3
  15. package/lib/auto-docs.mjs +2 -2
  16. package/lib/cli-commands.mjs +276 -122
  17. package/lib/comment-lint.mjs +5 -1
  18. package/lib/completions.mjs +1 -1
  19. package/lib/config/schema.mjs +15 -1
  20. package/lib/contracts/validate.mjs +65 -17
  21. package/lib/distill.mjs +22 -4
  22. package/lib/doc-stamp.mjs +48 -0
  23. package/lib/doctor/watchers/consistency.mjs +62 -12
  24. package/lib/document-extract.mjs +221 -28
  25. package/lib/document-ingest.mjs +2 -0
  26. package/lib/embed/inbox.mjs +48 -3
  27. package/lib/embed/role-framing.mjs +3 -3
  28. package/lib/env-config.mjs +9 -3
  29. package/lib/extractors/calendar.mjs +173 -0
  30. package/lib/extractors/shared/drop-info.mjs +22 -0
  31. package/lib/extractors/transcript.mjs +0 -0
  32. package/lib/gates-audit.mjs +8 -2
  33. package/lib/git-hooks/prepare-commit-msg +1 -1
  34. package/lib/headhunt.mjs +2 -2
  35. package/lib/hooks/agent-tracker.mjs +6 -11
  36. package/lib/hooks/guard-bash.mjs +35 -14
  37. package/lib/hooks/mcp-audit.mjs +12 -0
  38. package/lib/hooks/pre-compact.mjs +92 -5
  39. package/lib/hooks/pre-push-gate.mjs +41 -8
  40. package/lib/hooks/registry-sync.mjs +2 -2
  41. package/lib/hooks/stop-notify.mjs +7 -6
  42. package/lib/host-capabilities.mjs +10 -1
  43. package/lib/hygiene/scan.mjs +141 -0
  44. package/lib/init-unified.mjs +18 -0
  45. package/lib/install/stage-project.mjs +4 -4
  46. package/lib/intake/classify.mjs +222 -55
  47. package/lib/intake/filesystem-queue.mjs +25 -5
  48. package/lib/intake/postgres-queue.mjs +45 -3
  49. package/lib/intake/prepare.mjs +3 -0
  50. package/lib/intake/quarantine.mjs +205 -0
  51. package/lib/knowledge/postgres-search.mjs +132 -0
  52. package/lib/knowledge/search.mjs +14 -4
  53. package/lib/mcp/server.mjs +9 -3
  54. package/lib/mcp/tools/skills.mjs +32 -11
  55. package/lib/mcp/tools/workflow.mjs +1 -1
  56. package/lib/migrations/index.mjs +1 -1
  57. package/lib/model-registry.mjs +1 -1
  58. package/lib/opencode-runtime-plugin.mjs +1 -1
  59. package/lib/orchestration-policy.mjs +4 -4
  60. package/lib/overrides/resolver.mjs +3 -3
  61. package/lib/parity.mjs +4 -4
  62. package/lib/policy/engine.mjs +2 -2
  63. package/lib/profiles/lifecycle.mjs +1 -1
  64. package/lib/prompt-metadata.mjs +4 -4
  65. package/lib/providers/circuit-breaker.mjs +14 -0
  66. package/lib/providers/contract.mjs +67 -3
  67. package/lib/providers/creds.mjs +219 -0
  68. package/lib/providers/scaffold.mjs +107 -0
  69. package/lib/role-preload.mjs +5 -0
  70. package/lib/roles/catalog.mjs +1 -1
  71. package/lib/roles/manifest.mjs +2 -2
  72. package/lib/scheduler/index.mjs +112 -0
  73. package/lib/scheduler/solo.mjs +183 -0
  74. package/lib/server/index.mjs +7 -1
  75. package/lib/server/insights.mjs +14 -12
  76. package/lib/server/langfuse-login.mjs +58 -0
  77. package/lib/server/static/assets/index-ab25c707.js +1 -1
  78. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  79. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  80. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  81. package/lib/{agents → specialists}/schema.mjs +6 -6
  82. package/lib/status.mjs +13 -11
  83. package/lib/storage/backup.mjs +2 -2
  84. package/lib/tags/lifecycle.mjs +278 -0
  85. package/lib/tags/vocabulary.mjs +140 -0
  86. package/lib/telemetry/otel-tracer.mjs +184 -0
  87. package/lib/telemetry/skill-calls.mjs +73 -12
  88. package/lib/uninstall/uninstall.mjs +1 -1
  89. package/lib/update.mjs +1 -1
  90. package/lib/validator.mjs +57 -56
  91. package/lib/workflows/instantiate.mjs +320 -0
  92. package/package.json +13 -3
  93. package/personas/construct.md +2 -2
  94. package/platforms/claude/CLAUDE.md +1 -1
  95. package/rules/common/no-fabrication.md +1 -1
  96. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +126 -54
  97. package/skills/ai/prompt-optimizer.md +3 -3
  98. package/skills/exploration/dependency-graph-reading.md +98 -0
  99. package/skills/exploration/tracer-bullet-method.md +71 -0
  100. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  101. package/skills/operating/change-management.md +91 -0
  102. package/skills/operating/incident-response.md +75 -0
  103. package/skills/operating/oncall-rotation.md +95 -0
  104. package/skills/operating/orchestration-reference.md +2 -2
  105. package/skills/strategy/competitive-landscape.md +75 -0
  106. package/skills/strategy/market-research-methods.md +87 -0
  107. package/skills/strategy/narrative-arc.md +77 -0
  108. package/skills/strategy/pricing-positioning.md +94 -0
  109. package/{agents → specialists}/contracts.schema.json +1 -1
  110. package/specialists/policy-inventory.json +160 -0
  111. package/{agents → specialists}/prompts/cx-accessibility.md +1 -1
  112. package/{agents → specialists}/prompts/cx-ai-engineer.md +1 -1
  113. package/{agents → specialists}/prompts/cx-architect.md +1 -1
  114. package/{agents → specialists}/prompts/cx-business-strategist.md +1 -1
  115. package/{agents → specialists}/prompts/cx-data-analyst.md +1 -1
  116. package/{agents → specialists}/prompts/cx-data-engineer.md +1 -1
  117. package/{agents → specialists}/prompts/cx-debugger.md +1 -1
  118. package/{agents → specialists}/prompts/cx-designer.md +1 -1
  119. package/{agents → specialists}/prompts/cx-devil-advocate.md +1 -1
  120. package/{agents → specialists}/prompts/cx-docs-keeper.md +2 -2
  121. package/{agents → specialists}/prompts/cx-engineer.md +2 -2
  122. package/{agents → specialists}/prompts/cx-evaluator.md +1 -1
  123. package/{agents → specialists}/prompts/cx-explorer.md +1 -1
  124. package/{agents → specialists}/prompts/cx-legal-compliance.md +1 -1
  125. package/{agents → specialists}/prompts/cx-operations.md +1 -1
  126. package/{agents → specialists}/prompts/cx-orchestrator.md +2 -2
  127. package/{agents → specialists}/prompts/cx-platform-engineer.md +1 -1
  128. package/{agents → specialists}/prompts/cx-product-manager.md +1 -1
  129. package/{agents → specialists}/prompts/cx-qa.md +1 -1
  130. package/{agents → specialists}/prompts/cx-rd-lead.md +1 -1
  131. package/{agents → specialists}/prompts/cx-release-manager.md +1 -1
  132. package/{agents → specialists}/prompts/cx-researcher.md +1 -1
  133. package/{agents → specialists}/prompts/cx-reviewer.md +2 -2
  134. package/{agents → specialists}/prompts/cx-security.md +1 -1
  135. package/{agents → specialists}/prompts/cx-sre.md +2 -2
  136. package/{agents → specialists}/prompts/cx-test-automation.md +1 -1
  137. package/{agents → specialists}/prompts/cx-trace-reviewer.md +3 -3
  138. package/{agents → specialists}/prompts/cx-ux-researcher.md +1 -1
  139. package/{agents → specialists}/registry.json +651 -423
  140. package/{agents → specialists}/role-manifests.json +8 -8
  141. package/templates/provider-scaffold/health.test.mjs +30 -0
  142. package/templates/provider-scaffold/index.mjs +48 -0
  143. package/templates/workflows/cross-team-handoff.yml +85 -0
  144. package/templates/workflows/engineering-onboarding.yml +77 -0
  145. package/templates/workflows/new-feature.yml +53 -0
  146. /package/{agents → specialists}/contracts.json +0 -0
  147. /package/{agents → specialists}/teams.json +0 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * lib/scheduler/index.mjs — deployment-topology-aware background job scheduler.
3
+ *
4
+ * Three modes:
5
+ * solo — native platform triggers (launchd/systemd/Task Scheduler) via lib/scheduler/solo.mjs
6
+ * team/enterprise — Postgres advisory lock runner via lib/scheduler/postgres.mjs
7
+ * one-shot — run handler synchronously and exit (for CI)
8
+ *
9
+ * Jobs registered here: 'tag-candidate-mining', 'skill-usage-rollup', 'doc-hygiene-scan'
10
+ *
11
+ * Deployment mode is determined by getDeploymentMode(env), which inspects env vars.
12
+ * Registration is in-memory: no persistence across process restarts beyond the
13
+ * native trigger files written by solo.mjs or the DB rows in 009_scheduler.sql.
14
+ */
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // In-memory job registry
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const jobRegistry = new Map();
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Public API
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Registers a job definition in memory. Overwrites any prior registration
28
+ * with the same id.
29
+ *
30
+ * @param {object} opts
31
+ * id {string} - Unique job identifier
32
+ * schedule {string} - Cron expression or interval string
33
+ * mode {string} - 'solo' | 'team' | 'enterprise' | 'one-shot'
34
+ * handler {Function} - Async function ({ cwd, env }) => result
35
+ * leaderLockKey {string} - Postgres advisory lock key (team/enterprise only)
36
+ */
37
+ export function registerJob({ id, schedule, mode, handler, leaderLockKey = null }) {
38
+ jobRegistry.set(id, { id, schedule, mode, handler, leaderLockKey, registeredAt: new Date().toISOString() });
39
+ }
40
+
41
+ /**
42
+ * Runs the handler for the given job id synchronously in the current process.
43
+ * Returns the handler's result. Throws if the job id is not registered.
44
+ */
45
+ export async function runJobOnce(id, { cwd = process.cwd(), env = process.env } = {}) {
46
+ const job = jobRegistry.get(id);
47
+ if (!job) throw new Error(`job not registered: ${id}`);
48
+ return job.handler({ cwd, env });
49
+ }
50
+
51
+ /**
52
+ * Returns an array of all registered job definitions (handler excluded for safety).
53
+ */
54
+ export function listJobs() {
55
+ return Array.from(jobRegistry.values()).map(({ id, schedule, mode, leaderLockKey, registeredAt }) => ({
56
+ id, schedule, mode, leaderLockKey, registeredAt,
57
+ }));
58
+ }
59
+
60
+ /**
61
+ * Derives the deployment mode from environment variables.
62
+ * Returns 'enterprise' | 'team' | 'solo'.
63
+ *
64
+ * CONSTRUCT_DEPLOYMENT=enterprise or CONSTRUCT_ENTERPRISE=1 => enterprise
65
+ * CONSTRUCT_DEPLOYMENT=team or CONSTRUCT_TEAM=1 => team
66
+ * Otherwise => solo
67
+ */
68
+ export function getDeploymentMode(env = process.env) {
69
+ const explicit = env.CONSTRUCT_DEPLOYMENT;
70
+ if (explicit === 'enterprise' || env.CONSTRUCT_ENTERPRISE === '1') return 'enterprise';
71
+ if (explicit === 'team' || env.CONSTRUCT_TEAM === '1') return 'team';
72
+ if (explicit === 'solo') return 'solo';
73
+ return 'solo';
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Built-in job registrations
78
+ // ---------------------------------------------------------------------------
79
+
80
+ registerJob({
81
+ id: 'tag-candidate-mining',
82
+ schedule: '0 3 * * *',
83
+ mode: 'solo',
84
+ handler: async ({ cwd, env }) => {
85
+ const { default: run } = await import('../../scripts/tag-candidate-mining.mjs');
86
+ return run({ cwd, env });
87
+ },
88
+ });
89
+
90
+ registerJob({
91
+ id: 'skill-usage-rollup',
92
+ schedule: '0 4 * * 1',
93
+ mode: 'solo',
94
+ handler: async () => ({ status: 'noop', reason: 'not yet implemented' }),
95
+ });
96
+
97
+ registerJob({
98
+ id: 'doc-hygiene-scan',
99
+ schedule: '0 2 * * *',
100
+ mode: 'solo',
101
+ handler: async ({ cwd, env }) => {
102
+ const { findHygieneCandidates } = await import('../hygiene/scan.mjs');
103
+ const mode = getDeploymentMode(env);
104
+ const limit = mode === 'solo' ? 25 : 50;
105
+ const candidates = findHygieneCandidates({ cwd, limit });
106
+ return {
107
+ status: 'ok',
108
+ candidates: candidates.length,
109
+ items: candidates.map((c) => ({ rel: c.rel, reason: c.reason, ageDays: c.ageDays })),
110
+ };
111
+ },
112
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * lib/scheduler/solo.mjs — native platform trigger management for solo deployment mode.
3
+ *
4
+ * Detects the current platform (darwin/linux/win32) and writes the appropriate
5
+ * trigger file so the OS scheduler runs the job without a long-lived Construct
6
+ * daemon.
7
+ *
8
+ * darwin — launchd plist at ~/Library/LaunchAgents/com.construct.job.<id>.plist
9
+ * linux — systemd timer unit at ~/.config/systemd/user/construct-<id>.timer
10
+ * (plus a matching .service unit at the same path)
11
+ * win32 — no-op with a descriptive message (Task Scheduler XML generation
12
+ * is deferred; Windows support is tracked separately)
13
+ *
14
+ * The schedule parameter is a cron expression (5-field).
15
+ * launchd uses StartCalendarInterval derived from the cron fields.
16
+ * systemd uses OnCalendar= translated from the cron expression.
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import os from 'node:os';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Cron parsing helpers — minimal support for standard 5-field cron
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function parseCron(schedule) {
28
+ const parts = schedule.trim().split(/\s+/);
29
+ if (parts.length !== 5) throw new Error(`unsupported cron expression (need 5 fields): ${schedule}`);
30
+ const [minute, hour, dom, month, dow] = parts;
31
+ return { minute, hour, dom, month, dow };
32
+ }
33
+
34
+ function cronToLaunchdCalendar(schedule) {
35
+ const { minute, hour, dom, month, dow } = parseCron(schedule);
36
+ const items = [];
37
+ if (minute !== '*') items.push(` <key>Minute</key><integer>${minute}</integer>`);
38
+ if (hour !== '*') items.push(` <key>Hour</key><integer>${hour}</integer>`);
39
+ if (dom !== '*') items.push(` <key>Day</key><integer>${dom}</integer>`);
40
+ if (month !== '*') items.push(` <key>Month</key><integer>${month}</integer>`);
41
+ if (dow !== '*') items.push(` <key>Weekday</key><integer>${dow}</integer>`);
42
+ return items.join('\n');
43
+ }
44
+
45
+ function cronToSystemdOnCalendar(schedule) {
46
+ const { minute, hour, dom, month, dow } = parseCron(schedule);
47
+ const dayOfWeek = dow === '*' ? '*' : dow;
48
+ const d = dom === '*' ? '*' : dom;
49
+ const m = month === '*' ? '*' : `*-${month}`;
50
+ const time = `${hour === '*' ? '*' : hour.padStart(2,'0')}:${minute === '*' ? '*' : minute.padStart(2,'0')}:00`;
51
+ return `${dayOfWeek} ${m}-${d} ${time}`;
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Platform-specific trigger writers
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function writeLaunchdPlist(id, schedule, cwd) {
59
+ const label = `com.construct.job.${id}`;
60
+ const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
61
+ const plistPath = path.join(plistDir, `${label}.plist`);
62
+ const constructBin = path.join(cwd, 'bin', 'construct');
63
+ const calendarEntries = cronToLaunchdCalendar(schedule);
64
+
65
+ const content = `<?xml version="1.0" encoding="UTF-8"?>
66
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
67
+ <plist version="1.0">
68
+ <dict>
69
+ <key>Label</key>
70
+ <string>${label}</string>
71
+ <key>ProgramArguments</key>
72
+ <array>
73
+ <string>${constructBin}</string>
74
+ <string>scheduler</string>
75
+ <string>run</string>
76
+ <string>${id}</string>
77
+ </array>
78
+ <key>WorkingDirectory</key>
79
+ <string>${cwd}</string>
80
+ <key>StartCalendarInterval</key>
81
+ <dict>
82
+ ${calendarEntries}
83
+ </dict>
84
+ <key>StandardOutPath</key>
85
+ <string>${path.join(os.homedir(), '.cx', 'scheduler', 'logs', `${id}.stdout.log`)}</string>
86
+ <key>StandardErrorPath</key>
87
+ <string>${path.join(os.homedir(), '.cx', 'scheduler', 'logs', `${id}.stderr.log`)}</string>
88
+ <key>RunAtLoad</key>
89
+ <false/>
90
+ </dict>
91
+ </plist>
92
+ `;
93
+ fs.mkdirSync(plistDir, { recursive: true });
94
+ fs.writeFileSync(plistPath, content, 'utf8');
95
+ return plistPath;
96
+ }
97
+
98
+ function writeSystemdUnits(id, schedule, cwd) {
99
+ const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
100
+ const timerPath = path.join(unitDir, `construct-${id}.timer`);
101
+ const servicePath = path.join(unitDir, `construct-${id}.service`);
102
+ const constructBin = path.join(cwd, 'bin', 'construct');
103
+ const onCalendar = cronToSystemdOnCalendar(schedule);
104
+
105
+ const timerContent = `[Unit]
106
+ Description=Construct job scheduler: ${id}
107
+
108
+ [Timer]
109
+ OnCalendar=${onCalendar}
110
+ Persistent=true
111
+
112
+ [Install]
113
+ WantedBy=timers.target
114
+ `;
115
+
116
+ const serviceContent = `[Unit]
117
+ Description=Construct job: ${id}
118
+
119
+ [Service]
120
+ Type=oneshot
121
+ WorkingDirectory=${cwd}
122
+ ExecStart=${constructBin} scheduler run ${id}
123
+ StandardOutput=append:${path.join(os.homedir(), '.cx', 'scheduler', 'logs', `${id}.stdout.log`)}
124
+ StandardError=append:${path.join(os.homedir(), '.cx', 'scheduler', 'logs', `${id}.stderr.log`)}
125
+ `;
126
+
127
+ fs.mkdirSync(unitDir, { recursive: true });
128
+ fs.writeFileSync(timerPath, timerContent, 'utf8');
129
+ fs.writeFileSync(servicePath, serviceContent, 'utf8');
130
+ return timerPath;
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Public API
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Detects the current platform and writes the appropriate native trigger file.
139
+ * Returns { platform, triggerPath, installed: boolean }.
140
+ *
141
+ * On win32, installed is false and triggerPath is null (deferred support).
142
+ */
143
+ export function registerNativeTrigger({ id, schedule, cwd = process.cwd() }) {
144
+ const platform = process.platform;
145
+
146
+ if (platform === 'darwin') {
147
+ const triggerPath = writeLaunchdPlist(id, schedule, cwd);
148
+ return { platform, triggerPath, installed: true };
149
+ }
150
+
151
+ if (platform === 'linux') {
152
+ const triggerPath = writeSystemdUnits(id, schedule, cwd);
153
+ return { platform, triggerPath, installed: true };
154
+ }
155
+
156
+ return { platform, triggerPath: null, installed: false };
157
+ }
158
+
159
+ /**
160
+ * Removes the native trigger file(s) for the given job id.
161
+ * Silently ignores missing files.
162
+ */
163
+ export function removeNativeTrigger({ id }) {
164
+ const platform = process.platform;
165
+
166
+ if (platform === 'darwin') {
167
+ const label = `com.construct.job.${id}`;
168
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
169
+ if (fs.existsSync(plistPath)) fs.unlinkSync(plistPath);
170
+ return { platform, removed: plistPath };
171
+ }
172
+
173
+ if (platform === 'linux') {
174
+ const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
175
+ const timerPath = path.join(unitDir, `construct-${id}.timer`);
176
+ const servicePath = path.join(unitDir, `construct-${id}.service`);
177
+ if (fs.existsSync(timerPath)) fs.unlinkSync(timerPath);
178
+ if (fs.existsSync(servicePath)) fs.unlinkSync(servicePath);
179
+ return { platform, removed: timerPath };
180
+ }
181
+
182
+ return { platform, removed: null };
183
+ }
@@ -693,6 +693,12 @@ const server = createServer(async (req, res) => {
693
693
  return;
694
694
  }
695
695
 
696
+ if (url.pathname === '/api/services/langfuse/login' && req.method === 'GET') {
697
+ const { handleLangfuseLogin } = await import('./langfuse-login.mjs');
698
+ await handleLangfuseLogin(req, res);
699
+ return;
700
+ }
701
+
696
702
  if (url.pathname === '/api/status') {
697
703
  try {
698
704
  const status = await buildStatus();
@@ -2174,7 +2180,7 @@ watchFiles();
2174
2180
  // to keep the knowledge base current without manual intervention.
2175
2181
  // Override interval via CX_EMBED_INTERVAL_MS (default: 30 minutes).
2176
2182
  function runEmbedSync() {
2177
- const syncScript = join(ROOT_DIR, 'scripts', 'sync-agents.mjs');
2183
+ const syncScript = join(ROOT_DIR, 'scripts', 'sync-specialists.mjs');
2178
2184
  if (!existsSync(syncScript)) return;
2179
2185
  try {
2180
2186
  spawnSync(process.execPath, [syncScript], {
@@ -47,9 +47,11 @@ async function fetchTelemetrySummary(env, { limit = 100 } = {}) {
47
47
  if (!res.ok) return { state: 'unreachable', message: `Telemetry HTTP ${res.status}` };
48
48
  const json = await res.json();
49
49
  const traces = Array.isArray(json.data) ? json.data : [];
50
- let totalCost = 0; let observations = 0; const byModel = new Map(); let withError = 0; let withInput = 0; let withOutput = 0;
50
+ // Cost figures are intentionally absent from the user-facing summary.
51
+ // The underlying telemetry trace data still carries totalCost; that
52
+ // surfaces via the OTel + dashboard wiring planned in Workstream J.
53
+ let observations = 0; const byModel = new Map(); let withError = 0; let withInput = 0; let withOutput = 0;
51
54
  for (const trace of traces) {
52
- totalCost += Number(trace?.totalCost) || 0;
53
55
  observations += Number(trace?.observationCount) || 0;
54
56
  if (trace?.input != null) withInput += 1;
55
57
  if (trace?.output != null) withOutput += 1;
@@ -59,16 +61,16 @@ async function fetchTelemetrySummary(env, { limit = 100 } = {}) {
59
61
  trace?.output?.model || trace?.output?.costModel || trace?.input?.metadata?.model ||
60
62
  (Array.isArray(trace?.observations) ? trace.observations.find((o) => o?.model)?.model : null) || null;
61
63
  if (!model) continue;
62
- const prior = byModel.get(model) || { traces: 0, cost: 0 };
63
- prior.traces += 1; prior.cost += Number(trace?.totalCost) || 0;
64
+ const prior = byModel.get(model) || { traces: 0 };
65
+ prior.traces += 1;
64
66
  byModel.set(model, prior);
65
67
  }
66
68
  return {
67
69
  state: traces.length === 0 ? 'empty' : 'ok',
68
70
  baseUrl, sampleSize: traces.length,
69
- totalCostUsd: Number(totalCost.toFixed(4)), observations, withInput, withOutput, withError,
70
- topModels: [...byModel.entries()].map(([model, v]) => ({ model, traces: v.traces, cost: Number(v.cost.toFixed(6)) }))
71
- .sort((a, b) => b.cost - a.cost || b.traces - a.traces).slice(0, 5),
71
+ observations, withInput, withOutput, withError,
72
+ topModels: [...byModel.entries()].map(([model, v]) => ({ model, traces: v.traces }))
73
+ .sort((a, b) => b.traces - a.traces).slice(0, 5),
72
74
  };
73
75
  } catch (err) {
74
76
  clearTimeout(timer);
@@ -727,11 +729,13 @@ export async function buildInsights({ env = process.env, cwd = process.cwd(), ro
727
729
  } catch (err) {
728
730
  handoffs = { state: 'unreachable', message: err.message };
729
731
  }
730
- const [telemetry, telemetryTrend, telemetryLatency, cost, intake, beads, recommendations, integrations] = await Promise.all([
732
+ // Cost-ledger and telemetry-cost-trend summaries are intentionally omitted
733
+ // from the dashboard payload. Underlying ledger writes + telemetry traces
734
+ // continue; the consumer surface lands with the OTel + dashboard wiring
735
+ // (plan Workstream J).
736
+ const [telemetry, telemetryLatency, intake, beads, recommendations, integrations] = await Promise.all([
731
737
  fetchTelemetrySummary(env),
732
- summarizeTelemetryCostTrend(env),
733
738
  summarizeTelemetryLatency(env),
734
- summarizeCostLedger(env),
735
739
  Promise.resolve(summarizeIntakeQueue(env, cwd)),
736
740
  Promise.resolve(summarizeBeads(rootDir)),
737
741
  summarizeRecommendations(),
@@ -747,9 +751,7 @@ export async function buildInsights({ env = process.env, cwd = process.cwd(), ro
747
751
  } catch { sessionUsage = null; }
748
752
  return {
749
753
  telemetry,
750
- telemetryTrend,
751
754
  telemetryLatency,
752
- cost,
753
755
  intake,
754
756
  recommendations,
755
757
  integrations,
@@ -0,0 +1,58 @@
1
+ /**
2
+ * lib/server/langfuse-login.mjs — Magic-link bridge for local Langfuse.
3
+ *
4
+ * `construct up` seeds Langfuse with deterministic admin creds via the
5
+ * LANGFUSE_INIT_* env vars in langfuse/docker-compose.yml. This bridge turns
6
+ * the "Open Langfuse" link in the dashboard into a one-click sign-in: the
7
+ * server fetches a CSRF token from local Langfuse, then returns an HTML
8
+ * auto-submit form that POSTs the seeded credentials to NextAuth's callback.
9
+ * The browser receives Langfuse's session cookie on localhost:3000 directly,
10
+ * sidestepping cross-origin restrictions.
11
+ *
12
+ * The seeded creds are NOT secret — they are deterministic local defaults
13
+ * declared in the public compose file. Their only job is to make local
14
+ * Langfuse zero-touch.
15
+ */
16
+ import { LANGFUSE_LOCAL } from '../service-manager.mjs';
17
+
18
+ const HTML_HEADERS = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' };
19
+
20
+ function escapeHtml(value) {
21
+ return String(value).replace(/[&<>"']/g, (c) => ({
22
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
23
+ })[c]);
24
+ }
25
+
26
+ export async function handleLangfuseLogin(req, res, { baseUrl = LANGFUSE_LOCAL.baseUrl, email = LANGFUSE_LOCAL.email, pwd = LANGFUSE_LOCAL.pwd } = {}) {
27
+ try {
28
+ const csrfResponse = await fetch(`${baseUrl}/api/auth/csrf`, { headers: { Accept: 'application/json' } });
29
+ if (!csrfResponse.ok) {
30
+ res.writeHead(502, HTML_HEADERS);
31
+ res.end(`<!doctype html><meta charset="utf-8"><title>Langfuse unreachable</title><pre>Could not reach local Langfuse at ${escapeHtml(baseUrl)}. Status: ${csrfResponse.status}. Try \`construct up\` first.</pre>`);
32
+ return;
33
+ }
34
+ const { csrfToken } = await csrfResponse.json();
35
+
36
+ const callbackUrl = `${baseUrl}/api/auth/callback/credentials`;
37
+ const redirectAfter = `${baseUrl}/`;
38
+
39
+ res.writeHead(200, HTML_HEADERS);
40
+ res.end(`<!doctype html>
41
+ <html><head><meta charset="utf-8"><title>Signing in to Langfuse…</title>
42
+ <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>
43
+ </head><body>
44
+ <p>Signing you in to Langfuse…</p>
45
+ <form id="lf" method="POST" action="${escapeHtml(callbackUrl)}">
46
+ <input type="hidden" name="csrfToken" value="${escapeHtml(csrfToken)}">
47
+ <input type="hidden" name="email" value="${escapeHtml(email)}">
48
+ <input type="hidden" name="password" value="${escapeHtml(pwd)}">
49
+ <input type="hidden" name="callbackUrl" value="${escapeHtml(redirectAfter)}">
50
+ <input type="hidden" name="json" value="false">
51
+ </form>
52
+ <script>document.getElementById('lf').submit();</script>
53
+ </body></html>`);
54
+ } catch (err) {
55
+ res.writeHead(502, HTML_HEADERS);
56
+ res.end(`<!doctype html><meta charset="utf-8"><title>Langfuse unreachable</title><pre>Could not reach local Langfuse: ${escapeHtml(err?.message || 'unknown error')}. Try \`construct up\` first.</pre>`);
57
+ }
58
+ }