@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.
Files changed (157) hide show
  1. package/agents/business/talisman-ceo.md +183 -0
  2. package/agents/business/talisman-comms.md +257 -0
  3. package/agents/business/talisman-cto.md +153 -0
  4. package/agents/business/talisman-finance.md +246 -0
  5. package/agents/business/talisman-marketing.md +240 -0
  6. package/agents/business/talisman-sales.md +242 -0
  7. package/agents/business/talisman-support.md +236 -0
  8. package/bin/helios-rpc.js +19 -0
  9. package/daemon/adapters/helios-rpc-adapter.js +5 -12
  10. package/daemon/adapters/tui_wakeup.js +8 -0
  11. package/daemon/context-enrichment.js +27 -0
  12. package/daemon/daemon-manager.js +1 -1
  13. package/daemon/db/email-infrastructure-migrate.js +192 -0
  14. package/daemon/db/hbo-core-migrate.js +189 -0
  15. package/daemon/helios-api.js +863 -64
  16. package/daemon/helios-company-daemon.js +233 -33
  17. package/daemon/lib/blast-radius-analyzer.js +75 -0
  18. package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
  19. package/daemon/lib/forensic-log.js +113 -0
  20. package/daemon/lib/goal-research-pipeline.js +644 -0
  21. package/daemon/lib/harada/cascade-judge.js +84 -1
  22. package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
  23. package/daemon/lib/harada/pillar-dispatcher.js +23 -2
  24. package/daemon/lib/hbo-bridge.js +74 -6
  25. package/daemon/lib/headroom-middleware.js +129 -0
  26. package/daemon/lib/headroom-proxy-manager.js +309 -0
  27. package/daemon/lib/hed-engine.js +25 -0
  28. package/daemon/lib/intelligence/department-page-generator.js +46 -1
  29. package/daemon/lib/interpretation-engine.js +92 -0
  30. package/daemon/lib/mental-model-cache.js +96 -0
  31. package/daemon/lib/project-factory.js +47 -0
  32. package/daemon/lib/session-log-reader.js +93 -0
  33. package/daemon/lib/standard-work-bootstrap.js +87 -1
  34. package/daemon/lib/task-completion-processor.js +23 -0
  35. package/daemon/lib/wizard-engine.js +57 -6
  36. package/daemon/package.json +2 -1
  37. package/daemon/routes/agents.js +51 -6
  38. package/daemon/routes/channels.js +116 -2
  39. package/daemon/routes/crm.js +85 -0
  40. package/daemon/routes/dashboard.js +62 -16
  41. package/daemon/routes/dept.js +10 -1
  42. package/daemon/routes/email-triage.js +19 -10
  43. package/daemon/routes/hbo.js +618 -58
  44. package/daemon/routes/hed.js +133 -0
  45. package/daemon/routes/inbox.js +397 -8
  46. package/daemon/routes/project.js +580 -66
  47. package/daemon/routes/routines.js +14 -0
  48. package/daemon/routes/tasks.js +15 -1
  49. package/daemon/schema-apply.js +174 -0
  50. package/daemon/schema-definitions.js +433 -0
  51. package/daemon/schema-migrations-hbo.js +20 -0
  52. package/daemon/schema-migrations-hed.js +18 -0
  53. package/daemon/schema-migrations-proj.js +153 -0
  54. package/extensions/__tests__/codebase-index.test.ts +73 -0
  55. package/extensions/__tests__/extension-command-registration.test.ts +35 -0
  56. package/extensions/__tests__/git-push-guard.test.ts +68 -0
  57. package/extensions/context-compaction.ts +104 -76
  58. package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
  59. package/extensions/cortex/wal-replay.ts +91 -0
  60. package/extensions/email/actions/draft-response.ts +21 -1
  61. package/extensions/email/auth/accounts.ts +5 -11
  62. package/extensions/email/auth/inbox-dog.ts +5 -2
  63. package/extensions/email/backfill.ts +20 -13
  64. package/extensions/email/providers/gmail.ts +164 -0
  65. package/extensions/email/providers/google-calendar.ts +34 -5
  66. package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
  67. package/extensions/helios-browser/backends/playwright.ts +3 -1
  68. package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
  69. package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
  70. package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
  71. package/extensions/hema-dispatch-v3/index.ts +46 -72
  72. package/extensions/interview/__tests__/server.test.ts +117 -0
  73. package/extensions/lib/helios-root.cjs +46 -0
  74. package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
  75. package/extensions/warm-tick/warm-tick-maintenance.ts +164 -0
  76. package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
  77. package/lib/__tests__/crash-fixes.test.ts +49 -0
  78. package/lib/__tests__/hbo-core-store.test.js +238 -0
  79. package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
  80. package/lib/broker/__tests__/jit-subscription.test.js +44 -1
  81. package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
  82. package/lib/compression/__tests__/ccr-store.test.js +138 -0
  83. package/lib/compression/__tests__/pipeline.test.js +280 -0
  84. package/lib/compression/__tests__/smart-crusher.test.js +242 -0
  85. package/lib/compression/dist/server.js +34 -1
  86. package/lib/compression/dist/start-server.js +77 -0
  87. package/lib/event-bus.mts +1 -1
  88. package/lib/graph/learning/headroom-learn-bridge.js +175 -0
  89. package/lib/graph-availability.js +62 -0
  90. package/lib/hbo-core-store.compiled.js +834 -0
  91. package/lib/hbo-core-store.js +124 -0
  92. package/lib/hbo-core-store.ts +979 -0
  93. package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
  94. package/lib/skill-sync.js +6 -1
  95. package/lib/startup-integrity.js +9 -2
  96. package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
  97. package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
  98. package/lib/triage-core/__tests__/classifier.test.ts +45 -7
  99. package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
  100. package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
  101. package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
  102. package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
  103. package/lib/triage-core/__tests__/signals.test.ts +357 -0
  104. package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
  105. package/lib/triage-core/backfill-cost-estimator.ts +91 -0
  106. package/lib/triage-core/backfill-orchestrator.ts +119 -0
  107. package/lib/triage-core/classifier.ts +41 -8
  108. package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
  109. package/lib/triage-core/cos/response-debt.ts +2 -2
  110. package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
  111. package/lib/triage-core/graph/batch-persistence.ts +66 -2
  112. package/lib/triage-core/graph/betweenness-worker.js +75 -0
  113. package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
  114. package/lib/triage-core/graph/persistence.ts +1 -1
  115. package/lib/triage-core/graph/schema-v2.ts +2 -0
  116. package/lib/triage-core/graph/schema.cypher +11 -0
  117. package/lib/triage-core/graph/triage-query.ts +1 -1
  118. package/lib/triage-core/learning.ts +15 -20
  119. package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
  120. package/lib/triage-core/mental-model/cos-integration.ts +1 -1
  121. package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
  122. package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
  123. package/lib/triage-core/mental-model/key-facts.ts +1 -2
  124. package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
  125. package/lib/triage-core/mental-model/model-assembler.ts +16 -3
  126. package/lib/triage-core/orchestrator.ts +8 -15
  127. package/lib/triage-core/scheduled-sends.ts +39 -2
  128. package/lib/triage-core/signals/comms-style.ts +1 -1
  129. package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
  130. package/lib/triage-core/signals/favee-type.ts +6 -1
  131. package/lib/triage-core/signals/goal-relevance.ts +31 -2
  132. package/lib/triage-core/signals/personal-importance.ts +1 -1
  133. package/lib/triage-core/signals/referral-chain.ts +0 -1
  134. package/lib/triage-core/signals/relationship-decay.ts +4 -0
  135. package/lib/triage-core/signals/relationship-health.ts +6 -1
  136. package/lib/triage-core/signals/trajectory-signal.ts +38 -3
  137. package/lib/triage-core/tournament-runner.js +11 -1
  138. package/lib/triage-core/triage-llm-factory.ts +110 -0
  139. package/lib/triage-core/triage-local-llm.ts +145 -0
  140. package/lib/triage-core/triage-sql-store.ts +337 -0
  141. package/lib/triage-core/types.ts +2 -2
  142. package/lib/unified-graph.atomic.test.ts +52 -0
  143. package/lib/unified-graph.failure-categories.test.ts +55 -0
  144. package/package.json +18 -7
  145. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  146. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  147. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  148. package/skills/helios-bookkeeping/SKILL.md +321 -0
  149. package/skills/helios-briefer/SKILL.md +44 -0
  150. package/skills/helios-client-relations/SKILL.md +322 -0
  151. package/skills/helios-personal-triager/SKILL.md +45 -0
  152. package/skills/helios-recruitment/SKILL.md +317 -0
  153. package/skills/helios-relationship-nudger/SKILL.md +77 -0
  154. package/skills/helios-researcher/SKILL.md +44 -0
  155. package/skills/helios-scheduler/SKILL.md +58 -0
  156. package/skills/helios-tax-analyst/SKILL.md +280 -0
  157. package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
