@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
@@ -13,7 +13,7 @@ import assert from "node:assert/strict";
13
13
  import { execFile, execFileSync } from "node:child_process";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { dirname, join } from "node:path";
16
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
16
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
17
17
  import { tmpdir } from "node:os";
18
18
 
19
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -213,3 +213,142 @@ test("daily cap blocks once exceeded (exit 2)", { skip: JQ ? false : "jq not on
213
213
  assert.equal(r2.code, 2, `send 2 expected 2 (daily cap), got ${r2.code}`);
214
214
  assert.match(r2.stderr, /Daily send limit/);
215
215
  });
216
+
217
+ // ── WP-M4: persona-leak patterns (front-door session §3.6) ─────────────────
218
+ //
219
+ // Outbound text may never expose the machinery: "Claude Code", "Claude
220
+ // session", "sub-session", a subagent that "said/reported", "workflow agent",
221
+ // "as an AI" / "language model", or an internal session name (<first>-main,
222
+ // <first>-<purpose>). The hook returns the offending phrase so the session can
223
+ // rewrite in its own voice.
224
+
225
+ const PERSONA_ENV = { MAESTRO_AGENT_FIRST: "alex" };
226
+
227
+ for (const [label, text] of [
228
+ ["Claude Code", "I'll have Claude Code pick that up this afternoon."],
229
+ ["Claude session", "A separate claude session is running the numbers."],
230
+ ["sub-session", "I kicked off a sub-session for the deck."],
231
+ ["subagent said", "The subagent said the deploy finished."],
232
+ ["sub-agent reported", "My sub-agent reported two blockers."],
233
+ ["subagent session", "The subagent session is still going."],
234
+ ["workflow agent", "The workflow agent will file the summary."],
235
+ ["language model", "Every language model has limits, mine included."],
236
+ ["<first>-main", "Check with alex-main for the latest."],
237
+ ]) {
238
+ test(`persona leak "${label}" blocks (exit 2) and names the phrase`, { skip: JQ ? false : "jq not on PATH" }, async () => {
239
+ const payload = JSON.stringify({ tool_name: "mcp__cohort__messaging_send", tool_input: { channelId: "C1", body: text } });
240
+ const { code, stderr } = await runHook(payload, { channel: "cohort", env: PERSONA_ENV });
241
+ assert.equal(code, 2, `expected exit 2 for "${label}", got ${code}`);
242
+ assert.match(stderr, /BLOCKED/);
243
+ assert.match(stderr, /persona/i);
244
+ });
245
+ }
246
+
247
+ test("persona: the leak line names the offending phrase so the session can rewrite", { skip: JQ ? false : "jq not on PATH" }, async () => {
248
+ const payload = JSON.stringify({ tool_input: { channelId: "C1", body: "I'll have Claude Code pick that up." } });
249
+ const { stderr } = await runHook(payload, { channel: "cohort", env: PERSONA_ENV });
250
+ assert.match(stderr, /claude code/i);
251
+ });
252
+
253
+ test("persona: colleague phrasing passes; bare 'subagent' without said/reported/session passes; mid-word matches ignored", { skip: JQ ? false : "jq not on PATH" }, async () => {
254
+ for (const text of [
255
+ "One of my analysts is on it; I'll have the numbers by 3.",
256
+ "My team has the deck in review — sending the draft after lunch.",
257
+ "We use subagents internally for the heavy lifting.",
258
+ "Alexandra-Maine sent the invoice.",
259
+ "The session on Thursday (the board session) starts at 9.",
260
+ ]) {
261
+ const payload = JSON.stringify({ tool_input: { channelId: "C1", body: text } });
262
+ const { code, stderr } = await runHook(payload, { channel: "cohort", env: PERSONA_ENV });
263
+ assert.equal(code, 0, `expected exit 0 for "${text}", got ${code}: ${stderr}`);
264
+ }
265
+ });
266
+
267
+ test("persona: session-name pattern reads the first name from AGENT_ROOT/config/agent.json when the env is unset", { skip: JQ ? false : "jq not on PATH" }, async () => {
268
+ const dir = mkdtempSync(join(tmpdir(), "presend-agent-"));
269
+ try {
270
+ const cfg = join(dir, "config");
271
+ mkdirSyncP(cfg);
272
+ writeFileSync(join(cfg, "agent.json"), JSON.stringify({ firstName: "Robin", fullName: "Robin Park" }));
273
+ const payload = JSON.stringify({ tool_input: { channelId: "C1", body: "robin-main will chase it." } });
274
+ const { code, stderr } = await runHook(payload, { channel: "cohort", env: { AGENT_ROOT: dir } });
275
+ assert.equal(code, 2, `expected exit 2, got ${code}: ${stderr}`);
276
+ assert.match(stderr, /robin-main/);
277
+ // No first name resolvable anywhere → the name pattern is skipped (fail-open on that one check), the rest still applies.
278
+ const { code: c2 } = await runHook(payload, { channel: "cohort", env: {} });
279
+ assert.equal(c2, 0);
280
+ } finally { rmSync(dir, { recursive: true, force: true }); }
281
+ });
282
+
283
+ // ── Review fix: session names are BOUNDED (spec §3.6 gap) ──────────────────
284
+ // `<first>-main` is always internal; any other `<first>-<x>` is internal only
285
+ // when it is a registered peer (state/session/peers.json under AGENT_ROOT) or
286
+ // listed in MAESTRO_SESSION_NAMES. A colleague's hyphenated name, a URL slug or
287
+ // an email local part that happens to start with the first name must pass.
288
+
289
+ test("persona: an unregistered <first>-<x> is ordinary text; a registered peer name (peers.json) or MAESTRO_SESSION_NAMES entry blocks", { skip: JQ ? false : "jq not on PATH" }, async () => {
290
+ const dir = mkdtempSync(join(tmpdir(), "presend-peers-"));
291
+ try {
292
+ const payload = JSON.stringify({ tool_input: { channelId: "C1", body: "alex-research has the draft ready." } });
293
+ const { code: c0, stderr: e0 } = await runHook(payload, { channel: "cohort", env: PERSONA_ENV });
294
+ assert.equal(c0, 0, `unregistered alex-research must pass: ${e0}`);
295
+
296
+ mkdirSyncP(join(dir, "state", "session"));
297
+ writeFileSync(join(dir, "state", "session", "peers.json"), JSON.stringify([{ name: "alex-research", muxName: "maestro-alex-research", purpose: "research" }]));
298
+ const { code: c1, stderr: e1 } = await runHook(payload, { channel: "cohort", env: { ...PERSONA_ENV, AGENT_ROOT: dir } });
299
+ assert.equal(c1, 2, `registered peer must block, got ${c1}: ${e1}`);
300
+ assert.match(e1, /alex-research/);
301
+
302
+ // {peers:[…]} shape is accepted too.
303
+ writeFileSync(join(dir, "state", "session", "peers.json"), JSON.stringify({ peers: [{ name: "alex-deck" }] }));
304
+ const deck = JSON.stringify({ tool_input: { channelId: "C1", body: "alex-deck is nearly done." } });
305
+ assert.equal((await runHook(deck, { channel: "cohort", env: { ...PERSONA_ENV, AGENT_ROOT: dir } })).code, 2);
306
+ assert.equal((await runHook(payload, { channel: "cohort", env: { ...PERSONA_ENV, AGENT_ROOT: dir } })).code, 0, "alex-research is no longer registered");
307
+
308
+ // A corrupt registry is an empty one — never a fail-closed on the whole send.
309
+ writeFileSync(join(dir, "state", "session", "peers.json"), "{nope");
310
+ assert.equal((await runHook(deck, { channel: "cohort", env: { ...PERSONA_ENV, AGENT_ROOT: dir } })).code, 0);
311
+
312
+ // Env override (comma-separated), no files needed.
313
+ const { code: c2, stderr: e2 } = await runHook(payload, { channel: "cohort", env: { ...PERSONA_ENV, MAESTRO_SESSION_NAMES: "alex-research, alex-ops" } });
314
+ assert.equal(c2, 2, `MAESTRO_SESSION_NAMES must block, got ${c2}: ${e2}`);
315
+ } finally { rmSync(dir, { recursive: true, force: true }); }
316
+ });
317
+
318
+ test("persona: a colleague's hyphenated name, a URL slug and an email local part starting with the first name all pass", { skip: JQ ? false : "jq not on PATH" }, async () => {
319
+ for (const [first, text] of [
320
+ ["marc", "Marc-Antoine confirmed the numbers."],
321
+ ["james", "See https://os.cohortapp.com/spaces/james-kirkland for the thread."],
322
+ ["ethan", "Loop in ethan-miller@example.com on the invoice."],
323
+ ["alex", "alex-mainland is the venue; alex-main-street too."],
324
+ ]) {
325
+ const payload = JSON.stringify({ tool_input: { channelId: "C1", body: text } });
326
+ const { code, stderr } = await runHook(payload, { channel: "cohort", env: { MAESTRO_AGENT_FIRST: first } });
327
+ assert.equal(code, 0, `expected exit 0 for "${text}" (first=${first}), got ${code}: ${stderr}`);
328
+ }
329
+ // …while the real session name still blocks, whatever the case.
330
+ const { code } = await runHook(JSON.stringify({ tool_input: { channelId: "C1", body: "Ask ALEX-MAIN." } }), { channel: "cohort", env: PERSONA_ENV });
331
+ assert.equal(code, 2);
332
+ });
333
+
334
+ // ── Review fix: the hook now fronts every mcp__cohort outbound tool ──────────
335
+ // org_rpc carries the body under tool_input.params; email_send under text (+
336
+ // subject); email_draft_send under bodyText (+ subject). All must be screened.
337
+
338
+ test("persona: org_rpc (params.body), email_send (subject+text) and email_draft_send (bodyText) payload shapes are screened", { skip: JQ ? false : "jq not on PATH" }, async () => {
339
+ const cases = [
340
+ ["org_rpc", { tool_name: "mcp__cohort__org_rpc", tool_input: { method: "messaging.send", params: { channelId: "C1", body: "My sub-session is on it." } } }],
341
+ ["email_send text", { tool_name: "mcp__cohort__email_send", tool_input: { to: ["a@b.com"], subject: "Update", text: "Claude Code finished the deploy." } }],
342
+ ["email_send subject", { tool_name: "mcp__cohort__email_send", tool_input: { to: ["a@b.com"], subject: "From alex-main", text: "Deploy finished." } }],
343
+ ["email_draft_send bodyText", { tool_name: "mcp__cohort__email_draft_send", tool_input: { to: ["a@b.com"], subject: "Update", bodyText: "The workflow agent will send the summary." } }],
344
+ ];
345
+ for (const [label, payload] of cases) {
346
+ const { code, stderr } = await runHook(JSON.stringify(payload), { channel: "cohort", env: PERSONA_ENV });
347
+ assert.equal(code, 2, `expected exit 2 for ${label}, got ${code}: ${stderr}`);
348
+ assert.match(stderr, /persona/i);
349
+ }
350
+ const clean = { tool_name: "mcp__cohort__org_rpc", tool_input: { method: "messaging.send", params: { channelId: "C1", body: "Numbers are in; deck by 3." } } };
351
+ assert.equal((await runHook(JSON.stringify(clean), { channel: "cohort", env: PERSONA_ENV })).code, 0);
352
+ });
353
+
354
+ function mkdirSyncP(p) { mkdirSync(p, { recursive: true }); }
@@ -4,9 +4,11 @@
4
4
  # =============================================================================
