@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.
- package/README.md +51 -42
- package/agents/prompts/cx-ai-engineer.md +6 -26
- package/agents/prompts/cx-architect.md +1 -0
- package/agents/prompts/cx-business-strategist.md +2 -0
- package/agents/prompts/cx-data-analyst.md +6 -26
- package/agents/prompts/cx-docs-keeper.md +1 -31
- package/agents/prompts/cx-explorer.md +1 -0
- package/agents/prompts/cx-orchestrator.md +40 -112
- package/agents/prompts/cx-platform-engineer.md +2 -22
- package/agents/prompts/cx-product-manager.md +2 -1
- package/agents/prompts/cx-qa.md +0 -20
- package/agents/prompts/cx-rd-lead.md +2 -0
- package/agents/prompts/cx-researcher.md +77 -31
- package/agents/prompts/cx-security.md +11 -49
- package/agents/prompts/cx-sre.md +9 -43
- package/agents/prompts/cx-ux-researcher.md +1 -0
- package/agents/role-manifests.json +4 -4
- package/bin/construct +279 -4
- package/commands/understand/research.md +5 -3
- package/db/schema/004_recommendations.sql +46 -0
- package/db/schema/005_strategy.sql +21 -0
- package/lib/auto-docs.mjs +3 -4
- package/lib/beads-automation.mjs +16 -7
- package/lib/cli-commands.mjs +52 -2
- package/lib/comment-lint.mjs +7 -1
- package/lib/config/schema.mjs +3 -0
- package/lib/embed/conflict-detection.mjs +26 -9
- package/lib/embed/customer-profiles.mjs +37 -17
- package/lib/embed/daemon.mjs +10 -8
- package/lib/embed/recommendation-store.mjs +213 -14
- package/lib/embed/workspaces.mjs +53 -18
- package/lib/flavors/loader.mjs +136 -0
- package/lib/gates-audit.mjs +3 -3
- package/lib/health-check.mjs +1 -1
- package/lib/hooks/agent-tracker.mjs +22 -3
- package/lib/hooks/pre-compact.mjs +3 -0
- package/lib/hooks/pre-push-gate.mjs +14 -1
- package/lib/hooks/read-tracker.mjs +10 -101
- package/lib/hooks/session-optimize.mjs +3 -2
- package/lib/hooks/session-reflect.mjs +68 -0
- package/lib/host-capabilities.mjs +90 -1
- package/lib/init-unified.mjs +25 -2
- package/lib/init-update.mjs +246 -131
- package/lib/intake/classify.mjs +61 -183
- package/lib/intake/prepare.mjs +7 -0
- package/lib/intake/tables/creative.mjs +94 -0
- package/lib/intake/tables/operations.mjs +85 -0
- package/lib/intake/tables/research.mjs +85 -0
- package/lib/intake/tables/rnd.mjs +175 -0
- package/lib/intent-classifier.mjs +1 -0
- package/lib/knowledge/layout.mjs +10 -0
- package/lib/knowledge/research-store.mjs +109 -0
- package/lib/mcp/tools/telemetry.mjs +30 -78
- package/lib/model-router.mjs +61 -1
- package/lib/observation-store.mjs +19 -0
- package/lib/ollama-manager.mjs +1 -1
- package/lib/opencode-telemetry.mjs +4 -5
- package/lib/orchestration-policy.mjs +9 -0
- package/lib/outcomes/aggregate.mjs +104 -0
- package/lib/outcomes/record.mjs +115 -0
- package/lib/parity.mjs +121 -21
- package/lib/profiles/lifecycle.mjs +388 -0
- package/lib/profiles/loader.mjs +123 -0
- package/lib/profiles/validate-custom.mjs +114 -0
- package/lib/prompt-composer.js +106 -29
- package/lib/read-tracker-store.mjs +149 -0
- package/lib/reflect/extractor.mjs +193 -0
- package/lib/reflect.mjs +89 -2
- package/lib/sandbox.mjs +102 -0
- package/lib/server/index.mjs +76 -0
- package/lib/server/telemetry-login.mjs +17 -25
- package/lib/service-manager.mjs +30 -22
- package/lib/services/local-postgres.mjs +15 -0
- package/lib/services/telemetry-backend.mjs +1 -2
- package/lib/setup.mjs +8 -43
- package/lib/status.mjs +51 -5
- package/lib/storage/backend.mjs +12 -2
- package/lib/strategy-store.mjs +371 -0
- package/lib/telemetry/backends/local.mjs +6 -4
- package/lib/telemetry/client.mjs +185 -0
- package/lib/telemetry/ingest.mjs +13 -5
- package/lib/telemetry/team-rollup.mjs +9 -2
- package/lib/worker/trace.mjs +17 -27
- package/package.json +10 -2
- package/personas/construct.md +20 -20
- package/platforms/claude/settings.template.json +13 -0
- package/rules/common/research.md +44 -12
- package/scripts/sync-agents.mjs +11 -0
- package/skills/docs/backlog-proposal-workflow.md +2 -2
- package/skills/docs/customer-profile-workflow.md +1 -1
- package/skills/docs/evidence-ingest-workflow.md +5 -5
- package/skills/docs/prfaq-workflow.md +1 -1
- package/skills/docs/product-intelligence-review.md +1 -1
- package/skills/docs/product-intelligence-workflow.md +3 -3
- package/skills/docs/product-signal-workflow.md +48 -18
- package/skills/docs/research-workflow.md +26 -14
- package/skills/docs/strategy-workflow.md +36 -0
- package/skills/roles/architect.ai-systems.md +4 -2
- package/skills/roles/architect.data.md +4 -2
- package/skills/roles/architect.enterprise.md +4 -2
- package/skills/roles/architect.integration.md +4 -2
- package/skills/roles/architect.md +7 -5
- package/skills/roles/architect.platform.md +4 -2
- package/skills/roles/data-analyst.experiment.md +4 -2
- package/skills/roles/data-analyst.md +9 -7
- package/skills/roles/data-analyst.product-intelligence.md +5 -3
- package/skills/roles/data-analyst.product.md +4 -2
- package/skills/roles/data-analyst.telemetry.md +4 -2
- package/skills/roles/data-engineer.pipeline.md +4 -2
- package/skills/roles/data-engineer.vector-retrieval.md +4 -2
- package/skills/roles/data-engineer.warehouse.md +4 -2
- package/skills/roles/debugger.md +7 -5
- package/skills/roles/designer.accessibility.md +4 -2
- package/skills/roles/designer.md +10 -8
- package/skills/roles/engineer.ai.md +4 -2
- package/skills/roles/engineer.data.md +5 -3
- package/skills/roles/engineer.md +14 -12
- package/skills/roles/engineer.platform.md +5 -3
- package/skills/roles/operator.docs.md +6 -4
- package/skills/roles/operator.md +6 -4
- package/skills/roles/operator.release.md +4 -2
- package/skills/roles/operator.sre.md +5 -3
- package/skills/roles/orchestrator.md +5 -3
- package/skills/roles/product-manager.ai-product.md +4 -2
- package/skills/roles/product-manager.business-strategy.md +4 -2
- package/skills/roles/product-manager.enterprise.md +4 -2
- package/skills/roles/product-manager.growth.md +4 -2
- package/skills/roles/product-manager.md +6 -4
- package/skills/roles/product-manager.platform.md +4 -2
- package/skills/roles/product-manager.product.md +4 -2
- package/skills/roles/qa.ai-eval.md +4 -2
- package/skills/roles/qa.api-contract.md +4 -2
- package/skills/roles/qa.data-pipeline.md +4 -2
- package/skills/roles/qa.md +7 -5
- package/skills/roles/qa.test-automation.md +5 -3
- package/skills/roles/qa.web-ui.md +4 -2
- package/skills/roles/researcher.explorer.md +4 -2
- package/skills/roles/researcher.md +35 -20
- package/skills/roles/researcher.ux.md +4 -2
- package/skills/roles/reviewer.devil-advocate.md +4 -2
- package/skills/roles/reviewer.evaluator.md +4 -2
- package/skills/roles/reviewer.md +14 -12
- package/skills/roles/reviewer.trace.md +4 -2
- package/skills/roles/security.ai.md +4 -2
- package/skills/roles/security.appsec.md +4 -2
- package/skills/roles/security.cloud.md +4 -2
- package/skills/roles/security.legal-compliance.md +4 -2
- package/skills/roles/security.md +7 -5
- package/skills/roles/security.privacy.md +4 -2
- package/skills/roles/security.supply-chain.md +4 -2
- package/skills/routing.md +8 -1
- package/templates/docs/persona-artifact.md +36 -0
- package/templates/docs/research-brief.md +63 -9
- package/templates/docs/research-finding.md +26 -0
- package/templates/docs/skill-artifact.md +27 -0
- package/templates/docs/strategy.md +36 -0
- 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/
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
const { indexFile } = indexPaths();
|
|
75
|
+
if (!forceRebuild && existsSync(indexFile)) {
|
|
59
76
|
try {
|
|
60
|
-
const cached = JSON.parse(readFileSync(
|
|
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(
|
|
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/
|
|
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/
|
|
10
|
-
* ~/.cx/
|
|
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 {
|
|
18
|
+
import { cxDir } from '../paths.mjs';
|
|
19
|
+
import { knowledgeInternalStore } from '../knowledge/layout.mjs';
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
53
|
+
const { indexFile } = profilePaths();
|
|
54
|
+
if (!existsSync(indexFile)) return {};
|
|
38
55
|
try {
|
|
39
|
-
return JSON.parse(readFileSync(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
package/lib/embed/daemon.mjs
CHANGED
|
@@ -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
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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 {
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
const { indexFile } = recommendationPaths();
|
|
55
|
+
if (!existsSync(indexFile)) return {};
|
|
43
56
|
try {
|
|
44
|
-
return JSON.parse(readFileSync(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|