@cohortapp/agent-sdk 2.11.15 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. package/.env.example +37 -22
  2. package/README.md +2 -0
  3. package/bin/maestro.mjs +117 -39
  4. package/bin/maestro.test.mjs +175 -5
  5. package/docs/guides/front-door-session.md +313 -0
  6. package/docs/guides/mac-mini.md +100 -28
  7. package/docs/guides/org-onboarding.md +1 -1
  8. package/docs/guides/setup-wizard.md +9 -5
  9. package/docs/runbooks/cohort-cutover.md +11 -1
  10. package/docs/runbooks/mac-mini-bootstrap.md +38 -63
  11. package/lib/cadence-bus-requeue.test.mjs +83 -0
  12. package/lib/cadence-bus.mjs +43 -7
  13. package/lib/channels/inbox-item.mjs +59 -2
  14. package/lib/cli/board.mjs +285 -0
  15. package/lib/cli/board.test.mjs +227 -0
  16. package/lib/cli/design.mjs +185 -0
  17. package/lib/cli/design.test.mjs +270 -0
  18. package/lib/cli/doctor-checks.mjs +441 -0
  19. package/lib/cli/doctor-checks.test.mjs +336 -0
  20. package/lib/cli/global-setup-extras.mjs +454 -0
  21. package/lib/cli/global-setup-extras.test.mjs +462 -0
  22. package/lib/cli/inbox.mjs +304 -0
  23. package/lib/cli/inbox.test.mjs +230 -0
  24. package/lib/cli/session-ack.mjs +63 -0
  25. package/lib/cli/session-ack.test.mjs +63 -0
  26. package/lib/cli/session.mjs +760 -0
  27. package/lib/cli/session.test.mjs +613 -0
  28. package/lib/collective/global-config.mjs +209 -6
  29. package/lib/collective/global-config.test.mjs +145 -0
  30. package/lib/collective/global-skills.mjs +145 -0
  31. package/lib/collective/global-skills.test.mjs +126 -0
  32. package/lib/collective/presence.mjs +4 -3
  33. package/lib/collective/vendor-skills.mjs +305 -0
  34. package/lib/collective/vendor-skills.test.mjs +306 -0
  35. package/lib/comms/send-gate.mjs +115 -0
  36. package/lib/comms/send-gate.test.mjs +113 -0
  37. package/lib/design/design-md.mjs +793 -0
  38. package/lib/design/design-md.test.mjs +318 -0
  39. package/lib/design/fixtures/DESIGN.golden.md +238 -0
  40. package/lib/design/fixtures/PRODUCT.golden.md +67 -0
  41. package/lib/design/fixtures/foundation.json +133 -0
  42. package/lib/design/refresh-gate.mjs +154 -0
  43. package/lib/design/refresh-gate.test.mjs +144 -0
  44. package/lib/design/write.mjs +275 -0
  45. package/lib/design/write.test.mjs +241 -0
  46. package/lib/feature-init.mjs +2 -2
  47. package/lib/mcp/server.test.mjs +9 -4
  48. package/lib/model-router/spawn.test.mjs +21 -0
  49. package/lib/org/board-mine-cache.mjs +99 -0
  50. package/lib/org/board-mine-cache.test.mjs +53 -0
  51. package/lib/org/board.mjs +11 -0
  52. package/lib/org/board.test.mjs +11 -1
  53. package/lib/org/client.mjs +36 -0
  54. package/lib/org/client.test.mjs +46 -0
  55. package/lib/org/inbound/directedness.mjs +18 -2
  56. package/lib/org/inbound/directedness.test.mjs +58 -0
  57. package/lib/org/inbound/index.mjs +8 -1
  58. package/lib/org/inbound/index.test.mjs +22 -0
  59. package/lib/org/mesh-directives.test.mjs +110 -0
  60. package/lib/org/mesh.mjs +61 -1
  61. package/lib/org/protocol.checksum +1 -1
  62. package/lib/org/protocol.mjs +52 -0
  63. package/lib/org/protocol.test.mjs +12 -1
  64. package/lib/org/registry.mjs +3 -2
  65. package/lib/org/tool-surface.mjs +120 -0
  66. package/lib/org/tool-surface.test.mjs +118 -5
  67. package/lib/prompts/parallelism.mjs +79 -0
  68. package/lib/prompts/parallelism.test.mjs +177 -0
  69. package/lib/security/external-content.mjs +1 -1
  70. package/lib/security/external-content.test.mjs +17 -0
  71. package/lib/session/config.mjs +137 -0
  72. package/lib/session/config.test.mjs +92 -0
  73. package/lib/session/feed-core.mjs +229 -0
  74. package/lib/session/feed-core.test.mjs +198 -0
  75. package/lib/session/first-run.mjs +126 -0
  76. package/lib/session/first-run.test.mjs +121 -0
  77. package/lib/session/frontdoor.mjs +266 -0
  78. package/lib/session/frontdoor.test.mjs +205 -0
  79. package/lib/session/handoffs.mjs +295 -0
  80. package/lib/session/handoffs.test.mjs +183 -0
  81. package/lib/session/identity.mjs +220 -0
  82. package/lib/session/identity.test.mjs +180 -0
  83. package/lib/session/inbox-claims.mjs +434 -0
  84. package/lib/session/inbox-claims.test.mjs +286 -0
  85. package/lib/session/launch-args.mjs +161 -0
  86. package/lib/session/launch-args.test.mjs +157 -0
  87. package/lib/session/liveness.mjs +174 -0
  88. package/lib/session/liveness.test.mjs +100 -0
  89. package/lib/session/status-summary.mjs +172 -0
  90. package/lib/session/status-summary.test.mjs +118 -0
  91. package/lib/session-permissions.mjs +39 -3
  92. package/lib/session-permissions.test.mjs +20 -0
  93. package/lib/setup/claude-probe.mjs +161 -24
  94. package/lib/setup/claude-probe.test.mjs +187 -0
  95. package/lib/setup/sections/learning.mjs +2 -1
  96. package/lib/setup/sections/model.mjs +104 -24
  97. package/lib/setup/sections/model.test.mjs +240 -0
  98. package/lib/setup/sections/org.mjs +27 -2
  99. package/lib/setup/sections/org.test.mjs +35 -2
  100. package/lib/setup/sections/verify.mjs +5 -0
  101. package/lib/setup/state.mjs +30 -10
  102. package/lib/setup/state.test.mjs +24 -1
  103. package/lib/singleton.js +11 -3
  104. package/lib/singleton.test.mjs +16 -0
  105. package/lib/subagents/lock.mjs +1 -1
  106. package/lib/telemetry/collect.mjs +270 -6
  107. package/lib/telemetry/collect.test.mjs +196 -1
  108. package/lib/upgrade/global-refresh.mjs +108 -0
  109. package/lib/upgrade/global-refresh.test.mjs +65 -0
  110. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  111. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  112. package/lib/upgrade/post-steps.mjs +151 -0
  113. package/lib/upgrade/post-steps.test.mjs +200 -0
  114. package/lib/upgrade/verify.mjs +215 -0
  115. package/lib/upgrade/verify.test.mjs +164 -0
  116. package/lib/voice/outbound.mjs +3 -2
  117. package/lib/voice/post-call-brief.mjs +2 -1
  118. package/lib/voice/session-rotation.mjs +6 -1
  119. package/lib/voice/session-rotation.test.mjs +114 -0
  120. package/package.json +3 -3
  121. package/plugins/maestro-skills/plugin.json +25 -1
  122. package/plugins/maestro-skills/skills/board-work.md +63 -0
  123. package/plugins/maestro-skills/skills/cohort-design.md +153 -0
  124. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  125. package/plugins/maestro-skills/skills/main-session.md +102 -0
  126. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  127. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  128. package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
  129. package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
  130. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
  131. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
  132. package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
  133. package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
  134. package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
  135. package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
  136. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
  137. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
  138. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
  139. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
  140. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
  141. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
  142. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
  143. package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
  144. package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
  145. package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
  146. package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
  147. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
  148. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
  149. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
  150. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  151. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
  152. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
  153. package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
  154. package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
  155. package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
  156. package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
  157. package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
  158. package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
  159. package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
  160. package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
  161. package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
  162. package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
  163. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
  164. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
  165. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
  166. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  167. package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
  168. package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
  169. package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
  170. package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
  171. package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
  172. package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
  173. package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
  174. package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
  175. package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
  176. package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
  177. package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
  178. package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
  179. package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
  180. package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
  181. package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
  182. package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
  183. package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
  184. package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
  185. package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
  186. package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
  187. package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
  188. package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
  189. package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
  190. package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
  191. package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
  192. package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
  193. package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
  194. package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
  195. package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
  196. package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
  197. package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
  198. package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
  199. package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
  200. package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
  201. package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
  202. package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
  203. package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
  204. package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
  205. package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
  206. package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
  207. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
  208. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
  209. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
  210. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
  211. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
  212. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
  213. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
  214. package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
  215. package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
  216. package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
  217. package/scaffold/CLAUDE.md +24 -0
  218. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  219. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  220. package/scripts/ci/check-skill-packs.mjs +388 -0
  221. package/scripts/ci/check-skill-packs.test.mjs +495 -0
  222. package/scripts/ci/check.mjs +6 -0
  223. package/scripts/collective/hook-runner.mjs +39 -4
  224. package/scripts/collective/hook-runner.test.mjs +85 -2
  225. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  226. package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
  227. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  228. package/scripts/daemon/agent-daemon.mjs +249 -10
  229. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  230. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  231. package/scripts/daemon/assurance.mjs +461 -37
  232. package/scripts/daemon/assurance.test.mjs +408 -43
  233. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +393 -0
  234. package/scripts/daemon/cadence-consumer.mjs +289 -89
  235. package/scripts/daemon/cadence-handlers.mjs +53 -0
  236. package/scripts/daemon/classifier.mjs +1 -1
  237. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  238. package/scripts/daemon/dispatcher.mjs +127 -19
  239. package/scripts/daemon/health.mjs +12 -1
  240. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  241. package/scripts/daemon/inbox-deferral.mjs +6 -0
  242. package/scripts/daemon/lib/self-echo.mjs +201 -0
  243. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  244. package/scripts/daemon/maestro-daemon.mjs +3 -0
  245. package/scripts/daemon/prompt-builder.mjs +19 -3
  246. package/scripts/daemon/responder.mjs +51 -40
  247. package/scripts/daemon/sdk-version.mjs +51 -0
  248. package/scripts/daemon/sdk-version.test.mjs +31 -0
  249. package/scripts/hooks/pre-send-audit.sh +97 -4
  250. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  251. package/scripts/local-triggers/autoupdate.sh +243 -19
  252. package/scripts/local-triggers/autoupdate.test.mjs +518 -0
  253. package/scripts/local-triggers/generate-plists.sh +24 -1
  254. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  255. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  256. package/scripts/org/send-orgmail.mjs +27 -3
  257. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  258. package/scripts/poller/slack-poller.mjs +13 -1
  259. package/scripts/poller/utils.mjs +46 -1
  260. package/scripts/poller-launchd/install.sh +19 -11
  261. package/scripts/poller-launchd/install.test.mjs +243 -0
  262. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  263. package/scripts/poller-launchd/migrate.sh +66 -0
  264. package/scripts/poller-launchd/poller.plist.template +4 -2
  265. package/scripts/session/feed.mjs +237 -0
  266. package/scripts/session/feed.test.mjs +196 -0
  267. package/scripts/session/supervisor-sh.test.mjs +218 -0
  268. package/scripts/session/supervisor.mjs +328 -0
  269. package/scripts/session/supervisor.sh +141 -0
  270. package/scripts/session/supervisor.test.mjs +482 -0
  271. package/scripts/setup/configure-macos.sh +250 -55
  272. package/scripts/setup/configure-macos.test.mjs +306 -0
  273. package/scripts/setup/init-agent.sh +112 -7
  274. package/scripts/setup/init-agent.test.mjs +220 -1
  275. package/scripts/vendor/skill-packs.mjs +354 -0
  276. package/scripts/vendor/sync-skill-packs.mjs +242 -0
  277. package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
  278. package/scripts/watchdog/memory-watchdog.sh +37 -1
  279. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  280. package/scripts/setup/boot-claude-session.sh +0 -94
