reflectt-node 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +60 -151
  2. package/dist/alert-preflight.d.ts +28 -0
  3. package/dist/alert-preflight.d.ts.map +1 -1
  4. package/dist/alert-preflight.js +178 -0
  5. package/dist/alert-preflight.js.map +1 -1
  6. package/dist/boardHealthWorker.d.ts.map +1 -1
  7. package/dist/boardHealthWorker.js +25 -12
  8. package/dist/boardHealthWorker.js.map +1 -1
  9. package/dist/chat-approval-detector.d.ts.map +1 -1
  10. package/dist/chat-approval-detector.js +29 -11
  11. package/dist/chat-approval-detector.js.map +1 -1
  12. package/dist/chat.d.ts +1 -0
  13. package/dist/chat.d.ts.map +1 -1
  14. package/dist/chat.js +14 -3
  15. package/dist/chat.js.map +1 -1
  16. package/dist/cli.js +187 -22
  17. package/dist/cli.js.map +1 -1
  18. package/dist/cloud.d.ts +28 -1
  19. package/dist/cloud.d.ts.map +1 -1
  20. package/dist/cloud.js +55 -20
  21. package/dist/cloud.js.map +1 -1
  22. package/dist/compliance-detector.d.ts +42 -0
  23. package/dist/compliance-detector.d.ts.map +1 -0
  24. package/dist/compliance-detector.js +286 -0
  25. package/dist/compliance-detector.js.map +1 -0
  26. package/dist/continuity-loop.d.ts.map +1 -1
  27. package/dist/continuity-loop.js +7 -3
  28. package/dist/continuity-loop.js.map +1 -1
  29. package/dist/dashboard.d.ts +6 -2
  30. package/dist/dashboard.d.ts.map +1 -1
  31. package/dist/dashboard.js +56 -26
  32. package/dist/dashboard.js.map +1 -1
  33. package/dist/doctor.d.ts +2 -0
  34. package/dist/doctor.d.ts.map +1 -1
  35. package/dist/doctor.js +78 -11
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/executionSweeper.d.ts.map +1 -1
  38. package/dist/executionSweeper.js +12 -0
  39. package/dist/executionSweeper.js.map +1 -1
  40. package/dist/health.d.ts.map +1 -1
  41. package/dist/health.js +30 -7
  42. package/dist/health.js.map +1 -1
  43. package/dist/hostConnectGuard.d.ts +25 -0
  44. package/dist/hostConnectGuard.d.ts.map +1 -0
  45. package/dist/hostConnectGuard.js +27 -0
  46. package/dist/hostConnectGuard.js.map +1 -0
  47. package/dist/index.js +144 -29
  48. package/dist/index.js.map +1 -1
  49. package/dist/insight-task-bridge.d.ts +22 -1
  50. package/dist/insight-task-bridge.d.ts.map +1 -1
  51. package/dist/insight-task-bridge.js +19 -4
  52. package/dist/insight-task-bridge.js.map +1 -1
  53. package/dist/mcp.js +6 -6
  54. package/dist/mcp.js.map +1 -1
  55. package/dist/notificationDedupeGuard.d.ts +33 -0
  56. package/dist/notificationDedupeGuard.d.ts.map +1 -0
  57. package/dist/notificationDedupeGuard.js +88 -0
  58. package/dist/notificationDedupeGuard.js.map +1 -0
  59. package/dist/policy.d.ts +1 -1
  60. package/dist/policy.d.ts.map +1 -1
  61. package/dist/policy.js +3 -1
  62. package/dist/policy.js.map +1 -1
  63. package/dist/preflight.d.ts.map +1 -1
  64. package/dist/preflight.js +7 -8
  65. package/dist/preflight.js.map +1 -1
  66. package/dist/reflection-automation.d.ts.map +1 -1
  67. package/dist/reflection-automation.js +38 -0
  68. package/dist/reflection-automation.js.map +1 -1
  69. package/dist/request-tracker.d.ts +13 -0
  70. package/dist/request-tracker.d.ts.map +1 -1
  71. package/dist/request-tracker.js +95 -16
  72. package/dist/request-tracker.js.map +1 -1
  73. package/dist/server.d.ts.map +1 -1
  74. package/dist/server.js +244 -58
  75. package/dist/server.js.map +1 -1
  76. package/dist/service-probe.d.ts.map +1 -1
  77. package/dist/service-probe.js +39 -2
  78. package/dist/service-probe.js.map +1 -1
  79. package/dist/shipped-heartbeat.d.ts +1 -1
  80. package/dist/shipped-heartbeat.js +1 -1
  81. package/dist/taskPrecheck.js +6 -6
  82. package/dist/taskPrecheck.js.map +1 -1
  83. package/dist/tasks.d.ts +1 -1
  84. package/dist/tasks.d.ts.map +1 -1
  85. package/dist/tasks.js +8 -5
  86. package/dist/tasks.js.map +1 -1
  87. package/dist/todoHoardingGuard.d.ts +35 -0
  88. package/dist/todoHoardingGuard.d.ts.map +1 -0
  89. package/dist/todoHoardingGuard.js +150 -0
  90. package/dist/todoHoardingGuard.js.map +1 -0
  91. package/dist/types.d.ts +2 -2
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/working-contract.d.ts.map +1 -1
  94. package/dist/working-contract.js +59 -3
  95. package/dist/working-contract.js.map +1 -1
  96. package/package.json +1 -1
  97. package/public/dashboard.js +94 -27
  98. package/public/docs.md +17 -2
  99. package/public/intensity-mock.html +413 -0
  100. package/public/polls-mock.html +610 -0
