@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
@@ -42,6 +42,12 @@ function loadAgent() {
42
42
  return _agent;
43
43
  }
44
44
 
45
+ // Self-echo guard (25/27/30 Aug recurrences): the poll lane re-ingested this
46
+ // agent's OWN outbound sends as fresh inbound and fired holding acks at them.
47
+ // The old inline check compared `sender` against "isla-roselli" while real
48
+ // events carry "Isla Roselli" — it never matched. The match now lives in one
49
+ // tested module and every drop is audited to logs/daemon/self-echo-drops.jsonl.
50
+ import { isSelfSender, logSelfEchoDrop } from "./lib/self-echo.mjs";
45
51
  import { pollSlack } from "../poller/slack-poller.mjs";
46
52
  import { pollGmail } from "../poller/gmail-poller.mjs";
47
53
  import { pollCalendar } from "../poller/calendar-poller.mjs";
@@ -71,6 +77,7 @@ import {
71
77
  openObligations,
72
78
  escalate,
73
79
  closeObligation,
80
+ isPidAlive,
74
81
  } from "./assurance.mjs";
75
82
  import { recordPoll, recordClassification, recordSession, writeHealthDashboard } from "./health.mjs";
76
83
  import { acquireLock, releaseLock, updateLock, scanStaleLocks, acquireThreadLock, claimRequest, hasActiveClaim, sweepStaleItemClaims, sanitiseItemId } from "./session-lock.mjs";
@@ -85,7 +92,15 @@ const RUNG_MECHANISM = Object.fromEntries(RUNGS.map((r) => [r.id, r.mechanism]))
85
92
  // work and land it in the org's shared, ACL'd store so the fleet's memory
86
93
  // compounds. Fail-open + best-effort — never blocks or throws into the hot path;
87
94
  // local SQLite/interaction logs remain the fail-open cache.
88
- import { isEnabled as orgEnabled, loadOrgConfig, configFromAgent, electResponder, fetchSelfProfile } from "../../lib/org/client.mjs";
95
+ import { isEnabled as orgEnabled, loadOrgConfig, configFromAgent, electResponder, fetchSelfProfile, listMineResult as orgListMineResult } from "../../lib/org/client.mjs";
96
+ // board.mine cache (design §3.5, WP-M6): the daemon writes state/org/board-mine.json
97
+ // every 5 min; the SessionStart primer, session_status and `maestro board mine`
98
+ // read it. The when/what is pure (lib/org/board-mine-cache.mjs); the I/O is here.
99
+ import { shouldRefresh as boardMineShouldRefresh, renderRefresh as renderBoardMineRefresh, cacheTs as boardMineCacheTs, BOARD_MINE_REL } from "../../lib/org/board-mine-cache.mjs";
100
+ import { writeJsonAtomic as writeJsonAtomicFile } from "../../lib/fs-atomic.mjs";
101
+ import { brandingGetFoundation } from "../../lib/org/client.mjs";
102
+ import { shouldRefreshDesign, foundationVersion, isUnversionedFoundation, DESIGN_REFRESH_INTERVAL_MS } from "../../lib/design/refresh-gate.mjs";
103
+ import { syncDesign, readFoundationState, workspaceName as designWorkspaceName } from "../../lib/design/write.mjs";
89
104
  import { remember as orgRemember } from "../../lib/org/knowledge.mjs";
90
105
  import { sweepSessionOutcomes, resultTextFromStdout } from "./session-outcomes.mjs";
91
106
  import { recordWorkStep as orgRecordWorkStep, cohortChannelId, cohortMessageId } from "../../lib/org/work-ledger.mjs";
@@ -117,6 +132,13 @@ import { getHookBus } from "../../lib/hooks/bus.mjs";
117
132
  // classifier path below is the mechanism the ladder's `react` effect drives,
118
133
  // not a parallel universe.
119
134
  import { processOne } from "../../lib/execution/pipeline.mjs";
