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

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 (44) 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/drivers/claude-code/index.d.ts +7 -0
  12. package/lib/drivers/claude-code/index.d.ts.map +1 -1
  13. package/lib/drivers/claude-code/index.js +12 -1
  14. package/lib/drivers/claude-code/index.js.map +1 -1
  15. package/lib/drivers/codex/config.d.ts +3 -0
  16. package/lib/drivers/codex/config.d.ts.map +1 -1
  17. package/lib/drivers/codex/config.js +9 -0
  18. package/lib/drivers/codex/config.js.map +1 -1
  19. package/lib/drivers/codex/index.d.ts +4 -0
  20. package/lib/drivers/codex/index.d.ts.map +1 -1
  21. package/lib/drivers/codex/index.js +2 -0
  22. package/lib/drivers/codex/index.js.map +1 -1
  23. package/lib/drivers/shared/experiment-behavior.d.ts +1 -1
  24. package/lib/drivers/shared/experiment-behavior.d.ts.map +1 -1
  25. package/lib/drivers/shared/experiment-behavior.js +3 -0
  26. package/lib/drivers/shared/experiment-behavior.js.map +1 -1
  27. package/lib/drivers/shared/experiment-policy.d.ts.map +1 -1
  28. package/lib/drivers/shared/experiment-policy.js +1 -0
  29. package/lib/drivers/shared/experiment-policy.js.map +1 -1
  30. package/lib/drivers/shared/skills.d.ts +3 -0
  31. package/lib/drivers/shared/skills.d.ts.map +1 -0
  32. package/lib/drivers/shared/skills.js +73 -0
  33. package/lib/drivers/shared/skills.js.map +1 -0
  34. package/package.json +2 -1
  35. package/skills/lux-answerer/SKILL.md +1 -1
  36. package/src/assertions/rubric/index.ts +10 -1
  37. package/src/assertions/rubric/internal.ts +27 -13
  38. package/src/core/artifact-evidence.ts +40 -5
  39. package/src/drivers/claude-code/index.ts +12 -1
  40. package/src/drivers/codex/config.ts +9 -0
  41. package/src/drivers/codex/index.ts +2 -0
  42. package/src/drivers/shared/experiment-behavior.ts +5 -2
  43. package/src/drivers/shared/experiment-policy.ts +1 -0
  44. package/src/drivers/shared/skills.ts +74 -0
@@ -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,
@@ -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
+ };