@cohortapp/agent-sdk 2.3.0

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 (731) hide show
  1. package/.claude/commands/init-agent.md +104 -0
  2. package/.claude/commands/init-maestro.md +1187 -0
  3. package/.claude/settings.json +161 -0
  4. package/.env.example +216 -0
  5. package/README.md +632 -0
  6. package/agents/browser-operator/agent.md +52 -0
  7. package/agents/calendar-ops/agent.md +50 -0
  8. package/agents/communications/agent.md +96 -0
  9. package/agents/decision-log/agent.md +65 -0
  10. package/agents/desktop-operator/agent.md +59 -0
  11. package/agents/gmail-operator/agent.md +62 -0
  12. package/agents/inbound-dispatcher/agent.md +66 -0
  13. package/agents/inbox-processor/agent.md +39 -0
  14. package/agents/pmo-execution/agent.md +60 -0
  15. package/agents/session-spawner/agent.md +64 -0
  16. package/agents/slack-operator/agent.md +60 -0
  17. package/agents/whatsapp-operator/agent.md +60 -0
  18. package/agents/workflow-automation/agent.md +61 -0
  19. package/archetypes/altitudes/c-suite.yaml +58 -0
  20. package/archetypes/altitudes/founder.yaml +68 -0
  21. package/archetypes/altitudes/senior-manager.yaml +63 -0
  22. package/archetypes/altitudes/svp.yaml +60 -0
  23. package/archetypes/altitudes/vp.yaml +50 -0
  24. package/archetypes/archetype.schema.json +77 -0
  25. package/archetypes/base.yaml +47 -0
  26. package/archetypes/capabilities/commercial-leader.yaml +159 -0
  27. package/archetypes/capabilities/compliance-officer.yaml +159 -0
  28. package/archetypes/capabilities/executive-operator.yaml +169 -0
  29. package/archetypes/capabilities/finance-leader.yaml +162 -0
  30. package/archetypes/capabilities/operations-leader.yaml +154 -0
  31. package/archetypes/capabilities/product-leader.yaml +148 -0
  32. package/archetypes/capabilities/technical-leader.yaml +146 -0
  33. package/archetypes/functions/commercial-leader.yaml +62 -0
  34. package/archetypes/functions/compliance-officer.yaml +64 -0
  35. package/archetypes/functions/executive-operator.yaml +70 -0
  36. package/archetypes/functions/finance-leader.yaml +70 -0
  37. package/archetypes/functions/operations-leader.yaml +62 -0
  38. package/archetypes/functions/product-leader.yaml +61 -0
  39. package/archetypes/functions/technical-leader.yaml +57 -0
  40. package/bin/cohort-mcp.mjs +81 -0
  41. package/bin/maestro.mjs +3516 -0
  42. package/bin/maestro.test.mjs +1015 -0
  43. package/desktop-control/README.md +56 -0
  44. package/desktop-control/app-profiles/gmail.yaml +120 -0
  45. package/desktop-control/app-profiles/slack.yaml +315 -0
  46. package/desktop-control/app-profiles/whatsapp.yaml +107 -0
  47. package/docs/architecture/agent-topology.md +2239 -0
  48. package/docs/architecture/archetype-agent-factory.md +110 -0
  49. package/docs/architecture/collective-memory-and-org-mesh.md +115 -0
  50. package/docs/architecture/continuous-monitoring.md +221 -0
  51. package/docs/architecture/mcp-capability-map.md +585 -0
  52. package/docs/architecture/system-architecture.md +1272 -0
  53. package/docs/company-context/README.md +40 -0
  54. package/docs/guides/agent-persona-setup.md +600 -0
  55. package/docs/guides/agents-observe-setup.md +64 -0
  56. package/docs/guides/billing-console-keys.md +88 -0
  57. package/docs/guides/ccxray-diagnostics.md +65 -0
  58. package/docs/guides/channel-bus.md +127 -0
  59. package/docs/guides/claude-mem-setup.md +79 -0
  60. package/docs/guides/claude-pace-setup.md +56 -0
  61. package/docs/guides/claudraband-sessions.md +98 -0
  62. package/docs/guides/clawteam-swarm.md +116 -0
  63. package/docs/guides/code-review-graph-setup.md +86 -0
  64. package/docs/guides/email-setup.md +431 -0
  65. package/docs/guides/mac-mini.md +119 -0
  66. package/docs/guides/media-generation-setup.md +349 -0
  67. package/docs/guides/model-routing.md +162 -0
  68. package/docs/guides/observability-otel.md +265 -0
  69. package/docs/guides/org-onboarding.md +132 -0
  70. package/docs/guides/outbound-governance-setup.md +437 -0
  71. package/docs/guides/pdf-generation-setup.md +315 -0
  72. package/docs/guides/poller-daemon-setup.md +563 -0
  73. package/docs/guides/rag-context-setup.md +459 -0
  74. package/docs/guides/self-optimization-pattern.md +82 -0
  75. package/docs/guides/setup-wizard.md +178 -0
  76. package/docs/guides/slack-setup.md +350 -0
  77. package/docs/guides/telegram-setup.md +227 -0
  78. package/docs/guides/twilio-subaccounts-setup.md +223 -0
  79. package/docs/guides/verification.md +128 -0
  80. package/docs/guides/voice-mode.md +188 -0
  81. package/docs/guides/voice-sms-setup.md +698 -0
  82. package/docs/guides/webhook-relay-setup.md +349 -0
  83. package/docs/guides/whatsapp-setup.md +288 -0
  84. package/docs/prompts/board-pack-cover-template.md +36 -0
  85. package/docs/prompts/decision-recommendation-template.md +88 -0
  86. package/docs/prompts/followup-message-template.md +141 -0
  87. package/docs/prompts/investor-letter-template.md +52 -0
  88. package/docs/prompts/morning-brief-template.md +82 -0
  89. package/docs/prompts/presentation-template.md +58 -0
  90. package/docs/prompts/weekly-strategic-memo-template.md +104 -0
  91. package/docs/research/hallucinated-tool-output-investigation.md +151 -0
  92. package/docs/runbooks/backup-restore.md +205 -0
  93. package/docs/runbooks/cohort-cutover.md +129 -0
  94. package/docs/runbooks/fleet-operations.md +200 -0
  95. package/docs/runbooks/incident-response.md +226 -0
  96. package/docs/runbooks/mac-mini-bootstrap.md +431 -0
  97. package/docs/runbooks/perpetual-operations.md +509 -0
  98. package/docs/runbooks/recovery-and-failover.md +260 -0
  99. package/framework-features.json +267 -0
  100. package/ingest/README.md +87 -0
  101. package/lib/action-executor.js +689 -0
  102. package/lib/action-executor.test.mjs +871 -0
  103. package/lib/agent-root.mjs +37 -0
  104. package/lib/archetype.mjs +236 -0
  105. package/lib/archetype.test.mjs +132 -0
  106. package/lib/autonomy.mjs +114 -0
  107. package/lib/autonomy.test.mjs +66 -0
  108. package/lib/backlog.mjs +358 -0
  109. package/lib/backlog.test.mjs +266 -0
  110. package/lib/budget-guard.mjs +279 -0
  111. package/lib/budget-guard.test.mjs +291 -0
  112. package/lib/cadence-bus-schedule.test.mjs +194 -0
  113. package/lib/cadence-bus.mjs +1120 -0
  114. package/lib/cadence-bus.test.mjs +720 -0
  115. package/lib/cadences.mjs +205 -0
  116. package/lib/cadences.test.mjs +125 -0
  117. package/lib/capability.mjs +154 -0
  118. package/lib/capability.test.mjs +78 -0
  119. package/lib/channels/base-adapter.mjs +719 -0
  120. package/lib/channels/base-adapter.test.mjs +590 -0
  121. package/lib/channels/channel.mjs +128 -0
  122. package/lib/channels/channels.test.mjs +371 -0
  123. package/lib/channels/contract.mjs +215 -0
  124. package/lib/channels/contract.test.mjs +137 -0
  125. package/lib/channels/conversation-resolver.mjs +95 -0
  126. package/lib/channels/gmail/adapter.mjs +87 -0
  127. package/lib/channels/inbox-item.mjs +255 -0
  128. package/lib/channels/inbox-item.test.mjs +335 -0
  129. package/lib/channels/index.mjs +94 -0
  130. package/lib/channels/orgmail/adapter.mjs +353 -0
  131. package/lib/channels/orgmail/adapter.test.mjs +311 -0
  132. package/lib/channels/pairing.mjs +363 -0
  133. package/lib/channels/pairing.test.mjs +270 -0
  134. package/lib/channels/registry.mjs +164 -0
  135. package/lib/channels/slack/adapter.mjs +317 -0
  136. package/lib/channels/slack-adapter.test.mjs +212 -0
  137. package/lib/channels/sms/adapter.mjs +43 -0
  138. package/lib/channels/telegram/adapter.mjs +432 -0
  139. package/lib/channels/telegram-adapter.test.mjs +306 -0
  140. package/lib/channels/voice/adapter.mjs +301 -0
  141. package/lib/channels/voice/adapter.test.mjs +278 -0
  142. package/lib/channels/whatsapp/adapter-baileys.mjs +587 -0
  143. package/lib/channels/whatsapp/adapter-baileys.test.mjs +359 -0
  144. package/lib/channels/whatsapp/adapter-twilio.mjs +65 -0
  145. package/lib/channels/whatsapp/baileys-typing.test.mjs +154 -0
  146. package/lib/charter.mjs +256 -0
  147. package/lib/charter.test.mjs +89 -0
  148. package/lib/claude-bin.mjs +134 -0
  149. package/lib/claude-bin.test.mjs +75 -0
  150. package/lib/collective/capture.mjs +185 -0
  151. package/lib/collective/capture.test.mjs +121 -0
  152. package/lib/collective/cards.mjs +201 -0
  153. package/lib/collective/cards.test.mjs +114 -0
  154. package/lib/collective/config.mjs +186 -0
  155. package/lib/collective/config.test.mjs +123 -0
  156. package/lib/collective/global-config.mjs +113 -0
  157. package/lib/collective/global-config.test.mjs +75 -0
  158. package/lib/collective/presence.mjs +201 -0
  159. package/lib/collective/presence.test.mjs +95 -0
  160. package/lib/collective/recall.mjs +215 -0
  161. package/lib/collective/recall.test.mjs +116 -0
  162. package/lib/comms/send-gate.mjs +554 -0
  163. package/lib/comms/send-gate.test.mjs +577 -0
  164. package/lib/comms.mjs +67 -0
  165. package/lib/comms.test.mjs +41 -0
  166. package/lib/diagnostics/alerts.mjs +424 -0
  167. package/lib/diagnostics/alerts.test.mjs +318 -0
  168. package/lib/diagnostics/backup-freshness.mjs +188 -0
  169. package/lib/diagnostics/backup-freshness.test.mjs +185 -0
  170. package/lib/diagnostics/counters.mjs +269 -0
  171. package/lib/diagnostics/counters.test.mjs +206 -0
  172. package/lib/diagnostics/events.mjs +188 -0
  173. package/lib/diagnostics/events.test.mjs +290 -0
  174. package/lib/diagnostics/otel.mjs +237 -0
  175. package/lib/diagnostics/otel.test.mjs +196 -0
  176. package/lib/diagnostics/trace.mjs +216 -0
  177. package/lib/diagnostics/trace.test.mjs +251 -0
  178. package/lib/env-compat.mjs +74 -0
  179. package/lib/env-compat.test.mjs +104 -0
  180. package/lib/feature-init.mjs +331 -0
  181. package/lib/fs-atomic.mjs +112 -0
  182. package/lib/fs-atomic.test.mjs +72 -0
  183. package/lib/fs-ownership.mjs +111 -0
  184. package/lib/fs-ownership.test.mjs +158 -0
  185. package/lib/hooks/bus.mjs +347 -0
  186. package/lib/hooks/bus.test.mjs +387 -0
  187. package/lib/index.js +16 -0
  188. package/lib/learning/config.mjs +106 -0
  189. package/lib/learning/config.test.mjs +75 -0
  190. package/lib/learning/counters.mjs +156 -0
  191. package/lib/learning/counters.test.mjs +69 -0
  192. package/lib/learning/curator-consolidate.test.mjs +238 -0
  193. package/lib/learning/curator.mjs +453 -0
  194. package/lib/learning/curator.test.mjs +106 -0
  195. package/lib/learning/log.mjs +40 -0
  196. package/lib/learning/reflect.mjs +534 -0
  197. package/lib/learning/reflect.test.mjs +0 -0
  198. package/lib/learning/session-index.mjs +352 -0
  199. package/lib/learning/session-index.test.mjs +125 -0
  200. package/lib/learning/skill-writer.mjs +474 -0
  201. package/lib/learning/skill-writer.test.mjs +210 -0
  202. package/lib/mcp/server.mjs +328 -0
  203. package/lib/mcp/server.test.mjs +400 -0
  204. package/lib/model-router/auth-profiles.mjs +758 -0
  205. package/lib/model-router/auth-profiles.test.mjs +580 -0
  206. package/lib/model-router/catalog/anthropic.yaml +153 -0
  207. package/lib/model-router/catalog/deepseek.yaml +86 -0
  208. package/lib/model-router/catalog/moonshot.yaml +81 -0
  209. package/lib/model-router/catalog/qwen.yaml +114 -0
  210. package/lib/model-router/catalog.mjs +925 -0
  211. package/lib/model-router/catalog.test.mjs +385 -0
  212. package/lib/model-router/economics.mjs +564 -0
  213. package/lib/model-router/economics.test.mjs +344 -0
  214. package/lib/model-router/failover.mjs +298 -0
  215. package/lib/model-router/failover.test.mjs +439 -0
  216. package/lib/model-router/health.mjs +453 -0
  217. package/lib/model-router/health.test.mjs +338 -0
  218. package/lib/model-router/integration-coverage.test.mjs +829 -0
  219. package/lib/model-router/integration.test.mjs +564 -0
  220. package/lib/model-router/ledger.mjs +402 -0
  221. package/lib/model-router/ledger.test.mjs +382 -0
  222. package/lib/model-router/llm-task.mjs +515 -0
  223. package/lib/model-router/llm-task.test.mjs +392 -0
  224. package/lib/model-router/org-credentials.mjs +260 -0
  225. package/lib/model-router/org-credentials.test.mjs +265 -0
  226. package/lib/model-router/pricing-refresh.mjs +463 -0
  227. package/lib/model-router/pricing-refresh.test.mjs +286 -0
  228. package/lib/model-router/reconcile.mjs +429 -0
  229. package/lib/model-router/reconcile.test.mjs +316 -0
  230. package/lib/model-router/repair.mjs +471 -0
  231. package/lib/model-router/repair.test.mjs +180 -0
  232. package/lib/model-router/resolve.mjs +1206 -0
  233. package/lib/model-router/spawn.mjs +497 -0
  234. package/lib/model-router/spawn.test.mjs +425 -0
  235. package/lib/model-router/taxonomy.mjs +893 -0
  236. package/lib/model-router/taxonomy.test.mjs +410 -0
  237. package/lib/model-router.mjs +677 -0
  238. package/lib/model-router.test.mjs +907 -0
  239. package/lib/org/activity.mjs +211 -0
  240. package/lib/org/activity.test.mjs +134 -0
  241. package/lib/org/approvals.mjs +448 -0
  242. package/lib/org/approvals.test.mjs +216 -0
  243. package/lib/org/awareness.mjs +222 -0
  244. package/lib/org/awareness.test.mjs +159 -0
  245. package/lib/org/board.mjs +229 -0
  246. package/lib/org/board.test.mjs +177 -0
  247. package/lib/org/bootstrap-context.mjs +169 -0
  248. package/lib/org/bootstrap-context.test.mjs +153 -0
  249. package/lib/org/client.mjs +1628 -0
  250. package/lib/org/client.test.mjs +1107 -0
  251. package/lib/org/cohort-client.mjs +67 -0
  252. package/lib/org/cohort-client.test.mjs +126 -0
  253. package/lib/org/cost-sync.mjs +227 -0
  254. package/lib/org/cost-sync.test.mjs +153 -0
  255. package/lib/org/doctor.mjs +212 -0
  256. package/lib/org/doctor.test.mjs +212 -0
  257. package/lib/org/handoff.mjs +293 -0
  258. package/lib/org/handoff.test.mjs +269 -0
  259. package/lib/org/integration-tools.mjs +182 -0
  260. package/lib/org/integration-tools.test.mjs +160 -0
  261. package/lib/org/keys.mjs +131 -0
  262. package/lib/org/keys.test.mjs +92 -0
  263. package/lib/org/knowledge.mjs +463 -0
  264. package/lib/org/knowledge.test.mjs +319 -0
  265. package/lib/org/leases.mjs +335 -0
  266. package/lib/org/leases.test.mjs +235 -0
  267. package/lib/org/mesh-integration.test.mjs +127 -0
  268. package/lib/org/mesh.mjs +459 -0
  269. package/lib/org/mesh.test.mjs +345 -0
  270. package/lib/org/messaging.mjs +503 -0
  271. package/lib/org/messaging.test.mjs +238 -0
  272. package/lib/org/policy.mjs +345 -0
  273. package/lib/org/policy.test.mjs +237 -0
  274. package/lib/org/protocol.checksum +1 -0
  275. package/lib/org/protocol.checksum.test.mjs +90 -0
  276. package/lib/org/protocol.mjs +967 -0
  277. package/lib/org/protocol.test.mjs +264 -0
  278. package/lib/org/registry.mjs +194 -0
  279. package/lib/org/registry.test.mjs +100 -0
  280. package/lib/org/tool-surface-integration.test.mjs +120 -0
  281. package/lib/org/tool-surface.mjs +2535 -0
  282. package/lib/org/tool-surface.test.mjs +589 -0
  283. package/lib/org/ui-parity.mjs +3236 -0
  284. package/lib/org/ui-parity.test.mjs +348 -0
  285. package/lib/org/verify.mjs +176 -0
  286. package/lib/org/verify.test.mjs +194 -0
  287. package/lib/rag/embed.mjs +188 -0
  288. package/lib/rag/indexer.mjs +425 -0
  289. package/lib/rag/rag.test.mjs +505 -0
  290. package/lib/rag/search.mjs +475 -0
  291. package/lib/rate-guard.mjs +246 -0
  292. package/lib/rate-guard.test.mjs +201 -0
  293. package/lib/render.mjs +112 -0
  294. package/lib/render.test.mjs +68 -0
  295. package/lib/resource-governor.mjs +297 -0
  296. package/lib/resource-governor.test.mjs +262 -0
  297. package/lib/scheduling/dynamic-jobs.mjs +675 -0
  298. package/lib/scheduling/dynamic-jobs.test.mjs +344 -0
  299. package/lib/scheduling/jitter.mjs +0 -0
  300. package/lib/scheduling/jitter.test.mjs +140 -0
  301. package/lib/secrets/broker.mjs +315 -0
  302. package/lib/secrets/broker.test.mjs +280 -0
  303. package/lib/secrets/providers.mjs +461 -0
  304. package/lib/secrets/providers.test.mjs +274 -0
  305. package/lib/security/audit-engine.mjs +684 -0
  306. package/lib/security/audit-engine.test.mjs +389 -0
  307. package/lib/security/coerce-args.mjs +552 -0
  308. package/lib/security/coerce-args.test.mjs +281 -0
  309. package/lib/security/dangerous-tools.mjs +97 -0
  310. package/lib/security/dangerous-tools.test.mjs +68 -0
  311. package/lib/security/external-content.mjs +145 -0
  312. package/lib/security/external-content.test.mjs +67 -0
  313. package/lib/security/redact.mjs +592 -0
  314. package/lib/security/redact.test.mjs +441 -0
  315. package/lib/security/secret-equal.mjs +73 -0
  316. package/lib/security/secret-equal.test.mjs +55 -0
  317. package/lib/session-permissions.mjs +101 -0
  318. package/lib/session-permissions.test.mjs +100 -0
  319. package/lib/setup/claude-probe.mjs +74 -0
  320. package/lib/setup/completeness.mjs +175 -0
  321. package/lib/setup/completeness.test.mjs +110 -0
  322. package/lib/setup/context-pack.mjs +173 -0
  323. package/lib/setup/context-pack.test.mjs +89 -0
  324. package/lib/setup/enrich.mjs +277 -0
  325. package/lib/setup/enrich.test.mjs +115 -0
  326. package/lib/setup/enroll-from-cohort.mjs +441 -0
  327. package/lib/setup/enroll-from-cohort.test.mjs +233 -0
  328. package/lib/setup/integration.test.mjs +162 -0
  329. package/lib/setup/io.mjs +360 -0
  330. package/lib/setup/io.test.mjs +77 -0
  331. package/lib/setup/run-generator.mjs +81 -0
  332. package/lib/setup/runner.mjs +244 -0
  333. package/lib/setup/runner.test.mjs +132 -0
  334. package/lib/setup/sections/comms.mjs +173 -0
  335. package/lib/setup/sections/company.mjs +120 -0
  336. package/lib/setup/sections/enrich.mjs +138 -0
  337. package/lib/setup/sections/identity.mjs +182 -0
  338. package/lib/setup/sections/identity.test.mjs +140 -0
  339. package/lib/setup/sections/learning.mjs +153 -0
  340. package/lib/setup/sections/learning.test.mjs +81 -0
  341. package/lib/setup/sections/messaging.mjs +219 -0
  342. package/lib/setup/sections/messaging.test.mjs +127 -0
  343. package/lib/setup/sections/model.mjs +102 -0
  344. package/lib/setup/sections/operating-model.mjs +78 -0
  345. package/lib/setup/sections/org.mjs +475 -0
  346. package/lib/setup/sections/org.test.mjs +313 -0
  347. package/lib/setup/sections/orgmail.mjs +173 -0
  348. package/lib/setup/sections/orgmail.test.mjs +118 -0
  349. package/lib/setup/sections/recovery.mjs +159 -0
  350. package/lib/setup/sections/recovery.test.mjs +98 -0
  351. package/lib/setup/sections/tools.mjs +132 -0
  352. package/lib/setup/sections/verify.mjs +97 -0
  353. package/lib/setup/sot.mjs +205 -0
  354. package/lib/setup/sot.test.mjs +81 -0
  355. package/lib/setup/state.mjs +151 -0
  356. package/lib/setup/state.test.mjs +92 -0
  357. package/lib/singleton.js +229 -0
  358. package/lib/singleton.test.mjs +135 -0
  359. package/lib/telemetry/alerts.mjs +216 -0
  360. package/lib/telemetry/alerts.test.mjs +109 -0
  361. package/lib/telemetry/collect.mjs +512 -0
  362. package/lib/telemetry/collect.test.mjs +202 -0
  363. package/lib/tool-definitions-integration.test.mjs +83 -0
  364. package/lib/tool-definitions.js +738 -0
  365. package/lib/tool-definitions.test.mjs +437 -0
  366. package/lib/util/fetch-timeout.mjs +136 -0
  367. package/lib/util/fetch-timeout.test.mjs +202 -0
  368. package/lib/util/reconnect.mjs +343 -0
  369. package/lib/util/reconnect.test.mjs +369 -0
  370. package/lib/util/unhandled.mjs +205 -0
  371. package/lib/util/unhandled.test.mjs +216 -0
  372. package/lib/voice/context-loader.mjs +466 -0
  373. package/lib/voice/index.mjs +100 -0
  374. package/lib/voice/openai-realtime.mjs +510 -0
  375. package/lib/voice/outbound.mjs +542 -0
  376. package/lib/voice/outbound.test.mjs +69 -0
  377. package/lib/voice/post-call-brief.mjs +428 -0
  378. package/lib/voice/provider.mjs +52 -0
  379. package/lib/voice/session-rotation.mjs +257 -0
  380. package/lib/voice/stt.mjs +161 -0
  381. package/lib/voice/stt.test.mjs +226 -0
  382. package/lib/voice/tool-bridge.mjs +370 -0
  383. package/lib/voice/tts.mjs +104 -0
  384. package/lib/voice/twilio-sip-bridge.mjs +288 -0
  385. package/lib/voice/voice.test.mjs +990 -0
  386. package/mcp/README.md +80 -0
  387. package/package.json +151 -0
  388. package/plugins/maestro-skills/plugin.json +139 -0
  389. package/plugins/maestro-skills/skills/agents-observe.md +110 -0
  390. package/plugins/maestro-skills/skills/board-deck.md +68 -0
  391. package/plugins/maestro-skills/skills/books-close.md +77 -0
  392. package/plugins/maestro-skills/skills/brand-steward.md +121 -0
  393. package/plugins/maestro-skills/skills/calendar-plan.md +57 -0
  394. package/plugins/maestro-skills/skills/call-working-sessions.md +124 -0
  395. package/plugins/maestro-skills/skills/ccxray-diagnostics.md +91 -0
  396. package/plugins/maestro-skills/skills/claude-pace.md +61 -0
  397. package/plugins/maestro-skills/skills/code-review-graph.md +99 -0
  398. package/plugins/maestro-skills/skills/crm-pipeline.md +65 -0
  399. package/plugins/maestro-skills/skills/decision-brief.md +89 -0
  400. package/plugins/maestro-skills/skills/directory-hygiene.md +125 -0
  401. package/plugins/maestro-skills/skills/draft-comms.md +84 -0
  402. package/plugins/maestro-skills/skills/evening-wrap.md +53 -0
  403. package/plugins/maestro-skills/skills/files-find.md +65 -0
  404. package/plugins/maestro-skills/skills/generative-ui.md +228 -0
  405. package/plugins/maestro-skills/skills/hiring-triage.md +74 -0
  406. package/plugins/maestro-skills/skills/inbox-triage.md +61 -0
  407. package/plugins/maestro-skills/skills/mail-triage.md +86 -0
  408. package/plugins/maestro-skills/skills/morning-brief.md +54 -0
  409. package/plugins/maestro-skills/skills/native-artifacts.md +157 -0
  410. package/plugins/maestro-skills/skills/org-board.md +133 -0
  411. package/plugins/maestro-skills/skills/org-credential.md +68 -0
  412. package/plugins/maestro-skills/skills/org-recall.md +81 -0
  413. package/plugins/maestro-skills/skills/pipeline-review.md +76 -0
  414. package/plugins/maestro-skills/skills/regulatory-status.md +81 -0
  415. package/plugins/maestro-skills/skills/router-why.md +78 -0
  416. package/plugins/maestro-skills/skills/schedule-meeting.md +91 -0
  417. package/plugins/maestro-skills/skills/session-search.md +71 -0
  418. package/plugins/maestro-skills/skills/set-reminder.md +93 -0
  419. package/plugins/maestro-skills/skills/slack-followup.md +64 -0
  420. package/plugins/maestro-skills/skills/team-activity.md +86 -0
  421. package/plugins/maestro-skills/skills/weekly-memo.md +70 -0
  422. package/policies/action-classification.yaml +114 -0
  423. package/policies/ai-disclosure.yaml +294 -0
  424. package/policies/communication-style.md +139 -0
  425. package/policies/information-barriers.yaml +118 -0
  426. package/policies/prompt-injection-defence.yaml +138 -0
  427. package/public/assets/icon-dark.png +0 -0
  428. package/public/assets/icon-dark.svg +9 -0
  429. package/public/assets/icon-light.svg +9 -0
  430. package/public/assets/logo-dark.svg +15 -0
  431. package/public/assets/logo-light.svg +15 -0
  432. package/scaffold/.mcp.json +7 -0
  433. package/scaffold/CLAUDE.md +368 -0
  434. package/scaffold/config/agent.json +55 -0
  435. package/scaffold/config/agent.ts +76 -0
  436. package/scaffold/config/agent.ts.example +89 -0
  437. package/scaffold/config/alerts.yaml +23 -0
  438. package/scaffold/config/allowlist.yaml.example +25 -0
  439. package/scaffold/config/caller-id-map.yaml +46 -0
  440. package/scaffold/config/collective.yaml +49 -0
  441. package/scaffold/config/company.json +20 -0
  442. package/scaffold/config/known-agents.json +6 -0
  443. package/scaffold/config/learning.yaml +55 -0
  444. package/scaffold/config/model-routing.yaml.example +104 -0
  445. package/scaffold/config/org.yaml +25 -0
  446. package/scaffold/config/orgmail.yaml.example +19 -0
  447. package/scaffold/config/recovery.yaml +72 -0
  448. package/scaffold/config/secrets.yaml +27 -0
  449. package/scaffold/config/slack.yaml.example +35 -0
  450. package/scaffold/config/telegram.yaml.example +38 -0
  451. package/scaffold/config/voice.yaml.example +89 -0
  452. package/scaffold/config/whatsapp.yaml.example +39 -0
  453. package/schedules/README.md +49 -0
  454. package/schedules/triggers/backlog-executor.md +102 -0
  455. package/schedules/triggers/brand-steward.md +72 -0
  456. package/schedules/triggers/daily-evening-wrap.md +159 -0
  457. package/schedules/triggers/daily-midday-sweep.md +58 -0
  458. package/schedules/triggers/daily-morning-brief.md +55 -0
  459. package/schedules/triggers/directory-hygiene.md +81 -0
  460. package/schedules/triggers/dynamic-jobs.md +40 -0
  461. package/schedules/triggers/inbox-processor.md +115 -0
  462. package/schedules/triggers/meeting-action-capture.md +60 -0
  463. package/schedules/triggers/meeting-prep.md +69 -0
  464. package/schedules/triggers/messaging-inbound.md +50 -0
  465. package/schedules/triggers/org-pulse.md +24 -0
  466. package/schedules/triggers/quarterly-self-assessment.md +54 -0
  467. package/schedules/triggers/weekly-engineering-health.md +37 -0
  468. package/schedules/triggers/weekly-execution.md +65 -0
  469. package/schedules/triggers/weekly-hiring.md +53 -0
  470. package/schedules/triggers/weekly-priorities.md +38 -0
  471. package/schedules/triggers/weekly-strategic-memo.md +124 -0
  472. package/scripts/archive-email.sh +55 -0
  473. package/scripts/cadence/cadence-status.mjs +36 -0
  474. package/scripts/cadence/enqueue-cadence-tick.mjs +174 -0
  475. package/scripts/cadence/enqueue-cadence-tick.test.mjs +187 -0
  476. package/scripts/cadence/launchd-cadence-wrapper.sh +85 -0
  477. package/scripts/cadence/launchd-cloud-relay-wrapper.sh +95 -0
  478. package/scripts/cadence/launchd-socket-mode-wrapper.sh +95 -0
  479. package/scripts/ci/check-docs-accuracy.mjs +493 -0
  480. package/scripts/ci/check-docs-accuracy.test.mjs +409 -0
  481. package/scripts/ci/check-exports-exist.mjs +140 -0
  482. package/scripts/ci/check-files-exist.mjs +107 -0
  483. package/scripts/ci/check-no-build-artifacts.mjs +111 -0
  484. package/scripts/ci/check-no-build-artifacts.test.mjs +71 -0
  485. package/scripts/ci/check-no-confidential.mjs +198 -0
  486. package/scripts/ci/check-no-conflict-markers.mjs +169 -0
  487. package/scripts/ci/check-no-residual-identity.mjs +163 -0
  488. package/scripts/ci/check-no-residual-identity.test.mjs +89 -0
  489. package/scripts/ci/check-tarball-fidelity.mjs +205 -0
  490. package/scripts/ci/check-unresolved-tokens.mjs +83 -0
  491. package/scripts/ci/check.mjs +109 -0
  492. package/scripts/ci/check.test.mjs +194 -0
  493. package/scripts/ci/run-coverage.mjs +82 -0
  494. package/scripts/ci/run-tests.mjs +71 -0
  495. package/scripts/cloud-relay/README.md +59 -0
  496. package/scripts/cloud-relay/index.mjs +233 -0
  497. package/scripts/cloud-relay/package.json +15 -0
  498. package/scripts/cloud-relay/railway.json +13 -0
  499. package/scripts/cloud-relay/voice/README.md +94 -0
  500. package/scripts/cloud-relay/voice/package-lock.json +39 -0
  501. package/scripts/cloud-relay/voice/package.json +16 -0
  502. package/scripts/cloud-relay/voice/railway.json +13 -0
  503. package/scripts/cloud-relay/voice/server.mjs +532 -0
  504. package/scripts/collective/hook-runner.mjs +211 -0
  505. package/scripts/collective/hook-runner.test.mjs +90 -0
  506. package/scripts/collective/org-pulse.mjs +72 -0
  507. package/scripts/collective/org-sync.mjs +61 -0
  508. package/scripts/collective/recall.mjs +45 -0
  509. package/scripts/collective/who.mjs +30 -0
  510. package/scripts/comms-monitor.sh +288 -0
  511. package/scripts/configure-whatsapp-sandbox.sh +201 -0
  512. package/scripts/continuous-monitor.sh +91 -0
  513. package/scripts/cost/fleet-digest.mjs +407 -0
  514. package/scripts/cost/fleet-digest.test.mjs +207 -0
  515. package/scripts/cost/track-claude-usage.mjs +169 -0
  516. package/scripts/daemon/agent-daemon.mjs +989 -0
  517. package/scripts/daemon/agent-daemon.test.mjs +525 -0
  518. package/scripts/daemon/cadence-consumer-governance.test.mjs +220 -0
  519. package/scripts/daemon/cadence-consumer.mjs +1080 -0
  520. package/scripts/daemon/cadence-consumer.test.mjs +770 -0
  521. package/scripts/daemon/cadence-handlers.mjs +1121 -0
  522. package/scripts/daemon/cadence-handlers.test.mjs +617 -0
  523. package/scripts/daemon/classifier.mjs +704 -0
  524. package/scripts/daemon/classifier.test.mjs +238 -0
  525. package/scripts/daemon/classify-kind.mjs +54 -0
  526. package/scripts/daemon/classify-kind.test.mjs +40 -0
  527. package/scripts/daemon/context-compiler.mjs +605 -0
  528. package/scripts/daemon/context-compiler.test.mjs +300 -0
  529. package/scripts/daemon/dispatcher-cooldown.test.mjs +122 -0
  530. package/scripts/daemon/dispatcher-governance.test.mjs +886 -0
  531. package/scripts/daemon/dispatcher.mjs +1516 -0
  532. package/scripts/daemon/health.mjs +72 -0
  533. package/scripts/daemon/inbox-deferral.mjs +210 -0
  534. package/scripts/daemon/inbox-deferral.test.mjs +242 -0
  535. package/scripts/daemon/integration.test.mjs +149 -0
  536. package/scripts/daemon/launchd-wrapper-generic.sh +96 -0
  537. package/scripts/daemon/launchd-wrapper-slack-events.sh +37 -0
  538. package/scripts/daemon/launchd-wrapper.sh +91 -0
  539. package/scripts/daemon/lib/session-router.mjs +274 -0
  540. package/scripts/daemon/lib/session-router.test.mjs +295 -0
  541. package/scripts/daemon/maestro-daemon.mjs +275 -0
  542. package/scripts/daemon/prompt-builder.mjs +685 -0
  543. package/scripts/daemon/prompt-builder.test.mjs +213 -0
  544. package/scripts/daemon/responder.mjs +854 -0
  545. package/scripts/daemon/session-lock.mjs +721 -0
  546. package/scripts/daemon/session-lock.test.mjs +252 -0
  547. package/scripts/daemon/session-outcomes.mjs +640 -0
  548. package/scripts/daemon/session-outcomes.test.mjs +533 -0
  549. package/scripts/daemon/typing-registry.mjs +90 -0
  550. package/scripts/daemon/typing-registry.test.mjs +77 -0
  551. package/scripts/daemon/voice-webhook-server.mjs +804 -0
  552. package/scripts/decisions/capture-decision.mjs +116 -0
  553. package/scripts/disclosure_assessment.py +873 -0
  554. package/scripts/disclosure_boundaries.py +562 -0
  555. package/scripts/email-signature-principal.html +52 -0
  556. package/scripts/email-signature.html +60 -0
  557. package/scripts/email_quote_thread.py +167 -0
  558. package/scripts/email_thread_dedup.py +362 -0
  559. package/scripts/emergency-stop.sh +81 -0
  560. package/scripts/healthcheck.sh +116 -0
  561. package/scripts/hooks/block-mcp-cohort-send.sh +15 -0
  562. package/scripts/hooks/block-mcp-slack-send.sh +7 -0
  563. package/scripts/hooks/post-action-log.sh +126 -0
  564. package/scripts/hooks/pre-send-audit.sh +174 -0
  565. package/scripts/hooks/pre-send-audit.test.mjs +215 -0
  566. package/scripts/hooks/session-end-log.sh +27 -0
  567. package/scripts/hooks/session-start-banner.sh +115 -0
  568. package/scripts/huddle/audio-bridge.mjs +664 -0
  569. package/scripts/huddle/boot-slack-cdp.sh +102 -0
  570. package/scripts/huddle/huddle-controller.mjs +942 -0
  571. package/scripts/huddle/huddle-server.mjs +1229 -0
  572. package/scripts/huddle/launch-slack.sh +232 -0
  573. package/scripts/huddle/openai-realtime-bridge.mjs +462 -0
  574. package/scripts/huddle/package-lock.json +62 -0
  575. package/scripts/huddle/package.json +22 -0
  576. package/scripts/huddle/setup-audio.sh +239 -0
  577. package/scripts/huddle/start-call.mjs +318 -0
  578. package/scripts/huddle/test-pipeline.mjs +263 -0
  579. package/scripts/learning/consolidate-skills.mjs +72 -0
  580. package/scripts/learning/session-search.mjs +125 -0
  581. package/scripts/llm_email_dedup.py +442 -0
  582. package/scripts/local-triggers/generate-plists.sh +432 -0
  583. package/scripts/local-triggers/generate-plists.test.mjs +413 -0
  584. package/scripts/local-triggers/install-all.sh +49 -0
  585. package/scripts/local-triggers/plists/.gitkeep +0 -0
  586. package/scripts/local-triggers/run-trigger.sh +63 -0
  587. package/scripts/local-triggers/templates/rag-reindex.plist.template +47 -0
  588. package/scripts/local-triggers/templates/voice-relay-poller.plist.template +54 -0
  589. package/scripts/local-triggers/templates/voice-tunnel.plist.template +55 -0
  590. package/scripts/local-triggers/templates/voice-webhook.plist.template +51 -0
  591. package/scripts/maintenance/backup-to-cloud.sh +124 -0
  592. package/scripts/maintenance/health-check.sh +377 -0
  593. package/scripts/media-generation/README.md +105 -0
  594. package/scripts/media-generation/gemini-image-client.mjs +173 -0
  595. package/scripts/media-generation/generate-assets.mjs +289 -0
  596. package/scripts/media-generation/veo-video-client.mjs +219 -0
  597. package/scripts/org/send-orgmail.mjs +227 -0
  598. package/scripts/outbound-dedup-cleanup.sh +43 -0
  599. package/scripts/outbound-dedup.sh +477 -0
  600. package/scripts/outbound_dedup.py +115 -0
  601. package/scripts/parse-voice-transcript.mjs +481 -0
  602. package/scripts/pdf-generation/README.md +63 -0
  603. package/scripts/pdf-generation/build-document.mjs +247 -0
  604. package/scripts/pdf-generation/templates/board-pack.latex +136 -0
  605. package/scripts/pdf-generation/templates/corporate-letter.latex +126 -0
  606. package/scripts/pdf-generation/templates/memo.latex +114 -0
  607. package/scripts/poll-slack-events.sh +35 -0
  608. package/scripts/poller/calendar-poller.mjs +12 -0
  609. package/scripts/poller/gmail-poller.mjs +192 -0
  610. package/scripts/poller/imap-client.mjs +289 -0
  611. package/scripts/poller/inbox-scan-poller.mjs +156 -0
  612. package/scripts/poller/inbox-scan-poller.test.mjs +231 -0
  613. package/scripts/poller/index.mjs +73 -0
  614. package/scripts/poller/intra-session-check.mjs +285 -0
  615. package/scripts/poller/lib/cloud-relay-dedup.mjs +88 -0
  616. package/scripts/poller/lib/cloud-relay-dedup.test.mjs +133 -0
  617. package/scripts/poller/lib/slash-command-handlers.mjs +177 -0
  618. package/scripts/poller/secondary-gmail-poller.mjs +132 -0
  619. package/scripts/poller/slack-cloud-relay-client.mjs +368 -0
  620. package/scripts/poller/slack-poller.mjs +854 -0
  621. package/scripts/poller/slack-socket-mode.mjs +917 -0
  622. package/scripts/poller/slack-socket-mode.test.mjs +753 -0
  623. package/scripts/poller/trigger.mjs +75 -0
  624. package/scripts/poller/utils.mjs +371 -0
  625. package/scripts/poller/voice-cloud-relay-client.mjs +179 -0
  626. package/scripts/poller/voice-poller.mjs +236 -0
  627. package/scripts/poller-launchd/install.sh +66 -0
  628. package/scripts/poller-launchd/poller.plist.template +40 -0
  629. package/scripts/poller-launchd/whatsapp-handler.plist.template +39 -0
  630. package/scripts/post-interaction-indexer.py +1598 -0
  631. package/scripts/pre-draft-context.py +994 -0
  632. package/scripts/pre_draft_lookup.py +258 -0
  633. package/scripts/rag/build-index.mjs +47 -0
  634. package/scripts/rag/ingest.mjs +111 -0
  635. package/scripts/rag/search.mjs +119 -0
  636. package/scripts/rag-indexer.py +629 -0
  637. package/scripts/restore-from-backup.sh +248 -0
  638. package/scripts/restore-from-backup.test.mjs +178 -0
  639. package/scripts/resume-operations.sh +80 -0
  640. package/scripts/search-secondary-inbox.py +181 -0
  641. package/scripts/secondary-inbox-poller.py +437 -0
  642. package/scripts/self-optimization/compute-metrics.py +398 -0
  643. package/scripts/send-email-as-principal.py +369 -0
  644. package/scripts/send-email-threaded.py +392 -0
  645. package/scripts/send-email-with-attachment.py +377 -0
  646. package/scripts/send-email.sh +131 -0
  647. package/scripts/send-sms.sh +175 -0
  648. package/scripts/send-whatsapp.sh +292 -0
  649. package/scripts/session-start.sh +106 -0
  650. package/scripts/setup/boot-claude-session.sh +94 -0
  651. package/scripts/setup/configure-macos.sh +674 -0
  652. package/scripts/setup/configure-twilio-sip-trunk.mjs +207 -0
  653. package/scripts/setup/configure-voice-tunnel.mjs +182 -0
  654. package/scripts/setup/generate-agent-env.mjs +92 -0
  655. package/scripts/setup/generate-agent-package-json.mjs +222 -0
  656. package/scripts/setup/generate-agent-package-json.test.mjs +143 -0
  657. package/scripts/setup/generate-autonomy.mjs +60 -0
  658. package/scripts/setup/generate-backlog.mjs +101 -0
  659. package/scripts/setup/generate-cadences.mjs +92 -0
  660. package/scripts/setup/generate-capability.mjs +162 -0
  661. package/scripts/setup/generate-charter.mjs +76 -0
  662. package/scripts/setup/generate-comms.mjs +59 -0
  663. package/scripts/setup/generate-company.mjs +92 -0
  664. package/scripts/setup/init-agent.sh +547 -0
  665. package/scripts/setup/init-agent.test.mjs +151 -0
  666. package/scripts/setup/init-archetype.mjs +74 -0
  667. package/scripts/setup/init-backup.mjs +54 -0
  668. package/scripts/setup/init-cadence-bus.mjs +60 -0
  669. package/scripts/setup/init-channel-bus.mjs +46 -0
  670. package/scripts/setup/init-cost-tracking.mjs +45 -0
  671. package/scripts/setup/init-decision-capture.mjs +66 -0
  672. package/scripts/setup/init-known-agents.mjs +57 -0
  673. package/scripts/setup/init-learning.mjs +70 -0
  674. package/scripts/setup/init-memory-executive.mjs +45 -0
  675. package/scripts/setup/init-model-router.mjs +124 -0
  676. package/scripts/setup/init-rag.mjs +174 -0
  677. package/scripts/setup/init-session-router.mjs +38 -0
  678. package/scripts/setup/init-slack-socket-mode.mjs +260 -0
  679. package/scripts/setup/init-telegram.mjs +165 -0
  680. package/scripts/setup/init-voice-realtime.mjs +204 -0
  681. package/scripts/setup/init-whatsapp-baileys.mjs +77 -0
  682. package/scripts/setup/install-dev-tools.sh +150 -0
  683. package/scripts/setup/lib/install-plist.mjs +95 -0
  684. package/scripts/setup/migrate-agent-to-sot.mjs +192 -0
  685. package/scripts/setup/render-environment-yaml.mjs +133 -0
  686. package/scripts/slack-events-ctl.sh +177 -0
  687. package/scripts/slack-events-server.mjs +1045 -0
  688. package/scripts/slack-react.mjs +89 -0
  689. package/scripts/slack-responded.sh +232 -0
  690. package/scripts/slack-send.sh +287 -0
  691. package/scripts/slack-typing.mjs +196 -0
  692. package/scripts/slack-upload-v2.py +95 -0
  693. package/scripts/sms-handler.mjs +450 -0
  694. package/scripts/spawn-session.sh +120 -0
  695. package/scripts/sync-protocol.mjs +217 -0
  696. package/scripts/system-verify.sh +184 -0
  697. package/scripts/test-email-thread-dedup.py +239 -0
  698. package/scripts/test-information-barriers.py +484 -0
  699. package/scripts/test-llm-email-dedup.py +251 -0
  700. package/scripts/test-pre-draft-integration.py +203 -0
  701. package/scripts/test-rag-phase2.sh +442 -0
  702. package/scripts/test-rag-search.sh +251 -0
  703. package/scripts/test-voice-parser.mjs +316 -0
  704. package/scripts/user-context-search.py +659 -0
  705. package/scripts/validate_outbound.py +1504 -0
  706. package/scripts/watchdog/ai.maestro.memory-watchdog.plist +41 -0
  707. package/scripts/watchdog/force-reboot.sh +157 -0
  708. package/scripts/watchdog/memory-watchdog.sh +473 -0
  709. package/scripts/whatsapp-handler.mjs +538 -0
  710. package/teams/desktop-operations.yaml +34 -0
  711. package/teams/executive-office.yaml +27 -0
  712. package/teams/legal-and-regulatory.yaml +24 -0
  713. package/teams/platform-and-engineering.yaml +23 -0
  714. package/teams/strategy-and-growth.yaml +29 -0
  715. package/workflows/continuous/backlog-executor.yaml +141 -0
  716. package/workflows/continuous/inbound-monitor.yaml +168 -0
  717. package/workflows/daily/applicant-triage.yaml +197 -0
  718. package/workflows/daily/comms-triage.yaml +80 -0
  719. package/workflows/daily/evening-wrap.yaml +105 -0
  720. package/workflows/daily/morning-brief.yaml +164 -0
  721. package/workflows/daily/slack-followup-sweep.yaml +87 -0
  722. package/workflows/event-driven/README.md +50 -0
  723. package/workflows/event-driven/agent-failure-investigation.yaml +137 -0
  724. package/workflows/event-driven/pr-review.yaml +107 -0
  725. package/workflows/monthly/board-readiness.yaml +76 -0
  726. package/workflows/quarterly/strategic-scenario-analysis.yaml +85 -0
  727. package/workflows/session-protocol.md +171 -0
  728. package/workflows/weekly/engineering-health.yaml +154 -0
  729. package/workflows/weekly/hiring-review.yaml +169 -0
  730. package/workflows/weekly/rollup-pipeline-review.yaml +76 -0
  731. package/workflows/weekly/strategic-memo.yaml +79 -0
