reflectt-node 0.1.7 → 0.1.11

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 (220) hide show
  1. package/README.md +13 -0
  2. package/defaults/TEAM-ROLES.yaml +317 -5
  3. package/defaults/gitignore.template +23 -0
  4. package/dist/agent-config.d.ts +51 -0
  5. package/dist/agent-config.d.ts.map +1 -0
  6. package/dist/agent-config.js +129 -0
  7. package/dist/agent-config.js.map +1 -0
  8. package/dist/agent-config.test.d.ts +2 -0
  9. package/dist/agent-config.test.d.ts.map +1 -0
  10. package/dist/agent-config.test.js +91 -0
  11. package/dist/agent-config.test.js.map +1 -0
  12. package/dist/agent-memories.d.ts +58 -0
  13. package/dist/agent-memories.d.ts.map +1 -0
  14. package/dist/agent-memories.js +168 -0
  15. package/dist/agent-memories.js.map +1 -0
  16. package/dist/agent-memories.test.d.ts +2 -0
  17. package/dist/agent-memories.test.d.ts.map +1 -0
  18. package/dist/agent-memories.test.js +327 -0
  19. package/dist/agent-memories.test.js.map +1 -0
  20. package/dist/agent-messaging.d.ts +50 -0
  21. package/dist/agent-messaging.d.ts.map +1 -0
  22. package/dist/agent-messaging.js +103 -0
  23. package/dist/agent-messaging.js.map +1 -0
  24. package/dist/agent-messaging.test.d.ts +2 -0
  25. package/dist/agent-messaging.test.d.ts.map +1 -0
  26. package/dist/agent-messaging.test.js +105 -0
  27. package/dist/agent-messaging.test.js.map +1 -0
  28. package/dist/agent-runs.d.ts +158 -0
  29. package/dist/agent-runs.d.ts.map +1 -0
  30. package/dist/agent-runs.js +514 -0
  31. package/dist/agent-runs.js.map +1 -0
  32. package/dist/agent-runs.test.d.ts +2 -0
  33. package/dist/agent-runs.test.d.ts.map +1 -0
  34. package/dist/agent-runs.test.js +386 -0
  35. package/dist/agent-runs.test.js.map +1 -0
  36. package/dist/approval-queue.test.d.ts +2 -0
  37. package/dist/approval-queue.test.d.ts.map +1 -0
  38. package/dist/approval-queue.test.js +118 -0
  39. package/dist/approval-queue.test.js.map +1 -0
  40. package/dist/artifact-store.d.ts +55 -0
  41. package/dist/artifact-store.d.ts.map +1 -0
  42. package/dist/artifact-store.js +128 -0
  43. package/dist/artifact-store.js.map +1 -0
  44. package/dist/artifact-store.test.d.ts +2 -0
  45. package/dist/artifact-store.test.d.ts.map +1 -0
  46. package/dist/artifact-store.test.js +119 -0
  47. package/dist/artifact-store.test.js.map +1 -0
  48. package/dist/boardHealthWorker.d.ts +32 -0
  49. package/dist/boardHealthWorker.d.ts.map +1 -1
  50. package/dist/boardHealthWorker.js +69 -2
  51. package/dist/boardHealthWorker.js.map +1 -1
  52. package/dist/buildInfo.d.ts.map +1 -1
  53. package/dist/buildInfo.js +47 -10
  54. package/dist/buildInfo.js.map +1 -1
  55. package/dist/canvas-input.test.d.ts +2 -0
  56. package/dist/canvas-input.test.d.ts.map +1 -0
  57. package/dist/canvas-input.test.js +96 -0
  58. package/dist/canvas-input.test.js.map +1 -0
  59. package/dist/canvas-render.test.d.ts +2 -0
  60. package/dist/canvas-render.test.d.ts.map +1 -0
  61. package/dist/canvas-render.test.js +95 -0
  62. package/dist/canvas-render.test.js.map +1 -0
  63. package/dist/capabilities/browser.d.ts +75 -0
  64. package/dist/capabilities/browser.d.ts.map +1 -0
  65. package/dist/capabilities/browser.js +172 -0
  66. package/dist/capabilities/browser.js.map +1 -0
  67. package/dist/channels.d.ts +1 -1
  68. package/dist/chat.d.ts +4 -0
  69. package/dist/chat.d.ts.map +1 -1
  70. package/dist/chat.js +6 -2
  71. package/dist/chat.js.map +1 -1
  72. package/dist/cli.js +41 -14
  73. package/dist/cli.js.map +1 -1
  74. package/dist/cloud.d.ts +2 -0
  75. package/dist/cloud.d.ts.map +1 -1
  76. package/dist/cloud.js +151 -64
  77. package/dist/cloud.js.map +1 -1
  78. package/dist/continuity-loop.d.ts.map +1 -1
  79. package/dist/continuity-loop.js +297 -29
  80. package/dist/continuity-loop.js.map +1 -1
  81. package/dist/cost-enforcement.d.ts +38 -0
  82. package/dist/cost-enforcement.d.ts.map +1 -0
  83. package/dist/cost-enforcement.js +84 -0
  84. package/dist/cost-enforcement.js.map +1 -0
  85. package/dist/db.d.ts.map +1 -1
  86. package/dist/db.js +131 -0
  87. package/dist/db.js.map +1 -1
  88. package/dist/deploy-monitor.d.ts +18 -0
  89. package/dist/deploy-monitor.d.ts.map +1 -0
  90. package/dist/deploy-monitor.js +165 -0
  91. package/dist/deploy-monitor.js.map +1 -0
  92. package/dist/e2e-loop-proof.test.d.ts +2 -0
  93. package/dist/e2e-loop-proof.test.d.ts.map +1 -0
  94. package/dist/e2e-loop-proof.test.js +104 -0
  95. package/dist/e2e-loop-proof.test.js.map +1 -0
  96. package/dist/email-sms-send.test.d.ts +2 -0
  97. package/dist/email-sms-send.test.d.ts.map +1 -0
  98. package/dist/email-sms-send.test.js +96 -0
  99. package/dist/email-sms-send.test.js.map +1 -0
  100. package/dist/events.d.ts +1 -1
  101. package/dist/events.d.ts.map +1 -1
  102. package/dist/events.js +2 -0
  103. package/dist/events.js.map +1 -1
  104. package/dist/executionSweeper.d.ts +1 -0
  105. package/dist/executionSweeper.d.ts.map +1 -1
  106. package/dist/executionSweeper.js +43 -7
  107. package/dist/executionSweeper.js.map +1 -1
  108. package/dist/files.d.ts.map +1 -1
  109. package/dist/files.js +17 -3
  110. package/dist/files.js.map +1 -1
  111. package/dist/fingerprint.d.ts +30 -0
  112. package/dist/fingerprint.d.ts.map +1 -0
  113. package/dist/fingerprint.js +117 -0
  114. package/dist/fingerprint.js.map +1 -0
  115. package/dist/github-webhook-attribution.d.ts +38 -0
  116. package/dist/github-webhook-attribution.d.ts.map +1 -0
  117. package/dist/github-webhook-attribution.js +123 -0
  118. package/dist/github-webhook-attribution.js.map +1 -0
  119. package/dist/github-webhook-chat.d.ts +75 -0
  120. package/dist/github-webhook-chat.d.ts.map +1 -0
  121. package/dist/github-webhook-chat.js +108 -0
  122. package/dist/github-webhook-chat.js.map +1 -0
  123. package/dist/handoff-state.test.d.ts +2 -0
  124. package/dist/handoff-state.test.d.ts.map +1 -0
  125. package/dist/handoff-state.test.js +102 -0
  126. package/dist/handoff-state.test.js.map +1 -0
  127. package/dist/health.d.ts +9 -0
  128. package/dist/health.d.ts.map +1 -1
  129. package/dist/health.js +18 -0
  130. package/dist/health.js.map +1 -1
  131. package/dist/host-error-correlation.d.ts +65 -0
  132. package/dist/host-error-correlation.d.ts.map +1 -0
  133. package/dist/host-error-correlation.js +123 -0
  134. package/dist/host-error-correlation.js.map +1 -0
  135. package/dist/inbox.d.ts.map +1 -1
  136. package/dist/inbox.js +4 -0
  137. package/dist/inbox.js.map +1 -1
  138. package/dist/index.js +76 -11
  139. package/dist/index.js.map +1 -1
  140. package/dist/notificationDedupeGuard.d.ts +4 -0
  141. package/dist/notificationDedupeGuard.d.ts.map +1 -1
  142. package/dist/notificationDedupeGuard.js +8 -4
  143. package/dist/notificationDedupeGuard.js.map +1 -1
  144. package/dist/presence.d.ts +37 -5
  145. package/dist/presence.d.ts.map +1 -1
  146. package/dist/presence.js +127 -16
  147. package/dist/presence.js.map +1 -1
  148. package/dist/pulse.d.ts +7 -0
  149. package/dist/pulse.d.ts.map +1 -1
  150. package/dist/pulse.js +15 -0
  151. package/dist/pulse.js.map +1 -1
  152. package/dist/review-sla.d.ts +9 -0
  153. package/dist/review-sla.d.ts.map +1 -0
  154. package/dist/review-sla.js +51 -0
  155. package/dist/review-sla.js.map +1 -0
  156. package/dist/review-state.d.ts +9 -0
  157. package/dist/review-state.d.ts.map +1 -0
  158. package/dist/review-state.js +17 -0
  159. package/dist/review-state.js.map +1 -0
  160. package/dist/routing-enforcement.test.d.ts +2 -0
  161. package/dist/routing-enforcement.test.d.ts.map +1 -0
  162. package/dist/routing-enforcement.test.js +86 -0
  163. package/dist/routing-enforcement.test.js.map +1 -0
  164. package/dist/run-retention.test.d.ts +2 -0
  165. package/dist/run-retention.test.d.ts.map +1 -0
  166. package/dist/run-retention.test.js +57 -0
  167. package/dist/run-retention.test.js.map +1 -0
  168. package/dist/run-stream.test.d.ts +2 -0
  169. package/dist/run-stream.test.d.ts.map +1 -0
  170. package/dist/run-stream.test.js +70 -0
  171. package/dist/run-stream.test.js.map +1 -0
  172. package/dist/schedule.d.ts +60 -0
  173. package/dist/schedule.d.ts.map +1 -0
  174. package/dist/schedule.js +176 -0
  175. package/dist/schedule.js.map +1 -0
  176. package/dist/server.d.ts.map +1 -1
  177. package/dist/server.js +1714 -88
  178. package/dist/server.js.map +1 -1
  179. package/dist/suppression-ledger.d.ts.map +1 -1
  180. package/dist/suppression-ledger.js +12 -3
  181. package/dist/suppression-ledger.js.map +1 -1
  182. package/dist/system-loop-state.d.ts +1 -1
  183. package/dist/system-loop-state.d.ts.map +1 -1
  184. package/dist/system-loop-state.js +1 -0
  185. package/dist/system-loop-state.js.map +1 -1
  186. package/dist/tasks.d.ts +9 -1
  187. package/dist/tasks.d.ts.map +1 -1
  188. package/dist/tasks.js +238 -41
  189. package/dist/tasks.js.map +1 -1
  190. package/dist/todoHoardingGuard.d.ts +17 -0
  191. package/dist/todoHoardingGuard.d.ts.map +1 -1
  192. package/dist/todoHoardingGuard.js +25 -2
  193. package/dist/todoHoardingGuard.js.map +1 -1
  194. package/dist/types.d.ts +1 -1
  195. package/dist/types.d.ts.map +1 -1
  196. package/dist/usage-tracking.d.ts +26 -0
  197. package/dist/usage-tracking.d.ts.map +1 -1
  198. package/dist/usage-tracking.js +91 -4
  199. package/dist/usage-tracking.js.map +1 -1
  200. package/dist/webhook-storage.d.ts +50 -0
  201. package/dist/webhook-storage.d.ts.map +1 -0
  202. package/dist/webhook-storage.js +102 -0
  203. package/dist/webhook-storage.js.map +1 -0
  204. package/dist/webhook-storage.test.d.ts +2 -0
  205. package/dist/webhook-storage.test.d.ts.map +1 -0
  206. package/dist/webhook-storage.test.js +86 -0
  207. package/dist/webhook-storage.test.js.map +1 -0
  208. package/dist/workflow-templates.d.ts +44 -0
  209. package/dist/workflow-templates.d.ts.map +1 -0
  210. package/dist/workflow-templates.js +154 -0
  211. package/dist/workflow-templates.js.map +1 -0
  212. package/dist/workflow-templates.test.d.ts +2 -0
  213. package/dist/workflow-templates.test.d.ts.map +1 -0
  214. package/dist/workflow-templates.test.js +76 -0
  215. package/dist/workflow-templates.test.js.map +1 -0
  216. package/package.json +3 -1
  217. package/public/dashboard.js +130 -37
  218. package/public/design-tokens-platform.md +118 -0
  219. package/public/design-tokens.css +195 -0
  220. package/public/docs.md +145 -2
package/dist/server.js CHANGED
@@ -53,7 +53,7 @@ import { autoPopulateCloseGate, tryAutoCloseTask, getMergeAttemptLog } from './p
53
53
  import { getDuplicateClosureCanonicalRefError } from './duplicateClosureGuard.js';
54
54
  import { recordReviewMutation, diffReviewFields, getAuditEntries, loadAuditLedger } from './auditLedger.js';
55
55
  import { listSharedFiles, readSharedFile, resolveTaskArtifact } from './shared-workspace-api.js';
56
- import { normalizeTaskArtifactPaths, buildGitHubBlobUrl, buildGitHubRawUrl } from './artifact-resolver.js';
56
+ import { normalizeArtifactPath, normalizeTaskArtifactPaths, buildGitHubBlobUrl, buildGitHubRawUrl } from './artifact-resolver.js';
57
57
  import { emitActivationEvent, getUserFunnelState, getFunnelSummary, hasCompletedEvent, isDay2Eligible, loadActivationFunnel, getConversionFunnel, getFailureDistribution, getWeeklyTrends, getOnboardingDashboard, } from './activationEvents.js';
58
58
  import { alertUnauthorizedApproval, alertFlipAttempt, getMutationAlertStatus, pruneOldAttempts } from './mutationAlert.js';
59
59
  import { mentionAckTracker } from './mention-ack.js';
@@ -69,9 +69,9 @@ import { researchManager } from './research.js';
69
69
  import { wsHeartbeat } from './ws-heartbeat.js';
70
70
  import { getBuildInfo } from './buildInfo.js';
71
71
  import { appendStoredLog, readStoredLogs, getStoredLogPath } from './logStore.js';
72
- import { getAgentRoles, getAgentRolesSource, loadAgentRoles, startConfigWatch, suggestAssignee, suggestReviewer, checkWipCap, saveAgentRoles, scoreAssignment, getAgentRole, getAgentAliases, setAgentDisplayName, resolveAgentMention } from './assignment.js';
72
+ import { getAgentRoles, getAgentRolesSource, loadAgentRoles, startConfigWatch, suggestAssignee, suggestReviewer, checkWipCap, saveAgentRoles, getAgentRole, getAgentAliases, setAgentDisplayName, resolveAgentMention } from './assignment.js';
73
73
  import { initTelemetry, trackRequest as trackTelemetryRequest, trackError as trackTelemetryError, trackTaskEvent, getSnapshot as getTelemetrySnapshot, getTelemetryConfig } from './telemetry.js';
74
- import { recordUsage, recordUsageBatch, getUsageSummary, getUsageByAgent, getUsageByModel, getUsageByTask, setCap, listCaps, deleteCap, checkCaps, getRoutingSuggestions, estimateCost, ensureUsageTables } from './usage-tracking.js';
74
+ import { recordUsageBatch, getUsageSummary, getUsageByAgent, getUsageByModel, getUsageByTask, getDailySpendByModel, getAvgCostByLane, getAvgCostByAgent, setCap, listCaps, deleteCap, checkCaps, getRoutingSuggestions, estimateCost, ensureUsageTables } from './usage-tracking.js';
75
75
  import { getTeamConfigHealth } from './team-config.js';
76
76
  import { SecretVault } from './secrets.js';
77
77
  import { initGitHubActorAuth, resolveGitHubTokenForActor } from './github-actor-auth.js';
