@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
package/daemon/routes/project.js
CHANGED
|
@@ -107,35 +107,63 @@ function startSseKeepAlive(res) {
|
|
|
107
107
|
|
|
108
108
|
// ── Route factory ─────────────────────────────────────────────────────────────
|
|
109
109
|
|
|
110
|
-
module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateContextCache, semanticUpdater, driftDetector } = {}) {
|
|
110
|
+
module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateContextCache, semanticUpdater, driftDetector, hboStore } = {}) {
|
|
111
111
|
|
|
112
112
|
// ── POST /api/project ─────────────────────────────────────────────────────
|
|
113
113
|
async function handleCreate(req, res, ctx) {
|
|
114
114
|
const body = await readBody(req);
|
|
115
115
|
if (!assertBody(body, res, req)) return;
|
|
116
116
|
// D3: ctx.cid is authoritative - prevents cross-company project injection
|
|
117
|
-
|
|
117
|
+
// P3-02: pillarId and goalId are now optional (defaults to null) so desktop
|
|
118
|
+
// users without Harada pillars can still create projects.
|
|
119
|
+
const { name } = body;
|
|
120
|
+
const pillarId = body.pillarId ? String(body.pillarId) : null;
|
|
121
|
+
const goalId = body.goalId ? String(body.goalId) : null;
|
|
118
122
|
const companyId = ctx?.cid || body?.companyId || '';
|
|
119
|
-
if (!companyId || !
|
|
120
|
-
return jsonErr(res, 400, 'companyId
|
|
123
|
+
if (!companyId || !name) {
|
|
124
|
+
return jsonErr(res, 400, 'companyId and name are required', req);
|
|
121
125
|
}
|
|
122
126
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
127
|
+
// projId is deterministic: use caller-supplied id (H-3: preserves desktop ID for reconciliation),
|
|
128
|
+
// then pillarId-slug, then UUID slug (P3-02: pillarId optional)
|
|
129
|
+
const slug = pillarId
|
|
130
|
+
? pillarId.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 40)
|
|
131
|
+
: randomUUID().slice(0, 8);
|
|
132
|
+
const projId = body.id ? String(body.id) : `proj:${companyId}:${slug}`;
|
|
125
133
|
const docId = `pdoc:${projId}:main`;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
p.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
if (mgQuery) {
|
|
135
|
+
await mgQuery(
|
|
136
|
+
`MERGE (p:HeliosProject {id: $projId})
|
|
137
|
+
ON CREATE SET p.companyId=$cid, p.goalId=$goalId, p.pillarId=$pillarId,
|
|
138
|
+
p.name=$name, p.status='planning', p.phase='planning', p.createdAt=datetime()`,
|
|
139
|
+
{ projId, cid: companyId, goalId, pillarId, name }
|
|
140
|
+
);
|
|
141
|
+
// H-1 fix: SQLite write-through happens HERE (after first Memgraph write succeeds)
|
|
142
|
+
// so it executes even if the second Memgraph write (ProjectDocument) fails
|
|
143
|
+
if (hboStore?.upsertProject) {
|
|
144
|
+
try {
|
|
145
|
+
hboStore.upsertProject({ id: projId, companyId, name, pillarId, goalId, status: 'planning', phase: 'planning', createdAt: new Date().toISOString(), updated_at: Date.now() });
|
|
146
|
+
} catch (storeErr) {
|
|
147
|
+
process.stderr.write(`[project] hboStore.upsertProject failed (non-fatal): ${storeErr.message}\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
await mgQuery(
|
|
151
|
+
`MERGE (d:ProjectDocument {id: $docId})
|
|
152
|
+
ON CREATE SET d.projectId=$projId, d.purpose='', d.approach='',
|
|
153
|
+
d.successCriteria='[]', d.intentAnchor='', d.exclusions='[]',
|
|
154
|
+
d.content='', d.version=toInteger(1), d.updatedAt=datetime()`,
|
|
155
|
+
{ docId, projId }
|
|
156
|
+
);
|
|
157
|
+
} else {
|
|
158
|
+
// Memgraph unavailable — write to SQLite only (C-2 / SQL-parity invariant)
|
|
159
|
+
if (hboStore?.upsertProject) {
|
|
160
|
+
try {
|
|
161
|
+
hboStore.upsertProject({ id: projId, companyId, name, pillarId, goalId, status: 'planning', phase: 'planning', createdAt: new Date().toISOString(), updated_at: Date.now() });
|
|
162
|
+
} catch (storeErr) {
|
|
163
|
+
process.stderr.write(`[project] hboStore.upsertProject (offline) failed (non-fatal): ${storeErr.message}\n`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
139
167
|
jsonOk(res, { ok: true, projectId: projId }, req, 201);
|
|
140
168
|
} catch (e) {
|
|
141
169
|
jsonErr(res, 500, safeMsg(e), req);
|
|
@@ -148,41 +176,98 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
148
176
|
const cid = url.searchParams.get('companyId') || ctx?.cid || '';
|
|
149
177
|
const pillar = url.searchParams.get('pillarId') || '';
|
|
150
178
|
const status = url.searchParams.get('status') || '';
|
|
179
|
+
// P3-SQL: try Memgraph first; fall back to SQLite when unavailable
|
|
180
|
+
if (mgQuery) {
|
|
181
|
+
try {
|
|
182
|
+
let cypher = `MATCH (p:HeliosProject)`;
|
|
183
|
+
const params = {};
|
|
184
|
+
const where = [];
|
|
185
|
+
if (cid) { where.push('p.companyId = $cid'); params.cid = cid; }
|
|
186
|
+
if (pillar) { where.push('p.pillarId = $pillar'); params.pillar = pillar; }
|
|
187
|
+
if (status) { where.push('p.status = $status'); params.status = status; }
|
|
188
|
+
if (where.length) cypher += ` WHERE ${where.join(' AND ')}`;
|
|
189
|
+
cypher += ` RETURN p.id, p.name, p.phase, p.status, p.pillarId, p.goalId, p.createdAt ORDER BY p.createdAt DESC LIMIT 100`;
|
|
190
|
+
const result = await mgQuery(cypher, params);
|
|
191
|
+
const keys = ['id','name','phase','status','pillarId','goalId','createdAt'];
|
|
192
|
+
const projects = parseRows(result).map(r => rowToObj(r, keys));
|
|
193
|
+
return jsonOk(res, { projects, count: projects.length }, req);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
process.stderr.write(`[project] handleList Memgraph failed, falling back to SQLite: ${e.message}\n`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// SQLite fallback
|
|
151
199
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
cypher += ` RETURN p.id, p.name, p.phase, p.status, p.pillarId, p.goalId, p.createdAt ORDER BY p.createdAt DESC LIMIT 100`;
|
|
160
|
-
const result = await mgQuery(cypher, params);
|
|
161
|
-
const keys = ['id','name','phase','status','pillarId','goalId','createdAt'];
|
|
162
|
-
const projects = parseRows(result).map(r => rowToObj(r, keys));
|
|
163
|
-
jsonOk(res, { projects, count: projects.length }, req);
|
|
164
|
-
} catch (e) {
|
|
165
|
-
jsonErr(res, 500, safeMsg(e), req);
|
|
200
|
+
const all = hboStore?.getProjectsByCompany ? hboStore.getProjectsByCompany(cid) : [];
|
|
201
|
+
let projects = all || [];
|
|
202
|
+
if (pillar) projects = projects.filter(p => p.pillarId === pillar);
|
|
203
|
+
if (status) projects = projects.filter(p => p.status === status);
|
|
204
|
+
return jsonOk(res, { projects, count: projects.length, _source: 'sqlite' }, req);
|
|
205
|
+
} catch (storeErr) {
|
|
206
|
+
return jsonErr(res, 503, `Projects unavailable: Memgraph not connected and SQLite fallback failed: ${storeErr.message}`, req);
|
|
166
207
|
}
|
|
167
208
|
}
|
|
168
209
|
|
|
169
210
|
// ── GET /api/project/:id ──────────────────────────────────────────────────
|
|
170
211
|
async function handleGet(req, res, ctx, projectId) {
|
|
212
|
+
// P3-SQL: Memgraph-first, SQLite fallback
|
|
213
|
+
if (mgQuery) {
|
|
214
|
+
try {
|
|
215
|
+
const projRes = await mgQuery(
|
|
216
|
+
`MATCH (p:HeliosProject {id: $id, companyId: $cid})
|
|
217
|
+
OPTIONAL MATCH (d:ProjectDocument {projectId: p.id})
|
|
218
|
+
RETURN p.id, p.name, p.phase, p.status, p.pillarId, p.goalId, p.createdAt,
|
|
219
|
+
d.purpose, d.approach, d.successCriteria, d.intentAnchor, d.exclusions, d.version, d.updatedAt, d.content`,
|
|
220
|
+
{ id: projectId, cid: ctx.cid || ctx?.cid || '' }
|
|
221
|
+
);
|
|
222
|
+
const row = parseRows(projRes)[0];
|
|
223
|
+
if (!row) return jsonErr(res, 404, 'Project not found', req);
|
|
224
|
+
const keys = ['id','name','phase','status','pillarId','goalId','createdAt','purpose','approach','successCriteria','intentAnchor','exclusions','version','updatedAt','content'];
|
|
225
|
+
const proj = rowToObj(row, keys);
|
|
226
|
+
// P3-02: fetch standardWorkDocuments, questions, linkedTasks as supplemental arrays
|
|
227
|
+
const cid = ctx.cid || ctx?.cid || '';
|
|
228
|
+
const [swdRes, qRes, ltRes] = await Promise.all([
|
|
229
|
+
mgQuery(`MATCH (s:StandardWorkDocument {projectId: $id, companyId: $cid}) RETURN s.id, s.title, s.content, s.section, s.updatedAt`, { id: projectId, cid }).catch(() => null),
|
|
230
|
+
mgQuery(`MATCH (q:ProjectQuestion {projectId: $id, companyId: $cid}) RETURN q.id, q.question, q.answer, q.askedBy, q.createdAt`, { id: projectId, cid }).catch(() => null),
|
|
231
|
+
mgQuery(`MATCH (t:Task {companyId: $cid}) WHERE t.projectId = $id OR (t)-[:BELONGS_TO_PROJECT]->(:HeliosProject {id: $id}) RETURN t.id, t.title, t.status, t.assigneeAgentId`, { id: projectId, cid }).catch(() => null),
|
|
232
|
+
]);
|
|
233
|
+
const swdKeys = ['id','title','content','section','updatedAt'];
|
|
234
|
+
const qKeys = ['id','question','answer','askedBy','createdAt'];
|
|
235
|
+
const ltKeys = ['id','title','status','assigneeAgentId'];
|
|
236
|
+
proj.standardWorkDocuments = parseRows(swdRes).map(r => rowToObj(r, swdKeys));
|
|
237
|
+
proj.questions = parseRows(qRes).map(r => rowToObj(r, qKeys));
|
|
238
|
+
proj.linkedTasks = parseRows(ltRes).map(r => rowToObj(r, ltKeys));
|
|
239
|
+
return jsonOk(res, proj, req);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
process.stderr.write(`[project] handleGet Memgraph failed, falling back to SQLite: ${e.message}\n`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// SQLite fallback
|
|
171
245
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
246
|
+
const p = hboStore?.getProject ? hboStore.getProject(projectId, ctx?.cid || '') : null;
|
|
247
|
+
if (!p) return jsonErr(res, 404, 'Project not found', req);
|
|
248
|
+
// M-2 fix: normalize response shape to match Memgraph path exactly
|
|
249
|
+
const doc = hboStore?.getProjectDocument ? hboStore.getProjectDocument(projectId) : null;
|
|
250
|
+
const createdAtMs = typeof p.created_at === 'number' ? p.created_at : (p.createdAt ? new Date(p.createdAt).getTime() : null);
|
|
251
|
+
return jsonOk(res, {
|
|
252
|
+
id: p.id,
|
|
253
|
+
name: p.name ?? null,
|
|
254
|
+
phase: p.phase ?? 'planning',
|
|
255
|
+
status: p.status ?? 'planning',
|
|
256
|
+
pillarId: p.pillarId ?? p.pillar_id ?? null,
|
|
257
|
+
goalId: p.goalId ?? p.goal_id ?? null,
|
|
258
|
+
createdAt: createdAtMs ? new Date(createdAtMs).toISOString() : null,
|
|
259
|
+
purpose: doc?.purpose ?? null,
|
|
260
|
+
approach: doc?.approach ?? null,
|
|
261
|
+
successCriteria: doc?.success_criteria ?? doc?.successCriteria ?? '[]',
|
|
262
|
+
intentAnchor: doc?.intent_anchor ?? doc?.intentAnchor ?? null,
|
|
263
|
+
exclusions: doc?.exclusions ?? '[]',
|
|
264
|
+
version: doc?.version ?? 1,
|
|
265
|
+
updatedAt: doc?.updated_at ? new Date(doc.updated_at).toISOString() : null,
|
|
266
|
+
content: doc?.content ?? null,
|
|
267
|
+
_source: 'sqlite',
|
|
268
|
+
}, req);
|
|
269
|
+
} catch (storeErr) {
|
|
270
|
+
return jsonErr(res, 503, `Project unavailable: ${storeErr.message}`, req);
|
|
186
271
|
}
|
|
187
272
|
}
|
|
188
273
|
|
|
@@ -202,6 +287,26 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
202
287
|
const docId = `pdoc:${projectId}:main`;
|
|
203
288
|
const companyId = bodyCompanyId || ctx?.cid || projectId.split(':')[1] || 'unknown';
|
|
204
289
|
|
|
290
|
+
// C-2 fix: SQLite-only path when Memgraph unavailable
|
|
291
|
+
if (!mgQuery) {
|
|
292
|
+
if (hboStore?.upsertProjectDocument) {
|
|
293
|
+
try {
|
|
294
|
+
const existing = hboStore.getProjectDocument ? hboStore.getProjectDocument(projectId) : null;
|
|
295
|
+
const update = {
|
|
296
|
+
id: docId, project_id: projectId, company_id: companyId,
|
|
297
|
+
...(existing ? { purpose: existing.purpose, approach: existing.approach, intent_anchor: existing.intent_anchor, success_criteria: existing.success_criteria, exclusions: existing.exclusions, content: existing.content, version: existing.version ?? 1 } : {}),
|
|
298
|
+
[section === 'intentAnchor' ? 'intent_anchor' : section === 'successCriteria' ? 'success_criteria' : section]: String(value),
|
|
299
|
+
version: ((existing?.version ?? 0) + 1),
|
|
300
|
+
updated_at: Date.now(),
|
|
301
|
+
};
|
|
302
|
+
hboStore.upsertProjectDocument(update);
|
|
303
|
+
} catch (storeErr) {
|
|
304
|
+
process.stderr.write(`[project] hboStore.upsertProjectDocument (offline) failed: ${storeErr.message}\n`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return jsonOk(res, { ok: true, section, _source: 'sqlite' }, req, 200);
|
|
308
|
+
}
|
|
309
|
+
|
|
205
310
|
// ── content section: persist block tree + back-extract semantic fields ──
|
|
206
311
|
// When the editor saves the full BlockNote block array, we:
|
|
207
312
|
// 1. Store it as d.content (JSON string) — source of truth for the editor
|
|
@@ -260,6 +365,12 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
260
365
|
{ docId, content: String(value), purpose, approach, intentAnchor }
|
|
261
366
|
);
|
|
262
367
|
if (invalidateContextCache) invalidateContextCache(projectId);
|
|
368
|
+
// C-2: SQLite write-through for content section
|
|
369
|
+
if (hboStore?.upsertProjectDocument) {
|
|
370
|
+
try {
|
|
371
|
+
hboStore.upsertProjectDocument({ id: docId, project_id: projectId, company_id: companyId, content: String(value), purpose, approach, intent_anchor: intentAnchor, updated_at: Date.now() });
|
|
372
|
+
} catch (storeErr) { process.stderr.write(`[project] content write-through failed: ${storeErr.message}\n`); }
|
|
373
|
+
}
|
|
263
374
|
return jsonOk(res, { ok: true, section: 'content' }, req, 200);
|
|
264
375
|
}
|
|
265
376
|
|
|
@@ -296,6 +407,21 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
296
407
|
// proposalId: internal LLM analysis task ID
|
|
297
408
|
jsonOk(res, { ok: true, pduId, proposalId, message: 'Analyzing edit — check back shortly' }, req, 202);
|
|
298
409
|
|
|
410
|
+
// C-2: SQLite write-through for proposal/semantic edit sections
|
|
411
|
+
if (hboStore?.upsertProjectDocument) {
|
|
412
|
+
try {
|
|
413
|
+
const existing = hboStore.getProjectDocument ? hboStore.getProjectDocument(projectId) : null;
|
|
414
|
+
const colKey = section === 'intentAnchor' ? 'intent_anchor' : section === 'successCriteria' ? 'success_criteria' : section;
|
|
415
|
+
hboStore.upsertProjectDocument({
|
|
416
|
+
id: docId, project_id: projectId, company_id: companyId,
|
|
417
|
+
...(existing ? { purpose: existing.purpose, approach: existing.approach, intent_anchor: existing.intent_anchor, success_criteria: existing.success_criteria, exclusions: existing.exclusions, content: existing.content, version: existing.version ?? 1 } : {}),
|
|
418
|
+
[colKey]: String(value),
|
|
419
|
+
version: ((existing?.version ?? 0) + 1),
|
|
420
|
+
updated_at: Date.now(),
|
|
421
|
+
});
|
|
422
|
+
} catch (storeErr) { process.stderr.write(`[project] proposal write-through failed: ${storeErr.message}\n`); }
|
|
423
|
+
}
|
|
424
|
+
|
|
299
425
|
// Trigger semantic analysis asynchronously (non-blocking)
|
|
300
426
|
if (semanticUpdater) {
|
|
301
427
|
setImmediate(async () => {
|
|
@@ -374,11 +500,26 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
374
500
|
async function handleConfirmUnderstanding(req, res, ctx, projectId) {
|
|
375
501
|
const body = await readBody(req);
|
|
376
502
|
if (!assertBody(body, res, req)) return;
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
503
|
+
// Accept both pduId (desktop sends this) and proposalId (legacy) to fix the
|
|
504
|
+
// pduId/proposalId mismatch. When pduId is supplied, resolve proposalId from the
|
|
505
|
+
// PendingDocumentUpdate node so the daemon handler always has the correct proposalId.
|
|
506
|
+
let { proposalId, pduId, confirmed, correctionNote } = body;
|
|
507
|
+
if (!proposalId && !pduId) {
|
|
508
|
+
return jsonErr(res, 400, 'pduId or proposalId required', req);
|
|
380
509
|
}
|
|
510
|
+
if (confirmed === undefined) return jsonErr(res, 400, 'confirmed required', req);
|
|
381
511
|
try {
|
|
512
|
+
// Resolve proposalId from pduId if only pduId was provided
|
|
513
|
+
if (!proposalId && pduId) {
|
|
514
|
+
const pduRes = await mgQuery(
|
|
515
|
+
`MATCH (u:PendingDocumentUpdate {id: $pduId}) RETURN u.proposalId`,
|
|
516
|
+
{ pduId }
|
|
517
|
+
);
|
|
518
|
+
const pduRow = parseRows(pduRes)[0];
|
|
519
|
+
proposalId = pduRow ? (pduRow[0] ?? pduRow['proposalId']) : null;
|
|
520
|
+
if (!proposalId) return jsonErr(res, 404, 'PendingDocumentUpdate not found for pduId', req);
|
|
521
|
+
}
|
|
522
|
+
|
|
382
523
|
await mgQuery(
|
|
383
524
|
`MATCH (pcp:PlanChangeProposal {id: $proposalId, targetId: $projId})
|
|
384
525
|
SET pcp.confirmedAt=datetime(),
|
|
@@ -403,7 +544,7 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
403
544
|
`MATCH (pcp:PlanChangeProposal {id: $proposalId}) SET pcp.impact=$impact, pcp.status='impact_ready'`,
|
|
404
545
|
{ proposalId, impact: JSON.stringify(impact) }
|
|
405
546
|
).catch(() => {});
|
|
406
|
-
broadcast({ type: 'project:impact:ready', payload: { projectId, proposalId, impact }, companyId: ctx?.cid || '' });
|
|
547
|
+
broadcast({ type: 'project:impact:ready', payload: { projectId, proposalId, pduId, impact }, companyId: ctx?.cid || '' });
|
|
407
548
|
} catch (_) {}
|
|
408
549
|
});
|
|
409
550
|
}
|
|
@@ -418,10 +559,22 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
418
559
|
async function handleApplyUpdate(req, res, ctx, projectId) {
|
|
419
560
|
const body = await readBody(req);
|
|
420
561
|
if (!assertBody(body, res, req)) return;
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (!
|
|
562
|
+
// Accept both pduId (desktop sends this) and proposalId (legacy) — same fix as confirm.
|
|
563
|
+
let { proposalId, pduId } = body;
|
|
564
|
+
if (!proposalId && !pduId) return jsonErr(res, 400, 'pduId or proposalId required', req);
|
|
565
|
+
if (!projectId) return jsonErr(res, 400, 'projectId required for project-scope proposals', req);
|
|
424
566
|
try {
|
|
567
|
+
// Resolve proposalId from pduId if only pduId was provided
|
|
568
|
+
if (!proposalId && pduId) {
|
|
569
|
+
const pduRes = await mgQuery(
|
|
570
|
+
`MATCH (u:PendingDocumentUpdate {id: $pduId}) RETURN u.proposalId`,
|
|
571
|
+
{ pduId }
|
|
572
|
+
);
|
|
573
|
+
const pduRow = parseRows(pduRes)[0];
|
|
574
|
+
proposalId = pduRow ? (pduRow[0] ?? pduRow['proposalId']) : null;
|
|
575
|
+
if (!proposalId) return jsonErr(res, 404, 'PendingDocumentUpdate not found for pduId', req);
|
|
576
|
+
}
|
|
577
|
+
|
|
425
578
|
const pcpRes = await mgQuery(
|
|
426
579
|
`MATCH (pcp:PlanChangeProposal {id: $proposalId, targetId: $projId})
|
|
427
580
|
RETURN pcp.section, pcp.proposedChange`,
|
|
@@ -480,21 +633,34 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
480
633
|
|
|
481
634
|
// ── GET /api/project/:id/state ────────────────────────────────────────────
|
|
482
635
|
async function handleGetState(req, res, ctx, projectId) {
|
|
636
|
+
// C-1 fix: guard against Memgraph unavailability — return zero-state from SQLite
|
|
637
|
+
if (!mgQuery) {
|
|
638
|
+
try {
|
|
639
|
+
const p = hboStore?.getProject ? hboStore.getProject(projectId, ctx?.cid || '') : null;
|
|
640
|
+
const currentPhase = p?.phase ?? p?.status ?? 'planning';
|
|
641
|
+
return jsonOk(res, { totalCells: 0, closedCells: 0, activeTasks: 0, openDriftSignals: 0, currentPhase, hillProgress: 0, _source: 'sqlite' }, req);
|
|
642
|
+
} catch (storeErr) {
|
|
643
|
+
return jsonOk(res, { totalCells: 0, closedCells: 0, activeTasks: 0, openDriftSignals: 0, currentPhase: 'planning', hillProgress: 0, _source: 'sqlite' }, req);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
483
646
|
try {
|
|
484
647
|
const [cellRes, taskRes, driftRes, docRes] = await Promise.all([
|
|
485
648
|
mgQuery(
|
|
649
|
+
// P3-05: OPTIONAL MATCH so projects without GoalPillars return hillProgress:0 not an error
|
|
650
|
+
// H-6 fix: WHERE gp IS NOT NULL prevents null pillarId matching unrelated ActionCells
|
|
486
651
|
`MATCH (p:HeliosProject {id: $id})
|
|
487
|
-
MATCH (gp:GoalPillar {id: p.pillarId})
|
|
488
|
-
MATCH (cell:ActionCell
|
|
652
|
+
OPTIONAL MATCH (gp:GoalPillar {id: p.pillarId})
|
|
653
|
+
OPTIONAL MATCH (cell:ActionCell) WHERE cell.pillarId = gp.id AND gp IS NOT NULL
|
|
654
|
+
RETURN count(cell) AS total,
|
|
489
655
|
count(CASE WHEN cell.status='closed' THEN 1 END) AS closed`,
|
|
490
|
-
{ id: projectId
|
|
656
|
+
{ id: projectId }
|
|
491
657
|
).catch(() => null),
|
|
492
658
|
mgQuery(
|
|
493
659
|
`MATCH (p:HeliosProject {id: $id})
|
|
494
660
|
MATCH (t:Task {companyId: p.companyId}) WHERE t.task_metadata CONTAINS p.pillarId
|
|
495
661
|
AND t.status IN ['todo','in_progress']
|
|
496
662
|
RETURN count(t) AS active`,
|
|
497
|
-
{ id: projectId
|
|
663
|
+
{ id: projectId }
|
|
498
664
|
).catch(() => null),
|
|
499
665
|
mgQuery(
|
|
500
666
|
`MATCH (sig:AnomalySignal) WHERE sig.id STARTS WITH $prefix AND sig.status='open'
|
|
@@ -503,7 +669,7 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
503
669
|
).catch(() => null),
|
|
504
670
|
mgQuery(
|
|
505
671
|
`MATCH (p:HeliosProject {id: $id}) RETURN p.phase`,
|
|
506
|
-
{ id: projectId
|
|
672
|
+
{ id: projectId }
|
|
507
673
|
).catch(() => null),
|
|
508
674
|
]);
|
|
509
675
|
|
|
@@ -512,16 +678,22 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
512
678
|
const driftRow = parseRows(driftRes)[0];
|
|
513
679
|
const docRow = parseRows(docRes)[0];
|
|
514
680
|
|
|
515
|
-
const totalCells
|
|
516
|
-
const closedCells
|
|
517
|
-
const activeTasks
|
|
518
|
-
const openDriftSignals = Number(driftRow?.[0] ?? driftRow?.['open']
|
|
519
|
-
const currentPhase
|
|
520
|
-
const hillProgress
|
|
681
|
+
const totalCells = Number(cellRow?.[0] ?? cellRow?.['total'] ?? 0);
|
|
682
|
+
const closedCells = Number(cellRow?.[1] ?? cellRow?.['closed'] ?? 0);
|
|
683
|
+
const activeTasks = Number(taskRow?.[0] ?? taskRow?.['active'] ?? 0);
|
|
684
|
+
const openDriftSignals = Number(driftRow?.[0] ?? driftRow?.['open'] ?? 0);
|
|
685
|
+
const currentPhase = docRow?.[0] ?? docRow?.['phase'] ?? 'planning';
|
|
686
|
+
const hillProgress = totalCells > 0 ? Math.round((closedCells / totalCells) * 100) : 0;
|
|
521
687
|
|
|
522
688
|
jsonOk(res, { totalCells, closedCells, activeTasks, openDriftSignals, currentPhase, hillProgress }, req);
|
|
523
689
|
} catch (e) {
|
|
524
|
-
|
|
690
|
+
// Memgraph query failed mid-flight — degrade to SQLite zero-state
|
|
691
|
+
try {
|
|
692
|
+
const p = hboStore?.getProject ? hboStore.getProject(projectId, ctx?.cid || '') : null;
|
|
693
|
+
jsonOk(res, { totalCells: 0, closedCells: 0, activeTasks: 0, openDriftSignals: 0, currentPhase: p?.phase ?? 'planning', hillProgress: 0, _source: 'sqlite' }, req);
|
|
694
|
+
} catch (_) {
|
|
695
|
+
jsonErr(res, 500, safeMsg(e), req);
|
|
696
|
+
}
|
|
525
697
|
}
|
|
526
698
|
}
|
|
527
699
|
|
|
@@ -921,6 +1093,319 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
921
1093
|
}
|
|
922
1094
|
}
|
|
923
1095
|
|
|
1096
|
+
// ── GET /api/department/:dept/document ────────────────────────────────────
|
|
1097
|
+
// Returns the DepartmentDocument node for a dept, or a null-content stub when
|
|
1098
|
+
// no document exists yet (never 404 — callers show blank editor on null content).
|
|
1099
|
+
async function handleGetDepartmentDocument(req, res, ctx, deptSlug) {
|
|
1100
|
+
const url = new URL(req.url, 'http://localhost');
|
|
1101
|
+
const companyId = url.searchParams.get('companyId') || ctx?.cid || '';
|
|
1102
|
+
if (!companyId) return jsonErr(res, 400, 'companyId required', req);
|
|
1103
|
+
const docId = `ddoc:${companyId}:${deptSlug}:main`;
|
|
1104
|
+
try {
|
|
1105
|
+
const result = await mgQuery(
|
|
1106
|
+
`MATCH (d:DepartmentDocument {id: $docId}) RETURN d.id, d.companyId, d.department, d.content, d.charter, d.teamFocus, d.successCriteria, d.intentAnchor, d.version, toString(d.updatedAt) AS updatedAt`,
|
|
1107
|
+
{ docId }
|
|
1108
|
+
);
|
|
1109
|
+
const row = parseRows(result)[0];
|
|
1110
|
+
if (!row) {
|
|
1111
|
+
// No document yet — return stub (editor renders default blocks)
|
|
1112
|
+
return jsonOk(res, { id: docId, companyId, department: deptSlug, content: null, charter: null, teamFocus: null, successCriteria: null, intentAnchor: null, version: 0, updatedAt: null }, req);
|
|
1113
|
+
}
|
|
1114
|
+
const keys = ['id','companyId','department','content','charter','teamFocus','successCriteria','intentAnchor','version','updatedAt'];
|
|
1115
|
+
return jsonOk(res, rowToObj(row, keys), req);
|
|
1116
|
+
} catch (e) {
|
|
1117
|
+
jsonErr(res, 500, safeMsg(e), req);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// ── PATCH /api/department/:dept/document ──────────────────────────────────
|
|
1122
|
+
// Accepts { section, value, companyId }. Creates node on first save via MERGE.
|
|
1123
|
+
// Sections 'charter','teamFocus','successCriteria','intentAnchor' trigger LLM analysis.
|
|
1124
|
+
// Section 'content' (full block tree) persists without LLM.
|
|
1125
|
+
async function handlePatchDepartmentDocument(req, res, ctx, deptSlug) {
|
|
1126
|
+
const body = await readBody(req);
|
|
1127
|
+
if (!assertBody(body, res, req)) return;
|
|
1128
|
+
const { section, value, companyId: bodyCompanyId } = body;
|
|
1129
|
+
if (!section || value === undefined) return jsonErr(res, 400, 'section and value required', req);
|
|
1130
|
+
const DEPT_ALLOWED_SECTIONS = ['charter', 'teamFocus', 'successCriteria', 'intentAnchor', 'content'];
|
|
1131
|
+
if (!DEPT_ALLOWED_SECTIONS.includes(section)) {
|
|
1132
|
+
return jsonErr(res, 400, `section must be one of: ${DEPT_ALLOWED_SECTIONS.join(', ')}`, req);
|
|
1133
|
+
}
|
|
1134
|
+
const companyId = bodyCompanyId || ctx?.cid || '';
|
|
1135
|
+
if (!companyId) return jsonErr(res, 400, 'companyId required', req);
|
|
1136
|
+
const docId = `ddoc:${companyId}:${deptSlug}:main`;
|
|
1137
|
+
|
|
1138
|
+
try {
|
|
1139
|
+
// ── content section: persist block tree without LLM ───────────────────
|
|
1140
|
+
if (section === 'content') {
|
|
1141
|
+
let blocks;
|
|
1142
|
+
try {
|
|
1143
|
+
blocks = JSON.parse(String(value));
|
|
1144
|
+
if (!Array.isArray(blocks)) throw new Error('content must be a JSON array');
|
|
1145
|
+
} catch (pe) {
|
|
1146
|
+
return jsonErr(res, 400, `content is not valid block JSON: ${pe.message}`, req);
|
|
1147
|
+
}
|
|
1148
|
+
// Back-extract semantic fields from block tree so LLM context stays current
|
|
1149
|
+
let charter = '', teamFocus = '', intentAnchor = '';
|
|
1150
|
+
let lastHeading = '';
|
|
1151
|
+
const charterLines = [], teamFocusLines = [];
|
|
1152
|
+
function visitDeptBlock(block) {
|
|
1153
|
+
if (block.type === 'heading') {
|
|
1154
|
+
const text = (block.content || []).filter(n => n.type === 'text').map(n => n.text || '').join('').toLowerCase();
|
|
1155
|
+
if (text.includes('charter')) lastHeading = 'charter';
|
|
1156
|
+
else if (text.includes('team focus')) lastHeading = 'teamFocus';
|
|
1157
|
+
else lastHeading = text;
|
|
1158
|
+
} else if (block.type === 'intentAnchor') {
|
|
1159
|
+
intentAnchor = block.props?.value || '';
|
|
1160
|
+
} else if (block.type === 'paragraph' || block.type === 'bulletListItem') {
|
|
1161
|
+
const text = (block.content || []).filter(n => n.type === 'text').map(n => n.text || '').join('');
|
|
1162
|
+
if (lastHeading === 'charter') charterLines.push(text);
|
|
1163
|
+
else if (lastHeading === 'teamFocus') teamFocusLines.push(text);
|
|
1164
|
+
}
|
|
1165
|
+
if (Array.isArray(block.children)) block.children.forEach(visitDeptBlock);
|
|
1166
|
+
}
|
|
1167
|
+
blocks.forEach(visitDeptBlock);
|
|
1168
|
+
charter = charterLines.filter(Boolean).join('\n').slice(0, 2000);
|
|
1169
|
+
teamFocus = teamFocusLines.filter(Boolean).join('\n').slice(0, 2000);
|
|
1170
|
+
|
|
1171
|
+
await mgQuery(
|
|
1172
|
+
`MERGE (d:DepartmentDocument {id: $docId})
|
|
1173
|
+
ON CREATE SET d.companyId=$companyId, d.department=$dept, d.version=1, d.createdAt=datetime()
|
|
1174
|
+
ON MATCH SET d.version=d.version+1
|
|
1175
|
+
SET d.content=$content,
|
|
1176
|
+
d.charter = CASE WHEN $charter <> '' THEN $charter ELSE d.charter END,
|
|
1177
|
+
d.teamFocus = CASE WHEN $teamFocus <> '' THEN $teamFocus ELSE d.teamFocus END,
|
|
1178
|
+
d.intentAnchor= CASE WHEN $intentAnchor<> '' THEN $intentAnchor ELSE d.intentAnchor END,
|
|
1179
|
+
d.updatedAt=datetime()`,
|
|
1180
|
+
{ docId, companyId, dept: deptSlug, content: String(value), charter, teamFocus, intentAnchor }
|
|
1181
|
+
);
|
|
1182
|
+
return jsonOk(res, { ok: true, section: 'content' }, req, 200);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ── Semantic section: create pduId, return 202, fire LLM async ────────
|
|
1186
|
+
const proposalId = `dcpp:${deptSlug}:${companyId}:${Date.now()}`;
|
|
1187
|
+
const pduId = `dpdu:${docId}:${Date.now()}`;
|
|
1188
|
+
|
|
1189
|
+
// MERGE the DepartmentDocument first (auto-create on first semantic edit)
|
|
1190
|
+
await mgQuery(
|
|
1191
|
+
`MERGE (d:DepartmentDocument {id: $docId})
|
|
1192
|
+
ON CREATE SET d.companyId=$companyId, d.department=$dept, d.version=0, d.createdAt=datetime(), d.updatedAt=datetime()`,
|
|
1193
|
+
{ docId, companyId, dept: deptSlug }
|
|
1194
|
+
);
|
|
1195
|
+
|
|
1196
|
+
await mgQuery(
|
|
1197
|
+
`MERGE (pdu:DeptPendingDocumentUpdate {id: $pduId})
|
|
1198
|
+
ON CREATE SET pdu.companyId=$companyId, pdu.deptSlug=$deptSlug,
|
|
1199
|
+
pdu.docId=$docId, pdu.section=$section,
|
|
1200
|
+
pdu.newValue=$newValue, pdu.proposalId=$proposalId,
|
|
1201
|
+
pdu.status='pending_review', pdu.createdAt=datetime()`,
|
|
1202
|
+
{ pduId, companyId, deptSlug, docId, section, newValue: String(value), proposalId }
|
|
1203
|
+
);
|
|
1204
|
+
|
|
1205
|
+
await mgQuery(
|
|
1206
|
+
`MERGE (pcp:DeptPlanChangeProposal {id: $proposalId})
|
|
1207
|
+
ON CREATE SET pcp.companyId=$companyId, pcp.deptSlug=$deptSlug,
|
|
1208
|
+
pcp.scope='department', pcp.section=$section,
|
|
1209
|
+
pcp.proposedChange=$value, pcp.pduId=$pduId,
|
|
1210
|
+
pcp.status='pending', pcp.createdAt=datetime()`,
|
|
1211
|
+
{ proposalId, companyId, deptSlug, section, value: String(value), pduId }
|
|
1212
|
+
);
|
|
1213
|
+
|
|
1214
|
+
// Respond 202 immediately — LLM analysis fires asynchronously
|
|
1215
|
+
jsonOk(res, { ok: true, pduId, proposalId, message: 'Analyzing edit — check back shortly' }, req, 202);
|
|
1216
|
+
|
|
1217
|
+
// Trigger semantic analysis asynchronously if semanticUpdater is available
|
|
1218
|
+
if (semanticUpdater) {
|
|
1219
|
+
setImmediate(async () => {
|
|
1220
|
+
try {
|
|
1221
|
+
// Fetch current document for context (charter = intentAnchor analog)
|
|
1222
|
+
const docRes = await mgQuery(
|
|
1223
|
+
`MATCH (d:DepartmentDocument {id: $docId}) RETURN d.charter, d.teamFocus, d.successCriteria, d.intentAnchor`,
|
|
1224
|
+
{ docId }
|
|
1225
|
+
);
|
|
1226
|
+
const docRow = parseRows(docRes)[0];
|
|
1227
|
+
const currentDoc = docRow
|
|
1228
|
+
? {
|
|
1229
|
+
purpose: docRow[0] ?? docRow['charter'] ?? '', // charter → purpose slot
|
|
1230
|
+
approach: docRow[1] ?? docRow['teamFocus'] ?? '', // teamFocus → approach slot
|
|
1231
|
+
successCriteria: docRow[2] ?? docRow['successCriteria'] ?? '[]',
|
|
1232
|
+
intentAnchor: docRow[3] ?? docRow['intentAnchor'] ?? '',
|
|
1233
|
+
}
|
|
1234
|
+
: {};
|
|
1235
|
+
|
|
1236
|
+
let check;
|
|
1237
|
+
if (typeof semanticUpdater.analyzeProposal === 'function') {
|
|
1238
|
+
const proposal = { scope: 'department', targetId: deptSlug, section, proposedChange: String(value), companyId };
|
|
1239
|
+
const context = { purpose: currentDoc.purpose, approach: currentDoc.approach };
|
|
1240
|
+
check = await semanticUpdater.analyzeProposal(proposal, context);
|
|
1241
|
+
} else {
|
|
1242
|
+
const raw = await semanticUpdater.analyzeEdit({
|
|
1243
|
+
pduId,
|
|
1244
|
+
projectId: deptSlug,
|
|
1245
|
+
docId,
|
|
1246
|
+
section,
|
|
1247
|
+
newValue: String(value),
|
|
1248
|
+
oldValue: currentDoc[section] || '',
|
|
1249
|
+
intentAnchor: currentDoc.intentAnchor,
|
|
1250
|
+
exclusions: '[]',
|
|
1251
|
+
});
|
|
1252
|
+
check = {
|
|
1253
|
+
userIntentAsUnderstood: (raw && raw.userIntentAsUnderstood) ? raw.userIntentAsUnderstood : String(value).slice(0, 60),
|
|
1254
|
+
confidenceScore: (raw && raw.confidenceScore != null) ? raw.confidenceScore : 0,
|
|
1255
|
+
components: [],
|
|
1256
|
+
affectedCells: [],
|
|
1257
|
+
proposedChanges: [],
|
|
1258
|
+
requiresApproval: false,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
await mgQuery(
|
|
1263
|
+
`MATCH (pcp:DeptPlanChangeProposal {id: $proposalId})
|
|
1264
|
+
SET pcp.interpretation=$interp, pcp.confidenceScore=$score,
|
|
1265
|
+
pcp.status='ready'`,
|
|
1266
|
+
{
|
|
1267
|
+
proposalId,
|
|
1268
|
+
interp: check.userIntentAsUnderstood || String(value).slice(0, 60),
|
|
1269
|
+
score: check.confidenceScore ?? 0,
|
|
1270
|
+
}
|
|
1271
|
+
).catch(() => {});
|
|
1272
|
+
|
|
1273
|
+
await mgQuery(
|
|
1274
|
+
`MATCH (pdu:DeptPendingDocumentUpdate {id: $pduId}) SET pdu.status='ready', pdu.updatedAt=datetime()`,
|
|
1275
|
+
{ pduId }
|
|
1276
|
+
).catch(() => {});
|
|
1277
|
+
|
|
1278
|
+
broadcast({ type: 'dept:understanding:ready', payload: { deptSlug, proposalId, pduId, check }, companyId });
|
|
1279
|
+
} catch (_) {}
|
|
1280
|
+
});
|
|
1281
|
+
} else {
|
|
1282
|
+
await mgQuery(
|
|
1283
|
+
`MATCH (pcp:DeptPlanChangeProposal {id: $proposalId}) SET pcp.status='ready'`,
|
|
1284
|
+
{ proposalId }
|
|
1285
|
+
).catch(() => {});
|
|
1286
|
+
broadcast({ type: 'dept:understanding:ready', payload: { deptSlug, proposalId, pduId, check: { userIntentAsUnderstood: value, confidenceScore: 1 } }, companyId });
|
|
1287
|
+
}
|
|
1288
|
+
} catch (e) {
|
|
1289
|
+
jsonErr(res, 500, safeMsg(e), req);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// ── POST /api/department/:dept/document/confirm-understanding ─────────────
|
|
1294
|
+
// Accepts { pduId, confirmed, correctionNote }. Looks up proposalId from
|
|
1295
|
+
// DeptPendingDocumentUpdate (fixes pduId/proposalId mismatch pattern).
|
|
1296
|
+
async function handleConfirmDeptUnderstanding(req, res, ctx, deptSlug) {
|
|
1297
|
+
const body = await readBody(req);
|
|
1298
|
+
if (!assertBody(body, res, req)) return;
|
|
1299
|
+
const { pduId, confirmed, correctionNote } = body;
|
|
1300
|
+
if (!pduId || confirmed === undefined) return jsonErr(res, 400, 'pduId and confirmed required', req);
|
|
1301
|
+
try {
|
|
1302
|
+
// Resolve proposalId from pduId — avoids the pduId/proposalId mismatch bug
|
|
1303
|
+
const pduRes = await mgQuery(
|
|
1304
|
+
`MATCH (pdu:DeptPendingDocumentUpdate {id: $pduId}) RETURN pdu.proposalId, pdu.companyId`,
|
|
1305
|
+
{ pduId }
|
|
1306
|
+
);
|
|
1307
|
+
const pduRow = parseRows(pduRes)[0];
|
|
1308
|
+
if (!pduRow) return jsonErr(res, 404, 'DeptPendingDocumentUpdate not found', req);
|
|
1309
|
+
const proposalId = pduRow[0] ?? pduRow['proposalId'];
|
|
1310
|
+
const companyId = (pduRow[1] ?? pduRow['companyId'] ?? ctx?.cid) || '';
|
|
1311
|
+
|
|
1312
|
+
await mgQuery(
|
|
1313
|
+
`MATCH (pcp:DeptPlanChangeProposal {id: $proposalId})
|
|
1314
|
+
SET pcp.confirmedAt=datetime(),
|
|
1315
|
+
pcp.status = CASE WHEN $confirmed THEN 'confirmed' ELSE 'dismissed' END`,
|
|
1316
|
+
{ proposalId, confirmed: !!confirmed }
|
|
1317
|
+
);
|
|
1318
|
+
|
|
1319
|
+
if (confirmed && semanticUpdater) {
|
|
1320
|
+
setImmediate(async () => {
|
|
1321
|
+
try {
|
|
1322
|
+
const pcpRes = await mgQuery(
|
|
1323
|
+
`MATCH (pcp:DeptPlanChangeProposal {id: $proposalId}) RETURN pcp.section, pcp.proposedChange, pcp.interpretation`,
|
|
1324
|
+
{ proposalId }
|
|
1325
|
+
);
|
|
1326
|
+
const pcpRow = parseRows(pcpRes)[0];
|
|
1327
|
+
if (!pcpRow) return;
|
|
1328
|
+
const section = pcpRow[0] ?? pcpRow['section'];
|
|
1329
|
+
const interp = pcpRow[2] ?? pcpRow['interpretation'];
|
|
1330
|
+
// Build dept-scoped impact ctx (no real ActionCells — use empty set)
|
|
1331
|
+
const impact = await semanticUpdater.extractImpact(
|
|
1332
|
+
{ section, proposedValue: pcpRow[1] ?? pcpRow['proposedChange'], interpretation: interp, companyId, actionCells: [] },
|
|
1333
|
+
deptSlug
|
|
1334
|
+
).catch(() => null);
|
|
1335
|
+
if (impact) {
|
|
1336
|
+
await mgQuery(
|
|
1337
|
+
`MATCH (pcp:DeptPlanChangeProposal {id: $proposalId}) SET pcp.impact=$impact, pcp.status='impact_ready'`,
|
|
1338
|
+
{ proposalId, impact: JSON.stringify(impact) }
|
|
1339
|
+
).catch(() => {});
|
|
1340
|
+
broadcast({ type: 'dept:impact:ready', payload: { deptSlug, proposalId, pduId, impact }, companyId });
|
|
1341
|
+
}
|
|
1342
|
+
} catch (_) {}
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
jsonOk(res, { ok: true }, req);
|
|
1347
|
+
} catch (e) {
|
|
1348
|
+
jsonErr(res, 500, safeMsg(e), req);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// ── POST /api/department/:dept/document/apply-update ─────────────────────
|
|
1353
|
+
// Accepts { pduId }. Looks up proposalId from DeptPendingDocumentUpdate.
|
|
1354
|
+
async function handleApplyDeptUpdate(req, res, ctx, deptSlug) {
|
|
1355
|
+
const body = await readBody(req);
|
|
1356
|
+
if (!assertBody(body, res, req)) return;
|
|
1357
|
+
const { pduId } = body;
|
|
1358
|
+
if (!pduId) return jsonErr(res, 400, 'pduId required', req);
|
|
1359
|
+
try {
|
|
1360
|
+
// Resolve proposalId from pduId
|
|
1361
|
+
const pduRes = await mgQuery(
|
|
1362
|
+
`MATCH (pdu:DeptPendingDocumentUpdate {id: $pduId}) RETURN pdu.proposalId, pdu.companyId`,
|
|
1363
|
+
{ pduId }
|
|
1364
|
+
);
|
|
1365
|
+
const pduRow = parseRows(pduRes)[0];
|
|
1366
|
+
if (!pduRow) return jsonErr(res, 404, 'DeptPendingDocumentUpdate not found', req);
|
|
1367
|
+
const proposalId = pduRow[0] ?? pduRow['proposalId'];
|
|
1368
|
+
const companyId = (pduRow[1] ?? pduRow['companyId'] ?? ctx?.cid) || '';
|
|
1369
|
+
|
|
1370
|
+
const pcpRes = await mgQuery(
|
|
1371
|
+
`MATCH (pcp:DeptPlanChangeProposal {id: $proposalId, deptSlug: $deptSlug})
|
|
1372
|
+
RETURN pcp.section, pcp.proposedChange`,
|
|
1373
|
+
{ proposalId, deptSlug }
|
|
1374
|
+
);
|
|
1375
|
+
const pcpRow = parseRows(pcpRes)[0];
|
|
1376
|
+
if (!pcpRow) return jsonErr(res, 404, 'DeptPlanChangeProposal not found', req);
|
|
1377
|
+
|
|
1378
|
+
const section = pcpRow[0] ?? pcpRow['section'];
|
|
1379
|
+
const value = pcpRow[1] ?? pcpRow['proposedChange'];
|
|
1380
|
+
const docId = `ddoc:${companyId}:${deptSlug}:main`;
|
|
1381
|
+
|
|
1382
|
+
const SAFE_DEPT_SECTIONS = ['charter', 'teamFocus', 'successCriteria', 'intentAnchor'];
|
|
1383
|
+
if (!SAFE_DEPT_SECTIONS.includes(section)) return jsonErr(res, 400, `Invalid section: ${section}`, req);
|
|
1384
|
+
|
|
1385
|
+
await mgQuery(
|
|
1386
|
+
`MATCH (d:DepartmentDocument {id: $docId})
|
|
1387
|
+
SET d.${section} = $value, d.version = d.version + 1, d.updatedAt = datetime()`,
|
|
1388
|
+
{ docId, value: String(value) }
|
|
1389
|
+
);
|
|
1390
|
+
|
|
1391
|
+
await mgQuery(
|
|
1392
|
+
`MATCH (pcp:DeptPlanChangeProposal {id: $proposalId}) SET pcp.status='applied', pcp.resolvedAt=datetime()`,
|
|
1393
|
+
{ proposalId }
|
|
1394
|
+
).catch(() => {});
|
|
1395
|
+
|
|
1396
|
+
const verRes = await mgQuery(
|
|
1397
|
+
`MATCH (d:DepartmentDocument {id: $docId}) RETURN d.version`,
|
|
1398
|
+
{ docId }
|
|
1399
|
+
).catch(() => null);
|
|
1400
|
+
const documentVersion = parseRows(verRes)[0]?.[0] ?? null;
|
|
1401
|
+
|
|
1402
|
+
broadcast({ type: 'dept:document:applied', payload: { deptSlug, proposalId, pduId, section, documentVersion }, companyId });
|
|
1403
|
+
jsonOk(res, { ok: true, documentVersion }, req);
|
|
1404
|
+
} catch (e) {
|
|
1405
|
+
jsonErr(res, 500, safeMsg(e), req);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
924
1409
|
// ── Router ────────────────────────────────────────────────────────────────
|
|
925
1410
|
|
|
926
1411
|
return async function projectRoute(req, res, ctx, pathname, method) {
|
|
@@ -940,6 +1425,35 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
940
1425
|
await handleGenerateDepartmentPage(req, res, ctx, deptGenMatch[1]);
|
|
941
1426
|
return true;
|
|
942
1427
|
}
|
|
1428
|
+
|
|
1429
|
+
// ── /api/department/:dept/document routes ─────────────────────────────────
|
|
1430
|
+
// GET /api/department/:dept/document?companyId=
|
|
1431
|
+
// PATCH /api/department/:dept/document
|
|
1432
|
+
// POST /api/department/:dept/document/confirm-understanding
|
|
1433
|
+
// POST /api/department/:dept/document/apply-update
|
|
1434
|
+
const deptDocMatch = pathname.match(/^\/api\/department\/([^/]+)\/document(\/.*)?$/);
|
|
1435
|
+
if (deptDocMatch) {
|
|
1436
|
+
const deptSlug = deptDocMatch[1];
|
|
1437
|
+
const deptDocRest = deptDocMatch[2] || '';
|
|
1438
|
+
|
|
1439
|
+
if (method === 'GET' && deptDocRest === '') {
|
|
1440
|
+
await handleGetDepartmentDocument(req, res, ctx, deptSlug);
|
|
1441
|
+
return true;
|
|
1442
|
+
}
|
|
1443
|
+
if (method === 'PATCH' && deptDocRest === '') {
|
|
1444
|
+
await handlePatchDepartmentDocument(req, res, ctx, deptSlug);
|
|
1445
|
+
return true;
|
|
1446
|
+
}
|
|
1447
|
+
if (method === 'POST' && deptDocRest === '/confirm-understanding') {
|
|
1448
|
+
await handleConfirmDeptUnderstanding(req, res, ctx, deptSlug);
|
|
1449
|
+
return true;
|
|
1450
|
+
}
|
|
1451
|
+
if (method === 'POST' && deptDocRest === '/apply-update') {
|
|
1452
|
+
await handleApplyDeptUpdate(req, res, ctx, deptSlug);
|
|
1453
|
+
return true;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
943
1457
|
// Return false for any other /api/department path (GET is handled by helios-api.js)
|
|
944
1458
|
if (pathname.startsWith('/api/department')) return false;
|
|
945
1459
|
|