@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,99 @@
1
+ /**
2
+ * lib/org/board-mine-cache.mjs — the pure half of the daemon's board-mine
3
+ * cache (design 2026-09-08 §3.5; left over from WP-M2/M4).
4
+ *
5
+ * The daemon asks hq `board.mine` (`lib/org/client.mjs#listMine`, fail-open
6
+ * `[]`) every five minutes and writes the answer to
7
+ * `state/org/board-mine.json` as `{ts, items:[...]}`. Three readers already
8
+ * exist: the SessionStart primer (`lib/session/status-summary.mjs`), the
9
+ * `session_status` MCP tool and `maestro board mine` (`lib/cli/board.mjs`,
10
+ * which falls back to this cache when the live read is empty). This module
11
+ * decides WHEN to refresh and WHAT to write; the daemon does the I/O.
12
+ *
13
+ * @module lib/org/board-mine-cache
14
+ */
15
+
16
+ "use strict";
17
+
18
+ /** Five minutes, per the design. */
19
+ export const REFRESH_INTERVAL_MS = 5 * 60_000;
20
+
21
+ /** Where the daemon writes the cache, relative to the agent root. */
22
+ export const BOARD_MINE_REL = "state/org/board-mine.json";
23
+
24
+ /**
25
+ * Should the cache be refreshed now? Pure. `lastTs` is the `ts` of the last
26
+ * write (ISO string or epoch ms; null/invalid = never), `now` is epoch ms.
27
+ * A clock that went backwards (lastTs in the future) also refreshes, so a
28
+ * bad timestamp can never freeze the cache.
29
+ * @param {string|number|null|undefined} lastTs
30
+ * @param {number} now
31
+ * @param {number} [intervalMs]
32
+ * @returns {boolean}
33
+ */
34
+ export function shouldRefresh(lastTs, now, intervalMs = REFRESH_INTERVAL_MS) {
35
+ const last = toMs(lastTs);
36
+ if (last === null) return true;
37
+ if (last > now) return true;
38
+ return now - last >= intervalMs;
39
+ }
40
+
41
+ /**
42
+ * The document to write. Pure. Non-array or non-object items are dropped so a
43
+ * malformed server answer cannot poison the readers.
44
+ * @param {unknown} items what listMine() returned
45
+ * @param {number} now epoch ms
46
+ * @returns {{ts:string, items:object[]}}
47
+ */
48
+ export function render(items, now) {
49
+ const list = Array.isArray(items) ? items.filter((it) => it && typeof it === "object" && !Array.isArray(it)) : [];
50
+ return { ts: new Date(now).toISOString(), items: list };
51
+ }
52
+
53
+ /**
54
+ * What to write after a refresh attempt. Pure. A successful read renders the
55
+ * fresh items; a FAILED read keeps the previous document's items (last known
56
+ * good) and marks the document `stale: true` with `itemsAt` = when those
57
+ * items were actually fetched — so one unreachable hq tick does not turn the
58
+ * SessionStart primer, session_status and `maestro board mine` into "zero
59
+ * items" for five minutes. With nothing previous, a failed read renders
60
+ * empty-and-stale.
61
+ * @param {{ok:boolean, items?:unknown, error?:string}|unknown[]|null|undefined} result listMineResult() (an array is treated as ok)
62
+ * @param {unknown} prev the current cache document (or null)
63
+ * @param {number} now epoch ms
64
+ * @returns {{ts:string, items:object[], stale?:true, itemsAt?:string|null, error?:string}}
65
+ */
66
+ export function renderRefresh(result, prev, now) {
67
+ const r = Array.isArray(result) ? { ok: true, items: result } : (result && typeof result === "object" ? result : { ok: false, items: [] });
68
+ if (r.ok) return render(r.items, now);
69
+ const prevDoc = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : null;
70
+ const kept = render(prevDoc ? prevDoc.items : [], now);
71
+ const itemsAt = prevDoc ? (prevDoc.stale && prevDoc.itemsAt !== undefined ? prevDoc.itemsAt : cacheTs(prevDoc)) : null;
72
+ const doc = { ...kept, stale: true, itemsAt: itemsAt || null };
73
+ if (r.error) doc.error = String(r.error).slice(0, 200);
74
+ return doc;
75
+ }
76
+
77
+ /**
78
+ * The `ts` of an existing cache document, or null. Tolerates the pre-M6
79
+ * `{fetchedAt}` spelling and a bare array (no timestamp → refresh).
80
+ * @param {unknown} doc
81
+ * @returns {string|null}
82
+ */
83
+ export function cacheTs(doc) {
84
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
85
+ if (typeof doc.ts === "string") return doc.ts;
86
+ if (typeof doc.fetchedAt === "string") return doc.fetchedAt;
87
+ return null;
88
+ }
89
+
90
+ function toMs(v) {
91
+ if (typeof v === "number" && Number.isFinite(v)) return v;
92
+ if (typeof v === "string" && v.trim()) {
93
+ const n = Date.parse(v);
94
+ if (Number.isFinite(n)) return n;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ export default { shouldRefresh, render, renderRefresh, cacheTs, REFRESH_INTERVAL_MS, BOARD_MINE_REL };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * board-mine-cache.test.mjs — the pure half of the daemon's 5-minute
3
+ * state/org/board-mine.json cache.
4
+ */
5
+
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { shouldRefresh, render, renderRefresh, cacheTs, REFRESH_INTERVAL_MS, BOARD_MINE_REL } from "./board-mine-cache.mjs";
9
+
10
+ const NOW = Date.parse("2026-09-08T12:00:00Z");
11
+
12
+ test("shouldRefresh: never written → yes; younger than 5 min → no; 5 min or older → yes; a future ts → yes", () => {
13
+ assert.equal(shouldRefresh(null, NOW), true);
14
+ assert.equal(shouldRefresh(undefined, NOW), true);
15
+ assert.equal(shouldRefresh("garbage", NOW), true);
16
+ assert.equal(shouldRefresh("2026-09-08T11:56:00Z", NOW), false);
17
+ assert.equal(shouldRefresh(NOW - REFRESH_INTERVAL_MS + 1, NOW), false);
18
+ assert.equal(shouldRefresh("2026-09-08T11:55:00Z", NOW), true);
19
+ assert.equal(shouldRefresh(NOW - REFRESH_INTERVAL_MS, NOW), true);
20
+ assert.equal(shouldRefresh("2026-09-08T12:30:00Z", NOW), true, "a clock that went backwards must not freeze the cache");
21
+ assert.equal(shouldRefresh("2026-09-08T11:59:30Z", NOW, 10_000), true, "interval is a parameter");
22
+ assert.equal(REFRESH_INTERVAL_MS, 300_000);
23
+ assert.equal(BOARD_MINE_REL, "state/org/board-mine.json");
24
+ });
25
+
26
+ test("render: {ts, items} with only object items kept; a non-array answer renders as empty", () => {
27
+ const doc = render([{ itemId: "i1", title: "Do X" }, null, "junk", [1], { itemId: "i2" }], NOW);
28
+ assert.deepEqual(doc, { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i1", title: "Do X" }, { itemId: "i2" }] });
29
+ assert.deepEqual(render(undefined, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [] });
30
+ assert.deepEqual(render({ items: [{ itemId: "x" }] }, NOW).items, [], "an object is not the items array (listMine already unwraps)");
31
+ });
32
+
33
+ test("cacheTs reads ts, tolerates the earlier fetchedAt spelling, and is null for a bare array or junk", () => {
34
+ assert.equal(cacheTs({ ts: "2026-09-08T11:59:00Z", items: [] }), "2026-09-08T11:59:00Z");
35
+ assert.equal(cacheTs({ fetchedAt: "2026-09-08T11:59:00Z", items: [] }), "2026-09-08T11:59:00Z");
36
+ assert.equal(cacheTs([{ itemId: "x" }]), null);
37
+ assert.equal(cacheTs(null), null);
38
+ assert.equal(cacheTs({ items: [] }), null);
39
+ });
40
+
41
+ test("renderRefresh: a successful read renders fresh items; a FAILED read keeps the previous items, marks stale and remembers when they were fetched", () => {
42
+ const prev = { ts: "2026-09-08T11:55:00.000Z", items: [{ itemId: "i1" }] };
43
+ assert.deepEqual(renderRefresh({ ok: true, items: [{ itemId: "i2" }] }, prev, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i2" }] });
44
+ assert.deepEqual(renderRefresh([{ itemId: "i3" }], prev, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i3" }] }, "a bare array (the old listMine shape) is a success");
45
+ assert.deepEqual(renderRefresh({ ok: true, items: [] }, prev, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [] }, "an empty board is a real answer, not a failure");
46
+ const failed = renderRefresh({ ok: false, items: [], error: "http 503" }, prev, NOW);
47
+ assert.deepEqual(failed, { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i1" }], stale: true, itemsAt: "2026-09-08T11:55:00.000Z", error: "http 503" });
48
+ const failedAgain = renderRefresh({ ok: false, items: [] }, failed, NOW + 300_000);
49
+ assert.equal(failedAgain.itemsAt, "2026-09-08T11:55:00.000Z", "a second failure keeps the ORIGINAL fetch time, not the last stale write");
50
+ assert.deepEqual(failedAgain.items, [{ itemId: "i1" }]);
51
+ assert.deepEqual(renderRefresh({ ok: false, items: [] }, null, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [], stale: true, itemsAt: null }, "nothing previous → empty and stale");
52
+ assert.deepEqual(renderRefresh(null, prev, NOW).items, [{ itemId: "i1" }], "a null result is a failure");
53
+ });
package/lib/org/board.mjs CHANGED
@@ -128,6 +128,17 @@ export function ready(opts = {}) {
128
128
  return resolveClient(opts).listReady(rpcOpts(opts));
129
129
  }
130
130
 
131
+ /**
132
+ * Read THIS agent's items across every board (GET /v1/board.mine — assignee
133
+ * or reviewer = caller). Returns the items array, or [] on any failure
134
+ * (fail-open; an hq without the read yet answers 404 → []).
135
+ * @param {object} [opts] - { client?, base, token, fetchImpl? }
136
+ * @returns {Promise<object[]>}
137
+ */
138
+ export function mine(opts = {}) {
139
+ return resolveClient(opts).listMine(rpcOpts(opts));
140
+ }
141
+
131
142
  // ---------------------------------------------------------------------------
132
143
  // Read-only YAML projection writer
133
144
  // ---------------------------------------------------------------------------
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
 
16
16
  import {
17
- createItem, claimItem, completeItem, decompose, assign, link, ready,
17
+ createItem, claimItem, completeItem, decompose, assign, link, ready, mine,
18
18
  buildProjection, writeProjection, writeReadyProjection,
19
19
  } from "./board.mjs";
20
20
  import { okFrame, errFrame, BOARD_STATUS } from "./protocol.mjs";
@@ -38,6 +38,7 @@ function fakeClient(map = {}) {
38
38
  boardAssign: make("boardAssign"),
39
39
  boardLink: make("boardLink"),
40
40
  listReady: make("listReady"),
41
+ listMine: make("listMine"),
41
42
  };
42
43
  }
43
44
 
@@ -175,3 +176,12 @@ test("writeReadyProjection: down server (empty ready) writes an empty projection
175
176
  assert.equal(doc.count, 0);
176
177
  } finally { rmSync(root, { recursive: true, force: true }); }
177
178
  });
179
+
180
+ test("mine: routes to client.listMine, forwarding conn opts (fail-open [] handled by the client)", async () => {
181
+ const client = fakeClient({ listMine: () => [{ itemId: "m1" }] });
182
+ assert.deepEqual(await mine({ client, ...CONN }), [{ itemId: "m1" }]);
183
+ // listMine takes ONE argument (the conn opts), like listReady.
184
+ const c = client.calls.find((x) => x.name === "listMine");
185
+ assert.equal(c.params.base, CONN.base);
186
+ assert.equal(c.params.token, CONN.token);
187
+ });
@@ -694,6 +694,42 @@ export async function listReady(o = {}) {
694
694
  return [];
695
695
  }
696
696
 
697
+ /**
698
+ * List every board item assigned to (or under review by) THIS agent across all
699
+ * boards and workstreams (GET /v1/board.mine — hq 2026-09, front-door session
700
+ * §3.5). Returns the items array, or [] on any failure (fail-open): an older hq
701
+ * without the read answers 404 and this returns [], same as listReady.
702
+ * Item shape: {itemId, title, boardId, boardName, channelId, workstreamId, col,
703
+ * stage, priority, dueAt, updatedAt, url}.
704
+ * @param {object} o - { base, token, orgId?, fetchImpl? }
705
+ * @returns {Promise<object[]>}
706
+ */
707
+ export async function listMine(o = {}) {
708
+ return (await listMineResult(o)).items;
709
+ }
710
+
711
+ /**
712
+ * `listMine` with the failure kept visible: `{ok:false, items:[]}` when hq
713
+ * could not be read, `{ok:true, items}` otherwise (an empty board is
714
+ * `ok:true`). The daemon's board-mine cache needs the difference so one
715
+ * unreachable tick does not overwrite the last known good answer with [].
716
+ * Never throws.
717
+ * @param {object} o - { base, token, orgId, fetchImpl? }
718
+ * @returns {Promise<{ok:boolean, items:object[], error?:string}>}
719
+ */
720
+ export async function listMineResult(o = {}) {
721
+ try {
722
+ const r = await read("board.mine", o);
723
+ if (!r.ok) return { ok: false, items: [], error: (r.error && r.error.message) || `http ${r.status || 0}` };
724
+ if (!r.payload) return { ok: true, items: [] };
725
+ if (Array.isArray(r.payload)) return { ok: true, items: r.payload };
726
+ if (Array.isArray(r.payload.items)) return { ok: true, items: r.payload.items };
727
+ return { ok: true, items: [] };
728
+ } catch (err) {
729
+ return { ok: false, items: [], error: String((err && err.message) || err) }; // read() never throws; belt-and-braces for a throwing fetchImpl
730
+ }
731
+ }
732
+
697
733
  /**
698
734
  * Fetch the event slice (GET /v1/events?cursor=N&limit=500). Returns the events
699
735
  * array (or [] on failure). The caller pipes these to verify.verifyEvents.
@@ -35,6 +35,9 @@ import {
35
35
  claimItem,
36
36
  completeItem,
37
37
  listReady,
38
+ listMine,
39
+ listMineResult,
40
+ trackWork,
38
41
  fetchEvents,
39
42
  fetchSnapshot,
40
43
  publishEntry,
@@ -1158,3 +1161,46 @@ test("loadOrgConfig: a PRESENT but unparseable org.yaml WARNS (never silently un
1158
1161
  assert.match(warnings.join("\n"), /could not be loaded/, "the operator must be told");
1159
1162
  assert.match(warnings.join("\n"), /NOT enrolled/, "and told what it means");
1160
1163
  });
1164
+
1165
+ // ── WP-M4: board.mine + board.track passthrough (front-door session §3.5) ──
1166
+
1167
+ test("listMineResult: keeps the failure visible — {ok:false} on an error frame or a throwing transport, {ok:true, items:[]} for an empty board", async () => {
1168
+ assert.deepEqual(await listMineResult({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ body: [{ itemId: "i1" }] })) }), { ok: true, items: [{ itemId: "i1" }] });
1169
+ assert.deepEqual(await listMineResult({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ body: { items: [] } })) }), { ok: true, items: [] }, "an empty board is a real answer");
1170
+ const failed = await listMineResult({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ ok: false, status: 503, body: { error: { code: "UNAVAILABLE", message: "hq down" } } })) });
1171
+ assert.equal(failed.ok, false);
1172
+ assert.deepEqual(failed.items, []);
1173
+ assert.match(failed.error, /hq down/);
1174
+ const threw = await listMineResult({ base: BASE, token: TOKEN, fetchImpl: () => { throw new Error("ECONNREFUSED"); } });
1175
+ assert.equal(threw.ok, false);
1176
+ assert.deepEqual(threw.items, []);
1177
+ assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: () => { throw new Error("ECONNREFUSED"); } }), [], "listMine stays the fail-open [] its other callers expect");
1178
+ });
1179
+
1180
+ test("listMine: GET /v1/board.mine; parses array and {items:[]}; [] on error / transport failure (fail-open)", async () => {
1181
+ const f = fakeFetch(() => ({ body: [{ itemId: "i1", title: "Deck" }] }));
1182
+ assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: f }), [{ itemId: "i1", title: "Deck" }]);
1183
+ assert.match(f.calls[0].url, /\/v1\/board\.mine$/);
1184
+ assert.equal(f.calls[0].init.method, "GET");
1185
+ assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ body: { items: [{ itemId: "i2" }] } })) }), [{ itemId: "i2" }]);
1186
+ assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ ok: false, status: 404, body: { error: { code: "NOT_FOUND" } } })) }), [], "an hq without board.mine yet → []");
1187
+ assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: async () => { throw new Error("boom"); } }), []);
1188
+ assert.deepEqual(await listMine({ base: "", token: TOKEN }), [], "no base → []");
1189
+ });
1190
+
1191
+ test("trackWork: title/why/stage (incl. accepted|done) pass through to board.track verbatim", async () => {
1192
+ const f = fakeFetch(() => ({ body: { ok: true, result: { tracked: true, taskId: "t1" } } }));
1193
+ const r = await trackWork(
1194
+ { service: "cohort", channelId: "c1", messageId: "m1", stage: "accepted", title: "Build the deck", why: "asked in DM" },
1195
+ { base: BASE, token: TOKEN, fetchImpl: f, idempotencyKey: "k1" },
1196
+ );
1197
+ assert.equal(r.ok, true);
1198
+ const body = JSON.parse(f.calls[0].init.body);
1199
+ assert.equal(body.stage, "accepted");
1200
+ assert.equal(body.title, "Build the deck");
1201
+ assert.equal(body.why, "asked in DM");
1202
+ assert.equal(body.channelId, "c1");
1203
+ const f2 = fakeFetch(() => ({ body: { ok: true, result: { tracked: true } } }));
1204
+ await trackWork({ service: "cohort", channelId: "c1", messageId: "m1", stage: "done" }, { base: BASE, token: TOKEN, fetchImpl: f2 });
1205
+ assert.equal(JSON.parse(f2.calls[0].init.body).stage, "done");
1206
+ });
@@ -406,7 +406,9 @@ function yes(surface, reason, extra = {}) {
406
406
  * @param {Candidate} cand
407
407
  * @param {string} me the agent's member id
408
408
  * @param {import("./facts.mjs").Facts} facts
409
- * @param {{enabled?: Record<string,boolean>}} [opts]
409
+ * @param {{enabled?: Record<string,boolean>, meAliases?: string[]}} [opts]
410
+ * `meAliases` — additional identity strings for this seat (slug, member id,
411
+ * agent email). Used ONLY to widen own-echo suppression; never to widen reach.
410
412
  * @returns {{directed:boolean, surface:string|null, reason:string, why:string}}
411
413
  */
412
414
  export function resolveDirected(cand, me, facts = {}, opts = {}) {
@@ -414,10 +416,24 @@ export function resolveDirected(cand, me, facts = {}, opts = {}) {
414
416
  const enabled = opts.enabled || null;
415
417
  const on = (s) => (enabled ? !!enabled[s] : true);
416
418
  const meId = me ? String(me) : "";
419
+ // Every identity string that means "this seat". `me` SHOULD be the org member
420
+ // id (a cuid), because that is what hq stamps on `actor` and `directTo`. But a
421
+ // real machine can just as easily hand us the SLUG ("A028") — COHORT_AGENT_ID
422
+ // holds a slug, and the daemon's responder-election path needs it that way.
423
+ // When the slug arrives here, `actor === meId` compares across two namespaces,
424
+ // never matches, and the own-echo drop below silently stops existing. Echo
425
+ // suppression must not depend on which alias the caller happened to pass, so
426
+ // match ANY known alias. Observed in production 2026-08-26: A028 re-ingested
427
+ // its own morning brief and its own holding note and began answering itself.
428
+ const meAliases = new Set(
429
+ [meId, ...(Array.isArray(opts.meAliases) ? opts.meAliases : [])]
430
+ .map((v) => (v == null ? "" : String(v).trim()))
431
+ .filter(Boolean),
432
+ );
417
433
 
418
434
  // My OWN echo is never inbound. This is the single most important drop: without
419
435
  // it an agent re-ingests its own replies and holds a conversation with itself.
420
- if (meId && cand.actor && cand.actor === meId) return no("own_echo");
436
+ if (cand.actor && meAliases.has(String(cand.actor))) return no("own_echo");
421
437
 
422
438
  // A payload that named me outright needs no join at all.
423
439
  if (meId && cand.directTo.map(String).includes(meId)) {
@@ -145,6 +145,64 @@ test("NEGATIVE: my own echo is never inbound", () => {
145
145
  assert.equal(resolveDirected(c, ME, f).reason, "own_echo");
146
146
  });
147
147
 
148
+ // --- own-echo across id namespaces (regression, production 2026-08-26) -------
149
+ //
150
+ // `me` SHOULD be the org member cuid — that is what hq stamps on `actor`. But
151
+ // COHORT_AGENT_ID holds a SLUG and the daemon's responder-election path needs it
152
+ // that way, so a slug reaches this resolver on a real machine. Strict equality
153
+ // on one id then compares across two namespaces: it does not throw, it answers
154
+ // "no" to every identity question, and the own-echo drop silently stops
155
+ // existing. That is how a seat re-ingested its own morning brief and its own
156
+ // holding note and began answering itself.
157
+ //
158
+ // These tests exist because nothing turns red when this lapses. The failure has
159
+ // no runtime signal at all — only an assertion.
160
+
161
+ const ME_SLUG = "A028";
162
+
163
+ test("REGRESSION: a SLUG in the me position still suppresses my own echo", () => {
164
+ const f = withChannel(facts(), "C-dm", "DM");
165
+ const c = classifyEvent(
166
+ ev({ family: "messaging", kind: "send", actor: ME, entity_id: "m1", payload: { actor: ME, channelId: "C-dm", channelKind: "DM" } }),
167
+ );
168
+ // hq stamped the cuid; the caller handed us the slug. Pre-fix this returned
169
+ // `dm` — the agent's own message delivered back to it as fresh inbound.
170
+ const v = resolveDirected(c, ME_SLUG, f, { meAliases: [ME_SLUG, ME] });
171
+ assert.equal(v.directed, false);
172
+ assert.equal(v.reason, "own_echo");
173
+ });
174
+
175
+ test("REGRESSION: a SLUG-shaped actor is suppressed when me is the cuid", () => {
176
+ const f = withChannel(facts(), "C-dm", "DM");
177
+ const c = classifyEvent(
178
+ ev({ family: "messaging", kind: "send", actor: ME_SLUG, entity_id: "m1", payload: { actor: ME_SLUG, channelId: "C-dm", channelKind: "DM" } }),
179
+ );
180
+ // Symmetric: suppression must not depend on WHICH namespace either side used.
181
+ assert.equal(resolveDirected(c, ME, f, { meAliases: [ME, ME_SLUG] }).reason, "own_echo");
182
+ });
183
+
184
+ test("meAliases widen own-echo ONLY — never reach", () => {
185
+ const f = facts(); // C-theirs is in nobody's roster but its members'
186
+ const c = classifyEvent(
187
+ ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C-theirs", channelKind: "DM" } }),
188
+ );
189
+ const v = resolveDirected(c, ME_SLUG, f, { meAliases: [ME_SLUG, ME] });
190
+ assert.equal(v.directed, false, "an alias list must never open someone else's room");
191
+ assert.equal(v.reason, "channel_not_visible");
192
+ });
193
+
194
+ test("a blank/garbage alias never swallows real inbound", () => {
195
+ const f = withChannel(facts(), "C-dm", "DM");
196
+ withMessage(f, "C-dm", { id: "m1", authorId: THEM, body: "ping" });
197
+ const c = classifyEvent(
198
+ ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM", mentionCount: 0, threaded: false } }),
199
+ );
200
+ // Empty strings and nullish entries are dropped, so `has(actor)` cannot match
201
+ // on emptiness and turn every message into my own echo.
202
+ const v = resolveDirected(c, ME, f, { meAliases: [null, "", " ", undefined] });
203
+ assert.deepEqual({ d: v.directed, r: v.reason }, { d: true, r: "dm" });
204
+ });
205
+
148
206
  // ---------------------------------------------------------------------------
149
207
  // messaging — DM / mention / thread / ambient
150
208
  // ---------------------------------------------------------------------------
@@ -87,6 +87,8 @@ const DEFAULT_EVENT_LIMIT = 200;
87
87
  * @param {number} [o.limit]
88
88
  * @param {Function} [o.fetchImpl]
89
89
  * @param {string[]} [o.myNames] display names for prose-mention matching
90
+ * @param {string[]} [o.meAliases] other identity strings for this seat
91
+ * (slug, member id) — widens OWN-ECHO suppression only, never reach
90
92
  * @param {string[]|Record<string,boolean>} [o.surfaces] surface enable override
91
93
  * @param {object} [o.limits] overrides for {@link DEFAULT_LIMITS}
92
94
  * @param {Function} [o.log] (level, message, meta?) — daemon logger
@@ -125,6 +127,11 @@ export async function pullWideInbound(o = {}) {
125
127
  return empty;
126
128
  }
127
129
 
130
+ // Extra identity strings for own-echo suppression only (slug AND member id).
131
+ // The caller cannot always know which namespace `agentId` is in; passing both
132
+ // means a slug-valued COHORT_AGENT_ID can never silently disable the drop.
133
+ const meAliases = Array.isArray(o.meAliases) ? o.meAliases.filter(Boolean) : [];
134
+
128
135
  const { enabled, unknown } = resolveEnabledSurfaces(
129
136
  o.surfaces !== undefined ? o.surfaces : parseSurfaceEnv(process.env.COHORT_INBOUND_SURFACES),
130
137
  );
@@ -185,7 +192,7 @@ export async function pullWideInbound(o = {}) {
185
192
  // ── 3. Verdicts.
186
193
  const directed = [];
187
194
  for (const cand of candidates) {
188
- const verdict = resolveDirected(cand, me, facts, { enabled });
195
+ const verdict = resolveDirected(cand, me, facts, { enabled, meAliases });
189
196
  if (!verdict.directed) {
190
197
  stats.dropped[verdict.reason] = (stats.dropped[verdict.reason] || 0) + 1;
191
198
  continue;
@@ -208,6 +208,28 @@ test("NEGATIVE: nothing from someone else's private room, my own echo, or ambien
208
208
  assert.ok(stats.dropped.channel_not_visible >= 1);
209
209
  });
210
210
 
211
+ test("REGRESSION: a slug-valued agentId still drops my own echo, via meAliases", async () => {
212
+ // The production shape on 2026-08-26: COHORT_AGENT_ID is a SLUG, hq stamps
213
+ // `actor` with the member cuid. `agentId` alone then matches nothing and the
214
+ // own-echo drop silently stops existing — no throw, no log, just the seat
215
+ // answering itself. The daemon hands BOTH namespaces down as `meAliases`, so
216
+ // the drop survives regardless of which one `agentId` resolved to.
217
+ const io = fakeIo(routes());
218
+ const { events, stats } = await pullWideInbound({
219
+ cfg: CFG,
220
+ agentId: "A028",
221
+ meAliases: ["A028", ME],
222
+ io,
223
+ });
224
+
225
+ assert.ok(stats.dropped.own_echo >= 1, "own_echo must still fire when me is the slug");
226
+ assert.equal(
227
+ events.some((e) => e.message_id === "m-mine"),
228
+ false,
229
+ "my own message must never come back, whichever id namespace the caller passed",
230
+ );
231
+ });
232
+
211
233
  test("email is OFF by default and is not even hydrated", async () => {
212
234
  const io = fakeIo(routes());
213
235
  const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * mesh-directives.test.mjs — the two front-door directives (design §3.4 /
3
+ * audit F10): hq's `remediateFleetSeat` queues `restart` and nothing honoured
4
+ * it. Now:
5
+ *
6
+ * restart → the daemon flushes and exits NON-ZERO so launchd's
7
+ * KeepAlive {SuccessfulExit:false} relaunches it (a clean
8
+ * exit 0 would leave it down). Injected as requestRestart.
9
+ * session.restart → touch state/session/restart-requested; the session's
10
+ * feed sees it and the session restarts itself at an idle
11
+ * moment. The daemon is untouched.
12
+ *
13
+ * Driven through the same fake client + explicit beatOnce as mesh.test.mjs.
14
+ */
15
+
16
+ import { test } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { tmpdir } from "node:os";
21
+
22
+ import { connectOrgMesh, _internals } from "./mesh.mjs";
23
+
24
+ const BASE = "https://org.cohortapp.com";
25
+ function tmpRepo() {
26
+ const root = mkdtempSync(join(tmpdir(), "org-mesh-directives-"));
27
+ mkdirSync(join(root, "config"), { recursive: true });
28
+ writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Alex", fullName: "Alex Chen", repoSlug: "alex-ai" }));
29
+ return root;
30
+ }
31
+ function cfgEnabled() { return { org: { cohort: { enabled: true, base: BASE, token: "tok" } } }; }
32
+ function fakeClient(beatFrames) {
33
+ const c = {
34
+ isEnabled: (cfg) => !!(cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.enabled),
35
+ configFromAgent: () => ({ base: BASE, token: "tok", orgId: "", enabled: true, trustedSignersPem: [] }),
36
+ _beatFrames: beatFrames.slice(),
37
+ register() { return Promise.resolve({ ok: true, result: { status: 200 } }); },
38
+ presenceBeat() { return Promise.resolve(c._beatFrames.length ? c._beatFrames.shift() : { ok: true, result: { directives: [] } }); },
39
+ fetchDirectory() { return Promise.resolve({ ok: true, result: { agents: [] } }); },
40
+ call() { return Promise.resolve({ ok: true, result: {} }); },
41
+ };
42
+ return c;
43
+ }
44
+
45
+ test("KNOWN_DIRECTIVES includes restart and session.restart", () => {
46
+ assert.ok(_internals.KNOWN_DIRECTIVES.has("restart"));
47
+ assert.ok(_internals.KNOWN_DIRECTIVES.has("session.restart"));
48
+ });
49
+
50
+ test('"restart" directive stops the beat loop and asks the daemon to restart (exit non-zero); never writes .emergency-stop', async () => {
51
+ const root = tmpRepo();
52
+ const client = fakeClient([{ ok: true, result: { directives: ["restart"] } }]);
53
+ let stops = 0;
54
+ let restarts = 0;
55
+ const handle = await connectOrgMesh({
56
+ agentRoot: root, cfg: cfgEnabled(), client,
57
+ setInterval: () => ({ unref() {} }),
58
+ requestStop: () => { stops += 1; },
59
+ requestRestart: () => { restarts += 1; },
60
+ });
61
+ const directives = await _internals.beatOnce(handle._ctx);
62
+ assert.deepEqual(directives, ["restart"]);
63
+ assert.equal(restarts, 1, "requestRestart() was invoked");
64
+ assert.equal(stops, 0, "a restart is not a halt");
65
+ assert.equal(existsSync(join(root, ".emergency-stop")), false, "a restart must never trip the kill switch");
66
+ assert.equal(handle._ctx.stopped, true, "the beat loop stopped: the process is going down to come back");
67
+ });
68
+
69
+ test('"session.restart" directive touches state/session/restart-requested and leaves the daemon running', async () => {
70
+ const root = tmpRepo();
71
+ const client = fakeClient([{ ok: true, result: { directives: ["session.restart"] } }]);
72
+ let stops = 0;
73
+ let restarts = 0;
74
+ const T0 = Date.parse("2026-09-08T10:00:00.000Z");
75
+ const handle = await connectOrgMesh({
76
+ agentRoot: root, cfg: cfgEnabled(), client,
77
+ setInterval: () => ({ unref() {} }),
78
+ now: () => T0,
79
+ requestStop: () => { stops += 1; },
80
+ requestRestart: () => { restarts += 1; },
81
+ });
82
+ const directives = await _internals.beatOnce(handle._ctx);
83
+ assert.deepEqual(directives, ["session.restart"]);
84
+ const flag = join(root, "state", "session", "restart-requested");
85
+ assert.ok(existsSync(flag), "restart-requested flag written");
86
+ assert.match(readFileSync(flag, "utf8"), /2026-09-08T10:00:00/, "flag carries the request time");
87
+ assert.equal(stops, 0);
88
+ assert.equal(restarts, 0, "the daemon itself is not restarted");
89
+ assert.equal(handle._ctx.stopped, false, "the beat loop keeps running");
90
+ // Idempotent: a second directive overwrites the flag, never throws.
91
+ await _internals.handleDirective("session.restart", handle._ctx);
92
+ assert.ok(existsSync(flag));
93
+ });
94
+
95
+ test("the default restart signal sets a non-zero exit code before the graceful stop, so launchd relaunches", () => {
96
+ // defaultRequestRestart is the production path; we only check its contract
97
+ // on process.exitCode without sending ourselves a signal.
98
+ const prevExitCode = process.exitCode;
99
+ const prevKill = process.kill;
100
+ let killed = null;
101
+ process.kill = (pid, sig) => { killed = { pid, sig }; return true; };
102
+ try {
103
+ _internals.defaultRequestRestart();
104
+ assert.equal(process.exitCode, 1, "exit code 1 → KeepAlive {SuccessfulExit:false} brings the daemon back");
105
+ assert.deepEqual(killed, { pid: process.pid, sig: "SIGTERM" }, "graceful stop via the daemon's own SIGTERM handlers (flush)");
106
+ } finally {
107
+ process.kill = prevKill;
108
+ process.exitCode = prevExitCode;
109
+ }
110
+ });