simt-dev 0.7.0 → 0.7.1
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/CHANGELOG.md +158 -872
- package/README.md +19 -133
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/package.json +10 -2
- package/rules/core.md +7 -0
- package/rules/mdc/slice-commit.mdc +57 -4
- package/src/agenthooks/prompt.mjs +5 -2
- package/src/agenthooks/shim.mjs +1 -1
- package/src/api.mjs +29 -0
- package/src/argv.mjs +29 -3
- package/src/bench/adapters.json +16 -0
- package/src/bench/corpus.mjs +150 -0
- package/src/bench/grade.mjs +23 -49
- package/src/bench/mine.mjs +199 -35
- package/src/bench/probes.mjs +99 -62
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +17 -4
- package/src/bench/run.mjs +242 -142
- package/src/brief/assemble.mjs +38 -3
- package/src/ci/workflow.mjs +30 -7
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +3 -4
- package/src/commands/agenthook.mjs +4 -5
- package/src/commands/bench.mjs +190 -17
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +42 -42
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +4 -4
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +6 -9
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +16 -12
- package/src/commands/graph.mjs +2 -3
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +125 -17
- package/src/commands/hooks.mjs +100 -30
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +4 -7
- package/src/commands/index.mjs +273 -188
- package/src/commands/init.mjs +2 -1
- package/src/commands/lease.mjs +6 -7
- package/src/commands/lessons.mjs +10 -9
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +14 -9
- package/src/commands/mcp.mjs +0 -1
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +2 -2
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +4 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +33 -16
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +28 -26
- package/src/commands/skillscompile.mjs +4 -5
- package/src/commands/skillsinstall.mjs +2 -2
- package/src/commands/slice.mjs +21 -10
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +13 -11
- package/src/commands/tests.mjs +6 -9
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +5 -15
- package/src/commands/upgrade.mjs +7 -3
- package/src/commands/why.mjs +9 -8
- package/src/compare/row.mjs +0 -2
- package/src/context.mjs +64 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +14 -1
- package/src/distill/engine.mjs +5 -1
- package/src/graph/load.mjs +4 -1
- package/src/hooks/capability.mjs +3 -3
- package/src/hooks/dispatcher.mjs +198 -43
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +2 -7
- package/src/hooks/shim.mjs +5 -2
- package/src/identity.mjs +29 -1
- package/src/journal.mjs +78 -9
- package/src/ledger/index.mjs +30 -1
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/resources.mjs +7 -10
- package/src/mcp/server.mjs +70 -16
- package/src/mcp/tools.mjs +45 -7
- package/src/measure/session.mjs +3 -2
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +111 -3
- package/src/packs/manage.mjs +17 -3
- package/src/packs.mjs +20 -2
- package/src/projection/lock.mjs +11 -1
- package/src/projection/manifest.mjs +20 -34
- package/src/projection/rules.mjs +4 -3
- package/src/repo-root.mjs +19 -0
- package/src/resolve/fingerprint.mjs +19 -15
- package/src/review/critic.mjs +2 -1
- package/src/toolwords.mjs +3 -0
- package/src/workspace.mjs +55 -10
- package/docs/decisions/README.md +0 -600
package/src/bench/run.mjs
CHANGED
|
@@ -1,23 +1,21 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { existsSync } from 'node:fs';
|
|
3
2
|
import { lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
4
3
|
import { tmpdir } from 'node:os';
|
|
5
4
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
6
5
|
import { fileURLToPath } from 'node:url';
|
|
7
6
|
|
|
7
|
+
import { assembleBrief } from '../brief/assemble.mjs';
|
|
8
|
+
import { DEFAULT_BRIEF_BYTES } from '../brief/packet.mjs';
|
|
8
9
|
import { loadRegistry } from '../graph/load.mjs';
|
|
9
10
|
import { extractOpenedSkills, matchSkillsForPaths } from '../graph/compile.mjs';
|
|
10
|
-
import { matchGlob } from '../index/govern.mjs';
|
|
11
|
-
import { formatLessonParagraph, listPromotedLessons } from '../memory/lessons.mjs';
|
|
12
11
|
import { MANIFEST_FILE, STATE_DIR } from '../name.mjs';
|
|
13
12
|
import { LOCAL_DIR } from '../state.mjs';
|
|
14
13
|
import { readManifest } from '../projection/manifest.mjs';
|
|
15
14
|
import { plan } from '../projection/project.mjs';
|
|
16
15
|
import { parse as parseRegion } from '../projection/markers.mjs';
|
|
17
|
-
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
18
|
-
import { rankSkills, tokenize } from '../resolve/resolve.mjs';
|
|
19
16
|
import { gradeWorkspace } from './grade.mjs';
|
|
20
17
|
import { resolveVaultDir } from './mine.mjs';
|
|
18
|
+
import { spawnBounded } from './process.mjs';
|
|
21
19
|
import { loadTasks } from './tasks.mjs';
|
|
22
20
|
import { ablateSectionInWorkspace, parseAblationArmName } from './ablate.mjs';
|
|
23
21
|
import { applyVariantInWorkspace, parseVariantArmName } from './variants.mjs';
|
|
@@ -54,48 +52,80 @@ function sanitizedEnv(extra = {}) {
|
|
|
54
52
|
return env;
|
|
55
53
|
}
|
|
56
54
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
timedOut,
|
|
82
|
-
stdout: Buffer.concat(out),
|
|
83
|
-
stderr: Buffer.from(String(error.message ?? error)),
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
child.on('close', (code) => {
|
|
87
|
-
if (timer) clearTimeout(timer);
|
|
88
|
-
resolveOutcome({
|
|
89
|
-
code: timedOut ? 124 : (code ?? 1),
|
|
90
|
-
timedOut,
|
|
91
|
-
stdout: Buffer.concat(out),
|
|
92
|
-
stderr: Buffer.concat(err),
|
|
93
|
-
});
|
|
94
|
-
});
|
|
55
|
+
/**
|
|
56
|
+
* What every benchmarked agent is allowed to inherit from the operator's shell.
|
|
57
|
+
*
|
|
58
|
+
* `PATH` and the locale are what a CLI needs to run at all. Nothing here names a
|
|
59
|
+
* credential or a state directory: those come from the adapter's own `env` list.
|
|
60
|
+
*/
|
|
61
|
+
const ENV_BASELINE = [
|
|
62
|
+
'PATH',
|
|
63
|
+
'LANG',
|
|
64
|
+
'LC_ALL',
|
|
65
|
+
'LC_CTYPE',
|
|
66
|
+
'TZ',
|
|
67
|
+
'TERM',
|
|
68
|
+
'SHELL',
|
|
69
|
+
'TMPDIR',
|
|
70
|
+
'TEMP',
|
|
71
|
+
'TMP',
|
|
72
|
+
'SystemRoot',
|
|
73
|
+
'WINDIR',
|
|
74
|
+
'USER',
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** The isolation levels a run may record; `sandbox` is declared, not implemented. */
|
|
78
|
+
const ISOLATION_LEVELS = new Set(['none', 'env', 'sandbox']);
|
|
95
79
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The environment an arm's agent sees: the baseline, the adapter's declared keys,
|
|
82
|
+
* and nothing else.
|
|
83
|
+
*
|
|
84
|
+
* The agent used to inherit everything the operator had — `SIMT_DEV_VAULT_DIR`,
|
|
85
|
+
* `XDG_CACHE_HOME`, `HOME`, and every credential in the shell. A run that can read
|
|
86
|
+
* the vault can read the hidden tests it is graded against, and a run sharing the
|
|
87
|
+
* operator's HOME writes into their own configuration, so the number it produced
|
|
88
|
+
* described the machine rather than the arm (review 0003 Q49). `home` is the run's
|
|
89
|
+
* own slot, which is removed when the run ends.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} adapter an entry from `adapters.json`
|
|
92
|
+
* @param {{home?: string, extra?: Record<string, string>, base?: NodeJS.ProcessEnv}} [options]
|
|
93
|
+
* @returns {Record<string, string>}
|
|
94
|
+
*/
|
|
95
|
+
export function agentEnv(adapter, { home, extra = {}, base = process.env } = {}) {
|
|
96
|
+
const allowed = new Set([...ENV_BASELINE, ...(adapter?.env ?? [])]);
|
|
97
|
+
const env = {};
|
|
98
|
+
for (const key of allowed) {
|
|
99
|
+
if (typeof base[key] === 'string') env[key] = base[key];
|
|
100
|
+
}
|
|
101
|
+
for (const [key, value] of Object.entries(extra ?? {})) {
|
|
102
|
+
if (typeof value === 'string') env[key] = value;
|
|
103
|
+
}
|
|
104
|
+
if (typeof home === 'string' && home.length > 0) env.HOME = home;
|
|
105
|
+
env.SIMT_DEV_WORKSPACE = 'off';
|
|
106
|
+
for (const key of Object.keys(env)) {
|
|
107
|
+
if (key.startsWith('NODE_TEST')) delete env[key];
|
|
108
|
+
}
|
|
109
|
+
return env;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The isolation an adapter's runs provide, as the record states it.
|
|
114
|
+
*
|
|
115
|
+
* `none` is a built-in that scripts the workspace, `env` is a CLI driven with a
|
|
116
|
+
* scrubbed environment and a slot-local HOME, and `sandbox` is reserved for a
|
|
117
|
+
* real filesystem sandbox — nothing claims it yet, so a record never says it.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} adapter
|
|
120
|
+
* @returns {'none'|'env'|'sandbox'}
|
|
121
|
+
*/
|
|
122
|
+
export function adapterIsolation(adapter) {
|
|
123
|
+
const declared = adapter?.isolation;
|
|
124
|
+
return ISOLATION_LEVELS.has(declared) ? declared : 'none';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_AGENT_TIMEOUT_MS } = {}) {
|
|
128
|
+
return spawnBounded(cmd, args, { cwd, env: sanitizedEnv(env), input, timeoutMs });
|
|
99
129
|
}
|
|
100
130
|
|
|
101
131
|
function getByPath(obj, pathStr) {
|
|
@@ -468,55 +498,29 @@ async function applyProjectionMode(workDir, mode) {
|
|
|
468
498
|
}
|
|
469
499
|
}
|
|
470
500
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
if (lessonGlobs.some((glob) => prompt.includes(glob.replace(/\/\*\*$/, '')))) return true;
|
|
495
|
-
const kw = Array.isArray(lesson.keywords) ? lesson.keywords : [];
|
|
496
|
-
return kw.some((k) => taskTokenSet.has(String(k).toLowerCase()));
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
const lines = [
|
|
500
|
-
`# Task Brief: ${task?.id ?? 'task'}`,
|
|
501
|
-
'',
|
|
502
|
-
`Prompt: ${prompt}`,
|
|
503
|
-
'',
|
|
504
|
-
'## Ranked Skills',
|
|
505
|
-
...(ranked.length > 0
|
|
506
|
-
? ranked.map((r) => `- ${r.skill.name} (score ${r.score}): ${r.skill.description ?? ''}`)
|
|
507
|
-
: ['- (none matched)']),
|
|
508
|
-
...(matchingLessons.length > 0
|
|
509
|
-
? [
|
|
510
|
-
'',
|
|
511
|
-
'## Lessons',
|
|
512
|
-
...matchingLessons.map((l) => `- ${formatLessonParagraph(l)}`),
|
|
513
|
-
]
|
|
514
|
-
: []),
|
|
515
|
-
'',
|
|
516
|
-
];
|
|
517
|
-
const briefPath = join(workDir, LOCAL_DIR, 'brief.md');
|
|
518
|
-
await mkdir(dirname(briefPath), { recursive: true });
|
|
519
|
-
await writeFile(briefPath, lines.join('\n'), 'utf8');
|
|
501
|
+
/**
|
|
502
|
+
* The `brief` arm's context: the packet `simt brief` delivers, assembled by the same
|
|
503
|
+
* function the command and the prompt-submit hook call.
|
|
504
|
+
*
|
|
505
|
+
* The arm used to re-implement the packet from `registry.skills` — a property the
|
|
506
|
+
* registry does not have, so every run reported "(none matched)" while the real
|
|
507
|
+
* command resolved skills — and wrote it to a file no agent was told to read. An arm
|
|
508
|
+
* meant to measure the packet measured a different artefact (review 0003 Q49).
|
|
509
|
+
*/
|
|
510
|
+
async function buildBriefPrefix(workDir, task) {
|
|
511
|
+
// A packet is built from git's answer; a directory that is not a repository has none,
|
|
512
|
+
// and asking would print a failure into a bench log for no measurement.
|
|
513
|
+
if (!existsSync(join(workDir, '.git'))) return '';
|
|
514
|
+
const assembled = await assembleBrief({
|
|
515
|
+
root: workDir,
|
|
516
|
+
task: String(task?.prompt ?? ''),
|
|
517
|
+
paths: '',
|
|
518
|
+
budget: DEFAULT_BRIEF_BYTES,
|
|
519
|
+
build: true,
|
|
520
|
+
federate: false,
|
|
521
|
+
}).catch(() => null);
|
|
522
|
+
if (!assembled || assembled.error || assembled.cold) return '';
|
|
523
|
+
return assembled.packed?.text ?? '';
|
|
520
524
|
}
|
|
521
525
|
|
|
522
526
|
/**
|
|
@@ -525,9 +529,10 @@ async function writeTaskBrief(workDir, task) {
|
|
|
525
529
|
* @param {string} workDir
|
|
526
530
|
* @param {{name: string, kind?: string, base?: string|null, write?: Record<string, string>, remove?: string[], env?: Record<string, string>}} armSpec
|
|
527
531
|
* @param {object} [task]
|
|
528
|
-
* @returns {Promise<{env: Record<string, string
|
|
532
|
+
* @returns {Promise<{env: Record<string, string>, promptPrefix: string}>}
|
|
529
533
|
*/
|
|
530
534
|
export async function materialiseArm(workDir, armSpec, task = {}) {
|
|
535
|
+
let promptPrefix = '';
|
|
531
536
|
const ablate = armSpec.ablate ?? parseAblationArmName(armSpec.name)?.ablate ?? null;
|
|
532
537
|
const variant = armSpec.variant ?? parseVariantArmName(armSpec.name)?.variant ?? null;
|
|
533
538
|
const base = armSpec.base ?? (BUILTIN_ARMS.has(armSpec.name) ? armSpec.name : null);
|
|
@@ -543,7 +548,7 @@ export async function materialiseArm(workDir, armSpec, task = {}) {
|
|
|
543
548
|
await applyProjectionMode(workDir, 'kernel');
|
|
544
549
|
} else if (base === 'brief') {
|
|
545
550
|
await applyProjectionMode(workDir, 'full');
|
|
546
|
-
await
|
|
551
|
+
promptPrefix = await buildBriefPrefix(workDir, task);
|
|
547
552
|
}
|
|
548
553
|
|
|
549
554
|
for (const relPath of armSpec.remove ?? []) {
|
|
@@ -557,7 +562,7 @@ export async function materialiseArm(workDir, armSpec, task = {}) {
|
|
|
557
562
|
await writeFile(target, String(content), 'utf8');
|
|
558
563
|
}
|
|
559
564
|
|
|
560
|
-
return { env: { ...(armSpec.env ?? {}) } };
|
|
565
|
+
return { env: { ...(armSpec.env ?? {}) }, promptPrefix };
|
|
561
566
|
}
|
|
562
567
|
|
|
563
568
|
/**
|
|
@@ -570,7 +575,7 @@ export async function materialiseArm(workDir, armSpec, task = {}) {
|
|
|
570
575
|
* @param {object} armSpec
|
|
571
576
|
* @param {object} [task]
|
|
572
577
|
* @param {{parentDir?: string}} [options]
|
|
573
|
-
* @returns {Promise<{slotDir: string, workDir: string, initialHead: string, env: Record<string, string
|
|
578
|
+
* @returns {Promise<{slotDir: string, workDir: string, initialHead: string, env: Record<string, string>, promptPrefix: string}>}
|
|
574
579
|
*/
|
|
575
580
|
export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}, { parentDir } = {}) {
|
|
576
581
|
const slotDir = await mkdtemp(join(parentDir ?? tmpdir(), 'bench-run-'));
|
|
@@ -619,12 +624,18 @@ export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}
|
|
|
619
624
|
}
|
|
620
625
|
}
|
|
621
626
|
|
|
622
|
-
const { env } = await materialiseArm(workDir, armSpec, task);
|
|
623
|
-
|
|
624
627
|
await runCmd('git', ['init', '-q'], { cwd: workDir });
|
|
625
628
|
await runCmd('git', ['config', 'user.name', 'bench'], { cwd: workDir });
|
|
626
629
|
await runCmd('git', ['config', 'user.email', 'bench@local'], { cwd: workDir });
|
|
627
630
|
await runCmd('git', ['config', 'commit.gpgsign', 'false'], { cwd: workDir });
|
|
631
|
+
|
|
632
|
+
// The repository exists before an arm materialises: the `brief` arm assembles its
|
|
633
|
+
// packet from git's answer, which a directory that is not yet a repository cannot give.
|
|
634
|
+
const { env, promptPrefix } = await materialiseArm(workDir, armSpec, task);
|
|
635
|
+
// Assembling a packet writes tool state. It belongs to no arm, and leaving it in the
|
|
636
|
+
// base commit would make the `brief` workspace differ by an index as well as by context.
|
|
637
|
+
await rm(join(workDir, LOCAL_DIR), { recursive: true, force: true }).catch(() => {});
|
|
638
|
+
|
|
628
639
|
await runCmd('git', ['add', '-A'], { cwd: workDir });
|
|
629
640
|
const commitRes = await runCmd(
|
|
630
641
|
'git',
|
|
@@ -639,7 +650,7 @@ export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}
|
|
|
639
650
|
const headRes = await runCmd('git', ['rev-parse', 'HEAD'], { cwd: workDir });
|
|
640
651
|
const initialHead = headRes.stdout.toString('utf8').trim();
|
|
641
652
|
|
|
642
|
-
return { slotDir, workDir, initialHead, env };
|
|
653
|
+
return { slotDir, workDir, initialHead, env, promptPrefix };
|
|
643
654
|
}
|
|
644
655
|
|
|
645
656
|
function buildAdapterArgs(adapter, { prompt, turns, model, analyticsLog }) {
|
|
@@ -657,25 +668,75 @@ function buildAdapterArgs(adapter, { prompt, turns, model, analyticsLog }) {
|
|
|
657
668
|
return args;
|
|
658
669
|
}
|
|
659
670
|
|
|
660
|
-
|
|
661
|
-
|
|
671
|
+
/**
|
|
672
|
+
* Apply a patch to a workspace, falling back to a three-way merge.
|
|
673
|
+
*
|
|
674
|
+
* The gold oracle and the regrade path land operator-supplied bytes the same way. They
|
|
675
|
+
* used to disagree (`--binary` on one side only), so a patch that applied under one read
|
|
676
|
+
* as unreproducible under the other (review 0003 Q59).
|
|
677
|
+
*/
|
|
678
|
+
async function applyPatch(workDir, patch, { timeoutMs = DEFAULT_AGENT_TIMEOUT_MS } = {}) {
|
|
679
|
+
if (!patch || patch.length === 0) return { code: 0 };
|
|
680
|
+
const applied = await runCmd('git', ['apply', '--binary', '-'], {
|
|
681
|
+
cwd: workDir,
|
|
682
|
+
input: patch,
|
|
683
|
+
timeoutMs,
|
|
684
|
+
});
|
|
685
|
+
if (applied.code === 0) return applied;
|
|
686
|
+
return runCmd('git', ['apply', '--3way', '-'], { cwd: workDir, input: patch, timeoutMs });
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export async function executeAdapter(
|
|
690
|
+
adapter,
|
|
691
|
+
workDir,
|
|
692
|
+
task,
|
|
693
|
+
{ vaultRoot, model, maxTurns = DEFAULT_MAX_TURNS, env, timeoutMs, slotDir, promptPrefix = '' },
|
|
694
|
+
) {
|
|
695
|
+
if (adapter.id === 'noop') {
|
|
696
|
+
return {
|
|
697
|
+
...parseAdapterUsage(adapter, ''),
|
|
698
|
+
transcript: '',
|
|
699
|
+
agent: { exitCode: 0, timedOut: false, wallMs: 0 },
|
|
700
|
+
};
|
|
701
|
+
}
|
|
662
702
|
if (adapter.id === 'gold') {
|
|
703
|
+
const startedAt = Date.now();
|
|
663
704
|
const goldPatch = await readFile(join(vaultRoot, task.id, 'gold.patch'));
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
return { ...parseAdapterUsage(adapter, ''), transcript: '' };
|
|
705
|
+
await applyPatch(workDir, goldPatch, { timeoutMs });
|
|
706
|
+
return {
|
|
707
|
+
...parseAdapterUsage(adapter, ''),
|
|
708
|
+
transcript: '',
|
|
709
|
+
agent: { exitCode: 0, timedOut: false, wallMs: Date.now() - startedAt },
|
|
710
|
+
};
|
|
671
711
|
}
|
|
672
712
|
|
|
673
713
|
const analyticsLog = join(slotDir, 'analytics.jsonl');
|
|
674
|
-
|
|
675
|
-
|
|
714
|
+
// The arm's context arrives as a prefix rather than a file the agent must be told to
|
|
715
|
+
// read, so the prompt an adapter receives is the whole thing it was given. A `gold`
|
|
716
|
+
// or `noop` run never reaches this line: it scripts the workspace instead.
|
|
717
|
+
const prompt = [String(promptPrefix ?? ''), String(task.prompt ?? '')]
|
|
718
|
+
.filter((part) => part.length > 0)
|
|
719
|
+
.join('\n\n');
|
|
720
|
+
const args = buildAdapterArgs(adapter, { prompt, turns: maxTurns, model, analyticsLog });
|
|
721
|
+
const outcome = await runCmd(adapter.bin, args, {
|
|
722
|
+
cwd: workDir,
|
|
723
|
+
env: agentEnv(adapter, { home: slotDir, extra: env }),
|
|
724
|
+
timeoutMs,
|
|
725
|
+
});
|
|
726
|
+
// A binary that could not start produced no run. Grading its empty workspace would
|
|
727
|
+
// record an unresolved arm instead of the missing harness it is (review 0003 Q47).
|
|
728
|
+
if (outcome.spawnError) {
|
|
729
|
+
throw new Error(`harness ${adapter.id} could not start ${adapter.bin}: ${outcome.spawnError}`);
|
|
730
|
+
}
|
|
676
731
|
const stdoutText = outcome.stdout.toString('utf8');
|
|
677
732
|
const analyticsText = adapter.format === 'analytics-log' ? await readFile(analyticsLog, 'utf8').catch(() => '') : '';
|
|
678
|
-
return {
|
|
733
|
+
return {
|
|
734
|
+
...parseAdapterUsage(adapter, stdoutText, analyticsText),
|
|
735
|
+
transcript: `${stdoutText}\n${analyticsText}`,
|
|
736
|
+
// A run's own exit code, deadline and wall time belong in its record: the old one
|
|
737
|
+
// stored none, so a timed-out run was indistinguishable from a completed one.
|
|
738
|
+
agent: { exitCode: outcome.code, timedOut: outcome.timedOut, wallMs: outcome.wallMs },
|
|
739
|
+
};
|
|
679
740
|
}
|
|
680
741
|
|
|
681
742
|
// loadTasks lives in tasks.mjs (the advice engine became a second reader, so the loader
|
|
@@ -718,9 +779,10 @@ export async function runBench(
|
|
|
718
779
|
maxTurns = DEFAULT_MAX_TURNS,
|
|
719
780
|
concurrency = DEFAULT_CONCURRENCY,
|
|
720
781
|
cwd = process.cwd(),
|
|
782
|
+
adapters: adapterTable,
|
|
721
783
|
} = {},
|
|
722
784
|
) {
|
|
723
|
-
const adapters = await loadAdapters();
|
|
785
|
+
const adapters = adapterTable ?? (await loadAdapters());
|
|
724
786
|
const adapter = adapters[harness];
|
|
725
787
|
if (!adapter) {
|
|
726
788
|
throw new Error(
|
|
@@ -747,6 +809,10 @@ export async function runBench(
|
|
|
747
809
|
}
|
|
748
810
|
|
|
749
811
|
const resolvedModel = model ?? (adapter.kind === 'builtin' ? adapter.id : 'default');
|
|
812
|
+
// A built-in harness scripts the workspace instead of driving an agent, so its
|
|
813
|
+
// numbers calibrate the grader and are never an arm's measurement (review 0003 Q49).
|
|
814
|
+
const measured = adapter.kind !== 'builtin';
|
|
815
|
+
const isolation = adapterIsolation(adapter);
|
|
750
816
|
const plannedRuns = [];
|
|
751
817
|
for (const armSpec of armSpecs) {
|
|
752
818
|
for (let rep = 1; rep <= reps; rep += 1) {
|
|
@@ -779,9 +845,11 @@ export async function runBench(
|
|
|
779
845
|
harness,
|
|
780
846
|
model: resolvedModel,
|
|
781
847
|
rep: item.rep,
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
848
|
+
measured,
|
|
849
|
+
isolation, status: 'planned',
|
|
850
|
+
resolved: null,
|
|
851
|
+
gatesPassed: null,
|
|
852
|
+
usage: emptyUsage().usage,
|
|
785
853
|
costUsd: null,
|
|
786
854
|
diff: { files: 0, outsideBlastRadius: 0 },
|
|
787
855
|
probe: null,
|
|
@@ -804,21 +872,25 @@ export async function runBench(
|
|
|
804
872
|
const parentDir = await mkdtemp(join(tmpdir(), 'bench-batch-'));
|
|
805
873
|
try {
|
|
806
874
|
const records = await mapPool(plannedRuns, concurrency, async (item) => {
|
|
807
|
-
|
|
808
|
-
root,
|
|
809
|
-
item.task.base,
|
|
810
|
-
item.armSpec,
|
|
811
|
-
item.task,
|
|
812
|
-
{ parentDir },
|
|
813
|
-
);
|
|
875
|
+
let slotDir = null;
|
|
814
876
|
try {
|
|
815
|
-
const
|
|
877
|
+
const prepared = await prepareIsolatedWorkspace(
|
|
878
|
+
root,
|
|
879
|
+
item.task.base,
|
|
880
|
+
item.armSpec,
|
|
881
|
+
item.task,
|
|
882
|
+
{ parentDir },
|
|
883
|
+
);
|
|
884
|
+
slotDir = prepared.slotDir;
|
|
885
|
+
const { workDir, initialHead, env, promptPrefix } = prepared;
|
|
886
|
+
const { usage, costUsd, transcript, agent } = await executeAdapter(adapter, workDir, item.task, {
|
|
816
887
|
vaultRoot,
|
|
817
888
|
model: resolvedModel,
|
|
818
889
|
maxTurns,
|
|
819
890
|
env,
|
|
820
891
|
timeoutMs,
|
|
821
892
|
slotDir,
|
|
893
|
+
promptPrefix,
|
|
822
894
|
});
|
|
823
895
|
|
|
824
896
|
const grade = await gradeWorkspace(workDir, item.task, {
|
|
@@ -841,8 +913,13 @@ export async function runBench(
|
|
|
841
913
|
harness,
|
|
842
914
|
model: resolvedModel,
|
|
843
915
|
rep: item.rep,
|
|
916
|
+
measured,
|
|
917
|
+
isolation,
|
|
918
|
+
status: 'ok',
|
|
919
|
+
agent,
|
|
844
920
|
resolved: grade.resolved,
|
|
845
921
|
gatesPassed: grade.gatesPassed,
|
|
922
|
+
f2pPassed: grade.f2pPassed ?? [],
|
|
846
923
|
usage,
|
|
847
924
|
costUsd,
|
|
848
925
|
diff: grade.diff,
|
|
@@ -857,8 +934,33 @@ export async function runBench(
|
|
|
857
934
|
]);
|
|
858
935
|
|
|
859
936
|
return { ...record, runId: item.runId };
|
|
937
|
+
} catch (error) {
|
|
938
|
+
// One failed run is one exclusion, not a lost batch: the old path exited 3 and
|
|
939
|
+
// left a half-written run behind (review 0003 Q47).
|
|
940
|
+
return {
|
|
941
|
+
schemaVersion: 1,
|
|
942
|
+
runId: item.runId,
|
|
943
|
+
task: item.task.id,
|
|
944
|
+
arm: item.armSpec.name,
|
|
945
|
+
harness,
|
|
946
|
+
model: resolvedModel,
|
|
947
|
+
rep: item.rep,
|
|
948
|
+
measured,
|
|
949
|
+
isolation,
|
|
950
|
+
status: 'error',
|
|
951
|
+
error: String(error?.message ?? error),
|
|
952
|
+
resolved: false,
|
|
953
|
+
gatesPassed: false,
|
|
954
|
+
f2pPassed: [],
|
|
955
|
+
usage: emptyUsage().usage,
|
|
956
|
+
costUsd: null,
|
|
957
|
+
diff: { files: 0, outsideBlastRadius: 0 },
|
|
958
|
+
probe: null,
|
|
959
|
+
};
|
|
860
960
|
} finally {
|
|
861
|
-
|
|
961
|
+
if (slotDir !== null) {
|
|
962
|
+
await rm(slotDir, { recursive: true, force: true }).catch(() => {});
|
|
963
|
+
}
|
|
862
964
|
}
|
|
863
965
|
});
|
|
864
966
|
|
|
@@ -877,6 +979,13 @@ export async function runBench(
|
|
|
877
979
|
}
|
|
878
980
|
}
|
|
879
981
|
|
|
982
|
+
/** Two string lists hold the same members, ignoring order and duplicates. */
|
|
983
|
+
function sameStringSet(a, b) {
|
|
984
|
+
const left = [...new Set(a)].sort();
|
|
985
|
+
const right = [...new Set(b)].sort();
|
|
986
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
987
|
+
}
|
|
988
|
+
|
|
880
989
|
async function resolveRegradeTargets(root, target) {
|
|
881
990
|
const runsBaseDir = join(root, LOCAL_DIR, 'bench', 'runs');
|
|
882
991
|
const direct = resolve(root, target);
|
|
@@ -935,7 +1044,7 @@ export async function regradeRun(
|
|
|
935
1044
|
}
|
|
936
1045
|
|
|
937
1046
|
const tasksDir = join(root, LOCAL_DIR, 'bench', 'runs');
|
|
938
|
-
const taskDefsDir = join(root,
|
|
1047
|
+
const taskDefsDir = join(root, STATE_DIR, 'bench', 'tasks');
|
|
939
1048
|
const parentDir = await mkdtemp(join(tmpdir(), 'bench-regrade-'));
|
|
940
1049
|
|
|
941
1050
|
try {
|
|
@@ -972,20 +1081,7 @@ export async function regradeRun(
|
|
|
972
1081
|
{ parentDir },
|
|
973
1082
|
);
|
|
974
1083
|
try {
|
|
975
|
-
|
|
976
|
-
const applyRes = await runCmd('git', ['apply', '--binary', '-'], {
|
|
977
|
-
cwd: workDir,
|
|
978
|
-
input: agentPatch,
|
|
979
|
-
timeoutMs,
|
|
980
|
-
});
|
|
981
|
-
if (applyRes.code !== 0) {
|
|
982
|
-
await runCmd('git', ['apply', '--3way', '-'], {
|
|
983
|
-
cwd: workDir,
|
|
984
|
-
input: agentPatch,
|
|
985
|
-
timeoutMs,
|
|
986
|
-
});
|
|
987
|
-
}
|
|
988
|
-
}
|
|
1084
|
+
await applyPatch(workDir, agentPatch, { timeoutMs });
|
|
989
1085
|
|
|
990
1086
|
const grade = await gradeWorkspace(workDir, task, {
|
|
991
1087
|
vaultRoot,
|
|
@@ -993,9 +1089,12 @@ export async function regradeRun(
|
|
|
993
1089
|
timeoutMs,
|
|
994
1090
|
});
|
|
995
1091
|
|
|
1092
|
+
// The verdicts alone cannot tell "the same tests passed" from "the same number
|
|
1093
|
+
// of tests passed"; the fail-to-pass set is compared too (review 0003 Q59).
|
|
996
1094
|
const reproduced =
|
|
997
1095
|
grade.resolved === stored.resolved &&
|
|
998
1096
|
grade.gatesPassed === stored.gatesPassed &&
|
|
1097
|
+
sameStringSet(grade.f2pPassed ?? [], stored.f2pPassed ?? []) &&
|
|
999
1098
|
grade.diff.files === stored.diff?.files &&
|
|
1000
1099
|
grade.diff.outsideBlastRadius === stored.diff?.outsideBlastRadius;
|
|
1001
1100
|
|
|
@@ -1003,6 +1102,7 @@ export async function regradeRun(
|
|
|
1003
1102
|
...stored,
|
|
1004
1103
|
resolved: grade.resolved,
|
|
1005
1104
|
gatesPassed: grade.gatesPassed,
|
|
1105
|
+
f2pPassed: grade.f2pPassed ?? [],
|
|
1006
1106
|
diff: grade.diff,
|
|
1007
1107
|
};
|
|
1008
1108
|
|
package/src/brief/assemble.mjs
CHANGED
|
@@ -34,7 +34,10 @@
|
|
|
34
34
|
// command and the tool leave it on. It is inert either way in a repository that declared no
|
|
35
35
|
// servers.
|
|
36
36
|
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
|
|
37
39
|
import { CLI, STATE_DIR } from '../name.mjs';
|
|
40
|
+
import { parse as parseRegion } from '../projection/markers.mjs';
|
|
38
41
|
import { DEFAULT_BRIEF_BYTES, buildPacket, collect } from './packet.mjs';
|
|
39
42
|
import { classifyTask, cardVerbs } from './kind.mjs';
|
|
40
43
|
import { briefAdvice } from '../advice/advice.mjs';
|
|
@@ -81,22 +84,38 @@ const MAP_FACTOR = 8;
|
|
|
81
84
|
* space is never part of a task's scope.
|
|
82
85
|
*/
|
|
83
86
|
|
|
84
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Every file git tracks that carries a generated region, one `git grep` away.
|
|
89
|
+
*
|
|
90
|
+
* A file that merely mentions the marker is not carrying a region: a rule file, a review
|
|
91
|
+
* and this test all quote `simt:begin`, and warning that they are generated sends a reader
|
|
92
|
+
* to edit a source file that has no generated bytes at all (review 0003 Q52). The grep is
|
|
93
|
+
* only the candidate list; the region parser decides.
|
|
94
|
+
*/
|
|
85
95
|
async function generatedRegions(root) {
|
|
86
96
|
const { execFile } = await import('node:child_process');
|
|
87
97
|
const { promisify } = await import('node:util');
|
|
98
|
+
const { readFile } = await import('node:fs/promises');
|
|
99
|
+
let candidates;
|
|
88
100
|
try {
|
|
89
101
|
const { stdout } = await promisify(execFile)(
|
|
90
102
|
'git',
|
|
91
103
|
['grep', '-lF', '--', `${'simt'}:begin`],
|
|
92
104
|
{ cwd: root, maxBuffer: 8 << 20 },
|
|
93
105
|
);
|
|
94
|
-
|
|
106
|
+
candidates = stdout.split('\n').filter((line) => line.length > 0).sort();
|
|
95
107
|
} catch {
|
|
96
108
|
// No match is exit 1, and a directory that is not a repository fails too. Both mean
|
|
97
109
|
// the same thing to a reader: no generated region was found to warn about.
|
|
98
110
|
return [];
|
|
99
111
|
}
|
|
112
|
+
const carrying = [];
|
|
113
|
+
for (const path of candidates) {
|
|
114
|
+
const text = await readFile(join(root, path), 'utf8').catch(() => null);
|
|
115
|
+
if (text === null) continue;
|
|
116
|
+
if (parseRegion(text, 'html').status === 'ok') carrying.push(path);
|
|
117
|
+
}
|
|
118
|
+
return carrying;
|
|
100
119
|
}
|
|
101
120
|
|
|
102
121
|
/**
|
|
@@ -113,6 +132,9 @@ async function generatedRegions(root) {
|
|
|
113
132
|
* @param {boolean} [options.build] whether an empty index may be built here
|
|
114
133
|
* @param {boolean} [options.federate] whether a declared external server may be asked one
|
|
115
134
|
* question; a delivered packet turns it off to keep its 300 ms bar
|
|
135
|
+
* @param {string[]} [options.commands] the dispatchable command paths (`COMMANDS`). A
|
|
136
|
+
* decision's `Affects:` token is a command only when the tool has that command, so a
|
|
137
|
+
* packet that does not know them reports every one of them as unresolved
|
|
116
138
|
* @returns a packet, or `{ error }` carrying the exit code a failure already reported, or
|
|
117
139
|
* `{ cold: true }` when the index is empty and this caller may not build one
|
|
118
140
|
*/
|
|
@@ -124,6 +146,7 @@ export async function assembleBrief({
|
|
|
124
146
|
flags = {},
|
|
125
147
|
build = true,
|
|
126
148
|
federate = true,
|
|
149
|
+
commands = [],
|
|
127
150
|
} = {}) {
|
|
128
151
|
const named = paths !== null;
|
|
129
152
|
const dirty = (await dirtyPaths(root)).filter((path) => !path.startsWith(`${STATE_DIR}/`));
|
|
@@ -187,6 +210,15 @@ export async function assembleBrief({
|
|
|
187
210
|
})
|
|
188
211
|
: null;
|
|
189
212
|
const ranked = rankFiles({ files, symbols, edges, fts, bm25, embeddings, queryVector, task });
|
|
213
|
+
// An empty match is a different answer from a weak one: the ranking is then the graph
|
|
214
|
+
// hub, which is what a reader who asked about `zebracorn` must not read as a hit
|
|
215
|
+
// (review 0003 Q52).
|
|
216
|
+
const matched = ranked.filter((row) => (row.score ?? 0) > 0).length;
|
|
217
|
+
if (task.trim().length > 0 && matched === 0) {
|
|
218
|
+
packet.add('CAUTIONS', 'matched 0: no file or symbol mentions this task, so FILES is the graph hub rather than a match', {
|
|
219
|
+
why: ['an answer to a graph-shaped question is not an answer to this task'],
|
|
220
|
+
});
|
|
221
|
+
}
|
|
190
222
|
const rendered = renderMap(ranked, symbols, { level: 'names', budget: budget > 0 ? budget * MAP_FACTOR : 0 });
|
|
191
223
|
for (const line of rendered.text.split('\n')) {
|
|
192
224
|
packet.add('FILES', line, { why: ['ranked by the task words against the import graph'] });
|
|
@@ -233,7 +265,10 @@ export async function assembleBrief({
|
|
|
233
265
|
const records = await decisionRecords(root);
|
|
234
266
|
const ctx = {
|
|
235
267
|
files: filePaths,
|
|
236
|
-
|
|
268
|
+
// The card's lines are shell commands (`npm run test`); a decision's Affects token is
|
|
269
|
+
// a command of this tool. Passing only the card's list made every `simt …` token
|
|
270
|
+
// unresolved — 47 false cautions on this repository (review 0003 Q52).
|
|
271
|
+
commands: new Set([...card.lines.map((line) => line.command), ...commands]),
|
|
237
272
|
roots: sourceRoots([...filePaths]),
|
|
238
273
|
};
|
|
239
274
|
const decisions = new Map();
|