@geraldmaron/construct 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +51 -42
  2. package/agents/prompts/cx-ai-engineer.md +6 -26
  3. package/agents/prompts/cx-architect.md +1 -0
  4. package/agents/prompts/cx-business-strategist.md +2 -0
  5. package/agents/prompts/cx-data-analyst.md +6 -26
  6. package/agents/prompts/cx-docs-keeper.md +1 -31
  7. package/agents/prompts/cx-explorer.md +1 -0
  8. package/agents/prompts/cx-orchestrator.md +40 -112
  9. package/agents/prompts/cx-platform-engineer.md +2 -22
  10. package/agents/prompts/cx-product-manager.md +2 -1
  11. package/agents/prompts/cx-qa.md +0 -20
  12. package/agents/prompts/cx-rd-lead.md +2 -0
  13. package/agents/prompts/cx-researcher.md +77 -31
  14. package/agents/prompts/cx-security.md +11 -49
  15. package/agents/prompts/cx-sre.md +9 -43
  16. package/agents/prompts/cx-ux-researcher.md +1 -0
  17. package/agents/role-manifests.json +4 -4
  18. package/bin/construct +279 -4
  19. package/commands/understand/research.md +5 -3
  20. package/db/schema/004_recommendations.sql +46 -0
  21. package/db/schema/005_strategy.sql +21 -0
  22. package/lib/auto-docs.mjs +3 -4
  23. package/lib/beads-automation.mjs +16 -7
  24. package/lib/cli-commands.mjs +52 -2
  25. package/lib/comment-lint.mjs +7 -1
  26. package/lib/config/schema.mjs +3 -0
  27. package/lib/embed/conflict-detection.mjs +26 -9
  28. package/lib/embed/customer-profiles.mjs +37 -17
  29. package/lib/embed/daemon.mjs +10 -8
  30. package/lib/embed/recommendation-store.mjs +213 -14
  31. package/lib/embed/workspaces.mjs +53 -18
  32. package/lib/flavors/loader.mjs +136 -0
  33. package/lib/gates-audit.mjs +3 -3
  34. package/lib/health-check.mjs +1 -1
  35. package/lib/hooks/agent-tracker.mjs +22 -3
  36. package/lib/hooks/pre-compact.mjs +3 -0
  37. package/lib/hooks/pre-push-gate.mjs +14 -1
  38. package/lib/hooks/read-tracker.mjs +10 -101
  39. package/lib/hooks/session-optimize.mjs +3 -2
  40. package/lib/hooks/session-reflect.mjs +68 -0
  41. package/lib/host-capabilities.mjs +90 -1
  42. package/lib/init-unified.mjs +25 -2
  43. package/lib/init-update.mjs +246 -131
  44. package/lib/intake/classify.mjs +61 -183
  45. package/lib/intake/prepare.mjs +7 -0
  46. package/lib/intake/tables/creative.mjs +94 -0
  47. package/lib/intake/tables/operations.mjs +85 -0
  48. package/lib/intake/tables/research.mjs +85 -0
  49. package/lib/intake/tables/rnd.mjs +175 -0
  50. package/lib/intent-classifier.mjs +1 -0
  51. package/lib/knowledge/layout.mjs +10 -0
  52. package/lib/knowledge/research-store.mjs +109 -0
  53. package/lib/mcp/tools/telemetry.mjs +30 -78
  54. package/lib/model-router.mjs +61 -1
  55. package/lib/observation-store.mjs +19 -0
  56. package/lib/ollama-manager.mjs +1 -1
  57. package/lib/opencode-telemetry.mjs +4 -5
  58. package/lib/orchestration-policy.mjs +9 -0
  59. package/lib/outcomes/aggregate.mjs +104 -0
  60. package/lib/outcomes/record.mjs +115 -0
  61. package/lib/parity.mjs +121 -21
  62. package/lib/profiles/lifecycle.mjs +388 -0
  63. package/lib/profiles/loader.mjs +123 -0
  64. package/lib/profiles/validate-custom.mjs +114 -0
  65. package/lib/prompt-composer.js +106 -29
  66. package/lib/read-tracker-store.mjs +149 -0
  67. package/lib/reflect/extractor.mjs +193 -0
  68. package/lib/reflect.mjs +89 -2
  69. package/lib/sandbox.mjs +102 -0
  70. package/lib/server/index.mjs +76 -0
  71. package/lib/server/telemetry-login.mjs +17 -25
  72. package/lib/service-manager.mjs +30 -22
  73. package/lib/services/local-postgres.mjs +15 -0
  74. package/lib/services/telemetry-backend.mjs +1 -2
  75. package/lib/setup.mjs +8 -43
  76. package/lib/status.mjs +51 -5
  77. package/lib/storage/backend.mjs +12 -2
  78. package/lib/strategy-store.mjs +371 -0
  79. package/lib/telemetry/backends/local.mjs +6 -4
  80. package/lib/telemetry/client.mjs +185 -0
  81. package/lib/telemetry/ingest.mjs +13 -5
  82. package/lib/telemetry/team-rollup.mjs +9 -2
  83. package/lib/worker/trace.mjs +17 -27
  84. package/package.json +10 -2
  85. package/personas/construct.md +20 -20
  86. package/platforms/claude/settings.template.json +13 -0
  87. package/rules/common/research.md +44 -12
  88. package/scripts/sync-agents.mjs +11 -0
  89. package/skills/docs/backlog-proposal-workflow.md +2 -2
  90. package/skills/docs/customer-profile-workflow.md +1 -1
  91. package/skills/docs/evidence-ingest-workflow.md +5 -5
  92. package/skills/docs/prfaq-workflow.md +1 -1
  93. package/skills/docs/product-intelligence-review.md +1 -1
  94. package/skills/docs/product-intelligence-workflow.md +3 -3
  95. package/skills/docs/product-signal-workflow.md +48 -18
  96. package/skills/docs/research-workflow.md +26 -14
  97. package/skills/docs/strategy-workflow.md +36 -0
  98. package/skills/roles/architect.ai-systems.md +4 -2
  99. package/skills/roles/architect.data.md +4 -2
  100. package/skills/roles/architect.enterprise.md +4 -2
  101. package/skills/roles/architect.integration.md +4 -2
  102. package/skills/roles/architect.md +7 -5
  103. package/skills/roles/architect.platform.md +4 -2
  104. package/skills/roles/data-analyst.experiment.md +4 -2
  105. package/skills/roles/data-analyst.md +9 -7
  106. package/skills/roles/data-analyst.product-intelligence.md +5 -3
  107. package/skills/roles/data-analyst.product.md +4 -2
  108. package/skills/roles/data-analyst.telemetry.md +4 -2
  109. package/skills/roles/data-engineer.pipeline.md +4 -2
  110. package/skills/roles/data-engineer.vector-retrieval.md +4 -2
  111. package/skills/roles/data-engineer.warehouse.md +4 -2
  112. package/skills/roles/debugger.md +7 -5
  113. package/skills/roles/designer.accessibility.md +4 -2
  114. package/skills/roles/designer.md +10 -8
  115. package/skills/roles/engineer.ai.md +4 -2
  116. package/skills/roles/engineer.data.md +5 -3
  117. package/skills/roles/engineer.md +14 -12
  118. package/skills/roles/engineer.platform.md +5 -3
  119. package/skills/roles/operator.docs.md +6 -4
  120. package/skills/roles/operator.md +6 -4
  121. package/skills/roles/operator.release.md +4 -2
  122. package/skills/roles/operator.sre.md +5 -3
  123. package/skills/roles/orchestrator.md +5 -3
  124. package/skills/roles/product-manager.ai-product.md +4 -2
  125. package/skills/roles/product-manager.business-strategy.md +4 -2
  126. package/skills/roles/product-manager.enterprise.md +4 -2
  127. package/skills/roles/product-manager.growth.md +4 -2
  128. package/skills/roles/product-manager.md +6 -4
  129. package/skills/roles/product-manager.platform.md +4 -2
  130. package/skills/roles/product-manager.product.md +4 -2
  131. package/skills/roles/qa.ai-eval.md +4 -2
  132. package/skills/roles/qa.api-contract.md +4 -2
  133. package/skills/roles/qa.data-pipeline.md +4 -2
  134. package/skills/roles/qa.md +7 -5
  135. package/skills/roles/qa.test-automation.md +5 -3
  136. package/skills/roles/qa.web-ui.md +4 -2
  137. package/skills/roles/researcher.explorer.md +4 -2
  138. package/skills/roles/researcher.md +35 -20
  139. package/skills/roles/researcher.ux.md +4 -2
  140. package/skills/roles/reviewer.devil-advocate.md +4 -2
  141. package/skills/roles/reviewer.evaluator.md +4 -2
  142. package/skills/roles/reviewer.md +14 -12
  143. package/skills/roles/reviewer.trace.md +4 -2
  144. package/skills/roles/security.ai.md +4 -2
  145. package/skills/roles/security.appsec.md +4 -2
  146. package/skills/roles/security.cloud.md +4 -2
  147. package/skills/roles/security.legal-compliance.md +4 -2
  148. package/skills/roles/security.md +7 -5
  149. package/skills/roles/security.privacy.md +4 -2
  150. package/skills/roles/security.supply-chain.md +4 -2
  151. package/skills/routing.md +8 -1
  152. package/templates/docs/persona-artifact.md +36 -0
  153. package/templates/docs/research-brief.md +63 -9
  154. package/templates/docs/research-finding.md +26 -0
  155. package/templates/docs/skill-artifact.md +27 -0
  156. package/templates/docs/strategy.md +36 -0
  157. package/templates/homebrew/construct.rb +6 -6