@@ -20,9 +20,10 @@
20
20
  "use strict";
21
21
 
22
22
  import {
23
- writeFileSync, readFileSync, unlinkSync, readdirSync, mkdirSync, existsSync,
23
+ readFileSync, unlinkSync, readdirSync, mkdirSync, existsSync,
24
24
  } from "node:fs";
25
25
  import { join, basename } from "node:path";
26
+ import { writeJsonAtomic } from "../fs-atomic.mjs";
26
27
 
27
28
  const PRESENCE_REL = "state/collective/presence";
28
29
  const DEFAULT_STALE_SEC = 300;
@@ -84,7 +85,7 @@ export function register(agentRoot, info = {}) {
84
85
  status: info.status || "active",
85
86
  kind: info.kind || "interactive",
86
87
  };
87
- writeFileSync(path, JSON.stringify(rec, null, 2));
88
+ writeJsonAtomic(path, rec);
88
89
  return true;
89
90
  } catch {
90
91
  return false;
@@ -104,7 +105,7 @@ export function heartbeat(agentRoot, sessionId, updates = {}) {
104
105
  rec.lastBeat = nowIso(updates.nowMs);
105
106
  if (updates.intent !== undefined) rec.intent = String(updates.intent).slice(0, 200);
106
107
  if (updates.status !== undefined) rec.status = updates.status;
107
- writeFileSync(path, JSON.stringify(rec, null, 2));
108
+ writeJsonAtomic(path, rec);
108
109
  return true;
109
110
  } catch {
110
111
  return false;
@@ -0,0 +1,305 @@
1
+ /**
2
+ * lib/collective/vendor-skills.mjs — install the VENDORED design skill packs
3
+ * into ~/.claude/skills so any Claude Code session on the seat machine has
4
+ * them, alongside the maestro-authored skills that lib/collective/global-skills.mjs
5
+ * installs.
6
+ *
7
+ * The two installers differ in two ways that matter:
8
+ *
9
+ * - **Directory name.** A maestro skill lands at `maestro-<file>`; a vendored
10
+ * skill lands at its own frontmatter `name`, because that name is what its
11
+ * own prose, its sibling references and the harmoniser skill all refer to.
12
+ * - **Copy, never symlink.** A vendored skill is a small tree (SKILL.md plus
13
+ * reference files), and the point of vendoring is that the seat holds
14
+ * reviewed bytes rather than a pointer into a package that `npm` may replace
15
+ * underneath a running session.
16
+ *
17
+ * **Never clobber a directory that is not ours.** `~/.claude/skills/impeccable`
18
+ * may already exist because the human installed the upstream skill themselves —
19
+ * with its launcher, its hooks and its live mode, none of which belong on a
20
+ * seat. Overwriting that would silently take their install away, and merging
21
+ * into it would produce a tree that is neither. So every directory we own
22
+ * carries {@link MARKER_FILE}; a directory without one is left exactly as it is
23
+ * and reported. The marker's content is derived entirely from the pack (no
24
+ * timestamps), so a re-run of an unchanged install writes nothing at all.
25
+ *
26
+ * Nothing here executes a vendored file. It is content.
27
+ *
28
+ * @module lib/collective/vendor-skills
29
+ */
30
+
31
+ "use strict";
32
+
33
+ import { createHash } from "node:crypto";
34
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, cpSync } from "node:fs";
35
+ import { join, dirname, sep } from "node:path";
36
+
37
+ /** Where the vendored packs live, relative to the SDK root. */
38
+ export const VENDOR_REL = join("plugins", "maestro-skills", "vendor");
39
+
40
+ /** The file that says "maestro owns this skill directory". */
41
+ export const MARKER_FILE = ".maestro-vendor.json";
42
+
43
+ /** Written into MARKER_FILE so the owner is unambiguous to a human reading it. */
44
+ export const MARKER_OWNER = "@cohortapp/agent-sdk";
45
+
46
+ /**
47
+ * The `name` from a SKILL.md's YAML frontmatter. Pure. Returns null when the
48
+ * file has no frontmatter or no name — the caller must not guess a directory
49
+ * name, because a wrong guess is a directory nobody can find again.
50
+ * @param {string} body
51
+ * @returns {string|null}
52
+ */
53
+ export function frontmatterName(body) {
54
+ const text = String(body || "");
55
+ if (!text.startsWith("---")) return null;
56
+ const end = text.indexOf("\n---", 3);
57
+ if (end === -1) return null;
58
+ for (const line of text.slice(3, end).split("\n")) {
59
+ const m = /^name:\s*(.+?)\s*$/.exec(line);
60
+ if (!m) continue;
61
+ const name = m[1].replace(/^["']|["']$/g, "").trim();
62
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) ? name : null;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /** Recursively list files under `dir` relative to it (POSIX), sorted. */
68
+ export function listFiles(dir, prefix = "") {
69
+ let out = [];
70
+ let entries;
71
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
72
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
73
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
74
+ if (e.isDirectory()) out = out.concat(listFiles(join(dir, e.name), rel));
75
+ else if (e.isFile()) out.push(rel);
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * The marker body for one installed skill. Deterministic — no clock, so a
82
+ * re-run over an unchanged install writes nothing.
83
+ *
84
+ * `files` records the sha256 of every file THIS installer wrote. That is what
85
+ * lets the next run tell a file it wrote itself from a file a person edited or
86
+ * added, and back the latter up instead of deleting it — the module's own
87
+ * "additive, idempotent, backed up" contract, which the first cut honoured for
88
+ * every JSON file `global-setup` touches and not for this step.
89
+ *
90
+ * @param {object} pack @param {string} name
91
+ * @param {Array<{to:string, sha256:string}>} [files]
92
+ */
93
+ export function markerFor(pack, name, files = []) {
94
+ const doc = {
95
+ owner: MARKER_OWNER, pack: pack.id, repo: pack.repo, sha: pack.sha, license: pack.license, skill: name,
96
+ files: files.slice().sort((a, b) => (a.to < b.to ? -1 : 1)).map((f) => ({ path: f.to, sha256: f.sha256 })),
97
+ };
98
+ return `${JSON.stringify(doc, null, 2)}\n`;
99
+ }
100
+
101
+ /** sha256 of a buffer or string. Pure. */
102
+ export function sha256(body) {
103
+ return createHash("sha256").update(body).digest("hex");
104
+ }
105
+
106
+ /**
107
+ * What this installer last wrote into `dir`, as path → sha256, from its marker.
108
+ * An older marker with no `files` map yields an empty map, which is read as
109
+ * "we cannot prove we wrote any of this" — so nothing there is deleted without
110
+ * a backup. Failing safe costs one backup directory; failing open costs
111
+ * somebody their edits.
112
+ * @param {string} dir @returns {Map<string,string>}
113
+ */
114
+ export function recordedFiles(dir) {
115
+ try {
116
+ const doc = JSON.parse(readFileSync(join(dir, MARKER_FILE), "utf8"));
117
+ if (!doc || !Array.isArray(doc.files)) return new Map();
118
+ return new Map(doc.files.map((f) => [String(f && f.path), String(f && f.sha256)]));
119
+ } catch { return new Map(); }
120
+ }
121
+
122
+ /**
123
+ * Does this directory belong to us? `absent` when there is nothing there,
124
+ * `ours` when it carries our marker, `foreign` otherwise — and `foreign` is
125
+ * always left alone.
126
+ * @param {string} dir
127
+ * @returns {"absent"|"ours"|"foreign"}
128
+ */
129
+ export function ownership(dir) {
130
+ if (!existsSync(dir)) return "absent";
131
+ const marker = join(dir, MARKER_FILE);
132
+ if (!existsSync(marker)) return "foreign";
133
+ try {
134
+ const doc = JSON.parse(readFileSync(marker, "utf8"));
135
+ return doc && doc.owner === MARKER_OWNER ? "ours" : "foreign";
136
+ } catch {
137
+ return "foreign"; // an unreadable marker is not our proof of ownership
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Plan the install: which vendored file goes to which destination, per skill.
143
+ * Pure over the filesystem it reads (no writes). A pack whose vendored tree is
144
+ * absent (a checkout that has not synced, a tarball built before WP-M7) simply
145
+ * contributes nothing.
146
+ *
147
+ * @param {{sdkRoot:string, skillsDir:string, packs:Array}} o
148
+ * @returns {{ skills: Array<{pack:object, name:string, root:string, dest:string, files:Array<{from:string,to:string}>}>, errors: Array<{pack:string, error:string}> }}
149
+ */
150
+ export function planVendorSkills(o = {}) {
151
+ const sdkRoot = String(o.sdkRoot || "");
152
+ const skillsDir = String(o.skillsDir || "");
153
+ const skills = [];
154
+ const errors = [];
155
+ for (const pack of o.packs || []) {
156
+ const packDir = join(sdkRoot, VENDOR_REL, pack.id);
157
+ if (!existsSync(packDir)) { errors.push({ pack: pack.id, error: `vendored tree absent at ${packDir}` }); continue; }
158
+ const packFiles = listFiles(packDir).filter((f) => f !== "UPSTREAM.json");
159
+ for (const root of pack.skillRoots || []) {
160
+ const rel = root === "." ? "" : root;
161
+ const prefix = rel ? `${rel}/` : "";
162
+ const inRoot = packFiles.filter((f) => (prefix ? f.startsWith(prefix) : true));
163
+ // A nested skill root (emilkowalski/taste-skill hold several) must not
164
+ // swallow its siblings when the outer root is the pack itself.
165
+ const owned = rel
166
+ ? inRoot
167
+ : packFiles.filter((f) => !(pack.skillRoots || []).some((r) => r !== "." && f.startsWith(`${r}/`)));
168
+ const skillMd = owned.find((f) => f === `${prefix}SKILL.md`);
169
+ if (!skillMd) { errors.push({ pack: pack.id, error: `no SKILL.md under ${root}` }); continue; }
170
+ let name;
171
+ try {
172
+ name = frontmatterName(readFileSync(join(packDir, skillMd), "utf8"));
173
+ } catch (err) {
174
+ errors.push({ pack: pack.id, error: `unreadable ${skillMd}: ${err && err.message}` });
175
+ continue;
176
+ }
177
+ if (!name) { errors.push({ pack: pack.id, error: `${skillMd} has no usable frontmatter name` }); continue; }
178
+ const files = owned.map((f) => ({ from: f, to: prefix ? f.slice(prefix.length) : f }));
179
+ // A skill installed out of a sub-directory still ships its licence.
180
+ if (rel) {
181
+ for (const lic of ["LICENSE", "NOTICE.md"]) {
182
+ if (packFiles.includes(lic) && !files.some((f) => f.to === lic)) files.push({ from: lic, to: lic });
183
+ }
184
+ }
185
+ files.sort((a, b) => (a.to < b.to ? -1 : 1));
186
+ skills.push({ pack, name, root, dest: join(skillsDir, name), files });
187
+ }
188
+ }
189
+ skills.sort((a, b) => (a.name < b.name ? -1 : 1));
190
+ // Two packs claiming one frontmatter name would install into one directory:
191
+ // `ownership()` reads only the marker's `owner`, so the second pack would see
192
+ // "ours", compute the first pack's files as stale, delete them and write its
193
+ // own — reported as a normal install. There is no collision today; the
194
+ // registry is meant to grow, so the collision is an error rather than a race.
195
+ const kept = [];
196
+ const claimed = new Map();
197
+ for (const s of skills) {
198
+ const first = claimed.get(s.name);
199
+ if (first) {
200
+ errors.push({ pack: s.pack.id, error: `skill name "${s.name}" is already claimed by pack ${first} — refusing to install one over the other; rename it in the registry` });
201
+ continue;
202
+ }
203
+ claimed.set(s.name, s.pack.id);
204
+ kept.push(s);
205
+ }
206
+ return { skills: kept, errors };
207
+ }
208
+
209
+ /**
210
+ * Apply the plan. Idempotent: an up-to-date directory is left untouched and
211
+ * reported as unchanged; a foreign directory is skipped with a reason; a stale
212
+ * file inside a directory we own is removed. Never throws.
213
+ *
214
+ * @param {{sdkRoot:string, skillsDir:string, packs:Array, dryRun?:boolean}} o
215
+ * @returns {{ok:boolean, installed:string[], unchanged:string[], skipped:Array<{name:string,reason:string}>, backups:Array<{name:string,path:string,files:string[]}>, errors:Array<{pack:string,error:string}>, plan:Array}}
216
+ */
217
+ export function installVendorSkills(o = {}) {
218
+ // A destination that is not a skills directory is a bug upstream of us — an
219
+ // unset variable interpolating to "undefined/home/.claude/skills" once had
220
+ // this installer create a full 14-skill tree relative to the cwd, inside a
221
+ // repo checkout. `looksLikeSkillsDir` existed for exactly that and was never
222
+ // called, which is worse than not having it: its passing test read as proof.
223
+ if (!looksLikeSkillsDir(o.skillsDir)) {
224
+ return { ok: false, installed: [], unchanged: [], skipped: [], plan: [],
225
+ errors: [{ pack: "-", error: `refusing to install: ${JSON.stringify(String(o.skillsDir || ""))} is not a skills directory` }] };
226
+ }
227
+ const { skills, errors } = planVendorSkills(o);
228
+ const installed = [];
229
+ const unchanged = [];
230
+ const skipped = [];
231
+ const backups = [];
232
+ const stamp = o.stamp || new Date().toISOString().replace(/[:.]/g, "-");
233
+ for (const skill of skills) {
234
+ try {
235
+ const own = ownership(skill.dest);
236
+ if (own === "foreign") {
237
+ skipped.push({ name: skill.name, reason: `${skill.dest} exists and is not maestro's — leaving it alone` });
238
+ continue;
239
+ }
240
+ const packDir = join(o.sdkRoot, VENDOR_REL, skill.pack.id);
241
+ const wanted = new Map(skill.files.map((f) => [f.to, readFileSync(join(packDir, f.from))]));
242
+ wanted.set(MARKER_FILE, Buffer.from(markerFor(
243
+ skill.pack, skill.name,
244
+ skill.files.map((f) => ({ to: f.to, sha256: sha256(wanted.get(f.to)) })),
245
+ )));
246
+ const present = own === "ours" ? listFiles(skill.dest) : [];
247
+ const stale = present.filter((f) => !wanted.has(f));
248
+ const wrote = recordedFiles(skill.dest);
249
+
250
+ // Anything in this directory we cannot prove we wrote is somebody's work:
251
+ // a file they added, or one of ours they edited. Copy the directory aside
252
+ // once before touching it, exactly as every other global-setup step backs
253
+ // up a file it is about to rewrite.
254
+ const mine = (rel, cur) => rel === MARKER_FILE || (wrote.has(rel) && cur !== null && wrote.get(rel) === sha256(cur));
255
+ const readOr = (rel) => { try { return readFileSync(join(skill.dest, rel)); } catch { return null; } };
256
+ const humanTouched = present.filter((rel) => !mine(rel, readOr(rel)));
257
+
258
+ let changed = stale.length > 0;
259
+ let backedUp = null;
260
+ const backup = () => {
261
+ if (backedUp || o.dryRun) return;
262
+ backedUp = `${skill.dest}.backup.${stamp}`;
263
+ cpSync(skill.dest, backedUp, { recursive: true });
264
+ backups.push({ name: skill.name, path: backedUp, files: humanTouched.slice() });
265
+ };
266
+ if (humanTouched.length) backup();
267
+
268
+ for (const [rel, body] of wanted) {
269
+ const abs = join(skill.dest, rel);
270
+ const cur = readOr(rel);
271
+ if (cur !== null && cur.equals(body)) continue;
272
+ changed = true;
273
+ if (o.dryRun) continue;
274
+ mkdirSync(dirname(abs), { recursive: true });
275
+ writeFileSync(abs, body, { mode: 0o644 });
276
+ }
277
+ if (!o.dryRun) for (const rel of stale) rmSync(join(skill.dest, rel), { force: true });
278
+ (changed ? installed : unchanged).push(skill.name);
279
+ } catch (err) {
280
+ errors.push({ pack: skill.pack.id, error: `${skill.name}: ${err && err.message ? err.message : err}` });
281
+ }
282
+ }
283
+ return { ok: errors.length === 0, installed, unchanged, skipped, backups, errors, plan: skills };
284
+ }
285
+
286
+ /** Guard against a skills dir that is not under a home-ish path. Pure. */
287
+ export function looksLikeSkillsDir(dir) {
288
+ const d = String(dir || "");
289
+ return d.endsWith(`${sep}skills`) || d.endsWith("/skills");
290
+ }
291
+
292
+ /** File count + byte size of a vendored pack, for reporting. */
293
+ export function packSize(sdkRoot, pack) {
294
+ const dir = join(sdkRoot, VENDOR_REL, pack.id);
295
+ let bytes = 0;
296
+ const files = listFiles(dir);
297
+ for (const f of files) { try { bytes += statSync(join(dir, f)).size; } catch { /* a file that vanished mid-scan is not a size */ } }
298
+ return { files: files.length, bytes };
299
+ }
300
+
301
+ export default {
302
+ VENDOR_REL, MARKER_FILE, MARKER_OWNER, frontmatterName, listFiles, markerFor,
303
+ ownership, planVendorSkills, installVendorSkills, looksLikeSkillsDir, packSize,
304
+ sha256, recordedFiles,
305
+ };
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Tests for lib/collective/vendor-skills.mjs — installing the VENDORED design
3
+ * skill packs into a seat's ~/.claude/skills.
4
+ *
5
+ * Three properties carry the weight, and each is pinned here:
6
+ *
7
+ * 1. **It installs under the frontmatter name**, not the directory name —
8
+ * `taste-skill/skills/soft-skill` is `high-end-visual-design` to every
9
+ * piece of prose that refers to it, including the harmoniser skill.
10
+ * 2. **It is idempotent.** A second run over an unchanged tree writes
11
+ * nothing, because `global-setup` runs on every upgrade hop on 40+ seats.
12
+ * 3. **It never clobbers a directory that is not ours.** A human who
13
+ * installed upstream `impeccable` themselves — launcher, hooks, live mode
14
+ * — keeps it, and the run says so instead of silently taking it away.
15
+ *
16
+ * The end-to-end cases run against the REAL vendored trees in a throwaway HOME,
17
+ * because a plan that is right about a synthetic pack and wrong about
18
+ * `emilkowalski`'s nested skill roots would ship.
19
+ *
20
+ * @module lib/collective/vendor-skills.test
21
+ */
22
+
23
+ import { describe, it } from "node:test";
24
+ import assert from "node:assert/strict";
25
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, statSync } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import { join, dirname } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import {
30
+ frontmatterName, ownership, planVendorSkills, installVendorSkills, markerFor,
31
+ packSize, looksLikeSkillsDir, MARKER_FILE, MARKER_OWNER,
32
+ } from "./vendor-skills.mjs";
33
+ import { PACKS, findPack } from "../../scripts/vendor/skill-packs.mjs";
34
+
35
+ const SDK_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
36
+
37
+ /** A throwaway HOME. Returns {home, skillsDir, cleanup}. */
38
+ function throwawayHome() {
39
+ const home = mkdtempSync(join(tmpdir(), "maestro-vendor-home-"));
40
+ return {
41
+ home,
42
+ skillsDir: join(home, ".claude", "skills"),
43
+ cleanup: () => rmSync(home, { recursive: true, force: true }),
44
+ };
45
+ }
46
+
47
+ describe("vendor-skills: pure helpers", () => {
48
+ it("frontmatterName reads the name and refuses a body that has none", () => {
49
+ assert.equal(frontmatterName("---\nname: impeccable\nversion: 4.3.1\n---\n# x"), "impeccable");
50
+ assert.equal(frontmatterName('---\nname: "quoted-name"\n---\n'), "quoted-name");
51
+ assert.equal(frontmatterName("# no frontmatter\n"), null);
52
+ assert.equal(frontmatterName("---\ndescription: no name here\n---\n"), null);
53
+ });
54
+
55
+ it("frontmatterName refuses a name that would escape the skills directory", () => {
56
+ assert.equal(frontmatterName("---\nname: ../../etc\n---\n"), null);
57
+ assert.equal(frontmatterName("---\nname: a/b\n---\n"), null);
58
+ });
59
+
60
+ it("looksLikeSkillsDir only accepts a path ending in skills/", () => {
61
+ assert.equal(looksLikeSkillsDir("/home/x/.claude/skills"), true);
62
+ assert.equal(looksLikeSkillsDir("/home/x/.claude"), false);
63
+ });
64
+
65
+ it("markerFor is deterministic — no clock, so a re-run writes nothing", () => {
66
+ const pack = findPack("unlazy");
67
+ assert.equal(markerFor(pack, "unlazy"), markerFor(pack, "unlazy"));
68
+ const doc = JSON.parse(markerFor(pack, "unlazy"));
69
+ assert.equal(doc.owner, MARKER_OWNER);
70
+ assert.equal(doc.sha, pack.sha);
71
+ });
72
+ });
73
+
74
+ describe("vendor-skills: ownership", () => {
75
+ it("reports absent / ours / foreign", () => {
76
+ const { home, cleanup } = throwawayHome();
77
+ try {
78
+ const absent = join(home, "nothing");
79
+ assert.equal(ownership(absent), "absent");
80
+
81
+ const foreign = join(home, "impeccable");
82
+ mkdirSync(foreign, { recursive: true });
83
+ writeFileSync(join(foreign, "SKILL.md"), "the human's own install\n");
84
+ assert.equal(ownership(foreign), "foreign");
85
+
86
+ const ours = join(home, "unlazy");
87
+ mkdirSync(ours, { recursive: true });
88
+ writeFileSync(join(ours, MARKER_FILE), markerFor(findPack("unlazy"), "unlazy"));
89
+ assert.equal(ownership(ours), "ours");
90
+
91
+ writeFileSync(join(ours, MARKER_FILE), "{not json");
92
+ assert.equal(ownership(ours), "foreign", "an unreadable marker is not proof of ownership");
93
+
94
+ writeFileSync(join(ours, MARKER_FILE), JSON.stringify({ owner: "someone-else" }));
95
+ assert.equal(ownership(ours), "foreign");
96
+ } finally { cleanup(); }
97
+ });
98
+ });
99
+
100
+ describe("vendor-skills: the plan over the real vendored trees", () => {
101
+ const { skills, errors } = planVendorSkills({ sdkRoot: SDK_ROOT, skillsDir: "/tmp/unused", packs: PACKS });
102
+
103
+ it("plans one skill per declared skill root, with no errors", () => {
104
+ assert.deepEqual(errors, [], "every pack's tree is present and every root has a SKILL.md");
105
+ const roots = PACKS.reduce((n, p) => n + p.skillRoots.length, 0);
106
+ assert.equal(skills.length, roots);
107
+ });
108
+
109
+ it("installs under the frontmatter name, not the directory name", () => {
110
+ const names = skills.map((s) => s.name);
111
+ for (const expected of [
112
+ "impeccable", "unlazy",
113
+ "emil-design-eng", "animate", "animation-vocabulary", "apple-design",
114
+ "find-animation-opportunities", "improve-animations", "review-animations", "prototype",
115
+ "design-taste-frontend", "redesign-existing-projects", "high-end-visual-design", "minimalist-ui",
116
+ ]) assert.ok(names.includes(expected), `expected skill ${expected}, got ${names.join(", ")}`);
117
+ assert.equal(new Set(names).size, names.length, "no two packs claim the same directory");
118
+ });
119
+
120
+ it("gives every skill a SKILL.md at its root and never a sibling skill's files", () => {
121
+ for (const s of skills) {
122
+ assert.ok(s.files.some((f) => f.to === "SKILL.md"), `${s.name} has SKILL.md at its root`);
123
+ assert.ok(s.files.every((f) => !f.to.startsWith("skills/")), `${s.name} carries no nested skills/ path`);
124
+ }
125
+ const animate = skills.find((s) => s.name === "animate");
126
+ assert.ok(animate.files.some((f) => f.to === "RECIPES.md"), "sibling reference file comes along");
127
+ assert.ok(animate.files.some((f) => f.to === "LICENSE"), "a sub-directory skill still ships its licence");
128
+ assert.ok(!animate.files.some((f) => f.to.includes("apple-design")), "a sibling skill's files stay out");
129
+ });
130
+
131
+ it("impeccable brings its reference tree, its subordinate briefs and its NOTICE", () => {
132
+ const imp = skills.find((s) => s.name === "impeccable");
133
+ assert.ok(imp.files.some((f) => f.to === "NOTICE.md"));
134
+ assert.ok(imp.files.some((f) => f.to.startsWith("reference/")));
135
+ assert.ok(imp.files.some((f) => f.to.startsWith("agents/impeccable-")));
136
+ });
137
+
138
+ it("a pack whose vendored tree is absent is an error, not a crash", () => {
139
+ const r = planVendorSkills({ sdkRoot: join(tmpdir(), "no-such-sdk-root"), skillsDir: "/tmp/unused", packs: PACKS });
140
+ assert.equal(r.skills.length, 0);
141
+ assert.equal(r.errors.length, PACKS.length);
142
+ });
143
+ });
144
+
145
+ describe("vendor-skills: install into a throwaway HOME", () => {
146
+ it("installs every pack skill, is idempotent, and heals a tampered file", () => {
147
+ const { skillsDir, cleanup } = throwawayHome();
148
+ try {
149
+ const first = installVendorSkills({ sdkRoot: SDK_ROOT, skillsDir, packs: PACKS });
150
+ assert.deepEqual(first.errors, []);
151
+ assert.deepEqual(first.skipped, []);
152
+ assert.equal(first.unchanged.length, 0);
153
+ assert.ok(first.installed.length >= 14, `installed ${first.installed.length} skills`);
154
+
155
+ // The bytes landed, under the frontmatter name, with our marker.
156
+ const impeccable = join(skillsDir, "impeccable");
157
+ assert.ok(existsSync(join(impeccable, "SKILL.md")));
158
+ assert.ok(existsSync(join(impeccable, "reference", "critique.md")));
159
+ assert.ok(existsSync(join(impeccable, "LICENSE")));
160
+ assert.equal(ownership(impeccable), "ours");
161
+ assert.equal(statSync(join(impeccable, "SKILL.md")).mode & 0o777, 0o644);
162
+
163
+ const minimalist = join(skillsDir, "minimalist-ui");
164
+ assert.match(readFileSync(join(minimalist, "SKILL.md"), "utf8"), /name: minimalist-ui/);
165
+
166
+ // Second run: nothing written.
167
+ const second = installVendorSkills({ sdkRoot: SDK_ROOT, skillsDir, packs: PACKS });
168
+ assert.deepEqual(second.installed, [], "idempotent");
169
+ assert.equal(second.unchanged.length, first.installed.length);
170
+
171
+ // A locally edited file is restored; a stray file inside a directory we
172
+ // own is removed (it would otherwise be read as skill content forever).
173
+ writeFileSync(join(impeccable, "SKILL.md"), "tampered\n");
174
+ writeFileSync(join(impeccable, "stray.md"), "not upstream\n");
175
+ const third = installVendorSkills({ sdkRoot: SDK_ROOT, skillsDir, packs: PACKS });
176
+ assert.ok(third.installed.includes("impeccable"));
177
+ assert.match(readFileSync(join(impeccable, "SKILL.md"), "utf8"), /^---\nname: impeccable/);
178
+ assert.equal(existsSync(join(impeccable, "stray.md")), false);
179
+ } finally { cleanup(); }
180
+ });
181
+
182
+ it("leaves a FOREIGN directory exactly as it is and says which one", () => {
183
+ const { skillsDir, cleanup } = throwawayHome();
184
+ try {
185
+ // The human installed upstream impeccable themselves — launcher and all.
186
+ const foreign = join(skillsDir, "impeccable");
187
+ mkdirSync(join(foreign, "scripts"), { recursive: true });
188
+ const own = "---\nname: impeccable\n---\nthe human's own copy, with its launcher\n";
189
+ writeFileSync(join(foreign, "SKILL.md"), own);
190
+ writeFileSync(join(foreign, "scripts", "impeccable"), "#!/bin/sh\n");
191
+
192
+ const r = installVendorSkills({ sdkRoot: SDK_ROOT, skillsDir, packs: PACKS });
193
+ assert.deepEqual(r.installed.includes("impeccable"), false, "not installed over the human's copy");
194
+ assert.equal(r.skipped.length, 1);
195
+ assert.equal(r.skipped[0].name, "impeccable");
196
+ assert.match(r.skipped[0].reason, /not maestro's/);
197
+ assert.equal(readFileSync(join(foreign, "SKILL.md"), "utf8"), own, "byte-for-byte untouched");
198
+ assert.ok(existsSync(join(foreign, "scripts", "impeccable")), "their launcher is still theirs");
199
+ // Every other pack still installed.
200
+ assert.ok(r.installed.includes("unlazy"));
201
+ assert.ok(existsSync(join(skillsDir, "unlazy", "SKILL.md")));
202
+ } finally { cleanup(); }
203
+ });
204
+
205
+ it("dryRun reports what it would do and writes nothing", () => {
206
+ const { skillsDir, cleanup } = throwawayHome();
207
+ try {
208
+ const r = installVendorSkills({ sdkRoot: SDK_ROOT, skillsDir, packs: PACKS, dryRun: true });
209
+ assert.ok(r.installed.length > 0);
210
+ assert.equal(existsSync(skillsDir), false, "nothing written");
211
+ } finally { cleanup(); }
212
+ });
213
+
214
+ it("refuses a destination that is not a skills directory, and writes nothing", () => {
215
+ // The failure this pins: an unset variable interpolated to
216
+ // "undefined/home/.claude/skills" and a full 14-skill tree was created at
217
+ // ./undefined/ relative to the cwd, inside a repo checkout.
218
+ const home = mkdtempSync(join(tmpdir(), "maestro-vendor-bogus-"));
219
+ try {
220
+ const bogus = join(home, ".claude", "skulls");
221
+ const r = installVendorSkills({ sdkRoot: SDK_ROOT, skillsDir: bogus, packs: PACKS });
222
+ assert.equal(r.ok, false);
223
+ assert.deepEqual(r.installed, []);
224
+ assert.match(r.errors[0].error, /not a skills directory/);
225
+ assert.equal(existsSync(bogus), false, "nothing was created");
226
+ } finally { rmSync(home, { recursive: true, force: true }); }
227
+ });
228
+
229
+ it("refuses to install two packs into one skill name rather than letting one eat the other", () => {
230
+ // No collision exists today; the registry is meant to grow, and
231
+ // ownership() reads only the owner string, so the second pack would see
232
+ // "ours", delete the first pack's files as stale and report a normal
233
+ // install.
234
+ const fakeSdk = mkdtempSync(join(tmpdir(), "maestro-vendor-sdk-"));
235
+ const { skillsDir, cleanup } = throwawayHome();
236
+ try {
237
+ const packs = ["alpha", "beta"].map((id) => {
238
+ const dir = join(fakeSdk, "plugins", "maestro-skills", "vendor", id);
239
+ mkdirSync(dir, { recursive: true });
240
+ writeFileSync(join(dir, "SKILL.md"), `---\nname: shared-skill\n---\nfrom ${id}\n`);
241
+ return { id, repo: `example/${id}`, sha: "0".repeat(40), license: "MIT", skillRoots: ["."] };
242
+ });
243
+ const r = planVendorSkills({ sdkRoot: fakeSdk, skillsDir, packs });
244
+ assert.equal(r.skills.length, 1, "only the first claim stands");
245
+ assert.equal(r.skills[0].pack.id, "alpha");
246
+ assert.ok(r.errors.some((e) => e.pack === "beta" && /already claimed by pack alpha/.test(e.error)), JSON.stringify(r.errors));
247
+
248
+ const inst = installVendorSkills({ sdkRoot: fakeSdk, skillsDir, packs });
249
+ assert.equal(inst.ok, false, "a collision is an error, not a silent overwrite");
250
+ assert.match(readFileSync(join(skillsDir, "shared-skill", "SKILL.md"), "utf8"), /from alpha/);
251
+ } finally { cleanup(); rmSync(fakeSdk, { recursive: true, force: true }); }
252
+ });
253
+
254
+ it("copies a directory aside before overwriting work somebody did in it", () => {
255
+ const { skillsDir, cleanup } = throwawayHome();
256
+ try {
257
+ installVendorSkills({ sdkRoot: SDK_ROOT, skillsDir, packs: PACKS, stamp: "STAMP" });
258
+ const dir = join(skillsDir, "impeccable");
259
+ const tuned = "---\nname: impeccable\n---\ntuned for Cohort\n";
260
+ writeFileSync(join(dir, "SKILL.md"), tuned);
261
+ writeFileSync(join(dir, "cohort-notes.md"), "a person's own notes\n");
262
+
263
+ const r = installVendorSkills({ sdkRoot: SDK_ROOT, skillsDir, packs: PACKS, stamp: "STAMP" });
264
+ const b = r.backups.find((x) => x.name === "impeccable");
265
+ assert.ok(b, `expected a backup, got ${JSON.stringify(r.backups)}`);
266
+ assert.deepEqual(b.files.sort(), ["SKILL.md", "cohort-notes.md"]);
267
+ assert.equal(readFileSync(join(b.path, "SKILL.md"), "utf8"), tuned, "their edit survives");
268
+ assert.equal(readFileSync(join(b.path, "cohort-notes.md"), "utf8"), "a person's own notes\n");
269
+ // …and the live directory is back to the vendored bytes.
270
+ assert.match(readFileSync(join(dir, "SKILL.md"), "utf8"), /^---\nname: impeccable/);
271
+ assert.equal(existsSync(join(dir, "cohort-notes.md")), false);
272
+ } finally { cleanup(); }
273
+ });
274
+
275
+ it("backs nothing up when it only rewrites files it wrote itself", () => {
276
+ const { skillsDir, cleanup } = throwawayHome();
277
+ try {
278
+ const first = installVendorSkills({ sdkRoot: SDK_ROOT, skillsDir, packs: PACKS, stamp: "STAMP" });
279
+ assert.deepEqual(first.backups, []);
280
+ const second = installVendorSkills({ sdkRoot: SDK_ROOT, skillsDir, packs: PACKS, stamp: "STAMP" });
281
+ assert.deepEqual(second.backups, [], "an idempotent re-run leaves no backup directories behind");
282
+ assert.deepEqual(second.installed, []);
283
+ } finally { cleanup(); }
284
+ });
285
+
286
+ it("puts no hook machinery on the seat", () => {
287
+ const { skillsDir, cleanup } = throwawayHome();
288
+ try {
289
+ installVendorSkills({ sdkRoot: SDK_ROOT, skillsDir, packs: PACKS });
290
+ const unlazy = join(skillsDir, "unlazy");
291
+ assert.ok(existsSync(join(unlazy, "scripts", "gate-check.mjs")), "the gate checker is the reason scripts/ is vendored");
292
+ for (const name of ["install-hooks.mjs", "stop-hook.mjs"]) {
293
+ assert.equal(existsSync(join(unlazy, "scripts", name)), false,
294
+ `${name} would be one \`node <path>\` away from a Stop hook in the settings file every session reads`);
295
+ }
296
+ } finally { cleanup(); }
297
+ });
298
+
299
+ it("packSize reports a file count and a byte size for every pack", () => {
300
+ for (const pack of PACKS) {
301
+ const s = packSize(SDK_ROOT, pack);
302
+ assert.ok(s.files > 0, `${pack.id} has files`);
303
+ assert.ok(s.bytes > 0, `${pack.id} has bytes`);
304
+ }
305
+ });
306
+ });