@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
@@ -0,0 +1,175 @@
1
+ /**
2
+ * lib/intake/tables/rnd.mjs — RND intake classification table.
3
+ *
4
+ * Extracted from lib/intake/classify.mjs as part of B2. Behavior must remain
5
+ * byte-identical for RND inputs; tests/intake/golden-rnd.test.mjs is the
6
+ * regression guard.
7
+ *
8
+ * Schema: { INTAKE_TYPES, STAGES, CLASSIFICATION_TABLE, UNKNOWN_TRIAGE }.
9
+ * UNKNOWN_TRIAGE handles the no-match fallback for this profile.
10
+ */
11
+
12
+ export const INTAKE_TYPES = [
13
+ 'user-signal',
14
+ 'bug',
15
+ 'requirement',
16
+ 'research',
17
+ 'experiment',
18
+ 'eval-finding',
19
+ 'architecture',
20
+ 'incident',
21
+ 'launch-asset',
22
+ 'ops',
23
+ 'security',
24
+ 'legal-compliance',
25
+ 'unknown',
26
+ ];
27
+
28
+ export const STAGES = [
29
+ 'signal',
30
+ 'framing',
31
+ 'hypothesis',
32
+ 'research',
33
+ 'artifact',
34
+ 'design',
35
+ 'implementation',
36
+ 'evaluation',
37
+ 'release',
38
+ 'operations',
39
+ 'unknown',
40
+ ];
41
+
42
+ export const UNKNOWN_TRIAGE = {
43
+ intakeType: 'unknown',
44
+ rdStage: 'unknown',
45
+ primaryOwner: 'orchestrator',
46
+ recommendedChain: ['orchestrator'],
47
+ recommendedAction: 'summarize',
48
+ risk: 'low',
49
+ requiresApproval: false,
50
+ };
51
+
52
+ export const CLASSIFICATION_TABLE = [
53
+ {
54
+ intakeType: 'security',
55
+ keywords: ['security', 'secret', 'cve', 'vulnerability', 'vuln', 'exploit', 'leak', 'auth bypass', 'privilege escalation', 'sqli', 'xss', 'csrf', 'rce'],
56
+ rdStage: 'operations',
57
+ primaryOwner: 'security',
58
+ recommendedChain: ['security', 'engineer', 'reviewer'],
59
+ recommendedAction: 'diagnose',
60
+ risk: 'high',
61
+ requiresApproval: true,
62
+ },
63
+ {
64
+ intakeType: 'incident',
65
+ keywords: ['incident', 'outage', 'slo breach', 'sla breach', 'latency spike', 'availability', 'down', 'p0 ', 'p1 ', 'pagerduty', '5xx', 'oncall'],
66
+ rdStage: 'operations',
67
+ primaryOwner: 'sre',
68
+ recommendedChain: ['sre', 'debugger', 'platform-engineer'],
69
+ recommendedAction: 'create-runbook',
70
+ risk: 'high',
71
+ requiresApproval: true,
72
+ },
73
+ {
74
+ intakeType: 'legal-compliance',
75
+ keywords: ['gdpr', 'ccpa', 'hipaa', 'sox', 'soc2', 'license', 'lawsuit', 'dpa', 'data retention', 'pii', 'subpoena', 'compliance audit'],
76
+ rdStage: 'operations',
77
+ primaryOwner: 'legal-compliance',
78
+ recommendedChain: ['legal-compliance', 'security', 'product-manager'],
79
+ recommendedAction: 'clarify',
80
+ risk: 'high',
81
+ requiresApproval: true,
82
+ },
83
+ {
84
+ intakeType: 'architecture',
85
+ keywords: ['architecture', 'adr', 'rfc', 'interface', 'tradeoff', 'boundary', 'system design', 'data model', 'api contract', 'migration plan'],
86
+ rdStage: 'design',
87
+ primaryOwner: 'architect',
88
+ recommendedChain: ['architect', 'devil-advocate', 'engineer'],
89
+ recommendedAction: 'draft-rfc',
90
+ risk: 'medium',
91
+ requiresApproval: false,
92
+ },
93
+ {
94
+ intakeType: 'eval-finding',
95
+ keywords: ['eval', 'evaluation', 'hallucination', 'judge', 'trace', 'score regression', 'recall@', 'precision@', 'mrr', 'ndcg', 'failure case', 'rubric'],
96
+ rdStage: 'evaluation',
97
+ primaryOwner: 'evaluator',
98
+ recommendedChain: ['evaluator', 'ai-engineer', 'trace-reviewer'],
99
+ recommendedAction: 'evaluate',
100
+ risk: 'medium',
101
+ requiresApproval: false,
102
+ },
103
+ {
104
+ intakeType: 'bug',
105
+ keywords: ['bug', 'broken', 'error', 'stack trace', 'regression', 'crash', 'exception', 'fails', 'failing', 'throws', 'not working', 'reproduce', 'repro:'],
106
+ rdStage: 'implementation',
107
+ primaryOwner: 'debugger',
108
+ recommendedChain: ['debugger', 'engineer', 'qa', 'reviewer'],
109
+ recommendedAction: 'diagnose',
110
+ risk: 'medium',
111
+ requiresApproval: false,
112
+ },
113
+ {
114
+ intakeType: 'experiment',
115
+ keywords: ['hypothesis', 'experiment', 'spike', 'prototype', 'falsifiable', 'research question', 'a/b test', 'pilot'],
116
+ rdStage: 'hypothesis',
117
+ primaryOwner: 'rd-lead',
118
+ recommendedChain: ['rd-lead', 'researcher', 'evaluator'],
119
+ recommendedAction: 'create-experiment',
120
+ risk: 'low',
121
+ requiresApproval: false,
122
+ },
123
+ {
124
+ intakeType: 'launch-asset',
125
+ keywords: ['release', 'changelog', 'version bump', 'ship', 'launch', 'rollout', 'cut a release', 'rc1', 'rc2', 'release candidate'],
126
+ rdStage: 'release',
127
+ primaryOwner: 'release-manager',
128
+ recommendedChain: ['release-manager', 'qa', 'docs-keeper'],
129
+ recommendedAction: 'release-review',
130
+ risk: 'medium',
131
+ requiresApproval: false,
132
+ },
133
+ {
134
+ intakeType: 'research',
135
+ keywords: ['competitor', 'market', 'pricing', 'positioning', 'industry', 'state of the art', 'literature', 'benchmark study', 'desk research'],
136
+ rdStage: 'research',
137
+ primaryOwner: 'business-strategist',
138
+ recommendedChain: ['business-strategist', 'researcher', 'product-manager'],
139
+ recommendedAction: 'research',
140
+ risk: 'low',
141
+ requiresApproval: false,
142
+ },
143
+ {
144
+ intakeType: 'user-signal',
145
+ keywords: ['customer', 'feedback', 'pain point', 'user says', 'user feedback', 'support ticket', 'churn', 'nps', 'usability', 'frustrated'],
146
+ rdStage: 'signal',
147
+ primaryOwner: 'product-manager',
148
+ recommendedChain: ['product-manager', 'ux-researcher', 'researcher'],
149
+ recommendedAction: 'clarify',
150
+ risk: 'low',
151
+ requiresApproval: false,
152
+ },
153
+ {
154
+ intakeType: 'requirement',
155
+ keywords: ['acceptance criteria', 'requirement', 'must have', 'should have', 'feature request', 'prd', 'use case', 'success metric'],
156
+ rdStage: 'framing',
157
+ primaryOwner: 'product-manager',
158
+ recommendedChain: ['product-manager', 'architect', 'engineer'],
159
+ recommendedAction: 'draft-prd',
160
+ risk: 'low',
161
+ requiresApproval: false,
162
+ },
163
+ {
164
+ intakeType: 'ops',
165
+ keywords: ['runbook', 'cron', 'scheduled job', 'maintenance', 'backup', 'restore', 'capacity plan', 'cost optimization', 'dependency upgrade'],
166
+ rdStage: 'operations',
167
+ primaryOwner: 'operations',
168
+ recommendedChain: ['operations', 'sre', 'engineer'],
169
+ recommendedAction: 'create-runbook',
170
+ risk: 'low',
171
+ requiresApproval: false,
172
+ },
173
+ ];
174
+
175
+ export default { INTAKE_TYPES, STAGES, CLASSIFICATION_TABLE, UNKNOWN_TRIAGE };
@@ -209,6 +209,7 @@ export async function verifyIntent({ request, specialist, flavor, matchedKeyword
209
209
  // Kept here (not in orchestration-policy) so the classifier module stays
210
210
  // the canonical source of "what does verified mean for this role."
211
211
  const ROLE_PROMPT_KEYWORDS = {
212
+ engineer: ['ai', 'platform', 'data', 'prompt', 'pipeline', 'deploy'],
212
213
  productManager: ['platform', 'enterprise', 'ai-product', 'growth', 'go-to-market'],
213
214
  architect: ['ai-systems', 'integration', 'data model', 'enterprise', 'platform'],
214
215
  qa: ['ai-eval', 'api-contract', 'data-pipeline', 'web-ui'],
@@ -19,6 +19,8 @@
19
19
  */
20
20
 
21
21
  export const KNOWLEDGE_ROOT = '.cx/knowledge';
22
+ export const KNOWLEDGE_INTERNAL_ROOT = `${KNOWLEDGE_ROOT}/internal`;
23
+ export const KNOWLEDGE_REFERENCE_ROOT = `${KNOWLEDGE_ROOT}/reference`;
22
24
 
23
25
  /**
24
26
  * All valid knowledge subdirectory names.
@@ -70,3 +72,11 @@ export function inferKnowledgeTarget(filePath) {
70
72
  if (/\bpost.?mortem\b|\bincident\b|\brca\b/.test(name)) return 'internal';
71
73
  return 'internal';
72
74
  }
75
+
76
+ export function knowledgeInternalStore(...segments) {
77
+ return ['knowledge', 'internal', ...segments].join('/');
78
+ }
79
+
80
+ export function knowledgeReferenceStore(...segments) {
81
+ return ['knowledge', 'reference', ...segments].join('/');
82
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * lib/knowledge/research-store.mjs — Persist research findings into the knowledge base.
3
+ *
4
+ * Wraps the existing document-ingest pipeline so research output written via
5
+ * `construct knowledge add --source=research` lands in
6
+ * `.cx/knowledge/external/research/<slug>.md` with research-specific frontmatter
7
+ * (topic, confidence, sources, expiresAt, profile). The file is then synced into
8
+ * the SQL/vector index via the standard `syncFileStateToSql` path.
9
+ *
10
+ * Schema (frontmatter):
11
+ * kind: research-finding
12
+ * topic: string
13
+ * confidence: confirmed | inferred | weak
14
+ * sources: [{ url, accessedAt, span? }]
15
+ * expiresAt: ISO date (default +90d)
16
+ * profile: <profile-id>
17
+ */
18
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { syncFileStateToSql } from '../storage/sync.mjs';
21
+ import { resolveActiveProfile } from '../profiles/loader.mjs';
22
+
23
+ const ROOT = '.cx/knowledge/external/research';
24
+ const MAX_BYTES = 50 * 1024;
25
+ const DEFAULT_TTL_DAYS = 90;
26
+
27
+ const VALID_CONFIDENCE = new Set(['confirmed', 'inferred', 'weak']);
28
+
29
+ /**
30
+ * @param {object} args
31
+ * @param {string} args.cwd - project root
32
+ * @param {string} args.slug - filename slug (lowercase, hyphenated)
33
+ * @param {string} args.topic - human-readable topic line
34
+ * @param {string} args.body - the FINDINGS / INFERENCES / GAPS / RECOMMENDATION block
35
+ * @param {string} [args.confidence] - confirmed | inferred | weak
36
+ * @param {Array<{url:string,accessedAt?:string,span?:string}>} [args.sources]
37
+ * @param {number} [args.ttlDays] - override default expiry
38
+ * @returns {Promise<{path:string,bytes:number}>}
39
+ */
40
+ export async function addResearchFinding({
41
+ cwd,
42
+ slug,
43
+ topic,
44
+ body,
45
+ confidence = 'inferred',
46
+ sources = [],
47
+ ttlDays = DEFAULT_TTL_DAYS,
48
+ }) {
49
+ if (!cwd) throw new Error('addResearchFinding: cwd is required');
50
+ if (!slug || !/^[a-z0-9][a-z0-9-]{0,60}$/.test(slug)) {
51
+ throw new Error('addResearchFinding: slug must be lowercase, hyphenated, max 60 chars');
52
+ }
53
+ if (!topic || typeof topic !== 'string') {
54
+ throw new Error('addResearchFinding: topic is required');
55
+ }
56
+ if (!body || typeof body !== 'string') {
57
+ throw new Error('addResearchFinding: body is required');
58
+ }
59
+ if (!VALID_CONFIDENCE.has(confidence)) {
60
+ throw new Error(`addResearchFinding: confidence must be one of ${Array.from(VALID_CONFIDENCE).join(', ')}`);
61
+ }
62
+ if (confidence === 'confirmed' && (!Array.isArray(sources) || sources.length === 0)) {
63
+ throw new Error('addResearchFinding: confidence=confirmed requires at least one source');
64
+ }
65
+
66
+ const profile = resolveActiveProfile(cwd);
67
+ const now = new Date();
68
+ const expiresAt = new Date(now.getTime() + ttlDays * 24 * 60 * 60 * 1000).toISOString();
69
+
70
+ const outDir = join(cwd, ROOT);
71
+ mkdirSync(outDir, { recursive: true });
72
+ const outPath = join(outDir, `${slug}.md`);
73
+
74
+ const fmSources = (Array.isArray(sources) ? sources : [])
75
+ .filter((s) => s && typeof s.url === 'string')
76
+ .map((s) => ({
77
+ url: s.url,
78
+ accessedAt: s.accessedAt || now.toISOString(),
79
+ ...(s.span ? { span: String(s.span).slice(0, 200) } : {}),
80
+ }));
81
+
82
+ const frontmatter = [
83
+ '---',
84
+ 'kind: research-finding',
85
+ `topic: ${JSON.stringify(topic)}`,
86
+ `confidence: ${confidence}`,
87
+ `sources: ${JSON.stringify(fmSources)}`,
88
+ `created: ${now.toISOString()}`,
89
+ `expiresAt: ${expiresAt}`,
90
+ `profile: ${profile?.id ?? 'rnd'}`,
91
+ '---',
92
+ '',
93
+ ].join('\n');
94
+
95
+ const fullText = frontmatter + body.trim() + '\n';
96
+ if (Buffer.byteLength(fullText, 'utf8') > MAX_BYTES) {
97
+ throw new Error(`addResearchFinding: file exceeds ${MAX_BYTES} bytes`);
98
+ }
99
+
100
+ writeFileSync(outPath, fullText);
101
+
102
+ // Best-effort sync into SQL/vector index. The file is the source of truth;
103
+ // index lag never blocks the operator.
104
+ try {
105
+ await syncFileStateToSql(cwd, { project: profile?.id ?? 'rnd' });
106
+ } catch { /* best effort */ }
107
+
108
+ return { path: outPath, bytes: Buffer.byteLength(fullText, 'utf8') };
109
+ }
@@ -2,12 +2,13 @@
2
2
  * lib/mcp/tools/telemetry.mjs — Telemetry MCP tools: trace/score, session usage, efficiency snapshot.
3
3
  *
4
4
  * Exposes cxTrace, cxScore, sessionUsage, and efficiencySnapshot.
5
- * Requires ROOT_DIR injected via opts. Telemetry credentials must be in env.
5
+ * Requires ROOT_DIR injected via opts. Remote export uses the shared telemetry
6
+ * adapter when configured; local JSONL remains available by default.
6
7
  */
7
8
  import { join, resolve } from 'node:path';
8
9
  import { homedir } from 'node:os';
9
10
  import { readFileSync, existsSync } from 'node:fs';
10
- import { isAvailable as telemetryAvailable } from '../../telemetry/backends/remote.mjs';
11
+ import { createTelemetryClient } from '../../telemetry/client.mjs';
11
12
  import { summarizePromptComposition } from '../../prompt-composer.js';
12
13
  import { enrichMetadataWithPrompt } from '../../prompt-metadata.mjs';
13
14
  import { readCurrentModels, resolveExecutionContractModelMetadata, selectModelTierForWorkCategory } from '../../model-router.mjs';
@@ -16,7 +17,6 @@ import { loadWorkflow } from '../../workflow-state.mjs';
16
17
  import { buildStatus } from '../../status.mjs';
17
18
  import { readEfficiencyLog, buildCompactEfficiencyDigest } from '../../efficiency.mjs';
18
19
  import { addObservation } from '../../observation-store.mjs';
19
- // Local mock ingest client for fallback when ingestion service is unavailable
20
20
  import { loadConstructEnv } from '../../env-config.mjs';
21
21
 
22
22
  // Load config.env once at module init so config.env values win over shell env
@@ -34,15 +34,6 @@ function readJSON(filePath) {
34
34
 
35
35
  import { execSync as _execSync } from 'node:child_process';
36
36
 
37
- // Mock ingest client for fallback to direct API when the real client is not available.
38
-
39
- function getOrCreateIngestClient() {
40
- return {
41
- available: false,
42
- trace: () => {}
43
- };
44
- }
45
-
46
37
  function resolveReleaseTag(cwd) {
47
38
  try {
48
39
  return _execSync('git rev-parse --short HEAD', { stdio: 'pipe', cwd, timeout: 2000 }).toString().trim() || undefined;
@@ -51,18 +42,8 @@ function resolveReleaseTag(cwd) {
51
42
  }
52
43
  }
53
44
 
54
- function telemetryHeaders() {
55
- const key = CONF_ENV.CONSTRUCT_TELEMETRY_PUBLIC_KEY ?? process.env.CONSTRUCT_TELEMETRY_PUBLIC_KEY;
56
- const secret = CONF_ENV.CONSTRUCT_TELEMETRY_SECRET_KEY ?? process.env.CONSTRUCT_TELEMETRY_SECRET_KEY;
57
- if (!key || !secret) throw new Error('CONSTRUCT_TELEMETRY_PUBLIC_KEY and CONSTRUCT_TELEMETRY_SECRET_KEY must be set.');
58
- return {
59
- Authorization: `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`,
60
- 'Content-Type': 'application/json',
61
- };
62
- }
63
-
64
- function telemetryBaseUrl() {
65
- return (CONF_ENV.CONSTRUCT_TELEMETRY_URL ?? process.env.CONSTRUCT_TELEMETRY_URL ?? '').replace(/\/$/, '');
45
+ function telemetryEnv() {
46
+ return { ...process.env, ...CONF_ENV };
66
47
  }
67
48
 
68
49
  function resolveSessionContext() {
@@ -128,10 +109,8 @@ export async function cxTrace(args, { ROOT_DIR }) {
128
109
  }, { rootDir: ROOT_DIR });
129
110
  const traceId = args.id ?? crypto.randomUUID();
130
111
  try {
131
- const available = await telemetryAvailable();
132
- if (!available) return { ok: false, error: 'Telemetry credentials not configured', id: traceId };
133
112
  const teamId = args.metadata?.teamId ?? metadata.teamId;
134
- const workCategoryValue = route?.workCategory ?? (typeof args.input === 'string' ? classifyWorkCategory({ request: args.input }) : null);
113
+ const workCategoryValue = route?.workCategory ?? null;
135
114
  const body = {
136
115
  id: traceId,
137
116
  name: args.name,
@@ -156,27 +135,17 @@ export async function cxTrace(args, { ROOT_DIR }) {
156
135
  release: ctx.release,
157
136
  };
158
137
 
159
- // Add version to metadata for test compatibility
160
- body.metadata.version = 'v1';
161
-
162
- // Use ingestion batch for creation (handles large payloads better than direct API)
163
- const ingestClient = getOrCreateIngestClient();
164
- if (ingestClient?.available) {
165
- ingestClient.trace(body);
166
- return { ok: true, id: traceId };
167
- }
138
+ body.metadata.version = 'v1';
168
139
 
169
- // Fallback: direct API call
170
- const res = await fetch(`${telemetryBaseUrl()}/api/public/traces`, {
171
- method: 'POST',
172
- headers: telemetryHeaders(),
173
- body: JSON.stringify(body),
174
- });
175
- if (!res.ok) {
176
- const text = await res.text().catch(() => '');
177
- return { ok: false, error: `Telemetry API error ${res.status}: ${text}`, id: traceId };
178
- }
179
- return { ok: true, id: traceId };
140
+ const client = createTelemetryClient({ env: telemetryEnv(), rootDir: ROOT_DIR });
141
+ client.trace(body);
142
+ await client.flush();
143
+ return {
144
+ ok: true,
145
+ id: traceId,
146
+ backend: client.backend,
147
+ remoteStatus: client.remoteStatus,
148
+ };
180
149
  } catch (err) {
181
150
  return { ok: false, error: err.message, id: traceId };
182
151
  }
@@ -193,25 +162,16 @@ export async function cxTraceUpdate(args) {
193
162
  const metadata = args.metadata;
194
163
 
195
164
  try {
196
- const available = await telemetryAvailable();
197
- if (!available) return { ok: false, error: 'Telemetry credentials not configured' };
198
-
199
- const res = await fetch(`${telemetryBaseUrl()}/api/public/traces/${traceId}`, {
200
- method: 'PATCH',
201
- headers: telemetryHeaders(),
202
- body: JSON.stringify({
203
- output,
204
- metadata: {
205
- ...(metadata && typeof metadata === 'object' ? metadata : {}),
206
- traceUpdatedAt: new Date().toISOString(),
207
- },
208
- }),
165
+ const client = createTelemetryClient({ env: telemetryEnv(), rootDir: process.cwd() });
166
+ client.traceUpdate({
167
+ id: traceId,
168
+ output,
169
+ metadata: {
170
+ ...(metadata && typeof metadata === 'object' ? metadata : {}),
171
+ traceUpdatedAt: new Date().toISOString(),
172
+ },
209
173
  });
210
-
211
- if (!res.ok) {
212
- const text = await res.text().catch(() => '');
213
- return { ok: false, error: `Telemetry PATCH error ${res.status}: ${text}` };
214
- }
174
+ await client.flush();
215
175
 
216
176
  // Also record as observation for local learning
217
177
  if (output || metadata) {
@@ -227,7 +187,7 @@ export async function cxTraceUpdate(args) {
227
187
  });
228
188
  }
229
189
 
230
- return { ok: true, traceId };
190
+ return { ok: true, traceId, backend: client.backend, remoteStatus: client.remoteStatus };
231
191
  } catch (err) {
232
192
  return { ok: false, error: err.message };
233
193
  }
@@ -240,8 +200,6 @@ const SCORE_GOOD_THRESHOLD = 0.85; // at or above this → record positive pat
240
200
  export async function cxScore(args) {
241
201
  const traceId = args.trace_id ?? '';
242
202
  try {
243
- const available = await telemetryAvailable();
244
- if (!available) return { ok: false, error: 'Telemetry credentials not configured' };
245
203
  const body = {
246
204
  id: crypto.randomUUID(),
247
205
  traceId,
@@ -250,15 +208,9 @@ export async function cxScore(args) {
250
208
  dataType: 'NUMERIC',
251
209
  comment: args.comment,
252
210
  };
253
- const res = await fetch(`${telemetryBaseUrl()}/api/public/scores`, {
254
- method: 'POST',
255
- headers: telemetryHeaders(),
256
- body: JSON.stringify(body),
257
- });
258
- if (!res.ok) {
259
- const text = await res.text().catch(() => '');
260
- return { ok: false, error: `Telemetry API error ${res.status}: ${text}` };
261
- }
211
+ const client = createTelemetryClient({ env: telemetryEnv(), rootDir: process.cwd() });
212
+ client.score(body);
213
+ await client.flush();
262
214
 
263
215
  // Feed score back into the local observation store so future agents learn from it.
264
216
  // Low scores generate anti-pattern observations; high scores reinforce positive patterns.
@@ -291,7 +243,7 @@ export async function cxScore(args) {
291
243
  }
292
244
  }
293
245
 
294
- return { ok: true, traceId };
246
+ return { ok: true, traceId, backend: client.backend, remoteStatus: client.remoteStatus };
295
247
  } catch (err) {
296
248
  return { ok: false, error: err.message };
297
249
  }
@@ -45,6 +45,61 @@ export const MODEL_TIER_BY_WORK_CATEGORY = {
45
45
  analysis: "standard",
46
46
  };
47
47
 
48
+ export const MODEL_OPERATING_PROFILES = Object.freeze({
49
+ balanced: {
50
+ id: 'balanced',
51
+ label: 'Balanced',
52
+ maxPromptTokens: 3000,
53
+ learnedPatternsTokens: 200,
54
+ taskPacketTokens: 150,
55
+ contextDigestTokens: 200,
56
+ hostConstraintsTokens: 75,
57
+ roleFlavorTokens: 600,
58
+ retrievalFirst: false,
59
+ preferCompressedRoleGuidance: false,
60
+ },
61
+ small: {
62
+ id: 'small',
63
+ label: 'Small-model',
64
+ maxPromptTokens: 1800,
65
+ learnedPatternsTokens: 120,
66
+ taskPacketTokens: 110,
67
+ contextDigestTokens: 120,
68
+ hostConstraintsTokens: 40,
69
+ roleFlavorTokens: 280,
70
+ retrievalFirst: true,
71
+ preferCompressedRoleGuidance: true,
72
+ },
73
+ });
74
+
75
+ function normalizeModelOperatingProfile(value) {
76
+ const normalized = String(value || '').trim().toLowerCase();
77
+ if (!normalized) return null;
78
+ if (normalized === 'default') return 'balanced';
79
+ return MODEL_OPERATING_PROFILES[normalized] ? normalized : null;
80
+ }
81
+
82
+ function inferSmallModelProfile(selectedModel) {
83
+ const model = String(selectedModel || '').toLowerCase();
84
+ if (!model) return false;
85
+ if (/^(ollama|local)\//.test(model) && /(?:[:/-])(3b|7b|8b|13b|14b|32b)\b/.test(model)) return true;
86
+ if (/^(anthropic|openrouter\/anthropic)\/.*haiku/.test(model)) return true;
87
+ if (/gpt-5\.1-mini|gemma-3|gemma-4|phi3:mini/.test(model)) return true;
88
+ return false;
89
+ }
90
+
91
+ export function resolveModelOperatingProfile({
92
+ envValues = {},
93
+ selectedModel = null,
94
+ } = {}) {
95
+ const explicit = normalizeModelOperatingProfile(
96
+ envValues.CONSTRUCT_MODEL_PROFILE ?? envValues.constructModelProfile
97
+ );
98
+ if (explicit) return MODEL_OPERATING_PROFILES[explicit];
99
+ if (inferSmallModelProfile(selectedModel)) return MODEL_OPERATING_PROFILES.small;
100
+ return MODEL_OPERATING_PROFILES.balanced;
101
+ }
102
+
48
103
  /**
49
104
  * Provider-family definitions. Each entry contains:
50
105
  * - `test`: RegExp that matches provider URLs.
@@ -229,7 +284,7 @@ const PROVIDER_ENV_MAP = {
229
284
  'github-copilot': ['GITHUB_TOKEN', 'GH_TOKEN'],
230
285
  'openai': ['OPENAI_API_KEY'],
231
286
  'openrouter-llama': ['OPENROUTER_API_KEY', 'OPEN_ROUTER_API_KEY'],
232
- 'ollama': ['OLLAMA_BASE_URL'],
287
+ 'ollama': ['OLLAMA_BASE_URL', 'OLLAMA_HOST'],
233
288
  'local': ['LOCAL_LLM_BASE_URL'],
234
289
  };
235
290
 
@@ -630,6 +685,10 @@ export function resolveExecutionContractModelMetadata({
630
685
  const tiers = resolveTierAssignments(envValues, registryModels);
631
686
  const selectedTier = requestedTier ?? selectModelTierForWorkCategory(workCategory);
632
687
  const selected = selectedTier ? tiers[selectedTier] : null;
688
+ const profile = resolveModelOperatingProfile({
689
+ envValues,
690
+ selectedModel: selected?.model ?? null,
691
+ });
633
692
 
634
693
  return {
635
694
  version: "v1",
@@ -638,6 +697,7 @@ export function resolveExecutionContractModelMetadata({
638
697
  selectedTier: selectedTier ?? null,
639
698
  selectedModel: selected?.model ?? null,
640
699
  selectedModelSource: selected?.source ?? null,
700
+ profile,
641
701
  tiers,
642
702
  };
643
703
  }
@@ -58,6 +58,20 @@ function generateId() {
58
58
  return `obs-${ts}-${rand}`;
59
59
  }
60
60
 
61
+ const MAX_EXTRAS_BYTES = 2 * 1024;
62
+
63
+ function sanitizeExtras(extras) {
64
+ if (extras == null) return null;
65
+ if (typeof extras !== 'object' || Array.isArray(extras)) return null;
66
+ try {
67
+ const serialized = JSON.stringify(extras);
68
+ if (Buffer.byteLength(serialized, 'utf8') > MAX_EXTRAS_BYTES) return null;
69
+ return JSON.parse(serialized);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
61
75
  function clamp(str, max) {
62
76
  if (!str || str.length <= max) return str || null;
63
77
  return str.slice(0, max - 1) + '\u2026';
@@ -131,6 +145,7 @@ export async function addObservation(rootDir, {
131
145
  confidence = 0.8,
132
146
  source = null,
133
147
  gitSha = null,
148
+ extras = null,
134
149
  } = {}) {
135
150
  const id = generateId();
136
151
  const now = new Date().toISOString();
@@ -139,6 +154,9 @@ export async function addObservation(rootDir, {
139
154
  const clampedSummary = clamp(String(summary), MAX_SUMMARY);
140
155
  const clampedContent = clamp(String(content), MAX_CONTENT);
141
156
  const clampedTags = (Array.isArray(tags) ? tags : []).slice(0, MAX_TAGS).map(String);
157
+ // Structured metadata: opt-in. Capped at 2 KB stringified so a misuse can't
158
+ // bloat the per-id JSON. Plain object only; rejects arrays and functions.
159
+ const clampedExtras = sanitizeExtras(extras);
142
160
 
143
161
  const record = {
144
162
  id,
@@ -151,6 +169,7 @@ export async function addObservation(rootDir, {
151
169
  confidence: Math.max(0, Math.min(1, Number(confidence) || 0.8)),
152
170
  source: source || null,
153
171
  gitSha: gitSha ? String(gitSha).slice(0, 40) : null,
172
+ extras: clampedExtras,
154
173
  createdAt: now,
155
174
  updatedAt: now,
156
175
  };
@@ -10,7 +10,7 @@ import { execSync } from 'node:child_process';
10
10
  import { join } from 'node:path';
11
11
  import { homedir } from 'node:os';
12
12
 
13
- const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
13
+ const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || process.env.OLLAMA_HOST || 'http://localhost:11434';
14
14
 
15
15
  /**
16
16
  * Check if Ollama is installed and running
@@ -5,7 +5,7 @@
5
5
  * trace/generation/span/event observations, and flushes on session.idle.
6
6
  */
7
7
  import { randomUUID } from "node:crypto";
8
- import { createIngestClient } from "./telemetry/ingest.mjs";
8
+ import { createTelemetryClient } from "./telemetry/client.mjs";
9
9
 
10
10
  const SECRET_PATH_PATTERNS = [
11
11
  /(^|\/)\.env(\.|$)/i,
@@ -24,10 +24,9 @@ let cachedIngest = null;
24
24
 
25
25
  export function getIngestClient(env = process.env) {
26
26
  if (cachedIngest) return cachedIngest;
27
- cachedIngest = createIngestClient({
28
- baseUrl: env.CONSTRUCT_TELEMETRY_URL,
29
- publicKey: env.CONSTRUCT_TELEMETRY_PUBLIC_KEY,
30
- secretKey: env.CONSTRUCT_TELEMETRY_SECRET_KEY,
27
+ cachedIngest = createTelemetryClient({
28
+ env,
29
+ rootDir: env.CX_TOOLKIT_DIR,
31
30
  onError: (err) => {
32
31
  if (env.CONSTRUCT_TRACE_DEBUG === "1") {
33
32
  // eslint-disable-next-line no-console