@prismatic-io/lux 0.0.2-preview.33 → 0.0.2-preview.36

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 (48) hide show
  1. package/lib/assertions/rubric/index.d.ts.map +1 -1
  2. package/lib/assertions/rubric/index.js +10 -1
  3. package/lib/assertions/rubric/index.js.map +1 -1
  4. package/lib/assertions/rubric/internal.d.ts.map +1 -1
  5. package/lib/assertions/rubric/internal.js +22 -13
  6. package/lib/assertions/rubric/internal.js.map +1 -1
  7. package/lib/core/artifact-evidence.d.ts +6 -1
  8. package/lib/core/artifact-evidence.d.ts.map +1 -1
  9. package/lib/core/artifact-evidence.js +27 -6
  10. package/lib/core/artifact-evidence.js.map +1 -1
  11. package/lib/core/model-cli.d.ts.map +1 -1
  12. package/lib/core/model-cli.js +8 -3
  13. package/lib/core/model-cli.js.map +1 -1
  14. package/lib/drivers/claude-code/index.d.ts +7 -0
  15. package/lib/drivers/claude-code/index.d.ts.map +1 -1
  16. package/lib/drivers/claude-code/index.js +12 -1
  17. package/lib/drivers/claude-code/index.js.map +1 -1
  18. package/lib/drivers/codex/config.d.ts +3 -0
  19. package/lib/drivers/codex/config.d.ts.map +1 -1
  20. package/lib/drivers/codex/config.js +9 -0
  21. package/lib/drivers/codex/config.js.map +1 -1
  22. package/lib/drivers/codex/index.d.ts +4 -0
  23. package/lib/drivers/codex/index.d.ts.map +1 -1
  24. package/lib/drivers/codex/index.js +2 -0
  25. package/lib/drivers/codex/index.js.map +1 -1
  26. package/lib/drivers/shared/experiment-behavior.d.ts +1 -1
  27. package/lib/drivers/shared/experiment-behavior.d.ts.map +1 -1
  28. package/lib/drivers/shared/experiment-behavior.js +3 -0
  29. package/lib/drivers/shared/experiment-behavior.js.map +1 -1
  30. package/lib/drivers/shared/experiment-policy.d.ts.map +1 -1
  31. package/lib/drivers/shared/experiment-policy.js +1 -0
  32. package/lib/drivers/shared/experiment-policy.js.map +1 -1
  33. package/lib/drivers/shared/skills.d.ts +3 -0
  34. package/lib/drivers/shared/skills.d.ts.map +1 -0
  35. package/lib/drivers/shared/skills.js +73 -0
  36. package/lib/drivers/shared/skills.js.map +1 -0
  37. package/package.json +2 -1
  38. package/skills/lux-answerer/SKILL.md +1 -1
  39. package/src/assertions/rubric/index.ts +10 -1
  40. package/src/assertions/rubric/internal.ts +27 -13
  41. package/src/core/artifact-evidence.ts +40 -5
  42. package/src/core/model-cli.ts +8 -3
  43. package/src/drivers/claude-code/index.ts +12 -1
  44. package/src/drivers/codex/config.ts +9 -0
  45. package/src/drivers/codex/index.ts +2 -0
  46. package/src/drivers/shared/experiment-behavior.ts +5 -2
  47. package/src/drivers/shared/experiment-policy.ts +1 -0
  48. package/src/drivers/shared/skills.ts +74 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skills.js","sourceRoot":"","sources":["../../../src/drivers/shared/skills.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prismatic-io/lux",
3
- "version": "0.0.2-preview.33",
3
+ "version": "0.0.2-preview.36",
4
4
  "description": "Coding-agent evaluation and improvement with deterministic assertions, optional LLM judges, and first-class human-in-the-loop runs.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/prismatic-io/lux",
@@ -79,6 +79,7 @@
79
79
  "tinyexec": "1.2.4",
80
80
  "tinyglobby": "^0.2.17",
81
81
  "xstate": "5.32.0",
82
+ "yaml": "^2.9.0",
82
83
  "zod": "^4.4.3"
83
84
  }
84
85
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: lux-answerer
3
- version: 0.0.2-preview.33
3
+ version: 0.0.2-preview.36
4
4
  description: Play the persona for a running Lux orchestrator that uses the claude-code answerer. Read structured events on stdout, decide answers from persona + context, write JSON to its answer channel.
