@cgh567/agent 2.4.3 → 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 (140) 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/context-enrichment.js +27 -0
  11. package/daemon/helios-api.js +290 -45
  12. package/daemon/helios-company-daemon.js +160 -50
  13. package/daemon/lib/blast-radius-analyzer.js +75 -0
  14. package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
  15. package/daemon/lib/forensic-log.js +113 -0
  16. package/daemon/lib/goal-research-pipeline.js +644 -0
  17. package/daemon/lib/harada/cascade-judge.js +84 -1
  18. package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
  19. package/daemon/lib/harada/pillar-dispatcher.js +23 -2
  20. package/daemon/lib/hbo-bridge.js +73 -5
  21. package/daemon/lib/headroom-middleware.js +129 -0
  22. package/daemon/lib/headroom-proxy-manager.js +309 -0
  23. package/daemon/lib/intelligence/department-page-generator.js +46 -1
  24. package/daemon/lib/interpretation-engine.js +92 -0
  25. package/daemon/lib/mental-model-cache.js +96 -0
  26. package/daemon/lib/project-factory.js +47 -0
  27. package/daemon/lib/session-log-reader.js +93 -0
  28. package/daemon/lib/standard-work-bootstrap.js +87 -1
  29. package/daemon/lib/task-completion-processor.js +12 -0
  30. package/daemon/package.json +2 -1
  31. package/daemon/routes/agents.js +51 -6
  32. package/daemon/routes/channels.js +116 -2
  33. package/daemon/routes/crm.js +85 -0
  34. package/daemon/routes/dashboard.js +62 -16
  35. package/daemon/routes/dept.js +10 -1
  36. package/daemon/routes/email-triage.js +19 -10
  37. package/daemon/routes/hbo.js +367 -13
  38. package/daemon/routes/hed.js +133 -0
  39. package/daemon/routes/inbox.js +397 -8
  40. package/daemon/routes/project.js +392 -9
  41. package/daemon/schema-definitions.js +10 -0
  42. package/daemon/schema-migrations-hbo.js +10 -0
  43. package/daemon/schema-migrations-proj.js +22 -0
  44. package/extensions/__tests__/codebase-index.test.ts +73 -0
  45. package/extensions/__tests__/extension-command-registration.test.ts +35 -0
  46. package/extensions/__tests__/git-push-guard.test.ts +68 -0
  47. package/extensions/context-compaction.ts +104 -76
  48. package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
  49. package/extensions/email/actions/draft-response.ts +21 -1
  50. package/extensions/email/auth/accounts.ts +5 -11
  51. package/extensions/email/auth/inbox-dog.ts +5 -2
  52. package/extensions/email/backfill.ts +20 -13
  53. package/extensions/email/providers/gmail.ts +164 -0
  54. package/extensions/email/providers/google-calendar.ts +34 -5
  55. package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
  56. package/extensions/helios-browser/backends/playwright.ts +3 -1
  57. package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
  58. package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
  59. package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
  60. package/extensions/hema-dispatch-v3/index.ts +33 -65
  61. package/extensions/interview/__tests__/server.test.ts +117 -0
  62. package/extensions/lib/helios-root.cjs +46 -0
  63. package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
  64. package/extensions/warm-tick/warm-tick-maintenance.ts +156 -0
  65. package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
  66. package/lib/__tests__/crash-fixes.test.ts +49 -0
  67. package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
  68. package/lib/broker/__tests__/jit-subscription.test.js +44 -1
  69. package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
  70. package/lib/compression/__tests__/ccr-store.test.js +138 -0
  71. package/lib/compression/__tests__/pipeline.test.js +280 -0
  72. package/lib/compression/__tests__/smart-crusher.test.js +242 -0
  73. package/lib/compression/dist/server.js +34 -1
  74. package/lib/compression/dist/start-server.js +77 -0
  75. package/lib/graph/learning/headroom-learn-bridge.js +175 -0
  76. package/lib/hbo-core-store.ts +71 -0
  77. package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
  78. package/lib/skill-sync.js +6 -1
  79. package/lib/startup-integrity.js +9 -2
  80. package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
  81. package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
  82. package/lib/triage-core/__tests__/classifier.test.ts +45 -7
  83. package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
  84. package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
  85. package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
  86. package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
  87. package/lib/triage-core/__tests__/signals.test.ts +357 -0
  88. package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
  89. package/lib/triage-core/backfill-cost-estimator.ts +91 -0
  90. package/lib/triage-core/backfill-orchestrator.ts +119 -0
  91. package/lib/triage-core/classifier.ts +38 -6
  92. package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
  93. package/lib/triage-core/cos/response-debt.ts +2 -2
  94. package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
  95. package/lib/triage-core/graph/batch-persistence.ts +66 -2
  96. package/lib/triage-core/graph/betweenness-worker.js +75 -0
  97. package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
  98. package/lib/triage-core/graph/persistence.ts +1 -1
  99. package/lib/triage-core/graph/schema-v2.ts +2 -0
  100. package/lib/triage-core/graph/schema.cypher +1 -0
  101. package/lib/triage-core/graph/triage-query.ts +1 -1
  102. package/lib/triage-core/learning.ts +15 -20
  103. package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
  104. package/lib/triage-core/mental-model/cos-integration.ts +1 -1
  105. package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
  106. package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
  107. package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
  108. package/lib/triage-core/mental-model/model-assembler.ts +16 -3
  109. package/lib/triage-core/orchestrator.ts +4 -4
  110. package/lib/triage-core/scheduled-sends.ts +39 -2
  111. package/lib/triage-core/signals/comms-style.ts +1 -1
  112. package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
  113. package/lib/triage-core/signals/favee-type.ts +6 -1
  114. package/lib/triage-core/signals/goal-relevance.ts +31 -2
  115. package/lib/triage-core/signals/personal-importance.ts +1 -1
  116. package/lib/triage-core/signals/referral-chain.ts +0 -1
  117. package/lib/triage-core/signals/relationship-decay.ts +4 -0
  118. package/lib/triage-core/signals/relationship-health.ts +6 -1
  119. package/lib/triage-core/signals/trajectory-signal.ts +38 -3
  120. package/lib/triage-core/tournament-runner.js +11 -1
  121. package/lib/triage-core/triage-llm-factory.ts +110 -0
  122. package/lib/triage-core/triage-local-llm.ts +145 -0
  123. package/lib/triage-core/triage-sql-store.ts +337 -0
  124. package/lib/triage-core/types.ts +2 -2
  125. package/lib/unified-graph.atomic.test.ts +52 -0
  126. package/lib/unified-graph.failure-categories.test.ts +55 -0
  127. package/package.json +10 -3
  128. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  129. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  130. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  131. package/skills/helios-bookkeeping/SKILL.md +321 -0
  132. package/skills/helios-briefer/SKILL.md +44 -0
  133. package/skills/helios-client-relations/SKILL.md +322 -0
  134. package/skills/helios-personal-triager/SKILL.md +45 -0
  135. package/skills/helios-recruitment/SKILL.md +317 -0
  136. package/skills/helios-relationship-nudger/SKILL.md +77 -0
  137. package/skills/helios-researcher/SKILL.md +44 -0
  138. package/skills/helios-scheduler/SKILL.md +58 -0
  139. package/skills/helios-tax-analyst/SKILL.md +280 -0
  140. package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
