@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
@@ -29,10 +29,13 @@ const receipts = await import("../../lib/comms/receipts.mjs");
29
29
 
30
30
  const {
31
31
  shouldAcknowledge,
32
- composeAck,
32
+ generateAck,
33
+ sanitiseAckText,
33
34
  composeProgress,
34
35
  composeFailure,
35
36
  composeSilentSuccess,
37
+ isOwnNarratorText,
38
+ SILENT_SUCCESS_EXCERPT_LIMIT,
36
39
  classifyFailure,
37
40
  openAndAcknowledge,
38
41
  settleSession,
@@ -46,6 +49,7 @@ const {
46
49
  STALE_AFTER_MS,
47
50
  INTERRUPT_GRACE_MS,
48
51
  ACK_MAX_ATTEMPTS,
52
+ ACK_GEN_MAX_FAILURES,
49
53
  RETRY_MAX,
50
54
  } = assurance;
51
55
 
@@ -55,7 +59,11 @@ after(() => { try { rmSync(ROOT, { recursive: true, force: true }); } catch { /*
55
59
  // Fakes
56
60
  // ---------------------------------------------------------------------------
57
61
 
58
- /** A transport that records what a human would have read, and can be told to fail. */
62
+ /** A transport that records what a human would have read, and can be told to fail.
63
+ * The ackSender mirrors responder.sendHoldingMessage's NEW contract exactly:
64
+ * a generated bespoke line on success, and `{sent:false, holdingText:null,
65
+ * genFailed:true}` — i.e. deliberate silence — when generation fails. */
66
+ const FAKE_ACK_LINE = "Give me a few minutes on the git fixes.";
59
67
  function fakeTransport(opts = {}) {
60
68
  const sent = [];
61
69
  let failures = opts.failTimes || 0;
@@ -65,7 +73,8 @@ function fakeTransport(opts = {}) {
65
73
  return { sent: true, via: "fake", channel: item.channel_id || item.channel };
66
74
  };
67
75
  return { sent, impl, ackSender: async (item, cr) => {
68
- const text = composeAck(item, cr);
76
+ if (opts.genFail) return { sent: false, holdingText: null, error: "ack_generation_failed", genFailed: true };
77
+ const text = opts.ackText || FAKE_ACK_LINE;
69
78
  const r = await impl(item, text, { kind: "ack" });
70
79
  return { sent: r.sent, holdingText: text, error: r.error };
71
80
  } };
@@ -120,52 +129,268 @@ describe("shouldAcknowledge — the simple/complex distinction", () => {
120
129
  const orphan = { ...ITEM, channel_id: null, channel: null };
121
130
  assert.equal(shouldAcknowledge({ willSpawnSession: true, item: orphan, source: "inbox" }).ack, false);
122
131
  });
132
+
133
+ test("REGRESSION: the agent's OWN echoed message is never acknowledged (self-echo guard #2)", async () => {
134
+ // 25/27/30 Aug 2026: the poll lane re-ingested the agent's own outbound
135
+ // sends and the daemon said "Looking now." to itself — in shared DMs two
136
+ // agents traded canned acks at each other. Ingestion now drops echoes
137
+ // (agent-daemon + self-echo.mjs); this is the second, independent guard on
138
+ // the ack decision itself.
139
+ const { writeFileSync } = await import("node:fs");
140
+ const selfEcho = await import("./lib/self-echo.mjs");
141
+ mkdirSync(join(ROOT, "config"), { recursive: true });
142
+ writeFileSync(join(ROOT, "config", "agent.json"), JSON.stringify({
143
+ firstName: "Isla", fullName: "Isla Roselli", memberId: "cmqh0tcml004ihhl6zev6ocb5",
144
+ }));
145
+ selfEcho._resetSelfIdentity();
146
+ try {
147
+ // The REAL echoed shape: display name verbatim, spaces and all.
148
+ const echo = { ...ITEM, sender: "Isla Roselli" };
149
+ const v = shouldAcknowledge({ willSpawnSession: true, item: echo, source: "inbox" });
150
+ assert.equal(v.ack, false);
151
+ assert.equal(v.reason, "self-sender");
152
+ // The old filter's normalised form must ALSO match — it is how the echo
153
+ // slipped through three times.
154
+ assert.equal(shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, sender: "isla-roselli" }, source: "inbox" }).ack, false);
155
+ // And a genuine human sender still gets their acknowledgement.
156
+ assert.equal(shouldAcknowledge({ willSpawnSession: true, item: ITEM, source: "inbox" }).ack, true);
157
+ } finally {
158
+ rmSync(join(ROOT, "config", "agent.json"), { force: true });
159
+ selfEcho._resetSelfIdentity();
160
+ }
161
+ });
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // 1b. THE DISPATCH GATE READS THE INBOUND — a seat must not ack (and its
166
+ // caller must not spawn) on output its own emitter class produces.
167
+ // ---------------------------------------------------------------------------
168
+
169
+ describe("shouldAcknowledge — the dispatch gate reads item.content", () => {
170
+ // A peer AGENT seat, identified structurally (seat-slug id / agent↔agent DM),
171
+ // never by name.
172
+ const PEER_ACK = {
173
+ ...ITEM,
174
+ sender: "Piotr Novak",
175
+ sender_id: "A039",
176
+ content: "On it — taking a look now.",
177
+ };
178
+
179
+ test("REGRESSION: an ack-shaped one-liner from a peer agent seat gets NO ack and signals NO spawn", () => {
180
+ const v = shouldAcknowledge({ willSpawnSession: true, item: PEER_ACK, source: "inbox" });
181
+ assert.equal(v.ack, false);
182
+ assert.equal(v.reason, "peer-ack-shaped");
183
+ assert.equal(v.emitterClass, true, "the caller reads this to skip the session spawn too");
184
+ });
185
+
186
+ test("the SHAPE matches, not a phrase list — every 'Looking now' variant from a seat is refused", () => {
187
+ for (const content of ["On it.", "Looking now", "Taking the git fixes now — push coming.", "Still digging into this."]) {
188
+ const v = shouldAcknowledge({ willSpawnSession: true, item: { ...PEER_ACK, content }, source: "inbox" });
189
+ assert.equal(v.ack, false, `"${content}" is ack-shaped`);
190
+ assert.equal(v.emitterClass, true);
191
+ }
192
+ });
193
+
194
+ test("an agent↔agent DM channel is an agent-seat signal too", () => {
195
+ const v = shouldAcknowledge({
196
+ willSpawnSession: true,
197
+ item: { ...ITEM, sender: "Piotr Novak", channel: "dm-a016-a039", content: "On it." },
198
+ source: "inbox",
199
+ });
200
+ assert.equal(v.ack, false);
201
+ assert.equal(v.reason, "peer-ack-shaped");
202
+ });
203
+
204
+ test("the SAME short line from a HUMAN sender passes — shape alone is not enough", () => {
205
+ // No seat-slug id, no agent DM channel: treated as human, and a human's
206
+ // terse "On it" must never be met with silence.
207
+ const v = shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, content: "On it — will do." }, source: "inbox" });
208
+ assert.equal(v.ack, true);
209
+ });
210
+
211
+ test("a QUESTION or a REQUEST from a peer agent seat still passes the gate", () => {
212
+ for (const content of [
213
+ "did the migration land cleanly?",
214
+ "please push the fix to git",
215
+ "can you rerun the failing suite",
216
+ "fix the flaky poller test and push it",
217
+ ]) {
218
+ const v = shouldAcknowledge({ willSpawnSession: true, item: { ...PEER_ACK, content }, source: "inbox" });
219
+ assert.equal(v.ack, true, `"${content}" is substantive and must pass`);
220
+ }
221
+ });
222
+
223
+ test("an INFORMATION-BEARING terse line from a peer agent seat passes — facts, links, decisions and completions are not acks", () => {
224
+ // Re-review finding on d5abda3: the shape test alone consumed essentially
225
+ // every terse peer utterance — including decision grants and direct
226
+ // answers to questions this seat asked — and a false consume is silent to
227
+ // the sender and unrecoverable. Only commitment-shaped acks may be eaten.
228
+ for (const content of [
229
+ "Runway is 14 months.", // carries a fact (digit)
230
+ "Yes.", // direct answer
231
+ "No.", // direct answer
232
+ "Approved.", // decision grant
233
+ "Go ahead.", // decision grant
234
+ "LGTM.", // review verdict
235
+ "Ship it.", // decision grant
236
+ "Done.", // completion report
237
+ "Merged.", // completion report
238
+ "Deployed, all green.", // completion report
239
+ "Spec is at https://example.com/doc", // carries a link
240
+ ]) {
241
+ const v = shouldAcknowledge({ willSpawnSession: true, item: { ...PEER_ACK, content }, source: "inbox" });
242
+ assert.equal(v.ack, true, `"${content}" is information-bearing and must pass`);
243
+ }
244
+ });
245
+
246
+ test("REGRESSION: the daemon's OWN narrator lines are refused — built from the real compose* strings, so the list cannot drift", () => {
247
+ const rec = { openedAt: Date.now() - 6 * 60_000, progressSent: 0 };
248
+ const narrations = [
249
+ composeProgress(rec, {}), // "Still on this — N minutes in. …"
250
+ composeProgress({ ...rec, progressSent: 1 }, {}), // "Still going — N minutes in. …"
251
+ composeFailure({}, { failure: { human: "the working session ended unexpectedly" }, willRetry: true }),
252
+ composeFailure({}, { failure: { human: "the work never came back with a result and has now outrun its time limit" }, willRetry: false }),
253
+ assurance.composeInterrupted(),
254
+ composeSilentSuccess({}, {}),
255
+ composeSilentSuccess({}, { finalText: "Here is what I found before it died." }),
256
+ ];
257
+ for (const content of narrations) {
258
+ const v = shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, content }, source: "inbox" });
259
+ assert.equal(v.ack, false, `narrator line must be refused: "${content.slice(0, 50)}…"`);
260
+ assert.equal(v.reason, "own-narrator-pattern");
261
+ assert.equal(v.emitterClass, true);
262
+ }
263
+ });
264
+
265
+ test("a human QUOTING a narrator line inside a longer message still passes (anchored full match)", () => {
266
+ const content = `why did you send me this?\n\n${composeSilentSuccess({}, {})}`;
267
+ const v = shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, content }, source: "inbox" });
268
+ assert.equal(v.ack, true);
269
+ });
270
+
271
+ test("a genuine substantive ask is untouched by the gate", () => {
272
+ const v = shouldAcknowledge({ willSpawnSession: true, item: ITEM, source: "inbox" });
273
+ assert.equal(v.ack, true);
274
+ assert.equal(v.reason, "session-dispatched");
275
+ });
276
+
277
+ test("every refusal is AUDITED — one JSONL row, self-echo-drop style", async () => {
278
+ const { readFileSync, rmSync: rm } = await import("node:fs");
279
+ const auditPath = join(ROOT, "logs", "daemon", "dispatch-gate-drops.jsonl");
280
+ try { rm(auditPath, { force: true }); } catch { /* */ }
281
+ shouldAcknowledge({ willSpawnSession: true, item: PEER_ACK, source: "inbox" });
282
+ shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, content: composeSilentSuccess({}, {}) }, source: "inbox" });
283
+ const rows = readFileSync(auditPath, "utf-8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
284
+ assert.equal(rows.length, 2);
285
+ assert.equal(rows[0].stage, "dispatch-gate");
286
+ assert.equal(rows[0].reason, "peer-ack-shaped");
287
+ assert.equal(rows[0].sender, "Piotr Novak");
288
+ assert.equal(rows[1].reason, "own-narrator-pattern");
289
+ });
123
290
  });
