@geraldmaron/construct 1.0.21 → 1.0.24

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 (150) hide show
  1. package/README.md +23 -7
  2. package/bin/construct +206 -228
  3. package/bin/construct-postinstall.mjs +25 -15
  4. package/lib/acp/server.mjs +113 -0
  5. package/lib/agent-instructions/inject.mjs +94 -0
  6. package/lib/auto-docs.mjs +10 -2
  7. package/lib/cli-commands.mjs +43 -15
  8. package/lib/comment-lint.mjs +115 -0
  9. package/lib/completions.mjs +7 -1
  10. package/lib/config/schema.mjs +4 -0
  11. package/lib/decisions/enforced-baseline.json +2 -0
  12. package/lib/docs-verify.mjs +15 -18
  13. package/lib/doctor/cli.mjs +8 -1
  14. package/lib/document-export.mjs +124 -0
  15. package/lib/embed/daemon.mjs +1 -1
  16. package/lib/embed/inbox.mjs +31 -10
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/features.mjs +11 -11
  20. package/lib/git-hooks-path.mjs +61 -0
  21. package/lib/home-namespace.mjs +60 -0
  22. package/lib/hooks/ci-status-check.mjs +62 -40
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  25. package/lib/hooks/pre-push-gate.mjs +15 -6
  26. package/lib/hooks/session-reflect.mjs +5 -1
  27. package/lib/hooks/stop-notify.mjs +32 -17
  28. package/lib/hooks/stop-typecheck.mjs +7 -2
  29. package/lib/host-capabilities.mjs +24 -8
  30. package/lib/host-disposition.mjs +76 -0
  31. package/lib/ingest/provider-extract.mjs +1 -1
  32. package/lib/init-docs.mjs +1 -1
  33. package/lib/init-unified.mjs +320 -219
  34. package/lib/init-update.mjs +4 -84
  35. package/lib/init.mjs +9 -25
  36. package/lib/install/stage-project.mjs +8 -2
  37. package/lib/intake/git-queue.mjs +195 -0
  38. package/lib/intake/queue.mjs +9 -16
  39. package/lib/intent-classifier.mjs +1 -1
  40. package/lib/knowledge/search.mjs +52 -3
  41. package/lib/mcp/server.mjs +57 -14
  42. package/lib/mcp/tools/memory.mjs +2 -2
  43. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  44. package/lib/mcp/tools/storage.mjs +2 -3
  45. package/lib/mcp-catalog.json +3 -3
  46. package/lib/mcp-manager.mjs +59 -3
  47. package/lib/model-registry.mjs +40 -33
  48. package/lib/observation-store.mjs +39 -163
  49. package/lib/opencode-config.mjs +1 -1
  50. package/lib/orchestration/events.mjs +66 -0
  51. package/lib/orchestration/runtime.mjs +72 -10
  52. package/lib/orchestration/worker.mjs +69 -21
  53. package/lib/orchestration-policy.mjs +27 -3
  54. package/lib/parity.mjs +46 -24
  55. package/lib/policy/unified-gates.mjs +96 -0
  56. package/lib/project-init-shared.mjs +0 -173
  57. package/lib/reconcile/adapter-prune.mjs +105 -0
  58. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  59. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  60. package/lib/reconcile/index.mjs +169 -0
  61. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  62. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  63. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  64. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  65. package/lib/runtime/uv-bootstrap.mjs +27 -3
  66. package/lib/schema-infer.mjs +16 -2
  67. package/lib/server/csrf.mjs +14 -2
  68. package/lib/server/index.mjs +95 -0
  69. package/lib/service-manager.mjs +59 -252
  70. package/lib/setup-prompts.mjs +2 -1
  71. package/lib/setup.mjs +104 -480
  72. package/lib/status.mjs +3 -6
  73. package/lib/storage/admin.mjs +48 -325
  74. package/lib/storage/backend.mjs +10 -43
  75. package/lib/storage/hybrid-query.mjs +15 -196
  76. package/lib/storage/sync.mjs +36 -177
  77. package/lib/storage/vector-client.mjs +256 -235
  78. package/lib/strategy-store.mjs +35 -286
  79. package/lib/template-registry.mjs +73 -0
  80. package/lib/term-format.mjs +75 -0
  81. package/lib/uninstall/uninstall.mjs +180 -7
  82. package/lib/worker/entrypoint.mjs +6 -14
  83. package/package.json +7 -6
  84. package/personas/construct.md +7 -8
  85. package/platforms/claude/settings.template.json +30 -11
  86. package/platforms/opencode/config.template.json +2 -2
  87. package/rules/common/neurodivergent-output.md +66 -0
  88. package/rules/common/tool-invisibility.md +37 -0
  89. package/scripts/sync-specialists.mjs +427 -107
  90. package/skills/operating/orchestration-reference.md +2 -16
  91. package/specialists/policy-inventory.json +14 -0
  92. package/specialists/prompts/cx-accessibility.md +2 -6
  93. package/specialists/prompts/cx-ai-engineer.md +0 -4
  94. package/specialists/prompts/cx-architect.md +3 -5
  95. package/specialists/prompts/cx-business-strategist.md +0 -5
  96. package/specialists/prompts/cx-data-analyst.md +0 -4
  97. package/specialists/prompts/cx-data-engineer.md +0 -4
  98. package/specialists/prompts/cx-debugger.md +2 -6
  99. package/specialists/prompts/cx-designer.md +0 -8
  100. package/specialists/prompts/cx-devil-advocate.md +2 -2
  101. package/specialists/prompts/cx-docs-keeper.md +0 -13
  102. package/specialists/prompts/cx-engineer.md +0 -13
  103. package/specialists/prompts/cx-evaluator.md +2 -2
  104. package/specialists/prompts/cx-explorer.md +4 -5
  105. package/specialists/prompts/cx-legal-compliance.md +4 -5
  106. package/specialists/prompts/cx-operations.md +0 -5
  107. package/specialists/prompts/cx-orchestrator.md +0 -4
  108. package/specialists/prompts/cx-platform-engineer.md +0 -8
  109. package/specialists/prompts/cx-product-manager.md +0 -8
  110. package/specialists/prompts/cx-qa.md +3 -12
  111. package/specialists/prompts/cx-rd-lead.md +0 -5
  112. package/specialists/prompts/cx-release-manager.md +0 -8
  113. package/specialists/prompts/cx-researcher.md +5 -29
  114. package/specialists/prompts/cx-reviewer.md +2 -6
  115. package/specialists/prompts/cx-security.md +2 -11
  116. package/specialists/prompts/cx-sre.md +0 -15
  117. package/specialists/prompts/cx-test-automation.md +0 -4
  118. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  119. package/specialists/prompts/cx-ux-researcher.md +0 -4
  120. package/specialists/registry.json +28 -36
  121. package/templates/distribution/run.mjs +36 -7
  122. package/templates/docs/accessibility-audit.md +56 -0
  123. package/templates/docs/architecture-review.md +59 -0
  124. package/templates/docs/code-review-report.md +46 -0
  125. package/templates/docs/construct_guide.md +15 -15
  126. package/templates/docs/debug-investigation.md +53 -0
  127. package/templates/docs/qa-report.md +48 -0
  128. package/templates/docs/security-audit-report.md +48 -0
  129. package/templates/docs/task-packet.md +49 -0
  130. package/templates/docs/verdict.md +40 -0
  131. package/db/schema/001_init.sql +0 -40
  132. package/db/schema/002_pgvector.sql +0 -182
  133. package/db/schema/003_intake.sql +0 -47
  134. package/db/schema/003_observation_reconciliation.sql +0 -14
  135. package/db/schema/004_recommendations.sql +0 -46
  136. package/db/schema/005_strategy.sql +0 -21
  137. package/db/schema/006_graph.sql +0 -24
  138. package/db/schema/007_tags.sql +0 -30
  139. package/db/schema/008_skill_usage.sql +0 -24
  140. package/db/schema/009_scheduler.sql +0 -14
  141. package/db/schema/010_cx_scores.sql +0 -51
  142. package/lib/intake/postgres-queue.mjs +0 -240
  143. package/lib/server/static/index.html +0 -1
  144. package/lib/services/local-postgres.mjs +0 -15
  145. package/lib/storage/backup.mjs +0 -347
  146. package/lib/storage/migrations.mjs +0 -187
  147. package/lib/storage/postgres-backup.mjs +0 -124
  148. package/lib/storage/sql-store.mjs +0 -45
  149. package/lib/storage/store-version.mjs +0 -115
  150. package/lib/storage/vector-store.mjs +0 -100