package/lib/status.mjs CHANGED
@@ -24,6 +24,7 @@ import { describeSqlStore, sqlStoreHealth } from './storage/sql-store.mjs';
24
24
  import { describeVectorStore } from './storage/vector-store.mjs';
25
25
  import { describeSqlStoreHealth } from './storage/sql-store.mjs';
26
26
  import { triggerAutoBackfillIfSparse } from './telemetry/backfill.mjs';
27
+ import { resolveTraceBackend, telemetryProviderLabel } from './telemetry/client.mjs';
27
28
  const TOTAL_BYTES_WARNING_THRESHOLD = 750_000;
28
29
 
29
30
  function readJSON(path) {
@@ -317,18 +318,27 @@ async function defaultProbeService(service) {
317
318
  }
318
319
 
319
320
  async function fetchTelemetryStatus(env, { timeout = 2500 } = {}) {
321
+ const backend = resolveTraceBackend(env);
322
+ if (backend === 'local') return { status: 'healthy', summary: 'Local JSONL tracing enabled · remote export not configured', backend, provider: telemetryProviderLabel(env) };
323
+ if (backend === 'none') return { status: 'disabled', summary: 'Remote telemetry disabled · local JSONL tracing preserved', backend, provider: telemetryProviderLabel(env) };
324
+ if (backend === 'otel') {
325
+ const endpoint = (env.CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT ?? '').replace(/\/$/, '');
326
+ if (!endpoint) return { status: 'unavailable', summary: 'OTLP endpoint not configured', backend, provider: telemetryProviderLabel(env) };
327
+ return { status: 'configured', summary: `OTLP export configured · ${endpoint}`, backend, provider: telemetryProviderLabel(env) };
328
+ }
320
329
  const baseUrl = (env.CONSTRUCT_TELEMETRY_URL ?? '').replace(/\/$/, '');
321
330
  const key = env.CONSTRUCT_TELEMETRY_PUBLIC_KEY;
322
331
  const secret = env.CONSTRUCT_TELEMETRY_SECRET_KEY;
323
- if (!key || !secret) return { status: 'unavailable', summary: 'Telemetry credentials not configured' };
332
+ if (backend === 'langfuse' && (!key || !secret)) return { status: 'unavailable', summary: 'Langfuse credentials not configured', backend, provider: telemetryProviderLabel(env) };
324
333
  if (!baseUrl) return { status: 'unavailable', summary: 'CONSTRUCT_TELEMETRY_URL not set' };
325
334
  const headers = {
326
- Authorization: `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`,
335
+ ...(key && secret ? { Authorization: `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}` } : {}),
327
336
  };
328
337
  const controller = new AbortController();
329
338
  const timer = setTimeout(() => controller.abort(), timeout);
330
339
  try {
331
- const res = await fetch(`${baseUrl}/api/public/traces?limit=25`, { headers, signal: controller.signal });
340
+ const tracesPath = backend === 'http' ? '/traces?limit=25' : '/api/public/traces?limit=25';
341
+ const res = await fetch(`${baseUrl}${tracesPath}`, { headers, signal: controller.signal });
332
342
  if (res.status === 401 || res.status === 403) {
333
343
  return { status: 'credentials-invalid', summary: `Telemetry credentials rejected (HTTP ${res.status}) — run: construct init` };
334
344
  }
@@ -359,6 +369,8 @@ async function fetchTelemetryStatus(env, { timeout = 2500 } = {}) {
359
369
 
360
370
  return {
361
371
  status,
372
+ backend,
373
+ provider: telemetryProviderLabel(env),
362
374
  total,
363
375
  rich: counts.rich,
364
376
  partial: counts.partial,
@@ -478,6 +490,40 @@ export function buildPublicHealthSurface({
478
490
  }
479
491
 
480
492
  function traceBackendDefinition(env) {
493
+ const backend = resolveTraceBackend(env);
494
+ const provider = telemetryProviderLabel(env);
495
+ if (backend === 'local' || backend === 'none') {
496
+ return {
497
+ id: 'telemetry',
498
+ name: 'Telemetry',
499
+ url: 'local://.cx/traces',
500
+ runtime: 'local',
501
+ note: backend === 'none' ? 'Remote disabled; local JSONL preserved' : 'Local JSONL traces',
502
+ healthyMessage: 'Local trace capture enabled',
503
+ impactsOverall: false,
504
+ selfCheck: {
505
+ status: backend === 'none' ? 'disabled' : 'healthy',
506
+ message: backend === 'none' ? 'Remote telemetry disabled' : 'Writing .cx/traces/*.jsonl',
507
+ },
508
+ };
509
+ }
510
+ if (backend === 'otel') {
511
+ const endpoint = (env.CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT ?? '').replace(/\/$/, '');
512
+ return {
513
+ id: 'telemetry',
514
+ name: 'Telemetry',
515
+ url: endpoint || '(not configured)',
516
+ probeUrl: '',
517
+ runtime: 'remote',
518
+ note: `${provider} trace export`,
519
+ healthyMessage: 'Configured',
520
+ impactsOverall: false,
521
+ selfCheck: {
522
+ status: endpoint ? 'configured' : 'unavailable',
523
+ message: endpoint ? `OTLP export configured: ${endpoint}` : 'CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT not set',
524
+ },
525
+ };
526
+ }
481
527
  const url = (env.CONSTRUCT_TELEMETRY_URL ?? '').replace(/\/$/, '');
482
528
  const pubKey = env.CONSTRUCT_TELEMETRY_PUBLIC_KEY || '';
483
529
  const secKey = env.CONSTRUCT_TELEMETRY_SECRET_KEY || '';
@@ -486,10 +532,10 @@ function traceBackendDefinition(env) {
486
532
  id: 'telemetry',
487
533
  name: 'Telemetry',
488
534
  url: url || '(not configured)',
489
- probeUrl: url ? `${url}/api/public/traces?limit=1` : '',
535
+ probeUrl: url ? `${url}${backend === 'http' ? '/traces?limit=1' : '/api/public/traces?limit=1'}` : '',
490
536
  probeHeaders: auth ? { Authorization: auth } : undefined,
491
537
  runtime: 'live',
492
- note: 'Trace backend',
538
+ note: `${provider} trace export`,
493
539
  healthyMessage: 'Reachable',
494
540
  impactsOverall: false,
495
541
  };
@@ -1,17 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * lib/storage/backend.mjs — shared storage backend helpers for SQL/vector wiring.
4
+ *
5
+ * postgres is an optional peer dep. Top-level await catches the case where it is not
6
+ * installed (fresh installs, test temp dirs) so the module loads cleanly everywhere.
4
7
  */
5
- import postgres from 'postgres';
6
8
  import { resolveDatabaseUrl } from '../env-config.mjs';
7
9
 
10
+ let postgres = null;
11
+ try {
12
+ const mod = await import('postgres');
13
+ postgres = mod.default ?? mod;
14
+ } catch {
15
+ // postgres not installed — SQL features unavailable; createSqlClient returns null
16
+ }
17
+
8
18
  function cleanUrl(url) {
9
19
  return String(url || '').trim();
10
20
  }
11
21
 
12
22
  export function createSqlClient(env = process.env) {
13
23
  const databaseUrl = cleanUrl(resolveDatabaseUrl(env));
14
- if (!databaseUrl) return null;
24
+ if (!databaseUrl || !postgres) return null;
15
25
  return postgres(databaseUrl, {
16
26
  max: Number.parseInt(env.CONSTRUCT_DB_POOL_SIZE || '5', 10),
17
27
  idle_timeout: Number.parseInt(env.CONSTRUCT_DB_IDLE_TIMEOUT_MS || '30000', 10),
@@ -0,0 +1,371 @@
1
+ /**
2
+ * lib/strategy-store.mjs — Multi-scope product strategy store.
3
+ *
4
+ * Strategy is organised by scope: product, technical, gtm, platform.
5
+ * Each scope lives in its own file under .cx/knowledge/decisions/strategy/{scope}.md.
6
+ * Strategy documents are NEVER auto-updated from ingested documents — they require
7
+ * an explicit writeStrategy() call from a privileged caller.
8
+ *
9
+ * In team/enterprise mode Postgres is the secondary store (best-effort). Each scope
10
+ * is stored as a separate row whose content is prefixed with `scope:{name}\n` so we
11
+ * can store and retrieve scopes without adding a new column to the existing schema.
12
+ *
13
+ * getStrategyDigest() / getStrategyDigestSync() return a compact block (≤ 500 tokens)
14
+ * suitable for injection into agent prompts. getStrategyDigestSync() is the file-only
15
+ * synchronous path required where await is not available (e.g. prompt assembly).
16
+ */
17
+
18
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
19
+ import { join, basename } from 'node:path';
20
+ import { cxDir } from './paths.mjs';
21
+ import { hasSqlStore } from './storage/sql-store.mjs';
22
+ import { createSqlClient } from './storage/backend.mjs';
23
+
24
+ const VALID_SCOPES = ['product', 'technical', 'gtm', 'platform'];
25
+ const SCOPE_PREFIX = 'scope:';
26
+
27
+ // ── Path helpers ──────────────────────────────────────────────────────────────
28
+
29
+ export function strategyDir(env = process.env) {
30
+ return join(cxDir(), 'knowledge', 'decisions', 'strategy');
31
+ }
32
+
33
+ export function strategyFilePath(scope = 'product', env = process.env) {
34
+ return join(strategyDir(env), `${scope}.md`);
35
+ }
36
+
37
+ // ── Scope discovery ───────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Return scope names that have corresponding files in the strategy directory.
41
+ *
42
+ * @param {object} [env]
43
+ * @returns {string[]}
44
+ */
45
+ export function listStrategyScopes(env = process.env) {
46
+ const dir = strategyDir(env);
47
+ if (!existsSync(dir)) return [];
48
+ try {
49
+ return readdirSync(dir)
50
+ .filter((f) => f.endsWith('.md'))
51
+ .map((f) => basename(f, '.md'));
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ // ── Read helpers ──────────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Read one strategy scope from Postgres (primary) or file (fallback).
61
+ *
62
+ * @param {string} [scope]
63
+ * @param {object} [env]
64
+ * @returns {Promise<{ content: string, version: number, updatedAt: string, source: 'postgres'|'file'|'none', scope: string }>}
65
+ */
66
+ export async function readStrategy(scope = 'product', env = process.env) {
67
+ if (hasSqlStore(env)) {
68
+ const client = createSqlClient(env);
69
+ try {
70
+ const project = env.CX_PROJECT || 'default';
71
+ const prefix = `${SCOPE_PREFIX}${scope}\n`;
72
+
73
+ // Rows stored with the scope prefix — match on content prefix
74
+ const rows = await client`
75
+ select content, version, updated_at
76
+ from construct_strategy
77
+ where project = ${project}
78
+ and content like ${prefix + '%'}
79
+ order by version desc
80
+ limit 1
81
+ `;
82
+
83
+ if (rows.length > 0) {
84
+ const row = rows[0];
85
+ return {
86
+ content: row.content.startsWith(prefix) ? row.content.slice(prefix.length) : row.content,
87
+ version: row.version,
88
+ updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
89
+ source: 'postgres',
90
+ scope,
91
+ };
92
+ }
93
+ } catch {
94
+ // Postgres unavailable — fall through to file
95
+ } finally {
96
+ if (client) await client.end({ timeout: 5 }).catch(() => {});
97
+ }
98
+ }
99
+
100
+ const filePath = strategyFilePath(scope, env);
101
+ if (existsSync(filePath)) {
102
+ const content = readFileSync(filePath, 'utf8');
103
+ return { content, version: 1, updatedAt: new Date().toISOString(), source: 'file', scope };
104
+ }
105
+
106
+ return { content: '', version: 0, updatedAt: new Date().toISOString(), source: 'none', scope };
107
+ }
108
+
109
+ /**
110
+ * Read all present strategy scopes.
111
+ *
112
+ * When Postgres is available it reads all rows for the project and parses scope
113
+ * from the prefix. Files are used as the fallback or supplement for scopes that
114
+ * have no Postgres row.
115
+ *
116
+ * @param {object} [env]
117
+ * @returns {Promise<Map<string, { content: string, version: number, updatedAt: string, source: string }>>}
118
+ */
119
+ export async function readAllStrategies(env = process.env) {
120
+ const result = new Map();
121
+
122
+ if (hasSqlStore(env)) {
123
+ const client = createSqlClient(env);
124
+ try {
125
+ const project = env.CX_PROJECT || 'default';
126
+
127
+ // Fetch all rows — latest version per scope
128
+ const rows = await client`
129
+ select distinct on (
130
+ substring(content from 1 for position(E'\n' in content))
131
+ ) content, version, updated_at
132
+ from construct_strategy
133
+ where project = ${project}
134
+ and content like ${SCOPE_PREFIX + '%'}
135
+ order by
136
+ substring(content from 1 for position(E'\n' in content)),
137
+ version desc
138
+ `;
139
+
140
+ for (const row of rows) {
141
+ const firstNewline = row.content.indexOf('\n');
142
+ if (firstNewline === -1) continue;
143
+ const header = row.content.slice(0, firstNewline);
144
+ if (!header.startsWith(SCOPE_PREFIX)) continue;
145
+ const rowScope = header.slice(SCOPE_PREFIX.length);
146
+ const content = row.content.slice(firstNewline + 1);
147
+ result.set(rowScope, {
148
+ content,
149
+ version: row.version,
150
+ updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
151
+ source: 'postgres',
152
+ });
153
+ }
154
+ } catch {
155
+ // Fall through to file-based reads
156
+ } finally {
157
+ if (client) await client.end({ timeout: 5 }).catch(() => {});
158
+ }
159
+ }
160
+
161
+ // Supplement / replace with file-based reads for any scope present on disk
162
+ const fileScopes = listStrategyScopes(env);
163
+ for (const fileScope of fileScopes) {
164
+ if (result.has(fileScope)) continue;
165
+ const filePath = strategyFilePath(fileScope, env);
166
+ try {
167
+ const content = readFileSync(filePath, 'utf8');
168
+ result.set(fileScope, {
169
+ content,
170
+ version: 1,
171
+ updatedAt: new Date().toISOString(),
172
+ source: 'file',
173
+ });
174
+ } catch {
175
+ // Skip unreadable files
176
+ }
177
+ }
178
+
179
+ return result;
180
+ }
181
+
182
+ // ── Digest helpers ────────────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Parse markdown into sections split on ## headers.
186
+ *
187
+ * @param {string} content
188
+ * @returns {Array<{ header: string, body: string }>}
189
+ */
190
+ function parseSections(content) {
191
+ const lines = content.split('\n');
192
+ const sections = [];
193
+ let current = null;
194
+
195
+ for (const line of lines) {
196
+ if (line.startsWith('## ')) {
197
+ if (current) sections.push(current);
198
+ current = { header: line.slice(3).trim(), body: '' };
199
+ } else if (current) {
200
+ current.body += line + '\n';
201
+ }
202
+ }
203
+ if (current) sections.push(current);
204
+ return sections;
205
+ }
206
+
207
+ /**
208
+ * Truncate a body to the first N sentences.
209
+ *
210
+ * @param {string} body
211
+ * @param {number} maxSentences
212
+ * @returns {string}
213
+ */
214
+ function firstSentences(body, maxSentences = 2) {
215
+ const trimmed = body.trim();
216
+ if (!trimmed) return '';
217
+ const sentences = trimmed.match(/[^.!?\n]+[.!?\n]+/g) || [trimmed];
218
+ return sentences.slice(0, maxSentences).join(' ').trim();
219
+ }
220
+
221
+ /**
222
+ * Build a compact markdown block from a single scope's content.
223
+ *
224
+ * @param {string} scopeName
225
+ * @param {string} content
226
+ * @param {number} charBudget — remaining character budget; mutated by reference via returned delta
227
+ * @returns {{ text: string, charsUsed: number }}
228
+ */
229
+ function buildScopeBlock(scopeName, content, charBudget) {
230
+ const sections = parseSections(content);
231
+ if (!sections.length) return { text: '', charsUsed: 0 };
232
+
233
+ const lines = [`### ${scopeName}`];
234
+ let used = scopeName.length + 5;
235
+
236
+ for (const { header, body } of sections) {
237
+ if (used >= charBudget) break;
238
+ const excerpt = firstSentences(body, 2);
239
+ const line = excerpt ? `**${header}:** ${excerpt}` : `**${header}**`;
240
+ lines.push(line);
241
+ used += line.length + 1;
242
+ }
243
+
244
+ const text = lines.join('\n');
245
+ return { text, charsUsed: used };
246
+ }
247
+
248
+ /**
249
+ * Return a compact strategy digest (≤ 500 tokens) for agent prompt injection.
250
+ *
251
+ * Reads all present scopes asynchronously. Returns empty string when no scopes exist.
252
+ *
253
+ * @param {object} [env]
254
+ * @returns {Promise<string>}
255
+ */
256
+ export async function getStrategyDigest(env = process.env) {
257
+ try {
258
+ const all = await readAllStrategies(env);
259
+ if (!all.size) return '';
260
+
261
+ const TOKEN_LIMIT = 500;
262
+ let charBudget = TOKEN_LIMIT * 4;
263
+ const parts = ['## Active strategy'];
264
+
265
+ for (const [scopeName, { content }] of all) {
266
+ if (!content) continue;
267
+ const { text, charsUsed } = buildScopeBlock(scopeName, content, charBudget);
268
+ if (text) {
269
+ parts.push(text);
270
+ charBudget -= charsUsed;
271
+ }
272
+ if (charBudget <= 0) break;
273
+ }
274
+
275
+ return parts.length > 1 ? parts.join('\n') : '';
276
+ } catch {
277
+ return '';
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Synchronous file-only strategy digest for prompt-composer.js.
283
+ *
284
+ * Reads all .md files present in the strategy directory directly. Never reads
285
+ * Postgres. Returns empty string when the directory is absent or empty.
286
+ *
287
+ * @param {object} [env]
288
+ * @returns {string}
289
+ */
290
+ export function getStrategyDigestSync(env = process.env) {
291
+ try {
292
+ const dir = strategyDir(env);
293
+ if (!existsSync(dir)) return '';
294
+
295
+ const files = readdirSync(dir).filter((f) => f.endsWith('.md'));
296
+ if (!files.length) return '';
297
+
298
+ const TOKEN_LIMIT = 500;
299
+ let charBudget = TOKEN_LIMIT * 4;
300
+ const parts = ['## Active strategy'];
301
+
302
+ for (const file of files) {
303
+ if (charBudget <= 0) break;
304
+ const scopeName = basename(file, '.md');
305
+ let content;
306
+ try {
307
+ content = readFileSync(join(dir, file), 'utf8');
308
+ } catch {
309
+ continue;
310
+ }
311
+ if (!content) continue;
312
+ const { text, charsUsed } = buildScopeBlock(scopeName, content, charBudget);
313
+ if (text) {
314
+ parts.push(text);
315
+ charBudget -= charsUsed;
316
+ }
317
+ }
318
+
319
+ return parts.length > 1 ? parts.join('\n') : '';
320
+ } catch {
321
+ return '';
322
+ }
323
+ }
324
+
325
+ // ── Write ─────────────────────────────────────────────────────────────────────
326
+
327
+ /**
328
+ * Write a strategy scope to file (always) and Postgres (best-effort if available).
329
+ *
330
+ * The scope parameter defaults to 'product'. When writing to Postgres the content
331
+ * is prefixed with `scope:{name}\n` to allow per-scope storage without a schema change.
332
+ *
333
+ * @param {string} content
334
+ * @param {string} [scope]
335
+ * @param {object} [opts]
336
+ * @param {string} [opts.updatedBy]
337
+ * @param {object} [opts.env]
338
+ */
339
+ export async function writeStrategy(content, scope = 'product', { updatedBy, env = process.env } = {}) {
340
+ const filePath = strategyFilePath(scope, env);
341
+ const dir = strategyDir(env);
342
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
343
+ writeFileSync(filePath, content, 'utf8');
344
+
345
+ if (!hasSqlStore(env)) return;
346
+
347
+ const client = createSqlClient(env);
348
+ try {
349
+ const project = env.CX_PROJECT || 'default';
350
+ const scopedContent = `${SCOPE_PREFIX}${scope}\n${content}`;
351
+ const prefix = `${SCOPE_PREFIX}${scope}\n`;
352
+
353
+ const existing = await client`
354
+ select version from construct_strategy
355
+ where project = ${project}
356
+ and content like ${prefix + '%'}
357
+ order by version desc
358
+ limit 1
359
+ `;
360
+ const nextVersion = existing.length > 0 ? existing[0].version + 1 : 1;
361
+
362
+ await client`
363
+ insert into construct_strategy (project, content, version, updated_by)
364
+ values (${project}, ${scopedContent}, ${nextVersion}, ${updatedBy ?? null})
365
+ `;
366
+ } catch {
367
+ // Best-effort Postgres write — file write already succeeded
368
+ } finally {
369
+ if (client) await client.end({ timeout: 5 }).catch(() => {});
370
+ }
371
+ }
@@ -7,11 +7,12 @@
7
7
 
8
8
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
- import { homedir } from 'node:os';
11
10
 
12
11
  export const name = 'local';
13
12
 
14
- const TRACES_DIR = join(homedir(), '.cx', 'traces');
13
+ function tracesDir(env = process.env) {
14
+ return join(env.CX_TOOLKIT_DIR || process.cwd(), '.cx', 'traces');
15
+ }
15
16
 
16
17
  export async function isAvailable() {
17
18
  return true;
@@ -25,7 +26,8 @@ export async function isAvailable() {
25
26
  * @returns {Promise<object[]>}
26
27
  */
27
28
  export async function listTraces(teamId, windowMs) {
28
- if (!existsSync(TRACES_DIR)) return [];
29
+ const dir = tracesDir();
30
+ if (!existsSync(dir)) return [];
29
31
 
30
32
  const since = Date.now() - windowMs;
31
33
  const files = readdirSync(TRACES_DIR)
@@ -36,7 +38,7 @@ export async function listTraces(teamId, windowMs) {
36
38
 
37
39
  const traces = [];
38
40
  for (const file of files) {
39
- const lines = readFileSync(join(TRACES_DIR, file), 'utf8')
41
+ const lines = readFileSync(join(dir, file), 'utf8')
40
42
  .split('\n')
41
43
  .filter(Boolean);
42
44
  for (const line of lines) {