135
+ // FRONT DOOR (design §3.1/§3.3). When the seat's main session is live, the
136
+ // daemon leaves Cohort inbox items in place for the session to claim instead
137
+ // of spawning `claude --print`; not live → today's dispatch, unchanged. The
138
+ // gate is pure (lib/session/frontdoor.mjs); the assurance sweep reopens a
139
+ // session claim that was neither replied nor done within 20 min.
140
+ import { makeFrontDoorGate } from "../../lib/session/frontdoor.mjs";
141
+ import { sweepSessionInboxForDaemon } from "../../lib/session/inbox-claims.mjs";
120
142
  import { defaultEffects, scheduleToQueue } from "../../lib/execution/effects.mjs";
121
143
  import { loadReactObligations } from "../../lib/execution/match.mjs";
122
144
  import { policyFor } from "../../lib/execution/surface-policy.mjs";
@@ -227,8 +249,13 @@ async function poll() {
227
249
  * cohort path can run on its own fast interval (pollCohortFast) without waiting
228
250
  * on the 60s full poll. Returns the count of new items processed. Never throws.
229
251
  */
252
+ const _frontDoorGate = makeFrontDoorGate({ agentRoot: AGENT_REPO_DIR });
230
253
  async function pollService(svc) {
231
254
  try {
255
+ // Front door: a live main session owns this service's inbox — leave it.
256
+ const fd = _frontDoorGate.check(svc.name);
257
+ if (fd.changed) console.log(`[daemon] front door for ${svc.name}: ${fd.dispatch ? "daemon dispatches" : "main session owns intake"} (${fd.reason})`);
258
+ if (!fd.dispatch) return 0;
232
259
  const result = await svc.fn();
233
260
  // Dedup by raw_ref first: the same Slack message arrives via multiple paths
234
261
  // (channel scan, thread scan, events-server JSON) with different item IDs.
@@ -236,12 +263,18 @@ async function pollService(svc) {
236
263
  // once regardless of path.
237
264
  const seenRefs = new Set();
238
265
  const newItems = result.items.filter((item) => {
239
- // Skip the agent's own messages — defensive check in case intake filters miss them
240
- const sender = (item.sender || "").toLowerCase();
241
- const me = loadAgent();
242
- const myFirst = (me.firstName || "").toLowerCase();
243
- const myFull = (me.fullName || "").toLowerCase().replace(/\s+/g, "-");
244
- if (myFirst && (sender === myFirst || sender === myFull)) return false;
266
+ // SELF-ECHO DROP (ingestion guard #1). The agent's own outbound, echoed
267
+ // back by a lane, is not inbound. Dropped LOUDLY (audit log) and the inbox
268
+ // file is retired — without the rename the same echo was re-scanned and
269
+ // re-considered every tick forever. The old check here compared `sender`
270
+ // against `fullName` with spaces folded to dashes, which never matched the
271
+ // real event shape ("Isla Roselli") — the exact reason the 25/27/30 Aug
272
+ // self-reply loops got past it.
273
+ if (isSelfSender(item)) {
274
+ logSelfEchoDrop(item, { stage: "poll", service: svc.name });
275
+ markProcessed(item, svc.name);
276
+ return false;
277
+ }
245
278
 
246
279
  const lockKey = item.raw_ref || item.id || `${svc.name}-${Date.now()}`;
247
280
  if (seenRefs.has(lockKey)) return false;
@@ -278,7 +311,7 @@ async function pollService(svc) {
278
311
  // against the 60s poll's own cohort scan (kept as a safety net).
279
312
  let _cohortPoller = null;
280
313
  let _cohortFastRunning = false;
281
- async function pollCohortFast() {
314
+ export async function pollCohortFast() {
282
315
  if (_cohortFastRunning) return 0;
283
316
  if (!_existsSync(join(AGENT_REPO_DIR, "config/org.yaml"))) return 0;
284
317
  if (!_cohortPoller) _cohortPoller = makeInboxScanPoller("cohort", { agentRoot: AGENT_REPO_DIR });
@@ -339,6 +372,18 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
339
372
  // regression-prone line in the file.
340
373
  enrichItem(item, service);
341
374
 
375
+ // ── SELF-ECHO DROP (guard #2, the choke point) ───────────────────────────
376
+ // Every entry path — the 60s poll, the fast cohort scan, crash recovery —
377
+ // crosses this line, so an echo that slipped past a lane's own filter is
378
+ // still stopped before it can be classified, elected on, acked or answered.
379
+ // Retiring the file is part of the drop: an un-retired echo is re-delivered
380
+ // next poll and re-dropped forever, which is noise pretending to be safety.
381
+ if (isSelfSender(item)) {
382
+ logSelfEchoDrop(item, { stage: "process", service });
383
+ markProcessed(item, service);
384
+ return { ok: true, path: "filtered", reason: "self_echo" };
385
+ }
386
+
342
387
  // ── THE EXECUTION LADDER, at the front of the chain ──────────────────────
343
388
  // Returns `{ handled }`. `handled:true` means the ladder took responsibility
344
389
  // for this item — it ignored it (journalled), queued it, delegated it,
@@ -899,6 +944,20 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
899
944
  const ackVerdict = _claudeAvailable()
900
945
  ? shouldAcknowledge({ willSpawnSession: true, item, source: "inbox" })
901
946
  : { ack: false, reason: "claude-unavailable" };
947
+ // ── DISPATCH GATE: emitter-class inbound spawns NOTHING ─────────────────
948
+ // `emitterClass: true` means the gate read the inbound and recognised output
949
+ // this seat's own machinery class produces — a peer agent's ack-shaped
950
+ // one-liner, or another daemon's narrator line (progress/failure/terminal).
951
+ // Suppressing only the ack while still spawning would keep the expensive
952
+ // half of the 25/27/30 Aug loop: a 15-45 minute session burned answering a
953
+ // reflex. The gate already audited the refusal
954
+ // (logs/daemon/dispatch-gate-drops.jsonl); retire the item and stop here.
955
+ if (!ackVerdict.ack && ackVerdict.emitterClass) {
956
+ console.log(`[daemon] dispatch gate: no ack, no session for ${itemId} (${ackVerdict.reason})`);
957
+ counters.bump("dispatch.gate_refused", { service, reason: ackVerdict.reason });
958
+ markProcessed(item, service);
959
+ return { ok: true, path: "filtered", reason: ackVerdict.reason };
960
+ }
902
961
  try {
903
962
  // The DEBT is opened whether or not we can speak. Those are two different
904
963
  // questions and conflating them is what made a whole class of ask
@@ -1712,6 +1771,127 @@ export function _resetDaemonOrgCfgCache() {
1712
1771
  _cachedDaemonOrgCfg = undefined;
1713
1772
  }
1714
1773
 
1774
+ /**
1775
+ * Refresh state/org/board-mine.json from hq `board.mine` when the cache is
1776
+ * 5 min old (or absent). Fail-open end to end: an un-enrolled seat writes
1777
+ * nothing; a FAILED read (hq unreachable) keeps the previous items and marks
1778
+ * the document `stale: true` with `itemsAt` (lib/org/board-mine-cache.mjs
1779
+ * renderRefresh) — one bad tick must not blank the primer, session_status
1780
+ * and `maestro board mine` for five minutes. `listMineImpl` may return the
1781
+ * `{ok, items}` shape of listMineResult or a bare array (= ok).
1782
+ * @param {{now?:number, listMineImpl?:Function, force?:boolean}} [deps]
1783
+ * @returns {Promise<{refreshed:boolean, reason:string, count?:number}>}
1784
+ */
1785
+ export async function refreshBoardMineCache(deps = {}) {
1786
+ const now = Number.isFinite(deps.now) ? deps.now : Date.now();
1787
+ const path = join(AGENT_REPO_DIR, BOARD_MINE_REL);
1788
+ let cur = null;
1789
+ try { cur = JSON.parse(readFileSync(path, "utf8")); } catch { cur = null; /* absent or corrupt → refresh */ }
1790
+ if (!deps.force && !boardMineShouldRefresh(boardMineCacheTs(cur), now)) return { refreshed: false, reason: "fresh" };
1791
+ const cfg = daemonOrgCfg();
1792
+ if (!orgEnabled(cfg)) return { refreshed: false, reason: "org-disabled" };
1793
+ const conn = configFromAgent(cfg) || {};
1794
+ const result = await (deps.listMineImpl || orgListMineResult)({ base: conn.base, token: conn.token, orgId: conn.orgId });
1795
+ const doc = renderBoardMineRefresh(result, cur, now);
1796
+ writeJsonAtomicFile(path, doc);
1797
+ return { refreshed: true, reason: doc.stale ? "kept-previous" : "written", count: doc.items.length };
1798
+ }
1799
+
1800
+ /**
1801
+ * Re-sync the workspace brand foundation into DESIGN.md + PRODUCT.md (WP-M7
1802
+ * mechanic 4), in the same idiom as `refreshIntegrationTools` in
1803
+ * `lib/org/mesh.mjs`: a cheap gated poll on the beat, fail-open end to end,
1804
+ * keeping the last-good files when hq is unreachable.
1805
+ *
1806
+ * WHY THIS EXISTS AT ALL. Every design skill on the seat is told to read
1807
+ * `$AGENT_ROOT/DESIGN.md` before it picks a colour or a typeface. A designer
1808
+ * working from a palette a human replaced last week is worse than one with no
1809
+ * palette, because the stale file looks authoritative — so the seat follows the
1810
+ * foundation rather than waiting for someone to re-run the CLI.
1811
+ *
1812
+ * THE GATE IS ASKED TWICE (see `lib/design/refresh-gate.mjs`): once before the
1813
+ * read, with the version unknown, to decide whether to spend a call at all
1814
+ * (15-minute floor); once after, with the version hq returned, to say honestly
1815
+ * WHY this tick refreshed. hq exposes no cheap "has the foundation changed"
1816
+ * etag the way `integration.toolsetVersion` does, so the version comparison can
1817
+ * only happen after a read and the interval is what bounds the reads.
1818
+ *
1819
+ * FAIL-OPEN, AND SILENT ABOUT NOTHING. An un-enrolled seat writes nothing and
1820
+ * says so; a fetch failure keeps the files that are already on disk and logs
1821
+ * the reason. The write itself is byte-idempotent (`syncDesign`), so a steady
1822
+ * foundation never touches an mtime — which is what keeps a git tree clean and
1823
+ * an editor's file watcher quiet on a seat that polls every 15 minutes.
1824
+ *
1825
+ * @param {{now?:number, intervalMs?:number, force?:boolean, getFoundationImpl?:Function, fetchImpl?:Function}} [deps]
1826
+ * @returns {Promise<{refreshed:boolean, reason:string, version?:string|null, written?:string[]}>}
1827
+ */
1828
+ export async function refreshDesignFoundation(deps = {}) {
1829
+ const now = Number.isFinite(deps.now) ? deps.now : Date.now();
1830
+ const intervalMs = Number.isFinite(deps.intervalMs) ? deps.intervalMs : DESIGN_REFRESH_INTERVAL_MS;
1831
+ const prior = readFoundationState(AGENT_REPO_DIR);
1832
+ const before = shouldRefreshDesign({
1833
+ lastVersion: prior ? prior.version : null,
1834
+ currentVersion: null,
1835
+ lastAt: prior ? prior.fetchedAt : null,
1836
+ now,
1837
+ intervalMs,
1838
+ });
1839
+ if (!deps.force && !before.refresh) return { refreshed: false, reason: "fresh" };
1840
+
1841
+ const cfg = daemonOrgCfg();
1842
+ if (!orgEnabled(cfg)) return { refreshed: false, reason: "org-disabled" };
1843
+ const conn = configFromAgent(cfg) || {};
1844
+
1845
+ let frame;
1846
+ try {
1847
+ frame = await (deps.getFoundationImpl || brandingGetFoundation)(
1848
+ { historyLimit: 0 },
1849
+ { base: conn.base, token: conn.token, orgId: conn.orgId, fetchImpl: deps.fetchImpl },
1850
+ );
1851
+ } catch (err) {
1852
+ console.warn(`[daemon] design sync: foundation read failed (${err && err.message ? err.message : err}) — keeping the DESIGN.md already on disk`);
1853
+ return { refreshed: false, reason: "fetch-failed" };
1854
+ }
1855
+ if (!frame || !frame.ok || !frame.result || typeof frame.result !== "object" || !frame.result.foundation) {
1856
+ const why = frame && frame.error && frame.error.code ? frame.error.code : "no-foundation";
1857
+ return { refreshed: false, reason: why === "no-foundation" ? "no-foundation" : `error:${why}` };
1858
+ }
1859
+ // A workspace that has never SAVED a foundation reads back hq's own product
1860
+ // defaults with `unversioned: true` — a complete palette that is nobody's
1861
+ // brand. Writing it would ground every design skill on the seat in the wrong
1862
+ // palette, with a generated file's authority behind it. Nothing is written
1863
+ // and nothing already on disk is disturbed; see
1864
+ // refresh-gate.mjs#isUnversionedFoundation.
1865
+ if (isUnversionedFoundation(frame.result)) return { refreshed: false, reason: "unversioned" };
1866
+
1867
+ // The second gate call: now that the version is known, say why this tick ran.
1868
+ const after = shouldRefreshDesign({
1869
+ lastVersion: prior ? prior.version : null,
1870
+ currentVersion: foundationVersion(frame.result),
1871
+ lastAt: prior ? prior.fetchedAt : null,
1872
+ now,
1873
+ intervalMs,
1874
+ });
1875
+
1876
+ let out;
1877
+ try {
1878
+ out = syncDesign({
1879
+ agentRoot: AGENT_REPO_DIR,
1880
+ result: frame.result,
1881
+ now,
1882
+ name: designWorkspaceName(cfg),
1883
+ source: "cohort · branding.getFoundation",
1884
+ });
1885
+ } catch (err) {
1886
+ console.warn(`[daemon] design sync: write failed (${err && err.message ? err.message : err})`);
1887
+ return { refreshed: false, reason: "write-failed" };
1888
+ }
1889
+ if (out.written.length) {
1890
+ console.log(`[daemon] design sync (${after.reason}) → wrote ${out.written.join(", ")}${out.version ? ` — foundation ${out.version}` : ""}`);
1891
+ }
1892
+ return { refreshed: out.written.length > 0, reason: after.reason, version: out.version, written: out.written };
1893
+ }
1894
+
1715
1895
  /** Longest completion note we will put on a board comment. */
1716
1896
  const COMPLETION_NOTE_CHARS = 1200;
1717
1897
 
@@ -2179,6 +2359,38 @@ async function sweepBacklog() {
2179
2359
  // Main
2180
2360
  // ---------------------------------------------------------------------------
2181
2361
 
2362
+ /**
2363
+ * Daemon singleton ADVISORY — a pidfile check, deliberately not a lock.
2364
+ *
2365
+ * Two live daemons on one AGENT_DIR (the operator's manual run next to the
2366
+ * launchd one) both poll, both sweep, and both narrate; the per-obligation
2367
+ * ownership guard in assurance.sweepObligations bounds the damage, but the
2368
+ * condition itself deserves a LOUD line the moment it starts. This only logs
2369
+ * and counts — it never refuses to start and never exits — because launchd's
2370
+ * KeepAlive restart may race the old process's teardown, and a pidfile that
2371
+ * can veto startup under KeepAlive is a daemon that stays down. A recycled pid
2372
+ * can false-positive; that costs one loud log line, nothing more.
2373
+ */
2374
+ function advisePidfileSingleton() {
2375
+ const pidPath = join(AGENT_REPO_DIR, "state", "daemon.pid");
2376
+ try {
2377
+ let prior = null;
2378
+ try { prior = parseInt(readFileSync(pidPath, "utf-8"), 10); } catch { /* no pidfile yet */ }
2379
+ if (Number.isFinite(prior) && prior > 0 && prior !== process.pid && isPidAlive(prior)) {
2380
+ console.error(
2381
+ `[daemon] ⚠ ANOTHER DAEMON MAY BE LIVE: pid ${prior} (from ${pidPath}) is running and also holds ${AGENT_REPO_DIR}. ` +
2382
+ `Two daemons on one AGENT_DIR double-poll and race the obligation ledger — stop one unless this is deliberate.`,
2383
+ );
2384
+ counters.bump("daemon.singleton_advisory", { prior_pid: prior });
2385
+ }
2386
+ mkdirSync(join(AGENT_REPO_DIR, "state"), { recursive: true });
2387
+ writeFileSync(pidPath, `${process.pid}\n`);
2388
+ } catch (err) {
2389
+ // Advisory only — an unwritable pidfile must never stop the daemon booting.
2390
+ console.warn(`[daemon] pidfile advisory unavailable: ${err.message}`);
2391
+ }
2392
+ }
2393
+
2182
2394
  async function main() {
2183
2395
  console.log("╔══════════════════════════════════════════════════════════╗");
2184
2396
  console.log(`║ ${(loadAgent().firstName || "Agent").padEnd(8)} Daemon — Reactive Event Processor ║`);
@@ -2205,6 +2417,11 @@ async function main() {
2205
2417
  // No emergency stop — continue
2206
2418
  }
2207
2419
 
2420
+ // Two-daemon advisory: log loudly if another live daemon already holds this
2421
+ // AGENT_DIR (pidfile check; never blocks startup — see the function's note on
2422
+ // launchd KeepAlive semantics), then record this process as the holder.
2423
+ advisePidfileSingleton();
2424
+
2208
2425
  // Clear orphaned state from prior daemon instances
2209
2426
  const staleCleared = scanStaleLocks();
2210
2427
  if (staleCleared > 0) {
@@ -2242,6 +2459,9 @@ async function main() {
2242
2459
 
2243
2460
  // Initial poll immediately
2244
2461
  await poll();
2462
+ // First board.mine cache write, so the primer has something before the
2463
+ // first health tick (fail-open; an un-enrolled seat writes nothing).
2464
+ refreshBoardMineCache().catch((err) => console.error("[daemon] board-mine cache error:", err.message));
2245
2465
 
2246
2466
  // Poll loop
2247
2467
  setInterval(async () => {
@@ -2254,6 +2474,16 @@ async function main() {
2254
2474
  }
2255
2475
  }, POLL_INTERVAL);
2256
2476
 
2477
+ // Brand foundation → DESIGN.md + PRODUCT.md, every 15 min (WP-M7). Fired once
2478
+ // at boot so a seat that has never synced has its design files before the
2479
+ // first session opens, then on its own interval. Fail-open and never awaited:
2480
+ // the design docs are guidance, and no failure to fetch them may hold up the
2481
+ // poll loop.
2482
+ refreshDesignFoundation().catch((err) => console.error("[daemon] design sync error:", err.message));
2483
+ setInterval(() => {
2484
+ refreshDesignFoundation().catch((err) => console.error("[daemon] design sync error:", err.message));
2485
+ }, DESIGN_REFRESH_INTERVAL_MS);
2486
+
2257
2487
  // Fast reactive cohort scan — drains org-push-fetched DMs in ~2s instead of
2258
2488
  // waiting up to 60s for the full poll. Local YAML only; no API/rate-limit cost.
2259
2489
  setInterval(() => {
@@ -2285,6 +2515,12 @@ async function main() {
2285
2515
  if (claimsSwept > 0) {
2286
2516
  console.log(`[daemon] Swept ${claimsSwept} stale item claims`);
2287
2517
  }
2518
+ // Front door: reopen session claims older than 20 min, due deferrals and
2519
+ // (while the session is live) the daemon's own orphaned .dispatched files.
2520
+ const reopened = sweepSessionInboxForDaemon(AGENT_REPO_DIR).reopened;
2521
+ if (reopened.length > 0) console.log(`[daemon] reopened ${reopened.length} session-claimed inbox item(s): ${reopened.map((r) => `${r.id} (${r.reason})`).join(", ")}`);
2522
+ // board.mine cache — the pure gate says "no" for four of every five ticks.
2523
+ refreshBoardMineCache().catch((err) => console.error("[daemon] board-mine cache error:", err.message));
2288
2524
  } catch (err) {
2289
2525
  console.error("[daemon] Health write error:", err.message);
2290
2526
  }
@@ -2300,18 +2536,21 @@ async function main() {
2300
2536
  }, HEALTH_INTERVAL);
2301
2537
 
2302
2538
  // Graceful shutdown — clean up active.json so next startup doesn't see stale sessions
2539
+ // Exit honours a pre-set process.exitCode: the org-mesh "restart" directive
2540
+ // sets 1 before signalling so launchd's KeepAlive {SuccessfulExit:false}
2541
+ // relaunches the daemon; a plain SIGTERM still exits 0 (stays down).
2303
2542
  process.on("SIGTERM", () => {
2304
2543
  console.log("[daemon] SIGTERM received, shutting down gracefully");
2305
2544
  resetActiveSessions();
2306
2545
  writeHealthDashboard();
2307
- process.exit(0);
2546
+ process.exit(process.exitCode || 0);
2308
2547
  });
2309
2548
 
2310
2549
  process.on("SIGINT", () => {
2311
2550
  console.log("[daemon] SIGINT received, shutting down");
2312
2551
  resetActiveSessions();
2313
2552
  writeHealthDashboard();
2314
- process.exit(0);
2553
+ process.exit(process.exitCode || 0);
2315
2554
  });
2316
2555
 
2317
2556
  console.log("[daemon] Running. Ctrl+C to stop.");
@@ -903,3 +903,76 @@ test("fastPathClassify: a short directed question skips the classifier; work-req
903
903
  assert.ok(daemon.fastPathClassify({ content: "what OS?" }, true));
904
904
  assert.equal(daemon.fastPathClassify({ content: "what OS?" }, true, { disableFastPath: true }), null);
905
905
  });
906
+
907
+ // ===========================================================================
908
+ // SELF-ECHO — the agent's own outbound, re-ingested by a poll lane, must be
909
+ // dropped BEFORE it can be classified, elected on, acked or answered. Recurred
910
+ // live 2026-08-25 / 08-27 / 08-30: the old filter normalised the agent's full
911
+ // name to "isla-roselli" while real events carry "Isla Roselli", so the agent
912
+ // said "Looking now." to itself (and, in shared DMs, to another agent's echo).
913
+ // ===========================================================================
914
+
915
+ test("SELF-ECHO: an echoed own message is dropped at the processItem choke point — file retired, drop audited", async () => {
916
+ resetState();
917
+ const selfEcho = await import("./lib/self-echo.mjs");
918
+ const { writeFileSync: wf } = await import("node:fs");
919
+ mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
920
+ wf(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({
921
+ firstName: "Isla", fullName: "Isla Roselli", memberId: "cmqh0tcml004ihhl6zev6ocb5",
922
+ }));
923
+ selfEcho._resetSelfIdentity();
924
+ try {
925
+ // The REAL echo shape: display name verbatim, spaces and all.
926
+ const item = { id: "MSG-SELF", raw_ref: "slack:DSELF:1", channel: "dm/candace", channel_id: "DSELF0001", sender: "Isla Roselli", content: "Both answers — and the second one settles it." };
927
+ const name = writeLiveItem(item);
928
+
929
+ let pipelineTouched = null;
930
+ const res = await daemon.processItem(item, "slack", {
931
+ classify: async () => { pipelineTouched = "classify"; return { priority: "high", action: "respond", model: "opus", summary: "x", category: "action_required", directed_at_agent: true }; },
932
+ isQuickReply: () => { pipelineTouched = "isQuickReply"; return false; },
933
+ sendQuickResponse: async () => { pipelineTouched = "quick_reply"; return { sent: true }; },
934
+ sendHoldingMessage: async () => { pipelineTouched = "holding_ack"; return { sent: true, holdingText: "x" }; },
935
+ dispatch: () => { pipelineTouched = "dispatch"; },
936
+ claudeAvailable: () => true,
937
+ });
938
+
939
+ assert.equal(res && res.path, "filtered", "the echo is filtered, not answered");
940
+ assert.equal(res && res.reason, "self_echo");
941
+ assert.equal(pipelineTouched, null, "no classify, no ack, no reply, no session — the echo never enters the pipeline");
942
+ assert.deepEqual(inboxFiles(), [`${name}.processed`], "the echo's inbox file is RETIRED — an un-retired echo would be re-scanned and re-dropped every tick forever");
943
+ const auditPath = join(AGENT_DIR, "logs", "daemon", "self-echo-drops.jsonl");
944
+ assert.ok(existsSync(auditPath), "every drop leaves an audit row");
945
+ assert.match(readFileSync(auditPath, "utf-8"), /slack:DSELF:1/, "the audit row names the dropped event");
946
+ } finally {
947
+ rmSync(join(AGENT_DIR, "config", "agent.json"), { force: true });
948
+ selfEcho._resetSelfIdentity();
949
+ }
950
+ });
951
+
952
+ test("SELF-ECHO: a genuine colleague's message is untouched by the guard", async () => {
953
+ resetState();
954
+ const selfEcho = await import("./lib/self-echo.mjs");
955
+ const { writeFileSync: wf } = await import("node:fs");
956
+ mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
957
+ wf(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({
958
+ firstName: "Isla", fullName: "Isla Roselli", memberId: "cmqh0tcml004ihhl6zev6ocb5",
959
+ }));
960
+ selfEcho._resetSelfIdentity();
961
+ try {
962
+ const item = { id: "MSG-COLLEAGUE", raw_ref: "slack:DCOLL:1", channel: "dm/jordan", channel_id: "DCOLL0001", is_dm: true, sender: "Jordan Blake", content: "please draft the board memo" };
963
+ writeLiveItem(item);
964
+ let dispatched = false;
965
+ const res = await daemon.answerItem(item, "slack", "slack:DCOLL:1", "trace-coll", {
966
+ classify: async () => ({ priority: "critical", action: "respond", model: "opus", summary: "draft board memo", category: "action_required", directed_at_agent: true }),
967
+ isQuickReply: () => false,
968
+ sendHoldingMessage: async () => ({ sent: true, holdingText: "Starting on the board memo now." }),
969
+ dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
970
+ claudeAvailable: () => true,
971
+ });
972
+ assert.equal(dispatched, true, "a real colleague's ask still dispatches");
973
+ assert.equal(res.path, "session");
974
+ } finally {
975
+ rmSync(join(AGENT_DIR, "config", "agent.json"), { force: true });
976
+ selfEcho._resetSelfIdentity();
977
+ }
978
+ });