@cohortapp/agent-sdk 2.11.15 → 2.12.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 (168) hide show
  1. package/.env.example +37 -22
  2. package/README.md +2 -0
  3. package/bin/maestro.mjs +113 -39
  4. package/bin/maestro.test.mjs +175 -5
  5. package/docs/guides/front-door-session.md +264 -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/doctor-checks.mjs +441 -0
  17. package/lib/cli/doctor-checks.test.mjs +336 -0
  18. package/lib/cli/global-setup-extras.mjs +410 -0
  19. package/lib/cli/global-setup-extras.test.mjs +367 -0
  20. package/lib/cli/inbox.mjs +304 -0
  21. package/lib/cli/inbox.test.mjs +230 -0
  22. package/lib/cli/session-ack.mjs +63 -0
  23. package/lib/cli/session-ack.test.mjs +63 -0
  24. package/lib/cli/session.mjs +750 -0
  25. package/lib/cli/session.test.mjs +602 -0
  26. package/lib/collective/global-config.mjs +204 -6
  27. package/lib/collective/global-config.test.mjs +140 -0
  28. package/lib/collective/global-skills.mjs +145 -0
  29. package/lib/collective/global-skills.test.mjs +126 -0
  30. package/lib/collective/presence.mjs +4 -3
  31. package/lib/comms/send-gate.mjs +115 -0
  32. package/lib/comms/send-gate.test.mjs +113 -0
  33. package/lib/feature-init.mjs +2 -2
  34. package/lib/mcp/server.test.mjs +9 -4
  35. package/lib/model-router/spawn.test.mjs +21 -0
  36. package/lib/org/board-mine-cache.mjs +99 -0
  37. package/lib/org/board-mine-cache.test.mjs +53 -0
  38. package/lib/org/board.mjs +11 -0
  39. package/lib/org/board.test.mjs +11 -1
  40. package/lib/org/client.mjs +36 -0
  41. package/lib/org/client.test.mjs +46 -0
  42. package/lib/org/inbound/directedness.mjs +18 -2
  43. package/lib/org/inbound/directedness.test.mjs +58 -0
  44. package/lib/org/inbound/index.mjs +8 -1
  45. package/lib/org/inbound/index.test.mjs +22 -0
  46. package/lib/org/mesh-directives.test.mjs +110 -0
  47. package/lib/org/mesh.mjs +61 -1
  48. package/lib/org/protocol.checksum +1 -1
  49. package/lib/org/protocol.mjs +52 -0
  50. package/lib/org/protocol.test.mjs +12 -1
  51. package/lib/org/registry.mjs +3 -2
  52. package/lib/org/tool-surface.mjs +120 -0
  53. package/lib/org/tool-surface.test.mjs +118 -5
  54. package/lib/security/external-content.mjs +1 -1
  55. package/lib/security/external-content.test.mjs +17 -0
  56. package/lib/session/config.mjs +137 -0
  57. package/lib/session/config.test.mjs +92 -0
  58. package/lib/session/feed-core.mjs +229 -0
  59. package/lib/session/feed-core.test.mjs +198 -0
  60. package/lib/session/first-run.mjs +126 -0
  61. package/lib/session/first-run.test.mjs +121 -0
  62. package/lib/session/frontdoor.mjs +266 -0
  63. package/lib/session/frontdoor.test.mjs +205 -0
  64. package/lib/session/handoffs.mjs +295 -0
  65. package/lib/session/handoffs.test.mjs +183 -0
  66. package/lib/session/identity.mjs +220 -0
  67. package/lib/session/identity.test.mjs +180 -0
  68. package/lib/session/inbox-claims.mjs +434 -0
  69. package/lib/session/inbox-claims.test.mjs +286 -0
  70. package/lib/session/launch-args.mjs +161 -0
  71. package/lib/session/launch-args.test.mjs +157 -0
  72. package/lib/session/liveness.mjs +174 -0
  73. package/lib/session/liveness.test.mjs +100 -0
  74. package/lib/session/status-summary.mjs +172 -0
  75. package/lib/session/status-summary.test.mjs +118 -0
  76. package/lib/session-permissions.mjs +39 -3
  77. package/lib/session-permissions.test.mjs +20 -0
  78. package/lib/setup/claude-probe.mjs +161 -24
  79. package/lib/setup/claude-probe.test.mjs +187 -0
  80. package/lib/setup/sections/learning.mjs +2 -1
  81. package/lib/setup/sections/model.mjs +104 -24
  82. package/lib/setup/sections/model.test.mjs +240 -0
  83. package/lib/setup/sections/org.mjs +27 -2
  84. package/lib/setup/sections/org.test.mjs +35 -2
  85. package/lib/setup/sections/verify.mjs +5 -0
  86. package/lib/setup/state.mjs +30 -10
  87. package/lib/setup/state.test.mjs +24 -1
  88. package/lib/singleton.js +11 -3
  89. package/lib/singleton.test.mjs +16 -0
  90. package/lib/subagents/lock.mjs +1 -1
  91. package/lib/telemetry/collect.mjs +270 -6
  92. package/lib/telemetry/collect.test.mjs +196 -1
  93. package/lib/upgrade/global-refresh.mjs +108 -0
  94. package/lib/upgrade/global-refresh.test.mjs +65 -0
  95. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  96. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  97. package/lib/upgrade/post-steps.mjs +151 -0
  98. package/lib/upgrade/post-steps.test.mjs +200 -0
  99. package/lib/upgrade/verify.mjs +215 -0
  100. package/lib/upgrade/verify.test.mjs +164 -0
  101. package/lib/voice/outbound.mjs +3 -2
  102. package/lib/voice/post-call-brief.mjs +2 -1
  103. package/lib/voice/session-rotation.mjs +6 -1
  104. package/lib/voice/session-rotation.test.mjs +114 -0
  105. package/package.json +3 -3
  106. package/plugins/maestro-skills/plugin.json +21 -1
  107. package/plugins/maestro-skills/skills/board-work.md +63 -0
  108. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  109. package/plugins/maestro-skills/skills/main-session.md +102 -0
  110. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  111. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  112. package/scaffold/CLAUDE.md +24 -0
  113. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  114. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  115. package/scripts/ci/check.mjs +3 -0
  116. package/scripts/collective/hook-runner.mjs +39 -4
  117. package/scripts/collective/hook-runner.test.mjs +85 -2
  118. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  119. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  120. package/scripts/daemon/agent-daemon.mjs +141 -10
  121. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  122. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  123. package/scripts/daemon/assurance.mjs +461 -37
  124. package/scripts/daemon/assurance.test.mjs +408 -43
  125. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
  126. package/scripts/daemon/cadence-consumer.mjs +254 -78
  127. package/scripts/daemon/cadence-handlers.mjs +53 -0
  128. package/scripts/daemon/classifier.mjs +1 -1
  129. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  130. package/scripts/daemon/dispatcher.mjs +127 -19
  131. package/scripts/daemon/health.mjs +12 -1
  132. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  133. package/scripts/daemon/inbox-deferral.mjs +6 -0
  134. package/scripts/daemon/lib/self-echo.mjs +201 -0
  135. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  136. package/scripts/daemon/maestro-daemon.mjs +3 -0
  137. package/scripts/daemon/responder.mjs +51 -40
  138. package/scripts/daemon/sdk-version.mjs +51 -0
  139. package/scripts/daemon/sdk-version.test.mjs +31 -0
  140. package/scripts/hooks/pre-send-audit.sh +97 -4
  141. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  142. package/scripts/local-triggers/autoupdate.sh +243 -19
  143. package/scripts/local-triggers/autoupdate.test.mjs +488 -0
  144. package/scripts/local-triggers/generate-plists.sh +24 -1
  145. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  146. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  147. package/scripts/org/send-orgmail.mjs +27 -3
  148. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  149. package/scripts/poller/slack-poller.mjs +13 -1
  150. package/scripts/poller/utils.mjs +46 -1
  151. package/scripts/poller-launchd/install.sh +19 -11
  152. package/scripts/poller-launchd/install.test.mjs +243 -0
  153. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  154. package/scripts/poller-launchd/migrate.sh +66 -0
  155. package/scripts/poller-launchd/poller.plist.template +4 -2
  156. package/scripts/session/feed.mjs +237 -0
  157. package/scripts/session/feed.test.mjs +196 -0
  158. package/scripts/session/supervisor-sh.test.mjs +218 -0
  159. package/scripts/session/supervisor.mjs +328 -0
  160. package/scripts/session/supervisor.sh +141 -0
  161. package/scripts/session/supervisor.test.mjs +482 -0
  162. package/scripts/setup/configure-macos.sh +250 -55
  163. package/scripts/setup/configure-macos.test.mjs +306 -0
  164. package/scripts/setup/init-agent.sh +112 -7
  165. package/scripts/setup/init-agent.test.mjs +220 -1
  166. package/scripts/watchdog/memory-watchdog.sh +37 -1
  167. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  168. package/scripts/setup/boot-claude-session.sh +0 -94
