@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,198 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ PREVIEW_MAX,
6
+ emptySeen,
7
+ parseSeen,
8
+ isLiveInboxFile,
9
+ isHandoffFile,
10
+ inboundEvent,
11
+ handoffEvent,
12
+ computeFeedEvents,
13
+ } from "./feed-core.mjs";
14
+ import { inboxItemToYaml } from "../../scripts/poller/utils.mjs";
15
+
16
+ const T0 = Date.parse("2026-09-08T10:00:00Z");
17
+
18
+ function cohortItem(over = {}) {
19
+ return inboxItemToYaml({
20
+ id: "msg-1",
21
+ service: "cohort",
22
+ channel: "dm/casey",
23
+ channel_id: "ch_abc",
24
+ sender: "Casey Ng",
25
+ sender_privilege: "ceo",
26
+ timestamp: "2026-09-08T09:59:00.000Z",
27
+ subject: "Cohort message",
28
+ content: "Can you pull the Q3 numbers? my key is sk-ant-api03-" + "a".repeat(40),
29
+ thread_id: "th_1",
30
+ kind: "mention",
31
+ direct_reason: "mention",
32
+ channel_kind: "DM",
33
+ raw_ref: "cohort:ch_abc:msg-1",
34
+ ...over,
35
+ });
36
+ }
37
+
38
+ test("emptySeen / parseSeen: versioned, fail-open", () => {
39
+ assert.deepEqual(emptySeen(), { version: 1, seq: 0, inbox: {}, handoffs: {}, signals: {} });
40
+ assert.deepEqual(parseSeen("{"), emptySeen());
41
+ assert.deepEqual(parseSeen(JSON.stringify({ version: 99 })), emptySeen());
42
+ const s = { version: 1, seq: 7, inbox: { "cohort/a.yaml": T0 }, handoffs: {}, signals: { restart: 1 } };
43
+ assert.deepEqual(parseSeen(JSON.stringify(s)), s);
44
+ });
45
+
46
+ test("isLiveInboxFile / isHandoffFile", () => {
47
+ assert.equal(isLiveInboxFile("2026-09-08T09-59-00-000Z-msg-1.yaml"), true);
48
+ assert.equal(isLiveInboxFile("x.yaml.dispatched"), false);
49
+ assert.equal(isLiveInboxFile("x.yaml.processed"), false);
50
+ assert.equal(isLiveInboxFile("x.yaml.deferred"), false);
51
+ assert.equal(isLiveInboxFile(".hidden.yaml"), false);
52
+ assert.equal(isHandoffFile("tick-1.json"), true);
53
+ assert.equal(isHandoffFile("tick-1.json.tmp.1.2.ab"), false);
54
+ assert.equal(isHandoffFile("done"), false);
55
+ });
56
+
57
+ test("inboundEvent: the documented line, surface from kind, topic from the surface table, preview redacted + capped", () => {
58
+ const ev = inboundEvent({ service: "cohort", path: "/agent/state/inbox/cohort/a.yaml", body: cohortItem(), seq: 3 });
59
+ assert.equal(ev.type, "inbound");
60
+ assert.equal(ev.id, "msg-1");
61
+ assert.equal(ev.service, "cohort");
62
+ assert.equal(ev.surface, "mention");
63
+ assert.equal(ev.topic, "message");
64
+ assert.equal(ev.reason, "mention");
65
+ assert.equal(ev.from, "Casey Ng");
66
+ assert.equal(ev.channelId, "ch_abc");
67
+ assert.equal(ev.channelName, "dm/casey");
68
+ assert.equal(ev.threadId, "th_1");
69
+ assert.equal(ev.path, "/agent/state/inbox/cohort/a.yaml");
70
+ assert.equal(ev.seq, 3);
71
+ assert.match(ev.preview, /Q3 numbers/);
72
+ assert.doesNotMatch(ev.preview, /sk-ant-api03/);
73
+ assert.ok(ev.preview.length <= PREVIEW_MAX);
74
+ assert.deepEqual(Object.keys(ev), ["type", "id", "service", "surface", "topic", "reason", "from", "channelId", "channelName", "threadId", "preview", "path", "seq"]);
75
+ });
76
+
77
+ test("inboundEvent: a plain message maps to surface=message/topic=message; a task kind maps to topic=task; long previews are capped", () => {
78
+ const plain = inboundEvent({ service: "cohort", path: "/p", body: cohortItem({ kind: undefined, direct_reason: undefined, content: "x ".repeat(400) }), seq: 1 });
79
+ assert.equal(plain.surface, "message");
80
+ assert.equal(plain.topic, "message");
81
+ assert.equal(plain.reason, "");
82
+ assert.equal(plain.preview.length, PREVIEW_MAX);
83
+ const task = inboundEvent({ service: "cohort", path: "/p", body: cohortItem({ kind: "task_assigned", direct_reason: "assignee" }), seq: 2 });
84
+ assert.equal(task.topic, "task");
85
+ assert.equal(task.reason, "assignee");
86
+ });
87
+
88
+ test("inboundEvent: an unparseable body yields null (the file is skipped, not a crash)", () => {
89
+ assert.equal(inboundEvent({ service: "cohort", path: "/p", body: "", seq: 1 }), null);
90
+ assert.equal(inboundEvent({ service: "cohort", path: "/p", body: null, seq: 1 }), null);
91
+ });
92
+
93
+ test("handoffEvent: from the consumer's handoff JSON; id falls back to the file name", () => {
94
+ const body = JSON.stringify({ tickId: "t-9", cadence: "morning-brief", mode: "escalate", promptPath: "/agent/state/session/handoffs/t-9.prompt.md", enqueuedAt: "2026-09-08T10:00:00Z", deadlineAt: "x" });
95
+ const ev = handoffEvent({ path: "/agent/state/session/handoffs/t-9.json", file: "t-9.json", body, seq: 5 });
96
+ assert.deepEqual(ev, { type: "handoff", id: "t-9", cadence: "morning-brief", promptPath: "/agent/state/session/handoffs/t-9.prompt.md", enqueuedAt: "2026-09-08T10:00:00Z", path: "/agent/state/session/handoffs/t-9.json", seq: 5 });
97
+ const bare = handoffEvent({ path: "/h/tick-2.json", file: "tick-2.json", body: "{}", seq: 6 });
98
+ assert.equal(bare.id, "tick-2");
99
+ assert.equal(bare.cadence, "");
100
+ assert.equal(handoffEvent({ path: "/h/x.json", file: "x.json", body: "{", seq: 7 }), null);
101
+ });
102
+
103
+ function snapshot(over = {}) {
104
+ return {
105
+ inbox: { cohort: [] },
106
+ handoffs: [],
107
+ signals: { restartRequestedMtimeMs: null, upgradeNotice: null, daemonHealthMtimeMs: T0, daemonPidAlive: null },
108
+ ...over,
109
+ };
110
+ }
111
+
112
+ test("computeFeedEvents: new inbox items and handoffs are emitted once, in a stable order, with increasing seq", () => {
113
+ const snap = snapshot({
114
+ inbox: {
115
+ cohort: [
116
+ { file: "b.yaml", path: "/i/b.yaml", body: cohortItem({ id: "b" }) },
117
+ { file: "a.yaml", path: "/i/a.yaml", body: cohortItem({ id: "a" }) },
118
+ { file: "c.yaml.dispatched", path: "/i/c.yaml.dispatched", body: cohortItem({ id: "c" }) },
119
+ ],
120
+ },
121
+ handoffs: [{ file: "t.json", path: "/h/t.json", body: JSON.stringify({ tickId: "t", cadence: "c" }) }],
122
+ });
123
+ const r1 = computeFeedEvents(snap, emptySeen(), { now: T0 });
124
+ assert.deepEqual(r1.events.map((e) => [e.type, e.id, e.seq]), [["inbound", "a", 1], ["inbound", "b", 2], ["handoff", "t", 3]]);
125
+ assert.equal(r1.seen.seq, 3);
126
+ assert.deepEqual(Object.keys(r1.seen.inbox).sort(), ["cohort/a.yaml", "cohort/b.yaml"]);
127
+ assert.deepEqual(Object.keys(r1.seen.handoffs), ["t.json"]);
128
+ // Same snapshot again → nothing new.
129
+ const r2 = computeFeedEvents(snap, r1.seen, { now: T0 + 1_000 });
130
+ assert.deepEqual(r2.events, []);
131
+ assert.equal(r2.seen.seq, 3);
132
+ });
133
+
134
+ test("computeFeedEvents: a file that disappears leaves the seen-set; if it re-appears it is re-emitted (assurance re-open)", () => {
135
+ const withA = snapshot({ inbox: { cohort: [{ file: "a.yaml", path: "/i/a.yaml", body: cohortItem({ id: "a" }) }] } });
136
+ const r1 = computeFeedEvents(withA, emptySeen(), { now: T0 });
137
+ assert.equal(r1.events.length, 1);
138
+ const r2 = computeFeedEvents(snapshot(), r1.seen, { now: T0 + 1 });
139
+ assert.deepEqual(r2.seen.inbox, {});
140
+ const r3 = computeFeedEvents(withA, r2.seen, { now: T0 + 2 });
141
+ assert.equal(r3.events.length, 1);
142
+ assert.equal(r3.events[0].seq, 2);
143
+ });
144
+
145
+ test("computeFeedEvents: services not in the snapshot are simply absent; unparseable bodies are skipped and not marked seen", () => {
146
+ const snap = snapshot({ inbox: { cohort: [{ file: "bad.yaml", path: "/i/bad.yaml", body: "" }], slack: [{ file: "s.yaml", path: "/i/s.yaml", body: cohortItem({ id: "s", service: "slack" }) }] } });
147
+ const r = computeFeedEvents(snap, emptySeen(), { now: T0 });
148
+ assert.deepEqual(r.events.map((e) => e.id), ["s"]);
149
+ assert.equal(r.events[0].service, "slack");
150
+ assert.deepEqual(Object.keys(r.seen.inbox), ["slack/s.yaml"]);
151
+ });
152
+
153
+ test("computeFeedEvents: restart-requested → one directive per appearance (keyed by mtime); cleared when the file goes", () => {
154
+ const s1 = snapshot({ signals: { restartRequestedMtimeMs: T0, upgradeNotice: null, daemonHealthMtimeMs: T0, daemonPidAlive: null } });
155
+ const r1 = computeFeedEvents(s1, emptySeen(), { now: T0 });
156
+ assert.deepEqual(r1.events, [{ type: "directive", action: "restart", detail: { requestedAt: "2026-09-08T10:00:00.000Z" }, seq: 1 }]);
157
+ const r2 = computeFeedEvents(s1, r1.seen, { now: T0 + 1 });
158
+ assert.deepEqual(r2.events, []);
159
+ const r3 = computeFeedEvents(snapshot(), r2.seen, { now: T0 + 2 });
160
+ assert.deepEqual(r3.events, []);
161
+ assert.equal(r3.seen.signals.restart, undefined);
162
+ const s4 = snapshot({ signals: { restartRequestedMtimeMs: T0 + 5, upgradeNotice: null, daemonHealthMtimeMs: T0, daemonPidAlive: null } });
163
+ assert.equal(computeFeedEvents(s4, r3.seen, { now: T0 + 5 }).events.length, 1);
164
+ });
165
+
166
+ test("computeFeedEvents: upgrade-notice → directive carrying the parsed notice, once per mtime", () => {
167
+ const s = snapshot({ signals: { restartRequestedMtimeMs: null, upgradeNotice: { mtimeMs: T0, body: JSON.stringify({ from: "2.11.17", to: "2.12.0" }) }, daemonHealthMtimeMs: T0, daemonPidAlive: null } });
168
+ const r1 = computeFeedEvents(s, emptySeen(), { now: T0 });
169
+ assert.deepEqual(r1.events, [{ type: "directive", action: "upgrade-available", detail: { notice: { from: "2.11.17", to: "2.12.0" } }, seq: 1 }]);
170
+ assert.deepEqual(computeFeedEvents(s, r1.seen, { now: T0 + 1 }).events, []);
171
+ // Junk notice body → detail carries the raw text, still one directive.
172
+ const junk = snapshot({ signals: { restartRequestedMtimeMs: null, upgradeNotice: { mtimeMs: T0 + 9, body: "not json" }, daemonHealthMtimeMs: T0, daemonPidAlive: null } });
173
+ const rj = computeFeedEvents(junk, r1.seen, { now: T0 + 9 });
174
+ assert.equal(rj.events.length, 1);
175
+ assert.deepEqual(rj.events[0].detail, { notice: null, raw: "not json" });
176
+ });
177
+
178
+ test("computeFeedEvents: daemon-stale fires once on the transition to stale and re-arms when the daemon is fresh again", () => {
179
+ const stale = snapshot({ signals: { restartRequestedMtimeMs: null, upgradeNotice: null, daemonHealthMtimeMs: T0 - 10 * 60_000, daemonPidAlive: false } });
180
+ const r1 = computeFeedEvents(stale, emptySeen(), { now: T0 });
181
+ assert.equal(r1.events.length, 1);
182
+ assert.equal(r1.events[0].action, "daemon-stale");
183
+ assert.deepEqual(r1.events[0].detail, { healthAgeMs: 10 * 60_000, pidAlive: false });
184
+ assert.deepEqual(computeFeedEvents(stale, r1.seen, { now: T0 + 1 }).events, []);
185
+ const fresh = snapshot();
186
+ const r3 = computeFeedEvents(fresh, r1.seen, { now: T0 + 2 });
187
+ assert.deepEqual(r3.events, []);
188
+ assert.equal(r3.seen.signals.daemonStale, false);
189
+ assert.equal(computeFeedEvents(stale, r3.seen, { now: T0 + 3 }).events.length, 1);
190
+ });
191
+
192
+ test("computeFeedEvents: never mutates its inputs", () => {
193
+ const seen = emptySeen();
194
+ const frozen = JSON.stringify(seen);
195
+ const snap = snapshot({ inbox: { cohort: [{ file: "a.yaml", path: "/i/a.yaml", body: cohortItem({ id: "a" }) }] } });
196
+ computeFeedEvents(snap, seen, { now: T0 });
197
+ assert.equal(JSON.stringify(seen), frozen);
198
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * lib/session/first-run.mjs — the dialogs an unattended session cannot answer.
3
+ *
4
+ * The supervisor launches an INTERACTIVE `claude` inside a multiplexer with
5
+ * nobody attached. On a fresh seat that first launch shows two one-time
6
+ * dialogs and waits for a keypress that never comes:
7
+ *
8
+ * - the bypass-permissions acknowledgement (`--dangerously-skip-permissions`),
9
+ * recorded in `~/.claude.json` as `bypassPermissionsModeAccepted: true`;
10
+ * - the per-directory trust dialog, recorded as
11
+ * `projects[<cwd>].hasTrustDialogAccepted: true`.
12
+ *
13
+ * Neither is a secret and both are exactly what the operator answered when
14
+ * they set the seat up by hand, so the supervisor SEEDS them before launch:
15
+ * {@link seedClaudeConfig} is the pure merge (adds only the missing flags,
16
+ * preserves every other key, refuses to touch a malformed file), and
17
+ * {@link ensureClaudeConfig} is its I/O shell (writes only when something
18
+ * changed, so the one-time race with a `--print` session rewriting the same
19
+ * file is confined to the first boot of a seat).
20
+ *
21
+ * Belt and braces: a session that is alive but has never beaten after
22
+ * {@link HEARTBEAT_GRACE_MS} is exactly the shape of a blocked dialog (or a
23
+ * missing skill, or a wedged permission prompt). {@link heartbeatSilence}
24
+ * decides that from the heartbeat and an injected clock; the supervisor logs
25
+ * it and writes `state/session/attention.json` ({@link attentionRecord}) so
26
+ * `doctor` can report it and an operator knows which command attaches.
27
+ *
28
+ * Pure except the one I/O shell; no ambient clock, fs or home.
29
+ *
30
+ * @module lib/session/first-run
31
+ */
32
+
33
+ "use strict";
34
+
35
+ import { join } from "node:path";
36
+
37
+ /** Claude Code's user-level config file, relative to the home directory. */
38
+ export const CLAUDE_CONFIG_FILE = ".claude.json";
39
+ /** A session alive this long without a beat is flagged for attention. */
40
+ export const HEARTBEAT_GRACE_MS = 120_000;
41
+
42
+ /**
43
+ * Pure: merge the two acknowledgements into a `~/.claude.json` body.
44
+ *
45
+ * @param {string|null|undefined} text current file body (absent → empty)
46
+ * @param {{agentRoot:string, bypass?:boolean}} a `bypass` — seed the bypass-permissions flag (only needed when launching with --dangerously-skip-permissions)
47
+ * @returns {{ok:true, changed:boolean, applied:string[], config:object}|{ok:false, changed:false, applied:[], error:string}}
48
+ */
49
+ export function seedClaudeConfig(text, a) {
50
+ let raw = {};
51
+ if (typeof text === "string" && text.trim()) {
52
+ try { raw = JSON.parse(text); } catch { return { ok: false, changed: false, applied: [], error: "malformed JSON" }; }
53
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, changed: false, applied: [], error: "not an object" };
54
+ }
55
+ const applied = [];
56
+ const next = { ...raw };
57
+ if (a.bypass !== false && next.bypassPermissionsModeAccepted !== true) {
58
+ next.bypassPermissionsModeAccepted = true;
59
+ applied.push("bypassPermissionsModeAccepted");
60
+ }
61
+ const projects = next.projects && typeof next.projects === "object" && !Array.isArray(next.projects) ? next.projects : {};
62
+ const proj = projects[a.agentRoot] && typeof projects[a.agentRoot] === "object" && !Array.isArray(projects[a.agentRoot]) ? projects[a.agentRoot] : {};
63
+ if (proj.hasTrustDialogAccepted !== true) {
64
+ next.projects = { ...projects, [a.agentRoot]: { ...proj, hasTrustDialogAccepted: true } };
65
+ applied.push(`projects[${a.agentRoot}].hasTrustDialogAccepted`);
66
+ }
67
+ return { ok: true, changed: applied.length > 0, applied, config: next };
68
+ }
69
+
70
+ /**
71
+ * Read `~/.claude.json`, seed it, write it back only if something changed.
72
+ * Result frame; never throws (a seat must still launch when this fails —
73
+ * the dialog then blocks and {@link heartbeatSilence} reports it).
74
+ *
75
+ * @param {{homeDir:string, agentRoot:string, bypass?:boolean, readFileSync:Function, writeFileAtomic:Function}} a
76
+ * @returns {{ok:boolean, changed:boolean, applied:string[], path:string|null, error?:string}}
77
+ */
78
+ export function ensureClaudeConfig(a) {
79
+ if (!a.homeDir) return { ok: false, changed: false, applied: [], path: null, error: "no home directory" };
80
+ const path = join(a.homeDir, CLAUDE_CONFIG_FILE);
81
+ let text = null;
82
+ try { text = a.readFileSync(path, "utf8"); } catch (err) {
83
+ if (!err || err.code !== "ENOENT") return { ok: false, changed: false, applied: [], path, error: err && err.message ? err.message : String(err) };
84
+ text = null; // first launch on this account
85
+ }
86
+ const seeded = seedClaudeConfig(text, { agentRoot: a.agentRoot, bypass: a.bypass });
87
+ if (!seeded.ok) return { ok: false, changed: false, applied: [], path, error: seeded.error };
88
+ if (!seeded.changed) return { ok: true, changed: false, applied: [], path };
89
+ try {
90
+ a.writeFileAtomic(path, JSON.stringify(seeded.config, null, 2) + "\n");
91
+ } catch (err) {
92
+ return { ok: false, changed: false, applied: [], path, error: err && err.message ? err.message : String(err) };
93
+ }
94
+ return { ok: true, changed: true, applied: seeded.applied, path };
95
+ }
96
+
97
+ /**
98
+ * Pure: has the session run past the grace period without beating?
99
+ * @param {{startedAt:number, heartbeat:{ts:string}|null, now:number, graceMs?:number}} a
100
+ * @returns {{silent:boolean, reason:"within-grace"|"beating"|"no-heartbeat"|"stale-heartbeat", runMs:number}}
101
+ */
102
+ export function heartbeatSilence(a) {
103
+ const graceMs = a.graceMs ?? HEARTBEAT_GRACE_MS;
104
+ const runMs = Number(a.now) - Number(a.startedAt);
105
+ if (runMs < graceMs) return { silent: false, reason: "within-grace", runMs };
106
+ const ts = a.heartbeat && typeof a.heartbeat.ts === "string" ? Date.parse(a.heartbeat.ts) : NaN;
107
+ if (Number.isFinite(ts) && ts >= Number(a.startedAt)) return { silent: false, reason: "beating", runMs };
108
+ return { silent: true, reason: Number.isFinite(ts) ? "stale-heartbeat" : "no-heartbeat", runMs };
109
+ }
110
+
111
+ /**
112
+ * Pure: the `state/session/attention.json` body.
113
+ * @param {{reason:string, mux:{kind:string}, muxName:string, since:number, runMs:number}} a
114
+ */
115
+ export function attentionRecord(a) {
116
+ const attach = a.mux && a.mux.kind === "tmux" ? `tmux attach -t =${a.muxName}` : `screen -r ${a.muxName}`;
117
+ return {
118
+ reason: a.reason,
119
+ since: new Date(Number(a.since)).toISOString(),
120
+ runMs: a.runMs,
121
+ attach,
122
+ hint: `The session has been up ${Math.round(a.runMs / 1000)} s without a heartbeat — it is probably waiting on a first-run dialog or a permission prompt. Attach with \`${attach}\` and answer it.`,
123
+ };
124
+ }
125
+
126
+ export default { CLAUDE_CONFIG_FILE, HEARTBEAT_GRACE_MS, seedClaudeConfig, ensureClaudeConfig, heartbeatSilence, attentionRecord };
@@ -0,0 +1,121 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { join } from "node:path";
4
+
5
+ import {
6
+ CLAUDE_CONFIG_FILE,
7
+ HEARTBEAT_GRACE_MS,
8
+ seedClaudeConfig,
9
+ ensureClaudeConfig,
10
+ heartbeatSilence,
11
+ attentionRecord,
12
+ } from "./first-run.mjs";
13
+
14
+ const ROOT = "/Users/o/olivia-ai";
15
+ const T0 = Date.parse("2026-09-08T10:00:00Z");
16
+
17
+ test("seedClaudeConfig: an absent/empty ~/.claude.json gets both acknowledgements", () => {
18
+ for (const text of [undefined, null, "", " "]) {
19
+ const r = seedClaudeConfig(text, { agentRoot: ROOT, bypass: true });
20
+ assert.equal(r.ok, true);
21
+ assert.equal(r.changed, true);
22
+ assert.deepEqual(r.applied, ["bypassPermissionsModeAccepted", `projects[${ROOT}].hasTrustDialogAccepted`]);
23
+ assert.deepEqual(r.config, { bypassPermissionsModeAccepted: true, projects: { [ROOT]: { hasTrustDialogAccepted: true } } });
24
+ }
25
+ });
26
+
27
+ test("seedClaudeConfig: existing keys, other projects and project fields are preserved; only the missing flags are added", () => {
28
+ const existing = {
29
+ numStartups: 40,
30
+ theme: "dark",
31
+ projects: {
32
+ "/Users/o/other": { hasTrustDialogAccepted: false, allowedTools: ["Bash"] },
33
+ [ROOT]: { allowedTools: ["Read"], hasCompletedProjectOnboarding: true },
34
+ },
35
+ };
36
+ const r = seedClaudeConfig(JSON.stringify(existing), { agentRoot: ROOT, bypass: true });
37
+ assert.equal(r.changed, true);
38
+ assert.deepEqual(r.config, {
39
+ numStartups: 40,
40
+ theme: "dark",
41
+ bypassPermissionsModeAccepted: true,
42
+ projects: {
43
+ "/Users/o/other": { hasTrustDialogAccepted: false, allowedTools: ["Bash"] },
44
+ [ROOT]: { allowedTools: ["Read"], hasCompletedProjectOnboarding: true, hasTrustDialogAccepted: true },
45
+ },
46
+ });
47
+ });
48
+
49
+ test("seedClaudeConfig: already seeded → unchanged (idempotent); bypass:false leaves the bypass flag alone", () => {
50
+ const seeded = { bypassPermissionsModeAccepted: true, projects: { [ROOT]: { hasTrustDialogAccepted: true } } };
51
+ const r = seedClaudeConfig(JSON.stringify(seeded), { agentRoot: ROOT, bypass: true });
52
+ assert.equal(r.changed, false);
53
+ assert.deepEqual(r.applied, []);
54
+ const r2 = seedClaudeConfig("{}", { agentRoot: ROOT, bypass: false });
55
+ assert.deepEqual(r2.applied, [`projects[${ROOT}].hasTrustDialogAccepted`]);
56
+ assert.equal("bypassPermissionsModeAccepted" in r2.config, false);
57
+ });
58
+
59
+ test("seedClaudeConfig: a malformed or non-object file is NOT clobbered", () => {
60
+ for (const text of ["{", "[1,2]", "42", "null"]) {
61
+ const r = seedClaudeConfig(text, { agentRoot: ROOT, bypass: true });
62
+ assert.equal(r.ok, false, text);
63
+ assert.equal(r.changed, false);
64
+ assert.match(r.error, /malformed|not an object/);
65
+ }
66
+ });
67
+
68
+ test("ensureClaudeConfig: writes ~/.claude.json only when something changed; never throws", () => {
69
+ const home = "/Users/o";
70
+ const file = join(home, CLAUDE_CONFIG_FILE);
71
+ const writes = [];
72
+ const deps = (files) => ({
73
+ readFileSync: (p) => { if (!(p in files)) { const e = new Error("ENOENT"); e.code = "ENOENT"; throw e; } return files[p]; },
74
+ writeFileAtomic: (p, data) => { writes.push([p, String(data)]); },
75
+ });
76
+ // Absent → written.
77
+ let r = ensureClaudeConfig({ homeDir: home, agentRoot: ROOT, bypass: true, ...deps({}) });
78
+ assert.equal(r.ok, true);
79
+ assert.equal(r.changed, true);
80
+ assert.equal(r.path, file);
81
+ assert.equal(writes.length, 1);
82
+ assert.deepEqual(JSON.parse(writes[0][1]), { bypassPermissionsModeAccepted: true, projects: { [ROOT]: { hasTrustDialogAccepted: true } } });
83
+ // Already seeded → no write.
84
+ r = ensureClaudeConfig({ homeDir: home, agentRoot: ROOT, bypass: true, ...deps({ [file]: writes[0][1] }) });
85
+ assert.equal(r.changed, false);
86
+ assert.equal(writes.length, 1);
87
+ // Malformed → reported, untouched.
88
+ r = ensureClaudeConfig({ homeDir: home, agentRoot: ROOT, bypass: true, ...deps({ [file]: "{oops" }) });
89
+ assert.equal(r.ok, false);
90
+ assert.equal(writes.length, 1);
91
+ // A throwing writer is a result, not a throw.
92
+ r = ensureClaudeConfig({ homeDir: home, agentRoot: ROOT, bypass: true, readFileSync: deps({}).readFileSync, writeFileAtomic: () => { throw new Error("EROFS"); } });
93
+ assert.equal(r.ok, false);
94
+ assert.match(r.error, /EROFS/);
95
+ // No home → nothing to do.
96
+ r = ensureClaudeConfig({ homeDir: "", agentRoot: ROOT, bypass: true, ...deps({}) });
97
+ assert.equal(r.ok, false);
98
+ assert.equal(writes.length, 1);
99
+ });
100
+
101
+ test("heartbeatSilence: within grace → not silent; a beat since launch → beating; no/old beat past grace → silent", () => {
102
+ const started = T0;
103
+ assert.deepEqual(heartbeatSilence({ startedAt: started, heartbeat: null, now: T0 + 10_000 }), { silent: false, reason: "within-grace", runMs: 10_000 });
104
+ assert.equal(HEARTBEAT_GRACE_MS, 120_000);
105
+ assert.deepEqual(heartbeatSilence({ startedAt: started, heartbeat: null, now: T0 + 120_000 }), { silent: true, reason: "no-heartbeat", runMs: 120_000 });
106
+ const old = { ts: new Date(T0 - 60_000).toISOString() }; // from the PREVIOUS lifetime
107
+ assert.deepEqual(heartbeatSilence({ startedAt: started, heartbeat: old, now: T0 + 120_000 }), { silent: true, reason: "stale-heartbeat", runMs: 120_000 });
108
+ const fresh = { ts: new Date(T0 + 15_000).toISOString() };
109
+ assert.deepEqual(heartbeatSilence({ startedAt: started, heartbeat: fresh, now: T0 + 120_000 }), { silent: false, reason: "beating", runMs: 120_000 });
110
+ assert.equal(heartbeatSilence({ startedAt: started, heartbeat: null, now: T0 + 30_000, graceMs: 20_000 }).silent, true);
111
+ });
112
+
113
+ test("attentionRecord: what the doctor reads — reason, since, the attach command for the mux in use", () => {
114
+ const r = attentionRecord({ reason: "no-heartbeat", mux: { kind: "screen" }, muxName: "maestro-olivia", since: T0, runMs: 120_000 });
115
+ assert.equal(r.reason, "no-heartbeat");
116
+ assert.equal(r.since, "2026-09-08T10:00:00.000Z");
117
+ assert.equal(r.runMs, 120_000);
118
+ assert.equal(r.attach, "screen -r maestro-olivia");
119
+ assert.match(r.hint, /dialog|prompt/i);
120
+ assert.equal(attentionRecord({ reason: "no-heartbeat", mux: { kind: "tmux" }, muxName: "maestro-olivia", since: T0, runMs: 1 }).attach, "tmux attach -t =maestro-olivia");
121
+ });