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.
Files changed (85) 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/dashboard.js +1 -1
  23. package/dist/dashboard.js.map +1 -1
  24. package/dist/deploy-monitor.d.ts +18 -0
  25. package/dist/deploy-monitor.d.ts.map +1 -0
  26. package/dist/deploy-monitor.js +165 -0
  27. package/dist/deploy-monitor.js.map +1 -0
  28. package/dist/events.d.ts.map +1 -1
  29. package/dist/events.js +15 -2
  30. package/dist/events.js.map +1 -1
  31. package/dist/executionSweeper.d.ts +1 -0
  32. package/dist/executionSweeper.d.ts.map +1 -1
  33. package/dist/executionSweeper.js +43 -7
  34. package/dist/executionSweeper.js.map +1 -1
  35. package/dist/files.d.ts.map +1 -1
  36. package/dist/files.js +17 -3
  37. package/dist/files.js.map +1 -1
  38. package/dist/fingerprint.d.ts +30 -0
  39. package/dist/fingerprint.d.ts.map +1 -0
  40. package/dist/fingerprint.js +122 -0
  41. package/dist/fingerprint.js.map +1 -0
  42. package/dist/github-webhook-attribution.d.ts +38 -0
  43. package/dist/github-webhook-attribution.d.ts.map +1 -0
  44. package/dist/github-webhook-attribution.js +123 -0
  45. package/dist/github-webhook-attribution.js.map +1 -0
  46. package/dist/inbox.d.ts.map +1 -1
  47. package/dist/inbox.js +4 -0
  48. package/dist/inbox.js.map +1 -1
  49. package/dist/index.js +37 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/pulse.d.ts +7 -0
  52. package/dist/pulse.d.ts.map +1 -1
  53. package/dist/pulse.js +15 -0
  54. package/dist/pulse.js.map +1 -1
  55. package/dist/review-state.d.ts +9 -0
  56. package/dist/review-state.d.ts.map +1 -0
  57. package/dist/review-state.js +17 -0
  58. package/dist/review-state.js.map +1 -0
  59. package/dist/schedule.d.ts +60 -0
  60. package/dist/schedule.d.ts.map +1 -0
  61. package/dist/schedule.js +176 -0
  62. package/dist/schedule.js.map +1 -0
  63. package/dist/server.d.ts.map +1 -1
  64. package/dist/server.js +501 -14
  65. package/dist/server.js.map +1 -1
  66. package/dist/suppression-ledger.d.ts.map +1 -1
  67. package/dist/suppression-ledger.js +12 -3
  68. package/dist/suppression-ledger.js.map +1 -1
  69. package/dist/system-loop-state.d.ts +1 -1
  70. package/dist/system-loop-state.d.ts.map +1 -1
  71. package/dist/system-loop-state.js +1 -0
  72. package/dist/system-loop-state.js.map +1 -1
  73. package/dist/tasks.d.ts +9 -1
  74. package/dist/tasks.d.ts.map +1 -1
  75. package/dist/tasks.js +193 -41
  76. package/dist/tasks.js.map +1 -1
  77. package/dist/types.d.ts +1 -1
  78. package/dist/types.d.ts.map +1 -1
  79. package/dist/usage-tracking.d.ts +26 -0
  80. package/dist/usage-tracking.d.ts.map +1 -1
  81. package/dist/usage-tracking.js +91 -4
  82. package/dist/usage-tracking.js.map +1 -1
  83. package/package.json +1 -1
  84. package/public/dashboard.js +136 -56
  85. 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`
@@ -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
- 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
+ }
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: 'File exceeds 50MB limit' };
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: 'File exceeds 50MB limit' };
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
- if (task.assignee) {
9305
- 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
+ };
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: body,
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) => {