@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
@@ -16,6 +16,13 @@
16
16
  * prompt under schedules/triggers/<name>.md.
17
17
  * Unknown cadences with a prompt on disk default to escalate; without
18
18
  * a prompt they go straight to dlq with a clear error.
19
+ * FRONT DOOR (design §3.4): when the seat's main session is live, an
20
+ * escalate/guarded tick is HANDED to it (state/session/handoffs/<id>.json
21
+ * + the rendered prompt) and processed as "handed-to-session" instead of
22
+ * spawning; a handoff un-acked past its deadline is re-enqueued with
23
+ * metadata.handoffTimedOut=true and then spawned on the legacy lane.
24
+ * A deferred tick (circuit, governance, concurrency, per-cadence
25
+ * in-flight guard) is re-keyed to the TAIL and the drain keeps scanning.
19
26
  * 4. Respects .emergency-stop: while present, the loop logs a heartbeat
20
27
  * but never spawns a sub-session and never processes events. Existing
21
28
  * claims remain on disk so they can be resumed once the stop is lifted.
@@ -49,7 +56,12 @@
49
56
  * logger optional fn({ ts, level, …rest }) → void for tests.
50
57
  * now injectable clock fn() → ms (tests); used when re-stamping
51
58
  * in-flight claim mtimes so the stale-claim sweep can't sweep
52
- * a live escalate (audit L6).
59
+ * a live escalate (audit L6), and for handoff deadlines.
60
+ * frontDoorState injected fn() → { frontDoor, sessionLive } (tests);
61
+ * defaults to lib/session/frontdoor#readFrontDoorState over
62
+ * config/session.yaml + state/session/heartbeat.json.
63
+ * handoffDeadlineMs how long a handed-off tick may sit un-acked before it
64
+ * is re-enqueued on the legacy lane (default 30 min).
53
65
  */
54
66
 
55
67
  import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, openSync, closeSync, statSync, unlinkSync, utimesSync } from "node:fs";
@@ -63,6 +75,7 @@ import {
63
75
  completeTick,
64
76
  failTick,
65
77
  requeueTick,
78
+ enqueueTick,
66
79
  recoverStaleClaims,
67
80
  sweepRetention,
68
81
  writeHealth,
@@ -70,12 +83,24 @@ import {
70
83
  logBusEvent,
71
84
  busDepth,
72
85
  } from "../../lib/cadence-bus.mjs";
86
+ // FRONT DOOR (design §3.4). When the seat's main session is live, an
87
+ // escalate/guarded tick is HANDED to it (a handoff file + the rendered prompt
88
+ // on disk) instead of spawning a `claude --print` sub-session; the session acks
89
+ // with `maestro session ack <tickId>`. A handoff past its deadline is expired
90
+ // and the tick re-enqueued with metadata.handoffTimedOut=true, which is never
91
+ // handed off again — so a wedged session cannot starve a cadence. Not live →
92
+ // the legacy spawn, unchanged. The decision is pure; the state reader is the
93
+ // one edge and it fails open to "not live".
94
+ import { readFrontDoorState, shouldHandOffTick } from "../../lib/session/frontdoor.mjs";
95
+ import { writeHandoff, listHandoffs, expireHandoffs, pruneHandoffs, handoffPaths } from "../../lib/session/handoffs.mjs";
96
+ import { writeFileAtomic } from "../../lib/fs-atomic.mjs";
73
97
  import { resolveClaudeBin as sharedResolveClaude, augmentedPath, daemonClaudeArgs } from "../../lib/claude-bin.mjs";
74
98
  import { getCadenceDef } from "./cadence-handlers.mjs";
75
99
  import { obligationAllowedUnderPosture } from "../../lib/plan/compile.mjs";
76
100
  import { isHumanLaneCadence } from "../../lib/cadences.mjs";
77
101
  import { sessionPermissionArgs } from "../../lib/session-permissions.mjs";
78
102
  import { renderTemplate, buildContext } from "../../lib/render.mjs";
103
+ import { withParallelism } from "../../lib/prompts/parallelism.mjs";
79
104
  // Model router (opt-in). A `schema_version: 2` config routes each cadence
80
105
  // sub-session's model through resolveChain (SPEC §6.2): the RouteDecision
81
106
  // supplies the --model flag, the session-retarget env (third-party backends),
@@ -291,6 +316,43 @@ export function routeCadenceSpawn(agentRoot, cadence) {
291
316
  }
292
317
  }
