@cohortapp/agent-sdk 2.11.14 → 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 (172) 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/identity/persona.mjs +29 -0
  35. package/lib/identity/persona.test.mjs +26 -1
  36. package/lib/mcp/server.test.mjs +9 -4
  37. package/lib/model-router/spawn.test.mjs +21 -0
  38. package/lib/org/board-mine-cache.mjs +99 -0
  39. package/lib/org/board-mine-cache.test.mjs +53 -0
  40. package/lib/org/board.mjs +11 -0
  41. package/lib/org/board.test.mjs +11 -1
  42. package/lib/org/client.mjs +36 -0
  43. package/lib/org/client.test.mjs +46 -0
  44. package/lib/org/inbound/directedness.mjs +18 -2
  45. package/lib/org/inbound/directedness.test.mjs +58 -0
  46. package/lib/org/inbound/index.mjs +8 -1
  47. package/lib/org/inbound/index.test.mjs +22 -0
  48. package/lib/org/mesh-directives.test.mjs +110 -0
  49. package/lib/org/mesh.mjs +61 -1
  50. package/lib/org/protocol.checksum +1 -1
  51. package/lib/org/protocol.mjs +52 -0
  52. package/lib/org/protocol.test.mjs +12 -1
  53. package/lib/org/registry.mjs +3 -2
  54. package/lib/org/tool-surface.mjs +120 -0
  55. package/lib/org/tool-surface.test.mjs +118 -5
  56. package/lib/security/external-content.mjs +1 -1
  57. package/lib/security/external-content.test.mjs +17 -0
  58. package/lib/session/config.mjs +137 -0
  59. package/lib/session/config.test.mjs +92 -0
  60. package/lib/session/feed-core.mjs +229 -0
  61. package/lib/session/feed-core.test.mjs +198 -0
  62. package/lib/session/first-run.mjs +126 -0
  63. package/lib/session/first-run.test.mjs +121 -0
  64. package/lib/session/frontdoor.mjs +266 -0
  65. package/lib/session/frontdoor.test.mjs +205 -0
  66. package/lib/session/handoffs.mjs +295 -0
  67. package/lib/session/handoffs.test.mjs +183 -0
  68. package/lib/session/identity.mjs +220 -0
  69. package/lib/session/identity.test.mjs +180 -0
  70. package/lib/session/inbox-claims.mjs +434 -0
  71. package/lib/session/inbox-claims.test.mjs +286 -0
  72. package/lib/session/launch-args.mjs +161 -0
  73. package/lib/session/launch-args.test.mjs +157 -0
  74. package/lib/session/liveness.mjs +174 -0
  75. package/lib/session/liveness.test.mjs +100 -0
  76. package/lib/session/status-summary.mjs +172 -0
  77. package/lib/session/status-summary.test.mjs +118 -0
  78. package/lib/session-permissions.mjs +39 -3
  79. package/lib/session-permissions.test.mjs +20 -0
  80. package/lib/setup/claude-probe.mjs +161 -24
  81. package/lib/setup/claude-probe.test.mjs +187 -0
  82. package/lib/setup/sections/learning.mjs +2 -1
  83. package/lib/setup/sections/model.mjs +104 -24
  84. package/lib/setup/sections/model.test.mjs +240 -0
  85. package/lib/setup/sections/org.mjs +27 -2
  86. package/lib/setup/sections/org.test.mjs +35 -2
  87. package/lib/setup/sections/verify.mjs +5 -0
  88. package/lib/setup/state.mjs +30 -10
  89. package/lib/setup/state.test.mjs +24 -1
  90. package/lib/singleton.js +11 -3
  91. package/lib/singleton.test.mjs +16 -0
  92. package/lib/subagents/lock.mjs +1 -1
  93. package/lib/telemetry/collect.mjs +270 -6
  94. package/lib/telemetry/collect.test.mjs +196 -1
  95. package/lib/upgrade/global-refresh.mjs +108 -0
  96. package/lib/upgrade/global-refresh.test.mjs +65 -0
  97. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  98. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  99. package/lib/upgrade/post-steps.mjs +151 -0
  100. package/lib/upgrade/post-steps.test.mjs +200 -0
  101. package/lib/upgrade/verify.mjs +215 -0
  102. package/lib/upgrade/verify.test.mjs +164 -0
  103. package/lib/voice/outbound.mjs +3 -2
  104. package/lib/voice/post-call-brief.mjs +2 -1
  105. package/lib/voice/session-rotation.mjs +6 -1
  106. package/lib/voice/session-rotation.test.mjs +114 -0
  107. package/package.json +3 -3
  108. package/plugins/maestro-skills/plugin.json +21 -1
  109. package/plugins/maestro-skills/skills/board-work.md +63 -0
  110. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  111. package/plugins/maestro-skills/skills/main-session.md +102 -0
  112. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  113. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  114. package/scaffold/CLAUDE.md +34 -0
  115. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  116. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  117. package/scripts/ci/check.mjs +3 -0
  118. package/scripts/collective/hook-runner.mjs +39 -4
  119. package/scripts/collective/hook-runner.test.mjs +85 -2
  120. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  121. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  122. package/scripts/daemon/agent-daemon.mjs +141 -10
  123. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  124. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  125. package/scripts/daemon/assurance.mjs +461 -37
  126. package/scripts/daemon/assurance.test.mjs +408 -43
  127. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
  128. package/scripts/daemon/cadence-consumer.mjs +254 -78
  129. package/scripts/daemon/cadence-handlers.mjs +53 -0
  130. package/scripts/daemon/classifier.mjs +1 -1
  131. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  132. package/scripts/daemon/dispatcher.mjs +127 -19
  133. package/scripts/daemon/health.mjs +12 -1
  134. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  135. package/scripts/daemon/inbox-deferral.mjs +6 -0
  136. package/scripts/daemon/lib/self-echo.mjs +201 -0
  137. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  138. package/scripts/daemon/maestro-daemon.mjs +3 -0
  139. package/scripts/daemon/prompt-builder.mjs +9 -1
  140. package/scripts/daemon/prompt-builder.test.mjs +22 -0
  141. package/scripts/daemon/responder.mjs +61 -41
  142. package/scripts/daemon/sdk-version.mjs +51 -0
  143. package/scripts/daemon/sdk-version.test.mjs +31 -0
  144. package/scripts/hooks/pre-send-audit.sh +97 -4
  145. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  146. package/scripts/local-triggers/autoupdate.sh +243 -19
  147. package/scripts/local-triggers/autoupdate.test.mjs +488 -0
  148. package/scripts/local-triggers/generate-plists.sh +24 -1
  149. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  150. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  151. package/scripts/org/send-orgmail.mjs +27 -3
  152. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  153. package/scripts/poller/slack-poller.mjs +13 -1
  154. package/scripts/poller/utils.mjs +46 -1
  155. package/scripts/poller-launchd/install.sh +19 -11
  156. package/scripts/poller-launchd/install.test.mjs +243 -0
  157. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  158. package/scripts/poller-launchd/migrate.sh +66 -0
  159. package/scripts/poller-launchd/poller.plist.template +4 -2
  160. package/scripts/session/feed.mjs +237 -0
  161. package/scripts/session/feed.test.mjs +196 -0
  162. package/scripts/session/supervisor-sh.test.mjs +218 -0
  163. package/scripts/session/supervisor.mjs +328 -0
  164. package/scripts/session/supervisor.sh +141 -0
  165. package/scripts/session/supervisor.test.mjs +482 -0
  166. package/scripts/setup/configure-macos.sh +250 -55
  167. package/scripts/setup/configure-macos.test.mjs +306 -0
  168. package/scripts/setup/init-agent.sh +112 -7
  169. package/scripts/setup/init-agent.test.mjs +220 -1
  170. package/scripts/watchdog/memory-watchdog.sh +37 -1
  171. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  172. package/scripts/setup/boot-claude-session.sh +0 -94
