@cohortapp/agent-sdk 2.12.0 → 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 (127) hide show
  1. package/bin/maestro.mjs +6 -2
  2. package/docs/guides/front-door-session.md +49 -0
  3. package/lib/cli/design.mjs +185 -0
  4. package/lib/cli/design.test.mjs +270 -0
  5. package/lib/cli/global-setup-extras.mjs +44 -0
  6. package/lib/cli/global-setup-extras.test.mjs +95 -0
  7. package/lib/cli/session.mjs +11 -1
  8. package/lib/cli/session.test.mjs +17 -6
  9. package/lib/collective/global-config.mjs +5 -0
  10. package/lib/collective/global-config.test.mjs +5 -0
  11. package/lib/collective/vendor-skills.mjs +305 -0
  12. package/lib/collective/vendor-skills.test.mjs +306 -0
  13. package/lib/design/design-md.mjs +793 -0
  14. package/lib/design/design-md.test.mjs +318 -0
  15. package/lib/design/fixtures/DESIGN.golden.md +238 -0
  16. package/lib/design/fixtures/PRODUCT.golden.md +67 -0
  17. package/lib/design/fixtures/foundation.json +133 -0
  18. package/lib/design/refresh-gate.mjs +154 -0
  19. package/lib/design/refresh-gate.test.mjs +144 -0
  20. package/lib/design/write.mjs +275 -0
  21. package/lib/design/write.test.mjs +241 -0
  22. package/lib/prompts/parallelism.mjs +79 -0
  23. package/lib/prompts/parallelism.test.mjs +177 -0
  24. package/package.json +1 -1
  25. package/plugins/maestro-skills/plugin.json +4 -0
  26. package/plugins/maestro-skills/skills/cohort-design.md +153 -0
  27. package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
  28. package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
  29. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
  30. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
  31. package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
  32. package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
  33. package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
  34. package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
  35. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
  36. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
  37. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
  38. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
  39. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
  40. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
  41. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
  42. package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
  43. package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
  44. package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
  45. package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
  46. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
  47. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
  48. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
  49. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  50. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
  51. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
  52. package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
  53. package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
  54. package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
  55. package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
  56. package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
  57. package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
  58. package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
  59. package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
  60. package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
  61. package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
  62. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
  63. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
  64. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
  65. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  66. package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
  67. package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
  68. package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
  69. package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
  70. package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
  71. package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
  72. package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
  73. package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
  74. package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
  75. package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
  76. package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
  77. package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
  78. package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
  79. package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
  80. package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
  81. package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
  82. package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
  83. package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
  84. package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
  85. package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
  86. package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
  87. package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
  88. package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
  89. package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
  90. package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
  91. package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
  92. package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
  93. package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
  94. package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
  95. package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
  96. package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
  97. package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
  98. package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
  99. package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
  100. package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
  101. package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
  102. package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
  103. package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
  104. package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
  105. package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
  106. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
  107. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
  108. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
  109. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
  110. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
  111. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
  112. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
  113. package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
  114. package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
  115. package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
  116. package/scripts/ci/check-skill-packs.mjs +388 -0
  117. package/scripts/ci/check-skill-packs.test.mjs +495 -0
  118. package/scripts/ci/check.mjs +3 -0
  119. package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
  120. package/scripts/daemon/agent-daemon.mjs +108 -0
  121. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +61 -2
  122. package/scripts/daemon/cadence-consumer.mjs +46 -22
  123. package/scripts/daemon/prompt-builder.mjs +19 -3
  124. package/scripts/local-triggers/autoupdate.test.mjs +33 -3
  125. package/scripts/vendor/skill-packs.mjs +354 -0
  126. package/scripts/vendor/sync-skill-packs.mjs +242 -0
  127. package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
@@ -12,6 +12,7 @@ import { join } from "node:path";
12
12
 
13
13
  import { applyFrontDoorSetup, mergeJsonFileAtomic, findOnPath, pickClaudeBin, checkPersonaHookScript } from "./global-setup-extras.mjs";
14
14
  import { IDENTITY_BEGIN, CLAUDE_MD_BEGIN, PRE_SEND_HOOK_MATCHER } from "../collective/global-config.mjs";