@@ -32,7 +32,7 @@ function getCorsHeaders(req) {
32
32
  const allowedOrigin = origin && HBO_ALLOWED_ORIGINS.has(origin) ? origin : 'http://localhost:9093';
33
33
  return {
34
34
  'Access-Control-Allow-Origin': allowedOrigin,
35
- 'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS',
35
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
36
36
  'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization',
37
37
  'Vary': 'Origin',
38
38
  };
@@ -431,28 +431,37 @@ async function handleTransitionBusinessTask(req, res, ctx, taskId) {
431
431
  const taskService = loadHBOService('lib/business-task-service.js');
432
432
  if (taskService && typeof taskService.transition === 'function') {
433
433
  try {
434
- const task = await Promise.race([
435
- taskService.transition(taskId, status, cid),
436
- new Promise((_, reject) =>
437
- setTimeout(() => reject(new Error('task-service timeout — falling back to direct')), 5000)
438
- ),
439
- ]);
440
- jsonOk(res, { task });
441
- return;
442
- } catch (_svcErr) {
443
- // service failed for any reason — fall through to direct Memgraph path below
444
- }
445
- }
446
-
447
- // Fallback: update directly via ctx.mgQuery
448
- const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
449
- await mgQuery(
450
- `MATCH (bt:BusinessTask {id: $id, companyId: $cid})
451
- SET bt.status = $status, bt.updatedAt = datetime($now)`,
452
- { id: taskId, cid, status, now }
453
- );
454
- _bc({ type: 'task.updated', taskId, companyId: cid, status });
455
- jsonOk(res, { updated: true, taskId, status });
434
+ const task = await Promise.race([
435
+ taskService.transition(taskId, status, cid),
436
+ new Promise((_, reject) =>
437
+ setTimeout(() => reject(new Error('task-service timeout — falling back to direct')), 5000)
438
+ ),
439
+ ]);
440
+ if (task !== null && task !== undefined) {
441
+ jsonOk(res, { task });
442
+ return;
443
+ }
444
+ // Service did not find a task for this company; fall through to the
445
+ // direct path so we can return a clear 404 instead of a silent success.
446
+ } catch (_svcErr) {
447
+ // service failed for any reason — fall through to direct Memgraph path below
448
+ }
449
+ }
450
+
451
+ // Fallback: update directly via ctx.mgQuery
452
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
453
+ const result = await mgQuery(
454
+ `MATCH (bt:BusinessTask {id: $id, companyId: $cid})
455
+ SET bt.status = $status, bt.updatedAt = datetime($now)
456
+ RETURN bt.id AS id`,
457
+ { id: taskId, cid, status, now }
458
+ );
459
+ if (parseRows(result).length === 0) {
460
+ jsonErr(res, 404, `BusinessTask ${taskId} not found for company ${cid}`);
461
+ return;
462
+ }
463
+ _bc({ type: 'task.updated', taskId, companyId: cid, status });
464
+ jsonOk(res, { updated: true, taskId, status });
456
465
  } catch (e) {
457
466
  jsonErr(res, 500, `Transition BusinessTask failed: ${safeErrMsg(e)}`);
458
467
  }