@@ -383,7 +383,9 @@ async function handleCreateTask(req, res, ctx) {
383
383
  const cid = ctx.cid;
384
384
 
385
385
  const body = await parseBody(req);
386
- const { title, assigneeAgentId, priority, body: taskBody, executionPolicy } = body;
386
+ const { title, assigneeAgentId, priority, body: taskBody, executionPolicy,
387
+ // Phase 2.5-B additions: sourceType, sourceId, originKind, projectId for email-to-task
388
+ sourceType, sourceId, originKind, projectId } = body;
387
389
 
388
390
  if (!title || typeof title !== 'string' || title.trim() === '') {
389
391
  jsonResponse(res, 400, { error: 'title is required and must be a non-empty string' });
@@ -450,6 +452,10 @@ async function handleCreateTask(req, res, ctx) {
450
452
  body: $body,
451
453
  executionPolicyJson: $policyJson,
452
454
  executionStateJson: $stateJson,
455
+ sourceType: $sourceType,
456
+ sourceId: $sourceId,
457
+ originKind: $originKind,
458
+ projectId: $projectId,
453
459
  createdAt: datetime()
454
460
  })`,
455
461
  {
@@ -461,9 +467,27 @@ async function handleCreateTask(req, res, ctx) {
461
467
  body: taskBody ? String(taskBody) : null,
462
468
  policyJson: validatedPolicyJson,
463
469
  stateJson: initialStateJson,
470
+ sourceType: sourceType ? String(sourceType) : null,
471
+ sourceId: sourceId ? String(sourceId) : null,
472
+ originKind: originKind ? String(originKind) : null,
473
+ projectId: projectId ? String(projectId) : null,
464
474
  }
465
475
  );
466
476
 
477
+ // Phase 2.5-B: OI-P3 fix — write BELONGS_TO_PROJECT edge when projectId is provided
478
+ if (projectId) {
479
+ try {
480
+ await mgQuery(
481
+ `MATCH (t:Task {id: $taskId}), (p:HeliosProject {id: $projectId, companyId: $cid})
482
+ MERGE (t)-[:BELONGS_TO_PROJECT]->(p)`,
483
+ { taskId, projectId: String(projectId), cid }
484
+ );
485
+ } catch (_edgeErr) {
486
+ // Edge write is best-effort — task is already created; log but don't fail
487
+ log('warn', 'BELONGS_TO_PROJECT edge write failed (OI-P3)', { taskId, projectId });
488
+ }
489
+ }
490
+
467
491
  mirrorHboStore('task.create', () => hboStore.createTask?.({
468
492
  id: taskId,
469
493
  companyId: cid,
@@ -479,6 +503,17 @@ async function handleCreateTask(req, res, ctx) {
479
503
 
480
504
  broadcast({ type: 'task.created', taskId, status: 'todo', companyId: cid });
481
505
  jsonResponse(res, 201, { task: { id: taskId, title, status: 'todo', priority: resolvedPriority, assigneeAgentId, executionPolicyJson: validatedPolicyJson } });
506
+
507
+ // P_EXPLAIN-01: fire-and-forget interpretation explanation background job
508
+ const { rawWrite: _interpRawWrite } = require('../lib/safe-memgraph.js');
509
+ setImmediate(() => {
510
+ try {
511
+ const { createInterpretationExplanation } = require('./lib/interpretation-engine.js');
512
+ createInterpretationExplanation(taskId, cid, _interpRawWrite).catch(e =>
513
+ log('warn', 'P_EXPLAIN-01 failed', { e: e.message })
514
+ );
515
+ } catch (_) { /* interpretation is non-blocking */ }
516
+ });
482
517
  } catch (err) {
483
518
  jsonResponse(res, 500, { error: `Create failed: ${err.message}` });
484
519
  }
@@ -491,7 +526,9 @@ async function handleUpdateTask(req, res, ctx, taskId) {
491
526
  const cid = ctx.cid;
492
527
 
493
528
  const body = await parseBody(req);
494
- const { status, priority } = body;
529
+ // P6-06a: workspacePath added desktop sets this after acquiring a SessionWorktree
530
+ // for isolated execution; daemon reads it into adapterContext.workspacePath
531
+ const { status, priority, workspacePath, blockedReason, clearBlocked, hillPosition } = body;
495
532
 
496
533
  // P4-01: 'in_review' added — allows execution policy gate to hold a task for review
497
534
  const VALID_STATUSES = new Set(['todo', 'in_progress', 'done', 'failed', 'cancelled', 'blocked', 'escalated', 'in_review']);
@@ -504,8 +541,8 @@ async function handleUpdateTask(req, res, ctx, taskId) {
504
541
  return;
505
542
  }
506
543
 
507
- if (status === undefined && priority === undefined) {
508
- jsonResponse(res, 400, { error: 'Provide at least one field to update: status, priority' });
544
+ if (status === undefined && priority === undefined && workspacePath === undefined && blockedReason === undefined && clearBlocked === undefined && hillPosition === undefined) {
545
+ jsonResponse(res, 400, { error: 'Provide at least one field to update: status, priority, workspacePath, blockedReason, clearBlocked, hillPosition' });
509
546
  return;
510
547
  }
511
548
 
@@ -767,6 +804,17 @@ async function handleUpdateTask(req, res, ctx, taskId) {
767
804
  const params = { id: taskId, cid };
768
805
  if (status !== undefined) { setClauses.push('t.status = $status'); params.status = status; }
769
806
  if (priority !== undefined) { setClauses.push('t.priority = toInteger($priority)'); params.priority = Number(priority); }
807
+ // P6-06a: persist workspacePath on Task node so daemon can read it into adapterContext
808
+ if (workspacePath !== undefined) { setClauses.push('t.workspacePath = $workspacePath'); params.workspacePath = workspacePath ? String(workspacePath) : null; }
809
+ // P1-G: blockedReason + blockedAt — clearBlocked=true nulls both fields and resets status to todo.
810
+ // COALESCE preserves existing reason if not re-set; blockedAt only set once on initial block.
811
+ if (blockedReason !== undefined || clearBlocked !== undefined) {
812
+ params.clearBlocked = clearBlocked === true;
813
+ params.blockedReason = (blockedReason !== undefined && blockedReason !== null) ? String(blockedReason) : null;
814
+ setClauses.push('t.blockedReason = CASE WHEN $clearBlocked = true THEN null ELSE COALESCE($blockedReason, t.blockedReason) END');
815
+ setClauses.push('t.blockedAt = CASE WHEN $clearBlocked = true THEN null WHEN $blockedReason IS NOT NULL AND t.blockedAt IS NULL THEN datetime() ELSE t.blockedAt END');
816
+ setClauses.push('t.status = CASE WHEN $clearBlocked = true AND t.status = \'blocked\' THEN \'todo\' ELSE t.status END');
817
+ }
770
818
  setClauses.push('t.updatedAt = datetime()');
771
819
 
772
820
  // B-09: Single atomic check+update — no TOCTOU window between existence check and SET
@@ -779,9 +827,25 @@ async function handleUpdateTask(req, res, ctx, taskId) {
779
827
  return;
780
828
  }
781
829
 
830
+ // T4-02: HillChart write — track task work-cycle position for hill chart visualisation
831
+ if (hillPosition !== undefined && Number.isFinite(Number(hillPosition))) {
832
+ const hPos = Math.max(0, Math.min(1, Number(hillPosition)));
833
+ const hPhase = hPos < 0.5 ? 'uphill' : hPos < 1.0 ? 'downhill' : 'complete';
834
+ mgQuery(
835
+ `MERGE (h:HillChart {id: 'hill:' + $taskId})
836
+ SET h.taskId = $taskId, h.companyId = $cid,
837
+ h.position = $pos, h.phase = $phase, h.updatedAt = datetime()
838
+ WITH h
839
+ MATCH (t:Task {id: $taskId, companyId: $cid})
840
+ MERGE (t)-[:HAS_HILL_CHART]->(h)`,
841
+ { taskId, cid, pos: hPos, phase: hPhase }
842
+ ).catch(e => log('warn', `HillChart MERGE failed for task ${taskId}: ${e.message}`));
843
+ }
844
+
782
845
  const storeUpdate = {};
783
846
  if (status !== undefined) storeUpdate.status = status;
784
847
  if (priority !== undefined) storeUpdate.priority = Number(priority);
848
+ if (workspacePath !== undefined) storeUpdate.workspacePath = workspacePath ? String(workspacePath) : null;
785
849
  mirrorHboStore('task.update', () => hboStore.updateTask?.(taskId, cid, storeUpdate));
786
850
 
787
851
  broadcast({ type: 'task.updated', taskId, ...(status !== undefined ? { status } : {}), companyId: cid });
@@ -907,6 +971,7 @@ async function handleGetApprovals(req, res, ctx) {
907
971
  a.status AS status, a.requestedBy AS requestedBy,
908
972
  a.agentId AS agentId, a.taskId AS taskId,
909
973
  a.createdAt AS createdAt, a.followUpTaskCreated AS followUpTaskCreated,
974
+ a.followUpTaskId AS followUpTaskId,
910
975
  a.expiresAt AS expiresAt,
911
976
  a.kaizenProposalId AS kaizenProposalId,
912
977
  a.description AS description,
@@ -954,7 +1019,7 @@ async function handleGetApprovals(req, res, ctx) {
954
1019
  const rows = parseRows(result);
955
1020
  const keys = [
956
1021
  'id', 'type', 'title', 'status', 'requestedBy', 'agentId', 'taskId',
957
- 'createdAt', 'followUpTaskCreated', 'expiresAt', 'kaizenProposalId',
1022
+ 'createdAt', 'followUpTaskCreated', 'followUpTaskId', 'expiresAt', 'kaizenProposalId',
958
1023
  'description', 'payload', 'body', 'question', 'pillarId',
959
1024
  'sourceAgent', 'resolvedAt', 'department', 'templateKey',
960
1025
  'inferredAnswer', 'defaultAnswer',
@@ -1089,6 +1154,8 @@ async function handleApproveApproval(req, res, ctx, approvalId) {
1089
1154
  }
1090
1155
 
1091
1156
  broadcast({ type: 'approval.approved', approvalId, companyId: cid });
1157
+ // H-01: record approval.approve in ActivityLogger
1158
+ ctx.activityLogger?.record({ action: 'approval.approve', actor: cid, entityId: approvalId, companyId: cid });
1092
1159
  jsonResponse(res, 200, { approved: true, approvalId });
1093
1160
  } catch (err) {
1094
1161
  jsonResponse(res, 500, { error: `Approve failed: ${err.message}` });
@@ -1118,6 +1185,8 @@ async function handleRejectApproval(req, res, ctx, approvalId) {
1118
1185
  }
1119
1186
  mirrorHboStore('approval.reject', () => hboStore.updateApproval?.(approvalId, cid, { status: 'rejected', rejectReason: reason, rejectedAt: Date.now() }));
1120
1187
  broadcast({ type: 'approval.rejected', approvalId, reason, companyId: cid });
1188
+ // H-01: record approval.reject in ActivityLogger
1189
+ ctx.activityLogger?.record({ action: 'approval.reject', actor: cid, entityId: approvalId, companyId: cid });
1121
1190
  jsonResponse(res, 200, { rejected: true, approvalId, reason });
1122
1191
  } catch (err) {
1123
1192
  jsonResponse(res, 500, { error: `Reject failed: ${err.message}` });
@@ -1396,16 +1465,39 @@ async function handleGetAgents(req, res, ctx) {
1396
1465
 
1397
1466
  async function handleGetAgent(req, res, ctx, agentId) {
1398
1467
  const { mgQuery } = ctx;
1399
- if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
1400
1468
  if (!agentId) { jsonResponse(res, 400, { error: 'Missing agent ID' }); return; }
1401
1469
  const cid = ctx.cid;
1402
1470
 
1471
+ // P7-A1: SQL parity — fall back to SQLite when Memgraph unavailable
1472
+ if (!mgQuery) {
1473
+ try {
1474
+ const a = hboStore.getBusinessAgent ? hboStore.getBusinessAgent(agentId, cid) : null;
1475
+ if (!a) { jsonResponse(res, 404, { error: `Agent not found: ${agentId}` }); return; }
1476
+ // H-1 fix: also include recentTasks from SQLite so shape matches Memgraph path
1477
+ const sqliteTasks = hboStore.getTasksByCompanyStatus
1478
+ ? hboStore.getTasksByCompanyStatus(cid, ['todo', 'in_progress', 'done']).filter(t => t.assigneeAgentId === agentId || t.assignee_agent_id === agentId).slice(0, 10)
1479
+ : [];
1480
+ a.recentTasks = sqliteTasks.map(t => ({ id: t.id, title: t.title ?? null, status: t.status ?? null, priority: t.priority ?? null }));
1481
+ return jsonResponse(res, 200, { agent: a, _source: 'sqlite' });
1482
+ } catch (storeErr) {
1483
+ return jsonResponse(res, 503, { error: 'Agent unavailable: Memgraph not connected', agent: null });
1484
+ }
1485
+ }
1486
+
1403
1487
  try {
1404
1488
  const [agentResult, tasksResult] = await Promise.all([
1405
1489
  mgQuery(
1490
+ // P7-A1: extended RETURN to include all required fields for agent detail page
1406
1491
  `MATCH (a:BusinessAgent {id: $id, companyId: $cid})
1407
- RETURN a.id AS id, a.title AS title, a.status AS status,
1408
- a.lastHeartbeatAt AS lastHeartbeat, a.pauseReason AS pauseReason`,
1492
+ RETURN a.id AS id, a.title AS title, a.role AS role, a.status AS status,
1493
+ a.skill AS skill, a.adapter AS adapter,
1494
+ a.budgetMonthlyCents AS budgetMonthlyCents,
1495
+ a.heartbeatIntervalMs AS heartbeatIntervalMs,
1496
+ a.capabilities AS capabilities,
1497
+ a.defaultSkills AS defaultSkills,
1498
+ a.lastHeartbeatAt AS lastHeartbeatAt,
1499
+ a.pauseReason AS pauseReason,
1500
+ a.companyId AS companyId`,
1409
1501
  { id: agentId, cid }
1410
1502
  ),
