@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
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
  /**
3
- * helios-api.js — REST API for Helios daemon (port 9091)
3
+ * helios-api.js — REST API for Helios daemon
4
4
  *
5
5
  * Endpoints:
6
6
  * GET /api/health
@@ -48,8 +48,8 @@ const TRANSCRIPTS_DIR = path.join(__dirname, 'transcripts');
48
48
  const _transcriptStore = new TranscriptStore(TRANSCRIPTS_DIR);
49
49
  const CORS_HEADERS = {
50
50
  'Access-Control-Allow-Origin': '*',
51
- 'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS',
52
- 'Access-Control-Allow-Headers': 'Content-Type, Accept',
51
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
52
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization',
53
53
  };
54
54
 
55
55
  // ── Logging ────────────────────────────────────────────────────────────────────────────────
@@ -61,6 +61,14 @@ function log(level, msg, extra) {
61
61
  else process.stdout.write(JSON.stringify(entry) + '\n');
62
62
  }
63
63
 
64
+ function mirrorHboStore(label, write) {
65
+ try {
66
+ if (typeof write === 'function') write();
67
+ } catch (err) {
68
+ log('warn', `hbo-core mirror failed: ${label}`, { error: err.message });
69
+ }
70
+ }
71
+
64
72
  // ── SSE Client Registry ───────────────────────────────────────────────────────
65
73
 
66
74
  const sseClients = new Set();
@@ -260,6 +268,7 @@ async function handleHealth(req, res, ctx) {
260
268
  const { tickCount, modules } = ctx;
261
269
  jsonResponse(res, 200, {
262
270
  status: 'ok',
271
+ port: ctx.actualBoundPort ?? ctx.apiPort ?? null,
263
272
  tick: tickCount ?? 0,
264
273
  modules: modules ?? [],
265
274
  timestamp: new Date().toISOString(),
@@ -374,13 +383,60 @@ async function handleCreateTask(req, res, ctx) {
374
383
  const cid = ctx.cid;
375
384
 
376
385
  const body = await parseBody(req);
377
- const { title, assigneeAgentId, priority, body: taskBody } = 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;
378
389
 
379
390
  if (!title || typeof title !== 'string' || title.trim() === '') {
380
391
  jsonResponse(res, 400, { error: 'title is required and must be a non-empty string' });
381
392
  return;
382
393
  }
383
394
 
395
+ // P4-02: validate executionPolicy if provided
396
+ let validatedPolicyJson = null;
397
+ if (executionPolicy !== undefined) {
398
+ if (!executionPolicy || typeof executionPolicy !== 'object' || !Array.isArray(executionPolicy.stages)) {
399
+ jsonResponse(res, 400, { error: 'executionPolicy must have a stages array' });
400
+ return;
401
+ }
402
+ if (executionPolicy.stages.length === 0) {
403
+ jsonResponse(res, 400, { error: 'executionPolicy.stages must not be empty' });
404
+ return;
405
+ }
406
+ for (const stage of executionPolicy.stages) {
407
+ if (!stage.type || !['review', 'approval'].includes(stage.type)) {
408
+ jsonResponse(res, 400, { error: `stage.type must be 'review' or 'approval', got: ${stage.type}` });
409
+ return;
410
+ }
411
+ if (!Array.isArray(stage.participants) || stage.participants.length === 0) {
412
+ jsonResponse(res, 400, { error: 'each stage must have a non-empty participants array' });
413
+ return;
414
+ }
415
+ }
416
+ // M-1 fix: validate ALL participants in a single batched query instead of N sequential queries
417
+ const allParticipantIds = [...new Set(
418
+ executionPolicy.stages.flatMap(s => s.participants.map(p => String(p)))
419
+ )];
420
+ const agentCheckResult = await mgQuery(
421
+ `MATCH (a:BusinessAgent {companyId: $cid}) WHERE a.id IN $pids RETURN collect(a.id) AS found`,
422
+ { cid, pids: allParticipantIds }
423
+ );
424
+ const foundIds = new Set(
425
+ (parseRows(agentCheckResult)[0]?.[0] ?? parseRows(agentCheckResult)[0]?.['found'] ?? [])
426
+ );
427
+ const missingIds = allParticipantIds.filter(id => !foundIds.has(id));
428
+ if (missingIds.length > 0) {
429
+ jsonResponse(res, 400, { error: `participants not found as BusinessAgents in this company: ${missingIds.join(', ')}` });
430
+ return;
431
+ }
432
+ validatedPolicyJson = JSON.stringify(executionPolicy);
433
+ }
434
+
435
+ // P4-03: initial executionState — 'idle' when policy exists, null string otherwise
436
+ const initialStateJson = validatedPolicyJson
437
+ ? JSON.stringify({ status: 'idle', currentStageIndex: null, currentParticipant: null, returnAssignee: null, completedStageIds: [] })
438
+ : 'null';
439
+
384
440
  const taskId = `task:api:${crypto.randomUUID()}`;
385
441
  const resolvedPriority = Number.isFinite(Number(priority)) ? Number(priority) : 2;
386
442
 
@@ -394,6 +450,12 @@ async function handleCreateTask(req, res, ctx) {
394
450
  priority: toInteger($priority),
395
451
  assigneeAgentId: $assigneeAgentId,
396
452
  body: $body,
453
+ executionPolicyJson: $policyJson,
454
+ executionStateJson: $stateJson,
455
+ sourceType: $sourceType,
456
+ sourceId: $sourceId,
457
+ originKind: $originKind,
458
+ projectId: $projectId,
397
459
  createdAt: datetime()
398
460
  })`,
399
461
  {
@@ -403,11 +465,55 @@ async function handleCreateTask(req, res, ctx) {
403
465
  priority: resolvedPriority,
404
466
  assigneeAgentId: assigneeAgentId ? String(assigneeAgentId) : null,
405
467
  body: taskBody ? String(taskBody) : null,
468
+ policyJson: validatedPolicyJson,
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,
406
474
  }
407
475
  );
408
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
+
491
+ mirrorHboStore('task.create', () => hboStore.createTask?.({
492
+ id: taskId,
493
+ companyId: cid,
494
+ title: String(title).trim(),
495
+ status: 'todo',
496
+ priority: resolvedPriority,
497
+ assigneeAgentId: assigneeAgentId ? String(assigneeAgentId) : null,
498
+ body: taskBody ? String(taskBody) : null,
499
+ executionPolicyJson: validatedPolicyJson,
500
+ executionStateJson: initialStateJson,
501
+ createdAt: Date.now(),
502
+ }));
503
+
409
504
  broadcast({ type: 'task.created', taskId, status: 'todo', companyId: cid });
410
- jsonResponse(res, 201, { task: { id: taskId, title, status: 'todo', priority: resolvedPriority, assigneeAgentId } });
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
+ });
411
517
  } catch (err) {
412
518
  jsonResponse(res, 500, { error: `Create failed: ${err.message}` });
413
519
  }
@@ -420,9 +526,12 @@ async function handleUpdateTask(req, res, ctx, taskId) {
420
526
  const cid = ctx.cid;
421
527
 
422
528
  const body = await parseBody(req);
423
- 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;
424
532
 
425
- const VALID_STATUSES = new Set(['todo', 'in_progress', 'done', 'failed', 'cancelled', 'blocked', 'escalated']);
533
+ // P4-01: 'in_review' added allows execution policy gate to hold a task for review
534
+ const VALID_STATUSES = new Set(['todo', 'in_progress', 'done', 'failed', 'cancelled', 'blocked', 'escalated', 'in_review']);
426
535
  if (status !== undefined && !VALID_STATUSES.has(status)) {
427
536
  jsonResponse(res, 400, { error: `Invalid status. Allowed: ${[...VALID_STATUSES].join(', ')}` });
428
537
  return;
@@ -432,16 +541,280 @@ async function handleUpdateTask(req, res, ctx, taskId) {
432
541
  return;
433
542
  }
434
543
 
435
- if (status === undefined && priority === undefined) {
436
- 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' });
437
546
  return;
438
547
  }
439
548
 
440
549
  try {
550
+ // ─────────────────────────────────────────────────────────────────────────
551
+ // EXECUTION POLICY STATE MACHINE
552
+ //
553
+ // The state machine has three actors:
554
+ // A) Original executor sets status='done' and task has an active policy
555
+ // → INTERCEPT: redirect to in_review, assign to current stage reviewer
556
+ // B) Reviewer (currently assigned) sets status='done' to approve
557
+ // → ADVANCE: move to next stage, or if last stage, allow done
558
+ // C) Reviewer sets any non-done status to reject/request changes
559
+ // → CHANGES_REQUESTED: return task to original executor
560
+ //
561
+ // C-1 fix: The intercept (actor A) uses TWO queries, but the second (SET) query
562
+ // re-checks the WHERE condition with the expected policyJson — if policy changed
563
+ // between reads, the second query returns 0 rows and we return 409 (optimistic
564
+ // concurrency control). This correctly closes the TOCTOU window.
565
+ // ─────────────────────────────────────────────────────────────────────────
566
+
567
+ if (status === 'done') {
568
+ // Step 1: Read task's current state (fast metadata read)
569
+ const taskReadResult = await mgQuery(
570
+ `MATCH (t:Task {id: $id, companyId: $cid})
571
+ RETURN t.status AS taskStatus,
572
+ t.assigneeAgentId AS assigneeAgentId,
573
+ t.executionPolicyJson AS policyJson,
574
+ t.executionStateJson AS stateJson`,
575
+ { id: taskId, cid }
576
+ );
577
+ const taskReadRows = parseRows(taskReadResult);
578
+
579
+ if (taskReadRows.length > 0) {
580
+ const taskRow = taskReadRows[0];
581
+ const taskStatus = taskRow[0] ?? taskRow['taskStatus'] ?? null;
582
+ const returnAssignee = taskRow[1] ?? taskRow['assigneeAgentId'] ?? null;
583
+ const policyJson = taskRow[2] ?? taskRow['policyJson'] ?? null;
584
+ const stateJsonRaw = taskRow[3] ?? taskRow['stateJson'] ?? 'null';
585
+
586
+ let policy, state;
587
+ try { policy = policyJson ? JSON.parse(policyJson) : null; } catch (_) { policy = null; }
588
+ try { state = JSON.parse(stateJsonRaw); } catch (_) { state = null; }
589
+ if (state === null && stateJsonRaw === 'null') state = null; // explicit null string
590
+
591
+ // ── ACTOR B: Reviewer approving (task is currently in_review) ───────
592
+ if (taskStatus === 'in_review' && policy && state) {
593
+ const currentStageIdx = typeof state.currentStageIndex === 'number' ? state.currentStageIndex : 0;
594
+ const stages = Array.isArray(policy.stages) ? policy.stages : [];
595
+ const nextStageIdx = currentStageIdx + 1;
596
+
597
+ if (nextStageIdx < stages.length) {
598
+ // Advance to next stage (multi-stage full advancement — C-4 full)
599
+ const nextStage = stages[nextStageIdx];
600
+ const nextParticipant = nextStage?.participants?.[0] ?? null;
601
+ if (nextParticipant) {
602
+ const newState = JSON.stringify({
603
+ status: 'pending',
604
+ currentStageIndex: nextStageIdx,
605
+ currentParticipant: nextParticipant,
606
+ returnAssignee: state.returnAssignee,
607
+ completedStageIds: [...(state.completedStageIds ?? []), `stage:${currentStageIdx}`],
608
+ });
609
+ // Atomic: re-check we're still in_review before advancing
610
+ const advanceResult = await mgQuery(
611
+ `MATCH (t:Task {id: $id, companyId: $cid})
612
+ WHERE t.status = 'in_review' AND t.executionPolicyJson = $expectedPolicy
613
+ SET t.executionStateJson = $stateJson,
614
+ t.assigneeAgentId = $nextReviewer,
615
+ t.updatedAt = datetime()
616
+ RETURN t.id`,
617
+ { id: taskId, cid, expectedPolicy: policyJson, stateJson: newState, nextReviewer: nextParticipant }
618
+ );
619
+ if (parseRows(advanceResult).length === 0) {
620
+ jsonResponse(res, 409, { error: 'Concurrent update conflict' });
621
+ return;
622
+ }
623
+ mgQuery(
624
+ `MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
625
+ OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
626
+ WITH a, existing WHERE existing IS NULL
627
+ CREATE (s:AgentReadySignal {id: $sigId, agentId: $agentId, companyId: $cid,
628
+ status: 'pending', claimedBy: null, createdAt: datetime()})`,
629
+ { agentId: nextParticipant, cid, sigId: `ars:advance:${taskId}:${crypto.randomUUID()}` }
630
+ ).catch(e => log('warn', `P4 advance AgentReadySignal failed: ${e.message}`));
631
+ mirrorHboStore('task.update.advance_stage', () => hboStore.updateTask?.(taskId, cid, { executionStateJson: newState, assigneeAgentId: nextParticipant }));
632
+ broadcast({ type: 'task.updated', taskId, status: 'in_review', companyId: cid });
633
+ jsonResponse(res, 200, { updated: true, taskId, advanced: true, nextStage: nextStageIdx, nextReviewer: nextParticipant });
634
+ return;
635
+ }
636
+ }
637
+
638
+ // All stages passed (or last stage reviewer approved) — mark completed and allow done
639
+ const completedState = JSON.stringify({
640
+ ...state,
641
+ status: 'completed',
642
+ completedStageIds: [...(state.completedStageIds ?? []), `stage:${currentStageIdx}`],
643
+ });
644
+ // Atomic: re-check still in_review before marking completed
645
+ const completeResult = await mgQuery(
646
+ `MATCH (t:Task {id: $id, companyId: $cid})
647
+ WHERE t.status = 'in_review' AND t.executionPolicyJson = $expectedPolicy
648
+ SET t.status = 'done',
649
+ t.executionStateJson = $stateJson,
650
+ t.updatedAt = datetime()
651
+ RETURN t.id`,
652
+ { id: taskId, cid, expectedPolicy: policyJson, stateJson: completedState }
653
+ );
654
+ if (parseRows(completeResult).length === 0) {
655
+ jsonResponse(res, 409, { error: 'Concurrent update conflict' });
656
+ return;
657
+ }
658
+ mirrorHboStore('task.update.approved', () => hboStore.updateTask?.(taskId, cid, { status: 'done', executionStateJson: completedState }));
659
+ broadcast({ type: 'task.updated', taskId, status: 'done', companyId: cid });
660
+ jsonResponse(res, 200, { updated: true, taskId, approved: true, status: 'done' });
661
+ return;
662
+ }
663
+
664
+ // ── ACTOR A: Original executor marking done with active policy ───────
665
+ if (taskStatus !== 'in_review' && policy) {
666
+ // Check if state is already completed — if so, allow done through
667
+ const alreadyCompleted = state && typeof state === 'object' && state.status === 'completed';
668
+ if (!alreadyCompleted) {
669
+ const firstStage = Array.isArray(policy.stages) && policy.stages.length > 0 ? policy.stages[0] : null;
670
+ const firstParticipant = firstStage?.participants?.[0] ?? null;
671
+
672
+ // C-2 fix: explicit guard — if no valid participant, log and fall through to done
673
+ if (!firstParticipant) {
674
+ log('warn', `P4: task ${taskId} has executionPolicy but no valid first participant — allowing done through`);
675
+ // fall through to standard path below
676
+ } else {
677
+ const newState = JSON.stringify({
678
+ status: 'pending',
679
+ currentStageIndex: 0,
680
+ currentParticipant: firstParticipant,
681
+ returnAssignee, // captured returnAssignee = original executor
682
+ completedStageIds: [],
683
+ });
684
+ // C-1 fix: ATOMIC check+set — WHERE re-checks policyJson so a concurrent
685
+ // policy change causes 0 rows returned → 409 (optimistic concurrency control)
686
+ const atomicResult = await mgQuery(
687
+ `MATCH (t:Task {id: $id, companyId: $cid})
688
+ WHERE t.executionPolicyJson = $expectedPolicy
689
+ AND t.status <> 'in_review'
690
+ AND (t.executionStateJson IS NULL OR NOT t.executionStateJson CONTAINS '"status":"completed"')
691
+ SET t.status = 'in_review',
692
+ t.executionStateJson = $stateJson,
693
+ t.assigneeAgentId = $reviewer,
694
+ t.updatedAt = datetime()
695
+ RETURN t.id AS id`,
696
+ { id: taskId, cid, expectedPolicy: policyJson, stateJson: newState, reviewer: firstParticipant }
697
+ );
698
+ if (parseRows(atomicResult).length === 0) {
699
+ // Policy changed or state changed between reads — 409
700
+ jsonResponse(res, 409, { error: 'Concurrent update conflict — task state changed by another request' });
701
+ return;
702
+ }
703
+ mgQuery(
704
+ `MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
705
+ OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
706
+ WITH a, existing WHERE existing IS NULL
707
+ CREATE (s:AgentReadySignal {id: $sigId, agentId: $agentId, companyId: $cid,
708
+ status: 'pending', claimedBy: null, createdAt: datetime()})`,
709
+ { agentId: firstParticipant, cid, sigId: `ars:review:${taskId}:${crypto.randomUUID()}` }
710
+ ).catch(e => log('warn', `P4-04 AgentReadySignal emit failed: ${e.message}`));
711
+ mirrorHboStore('task.update.in_review', () => hboStore.updateTask?.(taskId, cid, { status: 'in_review', executionStateJson: newState, assigneeAgentId: firstParticipant }));
712
+ broadcast({ type: 'task.updated', taskId, status: 'in_review', companyId: cid });
713
+ jsonResponse(res, 200, { updated: true, taskId, intercepted: true, status: 'in_review', reviewer: firstParticipant });
714
+ return;
715
+ }
716
+ }
717
+ }
718
+ }
719
+ }
720
+
721
+ // ── ACTOR C: Reviewer rejecting / requesting changes ───────────────────
722
+ // C-3 fix: WHERE t.status = 'in_review' added to the SET query (prevents TOCTOU
723
+ // overwriting a task that was concurrently moved out of in_review by another request).
724
+ // FIX-H1: Check stage type to differentiate review vs approval semantics.
725
+ // FIX-M2: Explicit return for state=null case — no fall-through.
726
+ if (status !== undefined && status !== 'done') {
727
+ // Single atomic read+write: check in_review AND apply in one round-trip
728
+ // by embedding the WHERE in the SET query.
729
+ const reviewCheckResult = await mgQuery(
730
+ `MATCH (t:Task {id: $id, companyId: $cid})
731
+ WHERE t.status = 'in_review' AND t.executionStateJson IS NOT NULL
732
+ RETURN t.executionStateJson AS stateJson, t.executionPolicyJson AS policyJson`,
733
+ { id: taskId, cid }
734
+ );
735
+ const reviewRows = parseRows(reviewCheckResult);
736
+ if (reviewRows.length > 0) {
737
+ const reviewRow = reviewRows[0];
738
+ const stateJsonStr = reviewRow[0] ?? reviewRow['stateJson'] ?? 'null';
739
+ const policyJsonStr = reviewRow[1] ?? reviewRow['policyJson'] ?? null;
740
+ let state, policy;
741
+ try { state = JSON.parse(stateJsonStr); } catch (_) { state = null; }
742
+ try { policy = policyJsonStr ? JSON.parse(policyJsonStr) : null; } catch (_) { policy = null; }
743
+
744
+ // M2 fix: if state is null (corrupt JSON), block the standard path on in_review tasks
745
+ if (!state) {
746
+ jsonResponse(res, 409, { error: 'Task is in_review but execution state is corrupt — cannot apply status change' });
747
+ return;
748
+ }
749
+
750
+ if (state.returnAssignee) {
751
+ const returnAssignee = state.returnAssignee;
752
+ const changesState = JSON.stringify({ ...state, status: 'changes_requested' });
753
+ // C-3 fix: WHERE t.status = 'in_review' guards against concurrent status change
754
+ const changesResult = await mgQuery(
755
+ `MATCH (t:Task {id: $id, companyId: $cid})
756
+ WHERE t.status = 'in_review'
757
+ SET t.status = 'in_progress',
758
+ t.assigneeAgentId = $returnAssignee,
759
+ t.executionStateJson = $stateJson,
760
+ t.updatedAt = datetime()
761
+ RETURN t.id`,
762
+ { id: taskId, cid, returnAssignee, stateJson: changesState }
763
+ );
764
+ if (parseRows(changesResult).length === 0) {
765
+ // Concurrent write changed task out of in_review — fall through to standard path
766
+ // (the task is no longer in_review so standard update is correct)
767
+ } else {
768
+ mgQuery(
769
+ `MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
770
+ OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
771
+ WITH a, existing WHERE existing IS NULL
772
+ CREATE (s:AgentReadySignal {id: $sigId, agentId: $agentId, companyId: $cid,
773
+ status: 'pending', claimedBy: null, createdAt: datetime()})`,
774
+ { agentId: returnAssignee, cid, sigId: `ars:changes:${taskId}:${crypto.randomUUID()}` }
775
+ ).catch(e => log('warn', `P4-05 AgentReadySignal emit failed: ${e.message}`));
776
+ mirrorHboStore('task.update.changes_requested', () => hboStore.updateTask?.(taskId, cid, { status: 'in_progress', executionStateJson: changesState, assigneeAgentId: returnAssignee }));
777
+ broadcast({ type: 'task.updated', taskId, status: 'in_progress', companyId: cid });
778
+ jsonResponse(res, 200, { updated: true, taskId, changesRequested: true, returnAssignee });
779
+ return;
780
+ }
781
+ } else {
782
+ // returnAssignee is null — escalation path (no valid return point)
783
+ // M2 fix: explicit return after escalation to prevent fall-through
784
+ const escalateResult = await mgQuery(
785
+ `MATCH (t:Task {id: $id, companyId: $cid})
786
+ WHERE t.status = 'in_review'
787
+ SET t.status = 'blocked', t.blockedReason = 'escalation_chain_exhausted', t.updatedAt = datetime()
788
+ RETURN t.id`,
789
+ { id: taskId, cid }
790
+ ).catch(() => null);
791
+ if (escalateResult && parseRows(escalateResult).length > 0) {
792
+ mirrorHboStore('task.escalation', () => hboStore.updateTask?.(taskId, cid, { status: 'blocked', blockedReason: 'escalation_chain_exhausted' }));
793
+ broadcast({ type: 'task.blocked', taskId, reason: 'escalation_chain_exhausted', companyId: cid });
794
+ jsonResponse(res, 200, { updated: true, taskId, escalated: true });
795
+ return;
796
+ }
797
+ // If escalate returned 0 rows, task was concurrently moved out of in_review — fall through
798
+ }
799
+ }
800
+ }
801
+
802
+ // Standard update path (no policy intercept applies)
441
803
  const setClauses = [];
442
804
  const params = { id: taskId, cid };
443
805
  if (status !== undefined) { setClauses.push('t.status = $status'); params.status = status; }
444
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
+ }
445
818
  setClauses.push('t.updatedAt = datetime()');
446
819
 
447
820
  // B-09: Single atomic check+update — no TOCTOU window between existence check and SET
@@ -454,6 +827,27 @@ async function handleUpdateTask(req, res, ctx, taskId) {
454
827
  return;
455
828
  }
456
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
+
845
+ const storeUpdate = {};
846
+ if (status !== undefined) storeUpdate.status = status;
847
+ if (priority !== undefined) storeUpdate.priority = Number(priority);
848
+ if (workspacePath !== undefined) storeUpdate.workspacePath = workspacePath ? String(workspacePath) : null;
849
+ mirrorHboStore('task.update', () => hboStore.updateTask?.(taskId, cid, storeUpdate));
850
+
457
851
  broadcast({ type: 'task.updated', taskId, ...(status !== undefined ? { status } : {}), companyId: cid });
458
852
  jsonResponse(res, 200, { updated: true, taskId });
459
853
  } catch (err) {
@@ -461,6 +855,76 @@ async function handleUpdateTask(req, res, ctx, taskId) {
461
855
  }
462
856
  }
463
857
 
858
+ // P4-07: PATCH /api/tasks/:id/policy — set or update executionPolicy on an existing task
859
+ async function handlePatchTaskPolicy(req, res, ctx, taskId) {
860
+ const { mgQuery } = ctx;
861
+ if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
862
+ if (!taskId) { jsonResponse(res, 400, { error: 'Missing task ID' }); return; }
863
+ const cid = ctx.cid;
864
+
865
+ const body = await parseBody(req);
866
+ const { policy } = body;
867
+
868
+ if (!policy || typeof policy !== 'object' || !Array.isArray(policy.stages)) {
869
+ jsonResponse(res, 400, { error: 'policy must have a stages array' });
870
+ return;
871
+ }
872
+ if (policy.stages.length === 0) {
873
+ jsonResponse(res, 400, { error: 'policy.stages must not be empty' });
874
+ return;
875
+ }
876
+ for (const stage of policy.stages) {
877
+ if (!stage.type || !['review', 'approval'].includes(stage.type)) {
878
+ jsonResponse(res, 400, { error: `stage.type must be 'review' or 'approval'` });
879
+ return;
880
+ }
881
+ if (!Array.isArray(stage.participants) || stage.participants.length === 0) {
882
+ jsonResponse(res, 400, { error: 'each stage must have a non-empty participants array' });
883
+ return;
884
+ }
885
+ }
886
+ // M-1 fix: validate ALL participants in a single batched query
887
+ const allPolicyPids = [...new Set(policy.stages.flatMap(s => s.participants.map(p => String(p))))];
888
+ const policyAgentCheck = await mgQuery(
889
+ `MATCH (a:BusinessAgent {companyId: $cid}) WHERE a.id IN $pids RETURN collect(a.id) AS found`,
890
+ { cid, pids: allPolicyPids }
891
+ );
892
+ const foundPolicyAgents = new Set(
893
+ (parseRows(policyAgentCheck)[0]?.[0] ?? parseRows(policyAgentCheck)[0]?.['found'] ?? [])
894
+ );
895
+ const missingPolicyAgents = allPolicyPids.filter(id => !foundPolicyAgents.has(id));
896
+ if (missingPolicyAgents.length > 0) {
897
+ jsonResponse(res, 400, { error: `participants not found as BusinessAgents: ${missingPolicyAgents.join(', ')}` });
898
+ return;
899
+ }
900
+
901
+ const policyJson = JSON.stringify(policy);
902
+ // H-5 fix: initial executionStateJson for the policy — ensures state machine starts correctly.
903
+ // Without this, existing tasks with executionStateJson='null' would have returnAssignee=null
904
+ // when P4-04 fires, causing immediate escalation instead of returning to the original executor.
905
+ const initialStateJson = JSON.stringify({ status: 'idle', currentStageIndex: null, currentParticipant: null, returnAssignee: null, completedStageIds: [] });
906
+ try {
907
+ const result = await mgQuery(
908
+ `MATCH (t:Task {id: $id, companyId: $cid})
909
+ SET t.executionPolicyJson = $policyJson,
910
+ t.executionStateJson = $stateJson,
911
+ t.updatedAt = datetime()
912
+ RETURN t.id`,
913
+ { id: taskId, cid, policyJson, stateJson: initialStateJson }
914
+ );
915
+ if (parseRows(result).length === 0) {
916
+ jsonResponse(res, 404, { error: `Task not found: ${taskId}` });
917
+ return;
918
+ }
919
+ // P4-08: SQLite write-through for policy and initial state
920
+ mirrorHboStore('task.policy', () => hboStore.updateTask?.(taskId, cid, { executionPolicyJson: policyJson, executionStateJson: initialStateJson }));
921
+ broadcast({ type: 'task.policy.updated', taskId, companyId: cid });
922
+ jsonResponse(res, 200, { updated: true, taskId, executionPolicyJson: policyJson });
923
+ } catch (err) {
924
+ jsonResponse(res, 500, { error: `Policy update failed: ${err.message}` });
925
+ }
926
+ }
927
+
464
928
  async function handleCancelTask(req, res, ctx, taskId) {
465
929
  const { mgQuery } = ctx;
466
930
  if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
@@ -481,6 +945,7 @@ async function handleCancelTask(req, res, ctx, taskId) {
481
945
  jsonResponse(res, 404, { error: `Task not found: ${taskId}` });
482
946
  return;
483
947
  }
948
+ mirrorHboStore('task.cancel', () => hboStore.updateTask?.(taskId, cid, { status: 'cancelled', cancelReason: 'api_cancel' }));
484
949
  broadcast({ type: 'task.cancelled', taskId, companyId: cid });
485
950
  jsonResponse(res, 200, { cancelled: true, taskId });
486
951
  } catch (err) {
@@ -506,6 +971,7 @@ async function handleGetApprovals(req, res, ctx) {
506
971
  a.status AS status, a.requestedBy AS requestedBy,
507
972
  a.agentId AS agentId, a.taskId AS taskId,
508
973
  a.createdAt AS createdAt, a.followUpTaskCreated AS followUpTaskCreated,
974
+ a.followUpTaskId AS followUpTaskId,
509
975
  a.expiresAt AS expiresAt,
510
976
  a.kaizenProposalId AS kaizenProposalId,
511
977
  a.description AS description,
@@ -553,7 +1019,7 @@ async function handleGetApprovals(req, res, ctx) {
553
1019
  const rows = parseRows(result);
554
1020
  const keys = [
555
1021
  'id', 'type', 'title', 'status', 'requestedBy', 'agentId', 'taskId',
556
- 'createdAt', 'followUpTaskCreated', 'expiresAt', 'kaizenProposalId',
1022
+ 'createdAt', 'followUpTaskCreated', 'followUpTaskId', 'expiresAt', 'kaizenProposalId',
557
1023
  'description', 'payload', 'body', 'question', 'pillarId',
558
1024
  'sourceAgent', 'resolvedAt', 'department', 'templateKey',
559
1025
  'inferredAnswer', 'defaultAnswer',
@@ -581,11 +1047,12 @@ async function handleApproveApproval(req, res, ctx, approvalId) {
581
1047
  RETURN a.id`,
582
1048
  { id: approvalId, cid, answer }
583
1049
  );
584
- const rows = parseRows(approveResult);
585
- if (rows.length === 0) {
586
- jsonResponse(res, 409, { error: `Approval not found or not in pending state: ${approvalId}` });
587
- return;
588
- }
1050
+ const rows = parseRows(approveResult);
1051
+ if (rows.length === 0) {
1052
+ jsonResponse(res, 409, { error: `Approval not found or not in pending state: ${approvalId}` });
1053
+ return;
1054
+ }
1055
+ mirrorHboStore('approval.approve', () => hboStore.updateApproval?.(approvalId, cid, { status: 'approved', answer, approvedAt: Date.now() }));
589
1056
 
590
1057
  // Synchronously update linked Strategy if this is a strategy_proposal
591
1058
  try {
@@ -687,6 +1154,8 @@ async function handleApproveApproval(req, res, ctx, approvalId) {
687
1154
  }
688
1155
 
689
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 });
690
1159
  jsonResponse(res, 200, { approved: true, approvalId });
691
1160
  } catch (err) {
692
1161
  jsonResponse(res, 500, { error: `Approve failed: ${err.message}` });
@@ -714,7 +1183,10 @@ async function handleRejectApproval(req, res, ctx, approvalId) {
714
1183
  jsonResponse(res, 409, { error: `Approval not found or not in pending state: ${approvalId}` });
715
1184
  return;
716
1185
  }
1186
+ mirrorHboStore('approval.reject', () => hboStore.updateApproval?.(approvalId, cid, { status: 'rejected', rejectReason: reason, rejectedAt: Date.now() }));
717
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 });
718
1190
  jsonResponse(res, 200, { rejected: true, approvalId, reason });
719
1191
  } catch (err) {
720
1192
  jsonResponse(res, 500, { error: `Reject failed: ${err.message}` });
@@ -993,16 +1465,39 @@ async function handleGetAgents(req, res, ctx) {
993
1465
 
994
1466
  async function handleGetAgent(req, res, ctx, agentId) {
995
1467
  const { mgQuery } = ctx;
996
- if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
997
1468
  if (!agentId) { jsonResponse(res, 400, { error: 'Missing agent ID' }); return; }
998
1469
  const cid = ctx.cid;
999
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
+
1000
1487
  try {
1001
1488
  const [agentResult, tasksResult] = await Promise.all([
1002
1489
  mgQuery(
1490
+ // P7-A1: extended RETURN to include all required fields for agent detail page
1003
1491
  `MATCH (a:BusinessAgent {id: $id, companyId: $cid})
1004
- RETURN a.id AS id, a.title AS title, a.status AS status,
1005
- 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`,
1006
1501
  { id: agentId, cid }
1007
1502
  ),
1008
1503
  mgQuery(
@@ -1019,7 +1514,9 @@ async function handleGetAgent(req, res, ctx, agentId) {
1019
1514
  return;
1020
1515
  }
1021
1516
 
1022
- 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'];
1023
1520
  const taskKeys = ['id', 'title', 'status', 'priority'];
1024
1521
  const agent = rowToObj(agentRows[0], agentKeys);
1025
1522
  agent.recentTasks = parseRows(tasksResult).map(r => rowToObj(r, taskKeys));
@@ -1030,6 +1527,37 @@ async function handleGetAgent(req, res, ctx, agentId) {
1030
1527
  }
1031
1528
  }
1032
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
+
1033
1561
  // ── Activity ──────────────────────────────────────────────────────────────────
1034
1562
 
1035
1563
  async function handleGetActivity(req, res, ctx) {
@@ -1418,6 +1946,32 @@ async function handleReviseApproval(req, res, ctx, approvalId) {
1418
1946
  return;
1419
1947
  }
1420
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
+
1421
1975
  jsonResponse(res, 200, { revised: true, approvalId });
1422
1976
  } catch (err) {
1423
1977
  jsonResponse(res, 500, { error: { code: 'UPDATE_FAILED', message: `Revise failed: ${err.message}` } });
@@ -2222,6 +2776,18 @@ async function handleCreateStrategy(req, res, ctx) {
2222
2776
  { id: approvalId, cid, approvalTitle: `Strategy: ${title}`, plan, proposedBy, strategyId }
2223
2777
  );
2224
2778
 
2779
+ mirrorHboStore('approval.create', () => hboStore.createApproval?.({
2780
+ id: approvalId,
2781
+ companyId: cid,
2782
+ type: 'strategy_proposal',
2783
+ title: `Strategy: ${title}`,
2784
+ description: plan,
2785
+ requestedBy: proposedBy,
2786
+ strategyId,
2787
+ status: 'pending',
2788
+ createdAt: Date.now(),
2789
+ }));
2790
+
2225
2791
  // Link goal to strategy
2226
2792
  await mgQuery(
2227
2793
  `MATCH (g:CompanyGoal {id: $goalId}), (s:Strategy {id: $strategyId})
@@ -2259,6 +2825,10 @@ async function handleGetRoutineTriggers(req, res, ctx, routineId) {
2259
2825
 
2260
2826
  async function handleCreateRoutineTrigger(req, res, ctx, routineId) {
2261
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;
2262
2832
  const body = await parseBody(req);
2263
2833
  const kind = body.kind;
2264
2834
  if (!kind || !['schedule', 'webhook', 'api'].includes(kind)) {
@@ -2283,7 +2853,7 @@ async function handleCreateRoutineTrigger(req, res, ctx, routineId) {
2283
2853
  {
2284
2854
  triggerId,
2285
2855
  routineId,
2286
- cid: ctx.cid,
2856
+ cid,
2287
2857
  kind,
2288
2858
  publicId,
2289
2859
  config: JSON.stringify(body.config || {}),
@@ -2305,13 +2875,13 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
2305
2875
  const result = await mgQuery(
2306
2876
  `MATCH (rt:RoutineTrigger {publicId: $publicId, companyId: $cid})-[:TRIGGERS]->(r:Routine)
2307
2877
  RETURN rt.id AS triggerId, rt.routineId AS routineId, r.title AS routineTitle,
2308
- r.variables AS variables, r.companyId AS companyId`,
2878
+ r.variables AS variables, r.companyId AS companyId, rt.secret AS secret`,
2309
2879
  { publicId, cid: ctx.cid }
2310
2880
  );
2311
2881
  const rows = parseRows(result);
2312
2882
  if (rows.length === 0) return jsonResponse(res, 404, { error: 'Trigger not found' });
2313
2883
 
2314
- const keys = ['triggerId', 'routineId', 'routineTitle', 'variables', 'companyId'];
2884
+ const keys = ['triggerId', 'routineId', 'routineTitle', 'variables', 'companyId', 'secret'];
2315
2885
  const trigger = rowToObj(rows[0], keys);
2316
2886
 
2317
2887
  // S-06: Verify HMAC signature if trigger has a secret configured
@@ -2356,8 +2926,8 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
2356
2926
  }
2357
2927
  );
2358
2928
 
2359
- broadcast({ type: 'routine.fired', routineId: trigger.routineId, taskId, source: 'webhook', companyId: cid });
2360
- return jsonResponse(res, 200, { fired: true, taskId, routineId: trigger.routineId });
2929
+ broadcast({ type: 'routine.fired', routineId: trigger.routineId, taskId, source: 'webhook', companyId: trigger.companyId });
2930
+ return jsonResponse(res, 200, { fired: true, taskId, routineId: trigger.routineId });
2361
2931
  } catch (err) {
2362
2932
  return jsonResponse(res, 500, { error: err.message });
2363
2933
  }
@@ -2365,15 +2935,137 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
2365
2935
 
2366
2936
  // ── Routine Revisions & Runs ──────────────────────────────────────────────────
2367
2937
 
2938
+ // P5-01: GET /api/routines — list all routines for a company
2939
+ // SQL parity: falls back to hboStore.getRoutinesByCompany when Memgraph unavailable
2940
+ async function handleGetRoutines(req, res, ctx) {
2941
+ const { mgQuery } = ctx;
2942
+ const cid = ctx.cid;
2943
+ const url = new URL(req.url, 'http://localhost');
2944
+ const statusFilter = url.searchParams.get('status') || '';
2945
+
2946
+ if (mgQuery) {
2947
+ try {
2948
+ let cypher = `MATCH (r:Routine {companyId: $cid})`;
2949
+ const params = { cid };
2950
+ if (statusFilter) { cypher += ` WHERE r.status = $status`; params.status = statusFilter; }
2951
+ cypher += ` RETURN r.id, r.name, r.cronExpr, r.agentId, r.status, r.concurrencyPolicy,
2952
+ r.catchUpPolicy, r.catchUpCap, r.nextRunAt, r.lastRunAt, r.createdAt
2953
+ ORDER BY r.createdAt DESC`;
2954
+ const result = await mgQuery(cypher, params);
2955
+ const keys = ['id','name','cronExpr','agentId','status','concurrencyPolicy','catchUpPolicy','catchUpCap','nextRunAt','lastRunAt','createdAt'];
2956
+ const routines = parseRows(result).map(r => rowToObj(r, keys));
2957
+ return jsonResponse(res, 200, { routines, count: routines.length });
2958
+ } catch (e) {
2959
+ log('warn', `handleGetRoutines Memgraph failed, falling back to SQLite: ${e.message}`);
2960
+ }
2961
+ }
2962
+ // SQLite fallback
2963
+ try {
2964
+ const rows = hboStore.getRoutinesByCompany ? hboStore.getRoutinesByCompany(cid, statusFilter || undefined) : [];
2965
+ const routines = (rows || []).map(r => ({
2966
+ id: r.id, name: r.name ?? null, cronExpr: r.cron_expr ?? r.cronExpr ?? null,
2967
+ agentId: r.agent_id ?? r.agentId ?? null, status: r.status ?? 'active',
2968
+ concurrencyPolicy: r.concurrency_policy ?? r.concurrencyPolicy ?? 'skip_if_active',
2969
+ catchUpPolicy: r.catch_up_policy ?? r.catchUpPolicy ?? 'skip_missed',
2970
+ catchUpCap: r.catch_up_cap ?? r.catchUpCap ?? 0,
2971
+ nextRunAt: r.next_run_at ?? r.nextRunAt ?? null,
2972
+ lastRunAt: r.last_run_at ?? r.lastRunAt ?? null,
2973
+ createdAt: r.created_at ? new Date(r.created_at).toISOString() : null,
2974
+ }));
2975
+ return jsonResponse(res, 200, { routines, count: routines.length, _source: 'sqlite' });
2976
+ } catch (storeErr) {
2977
+ return jsonResponse(res, 503, { error: `Routines unavailable: ${storeErr.message}` });
2978
+ }
2979
+ }
2980
+
2981
+ // P5-02: POST /api/routines — create a new routine
2982
+ async function handleCreateRoutine(req, res, ctx) {
2983
+ const { mgQuery } = ctx;
2984
+ if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
2985
+ const cid = ctx.cid;
2986
+ if (!cid || cid === 'default-company') { jsonResponse(res, 400, { error: 'companyId is required' }); return; }
2987
+
2988
+ const body = await parseBody(req);
2989
+ const { name, agentId, cronExpr, concurrencyPolicy, catchUpPolicy, catchUpCap } = body;
2990
+
2991
+ if (!name || typeof name !== 'string' || !name.trim()) {
2992
+ jsonResponse(res, 400, { error: 'name is required' });
2993
+ return;
2994
+ }
2995
+ if (!agentId || typeof agentId !== 'string') {
2996
+ jsonResponse(res, 400, { error: 'agentId is required' });
2997
+ return;
2998
+ }
2999
+ if (!cronExpr || typeof cronExpr !== 'string') {
3000
+ jsonResponse(res, 400, { error: 'cronExpr is required' });
3001
+ return;
3002
+ }
3003
+
3004
+ // Compute nextRunAt using croner (confirmed available in daemon)
3005
+ let nextRunAt;
3006
+ try {
3007
+ const { Cron } = require('croner');
3008
+ const cron = new Cron(cronExpr);
3009
+ const next = cron.nextRun();
3010
+ cron.stop();
3011
+ nextRunAt = next ? next.toISOString().replace(/\.\d{3}Z$/, '+00:00') : null;
3012
+ } catch (cronErr) {
3013
+ log('warn', `P5-02: cronExpr could not be parsed: ${cronErr.message} — using +1h fallback`);
3014
+ nextRunAt = null; // Memgraph will use datetime() + duration("PT1H") fallback
3015
+ }
3016
+
3017
+ const resolvedPolicy = ['skip_if_active', 'coalesce_if_active', 'allow_concurrent'].includes(concurrencyPolicy)
3018
+ ? concurrencyPolicy : 'skip_if_active';
3019
+ const resolvedCatchUp = ['skip_missed', 'enqueue_missed_with_cap'].includes(catchUpPolicy)
3020
+ ? catchUpPolicy : 'skip_missed';
3021
+ const resolvedCap = Number.isFinite(Number(catchUpCap)) ? Math.max(0, Number(catchUpCap)) : 0;
3022
+ const routineId = `routine:${cid}:${crypto.randomUUID()}`;
3023
+
3024
+ try {
3025
+ const cypher = nextRunAt
3026
+ ? `CREATE (r:Routine { id:$id, companyId:$cid, agentId:$agentId, name:$name,
3027
+ cronExpr:$cron, status:'active', concurrencyPolicy:$policy, catchUpPolicy:$catchUp,
3028
+ catchUpCap:toInteger($cap), nextRunAt:datetime($nextRun), createdAt:datetime() })`
3029
+ : `CREATE (r:Routine { id:$id, companyId:$cid, agentId:$agentId, name:$name,
3030
+ cronExpr:$cron, status:'active', concurrencyPolicy:$policy, catchUpPolicy:$catchUp,
3031
+ catchUpCap:toInteger($cap), nextRunAt:datetime() + duration("PT1H"), createdAt:datetime() })`;
3032
+ const params = {
3033
+ id: routineId, cid, agentId: String(agentId), name: String(name).trim(),
3034
+ cron: cronExpr, policy: resolvedPolicy, catchUp: resolvedCatchUp, cap: resolvedCap,
3035
+ ...(nextRunAt ? { nextRun: nextRunAt } : {}),
3036
+ };
3037
+ await mgQuery(cypher, params);
3038
+
3039
+ // P5-SQL: SQLite write-through (fire-and-forget)
3040
+ try {
3041
+ hboStore.upsertRoutine?.({
3042
+ id: routineId, company_id: cid, agent_id: String(agentId), name: String(name).trim(),
3043
+ cron_expr: cronExpr, status: 'active', concurrency_policy: resolvedPolicy,
3044
+ catch_up_policy: resolvedCatchUp, catch_up_cap: resolvedCap,
3045
+ next_run_at: nextRunAt, created_at: Date.now(),
3046
+ });
3047
+ } catch (storeErr) { log('error', 'hboStore.upsertRoutine failed', { err: storeErr.message }); }
3048
+
3049
+ broadcast({ type: 'routine.created', routineId, companyId: cid });
3050
+ jsonResponse(res, 201, { routine: { id: routineId, name: String(name).trim(), agentId, cronExpr, status: 'active', concurrencyPolicy: resolvedPolicy, catchUpCap: resolvedCap } });
3051
+ } catch (err) {
3052
+ jsonResponse(res, 500, { error: `Create routine failed: ${err.message}` });
3053
+ }
3054
+ }
3055
+
2368
3056
  async function handlePatchRoutine(req, res, ctx, routineId) {
2369
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;
2370
3062
  const body = await parseBody(req);
2371
3063
 
2372
3064
  try {
2373
- // Get current routine
3065
+ // Get current routine — CRIT-5 fix: use r.name (not r.title) consistently
2374
3066
  const existing = await mgQuery(
2375
- `MATCH (r:Routine {id: $routineId, companyId: $cid}) RETURN r.title AS title, r.config AS config`,
2376
- { routineId, cid: ctx.cid }
3067
+ `MATCH (r:Routine {id: $routineId, companyId: $cid}) RETURN r.name AS name, r.config AS config`,
3068
+ { routineId, cid }
2377
3069
  );
2378
3070
  const rows = parseRows(existing);
2379
3071
  if (rows.length === 0) return jsonResponse(res, 404, { error: 'Routine not found' });
@@ -2381,48 +3073,69 @@ async function handlePatchRoutine(req, res, ctx, routineId) {
2381
3073
  // Count existing revisions to determine revision number
2382
3074
  const revCount = await mgQuery(
2383
3075
  `MATCH (rv:RoutineRevision {routineId: $routineId, companyId: $cid}) RETURN count(rv) AS cnt`,
2384
- { routineId, cid: ctx.cid }
3076
+ { routineId, cid }
2385
3077
  );
2386
3078
  const revRows = parseRows(revCount);
2387
3079
  const revisionNumber = (revRows[0]?.[0] || 0) + 1;
2388
3080
 
2389
- // Create revision
2390
- const revisionId = `revision:${crypto.randomUUID()}`;
2391
- await mgQuery(
2392
- `CREATE (rv:RoutineRevision {
2393
- id: $revisionId,
2394
- routineId: $routineId,
2395
- companyId: $cid,
2396
- revisionNumber: $revisionNumber,
2397
- config: $config,
2398
- createdAt: datetime()
2399
- })`,
2400
- {
2401
- revisionId,
2402
- routineId,
2403
- cid: ctx.cid,
2404
- revisionNumber,
2405
- config: JSON.stringify(body)
2406
- }
2407
- );
2408
-
2409
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
2410
3084
  const sets = [];
2411
- const params = { routineId, cid: ctx.cid };
2412
- if (body.title) { sets.push('r.title = $title'); params.title = body.title; }
2413
- 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; }
2414
3090
  if (body.concurrencyPolicy) { sets.push('r.concurrencyPolicy = $concurrencyPolicy'); params.concurrencyPolicy = body.concurrencyPolicy; }
2415
- 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
+ }
2416
3117
 
2417
- if (sets.length > 0) {
3118
+ if (sets.length > 1) {
2418
3119
  await mgQuery(
2419
3120
  `MATCH (r:Routine {id: $routineId, companyId: $cid}) SET ${sets.join(', ')}`,
2420
3121
  params
2421
3122
  );
2422
3123
  }
2423
3124
 
2424
- broadcast({ type: 'routine.updated', routineId, revisionNumber, companyId: cid });
2425
- 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 });
2426
3139
  } catch (err) {
2427
3140
  return jsonResponse(res, 500, { error: err.message });
2428
3141
  }
@@ -2448,6 +3161,12 @@ async function handleGetRoutineRevisions(req, res, ctx, routineId) {
2448
3161
 
2449
3162
  async function handleGetRoutineRuns(req, res, ctx, routineId) {
2450
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
+ }
2451
3170
  try {
2452
3171
  const result = await mgQuery(
2453
3172
  `MATCH (rr:RoutineRun {routineId: $routineId, companyId: $cid})
@@ -2455,7 +3174,7 @@ async function handleGetRoutineRuns(req, res, ctx, routineId) {
2455
3174
  rr.startedAt AS startedAt, rr.completedAt AS completedAt
2456
3175
  ORDER BY rr.startedAt DESC
2457
3176
  LIMIT toInteger($lim)`,
2458
- { routineId, cid: ctx.cid, lim: 50 }
3177
+ { routineId, cid, lim: 50 }
2459
3178
  );
2460
3179
  const rows = parseRows(result);
2461
3180
  const keys = ['id', 'status', 'taskId', 'startedAt', 'completedAt'];
@@ -2500,7 +3219,7 @@ async function dispatchToAdapter(mgQuery, ctx, taskId, agentId) {
2500
3219
  HELIOS_AGENT_ID: agentId,
2501
3220
  HELIOS_RUN_ID: runId,
2502
3221
  HELIOS_TASK_ID: taskId,
2503
- HELIOS_API_URL: `http://localhost:${ctx.apiPort || 9091}`,
3222
+ HELIOS_API_URL: `http://localhost:${ctx.apiPort || 9093}`,
2504
3223
  };
2505
3224
 
2506
3225
  broadcast({ type: 'run.started', runId, taskId, agentId, adapterType, companyId: cid });
@@ -2799,6 +3518,7 @@ const tasksRoute = require('./routes/tasks')({
2799
3518
  handleGetTaskComments,
2800
3519
  handlePostTaskComment,
2801
3520
  handlePatchTask: handleUpdateTask, // GAP-20 fix: was wired to duplicate (no companyId); now uses correct handleUpdateTask
3521
+ handlePatchTaskPolicy, // P4-07: PATCH /api/tasks/:id/policy
2802
3522
  });
2803
3523
 
2804
3524
  const approvalsRoute = require('./routes/approvals')({
@@ -2814,9 +3534,14 @@ const approvalsRoute = require('./routes/approvals')({
2814
3534
 
2815
3535
  const agentsRoute = require('./routes/agents')({
2816
3536
  handleGetAgents,
3537
+ handleGetAgent, // P7-A1: wire single-agent GET (was dead code — not passed before)
3538
+ handleGetAgentRuns, // P7-A2: HeartbeatRun history
2817
3539
  handleSyncSkills,
2818
3540
  handleApproveAgent,
2819
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
2820
3545
  });
2821
3546
 
2822
3547
  const skillsRoute = require('./routes/skills')({
@@ -2845,6 +3570,8 @@ const strategyRoute = require('./routes/strategy')({
2845
3570
  });
2846
3571
 
2847
3572
  const routinesRoute = require('./routes/routines')({
3573
+ handleGetRoutines,
3574
+ handleCreateRoutine,
2848
3575
  handleGetRoutineTriggers,
2849
3576
  handleCreateRoutineTrigger,
2850
3577
  handleFireWebhookTrigger,
@@ -2869,7 +3596,7 @@ const inboxRoute = require('./routes/inbox')({ broadcast });
2869
3596
  const queueRoute = require('./routes/queue')({ broadcast });
2870
3597
  const { handleDraftsRoute } = require('./routes/drafts');
2871
3598
  const goalsRoute = require('./routes/goals');
2872
- const hboRoute = require('./routes/hbo')({ broadcast });
3599
+ let hboRoute = require('./routes/hbo')({ broadcast });
2873
3600
  const emailTriageRoute = require('./routes/email-triage')();
2874
3601
  const andonRoute = require('./routes/andon')({ handleGetAndonBoard });
2875
3602
  const wizardRoute = require('./routes/wizard')({});
@@ -2899,6 +3626,9 @@ let mandalaRoute;
2899
3626
  try { mandalaRoute = require('./routes/mandala').mandalaRoute; } catch (_) { mandalaRoute = () => false; }
2900
3627
  let personRoute;
2901
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; }
2902
3632
 
2903
3633
 
2904
3634
  // ── WS-nonce store (single-use, 60-second TTL) ───────────────────────────────
@@ -2931,6 +3661,10 @@ async function route(req, res, ctx) {
2931
3661
  // It can never elevate to a company this daemon doesn't own.
2932
3662
  const qcid = parsedUrl.searchParams.get('companyId');
2933
3663
  const primaryCid = ctx.companies?.[0]?.id ?? 'default-company';
3664
+ if (method === 'GET' && pathname === '/api/agents' && (!qcid || !qcid.trim())) {
3665
+ jsonResponse(res, 400, { error: 'companyId required' });
3666
+ return;
3667
+ }
2934
3668
  // qcid will be validated against allowedCompanyIds before being used;
2935
3669
  // if it doesn't match, the 403 below catches it. This is safe.
2936
3670
  // Group C: Create per-request context clone so concurrent requests can't corrupt each other's cid.
@@ -2959,6 +3693,25 @@ async function route(req, res, ctx) {
2959
3693
  return;
2960
3694
  }
2961
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
+
2962
3715
  // ── Bearer token auth ────────────────────────────────────────────────────
2963
3716
  // Public routes that skip auth
2964
3717
  const PUBLIC_PATHS = new Set(['/api/health', '/api/auth/ws-nonce', '/login', '/signup', '/api/auth/signup', '/api/auth/login', '/api/auth/logout']);
@@ -3335,6 +4088,7 @@ async function route(req, res, ctx) {
3335
4088
  if (await suggestionsRoute(req, res, reqCtx, pathname, method)) return;
3336
4089
  if (await mandalaRoute(req, res, reqCtx, pathname, method)) return;
3337
4090
  if (await personRoute(req, res, reqCtx, pathname, method)) return;
4091
+ if (await crmRoute(req, res, reqCtx, pathname, method)) return;
3338
4092
 
3339
4093
  // ── Phase 3: SystemAim endpoints ─────────────────────────────────────────
3340
4094
  if (pathname === '/api/system-aim') {
@@ -3611,11 +4365,26 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
3611
4365
  if (narrativeStr && lastGenStr) {
3612
4366
  const lastGen = new Date(String(lastGenStr)).getTime();
3613
4367
  if (!isNaN(lastGen) && Date.now() - lastGen < CACHE_TTL_MS) {
3614
- // Cache hit — return as-is
4368
+ // Cache hit — return as-is, also include tasks created from this page
3615
4369
  let narrative;
3616
4370
  try { narrative = JSON.parse(String(narrativeStr)); }
3617
4371
  catch (_) { narrative = { governingThought: String(narrativeStr) }; }
3618
- 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 });
3619
4388
  return;
3620
4389
  }
3621
4390
  }
@@ -3654,7 +4423,7 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
3654
4423
  * Start the REST API server.
3655
4424
  *
3656
4425
  * @param {Function} mgQuery - Memgraph query function: (cypher, params) => Promise<rows>
3657
- * @param {object} config - Daemon config (uses config.apiPort ?? 9091)
4426
+ * @param {object} config - Daemon config (uses config.apiPort ?? 9093)
3658
4427
  * @param {object} state - Optional shared state for health endpoint
3659
4428
  * @returns {{ server, updateTick, setDraining }}
3660
4429
  */
