@aitne/daemon 0.1.10 → 0.1.11

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 (305) hide show
  1. package/dist/adapters/adapter-watchdog.d.ts +70 -0
  2. package/dist/adapters/adapter-watchdog.js +115 -0
  3. package/dist/adapters/discord.d.ts +17 -1
  4. package/dist/adapters/discord.js +33 -0
  5. package/dist/adapters/notification-manager.d.ts +27 -1
  6. package/dist/adapters/notification-manager.js +54 -39
  7. package/dist/adapters/slack-adapter.d.ts +26 -1
  8. package/dist/adapters/slack-adapter.js +41 -0
  9. package/dist/adapters/telegram-adapter.d.ts +18 -1
  10. package/dist/adapters/telegram-adapter.js +41 -2
  11. package/dist/adapters/types.d.ts +20 -0
  12. package/dist/adapters/whatsapp-adapter.d.ts +26 -7
  13. package/dist/adapters/whatsapp-adapter.js +74 -21
  14. package/dist/api/env-writer.js +8 -5
  15. package/dist/api/helpers/agent-errors-registry.d.ts +5 -5
  16. package/dist/api/helpers/agent-errors-registry.js +5 -5
  17. package/dist/api/routes/agent.js +33 -12
  18. package/dist/api/routes/agents/index.js +75 -16
  19. package/dist/api/routes/agents/views.d.ts +37 -2
  20. package/dist/api/routes/agents/views.js +64 -2
  21. package/dist/api/routes/background-task.d.ts +22 -0
  22. package/dist/api/routes/background-task.js +338 -0
  23. package/dist/api/routes/browser-history.js +9 -1
  24. package/dist/api/routes/context/permissions.js +3 -2
  25. package/dist/api/routes/context/snapshots.js +0 -3
  26. package/dist/api/routes/context/write.js +3 -17
  27. package/dist/api/routes/dashboard/config.js +48 -12
  28. package/dist/api/routes/dashboard/cost-approvals.js +66 -0
  29. package/dist/api/routes/dashboard/notifications.js +9 -9
  30. package/dist/api/routes/integrations/crud-patch.js +5 -1
  31. package/dist/api/routes/integrations-reconcile.js +2 -2
  32. package/dist/api/routes/notion.d.ts +1 -1
  33. package/dist/api/routes/observations.js +7 -7
  34. package/dist/api/routes/obsidian.d.ts +1 -1
  35. package/dist/api/routes/receipts.js +5 -1
  36. package/dist/api/routes/setup-migrate.js +1 -1
  37. package/dist/api/routes/setup.js +1 -1
  38. package/dist/api/routes/task-flows.d.ts +1 -1
  39. package/dist/api/routes/task-flows.js +1 -1
  40. package/dist/api/routes/tuning.d.ts +29 -0
  41. package/dist/api/routes/tuning.js +304 -0
  42. package/dist/api/server.d.ts +44 -16
  43. package/dist/api/server.js +9 -0
  44. package/dist/bootstrap/adapters.d.ts +19 -0
  45. package/dist/bootstrap/adapters.js +61 -0
  46. package/dist/bootstrap/api.d.ts +5 -3
  47. package/dist/bootstrap/api.js +45 -13
  48. package/dist/bootstrap/catchup.d.ts +1 -1
  49. package/dist/bootstrap/catchup.js +11 -11
  50. package/dist/bootstrap/event-pipeline.d.ts +11 -0
  51. package/dist/bootstrap/event-pipeline.js +245 -7
  52. package/dist/bootstrap/observers.js +9 -6
  53. package/dist/bootstrap/schedule-helpers.d.ts +104 -6
  54. package/dist/bootstrap/schedule-helpers.js +172 -19
  55. package/dist/config.js +26 -12
  56. package/dist/core/agent-core.d.ts +33 -1
  57. package/dist/core/agent-core.js +36 -1
  58. package/dist/core/agents/activity-scan-cadence.d.ts +103 -0
  59. package/dist/core/agents/activity-scan-cadence.js +127 -0
  60. package/dist/core/agents/agent-route-override.d.ts +53 -0
  61. package/dist/core/agents/agent-route-override.js +69 -0
  62. package/dist/core/agents/builtin-registry.d.ts +51 -14
  63. package/dist/core/agents/builtin-registry.js +92 -15
  64. package/dist/core/agents/config-gate-reconcile.d.ts +38 -0
  65. package/dist/core/agents/config-gate-reconcile.js +51 -0
  66. package/dist/core/agents/cron-substitute.d.ts +1 -1
  67. package/dist/core/agents/cron-substitute.js +1 -1
  68. package/dist/core/agents/custom-routine-migration.d.ts +60 -0
  69. package/dist/core/agents/custom-routine-migration.js +149 -0
  70. package/dist/core/agents/firing-blocked.d.ts +1 -1
  71. package/dist/core/agents/hourly-cadence.d.ts +102 -0
  72. package/dist/core/agents/hourly-cadence.js +126 -0
  73. package/dist/core/agents/loader-boot.js +23 -0
  74. package/dist/core/agents/loader.d.ts +19 -0
  75. package/dist/core/agents/loader.js +34 -2
  76. package/dist/core/agents/override-merge.d.ts +1 -1
  77. package/dist/core/agents/override-merge.js +9 -1
  78. package/dist/core/agents/recurrence-convert.d.ts +1 -1
  79. package/dist/core/agents/recurrence-convert.js +1 -1
  80. package/dist/core/agents/recurring-schedule-adapter.js +8 -0
  81. package/dist/core/alerts.js +6 -6
  82. package/dist/core/backends/auth-health-monitor.d.ts +2 -2
  83. package/dist/core/backends/auth-health-monitor.js +1 -1
  84. package/dist/core/backends/backend-router.d.ts +27 -1
  85. package/dist/core/backends/backend-router.js +165 -1
  86. package/dist/core/backends/claude-code-core.d.ts +71 -31
  87. package/dist/core/backends/claude-code-core.js +282 -54
  88. package/dist/core/backends/cli-quota-guards.d.ts +29 -1
  89. package/dist/core/backends/cli-quota-guards.js +40 -5
  90. package/dist/core/backends/codex-core.d.ts +6 -0
  91. package/dist/core/backends/codex-core.js +22 -6
  92. package/dist/core/backends/failure-spend.d.ts +58 -0
  93. package/dist/core/backends/failure-spend.js +137 -0
  94. package/dist/core/backends/gemini-cli-core.d.ts +6 -0
  95. package/dist/core/backends/gemini-cli-core.js +25 -6
  96. package/dist/core/backends/model-registry.d.ts +1 -1
  97. package/dist/core/backends/model-registry.js +4 -4
  98. package/dist/core/backends/opencode-core.d.ts +1 -1
  99. package/dist/core/backends/opencode-core.js +5 -5
  100. package/dist/core/backends/plan-presets.js +39 -15
  101. package/dist/core/bang-commands/commands-cost.js +3 -1
  102. package/dist/core/bang-commands/commands-report.js +4 -3
  103. package/dist/core/bang-commands/commands-research.js +4 -1
  104. package/dist/core/bang-commands/commands-revert-tuning.d.ts +18 -0
  105. package/dist/core/bang-commands/commands-revert-tuning.js +63 -0
  106. package/dist/core/bang-commands/commands-stop-start.js +3 -3
  107. package/dist/core/bang-commands/commands-task-control.d.ts +19 -0
  108. package/dist/core/bang-commands/commands-task-control.js +147 -0
  109. package/dist/core/bang-commands/commands-wiki.js +5 -5
  110. package/dist/core/bang-commands/index.d.ts +2 -0
  111. package/dist/core/bang-commands/index.js +12 -0
  112. package/dist/core/bang-commands/registry.d.ts +12 -0
  113. package/dist/core/browser-history/research-cluster-fanout.d.ts +28 -14
  114. package/dist/core/browser-history/research-cluster-fanout.js +39 -16
  115. package/dist/core/channel-timeline.d.ts +5 -1
  116. package/dist/core/channel-timeline.js +13 -0
  117. package/dist/core/context/index-reconciler.js +5 -2
  118. package/dist/core/context/policy-index-reconciler.d.ts +6 -4
  119. package/dist/core/context/policy-index-runner.js +25 -6
  120. package/dist/core/context-builder-calendar.js +10 -2
  121. package/dist/core/context-builder-conversation.d.ts +8 -1
  122. package/dist/core/context-builder-conversation.js +41 -7
  123. package/dist/core/context-builder-yesterday.js +4 -3
  124. package/dist/core/context-builder.d.ts +7 -2
  125. package/dist/core/context-builder.js +62 -20
  126. package/dist/core/context-file-serializer.d.ts +1 -1
  127. package/dist/core/context-file-serializer.js +1 -1
  128. package/dist/core/context-health.js +2 -2
  129. package/dist/core/context-paths.d.ts +1 -1
  130. package/dist/core/context-paths.js +1 -1
  131. package/dist/core/context-validation/prepare-write.js +1 -1
  132. package/dist/core/context-validation/routine-rulebook.d.ts +1 -1
  133. package/dist/core/context-vault-aliases.d.ts +0 -13
  134. package/dist/core/context-vault-aliases.js +37 -0
  135. package/dist/core/custom-routines.d.ts +99 -0
  136. package/dist/core/custom-routines.js +187 -0
  137. package/dist/core/daemon-api-cli.js +49 -0
  138. package/dist/core/day-boundary.d.ts +46 -0
  139. package/dist/core/day-boundary.js +40 -0
  140. package/dist/core/dispatcher-activity-scan.d.ts +221 -0
  141. package/dist/core/dispatcher-activity-scan.js +775 -0
  142. package/dist/core/dispatcher-error-handling.d.ts +6 -11
  143. package/dist/core/dispatcher-error-handling.js +38 -62
  144. package/dist/core/dispatcher-hourly-check.js +6 -1
  145. package/dist/core/dispatcher-message-handler.d.ts +10 -0
  146. package/dist/core/dispatcher-message-handler.js +17 -0
  147. package/dist/core/dispatcher-morning-routine.d.ts +6 -6
  148. package/dist/core/dispatcher-morning-routine.js +13 -13
  149. package/dist/core/dispatcher-result-processor.d.ts +33 -0
  150. package/dist/core/dispatcher-result-processor.js +167 -11
  151. package/dist/core/dispatcher-scheduled-background-task.d.ts +42 -0
  152. package/dist/core/dispatcher-scheduled-background-task.js +89 -0
  153. package/dist/core/dispatcher-scheduled-tasks.d.ts +63 -1
  154. package/dist/core/dispatcher-scheduled-tasks.js +213 -6
  155. package/dist/core/dispatcher-task-delivery.d.ts +105 -0
  156. package/dist/core/dispatcher-task-delivery.js +555 -0
  157. package/dist/core/dispatcher-types.d.ts +48 -9
  158. package/dist/core/dispatcher-types.js +3 -3
  159. package/dist/core/dispatcher.d.ts +112 -31
  160. package/dist/core/dispatcher.js +284 -59
  161. package/dist/core/dm-freshness-metrics.d.ts +1 -1
  162. package/dist/core/drift-effects.js +2 -2
  163. package/dist/core/feedback/consolidation-prep.js +17 -5
  164. package/dist/core/feedback/eviction-scorer.js +6 -2
  165. package/dist/core/feedback/lesson-format.js +9 -4
  166. package/dist/core/feedback/lesson-injection.d.ts +1 -1
  167. package/dist/core/feedback/lesson-injection.js +17 -2
  168. package/dist/core/feedback/lesson-store-overview.d.ts +8 -4
  169. package/dist/core/feedback/lesson-store-overview.js +8 -4
  170. package/dist/core/feedback/regeneralization-prep.js +29 -16
  171. package/dist/core/feedback/self-performance-prep.d.ts +186 -0
  172. package/dist/core/feedback/self-performance-prep.js +541 -0
  173. package/dist/core/feedback/tuning-actuator.d.ts +198 -0
  174. package/dist/core/feedback/tuning-actuator.js +432 -0
  175. package/dist/core/feedback/tuning-recommender.d.ts +247 -0
  176. package/dist/core/feedback/tuning-recommender.js +580 -0
  177. package/dist/core/feedback/tuning-revert-monitor.d.ts +90 -0
  178. package/dist/core/feedback/tuning-revert-monitor.js +213 -0
  179. package/dist/core/health-monitor.d.ts +6 -0
  180. package/dist/core/health-monitor.js +1 -1
  181. package/dist/core/injection-policy.d.ts +4 -4
  182. package/dist/core/injection-policy.js +4 -4
  183. package/dist/core/integration-main-backend.js +4 -0
  184. package/dist/core/management-md.d.ts +2 -2
  185. package/dist/core/management-md.js +51 -13
  186. package/dist/core/morning/orchestrator.d.ts +2 -2
  187. package/dist/core/morning/orchestrator.js +2 -2
  188. package/dist/core/notification-gate.d.ts +64 -0
  189. package/dist/core/notification-gate.js +51 -0
  190. package/dist/core/notification-rate-limit.d.ts +40 -0
  191. package/dist/core/notification-rate-limit.js +50 -0
  192. package/dist/core/policy-files.d.ts +1 -1
  193. package/dist/core/policy-files.js +2 -2
  194. package/dist/core/pre-pass-freshness.d.ts +4 -4
  195. package/dist/core/retention.d.ts +5 -0
  196. package/dist/core/retention.js +20 -4
  197. package/dist/core/review-context.d.ts +1 -1
  198. package/dist/core/review-context.js +10 -5
  199. package/dist/core/roadmap-write-lock.d.ts +2 -1
  200. package/dist/core/roadmap-write-lock.js +15 -10
  201. package/dist/core/routine-acquisition-plan.d.ts +47 -1
  202. package/dist/core/routine-acquisition-plan.js +78 -20
  203. package/dist/core/routine-fetch-window-retry.js +7 -4
  204. package/dist/core/routine-fetch-window-runner.d.ts +39 -3
  205. package/dist/core/routine-fetch-window-runner.js +264 -13
  206. package/dist/core/routine-windows.d.ts +2 -2
  207. package/dist/core/routine-windows.js +8 -5
  208. package/dist/core/scheduler.d.ts +175 -16
  209. package/dist/core/scheduler.js +559 -102
  210. package/dist/core/signal-detector.d.ts +12 -0
  211. package/dist/core/signal-detector.js +53 -9
  212. package/dist/core/skills-compiler-denied-tools.js +2 -2
  213. package/dist/core/skills-compiler-skill-index.d.ts +2 -2
  214. package/dist/core/skills-compiler-skill-index.js +2 -2
  215. package/dist/core/skills-compiler-variants.d.ts +1 -1
  216. package/dist/core/skills-compiler-variants.js +8 -0
  217. package/dist/core/skills-compiler.d.ts +29 -26
  218. package/dist/core/skills-compiler.js +117 -81
  219. package/dist/core/skills-manifest.d.ts +37 -0
  220. package/dist/core/skills-manifest.js +73 -2
  221. package/dist/core/sleep-inhibitor.d.ts +79 -0
  222. package/dist/core/sleep-inhibitor.js +132 -0
  223. package/dist/core/slim-system-prompt-loader.d.ts +77 -0
  224. package/dist/core/slim-system-prompt-loader.js +141 -0
  225. package/dist/core/spawn-gates.d.ts +126 -0
  226. package/dist/core/spawn-gates.js +180 -0
  227. package/dist/core/today-direct-writer.d.ts +2 -2
  228. package/dist/core/today-direct-writer.js +1 -1
  229. package/dist/core/today-write-lock.d.ts +4 -2
  230. package/dist/core/today-write-lock.js +30 -20
  231. package/dist/core/wake-detector.d.ts +55 -0
  232. package/dist/core/wake-detector.js +80 -0
  233. package/dist/core/wiki/compile-lock.d.ts +1 -1
  234. package/dist/core/wiki/compile-lock.js +1 -1
  235. package/dist/core/workdir.js +15 -6
  236. package/dist/db/activity-scan-signals.d.ts +77 -0
  237. package/dist/db/activity-scan-signals.js +378 -0
  238. package/dist/db/agents-store.d.ts +28 -0
  239. package/dist/db/agents-store.js +62 -0
  240. package/dist/db/background-task-clarifications-store.d.ts +81 -0
  241. package/dist/db/background-task-clarifications-store.js +152 -0
  242. package/dist/db/background-task-store.d.ts +207 -0
  243. package/dist/db/background-task-store.js +380 -0
  244. package/dist/db/browser-history-store.d.ts +39 -6
  245. package/dist/db/browser-history-store.js +51 -7
  246. package/dist/db/browser-task-clarifications-store.d.ts +12 -0
  247. package/dist/db/browser-task-clarifications-store.js +35 -5
  248. package/dist/db/browser-task-store.d.ts +3 -0
  249. package/dist/db/browser-task-store.js +29 -4
  250. package/dist/db/deferred-dm.d.ts +86 -0
  251. package/dist/db/deferred-dm.js +199 -0
  252. package/dist/db/migrations.js +330 -0
  253. package/dist/db/observations.d.ts +2 -2
  254. package/dist/db/observations.js +3 -3
  255. package/dist/db/schema.js +217 -16
  256. package/dist/db/voice-transcripts-store.d.ts +1 -1
  257. package/dist/index.js +86 -29
  258. package/dist/messaging/browser-task-mcp-notifier.d.ts +12 -70
  259. package/dist/messaging/browser-task-mcp-notifier.js +30 -151
  260. package/dist/messaging/browser-task-screenshot-attachment.d.ts +15 -0
  261. package/dist/messaging/browser-task-screenshot-attachment.js +63 -0
  262. package/dist/observers/delegated-sync-worker.d.ts +6 -6
  263. package/dist/observers/delegated-sync-worker.js +10 -10
  264. package/dist/observers/git-delegated-cron.d.ts +1 -1
  265. package/dist/observers/git-delegated-cron.js +2 -2
  266. package/dist/observers/github-poller-classifier.d.ts +3 -3
  267. package/dist/observers/github-poller-classifier.js +3 -3
  268. package/dist/observers/imminent-event-scheduler.d.ts +1 -1
  269. package/dist/observers/imminent-event-scheduler.js +1 -1
  270. package/dist/observers/mail-poller.d.ts +1 -0
  271. package/dist/observers/mail-poller.js +42 -3
  272. package/dist/observers/observation-summarizer/summarizer-client.d.ts +2 -2
  273. package/dist/observers/observation-summarizer/summarizer-client.js +2 -2
  274. package/dist/observers/observation-summarizer/worker.d.ts +2 -2
  275. package/dist/observers/observation-summarizer/worker.js +4 -4
  276. package/dist/observers/obsidian-watcher.d.ts +1 -1
  277. package/dist/observers/obsidian-watcher.js +1 -1
  278. package/dist/safety/agent-write-tracker.d.ts +4 -4
  279. package/dist/safety/agent-write-tracker.js +4 -4
  280. package/dist/safety/audit.d.ts +43 -5
  281. package/dist/safety/audit.js +86 -18
  282. package/dist/safety/risk-classifier.d.ts +6 -0
  283. package/dist/safety/risk-classifier.js +75 -11
  284. package/dist/scheduler/activity-scan-gate.d.ts +86 -0
  285. package/dist/scheduler/activity-scan-gate.js +132 -0
  286. package/dist/services/background-task/background-task-budget.d.ts +80 -0
  287. package/dist/services/background-task/background-task-budget.js +91 -0
  288. package/dist/services/background-task/background-task-driver.d.ts +105 -0
  289. package/dist/services/background-task/background-task-driver.js +416 -0
  290. package/dist/services/background-task/background-task-runner.d.ts +96 -0
  291. package/dist/services/background-task/background-task-runner.js +673 -0
  292. package/dist/services/background-task/background-task-tools.d.ts +84 -0
  293. package/dist/services/background-task/background-task-tools.js +247 -0
  294. package/dist/services/background-task/background-task-transition-events.d.ts +43 -0
  295. package/dist/services/background-task/background-task-transition-events.js +54 -0
  296. package/dist/services/browser-history/automation/egress-denylist.d.ts +1 -1
  297. package/dist/services/browser-history/automation/egress-denylist.js +16 -6
  298. package/dist/services/browser-history/managed-chromium/sandbox-launcher.js +0 -1
  299. package/dist/services/browser-task/browser-task-runner.js +53 -8
  300. package/dist/services/observations-batch.d.ts +1 -1
  301. package/dist/services/observations-batch.js +2 -2
  302. package/dist/settings/runtime-settings.d.ts +38 -11
  303. package/dist/settings/runtime-settings.js +203 -40
  304. package/dist/settings/settings-store.js +11 -3
  305. package/package.json +4 -4
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Self-Tuning Review Cycle — Verify stage / auto-revert monitor
3
+ * (SELF_TUNING_REVIEW_CYCLE_DESIGN.md §3.4, Phase 3).
4
+ *
5
+ * Piggybacks the existing hourly cron tick (scheduler.ts — same
6
+ * fire-and-forget slot as the auth probe; no new scheduled session, P2) and
7
+ * throttles itself to one pass per UTC day via
8
+ * {@link REVERT_MONITOR_STATE_KEY}. Seven days after an applied config
9
+ * change, it recomputes the rule's target metric over the verify window
10
+ * `[applied_at, applied_at + 7d)` and:
11
+ *
12
+ * - **regression past the rule's margin** → revert through the shared
13
+ * {@link revertAppliedTuningChange} (config restored via the
14
+ * `applyConfigUpdates` chokepoint, ledger stamped `reverted_at` — which
15
+ * triggers the 28-day re-proposal cool-down — audit
16
+ * `self_tuning.reverted`, `self_critique` signal so the failure becomes
17
+ * a lesson) and DM the owner;
18
+ * - **no regression** → stamp `verified_at` + audit
19
+ * `self_tuning.verified` so the entry is never re-examined.
20
+ *
21
+ * Per-rule margins (D3/D4 — named constants, deliberately not settings
22
+ * keys):
23
+ * - R1 reverts if daily novelty≥2 observation arrivals fall >30% below
24
+ * the pre-change baseline (stale pre-pass suppressing signal) OR the
25
+ * cautious-escalate tick share rises >10 pt.
26
+ * - R3 reverts if >10% of `stage0_silent` ticks in the window carried
27
+ * `maxNoveltyScore ≥ 2` in their audited snapshot — harm only the
28
+ * raised ceiling can introduce (today's gate never silences novelty≥2).
29
+ * - R5 reverts on the explicit-correction proxy: any negative explicit /
30
+ * self_critique signal citing a lesson within the window.
31
+ *
32
+ * The monitor runs regardless of `selfTuningEnabled`: entries only exist
33
+ * once actuation has run, and a safety rollback must keep working even if
34
+ * the owner turns the loop off afterwards. Only `config`-actuator entries
35
+ * are verified — lesson/schedule entries carry no machine state.
36
+ */
37
+ import { TUNING_METRIC_WINDOW_DAYS, auditSelfTuning, computeR1Metric, computeR3Metric, countLessonRegressionSignals, ledgerStateKey, listLedgerEntries, revertAppliedTuningChange, } from "./tuning-actuator.js";
38
+ import { readRuntimeState, writeRuntimeState } from "../../db/runtime-state.js";
39
+ import { createLogger } from "../../logging.js";
40
+ const logger = createLogger("tuning-revert-monitor");
41
+ const DAY_MS = 24 * 60 * 60 * 1000;
42
+ /**
43
+ * Daily-throttle state key. Dot-separated namespace on purpose — the
44
+ * Measure stage's `gatherLedger` scans `self_tuning:%` and must never pick
45
+ * monitor state up as a phantom ledger entry (same rule as the pending
46
+ * cycle key).
47
+ */
48
+ export const REVERT_MONITOR_STATE_KEY = "self_tuning.revert_monitor";
49
+ /** §3.4 — days between apply and the verify pass. */
50
+ export const TUNING_VERIFY_WINDOW_DAYS = TUNING_METRIC_WINDOW_DAYS;
51
+ /** D4 — R1 reverts when novelty≥2 arrivals fall >30% below baseline. */
52
+ export const R1_NOVELTY_ARRIVALS_MAX_DROP = 0.3;
53
+ /** D4 — R1 reverts when the cautious-escalate share rises >10 pt. */
54
+ export const R1_CAUTIOUS_ESCALATE_MAX_RISE = 0.1;
55
+ /** D3 — R3 reverts when >10% of silent ticks carried novelty≥2 snapshots. */
56
+ export const R3_SILENT_NOVELTY_GE2_MAX_SHARE = 0.1;
57
+ function isR1Metric(value) {
58
+ return (typeof value === "object" &&
59
+ value !== null &&
60
+ typeof value.noveltyGe2PerDay === "number" &&
61
+ typeof value.cautiousEscalateShare === "number");
62
+ }
63
+ function pct(value) {
64
+ return `${Math.round(value * 100)}%`;
65
+ }
66
+ /**
67
+ * Decide one applied entry's fate. Pure given the DB rows: every margin is
68
+ * compared against telemetry that already exists (D3 — no recomputation of
69
+ * live signals). An entry whose `applied_at` cannot be parsed, or whose
70
+ * rule has no metric, settles as verified with an explanatory result — the
71
+ * conservative direction is "leave the change in place", never "revert
72
+ * without evidence".
73
+ */
74
+ export function evaluateAppliedEntry(db, entry, now) {
75
+ const appliedMs = Date.parse(entry.blob.applied_at);
76
+ if (Number.isNaN(appliedMs)) {
77
+ return { action: "verify", result: "invalid_applied_at" };
78
+ }
79
+ const windowEndMs = appliedMs + TUNING_VERIFY_WINDOW_DAYS * DAY_MS;
80
+ if (now.getTime() < windowEndMs)
81
+ return { action: "wait" };
82
+ const from = new Date(appliedMs);
83
+ const to = new Date(windowEndMs);
84
+ if (entry.blob.rule === "R1") {
85
+ if (!isR1Metric(entry.blob.baselineMetric)) {
86
+ return { action: "verify", result: "no_baseline" };
87
+ }
88
+ const baseline = entry.blob.baselineMetric;
89
+ const current = computeR1Metric(db, from, to);
90
+ if (baseline.noveltyGe2PerDay > 0 &&
91
+ current.noveltyGe2PerDay <
92
+ baseline.noveltyGe2PerDay * (1 - R1_NOVELTY_ARRIVALS_MAX_DROP)) {
93
+ return {
94
+ action: "revert",
95
+ reason: `novelty>=2 observation arrivals fell to ` +
96
+ `${current.noveltyGe2PerDay.toFixed(2)}/day vs baseline ` +
97
+ `${baseline.noveltyGe2PerDay.toFixed(2)}/day (>30% drop)`,
98
+ };
99
+ }
100
+ if (current.cautiousEscalateShare >
101
+ baseline.cautiousEscalateShare + R1_CAUTIOUS_ESCALATE_MAX_RISE) {
102
+ return {
103
+ action: "revert",
104
+ reason: `cautious-escalate tick share rose to ` +
105
+ `${pct(current.cautiousEscalateShare)} vs baseline ` +
106
+ `${pct(baseline.cautiousEscalateShare)} (>10 pt rise)`,
107
+ };
108
+ }
109
+ return { action: "verify", result: "pass" };
110
+ }
111
+ if (entry.blob.rule === "R3") {
112
+ const metric = computeR3Metric(db, from, to);
113
+ if (metric.stage0Ticks > 0 &&
114
+ metric.noveltyGe2 / metric.stage0Ticks > R3_SILENT_NOVELTY_GE2_MAX_SHARE) {
115
+ return {
116
+ action: "revert",
117
+ reason: `${metric.noveltyGe2}/${metric.stage0Ticks} silent ticks carried ` +
118
+ `maxNoveltyScore>=2 (>10% — harm from the raised ceiling)`,
119
+ };
120
+ }
121
+ return { action: "verify", result: "pass" };
122
+ }
123
+ if (entry.blob.rule === "R5") {
124
+ const signals = countLessonRegressionSignals(db, from, to);
125
+ if (signals > 0) {
126
+ return {
127
+ action: "revert",
128
+ reason: `${signals} explicit-correction signal(s) cited a lesson within ` +
129
+ "the verify window (forgotten-lesson proxy)",
130
+ };
131
+ }
132
+ return { action: "verify", result: "pass" };
133
+ }
134
+ return { action: "verify", result: "no_metric" };
135
+ }
136
+ /** §3.4 — the one-line owner DM for an auto-revert. */
137
+ export function buildAutoRevertDmMessage(entry, reason) {
138
+ return (`Self-tuning auto-revert: restored ${entry.key} to ` +
139
+ `${String(entry.blob.prev)} — ${reason}. The key is now in a 28-day ` +
140
+ "re-proposal cool-down.");
141
+ }
142
+ /**
143
+ * The cron-tick entry point. Throttled to one pass per UTC day; the state
144
+ * write happens before the scan so a mid-pass failure waits for tomorrow
145
+ * instead of retrying every tick. Each entry is processed in isolation —
146
+ * one broken entry never blocks the rest.
147
+ */
148
+ export async function runSelfTuningRevertMonitor(deps, now = new Date()) {
149
+ const today = now.toISOString().slice(0, 10);
150
+ const state = readRuntimeState(deps.db, REVERT_MONITOR_STATE_KEY);
151
+ if (state?.lastRunDay === today) {
152
+ return { ran: false, reverted: [], verified: [] };
153
+ }
154
+ writeRuntimeState(deps.db, REVERT_MONITOR_STATE_KEY, { lastRunDay: today });
155
+ const run = { ran: true, reverted: [], verified: [] };
156
+ const due = listLedgerEntries(deps.db).filter((entry) => entry.blob.actuator === "config" &&
157
+ entry.blob.reverted_at === undefined &&
158
+ entry.blob.verified_at === undefined);
159
+ for (const entry of due) {
160
+ try {
161
+ const decision = evaluateAppliedEntry(deps.db, entry, now);
162
+ if (decision.action === "wait")
163
+ continue;
164
+ if (decision.action === "revert") {
165
+ const result = await revertAppliedTuningChange(deps, entry, {
166
+ trigger: "auto",
167
+ reason: decision.reason,
168
+ now,
169
+ });
170
+ if (!result.ok) {
171
+ logger.warn({ key: entry.key, error: result.error }, "Auto-revert failed at the config chokepoint");
172
+ continue;
173
+ }
174
+ run.reverted.push(entry.key);
175
+ if (deps.sendDm) {
176
+ try {
177
+ await deps.sendDm(buildAutoRevertDmMessage(entry, decision.reason));
178
+ }
179
+ catch (err) {
180
+ logger.warn({ err, key: entry.key }, "Auto-revert DM failed");
181
+ }
182
+ }
183
+ else {
184
+ logger.warn({ key: entry.key }, "Auto-revert applied without DM path — owner not notified");
185
+ }
186
+ continue;
187
+ }
188
+ // decision.action === "verify" — clean window (or no metric): stamp
189
+ // so the entry is never re-examined; revertability via
190
+ // `!revert tuning` is unaffected. Re-read before writing (same
191
+ // discipline as revertAppliedTuningChange): an `!revert tuning`
192
+ // landing between this pass's scan and this stamp must not have its
193
+ // `reverted_at` clobbered by the stale scanned blob — that would
194
+ // both resurrect the key as revertable and drop its 28d cool-down.
195
+ const current = readRuntimeState(deps.db, ledgerStateKey(entry.key)) ?? entry.blob;
196
+ writeRuntimeState(deps.db, ledgerStateKey(entry.key), {
197
+ ...current,
198
+ verified_at: now.toISOString(),
199
+ verify_result: decision.result,
200
+ });
201
+ auditSelfTuning(deps.db, "self_tuning.verified", "autonomous", "success", {
202
+ key: entry.key,
203
+ rule: entry.blob.rule,
204
+ verifyResult: decision.result,
205
+ });
206
+ run.verified.push(entry.key);
207
+ }
208
+ catch (err) {
209
+ logger.warn({ err, key: entry.key }, "Revert-monitor entry failed");
210
+ }
211
+ }
212
+ return run;
213
+ }
@@ -4,6 +4,12 @@ import type { EventBus } from "./event-bus.js";
4
4
  import type { MessageHub } from "../adapters/message-hub.js";
5
5
  import type { ObserverManager } from "../observers/manager.js";
6
6
  export interface HealthStatus {
7
+ /**
8
+ * Seconds since daemon start. Every consumer of the `/health` `uptime`
9
+ * field (`bin/aitne.mjs formatUptime`, dashboard `formatUptime`)
10
+ * formats seconds — this was milliseconds until 2026-06-10, which made
11
+ * `aitne status` report a minutes-old daemon as days of uptime.
12
+ */
7
13
  daemonUptime: number;
8
14
  eventBusSize: number;
9
15
  activeSessions: number;
@@ -89,7 +89,7 @@ export class HealthMonitor {
89
89
  dbConnected = false;
90
90
  }
91
91
  return {
92
- daemonUptime: Date.now() - this.startedAt.getTime(),
92
+ daemonUptime: Math.floor((Date.now() - this.startedAt.getTime()) / 1000),
93
93
  eventBusSize: this.eventBus.size,
94
94
  activeSessions,
95
95
  dbConnected,
@@ -83,7 +83,7 @@ export interface InjectionPolicy {
83
83
  * redaction-aware wikilinks. Also drops the `*` policy-file merge
84
84
  * because the lite-tier skill bundle never invokes MCP; the redaction
85
85
  * policy is re-declared inline.
86
- * - **Hourly check** (`routine.hourly_check`) — task-flow §"Execution
86
+ * - **Activity scan** (`routine.activity_scan`) — task-flow §"Execution
87
87
  * budget" explicitly tells the agent NOT to read roadmap / projects /
88
88
  * user files unless an observation warrants it.
89
89
  * - **Today refresh** (`routine.today_refresh`) — dashboard-triggered
@@ -125,13 +125,13 @@ export declare function getInjectionPolicy(eventOrProcessKey: string): Injection
125
125
  * global agent-operating behaviour — notification discipline, filter
126
126
  * quality). Phase 3 consumer: `ContextBuilder`.
127
127
  * - `slim` — use the hard-2048-byte, top-N-by-score variant on the hourly
128
- * notify turn (§6). Only `routine.hourly_check` sets it. Implies `global`.
128
+ * notify turn (§6). Only `routine.activity_scan` sets it. Implies `global`.
129
129
  * - `self` — eligible for the per-agent `policies/agents/<slug>/lessons.md`
130
130
  * block (scope `agent:<slug>`). **Phase 4 consumer.** The builder reads it
131
131
  * next to `<agent_identity>` and gates it on a resolved, path-safe slug
132
132
  * stamped onto `event.data.agentId` at the dispatch site — `self === true`
133
133
  * here means "this surface *may* carry self lessons"; an actual injection
134
- * additionally requires the run to be bound to an Agent. `hourly_check`
134
+ * additionally requires the run to be bound to an Agent. `activity_scan`
135
135
  * keeps `self: false` so the slim notify turn never carries a second block.
136
136
  *
137
137
  * **Surface keying is grounded in the real event-type strings build() sees,
@@ -146,7 +146,7 @@ export declare function getInjectionPolicy(eventOrProcessKey: string): Injection
146
146
  * no notifications — injecting lessons there would be wasted bytes against
147
147
  * the §0 cost constraint. So Stage A is keyed, the umbrella and Stage B are
148
148
  * not.
149
- * - `routine.hourly_check` is the escalated Stage-3 LLM/notify turn (gate
149
+ * - `routine.activity_scan` is the escalated Stage-3 LLM/notify turn (gate
150
150
  * Layers 1–3 are code and build no prompt), so the slim block bites exactly
151
151
  * where the notify decision is made. The `.triage` lite classification is
152
152
  * intentionally excluded.
@@ -59,7 +59,7 @@ const DEFAULT_POLICY = {
59
59
  * redaction-aware wikilinks. Also drops the `*` policy-file merge
60
60
  * because the lite-tier skill bundle never invokes MCP; the redaction
61
61
  * policy is re-declared inline.
62
- * - **Hourly check** (`routine.hourly_check`) — task-flow §"Execution
62
+ * - **Activity scan** (`routine.activity_scan`) — task-flow §"Execution
63
63
  * budget" explicitly tells the agent NOT to read roadmap / projects /
64
64
  * user files unless an observation warrants it.
65
65
  * - **Today refresh** (`routine.today_refresh`) — dashboard-triggered
@@ -88,9 +88,9 @@ export function getInjectionPolicy(eventOrProcessKey) {
88
88
  policyFileGlobalMerge: false,
89
89
  };
90
90
  }
91
- // Narrow routines (hourly check, today refresh) — drop both heavy
91
+ // Narrow routines (activity scan, today refresh) — drop both heavy
92
92
  // blocks. `*` policy merge is preserved (redaction.md is non-negotiable).
93
- if (eventOrProcessKey === "routine.hourly_check" ||
93
+ if (eventOrProcessKey === "routine.activity_scan" ||
94
94
  eventOrProcessKey === "routine.today_refresh") {
95
95
  return {
96
96
  alwaysBlocks: NO_BLOCKS,
@@ -162,7 +162,7 @@ export function getAgentLessonsInjection(eventOrProcessKey, opts) {
162
162
  case "routine.monthly_review":
163
163
  return LESSONS_DM_REVIEW;
164
164
  // Hourly notify turn — slim, hard-capped notification-discipline variant.
165
- case "routine.hourly_check":
165
+ case "routine.activity_scan":
166
166
  return LESSONS_HOURLY;
167
167
  // Defined-agent task execution (§5 "Defined-agent execution"). A bare
168
168
  // scheduled.task stays NONE (the §5 opt-out); one that resolves to an Agent
@@ -131,6 +131,10 @@ export function cascadeNativeBindingsOnMainSwitch(db, newMainBackendId) {
131
131
  // against the same drift covered by the c8-ignored branch above.
132
132
  /* c8 ignore next */
133
133
  deniedTools: state.deniedTools ?? [],
134
+ // User configuration that must survive the disable/re-enable cycle —
135
+ // PATCH re-enables with `previous.fetchTargets`, so dropping it here
136
+ // would silently wipe the allowlist on a main-backend change.
137
+ fetchTargets: state.fetchTargets ?? [],
134
138
  lastChangedAt: now,
135
139
  });
136
140
  flipped.push({
@@ -58,7 +58,7 @@ export declare function renderNoteSourcesSection(integrations: IntegrationsRecor
58
58
  /**
59
59
  * INTEGRATION_NATIVE_MODE_DESIGN.md §7.3 — render the full per-session
60
60
  * routing table that the per-backend instruction file (`CLAUDE.md` /
61
- * `AGENTS.md` / `GEMINI.md`) and the hourly_check / DM task-flow files
61
+ * `AGENTS.md` / `GEMINI.md`) and the activity_scan / DM task-flow files
62
62
  * substitute in for the `<integration-routing-table>` placeholder.
63
63
  *
64
64
  * Always renders every registered integration, even when all rows are
@@ -77,7 +77,7 @@ export declare function renderIntegrationRoutingTable(integrations: Integrations
77
77
  * `native` rows; `disabled` rows are filtered out entirely so the
78
78
  * task-flow's "for each integration" loop has zero iterations for them.
79
79
  *
80
- * This is what the hourly_check and DM task-flow files iterate over;
80
+ * This is what the activity_scan and DM task-flow files iterate over;
81
81
  * the full {@link renderIntegrationRoutingTable} is for the instruction
82
82
  * file's read-only audit summary.
83
83
  */
@@ -63,24 +63,53 @@ function consumeSelfWrite(absPath) {
63
63
  return pendingSelfWrites.delete(absPath);
64
64
  }
65
65
  // ── Render ─────────────────────────────────────────────────────────────────
66
- // Ownership note: this frontmatter emits `owner: daemon`. The file is a
67
- // daemon-rendered snapshot of `settings.integrations_json`; the Dashboard
68
- // (Settings Connections) is the canonical edit surface. Hand-edits are
69
- // still parsed by chokidar as a break-glass path for resilience, but the
70
- // file is not advertised as user-editable. See §14.3 of
66
+ // Frontmatter contract. `policies/integrations.md` lives inside the vault
67
+ // under the `policies/` authority class, so it MUST satisfy the vault
68
+ // frontmatter validator (`context-frontmatter.ts`): `type: rule`,
69
+ // `owner {agent, shared, user}`, and an ISO `updated` date. Before the
70
+ // CONTEXT_VAULT_REDESIGN restructure this file lived at the un-validated
71
+ // `~/.personal-agent/integrations.md`, so it shipped a bespoke
72
+ // daemon-snapshot frontmatter (`owner: daemon`, no `type`/`updated`). The
73
+ // restructure moved it under `policies/` and added the generic `policies/`
74
+ // validation, but this renderer was never reconciled — leaving every
75
+ // install's file flagged "frontmatter requires `type`" by Vault Health.
76
+ //
77
+ // `owner` is `shared` because the file is a daemon-rendered snapshot of
78
+ // `settings.integrations_json` that the user may also hand-edit (chokidar
79
+ // reconciles edits back into the DB) — the same mixed authority as
80
+ // `policies/management.md`. The Dashboard (Settings → Connections) remains
81
+ // the canonical edit surface. See §14.3 of
71
82
  // docs/design/14-integration-delegation.md.
72
- const FRONTMATTER = `---
73
- file: integrations.md
74
- purpose: per-integration access-mode configuration
75
- owner: daemon
83
+ //
84
+ // `updated` is derived from the most recent `lastChangedAt` across all
85
+ // integration rows (truncated to a calendar date) so the render stays a
86
+ // pure function of DB state: booting re-renders byte-identical output until
87
+ // a mode actually changes, preserving the idempotency contract above.
88
+ const FRONTMATTER_FALLBACK_UPDATED = "2026-04-17";
89
+ function renderFrontmatter(integrations) {
90
+ let latest = "";
91
+ for (const key of INTEGRATION_KEYS) {
92
+ const ts = integrations[key].lastChangedAt;
93
+ if (ts > latest)
94
+ latest = ts;
95
+ }
96
+ const updated = /^\d{4}-\d{2}-\d{2}/.test(latest)
97
+ ? latest.slice(0, 10)
98
+ : FRONTMATTER_FALLBACK_UPDATED;
99
+ return `---
100
+ type: rule
101
+ slug: integrations
102
+ owner: shared
103
+ updated: ${updated}
76
104
  schema_version: 1
77
105
  ---
78
106
  `;
107
+ }
79
108
  const MODES_SECTION = `## Modes
80
109
 
81
110
  - **direct** — daemon holds credentials and polls; full feature set; setup required.
82
111
  - **delegated** — daemon proxies a separate backend connector on a cadence; reduced features; zero setup.
83
- - **native** — main backend's own native MCP / connector reaches the integration on-demand within the same DM / hourly_check turn; no daemon polling and no daemon-side proxy.
112
+ - **native** — main backend's own native MCP / connector reaches the integration on-demand within the same DM / activity_scan turn; no daemon polling and no daemon-side proxy.
84
113
  - **disabled** — integration off.
85
114
  `;
86
115
  function renderCurrentStateTable(integrations) {
@@ -132,15 +161,23 @@ export function renderNoteSourcesSection(integrations, notes) {
132
161
  else if (notion.mode === "delegated" && notion.delegatedBackend) {
133
162
  notionLine = `enabled (delegated via ${notion.delegatedBackend})`;
134
163
  }
164
+ else if (notion.mode === "native" && notion.nativeBackend) {
165
+ notionLine = `enabled (native via ${notion.nativeBackend})`;
166
+ }
135
167
  else {
136
168
  notionLine = "enabled (direct)";
137
169
  }
170
+ const notionTargets = (notion.fetchTargets ?? []).map((target) => target.label);
171
+ const notionTargetsLine = notionTargets.length > 0
172
+ ? notionTargets.join(", ")
173
+ : "—";
138
174
  return [
139
175
  "## Note Sources",
140
176
  "",
141
177
  "<!-- Auto-generated. Edit settings via Dashboard → Settings → Note. Hand-edits are overwritten on next render. -->",
142
178
  `- Obsidian vault (personal): ${obsidianLine}`,
143
179
  `- Notion: ${notionLine}`,
180
+ `- Notion routine fetch targets: ${notionTargetsLine}`,
144
181
  "",
145
182
  ].join("\n");
146
183
  }
@@ -218,7 +255,7 @@ function renderToolDenySection(integrations) {
218
255
  /**
219
256
  * INTEGRATION_NATIVE_MODE_DESIGN.md §7.3 — render the full per-session
220
257
  * routing table that the per-backend instruction file (`CLAUDE.md` /
221
- * `AGENTS.md` / `GEMINI.md`) and the hourly_check / DM task-flow files
258
+ * `AGENTS.md` / `GEMINI.md`) and the activity_scan / DM task-flow files
222
259
  * substitute in for the `<integration-routing-table>` placeholder.
223
260
  *
224
261
  * Always renders every registered integration, even when all rows are
@@ -247,7 +284,7 @@ export function renderIntegrationRoutingTable(integrations) {
247
284
  * `native` rows; `disabled` rows are filtered out entirely so the
248
285
  * task-flow's "for each integration" loop has zero iterations for them.
249
286
  *
250
- * This is what the hourly_check and DM task-flow files iterate over;
287
+ * This is what the activity_scan and DM task-flow files iterate over;
251
288
  * the full {@link renderIntegrationRoutingTable} is for the instruction
252
289
  * file's read-only audit summary.
253
290
  */
@@ -381,7 +418,7 @@ export function renderManagementMd(integrations, notes = {
381
418
  externalObsidianWatch: true,
382
419
  }) {
383
420
  return [
384
- FRONTMATTER,
421
+ renderFrontmatter(integrations),
385
422
  "# Integration Management\n",
386
423
  MODES_SECTION,
387
424
  renderCurrentStateTable(integrations),
@@ -666,6 +703,7 @@ function mergeParsedIntoDb(dbState, parsed) {
666
703
  if (semanticChange) {
667
704
  merged[key] = {
668
705
  ...next,
706
+ fetchTargets: prev.fetchTargets ?? [],
669
707
  lastChangedAt: new Date().toISOString(),
670
708
  };
671
709
  }
@@ -110,7 +110,7 @@ export interface MorningPipelineOrchestratorDeps {
110
110
  * morning-routine-optimization.md Phase 6 — ⑥ AgentJournalAppender
111
111
  * needs the safety write-tracker so the journal's atomic write does
112
112
  * not get tagged as a user-actor change by the obsidian / git
113
- * observers (which would re-trigger the hourly check on the agent's
113
+ * observers (which would re-trigger the activity scan on the agent's
114
114
  * own output). The context-index reconciler is intentionally NOT
115
115
  * threaded here: `journal/agent.md` is not in the indexable set, so
116
116
  * the chokidar fallback path covers it without an explicit hint.
@@ -254,7 +254,7 @@ export declare class MorningRoutinePipelineOrchestrator {
254
254
  * (audit's internal try/catch swallowed a real SQLite error AND
255
255
  * `processResult`'s notification path threw too), the parent-audit
256
256
  * emitter will return `stage_a_row_missing` and the pre-routine gate
257
- * stays unfired for the day — that day's hourly_check / evening_review
257
+ * stays unfired for the day — that day's activity_scan / evening_review
258
258
  * are skipped with `morning_routine_pending_for_today`, but
259
259
  * `MAX_RETRIES`-bounded `scheduleMorningRetry` does NOT loop on this
260
260
  * shape because today.md health is independent. The day's automation
@@ -358,7 +358,7 @@ export class MorningRoutinePipelineOrchestrator {
358
358
  * (audit's internal try/catch swallowed a real SQLite error AND
359
359
  * `processResult`'s notification path threw too), the parent-audit
360
360
  * emitter will return `stage_a_row_missing` and the pre-routine gate
361
- * stays unfired for the day — that day's hourly_check / evening_review
361
+ * stays unfired for the day — that day's activity_scan / evening_review
362
362
  * are skipped with `morning_routine_pending_for_today`, but
363
363
  * `MAX_RETRIES`-bounded `scheduleMorningRetry` does NOT loop on this
364
364
  * shape because today.md health is independent. The day's automation
@@ -993,7 +993,7 @@ export class MorningRoutinePipelineOrchestrator {
993
993
  // could still call `Write` on `daily/<date>.md` directly —
994
994
  // bypassing the daemon-side `DailyJournalComposer` chokepoint
995
995
  // and racing it. Mirrors the precedent at
996
- // `dispatcher-hourly-check.ts:1003` (`routine.hourly_check.triage`).
996
+ // `dispatcher-activity-scan.ts:1003` (`routine.activity_scan.triage`).
997
997
  //
998
998
  // Activation requires the clamp gate in `claude-code-core.ts`
999
999
  // to honour an empty array as "no tools" — fixed in the same
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Outbound-notification gate — QUIET_HOURS_HARDENING_PLAN.md Phase 1.
3
+ *
4
+ * Single decision function for the API-deps `sendNotification` chokepoint
5
+ * (`bootstrap/api.ts`), closing finding F1: `POST /api/notify` used to
6
+ * bypass quiet hours AND rate limits without the explicit-user-intent
7
+ * justification the other delivery paths encode. The intent rule —
8
+ * "explicit user-chosen time → deliver regardless of quiet hours; ambient
9
+ * autonomous output → suppress/defer" — now holds here too:
10
+ *
11
+ * 1. safety / critical → send immediately (mirrors NotificationManager);
12
+ * 2. inside quiet hours → defer to a `task_type='dm'` agent_schedule row
13
+ * at the quiet-hours edge (durable, coalesced per origin — see
14
+ * `db/deferred-dm.ts`), never silently dropped;
15
+ * 3. outside quiet hours → enforce the same hourly/daily rate limits the
16
+ * proactive path enforces; the live session gets a `rate_limit`
17
+ * verdict it can adapt to (write to today.md instead) rather than a
18
+ * silent queue.
19
+ *
20
+ * Pure composition over covered helpers — keep glue out of bootstrap.
21
+ */
22
+ import type Database from "better-sqlite3";
23
+ /** Safety categories bypass quiet hours and user preferences. Owned here
24
+ * so the NotificationManager and this gate share one list. */
25
+ export declare const SAFETY_CATEGORIES: readonly ["security", "deadline", "error", "critical"];
26
+ export interface OutboundGateConfig {
27
+ quietHoursStart: string;
28
+ quietHoursEnd: string;
29
+ /** IANA tz; empty string falls back to system timezone. */
30
+ timezone: string;
31
+ maxNotificationsPerHour: number;
32
+ maxNotificationsPerDay: number;
33
+ dayBoundaryHour: number;
34
+ }
35
+ export interface OutboundGateParams {
36
+ message: string;
37
+ platforms?: string[] | undefined;
38
+ priority?: string | undefined;
39
+ notificationType?: string | undefined;
40
+ originSessionId?: number | undefined;
41
+ agentId?: string | null | undefined;
42
+ /** Origin marker stamped into the deferred row, e.g. `"api.notify"`. */
43
+ deferredFrom: string;
44
+ }
45
+ export type OutboundGateResult = {
46
+ action: "send";
47
+ } | {
48
+ action: "defer";
49
+ scheduleId: string;
50
+ /** SQLite-format UTC datetime the deferred DM fires at. */
51
+ deliverAfter: string;
52
+ coalesced: boolean;
53
+ } | {
54
+ action: "rate_limit";
55
+ retryAfter: string | null;
56
+ };
57
+ /**
58
+ * Critical priority and safety-tagged notification types deliver
59
+ * immediately — same bypass set as `NotificationManager.isSafetyCategory`
60
+ * ("urgent" accepted defensively; the notify schema only emits
61
+ * critical/high/normal/low).
62
+ */
63
+ export declare function bypassesOutboundGate(priority: string | undefined, notificationType: string | undefined): boolean;
64
+ export declare function gateOutboundNotification(db: Database.Database, config: OutboundGateConfig, params: OutboundGateParams, now?: Date): OutboundGateResult;
@@ -0,0 +1,51 @@
1
+ import { deferDmToQuietHoursEnd } from "../db/deferred-dm.js";
2
+ import { evaluateNotificationRateLimit, } from "./notification-rate-limit.js";
3
+ /** Safety categories bypass quiet hours and user preferences. Owned here
4
+ * so the NotificationManager and this gate share one list. */
5
+ export const SAFETY_CATEGORIES = [
6
+ "security",
7
+ "deadline",
8
+ "error",
9
+ "critical",
10
+ ];
11
+ /**
12
+ * Critical priority and safety-tagged notification types deliver
13
+ * immediately — same bypass set as `NotificationManager.isSafetyCategory`
14
+ * ("urgent" accepted defensively; the notify schema only emits
15
+ * critical/high/normal/low).
16
+ */
17
+ export function bypassesOutboundGate(priority, notificationType) {
18
+ if (priority === "critical" || priority === "urgent")
19
+ return true;
20
+ return (notificationType !== undefined &&
21
+ SAFETY_CATEGORIES.includes(notificationType));
22
+ }
23
+ export function gateOutboundNotification(db, config, params, now = new Date()) {
24
+ if (bypassesOutboundGate(params.priority, params.notificationType)) {
25
+ return { action: "send" };
26
+ }
27
+ const deferred = deferDmToQuietHoursEnd(db, {
28
+ start: config.quietHoursStart,
29
+ end: config.quietHoursEnd,
30
+ timezone: config.timezone || undefined,
31
+ }, {
32
+ message: params.message,
33
+ platforms: params.platforms,
34
+ deferredFrom: params.deferredFrom,
35
+ originSessionId: params.originSessionId,
36
+ agentId: params.agentId,
37
+ }, now);
38
+ if (deferred !== null) {
39
+ return { action: "defer", ...deferred };
40
+ }
41
+ const rateLimit = evaluateNotificationRateLimit(db, {
42
+ maxNotificationsPerHour: config.maxNotificationsPerHour,
43
+ maxNotificationsPerDay: config.maxNotificationsPerDay,
44
+ timezone: config.timezone,
45
+ dayBoundaryHour: config.dayBoundaryHour,
46
+ }, now);
47
+ if (rateLimit.limited) {
48
+ return { action: "rate_limit", retryAfter: rateLimit.retryAfter };
49
+ }
50
+ return { action: "send" };
51
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Outbound-notification rate-limit evaluation — pure(ish) helper shared by
3
+ * NotificationManager (proactive suppression) and the `/api/notify` gate
4
+ * (QUIET_HOURS_HARDENING_PLAN.md Phase 1). Extracted so the two call sites
5
+ * cannot drift on the counting semantics: distinct dispatches, delivered
6
+ * only, `message.received` replies excluded, hourly window + agent-day
7
+ * window both enforced.
8
+ *
9
+ * 100% covered. NotificationManager itself stays excluded from the
10
+ * coverage gate as I/O-heavy; this helper is the pure leg it shares with
11
+ * the notify-route gate.
12
+ */
13
+ import type Database from "better-sqlite3";
14
+ export interface NotificationRateLimitOptions {
15
+ maxNotificationsPerHour: number;
16
+ maxNotificationsPerDay: number;
17
+ /** IANA tz; empty/undefined falls back to system timezone. */
18
+ timezone?: string | undefined;
19
+ /** Agent day boundary hour (default config: 4). */
20
+ dayBoundaryHour: number;
21
+ }
22
+ export interface NotificationRateLimitState {
23
+ limited: boolean;
24
+ /**
25
+ * SQLite-format UTC datetime (`YYYY-MM-DD HH:MM:SS`) when a retry could
26
+ * succeed, or `null` when not limited. Hourly limit → the moment the
27
+ * oldest delivery in the trailing hour ages out of the window; daily
28
+ * limit → the agent-day end boundary. Advisory — the caller's retry can
29
+ * still lose to a concurrent delivery.
30
+ */
31
+ retryAfter: string | null;
32
+ }
33
+ /**
34
+ * Count semantics mirror the pre-extraction `NotificationManager`
35
+ * implementation byte-for-byte: a multi-channel dispatch counts once
36
+ * (DISTINCT on dispatch_id, falling back to the row id for legacy rows
37
+ * with an empty dispatch_id), only `delivered` rows count, and
38
+ * `message.received` reply forwards never count against proactive budget.
39
+ */
40
+ export declare function evaluateNotificationRateLimit(db: Database.Database, opts: NotificationRateLimitOptions, now?: Date): NotificationRateLimitState;