1411
1503
  mgQuery(
@@ -1422,7 +1514,9 @@ async function handleGetAgent(req, res, ctx, agentId) {
1422
1514
  return;
1423
1515
  }
1424
1516
 
1425
- const agentKeys = ['id', 'title', 'status', 'lastHeartbeat', 'pauseReason'];
1517
+ const agentKeys = ['id', 'title', 'role', 'status', 'skill', 'adapter',
1518
+ 'budgetMonthlyCents', 'heartbeatIntervalMs', 'capabilities', 'defaultSkills',
1519
+ 'lastHeartbeatAt', 'pauseReason', 'companyId'];
1426
1520
  const taskKeys = ['id', 'title', 'status', 'priority'];
1427
1521
  const agent = rowToObj(agentRows[0], agentKeys);
1428
1522
  agent.recentTasks = parseRows(tasksResult).map(r => rowToObj(r, taskKeys));
@@ -1433,6 +1527,37 @@ async function handleGetAgent(req, res, ctx, agentId) {
1433
1527
  }
1434
1528
  }
1435
1529
 
1530
+ // P7-A2: GET /api/agents/:id/runs — HeartbeatRun history for an agent
1531
+ async function handleGetAgentRuns(req, res, ctx, agentId) {
1532
+ const { mgQuery } = ctx;
1533
+ if (!agentId) { jsonResponse(res, 400, { error: 'Missing agent ID' }); return; }
1534
+ const cid = ctx.cid;
1535
+
1536
+ // SQL parity: HeartbeatRun nodes are Memgraph-only (no SQLite mirror yet)
1537
+ if (!mgQuery) {
1538
+ return jsonResponse(res, 200, { runs: [], _source: 'sqlite' });
1539
+ }
1540
+
1541
+ try {
1542
+ const result = await mgQuery(
1543
+ // H-2 fix: removed h.runId (field doesn't exist on HeartbeatRun nodes — they use h.id)
1544
+ // M-4 fix: use toString() for consistent datetime ordering (matches all other ORDER BY in codebase)
1545
+ `MATCH (h:HeartbeatRun {agentId: $agentId, companyId: $cid})
1546
+ RETURN h.id AS id, h.agentId AS agentId, h.companyId AS companyId,
1547
+ h.status AS status, h.startedAt AS startedAt, h.endedAt AS endedAt,
1548
+ h.taskId AS taskId
1549
+ ORDER BY toString(h.startedAt) DESC LIMIT toInteger(50)`,
1550
+ { agentId, cid }
1551
+ );
1552
+ const rows = parseRows(result);
1553
+ const keys = ['id', 'agentId', 'companyId', 'status', 'startedAt', 'endedAt', 'taskId'];
1554
+ const runs = rows.map(r => rowToObj(r, keys));
1555
+ return jsonResponse(res, 200, { runs, count: runs.length });
1556
+ } catch (err) {
1557
+ return jsonResponse(res, 500, { error: `Query failed: ${err.message}` });
1558
+ }
1559
+ }
1560
+
1436
1561
  // ── Activity ──────────────────────────────────────────────────────────────────