@@ -462,34 +471,206 @@ async function handleTransitionBusinessTask(req, res, ctx, taskId) {
462
471
 
463
472
  async function handleGetBusinessGoals(req, res, ctx) {
464
473
  const { mgQuery, cid } = ctx;
465
- if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
474
+ // SQL-P1: SQLite fallback when Memgraph is unavailable — not all users have Memgraph.
475
+ // Try Memgraph first; on failure (no connection or query error), fall through to SQLite.
476
+ if (mgQuery) {
477
+ try {
478
+ // Get all BusinessGoal nodes
479
+ // BUG-12 fix: BusinessGoal relationships are stored as (child)-[:CHILD_OF]->(parent).
480
+ const result = await mgQuery(
481
+ `MATCH (bg:BusinessGoal {companyId: $cid})
482
+ OPTIONAL MATCH (child:BusinessGoal)-[:CHILD_OF]->(bg)
483
+ WITH bg, [x IN collect(child.id) WHERE x IS NOT NULL] AS childIds
484
+ RETURN bg.id AS id, bg.title AS title, bg.level AS level,
485
+ bg.ownerAgentId AS ownerAgentId, bg.status AS status,
486
+ bg.progress AS progress, childIds
487
+ ORDER BY bg.level ASC`,
488
+ { cid }
489
+ );
490
+ const rows = parseRows(result);
491
+ const keys = ['id', 'title', 'level', 'ownerAgentId', 'status', 'progress', 'childIds'];
492
+ const goals = rows.map(r => {
493
+ const goal = rowToObj(r, keys);
494
+ if (goal.level !== null && goal.level !== undefined) goal.level = String(goal.level);
495
+ return goal;
496
+ });
497
+ const rootGoals = goals.filter(g => !g.level || String(g.level) === 'company');
498
+ return jsonOk(res, { goals, rootGoals, count: goals.length });
499
+ } catch (e) {
500
+ // Memgraph query failed — fall through to SQLite fallback below
501
+ process.stderr.write(`[hbo] handleGetBusinessGoals Memgraph failed, falling back to SQLite: ${e.message}\n`);
502
+ }
503
+ }
504
+ // SQLite fallback (Memgraph unavailable or query failed)
466
505
  try {
467
- // Get all BusinessGoal nodes
468
- // BUG-12 fix: BusinessGoal relationships are stored as (child)-[:CHILD_OF]->(parent).
469
- // The old query used (bg)-[:PARENT_OF]->(child) which matched zero edges.
470
- // Migration note: run `MATCH (bg:BusinessGoal)-[:PARENT_OF]->(c:BusinessGoal)
471
- // MERGE (c)-[:CHILD_OF]->(bg) DELETE relationship` if old edges exist.
472
- const result = await mgQuery(
473
- `MATCH (bg:BusinessGoal {companyId: $cid})
474
- OPTIONAL MATCH (child:BusinessGoal)-[:CHILD_OF]->(bg)
475
- WITH bg, [x IN collect(child.id) WHERE x IS NOT NULL] AS childIds
476
- RETURN bg.id AS id, bg.title AS title, bg.level AS level,
477
- bg.ownerAgentId AS ownerAgentId, bg.status AS status,
478
- bg.progress AS progress, childIds
479
- ORDER BY bg.level ASC`,
480
- { cid }
481
- );
482
- const rows = parseRows(result);
483
- const keys = ['id', 'title', 'level', 'ownerAgentId', 'status', 'progress', 'childIds'];
484
- const goals = rows.map(r => rowToObj(r, keys));
485
- // Build hierarchy tree
486
- const rootGoals = goals.filter(g => !g.level || g.level === 0 || g.level === 'company');
487
- jsonOk(res, { goals, rootGoals, count: goals.length });
488
- } catch (e) {
489
- jsonErr(res, 500, `BusinessGoal query failed: ${safeErrMsg(e)}`);
506
+ const storeGoals = hboStore.getGoalsByCompany ? hboStore.getGoalsByCompany(cid) : [];
507
+ const goals = (storeGoals || []).map(g => ({
508
+ id: g.id ?? g.id,
509
+ title: g.title ?? null,
510
+ level: g.level ? String(g.level) : null,
511
+ ownerAgentId: g.ownerAgentId ?? null,
512
+ status: g.status ?? null,
513
+ progress: g.progress ?? null,
514
+ childIds: g.childIds ?? [],
515
+ }));
516
+ const rootGoals = goals.filter(g => !g.level || g.level === 'company');
517
+ return jsonOk(res, { goals, rootGoals, count: goals.length, _source: 'sqlite' });
518
+ } catch (storeErr) {
519
+ return jsonErr(res, 503, `Goals unavailable: Memgraph not connected and SQLite fallback failed: ${storeErr.message}`);
520
+ }
521
+ }
522
+
523
+ async function handleGetBusinessGoal(req, res, ctx, goalId) {
524
+ const { mgQuery, cid } = ctx;
525
+ if (!goalId) { jsonErr(res, 400, 'Missing goal ID'); return; }
526
+ // SQL-P2: SQLite fallback when Memgraph unavailable.
527
+ if (mgQuery) {
528
+ try {
529
+ const result = await mgQuery(
530
+ `MATCH (g:BusinessGoal {id: $id, companyId: $cid})
531
+ RETURN g.id AS id, g.title AS title, g.description AS description,
532
+ g.level AS level, g.status AS status, g.parentId AS parentId,
533
+ g.ownerAgentId AS ownerAgentId, g.progress AS progress`,
534
+ { id: goalId, cid }
535
+ );
536
+ const rows = parseRows(result);
537
+ if (rows.length === 0) { jsonErr(res, 404, `BusinessGoal ${goalId} not found`); return; }
538
+ const keys = ['id', 'title', 'description', 'level', 'status', 'parentId', 'ownerAgentId', 'progress'];
539
+ return jsonOk(res, { goal: rowToObj(rows[0], keys) });
540
+ } catch (e) {
541
+ process.stderr.write(`[hbo] handleGetBusinessGoal Memgraph failed, falling back to SQLite: ${e.message}\n`);
542
+ }
543
+ }
544
+ // SQLite fallback
545
+ try {
546
+ const g = hboStore.getGoal ? hboStore.getGoal(goalId, cid) : null;
547
+ if (!g) { jsonErr(res, 404, `BusinessGoal ${goalId} not found`); return; }
548
+ return jsonOk(res, { goal: g, _source: 'sqlite' });
549
+ } catch (storeErr) {
550
+ return jsonErr(res, 503, `Goal unavailable: ${storeErr.message}`);
490
551
  }
491
552
  }
492
553
 
554
+ async function handleCreateBusinessGoal(req, res, ctx) {
555
+ const { mgQuery, cid } = ctx;
556
+ if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
557
+ // H-4 fix: reject if cid is missing or is the fallback sentinel — prevents silent writes to 'default-company'
558
+ if (!cid || cid === 'default-company') { jsonErr(res, 400, 'companyId is required'); return; }
559
+ try {
560
+ const body = await readBody(req);
561
+ if (!assertValidBody(body, res)) return;
562
+ const title = typeof body.title === 'string' ? body.title.trim() : '';
563
+ if (!title) { jsonErr(res, 400, 'title is required'); return; }
564
+
565
+ const goalId = body.id ? String(body.id) : `bg:${randomUUID()}`;
566
+ const description = body.description !== undefined ? String(body.description) : null;
567
+ const level = body.level !== undefined ? String(body.level) : 'company';
568
+ const parentId = body.parentId || body.parentGoalId ? String(body.parentId || body.parentGoalId) : null;
569
+
570
+ await mgQuery(
571
+ `CREATE (g:BusinessGoal {
572
+ id: $id, companyId: $cid, title: $title, description: $desc,
573
+ level: $level, status: 'active', parentId: $parentId,
574
+ parentGoalId: $parentId, createdAt: datetime(), updatedAt: datetime()
575
+ })`,
576
+ { id: goalId, cid, title, desc: description, level, parentId }
577
+ );
578
+ try {
579
+ hboStore.createGoal?.({ id: goalId, companyId: cid, title, description, level, status: 'active', parentId, createdAt: Date.now() });
580
+ } catch (storeErr) {
581
+ process.stderr.write(`[hbo] goal mirror failed: ${storeErr.message}\n`);
582
+ }
583
+ try { ctx._bc?.({ type: 'goal.created', companyId: cid, goalId }); } catch (_) {}
584
+ // Fire-and-forget: trigger tickGoalDecompose so the Harada cascade begins immediately
585
+ // (normally runs every 5th tick = up to 150s; injected hook shortens this to ~0ms).
586
+ if (_triggerGoalDecompose) {
587
+ setImmediate(() => {
588
+ try { _triggerGoalDecompose(cid); } catch (_tgdErr) {}
589
+ });
590
+ }
591
+ jsonOk(res, { goal: { id: goalId, companyId: cid, title, description, level, status: 'active', parentId } }, 201);
592
+ } catch (e) {
593
+ jsonErr(res, 500, `Create BusinessGoal failed: ${safeErrMsg(e)}`);
594
+ }
595
+ }
596
+
597
+ async function handleUpdateBusinessGoal(req, res, ctx, goalId) {
598
+ const { mgQuery, cid } = ctx;
599
+ if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
600
+ if (!goalId) { jsonErr(res, 400, 'Missing goal ID'); return; }
601
+ try {
602
+ const body = await readBody(req);
603
+ if (!assertValidBody(body, res)) return;
604
+ const title = body.title !== undefined ? String(body.title).trim() : null;
605
+ const description = body.description !== undefined ? String(body.description) : null;
606
+ const level = body.level !== undefined ? String(body.level) : null;
607
+ const status = body.status !== undefined ? String(body.status) : null;
608
+ const parentId = body.parentId !== undefined ? (body.parentId ? String(body.parentId) : null) : undefined;
609
+
610
+ if (title === '' || (title === null && description === null && level === null && status === null && parentId === undefined)) {
611
+ jsonErr(res, 400, 'At least one of title, description, level, status, parentId must be provided');
612
+ return;
613
+ }
614
+
615
+ const result = await mgQuery(
616
+ `MATCH (g:BusinessGoal {id: $id, companyId: $cid})
617
+ SET g.title = COALESCE($title, g.title),
618
+ g.description = CASE WHEN $hasDescription THEN $description ELSE g.description END,
619
+ g.level = COALESCE($level, g.level),
620
+ g.status = COALESCE($status, g.status),
621
+ g.parentId = CASE WHEN $hasParentId THEN $parentId ELSE g.parentId END,
622
+ g.parentGoalId = CASE WHEN $hasParentId THEN $parentId ELSE g.parentGoalId END,
623
+ g.updatedAt = datetime()
624
+ RETURN g.id AS id, g.title AS title, g.description AS description,
625
+ g.level AS level, g.status AS status, g.parentId AS parentId`,
626
+ {
627
+ id: goalId, cid, title, description, level, status,
628
+ parentId: parentId === undefined ? null : parentId,
629
+ hasDescription: body.description !== undefined,
630
+ hasParentId: parentId !== undefined,
631
+ }
632
+ );
633
+ const rows = parseRows(result);
634
+ if (rows.length === 0) { jsonErr(res, 404, `BusinessGoal ${goalId} not found`); return; }
635
+ const goal = rowToObj(rows[0], ['id', 'title', 'description', 'level', 'status', 'parentId']);
636
+ // H-3 fix: only pass fields that were explicitly provided in the PATCH body to updateGoal.
637
+ // rowToObj fills missing Memgraph fields with null — passing null for e.g. title would
638
+ // overwrite the existing SQLite title with null on a status-only update.
639
+ const storeUpdate = { id: goalId };
640
+ if (title !== null) storeUpdate.title = goal.title;
641
+ if (description !== null) storeUpdate.description = goal.description;
642
+ if (level !== null) storeUpdate.level = goal.level;
643
+ if (status !== null) storeUpdate.status = goal.status;
644
+ if (parentId !== undefined) storeUpdate.parentId = goal.parentId;
645
+ try { hboStore.updateGoal?.(goalId, cid, storeUpdate); } catch (storeErr) { process.stderr.write(`[hbo] goal mirror failed: ${storeErr.message}\n`); }
646
+ try { ctx._bc?.({ type: 'goal.updated', companyId: cid, goalId }); } catch (_) {}
647
+ jsonOk(res, { goal });
648
+ } catch (e) {
649
+ jsonErr(res, 500, `Update BusinessGoal failed: ${safeErrMsg(e)}`);
650
+ }
651
+ }
652
+
653
+ async function handleDeleteBusinessGoal(req, res, ctx, goalId) {
654
+ const { mgQuery, cid } = ctx;
655
+ if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
656
+ if (!goalId) { jsonErr(res, 400, 'Missing goal ID'); return; }
657
+ try {
658
+ const result = await mgQuery(
659
+ `MATCH (g:BusinessGoal {id: $id, companyId: $cid})
660
+ WITH g, g.id AS deletedId
661
+ DETACH DELETE g
662
+ RETURN deletedId AS id`,
663
+ { id: goalId, cid }
664
+ );
665
+ if (parseRows(result).length === 0) { jsonErr(res, 404, `BusinessGoal ${goalId} not found`); return; }
666
+ try { hboStore.deleteGoal?.(goalId, cid); } catch (storeErr) { process.stderr.write(`[hbo] goal mirror failed: ${storeErr.message}\n`); }
667
+ try { ctx._bc?.({ type: 'goal.deleted', companyId: cid, goalId }); } catch (_) {}
668
+ jsonOk(res, { deleted: true, goalId });
669
+ } catch (e) {
670
+ jsonErr(res, 500, `Delete BusinessGoal failed: ${safeErrMsg(e)}`);
671
+ }
672
+ }
673
+
493
674
  // ── Warm signals handler ──────────────────────────────────────────────────────
494
675
 
495
676
  async function handleGetWarmSignals(req, res, ctx) {
@@ -2181,9 +2362,11 @@ async function handleGetHboApprovals(req, res, ctx) {
2181
2362
  const type = url.searchParams.get('type') || null;
2182
2363
 
2183
2364
  // GP3-B2: expanded to include strategy_proposal and harada_strategy_review types
2184
- let cypher = `MATCH (a:Approval {companyId: $cid}) WHERE a.type IN ['question','send_approval','strategy_proposal','harada_strategy_review','approval','domain_purchase','sender_identity_config','dns_records_review','email_domain_setup','harada_l2_review','harada_l3_review','harada_question']`;
2365
+ let cypher = `MATCH (a:Approval {companyId: $cid}) WHERE a.type IN ['question','send_approval','strategy_proposal','harada_strategy_review','approval','domain_purchase','sender_identity_config','dns_records_review','email_domain_setup','harada_l2_review','harada_l3_review','harada_question','plan_change_review']`;
2185
2366
  const params = { cid: ctx.cid };
2186
- if (status) { cypher += ' AND a.status = $status'; params.status = status; }
2367
+ // F-03: 'pending' filter also includes 'revision_requested' so operators see revision_requested items in the Pending tab
2368
+ if (status === 'pending') { cypher += " AND a.status IN ['pending', 'revision_requested']"; }
2369
+ else if (status) { cypher += ' AND a.status = $status'; params.status = status; }
2187
2370
  if (type) { cypher += ' AND a.type = $type'; params.type = type; }
2188
2371
  cypher += ' RETURN a ORDER BY a.createdAt DESC LIMIT toInteger(50)';
2189
2372
 
@@ -2210,6 +2393,8 @@ async function handleGetHboApprovals(req, res, ctx) {
2210
2393
  department: p?.department ?? null,
2211
2394
  templateKey: p?.templateKey ?? null,
2212
2395
  inferredAnswer: p?.inferredAnswer ?? null,
2396
+ decisionNote: p?.decisionNote ?? null,
2397
+ followUpTaskId: p?.followUpTaskId ?? null,
2213
2398
  };
2214
2399
  });
2215
2400
  jsonOk(res, { approvals, count: approvals.length });
@@ -2218,6 +2403,28 @@ async function handleGetHboApprovals(req, res, ctx) {
2218
2403
  }
2219
2404
  }
