reflectt-node 0.1.7 → 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.
Files changed (80) hide show
  1. package/README.md +13 -0
  2. package/defaults/gitignore.template +23 -0
  3. package/dist/boardHealthWorker.d.ts +4 -0
  4. package/dist/boardHealthWorker.d.ts.map +1 -1
  5. package/dist/boardHealthWorker.js +36 -1
  6. package/dist/boardHealthWorker.js.map +1 -1
  7. package/dist/buildInfo.d.ts.map +1 -1
  8. package/dist/buildInfo.js +47 -10
  9. package/dist/buildInfo.js.map +1 -1
  10. package/dist/chat.d.ts +4 -0
  11. package/dist/chat.d.ts.map +1 -1
  12. package/dist/chat.js +6 -2
  13. package/dist/chat.js.map +1 -1
  14. package/dist/cli.js +37 -12
  15. package/dist/cli.js.map +1 -1
  16. package/dist/cloud.d.ts.map +1 -1
  17. package/dist/cloud.js +131 -64
  18. package/dist/cloud.js.map +1 -1
  19. package/dist/continuity-loop.d.ts.map +1 -1
  20. package/dist/continuity-loop.js +297 -29
  21. package/dist/continuity-loop.js.map +1 -1
  22. package/dist/deploy-monitor.d.ts +18 -0
  23. package/dist/deploy-monitor.d.ts.map +1 -0
  24. package/dist/deploy-monitor.js +165 -0
  25. package/dist/deploy-monitor.js.map +1 -0
  26. package/dist/executionSweeper.d.ts +1 -0
  27. package/dist/executionSweeper.d.ts.map +1 -1
  28. package/dist/executionSweeper.js +43 -7
  29. package/dist/executionSweeper.js.map +1 -1
  30. package/dist/files.d.ts.map +1 -1
  31. package/dist/files.js +17 -3
  32. package/dist/files.js.map +1 -1
  33. package/dist/fingerprint.d.ts +30 -0
  34. package/dist/fingerprint.d.ts.map +1 -0
  35. package/dist/fingerprint.js +122 -0
  36. package/dist/fingerprint.js.map +1 -0
  37. package/dist/github-webhook-attribution.d.ts +38 -0
  38. package/dist/github-webhook-attribution.d.ts.map +1 -0
  39. package/dist/github-webhook-attribution.js +123 -0
  40. package/dist/github-webhook-attribution.js.map +1 -0
  41. package/dist/inbox.d.ts.map +1 -1
  42. package/dist/inbox.js +4 -0
  43. package/dist/inbox.js.map +1 -1
  44. package/dist/index.js +37 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/pulse.d.ts +7 -0
  47. package/dist/pulse.d.ts.map +1 -1
  48. package/dist/pulse.js +15 -0
  49. package/dist/pulse.js.map +1 -1
  50. package/dist/review-state.d.ts +9 -0
  51. package/dist/review-state.d.ts.map +1 -0
  52. package/dist/review-state.js +17 -0
  53. package/dist/review-state.js.map +1 -0
  54. package/dist/schedule.d.ts +60 -0
  55. package/dist/schedule.d.ts.map +1 -0
  56. package/dist/schedule.js +176 -0
  57. package/dist/schedule.js.map +1 -0
  58. package/dist/server.d.ts.map +1 -1
  59. package/dist/server.js +486 -14
  60. package/dist/server.js.map +1 -1
  61. package/dist/suppression-ledger.d.ts.map +1 -1
  62. package/dist/suppression-ledger.js +12 -3
  63. package/dist/suppression-ledger.js.map +1 -1
  64. package/dist/system-loop-state.d.ts +1 -1
  65. package/dist/system-loop-state.d.ts.map +1 -1
  66. package/dist/system-loop-state.js +1 -0
  67. package/dist/system-loop-state.js.map +1 -1
  68. package/dist/tasks.d.ts +9 -1
  69. package/dist/tasks.d.ts.map +1 -1
  70. package/dist/tasks.js +193 -41
  71. package/dist/tasks.js.map +1 -1
  72. package/dist/types.d.ts +1 -1
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/usage-tracking.d.ts +26 -0
  75. package/dist/usage-tracking.d.ts.map +1 -1
  76. package/dist/usage-tracking.js +91 -4
  77. package/dist/usage-tracking.js.map +1 -1
  78. package/package.json +1 -1
  79. package/public/dashboard.js +119 -37
  80. 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