5
5
  user-invocable: false
6
6
  allowed-tools: Bash, Read
@@ -130,7 +130,16 @@ Rules:
130
130
  ${
131
131
  scoring === "binary"
132
132
  ? "- score must be exactly 1 (pass) or 0 (fail), never a fractional number. Pass only when the evidence establishes every required behavior in this criterion. A demonstrated violation of any required behavior fails; other successes cannot compensate. Cite the decisive evidence. Abstain if missing evidence prevents deciding, rather than awarding partial credit."
133
- : "- score is a continuous number in [0, 1]."
133
+ : `- score is a continuous number in [0, 1], proportional to the demonstrated
134
+ requested outcomes within this criterion, weighted by their importance to the
135
+ task. Grade this criterion, not the whole task or unrelated criteria.
136
+ - Credit independently working parts even when another part is missing or
137
+ incorrect. Use intermediate values throughout the range; do not round to a
138
+ verdict, impose score bands, or adjust a score to cross an acceptance threshold.
139
+ - Zero means no demonstrated credit; one means the requested outcome is fully
140
+ demonstrated. Optional polish is outside the denominator. Do not invent gaps
141
+ to avoid awarding full credit, require perfection beyond the request, or force
142
+ scores into a distribution.`
134
143
  }
135
144
  - Missing or truncated evidence is not proof of failure or success. Abstain when
136
145
  it prevents a supported judgment; do not guess a score.
@@ -3,7 +3,7 @@ import {
3
3
  matchesAnyGlob,
4
4
  type Run,
5
5
  type RunEvent,
6
- readRunArtifactText,
6
+ readRunArtifactExcerpt,
7
7
  tryParseJson,
8
8
  } from "../../core/index.js";
9
9
  import type { RubricConfig, RubricEvidence } from "./index.js";
