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.
- package/README.md +63 -146
- package/defaults/TEAM-ROLES.yaml +221 -31
- package/dist/activationEvents.d.ts +13 -2
- package/dist/activationEvents.d.ts.map +1 -1
- package/dist/activationEvents.js +172 -38
- package/dist/activationEvents.js.map +1 -1
- package/dist/activity.d.ts +72 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/activity.js +510 -0
- package/dist/activity.js.map +1 -0
- package/dist/alert-preflight.d.ts +33 -0
- package/dist/alert-preflight.d.ts.map +1 -1
- package/dist/alert-preflight.js +218 -2
- package/dist/alert-preflight.js.map +1 -1
- package/dist/assignment.d.ts.map +1 -1
- package/dist/assignment.js +11 -6
- package/dist/assignment.js.map +1 -1
- package/dist/boardHealthWorker.d.ts.map +1 -1
- package/dist/boardHealthWorker.js +25 -12
- package/dist/boardHealthWorker.js.map +1 -1
- package/dist/canvas-slots.d.ts +1 -1
- package/dist/channels.d.ts +1 -1
- package/dist/chat-approval-detector.d.ts.map +1 -1
- package/dist/chat-approval-detector.js +29 -11
- package/dist/chat-approval-detector.js.map +1 -1
- package/dist/chat.d.ts +14 -0
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +68 -4
- package/dist/chat.js.map +1 -1
- package/dist/cli.js +349 -28
- package/dist/cli.js.map +1 -1
- package/dist/cloud.d.ts +28 -1
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js +62 -25
- package/dist/cloud.js.map +1 -1
- package/dist/compliance-detector.d.ts +42 -0
- package/dist/compliance-detector.d.ts.map +1 -0
- package/dist/compliance-detector.js +286 -0
- package/dist/compliance-detector.js.map +1 -0
- package/dist/continuity-loop.d.ts.map +1 -1
- package/dist/continuity-loop.js +7 -3
- package/dist/continuity-loop.js.map +1 -1
- package/dist/dashboard.d.ts +6 -2
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/dashboard.js +84 -28
- package/dist/dashboard.js.map +1 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +24 -1
- package/dist/db.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +17 -6
- package/dist/doctor.js.map +1 -1
- package/dist/executionSweeper.d.ts +2 -0
- package/dist/executionSweeper.d.ts.map +1 -1
- package/dist/executionSweeper.js +60 -4
- package/dist/executionSweeper.js.map +1 -1
- package/dist/focus.d.ts +20 -0
- package/dist/focus.d.ts.map +1 -0
- package/dist/focus.js +57 -0
- package/dist/focus.js.map +1 -0
- package/dist/health.d.ts +1 -0
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +47 -15
- package/dist/health.js.map +1 -1
- package/dist/hostConnectGuard.d.ts +25 -0
- package/dist/hostConnectGuard.d.ts.map +1 -0
- package/dist/hostConnectGuard.js +27 -0
- package/dist/hostConnectGuard.js.map +1 -0
- package/dist/index.js +257 -39
- package/dist/index.js.map +1 -1
- package/dist/insight-mutation.d.ts +26 -0
- package/dist/insight-mutation.d.ts.map +1 -1
- package/dist/insight-mutation.js +103 -12
- package/dist/insight-mutation.js.map +1 -1
- package/dist/insight-task-bridge.d.ts +1 -1
- package/dist/insight-task-bridge.d.ts.map +1 -1
- package/dist/insight-task-bridge.js +6 -3
- package/dist/insight-task-bridge.js.map +1 -1
- package/dist/insights.d.ts +20 -0
- package/dist/insights.d.ts.map +1 -1
- package/dist/insights.js +129 -4
- package/dist/insights.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +9 -8
- package/dist/mcp.js.map +1 -1
- package/dist/notificationDedupeGuard.d.ts +33 -0
- package/dist/notificationDedupeGuard.d.ts.map +1 -0
- package/dist/notificationDedupeGuard.js +88 -0
- package/dist/notificationDedupeGuard.js.map +1 -0
- package/dist/openclaw.d.ts.map +1 -1
- package/dist/openclaw.js +3 -2
- package/dist/openclaw.js.map +1 -1
- package/dist/policy.d.ts +1 -1
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +3 -1
- package/dist/policy.js.map +1 -1
- package/dist/prAutoMerge.d.ts.map +1 -1
- package/dist/prAutoMerge.js +23 -0
- package/dist/prAutoMerge.js.map +1 -1
- package/dist/presence.d.ts +16 -1
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +97 -9
- package/dist/presence.js.map +1 -1
- package/dist/pulse.d.ts +60 -0
- package/dist/pulse.d.ts.map +1 -0
- package/dist/pulse.js +139 -0
- package/dist/pulse.js.map +1 -0
- package/dist/reflection-automation.d.ts.map +1 -1
- package/dist/reflection-automation.js +38 -0
- package/dist/reflection-automation.js.map +1 -1
- package/dist/release.d.ts +2 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +14 -1
- package/dist/release.js.map +1 -1
- package/dist/request-tracker.d.ts +6 -0
- package/dist/request-tracker.d.ts.map +1 -1
- package/dist/request-tracker.js +31 -12
- package/dist/request-tracker.js.map +1 -1
- package/dist/scopeOverlap.d.ts +32 -0
- package/dist/scopeOverlap.d.ts.map +1 -0
- package/dist/scopeOverlap.js +219 -0
- package/dist/scopeOverlap.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +736 -117
- package/dist/server.js.map +1 -1
- package/dist/service-probe.d.ts.map +1 -1
- package/dist/service-probe.js +39 -2
- package/dist/service-probe.js.map +1 -1
- package/dist/shipped-heartbeat.d.ts +1 -1
- package/dist/shipped-heartbeat.js +1 -1
- package/dist/taskPrecheck.js +6 -6
- package/dist/taskPrecheck.js.map +1 -1
- package/dist/tasks-next-diagnostics.d.ts +15 -0
- package/dist/tasks-next-diagnostics.d.ts.map +1 -0
- package/dist/tasks-next-diagnostics.js +33 -0
- package/dist/tasks-next-diagnostics.js.map +1 -0
- package/dist/tasks.d.ts +3 -2
- package/dist/tasks.d.ts.map +1 -1
- package/dist/tasks.js +41 -16
- package/dist/tasks.js.map +1 -1
- package/dist/team-config.d.ts.map +1 -1
- package/dist/team-config.js +20 -0
- package/dist/team-config.js.map +1 -1
- package/dist/todoHoardingGuard.d.ts +35 -0
- package/dist/todoHoardingGuard.d.ts.map +1 -0
- package/dist/todoHoardingGuard.js +150 -0
- package/dist/todoHoardingGuard.js.map +1 -0
- package/dist/types.d.ts +4 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +16 -0
- package/dist/version.js.map +1 -0
- package/dist/working-contract.d.ts.map +1 -1
- package/dist/working-contract.js +59 -3
- package/dist/working-contract.js.map +1 -1
- package/package.json +5 -1
- package/public/dashboard.js +161 -20
- package/public/docs.md +68 -8
- 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 {
|
|
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:
|
|
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:
|
|
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
|
|
1329
|
-
const
|
|
1330
|
-
if (
|
|
1354
|
+
function getOwnerHandlesFromEnv() {
|
|
1355
|
+
const raw = String(process.env.REFLECTT_OWNER_HANDLES || '').trim();
|
|
1356
|
+
if (!raw)
|
|
1331
1357
|
return [];
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
|
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
|
|
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
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
1370
|
-
hint: 'If
|
|
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 = '
|
|
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
|
|
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
|
|
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 &&
|
|
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 =>
|
|
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 (
|
|
2737
|
-
|
|
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
|
-
//
|
|
2775
|
-
|
|
2776
|
-
|
|
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
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
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
|
|
2791
|
-
|
|
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
|
|
2937
|
-
if (
|
|
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:
|
|
2942
|
-
gate: '
|
|
2943
|
-
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.
|
|
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 &&
|
|
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) =>
|
|
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
|
|
3602
|
-
const
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
const
|
|
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:
|
|
3608
|
-
assignee: query.assignee || query.assignedTo
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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 ? {
|
|
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
|
-
|
|
8418
|
-
|
|
8419
|
-
|
|
8420
|
-
|
|
8421
|
-
|
|
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
|
-
//
|
|
8970
|
-
const
|
|
8971
|
-
//
|
|
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 <
|
|
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 <
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
10240
|
+
// Touch presence: publishing content proves agent is alive
|
|
9649
10241
|
if (body.publishedBy) {
|
|
9650
10242
|
presenceManager.recordActivity(body.publishedBy, 'message');
|
|
9651
|
-
presenceManager.
|
|
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
|
-
//
|
|
10287
|
+
// Touch presence when adding content to calendar
|
|
9696
10288
|
if (body.createdBy) {
|
|
9697
|
-
presenceManager.
|
|
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
|
|
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
|