@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.
- package/bin/maestro.mjs +6 -2
- package/docs/guides/front-door-session.md +49 -0
- package/lib/cli/design.mjs +185 -0
- package/lib/cli/design.test.mjs +270 -0
- package/lib/cli/global-setup-extras.mjs +44 -0
- package/lib/cli/global-setup-extras.test.mjs +95 -0
- package/lib/cli/session.mjs +11 -1
- package/lib/cli/session.test.mjs +17 -6
- package/lib/collective/global-config.mjs +5 -0
- package/lib/collective/global-config.test.mjs +5 -0
- package/lib/collective/vendor-skills.mjs +305 -0
- package/lib/collective/vendor-skills.test.mjs +306 -0
- package/lib/design/design-md.mjs +793 -0
- package/lib/design/design-md.test.mjs +318 -0
- package/lib/design/fixtures/DESIGN.golden.md +238 -0
- package/lib/design/fixtures/PRODUCT.golden.md +67 -0
- package/lib/design/fixtures/foundation.json +133 -0
- package/lib/design/refresh-gate.mjs +154 -0
- package/lib/design/refresh-gate.test.mjs +144 -0
- package/lib/design/write.mjs +275 -0
- package/lib/design/write.test.mjs +241 -0
- package/lib/prompts/parallelism.mjs +79 -0
- package/lib/prompts/parallelism.test.mjs +177 -0
- package/package.json +1 -1
- package/plugins/maestro-skills/plugin.json +4 -0
- package/plugins/maestro-skills/skills/cohort-design.md +153 -0
- package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
- package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
- package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
- package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
- package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
- package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
- package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
- package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
- package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
- package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
- package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
- package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
- package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
- package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
- package/scripts/ci/check-skill-packs.mjs +388 -0
- package/scripts/ci/check-skill-packs.test.mjs +495 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
- package/scripts/daemon/agent-daemon.mjs +108 -0
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +61 -2
- package/scripts/daemon/cadence-consumer.mjs +46 -22
- package/scripts/daemon/prompt-builder.mjs +19 -3
- package/scripts/local-triggers/autoupdate.test.mjs +33 -3
- package/scripts/vendor/skill-packs.mjs +354 -0
- package/scripts/vendor/sync-skill-packs.mjs +242 -0
- 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
|
+
});
|
package/lib/cli/session.mjs
CHANGED
|
@@ -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
|
/**
|
package/lib/cli/session.test.mjs
CHANGED
|
@@ -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",
|
|
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",
|
|
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",
|
|
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
|
+
};
|