@cgh567/agent 2.4.3 → 2.4.5

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 (141) 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-wrapper.sh +4 -1
  9. package/bin/helios-rpc.js +19 -0
  10. package/daemon/adapters/helios-rpc-adapter.js +5 -12
  11. package/daemon/context-enrichment.js +27 -0
  12. package/daemon/helios-api.js +310 -58
  13. package/daemon/helios-company-daemon.js +179 -53
  14. package/daemon/lib/blast-radius-analyzer.js +75 -0
  15. package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
  16. package/daemon/lib/forensic-log.js +113 -0
  17. package/daemon/lib/goal-research-pipeline.js +644 -0
  18. package/daemon/lib/harada/cascade-judge.js +84 -1
  19. package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
  20. package/daemon/lib/harada/pillar-dispatcher.js +23 -2
  21. package/daemon/lib/hbo-bridge.js +73 -5
  22. package/daemon/lib/headroom-middleware.js +129 -0
  23. package/daemon/lib/headroom-proxy-manager.js +319 -0
  24. package/daemon/lib/intelligence/department-page-generator.js +46 -1
  25. package/daemon/lib/interpretation-engine.js +92 -0
  26. package/daemon/lib/mental-model-cache.js +96 -0
  27. package/daemon/lib/project-factory.js +47 -0
  28. package/daemon/lib/session-log-reader.js +93 -0
  29. package/daemon/lib/standard-work-bootstrap.js +87 -1
  30. package/daemon/lib/task-completion-processor.js +12 -0
  31. package/daemon/package.json +2 -1
  32. package/daemon/routes/agents.js +51 -6
  33. package/daemon/routes/channels.js +116 -2
  34. package/daemon/routes/crm.js +85 -0
  35. package/daemon/routes/dashboard.js +62 -16
  36. package/daemon/routes/dept.js +10 -1
  37. package/daemon/routes/email-triage.js +19 -10
  38. package/daemon/routes/hbo.js +367 -13
  39. package/daemon/routes/hed.js +133 -0
  40. package/daemon/routes/inbox.js +466 -10
  41. package/daemon/routes/project.js +392 -9
  42. package/daemon/schema-definitions.js +10 -0
  43. package/daemon/schema-migrations-hbo.js +10 -0
  44. package/daemon/schema-migrations-proj.js +22 -0
  45. package/extensions/__tests__/codebase-index.test.ts +73 -0
  46. package/extensions/__tests__/extension-command-registration.test.ts +35 -0
  47. package/extensions/__tests__/git-push-guard.test.ts +68 -0
  48. package/extensions/context-compaction.ts +104 -76
  49. package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
  50. package/extensions/email/actions/draft-response.ts +21 -1
  51. package/extensions/email/auth/accounts.ts +5 -11
  52. package/extensions/email/auth/inbox-dog.ts +5 -2
  53. package/extensions/email/backfill.ts +20 -13
  54. package/extensions/email/providers/gmail.ts +164 -0
  55. package/extensions/email/providers/google-calendar.ts +34 -5
  56. package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
  57. package/extensions/helios-browser/backends/playwright.ts +3 -1
  58. package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
  59. package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
  60. package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
  61. package/extensions/hema-dispatch-v3/index.ts +33 -65
  62. package/extensions/interview/__tests__/server.test.ts +117 -0
  63. package/extensions/lib/helios-root.cjs +46 -0
  64. package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
  65. package/extensions/warm-tick/warm-tick-maintenance.ts +156 -0
  66. package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
  67. package/lib/__tests__/crash-fixes.test.ts +49 -0
  68. package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
  69. package/lib/broker/__tests__/jit-subscription.test.js +44 -1
  70. package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
  71. package/lib/compression/__tests__/ccr-store.test.js +138 -0
  72. package/lib/compression/__tests__/pipeline.test.js +280 -0
  73. package/lib/compression/__tests__/smart-crusher.test.js +242 -0
  74. package/lib/compression/dist/server.js +34 -1
  75. package/lib/compression/dist/start-server.js +77 -0
  76. package/lib/graph/learning/headroom-learn-bridge.js +175 -0
  77. package/lib/hbo-core-store.ts +71 -0
  78. package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
  79. package/lib/skill-sync.js +6 -1
  80. package/lib/startup-integrity.js +9 -2
  81. package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
  82. package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
  83. package/lib/triage-core/__tests__/classifier.test.ts +45 -7
  84. package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
  85. package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
  86. package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
  87. package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
  88. package/lib/triage-core/__tests__/signals.test.ts +357 -0
  89. package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
  90. package/lib/triage-core/backfill-cost-estimator.ts +91 -0
  91. package/lib/triage-core/backfill-orchestrator.ts +119 -0
  92. package/lib/triage-core/classifier.ts +38 -6
  93. package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
  94. package/lib/triage-core/cos/response-debt.ts +2 -2
  95. package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
  96. package/lib/triage-core/graph/batch-persistence.ts +66 -2
  97. package/lib/triage-core/graph/betweenness-worker.js +75 -0
  98. package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
  99. package/lib/triage-core/graph/persistence.ts +1 -1
  100. package/lib/triage-core/graph/schema-v2.ts +2 -0
  101. package/lib/triage-core/graph/schema.cypher +1 -0
  102. package/lib/triage-core/graph/triage-query.ts +1 -1
  103. package/lib/triage-core/learning.ts +15 -20
  104. package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
  105. package/lib/triage-core/mental-model/cos-integration.ts +1 -1
  106. package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
  107. package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
  108. package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
  109. package/lib/triage-core/mental-model/model-assembler.ts +16 -3
  110. package/lib/triage-core/orchestrator.ts +4 -4
  111. package/lib/triage-core/scheduled-sends.ts +39 -2
  112. package/lib/triage-core/signals/comms-style.ts +1 -1
  113. package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
  114. package/lib/triage-core/signals/favee-type.ts +6 -1
  115. package/lib/triage-core/signals/goal-relevance.ts +31 -2
  116. package/lib/triage-core/signals/personal-importance.ts +1 -1
  117. package/lib/triage-core/signals/referral-chain.ts +0 -1
  118. package/lib/triage-core/signals/relationship-decay.ts +4 -0
  119. package/lib/triage-core/signals/relationship-health.ts +6 -1
  120. package/lib/triage-core/signals/trajectory-signal.ts +38 -3
  121. package/lib/triage-core/tournament-runner.js +11 -1
  122. package/lib/triage-core/triage-llm-factory.ts +110 -0
  123. package/lib/triage-core/triage-local-llm.ts +145 -0
  124. package/lib/triage-core/triage-sql-store.ts +337 -0
  125. package/lib/triage-core/types.ts +2 -2
  126. package/lib/unified-graph.atomic.test.ts +52 -0
  127. package/lib/unified-graph.failure-categories.test.ts +55 -0
  128. package/package.json +10 -3
  129. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  130. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  131. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  132. package/skills/helios-bookkeeping/SKILL.md +321 -0
  133. package/skills/helios-briefer/SKILL.md +44 -0
  134. package/skills/helios-client-relations/SKILL.md +322 -0
  135. package/skills/helios-personal-triager/SKILL.md +45 -0
  136. package/skills/helios-recruitment/SKILL.md +317 -0
  137. package/skills/helios-relationship-nudger/SKILL.md +77 -0
  138. package/skills/helios-researcher/SKILL.md +44 -0
  139. package/skills/helios-scheduler/SKILL.md +58 -0
  140. package/skills/helios-tax-analyst/SKILL.md +280 -0
  141. package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