@@ -3662,6 +4431,16 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
3662
4431
 
3663
4432
  async function handleGetTaskDetail(req, res, ctx, taskId) {
3664
4433
  const cid = ctx.cid;
4434
+ // H-2 fix: SQLite fallback when Memgraph is unavailable
4435
+ if (!ctx.mgQuery) {
4436
+ try {
4437
+ const t = hboStore.getTask ? hboStore.getTask(taskId, cid) : null;
4438
+ if (!t) return jsonResponse(res, 404, { error: 'Task not found' });
4439
+ return jsonResponse(res, 200, { task: t, _source: 'sqlite' });
4440
+ } catch (storeErr) {
4441
+ return jsonResponse(res, 503, { error: 'Task unavailable: Memgraph not connected', task: null });
4442
+ }
4443
+ }
3665
4444
  try {
3666
4445
  const result = await ctx.mgQuery('MATCH (t:Task {id: $id, companyId: $cid}) RETURN t', { id: taskId, cid });
3667
4446
  if (result.rows && result.rows.length > 0) {
@@ -3842,6 +4621,7 @@ function startApi(mgQuery, config = {}, state = {}) {
3842
4621
  invalidateContextCache,
3843
4622
  semanticUpdater: container?.cradle?.projectSemanticUpdater ?? null,
3844
4623
  driftDetector: container?.cradle?.projectDriftDetector ?? null,
4624
+ hboStore,
3845
4625
  });
3846
4626
  // Dept Catchball pitch — shares same semanticUpdater from container
3847
4627
  deptRoute = require('./routes/dept')({
@@ -3852,10 +4632,25 @@ function startApi(mgQuery, config = {}, state = {}) {
3852
4632
  } catch (e) {
3853
4633
  // container.js not yet built — projectRoute falls back to () => false
3854
4634
  const { invalidateContextCache } = require('./context-enrichment');
3855
- projectRoute = require('./routes/project')({ mgQuery, broadcast: state.broadcast ?? (() => {}), invalidateContextCache });
4635
+ projectRoute = require('./routes/project')({ mgQuery, broadcast: state.broadcast ?? (() => {}), invalidateContextCache, hboStore });
3856
4636
  deptRoute = require('./routes/dept')({ mgQuery, broadcast: state.broadcast ?? (() => {}) });
3857
4637
  }
3858
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
+
3859
4654
  // HED routes — requires mgQuery for HEDEngine
3860
4655
  try {
3861
4656
  const { createHedRoutes } = require('./routes/hed');
@@ -3870,7 +4665,7 @@ function startApi(mgQuery, config = {}, state = {}) {
3870
4665
  try { channelsRoute = require('./routes/channels')({ mgQuery }); } catch(e) { log('warn', `channels route unavailable: ${e.message}`); }
3871
4666
  try { emailInfraRoute = require('./routes/email-infrastructure')({ mgQuery }); } catch(e) { log('warn', `emailInfra route unavailable: ${e.message}`); }
3872
4667
 
3873
- const port = config.apiPort ?? 9091;
4668
+ const port = config.apiPort ?? 9093;
3874
4669
 
3875
4670
  // Security: shared-secret token for WebSocket upgrade.
3876
4671
  // Mirrors GHSA-3c6j-hq33-3jv4 (forged exec lifecycle events via unauthenticated WS).
@@ -3882,10 +4677,13 @@ function startApi(mgQuery, config = {}, state = {}) {
3882
4677
  mgQuery,
3883
4678
  companies: state.companies ?? [],
3884
4679
  cid: state.companies?.[0]?.id ?? 'default-company', // default — overridden per-request in route()
4680
+ apiPort: port,
4681
+ actualBoundPort: null,
3885
4682
  tickCount: 0,
3886
4683
  draining: false,
3887
4684
  apiToken: config.apiToken || null, // null = no auth (backward compat)
3888
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
3889
4687
  modules: [
3890
4688
  'RoutineEvaluator', 'BudgetEnforcer', 'LivenessWatchdog',
3891
4689
  'AgentDispatcher', 'ActivityLogger', 'TaskCompletionWatchdog',
@@ -4000,11 +4798,12 @@ function startApi(mgQuery, config = {}, state = {}) {
4000
4798
  });
4001
4799
 
4002
4800
  server.listen(port, config.apiHost ?? '127.0.0.1', () => {
4801
+ ctx.actualBoundPort = server.address()?.port ?? port;
4003
4802
  process.stdout.write(JSON.stringify({
4004
4803
  ts: new Date().toISOString(),
4005
4804
  level: 'info',
4006
4805
  module: 'helios-api',
4007
- msg: `REST API listening on http://127.0.0.1:${port}`,
4806
+ msg: `REST API listening on http://127.0.0.1:${ctx.actualBoundPort}`,
4008
4807
  }) + '\n');
4009
4808
 
4010
4809
  // Write daemon.lock — the ground-truth discovery file for Helios Desktop.