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.
- package/README.md +60 -151
- package/dist/alert-preflight.d.ts +28 -0
- package/dist/alert-preflight.d.ts.map +1 -1
- package/dist/alert-preflight.js +178 -0
- package/dist/alert-preflight.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/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 +1 -0
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +14 -3
- package/dist/chat.js.map +1 -1
- package/dist/cli.js +187 -22
- 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 +55 -20
- 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 +56 -26
- package/dist/dashboard.js.map +1 -1
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +78 -11
- package/dist/doctor.js.map +1 -1
- package/dist/executionSweeper.d.ts.map +1 -1
- package/dist/executionSweeper.js +12 -0
- package/dist/executionSweeper.js.map +1 -1
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +30 -7
- 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 +144 -29
- package/dist/index.js.map +1 -1
- package/dist/insight-task-bridge.d.ts +22 -1
- package/dist/insight-task-bridge.d.ts.map +1 -1
- package/dist/insight-task-bridge.js +19 -4
- package/dist/insight-task-bridge.js.map +1 -1
- package/dist/mcp.js +6 -6
- 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/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/preflight.d.ts.map +1 -1
- package/dist/preflight.js +7 -8
- package/dist/preflight.js.map +1 -1
- 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/request-tracker.d.ts +13 -0
- package/dist/request-tracker.d.ts.map +1 -1
- package/dist/request-tracker.js +95 -16
- package/dist/request-tracker.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +244 -58
- 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.d.ts +1 -1
- package/dist/tasks.d.ts.map +1 -1
- package/dist/tasks.js +8 -5
- package/dist/tasks.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 +2 -2
- package/dist/types.d.ts.map +1 -1
- 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 +1 -1
- package/public/dashboard.js +94 -27
- package/public/docs.md +17 -2
- package/public/intensity-mock.html +413 -0
- 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
|
|
1329
|
-
const
|
|
1330
|
-
if (
|
|
1330
|
+
function getOwnerHandlesFromEnv() {
|
|
1331
|
+
const raw = String(process.env.REFLECTT_OWNER_HANDLES || '').trim();
|
|
1332
|
+
if (!raw)
|
|
1331
1333
|
return [];
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
|
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
|
|
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
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
|
1368
|
+
// Don't gate the owner/system talking to themselves.
|
|
1353
1369
|
const sender = String(from || '').toLowerCase();
|
|
1354
|
-
if (sender
|
|
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
|
|
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
|
|
1370
|
-
hint: 'If
|
|
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 = '
|
|
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
|
|
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 &&
|
|
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 =>
|
|
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 (
|
|
2736
|
-
|
|
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
|
-
//
|
|
2774
|
-
|
|
2775
|
-
|
|
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
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
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
|
|
2790
|
-
|
|
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
|
|
2936
|
-
if (
|
|
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:
|
|
2941
|
-
gate: '
|
|
2942
|
-
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 &&
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
##
|
|
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
|
|
8418
|
-
3. **Review requests
|
|
8419
|
-
4. **Blockers
|
|
8420
|
-
5.
|
|
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
|
|
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
|