@geraldmaron/construct 1.0.6 → 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 (218) hide show
  1. package/README.md +39 -34
  2. package/bin/construct +1403 -96
  3. package/commands/build/feature.md +1 -1
  4. package/commands/build/fix.md +1 -1
  5. package/commands/design/access.md +1 -1
  6. package/commands/design/flow.md +1 -1
  7. package/commands/design/ui.md +1 -1
  8. package/commands/measure/experiment.md +1 -1
  9. package/commands/measure/metrics.md +1 -1
  10. package/commands/measure/results.md +1 -1
  11. package/commands/plan/api.md +1 -1
  12. package/commands/plan/challenge.md +1 -1
  13. package/commands/plan/decide.md +1 -1
  14. package/commands/plan/feature.md +1 -1
  15. package/commands/plan/requirements.md +1 -1
  16. package/commands/remember/context.md +1 -1
  17. package/commands/remember/handoff.md +1 -1
  18. package/commands/remember/runbook.md +1 -1
  19. package/commands/review/code.md +1 -1
  20. package/commands/review/quality.md +1 -1
  21. package/commands/review/security.md +1 -1
  22. package/commands/ship/ready.md +1 -1
  23. package/commands/ship/release.md +1 -1
  24. package/commands/ship/status.md +1 -1
  25. package/commands/understand/docs.md +1 -1
  26. package/commands/understand/this.md +1 -1
  27. package/commands/understand/why.md +1 -1
  28. package/commands/work/clean.md +1 -1
  29. package/commands/work/drive.md +1 -1
  30. package/commands/work/optimize-prompts.md +2 -2
  31. package/commands/work/parallel-review.md +1 -1
  32. package/db/schema/007_tags.sql +30 -0
  33. package/db/schema/008_skill_usage.sql +24 -0
  34. package/db/schema/009_scheduler.sql +14 -0
  35. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  36. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  37. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  38. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  39. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  40. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  41. package/examples/seed-observations/decisions.md +1 -1
  42. package/lib/audit-skills.mjs +3 -3
  43. package/lib/auto-docs.mjs +8 -8
  44. package/lib/boundary.mjs +126 -0
  45. package/lib/cache-strategy-google.js +26 -31
  46. package/lib/cli-commands.mjs +276 -122
  47. package/lib/comment-lint.mjs +139 -1
  48. package/lib/completions.mjs +1 -1
  49. package/lib/config/schema.mjs +15 -1
  50. package/lib/contracts/validate.mjs +371 -0
  51. package/lib/daemons/contract.mjs +210 -0
  52. package/lib/distill.mjs +22 -4
  53. package/lib/doc-stamp.mjs +48 -0
  54. package/lib/docs-verify.mjs +59 -6
  55. package/lib/doctor/cli.mjs +16 -1
  56. package/lib/doctor/index.mjs +3 -1
  57. package/lib/doctor/watchers/consistency.mjs +360 -0
  58. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  59. package/lib/document-extract.mjs +420 -17
  60. package/lib/document-ingest.mjs +2 -0
  61. package/lib/embed/cli.mjs +124 -3
  62. package/lib/embed/daemon.mjs +43 -4
  63. package/lib/embed/docs-lifecycle.mjs +1 -1
  64. package/lib/embed/inbox.mjs +50 -3
  65. package/lib/embed/role-framing.mjs +3 -3
  66. package/lib/embed/scheduler.mjs +33 -5
  67. package/lib/env-config.mjs +9 -3
  68. package/lib/evaluator-optimizer.mjs +2 -3
  69. package/lib/extractors/calendar.mjs +173 -0
  70. package/lib/extractors/shared/drop-info.mjs +22 -0
  71. package/lib/extractors/transcript.mjs +0 -0
  72. package/lib/gates-audit.mjs +8 -2
  73. package/lib/git-hooks/prepare-commit-msg +1 -1
  74. package/lib/headhunt.mjs +2 -2
  75. package/lib/hooks/agent-tracker.mjs +6 -11
  76. package/lib/hooks/comment-lint.mjs +16 -0
  77. package/lib/hooks/guard-bash.mjs +35 -14
  78. package/lib/hooks/mcp-audit.mjs +14 -1
  79. package/lib/hooks/pre-compact.mjs +92 -5
  80. package/lib/hooks/pre-push-gate.mjs +41 -8
  81. package/lib/hooks/proactive-activation.mjs +0 -14
  82. package/lib/hooks/registry-sync.mjs +2 -2
  83. package/lib/hooks/rule-verifier.mjs +217 -0
  84. package/lib/hooks/session-optimize.mjs +2 -1
  85. package/lib/hooks/stop-notify.mjs +7 -6
  86. package/lib/host-capabilities.mjs +10 -1
  87. package/lib/hygiene/scan.mjs +141 -0
  88. package/lib/init-unified.mjs +73 -65
  89. package/lib/install/stage-project.mjs +4 -4
  90. package/lib/intake/classify.mjs +309 -58
  91. package/lib/intake/daemon.mjs +121 -0
  92. package/lib/intake/filesystem-queue.mjs +31 -6
  93. package/lib/intake/intake-config.mjs +2 -1
  94. package/lib/intake/postgres-queue.mjs +45 -3
  95. package/lib/intake/prepare.mjs +3 -1
  96. package/lib/intake/quarantine.mjs +205 -0
  97. package/lib/intake/session-prelude.mjs +7 -1
  98. package/lib/intake/traceability.mjs +90 -0
  99. package/lib/knowledge/postgres-search.mjs +132 -0
  100. package/lib/knowledge/research-store.mjs +2 -0
  101. package/lib/knowledge/search.mjs +14 -4
  102. package/lib/maintenance/cleanup.mjs +315 -0
  103. package/lib/mcp/memory-bridge.mjs +276 -0
  104. package/lib/mcp/server.mjs +11 -3
  105. package/lib/mcp/tools/skills.mjs +32 -11
  106. package/lib/mcp/tools/workflow.mjs +25 -0
  107. package/lib/mcp-catalog.json +12 -8
  108. package/lib/mcp-platform-config.mjs +16 -8
  109. package/lib/migrations/index.mjs +106 -0
  110. package/lib/migrations/v1-baseline.mjs +33 -0
  111. package/lib/model-registry.mjs +1 -1
  112. package/lib/observation-store.mjs +9 -4
  113. package/lib/opencode-runtime-plugin.mjs +1 -1
  114. package/lib/orchestration-policy.mjs +4 -4
  115. package/lib/outcomes/record.mjs +2 -0
  116. package/lib/overrides/resolver.mjs +3 -3
  117. package/lib/parity.mjs +4 -4
  118. package/lib/policy/engine.mjs +2 -2
  119. package/lib/profiles/lifecycle.mjs +1 -1
  120. package/lib/profiles/rebrand.mjs +46 -0
  121. package/lib/project-init-shared.mjs +12 -0
  122. package/lib/prompt-metadata.mjs +4 -4
  123. package/lib/provider-capabilities.js +20 -7
  124. package/lib/providers/auth-manager.mjs +58 -17
  125. package/lib/providers/circuit-breaker.mjs +14 -0
  126. package/lib/providers/contract.mjs +67 -3
  127. package/lib/providers/creds.mjs +219 -0
  128. package/lib/providers/scaffold.mjs +107 -0
  129. package/lib/reflect.mjs +49 -12
  130. package/lib/role-preload.mjs +5 -0
  131. package/lib/roles/catalog.mjs +1 -1
  132. package/lib/roles/manifest.mjs +2 -2
  133. package/lib/scheduler/index.mjs +112 -0
  134. package/lib/scheduler/solo.mjs +183 -0
  135. package/lib/server/index.mjs +29 -29
  136. package/lib/server/insights.mjs +14 -12
  137. package/lib/server/langfuse-login.mjs +58 -0
  138. package/lib/server/static/assets/index-ab25c707.js +1 -1
  139. package/lib/session-store.mjs +6 -4
  140. package/lib/setup.mjs +14 -3
  141. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  142. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  143. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  144. package/lib/{agents → specialists}/schema.mjs +6 -6
  145. package/lib/status.mjs +13 -11
  146. package/lib/storage/backup.mjs +2 -2
  147. package/lib/tags/lifecycle.mjs +278 -0
  148. package/lib/tags/vocabulary.mjs +140 -0
  149. package/lib/telemetry/client.mjs +5 -1
  150. package/lib/telemetry/otel-tracer.mjs +184 -0
  151. package/lib/telemetry/skill-calls.mjs +73 -12
  152. package/lib/uninstall/uninstall.mjs +1 -1
  153. package/lib/update.mjs +1 -1
  154. package/lib/validator.mjs +57 -56
  155. package/lib/version.mjs +51 -0
  156. package/lib/worker/trace.mjs +5 -1
  157. package/lib/workflows/instantiate.mjs +320 -0
  158. package/package.json +16 -3
  159. package/personas/construct.md +5 -3
  160. package/platforms/claude/CLAUDE.md +1 -1
  161. package/rules/common/development-workflow.md +2 -1
  162. package/rules/common/no-fabrication.md +69 -0
  163. package/rules/common/review-before-change.md +58 -0
  164. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
  165. package/skills/ai/prompt-optimizer.md +3 -3
  166. package/skills/exploration/dependency-graph-reading.md +98 -0
  167. package/skills/exploration/tracer-bullet-method.md +71 -0
  168. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  169. package/skills/operating/change-management.md +91 -0
  170. package/skills/operating/incident-response.md +75 -0
  171. package/skills/operating/oncall-rotation.md +95 -0
  172. package/skills/operating/orchestration-reference.md +2 -2
  173. package/skills/strategy/competitive-landscape.md +75 -0
  174. package/skills/strategy/market-research-methods.md +87 -0
  175. package/skills/strategy/narrative-arc.md +77 -0
  176. package/skills/strategy/pricing-positioning.md +94 -0
  177. package/specialists/contracts.json +1002 -0
  178. package/specialists/contracts.schema.json +83 -0
  179. package/specialists/policy-inventory.json +160 -0
  180. package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
  181. package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
  182. package/{agents → specialists}/prompts/cx-architect.md +3 -1
  183. package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
  184. package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
  185. package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
  186. package/{agents → specialists}/prompts/cx-debugger.md +3 -1
  187. package/{agents → specialists}/prompts/cx-designer.md +3 -1
  188. package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
  189. package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
  190. package/{agents → specialists}/prompts/cx-engineer.md +4 -2
  191. package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
  192. package/{agents → specialists}/prompts/cx-explorer.md +3 -1
  193. package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
  194. package/{agents → specialists}/prompts/cx-operations.md +3 -1
  195. package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
  196. package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
  197. package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
  198. package/{agents → specialists}/prompts/cx-qa.md +3 -1
  199. package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
  200. package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
  201. package/{agents → specialists}/prompts/cx-researcher.md +3 -1
  202. package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
  203. package/{agents → specialists}/prompts/cx-security.md +3 -1
  204. package/{agents → specialists}/prompts/cx-sre.md +4 -2
  205. package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
  206. package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
  207. package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
  208. package/{agents → specialists}/registry.json +651 -418
  209. package/{agents → specialists}/role-manifests.json +8 -8
  210. package/templates/docs/construct_guide.md +6 -6
  211. package/templates/docs/skill-artifact.md +1 -1
  212. package/templates/provider-scaffold/health.test.mjs +30 -0
  213. package/templates/provider-scaffold/index.mjs +48 -0
  214. package/templates/workflows/cross-team-handoff.yml +85 -0
  215. package/templates/workflows/engineering-onboarding.yml +77 -0
  216. package/templates/workflows/new-feature.yml +53 -0
  217. package/agents/contracts.json +0 -387
  218. /package/{agents → specialists}/teams.json +0 -0