5
5
  # Runs hourly via the ai.maestro.<first>-autoupdate launchd job. Policy is
6
6
  # "auto to @latest": whenever npm's latest is a strictly-greater semver than the
7
- # installed SDK, this installs it, smart-merges the copied framework files
8
- # (`maestro upgrade`), restarts the daemon, and VERIFIES the daemon comes back
9
- # healthy — rolling back to the prior version if it does not.
7
+ # installed SDK, this installs it, runs the NEW package's `maestro upgrade`
8
+ # (which smart-merges the framework files AND reconciles the seat: plists,
9
+ # launchd jobs, global-setup, the global npm install — see
10
+ # lib/upgrade/post-steps.mjs), restarts the daemon, and VERIFIES the daemon
11
+ # comes back healthy — rolling back to the prior version if it does not.
10
12
  #
11
13
  # Safety rails:
12
14
  # - kill-switch: `touch ~/.maestro-no-autoupdate` (or $AGENT_DIR/.no-autoupdate)
@@ -14,17 +16,64 @@
14
16
  # - jitter 0-600s so 15 machines don't hit npm / restart in lockstep.
15
17
  # - re-exec from a /tmp copy so `maestro upgrade` overwriting THIS file
16
18
  # mid-run cannot corrupt the running shell.
17
- # - health-gate + automatic rollback to the previous version on a bad release.
19
+ # - overlap lock: state/locks/autoupdate/ (mkdir, pid inside; stale after 2 h)
20
+ # so a slow npm on one tick cannot race the next tick.
21
+ # - npm view / install retried 3x with backoff (--no-audit --no-fund --prefer-online).
22
+ # - health-gate + automatic rollback to the previous version on a bad release;
23
+ # the rollback also puts the global install back to the previous version
24
+ # and reverts the -session job IF THIS RUN installed it (never a
25
+ # pre-existing one). What a rollback does NOT undo: files the new version
26
+ # added that the old manifest never listed stay on disk, and the old
27
+ # upgrade rewrites the manifest without sdkVersion — `maestro doctor` /
28
+ # `upgrade --verify` on the next healthy hop report both.
29
+ # - a version that failed the health gate here is NOT retried every hour:
30
+ # state/autoupdate/last.json remembers it and the run holds for 24 h
31
+ # (MAESTRO_AUTOUPDATE_FAILED_HOLD_S; delete last.json to retry now).
32
+ #
33
+ # The health gate is the DAEMON: alive + a fresh "org-mesh connected" /
34
+ # "[daemon] Running" line. When the generated plist set carries a -session
35
+ # job, the gate ALSO checks the session label is loaded and a supervisor is
36
+ # alive — but a missing session is logged as `reconcile-failed` and does NOT
37
+ # roll back (the daemon's --print lane is the front door meanwhile; doctor
38
+ # and `maestro upgrade --verify` say what to do).
39
+ #
40
+ # The front-door session (ai.maestro.<first>-session) is NEVER restarted or
41
+ # killed here: after a HEALTHY upgrade this writes
42
+ # state/session/upgrade-notice.json {from,to,at}; the session's feed surfaces
43
+ # it as an `upgrade-available` directive and the session restarts itself at an
44
+ # idle moment via `maestro session restart` (the supervisor relaunches on the
45
+ # new code). A rollback leaves no notice. Every attempt also writes
46
+ # state/autoupdate/last.json {from,to,at,ok,healthy,reason}, which the beat
47
+ # reports as machine.upgrade. See
48
+ # docs/superpowers/specs/2026-09-08-front-door-session-design.md §3.10 + WP-M6.
49
+ #
50
+ # Every launchd / pgrep question is scoped to THIS agent (its first name from
51
+ # config/agent.json, else its own generated plist set): two seats on one Mac
52
+ # must not gate on — or kickstart — each other's jobs.
53
+ #
54
+ # Test knobs (scripts/local-triggers/autoupdate.test.mjs): MAESTRO_AUTOUPDATE_JITTER_MAX
55
+ # (default 600) and MAESTRO_AUTOUPDATE_HEALTH_WAIT (default 30), both seconds;
56
+ # MAESTRO_AUTOUPDATE_RETRY_SLEEP (default 20 s, ×attempt); MAESTRO_AUTOUPDATE_LOCK_STALE_S
57
+ # (default 7200); MAESTRO_AUTOUPDATE_FAILED_HOLD_S (default 86400);
58
+ # MAESTRO_AUTOUPDATE_PATH_PREFIX (a stub-binary dir that wins over PATH);
59
+ # MAESTRO_SKIP_GLOBAL_INSTALL=1 (never touch the global install).
18
60
  # =============================================================================
