@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
@@ -11,18 +11,33 @@
11
11
  * 2. Contradiction detection — new signal contradicts a documented decision
12
12
  *
13
13
  * Storage:
14
- * ~/.cx/product-intel/artifact-index.json — cached artifact embeddings
14
+ * ~/.cx/knowledge/reference/artifact-index.json — cached artifact embeddings
15
15
  * Rebuilt on first use or when artifact file count changes.
16
16
  */
17
17
 
18
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
18
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, renameSync } from 'node:fs';
19
19
  import { join } from 'node:path';
20
- import { homedir } from 'node:os';
21
20
  import { embed, cosineSimilarity, extractTextFromPacket } from './semantic.mjs';
22
21
  import { listArtifacts } from './artifact.mjs';
22
+ import { cxDir } from '../paths.mjs';
23
+ import { knowledgeReferenceStore } from '../knowledge/layout.mjs';
23
24
 
24
- const INDEX_DIR = join(homedir(), '.cx', 'product-intel');
25
- const INDEX_FILE = join(INDEX_DIR, 'artifact-index.json');
25
+ function migrateLegacyIndex(indexFile, legacyFile) {
26
+ if (existsSync(indexFile) || !existsSync(legacyFile)) return;
27
+ mkdirSync(join(indexFile, '..'), { recursive: true });
28
+ try { renameSync(legacyFile, indexFile); } catch { /* compatibility-only */ }
29
+ }
30
+
31
+ function indexPaths({ migrate = false } = {}) {
32
+ const indexDir = join(cxDir(), knowledgeReferenceStore());
33
+ const legacyFile = join(cxDir(), 'product-intel', 'artifact-index.json');
34
+ const indexFile = join(indexDir, 'artifact-index.json');
35
+ if (migrate) migrateLegacyIndex(indexFile, legacyFile);
36
+ return {
37
+ indexDir,
38
+ indexFile,
39
+ };
40
+ }
26
41
 
27
42
  const OVERLAP_THRESHOLD = 0.6;
28
43
  const CONTRADICTION_THRESHOLD = 0.7;
@@ -32,7 +47,8 @@ const STALE_INDEX_HOURS = 24;
32
47
  * Ensure index directory exists.
33
48
  */
34
49
  function ensureDir() {
35
- if (!existsSync(INDEX_DIR)) mkdirSync(INDEX_DIR, { recursive: true });
50
+ const { indexDir } = indexPaths({ migrate: true });
51
+ if (!existsSync(indexDir)) mkdirSync(indexDir, { recursive: true });
36
52
  }
37
53
 
38
54
  let _artifactIndex = null;
@@ -55,9 +71,10 @@ export async function buildArtifactIndex({ rootDir, forceRebuild = false } = {})
55
71
  const cacheHash = cacheKey.length;
56
72
 
57
73
  // Check if cached index is fresh