@@ -0,0 +1,124 @@
1
+ /**
2
+ * lib/document-export.mjs — markdown → PDF / DOCX / HTML export via external
3
+ * binaries (Pandoc + Typst), per ADR-0024.
4
+ *
5
+ * Bound to system-binary tooling discovered at runtime, never bundled in core
6
+ * (ADR-0001 zero-npm-core; ADR-0014 optional-capability pattern). Both engines
7
+ * are spawned as separate processes — Pandoc isolates its GPLv2+ licence at
8
+ * the process boundary; Typst is the Apache-2.0 PDF engine driven through
9
+ * Pandoc's `--pdf-engine=typst` flag, so a single Pandoc invocation produces
10
+ * every target format the contract supports.
11
+ *
12
+ * detect(format) reports availability without spawning the engine for real.
13
+ * exportMarkdown({ inputPath, outputPath, format, env }) spawns the engine and
14
+ * returns a structured result. Both surface actionable "install X" guidance
15
+ * when a required binary is absent; neither throws on missing tooling.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { spawnSync } from 'node:child_process';
21
+
22
+ export const EXPORT_FORMATS = ['pdf', 'docx', 'html'];
23
+
24
+ const FORMAT_ENGINES = {
25
+ pdf: { engine: 'pandoc', pdfEngine: 'typst', extraBinaries: ['typst'] },
26
+ docx: { engine: 'pandoc', pdfEngine: null, extraBinaries: [] },
27
+ html: { engine: 'pandoc', pdfEngine: null, extraBinaries: [] },
28
+ };
29
+
30
+ function whichBin(name, env = process.env) {
31
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
32
+ const result = spawnSync(cmd, [name], { encoding: 'utf8', env });
33
+ if (result.status !== 0) return null;
34
+ const first = (result.stdout || '').trim().split('\n')[0];
35
+ return first || null;
36
+ }
37
+
38
+ function binVersion(name, args = ['--version'], env = process.env) {
39
+ const r = spawnSync(name, args, { encoding: 'utf8', env });
40
+ if (r.status !== 0) return null;
41
+ const line = (r.stdout || '').trim().split('\n')[0];
42
+ return line || null;
43
+ }
44
+
45
+ function installHint(name) {
46
+ if (name === 'pandoc') return 'Install pandoc to enable document export (e.g. `brew install pandoc` on macOS, `apt install pandoc` on Debian/Ubuntu, or https://pandoc.org/installing.html).';
47
+ if (name === 'typst') return 'Install typst to enable PDF export via Pandoc (`brew install typst` on macOS, https://github.com/typst/typst/releases for binaries).';
48
+ return `Install ${name} to enable this export format.`;
49
+ }
50
+
51
+ // detect returns the same structure regardless of format so callers can render
52
+ // availability uniformly. `present` is the single boolean a caller should gate
53
+ // on; `missing` enumerates the binaries to install when present is false.
54
+
55
+ export function detect(format, env = process.env) {
56
+ const config = FORMAT_ENGINES[format];
57
+ if (!config) return { ok: false, format, present: false, missing: [], message: `Unsupported format: ${format}. Supported: ${EXPORT_FORMATS.join(', ')}.` };
58
+ const required = [config.engine, ...(config.extraBinaries || [])];
59
+ const status = required.map((name) => ({ name, path: whichBin(name, env), version: null }));
60
+ for (const s of status) if (s.path) s.version = binVersion(s.name, ['--version'], env);
61
+ const missing = status.filter((s) => !s.path).map((s) => s.name);
62
+ return {
63
+ ok: true,
64
+ format,
65
+ present: missing.length === 0,
66
+ binaries: status,
67
+ missing,
68
+ message: missing.length === 0
69
+ ? `Ready: ${status.map((s) => `${s.name} (${s.version?.split(' ').slice(0, 2).join(' ') ?? 'unknown'})`).join(', ')}`
70
+ : missing.map(installHint).join(' '),
71
+ };
72
+ }
73
+
74
+ function defaultOutputPath(inputPath, format) {
75
+ const parsed = path.parse(inputPath);
76
+ return path.join(parsed.dir, `${parsed.name}.${format}`);
77
+ }
78
+
79
+ // exportMarkdown is the single contract callers depend on (CLI / MCP / SDK).
80
+ // Returns { ok, format, inputPath, outputPath, engine, missing?, message }.
81
+ // ok=false with a populated `missing` means a required binary is absent — the
82
+ // caller surfaces `message` (which carries the install hint) instead of
83
+ // retrying or crashing.
84
+
85
+ export function exportMarkdown({ inputPath, outputPath, format, env = process.env, spawnFn = spawnSync } = {}) {
86
+ if (!inputPath) return { ok: false, format, message: 'exportMarkdown: inputPath is required.' };
87
+ if (!EXPORT_FORMATS.includes(format)) return { ok: false, format, inputPath, message: `Unsupported format: ${format}. Supported: ${EXPORT_FORMATS.join(', ')}.` };
88
+ if (!fs.existsSync(inputPath)) return { ok: false, format, inputPath, message: `exportMarkdown: input does not exist: ${inputPath}` };
89
+
90
+ const detection = detect(format, env);
91
+ if (!detection.present) {
92
+ return { ok: false, format, inputPath, missing: detection.missing, message: detection.message };
93
+ }
94
+
95
+ const config = FORMAT_ENGINES[format];
96
+ const target = outputPath || defaultOutputPath(inputPath, format);
97
+ fs.mkdirSync(path.dirname(target), { recursive: true });
98
+
99
+ const args = ['-f', 'markdown', '-o', target, '--standalone'];
100
+ if (config.pdfEngine) args.push(`--pdf-engine=${config.pdfEngine}`);
101
+ args.push(inputPath);
102
+
103
+ const result = spawnFn(config.engine, args, { encoding: 'utf8', env });
104
+ if (result.status !== 0) {
105
+ return {
106
+ ok: false,
107
+ format,
108
+ inputPath,
109
+ outputPath: target,
110
+ engine: config.engine,
111
+ message: `Export failed (${config.engine} exit ${result.status}): ${(result.stderr || result.stdout || '').trim().slice(0, 400)}`,
112
+ };
113
+ }
114
+
115
+ return {
116
+ ok: true,
117
+ format,
118
+ inputPath,
119
+ outputPath: target,
120
+ engine: config.engine,
121
+ pdfEngine: config.pdfEngine,
122
+ message: `Wrote ${path.relative(process.cwd(), target)}`,
123
+ };
124
+ }
@@ -1077,7 +1077,7 @@ this.#scheduler.register(
1077
1077
  method: 'POST',
1078
1078
  headers: {
1079
1079
  Authorization: `Bearer ${orKey}`, 'content-type': 'application/json',
1080
- 'HTTP-Referer': 'https://github.com/construct',
1080
+ 'HTTP-Referer': 'https://github.com/geraldmaron/construct',
1081
1081
  },
1082
1082
  body: JSON.stringify({
1083
1083
  model: modelId.replace(/^openrouter\//, ''),
@@ -241,7 +241,7 @@ function inferFileClassification(filePath) {
241
241
  * Summary = first non-empty line of extracted text (≤120 chars).
242
242
  * Content = first 1500 chars of extracted text for RAG searchability.
243
243
  */
