@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,775 @@
1
+ /**
2
+ * `ActivityScanCoordinator` — owns the dispatcher's
3
+ * `triggerActivityScan` entry point and the cost-reduction-structural §B
4
+ * three-stage gate that fronts it. The coordinator decides whether a
5
+ * given hourly tick:
6
+ * - skips (autonomous gate / morning routine active / already running
7
+ * / below threshold);
8
+ * - silently consumes observations + records an Agent Log line
9
+ * (Stage 0 deterministic gate or Stage 2 lite-tier `log_only`);
10
+ * - escalates to the existing Stage 3 enqueue (Stage 2 `escalate`
11
+ * verdict or `failed` cautious-escalate path).
12
+ *
13
+ * Extracted from `core/dispatcher.ts` as part of phase D-2 of
14
+ * `docs/design/appendices/file-split-plan.md`. Pattern B (stateful
15
+ * coordinator): the coordinator owns the gate logic but borrows live
16
+ * accessors for state the dispatcher continues to own — the
17
+ * `activityScanInProgress` flag (atomic check-and-set inside the
18
+ * trigger), the `morningRoutineInProgress` flag (read-only), and the
19
+ * lazily-injected delegated-sync refresh callback.
20
+ *
21
+ * Dispatcher entry points served:
22
+ * - `EventDispatcher.triggerActivityScan(source, options)` is now a
23
+ * thin one-liner that delegates to `trigger(source, options)`.
24
+ *
25
+ * Invariants preserved bit-for-bit from
26
+ * `docs/design/02-event-pipeline.md` §2:
27
+ * - skip-if-morning-routine-in-progress;
28
+ * - skip-if-hourly-already-running (atomic flag flip BEFORE any
29
+ * await boundary — the C1 race fix from before the split);
30
+ * - skip-if-pending-observations-below-threshold (legacy
31
+ * min-observations floor honoured only when the gate would have
32
+ * proceeded to Stage 3 anyway);
33
+ * - skip-if-setup-incomplete / vault-degraded / user-paused via
34
+ * `isAutonomousAllowed`.
35
+ *
36
+ * Shared-state references held:
37
+ * - `setActivityScanInProgress` / `isActivityScanInProgress` —
38
+ * getter/setter pair around the dispatcher's flag. The flag is
39
+ * left `true` when an enqueue actually happens (the EventBus
40
+ * consumer's `dispatchSafe` finally clears it on routine
41
+ * completion); it is reset inline when the coordinator owns the
42
+ * turn (silent gate paths) or when the trigger is skipping.
43
+ * - `isMorningRoutineActive` — read-only mirror of the dispatcher
44
+ * method so the gate stays single-sourced.
45
+ * - `isAutonomousAllowed` — same; returns the
46
+ * `TriggerActivityScanSkipReason` the gate should surface.
47
+ * - `getDelegatedSyncRefresh` — accessor; null when no delegated
48
+ * integration is wired, in which case the gate proceeds without
49
+ * a refresh, matching pre-injection behaviour.
50
+ */
51
+ import { EventPriority, INTEGRATION_KEYS, createEvent, } from "@aitne/shared";
52
+ import { readIntegrations } from "../db/integrations-store.js";
53
+ import { existsSync, readFileSync } from "node:fs";
54
+ import { join } from "node:path";
55
+ import { CONTEXT_RELATIVE_PATHS } from "./context-paths.js";
56
+ import { getContextDir } from "../config.js";
57
+ import { consumeObservations, getPendingCount, getPendingObservations, } from "../db/observations.js";
58
+ import { computeActivityScanSignals } from "../db/activity-scan-signals.js";
59
+ import { buildGateAuditDetail, decideStage, renderGateDecisionBlock, } from "../scheduler/activity-scan-gate.js";
60
+ import { appendAgentLogLine } from "./today-direct-writer.js";
61
+ import { parseStage2Verdict } from "./dispatcher-types.js";
62
+ import { morningRoutineRanToday } from "../bootstrap/schedule-helpers.js";
63
+ import { prePassLastRunRuntimeStateKey } from "./pre-pass-freshness.js";
64
+ import { readRuntimeState } from "../db/runtime-state.js";
65
+ import { getRuntimeWindow } from "../db/agents-store.js";
66
+ import { resolveActivityScanCadence } from "./agents/activity-scan-cadence.js";
67
+ import { createLogger } from "../logging.js";
68
+ const logger = createLogger("dispatcher-activity-scan");
69
+ export class ActivityScanCoordinator {
70
+ db;
71
+ config;
72
+ eventBus;
73
+ contextBuilder;
74
+ agentRouter;
75
+ audit;
76
+ todayWriteLock;
77
+ prompt;
78
+ fetchWindowRunner;
79
+ getDelegatedSyncRefresh;
80
+ setActivityScanInProgress;
81
+ isActivityScanInProgress;
82
+ isMorningRoutineActive;
83
+ isAutonomousAllowed;
84
+ getQueueMorningRoutineWake;
85
+ constructor(deps) {
86
+ this.db = deps.db;
87
+ this.config = deps.config;
88
+ this.eventBus = deps.eventBus;
89
+ this.contextBuilder = deps.contextBuilder;
90
+ this.agentRouter = deps.agentRouter;
91
+ this.audit = deps.audit;
92
+ this.todayWriteLock = deps.todayWriteLock;
93
+ this.prompt = deps.prompt;
94
+ this.fetchWindowRunner = deps.fetchWindowRunner;
95
+ this.getDelegatedSyncRefresh = deps.getDelegatedSyncRefresh;
96
+ this.setActivityScanInProgress = deps.setActivityScanInProgress;
97
+ this.isActivityScanInProgress = deps.isActivityScanInProgress;
98
+ this.isMorningRoutineActive = deps.isMorningRoutineActive;
99
+ this.isAutonomousAllowed = deps.isAutonomousAllowed;
100
+ this.getQueueMorningRoutineWake = deps.getQueueMorningRoutineWake;
101
+ }
102
+ async trigger(source, options = {}) {
103
+ const forced = options.force === true;
104
+ // Observation threshold comes from the activity-scan agent row's
105
+ // runtime_window, with the legacy `activityScanMinObservations` config key
106
+ // as fallback (AGENTS_HUB_REDESIGN_PLAN.md §2).
107
+ const minObservations = resolveActivityScanCadence(getRuntimeWindow(this.db, "activity-scan"), this.config).minObservations;
108
+ // C1 fix: atomic check-and-set on activityScanInProgress BEFORE any await
109
+ // boundary. Previously `await this.isMorningRoutineActive()` yielded to
110
+ // the microtask queue, allowing cron + /api/agent/run-now arriving in
111
+ // the same tick to both observe `activityScanInProgress === false` and
112
+ // both enqueue. Because Node is single-threaded and better-sqlite3 is
113
+ // synchronous, doing set-first + sync checks + rollback-on-skip is now
114
+ // race-free.
115
+ if (this.isActivityScanInProgress()) {
116
+ logger.info({ source }, "Activity scan skipped — previous activity scan is still running");
117
+ return {
118
+ status: "skipped",
119
+ reason: "activity_scan_in_progress",
120
+ minObservations,
121
+ forced,
122
+ };
123
+ }
124
+ this.setActivityScanInProgress(true);
125
+ // Rollback flag unless we actually enqueue the event or land on a
126
+ // silent path that owns its own reset.
127
+ let enqueued = false;
128
+ let silentPathOwnsReset = false;
129
+ try {
130
+ const setupBlock = this.isAutonomousAllowed();
131
+ if (setupBlock !== null) {
132
+ logger.info({ source, reason: setupBlock }, "Activity scan skipped — autonomous work paused for setup");
133
+ return {
134
+ status: "skipped",
135
+ reason: setupBlock,
136
+ minObservations,
137
+ forced,
138
+ };
139
+ }
140
+ if (this.isMorningRoutineActive()) {
141
+ logger.info({ source }, "Activity scan skipped — morning routine is active");
142
+ return {
143
+ status: "skipped",
144
+ reason: "morning_routine_active",
145
+ minObservations,
146
+ forced,
147
+ };
148
+ }
149
+ // Pre-routine morning_routine gate. The signal is the
150
+ // `agent_actions` row (not today.md) because today.md can be
151
+ // user-edited and lie about completion — see the 2026-05-14
152
+ // sleep-skip incident captured in `morningRoutineRanToday`'s
153
+ // doc. When the gate trips, we enqueue a wake row so the
154
+ // morning_routine catches up on the next watcher tick, then
155
+ // skip the current hourly tick. The next hourly cron tick will
156
+ // see the action row and proceed normally; `queueMorningRoutineWake`
157
+ // dedups across back-to-back trips so a sleep gap covering many
158
+ // hours produces exactly one wake row.
159
+ if (!morningRoutineRanToday(this.db, this.config)) {
160
+ const queueWake = this.getQueueMorningRoutineWake();
161
+ if (queueWake) {
162
+ const queueResult = queueWake(`activity_scan_dependency:${source}`);
163
+ logger.info({ source, queueResult }, "Activity scan skipped — morning_routine not yet complete for current agent-day; enqueued morning_routine wake");
164
+ }
165
+ else {
166
+ logger.warn({ source }, "Activity scan skipped — morning_routine not yet complete and queueMorningRoutineWake not wired");
167
+ }
168
+ return {
169
+ status: "skipped",
170
+ reason: "morning_routine_pending_for_today",
171
+ minObservations,
172
+ forced,
173
+ };
174
+ }
175
+ // Refresh delegated-sync snapshots for any cadence the operator
176
+ // left opted-OUT (the post-Phase-9 default). Without this, Gmail /
177
+ // Notion observations would dry up entirely in delegated mode and
178
+ // the routine.activity_scan.delegated.* task flow's Step 0a / 0c
179
+ // would have nothing to consume — Step 1's `/api/observations`
180
+ // call would return only Obsidian / Git rows. Calendar's Step 0b
181
+ // already fetches actively via `/reconcile`, so the gap is
182
+ // specific to gmail / notion. See `docs/design/appendices/
183
+ // delegated-sync-opt-in.md` and the worker's
184
+ // `runDisabledCadencesForActivityScan` doc-comment for the full
185
+ // reasoning. Failures are logged but do NOT block the check —
186
+ // a stuck cadence cannot starve the entire hourly loop.
187
+ const delegatedSyncRefresh = this.getDelegatedSyncRefresh();
188
+ if (delegatedSyncRefresh) {
189
+ try {
190
+ await delegatedSyncRefresh();
191
+ }
192
+ catch (err) {
193
+ logger.warn({ err, source }, "Pre-activity-scan delegated sync refresh failed; proceeding with stale snapshot");
194
+ }
195
+ }
196
+ // HOURLY_CHECK_GATE_REDESIGN_PLAN.md Layer 1 — pre-pass harvest
197
+ // for delegated/native integrations BEFORE the gate signal
198
+ // computation. Direct-mode integrations rely on their in-process
199
+ // pollers and are not touched here. The freshness window
200
+ // (`activityScanPrePassFreshnessMinutes`, default 30 min) bounds
201
+ // Haiku spend; forced runs (`/api/agent/run-now`) bypass the
202
+ // window so the operator always sees fresh data.
203
+ //
204
+ // Failures surface as `harvest.failed === true`; combined with
205
+ // §3.5 cautious-escalate the gate force-runs Stage 3 so a
206
+ // transient fetch outage doesn't manifest as silent stage0.
207
+ const harvest = await this.harvestForGate(source, forced);
208
+ // Layer 2 — gate signals are now mode-blind. The actor='user'
209
+ // filter has been dropped (HOURLY_CHECK_GATE_REDESIGN_PLAN.md
210
+ // Phase 1+2): delegated-sync-worker and pre-pass both POST
211
+ // actor='agent' rows that represent real activity. The
212
+ // signal-compute filters by source-prefix sets derived from
213
+ // `INTEGRATION_DESCRIPTORS` instead.
214
+ const pendingCount = getPendingCount(this.db);
215
+ // Layer 2+3 — compute gate verdict.
216
+ const baseDecision = this.computeActivityScanGateDecision();
217
+ // §3.5 cautious-escalate: when pre-pass failed for any non-direct
218
+ // integration, force `stage3` regardless of the signal verdict.
219
+ // The Stage 3 prompt carries the `<fetch_report status="failed">`
220
+ // block so the routine knows the fetch was lossy. We preserve the
221
+ // pre-overwrite gate verdict so the audit row carries both views
222
+ // (`gate_stage`/`gate_reason` show the cautious-escalate label
223
+ // the prompt sees; `pre_escalate_gate_stage`/`_reason` show what
224
+ // the gate would have said without the pre-pass failure).
225
+ const cautiousEscalate = harvest.failed;
226
+ const decision = cautiousEscalate
227
+ ? {
228
+ ...baseDecision,
229
+ stage: "stage3",
230
+ reason: "cautious_escalate_prepass_failure",
231
+ }
232
+ : baseDecision;
233
+ const preEscalate = cautiousEscalate
234
+ ? { stage: baseDecision.stage, reason: baseDecision.reason }
235
+ : null;
236
+ // Honour the legacy min-observations floor only when the gate
237
+ // would have proceeded to Stage 3 anyway. The silent gate path
238
+ // already short-circuits the noisy "1 obs, no signal" case below
239
+ // it, so keeping the floor active there would just suppress the
240
+ // gate's telemetry. The native-integration §6.5.1 bypass is no
241
+ // longer needed — the gate's signal compute now sees pre-pass +
242
+ // delegated-sync rows directly.
243
+ if (!forced
244
+ && !cautiousEscalate
245
+ && decision.stage === "stage3"
246
+ && pendingCount < minObservations) {
247
+ this.logGateAuditRow(decision, {
248
+ appliedDecision: "stage3",
249
+ forced,
250
+ harvest,
251
+ preEscalate,
252
+ // Mark the row as a skip even though the gate wanted Stage 3 —
253
+ // the legacy min-observations floor short-circuited it. Without
254
+ // this, every `below_threshold` skip would persist as a phantom
255
+ // `result='success'` row in the audit feed.
256
+ resultOverride: "skipped",
257
+ extra: { skipped: "below_threshold" },
258
+ });
259
+ return {
260
+ status: "skipped",
261
+ reason: "below_threshold",
262
+ pendingCount,
263
+ minObservations,
264
+ forced,
265
+ gateStage: decision.stage,
266
+ gateReason: decision.reason,
267
+ };
268
+ }
269
+ if (decision.stage === "stage0_silent") {
270
+ const silentResult = this.runSilentActivityScanPath(decision, "stage0_silent", {
271
+ source,
272
+ forced,
273
+ harvest,
274
+ preEscalate,
275
+ });
276
+ silentPathOwnsReset = true;
277
+ return {
278
+ ...silentResult,
279
+ minObservations,
280
+ gateStage: decision.stage,
281
+ gateReason: decision.reason,
282
+ appliedStage: "stage0_silent",
283
+ };
284
+ }
285
+ if (decision.stage === "stage2") {
286
+ const verdict = await this.runStage2Triage(decision, source);
287
+ if (verdict === "log_only") {
288
+ const silentResult = this.runSilentActivityScanPath(decision, "stage2_log_only", { source, forced, harvest, preEscalate });
289
+ silentPathOwnsReset = true;
290
+ return {
291
+ ...silentResult,
292
+ minObservations,
293
+ gateStage: decision.stage,
294
+ gateReason: decision.reason,
295
+ appliedStage: "stage2_log_only",
296
+ };
297
+ }
298
+ // verdict === 'escalate' OR 'failed' (failed → cautious escalate
299
+ // since a malformed JSON should not silently skip a hour's worth
300
+ // of signals; matches the prompt contract's stated default).
301
+ await this.enqueueStage3ActivityScan(source, decision, {
302
+ forced,
303
+ pendingCount,
304
+ requestedModel: options.requestedModel,
305
+ stage2Verdict: verdict,
306
+ harvest,
307
+ cautiousEscalate,
308
+ preEscalate,
309
+ });
310
+ enqueued = true;
311
+ return {
312
+ status: "queued",
313
+ pendingCount,
314
+ minObservations,
315
+ forced,
316
+ gateStage: decision.stage,
317
+ gateReason: decision.reason,
318
+ appliedStage: "stage3",
319
+ ...(cautiousEscalate ? { cautiousEscalate: true } : {}),
320
+ };
321
+ }
322
+ // decision.stage === 'stage3'
323
+ await this.enqueueStage3ActivityScan(source, decision, {
324
+ forced,
325
+ pendingCount,
326
+ requestedModel: options.requestedModel,
327
+ harvest,
328
+ cautiousEscalate,
329
+ preEscalate,
330
+ });
331
+ enqueued = true;
332
+ return {
333
+ status: "queued",
334
+ pendingCount,
335
+ minObservations,
336
+ forced,
337
+ gateStage: decision.stage,
338
+ gateReason: decision.reason,
339
+ appliedStage: "stage3",
340
+ ...(cautiousEscalate ? { cautiousEscalate: true } : {}),
341
+ };
342
+ }
343
+ finally {
344
+ // Flag is only left true when we successfully enqueued OR the
345
+ // silent path explicitly opted out of resetting (it resets at
346
+ // the end of its own helper). The event loop's dispatchSafe()
347
+ // finally block clears the flag when an enqueued routine event
348
+ // finishes processing.
349
+ if (!enqueued && !silentPathOwnsReset) {
350
+ this.setActivityScanInProgress(false);
351
+ }
352
+ }
353
+ }
354
+ /**
355
+ * HOURLY_CHECK_GATE_REDESIGN_PLAN.md §3.3 Layer 1 — pre-pass harvest
356
+ * for active non-direct integrations. Reads the per-integration
357
+ * `pre_pass_last_run:<key>` freshness key; integrations whose last
358
+ * successful run is within `activityScanPrePassFreshnessMinutes` are
359
+ * skipped this tick. Forced runs (`/api/agent/run-now`) bypass the
360
+ * freshness gate.
361
+ *
362
+ * Returns a `HarvestResult` so the caller can:
363
+ * - emit telemetry (which integrations fetched, which skipped on
364
+ * freshness, which failed),
365
+ * - cautious-escalate when any non-direct integration failed
366
+ * (§3.5 — prevents silent stage0 from masking a fetch outage),
367
+ * - plumb the rendered `<fetch_report>` block onto the Stage 3
368
+ * event so ContextBuilder folds it into the prompt.
369
+ */
370
+ async harvestForGate(source, forced) {
371
+ const startedAt = Date.now();
372
+ const integrations = readIntegrations(this.db);
373
+ const freshnessMinutes = this.config.activityScanPrePassFreshnessMinutes ?? 30;
374
+ const freshnessMs = Math.max(0, freshnessMinutes) * 60 * 1000;
375
+ const now = Date.now();
376
+ const eligibleIntegrations = [];
377
+ const skipped = [];
378
+ for (const key of INTEGRATION_KEYS) {
379
+ const state = integrations[key];
380
+ if (!state)
381
+ continue;
382
+ // Only non-direct integrations participate in pre-pass harvest.
383
+ // Direct-mode integrations rely on their in-process pollers; their
384
+ // observations land in `observations` independently of the gate.
385
+ if (state.mode !== "delegated" && state.mode !== "native")
386
+ continue;
387
+ if (forced || freshnessMs === 0) {
388
+ eligibleIntegrations.push(key);
389
+ continue;
390
+ }
391
+ const last = readRuntimeState(this.db, prePassLastRunRuntimeStateKey(key));
392
+ const lastMs = last ? Date.parse(last) : NaN;
393
+ if (Number.isFinite(lastMs) && now - lastMs < freshnessMs) {
394
+ skipped.push(key);
395
+ continue;
396
+ }
397
+ eligibleIntegrations.push(key);
398
+ }
399
+ if (eligibleIntegrations.length === 0) {
400
+ return {
401
+ ran: false,
402
+ integrations: [],
403
+ skippedIntegrations: skipped,
404
+ failedIntegrations: [],
405
+ durationMs: Date.now() - startedAt,
406
+ failed: false,
407
+ fetchReportBlock: null,
408
+ };
409
+ }
410
+ // Manufacture a placeholder activity_scan event so the runner can
411
+ // derive `RoutineWindowKey` and the agent-day. The runner's own
412
+ // `prepass_started` / `prepass_completed` SSE pair carries the
413
+ // correlation id back to the dashboard.
414
+ const parentEvent = {
415
+ ...createEvent({
416
+ type: "routine.activity_scan",
417
+ source,
418
+ priority: EventPriority.NORMAL,
419
+ }),
420
+ routine: "activity_scan",
421
+ data: { forced },
422
+ };
423
+ let result;
424
+ try {
425
+ result = await this.fetchWindowRunner.run(parentEvent, "routine.activity_scan", { integrationKeyFilter: new Set(eligibleIntegrations) });
426
+ }
427
+ catch (err) {
428
+ // Runner errors never propagate per design — but as a defensive
429
+ // floor we treat any throw as a hard failure across all eligible
430
+ // integrations so cautious-escalate kicks in.
431
+ logger.error({ err, source, eligibleIntegrations }, "harvestForGate: fetchWindowRunner.run threw — forcing cautious escalate");
432
+ return {
433
+ ran: true,
434
+ integrations: [],
435
+ skippedIntegrations: skipped,
436
+ failedIntegrations: eligibleIntegrations,
437
+ durationMs: Date.now() - startedAt,
438
+ failed: true,
439
+ fetchReportBlock: null,
440
+ };
441
+ }
442
+ const perIntegration = result.report.perIntegration ?? [];
443
+ const fetched = [];
444
+ const failed = [];
445
+ for (const sub of perIntegration) {
446
+ if (sub.status === "success")
447
+ fetched.push(sub.integrationKey);
448
+ else if (sub.status === "failed")
449
+ failed.push(sub.integrationKey);
450
+ else if (sub.status === "partial")
451
+ fetched.push(sub.integrationKey);
452
+ // skipped → no per-integration list entry; suppressed silently.
453
+ }
454
+ return {
455
+ ran: true,
456
+ integrations: fetched,
457
+ skippedIntegrations: skipped,
458
+ failedIntegrations: failed,
459
+ durationMs: Date.now() - startedAt,
460
+ failed: failed.length > 0,
461
+ fetchReportBlock: result.block,
462
+ };
463
+ }
464
+ /**
465
+ * cost-reduction-structural §B — pull a fresh signal snapshot and run
466
+ * the deterministic gate. Helper so the dispatcher's call site stays
467
+ * compact and tests can spy on the boundary.
468
+ */
469
+ computeActivityScanGateDecision() {
470
+ const todayMd = this.readTodayMdSafe();
471
+ const signals = computeActivityScanSignals(this.db, {
472
+ vipMailSenders: this.config.vipMailSenders ?? [],
473
+ todayMd,
474
+ // Pass the configured agent timezone so `agentPlanOverdueCount`
475
+ // compares HH:MM rows in the right zone. Falls back to the
476
+ // engine's local TZ inside `computeActivityScanSignals` when this
477
+ // config field is empty (the common single-user case).
478
+ ...(this.config.timezone
479
+ ? { agentTimezone: this.config.timezone }
480
+ : {}),
481
+ });
482
+ return decideStage(signals, {
483
+ heartbeatHours: this.config.activityScanHeartbeatHours ?? 4,
484
+ stage2Enabled: this.config.activityScanStage2Enabled ?? false,
485
+ pendingObsLowSignalCeiling: this.config.activityScanLowSignalPendingCeiling ?? 0,
486
+ });
487
+ }
488
+ readTodayMdSafe() {
489
+ try {
490
+ const path = join(getContextDir(this.config, this.db), CONTEXT_RELATIVE_PATHS.today);
491
+ if (!existsSync(path))
492
+ return null;
493
+ return readFileSync(path, "utf-8");
494
+ }
495
+ catch (err) {
496
+ logger.warn({ err }, "Failed to read today.md for activity_scan signals");
497
+ return null;
498
+ }
499
+ }
500
+ /**
501
+ * cost-reduction-structural §B — daemon-direct silent path. Used by
502
+ * Stage 0 and Stage 2 log-only verdicts. Consumes pending user
503
+ * observations + appends a single Agent Log line + records the gate
504
+ * verdict to `agent_actions`. The flag is reset before return.
505
+ */
506
+ runSilentActivityScanPath(decision, appliedDecision, ctx) {
507
+ const reason = appliedDecision === "stage0_silent"
508
+ ? "gate_stage0_silent"
509
+ : "gate_stage2_log_only";
510
+ let pendingCount = 0;
511
+ try {
512
+ pendingCount = decision.signals.pendingObsCount;
513
+ // Append a single bullet to today.md ## Agent Log. Best-effort —
514
+ // when today.md is missing or the lock is held, we still consume
515
+ // the observations so the queue doesn't grow indefinitely.
516
+ const message = appliedDecision === "stage0_silent"
517
+ ? `[activity_scan] Quiet (${decision.reason}) — ${pendingCount} obs consumed silently`
518
+ : `[activity_scan] Stage 2 log-only (${decision.reason}) — ${pendingCount} obs consumed silently`;
519
+ if (this.todayWriteLock) {
520
+ // Fire-and-forget: the silent-path return is a sync object the
521
+ // gate caller needs immediately to bookkeep observations. The
522
+ // Agent Log bullet is a best-effort trace (skipping is already
523
+ // an accepted outcome per AppendAgentLogLineResult.reason), so
524
+ // we don't await it. The serializer inside ensures the write
525
+ // does not race with HTTP context PATCHes on today.md.
526
+ void appendAgentLogLine({
527
+ contextDir: getContextDir(this.config, this.db),
528
+ message,
529
+ todayWriteLock: this.todayWriteLock,
530
+ timezone: this.config.timezone || undefined,
531
+ }).catch((err) => {
532
+ logger.error({ err }, "Daemon-direct Agent Log append threw — silent-path observations were still consumed");
533
+ });
534
+ }
535
+ // Consume the observations under the gate's correlation id so
536
+ // dashboards can attribute "consumed by gate" rows separately
537
+ // from agent-driven consumption. The actor filter is dropped
538
+ // (HOURLY_CHECK_GATE_REDESIGN_PLAN.md Phase 1+2) so pre-pass and
539
+ // delegated-sync agent rows are cleared too — otherwise they
540
+ // would accumulate on every silent tick.
541
+ try {
542
+ const pending = getPendingObservations(this.db, { limit: 100 });
543
+ if (pending.length > 0) {
544
+ consumeObservations(this.db, pending.map((row) => row.id), `activity_scan_gate:${appliedDecision}`);
545
+ }
546
+ }
547
+ catch (err) {
548
+ logger.warn({ err }, "Failed to consume observations on silent gate path");
549
+ }
550
+ this.logGateAuditRow(decision, {
551
+ appliedDecision,
552
+ forced: ctx.forced,
553
+ harvest: ctx.harvest,
554
+ preEscalate: ctx.preEscalate,
555
+ });
556
+ logger.info({
557
+ source: ctx.source,
558
+ gateStage: decision.stage,
559
+ gateReason: decision.reason,
560
+ appliedDecision,
561
+ pendingCount,
562
+ }, "Activity scan silenced by Stage-1 gate");
563
+ }
564
+ finally {
565
+ this.setActivityScanInProgress(false);
566
+ }
567
+ return {
568
+ status: "skipped",
569
+ reason,
570
+ pendingCount,
571
+ forced: ctx.forced,
572
+ };
573
+ }
574
+ async enqueueStage3ActivityScan(source, decision, extra) {
575
+ const gateBlock = renderGateDecisionBlock(decision, {
576
+ forced: extra.forced,
577
+ cautiousEscalate: extra.cautiousEscalate,
578
+ });
579
+ this.logGateAuditRow(decision, {
580
+ appliedDecision: "stage3",
581
+ forced: extra.forced,
582
+ harvest: extra.harvest,
583
+ preEscalate: extra.preEscalate,
584
+ ...(extra.cautiousEscalate ? { cautiousEscalate: true } : {}),
585
+ ...(extra.stage2Verdict ? { stage2Verdict: extra.stage2Verdict } : {}),
586
+ });
587
+ const stage3Event = {
588
+ ...createEvent({
589
+ type: "routine.activity_scan",
590
+ source,
591
+ priority: EventPriority.NORMAL,
592
+ }),
593
+ routine: "activity_scan",
594
+ data: {
595
+ pendingCount: extra.pendingCount,
596
+ forced: extra.forced,
597
+ gateDecision: {
598
+ stage: decision.stage,
599
+ reason: decision.reason,
600
+ forced: extra.forced,
601
+ ...(extra.cautiousEscalate ? { cautiousEscalate: true } : {}),
602
+ ...(extra.stage2Verdict ? { stage2Verdict: extra.stage2Verdict } : {}),
603
+ block: gateBlock,
604
+ },
605
+ // HOURLY_CHECK_GATE_REDESIGN_PLAN.md §3.3 — Layer-1 harvest ran
606
+ // BEFORE this enqueue so the gate could see fresh signals. The
607
+ // rendered `<fetch_report>` block is plumbed onto the event so
608
+ // ContextBuilder folds it into the Stage 3 prompt (the routine
609
+ // body still relies on the block for "what arrived this tick").
610
+ ...(extra.harvest.fetchReportBlock
611
+ ? { fetchReportBlock: extra.harvest.fetchReportBlock }
612
+ : {}),
613
+ },
614
+ ...(extra.requestedModel ? { requestedModel: extra.requestedModel } : {}),
615
+ };
616
+ await this.eventBus.put(stage3Event);
617
+ }
618
+ logGateAuditRow(decision, params) {
619
+ try {
620
+ // The gate-audit helper only knows about the canonical stages
621
+ // (gate output). Map the silent-path alias `stage2_log_only` onto
622
+ // its canonical sibling so the helper's typing stays narrow; the
623
+ // verdict is preserved verbatim alongside `stage_reached` in the
624
+ // merged detail.
625
+ const auditAppliedDecision = params.appliedDecision === "stage2_log_only"
626
+ ? "stage0_silent"
627
+ : params.appliedDecision;
628
+ const detail = {
629
+ ...buildGateAuditDetail(decision, {
630
+ appliedDecision: auditAppliedDecision,
631
+ forced: params.forced,
632
+ ...(params.stage2Verdict ? { stage2Verdict: params.stage2Verdict } : {}),
633
+ ...(params.cautiousEscalate ? { cautiousEscalate: true } : {}),
634
+ ...(params.preEscalate
635
+ ? {
636
+ preEscalateGateStage: params.preEscalate.stage,
637
+ preEscalateGateReason: params.preEscalate.reason,
638
+ }
639
+ : {}),
640
+ }),
641
+ // Always reflect the *real* applied stage in the row regardless
642
+ // of the alias mapping above.
643
+ stage_reached: params.appliedDecision,
644
+ // §7.2 harvest telemetry — surfaces on every gate audit row,
645
+ // including silent-gate skips, so per-tick cadence is observable
646
+ // without re-querying `routine.fetch_window` rows.
647
+ harvest_ran: params.harvest.ran,
648
+ harvest_integrations: params.harvest.integrations,
649
+ harvest_skipped_integrations: params.harvest.skippedIntegrations,
650
+ harvest_failed_integrations: params.harvest.failedIntegrations,
651
+ harvest_duration_ms: params.harvest.durationMs,
652
+ ...(params.extra ?? {}),
653
+ };
654
+ const isSilentPath = params.appliedDecision === "stage0_silent"
655
+ || params.appliedDecision === "stage2_log_only";
656
+ const result = params.resultOverride
657
+ ?? (isSilentPath ? "skipped" : "success");
658
+ this.db
659
+ .prepare(`INSERT INTO agent_actions
660
+ (action_type, trigger, result, detail, started_at, completed_at)
661
+ VALUES ('activity_scan.gate', 'autonomous', ?, json(?), datetime('now'), datetime('now'))`)
662
+ .run(result, JSON.stringify(detail));
663
+ }
664
+ catch (err) {
665
+ logger.warn({ err }, "Failed to record activity_scan.gate audit row");
666
+ }
667
+ }
668
+ /**
669
+ * cost-reduction-structural §B Stage 2 — synchronous lite-tier triage.
670
+ * Builds a `routine.activity_scan.triage` RoutineEvent and runs it
671
+ * inline through the agent router (NOT the EventBus, so the result
672
+ * is available before we decide whether to silence or escalate).
673
+ *
674
+ * The agent contract is JSON-only output (`{ "action": "log_only" |
675
+ * "escalate", "reason": "..." }`); on parse failure we return
676
+ * `'failed'` and the caller treats that as cautious escalate.
677
+ *
678
+ * Tool/turn clamp (defense-in-depth):
679
+ * - `allowedToolsOverride: []` removes every tool from the SDK's
680
+ * allowlist for the spawn. Stage 2 has nothing to do but emit a
681
+ * JSON line; the design's "no write tools" rule is enforced here
682
+ * instead of relying on the prompt alone.
683
+ * - `maxTurns: 1` caps the spawn at a single assistant turn. Even
684
+ * if a future prompt change accidentally invites tool use, the
685
+ * spawn cannot loop. Codex/Gemini have no per-spawn `allowedTools`
686
+ * surface today (acknowledged gap in `agent-core.ts`); the
687
+ * `maxTurns` cap and process_backend_config envelope are the
688
+ * remaining safety floor on those backends.
689
+ */
690
+ async runStage2Triage(decision, source) {
691
+ const triageEvent = {
692
+ ...createEvent({
693
+ type: "routine.activity_scan.triage",
694
+ source,
695
+ priority: EventPriority.NORMAL,
696
+ }),
697
+ routine: "activity_scan.triage",
698
+ data: {
699
+ forced: false,
700
+ gateDecision: {
701
+ stage: decision.stage,
702
+ reason: decision.reason,
703
+ forced: false,
704
+ block: renderGateDecisionBlock(decision, { forced: false }),
705
+ },
706
+ },
707
+ };
708
+ let context;
709
+ try {
710
+ context = await this.contextBuilder.build(triageEvent);
711
+ }
712
+ catch (err) {
713
+ logger.error({ err }, "Stage 2 triage context build failed");
714
+ return "failed";
715
+ }
716
+ const processKey = "routine.activity_scan.triage";
717
+ const reassemblePrompt = (bid) => this.prompt.assemble(triageEvent.type, processKey, bid);
718
+ let binding;
719
+ try {
720
+ binding = this.agentRouter.resolveBinding(triageEvent, { processKey });
721
+ }
722
+ catch (err) {
723
+ logger.error({ err }, "Stage 2 triage binding resolve failed");
724
+ return "failed";
725
+ }
726
+ const prompt = reassemblePrompt(binding.main.backendId);
727
+ let result;
728
+ try {
729
+ result = await this.agentRouter.execute({
730
+ prompt,
731
+ context,
732
+ event: triageEvent,
733
+ processKey,
734
+ preResolvedBinding: binding,
735
+ reassemblePrompt,
736
+ // Defense-in-depth: Stage 2 must not call any tool. Empty
737
+ // `allowedToolsOverride` REPLACES the default allowlist on
738
+ // Claude (Codex/Gemini have no per-spawn `allowedTools` surface
739
+ // — acknowledged gap in `agent-core.ts`). The `max_turns=1` cap
740
+ // for the spawn comes from the seeded `process_backend_config`
741
+ // row for `routine.activity_scan.triage` (see `db/schema.ts`),
742
+ // which the router reads via `binding.main.maxTurns`. Together
743
+ // these mean: zero tools on Claude, one assistant turn on every
744
+ // backend.
745
+ allowedToolsOverride: [],
746
+ });
747
+ }
748
+ catch (err) {
749
+ logger.error({ err }, "Stage 2 triage agent execution failed");
750
+ return "failed";
751
+ }
752
+ // Audit row for the lite-tier session itself, distinct from the gate
753
+ // audit row written by `logGateAuditRow`.
754
+ try {
755
+ this.audit.logAction({
756
+ event: triageEvent,
757
+ model: result.model,
758
+ costUsd: result.costUsd,
759
+ usage: result.usage,
760
+ modelUsage: result.modelUsage,
761
+ durationMs: result.durationMs,
762
+ numTurns: result.numTurns,
763
+ trigger: "autonomous",
764
+ backend: result.backendId,
765
+ costSource: result.costSource,
766
+ contextUpdated: result.contextUpdated,
767
+ advisorCallCount: result.advisorCallCount,
768
+ });
769
+ }
770
+ catch (err) {
771
+ logger.warn({ err }, "Failed to log Stage 2 triage agent_actions row");
772
+ }
773
+ return parseStage2Verdict(result.output);
774
+ }
775
+ }