293
318
 
319
+ /**
320
+ * A cadence trigger prompt, as the sub-session actually receives it.
321
+ *
322
+ * TWO transforms, and BOTH lanes must apply them or the two lanes disagree
323
+ * about what the same tick asked for:
324
+ *
325
+ * 1. `{{agent.*}}` / `{{company.*}}` identity tokens are rendered from
326
+ * config/agent.json + config/company.json (lib/render.mjs), so a trigger
327
+ * shipped generically in the package resolves to THIS seat. No-op on
328
+ * token-free text.
329
+ * 2. The fleet parallelism directive LEADS the body (WP-M7 mechanic 5). An
330
+ * escalate/guarded tick is exactly the sub-session the directive exists
331
+ * for — a trigger that asks for six independent audits has, without it,
332
+ * no standing permission to fan out, and runs them one at a time. The
333
+ * inbox/backlog lane gets this from `prompt-builder.mjs#buildPrompt`;
334
+ * cadence prompts never pass through that builder, which is how this lane
335
+ * was missed. `withParallelism` is idempotent, so a trigger file that
336
+ * already carries the text keeps exactly one copy.
337
+ *
338
+ * Pure apart from the two config reads; a missing/invalid config/agent.json
339
+ * leaves the body's tokens verbatim rather than failing the tick.
340
+ *
341
+ * @param {string} agentRoot
342
+ * @param {string} body the raw trigger prompt as read from disk
343
+ * @returns {string}
344
+ */
345
+ export function renderCadencePromptBody(agentRoot, body) {
346
+ let out = typeof body === "string" ? body : "";
347
+ try {
348
+ const agentCfg = JSON.parse(readFileSync(join(agentRoot, "config/agent.json"), "utf-8"));
349
+ let companyCfg = {};
350
+ try { companyCfg = JSON.parse(readFileSync(join(agentRoot, "config/company.json"), "utf-8")); } catch { /* optional */ }
351
+ out = renderTemplate(out, buildContext(agentCfg, null, companyCfg));
352
+ } catch { /* no/invalid config/agent.json — leave body verbatim */ }
353
+ return withParallelism(out);
354
+ }
355
+
294
356
  /**
295
357
  * Spawn a sub-session running the cadence's trigger prompt and resolve
296
358
  * with { exit_code, durationMs, stderr_tail }. Reads the prompt at call
@@ -315,17 +377,9 @@ function realSpawnSession({ agentRoot, cadence, promptPath, timeoutMs, log }) {
315
377
  return;
316
378
  }
317
379
 
318
- // Render {{agent.*}} / {{company.*}} identity tokens at spawn time so framework
319
- // triggers stay generic in the package and resolve to THIS agent + company
320
- // (see lib/render.mjs). Synchronous, from config/agent.json + config/company.json.
321
- // No-op on token-free text, so this is behaviour-preserving for any not-yet-
322
- // tokenised trigger.
323
- try {
324
- const agentCfg = JSON.parse(readFileSync(join(agentRoot, "config/agent.json"), "utf-8"));
325
- let companyCfg = {};
326
- try { companyCfg = JSON.parse(readFileSync(join(agentRoot, "config/company.json"), "utf-8")); } catch { /* optional */ }
327
- body = renderTemplate(body, buildContext(agentCfg, null, companyCfg));
328
- } catch { /* no/invalid config/agent.json — leave body verbatim */ }
380
+ // Identity tokens + the parallelism directive, at spawn time — one seam,
381
+ // shared with the front-door handoff lane. See renderCadencePromptBody.
382
+ body = renderCadencePromptBody(agentRoot, body);
329
383
 
330
384
  const bin = resolveClaudeBin();
331
385
  // Permission scoping (audit H1). sessionPermissionArgs() defaults to the
