praxis-agent 0.67.1 → 0.68.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/README.md +1 -1
- package/dist/application/session-service.js +4 -3
- package/dist/application/turn-lifecycle.d.ts +4 -1
- package/dist/application/turn-lifecycle.js +53 -10
- package/dist/build-identity.json +1 -1
- package/dist/evals/held-out-corpus.d.ts +28 -0
- package/dist/evals/held-out-corpus.js +303 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -407,7 +407,7 @@ normal/low-capability full-frame p95 budgets of `<16.7/<33 ms`.
|
|
|
407
407
|
`npm run test:coverage` measures all production code under `src/**` with V8 and
|
|
408
408
|
enforces global floors of 79% statements, 70% branches, 85% functions, and 81% lines,
|
|
409
409
|
and rejects any production runtime module with zero covered statements (while allowing
|
|
410
|
-
type-only modules). `npm run test:fixtures` executes the
|
|
410
|
+
type-only modules). `npm run test:fixtures` executes the 75-behavior native contract; 67 behaviors
|
|
411
411
|
are qualified and 8 are explicitly excluded. Schema-v2 risk tiers and executable evidence dimensions
|
|
412
412
|
are enforced fail-closed. `npm run verify:fixture-contracts` performs the structural check and is part
|
|
413
413
|
of `npm run check`.
|
|
@@ -901,7 +901,7 @@ export class ClaudeSessionService {
|
|
|
901
901
|
}
|
|
902
902
|
async close() {
|
|
903
903
|
this.closing = true;
|
|
904
|
-
this.turnCoordinator.close();
|
|
904
|
+
await this.turnCoordinator.close();
|
|
905
905
|
await this.fileChangeWatcher?.close(5_000);
|
|
906
906
|
await this.hookLifecycle.close();
|
|
907
907
|
await this.drainDetachedHookRuns(5_000);
|
|
@@ -2590,7 +2590,7 @@ export class ClaudeSessionService {
|
|
|
2590
2590
|
return tracker.snapshot();
|
|
2591
2591
|
}
|
|
2592
2592
|
async executeTurn(request) {
|
|
2593
|
-
const { activation, submission
|
|
2593
|
+
const { activation, submission } = request;
|
|
2594
2594
|
const sessionId = activation.sessionId;
|
|
2595
2595
|
const requireExisting = activation.kind === 'resume';
|
|
2596
2596
|
const name = activation.name;
|
|
@@ -2604,7 +2604,8 @@ export class ClaudeSessionService {
|
|
|
2604
2604
|
const documents = submission.kind === 'prompt' ? (submission.documents ?? []) : [];
|
|
2605
2605
|
const shellCommand = submission.kind === 'shell' ? submission.command : undefined;
|
|
2606
2606
|
const skipUserPrompt = submission.kind === 'retry';
|
|
2607
|
-
return this.turnCoordinator.run(request, async (
|
|
2607
|
+
return this.turnCoordinator.run(request, async (scope) => {
|
|
2608
|
+
const { emit, signal, steering } = scope;
|
|
2608
2609
|
this.assertTurnWritable();
|
|
2609
2610
|
await this.activateSessionCostTracker(sessionId);
|
|
2610
2611
|
await this.ensureFileResources(sessionId, signal);
|
|
@@ -29,6 +29,7 @@ export interface TurnRequest {
|
|
|
29
29
|
}
|
|
30
30
|
export interface TurnScope {
|
|
31
31
|
readonly emit: RuntimeEventSink;
|
|
32
|
+
readonly signal: AbortSignal;
|
|
32
33
|
readonly steering?: ActiveTurnInputPort;
|
|
33
34
|
}
|
|
34
35
|
export interface TurnCoordinatorOptions {
|
|
@@ -39,11 +40,13 @@ export interface TurnCoordinatorOptions {
|
|
|
39
40
|
export declare class TurnCoordinator {
|
|
40
41
|
private readonly options;
|
|
41
42
|
private readonly activeTurns;
|
|
43
|
+
private closing;
|
|
44
|
+
private closePromise;
|
|
42
45
|
constructor(options: TurnCoordinatorOptions);
|
|
43
46
|
run<T>(request: TurnRequest, work: (scope: TurnScope) => Promise<T>): Promise<T>;
|
|
44
47
|
steer(sessionId: string, content: string): ActiveTurnInputCommandResult;
|
|
45
48
|
withdrawSteering(sessionId: string, id: string): ActiveTurnInputCommandResult;
|
|
46
|
-
close(): void
|
|
49
|
+
close(): Promise<void>;
|
|
47
50
|
private validateRequest;
|
|
48
51
|
private terminalState;
|
|
49
52
|
private transition;
|
|
@@ -4,6 +4,8 @@ import { ActiveTurnInputMailbox, } from '../core/active-turn-input.js';
|
|
|
4
4
|
export class TurnCoordinator {
|
|
5
5
|
options;
|
|
6
6
|
activeTurns = new Map();
|
|
7
|
+
closing = false;
|
|
8
|
+
closePromise;
|
|
7
9
|
constructor(options) {
|
|
8
10
|
this.options = options;
|
|
9
11
|
}
|
|
@@ -12,13 +14,23 @@ export class TurnCoordinator {
|
|
|
12
14
|
const mailbox = request.submission.kind === 'shell'
|
|
13
15
|
? undefined
|
|
14
16
|
: new ActiveTurnInputMailbox(this.options.createSteeringId);
|
|
17
|
+
let settle;
|
|
18
|
+
const settled = new Promise((resolve) => {
|
|
19
|
+
settle = resolve;
|
|
20
|
+
});
|
|
21
|
+
const controller = new AbortController();
|
|
15
22
|
const record = {
|
|
16
23
|
...(mailbox ? { mailbox } : {}),
|
|
24
|
+
controller,
|
|
25
|
+
settled,
|
|
26
|
+
settle,
|
|
17
27
|
terminal: false,
|
|
18
28
|
};
|
|
19
29
|
let terminalState = 'failed';
|
|
20
30
|
let pendingFailure;
|
|
31
|
+
let callerAbort;
|
|
21
32
|
const scope = {
|
|
33
|
+
signal: controller.signal,
|
|
22
34
|
emit: (event) => {
|
|
23
35
|
if (event.type === 'state' &&
|
|
24
36
|
(event.state === 'completed' ||
|
|
@@ -31,19 +43,29 @@ export class TurnCoordinator {
|
|
|
31
43
|
...(mailbox ? { steering: mailbox } : {}),
|
|
32
44
|
};
|
|
33
45
|
try {
|
|
46
|
+
if (request.signal?.aborted)
|
|
47
|
+
controller.abort(request.signal.reason);
|
|
34
48
|
this.validateRequest(request);
|
|
35
49
|
if (this.activeTurns.has(sessionId)) {
|
|
36
50
|
throw new Error(`conflict: locked (session ${sessionId} already has an active turn)`);
|
|
37
51
|
}
|
|
52
|
+
if (this.closing)
|
|
53
|
+
throw new Error('turn coordinator is closed');
|
|
38
54
|
this.activeTurns.set(sessionId, record);
|
|
55
|
+
if (request.signal && !request.signal.aborted) {
|
|
56
|
+
callerAbort = () => controller.abort(request.signal?.reason);
|
|
57
|
+
request.signal.addEventListener('abort', callerAbort, { once: true });
|
|
58
|
+
}
|
|
39
59
|
const result = await work(scope);
|
|
60
|
+
if (controller.signal.aborted)
|
|
61
|
+
throw new AgentRunCancelledError();
|
|
40
62
|
terminalState = 'completed';
|
|
41
63
|
this.transition(record, 'completed');
|
|
42
64
|
return result;
|
|
43
65
|
}
|
|
44
66
|
catch (error) {
|
|
45
67
|
if (!record.terminal) {
|
|
46
|
-
terminalState = this.terminalState(error,
|
|
68
|
+
terminalState = this.terminalState(error, controller.signal);
|
|
47
69
|
this.transition(record, terminalState);
|
|
48
70
|
}
|
|
49
71
|
throw error;
|
|
@@ -55,10 +77,14 @@ export class TurnCoordinator {
|
|
|
55
77
|
}
|
|
56
78
|
}
|
|
57
79
|
finally {
|
|
80
|
+
if (callerAbort && request.signal) {
|
|
81
|
+
request.signal.removeEventListener('abort', callerAbort);
|
|
82
|
+
}
|
|
58
83
|
if (this.activeTurns.get(sessionId) === record) {
|
|
59
84
|
this.activeTurns.delete(sessionId);
|
|
60
85
|
}
|
|
61
86
|
}
|
|
87
|
+
record.settle();
|
|
62
88
|
if (pendingFailure) {
|
|
63
89
|
// A rejected-input sink failure intentionally retains its prior precedence.
|
|
64
90
|
// eslint-disable-next-line no-unsafe-finally -- compatibility is covered by the sink-error regression
|
|
@@ -86,16 +112,33 @@ export class TurnCoordinator {
|
|
|
86
112
|
const result = active.mailbox.withdraw(id);
|
|
87
113
|
return result.kind === 'withdrawn' ? result : { kind: 'not-pending' };
|
|
88
114
|
}
|
|
89
|
-
close() {
|
|
115
|
+
async close() {
|
|
116
|
+
if (this.closePromise)
|
|
117
|
+
return this.closePromise;
|
|
118
|
+
this.closing = true;
|
|
119
|
+
const snapshot = [...this.activeTurns.values()];
|
|
90
120
|
let firstFailure;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
121
|
+
let resolveClose;
|
|
122
|
+
let rejectClose;
|
|
123
|
+
this.closePromise = new Promise((resolve, reject) => {
|
|
124
|
+
resolveClose = resolve;
|
|
125
|
+
rejectClose = reject;
|
|
126
|
+
});
|
|
127
|
+
void (async () => {
|
|
128
|
+
for (const active of snapshot) {
|
|
129
|
+
if (active.mailbox) {
|
|
130
|
+
const failure = this.rejectPending(active.mailbox.close(), 'closed');
|
|
131
|
+
firstFailure ??= failure;
|
|
132
|
+
}
|
|
133
|
+
active.controller.abort();
|
|
134
|
+
}
|
|
135
|
+
await Promise.allSettled(snapshot.map((active) => active.settled));
|
|
136
|
+
if (firstFailure)
|
|
137
|
+
rejectClose(firstFailure.error);
|
|
138
|
+
else
|
|
139
|
+
resolveClose();
|
|
140
|
+
})();
|
|
141
|
+
return this.closePromise;
|
|
99
142
|
}
|
|
100
143
|
validateRequest(request) {
|
|
101
144
|
const { activation, submission } = request;
|
package/dist/build-identity.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"schema_version":"1.0","source_revision":"git:
|
|
1
|
+
{"schema_version":"1.0","source_revision":"git:ef170d59a802ce59a82fd5cae23c0adaff912d8a","source_dirty":false,"artifact_sha256":"sha256:60ff6f473f030f3a5cd5c2db430f866898607b9f1c205f55ed68abcc2eff1b0d"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ProjectEvalCase } from './project-eval-schema.js';
|
|
2
|
+
export interface HeldOutCorpusPolicy {
|
|
3
|
+
readonly execution: 'opt-in-only';
|
|
4
|
+
readonly tuning: 'forbidden';
|
|
5
|
+
readonly resultInformedChanges: 'require-new-version';
|
|
6
|
+
}
|
|
7
|
+
export interface HeldOutCorpusRepository {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly target: string;
|
|
11
|
+
readonly tasks: readonly string[];
|
|
12
|
+
readonly cases: readonly ProjectEvalCase[];
|
|
13
|
+
}
|
|
14
|
+
export interface HeldOutCorpus {
|
|
15
|
+
readonly root: string;
|
|
16
|
+
readonly schemaVersion: '1.0';
|
|
17
|
+
readonly id: 'praxis-held-out-v1';
|
|
18
|
+
readonly version: 1;
|
|
19
|
+
readonly split: 'held-out';
|
|
20
|
+
readonly repetitions: 3;
|
|
21
|
+
readonly policy: HeldOutCorpusPolicy;
|
|
22
|
+
readonly contentSha256: `sha256:${string}`;
|
|
23
|
+
readonly repositories: readonly HeldOutCorpusRepository[];
|
|
24
|
+
readonly taskCount: number;
|
|
25
|
+
readonly plannedRunCount: number;
|
|
26
|
+
}
|
|
27
|
+
export declare function loadHeldOutCorpus(root: string): Promise<HeldOutCorpus>;
|
|
28
|
+
//# sourceMappingURL=held-out-corpus.d.ts.map
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { lstat, opendir, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { Minimatch } from 'minimatch';
|
|
5
|
+
import { parse as parseYaml } from 'yaml';
|
|
6
|
+
import { discoverProjectEvalCases, } from './project-eval-schema.js';
|
|
7
|
+
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
|
8
|
+
const MAX_FILES = 4096;
|
|
9
|
+
const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
const MAX_ENTRIES = 16_384;
|
|
11
|
+
const REQUIRED_TAGS = ['held-out', 'praxis-held-out-v1'];
|
|
12
|
+
const FORBIDDEN_TAGS = new Set([
|
|
13
|
+
'tuning',
|
|
14
|
+
'calibration',
|
|
15
|
+
'baseline',
|
|
16
|
+
'candidate',
|
|
17
|
+
'admission',
|
|
18
|
+
]);
|
|
19
|
+
const MUTATION_GLOB_OPTIONS = {
|
|
20
|
+
dot: true,
|
|
21
|
+
magicalBraces: true,
|
|
22
|
+
nocomment: true,
|
|
23
|
+
nonegate: true,
|
|
24
|
+
};
|
|
25
|
+
function object(value, label) {
|
|
26
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
27
|
+
throw new Error(`${label} must be an object`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function exactKeys(value, keys, label) {
|
|
31
|
+
const expected = new Set(keys);
|
|
32
|
+
const actual = Object.keys(value);
|
|
33
|
+
if (actual.some((key) => !expected.has(key)) ||
|
|
34
|
+
actual.length !== expected.size)
|
|
35
|
+
throw new Error(`${label} has unexpected or missing fields`);
|
|
36
|
+
}
|
|
37
|
+
function bounded(value, label = 'manifest', depth = 0, state = { nodes: 0 }) {
|
|
38
|
+
state.nodes += 1;
|
|
39
|
+
if (state.nodes > 4096)
|
|
40
|
+
throw new Error(`${label} exceeds object node limit`);
|
|
41
|
+
if (depth > 16)
|
|
42
|
+
throw new Error(`${label} exceeds object depth limit`);
|
|
43
|
+
if (typeof value === 'string' && value.length > 16 * 1024)
|
|
44
|
+
throw new Error(`${label} contains oversized string`);
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
if (value.length > 256)
|
|
47
|
+
throw new Error(`${label} contains oversized collection`);
|
|
48
|
+
for (const item of value)
|
|
49
|
+
bounded(item, label, depth + 1, state);
|
|
50
|
+
}
|
|
51
|
+
else if (value && typeof value === 'object') {
|
|
52
|
+
const entries = Object.entries(value);
|
|
53
|
+
if (entries.length > 256)
|
|
54
|
+
throw new Error(`${label} contains oversized collection`);
|
|
55
|
+
for (const [key, item] of entries) {
|
|
56
|
+
if (key.length > 256)
|
|
57
|
+
throw new Error(`${label} contains oversized key`);
|
|
58
|
+
bounded(item, label, depth + 1, state);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function text(value, label) {
|
|
63
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 16 * 1024)
|
|
64
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
function compareStrings(left, right) {
|
|
68
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
69
|
+
}
|
|
70
|
+
function safeRelativePath(value, label) {
|
|
71
|
+
const input = text(value, label);
|
|
72
|
+
const normalized = input.replaceAll('\\', '/');
|
|
73
|
+
if (isAbsolute(input) ||
|
|
74
|
+
/^[A-Za-z]:\//u.test(normalized) ||
|
|
75
|
+
normalized.includes('\0') ||
|
|
76
|
+
normalized.split('/').some((part) => !part || part === '.' || part === '..'))
|
|
77
|
+
throw new Error(`${label} must be a contained relative path`);
|
|
78
|
+
return normalized;
|
|
79
|
+
}
|
|
80
|
+
function contained(root, candidate, label) {
|
|
81
|
+
if (candidate !== root && !candidate.startsWith(`${root}${sep}`))
|
|
82
|
+
throw new Error(`${label} escapes corpus root`);
|
|
83
|
+
}
|
|
84
|
+
async function regularContainedPath(root, path, label) {
|
|
85
|
+
const candidate = resolve(root, path);
|
|
86
|
+
contained(root, candidate, label);
|
|
87
|
+
const parts = relative(root, candidate).split(sep);
|
|
88
|
+
let current = root;
|
|
89
|
+
for (const part of parts) {
|
|
90
|
+
current = join(current, part);
|
|
91
|
+
const component = await lstat(current).catch(() => null);
|
|
92
|
+
if (!component)
|
|
93
|
+
throw new Error(`${label} does not exist`);
|
|
94
|
+
if (component.isSymbolicLink())
|
|
95
|
+
throw new Error(`${label} contains symlink`);
|
|
96
|
+
}
|
|
97
|
+
const info = await lstat(candidate);
|
|
98
|
+
if (!info.isDirectory())
|
|
99
|
+
throw new Error(`${label} must be a directory`);
|
|
100
|
+
const canonical = await realpath(candidate);
|
|
101
|
+
contained(root, canonical, label);
|
|
102
|
+
return canonical;
|
|
103
|
+
}
|
|
104
|
+
async function enumerateFiles(root, repositoryRoots) {
|
|
105
|
+
const files = [];
|
|
106
|
+
let totalBytes = 0;
|
|
107
|
+
let visitedEntries = 0;
|
|
108
|
+
async function walk(directory) {
|
|
109
|
+
const handle = await opendir(directory);
|
|
110
|
+
for await (const entry of handle) {
|
|
111
|
+
visitedEntries += 1;
|
|
112
|
+
if (visitedEntries > MAX_ENTRIES)
|
|
113
|
+
throw new Error('Corpus exceeds directory entry limit');
|
|
114
|
+
if (entry.name === '.git' || entry.name === 'node_modules')
|
|
115
|
+
throw new Error(`Corpus contains forbidden entry: ${entry.name}`);
|
|
116
|
+
const path = join(directory, entry.name);
|
|
117
|
+
const info = await lstat(path);
|
|
118
|
+
if (info.isSymbolicLink())
|
|
119
|
+
throw new Error(`Corpus contains symlink: ${path}`);
|
|
120
|
+
if (info.isDirectory()) {
|
|
121
|
+
await walk(path);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!info.isFile())
|
|
125
|
+
throw new Error(`Corpus contains unsupported entry: ${path}`);
|
|
126
|
+
if (files.length >= MAX_FILES)
|
|
127
|
+
throw new Error('Corpus exceeds file limit');
|
|
128
|
+
totalBytes += info.size;
|
|
129
|
+
if (totalBytes > MAX_TOTAL_BYTES)
|
|
130
|
+
throw new Error('Corpus exceeds byte limit');
|
|
131
|
+
const content = await readFile(path);
|
|
132
|
+
const digest = createHash('sha256').update(content).digest('hex');
|
|
133
|
+
const rel = relative(root, path).split(sep).join('/');
|
|
134
|
+
files.push({
|
|
135
|
+
path: rel,
|
|
136
|
+
mode: info.mode & 0o777,
|
|
137
|
+
size: info.size,
|
|
138
|
+
digest,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const repositoryRoot of repositoryRoots)
|
|
143
|
+
await walk(repositoryRoot);
|
|
144
|
+
return files;
|
|
145
|
+
}
|
|
146
|
+
function contentDigest(files) {
|
|
147
|
+
const records = [...files]
|
|
148
|
+
.sort((a, b) => compareStrings(a.path, b.path))
|
|
149
|
+
.map((file) => `${file.path}\0${file.mode.toString(8)}\0${file.size}\0${file.digest}\n`)
|
|
150
|
+
.join('');
|
|
151
|
+
return `sha256:${createHash('sha256').update(records, 'utf8').digest('hex')}`;
|
|
152
|
+
}
|
|
153
|
+
export async function loadHeldOutCorpus(root) {
|
|
154
|
+
const corpusRoot = await realpath(resolve(root));
|
|
155
|
+
const manifestPath = join(corpusRoot, 'corpus.yaml');
|
|
156
|
+
const manifestInfo = await lstat(manifestPath);
|
|
157
|
+
if (manifestInfo.isSymbolicLink() || !manifestInfo.isFile())
|
|
158
|
+
throw new Error('corpus.yaml must be a regular file');
|
|
159
|
+
if (manifestInfo.size > MAX_MANIFEST_BYTES)
|
|
160
|
+
throw new Error('corpus.yaml exceeds 1 MiB');
|
|
161
|
+
const raw = parseYaml(await readFile(manifestPath, 'utf8'), {
|
|
162
|
+
maxAliasCount: 20,
|
|
163
|
+
});
|
|
164
|
+
bounded(raw);
|
|
165
|
+
const manifest = object(raw, 'corpus');
|
|
166
|
+
exactKeys(manifest, [
|
|
167
|
+
'schema_version',
|
|
168
|
+
'id',
|
|
169
|
+
'version',
|
|
170
|
+
'split',
|
|
171
|
+
'repetitions',
|
|
172
|
+
'policy',
|
|
173
|
+
'content_sha256',
|
|
174
|
+
'repositories',
|
|
175
|
+
], 'corpus');
|
|
176
|
+
if (manifest.schema_version !== '1.0')
|
|
177
|
+
throw new Error('Unsupported corpus schema_version');
|
|
178
|
+
if (manifest.id !== 'praxis-held-out-v1')
|
|
179
|
+
throw new Error('Unsupported corpus id');
|
|
180
|
+
if (manifest.version !== 1)
|
|
181
|
+
throw new Error('Unsupported corpus version');
|
|
182
|
+
if (manifest.split !== 'held-out')
|
|
183
|
+
throw new Error('corpus split must be held-out');
|
|
184
|
+
if (manifest.repetitions !== 3)
|
|
185
|
+
throw new Error('corpus repetitions must be 3');
|
|
186
|
+
const policyRaw = object(manifest.policy, 'corpus.policy');
|
|
187
|
+
exactKeys(policyRaw, ['execution', 'tuning', 'result_informed_changes'], 'corpus.policy');
|
|
188
|
+
if (policyRaw.execution !== 'opt-in-only' ||
|
|
189
|
+
policyRaw.tuning !== 'forbidden' ||
|
|
190
|
+
policyRaw.result_informed_changes !== 'require-new-version')
|
|
191
|
+
throw new Error('corpus policy is invalid');
|
|
192
|
+
const declaredDigest = text(manifest.content_sha256, 'corpus.content_sha256');
|
|
193
|
+
if (!/^sha256:[0-9a-f]{64}$/u.test(declaredDigest))
|
|
194
|
+
throw new Error('corpus.content_sha256 is invalid');
|
|
195
|
+
if (!Array.isArray(manifest.repositories) || manifest.repositories.length < 3)
|
|
196
|
+
throw new Error('corpus must declare at least three repositories');
|
|
197
|
+
const repositories = [];
|
|
198
|
+
const ids = new Set();
|
|
199
|
+
const targets = [];
|
|
200
|
+
const globalTasks = new Set();
|
|
201
|
+
for (const [index, value] of manifest.repositories.entries()) {
|
|
202
|
+
const item = object(value, `repositories[${index}]`);
|
|
203
|
+
exactKeys(item, ['id', 'path', 'tasks'], `repositories[${index}]`);
|
|
204
|
+
const id = text(item.id, `repositories[${index}].id`);
|
|
205
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id) || ids.has(id))
|
|
206
|
+
throw new Error('repository IDs must be unique lowercase dash-delimited identifiers');
|
|
207
|
+
ids.add(id);
|
|
208
|
+
const path = safeRelativePath(item.path, `repositories[${index}].path`);
|
|
209
|
+
const tasksRaw = item.tasks;
|
|
210
|
+
if (!Array.isArray(tasksRaw) ||
|
|
211
|
+
tasksRaw.length < 4 ||
|
|
212
|
+
tasksRaw.some((task) => typeof task !== 'string'))
|
|
213
|
+
throw new Error(`repositories[${index}].tasks must contain at least four task names`);
|
|
214
|
+
const tasks = tasksRaw.map((task) => text(task, `repositories[${index}].tasks`));
|
|
215
|
+
if (new Set(tasks).size !== tasks.length ||
|
|
216
|
+
[...tasks].sort(compareStrings).some((task, i) => task !== tasks[i]))
|
|
217
|
+
throw new Error(`repositories[${index}].tasks must be unique and sorted`);
|
|
218
|
+
for (const task of tasks) {
|
|
219
|
+
if (globalTasks.has(task))
|
|
220
|
+
throw new Error(`Task name is duplicated across repositories: ${task}`);
|
|
221
|
+
globalTasks.add(task);
|
|
222
|
+
}
|
|
223
|
+
const target = await regularContainedPath(corpusRoot, path, `repositories[${index}].path`);
|
|
224
|
+
targets.push(target);
|
|
225
|
+
repositories.push({ id, path, target, tasks, cases: [] });
|
|
226
|
+
}
|
|
227
|
+
if (globalTasks.size < 12)
|
|
228
|
+
throw new Error('corpus must contain at least twelve tasks');
|
|
229
|
+
for (let i = 0; i < targets.length; i += 1) {
|
|
230
|
+
for (let j = i + 1; j < targets.length; j += 1) {
|
|
231
|
+
const left = targets[i];
|
|
232
|
+
const right = targets[j];
|
|
233
|
+
if (left === undefined || right === undefined)
|
|
234
|
+
continue;
|
|
235
|
+
if (left === right ||
|
|
236
|
+
left.startsWith(`${right}${sep}`) ||
|
|
237
|
+
right.startsWith(`${left}${sep}`))
|
|
238
|
+
throw new Error('repository roots must be unique and non-nested');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Preflight every repository before Project Eval discovery. This bounds the
|
|
242
|
+
// tree before the existing discovery walker inspects any case definitions.
|
|
243
|
+
const files = await enumerateFiles(corpusRoot, targets);
|
|
244
|
+
const finalRepositories = [];
|
|
245
|
+
for (const repository of repositories) {
|
|
246
|
+
const cases = await discoverProjectEvalCases(repository.target);
|
|
247
|
+
cases.sort((left, right) => compareStrings(left.name, right.name));
|
|
248
|
+
const names = cases.map((item) => item.name);
|
|
249
|
+
if (names.length !== repository.tasks.length ||
|
|
250
|
+
names.some((name, index) => name !== repository.tasks[index]))
|
|
251
|
+
throw new Error(`Repository ${repository.id} task declaration does not match discovery`);
|
|
252
|
+
for (const item of cases) {
|
|
253
|
+
if (item.runs !== 3)
|
|
254
|
+
throw new Error(`${item.name} must have three repetitions`);
|
|
255
|
+
if (!item.verification.some((verifier) => verifier.required))
|
|
256
|
+
throw new Error(`${item.name} must have a required verifier`);
|
|
257
|
+
if (!item.expect.allowedChangedPaths.length ||
|
|
258
|
+
!item.expect.expectedChangedPaths.length ||
|
|
259
|
+
!item.expect.forbiddenChangedPaths.length)
|
|
260
|
+
throw new Error(`${item.name} must declare allowed, expected, and forbidden mutations`);
|
|
261
|
+
if (item.execution.model !== undefined)
|
|
262
|
+
throw new Error(`${item.name} must not pin a model`);
|
|
263
|
+
const allowed = new Set(item.expect.allowedChangedPaths);
|
|
264
|
+
const expected = new Set(item.expect.expectedChangedPaths);
|
|
265
|
+
const mutationPaths = [...allowed, ...expected];
|
|
266
|
+
if (mutationPaths.some((path) => new Minimatch(path, MUTATION_GLOB_OPTIONS).hasMagic()))
|
|
267
|
+
throw new Error(`${item.name} allowed and expected mutations must use exact paths`);
|
|
268
|
+
if ([...expected].some((path) => !allowed.has(path)))
|
|
269
|
+
throw new Error(`${item.name} expected mutations must be allowed`);
|
|
270
|
+
const forbidden = item.expect.forbiddenChangedPaths.map((pattern) => new Minimatch(pattern, MUTATION_GLOB_OPTIONS));
|
|
271
|
+
if (mutationPaths.some((path) => forbidden.some((matcher) => matcher.match(path))))
|
|
272
|
+
throw new Error(`${item.name} mutation paths overlap`);
|
|
273
|
+
if (!REQUIRED_TAGS.every((tag) => item.tags.includes(tag)) ||
|
|
274
|
+
!item.tags.includes(repository.id))
|
|
275
|
+
throw new Error(`${item.name} is missing required held-out tags`);
|
|
276
|
+
if (item.tags.some((tag) => FORBIDDEN_TAGS.has(tag)))
|
|
277
|
+
throw new Error(`${item.name} contains a forbidden tuning tag`);
|
|
278
|
+
}
|
|
279
|
+
finalRepositories.push({ ...repository, cases });
|
|
280
|
+
}
|
|
281
|
+
const actualDigest = contentDigest(files);
|
|
282
|
+
if (actualDigest !== declaredDigest)
|
|
283
|
+
throw new Error(`corpus content digest mismatch: expected ${declaredDigest}, got ${actualDigest}`);
|
|
284
|
+
const taskCount = globalTasks.size;
|
|
285
|
+
return {
|
|
286
|
+
root: corpusRoot,
|
|
287
|
+
schemaVersion: '1.0',
|
|
288
|
+
id: 'praxis-held-out-v1',
|
|
289
|
+
version: 1,
|
|
290
|
+
split: 'held-out',
|
|
291
|
+
repetitions: 3,
|
|
292
|
+
policy: {
|
|
293
|
+
execution: 'opt-in-only',
|
|
294
|
+
tuning: 'forbidden',
|
|
295
|
+
resultInformedChanges: 'require-new-version',
|
|
296
|
+
},
|
|
297
|
+
contentSha256: actualDigest,
|
|
298
|
+
repositories: finalRepositories,
|
|
299
|
+
taskCount,
|
|
300
|
+
plannedRunCount: taskCount * 3,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
//# sourceMappingURL=held-out-corpus.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "praxis-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.0",
|
|
4
4
|
"description": "Local-first, single-user general agent for the command line.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "wuqisen",
|
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
"verify:ci-coverage": "node scripts/verify-ci-coverage.mjs",
|
|
63
63
|
"verify:fixture-contracts": "node scripts/verify-fixture-contracts.mjs",
|
|
64
64
|
"test:fixtures": "node scripts/run-fixture-contracts.mjs",
|
|
65
|
-
"test:eval:baseline": "vitest run src/evals/coding-baseline.test.ts src/evals/project-eval-comparison.test.ts src/evals/apply-patch-admission.test.ts src/evals/lsp-diagnostics-admission.test.ts src/evals/glob-ripgrep-admission.test.ts"
|
|
65
|
+
"test:eval:baseline": "vitest run src/evals/coding-baseline.test.ts src/evals/project-eval-comparison.test.ts src/evals/apply-patch-admission.test.ts src/evals/lsp-diagnostics-admission.test.ts src/evals/glob-ripgrep-admission.test.ts",
|
|
66
|
+
"test:eval:held-out-contract": "vitest run src/evals/held-out-corpus.test.ts"
|
|
66
67
|
},
|
|
67
68
|
"engines": {
|
|
68
69
|
"node": ">=24"
|