reflectt-node 0.1.6 → 0.1.8
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 +13 -0
- package/defaults/gitignore.template +23 -0
- package/dist/boardHealthWorker.d.ts +4 -0
- package/dist/boardHealthWorker.d.ts.map +1 -1
- package/dist/boardHealthWorker.js +36 -1
- package/dist/boardHealthWorker.js.map +1 -1
- package/dist/buildInfo.d.ts.map +1 -1
- package/dist/buildInfo.js +47 -10
- package/dist/buildInfo.js.map +1 -1
- package/dist/chat.d.ts +4 -0
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +6 -2
- package/dist/chat.js.map +1 -1
- package/dist/cli.js +37 -12
- package/dist/cli.js.map +1 -1
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js +131 -64
- package/dist/cloud.js.map +1 -1
- package/dist/continuity-loop.d.ts.map +1 -1
- package/dist/continuity-loop.js +297 -29
- package/dist/continuity-loop.js.map +1 -1
- package/dist/dashboard.js +1 -1
- package/dist/dashboard.js.map +1 -1
- package/dist/deploy-monitor.d.ts +18 -0
- package/dist/deploy-monitor.d.ts.map +1 -0
- package/dist/deploy-monitor.js +165 -0
- package/dist/deploy-monitor.js.map +1 -0
- 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 +1 -0
- package/dist/executionSweeper.d.ts.map +1 -1
- package/dist/executionSweeper.js +43 -7
- package/dist/executionSweeper.js.map +1 -1
- package/dist/files.d.ts.map +1 -1
- package/dist/files.js +17 -3
- package/dist/files.js.map +1 -1
- package/dist/fingerprint.d.ts +30 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +122 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/github-webhook-attribution.d.ts +38 -0
- package/dist/github-webhook-attribution.d.ts.map +1 -0
- package/dist/github-webhook-attribution.js +123 -0
- package/dist/github-webhook-attribution.js.map +1 -0
- package/dist/inbox.d.ts.map +1 -1
- package/dist/inbox.js +4 -0
- package/dist/inbox.js.map +1 -1
- package/dist/index.js +37 -1
- package/dist/index.js.map +1 -1
- package/dist/pulse.d.ts +7 -0
- package/dist/pulse.d.ts.map +1 -1
- package/dist/pulse.js +15 -0
- package/dist/pulse.js.map +1 -1
- package/dist/review-state.d.ts +9 -0
- package/dist/review-state.d.ts.map +1 -0
- package/dist/review-state.js +17 -0
- package/dist/review-state.js.map +1 -0
- package/dist/schedule.d.ts +60 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +176 -0
- package/dist/schedule.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +501 -14
- package/dist/server.js.map +1 -1
- package/dist/suppression-ledger.d.ts.map +1 -1
- package/dist/suppression-ledger.js +12 -3
- package/dist/suppression-ledger.js.map +1 -1
- package/dist/system-loop-state.d.ts +1 -1
- package/dist/system-loop-state.d.ts.map +1 -1
- package/dist/system-loop-state.js +1 -0
- package/dist/system-loop-state.js.map +1 -1
- package/dist/tasks.d.ts +9 -1
- package/dist/tasks.d.ts.map +1 -1
- package/dist/tasks.js +193 -41
- package/dist/tasks.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/usage-tracking.d.ts +26 -0
- package/dist/usage-tracking.d.ts.map +1 -1
- package/dist/usage-tracking.js +91 -4
- package/dist/usage-tracking.js.map +1 -1
- package/package.json +1 -1
- package/public/dashboard.js +136 -56
- package/public/docs.md +18 -0
package/dist/server.js
CHANGED
|
@@ -53,7 +53,7 @@ import { autoPopulateCloseGate, tryAutoCloseTask, getMergeAttemptLog } from './p
|
|
|
53
53
|
import { getDuplicateClosureCanonicalRefError } from './duplicateClosureGuard.js';
|
|
54
54
|
import { recordReviewMutation, diffReviewFields, getAuditEntries, loadAuditLedger } from './auditLedger.js';
|
|
55
55
|
import { listSharedFiles, readSharedFile, resolveTaskArtifact } from './shared-workspace-api.js';
|
|
56
|
-
import { normalizeTaskArtifactPaths, buildGitHubBlobUrl, buildGitHubRawUrl } from './artifact-resolver.js';
|
|
56
|
+
import { normalizeArtifactPath, normalizeTaskArtifactPaths, buildGitHubBlobUrl, buildGitHubRawUrl } from './artifact-resolver.js';
|
|
57
57
|
import { emitActivationEvent, getUserFunnelState, getFunnelSummary, hasCompletedEvent, isDay2Eligible, loadActivationFunnel, getConversionFunnel, getFailureDistribution, getWeeklyTrends, getOnboardingDashboard, } from './activationEvents.js';
|
|
58
58
|
import { alertUnauthorizedApproval, alertFlipAttempt, getMutationAlertStatus, pruneOldAttempts } from './mutationAlert.js';
|
|
59
59
|
import { mentionAckTracker } from './mention-ack.js';
|
|
@@ -71,7 +71,7 @@ import { getBuildInfo } from './buildInfo.js';
|
|
|
71
71
|
import { appendStoredLog, readStoredLogs, getStoredLogPath } from './logStore.js';
|
|
72
72
|
import { getAgentRoles, getAgentRolesSource, loadAgentRoles, startConfigWatch, suggestAssignee, suggestReviewer, checkWipCap, saveAgentRoles, scoreAssignment, getAgentRole, getAgentAliases, setAgentDisplayName, resolveAgentMention } from './assignment.js';
|
|
73
73
|
import { initTelemetry, trackRequest as trackTelemetryRequest, trackError as trackTelemetryError, trackTaskEvent, getSnapshot as getTelemetrySnapshot, getTelemetryConfig } from './telemetry.js';
|
|
74
|
-
import { recordUsage, recordUsageBatch, getUsageSummary, getUsageByAgent, getUsageByModel, getUsageByTask, setCap, listCaps, deleteCap, checkCaps, getRoutingSuggestions, estimateCost, ensureUsageTables } from './usage-tracking.js';
|
|
74
|
+
import { recordUsage, recordUsageBatch, getUsageSummary, getUsageByAgent, getUsageByModel, getUsageByTask, getDailySpendByModel, getAvgCostByLane, getAvgCostByAgent, setCap, listCaps, deleteCap, checkCaps, getRoutingSuggestions, estimateCost, ensureUsageTables } from './usage-tracking.js';
|
|
75
75
|
import { getTeamConfigHealth } from './team-config.js';
|
|
76
76
|
import { SecretVault } from './secrets.js';
|
|
77
77
|
import { initGitHubActorAuth, resolveGitHubTokenForActor } from './github-actor-auth.js';
|
|
@@ -80,6 +80,7 @@ import { computeCiFromCheckRuns, computeCiFromCombinedStatus } from './github-ci
|
|
|
80
80
|
import { createGitHubIdentityProvider } from './github-identity.js';
|
|
81
81
|
import { getProvisioningManager } from './provisioning.js';
|
|
82
82
|
import { getWebhookDeliveryManager } from './webhooks.js';
|
|
83
|
+
import { enrichWebhookPayload } from './github-webhook-attribution.js';
|
|
83
84
|
import { exportBundle, importBundle } from './portability.js';
|
|
84
85
|
import { getNotificationManager } from './notifications.js';
|
|
85
86
|
import { getConnectivityManager } from './connectivity.js';
|
|
@@ -115,7 +116,9 @@ import { getRoutingApprovalQueue, getRoutingSuggestion, buildApprovalPatch, buil
|
|
|
115
116
|
import { calendarManager } from './calendar.js';
|
|
116
117
|
import { calendarEvents } from './calendar-events.js';
|
|
117
118
|
import { startReminderEngine, stopReminderEngine, getReminderEngineStats } from './calendar-reminder-engine.js';
|
|
119
|
+
import { startDeployMonitor, stopDeployMonitor } from './deploy-monitor.js';
|
|
118
120
|
import { exportICS, exportEventICS, importICS } from './calendar-ical.js';
|
|
121
|
+
import { createScheduleEntry, getScheduleEntry, updateScheduleEntry, deleteScheduleEntry, getScheduleFeed } from './schedule.js';
|
|
119
122
|
import { createDoc, getDoc, listDocs, updateDoc, deleteDoc } from './knowledge-docs.js';
|
|
120
123
|
import { onTaskShipped, onDecisionComment, isDecisionComment } from './knowledge-auto-index.js';
|
|
121
124
|
import { upsertHostHeartbeat, getHost, listHosts, removeHost } from './host-registry.js';
|
|
@@ -426,6 +429,9 @@ const QaBundleSchema = z.object({
|
|
|
426
429
|
});
|
|
427
430
|
const ReviewHandoffSchema = z.object({
|
|
428
431
|
task_id: z.string().trim().regex(/^task-[a-zA-Z0-9-]+$/),
|
|
432
|
+
// Stored transactionally (server-side) from POST /tasks/:id/comments.
|
|
433
|
+
// This must always resolve via GET /tasks/:id/comments.
|
|
434
|
+
comment_id: z.string().trim().regex(/^tcomment-\d+-[a-z0-9]+$/i).optional(),
|
|
429
435
|
repo: z.string().trim().min(1).optional(), // optional for config_only tasks
|
|
430
436
|
artifact_path: z.string().trim().min(1), // relaxed: accepts any path (process/, ~/.reflectt/, etc.)
|
|
431
437
|
test_proof: z.string().trim().min(1).optional(), // optional for non-code tasks
|
|
@@ -621,6 +627,29 @@ function enforceQaBundleGateForValidating(status, metadata, expectedTaskId) {
|
|
|
621
627
|
hint: 'Use the same canonical process/... artifact path in both fields.',
|
|
622
628
|
};
|
|
623
629
|
}
|
|
630
|
+
// Canonical artifact reference (until central storage exists):
|
|
631
|
+
// For code tasks, artifact paths must be repo-relative under process/ (or a URL).
|
|
632
|
+
if (reviewPacket && !nonCodeLane) {
|
|
633
|
+
const packetArtifact = typeof reviewPacket.artifact_path === 'string' ? reviewPacket.artifact_path.trim() : '';
|
|
634
|
+
const packetIsUrl = /^https?:\/\//i.test(packetArtifact);
|
|
635
|
+
const packetIsProcess = packetArtifact.startsWith('process/');
|
|
636
|
+
if (packetArtifact && !packetIsUrl && !packetIsProcess) {
|
|
637
|
+
return {
|
|
638
|
+
ok: false,
|
|
639
|
+
error: 'Validating gate: metadata.qa_bundle.review_packet.artifact_path must be under process/ (repo-relative) or a URL',
|
|
640
|
+
hint: 'Set review_packet.artifact_path to process/TASK-...md (committed in the PR) or a PR/GitHub URL.',
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
const metaIsUrl = /^https?:\/\//i.test(artifactPath);
|
|
644
|
+
const metaIsProcess = artifactPath.startsWith('process/');
|
|
645
|
+
if (artifactPath && !metaIsUrl && !metaIsProcess) {
|
|
646
|
+
return {
|
|
647
|
+
ok: false,
|
|
648
|
+
error: 'Validating gate: metadata.artifact_path must be under process/ (repo-relative) or a URL',
|
|
649
|
+
hint: 'Set metadata.artifact_path to process/TASK-...md (committed in the PR) or a PR/GitHub URL.',
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
}
|
|
624
653
|
// PR integrity: validate commit SHA + changed_files against live PR head
|
|
625
654
|
if (!nonCodeLane && reviewPacket?.pr_url) {
|
|
626
655
|
const overrideFlag = metadataObj.pr_integrity_override === true;
|
|
@@ -742,6 +771,40 @@ function applyReviewStateMetadata(existing, parsed, mergedMeta, now) {
|
|
|
742
771
|
};
|
|
743
772
|
console.log(`[ArtifactNormalize] task ${existing.id}: normalized`, normResult.warnings);
|
|
744
773
|
}
|
|
774
|
+
// ── Review handoff comment pointer repair/fill ──
|
|
775
|
+
// If review_handoff exists, ensure comment_id points to a real comment.
|
|
776
|
+
// We do this *server-side* to avoid phantom/unresolvable pointers.
|
|
777
|
+
const rh = metadata.review_handoff;
|
|
778
|
+
if (rh && typeof rh === 'object' && !Array.isArray(rh)) {
|
|
779
|
+
const rhAny = rh;
|
|
780
|
+
const commentId = typeof rhAny.comment_id === 'string' ? rhAny.comment_id.trim() : '';
|
|
781
|
+
const all = taskManager.getTaskComments(existing.id, { includeSuppressed: true });
|
|
782
|
+
const resolves = commentId ? all.some(c => c.id === commentId) : false;
|
|
783
|
+
if (!resolves) {
|
|
784
|
+
// Prefer an explicit category tag; fallback to most recent comment by assignee.
|
|
785
|
+
const assignee = (existing.assignee || '').trim().toLowerCase();
|
|
786
|
+
const byHandoffCategory = all
|
|
787
|
+
.filter(c => {
|
|
788
|
+
const cat = String(c.category || '').toLowerCase();
|
|
789
|
+
return cat === 'review_handoff' || cat === 'handoff';
|
|
790
|
+
});
|
|
791
|
+
const byAssignee = assignee
|
|
792
|
+
? all.filter(c => String(c.author || '').trim().toLowerCase() === assignee)
|
|
793
|
+
: [];
|
|
794
|
+
const candidate = (byHandoffCategory.length > 0
|
|
795
|
+
? byHandoffCategory[byHandoffCategory.length - 1]
|
|
796
|
+
: (byAssignee.length > 0 ? byAssignee[byAssignee.length - 1] : (all.length > 0 ? all[all.length - 1] : null)));
|
|
797
|
+
if (candidate) {
|
|
798
|
+
metadata.review_handoff = { ...rhAny, comment_id: candidate.id };
|
|
799
|
+
metadata.review_handoff_comment_id_autofilled = {
|
|
800
|
+
previous: commentId || null,
|
|
801
|
+
next: candidate.id,
|
|
802
|
+
at: now,
|
|
803
|
+
strategy: byHandoffCategory.length > 0 ? 'category:review_handoff' : (byAssignee.length > 0 ? 'latest_assignee_comment' : 'latest_comment'),
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
745
808
|
}
|
|
746
809
|
if (previousStatus === 'validating' && nextStatus === 'doing' && !incomingReviewState) {
|
|
747
810
|
metadata.review_state = 'needs_author';
|
|
@@ -859,7 +922,13 @@ function isEchoOutOfLaneTask(task) {
|
|
|
859
922
|
// For Echo, anything classified outside content/docs voice lane gets flagged unless reassigned.
|
|
860
923
|
return true;
|
|
861
924
|
}
|
|
862
|
-
|
|
925
|
+
const reviewHandoffValidationStats = {
|
|
926
|
+
failures: 0,
|
|
927
|
+
lastFailureAt: 0,
|
|
928
|
+
lastFailureTaskId: '',
|
|
929
|
+
lastFailureError: '',
|
|
930
|
+
};
|
|
931
|
+
async function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
|
|
863
932
|
if (status !== 'validating')
|
|
864
933
|
return { ok: true };
|
|
865
934
|
if (isTaskAutomatedRecurring(metadata))
|
|
@@ -874,7 +943,7 @@ function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
|
|
|
874
943
|
return {
|
|
875
944
|
ok: false,
|
|
876
945
|
error: 'Review handoff required: metadata.review_handoff must include task_id, artifact_path, known_caveats (and pr_url + commit_sha unless doc_only=true, config_only=true, or non_code=true).',
|
|
877
|
-
hint: 'Example: { "review_handoff": { "task_id":"task-...", "artifact_path":"process/TASK-...md", "known_caveats":"none" } }. For non-code tasks
|
|
946
|
+
hint: 'Example: { "review_handoff": { "task_id":"task-...", "artifact_path":"process/TASK-...md", "known_caveats":"none" } }. For non-code tasks: set non_code=true. Recommended: post the handoff comment with category="review_handoff" so the server stamps comment_id automatically.',
|
|
878
947
|
};
|
|
879
948
|
}
|
|
880
949
|
const handoff = parsed.data;
|
|
@@ -885,6 +954,38 @@ function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
|
|
|
885
954
|
hint: 'Set metadata.review_handoff.task_id to the exact task being transitioned.',
|
|
886
955
|
};
|
|
887
956
|
}
|
|
957
|
+
// Ensure review_handoff.comment_id resolves to a real comment.
|
|
958
|
+
// If missing (or stale), we repair it from existing comments; if none exist,
|
|
959
|
+
// we create a server-authored pointer comment so reviewers always have a stable anchor.
|
|
960
|
+
const commentsAll = taskManager.getTaskComments(taskId, { includeSuppressed: true });
|
|
961
|
+
let commentId = typeof handoff.comment_id === 'string' ? handoff.comment_id.trim() : '';
|
|
962
|
+
let handoffComment = commentId ? (commentsAll.find(c => c.id === commentId) || null) : null;
|
|
963
|
+
if (!handoffComment) {
|
|
964
|
+
const byCategory = commentsAll.filter(c => {
|
|
965
|
+
const cat = String(c.category || '').toLowerCase();
|
|
966
|
+
return cat === 'review_handoff' || cat === 'handoff';
|
|
967
|
+
});
|
|
968
|
+
const candidate = byCategory.length > 0
|
|
969
|
+
? byCategory[byCategory.length - 1]
|
|
970
|
+
: (commentsAll.length > 0 ? commentsAll[commentsAll.length - 1] : null);
|
|
971
|
+
if (candidate) {
|
|
972
|
+
commentId = candidate.id;
|
|
973
|
+
handoffComment = candidate;
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
// No comments exist — create a stable anchor comment.
|
|
977
|
+
const created = await taskManager.addTaskComment(taskId, 'system', 'Auto-handoff: review_handoff is recorded in metadata.review_handoff (no explicit handoff comment was posted).', { category: 'review_handoff', provenance: { kind: 'auto_review_handoff', source: 'validating_gate' } });
|
|
978
|
+
commentId = created.id;
|
|
979
|
+
handoffComment = created;
|
|
980
|
+
}
|
|
981
|
+
// Persist repaired comment_id into the handoff metadata (server-side).
|
|
982
|
+
;
|
|
983
|
+
handoff.comment_id = commentId;
|
|
984
|
+
const rhObj = root.review_handoff;
|
|
985
|
+
if (rhObj && typeof rhObj === 'object' && !Array.isArray(rhObj)) {
|
|
986
|
+
rhObj.comment_id = commentId;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
888
989
|
// config_only: artifacts live in ~/.reflectt/, no repo/PR required
|
|
889
990
|
// doc_only/non_code/design/docs lanes: no PR/commit required
|
|
890
991
|
const nonCodeLane = handoff.non_code === true || isDesignOrDocsLane(root);
|
|
@@ -904,7 +1005,43 @@ function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
|
|
|
904
1005
|
};
|
|
905
1006
|
}
|
|
906
1007
|
}
|
|
907
|
-
|
|
1008
|
+
// Artifact retrievability gate.
|
|
1009
|
+
// If the artifact isn't accessible from this node (repo / shared-workspace / GitHub fallback),
|
|
1010
|
+
// a reviewer on another host will almost certainly be blocked.
|
|
1011
|
+
const artifactPath = typeof handoff.artifact_path === 'string' ? handoff.artifact_path.trim() : '';
|
|
1012
|
+
const norm = normalizeArtifactPath(artifactPath);
|
|
1013
|
+
if (norm.rejected || !norm.normalized) {
|
|
1014
|
+
return {
|
|
1015
|
+
ok: false,
|
|
1016
|
+
error: `Validating gate: review_handoff.artifact_path is not a valid retrievable reference (${norm.rejectReason || 'invalid path'}).`,
|
|
1017
|
+
hint: 'Use either (a) a PR/GitHub URL, or (b) a repo-relative path (e.g. process/TASK-...md) that exists on the referenced PR/commit, or (c) put the full spec in the handoff comment and point artifact_path at a stable URL.',
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
// URLs are assumed retrievable.
|
|
1021
|
+
if (/^https?:\/\//i.test(norm.normalized))
|
|
1022
|
+
return { ok: true };
|
|
1023
|
+
// If the file is accessible locally (repo or shared-workspace), accept.
|
|
1024
|
+
const repoRoot = resolve(import.meta.dirname || process.cwd(), '..');
|
|
1025
|
+
const resolved = await resolveTaskArtifact(norm.normalized, repoRoot);
|
|
1026
|
+
if (resolved.accessible)
|
|
1027
|
+
return { ok: true };
|
|
1028
|
+
// GitHub fallback: if PR+commit are known and artifact is process/*, we can build a stable blob URL.
|
|
1029
|
+
const prUrl = root.pr_url || root.qa_bundle?.review_packet?.pr_url || root.review_handoff?.pr_url;
|
|
1030
|
+
const commitSha = root.commit_sha || root.commit || root.qa_bundle?.review_packet?.commit || root.review_handoff?.commit_sha;
|
|
1031
|
+
if (typeof prUrl === 'string' && typeof commitSha === 'string' && norm.normalized.startsWith('process/')) {
|
|
1032
|
+
const blobUrl = buildGitHubBlobUrl(prUrl, commitSha, norm.normalized);
|
|
1033
|
+
if (blobUrl)
|
|
1034
|
+
return { ok: true };
|
|
1035
|
+
}
|
|
1036
|
+
// For non-code tasks, the handoff comment itself is considered the primary artifact.
|
|
1037
|
+
// We only require that comment_id resolves (handled above).
|
|
1038
|
+
if (nonCodeLane)
|
|
1039
|
+
return { ok: true };
|
|
1040
|
+
return {
|
|
1041
|
+
ok: false,
|
|
1042
|
+
error: 'Validating gate: review_handoff.artifact_path is not retrievable from repo/shared-workspace/GitHub fallback.',
|
|
1043
|
+
hint: 'Move the artifact into shared-workspace process/, or reference a PR+commit so GitHub blob fallback can resolve it (process/* only).',
|
|
1044
|
+
};
|
|
908
1045
|
}
|
|
909
1046
|
const DEFAULT_LIMITS = {
|
|
910
1047
|
chatMessages: 50,
|
|
@@ -1516,7 +1653,8 @@ export async function createServer() {
|
|
|
1516
1653
|
await app.register(fastifyWebsocket);
|
|
1517
1654
|
// Multipart file uploads (50MB limit)
|
|
1518
1655
|
const fastifyMultipart = await import('@fastify/multipart');
|
|
1519
|
-
|
|
1656
|
+
const { MAX_SIZE_BYTES: _multipartMax } = await import('./files.js');
|
|
1657
|
+
await app.register(fastifyMultipart.default, { limits: { fileSize: _multipartMax } });
|
|
1520
1658
|
// Normalize error responses to a consistent envelope
|
|
1521
1659
|
app.addHook('preSerialization', async (request, reply, payload) => {
|
|
1522
1660
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
@@ -1561,6 +1699,8 @@ export async function createServer() {
|
|
|
1561
1699
|
envelope.gate = body.gate;
|
|
1562
1700
|
if (body.problems !== undefined)
|
|
1563
1701
|
envelope.problems = body.problems;
|
|
1702
|
+
if (body.tombstone !== undefined)
|
|
1703
|
+
envelope.tombstone = body.tombstone;
|
|
1564
1704
|
if (alreadyEnvelope && body.data !== undefined)
|
|
1565
1705
|
envelope.data = body.data;
|
|
1566
1706
|
// Minimal persisted error log: enables /logs to return real entries.
|
|
@@ -1847,6 +1987,10 @@ export async function createServer() {
|
|
|
1847
1987
|
// Board health execution worker — config from policy
|
|
1848
1988
|
boardHealthWorker.updateConfig(policy.boardHealth);
|
|
1849
1989
|
boardHealthWorker.start();
|
|
1990
|
+
// Activate noise budget enforcement — the 24h canary period is complete.
|
|
1991
|
+
// Canary mode (log-only) is still the default in case of fresh installs,
|
|
1992
|
+
// but on a running server we want real duplicate suppression.
|
|
1993
|
+
noiseBudgetManager.activateEnforcement();
|
|
1850
1994
|
// Noise budget: wire digest flush handler to send batched messages to #ops
|
|
1851
1995
|
noiseBudgetManager.setDigestFlushHandler(async (channel, entries) => {
|
|
1852
1996
|
if (entries.length === 0)
|
|
@@ -1876,6 +2020,8 @@ export async function createServer() {
|
|
|
1876
2020
|
startShippedHeartbeat();
|
|
1877
2021
|
// Calendar reminder engine — polls for pending reminders every 30s
|
|
1878
2022
|
startReminderEngine();
|
|
2023
|
+
// Deploy monitor — alert within 5m when production deploys fail (Vercel + health URL)
|
|
2024
|
+
startDeployMonitor();
|
|
1879
2025
|
app.addHook('onClose', async () => {
|
|
1880
2026
|
clearInterval(idleNudgeTimer);
|
|
1881
2027
|
clearInterval(cadenceWatchdogTimer);
|
|
@@ -1885,6 +2031,7 @@ export async function createServer() {
|
|
|
1885
2031
|
stopShippedHeartbeat();
|
|
1886
2032
|
stopTeamPulse();
|
|
1887
2033
|
stopReminderEngine();
|
|
2034
|
+
stopDeployMonitor();
|
|
1888
2035
|
stopKeepalive();
|
|
1889
2036
|
stopSelfKeepalive();
|
|
1890
2037
|
wsHeartbeat.stop();
|
|
@@ -2703,6 +2850,9 @@ export async function createServer() {
|
|
|
2703
2850
|
reflectionPipeline: { registered: Boolean(reflectionPipelineTimer), lastTickAt: ticks.reflection_pipeline, lastTickAgeSec: ageSec(ticks.reflection_pipeline) },
|
|
2704
2851
|
boardHealthWorker: { registered: board.running, lastTickAt: ticks.board_health || board.lastTickAt, lastTickAgeSec: ageSec(ticks.board_health || board.lastTickAt) },
|
|
2705
2852
|
},
|
|
2853
|
+
reviewHandoffValidation: {
|
|
2854
|
+
...reviewHandoffValidationStats,
|
|
2855
|
+
},
|
|
2706
2856
|
reflectionPipelineHealth: {
|
|
2707
2857
|
...reflectionPipelineHealth,
|
|
2708
2858
|
},
|
|
@@ -3037,6 +3187,23 @@ export async function createServer() {
|
|
|
3037
3187
|
};
|
|
3038
3188
|
}
|
|
3039
3189
|
const data = parsedBody.data;
|
|
3190
|
+
// Reserve system sender for server-internal control-plane messages.
|
|
3191
|
+
// Prevent browser clients (dashboard.js) or external callers from spoofing system alerts.
|
|
3192
|
+
//
|
|
3193
|
+
// Allow explicit internal callers (tests/tools) via header:
|
|
3194
|
+
// x-reflectt-internal: true
|
|
3195
|
+
if (data.from === 'system') {
|
|
3196
|
+
const internal = String(request.headers['x-reflectt-internal'] || '').toLowerCase() === 'true';
|
|
3197
|
+
if (!internal) {
|
|
3198
|
+
reply.code(403);
|
|
3199
|
+
return {
|
|
3200
|
+
success: false,
|
|
3201
|
+
error: 'Sender "system" is reserved (use from="dashboard" or your agent name).',
|
|
3202
|
+
code: 'SENDER_RESERVED',
|
|
3203
|
+
hint: 'Only internal callers may emit system messages. Add header x-reflectt-internal:true for test/tooling.',
|
|
3204
|
+
};
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3040
3207
|
// Require at least content or attachments
|
|
3041
3208
|
if (!data.content && (!data.attachments || data.attachments.length === 0)) {
|
|
3042
3209
|
reply.code(400);
|
|
@@ -4306,6 +4473,24 @@ export async function createServer() {
|
|
|
4306
4473
|
};
|
|
4307
4474
|
}
|
|
4308
4475
|
if (!resolved.task || !resolved.resolvedId) {
|
|
4476
|
+
// Check if this task was deleted — return 410 Gone with tombstone metadata instead of 404.
|
|
4477
|
+
const tombstone = taskManager.getTaskDeletionTombstone(request.params.id);
|
|
4478
|
+
if (tombstone) {
|
|
4479
|
+
reply.code(410);
|
|
4480
|
+
return {
|
|
4481
|
+
success: false,
|
|
4482
|
+
error: 'Task has been deleted',
|
|
4483
|
+
code: 'TASK_DELETED',
|
|
4484
|
+
status: 410,
|
|
4485
|
+
tombstone: {
|
|
4486
|
+
taskId: tombstone.taskId,
|
|
4487
|
+
deletedAt: tombstone.deletedAt,
|
|
4488
|
+
deletedBy: tombstone.deletedBy,
|
|
4489
|
+
previousStatus: tombstone.previousStatus,
|
|
4490
|
+
title: tombstone.title,
|
|
4491
|
+
},
|
|
4492
|
+
};
|
|
4493
|
+
}
|
|
4309
4494
|
reply.code(404);
|
|
4310
4495
|
return {
|
|
4311
4496
|
error: 'Task not found',
|
|
@@ -4987,6 +5172,24 @@ export async function createServer() {
|
|
|
4987
5172
|
// fan out inbox-visible notifications to assignee/reviewer + explicit @mentions.
|
|
4988
5173
|
// Notification routing respects per-agent preferences (quiet hours, mute, filters).
|
|
4989
5174
|
const task = taskManager.getTask(resolved.resolvedId);
|
|
5175
|
+
// ── Transactional review_handoff.comment_id stamping ──
|
|
5176
|
+
// If the author tags this comment as the handoff entrypoint, the server stamps
|
|
5177
|
+
// metadata.review_handoff.comment_id from the persisted comment ID.
|
|
5178
|
+
// This prevents clients from supplying phantom IDs.
|
|
5179
|
+
const category = typeof data.category === 'string' ? String(data.category).trim().toLowerCase() : '';
|
|
5180
|
+
if (task && (category === 'review_handoff' || category === 'handoff')) {
|
|
5181
|
+
const meta = (task.metadata || {});
|
|
5182
|
+
const rh = meta.review_handoff;
|
|
5183
|
+
if (rh && typeof rh === 'object' && !Array.isArray(rh)) {
|
|
5184
|
+
const rhAny = rh;
|
|
5185
|
+
if (rhAny.comment_id !== comment.id) {
|
|
5186
|
+
taskManager.patchTaskMetadata(task.id, {
|
|
5187
|
+
review_handoff: { ...rhAny, comment_id: comment.id },
|
|
5188
|
+
review_handoff_comment_id_stamped_at: Date.now(),
|
|
5189
|
+
});
|
|
5190
|
+
}
|
|
5191
|
+
}
|
|
5192
|
+
}
|
|
4990
5193
|
// Never fan out notifications for test-harness tasks.
|
|
4991
5194
|
// Our repo contains a few "LIVE server" tests (BASE=127.0.0.1:4445) that create
|
|
4992
5195
|
// tasks/comments with metadata.is_test=true. Without this guard, running `npm test`
|
|
@@ -5579,6 +5782,21 @@ export async function createServer() {
|
|
|
5579
5782
|
.then(({ indexTask }) => indexTask(task.id, task.title, undefined, data.done_criteria))
|
|
5580
5783
|
.catch(() => { });
|
|
5581
5784
|
}
|
|
5785
|
+
// Auto-link insight when task is manually created with source_insight metadata.
|
|
5786
|
+
// Mirrors the bridge's updateInsightStatus call so insights don't stay pending_triage
|
|
5787
|
+
// after an agent manually files a task addressing them.
|
|
5788
|
+
const sourceInsightId = typeof newMetadata.source_insight === 'string' ? newMetadata.source_insight : null;
|
|
5789
|
+
if (sourceInsightId && !sourceInsightId.startsWith('ins-test-')) {
|
|
5790
|
+
try {
|
|
5791
|
+
const linkedInsight = getInsight(sourceInsightId);
|
|
5792
|
+
if (linkedInsight && linkedInsight.status !== 'task_created' && linkedInsight.status !== 'closed') {
|
|
5793
|
+
updateInsightStatus(sourceInsightId, 'task_created', task.id);
|
|
5794
|
+
}
|
|
5795
|
+
}
|
|
5796
|
+
catch {
|
|
5797
|
+
// Non-fatal: insight link failure must not block task creation
|
|
5798
|
+
}
|
|
5799
|
+
}
|
|
5582
5800
|
trackTaskEvent('created');
|
|
5583
5801
|
return { success: true, task: enrichTaskWithComments(task), warnings: creationWarnings };
|
|
5584
5802
|
}
|
|
@@ -6091,7 +6309,23 @@ export async function createServer() {
|
|
|
6091
6309
|
}
|
|
6092
6310
|
// Merge incoming metadata with existing for gate checks + persistence.
|
|
6093
6311
|
// Apply auto-defaults (ETA, artifact_path) when not explicitly provided.
|
|
6094
|
-
|
|
6312
|
+
// Do not accept caller-supplied review_handoff.comment_id (it must be stamped server-side from POST /tasks/:id/comments).
|
|
6313
|
+
// If a client tries to patch it directly, we strip it to prevent phantom pointers.
|
|
6314
|
+
const incomingMetaRaw = (parsed.metadata || {});
|
|
6315
|
+
const incomingMeta = { ...incomingMetaRaw };
|
|
6316
|
+
const incomingRh = incomingMeta.review_handoff;
|
|
6317
|
+
if (incomingRh && typeof incomingRh === 'object' && !Array.isArray(incomingRh)) {
|
|
6318
|
+
const rhAny = incomingRh;
|
|
6319
|
+
if (typeof rhAny.comment_id === 'string') {
|
|
6320
|
+
const { comment_id, ...rest } = rhAny;
|
|
6321
|
+
incomingMeta.review_handoff = rest;
|
|
6322
|
+
incomingMeta.review_handoff_comment_id_stripped = {
|
|
6323
|
+
stripped: true,
|
|
6324
|
+
attempted: comment_id,
|
|
6325
|
+
at: Date.now(),
|
|
6326
|
+
};
|
|
6327
|
+
}
|
|
6328
|
+
}
|
|
6095
6329
|
const effectiveTargetStatus = parsed.status ?? existing.status;
|
|
6096
6330
|
const autoFilledMeta = applyAutoDefaults(lookup.resolvedId, effectiveTargetStatus, incomingMeta);
|
|
6097
6331
|
const mergedRawMeta = { ...(existing.metadata || {}), ...autoFilledMeta };
|
|
@@ -6270,8 +6504,12 @@ export async function createServer() {
|
|
|
6270
6504
|
hint: qaGate.hint,
|
|
6271
6505
|
};
|
|
6272
6506
|
}
|
|
6273
|
-
const handoffGate = enforceReviewHandoffGateForValidating(effectiveStatus, lookup.resolvedId, mergedMeta);
|
|
6507
|
+
const handoffGate = await enforceReviewHandoffGateForValidating(effectiveStatus, lookup.resolvedId, mergedMeta);
|
|
6274
6508
|
if (!handoffGate.ok) {
|
|
6509
|
+
reviewHandoffValidationStats.failures += 1;
|
|
6510
|
+
reviewHandoffValidationStats.lastFailureAt = Date.now();
|
|
6511
|
+
reviewHandoffValidationStats.lastFailureTaskId = lookup.resolvedId;
|
|
6512
|
+
reviewHandoffValidationStats.lastFailureError = handoffGate.error;
|
|
6275
6513
|
reply.code(400);
|
|
6276
6514
|
return {
|
|
6277
6515
|
success: false,
|
|
@@ -6602,6 +6840,7 @@ export async function createServer() {
|
|
|
6602
6840
|
const prLine = prUrl ? `\nPR: ${prUrl}` : '';
|
|
6603
6841
|
chatManager.sendMessage({
|
|
6604
6842
|
from: 'system',
|
|
6843
|
+
to: existing.reviewer,
|
|
6605
6844
|
content: `@${existing.reviewer} [reviewRequested:${task.id}] ${task.title} → validating${prLine}`,
|
|
6606
6845
|
channel: 'task-notifications',
|
|
6607
6846
|
metadata: {
|
|
@@ -6711,6 +6950,7 @@ export async function createServer() {
|
|
|
6711
6950
|
const reviewMsg = `@${task.reviewer} review requested: **${task.title}** (${task.id})${prLink}. Please approve or flag issues.`;
|
|
6712
6951
|
chatManager.sendMessage({
|
|
6713
6952
|
from: 'system',
|
|
6953
|
+
to: task.reviewer,
|
|
6714
6954
|
content: reviewMsg,
|
|
6715
6955
|
channel: 'reviews',
|
|
6716
6956
|
metadata: {
|
|
@@ -6829,6 +7069,7 @@ export async function createServer() {
|
|
|
6829
7069
|
const sourceInfo = getAgentRolesSource();
|
|
6830
7070
|
return { success: true, agents: enriched, config: sourceInfo };
|
|
6831
7071
|
};
|
|
7072
|
+
app.get('/agents', async () => buildRoleRegistryPayload());
|
|
6832
7073
|
app.get('/agents/roles', async () => buildRoleRegistryPayload());
|
|
6833
7074
|
// Team-scoped alias for assignment-engine consumers
|
|
6834
7075
|
app.get('/team/roles', async () => {
|
|
@@ -6845,6 +7086,13 @@ export async function createServer() {
|
|
|
6845
7086
|
// ── File upload/download ──
|
|
6846
7087
|
app.post('/files', async (request, reply) => {
|
|
6847
7088
|
try {
|
|
7089
|
+
const { MAX_SIZE_BYTES: maxBytes } = await import('./files.js');
|
|
7090
|
+
// Early rejection via Content-Length before reading body
|
|
7091
|
+
const declaredLength = parseInt(String(request.headers['content-length'] || ''), 10);
|
|
7092
|
+
if (!Number.isNaN(declaredLength) && declaredLength > maxBytes) {
|
|
7093
|
+
reply.code(413);
|
|
7094
|
+
return { success: false, error: `File exceeds ${maxBytes / (1024 * 1024)}MB limit (Content-Length: ${declaredLength} bytes)` };
|
|
7095
|
+
}
|
|
6848
7096
|
const data = await request.file();
|
|
6849
7097
|
if (!data) {
|
|
6850
7098
|
reply.code(400);
|
|
@@ -6854,10 +7102,10 @@ export async function createServer() {
|
|
|
6854
7102
|
for await (const chunk of data.file)
|
|
6855
7103
|
chunks.push(chunk);
|
|
6856
7104
|
const buffer = Buffer.concat(chunks);
|
|
6857
|
-
// Check if stream was truncated (exceeds limit)
|
|
7105
|
+
// Check if stream was truncated (exceeds multipart limit)
|
|
6858
7106
|
if (data.file.truncated) {
|
|
6859
7107
|
reply.code(413);
|
|
6860
|
-
return { success: false, error:
|
|
7108
|
+
return { success: false, error: `File exceeds ${maxBytes / (1024 * 1024)}MB limit` };
|
|
6861
7109
|
}
|
|
6862
7110
|
const fields = data.fields;
|
|
6863
7111
|
const uploadedBy = typeof fields?.uploadedBy?.value === 'string' ? fields.uploadedBy.value : 'anonymous';
|
|
@@ -6879,10 +7127,11 @@ export async function createServer() {
|
|
|
6879
7127
|
return result;
|
|
6880
7128
|
}
|
|
6881
7129
|
catch (err) {
|
|
7130
|
+
const { MAX_SIZE_BYTES: maxBytes } = await import('./files.js');
|
|
6882
7131
|
const msg = err instanceof Error ? err.message : String(err);
|
|
6883
7132
|
if (msg.includes('Request file too large')) {
|
|
6884
7133
|
reply.code(413);
|
|
6885
|
-
return { success: false, error:
|
|
7134
|
+
return { success: false, error: `File exceeds ${maxBytes / (1024 * 1024)}MB limit` };
|
|
6886
7135
|
}
|
|
6887
7136
|
reply.code(500);
|
|
6888
7137
|
return { success: false, error: 'Upload failed' };
|
|
@@ -7078,6 +7327,137 @@ export async function createServer() {
|
|
|
7078
7327
|
return { success: false, error: msg };
|
|
7079
7328
|
}
|
|
7080
7329
|
});
|
|
7330
|
+
// POST /agents — Add a single agent to the team
|
|
7331
|
+
app.post('/agents', async (request, reply) => {
|
|
7332
|
+
const body = request.body;
|
|
7333
|
+
const name = typeof body.name === 'string' ? body.name.trim().toLowerCase() : '';
|
|
7334
|
+
const role = typeof body.role === 'string' ? body.role.trim() : '';
|
|
7335
|
+
const description = typeof body.description === 'string' ? body.description.trim() : '';
|
|
7336
|
+
if (!name) {
|
|
7337
|
+
reply.code(400);
|
|
7338
|
+
return { success: false, error: 'name is required' };
|
|
7339
|
+
}
|
|
7340
|
+
if (!role) {
|
|
7341
|
+
reply.code(400);
|
|
7342
|
+
return { success: false, error: 'role is required' };
|
|
7343
|
+
}
|
|
7344
|
+
if (/[^a-z0-9_-]/.test(name)) {
|
|
7345
|
+
reply.code(400);
|
|
7346
|
+
return { success: false, error: 'name must be lowercase alphanumeric (a-z, 0-9, -, _)' };
|
|
7347
|
+
}
|
|
7348
|
+
// Check if agent already exists
|
|
7349
|
+
const existing = getAgentRoles().find(r => r.name === name);
|
|
7350
|
+
if (existing) {
|
|
7351
|
+
reply.code(409);
|
|
7352
|
+
return { success: false, error: `Agent "${name}" already exists (role: ${existing.role})` };
|
|
7353
|
+
}
|
|
7354
|
+
// Read existing YAML and append new agent
|
|
7355
|
+
const { readFileSync, writeFileSync, existsSync } = await import('node:fs');
|
|
7356
|
+
const { join } = await import('node:path');
|
|
7357
|
+
const filePath = join(REFLECTT_HOME, 'TEAM-ROLES.yaml');
|
|
7358
|
+
let yaml = '';
|
|
7359
|
+
if (existsSync(filePath)) {
|
|
7360
|
+
yaml = readFileSync(filePath, 'utf-8');
|
|
7361
|
+
}
|
|
7362
|
+
if (!yaml.includes('agents:')) {
|
|
7363
|
+
yaml = 'agents:\n';
|
|
7364
|
+
}
|
|
7365
|
+
// Build agent YAML entry
|
|
7366
|
+
const affinityTags = Array.isArray(body.affinityTags) ? body.affinityTags : [role];
|
|
7367
|
+
const wipCap = typeof body.wipCap === 'number' ? body.wipCap : 2;
|
|
7368
|
+
const desc = description || `${role} agent.`;
|
|
7369
|
+
const entry = [
|
|
7370
|
+
` - name: ${name}`,
|
|
7371
|
+
` role: ${role}`,
|
|
7372
|
+
` description: ${desc}`,
|
|
7373
|
+
` affinityTags: [${affinityTags.join(', ')}]`,
|
|
7374
|
+
` wipCap: ${wipCap}`,
|
|
7375
|
+
].join('\n');
|
|
7376
|
+
// Insert before lanes: section (if present), otherwise append
|
|
7377
|
+
const lanesIdx = yaml.indexOf('\nlanes:');
|
|
7378
|
+
if (lanesIdx >= 0) {
|
|
7379
|
+
yaml = yaml.slice(0, lanesIdx) + '\n' + entry + yaml.slice(lanesIdx);
|
|
7380
|
+
}
|
|
7381
|
+
else {
|
|
7382
|
+
yaml = yaml.trimEnd() + '\n' + entry + '\n';
|
|
7383
|
+
}
|
|
7384
|
+
try {
|
|
7385
|
+
writeFileSync(filePath, yaml, 'utf-8');
|
|
7386
|
+
const { loadAgentRoles } = await import('./assignment.js');
|
|
7387
|
+
const reloaded = loadAgentRoles();
|
|
7388
|
+
// Scaffold agent workspace if it doesn't exist
|
|
7389
|
+
let workspaceCreated = false;
|
|
7390
|
+
try {
|
|
7391
|
+
const { mkdirSync, existsSync: dirExists } = await import('node:fs');
|
|
7392
|
+
const workspaceDir = join(REFLECTT_HOME, `workspace-${name}`);
|
|
7393
|
+
if (!dirExists(workspaceDir)) {
|
|
7394
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
7395
|
+
writeFileSync(join(workspaceDir, 'SOUL.md'), `# ${name}\n\n*${desc}*\n`, 'utf-8');
|
|
7396
|
+
writeFileSync(join(workspaceDir, 'AGENTS.md'), `# ${name}\n\nRole: ${role}\n\n${desc}\n`, 'utf-8');
|
|
7397
|
+
workspaceCreated = true;
|
|
7398
|
+
}
|
|
7399
|
+
}
|
|
7400
|
+
catch (wsErr) {
|
|
7401
|
+
console.warn(`[Agents] Workspace scaffold failed for ${name}:`, wsErr.message);
|
|
7402
|
+
}
|
|
7403
|
+
return {
|
|
7404
|
+
success: true,
|
|
7405
|
+
agent: { name, role, description: desc, wipCap },
|
|
7406
|
+
totalAgents: reloaded.roles.length,
|
|
7407
|
+
workspaceCreated,
|
|
7408
|
+
hint: `Agent "${name}" added to team and hot-reloaded. Start heartbeating: GET /heartbeat/${name}`,
|
|
7409
|
+
};
|
|
7410
|
+
}
|
|
7411
|
+
catch (err) {
|
|
7412
|
+
const msg = err instanceof Error ? err.message : 'Failed to save agent';
|
|
7413
|
+
reply.code(500);
|
|
7414
|
+
return { success: false, error: msg };
|
|
7415
|
+
}
|
|
7416
|
+
});
|
|
7417
|
+
// DELETE /agents/:name — Remove an agent from the team
|
|
7418
|
+
app.delete('/agents/:name', async (request, reply) => {
|
|
7419
|
+
const name = request.params.name.toLowerCase();
|
|
7420
|
+
const existing = getAgentRoles().find(r => r.name === name);
|
|
7421
|
+
if (!existing) {
|
|
7422
|
+
reply.code(404);
|
|
7423
|
+
return { success: false, error: `Agent "${name}" not found` };
|
|
7424
|
+
}
|
|
7425
|
+
const { readFileSync, writeFileSync } = await import('node:fs');
|
|
7426
|
+
const { join } = await import('node:path');
|
|
7427
|
+
const filePath = join(REFLECTT_HOME, 'TEAM-ROLES.yaml');
|
|
7428
|
+
let yaml = readFileSync(filePath, 'utf-8');
|
|
7429
|
+
// Remove the agent block: from " - name: <name>" to the next " - name:" or top-level key or EOF
|
|
7430
|
+
const lines = yaml.split('\n');
|
|
7431
|
+
const filtered = [];
|
|
7432
|
+
let skipping = false;
|
|
7433
|
+
for (const line of lines) {
|
|
7434
|
+
if (line.match(new RegExp(`^\\s+-\\s+name:\\s+${name}\\s*$`))) {
|
|
7435
|
+
skipping = true;
|
|
7436
|
+
continue;
|
|
7437
|
+
}
|
|
7438
|
+
if (skipping) {
|
|
7439
|
+
// Stop skipping at next agent entry, top-level key, or blank line before top-level
|
|
7440
|
+
if (line.match(/^\s+-\s+name:\s/) || line.match(/^[a-z]/)) {
|
|
7441
|
+
skipping = false;
|
|
7442
|
+
filtered.push(line);
|
|
7443
|
+
}
|
|
7444
|
+
continue;
|
|
7445
|
+
}
|
|
7446
|
+
filtered.push(line);
|
|
7447
|
+
}
|
|
7448
|
+
yaml = filtered.join('\n');
|
|
7449
|
+
try {
|
|
7450
|
+
writeFileSync(filePath, yaml, 'utf-8');
|
|
7451
|
+
const { loadAgentRoles } = await import('./assignment.js');
|
|
7452
|
+
const reloaded = loadAgentRoles();
|
|
7453
|
+
return { success: true, removed: name, totalAgents: reloaded.roles.length };
|
|
7454
|
+
}
|
|
7455
|
+
catch (err) {
|
|
7456
|
+
const msg = err instanceof Error ? err.message : 'Failed to remove agent';
|
|
7457
|
+
reply.code(500);
|
|
7458
|
+
return { success: false, error: msg };
|
|
7459
|
+
}
|
|
7460
|
+
});
|
|
7081
7461
|
// Resolve a mention string (name, displayName, or alias) to an agent ID
|
|
7082
7462
|
app.get('/resolve/mention/:mention', async (request) => {
|
|
7083
7463
|
const agentName = resolveAgentMention(request.params.mention);
|
|
@@ -9048,6 +9428,7 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
9048
9428
|
endpoints: [
|
|
9049
9429
|
{ method: 'POST', path: '/reflections', hint: 'Submit. Required: pain, impact, evidence[], went_well, suspected_why, proposed_fix, confidence, role_type, author' },
|
|
9050
9430
|
{ method: 'GET', path: '/reflections', hint: 'List. Query: author, limit' },
|
|
9431
|
+
{ method: 'GET', path: '/reflections/schema', hint: 'Required/optional fields, role types, severity levels, dedup rules' },
|
|
9051
9432
|
],
|
|
9052
9433
|
},
|
|
9053
9434
|
activity: {
|
|
@@ -9301,8 +9682,18 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
9301
9682
|
reply.code(404);
|
|
9302
9683
|
return { success: false, error: 'Task not found', input: id, suggestions: lookup.suggestions };
|
|
9303
9684
|
}
|
|
9304
|
-
|
|
9305
|
-
|
|
9685
|
+
const assignee = String(task.assignee || '').trim();
|
|
9686
|
+
const isUnassigned = assignee.length === 0 || assignee.toLowerCase() === 'unassigned';
|
|
9687
|
+
if (!isUnassigned) {
|
|
9688
|
+
reply.code(409);
|
|
9689
|
+
return {
|
|
9690
|
+
success: false,
|
|
9691
|
+
error: `Task already assigned to ${assignee}`,
|
|
9692
|
+
code: 'TASK_ALREADY_ASSIGNED',
|
|
9693
|
+
status: 409,
|
|
9694
|
+
assignee,
|
|
9695
|
+
hint: 'Task claims are atomic: first claim wins. Pull another task via GET /tasks/next.',
|
|
9696
|
+
};
|
|
9306
9697
|
}
|
|
9307
9698
|
const shortId = lookup.resolvedId.replace(/^task-\d+-/, '');
|
|
9308
9699
|
const branch = `${body.agent}/task-${shortId}`;
|
|
@@ -10155,6 +10546,42 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
10155
10546
|
const q = request.query;
|
|
10156
10547
|
return { suggestions: getRoutingSuggestions({ since: q.since ? Number(q.since) : undefined }) };
|
|
10157
10548
|
});
|
|
10549
|
+
// ── Cost Dashboard ──
|
|
10550
|
+
// GET /costs — aggregated spend: daily by model, avg per lane, top tasks
|
|
10551
|
+
app.get('/costs', async (request) => {
|
|
10552
|
+
const q = request.query;
|
|
10553
|
+
const days = q.days ? Math.min(Number(q.days), 90) : 7;
|
|
10554
|
+
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
10555
|
+
const dailyByModel = getDailySpendByModel({ days });
|
|
10556
|
+
const byLane = getAvgCostByLane({ days: Math.max(days, 30) }); // lane data needs more window
|
|
10557
|
+
const byAgent = getAvgCostByAgent({ days: Math.max(days, 30) });
|
|
10558
|
+
const topTasks = getUsageByTask({ since, limit: 20 });
|
|
10559
|
+
const summary = getUsageSummary({ since });
|
|
10560
|
+
// Roll up daily totals per day for the sparkline
|
|
10561
|
+
const dailyTotals = {};
|
|
10562
|
+
for (const row of dailyByModel) {
|
|
10563
|
+
dailyTotals[row.date] = (dailyTotals[row.date] ?? 0) + row.total_cost_usd;
|
|
10564
|
+
}
|
|
10565
|
+
// Note: avg_cost_by_lane and avg_cost_by_agent use Math.max(days, 30) as their window.
|
|
10566
|
+
// Lane/agent-level averages need task density to be meaningful — a 7-day window might
|
|
10567
|
+
// have 0-1 closed tasks per agent/lane and produce misleading numbers. Using a 30-day
|
|
10568
|
+
// floor is intentional. daily_by_model, daily_totals, and top_tasks_by_cost use the
|
|
10569
|
+
// requested `days` window directly and will match the `window_days` field in the response.
|
|
10570
|
+
const laneAgentWindow = Math.max(days, 30);
|
|
10571
|
+
return {
|
|
10572
|
+
window_days: days,
|
|
10573
|
+
lane_agent_window_days: laneAgentWindow,
|
|
10574
|
+
summary: Array.isArray(summary) ? summary[0] ?? null : summary,
|
|
10575
|
+
daily_by_model: dailyByModel,
|
|
10576
|
+
daily_totals: Object.entries(dailyTotals)
|
|
10577
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
10578
|
+
.map(([date, total_cost_usd]) => ({ date, total_cost_usd })),
|
|
10579
|
+
avg_cost_by_lane: byLane,
|
|
10580
|
+
avg_cost_by_agent: byAgent,
|
|
10581
|
+
top_tasks_by_cost: topTasks,
|
|
10582
|
+
generated_at: Date.now(),
|
|
10583
|
+
};
|
|
10584
|
+
});
|
|
10158
10585
|
// Operational metrics endpoint (lightweight dashboard contract)
|
|
10159
10586
|
app.get('/metrics', async () => {
|
|
10160
10587
|
const startedAt = Date.now();
|
|
@@ -10572,6 +10999,10 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
10572
10999
|
request.headers['x-request-id'] ||
|
|
10573
11000
|
undefined;
|
|
10574
11001
|
const idempotencyKey = deliveryId ? `${provider}_${deliveryId}` : undefined;
|
|
11002
|
+
// Enrich GitHub webhook payloads with agent attribution
|
|
11003
|
+
const enrichedBody = provider === 'github'
|
|
11004
|
+
? enrichWebhookPayload(body)
|
|
11005
|
+
: body;
|
|
10575
11006
|
// Enqueue through delivery engine for each configured target
|
|
10576
11007
|
const events = [];
|
|
10577
11008
|
for (const route of routes) {
|
|
@@ -10582,7 +11013,7 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
10582
11013
|
const event = webhookDelivery.enqueue({
|
|
10583
11014
|
provider,
|
|
10584
11015
|
eventType,
|
|
10585
|
-
payload:
|
|
11016
|
+
payload: enrichedBody,
|
|
10586
11017
|
targetUrl: `http://localhost:${serverConfig.port}${route.path}`,
|
|
10587
11018
|
idempotencyKey: idempotencyKey ? `${idempotencyKey}_${route.id}` : undefined,
|
|
10588
11019
|
metadata: {
|
|
@@ -11329,6 +11760,62 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
11329
11760
|
app.get('/calendar/reminders/stats', async () => {
|
|
11330
11761
|
return getReminderEngineStats();
|
|
11331
11762
|
});
|
|
11763
|
+
// ── Schedule feed — team-wide time-awareness ──────────────────────────────
|
|
11764
|
+
//
|
|
11765
|
+
// Provides canonical records for deploy windows, focus blocks, and
|
|
11766
|
+
// scheduled task work so agents can coordinate timing without chat.
|
|
11767
|
+
//
|
|
11768
|
+
// MVP scope: one-off windows only. No iCal/RRULE, no reminders.
|
|
11769
|
+
// See src/schedule.ts for what is intentionally NOT included.
|
|
11770
|
+
// GET /schedule/feed — upcoming entries in chronological order
|
|
11771
|
+
app.get('/schedule/feed', async (request) => {
|
|
11772
|
+
const q = request.query;
|
|
11773
|
+
const kinds = q.kinds ? q.kinds.split(',') : undefined;
|
|
11774
|
+
const entries = getScheduleFeed({
|
|
11775
|
+
after: q.after ? parseInt(q.after, 10) : undefined,
|
|
11776
|
+
before: q.before ? parseInt(q.before, 10) : undefined,
|
|
11777
|
+
kinds,
|
|
11778
|
+
owner: q.owner,
|
|
11779
|
+
limit: q.limit ? parseInt(q.limit, 10) : undefined,
|
|
11780
|
+
});
|
|
11781
|
+
return { entries, count: entries.length };
|
|
11782
|
+
});
|
|
11783
|
+
// POST /schedule/entries — create a new schedule entry
|
|
11784
|
+
app.post('/schedule/entries', async (request, reply) => {
|
|
11785
|
+
try {
|
|
11786
|
+
const entry = createScheduleEntry(request.body);
|
|
11787
|
+
return reply.status(201).send({ entry });
|
|
11788
|
+
}
|
|
11789
|
+
catch (err) {
|
|
11790
|
+
return reply.status(400).send({ error: err.message });
|
|
11791
|
+
}
|
|
11792
|
+
});
|
|
11793
|
+
// GET /schedule/entries/:id
|
|
11794
|
+
app.get('/schedule/entries/:id', async (request, reply) => {
|
|
11795
|
+
const entry = getScheduleEntry(request.params.id);
|
|
11796
|
+
if (!entry)
|
|
11797
|
+
return reply.status(404).send({ error: 'Not found' });
|
|
11798
|
+
return { entry };
|
|
11799
|
+
});
|
|
11800
|
+
// PATCH /schedule/entries/:id
|
|
11801
|
+
app.patch('/schedule/entries/:id', async (request, reply) => {
|
|
11802
|
+
try {
|
|
11803
|
+
const entry = updateScheduleEntry(request.params.id, request.body);
|
|
11804
|
+
if (!entry)
|
|
11805
|
+
return reply.status(404).send({ error: 'Not found' });
|
|
11806
|
+
return { entry };
|
|
11807
|
+
}
|
|
11808
|
+
catch (err) {
|
|
11809
|
+
return reply.status(400).send({ error: err.message });
|
|
11810
|
+
}
|
|
11811
|
+
});
|
|
11812
|
+
// DELETE /schedule/entries/:id
|
|
11813
|
+
app.delete('/schedule/entries/:id', async (request, reply) => {
|
|
11814
|
+
const deleted = deleteScheduleEntry(request.params.id);
|
|
11815
|
+
if (!deleted)
|
|
11816
|
+
return reply.status(404).send({ error: 'Not found' });
|
|
11817
|
+
return reply.status(204).send();
|
|
11818
|
+
});
|
|
11332
11819
|
// ── iCal Import/Export ───────────────────────────────────────────────────
|
|
11333
11820
|
// Export all events as .ics
|
|
11334
11821
|
app.get('/calendar/export.ics', async (request, reply) => {
|