@@ -549,6 +603,37 @@ export function startConsumer(opts = {}) {
549
603
  const governor = opts.governor || resourceGovernor;
550
604
  const rateGuard = opts.rateGuard || rateGuardModule;
551
605
  const budgetGuard = opts.budgetGuard || budgetGuardModule;
606
+ const handoffDeadlineMs = opts.handoffDeadlineMs;
607
+ const frontDoorStateImpl = typeof opts.frontDoorState === "function"
608
+ ? opts.frontDoorState
609
+ : () => readFrontDoorState(agentRoot, { now: nowMs() });
610
+
611
+ /**
612
+ * The front-door state for THIS decision. A throwing reader means "not
613
+ * live" — the legacy lane runs, nothing is dropped.
614
+ */
615
+ function frontDoorState() {
616
+ try {
617
+ const st = frontDoorStateImpl();
618
+ return st && typeof st === "object" ? st : { frontDoor: "daemon", sessionLive: false };
619
+ } catch {
620
+ return { frontDoor: "daemon", sessionLive: false, reason: "reader-error" };
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Render a cadence prompt the way realSpawnSession does — the SAME
626
+ * `renderCadencePromptBody`, so the handed-off prompt is byte-identical to
627
+ * what a sub-session would have been given, parallelism directive included —
628
+ * and land it under state/session/handoffs/prompts/<tickId>.md.
629
+ */
630
+ function renderHandoffPrompt(tickId, promptPath) {
631
+ const fullPrompt = join(agentRoot, promptPath);
632
+ const body = renderCadencePromptBody(agentRoot, readFileSync(fullPrompt, "utf-8"));
633
+ const out = join(handoffPaths(agentRoot).prompts, `${tickId}.md`);
634
+ writeFileAtomic(out, body);
635
+ return out;
636
+ }
552
637
 
553
638
  /**
554
639
  * Admission gate for an escalation. Folds the shared 429 breaker + the
@@ -635,6 +720,11 @@ export function startConsumer(opts = {}) {
635
720
  dlq: 0,
636
721
  retries: 0,
637
722
  spawn_failures: 0,
723
+ // Front door (design §3.4): ticks handed to the live main session, handoffs
724
+ // that timed out and were re-enqueued, and deferrals re-keyed to the tail.
725
+ handed_off: 0,
726
+ handoff_timeouts: 0,
727
+ deferred: 0,
638
728
  last_event_id: null,
639
729
  last_decision: null,
640
730
  };
@@ -660,10 +750,94 @@ export function startConsumer(opts = {}) {
660
750
  // change recoverStaleClaims' signature (it lives in lib/cadence-bus.mjs,
661
751
  // owned by another change-set).
662
752
  const inFlightClaimIds = new Set();
753
+ // Per-cadence in-flight guard (audit F9: the same cadence spawned twice
754
+ // back-to-back). A cadence whose sub-session is still running, or whose
755
+ // handoff is still open with the session, must not start again — the second
756
+ // tick is deferred to the tail instead. Names, not ids: two ticks of one
757
+ // cadence are the duplicate this guards against.
758
+ const inFlightCadences = new Set();
663
759
 
664
760
  // Injectable clock so tests can drive the stale window deterministically.
665
761
  const nowMs = typeof opts.now === "function" ? opts.now : Date.now;
666
762
 
763
+ /** Is a cadence in flight — running here, or open as a handoff with the session? */
764
+ function cadenceInFlight(cadence) {
765
+ if (inFlightCadences.has(cadence)) return "sub-session";
766
+ try {
767
+ if (listHandoffs(agentRoot).some((h) => h.cadence === cadence)) return "handoff";
768
+ } catch { /* unreadable handoff dir → not a reason to block */ }
769
+ return null;
770
+ }
771
+
772
+ /**
773
+ * Re-queue a tick that cannot run YET (an upstream gate, never a per-event
774
+ * failure) to the TAIL under a new id (audit F8). Attempts untouched.
775
+ * `count:false` (the circuit/backoff skip) re-keys without touching the
776
+ * retry/deferral counters or last_decision — a held-back cadence was never
777
+ * attempted, and the skip is already logged and counted as skipped_*; a
778
+ * 30 s poll inside a 60 min circuit window must not read as 120 retries.
779
+ */
780
+ function deferTick(event, reason, extra = {}, { count = true } = {}) {
781
+ const r = requeueTick(agentRoot, event, { defer: true });
782
+ if (count) {
783
+ stats.retries += 1;
784
+ stats.deferred += 1;
785
+ stats.last_decision = "deferred";
786
+ log({ level: "info", stage: "escalate_deferred", reason, id: event.id, requeued_as: r && r.id, cadence: event.cadence, ...extra });
787
+ }
788
+ return { ok: false, decision: "deferred", reason };
789
+ }
790
+
791
+ // Ledger hygiene runs from the sweep at most this often.
792
+ const HANDOFF_PRUNE_EVERY_MS = 60 * 60_000;
793
+ let lastHandoffPruneAt = 0;
794
+
795
+ /**
796
+ * Expire handoffs past their deadline and re-enqueue each tick on the legacy
797
+ * lane with metadata.handoffTimedOut=true (never handed off again). The
798
+ * re-enqueue runs BEFORE the handoff is retired (expireHandoffs#onExpire):
799
+ * if the bus is unwritable the handoff stays open and the next sweep
800
+ * retries, so the cadence run is never lost. Cheap: one readdir of a small
801
+ * directory. Never throws.
802
+ */
803
+ function sweepHandoffTimeouts() {
804
+ const now = nowMs();
805
+ let out;
806
+ try {
807
+ out = expireHandoffs(agentRoot, {
808
+ now,
809
+ deadlineMs: handoffDeadlineMs,
810
+ onExpire: (h) => {
811
+ const meta = h.metadata && typeof h.metadata === "object" ? h.metadata : {};
812
+ const r = enqueueTick({
813
+ cadence: h.cadence,
814
+ source: "handoff-timeout",
815
+ agentRoot,
816
+ metadata: { ...meta, handoffTimedOut: true, handoffTickId: h.tickId, reason: `handoff to session timed out (${h.deadlineAt || "no deadline"})` },
817
+ });
818
+ // enqueueTick swallows an unwritable inbox (fallbackOnly, audit row
819
+ // only) — that is NOT back on the bus: keep the handoff open.
820
+ if (!r || !r.path) throw new Error(`tick not enqueued (${r && r.fallbackOnly ? "inbox unwritable" : "no path"})`);
821
+ stats.handoff_timeouts += 1;
822
+ log({ level: "warn", stage: "handoff_timed_out", id: h.tickId, cadence: h.cadence, requeued_as: r.id, deadline_at: h.deadlineAt || null });
823
+ return true;
824
+ },
825
+ });
826
+ } catch { return 0; }
827
+ for (const f of out.failed) {
828
+ log({ level: "error", stage: "handoff_requeue_failed", id: f.tickId, error: f.error, note: "handoff kept open; retried next sweep" });
829
+ }
830
+ if (now - lastHandoffPruneAt >= HANDOFF_PRUNE_EVERY_MS) {
831
+ lastHandoffPruneAt = now;
832
+ try {
833
+ const pr = pruneHandoffs(agentRoot, { now });
834
+ const n = pr.removed.done.length + pr.removed.prompts.length;
835
+ if (n > 0) log({ level: "info", stage: "handoffs_pruned", done: pr.removed.done.length, prompts: pr.removed.prompts.length });
836
+ } catch { /* hygiene is never a reason to skip the tick */ }
837
+ }
838
+ return out.expired.length;
839
+ }
840
+
667
841
  /**
668
842
  * Re-stamp the mtime of every in-flight claim file to the current time so
669
843
  * the next mtime-based stale sweep treats it as fresh. Best-effort: a missing
@@ -793,56 +967,22 @@ export function startConsumer(opts = {}) {
793
967
  });
794
968
  if (gate.reason === "circuit-open") stats.skipped_circuit_open += 1;
795
969
  else stats.skipped_backoff += 1;
796
- // Put the event back in inbox unchanged. Attempt accounting is
797
- // single-sourced in failTick (audit M5), so we no longer decrement here
798
- // — a held-back-by-circuit re-queue is not a failed attempt and must
799
- // not drift the count. Use the atomic requeueTick (audit M6) instead of
800
- // a bare writeFileSync so a crash can't leave a half-written inbox file.
801
- requeueTick(agentRoot, event);
970
+ // Put the event back — to the TAIL (F8). Attempt accounting is
971
+ // single-sourced in failTick (audit M5), so we never touch it here: a
972
+ // held-back-by-circuit re-queue is not a failed attempt, nor a retry.
973
+ deferTick(event, gate.reason, { retry_at: new Date(gate.retry_at).toISOString() }, { count: false });
802
974
  return { ok: false, decision: gate.reason };
803
975
  }
804
976
 
805
977
  // WS4 governance gate — beside the concurrency cap. If the host is under
806
978
  // memory/load pressure, the 429 breaker is open, or the daily budget is
807
- // exhausted (essential-only), DEFER this cadence tick: requeue it unchanged
808
- // and DO NOT call failTick (a governor/rate deferral is an upstream gate,
809
- // not a per-event failure — burning retry budget here would eventually DLQ
810
- // a perfectly good cadence just because the box was busy).
979
+ // exhausted (essential-only), DEFER this cadence tick: requeue it to the
980
+ // tail and DO NOT call failTick (a governor/rate deferral is an upstream
981
+ // gate, not a per-event failure — burning retry budget here would
982
+ // eventually DLQ a perfectly good cadence just because the box was busy).
811
983
  {
812
984
  const gov = governanceGate(event.cadence);
813
- if (!gov.admit) {
814
- log({
815
- level: "info",
816
- stage: "escalate_deferred",
817
- reason: gov.reason,
818
- id: event.id,
819
- cadence: event.cadence,
820
- });
821
- requeueTick(agentRoot, event);
822
- stats.retries += 1;
823
- stats.last_decision = "deferred";
824
- return { ok: false, decision: "deferred" };
825
- }
826
- }
827
-
828
- if (activeSubSessions >= MAX_CONCURRENT_SUB_SESSIONS) {
829
- // Re-queue and try again next tick. Single-owner cadence consumer
830
- // means this can only happen when a prior tick is still running —
831
- // queue depth is the right back-pressure signal.
832
- log({
833
- level: "info",
834
- stage: "escalate_deferred",
835
- id: event.id,
836
- cadence: event.cadence,
837
- active_subsessions: activeSubSessions,
838
- });
839
- // Re-queue unchanged — concurrent-spawn isn't a per-event failure, so
840
- // it must not touch the attempt count (single-sourced in failTick,
841
- // audit M5). Atomic requeueTick (audit M6) replaces the bare
842
- // writeFileSync that could leave a half-written inbox file.
843
- requeueTick(agentRoot, event);
844
- stats.retries += 1;
845
- return { ok: false, decision: "deferred" };
985
+ if (!gov.admit) return deferTick(event, gov.reason);
846
986
  }
847
987
 
848
988
  const def = getCadenceDef(event.cadence);
@@ -861,7 +1001,61 @@ export function startConsumer(opts = {}) {
861
1001
  }
862
1002
  }
863
1003
 
1004
+ // Per-cadence in-flight guard (F9): never the same cadence twice at once —
1005
+ // neither as two sub-sessions nor as a sub-session beside an open handoff.
1006
+ {
1007
+ const why = cadenceInFlight(event.cadence);
1008
+ if (why) return deferTick(event, `cadence-in-flight:${why}`);
1009
+ }
1010
+
1011
+ // FRONT DOOR (§3.4): a live main session takes this tick instead of a
1012
+ // sub-session. Handoff = rendered prompt on disk + the ledger file; the
1013
+ // tick is processed here with decision "handed-to-session". A tick whose
1014
+ // previous handoff timed out (metadata.handoffTimedOut) falls through to
1015
+ // the legacy spawn — shouldHandOffTick is pure and owns that rule.
1016
+ {
1017
+ const fd = frontDoorState();
1018
+ const verdict = shouldHandOffTick({ ...fd, mode: def?.mode, metadata: event.metadata });
1019
+ if (verdict.handOff) {
1020
+ try {
1021
+ const rendered = renderHandoffPrompt(event.id, promptPath);
1022
+ const h = writeHandoff(agentRoot, {
1023
+ tickId: event.id,
1024
+ cadence: event.cadence,
1025
+ mode: def?.mode || "escalate",
1026
+ promptPath: rendered,
1027
+ metadata: { ...(event.metadata || {}), sourcePrompt: promptPath },
1028
+ }, { now: nowMs(), deadlineMs: handoffDeadlineMs });
1029
+ if (!h.ok) throw new Error(h.error || "handoff write failed");
1030
+ completeTick(agentRoot, event.id, {
1031
+ decision: "handed-to-session",
1032
+ cadence: event.cadence,
1033
+ prompt: promptPath,
1034
+ handoff: h.path,
1035
+ deadline_at: h.handoff && h.handoff.deadlineAt,
1036
+ });
1037
+ stats.handed_off += 1;
1038
+ stats.last_decision = "handed-to-session";
1039
+ log({ level: "info", stage: "handed_to_session", id: event.id, cadence: event.cadence, prompt: rendered, deadline_at: h.handoff && h.handoff.deadlineAt });
1040
+ return { ok: true, decision: "handed-to-session" };
1041
+ } catch (err) {
1042
+ // A handoff we could not write is not a reason to lose the tick —
1043
+ // fall through to the legacy spawn, loudly.
1044
+ log({ level: "warn", stage: "handoff_failed_spawning_instead", id: event.id, cadence: event.cadence, error: err && err.message });
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ if (activeSubSessions >= MAX_CONCURRENT_SUB_SESSIONS) {
1050
+ // Re-queue (to the tail) and try again next tick. Single-owner cadence
1051
+ // consumer means this can only happen when a prior tick is still
1052
+ // running — queue depth is the right back-pressure signal. Not a
1053
+ // per-event failure: the attempt count (failTick, audit M5) is untouched.
1054
+ return deferTick(event, "concurrency", { active_subsessions: activeSubSessions });
1055
+ }
1056
+
864
1057
  activeSubSessions += 1;
1058
+ inFlightCadences.add(event.cadence);
865
1059
  // Audit L6: mark this claim in-flight so the periodic stale-claim sweep
866
1060
  // (which can run concurrently with a long-running sub-session) does not
867
1061
  // treat its claimed/<id>.json as crashed and re-queue it under us.
@@ -878,6 +1072,7 @@ export function startConsumer(opts = {}) {
878
1072
  });
879
1073
  } finally {
880
1074
  activeSubSessions -= 1;
1075
+ inFlightCadences.delete(event.cadence);
881
1076
  inFlightClaimIds.delete(event.id);
882
1077
  }
883
1078
 
@@ -910,14 +1105,11 @@ export function startConsumer(opts = {}) {
910
1105
  const rec = rateGuard.recordUsageLimit(RATE_PROVIDER, cadenceUl.resetAt, { agentRoot });
911
1106
  log({ level: "warn", stage: "subsession_usage_limited", id: event.id, cadence: event.cadence, open_until: rec.openUntil, reset_at: rec.resetAt });
912
1107
  } catch { /* */ }
913
- // Same requeue-unchanged handling as a 429: a window-usage limit is a
914
- // shared, provider-side gate — not evidence THIS cadence is broken — so
915
- // requeue without failTick or a circuit trip; the shared breaker (held
916
- // until reset) gates re-escalation.
917
- requeueTick(agentRoot, event);
918
- stats.retries += 1;
919
- stats.last_decision = "deferred";
920
- return { ok: false, decision: "deferred" };
1108
+ // Same handling as a 429: a window-usage limit is a shared, provider-side
1109
+ // gate — not evidence THIS cadence is broken — so requeue (to the tail)
1110
+ // without failTick or a circuit trip; the shared breaker (held until
1111
+ // reset) gates re-escalation.
1112
+ return deferTick(event, "usage-limited");
921
1113
  }
922
1114
  if (rateGuard.classifyStderr(cadenceOut)) {
923
1115
  try {
@@ -929,12 +1121,9 @@ export function startConsumer(opts = {}) {
929
1121
  // NOT evidence that THIS cadence is broken, so we must NOT call
930
1122
  // recordSubsessionFailure here: doing so would advance the per-cadence
931
1123
  // circuit toward open and leave a perfectly-healthy cadence circuit-open
932
- // even after the shared breaker clears. Requeue unchanged (no failTick,
1124
+ // even after the shared breaker clears. Requeue to the tail (no failTick,
933
1125
  // no circuit trip); the shared breaker gates re-escalation.
934
- requeueTick(agentRoot, event);
935
- stats.retries += 1;
936
- stats.last_decision = "deferred";
937
- return { ok: false, decision: "deferred" };
1126
+ return deferTick(event, "rate-limited");
938
1127
  }
939
1128
 
940
1129
  // Failure path: log + cap retries low. The exact stderr tail comes
@@ -1040,40 +1229,49 @@ export function startConsumer(opts = {}) {
1040
1229
  // Routed through the guarded wrapper so an in-flight claim is never swept
1041
1230
  // (audit L6).
1042
1231
  recoverStaleClaimsGuarded();
1232
+ // Front door: a handed-off tick the session never acked comes back to the
1233
+ // legacy lane here, ahead of the drain, so it is claimable in this tick.
1234
+ sweepHandoffTimeouts();
1043
1235
 
1044
1236
  let processed = 0;
1045
- let escalatedThisTick = 0;
1046
- // Drain inline events as much as the consumer can in one tick; cap
1047
- // sub-session escalations at 1 per tick so a fast-failing cadence
1048
- // can't burn a whole minute's worth of retries inside a single poll.
1049
- // The next poll (DEFAULT_POLL_MS later) will pick up where we left off.
1237
+ let spawnedThisTick = 0;
1238
+ // Head-of-line (F8): a deferred tick is re-keyed to the tail, so the loop
1239
+ // KEEPS SCANNING past it to the next cadence instead of breaking. This set
1240
+ // (keyed by the tick's original id, which a re-key preserves) is how the
1241
+ // loop notices it has come back round to a tick it already deferred in
1242
+ // this very tick — it puts that one back untouched and stops, so a queue
1243
+ // holding only un-runnable ticks costs one visit per tick, not sixteen.
1244
+ const seenThisTick = new Set();
1050
1245
  while (!stopping) {
1051
1246
  const claim = claimNextTick(agentRoot);
1052
1247
  if (!claim) break;
1053
1248
  const event = claim.event;
1249
+ const key = (event.metadata && event.metadata.originalId) || event.id;
1250
+ if (seenThisTick.has(key)) {
1251
+ requeueTick(agentRoot, event);
1252
+ break;
1253
+ }
1254
+ seenThisTick.add(key);
1054
1255
  activeTick = event.id;
1055
- let didEscalate = false;
1256
+ let didSpawn = false;
1056
1257
  try {
1057
- const def = getCadenceDef(event.cadence);
1058
- const willEscalate = !def || (def.mode !== "inline" && (def.mode !== "guarded" || true));
1059
- // Roughly: if it's not a registry-inline cadence, we MAY escalate.
1060
- // We don't yet know if the guard will say inline; processEvent
1061
- // will tell us via stats. Use the escalated stats delta as the
1062
- // signal that an actual sub-session ran this iteration.
1063
- const before = stats.escalated + stats.spawn_failures + stats.skipped_circuit_open + stats.skipped_backoff;
1258
+ // Did a sub-session actually run (or fail to spawn) this iteration? A
1259
+ // deferral — circuit/backoff skip, governance, concurrency, in-flight
1260
+ // guard — is NOT a spawn and must not end the scan (that was the
1261
+ // head-of-line block). A handoff is not a spawn either: it is cheap.
1262
+ const before = stats.escalated + stats.spawn_failures;
1064
1263
  await processEvent(event);
1065
- const after = stats.escalated + stats.spawn_failures + stats.skipped_circuit_open + stats.skipped_backoff;
1066
- if (after > before) didEscalate = true;
1067
- // Silence unused var warning.
1068
- void willEscalate;
1264
+ const after = stats.escalated + stats.spawn_failures;
1265
+ if (after > before) didSpawn = true;
1069
1266
  } finally {
1070
1267
  activeTick = null;
1071
1268
  }
1072
1269
  processed += 1;
1073
- if (didEscalate) escalatedThisTick += 1;
1074
- // Hard cap: at most ONE sub-session spawn per tick. Inline ticks
1075
- // keep draining freely (they're cheap).
1076
- if (escalatedThisTick >= 1) break;
1270
+ if (didSpawn) spawnedThisTick += 1;
1271
+ // Hard cap: at most ONE sub-session spawn per tick so a fast-failing
1272
+ // cadence can't burn a whole minute's worth of retries inside a single
1273
+ // poll. Inline ticks and handoffs keep draining freely (they're cheap).
1274
+ if (spawnedThisTick >= 1) break;
1077
1275
  if (processed >= 16) break; // soft batch cap
1078
1276
  }
1079
1277
  return { processed };
@@ -1216,6 +1414,8 @@ export function startConsumer(opts = {}) {
1216
1414
  _recoverStaleClaimsGuarded: recoverStaleClaimsGuarded,
1217
1415
  _markInFlight: (id) => inFlightClaimIds.add(id),
1218
1416
  _clearInFlight: (id) => inFlightClaimIds.delete(id),
1417
+ // Front door: the open handoffs this consumer has written for the session.
1418
+ _handoffs: () => listHandoffs(agentRoot),
1219
1419
  };
1220
1420
  }
1221
1421
 
@@ -679,6 +679,33 @@ const MESSAGING_CURSOR_REL = join("state", "messaging", "inbound-cursor.json");
679
679
  * (re)install starts at "now" and replays nothing. A real, persisted cursor
680
680
  * (always > 0 in practice — see writeMessagingCursor) is returned as-is.
681
681
  */
682
+ /**
683
+ * The seat's own member cuid, as resolved by `agent-daemon.resolveSelfMemberId`
684
+ * and persisted to `config/agent.json`. This is the `actor` hq stamps on every
685
+ * event the seat emits, so it is the ONLY id the wide reader's own-echo and
686
+ * mention joins can match against.
687
+ *
688
+ * Returns "" when unresolved. Also returns "" when the field holds the SLUG
689
+ * (COHORT_AGENT_ID) rather than a cuid — a bad writer putting the slug in the
690
+ * cuid field would otherwise make this fix mask the very bug it fixes, since a
691
+ * slug read from disk is exactly as broken as a slug read from env.
692
+ *
693
+ * @param {string} agentRoot
694
+ * @returns {string} member cuid, or "" if unresolved
695
+ */
696
+ function persistedMemberId(agentRoot) {
697
+ try {
698
+ const c = JSON.parse(readFileSync(join(agentRoot, "config", "agent.json"), "utf-8"));
699
+ const mid = c && typeof c.memberId === "string" ? c.memberId.trim() : "";
700
+ if (!mid) return "";
701
+ const slug = String(process.env.COHORT_AGENT_ID || "").trim();
702
+ if (slug && mid === slug) return "";
703
+ return mid;
704
+ } catch {
705
+ return "";
706
+ }
707
+ }
708
+
682
709
  function readMessagingCursor(agentRoot) {
683
710
  try {
684
711
  const p = join(agentRoot, MESSAGING_CURSOR_REL);
@@ -813,11 +840,36 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
813
840
  // skipped, and the agent re-ingests its own replies as fresh inbound (a
814
841
  // conversation with itself). config/org.yaml does not carry an agentId, so
815
842
  // env is the reliable source on a real machine.
843
+ //
844
+ // MUST BE THE MEMBER CUID, NOT THE SLUG. hq names the actor on every
845
+ // /v1/events row by member cuid ("cmqh0te…"); COHORT_AGENT_ID is a SLUG
846
+ // ("A028"). Every directedness join in the wide reader — own_echo, mention,
847
+ // assignee, decision proposer, doc owner — compares `me` against a
848
+ // cuid-shaped payload field, so handing it the slug does not throw: it
849
+ // silently answers "no" to every identity question. own_echo then never
850
+ // fires and the seat re-ingests its own outbound, which produces a holding
851
+ // note, which is itself re-ingested — a self-sustaining loop that burns a
852
+ // sub-session per cycle and posts into real rooms.
853
+ //
854
+ // agent-daemon.resolveSelfMemberId already resolves the cuid via whoami and
855
+ // persists it to config/agent.json; prefer that, and fall back to the slug
856
+ // (degraded, but better than no id) only until it has been resolved once.
816
857
  const agentId =
817
858
  opts.agentId ||
859
+ persistedMemberId(agentRoot) ||
818
860
  process.env.COHORT_AGENT_ID ||
819
861
  (cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.agentId) ||
820
862
  undefined;
863
+ // Belt and braces on top of the id above: hand the reader BOTH namespaces so
864
+ // own-echo suppression cannot silently lapse if `agentId` resolves to the
865
+ // slug after all (persistedMemberId returns "" when no memberId is on record,
866
+ // or when it equals the slug). resolveDirected uses these ONLY to widen the
867
+ // own-echo drop — never to widen what the agent is entitled to read.
868
+ const meAliases = [
869
+ cfg && cfg.memberId,
870
+ process.env.COHORT_AGENT_ID,
871
+ cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.agentId,
872
+ ].filter((v) => typeof v === "string" && v.trim());
821
873
  // Display names for prose-mention matching ("Isla, can you…" with no @).
822
874
  // The wide reader only ever uses these to match text it was ALREADY entitled
823
875
  // to read, so this cannot widen the aperture.
@@ -835,6 +887,7 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
835
887
  const res = await pull({
836
888
  cfg,
837
889
  agentId,
890
+ meAliases,
838
891
  cursor,
839
892
  fetchImpl: opts.fetchImpl,
840
893
  myNames,
@@ -196,7 +196,7 @@ const CLAUDE_CLI_TIMEOUT_MS = 30_000;
196
196
  * it was a hardcoded example-company literal sitting between two interpolated
197
197
  * fields. This
198
198
  * is not cosmetic: the classifier's `summary` is carried verbatim into
199
- * assurance.composeAck (text a human reads) and board-mirror's board row title,
199
+ * board-mirror's board row title and the needs-attention escalation record,
200
200
  * so a fabricated employer in its frame of reference leaks into the org record.
201
201
  *
202
202
  * @param {{name?:string, role?:string, company?:string, principal?:object}} id