19
61
  set -uo pipefail
20
- export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH:-}"
62
+ # MAESTRO_AUTOUPDATE_PATH_PREFIX: a dir that wins over the standard prepend
63
+ # (the test puts its npm/launchctl/pgrep stubs there so a run never reaches
64
+ # the real registry or launchd).
65
+ export PATH="${MAESTRO_AUTOUPDATE_PATH_PREFIX:+$MAESTRO_AUTOUPDATE_PATH_PREFIX:}/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH:-}"
21
66
 
22
67
  # Re-exec from a stable copy: `maestro upgrade` may overwrite this very file.
68
+ # The copy removes itself on exit (REEXEC_COPY is only ever a mktemp'd
69
+ # maestro-autoupdate.* file — never the agent dir's script).
23
70
  if [ "${MAESTRO_AUTOUPDATE_REEXEC:-}" != "1" ]; then
24
71
  _tmp="$(mktemp "${TMPDIR:-/tmp}/maestro-autoupdate.XXXXXX")" || exit 0
25
72
  cp "${BASH_SOURCE[0]}" "$_tmp" 2>/dev/null || exit 0
26
73
  MAESTRO_AUTOUPDATE_REEXEC=1 exec /bin/bash "$_tmp" "$@"
27
74
  fi
75
+ REEXEC_COPY=""
76
+ case "${BASH_SOURCE[0]}" in */maestro-autoupdate.??????) REEXEC_COPY="${BASH_SOURCE[0]}" ;; esac
28
77
 