244
- function recordInboxObservation(rootDir, { sourcePath, outputPath, characters, extractedText, category, knowledgeSubdir }) {
244
+ async function recordInboxObservation(rootDir, { sourcePath, outputPath, characters, extractedText, category, knowledgeSubdir }) {
245
245
  const lines = (extractedText ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
246
246
  const firstLine = (lines[0] ?? '').slice(0, 120);
247
247
  const filename = sourcePath.split('/').pop();
@@ -251,7 +251,10 @@ function recordInboxObservation(rootDir, { sourcePath, outputPath, characters, e
251
251
  const content = (extractedText ?? '').slice(0, 1500);
252
252
  const tags = ['inbox', 'ingested-doc', category, `knowledge:${knowledgeSubdir}`];
253
253
 
254
- addObservation(rootDir, {
254
+ // Await the write so callers (poll) know ingestion is complete. Fire-and-
255
+ // forget here let the LanceDB write outlive poll() and race the caller's
256
+ // teardown, surfacing as "ENOTEMPTY: directory not empty" on .cx cleanup.
257
+ await addObservation(rootDir, {
255
258
  role: 'construct',
256
259
  category,
257
260
  summary,
@@ -296,17 +299,22 @@ export class InboxWatcher {
296
299
  #rootDir;
297
300
  #env;
298
301
  #cwd;
302
+ #prepareIntakeFn;
299
303
 
300
304
  /**
301
305
  * @param {object} opts
302
306
  * @param {string} opts.rootDir - Root dir for observation store + state file
303
307
  * @param {object} [opts.env] - Env override (default: process.env)
304
308
  * @param {string} [opts.cwd] - Working dir for ingest output (default: rootDir)
309
+ * @param {Function} [opts.prepareIntakeFn] - Override for prepareIntakeForIngestedFile
310
+ * (test seam): receives the same args; returns null/throws to simulate a failed
311
+ * packet creation so tests can assert the manifest-gate behavior (construct-k4bg).
305
312
  */
306
- constructor({ rootDir, env = process.env, cwd } = {}) {
313
+ constructor({ rootDir, env = process.env, cwd, prepareIntakeFn } = {}) {
307
314
  this.#rootDir = rootDir;
308
315
  this.#env = env;
309
316
  this.#cwd = cwd ?? rootDir;
317
+ this.#prepareIntakeFn = prepareIntakeFn ?? prepareIntakeForIngestedFile;
310
318
  }
311
319
 
312
320
  /**
@@ -409,7 +417,7 @@ export class InboxWatcher {
409
417
  const droppedInfo = fileResult.droppedInfo ?? [];
410
418
  writeExtractionWarning({ sourcePath: candidate.path, droppedInfo });
411
419
 
412
- recordInboxObservation(this.#rootDir, {
420
+ await recordInboxObservation(this.#rootDir, {
413
421
  sourcePath: candidate.path,
414
422
  outputPath: fileResult.outputPath,
415
423
  characters: fileResult.characters,
@@ -423,13 +431,15 @@ export class InboxWatcher {
423
431
  extractedText,
424
432
  });
425
433
 
426
- // Queue an intake packet so the agent can triage the new
427
- // signal against the existing corpus. Failure here is
428
- // non-fatal — ingest already succeeded; the queue is a
429
- // best-effort handoff, not the ingestion's source of truth.
434
+ // Queue an intake packet so the agent can triage the new signal
435
+ // against the existing corpus. Failure here is non-fatal — ingest
436
+ // already succeeded; the queue is a best-effort handoff, not the
437
+ // ingestion's source of truth.
438
+
430
439
  let intakeId = null;
440
+ let intakeFailed = false;
431
441
  try {
432
- const intakeResult = await prepareIntakeForIngestedFile({
442
+ const intakeResult = await this.#prepareIntakeFn({
433
443
  rootDir: this.#rootDir,
434
444
  ingestedFile: {
435
445
  sourcePath: candidate.path,
@@ -441,7 +451,9 @@ export class InboxWatcher {
441
451
  env: this.#env,
442
452
  });
443
453
  intakeId = intakeResult?.id || null;
454
+ if (!intakeId) intakeFailed = true;
444
455
  } catch (err) {
456
+ intakeFailed = true;
445
457
  if (this.#env.CONSTRUCT_DEBUG_INTAKE === '1') {
446
458
  process.stderr.write(`[inbox] intake queue write failed for ${candidate.path}: ${err.message}\n`);
447
459
  }
@@ -449,7 +461,14 @@ export class InboxWatcher {
449
461
 
450
462
  state[key] = { mtimeMs: candidate.mtimeMs, processedAt: new Date().toISOString(), outputPath: fileResult.outputPath };
451
463
 
452
- if (manifestEnabled && candidateSha) {
464
+ // The dedup manifest entry is the signal that `construct intake
465
+ // process` uses to skip already-handled files. Recording it when
466
+ // packet creation failed strands the file: explicit `intake process`
467
+ // reports "skipped (unchanged)" while `intake list` is empty, with
468
+ // no path back. Record only on a successful intake id so a failed
469
+ // auto-ingest stays retriable via the explicit path (construct-k4bg).
470
+
471
+ if (manifestEnabled && candidateSha && intakeId && !intakeFailed) {
453
472
  try {
454
473
  recordFile(this.#rootDir, candidateSha, {
455
474
  sourcePath: candidate.path,
@@ -462,6 +481,8 @@ export class InboxWatcher {
462
481
  process.stderr.write(`[inbox] manifest record failed for ${candidate.path}: ${err.message}\n`);
463
482
  }
464
483
  }
484
+ } else if (manifestEnabled && intakeFailed) {
485
+ process.stderr.write(`[inbox] ${candidate.path}: ingest succeeded but intake-packet creation failed; rerun \`construct intake process\` to retry.\n`);
465
486
  }
466
487
 
467
488
  processed.push({ path: candidate.path, outputPath: fileResult.outputPath, docsPath, characters: fileResult.characters, knowledgeSubdir, intakeId, dedupHash: candidateSha });
@@ -7,25 +7,12 @@
7
7
  * dismissal, automatic suppression after 7 days, and re-surfacing when
8
8
  * new signals arrive.
9
9
  *
10
- * Storage (solo mode):
10
+ * Construct now uses embedded LanceDB for vectors; recommendations
11
+ * remain filesystem-primary for simplicity and Git-backed collaboration.
12
+ *
13
+ * Storage:
11
14
  * ~/.cx/intake/recommendations.jsonl — append-only log
12
15
  * ~/.cx/intake/recommendations-index.json — fast lookup by id
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
- *
18
- * Prioritization formula:
19
- * score = (signalCount * 2) + (customerImpact * 3) + (recencyBonus) + (strategicBonus)
20
- * P0: score >= 10, P1: >= 7, P2: >= 4, P3: < 4
21
- *
22
- * Enrichment state machine: every new record starts with state: 'raw'. The
23
- * enrichment loop (subscriber to recommendation.generated, lifecycle event
24
- * emitted from createRecommendation on the new-record path only) transitions
25
- * raw → enriching → enriched | enrichment_failed and writes enrichedAt /
26
- * enrichedBy / framingNotes back to the index. Dedup-update path does not
27
- * re-emit so a steady drip of signals against the same recommendation does
28
- * not retrigger enrichment.
29
16
  */
30
17
 
31
18
  import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
@@ -33,8 +20,6 @@ import { join } from 'node:path';
33
20
  import { randomUUID } from 'node:crypto';
34
21
  import { emitBestEffort } from '../roles/event-bus.mjs';
35
22
  import { cxDir } from '../paths.mjs';
36
- import { hasSqlStore } from '../storage/sql-store.mjs';
37
- import { createSqlClient } from '../storage/backend.mjs';
38
23
 
39
24
  const DEFAULT_SUPPRESS_DAYS = 7;
40
25
  const SUPERSEDE_WITHIN_HOURS = 72;
@@ -90,7 +75,6 @@ function priorityTier(score) {
90
75
 
91
76
  /**
92
77
  * Compute a dedup key from recommendation properties.
93
- * Two recommendations with the same dedup key are considered identical.
94
78
  */
95
79
  function dedupKey(type, title) {
96
80
  return `${type}::${title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 60)}`;
@@ -98,20 +82,6 @@ function dedupKey(type, title) {
98
82
 
99
83
  /**
100
84
  * Create a recommendation.
101
- *
102
- * @param {object} opts
103
- * @param {string} opts.type - 'prd' | 'adr' | 'rfc' | 'runbook' | 'postmortem'
104
- * @param {string} opts.title - Human-readable title
105
- * @param {string} opts.reason - Why this is being recommended
106
- * @param {number} [opts.signalCount=1] - Number of signals triggering this
107
- * @param {number} [opts.customerImpact=0] - 0-3: how many customers affected
108
- * @param {number} [opts.recencyBonus=0] - 0-3: how recent the signals are
109
- * @param {number} [opts.strategicBonus=0] - 0-3: strategic alignment
110
- * @param {string[]} [opts.sourceSignalIds] - IDs of intake signals that triggered this
111
- * @param {string} [opts.lane] - Docs lane this belongs to
112
- * @param {string} [opts.project] - Project name (defaults to CX_PROJECT or 'default')
113
- * @param {object} [opts.env] - Environment (defaults to process.env)
114
- * @returns {{ id: string, dedupKey: string, priority: string, score: number }}
115
85
  */
116
86
  export function createRecommendation({ type, title, reason, signalCount = 1, customerImpact = 0, recencyBonus = 0, strategicBonus = 0, sourceSignalIds = [], lane, project, env }) {
117
87
  if (!type || !title) throw new Error('type and title are required');
@@ -120,11 +90,9 @@ export function createRecommendation({ type, title, reason, signalCount = 1, cus
120
90
  const key = dedupKey(type, title);
121
91
  const index = readIndex();
122
92
 
123
- // Check if already exists (dedup)
124
93
  if (index[key]) {
125
94
  const existing = index[key];
126
95
  if (!existing.supersededAt) {
127
- // Update existing — increment signal count, extend dates
128
96
  const updated = {
129
97
  ...existing,
130
98
  lastSeen: new Date().toISOString(),
@@ -140,12 +108,8 @@ export function createRecommendation({ type, title, reason, signalCount = 1, cus
140
108
  index[key] = updated;
141
109
  writeIndex(index);
142
110
 
143
- const result = { id: existing.id, dedupKey: key, priority: updated.priority, score, existing: true, updatedAt: updated.lastSeen };
144
- createRecommendationPgBestEffort(updated, project, env);
145
- return result;
111
+ return { id: existing.id, dedupKey: key, priority: updated.priority, score, existing: true, updatedAt: updated.lastSeen };
146
112
  }
147
- // Existing was dismissed — create new instance with new signals
148
- // (superseded recommendations can be revived with fresh signals)
149
113
  }
150
114
 
151
115
  const id = `rec-${randomUUID().slice(0, 8)}`;
@@ -175,19 +139,11 @@ export function createRecommendation({ type, title, reason, signalCount = 1, cus
175
139
  enrichedBy: null,
176
140
  };
177
141
 
178
- // Append to log
179
142
  appendFileSync(recommendationPaths().logFile, JSON.stringify(rec) + '\n', 'utf8');
180
143
 
181
- // Update index
182
144
  index[key] = rec;
183
145
  writeIndex(index);
184
146
 
185
- createRecommendationPgBestEffort(rec, project, env);
186
-
187
- // Surfaces the new recommendation as a lifecycle event so the enrichment
188
- // loop (and any project-bound subscribers in .cx/specialists/) can route
189
- // the matched specialist's contract chain against it.
190
-
191
147
  emitBestEffort('recommendation.generated', {
192
148
  project: project || '',
193
149
  summary: `${rec.type}: ${rec.title}`,
@@ -203,23 +159,10 @@ export function createRecommendation({ type, title, reason, signalCount = 1, cus
203
159
  return { id, dedupKey: key, priority: rec.priority, score, existing: false };
204
160
  }
205
161
 
206
- /**
207
- * Compute prioritization score.
208
- * score = (signalCount * 2) + (customerImpact * 3) + (recencyBonus) + (strategicBonus)
209
- */
210
162
  function computeScore({ signalCount = 1, customerImpact = 0, recencyBonus = 0, strategicBonus = 0 }) {
211
163
  return (signalCount * 2) + (customerImpact * 3) + (recencyBonus) + (strategicBonus);
212
164
  }
213
165
 
214
- /**
215
- * Dismiss a recommendation (suppress permanently or for N days).
216
- *
217
- * @param {string} dedupKey - Dedup key
218
- * @param {object} [opts]
219
- * @param {string} [opts.reason] - Why dismissed
220
- * @param {number} [opts.suppressDays] - Days to suppress (default: forever)
221
- * @returns {{ success: boolean }}
222
- */
223
166
  export function dismissRecommendation(dedupKey, { reason = 'manually dismissed', suppressDays } = {}) {
224
167
  const index = readIndex();
225
168
  if (!index[dedupKey]) {
@@ -239,13 +182,6 @@ export function dismissRecommendation(dedupKey, { reason = 'manually dismissed',
239
182
  return { success: true };
240
183
  }
241
184
 
242
- /**
243
- * Supersede a recommendation (mark as superseded by a newer one).
244
- *
245
- * @param {string} dedupKey - Existing recommendation to supersede
246
- * @param {string} supersedingId - ID of the recommendation that replaces it
247
- * @returns {{ success: boolean }}
248
- */
249
185
  export function supersedeRecommendation(dedupKey, supersedingId) {
250
186
  const index = readIndex();
251
187
  if (!index[dedupKey]) {
@@ -263,41 +199,17 @@ export function supersedeRecommendation(dedupKey, supersedingId) {
263
199
  }
264
200
 
265
201
  /**
266
- * List active (non-dismissed, non-superseded) recommendations.
267
- *
268
- * In team/enterprise mode (DATABASE_URL configured), reads from Postgres when
269
- * the project has rows. Falls back to JSONL index.
270
- *
271
- * @param {object} [opts]
272
- * @param {string} [opts.type] - Filter by type
273
- * @param {string} [opts.priority] - Filter by priority (P0, P1, etc.)
274
- * @param {number} [opts.limit=20]
275
- * @param {string} [opts.project] - Project name (defaults to CX_PROJECT or 'default')
276
- * @param {object} [opts.env] - Environment (defaults to process.env)
277
- * @returns {Array<object>}
202
+ * List active recommendations (filesystem-only).
278
203
  */
279
204
  export function listActiveRecommendations({ type, priority, limit = 20, project, env } = {}) {
280
- if (hasSqlStore(env)) {
281
- return listActiveRecommendationsPgWithFallback({ type, priority, limit, project, env });
282
- }
283
- return listActiveRecommendationsFile({ type, priority, limit });
284
- }
285
-
286
- /**
287
- * File-based active recommendations list.
288
- */
289
- function listActiveRecommendationsFile({ type, priority, limit = 20 } = {}) {
290
205
  const index = readIndex();
291
206
  const now = new Date();
292
207
  let results = Object.values(index).filter(rec => {
293
- // Skip dismissed (unless temporary suppress and still active)
294
208
  if (rec.dismissedAt) {
295
209
  if (rec.suppressedUntil && new Date(rec.suppressedUntil) > now) return false;
296
210
  if (!rec.suppressedUntil) return false;
297
211
  }
298
- // Skip superseded
299
212
  if (rec.supersededAt) return false;
300
- // Skip suppressed
301
213
  if (rec.suppressedUntil && new Date(rec.suppressedUntil) > now) return false;
302
214
  return true;
303
215
  });
@@ -310,34 +222,6 @@ function listActiveRecommendationsFile({ type, priority, limit = 20 } = {}) {
310
222
  .slice(0, limit);
311
223
  }
312
224
 
313
- /**
314
- * Attempt Postgres read, fall back to file synchronously.
315
- * Returns a Promise so callers using await get Postgres data; callers not
316
- * using await get the file-based results via the sync fallback.
317
- */
318
- function listActiveRecommendationsPgWithFallback({ type, priority, limit, project, env }) {
319
- const resolvedProject = project || (env || process.env).CX_PROJECT || 'default';
320
- const client = createSqlClient(env);
321
-
322
- return listActiveRecommendationsPg(resolvedProject, { type, priority, limit }, client)
323
- .then(rows => {
324
- client.end({ timeout: 5 }).catch(() => {});
325
- if (rows.length > 0) return rows;
326
- return listActiveRecommendationsFile({ type, priority, limit });
327
- })
328
- .catch(() => {
329
- client.end({ timeout: 5 }).catch(() => {});
330
- return listActiveRecommendationsFile({ type, priority, limit });
331
- });
332
- }
333
-
334
- /**
335
- * Auto-suppress recommendations that have been active for DEFAULT_SUPPRESS_DAYS
336
- * without new signals, or that were superseded more than SUPERSEDE_WITHIN_HOURS ago.
337
- * Call from docs-lifecycle or a scheduled daemon job.
338
- *
339
- * @returns {number} Number of recommendations suppressed
340
- */
341
225
  export function autoSuppressStale() {
342
226
  const index = readIndex();
343
227
  const now = Date.now();
@@ -346,7 +230,6 @@ export function autoSuppressStale() {
346
230
  for (const [key, rec] of Object.entries(index)) {
347
231
  if (rec.dismissedAt || rec.supersededAt) continue;
348
232
 
349
- // Suppress if lastSeen is older than DEFAULT_SUPPRESS_DAYS
350
233
  const lastSeen = new Date(rec.lastSeen || rec.firstSeen).getTime();
351
234
  const ageDays = (now - lastSeen) / (24 * 60 * 60 * 1000);
352
235
  if (ageDays > DEFAULT_SUPPRESS_DAYS) {
@@ -359,7 +242,6 @@ export function autoSuppressStale() {
359
242
  continue;
360
243
  }
361
244
 
362
- // Suppress superseded older than threshold
363
245
  if (rec.supersededAt) {
364
246
  const supersededAt = new Date(rec.supersededAt).getTime();
365
247
  if ((now - supersededAt) > SUPERSEDE_WITHIN_HOURS * 60 * 60 * 1000) {
@@ -377,15 +259,6 @@ export function autoSuppressStale() {
377
259
  return suppressed;
378
260
  }
379
261
 
380
- /**
381
- * Revive a recommendation from dismissal with new signals.
382
- *
383
- * @param {string} dedupKey
384
- * @param {object} newSignals
385
- * @param {number} [newSignals.signalCount=1]
386
- * @param {string[]} [newSignals.sourceSignalIds]
387
- * @returns {{ success: boolean }}
388
- */
389
262
  export function reviveRecommendation(dedupKey, { signalCount = 1, sourceSignalIds = [] } = {}) {
390
263
  const index = readIndex();
391
264
  if (!index[dedupKey]) {
@@ -397,7 +270,7 @@ export function reviveRecommendation(dedupKey, { signalCount = 1, sourceSignalId
397
270
  const score = computeScore({
398
271
  signalCount: updatedSignalCount,
399
272
  customerImpact: existing.customerImpact || 0,
400
- recencyBonus: 3, // recent signals get max recency bonus
273
+ recencyBonus: 3,
401
274
  strategicBonus: existing.strategicBonus || 0,
402
275
  });
403
276
 
@@ -418,11 +291,6 @@ export function reviveRecommendation(dedupKey, { signalCount = 1, sourceSignalId
418
291
  return { success: true };
419
292
  }
420
293
 
421
- /**
422
- * Get store statistics.
423
- *
424
- * @returns {{ total: number, active: number, dismissed: number, superseded: number, byPriority: object }}
425
- */
426
294
  export function recommendationStats() {
427
295
  const index = readIndex();
428
296
  const entries = Object.values(index);
@@ -446,13 +314,6 @@ export function recommendationStats() {
446
314
  };
447
315
  }
448
316
 
449
- /**
450
- * Check if a recommendation with the given dedup key is active.
451
- *
452
- * @param {string} type
453
- * @param {string} title
454
- * @returns {{ active: boolean, existing: object|null }}
455
- */
456
317
  export function isRecommendationActive(type, title) {
457
318
  const key = dedupKey(type, title);
458
319
  const index = readIndex();
@@ -461,146 +322,3 @@ export function isRecommendationActive(type, title) {
461
322
  if (rec.dismissedAt || rec.supersededAt) return { active: false, existing: rec };
462
323
  return { active: true, existing: rec };
463
324
  }
464
-
465
- // ---------------------------------------------------------------------------
466
- // Postgres backend — team/enterprise mode only
467
- // ---------------------------------------------------------------------------
468
-
469
- /**
470
- * Upsert a recommendation into construct_recommendations.
471
- * On conflict (project, dedup_key) update all mutable fields.
472
- *
473
- * @param {object} rec - Recommendation object (camelCase)
474
- * @param {string} project
475
- * @param {object} client - postgres.js SQL client
476
- */
477
- export async function createRecommendationPg(rec, project, client) {
478
- await client`
479
- insert into construct_recommendations (
480
- id, project, dedup_key, type, title, reason, lane,
481
- signal_count, total_signal_count,
482
- customer_impact, recency_bonus, strategic_bonus,
483
- score, priority, source_signal_ids,
484
- first_seen, last_seen,
485
- dismissed_at, dismiss_reason,
486
- superseded_at, superseded_by_id,
487
- suppressed_until, suppress_reason,
488
- updated_at
489
- ) values (
490
- ${rec.id}, ${project}, ${rec.dedupKey || dedupKey(rec.type, rec.title)}, ${rec.type}, ${rec.title},
491
- ${rec.reason ?? null}, ${rec.lane ?? null},
492
- ${rec.signalCount ?? 1}, ${rec.totalSignalCount ?? 1},
493
- ${rec.customerImpact ?? 0}, ${rec.recencyBonus ?? 0}, ${rec.strategicBonus ?? 0},
494
- ${rec.score ?? 0}, ${rec.priority ?? 'P3'},
495
- ${JSON.stringify(rec.sourceSignalIds ?? [])}::jsonb,
496
- ${rec.firstSeen ?? new Date().toISOString()}, ${rec.lastSeen ?? new Date().toISOString()},
497
- ${rec.dismissedAt ?? null}, ${rec.dismissReason ?? null},
498
- ${rec.supersededAt ?? null}, ${rec.supersededById ?? null},
499
- ${rec.suppressedUntil ?? null}, ${rec.suppressReason ?? null},
500
- now()
501
- )
502
- on conflict (project, dedup_key) do update set
503
- signal_count = excluded.signal_count,
504
- total_signal_count = excluded.total_signal_count,
505
- customer_impact = excluded.customer_impact,
506
- recency_bonus = excluded.recency_bonus,
507
- strategic_bonus = excluded.strategic_bonus,
508
- score = excluded.score,
509
- priority = excluded.priority,
510
- source_signal_ids = excluded.source_signal_ids,
511
- last_seen = excluded.last_seen,
512
- dismissed_at = excluded.dismissed_at,
513
- dismiss_reason = excluded.dismiss_reason,
514
- superseded_at = excluded.superseded_at,
515
- superseded_by_id = excluded.superseded_by_id,
516
- suppressed_until = excluded.suppressed_until,
517
- suppress_reason = excluded.suppress_reason,
518
- updated_at = now()
519
- `;
520
- }
521
-
522
- /**
523
- * Query active recommendations from Postgres for a project.
524
- *
525
- * @param {string} project
526
- * @param {object} [opts]
527
- * @param {string} [opts.type]
528
- * @param {string} [opts.priority]
529
- * @param {number} [opts.limit=20]
530
- * @param {object} client - postgres.js SQL client
531
- * @returns {Promise<Array<object>>}
532
- */
533
- export async function listActiveRecommendationsPg(project, { type, priority, limit = 20 } = {}, client) {
534
- const rows = await client`
535
- select *
536
- from construct_recommendations
537
- where project = ${project}
538
- and dismissed_at is null
539
- and superseded_at is null
540
- and (suppressed_until is null or suppressed_until <= now())
541
- ${type ? client`and type = ${type}` : client``}
542
- ${priority ? client`and priority = ${priority}` : client``}
543
- order by score desc
544
- limit ${limit}
545
- `;
546
-
547
- return rows.map(row => ({
548
- id: row.id,
549
- type: row.type,
550
- title: row.title,
551
- reason: row.reason,
552
- lane: row.lane,
553
- signalCount: row.signal_count,
554
- totalSignalCount: row.total_signal_count,
555
- customerImpact: row.customer_impact,
556
- recencyBonus: row.recency_bonus,
557
- strategicBonus: row.strategic_bonus,
558
- score: row.score,
559
- priority: row.priority,
560
- sourceSignalIds: row.source_signal_ids ?? [],
561
- firstSeen: row.first_seen instanceof Date ? row.first_seen.toISOString() : row.first_seen,
562
- lastSeen: row.last_seen instanceof Date ? row.last_seen.toISOString() : row.last_seen,
563
- dismissedAt: row.dismissed_at,
564
- supersededAt: row.superseded_at,
565
- suppressedUntil: row.suppressed_until,
566
- }));
567
- }
568
-
569
- /**
570
- * Dismiss a recommendation in Postgres.
571
- *
572
- * @param {string} dedupKeyValue
573
- * @param {string} project
574
- * @param {object} [opts]
575
- * @param {string} [opts.reason]
576
- * @param {number} [opts.suppressDays]
577
- * @param {object} client - postgres.js SQL client
578
- */
579
- export async function dismissRecommendationPg(dedupKeyValue, project, { reason = 'manually dismissed', suppressDays } = {}, client) {
580
- const suppressedUntil = suppressDays
581
- ? new Date(Date.now() + suppressDays * 24 * 60 * 60 * 1000).toISOString()
582
- : null;
583
-
584
- await client`
585
- update construct_recommendations
586
- set
587
- dismissed_at = now(),
588
- dismiss_reason = ${reason},
589
- suppressed_until = ${suppressedUntil},
590
- updated_at = now()
591
- where project = ${project}
592
- and dedup_key = ${dedupKeyValue}
593
- `;
594
- }
595
-
596
- /**
597
- * Fire-and-forget Postgres upsert — never throws, never blocks the caller.
598
- */
599
- function createRecommendationPgBestEffort(rec, project, env) {
600
- if (!hasSqlStore(env)) return;
601
- const resolvedProject = project || (env || process.env).CX_PROJECT || 'default';
602
- const client = createSqlClient(env);
603
- createRecommendationPg(rec, resolvedProject, client)
604
- .catch(() => {})
605
- .finally(() => client.end({ timeout: 5 }).catch(() => {}));
606
- }