@cgh567/agent 2.4.1 → 2.4.3

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 (169) hide show
  1. package/bin/helios +0 -0
  2. package/bin/helios-rpc-node-wrapper.cjs +0 -0
  3. package/bin/helios-rpc-wrapper.sh +0 -0
  4. package/daemon/adapters/helios-rpc-adapter.js +47 -25
  5. package/daemon/adapters/tui_wakeup.js +8 -0
  6. package/daemon/config/com.familiar.helios-daemon.plist +5 -0
  7. package/daemon/config/helios-daemon.service +4 -0
  8. package/daemon/context-enrichment.js +59 -21
  9. package/daemon/daemon-manager.js +1 -1
  10. package/daemon/db/email-infrastructure-migrate.js +192 -0
  11. package/daemon/db/hbo-core-migrate.js +189 -0
  12. package/daemon/helios-api.js +723 -57
  13. package/daemon/helios-company-daemon.js +616 -134
  14. package/daemon/lib/harada/cascade-judge.js +12 -50
  15. package/daemon/lib/harada/mandala.js +20 -0
  16. package/daemon/lib/harada/pillar-dispatcher.js +1 -1
  17. package/daemon/lib/harada/project-factory.js +7 -2
  18. package/daemon/lib/hbo-bridge.js +32 -13
  19. package/daemon/lib/hed-engine.js +10 -292
  20. package/daemon/lib/helios-hitl-host.js +15 -2
  21. package/daemon/lib/hitl-interaction-service.js +0 -0
  22. package/daemon/lib/memgraph-verify.js +38 -33
  23. package/daemon/lib/project-drift-detector.js +7 -17
  24. package/daemon/lib/project-semantic-updater.js +1 -14
  25. package/daemon/lib/task-completion-processor.js +11 -0
  26. package/daemon/lib/wizard-engine.js +57 -6
  27. package/daemon/routes/channels.js +10 -5
  28. package/daemon/routes/harada-map.js +11 -48
  29. package/daemon/routes/hbo.js +342 -75
  30. package/daemon/routes/hitl.js +0 -0
  31. package/daemon/routes/project.js +194 -62
  32. package/daemon/routes/routines.js +14 -0
  33. package/daemon/routes/tasks.js +15 -1
  34. package/daemon/routes/wizard.js +11 -4
  35. package/daemon/schema-apply.js +174 -0
  36. package/daemon/schema-definitions.js +423 -0
  37. package/daemon/schema-migrations-hbo.js +10 -0
  38. package/daemon/schema-migrations-hed.js +18 -0
  39. package/daemon/schema-migrations-hitl.js +0 -0
  40. package/daemon/schema-migrations-proj.js +131 -0
  41. package/extensions/001-tool-output-cap.ts +0 -0
  42. package/extensions/context-compaction.ts +45 -26
  43. package/extensions/cortex/activation-bridge.ts +5 -0
  44. package/extensions/cortex/learn.ts +26 -0
  45. package/extensions/cortex/wal-replay.ts +91 -0
  46. package/extensions/email/backfill.ts +0 -0
  47. package/extensions/helios-governance/analysis/ambiguity.ts +0 -0
  48. package/extensions/helios-governance/analysis/compliance.ts +0 -0
  49. package/extensions/helios-governance/analysis/long-task-detector.ts +0 -0
  50. package/extensions/helios-governance/analysis/output-contract.ts +0 -0
  51. package/extensions/helios-governance/analysis/patterns.ts +0 -0
  52. package/extensions/helios-governance/analysis/preflight.ts +0 -0
  53. package/extensions/helios-governance/analysis/recurring-violations.ts +0 -0
  54. package/extensions/helios-governance/analysis/task-classification.ts +0 -0
  55. package/extensions/helios-governance/analysis/task-intent.ts +0 -0
  56. package/extensions/helios-governance/gates/high-impact.ts +1 -1
  57. package/extensions/helios-governance/handlers/_jiti-require.ts +15 -8
  58. package/extensions/helios-governance/handlers/proxy-test-detector.ts +0 -0
  59. package/extensions/hema-dispatch-v3/graph-memory.ts +10 -0
  60. package/extensions/hema-dispatch-v3/index.ts +72 -47
  61. package/extensions/lib/elo-engine.js +0 -0
  62. package/extensions/lib/elo-engine.test.js +0 -0
  63. package/extensions/memgraph-autostart.ts +13 -0
  64. package/extensions/neuroplastic-eval.ts +0 -0
  65. package/extensions/shadow-loop/index.ts +0 -0
  66. package/extensions/warm-tick/warm-tick-maintenance.ts +8 -0
  67. package/lib/__tests__/hbo-core-store.test.js +238 -0
  68. package/lib/brain-v2-budget.js +0 -0
  69. package/lib/brain-v2-circuit-breaker.js +0 -0
  70. package/lib/brain-v2.js +0 -0
  71. package/lib/broker/adaptive-throttle.js +0 -0
  72. package/lib/broker/batch-coalescer.js +0 -0
  73. package/lib/broker/bulkhead.js +0 -0
  74. package/lib/broker/channel-registry.js +0 -0
  75. package/lib/broker/circuit-breaker.js +0 -0
  76. package/lib/broker/evidence-cache.js +0 -0
  77. package/lib/broker/health-monitor.js +0 -0
  78. package/lib/broker/mage-queue.js +0 -0
  79. package/lib/broker/priority-queue.js +0 -0
  80. package/lib/broker/server.js.bak-error2-fix +0 -0
  81. package/lib/broker/session-registry.js +0 -0
  82. package/lib/broker/singleton-timers.js +0 -0
  83. package/lib/broker/types.d.ts +0 -0
  84. package/lib/broker/vegas-limit.js +0 -0
  85. package/lib/compression/dist/ccr-store.js +74 -0
  86. package/lib/compression/dist/content-router.js +115 -0
  87. package/lib/compression/dist/pipeline.js +113 -0
  88. package/lib/compression/dist/server.js +265 -0
  89. package/lib/compression/dist/smart-crusher.js +251 -0
  90. package/lib/context-budget.ts +0 -0
  91. package/lib/context-firewall.js +0 -0
  92. package/lib/crm/integration/triage-bridge.js +0 -0
  93. package/lib/email-utils.ts +0 -0
  94. package/lib/eval/__tests__/preflight-checker.test.ts +0 -0
  95. package/lib/eval/__tests__/task-instruction-parser.test.ts +0 -0
  96. package/lib/eval/__tests__/verifier-runner.test.ts +0 -0
  97. package/lib/eval/index.ts +0 -0
  98. package/lib/eval/preflight-checker.ts +0 -0
  99. package/lib/eval/task-domain-classifier.ts +0 -0
  100. package/lib/eval/task-instruction-parser.ts +0 -0
  101. package/lib/eval/verifier-runner.ts +0 -0
  102. package/lib/event-bus.d.ts +0 -0
  103. package/lib/event-bus.mts +1 -1
  104. package/lib/governance-context-selector.ts +0 -0
  105. package/lib/graph/generate-extension-embeddings.js +0 -0
  106. package/lib/graph/generate-static-embeddings.js +0 -0
  107. package/lib/graph/lib/utils.js +1 -1
  108. package/lib/graph-audit.d.ts +0 -0
  109. package/lib/graph-availability.js +62 -0
  110. package/lib/hbo-core-store.compiled.js +834 -0
  111. package/lib/hbo-core-store.js +124 -0
  112. package/lib/hbo-core-store.ts +908 -0
  113. package/lib/mesh-circuit-breaker.js +0 -0
  114. package/lib/mission-loop/lesson-extractor.ts +0 -0
  115. package/lib/mission-loop/mental-model-scorer.ts +0 -0
  116. package/lib/mission-loop/occ-detector.ts +0 -0
  117. package/lib/mission-loop/query-variants.ts +0 -0
  118. package/lib/mission-loop/verifier-check.ts +0 -0
  119. package/lib/skill-reference-builder.ts +0 -0
  120. package/lib/telemetry/token-breakdown.ts +0 -0
  121. package/lib/tool-compressor.ts +0 -0
  122. package/lib/triage-core/classifier.ts +3 -2
  123. package/lib/triage-core/graph/schema.cypher +10 -0
  124. package/lib/triage-core/legal-routing.ts +0 -0
  125. package/lib/triage-core/mental-model/dunbar-classifier.ts +0 -0
  126. package/lib/triage-core/mental-model/enrich-all.ts +0 -0
  127. package/lib/triage-core/mental-model/identity-resolver.ts +0 -0
  128. package/lib/triage-core/mental-model/key-facts.ts +1 -2
  129. package/lib/triage-core/mental-model/model-assembler.ts +0 -0
  130. package/lib/triage-core/orchestrator.ts +4 -11
  131. package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -0
  132. package/package.json +18 -8
  133. package/skills/helios-business-operator/services/signals/upwork-signals.js +0 -0
  134. package/skills/talisman-ceo/SKILL.md +23 -25
  135. package/skills/talisman-comms/SKILL.md +5 -5
  136. package/skills/talisman-engineering/SKILL.md +5 -5
  137. package/skills/talisman-finance/SKILL.md +10 -8
  138. package/skills/talisman-marketing/SKILL.md +10 -10
  139. package/skills/talisman-sales/SKILL.md +12 -15
  140. package/skills/talisman-support/SKILL.md +5 -5
  141. package/agents/business/talisman-ceo.md +0 -183
  142. package/agents/business/talisman-comms.md +0 -257
  143. package/agents/business/talisman-cto.md +0 -153
  144. package/agents/business/talisman-finance.md +0 -246
  145. package/agents/business/talisman-marketing.md +0 -240
  146. package/agents/business/talisman-sales.md +0 -242
  147. package/agents/business/talisman-support.md +0 -236
  148. package/daemon/lib/approval-expiry.js +0 -162
  149. package/daemon/lib/blast-radius-analyzer.js +0 -75
  150. package/daemon/lib/domain-bootstrap-orchestrator.js +0 -267
  151. package/daemon/lib/forensic-log.js +0 -113
  152. package/daemon/lib/goal-research-pipeline.js +0 -644
  153. package/daemon/lib/harada/cascade-research-dispatcher.js +0 -261
  154. package/daemon/lib/headroom-middleware.js +0 -167
  155. package/daemon/lib/headroom-proxy-manager.js +0 -623
  156. package/daemon/lib/mental-model-cache.js +0 -96
  157. package/daemon/lib/project-factory.js +0 -47
  158. package/daemon/lib/session-log-reader.js +0 -93
  159. package/daemon/routes/hed.js +0 -133
  160. package/lib/graph/learning/headroom-learn-bridge.js +0 -215
  161. package/skills/helios-bookkeeping/SKILL.md +0 -321
  162. package/skills/helios-briefer/SKILL.md +0 -44
  163. package/skills/helios-client-relations/SKILL.md +0 -322
  164. package/skills/helios-personal-triager/SKILL.md +0 -45
  165. package/skills/helios-recruitment/SKILL.md +0 -317
  166. package/skills/helios-relationship-nudger/SKILL.md +0 -77
  167. package/skills/helios-researcher/SKILL.md +0 -44
  168. package/skills/helios-scheduler/SKILL.md +0 -58
  169. package/skills/helios-tax-analyst/SKILL.md +0 -280