29
78
  AGENT_DIR="${AGENT_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
30
79
  cd "$AGENT_DIR" 2>/dev/null || exit 0
@@ -38,46 +87,221 @@ if [ -e "$HOME/.maestro-no-autoupdate" ] || [ -e "$AGENT_DIR/.no-autoupdate" ];
38
87
  log "SKIP: kill-switch present"; exit 0
39
88
  fi
40
89
 
90
+ # ── overlap lock ─────────────────────────────────────────────────────────────
91
+ # mkdir is atomic; the pid inside says who holds it. A holder that is dead, or
92
+ # older than LOCK_STALE_S (a wedged npm), is reclaimed rather than honoured.
93
+ LOCK_DIR="$AGENT_DIR/state/locks/autoupdate"
94
+ LOCK_STALE_S="${MAESTRO_AUTOUPDATE_LOCK_STALE_S:-7200}"
95
+ take_lock(){
96
+ mkdir -p "$AGENT_DIR/state/locks" 2>/dev/null
97
+ if mkdir "$LOCK_DIR" 2>/dev/null; then echo $$ > "$LOCK_DIR/pid"; return 0; fi
98
+ local pid mtime now age
99
+ pid="$(cat "$LOCK_DIR/pid" 2>/dev/null)"
100
+ mtime="$(stat -f %m "$LOCK_DIR" 2>/dev/null || stat -c %Y "$LOCK_DIR" 2>/dev/null || echo 0)"
101
+ now="$(date +%s)"; age=$(( now - mtime ))
102
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && [ "$age" -lt "$LOCK_STALE_S" ]; then
103
+ log "SKIP: another autoupdate (pid $pid) holds $LOCK_DIR (${age}s old)"; return 1
104
+ fi
105
+ log "reclaiming stale autoupdate lock (pid ${pid:-?}, ${age}s old)"
106
+ rm -rf "$LOCK_DIR" 2>/dev/null
107
+ mkdir "$LOCK_DIR" 2>/dev/null || return 1
108
+ echo $$ > "$LOCK_DIR/pid"; return 0
109
+ }
110
+ release_lock(){ rm -rf "$LOCK_DIR" 2>/dev/null; }
111
+ remove_reexec_copy(){ [ -n "$REEXEC_COPY" ] && rm -f "$REEXEC_COPY" 2>/dev/null; return 0; }
112
+ cleanup(){ release_lock; remove_reexec_copy; }
113
+ trap remove_reexec_copy EXIT # a run that yields to another holder must not release THAT holder's lock
114
+ take_lock || exit 0
115
+ trap cleanup EXIT
116
+
41
117
  # ── jitter ───────────────────────────────────────────────────────────────────