package/.env.example CHANGED
@@ -36,7 +36,11 @@ COHORT_BASE=https://os.cohortapp.com
36
36
  # agent's workforce member (aliases: COHORT_API_KEY, COHORT_TOKEN).
37
37
  COHORT_API_TOKEN=
38
38
 
39
- # Org slug — sent as the x-org-id pin header + used by pull-enrollment.
39
+ # The org's ID — NOT its slug. It has the form org_default_<slug> (Adaptic:
40
+ # org_default_adaptic, slug adaptic) and is sent as the x-org-id pin header,
41
+ # which hq compares with strict equality against the key's Org.id: a slug here
42
+ # 401s every call. `maestro doctor` checks this value against the server and
43
+ # says "you set the slug" when that is what happened. Also used by pull-enrollment.
40
44
  COHORT_ORG_ID=
41
45
 
42
46
  # OPTIONAL — this agent's member id/slug. Used by pull-enrollment, org_whoami,
@@ -53,29 +57,40 @@ COHORT_AGENT_EMAIL=
53
57
  # The agent's reasoning engines. At minimum you need Anthropic (Claude).
54
58
  #
55
59
 
56
- # REQUIRED — Primary reasoning engine. Two ways to authenticate:
57
- #
58
- # Option A — API key (pay-per-token)
59
- # Set ANTHROPIC_API_KEY below to a valid sk-ant-api03-... key.
60
- # Get one: https://console.anthropic.com/settings/keys
61
- #
62
- # Option B — Claude Code subscription (Pro/Max, OAuth via Keychain)
63
- # LEAVE ANTHROPIC_API_KEY EMPTY *and* set MAESTRO_PREFER_SUBSCRIPTION_AUTH=1.
64
- # This tells the cadence consumer to strip ANTHROPIC_API_KEY from every
65
- # sub-session spawn so claude --print falls back to the keychain OAuth
66
- # token. Most agents on a Mac mini with a Claude Code subscription
67
- # should use this option — routine cadence ticks cost zero API credits.
68
- #
69
- # Doctor validates the key against api.anthropic.com on every run; an
70
- # invalid key here will cascade 401s through every sub-session spawn.
71
- ANTHROPIC_API_KEY=
72
-
73
- # OPTIONAL — When set to 1, the cadence consumer strips ANTHROPIC_API_KEY
74
- # from every claude --print sub-session env so claude falls back to
75
- # Claude Code subscription auth (Keychain OAuth). Use this when the
76
- # agent's Mac has a Claude Code Pro/Max subscription.
60
+ # REQUIRED — Primary reasoning engine. ONE auth story, three ways to satisfy it,
61
+ # in order of preference. `maestro setup` (model section, order 30) writes the
62
+ # chosen one here and chmods this file 600; `maestro doctor` reports which mode
63
+ # is in effect and what `claude auth status` reports (presence of a credential,
64
+ # not its validity — a bad token surfaces on the first real spawn).
65
+ #
66
+ # Option A — subscription OAuth token ← THE FLEET DEFAULT (seat machines)
67
+ # On ANY machine where you are logged in to Claude Code, run
68
+ # claude setup-token
69
+ # and paste the long-lived token below, together with
70
+ # MAESTRO_PREFER_SUBSCRIPTION_AUTH=1. Every spawn — the daemon's --print
71
+ # lane, the main session, cadence sub-sessions — carries it; the interactive
72
+ # keychain login is NOT relied on because it expires headlessly. The token is
73
+ # per seat; mint a fresh one with the same command when it is rejected.
74
+ # Headless setup reads it from the environment: CLAUDE_CODE_OAUTH_TOKEN=… maestro setup --headless
75
+ CLAUDE_CODE_OAUTH_TOKEN=
76
+
77
+ # Option B — keychain login on this machine (interactive / dev boxes only)
78
+ # `claude login` here, leave CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY
79
+ # empty, set MAESTRO_PREFER_SUBSCRIPTION_AUTH=1. Doctor warns: the login
80
+ # expires headlessly and a seat machine should hold the token instead.
81
+ #
82
+ # Set to 1 for Option A and Option B: every claude spawn strips
83
+ # ANTHROPIC_API_KEY from its env so claude rides the subscription (token or
84
+ # keychain) — routine cadence ticks then cost zero API credits.
77
85
  MAESTRO_PREFER_SUBSCRIPTION_AUTH=