1437
1562
 
1438
1563
  async function handleGetActivity(req, res, ctx) {
@@ -1821,6 +1946,32 @@ async function handleReviseApproval(req, res, ctx, approvalId) {
1821
1946
  return;
1822
1947
  }
1823
1948
  broadcast({ type: 'approval.revised', approvalId, companyId: cid });
1949
+
1950
+ // B-19 fix: create AgentReadySignal so the assigned agent wakes and processes the revision.
1951
+ // Without this, the agent that owns the approval's task never re-evaluates after revision.
1952
+ setImmediate(async () => {
1953
+ try {
1954
+ const taskRows = await mgQuery(
1955
+ `MATCH (a:Approval {id: $id, companyId: $cid})-[:FOR_TASK]->(t:Task)
1956
+ RETURN t.id AS taskId, t.assigneeAgentId AS agentId`,
1957
+ { id: approvalId, cid }
1958
+ );
1959
+ const taskRow = parseRows(taskRows)[0];
1960
+ if (taskRow && taskRow.taskId && taskRow.agentId) {
1961
+ await mgQuery(
1962
+ `MERGE (s:AgentReadySignal {id: 'signal:revise:' + $taskId})
1963
+ ON CREATE SET s.agentId = $agentId, s.companyId = $cid, s.status = 'pending',
1964
+ s.origin = 'approval_revision', s.taskId = $taskId,
1965
+ s.approvalId = $approvalId, s.createdAt = localdatetime()
1966
+ ON MATCH SET s.status = 'pending', s.approvalId = $approvalId`,
1967
+ { taskId: taskRow.taskId, agentId: taskRow.agentId, cid, approvalId }
1968
+ );
1969
+ }
1970
+ } catch (sigErr) {
1971
+ log('warn', 'B-19: AgentReadySignal creation failed after revision', { approvalId, err: sigErr.message });
1972
+ }
1973
+ });
1974
+
1824
1975
  jsonResponse(res, 200, { revised: true, approvalId });
1825
1976
  } catch (err) {
1826
1977
  jsonResponse(res, 500, { error: { code: 'UPDATE_FAILED', message: `Revise failed: ${err.message}` } });
@@ -2674,6 +2825,10 @@ async function handleGetRoutineTriggers(req, res, ctx, routineId) {
2674
2825
 
2675
2826
  async function handleCreateRoutineTrigger(req, res, ctx, routineId) {
2676
2827
  const { mgQuery } = ctx;
2828
+ // HIGH-2 fix: guard against Memgraph unavailability
2829
+ if (!mgQuery) { return jsonResponse(res, 503, { error: 'Memgraph not connected' }); }
2830
+ // CRIT-2 fix: declare cid so broadcast() does not throw ReferenceError
2831
+ const cid = ctx.cid;
2677
2832
  const body = await parseBody(req);
2678
2833
  const kind = body.kind;
2679
2834
  if (!kind || !['schedule', 'webhook', 'api'].includes(kind)) {
@@ -2698,7 +2853,7 @@ async function handleCreateRoutineTrigger(req, res, ctx, routineId) {
2698
2853
  {
2699
2854
  triggerId,
2700
2855
  routineId,
2701
- cid: ctx.cid,
2856
+ cid,
2702
2857
  kind,
2703
2858
  publicId,
2704
2859
  config: JSON.stringify(body.config || {}),
@@ -2900,13 +3055,17 @@ async function handleCreateRoutine(req, res, ctx) {
2900
3055
 
2901
3056
  async function handlePatchRoutine(req, res, ctx, routineId) {
2902
3057
  const { mgQuery } = ctx;
3058
+ // HIGH-2 fix: guard against Memgraph unavailability
3059
+ if (!mgQuery) { return jsonResponse(res, 503, { error: 'Memgraph not connected' }); }
3060
+ // CRIT-2 fix: declare cid so broadcast() does not throw ReferenceError
3061
+ const cid = ctx.cid;
2903
3062
  const body = await parseBody(req);
2904
3063
 
2905
3064
  try {
2906
- // Get current routine
3065
+ // Get current routine — CRIT-5 fix: use r.name (not r.title) consistently
2907
3066
  const existing = await mgQuery(
2908
- `MATCH (r:Routine {id: $routineId, companyId: $cid}) RETURN r.title AS title, r.config AS config`,
2909
- { routineId, cid: ctx.cid }
3067
+ `MATCH (r:Routine {id: $routineId, companyId: $cid}) RETURN r.name AS name, r.config AS config`,
3068
+ { routineId, cid }
2910
3069
  );
2911
3070
  const rows = parseRows(existing);
2912
3071
  if (rows.length === 0) return jsonResponse(res, 404, { error: 'Routine not found' });
@@ -2914,48 +3073,69 @@ async function handlePatchRoutine(req, res, ctx, routineId) {
2914
3073
  // Count existing revisions to determine revision number
2915
3074
  const revCount = await mgQuery(
2916
3075
  `MATCH (rv:RoutineRevision {routineId: $routineId, companyId: $cid}) RETURN count(rv) AS cnt`,
2917
- { routineId, cid: ctx.cid }
3076
+ { routineId, cid }
2918
3077
  );
2919
3078
  const revRows = parseRows(revCount);
2920
3079
  const revisionNumber = (revRows[0]?.[0] || 0) + 1;
2921
3080
 
2922
- // Create revision
2923
- const revisionId = `revision:${crypto.randomUUID()}`;
2924
- await mgQuery(
2925
- `CREATE (rv:RoutineRevision {
2926
- id: $revisionId,
2927
- routineId: $routineId,
2928
- companyId: $cid,
2929
- revisionNumber: $revisionNumber,
2930
- config: $config,
2931
- createdAt: datetime()
2932
- })`,
2933
- {
2934
- revisionId,
2935
- routineId,
2936
- cid: ctx.cid,
2937
- revisionNumber,
2938
- config: JSON.stringify(body)
2939
- }
2940
- );
2941
-
2942
3081
  // Update routine with new values
3082
+ // CRIT-3 fix: handle body.status (for soft-delete via 'inactive' + other status changes)
3083
+ // CRIT-5 fix: use r.name not r.title
2943
3084
  const sets = [];
2944
- const params = { routineId, cid: ctx.cid };
2945
- if (body.title) { sets.push('r.title = $title'); params.title = body.title; }
2946
- if (body.catchUpPolicy) { sets.push('r.catchUpPolicy = $catchUpPolicy'); params.catchUpPolicy = body.catchUpPolicy; }
3085
+ const params = { routineId, cid };
3086
+ if (body.name) { sets.push('r.name = $name'); params.name = String(body.name); }
3087
+ if (body.title) { sets.push('r.name = $name'); params.name = String(body.title); } // legacy alias
3088
+ if (body.status) { sets.push('r.status = $status'); params.status = String(body.status); }
3089
+ if (body.catchUpPolicy) { sets.push('r.catchUpPolicy = $catchUpPolicy'); params.catchUpPolicy = body.catchUpPolicy; }
2947
3090
  if (body.concurrencyPolicy) { sets.push('r.concurrencyPolicy = $concurrencyPolicy'); params.concurrencyPolicy = body.concurrencyPolicy; }
2948
- if (body.config) { sets.push('r.config = $config'); params.config = JSON.stringify(body.config); }
3091
+ if (body.cronExpr) { sets.push('r.cronExpr = $cronExpr'); params.cronExpr = body.cronExpr; }
3092
+ if (body.config) { sets.push('r.config = $config'); params.config = JSON.stringify(body.config); }
3093
+ sets.push('r.updatedAt = datetime()');
3094
+
3095
+ // MED-3 fix: only create a revision if there are actual field changes (not on no-op patches)
3096
+ const hasChanges = sets.length > 1; // always has updatedAt, so check > 1
3097
+ if (hasChanges) {
3098
+ const revisionId = `revision:${crypto.randomUUID()}`;
3099
+ await mgQuery(
3100
+ `CREATE (rv:RoutineRevision {
3101
+ id: $revisionId,
3102
+ routineId: $routineId,
3103
+ companyId: $cid,
3104
+ revisionNumber: $revisionNumber,
3105
+ config: $config,
3106
+ createdAt: datetime()
3107
+ })`,
3108
+ {
3109
+ revisionId,
3110
+ routineId,
3111
+ cid,
3112
+ revisionNumber,
3113
+ config: JSON.stringify(body)
3114
+ }
3115
+ );
3116
+ }
2949
3117
 
2950
- if (sets.length > 0) {
3118
+ if (sets.length > 1) {
2951
3119
  await mgQuery(
2952
3120
  `MATCH (r:Routine {id: $routineId, companyId: $cid}) SET ${sets.join(', ')}`,
2953
3121
  params
2954
3122
  );
2955
3123
  }
2956
3124
 
2957
- broadcast({ type: 'routine.updated', routineId, revisionNumber, companyId: cid });
2958
- return jsonResponse(res, 200, { updated: true, routineId, revisionNumber, revisionId });
3125
+ // HIGH-3 fix: SQLite write-through after successful Memgraph update
3126
+ if (sets.length > 1 && hboStore?.updateRoutine) {
3127
+ const storeUpdate = {};
3128
+ if (body.name || body.title) storeUpdate.name = body.name ?? body.title;
3129
+ if (body.status) storeUpdate.status = body.status;
3130
+ if (body.catchUpPolicy) storeUpdate.catch_up_policy = body.catchUpPolicy;
3131
+ if (body.concurrencyPolicy) storeUpdate.concurrency_policy = body.concurrencyPolicy;
3132
+ if (body.cronExpr) storeUpdate.cron_expr = body.cronExpr;
3133
+ try { hboStore.updateRoutine(routineId, cid, storeUpdate); }
3134
+ catch (storeErr) { log('error', 'hboStore.updateRoutine failed', { err: storeErr.message, routineId }); }
3135
+ }
3136
+
3137
+ broadcast({ type: 'routine.updated', routineId, revisionNumber: hasChanges ? revisionNumber : null, companyId: cid });
3138
+ return jsonResponse(res, 200, { updated: true, routineId, revisionNumber: hasChanges ? revisionNumber : null });
2959
3139
  } catch (err) {
2960
3140
  return jsonResponse(res, 500, { error: err.message });
2961
3141
  }
@@ -2981,6 +3161,12 @@ async function handleGetRoutineRevisions(req, res, ctx, routineId) {
2981
3161
 
2982
3162
  async function handleGetRoutineRuns(req, res, ctx, routineId) {
2983
3163
  const { mgQuery } = ctx;
3164
+ const cid = ctx.cid;
3165
+ // MED-5 fix: SQL parity — fallback to SQLite when Memgraph unavailable
3166
+ if (!mgQuery) {
3167
+ // No RoutineRun SQLite table exists yet — return empty gracefully
3168
+ return jsonResponse(res, 200, { runs: [], _source: 'sqlite' });
3169
+ }
2984
3170
  try {
2985
3171
  const result = await mgQuery(
2986
3172
  `MATCH (rr:RoutineRun {routineId: $routineId, companyId: $cid})
@@ -2988,7 +3174,7 @@ async function handleGetRoutineRuns(req, res, ctx, routineId) {
2988
3174
  rr.startedAt AS startedAt, rr.completedAt AS completedAt
2989
3175
  ORDER BY rr.startedAt DESC
2990
3176
  LIMIT toInteger($lim)`,
2991
- { routineId, cid: ctx.cid, lim: 50 }
3177
+ { routineId, cid, lim: 50 }
2992
3178
  );
2993
3179
  const rows = parseRows(result);
2994
3180
  const keys = ['id', 'status', 'taskId', 'startedAt', 'completedAt'];
@@ -3348,9 +3534,14 @@ const approvalsRoute = require('./routes/approvals')({
3348
3534
 
3349
3535
  const agentsRoute = require('./routes/agents')({
3350
3536
  handleGetAgents,
3537
+ handleGetAgent, // P7-A1: wire single-agent GET (was dead code — not passed before)
3538
+ handleGetAgentRuns, // P7-A2: HeartbeatRun history
3351
3539
  handleSyncSkills,
3352
3540
  handleApproveAgent,
3353
3541
  handleTerminateAgent,
3542
+ handlePauseAgent, // Bug fix: was missing, caused TypeError in agents.js
3543
+ handleResumeAgent, // Bug fix: was missing
3544
+ handlePauseAll: null, // pause-all is handled in hbo.js; stub null to prevent TypeError
3354
3545
  });
3355
3546
 
3356
3547
  const skillsRoute = require('./routes/skills')({
@@ -3405,7 +3596,7 @@ const inboxRoute = require('./routes/inbox')({ broadcast });
3405
3596
  const queueRoute = require('./routes/queue')({ broadcast });
3406
3597
  const { handleDraftsRoute } = require('./routes/drafts');
3407
3598
  const goalsRoute = require('./routes/goals');
3408
- const hboRoute = require('./routes/hbo')({ broadcast });
3599
+ let hboRoute = require('./routes/hbo')({ broadcast });
3409
3600
  const emailTriageRoute = require('./routes/email-triage')();
3410
3601
  const andonRoute = require('./routes/andon')({ handleGetAndonBoard });
3411
3602
  const wizardRoute = require('./routes/wizard')({});
@@ -3435,6 +3626,9 @@ let mandalaRoute;
3435
3626
  try { mandalaRoute = require('./routes/mandala').mandalaRoute; } catch (_) { mandalaRoute = () => false; }
3436
3627
  let personRoute;
3437
3628
  try { personRoute = require('./routes/person')({ handleGetPersonProfile, handleGetPersonByEmail, handleGetPersonEpisodes }); } catch (_) { personRoute = () => false; }
3629
+ // P5-S5c: CRM dual-write route — accepts POST /api/crm/contacts from desktop crmSyncService
3630
+ let crmRoute;
3631
+ try { crmRoute = require('./routes/crm')({ parseBody, jsonResponse }); } catch (_) { crmRoute = () => false; }
3438
3632
 
3439
3633
 
3440
3634
  // ── WS-nonce store (single-use, 60-second TTL) ───────────────────────────────
@@ -3499,6 +3693,25 @@ async function route(req, res, ctx) {
3499
3693
  return;
3500
3694
  }
3501
3695
 
3696
+ // SP3: Read receipt tracking pixel — must be before auth middleware, no auth required
3697
+ if (req.method === 'GET' && /^\/t\/[a-zA-Z0-9_-]+\.png$/.test(req.url || '')) {
3698
+ const trackingId = (req.url || '').slice(3, -4); // strip leading /t/ and trailing .png
3699
+ const gif1x1 = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
3700
+ res.writeHead(200, {
3701
+ 'Content-Type': 'image/gif',
3702
+ 'Content-Length': gif1x1.length,
3703
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
3704
+ 'Pragma': 'no-cache',
3705
+ });
3706
+ res.end(gif1x1);
3707
+ // Fire-and-forget: log open event to Memgraph
3708
+ try {
3709
+ const { rawWrite: rw } = require('../lib/safe-memgraph.js');
3710
+ rw(`MERGE (o:OpenEvent {trackingId: $id}) ON CREATE SET o.count = 1, o.firstOpenedAt = datetime() ON MATCH SET o.count = o.count + 1, o.lastOpenedAt = datetime()`, { id: trackingId }).catch(() => {});
3711
+ } catch {}
3712
+ return;
3713
+ }
3714
+
3502
3715
  // ── Bearer token auth ────────────────────────────────────────────────────
3503
3716
  // Public routes that skip auth
3504
3717
  const PUBLIC_PATHS = new Set(['/api/health', '/api/auth/ws-nonce', '/login', '/signup', '/api/auth/signup', '/api/auth/login', '/api/auth/logout']);
@@ -3875,6 +4088,7 @@ async function route(req, res, ctx) {
3875
4088
  if (await suggestionsRoute(req, res, reqCtx, pathname, method)) return;
3876
4089
  if (await mandalaRoute(req, res, reqCtx, pathname, method)) return;
3877
4090
  if (await personRoute(req, res, reqCtx, pathname, method)) return;
4091
+ if (await crmRoute(req, res, reqCtx, pathname, method)) return;
3878
4092
 
3879
4093
  // ── Phase 3: SystemAim endpoints ─────────────────────────────────────────
3880
4094
  if (pathname === '/api/system-aim') {
@@ -4151,11 +4365,26 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
4151
4365
  if (narrativeStr && lastGenStr) {
4152
4366
  const lastGen = new Date(String(lastGenStr)).getTime();
4153
4367
  if (!isNaN(lastGen) && Date.now() - lastGen < CACHE_TTL_MS) {
4154
- // Cache hit — return as-is
4368
+ // Cache hit — return as-is, also include tasks created from this page
4155
4369
  let narrative;
4156
4370
  try { narrative = JSON.parse(String(narrativeStr)); }
4157
4371
  catch (_) { narrative = { governingThought: String(narrativeStr) }; }
4158
- jsonResponse(res, 200, { department, narrative, generatedAt: lastGenStr, cached: true });
4372
+
4373
+ // P8E-01: Include tasks created from this department page's decisionsStructured
4374
+ const tasksResult = await mg(
4375
+ `MATCH (dp:DepartmentPage {companyId: $cid, department: $dept})
4376
+ WHERE dp.narrative IS NOT NULL
4377
+ OPTIONAL MATCH (dp)-[:CREATED_TASK]->(t:Task)
4378
+ RETURN collect({id: t.id, title: t.title, status: t.status, originKind: t.originKind}) AS tasks
4379
+ ORDER BY dp.lastGeneratedAt DESC LIMIT 1`,
4380
+ { cid, dept: department }
4381
+ ).catch(() => null);
4382
+ const tasksRow = tasksResult && tasksResult.rows ? tasksResult.rows[0] : null;
4383
+ const tasks = tasksRow
4384
+ ? (Array.isArray(tasksRow) ? (tasksRow[0] || []) : (tasksRow.tasks || []))
4385
+ : [];
4386
+
4387
+ jsonResponse(res, 200, { department, narrative, generatedAt: lastGenStr, cached: true, tasks });
4159
4388
  return;
4160
4389
  }
4161
4390
  }
@@ -4407,6 +4636,21 @@ function startApi(mgQuery, config = {}, state = {}) {
4407
4636
  deptRoute = require('./routes/dept')({ mgQuery, broadcast: state.broadcast ?? (() => {}) });
4408
4637
  }
4409
4638
 
4639
+ // Reinitialize hboRoute with triggerGoalDecompose hook so POST /api/hbo/goals immediately
4640
+ // fires tickGoalDecompose instead of waiting up to 150s for the next 5th-tick.
4641
+ // state.daemon._mods is the per-company module registry built by buildForCompany().
4642
+ if (state.daemon && typeof state.broadcast === 'function') {
4643
+ const _triggerFn = (cid) => {
4644
+ try {
4645
+ const mods = state.daemon._mods && state.daemon._mods.get ? state.daemon._mods.get(cid) : (state.daemon._mods?.[cid]);
4646
+ if (mods && mods.hboBridge && typeof mods.hboBridge.tickGoalDecompose === 'function') {
4647
+ mods.hboBridge.tickGoalDecompose({ fromGoalCreate: true }).catch(() => {});
4648
+ }
4649
+ } catch (_) {}
4650
+ };
4651
+ hboRoute = require('./routes/hbo')({ broadcast: state.broadcast, mgQuery, triggerGoalDecompose: _triggerFn });
4652
+ }
4653
+
4410
4654
  // HED routes — requires mgQuery for HEDEngine
4411
4655
  try {
4412
4656
  const { createHedRoutes } = require('./routes/hed');
@@ -4439,6 +4683,7 @@ function startApi(mgQuery, config = {}, state = {}) {
4439
4683
  draining: false,
4440
4684
  apiToken: config.apiToken || null, // null = no auth (backward compat)
4441
4685
  daemon: state.daemon ?? null, // daemon instance ref — used by wizard route for onCompanyReady
4686
+ activityLogger: state.activityLogger ?? null, // H-01: for recording approval events
4442
4687
  modules: [
4443
4688
  'RoutineEvaluator', 'BudgetEnforcer', 'LivenessWatchdog',
4444
4689
  'AgentDispatcher', 'ActivityLogger', 'TaskCompletionWatchdog',