@@ -34,11 +34,16 @@ function stripTopHeading(body) {
34
34
  export function readRoleFile(root, name, opts = {}) {
35
35
  const p = path.join(root, "skills", "roles", `${name}.md`);
36
36
  if (!fs.existsSync(p)) return null;
37
+ const t0 = Date.now();
37
38
  const body = stripTopHeading(stripFrontmatter(fs.readFileSync(p, "utf8"))).trim();
38
39
  logSkillCall({
39
40
  skillId: `roles/${name}`,
40
41
  source: opts.source || 'role-preload',
41
42
  callerContext: opts.callerContext,
43
+ latencyMs: Date.now() - t0,
44
+ agentId: opts.agentId,
45
+ sessionId: opts.sessionId,
46
+ tokensReturned: Math.ceil(body.length / 4),
42
47
  });
43
48
  return body;
44
49
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * lib/roles/catalog.mjs — role listing and display helpers.
3
3
  *
4
- * Reads agents/registry.json to produce a list of available roles with
4
+ * Reads specialists/registry.json to produce a list of available roles with
5
5
  * optional department grouping and consolidated-role views. Consumed by the
6
6
  * `roles:list` CLI command.
7
7
  */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * lib/roles/manifest.mjs — role manifest loader.
3
3
  *
4
- * Reads agents/role-manifests.json. A persona is "onboarded" when its
4
+ * Reads specialists/role-manifests.json. A persona is "onboarded" when its
5
5
  * `events` array is non-empty. Empty entries reserve the slot for future
6
6
  * wiring without code change.
7
7
  */
@@ -11,7 +11,7 @@ import { dirname, join } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
 
13
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
- const MANIFEST_PATH = join(__dirname, '..', '..', 'agents', 'role-manifests.json');
14
+ const MANIFEST_PATH = join(__dirname, '..', '..', 'specialists', 'role-manifests.json');
15
15
 
16
16
  let cached = null;
17
17
 
@@ -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
+ }
@@ -8,6 +8,7 @@
8
8
  * Runs on port 4242 (overridable via PORT env var), bound to 127.0.0.1.
9
9
  */
10
10
  import { createServer } from 'http';
11
+ import { registerBoundary } from '../boundary.mjs';
11
12
  import { readFileSync, writeFileSync, statSync, watch, existsSync, readdirSync, mkdirSync, renameSync, chmodSync, appendFileSync } from 'fs';
12
13
  import { spawnSync } from 'child_process';
13
14
  import { join, extname, relative, normalize, dirname } from 'path';
@@ -692,6 +693,12 @@ const server = createServer(async (req, res) => {
692
693
  return;
693
694
  }
694
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
+
695
702
  if (url.pathname === '/api/status') {
696
703
  try {
697
704
  const status = await buildStatus();
@@ -1115,44 +1122,36 @@ const server = createServer(async (req, res) => {
1115
1122
  const chunks = [];
1116
1123
  await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
1117
1124
  const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
1118
-
1119
- const { parentInstance, parentUrl, childInstanceId } = body;
1120
-
1125
+
1126
+ const { parentInstance, parentUrl, childInstanceId, nonce, signature } = body;
1127
+
1121
1128
  if (!parentInstance || !parentUrl) {
1122
1129
  res.writeHead(400, { 'Content-Type': 'application/json' });
1123
1130
  res.end(JSON.stringify({ error: 'parentInstance and parentUrl are required' }));
1124
1131
  return;
1125
1132
  }
1126
-
1127
- // In a real implementation, this would:
1128
- // - Validate the parent is a legitimate Construct instance
1129
- // - Store the parent registration in a boundary configuration
1130
- // - Set up communication channels between parent and child
1131
- // - Configure isolation boundaries
1132
-
1133
- // For now, just acknowledge the registration
1134
- const configDir = join(HOME, '.construct');
1135
- if (!existsSync(configDir)) {
1136
- mkdirSync(configDir, { recursive: true });
1137
- }
1138
-
1139
- const boundaryConfig = {
1133
+
1134
+ const result = await registerBoundary({
1140
1135
  parentInstance,
1141
1136
  parentUrl,
1142
- childInstanceId: childInstanceId || env.CONSTRUCT_INSTANCE_ID || 'default',
1143
- registeredAt: new Date().toISOString(),
1144
- boundaryVersion: '1.0'
1145
- };
1146
-
1147
- const boundaryConfigPath = join(configDir, 'boundary-config.json');
1148
- writeFileSync(boundaryConfigPath, JSON.stringify(boundaryConfig, null, 2));
1149
-
1137
+ childInstanceId: childInstanceId || process.env.CONSTRUCT_INSTANCE_ID || 'default',
1138
+ nonce,
1139
+ signature,
1140
+ sharedSecret: process.env.CONSTRUCT_BOUNDARY_SECRET || null,
1141
+ });
1142
+
1143
+ if (!result.ok) {
1144
+ res.writeHead(result.status || 400, { 'Content-Type': 'application/json' });
1145
+ res.end(JSON.stringify({ error: result.error }));
1146
+ return;
1147
+ }
1148
+
1150
1149
  res.writeHead(200, { 'Content-Type': 'application/json' });
1151
- res.end(JSON.stringify({
1150
+ res.end(JSON.stringify({
1152
1151
  success: true,
1153
1152
  message: 'Boundary registration accepted',
1154
- boundaryConfig,
1155
- boundaryConfigPath
1153
+ boundaryConfig: result.config,
1154
+ boundaryConfigPath: result.path,
1156
1155
  }));
1157
1156
  } catch (err) {
1158
1157
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -2181,7 +2180,7 @@ watchFiles();
2181
2180
  // to keep the knowledge base current without manual intervention.
2182
2181
  // Override interval via CX_EMBED_INTERVAL_MS (default: 30 minutes).
2183
2182
  function runEmbedSync() {
2184
- const syncScript = join(ROOT_DIR, 'scripts', 'sync-agents.mjs');
2183
+ const syncScript = join(ROOT_DIR, 'scripts', 'sync-specialists.mjs');
2185
2184
  if (!existsSync(syncScript)) return;
2186
2185
  try {
2187
2186
  spawnSync(process.execPath, [syncScript], {
@@ -2243,3 +2242,4 @@ process.on('SIGINT', () => shutdown('SIGINT'));
2243
2242
  onEmbedNotification((event) => {
2244
2243
  notifyClients({ type: 'toast', ...event });
2245
2244
  });
2245
+
@@ -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
+ }