reflectt-node 0.1.4 → 0.1.6

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 (160) hide show
  1. package/README.md +63 -146
  2. package/defaults/TEAM-ROLES.yaml +221 -31
  3. package/dist/activationEvents.d.ts +13 -2
  4. package/dist/activationEvents.d.ts.map +1 -1
  5. package/dist/activationEvents.js +172 -38
  6. package/dist/activationEvents.js.map +1 -1
  7. package/dist/activity.d.ts +72 -0
  8. package/dist/activity.d.ts.map +1 -0
  9. package/dist/activity.js +510 -0
  10. package/dist/activity.js.map +1 -0
  11. package/dist/alert-preflight.d.ts +33 -0
  12. package/dist/alert-preflight.d.ts.map +1 -1
  13. package/dist/alert-preflight.js +218 -2
  14. package/dist/alert-preflight.js.map +1 -1
  15. package/dist/assignment.d.ts.map +1 -1
  16. package/dist/assignment.js +11 -6
  17. package/dist/assignment.js.map +1 -1
  18. package/dist/boardHealthWorker.d.ts.map +1 -1
  19. package/dist/boardHealthWorker.js +25 -12
  20. package/dist/boardHealthWorker.js.map +1 -1
  21. package/dist/canvas-slots.d.ts +1 -1
  22. package/dist/channels.d.ts +1 -1
  23. package/dist/chat-approval-detector.d.ts.map +1 -1
  24. package/dist/chat-approval-detector.js +29 -11
  25. package/dist/chat-approval-detector.js.map +1 -1
  26. package/dist/chat.d.ts +14 -0
  27. package/dist/chat.d.ts.map +1 -1
  28. package/dist/chat.js +68 -4
  29. package/dist/chat.js.map +1 -1
  30. package/dist/cli.js +349 -28
  31. package/dist/cli.js.map +1 -1
  32. package/dist/cloud.d.ts +28 -1
  33. package/dist/cloud.d.ts.map +1 -1
  34. package/dist/cloud.js +62 -25
  35. package/dist/cloud.js.map +1 -1
  36. package/dist/compliance-detector.d.ts +42 -0
  37. package/dist/compliance-detector.d.ts.map +1 -0
  38. package/dist/compliance-detector.js +286 -0
  39. package/dist/compliance-detector.js.map +1 -0
  40. package/dist/continuity-loop.d.ts.map +1 -1
  41. package/dist/continuity-loop.js +7 -3
  42. package/dist/continuity-loop.js.map +1 -1
  43. package/dist/dashboard.d.ts +6 -2
  44. package/dist/dashboard.d.ts.map +1 -1
  45. package/dist/dashboard.js +84 -28
  46. package/dist/dashboard.js.map +1 -1
  47. package/dist/db.d.ts.map +1 -1
  48. package/dist/db.js +24 -1
  49. package/dist/db.js.map +1 -1
  50. package/dist/doctor.d.ts.map +1 -1
  51. package/dist/doctor.js +17 -6
  52. package/dist/doctor.js.map +1 -1
  53. package/dist/executionSweeper.d.ts +2 -0
  54. package/dist/executionSweeper.d.ts.map +1 -1
  55. package/dist/executionSweeper.js +60 -4
  56. package/dist/executionSweeper.js.map +1 -1
  57. package/dist/focus.d.ts +20 -0
  58. package/dist/focus.d.ts.map +1 -0
  59. package/dist/focus.js +57 -0
  60. package/dist/focus.js.map +1 -0
  61. package/dist/health.d.ts +1 -0
  62. package/dist/health.d.ts.map +1 -1
  63. package/dist/health.js +47 -15
  64. package/dist/health.js.map +1 -1
  65. package/dist/hostConnectGuard.d.ts +25 -0
  66. package/dist/hostConnectGuard.d.ts.map +1 -0
  67. package/dist/hostConnectGuard.js +27 -0
  68. package/dist/hostConnectGuard.js.map +1 -0
  69. package/dist/index.js +257 -39
  70. package/dist/index.js.map +1 -1
  71. package/dist/insight-mutation.d.ts +26 -0
  72. package/dist/insight-mutation.d.ts.map +1 -1
  73. package/dist/insight-mutation.js +103 -12
  74. package/dist/insight-mutation.js.map +1 -1
  75. package/dist/insight-task-bridge.d.ts +1 -1
  76. package/dist/insight-task-bridge.d.ts.map +1 -1
  77. package/dist/insight-task-bridge.js +6 -3
  78. package/dist/insight-task-bridge.js.map +1 -1
  79. package/dist/insights.d.ts +20 -0
  80. package/dist/insights.d.ts.map +1 -1
  81. package/dist/insights.js +129 -4
  82. package/dist/insights.js.map +1 -1
  83. package/dist/mcp.d.ts.map +1 -1
  84. package/dist/mcp.js +9 -8
  85. package/dist/mcp.js.map +1 -1
  86. package/dist/notificationDedupeGuard.d.ts +33 -0
  87. package/dist/notificationDedupeGuard.d.ts.map +1 -0
  88. package/dist/notificationDedupeGuard.js +88 -0
  89. package/dist/notificationDedupeGuard.js.map +1 -0
  90. package/dist/openclaw.d.ts.map +1 -1
  91. package/dist/openclaw.js +3 -2
  92. package/dist/openclaw.js.map +1 -1
  93. package/dist/policy.d.ts +1 -1
  94. package/dist/policy.d.ts.map +1 -1
  95. package/dist/policy.js +3 -1
  96. package/dist/policy.js.map +1 -1
  97. package/dist/prAutoMerge.d.ts.map +1 -1
  98. package/dist/prAutoMerge.js +23 -0
  99. package/dist/prAutoMerge.js.map +1 -1
  100. package/dist/presence.d.ts +16 -1
  101. package/dist/presence.d.ts.map +1 -1
  102. package/dist/presence.js +97 -9
  103. package/dist/presence.js.map +1 -1
  104. package/dist/pulse.d.ts +60 -0
  105. package/dist/pulse.d.ts.map +1 -0
  106. package/dist/pulse.js +139 -0
  107. package/dist/pulse.js.map +1 -0
  108. package/dist/reflection-automation.d.ts.map +1 -1
  109. package/dist/reflection-automation.js +38 -0
  110. package/dist/reflection-automation.js.map +1 -1
  111. package/dist/release.d.ts +2 -0
  112. package/dist/release.d.ts.map +1 -1
  113. package/dist/release.js +14 -1
  114. package/dist/release.js.map +1 -1
  115. package/dist/request-tracker.d.ts +6 -0
  116. package/dist/request-tracker.d.ts.map +1 -1
  117. package/dist/request-tracker.js +31 -12
  118. package/dist/request-tracker.js.map +1 -1
  119. package/dist/scopeOverlap.d.ts +32 -0
  120. package/dist/scopeOverlap.d.ts.map +1 -0
  121. package/dist/scopeOverlap.js +219 -0
  122. package/dist/scopeOverlap.js.map +1 -0
  123. package/dist/server.d.ts.map +1 -1
  124. package/dist/server.js +736 -117
  125. package/dist/server.js.map +1 -1
  126. package/dist/service-probe.d.ts.map +1 -1
  127. package/dist/service-probe.js +39 -2
  128. package/dist/service-probe.js.map +1 -1
  129. package/dist/shipped-heartbeat.d.ts +1 -1
  130. package/dist/shipped-heartbeat.js +1 -1
  131. package/dist/taskPrecheck.js +6 -6
  132. package/dist/taskPrecheck.js.map +1 -1
  133. package/dist/tasks-next-diagnostics.d.ts +15 -0
  134. package/dist/tasks-next-diagnostics.d.ts.map +1 -0
  135. package/dist/tasks-next-diagnostics.js +33 -0
  136. package/dist/tasks-next-diagnostics.js.map +1 -0
  137. package/dist/tasks.d.ts +3 -2
  138. package/dist/tasks.d.ts.map +1 -1
  139. package/dist/tasks.js +41 -16
  140. package/dist/tasks.js.map +1 -1
  141. package/dist/team-config.d.ts.map +1 -1
  142. package/dist/team-config.js +20 -0
  143. package/dist/team-config.js.map +1 -1
  144. package/dist/todoHoardingGuard.d.ts +35 -0
  145. package/dist/todoHoardingGuard.d.ts.map +1 -0
  146. package/dist/todoHoardingGuard.js +150 -0
  147. package/dist/todoHoardingGuard.js.map +1 -0
  148. package/dist/types.d.ts +4 -2
  149. package/dist/types.d.ts.map +1 -1
  150. package/dist/version.d.ts +2 -0
  151. package/dist/version.d.ts.map +1 -0
  152. package/dist/version.js +16 -0
  153. package/dist/version.js.map +1 -0
  154. package/dist/working-contract.d.ts.map +1 -1
  155. package/dist/working-contract.js +59 -3
  156. package/dist/working-contract.js.map +1 -1
  157. package/package.json +5 -1
  158. package/public/dashboard.js +161 -20
  159. package/public/docs.md +68 -8
  160. package/public/polls-mock.html +1 -1
package/dist/server.js CHANGED
@@ -11,9 +11,9 @@ import { createHash } from 'crypto';
11
11
  import { promises as fs, existsSync, readFileSync, readdirSync } from 'fs';
12
12
  import { resolve, sep, join } from 'path';
13
13
  import { execSync } from 'child_process';
14
- import { serverConfig, openclawConfig, isDev, REFLECTT_HOME } from './config.js';
14
+ import { serverConfig, openclawConfig, isDev, REFLECTT_HOME, DATA_DIR } from './config.js';
15
15
  import { trackRequest, getRequestMetrics } from './request-tracker.js';
16
- import { getPreflightMetrics } from './alert-preflight.js';
16
+ import { getPreflightMetrics, snapshotDailyMetrics, getDailySnapshots, startAutoSnapshot } from './alert-preflight.js';
17
17
  // ── Build info (read once at startup) ──────────────────────────────────────