@@ -0,0 +1,410 @@
1
+ /**
2
+ * lib/cli/global-setup-extras.mjs — the front-door half of `maestro global-setup`.
3
+ *
4
+ * `bin/maestro.mjs#globalSetup` wires the collective hooks, the collective
5
+ * CLAUDE.md block and the machine pointer. This module adds what §3.7 of the
6
+ * 2026-09-08 design asks for, so ANY Claude Code session on the seat machine
7
+ * knows who the agent is and has its tools:
8
+ *
9
+ * 1. ~/.claude/CLAUDE.md the delimited `maestro:identity` block
10
+ * 2. ~/.claude.json user-scope MCP server `cohort` → cohort-mcp
11
+ * (via `claude mcp add-json --scope user` when
12
+ * the CLI is available, else a JSON merge)
13
+ * 3. ~/.claude/settings.json crossSessionInbound, permissions.allow,
14
+ * the mcp__cohort__messaging_send PreToolUse
15
+ * persona/audit hook
16
+ * 4. ~/.claude/skills/maestro-<name>/ every shipped skill (symlink for a global
17
+ * install, copy otherwise)
18
+ *
19
+ * Additive, idempotent, backed up: JSON files are copied to
20
+ * `<file>.backup.<stamp>` before the first write of a run, and a re-run that
21
+ * changes nothing writes nothing (and backs up nothing). Every step is
22
+ * fail-open and reported; the merges themselves are the pure functions in
23
+ * lib/collective/global-config.mjs. All I/O is injectable for tests.
24
+ *
25
+ * Two rules for the two files Claude Code itself owns (~/.claude.json and
26
+ * ~/.claude/settings.json), because a wrong write there costs the seat its
27
+ * OAuth account, onboarding state and per-project trust:
28
+ * - A file that is PRESENT but UNPARSEABLE (mid-rewrite by a running claude,
29
+ * a BOM, trailing garbage) is never "merged onto {}": the step warns and
30
+ * skips; nothing is written.
31
+ * - A write is a read → merge → verify-unchanged → rename cycle
32
+ * ({@link mergeJsonFileAtomic}) that keeps the file's mode (0600 stays
33
+ * 0600) and re-merges when another process rewrote the file in between —
34
+ * the closest thing to a lock a file Claude Code rewrites at will allows.
35
+ *
36
+ * @module lib/cli/global-setup-extras
37
+ */
38
+
39
+ "use strict";
40
+
41
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, statSync, openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync } from "node:fs";
42
+ import { execFileSync } from "node:child_process";
43
+ import { homedir } from "node:os";
44
+ import { join, dirname } from "node:path";
45
+ import { fileURLToPath } from "node:url";
46
+ import {
47
+ buildIdentityClaudeMd, upsertDelimitedBlock, IDENTITY_BEGIN, IDENTITY_END,
48
+ cohortMcpEntry, mergeMcpServers, mergeSessionSettings,
49
+ } from "../collective/global-config.mjs";
50
+ import { installSkills, isGlobalInstall } from "../collective/global-skills.mjs";
51
+ import { buildContext } from "../render.mjs";
52
+
53
+ /** The SDK package root (two levels up from lib/cli/). */
54
+ export const SDK_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
55
+
56
+ function stamp(now) {
57
+ return new Date(Number.isFinite(now) ? now : Date.now()).toISOString().replace(/[:.]/g, "-");
58
+ }
59
+
60
+ /**
61
+ * Read a JSON file. `{present:false}` when absent; `{present:true, value}` when
62
+ * parsed; `{present:true, value:null, unparseable:true}` when the bytes are
63
+ * there but do not parse — the caller must NOT write over that.
64
+ */
65
+ function readJsonFile(p) {
66
+ if (!existsSync(p)) return { present: false, value: null };
67
+ try {
68
+ return { present: true, value: JSON.parse(readFileSync(p, "utf8")) };
69
+ } catch {
70
+ return { present: true, value: null, unparseable: true };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * First `<dir>/<name>` on a PATH string that exists as a file. Pure over the
76
+ * injected `existsSync`. Null when nothing matches or the PATH is empty.
77
+ * @param {string} name @param {string} pathEnv @param {{existsSync?:Function}} [deps]
78
+ */
79
+ export function findOnPath(name, pathEnv, deps = {}) {
80
+ const ex = deps.existsSync || existsSync;
81
+ const n = String(name || "");
82
+ if (!n) return null;
83
+ for (const dir of String(pathEnv || "").split(":")) {
84
+ if (!dir) continue;
85
+ const candidate = join(dir, n);
86
+ try { if (ex(candidate)) return candidate; } catch { /* an unreadable PATH entry is not a hit */ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Turn the resolver's answer into a usable binary path or null. lib/claude-bin
93
+ * returns the bare name "claude" when none of its fixed locations exist (an
94
+ * nvm / npm-prefix install); that is a PATH lookup, not a miss.
95
+ * @param {string} resolved @param {{pathEnv?:string, existsSync?:Function}} [o]
96
+ */
97
+ export function pickClaudeBin(resolved, o = {}) {
98
+ const ex = o.existsSync || existsSync;
99
+ const r = String(resolved || "");
100
+ if (!r) return null;
101
+ if (r === "claude" || !r.includes("/")) return findOnPath(r, o.pathEnv === undefined ? process.env.PATH : o.pathEnv, { existsSync: ex });
102
+ try { return ex(r) ? r : null; } catch { return null; }
103
+ }
104
+
105
+ /**
106
+ * Read → merge → publish, refusing to clobber. `mergeFn(parsedOrNull)` returns
107
+ * `{config, changed}` (the shape of the pure mergers). The file's mode is kept
108
+ * (a new file gets `o.mode`, default 0o600). Before the rename the file is
109
+ * re-read and compared with what was merged from: a difference means another
110
+ * process wrote in between, so the merge is redone (up to `o.attempts`, 3).
111
+ *
112
+ * @returns {{ok:boolean, changed:boolean, attempts:number, reason?:string}}
113
+ */
114
+ export function mergeJsonFileAtomic(path, mergeFn, o = {}) {
115
+ const maxAttempts = Number.isFinite(o.attempts) && o.attempts > 0 ? o.attempts : 3;
116
+ let attempts = 0;
117
+ while (attempts < maxAttempts) {
118
+ attempts += 1;
119
+ let raw = null;
120
+ let mode = Number.isFinite(o.mode) ? o.mode : 0o600;
121
+ if (existsSync(path)) {
122
+ try {
123
+ raw = readFileSync(path, "utf8");
124
+ mode = statSync(path).mode & 0o777;
125
+ } catch (err) {
126
+ return { ok: false, changed: false, attempts, reason: `unreadable: ${err && err.message}` };
127
+ }
128
+ }
129
+ let parsed = null;
130
+ if (raw !== null) {
131
+ try { parsed = JSON.parse(raw); } catch { return { ok: false, changed: false, attempts, reason: "unparseable" }; }
132
+ }
133
+ const merged = mergeFn(parsed);
134
+ if (!merged || !merged.changed) return { ok: true, changed: false, attempts };
135
+ const out = JSON.stringify(merged.config, null, 2) + "\n";
136
+ mkdirSync(dirname(path), { recursive: true });
137
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
138
+ const fd = openSync(tmp, "w", mode);
139
+ try { writeSync(fd, out); fsyncSync(fd); } finally { closeSync(fd); }
140
+ // Verify nobody wrote in between; a change means our merge is stale.
141
+ let now = null;
142
+ try { now = existsSync(path) ? readFileSync(path, "utf8") : null; } catch { now = undefined; }
143
+ if (now !== raw) {
144
+ try { unlinkSync(tmp); } catch { /* orphan tmp is harmless */ }
145
+ continue;
146
+ }
147
+ try {
148
+ renameSync(tmp, path);
149
+ } catch (err) {
150
+ try { unlinkSync(tmp); } catch { /* ignore */ }
151
+ return { ok: false, changed: false, attempts, reason: `rename failed: ${err && err.message}` };
152
+ }
153
+ return { ok: true, changed: true, attempts };
154
+ }
155
+ return { ok: false, changed: false, attempts, reason: "concurrent writer: gave up after retries" };
156
+ }
157
+
158
+ /** `npm root -g`, "" when npm is unavailable (→ copy mode). */
159
+ function defaultGlobalRoot() {
160
+ try {
161
+ return execFileSync("npm", ["root", "-g"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000 }).trim();
162
+ } catch {
163
+ return ""; // no npm on PATH: skills are copied rather than linked
164
+ }
165
+ }
166
+
167
+ /** The claude CLI, or null when it is not installed (→ JSON merge). */
168
+ async function defaultClaudeBin(pathEnv) {
169
+ try {
170
+ const mod = await import("../claude-bin.mjs");
171
+ return pickClaudeBin(mod.resolveClaudeBin(), { pathEnv });
172
+ } catch {
173
+ return null; // resolver unavailable: the JSON merge path is the fallback
174
+ }
175
+ }
176
+
177
+ function defaultExec(bin, args) {
178
+ try {
179
+ const stdout = execFileSync(bin, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30_000 });
180
+ return { status: 0, stdout };
181
+ } catch (err) {
182
+ return { status: typeof err.status === "number" ? err.status : 1, stderr: String((err && err.stderr) || (err && err.message) || "") };
183
+ }
184
+ }
185
+
186
+ /** Marker every persona-gated pre-send-audit.sh carries (section a2). */
187
+ export const PERSONA_GATE_MARKER = "PERSONA_PATTERNS";
188
+
189
+ /** Does `<agentRoot>/scripts/hooks/pre-send-audit.sh` exist and carry the persona gate? */
190
+ export function checkPersonaHookScript(agentRoot) {
191
+ const path = join(String(agentRoot || ""), "scripts", "hooks", "pre-send-audit.sh");
192
+ try {
193
+ if (!existsSync(path)) return { path, present: false, personaGate: false };
194
+ return { path, present: true, personaGate: readFileSync(path, "utf8").includes(PERSONA_GATE_MARKER) };
195
+ } catch {
196
+ return { path, present: false, personaGate: false };
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Apply the four steps. Returns a summary; never throws.
202
+ *
203
+ * @param {object} o
204
+ * @param {string} o.agentRoot the agent repo being wired
205
+ * @param {string} [o.claudeDir] ~/.claude
206
+ * @param {string} [o.claudeJsonPath] ~/.claude.json
207
+ * @param {string} [o.sdkRoot] where plugins/maestro-skills lives (default: this package)
208
+ * @param {boolean} [o.dryRun]
209
+ * @param {string|null} [o.claudeBin] claude CLI path; null = not available
210
+ * @param {(bin:string,args:string[])=>{status:number}} [o.execImpl]
211
+ * @param {string} [o.globalRoot] `npm root -g`
212
+ * @param {string} [o.pathEnv] PATH to resolve `claude` / `cohort-mcp` on (default process.env.PATH)
213
+ * @param {number} [o.now]
214
+ * @param {(m:string)=>void} [o.log] [o.ok] [o.warn]
215
+ */
216
+ export async function applyFrontDoorSetup(o = {}) {
217
+ const log = o.log || (() => {});
218
+ const ok = o.ok || log;
219
+ const warn = o.warn || log;
220
+ const dryRun = !!o.dryRun;
221
+ const pre = dryRun ? "[dry-run] " : "";
222
+ const agentRoot = String(o.agentRoot || "");
223
+ const claudeDir = o.claudeDir || join(homedir(), ".claude");
224
+ const claudeJsonPath = o.claudeJsonPath || join(homedir(), ".claude.json");
225
+ const sdkRoot = o.sdkRoot || SDK_ROOT;
226
+ const now = Number.isFinite(o.now) ? o.now : Date.now();
227
+ const ts = stamp(now);
228
+ const pathEnv = o.pathEnv === undefined ? (process.env.PATH || "") : String(o.pathEnv || "");
229
+ const summary = {
230
+ ok: true,
231
+ identity: { changed: false, skipped: false },
232
+ mcp: { changed: false, via: null, skipped: null },
233
+ settings: { changes: [], skipped: null, hookScript: null },
234
+ skills: { mode: null, installed: [], unchanged: [] },
235
+ };
236
+ let agentJson = null; // config/agent.json, shared by the identity block and the skill renderer
237
+
238
+ const backupOnce = (p) => {
239
+ if (dryRun || !existsSync(p)) return;
240
+ try { copyFileSync(p, `${p}.backup.${ts}`); } catch { /* a missing backup must not block the write; the merge is additive */ }
241
+ };
242
+
243
+ // 1) Identity block.
244
+ try {
245
+ const aj = join(agentRoot, "config", "agent.json");
246
+ if (existsSync(aj)) { try { agentJson = JSON.parse(readFileSync(aj, "utf8")); } catch { agentJson = null; } }
247
+ if (!agentJson || !(agentJson.fullName || agentJson.firstName)) {
248
+ summary.identity.skipped = true;
249
+ warn("identity block skipped: config/agent.json has no name yet (run `maestro setup`, then re-run global-setup)");
250
+ } else {
251
+ const mdPath = join(claudeDir, "CLAUDE.md");
252
+ let cur = "";
253
+ if (existsSync(mdPath)) { try { cur = readFileSync(mdPath, "utf8"); } catch { cur = ""; } }
254
+ const r = upsertDelimitedBlock(cur, buildIdentityClaudeMd(agentJson, { agentRoot }), { begin: IDENTITY_BEGIN, end: IDENTITY_END });
255
+ summary.identity.changed = r.changed;
256
+ if (r.changed && !dryRun) { mkdirSync(claudeDir, { recursive: true }); writeFileSync(mdPath, r.content); }
257
+ ok(r.changed ? `${pre}global CLAUDE.md identity block written` : "global CLAUDE.md identity block up to date");
258
+ }
259
+ } catch (err) {
260
+ summary.ok = false;
261
+ warn(`identity block failed: ${err && err.message}`);
262
+ }
263
+
264
+ // 2) MCP server entry (user scope).
265
+ try {
266
+ // Bare `cohort-mcp` needs the global npm install. Until that lands on a
267
+ // seat (WP-M6 refreshes it on upgrade), point the entry at this package's
268
+ // own bin under the current node so every session's `cohort` server still
269
+ // starts; a later re-run flips it back to the bare command once it is on PATH.
270
+ let entry;
271
+ if (findOnPath("cohort-mcp", pathEnv)) {
272
+ entry = cohortMcpEntry(agentRoot);
273
+ } else {
274
+ const localBin = join(sdkRoot, "bin", "cohort-mcp.mjs");
275
+ if (existsSync(localBin)) {
276
+ entry = cohortMcpEntry(agentRoot, { command: process.execPath, args: [localBin] });
277
+ log(`cohort-mcp is not on PATH — registering ${localBin} under ${process.execPath} (re-run global-setup after \`npm i -g @cohortapp/agent-sdk\`)`);
278
+ } else {
279
+ entry = cohortMcpEntry(agentRoot);
280
+ warn("cohort-mcp is not on PATH and this package has no bin/cohort-mcp.mjs; the entry is registered as the bare command and will fail to start until the global install exists");
281
+ }
282
+ }
283
+ const cur = readJsonFile(claudeJsonPath);
284
+ if (cur.unparseable) {
285
+ summary.ok = false;
286
+ summary.mcp.skipped = "unparseable";
287
+ warn(`${claudeJsonPath} is present but does not parse as JSON — leaving it untouched (a running claude may be mid-rewrite; re-run global-setup in a minute, or fix the file). cohort MCP server NOT registered.`);
288
+ } else {
289
+ const merged = mergeMcpServers(cur.value, entry);
290
+ if (!merged.changed) {
291
+ ok("cohort MCP server already registered in ~/.claude.json");
292
+ } else if (dryRun) {
293
+ summary.mcp.changed = true;
294
+ summary.mcp.via = "json";
295
+ ok(`${pre}cohort MCP server → ~/.claude.json (user scope)`);
296
+ } else {
297
+ summary.mcp.changed = true;
298
+ const claudeBin = o.claudeBin === undefined ? await defaultClaudeBin(pathEnv) : o.claudeBin;
299
+ const exec = o.execImpl || defaultExec;
300
+ let done = false;
301
+ if (claudeBin) {
302
+ try {
303
+ const r = exec(claudeBin, ["mcp", "add-json", "cohort", JSON.stringify(entry), "--scope", "user"]);
304
+ if (r && r.status === 0) {
305
+ // Trust but verify: the CLI owns the file format; confirm the entry landed.
306
+ const after = readJsonFile(claudeJsonPath);
307
+ done = !after.unparseable && !mergeMcpServers(after.value, entry).changed;
308
+ }
309
+ } catch {
310
+ done = false; // spawn failure → JSON merge below
311
+ }
312
+ }
313
+ if (done) {
314
+ summary.mcp.via = "cli";
315
+ ok("cohort MCP server registered via `claude mcp add-json --scope user`");
316
+ } else {
317
+ backupOnce(claudeJsonPath);
318
+ const w = mergeJsonFileAtomic(claudeJsonPath, (doc) => mergeMcpServers(doc, entry), { mode: 0o600 });
319
+ if (!w.ok) {
320
+ summary.ok = false;
321
+ summary.mcp.skipped = w.reason || "write failed";
322
+ warn(`cohort MCP server NOT written to ${claudeJsonPath}: ${w.reason}`);
323
+ } else {
324
+ summary.mcp.via = "json";
325
+ ok(`cohort MCP server merged into ${claudeJsonPath}${claudeBin ? " (CLI registration failed; JSON merge used)" : ""}`);
326
+ }
327
+ }
328
+ }
329
+ }
330
+ } catch (err) {
331
+ summary.ok = false;
332
+ warn(`MCP registration failed: ${err && err.message}`);
333
+ }
334
+
335
+ // 3) settings.json.
336
+ try {
337
+ const settingsPath = join(claudeDir, "settings.json");
338
+ const cur = readJsonFile(settingsPath);
339
+ if (cur.unparseable) {
340
+ summary.ok = false;
341
+ summary.settings.skipped = "unparseable";
342
+ warn(`${settingsPath} is present but does not parse as JSON — leaving it untouched (its hooks and permissions would be lost). Front-door settings NOT applied.`);
343
+ } else {
344
+ const r = mergeSessionSettings(cur.value, { agentRoot });
345
+ summary.settings.changes = r.changes;
346
+ if (r.changes.length === 0) {
347
+ ok("global settings already carry the front-door additions (no change)");
348
+ } else if (dryRun) {
349
+ ok(`${pre}global settings updated: ${r.changes.join(", ")}`);
350
+ } else {
351
+ backupOnce(settingsPath);
352
+ mkdirSync(claudeDir, { recursive: true });
353
+ const w = mergeJsonFileAtomic(settingsPath, (doc) => {
354
+ const m = mergeSessionSettings(doc, { agentRoot });
355
+ return { config: m.settings, changed: m.changes.length > 0 };
356
+ }, { mode: 0o644 });
357
+ if (!w.ok) {
358
+ summary.ok = false;
359
+ summary.settings.skipped = w.reason || "write failed";
360
+ warn(`global settings NOT written to ${settingsPath}: ${w.reason}`);
361
+ } else {
362
+ ok(`global settings updated: ${r.changes.join(", ")}`);
363
+ }
364
+ }
365
+ }
366
+ // The PreToolUse persona hook points at the AGENT's copy of pre-send-audit.sh.
367
+ // `upgrade` keeps a locally modified copy, so a seat can have the matcher
368
+ // wired to a script that predates the persona gate. Say so; nothing else
369
+ // in this WP can (doctor/verify are M5/M6).
370
+ summary.settings.hookScript = checkPersonaHookScript(agentRoot);
371
+ if (summary.settings.hookScript.present && !summary.settings.hookScript.personaGate) {
372
+ warn(`${summary.settings.hookScript.path} has no persona gate (PERSONA_PATTERNS) — the file is older than this SDK or locally modified; the mcp__cohort outbound hook will not screen persona leaks until it is refreshed (\`maestro upgrade --force-overwrite\`, or merge by hand)`);
373
+ } else if (!summary.settings.hookScript.present) {
374
+ warn(`${summary.settings.hookScript.path} is missing — the global PreToolUse persona hook will fail closed on every mcp__cohort outbound call until \`maestro upgrade\` ships it`);
375
+ }
376
+ } catch (err) {
377
+ summary.ok = false;
378
+ warn(`settings merge failed: ${err && err.message}`);
379
+ }
380
+
381
+ // 4) Skills — rendered to this agent's identity where they carry tokens
382
+ // (Claude Code loads SKILL.md verbatim; only the cadence consumer used
383
+ // to render). No identity yet → templates verbatim, as before.
384
+ try {
385
+ const globalRoot = o.globalRoot === undefined ? defaultGlobalRoot() : o.globalRoot;
386
+ const mode = isGlobalInstall(sdkRoot, { globalRoot }) ? "symlink" : "copy";
387
+ let context = null;
388
+ if (agentJson) {
389
+ let companyJson = null;
390
+ const cj = join(agentRoot, "config", "company.json");
391
+ if (existsSync(cj)) { try { companyJson = JSON.parse(readFileSync(cj, "utf8")); } catch { companyJson = null; } }
392
+ context = buildContext(agentJson, null, companyJson);
393
+ }
394
+ const r = installSkills({ sdkRoot, skillsDir: join(claudeDir, "skills"), mode, dryRun, context });
395
+ summary.skills = { mode, installed: r.installed, unchanged: r.unchanged, rendered: r.rendered || [], errors: r.errors };
396
+ const renderedNote = r.rendered && r.rendered.length ? `; ${r.rendered.length} rendered to ${agentJson.firstName || agentJson.fullName}` : "";
397
+ if (!r.ok && r.error) warn(`skills not installed: ${r.error}`);
398
+ else if (r.installed.length === 0) ok(`global skills up to date (${r.unchanged.length} ${mode === "symlink" ? "linked" : "copied"}${renderedNote})`);
399
+ else ok(`${pre}global skills ${mode === "symlink" ? "linked" : "copied"}: ${r.installed.join(", ")}${r.unchanged.length ? ` (+${r.unchanged.length} unchanged)` : ""}${renderedNote}`);
400
+ if (!agentJson && r.plan && r.plan.length) warn("skills installed as templates ({{agent.*}} unrendered): config/agent.json has no name yet — re-run global-setup after `maestro setup`");
401
+ for (const e of r.errors || []) warn(`skill ${e.name}: ${e.error}`);
402
+ } catch (err) {
403
+ summary.ok = false;
404
+ warn(`skills install failed: ${err && err.message}`);
405
+ }
406
+
407
+ return summary;
408
+ }
409
+
410
+ export default { applyFrontDoorSetup, mergeJsonFileAtomic, findOnPath, pickClaudeBin, checkPersonaHookScript, SDK_ROOT };