reflectt-node 0.1.5 → 0.1.7

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 (106) hide show
  1. package/README.md +50 -42
  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 +6 -1
  12. package/dist/alert-preflight.d.ts.map +1 -1
  13. package/dist/alert-preflight.js +52 -14
  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/canvas-slots.d.ts +1 -1
  19. package/dist/channels.d.ts +1 -1
  20. package/dist/chat.d.ts +13 -0
  21. package/dist/chat.d.ts.map +1 -1
  22. package/dist/chat.js +54 -1
  23. package/dist/chat.js.map +1 -1
  24. package/dist/cli.js +162 -6
  25. package/dist/cli.js.map +1 -1
  26. package/dist/cloud.js +7 -5
  27. package/dist/cloud.js.map +1 -1
  28. package/dist/dashboard.d.ts.map +1 -1
  29. package/dist/dashboard.js +30 -3
  30. package/dist/dashboard.js.map +1 -1
  31. package/dist/db.d.ts.map +1 -1
  32. package/dist/db.js +24 -1
  33. package/dist/db.js.map +1 -1
  34. package/dist/events.d.ts.map +1 -1
  35. package/dist/events.js +15 -2
  36. package/dist/events.js.map +1 -1
  37. package/dist/executionSweeper.d.ts +2 -0
  38. package/dist/executionSweeper.d.ts.map +1 -1
  39. package/dist/executionSweeper.js +48 -4
  40. package/dist/executionSweeper.js.map +1 -1
  41. package/dist/focus.d.ts +20 -0
  42. package/dist/focus.d.ts.map +1 -0
  43. package/dist/focus.js +57 -0
  44. package/dist/focus.js.map +1 -0
  45. package/dist/health.d.ts +1 -0
  46. package/dist/health.d.ts.map +1 -1
  47. package/dist/health.js +17 -8
  48. package/dist/health.js.map +1 -1
  49. package/dist/index.js +113 -10
  50. package/dist/index.js.map +1 -1
  51. package/dist/insight-mutation.d.ts +26 -0
  52. package/dist/insight-mutation.d.ts.map +1 -1
  53. package/dist/insight-mutation.js +103 -12
  54. package/dist/insight-mutation.js.map +1 -1
  55. package/dist/insights.d.ts +20 -0
  56. package/dist/insights.d.ts.map +1 -1
  57. package/dist/insights.js +129 -4
  58. package/dist/insights.js.map +1 -1
  59. package/dist/mcp.d.ts.map +1 -1
  60. package/dist/mcp.js +3 -2
  61. package/dist/mcp.js.map +1 -1
  62. package/dist/openclaw.d.ts.map +1 -1
  63. package/dist/openclaw.js +3 -2
  64. package/dist/openclaw.js.map +1 -1
  65. package/dist/prAutoMerge.d.ts.map +1 -1
  66. package/dist/prAutoMerge.js +23 -0
  67. package/dist/prAutoMerge.js.map +1 -1
  68. package/dist/presence.d.ts +16 -1
  69. package/dist/presence.d.ts.map +1 -1
  70. package/dist/presence.js +97 -9
  71. package/dist/presence.js.map +1 -1
  72. package/dist/pulse.d.ts +60 -0
  73. package/dist/pulse.d.ts.map +1 -0
  74. package/dist/pulse.js +139 -0
  75. package/dist/pulse.js.map +1 -0
  76. package/dist/release.d.ts +2 -0
  77. package/dist/release.d.ts.map +1 -1
  78. package/dist/release.js +14 -1
  79. package/dist/release.js.map +1 -1
  80. package/dist/scopeOverlap.d.ts +32 -0
  81. package/dist/scopeOverlap.d.ts.map +1 -0
  82. package/dist/scopeOverlap.js +219 -0
  83. package/dist/scopeOverlap.js.map +1 -0
  84. package/dist/server.d.ts.map +1 -1
  85. package/dist/server.js +520 -71
  86. package/dist/server.js.map +1 -1
  87. package/dist/tasks-next-diagnostics.d.ts +15 -0
  88. package/dist/tasks-next-diagnostics.d.ts.map +1 -0
  89. package/dist/tasks-next-diagnostics.js +33 -0
  90. package/dist/tasks-next-diagnostics.js.map +1 -0
  91. package/dist/tasks.d.ts +2 -1
  92. package/dist/tasks.d.ts.map +1 -1
  93. package/dist/tasks.js +33 -11
  94. package/dist/tasks.js.map +1 -1
  95. package/dist/team-config.d.ts.map +1 -1
  96. package/dist/team-config.js +20 -0
  97. package/dist/team-config.js.map +1 -1
  98. package/dist/types.d.ts +2 -0
  99. package/dist/types.d.ts.map +1 -1
  100. package/dist/version.d.ts +2 -0
  101. package/dist/version.d.ts.map +1 -0
  102. package/dist/version.js +16 -0
  103. package/dist/version.js.map +1 -0
  104. package/package.json +5 -1
  105. package/public/dashboard.js +97 -14
  106. package/public/docs.md +52 -7
package/dist/server.js CHANGED
@@ -11,7 +11,7 @@ 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
16
  import { getPreflightMetrics, snapshotDailyMetrics, getDailySnapshots, startAutoSnapshot } from './alert-preflight.js';
17
17
  // ── Build info (read once at startup) ──────────────────────────────────────
@@ -37,6 +37,9 @@ 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';
41
44
  import { isTestHarnessTask } from './test-task-filter.js';
42
45
  import { handleMCPRequest, handleSSERequest, handleMessagesRequest } from './mcp.js';