@@ -371,21 +371,35 @@ export const sampleArtifacts = async (
371
371
  const listed = artifactListing(selected.map((artifact) => artifact.path));
372
372
  const out: string[] = [
373
373
  `Files selected (${selected.length} of ${run.artifacts.length}):\n${listed.join("\n")}`,
374
+ "Selection counts refer to captured paths matching this criterion, not capture failures. Listed files are not necessarily shown in full below.",
374
375
  ];
375
- if (evidence?.includeArtifactContents === false) return out.join("\n\n");
376
+ if (evidence?.includeArtifactContents === false) {
377
+ out.push("[File contents excluded by the evidence selector.]");
378
+ return out.join("\n\n");
379
+ }
376
380
  let budget = Math.min(evidence?.maxArtifactChars ?? 60_000, MAX_ARTIFACT_CHARS);
377
- for (const artifact of selected) {
378
- if (budget <= 0) break;
381
+ // Empty and unreadable files still cost framing; do not let them evade the cap.
382
+ let framingBudget = 8_000;
383
+ for (const [index, artifact] of selected.entries()) {
384
+ const path = boundedText(artifact.path, 500, "artifact path");
385
+ if (budget <= 0 || framingBudget < path.length + 100) {
386
+ out.push(
387
+ `[Content omitted for ${selected.length - index} remaining selected file(s): artifact ${budget <= 0 ? "character" : "framing"} budget exhausted.]`,
388
+ );
389
+ break;
390
+ }
379
391
  const limit = Math.min(perFileLimit, budget);
380
- const content = await readRunArtifactText(run, artifact.path, {
381
- // Four UTF-8 bytes per requested character preserves the character cap.
382
- maxBytes: Math.max(1, limit * 4),
383
- })
384
- .then((text) => text.slice(0, limit))
385
- .catch(() => null);
386
- if (content === null) continue;
387
- budget -= content.length;
388
- out.push(`--- ${artifact.path} ---\n${content}`);
392
+ const content = await readRunArtifactExcerpt(run, artifact.path, limit).catch(() => null);
393
+ if (content === null) {
394
+ const marker = `[Content unavailable: ${path}. The captured file could not be read safely.]`;
395
+ framingBudget -= marker.length + 2;
396
+ out.push(marker);
397
+ continue;
398
+ }
399
+ budget -= content.text.length;
400
+ const block = `--- ${path} ---\n${content.text}${content.truncated ? "\n[File excerpt truncated; omitted content is unassessed.]" : ""}`;
401
+ framingBudget -= block.length - content.text.length + 2;
402
+ out.push(block);
389
403
  }
390
404
  return out.join("\n\n");
391
405
  };
@@ -188,6 +188,7 @@ export const readInspectedFileBytes = async (
188
188
  expected: Stats,
189
189
  maxBytes: number,
190
190
  label: string,
191
+ prefixOnly = false,
191
192
  ): Promise<Buffer> => {
192
193
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || maxBytes > MAX_ARTIFACT_READ_BYTES) {
193
194
  throw new RangeError(
@@ -195,7 +196,7 @@ export const readInspectedFileBytes = async (
195
196
  );
196
197
  }
197
198
  if (!expected.isFile()) throw new Error(`${label} is not a file`);
198
- if (expected.size > maxBytes) {
199
+ if (!prefixOnly && expected.size > maxBytes) {
199
200
  throw new Error(`${label} exceeds the ${maxBytes}-byte artifact read limit`);
200
201
  }
201
202
 
@@ -210,23 +211,24 @@ export const readInspectedFileBytes = async (
210
211
  if (opened.size !== expected.size) {
211
212
  throw new Error(`${label} changed while it was being inspected`);
212
213
  }
213
- if (opened.size > maxBytes) {
214
+ if (!prefixOnly && opened.size > maxBytes) {
214
215
  throw new Error(`${label} exceeds the ${maxBytes}-byte artifact read limit`);
215
216
  }
216
217
  // Allocate for the inspected file, not the caller's ceiling. Reading one
217
218
  // extra byte detects same-inode growth after fstat without making many
218
219
  // tiny artifact reads reserve the maximum concurrently.
219
- const bytes = Buffer.alloc(expected.size + 1);
220
+ const size = Math.min(expected.size, maxBytes);
221
+ const bytes = Buffer.alloc(size + 1);
220
222
  let offset = 0;
221
223
  while (offset < bytes.length) {
222
224
  const read = await handle.read(bytes, offset, bytes.length - offset, offset);
223
225
  if (read.bytesRead === 0) break;
224
226
  offset += read.bytesRead;
225
227
  }
226
- if (offset !== expected.size) {
228
+ if (offset !== Math.min(expected.size, bytes.length)) {
227
229
  throw new Error(`${label} changed while it was being inspected`);
228
230
  }
229
- return bytes.subarray(0, offset);
231
+ return bytes.subarray(0, Math.min(offset, size));
230
232
  } finally {
231
233
  await handle.close();
232
234
  }
@@ -238,6 +240,39 @@ export const readInspectedFileBytes = async (
238
240
  }
239
241
  };
240
242
 
243
+ /** Read a bounded prefix for presentation without weakening full-evidence reads. */
244
+ export const readRunArtifactExcerpt = async (
245
+ run: RunArtifactEvidence,
246
+ authoredPath: string,
247
+ maxChars: number,
248
+ ): Promise<{ text: string; truncated: boolean }> => {
249
+ if (!Number.isSafeInteger(maxChars) || maxChars < 0 || maxChars > 80_000) {
250
+ throw new RangeError("artifact excerpt maxChars must be an integer from 0 to 80000");
251
+ }
252
+ const inspected = await inspectRunArtifact(run, authoredPath);
253
+ if (!inspected.stats?.isFile()) {
254
+ throw new Error(inspected.issue ?? `${authoredPath} is not a file`);
255
+ }
256
+ // An extra character distinguishes an exact fit from an omitted suffix.
257
+ const bytes = await readInspectedFileBytes(
258
+ inspected.target,
259
+ inspected.stats,
260
+ (maxChars + 1) * 4,
261
+ authoredPath,
262
+ true,
263
+ );
264
+ const text = bytes.toString("utf8");
265
+ let end = Math.min(maxChars, text.length);
266
+ if (
267
+ end > 0 &&
268
+ end < text.length &&
269
+ /[\uD800-\uDBFF]/.test(text[end - 1] ?? "") &&
270
+ /[\uDC00-\uDFFF]/.test(text[end] ?? "")
271
+ )
272
+ end--;
273
+ return { text: text.slice(0, end), truncated: text.length > end };
274
+ };
275
+
241
276
  export const readRunArtifactBytes = async (
242
277
  run: RunArtifactEvidence,
243
278
  authoredPath: string,
@@ -1,4 +1,5 @@
1
- import { rm } from "node:fs/promises";
1
+ import { rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
2
3
  import { temporaryDirectory } from "tempy";
3
4
  import { z } from "zod";
4
5
  import {
@@ -155,6 +156,10 @@ const runClaude = async (
155
156
  const model = options.model ?? DEFAULT_CLAUDE_MODEL;
156
157
  const workspace = temporaryDirectory({ prefix: "lux-claude-model-" });
157
158
  try {
159
+ // Windows command shims cannot preserve multiline argv. Keep the exact
160
+ // system instructions in the private workspace for the call's lifetime.
161
+ const systemPromptPath = join(workspace, "system-prompt.txt");
162
+ await writeFile(systemPromptPath, options.system, { mode: 0o600 });
158
163
  const effort = resolveEffort("claude-code", model, options.reasoningEffort);
159
164
  const result = await exec(
160
165
  command,
@@ -183,8 +188,8 @@ const runClaude = async (
183
188
  : []),
184
189
  "--settings",
185
190
  JSON.stringify({ disableAllHooks: true }),
186
- "--system-prompt",
187
- options.system,
191
+ "--system-prompt-file",
192
+ systemPromptPath,
188
193
  ],
189
194
  {
190
195
  input: options.prompt,
@@ -28,6 +28,7 @@ import {
28
28
  configuredClaudeSourceHashes,
29
29
  } from "../shared/experiment-behavior.js";
30
30
  import { prepareClaudeExperimentConfig } from "../shared/experiment-policy.js";
31
+ import { stageSkills } from "../shared/skills.js";
31
32
  import { subprocessDriver } from "../subprocess/index.js";
32
33
  import { runDeferredProcess } from "./deferred-process.js";
33
34
  import { isolatedLaunch } from "./isolation.js";
@@ -106,6 +107,8 @@ export const ClaudeCodeDriverConfigSchema = z
106
107
  agent: z.string().optional(),
107
108
  /** Extra readable directories (`--add-dir`). Grant the narrowest tree the case needs. */
108
109
  addDirs: z.array(z.string()).optional(),
110
+ /** Standalone skill directories registered through native discovery in an isolated workspace. */
111
+ skillDirs: z.array(z.string().min(1)).optional(),
109
112
  /**
110
113
  * Run in a disposable workspace with local memory, persisted runs, and
111
114
  * standard credential stores denied. Live network access is retained.
@@ -132,6 +135,13 @@ export const ClaudeCodeDriverConfigSchema = z
132
135
  })
133
136
  .strict()
134
137
  .superRefine((config, ctx) => {
138
+ if (config.skillDirs?.length && !config.isolation) {
139
+ ctx.addIssue({
140
+ code: "custom",
141
+ path: ["skillDirs"],
142
+ message: "skillDirs requires isolation",
143
+ });
144
+ }
135
145
  if (config.turnTimeout !== undefined && config.interactionMode !== "defer-resume") {
136
146
  ctx.addIssue({
137
147
  code: "custom",
@@ -173,7 +183,7 @@ export const ClaudeCodeDriverConfigSchema = z
173
183
  code: "custom",
174
184
  path: ["pluginDirs"],
175
185
  message:
176
- "isolated defer-resume cannot load plugin hooks; use addDirs for read-only skill files",
186
+ "isolated defer-resume cannot load plugin hooks; use skillDirs for standalone skills",
177
187
  });
178
188
  }
179
189
  if (config.cwd || config.cwdMode !== "artifacts") {
@@ -571,6 +581,7 @@ class ClaudeCodeDriverImpl implements AgentDriver {
571
581
  temporaryDirectory({ prefix: "lux-claude-control-" }),
572
582
  );
573
583
  await cp(ctx.artifactsDir, this.isolatedWorkspace, { recursive: true });
584
+ await stageSkills(this.config.skillDirs ?? [], this.isolatedWorkspace, ".claude");
574
585
  return isolatedLaunch(this.config, ctx, this.isolatedWorkspace, this.isolationControlDir);
575
586
  }
576
587
 
@@ -39,6 +39,8 @@ export const CodexDriverConfigSchema = z
39
39
  env: z.record(z.string(), z.string()).optional(),
40
40
  inheritEnv: InheritEnvironmentSchema.optional(),
41
41
  addDirs: z.array(z.string()).optional(),
42
+ /** Standalone skill directories registered through native discovery in an isolated workspace. */
43
+ skillDirs: z.array(z.string().min(1)).optional(),
42
44
  profile: z.string().optional(),
43
45
  config: z.record(z.string(), CodexConfigValueSchema).optional(),
44
46
  extraArgs: z.array(z.string()).optional(),
@@ -58,6 +60,13 @@ export const CodexDriverConfigSchema = z
58
60
  })
59
61
  .strict()
60
62
  .superRefine((config, ctx) => {
63
+ if (config.skillDirs?.length && !config.isolation) {
64
+ ctx.addIssue({
65
+ code: "custom",
66
+ path: ["skillDirs"],
67
+ message: "skillDirs requires isolation",
68
+ });
69
+ }
61
70
  if (
62
71
  config.approvalsReviewer === "auto_review" &&
63
72
  (config.interactionMode !== "app-server" || config.approvalPolicy !== "on-request")
@@ -24,6 +24,7 @@ import { copyRegularFileNoFollow } from "../../core/secure-copy.js";
24
24
  import { walkArtifacts } from "../shared/artifacts.js";
25
25
  import { configuredClaudeSourceHashes } from "../shared/experiment-behavior.js";
26
26
  import { prepareCodexExperimentConfig } from "../shared/experiment-policy.js";
27
+ import { stageSkills } from "../shared/skills.js";
27
28
  import { subprocessDriver } from "../subprocess/index.js";
28
29
  import {
29
30
  type AppTurnFacets,
@@ -104,6 +105,7 @@ class CodexAppServerDriverImpl implements AgentDriver {
104
105
  const workspace = await realpath(temporaryDirectory({ prefix: "lux-codex-workspace-" }));
105
106
  await cp(ctx.artifactsDir, workspace, { recursive: true });
106
107
  this.isolatedWorkspace = workspace;
108
+ await stageSkills(this.config.skillDirs ?? [], workspace, ".agents");
107
109
  cwd = workspace;
108
110
  }
109
111
  if (this.config.isolation || this.config.ignoreUserConfig) {
@@ -162,7 +162,7 @@ export const configuredClaudeSourceHashes = async (
162
162
  driver: unknown,
163
163
  baseRoot: string,
164
164
  cache: Map<string, Promise<string>> = new Map(),
165
- ): Promise<Array<{ kind: "plugin" | "read"; path: string; hash: string }>> => {
165
+ ): Promise<Array<{ kind: "plugin" | "read" | "skill"; path: string; hash: string }>> => {
166
166
  const name =
167
167
  typeof driver === "string" ? driver : (driver as { name?: unknown } | undefined)?.name;
168
168
  const driverCli = name === "claude-code" || name === "codex" ? { name } : undefined;
@@ -183,11 +183,14 @@ export const configuredClaudeSourceHashes = async (
183
183
  ...(Array.isArray(config.addDirs)
184
184
  ? config.addDirs.map((path) => ({ kind: "read" as const, path }))
185
185
  : []),
186
+ ...(Array.isArray(config.skillDirs)
187
+ ? config.skillDirs.map((path) => ({ kind: "skill" as const, path }))
188
+ : []),
186
189
  ...(driverCli.name === "claude-code" && isolation && Array.isArray(isolation.allowRead)
187
190
  ? isolation.allowRead.map((path) => ({ kind: "read" as const, path }))
188
191
  : []),
189
192
  ].filter(
190
- (entry): entry is { kind: "plugin" | "read"; path: string } =>
193
+ (entry): entry is { kind: "plugin" | "read" | "skill"; path: string } =>
191
194
  typeof entry.path === "string" && entry.path.length > 0,
192
195
  );
193
196
  return Promise.all(
@@ -91,6 +91,7 @@ const validateAgentExperimentConfig = (
91
91
  const structuredPaths = [
92
92
  ...(driverName === "claude-code" && Array.isArray(config.pluginDirs) ? config.pluginDirs : []),
93
93
  ...(Array.isArray(config.addDirs) ? config.addDirs : []),
94
+ ...(Array.isArray(config.skillDirs) ? config.skillDirs : []),
94
95
  ...(driverName === "claude-code" && Array.isArray(isolation.allowRead)
95
96
  ? isolation.allowRead
96
97
  : []),
@@ -0,0 +1,74 @@
1
+ import { lstat, mkdir, readFile, realpath } from "node:fs/promises";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { parse } from "yaml";
4
+ import { walkAuthoredFiles } from "../../core/file-tree.js";
5
+ import { copyRegularFileNoFollow } from "../../core/secure-copy.js";
6
+
7
+ const validateSkillMetadata = async (root: string, file: string, name: string): Promise<void> => {
8
+ const text = await readFile(join(root, file), "utf8");
9
+ const frontmatter = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
10
+ if (!frontmatter) throw new Error(`skill requires YAML frontmatter: ${file}`);
11
+ const metadata: unknown = parse(frontmatter[1] ?? "", { maxAliasCount: 50, merge: true });
12
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
13
+ throw new Error(`skill frontmatter must be a mapping: ${file}`);
14
+ if ("hooks" in metadata) throw new Error(`isolated skills cannot define hooks: ${file}`);
15
+ if (file === "SKILL.md" && (!("name" in metadata) || metadata.name !== name))
16
+ throw new Error(`skill frontmatter name must match its directory: ${name}`);
17
+ };
18
+
19
+ /** Install only explicitly supplied, standalone skills into native project discovery. */
20
+ export const stageSkills = async (
21
+ directories: readonly string[],
22
+ workspace: string,
23
+ host: ".claude" | ".agents",
24
+ ): Promise<void> => {
25
+ const names = new Set<string>();
26
+ for (const directory of directories) {
27
+ const requestedRoot = resolve(directory);
28
+ const metadata = await lstat(requestedRoot);
29
+ if (metadata.isSymbolicLink() || !metadata.isDirectory())
30
+ throw new Error(`skill source must be a real directory: ${requestedRoot}`);
31
+ const root = await realpath(requestedRoot);
32
+ const name = basename(root);
33
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
34
+ throw new Error(`skill directory must use a portable skill name: ${name}`);
35
+ if (names.has(name)) throw new Error(`duplicate skill directory name: ${name}`);
36
+ names.add(name);
37
+ const files = await walkAuthoredFiles(root);
38
+ if (!files.includes("SKILL.md")) throw new Error(`skill source lacks SKILL.md: ${root}`);
39
+ if (files.length > 5_000) throw new Error(`skill source exceeds 5000 files: ${root}`);
40
+ let bytes = 0;
41
+ for (const file of files) {
42
+ const parts = file.split(/[\\/]/);
43
+ if (
44
+ parts.some((part) =>
45
+ [".claude-plugin", ".claude", ".codex", ".agents", ".git", ".jj"].includes(part),
46
+ ) ||
47
+ parts.some((part) => [".mcp.json", ".lsp.json"].includes(part)) ||
48
+ parts.join("/").endsWith("hooks/hooks.json")
49
+ )
50
+ throw new Error(`skill source contains plugin or agent configuration: ${file}`);
51
+ bytes += (await lstat(join(root, file))).size;
52
+ if (bytes > 256 * 1024 * 1024) throw new Error(`skill source exceeds 256 MiB: ${root}`);
53
+ if (basename(file) === "SKILL.md") await validateSkillMetadata(root, file, name);
54
+ }
55
+ // Never follow a fixture-owned discovery directory or replace an authored skill.
56
+ let parent = workspace;
57
+ for (const part of [host, "skills"]) {
58
+ parent = join(parent, part);
59
+ await mkdir(parent).catch((error: NodeJS.ErrnoException) => {
60
+ if (error.code !== "EEXIST") throw error;
61
+ });
62
+ const info = await lstat(parent);
63
+ if (info.isSymbolicLink() || !info.isDirectory())
64
+ throw new Error(`skill discovery path must be a real directory: ${parent}`);
65
+ }
66
+ const destination = join(parent, name);
67
+ await mkdir(destination);
68
+ for (const file of files) {
69
+ const target = join(destination, file);
70
+ await mkdir(dirname(target), { recursive: true });
71
+ await copyRegularFileNoFollow(root, join(root, file), target);
72
+ }
73
+ }
74
+ };