@cgh567/agent 2.4.2 → 2.4.4
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/agents/business/talisman-ceo.md +183 -0
- package/agents/business/talisman-comms.md +257 -0
- package/agents/business/talisman-cto.md +153 -0
- package/agents/business/talisman-finance.md +246 -0
- package/agents/business/talisman-marketing.md +240 -0
- package/agents/business/talisman-sales.md +242 -0
- package/agents/business/talisman-support.md +236 -0
- package/bin/helios-rpc.js +19 -0
- package/daemon/adapters/helios-rpc-adapter.js +5 -12
- package/daemon/adapters/tui_wakeup.js +8 -0
- package/daemon/context-enrichment.js +27 -0
- package/daemon/daemon-manager.js +1 -1
- package/daemon/db/email-infrastructure-migrate.js +192 -0
- package/daemon/db/hbo-core-migrate.js +189 -0
- package/daemon/helios-api.js +863 -64
- package/daemon/helios-company-daemon.js +233 -33
- package/daemon/lib/blast-radius-analyzer.js +75 -0
- package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
- package/daemon/lib/forensic-log.js +113 -0
- package/daemon/lib/goal-research-pipeline.js +644 -0
- package/daemon/lib/harada/cascade-judge.js +84 -1
- package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
- package/daemon/lib/harada/pillar-dispatcher.js +23 -2
- package/daemon/lib/hbo-bridge.js +74 -6
- package/daemon/lib/headroom-middleware.js +129 -0
- package/daemon/lib/headroom-proxy-manager.js +309 -0
- package/daemon/lib/hed-engine.js +25 -0
- package/daemon/lib/intelligence/department-page-generator.js +46 -1
- package/daemon/lib/interpretation-engine.js +92 -0
- package/daemon/lib/mental-model-cache.js +96 -0
- package/daemon/lib/project-factory.js +47 -0
- package/daemon/lib/session-log-reader.js +93 -0
- package/daemon/lib/standard-work-bootstrap.js +87 -1
- package/daemon/lib/task-completion-processor.js +23 -0
- package/daemon/lib/wizard-engine.js +57 -6
- package/daemon/package.json +2 -1
- package/daemon/routes/agents.js +51 -6
- package/daemon/routes/channels.js +116 -2
- package/daemon/routes/crm.js +85 -0
- package/daemon/routes/dashboard.js +62 -16
- package/daemon/routes/dept.js +10 -1
- package/daemon/routes/email-triage.js +19 -10
- package/daemon/routes/hbo.js +618 -58
- package/daemon/routes/hed.js +133 -0
- package/daemon/routes/inbox.js +397 -8
- package/daemon/routes/project.js +580 -66
- package/daemon/routes/routines.js +14 -0
- package/daemon/routes/tasks.js +15 -1
- package/daemon/schema-apply.js +174 -0
- package/daemon/schema-definitions.js +433 -0
- package/daemon/schema-migrations-hbo.js +20 -0
- package/daemon/schema-migrations-hed.js +18 -0
- package/daemon/schema-migrations-proj.js +153 -0
- package/extensions/__tests__/codebase-index.test.ts +73 -0
- package/extensions/__tests__/extension-command-registration.test.ts +35 -0
- package/extensions/__tests__/git-push-guard.test.ts +68 -0
- package/extensions/context-compaction.ts +104 -76
- package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
- package/extensions/cortex/wal-replay.ts +91 -0
- package/extensions/email/actions/draft-response.ts +21 -1
- package/extensions/email/auth/accounts.ts +5 -11
- package/extensions/email/auth/inbox-dog.ts +5 -2
- package/extensions/email/backfill.ts +20 -13
- package/extensions/email/providers/gmail.ts +164 -0
- package/extensions/email/providers/google-calendar.ts +34 -5
- package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
- package/extensions/helios-browser/backends/playwright.ts +3 -1
- package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
- package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
- package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
- package/extensions/hema-dispatch-v3/index.ts +46 -72
- package/extensions/interview/__tests__/server.test.ts +117 -0
- package/extensions/lib/helios-root.cjs +46 -0
- package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
- package/extensions/warm-tick/warm-tick-maintenance.ts +164 -0
- package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
- package/lib/__tests__/crash-fixes.test.ts +49 -0
- package/lib/__tests__/hbo-core-store.test.js +238 -0
- package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
- package/lib/broker/__tests__/jit-subscription.test.js +44 -1
- package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
- package/lib/compression/__tests__/ccr-store.test.js +138 -0
- package/lib/compression/__tests__/pipeline.test.js +280 -0
- package/lib/compression/__tests__/smart-crusher.test.js +242 -0
- package/lib/compression/dist/server.js +34 -1
- package/lib/compression/dist/start-server.js +77 -0
- package/lib/event-bus.mts +1 -1
- package/lib/graph/learning/headroom-learn-bridge.js +175 -0
- package/lib/graph-availability.js +62 -0
- package/lib/hbo-core-store.compiled.js +834 -0
- package/lib/hbo-core-store.js +124 -0
- package/lib/hbo-core-store.ts +979 -0
- package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
- package/lib/skill-sync.js +6 -1
- package/lib/startup-integrity.js +9 -2
- package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
- package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
- package/lib/triage-core/__tests__/classifier.test.ts +45 -7
- package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
- package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
- package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
- package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
- package/lib/triage-core/__tests__/signals.test.ts +357 -0
- package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
- package/lib/triage-core/backfill-cost-estimator.ts +91 -0
- package/lib/triage-core/backfill-orchestrator.ts +119 -0
- package/lib/triage-core/classifier.ts +41 -8
- package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
- package/lib/triage-core/cos/response-debt.ts +2 -2
- package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
- package/lib/triage-core/graph/batch-persistence.ts +66 -2
- package/lib/triage-core/graph/betweenness-worker.js +75 -0
- package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
- package/lib/triage-core/graph/persistence.ts +1 -1
- package/lib/triage-core/graph/schema-v2.ts +2 -0
- package/lib/triage-core/graph/schema.cypher +11 -0
- package/lib/triage-core/graph/triage-query.ts +1 -1
- package/lib/triage-core/learning.ts +15 -20
- package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
- package/lib/triage-core/mental-model/cos-integration.ts +1 -1
- package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
- package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
- package/lib/triage-core/mental-model/key-facts.ts +1 -2
- package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
- package/lib/triage-core/mental-model/model-assembler.ts +16 -3
- package/lib/triage-core/orchestrator.ts +8 -15
- package/lib/triage-core/scheduled-sends.ts +39 -2
- package/lib/triage-core/signals/comms-style.ts +1 -1
- package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
- package/lib/triage-core/signals/favee-type.ts +6 -1
- package/lib/triage-core/signals/goal-relevance.ts +31 -2
- package/lib/triage-core/signals/personal-importance.ts +1 -1
- package/lib/triage-core/signals/referral-chain.ts +0 -1
- package/lib/triage-core/signals/relationship-decay.ts +4 -0
- package/lib/triage-core/signals/relationship-health.ts +6 -1
- package/lib/triage-core/signals/trajectory-signal.ts +38 -3
- package/lib/triage-core/tournament-runner.js +11 -1
- package/lib/triage-core/triage-llm-factory.ts +110 -0
- package/lib/triage-core/triage-local-llm.ts +145 -0
- package/lib/triage-core/triage-sql-store.ts +337 -0
- package/lib/triage-core/types.ts +2 -2
- package/lib/unified-graph.atomic.test.ts +52 -0
- package/lib/unified-graph.failure-categories.test.ts +55 -0
- package/package.json +18 -7
- package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
- package/prebuilds/linux-x64/better_sqlite3.node +0 -0
- package/prebuilds/win32-x64/better_sqlite3.node +0 -0
- package/skills/helios-bookkeeping/SKILL.md +321 -0
- package/skills/helios-briefer/SKILL.md +44 -0
- package/skills/helios-client-relations/SKILL.md +322 -0
- package/skills/helios-personal-triager/SKILL.md +45 -0
- package/skills/helios-recruitment/SKILL.md +317 -0
- package/skills/helios-relationship-nudger/SKILL.md +77 -0
- package/skills/helios-researcher/SKILL.md +44 -0
- package/skills/helios-scheduler/SKILL.md +58 -0
- package/skills/helios-tax-analyst/SKILL.md +280 -0
- package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* daemon/lib/goal-research-pipeline.js — GoalResearchPipeline
|
|
4
|
+
*
|
|
5
|
+
* Runs 10 parallel research sources for a CompanyGoal before the Three Cs
|
|
6
|
+
* interpretation loop fires. Writes a GoalResearchBrief node to Memgraph
|
|
7
|
+
* and links it to the CompanyGoal via HAS_RESEARCH edge.
|
|
8
|
+
*
|
|
9
|
+
* Called by:
|
|
10
|
+
* - wizard-engine.js (Step 2 submit — fires async after company created)
|
|
11
|
+
* - hbo-bridge.js tickGoalDecompose() — if brief missing/stale, research first
|
|
12
|
+
*
|
|
13
|
+
* Design principles:
|
|
14
|
+
* - Idempotent: skips if fresh brief (< 24h) already exists for this goal
|
|
15
|
+
* - Fail-open: any source failure returns null for that source — pipeline continues
|
|
16
|
+
* - 15s total timeout via Promise.race on the full parallel batch
|
|
17
|
+
* - No new infrastructure: SearXNG via hbo-research-bridge, Memgraph via mgQuery
|
|
18
|
+
* - Pure CJS — no TypeScript, matches daemon/lib/ convention
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
|
|
24
|
+
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
25
|
+
const RESEARCH_TIMEOUT_MS = 15000; // 15s total
|
|
26
|
+
|
|
27
|
+
// ── Lazy-loaded deps ─────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function _getResearchBridge(mg, companyId) {
|
|
30
|
+
try {
|
|
31
|
+
const { HBOResearchBridge } = require('./hbo-research-bridge');
|
|
32
|
+
// Pass positional (mgQuery, companyId) — bridge uses companyId for log labels only
|
|
33
|
+
return new HBOResearchBridge(mg, companyId || 'pipeline');
|
|
34
|
+
} catch (_) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let _isBusinessSignal = null;
|
|
40
|
+
function _getIsBusinessSignal() {
|
|
41
|
+
if (_isBusinessSignal !== null) return _isBusinessSignal;
|
|
42
|
+
try {
|
|
43
|
+
_isBusinessSignal = require('./mental-model-service').isBusinessSignal;
|
|
44
|
+
} catch (_) {
|
|
45
|
+
_isBusinessSignal = () => true; // default to business framing if import fails
|
|
46
|
+
}
|
|
47
|
+
return _isBusinessSignal;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Logging ───────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function _log(level, msg, extra) {
|
|
53
|
+
const entry = JSON.stringify({ ts: new Date().toISOString(), level, module: 'goal-research-pipeline', msg, ...extra });
|
|
54
|
+
process.stdout.write(entry + '\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Source runners ────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* SearXNG web sources (sources 2–5).
|
|
61
|
+
* Returns { marketContext, competitorContext, strategyContext, newsContext }.
|
|
62
|
+
*/
|
|
63
|
+
async function _runWebSources(goalTitle, companyName, bridge) {
|
|
64
|
+
if (!bridge || typeof bridge.search !== 'function') {
|
|
65
|
+
return { marketContext: null, competitorContext: null, strategyContext: null, newsContext: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const industry = companyName ? `${companyName} industry` : '';
|
|
69
|
+
const [market, competitors, strategy, news] = await Promise.allSettled([
|
|
70
|
+
bridge.search(`${goalTitle} ${industry} benchmark market data 2024 2025`),
|
|
71
|
+
bridge.search(`${goalTitle} competitors strategies how to achieve`),
|
|
72
|
+
bridge.search(`${goalTitle} OKR framework best practices strategy 2024`),
|
|
73
|
+
companyName ? bridge.search(`${companyName} news growth 2024 2025`) : Promise.resolve(null),
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const _fmt = (r) => {
|
|
77
|
+
if (r.status !== 'fulfilled' || !r.value?.results?.length) return null;
|
|
78
|
+
return r.value.results.slice(0, 3)
|
|
79
|
+
.map(x => `- ${(x.title || '').slice(0, 100)}: ${(x.content || '').slice(0, 200)}`)
|
|
80
|
+
.join('\n');
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
marketContext: _fmt(market),
|
|
85
|
+
competitorContext: _fmt(competitors),
|
|
86
|
+
strategyContext: _fmt(strategy),
|
|
87
|
+
newsContext: _fmt(news),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Company website crawl (source 1).
|
|
93
|
+
* Returns companyWebsite string or null.
|
|
94
|
+
*/
|
|
95
|
+
async function _runWebsiteCrawl(domain, bridge) {
|
|
96
|
+
if (!domain || !bridge || typeof bridge.crawl !== 'function') return null;
|
|
97
|
+
try {
|
|
98
|
+
const url = domain.startsWith('http') ? domain : `https://${domain}`;
|
|
99
|
+
const result = await bridge.crawl(url, { timeout: 8000, maxChars: 2000 });
|
|
100
|
+
if (!result || !result.text) return null;
|
|
101
|
+
return result.text.slice(0, 2000);
|
|
102
|
+
} catch (_) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Memgraph CRM context (source 6).
|
|
109
|
+
* Reads Lead/Opportunity/Account nodes for the company.
|
|
110
|
+
*/
|
|
111
|
+
async function _runCrmContext(companyId, mg) {
|
|
112
|
+
try {
|
|
113
|
+
const result = await mg(
|
|
114
|
+
`MATCH (n)
|
|
115
|
+
WHERE (n:Lead OR n:Opportunity OR n:Account) AND n.companyId = $cid
|
|
116
|
+
RETURN labels(n)[0] AS type, n.name AS name, n.value AS value,
|
|
117
|
+
n.status AS status, n.stage AS stage
|
|
118
|
+
ORDER BY n.createdAt DESC LIMIT 20`,
|
|
119
|
+
{ cid: companyId }
|
|
120
|
+
);
|
|
121
|
+
const rows = result?.rows ?? [];
|
|
122
|
+
if (!rows.length) return null;
|
|
123
|
+
const lines = rows.map(r => {
|
|
124
|
+
const [type, name, value, status, stage] = Array.isArray(r) ? r : [r.type, r.name, r.value, r.status, r.stage];
|
|
125
|
+
const parts = [type, name].filter(Boolean);
|
|
126
|
+
if (value) parts.push(`$${value}`);
|
|
127
|
+
if (stage || status) parts.push(stage || status);
|
|
128
|
+
return `- ${parts.join(' | ')}`;
|
|
129
|
+
});
|
|
130
|
+
return `CRM pipeline (${rows.length} records):\n${lines.join('\n')}`;
|
|
131
|
+
} catch (_) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Prior task results (source 7).
|
|
138
|
+
* Reads recent TaskResult nodes that mention the goal domain.
|
|
139
|
+
*/
|
|
140
|
+
async function _runPriorTaskResults(companyId, goalTitle, mg) {
|
|
141
|
+
try {
|
|
142
|
+
const keywords = goalTitle.split(/\s+/).filter(w => w.length > 3).slice(0, 3);
|
|
143
|
+
if (!keywords.length) return null;
|
|
144
|
+
// Build a simple WHERE clause checking result text for any keyword
|
|
145
|
+
const result = await mg(
|
|
146
|
+
`MATCH (tr:TaskResult {companyId: $cid})
|
|
147
|
+
WHERE tr.summary IS NOT NULL
|
|
148
|
+
RETURN tr.summary AS summary, tr.createdAt AS createdAt
|
|
149
|
+
ORDER BY tr.createdAt DESC LIMIT 10`,
|
|
150
|
+
{ cid: companyId }
|
|
151
|
+
);
|
|
152
|
+
const rows = result?.rows ?? [];
|
|
153
|
+
if (!rows.length) return null;
|
|
154
|
+
// Filter client-side for keyword relevance
|
|
155
|
+
const kw = keywords.map(k => k.toLowerCase());
|
|
156
|
+
const relevant = rows.filter(r => {
|
|
157
|
+
const text = (Array.isArray(r) ? r[0] : r.summary || '').toLowerCase();
|
|
158
|
+
return kw.some(k => text.includes(k));
|
|
159
|
+
}).slice(0, 5);
|
|
160
|
+
if (!relevant.length) return null;
|
|
161
|
+
return relevant.map(r => `- ${(Array.isArray(r) ? r[0] : r.summary || '').slice(0, 200)}`).join('\n');
|
|
162
|
+
} catch (_) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Company beliefs context (source 8).
|
|
169
|
+
* Reads confirmed CompanyBelief nodes.
|
|
170
|
+
*/
|
|
171
|
+
async function _runBeliefsContext(companyId, mg) {
|
|
172
|
+
try {
|
|
173
|
+
const result = await mg(
|
|
174
|
+
`MATCH (cb:CompanyBelief {companyId: $cid, status: 'confirmed'})
|
|
175
|
+
RETURN cb.question AS question, cb.answer AS answer
|
|
176
|
+
ORDER BY cb.confirmedAt DESC LIMIT 10`,
|
|
177
|
+
{ cid: companyId }
|
|
178
|
+
);
|
|
179
|
+
const rows = result?.rows ?? [];
|
|
180
|
+
if (!rows.length) return null;
|
|
181
|
+
return rows.map(r => {
|
|
182
|
+
const [q, a] = Array.isArray(r) ? r : [r.question, r.answer];
|
|
183
|
+
return `- Q: ${(q || '').slice(0, 150)}\n A: ${(a || '').slice(0, 200)}`;
|
|
184
|
+
}).join('\n');
|
|
185
|
+
} catch (_) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Department plans context (source 9).
|
|
192
|
+
* Reads SystemAim.strategicDirectives and active GoalPillar nodes.
|
|
193
|
+
*/
|
|
194
|
+
async function _runDepartmentPlansContext(companyId, mg) {
|
|
195
|
+
try {
|
|
196
|
+
const [aimResult, pillarResult] = await Promise.allSettled([
|
|
197
|
+
mg(
|
|
198
|
+
`MATCH (sa:SystemAim {companyId: $cid})
|
|
199
|
+
RETURN sa.mission AS mission, sa.strategicDirectives AS directives LIMIT 1`,
|
|
200
|
+
{ cid: companyId }
|
|
201
|
+
),
|
|
202
|
+
mg(
|
|
203
|
+
`MATCH (gp:GoalPillar {companyId: $cid})
|
|
204
|
+
WHERE gp.status IN ['active', 'in_progress']
|
|
205
|
+
RETURN gp.name AS name, gp.description AS desc
|
|
206
|
+
ORDER BY gp.createdAt DESC LIMIT 8`,
|
|
207
|
+
{ cid: companyId }
|
|
208
|
+
),
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
const parts = [];
|
|
212
|
+
if (aimResult.status === 'fulfilled') {
|
|
213
|
+
const row = aimResult.value?.rows?.[0];
|
|
214
|
+
if (row) {
|
|
215
|
+
const [mission, directives] = Array.isArray(row) ? row : [row.mission, row.directives];
|
|
216
|
+
if (mission) parts.push(`Mission: ${String(mission).slice(0, 300)}`);
|
|
217
|
+
if (directives) {
|
|
218
|
+
const dirs = Array.isArray(directives) ? directives : [directives];
|
|
219
|
+
parts.push(`Strategic directives:\n${dirs.slice(0, 5).map(d => `- ${String(d).slice(0, 150)}`).join('\n')}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (pillarResult.status === 'fulfilled') {
|
|
224
|
+
const rows = pillarResult.value?.rows ?? [];
|
|
225
|
+
if (rows.length) {
|
|
226
|
+
parts.push(`Active pillars:\n${rows.map(r => {
|
|
227
|
+
const [name, desc] = Array.isArray(r) ? r : [r.name, r.desc];
|
|
228
|
+
return `- ${name || ''}: ${(desc || '').slice(0, 100)}`;
|
|
229
|
+
}).join('\n')}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return parts.length ? parts.join('\n\n') : null;
|
|
233
|
+
} catch (_) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Industry-specific tournament candidate strategies.
|
|
240
|
+
* Keys are industry slugs matching SystemAim.industry values.
|
|
241
|
+
*/
|
|
242
|
+
const INDUSTRY_CANDIDATES = {
|
|
243
|
+
'professional-services/tax': [
|
|
244
|
+
{ label: 'Client acquisition via referral network and trust-building' },
|
|
245
|
+
{ label: 'Regulatory compliance automation — ZIMRA/IRS/HMRC filing efficiency' },
|
|
246
|
+
{ label: 'Self-service digital portal reducing manual client touchpoints' },
|
|
247
|
+
{ label: 'SMB market penetration via affordable tiered pricing' },
|
|
248
|
+
{ label: 'WhatsApp and mobile-native service delivery for emerging markets' },
|
|
249
|
+
],
|
|
250
|
+
'professional-services/legal': [
|
|
251
|
+
{ label: 'Contract automation and document template library' },
|
|
252
|
+
{ label: 'Compliance monitoring for regulatory changes affecting clients' },
|
|
253
|
+
{ label: 'Referral network expansion via professional associations' },
|
|
254
|
+
{ label: 'Subscription retainer model for recurring legal advisory' },
|
|
255
|
+
{ label: 'Digital client portal for document exchange and status tracking' },
|
|
256
|
+
],
|
|
257
|
+
'healthcare': [
|
|
258
|
+
{ label: 'Patient acquisition via community outreach and referral programs' },
|
|
259
|
+
{ label: 'Telehealth expansion to reduce access barriers' },
|
|
260
|
+
{ label: 'Preventive care program to improve patient outcomes and retention' },
|
|
261
|
+
{ label: 'Insurance and billing process automation to reduce administrative burden' },
|
|
262
|
+
{ label: 'Specialist partnership network for referral and care coordination' },
|
|
263
|
+
],
|
|
264
|
+
'research-platform': [
|
|
265
|
+
{ label: 'Participant pool expansion via WhatsApp and community networks' },
|
|
266
|
+
{ label: 'Researcher onboarding automation to reduce time-to-first-study' },
|
|
267
|
+
{ label: 'Platform marketplace connecting researchers with participant segments' },
|
|
268
|
+
{ label: 'Longitudinal study capability to increase researcher stickiness' },
|
|
269
|
+
{ label: 'Academic institution partnerships for credibility and volume' },
|
|
270
|
+
],
|
|
271
|
+
'saas/b2b': [
|
|
272
|
+
{ label: 'Product-led growth — feature-driven acquisition and retention' },
|
|
273
|
+
{ label: 'Revenue expansion — upsell + cross-sell to existing accounts' },
|
|
274
|
+
{ label: 'New market penetration — target new ICP segment' },
|
|
275
|
+
{ label: 'Operational efficiency — reduce costs and improve margins' },
|
|
276
|
+
{ label: 'Top-down goal cascade — CEO decomposes to department OKRs' },
|
|
277
|
+
],
|
|
278
|
+
'ecommerce': [
|
|
279
|
+
{ label: 'Conversion rate optimization via UX and checkout improvements' },
|
|
280
|
+
{ label: 'Customer lifetime value expansion via loyalty and subscription' },
|
|
281
|
+
{ label: 'Marketplace expansion to new channels and geographies' },
|
|
282
|
+
{ label: 'Supply chain efficiency to improve margins and delivery speed' },
|
|
283
|
+
{ label: 'Influencer and community-led acquisition strategy' },
|
|
284
|
+
],
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Idea tournament (source 10).
|
|
289
|
+
* Generates 5 strategic approaches and runs a tournament.
|
|
290
|
+
* Returns { winner, rankings, hint }.
|
|
291
|
+
*/
|
|
292
|
+
async function _runTournament(goalTitle, companyId, bridge, mg) {
|
|
293
|
+
if (!bridge || typeof bridge.runTournament !== 'function') {
|
|
294
|
+
return { tournamentWinner: null, tournamentRankings: null, tournamentHint: null };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Read industry from SystemAim for domain-aware candidates
|
|
298
|
+
let industry = null;
|
|
299
|
+
if (mg) {
|
|
300
|
+
try {
|
|
301
|
+
const aimRes = await mg(
|
|
302
|
+
`MATCH (sa:SystemAim {companyId: $cid}) RETURN sa.industry AS industry LIMIT 1`,
|
|
303
|
+
{ cid: companyId }
|
|
304
|
+
);
|
|
305
|
+
industry = aimRes?.rows?.[0]?.[0] ?? aimRes?.rows?.[0]?.industry ?? null;
|
|
306
|
+
} catch (_) {}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Generate candidate approaches based on the goal
|
|
310
|
+
const isBusinessSignal = _getIsBusinessSignal();
|
|
311
|
+
const isBusiness = isBusinessSignal(goalTitle);
|
|
312
|
+
|
|
313
|
+
// Determine candidates from industry if available, fall back to generic
|
|
314
|
+
let candidates;
|
|
315
|
+
if (industry && INDUSTRY_CANDIDATES[industry]) {
|
|
316
|
+
candidates = INDUSTRY_CANDIDATES[industry];
|
|
317
|
+
} else if (industry) {
|
|
318
|
+
// Check prefix match (e.g. 'professional-services/consulting' matches 'professional-services/tax' patterns)
|
|
319
|
+
const prefix = Object.keys(INDUSTRY_CANDIDATES).find(k => industry.startsWith(k.split('/')[0]));
|
|
320
|
+
candidates = prefix ? INDUSTRY_CANDIDATES[prefix] : null;
|
|
321
|
+
}
|
|
322
|
+
if (!candidates) {
|
|
323
|
+
// Generic fallback — existing behaviour
|
|
324
|
+
candidates = isBusiness
|
|
325
|
+
? [
|
|
326
|
+
{ label: 'Top-down goal cascade — CEO decomposes to department OKRs' },
|
|
327
|
+
{ label: 'Revenue expansion — upsell + cross-sell to existing accounts' },
|
|
328
|
+
{ label: 'New market penetration — target new ICP segment' },
|
|
329
|
+
{ label: 'Operational efficiency — reduce costs and improve margins' },
|
|
330
|
+
{ label: 'Product-led growth — feature-driven acquisition and retention' },
|
|
331
|
+
]
|
|
332
|
+
: [
|
|
333
|
+
{ label: 'Iterative improvement — incremental changes with fast feedback' },
|
|
334
|
+
{ label: 'Systematic approach — structured planning and execution' },
|
|
335
|
+
{ label: 'Parallel workstreams — split across multiple teams' },
|
|
336
|
+
{ label: 'Risk-first approach — address blockers before building' },
|
|
337
|
+
{ label: 'MVP-first approach — ship minimal version, learn, iterate' },
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const result = await bridge.runTournament({
|
|
343
|
+
goal: goalTitle,
|
|
344
|
+
criteria: ['feasibility', 'impact', 'speed', 'alignment'],
|
|
345
|
+
candidates,
|
|
346
|
+
timeoutSeconds: 12, // must be less than RESEARCH_TIMEOUT_MS (15s) so result can be processed
|
|
347
|
+
goalId: companyId,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
if (!result || !result.winner) {
|
|
351
|
+
return { tournamentWinner: null, tournamentRankings: null, tournamentHint: null };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
tournamentWinner: result.winner,
|
|
356
|
+
tournamentRankings: JSON.stringify(result.rankings?.slice(0, 5) || []),
|
|
357
|
+
tournamentHint: result.confidence ? `${Math.round(result.confidence * 100)}% confidence` : null,
|
|
358
|
+
};
|
|
359
|
+
} catch (_) {
|
|
360
|
+
return { tournamentWinner: null, tournamentRankings: null, tournamentHint: null };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── Verification rubric generator ─────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* generateRubric — Build a verification rubric from assembled research results.
|
|
368
|
+
* Produces a structured checklist agents can use to verify goal completion.
|
|
369
|
+
*
|
|
370
|
+
* @param {{ marketContext, competitorContext, crmContext, tournamentWinner, beliefsContext }} results
|
|
371
|
+
* @returns {{ checks: string[], dataSources: string[], completionCriteria: string }}
|
|
372
|
+
*/
|
|
373
|
+
function generateRubric(results) {
|
|
374
|
+
const checks = [];
|
|
375
|
+
const dataSources = [];
|
|
376
|
+
|
|
377
|
+
if (results.marketContext) {
|
|
378
|
+
checks.push('Market data reviewed and benchmark targets set');
|
|
379
|
+
dataSources.push('market_research');
|
|
380
|
+
}
|
|
381
|
+
if (results.competitorContext) {
|
|
382
|
+
checks.push('Competitor analysis completed and differentiation identified');
|
|
383
|
+
dataSources.push('competitor_analysis');
|
|
384
|
+
}
|
|
385
|
+
if (results.crmContext) {
|
|
386
|
+
checks.push('CRM pipeline reviewed — accounts and opportunities aligned to goal');
|
|
387
|
+
dataSources.push('crm');
|
|
388
|
+
}
|
|
389
|
+
if (results.tournamentWinner) {
|
|
390
|
+
checks.push(`Strategy selected via tournament: ${String(results.tournamentWinner).slice(0, 80)}`);
|
|
391
|
+
dataSources.push('strategy_tournament');
|
|
392
|
+
}
|
|
393
|
+
if (results.beliefsContext) {
|
|
394
|
+
checks.push('Company beliefs and constraints reviewed');
|
|
395
|
+
dataSources.push('company_beliefs');
|
|
396
|
+
}
|
|
397
|
+
if (results.priorWorkContext) {
|
|
398
|
+
checks.push('Prior task results reviewed for continuity');
|
|
399
|
+
dataSources.push('prior_work');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Always include baseline checks
|
|
403
|
+
checks.push('Goal decomposition completed by CEO agent');
|
|
404
|
+
checks.push('Department OKRs aligned to company goal');
|
|
405
|
+
checks.push('Progress tracked in Memgraph via Task completion');
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
checks,
|
|
409
|
+
dataSources,
|
|
410
|
+
completionCriteria: `All ${checks.length} checklist items satisfied with evidence in Memgraph`,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class GoalResearchPipeline {
|
|
417
|
+
/**
|
|
418
|
+
* @param {Function} mgQuery - async (cypher, params) => { keys, rows }
|
|
419
|
+
*/
|
|
420
|
+
constructor(mgQuery) {
|
|
421
|
+
this.mg = mgQuery;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* research — Run all 10 sources in parallel and write GoalResearchBrief to Memgraph.
|
|
426
|
+
*
|
|
427
|
+
* @param {string} goalId - CompanyGoal node ID
|
|
428
|
+
* @param {string} goalTitle - The goal text (used for queries)
|
|
429
|
+
* @param {string} companyId - Company ID
|
|
430
|
+
* @param {{ domain?: string, companyName?: string, force?: boolean }} [options]
|
|
431
|
+
* @returns {Promise<string|null>} - The GoalResearchBrief node ID, or null on failure
|
|
432
|
+
*/
|
|
433
|
+
async research(goalId, goalTitle, companyId, options = {}) {
|
|
434
|
+
const { domain, companyName, force = false } = options;
|
|
435
|
+
|
|
436
|
+
if (!goalId || !goalTitle || !companyId) {
|
|
437
|
+
_log('warn', 'research: missing required params', { goalId, companyId });
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── Idempotency check: skip if fresh brief already exists ──────────────
|
|
442
|
+
if (!force) {
|
|
443
|
+
try {
|
|
444
|
+
const existing = await this.mg(
|
|
445
|
+
`MATCH (cg:CompanyGoal {id: $gid})-[:HAS_RESEARCH]->(grb:GoalResearchBrief)
|
|
446
|
+
WHERE grb.createdAt >= localdatetime() - duration({hours: 24})
|
|
447
|
+
RETURN grb.id AS id LIMIT 1`,
|
|
448
|
+
{ gid: goalId }
|
|
449
|
+
);
|
|
450
|
+
if (existing?.rows?.length) {
|
|
451
|
+
const existingId = existing.rows[0][0] ?? existing.rows[0].id;
|
|
452
|
+
_log('info', `research: fresh brief already exists — skipping`, { goalId, briefId: existingId });
|
|
453
|
+
return existingId;
|
|
454
|
+
}
|
|
455
|
+
} catch (_) { /* non-fatal — proceed with research */ }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const briefId = `grb:${goalId}:${Date.now()}`;
|
|
459
|
+
const bridge = _getResearchBridge(this.mg, companyId);
|
|
460
|
+
|
|
461
|
+
_log('info', `research: starting 10-source parallel research`, { goalId, goalTitle: goalTitle.slice(0, 80) });
|
|
462
|
+
|
|
463
|
+
// ── Run all sources in parallel with 15s total timeout ─────────────────
|
|
464
|
+
let results;
|
|
465
|
+
try {
|
|
466
|
+
results = await Promise.race([
|
|
467
|
+
Promise.allSettled([
|
|
468
|
+
/* 1 */ _runWebsiteCrawl(domain, bridge),
|
|
469
|
+
/* 2-5 */ _runWebSources(goalTitle, companyName, bridge),
|
|
470
|
+
/* 6 */ _runCrmContext(companyId, this.mg),
|
|
471
|
+
/* 7 */ _runPriorTaskResults(companyId, goalTitle, this.mg),
|
|
472
|
+
/* 8 */ _runBeliefsContext(companyId, this.mg),
|
|
473
|
+
/* 9 */ _runDepartmentPlansContext(companyId, this.mg),
|
|
474
|
+
/* 10 */ _runTournament(goalTitle, companyId, bridge, this.mg),
|
|
475
|
+
]),
|
|
476
|
+
new Promise((_, reject) =>
|
|
477
|
+
setTimeout(() => reject(new Error('research_timeout_15s')), RESEARCH_TIMEOUT_MS)
|
|
478
|
+
),
|
|
479
|
+
]);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
_log('warn', `research: timeout or error — writing partial brief`, { goalId, err: err.message });
|
|
482
|
+
// On timeout, we still write whatever was collected (all settled values up to timeout)
|
|
483
|
+
// Promise.allSettled with a race: any pending promises resolve to their settled state
|
|
484
|
+
// We write a minimal brief so the pipeline isn't blocked.
|
|
485
|
+
results = [
|
|
486
|
+
{ status: 'rejected', reason: new Error('timeout') },
|
|
487
|
+
{ status: 'fulfilled', value: { marketContext: null, competitorContext: null, strategyContext: null, newsContext: null } },
|
|
488
|
+
{ status: 'rejected', reason: new Error('timeout') },
|
|
489
|
+
{ status: 'rejected', reason: new Error('timeout') },
|
|
490
|
+
{ status: 'rejected', reason: new Error('timeout') },
|
|
491
|
+
{ status: 'rejected', reason: new Error('timeout') },
|
|
492
|
+
{ status: 'fulfilled', value: { tournamentWinner: null, tournamentRankings: null, tournamentHint: null } },
|
|
493
|
+
];
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── Unpack results ─────────────────────────────────────────────────────
|
|
497
|
+
const [websiteR, webR, crmR, priorR, beliefsR, deptR, tournamentR] = results;
|
|
498
|
+
|
|
499
|
+
const companyWebsite = websiteR.status === 'fulfilled' ? websiteR.value : null;
|
|
500
|
+
const webCtx = webR.status === 'fulfilled' ? webR.value : {};
|
|
501
|
+
const crmContext = crmR.status === 'fulfilled' ? crmR.value : null;
|
|
502
|
+
const priorWorkContext = priorR.status === 'fulfilled' ? priorR.value : null;
|
|
503
|
+
const beliefsContext = beliefsR.status === 'fulfilled' ? beliefsR.value : null;
|
|
504
|
+
const deptContext = deptR.status === 'fulfilled' ? deptR.value : null;
|
|
505
|
+
const tournamentCtx = tournamentR.status === 'fulfilled' ? tournamentR.value : {};
|
|
506
|
+
|
|
507
|
+
const { marketContext = null, competitorContext = null, strategyContext = null, newsContext = null } = webCtx || {};
|
|
508
|
+
const { tournamentWinner = null, tournamentRankings = null, tournamentHint = null } = tournamentCtx || {};
|
|
509
|
+
|
|
510
|
+
// ── Compute confidence score ───────────────────────────────────────────
|
|
511
|
+
const sourcesUsed = [
|
|
512
|
+
companyWebsite, marketContext, competitorContext, strategyContext, newsContext,
|
|
513
|
+
crmContext, priorWorkContext, beliefsContext, deptContext, tournamentWinner,
|
|
514
|
+
].filter(Boolean).length;
|
|
515
|
+
const confidenceScore = Math.round((sourcesUsed / 10) * 100);
|
|
516
|
+
|
|
517
|
+
// ── Generate verification rubric ───────────────────────────────────────
|
|
518
|
+
const verificationRubric = generateRubric({
|
|
519
|
+
marketContext, competitorContext, crmContext: crmContext,
|
|
520
|
+
tournamentWinner, beliefsContext, priorWorkContext,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// ── Write GoalResearchBrief node ───────────────────────────────────────
|
|
524
|
+
try {
|
|
525
|
+
await this.mg(
|
|
526
|
+
`MERGE (grb:GoalResearchBrief {id: $id})
|
|
527
|
+
ON CREATE SET
|
|
528
|
+
grb.goalId = $goalId,
|
|
529
|
+
grb.companyId = $companyId,
|
|
530
|
+
grb.companyWebsite = $companyWebsite,
|
|
531
|
+
grb.marketContext = $marketContext,
|
|
532
|
+
grb.competitorContext = $competitorContext,
|
|
533
|
+
grb.benchmarks = $strategyContext,
|
|
534
|
+
grb.newsContext = $newsContext,
|
|
535
|
+
grb.crmContext = $crmContext,
|
|
536
|
+
grb.priorWorkContext = $priorWorkContext,
|
|
537
|
+
grb.beliefsContext = $beliefsContext,
|
|
538
|
+
grb.departmentPlansContext= $deptContext,
|
|
539
|
+
grb.tournamentWinner = $tournamentWinner,
|
|
540
|
+
grb.tournamentRankings = $tournamentRankings,
|
|
541
|
+
grb.tournamentHint = $tournamentHint,
|
|
542
|
+
grb.confidenceScore = $confidenceScore,
|
|
543
|
+
grb.sourcesUsed = $sourcesUsed,
|
|
544
|
+
grb.verificationRubric = $verificationRubric,
|
|
545
|
+
grb.dataCompleteness = $dataCompleteness,
|
|
546
|
+
grb.confirmedByUser = false,
|
|
547
|
+
grb.corrections = [],
|
|
548
|
+
grb.createdAt = localdatetime(),
|
|
549
|
+
grb.expiresAt = localdatetime() + duration({hours: 24})
|
|
550
|
+
ON MATCH SET
|
|
551
|
+
grb.updatedAt = localdatetime()`,
|
|
552
|
+
{
|
|
553
|
+
id: briefId, goalId, companyId,
|
|
554
|
+
companyWebsite: companyWebsite ? companyWebsite.slice(0, 3000) : null,
|
|
555
|
+
marketContext: marketContext ? marketContext.slice(0, 2000) : null,
|
|
556
|
+
competitorContext: competitorContext ? competitorContext.slice(0, 2000) : null,
|
|
557
|
+
strategyContext: strategyContext ? strategyContext.slice(0, 2000) : null,
|
|
558
|
+
newsContext: newsContext ? newsContext.slice(0, 1000) : null,
|
|
559
|
+
crmContext: crmContext ? crmContext.slice(0, 2000) : null,
|
|
560
|
+
priorWorkContext: priorWorkContext ? priorWorkContext.slice(0, 2000) : null,
|
|
561
|
+
beliefsContext: beliefsContext ? beliefsContext.slice(0, 2000) : null,
|
|
562
|
+
deptContext: deptContext ? deptContext.slice(0, 2000) : null,
|
|
563
|
+
tournamentWinner: tournamentWinner ? String(tournamentWinner).slice(0, 500) : null,
|
|
564
|
+
tournamentRankings: tournamentRankings ? String(tournamentRankings).slice(0, 2000) : null,
|
|
565
|
+
tournamentHint: tournamentHint ? String(tournamentHint).slice(0, 200) : null,
|
|
566
|
+
confidenceScore, sourcesUsed,
|
|
567
|
+
verificationRubric: JSON.stringify(verificationRubric),
|
|
568
|
+
dataCompleteness: sourcesUsed / 10,
|
|
569
|
+
}
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
// Link brief to goal
|
|
573
|
+
await this.mg(
|
|
574
|
+
`MATCH (cg:CompanyGoal {id: $goalId}), (grb:GoalResearchBrief {id: $briefId})
|
|
575
|
+
MERGE (cg)-[:HAS_RESEARCH]->(grb)`,
|
|
576
|
+
{ goalId, briefId }
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
_log('info', `research: brief written`, {
|
|
580
|
+
goalId, briefId, sourcesUsed, confidenceScore,
|
|
581
|
+
tournamentWinner: tournamentWinner ? String(tournamentWinner).slice(0, 60) : null,
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
return briefId;
|
|
585
|
+
} catch (err) {
|
|
586
|
+
_log('warn', `research: failed to write brief to Memgraph`, { goalId, err: err.message });
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* getLatestBrief — Retrieve the most recent GoalResearchBrief for a goal.
|
|
593
|
+
* Returns the brief object or null.
|
|
594
|
+
*/
|
|
595
|
+
async getLatestBrief(goalId) {
|
|
596
|
+
try {
|
|
597
|
+
const result = await this.mg(
|
|
598
|
+
`MATCH (cg:CompanyGoal {id: $gid})-[:HAS_RESEARCH]->(grb:GoalResearchBrief)
|
|
599
|
+
RETURN grb
|
|
600
|
+
ORDER BY grb.createdAt DESC LIMIT 1`,
|
|
601
|
+
{ gid: goalId }
|
|
602
|
+
);
|
|
603
|
+
const row = result?.rows?.[0];
|
|
604
|
+
if (!row) return null;
|
|
605
|
+
// Memgraph returns node properties as an object in rows[0][0]
|
|
606
|
+
const node = Array.isArray(row) ? row[0] : row;
|
|
607
|
+
return node?.properties ?? node ?? null;
|
|
608
|
+
} catch (_) {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* applyCorrection — Append a user correction to the brief.
|
|
615
|
+
* Called by the Three Cs loop when user responds with a correction.
|
|
616
|
+
*/
|
|
617
|
+
async applyCorrection(briefId, correction) {
|
|
618
|
+
if (!briefId || !correction) return;
|
|
619
|
+
try {
|
|
620
|
+
await this.mg(
|
|
621
|
+
`MATCH (grb:GoalResearchBrief {id: $id})
|
|
622
|
+
SET grb.corrections = coalesce(grb.corrections, []) + [$correction],
|
|
623
|
+
grb.updatedAt = localdatetime()`,
|
|
624
|
+
{ id: briefId, correction: String(correction).slice(0, 500) }
|
|
625
|
+
);
|
|
626
|
+
} catch (_) { /* non-fatal */ }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* confirm — Mark brief as confirmed by user (Three Cs loop complete).
|
|
631
|
+
*/
|
|
632
|
+
async confirm(briefId) {
|
|
633
|
+
if (!briefId) return;
|
|
634
|
+
try {
|
|
635
|
+
await this.mg(
|
|
636
|
+
`MATCH (grb:GoalResearchBrief {id: $id})
|
|
637
|
+
SET grb.confirmedByUser = true, grb.confirmedAt = localdatetime()`,
|
|
638
|
+
{ id: briefId }
|
|
639
|
+
);
|
|
640
|
+
} catch (_) { /* non-fatal */ }
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
module.exports = { GoalResearchPipeline };
|