@@ -0,0 +1,318 @@
1
+ /**
2
+ * alerts.test.mjs — coverage for lib/diagnostics/alerts.mjs.
3
+ *
4
+ * Each behavioral rule must FIRE at/above its threshold and stay QUIET below it,
5
+ * emitAlerts must post every alert to an injected sink (no live call), and both
6
+ * entry points must never throw on garbage input.
7
+ */
8
+
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+
12
+ import {
13
+ evaluateAlerts,
14
+ emitAlerts,
15
+ loadThresholds,
16
+ mergeThresholds,
17
+ DEFAULT_THRESHOLDS,
18
+ _internals,
19
+ } from "./alerts.mjs";
20
+
21
+ const ids = (alerts) => alerts.map((a) => a.id).sort();
22
+ const byId = (alerts, id) => alerts.find((a) => a.id === id);
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // refusal spike
26
+ // ---------------------------------------------------------------------------
27
+
28
+ test("refusal_spike: quiet below warn, warns at warn, critical at crit", () => {
29
+ // Defaults: warn 10, crit 25, across counters refusal/policy.refusal/classifier.refusal.
30
+ assert.equal(byId(evaluateAlerts({ snapshot: { refusal: 9 } }), "refusal_spike"), undefined);
31
+
32
+ const warn = byId(evaluateAlerts({ snapshot: { refusal: 10 } }), "refusal_spike");
33
+ assert.ok(warn, "fires at warn threshold");
34
+ assert.equal(warn.severity, "warning");
35
+
36
+ // Summed across the configured counters.
37
+ const summed = byId(
38
+ evaluateAlerts({ snapshot: { refusal: 12, "policy.refusal": 8, "classifier.refusal": 6 } }),
39
+ "refusal_spike"
40
+ );
41
+ assert.ok(summed);
42
+ assert.equal(summed.severity, "critical"); // 26 >= 25
43
+ assert.match(summed.detail, /26 refusals/);
44
+ });
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // guard fail-open spike (named + suffix match)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ test("guard_fail_spike: suffix-matched counters are summed and banded", () => {
51
+ // Below warn (5).
52
+ assert.equal(
53
+ byId(evaluateAlerts({ snapshot: { "rate_guard.fail_open": 4 } }), "guard_fail_spike"),
54
+ undefined
55
+ );
56
+
57
+ // At warn: 5 across suffix matches.
58
+ const warn = byId(
59
+ evaluateAlerts({ snapshot: { "rate_guard.fail_open": 3, "budget_guard.persist_failed": 2 } }),
60
+ "guard_fail_spike"
61
+ );
62
+ assert.ok(warn);
63
+ assert.equal(warn.severity, "warning");
64
+
65
+ // At crit: 20.
66
+ const crit = byId(
67
+ evaluateAlerts({ snapshot: { "x.fail_open": 10, "y.degraded": 10 } }),
68
+ "guard_fail_spike"
69
+ );
70
+ assert.ok(crit);
71
+ assert.equal(crit.severity, "critical");
72
+
73
+ // A non-matching counter must NOT contribute.
74
+ assert.equal(
75
+ byId(evaluateAlerts({ snapshot: { "some.unrelated_counter": 99 } }), "guard_fail_spike"),
76
+ undefined
77
+ );
78
+ });
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // cost per session p99
82
+ // ---------------------------------------------------------------------------
83
+
84
+ test("cost_per_session_p99: uses p99 of the distribution when present", () => {
85
+ // 10 cheap sessions, one $4 outlier — p99 (nearest-rank) picks the outlier.
86
+ const perSessionUSD = [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 4.0];
87
+ const a = byId(
88
+ evaluateAlerts({ ledger: { sessions: 10, spentUSD: 4.9, perSessionUSD } }),
89
+ "cost_per_session_p99"
90
+ );
91
+ assert.ok(a, "fires when p99 exceeds crit");
92
+ assert.equal(a.severity, "critical"); // 4.0 >= crit 3.0
93
+ assert.match(a.detail, /p99/);
94
+ });
95
+
96
+ test("cost_per_session_p99: falls back to mean when no distribution", () => {
97
+ // mean = 10/5 = 2.0 → warning (>=1.5, <3.0).
98
+ const a = byId(
99
+ evaluateAlerts({ ledger: { sessions: 5, spentUSD: 10 } }),
100
+ "cost_per_session_p99"
101
+ );
102
+ assert.ok(a);
103
+ assert.equal(a.severity, "warning");
104
+ assert.match(a.detail, /mean/);
105
+ });
106
+
107
+ test("cost_per_session_p99: stays quiet below the warn threshold", () => {
108
+ // mean = 5/5 = 1.0 < warn 1.5.
109
+ assert.equal(
110
+ byId(evaluateAlerts({ ledger: { sessions: 5, spentUSD: 5 } }), "cost_per_session_p99"),
111
+ undefined
112
+ );
113
+ });
114
+
115
+ test("cost_per_session_p99: does not fire on a tiny sample (minSessions)", () => {
116
+ // Only 4 sessions (< minSessions 5) even though per-session spend is huge.
117
+ assert.equal(
118
+ byId(evaluateAlerts({ ledger: { sessions: 4, spentUSD: 40 } }), "cost_per_session_p99"),
119
+ undefined
120
+ );
121
+ });
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // absent agent
125
+ // ---------------------------------------------------------------------------
126
+
127
+ test("absent_agent: quiet below warn, warns then crits on heartbeat age", () => {
128
+ // Defaults: warn 600s, crit 1800s.
129
+ assert.equal(
130
+ byId(evaluateAlerts({ presence: { heartbeatAgeSec: 300 } }), "absent_agent"),
131
+ undefined
132
+ );
133
+
134
+ const warn = byId(evaluateAlerts({ presence: { heartbeatAgeSec: 600 } }), "absent_agent");
135
+ assert.ok(warn);
136
+ assert.equal(warn.severity, "warning");
137
+
138
+ const crit = byId(evaluateAlerts({ presence: { heartbeatAgeSec: 2000 } }), "absent_agent");
139
+ assert.ok(crit);
140
+ assert.equal(crit.severity, "critical");
141
+
142
+ // No heartbeat age supplied → absent_agent does NOT fire (daemon_down covers it).
143
+ assert.equal(byId(evaluateAlerts({ presence: {} }), "absent_agent"), undefined);
144
+ });
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // daemon down
148
+ // ---------------------------------------------------------------------------
149
+
150
+ test("daemon_down: explicit flag is critical; stale heartbeat is banded", () => {
151
+ const flagged = byId(evaluateAlerts({ presence: { daemonDown: true } }), "daemon_down");
152
+ assert.ok(flagged);
153
+ assert.equal(flagged.severity, "critical");
154
+
155
+ // Defaults: warn 120s, crit 300s.
156
+ assert.equal(
157
+ byId(evaluateAlerts({ presence: { daemonHealthAgeSec: 60 } }), "daemon_down"),
158
+ undefined
159
+ );
160
+ const warn = byId(evaluateAlerts({ presence: { daemonHealthAgeSec: 150 } }), "daemon_down");
161
+ assert.ok(warn);
162
+ assert.equal(warn.severity, "warning");
163
+ const crit = byId(evaluateAlerts({ presence: { daemonHealthAgeSec: 600 } }), "daemon_down");
164
+ assert.ok(crit);
165
+ assert.equal(crit.severity, "critical");
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // determinism, ordering, custom thresholds
170
+ // ---------------------------------------------------------------------------
171
+
172
+ test("evaluateAlerts: deterministic, critical-first ordering", () => {
173
+ const args = {
174
+ snapshot: { refusal: 30 }, // critical refusal_spike
175
+ presence: { heartbeatAgeSec: 700 }, // warning absent_agent
176
+ };
177
+ const a = evaluateAlerts(args);
178
+ assert.deepEqual(ids(a), ["absent_agent", "refusal_spike"]);
179
+ // Critical sorts before warning.
180
+ assert.equal(a[0].severity, "critical");
181
+ assert.equal(a[0].id, "refusal_spike");
182
+ // Repeated evaluation is identical.
183
+ assert.deepEqual(evaluateAlerts(args), a);
184
+ });
185
+
186
+ test("evaluateAlerts: custom thresholds override defaults", () => {
187
+ // Lower the refusal warn to 2 so 3 refusals fires.
188
+ const a = byId(
189
+ evaluateAlerts({ snapshot: { refusal: 3 }, thresholds: { refusalSpike: { warnCount: 2, critCount: 100 } } }),
190
+ "refusal_spike"
191
+ );
192
+ assert.ok(a);
193
+ assert.equal(a.severity, "warning");
194
+ });
195
+
196
+ test("evaluateAlerts: clean inputs produce no alerts", () => {
197
+ const a = evaluateAlerts({
198
+ snapshot: { some_ok_counter: 3 },
199
+ ledger: { sessions: 100, spentUSD: 10 }, // mean 0.1 — fine
200
+ presence: { heartbeatAgeSec: 5, daemonHealthAgeSec: 5 },
201
+ });
202
+ assert.deepEqual(a, []);
203
+ });
204
+
205
+ test("evaluateAlerts: never throws on garbage input", () => {
206
+ assert.doesNotThrow(() => evaluateAlerts());
207
+ assert.doesNotThrow(() => evaluateAlerts(null));
208
+ assert.doesNotThrow(() => evaluateAlerts({ snapshot: 42, ledger: "x", presence: [] }));
209
+ assert.deepEqual(evaluateAlerts({ snapshot: 42, ledger: "x", presence: [] }), []);
210
+ });
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // emitAlerts — injected sink, never live
214
+ // ---------------------------------------------------------------------------
215
+
216
+ test("emitAlerts: posts every alert to the injected sink", async () => {
217
+ const posted = [];
218
+ const sink = (alert) => {
219
+ posted.push(alert);
220
+ return true;
221
+ };
222
+ const alerts = [
223
+ { id: "a", severity: "warning", detail: "x" },
224
+ { id: "b", severity: "critical", detail: "y" },
225
+ ];
226
+ const summary = await emitAlerts(alerts, { sink });
227
+ assert.equal(summary.attempted, 2);
228
+ assert.equal(summary.delivered, 2);
229
+ assert.equal(summary.failed, 0);
230
+ assert.deepEqual(posted.map((a) => a.id), ["a", "b"]);
231
+ });
232
+
233
+ test("emitAlerts: async sink is awaited; undefined return counts as delivered", async () => {
234
+ const seen = [];
235
+ const sink = async (alert) => {
236
+ await Promise.resolve();
237
+ seen.push(alert.id);
238
+ // returns undefined
239
+ };
240
+ const summary = await emitAlerts([{ id: "z", severity: "warning", detail: "d" }], { sink });
241
+ assert.equal(summary.delivered, 1);
242
+ assert.deepEqual(seen, ["z"]);
243
+ });
244
+
245
+ test("emitAlerts: a throwing sink fails that alert but not the rest", async () => {
246
+ const delivered = [];
247
+ const sink = (alert) => {
248
+ if (alert.id === "boom") throw new Error("sink down");
249
+ delivered.push(alert.id);
250
+ return true;
251
+ };
252
+ const summary = await emitAlerts(
253
+ [
254
+ { id: "ok1", severity: "warning", detail: "" },
255
+ { id: "boom", severity: "critical", detail: "" },
256
+ { id: "ok2", severity: "warning", detail: "" },
257
+ ],
258
+ { sink }
259
+ );
260
+ assert.equal(summary.attempted, 3);
261
+ assert.equal(summary.delivered, 2);
262
+ assert.equal(summary.failed, 1);
263
+ assert.deepEqual(delivered, ["ok1", "ok2"]);
264
+ assert.deepEqual(summary.results.find((r) => r.id === "boom"), { id: "boom", ok: false });
265
+ });
266
+
267
+ test("emitAlerts: a sink returning false is a failed delivery", async () => {
268
+ const summary = await emitAlerts([{ id: "a", severity: "warning", detail: "" }], {
269
+ sink: () => false,
270
+ });
271
+ assert.equal(summary.delivered, 0);
272
+ assert.equal(summary.failed, 1);
273
+ });
274
+
275
+ test("emitAlerts: no sink wired counts as failed (raised but went nowhere)", async () => {
276
+ const summary = await emitAlerts([{ id: "a", severity: "warning", detail: "" }], {});
277
+ assert.equal(summary.attempted, 1);
278
+ assert.equal(summary.delivered, 0);
279
+ assert.equal(summary.failed, 1);
280
+ });
281
+
282
+ test("emitAlerts: never throws on garbage", async () => {
283
+ await assert.doesNotReject(() => emitAlerts());
284
+ await assert.doesNotReject(() => emitAlerts(null, null));
285
+ const s = await emitAlerts("not-an-array", { sink: () => true });
286
+ assert.equal(s.attempted, 0);
287
+ });
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // thresholds config
291
+ // ---------------------------------------------------------------------------
292
+
293
+ test("loadThresholds: returns defaults when no config file present", async () => {
294
+ const t = await loadThresholds({ configPath: "/nonexistent/alerts.yaml" });
295
+ assert.equal(t.refusalSpike.warnCount, DEFAULT_THRESHOLDS.refusalSpike.warnCount);
296
+ assert.equal(t.daemonDown.critSec, DEFAULT_THRESHOLDS.daemonDown.critSec);
297
+ });
298
+
299
+ test("mergeThresholds: deep-merges override over defaults without mutating defaults", () => {
300
+ const merged = mergeThresholds(DEFAULT_THRESHOLDS, { refusalSpike: { warnCount: 1 } });
301
+ assert.equal(merged.refusalSpike.warnCount, 1);
302
+ // Sibling keys preserved.
303
+ assert.equal(merged.refusalSpike.critCount, DEFAULT_THRESHOLDS.refusalSpike.critCount);
304
+ // Defaults untouched (frozen + cloned).
305
+ assert.equal(DEFAULT_THRESHOLDS.refusalSpike.warnCount, 10);
306
+ });
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // internals
310
+ // ---------------------------------------------------------------------------
311
+
312
+ test("p99: nearest-rank, tolerant of junk, null on empty", () => {
313
+ assert.equal(_internals.p99([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]), 10);
314
+ assert.equal(_internals.p99([5]), 5);
315
+ assert.equal(_internals.p99([]), null);
316
+ assert.equal(_internals.p99("nope"), null);
317
+ assert.equal(_internals.p99([1, "x", 3, null, 2]), 3); // junk dropped → [1,2,3]
318
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * lib/diagnostics/backup-freshness.mjs — is the off-machine backup real and current?
3
+ *
4
+ * Machine-loss DR (gaps-enterprise-ops G16) starts with one honest question the
5
+ * operator can never answer from casual inspection: *does this agent have a
6
+ * backup, and how old is it?* An agent with no backup configured looks identical
7
+ * to a healthy one until the disk dies. `backupFreshness` collapses that question
8
+ * to a single, fail-open verdict that both `maestro doctor` and the alerting path
9
+ * (lib/diagnostics/alerts.mjs) consume — so a missing/stale backup is loud, not
10
+ * silent.
11
+ *
12
+ * Sources of truth (matching scripts/maintenance/backup-to-cloud.sh +
13
+ * scripts/setup/init-backup.mjs):
14
+ * - `.maestro/backup-config.yaml` — is backup configured + `enabled: true`?
15
+ * - `.maestro/last-backup.json` — the marker the backup driver stamps on a
16
+ * successful run: `{ completed_at, ... }`.
17
+ *
18
+ * "Configured" means the config file exists AND `enabled: true` — an agent whose
19
+ * config sits at the scaffold default (`enabled: false`) is reported
20
+ * `configured:false` so doctor flags it, NOT silently green. "Stale" means the
21
+ * last successful backup is older than `staleHours` (default 48h — two missed
22
+ * daily runs), or there is no marker at all when backup is configured.
23
+ *
24
+ * Invariant: NEVER throws. A missing file, an unreadable marker, a malformed
25
+ * timestamp all degrade to a conservative verdict (`stale:true` when configured,
26
+ * `configured:false` when we can't even read the config) — diagnostics must not
27
+ * be able to crash the path that observes them, and a DR check that throws is
28
+ * worse than one that says "I can't tell, assume bad."
29
+ *
30
+ * Constraints (CLAUDE.md): ESM, Node built-ins only, injectable clock + paths so
31
+ * tests are hermetic and need no real `.maestro/` on disk.
32
+ *
33
+ * @module lib/diagnostics/backup-freshness
34
+ */
35
+
36
+ import { existsSync, readFileSync } from "node:fs";
37
+ import { join, resolve } from "node:path";
38
+
39
+ /** Two missed daily runs before we call the backup stale. */
40
+ export const DEFAULT_STALE_HOURS = 48;
41
+
42
+ const MS_PER_HOUR = 3_600_000;
43
+
44
+ /**
45
+ * Resolve the agent root the same way every other maestro module does, so the
46
+ * `.maestro/` markers are found wherever the agent actually lives.
47
+ * @param {object} [cfg]
48
+ * @returns {string}
49
+ */
50
+ function agentRoot(cfg = {}) {
51
+ return resolve(
52
+ cfg.agentRoot ||
53
+ process.env.AGENT_ROOT ||
54
+ process.env.AGENT_DIR ||
55
+ process.cwd()
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Path to `.maestro/backup-config.yaml` (explicit override wins for tests).
61
+ * @param {object} cfg
62
+ * @returns {string}
63
+ */
64
+ function configPath(cfg) {
65
+ if (cfg.configPath) return resolve(cfg.configPath);
66
+ return join(agentRoot(cfg), ".maestro", "backup-config.yaml");
67
+ }
68
+
69
+ /**
70
+ * Path to `.maestro/last-backup.json` (explicit override wins for tests).
71
+ * @param {object} cfg
72
+ * @returns {string}
73
+ */
74
+ function markerPath(cfg) {
75
+ if (cfg.markerPath) return resolve(cfg.markerPath);
76
+ return join(agentRoot(cfg), ".maestro", "last-backup.json");
77
+ }
78
+
79
+ /**
80
+ * Is backup configured AND enabled? Reads `enabled:` from the YAML with a
81
+ * targeted regex rather than pulling in a parser — the same zero-dep, never-throw
82
+ * idiom lib/budget-guard.mjs uses for recovery.yaml. A missing file, an
83
+ * unreadable file, or `enabled: false` all → false.
84
+ * @param {object} cfg
85
+ * @returns {boolean}
86
+ */
87
+ function isConfigured(cfg) {
88
+ const p = configPath(cfg);
89
+ let body;
90
+ try {
91
+ if (!existsSync(p)) return false;
92
+ body = readFileSync(p, "utf-8");
93
+ } catch {
94
+ return false;
95
+ }
96
+ if (typeof body !== "string") return false;
97
+ const m = body.match(/^\s*enabled\s*:\s*(true|false)\b/im);
98
+ return !!m && m[1].toLowerCase() === "true";
99
+ }
100
+
101
+ /**
102
+ * Read the last-backup marker's `completed_at` as epoch ms, or null if the
103
+ * marker is absent, unreadable, or carries no parseable timestamp.
104
+ * @param {object} cfg
105
+ * @returns {{ lastBackupAt: string|null, ms: number|null }}
106
+ */
107
+ function readMarker(cfg) {
108
+ const p = markerPath(cfg);
109
+ let raw;
110
+ try {
111
+ if (!existsSync(p)) return { lastBackupAt: null, ms: null };
112
+ raw = readFileSync(p, "utf-8");
113
+ } catch {
114
+ return { lastBackupAt: null, ms: null };
115
+ }
116
+ let obj;
117
+ try {
118
+ obj = JSON.parse(raw);
119
+ } catch {
120
+ return { lastBackupAt: null, ms: null };
121
+ }
122
+ if (!obj || typeof obj !== "object") return { lastBackupAt: null, ms: null };
123
+ const at = obj.completed_at;
124
+ if (typeof at !== "string") return { lastBackupAt: null, ms: null };
125
+ const ms = Date.parse(at);
126
+ if (!Number.isFinite(ms)) return { lastBackupAt: at, ms: null };
127
+ return { lastBackupAt: at, ms };
128
+ }
129
+
130
+ /**
131
+ * Report whether the off-machine backup is configured and current.
132
+ *
133
+ * @param {object} [cfg]
134
+ * @param {string} [cfg.agentRoot] agent root (derives the `.maestro/` paths)
135
+ * @param {string} [cfg.configPath] explicit backup-config.yaml path (tests)
136
+ * @param {string} [cfg.markerPath] explicit last-backup.json path (tests)
137
+ * @param {number} [cfg.staleHours] age beyond which a backup is stale (48h)
138
+ * @param {number} [cfg.now] clock in epoch ms (default Date.now())
139
+ * @returns {{
140
+ * configured: boolean,
141
+ * lastBackupAt: string|null,
142
+ * ageHours: number|null,
143
+ * stale: boolean
144
+ * }}
145
+ */
146
+ export function backupFreshness(cfg = {}) {
147
+ try {
148
+ const now = Number.isFinite(cfg.now) ? cfg.now : Date.now();
149
+ const staleHours =
150
+ Number.isFinite(cfg.staleHours) && cfg.staleHours > 0
151
+ ? cfg.staleHours
152
+ : DEFAULT_STALE_HOURS;
153
+
154
+ const configured = isConfigured(cfg);
155
+ const { lastBackupAt, ms } = readMarker(cfg);
156
+
157
+ // Age is only meaningful when we have a parseable marker timestamp.
158
+ const ageHours =
159
+ ms == null ? null : round2((now - ms) / MS_PER_HOUR);
160
+
161
+ // Stale logic:
162
+ // - not configured → not "stale" (it's unconfigured; a different, louder
163
+ // problem doctor reports separately) but ageHours is whatever we found.
164
+ // - configured but no usable marker → stale (a backup that never ran, or
165
+ // whose marker we can't read, is not a current backup).
166
+ // - configured with a marker → stale iff older than the window. A
167
+ // future-dated marker (clock skew) is treated as fresh, not stale.
168
+ let stale;
169
+ if (!configured) {
170
+ stale = false;
171
+ } else if (ageHours == null) {
172
+ stale = true;
173
+ } else {
174
+ stale = ageHours > staleHours;
175
+ }
176
+
177
+ return { configured, lastBackupAt, ageHours, stale };
178
+ } catch {
179
+ // Never throw: degrade to the conservative "I can't tell, assume bad" shape.
180
+ return { configured: false, lastBackupAt: null, ageHours: null, stale: true };
181
+ }
182
+ }
183
+
184
+ function round2(n) {
185
+ return Math.round(n * 100) / 100;
186
+ }
187
+
188
+ export default { backupFreshness, DEFAULT_STALE_HOURS };
@@ -0,0 +1,185 @@
1
+ /**
2
+ * backup-freshness.test.mjs — coverage for lib/diagnostics/backup-freshness.mjs.
3
+ *
4
+ * Hermetic: every case builds a tmp `.maestro/` fixture (config + marker) and
5
+ * injects `now`, so no real backup machinery runs. Verifies the configured /
6
+ * stale / ageHours verdict for the cases doctor + alerting depend on.
7
+ */
8
+
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import { backupFreshness, DEFAULT_STALE_HOURS } from "./backup-freshness.mjs";
16
+
17
+ const HOUR = 3_600_000;
18
+
19
+ /** Build a tmp agent root with optional backup-config.yaml + last-backup.json. */
20
+ function makeAgent({ configBody, marker } = {}) {
21
+ const root = mkdtempSync(join(tmpdir(), "bf-test-"));
22
+ mkdirSync(join(root, ".maestro"), { recursive: true });
23
+ if (configBody !== undefined) {
24
+ writeFileSync(join(root, ".maestro", "backup-config.yaml"), configBody);
25
+ }
26
+ if (marker !== undefined) {
27
+ writeFileSync(
28
+ join(root, ".maestro", "last-backup.json"),
29
+ typeof marker === "string" ? marker : JSON.stringify(marker)
30
+ );
31
+ }
32
+ return root;
33
+ }
34
+
35
+ function cleanup(root) {
36
+ rmSync(root, { recursive: true, force: true });
37
+ }
38
+
39
+ test("unconfigured (no files) → configured:false, not stale, no age", () => {
40
+ const root = makeAgent();
41
+ try {
42
+ const r = backupFreshness({ agentRoot: root, now: Date.now() });
43
+ assert.equal(r.configured, false);
44
+ assert.equal(r.stale, false);
45
+ assert.equal(r.lastBackupAt, null);
46
+ assert.equal(r.ageHours, null);
47
+ } finally {
48
+ cleanup(root);
49
+ }
50
+ });
51
+
52
+ test("scaffold default (enabled: false) → configured:false", () => {
53
+ const root = makeAgent({ configBody: "enabled: false\nprovider: gcs\n" });
54
+ try {
55
+ const r = backupFreshness({ agentRoot: root });
56
+ assert.equal(r.configured, false);
57
+ } finally {
58
+ cleanup(root);
59
+ }
60
+ });
61
+
62
+ test("enabled: true with a fresh marker → configured, not stale, age computed", () => {
63
+ const now = Date.parse("2026-06-11T12:00:00Z");
64
+ const completed_at = new Date(now - 6 * HOUR).toISOString(); // 6h ago
65
+ const root = makeAgent({
66
+ configBody: "enabled: true\nprovider: gcs\nbucket: x\nprefix: y\n",
67
+ marker: { completed_at, provider: "gcs", size_bytes: 1234 },
68
+ });
69
+ try {
70
+ const r = backupFreshness({ agentRoot: root, now });
71
+ assert.equal(r.configured, true);
72
+ assert.equal(r.stale, false);
73
+ assert.equal(r.lastBackupAt, completed_at);
74
+ assert.equal(r.ageHours, 6);
75
+ } finally {
76
+ cleanup(root);
77
+ }
78
+ });
79
+
80
+ test("enabled with a marker older than the window → stale", () => {
81
+ const now = Date.parse("2026-06-11T12:00:00Z");
82
+ const completed_at = new Date(now - 72 * HOUR).toISOString(); // 72h ago > 48h default
83
+ const root = makeAgent({
84
+ configBody: "enabled: true\n",
85
+ marker: { completed_at },
86
+ });
87
+ try {
88
+ const r = backupFreshness({ agentRoot: root, now });
89
+ assert.equal(r.configured, true);
90
+ assert.equal(r.ageHours, 72);
91
+ assert.equal(r.stale, true);
92
+ } finally {
93
+ cleanup(root);
94
+ }
95
+ });
96
+
97
+ test("stale boundary: exactly at staleHours is fresh; just over is stale", () => {
98
+ const now = Date.parse("2026-06-11T12:00:00Z");
99
+ const atBoundary = new Date(now - DEFAULT_STALE_HOURS * HOUR).toISOString();
100
+ const overBoundary = new Date(now - (DEFAULT_STALE_HOURS * HOUR + 60_000)).toISOString();
101
+ const rootA = makeAgent({ configBody: "enabled: true\n", marker: { completed_at: atBoundary } });
102
+ const rootB = makeAgent({ configBody: "enabled: true\n", marker: { completed_at: overBoundary } });
103
+ try {
104
+ assert.equal(backupFreshness({ agentRoot: rootA, now }).stale, false, "exactly at window is fresh");
105
+ assert.equal(backupFreshness({ agentRoot: rootB, now }).stale, true, "just over window is stale");
106
+ } finally {
107
+ cleanup(rootA);
108
+ cleanup(rootB);
109
+ }
110
+ });
111
+
112
+ test("custom staleHours is honoured", () => {
113
+ const now = Date.parse("2026-06-11T12:00:00Z");
114
+ const completed_at = new Date(now - 10 * HOUR).toISOString();
115
+ const root = makeAgent({ configBody: "enabled: true\n", marker: { completed_at } });
116
+ try {
117
+ // 10h old: stale under an 8h window, fresh under the 48h default.
118
+ assert.equal(backupFreshness({ agentRoot: root, now, staleHours: 8 }).stale, true);
119
+ assert.equal(backupFreshness({ agentRoot: root, now }).stale, false);
120
+ } finally {
121
+ cleanup(root);
122
+ }
123
+ });
124
+
125
+ test("configured but NO marker → stale (a backup that never ran)", () => {
126
+ const root = makeAgent({ configBody: "enabled: true\n" });
127
+ try {
128
+ const r = backupFreshness({ agentRoot: root, now: Date.now() });
129
+ assert.equal(r.configured, true);
130
+ assert.equal(r.lastBackupAt, null);
131
+ assert.equal(r.ageHours, null);
132
+ assert.equal(r.stale, true);
133
+ } finally {
134
+ cleanup(root);
135
+ }
136
+ });
137
+
138
+ test("configured with a corrupt marker → stale, not a throw", () => {
139
+ const root = makeAgent({ configBody: "enabled: true\n", marker: "{not json" });
140
+ try {
141
+ const r = backupFreshness({ agentRoot: root, now: Date.now() });
142
+ assert.equal(r.configured, true);
143
+ assert.equal(r.lastBackupAt, null);
144
+ assert.equal(r.stale, true);
145
+ } finally {
146
+ cleanup(root);
147
+ }
148
+ });
149
+
150
+ test("future-dated marker (clock skew) is treated as fresh, not stale", () => {
151
+ const now = Date.parse("2026-06-11T12:00:00Z");
152
+ const completed_at = new Date(now + 2 * HOUR).toISOString(); // 2h in the future
153
+ const root = makeAgent({ configBody: "enabled: true\n", marker: { completed_at } });
154
+ try {
155
+ const r = backupFreshness({ agentRoot: root, now });
156
+ assert.equal(r.stale, false);
157
+ assert.ok(r.ageHours < 0, "negative age for a future marker");
158
+ } finally {
159
+ cleanup(root);
160
+ }
161
+ });
162
+
163
+ test("explicit configPath + markerPath overrides agentRoot", () => {
164
+ const now = Date.parse("2026-06-11T12:00:00Z");
165
+ const root = mkdtempSync(join(tmpdir(), "bf-explicit-"));
166
+ const cfg = join(root, "cfg.yaml");
167
+ const mk = join(root, "marker.json");
168
+ writeFileSync(cfg, "enabled: true\n");
169
+ writeFileSync(mk, JSON.stringify({ completed_at: new Date(now - HOUR).toISOString() }));
170
+ try {
171
+ const r = backupFreshness({ configPath: cfg, markerPath: mk, now });
172
+ assert.equal(r.configured, true);
173
+ assert.equal(r.ageHours, 1);
174
+ assert.equal(r.stale, false);
175
+ } finally {
176
+ cleanup(root);
177
+ }
178
+ });
179
+
180
+ test("never throws on a totally bogus cfg", () => {
181
+ assert.doesNotThrow(() => backupFreshness(null));
182
+ assert.doesNotThrow(() => backupFreshness({ agentRoot: 42, now: "x" }));
183
+ const r = backupFreshness({ configPath: 12345 });
184
+ assert.equal(typeof r.configured, "boolean");
185
+ });