@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.
- package/lib/assertions/rubric/index.d.ts.map +1 -1
- package/lib/assertions/rubric/index.js +10 -1
- package/lib/assertions/rubric/index.js.map +1 -1
- package/lib/assertions/rubric/internal.d.ts.map +1 -1
- package/lib/assertions/rubric/internal.js +22 -13
- package/lib/assertions/rubric/internal.js.map +1 -1
- package/lib/core/artifact-evidence.d.ts +6 -1
- package/lib/core/artifact-evidence.d.ts.map +1 -1
- package/lib/core/artifact-evidence.js +27 -6
- package/lib/core/artifact-evidence.js.map +1 -1
- package/lib/drivers/claude-code/index.d.ts +7 -0
- package/lib/drivers/claude-code/index.d.ts.map +1 -1
- package/lib/drivers/claude-code/index.js +12 -1
- package/lib/drivers/claude-code/index.js.map +1 -1
- package/lib/drivers/codex/config.d.ts +3 -0
- package/lib/drivers/codex/config.d.ts.map +1 -1
- package/lib/drivers/codex/config.js +9 -0
- package/lib/drivers/codex/config.js.map +1 -1
- package/lib/drivers/codex/index.d.ts +4 -0
- package/lib/drivers/codex/index.d.ts.map +1 -1
- package/lib/drivers/codex/index.js +2 -0
- package/lib/drivers/codex/index.js.map +1 -1
- package/lib/drivers/shared/experiment-behavior.d.ts +1 -1
- package/lib/drivers/shared/experiment-behavior.d.ts.map +1 -1
- package/lib/drivers/shared/experiment-behavior.js +3 -0
- package/lib/drivers/shared/experiment-behavior.js.map +1 -1
- package/lib/drivers/shared/experiment-policy.d.ts.map +1 -1
- package/lib/drivers/shared/experiment-policy.js +1 -0
- package/lib/drivers/shared/experiment-policy.js.map +1 -1
- package/lib/drivers/shared/skills.d.ts +3 -0
- package/lib/drivers/shared/skills.d.ts.map +1 -0
- package/lib/drivers/shared/skills.js +73 -0
- package/lib/drivers/shared/skills.js.map +1 -0
- package/package.json +2 -1
- package/skills/lux-answerer/SKILL.md +1 -1
- package/src/assertions/rubric/index.ts +10 -1
- package/src/assertions/rubric/internal.ts +27 -13
- package/src/core/artifact-evidence.ts +40 -5
- package/src/drivers/claude-code/index.ts +12 -1
- package/src/drivers/codex/config.ts +9 -0
- package/src/drivers/codex/index.ts +2 -0
- package/src/drivers/shared/experiment-behavior.ts +5 -2
- package/src/drivers/shared/experiment-policy.ts +1 -0
- 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
|
|
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
|
|
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
|
+
};
|