- function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
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 (finance, legal, ops): set non_code=true. For config tasks: set config_only=true.',
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
- return { ok: true };
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
- await app.register(fastifyMultipart.default, { limits: { fileSize: 50 * 1024 * 1024 } });
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`
@@ -6106,7 +6309,23 @@ export async function createServer() {
6106
6309
  }
6107
6310
  // Merge incoming metadata with existing for gate checks + persistence.
6108
6311
  // Apply auto-defaults (ETA, artifact_path) when not explicitly provided.
6109
- const incomingMeta = parsed.metadata || {};
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
+ }
6110
6329
  const effectiveTargetStatus = parsed.status ?? existing.status;
6111
6330
  const autoFilledMeta = applyAutoDefaults(lookup.resolvedId, effectiveTargetStatus, incomingMeta);
6112
6331
  const mergedRawMeta = { ...(existing.metadata || {}), ...autoFilledMeta };
@@ -6285,8 +6504,12 @@ export async function createServer() {
6285
6504
  hint: qaGate.hint,
6286
6505
  };
6287
6506
  }
6288
- const handoffGate = enforceReviewHandoffGateForValidating(effectiveStatus, lookup.resolvedId, mergedMeta);
6507
+ const handoffGate = await enforceReviewHandoffGateForValidating(effectiveStatus, lookup.resolvedId, mergedMeta);
6289
6508
  if (!handoffGate.ok) {
6509
+ reviewHandoffValidationStats.failures += 1;
6510
+ reviewHandoffValidationStats.lastFailureAt = Date.now();
6511
+ reviewHandoffValidationStats.lastFailureTaskId = lookup.resolvedId;
6512
+ reviewHandoffValidationStats.lastFailureError = handoffGate.error;
6290
6513
  reply.code(400);
6291
6514
  return {
6292
6515
  success: false,
@@ -6617,6 +6840,7 @@ export async function createServer() {
6617
6840
  const prLine = prUrl ? `\nPR: ${prUrl}` : '';
6618
6841
  chatManager.sendMessage({
6619
6842
  from: 'system',
6843
+ to: existing.reviewer,
6620
6844
  content: `@${existing.reviewer} [reviewRequested:${task.id}] ${task.title} → validating${prLine}`,
6621
6845
  channel: 'task-notifications',
6622
6846
  metadata: {
@@ -6726,6 +6950,7 @@ export async function createServer() {
6726
6950
  const reviewMsg = `@${task.reviewer} review requested: **${task.title}** (${task.id})${prLink}. Please approve or flag issues.`;
6727
6951
  chatManager.sendMessage({
6728
6952
  from: 'system',
6953
+ to: task.reviewer,
6729
6954
  content: reviewMsg,
6730
6955
  channel: 'reviews',
6731
6956
  metadata: {
@@ -6844,6 +7069,7 @@ export async function createServer() {
6844
7069
  const sourceInfo = getAgentRolesSource();
6845
7070
  return { success: true, agents: enriched, config: sourceInfo };
6846
7071
  };
7072
+ app.get('/agents', async () => buildRoleRegistryPayload());
6847
7073
  app.get('/agents/roles', async () => buildRoleRegistryPayload());
6848
7074
  // Team-scoped alias for assignment-engine consumers
6849
7075
  app.get('/team/roles', async () => {
@@ -6860,6 +7086,13 @@ export async function createServer() {
6860
7086
  // ── File upload/download ──
6861
7087
  app.post('/files', async (request, reply) => {
6862
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
+ }
6863
7096
  const data = await request.file();
6864
7097
  if (!data) {
6865
7098
  reply.code(400);
@@ -6869,10 +7102,10 @@ export async function createServer() {
6869
7102
  for await (const chunk of data.file)
6870
7103
  chunks.push(chunk);
6871
7104
  const buffer = Buffer.concat(chunks);
6872
- // Check if stream was truncated (exceeds limit)
7105
+ // Check if stream was truncated (exceeds multipart limit)
6873
7106
  if (data.file.truncated) {
6874
7107
  reply.code(413);
6875
- return { success: false, error: 'File exceeds 50MB limit' };
7108
+ return { success: false, error: `File exceeds ${maxBytes / (1024 * 1024)}MB limit` };
6876
7109
  }
6877
7110
  const fields = data.fields;
6878
7111
  const uploadedBy = typeof fields?.uploadedBy?.value === 'string' ? fields.uploadedBy.value : 'anonymous';
@@ -6894,10 +7127,11 @@ export async function createServer() {
6894
7127
  return result;
6895
7128
  }
6896
7129
  catch (err) {
7130
+ const { MAX_SIZE_BYTES: maxBytes } = await import('./files.js');
6897
7131
  const msg = err instanceof Error ? err.message : String(err);
6898
7132
  if (msg.includes('Request file too large')) {
6899
7133
  reply.code(413);
6900
- return { success: false, error: 'File exceeds 50MB limit' };
7134
+ return { success: false, error: `File exceeds ${maxBytes / (1024 * 1024)}MB limit` };
6901
7135
  }
6902
7136
  reply.code(500);
6903
7137
  return { success: false, error: 'Upload failed' };
@@ -7093,6 +7327,137 @@ export async function createServer() {
7093
7327
  return { success: false, error: msg };
7094
7328
  }
7095
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
+ });
7096
7461
  // Resolve a mention string (name, displayName, or alias) to an agent ID
7097
7462
  app.get('/resolve/mention/:mention', async (request) => {
7098
7463
  const agentName = resolveAgentMention(request.params.mention);
@@ -9063,6 +9428,7 @@ If your heartbeat shows **no active task** and **no next task**:
9063
9428
  endpoints: [
9064
9429
  { method: 'POST', path: '/reflections', hint: 'Submit. Required: pain, impact, evidence[], went_well, suspected_why, proposed_fix, confidence, role_type, author' },
9065
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' },
9066
9432
  ],
9067
9433
  },
9068
9434
  activity: {
@@ -9316,8 +9682,18 @@ If your heartbeat shows **no active task** and **no next task**:
9316
9682
  reply.code(404);
9317
9683
  return { success: false, error: 'Task not found', input: id, suggestions: lookup.suggestions };
9318
9684
  }
9319
- if (task.assignee) {
9320
- return { success: false, error: `Task already assigned to ${task.assignee}` };
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
+ };
9321
9697
  }
9322
9698
  const shortId = lookup.resolvedId.replace(/^task-\d+-/, '');
9323
9699
  const branch = `${body.agent}/task-${shortId}`;
@@ -10170,6 +10546,42 @@ If your heartbeat shows **no active task** and **no next task**:
10170
10546
  const q = request.query;
10171
10547
  return { suggestions: getRoutingSuggestions({ since: q.since ? Number(q.since) : undefined }) };
10172
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
+ });
10173
10585
  // Operational metrics endpoint (lightweight dashboard contract)
10174
10586
  app.get('/metrics', async () => {
10175
10587
  const startedAt = Date.now();
@@ -10587,6 +10999,10 @@ If your heartbeat shows **no active task** and **no next task**:
10587
10999
  request.headers['x-request-id'] ||
10588
11000
  undefined;
10589
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;
10590
11006
  // Enqueue through delivery engine for each configured target
10591
11007
  const events = [];
10592
11008
  for (const route of routes) {
@@ -10597,7 +11013,7 @@ If your heartbeat shows **no active task** and **no next task**:
10597
11013
  const event = webhookDelivery.enqueue({
10598
11014
  provider,
10599
11015
  eventType,
10600
- payload: body,
11016
+ payload: enrichedBody,
10601
11017
  targetUrl: `http://localhost:${serverConfig.port}${route.path}`,
10602
11018
  idempotencyKey: idempotencyKey ? `${idempotencyKey}_${route.id}` : undefined,
10603
11019
  metadata: {
@@ -11344,6 +11760,62 @@ If your heartbeat shows **no active task** and **no next task**:
11344
11760
  app.get('/calendar/reminders/stats', async () => {
11345
11761
  return getReminderEngineStats();
11346
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
+ });
11347
11819
  // ── iCal Import/Export ───────────────────────────────────────────────────
11348
11820
  // Export all events as .ics
11349
11821
  app.get('/calendar/export.ics', async (request, reply) => {