78
86
 
87
+ # Option C — API key (pay-per-token)
88
+ # Set ANTHROPIC_API_KEY to a valid sk-ant-api03-... key and leave the two
89
+ # above empty. Get one: https://console.anthropic.com/settings/keys
90
+ # Doctor validates it against api.anthropic.com on every run; an invalid
91
+ # key cascades 401s through every sub-session spawn.
92
+ ANTHROPIC_API_KEY=
93
+
79
94
  # OPTIONAL — Supplemental model access (GPT-4, embeddings)
80
95
  # Get your key: https://platform.openai.com/api-keys
81
96
  # Subscription: OpenAI API plan (pay-per-token)
package/README.md CHANGED
@@ -568,6 +568,8 @@ Autonomous work is designed to survive reboot and power loss, and the agent degr
568
568
 
569
569
  - [Setup Wizard](docs/guides/setup-wizard.md) -- `maestro setup`: sections, resume/checkpoint, source of truth, enrichment, verify probe, all flags
570
570
  - [Agent Persona Setup](docs/guides/agent-persona-setup.md) -- Identity, operating charter, and behavioural policies
571
+ - [Front-door Session](docs/guides/front-door-session.md) -- The always-on main session: what runs, how to attach, stop, restart, and read its status
572
+ - [Mac mini bring-up](docs/guides/mac-mini.md) -- Boxed mini to enrolled seat: token, create, setup, session install, Tailscale SSH, doctor
571
573
  - [Email Setup](docs/guides/email-setup.md) -- Gmail IMAP polling, SMTP sending, thread dedup, signatures
572
574
  - [Slack Setup](docs/guides/slack-setup.md) -- Slack app, tokens, sending, typing indicators, events, CDP
573
575
  - [WhatsApp Setup](docs/guides/whatsapp-setup.md) -- Twilio WhatsApp sandbox/production, webhook handler
