@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
@@ -0,0 +1,367 @@
1
+ /**
2
+ * global-setup-extras.test.mjs — the front-door half of `maestro global-setup`.
3
+ * Run: node --test lib/cli/global-setup-extras.test.mjs
4
+ */
5
+ "use strict";
6
+
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, readdirSync, lstatSync, statSync, chmodSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ import { applyFrontDoorSetup, mergeJsonFileAtomic, findOnPath, pickClaudeBin, checkPersonaHookScript } from "./global-setup-extras.mjs";
14
+ import { IDENTITY_BEGIN, CLAUDE_MD_BEGIN, PRE_SEND_HOOK_MATCHER } from "../collective/global-config.mjs";
15
+
16
+ function fixture() {
17
+ const root = mkdtempSync(join(tmpdir(), "gs-extras-"));
18
+ const agentRoot = join(root, "alex-ai");
19
+ mkdirSync(join(agentRoot, "config"), { recursive: true });
20
+ writeFileSync(join(agentRoot, "config", "agent.json"), JSON.stringify({ firstName: "Alex", fullName: "Alex Rivera", title: "Chief of Staff", company: "Acme", repoSlug: "alex-ai" }));
21
+ const sdkRoot = join(root, "sdk");
22
+ mkdirSync(join(sdkRoot, "plugins", "maestro-skills", "skills"), { recursive: true });
23
+ writeFileSync(join(sdkRoot, "plugins", "maestro-skills", "skills", "main-session.md"), "---\nname: main-session\n---\nbody\n");
24
+ const claudeDir = join(root, "home", ".claude");
25
+ mkdirSync(claudeDir, { recursive: true });
26
+ const claudeJsonPath = join(root, "home", ".claude.json");
27
+ const logs = [];
28
+ const io = { log: (m) => logs.push(`log:${m}`), ok: (m) => logs.push(`ok:${m}`), warn: (m) => logs.push(`warn:${m}`) };
29
+ return { root, agentRoot, sdkRoot, claudeDir, claudeJsonPath, logs, io };
30
+ }
31
+
32
+ test("applyFrontDoorSetup: identity block, JSON-merged MCP entry (no claude CLI), settings, skills copy — then a no-op re-run", async () => {
33
+ const f = fixture();
34
+ try {
35
+ writeFileSync(join(f.claudeDir, "CLAUDE.md"), `# mine\n\n${CLAUDE_MD_BEGIN}\nold collective\n<!-- END maestro:collective -->\n`);
36
+ writeFileSync(join(f.claudeDir, "settings.json"), JSON.stringify({ permissions: { allow: ["Bash(ls)"] }, hooks: { Stop: [] } }));
37
+ writeFileSync(f.claudeJsonPath, JSON.stringify({ mcpServers: { other: { command: "x" } }, oauthAccount: { id: "keep" } }));
38
+ const execCalls = [];
39
+ const r = await applyFrontDoorSetup({
40
+ agentRoot: f.agentRoot, sdkRoot: f.sdkRoot, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
41
+ execImpl: (bin, args) => { execCalls.push({ bin, args }); return { status: 1, stderr: "no such command" }; },
42
+ claudeBin: null, globalRoot: "/opt/homebrew/lib/node_modules", now: Date.parse("2026-09-08T12:00:00Z"), ...f.io,
43
+ });
44
+ assert.equal(r.ok, true);
45
+ // 1) identity block written, collective block preserved.
46
+ const md = readFileSync(join(f.claudeDir, "CLAUDE.md"), "utf8");
47
+ assert.match(md, /# mine/);
48
+ assert.match(md, /old collective/);
49
+ assert.match(md, new RegExp(IDENTITY_BEGIN));
50
+ assert.match(md, /Alex Rivera/);
51
+ assert.equal(r.identity.changed, true);
52
+ // 2) MCP entry merged via JSON (no CLI available → not even attempted).
53
+ assert.equal(execCalls.length, 0, "claudeBin:null → no CLI attempt");
54
+ const cj = JSON.parse(readFileSync(f.claudeJsonPath, "utf8"));
55
+ assert.deepEqual(cj.mcpServers.other, { command: "x" });
56
+ assert.equal(cj.mcpServers.cohort.command, "cohort-mcp");
57
+ assert.equal(cj.mcpServers.cohort.env.COHORT_AGENT_ROOT, f.agentRoot);
58
+ assert.deepEqual(cj.oauthAccount, { id: "keep" });
59
+ assert.equal(r.mcp.via, "json");
60
+ assert.ok(readdirSync(join(f.root, "home")).some((n) => n.startsWith(".claude.json.backup.")), "timestamped backup of ~/.claude.json");
61
+ // 3) settings merged with backup.
62
+ const st = JSON.parse(readFileSync(join(f.claudeDir, "settings.json"), "utf8"));
63
+ assert.equal(st.crossSessionInbound, "accept");
64
+ assert.ok(st.permissions.allow.includes("Bash(ls)"));
65
+ assert.ok(st.permissions.allow.includes("mcp__cohort__*"));
66
+ assert.ok(st.hooks.PreToolUse.some((g) => g.matcher === PRE_SEND_HOOK_MATCHER && /messaging_send\|/.test(g.matcher)));
67
+ assert.deepEqual(st.hooks.Stop, []);
68
+ assert.ok(readdirSync(f.claudeDir).some((n) => n.startsWith("settings.json.backup.")), "timestamped backup of settings.json");
69
+ assert.ok(r.settings.changes.length > 0);
70
+ // 4) skills copied (sdk is not under the global root).
71
+ assert.equal(r.skills.mode, "copy");
72
+ const skill = join(f.claudeDir, "skills", "maestro-main-session", "SKILL.md");
73
+ assert.ok(existsSync(skill));
74
+ assert.equal(lstatSync(skill).isSymbolicLink(), false);
75
+ assert.deepEqual(r.skills.installed, ["main-session"]);
76
+
77
+ // Re-run: nothing changes, no new backups.
78
+ const backupsBefore = readdirSync(f.claudeDir).filter((n) => n.includes(".backup.")).length;
79
+ const r2 = await applyFrontDoorSetup({
80
+ agentRoot: f.agentRoot, sdkRoot: f.sdkRoot, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
81
+ execImpl: () => ({ status: 1 }), claudeBin: null, globalRoot: "/opt/homebrew/lib/node_modules", now: Date.parse("2026-09-08T12:05:00Z"), ...f.io,
82
+ });
83
+ assert.equal(r2.ok, true);
84
+ assert.equal(r2.identity.changed, false);
85
+ assert.equal(r2.mcp.changed, false);
86
+ assert.deepEqual(r2.settings.changes, []);
87
+ assert.deepEqual(r2.skills.installed, []);
88
+ assert.equal(readdirSync(f.claudeDir).filter((n) => n.includes(".backup.")).length, backupsBefore, "idempotent: no new backups");
89
+ } finally { rmSync(f.root, { recursive: true, force: true }); }
90
+ });
91
+
92
+ test("applyFrontDoorSetup: uses `claude mcp add-json --scope user` when the CLI is available and it succeeds; symlinks skills for a global install", async () => {
93
+ const f = fixture();
94
+ try {
95
+ const globalRoot = join(f.root, "global", "node_modules");
96
+ const sdkRoot = join(globalRoot, "@cohortapp", "agent-sdk");
97
+ mkdirSync(join(sdkRoot, "plugins", "maestro-skills", "skills"), { recursive: true });
98
+ writeFileSync(join(sdkRoot, "plugins", "maestro-skills", "skills", "board-work.md"), "---\nname: board-work\n---\nbw\n");
99
+ const execCalls = [];
100
+ const r = await applyFrontDoorSetup({
101
+ agentRoot: f.agentRoot, sdkRoot, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
102
+ claudeBin: "/fake/claude",
103
+ execImpl: (bin, args) => {
104
+ execCalls.push({ bin, args });
105
+ // Emulate the CLI writing the user-scope entry.
106
+ const cur = existsSync(f.claudeJsonPath) ? JSON.parse(readFileSync(f.claudeJsonPath, "utf8")) : {};
107
+ cur.mcpServers = { ...(cur.mcpServers || {}), [args[2]]: JSON.parse(args[3]) };
108
+ writeFileSync(f.claudeJsonPath, JSON.stringify(cur));
109
+ return { status: 0, stdout: "Added stdio MCP server cohort" };
110
+ },
111
+ globalRoot, now: Date.parse("2026-09-08T12:00:00Z"), ...f.io,
112
+ });
113
+ assert.equal(r.ok, true);
114
+ assert.equal(execCalls.length, 1);
115
+ assert.equal(execCalls[0].bin, "/fake/claude");
116
+ assert.deepEqual(execCalls[0].args.slice(0, 3), ["mcp", "add-json", "cohort"]);
117
+ assert.ok(execCalls[0].args.includes("--scope") && execCalls[0].args.includes("user"));
118
+ assert.equal(JSON.parse(execCalls[0].args[3]).env.COHORT_AGENT_ROOT, f.agentRoot);
119
+ assert.equal(r.mcp.via, "cli");
120
+ assert.equal(r.mcp.changed, true);
121
+ const cj = JSON.parse(readFileSync(f.claudeJsonPath, "utf8"));
122
+ assert.equal(cj.mcpServers.cohort.command, "cohort-mcp");
123
+ // Skills: symlink for a global install.
124
+ assert.equal(r.skills.mode, "symlink");
125
+ assert.ok(lstatSync(join(f.claudeDir, "skills", "maestro-board-work", "SKILL.md")).isSymbolicLink());
126
+ // Settings created from scratch when absent.
127
+ const st = JSON.parse(readFileSync(join(f.claudeDir, "settings.json"), "utf8"));
128
+ assert.equal(st.crossSessionInbound, "accept");
129
+ } finally { rmSync(f.root, { recursive: true, force: true }); }
130
+ });
131
+
132
+ test("applyFrontDoorSetup: CLI failure falls back to the JSON merge; dry-run writes nothing", async () => {
133
+ const f = fixture();
134
+ try {
135
+ const r = await applyFrontDoorSetup({
136
+ agentRoot: f.agentRoot, sdkRoot: f.sdkRoot, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
137
+ claudeBin: "/fake/claude", execImpl: () => { throw new Error("spawn ENOENT"); },
138
+ globalRoot: "", now: Date.parse("2026-09-08T12:00:00Z"), ...f.io,
139
+ });
140
+ assert.equal(r.ok, true);
141
+ assert.equal(r.mcp.via, "json", "a throwing CLI is survivable");
142
+ assert.equal(JSON.parse(readFileSync(f.claudeJsonPath, "utf8")).mcpServers.cohort.command, "cohort-mcp");
143
+
144
+ const g = fixture();
145
+ try {
146
+ const d = await applyFrontDoorSetup({
147
+ agentRoot: g.agentRoot, sdkRoot: g.sdkRoot, claudeDir: g.claudeDir, claudeJsonPath: g.claudeJsonPath,
148
+ claudeBin: null, execImpl: () => ({ status: 1 }), globalRoot: "", dryRun: true, now: Date.parse("2026-09-08T12:00:00Z"), ...g.io,
149
+ });
150
+ assert.equal(d.ok, true);
151
+ assert.equal(d.identity.changed, true, "reports what it WOULD do");
152
+ assert.equal(existsSync(join(g.claudeDir, "CLAUDE.md")), false);
153
+ assert.equal(existsSync(join(g.claudeDir, "settings.json")), false);
154
+ assert.equal(existsSync(g.claudeJsonPath), false);
155
+ assert.equal(existsSync(join(g.claudeDir, "skills")), false);
156
+ } finally { rmSync(g.root, { recursive: true, force: true }); }
157
+ } finally { rmSync(f.root, { recursive: true, force: true }); }
158
+ });
159
+
160
+ test("applyFrontDoorSetup: a repo with no agent.json still wires MCP/settings/skills and skips the identity block with a warning", async () => {
161
+ const f = fixture();
162
+ try {
163
+ rmSync(join(f.agentRoot, "config", "agent.json"));
164
+ const r = await applyFrontDoorSetup({
165
+ agentRoot: f.agentRoot, sdkRoot: f.sdkRoot, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
166
+ claudeBin: null, execImpl: () => ({ status: 1 }), globalRoot: "", now: Date.parse("2026-09-08T12:00:00Z"), ...f.io,
167
+ });
168
+ assert.equal(r.ok, true);
169
+ assert.equal(r.identity.changed, false);
170
+ assert.equal(r.identity.skipped, true);
171
+ assert.ok(f.logs.some((l) => l.startsWith("warn:") && /identity/i.test(l)));
172
+ assert.equal(JSON.parse(readFileSync(f.claudeJsonPath, "utf8")).mcpServers.cohort.command, "cohort-mcp");
173
+ } finally { rmSync(f.root, { recursive: true, force: true }); }
174
+ });
175
+
176
+ // ── Review fixes (2026-09-08): never clobber a state file we could not parse ─
177
+
178
+ test("applyFrontDoorSetup: an unparseable ~/.claude.json or settings.json is left byte-for-byte alone (warned, skipped) — never replaced with a fresh {}", async () => {
179
+ const f = fixture();
180
+ try {
181
+ const garbage = "{\"oauthAccount\":{\"id\":\"keep\"},\"projects\":{} trailing-garbage";
182
+ writeFileSync(f.claudeJsonPath, garbage);
183
+ const badSettings = "{ hooks: [ not json";
184
+ writeFileSync(join(f.claudeDir, "settings.json"), badSettings);
185
+ const execCalls = [];
186
+ const r = await applyFrontDoorSetup({
187
+ agentRoot: f.agentRoot, sdkRoot: f.sdkRoot, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
188
+ claudeBin: "/fake/claude", execImpl: (bin, args) => { execCalls.push(args); return { status: 0 }; },
189
+ globalRoot: "", now: Date.parse("2026-09-08T12:00:00Z"), ...f.io,
190
+ });
191
+ assert.equal(readFileSync(f.claudeJsonPath, "utf8"), garbage, "~/.claude.json untouched");
192
+ assert.equal(readFileSync(join(f.claudeDir, "settings.json"), "utf8"), badSettings, "settings.json untouched");
193
+ assert.equal(execCalls.length, 0, "the CLI is not asked to rewrite a file we cannot read back");
194
+ assert.equal(r.mcp.skipped, "unparseable");
195
+ assert.equal(r.settings.skipped, "unparseable");
196
+ assert.equal(r.ok, false, "a skipped step is reported, not hidden");
197
+ assert.ok(f.logs.some((l) => l.startsWith("warn:") && /\.claude\.json/.test(l) && /parse/i.test(l)));
198
+ assert.ok(f.logs.some((l) => l.startsWith("warn:") && /settings\.json/.test(l) && /parse/i.test(l)));
199
+ assert.equal(readdirSync(join(f.root, "home")).filter((n) => n.startsWith(".claude.json.backup.")).length, 0, "no backup of a file we did not write");
200
+ // The other steps still ran.
201
+ assert.equal(r.identity.changed, true);
202
+ assert.deepEqual(r.skills.installed, ["main-session"]);
203
+ } finally { rmSync(f.root, { recursive: true, force: true }); }
204
+ });
205
+
206
+ test("applyFrontDoorSetup: the JSON merge keeps the file's mode (0600 stays 0600); a fresh ~/.claude.json is created 0600", async () => {
207
+ const f = fixture();
208
+ try {
209
+ writeFileSync(f.claudeJsonPath, JSON.stringify({ oauthAccount: { id: "x" } }), { mode: 0o600 });
210
+ chmodSync(f.claudeJsonPath, 0o600);
211
+ writeFileSync(join(f.claudeDir, "settings.json"), "{}", { mode: 0o640 });
212
+ chmodSync(join(f.claudeDir, "settings.json"), 0o640);
213
+ await applyFrontDoorSetup({
214
+ agentRoot: f.agentRoot, sdkRoot: f.sdkRoot, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
215
+ claudeBin: null, globalRoot: "", now: Date.parse("2026-09-08T12:00:00Z"), ...f.io,
216
+ });
217
+ assert.equal(statSync(f.claudeJsonPath).mode & 0o777, 0o600);
218
+ assert.equal(statSync(join(f.claudeDir, "settings.json")).mode & 0o777, 0o640);
219
+ assert.equal(JSON.parse(readFileSync(f.claudeJsonPath, "utf8")).mcpServers.cohort.command, "cohort-mcp");
220
+
221
+ const g = fixture();
222
+ try {
223
+ await applyFrontDoorSetup({
224
+ agentRoot: g.agentRoot, sdkRoot: g.sdkRoot, claudeDir: g.claudeDir, claudeJsonPath: g.claudeJsonPath,
225
+ claudeBin: null, globalRoot: "", now: Date.parse("2026-09-08T12:00:00Z"), ...g.io,
226
+ });
227
+ assert.equal(statSync(g.claudeJsonPath).mode & 0o777, 0o600, "a new ~/.claude.json is private: it will carry oauthAccount");
228
+ } finally { rmSync(g.root, { recursive: true, force: true }); }
229
+ } finally { rmSync(f.root, { recursive: true, force: true }); }
230
+ });
231
+
232
+ test("mergeJsonFileAtomic: a concurrent rewrite between read and publish is re-merged, never lost", () => {
233
+ const root = mkdtempSync(join(tmpdir(), "gs-merge-"));
234
+ try {
235
+ const p = join(root, "state.json");
236
+ writeFileSync(p, JSON.stringify({ numStartups: 1, projects: {} }));
237
+ let calls = 0;
238
+ const r = mergeJsonFileAtomic(p, (cur) => {
239
+ calls += 1;
240
+ if (calls === 1) {
241
+ // Another process (an interactive claude) rewrites the file while we merge.
242
+ writeFileSync(p, JSON.stringify({ numStartups: 2, projects: { "/x": { trust: true } } }));
243
+ }
244
+ return { config: { ...cur, mcpServers: { cohort: { command: "cohort-mcp" } } }, changed: true };
245
+ });
246
+ assert.equal(r.ok, true);
247
+ assert.equal(r.attempts, 2, "first attempt detected the concurrent write and retried");
248
+ const final = JSON.parse(readFileSync(p, "utf8"));
249
+ assert.equal(final.numStartups, 2, "the other writer's update survived");
250
+ assert.deepEqual(final.projects, { "/x": { trust: true } });
251
+ assert.equal(final.mcpServers.cohort.command, "cohort-mcp", "ours landed too");
252
+ // Unparseable → refuses, untouched.
253
+ writeFileSync(p, "nope{");
254
+ const bad = mergeJsonFileAtomic(p, () => ({ config: {}, changed: true }));
255
+ assert.equal(bad.ok, false);
256
+ assert.equal(bad.reason, "unparseable");
257
+ assert.equal(readFileSync(p, "utf8"), "nope{");
258
+ } finally { rmSync(root, { recursive: true, force: true }); }
259
+ });
260
+
261
+ test("pickClaudeBin / findOnPath: a bare `claude` from the resolver is looked up on PATH instead of being discarded", () => {
262
+ const root = mkdtempSync(join(tmpdir(), "gs-path-"));
263
+ try {
264
+ const bin = join(root, "nvm", "bin");
265
+ mkdirSync(bin, { recursive: true });
266
+ writeFileSync(join(bin, "claude"), "#!/bin/sh\n", { mode: 0o755 });
267
+ const pathEnv = `${join(root, "empty")}:${bin}`;
268
+ assert.equal(findOnPath("claude", pathEnv), join(bin, "claude"));
269
+ assert.equal(findOnPath("claude", join(root, "empty")), null);
270
+ assert.equal(findOnPath("claude", ""), null);
271
+ assert.equal(pickClaudeBin("claude", { pathEnv }), join(bin, "claude"), "bare name → PATH lookup");
272
+ assert.equal(pickClaudeBin("claude", { pathEnv: "" }), null, "not on PATH → null (JSON merge path)");
273
+ assert.equal(pickClaudeBin(join(bin, "claude"), { pathEnv: "" }), join(bin, "claude"), "absolute + exists → itself");
274
+ assert.equal(pickClaudeBin(join(root, "missing", "claude"), { pathEnv }), null, "absolute + missing → null");
275
+ assert.equal(pickClaudeBin("", { pathEnv }), null);
276
+ } finally { rmSync(root, { recursive: true, force: true }); }
277
+ });
278
+
279
+ test("applyFrontDoorSetup: when `cohort-mcp` is not on PATH the MCP entry runs the SDK's own bin under the current node (a 2.11 seat without a global install still gets a working server)", async () => {
280
+ const f = fixture();
281
+ try {
282
+ mkdirSync(join(f.sdkRoot, "bin"), { recursive: true });
283
+ writeFileSync(join(f.sdkRoot, "bin", "cohort-mcp.mjs"), "// stub\n");
284
+ const r = await applyFrontDoorSetup({
285
+ agentRoot: f.agentRoot, sdkRoot: f.sdkRoot, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
286
+ claudeBin: null, globalRoot: "", pathEnv: join(f.root, "nothing-here"), now: Date.parse("2026-09-08T12:00:00Z"), ...f.io,
287
+ });
288
+ assert.equal(r.ok, true);
289
+ const cj = JSON.parse(readFileSync(f.claudeJsonPath, "utf8"));
290
+ assert.equal(cj.mcpServers.cohort.command, process.execPath);
291
+ assert.deepEqual(cj.mcpServers.cohort.args, [join(f.sdkRoot, "bin", "cohort-mcp.mjs")]);
292
+ assert.equal(cj.mcpServers.cohort.env.COHORT_AGENT_ROOT, f.agentRoot);
293
+ assert.ok(f.logs.some((l) => /not on PATH/.test(l)), "says why the entry is not the bare command");
294
+
295
+ // Once the global install lands (cohort-mcp on PATH), a re-run rewrites to the bare command.
296
+ const bin = join(f.root, "gbin");
297
+ mkdirSync(bin, { recursive: true });
298
+ writeFileSync(join(bin, "cohort-mcp"), "#!/bin/sh\n", { mode: 0o755 });
299
+ const r2 = await applyFrontDoorSetup({
300
+ agentRoot: f.agentRoot, sdkRoot: f.sdkRoot, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
301
+ claudeBin: null, globalRoot: "", pathEnv: bin, now: Date.parse("2026-09-08T12:10:00Z"), ...f.io,
302
+ });
303
+ assert.equal(r2.mcp.changed, true);
304
+ const cj2 = JSON.parse(readFileSync(f.claudeJsonPath, "utf8"));
305
+ assert.equal(cj2.mcpServers.cohort.command, "cohort-mcp");
306
+ assert.deepEqual(cj2.mcpServers.cohort.args, []);
307
+ } finally { rmSync(f.root, { recursive: true, force: true }); }
308
+ });
309
+
310
+ test("applyFrontDoorSetup: warns when the agent's pre-send-audit.sh predates the persona gate (upgrade keeps locally modified copies), and when it is missing", async () => {
311
+ const f = fixture();
312
+ try {
313
+ const hooks = join(f.agentRoot, "scripts", "hooks");
314
+ mkdirSync(hooks, { recursive: true });
315
+ writeFileSync(join(hooks, "pre-send-audit.sh"), "#!/bin/bash\nBANNED_SUBSTRINGS=(\"As an AI\")\nexit 0\n");
316
+ const r = await applyFrontDoorSetup({
317
+ agentRoot: f.agentRoot, sdkRoot: f.sdkRoot, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
318
+ claudeBin: null, globalRoot: "", now: Date.parse("2026-09-08T12:00:00Z"), ...f.io,
319
+ });
320
+ assert.equal(r.settings.hookScript.present, true);
321
+ assert.equal(r.settings.hookScript.personaGate, false);
322
+ assert.ok(f.logs.some((l) => l.startsWith("warn:") && /no persona gate/.test(l) && /pre-send-audit\.sh/.test(l)));
323
+ assert.equal(checkPersonaHookScript(f.agentRoot).personaGate, false);
324
+
325
+ writeFileSync(join(hooks, "pre-send-audit.sh"), "#!/bin/bash\nPERSONA_PATTERNS=('sub-sessions?')\nexit 0\n");
326
+ assert.equal(checkPersonaHookScript(f.agentRoot).personaGate, true);
327
+ f.logs.length = 0;
328
+ await applyFrontDoorSetup({
329
+ agentRoot: f.agentRoot, sdkRoot: f.sdkRoot, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
330
+ claudeBin: null, globalRoot: "", now: Date.parse("2026-09-08T12:01:00Z"), ...f.io,
331
+ });
332
+ assert.ok(!f.logs.some((l) => /persona gate/.test(l) || /pre-send-audit\.sh is missing/.test(l)), "no warning once the gate is present");
333
+
334
+ rmSync(join(hooks, "pre-send-audit.sh"));
335
+ assert.deepEqual(checkPersonaHookScript(f.agentRoot).present, false);
336
+ f.logs.length = 0;
337
+ await applyFrontDoorSetup({
338
+ agentRoot: f.agentRoot, sdkRoot: f.sdkRoot, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
339
+ claudeBin: null, globalRoot: "", now: Date.parse("2026-09-08T12:02:00Z"), ...f.io,
340
+ });
341
+ assert.ok(f.logs.some((l) => l.startsWith("warn:") && /is missing/.test(l)));
342
+ } finally { rmSync(f.root, { recursive: true, force: true }); }
343
+ });
344
+
345
+ test("applyFrontDoorSetup: skills are rendered to the agent's identity (config/agent.json + config/company.json) — a global install still symlinks token-free skills", async () => {
346
+ const f = fixture();
347
+ try {
348
+ const globalRoot = join(f.root, "global", "node_modules");
349
+ const sdkRoot = join(globalRoot, "@cohortapp", "agent-sdk");
350
+ const src = join(sdkRoot, "plugins", "maestro-skills", "skills");
351
+ mkdirSync(src, { recursive: true });
352
+ writeFileSync(join(src, "main-session.md"), "---\nname: main-session\n---\nYou are `{{agent.firstName}}`'s main session; {{agent.fullName}}, {{agent.title}} at {{company.name}}.\n");
353
+ writeFileSync(join(src, "board-work.md"), "---\nname: board-work\n---\nplain\n");
354
+ writeFileSync(join(f.agentRoot, "config", "company.json"), JSON.stringify({ name: "Acme Corp" }));
355
+ const r = await applyFrontDoorSetup({
356
+ agentRoot: f.agentRoot, sdkRoot, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
357
+ claudeBin: null, globalRoot, now: Date.parse("2026-09-08T12:00:00Z"), ...f.io,
358
+ });
359
+ assert.equal(r.skills.mode, "symlink");
360
+ assert.deepEqual(r.skills.rendered, ["main-session"]);
361
+ const main = join(f.claudeDir, "skills", "maestro-main-session", "SKILL.md");
362
+ assert.equal(lstatSync(main).isSymbolicLink(), false);
363
+ assert.match(readFileSync(main, "utf8"), /You are `Alex`'s main session; Alex Rivera, Chief of Staff at Acme Corp\./);
364
+ assert.ok(lstatSync(join(f.claudeDir, "skills", "maestro-board-work", "SKILL.md")).isSymbolicLink());
365
+ assert.ok(f.logs.some((l) => /rendered/.test(l)), "the summary line says which skills were rendered");
366
+ } finally { rmSync(f.root, { recursive: true, force: true }); }
367
+ });