18
18
  const BUILD_VERSION = (() => {
19
19
  try {
@@ -37,7 +37,11 @@ import { chatManager } from './chat.js';
37
37
  import { taskManager } from './tasks.js';
38
38
  import { detectApproval, applyApproval } from './chat-approval-detector.js';
39
39
  import { inboxManager } from './inbox.js';
40
+ import { getFocus, setFocus, clearFocus, getFocusSummary } from './focus.js';
41
+ import { generatePulse, generateCompactPulse } from './pulse.js';
42
+ import { scanScopeOverlap, scanAndNotify } from './scopeOverlap.js';
40
43
  import { getDb } from './db.js';
44
+ import { isTestHarnessTask } from './test-task-filter.js';
41
45
  import { handleMCPRequest, handleSSERequest, handleMessagesRequest } from './mcp.js';
42
46
  import { memoryManager } from './memory.js';
43
47
  import { buildContextInjection, getContextBudgets, getContextMemo, upsertContextMemo } from './context-budget.js';
@@ -54,6 +58,7 @@ import { emitActivationEvent, getUserFunnelState, getFunnelSummary, hasCompleted
54
58
  import { alertUnauthorizedApproval, alertFlipAttempt, getMutationAlertStatus, pruneOldAttempts } from './mutationAlert.js';
55
59
  import { mentionAckTracker } from './mention-ack.js';
56
60
  import { analyticsManager } from './analytics.js';
61
+ import { processRequest as complianceProcessRequest, queryViolations, getViolationSummary } from './compliance-detector.js';
57
62
  import { getDashboardHTML } from './dashboard.js';
58
63
  import { healthMonitor, computeActiveLane } from './health.js';
59
64
  import { getSystemLoopTicks, recordSystemLoopTick } from './system-loop-state.js';
@@ -89,8 +94,9 @@ import { submitFeedback, listFeedback, getFeedback, updateFeedback, voteFeedback
89
94
  import { createEscalation, acknowledgeEscalation, resolveEscalation, tickEscalations, getEscalation, getEscalationByFeedback, listEscalations, } from './escalation.js';
90
95
  import { slotManager as canvasSlots } from './canvas-slots.js';
91
96
  import { createReflection, getReflection, listReflections, countReflections, reflectionStats, validateReflection, ROLE_TYPES, SEVERITY_LEVELS, recordReflectionDuplicate } from './reflections.js';
92
- import { ingestReflection, getInsight, listInsights, insightStats, extractClusterKey, tickCooldowns, updateInsightStatus, getOrphanedInsights, reconcileInsightTaskLinks, getLoopSummary } from './insights.js';
93
- import { patchInsightById } from './insight-mutation.js';
97
+ import { ingestReflection, getInsight, listInsights, insightStats, extractClusterKey, tickCooldowns, updateInsightStatus, getOrphanedInsights, reconcileInsightTaskLinks, getLoopSummary, sweepShippedCandidates } from './insights.js';
98
+ import { queryActivity, ACTIVITY_SOURCES } from './activity.js';
99
+ import { patchInsightById, cooldownInsightById, closeInsightById } from './insight-mutation.js';
94
100
  import { promoteInsight, validatePromotionInput, generateRecurringCandidates, listPromotionAudits, getPromotionAuditByInsight } from './insight-promotion.js';
95
101
  import { runIntake, batchIntake, pipelineMaintenance, getPipelineStats } from './intake-pipeline.js';
96
102
  import { listLineage, getLineage, lineageStats } from './lineage.js';
@@ -140,7 +146,7 @@ const CreateTaskSchema = z.object({
140
146
  title: z.string().min(1),
141
147
  type: z.enum(TASK_TYPES).optional(), // optional for backward compat, validated when present
142
148
  description: z.string().optional(),
143
- status: z.enum(['todo', 'doing', 'blocked', 'validating', 'done']).default('todo'),
149
+ status: z.enum(['todo', 'doing', 'blocked', 'validating', 'done', 'cancelled']).default('todo'),
144
150
  assignee: z.string().trim().min(1).optional().default('unassigned'),
145
151
  reviewer: z.string().trim().min(1).or(z.literal('auto')).default('auto'), // 'auto' triggers load-balanced assignment
146
152
  done_criteria: z.array(z.string().trim().min(1)).optional().default([]),
@@ -152,6 +158,8 @@ const CreateTaskSchema = z.object({
152
158
  tags: z.array(z.string()).optional(),
153
159
  teamId: z.string().optional(),
154
160
  metadata: z.record(z.unknown()).optional(),
161
+ dueAt: z.number().int().positive().optional(), // epoch ms — when the task is due
162
+ scheduledFor: z.number().int().positive().optional(), // epoch ms — when work should start
155
163
  });
156
164
  /**
157
165
  * Definition-of-ready check: validates task quality at creation time.
@@ -267,7 +275,7 @@ function normalizeConfiguredModel(value) {
267
275
  const UpdateTaskSchema = z.object({
268
276
  title: z.string().min(1).optional(),
269
277
  description: z.string().optional(),
270
- status: z.enum(['todo', 'doing', 'blocked', 'validating', 'done']).optional(),
278
+ status: z.enum(['todo', 'doing', 'blocked', 'validating', 'done', 'cancelled']).optional(),
271
279
  assignee: z.string().optional(),
272
280
  reviewer: z.string().optional(),
273
281
  done_criteria: z.array(z.string().min(1)).optional(),
@@ -277,6 +285,8 @@ const UpdateTaskSchema = z.object({
277
285
  tags: z.array(z.string()).optional(),
278
286
  metadata: z.record(z.unknown()).optional(),
279
287
  actor: z.string().trim().min(1).optional(),
288
+ dueAt: z.number().int().positive().nullable().optional(), // epoch ms, null to clear
289
+ scheduledFor: z.number().int().positive().nullable().optional(), // epoch ms, null to clear
280
290
  });
281
291
  const CreateTaskCommentSchema = z.object({
282
292
  author: z.string().trim().min(1),
@@ -328,7 +338,7 @@ const CreateRecurringTaskSchema = z.object({
328
338
  metadata: z.record(z.unknown()).optional(),
329
339
  schedule: RecurringTaskScheduleSchema,
330
340
  enabled: z.boolean().optional(),
331
- status: z.enum(['todo', 'doing', 'blocked', 'validating', 'done']).optional(),
341
+ status: z.enum(['todo', 'doing', 'blocked', 'validating', 'done', 'cancelled']).optional(),
332
342
  });
333
343
  const UpdateRecurringTaskSchema = z.object({
334
344
  enabled: z.boolean().optional(),
@@ -580,10 +590,26 @@ function enforceQaBundleGateForValidating(status, metadata, expectedTaskId) {
580
590
  };
581
591
  }
582
592
  }
593
+ // Early format validation: PR URL must be a valid GitHub PR URL
594
+ if (reviewPacket?.pr_url && !/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+$/.test(reviewPacket.pr_url)) {
595
+ return {
596
+ ok: false,
597
+ error: `Invalid PR URL format: "${reviewPacket.pr_url}"`,
598
+ hint: 'Expected format: https://github.com/owner/repo/pull/123',
599
+ };
600
+ }
601
+ // Early format validation: commit SHA must be 7-40 hex chars
602
+ if (reviewPacket?.commit && !/^[a-f0-9]{7,40}$/i.test(reviewPacket.commit)) {
603
+ return {
604
+ ok: false,
605
+ error: `Invalid commit SHA format: "${reviewPacket.commit}"`,
606
+ hint: 'Expected 7-40 hex characters, e.g. "a1b2c3d"',
607
+ };
608
+ }
583
609
  if (reviewPacket && !nonCodeLane && expectedTaskId && reviewPacket.task_id !== expectedTaskId) {
584
610
  return {
585
611
  ok: false,
586
- error: `Review packet task mismatch: metadata.qa_bundle.review_packet.task_id must match ${expectedTaskId}`,
612
+ error: `Review packet task mismatch: got "${reviewPacket.task_id}", expected "${expectedTaskId}"`,
587
613
  hint: 'Set review_packet.task_id to the current task ID before moving to validating.',
588
614
  };
589
615
  }
@@ -591,7 +617,7 @@ function enforceQaBundleGateForValidating(status, metadata, expectedTaskId) {
591
617
  if (reviewPacket && !nonCodeLane && artifactPath && artifactPath !== reviewPacket.artifact_path) {
592
618
  return {
593
619
  ok: false,
594
- error: 'Review packet mismatch: metadata.qa_bundle.review_packet.artifact_path must match metadata.artifact_path',
620
+ error: `Review packet artifact_path mismatch: got "${reviewPacket.artifact_path}", expected "${artifactPath}"`,
595
621
  hint: 'Use the same canonical process/... artifact path in both fields.',
596
622
  };
597
623
  }
@@ -855,7 +881,7 @@ function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
855
881
  if (handoff.task_id !== taskId) {
856
882
  return {
857
883
  ok: false,
858
- error: `Review handoff task_id mismatch: expected ${taskId}`,
884
+ error: `Review handoff task_id mismatch: got "${handoff.task_id}", expected "${taskId}"`,
859
885
  hint: 'Set metadata.review_handoff.task_id to the exact task being transitioned.',
860
886
  };
861
887
  }
@@ -1325,33 +1351,47 @@ function extractMentions(content) {
1325
1351
  return resolveAgentMention(raw) || raw;
1326
1352
  }).filter(Boolean)));
1327
1353
  }
1328
- function buildAutonomyWarnings(content) {
1329
- const mentions = extractMentions(content);
1330
- if (mentions.length === 0)
1354
+ function getOwnerHandlesFromEnv() {
1355
+ const raw = String(process.env.REFLECTT_OWNER_HANDLES || '').trim();
1356
+ if (!raw)
1331
1357
  return [];
1332
- // Only warn when the message is explicitly directed at Ryan.
1333
- const directedAtRyan = mentions.includes('ryan') || mentions.includes('ryancampbell');
1334
- if (!directedAtRyan)
1358
+ return raw
1359
+ .split(',')
1360
+ .map(s => s.trim().toLowerCase())
1361
+ .filter(Boolean);
1362
+ }
1363
+ function isDirectedAtConfiguredOwner(content) {
1364
+ const owners = getOwnerHandlesFromEnv();
1365
+ if (owners.length === 0)
1366
+ return false;
1367
+ const mentions = extractMentions(content);
1368
+ return owners.some(o => mentions.includes(o));
1369
+ }
1370
+ function buildAutonomyWarnings(content) {
1371
+ // If no owner handles are configured, keep this feature off by default.
1372
+ // reflectt-node must remain generic for customers.
1373
+ if (!isDirectedAtConfiguredOwner(content))
1335
1374
  return [];
1336
1375
  const normalized = content.toLowerCase();
1337
- // Detect the specific anti-pattern: asking leadership what to do next.
1376
+ // Detect the specific anti-pattern: asking a human leader/operator what to do next.
1338
1377
  // Keep the pattern narrow to avoid false positives on legitimate asks.
1339
1378
  const approvalSeeking = /\b(what should i (do|work on) next|what(?:['’]?s) next(?: for me)?|what do i do next|what do you want me to do next|should i (do|work on)( [^\n\r]{0,80})? next)\b/i;
1340
1379
  if (!approvalSeeking.test(normalized))
1341
1380
  return [];
1342
1381
  return [
1343
- 'Autonomy guardrail: avoid asking Ryan what to do next. Pull from the board (/tasks/next) or pick the highest-priority task and ship. Escalate to Ryan only if blocked on a decision only a human can make.',
1382
+ 'Autonomy guardrail: avoid asking a human operator what to do next. Pull from the board (/tasks/next) or pick the highest-priority task and ship. Escalate only if you are blocked on a decision or permission that only a human can provide.',
1344
1383
  ];
1345
1384
  }
1346
- function validateRyanApprovalPing(content, from, channel) {
1347
- // Only gate messages directed at Ryan.
1348
- const mentions = extractMentions(content);
1349
- const directedAtRyan = mentions.includes('ryan') || mentions.includes('ryancampbell');
1350
- if (!directedAtRyan)
1385
+ function validateOwnerApprovalPing(content, from, channel) {
1386
+ const owners = getOwnerHandlesFromEnv();
1387
+ if (owners.length === 0)
1351
1388
  return {};
1352
- // Don't gate Ryan/system talking to themselves.
1389
+ // Only gate messages directed at configured owner handles.
1390
+ if (!isDirectedAtConfiguredOwner(content))
1391
+ return {};
1392
+ // Don't gate the owner/system talking to themselves.
1353
1393
  const sender = String(from || '').toLowerCase();
1354
- if (sender === 'ryan' || sender === 'system')
1394
+ if (owners.includes(sender) || sender === 'system')
1355
1395
  return {};
1356
1396
  const normalized = content.toLowerCase();
1357
1397
  // We only care about PR approval/merge requests.
@@ -1359,15 +1399,15 @@ function validateRyanApprovalPing(content, from, channel) {
1359
1399
  (/(\bpr\b|pull request|github\.com\/[^\s]+\/pull\/[0-9]+|#\d+)/i.test(normalized));
1360
1400
  if (!looksLikePrRequest)
1361
1401
  return {};
1362
- // Allow if the message explicitly explains why Ryan is required and references a task id.
1402
+ // Allow if the message explicitly explains why a human is required and references a task id.
1363
1403
  const hasTaskId = hasTaskIdReference(content);
1364
1404
  const hasPermissionsReason = /(permission|permissions|auth|authed|rights|cannot|can\s*not|can't|blocked|branch protection|required)/i.test(normalized);
1365
1405
  if (hasTaskId && hasPermissionsReason)
1366
1406
  return {};
1367
1407
  const normalizedChannel = (channel || 'general').toLowerCase();
1368
1408
  return {
1369
- blockingError: `Don't ask @ryan to approve/merge PRs by default (channel=${normalizedChannel}). Ask the assigned reviewer, or merge it yourself. Escalate to Ryan only when truly blocked by permissions/auth.`,
1370
- hint: 'If Ryan is genuinely required: include task-<id> and a short permissions/auth reason (e.g., "no merge rights" / "branch protection"), plus the PR link.',
1409
+ blockingError: `Don't ask a human operator to approve/merge PRs by default (channel=${normalizedChannel}). Ask the assigned reviewer, or merge it yourself. Escalate only when truly blocked by permissions/auth.`,
1410
+ hint: 'If a human is genuinely required: include task-<id> and a short permissions/auth reason (e.g., "no merge rights" / "branch protection"), plus the PR link.',
1371
1411
  };
1372
1412
  }
1373
1413
  function buildMentionWarnings(content) {
@@ -1553,6 +1593,11 @@ export async function createServer() {
1553
1593
  healthMonitor.trackRequest(duration);
1554
1594
  trackTelemetryRequest(request.method, request.url, reply.statusCode, duration);
1555
1595
  trackRequest(request.method, request.url, reply.statusCode, request.headers['user-agent']);
1596
+ // Compliance detector: flag state-read-before-assertion violations
1597
+ try {
1598
+ complianceProcessRequest(request.method, request.url, reply.statusCode, request.query ?? {}, request.body, request.headers);
1599
+ }
1600
+ catch { /* never let compliance logging break a request */ }
1556
1601
  if (reply.statusCode >= 400) {
1557
1602
  healthMonitor.trackError();
1558
1603
  // Normalize URL before telemetry to prevent PII leaks in query params
@@ -1758,7 +1803,7 @@ export async function createServer() {
1758
1803
  reflectionPipelineHealth.recentPromotions = recentPromotions;
1759
1804
  reflectionPipelineHealth.lastCheckedAt = now;
1760
1805
  if (recentReflections === 0) {
1761
- reflectionPipelineHealth.status = 'healthy';
1806
+ reflectionPipelineHealth.status = 'idle';
1762
1807
  reflectionPipelineHealth.firstZeroInsightAt = 0;
1763
1808
  return reflectionPipelineHealth;
1764
1809
  }
@@ -1791,7 +1836,7 @@ export async function createServer() {
1791
1836
  chatManager.sendMessage({
1792
1837
  channel: 'general',
1793
1838
  from: 'system',
1794
- content: `🚨 Reflection pipeline broken: ${health.recentReflections} reflections in last ${health.windowMin}m but 0 insights created. @link @sage investigate ingestion/listener path.`,
1839
+ content: `🚨 Reflection pipeline broken: ${health.recentReflections} reflections in last ${health.windowMin}m but 0 recentInsightActivity (created+updated). @link @sage investigate ingestion/listener path.`,
1795
1840
  }).catch(() => { });
1796
1841
  }
1797
1842
  }
@@ -1888,7 +1933,7 @@ export async function createServer() {
1888
1933
  : {
1889
1934
  status: 'not configured',
1890
1935
  hint: 'Set OPENCLAW_GATEWAY_URL and OPENCLAW_GATEWAY_TOKEN environment variables, or run: openclaw gateway token',
1891
- docs: 'https://reflectt.ai/bootstrap',
1936
+ docs: 'https://app.reflectt.ai',
1892
1937
  },
1893
1938
  chat: chatManager.getStats(),
1894
1939
  tasks: taskManager.getStats({ includeTest }),
@@ -1901,6 +1946,15 @@ export async function createServer() {
1901
1946
  };
1902
1947
  });
1903
1948
  // ─── Request errors — last N errors for launch-day debugging ───
1949
+ app.get('/health/chat', async () => {
1950
+ const stats = chatManager.getStats();
1951
+ return {
1952
+ totalMessages: stats.totalMessages,
1953
+ rooms: stats.rooms,
1954
+ subscribers: stats.subscribers,
1955
+ drops: stats.drops,
1956
+ };
1957
+ });
1904
1958
  app.get('/health/errors', async () => {
1905
1959
  const m = getRequestMetrics();
1906
1960
  return {
@@ -1909,6 +1963,7 @@ export async function createServer() {
1909
1963
  error_rate: m.total > 0 ? Math.round((m.errors / m.total) * 10000) / 100 : 0,
1910
1964
  rolling: m.rolling,
1911
1965
  recent: m.recentErrors.slice(0, 20),
1966
+ top_buckets: m.topErrorBuckets,
1912
1967
  timestamp: Date.now(),
1913
1968
  };
1914
1969
  });
@@ -2188,8 +2243,25 @@ export async function createServer() {
2188
2243
  });
2189
2244
  // ─── Alert preflight metrics ───
2190
2245
  app.get('/health/alert-preflight', async () => {
2246
+ snapshotDailyMetrics(); // Persist daily checkpoint on health check
2191
2247
  return { ...getPreflightMetrics(), timestamp: Date.now() };
2192
2248
  });
2249
+ app.get('/health/alert-preflight/history', async () => {
2250
+ snapshotDailyMetrics();
2251
+ return { snapshots: getDailySnapshots(), currentSession: getPreflightMetrics() };
2252
+ });
2253
+ // ─── Todo hoarding health: orphan detection + auto-unassign status ───
2254
+ app.get('/health/hoarding', async (request) => {
2255
+ const query = request.query;
2256
+ const dryRun = query.dry_run !== '0' && query.dry_run !== 'false'; // default: dry run
2257
+ const { sweepTodoHoarding, TODO_CAP, IDLE_THRESHOLD_MS } = await import('./todoHoardingGuard.js');
2258
+ const result = await sweepTodoHoarding({ dryRun });
2259
+ return {
2260
+ ...result,
2261
+ config: { todoCap: TODO_CAP, idleThresholdMinutes: Math.round(IDLE_THRESHOLD_MS / 60000) },
2262
+ dryRun,
2263
+ };
2264
+ });
2193
2265
  // ─── Backlog health: ready counts per lane, breach status, floor compliance ───
2194
2266
  app.get('/health/backlog', async (request, reply) => {
2195
2267
  const query = request.query;
@@ -2206,7 +2278,7 @@ export async function createServer() {
2206
2278
  return false;
2207
2279
  return task.blocked_by.some((blockerId) => {
2208
2280
  const blocker = taskManager.getTask(blockerId);
2209
- return blocker && blocker.status !== 'done';
2281
+ return blocker && !['done', 'resolved_externally', 'cancelled'].includes(blocker.status);
2210
2282
  });
2211
2283
  };
2212
2284
  // Definition-of-ready gate for backlog readiness: todo + required fields + unblocked
@@ -2217,14 +2289,23 @@ export async function createServer() {
2217
2289
  const hasDoneCriteria = Array.isArray(task.done_criteria) && task.done_criteria.length > 0;
2218
2290
  return hasTitle && hasPriority && hasReviewer && hasDoneCriteria;
2219
2291
  };
2292
+ // Count tasks missing metadata.lane for visibility
2293
+ const missingLaneCount = allTasks.filter(t => !t.metadata?.lane && !['done', 'cancelled', 'resolved_externally'].includes(t.status)).length;
2220
2294
  // Build per-lane health
2295
+ // Task belongs to a lane if: (1) metadata.lane matches, OR (2) assignee is in lane agents (fallback)
2221
2296
  const laneHealth = Object.entries(lanes).map(([laneName, config]) => {
2222
- const laneTasks = allTasks.filter(t => config.agents.includes(t.assignee || ''));
2297
+ const laneTasks = allTasks.filter(t => {
2298
+ const taskLane = t.metadata?.lane;
2299
+ if (taskLane)
2300
+ return taskLane === laneName;
2301
+ return config.agents.includes(t.assignee || '');
2302
+ });
2223
2303
  const todo = laneTasks.filter(t => t.status === 'todo');
2224
2304
  const doing = laneTasks.filter(t => t.status === 'doing');
2225
2305
  const validating = laneTasks.filter(t => t.status === 'validating');
2226
2306
  const blocked = laneTasks.filter(t => t.status === 'blocked' || (t.status === 'todo' && isBlocked(t)));
2227
2307
  const done = laneTasks.filter(t => t.status === 'done');
2308
+ const resolvedExternally = laneTasks.filter(t => t.status === 'resolved_externally');
2228
2309
  // Ready = todo + required fields + unblocked
2229
2310
  const ready = todo.filter(t => !isBlocked(t) && hasRequiredFields(t));
2230
2311
  const notReady = todo.filter(t => isBlocked(t) || !hasRequiredFields(t));
@@ -2260,9 +2341,19 @@ export async function createServer() {
2260
2341
  validating: validating.length,
2261
2342
  blocked: blocked.length,
2262
2343
  done: done.length,
2344
+ resolvedExternally: resolvedExternally.length,
2263
2345
  },
2346
+ // Top-level convenience aliases (never null)
2347
+ readyCount: ready.length,
2348
+ wipCount: doing.length,
2264
2349
  compliance: {
2265
2350
  status: floorBreaches.length > 0 ? 'breach' : ready.length >= config.readyFloor ? 'healthy' : 'warning',
2351
+ breaches: floorBreaches.map(a => ({
2352
+ agent: a.agent,
2353
+ ready: a.ready,
2354
+ required: config.readyFloor,
2355
+ deficit: config.readyFloor - a.ready,
2356
+ })),
2266
2357
  floorBreaches: floorBreaches.map(a => ({
2267
2358
  agent: a.agent,
2268
2359
  ready: a.ready,
@@ -2307,6 +2398,7 @@ export async function createServer() {
2307
2398
  breachedLaneCount: breachedLanes.length,
2308
2399
  overallStatus: breachedLanes.length > 0 ? 'breach' : totalReady === 0 ? 'critical' : 'healthy',
2309
2400
  staleValidatingCount: staleValidating.length,
2401
+ missingLaneMetadata: missingLaneCount,
2310
2402
  },
2311
2403
  lanes: laneHealth,
2312
2404
  staleValidating,
@@ -2634,6 +2726,8 @@ export async function createServer() {
2634
2726
  pid: build.pid,
2635
2727
  nodeVersion: build.nodeVersion,
2636
2728
  uptime: build.uptime,
2729
+ dataDir: DATA_DIR,
2730
+ reflecttHome: REFLECTT_HOME,
2637
2731
  };
2638
2732
  });
2639
2733
  // Error logs (for debugging)
@@ -2733,8 +2827,11 @@ export async function createServer() {
2733
2827
  app.get('/', async (_request, reply) => {
2734
2828
  reply.redirect('/dashboard');
2735
2829
  });
2736
- app.get('/dashboard', async (_request, reply) => {
2737
- reply.type('text/html').send(getDashboardHTML());
2830
+ app.get('/dashboard', async (request, reply) => {
2831
+ const envFlag = process.env.REFLECTT_INTERNAL_UI === '1';
2832
+ const queryFlag = request.query?.internal === '1';
2833
+ const internalMode = envFlag && queryFlag;
2834
+ reply.type('text/html').send(getDashboardHTML({ internalMode }));
2738
2835
  });
2739
2836
  // API docs page (markdown — token-efficient for agents)
2740
2837
  // UI Kit reference page — living component/states documentation
@@ -2768,13 +2865,13 @@ export async function createServer() {
2768
2865
  reply.code(500).send({ error: 'Failed to load docs' });
2769
2866
  }
2770
2867
  });
2771
- // Serve avatar images
2868
+ // Serve avatar images (with fallback for missing avatars)
2772
2869
  app.get('/avatars/:filename', async (request, reply) => {
2773
2870
  const { filename } = request.params;
2774
- // Basic security: only allow .png files with alphanumeric names
2775
- if (!/^[a-z]+\.png$/.test(filename)) {
2776
- return reply.code(404).send({ error: 'Not found' });
2777
- }
2871
+ // Security: allow alphanumeric, hyphens, underscores + .png/.svg extension
2872
+ // (no slashes / traversal). If invalid, still return a safe default avatar (200)
2873
+ // to avoid error-rate pollution from bad/mismatched filenames.
2874
+ const safe = /^[a-z0-9_-]+\.(png|svg)$/i.test(filename);
2778
2875
  try {
2779
2876
  const { promises: fs } = await import('fs');
2780
2877
  const { join } = await import('path');
@@ -2783,13 +2880,59 @@ export async function createServer() {
2783
2880
  const __filename = fileURLToPath(import.meta.url);
2784
2881
  const __dirname = dirname(__filename);
2785
2882
  const publicDir = join(__dirname, '..', 'public', 'avatars');
2786
- const filePath = join(publicDir, filename);
2787
- const data = await fs.readFile(filePath);
2788
- reply.type('image/png').send(data);
2883
+ if (safe) {
2884
+ const lower = filename.toLowerCase();
2885
+ const ext = lower.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
2886
+ // 1) Exact avatar file
2887
+ try {
2888
+ const filePath = join(publicDir, filename);
2889
+ const data = await fs.readFile(filePath);
2890
+ reply.type(ext).header('Cache-Control', 'public, max-age=3600').send(data);
2891
+ return;
2892
+ }
2893
+ catch {
2894
+ // fall through to fallback handling below
2895
+ }
2896
+ // 2) Some clients request generic agent-N.png assets. Serve a deterministic
2897
+ // fallback PNG instead of returning 404 (noise) or a mismatched SVG.
2898
+ if (lower.endsWith('.png')) {
2899
+ const match = /^agent-(\d+)\.png$/.exec(lower);
2900
+ const fallbackPool = [
2901
+ 'kai.png',
2902
+ 'link.png',
2903
+ 'sage.png',
2904
+ 'pixel.png',
2905
+ 'echo.png',
2906
+ 'scout.png',
2907
+ 'harmony.png',
2908
+ 'spark.png',
2909
+ 'rhythm.png',
2910
+ 'ryan.png',
2911
+ ];
2912
+ const n = match ? Number(match[1]) : NaN;
2913
+ const index = Number.isFinite(n) ? (Math.max(1, n) - 1) % fallbackPool.length : 0;
2914
+ const fallbackName = fallbackPool[index] || 'ryan.png';
2915
+ try {
2916
+ const data = await fs.readFile(join(publicDir, fallbackName));
2917
+ reply.type('image/png').header('Cache-Control', 'public, max-age=3600').send(data);
2918
+ return;
2919
+ }
2920
+ catch {
2921
+ // fall through to SVG default below
2922
+ }
2923
+ }
2924
+ }
2789
2925
  }
2790
- catch (err) {
2791
- reply.code(404).send({ error: 'Avatar not found' });
2926
+ catch {
2927
+ // fall through to default avatar below
2792
2928
  }
2929
+ // Default avatar: render an initial when possible, else '?'
2930
+ const initial = safe ? (filename.replace(/\.(png|svg)$/i, '').charAt(0) || '?').toUpperCase() : '?';
2931
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
2932
+ <rect width="64" height="64" rx="12" fill="#21262d"/>
2933
+ <text x="32" y="38" text-anchor="middle" font-family="system-ui,sans-serif" font-size="24" font-weight="600" fill="#8d96a0">${initial}</text>
2934
+ </svg>`;
2935
+ reply.type('image/svg+xml').header('Cache-Control', 'public, max-age=3600').send(svg);
2793
2936
  });
2794
2937
  // Serve dashboard JS (extracted from inline template)
2795
2938
  app.get('/dashboard.js', async (_request, reply) => {
@@ -2933,14 +3076,14 @@ export async function createServer() {
2933
3076
  hint: actionValidation.hint,
2934
3077
  };
2935
3078
  }
2936
- const ryanApprovalGate = validateRyanApprovalPing(data.content, data.from, data.channel);
2937
- if (ryanApprovalGate.blockingError) {
3079
+ const ownerApprovalGate = validateOwnerApprovalPing(data.content, data.from, data.channel);
3080
+ if (ownerApprovalGate.blockingError) {
2938
3081
  reply.code(400);
2939
3082
  return {
2940
3083
  success: false,
2941
- error: ryanApprovalGate.blockingError,
2942
- gate: 'ryan_approval_gate',
2943
- hint: ryanApprovalGate.hint,
3084
+ error: ownerApprovalGate.blockingError,
3085
+ gate: 'owner_approval_gate',
3086
+ hint: ownerApprovalGate.hint,
2944
3087
  };
2945
3088
  }
2946
3089
  const message = await chatManager.sendMessage(data);
@@ -2954,7 +3097,7 @@ export async function createServer() {
2954
3097
  // Auto-update presence: if you're posting, you're active
2955
3098
  if (data.from) {
2956
3099
  presenceManager.recordActivity(data.from, 'message');
2957
- presenceManager.updatePresence(data.from, 'working');
3100
+ presenceManager.touchPresence(data.from);
2958
3101
  // Activation funnel: first team message
2959
3102
  emitActivationEvent('first_team_message_sent', data.from, {
2960
3103
  channel: data.channel || 'general',
@@ -3150,7 +3293,7 @@ export async function createServer() {
3150
3293
  return false;
3151
3294
  return t.blocked_by.some((blockerId) => {
3152
3295
  const blocker = taskManager.getTask(blockerId);
3153
- return blocker && blocker.status !== 'done';
3296
+ return blocker && !['done', 'resolved_externally', 'cancelled'].includes(blocker.status);
3154
3297
  });
3155
3298
  };
3156
3299
  const nextTodo = taskManager
@@ -3593,22 +3736,38 @@ export async function createServer() {
3593
3736
  const { metadata, description, done_criteria, ...slim } = task;
3594
3737
  return slim;
3595
3738
  };
3596
- const isCompact = (query) => query.compact === '1' || query.compact === 'true';
3739
+ const isCompact = (query) => {
3740
+ const v = Array.isArray(query.compact) ? query.compact[0] : query.compact;
3741
+ return v === '1' || v === 'true';
3742
+ };
3597
3743
  // List tasks
3598
3744
  app.get('/tasks', async (request, reply) => {
3599
3745
  const query = request.query;
3600
- const updatedSince = parseEpochMs(query.updatedSince || query.since);
3601
- const limit = boundedLimit(query.limit, DEFAULT_LIMITS.tasks, MAX_LIMITS.tasks);
3602
- const tagFilter = query.tag
3603
- ? [query.tag]
3604
- : (query.tags ? query.tags.split(',') : undefined);
3605
- const includeTest = query.include_test === '1' || query.include_test === 'true';
3746
+ const updatedSince = parseEpochMs((Array.isArray(query.updatedSince) ? query.updatedSince[0] : query.updatedSince) || (Array.isArray(query.since) ? query.since[0] : query.since));
3747
+ const limitRaw = Array.isArray(query.limit) ? query.limit[0] : query.limit;
3748
+ const limit = boundedLimit(limitRaw, DEFAULT_LIMITS.tasks, MAX_LIMITS.tasks);
3749
+ const tagRaw = Array.isArray(query.tag) ? query.tag[0] : query.tag;
3750
+ const tagsRaw = Array.isArray(query.tags) ? query.tags[0] : query.tags;
3751
+ const tagFilter = tagRaw
3752
+ ? [tagRaw]
3753
+ : (tagsRaw ? tagsRaw.split(',') : undefined);
3754
+ const includeTestRaw = Array.isArray(query.include_test) ? query.include_test[0] : query.include_test;
3755
+ const includeTest = includeTestRaw === '1' || includeTestRaw === 'true';
3756
+ // Normalize status: supports ?status=todo, ?status[]=todo&status[]=doing,
3757
+ // and repeated ?status=todo&status=doing (Fastify parses as array)
3758
+ const VALID_STATUSES = ['todo', 'doing', 'blocked', 'validating', 'done', 'cancelled'];
3759
+ const statusRaw = query.status;
3760
+ const statusFilter = statusRaw === undefined
3761
+ ? undefined
3762
+ : (Array.isArray(statusRaw)
3763
+ ? statusRaw.filter((s) => VALID_STATUSES.includes(s))
3764
+ : VALID_STATUSES.includes(statusRaw) ? [statusRaw] : undefined);
3606
3765
  let tasks = taskManager.listTasks({
3607
- status: query.status,
3608
- assignee: query.assignee || query.assignedTo, // Support both for backward compatibility
3609
- createdBy: query.createdBy,
3610
- teamId: normalizeTeamId(query.teamId),
3611
- priority: query.priority,
3766
+ status: statusFilter && statusFilter.length === 1 ? statusFilter[0] : (statusFilter && statusFilter.length > 1 ? statusFilter : undefined),
3767
+ assignee: (Array.isArray(query.assignee) ? query.assignee[0] : query.assignee) || (Array.isArray(query.assignedTo) ? query.assignedTo[0] : query.assignedTo),
3768
+ createdBy: Array.isArray(query.createdBy) ? query.createdBy[0] : query.createdBy,
3769
+ teamId: normalizeTeamId(Array.isArray(query.teamId) ? query.teamId[0] : query.teamId),
3770
+ priority: (Array.isArray(query.priority) ? query.priority[0] : query.priority),
3612
3771
  tags: tagFilter,
3613
3772
  includeTest,
3614
3773
  });
@@ -3616,7 +3775,8 @@ export async function createServer() {
3616
3775
  tasks = tasks.filter(task => task.updatedAt >= updatedSince);
3617
3776
  }
3618
3777
  // Text search filter
3619
- const searchQuery = (query.q || '').trim().toLowerCase();
3778
+ const qRaw = Array.isArray(query.q) ? query.q[0] : query.q;
3779
+ const searchQuery = (qRaw || '').trim().toLowerCase();
3620
3780
  if (searchQuery) {
3621
3781
  tasks = tasks.filter(task => (task.title || '').toLowerCase().includes(searchQuery) ||
3622
3782
  (task.description || '').toLowerCase().includes(searchQuery) ||
@@ -3624,7 +3784,7 @@ export async function createServer() {
3624
3784
  (task.id || '').toLowerCase().includes(searchQuery));
3625
3785
  }
3626
3786
  const total = tasks.length;
3627
- const offset = parsePositiveInt(query.offset) || 0;
3787
+ const offset = parsePositiveInt(Array.isArray(query.offset) ? query.offset[0] : query.offset) || 0;
3628
3788
  tasks = tasks.slice(offset, offset + limit);
3629
3789
  const hasMore = offset + tasks.length < total;
3630
3790
  const enriched = tasks.map(enrichTaskWithComments);
@@ -4827,7 +4987,13 @@ export async function createServer() {
4827
4987
  // fan out inbox-visible notifications to assignee/reviewer + explicit @mentions.
4828
4988
  // Notification routing respects per-agent preferences (quiet hours, mute, filters).
4829
4989
  const task = taskManager.getTask(resolved.resolvedId);
4830
- if (task && !comment.suppressed) {
4990
+ // Never fan out notifications for test-harness tasks.
4991
+ // Our repo contains a few "LIVE server" tests (BASE=127.0.0.1:4445) that create
4992
+ // tasks/comments with metadata.is_test=true. Without this guard, running `npm test`
4993
+ // on a machine with a live node will spam real chat channels and look like a human
4994
+ // (e.g. @link) posted the comment.
4995
+ const shouldFanOut = task ? !isTestHarnessTask(task) : false;
4996
+ if (task && !comment.suppressed && shouldFanOut) {
4831
4997
  const targets = new Set();
4832
4998
  if (task.assignee)
4833
4999
  targets.add(task.assignee);
@@ -4875,7 +5041,7 @@ export async function createServer() {
4875
5041
  }
4876
5042
  }
4877
5043
  presenceManager.recordActivity(data.author, 'message');
4878
- presenceManager.updatePresence(data.author, 'working');
5044
+ presenceManager.touchPresence(data.author);
4879
5045
  // Heartbeat discipline: compute gap since previous comment for doing tasks
4880
5046
  let heartbeatWarning;
4881
5047
  if (task && task.status === 'doing' && !comment.suppressed) {
@@ -4940,6 +5106,44 @@ export async function createServer() {
4940
5106
  return { success: false, error: err.message || 'Failed to capture outcome verdict' };
4941
5107
  }
4942
5108
  });
5109
+ // ── Cancel a task (convenience endpoint) ──
5110
+ app.post('/tasks/:id/cancel', async (request, reply) => {
5111
+ const task = taskManager.getTask(request.params.id);
5112
+ if (!task) {
5113
+ reply.code(404);
5114
+ return { success: false, error: 'Task not found' };
5115
+ }
5116
+ if (task.status === 'cancelled') {
5117
+ return { success: true, message: 'Task already cancelled', task: enrichTaskWithComments(task) };
5118
+ }
5119
+ if (task.status === 'done') {
5120
+ reply.code(400);
5121
+ return { success: false, error: 'Cannot cancel a done task. Use metadata.reopen=true first.' };
5122
+ }
5123
+ const body = (request.body || {});
5124
+ const reason = typeof body.reason === 'string' ? body.reason : undefined;
5125
+ const author = typeof body.author === 'string' ? body.author : 'system';
5126
+ if (!reason) {
5127
+ reply.code(400);
5128
+ return { success: false, error: 'Cancel reason required. Include { "reason": "..." } in request body.' };
5129
+ }
5130
+ try {
5131
+ const updated = await taskManager.updateTask(task.id, {
5132
+ status: 'cancelled',
5133
+ metadata: {
5134
+ ...(task.metadata || {}),
5135
+ cancel_reason: reason,
5136
+ cancelled_by: author,
5137
+ cancelled_at: new Date().toISOString(),
5138
+ },
5139
+ });
5140
+ return { success: true, task: updated ? enrichTaskWithComments(updated) : null };
5141
+ }
5142
+ catch (err) {
5143
+ reply.code(400);
5144
+ return { success: false, error: err.message || 'Failed to cancel task' };
5145
+ }
5146
+ });
4943
5147
  // Build normalized reviewer packet (PR + CI + artifacts)
4944
5148
  app.post('/tasks/:id/review-bundle', async (request, reply) => {
4945
5149
  const task = taskManager.getTask(request.params.id);
@@ -5249,7 +5453,12 @@ export async function createServer() {
5249
5453
  // Create task
5250
5454
  app.post('/tasks', async (request, reply) => {
5251
5455
  try {
5252
- const data = CreateTaskSchema.parse(request.body);
5456
+ // Normalize legacy "in-progress" → "doing" before schema validation
5457
+ const rawPostBody = request.body;
5458
+ if (rawPostBody && typeof rawPostBody === 'object' && rawPostBody.status === 'in-progress') {
5459
+ rawPostBody.status = 'doing';
5460
+ }
5461
+ const data = CreateTaskSchema.parse(rawPostBody);
5253
5462
  // Reject TEST: prefixed tasks in production to prevent CI pollution
5254
5463
  if (process.env.NODE_ENV === 'production' && typeof data.title === 'string' && data.title.startsWith('TEST:')) {
5255
5464
  reply.code(400);
@@ -5359,9 +5568,10 @@ export async function createServer() {
5359
5568
  ...(normalizedTeamId ? { teamId: normalizedTeamId } : {}),
5360
5569
  metadata: newMetadata,
5361
5570
  });
5362
- // Auto-update presence: creating tasks = working
5571
+ // Touch presence: creating tasks proves the agent is alive, but shouldn't
5572
+ // override task-derived status (e.g. agent filing a task while reviewing)
5363
5573
  if (data.createdBy) {
5364
- presenceManager.updatePresence(data.createdBy, 'working');
5574
+ presenceManager.touchPresence(data.createdBy);
5365
5575
  }
5366
5576
  // Fire-and-forget: index task for semantic search
5367
5577
  if (!data.title.startsWith('TEST:')) {
@@ -5398,7 +5608,7 @@ export async function createServer() {
5398
5608
  // Deduplication: check existing tasks for similar titles
5399
5609
  if (data.deduplicate) {
5400
5610
  const existingTasks = taskManager.listTasks({});
5401
- const activeTasks = existingTasks.filter(t => t.status !== 'done');
5611
+ const activeTasks = existingTasks.filter(t => t.status !== 'done' && t.status !== 'cancelled' && t.status !== 'resolved_externally');
5402
5612
  const normalizedNew = taskData.title.toLowerCase().trim();
5403
5613
  // Exact title match
5404
5614
  const exactMatch = activeTasks.find(t => t.title.toLowerCase().trim() === normalizedNew);
@@ -5856,7 +6066,13 @@ export async function createServer() {
5856
6066
  // Update task
5857
6067
  app.patch('/tasks/:id', async (request, reply) => {
5858
6068
  try {
5859
- const parsed = UpdateTaskSchema.parse(request.body);
6069
+ // Normalize legacy "in-progress" → "doing" before schema validation.
6070
+ // Some agents (and older MCP callers) use the deprecated status name.
6071
+ const rawBody = request.body;
6072
+ if (rawBody && typeof rawBody === 'object' && rawBody.status === 'in-progress') {
6073
+ rawBody.status = 'doing';
6074
+ }
6075
+ const parsed = UpdateTaskSchema.parse(rawBody);
5860
6076
  const lookup = taskManager.resolveTaskId(request.params.id);
5861
6077
  if (lookup.matchType === 'ambiguous') {
5862
6078
  reply.code(400);
@@ -5885,12 +6101,13 @@ export async function createServer() {
5885
6101
  // Must run before all other gates to give a clear rejection message.
5886
6102
  if (parsed.status && parsed.status !== existing.status) {
5887
6103
  const ALLOWED_TRANSITIONS = {
5888
- 'todo': ['doing'],
5889
- 'doing': ['blocked', 'validating'],
5890
- 'blocked': ['doing', 'todo'],
6104
+ 'todo': ['doing', 'cancelled'],
6105
+ 'doing': ['blocked', 'validating', 'cancelled'],
6106
+ 'blocked': ['doing', 'todo', 'cancelled'],
5891
6107
  'validating': ['done', 'doing'], // doing = reviewer rejection / rework
5892
6108
  'done': [], // all exits require reopen
5893
- 'in-progress': ['blocked', 'validating', 'done', 'doing', 'todo'], // legacy, permissive
6109
+ 'cancelled': [], // terminal state, like done requires reopen to revive
6110
+ 'in-progress': ['blocked', 'validating', 'done', 'doing', 'todo', 'cancelled'], // legacy, permissive
5894
6111
  };
5895
6112
  const allowed = ALLOWED_TRANSITIONS[existing.status] ?? [];
5896
6113
  if (!allowed.includes(parsed.status)) {
@@ -5915,6 +6132,24 @@ export async function createServer() {
5915
6132
  mergedMeta.reopened_from = existing.status;
5916
6133
  }
5917
6134
  }
6135
+ // ── Cancel reason gate: require cancel_reason when transitioning to cancelled ──
6136
+ if (parsed.status === 'cancelled') {
6137
+ const meta = (incomingMeta ?? {});
6138
+ const cancelReason = typeof meta.cancel_reason === 'string' ? String(meta.cancel_reason).trim() : '';
6139
+ if (!cancelReason) {
6140
+ reply.code(422);
6141
+ return {
6142
+ success: false,
6143
+ error: 'Cancellation requires a cancel_reason in metadata (e.g. "duplicate", "out of scope", "won\'t fix").',
6144
+ code: 'CANCEL_REASON_REQUIRED',
6145
+ gate: 'cancel_reason',
6146
+ hint: 'Include metadata.cancel_reason explaining why this task is being cancelled.',
6147
+ };
6148
+ }
6149
+ mergedMeta.cancel_reason = cancelReason;
6150
+ mergedMeta.cancelled_at = Date.now();
6151
+ mergedMeta.cancelled_from = existing.status;
6152
+ }
5918
6153
  // Reviewer-identity gate: only assigned reviewer can set reviewer_approved=true.
5919
6154
  const incomingReviewerApproved = incomingMeta.reviewer_approved;
5920
6155
  if (incomingReviewerApproved === true) {
@@ -6000,6 +6235,29 @@ export async function createServer() {
6000
6235
  hint: duplicateGate.hint,
6001
6236
  };
6002
6237
  }
6238
+ // Early format validation: catch bad PR URLs and commit SHAs on any update, not just at validating transition
6239
+ const earlyReviewPacket = mergedMeta?.qa_bundle?.review_packet;
6240
+ const earlyHandoff = mergedMeta?.review_handoff;
6241
+ const earlyPrUrl = (earlyReviewPacket?.pr_url ?? earlyHandoff?.pr_url);
6242
+ const earlyCommit = (earlyReviewPacket?.commit ?? earlyHandoff?.commit_sha);
6243
+ if (earlyPrUrl && typeof earlyPrUrl === 'string' && !/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+$/.test(earlyPrUrl)) {
6244
+ reply.code(400);
6245
+ return {
6246
+ success: false,
6247
+ error: `Invalid PR URL format: "${earlyPrUrl}"`,
6248
+ gate: 'format_validation',
6249
+ hint: 'Expected format: https://github.com/owner/repo/pull/123',
6250
+ };
6251
+ }
6252
+ if (earlyCommit && typeof earlyCommit === 'string' && earlyCommit.length > 0 && !/^[a-f0-9]{7,40}$/i.test(earlyCommit)) {
6253
+ reply.code(400);
6254
+ return {
6255
+ success: false,
6256
+ error: `Invalid commit SHA format: "${earlyCommit}"`,
6257
+ gate: 'format_validation',
6258
+ hint: 'Expected 7-40 hex characters, e.g. "a1b2c3d"',
6259
+ };
6260
+ }
6003
6261
  // QA bundle gate: validating requires structured review evidence.
6004
6262
  const effectiveStatus = parsed.status ?? existing.status;
6005
6263
  const qaGate = enforceQaBundleGateForValidating(parsed.status, mergedMeta, existing.id);
@@ -6335,6 +6593,25 @@ export async function createServer() {
6335
6593
  presenceManager.updatePresence(task.assignee, 'reviewing');
6336
6594
  }
6337
6595
  }
6596
+ // ── Reviewer notification: @mention reviewer when task enters validating ──
6597
+ if (parsed.status === 'validating' && existing.status !== 'validating' && existing.reviewer) {
6598
+ const taskMeta = task.metadata;
6599
+ const prUrl = taskMeta?.review_handoff?.pr_url
6600
+ ?? taskMeta?.qa_bundle?.pr_url
6601
+ ?? '';
6602
+ const prLine = prUrl ? `\nPR: ${prUrl}` : '';
6603
+ chatManager.sendMessage({
6604
+ from: 'system',
6605
+ content: `@${existing.reviewer} [reviewRequested:${task.id}] ${task.title} → validating${prLine}`,
6606
+ channel: 'task-notifications',
6607
+ metadata: {
6608
+ kind: 'review_requested',
6609
+ taskId: task.id,
6610
+ reviewer: existing.reviewer,
6611
+ prUrl: prUrl || undefined,
6612
+ },
6613
+ }).catch(() => { }); // Non-blocking
6614
+ }
6338
6615
  // ── Activation funnel: track first_task_started / first_task_completed ──
6339
6616
  {
6340
6617
  const funnelUserId = task.metadata?.userId || task.assignee || '';
@@ -6426,6 +6703,24 @@ export async function createServer() {
6426
6703
  }
6427
6704
  if (parsed.status === 'validating' && task.reviewer) {
6428
6705
  statusNotifTargets.push({ agent: task.reviewer, type: 'reviewRequested' });
6706
+ // ── Explicit reviewer routing: ping reviewer with PR link + ask ──
6707
+ const prUrl = task.metadata?.pr_url
6708
+ || task.metadata?.qa_bundle?.pr_url
6709
+ || task.metadata?.review_handoff?.pr_url;
6710
+ const prLink = typeof prUrl === 'string' && prUrl ? ` — ${prUrl}` : '';
6711
+ const reviewMsg = `@${task.reviewer} review requested: **${task.title}** (${task.id})${prLink}. Please approve or flag issues.`;
6712
+ chatManager.sendMessage({
6713
+ from: 'system',
6714
+ content: reviewMsg,
6715
+ channel: 'reviews',
6716
+ metadata: {
6717
+ kind: 'review_routing',
6718
+ taskId: task.id,
6719
+ reviewer: task.reviewer,
6720
+ assignee: task.assignee,
6721
+ prUrl: prUrl || null,
6722
+ },
6723
+ }).catch(() => { }); // Non-blocking
6429
6724
  }
6430
6725
  if (parsed.status === 'done') {
6431
6726
  if (task.assignee)
@@ -6433,7 +6728,21 @@ export async function createServer() {
6433
6728
  if (task.reviewer)
6434
6729
  statusNotifTargets.push({ agent: task.reviewer, type: 'taskCompleted' });
6435
6730
  }
6731
+ // Dedupe guard: prevent stale/out-of-order notification events
6732
+ const { shouldEmitNotification } = await import('./notificationDedupeGuard.js');
6436
6733
  for (const target of statusNotifTargets) {
6734
+ // Check dedupe guard before emitting
6735
+ const dedupeCheck = shouldEmitNotification({
6736
+ taskId: task.id,
6737
+ eventUpdatedAt: task.updatedAt,
6738
+ eventStatus: parsed.status,
6739
+ currentTaskStatus: task.status,
6740
+ currentTaskUpdatedAt: task.updatedAt,
6741
+ });
6742
+ if (!dedupeCheck.emit) {
6743
+ console.log(`[NotifDedupe] Suppressed: ${dedupeCheck.reason}`);
6744
+ continue;
6745
+ }
6437
6746
  const routing = notifMgr.shouldNotify({
6438
6747
  type: target.type,
6439
6748
  agent: target.agent,
@@ -6450,6 +6759,7 @@ export async function createServer() {
6450
6759
  kind: target.type,
6451
6760
  taskId: task.id,
6452
6761
  status: parsed.status,
6762
+ updatedAt: task.updatedAt,
6453
6763
  deliveryMethod: routing.deliveryMethod,
6454
6764
  },
6455
6765
  }).catch(() => { }); // Non-blocking
@@ -6734,6 +7044,40 @@ export async function createServer() {
6734
7044
  }
6735
7045
  return { success: true, agent, displayName };
6736
7046
  });
7047
+ // ── Write TEAM-ROLES.yaml (used by bootstrap agent to configure the team) ──
7048
+ app.put('/config/team-roles', async (request, reply) => {
7049
+ const body = request.body;
7050
+ const yaml = typeof body.yaml === 'string' ? body.yaml.trim() : '';
7051
+ if (!yaml) {
7052
+ reply.code(400);
7053
+ return { success: false, error: 'yaml field is required (TEAM-ROLES.yaml content)' };
7054
+ }
7055
+ // Basic validation: must contain 'agents:' and at least one agent name
7056
+ if (!yaml.includes('agents:')) {
7057
+ reply.code(400);
7058
+ return { success: false, error: 'Invalid TEAM-ROLES.yaml: must contain "agents:" section' };
7059
+ }
7060
+ try {
7061
+ const { writeFileSync } = await import('node:fs');
7062
+ const { join } = await import('node:path');
7063
+ const filePath = join(REFLECTT_HOME, 'TEAM-ROLES.yaml');
7064
+ writeFileSync(filePath, yaml, 'utf-8');
7065
+ // Hot-reload the team config
7066
+ const { loadAgentRoles } = await import('./assignment.js');
7067
+ const reloaded = loadAgentRoles();
7068
+ return {
7069
+ success: true,
7070
+ path: filePath,
7071
+ agents: reloaded.roles.length,
7072
+ hint: 'TEAM-ROLES.yaml saved and hot-reloaded. Agents will pick up new routing immediately.',
7073
+ };
7074
+ }
7075
+ catch (err) {
7076
+ const msg = err instanceof Error ? err.message : 'Failed to write TEAM-ROLES.yaml';
7077
+ reply.code(500);
7078
+ return { success: false, error: msg };
7079
+ }
7080
+ });
6737
7081
  // Resolve a mention string (name, displayName, or alias) to an agent ID
6738
7082
  app.get('/resolve/mention/:mention', async (request) => {
6739
7083
  const agentName = resolveAgentMention(request.params.mention);
@@ -7537,8 +7881,41 @@ export async function createServer() {
7537
7881
  reply.code(201);
7538
7882
  return { success: true, insight, cluster_key: extractClusterKey(reflection) };
7539
7883
  });
7884
+ // ── Activity Timeline ──────────────────────────────────────────────────
7885
+ app.get('/activity', async (request) => {
7886
+ const query = request.query;
7887
+ const range = query.range === '7d' ? '7d' : '24h';
7888
+ const type = query.type ? query.type.split(',').map(t => t.trim()).filter(Boolean) : undefined;
7889
+ const agent = query.agent || undefined;
7890
+ const limit = query.limit ? Number(query.limit) : undefined;
7891
+ const after = query.after || undefined;
7892
+ // debug=1 only allowed from localhost
7893
+ const isLocalhost = request.ip === '127.0.0.1' || request.ip === '::1' || request.ip === '::ffff:127.0.0.1';
7894
+ const debug = query.debug === '1' && isLocalhost;
7895
+ try {
7896
+ return queryActivity({ range, type, agent, limit, after, debug });
7897
+ }
7898
+ catch (err) {
7899
+ request.log.error({ err }, 'Activity query failed');
7900
+ throw err;
7901
+ }
7902
+ });
7903
+ app.get('/activity/sources', async () => {
7904
+ return {
7905
+ sources: [...ACTIVITY_SOURCES],
7906
+ description: 'Allowed values for partial.missing[] and type filter',
7907
+ };
7908
+ });
7540
7909
  app.get('/insights', async (request) => {
7541
7910
  const query = request.query;
7911
+ // Hygiene: when listing candidate insights, proactively cool down any
7912
+ // candidates whose promoted task is already done/cancelled so they don't resurface.
7913
+ // Keep listInsights() itself pure (no DB writes on read).
7914
+ if (query.status === 'candidate') {
7915
+ const offset = query.offset ? Number(query.offset) || 0 : 0;
7916
+ if (offset === 0)
7917
+ sweepShippedCandidates();
7918
+ }
7542
7919
  const result = listInsights({
7543
7920
  status: query.status,
7544
7921
  priority: query.priority,
@@ -7657,6 +8034,104 @@ export async function createServer() {
7657
8034
  }
7658
8035
  return { success: true, insight: result.insight };
7659
8036
  });
8037
+ // Narrow localhost-only admin endpoints for routine hygiene: cooldown/close.
8038
+ // These avoid enabling the broader PATCH /insights/:id mutation API.
8039
+ app.post('/insights/:id/cooldown', async (request, reply) => {
8040
+ const ip = String(request.ip || '');
8041
+ const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
8042
+ if (!isLoopback) {
8043
+ reply.code(403);
8044
+ return {
8045
+ success: false,
8046
+ error: 'Forbidden: localhost-only endpoint',
8047
+ hint: `Request ip (${ip || 'unknown'}) is not loopback`,
8048
+ };
8049
+ }
8050
+ const requiredToken = process.env.REFLECTT_INSIGHT_MUTATION_TOKEN;
8051
+ if (requiredToken) {
8052
+ const raw = request.headers['x-reflectt-admin-token'];
8053
+ let provided = Array.isArray(raw) ? raw[0] : raw;
8054
+ const auth = request.headers.authorization;
8055
+ if ((!provided || typeof provided !== 'string') && typeof auth === 'string' && auth.startsWith('Bearer ')) {
8056
+ provided = auth.slice('Bearer '.length);
8057
+ }
8058
+ if (typeof provided !== 'string' || provided !== requiredToken) {
8059
+ reply.code(403);
8060
+ return {
8061
+ success: false,
8062
+ error: 'Forbidden: missing/invalid admin token',
8063
+ hint: 'Provide x-reflectt-admin-token header (or Authorization: Bearer ...) matching REFLECTT_INSIGHT_MUTATION_TOKEN.'
8064
+ };
8065
+ }
8066
+ }
8067
+ const body = (request.body ?? {});
8068
+ const actor = typeof body.actor === 'string' ? body.actor : '';
8069
+ const reason = typeof body.reason === 'string' ? body.reason : '';
8070
+ const notes = typeof body.notes === 'string' ? body.notes : undefined;
8071
+ const cooldown_reason = typeof body.cooldown_reason === 'string' ? body.cooldown_reason : undefined;
8072
+ const cooldown_until = typeof body.cooldown_until === 'number' && Number.isFinite(body.cooldown_until)
8073
+ ? body.cooldown_until
8074
+ : (typeof body.cooldown_ms === 'number' && Number.isFinite(body.cooldown_ms)
8075
+ ? Date.now() + Math.max(0, body.cooldown_ms)
8076
+ : undefined);
8077
+ const result = cooldownInsightById(request.params.id, {
8078
+ actor,
8079
+ reason,
8080
+ ...(notes ? { notes } : {}),
8081
+ ...(cooldown_until ? { cooldown_until } : {}),
8082
+ ...(cooldown_reason ? { cooldown_reason } : {}),
8083
+ });
8084
+ if (!result.success) {
8085
+ const notFound = result.error === 'Insight not found';
8086
+ reply.code(notFound ? 404 : 400);
8087
+ return { success: false, error: result.error };
8088
+ }
8089
+ return { success: true, insight: result.insight };
8090
+ });
8091
+ app.post('/insights/:id/close', async (request, reply) => {
8092
+ const ip = String(request.ip || '');
8093
+ const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
8094
+ if (!isLoopback) {
8095
+ reply.code(403);
8096
+ return {
8097
+ success: false,
8098
+ error: 'Forbidden: localhost-only endpoint',
8099
+ hint: `Request ip (${ip || 'unknown'}) is not loopback`,
8100
+ };
8101
+ }
8102
+ const requiredToken = process.env.REFLECTT_INSIGHT_MUTATION_TOKEN;
8103
+ if (requiredToken) {
8104
+ const raw = request.headers['x-reflectt-admin-token'];
8105
+ let provided = Array.isArray(raw) ? raw[0] : raw;
8106
+ const auth = request.headers.authorization;
8107
+ if ((!provided || typeof provided !== 'string') && typeof auth === 'string' && auth.startsWith('Bearer ')) {
8108
+ provided = auth.slice('Bearer '.length);
8109
+ }
8110
+ if (typeof provided !== 'string' || provided !== requiredToken) {
8111
+ reply.code(403);
8112
+ return {
8113
+ success: false,
8114
+ error: 'Forbidden: missing/invalid admin token',
8115
+ hint: 'Provide x-reflectt-admin-token header (or Authorization: Bearer ...) matching REFLECTT_INSIGHT_MUTATION_TOKEN.'
8116
+ };
8117
+ }
8118
+ }
8119
+ const body = (request.body ?? {});
8120
+ const actor = typeof body.actor === 'string' ? body.actor : '';
8121
+ const reason = typeof body.reason === 'string' ? body.reason : '';
8122
+ const notes = typeof body.notes === 'string' ? body.notes : undefined;
8123
+ const result = closeInsightById(request.params.id, {
8124
+ actor,
8125
+ reason,
8126
+ ...(notes ? { notes } : {}),
8127
+ });
8128
+ if (!result.success) {
8129
+ const notFound = result.error === 'Insight not found';
8130
+ reply.code(notFound ? 404 : 400);
8131
+ return { success: false, error: result.error };
8132
+ }
8133
+ return { success: true, insight: result.insight };
8134
+ });
7660
8135
  app.get('/insights/stats', async () => {
7661
8136
  return insightStats();
7662
8137
  });
@@ -8274,7 +8749,41 @@ export async function createServer() {
8274
8749
  }
8275
8750
  const task = taskManager.getNextTask(agent, { includeTest });
8276
8751
  if (!task) {
8277
- return { task: null, message: 'No available tasks' };
8752
+ const aliases = agent ? getAgentAliases(agent) : [];
8753
+ // "Ready" counts: match /tasks/next selection semantics (blocked excluded)
8754
+ const readyTodo = taskManager.listTasks({ status: 'todo', includeBlocked: false, includeTest });
8755
+ const ready_todo_unassigned = readyTodo.filter(t => {
8756
+ const a = String(t.assignee || '').trim().toLowerCase();
8757
+ return a.length === 0 || a === 'unassigned';
8758
+ }).length;
8759
+ const ready_todo_assigned = agent
8760
+ ? taskManager.listTasks({ status: 'todo', assigneeIn: aliases, includeBlocked: false, includeTest }).length
8761
+ : 0;
8762
+ const ready_doing_assigned = agent
8763
+ ? taskManager.listTasks({ status: 'doing', assigneeIn: aliases, includeBlocked: false, includeTest }).length
8764
+ : 0;
8765
+ const ready_validating_assigned = agent
8766
+ ? taskManager.listTasks({ status: 'validating', assigneeIn: aliases, includeBlocked: false, includeTest }).length
8767
+ : 0;
8768
+ const { formatTasksNextEmptyResponse } = await import('./tasks-next-diagnostics.js');
8769
+ const payload = formatTasksNextEmptyResponse({
8770
+ agent,
8771
+ ready_doing_assigned,
8772
+ ready_todo_unassigned,
8773
+ ready_todo_assigned,
8774
+ ready_validating_assigned,
8775
+ });
8776
+ return { task: null, ...payload };
8777
+ }
8778
+ // Rule C: auto-claim (todo→doing) when ?claim=1
8779
+ const shouldClaim = query.claim === '1' || query.claim === 'true';
8780
+ if (shouldClaim && agent && task.status === 'todo') {
8781
+ const { claimTask } = await import('./todoHoardingGuard.js');
8782
+ const claimed = await claimTask(task.id, agent);
8783
+ if (claimed) {
8784
+ const enriched = enrichTaskWithComments(claimed);
8785
+ return { task: isCompact(query) ? compactTask(enriched) : enriched, claimed: true };
8786
+ }
8278
8787
  }
8279
8788
  const enriched = enrichTaskWithComments(task);
8280
8789
  return { task: isCompact(query) ? compactTask(enriched) : enriched };
@@ -8367,7 +8876,11 @@ export async function createServer() {
8367
8876
  }));
8368
8877
  const todoTasks = taskManager.listTasks({ status: 'todo', assigneeIn: getAgentAliases(agent) });
8369
8878
  const validatingTasks = taskManager.listTasks({ status: 'validating', assigneeIn: getAgentAliases(agent) });
8370
- const slim = (t) => t ? { id: t.id, title: t.title, status: t.status, priority: t.priority } : null;
8879
+ const slim = (t) => t ? {
8880
+ id: t.id, title: t.title, status: t.status, priority: t.priority,
8881
+ ...(t.dueAt ? { dueAt: t.dueAt } : {}),
8882
+ ...(t.scheduledFor ? { scheduledFor: t.scheduledFor } : {}),
8883
+ } : null;
8371
8884
  presenceManager.recordActivity(agent, 'heartbeat');
8372
8885
  // Check pause status
8373
8886
  const pauseStatus = checkPauseStatus(agent);
@@ -8375,12 +8888,18 @@ export async function createServer() {
8375
8888
  const { getIntensity, checkPullBudget } = await import('./intensity.js');
8376
8889
  const intensity = getIntensity();
8377
8890
  const pullBudget = checkPullBudget(agent);
8891
+ // Drop stats for this agent
8892
+ const allDrops = chatManager.getDropStats();
8893
+ const agentDrops = allDrops[agent];
8894
+ const focusSummary = getFocusSummary();
8378
8895
  return {
8379
8896
  agent, ts: Date.now(),
8380
8897
  active: slim(activeTask), next: pauseStatus.paused ? null : slim(nextTask),
8381
8898
  inbox: slimInbox, inboxCount: inbox.length,
8382
8899
  queue: { todo: todoTasks.length, doing: doingTasks.length, validating: validatingTasks.length },
8383
8900
  intensity: { preset: intensity.preset, pullsRemaining: pullBudget.remaining, wipLimit: intensity.limits.wipLimit },
8901
+ ...(focusSummary ? { focus: focusSummary } : {}),
8902
+ ...(agentDrops ? { drops: { total: agentDrops.total, rolling_1h: agentDrops.rolling_1h } } : {}),
8384
8903
  ...(pauseStatus.paused ? { paused: true, pauseMessage: pauseStatus.message, resumesAt: pauseStatus.entry?.pausedUntil ?? null } : {}),
8385
8904
  action: pauseStatus.paused ? `PAUSED: ${pauseStatus.message}`
8386
8905
  : activeTask ? `Continue ${activeTask.id}`
@@ -8413,12 +8932,26 @@ export async function createServer() {
8413
8932
  3. If inbox has messages, respond to direct mentions.
8414
8933
  4. **Never report task status from memory alone** — always query the API first.
8415
8934
 
8935
+ ## No-Task Idle Loop (recommended)
8936
+ If your heartbeat shows **no active task** and **no next task**:
8937
+ 1. Post a brief status in team chat **once per hour max**: "[no task] checking board + signals".
8938
+ 2. Check the board + top signals:
8939
+ - \`curl -s "${baseUrl}/tasks?status=todo&limit=5&compact=true"\`
8940
+ - \`curl -s "${baseUrl}/loop/summary?compact=true"\`
8941
+ 3. If there’s a clear next task for your lane, claim it and start work. If a signal/insight is actionable, create/claim a task and start work.
8942
+ 4. If the board + signals are empty, write up what you checked and propose a next step in a problems/ideas channel if your team has one (otherwise use \`#general\`).
8943
+ 5. If you’re still idle after checking, propose the next highest-leverage work item with evidence — don’t wait for someone else to assign it.
8944
+
8416
8945
  ## Comms Protocol (required)
8417
- 1. **Status updates belong in task comments first** (\`POST /tasks/:id/comments\`).
8418
- 2. **Shipped artifacts go to \`#shipping\`** and must include \`@reviewer\` + task ID + PR/artifact link.
8419
- 3. **Review requests go to \`#reviews\`** and must include \`@reviewer\` + task ID + exact ask.
8420
- 4. **Blockers go to \`#blockers\`** and must include \`@kai\` + task ID + concrete unblock needed.
8421
- 5. **\`#general\` is decisions/cross-team coordination only** (not routine heartbeat chatter).
8946
+ **Rule: task updates go to the task, not to chat.**
8947
+ - \`POST /tasks/:id/comments\` for all progress, blockers, and decisions on a task.
8948
+ - Chat channels are for coordination, not status reports. Do not post "working on task-xyz" or "done with task-xyz" to chat.
8949
+
8950
+ 1. **Task progress, blockers, decisions** \`POST /tasks/:id/comments\` (always first)
8951
+ 2. **Shipped artifacts** → post in shipping channel after the task comment, include \`@reviewer\` + task ID + PR/artifact link
8952
+ 3. **Review requests** → post in reviews channel after the task comment, include \`@reviewer\` + task ID + exact ask
8953
+ 4. **Blockers needing human action** → post in blockers channel after the task comment, include **who you need** + task ID + concrete unblock needed
8954
+ 5. \`#general\` is for decisions and cross-team coordination only — not task status updates
8422
8955
 
8423
8956
  ## API Quick Reference
8424
8957
  - Heartbeat check: \`GET /heartbeat/${agent}\`
@@ -8517,15 +9050,30 @@ export async function createServer() {
8517
9050
  { method: 'GET', path: '/reflections', hint: 'List. Query: author, limit' },
8518
9051
  ],
8519
9052
  },
9053
+ activity: {
9054
+ description: 'Unified activity timeline',
9055
+ endpoints: [
9056
+ { method: 'GET', path: '/activity', hint: 'Timeline feed. Query: range (24h|7d), type, agent, limit, after (cursor)' },
9057
+ ],
9058
+ },
8520
9059
  system: {
8521
9060
  description: 'System health and discovery',
8522
9061
  endpoints: [
8523
9062
  { method: 'GET', path: '/health', hint: 'System health + version + stats' },
9063
+ { method: 'GET', path: '/pulse', compact: true, hint: 'Team pulse snapshot: deploy + board + per-agent doing + reviews. Query: compact' },
8524
9064
  { method: 'GET', path: '/capabilities', hint: 'This endpoint. Query: category to filter' },
8525
9065
  { method: 'GET', path: '/me/:agent', compact: true, hint: 'Full dashboard. Use /heartbeat/:agent for polls.' },
8526
9066
  { method: 'GET', path: '/docs', hint: 'Full API reference (68K chars). Use /capabilities instead when possible.' },
8527
9067
  ],
8528
9068
  },
9069
+ hosts: {
9070
+ description: 'Multi-host registry and cloud connection',
9071
+ endpoints: [
9072
+ { method: 'GET', path: '/hosts', compact: true, hint: 'List registered hosts. Query: compact' },
9073
+ { method: 'GET', path: '/cloud/status', hint: 'Cloud connection state + health summary' },
9074
+ { method: 'GET', path: '/cloud/events', hint: 'Connection lifecycle event log. Query: limit' },
9075
+ ],
9076
+ },
8529
9077
  manage: {
8530
9078
  description: 'Remote node management (auth-gated)',
8531
9079
  endpoints: [
@@ -8962,21 +9510,71 @@ export async function createServer() {
8962
9510
  return { success: false, error: err.message };
8963
9511
  }
8964
9512
  });
9513
+ // ── Team Pulse ─────────────────────────────────────────────────────
9514
+ app.get('/pulse', async (request) => {
9515
+ const compact = request.query.compact === 'true' || request.query.compact === '1';
9516
+ if (compact) {
9517
+ return generateCompactPulse();
9518
+ }
9519
+ return generatePulse();
9520
+ });
9521
+ // ── Scope Overlap Scanner ──────────────────────────────────────────
9522
+ // POST /scope-overlap — trigger scope overlap scan after a PR merge
9523
+ app.post('/scope-overlap', async (request) => {
9524
+ const { prNumber, prTitle, prBranch, mergedTaskId, repo, mergeCommit, notify } = request.body || {};
9525
+ if (!prNumber || !prTitle || !prBranch) {
9526
+ return { success: false, error: 'Required: prNumber, prTitle, prBranch' };
9527
+ }
9528
+ if (notify !== false) {
9529
+ const result = await scanAndNotify(prNumber, prTitle, prBranch, mergedTaskId, repo, mergeCommit);
9530
+ return { success: true, ...result };
9531
+ }
9532
+ const result = scanScopeOverlap(prNumber, prTitle, prBranch, mergedTaskId, repo);
9533
+ return { success: true, ...result };
9534
+ });
9535
+ // ── Team Focus ─────────────────────────────────────────────────────
9536
+ // GET /focus — current team focus directive
9537
+ app.get('/focus', async () => {
9538
+ const focus = getFocus();
9539
+ return focus ? { focus } : { focus: null, message: 'No focus set. Use POST /focus to set one.' };
9540
+ });
9541
+ // POST /focus — set team focus directive
9542
+ app.post('/focus', async (request) => {
9543
+ const { directive, setBy, expiresAt, tags } = request.body || {};
9544
+ if (!directive || !setBy) {
9545
+ return { success: false, error: 'Required: directive, setBy' };
9546
+ }
9547
+ const focus = setFocus(directive, setBy, { expiresAt, tags });
9548
+ return { success: true, focus };
9549
+ });
9550
+ // DELETE /focus — clear team focus
9551
+ app.delete('/focus', async () => {
9552
+ clearFocus();
9553
+ return { success: true, message: 'Focus cleared' };
9554
+ });
8965
9555
  // Get all agent presences
8966
9556
  app.get('/presence', async () => {
8967
9557
  const explicitPresences = presenceManager.getAllPresence();
8968
9558
  const allActivity = presenceManager.getAllActivity();
8969
- // Build map of explicit presence by agent
8970
- const presenceMap = new Map(explicitPresences.map(p => [p.agent, p]));
8971
- // Add inferred presence for agents with only activity
9559
+ // Filter to agents known to this node's TEAM-ROLES registry
9560
+ const knownAgentNames = new Set(getAgentRoles().map(r => r.name.toLowerCase()));
9561
+ // Build map of explicit presence by agent (filtered to registry)
9562
+ const presenceMap = new Map(explicitPresences
9563
+ .filter(p => knownAgentNames.size === 0 || knownAgentNames.has(p.agent.toLowerCase()))
9564
+ .map(p => [p.agent, p]));
9565
+ // Add inferred presence for agents with only activity (registry-gated)
8972
9566
  const now = Date.now();
8973
9567
  for (const activity of allActivity) {
8974
- if (!presenceMap.has(activity.agent) && activity.last_active) {
9568
+ if (!presenceMap.has(activity.agent) && activity.last_active
9569
+ && (knownAgentNames.size === 0 || knownAgentNames.has(activity.agent.toLowerCase()))) {
8975
9570
  const inactiveMs = now - activity.last_active;
8976
9571
  let status = 'offline';
8977
- if (inactiveMs < 10 * 60 * 1000) { // Active in last 10 minutes
9572
+ if (inactiveMs < 15 * 60 * 1000) { // Active in last 15 minutes — match presence.ts IDLE_THRESHOLD_MS
8978
9573
  status = activity.tasks_completed_today > 0 ? 'working' : 'idle';
8979
9574
  }
9575
+ else if (inactiveMs < 30 * 60 * 1000) { // 15-30 min — idle grace period before offline
9576
+ status = 'idle';
9577
+ }
8980
9578
  presenceMap.set(activity.agent, {
8981
9579
  agent: activity.agent,
8982
9580
  status,
@@ -9022,11 +9620,14 @@ export async function createServer() {
9022
9620
  if (activity && activity.last_active) {
9023
9621
  const now = Date.now();
9024
9622
  const inactiveMs = now - activity.last_active;
9025
- // Infer status based on recent activity
9623
+ // Infer status based on recent activity — match presence.ts thresholds
9026
9624
  let status = 'offline';
9027
- if (inactiveMs < 10 * 60 * 1000) { // Active in last 10 minutes
9625
+ if (inactiveMs < 15 * 60 * 1000) { // Active in last 15 minutes
9028
9626
  status = activity.tasks_completed_today > 0 ? 'working' : 'idle';
9029
9627
  }
9628
+ else if (inactiveMs < 30 * 60 * 1000) { // 15-30 min idle grace
9629
+ status = 'idle';
9630
+ }
9030
9631
  presence = {
9031
9632
  agent: request.params.agent,
9032
9633
  status,
@@ -9130,21 +9731,7 @@ export async function createServer() {
9130
9731
  }
9131
9732
  });
9132
9733
  // ============ ACTIVITY FEED ENDPOINT ============
9133
- // Get recent activity across all systems
9134
- app.get('/activity', async (request, reply) => {
9135
- const query = request.query;
9136
- const events = eventBus.getEvents({
9137
- agent: query.agent,
9138
- limit: boundedLimit(query.limit, DEFAULT_LIMITS.activity, MAX_LIMITS.activity),
9139
- since: parseEpochMs(query.since),
9140
- });
9141
- const payload = { events, count: events.length };
9142
- const lastModified = events.length > 0 ? Math.max(...events.map(e => e.timestamp || 0)) : undefined;
9143
- if (applyConditionalCaching(request, reply, payload, lastModified)) {
9144
- return;
9145
- }
9146
- return payload;
9147
- });
9734
+ // Legacy activity endpoint replaced by unified /activity timeline (see above)
9148
9735
  // ============ SECRET VAULT ENDPOINTS ============
9149
9736
  // List secrets (metadata only — no plaintext)
9150
9737
  app.get('/secrets', async () => {
@@ -9347,15 +9934,17 @@ export async function createServer() {
9347
9934
  * GET /activation/funnel — per-user funnel state + aggregate summary.
9348
9935
  * Query params:
9349
9936
  * ?userId=xxx — get single user's funnel state
9350
- * (no params) get aggregate summary across all users
9937
+ * ?raw=true include internal/infrastructure users (for debugging)
9938
+ * (no params) — get aggregate summary across all users (clean, external only)
9351
9939
  */
9352
9940
  app.get('/activation/funnel', async (request) => {
9353
9941
  const query = request.query;
9354
9942
  const userId = query.userId;
9943
+ const raw = query.raw === 'true';
9355
9944
  if (userId) {
9356
9945
  return { funnel: getUserFunnelState(userId) };
9357
9946
  }
9358
- return { funnel: getFunnelSummary() };
9947
+ return { funnel: getFunnelSummary({ raw }) };
9359
9948
  });
9360
9949
  /**
9361
9950
  * POST /activation/event — manually emit an activation event.
@@ -9392,14 +9981,17 @@ export async function createServer() {
9392
9981
  app.get('/activation/dashboard', async (request) => {
9393
9982
  const query = request.query;
9394
9983
  const weeks = query.weeks ? parseInt(query.weeks, 10) : 12;
9395
- return { success: true, dashboard: getOnboardingDashboard({ weeks }) };
9984
+ const raw = query.raw === 'true';
9985
+ return { success: true, dashboard: getOnboardingDashboard({ weeks, raw }) };
9396
9986
  });
9397
9987
  /**
9398
9988
  * GET /activation/funnel/conversions — Step-by-step conversion rates.
9399
9989
  * Returns per-step reach count, conversion rate, and median step time.
9400
9990
  */
9401
- app.get('/activation/funnel/conversions', async () => {
9402
- return { success: true, conversions: getConversionFunnel() };
9991
+ app.get('/activation/funnel/conversions', async (request) => {
9992
+ const query = request.query;
9993
+ const raw = query.raw === 'true';
9994
+ return { success: true, conversions: getConversionFunnel({ raw }) };
9403
9995
  });
9404
9996
  /**
9405
9997
  * GET /activation/funnel/failures — Failure-reason distribution per step.
@@ -9645,10 +10237,10 @@ export async function createServer() {
9645
10237
  };
9646
10238
  }
9647
10239
  const publication = await contentManager.logPublication(body);
9648
- // Update presence: publishing content = working
10240
+ // Touch presence: publishing content proves agent is alive
9649
10241
  if (body.publishedBy) {
9650
10242
  presenceManager.recordActivity(body.publishedBy, 'message');
9651
- presenceManager.updatePresence(body.publishedBy, 'working');
10243
+ presenceManager.touchPresence(body.publishedBy);
9652
10244
  }
9653
10245
  return { success: true, publication };
9654
10246
  }
@@ -9692,9 +10284,9 @@ export async function createServer() {
9692
10284
  };
9693
10285
  }
9694
10286
  const item = await contentManager.upsertCalendarItem(body);
9695
- // Update presence when adding content to calendar
10287
+ // Touch presence when adding content to calendar
9696
10288
  if (body.createdBy) {
9697
- presenceManager.updatePresence(body.createdBy, 'working');
10289
+ presenceManager.touchPresence(body.createdBy);
9698
10290
  }
9699
10291
  return { success: true, item };
9700
10292
  }
@@ -9819,8 +10411,17 @@ export async function createServer() {
9819
10411
  });
9820
10412
  // ============ CLOUD INTEGRATION (see docs/CLOUD_ENDPOINTS.md) ============
9821
10413
  app.get('/cloud/status', async () => {
9822
- const { getCloudStatus } = await import('./cloud.js');
9823
- return getCloudStatus();
10414
+ const { getCloudStatus, getConnectionHealth, getConnectionEvents } = await import('./cloud.js');
10415
+ return {
10416
+ ...getCloudStatus(),
10417
+ connectionHealth: getConnectionHealth(),
10418
+ };
10419
+ });
10420
+ app.get('/cloud/events', async (request) => {
10421
+ const { getConnectionEvents } = await import('./cloud.js');
10422
+ const url = new URL(request.url, 'http://localhost');
10423
+ const limit = Math.min(Number(url.searchParams.get('limit')) || 50, 100);
10424
+ return { events: getConnectionEvents(limit) };
9824
10425
  });
9825
10426
  app.post('/cloud/reload', async () => {
9826
10427
  const { stopCloudIntegration, startCloudIntegration, getCloudStatus } = await import('./cloud.js');
@@ -10417,6 +11018,22 @@ export async function createServer() {
10417
11018
  // Prune old mutation alert tracking every 30 minutes
10418
11019
  const pruneTimer = setInterval(pruneOldAttempts, 30 * 60 * 1000);
10419
11020
  pruneTimer.unref();
11021
+ // GET /compliance/violations — state-read-before-assertion compliance violations
11022
+ app.get('/compliance/violations', async (request, reply) => {
11023
+ const query = request.query;
11024
+ const agent = query.agent || undefined;
11025
+ const severity = query.severity || undefined;
11026
+ const limit = Math.min(parseInt(query.limit || '100', 10) || 100, 1000);
11027
+ const since = query.since ? parseInt(query.since, 10) : undefined;
11028
+ const violations = queryViolations({ agent, severity, limit, since });
11029
+ const summary = getViolationSummary(since);
11030
+ reply.send({
11031
+ violations,
11032
+ count: violations.length,
11033
+ summary,
11034
+ query: { agent: agent ?? null, severity: severity ?? null, limit, since: since ?? null },
11035
+ });
11036
+ });
10420
11037
  // GET /audit/reviews — review-field mutation audit ledger
10421
11038
  app.get('/audit/reviews', async (request, reply) => {
10422
11039
  const query = request.query;
@@ -10883,6 +11500,8 @@ export async function createServer() {
10883
11500
  const result = calendarEvents.getAgentNextEvent(query.agent);
10884
11501
  return { agent: query.agent, next_event: result?.event || null, starts_at: result?.starts_at || null };
10885
11502
  });
11503
+ // Start hourly auto-snapshot for alert-preflight daily metrics
11504
+ startAutoSnapshot();
10886
11505
  return app;
10887
11506
  }
10888
11507
  //# sourceMappingURL=server.js.map