package/bin/maestro.mjs CHANGED
@@ -816,12 +816,24 @@ function loadShippedManifest(cwd) {
816
816
  function writeShippedManifest(cwd, files) {
817
817
  const dst = join(cwd, SHIPPED_MANIFEST_REL);
818
818
  mkdirSync(dirname(dst), { recursive: true });
819
+ // `sdkVersion` (2.12+) is what `upgrade --verify` compares against the
820
+ // running package; `version` is the manifest FORMAT and stays 1.
819
821
  writeFileSync(
820
822
  dst,
821
- JSON.stringify({ version: 1, files: [...files].sort() }, null, 2) + "\n",
823
+ JSON.stringify({ version: 1, sdkVersion: readFrameworkVersion(), files: [...files].sort() }, null, 2) + "\n",
822
824
  );
823
825
  }
824
826
 
827
+ /** The SDK version the previous upgrade recorded here, or null (pre-2.12 manifest / none). */
828
+ function readShippedManifestVersion(cwd) {
829
+ try {
830
+ const raw = JSON.parse(readFileSync(join(cwd, SHIPPED_MANIFEST_REL), "utf-8"));
831
+ return typeof raw?.sdkVersion === "string" ? raw.sdkVersion : null;
832
+ } catch {
833
+ return null; // absent or unreadable — "from" is simply unknown
834
+ }
835
+ }
836
+
825
837
  function sha256File(p) {
826
838
  return createHash("sha256").update(readFileSync(p)).digest("hex");
827
839
  }
@@ -1130,10 +1142,12 @@ function parseUpgradeFlags(args) {
1130
1142
  noIncoming: false,
1131
1143
  verbose: false,
1132
1144
  noPrune: false,
1145
+ verify: false,
1133
1146
  };
1134
1147
  for (const a of args) {
1135
1148
  if (a === "--dry-run" || a === "-n") flags.dryRun = true;
1136
1149
  else if (a === "--force-overwrite" || a === "--force") flags.forceOverwrite = true;
1150
+ else if (a === "--verify") flags.verify = true;
1137
1151
  else if (a === "--no-incoming") flags.noIncoming = true;
1138
1152
  else if (a === "--no-prune") flags.noPrune = true;
1139
1153
  else if (a === "--verbose" || a === "-v") flags.verbose = true;
@@ -1154,9 +1168,24 @@ Flags:
1154
1168
  --force-overwrite Overwrite even locally-modified files (backs them up)
1155
1169
  --no-incoming Don't write .maestro/incoming/ shadows for preserved files
1156
1170
  --no-prune Keep framework files that upstream has deleted
1171
+ --verify Report only: is this seat wholly on this SDK version
1172
+ (global bin, local package, shipped manifest, running
1173
+ daemon, plists installed + loaded, global-setup)?
1174
+ Exit 1 on any mismatch. doctor prints the same rows.
1157
1175
  --verbose, -v Print classification for every file
1158
1176
  --help, -h Show this help
1159
1177
 
1178
+ After the file merge (every non-dry run, each step fail-open, reported here
1179
+ and in .maestro/upgrade-result.json {from,to,at,steps}):
1180
+ plists scripts/local-triggers/generate-plists.sh
1181
+ launchd install / replace / load every generated plist under
1182
+ ~/Library/LaunchAgents (mode 600, launchctl bootstrap);
1183
+ -daemon and -session are rewritten, never restarted
1184
+ globalSetup maestro global-setup (identity block, cohort MCP, settings, skills)
1185
+ globalInstall npm i -g @cohortapp/agent-sdk@<this version>
1186
+ (MAESTRO_SKIP_GLOBAL_INSTALL=1 skips)
1187
+ verify the --verify report
1188
+
1160
1189
  Per-file behaviour:
1161
1190
  added — new upstream file → copy
1162
1191
  updated — existed, byte-equal to your committed copy → safe overwrite
@@ -1190,6 +1219,16 @@ Per-file behaviour:
1190
1219
  process.exit(1);
1191
1220
  }
1192
1221
 
1222
+ if (flags.verify) {
1223
+ // Report only — nothing is written. lib/upgrade/verify.mjs is the core;
1224
+ // the same rows appear in `maestro doctor`.
1225
+ const { runVerify, formatVerifyReport } = await import("../lib/upgrade/verify.mjs");
1226
+ const report = runVerify({ cwd, expected: readFrameworkVersion() });
1227
+ for (const line of formatVerifyReport(report)) console.log(line);
1228
+ process.exitCode = report.ok ? 0 : 1;
1229
+ return;
1230
+ }
1231
+
1193
1232
  const inGit = isGitRepo(cwd);
1194
1233
  if (!inGit) {
1195
1234
  warn("Not in a git repo — cannot detect local modifications.");
@@ -1330,6 +1369,8 @@ Per-file behaviour:
1330
1369
  // Removals are backed up under .maestro/backup/ exactly like a forced
1331
1370
  // overwrite, so a prune is always reversible.
1332
1371
  const priorManifest = loadShippedManifest(cwd);
1372
+ // What this machine ran before this upgrade — the `from` of upgrade-result.json.
1373
+ const priorSdkVersion = readShippedManifestVersion(cwd);
1333
1374
 
1334
1375
  if (flags.noPrune) {
1335
1376
  log("Prune skipped (--no-prune): upstream-deleted files left in place.");
@@ -1614,6 +1655,31 @@ Per-file behaviour:
1614
1655
  }
1615
1656
  }
1616
1657
 
1658
+ // Post-steps (design 2026-09-08, WP-M6): plists → launchd reconcile →
1659
+ // global-setup → global npm install → verify. This is what puts an existing
1660
+ // seat wholly on the new architecture on the first hop from ANY old version,
1661
+ // because the old autoupdate.sh runs THIS upgrade and nothing else. Each
1662
+ // step is fail-open; the daemon is restarted by autoupdate, never here.
1663
+ if (!flags.dryRun) {
1664
+ console.log();
1665
+ console.log(`${C.bold}Seat reconcile${C.reset}`);
1666
+ try {
1667
+ const { runUpgradePostSteps } = await import("../lib/upgrade/post-steps.mjs");
1668
+ await runUpgradePostSteps({
1669
+ cwd,
1670
+ sdkVersion: readFrameworkVersion(),
1671
+ from: priorSdkVersion,
1672
+ say: { log: (m) => console.log(` ${m}`), ok, warn },
1673
+ // global-setup needs config/agent.json + the collective hook runner;
1674
+ // a repo without them is reported as a skipped step, not a crash.
1675
+ globalSetup: isAgentRepo(cwd) ? () => globalSetup([cwd]) : null,
1676
+ });
1677
+ log("Result: .maestro/upgrade-result.json · re-check any time: maestro upgrade --verify");
1678
+ } catch (e) {
1679
+ warn(`seat reconcile skipped: ${e && e.message ? e.message : e}`);
1680
+ }
1681
+ }
1682
+
1617
1683
  console.log();
1618
1684
  log("Agent-specific paths (config/, CLAUDE.md, knowledge/, memory/, state/, outputs/, logs/, .env) were NOT touched.");
1619
1685
  }
@@ -2301,6 +2367,35 @@ async function doctor() {
2301
2367
  } catch { /* pmset unavailable (non-mac / sandbox) — skip */ }
2302
2368
  }
2303
2369
 
2370
+ // Claude auth, the COHORT_ORG_ID id-vs-slug trap, the effective source of
2371
+ // every Cohort value, the global SDK install, Tailscale SSH and the main
2372
+ // session job — mode-aware, so a seat on the subscription token is no
2373
+ // longer told "ANTHROPIC_API_KEY not set". Pure checks + one injected edge
2374
+ // live in lib/cli/doctor-checks.mjs (design 2026-09-08 §3.9).
2375
+ try {
2376
+ const { runDoctorChecks } = await import("../lib/cli/doctor-checks.mjs");
2377
+ for (const r of await runDoctorChecks({ cwd })) {
2378
+ if (r.level === "ok") ok(r.msg);
2379
+ else if (r.level === "warn") warn(r.msg);
2380
+ else { fail(r.msg); issues++; }
2381
+ }
2382
+ } catch (err) {
2383
+ warn(`auth / front-door checks unavailable: ${err && err.message ? err.message : err}`);
2384
+ }
2385
+
2386
+ // `maestro upgrade --verify` rows: one SDK version end to end (global bin,
2387
+ // local package, shipped manifest, running daemon, plists, global-setup).
2388
+ try {
2389
+ const { runVerify } = await import("../lib/upgrade/verify.mjs");
2390
+ for (const r of runVerify({ cwd, expected: readFrameworkVersion() }).rows) {
2391
+ if (r.level === "ok") ok(r.msg);
2392
+ else if (r.level === "warn") warn(r.msg);
2393
+ else { fail(r.msg); issues++; }
2394
+ }
2395
+ } catch (err) {
2396
+ warn(`upgrade verify unavailable: ${err && err.message ? err.message : err}`);
2397
+ }
2398
+
2304
2399
  // ── .env ────────────────────────────────────────────────────────────────