@@ -80,6 +80,8 @@ import { computeCiFromCheckRuns, computeCiFromCombinedStatus } from './github-ci
80
80
  import { createGitHubIdentityProvider } from './github-identity.js';
81
81
  import { getProvisioningManager } from './provisioning.js';
82
82
  import { getWebhookDeliveryManager } from './webhooks.js';
83
+ import { enrichWebhookPayload } from './github-webhook-attribution.js';
84
+ import { formatGitHubEvent } from './github-webhook-chat.js';
83
85
  import { exportBundle, importBundle } from './portability.js';
84
86
  import { getNotificationManager } from './notifications.js';
85
87
  import { getConnectivityManager } from './connectivity.js';
@@ -115,7 +117,9 @@ import { getRoutingApprovalQueue, getRoutingSuggestion, buildApprovalPatch, buil
115
117
  import { calendarManager } from './calendar.js';
116
118
  import { calendarEvents } from './calendar-events.js';
117
119
  import { startReminderEngine, stopReminderEngine, getReminderEngineStats } from './calendar-reminder-engine.js';
120
+ import { startDeployMonitor, stopDeployMonitor } from './deploy-monitor.js';
118
121
  import { exportICS, exportEventICS, importICS } from './calendar-ical.js';
122
+ import { createScheduleEntry, getScheduleEntry, updateScheduleEntry, deleteScheduleEntry, getScheduleFeed } from './schedule.js';
119
123
  import { createDoc, getDoc, listDocs, updateDoc, deleteDoc } from './knowledge-docs.js';
120
124
  import { onTaskShipped, onDecisionComment, isDecisionComment } from './knowledge-auto-index.js';
121
125
  import { upsertHostHeartbeat, getHost, listHosts, removeHost } from './host-registry.js';
@@ -272,6 +276,13 @@ function normalizeConfiguredModel(value) {
272
276
  error: `Unknown model identifier "${raw}". Allowed aliases: ${Object.keys(MODEL_ALIASES).join(', ')} or provider/model format.`,
273
277
  };
274
278
  }
279
+ // ── Handoff state schema (max 3 columns per COO rule) ─────────────
280
+ const VALID_HANDOFF_DECISIONS = ['approved', 'rejected', 'needs_changes', 'escalated'];
281
+ const HandoffStateSchema = z.object({
282
+ reviewed_by: z.string().min(1),
283
+ decision: z.enum(VALID_HANDOFF_DECISIONS),
284
+ next_owner: z.string().min(1).optional(),
285
+ }).strict();
275
286
  const UpdateTaskSchema = z.object({
276
287
  title: z.string().min(1).optional(),
277
288
  description: z.string().optional(),
@@ -426,6 +437,9 @@ const QaBundleSchema = z.object({
426
437
  });
427
438
  const ReviewHandoffSchema = z.object({
428
439
  task_id: z.string().trim().regex(/^task-[a-zA-Z0-9-]+$/),
440
+ // Stored transactionally (server-side) from POST /tasks/:id/comments.
441
+ // This must always resolve via GET /tasks/:id/comments.
442
+ comment_id: z.string().trim().regex(/^tcomment-\d+-[a-z0-9]+$/i).optional(),
429
443
  repo: z.string().trim().min(1).optional(), // optional for config_only tasks
430
444
  artifact_path: z.string().trim().min(1), // relaxed: accepts any path (process/, ~/.reflectt/, etc.)
431
445
  test_proof: z.string().trim().min(1).optional(), // optional for non-code tasks
@@ -621,6 +635,29 @@ function enforceQaBundleGateForValidating(status, metadata, expectedTaskId) {
621
635
  hint: 'Use the same canonical process/... artifact path in both fields.',
622
636
  };
623
637
  }
638
+ // Canonical artifact reference (until central storage exists):
639
+ // For code tasks, artifact paths must be repo-relative under process/ (or a URL).
640
+ if (reviewPacket && !nonCodeLane) {
641
+ const packetArtifact = typeof reviewPacket.artifact_path === 'string' ? reviewPacket.artifact_path.trim() : '';
642
+ const packetIsUrl = /^https?:\/\//i.test(packetArtifact);
643
+ const packetIsProcess = packetArtifact.startsWith('process/');
644
+ if (packetArtifact && !packetIsUrl && !packetIsProcess) {
645
+ return {
646
+ ok: false,
647
+ error: 'Validating gate: metadata.qa_bundle.review_packet.artifact_path must be under process/ (repo-relative) or a URL',
648
+ hint: 'Set review_packet.artifact_path to process/TASK-...md (committed in the PR) or a PR/GitHub URL.',
649
+ };
650
+ }
651
+ const metaIsUrl = /^https?:\/\//i.test(artifactPath);
652
+ const metaIsProcess = artifactPath.startsWith('process/');
653
+ if (artifactPath && !metaIsUrl && !metaIsProcess) {
654
+ return {
655
+ ok: false,
656
+ error: 'Validating gate: metadata.artifact_path must be under process/ (repo-relative) or a URL',
657
+ hint: 'Set metadata.artifact_path to process/TASK-...md (committed in the PR) or a PR/GitHub URL.',
658
+ };
659
+ }
660
+ }
624
661
  // PR integrity: validate commit SHA + changed_files against live PR head
625
662
  if (!nonCodeLane && reviewPacket?.pr_url) {
626
663
  const overrideFlag = metadataObj.pr_integrity_override === true;
@@ -742,6 +779,40 @@ function applyReviewStateMetadata(existing, parsed, mergedMeta, now) {
742
779
  };
743
780
  console.log(`[ArtifactNormalize] task ${existing.id}: normalized`, normResult.warnings);
744
781
  }
782
+ // ── Review handoff comment pointer repair/fill ──
783
+ // If review_handoff exists, ensure comment_id points to a real comment.
784
+ // We do this *server-side* to avoid phantom/unresolvable pointers.
785
+ const rh = metadata.review_handoff;
786
+ if (rh && typeof rh === 'object' && !Array.isArray(rh)) {
787
+ const rhAny = rh;
788
+ const commentId = typeof rhAny.comment_id === 'string' ? rhAny.comment_id.trim() : '';
789
+ const all = taskManager.getTaskComments(existing.id, { includeSuppressed: true });
790
+ const resolves = commentId ? all.some(c => c.id === commentId) : false;
791
+ if (!resolves) {
792
+ // Prefer an explicit category tag; fallback to most recent comment by assignee.
793
+ const assignee = (existing.assignee || '').trim().toLowerCase();
794
+ const byHandoffCategory = all
795
+ .filter(c => {
796
+ const cat = String(c.category || '').toLowerCase();
797
+ return cat === 'review_handoff' || cat === 'handoff';
798
+ });
799
+ const byAssignee = assignee
800
+ ? all.filter(c => String(c.author || '').trim().toLowerCase() === assignee)
801
+ : [];
802
+ const candidate = (byHandoffCategory.length > 0
803
+ ? byHandoffCategory[byHandoffCategory.length - 1]
804
+ : (byAssignee.length > 0 ? byAssignee[byAssignee.length - 1] : (all.length > 0 ? all[all.length - 1] : null)));
805
+ if (candidate) {
806
+ metadata.review_handoff = { ...rhAny, comment_id: candidate.id };
807
+ metadata.review_handoff_comment_id_autofilled = {
808
+ previous: commentId || null,
809
+ next: candidate.id,
810
+ at: now,
811
+ strategy: byHandoffCategory.length > 0 ? 'category:review_handoff' : (byAssignee.length > 0 ? 'latest_assignee_comment' : 'latest_comment'),
812
+ };
813
+ }
814
+ }
815
+ }
745
816
  }
746
817
  if (previousStatus === 'validating' && nextStatus === 'doing' && !incomingReviewState) {
747
818
  metadata.review_state = 'needs_author';
@@ -859,7 +930,13 @@ function isEchoOutOfLaneTask(task) {
859
930
  // For Echo, anything classified outside content/docs voice lane gets flagged unless reassigned.
860
931
  return true;
861
932
  }
862
- function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
933
+ const reviewHandoffValidationStats = {
934
+ failures: 0,
935
+ lastFailureAt: 0,
936
+ lastFailureTaskId: '',
937
+ lastFailureError: '',
938
+ };
939
+ async function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
863
940
  if (status !== 'validating')
864
941
  return { ok: true };
865
942
  if (isTaskAutomatedRecurring(metadata))
@@ -874,7 +951,7 @@ function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
874
951
  return {
875
952
  ok: false,
876
953
  error: 'Review handoff required: metadata.review_handoff must include task_id, artifact_path, known_caveats (and pr_url + commit_sha unless doc_only=true, config_only=true, or non_code=true).',
877
- hint: 'Example: { "review_handoff": { "task_id":"task-...", "artifact_path":"process/TASK-...md", "known_caveats":"none" } }. For non-code tasks (finance, legal, ops): set non_code=true. For config tasks: set config_only=true.',
954
+ hint: 'Example: { "review_handoff": { "task_id":"task-...", "artifact_path":"process/TASK-...md", "known_caveats":"none" } }. For non-code tasks: set non_code=true. Recommended: post the handoff comment with category="review_handoff" so the server stamps comment_id automatically.',
878
955
  };
879
956
  }
880
957
  const handoff = parsed.data;
@@ -885,6 +962,38 @@ function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
885
962
  hint: 'Set metadata.review_handoff.task_id to the exact task being transitioned.',
886
963
  };
887
964
  }
965
+ // Ensure review_handoff.comment_id resolves to a real comment.
966
+ // If missing (or stale), we repair it from existing comments; if none exist,
967
+ // we create a server-authored pointer comment so reviewers always have a stable anchor.
968
+ const commentsAll = taskManager.getTaskComments(taskId, { includeSuppressed: true });
969
+ let commentId = typeof handoff.comment_id === 'string' ? handoff.comment_id.trim() : '';
970
+ let handoffComment = commentId ? (commentsAll.find(c => c.id === commentId) || null) : null;
971
+ if (!handoffComment) {
972
+ const byCategory = commentsAll.filter(c => {
973
+ const cat = String(c.category || '').toLowerCase();
974
+ return cat === 'review_handoff' || cat === 'handoff';
975
+ });
976
+ const candidate = byCategory.length > 0
977
+ ? byCategory[byCategory.length - 1]
978
+ : (commentsAll.length > 0 ? commentsAll[commentsAll.length - 1] : null);
979
+ if (candidate) {
980
+ commentId = candidate.id;
981
+ handoffComment = candidate;
982
+ }
983
+ else {
984
+ // No comments exist — create a stable anchor comment.
985
+ const created = await taskManager.addTaskComment(taskId, 'system', 'Auto-handoff: review_handoff is recorded in metadata.review_handoff (no explicit handoff comment was posted).', { category: 'review_handoff', provenance: { kind: 'auto_review_handoff', source: 'validating_gate' } });
986
+ commentId = created.id;
987
+ handoffComment = created;
988
+ }
989
+ // Persist repaired comment_id into the handoff metadata (server-side).
990
+ ;
991
+ handoff.comment_id = commentId;
992
+ const rhObj = root.review_handoff;
993
+ if (rhObj && typeof rhObj === 'object' && !Array.isArray(rhObj)) {
994
+ rhObj.comment_id = commentId;
995
+ }
996
+ }
888
997
  // config_only: artifacts live in ~/.reflectt/, no repo/PR required
889
998
  // doc_only/non_code/design/docs lanes: no PR/commit required
890
999
  const nonCodeLane = handoff.non_code === true || isDesignOrDocsLane(root);
@@ -904,7 +1013,43 @@ function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
904
1013
  };
905
1014
  }
906
1015
  }
907
- return { ok: true };
1016
+ // Artifact retrievability gate.
1017
+ // If the artifact isn't accessible from this node (repo / shared-workspace / GitHub fallback),
1018
+ // a reviewer on another host will almost certainly be blocked.
1019
+ const artifactPath = typeof handoff.artifact_path === 'string' ? handoff.artifact_path.trim() : '';
1020
+ const norm = normalizeArtifactPath(artifactPath);
1021
+ if (norm.rejected || !norm.normalized) {
1022
+ return {
1023
+ ok: false,
1024
+ error: `Validating gate: review_handoff.artifact_path is not a valid retrievable reference (${norm.rejectReason || 'invalid path'}).`,
1025
+ hint: 'Use either (a) a PR/GitHub URL, or (b) a repo-relative path (e.g. process/TASK-...md) that exists on the referenced PR/commit, or (c) put the full spec in the handoff comment and point artifact_path at a stable URL.',
1026
+ };
1027
+ }
1028
+ // URLs are assumed retrievable.
1029
+ if (/^https?:\/\//i.test(norm.normalized))
1030
+ return { ok: true };
1031
+ // If the file is accessible locally (repo or shared-workspace), accept.
1032
+ const repoRoot = resolve(import.meta.dirname || process.cwd(), '..');
1033
+ const resolved = await resolveTaskArtifact(norm.normalized, repoRoot);
1034
+ if (resolved.accessible)
1035
+ return { ok: true };
1036
+ // GitHub fallback: if PR+commit are known and artifact is process/*, we can build a stable blob URL.
1037
+ const prUrl = root.pr_url || root.qa_bundle?.review_packet?.pr_url || root.review_handoff?.pr_url;
1038
+ const commitSha = root.commit_sha || root.commit || root.qa_bundle?.review_packet?.commit || root.review_handoff?.commit_sha;
1039
+ if (typeof prUrl === 'string' && typeof commitSha === 'string' && norm.normalized.startsWith('process/')) {
1040
+ const blobUrl = buildGitHubBlobUrl(prUrl, commitSha, norm.normalized);
1041
+ if (blobUrl)
1042
+ return { ok: true };
1043
+ }
1044
+ // For non-code tasks, the handoff comment itself is considered the primary artifact.
1045
+ // We only require that comment_id resolves (handled above).
1046
+ if (nonCodeLane)
1047
+ return { ok: true };
1048
+ return {
1049
+ ok: false,
1050
+ error: 'Validating gate: review_handoff.artifact_path is not retrievable from repo/shared-workspace/GitHub fallback.',
1051
+ hint: 'Move the artifact into shared-workspace process/, or reference a PR+commit so GitHub blob fallback can resolve it (process/* only).',
1052
+ };
908
1053
  }