58
- if (!forceRebuild && existsSync(INDEX_FILE)) {
74
+ const { indexFile } = indexPaths();
75
+ if (!forceRebuild && existsSync(indexFile)) {
59
76
  try {
60
- const cached = JSON.parse(readFileSync(INDEX_FILE, 'utf8'));
77
+ const cached = JSON.parse(readFileSync(indexFile, 'utf8'));
61
78
  if (cached.cacheHash === cacheHash && cached.index?.length === artifacts.length) {
62
79
  const age = Date.now() - new Date(cached.builtAt).getTime();
63
80
  if (age < STALE_INDEX_HOURS * 60 * 60 * 1000) {
@@ -96,7 +113,7 @@ export async function buildArtifactIndex({ rootDir, forceRebuild = false } = {})
96
113
  embedding: a.embedding ? Array.from(a.embedding) : null,
97
114
  }));
98
115
 
99
- writeFileSync(INDEX_FILE, JSON.stringify({
116
+ writeFileSync(indexFile, JSON.stringify({
100
117
  cacheHash,
101
118
  builtAt: new Date().toISOString(),
102
119
  artifactCount: artifacts.length,
@@ -1,31 +1,47 @@
1
1
  /**
2
2
  * lib/embed/customer-profiles.mjs — Global customer profile store.
3
3
  *
4
- * Maintains durable customer/account memory in ~/.cx/product-intel/customer-profiles/.
4
+ * Maintains durable customer/account memory in ~/.cx/knowledge/internal/customer-profiles/.
5
5
  * Profiles are additive — history is preserved unless explicitly deleted.
6
6
  * Links signals to customers and detects account-level patterns during intake triage.
7
7
  *
8
8
  * Storage:
9
- * ~/.cx/product-intel/customer-profiles/<customer-id>.md
10
- * ~/.cx/product-intel/customer-profiles/index.json — quick lookup by name/email
9
+ * ~/.cx/knowledge/internal/customer-profiles/<customer-id>.md
10
+ * ~/.cx/knowledge/internal/customer-profiles/index.json — quick lookup by name/email
11
11
  *
12
12
  * Each profile follows templates/docs/customer-profile.md schema.
13
13
  */
14
14
 
15
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, appendFileSync } from 'node:fs';
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, appendFileSync, renameSync } from 'node:fs';
16
16
  import { join } from 'node:path';
17
17
  import { randomUUID } from 'node:crypto';
18
- import { homedir } from 'node:os';
18
+ import { cxDir } from '../paths.mjs';
19
+ import { knowledgeInternalStore } from '../knowledge/layout.mjs';
19
20
 
20
- const PROFILES_DIR = join(homedir(), '.cx', 'product-intel', 'customer-profiles');
21
- const INDEX_FILE = join(PROFILES_DIR, 'index.json');
21
+ function migrateLegacyDir(modernDir, legacyDir) {
22
+ if (existsSync(modernDir) || !existsSync(legacyDir)) return;
23
+ mkdirSync(join(modernDir, '..'), { recursive: true });
24
+ try { renameSync(legacyDir, modernDir); } catch { /* compatibility-only */ }
25
+ }
26
+
27
+ function profilePaths({ migrate = false } = {}) {
28
+ const modernDir = join(cxDir(), knowledgeInternalStore('customer-profiles'));
29
+ const legacyDir = join(cxDir(), 'product-intel', 'customer-profiles');
30
+ if (migrate) migrateLegacyDir(modernDir, legacyDir);
31
+ const profilesDir = existsSync(modernDir) || !existsSync(legacyDir) ? modernDir : legacyDir;
32
+ return {
33
+ profilesDir,
34
+ indexFile: join(profilesDir, 'index.json'),
35
+ };
36
+ }
22
37
 
23
38
  /**
24
39
  * Ensure profile directory exists.
25
40
  */
26
41
  function ensureDir() {
27
- if (!existsSync(PROFILES_DIR)) {
28
- mkdirSync(PROFILES_DIR, { recursive: true });
42
+ const { profilesDir } = profilePaths({ migrate: true });
43
+ if (!existsSync(profilesDir)) {
44
+ mkdirSync(profilesDir, { recursive: true });
29
45
  }
30
46
  }
31
47
 
@@ -34,9 +50,10 @@ function ensureDir() {
34
50
  * @returns {{ [key: string]: { id: string, name: string, status: string, updatedAt: string } }}
35
51
  */
36
52
  function readIndex() {
37
- if (!existsSync(INDEX_FILE)) return {};
53
+ const { indexFile } = profilePaths();
54
+ if (!existsSync(indexFile)) return {};
38
55
  try {
39
- return JSON.parse(readFileSync(INDEX_FILE, 'utf8'));
56
+ return JSON.parse(readFileSync(indexFile, 'utf8'));
40
57
  } catch (err) {
41
58
  process.stderr.write('[customer-profiles.mjs] readIndex: ' + (err?.message ?? String(err)) + '\n');
42
59
  return {};
@@ -48,8 +65,9 @@ function readIndex() {
48
65
  * @param {object} index
49
66
  */
50
67
  function writeIndex(index) {
68
+ const { indexFile } = profilePaths();
51
69
  ensureDir();
52
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2) + '\n');
70
+ writeFileSync(indexFile, JSON.stringify(index, null, 2) + '\n');
53
71
  }
54
72
 
55
73
  /**
@@ -95,7 +113,7 @@ export function createCustomerProfile({ name, owner, workspace, status = 'active
95
113
  const id = generateCustomerId();
96
114
  const slug = slugify(name);
97
115
  const filename = `${slug}-${id}.md`;
98
- const filePath = join(PROFILES_DIR, filename);
116
+ const filePath = join(profilePaths().profilesDir, filename);
99
117
 
100
118
  const date = new Date().toISOString().slice(0, 10);
101
119
  const profile = {
@@ -219,12 +237,13 @@ TODO
219
237
  */
220
238
  export function getCustomerProfile(customerId) {
221
239
  ensureDir();
240
+ const { profilesDir } = profilePaths();
222
241
 
223
242
  // Try direct lookup by ID in filename
224
- const files = readdirSync(PROFILES_DIR).filter(f => f.endsWith('.md'));
243
+ const files = readdirSync(profilesDir).filter(f => f.endsWith('.md'));
225
244
  for (const file of files) {
226
245
  if (file.includes(customerId)) {
227
- const filePath = join(PROFILES_DIR, file);
246
+ const filePath = join(profilesDir, file);
228
247
  return parseProfileFile(filePath);
229
248
  }
230
249
  }
@@ -239,6 +258,7 @@ export function getCustomerProfile(customerId) {
239
258
  */
240
259
  export function searchCustomerProfiles(query) {
241
260
  ensureDir();
261
+ const { profilesDir } = profilePaths();
242
262
 
243
263
  const index = readIndex();
244
264
  const querySlug = slugify(query);
@@ -268,7 +288,7 @@ export function searchCustomerProfiles(query) {
268
288
  id: entry.id,
269
289
  name: entry.name,
270
290
  status: entry.status,
271
- path: join(PROFILES_DIR, `${slugify(entry.name)}-${entry.id}.md`),
291
+ path: join(profilesDir, `${slugify(entry.name)}-${entry.id}.md`),
272
292
  });
273
293
  }
274
294
  }
@@ -328,7 +348,7 @@ export function updateCustomerProfile(customerId, updates) {
328
348
  throw new Error(`Customer profile not found: ${customerId}`);
329
349
  }
330
350
 
331
- const filePath = join(PROFILES_DIR, `${slugify(profile.name)}-${customerId}.md`);
351
+ const filePath = join(profilePaths().profilesDir, `${slugify(profile.name)}-${customerId}.md`);
332
352
  let content = readFileSync(filePath, 'utf8');
333
353
  const date = new Date().toISOString().slice(0, 10);
334
354
 
@@ -467,15 +467,16 @@ export class EmbedDaemon {
467
467
  process.stderr.write(`[embed] Distilled ${itemsWritten} snapshot item(s) into observation store\n`);
468
468
  }
469
469
 
470
- // Regenerate roadmap each tick so .cx/roadmap.md stays current and the
471
- // missing-focal-resource gap clears on the next snapshot.
472
- try {
473
- const roadmap = generateRoadmap({ targetPath: this.#rootDir, snapshot, roles: this.#config?.roles });
474
- if (!roadmap.skipped) {
475
- process.stderr.write(`[embed] Roadmap refreshed: ${roadmap.itemCount} open item(s) → ${roadmap.path}\n`);
470
+ // Regenerate roadmap each tick unless disabled by the host config.
471
+ if (process.env.CONSTRUCT_EMBED_ROADMAP_ENABLED !== '0') {
472
+ try {
473
+ const roadmap = generateRoadmap({ targetPath: this.#rootDir, snapshot, roles: this.#config?.roles });
474
+ if (!roadmap.skipped) {
475
+ process.stderr.write(`[embed] Roadmap refreshed: ${roadmap.itemCount} open item(s) → ${roadmap.path}\n`);
476
+ }
477
+ } catch (err) {
478
+ process.stderr.write(`[embed] Roadmap refresh failed: ${err.message}\n`);
476
479
  }
477
- } catch (err) {
478
- process.stderr.write(`[embed] Roadmap refresh failed: ${err.message}\n`);
479
480
  }
480
481
  },
481
482
  { runImmediately: true },
@@ -744,6 +745,7 @@ this.#scheduler.register(
744
745
  'roadmap',
745
746
  60 * 60_000,
746
747
  async () => {
748
+ if (process.env.CONSTRUCT_EMBED_ROADMAP_ENABLED === '0') return;
747
749
  if (!this.#lastSnapshot) return; // wait until at least one snapshot exists
748
750
  try {
749
751
  const result = generateRoadmap({ targetPath: this.#rootDir, snapshot: this.#lastSnapshot, roles: this.#config?.roles });
@@ -7,10 +7,14 @@
7
7
  * dismissal, automatic suppression after 7 days, and re-surfacing when
8
8
  * new signals arrive.
9
9
  *
10
- * Storage:
10
+ * Storage (solo mode):
11
11
  * ~/.cx/intake/recommendations.jsonl — append-only log
12
12
  * ~/.cx/intake/recommendations-index.json — fast lookup by id
13
13
  *
14
+ * Storage (team/enterprise mode — DATABASE_URL configured):
15
+ * construct_recommendations table in Postgres (see db/schema/004_recommendations.sql)
16
+ * JSONL store is also written as a local backup.
17
+ *
14
18
  * Prioritization formula:
15
19
  * score = (signalCount * 2) + (customerImpact * 3) + (recencyBonus) + (strategicBonus)
16
20
  * P0: score >= 10, P1: >= 7, P2: >= 4, P3: < 4
@@ -19,29 +23,38 @@
19
23
  import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
20
24
  import { join } from 'node:path';
21
25
  import { randomUUID } from 'node:crypto';
22
- import { homedir } from 'node:os';
23
-
24
- const STORE_DIR = join(homedir(), '.cx', 'intake');
25
- const LOG_FILE = join(STORE_DIR, 'recommendations.jsonl');
26
- const INDEX_FILE = join(STORE_DIR, 'recommendations-index.json');
26
+ import { cxDir } from '../paths.mjs';
27
+ import { hasSqlStore } from '../storage/sql-store.mjs';
28
+ import { createSqlClient } from '../storage/backend.mjs';
27
29
 
28
30
  const DEFAULT_SUPPRESS_DAYS = 7;
29
31
  const SUPERSEDE_WITHIN_HOURS = 72;
30
32
 
33
+ function recommendationPaths() {
34
+ const storeDir = join(cxDir(), 'intake');
35
+ return {
36
+ storeDir,
37
+ logFile: join(storeDir, 'recommendations.jsonl'),
38
+ indexFile: join(storeDir, 'recommendations-index.json'),
39
+ };
40
+ }
41
+
31
42
  /**
32
43
  * Ensure store directory exists.
33
44
  */
34
45
  function ensureDir() {
35
- if (!existsSync(STORE_DIR)) mkdirSync(STORE_DIR, { recursive: true });
46
+ const { storeDir } = recommendationPaths();
47
+ if (!existsSync(storeDir)) mkdirSync(storeDir, { recursive: true });
36
48
  }
37
49
 
38
50
  /**
39
51
  * Read the index (fast lookup).
40
52
  */
41
53
  function readIndex() {
42
- if (!existsSync(INDEX_FILE)) return {};
54
+ const { indexFile } = recommendationPaths();
55
+ if (!existsSync(indexFile)) return {};
43
56
  try {
44
- return JSON.parse(readFileSync(INDEX_FILE, 'utf8'));
57
+ return JSON.parse(readFileSync(indexFile, 'utf8'));
45
58
  } catch {
46
59
  return {};
47
60
  }
@@ -51,8 +64,9 @@ function readIndex() {
51
64
  * Write the index.
52
65
  */
53
66
  function writeIndex(index) {
67
+ const { indexFile } = recommendationPaths();
54
68
  ensureDir();
55
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2) + '\n');
69
+ writeFileSync(indexFile, JSON.stringify(index, null, 2) + '\n');
56
70
  }
57
71
 
58
72
  /**
@@ -86,9 +100,11 @@ function dedupKey(type, title) {
86
100
  * @param {number} [opts.strategicBonus=0] - 0-3: strategic alignment
87
101
  * @param {string[]} [opts.sourceSignalIds] - IDs of intake signals that triggered this
88
102
  * @param {string} [opts.lane] - Docs lane this belongs to
103
+ * @param {string} [opts.project] - Project name (defaults to CX_PROJECT or 'default')
104
+ * @param {object} [opts.env] - Environment (defaults to process.env)
89
105
  * @returns {{ id: string, dedupKey: string, priority: string, score: number }}
90
106
  */
91
- export function createRecommendation({ type, title, reason, signalCount = 1, customerImpact = 0, recencyBonus = 0, strategicBonus = 0, sourceSignalIds = [], lane }) {
107
+ export function createRecommendation({ type, title, reason, signalCount = 1, customerImpact = 0, recencyBonus = 0, strategicBonus = 0, sourceSignalIds = [], lane, project, env }) {
92
108
  if (!type || !title) throw new Error('type and title are required');
93
109
 
94
110
  ensureDir();
@@ -114,7 +130,10 @@ export function createRecommendation({ type, title, reason, signalCount = 1, cus
114
130
  updated.priority = priorityTier(score);
115
131
  index[key] = updated;
116
132
  writeIndex(index);
117
- return { id: existing.id, dedupKey: key, priority: updated.priority, score, existing: true, updatedAt: updated.lastSeen };
133
+
134
+ const result = { id: existing.id, dedupKey: key, priority: updated.priority, score, existing: true, updatedAt: updated.lastSeen };
135
+ createRecommendationPgBestEffort(updated, project, env);
136
+ return result;
118
137
  }
119
138
  // Existing was dismissed — create new instance with new signals
120
139
  // (superseded recommendations can be revived with fresh signals)
@@ -145,12 +164,13 @@ export function createRecommendation({ type, title, reason, signalCount = 1, cus
145
164
  };
146
165
 
147
166
  // Append to log
148
- appendFileSync(LOG_FILE, JSON.stringify(rec) + '\n', 'utf8');
167
+ appendFileSync(recommendationPaths().logFile, JSON.stringify(rec) + '\n', 'utf8');
149
168
 
150
169
  // Update index
151
170
  index[key] = rec;
152
171
  writeIndex(index);
153
172
 
173
+ createRecommendationPgBestEffort(rec, project, env);
154
174
  return { id, dedupKey: key, priority: rec.priority, score, existing: false };
155
175
  }
156
176
 
@@ -216,13 +236,28 @@ export function supersedeRecommendation(dedupKey, supersedingId) {
216
236
  /**
217
237
  * List active (non-dismissed, non-superseded) recommendations.
218
238
  *
239
+ * In team/enterprise mode (DATABASE_URL configured), reads from Postgres when
240
+ * the project has rows. Falls back to JSONL index.
241
+ *
219
242
  * @param {object} [opts]
220
243
  * @param {string} [opts.type] - Filter by type
221
244
  * @param {string} [opts.priority] - Filter by priority (P0, P1, etc.)
222
245
  * @param {number} [opts.limit=20]
246
+ * @param {string} [opts.project] - Project name (defaults to CX_PROJECT or 'default')
247
+ * @param {object} [opts.env] - Environment (defaults to process.env)
223
248
  * @returns {Array<object>}
224
249
  */
225
- export function listActiveRecommendations({ type, priority, limit = 20 } = {}) {
250
+ export function listActiveRecommendations({ type, priority, limit = 20, project, env } = {}) {
251
+ if (hasSqlStore(env)) {
252
+ return listActiveRecommendationsPgWithFallback({ type, priority, limit, project, env });
253
+ }
254
+ return listActiveRecommendationsFile({ type, priority, limit });
255
+ }
256
+
257
+ /**
258
+ * File-based active recommendations list.
259
+ */
260
+ function listActiveRecommendationsFile({ type, priority, limit = 20 } = {}) {
226
261
  const index = readIndex();
227
262
  const now = new Date();
228
263
  let results = Object.values(index).filter(rec => {
@@ -246,6 +281,27 @@ export function listActiveRecommendations({ type, priority, limit = 20 } = {}) {
246
281
  .slice(0, limit);
247
282
  }
248
283
 
284
+ /**
285
+ * Attempt Postgres read, fall back to file synchronously.
286
+ * Returns a Promise so callers using await get Postgres data; callers not
287
+ * using await get the file-based results via the sync fallback.
288
+ */
289
+ function listActiveRecommendationsPgWithFallback({ type, priority, limit, project, env }) {
290
+ const resolvedProject = project || (env || process.env).CX_PROJECT || 'default';
291
+ const client = createSqlClient(env);
292
+
293
+ return listActiveRecommendationsPg(resolvedProject, { type, priority, limit }, client)
294
+ .then(rows => {
295
+ client.end({ timeout: 5 }).catch(() => {});
296
+ if (rows.length > 0) return rows;
297
+ return listActiveRecommendationsFile({ type, priority, limit });
298
+ })
299
+ .catch(() => {
300
+ client.end({ timeout: 5 }).catch(() => {});
301
+ return listActiveRecommendationsFile({ type, priority, limit });
302
+ });
303
+ }
304
+
249
305
  /**
250
306
  * Auto-suppress recommendations that have been active for DEFAULT_SUPPRESS_DAYS
251
307
  * without new signals, or that were superseded more than SUPERSEDE_WITHIN_HOURS ago.
@@ -376,3 +432,146 @@ export function isRecommendationActive(type, title) {
376
432
  if (rec.dismissedAt || rec.supersededAt) return { active: false, existing: rec };
377
433
  return { active: true, existing: rec };
378
434
  }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Postgres backend — team/enterprise mode only
438
+ // ---------------------------------------------------------------------------
439
+
440
+ /**
441
+ * Upsert a recommendation into construct_recommendations.
442
+ * On conflict (project, dedup_key) update all mutable fields.
443
+ *
444
+ * @param {object} rec - Recommendation object (camelCase)
445
+ * @param {string} project
446
+ * @param {object} client - postgres.js SQL client
447
+ */
448
+ export async function createRecommendationPg(rec, project, client) {
449
+ await client`
450
+ insert into construct_recommendations (
451
+ id, project, dedup_key, type, title, reason, lane,
452
+ signal_count, total_signal_count,
453
+ customer_impact, recency_bonus, strategic_bonus,
454
+ score, priority, source_signal_ids,
455
+ first_seen, last_seen,
456
+ dismissed_at, dismiss_reason,
457
+ superseded_at, superseded_by_id,
458
+ suppressed_until, suppress_reason,
459
+ updated_at
460
+ ) values (
461
+ ${rec.id}, ${project}, ${rec.dedupKey || dedupKey(rec.type, rec.title)}, ${rec.type}, ${rec.title},
462
+ ${rec.reason ?? null}, ${rec.lane ?? null},
463
+ ${rec.signalCount ?? 1}, ${rec.totalSignalCount ?? 1},
464
+ ${rec.customerImpact ?? 0}, ${rec.recencyBonus ?? 0}, ${rec.strategicBonus ?? 0},
465
+ ${rec.score ?? 0}, ${rec.priority ?? 'P3'},
466
+ ${JSON.stringify(rec.sourceSignalIds ?? [])}::jsonb,
467
+ ${rec.firstSeen ?? new Date().toISOString()}, ${rec.lastSeen ?? new Date().toISOString()},
468
+ ${rec.dismissedAt ?? null}, ${rec.dismissReason ?? null},
469
+ ${rec.supersededAt ?? null}, ${rec.supersededById ?? null},
470
+ ${rec.suppressedUntil ?? null}, ${rec.suppressReason ?? null},
471
+ now()
472
+ )
473
+ on conflict (project, dedup_key) do update set
474
+ signal_count = excluded.signal_count,
475
+ total_signal_count = excluded.total_signal_count,
476
+ customer_impact = excluded.customer_impact,
477
+ recency_bonus = excluded.recency_bonus,
478
+ strategic_bonus = excluded.strategic_bonus,
479
+ score = excluded.score,
480
+ priority = excluded.priority,
481
+ source_signal_ids = excluded.source_signal_ids,
482
+ last_seen = excluded.last_seen,
483
+ dismissed_at = excluded.dismissed_at,
484
+ dismiss_reason = excluded.dismiss_reason,
485
+ superseded_at = excluded.superseded_at,
486
+ superseded_by_id = excluded.superseded_by_id,
487
+ suppressed_until = excluded.suppressed_until,
488
+ suppress_reason = excluded.suppress_reason,
489
+ updated_at = now()
490
+ `;
491
+ }
492
+
493
+ /**
494
+ * Query active recommendations from Postgres for a project.
495
+ *
496
+ * @param {string} project
497
+ * @param {object} [opts]
498
+ * @param {string} [opts.type]
499
+ * @param {string} [opts.priority]
500
+ * @param {number} [opts.limit=20]
501
+ * @param {object} client - postgres.js SQL client
502
+ * @returns {Promise<Array<object>>}
503
+ */
504
+ export async function listActiveRecommendationsPg(project, { type, priority, limit = 20 } = {}, client) {
505
+ const rows = await client`
506
+ select *
507
+ from construct_recommendations
508
+ where project = ${project}
509
+ and dismissed_at is null
510
+ and superseded_at is null
511
+ and (suppressed_until is null or suppressed_until <= now())
512
+ ${type ? client`and type = ${type}` : client``}
513
+ ${priority ? client`and priority = ${priority}` : client``}
514
+ order by score desc
515
+ limit ${limit}
516
+ `;
517
+
518
+ return rows.map(row => ({
519
+ id: row.id,
520
+ type: row.type,
521
+ title: row.title,
522
+ reason: row.reason,
523
+ lane: row.lane,
524
+ signalCount: row.signal_count,
525
+ totalSignalCount: row.total_signal_count,
526
+ customerImpact: row.customer_impact,
527
+ recencyBonus: row.recency_bonus,
528
+ strategicBonus: row.strategic_bonus,
529
+ score: row.score,
530
+ priority: row.priority,
531
+ sourceSignalIds: row.source_signal_ids ?? [],
532
+ firstSeen: row.first_seen instanceof Date ? row.first_seen.toISOString() : row.first_seen,
533
+ lastSeen: row.last_seen instanceof Date ? row.last_seen.toISOString() : row.last_seen,
534
+ dismissedAt: row.dismissed_at,
535
+ supersededAt: row.superseded_at,
536
+ suppressedUntil: row.suppressed_until,
537
+ }));
538
+ }
539
+
540
+ /**
541
+ * Dismiss a recommendation in Postgres.
542
+ *
543
+ * @param {string} dedupKeyValue
544
+ * @param {string} project
545
+ * @param {object} [opts]
546
+ * @param {string} [opts.reason]
547
+ * @param {number} [opts.suppressDays]
548
+ * @param {object} client - postgres.js SQL client
549
+ */
550
+ export async function dismissRecommendationPg(dedupKeyValue, project, { reason = 'manually dismissed', suppressDays } = {}, client) {
551
+ const suppressedUntil = suppressDays
552
+ ? new Date(Date.now() + suppressDays * 24 * 60 * 60 * 1000).toISOString()
553
+ : null;
554
+
555
+ await client`
556
+ update construct_recommendations
557
+ set
558
+ dismissed_at = now(),
559
+ dismiss_reason = ${reason},
560
+ suppressed_until = ${suppressedUntil},
561
+ updated_at = now()
562
+ where project = ${project}
563
+ and dedup_key = ${dedupKeyValue}
564
+ `;
565
+ }
566
+
567
+ /**
568
+ * Fire-and-forget Postgres upsert — never throws, never blocks the caller.
569
+ */
570
+ function createRecommendationPgBestEffort(rec, project, env) {
571
+ if (!hasSqlStore(env)) return;
572
+ const resolvedProject = project || (env || process.env).CX_PROJECT || 'default';
573
+ const client = createSqlClient(env);
574
+ createRecommendationPg(rec, resolvedProject, client)
575
+ .catch(() => {})
576
+ .finally(() => client.end({ timeout: 5 }).catch(() => {}));
577
+ }