@@ -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
@@ -42,13 +42,14 @@ const fs = require('fs');
42
42
  const path = require('path');
43
43
  const crypto = require('crypto');
44
44
  const { TranscriptStore } = require('./transcript-store');
45
+ const hboStore = require('../lib/hbo-core-store');
45
46
 
46
47
  const TRANSCRIPTS_DIR = path.join(__dirname, 'transcripts');
47
48
  const _transcriptStore = new TranscriptStore(TRANSCRIPTS_DIR);
48
49
  const CORS_HEADERS = {
49
50
  'Access-Control-Allow-Origin': '*',
50
- 'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS',
51
- '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',
52
53
  };
53
54
 
54
55
  // ── Logging ────────────────────────────────────────────────────────────────────────────────
@@ -60,6 +61,14 @@ function log(level, msg, extra) {
60
61
  else process.stdout.write(JSON.stringify(entry) + '\n');
61
62
  }
62
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
+
63
72
  // ── SSE Client Registry ───────────────────────────────────────────────────────
64
73
 
65
74
  const sseClients = new Set();
@@ -259,6 +268,7 @@ async function handleHealth(req, res, ctx) {
259
268
  const { tickCount, modules } = ctx;
260
269
  jsonResponse(res, 200, {
261
270
  status: 'ok',
271
+ port: ctx.actualBoundPort ?? ctx.apiPort ?? null,
262
272
  tick: tickCount ?? 0,
263
273
  modules: modules ?? [],
264
274
  timestamp: new Date().toISOString(),
@@ -280,6 +290,26 @@ async function handleGetTasks(req, res, ctx) {
280
290
  let cypher;
281
291
  const params = { limit, cid: ctx.cid };
282
292
 
293
+ // SQLite-first task list read (P2-9)
294
+ try {
295
+ const _storeTasks = hboStore.getTasksByCompanyStatus
296
+ ? (q.status
297
+ ? hboStore.getTasksByCompanyStatus(ctx.cid, q.status)
298
+ : (() => { try { return require('../lib/hbo-core-store').getTasksByCompanyStatus(ctx.cid, ['todo','in_progress','done','andon_paused','help_pending','cancelled']); } catch(_) { return null; } })()
299
+ )
300
+ : null;
301
+ if (_storeTasks) {
302
+ let tasks = _storeTasks;
303
+ if (q.agentId) tasks = tasks.filter(t => t.assigneeAgentId === q.agentId);
304
+ tasks = tasks
305
+ .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
306
+ .slice(0, limit)
307
+ .map(t => ({ id: t.id, title: t.title, status: t.status, priority: t.priority, assignee: t.assigneeAgentId, body: t.body ?? null }));
308
+ jsonResponse(res, 200, { tasks, count: tasks.length });
309
+ return;
310
+ }
311
+ } catch (_) {}
312
+
283
313
  if (q.status && q.agentId) {
284
314
  cypher = `MATCH (t:Task {companyId: $cid, status: $status, assigneeAgentId: $agentId})
285
315
  RETURN t.id AS id, t.title AS title, t.status AS status,
@@ -353,13 +383,58 @@ async function handleCreateTask(req, res, ctx) {
353
383
  const cid = ctx.cid;
354
384
 
355
385
  const body = await parseBody(req);
356
- const { title, assigneeAgentId, priority, body: taskBody } = body;
386
+ const { title, assigneeAgentId, priority, body: taskBody, executionPolicy } = body;
357
387
 
358
388
  if (!title || typeof title !== 'string' || title.trim() === '') {
359
389
  jsonResponse(res, 400, { error: 'title is required and must be a non-empty string' });
360
390
  return;
361
391
  }
362
392
 
393
+ // P4-02: validate executionPolicy if provided
394
+ let validatedPolicyJson = null;
395
+ if (executionPolicy !== undefined) {
396
+ if (!executionPolicy || typeof executionPolicy !== 'object' || !Array.isArray(executionPolicy.stages)) {
397
+ jsonResponse(res, 400, { error: 'executionPolicy must have a stages array' });
398
+ return;
399
+ }
400
+ if (executionPolicy.stages.length === 0) {
401
+ jsonResponse(res, 400, { error: 'executionPolicy.stages must not be empty' });
402
+ return;
403
+ }
404
+ for (const stage of executionPolicy.stages) {
405
+ if (!stage.type || !['review', 'approval'].includes(stage.type)) {
406
+ jsonResponse(res, 400, { error: `stage.type must be 'review' or 'approval', got: ${stage.type}` });
407
+ return;
408
+ }
409
+ if (!Array.isArray(stage.participants) || stage.participants.length === 0) {
410
+ jsonResponse(res, 400, { error: 'each stage must have a non-empty participants array' });
411
+ return;
412
+ }
413
+ }
414
+ // M-1 fix: validate ALL participants in a single batched query instead of N sequential queries
415
+ const allParticipantIds = [...new Set(
416
+ executionPolicy.stages.flatMap(s => s.participants.map(p => String(p)))
417
+ )];
418
+ const agentCheckResult = await mgQuery(
419
+ `MATCH (a:BusinessAgent {companyId: $cid}) WHERE a.id IN $pids RETURN collect(a.id) AS found`,
420
+ { cid, pids: allParticipantIds }
421
+ );
422
+ const foundIds = new Set(
423
+ (parseRows(agentCheckResult)[0]?.[0] ?? parseRows(agentCheckResult)[0]?.['found'] ?? [])
424
+ );
425
+ const missingIds = allParticipantIds.filter(id => !foundIds.has(id));
426
+ if (missingIds.length > 0) {
427
+ jsonResponse(res, 400, { error: `participants not found as BusinessAgents in this company: ${missingIds.join(', ')}` });
428
+ return;
429
+ }
430
+ validatedPolicyJson = JSON.stringify(executionPolicy);
431
+ }
432
+
433
+ // P4-03: initial executionState — 'idle' when policy exists, null string otherwise
434
+ const initialStateJson = validatedPolicyJson
435
+ ? JSON.stringify({ status: 'idle', currentStageIndex: null, currentParticipant: null, returnAssignee: null, completedStageIds: [] })
436
+ : 'null';
437
+
363
438
  const taskId = `task:api:${crypto.randomUUID()}`;
364
439
  const resolvedPriority = Number.isFinite(Number(priority)) ? Number(priority) : 2;
365
440
 
@@ -373,6 +448,8 @@ async function handleCreateTask(req, res, ctx) {
373
448
  priority: toInteger($priority),
374
449
  assigneeAgentId: $assigneeAgentId,
375
450
  body: $body,
451
+ executionPolicyJson: $policyJson,
452
+ executionStateJson: $stateJson,
376
453
  createdAt: datetime()
377
454
  })`,
378
455
  {
@@ -382,11 +459,26 @@ async function handleCreateTask(req, res, ctx) {
382
459
  priority: resolvedPriority,
383
460
  assigneeAgentId: assigneeAgentId ? String(assigneeAgentId) : null,
384
461
  body: taskBody ? String(taskBody) : null,
462
+ policyJson: validatedPolicyJson,
463
+ stateJson: initialStateJson,
385
464
  }
386
465
  );
387
466
 
467
+ mirrorHboStore('task.create', () => hboStore.createTask?.({
468
+ id: taskId,
469
+ companyId: cid,
470
+ title: String(title).trim(),
471
+ status: 'todo',
472
+ priority: resolvedPriority,
473
+ assigneeAgentId: assigneeAgentId ? String(assigneeAgentId) : null,
474
+ body: taskBody ? String(taskBody) : null,
475
+ executionPolicyJson: validatedPolicyJson,
476
+ executionStateJson: initialStateJson,
477
+ createdAt: Date.now(),
478
+ }));
479
+
388
480
  broadcast({ type: 'task.created', taskId, status: 'todo', companyId: cid });
389
- jsonResponse(res, 201, { task: { id: taskId, title, status: 'todo', priority: resolvedPriority, assigneeAgentId } });
481
+ jsonResponse(res, 201, { task: { id: taskId, title, status: 'todo', priority: resolvedPriority, assigneeAgentId, executionPolicyJson: validatedPolicyJson } });
390
482
  } catch (err) {
391
483
  jsonResponse(res, 500, { error: `Create failed: ${err.message}` });
392
484
  }
@@ -401,7 +493,8 @@ async function handleUpdateTask(req, res, ctx, taskId) {
401
493
  const body = await parseBody(req);
402
494
  const { status, priority } = body;
403
495
 
404
- const VALID_STATUSES = new Set(['todo', 'in_progress', 'done', 'failed', 'cancelled', 'blocked', 'escalated']);
496
+ // P4-01: 'in_review' added allows execution policy gate to hold a task for review
497
+ const VALID_STATUSES = new Set(['todo', 'in_progress', 'done', 'failed', 'cancelled', 'blocked', 'escalated', 'in_review']);
405
498
  if (status !== undefined && !VALID_STATUSES.has(status)) {
406
499
  jsonResponse(res, 400, { error: `Invalid status. Allowed: ${[...VALID_STATUSES].join(', ')}` });
407
500
  return;
@@ -417,6 +510,259 @@ async function handleUpdateTask(req, res, ctx, taskId) {
417
510
  }
418
511
 
419
512
  try {
513
+ // ─────────────────────────────────────────────────────────────────────────
514
+ // EXECUTION POLICY STATE MACHINE
515
+ //
516
+ // The state machine has three actors:
517
+ // A) Original executor sets status='done' and task has an active policy
518
+ // → INTERCEPT: redirect to in_review, assign to current stage reviewer
519
+ // B) Reviewer (currently assigned) sets status='done' to approve
520
+ // → ADVANCE: move to next stage, or if last stage, allow done
521
+ // C) Reviewer sets any non-done status to reject/request changes
522
+ // → CHANGES_REQUESTED: return task to original executor
523
+ //
524
+ // C-1 fix: The intercept (actor A) uses TWO queries, but the second (SET) query
525
+ // re-checks the WHERE condition with the expected policyJson — if policy changed
526
+ // between reads, the second query returns 0 rows and we return 409 (optimistic
527
+ // concurrency control). This correctly closes the TOCTOU window.
528
+ // ─────────────────────────────────────────────────────────────────────────
529
+
530
+ if (status === 'done') {
531
+ // Step 1: Read task's current state (fast metadata read)
532
+ const taskReadResult = await mgQuery(
533
+ `MATCH (t:Task {id: $id, companyId: $cid})
534
+ RETURN t.status AS taskStatus,
535
+ t.assigneeAgentId AS assigneeAgentId,
536
+ t.executionPolicyJson AS policyJson,
537
+ t.executionStateJson AS stateJson`,
538
+ { id: taskId, cid }
539
+ );
540
+ const taskReadRows = parseRows(taskReadResult);
541
+
542
+ if (taskReadRows.length > 0) {
543
+ const taskRow = taskReadRows[0];
544
+ const taskStatus = taskRow[0] ?? taskRow['taskStatus'] ?? null;
545
+ const returnAssignee = taskRow[1] ?? taskRow['assigneeAgentId'] ?? null;
546
+ const policyJson = taskRow[2] ?? taskRow['policyJson'] ?? null;
547
+ const stateJsonRaw = taskRow[3] ?? taskRow['stateJson'] ?? 'null';
548
+
549
+ let policy, state;
550
+ try { policy = policyJson ? JSON.parse(policyJson) : null; } catch (_) { policy = null; }
551
+ try { state = JSON.parse(stateJsonRaw); } catch (_) { state = null; }
552
+ if (state === null && stateJsonRaw === 'null') state = null; // explicit null string
553
+
554
+ // ── ACTOR B: Reviewer approving (task is currently in_review) ───────
555
+ if (taskStatus === 'in_review' && policy && state) {
556
+ const currentStageIdx = typeof state.currentStageIndex === 'number' ? state.currentStageIndex : 0;
557
+ const stages = Array.isArray(policy.stages) ? policy.stages : [];
558
+ const nextStageIdx = currentStageIdx + 1;
559
+
560
+ if (nextStageIdx < stages.length) {
561
+ // Advance to next stage (multi-stage full advancement — C-4 full)
562
+ const nextStage = stages[nextStageIdx];
563
+ const nextParticipant = nextStage?.participants?.[0] ?? null;
564
+ if (nextParticipant) {
565
+ const newState = JSON.stringify({
566
+ status: 'pending',
567
+ currentStageIndex: nextStageIdx,
568
+ currentParticipant: nextParticipant,
569
+ returnAssignee: state.returnAssignee,
570
+ completedStageIds: [...(state.completedStageIds ?? []), `stage:${currentStageIdx}`],
571
+ });
572
+ // Atomic: re-check we're still in_review before advancing
573
+ const advanceResult = await mgQuery(
574
+ `MATCH (t:Task {id: $id, companyId: $cid})
575
+ WHERE t.status = 'in_review' AND t.executionPolicyJson = $expectedPolicy
576
+ SET t.executionStateJson = $stateJson,
577
+ t.assigneeAgentId = $nextReviewer,
578
+ t.updatedAt = datetime()
579
+ RETURN t.id`,
580
+ { id: taskId, cid, expectedPolicy: policyJson, stateJson: newState, nextReviewer: nextParticipant }
581
+ );
582
+ if (parseRows(advanceResult).length === 0) {
583
+ jsonResponse(res, 409, { error: 'Concurrent update conflict' });
584
+ return;
585
+ }
586
+ mgQuery(
587
+ `MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
588
+ OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
589
+ WITH a, existing WHERE existing IS NULL
590
+ CREATE (s:AgentReadySignal {id: $sigId, agentId: $agentId, companyId: $cid,
591
+ status: 'pending', claimedBy: null, createdAt: datetime()})`,
592
+ { agentId: nextParticipant, cid, sigId: `ars:advance:${taskId}:${crypto.randomUUID()}` }
593
+ ).catch(e => log('warn', `P4 advance AgentReadySignal failed: ${e.message}`));
594
+ mirrorHboStore('task.update.advance_stage', () => hboStore.updateTask?.(taskId, cid, { executionStateJson: newState, assigneeAgentId: nextParticipant }));
595
+ broadcast({ type: 'task.updated', taskId, status: 'in_review', companyId: cid });
596
+ jsonResponse(res, 200, { updated: true, taskId, advanced: true, nextStage: nextStageIdx, nextReviewer: nextParticipant });
597
+ return;
598
+ }
599
+ }
600
+
601
+ // All stages passed (or last stage reviewer approved) — mark completed and allow done
602
+ const completedState = JSON.stringify({
603
+ ...state,
604
+ status: 'completed',
605
+ completedStageIds: [...(state.completedStageIds ?? []), `stage:${currentStageIdx}`],
606
+ });
607
+ // Atomic: re-check still in_review before marking completed
608
+ const completeResult = await mgQuery(
609
+ `MATCH (t:Task {id: $id, companyId: $cid})
610
+ WHERE t.status = 'in_review' AND t.executionPolicyJson = $expectedPolicy
611
+ SET t.status = 'done',
612
+ t.executionStateJson = $stateJson,
613
+ t.updatedAt = datetime()
614
+ RETURN t.id`,
615
+ { id: taskId, cid, expectedPolicy: policyJson, stateJson: completedState }
616
+ );
617
+ if (parseRows(completeResult).length === 0) {
618
+ jsonResponse(res, 409, { error: 'Concurrent update conflict' });
619
+ return;
620
+ }
621
+ mirrorHboStore('task.update.approved', () => hboStore.updateTask?.(taskId, cid, { status: 'done', executionStateJson: completedState }));
622
+ broadcast({ type: 'task.updated', taskId, status: 'done', companyId: cid });
623
+ jsonResponse(res, 200, { updated: true, taskId, approved: true, status: 'done' });
624
+ return;
625
+ }
626
+
627
+ // ── ACTOR A: Original executor marking done with active policy ───────
628
+ if (taskStatus !== 'in_review' && policy) {
629
+ // Check if state is already completed — if so, allow done through
630
+ const alreadyCompleted = state && typeof state === 'object' && state.status === 'completed';
631
+ if (!alreadyCompleted) {
632
+ const firstStage = Array.isArray(policy.stages) && policy.stages.length > 0 ? policy.stages[0] : null;
633
+ const firstParticipant = firstStage?.participants?.[0] ?? null;
634
+
635
+ // C-2 fix: explicit guard — if no valid participant, log and fall through to done
636
+ if (!firstParticipant) {
637
+ log('warn', `P4: task ${taskId} has executionPolicy but no valid first participant — allowing done through`);
638
+ // fall through to standard path below
639
+ } else {
640
+ const newState = JSON.stringify({
641
+ status: 'pending',
642
+ currentStageIndex: 0,
643
+ currentParticipant: firstParticipant,
644
+ returnAssignee, // captured returnAssignee = original executor
645
+ completedStageIds: [],
646
+ });
647
+ // C-1 fix: ATOMIC check+set — WHERE re-checks policyJson so a concurrent
648
+ // policy change causes 0 rows returned → 409 (optimistic concurrency control)
649
+ const atomicResult = await mgQuery(
650
+ `MATCH (t:Task {id: $id, companyId: $cid})
651
+ WHERE t.executionPolicyJson = $expectedPolicy
652
+ AND t.status <> 'in_review'
653
+ AND (t.executionStateJson IS NULL OR NOT t.executionStateJson CONTAINS '"status":"completed"')
654
+ SET t.status = 'in_review',
655
+ t.executionStateJson = $stateJson,
656
+ t.assigneeAgentId = $reviewer,
657
+ t.updatedAt = datetime()
658
+ RETURN t.id AS id`,
659
+ { id: taskId, cid, expectedPolicy: policyJson, stateJson: newState, reviewer: firstParticipant }
660
+ );
661
+ if (parseRows(atomicResult).length === 0) {
662
+ // Policy changed or state changed between reads — 409
663
+ jsonResponse(res, 409, { error: 'Concurrent update conflict — task state changed by another request' });
664
+ return;
665
+ }
666
+ mgQuery(
667
+ `MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
668
+ OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
669
+ WITH a, existing WHERE existing IS NULL
670
+ CREATE (s:AgentReadySignal {id: $sigId, agentId: $agentId, companyId: $cid,
671
+ status: 'pending', claimedBy: null, createdAt: datetime()})`,
672
+ { agentId: firstParticipant, cid, sigId: `ars:review:${taskId}:${crypto.randomUUID()}` }
673
+ ).catch(e => log('warn', `P4-04 AgentReadySignal emit failed: ${e.message}`));
674
+ mirrorHboStore('task.update.in_review', () => hboStore.updateTask?.(taskId, cid, { status: 'in_review', executionStateJson: newState, assigneeAgentId: firstParticipant }));
675
+ broadcast({ type: 'task.updated', taskId, status: 'in_review', companyId: cid });
676
+ jsonResponse(res, 200, { updated: true, taskId, intercepted: true, status: 'in_review', reviewer: firstParticipant });
677
+ return;
678
+ }
679
+ }
680
+ }
681
+ }
682
+ }
683
+
684
+ // ── ACTOR C: Reviewer rejecting / requesting changes ───────────────────
685
+ // C-3 fix: WHERE t.status = 'in_review' added to the SET query (prevents TOCTOU
686
+ // overwriting a task that was concurrently moved out of in_review by another request).
687
+ // FIX-H1: Check stage type to differentiate review vs approval semantics.
688
+ // FIX-M2: Explicit return for state=null case — no fall-through.
689
+ if (status !== undefined && status !== 'done') {
690
+ // Single atomic read+write: check in_review AND apply in one round-trip
691
+ // by embedding the WHERE in the SET query.
692
+ const reviewCheckResult = await mgQuery(
693
+ `MATCH (t:Task {id: $id, companyId: $cid})
694
+ WHERE t.status = 'in_review' AND t.executionStateJson IS NOT NULL
695
+ RETURN t.executionStateJson AS stateJson, t.executionPolicyJson AS policyJson`,
696
+ { id: taskId, cid }
697
+ );
698
+ const reviewRows = parseRows(reviewCheckResult);
699
+ if (reviewRows.length > 0) {
700
+ const reviewRow = reviewRows[0];
701
+ const stateJsonStr = reviewRow[0] ?? reviewRow['stateJson'] ?? 'null';
702
+ const policyJsonStr = reviewRow[1] ?? reviewRow['policyJson'] ?? null;
703
+ let state, policy;
704
+ try { state = JSON.parse(stateJsonStr); } catch (_) { state = null; }
705
+ try { policy = policyJsonStr ? JSON.parse(policyJsonStr) : null; } catch (_) { policy = null; }
706
+
707
+ // M2 fix: if state is null (corrupt JSON), block the standard path on in_review tasks
708
+ if (!state) {
709
+ jsonResponse(res, 409, { error: 'Task is in_review but execution state is corrupt — cannot apply status change' });
710
+ return;
711
+ }
712
+
713
+ if (state.returnAssignee) {
714
+ const returnAssignee = state.returnAssignee;
715
+ const changesState = JSON.stringify({ ...state, status: 'changes_requested' });
716
+ // C-3 fix: WHERE t.status = 'in_review' guards against concurrent status change
717
+ const changesResult = await mgQuery(
718
+ `MATCH (t:Task {id: $id, companyId: $cid})
719
+ WHERE t.status = 'in_review'
720
+ SET t.status = 'in_progress',
721
+ t.assigneeAgentId = $returnAssignee,
722
+ t.executionStateJson = $stateJson,
723
+ t.updatedAt = datetime()
724
+ RETURN t.id`,
725
+ { id: taskId, cid, returnAssignee, stateJson: changesState }
726
+ );
727
+ if (parseRows(changesResult).length === 0) {
728
+ // Concurrent write changed task out of in_review — fall through to standard path
729
+ // (the task is no longer in_review so standard update is correct)
730
+ } else {
731
+ mgQuery(
732
+ `MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
733
+ OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
734
+ WITH a, existing WHERE existing IS NULL
735
+ CREATE (s:AgentReadySignal {id: $sigId, agentId: $agentId, companyId: $cid,
736
+ status: 'pending', claimedBy: null, createdAt: datetime()})`,
737
+ { agentId: returnAssignee, cid, sigId: `ars:changes:${taskId}:${crypto.randomUUID()}` }
738
+ ).catch(e => log('warn', `P4-05 AgentReadySignal emit failed: ${e.message}`));
739
+ mirrorHboStore('task.update.changes_requested', () => hboStore.updateTask?.(taskId, cid, { status: 'in_progress', executionStateJson: changesState, assigneeAgentId: returnAssignee }));
740
+ broadcast({ type: 'task.updated', taskId, status: 'in_progress', companyId: cid });
741
+ jsonResponse(res, 200, { updated: true, taskId, changesRequested: true, returnAssignee });
742
+ return;
743
+ }
744
+ } else {
745
+ // returnAssignee is null — escalation path (no valid return point)
746
+ // M2 fix: explicit return after escalation to prevent fall-through
747
+ const escalateResult = await mgQuery(
748
+ `MATCH (t:Task {id: $id, companyId: $cid})
749
+ WHERE t.status = 'in_review'
750
+ SET t.status = 'blocked', t.blockedReason = 'escalation_chain_exhausted', t.updatedAt = datetime()
751
+ RETURN t.id`,
752
+ { id: taskId, cid }
753
+ ).catch(() => null);
754
+ if (escalateResult && parseRows(escalateResult).length > 0) {
755
+ mirrorHboStore('task.escalation', () => hboStore.updateTask?.(taskId, cid, { status: 'blocked', blockedReason: 'escalation_chain_exhausted' }));
756
+ broadcast({ type: 'task.blocked', taskId, reason: 'escalation_chain_exhausted', companyId: cid });
757
+ jsonResponse(res, 200, { updated: true, taskId, escalated: true });
758
+ return;
759
+ }
760
+ // If escalate returned 0 rows, task was concurrently moved out of in_review — fall through
761
+ }
762
+ }
763
+ }
764
+
765
+ // Standard update path (no policy intercept applies)
420
766
  const setClauses = [];
421
767
  const params = { id: taskId, cid };
422
768
  if (status !== undefined) { setClauses.push('t.status = $status'); params.status = status; }
@@ -433,6 +779,11 @@ async function handleUpdateTask(req, res, ctx, taskId) {
433
779
  return;
434
780
  }
435
781
 
782
+ const storeUpdate = {};
783
+ if (status !== undefined) storeUpdate.status = status;
784
+ if (priority !== undefined) storeUpdate.priority = Number(priority);
785
+ mirrorHboStore('task.update', () => hboStore.updateTask?.(taskId, cid, storeUpdate));
786
+
436
787
  broadcast({ type: 'task.updated', taskId, ...(status !== undefined ? { status } : {}), companyId: cid });
437
788
  jsonResponse(res, 200, { updated: true, taskId });
438
789
  } catch (err) {
@@ -440,6 +791,76 @@ async function handleUpdateTask(req, res, ctx, taskId) {
440
791
  }
441
792
  }
442
793
 
794
+ // P4-07: PATCH /api/tasks/:id/policy — set or update executionPolicy on an existing task
795
+ async function handlePatchTaskPolicy(req, res, ctx, taskId) {
796
+ const { mgQuery } = ctx;
797
+ if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
798
+ if (!taskId) { jsonResponse(res, 400, { error: 'Missing task ID' }); return; }
799
+ const cid = ctx.cid;
800
+
801
+ const body = await parseBody(req);
802
+ const { policy } = body;
803
+
804
+ if (!policy || typeof policy !== 'object' || !Array.isArray(policy.stages)) {
805
+ jsonResponse(res, 400, { error: 'policy must have a stages array' });
806
+ return;
807
+ }
808
+ if (policy.stages.length === 0) {
809
+ jsonResponse(res, 400, { error: 'policy.stages must not be empty' });
810
+ return;
811
+ }
812
+ for (const stage of policy.stages) {
813
+ if (!stage.type || !['review', 'approval'].includes(stage.type)) {
814
+ jsonResponse(res, 400, { error: `stage.type must be 'review' or 'approval'` });
815
+ return;
816
+ }
817
+ if (!Array.isArray(stage.participants) || stage.participants.length === 0) {
818
+ jsonResponse(res, 400, { error: 'each stage must have a non-empty participants array' });
819
+ return;
820
+ }
821
+ }
822
+ // M-1 fix: validate ALL participants in a single batched query
823
+ const allPolicyPids = [...new Set(policy.stages.flatMap(s => s.participants.map(p => String(p))))];
824
+ const policyAgentCheck = await mgQuery(
825
+ `MATCH (a:BusinessAgent {companyId: $cid}) WHERE a.id IN $pids RETURN collect(a.id) AS found`,
826
+ { cid, pids: allPolicyPids }
827
+ );
828
+ const foundPolicyAgents = new Set(
829
+ (parseRows(policyAgentCheck)[0]?.[0] ?? parseRows(policyAgentCheck)[0]?.['found'] ?? [])
830
+ );
831
+ const missingPolicyAgents = allPolicyPids.filter(id => !foundPolicyAgents.has(id));
832
+ if (missingPolicyAgents.length > 0) {
833
+ jsonResponse(res, 400, { error: `participants not found as BusinessAgents: ${missingPolicyAgents.join(', ')}` });
834
+ return;
835
+ }
836
+
837
+ const policyJson = JSON.stringify(policy);
838
+ // H-5 fix: initial executionStateJson for the policy — ensures state machine starts correctly.
839
+ // Without this, existing tasks with executionStateJson='null' would have returnAssignee=null
840
+ // when P4-04 fires, causing immediate escalation instead of returning to the original executor.
841
+ const initialStateJson = JSON.stringify({ status: 'idle', currentStageIndex: null, currentParticipant: null, returnAssignee: null, completedStageIds: [] });
842
+ try {
843
+ const result = await mgQuery(
844
+ `MATCH (t:Task {id: $id, companyId: $cid})
845
+ SET t.executionPolicyJson = $policyJson,
846
+ t.executionStateJson = $stateJson,
847
+ t.updatedAt = datetime()
848
+ RETURN t.id`,
849
+ { id: taskId, cid, policyJson, stateJson: initialStateJson }
850
+ );
851
+ if (parseRows(result).length === 0) {
852
+ jsonResponse(res, 404, { error: `Task not found: ${taskId}` });
853
+ return;
854
+ }
855
+ // P4-08: SQLite write-through for policy and initial state
856
+ mirrorHboStore('task.policy', () => hboStore.updateTask?.(taskId, cid, { executionPolicyJson: policyJson, executionStateJson: initialStateJson }));
857
+ broadcast({ type: 'task.policy.updated', taskId, companyId: cid });
858
+ jsonResponse(res, 200, { updated: true, taskId, executionPolicyJson: policyJson });
859
+ } catch (err) {
860
+ jsonResponse(res, 500, { error: `Policy update failed: ${err.message}` });
861
+ }
862
+ }
863
+
443
864
  async function handleCancelTask(req, res, ctx, taskId) {
444
865
  const { mgQuery } = ctx;
445
866
  if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
@@ -460,6 +881,7 @@ async function handleCancelTask(req, res, ctx, taskId) {
460
881
  jsonResponse(res, 404, { error: `Task not found: ${taskId}` });
461
882
  return;
462
883
  }
884
+ mirrorHboStore('task.cancel', () => hboStore.updateTask?.(taskId, cid, { status: 'cancelled', cancelReason: 'api_cancel' }));
463
885
  broadcast({ type: 'task.cancelled', taskId, companyId: cid });
464
886
  jsonResponse(res, 200, { cancelled: true, taskId });
465
887
  } catch (err) {
@@ -503,6 +925,23 @@ async function handleGetApprovals(req, res, ctx) {
503
925
  let cypher;
504
926
  const params = { limit, cid: ctx.cid };
505
927
 
928
+ // SQLite-first approval list read (P2-9)
929
+ try {
930
+ const _storeApprovals = hboStore.getApprovalsByCompanyStatus
931
+ ? (q.status
932
+ ? hboStore.getApprovalsByCompanyStatus(ctx.cid, q.status)
933
+ : hboStore.getApprovalsByCompanyStatus(ctx.cid, ['pending','approved','rejected','expired'])
934
+ )
935
+ : null;
936
+ if (_storeApprovals) {
937
+ const approvals = _storeApprovals
938
+ .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
939
+ .slice(0, limit);
940
+ jsonResponse(res, 200, { approvals, count: approvals.length });
941
+ return;
942
+ }
943
+ } catch (_) {}
944
+
506
945
  if (q.status) {
507
946
  cypher = `MATCH (a:Approval {companyId: $cid, status: $status})${returnClause}`;
508
947
  params.status = q.status;
@@ -543,11 +982,12 @@ async function handleApproveApproval(req, res, ctx, approvalId) {
543
982
  RETURN a.id`,
544
983
  { id: approvalId, cid, answer }
545
984
  );
546
- const rows = parseRows(approveResult);
547
- if (rows.length === 0) {
548
- jsonResponse(res, 409, { error: `Approval not found or not in pending state: ${approvalId}` });
549
- return;
550
- }
985
+ const rows = parseRows(approveResult);
986
+ if (rows.length === 0) {
987
+ jsonResponse(res, 409, { error: `Approval not found or not in pending state: ${approvalId}` });
988
+ return;
989
+ }
990
+ mirrorHboStore('approval.approve', () => hboStore.updateApproval?.(approvalId, cid, { status: 'approved', answer, approvedAt: Date.now() }));
551
991
 
552
992
  // Synchronously update linked Strategy if this is a strategy_proposal
553
993
  try {
@@ -676,6 +1116,7 @@ async function handleRejectApproval(req, res, ctx, approvalId) {
676
1116
  jsonResponse(res, 409, { error: `Approval not found or not in pending state: ${approvalId}` });
677
1117
  return;
678
1118
  }
1119
+ mirrorHboStore('approval.reject', () => hboStore.updateApproval?.(approvalId, cid, { status: 'rejected', rejectReason: reason, rejectedAt: Date.now() }));
679
1120
  broadcast({ type: 'approval.rejected', approvalId, reason, companyId: cid });
680
1121
  jsonResponse(res, 200, { rejected: true, approvalId, reason });
681
1122
  } catch (err) {
@@ -2184,6 +2625,18 @@ async function handleCreateStrategy(req, res, ctx) {
2184
2625
  { id: approvalId, cid, approvalTitle: `Strategy: ${title}`, plan, proposedBy, strategyId }
2185
2626
  );
2186
2627
 
2628
+ mirrorHboStore('approval.create', () => hboStore.createApproval?.({
2629
+ id: approvalId,
2630
+ companyId: cid,
2631
+ type: 'strategy_proposal',
2632
+ title: `Strategy: ${title}`,
2633
+ description: plan,
2634
+ requestedBy: proposedBy,
2635
+ strategyId,
2636
+ status: 'pending',
2637
+ createdAt: Date.now(),
2638
+ }));
2639
+
2187
2640
  // Link goal to strategy
2188
2641
  await mgQuery(
2189
2642
  `MATCH (g:CompanyGoal {id: $goalId}), (s:Strategy {id: $strategyId})
@@ -2267,13 +2720,13 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
2267
2720
  const result = await mgQuery(
2268
2721
  `MATCH (rt:RoutineTrigger {publicId: $publicId, companyId: $cid})-[:TRIGGERS]->(r:Routine)
2269
2722
  RETURN rt.id AS triggerId, rt.routineId AS routineId, r.title AS routineTitle,
2270
- r.variables AS variables, r.companyId AS companyId`,
2723
+ r.variables AS variables, r.companyId AS companyId, rt.secret AS secret`,
2271
2724
  { publicId, cid: ctx.cid }
2272
2725
  );
2273
2726
  const rows = parseRows(result);
2274
2727
  if (rows.length === 0) return jsonResponse(res, 404, { error: 'Trigger not found' });
2275
2728
 
2276
- const keys = ['triggerId', 'routineId', 'routineTitle', 'variables', 'companyId'];
2729
+ const keys = ['triggerId', 'routineId', 'routineTitle', 'variables', 'companyId', 'secret'];
2277
2730
  const trigger = rowToObj(rows[0], keys);
2278
2731
 
2279
2732
  // S-06: Verify HMAC signature if trigger has a secret configured
@@ -2318,8 +2771,8 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
2318
2771
  }
2319
2772
  );
2320
2773
 
2321
- broadcast({ type: 'routine.fired', routineId: trigger.routineId, taskId, source: 'webhook', companyId: cid });
2322
- return jsonResponse(res, 200, { fired: true, taskId, routineId: trigger.routineId });
2774
+ broadcast({ type: 'routine.fired', routineId: trigger.routineId, taskId, source: 'webhook', companyId: trigger.companyId });
2775
+ return jsonResponse(res, 200, { fired: true, taskId, routineId: trigger.routineId });
2323
2776
  } catch (err) {
2324
2777
  return jsonResponse(res, 500, { error: err.message });
2325
2778
  }
@@ -2327,6 +2780,124 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
2327
2780
 
2328
2781
  // ── Routine Revisions & Runs ──────────────────────────────────────────────────
2329
2782
 
2783
+ // P5-01: GET /api/routines — list all routines for a company
2784
+ // SQL parity: falls back to hboStore.getRoutinesByCompany when Memgraph unavailable
2785
+ async function handleGetRoutines(req, res, ctx) {
2786
+ const { mgQuery } = ctx;
2787
+ const cid = ctx.cid;
2788
+ const url = new URL(req.url, 'http://localhost');
2789
+ const statusFilter = url.searchParams.get('status') || '';
2790
+
2791
+ if (mgQuery) {
2792
+ try {
2793
+ let cypher = `MATCH (r:Routine {companyId: $cid})`;
2794
+ const params = { cid };
2795
+ if (statusFilter) { cypher += ` WHERE r.status = $status`; params.status = statusFilter; }
2796
+ cypher += ` RETURN r.id, r.name, r.cronExpr, r.agentId, r.status, r.concurrencyPolicy,
2797
+ r.catchUpPolicy, r.catchUpCap, r.nextRunAt, r.lastRunAt, r.createdAt
2798
+ ORDER BY r.createdAt DESC`;
2799
+ const result = await mgQuery(cypher, params);
2800
+ const keys = ['id','name','cronExpr','agentId','status','concurrencyPolicy','catchUpPolicy','catchUpCap','nextRunAt','lastRunAt','createdAt'];
2801
+ const routines = parseRows(result).map(r => rowToObj(r, keys));
2802
+ return jsonResponse(res, 200, { routines, count: routines.length });
2803
+ } catch (e) {
2804
+ log('warn', `handleGetRoutines Memgraph failed, falling back to SQLite: ${e.message}`);
2805
+ }
2806
+ }
2807
+ // SQLite fallback
2808
+ try {
2809
+ const rows = hboStore.getRoutinesByCompany ? hboStore.getRoutinesByCompany(cid, statusFilter || undefined) : [];
2810
+ const routines = (rows || []).map(r => ({
2811
+ id: r.id, name: r.name ?? null, cronExpr: r.cron_expr ?? r.cronExpr ?? null,
2812
+ agentId: r.agent_id ?? r.agentId ?? null, status: r.status ?? 'active',
2813
+ concurrencyPolicy: r.concurrency_policy ?? r.concurrencyPolicy ?? 'skip_if_active',
2814
+ catchUpPolicy: r.catch_up_policy ?? r.catchUpPolicy ?? 'skip_missed',
2815
+ catchUpCap: r.catch_up_cap ?? r.catchUpCap ?? 0,
2816
+ nextRunAt: r.next_run_at ?? r.nextRunAt ?? null,
2817
+ lastRunAt: r.last_run_at ?? r.lastRunAt ?? null,
2818
+ createdAt: r.created_at ? new Date(r.created_at).toISOString() : null,
2819
+ }));
2820
+ return jsonResponse(res, 200, { routines, count: routines.length, _source: 'sqlite' });
2821
+ } catch (storeErr) {
2822
+ return jsonResponse(res, 503, { error: `Routines unavailable: ${storeErr.message}` });
2823
+ }
2824
+ }
2825
+
2826
+ // P5-02: POST /api/routines — create a new routine
2827
+ async function handleCreateRoutine(req, res, ctx) {
2828
+ const { mgQuery } = ctx;
2829
+ if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
2830
+ const cid = ctx.cid;
2831
+ if (!cid || cid === 'default-company') { jsonResponse(res, 400, { error: 'companyId is required' }); return; }
2832
+
2833
+ const body = await parseBody(req);
2834
+ const { name, agentId, cronExpr, concurrencyPolicy, catchUpPolicy, catchUpCap } = body;
2835
+
2836
+ if (!name || typeof name !== 'string' || !name.trim()) {
2837
+ jsonResponse(res, 400, { error: 'name is required' });
2838
+ return;
2839
+ }
2840
+ if (!agentId || typeof agentId !== 'string') {
2841
+ jsonResponse(res, 400, { error: 'agentId is required' });
2842
+ return;
2843
+ }
2844
+ if (!cronExpr || typeof cronExpr !== 'string') {
2845
+ jsonResponse(res, 400, { error: 'cronExpr is required' });
2846
+ return;
2847
+ }
2848
+
2849
+ // Compute nextRunAt using croner (confirmed available in daemon)
2850
+ let nextRunAt;
2851
+ try {
2852
+ const { Cron } = require('croner');
2853
+ const cron = new Cron(cronExpr);
2854
+ const next = cron.nextRun();
2855
+ cron.stop();
2856
+ nextRunAt = next ? next.toISOString().replace(/\.\d{3}Z$/, '+00:00') : null;
2857
+ } catch (cronErr) {
2858
+ log('warn', `P5-02: cronExpr could not be parsed: ${cronErr.message} — using +1h fallback`);
2859
+ nextRunAt = null; // Memgraph will use datetime() + duration("PT1H") fallback
2860
+ }
2861
+
2862
+ const resolvedPolicy = ['skip_if_active', 'coalesce_if_active', 'allow_concurrent'].includes(concurrencyPolicy)
2863
+ ? concurrencyPolicy : 'skip_if_active';
2864
+ const resolvedCatchUp = ['skip_missed', 'enqueue_missed_with_cap'].includes(catchUpPolicy)
2865
+ ? catchUpPolicy : 'skip_missed';
2866
+ const resolvedCap = Number.isFinite(Number(catchUpCap)) ? Math.max(0, Number(catchUpCap)) : 0;
2867
+ const routineId = `routine:${cid}:${crypto.randomUUID()}`;
2868
+
2869
+ try {
2870
+ const cypher = nextRunAt
2871
+ ? `CREATE (r:Routine { id:$id, companyId:$cid, agentId:$agentId, name:$name,
2872
+ cronExpr:$cron, status:'active', concurrencyPolicy:$policy, catchUpPolicy:$catchUp,
2873
+ catchUpCap:toInteger($cap), nextRunAt:datetime($nextRun), createdAt:datetime() })`
2874
+ : `CREATE (r:Routine { id:$id, companyId:$cid, agentId:$agentId, name:$name,
2875
+ cronExpr:$cron, status:'active', concurrencyPolicy:$policy, catchUpPolicy:$catchUp,
2876
+ catchUpCap:toInteger($cap), nextRunAt:datetime() + duration("PT1H"), createdAt:datetime() })`;
2877
+ const params = {
2878
+ id: routineId, cid, agentId: String(agentId), name: String(name).trim(),
2879
+ cron: cronExpr, policy: resolvedPolicy, catchUp: resolvedCatchUp, cap: resolvedCap,
2880
+ ...(nextRunAt ? { nextRun: nextRunAt } : {}),
2881
+ };
2882
+ await mgQuery(cypher, params);
2883
+
2884
+ // P5-SQL: SQLite write-through (fire-and-forget)
2885
+ try {
2886
+ hboStore.upsertRoutine?.({
2887
+ id: routineId, company_id: cid, agent_id: String(agentId), name: String(name).trim(),
2888
+ cron_expr: cronExpr, status: 'active', concurrency_policy: resolvedPolicy,
2889
+ catch_up_policy: resolvedCatchUp, catch_up_cap: resolvedCap,
2890
+ next_run_at: nextRunAt, created_at: Date.now(),
2891
+ });
2892
+ } catch (storeErr) { log('error', 'hboStore.upsertRoutine failed', { err: storeErr.message }); }
2893
+
2894
+ broadcast({ type: 'routine.created', routineId, companyId: cid });
2895
+ jsonResponse(res, 201, { routine: { id: routineId, name: String(name).trim(), agentId, cronExpr, status: 'active', concurrencyPolicy: resolvedPolicy, catchUpCap: resolvedCap } });
2896
+ } catch (err) {
2897
+ jsonResponse(res, 500, { error: `Create routine failed: ${err.message}` });
2898
+ }
2899
+ }
2900
+
2330
2901
  async function handlePatchRoutine(req, res, ctx, routineId) {
2331
2902
  const { mgQuery } = ctx;
2332
2903
  const body = await parseBody(req);
@@ -2462,7 +3033,7 @@ async function dispatchToAdapter(mgQuery, ctx, taskId, agentId) {
2462
3033
  HELIOS_AGENT_ID: agentId,
2463
3034
  HELIOS_RUN_ID: runId,
2464
3035
  HELIOS_TASK_ID: taskId,
2465
- HELIOS_API_URL: `http://localhost:${ctx.apiPort || 9091}`,
3036
+ HELIOS_API_URL: `http://localhost:${ctx.apiPort || 9093}`,
2466
3037
  };
2467
3038
 
2468
3039
  broadcast({ type: 'run.started', runId, taskId, agentId, adapterType, companyId: cid });
@@ -2761,6 +3332,7 @@ const tasksRoute = require('./routes/tasks')({
2761
3332
  handleGetTaskComments,
2762
3333
  handlePostTaskComment,
2763
3334
  handlePatchTask: handleUpdateTask, // GAP-20 fix: was wired to duplicate (no companyId); now uses correct handleUpdateTask
3335
+ handlePatchTaskPolicy, // P4-07: PATCH /api/tasks/:id/policy
2764
3336
  });
2765
3337
 
2766
3338
  const approvalsRoute = require('./routes/approvals')({
@@ -2807,6 +3379,8 @@ const strategyRoute = require('./routes/strategy')({
2807
3379
  });
2808
3380
 
2809
3381
  const routinesRoute = require('./routes/routines')({
3382
+ handleGetRoutines,
3383
+ handleCreateRoutine,
2810
3384
  handleGetRoutineTriggers,
2811
3385
  handleCreateRoutineTrigger,
2812
3386
  handleFireWebhookTrigger,
@@ -2848,6 +3422,10 @@ let interpretationRoute = () => false;
2848
3422
  let hedRoute = () => false;
2849
3423
  // D4: Department intelligence page handler — initialized lazily inside startApi()
2850
3424
  let handleGetDepartmentPage = null;
3425
+ // Domain-specific routes — initialized lazily inside startApi()
3426
+ let hitlRoute = null;
3427
+ let channelsRoute = null;
3428
+ let emailInfraRoute = null;
2851
3429
  const { handleOKRRoutes } = require('./routes/okrs');
2852
3430
  const { handleSenseiRoutes } = require('./routes/sensei');
2853
3431
  let suggestionsRoute, suggestionsCron;
@@ -2889,6 +3467,10 @@ async function route(req, res, ctx) {
2889
3467
  // It can never elevate to a company this daemon doesn't own.
2890
3468
  const qcid = parsedUrl.searchParams.get('companyId');
2891
3469
  const primaryCid = ctx.companies?.[0]?.id ?? 'default-company';
3470
+ if (method === 'GET' && pathname === '/api/agents' && (!qcid || !qcid.trim())) {
3471
+ jsonResponse(res, 400, { error: 'companyId required' });
3472
+ return;
3473
+ }
2892
3474
  // qcid will be validated against allowedCompanyIds before being used;
2893
3475
  // if it doesn't match, the 403 below catches it. This is safe.
2894
3476
  // Group C: Create per-request context clone so concurrent requests can't corrupt each other's cid.
@@ -2967,6 +3549,14 @@ async function route(req, res, ctx) {
2967
3549
  return;
2968
3550
  }
2969
3551
  const result = ctx.daemon.registerCompany(String(companyId));
3552
+ // Also update ctx.companies so the tenant isolation check accepts this company
3553
+ // on subsequent requests (the 403 guard checks ctx.companies, not _modulesByCompany).
3554
+ // This is safe: ctx.companies is an array that only grows — no entries are removed.
3555
+ if (!ctx.companies) ctx.companies = [];
3556
+ const cidStr = String(companyId);
3557
+ if (!ctx.companies.find(c => String(c.id) === cidStr)) {
3558
+ try { ctx.companies.push({ id: cidStr, name: cidStr }); } catch (_) { /* frozen array — ignore */ }
3559
+ }
2970
3560
  jsonResponse(res, 200, result);
2971
3561
  } catch (err) {
2972
3562
  jsonResponse(res, 500, { error: `registerCompany failed: ${err.message}` });
@@ -2974,6 +3564,51 @@ async function route(req, res, ctx) {
2974
3564
  return;
2975
3565
  }
2976
3566
 
3567
+ // GET /api/v1/context-brief — build and return the agent context brief.
3568
+ //
3569
+ // Used by harbor tests (test_context_propagation.py) and desktop context panel.
3570
+ // Calls buildContextBrief() from context-enrichment.js — the same function the
3571
+ // daemon calls before every agent dispatch.
3572
+ //
3573
+ // Query params:
3574
+ // agentId (required) — the BusinessAgent id
3575
+ // companyId (optional) — defaults to primary company; checked against allowedCompanyIds
3576
+ // nocache (optional) — if set, bypasses the 5-minute context cache
3577
+ //
3578
+ // Primary path: GET /api/v1/context-brief?agentId=X&companyId=Y
3579
+ // → buildContextBrief(mgQuery, agentId, '', companyId)
3580
+ // → { brief: string, agentId, companyId }
3581
+ if (method === 'GET' && pathname === '/api/v1/context-brief') {
3582
+ try {
3583
+ const agentId = parsedUrl.searchParams.get('agentId');
3584
+ const explicitCid = parsedUrl.searchParams.get('companyId');
3585
+ if (!agentId) {
3586
+ jsonResponse(res, 400, { error: 'agentId required' });
3587
+ return;
3588
+ }
3589
+ if (!explicitCid) {
3590
+ jsonResponse(res, 400, { error: 'companyId required' });
3591
+ return;
3592
+ }
3593
+ const cid = resolvedCid;
3594
+ const { mgQuery } = ctx;
3595
+ if (!mgQuery) {
3596
+ jsonResponse(res, 503, { error: 'Memgraph not available' });
3597
+ return;
3598
+ }
3599
+ const { buildContextBrief, invalidateContextCache } = require('./context-enrichment');
3600
+ // Bust 5-minute context cache when nocache=1 is set (used by tests after seeding)
3601
+ if (parsedUrl.searchParams.get('nocache')) {
3602
+ try { invalidateContextCache(cid); } catch (_) {}
3603
+ }
3604
+ const brief = await buildContextBrief(mgQuery, agentId, '', cid, null);
3605
+ jsonResponse(res, 200, { brief: brief || '', agentId, companyId: cid });
3606
+ } catch (err) {
3607
+ jsonResponse(res, 500, { error: `context-brief failed: ${err.message}` });
3608
+ }
3609
+ return;
3610
+ }
3611
+
2977
3612
  // GET /api/headroom/health — proxy to Headroom compression proxy health endpoint
2978
3613
  // Used by HeliosInfraService (helios-desktop) to show proxy status in Ground Control.
2979
3614
  if (method === 'GET' && pathname === '/api/headroom/health') {
@@ -3163,18 +3798,49 @@ async function route(req, res, ctx) {
3163
3798
  const baseUrl = HeadroomProxyManager.getInstance().getBaseUrl();
3164
3799
  if (!baseUrl) return _origEnd(chunk, encoding, callback);
3165
3800
 
3166
- const headroomAi = require('headroom-ai');
3167
- const result = await headroomAi.compress(
3168
- [{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'hbo_get', content: body }] }],
3169
- { model: 'claude', baseUrl }
3170
- );
3801
+ // Direct HTTP POST /headroom/compress — no npm package required.
3802
+ // Same pattern as context-compaction.ts and headroom-middleware.js.
3803
+ const _payload = JSON.stringify({
3804
+ messages: [{
3805
+ role: 'user',
3806
+ content: [{
3807
+ type: 'tool_result',
3808
+ tool_use_id: 'hbo_get',
3809
+ content: body,
3810
+ }],
3811
+ }],
3812
+ });
3813
+ const _url = new URL(baseUrl);
3814
+ const result = await new Promise((resolve, reject) => {
3815
+ const http = require('http');
3816
+ const req = http.request(
3817
+ {
3818
+ hostname: _url.hostname,
3819
+ port: parseInt(_url.port || '8787', 10),
3820
+ path: '/headroom/compress',
3821
+ method: 'POST',
3822
+ headers: {
3823
+ 'Content-Type': 'application/json',
3824
+ 'Content-Length': Buffer.byteLength(_payload),
3825
+ },
3826
+ },
3827
+ (res) => {
3828
+ let buf = '';
3829
+ res.on('data', (c) => { buf += c; });
3830
+ res.on('end', () => { try { resolve(JSON.parse(buf)); } catch { reject(new Error('bad json')); } });
3831
+ res.on('error', reject);
3832
+ }
3833
+ );
3834
+ req.setTimeout(8000, () => { req.destroy(); reject(new Error('timeout')); });
3835
+ req.on('error', reject);
3836
+ req.write(_payload);
3837
+ req.end();
3838
+ });
3171
3839
 
3172
3840
  let compressed = body;
3173
3841
  try {
3174
- const c = result?.messages?.[0]?.content;
3175
- const text = typeof c === 'string' ? c
3176
- : Array.isArray(c) ? (c[0]?.text ?? c[0]?.content ?? body) : body;
3177
- compressed = text;
3842
+ const c = result?.messages?.[0]?.content?.[0]?.content;
3843
+ if (c) compressed = c;
3178
3844
  } catch (_) {}
3179
3845
 
3180
3846
  if (result?.ccrHashes?.length) {
@@ -3495,41 +4161,27 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
3495
4161
  }
3496
4162
  }
3497
4163
 
3498
- // Cache miss or stale — regenerate
4164
+ // Cache miss or stale — return 202 immediately and generate async
3499
4165
  if (!handleGetDepartmentPage) {
3500
4166
  jsonResponse(res, 503, { error: 'Department page generator not initialized' });
3501
4167
  return;
3502
4168
  }
3503
4169
 
3504
- const narrative = await handleGetDepartmentPage.generatePage(cid, department);
4170
+ // D5: Return 202 immediately so the frontend can show a loading state.
4171
+ // Generation runs in setImmediate (next event-loop tick) and broadcasts
4172
+ // department:page:ready when complete so the client can refetch.
4173
+ jsonResponse(res, 202, { ok: true, generating: true, department, companyId: cid });
3505
4174
 
3506
- // generatePage returns null on failure (LLM not configured, empty data, etc.).
3507
- // When null, query the DepartmentPage node for its status/error so the desktop
3508
- // can show a specific message (e.g. "AWS credentials required") instead of the
3509
- // generic "no intelligence page available" empty state.
3510
- if (!narrative) {
3511
- let failureError = null;
4175
+ setImmediate(async () => {
3512
4176
  try {
3513
- const failedNode = await mg(
3514
- 'MATCH (dp:DepartmentPage {companyId: $cid, department: $dept}) ' +
3515
- 'WHERE dp.status = \'failed\' RETURN dp.error AS error ORDER BY dp.generatedAt DESC LIMIT 1',
3516
- { cid, dept: department }
3517
- ).catch(() => null);
3518
- const failRow = failedNode?.rows?.[0];
3519
- if (failRow) {
3520
- failureError = Array.isArray(failRow) ? failRow[0] : failRow.error;
4177
+ const narrative = await handleGetDepartmentPage.generatePage(cid, department);
4178
+ if (narrative) {
4179
+ broadcast({ type: 'department:page:ready', companyId: cid, department });
3521
4180
  }
3522
- } catch (_) {}
3523
- jsonResponse(res, 200, {
3524
- department,
3525
- narrative: null,
3526
- error: failureError || 'Generation failed — check daemon logs',
3527
- cached: false,
3528
- });
3529
- return;
3530
- }
3531
-
3532
- jsonResponse(res, 200, { department, narrative, generatedAt: new Date().toISOString(), cached: false });
4181
+ } catch (_genErr) {
4182
+ console.warn('[dept-page] async generation error:', _genErr && _genErr.message);
4183
+ }
4184
+ });
3533
4185
 
3534
4186
  } catch (e) {
3535
4187
  jsonResponse(res, 500, { error: 'Department page generation failed: ' + (e.message || 'unknown error') });
@@ -3542,7 +4194,7 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
3542
4194
  * Start the REST API server.
3543
4195
  *
3544
4196
  * @param {Function} mgQuery - Memgraph query function: (cypher, params) => Promise<rows>
3545
- * @param {object} config - Daemon config (uses config.apiPort ?? 9091)
4197
+ * @param {object} config - Daemon config (uses config.apiPort ?? 9093)
3546
4198
  * @param {object} state - Optional shared state for health endpoint
3547
4199
  * @returns {{ server, updateTick, setDraining }}
3548
4200
  */
@@ -3550,6 +4202,16 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
3550
4202
 
3551
4203
  async function handleGetTaskDetail(req, res, ctx, taskId) {
3552
4204
  const cid = ctx.cid;
4205
+ // H-2 fix: SQLite fallback when Memgraph is unavailable
4206
+ if (!ctx.mgQuery) {
4207
+ try {
4208
+ const t = hboStore.getTask ? hboStore.getTask(taskId, cid) : null;
4209
+ if (!t) return jsonResponse(res, 404, { error: 'Task not found' });
4210
+ return jsonResponse(res, 200, { task: t, _source: 'sqlite' });
4211
+ } catch (storeErr) {
4212
+ return jsonResponse(res, 503, { error: 'Task unavailable: Memgraph not connected', task: null });
4213
+ }
4214
+ }
3553
4215
  try {
3554
4216
  const result = await ctx.mgQuery('MATCH (t:Task {id: $id, companyId: $cid}) RETURN t', { id: taskId, cid });
3555
4217
  if (result.rows && result.rows.length > 0) {
@@ -3730,6 +4392,7 @@ function startApi(mgQuery, config = {}, state = {}) {
3730
4392
  invalidateContextCache,
3731
4393
  semanticUpdater: container?.cradle?.projectSemanticUpdater ?? null,
3732
4394
  driftDetector: container?.cradle?.projectDriftDetector ?? null,
4395
+ hboStore,
3733
4396
  });
3734
4397
  // Dept Catchball pitch — shares same semanticUpdater from container
3735
4398
  deptRoute = require('./routes/dept')({
@@ -3740,7 +4403,7 @@ function startApi(mgQuery, config = {}, state = {}) {
3740
4403
  } catch (e) {
3741
4404
  // container.js not yet built — projectRoute falls back to () => false
3742
4405
  const { invalidateContextCache } = require('./context-enrichment');
3743
- projectRoute = require('./routes/project')({ mgQuery, broadcast: state.broadcast ?? (() => {}), invalidateContextCache });
4406
+ projectRoute = require('./routes/project')({ mgQuery, broadcast: state.broadcast ?? (() => {}), invalidateContextCache, hboStore });
3744
4407
  deptRoute = require('./routes/dept')({ mgQuery, broadcast: state.broadcast ?? (() => {}) });
3745
4408
  }
3746
4409
 
@@ -3753,12 +4416,12 @@ function startApi(mgQuery, config = {}, state = {}) {
3753
4416
  }
3754
4417
 
3755
4418
  // Domain-specific routes: HITL, channel setup, email infrastructure
3756
- let hitlRoute = null, channelsRoute = null, emailInfraRoute = null;
4419
+ hitlRoute = null; channelsRoute = null; emailInfraRoute = null;
3757
4420
  try { hitlRoute = require('./routes/hitl')({ mgQuery }); } catch(e) { log('warn', `hitl route unavailable: ${e.message}`); }
3758
4421
  try { channelsRoute = require('./routes/channels')({ mgQuery }); } catch(e) { log('warn', `channels route unavailable: ${e.message}`); }
3759
4422
  try { emailInfraRoute = require('./routes/email-infrastructure')({ mgQuery }); } catch(e) { log('warn', `emailInfra route unavailable: ${e.message}`); }
3760
4423
 
3761
- const port = config.apiPort ?? 9091;
4424
+ const port = config.apiPort ?? 9093;
3762
4425
 
3763
4426
  // Security: shared-secret token for WebSocket upgrade.
3764
4427
  // Mirrors GHSA-3c6j-hq33-3jv4 (forged exec lifecycle events via unauthenticated WS).
@@ -3770,6 +4433,8 @@ function startApi(mgQuery, config = {}, state = {}) {
3770
4433
  mgQuery,
3771
4434
  companies: state.companies ?? [],
3772
4435
  cid: state.companies?.[0]?.id ?? 'default-company', // default — overridden per-request in route()
4436
+ apiPort: port,
4437
+ actualBoundPort: null,
3773
4438
  tickCount: 0,
3774
4439
  draining: false,
3775
4440
  apiToken: config.apiToken || null, // null = no auth (backward compat)
@@ -3888,11 +4553,12 @@ function startApi(mgQuery, config = {}, state = {}) {
3888
4553
  });
3889
4554
 
3890
4555
  server.listen(port, config.apiHost ?? '127.0.0.1', () => {
4556
+ ctx.actualBoundPort = server.address()?.port ?? port;
3891
4557
  process.stdout.write(JSON.stringify({
3892
4558
  ts: new Date().toISOString(),
3893
4559
  level: 'info',
3894
4560
  module: 'helios-api',
3895
- msg: `REST API listening on http://127.0.0.1:${port}`,
4561
+ msg: `REST API listening on http://127.0.0.1:${ctx.actualBoundPort}`,
3896
4562
  }) + '\n');
3897
4563
 
3898
4564
  // Write daemon.lock — the ground-truth discovery file for Helios Desktop.