@@ -149,7 +149,16 @@ module.exports = function createDeptRoute({ mgQuery, broadcast, semanticUpdater
149
149
  pcp.rationale = $rationale,
150
150
  pcp.status = 'pending',
151
151
  pcp.updatedAt = datetime()`,
152
- { proposalId, cid, pillarId, dept, section, proposedChange: String(proposedChange), rationale: rationale ? String(rationale) : '', authorId }
152
+ { proposalId, cid, pillarId, dept, section, proposedChange: String(proposedChange), rationale: rationale ? String(rationale) : '', authorId }
153
+ );
154
+
155
+ // P1-C: Create Approval node for plan_change_review so it surfaces in GET /api/hbo/approvals
156
+ await mgQuery(
157
+ `MERGE (a:Approval {id: $approvalId})
158
+ ON CREATE SET a.type = 'plan_change_review', a.companyId = $cid, a.status = 'pending',
159
+ a.proposalId = $proposalId, a.createdAt = datetime()
160
+ ON MATCH SET a.status = 'pending', a.proposalId = $proposalId`,
161
+ { approvalId: `approval:pcp:${proposalId}`, proposalId, cid }
153
162
  );
154
163
 
155
164
  // Respond immediately — LLM analysis is async
@@ -16,14 +16,16 @@ const { spawn } = require('child_process');
16
16
  const path = require('path');
17
17
 
18
18
  const HOME = homedir();
19
- const TRIAGE_DATA = join(HOME, 'helios-agent', 'data', 'email-triage');
19
+ const HELIOS_ROOT = process.env.HELIOS_ROOT
20
+ || join(HOME, 'Desktop', 'Helios', 'helios-agent-main');
21
+ const TRIAGE_DATA = join(HELIOS_ROOT, 'data', 'email-triage');
20
22
  const BRIEFING_PATH = join(TRIAGE_DATA, 'latest-briefing.json');
21
23
  const INBOX_PATH = join(TRIAGE_DATA, 'latest-inbox.json');
22
24
  const DRAFTS_PATH = join(TRIAGE_DATA, 'drafted-responses.json');
23
- const DASHBOARD_PATH = join(HOME, 'helios-agent', 'daemon', 'triage', 'dashboard.html');
25
+ const DASHBOARD_PATH = join(HELIOS_ROOT, 'daemon', 'triage', 'dashboard.html');
24
26
 
25
27
  // ─── Backfill Process Mutex (PID file) ──────────────────────────────────────
26
- const BACKFILL_PID_FILE = path.join(HOME, 'helios-agent', 'data', 'backfill.pid');
28
+ const BACKFILL_PID_FILE = path.join(HELIOS_ROOT, 'data', 'backfill.pid');
27
29
 
28
30
  function isBackfillRunning() {
29
31
  try {
@@ -64,14 +66,14 @@ function jsonResponse(res, status, data) {
64
66
  */
65
67
  async function regenerateDashboard() {
66
68
  return new Promise((resolve) => {
67
- const updaterPath = join(HOME, 'helios-agent', 'scripts', 'update-dashboard.js');
69
+ const updaterPath = join(HELIOS_ROOT, 'scripts', 'update-dashboard.js');
68
70
  if (!existsSync(updaterPath)) {
69
71
  console.warn('[email-triage] update-dashboard.js not found — skipping regen');
70
72
  resolve();
71
73
  return;
72
74
  }
73
75
  const child = spawn(process.execPath, [updaterPath], {
74
- cwd: join(HOME, 'helios-agent'),
76
+ cwd: HELIOS_ROOT,
75
77
  env: { ...process.env },
76
78
  stdio: ['ignore', 'pipe', 'pipe'],
77
79
  windowsHide: true,
@@ -237,6 +239,7 @@ module.exports = function emailTriageRoute({ broadcast } = {}) {
237
239
  // Parse optional body for config
238
240
  let since = '30d';
239
241
  let limit = 100;
242
+ let account = null;
240
243
  try {
241
244
  const chunks = [];
242
245
  await new Promise((resolve, reject) => {
@@ -247,6 +250,7 @@ module.exports = function emailTriageRoute({ broadcast } = {}) {
247
250
  const body = chunks.length > 0 ? JSON.parse(Buffer.concat(chunks).toString()) : {};
248
251
  if (body.since) since = body.since;
249
252
  if (body.limit) limit = parseInt(body.limit, 10) || 100;
253
+ if (body.account) account = body.account;
250
254
  } catch (_) { /* use defaults */ }
251
255
 
252
256
  // Set up SSE headers
@@ -268,10 +272,12 @@ module.exports = function emailTriageRoute({ broadcast } = {}) {
268
272
 
269
273
  // Resolve the backfill script — prefer run-backfill-30d.ts (full triage + drafts)
270
274
  const scriptCandidates = [
271
- join(HOME, 'helios-agent', 'run-backfill-30d.ts'), // W1-3: correct script (full 5-phase email triage + drafts)
272
- join(HOME, 'helios-agent', 'run-triage-backfill.ts'), // legacy name (does not exist, kept as fallback)
273
- join(HOME, 'helios-agent', 'extensions', 'email', 'backfill.ts'), // graph ingestion only
274
- join(HOME, 'helios-agent', 'scripts', 'run-backfill.ts'), // full graph backfill (wrong for email triage)
275
+ process.env.HELIOS_ROOT
276
+ ? join(process.env.HELIOS_ROOT, 'run-backfill-30d.ts')
277
+ : join(HOME, 'Desktop', 'Helios', 'helios-agent-main', 'run-backfill-30d.ts'), // W1-3: correct script (full 5-phase email triage + drafts)
278
+ join(HELIOS_ROOT, 'run-triage-backfill.ts'), // legacy name (does not exist, kept as fallback)
279
+ join(HELIOS_ROOT, 'extensions', 'email', 'backfill.ts'), // graph ingestion only
280
+ join(HELIOS_ROOT, 'scripts', 'run-backfill.ts'), // full graph backfill (wrong for email triage)
275
281
  ];
276
282
 
277
283
  const scriptPath = scriptCandidates.find(p => existsSync(p));
@@ -293,17 +299,20 @@ module.exports = function emailTriageRoute({ broadcast } = {}) {
293
299
  }
294
300
 
295
301
  const args = [scriptPath, '--since', sinceDate, '--limit', limit];
302
+ if (account) args.push('--account', account);
296
303
  sendSSE({ phase: 1, message: `Running: npx tsx ${path.basename(scriptPath)} --since ${sinceDate} --limit ${limit}` });
297
304
 
298
305
  let child;
299
306
  try {
300
307
  // C6: use npx.cmd on Windows so spawn finds the correct executable
308
+ // shell:true required on Windows for .cmd files (EINVAL without it)
301
309
  const npxBin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
302
310
  child = spawn(npxBin, ['tsx', ...args], {
303
- cwd: join(HOME, 'helios-agent'),
311
+ cwd: HELIOS_ROOT,
304
312
  env: { ...process.env, HELIOS_SKIP_INTERNAL_DASHBOARD_REGEN: '1' },
305
313
  stdio: ['ignore', 'pipe', 'pipe'],
306
314
  windowsHide: true,
315
+ shell: process.platform === 'win32',
307
316
  });
308
317
  } catch (spawnErr) {
309
318
  sendSSE({ phase: -1, message: `Spawn error: ${spawnErr.message}`, error: true, done: true });
@@ -580,7 +580,14 @@ async function handleCreateBusinessGoal(req, res, ctx) {
580
580
  } catch (storeErr) {
581
581
  process.stderr.write(`[hbo] goal mirror failed: ${storeErr.message}\n`);
582
582
  }
583
- try { ctx._bc?.({ type: 'goal.created', companyId: cid, goalId }); } catch (_) {}
583
+ try { ctx._bc?.({ type: 'goal.created', companyId: cid, goalId }); } catch (_) {}
584
+ // Fire-and-forget: trigger tickGoalDecompose so the Harada cascade begins immediately
585
+ // (normally runs every 5th tick = up to 150s; injected hook shortens this to ~0ms).
586
+ if (_triggerGoalDecompose) {
587
+ setImmediate(() => {
588
+ try { _triggerGoalDecompose(cid); } catch (_tgdErr) {}
589
+ });
590
+ }
584
591
  jsonOk(res, { goal: { id: goalId, companyId: cid, title, description, level, status: 'active', parentId } }, 201);
585
592
  } catch (e) {
586
593
  jsonErr(res, 500, `Create BusinessGoal failed: ${safeErrMsg(e)}`);
@@ -629,7 +636,7 @@ async function handleUpdateBusinessGoal(req, res, ctx, goalId) {
629
636
  // H-3 fix: only pass fields that were explicitly provided in the PATCH body to updateGoal.
630
637
  // rowToObj fills missing Memgraph fields with null — passing null for e.g. title would
631
638
  // overwrite the existing SQLite title with null on a status-only update.
632
- const storeUpdate: Record<string, any> = { id: goalId };
639
+ const storeUpdate = { id: goalId };
633
640
  if (title !== null) storeUpdate.title = goal.title;
634
641
  if (description !== null) storeUpdate.description = goal.description;
635
642
  if (level !== null) storeUpdate.level = goal.level;
@@ -2355,9 +2362,11 @@ async function handleGetHboApprovals(req, res, ctx) {
2355
2362
  const type = url.searchParams.get('type') || null;
2356
2363
 
2357
2364
  // GP3-B2: expanded to include strategy_proposal and harada_strategy_review types
2358
- let cypher = `MATCH (a:Approval {companyId: $cid}) WHERE a.type IN ['question','send_approval','strategy_proposal','harada_strategy_review','approval','domain_purchase','sender_identity_config','dns_records_review','email_domain_setup','harada_l2_review','harada_l3_review','harada_question']`;
2365
+ let cypher = `MATCH (a:Approval {companyId: $cid}) WHERE a.type IN ['question','send_approval','strategy_proposal','harada_strategy_review','approval','domain_purchase','sender_identity_config','dns_records_review','email_domain_setup','harada_l2_review','harada_l3_review','harada_question','plan_change_review']`;
2359
2366
  const params = { cid: ctx.cid };
2360
- if (status) { cypher += ' AND a.status = $status'; params.status = status; }
2367
+ // F-03: 'pending' filter also includes 'revision_requested' so operators see revision_requested items in the Pending tab
2368
+ if (status === 'pending') { cypher += " AND a.status IN ['pending', 'revision_requested']"; }
2369
+ else if (status) { cypher += ' AND a.status = $status'; params.status = status; }
2361
2370
  if (type) { cypher += ' AND a.type = $type'; params.type = type; }
2362
2371
  cypher += ' RETURN a ORDER BY a.createdAt DESC LIMIT toInteger(50)';
2363
2372
 
@@ -2384,6 +2393,8 @@ async function handleGetHboApprovals(req, res, ctx) {
2384
2393
  department: p?.department ?? null,
2385
2394
  templateKey: p?.templateKey ?? null,
2386
2395
  inferredAnswer: p?.inferredAnswer ?? null,
2396
+ decisionNote: p?.decisionNote ?? null,
2397
+ followUpTaskId: p?.followUpTaskId ?? null,
2387
2398
  };
2388
2399
  });
2389
2400
  jsonOk(res, { approvals, count: approvals.length });
@@ -2392,6 +2403,28 @@ async function handleGetHboApprovals(req, res, ctx) {
2392
2403
  }
2393
2404
  }
2394
2405
 
2406
+ // P9A: GET /api/hbo/blocked-work — tasks stopped due to a blocker
2407
+ async function handleGetBlockedWork(req, res, ctx) {
2408
+ try {
2409
+ if (!ctx.mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
2410
+ const url = new URL(req.url, 'http://localhost');
2411
+ const cid = ctx.cid || url.searchParams.get('companyId') || url.searchParams.get('cid') || '';
2412
+ const r = await ctx.mgQuery(
2413
+ `MATCH (t:Task) WHERE t.blockedReason IS NOT NULL AND t.status = 'blocked' AND t.companyId = $cid
2414
+ RETURN t.id AS id, t.title AS title, t.blockedReason AS blockedReason, t.blockedAt AS blockedAt
2415
+ ORDER BY t.blockedAt DESC`,
2416
+ { cid }
2417
+ ).catch(() => null);
2418
+ const blockedWork = (r?.rows ?? []).map(row => {
2419
+ if (Array.isArray(row)) return { id: row[0], title: row[1], blockedReason: row[2], blockedAt: row[3] };
2420
+ return { id: row['id'], title: row['title'], blockedReason: row['blockedReason'], blockedAt: row['blockedAt'] };
2421
+ });
2422
+ jsonOk(res, { blockedWork, count: blockedWork.length });
2423
+ } catch (e) {
2424
+ jsonErr(res, 500, `Get blocked-work failed: ${safeErrMsg(e)}`);
2425
+ }
2426
+ }
2427
+
2395
2428
  async function handleHboLaunch(req, res, ctx) {
2396
2429
  const { mgQuery } = ctx;
2397
2430
  if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
@@ -2900,9 +2933,90 @@ async function handleGetCommandCenter(req, res, ctx) {
2900
2933
  }
2901
2934
  }
2902
2935
 
2936
+ // ── G-01: Budget Policy CRUD Handlers ────────────────────────────────────────
2937
+
2938
+ async function handleCreateBudgetPolicy(req, res, ctx) {
2939
+ try {
2940
+ if (!ctx.mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
2941
+ const body = await readBody(req);
2942
+ if (!assertValidBody(body, res)) return;
2943
+ const { agentId, limitUSD, period, companyId: bodyCompanyId, id: clientId } = body || {};
2944
+ if (!agentId || limitUSD === undefined || !period) {
2945
+ jsonErr(res, 400, 'agentId, limitUSD, and period are required'); return;
2946
+ }
2947
+ // Use client-supplied id (from SQLite saved record) so delete path matches; fall back to generated key.
2948
+ const cid = bodyCompanyId || ctx.cid;
2949
+ const id = clientId || `bp:${cid}:${agentId}:${Date.now()}`;
2950
+ await ctx.mgQuery(
2951
+ `MERGE (bp:BudgetPolicy {id: $id})
2952
+ ON CREATE SET bp.companyId = $cid, bp.agentId = $agentId,
2953
+ bp.limitUSD = $limitUSD, bp.period = $period,
2954
+ bp.limitCents = toInteger($limitUSD * 100),
2955
+ bp.spentCents = 0, bp.createdAt = datetime()
2956
+ ON MATCH SET bp.limitUSD = $limitUSD, bp.period = $period,
2957
+ bp.limitCents = toInteger($limitUSD * 100)`,
2958
+ { id, cid, agentId: String(agentId), limitUSD: Number(limitUSD), period: String(period) }
2959
+ );
2960
+ jsonOk(res, { policy: { id, agentId, limitUSD: Number(limitUSD), period } });
2961
+ } catch (e) { jsonErr(res, 500, `Create budget policy failed: ${safeErrMsg(e)}`); }
2962
+ }
2963
+
2964
+ async function handleUpdateBudgetPolicy(req, res, ctx, policyId) {
2965
+ try {
2966
+ if (!ctx.mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
2967
+ if (!policyId) { jsonErr(res, 400, 'Missing policy ID'); return; }
2968
+ const body = await readBody(req);
2969
+ if (!assertValidBody(body, res)) return;
2970
+ const setClauses = [];
2971
+ const params = { id: policyId, cid: ctx.cid };
2972
+ if (body.limitUSD !== undefined) { setClauses.push('bp.limitUSD = $limitUSD, bp.limitCents = toInteger($limitUSD * 100)'); params.limitUSD = Number(body.limitUSD); }
2973
+ if (body.period !== undefined) { setClauses.push('bp.period = $period'); params.period = String(body.period); }
2974
+ if (body.agentId !== undefined) { setClauses.push('bp.agentId = $agentId'); params.agentId = String(body.agentId); }
2975
+ if (setClauses.length === 0) { jsonErr(res, 400, 'No fields to update'); return; }
2976
+ const r = await ctx.mgQuery(
2977
+ `MATCH (bp:BudgetPolicy {id: $id, companyId: $cid}) SET ${setClauses.join(', ')}, bp.updatedAt = datetime() RETURN bp.id`,
2978
+ params
2979
+ );
2980
+ if (!(r?.rows?.length)) { jsonErr(res, 404, `Budget policy not found: ${policyId}`); return; }
2981
+ jsonOk(res, { updated: true, policyId });
2982
+ } catch (e) { jsonErr(res, 500, `Update budget policy failed: ${safeErrMsg(e)}`); }
2983
+ }
2984
+
2985
+ async function handleDeleteBudgetPolicy(req, res, ctx, policyId) {
2986
+ try {
2987
+ if (!ctx.mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
2988
+ if (!policyId) { jsonErr(res, 400, 'Missing policy ID'); return; }
2989
+ await ctx.mgQuery(
2990
+ `MATCH (bp:BudgetPolicy {id: $id, companyId: $cid}) DETACH DELETE bp`,
2991
+ { id: policyId, cid: ctx.cid }
2992
+ );
2993
+ jsonOk(res, { deleted: true, policyId });
2994
+ } catch (e) { jsonErr(res, 500, `Delete budget policy failed: ${safeErrMsg(e)}`); }
2995
+ }
2996
+
2997
+ async function handleGetBudgetIncidents(req, res, ctx) {
2998
+ try {
2999
+ if (!ctx.mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
3000
+ const r = await ctx.mgQuery(
3001
+ `MATCH (bi:BudgetIncident {companyId: $cid}) RETURN bi ORDER BY bi.createdAt DESC LIMIT 50`,
3002
+ { cid: ctx.cid }
3003
+ ).catch(() => null);
3004
+ const incidents = (r?.rows ?? []).map(row => {
3005
+ const node = Array.isArray(row) ? row[0] : (row['bi'] || row);
3006
+ const p = node?.properties || node;
3007
+ return { id: p?.id, agentId: p?.agentId, limitUSD: p?.limitUSD, spentUSD: p?.spentUSD,
3008
+ period: p?.period, resolvedAt: p?.resolvedAt ?? null, createdAt: p?.createdAt ?? null };
3009
+ });
3010
+ jsonOk(res, { incidents, count: incidents.length });
3011
+ } catch (e) { jsonErr(res, 500, `Get budget incidents failed: ${safeErrMsg(e)}`); }
3012
+ }
3013
+
2903
3014
  module.exports = function createHBORouter(handlers) {
2904
- const { broadcast, mgQuery: _mgQueryInit } = handlers || {};
3015
+ const { broadcast, mgQuery: _mgQueryInit, triggerGoalDecompose } = handlers || {};
2905
3016
  const _bc = typeof broadcast === 'function' ? broadcast : () => {};
3017
+ // Optional hook: after a BusinessGoal is created, caller can fire-and-forget tickGoalDecompose
3018
+ // so the Harada cascade starts on the next event-loop tick rather than waiting up to 150s.
3019
+ const _triggerGoalDecompose = typeof triggerGoalDecompose === 'function' ? triggerGoalDecompose : null;
2906
3020
  return async function hboRoute(req, res, ctx, pathname, method) {
2907
3021
  // R3-C10: Set module-level request reference so jsonOk/jsonErr can include CORS headers
2908
3022
  // at all 155 call sites without requiring changes to each handler.
@@ -2997,12 +3111,110 @@ module.exports = function createHBORouter(handlers) {
2997
3111
  return true;
2998
3112
  }
2999
3113
 
3114
+ // P8A-03: GET /api/hbo/tasks/:id — full task detail including originKind, approvalId, etc.
3115
+ // Must be matched BEFORE the PATCH /:id pattern below.
3116
+ const taskGetMatch = pathname.match(/^\/api\/hbo\/tasks\/([^/]+)$/);
3117
+ if (method === 'GET' && taskGetMatch) {
3118
+ const taskId = decodeURIComponent(taskGetMatch[1]);
3119
+ const { mgQuery, cid } = ctx;
3120
+ // E2 fix: prefer ?companyId query param so multi-company operator's active company
3121
+ // is used. Fall back to ctx.cid (session default) for single-company deployments.
3122
+ // Note: raw Node.js http.IncomingMessage has no .query property — must use URL.searchParams.
3123
+ const queryCid = new URL(req.url, 'http://localhost').searchParams.get('companyId') || cid;
3124
+ if (!mgQuery) {
3125
+ // SQL parity: fall back to SQLite when Memgraph unavailable
3126
+ try {
3127
+ const hboStore = require('../../lib/hbo-core-store');
3128
+ const t = hboStore.getTask ? hboStore.getTask(taskId, queryCid) : null;
3129
+ if (!t) { jsonErr(res, 404, 'Task not found'); return true; }
3130
+ jsonOk(res, { task: t, _source: 'sqlite' });
3131
+ } catch (storeErr) {
3132
+ jsonErr(res, 503, `Task unavailable: Memgraph not connected`);
3133
+ }
3134
+ return true;
3135
+ }
3136
+ try {
3137
+ // Return full node — includes all properties: originKind, approvalId, pillarId,
3138
+ // executionStateJson, executionPolicyJson, workspacePath, etc.
3139
+ const result = await mgQuery('MATCH (t:Task {id: $id, companyId: $cid}) RETURN t', { id: taskId, cid: queryCid });
3140
+ if (result.rows && result.rows.length > 0) {
3141
+ const t = result.rows[0][0].properties || result.rows[0][0];
3142
+ jsonOk(res, { task: t });
3143
+ } else {
3144
+ jsonErr(res, 404, 'Task not found');
3145
+ }
3146
+ } catch (e) {
3147
+ jsonErr(res, 500, `Task query failed: ${safeErrMsg(e)}`);
3148
+ }
3149
+ return true;
3150
+ }
3151
+
3000
3152
  // POST /api/hbo/tasks
3001
3153
  if (method === 'POST' && pathname === '/api/hbo/tasks') {
3002
3154
  await handleCreateBusinessTask(req, res, ctx);
3003
3155
  return true;
3004
3156
  }
3005
3157
 
3158
+ // POST /api/hbo/tasks/:id/interpretation/refresh — P_EXPLAIN-04
3159
+ const taskInterpRefreshMatch = pathname.match(/^\/api\/hbo\/tasks\/([^/]+)\/interpretation\/refresh$/);
3160
+ if (method === 'POST' && taskInterpRefreshMatch) {
3161
+ const taskId = decodeURIComponent(taskInterpRefreshMatch[1]);
3162
+ const { mgQuery } = ctx;
3163
+ if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return true; }
3164
+ const cid = ctx.cid;
3165
+ try {
3166
+ const { createInterpretationExplanation } = require('../lib/interpretation-engine.js');
3167
+ const { rawWrite: _interpRw } = require('../lib/safe-memgraph.js');
3168
+ await createInterpretationExplanation(taskId, cid, _interpRw);
3169
+ jsonResponse(res, 200, { ok: true, taskId });
3170
+ } catch (err) {
3171
+ jsonResponse(res, 500, { error: `Interpretation refresh failed: ${err.message}` });
3172
+ }
3173
+ return true;
3174
+ }
3175
+
3176
+ // E-02: GET /api/hbo/tasks/:taskId/enrichment — hill chart position + recent PDSA cycles
3177
+ // Returns hillChart: { id, position, phase, updatedAt } | null
3178
+ // pdsaCycles: [...] (up to 5 most recent, ordered by createdAt DESC)
3179
+ const taskEnrichmentMatch = pathname.match(/^\/api\/hbo\/tasks\/([^/]+)\/enrichment$/);
3180
+ if (method === 'GET' && taskEnrichmentMatch) {
3181
+ const taskId = decodeURIComponent(taskEnrichmentMatch[1]);
3182
+ const { mgQuery, cid } = ctx;
3183
+ const queryCid = new URL(req.url, 'http://localhost').searchParams.get('companyId') || cid;
3184
+ if (!mgQuery) {
3185
+ jsonResponse(res, 503, { error: 'Memgraph not connected' });
3186
+ return true;
3187
+ }
3188
+ try {
3189
+ const [hillResult, pdsaResult] = await Promise.all([
3190
+ mgQuery(
3191
+ `MATCH (t:Task {id: $taskId, companyId: $cid})
3192
+ OPTIONAL MATCH (t)-[:HAS_HILL_CHART]->(h:HillChart)
3193
+ RETURN h`,
3194
+ { taskId, cid: queryCid }
3195
+ ),
3196
+ mgQuery(
3197
+ `MATCH (t:Task {id: $taskId, companyId: $cid})
3198
+ OPTIONAL MATCH (t)-[:HAS_PDSA]->(p:PDSACycle)
3199
+ RETURN p ORDER BY p.createdAt DESC LIMIT 5`,
3200
+ { taskId, cid: queryCid }
3201
+ ),
3202
+ ]);
3203
+ const hRow = hillResult.rows && hillResult.rows[0] ? hillResult.rows[0][0] : null;
3204
+ const hillChart = hRow
3205
+ ? (hRow.properties ?? hRow)
3206
+ : null;
3207
+ const pdsaCycles = (pdsaResult.rows || [])
3208
+ .map((r) => r[0])
3209
+ .filter(Boolean)
3210
+ .map((p) => p.properties ?? p);
3211
+ jsonOk(res, { hillChart, pdsaCycles });
3212
+ } catch (e) {
3213
+ jsonErr(res, 500, `Enrichment query failed: ${safeErrMsg(e)}`);
3214
+ }
3215
+ return true;
3216
+ }
3217
+
3006
3218
  // PATCH /api/hbo/tasks/:id
3007
3219
  const taskPatchMatch = pathname.match(/^\/api\/hbo\/tasks\/(.+)$/);
3008
3220
  if (method === 'PATCH' && taskPatchMatch) {
@@ -3102,6 +3314,127 @@ module.exports = function createHBORouter(handlers) {
3102
3314
  return true;
3103
3315
  }
3104
3316
 
3317
+ // ── T3-01: POST /api/hbo/pillar/:id/l2content — L2 research agent submits strategy ──────
3318
+ const pillarL2ContentMatch = pathname.match(/^\/api\/hbo\/pillar\/([^/]+)\/l2content$/);
3319
+ if (method === 'POST' && pillarL2ContentMatch) {
3320
+ const pillarId = decodeURIComponent(pillarL2ContentMatch[1]);
3321
+ const cid = ctx.cid;
3322
+ const mgQuery = ctx.mgQuery;
3323
+ if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return true; }
3324
+ try {
3325
+ const body = await readBody(req);
3326
+ if (!assertValidBody(body, res)) return true;
3327
+ const { l2Strategy, l2Content } = body || {};
3328
+ if (!l2Strategy && !l2Content) { jsonErr(res, 400, 'l2Strategy or l2Content required'); return true; }
3329
+ const r = await mgQuery(
3330
+ `MATCH (gp:GoalPillar {id: $pillarId, companyId: $cid})
3331
+ SET gp.l2Strategy = COALESCE($l2Strategy, gp.l2Strategy),
3332
+ gp.l2Content = COALESCE($l2Content, gp.l2Content),
3333
+ gp.l2ReviewStatus = 'pending_review',
3334
+ gp.updatedAt = datetime()
3335
+ RETURN gp.id AS id`,
3336
+ { pillarId, cid, l2Strategy: l2Strategy ? String(l2Strategy) : null, l2Content: l2Content ? String(l2Content) : null }
3337
+ );
3338
+ if (!parseRows(r).length) { jsonErr(res, 404, `GoalPillar ${pillarId} not found`); return true; }
3339
+ jsonOk(res, { ok: true, pillarId, l2ReviewStatus: 'pending_review' });
3340
+ } catch (e) { jsonErr(res, 500, `l2content submit failed: ${safeErrMsg(e)}`); }
3341
+ return true;
3342
+ }
3343
+
3344
+ // ── T3-02: POST /api/hbo/pillar/:id/l2review — CEO agent reviews L2 content ────────────
3345
+ const pillarL2ReviewMatch = pathname.match(/^\/api\/hbo\/pillar\/([^/]+)\/l2review$/);
3346
+ if (method === 'POST' && pillarL2ReviewMatch) {
3347
+ const pillarId = decodeURIComponent(pillarL2ReviewMatch[1]);
3348
+ const cid = ctx.cid;
3349
+ const mgQuery = ctx.mgQuery;
3350
+ if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return true; }
3351
+ try {
3352
+ const body = await readBody(req);
3353
+ if (!assertValidBody(body, res)) return true;
3354
+ const { verdict, reviewCritique, goalId, agentId } = body || {};
3355
+ if (!verdict || !['pass', 'fail'].includes(verdict)) { jsonErr(res, 400, 'verdict must be "pass" or "fail"'); return true; }
3356
+ if (verdict === 'pass') {
3357
+ await mgQuery(
3358
+ `MATCH (gp:GoalPillar {id: $pillarId, companyId: $cid})
3359
+ SET gp.l2ReviewStatus = 'approved', gp.l2ReviewedAt = datetime()
3360
+ RETURN gp.id`,
3361
+ { pillarId, cid }
3362
+ );
3363
+ jsonOk(res, { ok: true, pillarId, verdict: 'pass' });
3364
+ } else {
3365
+ // FAIL: increment cycle count, re-dispatch L2 research with critique
3366
+ const cycleResult = await mgQuery(
3367
+ `MATCH (gp:GoalPillar {id: $pillarId, companyId: $cid})
3368
+ SET gp.l2ReviewStatus = 'revision_needed',
3369
+ gp.l2ReviewCycles = COALESCE(toInteger(gp.l2ReviewCycles), 0) + 1
3370
+ RETURN gp.l2ReviewCycles AS cycles, gp.goalId AS goalId`,
3371
+ { pillarId, cid }
3372
+ );
3373
+ const cycleRow = parseRows(cycleResult)[0];
3374
+ const resolvedGoalId = goalId || (cycleRow && (cycleRow['cycles'] !== undefined ? null : cycleRow['goalId'])) || null;
3375
+ const cycles = cycleRow ? Number(cycleRow['cycles'] ?? 1) : 1;
3376
+ if (cycles < 3 && resolvedGoalId && agentId) {
3377
+ try {
3378
+ const { CascadeResearchDispatcher } = require('../lib/harada/cascade-research-dispatcher');
3379
+ const crd = new CascadeResearchDispatcher(mgQuery, cid);
3380
+ await crd.dispatchL2Research(pillarId, resolvedGoalId, cid, agentId, reviewCritique || '');
3381
+ } catch (dispErr) {
3382
+ process.stderr.write(`[hbo] l2review: re-dispatch failed: ${safeErrMsg(dispErr)}\n`);
3383
+ }
3384
+ }
3385
+ jsonOk(res, { ok: true, pillarId, verdict: 'fail', cycles });
3386
+ }
3387
+ } catch (e) { jsonErr(res, 500, `l2review failed: ${safeErrMsg(e)}`); }
3388
+ return true;
3389
+ }
3390
+
3391
+ // ── T3-03: POST /api/hbo/action-cell/:id/l3content — L3 research agent submits ──────────
3392
+ const cellL3ContentMatch = pathname.match(/^\/api\/hbo\/action-cell\/([^/]+)\/l3content$/);
3393
+ if (method === 'POST' && cellL3ContentMatch) {
3394
+ const cellId = decodeURIComponent(cellL3ContentMatch[1]);
3395
+ const cid = ctx.cid;
3396
+ const mgQuery = ctx.mgQuery;
3397
+ if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return true; }
3398
+ try {
3399
+ const body = await readBody(req);
3400
+ if (!assertValidBody(body, res)) return true;
3401
+ const { l3Plan, l3Content } = body || {};
3402
+ if (!l3Plan && !l3Content) { jsonErr(res, 400, 'l3Plan or l3Content required'); return true; }
3403
+ const r = await mgQuery(
3404
+ `MATCH (ac:ActionCell {id: $cellId, companyId: $cid})
3405
+ SET ac.l3Plan = COALESCE($l3Plan, ac.l3Plan),
3406
+ ac.l3Content = COALESCE($l3Content, ac.l3Content),
3407
+ ac.l3ReviewStatus = 'pending_review',
3408
+ ac.updatedAt = datetime()
3409
+ RETURN ac.id AS id`,
3410
+ { cellId, cid, l3Plan: l3Plan ? String(l3Plan) : null, l3Content: l3Content ? String(l3Content) : null }
3411
+ );
3412
+ if (!parseRows(r).length) { jsonErr(res, 404, `ActionCell ${cellId} not found`); return true; }
3413
+ jsonOk(res, { ok: true, cellId, l3ReviewStatus: 'pending_review' });
3414
+ } catch (e) { jsonErr(res, 500, `l3content submit failed: ${safeErrMsg(e)}`); }
3415
+ return true;
3416
+ }
3417
+
3418
+ // ── T3-03b: POST /api/hbo/action-cell/:id/l3review — dept head reviews L3 content ───────
3419
+ const cellL3ReviewMatch = pathname.match(/^\/api\/hbo\/action-cell\/([^/]+)\/l3review$/);
3420
+ if (method === 'POST' && cellL3ReviewMatch) {
3421
+ const cellId = decodeURIComponent(cellL3ReviewMatch[1]);
3422
+ const cid = ctx.cid;
3423
+ const mgQuery = ctx.mgQuery;
3424
+ if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return true; }
3425
+ try {
3426
+ const body = await readBody(req);
3427
+ if (!assertValidBody(body, res)) return true;
3428
+ const { verdict, reviewCritique } = body || {};
3429
+ if (!verdict || !['pass', 'fail'].includes(verdict)) { jsonErr(res, 400, 'verdict must be "pass" or "fail"'); return true; }
3430
+ const { CascadeJudge } = require('../lib/harada/cascade-judge');
3431
+ const judge = new CascadeJudge(mgQuery, cid);
3432
+ const result = await judge.judgeL3(cellId);
3433
+ jsonOk(res, { ok: true, cellId, verdict: result.verdict, critique: result.critique });
3434
+ } catch (e) { jsonErr(res, 500, `l3review failed: ${safeErrMsg(e)}`); }
3435
+ return true;
3436
+ }
3437
+
3105
3438
  // GET /api/hbo/metrics — ProcessMetrics and ControlChartSignals
3106
3439
  if (method === 'GET' && pathname === '/api/hbo/metrics') {
3107
3440
  await handleGetMetrics(req, res, ctx);
@@ -3259,11 +3592,17 @@ module.exports = function createHBORouter(handlers) {
3259
3592
  return true;
3260
3593
  }
3261
3594
 
3262
- // GET /api/hbo/command-centeraggregated snapshot for dashboard
3263
- if (method === 'GET' && pathname === '/api/hbo/command-center') {
3264
- await handleGetCommandCenter(req, res, ctx);
3265
- return true;
3266
- }
3595
+ // GET /api/hbo/blocked-workP9A: tasks stopped due to a blocker
3596
+ if (method === 'GET' && pathname === '/api/hbo/blocked-work') {
3597
+ await handleGetBlockedWork(req, res, ctx);
3598
+ return true;
3599
+ }
3600
+
3601
+ // GET /api/hbo/command-center — aggregated snapshot for dashboard
3602
+ if (method === 'GET' && pathname === '/api/hbo/command-center') {
3603
+ await handleGetCommandCenter(req, res, ctx);
3604
+ return true;
3605
+ }
3267
3606
 
3268
3607
  // GET /api/hbo/decisions/timeline — unified historical decisions timeline
3269
3608
  if (method === 'GET' && pathname === '/api/hbo/decisions/timeline') {
@@ -3271,6 +3610,21 @@ module.exports = function createHBORouter(handlers) {
3271
3610
  return true;
3272
3611
  }
3273
3612
 
3274
- return false;
3275
- };
3276
- };
3613
+ // G-01: Budget Policy CRUD
3614
+ if (method === 'POST' && pathname === '/api/budget-policies') {
3615
+ await handleCreateBudgetPolicy(req, res, ctx);
3616
+ return true;
3617
+ }
3618
+ const budgetPolicyMatch = pathname.match(/^\/api\/budget-policies\/([^/]+)$/);
3619
+ if (budgetPolicyMatch) {
3620
+ if (method === 'PATCH') { await handleUpdateBudgetPolicy(req, res, ctx, budgetPolicyMatch[1]); return true; }
3621
+ if (method === 'DELETE') { await handleDeleteBudgetPolicy(req, res, ctx, budgetPolicyMatch[1]); return true; }
3622
+ }
3623
+ if (method === 'GET' && pathname === '/api/budget-incidents') {
3624
+ await handleGetBudgetIncidents(req, res, ctx);
3625
+ return true;
3626
+ }
3627
+
3628
+ return false;
3629
+ };
3630
+ };