@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.
- package/.env.example +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +113 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +264 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +410 -0
- package/lib/cli/global-setup-extras.test.mjs +367 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +750 -0
- package/lib/cli/session.test.mjs +602 -0
- package/lib/collective/global-config.mjs +204 -6
- package/lib/collective/global-config.test.mjs +140 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/identity/persona.mjs +29 -0
- package/lib/identity/persona.test.mjs +26 -1
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +21 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/scaffold/CLAUDE.md +34 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +141 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
- package/scripts/daemon/cadence-consumer.mjs +254 -78
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/prompt-builder.mjs +9 -1
- package/scripts/daemon/prompt-builder.test.mjs +22 -0
- package/scripts/daemon/responder.mjs +61 -41
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +488 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
"use strict";
|
|
15
15
|
|
|
16
|
-
import { join } from "node:path";
|
|
16
|
+
import { join, basename } from "node:path";
|
|
17
17
|
|
|
18
18
|
/** Marker substring present in every collective hook command. */
|
|
19
19
|
export const HOOK_MARKER = "scripts/collective/hook-runner.mjs";
|
|
@@ -83,17 +83,19 @@ export function buildCollectiveClaudeMd(repoRoot) {
|
|
|
83
83
|
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
* Insert or replace
|
|
87
|
-
*
|
|
86
|
+
* Insert or replace ONE delimited block in a CLAUDE.md body. Only the block
|
|
87
|
+
* between `begin`/`end` is touched; everything else (including a sibling
|
|
88
|
+
* block with different markers) is preserved byte-for-byte. Pure.
|
|
88
89
|
*
|
|
89
90
|
* @param {string} content - existing file content ("" if absent)
|
|
90
91
|
* @param {string} body - block body (without the delimiters)
|
|
92
|
+
* @param {{begin:string, end:string}} markers
|
|
91
93
|
* @returns {{ content: string, changed: boolean }}
|
|
92
94
|
*/
|
|
93
|
-
export function
|
|
94
|
-
const block = `${
|
|
95
|
+
export function upsertDelimitedBlock(content, body, { begin, end }) {
|
|
96
|
+
const block = `${begin}\n${body}\n${end}`;
|
|
95
97
|
const cur = content || "";
|
|
96
|
-
const re = new RegExp(escapeRe(
|
|
98
|
+
const re = new RegExp(escapeRe(begin) + "[\\s\\S]*?" + escapeRe(end));
|
|
97
99
|
if (re.test(cur)) {
|
|
98
100
|
const updated = cur.replace(re, block);
|
|
99
101
|
return { content: updated, changed: updated !== cur };
|
|
@@ -102,6 +104,202 @@ export function upsertClaudeMdBlock(content, body) {
|
|
|
102
104
|
return { content: prefix + block + "\n", changed: true };
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Insert or replace the delimited collective block in a CLAUDE.md body. Only our
|
|
109
|
+
* own block is touched; the rest of the file is preserved. Pure.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} content - existing file content ("" if absent)
|
|
112
|
+
* @param {string} body - block body (without the delimiters)
|
|
113
|
+
* @returns {{ content: string, changed: boolean }}
|
|
114
|
+
*/
|
|
115
|
+
export function upsertClaudeMdBlock(content, body) {
|
|
116
|
+
return upsertDelimitedBlock(content, body, { begin: CLAUDE_MD_BEGIN, end: CLAUDE_MD_END });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Front-door session (2026-09): the identity block, the MCP server entry and
|
|
121
|
+
// the settings.json additions that make ANY Claude Code session on a seat
|
|
122
|
+
// machine know who the agent is (design spec §3.7). All pure — the I/O is in
|
|
123
|
+
// lib/cli/global-setup-extras.mjs.
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
export const IDENTITY_BEGIN = "<!-- BEGIN maestro:identity -->";
|
|
127
|
+
export const IDENTITY_END = "<!-- END maestro:identity -->";
|
|
128
|
+
|
|
129
|
+
/** Lower-case first name from an agent.json (firstName, else the first word of fullName). */
|
|
130
|
+
export function agentFirst(agentJson) {
|
|
131
|
+
const a = agentJson && typeof agentJson === "object" ? agentJson : {};
|
|
132
|
+
const raw = String(a.firstName || String(a.fullName || "").split(/\s+/)[0] || "").trim();
|
|
133
|
+
return raw.toLowerCase().replace(/[^a-z0-9-]+/g, "");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Seat slug: agent.json repoSlug, else the agent directory's basename. */
|
|
137
|
+
export function agentSlug(agentJson, agentRoot) {
|
|
138
|
+
const a = agentJson && typeof agentJson === "object" ? agentJson : {};
|
|
139
|
+
return String(a.repoSlug || basename(String(agentRoot || "")) || "").trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The identity block for ~/.claude/CLAUDE.md: who the agent is, where the
|
|
144
|
+
* agent dir is, that every session on the machine is the same entity, the
|
|
145
|
+
* persona rules in two lines, where the main session lives, the commands, and
|
|
146
|
+
* the board duty. Pure; tolerates a sparse agent.json (never prints
|
|
147
|
+
* "undefined").
|
|
148
|
+
*
|
|
149
|
+
* @param {object} agentJson - config/agent.json
|
|
150
|
+
* @param {{agentRoot:string, first?:string, slug?:string}} o
|
|
151
|
+
* @returns {string} block body (without delimiters)
|
|
152
|
+
*/
|
|
153
|
+
export function buildIdentityClaudeMd(agentJson, o = {}) {
|
|
154
|
+
const a = agentJson && typeof agentJson === "object" ? agentJson : {};
|
|
155
|
+
const agentRoot = String(o.agentRoot || "");
|
|
156
|
+
const first = o.first || agentFirst(a) || "agent";
|
|
157
|
+
const slug = o.slug || agentSlug(a, agentRoot) || first;
|
|
158
|
+
const name = String(a.fullName || a.firstName || "this machine's agent").trim();
|
|
159
|
+
const p = a.principal && typeof a.principal === "object" ? a.principal : {};
|
|
160
|
+
const who = [
|
|
161
|
+
`You are ${name}`,
|
|
162
|
+
a.title ? `, ${a.title}` : "",
|
|
163
|
+
a.company ? ` at ${a.company}` : "",
|
|
164
|
+
p.fullName ? `, reporting to ${p.fullName}${p.title ? ` (${p.title})` : ""}` : "",
|
|
165
|
+
".",
|
|
166
|
+
].join("");
|
|
167
|
+
return [
|
|
168
|
+
"## Who you are on this machine (Maestro)",
|
|
169
|
+
"",
|
|
170
|
+
`${who} Agent directory: \`${agentRoot}\` (seat \`${slug}\`); \`config/agent.json\` there is the`,
|
|
171
|
+
"record of you. You are the same entity in every session on this machine — one",
|
|
172
|
+
"person, one voice, whatever window or directory this is.",
|
|
173
|
+
"",
|
|
174
|
+
`- Persona: to anyone outside this terminal you are ${a.firstName || name}, a colleague — never an`,
|
|
175
|
+
" assistant, an AI helper, a model, a tool, or a \"session\". Parallel work is \"my team\" /",
|
|
176
|
+
" \"a colleague\" / \"one of my analysts\"; never name a session, a sub-session or a subagent.",
|
|
177
|
+
"- Asked sincerely whether you are an AI, say yes plainly. That is a direct answer to a direct",
|
|
178
|
+
" question, not a disclaimer to attach to your work.",
|
|
179
|
+
`- The front door is the main session, \`${first}-main\` (\`ListAgents\` shows it; \`SendMessage\``,
|
|
180
|
+
" reaches it). It receives every inbound Cohort event; peer sessions it spawns are named",
|
|
181
|
+
` \`${first}-<purpose>\`. If you are a peer, report back to \`${first}-main\` when done.`,
|
|
182
|
+
"- Commands: `maestro session status|spawn|ack`, `maestro inbox list|show|claim|reply|done|defer`,",
|
|
183
|
+
" `maestro board mine|track|claim|complete`. The `cohort` MCP server carries the same",
|
|
184
|
+
" surface as tools (`board_mine`, `board_track`, `session_status`, `messaging_send`, ...).",
|
|
185
|
+
"- Board duty: every item assigned to you on any board is yours to pull. `maestro board mine`",
|
|
186
|
+
" lists them; claim the highest-priority one when idle, track progress with",
|
|
187
|
+
" `maestro board track`, and close it with `maestro board complete`.",
|
|
188
|
+
`- Skills: \`maestro-main-session\`, \`maestro-inbound-triage\`, \`maestro-board-work\`,`,
|
|
189
|
+
" `maestro-peer-sessions`, `maestro-persona-discipline` carry the operating rules.",
|
|
190
|
+
].join("\n");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The user-scope MCP server entry for cohort-mcp, pinned to one agent dir.
|
|
195
|
+
* Default command is the bare `cohort-mcp` (the global npm install); a caller
|
|
196
|
+
* that knows it is not on PATH passes `{command, args}` (e.g. the current node
|
|
197
|
+
* + this package's bin/cohort-mcp.mjs) so the server still starts. Pure.
|
|
198
|
+
* @param {string} agentRoot @param {{command?:string, args?:string[]}} [o]
|
|
199
|
+
*/
|
|
200
|
+
export function cohortMcpEntry(agentRoot, o = {}) {
|
|
201
|
+
const command = o.command ? String(o.command) : "cohort-mcp";
|
|
202
|
+
const args = Array.isArray(o.args) ? o.args.map(String) : [];
|
|
203
|
+
return { command, args, env: { COHORT_AGENT_ROOT: String(agentRoot || "") } };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function clone(v) { return JSON.parse(JSON.stringify(v)); }
|
|
207
|
+
function isObj(v) { return v && typeof v === "object" && !Array.isArray(v); }
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Merge the `cohort` server into a ~/.claude.json document's `mcpServers`.
|
|
211
|
+
* Other servers and every other key are preserved; an existing `cohort` entry
|
|
212
|
+
* is rewritten only when it differs (a stale agent root). Pure, never throws.
|
|
213
|
+
*
|
|
214
|
+
* @param {object} claudeJson
|
|
215
|
+
* @param {object} entry - cohortMcpEntry(agentRoot)
|
|
216
|
+
* @returns {{ config: object, changed: boolean }}
|
|
217
|
+
*/
|
|
218
|
+
export function mergeMcpServers(claudeJson, entry) {
|
|
219
|
+
const config = isObj(claudeJson) ? clone(claudeJson) : {};
|
|
220
|
+
if (!isObj(config.mcpServers)) config.mcpServers = {};
|
|
221
|
+
const cur = config.mcpServers.cohort;
|
|
222
|
+
const same = cur && JSON.stringify(cur) === JSON.stringify(entry);
|
|
223
|
+
if (same) return { config, changed: false };
|
|
224
|
+
config.mcpServers.cohort = clone(entry);
|
|
225
|
+
return { config, changed: true };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Substring present in the persona/audit PreToolUse hook command. */
|
|
229
|
+
export const PRE_SEND_HOOK_MARKER = "scripts/hooks/pre-send-audit.sh";
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Every outbound-content tool of the `cohort` MCP server. The persona/audit
|
|
233
|
+
* hook fronts ALL of them — a perimeter that gated `messaging_send` alone was
|
|
234
|
+
* bypassable by tool choice (email_send, org_rpc {method:"messaging.send"}).
|
|
235
|
+
* Mirrors the `outbound:true` tools in lib/org/tool-surface.mjs plus the
|
|
236
|
+
* `org_rpc` escape hatch.
|
|
237
|
+
*/
|
|
238
|
+
export const PRE_SEND_HOOK_TOOLS = Object.freeze([
|
|
239
|
+
"messaging_send",
|
|
240
|
+
"messaging_send_voice_note",
|
|
241
|
+
"email_send",
|
|
242
|
+
"email_draft_send",
|
|
243
|
+
"org_rpc",
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
/** The PreToolUse matcher (a Claude Code matcher is a regex) for those tools. */
|
|
247
|
+
export const PRE_SEND_HOOK_MATCHER = `mcp__cohort__(${PRE_SEND_HOOK_TOOLS.join("|")})`;
|
|
248
|
+
|
|
249
|
+
/** permissions.allow entries a front-door session needs. */
|
|
250
|
+
export const SESSION_PERMISSION_ALLOW = Object.freeze([
|
|
251
|
+
"mcp__cohort",
|
|
252
|
+
"mcp__cohort__*",
|
|
253
|
+
"ListAgents",
|
|
254
|
+
"SendMessage",
|
|
255
|
+
"Monitor",
|
|
256
|
+
"Workflow",
|
|
257
|
+
"Agent",
|
|
258
|
+
]);
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Merge the front-door additions into ~/.claude/settings.json:
|
|
262
|
+
* `crossSessionInbound:"accept"` (only when unset — an operator's explicit
|
|
263
|
+
* choice is respected), `permissions.allow` += SESSION_PERMISSION_ALLOW, and a
|
|
264
|
+
* `PreToolUse` group matching every outbound `mcp__cohort__*` tool
|
|
265
|
+
* ({@link PRE_SEND_HOOK_MATCHER}) that runs the persona + audit hook. Existing hooks/permissions are preserved; re-running
|
|
266
|
+
* changes nothing. Pure.
|
|
267
|
+
*
|
|
268
|
+
* @param {object} settings
|
|
269
|
+
* @param {{agentRoot:string}} o
|
|
270
|
+
* @returns {{ settings: object, changes: string[] }}
|
|
271
|
+
*/
|
|
272
|
+
export function mergeSessionSettings(settings, o = {}) {
|
|
273
|
+
const out = isObj(settings) ? clone(settings) : {};
|
|
274
|
+
const changes = [];
|
|
275
|
+
if (out.crossSessionInbound === undefined) {
|
|
276
|
+
out.crossSessionInbound = "accept";
|
|
277
|
+
changes.push("crossSessionInbound");
|
|
278
|
+
}
|
|
279
|
+
if (!isObj(out.permissions)) out.permissions = {};
|
|
280
|
+
if (!Array.isArray(out.permissions.allow)) out.permissions.allow = [];
|
|
281
|
+
for (const p of SESSION_PERMISSION_ALLOW) {
|
|
282
|
+
if (!out.permissions.allow.includes(p)) {
|
|
283
|
+
out.permissions.allow.push(p);
|
|
284
|
+
changes.push(`permissions.allow:${p}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (!isObj(out.hooks)) out.hooks = {};
|
|
288
|
+
if (!Array.isArray(out.hooks.PreToolUse)) out.hooks.PreToolUse = [];
|
|
289
|
+
const present = out.hooks.PreToolUse.some((g) =>
|
|
290
|
+
g && g.matcher === PRE_SEND_HOOK_MATCHER && Array.isArray(g.hooks) &&
|
|
291
|
+
g.hooks.some((h) => h && typeof h.command === "string" && h.command.includes(PRE_SEND_HOOK_MARKER)));
|
|
292
|
+
if (!present) {
|
|
293
|
+
const script = join(String(o.agentRoot || ""), "scripts", "hooks", "pre-send-audit.sh");
|
|
294
|
+
out.hooks.PreToolUse.push({
|
|
295
|
+
matcher: PRE_SEND_HOOK_MATCHER,
|
|
296
|
+
hooks: [{ type: "command", command: `${q("bash")} ${q(script)} cohort` }],
|
|
297
|
+
});
|
|
298
|
+
changes.push("hooks.PreToolUse:pre-send-audit");
|
|
299
|
+
}
|
|
300
|
+
return { settings: out, changes };
|
|
301
|
+
}
|
|
302
|
+
|
|
105
303
|
/** Build the machine pointer object. */
|
|
106
304
|
export function buildPointer(repoRoot, agentName, nowMs) {
|
|
107
305
|
return {
|
|
@@ -73,3 +73,143 @@ test("buildPointer: shape", () => {
|
|
|
73
73
|
assert.match(p.memoryRoot, /memory\/collective$/);
|
|
74
74
|
assert.equal(p.updatedAt, "2026-06-03T00:00:00.000Z");
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
// ── WP-M4: identity block + MCP/settings mergers (front-door session, §3.7) ──
|
|
78
|
+
|
|
79
|
+
import {
|
|
80
|
+
IDENTITY_BEGIN, IDENTITY_END, buildIdentityClaudeMd, upsertDelimitedBlock,
|
|
81
|
+
mergeMcpServers, mergeSessionSettings, cohortMcpEntry, PRE_SEND_HOOK_MARKER, PRE_SEND_HOOK_MATCHER,
|
|
82
|
+
SESSION_PERMISSION_ALLOW,
|
|
83
|
+
} from "./global-config.mjs";
|
|
84
|
+
|
|
85
|
+
const AGENT = {
|
|
86
|
+
firstName: "Alex", fullName: "Alex Rivera", title: "Chief of Staff", company: "Acme",
|
|
87
|
+
principal: { fullName: "Jordan Lee", title: "CEO", firstName: "Jordan" },
|
|
88
|
+
repoSlug: "alex-ai",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
test("buildIdentityClaudeMd: names the agent, the seat, the main session and the commands", () => {
|
|
92
|
+
const md = buildIdentityClaudeMd(AGENT, { agentRoot: "/Users/x/alex-ai", first: "alex", slug: "alex-ai" });
|
|
93
|
+
assert.match(md, /Alex Rivera/);
|
|
94
|
+
assert.match(md, /Chief of Staff/);
|
|
95
|
+
assert.match(md, /Acme/);
|
|
96
|
+
assert.match(md, /Jordan Lee/);
|
|
97
|
+
assert.match(md, /\/Users\/x\/alex-ai/);
|
|
98
|
+
assert.match(md, /`alex-ai`/, "seat slug");
|
|
99
|
+
assert.match(md, /same entity in every session/i);
|
|
100
|
+
assert.match(md, /`alex-main`/, "main session name");
|
|
101
|
+
assert.match(md, /ListAgents/);
|
|
102
|
+
assert.match(md, /maestro session status\|spawn\|ack/);
|
|
103
|
+
assert.match(md, /maestro inbox/);
|
|
104
|
+
assert.match(md, /maestro board mine/);
|
|
105
|
+
assert.match(md, /board/i);
|
|
106
|
+
// Persona rules are present and phrased as rules the agent follows outbound.
|
|
107
|
+
assert.match(md, /never .*(assistant|session|sub-?agent)/i);
|
|
108
|
+
// The identity block is a different block from the collective one.
|
|
109
|
+
assert.notEqual(IDENTITY_BEGIN, CLAUDE_MD_BEGIN);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("buildIdentityClaudeMd: derives first/slug when not given; tolerates a sparse agent.json", () => {
|
|
113
|
+
const md = buildIdentityClaudeMd({ fullName: "Robin Park" }, { agentRoot: "/home/r/robin-ai" });
|
|
114
|
+
assert.match(md, /Robin Park/);
|
|
115
|
+
assert.match(md, /`robin-main`/, "first derived from fullName");
|
|
116
|
+
assert.match(md, /`robin-ai`/, "slug derived from the agent dir basename");
|
|
117
|
+
const empty = buildIdentityClaudeMd({}, { agentRoot: "/home/r/robin-ai" });
|
|
118
|
+
assert.equal(typeof empty, "string");
|
|
119
|
+
assert.ok(empty.length > 50);
|
|
120
|
+
assert.doesNotMatch(empty, /undefined/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("upsertDelimitedBlock: identity + collective blocks coexist and are each idempotent", () => {
|
|
124
|
+
const coll = upsertClaudeMdBlock("# rules", buildCollectiveClaudeMd("/repo"));
|
|
125
|
+
const idBody = buildIdentityClaudeMd(AGENT, { agentRoot: "/repo" });
|
|
126
|
+
const r1 = upsertDelimitedBlock(coll.content, idBody, { begin: IDENTITY_BEGIN, end: IDENTITY_END });
|
|
127
|
+
assert.equal(r1.changed, true);
|
|
128
|
+
assert.match(r1.content, new RegExp(CLAUDE_MD_BEGIN), "collective block preserved");
|
|
129
|
+
assert.match(r1.content, new RegExp(IDENTITY_BEGIN));
|
|
130
|
+
const r2 = upsertDelimitedBlock(r1.content, idBody, { begin: IDENTITY_BEGIN, end: IDENTITY_END });
|
|
131
|
+
assert.equal(r2.changed, false, "idempotent");
|
|
132
|
+
const r3 = upsertDelimitedBlock(r1.content, "changed body", { begin: IDENTITY_BEGIN, end: IDENTITY_END });
|
|
133
|
+
assert.equal((r3.content.match(new RegExp(IDENTITY_BEGIN, "g")) || []).length, 1);
|
|
134
|
+
assert.match(r3.content, new RegExp(CLAUDE_MD_BEGIN), "collective block still there after identity update");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("cohortMcpEntry + mergeMcpServers: adds cohort once, preserves other servers, updates a stale root", () => {
|
|
138
|
+
const entry = cohortMcpEntry("/Users/x/alex-ai");
|
|
139
|
+
assert.equal(entry.command, "cohort-mcp");
|
|
140
|
+
assert.deepEqual(entry.args, []);
|
|
141
|
+
assert.equal(entry.env.COHORT_AGENT_ROOT, "/Users/x/alex-ai");
|
|
142
|
+
const existing = { mcpServers: { other: { command: "x" } }, someOtherKey: 1 };
|
|
143
|
+
const m1 = mergeMcpServers(existing, entry);
|
|
144
|
+
assert.equal(m1.changed, true);
|
|
145
|
+
assert.deepEqual(m1.config.mcpServers.other, { command: "x" });
|
|
146
|
+
assert.deepEqual(m1.config.mcpServers.cohort, entry);
|
|
147
|
+
assert.equal(m1.config.someOtherKey, 1);
|
|
148
|
+
assert.deepEqual(existing.mcpServers, { other: { command: "x" } }, "input not mutated");
|
|
149
|
+
const m2 = mergeMcpServers(m1.config, entry);
|
|
150
|
+
assert.equal(m2.changed, false, "idempotent");
|
|
151
|
+
const m3 = mergeMcpServers(m1.config, cohortMcpEntry("/Users/x/other-ai"));
|
|
152
|
+
assert.equal(m3.changed, true, "a different agent root rewrites the entry");
|
|
153
|
+
assert.equal(m3.config.mcpServers.cohort.env.COHORT_AGENT_ROOT, "/Users/x/other-ai");
|
|
154
|
+
// Garbage in → a fresh object out, never a throw.
|
|
155
|
+
assert.equal(mergeMcpServers(null, entry).config.mcpServers.cohort.command, "cohort-mcp");
|
|
156
|
+
assert.equal(mergeMcpServers("nope", entry).config.mcpServers.cohort.command, "cohort-mcp");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("mergeSessionSettings: crossSessionInbound + permissions.allow + PreToolUse persona hook, idempotent, additive", () => {
|
|
160
|
+
const settings = {
|
|
161
|
+
permissions: { allow: ["Bash(git status)"], deny: ["Bash(rm -rf /)"] },
|
|
162
|
+
hooks: { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "./mine.sh" }] }] },
|
|
163
|
+
theme: "dark",
|
|
164
|
+
};
|
|
165
|
+
const r1 = mergeSessionSettings(settings, { agentRoot: "/repo" });
|
|
166
|
+
assert.equal(r1.settings.crossSessionInbound, "accept");
|
|
167
|
+
assert.ok(r1.changes.includes("crossSessionInbound"));
|
|
168
|
+
for (const p of SESSION_PERMISSION_ALLOW) assert.ok(r1.settings.permissions.allow.includes(p), `allow ${p}`);
|
|
169
|
+
assert.ok(r1.settings.permissions.allow.includes("mcp__cohort__*"));
|
|
170
|
+
assert.ok(r1.settings.permissions.allow.includes("ListAgents"));
|
|
171
|
+
assert.ok(r1.settings.permissions.allow.includes("SendMessage"));
|
|
172
|
+
assert.ok(r1.settings.permissions.allow.includes("Monitor"));
|
|
173
|
+
assert.ok(r1.settings.permissions.allow.includes("Workflow"));
|
|
174
|
+
assert.ok(r1.settings.permissions.allow.includes("Agent"));
|
|
175
|
+
assert.ok(r1.settings.permissions.allow.includes("Bash(git status)"), "existing allow preserved");
|
|
176
|
+
assert.deepEqual(r1.settings.permissions.deny, ["Bash(rm -rf /)"], "deny untouched");
|
|
177
|
+
assert.equal(r1.settings.theme, "dark");
|
|
178
|
+
// PreToolUse: our matcher added, the operator's own group preserved.
|
|
179
|
+
const pre = r1.settings.hooks.PreToolUse;
|
|
180
|
+
assert.ok(pre.some((g) => g.matcher === "Bash" && g.hooks[0].command === "./mine.sh"));
|
|
181
|
+
const ours = pre.find((g) => g.matcher === PRE_SEND_HOOK_MATCHER);
|
|
182
|
+
assert.ok(ours, "persona hook group present");
|
|
183
|
+
// The matcher is a regex that fronts EVERY outbound tool, not messaging_send alone.
|
|
184
|
+
const matcherRe = new RegExp(`^${ours.matcher}$`);
|
|
185
|
+
for (const t of ["messaging_send", "messaging_send_voice_note", "email_send", "email_draft_send", "org_rpc"]) {
|
|
186
|
+
assert.ok(matcherRe.test(`mcp__cohort__${t}`), `matcher covers ${t}`);
|
|
187
|
+
}
|
|
188
|
+
assert.ok(!matcherRe.test("mcp__cohort__board_mine"), "reads are not fronted");
|
|
189
|
+
assert.ok(!matcherRe.test("mcp__cohort__messaging_send_extra"), "anchored");
|
|
190
|
+
assert.match(ours.hooks[0].command, new RegExp(PRE_SEND_HOOK_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
191
|
+
assert.match(ours.hooks[0].command, /\/repo\/scripts\/hooks\/pre-send-audit\.sh/);
|
|
192
|
+
assert.match(ours.hooks[0].command, / cohort$/, "channel arg is cohort");
|
|
193
|
+
// Input not mutated.
|
|
194
|
+
assert.equal(settings.crossSessionInbound, undefined);
|
|
195
|
+
assert.equal(settings.permissions.allow.length, 1);
|
|
196
|
+
// Idempotent.
|
|
197
|
+
const r2 = mergeSessionSettings(r1.settings, { agentRoot: "/repo" });
|
|
198
|
+
assert.deepEqual(r2.changes, []);
|
|
199
|
+
assert.deepEqual(r2.settings, r1.settings);
|
|
200
|
+
// An operator who set crossSessionInbound to something else is respected (additive, never clobber).
|
|
201
|
+
const r3 = mergeSessionSettings({ crossSessionInbound: "reject" }, { agentRoot: "/repo" });
|
|
202
|
+
assert.equal(r3.settings.crossSessionInbound, "reject");
|
|
203
|
+
assert.ok(!r3.changes.includes("crossSessionInbound"));
|
|
204
|
+
// Garbage → fresh.
|
|
205
|
+
assert.equal(mergeSessionSettings(null, { agentRoot: "/repo" }).settings.crossSessionInbound, "accept");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("cohortMcpEntry: a caller that knows cohort-mcp is off PATH pins command+args; the default stays the bare command", () => {
|
|
209
|
+
const local = cohortMcpEntry("/Users/x/alex-ai", { command: "/usr/local/bin/node", args: ["/sdk/bin/cohort-mcp.mjs"] });
|
|
210
|
+
assert.deepEqual(local, { command: "/usr/local/bin/node", args: ["/sdk/bin/cohort-mcp.mjs"], env: { COHORT_AGENT_ROOT: "/Users/x/alex-ai" } });
|
|
211
|
+
assert.equal(cohortMcpEntry("/r").command, "cohort-mcp");
|
|
212
|
+
assert.deepEqual(cohortMcpEntry("/r", {}).args, []);
|
|
213
|
+
// Switching from the local pin to the bare command is a change (so a re-run after `npm i -g` rewrites it).
|
|
214
|
+
assert.equal(mergeMcpServers({ mcpServers: { cohort: local } }, cohortMcpEntry("/Users/x/alex-ai")).changed, true);
|
|
215
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/collective/global-skills.mjs — install the shipped skills into
|
|
3
|
+
* ~/.claude/skills so ANY Claude Code session on the machine loads them.
|
|
4
|
+
*
|
|
5
|
+
* Every `plugins/maestro-skills/skills/<name>.md` becomes
|
|
6
|
+
* `~/.claude/skills/maestro-<name>/SKILL.md`: a symlink when the SDK is a
|
|
7
|
+
* global npm install (so `npm i -g` upgrades refresh them for free), a copy
|
|
8
|
+
* otherwise (a checkout or an agent-local node_modules can move or vanish).
|
|
9
|
+
*
|
|
10
|
+
* Shipped skills are TEMPLATES (`{{agent.firstName}}`, `{{company.name}}` —
|
|
11
|
+
* lib/render.mjs). Claude Code loads a SKILL.md verbatim, so a skill whose body
|
|
12
|
+
* carries tokens is rendered against the agent's identity (`context`) and
|
|
13
|
+
* COPIED — even under a global install, because a symlink cannot render.
|
|
14
|
+
* Token-free skills keep the symlink. Without a context the templates are
|
|
15
|
+
* installed verbatim (the pre-2.12 behaviour).
|
|
16
|
+
*
|
|
17
|
+
* Idempotent: an up-to-date copy or a link at the right target is left alone;
|
|
18
|
+
* a stale one (or a copy rendered for a previous identity) is replaced.
|
|
19
|
+
* `planSkillInstall` is the pure half; `installSkills` does the I/O and
|
|
20
|
+
* reports what it did. Nothing here throws.
|
|
21
|
+
*
|
|
22
|
+
* @module lib/collective/global-skills
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
"use strict";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, lstatSync, readlinkSync, unlinkSync, symlinkSync, rmSync,
|
|
29
|
+
} from "node:fs";
|
|
30
|
+
import { join, sep } from "node:path";
|
|
31
|
+
import { renderTemplate } from "../render.mjs";
|
|
32
|
+
|
|
33
|
+
/** ~/.claude/skills/<prefix><name>/SKILL.md */
|
|
34
|
+
export const GLOBAL_SKILL_PREFIX = "maestro-";
|
|
35
|
+
|
|
36
|
+
/** Where the shipped skills live, relative to the SDK root. */
|
|
37
|
+
export const SKILLS_REL = join("plugins", "maestro-skills", "skills");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Is the SDK a global npm install? True when its root sits under the global
|
|
41
|
+
* `node_modules` (`npm root -g`). An agent-local `node_modules` or a checkout
|
|
42
|
+
* is not global — those get copies.
|
|
43
|
+
* @param {string} sdkRoot
|
|
44
|
+
* @param {{globalRoot?:string}} o - the output of `npm root -g` ("" when unknown)
|
|
45
|
+
*/
|
|
46
|
+
export function isGlobalInstall(sdkRoot, o = {}) {
|
|
47
|
+
const root = String(sdkRoot || "");
|
|
48
|
+
const g = String(o.globalRoot || "").replace(/[\\/]+$/, "");
|
|
49
|
+
if (!root || !g) return false;
|
|
50
|
+
return root === g || root.startsWith(g + sep);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Plan: which files go where. Pure.
|
|
55
|
+
* @param {string[]} fileNames - entries of the skills dir
|
|
56
|
+
* @param {{sdkRoot:string, skillsDir:string, mode:"symlink"|"copy"}} o
|
|
57
|
+
* @returns {Array<{name:string, src:string, dest:string, mode:string}>}
|
|
58
|
+
*/
|
|
59
|
+
export function planSkillInstall(fileNames, o = {}) {
|
|
60
|
+
const mode = o.mode === "symlink" ? "symlink" : "copy";
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const f of Array.isArray(fileNames) ? fileNames : []) {
|
|
63
|
+
if (typeof f !== "string" || !f.endsWith(".md") || f.startsWith(".")) continue;
|
|
64
|
+
const name = f.slice(0, -3);
|
|
65
|
+
if (!name || /^readme$/i.test(name)) continue;
|
|
66
|
+
out.push({
|
|
67
|
+
name,
|
|
68
|
+
src: join(String(o.sdkRoot || ""), SKILLS_REL, f),
|
|
69
|
+
dest: join(String(o.skillsDir || ""), `${GLOBAL_SKILL_PREFIX}${name}`, "SKILL.md"),
|
|
70
|
+
mode,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function currentState(dest) {
|
|
77
|
+
try {
|
|
78
|
+
const st = lstatSync(dest);
|
|
79
|
+
if (st.isSymbolicLink()) return { kind: "symlink", target: readlinkSync(dest) };
|
|
80
|
+
if (st.isFile()) return { kind: "file", content: readFileSync(dest, "utf8") };
|
|
81
|
+
return { kind: "other" };
|
|
82
|
+
} catch {
|
|
83
|
+
return { kind: "absent" }; // ENOENT is the common case on first install
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Render one skill body against the identity context. Pure. Returns the body
|
|
89
|
+
* and whether rendering changed it (→ must be a copy, never a link).
|
|
90
|
+
* @param {string} body @param {object|null|undefined} context
|
|
91
|
+
*/
|
|
92
|
+
export function renderSkill(body, context) {
|
|
93
|
+
const src = typeof body === "string" ? body : "";
|
|
94
|
+
if (!context || typeof context !== "object") return { body: src, rendered: false };
|
|
95
|
+
const out = renderTemplate(src, context);
|
|
96
|
+
return { body: out, rendered: out !== src };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Apply the plan. Returns {ok, installed[], unchanged[], rendered[], errors[], plan}.
|
|
101
|
+
* @param {{sdkRoot:string, skillsDir:string, mode:"symlink"|"copy", dryRun?:boolean, context?:object}} o
|
|
102
|
+
* `context` — the lib/render `buildContext(agent, archetype, company)` shape;
|
|
103
|
+
* when given, token-bearing skills are rendered and copied.
|
|
104
|
+
*/
|
|
105
|
+
export function installSkills(o = {}) {
|
|
106
|
+
const sdkRoot = String(o.sdkRoot || "");
|
|
107
|
+
const srcDir = join(sdkRoot, SKILLS_REL);
|
|
108
|
+
let names;
|
|
109
|
+
try {
|
|
110
|
+
names = readdirSync(srcDir);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return { ok: false, error: `no skills directory at ${srcDir}: ${err && err.message}`, installed: [], unchanged: [], rendered: [], errors: [], plan: [] };
|
|
113
|
+
}
|
|
114
|
+
const plan = planSkillInstall(names, { sdkRoot, skillsDir: o.skillsDir, mode: o.mode });
|
|
115
|
+
const installed = [];
|
|
116
|
+
const unchanged = [];
|
|
117
|
+
const rendered = [];
|
|
118
|
+
const errors = [];
|
|
119
|
+
for (const p of plan) {
|
|
120
|
+
try {
|
|
121
|
+
const raw = readFileSync(p.src, "utf8");
|
|
122
|
+
const r = renderSkill(raw, o.context);
|
|
123
|
+
// A rendered body cannot live behind a symlink: the link would show the template.
|
|
124
|
+
const mode = r.rendered ? "copy" : p.mode;
|
|
125
|
+
if (r.rendered) rendered.push(p.name);
|
|
126
|
+
const cur = currentState(p.dest);
|
|
127
|
+
if (mode === "symlink" && cur.kind === "symlink" && cur.target === p.src) { unchanged.push(p.name); continue; }
|
|
128
|
+
if (mode === "copy" && cur.kind === "file" && cur.content === r.body) { unchanged.push(p.name); continue; }
|
|
129
|
+
installed.push(p.name);
|
|
130
|
+
if (o.dryRun) continue;
|
|
131
|
+
mkdirSync(join(p.dest, ".."), { recursive: true });
|
|
132
|
+
if (cur.kind !== "absent") {
|
|
133
|
+
if (cur.kind === "other") rmSync(p.dest, { recursive: true, force: true });
|
|
134
|
+
else unlinkSync(p.dest);
|
|
135
|
+
}
|
|
136
|
+
if (mode === "symlink") symlinkSync(p.src, p.dest);
|
|
137
|
+
else writeFileSync(p.dest, r.body);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
errors.push({ name: p.name, error: err && err.message ? err.message : String(err) });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { ok: errors.length === 0, installed, unchanged, rendered, errors, plan };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export default { planSkillInstall, isGlobalInstall, installSkills, renderSkill, GLOBAL_SKILL_PREFIX, SKILLS_REL };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* global-skills.test.mjs — the skills-install plan for ~/.claude/skills.
|
|
3
|
+
* Run: node --test lib/collective/global-skills.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, lstatSync, readlinkSync, existsSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { planSkillInstall, isGlobalInstall, installSkills, GLOBAL_SKILL_PREFIX } from "./global-skills.mjs";
|
|
14
|
+
|
|
15
|
+
test("isGlobalInstall: true under the global npm root (or a node_modules path outside the agent repo)", () => {
|
|
16
|
+
assert.equal(isGlobalInstall("/opt/homebrew/lib/node_modules/@cohortapp/agent-sdk", { globalRoot: "/opt/homebrew/lib/node_modules" }), true);
|
|
17
|
+
assert.equal(isGlobalInstall("/Users/a/alex-ai/node_modules/@cohortapp/agent-sdk", { globalRoot: "/opt/homebrew/lib/node_modules" }), false);
|
|
18
|
+
assert.equal(isGlobalInstall("/Users/a/maestro", { globalRoot: "/opt/homebrew/lib/node_modules" }), false);
|
|
19
|
+
assert.equal(isGlobalInstall("/Users/a/maestro", { globalRoot: "" }), false, "unknown global root → copy");
|
|
20
|
+
assert.equal(isGlobalInstall("", { globalRoot: "/x" }), false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("planSkillInstall: one maestro-<name>/SKILL.md per skill file, mode symlink|copy", () => {
|
|
24
|
+
const plan = planSkillInstall(["org-board.md", "main-session.md", "README.md", "notes.txt", ".hidden.md"], {
|
|
25
|
+
sdkRoot: "/sdk", skillsDir: "/home/.claude/skills", mode: "symlink",
|
|
26
|
+
});
|
|
27
|
+
assert.deepEqual(plan.map((p) => p.name), ["org-board", "main-session"], "README/non-md/hidden skipped");
|
|
28
|
+
assert.equal(plan[0].src, "/sdk/plugins/maestro-skills/skills/org-board.md");
|
|
29
|
+
assert.equal(plan[0].dest, `/home/.claude/skills/${GLOBAL_SKILL_PREFIX}org-board/SKILL.md`);
|
|
30
|
+
assert.equal(plan[0].mode, "symlink");
|
|
31
|
+
assert.equal(planSkillInstall(["a.md"], { sdkRoot: "/s", skillsDir: "/d", mode: "copy" })[0].mode, "copy");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("installSkills: copy mode writes files, symlink mode links, both idempotent and refresh on re-run", () => {
|
|
35
|
+
const root = mkdtempSync(join(tmpdir(), "global-skills-"));
|
|
36
|
+
try {
|
|
37
|
+
const sdk = join(root, "sdk");
|
|
38
|
+
const skillsSrc = join(sdk, "plugins", "maestro-skills", "skills");
|
|
39
|
+
mkdirSync(skillsSrc, { recursive: true });
|
|
40
|
+
writeFileSync(join(skillsSrc, "main-session.md"), "---\nname: main-session\n---\nbody v1\n");
|
|
41
|
+
writeFileSync(join(skillsSrc, "board-work.md"), "---\nname: board-work\n---\nbw\n");
|
|
42
|
+
const skillsDir = join(root, "home", ".claude", "skills");
|
|
43
|
+
|
|
44
|
+
const r1 = installSkills({ sdkRoot: sdk, skillsDir, mode: "copy" });
|
|
45
|
+
assert.equal(r1.ok, true);
|
|
46
|
+
assert.deepEqual(r1.installed.sort(), ["board-work", "main-session"]);
|
|
47
|
+
assert.equal(r1.unchanged.length, 0);
|
|
48
|
+
assert.equal(readFileSync(join(skillsDir, "maestro-main-session", "SKILL.md"), "utf8"), "---\nname: main-session\n---\nbody v1\n");
|
|
49
|
+
|
|
50
|
+
const r2 = installSkills({ sdkRoot: sdk, skillsDir, mode: "copy" });
|
|
51
|
+
assert.equal(r2.installed.length, 0, "idempotent");
|
|
52
|
+
assert.equal(r2.unchanged.length, 2);
|
|
53
|
+
|
|
54
|
+
writeFileSync(join(skillsSrc, "main-session.md"), "---\nname: main-session\n---\nbody v2\n");
|
|
55
|
+
const r3 = installSkills({ sdkRoot: sdk, skillsDir, mode: "copy" });
|
|
56
|
+
assert.deepEqual(r3.installed, ["main-session"], "a changed source refreshes the copy");
|
|
57
|
+
assert.match(readFileSync(join(skillsDir, "maestro-main-session", "SKILL.md"), "utf8"), /body v2/);
|
|
58
|
+
|
|
59
|
+
// Switch to symlink mode: replaces the copy with a link, then no-ops.
|
|
60
|
+
const r4 = installSkills({ sdkRoot: sdk, skillsDir, mode: "symlink" });
|
|
61
|
+
assert.equal(r4.installed.length, 2);
|
|
62
|
+
const link = join(skillsDir, "maestro-board-work", "SKILL.md");
|
|
63
|
+
assert.ok(lstatSync(link).isSymbolicLink());
|
|
64
|
+
assert.equal(readlinkSync(link), join(skillsSrc, "board-work.md"));
|
|
65
|
+
const r5 = installSkills({ sdkRoot: sdk, skillsDir, mode: "symlink" });
|
|
66
|
+
assert.equal(r5.installed.length, 0);
|
|
67
|
+
assert.equal(r5.unchanged.length, 2);
|
|
68
|
+
|
|
69
|
+
// Dry run touches nothing.
|
|
70
|
+
rmSync(skillsDir, { recursive: true, force: true });
|
|
71
|
+
const dry = installSkills({ sdkRoot: sdk, skillsDir, mode: "copy", dryRun: true });
|
|
72
|
+
assert.equal(dry.installed.length, 2);
|
|
73
|
+
assert.equal(existsSync(skillsDir), false);
|
|
74
|
+
|
|
75
|
+
// Missing source dir → ok:false with a reason, never a throw.
|
|
76
|
+
const none = installSkills({ sdkRoot: join(root, "nope"), skillsDir, mode: "copy" });
|
|
77
|
+
assert.equal(none.ok, false);
|
|
78
|
+
assert.match(none.error, /skills/);
|
|
79
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── Review fix: {{agent.*}} tokens must be rendered for Claude-Code-loaded skills ─
|
|
83
|
+
// Only the cadence consumer rendered templates; a skill installed into
|
|
84
|
+
// ~/.claude/skills read "You are `{{agent.firstName}}`'s main session". A symlink
|
|
85
|
+
// cannot render, so a token-bearing skill is rendered + COPIED even under a
|
|
86
|
+
// global install; token-free skills keep the symlink.
|
|
87
|
+
|
|
88
|
+
test("installSkills: with a render context, token-bearing skills are rendered and copied (even in symlink mode); token-free skills stay symlinks; a changed identity refreshes the copy", () => {
|
|
89
|
+
const root = mkdtempSync(join(tmpdir(), "global-skills-render-"));
|
|
90
|
+
try {
|
|
91
|
+
const sdk = join(root, "sdk");
|
|
92
|
+
const skillsSrc = join(sdk, "plugins", "maestro-skills", "skills");
|
|
93
|
+
mkdirSync(skillsSrc, { recursive: true });
|
|
94
|
+
writeFileSync(join(skillsSrc, "main-session.md"), "---\nname: main-session\n---\nYou are `{{agent.firstName}}`'s main session at {{company.name}}.\n");
|
|
95
|
+
writeFileSync(join(skillsSrc, "persona-discipline.md"), "---\nname: persona-discipline\n---\nYou are `{{agent.fullName}}`, `{{agent.title}}`. Unknown {{agent.nope}} stays.\n");
|
|
96
|
+
writeFileSync(join(skillsSrc, "board-work.md"), "---\nname: board-work\n---\nno tokens here\n");
|
|
97
|
+
const skillsDir = join(root, "home", ".claude", "skills");
|
|
98
|
+
const context = { agent: { firstName: "Alex", fullName: "Alex Rivera", title: "Chief of Staff" }, company: { name: "Acme" } };
|
|
99
|
+
|
|
100
|
+
const r1 = installSkills({ sdkRoot: sdk, skillsDir, mode: "symlink", context });
|
|
101
|
+
assert.equal(r1.ok, true);
|
|
102
|
+
assert.deepEqual(r1.installed.sort(), ["board-work", "main-session", "persona-discipline"]);
|
|
103
|
+
assert.deepEqual(r1.rendered.sort(), ["main-session", "persona-discipline"], "the token-bearing skills are reported as rendered");
|
|
104
|
+
const main = join(skillsDir, "maestro-main-session", "SKILL.md");
|
|
105
|
+
assert.equal(lstatSync(main).isSymbolicLink(), false, "rendered → a real file, never a link");
|
|
106
|
+
assert.equal(readFileSync(main, "utf8"), "---\nname: main-session\n---\nYou are `Alex`'s main session at Acme.\n");
|
|
107
|
+
assert.match(readFileSync(join(skillsDir, "maestro-persona-discipline", "SKILL.md"), "utf8"), /You are `Alex Rivera`, `Chief of Staff`\. Unknown \{\{agent\.nope\}\} stays\./);
|
|
108
|
+
assert.ok(lstatSync(join(skillsDir, "maestro-board-work", "SKILL.md")).isSymbolicLink(), "token-free → symlink");
|
|
109
|
+
|
|
110
|
+
const r2 = installSkills({ sdkRoot: sdk, skillsDir, mode: "symlink", context });
|
|
111
|
+
assert.equal(r2.installed.length, 0, "idempotent against the RENDERED content");
|
|
112
|
+
assert.equal(r2.unchanged.length, 3);
|
|
113
|
+
|
|
114
|
+
const r3 = installSkills({ sdkRoot: sdk, skillsDir, mode: "symlink", context: { agent: { firstName: "Robin", fullName: "Robin Park", title: "COO" }, company: { name: "Acme" } } });
|
|
115
|
+
assert.deepEqual(r3.installed.sort(), ["main-session", "persona-discipline"], "a new identity refreshes exactly the rendered skills");
|
|
116
|
+
assert.match(readFileSync(main, "utf8"), /`Robin`'s main session/);
|
|
117
|
+
|
|
118
|
+
// Copy mode renders too; no context → raw copy (previous behaviour).
|
|
119
|
+
const r4 = installSkills({ sdkRoot: sdk, skillsDir, mode: "copy", context });
|
|
120
|
+
assert.match(readFileSync(main, "utf8"), /`Alex`'s main session/);
|
|
121
|
+
assert.deepEqual(r4.rendered.sort(), ["main-session", "persona-discipline"]);
|
|
122
|
+
const r5 = installSkills({ sdkRoot: sdk, skillsDir, mode: "copy" });
|
|
123
|
+
assert.match(readFileSync(main, "utf8"), /\{\{agent\.firstName\}\}/, "no context → template left verbatim");
|
|
124
|
+
assert.deepEqual(r5.rendered, []);
|
|
125
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
126
|
+
});
|