15
+ import { SDK_ROOT } from "./global-setup-extras.mjs";
15
16
 
16
17
  function fixture() {
17
18
  const root = mkdtempSync(join(tmpdir(), "gs-extras-"));
@@ -365,3 +366,97 @@ test("applyFrontDoorSetup: skills are rendered to the agent's identity (config/a
365
366
  assert.ok(f.logs.some((l) => /rendered/.test(l)), "the summary line says which skills were rendered");
366
367
  } finally { rmSync(f.root, { recursive: true, force: true }); }
367
368
  });
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Step 5 (WP-M7): the vendored design skill packs. These run against the REAL
372
+ // vendored trees in this checkout, into a throwaway HOME — the seam that
373
+ // matters is "global-setup put impeccable/unlazy/... on the machine", and a
374
+ // synthetic pack would not prove it.
375
+ // ---------------------------------------------------------------------------
376
+
377
+ test("applyFrontDoorSetup: installs the vendored design packs into a throwaway HOME under their own names, idempotently", async () => {
378
+ const f = fixture();
379
+ try {
380
+ const opts = {
381
+ agentRoot: f.agentRoot, sdkRoot: SDK_ROOT, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
382
+ claudeBin: null, globalRoot: "", now: Date.parse("2026-09-08T12:00:00Z"), ...f.io,
383
+ };
384
+ const r = await applyFrontDoorSetup(opts);
385
+ assert.deepEqual(r.vendorSkills.errors, []);
386
+ assert.deepEqual(r.vendorSkills.skipped, []);
387
+ for (const name of ["impeccable", "unlazy", "animate", "design-taste-frontend"]) {
388
+ assert.ok(r.vendorSkills.installed.includes(name), `${name} installed`);
389
+ assert.ok(existsSync(join(f.claudeDir, "skills", name, "SKILL.md")), `${name}/SKILL.md on disk`);
390
+ }
391
+ // Under its own name, NOT the maestro- prefix the shipped skills use.
392
+ assert.equal(existsSync(join(f.claudeDir, "skills", "maestro-impeccable")), false);
393
+ // Licences travel with the content.
394
+ assert.ok(existsSync(join(f.claudeDir, "skills", "impeccable", "NOTICE.md")));
395
+ assert.ok(existsSync(join(f.claudeDir, "skills", "animate", "LICENSE")));
396
+ // No hook manifest is ever written next to a vendored skill.
397
+ assert.equal(existsSync(join(f.claudeDir, "skills", "unlazy", "hooks.json")), false);
398
+ assert.equal(existsSync(join(f.claudeDir, "skills", "unlazy", "settings.json")), false);
399
+
400
+ const again = await applyFrontDoorSetup({ ...opts, now: Date.parse("2026-09-08T12:05:00Z") });
401
+ assert.deepEqual(again.vendorSkills.installed, [], "second run writes nothing");
402
+ assert.ok(again.vendorSkills.unchanged.length >= 14);
403
+ } finally { rmSync(f.root, { recursive: true, force: true }); }
404
+ });
405
+
406
+ test("applyFrontDoorSetup: a design-skill directory that is not ours is left alone and reported", async () => {
407
+ const f = fixture();
408
+ try {
409
+ const foreign = join(f.claudeDir, "skills", "impeccable");
410
+ mkdirSync(foreign, { recursive: true });
411
+ const body = "---\nname: impeccable\n---\nthe human's own install\n";
412
+ writeFileSync(join(foreign, "SKILL.md"), body);
413
+ const r = await applyFrontDoorSetup({
414
+ agentRoot: f.agentRoot, sdkRoot: SDK_ROOT, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
415
+ claudeBin: null, globalRoot: "", now: Date.parse("2026-09-08T12:00:00Z"), ...f.io,
416
+ });
417
+ assert.equal(readFileSync(join(foreign, "SKILL.md"), "utf8"), body, "untouched");
418
+ assert.deepEqual(r.vendorSkills.skipped.map((s) => s.name), ["impeccable"]);
419
+ assert.ok(f.logs.some((l) => l.startsWith("warn:") && /impeccable/.test(l) && /left alone/.test(l)));
420
+ assert.ok(r.vendorSkills.installed.includes("unlazy"), "the other packs still install");
421
+ } finally { rmSync(f.root, { recursive: true, force: true }); }
422
+ });
423
+
424
+ test("applyFrontDoorSetup: MAESTRO_SKIP_VENDOR_SKILLS keeps the packs off the machine and every other step on", async () => {
425
+ // ~/.claude/skills is global: these packs load for work in the product repo,
426
+ // in customer repos and in scratch directories that have nothing to do with
427
+ // Cohort design. A seat that does not want them needs a way to say so that
428
+ // does not cost it its identity block or its tools.
429
+ const f = fixture();
430
+ const prev = process.env.MAESTRO_SKIP_VENDOR_SKILLS;
431
+ process.env.MAESTRO_SKIP_VENDOR_SKILLS = "1";
432
+ try {
433
+ const r = await applyFrontDoorSetup({
434
+ agentRoot: f.agentRoot, sdkRoot: SDK_ROOT, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
435
+ claudeBin: null, globalRoot: "", now: Date.parse("2026-09-08T12:00:00Z"), ...f.io,
436
+ });
437
+ assert.equal(r.vendorSkills.skippedByEnv, true);
438
+ assert.equal(existsSync(join(f.claudeDir, "skills", "impeccable")), false);
439
+ assert.equal(existsSync(join(f.claudeDir, "skills", "design-taste-frontend")), false);
440
+ assert.equal(r.ok, true);
441
+ assert.equal(r.identity.changed, true, "identity still wired");
442
+ assert.ok(existsSync(join(f.claudeDir, "skills", "maestro-cohort-design")), "maestro's own skills still install");
443
+ } finally {
444
+ if (prev === undefined) delete process.env.MAESTRO_SKIP_VENDOR_SKILLS;
445
+ else process.env.MAESTRO_SKIP_VENDOR_SKILLS = prev;
446
+ rmSync(f.root, { recursive: true, force: true });
447
+ }
448
+ });
449
+
450
+ test("applyFrontDoorSetup: an SDK copy with no vendored trees warns and still wires everything else", async () => {
451
+ const f = fixture();
452
+ try {
453
+ const r = await applyFrontDoorSetup({
454
+ agentRoot: f.agentRoot, sdkRoot: f.sdkRoot, claudeDir: f.claudeDir, claudeJsonPath: f.claudeJsonPath,
455
+ claudeBin: null, globalRoot: "", now: Date.parse("2026-09-08T12:00:00Z"), ...f.io,
456
+ });
457
+ assert.equal(r.ok, true, "a missing vendor tree is fail-open, not a failed setup");
458
+ assert.ok(r.vendorSkills.errors.length > 0);
459
+ assert.ok(f.logs.some((l) => l.startsWith("warn:") && /design pack/.test(l)));
460
+ assert.equal(r.identity.changed, true, "identity still wired");
461
+ } finally { rmSync(f.root, { recursive: true, force: true }); }
462
+ });
@@ -65,6 +65,7 @@ import { writeJsonAtomic } from "../fs-atomic.mjs";
65
65
  import { resolveAgentRoot } from "../agent-root.mjs";