42
- J=$(( RANDOM % 601 )); log "wake; jitter ${J}s"; sleep "$J"
118
+ JITTER_MAX="${MAESTRO_AUTOUPDATE_JITTER_MAX:-600}"
119
+ J=$(( RANDOM % (JITTER_MAX + 1) )); log "wake; jitter ${J}s"; [ "$J" -gt 0 ] && sleep "$J"
120
+
121
+ # ── retry helper (npm view / install) ────────────────────────────────────────
122
+ RETRIES=3
123
+ RETRY_SLEEP="${MAESTRO_AUTOUPDATE_RETRY_SLEEP:-20}"
124
+ retry(){ # $1 = label; rest = command (stdout+stderr → LOG)
125
+ local label="$1"; shift
126
+ local attempt=1 wait
127
+ while :; do
128
+ if "$@" >> "$LOG" 2>&1; then return 0; fi
129
+ if [ "$attempt" -ge "$RETRIES" ]; then log "$label: failed after $attempt attempt(s)"; return 1; fi
130
+ wait=$(( RETRY_SLEEP * attempt ))
131
+ log "$label: attempt $attempt failed; retrying in ${wait}s"
132
+ sleep "$wait"; attempt=$(( attempt + 1 ))
133
+ done
134
+ }
135
+ npm_view_latest(){ # prints the latest version, or nothing after RETRIES attempts
136
+ local attempt=1 out wait
137
+ while :; do
138
+ out="$(npm view "$PKG" version --no-audit --no-fund --prefer-online 2>>"$LOG" | tail -1)"
139
+ if [ -n "$out" ]; then printf '%s' "$out"; return 0; fi
140
+ if [ "$attempt" -ge "$RETRIES" ]; then return 1; fi
141
+ wait=$(( RETRY_SLEEP * attempt ))
142
+ log "npm view: attempt $attempt failed; retrying in ${wait}s"
143
+ sleep "$wait"; attempt=$(( attempt + 1 ))
144
+ done
145
+ }
43
146
 
44
147
  # ── version check ────────────────────────────────────────────────────────────
45
148
  CUR="$(node -p "require('$AGENT_DIR/node_modules/$PKG/package.json').version" 2>/dev/null || echo 0.0.0)"