package/dist/server.js CHANGED
@@ -13,7 +13,7 @@ import { resolve, sep, join } from 'path';
13
13
  import { execSync } from 'child_process';
14
14
  import { serverConfig, openclawConfig, isDev, REFLECTT_HOME } 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 {
@@ -38,6 +38,7 @@ import { taskManager } from './tasks.js';
38
38
  import { detectApproval, applyApproval } from './chat-approval-detector.js';
39
39
  import { inboxManager } from './inbox.js';
40
40
  import { getDb } from './db.js';
41
+ import { isTestHarnessTask } from './test-task-filter.js';
41
42
  import { handleMCPRequest, handleSSERequest, handleMessagesRequest } from './mcp.js';
42
43
  import { memoryManager } from './memory.js';
43
44
  import { buildContextInjection, getContextBudgets, getContextMemo, upsertContextMemo } from './context-budget.js';
@@ -54,6 +55,7 @@ import { emitActivationEvent, getUserFunnelState, getFunnelSummary, hasCompleted
54
55
  import { alertUnauthorizedApproval, alertFlipAttempt, getMutationAlertStatus, pruneOldAttempts } from './mutationAlert.js';
55
56
  import { mentionAckTracker } from './mention-ack.js';
56
57
  import { analyticsManager } from './analytics.js';
58
+ import { processRequest as complianceProcessRequest, queryViolations, getViolationSummary } from './compliance-detector.js';
57
59
  import { getDashboardHTML } from './dashboard.js';
58
60
  import { healthMonitor, computeActiveLane } from './health.js';
59
61
  import { getSystemLoopTicks, recordSystemLoopTick } from './system-loop-state.js';
@@ -140,7 +142,7 @@ const CreateTaskSchema = z.object({
140
142
  title: z.string().min(1),
141
143
  type: z.enum(TASK_TYPES).optional(), // optional for backward compat, validated when present
142
144
  description: z.string().optional(),
143
- status: z.enum(['todo', 'doing', 'blocked', 'validating', 'done']).default('todo'),
145
+ status: z.enum(['todo', 'doing', 'blocked', 'validating', 'done', 'cancelled']).default('todo'),
144
146
  assignee: z.string().trim().min(1).optional().default('unassigned'),
145
147
  reviewer: z.string().trim().min(1).or(z.literal('auto')).default('auto'), // 'auto' triggers load-balanced assignment
146
148
  done_criteria: z.array(z.string().trim().min(1)).optional().default([]),
@@ -267,7 +269,7 @@ function normalizeConfiguredModel(value) {
267
269
  const UpdateTaskSchema = z.object({
268
270
  title: z.string().min(1).optional(),
269
271
  description: z.string().optional(),
270
- status: z.enum(['todo', 'doing', 'blocked', 'validating', 'done']).optional(),
272
+ status: z.enum(['todo', 'doing', 'blocked', 'validating', 'done', 'cancelled']).optional(),
271
273
  assignee: z.string().optional(),
272
274
  reviewer: z.string().optional(),
273
275
  done_criteria: z.array(z.string().min(1)).optional(),
@@ -328,7 +330,7 @@ const CreateRecurringTaskSchema = z.object({
328
330
  metadata: z.record(z.unknown()).optional(),
329
331
  schedule: RecurringTaskScheduleSchema,
330
332
  enabled: z.boolean().optional(),
331
- status: z.enum(['todo', 'doing', 'blocked', 'validating', 'done']).optional(),
333
+ status: z.enum(['todo', 'doing', 'blocked', 'validating', 'done', 'cancelled']).optional(),
332
334
  });
333
335
  const UpdateRecurringTaskSchema = z.object({
334
336
  enabled: z.boolean().optional(),
@@ -1325,33 +1327,47 @@ function extractMentions(content) {
1325
1327
  return resolveAgentMention(raw) || raw;
1326
1328
  }).filter(Boolean)));
1327
1329
  }
1328
- function buildAutonomyWarnings(content) {
1329
- const mentions = extractMentions(content);
1330
- if (mentions.length === 0)
1330
+ function getOwnerHandlesFromEnv() {
1331
+ const raw = String(process.env.REFLECTT_OWNER_HANDLES || '').trim();
1332
+ if (!raw)
1331
1333
  return [];
1332
- // Only warn when the message is explicitly directed at Ryan.
1333
- const directedAtRyan = mentions.includes('ryan') || mentions.includes('ryancampbell');
1334
- if (!directedAtRyan)
1334
+ return raw
1335
+ .split(',')
1336
+ .map(s => s.trim().toLowerCase())
1337
+ .filter(Boolean);
1338
+ }
1339
+ function isDirectedAtConfiguredOwner(content) {
1340
+ const owners = getOwnerHandlesFromEnv();
1341
+ if (owners.length === 0)
1342
+ return false;
1343
+ const mentions = extractMentions(content);
1344
+ return owners.some(o => mentions.includes(o));
1345
+ }
1346
+ function buildAutonomyWarnings(content) {
1347
+ // If no owner handles are configured, keep this feature off by default.
1348
+ // reflectt-node must remain generic for customers.
1349
+ if (!isDirectedAtConfiguredOwner(content))
1335
1350
  return [];
1336
1351
  const normalized = content.toLowerCase();
1337
- // Detect the specific anti-pattern: asking leadership what to do next.
1352
+ // Detect the specific anti-pattern: asking a human leader/operator what to do next.
1338
1353
  // Keep the pattern narrow to avoid false positives on legitimate asks.
1339
1354
  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
1355
  if (!approvalSeeking.test(normalized))
1341
1356
  return [];
1342
1357
  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.',
1358
+ '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
1359
  ];
1345
1360
  }
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)
1361
+ function validateOwnerApprovalPing(content, from, channel) {
1362
+ const owners = getOwnerHandlesFromEnv();
1363
+ if (owners.length === 0)
1364
+ return {};
1365
+ // Only gate messages directed at configured owner handles.
1366
+ if (!isDirectedAtConfiguredOwner(content))
1351
1367
  return {};
1352
- // Don't gate Ryan/system talking to themselves.
1368
+ // Don't gate the owner/system talking to themselves.
1353
1369
  const sender = String(from || '').toLowerCase();
1354
- if (sender === 'ryan' || sender === 'system')
1370
+ if (owners.includes(sender) || sender === 'system')
1355
1371
  return {};
1356
1372
  const normalized = content.toLowerCase();
1357
1373
  // We only care about PR approval/merge requests.
@@ -1359,15 +1375,15 @@ function validateRyanApprovalPing(content, from, channel) {
1359
1375
  (/(\bpr\b|pull request|github\.com\/[^\s]+\/pull\/[0-9]+|#\d+)/i.test(normalized));
1360
1376
  if (!looksLikePrRequest)
1361
1377
  return {};
1362
- // Allow if the message explicitly explains why Ryan is required and references a task id.
1378
+ // Allow if the message explicitly explains why a human is required and references a task id.
1363
1379
  const hasTaskId = hasTaskIdReference(content);
1364
1380
  const hasPermissionsReason = /(permission|permissions|auth|authed|rights|cannot|can\s*not|can't|blocked|branch protection|required)/i.test(normalized);
1365
1381
  if (hasTaskId && hasPermissionsReason)
1366
1382
  return {};
1367
1383
  const normalizedChannel = (channel || 'general').toLowerCase();
1368
1384
  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.',
1385
+ 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.`,
1386
+ 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
1387
  };
1372
1388
  }
1373
1389
  function buildMentionWarnings(content) {
@@ -1553,6 +1569,11 @@ export async function createServer() {
1553
1569
  healthMonitor.trackRequest(duration);
1554
1570
  trackTelemetryRequest(request.method, request.url, reply.statusCode, duration);
1555
1571
  trackRequest(request.method, request.url, reply.statusCode, request.headers['user-agent']);
1572
+ // Compliance detector: flag state-read-before-assertion violations
1573
+ try {
1574
+ complianceProcessRequest(request.method, request.url, reply.statusCode, request.query ?? {}, request.body, request.headers);
1575
+ }
1576
+ catch { /* never let compliance logging break a request */ }
1556
1577
  if (reply.statusCode >= 400) {
1557
1578
  healthMonitor.trackError();
1558
1579
  // Normalize URL before telemetry to prevent PII leaks in query params
@@ -1758,7 +1779,7 @@ export async function createServer() {
1758
1779
  reflectionPipelineHealth.recentPromotions = recentPromotions;
1759
1780
  reflectionPipelineHealth.lastCheckedAt = now;
1760
1781
  if (recentReflections === 0) {
1761
- reflectionPipelineHealth.status = 'healthy';
1782
+ reflectionPipelineHealth.status = 'idle';
1762
1783
  reflectionPipelineHealth.firstZeroInsightAt = 0;
1763
1784
  return reflectionPipelineHealth;
1764
1785
  }
@@ -1791,7 +1812,7 @@ export async function createServer() {
1791
1812
  chatManager.sendMessage({
1792
1813
  channel: 'general',
1793
1814
  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.`,
1815
+ content: `🚨 Reflection pipeline broken: ${health.recentReflections} reflections in last ${health.windowMin}m but 0 recentInsightActivity (created+updated). @link @sage investigate ingestion/listener path.`,
1795
1816
  }).catch(() => { });
1796
1817
  }
1797
1818
  }
@@ -1895,7 +1916,7 @@ export async function createServer() {
1895
1916
  inbox: inboxManager.getStats(),
1896
1917
  request_counts: (() => {
1897
1918
  const m = getRequestMetrics();
1898
- return { total: m.total, errors: m.errors, rps: m.rps, byGroup: m.byGroup };
1919
+ return { total: m.total, errors: m.errors, rps: m.rps, byGroup: m.byGroup, rolling: m.rolling };
1899
1920
  })(),
1900
1921
  timestamp: Date.now(),
1901
1922
  };
@@ -1907,7 +1928,9 @@ export async function createServer() {
1907
1928
  total_errors: m.errors,
1908
1929
  total_requests: m.total,
1909
1930
  error_rate: m.total > 0 ? Math.round((m.errors / m.total) * 10000) / 100 : 0,
1931
+ rolling: m.rolling,
1910
1932
  recent: m.recentErrors.slice(0, 20),
1933
+ top_buckets: m.topErrorBuckets,
1911
1934
  timestamp: Date.now(),
1912
1935
  };
1913
1936
  });
@@ -2187,8 +2210,25 @@ export async function createServer() {
2187
2210
  });
2188
2211
  // ─── Alert preflight metrics ───
2189
2212
  app.get('/health/alert-preflight', async () => {
2213
+ snapshotDailyMetrics(); // Persist daily checkpoint on health check
2190
2214
  return { ...getPreflightMetrics(), timestamp: Date.now() };
2191
2215
  });
2216
+ app.get('/health/alert-preflight/history', async () => {
2217
+ snapshotDailyMetrics();
2218
+ return { snapshots: getDailySnapshots(), currentSession: getPreflightMetrics() };
2219
+ });
2220
+ // ─── Todo hoarding health: orphan detection + auto-unassign status ───
2221
+ app.get('/health/hoarding', async (request) => {
2222
+ const query = request.query;
2223
+ const dryRun = query.dry_run !== '0' && query.dry_run !== 'false'; // default: dry run
2224
+ const { sweepTodoHoarding, TODO_CAP, IDLE_THRESHOLD_MS } = await import('./todoHoardingGuard.js');
2225
+ const result = await sweepTodoHoarding({ dryRun });
2226
+ return {
2227
+ ...result,
2228
+ config: { todoCap: TODO_CAP, idleThresholdMinutes: Math.round(IDLE_THRESHOLD_MS / 60000) },
2229
+ dryRun,
2230
+ };
2231
+ });
2192
2232
  // ─── Backlog health: ready counts per lane, breach status, floor compliance ───
2193
2233
  app.get('/health/backlog', async (request, reply) => {
2194
2234
  const query = request.query;
@@ -2205,7 +2245,7 @@ export async function createServer() {
2205
2245
  return false;
2206
2246
  return task.blocked_by.some((blockerId) => {
2207
2247
  const blocker = taskManager.getTask(blockerId);
2208
- return blocker && blocker.status !== 'done';
2248
+ return blocker && !['done', 'resolved_externally', 'cancelled'].includes(blocker.status);
2209
2249
  });
2210
2250
  };
2211
2251
  // Definition-of-ready gate for backlog readiness: todo + required fields + unblocked
@@ -2216,14 +2256,23 @@ export async function createServer() {
2216
2256
  const hasDoneCriteria = Array.isArray(task.done_criteria) && task.done_criteria.length > 0;
2217
2257
  return hasTitle && hasPriority && hasReviewer && hasDoneCriteria;
2218
2258
  };
2259
+ // Count tasks missing metadata.lane for visibility
2260
+ const missingLaneCount = allTasks.filter(t => !t.metadata?.lane && !['done', 'cancelled', 'resolved_externally'].includes(t.status)).length;
2219
2261
  // Build per-lane health
2262
+ // Task belongs to a lane if: (1) metadata.lane matches, OR (2) assignee is in lane agents (fallback)
2220
2263
  const laneHealth = Object.entries(lanes).map(([laneName, config]) => {
2221
- const laneTasks = allTasks.filter(t => config.agents.includes(t.assignee || ''));
2264
+ const laneTasks = allTasks.filter(t => {
2265
+ const taskLane = t.metadata?.lane;
2266
+ if (taskLane)
2267
+ return taskLane === laneName;
2268
+ return config.agents.includes(t.assignee || '');
2269
+ });
2222
2270
  const todo = laneTasks.filter(t => t.status === 'todo');
2223
2271
  const doing = laneTasks.filter(t => t.status === 'doing');
2224
2272
  const validating = laneTasks.filter(t => t.status === 'validating');
2225
2273
  const blocked = laneTasks.filter(t => t.status === 'blocked' || (t.status === 'todo' && isBlocked(t)));
2226
2274
  const done = laneTasks.filter(t => t.status === 'done');
2275
+ const resolvedExternally = laneTasks.filter(t => t.status === 'resolved_externally');
2227
2276
  // Ready = todo + required fields + unblocked
2228
2277
  const ready = todo.filter(t => !isBlocked(t) && hasRequiredFields(t));
2229
2278
  const notReady = todo.filter(t => isBlocked(t) || !hasRequiredFields(t));
@@ -2259,6 +2308,7 @@ export async function createServer() {
2259
2308
  validating: validating.length,
2260
2309
  blocked: blocked.length,
2261
2310
  done: done.length,
2311
+ resolvedExternally: resolvedExternally.length,
2262
2312
  },
2263
2313
  compliance: {
2264
2314
  status: floorBreaches.length > 0 ? 'breach' : ready.length >= config.readyFloor ? 'healthy' : 'warning',
@@ -2306,6 +2356,7 @@ export async function createServer() {
2306
2356
  breachedLaneCount: breachedLanes.length,
2307
2357
  overallStatus: breachedLanes.length > 0 ? 'breach' : totalReady === 0 ? 'critical' : 'healthy',
2308
2358
  staleValidatingCount: staleValidating.length,
2359
+ missingLaneMetadata: missingLaneCount,
2309
2360
  },
2310
2361
  lanes: laneHealth,
2311
2362
  staleValidating,
@@ -2732,8 +2783,11 @@ export async function createServer() {
2732
2783
  app.get('/', async (_request, reply) => {
2733
2784
  reply.redirect('/dashboard');
2734
2785
  });
2735
- app.get('/dashboard', async (_request, reply) => {
2736
- reply.type('text/html').send(getDashboardHTML());
2786
+ app.get('/dashboard', async (request, reply) => {
2787
+ const envFlag = process.env.REFLECTT_INTERNAL_UI === '1';
2788
+ const queryFlag = request.query?.internal === '1';
2789
+ const internalMode = envFlag && queryFlag;
2790
+ reply.type('text/html').send(getDashboardHTML({ internalMode }));
2737
2791
  });
2738
2792
  // API docs page (markdown — token-efficient for agents)
2739
2793
  // UI Kit reference page — living component/states documentation
@@ -2767,13 +2821,13 @@ export async function createServer() {
2767
2821
  reply.code(500).send({ error: 'Failed to load docs' });
2768
2822
  }
2769
2823
  });
2770
- // Serve avatar images
2824
+ // Serve avatar images (with fallback for missing avatars)
2771
2825
  app.get('/avatars/:filename', async (request, reply) => {
2772
2826
  const { filename } = request.params;
2773
- // Basic security: only allow .png files with alphanumeric names
2774
- if (!/^[a-z]+\.png$/.test(filename)) {
2775
- return reply.code(404).send({ error: 'Not found' });
2776
- }
2827
+ // Security: allow alphanumeric, hyphens, underscores + .png/.svg extension
2828
+ // (no slashes / traversal). If invalid, still return a safe default avatar (200)
2829
+ // to avoid error-rate pollution from bad/mismatched filenames.
2830
+ const safe = /^[a-z0-9_-]+\.(png|svg)$/i.test(filename);
2777
2831
  try {
2778
2832
  const { promises: fs } = await import('fs');
2779
2833
  const { join } = await import('path');
@@ -2782,13 +2836,24 @@ export async function createServer() {
2782
2836
  const __filename = fileURLToPath(import.meta.url);
2783
2837
  const __dirname = dirname(__filename);
2784
2838
  const publicDir = join(__dirname, '..', 'public', 'avatars');
2785
- const filePath = join(publicDir, filename);
2786
- const data = await fs.readFile(filePath);
2787
- reply.type('image/png').send(data);
2839
+ 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;
2845
+ }
2788
2846
  }
2789
- catch (err) {
2790
- reply.code(404).send({ error: 'Avatar not found' });
2847
+ catch {
2848
+ // fall through to default avatar below
2791
2849
  }
2850
+ // Default avatar: render an initial when possible, else '?'
2851
+ const initial = safe ? (filename.replace(/\.(png|svg)$/i, '').charAt(0) || '?').toUpperCase() : '?';
2852
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
2853
+ <rect width="64" height="64" rx="12" fill="#21262d"/>
2854
+ <text x="32" y="38" text-anchor="middle" font-family="system-ui,sans-serif" font-size="24" font-weight="600" fill="#8d96a0">${initial}</text>
2855
+ </svg>`;
2856
+ reply.type('image/svg+xml').header('Cache-Control', 'public, max-age=3600').send(svg);
2792
2857
  });
2793
2858
  // Serve dashboard JS (extracted from inline template)
2794
2859
  app.get('/dashboard.js', async (_request, reply) => {
@@ -2932,14 +2997,14 @@ export async function createServer() {
2932
2997
  hint: actionValidation.hint,
2933
2998
  };
2934
2999
  }
2935
- const ryanApprovalGate = validateRyanApprovalPing(data.content, data.from, data.channel);
2936
- if (ryanApprovalGate.blockingError) {
3000
+ const ownerApprovalGate = validateOwnerApprovalPing(data.content, data.from, data.channel);
3001
+ if (ownerApprovalGate.blockingError) {
2937
3002
  reply.code(400);
2938
3003
  return {
2939
3004
  success: false,
2940
- error: ryanApprovalGate.blockingError,
2941
- gate: 'ryan_approval_gate',
2942
- hint: ryanApprovalGate.hint,
3005
+ error: ownerApprovalGate.blockingError,
3006
+ gate: 'owner_approval_gate',
3007
+ hint: ownerApprovalGate.hint,
2943
3008
  };
2944
3009
  }
2945
3010
  const message = await chatManager.sendMessage(data);
@@ -3149,7 +3214,7 @@ export async function createServer() {
3149
3214
  return false;
3150
3215
  return t.blocked_by.some((blockerId) => {
3151
3216
  const blocker = taskManager.getTask(blockerId);
3152
- return blocker && blocker.status !== 'done';
3217
+ return blocker && !['done', 'resolved_externally', 'cancelled'].includes(blocker.status);
3153
3218
  });
3154
3219
  };
3155
3220
  const nextTodo = taskManager
@@ -4826,7 +4891,13 @@ export async function createServer() {
4826
4891
  // fan out inbox-visible notifications to assignee/reviewer + explicit @mentions.
4827
4892
  // Notification routing respects per-agent preferences (quiet hours, mute, filters).
4828
4893
  const task = taskManager.getTask(resolved.resolvedId);
4829
- if (task && !comment.suppressed) {
4894
+ // Never fan out notifications for test-harness tasks.
4895
+ // Our repo contains a few "LIVE server" tests (BASE=127.0.0.1:4445) that create
4896
+ // tasks/comments with metadata.is_test=true. Without this guard, running `npm test`
4897
+ // on a machine with a live node will spam real chat channels and look like a human
4898
+ // (e.g. @link) posted the comment.
4899
+ const shouldFanOut = task ? !isTestHarnessTask(task) : false;
4900
+ if (task && !comment.suppressed && shouldFanOut) {
4830
4901
  const targets = new Set();
4831
4902
  if (task.assignee)
4832
4903
  targets.add(task.assignee);
@@ -5397,7 +5468,7 @@ export async function createServer() {
5397
5468
  // Deduplication: check existing tasks for similar titles
5398
5469
  if (data.deduplicate) {
5399
5470
  const existingTasks = taskManager.listTasks({});
5400
- const activeTasks = existingTasks.filter(t => t.status !== 'done');
5471
+ const activeTasks = existingTasks.filter(t => t.status !== 'done' && t.status !== 'cancelled' && t.status !== 'resolved_externally');
5401
5472
  const normalizedNew = taskData.title.toLowerCase().trim();
5402
5473
  // Exact title match
5403
5474
  const exactMatch = activeTasks.find(t => t.title.toLowerCase().trim() === normalizedNew);
@@ -5884,12 +5955,13 @@ export async function createServer() {
5884
5955
  // Must run before all other gates to give a clear rejection message.
5885
5956
  if (parsed.status && parsed.status !== existing.status) {
5886
5957
  const ALLOWED_TRANSITIONS = {
5887
- 'todo': ['doing'],
5888
- 'doing': ['blocked', 'validating'],
5889
- 'blocked': ['doing', 'todo'],
5958
+ 'todo': ['doing', 'cancelled'],
5959
+ 'doing': ['blocked', 'validating', 'cancelled'],
5960
+ 'blocked': ['doing', 'todo', 'cancelled'],
5890
5961
  'validating': ['done', 'doing'], // doing = reviewer rejection / rework
5891
5962
  'done': [], // all exits require reopen
5892
- 'in-progress': ['blocked', 'validating', 'done', 'doing', 'todo'], // legacy, permissive
5963
+ 'cancelled': [], // terminal state, like done requires reopen to revive
5964
+ 'in-progress': ['blocked', 'validating', 'done', 'doing', 'todo', 'cancelled'], // legacy, permissive
5893
5965
  };
5894
5966
  const allowed = ALLOWED_TRANSITIONS[existing.status] ?? [];
5895
5967
  if (!allowed.includes(parsed.status)) {
@@ -5914,6 +5986,24 @@ export async function createServer() {
5914
5986
  mergedMeta.reopened_from = existing.status;
5915
5987
  }
5916
5988
  }
5989
+ // ── Cancel reason gate: require cancel_reason when transitioning to cancelled ──
5990
+ if (parsed.status === 'cancelled') {
5991
+ const meta = (incomingMeta ?? {});
5992
+ const cancelReason = typeof meta.cancel_reason === 'string' ? String(meta.cancel_reason).trim() : '';
5993
+ if (!cancelReason) {
5994
+ reply.code(422);
5995
+ return {
5996
+ success: false,
5997
+ error: 'Cancellation requires a cancel_reason in metadata (e.g. "duplicate", "out of scope", "won\'t fix").',
5998
+ code: 'CANCEL_REASON_REQUIRED',
5999
+ gate: 'cancel_reason',
6000
+ hint: 'Include metadata.cancel_reason explaining why this task is being cancelled.',
6001
+ };
6002
+ }
6003
+ mergedMeta.cancel_reason = cancelReason;
6004
+ mergedMeta.cancelled_at = Date.now();
6005
+ mergedMeta.cancelled_from = existing.status;
6006
+ }
5917
6007
  // Reviewer-identity gate: only assigned reviewer can set reviewer_approved=true.
5918
6008
  const incomingReviewerApproved = incomingMeta.reviewer_approved;
5919
6009
  if (incomingReviewerApproved === true) {
@@ -6432,7 +6522,21 @@ export async function createServer() {
6432
6522
  if (task.reviewer)
6433
6523
  statusNotifTargets.push({ agent: task.reviewer, type: 'taskCompleted' });
6434
6524
  }
6525
+ // Dedupe guard: prevent stale/out-of-order notification events
6526
+ const { shouldEmitNotification } = await import('./notificationDedupeGuard.js');
6435
6527
  for (const target of statusNotifTargets) {
6528
+ // Check dedupe guard before emitting
6529
+ const dedupeCheck = shouldEmitNotification({
6530
+ taskId: task.id,
6531
+ eventUpdatedAt: task.updatedAt,
6532
+ eventStatus: parsed.status,
6533
+ currentTaskStatus: task.status,
6534
+ currentTaskUpdatedAt: task.updatedAt,
6535
+ });
6536
+ if (!dedupeCheck.emit) {
6537
+ console.log(`[NotifDedupe] Suppressed: ${dedupeCheck.reason}`);
6538
+ continue;
6539
+ }
6436
6540
  const routing = notifMgr.shouldNotify({
6437
6541
  type: target.type,
6438
6542
  agent: target.agent,
@@ -6449,6 +6553,7 @@ export async function createServer() {
6449
6553
  kind: target.type,
6450
6554
  taskId: task.id,
6451
6555
  status: parsed.status,
6556
+ updatedAt: task.updatedAt,
6452
6557
  deliveryMethod: routing.deliveryMethod,
6453
6558
  },
6454
6559
  }).catch(() => { }); // Non-blocking
@@ -6733,6 +6838,40 @@ export async function createServer() {
6733
6838
  }
6734
6839
  return { success: true, agent, displayName };
6735
6840
  });
6841
+ // ── Write TEAM-ROLES.yaml (used by bootstrap agent to configure the team) ──
6842
+ app.put('/config/team-roles', async (request, reply) => {
6843
+ const body = request.body;
6844
+ const yaml = typeof body.yaml === 'string' ? body.yaml.trim() : '';
6845
+ if (!yaml) {
6846
+ reply.code(400);
6847
+ return { success: false, error: 'yaml field is required (TEAM-ROLES.yaml content)' };
6848
+ }
6849
+ // Basic validation: must contain 'agents:' and at least one agent name
6850
+ if (!yaml.includes('agents:')) {
6851
+ reply.code(400);
6852
+ return { success: false, error: 'Invalid TEAM-ROLES.yaml: must contain "agents:" section' };
6853
+ }
6854
+ try {
6855
+ const { writeFileSync } = await import('node:fs');
6856
+ const { join } = await import('node:path');
6857
+ const filePath = join(REFLECTT_HOME, 'TEAM-ROLES.yaml');
6858
+ writeFileSync(filePath, yaml, 'utf-8');
6859
+ // Hot-reload the team config
6860
+ const { loadAgentRoles } = await import('./assignment.js');
6861
+ const reloaded = loadAgentRoles();
6862
+ return {
6863
+ success: true,
6864
+ path: filePath,
6865
+ agents: reloaded.roles.length,
6866
+ hint: 'TEAM-ROLES.yaml saved and hot-reloaded. Agents will pick up new routing immediately.',
6867
+ };
6868
+ }
6869
+ catch (err) {
6870
+ const msg = err instanceof Error ? err.message : 'Failed to write TEAM-ROLES.yaml';
6871
+ reply.code(500);
6872
+ return { success: false, error: msg };
6873
+ }
6874
+ });
6736
6875
  // Resolve a mention string (name, displayName, or alias) to an agent ID
6737
6876
  app.get('/resolve/mention/:mention', async (request) => {
6738
6877
  const agentName = resolveAgentMention(request.params.mention);
@@ -8275,6 +8414,16 @@ export async function createServer() {
8275
8414
  if (!task) {
8276
8415
  return { task: null, message: 'No available tasks' };
8277
8416
  }
8417
+ // Rule C: auto-claim (todo→doing) when ?claim=1
8418
+ const shouldClaim = query.claim === '1' || query.claim === 'true';
8419
+ if (shouldClaim && agent && task.status === 'todo') {
8420
+ const { claimTask } = await import('./todoHoardingGuard.js');
8421
+ const claimed = await claimTask(task.id, agent);
8422
+ if (claimed) {
8423
+ const enriched = enrichTaskWithComments(claimed);
8424
+ return { task: isCompact(query) ? compactTask(enriched) : enriched, claimed: true };
8425
+ }
8426
+ }
8278
8427
  const enriched = enrichTaskWithComments(task);
8279
8428
  return { task: isCompact(query) ? compactTask(enriched) : enriched };
8280
8429
  });
@@ -8412,12 +8561,22 @@ export async function createServer() {
8412
8561
  3. If inbox has messages, respond to direct mentions.
8413
8562
  4. **Never report task status from memory alone** — always query the API first.
8414
8563
 
8415
- ## Comms Protocol (required)
8564
+ ## No-Task Idle Loop (recommended)
8565
+ If your heartbeat shows **no active task** and **no next task**:
8566
+ 1. Post a brief status in team chat **once per hour max**: "[no task] checking board + signals".
8567
+ 2. Check the board + top signals:
8568
+ - \`curl -s "${baseUrl}/tasks?status=todo&limit=5&compact=true"\`
8569
+ - \`curl -s "${baseUrl}/loop/summary?compact=true"\`
8570
+ 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.
8571
+ 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
+ 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
+
8574
+ ## Comms Protocol (recommended)
8416
8575
  1. **Status updates belong in task comments first** (\`POST /tasks/:id/comments\`).
8417
- 2. **Shipped artifacts go to \`#shipping\`** and must include \`@reviewer\` + task ID + PR/artifact link.
8418
- 3. **Review requests go to \`#reviews\`** and must include \`@reviewer\` + task ID + exact ask.
8419
- 4. **Blockers go to \`#blockers\`** and must include \`@kai\` + task ID + concrete unblock needed.
8420
- 5. **\`#general\` is decisions/cross-team coordination only** (not routine heartbeat chatter).
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).
8421
8580
 
8422
8581
  ## API Quick Reference
8423
8582
  - Heartbeat check: \`GET /heartbeat/${agent}\`
@@ -9818,8 +9977,17 @@ export async function createServer() {
9818
9977
  });
9819
9978
  // ============ CLOUD INTEGRATION (see docs/CLOUD_ENDPOINTS.md) ============
9820
9979
  app.get('/cloud/status', async () => {
9821
- const { getCloudStatus } = await import('./cloud.js');
9822
- return getCloudStatus();
9980
+ const { getCloudStatus, getConnectionHealth, getConnectionEvents } = await import('./cloud.js');
9981
+ return {
9982
+ ...getCloudStatus(),
9983
+ connectionHealth: getConnectionHealth(),
9984
+ };
9985
+ });
9986
+ app.get('/cloud/events', async (request) => {
9987
+ const { getConnectionEvents } = await import('./cloud.js');
9988
+ const url = new URL(request.url, 'http://localhost');
9989
+ const limit = Math.min(Number(url.searchParams.get('limit')) || 50, 100);
9990
+ return { events: getConnectionEvents(limit) };
9823
9991
  });
9824
9992
  app.post('/cloud/reload', async () => {
9825
9993
  const { stopCloudIntegration, startCloudIntegration, getCloudStatus } = await import('./cloud.js');
@@ -10416,6 +10584,22 @@ export async function createServer() {
10416
10584
  // Prune old mutation alert tracking every 30 minutes
10417
10585
  const pruneTimer = setInterval(pruneOldAttempts, 30 * 60 * 1000);
10418
10586
  pruneTimer.unref();
10587
+ // GET /compliance/violations — state-read-before-assertion compliance violations
10588
+ app.get('/compliance/violations', async (request, reply) => {
10589
+ const query = request.query;
10590
+ const agent = query.agent || undefined;
10591
+ const severity = query.severity || undefined;
10592
+ const limit = Math.min(parseInt(query.limit || '100', 10) || 100, 1000);
10593
+ const since = query.since ? parseInt(query.since, 10) : undefined;
10594
+ const violations = queryViolations({ agent, severity, limit, since });
10595
+ const summary = getViolationSummary(since);
10596
+ reply.send({
10597
+ violations,
10598
+ count: violations.length,
10599
+ summary,
10600
+ query: { agent: agent ?? null, severity: severity ?? null, limit, since: since ?? null },
10601
+ });
10602
+ });
10419
10603
  // GET /audit/reviews — review-field mutation audit ledger
10420
10604
  app.get('/audit/reviews', async (request, reply) => {
10421
10605
  const query = request.query;
@@ -10882,6 +11066,8 @@ export async function createServer() {
10882
11066
  const result = calendarEvents.getAgentNextEvent(query.agent);
10883
11067
  return { agent: query.agent, next_event: result?.event || null, starts_at: result?.starts_at || null };
10884
11068
  });
11069
+ // Start hourly auto-snapshot for alert-preflight daily metrics
11070
+ startAutoSnapshot();
10885
11071
  return app;
10886
11072
  }
10887
11073
  //# sourceMappingURL=server.js.map