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