2305
2400
  if (existsSync(join(cwd, ".env"))) {
2306
2401
  // .env permission hygiene (setup-onboarding W4): every credential — Slack,
@@ -2327,46 +2422,9 @@ async function doctor() {
2327
2422
  else if (required) { warn(`${key} not set`); issues++; }
2328
2423
  else warn(`${key} not set (optional)`);
2329
2424
  };
2330
- check("ANTHROPIC_API_KEY", true);
2331
2425
  check("SLACK_USER_TOKEN", false);
2332
2426
  check("GMAIL_APP_PASSWORD", false);
2333
2427
 
2334
- // Auth validity: if ANTHROPIC_API_KEY is set, ping the API to
2335
- // verify it works. An invalid key in .env will silently be sent
2336
- // to every `claude --print` sub-session and cause cascading 401s
2337
- // (exactly the ravi-ai inbox-processor runaway). Better to catch
2338
- // it here. Skips the check if the user opted out via
2339
- // MAESTRO_PREFER_SUBSCRIPTION_AUTH=1 (subscription wins).
2340
- const keyMatch = env.match(/^ANTHROPIC_API_KEY=(.+)$/m);
2341
- const preferSubsMatch = env.match(/^MAESTRO_PREFER_SUBSCRIPTION_AUTH=(.+)$/m);
2342
- const preferSubs = preferSubsMatch && /^1|true|yes$/i.test(preferSubsMatch[1].trim());
2343
- if (keyMatch && !preferSubs) {
2344
- const key = keyMatch[1].trim().replace(/^"|"$/g, "");
2345
- try {
2346
- const result = spawnSync("curl", [
2347
- "-s", "-o", "/dev/null", "-w", "%{http_code}",
2348
- "-X", "POST",
2349
- "-H", `x-api-key: ${key}`,
2350
- "-H", "anthropic-version: 2023-06-01",
2351
- "-H", "content-type: application/json",
2352
- "--max-time", "8",
2353
- "https://api.anthropic.com/v1/messages",
2354
- "-d", JSON.stringify({ model: "claude-haiku-4-5", max_tokens: 5, messages: [{ role: "user", content: "ping" }] }),
2355
- ], { encoding: "utf-8" });
2356
- const code = (result.stdout || "").trim();
2357
- if (code === "200") ok("ANTHROPIC_API_KEY validated against api.anthropic.com");
2358
- else if (code === "401") {
2359
- warn(`ANTHROPIC_API_KEY is INVALID (HTTP 401 from api.anthropic.com).`);
2360
- warn(` This will cause every sub-session spawn to fail. Either:`);
2361
- warn(` 1. Replace the key in .env with a valid one, OR`);
2362
- warn(` 2. Set MAESTRO_PREFER_SUBSCRIPTION_AUTH=1 in .env to use Claude Code subscription auth.`);
2363
- issues++;
2364
- } else if (code) warn(`ANTHROPIC_API_KEY check returned HTTP ${code} (expected 200)`);
2365
- else warn(`ANTHROPIC_API_KEY check skipped (no network / curl missing)`);
2366
- } catch { warn("ANTHROPIC_API_KEY check failed (curl error)"); }
2367
- } else if (preferSubs) {
2368
- ok("MAESTRO_PREFER_SUBSCRIPTION_AUTH=1 — using Claude Code subscription (Keychain OAuth)");
2369
- }
2370
2428
 
2371
2429
  // ── Slack Socket Mode ────────────────────────────────────────────────
2372
2430
  // When SLACK_APP_LEVEL_TOKEN is set, verify the launchd job is loaded
@@ -3297,6 +3355,13 @@ async function globalSetup(args = []) {
3297
3355
  if (!dryRun) writeFileSync(pointerFile, JSON.stringify(gc.buildPointer(cwd, agentName), null, 2));
3298
3356
  ok(`${dryRun ? "[dry-run] " : ""}machine pointer → ${pointerFile}`);
3299
3357
 
3358
+ // 4) Front-door session (2026-09 §3.7): identity block, cohort MCP server,
3359
+ // settings additions, global skills — lib/cli/global-setup-extras.mjs.
3360
+ try {
3361
+ const extras = await import("../lib/cli/global-setup-extras.mjs");
3362
+ await extras.applyFrontDoorSetup({ agentRoot: cwd, claudeDir, dryRun, log, ok, warn });
3363
+ } catch (err) { warn(`front-door setup skipped: ${err && err.message}`); }
3364
+
3300
3365
  console.log();
3301
3366
  if (dryRun) {
3302
3367
  log("Dry run complete — no files written.");
@@ -3737,6 +3802,10 @@ switch (command) {
3737
3802
  case "router": await routerCmd(args); break;
3738
3803
  case "secrets": await secretsCmd(args); break;
3739
3804
  case "who-owns": case "who": await whoOwnsCmd(args); break;
3805
+ case "inbox": process.exitCode = await (await import("../lib/cli/inbox.mjs")).runInbox(args); break;
3806
+ case "session-ack": process.exitCode = await (await import("../lib/cli/session-ack.mjs")).runSessionAck(args); break;
3807
+ case "session": { const r = await (await import("../lib/cli/session.mjs")).run(args); process.exitCode = r.code; break; }
3808
+ case "board": await (await import("../lib/cli/board.mjs")).boardCmd(args); break;
3740
3809
  case "init":
3741
3810
  case "update-init":
3742
3811
  case "init-update":
@@ -3750,7 +3819,8 @@ Usage:
3750
3819
  npx @cohortapp/agent-sdk setup [section] [flags] Configure this agent (deterministic, resumable wizard)
3751
3820
  npx @cohortapp/agent-sdk pair <code> Open a pre-auth pairing handshake (org admin approves it)
3752
3821
  npx @cohortapp/agent-sdk sync [--force] [--dry-run] Re-pull this agent's record from Cohort into config/agent.json
3753
- npx @cohortapp/agent-sdk upgrade [--dry-run] Update framework files
3822
+ npx @cohortapp/agent-sdk upgrade [--dry-run] Update framework files (+ plists, launchd, global-setup, global install)
3823
+ npx @cohortapp/agent-sdk upgrade --verify Is this seat wholly on this SDK version? (exit 1 on mismatch)
3754
3824
  npx @cohortapp/agent-sdk init [feature] [--apply] Run pending feature-init steps
3755
3825
  npx @cohortapp/agent-sdk global-setup [--dry-run] Wire collective memory into ~/.claude (machine-wide)
3756
3826
  npx @cohortapp/agent-sdk doctor Verify installation
@@ -3761,6 +3831,10 @@ Usage:
3761
3831
  npx @cohortapp/agent-sdk secrets sync [--name X] Pull org secrets from the broker into the local store
3762
3832
  npx @cohortapp/agent-sdk secrets rotate --name X Rotate a secret (new value read from stdin)
3763
3833
  npx @cohortapp/agent-sdk who-owns <scope> Who owns a scope / escalate-to / reports (org context, zero-LLM)
3834
+ npx @cohortapp/agent-sdk inbox list|show|claim|reply|done|defer The main session's inbox (JSON; shares the daemon's markers)
3835
+ npx @cohortapp/agent-sdk session-ack <tickId> Ack a cadence tick handed to the main session
3836
+ npx @cohortapp/agent-sdk session <cmd> Front-door session: status|attach|start|stop|restart|spawn|peers|handoffs|ack
3837
+ npx @cohortapp/agent-sdk board mine|track|claim|complete Your work across every board (maestro board --help)
3764
3838
 
3765
3839
  Upgrade flags:
3766
3840
  --dry-run, -n Preview changes without writing
@@ -50,6 +50,65 @@ async function rmRoot(path) {
50
50
  try { await fsp.rm(path, { recursive: true, force: true }); } catch { /* */ }
51
51
  }
52
52
 
53
+ // ---------------------------------------------------------------------------
54
+ // The machine the CLI must NEVER touch from a test: the developer's real
55
+ // ~/Library/LaunchAgents, ~/.claude and global npm. `upgrade` now reconciles
56
+ // all three (WP-M6), so every CLI run gets a throwaway HOME, a `launchctl`
57
+ // stub that treats "installed" as "loaded" (in `list` AND in `print gui/<uid>`,
58
+ // which is what the reconcile reads), an `npm` stub, a `claude` stub (logs and
59
+ // exits 1 → global-setup's JSON fallback) and MAESTRO_SKIP_GLOBAL_INSTALL=1.
60
+ // The claude stub is reached through CLAUDE_BIN, not PATH: lib/claude-bin.mjs
61
+ // resolves ~/.local/bin, /opt/homebrew/bin, /usr/local/bin BEFORE PATH, so a
62
+ // PATH-only stub is silently bypassed by the developer's real `claude`.
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const TEST_SHIM = await tmpRoot("maestro-shim-global");
66
+ {
67
+ const stub = async (name, body) => { writeFileSync(join(TEST_SHIM, name), `#!/bin/bash\n${body}\n`); await fsp.chmod(join(TEST_SHIM, name), 0o755); };
68
+ await stub("launchctl", `echo "launchctl $*" >> "$HOME/launchctl.log"
69
+ if [ "$1" = "list" ]; then
70
+ if [ -n "\${2:-}" ]; then [ -f "$HOME/Library/LaunchAgents/$2.plist" ] && exit 0 || exit 113; fi
71
+ printf 'PID\tStatus\tLabel\n'
72
+ for f in "$HOME"/Library/LaunchAgents/*.plist; do [ -f "$f" ] && printf -- '-\t0\t%s\n' "$(basename "$f" .plist)"; done
73
+ exit 0
74
+ fi
75
+ if [ "$1" = "print" ]; then
76
+ case "$2" in
77
+ gui/*/*) l="\${2##*/}"; if [ -f "$HOME/Library/LaunchAgents/$l.plist" ]; then echo "$l = {}"; exit 0; fi; echo "Could not find service \"$l\" in domain for user gui: 501" >&2; exit 113 ;;
78
+ gui/*) printf '%s = {\n\tservices = {\n' "$2"; for f in "$HOME"/Library/LaunchAgents/*.plist; do [ -f "$f" ] && printf '\t\t0\t0\t%s\n' "$(basename "$f" .plist)"; done; printf '\t}\n}\n'; exit 0 ;;
79
+ esac
80
+ fi
81
+ exit 0`);
82
+ await stub("npm", `echo "npm $*" >> "$HOME/npm.log"
83
+ case "$1" in
84
+ ls) echo '{}'; exit 1 ;;
85
+ view) exit 1 ;;
86
+ root) echo "$HOME/npm-global/lib/node_modules"; exit 0 ;;
87
+ esac
88
+ exit 0`);
89
+ await stub("claude", `echo "claude $*" >> "$HOME/claude.log"
90
+ exit 1`);
91
+ }
92
+
93
+ /** A throwaway HOME for one CLI run (or a shared one when the test does not care). */
94
+ async function freshHome() {
95
+ const home = await tmpRoot("maestro-home");
96
+ mkdirSync(join(home, "Library", "LaunchAgents"), { recursive: true });
97
+ return home;
98
+ }
99
+ const TEST_HOME = await freshHome();
100
+
101
+ function baseEnv(extra = {}) {
102
+ return {
103
+ ...process.env,
104
+ HOME: TEST_HOME,
105
+ PATH: `${TEST_SHIM}:${process.env.PATH || ""}`,
106
+ CLAUDE_BIN: join(TEST_SHIM, "claude"),
107
+ MAESTRO_SKIP_GLOBAL_INSTALL: "1",
108
+ ...extra,
109
+ };
110
+ }
111
+
53
112
  /**
54
113
  * Build a PATH that shadows `npm` (and optionally `git`) with no-op shims
55
114
  * so `create` doesn't actually try to install dependencies. Returns the new
@@ -61,14 +120,14 @@ async function withShim() {
61
120
  writeFileSync(npm, "#!/bin/bash\necho '[shim npm]' \"$@\" >&2\nexit 0\n");
62
121
  await fsp.chmod(npm, 0o755);
63
122
  // Keep real git (we want git init to actually init).
64
- const env = { ...process.env, PATH: `${dir}:${process.env.PATH || ""}` };
123
+ const env = baseEnv({ PATH: `${dir}:${TEST_SHIM}:${process.env.PATH || ""}` });
65
124
  return { env, dir };
66
125
  }
67
126
 
68
127
  function runCli(args, cwd, env) {
69
128
  return spawnSync(process.execPath, [MAESTRO_CLI, ...args], {
70
129
  cwd: cwd || process.cwd(),
71
- env: env || process.env,
130
+ env: env || baseEnv(),
72
131
  encoding: "utf-8",
73
132
  });
74
133
  }
@@ -77,7 +136,7 @@ function runCli(args, cwd, env) {
77
136
  function runCliStdin(args, cwd, input, env) {
78
137
  return spawnSync(process.execPath, [MAESTRO_CLI, ...args], {
79
138
  cwd: cwd || process.cwd(),
80
- env: env || process.env,
139
+ env: env || baseEnv(),
81
140
  input,
82
141
  encoding: "utf-8",
83
142
  });
@@ -1325,10 +1384,17 @@ test("upgrade prune never removes machine-generated launchd plists", async () =>
1325
1384
  try {
1326
1385
  const r = runCli(["upgrade"], root);
1327
1386
  assert.equal(r.status, 0, r.stderr);
1387
+ // Prune backs a file up under .maestro/backup/<path> before unlinking it
1388
+ // and lists it as "pruned"; neither may happen here. (The file itself is
1389
+ // gone after the run since WP-M6 — generate-plists.sh runs unconditionally
1390
+ // as the first post-step and, as it always has, clears scripts/local-
1391
+ // triggers/plists/ before rendering this machine's set. That is the
1392
+ // generator owning its directory, not prune eating machine config.)
1328
1393
  assert.ok(
1329
- existsSync(join(root, plist)),
1330
- "generated plists are machine config; prune must leave them alone",
1394
+ !existsSync(join(root, ".maestro", "backup", plist)),
1395
+ "generated plists are machine config; prune must leave them alone (no prune backup)",
1331
1396
  );
1397
+ assert.ok(!/pruned/.test(r.stdout), `prune must not report the plist:\n${r.stdout}`);
1332
1398
  } finally {
1333
1399
  await fsp.rm(root, { recursive: true, force: true });
1334
1400
  }
@@ -1402,3 +1468,107 @@ test("upgrade --dry-run writes no shipped-manifest", async () => {
1402
1468
  await fsp.rm(root, { recursive: true, force: true });
1403
1469
  }
1404
1470
  });
1471
+
1472
+ // ---------------------------------------------------------------------------
1473
+ // upgrade — seat reconcile post-steps (WP-M6): the first hop from an old
1474
+ // version must leave the machine wholly on the new architecture by itself.
1475
+ // ---------------------------------------------------------------------------
1476
+
1477
+ /** makeLegacyAgent + a NAMED config/agent.json, so plist labels and the identity block use "legacy". */
1478
+ async function makeNamedLegacyAgent() {
1479
+ const root = await makeLegacyAgent();
1480
+ writeFileSync(join(root, "config/agent.json"), JSON.stringify({ firstName: "Legacy", lastName: "Tester", fullName: "Legacy Tester" }, null, 2));
1481
+ execFileSync("git", ["add", "-A"], { cwd: root });
1482
+ execFileSync("git", ["-c", "user.email=test@test", "-c", "user.name=test", "commit", "-q", "-m", "name"], { cwd: root });
1483
+ return root;
1484
+ }
1485
+
1486
+ test("upgrade installs the -session plist into $HOME/Library/LaunchAgents (600, bootstrapped), never kickstarts daemon/session, records sdkVersion in the manifest and writes .maestro/upgrade-result.json", async () => {
1487
+ const root = await makeNamedLegacyAgent();
1488
+ const home = await freshHome();
1489
+ try {
1490
+ // An existing seat: the daemon job is installed (old content) and loaded.
1491
+ writeFileSync(join(home, "Library", "LaunchAgents", "ai.maestro.legacy-daemon.plist"), "<plist>old daemon</plist>");
1492
+ const r = runCli(["upgrade"], root, baseEnv({ HOME: home }));
1493
+ assert.equal(r.status, 0, r.stderr + r.stdout);
1494
+
1495
+ const la = join(home, "Library", "LaunchAgents");
1496
+ const sessionPlist = join(la, "ai.maestro.legacy-session.plist");
1497
+ assert.ok(existsSync(sessionPlist), `session plist must be installed; stdout:\n${r.stdout}`);
1498
+ assert.equal(statSync(sessionPlist).mode & 0o777, 0o600);
1499
+ assert.match(readFileSync(sessionPlist, "utf-8"), /scripts\/session\/supervisor\.sh/);
1500
+ assert.match(readFileSync(join(la, "ai.maestro.legacy-daemon.plist"), "utf-8"), /launchd-wrapper\.sh/, "daemon plist rewritten to the generated one");
1501
+
1502
+ const calls = readFileSync(join(home, "launchctl.log"), "utf-8");
1503
+ assert.match(calls, new RegExp(`^launchctl bootstrap gui/\\d+ ${sessionPlist.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m"), calls);
1504
+ assert.ok(!/kickstart/.test(calls), `nothing is kickstarted by upgrade (the -autoupdate job is the one it runs inside):\n${calls}`);
1505
+ assert.ok(!/bootout|unload/.test(calls), calls);
1506
+ assert.match(calls, /^launchctl print gui\/\d+$/m, "loaded-ness is read from the gui domain");
1507
+
1508
+ // The claude STUB handled `mcp add-json` — not the developer's real CLI.
1509
+ // lib/claude-bin.mjs would otherwise pick /usr/local/bin/claude over PATH;
1510
+ // CLAUDE_BIN in baseEnv is what makes this isolation real.
1511
+ assert.ok(existsSync(join(home, "claude.log")), "the claude stub must be the binary global-setup reached");
1512
+ assert.match(readFileSync(join(home, "claude.log"), "utf-8"), /^claude mcp add-json cohort .* --scope user$/m);
1513
+ const claudeJson = JSON.parse(readFileSync(join(home, ".claude.json"), "utf-8"));
1514
+ assert.equal(claudeJson.mcpServers.cohort.env.COHORT_AGENT_ROOT.replace(/^\/private/, ""), root.replace(/^\/private/, ""), "stub exit 1 → the JSON merge fallback registered the server");
1515
+ assert.ok(!("firstStartVersion" in claudeJson), "a real `claude` would have stamped its own keys into ~/.claude.json");
1516
+ assert.ok(!existsSync(join(home, "npm.log")) || !/npm install -g/.test(readFileSync(join(home, "npm.log"), "utf-8")), "no global install under MAESTRO_SKIP_GLOBAL_INSTALL=1");
1517
+
1518
+ const manifest = JSON.parse(readFileSync(join(root, ".maestro/shipped-manifest.json"), "utf-8"));
1519
+ const sdkVersion = JSON.parse(readFileSync(join(MAESTRO_ROOT, "package.json"), "utf-8")).version;
1520
+ assert.equal(manifest.sdkVersion, sdkVersion);
1521
+ assert.equal(manifest.version, 1, "the manifest FORMAT version is untouched");
1522
+
1523
+ const result = JSON.parse(readFileSync(join(root, ".maestro/upgrade-result.json"), "utf-8"));
1524
+ assert.equal(result.to, sdkVersion);
1525
+ assert.equal(result.from, null, "no prior manifest → from is unknown");
1526
+ assert.match(result.at, /^\d{4}-\d{2}-\d{2}T/);
1527
+ assert.deepEqual(Object.keys(result.steps), ["plists", "launchd", "globalSetup", "globalInstall", "verify"]);
1528
+ assert.equal(result.steps.plists.ok, true, result.steps.plists.detail);
1529
+ assert.equal(result.steps.launchd.ok, true, result.steps.launchd.detail);
1530
+ assert.equal(result.steps.globalSetup.ok, true, result.steps.globalSetup.detail);
1531
+ assert.match(result.steps.globalInstall.detail, /MAESTRO_SKIP_GLOBAL_INSTALL/);
1532
+ assert.match(r.stdout, /Seat reconcile/);
1533
+
1534
+ // global-setup ran for real against the throwaway HOME.
1535
+ assert.match(readFileSync(join(home, ".claude", "CLAUDE.md"), "utf-8"), /BEGIN maestro:identity/);
1536
+ assert.ok(existsSync(join(home, ".claude", "skills", "maestro-main-session", "SKILL.md")));
1537
+
1538
+ // Second run: from is the recorded version, the session job is unchanged.
1539
+ const again = runCli(["upgrade"], root, baseEnv({ HOME: home }));
1540
+ assert.equal(again.status, 0, again.stderr);
1541
+ const result2 = JSON.parse(readFileSync(join(root, ".maestro/upgrade-result.json"), "utf-8"));
1542
+ assert.equal(result2.from, sdkVersion);
1543
+ assert.match(result2.steps.launchd.detail, /0 installed, 0 replaced/);
1544
+ assert.match(result.steps.launchd.detail, /1 replaced/, "the pre-installed daemon plist is rewritten in place");
1545
+ } finally { await rmRoot(root); await rmRoot(home); }
1546
+ });
1547
+
1548
+ test("upgrade --verify exits 0 on a reconciled seat and 1 once a generated plist is missing; doctor prints the same rows; --dry-run runs no post-steps", async () => {
1549
+ const root = await makeNamedLegacyAgent();
1550
+ const home = await freshHome();
1551
+ try {
1552
+ const dry = runCli(["upgrade", "--dry-run"], root, baseEnv({ HOME: home }));
1553
+ assert.equal(dry.status, 0, dry.stderr);
1554
+ assert.ok(!existsSync(join(root, ".maestro/upgrade-result.json")), "--dry-run writes no result");
1555
+ assert.ok(!existsSync(join(home, "Library", "LaunchAgents", "ai.maestro.legacy-session.plist")), "--dry-run installs nothing");
1556
+
1557
+ assert.equal(runCli(["upgrade"], root, baseEnv({ HOME: home })).status, 0);
1558
+ const v = runCli(["upgrade", "--verify"], root, baseEnv({ HOME: home }));
1559
+ assert.equal(v.status, 0, `verify should pass on a reconciled seat:\n${v.stdout}${v.stderr}`);
1560
+ assert.match(v.stdout, /generated plist\(s\) installed and loaded/);
1561
+ assert.match(v.stdout, /identity, MCP, settings, pointer and skills present/);
1562
+ assert.match(v.stdout, /verify: seat is on/);
1563
+
1564
+ execFileSync("rm", [join(home, "Library", "LaunchAgents", "ai.maestro.legacy-session.plist")]);
1565
+ const bad = runCli(["upgrade", "--verify"], root, baseEnv({ HOME: home }));
1566
+ assert.equal(bad.status, 1);
1567
+ assert.match(bad.stdout, /not installed: ai\.maestro\.legacy-session/);
1568
+ assert.match(bad.stdout, /verify: MISMATCH \(plists\)/);
1569
+
1570
+ const d = runCli(["doctor"], root, baseEnv({ HOME: home }));
1571
+ // doctor's fail() rows go to stderr; ok/warn to stdout.
1572
+ assert.match(d.stdout + d.stderr, /not installed: ai\.maestro\.legacy-session/, `doctor carries the verify rows:\n${d.stdout.slice(-1500)}\n${d.stderr.slice(-1500)}`);
1573
+ } finally { await rmRoot(root); await rmRoot(home); }
1574
+ });