46
- LATEST="$(npm view "$PKG" version 2>/dev/null || echo '')"
47
- [ -z "$LATEST" ] && { log "npm view failed; skip (still $CUR)"; exit 0; }
149
+ LATEST="$(npm_view_latest)" || LATEST=""
150
+ [ -z "$LATEST" ] && { log "npm view failed after $RETRIES attempts; skip (still $CUR)"; exit 0; }
48
151
  if [ "$CUR" = "$LATEST" ] || [ "$(printf '%s\n%s\n' "$CUR" "$LATEST" | sort -V | tail -1)" != "$LATEST" ]; then
49
152
  log "up to date ($CUR)"; exit 0
50
153
  fi
154
+
155
+ # ── failed-target hold ───────────────────────────────────────────────────────
156
+ # last.json is the memory of the health gate: a LATEST that was installed here
157
+ # and rolled back (or could not even be rolled back) is not retried every hour
158
+ # — that would be two installs, two global refreshes and two daemon restarts an
159
+ # hour until someone touched the kill-switch. An install-failed (network) entry
160
+ # does not hold. Fail-open: an unreadable last.json holds nothing.
161
+ FAILED_HOLD_S="${MAESTRO_AUTOUPDATE_FAILED_HOLD_S:-86400}"
162
+ failed_hold_reason(){ # prints "<reason> at <at>" when LATEST failed health within FAILED_HOLD_S
163
+ node -e '
164
+ const [file, latest, holdS] = process.argv.slice(1);
165
+ try {
166
+ const j = JSON.parse(require("fs").readFileSync(file, "utf8"));
167
+ const at = Date.parse(j.at);
168
+ if (j.to === latest && j.ok === false && /unhealthy/.test(String(j.reason)) && Number.isFinite(at) && Date.now() - at < Number(holdS) * 1000) process.stdout.write(`${j.reason} at ${j.at}`);
169
+ } catch {}
170
+ ' "$AGENT_DIR/state/autoupdate/last.json" "$LATEST" "$FAILED_HOLD_S" 2>/dev/null
171
+ }
172
+ HOLD="$(failed_hold_reason)"
173
+ if [ -n "$HOLD" ]; then
174
+ log "SKIP: $LATEST already failed the health gate here ($HOLD); holding ${FAILED_HOLD_S}s (still $CUR) — rm state/autoupdate/last.json to retry now"
175
+ exit 0
176
+ fi
51
177
  log "UPDATE $CUR -> $LATEST"
52
178
 
53
- DAEMON_PLIST="$(ls "$HOME"/Library/LaunchAgents/ai.maestro.*-daemon.plist 2>/dev/null | head -1)"
54
- DAEMON_LABEL="$(basename "$DAEMON_PLIST" .plist 2>/dev/null)"
55
179
  UID_="$(id -u)"
180
+ AGENT_FIRST="$(node -p "String(require('$AGENT_DIR/config/agent.json').firstName || '').toLowerCase()" 2>/dev/null)"
181
+ [ -n "$AGENT_FIRST" ] && log "agent: $AGENT_FIRST (launchd/pgrep questions are scoped to ai.maestro.$AGENT_FIRST-*)"
56
182
  DLOG="$AGENT_DIR/logs/daemon/daemon-$(date +%Y-%m-%d).log"
183
+ HEALTH_WAIT="${MAESTRO_AUTOUPDATE_HEALTH_WAIT:-30}"
184
+ DAEMON_LABEL=""
185
+ SESSION_LABEL=""
57
186
 
