@cohortapp/agent-sdk 2.11.15 → 2.13.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 (280) hide show
  1. package/.env.example +37 -22
  2. package/README.md +2 -0
  3. package/bin/maestro.mjs +117 -39
  4. package/bin/maestro.test.mjs +175 -5
  5. package/docs/guides/front-door-session.md +313 -0
  6. package/docs/guides/mac-mini.md +100 -28
  7. package/docs/guides/org-onboarding.md +1 -1
  8. package/docs/guides/setup-wizard.md +9 -5
  9. package/docs/runbooks/cohort-cutover.md +11 -1
  10. package/docs/runbooks/mac-mini-bootstrap.md +38 -63
  11. package/lib/cadence-bus-requeue.test.mjs +83 -0
  12. package/lib/cadence-bus.mjs +43 -7
  13. package/lib/channels/inbox-item.mjs +59 -2
  14. package/lib/cli/board.mjs +285 -0
  15. package/lib/cli/board.test.mjs +227 -0
  16. package/lib/cli/design.mjs +185 -0
  17. package/lib/cli/design.test.mjs +270 -0
  18. package/lib/cli/doctor-checks.mjs +441 -0
  19. package/lib/cli/doctor-checks.test.mjs +336 -0
  20. package/lib/cli/global-setup-extras.mjs +454 -0
  21. package/lib/cli/global-setup-extras.test.mjs +462 -0
  22. package/lib/cli/inbox.mjs +304 -0
  23. package/lib/cli/inbox.test.mjs +230 -0
  24. package/lib/cli/session-ack.mjs +63 -0
  25. package/lib/cli/session-ack.test.mjs +63 -0
  26. package/lib/cli/session.mjs +760 -0
  27. package/lib/cli/session.test.mjs +613 -0
  28. package/lib/collective/global-config.mjs +209 -6
  29. package/lib/collective/global-config.test.mjs +145 -0
  30. package/lib/collective/global-skills.mjs +145 -0
  31. package/lib/collective/global-skills.test.mjs +126 -0
  32. package/lib/collective/presence.mjs +4 -3
  33. package/lib/collective/vendor-skills.mjs +305 -0
  34. package/lib/collective/vendor-skills.test.mjs +306 -0
  35. package/lib/comms/send-gate.mjs +115 -0
  36. package/lib/comms/send-gate.test.mjs +113 -0
  37. package/lib/design/design-md.mjs +793 -0
  38. package/lib/design/design-md.test.mjs +318 -0
  39. package/lib/design/fixtures/DESIGN.golden.md +238 -0
  40. package/lib/design/fixtures/PRODUCT.golden.md +67 -0
  41. package/lib/design/fixtures/foundation.json +133 -0
  42. package/lib/design/refresh-gate.mjs +154 -0
  43. package/lib/design/refresh-gate.test.mjs +144 -0
  44. package/lib/design/write.mjs +275 -0
  45. package/lib/design/write.test.mjs +241 -0
  46. package/lib/feature-init.mjs +2 -2
  47. package/lib/mcp/server.test.mjs +9 -4
  48. package/lib/model-router/spawn.test.mjs +21 -0
  49. package/lib/org/board-mine-cache.mjs +99 -0
  50. package/lib/org/board-mine-cache.test.mjs +53 -0
  51. package/lib/org/board.mjs +11 -0
  52. package/lib/org/board.test.mjs +11 -1
  53. package/lib/org/client.mjs +36 -0
  54. package/lib/org/client.test.mjs +46 -0
  55. package/lib/org/inbound/directedness.mjs +18 -2
  56. package/lib/org/inbound/directedness.test.mjs +58 -0
  57. package/lib/org/inbound/index.mjs +8 -1
  58. package/lib/org/inbound/index.test.mjs +22 -0
  59. package/lib/org/mesh-directives.test.mjs +110 -0
  60. package/lib/org/mesh.mjs +61 -1
  61. package/lib/org/protocol.checksum +1 -1
  62. package/lib/org/protocol.mjs +52 -0
  63. package/lib/org/protocol.test.mjs +12 -1
  64. package/lib/org/registry.mjs +3 -2
  65. package/lib/org/tool-surface.mjs +120 -0
  66. package/lib/org/tool-surface.test.mjs +118 -5
  67. package/lib/prompts/parallelism.mjs +79 -0
  68. package/lib/prompts/parallelism.test.mjs +177 -0
  69. package/lib/security/external-content.mjs +1 -1
  70. package/lib/security/external-content.test.mjs +17 -0
  71. package/lib/session/config.mjs +137 -0
  72. package/lib/session/config.test.mjs +92 -0
  73. package/lib/session/feed-core.mjs +229 -0
  74. package/lib/session/feed-core.test.mjs +198 -0
  75. package/lib/session/first-run.mjs +126 -0
  76. package/lib/session/first-run.test.mjs +121 -0
  77. package/lib/session/frontdoor.mjs +266 -0
  78. package/lib/session/frontdoor.test.mjs +205 -0
  79. package/lib/session/handoffs.mjs +295 -0
  80. package/lib/session/handoffs.test.mjs +183 -0
  81. package/lib/session/identity.mjs +220 -0
  82. package/lib/session/identity.test.mjs +180 -0
  83. package/lib/session/inbox-claims.mjs +434 -0
  84. package/lib/session/inbox-claims.test.mjs +286 -0
  85. package/lib/session/launch-args.mjs +161 -0
  86. package/lib/session/launch-args.test.mjs +157 -0
  87. package/lib/session/liveness.mjs +174 -0
  88. package/lib/session/liveness.test.mjs +100 -0
  89. package/lib/session/status-summary.mjs +172 -0
  90. package/lib/session/status-summary.test.mjs +118 -0
  91. package/lib/session-permissions.mjs +39 -3
  92. package/lib/session-permissions.test.mjs +20 -0
  93. package/lib/setup/claude-probe.mjs +161 -24
  94. package/lib/setup/claude-probe.test.mjs +187 -0
  95. package/lib/setup/sections/learning.mjs +2 -1
  96. package/lib/setup/sections/model.mjs +104 -24
  97. package/lib/setup/sections/model.test.mjs +240 -0
  98. package/lib/setup/sections/org.mjs +27 -2
  99. package/lib/setup/sections/org.test.mjs +35 -2
  100. package/lib/setup/sections/verify.mjs +5 -0
  101. package/lib/setup/state.mjs +30 -10
  102. package/lib/setup/state.test.mjs +24 -1
  103. package/lib/singleton.js +11 -3
  104. package/lib/singleton.test.mjs +16 -0
  105. package/lib/subagents/lock.mjs +1 -1
  106. package/lib/telemetry/collect.mjs +270 -6
  107. package/lib/telemetry/collect.test.mjs +196 -1
  108. package/lib/upgrade/global-refresh.mjs +108 -0
  109. package/lib/upgrade/global-refresh.test.mjs +65 -0
  110. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  111. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  112. package/lib/upgrade/post-steps.mjs +151 -0
  113. package/lib/upgrade/post-steps.test.mjs +200 -0
  114. package/lib/upgrade/verify.mjs +215 -0
  115. package/lib/upgrade/verify.test.mjs +164 -0
  116. package/lib/voice/outbound.mjs +3 -2
  117. package/lib/voice/post-call-brief.mjs +2 -1
  118. package/lib/voice/session-rotation.mjs +6 -1
  119. package/lib/voice/session-rotation.test.mjs +114 -0
  120. package/package.json +3 -3
  121. package/plugins/maestro-skills/plugin.json +25 -1
  122. package/plugins/maestro-skills/skills/board-work.md +63 -0
  123. package/plugins/maestro-skills/skills/cohort-design.md +153 -0
  124. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  125. package/plugins/maestro-skills/skills/main-session.md +102 -0
  126. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  127. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  128. package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
  129. package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
  130. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
  131. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
  132. package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
  133. package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
  134. package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
  135. package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
  136. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
  137. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
  138. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
  139. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
  140. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
  141. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
  142. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
  143. package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
  144. package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
  145. package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
  146. package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
  147. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
  148. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
  149. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
  150. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  151. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
  152. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
  153. package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
  154. package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
  155. package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
  156. package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
  157. package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
  158. package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
  159. package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
  160. package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
  161. package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
  162. package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
  163. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
  164. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
  165. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
  166. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  167. package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
  168. package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
  169. package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
  170. package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
  171. package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
  172. package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
  173. package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
  174. package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
  175. package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
  176. package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
  177. package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
  178. package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
  179. package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
  180. package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
  181. package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
  182. package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
  183. package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
  184. package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
  185. package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
  186. package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
  187. package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
  188. package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
  189. package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
  190. package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
  191. package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
  192. package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
  193. package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
  194. package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
  195. package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
  196. package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
  197. package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
  198. package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
  199. package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
  200. package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
  201. package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
  202. package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
  203. package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
  204. package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
  205. package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
  206. package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
  207. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
  208. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
  209. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
  210. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
  211. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
  212. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
  213. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
  214. package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
  215. package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
  216. package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
  217. package/scaffold/CLAUDE.md +24 -0
  218. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  219. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  220. package/scripts/ci/check-skill-packs.mjs +388 -0
  221. package/scripts/ci/check-skill-packs.test.mjs +495 -0
  222. package/scripts/ci/check.mjs +6 -0
  223. package/scripts/collective/hook-runner.mjs +39 -4
  224. package/scripts/collective/hook-runner.test.mjs +85 -2
  225. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  226. package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
  227. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  228. package/scripts/daemon/agent-daemon.mjs +249 -10
  229. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  230. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  231. package/scripts/daemon/assurance.mjs +461 -37
  232. package/scripts/daemon/assurance.test.mjs +408 -43
  233. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +393 -0
  234. package/scripts/daemon/cadence-consumer.mjs +289 -89
  235. package/scripts/daemon/cadence-handlers.mjs +53 -0
  236. package/scripts/daemon/classifier.mjs +1 -1
  237. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  238. package/scripts/daemon/dispatcher.mjs +127 -19
  239. package/scripts/daemon/health.mjs +12 -1
  240. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  241. package/scripts/daemon/inbox-deferral.mjs +6 -0
  242. package/scripts/daemon/lib/self-echo.mjs +201 -0
  243. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  244. package/scripts/daemon/maestro-daemon.mjs +3 -0
  245. package/scripts/daemon/prompt-builder.mjs +19 -3
  246. package/scripts/daemon/responder.mjs +51 -40
  247. package/scripts/daemon/sdk-version.mjs +51 -0
  248. package/scripts/daemon/sdk-version.test.mjs +31 -0
  249. package/scripts/hooks/pre-send-audit.sh +97 -4
  250. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  251. package/scripts/local-triggers/autoupdate.sh +243 -19
  252. package/scripts/local-triggers/autoupdate.test.mjs +518 -0
  253. package/scripts/local-triggers/generate-plists.sh +24 -1
  254. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  255. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  256. package/scripts/org/send-orgmail.mjs +27 -3
  257. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  258. package/scripts/poller/slack-poller.mjs +13 -1
  259. package/scripts/poller/utils.mjs +46 -1
  260. package/scripts/poller-launchd/install.sh +19 -11
  261. package/scripts/poller-launchd/install.test.mjs +243 -0
  262. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  263. package/scripts/poller-launchd/migrate.sh +66 -0
  264. package/scripts/poller-launchd/poller.plist.template +4 -2
  265. package/scripts/session/feed.mjs +237 -0
  266. package/scripts/session/feed.test.mjs +196 -0
  267. package/scripts/session/supervisor-sh.test.mjs +218 -0
  268. package/scripts/session/supervisor.mjs +328 -0
  269. package/scripts/session/supervisor.sh +141 -0
  270. package/scripts/session/supervisor.test.mjs +482 -0
  271. package/scripts/setup/configure-macos.sh +250 -55
  272. package/scripts/setup/configure-macos.test.mjs +306 -0
  273. package/scripts/setup/init-agent.sh +112 -7
  274. package/scripts/setup/init-agent.test.mjs +220 -1
  275. package/scripts/vendor/skill-packs.mjs +354 -0
  276. package/scripts/vendor/sync-skill-packs.mjs +242 -0
  277. package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
  278. package/scripts/watchdog/memory-watchdog.sh +37 -1
  279. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  280. package/scripts/setup/boot-claude-session.sh +0 -94