@@ -91,8 +94,9 @@ import { submitFeedback, listFeedback, getFeedback, updateFeedback, voteFeedback
91
94
  import { createEscalation, acknowledgeEscalation, resolveEscalation, tickEscalations, getEscalation, getEscalationByFeedback, listEscalations, } from './escalation.js';
92
95
  import { slotManager as canvasSlots } from './canvas-slots.js';
93
96
  import { createReflection, getReflection, listReflections, countReflections, reflectionStats, validateReflection, ROLE_TYPES, SEVERITY_LEVELS, recordReflectionDuplicate } from './reflections.js';
94
- import { ingestReflection, getInsight, listInsights, insightStats, extractClusterKey, tickCooldowns, updateInsightStatus, getOrphanedInsights, reconcileInsightTaskLinks, getLoopSummary } from './insights.js';
95
- 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';
96
100
  import { promoteInsight, validatePromotionInput, generateRecurringCandidates, listPromotionAudits, getPromotionAuditByInsight } from './insight-promotion.js';
97
101
  import { runIntake, batchIntake, pipelineMaintenance, getPipelineStats } from './intake-pipeline.js';
98
102
  import { listLineage, getLineage, lineageStats } from './lineage.js';
@@ -154,6 +158,8 @@ const CreateTaskSchema = z.object({
154
158
  tags: z.array(z.string()).optional(),
155
159
  teamId: z.string().optional(),
156
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
157
163
  });
158
164
  /**
159
165
  * Definition-of-ready check: validates task quality at creation time.
@@ -279,6 +285,8 @@ const UpdateTaskSchema = z.object({
279
285
  tags: z.array(z.string()).optional(),
280
286
  metadata: z.record(z.unknown()).optional(),
281
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
282
290
  });
283
291
  const CreateTaskCommentSchema = z.object({
284
292
  author: z.string().trim().min(1),
@@ -582,10 +590,26 @@ function enforceQaBundleGateForValidating(status, metadata, expectedTaskId) {
582
590
  };
583
591
  }
584
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
+ }
585
609
  if (reviewPacket && !nonCodeLane && expectedTaskId && reviewPacket.task_id !== expectedTaskId) {
586
610
  return {
587
611
  ok: false,
588
- 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}"`,
589
613
  hint: 'Set review_packet.task_id to the current task ID before moving to validating.',
590
614
  };
591
615
  }
@@ -593,7 +617,7 @@ function enforceQaBundleGateForValidating(status, metadata, expectedTaskId) {
593
617
  if (reviewPacket && !nonCodeLane && artifactPath && artifactPath !== reviewPacket.artifact_path) {
594
618
  return {
595
619
  ok: false,
596
- 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}"`,
597
621
  hint: 'Use the same canonical process/... artifact path in both fields.',
598
622
  };
599
623
  }
@@ -857,7 +881,7 @@ function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
857
881
  if (handoff.task_id !== taskId) {
858
882
  return {
859
883
  ok: false,
860
- error: `Review handoff task_id mismatch: expected ${taskId}`,
884
+ error: `Review handoff task_id mismatch: got "${handoff.task_id}", expected "${taskId}"`,
861
885
  hint: 'Set metadata.review_handoff.task_id to the exact task being transitioned.',
862
886
  };
863
887
  }
@@ -1909,7 +1933,7 @@ export async function createServer() {
1909
1933
  : {
1910
1934
  status: 'not configured',
1911
1935
  hint: 'Set OPENCLAW_GATEWAY_URL and OPENCLAW_GATEWAY_TOKEN environment variables, or run: openclaw gateway token',
1912
- docs: 'https://reflectt.ai/bootstrap',
1936
+ docs: 'https://app.reflectt.ai',
1913
1937
  },
1914
1938
  chat: chatManager.getStats(),
1915
1939
  tasks: taskManager.getStats({ includeTest }),
@@ -1922,6 +1946,15 @@ export async function createServer() {
1922
1946
  };
1923
1947
  });
1924
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
+ });
1925
1958
  app.get('/health/errors', async () => {
1926
1959
  const m = getRequestMetrics();
1927
1960
  return {
@@ -2310,8 +2343,17 @@ export async function createServer() {
2310
2343
  done: done.length,
2311
2344
  resolvedExternally: resolvedExternally.length,
2312
2345
  },
2346
+ // Top-level convenience aliases (never null)
2347
+ readyCount: ready.length,
2348
+ wipCount: doing.length,
2313
2349
  compliance: {
2314
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
+ })),
2315
2357
  floorBreaches: floorBreaches.map(a => ({
2316
2358
  agent: a.agent,
2317
2359
  ready: a.ready,
@@ -2684,6 +2726,8 @@ export async function createServer() {
2684
2726
  pid: build.pid,
2685
2727
  nodeVersion: build.nodeVersion,
2686
2728
  uptime: build.uptime,
2729
+ dataDir: DATA_DIR,
2730
+ reflecttHome: REFLECTT_HOME,
2687
2731
  };
2688
2732
  });
2689
2733
  // Error logs (for debugging)
@@ -2837,11 +2881,46 @@ export async function createServer() {
2837
2881
  const __dirname = dirname(__filename);
2838
2882
  const publicDir = join(__dirname, '..', 'public', 'avatars');
2839
2883
  if (safe) {
2840
- const filePath = join(publicDir, filename);
2841
- const data = await fs.readFile(filePath);
2842
- const ext = filename.toLowerCase().endsWith('.svg') ? 'image/svg+xml' : 'image/png';
2843
- reply.type(ext).header('Cache-Control', 'public, max-age=3600').send(data);
2844
- return;
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
+ }
2845
2924
  }
2846
2925
  }
2847
2926
  catch {
@@ -3018,7 +3097,7 @@ export async function createServer() {
3018
3097
  // Auto-update presence: if you're posting, you're active
3019
3098
  if (data.from) {
3020
3099
  presenceManager.recordActivity(data.from, 'message');
3021
- presenceManager.updatePresence(data.from, 'working');
3100
+ presenceManager.touchPresence(data.from);
3022
3101
  // Activation funnel: first team message
3023
3102
  emitActivationEvent('first_team_message_sent', data.from, {
3024
3103
  channel: data.channel || 'general',
@@ -3657,22 +3736,38 @@ export async function createServer() {
3657
3736
  const { metadata, description, done_criteria, ...slim } = task;
3658
3737
  return slim;
3659
3738
  };
3660
- 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
+ };
3661
3743
  // List tasks
3662
3744
  app.get('/tasks', async (request, reply) => {
3663
3745
  const query = request.query;
3664
- const updatedSince = parseEpochMs(query.updatedSince || query.since);
3665
- const limit = boundedLimit(query.limit, DEFAULT_LIMITS.tasks, MAX_LIMITS.tasks);
3666
- const tagFilter = query.tag
3667
- ? [query.tag]
3668
- : (query.tags ? query.tags.split(',') : undefined);
3669
- 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);
3670
3765
  let tasks = taskManager.listTasks({
3671
- status: query.status,
3672
- assignee: query.assignee || query.assignedTo, // Support both for backward compatibility
3673
- createdBy: query.createdBy,
3674
- teamId: normalizeTeamId(query.teamId),
3675
- 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),
3676
3771
  tags: tagFilter,
3677
3772
  includeTest,
3678
3773
  });
@@ -3680,7 +3775,8 @@ export async function createServer() {
3680
3775
  tasks = tasks.filter(task => task.updatedAt >= updatedSince);
3681
3776
  }
3682
3777
  // Text search filter
3683
- const searchQuery = (query.q || '').trim().toLowerCase();
3778
+ const qRaw = Array.isArray(query.q) ? query.q[0] : query.q;
3779
+ const searchQuery = (qRaw || '').trim().toLowerCase();
3684
3780
  if (searchQuery) {
3685
3781
  tasks = tasks.filter(task => (task.title || '').toLowerCase().includes(searchQuery) ||
3686
3782
  (task.description || '').toLowerCase().includes(searchQuery) ||
@@ -3688,7 +3784,7 @@ export async function createServer() {
3688
3784
  (task.id || '').toLowerCase().includes(searchQuery));
3689
3785
  }
3690
3786
  const total = tasks.length;
3691
- const offset = parsePositiveInt(query.offset) || 0;
3787
+ const offset = parsePositiveInt(Array.isArray(query.offset) ? query.offset[0] : query.offset) || 0;
3692
3788
  tasks = tasks.slice(offset, offset + limit);
3693
3789
  const hasMore = offset + tasks.length < total;
3694
3790
  const enriched = tasks.map(enrichTaskWithComments);
@@ -4945,7 +5041,7 @@ export async function createServer() {
4945
5041
  }
4946
5042
  }
4947
5043
  presenceManager.recordActivity(data.author, 'message');
4948
- presenceManager.updatePresence(data.author, 'working');
5044
+ presenceManager.touchPresence(data.author);
4949
5045
  // Heartbeat discipline: compute gap since previous comment for doing tasks
4950
5046
  let heartbeatWarning;
4951
5047
  if (task && task.status === 'doing' && !comment.suppressed) {
@@ -5010,6 +5106,44 @@ export async function createServer() {
5010
5106
  return { success: false, error: err.message || 'Failed to capture outcome verdict' };
5011
5107
  }
5012
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
+ });
5013
5147
  // Build normalized reviewer packet (PR + CI + artifacts)
5014
5148
  app.post('/tasks/:id/review-bundle', async (request, reply) => {
5015
5149
  const task = taskManager.getTask(request.params.id);
@@ -5319,7 +5453,12 @@ export async function createServer() {
5319
5453
  // Create task
5320
5454
  app.post('/tasks', async (request, reply) => {
5321
5455
  try {
5322
- 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);
5323
5462
  // Reject TEST: prefixed tasks in production to prevent CI pollution
5324
5463
  if (process.env.NODE_ENV === 'production' && typeof data.title === 'string' && data.title.startsWith('TEST:')) {
5325
5464
  reply.code(400);
@@ -5429,9 +5568,10 @@ export async function createServer() {
5429
5568
  ...(normalizedTeamId ? { teamId: normalizedTeamId } : {}),
5430
5569
  metadata: newMetadata,
5431
5570
  });
5432
- // 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)
5433
5573
  if (data.createdBy) {
5434
- presenceManager.updatePresence(data.createdBy, 'working');
5574
+ presenceManager.touchPresence(data.createdBy);
5435
5575
  }
5436
5576
  // Fire-and-forget: index task for semantic search
5437
5577
  if (!data.title.startsWith('TEST:')) {
@@ -5439,6 +5579,21 @@ export async function createServer() {
5439
5579
  .then(({ indexTask }) => indexTask(task.id, task.title, undefined, data.done_criteria))
5440
5580
  .catch(() => { });
5441
5581
  }
5582
+ // Auto-link insight when task is manually created with source_insight metadata.
5583
+ // Mirrors the bridge's updateInsightStatus call so insights don't stay pending_triage
5584
+ // after an agent manually files a task addressing them.
5585
+ const sourceInsightId = typeof newMetadata.source_insight === 'string' ? newMetadata.source_insight : null;
5586
+ if (sourceInsightId && !sourceInsightId.startsWith('ins-test-')) {
5587
+ try {
5588
+ const linkedInsight = getInsight(sourceInsightId);
5589
+ if (linkedInsight && linkedInsight.status !== 'task_created' && linkedInsight.status !== 'closed') {
5590
+ updateInsightStatus(sourceInsightId, 'task_created', task.id);
5591
+ }
5592
+ }
5593
+ catch {
5594
+ // Non-fatal: insight link failure must not block task creation
5595
+ }
5596
+ }
5442
5597
  trackTaskEvent('created');
5443
5598
  return { success: true, task: enrichTaskWithComments(task), warnings: creationWarnings };
5444
5599
  }
@@ -5926,7 +6081,13 @@ export async function createServer() {
5926
6081
  // Update task
5927
6082
  app.patch('/tasks/:id', async (request, reply) => {
5928
6083
  try {
5929
- const parsed = UpdateTaskSchema.parse(request.body);
6084
+ // Normalize legacy "in-progress" → "doing" before schema validation.
6085
+ // Some agents (and older MCP callers) use the deprecated status name.
6086
+ const rawBody = request.body;
6087
+ if (rawBody && typeof rawBody === 'object' && rawBody.status === 'in-progress') {
6088
+ rawBody.status = 'doing';
6089
+ }
6090
+ const parsed = UpdateTaskSchema.parse(rawBody);
5930
6091
  const lookup = taskManager.resolveTaskId(request.params.id);
5931
6092
  if (lookup.matchType === 'ambiguous') {
5932
6093
  reply.code(400);
@@ -6089,6 +6250,29 @@ export async function createServer() {
6089
6250
  hint: duplicateGate.hint,
6090
6251
  };
6091
6252
  }
6253
+ // Early format validation: catch bad PR URLs and commit SHAs on any update, not just at validating transition
6254
+ const earlyReviewPacket = mergedMeta?.qa_bundle?.review_packet;
6255
+ const earlyHandoff = mergedMeta?.review_handoff;
6256
+ const earlyPrUrl = (earlyReviewPacket?.pr_url ?? earlyHandoff?.pr_url);
6257
+ const earlyCommit = (earlyReviewPacket?.commit ?? earlyHandoff?.commit_sha);
6258
+ if (earlyPrUrl && typeof earlyPrUrl === 'string' && !/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+$/.test(earlyPrUrl)) {
6259
+ reply.code(400);
6260
+ return {
6261
+ success: false,
6262
+ error: `Invalid PR URL format: "${earlyPrUrl}"`,
6263
+ gate: 'format_validation',
6264
+ hint: 'Expected format: https://github.com/owner/repo/pull/123',
6265
+ };
6266
+ }
6267
+ if (earlyCommit && typeof earlyCommit === 'string' && earlyCommit.length > 0 && !/^[a-f0-9]{7,40}$/i.test(earlyCommit)) {
6268
+ reply.code(400);
6269
+ return {
6270
+ success: false,
6271
+ error: `Invalid commit SHA format: "${earlyCommit}"`,
6272
+ gate: 'format_validation',
6273
+ hint: 'Expected 7-40 hex characters, e.g. "a1b2c3d"',
6274
+ };
6275
+ }
6092
6276
  // QA bundle gate: validating requires structured review evidence.
6093
6277
  const effectiveStatus = parsed.status ?? existing.status;
6094
6278
  const qaGate = enforceQaBundleGateForValidating(parsed.status, mergedMeta, existing.id);
@@ -6424,6 +6608,25 @@ export async function createServer() {
6424
6608
  presenceManager.updatePresence(task.assignee, 'reviewing');
6425
6609
  }
6426
6610
  }
6611
+ // ── Reviewer notification: @mention reviewer when task enters validating ──
6612
+ if (parsed.status === 'validating' && existing.status !== 'validating' && existing.reviewer) {
6613
+ const taskMeta = task.metadata;
6614
+ const prUrl = taskMeta?.review_handoff?.pr_url
6615
+ ?? taskMeta?.qa_bundle?.pr_url
6616
+ ?? '';
6617
+ const prLine = prUrl ? `\nPR: ${prUrl}` : '';
6618
+ chatManager.sendMessage({
6619
+ from: 'system',
6620
+ content: `@${existing.reviewer} [reviewRequested:${task.id}] ${task.title} → validating${prLine}`,
6621
+ channel: 'task-notifications',
6622
+ metadata: {
6623
+ kind: 'review_requested',
6624
+ taskId: task.id,
6625
+ reviewer: existing.reviewer,
6626
+ prUrl: prUrl || undefined,
6627
+ },
6628
+ }).catch(() => { }); // Non-blocking
6629
+ }
6427
6630
  // ── Activation funnel: track first_task_started / first_task_completed ──
6428
6631
  {
6429
6632
  const funnelUserId = task.metadata?.userId || task.assignee || '';
@@ -6515,6 +6718,24 @@ export async function createServer() {
6515
6718
  }
6516
6719
  if (parsed.status === 'validating' && task.reviewer) {
6517
6720
  statusNotifTargets.push({ agent: task.reviewer, type: 'reviewRequested' });
6721
+ // ── Explicit reviewer routing: ping reviewer with PR link + ask ──
6722
+ const prUrl = task.metadata?.pr_url
6723
+ || task.metadata?.qa_bundle?.pr_url
6724
+ || task.metadata?.review_handoff?.pr_url;
6725
+ const prLink = typeof prUrl === 'string' && prUrl ? ` — ${prUrl}` : '';
6726
+ const reviewMsg = `@${task.reviewer} review requested: **${task.title}** (${task.id})${prLink}. Please approve or flag issues.`;
6727
+ chatManager.sendMessage({
6728
+ from: 'system',
6729
+ content: reviewMsg,
6730
+ channel: 'reviews',
6731
+ metadata: {
6732
+ kind: 'review_routing',
6733
+ taskId: task.id,
6734
+ reviewer: task.reviewer,
6735
+ assignee: task.assignee,
6736
+ prUrl: prUrl || null,
6737
+ },
6738
+ }).catch(() => { }); // Non-blocking
6518
6739
  }
6519
6740
  if (parsed.status === 'done') {
6520
6741
  if (task.assignee)
@@ -7675,8 +7896,41 @@ export async function createServer() {
7675
7896
  reply.code(201);
7676
7897
  return { success: true, insight, cluster_key: extractClusterKey(reflection) };
7677
7898
  });
7899
+ // ── Activity Timeline ──────────────────────────────────────────────────
7900
+ app.get('/activity', async (request) => {
7901
+ const query = request.query;
7902
+ const range = query.range === '7d' ? '7d' : '24h';
7903
+ const type = query.type ? query.type.split(',').map(t => t.trim()).filter(Boolean) : undefined;
7904
+ const agent = query.agent || undefined;
7905
+ const limit = query.limit ? Number(query.limit) : undefined;
7906
+ const after = query.after || undefined;
7907
+ // debug=1 only allowed from localhost
7908
+ const isLocalhost = request.ip === '127.0.0.1' || request.ip === '::1' || request.ip === '::ffff:127.0.0.1';
7909
+ const debug = query.debug === '1' && isLocalhost;
7910
+ try {
7911
+ return queryActivity({ range, type, agent, limit, after, debug });
7912
+ }
7913
+ catch (err) {
7914
+ request.log.error({ err }, 'Activity query failed');
7915
+ throw err;
7916
+ }
7917
+ });
7918
+ app.get('/activity/sources', async () => {
7919
+ return {
7920
+ sources: [...ACTIVITY_SOURCES],
7921
+ description: 'Allowed values for partial.missing[] and type filter',
7922
+ };
7923
+ });
7678
7924
  app.get('/insights', async (request) => {
7679
7925
  const query = request.query;
7926
+ // Hygiene: when listing candidate insights, proactively cool down any
7927
+ // candidates whose promoted task is already done/cancelled so they don't resurface.
7928
+ // Keep listInsights() itself pure (no DB writes on read).
7929
+ if (query.status === 'candidate') {
7930
+ const offset = query.offset ? Number(query.offset) || 0 : 0;
7931
+ if (offset === 0)
7932
+ sweepShippedCandidates();
7933
+ }
7680
7934
  const result = listInsights({
7681
7935
  status: query.status,
7682
7936
  priority: query.priority,
@@ -7795,6 +8049,104 @@ export async function createServer() {
7795
8049
  }
7796
8050
  return { success: true, insight: result.insight };
7797
8051
  });
8052
+ // Narrow localhost-only admin endpoints for routine hygiene: cooldown/close.
8053
+ // These avoid enabling the broader PATCH /insights/:id mutation API.
8054
+ app.post('/insights/:id/cooldown', async (request, reply) => {
8055
+ const ip = String(request.ip || '');
8056
+ const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
8057
+ if (!isLoopback) {
8058
+ reply.code(403);
8059
+ return {
8060
+ success: false,
8061
+ error: 'Forbidden: localhost-only endpoint',
8062
+ hint: `Request ip (${ip || 'unknown'}) is not loopback`,
8063
+ };
8064
+ }
8065
+ const requiredToken = process.env.REFLECTT_INSIGHT_MUTATION_TOKEN;
8066
+ if (requiredToken) {
8067
+ const raw = request.headers['x-reflectt-admin-token'];
8068
+ let provided = Array.isArray(raw) ? raw[0] : raw;
8069
+ const auth = request.headers.authorization;
8070
+ if ((!provided || typeof provided !== 'string') && typeof auth === 'string' && auth.startsWith('Bearer ')) {
8071
+ provided = auth.slice('Bearer '.length);
8072
+ }
8073
+ if (typeof provided !== 'string' || provided !== requiredToken) {
8074
+ reply.code(403);
8075
+ return {
8076
+ success: false,
8077
+ error: 'Forbidden: missing/invalid admin token',
8078
+ hint: 'Provide x-reflectt-admin-token header (or Authorization: Bearer ...) matching REFLECTT_INSIGHT_MUTATION_TOKEN.'
8079
+ };
8080
+ }
8081
+ }
8082
+ const body = (request.body ?? {});
8083
+ const actor = typeof body.actor === 'string' ? body.actor : '';
8084
+ const reason = typeof body.reason === 'string' ? body.reason : '';
8085
+ const notes = typeof body.notes === 'string' ? body.notes : undefined;
8086
+ const cooldown_reason = typeof body.cooldown_reason === 'string' ? body.cooldown_reason : undefined;
8087
+ const cooldown_until = typeof body.cooldown_until === 'number' && Number.isFinite(body.cooldown_until)
8088
+ ? body.cooldown_until
8089
+ : (typeof body.cooldown_ms === 'number' && Number.isFinite(body.cooldown_ms)
8090
+ ? Date.now() + Math.max(0, body.cooldown_ms)
8091
+ : undefined);
8092
+ const result = cooldownInsightById(request.params.id, {
8093
+ actor,
8094
+ reason,
8095
+ ...(notes ? { notes } : {}),
8096
+ ...(cooldown_until ? { cooldown_until } : {}),
8097
+ ...(cooldown_reason ? { cooldown_reason } : {}),
8098
+ });
8099
+ if (!result.success) {
8100
+ const notFound = result.error === 'Insight not found';
8101
+ reply.code(notFound ? 404 : 400);
8102
+ return { success: false, error: result.error };
8103
+ }
8104
+ return { success: true, insight: result.insight };
8105
+ });
8106
+ app.post('/insights/:id/close', async (request, reply) => {
8107
+ const ip = String(request.ip || '');
8108
+ const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
8109
+ if (!isLoopback) {
8110
+ reply.code(403);
8111
+ return {
8112
+ success: false,
8113
+ error: 'Forbidden: localhost-only endpoint',
8114
+ hint: `Request ip (${ip || 'unknown'}) is not loopback`,
8115
+ };
8116
+ }
8117
+ const requiredToken = process.env.REFLECTT_INSIGHT_MUTATION_TOKEN;
8118
+ if (requiredToken) {
8119
+ const raw = request.headers['x-reflectt-admin-token'];
8120
+ let provided = Array.isArray(raw) ? raw[0] : raw;
8121
+ const auth = request.headers.authorization;
8122
+ if ((!provided || typeof provided !== 'string') && typeof auth === 'string' && auth.startsWith('Bearer ')) {
8123
+ provided = auth.slice('Bearer '.length);
8124
+ }
8125
+ if (typeof provided !== 'string' || provided !== requiredToken) {
8126
+ reply.code(403);
8127
+ return {
8128
+ success: false,
8129
+ error: 'Forbidden: missing/invalid admin token',
8130
+ hint: 'Provide x-reflectt-admin-token header (or Authorization: Bearer ...) matching REFLECTT_INSIGHT_MUTATION_TOKEN.'
8131
+ };
8132
+ }
8133
+ }
8134
+ const body = (request.body ?? {});
8135
+ const actor = typeof body.actor === 'string' ? body.actor : '';
8136
+ const reason = typeof body.reason === 'string' ? body.reason : '';
8137
+ const notes = typeof body.notes === 'string' ? body.notes : undefined;
8138
+ const result = closeInsightById(request.params.id, {
8139
+ actor,
8140
+ reason,
8141
+ ...(notes ? { notes } : {}),
8142
+ });
8143
+ if (!result.success) {
8144
+ const notFound = result.error === 'Insight not found';
8145
+ reply.code(notFound ? 404 : 400);
8146
+ return { success: false, error: result.error };
8147
+ }
8148
+ return { success: true, insight: result.insight };
8149
+ });
7798
8150
  app.get('/insights/stats', async () => {
7799
8151
  return insightStats();
7800
8152
  });
@@ -8412,7 +8764,31 @@ export async function createServer() {
8412
8764
  }
8413
8765
  const task = taskManager.getNextTask(agent, { includeTest });
8414
8766
  if (!task) {
8415
- return { task: null, message: 'No available tasks' };
8767
+ const aliases = agent ? getAgentAliases(agent) : [];
8768
+ // "Ready" counts: match /tasks/next selection semantics (blocked excluded)
8769
+ const readyTodo = taskManager.listTasks({ status: 'todo', includeBlocked: false, includeTest });
8770
+ const ready_todo_unassigned = readyTodo.filter(t => {
8771
+ const a = String(t.assignee || '').trim().toLowerCase();
8772
+ return a.length === 0 || a === 'unassigned';
8773
+ }).length;
8774
+ const ready_todo_assigned = agent
8775
+ ? taskManager.listTasks({ status: 'todo', assigneeIn: aliases, includeBlocked: false, includeTest }).length
8776
+ : 0;
8777
+ const ready_doing_assigned = agent
8778
+ ? taskManager.listTasks({ status: 'doing', assigneeIn: aliases, includeBlocked: false, includeTest }).length
8779
+ : 0;
8780
+ const ready_validating_assigned = agent
8781
+ ? taskManager.listTasks({ status: 'validating', assigneeIn: aliases, includeBlocked: false, includeTest }).length
8782
+ : 0;
8783
+ const { formatTasksNextEmptyResponse } = await import('./tasks-next-diagnostics.js');
8784
+ const payload = formatTasksNextEmptyResponse({
8785
+ agent,
8786
+ ready_doing_assigned,
8787
+ ready_todo_unassigned,
8788
+ ready_todo_assigned,
8789
+ ready_validating_assigned,
8790
+ });
8791
+ return { task: null, ...payload };
8416
8792
  }
8417
8793
  // Rule C: auto-claim (todo→doing) when ?claim=1
8418
8794
  const shouldClaim = query.claim === '1' || query.claim === 'true';
@@ -8515,7 +8891,11 @@ export async function createServer() {
8515
8891
  }));
8516
8892
  const todoTasks = taskManager.listTasks({ status: 'todo', assigneeIn: getAgentAliases(agent) });
8517
8893
  const validatingTasks = taskManager.listTasks({ status: 'validating', assigneeIn: getAgentAliases(agent) });
8518
- const slim = (t) => t ? { id: t.id, title: t.title, status: t.status, priority: t.priority } : null;
8894
+ const slim = (t) => t ? {
8895
+ id: t.id, title: t.title, status: t.status, priority: t.priority,
8896
+ ...(t.dueAt ? { dueAt: t.dueAt } : {}),
8897
+ ...(t.scheduledFor ? { scheduledFor: t.scheduledFor } : {}),
8898
+ } : null;
8519
8899
  presenceManager.recordActivity(agent, 'heartbeat');
8520
8900
  // Check pause status
8521
8901
  const pauseStatus = checkPauseStatus(agent);
@@ -8523,12 +8903,18 @@ export async function createServer() {
8523
8903
  const { getIntensity, checkPullBudget } = await import('./intensity.js');
8524
8904
  const intensity = getIntensity();
8525
8905
  const pullBudget = checkPullBudget(agent);
8906
+ // Drop stats for this agent
8907
+ const allDrops = chatManager.getDropStats();
8908
+ const agentDrops = allDrops[agent];
8909
+ const focusSummary = getFocusSummary();
8526
8910
  return {
8527
8911
  agent, ts: Date.now(),
8528
8912
  active: slim(activeTask), next: pauseStatus.paused ? null : slim(nextTask),
8529
8913
  inbox: slimInbox, inboxCount: inbox.length,
8530
8914
  queue: { todo: todoTasks.length, doing: doingTasks.length, validating: validatingTasks.length },
8531
8915
  intensity: { preset: intensity.preset, pullsRemaining: pullBudget.remaining, wipLimit: intensity.limits.wipLimit },
8916
+ ...(focusSummary ? { focus: focusSummary } : {}),
8917
+ ...(agentDrops ? { drops: { total: agentDrops.total, rolling_1h: agentDrops.rolling_1h } } : {}),
8532
8918
  ...(pauseStatus.paused ? { paused: true, pauseMessage: pauseStatus.message, resumesAt: pauseStatus.entry?.pausedUntil ?? null } : {}),
8533
8919
  action: pauseStatus.paused ? `PAUSED: ${pauseStatus.message}`
8534
8920
  : activeTask ? `Continue ${activeTask.id}`
@@ -8571,12 +8957,16 @@ If your heartbeat shows **no active task** and **no next task**:
8571
8957
  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\`).
8572
8958
  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.
8573
8959
 
8574
- ## Comms Protocol (recommended)
8575
- 1. **Status updates belong in task comments first** (\`POST /tasks/:id/comments\`).
8576
- 2. **Shipped artifacts**: post in a shipping/release channel (if your team uses one) and include \`@reviewer\` + task ID + PR/artifact link.
8577
- 3. **Review requests**: post in a reviews channel (if your team uses one) and include \`@reviewer\` + task ID + exact ask.
8578
- 4. **Blockers**: post in a blockers channel (if your team uses one) and include **who you need** + task ID + concrete unblock needed.
8579
- 5. Keep \`#general\` for decisions/cross-team coordination (not routine heartbeat chatter).
8960
+ ## Comms Protocol (required)
8961
+ **Rule: task updates go to the task, not to chat.**
8962
+ - \`POST /tasks/:id/comments\` for all progress, blockers, and decisions on a task.
8963
+ - Chat channels are for coordination, not status reports. Do not post "working on task-xyz" or "done with task-xyz" to chat.
8964
+
8965
+ 1. **Task progress, blockers, decisions** \`POST /tasks/:id/comments\` (always first)
8966
+ 2. **Shipped artifacts** → post in shipping channel after the task comment, include \`@reviewer\` + task ID + PR/artifact link
8967
+ 3. **Review requests** → post in reviews channel after the task comment, include \`@reviewer\` + task ID + exact ask
8968
+ 4. **Blockers needing human action** → post in blockers channel after the task comment, include **who you need** + task ID + concrete unblock needed
8969
+ 5. \`#general\` is for decisions and cross-team coordination only — not task status updates
8580
8970
 
8581
8971
  ## API Quick Reference
8582
8972
  - Heartbeat check: \`GET /heartbeat/${agent}\`
@@ -8675,15 +9065,30 @@ If your heartbeat shows **no active task** and **no next task**:
8675
9065
  { method: 'GET', path: '/reflections', hint: 'List. Query: author, limit' },
8676
9066
  ],
8677
9067
  },
9068
+ activity: {
9069
+ description: 'Unified activity timeline',
9070
+ endpoints: [
9071
+ { method: 'GET', path: '/activity', hint: 'Timeline feed. Query: range (24h|7d), type, agent, limit, after (cursor)' },
9072
+ ],
9073
+ },
8678
9074
  system: {
8679
9075
  description: 'System health and discovery',
8680
9076
  endpoints: [
8681
9077
  { method: 'GET', path: '/health', hint: 'System health + version + stats' },
9078
+ { method: 'GET', path: '/pulse', compact: true, hint: 'Team pulse snapshot: deploy + board + per-agent doing + reviews. Query: compact' },
8682
9079
  { method: 'GET', path: '/capabilities', hint: 'This endpoint. Query: category to filter' },
8683
9080
  { method: 'GET', path: '/me/:agent', compact: true, hint: 'Full dashboard. Use /heartbeat/:agent for polls.' },
8684
9081
  { method: 'GET', path: '/docs', hint: 'Full API reference (68K chars). Use /capabilities instead when possible.' },
8685
9082
  ],
8686
9083
  },
9084
+ hosts: {
9085
+ description: 'Multi-host registry and cloud connection',
9086
+ endpoints: [
9087
+ { method: 'GET', path: '/hosts', compact: true, hint: 'List registered hosts. Query: compact' },
9088
+ { method: 'GET', path: '/cloud/status', hint: 'Cloud connection state + health summary' },
9089
+ { method: 'GET', path: '/cloud/events', hint: 'Connection lifecycle event log. Query: limit' },
9090
+ ],
9091
+ },
8687
9092
  manage: {
8688
9093
  description: 'Remote node management (auth-gated)',
8689
9094
  endpoints: [
@@ -9120,21 +9525,71 @@ If your heartbeat shows **no active task** and **no next task**:
9120
9525
  return { success: false, error: err.message };
9121
9526
  }
9122
9527
  });
9528
+ // ── Team Pulse ─────────────────────────────────────────────────────
9529
+ app.get('/pulse', async (request) => {
9530
+ const compact = request.query.compact === 'true' || request.query.compact === '1';
9531
+ if (compact) {
9532
+ return generateCompactPulse();
9533
+ }
9534
+ return generatePulse();
9535
+ });
9536
+ // ── Scope Overlap Scanner ──────────────────────────────────────────
9537
+ // POST /scope-overlap — trigger scope overlap scan after a PR merge
9538
+ app.post('/scope-overlap', async (request) => {
9539
+ const { prNumber, prTitle, prBranch, mergedTaskId, repo, mergeCommit, notify } = request.body || {};
9540
+ if (!prNumber || !prTitle || !prBranch) {
9541
+ return { success: false, error: 'Required: prNumber, prTitle, prBranch' };
9542
+ }
9543
+ if (notify !== false) {
9544
+ const result = await scanAndNotify(prNumber, prTitle, prBranch, mergedTaskId, repo, mergeCommit);
9545
+ return { success: true, ...result };
9546
+ }
9547
+ const result = scanScopeOverlap(prNumber, prTitle, prBranch, mergedTaskId, repo);
9548
+ return { success: true, ...result };
9549
+ });
9550
+ // ── Team Focus ─────────────────────────────────────────────────────
9551
+ // GET /focus — current team focus directive
9552
+ app.get('/focus', async () => {
9553
+ const focus = getFocus();
9554
+ return focus ? { focus } : { focus: null, message: 'No focus set. Use POST /focus to set one.' };
9555
+ });
9556
+ // POST /focus — set team focus directive
9557
+ app.post('/focus', async (request) => {
9558
+ const { directive, setBy, expiresAt, tags } = request.body || {};
9559
+ if (!directive || !setBy) {
9560
+ return { success: false, error: 'Required: directive, setBy' };
9561
+ }
9562
+ const focus = setFocus(directive, setBy, { expiresAt, tags });
9563
+ return { success: true, focus };
9564
+ });
9565
+ // DELETE /focus — clear team focus
9566
+ app.delete('/focus', async () => {
9567
+ clearFocus();
9568
+ return { success: true, message: 'Focus cleared' };
9569
+ });
9123
9570
  // Get all agent presences
9124
9571
  app.get('/presence', async () => {
9125
9572
  const explicitPresences = presenceManager.getAllPresence();
9126
9573
  const allActivity = presenceManager.getAllActivity();
9127
- // Build map of explicit presence by agent
9128
- const presenceMap = new Map(explicitPresences.map(p => [p.agent, p]));
9129
- // Add inferred presence for agents with only activity
9574
+ // Filter to agents known to this node's TEAM-ROLES registry
9575
+ const knownAgentNames = new Set(getAgentRoles().map(r => r.name.toLowerCase()));
9576
+ // Build map of explicit presence by agent (filtered to registry)
9577
+ const presenceMap = new Map(explicitPresences
9578
+ .filter(p => knownAgentNames.size === 0 || knownAgentNames.has(p.agent.toLowerCase()))
9579
+ .map(p => [p.agent, p]));
9580
+ // Add inferred presence for agents with only activity (registry-gated)
9130
9581
  const now = Date.now();
9131
9582
  for (const activity of allActivity) {
9132
- if (!presenceMap.has(activity.agent) && activity.last_active) {
9583
+ if (!presenceMap.has(activity.agent) && activity.last_active
9584
+ && (knownAgentNames.size === 0 || knownAgentNames.has(activity.agent.toLowerCase()))) {
9133
9585
  const inactiveMs = now - activity.last_active;
9134
9586
  let status = 'offline';
9135
- if (inactiveMs < 10 * 60 * 1000) { // Active in last 10 minutes
9587
+ if (inactiveMs < 15 * 60 * 1000) { // Active in last 15 minutes — match presence.ts IDLE_THRESHOLD_MS
9136
9588
  status = activity.tasks_completed_today > 0 ? 'working' : 'idle';
9137
9589
  }
9590
+ else if (inactiveMs < 30 * 60 * 1000) { // 15-30 min — idle grace period before offline
9591
+ status = 'idle';
9592
+ }
9138
9593
  presenceMap.set(activity.agent, {
9139
9594
  agent: activity.agent,
9140
9595
  status,
@@ -9180,11 +9635,14 @@ If your heartbeat shows **no active task** and **no next task**:
9180
9635
  if (activity && activity.last_active) {
9181
9636
  const now = Date.now();
9182
9637
  const inactiveMs = now - activity.last_active;
9183
- // Infer status based on recent activity
9638
+ // Infer status based on recent activity — match presence.ts thresholds
9184
9639
  let status = 'offline';
9185
- if (inactiveMs < 10 * 60 * 1000) { // Active in last 10 minutes
9640
+ if (inactiveMs < 15 * 60 * 1000) { // Active in last 15 minutes
9186
9641
  status = activity.tasks_completed_today > 0 ? 'working' : 'idle';
9187
9642
  }
9643
+ else if (inactiveMs < 30 * 60 * 1000) { // 15-30 min idle grace
9644
+ status = 'idle';
9645
+ }
9188
9646
  presence = {
9189
9647
  agent: request.params.agent,
9190
9648
  status,
@@ -9288,21 +9746,7 @@ If your heartbeat shows **no active task** and **no next task**:
9288
9746
  }
9289
9747
  });
9290
9748
  // ============ ACTIVITY FEED ENDPOINT ============
9291
- // Get recent activity across all systems
9292
- app.get('/activity', async (request, reply) => {
9293
- const query = request.query;
9294
- const events = eventBus.getEvents({
9295
- agent: query.agent,
9296
- limit: boundedLimit(query.limit, DEFAULT_LIMITS.activity, MAX_LIMITS.activity),
9297
- since: parseEpochMs(query.since),
9298
- });
9299
- const payload = { events, count: events.length };
9300
- const lastModified = events.length > 0 ? Math.max(...events.map(e => e.timestamp || 0)) : undefined;
9301
- if (applyConditionalCaching(request, reply, payload, lastModified)) {
9302
- return;
9303
- }
9304
- return payload;
9305
- });
9749
+ // Legacy activity endpoint replaced by unified /activity timeline (see above)
9306
9750
  // ============ SECRET VAULT ENDPOINTS ============
9307
9751
  // List secrets (metadata only — no plaintext)
9308
9752
  app.get('/secrets', async () => {
@@ -9505,15 +9949,17 @@ If your heartbeat shows **no active task** and **no next task**:
9505
9949
  * GET /activation/funnel — per-user funnel state + aggregate summary.
9506
9950
  * Query params:
9507
9951
  * ?userId=xxx — get single user's funnel state
9508
- * (no params) get aggregate summary across all users
9952
+ * ?raw=true include internal/infrastructure users (for debugging)
9953
+ * (no params) — get aggregate summary across all users (clean, external only)
9509
9954
  */
9510
9955
  app.get('/activation/funnel', async (request) => {
9511
9956
  const query = request.query;
9512
9957
  const userId = query.userId;
9958
+ const raw = query.raw === 'true';
9513
9959
  if (userId) {
9514
9960
  return { funnel: getUserFunnelState(userId) };
9515
9961
  }
9516
- return { funnel: getFunnelSummary() };
9962
+ return { funnel: getFunnelSummary({ raw }) };
9517
9963
  });
9518
9964
  /**
9519
9965
  * POST /activation/event — manually emit an activation event.
@@ -9550,14 +9996,17 @@ If your heartbeat shows **no active task** and **no next task**:
9550
9996
  app.get('/activation/dashboard', async (request) => {
9551
9997
  const query = request.query;
9552
9998
  const weeks = query.weeks ? parseInt(query.weeks, 10) : 12;
9553
- return { success: true, dashboard: getOnboardingDashboard({ weeks }) };
9999
+ const raw = query.raw === 'true';
10000
+ return { success: true, dashboard: getOnboardingDashboard({ weeks, raw }) };
9554
10001
  });
9555
10002
  /**
9556
10003
  * GET /activation/funnel/conversions — Step-by-step conversion rates.
9557
10004
  * Returns per-step reach count, conversion rate, and median step time.
9558
10005
  */
9559
- app.get('/activation/funnel/conversions', async () => {
9560
- return { success: true, conversions: getConversionFunnel() };
10006
+ app.get('/activation/funnel/conversions', async (request) => {
10007
+ const query = request.query;
10008
+ const raw = query.raw === 'true';
10009
+ return { success: true, conversions: getConversionFunnel({ raw }) };
9561
10010
  });
9562
10011
  /**
9563
10012
  * GET /activation/funnel/failures — Failure-reason distribution per step.
@@ -9803,10 +10252,10 @@ If your heartbeat shows **no active task** and **no next task**:
9803
10252
  };
9804
10253
  }
9805
10254
  const publication = await contentManager.logPublication(body);
9806
- // Update presence: publishing content = working
10255
+ // Touch presence: publishing content proves agent is alive
9807
10256
  if (body.publishedBy) {
9808
10257
  presenceManager.recordActivity(body.publishedBy, 'message');
9809
- presenceManager.updatePresence(body.publishedBy, 'working');
10258
+ presenceManager.touchPresence(body.publishedBy);
9810
10259
  }
9811
10260
  return { success: true, publication };
9812
10261
  }
@@ -9850,9 +10299,9 @@ If your heartbeat shows **no active task** and **no next task**:
9850
10299
  };
9851
10300
  }
9852
10301
  const item = await contentManager.upsertCalendarItem(body);
9853
- // Update presence when adding content to calendar
10302
+ // Touch presence when adding content to calendar
9854
10303
  if (body.createdBy) {
9855
- presenceManager.updatePresence(body.createdBy, 'working');
10304
+ presenceManager.touchPresence(body.createdBy);
9856
10305
  }
9857
10306
  return { success: true, item };
9858
10307
  }