58
- restart_daemon(){ [ -n "$DAEMON_LABEL" ] && launchctl kickstart -k "gui/$UID_/$DAEMON_LABEL" >> "$LOG" 2>&1; }
59
- apply_version(){ # $1 = version spec
60
- npm install "$PKG@$1" --save >> "$LOG" 2>&1 || return 1
187
+ first_plist(){ # $1 = suffix, $2 = dir — this agent's plist for that suffix, or nothing (unquoted so `*` globs when no name is known)
188
+ ls "$2"/ai.maestro.${AGENT_FIRST:-*}-"$1".plist 2>/dev/null | head -1
189
+ }
190
+ resolve_labels(){ # AFTER the upgrade: its reconcile may have just installed these
191
+ local p
192
+ # This agent's generated set names the daemon label; ~/Library/LaunchAgents
193
+ # is the fallback for a seat with no generated dir. Either way the label only
194
+ # counts when its plist is actually installed.
195
+ p="$(first_plist daemon "$AGENT_DIR/scripts/local-triggers/plists")"
196
+ [ -n "$p" ] || p="$(first_plist daemon "$HOME/Library/LaunchAgents")"
197
+ DAEMON_LABEL="$(basename "$p" .plist 2>/dev/null)"
198
+ [ -n "$DAEMON_LABEL" ] && [ -f "$HOME/Library/LaunchAgents/$DAEMON_LABEL.plist" ] || DAEMON_LABEL=""
199
+ p="$(first_plist session "$AGENT_DIR/scripts/local-triggers/plists")"
200
+ SESSION_LABEL="$(basename "$p" .plist 2>/dev/null)"
201
+ }
202
+ label_loaded(){ # $1 = label — measured in the gui domain (right from ssh too); `launchctl list <label>` only when print could not answer
203
+ local out rc
204
+ out="$(launchctl print "gui/$UID_/$1" 2>&1)"; rc=$?
205
+ [ "$rc" -eq 0 ] && return 0
206
+ case "$out" in *"Could not find service"*) return 1 ;; esac
207
+ launchctl list "$1" >/dev/null 2>&1
208
+ }
209
+ restart_daemon(){
210
+ resolve_labels
211
+ if [ -z "$DAEMON_LABEL" ]; then log "no -daemon plist under ~/Library/LaunchAgents — nothing to kickstart (maestro upgrade installs it; doctor will say)"; return 0; fi
212
+ launchctl kickstart -k "gui/$UID_/$DAEMON_LABEL" >> "$LOG" 2>&1 || log "WARN: kickstart $DAEMON_LABEL nonzero"
213
+ }
214
+ # What -session plists THIS agent had installed BEFORE this run. A rollback
215
+ # reverts only a session job this run's upgrade installed — never one that
216
+ # was already there (that one is the operator's, and `maestro session stop`
217
+ # is how an operator stops it).
218
+ PRE_SESSION_PLISTS="$(ls "$HOME"/Library/LaunchAgents/ai.maestro.${AGENT_FIRST:-*}-session.plist 2>/dev/null)"
219
+ revert_session_install(){ # rollback only
220
+ local p label
221
+ ls "$HOME"/Library/LaunchAgents/ai.maestro.${AGENT_FIRST:-*}-session.plist 2>/dev/null | while IFS= read -r p; do
222
+ [ -n "$p" ] || continue
223
+ case "$PRE_SESSION_PLISTS" in *"$p"*) continue ;; esac
224
+ label="$(basename "$p" .plist)"
225
+ launchctl bootout "gui/$UID_/$label" >> "$LOG" 2>&1 || log "WARN: bootout $label nonzero (not loaded?)"
226
+ rm -f "$p" 2>/dev/null
227
+ log "rollback: reverted session job $label — this run installed it and $CUR has no front-door session"
228
+ done
229
+ return 0
230
+ }
231
+ apply_version(){ # $1 = version spec — install locally, then run THAT package's upgrade
232
+ retry "npm install $PKG@$1" npm install "$PKG@$1" --save --no-audit --no-fund --prefer-online || return 1
61
233
  node "$AGENT_DIR/node_modules/$PKG/bin/maestro.mjs" upgrade --force-overwrite >> "$LOG" 2>&1 || log "WARN: upgrade nonzero for $1"
62
234
  return 0
63
235
  }