124
291
 
125
292
  // ---------------------------------------------------------------------------
126
- // 2. THE ACKNOWLEDGEMENT — composed, not generated
293
+ // 2. THE ACKNOWLEDGEMENT — one bespoke generated line, or SILENCE. Never a
294
+ // canned rotation (CEO directive, 2026-08-30).
127
295
  // ---------------------------------------------------------------------------
128
296
 
129
- describe("composeAck", () => {
130
- test("is instant and deterministic — no model, no I/O, no timeout to lose", () => {
131
- const t0 = process.hrtime.bigint();
132
- const a = composeAck(ITEM, CLASS_COMPLEX);
133
- const elapsedMs = Number(process.hrtime.bigint() - t0) / 1e6;
134
- assert.ok(elapsedMs < 5, `ack composition took ${elapsedMs}ms; it must be effectively free`);
135
- assert.equal(a, composeAck(ITEM, CLASS_COMPLEX), "same input must give the same words");
297
+ describe("generateAck — bespoke line or silence, never boilerplate", () => {
298
+ test("the model's line is used verbatim once stripped to one clean line", async () => {
299
+ const a = await generateAck(ITEM, CLASS_COMPLEX, {
300
+ runImpl: async () => '```\n"Taking the poller fixes now — push coming."\n```',
301
+ });
302
+ assert.equal(a, "Taking the poller fixes now — push coming.");
303
+ });
304
+
305
+ test("REGRESSION: a failing generator yields NULL — the caller sends NOTHING, no canned fallback", async () => {
306
+ // The retired behaviour rotated "On it." / "Looking now." / "On it —
307
+ // digging in now." when convenient. The CEO's verdict: a reflex string is
308
+ // worse than no ack. Null is the contract for "say nothing".
309
+ const a = await generateAck(ITEM, CLASS_COMPLEX, {
310
+ runImpl: async () => { throw new Error("claude CLI spawn error: ENOENT"); },
311
+ });
312
+ assert.equal(a, null);
313
+ });
314
+
315
+ test("a HANGING generator is cut off at the budget and yields NULL", async () => {
316
+ const a = await generateAck(ITEM, CLASS_COMPLEX, {
317
+ timeoutMs: 25,
318
+ runImpl: () => new Promise(() => { /* never settles */ }),
319
+ });
320
+ assert.equal(a, null);
321
+ });
322
+
323
+ test("unusable output — empty, essay-length, refusals, assistant filler — yields NULL", () => {
324
+ assert.equal(sanitiseAckText(""), null);
325
+ assert.equal(sanitiseAckText(" \n\n "), null);
326
+ assert.equal(sanitiseAckText("x".repeat(300)), null, "a paragraph is a reply, not an ack");
327
+ assert.equal(sanitiseAckText("I cannot help with that request."), null);
328
+ assert.equal(sanitiseAckText("I'm here to help! Let me know if you need anything else."), null);
136
329
  });
137
330
 
138
- test("is one short human line — no filler, no promise-of-followup boilerplate", () => {
139
- const a = composeAck(ITEM, CLASS_COMPLEX);
140
- assert.ok(a.length <= 40, `an ack is one short line, got ${a.length} chars: ${a}`);
141
- // The templated filler communication-style.md bans.
142
- assert.doesNotMatch(a, /either way|full answer|within a few minutes|within 10-20|I want to get this right|hear from me|come back to you/i);
331
+ test("multi-line output is reduced to the FIRST usable line", () => {
332
+ assert.equal(
333
+ sanitiseAckText("Taking the poller fixes now.\nsecond line that must not leak"),
334
+ "Taking the poller fixes now.",
335
+ );
143
336
  });
144
337
 
145
- test("degrades gracefully when the classifier gave nothing to go on", () => {
146
- const a = composeAck(ITEM, {});
147
- assert.ok(a.length > 0);
148
- assert.doesNotMatch(a, /\(.*\)/, "no parenthetical aside");
338
+ test("REGRESSION: assistant-y openers are rejected — 'Sure — here it is:' is a chatbot, not a colleague", () => {
339
+ for (const t of [
340
+ "Sure — here it is:",
341
+ "Sure, taking a look at the git fixes.",
342
+ "Certainly! On the deck now.",
343
+ "Absolutely — picking the poller fix up.",
344
+ "Happy to — starting on the memo.",
345
+ "Of course, I'll dig into the runway numbers.",
346
+ ]) {
347
+ assert.equal(sanitiseAckText(t), null, `"${t}" must be rejected`);
348
+ }
349
+ // …while a plain colleague line still passes untouched.
350
+ assert.equal(sanitiseAckText("Taking the git fixes now — push coming."), "Taking the git fixes now — push coming.");
351
+ });
352
+
353
+ test("REGRESSION: the canned rotation is GONE from the module, not merely bypassed", async () => {
354
+ const { readFileSync } = await import("node:fs");
355
+ const src = readFileSync(new URL("./assurance.mjs", import.meta.url), "utf-8");
356
+ // The three retired strings may survive in comments as history, but never
357
+ // as a string-literal ARRAY a code path could pick from.
358
+ assert.doesNotMatch(src, /\[\s*"On it\.",/, "the canned rotation array must not exist");
359
+ });
360
+ });
361
+
362
+ describe("composeSilentSuccess — a finished answer is delivered, not silently clipped", () => {
363
+ const LIMIT = SILENT_SUCCESS_EXCERPT_LIMIT;
364
+
365
+ test("a normal multi-point answer (over the old 900 cap) comes through WHOLE", () => {
366
+ // The exact failure mode the old cap produced: a substantive multi-point
367
+ // answer, ~1200 chars — comfortably over the old 900 cap, well under the new
368
+ // budget. The reader must get every word, with no ellipsis and no marker.
369
+ const answer =
370
+ "Queue placement. A discrete task I own lands cleanest in action-stack.yaml, " +
371
+ "not follow-ups.yaml — the latter is external commitments with due dates, and " +
372
+ "this is a capture I own, not a promise to a counterparty. ".repeat(16).trim();
373
+ assert.ok(answer.length > 900 && answer.length < LIMIT, "fixture must straddle old cap / new budget");
374
+ const out = composeSilentSuccess({}, { finalText: answer });
375
+ assert.ok(out.includes(answer), "the full answer must be present verbatim");
376
+ assert.ok(!out.includes("…"), "a whole answer carries no truncation ellipsis");
149
377
  });
150
378
 
151
- test("REGRESSION: the classifier summary NEVER reaches the human — no '(CEO …)' bleed", () => {
152
- // Owner screenshot, verbatim: the ack carried the classifier's internal
153
- // summary in a parenthetical aside — "(CEO asking what machine/infrastructure
154
- // is being used)" reached a real human. The summary is now stored on the
155
- // obligation for the board row but never shown in a message anyone reads.
156
- const a = composeAck(ITEM, { ...CLASS_COMPLEX, summary: "CEO asking what machine/infrastructure is being used" });
157
- assert.doesNotMatch(a, /CEO asking/i);
158
- assert.doesNotMatch(a, /machine\/infrastructure/i);
159
- assert.doesNotMatch(a, /\(.*\)/, "no parenthetical aside at all");
379
+ test("a genuinely huge output is bounded but truncated HONESTLY — count + resend offer, never a bare ellipsis", () => {
380
+ const huge = "x".repeat(LIMIT + 2000);
381
+ const out = composeSilentSuccess({}, { finalText: huge });
382
+ assert.ok(out.length < huge.length, "oversized output is bounded, not dumped whole");
383
+ assert.ok(out.includes(String(huge.length)), "must state the true full length so nothing is lost silently");
384
+ assert.match(out, /resend|full/i, "must offer a path to the dropped remainder");
160
385
  });
161
386
 
162
- test("reads like a colleague, not a template — no internal framing shown to the human", () => {
163
- // "Picking up:" is triage vocabulary and "It needs a bit of work" is the
164
- // daemon explaining its own dispatch decision. Both leaked the machinery at
165
- // the person being acknowledged.
166
- const a = composeAck(ITEM, CLASS_COMPLEX);
167
- assert.doesNotMatch(a, /picking up:/i);
168
- assert.doesNotMatch(a, /needs a bit of work/i);
387
+ test("REGRESSION: a truncated recovery note is STILL recognised as the daemon's own narrator line (self-echo gate holds)", () => {
388
+ // If truncation broke narrator-recognition, the daemon would re-ingest and
389
+ // re-ack its own rescue message — the exact self-echo failure the gate exists
390
+ // to prevent. The honesty marker must live inside the variable excerpt slot.
391
+ const huge = "y".repeat(LIMIT + 2000);
392
+ const truncated = composeSilentSuccess({}, { finalText: huge });
393
+ assert.equal(isOwnNarratorText(truncated), true, "truncated silent-success must match a narrator pattern");
169
394
  });
170
395
  });
171
396
 
@@ -223,6 +448,34 @@ describe("openAndAcknowledge", () => {
223
448
  assert.equal(openObligations().length, 1, "the debt must be visible to the sweep");
224
449
  });
225
450
 
451
+ test("REGRESSION: generation failure at open ⇒ NOTHING is sent, and the debt records why", async () => {
452
+ // sendHoldingMessage's new contract on a failed generation: {sent:false,
453
+ // holdingText:null, genFailed:true}. Silence — never a placeholder.
454
+ const t = fakeTransport({ genFail: true });
455
+ const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
456
+ assert.equal(r.acked, false);
457
+ assert.equal(r.ackText, null, "no text was composed — there is nothing canned to fall back to");
458
+ assert.equal(t.sent.length, 0, "not one byte reached the human");
459
+ const rec = readObligation(r.key);
460
+ assert.equal(rec.acknowledged, false);
461
+ assert.equal(rec.ackGenFailures, 1, "the failure is counted, so the sweep's retry is bounded");
462
+ assert.equal(rec.state, "open", "the debt survives — progress/failure notices still owe the human");
463
+ });
464
+
465
+ test("the DEFAULT ack path generates before sending — generation failure short-circuits transport entirely", async () => {
466
+ // No injected ackSender here: this drives openAndAcknowledge's own default,
467
+ // which must consult generateAck FIRST and never touch transport on null.
468
+ const r = await openAndAcknowledge({
469
+ item: ITEM,
470
+ classResult: CLASS_COMPLEX,
471
+ deps: { ackGen: { runImpl: async () => { throw new Error("model down"); } } },
472
+ });
473
+ assert.equal(r.acked, false);
474
+ assert.equal(r.reason, "ack-generation-failed");
475
+ assert.equal(r.ackText, null);
476
+ assert.equal(readObligation(r.key).state, "open");
477
+ });
478
+
226
479
  test("the stored summary keeps the classifier's casing — the board row and escalations read from it", async () => {
227
480
  // `rec.summary` feeds board-mirror's row title and the needs-attention
228
481
  // escalation record. A summary mangled at open time would resurface there.
@@ -375,19 +628,46 @@ describe("settleSession", () => {
375
628
  // ---------------------------------------------------------------------------
376
629
 
377
630
  describe("sweepObligations", () => {
378
- test("retries an acknowledgement whose send failed", async () => {
631
+ test("retries an acknowledgement whose send failed — with a freshly GENERATED line, not a canned one", async () => {
379
632
  const failing = fakeTransport({ failTimes: 99 });
380
633
  const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: failing.ackSender } });
381
634
  assert.equal(r.acked, false);
382
635
 
383
636
  const working = fakeTransport();
384
637
  const now = Date.now() + ACK_GRACE_MS + 1000;
385
- const stats = await sweepObligations({ now, deps: { deliverImpl: working.impl, spokeSinceImpl: () => false } });
638
+ const stats = await sweepObligations({ now, deps: {
639
+ deliverImpl: working.impl,
640
+ spokeSinceImpl: () => false,
641
+ generateAckImpl: async () => "Back on the git fixes now.",
642
+ } });
386
643
  assert.equal(stats.acked, 1);
387
- assert.match(working.sent[0].text, /on it|looking now|digging in/i);
644
+ assert.equal(working.sent[0].text, "Back on the git fixes now.");
645
+ assert.doesNotMatch(working.sent[0].text, /^(on it|looking now)/i, "never the retired rotation");
388
646
  assert.equal(readObligation(r.key).acknowledged, true);
389
647
  });
390
648
 
649
+ test("REGRESSION: a failed generation in the sweep sends NOTHING — bounded, then given up on", async () => {
650
+ const t = fakeTransport({ genFail: true }); // the open-time generation already failed once
651
+ const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
652
+ assert.equal(r.acked, false);
653
+
654
+ let genCalls = 0;
655
+ const t0 = Date.now();
656
+ for (let m = 1; m <= 8; m++) {
657
+ await sweepObligations({ now: t0 + ACK_GRACE_MS + m * 60_000, deps: {
658
+ deliverImpl: t.impl,
659
+ spokeSinceImpl: () => false,
660
+ generateAckImpl: async () => { genCalls++; return null; },
661
+ } });
662
+ }
663
+ assert.equal(t.sent.filter((s) => s.kind === "ack").length, 0, "no canned line went out in place of the failed generation");
664
+ assert.ok(genCalls < ACK_GEN_MAX_FAILURES + 1, `generation retries are bounded, not once-a-tick forever (saw ${genCalls})`);
665
+ const rec = readObligation(r.key);
666
+ assert.equal(rec.ackSuppressed, true, "the ack is given up on…");
667
+ assert.equal(rec.state, "open", "…but the DEBT is not — progress and failure notices still speak");
668
+ assert.ok(t.sent.some((s) => s.kind === "progress"), "and indeed the human still hears about the long-running work");
669
+ });
670
+
391
671
  test("long work gets an interim update, not silence", async () => {
392
672
  const t = fakeTransport();
393
673
  const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
@@ -493,6 +773,91 @@ describe("sweepObligations", () => {
493
773
  assert.equal(stats.interrupted, 0);
494
774
  });
495
775
 
776
+ test("REGRESSION: a debt OWNED by another LIVE daemon is not ack-retried by this one (two-daemon narration)", async () => {
777
+ // Two live daemons on one AGENT_DIR both sweep the same ledger. The one
778
+ // that did NOT open this debt must not compensate its acknowledgement —
779
+ // the owner is alive and will; two daemons acking is one human hearing
780
+ // double.
781
+ const failing = fakeTransport({ failTimes: 99 });
782
+ const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: failing.ackSender } });
783
+ assert.equal(r.acked, false);
784
+ const rec = readObligation(r.key);
785
+ rec.daemonPid = process.pid + 99999;
786
+ const { writeFileSync } = await import("node:fs");
787
+ writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
788
+
789
+ const working = fakeTransport();
790
+ const stats = await sweepObligations({
791
+ now: Date.now() + ACK_GRACE_MS + 1000,
792
+ deps: {
793
+ deliverImpl: working.impl,
794
+ spokeSinceImpl: () => false,
795
+ generateAckImpl: async () => "Back on this now.",
796
+ isPidAliveImpl: () => true, // the owner is provably alive
797
+ },
798
+ });
799
+ assert.equal(stats.acked, 0, "the live owner narrates its own debt");
800
+ assert.equal(working.sent.length, 0, "not one byte from the non-owner");
801
+ assert.equal(readObligation(r.key).acknowledged, false);
802
+ });
803
+
804
+ test("…but once the owning daemon is DEAD, this daemon takes the ack over", async () => {
805
+ const failing = fakeTransport({ failTimes: 99 });
806
+ const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: failing.ackSender } });
807
+ const rec = readObligation(r.key);
808
+ rec.daemonPid = process.pid + 99999;
809
+ const { writeFileSync } = await import("node:fs");
810
+ writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
811
+
812
+ const working = fakeTransport();
813
+ const stats = await sweepObligations({
814
+ now: Date.now() + ACK_GRACE_MS + 1000,
815
+ deps: {
816
+ deliverImpl: working.impl,
817
+ spokeSinceImpl: () => false,
818
+ generateAckImpl: async () => "Picking your ask up now.",
819
+ isPidAliveImpl: () => false, // the owner is gone
820
+ },
821
+ });
822
+ assert.equal(stats.acked, 1, "an orphaned debt is this daemon's to speak to");
823
+ assert.equal(readObligation(r.key).acknowledged, true);
824
+ });
825
+
826
+ test("REGRESSION: a debt OWNED by another LIVE daemon gets no progress ping from this one", async () => {
827
+ const t = fakeTransport();
828
+ const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
829
+ const rec = readObligation(r.key);
830
+ rec.daemonPid = process.pid + 99999;
831
+ const { writeFileSync } = await import("node:fs");
832
+ writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
833
+
834
+ const before = t.sent.length;
835
+ const stats = await sweepObligations({
836
+ now: Date.now() + PROGRESS_AFTER_MS + 1000,
837
+ deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => true },
838
+ });
839
+ assert.equal(stats.progressed, 0, "the live owner sends its own progress updates");
840
+ assert.equal(stats.interrupted, 0, "and a live owner is certainly not 'interrupted'");
841
+ assert.equal(t.sent.length, before);
842
+ });
843
+
844
+ test("…and a dead owner's debt DOES get this daemon's progress ping (after the interrupted notice is done)", async () => {
845
+ const t = fakeTransport();
846
+ const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
847
+ const rec = readObligation(r.key);
848
+ rec.daemonPid = process.pid + 99999;
849
+ rec.interruptedNotifiedAt = Date.now(); // the orphan apology already went out
850
+ const { writeFileSync } = await import("node:fs");
851
+ writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
852
+
853
+ const stats = await sweepObligations({
854
+ now: Date.now() + PROGRESS_AFTER_MS + 1000,
855
+ deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => false },
856
+ });
857
+ assert.equal(stats.progressed, 1);
858
+ assert.match(t.sent.at(-1).text, /still on this/i);
859
+ });
860
+
496
861
  test("each distinct message carries a distinct idempotency suffix", async () => {
497
862
  // hq de-dups server-side on the client message id. Two progress updates that
498
863
  // both key on "progress" are ONE message as far as hq is concerned, so the
@@ -519,7 +884,7 @@ describe("sweepObligations", () => {
519
884
  const before = t.sent.length;
520
885
  // Old enough to qualify for BOTH the ack retry and a progress update.
521
886
  const now = Date.now() + PROGRESS_AFTER_MS + 1000;
522
- await sweepObligations({ now, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false } });
887
+ await sweepObligations({ now, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE } });
523
888
  assert.equal(t.sent.length - before, 1, "two messages in one tick reads as a malfunction");
524
889
  });
525
890
  });
@@ -639,10 +1004,10 @@ describe("attribution: which debt did that message actually discharge", () => {
639
1004
  assert.equal(readObligation(r.key).acknowledged, false);
640
1005
  receipts.recordOutbound({ service: "cohort", channel: "C-PRE", kind: "reply", agentRoot: ROOT });
641
1006
 
642
- const stats = await sweepObligations({ now: Date.now() + ACK_GRACE_MS + 1000, deps: { deliverImpl: t.impl } });
1007
+ const stats = await sweepObligations({ now: Date.now() + ACK_GRACE_MS + 1000, deps: { deliverImpl: t.impl, generateAckImpl: async () => "Finally picking your ask up now." } });
643
1008
  assert.equal(stats.closed, 0, "no session of ours had run — that message was not our answer");
644
1009
  assert.equal(stats.acked, 1, "instead the missing acknowledgement is finally sent");
645
- assert.match(t.sent.at(-1).text, /on it|looking now|digging in/i);
1010
+ assert.equal(t.sent.at(-1).text, "Finally picking your ask up now.");
646
1011
  });
647
1012
  });
648
1013
 
@@ -690,7 +1055,7 @@ describe("channels with no transport", () => {
690
1055
  const t0 = Date.now();
691
1056
  let ticks = 0;
692
1057
  for (let m = 1; m <= 60; m++) {
693
- const s = await sweepObligations({ now: t0 + m * 60_000, deps: { deliverImpl: t.impl } });
1058
+ const s = await sweepObligations({ now: t0 + m * 60_000, deps: { deliverImpl: t.impl, generateAckImpl: async () => FAKE_ACK_LINE } });
694
1059
  ticks++;
695
1060
  if (s.unreachable) break;
696
1061
  }