909
1054
  const DEFAULT_LIMITS = {
910
1055
  chatMessages: 50,
@@ -1516,7 +1661,8 @@ export async function createServer() {
1516
1661
  await app.register(fastifyWebsocket);
1517
1662
  // Multipart file uploads (50MB limit)
1518
1663
  const fastifyMultipart = await import('@fastify/multipart');
1519
- await app.register(fastifyMultipart.default, { limits: { fileSize: 50 * 1024 * 1024 } });
1664
+ const { MAX_SIZE_BYTES: _multipartMax } = await import('./files.js');
1665
+ await app.register(fastifyMultipart.default, { limits: { fileSize: _multipartMax } });
1520
1666
  // Normalize error responses to a consistent envelope
1521
1667
  app.addHook('preSerialization', async (request, reply, payload) => {
1522
1668
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
@@ -1561,6 +1707,8 @@ export async function createServer() {
1561
1707
  envelope.gate = body.gate;
1562
1708
  if (body.problems !== undefined)
1563
1709
  envelope.problems = body.problems;
1710
+ if (body.tombstone !== undefined)
1711
+ envelope.tombstone = body.tombstone;
1564
1712
  if (alreadyEnvelope && body.data !== undefined)
1565
1713
  envelope.data = body.data;
1566
1714
  // Minimal persisted error log: enables /logs to return real entries.
@@ -1847,6 +1995,10 @@ export async function createServer() {
1847
1995
  // Board health execution worker — config from policy
1848
1996
  boardHealthWorker.updateConfig(policy.boardHealth);
1849
1997
  boardHealthWorker.start();
1998
+ // Activate noise budget enforcement — the 24h canary period is complete.
1999
+ // Canary mode (log-only) is still the default in case of fresh installs,
2000
+ // but on a running server we want real duplicate suppression.
2001
+ noiseBudgetManager.activateEnforcement();
1850
2002
  // Noise budget: wire digest flush handler to send batched messages to #ops
1851
2003
  noiseBudgetManager.setDigestFlushHandler(async (channel, entries) => {
1852
2004
  if (entries.length === 0)
@@ -1876,6 +2028,8 @@ export async function createServer() {
1876
2028
  startShippedHeartbeat();
1877
2029
  // Calendar reminder engine — polls for pending reminders every 30s
1878
2030
  startReminderEngine();
2031
+ // Deploy monitor — alert within 5m when production deploys fail (Vercel + health URL)
2032
+ startDeployMonitor();
1879
2033
  app.addHook('onClose', async () => {
1880
2034
  clearInterval(idleNudgeTimer);
1881
2035
  clearInterval(cadenceWatchdogTimer);
@@ -1885,6 +2039,7 @@ export async function createServer() {
1885
2039
  stopShippedHeartbeat();
1886
2040
  stopTeamPulse();
1887
2041
  stopReminderEngine();
2042
+ stopDeployMonitor();
1888
2043
  stopKeepalive();
1889
2044
  stopSelfKeepalive();
1890
2045
  wsHeartbeat.stop();
@@ -2703,6 +2858,9 @@ export async function createServer() {
2703
2858
  reflectionPipeline: { registered: Boolean(reflectionPipelineTimer), lastTickAt: ticks.reflection_pipeline, lastTickAgeSec: ageSec(ticks.reflection_pipeline) },
2704
2859
  boardHealthWorker: { registered: board.running, lastTickAt: ticks.board_health || board.lastTickAt, lastTickAgeSec: ageSec(ticks.board_health || board.lastTickAt) },
2705
2860
  },
2861
+ reviewHandoffValidation: {
2862
+ ...reviewHandoffValidationStats,
2863
+ },
2706
2864
  reflectionPipelineHealth: {
2707
2865
  ...reflectionPipelineHealth,
2708
2866
  },
@@ -3037,6 +3195,23 @@ export async function createServer() {
3037
3195
  };
3038
3196
  }
3039
3197
  const data = parsedBody.data;
3198
+ // Reserve system sender for server-internal control-plane messages.
3199
+ // Prevent browser clients (dashboard.js) or external callers from spoofing system alerts.
3200
+ //
3201
+ // Allow explicit internal callers (tests/tools) via header:
3202
+ // x-reflectt-internal: true
3203
+ if (data.from === 'system') {
3204
+ const internal = String(request.headers['x-reflectt-internal'] || '').toLowerCase() === 'true';
3205
+ if (!internal) {
3206
+ reply.code(403);
3207
+ return {
3208
+ success: false,
3209
+ error: 'Sender "system" is reserved (use from="dashboard" or your agent name).',
3210
+ code: 'SENDER_RESERVED',
3211
+ hint: 'Only internal callers may emit system messages. Add header x-reflectt-internal:true for test/tooling.',
3212
+ };
3213
+ }
3214
+ }
3040
3215
  // Require at least content or attachments
3041
3216
  if (!data.content && (!data.attachments || data.attachments.length === 0)) {
3042
3217
  reply.code(400);
@@ -4306,6 +4481,24 @@ export async function createServer() {
4306
4481
  };
4307
4482
  }
4308
4483
  if (!resolved.task || !resolved.resolvedId) {
4484
+ // Check if this task was deleted — return 410 Gone with tombstone metadata instead of 404.
4485
+ const tombstone = taskManager.getTaskDeletionTombstone(request.params.id);
4486
+ if (tombstone) {
4487
+ reply.code(410);
4488
+ return {
4489
+ success: false,
4490
+ error: 'Task has been deleted',
4491
+ code: 'TASK_DELETED',
4492
+ status: 410,
4493
+ tombstone: {
4494
+ taskId: tombstone.taskId,
4495
+ deletedAt: tombstone.deletedAt,
4496
+ deletedBy: tombstone.deletedBy,
4497
+ previousStatus: tombstone.previousStatus,
4498
+ title: tombstone.title,
4499
+ },
4500
+ };
4501
+ }
4309
4502
  reply.code(404);
4310
4503
  return {
4311
4504
  error: 'Task not found',
@@ -4321,6 +4514,46 @@ export async function createServer() {
4321
4514
  matchType: resolved.matchType,
4322
4515
  };
4323
4516
  });
4517
+ // ── Task handoff state ─────────────────────────────────────────────
4518
+ app.get('/tasks/:id/handoff', async (request, reply) => {
4519
+ const resolved = taskManager.resolveTaskId(request.params.id);
4520
+ if (!resolved.task) {
4521
+ reply.code(404);
4522
+ return { error: 'Task not found' };
4523
+ }
4524
+ const meta = resolved.task.metadata;
4525
+ const handoff = meta?.handoff_state ?? null;
4526
+ return {
4527
+ taskId: resolved.resolvedId,
4528
+ status: resolved.task.status,
4529
+ handoff_state: handoff,
4530
+ };
4531
+ });
4532
+ app.put('/tasks/:id/handoff', async (request, reply) => {
4533
+ const resolved = taskManager.resolveTaskId(request.params.id);
4534
+ if (!resolved.task || !resolved.resolvedId) {
4535
+ reply.code(404);
4536
+ return { error: 'Task not found' };
4537
+ }
4538
+ const body = request.body;
4539
+ const result = HandoffStateSchema.safeParse(body);
4540
+ if (!result.success) {
4541
+ reply.code(422);
4542
+ return {
4543
+ error: `Invalid handoff_state: ${result.error.issues.map(i => i.message).join(', ')}`,
4544
+ hint: 'Required: reviewed_by (string), decision (approved|rejected|needs_changes|escalated). Optional: next_owner (string).',
4545
+ };
4546
+ }
4547
+ const existingMeta = (resolved.task.metadata || {});
4548
+ taskManager.updateTask(resolved.resolvedId, {
4549
+ metadata: { ...existingMeta, handoff_state: result.data },
4550
+ });
4551
+ return {
4552
+ success: true,
4553
+ taskId: resolved.resolvedId,
4554
+ handoff_state: result.data,
4555
+ };
4556
+ });
4324
4557
  // Task artifact visibility — resolves artifact paths and checks accessibility
4325
4558
  app.get('/tasks/:id/artifacts', async (request, reply) => {
4326
4559
  const resolved = resolveTaskFromParam(request.params.id, reply);
@@ -4987,6 +5220,24 @@ export async function createServer() {
4987
5220
  // fan out inbox-visible notifications to assignee/reviewer + explicit @mentions.
4988
5221
  // Notification routing respects per-agent preferences (quiet hours, mute, filters).
4989
5222
  const task = taskManager.getTask(resolved.resolvedId);
5223
+ // ── Transactional review_handoff.comment_id stamping ──
5224
+ // If the author tags this comment as the handoff entrypoint, the server stamps
5225
+ // metadata.review_handoff.comment_id from the persisted comment ID.
5226
+ // This prevents clients from supplying phantom IDs.
5227
+ const category = typeof data.category === 'string' ? String(data.category).trim().toLowerCase() : '';
5228
+ if (task && (category === 'review_handoff' || category === 'handoff')) {
5229
+ const meta = (task.metadata || {});
5230
+ const rh = meta.review_handoff;
5231
+ if (rh && typeof rh === 'object' && !Array.isArray(rh)) {
5232
+ const rhAny = rh;
5233
+ if (rhAny.comment_id !== comment.id) {
5234
+ taskManager.patchTaskMetadata(task.id, {
5235
+ review_handoff: { ...rhAny, comment_id: comment.id },
5236
+ review_handoff_comment_id_stamped_at: Date.now(),
5237
+ });
5238
+ }
5239
+ }
5240
+ }
4990
5241
  // Never fan out notifications for test-harness tasks.
4991
5242
  // Our repo contains a few "LIVE server" tests (BASE=127.0.0.1:4445) that create
4992
5243
  // tasks/comments with metadata.is_test=true. Without this guard, running `npm test`
@@ -5469,7 +5720,9 @@ export async function createServer() {
5469
5720
  || data.metadata?.skip_dedup === true
5470
5721
  || data.metadata?.is_test === true;
5471
5722
  if (!skipDedup && data.assignee) {
5472
- const TASK_DEDUP_WINDOW_MS = 4 * 60 * 60 * 1000; // 4 hours
5723
+ // 60-second window targets gateway reconnect double-fire (typical gap: <10s)
5724
+ // without blocking legitimate same-title task creation later in the day.
5725
+ const TASK_DEDUP_WINDOW_MS = 60_000; // 60 seconds
5473
5726
  const cutoff = Date.now() - TASK_DEDUP_WINDOW_MS;
5474
5727
  const normalizedTitle = data.title.trim().toLowerCase();
5475
5728
  const activeTasks = taskManager.listTasks({ includeTest: true }).filter(t => t.status !== 'done'
@@ -5477,16 +5730,15 @@ export async function createServer() {
5477
5730
  && t.createdAt >= cutoff
5478
5731
  && t.title.trim().toLowerCase() === normalizedTitle);
5479
5732
  if (activeTasks.length > 0) {
5733
+ // Return 200 with the existing task (collapse, not reject).
5734
+ // Agents that create-on-reconnect receive a success response identical
5735
+ // to what they'd get from a new creation — no retry loop triggered.
5480
5736
  const existing = activeTasks[0];
5481
- reply.code(409);
5482
5737
  return {
5483
- success: false,
5484
- error: 'Duplicate task',
5485
- code: 'DUPLICATE_TASK',
5486
- status: 409,
5487
- existing_id: existing.id,
5488
- existing_status: existing.status,
5489
- hint: `Task "${existing.title}" already exists for ${data.assignee} (${existing.id}, status: ${existing.status}). Created ${Math.round((Date.now() - existing.createdAt) / 60000)}m ago.`,
5738
+ success: true,
5739
+ task: existing,
5740
+ deduplicated: true,
5741
+ hint: `Duplicate suppressed — task "${existing.title}" already exists for ${data.assignee} (${existing.id}, status: ${existing.status}, created ${Math.round((Date.now() - existing.createdAt) / 1000)}s ago).`,
5490
5742
  };
5491
5743
  }
5492
5744
  }
@@ -5879,6 +6131,14 @@ export async function createServer() {
5879
6131
  const pruned = boardHealthWorker.pruneAuditLog(maxAgeDays);
5880
6132
  return { success: true, pruned };
5881
6133
  });
6134
+ app.post('/board-health/quiet-window', async () => {
6135
+ boardHealthWorker.resetQuietWindow();
6136
+ return {
6137
+ success: true,
6138
+ quietUntil: Date.now() + (boardHealthWorker.getStatus().config?.restartQuietWindowMs ?? 300_000),
6139
+ message: 'Quiet window reset — ready-queue alerts suppressed for restart window',
6140
+ };
6141
+ });
5882
6142
  // ── Agent change feed ─────────────────────────────────────────────────
5883
6143
  app.get('/feed/:agent', async (request) => {
5884
6144
  const { agent } = request.params;
@@ -6106,7 +6366,23 @@ export async function createServer() {
6106
6366
  }
6107
6367
  // Merge incoming metadata with existing for gate checks + persistence.
6108
6368
  // Apply auto-defaults (ETA, artifact_path) when not explicitly provided.
6109
- const incomingMeta = parsed.metadata || {};
6369
+ // Do not accept caller-supplied review_handoff.comment_id (it must be stamped server-side from POST /tasks/:id/comments).
6370
+ // If a client tries to patch it directly, we strip it to prevent phantom pointers.
6371
+ const incomingMetaRaw = (parsed.metadata || {});
6372
+ const incomingMeta = { ...incomingMetaRaw };
6373
+ const incomingRh = incomingMeta.review_handoff;
6374
+ if (incomingRh && typeof incomingRh === 'object' && !Array.isArray(incomingRh)) {
6375
+ const rhAny = incomingRh;
6376
+ if (typeof rhAny.comment_id === 'string') {
6377
+ const { comment_id, ...rest } = rhAny;
6378
+ incomingMeta.review_handoff = rest;
6379
+ incomingMeta.review_handoff_comment_id_stripped = {
6380
+ stripped: true,
6381
+ attempted: comment_id,
6382
+ at: Date.now(),
6383
+ };
6384
+ }
6385
+ }
6110
6386
  const effectiveTargetStatus = parsed.status ?? existing.status;
6111
6387
  const autoFilledMeta = applyAutoDefaults(lookup.resolvedId, effectiveTargetStatus, incomingMeta);
6112
6388
  const mergedRawMeta = { ...(existing.metadata || {}), ...autoFilledMeta };
@@ -6147,6 +6423,22 @@ export async function createServer() {
6147
6423
  mergedMeta.reopened_from = existing.status;
6148
6424
  }
6149
6425
  }
6426
+ // ── Handoff state validation ──
6427
+ if (mergedMeta.handoff_state && typeof mergedMeta.handoff_state === 'object') {
6428
+ const handoffResult = HandoffStateSchema.safeParse(mergedMeta.handoff_state);
6429
+ if (!handoffResult.success) {
6430
+ reply.code(422);
6431
+ return {
6432
+ success: false,
6433
+ error: `Invalid handoff_state: ${handoffResult.error.issues.map(i => i.message).join(', ')}`,
6434
+ code: 'INVALID_HANDOFF_STATE',
6435
+ hint: 'handoff_state must have: reviewed_by (string), decision (approved|rejected|needs_changes|escalated), optional next_owner (string). Max 3 fields per COO rule.',
6436
+ gate: 'handoff_state',
6437
+ };
6438
+ }
6439
+ // Stamp validated handoff
6440
+ mergedMeta.handoff_state = handoffResult.data;
6441
+ }
6150
6442
  // ── Cancel reason gate: require cancel_reason when transitioning to cancelled ──
6151
6443
  if (parsed.status === 'cancelled') {
6152
6444
  const meta = (incomingMeta ?? {});
@@ -6285,8 +6577,12 @@ export async function createServer() {
6285
6577
  hint: qaGate.hint,
6286
6578
  };
6287
6579
  }
6288
- const handoffGate = enforceReviewHandoffGateForValidating(effectiveStatus, lookup.resolvedId, mergedMeta);
6580
+ const handoffGate = await enforceReviewHandoffGateForValidating(effectiveStatus, lookup.resolvedId, mergedMeta);
6289
6581
  if (!handoffGate.ok) {
6582
+ reviewHandoffValidationStats.failures += 1;
6583
+ reviewHandoffValidationStats.lastFailureAt = Date.now();
6584
+ reviewHandoffValidationStats.lastFailureTaskId = lookup.resolvedId;
6585
+ reviewHandoffValidationStats.lastFailureError = handoffGate.error;
6290
6586
  reply.code(400);
6291
6587
  return {
6292
6588
  success: false,
@@ -6595,20 +6891,24 @@ export async function createServer() {
6595
6891
  if (task.assignee) {
6596
6892
  if (parsed.status === 'done') {
6597
6893
  presenceManager.recordActivity(task.assignee, 'task_completed');
6598
- presenceManager.updatePresence(task.assignee, 'working');
6894
+ presenceManager.updatePresence(task.assignee, 'working', null);
6599
6895
  trackTaskEvent('completed');
6600
6896
  }
6601
6897
  else if (parsed.status === 'doing') {
6602
- presenceManager.updatePresence(task.assignee, 'working');
6898
+ presenceManager.updatePresence(task.assignee, 'working', task.id);
6603
6899
  }
6604
6900
  else if (parsed.status === 'blocked') {
6605
- presenceManager.updatePresence(task.assignee, 'blocked');
6901
+ presenceManager.updatePresence(task.assignee, 'blocked', task.id);
6606
6902
  }
6607
6903
  else if (parsed.status === 'validating') {
6608
- presenceManager.updatePresence(task.assignee, 'reviewing');
6904
+ presenceManager.updatePresence(task.assignee, 'reviewing', task.id);
6609
6905
  }
6610
6906
  }
6611
6907
  // ── Reviewer notification: @mention reviewer when task enters validating ──
6908
+ // NOTE: A dedup_key is set here so the inline chat dedup guard suppresses
6909
+ // any duplicate reviewRequested send that may arrive via the statusNotifTargets
6910
+ // loop below for the same task+transition. Without it, two messages fire for
6911
+ // every todo→validating transition (this direct send + the loop send).
6612
6912
  if (parsed.status === 'validating' && existing.status !== 'validating' && existing.reviewer) {
6613
6913
  const taskMeta = task.metadata;
6614
6914
  const prUrl = taskMeta?.review_handoff?.pr_url
@@ -6617,6 +6917,7 @@ export async function createServer() {
6617
6917
  const prLine = prUrl ? `\nPR: ${prUrl}` : '';
6618
6918
  chatManager.sendMessage({
6619
6919
  from: 'system',
6920
+ to: existing.reviewer,
6620
6921
  content: `@${existing.reviewer} [reviewRequested:${task.id}] ${task.title} → validating${prLine}`,
6621
6922
  channel: 'task-notifications',
6622
6923
  metadata: {
@@ -6624,6 +6925,7 @@ export async function createServer() {
6624
6925
  taskId: task.id,
6625
6926
  reviewer: existing.reviewer,
6626
6927
  prUrl: prUrl || undefined,
6928
+ dedup_key: `review-requested:${task.id}:${task.updatedAt}`,
6627
6929
  },
6628
6930
  }).catch(() => { }); // Non-blocking
6629
6931
  }
@@ -6726,6 +7028,7 @@ export async function createServer() {
6726
7028
  const reviewMsg = `@${task.reviewer} review requested: **${task.title}** (${task.id})${prLink}. Please approve or flag issues.`;
6727
7029
  chatManager.sendMessage({
6728
7030
  from: 'system',
7031
+ to: task.reviewer,
6729
7032
  content: reviewMsg,
6730
7033
  channel: 'reviews',
6731
7034
  metadata: {
@@ -6746,13 +7049,17 @@ export async function createServer() {
6746
7049
  // Dedupe guard: prevent stale/out-of-order notification events
6747
7050
  const { shouldEmitNotification } = await import('./notificationDedupeGuard.js');
6748
7051
  for (const target of statusNotifTargets) {
6749
- // Check dedupe guard before emitting
7052
+ // Check dedupe guard before emitting.
7053
+ // Pass targetAgent so each recipient gets an independent cursor — prevents
7054
+ // the first recipient's cursor update from suppressing later recipients for
7055
+ // the same event (e.g. assignee + reviewer both getting taskCompleted on 'done').
6750
7056
  const dedupeCheck = shouldEmitNotification({
6751
7057
  taskId: task.id,
6752
7058
  eventUpdatedAt: task.updatedAt,
6753
7059
  eventStatus: parsed.status,
6754
7060
  currentTaskStatus: task.status,
6755
7061
  currentTaskUpdatedAt: task.updatedAt,
7062
+ targetAgent: target.agent,
6756
7063
  });
6757
7064
  if (!dedupeCheck.emit) {
6758
7065
  console.log(`[NotifDedupe] Suppressed: ${dedupeCheck.reason}`);
@@ -6765,7 +7072,13 @@ export async function createServer() {
6765
7072
  message: `Task ${task.id} → ${parsed.status}`,
6766
7073
  });
6767
7074
  if (routing.shouldNotify) {
6768
- // Route through inbox/chat based on delivery method preference
7075
+ // Route through inbox/chat based on delivery method preference.
7076
+ // For reviewRequested, set a dedup_key matching the direct send above so the
7077
+ // inline chat dedup suppresses this copy (the direct send fires first with a
7078
+ // richer payload including PR URL and `to:` routing).
7079
+ const dedupKey = target.type === 'reviewRequested'
7080
+ ? `review-requested:${task.id}:${task.updatedAt}`
7081
+ : undefined;
6769
7082
  chatManager.sendMessage({
6770
7083
  from: 'system',
6771
7084
  content: `@${target.agent} [${target.type}:${task.id}] ${task.title} → ${parsed.status}`,
@@ -6776,6 +7089,7 @@ export async function createServer() {
6776
7089
  status: parsed.status,
6777
7090
  updatedAt: task.updatedAt,
6778
7091
  deliveryMethod: routing.deliveryMethod,
7092
+ ...(dedupKey ? { dedup_key: dedupKey } : {}),
6779
7093
  },
6780
7094
  }).catch(() => { }); // Non-blocking
6781
7095
  }
@@ -6844,7 +7158,29 @@ export async function createServer() {
6844
7158
  const sourceInfo = getAgentRolesSource();
6845
7159
  return { success: true, agents: enriched, config: sourceInfo };
6846
7160
  };
7161
+ app.get('/agents', async () => buildRoleRegistryPayload());
6847
7162
  app.get('/agents/roles', async () => buildRoleRegistryPayload());
7163
+ // Host-native identity resolution — resolves agent by name, alias, or display name
7164
+ // without requiring the OpenClaw gateway. Merges YAML roles + agent_config table.
7165
+ app.get('/agents/:name/identity', async (request) => {
7166
+ const { name } = request.params;
7167
+ const resolved = resolveAgentMention(name);
7168
+ const role = resolved ? getAgentRole(resolved) : getAgentRole(name);
7169
+ if (!role) {
7170
+ return { found: false, query: name, hint: 'Agent not found in YAML roles or config' };
7171
+ }
7172
+ return {
7173
+ found: true,
7174
+ agentId: role.name,
7175
+ displayName: role.displayName ?? role.name,
7176
+ role: role.role,
7177
+ description: role.description ?? null,
7178
+ aliases: role.aliases ?? [],
7179
+ affinityTags: role.affinityTags ?? [],
7180
+ wipCap: role.wipCap,
7181
+ source: 'yaml',
7182
+ };
7183
+ });
6848
7184
  // Team-scoped alias for assignment-engine consumers
6849
7185
  app.get('/team/roles', async () => {
6850
7186
  const payload = buildRoleRegistryPayload();
@@ -6860,6 +7196,13 @@ export async function createServer() {
6860
7196
  // ── File upload/download ──
6861
7197
  app.post('/files', async (request, reply) => {
6862
7198
  try {
7199
+ const { MAX_SIZE_BYTES: maxBytes } = await import('./files.js');
7200
+ // Early rejection via Content-Length before reading body
7201
+ const declaredLength = parseInt(String(request.headers['content-length'] || ''), 10);
7202
+ if (!Number.isNaN(declaredLength) && declaredLength > maxBytes) {
7203
+ reply.code(413);
7204
+ return { success: false, error: `File exceeds ${maxBytes / (1024 * 1024)}MB limit (Content-Length: ${declaredLength} bytes)` };
7205
+ }
6863
7206
  const data = await request.file();
6864
7207
  if (!data) {
6865
7208
  reply.code(400);
@@ -6869,10 +7212,10 @@ export async function createServer() {
6869
7212
  for await (const chunk of data.file)
6870
7213
  chunks.push(chunk);
6871
7214
  const buffer = Buffer.concat(chunks);
6872
- // Check if stream was truncated (exceeds limit)
7215
+ // Check if stream was truncated (exceeds multipart limit)
6873
7216
  if (data.file.truncated) {
6874
7217
  reply.code(413);
6875
- return { success: false, error: 'File exceeds 50MB limit' };
7218
+ return { success: false, error: `File exceeds ${maxBytes / (1024 * 1024)}MB limit` };
6876
7219
  }
6877
7220
  const fields = data.fields;
6878
7221
  const uploadedBy = typeof fields?.uploadedBy?.value === 'string' ? fields.uploadedBy.value : 'anonymous';
@@ -6894,10 +7237,11 @@ export async function createServer() {
6894
7237
  return result;
6895
7238
  }
6896
7239
  catch (err) {
7240
+ const { MAX_SIZE_BYTES: maxBytes } = await import('./files.js');
6897
7241
  const msg = err instanceof Error ? err.message : String(err);
6898
7242
  if (msg.includes('Request file too large')) {
6899
7243
  reply.code(413);
6900
- return { success: false, error: 'File exceeds 50MB limit' };
7244
+ return { success: false, error: `File exceeds ${maxBytes / (1024 * 1024)}MB limit` };
6901
7245
  }
6902
7246
  reply.code(500);
6903
7247
  return { success: false, error: 'Upload failed' };
@@ -7093,6 +7437,137 @@ export async function createServer() {
7093
7437
  return { success: false, error: msg };
7094
7438
  }
7095
7439
  });
7440
+ // POST /agents — Add a single agent to the team
7441
+ app.post('/agents', async (request, reply) => {
7442
+ const body = request.body;
7443
+ const name = typeof body.name === 'string' ? body.name.trim().toLowerCase() : '';
7444
+ const role = typeof body.role === 'string' ? body.role.trim() : '';
7445
+ const description = typeof body.description === 'string' ? body.description.trim() : '';
7446
+ if (!name) {
7447
+ reply.code(400);
7448
+ return { success: false, error: 'name is required' };
7449
+ }
7450
+ if (!role) {
7451
+ reply.code(400);
7452
+ return { success: false, error: 'role is required' };
7453
+ }
7454
+ if (/[^a-z0-9_-]/.test(name)) {
7455
+ reply.code(400);
7456
+ return { success: false, error: 'name must be lowercase alphanumeric (a-z, 0-9, -, _)' };
7457
+ }
7458
+ // Check if agent already exists
7459
+ const existing = getAgentRoles().find(r => r.name === name);
7460
+ if (existing) {
7461
+ reply.code(409);
7462
+ return { success: false, error: `Agent "${name}" already exists (role: ${existing.role})` };
7463
+ }
7464
+ // Read existing YAML and append new agent
7465
+ const { readFileSync, writeFileSync, existsSync } = await import('node:fs');
7466
+ const { join } = await import('node:path');
7467
+ const filePath = join(REFLECTT_HOME, 'TEAM-ROLES.yaml');
7468
+ let yaml = '';
7469
+ if (existsSync(filePath)) {
7470
+ yaml = readFileSync(filePath, 'utf-8');
7471
+ }
7472
+ if (!yaml.includes('agents:')) {
7473
+ yaml = 'agents:\n';
7474
+ }
7475
+ // Build agent YAML entry
7476
+ const affinityTags = Array.isArray(body.affinityTags) ? body.affinityTags : [role];
7477
+ const wipCap = typeof body.wipCap === 'number' ? body.wipCap : 2;
7478
+ const desc = description || `${role} agent.`;
7479
+ const entry = [
7480
+ ` - name: ${name}`,
7481
+ ` role: ${role}`,
7482
+ ` description: ${desc}`,
7483
+ ` affinityTags: [${affinityTags.join(', ')}]`,
7484
+ ` wipCap: ${wipCap}`,
7485
+ ].join('\n');
7486
+ // Insert before lanes: section (if present), otherwise append
7487
+ const lanesIdx = yaml.indexOf('\nlanes:');
7488
+ if (lanesIdx >= 0) {
7489
+ yaml = yaml.slice(0, lanesIdx) + '\n' + entry + yaml.slice(lanesIdx);
7490
+ }
7491
+ else {
7492
+ yaml = yaml.trimEnd() + '\n' + entry + '\n';
7493
+ }
7494
+ try {
7495
+ writeFileSync(filePath, yaml, 'utf-8');
7496
+ const { loadAgentRoles } = await import('./assignment.js');
7497
+ const reloaded = loadAgentRoles();
7498
+ // Scaffold agent workspace if it doesn't exist
7499
+ let workspaceCreated = false;
7500
+ try {
7501
+ const { mkdirSync, existsSync: dirExists } = await import('node:fs');
7502
+ const workspaceDir = join(REFLECTT_HOME, `workspace-${name}`);
7503
+ if (!dirExists(workspaceDir)) {
7504
+ mkdirSync(workspaceDir, { recursive: true });
7505
+ writeFileSync(join(workspaceDir, 'SOUL.md'), `# ${name}\n\n*${desc}*\n`, 'utf-8');
7506
+ writeFileSync(join(workspaceDir, 'AGENTS.md'), `# ${name}\n\nRole: ${role}\n\n${desc}\n`, 'utf-8');
7507
+ workspaceCreated = true;
7508
+ }
7509
+ }
7510
+ catch (wsErr) {
7511
+ console.warn(`[Agents] Workspace scaffold failed for ${name}:`, wsErr.message);
7512
+ }
7513
+ return {
7514
+ success: true,
7515
+ agent: { name, role, description: desc, wipCap },
7516
+ totalAgents: reloaded.roles.length,
7517
+ workspaceCreated,
7518
+ hint: `Agent "${name}" added to team and hot-reloaded. Start heartbeating: GET /heartbeat/${name}`,
7519
+ };
7520
+ }
7521
+ catch (err) {
7522
+ const msg = err instanceof Error ? err.message : 'Failed to save agent';
7523
+ reply.code(500);
7524
+ return { success: false, error: msg };
7525
+ }
7526
+ });
7527
+ // DELETE /agents/:name — Remove an agent from the team
7528
+ app.delete('/agents/:name', async (request, reply) => {
7529
+ const name = request.params.name.toLowerCase();
7530
+ const existing = getAgentRoles().find(r => r.name === name);
7531
+ if (!existing) {
7532
+ reply.code(404);
7533
+ return { success: false, error: `Agent "${name}" not found` };
7534
+ }
7535
+ const { readFileSync, writeFileSync } = await import('node:fs');
7536
+ const { join } = await import('node:path');
7537
+ const filePath = join(REFLECTT_HOME, 'TEAM-ROLES.yaml');
7538
+ let yaml = readFileSync(filePath, 'utf-8');
7539
+ // Remove the agent block: from " - name: <name>" to the next " - name:" or top-level key or EOF
7540
+ const lines = yaml.split('\n');
7541
+ const filtered = [];
7542
+ let skipping = false;
7543
+ for (const line of lines) {
7544
+ if (line.match(new RegExp(`^\\s+-\\s+name:\\s+${name}\\s*$`))) {
7545
+ skipping = true;
7546
+ continue;
7547
+ }
7548
+ if (skipping) {
7549
+ // Stop skipping at next agent entry, top-level key, or blank line before top-level
7550
+ if (line.match(/^\s+-\s+name:\s/) || line.match(/^[a-z]/)) {
7551
+ skipping = false;
7552
+ filtered.push(line);
7553
+ }
7554
+ continue;
7555
+ }
7556
+ filtered.push(line);
7557
+ }
7558
+ yaml = filtered.join('\n');
7559
+ try {
7560
+ writeFileSync(filePath, yaml, 'utf-8');
7561
+ const { loadAgentRoles } = await import('./assignment.js');
7562
+ const reloaded = loadAgentRoles();
7563
+ return { success: true, removed: name, totalAgents: reloaded.roles.length };
7564
+ }
7565
+ catch (err) {
7566
+ const msg = err instanceof Error ? err.message : 'Failed to remove agent';
7567
+ reply.code(500);
7568
+ return { success: false, error: msg };
7569
+ }
7570
+ });
7096
7571
  // Resolve a mention string (name, displayName, or alias) to an agent ID
7097
7572
  app.get('/resolve/mention/:mention', async (request) => {
7098
7573
  const agentName = resolveAgentMention(request.params.mention);
@@ -7129,55 +7604,7 @@ export async function createServer() {
7129
7604
  };
7130
7605
  });
7131
7606
  // ── Approval Queue ──────────────────────────────────────────────────
7132
- app.get('/approval-queue', async () => {
7133
- // Tasks in 'todo' that were auto-assigned (have suggestedAgent in metadata) or need assignment review
7134
- const allTasks = taskManager.listTasks({});
7135
- const todoTasks = allTasks.filter(t => t.status === 'todo');
7136
- const items = todoTasks.map(t => {
7137
- const task = t;
7138
- const meta = task.metadata || {};
7139
- const title = task.title || '';
7140
- const tags = Array.isArray(task.tags) ? task.tags : [];
7141
- const doneCriteria = Array.isArray(task.done_criteria) ? task.done_criteria : [];
7142
- // Score all agents for this task
7143
- const roles = getAgentRoles();
7144
- const agentOptions = roles.map(agent => {
7145
- const wipCount = allTasks.filter(at => at.status === 'doing' && (at.assignee || '').toLowerCase() === agent.name).length;
7146
- const s = scoreAssignment(agent, { title, tags, done_criteria: doneCriteria }, wipCount);
7147
- return {
7148
- agentId: agent.name,
7149
- name: agent.name,
7150
- confidenceScore: Math.max(0, Math.min(1, s.score)),
7151
- affinityTags: agent.affinityTags,
7152
- };
7153
- }).sort((a, b) => b.confidenceScore - a.confidenceScore);
7154
- const topAgent = agentOptions[0];
7155
- const suggestedAgent = task.assignee || topAgent?.agentId || null;
7156
- const confidenceScore = topAgent?.confidenceScore || 0;
7157
- const confidenceReason = topAgent && topAgent.confidenceScore > 0
7158
- ? `${topAgent.name}: affinity match on ${topAgent.affinityTags.slice(0, 3).join(', ')}`
7159
- : 'No strong affinity match';
7160
- return {
7161
- taskId: task.id,
7162
- title,
7163
- description: task.description || '',
7164
- priority: task.priority || 'P3',
7165
- suggestedAgent,
7166
- confidenceScore,
7167
- confidenceReason,
7168
- agentOptions,
7169
- status: 'pending',
7170
- };
7171
- });
7172
- const highConfidence = items.filter(i => i.confidenceScore >= 0.85);
7173
- const needsReview = items.filter(i => i.confidenceScore < 0.85);
7174
- return {
7175
- items: [...highConfidence, ...needsReview],
7176
- total: items.length,
7177
- highConfidenceCount: highConfidence.length,
7178
- needsReviewCount: needsReview.length,
7179
- };
7180
- });
7607
+ // Note: GET /approval-queue is defined below near /approval-queue/:approvalId/decide
7181
7608
  app.post('/approval-queue/:taskId/approve', async (request, reply) => {
7182
7609
  const body = request.body;
7183
7610
  const taskId = request.params.taskId;
@@ -7333,6 +7760,88 @@ export async function createServer() {
7333
7760
  warnings: result.warnings,
7334
7761
  };
7335
7762
  });
7763
+ // ── Presence Layer canvas state ─────────────────────────────────────
7764
+ // Agent emits canvas_render state transitions for the Presence Layer.
7765
+ // Deterministic event types. No "AI can emit anything" protocol.
7766
+ const CANVAS_STATES = ['floor', 'listening', 'thinking', 'rendering', 'ambient', 'decision', 'urgent', 'handoff'];
7767
+ const SENSOR_VALUES = [null, 'mic', 'camera', 'mic+camera'];
7768
+ const CanvasRenderSchema = z.object({
7769
+ state: z.enum(CANVAS_STATES),
7770
+ sensors: z.enum(['mic', 'camera', 'mic+camera']).nullable().default(null),
7771
+ agentId: z.string().min(1),
7772
+ payload: z.object({
7773
+ text: z.string().optional(),
7774
+ media: z.unknown().optional(),
7775
+ decision: z.object({
7776
+ question: z.string(),
7777
+ context: z.string().optional(),
7778
+ decisionId: z.string(),
7779
+ expiresAt: z.number().optional(),
7780
+ autoAction: z.string().optional(),
7781
+ }).optional(),
7782
+ agents: z.array(z.object({
7783
+ name: z.string(),
7784
+ state: z.string(),
7785
+ task: z.string().optional(),
7786
+ })).optional(),
7787
+ summary: z.object({
7788
+ headline: z.string(),
7789
+ items: z.array(z.string()).optional(),
7790
+ cost: z.string().optional(),
7791
+ duration: z.string().optional(),
7792
+ }).optional(),
7793
+ }).default({}),
7794
+ });
7795
+ // Current state per agent — in-memory, not persisted
7796
+ const canvasStateMap = new Map();
7797
+ // POST /canvas/state — agent emits a state transition
7798
+ app.post('/canvas/state', async (request, reply) => {
7799
+ const result = CanvasRenderSchema.safeParse(request.body);
7800
+ if (!result.success) {
7801
+ reply.code(422);
7802
+ return {
7803
+ error: `Invalid canvas state: ${result.error.issues.map(i => i.message).join(', ')}`,
7804
+ hint: `state must be one of: ${CANVAS_STATES.join(', ')}`,
7805
+ validStates: CANVAS_STATES,
7806
+ };
7807
+ }
7808
+ const { state, sensors, agentId, payload } = result.data;
7809
+ const now = Date.now();
7810
+ // Store current state
7811
+ canvasStateMap.set(agentId, { state, sensors, payload, updatedAt: now });
7812
+ // Emit canvas_render event over SSE
7813
+ eventBus.emit({
7814
+ id: `crender-${now}-${Math.random().toString(36).slice(2, 8)}`,
7815
+ type: 'canvas_render',
7816
+ timestamp: now,
7817
+ data: { state, sensors, agentId, payload },
7818
+ });
7819
+ return { success: true, state, agentId, timestamp: now };
7820
+ });
7821
+ // GET /canvas/state — current state for all agents (or one)
7822
+ app.get('/canvas/state', async (request) => {
7823
+ const query = request.query;
7824
+ if (query.agentId) {
7825
+ const entry = canvasStateMap.get(query.agentId);
7826
+ return entry ?? { state: 'floor', sensors: null, payload: {}, updatedAt: null };
7827
+ }
7828
+ const all = {};
7829
+ for (const [id, entry] of canvasStateMap) {
7830
+ all[id] = entry;
7831
+ }
7832
+ return { agents: all, count: canvasStateMap.size };
7833
+ });
7834
+ // GET /canvas/states — valid state + sensor values (discovery)
7835
+ app.get('/canvas/states', async () => ({
7836
+ states: CANVAS_STATES,
7837
+ sensors: SENSOR_VALUES,
7838
+ schema: {
7839
+ state: 'floor | listening | thinking | rendering | ambient | decision | urgent | handoff',
7840
+ sensors: 'null | mic | camera | mic+camera (non-dismissable trust indicator)',
7841
+ agentId: 'required — which agent is driving the canvas',
7842
+ payload: 'optional — text, media, decision, agents, summary',
7843
+ },
7844
+ }));
7336
7845
  // GET /canvas/slots — current active slots
7337
7846
  app.get('/canvas/slots', async () => {
7338
7847
  return {
@@ -8907,6 +9416,26 @@ export async function createServer() {
8907
9416
  const allDrops = chatManager.getDropStats();
8908
9417
  const agentDrops = allDrops[agent];
8909
9418
  const focusSummary = getFocusSummary();
9419
+ // Boot context: recent memories + active run (survives restart)
9420
+ let bootMemories = [];
9421
+ let activeRun = null;
9422
+ try {
9423
+ const { listMemories } = await import('./agent-memories.js');
9424
+ const memories = listMemories({ agentId: agent, limit: 5 });
9425
+ bootMemories = memories.map(m => ({
9426
+ key: m.key, content: m.content.slice(0, 200),
9427
+ namespace: m.namespace, updatedAt: m.updatedAt,
9428
+ }));
9429
+ }
9430
+ catch { /* agent-memories not available */ }
9431
+ try {
9432
+ const { getActiveAgentRun } = await import('./agent-runs.js');
9433
+ const run = getActiveAgentRun(agent, 'default');
9434
+ if (run) {
9435
+ activeRun = { id: run.id, objective: run.objective, status: run.status, startedAt: run.startedAt };
9436
+ }
9437
+ }
9438
+ catch { /* agent-runs not available */ }
8910
9439
  return {
8911
9440
  agent, ts: Date.now(),
8912
9441
  active: slim(activeTask), next: pauseStatus.paused ? null : slim(nextTask),
@@ -8916,6 +9445,12 @@ export async function createServer() {
8916
9445
  ...(focusSummary ? { focus: focusSummary } : {}),
8917
9446
  ...(agentDrops ? { drops: { total: agentDrops.total, rolling_1h: agentDrops.rolling_1h } } : {}),
8918
9447
  ...(pauseStatus.paused ? { paused: true, pauseMessage: pauseStatus.message, resumesAt: pauseStatus.entry?.pausedUntil ?? null } : {}),
9448
+ ...(bootMemories.length > 0 ? { memories: bootMemories } : {}),
9449
+ ...(activeRun ? { run: activeRun } : {}),
9450
+ ...(() => {
9451
+ const p = presenceManager.getAllPresence().find(p => p.agent === agent);
9452
+ return p?.waiting ? { waiting: p.waiting } : {};
9453
+ })(),
8919
9454
  action: pauseStatus.paused ? `PAUSED: ${pauseStatus.message}`
8920
9455
  : activeTask ? `Continue ${activeTask.id}`
8921
9456
  : nextTask ? `Claim ${nextTask.id}`
@@ -8923,6 +9458,21 @@ export async function createServer() {
8923
9458
  : 'HEARTBEAT_OK',
8924
9459
  };
8925
9460
  });
9461
+ // ── Agent Waiting State ──────────────────────────────────────────────
9462
+ // Agents signal they're blocked on human input. Shows in heartbeat + presence.
9463
+ app.post('/agents/:agent/waiting', async (request, reply) => {
9464
+ const agent = String(request.params.agent || '').trim().toLowerCase();
9465
+ const body = request.body ?? {};
9466
+ if (!body.reason)
9467
+ return reply.code(400).send({ error: 'reason is required' });
9468
+ presenceManager.setWaiting(agent, { reason: body.reason, waitingFor: body.waitingFor, taskId: body.taskId, expiresAt: body.expiresAt });
9469
+ return { success: true, agent, status: 'waiting', waiting: { reason: body.reason, waitingFor: body.waitingFor, taskId: body.taskId, expiresAt: body.expiresAt } };
9470
+ });
9471
+ app.delete('/agents/:agent/waiting', async (request) => {
9472
+ const agent = String(request.params.agent || '').trim().toLowerCase();
9473
+ presenceManager.clearWaiting(agent);
9474
+ return { success: true, agent, status: 'idle' };
9475
+ });
8926
9476
  // ── Bootstrap: dynamic agent config generation ──────────────────────
8927
9477
  app.get('/bootstrap/heartbeat/:agent', async (request) => {
8928
9478
  const agent = String(request.params.agent || '').trim().toLowerCase();
@@ -8986,6 +9536,7 @@ If your heartbeat shows **no active task** and **no next task**:
8986
9536
  - Do not load full chat history.
8987
9537
  - Do not post plan-only updates.
8988
9538
  - If nothing changed and no direct action is required, reply \`HEARTBEAT_OK\`.
9539
+ - **Decision authority:** Team owns product/arch/process decisions. Escalate credentials, legal, and vision decisions to the admin/owner. See \`decision_authority\` block in \`defaults/TEAM-ROLES.yaml\` for the full list.
8989
9540
  `;
8990
9541
  // Stable hash for change detection (agents can cache and compare)
8991
9542
  const { createHash } = await import('node:crypto');
@@ -9063,6 +9614,7 @@ If your heartbeat shows **no active task** and **no next task**:
9063
9614
  endpoints: [
9064
9615
  { method: 'POST', path: '/reflections', hint: 'Submit. Required: pain, impact, evidence[], went_well, suspected_why, proposed_fix, confidence, role_type, author' },
9065
9616
  { method: 'GET', path: '/reflections', hint: 'List. Query: author, limit' },
9617
+ { method: 'GET', path: '/reflections/schema', hint: 'Required/optional fields, role types, severity levels, dedup rules' },
9066
9618
  ],
9067
9619
  },
9068
9620
  activity: {
@@ -9316,8 +9868,18 @@ If your heartbeat shows **no active task** and **no next task**:
9316
9868
  reply.code(404);
9317
9869
  return { success: false, error: 'Task not found', input: id, suggestions: lookup.suggestions };
9318
9870
  }
9319
- if (task.assignee) {
9320
- return { success: false, error: `Task already assigned to ${task.assignee}` };
9871
+ const assignee = String(task.assignee || '').trim();
9872
+ const isUnassigned = assignee.length === 0 || assignee.toLowerCase() === 'unassigned';
9873
+ if (!isUnassigned) {
9874
+ reply.code(409);
9875
+ return {
9876
+ success: false,
9877
+ error: `Task already assigned to ${assignee}`,
9878
+ code: 'TASK_ALREADY_ASSIGNED',
9879
+ status: 409,
9880
+ assignee,
9881
+ hint: 'Task claims are atomic: first claim wins. Pull another task via GET /tasks/next.',
9882
+ };
9321
9883
  }
9322
9884
  const shortId = lookup.resolvedId.replace(/^task-\d+-/, '');
9323
9885
  const branch = `${body.agent}/task-${shortId}`;
@@ -10079,17 +10641,12 @@ If your heartbeat shows **no active task** and **no next task**:
10079
10641
  return { success: false, error: 'agent and model are required' };
10080
10642
  }
10081
10643
  const event = recordUsage({
10082
- agent: body.agent,
10083
- task_id: body.task_id,
10644
+ agentId: body.agent,
10084
10645
  model: body.model,
10085
- provider: body.provider || 'unknown',
10086
- input_tokens: Number(body.input_tokens) || 0,
10087
- output_tokens: Number(body.output_tokens) || 0,
10088
- estimated_cost_usd: body.estimated_cost_usd != null ? Number(body.estimated_cost_usd) : undefined,
10089
- category: body.category || 'other',
10646
+ inputTokens: Number(body.input_tokens) || 0,
10647
+ outputTokens: Number(body.output_tokens) || 0,
10648
+ cost: body.estimated_cost_usd != null ? Number(body.estimated_cost_usd) : 0,
10090
10649
  timestamp: Number(body.timestamp) || Date.now(),
10091
- team_id: body.team_id,
10092
- metadata: body.metadata,
10093
10650
  });
10094
10651
  return { success: true, event };
10095
10652
  });
@@ -10170,6 +10727,42 @@ If your heartbeat shows **no active task** and **no next task**:
10170
10727
  const q = request.query;
10171
10728
  return { suggestions: getRoutingSuggestions({ since: q.since ? Number(q.since) : undefined }) };
10172
10729
  });
10730
+ // ── Cost Dashboard ──
10731
+ // GET /costs — aggregated spend: daily by model, avg per lane, top tasks
10732
+ app.get('/costs', async (request) => {
10733
+ const q = request.query;
10734
+ const days = q.days ? Math.min(Number(q.days), 90) : 7;
10735
+ const since = Date.now() - days * 24 * 60 * 60 * 1000;
10736
+ const dailyByModel = getDailySpendByModel({ days });
10737
+ const byLane = getAvgCostByLane({ days: Math.max(days, 30) }); // lane data needs more window
10738
+ const byAgent = getAvgCostByAgent({ days: Math.max(days, 30) });
10739
+ const topTasks = getUsageByTask({ since, limit: 20 });
10740
+ const summary = getUsageSummary({ since });
10741
+ // Roll up daily totals per day for the sparkline
10742
+ const dailyTotals = {};
10743
+ for (const row of dailyByModel) {
10744
+ dailyTotals[row.date] = (dailyTotals[row.date] ?? 0) + row.total_cost_usd;
10745
+ }
10746
+ // Note: avg_cost_by_lane and avg_cost_by_agent use Math.max(days, 30) as their window.
10747
+ // Lane/agent-level averages need task density to be meaningful — a 7-day window might
10748
+ // have 0-1 closed tasks per agent/lane and produce misleading numbers. Using a 30-day
10749
+ // floor is intentional. daily_by_model, daily_totals, and top_tasks_by_cost use the
10750
+ // requested `days` window directly and will match the `window_days` field in the response.
10751
+ const laneAgentWindow = Math.max(days, 30);
10752
+ return {
10753
+ window_days: days,
10754
+ lane_agent_window_days: laneAgentWindow,
10755
+ summary: Array.isArray(summary) ? summary[0] ?? null : summary,
10756
+ daily_by_model: dailyByModel,
10757
+ daily_totals: Object.entries(dailyTotals)
10758
+ .sort(([a], [b]) => a.localeCompare(b))
10759
+ .map(([date, total_cost_usd]) => ({ date, total_cost_usd })),
10760
+ avg_cost_by_lane: byLane,
10761
+ avg_cost_by_agent: byAgent,
10762
+ top_tasks_by_cost: topTasks,
10763
+ generated_at: Date.now(),
10764
+ };
10765
+ });
10173
10766
  // Operational metrics endpoint (lightweight dashboard contract)
10174
10767
  app.get('/metrics', async () => {
10175
10768
  const startedAt = Date.now();
@@ -10587,6 +11180,10 @@ If your heartbeat shows **no active task** and **no next task**:
10587
11180
  request.headers['x-request-id'] ||
10588
11181
  undefined;
10589
11182
  const idempotencyKey = deliveryId ? `${provider}_${deliveryId}` : undefined;
11183
+ // Enrich GitHub webhook payloads with agent attribution
11184
+ const enrichedBody = provider === 'github'
11185
+ ? enrichWebhookPayload(body)
11186
+ : body;
10590
11187
  // Enqueue through delivery engine for each configured target
10591
11188
  const events = [];
10592
11189
  for (const route of routes) {
@@ -10597,7 +11194,7 @@ If your heartbeat shows **no active task** and **no next task**:
10597
11194
  const event = webhookDelivery.enqueue({
10598
11195
  provider,
10599
11196
  eventType,
10600
- payload: body,
11197
+ payload: enrichedBody,
10601
11198
  targetUrl: `http://localhost:${serverConfig.port}${route.path}`,
10602
11199
  idempotencyKey: idempotencyKey ? `${idempotencyKey}_${route.id}` : undefined,
10603
11200
  metadata: {
@@ -10611,6 +11208,21 @@ If your heartbeat shows **no active task** and **no next task**:
10611
11208
  });
10612
11209
  events.push(event);
10613
11210
  }
11211
+ // Post GitHub events to the 'github' chat channel with remapped mentions.
11212
+ // Pass enrichedBody (not body) so formatGitHubEvent has access to
11213
+ // _reflectt_attribution and can mention the correct agent (@link not @kai).
11214
+ if (provider === 'github') {
11215
+ const ghEventType = request.headers['x-github-event'] || eventType;
11216
+ const chatMessage = formatGitHubEvent(ghEventType, enrichedBody);
11217
+ if (chatMessage) {
11218
+ chatManager.sendMessage({
11219
+ from: 'github',
11220
+ content: chatMessage,
11221
+ channel: 'github',
11222
+ metadata: { source: 'github-webhook', eventType: ghEventType, delivery: request.headers['x-github-delivery'] },
11223
+ }).catch(() => { }); // non-blocking
11224
+ }
11225
+ }
10614
11226
  reply.code(202);
10615
11227
  return { success: true, accepted: events.length, events: events.map(e => ({ id: e.id, idempotencyKey: e.idempotencyKey, status: e.status })) };
10616
11228
  });
@@ -11344,6 +11956,62 @@ If your heartbeat shows **no active task** and **no next task**:
11344
11956
  app.get('/calendar/reminders/stats', async () => {
11345
11957
  return getReminderEngineStats();
11346
11958
  });
11959
+ // ── Schedule feed — team-wide time-awareness ──────────────────────────────
11960
+ //
11961
+ // Provides canonical records for deploy windows, focus blocks, and
11962
+ // scheduled task work so agents can coordinate timing without chat.
11963
+ //
11964
+ // MVP scope: one-off windows only. No iCal/RRULE, no reminders.
11965
+ // See src/schedule.ts for what is intentionally NOT included.
11966
+ // GET /schedule/feed — upcoming entries in chronological order
11967
+ app.get('/schedule/feed', async (request) => {
11968
+ const q = request.query;
11969
+ const kinds = q.kinds ? q.kinds.split(',') : undefined;
11970
+ const entries = getScheduleFeed({
11971
+ after: q.after ? parseInt(q.after, 10) : undefined,
11972
+ before: q.before ? parseInt(q.before, 10) : undefined,
11973
+ kinds,
11974
+ owner: q.owner,
11975
+ limit: q.limit ? parseInt(q.limit, 10) : undefined,
11976
+ });
11977
+ return { entries, count: entries.length };
11978
+ });
11979
+ // POST /schedule/entries — create a new schedule entry
11980
+ app.post('/schedule/entries', async (request, reply) => {
11981
+ try {
11982
+ const entry = createScheduleEntry(request.body);
11983
+ return reply.status(201).send({ entry });
11984
+ }
11985
+ catch (err) {
11986
+ return reply.status(400).send({ error: err.message });
11987
+ }
11988
+ });
11989
+ // GET /schedule/entries/:id
11990
+ app.get('/schedule/entries/:id', async (request, reply) => {
11991
+ const entry = getScheduleEntry(request.params.id);
11992
+ if (!entry)
11993
+ return reply.status(404).send({ error: 'Not found' });
11994
+ return { entry };
11995
+ });
11996
+ // PATCH /schedule/entries/:id
11997
+ app.patch('/schedule/entries/:id', async (request, reply) => {
11998
+ try {
11999
+ const entry = updateScheduleEntry(request.params.id, request.body);
12000
+ if (!entry)
12001
+ return reply.status(404).send({ error: 'Not found' });
12002
+ return { entry };
12003
+ }
12004
+ catch (err) {
12005
+ return reply.status(400).send({ error: err.message });
12006
+ }
12007
+ });
12008
+ // DELETE /schedule/entries/:id
12009
+ app.delete('/schedule/entries/:id', async (request, reply) => {
12010
+ const deleted = deleteScheduleEntry(request.params.id);
12011
+ if (!deleted)
12012
+ return reply.status(404).send({ error: 'Not found' });
12013
+ return reply.status(204).send();
12014
+ });
11347
12015
  // ── iCal Import/Export ───────────────────────────────────────────────────
11348
12016
  // Export all events as .ics
11349
12017
  app.get('/calendar/export.ics', async (request, reply) => {
@@ -11517,6 +12185,964 @@ If your heartbeat shows **no active task** and **no next task**:
11517
12185
  });
11518
12186
  // Start hourly auto-snapshot for alert-preflight daily metrics
11519
12187
  startAutoSnapshot();
12188
+ // ─── Browser capability routes ───────────────────────────────────────────────
12189
+ const browser = await import('./capabilities/browser.js');
12190
+ app.get('/browser/config', async () => {
12191
+ return browser.getBrowserConfig();
12192
+ });
12193
+ app.post('/browser/sessions', async (request, reply) => {
12194
+ try {
12195
+ const body = request.body;
12196
+ if (!body?.agent)
12197
+ return reply.code(400).send({ error: 'agent is required' });
12198
+ const session = await browser.createSession({
12199
+ agent: body.agent,
12200
+ url: body.url,
12201
+ headless: body.headless,
12202
+ viewport: body.viewport,
12203
+ });
12204
+ const { _stagehand, _page, _idleTimer, ...safe } = session;
12205
+ return reply.code(201).send(safe);
12206
+ }
12207
+ catch (err) {
12208
+ const status = err.message?.includes('Max concurrent') || err.message?.includes('exceeded max') ? 429 : 500;
12209
+ return reply.code(status).send({ error: err.message });
12210
+ }
12211
+ });
12212
+ app.get('/browser/sessions', async () => {
12213
+ return { sessions: browser.listSessions() };
12214
+ });
12215
+ app.get('/browser/sessions/:id', async (request, reply) => {
12216
+ const session = browser.getSession(request.params.id);
12217
+ if (!session)
12218
+ return reply.code(404).send({ error: 'Session not found' });
12219
+ const { _stagehand, _page, _idleTimer, ...safe } = session;
12220
+ return safe;
12221
+ });
12222
+ app.delete('/browser/sessions/:id', async (request, reply) => {
12223
+ await browser.closeSession(request.params.id);
12224
+ return { ok: true };
12225
+ });
12226
+ app.post('/browser/sessions/:id/act', async (request, reply) => {
12227
+ try {
12228
+ const body = request.body;
12229
+ if (!body?.instruction)
12230
+ return reply.code(400).send({ error: 'instruction is required' });
12231
+ const result = await browser.act(request.params.id, body.instruction);
12232
+ return result;
12233
+ }
12234
+ catch (err) {
12235
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12236
+ }
12237
+ });
12238
+ app.post('/browser/sessions/:id/extract', async (request, reply) => {
12239
+ try {
12240
+ const body = request.body;
12241
+ if (!body?.instruction)
12242
+ return reply.code(400).send({ error: 'instruction is required' });
12243
+ const result = await browser.extract(request.params.id, body.instruction, body.schema);
12244
+ return result;
12245
+ }
12246
+ catch (err) {
12247
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12248
+ }
12249
+ });
12250
+ app.post('/browser/sessions/:id/observe', async (request, reply) => {
12251
+ try {
12252
+ const body = request.body;
12253
+ if (!body?.instruction)
12254
+ return reply.code(400).send({ error: 'instruction is required' });
12255
+ const result = await browser.observe(request.params.id, body.instruction);
12256
+ return result;
12257
+ }
12258
+ catch (err) {
12259
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12260
+ }
12261
+ });
12262
+ app.post('/browser/sessions/:id/navigate', async (request, reply) => {
12263
+ try {
12264
+ const body = request.body;
12265
+ if (!body?.url)
12266
+ return reply.code(400).send({ error: 'url is required' });
12267
+ const result = await browser.navigate(request.params.id, body.url);
12268
+ return result;
12269
+ }
12270
+ catch (err) {
12271
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12272
+ }
12273
+ });
12274
+ app.get('/browser/sessions/:id/screenshot', async (request, reply) => {
12275
+ try {
12276
+ const result = await browser.screenshot(request.params.id);
12277
+ return result;
12278
+ }
12279
+ catch (err) {
12280
+ return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
12281
+ }
12282
+ });
12283
+ // ── Agent Runs & Events ──────────────────────────────────────────────────
12284
+ const { createAgentRun, updateAgentRun, getAgentRun, getActiveAgentRun, listAgentRuns, appendAgentEvent, listAgentEvents, VALID_RUN_STATUSES, } = await import('./agent-runs.js');
12285
+ // Create a new agent run
12286
+ app.post('/agents/:agentId/runs', async (request, reply) => {
12287
+ const { agentId } = request.params;
12288
+ const body = request.body;
12289
+ if (!body?.objective)
12290
+ return reply.code(400).send({ error: 'objective is required' });
12291
+ const teamId = body.teamId ?? 'default';
12292
+ try {
12293
+ const run = createAgentRun(agentId, teamId, body.objective, {
12294
+ taskId: body.taskId,
12295
+ parentRunId: body.parentRunId,
12296
+ });
12297
+ return reply.code(201).send(run);
12298
+ }
12299
+ catch (err) {
12300
+ return reply.code(500).send({ error: err.message });
12301
+ }
12302
+ });
12303
+ // Update an agent run (status, context, artifacts)
12304
+ app.patch('/agents/:agentId/runs/:runId', async (request, reply) => {
12305
+ const { runId } = request.params;
12306
+ const body = request.body;
12307
+ if (body?.status && !VALID_RUN_STATUSES.includes(body.status)) {
12308
+ return reply.code(400).send({ error: `Invalid status. Valid: ${VALID_RUN_STATUSES.join(', ')}` });
12309
+ }
12310
+ try {
12311
+ const run = updateAgentRun(runId, {
12312
+ status: body?.status,
12313
+ contextSnapshot: body?.contextSnapshot,
12314
+ artifacts: body?.artifacts,
12315
+ });
12316
+ if (!run)
12317
+ return reply.code(404).send({ error: 'Run not found' });
12318
+ return run;
12319
+ }
12320
+ catch (err) {
12321
+ return reply.code(500).send({ error: err.message });
12322
+ }
12323
+ });
12324
+ // List agent runs
12325
+ app.get('/agents/:agentId/runs', async (request, reply) => {
12326
+ const { agentId } = request.params;
12327
+ const query = request.query;
12328
+ const teamId = query.teamId ?? 'default';
12329
+ const limit = query.limit ? parseInt(query.limit, 10) : undefined;
12330
+ return listAgentRuns(agentId, teamId, { status: query.status, limit });
12331
+ });
12332
+ // Get active run for an agent
12333
+ app.get('/agents/:agentId/runs/current', async (request, reply) => {
12334
+ const { agentId } = request.params;
12335
+ const query = request.query;
12336
+ const teamId = query.teamId ?? 'default';
12337
+ const run = getActiveAgentRun(agentId, teamId);
12338
+ if (!run)
12339
+ return reply.code(404).send({ error: 'No active run' });
12340
+ return run;
12341
+ });
12342
+ // Append an event
12343
+ const { validateRoutingSemantics } = await import('./agent-runs.js');
12344
+ // GET /events/routing/validate — check if a payload passes routing semantics
12345
+ app.post('/events/routing/validate', async (request) => {
12346
+ const body = request.body;
12347
+ if (!body?.eventType)
12348
+ return { valid: false, errors: ['eventType is required'], warnings: [] };
12349
+ return validateRoutingSemantics(body.eventType, body.payload ?? {});
12350
+ });
12351
+ app.post('/agents/:agentId/events', async (request, reply) => {
12352
+ const { agentId } = request.params;
12353
+ const body = request.body;
12354
+ if (!body?.eventType)
12355
+ return reply.code(400).send({ error: 'eventType is required' });
12356
+ try {
12357
+ const event = appendAgentEvent({
12358
+ agentId,
12359
+ runId: body.runId,
12360
+ eventType: body.eventType,
12361
+ payload: body.payload,
12362
+ enforceRouting: body.enforceRouting,
12363
+ });
12364
+ return reply.code(201).send(event);
12365
+ }
12366
+ catch (err) {
12367
+ if (err.message.includes('Routing semantics violation')) {
12368
+ return reply.code(422).send({ error: err.message, hint: 'Actionable events require: action_required (string), urgency (low|normal|high|critical), owner (string). Optional: expires_at (number).' });
12369
+ }
12370
+ return reply.code(500).send({ error: err.message });
12371
+ const message = String(err?.message || err);
12372
+ if (message.includes('rationale')) {
12373
+ return reply.code(400).send({ error: message });
12374
+ }
12375
+ return reply.code(500).send({ error: message });
12376
+ }
12377
+ });
12378
+ // List agent events
12379
+ app.get('/agents/:agentId/events', async (request, reply) => {
12380
+ const { agentId } = request.params;
12381
+ const query = request.query;
12382
+ return listAgentEvents({
12383
+ agentId,
12384
+ runId: query.runId,
12385
+ eventType: query.type,
12386
+ since: query.since ? parseInt(query.since, 10) : undefined,
12387
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12388
+ });
12389
+ });
12390
+ // ── Run Event Stream (SSE) ─────────────────────────────────────────────
12391
+ // Real-time SSE stream for run events. Canvas subscribes here instead of polling.
12392
+ // GET /agents/:agentId/runs/:runId/stream — stream events for a specific run
12393
+ // GET /agents/:agentId/stream — stream all events for an agent
12394
+ app.get('/agents/:agentId/runs/:runId/stream', async (request, reply) => {
12395
+ const { agentId, runId } = request.params;
12396
+ const run = getAgentRun(runId);
12397
+ if (!run) {
12398
+ reply.code(404);
12399
+ return { error: 'Run not found' };
12400
+ }
12401
+ reply.raw.writeHead(200, {
12402
+ 'Content-Type': 'text/event-stream',
12403
+ 'Cache-Control': 'no-cache',
12404
+ 'Connection': 'keep-alive',
12405
+ 'X-Accel-Buffering': 'no',
12406
+ });
12407
+ // Send current run state as initial snapshot
12408
+ reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ run, events: listAgentEvents({ runId, limit: 20 }) })}\n\n`);
12409
+ // Subscribe to eventBus for this run's events
12410
+ const listenerId = `run-stream-${runId}-${Date.now()}`;
12411
+ let closed = false;
12412
+ eventBus.on(listenerId, (event) => {
12413
+ if (closed)
12414
+ return;
12415
+ const data = event.data;
12416
+ // Forward events that match this agent or run
12417
+ if (data && (data.runId === runId || data.agentId === agentId)) {
12418
+ try {
12419
+ reply.raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
12420
+ }
12421
+ catch { /* connection closed */ }
12422
+ }
12423
+ });
12424
+ // Heartbeat
12425
+ const heartbeat = setInterval(() => {
12426
+ if (closed) {
12427
+ clearInterval(heartbeat);
12428
+ return;
12429
+ }
12430
+ try {
12431
+ reply.raw.write(`:heartbeat\n\n`);
12432
+ }
12433
+ catch {
12434
+ clearInterval(heartbeat);
12435
+ }
12436
+ }, 15_000);
12437
+ // Cleanup
12438
+ request.raw.on('close', () => {
12439
+ closed = true;
12440
+ eventBus.off(listenerId);
12441
+ clearInterval(heartbeat);
12442
+ });
12443
+ });
12444
+ // Stream all events for an agent
12445
+ app.get('/agents/:agentId/stream', async (request, reply) => {
12446
+ const { agentId } = request.params;
12447
+ reply.raw.writeHead(200, {
12448
+ 'Content-Type': 'text/event-stream',
12449
+ 'Cache-Control': 'no-cache',
12450
+ 'Connection': 'keep-alive',
12451
+ 'X-Accel-Buffering': 'no',
12452
+ });
12453
+ // Send recent events as snapshot
12454
+ const recentEvents = listAgentEvents({ agentId, limit: 20 });
12455
+ const activeRun = getActiveAgentRun(agentId, 'default');
12456
+ reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ activeRun, events: recentEvents })}\n\n`);
12457
+ const listenerId = `agent-stream-${agentId}-${Date.now()}`;
12458
+ let closed = false;
12459
+ eventBus.on(listenerId, (event) => {
12460
+ if (closed)
12461
+ return;
12462
+ const data = event.data;
12463
+ if (data && data.agentId === agentId) {
12464
+ try {
12465
+ reply.raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
12466
+ }
12467
+ catch { /* connection closed */ }
12468
+ }
12469
+ });
12470
+ const heartbeat = setInterval(() => {
12471
+ if (closed) {
12472
+ clearInterval(heartbeat);
12473
+ return;
12474
+ }
12475
+ try {
12476
+ reply.raw.write(`:heartbeat\n\n`);
12477
+ }
12478
+ catch {
12479
+ clearInterval(heartbeat);
12480
+ }
12481
+ }, 15_000);
12482
+ request.raw.on('close', () => {
12483
+ closed = true;
12484
+ eventBus.off(listenerId);
12485
+ clearInterval(heartbeat);
12486
+ });
12487
+ });
12488
+ // ── Workflow Templates ─────────────────────────────────────────────────
12489
+ const { listWorkflowTemplates, getWorkflowTemplate, runWorkflow } = await import('./workflow-templates.js');
12490
+ // GET /workflows — list available workflow templates
12491
+ app.get('/workflows', async () => ({ templates: listWorkflowTemplates() }));
12492
+ // GET /workflows/:id — get template details
12493
+ app.get('/workflows/:id', async (request, reply) => {
12494
+ const template = getWorkflowTemplate(request.params.id);
12495
+ if (!template) {
12496
+ reply.code(404);
12497
+ return { error: 'Template not found' };
12498
+ }
12499
+ return {
12500
+ id: template.id,
12501
+ name: template.name,
12502
+ description: template.description,
12503
+ steps: template.steps.map(s => ({ name: s.name, description: s.description })),
12504
+ };
12505
+ });
12506
+ // POST /workflows/:id/run — execute a workflow
12507
+ app.post('/workflows/:id/run', async (request, reply) => {
12508
+ const template = getWorkflowTemplate(request.params.id);
12509
+ if (!template) {
12510
+ reply.code(404);
12511
+ return { error: 'Template not found' };
12512
+ }
12513
+ const body = request.body ?? {};
12514
+ const agentId = body.agentId ?? 'link';
12515
+ const teamId = body.teamId ?? 'default';
12516
+ const result = await runWorkflow(template, agentId, teamId, body);
12517
+ return result;
12518
+ });
12519
+ // ── Agent Messaging (Host-native) ─────────────────────────────────────
12520
+ // Local agent-to-agent messaging. Replaces gateway for same-Host agents.
12521
+ const { sendAgentMessage, listAgentMessages, listSentMessages, markMessagesRead, getUnreadCount, listChannelMessages } = await import('./agent-messaging.js');
12522
+ // Send message
12523
+ app.post('/agents/:agentId/messages/send', async (request, reply) => {
12524
+ const { agentId } = request.params;
12525
+ const body = request.body;
12526
+ if (!body?.to)
12527
+ return reply.code(400).send({ error: 'to (recipient agent) is required' });
12528
+ if (!body?.content)
12529
+ return reply.code(400).send({ error: 'content is required' });
12530
+ const msg = sendAgentMessage({
12531
+ fromAgent: agentId,
12532
+ toAgent: body.to,
12533
+ channel: body.channel,
12534
+ content: body.content,
12535
+ metadata: body.metadata,
12536
+ });
12537
+ // Emit event for SSE subscribers
12538
+ eventBus.emit({
12539
+ id: `amsg-evt-${Date.now()}`,
12540
+ type: 'message_posted',
12541
+ timestamp: Date.now(),
12542
+ data: { messageId: msg.id, from: agentId, to: body.to, channel: msg.channel },
12543
+ });
12544
+ return reply.code(201).send(msg);
12545
+ });
12546
+ // Inbox
12547
+ app.get('/agents/:agentId/messages', async (request) => {
12548
+ const { agentId } = request.params;
12549
+ const query = request.query;
12550
+ return {
12551
+ messages: listAgentMessages({
12552
+ agentId,
12553
+ channel: query.channel,
12554
+ unreadOnly: query.unread === 'true',
12555
+ since: query.since ? parseInt(query.since, 10) : undefined,
12556
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12557
+ }),
12558
+ unreadCount: getUnreadCount(agentId),
12559
+ };
12560
+ });
12561
+ // Sent
12562
+ app.get('/agents/:agentId/messages/sent', async (request) => {
12563
+ const { agentId } = request.params;
12564
+ const query = request.query;
12565
+ return { messages: listSentMessages(agentId, query.limit ? parseInt(query.limit, 10) : undefined) };
12566
+ });
12567
+ // Mark read
12568
+ app.post('/agents/:agentId/messages/read', async (request) => {
12569
+ const { agentId } = request.params;
12570
+ const body = request.body ?? {};
12571
+ const marked = markMessagesRead(agentId, body.messageIds);
12572
+ return { marked };
12573
+ });
12574
+ // Channel messages
12575
+ app.get('/messages/channel/:channel', async (request) => {
12576
+ const { channel } = request.params;
12577
+ const query = request.query;
12578
+ return {
12579
+ messages: listChannelMessages(channel, {
12580
+ since: query.since ? parseInt(query.since, 10) : undefined,
12581
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12582
+ }),
12583
+ };
12584
+ });
12585
+ // ── Run Retention / Archive ────────────────────────────────────────────
12586
+ const { applyRunRetention, getRetentionStats } = await import('./agent-runs.js');
12587
+ // GET /runs/retention/stats — preview what retention policy would do
12588
+ app.get('/runs/retention/stats', async (request) => {
12589
+ const query = request.query;
12590
+ return getRetentionStats({
12591
+ maxAgeDays: query.maxAgeDays ? parseInt(query.maxAgeDays, 10) : undefined,
12592
+ maxCompletedRuns: query.maxCompletedRuns ? parseInt(query.maxCompletedRuns, 10) : undefined,
12593
+ });
12594
+ });
12595
+ // POST /runs/retention/apply — apply retention policy
12596
+ app.post('/runs/retention/apply', async (request) => {
12597
+ const body = request.body ?? {};
12598
+ return applyRunRetention({
12599
+ policy: {
12600
+ maxAgeDays: body.maxAgeDays,
12601
+ maxCompletedRuns: body.maxCompletedRuns,
12602
+ deleteArchived: body.deleteArchived,
12603
+ },
12604
+ agentId: body.agentId,
12605
+ dryRun: body.dryRun,
12606
+ });
12607
+ });
12608
+ // ── Artifact Store (Host-native) ──────────────────────────────────────
12609
+ const { storeArtifact, getArtifact, readArtifactContent, listArtifacts, deleteArtifact, getStorageUsage } = await import('./artifact-store.js');
12610
+ // Upload artifact
12611
+ app.post('/agents/:agentId/artifacts', async (request, reply) => {
12612
+ const { agentId } = request.params;
12613
+ const body = request.body;
12614
+ if (!body?.name)
12615
+ return reply.code(400).send({ error: 'name is required' });
12616
+ if (!body?.content)
12617
+ return reply.code(400).send({ error: 'content is required' });
12618
+ const contentBuf = body.encoding === 'base64' ? Buffer.from(body.content, 'base64') : Buffer.from(body.content);
12619
+ const art = storeArtifact({ agentId, name: body.name, content: contentBuf, mimeType: body.mimeType, runId: body.runId, taskId: body.taskId, metadata: body.metadata });
12620
+ return reply.code(201).send(art);
12621
+ });
12622
+ // List artifacts
12623
+ app.get('/agents/:agentId/artifacts', async (request) => {
12624
+ const { agentId } = request.params;
12625
+ const query = request.query;
12626
+ return {
12627
+ artifacts: listArtifacts({ agentId, runId: query.runId, taskId: query.taskId, limit: query.limit ? parseInt(query.limit, 10) : undefined }),
12628
+ usage: getStorageUsage(agentId),
12629
+ };
12630
+ });
12631
+ // Get artifact metadata
12632
+ app.get('/artifacts/:artifactId', async (request, reply) => {
12633
+ const { artifactId } = request.params;
12634
+ const art = getArtifact(artifactId);
12635
+ if (!art)
12636
+ return reply.code(404).send({ error: 'Artifact not found' });
12637
+ return art;
12638
+ });
12639
+ // Download artifact content
12640
+ app.get('/artifacts/:artifactId/content', async (request, reply) => {
12641
+ const { artifactId } = request.params;
12642
+ const content = readArtifactContent(artifactId);
12643
+ if (!content)
12644
+ return reply.code(404).send({ error: 'Artifact not found or file missing' });
12645
+ const art = getArtifact(artifactId);
12646
+ return reply.type(art.mimeType).send(content);
12647
+ });
12648
+ // Delete artifact
12649
+ app.delete('/artifacts/:artifactId', async (request, reply) => {
12650
+ const { artifactId } = request.params;
12651
+ const deleted = deleteArtifact(artifactId);
12652
+ if (!deleted)
12653
+ return reply.code(404).send({ error: 'Artifact not found' });
12654
+ return { deleted: true };
12655
+ });
12656
+ // Storage usage
12657
+ app.get('/agents/:agentId/storage', async (request) => {
12658
+ const { agentId } = request.params;
12659
+ return getStorageUsage(agentId);
12660
+ });
12661
+ // ── Webhook Storage ──────────────────────────────────────────────────
12662
+ const { storeWebhookPayload, getWebhookPayload, listWebhookPayloads, markPayloadProcessed, getUnprocessedCount, purgeOldPayloads } = await import('./webhook-storage.js');
12663
+ // Ingest webhook payload
12664
+ app.post('/webhooks/ingest', async (request, reply) => {
12665
+ const body = request.body;
12666
+ if (!body?.source)
12667
+ return reply.code(400).send({ error: 'source is required' });
12668
+ if (!body?.eventType)
12669
+ return reply.code(400).send({ error: 'eventType is required' });
12670
+ if (!body?.body)
12671
+ return reply.code(400).send({ error: 'body (payload) is required' });
12672
+ const headers = {};
12673
+ for (const [k, v] of Object.entries(request.headers)) {
12674
+ if (typeof v === 'string')
12675
+ headers[k] = v;
12676
+ }
12677
+ const payload = storeWebhookPayload({ source: body.source, eventType: body.eventType, agentId: body.agentId, body: body.body, headers });
12678
+ return reply.code(201).send(payload);
12679
+ });
12680
+ // List payloads
12681
+ app.get('/webhooks/payloads', async (request) => {
12682
+ const query = request.query;
12683
+ return {
12684
+ payloads: listWebhookPayloads({
12685
+ source: query.source,
12686
+ agentId: query.agentId,
12687
+ unprocessedOnly: query.unprocessed === 'true',
12688
+ since: query.since ? parseInt(query.since, 10) : undefined,
12689
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12690
+ }),
12691
+ unprocessedCount: getUnprocessedCount({ source: query.source, agentId: query.agentId }),
12692
+ };
12693
+ });
12694
+ // Get single payload
12695
+ app.get('/webhooks/payloads/:payloadId', async (request, reply) => {
12696
+ const { payloadId } = request.params;
12697
+ const payload = getWebhookPayload(payloadId);
12698
+ if (!payload)
12699
+ return reply.code(404).send({ error: 'Payload not found' });
12700
+ return payload;
12701
+ });
12702
+ // Mark processed
12703
+ app.post('/webhooks/payloads/:payloadId/process', async (request, reply) => {
12704
+ const { payloadId } = request.params;
12705
+ const marked = markPayloadProcessed(payloadId);
12706
+ if (!marked)
12707
+ return reply.code(404).send({ error: 'Payload not found or already processed' });
12708
+ return { processed: true };
12709
+ });
12710
+ // Purge old processed payloads
12711
+ app.post('/webhooks/purge', async (request) => {
12712
+ const body = request.body ?? {};
12713
+ const deleted = purgeOldPayloads(body.maxAgeDays ?? 30);
12714
+ return { deleted };
12715
+ });
12716
+ // ── Approval Routing ────────────────────────────────────────────────────
12717
+ const { listPendingApprovals, listApprovalQueue, submitApprovalDecision, } = await import('./agent-runs.js');
12718
+ // List pending approvals (review_requested events needing action)
12719
+ app.get('/approvals/pending', async (request) => {
12720
+ const query = request.query;
12721
+ return listPendingApprovals({
12722
+ agentId: query.agentId,
12723
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12724
+ });
12725
+ });
12726
+ // Dedicated approval queue — unified view of everything needing human decision.
12727
+ // Answers: what needs decision, who owns it, when it expires, what happens if ignored.
12728
+ app.get('/approval-queue', async (request) => {
12729
+ const query = request.query;
12730
+ const items = listApprovalQueue({
12731
+ agentId: query.agentId,
12732
+ category: query.category === 'review' || query.category === 'agent_action' ? query.category : undefined,
12733
+ includeExpired: query.includeExpired === 'true',
12734
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
12735
+ });
12736
+ return {
12737
+ items,
12738
+ count: items.length,
12739
+ hasExpired: items.some(i => i.isExpired),
12740
+ };
12741
+ });
12742
+ // Submit agent-action approval (approve_requested events)
12743
+ app.post('/approval-queue/:approvalId/decide', async (request, reply) => {
12744
+ const { approvalId } = request.params;
12745
+ const body = request.body;
12746
+ if (!body?.decision || !['approve', 'reject', 'defer'].includes(body.decision)) {
12747
+ return reply.code(400).send({ error: 'decision must be "approve", "reject", or "defer"' });
12748
+ }
12749
+ if (!body?.actor) {
12750
+ return reply.code(400).send({ error: 'actor is required' });
12751
+ }
12752
+ try {
12753
+ const result = submitApprovalDecision({
12754
+ eventId: approvalId,
12755
+ decision: body.decision,
12756
+ reviewer: body.actor,
12757
+ comment: body.comment,
12758
+ });
12759
+ // Emit canvas_input event so Presence Layer updates
12760
+ eventBus.emit({
12761
+ id: `aq-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
12762
+ type: 'canvas_input',
12763
+ timestamp: Date.now(),
12764
+ data: {
12765
+ action: 'decision',
12766
+ approvalId,
12767
+ decision: body.decision,
12768
+ actor: body.actor,
12769
+ },
12770
+ });
12771
+ return result;
12772
+ }
12773
+ catch (err) {
12774
+ return reply.code(err.message.includes('not found') ? 404 : 400).send({ error: err.message });
12775
+ }
12776
+ });
12777
+ // Submit approval decision
12778
+ app.post('/approvals/:eventId/decide', async (request, reply) => {
12779
+ const { eventId } = request.params;
12780
+ const body = request.body;
12781
+ if (!body?.decision || !['approve', 'reject'].includes(body.decision)) {
12782
+ return reply.code(400).send({ error: 'decision must be "approve" or "reject"' });
12783
+ }
12784
+ if (!body?.reviewer) {
12785
+ return reply.code(400).send({ error: 'reviewer is required' });
12786
+ }
12787
+ try {
12788
+ const result = submitApprovalDecision({
12789
+ eventId,
12790
+ decision: body.decision,
12791
+ reviewer: body.reviewer,
12792
+ comment: body.comment,
12793
+ rationale: body.rationale,
12794
+ });
12795
+ return result;
12796
+ }
12797
+ catch (err) {
12798
+ return reply.code(err.message.includes('not found') ? 404 : 400).send({ error: err.message });
12799
+ }
12800
+ });
12801
+ // ── Canvas Input ──────────────────────────────────────────────────────
12802
+ // Human → agent control seam for the Presence Layer.
12803
+ // Payload is intentionally small per COO spec: action + target + actor.
12804
+ const CANVAS_INPUT_ACTIONS = ['decision', 'interrupt', 'pause', 'resume', 'mute', 'unmute'];
12805
+ const CanvasInputSchema = z.object({
12806
+ action: z.enum(CANVAS_INPUT_ACTIONS),
12807
+ targetRunId: z.string().optional(), // which run to act on
12808
+ decisionId: z.string().optional(), // for decision actions
12809
+ choice: z.enum(['approve', 'deny', 'defer']).optional(), // for decision actions
12810
+ actor: z.string().min(1), // who made this input
12811
+ comment: z.string().optional(), // optional rationale
12812
+ });
12813
+ app.post('/canvas/input', async (request, reply) => {
12814
+ const body = request.body;
12815
+ const result = CanvasInputSchema.safeParse(body);
12816
+ if (!result.success) {
12817
+ reply.code(422);
12818
+ return {
12819
+ error: `Invalid canvas input: ${result.error.issues.map(i => i.message).join(', ')}`,
12820
+ hint: 'Required: action (decision|interrupt|pause|resume|mute|unmute), actor. Optional: targetRunId, decisionId, choice, comment.',
12821
+ };
12822
+ }
12823
+ const input = result.data;
12824
+ const now = Date.now();
12825
+ // Route by action type
12826
+ if (input.action === 'decision') {
12827
+ if (!input.decisionId || !input.choice) {
12828
+ reply.code(422);
12829
+ return { error: 'Decision action requires decisionId and choice (approve|deny|defer)' };
12830
+ }
12831
+ // Emit canvas_input event for SSE subscribers
12832
+ eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
12833
+ action: input.action,
12834
+ decisionId: input.decisionId,
12835
+ choice: input.choice,
12836
+ actor: input.actor,
12837
+ comment: input.comment,
12838
+ timestamp: now,
12839
+ } });
12840
+ return {
12841
+ success: true,
12842
+ action: 'decision',
12843
+ decisionId: input.decisionId,
12844
+ choice: input.choice,
12845
+ actor: input.actor,
12846
+ timestamp: now,
12847
+ };
12848
+ }
12849
+ if (input.action === 'interrupt' || input.action === 'pause') {
12850
+ // Update active run if specified
12851
+ const runId = input.targetRunId;
12852
+ if (runId) {
12853
+ try {
12854
+ updateAgentRun(runId, {
12855
+ status: input.action === 'interrupt' ? 'cancelled' : 'blocked',
12856
+ });
12857
+ }
12858
+ catch { /* run may not exist — still emit event */ }
12859
+ }
12860
+ eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
12861
+ action: input.action,
12862
+ targetRunId: runId || null,
12863
+ actor: input.actor,
12864
+ timestamp: now,
12865
+ } });
12866
+ return {
12867
+ success: true,
12868
+ action: input.action,
12869
+ targetRunId: runId || null,
12870
+ actor: input.actor,
12871
+ timestamp: now,
12872
+ };
12873
+ }
12874
+ if (input.action === 'resume') {
12875
+ const runId = input.targetRunId;
12876
+ if (runId) {
12877
+ try {
12878
+ updateAgentRun(runId, { status: 'working' });
12879
+ }
12880
+ catch { /* run may not exist */ }
12881
+ }
12882
+ eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
12883
+ action: 'resume',
12884
+ targetRunId: runId || null,
12885
+ actor: input.actor,
12886
+ timestamp: now,
12887
+ } });
12888
+ return { success: true, action: 'resume', targetRunId: runId || null, actor: input.actor, timestamp: now };
12889
+ }
12890
+ // Mute/unmute — emit event only, no state change needed
12891
+ eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
12892
+ action: input.action,
12893
+ actor: input.actor,
12894
+ timestamp: now,
12895
+ } });
12896
+ return { success: true, action: input.action, actor: input.actor, timestamp: now };
12897
+ });
12898
+ // GET /canvas/input/schema — discovery endpoint
12899
+ app.get('/canvas/input/schema', async () => ({
12900
+ actions: CANVAS_INPUT_ACTIONS,
12901
+ schema: {
12902
+ action: 'decision | interrupt | pause | resume | mute | unmute',
12903
+ targetRunId: 'optional — which run to act on',
12904
+ decisionId: 'required for decision action — approval event ID',
12905
+ choice: 'required for decision — approve | deny | defer',
12906
+ actor: 'required — who made this input',
12907
+ comment: 'optional — rationale',
12908
+ },
12909
+ }));
12910
+ // ── Email / SMS relay ──────────────────────────────────────────────────
12911
+ async function cloudRelay(path, body, reply) {
12912
+ const cloudUrl = process.env.REFLECTT_CLOUD_URL;
12913
+ const hostToken = process.env.REFLECTT_HOST_TOKEN;
12914
+ if (!cloudUrl || !hostToken) {
12915
+ reply.code(503);
12916
+ return { error: 'Not connected to cloud. Configure REFLECTT_CLOUD_URL and REFLECTT_HOST_TOKEN.' };
12917
+ }
12918
+ try {
12919
+ const res = await fetch(`${cloudUrl}${path}`, {
12920
+ method: 'POST',
12921
+ headers: {
12922
+ 'Content-Type': 'application/json',
12923
+ Authorization: `Bearer ${hostToken}`,
12924
+ },
12925
+ body: JSON.stringify(body),
12926
+ });
12927
+ const data = await res.json().catch(() => ({}));
12928
+ if (!res.ok) {
12929
+ reply.code(res.status);
12930
+ return data;
12931
+ }
12932
+ return data;
12933
+ }
12934
+ catch (err) {
12935
+ reply.code(502);
12936
+ return { error: `Cloud relay failed: ${err.message}` };
12937
+ }
12938
+ }
12939
+ // Send email via cloud relay
12940
+ app.post('/email/send', async (request, reply) => {
12941
+ const body = request.body;
12942
+ const from = typeof body.from === 'string' ? body.from.trim() : '';
12943
+ const to = body.to;
12944
+ const subject = typeof body.subject === 'string' ? body.subject.trim() : '';
12945
+ if (!from)
12946
+ return reply.code(400).send({ error: 'from is required' });
12947
+ if (!to)
12948
+ return reply.code(400).send({ error: 'to is required' });
12949
+ if (!subject)
12950
+ return reply.code(400).send({ error: 'subject is required' });
12951
+ if (!body.html && !body.text)
12952
+ return reply.code(400).send({ error: 'html or text body is required' });
12953
+ // Use host-relay endpoint — authenticates with host credential, uses host's own teamId server-side
12954
+ const hostId = process.env.REFLECTT_HOST_ID;
12955
+ const relayPath = hostId ? `/api/hosts/${encodeURIComponent(hostId)}/relay/email` : '/api/hosts/relay/email';
12956
+ return cloudRelay(relayPath, {
12957
+ from,
12958
+ to,
12959
+ subject,
12960
+ html: body.html,
12961
+ text: body.text,
12962
+ replyTo: body.replyTo,
12963
+ cc: body.cc,
12964
+ bcc: body.bcc,
12965
+ agent: body.agentId || body.agent || 'unknown',
12966
+ }, reply);
12967
+ });
12968
+ // Send SMS via cloud relay
12969
+ app.post('/sms/send', async (request, reply) => {
12970
+ const body = request.body;
12971
+ const to = typeof body.to === 'string' ? body.to.trim() : '';
12972
+ const msgBody = typeof body.body === 'string' ? body.body.trim() : '';
12973
+ if (!to)
12974
+ return reply.code(400).send({ error: 'to is required (phone number)' });
12975
+ if (!msgBody)
12976
+ return reply.code(400).send({ error: 'body is required' });
12977
+ const hostIdSms = process.env.REFLECTT_HOST_ID;
12978
+ const smsRelayPath = hostIdSms ? `/api/hosts/${encodeURIComponent(hostIdSms)}/relay/sms` : '/api/hosts/relay/sms';
12979
+ return cloudRelay(smsRelayPath, {
12980
+ to,
12981
+ body: msgBody,
12982
+ from: body.from,
12983
+ agent: body.agentId || body.agent || 'unknown',
12984
+ }, reply);
12985
+ });
12986
+ // ── Agent Config ──────────────────────────────────────────────────────
12987
+ // Per-agent model preference, cost cap, and settings.
12988
+ // This is the policy anchor for cost enforcement.
12989
+ const { getAgentConfig, listAgentConfigs, setAgentConfig, deleteAgentConfig, checkCostCap } = await import('./agent-config.js');
12990
+ // GET /agents/:agentId/config — get config for an agent
12991
+ app.get('/agents/:agentId/config', async (request) => {
12992
+ const config = getAgentConfig(request.params.agentId);
12993
+ return config ?? { agentId: request.params.agentId, configured: false };
12994
+ });
12995
+ // PUT /agents/:agentId/config — upsert config for an agent
12996
+ app.put('/agents/:agentId/config', async (request, reply) => {
12997
+ const body = request.body ?? {};
12998
+ try {
12999
+ const config = setAgentConfig(request.params.agentId, {
13000
+ teamId: typeof body.teamId === 'string' ? body.teamId : undefined,
13001
+ model: body.model !== undefined ? body.model : undefined,
13002
+ fallbackModel: body.fallbackModel !== undefined ? body.fallbackModel : undefined,
13003
+ costCapDaily: body.costCapDaily !== undefined ? body.costCapDaily : undefined,
13004
+ costCapMonthly: body.costCapMonthly !== undefined ? body.costCapMonthly : undefined,
13005
+ maxTokensPerCall: body.maxTokensPerCall !== undefined ? body.maxTokensPerCall : undefined,
13006
+ settings: body.settings !== undefined ? body.settings : undefined,
13007
+ });
13008
+ return config;
13009
+ }
13010
+ catch (err) {
13011
+ reply.code(400);
13012
+ return { error: err.message };
13013
+ }
13014
+ });
13015
+ // DELETE /agents/:agentId/config — remove config for an agent
13016
+ app.delete('/agents/:agentId/config', async (request, reply) => {
13017
+ const deleted = deleteAgentConfig(request.params.agentId);
13018
+ if (!deleted) {
13019
+ reply.code(404);
13020
+ return { error: 'Config not found' };
13021
+ }
13022
+ return { success: true };
13023
+ });
13024
+ // GET /agent-configs — list all agent configs
13025
+ app.get('/agent-configs', async (request) => {
13026
+ const query = request.query;
13027
+ return { configs: listAgentConfigs({ teamId: query.teamId }) };
13028
+ });
13029
+ // GET /agents/:agentId/cost-check — runtime cost enforcement check
13030
+ // Used by the runtime before making model calls.
13031
+ app.get('/agents/:agentId/cost-check', async (request) => {
13032
+ const query = request.query;
13033
+ const dailySpend = query.dailySpend ? parseFloat(query.dailySpend) : 0;
13034
+ const monthlySpend = query.monthlySpend ? parseFloat(query.monthlySpend) : 0;
13035
+ return checkCostCap(request.params.agentId, dailySpend, monthlySpend);
13036
+ });
13037
+ // ── Cost-Policy Enforcement Middleware ──────────────────────────────────
13038
+ const { enforcePolicy, recordUsage, getDailySpend, getMonthlySpend, purgeUsageLog, ensureUsageLogTable, } = await import('./cost-enforcement.js');
13039
+ ensureUsageLogTable();
13040
+ // POST /agents/:agentId/enforce-cost — runtime enforcement before model calls
13041
+ app.post('/agents/:agentId/enforce-cost', async (request, reply) => {
13042
+ const result = enforcePolicy(request.params.agentId);
13043
+ const status = result.action === 'deny' ? 403 : 200;
13044
+ return reply.code(status).send(result);
13045
+ });
13046
+ // GET /agents/:agentId/spend — current daily + monthly spend
13047
+ app.get('/agents/:agentId/spend', async (request) => {
13048
+ const { agentId } = request.params;
13049
+ return {
13050
+ agentId,
13051
+ dailySpend: getDailySpend(agentId),
13052
+ monthlySpend: getMonthlySpend(agentId),
13053
+ };
13054
+ });
13055
+ // POST /usage/record — record a usage event
13056
+ app.post('/usage/record', async (request, reply) => {
13057
+ const body = request.body;
13058
+ if (!body?.agentId)
13059
+ return reply.code(400).send({ error: 'agentId is required' });
13060
+ if (!body?.model)
13061
+ return reply.code(400).send({ error: 'model is required' });
13062
+ if (typeof body.cost !== 'number')
13063
+ return reply.code(400).send({ error: 'cost is required (number)' });
13064
+ recordUsage({
13065
+ agentId: body.agentId,
13066
+ model: body.model,
13067
+ inputTokens: body.inputTokens ?? 0,
13068
+ outputTokens: body.outputTokens ?? 0,
13069
+ cost: body.cost,
13070
+ timestamp: Date.now(),
13071
+ });
13072
+ return reply.code(201).send({ ok: true });
13073
+ });
13074
+ // POST /usage/purge — purge old usage records
13075
+ app.post('/usage/purge', async (request) => {
13076
+ const body = request.body;
13077
+ const deleted = purgeUsageLog(body?.maxAgeDays ?? 90);
13078
+ return { deleted };
13079
+ });
13080
+ // ── Agent Memories ─────────────────────────────────────────────────────
13081
+ const { setMemory, getMemory, listMemories, deleteMemory, deleteMemoryById, purgeExpiredMemories, countMemories, } = await import('./agent-memories.js');
13082
+ // Set (create or update) a memory
13083
+ app.put('/agents/:agentId/memories', async (request, reply) => {
13084
+ const { agentId } = request.params;
13085
+ const body = request.body;
13086
+ if (!body?.key)
13087
+ return reply.code(400).send({ error: 'key is required' });
13088
+ if (body.content === undefined || body.content === null)
13089
+ return reply.code(400).send({ error: 'content is required' });
13090
+ try {
13091
+ const memory = setMemory({
13092
+ agentId,
13093
+ namespace: body.namespace,
13094
+ key: body.key,
13095
+ content: body.content,
13096
+ tags: body.tags,
13097
+ expiresAt: body.expiresAt,
13098
+ });
13099
+ return reply.code(200).send(memory);
13100
+ }
13101
+ catch (err) {
13102
+ return reply.code(500).send({ error: err.message });
13103
+ }
13104
+ });
13105
+ // Get a specific memory by key
13106
+ app.get('/agents/:agentId/memories/:key', async (request, reply) => {
13107
+ const { agentId, key } = request.params;
13108
+ const query = request.query;
13109
+ const memory = getMemory(agentId, key, query.namespace);
13110
+ if (!memory)
13111
+ return reply.code(404).send({ error: 'Memory not found' });
13112
+ return memory;
13113
+ });
13114
+ // List memories for an agent
13115
+ app.get('/agents/:agentId/memories', async (request, reply) => {
13116
+ const { agentId } = request.params;
13117
+ const query = request.query;
13118
+ return listMemories({
13119
+ agentId,
13120
+ namespace: query.namespace,
13121
+ tag: query.tag,
13122
+ search: query.search,
13123
+ limit: query.limit ? parseInt(query.limit, 10) : undefined,
13124
+ });
13125
+ });
13126
+ // Delete a memory by key
13127
+ app.delete('/agents/:agentId/memories/:key', async (request, reply) => {
13128
+ const { agentId, key } = request.params;
13129
+ const query = request.query;
13130
+ const deleted = deleteMemory(agentId, key, query.namespace);
13131
+ if (!deleted)
13132
+ return reply.code(404).send({ error: 'Memory not found' });
13133
+ return { deleted: true };
13134
+ });
13135
+ // Count memories
13136
+ app.get('/agents/:agentId/memories/count', async (request, reply) => {
13137
+ const { agentId } = request.params;
13138
+ const query = request.query;
13139
+ return { count: countMemories(agentId, query.namespace) };
13140
+ });
13141
+ // Purge expired memories (housekeeping)
13142
+ app.post('/agents/memories/purge', async (_request, reply) => {
13143
+ const purged = purgeExpiredMemories();
13144
+ return { purged };
13145
+ });
11520
13146
  return app;
11521
13147
  }
11522
13148
  //# sourceMappingURL=server.js.map