64
- is_healthy(){ # daemon process alive AND a fresh boot/connect line
236
+ refresh_global(){ # $1 = version — the rollback's counterpart of upgrade() step 4
237
+ if [ "${MAESTRO_SKIP_GLOBAL_INSTALL:-}" = "1" ]; then
238
+ log "global refresh skipped (MAESTRO_SKIP_GLOBAL_INSTALL=1): npm install -g $PKG@$1 --no-audit --no-fund"; return 0
239
+ fi
240
+ retry "npm install -g $PKG@$1" npm install -g "$PKG@$1" --no-audit --no-fund || log "WARN: global refresh to $1 failed — run: npm install -g $PKG@$1"
241
+ return 0
242
+ }
243
+ is_healthy(){ # THIS agent's daemon process alive AND a fresh boot/connect line
65
244
  pgrep -f "$AGENT_DIR/scripts/daemon/maestro-daemon.mjs" >/dev/null 2>&1 || return 1
66
245
  tail -80 "$DLOG" 2>/dev/null | grep -qE "org-mesh connected|daemon\] Running" || return 1
67
246
  return 0
68
247
  }
248
+ session_reconciled(){ # only asked when a -session plist was generated; never a rollback reason
249
+ label_loaded "$SESSION_LABEL" || { log "reconcile-failed: session job $SESSION_LABEL is not loaded (maestro session start)"; return 1; }
250
+ pgrep -f "$AGENT_DIR/scripts/session/supervisor" >/dev/null 2>&1 || { log "reconcile-failed: $SESSION_LABEL is loaded but no supervisor process is alive for $AGENT_DIR (maestro session status)"; return 1; }
251
+ return 0
252
+ }
253
+ write_upgrade_notice(){ # $1 = from, $2 = to — tell the front-door session; never restart it here
254
+ local dir="$AGENT_DIR/state/session" tmp
255
+ mkdir -p "$dir" 2>/dev/null || return 0
256
+ tmp="$(mktemp "$dir/.upgrade-notice.XXXXXX" 2>/dev/null)" || return 0
257
+ printf '{\n "from": "%s",\n "to": "%s",\n "at": "%s"\n}\n' "$1" "$2" "$(date -u +%FT%TZ)" > "$tmp" \
258
+ && mv -f "$tmp" "$dir/upgrade-notice.json" \
259
+ && log "session notified: upgrade-notice.json $1 -> $2 (session restarts itself when idle)"
260
+ rm -f "$tmp" 2>/dev/null
261
+ return 0
262
+ }
263
+ write_last(){ # $1 from, $2 to, $3 ok, $4 healthy, $5 reason — state/autoupdate/last.json (→ beat machine.upgrade)
264
+ local dir="$AGENT_DIR/state/autoupdate" tmp
265
+ mkdir -p "$dir" 2>/dev/null || return 0
266
+ tmp="$(mktemp "$dir/.last.XXXXXX" 2>/dev/null)" || return 0
267
+ printf '{\n "from": "%s",\n "to": "%s",\n "at": "%s",\n "ok": %s,\n "healthy": %s,\n "reason": "%s"\n}\n' \
268
+ "$1" "$2" "$(date -u +%FT%TZ)" "$3" "$4" "$5" > "$tmp" && mv -f "$tmp" "$dir/last.json"
269
+ rm -f "$tmp" 2>/dev/null
270
+ return 0
271
+ }
272
+ migrate_poller_plist(){ # audit F6 follow-through: an older install left SLACK_USER_TOKEN in a 0644 poller plist; nothing else rewrites it
273
+ [ -f "$AGENT_DIR/scripts/poller-launchd/migrate.sh" ] || return 0
274
+ bash "$AGENT_DIR/scripts/poller-launchd/migrate.sh" >> "$LOG" 2>&1 || log "WARN: poller plist migration nonzero (see above)"
275
+ return 0
276
+ }
69
277
 
70
278
  if ! apply_version "$LATEST"; then
71
- log "npm install FAILED; aborting, staying on $CUR"; exit 1
279
+ log "npm install FAILED; aborting, staying on $CUR"
280
+ write_last "$CUR" "$LATEST" false false "install-failed"
281
+ exit 1
72
282
  fi
73
283
  restart_daemon
74
- sleep 30
284
+ [ "$HEALTH_WAIT" -gt 0 ] && sleep "$HEALTH_WAIT"
285
+
75
286
  if is_healthy; then
76
- log "OK: healthy on $LATEST"
287
+ REASON=""
288
+ if [ -n "$SESSION_LABEL" ] && ! session_reconciled; then REASON="session-reconcile-failed"; fi
289
+ log "OK: healthy on $LATEST${REASON:+ ($REASON)}"
290
+ write_upgrade_notice "$CUR" "$LATEST"
291
+ write_last "$CUR" "$LATEST" true true "$REASON"
292
+ migrate_poller_plist
77
293
  else
78
294
  log "UNHEALTHY on $LATEST -> ROLLBACK to $CUR"
79
295
  apply_version "$CUR" || log "WARN: rollback install nonzero"
296
+ refresh_global "$CUR"
297
+ revert_session_install
80
298
  restart_daemon
81
- sleep 20
82
- is_healthy && log "rolled back to $CUR (healthy)" || log "ROLLBACK health still failing on $CUR — needs operator"
299
+ [ "$HEALTH_WAIT" -gt 0 ] && sleep 20
300
+ if is_healthy; then
301
+ log "rolled back to $CUR (healthy)"
302
+ write_last "$CUR" "$LATEST" false true "unhealthy-rolled-back"
303
+ else
304
+ log "ROLLBACK health still failing on $CUR — needs operator"
305
+ write_last "$CUR" "$LATEST" false false "rollback-unhealthy"
306
+ fi
83
307
  fi