@@ -0,0 +1,266 @@
1
+ /**
2
+ * lib/session/frontdoor.mjs — WHO answers an inbound event: the main session
3
+ * or the daemon's legacy `claude --print` lane (design §3.1, §3.3, §3.4).
4
+ *
5
+ * THE ONE RULE. When the seat's main session is the configured front door AND
6
+ * it is provably live (a fresh `state/session/heartbeat.json`), the daemon
7
+ * stops spawning for the surfaces the session owns: Cohort inbox items are
8
+ * left in place for the session to claim, and escalate/guarded cadence ticks
9
+ * are written as handoffs for the session to ack. When the session is NOT
10
+ * live — no heartbeat, a stale one, or the front door is `daemon` — the daemon
11
+ * dispatches exactly as it always has. Liveness is MEASURED, never assumed,
12
+ * and the fallback is the legacy lane, so nothing is ever dropped.
13
+ *
14
+ * PURE CORE. `sessionLiveFromHeartbeat`, `resolveFrontDoor`,
15
+ * `frontDoorServices`, `shouldDaemonDispatch` and `shouldHandOffTick` take
16
+ * every input as a parameter — the parsed heartbeat object, the clock, the
17
+ * mode, the tick's mode/metadata. `readFrontDoorState` is the ONE edge reader
18
+ * (config/session.yaml + state/session/heartbeat.json) and it takes its fs /
19
+ * env / clock injected so a call site can be tested without a disk.
20
+ *
21
+ * NOTE: `lib/session/{config,liveness}.mjs` (WP-M1) own the canonical session
22
+ * config + heartbeat readers once they land; the readers here are deliberately
23
+ * minimal and shaped to be swapped for those without touching the callers.
24
+ *
25
+ * @module lib/session/frontdoor
26
+ */
27
+
28
+ import { join } from "node:path";
29
+ import * as nodeFs from "node:fs";
30
+ import { createRequire } from "node:module";
31
+
32
+ // js-yaml is a declared dependency; loaded synchronously so the reader stays
33
+ // sync. Fail-open: without it the flat line parser below still reads the
34
+ // one-level form (`frontDoor: daemon`), and every nested form is a no-op.
35
+ let defaultYamlParse = null;
36
+ try {
37
+ const y = createRequire(import.meta.url)("js-yaml");
38
+ if (y && typeof y.load === "function") defaultYamlParse = (text) => y.load(text);
39
+ } catch { defaultYamlParse = null; }
40
+
41
+ /** A heartbeat older than this is stale — the session is treated as not live. */
42
+ export const DEFAULT_STALE_MS = 90_000;
43
+ /** Inbox services the session fronts by default. */
44
+ export const DEFAULT_FRONT_DOOR_SERVICES = Object.freeze(["cohort"]);
45
+ /** Cadence modes whose tick spawns a sub-session — the ones a live session takes over. */
46
+ export const HANDOFF_MODES = Object.freeze(["escalate", "guarded"]);
47
+ /** Where the session runtime writes its heartbeat (relative to the agent root). */
48
+ export const HEARTBEAT_RELATIVE = "state/session/heartbeat.json";
49
+ /** Session runtime config (relative to the agent root). */
50
+ export const SESSION_CONFIG_RELATIVE = "config/session.yaml";
51
+
52
+ const MODES = new Set(["session", "daemon"]);
53
+
54
+ /**
55
+ * Is the main session live, given its parsed heartbeat? Pure.
56
+ *
57
+ * @param {object|null} heartbeat parsed `state/session/heartbeat.json` ({ts,...}) or null
58
+ * @param {object} [o]
59
+ * @param {number} [o.now] epoch ms
60
+ * @param {number} [o.staleMs] freshness window (default DEFAULT_STALE_MS)
61
+ * @returns {{live:boolean, reason:string, ageMs:number|null}}
62
+ */
63
+ export function sessionLiveFromHeartbeat(heartbeat, o = {}) {
64
+ const now = typeof o.now === "number" ? o.now : Date.now();
65
+ const staleMs = Number.isFinite(o.staleMs) && o.staleMs > 0 ? o.staleMs : DEFAULT_STALE_MS;
66
+ if (!heartbeat || typeof heartbeat !== "object") return { live: false, reason: "no-heartbeat", ageMs: null };
67
+ const raw = heartbeat.ts;
68
+ const ts = typeof raw === "number" ? raw : (typeof raw === "string" ? Date.parse(raw) : NaN);
69
+ if (!Number.isFinite(ts)) return { live: false, reason: "bad-ts", ageMs: null };
70
+ const ageMs = now - ts;
71
+ // A slightly future stamp is writer/reader clock skew, not a dead session;
72
+ // a stamp further ahead than the window is a broken clock — not trusted.
73
+ if (ageMs < -staleMs) return { live: false, reason: "future-ts", ageMs };
74
+ if (ageMs > staleMs) return { live: false, reason: "stale", ageMs };
75
+ return { live: true, reason: "fresh", ageMs };
76
+ }
77
+
78
+ /**
79
+ * Which process is the front door. `MAESTRO_FRONT_DOOR` in the env wins, then
80
+ * `frontDoor` / `front_door` in config/session.yaml (a string, or an object
81
+ * with `.mode`), else `session`. An unknown value never selects a mode.
82
+ *
83
+ * @param {{env?:object, config?:object|null}} [o]
84
+ * @returns {"session"|"daemon"}
85
+ */
86
+ export function resolveFrontDoor(o = {}) {
87
+ const env = o.env && typeof o.env === "object" ? o.env : {};
88
+ const fromEnv = String(env.MAESTRO_FRONT_DOOR || "").trim().toLowerCase();
89
+ if (MODES.has(fromEnv)) return fromEnv;
90
+ const cfg = o.config && typeof o.config === "object" ? o.config : {};
91
+ const raw = cfg.frontDoor !== undefined ? cfg.frontDoor : cfg.front_door;
92
+ const val = raw && typeof raw === "object" ? raw.mode : raw;
93
+ const fromCfg = String(val || "").trim().toLowerCase();
94
+ if (MODES.has(fromCfg)) return fromCfg;
95
+ return "session";
96
+ }
97
+
98
+ /**
99
+ * The inbox services the session fronts. `frontDoor.services` or a top-level
100
+ * `services` list in config/session.yaml; default `["cohort"]`.
101
+ *
102
+ * @param {object|null} config
103
+ * @returns {string[]}
104
+ */
105
+ export function frontDoorServices(config) {
106
+ const cfg = config && typeof config === "object" ? config : {};
107
+ const fd = cfg.frontDoor && typeof cfg.frontDoor === "object" ? cfg.frontDoor : null;
108
+ const list = fd && Array.isArray(fd.services) ? fd.services : (Array.isArray(cfg.services) ? cfg.services : null);
109
+ if (!list) return [...DEFAULT_FRONT_DOOR_SERVICES];
110
+ const out = list.map((s) => String(s || "").trim().toLowerCase()).filter(Boolean);
111
+ return out.length ? out : [...DEFAULT_FRONT_DOOR_SERVICES];
112
+ }
113
+
114
+ /**
115
+ * Should the DAEMON dispatch an inbox item from `service`? Pure.
116
+ * `dispatch:false` means "leave the item in place — the live session owns it".
117
+ * Any malformed input resolves to dispatch (fail-open: never drop an item).
118
+ *
119
+ * @param {{frontDoor?:string, sessionLive?:boolean, service?:string, services?:string[]}} a
120
+ * @returns {{dispatch:boolean, reason:string}}
121
+ */
122
+ export function shouldDaemonDispatch(a) {
123
+ const x = a && typeof a === "object" ? a : {};
124
+ if (x.frontDoor !== "session") return { dispatch: true, reason: "front-door-daemon" };
125
+ if (x.sessionLive !== true) return { dispatch: true, reason: "session-not-live" };
126
+ const services = Array.isArray(x.services) && x.services.length ? x.services : DEFAULT_FRONT_DOOR_SERVICES;
127
+ const svc = String(x.service || "").trim().toLowerCase();
128
+ if (!svc || !services.includes(svc)) return { dispatch: true, reason: "service-not-front-door" };
129
+ return { dispatch: false, reason: "session-live" };
130
+ }
131
+
132
+ /**
133
+ * Should the cadence consumer HAND a tick to the live session instead of
134
+ * spawning a sub-session? Pure. Only escalate/guarded ticks (the ones that
135
+ * spawn) qualify; a tick whose previous handoff timed out is never handed off
136
+ * again (`metadata.handoffTimedOut`), so a wedged session cannot starve a
137
+ * cadence. An absent mode means "unknown cadence" — the consumer treats those
138
+ * as escalate, so they qualify too.
139
+ *
140
+ * @param {{frontDoor?:string, sessionLive?:boolean, mode?:string, metadata?:object}} a
141
+ * @returns {{handOff:boolean, reason:string}}
142
+ */
143
+ export function shouldHandOffTick(a) {
144
+ const x = a && typeof a === "object" ? a : {};
145
+ if (x.frontDoor !== "session") return { handOff: false, reason: "front-door-daemon" };
146
+ if (x.sessionLive !== true) return { handOff: false, reason: "session-not-live" };
147
+ const mode = x.mode == null ? "escalate" : String(x.mode);
148
+ if (!HANDOFF_MODES.includes(mode)) return { handOff: false, reason: "mode-not-handoff" };
149
+ if (x.metadata && typeof x.metadata === "object" && x.metadata.handoffTimedOut === true) {
150
+ return { handOff: false, reason: "handoff-timed-out" };
151
+ }
152
+ return { handOff: true, reason: "hand-off" };
153
+ }
154
+
155
+ /**
156
+ * YAML reader for config/session.yaml: the injected parser, else js-yaml
157
+ * (the documented nested `frontDoor: {mode, services}` shape needs a real
158
+ * parser), else a flat `key: value` / `key: [a, b]` line parser as the last
159
+ * resort. Never throws — returns null on any failure.
160
+ */
161
+ function parseSessionYaml(text, yamlParse) {
162
+ const parse = typeof yamlParse === "function" ? yamlParse : defaultYamlParse;
163
+ if (parse) {
164
+ try { const v = parse(text); return v && typeof v === "object" ? v : null; } catch { return null; }
165
+ }
166
+ const out = {};
167
+ try {
168
+ for (const line of String(text).split(/\r?\n/)) {
169
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$/);
170
+ if (!m) continue;
171
+ const [, k, v] = m;
172
+ if (/^\[.*\]$/.test(v)) out[k] = v.slice(1, -1).split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
173
+ else if (v !== "") out[k] = v.replace(/^["']|["']$/g, "");
174
+ }
175
+ } catch { return null; }
176
+ return out;
177
+ }
178
+
179
+ /**
180
+ * THE EDGE READER. Reads config/session.yaml and state/session/heartbeat.json
181
+ * under `agentRoot` and folds them into the decision inputs. Every failure is
182
+ * fail-open: an unreadable config keeps the env/default mode, an unreadable or
183
+ * corrupt heartbeat means "not live" — i.e. the legacy daemon lane runs.
184
+ *
185
+ * @param {string} agentRoot
186
+ * @param {object} [deps]
187
+ * @param {object} [deps.fs] `{existsSync, readFileSync}` (default node:fs)
188
+ * @param {object} [deps.env] (default process.env)
189
+ * @param {number} [deps.now] epoch ms
190
+ * @param {number} [deps.staleMs]
191
+ * @param {Function} [deps.yamlParse] optional YAML parser (js-yaml `load`)
192
+ * @returns {{frontDoor:"session"|"daemon", sessionLive:boolean, services:string[], liveness:object, heartbeat:object|null}}
193
+ */
194
+ export function readFrontDoorState(agentRoot, deps = {}) {
195
+ const fs = deps.fs || nodeFs;
196
+ const env = deps.env || process.env;
197
+ const now = typeof deps.now === "number" ? deps.now : Date.now();
198
+ let config = null;
199
+ try {
200
+ const p = join(agentRoot, SESSION_CONFIG_RELATIVE);
201
+ if (fs.existsSync(p)) config = parseSessionYaml(fs.readFileSync(p, "utf-8"), deps.yamlParse);
202
+ } catch { config = null; /* unreadable config → env/default mode (fail-open) */ }
203
+ let heartbeat = null;
204
+ try {
205
+ const p = join(agentRoot, HEARTBEAT_RELATIVE);
206
+ if (fs.existsSync(p)) {
207
+ const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
208
+ heartbeat = parsed && typeof parsed === "object" ? parsed : null;
209
+ }
210
+ } catch { heartbeat = null; /* corrupt heartbeat → not live → legacy lane (fail-open) */ }
211
+ const liveness = sessionLiveFromHeartbeat(heartbeat, { now, staleMs: deps.staleMs });
212
+ return {
213
+ frontDoor: resolveFrontDoor({ env, config }),
214
+ sessionLive: liveness.live,
215
+ services: frontDoorServices(config),
216
+ liveness,
217
+ heartbeat,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * A small stateful gate for a poll loop: `check(service)` re-reads the front
223
+ * door state and returns the dispatch verdict plus `changed` — true only when
224
+ * the verdict for that service differs from the last one, so a 2-second poll
225
+ * can log transitions without logging every tick. A throwing reader fails
226
+ * open to dispatch.
227
+ *
228
+ * @param {{agentRoot:string, readState?:Function, now?:Function}} o
229
+ * @returns {{check:(service:string)=>{dispatch:boolean, reason:string, changed:boolean, state:object|null}}}
230
+ */
231
+ export function makeFrontDoorGate(o = {}) {
232
+ const readState = typeof o.readState === "function" ? o.readState : readFrontDoorState;
233
+ const nowFn = typeof o.now === "function" ? o.now : Date.now;
234
+ // Last verdict key per service — the only state, and only for log hygiene.
235
+ const last = new Map();
236
+ return {
237
+ check(service) {
238
+ let state = null;
239
+ let verdict;
240
+ try {
241
+ state = readState(o.agentRoot, { now: nowFn() });
242
+ verdict = shouldDaemonDispatch({ ...state, service });
243
+ } catch {
244
+ // A broken reader must never stall intake: dispatch on the legacy lane.
245
+ verdict = { dispatch: true, reason: "reader-error" };
246
+ }
247
+ const key = `${verdict.dispatch}:${verdict.reason}`;
248
+ const changed = last.get(service) !== key;
249
+ last.set(service, key);
250
+ return { ...verdict, changed, state };
251
+ },
252
+ };
253
+ }
254
+
255
+ export default {
256
+ DEFAULT_STALE_MS,
257
+ DEFAULT_FRONT_DOOR_SERVICES,
258
+ HANDOFF_MODES,
259
+ sessionLiveFromHeartbeat,
260
+ resolveFrontDoor,
261
+ frontDoorServices,
262
+ shouldDaemonDispatch,
263
+ shouldHandOffTick,
264
+ readFrontDoorState,
265
+ makeFrontDoorGate,
266
+ };
@@ -0,0 +1,205 @@
1
+ /**
2
+ * frontdoor.test.mjs — the pure front-door decision core (design §3.1, §3.4).
3
+ *
4
+ * Every function here is a pure decision over injected inputs: the parsed
5
+ * heartbeat, a clock, the front-door mode and the tick shape. No disk, no
6
+ * env reads inside the core — `readFrontDoorState` is the one edge reader and
7
+ * it takes its fs/env/clock as parameters so the tests are hermetic.
8
+ */
9
+
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+
13
+ import {
14
+ DEFAULT_STALE_MS,
15
+ DEFAULT_FRONT_DOOR_SERVICES,
16
+ sessionLiveFromHeartbeat,
17
+ resolveFrontDoor,
18
+ frontDoorServices,
19
+ shouldDaemonDispatch,
20
+ shouldHandOffTick,
21
+ readFrontDoorState,
22
+ makeFrontDoorGate,
23
+ } from "./frontdoor.mjs";
24
+
25
+ const T0 = Date.parse("2026-09-08T10:00:00.000Z");
26
+
27
+ // ── sessionLiveFromHeartbeat ─────────────────────────────────────────────────
28
+
29
+ test("liveness: a fresh heartbeat is live; a stale one is not; none is not", () => {
30
+ assert.deepEqual(
31
+ sessionLiveFromHeartbeat({ pid: 1, ts: new Date(T0 - 10_000).toISOString() }, { now: T0 }),
32
+ { live: true, reason: "fresh", ageMs: 10_000 },
33
+ );
34
+ const stale = sessionLiveFromHeartbeat({ ts: new Date(T0 - DEFAULT_STALE_MS - 1).toISOString() }, { now: T0 });
35
+ assert.equal(stale.live, false);
36
+ assert.equal(stale.reason, "stale");
37
+ assert.equal(sessionLiveFromHeartbeat(null, { now: T0 }).live, false);
38
+ assert.equal(sessionLiveFromHeartbeat(null, { now: T0 }).reason, "no-heartbeat");
39
+ assert.equal(sessionLiveFromHeartbeat({ ts: "garbage" }, { now: T0 }).reason, "bad-ts");
40
+ });
41
+
42
+ test("liveness: accepts epoch-ms ts, honours a custom staleMs, tolerates slight clock skew", () => {
43
+ assert.equal(sessionLiveFromHeartbeat({ ts: T0 - 5_000 }, { now: T0 }).live, true);
44
+ assert.equal(sessionLiveFromHeartbeat({ ts: T0 - 5_000 }, { now: T0, staleMs: 4_000 }).live, false);
45
+ // A heartbeat a second "in the future" (writer/reader clock skew) is fresh.
46
+ assert.equal(sessionLiveFromHeartbeat({ ts: T0 + 1_000 }, { now: T0 }).live, true);
47
+ });
48
+
49
+ // ── resolveFrontDoor / frontDoorServices ─────────────────────────────────────
50
+
51
+ test("resolveFrontDoor: env wins over config; config string or object; default session", () => {
52
+ assert.equal(resolveFrontDoor(), "session");
53
+ assert.equal(resolveFrontDoor({ config: { frontDoor: "daemon" } }), "daemon");
54
+ assert.equal(resolveFrontDoor({ config: { front_door: "daemon" } }), "daemon");
55
+ assert.equal(resolveFrontDoor({ config: { frontDoor: { mode: "daemon" } } }), "daemon");
56
+ assert.equal(resolveFrontDoor({ env: { MAESTRO_FRONT_DOOR: "daemon" }, config: { frontDoor: "session" } }), "daemon");
57
+ // An unknown value never selects an unknown mode.
58
+ assert.equal(resolveFrontDoor({ env: { MAESTRO_FRONT_DOOR: "banana" } }), "session");
59
+ assert.equal(resolveFrontDoor({ config: { frontDoor: "banana" } }), "session");
60
+ });
61
+
62
+ test("frontDoorServices: default cohort; config may widen; junk is ignored", () => {
63
+ assert.deepEqual(frontDoorServices(null), [...DEFAULT_FRONT_DOOR_SERVICES]);
64
+ assert.deepEqual(frontDoorServices({ frontDoor: { services: ["cohort", "slack"] } }), ["cohort", "slack"]);
65
+ assert.deepEqual(frontDoorServices({ services: ["orgmail"] }), ["orgmail"]);
66
+ assert.deepEqual(frontDoorServices({ services: "nope" }), [...DEFAULT_FRONT_DOOR_SERVICES]);
67
+ });
68
+
69
+ // ── shouldDaemonDispatch ─────────────────────────────────────────────────────
70
+
71
+ test("shouldDaemonDispatch: the daemon steps back only when the session is the live front door for that service", () => {
72
+ assert.deepEqual(
73
+ shouldDaemonDispatch({ frontDoor: "session", sessionLive: true, service: "cohort" }),
74
+ { dispatch: false, reason: "session-live" },
75
+ );
76
+ assert.deepEqual(
77
+ shouldDaemonDispatch({ frontDoor: "session", sessionLive: false, service: "cohort" }),
78
+ { dispatch: true, reason: "session-not-live" },
79
+ );
80
+ assert.deepEqual(
81
+ shouldDaemonDispatch({ frontDoor: "daemon", sessionLive: true, service: "cohort" }),
82
+ { dispatch: true, reason: "front-door-daemon" },
83
+ );
84
+ assert.deepEqual(
85
+ shouldDaemonDispatch({ frontDoor: "session", sessionLive: true, service: "slack" }),
86
+ { dispatch: true, reason: "service-not-front-door" },
87
+ );
88
+ assert.deepEqual(
89
+ shouldDaemonDispatch({ frontDoor: "session", sessionLive: true, service: "slack", services: ["cohort", "slack"] }),
90
+ { dispatch: false, reason: "session-live" },
91
+ );
92
+ // Garbage in → legacy dispatch (fail-open: nothing is ever dropped).
93
+ assert.equal(shouldDaemonDispatch({}).dispatch, true);
94
+ assert.equal(shouldDaemonDispatch(null).dispatch, true);
95
+ });
96
+
97
+ // ── shouldHandOffTick ────────────────────────────────────────────────────────
98
+
99
+ test("shouldHandOffTick: escalate/guarded ticks hand off to a live session; inline never; timed-out never", () => {
100
+ const live = { frontDoor: "session", sessionLive: true };
101
+ assert.deepEqual(shouldHandOffTick({ ...live, mode: "escalate" }), { handOff: true, reason: "hand-off" });
102
+ assert.deepEqual(shouldHandOffTick({ ...live, mode: "guarded" }), { handOff: true, reason: "hand-off" });
103
+ assert.deepEqual(shouldHandOffTick({ ...live, mode: "inline" }), { handOff: false, reason: "mode-not-handoff" });
104
+ // An unknown cadence (no registry entry) defaults to escalate in the consumer.
105
+ assert.deepEqual(shouldHandOffTick({ ...live, mode: undefined }), { handOff: true, reason: "hand-off" });
106
+ assert.deepEqual(
107
+ shouldHandOffTick({ ...live, mode: "escalate", metadata: { handoffTimedOut: true } }),
108
+ { handOff: false, reason: "handoff-timed-out" },
109
+ );
110
+ assert.deepEqual(
111
+ shouldHandOffTick({ frontDoor: "session", sessionLive: false, mode: "escalate" }),
112
+ { handOff: false, reason: "session-not-live" },
113
+ );
114
+ assert.deepEqual(
115
+ shouldHandOffTick({ frontDoor: "daemon", sessionLive: true, mode: "escalate" }),
116
+ { handOff: false, reason: "front-door-daemon" },
117
+ );
118
+ assert.equal(shouldHandOffTick(null).handOff, false);
119
+ });
120
+
121
+ // ── readFrontDoorState (edge reader, injected fs/env/clock) ──────────────────
122
+
123
+ function fakeFs(files) {
124
+ return {
125
+ existsSync: (p) => Object.prototype.hasOwnProperty.call(files, p),
126
+ readFileSync: (p) => {
127
+ if (!Object.prototype.hasOwnProperty.call(files, p)) { const e = new Error("ENOENT"); e.code = "ENOENT"; throw e; }
128
+ return files[p];
129
+ },
130
+ };
131
+ }
132
+
133
+ test("readFrontDoorState: reads config/session.yaml + state/session/heartbeat.json through injected fs", () => {
134
+ const root = "/agent";
135
+ const fs = fakeFs({
136
+ "/agent/config/session.yaml": "frontDoor: session\nservices: [cohort, slack]\n",
137
+ "/agent/state/session/heartbeat.json": JSON.stringify({ pid: 4, ts: new Date(T0 - 1000).toISOString() }),
138
+ });
139
+ const st = readFrontDoorState(root, { fs, env: {}, now: T0 });
140
+ assert.equal(st.frontDoor, "session");
141
+ assert.equal(st.sessionLive, true);
142
+ assert.deepEqual(st.services, ["cohort", "slack"]);
143
+ assert.equal(st.liveness.reason, "fresh");
144
+ });
145
+
146
+ test("readFrontDoorState: no config + no heartbeat → session mode, not live (legacy dispatch); env override honoured", () => {
147
+ const st = readFrontDoorState("/agent", { fs: fakeFs({}), env: {}, now: T0 });
148
+ assert.equal(st.frontDoor, "session");
149
+ assert.equal(st.sessionLive, false);
150
+ assert.deepEqual(st.services, ["cohort"]);
151
+ const st2 = readFrontDoorState("/agent", { fs: fakeFs({}), env: { MAESTRO_FRONT_DOOR: "daemon" }, now: T0 });
152
+ assert.equal(st2.frontDoor, "daemon");
153
+ });
154
+
155
+ test("readFrontDoorState: a corrupt heartbeat or config never throws — fails open to not-live", () => {
156
+ const fs = fakeFs({
157
+ "/agent/config/session.yaml": ": : not yaml [[[",
158
+ "/agent/state/session/heartbeat.json": "{not json",
159
+ });
160
+ const st = readFrontDoorState("/agent", { fs, env: {}, now: T0 });
161
+ assert.equal(st.sessionLive, false);
162
+ assert.equal(st.frontDoor, "session");
163
+ });
164
+
165
+ // ── makeFrontDoorGate ────────────────────────────────────────────────────────
166
+
167
+ test("makeFrontDoorGate: check() reports the decision and flags transitions only once", () => {
168
+ let live = true;
169
+ const gate = makeFrontDoorGate({
170
+ agentRoot: "/agent",
171
+ readState: () => ({ frontDoor: "session", sessionLive: live, services: ["cohort"] }),
172
+ });
173
+ const a = gate.check("cohort");
174
+ assert.equal(a.dispatch, false);
175
+ assert.equal(a.changed, true, "first observation is a transition");
176
+ const b = gate.check("cohort");
177
+ assert.equal(b.changed, false, "same verdict again is not a transition");
178
+ live = false;
179
+ const c = gate.check("cohort");
180
+ assert.equal(c.dispatch, true);
181
+ assert.equal(c.changed, true, "liveness flipped → transition");
182
+ // A reader that throws fails open to dispatch.
183
+ const broken = makeFrontDoorGate({ agentRoot: "/agent", readState: () => { throw new Error("boom"); } });
184
+ assert.equal(broken.check("cohort").dispatch, true);
185
+ });
186
+
187
+ test("readFrontDoorState: the documented NESTED config (frontDoor.mode / frontDoor.services) and the flow form are read without an injected parser", () => {
188
+ const nested = readFrontDoorState("/agent", {
189
+ fs: fakeFs({ "/agent/config/session.yaml": "frontDoor:\n mode: daemon\n services: [cohort, slack]\n" }),
190
+ env: {}, now: T0,
191
+ });
192
+ assert.equal(nested.frontDoor, "daemon", "an operator opting a seat out via the nested form must be honoured");
193
+ assert.deepEqual(nested.services, ["cohort", "slack"]);
194
+ const flow = readFrontDoorState("/agent", {
195
+ fs: fakeFs({ "/agent/config/session.yaml": "frontDoor: {mode: daemon}\n" }),
196
+ env: {}, now: T0,
197
+ });
198
+ assert.equal(flow.frontDoor, "daemon");
199
+ const block = readFrontDoorState("/agent", {
200
+ fs: fakeFs({ "/agent/config/session.yaml": "# session runtime\nfrontDoor:\n mode: session\n services:\n - cohort\n - orgmail\n" }),
201
+ env: {}, now: T0,
202
+ });
203
+ assert.equal(block.frontDoor, "session");
204
+ assert.deepEqual(block.services, ["cohort", "orgmail"]);
205
+ });