66
66
  import { resolveClaudeBin } from "../claude-bin.mjs";
67
67
  import { sessionPermissionArgs } from "../session-permissions.mjs";
68
+ import { withParallelism } from "../prompts/parallelism.mjs";
68
69
 
69
70
  /** The SDK's own package.json version — what "installed" means for an upgrade notice. */
70
71
  function ownVersion() {
@@ -215,11 +216,20 @@ export function peerPermissionArgs({ env = process.env, allowedTools } = {}) {
215
216
  * CLI args for a spawned peer: `--name <first>-<slug>`, the interactive
216
217
  * permission posture ({@link peerPermissionArgs}), then the prompt. The binary
217
218
  * itself is prepended by the caller.
219
+ *
220
+ * THE PROMPT IS LED BY THE PARALLELISM DIRECTIVE (WP-M7 mechanic 5). A peer is
221
+ * spawned precisely when there is more work than one session can hold, so it is
222
+ * the one lane where "dispatch the independent tasks together" pays every time
223
+ * — and the directive carries the file-scope rule that makes that safe on a
224
+ * shared checkout. `withParallelism` is idempotent, so a caller that already
225
+ * led its prompt with it (a daemon prompt handed on to `session spawn`) does
226
+ * not get it twice; the peers registry still records the OPERATOR's prompt as
227
+ * the peer's purpose, not the directive.
218
228
  * @param {{first:string, slug:string, prompt:string, env?:object, allowedTools?:string[]}} o
219
229
  * @returns {string[]}
220
230
  */
221
231
  export function buildSpawnArgs({ first, slug, prompt, env, allowedTools }) {
222
- return ["--name", `${first}-${slug}`, ...peerPermissionArgs({ env, allowedTools }), prompt];
232
+ return ["--name", `${first}-${slug}`, ...peerPermissionArgs({ env, allowedTools }), withParallelism(prompt)];
223
233
  }
224
234
 
225
235
  /**
@@ -21,6 +21,7 @@ import {
21
21
  run, usage, resolveFirstName, sessionLabel, muxSessionName, sessionPaths,
22
22
  heartbeatAgeMs, isLive, attachCommand, buildSpawnArgs, prunePeers, STALE_MS, updatePeers,
23
23
  } from "./session.mjs";
24
+ import { withParallelism, countParallelism, PARALLELISM_DIRECTIVE } from "../prompts/parallelism.mjs";
24
25
 
25
26
  const NOW = Date.parse("2026-09-08T12:00:00Z");
26
27
 
@@ -108,12 +109,21 @@ test("attachCommand prints the exact tmux/screen attach line", () => {
108
109
  assert.deepEqual(attachCommand("ivy", "screen"), ["screen", "-r", "maestro-ivy"]);
109
110
  });
110
111
 
111
- test("buildSpawnArgs: claude --name <first>-<slug> + permission args + prompt", () => {
112
+ test("buildSpawnArgs: claude --name <first>-<slug> + permission args + the prompt, led by the parallelism directive", () => {
112
113
  const prev = process.env.MAESTRO_SCOPED_PERMISSIONS;
113
114
  delete process.env.MAESTRO_SCOPED_PERMISSIONS;
114
115
  try {
116
+ // WP-M7 mechanic 5: a peer is spawned precisely when there is more work than
117
+ // one session can hold, so every peer prompt carries the standing permission
118
+ // to fan out — and the file-scope rule that makes fanning out safe.
115
119
  const a = buildSpawnArgs({ first: "ivy", slug: "research", prompt: "Look into X" });
116
- assert.deepEqual(a, ["--name", "ivy-research", "--dangerously-skip-permissions", "Look into X"]);
120
+ assert.deepEqual(a, ["--name", "ivy-research", "--dangerously-skip-permissions", withParallelism("Look into X")]);
121
+ assert.equal(countParallelism(a[3]), 1);
122
+ assert.ok(a[3].startsWith(PARALLELISM_DIRECTIVE) && a[3].endsWith("Look into X"));
123
+
124
+ // Idempotent: a caller that already led its prompt with it is not doubled.
125
+ const b = buildSpawnArgs({ first: "ivy", slug: "research", prompt: withParallelism("Look into X") });
126
+ assert.deepEqual(b, a);
117
127
  } finally {
118
128
  if (prev === undefined) delete process.env.MAESTRO_SCOPED_PERMISSIONS; else process.env.MAESTRO_SCOPED_PERMISSIONS = prev;
119
129
  }
@@ -321,7 +331,7 @@ test("spawn launches a detached tmux session running claude --name <first>-<slug
321
331
  const spawnCall = d.exec.calls.find((c) => c[0] === "tmux" && c[1] === "new-session");
322
332
  assert.ok(spawnCall, `expected a tmux new-session; calls: ${JSON.stringify(d.exec.calls)}`);
323
333
  assert.deepEqual(spawnCall, ["tmux", "new-session", "-d", "-s", "maestro-ivy-research", "-c", root, "--",
324
- "/stub/claude", "--name", "ivy-research", "--dangerously-skip-permissions", "Look into X"]);
334
+ "/stub/claude", "--name", "ivy-research", "--dangerously-skip-permissions", withParallelism("Look into X")]);
325
335
  const peers = JSON.parse(readFileSync(join(root, "state", "session", "peers.json"), "utf8"));
326
336
  assert.equal(peers.peers.length, 1);
327
337
  assert.equal(peers.peers[0].name, "ivy-research");
@@ -465,13 +475,14 @@ test("resolveFirstName falls back to config/agent.ts firstName (legacy seat) bef
465
475
 
466
476
  test("buildSpawnArgs: a peer is interactive — scoped mode uses the configured allowlist or bypass, never the read-only --print default", () => {
467
477
  const scoped = { MAESTRO_SCOPED_PERMISSIONS: "1" };
478
+ const p = withParallelism("p"); // the prompt as the peer actually receives it (WP-M7)
468
479
  assert.deepEqual(buildSpawnArgs({ first: "ivy", slug: "fix", prompt: "p", env: scoped }),
469
- ["--name", "ivy-fix", "--dangerously-skip-permissions", "p"]);
480
+ ["--name", "ivy-fix", "--dangerously-skip-permissions", p]);
470
481
  assert.deepEqual(buildSpawnArgs({ first: "ivy", slug: "fix", prompt: "p", env: scoped, allowedTools: ["Read", " Edit ", "", "Bash"] }),
471
- ["--name", "ivy-fix", "--allowedTools", "Read,Edit,Bash", "p"]);
482
+ ["--name", "ivy-fix", "--allowedTools", "Read,Edit,Bash", p]);
472
483
  // Unscoped: the allowlist is ignored (byte-for-byte the pre-H1 posture).
473
484
  assert.deepEqual(buildSpawnArgs({ first: "ivy", slug: "fix", prompt: "p", env: {}, allowedTools: ["Read"] }),
474
- ["--name", "ivy-fix", "--dangerously-skip-permissions", "p"]);
485
+ ["--name", "ivy-fix", "--dangerously-skip-permissions", p]);
475
486
  });
476
487
 
477
488
  test("spawn honours config/session.yaml `mux:` (M1 loadSessionConfig) over tmux-on-PATH and MAESTRO_SESSION_MUX", async () => {
@@ -187,6 +187,11 @@ export function buildIdentityClaudeMd(agentJson, o = {}) {
187
187
  " `maestro board track`, and close it with `maestro board complete`.",
188
188
  `- Skills: \`maestro-main-session\`, \`maestro-inbound-triage\`, \`maestro-board-work\`,`,
189
189
  " `maestro-peer-sessions`, `maestro-persona-discipline` carry the operating rules.",
190
+ "- Design, redesign, polish, critique, audit or motion work on any interface: start from",
191
+ " `maestro-cohort-design`. It sets the precedence — this agent's `DESIGN.md` and `PRODUCT.md`",
192
+ " plus the `cohort` MCP `design_*` tools are the source of truth for tokens, type and voice;",
193
+ " the vendored craft packs (`impeccable`, the motion skills, `design-taste-frontend` for",
194
+ " marketing pages, `unlazy` for completion gates) sit under them.",
190
195
  ].join("\n");
191
196
  }
192
197
 
@@ -105,6 +105,11 @@ test("buildIdentityClaudeMd: names the agent, the seat, the main session and the
105
105
  assert.match(md, /board/i);
106
106
  // Persona rules are present and phrased as rules the agent follows outbound.
107
107
  assert.match(md, /never .*(assistant|session|sub-?agent)/i);
108
+ // Design work is routed to the harmoniser, which is what keeps the vendored
109
+ // craft skills from deciding the typeface (WP-M7).
110
+ assert.match(md, /maestro-cohort-design/);
111
+ assert.match(md, /DESIGN\.md/);
112
+ assert.match(md, /design_\*/);
108
113
  // The identity block is a different block from the collective one.
109
114
  assert.notEqual(IDENTITY_BEGIN, CLAUDE_MD_BEGIN);
110
115
  });
@@ -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
+ };