2220
2405
 
2406
+ // P9A: GET /api/hbo/blocked-work — tasks stopped due to a blocker
2407
+ async function handleGetBlockedWork(req, res, ctx) {
2408
+ try {
2409
+ if (!ctx.mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
2410
+ const url = new URL(req.url, 'http://localhost');
2411
+ const cid = ctx.cid || url.searchParams.get('companyId') || url.searchParams.get('cid') || '';
2412
+ const r = await ctx.mgQuery(
2413
+ `MATCH (t:Task) WHERE t.blockedReason IS NOT NULL AND t.status = 'blocked' AND t.companyId = $cid
2414
+ RETURN t.id AS id, t.title AS title, t.blockedReason AS blockedReason, t.blockedAt AS blockedAt
2415
+ ORDER BY t.blockedAt DESC`,
2416
+ { cid }
2417
+ ).catch(() => null);
2418
+ const blockedWork = (r?.rows ?? []).map(row => {
2419
+ if (Array.isArray(row)) return { id: row[0], title: row[1], blockedReason: row[2], blockedAt: row[3] };
2420
+ return { id: row['id'], title: row['title'], blockedReason: row['blockedReason'], blockedAt: row['blockedAt'] };
2421
+ });
2422
+ jsonOk(res, { blockedWork, count: blockedWork.length });
2423
+ } catch (e) {
2424
+ jsonErr(res, 500, `Get blocked-work failed: ${safeErrMsg(e)}`);
2425
+ }
2426
+ }
2427
+
2221
2428
  async function handleHboLaunch(req, res, ctx) {
2222
2429
  const { mgQuery } = ctx;
2223
2430
  if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
@@ -2726,9 +2933,90 @@ async function handleGetCommandCenter(req, res, ctx) {
2726
2933
  }
2727
2934
  }
2728
2935
 
2936
+ // ── G-01: Budget Policy CRUD Handlers ────────────────────────────────────────
2937
+
2938
+ async function handleCreateBudgetPolicy(req, res, ctx) {
2939
+ try {
2940
+ if (!ctx.mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
2941
+ const body = await readBody(req);
2942
+ if (!assertValidBody(body, res)) return;
2943
+ const { agentId, limitUSD, period, companyId: bodyCompanyId, id: clientId } = body || {};
2944
+ if (!agentId || limitUSD === undefined || !period) {
2945
+ jsonErr(res, 400, 'agentId, limitUSD, and period are required'); return;
2946
+ }
2947
+ // Use client-supplied id (from SQLite saved record) so delete path matches; fall back to generated key.
2948
+ const cid = bodyCompanyId || ctx.cid;
2949
+ const id = clientId || `bp:${cid}:${agentId}:${Date.now()}`;
2950
+ await ctx.mgQuery(
2951
+ `MERGE (bp:BudgetPolicy {id: $id})
2952
+ ON CREATE SET bp.companyId = $cid, bp.agentId = $agentId,
2953
+ bp.limitUSD = $limitUSD, bp.period = $period,
2954
+ bp.limitCents = toInteger($limitUSD * 100),
2955
+ bp.spentCents = 0, bp.createdAt = datetime()
2956
+ ON MATCH SET bp.limitUSD = $limitUSD, bp.period = $period,
2957
+ bp.limitCents = toInteger($limitUSD * 100)`,
2958
+ { id, cid, agentId: String(agentId), limitUSD: Number(limitUSD), period: String(period) }
2959
+ );
2960
+ jsonOk(res, { policy: { id, agentId, limitUSD: Number(limitUSD), period } });
2961
+ } catch (e) { jsonErr(res, 500, `Create budget policy failed: ${safeErrMsg(e)}`); }
2962
+ }
2963
+
2964
+ async function handleUpdateBudgetPolicy(req, res, ctx, policyId) {
2965
+ try {
2966
+ if (!ctx.mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
2967
+ if (!policyId) { jsonErr(res, 400, 'Missing policy ID'); return; }
2968
+ const body = await readBody(req);
2969
+ if (!assertValidBody(body, res)) return;
2970
+ const setClauses = [];
2971
+ const params = { id: policyId, cid: ctx.cid };
2972
+ if (body.limitUSD !== undefined) { setClauses.push('bp.limitUSD = $limitUSD, bp.limitCents = toInteger($limitUSD * 100)'); params.limitUSD = Number(body.limitUSD); }
2973
+ if (body.period !== undefined) { setClauses.push('bp.period = $period'); params.period = String(body.period); }
2974
+ if (body.agentId !== undefined) { setClauses.push('bp.agentId = $agentId'); params.agentId = String(body.agentId); }
2975
+ if (setClauses.length === 0) { jsonErr(res, 400, 'No fields to update'); return; }
2976
+ const r = await ctx.mgQuery(
2977
+ `MATCH (bp:BudgetPolicy {id: $id, companyId: $cid}) SET ${setClauses.join(', ')}, bp.updatedAt = datetime() RETURN bp.id`,
2978
+ params
2979
+ );
2980
+ if (!(r?.rows?.length)) { jsonErr(res, 404, `Budget policy not found: ${policyId}`); return; }
2981
+ jsonOk(res, { updated: true, policyId });
2982
+ } catch (e) { jsonErr(res, 500, `Update budget policy failed: ${safeErrMsg(e)}`); }
2983
+ }
2984
+
2985
+ async function handleDeleteBudgetPolicy(req, res, ctx, policyId) {
2986
+ try {
2987
+ if (!ctx.mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
2988
+ if (!policyId) { jsonErr(res, 400, 'Missing policy ID'); return; }
2989
+ await ctx.mgQuery(
2990
+ `MATCH (bp:BudgetPolicy {id: $id, companyId: $cid}) DETACH DELETE bp`,
2991
+ { id: policyId, cid: ctx.cid }
2992
+ );
2993
+ jsonOk(res, { deleted: true, policyId });
2994
+ } catch (e) { jsonErr(res, 500, `Delete budget policy failed: ${safeErrMsg(e)}`); }
2995
+ }
2996
+
2997
+ async function handleGetBudgetIncidents(req, res, ctx) {
2998
+ try {
2999
+ if (!ctx.mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
3000
+ const r = await ctx.mgQuery(
3001
+ `MATCH (bi:BudgetIncident {companyId: $cid}) RETURN bi ORDER BY bi.createdAt DESC LIMIT 50`,
3002
+ { cid: ctx.cid }
3003
+ ).catch(() => null);
3004
+ const incidents = (r?.rows ?? []).map(row => {
3005
+ const node = Array.isArray(row) ? row[0] : (row['bi'] || row);
3006
+ const p = node?.properties || node;
3007
+ return { id: p?.id, agentId: p?.agentId, limitUSD: p?.limitUSD, spentUSD: p?.spentUSD,
3008
+ period: p?.period, resolvedAt: p?.resolvedAt ?? null, createdAt: p?.createdAt ?? null };
3009
+ });
3010
+ jsonOk(res, { incidents, count: incidents.length });
3011
+ } catch (e) { jsonErr(res, 500, `Get budget incidents failed: ${safeErrMsg(e)}`); }
3012
+ }
3013
+
2729
3014
  module.exports = function createHBORouter(handlers) {
2730
- const { broadcast, mgQuery: _mgQueryInit } = handlers || {};
3015
+ const { broadcast, mgQuery: _mgQueryInit, triggerGoalDecompose } = handlers || {};
2731
3016
  const _bc = typeof broadcast === 'function' ? broadcast : () => {};
3017
+ // Optional hook: after a BusinessGoal is created, caller can fire-and-forget tickGoalDecompose
3018
+ // so the Harada cascade starts on the next event-loop tick rather than waiting up to 150s.
3019
+ const _triggerGoalDecompose = typeof triggerGoalDecompose === 'function' ? triggerGoalDecompose : null;
2732
3020
  return async function hboRoute(req, res, ctx, pathname, method) {
2733
3021
  // R3-C10: Set module-level request reference so jsonOk/jsonErr can include CORS headers
2734
3022
  // at all 155 call sites without requiring changes to each handler.
@@ -2744,6 +3032,14 @@ module.exports = function createHBORouter(handlers) {
2744
3032
  return false;
2745
3033
  }
2746
3034
 
3035
+ // H-2 fix: Handle OPTIONS preflight for all HBO routes (including DELETE /api/hbo/goals/:id).
3036
+ // Must be BEFORE auth check — browsers never send credentials on preflight requests.
3037
+ if (method === 'OPTIONS') {
3038
+ res.writeHead(204, getCorsHeaders(req));
3039
+ res.end();
3040
+ return true;
3041
+ }
3042
+
2747
3043
  // Auth check for all HBO routes
2748
3044
  const authHeader = (req.headers || {})['authorization'];
2749
3045
  const expected = ctx.apiToken;
@@ -2815,12 +3111,110 @@ module.exports = function createHBORouter(handlers) {
2815
3111
  return true;
2816
3112
  }
2817
3113
 
3114
+ // P8A-03: GET /api/hbo/tasks/:id — full task detail including originKind, approvalId, etc.
3115
+ // Must be matched BEFORE the PATCH /:id pattern below.
3116
+ const taskGetMatch = pathname.match(/^\/api\/hbo\/tasks\/([^/]+)$/);
3117
+ if (method === 'GET' && taskGetMatch) {
3118
+ const taskId = decodeURIComponent(taskGetMatch[1]);
3119
+ const { mgQuery, cid } = ctx;
3120
+ // E2 fix: prefer ?companyId query param so multi-company operator's active company
3121
+ // is used. Fall back to ctx.cid (session default) for single-company deployments.
3122
+ // Note: raw Node.js http.IncomingMessage has no .query property — must use URL.searchParams.
3123
+ const queryCid = new URL(req.url, 'http://localhost').searchParams.get('companyId') || cid;
3124
+ if (!mgQuery) {
3125
+ // SQL parity: fall back to SQLite when Memgraph unavailable
3126
+ try {
3127
+ const hboStore = require('../../lib/hbo-core-store');
3128
+ const t = hboStore.getTask ? hboStore.getTask(taskId, queryCid) : null;
3129
+ if (!t) { jsonErr(res, 404, 'Task not found'); return true; }
3130
+ jsonOk(res, { task: t, _source: 'sqlite' });
3131
+ } catch (storeErr) {
3132
+ jsonErr(res, 503, `Task unavailable: Memgraph not connected`);
3133
+ }
3134
+ return true;
3135
+ }
3136
+ try {
3137
+ // Return full node — includes all properties: originKind, approvalId, pillarId,
3138
+ // executionStateJson, executionPolicyJson, workspacePath, etc.
3139
+ const result = await mgQuery('MATCH (t:Task {id: $id, companyId: $cid}) RETURN t', { id: taskId, cid: queryCid });
3140
+ if (result.rows && result.rows.length > 0) {
3141
+ const t = result.rows[0][0].properties || result.rows[0][0];
3142
+ jsonOk(res, { task: t });
3143
+ } else {
3144
+ jsonErr(res, 404, 'Task not found');
3145
+ }
3146
+ } catch (e) {
3147
+ jsonErr(res, 500, `Task query failed: ${safeErrMsg(e)}`);
3148
+ }
3149
+ return true;
3150
+ }
3151
+
2818
3152
  // POST /api/hbo/tasks
2819
3153
  if (method === 'POST' && pathname === '/api/hbo/tasks') {
2820
3154
  await handleCreateBusinessTask(req, res, ctx);
2821
3155
  return true;
2822
3156
  }
2823
3157
 
3158
+ // POST /api/hbo/tasks/:id/interpretation/refresh — P_EXPLAIN-04
3159
+ const taskInterpRefreshMatch = pathname.match(/^\/api\/hbo\/tasks\/([^/]+)\/interpretation\/refresh$/);
3160
+ if (method === 'POST' && taskInterpRefreshMatch) {
3161
+ const taskId = decodeURIComponent(taskInterpRefreshMatch[1]);
3162
+ const { mgQuery } = ctx;
3163
+ if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return true; }
3164
+ const cid = ctx.cid;
3165
+ try {
3166
+ const { createInterpretationExplanation } = require('../lib/interpretation-engine.js');
3167
+ const { rawWrite: _interpRw } = require('../lib/safe-memgraph.js');
3168
+ await createInterpretationExplanation(taskId, cid, _interpRw);
3169
+ jsonResponse(res, 200, { ok: true, taskId });
3170
+ } catch (err) {
3171
+ jsonResponse(res, 500, { error: `Interpretation refresh failed: ${err.message}` });
3172
+ }
3173
+ return true;
3174
+ }
3175
+
3176
+ // E-02: GET /api/hbo/tasks/:taskId/enrichment — hill chart position + recent PDSA cycles
3177
+ // Returns hillChart: { id, position, phase, updatedAt } | null
3178
+ // pdsaCycles: [...] (up to 5 most recent, ordered by createdAt DESC)
3179
+ const taskEnrichmentMatch = pathname.match(/^\/api\/hbo\/tasks\/([^/]+)\/enrichment$/);
3180
+ if (method === 'GET' && taskEnrichmentMatch) {
3181
+ const taskId = decodeURIComponent(taskEnrichmentMatch[1]);
3182
+ const { mgQuery, cid } = ctx;
3183
+ const queryCid = new URL(req.url, 'http://localhost').searchParams.get('companyId') || cid;
3184
+ if (!mgQuery) {
3185
+ jsonResponse(res, 503, { error: 'Memgraph not connected' });
3186
+ return true;
3187
+ }
3188
+ try {
3189
+ const [hillResult, pdsaResult] = await Promise.all([
3190
+ mgQuery(
3191
+ `MATCH (t:Task {id: $taskId, companyId: $cid})
3192
+ OPTIONAL MATCH (t)-[:HAS_HILL_CHART]->(h:HillChart)
3193
+ RETURN h`,
3194
+ { taskId, cid: queryCid }
3195
+ ),
3196
+ mgQuery(
3197
+ `MATCH (t:Task {id: $taskId, companyId: $cid})
3198
+ OPTIONAL MATCH (t)-[:HAS_PDSA]->(p:PDSACycle)
3199
+ RETURN p ORDER BY p.createdAt DESC LIMIT 5`,
3200
+ { taskId, cid: queryCid }
3201
+ ),
3202
+ ]);
3203
+ const hRow = hillResult.rows && hillResult.rows[0] ? hillResult.rows[0][0] : null;
3204
+ const hillChart = hRow
3205
+ ? (hRow.properties ?? hRow)
3206
+ : null;
3207
+ const pdsaCycles = (pdsaResult.rows || [])
3208
+ .map((r) => r[0])
3209
+ .filter(Boolean)
3210
+ .map((p) => p.properties ?? p);
3211
+ jsonOk(res, { hillChart, pdsaCycles });
3212
+ } catch (e) {
3213
+ jsonErr(res, 500, `Enrichment query failed: ${safeErrMsg(e)}`);
3214
+ }
3215
+ return true;
3216
+ }
3217
+
2824
3218
  // PATCH /api/hbo/tasks/:id
2825
3219
  const taskPatchMatch = pathname.match(/^\/api\/hbo\/tasks\/(.+)$/);
2826
3220
  if (method === 'PATCH' && taskPatchMatch) {
@@ -2834,6 +3228,30 @@ module.exports = function createHBORouter(handlers) {
2834
3228
  return true;
2835
3229
  }
2836
3230
 
3231
+ // GET /api/hbo/goals/:id — must be checked before POST/PATCH/DELETE goalMatch
3232
+ const goalIdOnlyMatch = pathname.match(/^\/api\/hbo\/goals\/([^/]+)$/);
3233
+ if (method === 'GET' && goalIdOnlyMatch) {
3234
+ await handleGetBusinessGoal(req, res, ctx, decodeURIComponent(goalIdOnlyMatch[1]));
3235
+ return true;
3236
+ }
3237
+
3238
+ // POST /api/hbo/goals
3239
+ if (method === 'POST' && pathname === '/api/hbo/goals') {
3240
+ await handleCreateBusinessGoal(req, res, ctx);
3241
+ return true;
3242
+ }
3243
+
3244
+ // PATCH/DELETE /api/hbo/goals/:id
3245
+ const goalMatch = pathname.match(/^\/api\/hbo\/goals\/(.+)$/);
3246
+ if (method === 'PATCH' && goalMatch) {
3247
+ await handleUpdateBusinessGoal(req, res, ctx, decodeURIComponent(goalMatch[1]));
3248
+ return true;
3249
+ }
3250
+ if (method === 'DELETE' && goalMatch) {
3251
+ await handleDeleteBusinessGoal(req, res, ctx, decodeURIComponent(goalMatch[1]));
3252
+ return true;
3253
+ }
3254
+
2837
3255
  // GET /api/hbo/triage-summary
2838
3256
  if (method === 'GET' && pathname === '/api/hbo/triage-summary') {
2839
3257
  await handleGetTriageSummary(req, res, ctx);
@@ -2896,6 +3314,127 @@ module.exports = function createHBORouter(handlers) {
2896
3314
  return true;
2897
3315
  }
2898
3316
 
3317
+ // ── T3-01: POST /api/hbo/pillar/:id/l2content — L2 research agent submits strategy ──────
3318
+ const pillarL2ContentMatch = pathname.match(/^\/api\/hbo\/pillar\/([^/]+)\/l2content$/);
3319
+ if (method === 'POST' && pillarL2ContentMatch) {
3320
+ const pillarId = decodeURIComponent(pillarL2ContentMatch[1]);
3321
+ const cid = ctx.cid;
3322
+ const mgQuery = ctx.mgQuery;
3323
+ if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return true; }
3324
+ try {
3325
+ const body = await readBody(req);
3326
+ if (!assertValidBody(body, res)) return true;
3327
+ const { l2Strategy, l2Content } = body || {};
3328
+ if (!l2Strategy && !l2Content) { jsonErr(res, 400, 'l2Strategy or l2Content required'); return true; }
3329
+ const r = await mgQuery(
3330
+ `MATCH (gp:GoalPillar {id: $pillarId, companyId: $cid})
3331
+ SET gp.l2Strategy = COALESCE($l2Strategy, gp.l2Strategy),
3332
+ gp.l2Content = COALESCE($l2Content, gp.l2Content),
3333
+ gp.l2ReviewStatus = 'pending_review',
3334
+ gp.updatedAt = datetime()
3335
+ RETURN gp.id AS id`,
3336
+ { pillarId, cid, l2Strategy: l2Strategy ? String(l2Strategy) : null, l2Content: l2Content ? String(l2Content) : null }
3337
+ );
3338
+ if (!parseRows(r).length) { jsonErr(res, 404, `GoalPillar ${pillarId} not found`); return true; }
3339
+ jsonOk(res, { ok: true, pillarId, l2ReviewStatus: 'pending_review' });
3340
+ } catch (e) { jsonErr(res, 500, `l2content submit failed: ${safeErrMsg(e)}`); }
3341
+ return true;
3342
+ }
3343
+
3344
+ // ── T3-02: POST /api/hbo/pillar/:id/l2review — CEO agent reviews L2 content ────────────
3345
+ const pillarL2ReviewMatch = pathname.match(/^\/api\/hbo\/pillar\/([^/]+)\/l2review$/);
3346
+ if (method === 'POST' && pillarL2ReviewMatch) {
3347
+ const pillarId = decodeURIComponent(pillarL2ReviewMatch[1]);
3348
+ const cid = ctx.cid;
3349
+ const mgQuery = ctx.mgQuery;
3350
+ if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return true; }
3351
+ try {
3352
+ const body = await readBody(req);
3353
+ if (!assertValidBody(body, res)) return true;
3354
+ const { verdict, reviewCritique, goalId, agentId } = body || {};
3355
+ if (!verdict || !['pass', 'fail'].includes(verdict)) { jsonErr(res, 400, 'verdict must be "pass" or "fail"'); return true; }
3356
+ if (verdict === 'pass') {
3357
+ await mgQuery(
3358
+ `MATCH (gp:GoalPillar {id: $pillarId, companyId: $cid})
3359
+ SET gp.l2ReviewStatus = 'approved', gp.l2ReviewedAt = datetime()
3360
+ RETURN gp.id`,
3361
+ { pillarId, cid }
3362
+ );
3363
+ jsonOk(res, { ok: true, pillarId, verdict: 'pass' });
3364
+ } else {
3365
+ // FAIL: increment cycle count, re-dispatch L2 research with critique
3366
+ const cycleResult = await mgQuery(
3367
+ `MATCH (gp:GoalPillar {id: $pillarId, companyId: $cid})
3368
+ SET gp.l2ReviewStatus = 'revision_needed',
3369
+ gp.l2ReviewCycles = COALESCE(toInteger(gp.l2ReviewCycles), 0) + 1
3370
+ RETURN gp.l2ReviewCycles AS cycles, gp.goalId AS goalId`,
3371
+ { pillarId, cid }
3372
+ );
3373
+ const cycleRow = parseRows(cycleResult)[0];
3374
+ const resolvedGoalId = goalId || (cycleRow && (cycleRow['cycles'] !== undefined ? null : cycleRow['goalId'])) || null;
3375
+ const cycles = cycleRow ? Number(cycleRow['cycles'] ?? 1) : 1;
3376
+ if (cycles < 3 && resolvedGoalId && agentId) {
3377
+ try {
3378
+ const { CascadeResearchDispatcher } = require('../lib/harada/cascade-research-dispatcher');
3379
+ const crd = new CascadeResearchDispatcher(mgQuery, cid);
3380
+ await crd.dispatchL2Research(pillarId, resolvedGoalId, cid, agentId, reviewCritique || '');
3381
+ } catch (dispErr) {
3382
+ process.stderr.write(`[hbo] l2review: re-dispatch failed: ${safeErrMsg(dispErr)}\n`);
3383
+ }
3384
+ }
3385
+ jsonOk(res, { ok: true, pillarId, verdict: 'fail', cycles });
3386
+ }
3387
+ } catch (e) { jsonErr(res, 500, `l2review failed: ${safeErrMsg(e)}`); }
3388
+ return true;
3389
+ }
3390
+
3391
+ // ── T3-03: POST /api/hbo/action-cell/:id/l3content — L3 research agent submits ──────────
3392
+ const cellL3ContentMatch = pathname.match(/^\/api\/hbo\/action-cell\/([^/]+)\/l3content$/);
3393
+ if (method === 'POST' && cellL3ContentMatch) {
3394
+ const cellId = decodeURIComponent(cellL3ContentMatch[1]);
3395
+ const cid = ctx.cid;
3396
+ const mgQuery = ctx.mgQuery;
3397
+ if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return true; }
3398
+ try {
3399
+ const body = await readBody(req);
3400
+ if (!assertValidBody(body, res)) return true;
3401
+ const { l3Plan, l3Content } = body || {};
3402
+ if (!l3Plan && !l3Content) { jsonErr(res, 400, 'l3Plan or l3Content required'); return true; }
3403
+ const r = await mgQuery(
3404
+ `MATCH (ac:ActionCell {id: $cellId, companyId: $cid})
3405
+ SET ac.l3Plan = COALESCE($l3Plan, ac.l3Plan),
3406
+ ac.l3Content = COALESCE($l3Content, ac.l3Content),
3407
+ ac.l3ReviewStatus = 'pending_review',
3408
+ ac.updatedAt = datetime()
3409
+ RETURN ac.id AS id`,
3410
+ { cellId, cid, l3Plan: l3Plan ? String(l3Plan) : null, l3Content: l3Content ? String(l3Content) : null }
3411
+ );
3412
+ if (!parseRows(r).length) { jsonErr(res, 404, `ActionCell ${cellId} not found`); return true; }
3413
+ jsonOk(res, { ok: true, cellId, l3ReviewStatus: 'pending_review' });
3414
+ } catch (e) { jsonErr(res, 500, `l3content submit failed: ${safeErrMsg(e)}`); }
3415
+ return true;
3416
+ }
3417
+
3418
+ // ── T3-03b: POST /api/hbo/action-cell/:id/l3review — dept head reviews L3 content ───────
3419
+ const cellL3ReviewMatch = pathname.match(/^\/api\/hbo\/action-cell\/([^/]+)\/l3review$/);
3420
+ if (method === 'POST' && cellL3ReviewMatch) {
3421
+ const cellId = decodeURIComponent(cellL3ReviewMatch[1]);
3422
+ const cid = ctx.cid;
3423
+ const mgQuery = ctx.mgQuery;
3424
+ if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return true; }
3425
+ try {
3426
+ const body = await readBody(req);
3427
+ if (!assertValidBody(body, res)) return true;
3428
+ const { verdict, reviewCritique } = body || {};
3429
+ if (!verdict || !['pass', 'fail'].includes(verdict)) { jsonErr(res, 400, 'verdict must be "pass" or "fail"'); return true; }
3430
+ const { CascadeJudge } = require('../lib/harada/cascade-judge');
3431
+ const judge = new CascadeJudge(mgQuery, cid);
3432
+ const result = await judge.judgeL3(cellId);
3433
+ jsonOk(res, { ok: true, cellId, verdict: result.verdict, critique: result.critique });
3434
+ } catch (e) { jsonErr(res, 500, `l3review failed: ${safeErrMsg(e)}`); }
3435
+ return true;
3436
+ }
3437
+
2899
3438
  // GET /api/hbo/metrics — ProcessMetrics and ControlChartSignals
2900
3439
  if (method === 'GET' && pathname === '/api/hbo/metrics') {
2901
3440
  await handleGetMetrics(req, res, ctx);
@@ -3053,11 +3592,17 @@ module.exports = function createHBORouter(handlers) {
3053
3592
  return true;
3054
3593
  }
3055
3594
 
3056
- // GET /api/hbo/command-centeraggregated snapshot for dashboard
3057
- if (method === 'GET' && pathname === '/api/hbo/command-center') {
3058
- await handleGetCommandCenter(req, res, ctx);
3059
- return true;
3060
- }
3595
+ // GET /api/hbo/blocked-workP9A: tasks stopped due to a blocker
3596
+ if (method === 'GET' && pathname === '/api/hbo/blocked-work') {
3597
+ await handleGetBlockedWork(req, res, ctx);
3598
+ return true;
3599
+ }
3600
+
3601
+ // GET /api/hbo/command-center — aggregated snapshot for dashboard
3602
+ if (method === 'GET' && pathname === '/api/hbo/command-center') {
3603
+ await handleGetCommandCenter(req, res, ctx);
3604
+ return true;
3605
+ }
3061
3606
 
3062
3607
  // GET /api/hbo/decisions/timeline — unified historical decisions timeline
3063
3608
  if (method === 'GET' && pathname === '/api/hbo/decisions/timeline') {
@@ -3065,6 +3610,21 @@ module.exports = function createHBORouter(handlers) {
3065
3610
  return true;
3066
3611
  }
3067
3612
 
3068
- return false;
3069
- };
3070
- };
3613
+ // G-01: Budget Policy CRUD
3614
+ if (method === 'POST' && pathname === '/api/budget-policies') {
3615
+ await handleCreateBudgetPolicy(req, res, ctx);
3616
+ return true;
3617
+ }
3618
+ const budgetPolicyMatch = pathname.match(/^\/api\/budget-policies\/([^/]+)$/);
3619
+ if (budgetPolicyMatch) {
3620
+ if (method === 'PATCH') { await handleUpdateBudgetPolicy(req, res, ctx, budgetPolicyMatch[1]); return true; }
3621
+ if (method === 'DELETE') { await handleDeleteBudgetPolicy(req, res, ctx, budgetPolicyMatch[1]); return true; }
3622
+ }
3623
+ if (method === 'GET' && pathname === '/api/budget-incidents') {
3624
+ await handleGetBudgetIncidents(req, res, ctx);
3625
+ return true;
3626
+ }
3627
+
3628
+ return false;
3629
+ };
3630
+ };