simt-dev 0.7.0 → 0.8.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/CHANGELOG.md +191 -870
- package/README.md +188 -213
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
- package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.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/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
- package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
- package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
- package/ecosystems/bun.json +6 -0
- package/ecosystems/cargo.json +25 -0
- package/ecosystems/composer.json +6 -0
- package/ecosystems/dart.json +24 -0
- package/ecosystems/deno.json +21 -0
- package/ecosystems/devcontainers.json +6 -0
- package/ecosystems/docker-compose.json +6 -0
- package/ecosystems/docker.json +6 -0
- package/ecosystems/dotnet.json +25 -0
- package/ecosystems/elixir.json +23 -0
- package/ecosystems/gitsubmodule.json +6 -0
- package/ecosystems/go.json +25 -0
- package/ecosystems/gradle.json +13 -0
- package/ecosystems/maven.json +23 -0
- package/ecosystems/node.json +25 -0
- package/ecosystems/python.json +20 -0
- package/ecosystems/ruby.json +23 -0
- package/ecosystems/swift.json +25 -0
- package/ecosystems/terraform.json +6 -0
- package/ecosystems/uv.json +6 -0
- package/harnesses/antigravity.json +162 -0
- package/harnesses/claude.json +206 -0
- package/harnesses/codex.json +136 -0
- package/harnesses/copilot.json +88 -0
- package/harnesses/cursor.json +140 -0
- package/harnesses/deepseek.json +99 -0
- package/harnesses/gemini.json +122 -0
- package/harnesses/opencode.json +173 -0
- package/package.json +12 -2
- package/rules/core.md +7 -0
- package/rules/mdc/intake-questions.mdc +8 -0
- package/rules/mdc/slice-commit.mdc +57 -4
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/prompt.mjs +5 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agenthooks/shim.mjs +1 -1
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +31 -0
- package/src/argv.mjs +48 -3
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +163 -105
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +33 -73
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +121 -475
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +79 -1064
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +120 -962
- package/src/bench/run.mjs +185 -802
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +48 -15
- package/src/ci/dependabot.mjs +25 -55
- package/src/ci/workflow.mjs +30 -7
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +4 -13
- package/src/commands/agenthook.mjs +70 -40
- package/src/commands/bench.mjs +191 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +13 -14
- 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/docs.mjs +88 -0
- 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 +26 -25
- package/src/commands/graph.mjs +2 -3
- package/src/commands/harness.mjs +6 -5
- package/src/commands/help.mjs +168 -19
- package/src/commands/hooks.mjs +119 -49
- package/src/commands/ignore.mjs +13 -8
- package/src/commands/impact.mjs +8 -15
- package/src/commands/index.mjs +318 -193
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +124 -105
- package/src/commands/lease.mjs +73 -25
- package/src/commands/lessons.mjs +10 -9
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +24 -14
- package/src/commands/mcp.mjs +2 -18
- 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 +12 -11
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +37 -16
- package/src/commands/shared.mjs +14 -19
- package/src/commands/skills.mjs +33 -32
- package/src/commands/skillscompile.mjs +7 -8
- package/src/commands/skillsinstall.mjs +7 -6
- package/src/commands/slice.mjs +220 -74
- package/src/commands/spec.mjs +167 -0
- package/src/commands/status.mjs +27 -18
- package/src/commands/sync.mjs +27 -25
- package/src/commands/tests.mjs +18 -21
- package/src/commands/tokens.mjs +21 -26
- package/src/commands/undo.mjs +5 -15
- package/src/commands/upgrade.mjs +15 -7
- package/src/commands/why.mjs +15 -21
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/compare/row.mjs +0 -2
- package/src/context.mjs +64 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +3 -2
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +14 -1
- package/src/distill/engine.mjs +5 -1
- package/src/docs/commands.mjs +65 -0
- package/src/ecosystem/records.mjs +219 -0
- package/src/explain.mjs +31 -0
- package/src/gates/index.mjs +385 -0
- package/src/graph/compile.mjs +8 -4
- package/src/graph/load.mjs +6 -2
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +7 -5
- package/src/hooks/dispatcher.mjs +209 -60
- package/src/hooks/gates.mjs +10 -43
- package/src/hooks/resolve.mjs +4 -14
- package/src/hooks/shim.mjs +5 -2
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +31 -12
- package/src/index/coverage.mjs +58 -0
- package/src/index/extract.mjs +6 -16
- package/src/index/gate.mjs +1 -1
- package/src/index/govern.mjs +26 -9
- package/src/index/impact.mjs +4 -8
- package/src/index/map.mjs +14 -15
- package/src/index/open.mjs +173 -0
- package/src/index/persist.mjs +224 -57
- package/src/index/store.mjs +82 -39
- package/src/index/tests.mjs +2 -6
- package/src/journal.mjs +94 -20
- package/src/kernel/card.mjs +37 -63
- package/src/kernel/checks.mjs +4 -10
- package/src/lease.mjs +102 -7
- package/src/ledger/index.mjs +150 -14
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/resources.mjs +7 -10
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +119 -25
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -967
- package/src/measure/corpus.mjs +150 -0
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- package/src/measure/session.mjs +3 -2
- package/src/measure/stats.mjs +538 -0
- package/src/measure/task-schema.mjs +125 -0
- package/src/measure/tasks.mjs +69 -0
- package/src/memory/decide.mjs +6 -15
- package/src/memory/lesson-text.mjs +61 -0
- package/src/memory/lessons.mjs +29 -82
- package/src/name.mjs +7 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +209 -10
- package/src/packs/manage.mjs +32 -12
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +1 -1
- package/src/plugins.mjs +22 -16
- package/src/prbody.mjs +7 -11
- package/src/projection/loaders.mjs +7 -692
- package/src/projection/lock.mjs +11 -1
- package/src/projection/manifest.mjs +29 -468
- package/src/projection/mdc.mjs +7 -8
- package/src/projection/project.mjs +126 -221
- package/src/projection/region.mjs +561 -0
- package/src/projection/rules.mjs +20 -550
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +31 -0
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +19 -22
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +10 -13
- package/src/run.mjs +98 -0
- package/src/secrets.mjs +14 -22
- package/src/state/events.mjs +129 -0
- package/src/status-snapshot.mjs +233 -0
- package/src/status.mjs +2 -6
- package/src/sys/git.mjs +82 -0
- package/src/sys/proc.mjs +151 -0
- package/src/testing/parsers.mjs +265 -0
- package/src/testing/patches.mjs +23 -0
- package/src/testing/paths.mjs +49 -0
- package/src/testing/runners.mjs +186 -0
- package/src/testing/vault.mjs +68 -0
- package/src/text/frontmatter.mjs +49 -0
- package/src/text/tokens.mjs +52 -0
- package/src/toolwords.mjs +3 -0
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +58 -11
- package/src/write/plan.mjs +82 -0
- package/templates/plan/tracker.json +1 -0
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
- package/docs/decisions/README.md +0 -600
- package/src/bench/tasks.mjs +0 -36
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Experimental arms: what a run is measured against, and how it is materialised.
|
|
2
|
+
//
|
|
3
|
+
// An arm used to be a prefix-parsing chain followed by an if/else over names, so adding
|
|
4
|
+
// one meant editing the runner in two places and the second place was easy to miss. An
|
|
5
|
+
// arm kind is now a record in one registry — `match` recognises its spelling, `describe`
|
|
6
|
+
// says what it is in words a report can print, and `materialise` puts it in a workspace.
|
|
7
|
+
// The custom-arm JSON file is the data format the registry already stores, not a fourth
|
|
8
|
+
// branch beside three named arms (review 0003 Q78).
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { lstat, mkdir, readdir, readFile, rm, unlink, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { assembleBrief } from '../brief/assemble.mjs';
|
|
14
|
+
import { DEFAULT_BRIEF_BYTES } from '../brief/packet.mjs';
|
|
15
|
+
import { MANIFEST_FILE } from '../name.mjs';
|
|
16
|
+
import { readManifest } from '../projection/manifest.mjs';
|
|
17
|
+
import { plan } from '../projection/project.mjs';
|
|
18
|
+
import { parse as parseRegion } from '../projection/markers.mjs';
|
|
19
|
+
import { ablateSectionInWorkspace, parseAblationArmName } from './ablate.mjs';
|
|
20
|
+
import { applyVariantInWorkspace, parseVariantArmName } from './variants.mjs';
|
|
21
|
+
|
|
22
|
+
/** The arms every repository has without writing a file. */
|
|
23
|
+
export const BUILTIN_ARMS = new Set(['bare', 'current', 'kernel', 'brief']);
|
|
24
|
+
|
|
25
|
+
function assertContained(workDir, relPath) {
|
|
26
|
+
const target = resolve(workDir, relPath);
|
|
27
|
+
if (target !== workDir && !target.startsWith(`${workDir}${sep}`)) {
|
|
28
|
+
throw new Error(`arm path "${relPath}" escapes workspace`);
|
|
29
|
+
}
|
|
30
|
+
return target;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function stripRegionFile(filePath, style) {
|
|
34
|
+
let text;
|
|
35
|
+
try {
|
|
36
|
+
text = await readFile(filePath, 'utf8');
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
const state = parseRegion(text, style);
|
|
41
|
+
if (state.status !== 'ok') return false;
|
|
42
|
+
const remaining = `${state.before.trimEnd()}\n${state.after.trimStart()}`.trim();
|
|
43
|
+
if (!remaining) {
|
|
44
|
+
await unlink(filePath).catch(() => {});
|
|
45
|
+
} else {
|
|
46
|
+
await writeFile(filePath, `${remaining}\n`, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function stripBareArm(workDir) {
|
|
52
|
+
await stripRegionFile(join(workDir, 'AGENTS.md'), 'html');
|
|
53
|
+
await stripRegionFile(join(workDir, '.editorconfig'), 'hash');
|
|
54
|
+
await stripRegionFile(join(workDir, '.gitattributes'), 'hash');
|
|
55
|
+
|
|
56
|
+
const surfaceDirs = ['.cursor/rules', '.claude/rules', '.github/instructions', '.agents/rules'];
|
|
57
|
+
for (const relDir of surfaceDirs) {
|
|
58
|
+
const absDir = join(workDir, relDir);
|
|
59
|
+
const entries = await readdir(absDir).catch(() => []);
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const full = join(absDir, entry);
|
|
62
|
+
const text = await readFile(full, 'utf8').catch(() => null);
|
|
63
|
+
if (text !== null && parseRegion(text, 'html').status === 'ok') {
|
|
64
|
+
await unlink(full).catch(() => {});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const rel of ['.githooks', '.mcp.json', '.cursor/mcp.json', '.vscode/mcp.json', 'docs/lessons']) {
|
|
70
|
+
await rm(join(workDir, rel), { recursive: true, force: true }).catch(() => {});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!existsSync(join(workDir, 'AGENTS.md'))) {
|
|
74
|
+
for (const mirror of ['CLAUDE.md', 'GEMINI.md']) {
|
|
75
|
+
const full = join(workDir, mirror);
|
|
76
|
+
const stat = await lstat(full).catch(() => null);
|
|
77
|
+
if (stat?.isSymbolicLink()) {
|
|
78
|
+
await unlink(full).catch(() => {});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function applyProjectionMode(workDir, mode) {
|
|
85
|
+
const manifestRes = await readManifest(workDir);
|
|
86
|
+
const agentsText = await readFile(join(workDir, 'AGENTS.md'), 'utf8').catch(() => null);
|
|
87
|
+
const hasRegion = agentsText !== null && parseRegion(agentsText, 'html').status === 'ok';
|
|
88
|
+
|
|
89
|
+
// If the repository is neither adopted via manifest nor carrying a generated region,
|
|
90
|
+
// `current` leaves its hand-written files as-is, while `kernel` projects a kernel
|
|
91
|
+
// region only when the repo is adopted or already carries a region.
|
|
92
|
+
if (!manifestRes.exists && !hasRegion) return;
|
|
93
|
+
|
|
94
|
+
const manifestPath = manifestRes.path ?? join(workDir, MANIFEST_FILE);
|
|
95
|
+
const hadManifest = manifestRes.exists;
|
|
96
|
+
const originalManifestBytes = hadManifest ? await readFile(manifestPath, 'utf8').catch(() => null) : null;
|
|
97
|
+
|
|
98
|
+
const baseManifest = manifestRes.data ?? {
|
|
99
|
+
schemaVersion: 1,
|
|
100
|
+
projection: { sha: '0000000000000000000000000000000000000000', sources: [] },
|
|
101
|
+
gates: { preCommit: [], prePush: [] },
|
|
102
|
+
};
|
|
103
|
+
const nextManifest = { ...baseManifest };
|
|
104
|
+
if (mode === 'kernel') nextManifest.mode = 'kernel';
|
|
105
|
+
else delete nextManifest.mode;
|
|
106
|
+
|
|
107
|
+
await writeFile(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
|
|
108
|
+
|
|
109
|
+
const p = await plan(workDir);
|
|
110
|
+
for (const target of p.targets ?? []) {
|
|
111
|
+
if (target.action !== 'unchanged' && typeof target.text === 'string') {
|
|
112
|
+
await mkdir(dirname(target.path), { recursive: true });
|
|
113
|
+
await writeFile(target.path, target.text, 'utf8');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!hadManifest) {
|
|
118
|
+
await unlink(manifestPath).catch(() => {});
|
|
119
|
+
} else if (originalManifestBytes !== null && mode !== 'kernel') {
|
|
120
|
+
await writeFile(manifestPath, originalManifestBytes, 'utf8');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The `brief` arm's context: the packet `simt brief` delivers, assembled by the same
|
|
126
|
+
* function the command and the prompt-submit hook call.
|
|
127
|
+
*
|
|
128
|
+
* The arm used to re-implement the packet from `registry.skills` — a property the
|
|
129
|
+
* registry does not have, so every run reported "(none matched)" while the real command
|
|
130
|
+
* resolved skills — and wrote it to a file no agent was told to read. An arm meant to
|
|
131
|
+
* measure the packet measured a different artefact (review 0003 Q49).
|
|
132
|
+
*/
|
|
133
|
+
async function buildBriefPrefix(workDir, task) {
|
|
134
|
+
// A packet is built from git's answer; a directory that is not a repository has none,
|
|
135
|
+
// and asking would print a failure into a bench log for no measurement.
|
|
136
|
+
if (!existsSync(join(workDir, '.git'))) return '';
|
|
137
|
+
const assembled = await assembleBrief({
|
|
138
|
+
root: workDir,
|
|
139
|
+
task: String(task?.prompt ?? ''),
|
|
140
|
+
paths: '',
|
|
141
|
+
budget: DEFAULT_BRIEF_BYTES,
|
|
142
|
+
build: true,
|
|
143
|
+
federate: false,
|
|
144
|
+
}).catch(() => null);
|
|
145
|
+
if (!assembled || assembled.error || assembled.cold) return '';
|
|
146
|
+
return assembled.packed?.text ?? '';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The built-in projections, by name. A table rather than a branch: `describe` is what a
|
|
151
|
+
* report prints and what a reader can check, and adding a projection is one entry.
|
|
152
|
+
*/
|
|
153
|
+
const ARM_TRANSFORMS = {
|
|
154
|
+
bare: {
|
|
155
|
+
describe: 'the repository with every projected artefact removed',
|
|
156
|
+
run: (workDir) => stripBareArm(workDir),
|
|
157
|
+
},
|
|
158
|
+
current: {
|
|
159
|
+
describe: 'the repository as `simt init` leaves it',
|
|
160
|
+
run: (workDir) => applyProjectionMode(workDir, 'full'),
|
|
161
|
+
},
|
|
162
|
+
kernel: {
|
|
163
|
+
describe: 'the current projection, kernel class only',
|
|
164
|
+
run: (workDir) => applyProjectionMode(workDir, 'kernel'),
|
|
165
|
+
},
|
|
166
|
+
brief: {
|
|
167
|
+
describe: 'the current projection with the `simt brief` packet prepended to the prompt',
|
|
168
|
+
run: (workDir, task) => buildBriefPrefix(workDir, task),
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
async function parseCustomArm(spec, cwd) {
|
|
173
|
+
const eq = spec.indexOf('=');
|
|
174
|
+
if (eq === -1) return null;
|
|
175
|
+
const name = spec.slice(0, eq).trim();
|
|
176
|
+
const relPath = spec.slice(eq + 1).trim();
|
|
177
|
+
if (!name || !/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
178
|
+
throw new Error(`invalid custom arm name "${name}"`);
|
|
179
|
+
}
|
|
180
|
+
if (!relPath) {
|
|
181
|
+
throw new Error(`custom arm "${name}" is missing a JSON file path`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const fullPath = resolve(cwd, relPath);
|
|
185
|
+
let data;
|
|
186
|
+
try {
|
|
187
|
+
data = JSON.parse(await readFile(fullPath, 'utf8'));
|
|
188
|
+
} catch (error) {
|
|
189
|
+
throw new Error(`could not read arm file ${relPath}: ${error.message ?? error}`);
|
|
190
|
+
}
|
|
191
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
192
|
+
throw new Error(`arm file ${relPath} must contain a JSON object`);
|
|
193
|
+
}
|
|
194
|
+
if (data.base !== undefined && data.base !== null && !BUILTIN_ARMS.has(data.base)) {
|
|
195
|
+
throw new Error(`arm file ${relPath} has unknown base "${data.base}"`);
|
|
196
|
+
}
|
|
197
|
+
if (data.write !== undefined && (typeof data.write !== 'object' || data.write === null || Array.isArray(data.write))) {
|
|
198
|
+
throw new Error(`arm file ${relPath} field "write" must be an object mapping paths to strings`);
|
|
199
|
+
}
|
|
200
|
+
if (data.remove !== undefined && !Array.isArray(data.remove)) {
|
|
201
|
+
throw new Error(`arm file ${relPath} field "remove" must be an array of paths`);
|
|
202
|
+
}
|
|
203
|
+
if (data.env !== undefined && (typeof data.env !== 'object' || data.env === null || Array.isArray(data.env))) {
|
|
204
|
+
throw new Error(`arm file ${relPath} field "env" must be an object mapping env keys to strings`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const write = {};
|
|
208
|
+
for (const [key, value] of Object.entries(data.write ?? {})) write[key] = String(value);
|
|
209
|
+
const remove = (data.remove ?? []).map((path) => String(path));
|
|
210
|
+
const env = {};
|
|
211
|
+
for (const [key, value] of Object.entries(data.env ?? {})) env[key] = String(value);
|
|
212
|
+
|
|
213
|
+
return { name, base: data.base ?? null, source: relPath, write, remove, env };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The arm kinds, in match order. The first kind that recognises a spelling owns it.
|
|
218
|
+
*
|
|
219
|
+
* `match` answers the arm's own fields (or `null`), and `materialise` puts it in a
|
|
220
|
+
* workspace and answers the prompt prefix it contributes. A kind that changes files but
|
|
221
|
+
* says nothing in a prompt returns no prefix.
|
|
222
|
+
*/
|
|
223
|
+
const ARM_KINDS = [
|
|
224
|
+
{
|
|
225
|
+
kind: 'ablation',
|
|
226
|
+
match: (spec) => parseAblationArmName(spec),
|
|
227
|
+
describe: (arm) => `the current projection without the \`${arm.ablate?.section ?? 'named'}\` section`,
|
|
228
|
+
materialise: (workDir, arm) => ablateSectionInWorkspace(workDir, arm.ablate),
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
kind: 'variant',
|
|
232
|
+
match: (spec) => parseVariantArmName(spec),
|
|
233
|
+
describe: (arm) =>
|
|
234
|
+
`rule slot \`${arm.variant?.slot ?? 'named'}\` set to \`${arm.variant?.variant ?? 'named'}\``,
|
|
235
|
+
materialise: (workDir, arm) => applyVariantInWorkspace(workDir, arm.variant),
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
kind: 'builtin',
|
|
239
|
+
match: (spec) => (BUILTIN_ARMS.has(spec) ? { name: spec, base: spec } : null),
|
|
240
|
+
describe: (arm) => ARM_TRANSFORMS[arm.base].describe,
|
|
241
|
+
materialise: (workDir, arm, task) => ARM_TRANSFORMS[arm.base].run(workDir, task),
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
kind: 'custom',
|
|
245
|
+
match: parseCustomArm,
|
|
246
|
+
describe: (arm) => `a custom arm from ${arm.source ?? 'a JSON file'}`,
|
|
247
|
+
materialise: (workDir, arm, task) =>
|
|
248
|
+
arm.base ? ARM_TRANSFORMS[arm.base].run(workDir, task) : '',
|
|
249
|
+
},
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Parse an `--arm` specification into the arm it names.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} rawSpec `bare`, `current`, `kernel`, `brief`, an ablation or variant
|
|
256
|
+
* name, or `name=path/to/arm.json`
|
|
257
|
+
* @param {string} [cwd]
|
|
258
|
+
* @returns {Promise<object>} `{name, kind, base, write, remove, env}`
|
|
259
|
+
*/
|
|
260
|
+
export async function parseArmSpec(rawSpec, cwd = process.cwd()) {
|
|
261
|
+
const spec = String(rawSpec ?? '').trim();
|
|
262
|
+
if (!spec) throw new Error('empty --arm specification');
|
|
263
|
+
|
|
264
|
+
for (const entry of ARM_KINDS) {
|
|
265
|
+
const matched = await entry.match(spec, cwd);
|
|
266
|
+
if (!matched) continue;
|
|
267
|
+
return {
|
|
268
|
+
name: matched.name,
|
|
269
|
+
kind: entry.kind,
|
|
270
|
+
base: matched.base ?? null,
|
|
271
|
+
...(matched.ablate ? { ablate: matched.ablate } : {}),
|
|
272
|
+
...(matched.variant ? { variant: matched.variant } : {}),
|
|
273
|
+
...(matched.source ? { source: matched.source } : {}),
|
|
274
|
+
write: matched.write ?? {},
|
|
275
|
+
remove: matched.remove ?? [],
|
|
276
|
+
env: matched.env ?? {},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
throw new Error(
|
|
281
|
+
`unknown arm "${spec}"; built-in arms are: ${[...BUILTIN_ARMS].join(', ')} (or pass --arm name=path/to/arm.json)`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* What an arm is, in words a report can print.
|
|
287
|
+
*
|
|
288
|
+
* @param {object} armSpec
|
|
289
|
+
* @returns {string}
|
|
290
|
+
*/
|
|
291
|
+
export function describeArm(armSpec) {
|
|
292
|
+
const entry = ARM_KINDS.find((candidate) => candidate.kind === armSpec?.kind);
|
|
293
|
+
if (!entry) return 'an arm this version does not know how to describe';
|
|
294
|
+
return entry.describe(armSpec);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Materialise an experimental arm in `workDir`.
|
|
299
|
+
*
|
|
300
|
+
* @param {string} workDir
|
|
301
|
+
* @param {object} armSpec
|
|
302
|
+
* @param {object} [task]
|
|
303
|
+
* @returns {Promise<{env: Record<string, string>, promptPrefix: string}>}
|
|
304
|
+
*/
|
|
305
|
+
export async function materialiseArm(workDir, armSpec, task = {}) {
|
|
306
|
+
// A spec read from a stored `.arm.json`, or passed by a caller that only knows the
|
|
307
|
+
// arm's name, names no kind. Inferring it here keeps one code path: every arm is a
|
|
308
|
+
// registry entry, and a legacy spec is resolved into one rather than branched around.
|
|
309
|
+
const base = armSpec.base ?? (BUILTIN_ARMS.has(armSpec.name) ? armSpec.name : null);
|
|
310
|
+
const entry =
|
|
311
|
+
ARM_KINDS.find((candidate) => candidate.kind === armSpec.kind) ??
|
|
312
|
+
(armSpec.ablate
|
|
313
|
+
? ARM_KINDS.find((candidate) => candidate.kind === 'ablation')
|
|
314
|
+
: armSpec.variant
|
|
315
|
+
? ARM_KINDS.find((candidate) => candidate.kind === 'variant')
|
|
316
|
+
: base
|
|
317
|
+
? ARM_KINDS.find((candidate) => candidate.kind === 'builtin')
|
|
318
|
+
: ARM_KINDS.find((candidate) => candidate.kind === 'custom'));
|
|
319
|
+
const prefix = await entry.materialise(workDir, { ...armSpec, base }, task);
|
|
320
|
+
|
|
321
|
+
for (const relPath of armSpec.remove ?? []) {
|
|
322
|
+
const target = assertContained(workDir, relPath);
|
|
323
|
+
await rm(target, { recursive: true, force: true }).catch(() => {});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
for (const [relPath, content] of Object.entries(armSpec.write ?? {})) {
|
|
327
|
+
const target = assertContained(workDir, relPath);
|
|
328
|
+
await mkdir(dirname(target), { recursive: true });
|
|
329
|
+
await writeFile(target, String(content), 'utf8');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { env: { ...(armSpec.env ?? {}) }, promptPrefix: typeof prefix === 'string' ? prefix : '' };
|
|
333
|
+
}
|
package/src/bench/grade.mjs
CHANGED
|
@@ -1,80 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readFile, rm } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
|
|
5
5
|
import { runGates } from '../hooks/dispatcher.mjs';
|
|
6
6
|
import { packageVersion } from '../package-version.mjs';
|
|
7
7
|
import { readManifest } from '../projection/manifest.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import { registerOracle } from './graders.mjs';
|
|
9
|
+
import { cleanupReports, readReports } from '../testing/parsers.mjs';
|
|
10
|
+
import { extractPatchFiles } from '../testing/patches.mjs';
|
|
11
|
+
import { detectTestRunner } from '../testing/runners.mjs';
|
|
12
|
+
import { sanitizedEnv, spawnBounded } from '../sys/proc.mjs';
|
|
9
13
|
|
|
10
14
|
const DEFAULT_GRADE_TIMEOUT_MS = 45_000;
|
|
11
15
|
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
for (const key of Object.keys(env)) {
|
|
15
|
-
if (key.startsWith('NODE_TEST')) delete env[key];
|
|
16
|
-
}
|
|
17
|
-
return env;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function runCmd(cmd, args, { cwd, input = null, timeoutMs = DEFAULT_GRADE_TIMEOUT_MS } = {}) {
|
|
21
|
-
return new Promise((resolveOutcome) => {
|
|
22
|
-
const child = spawn(cmd, args, {
|
|
23
|
-
cwd,
|
|
24
|
-
env: sanitizedEnv(),
|
|
25
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
26
|
-
});
|
|
27
|
-
const out = [];
|
|
28
|
-
const err = [];
|
|
29
|
-
let timedOut = false;
|
|
30
|
-
const timer =
|
|
31
|
-
timeoutMs > 0
|
|
32
|
-
? setTimeout(() => {
|
|
33
|
-
timedOut = true;
|
|
34
|
-
child.kill('SIGKILL');
|
|
35
|
-
}, timeoutMs)
|
|
36
|
-
: null;
|
|
37
|
-
|
|
38
|
-
child.stdout.on('data', (chunk) => out.push(chunk));
|
|
39
|
-
child.stderr.on('data', (chunk) => err.push(chunk));
|
|
40
|
-
child.on('error', (error) => {
|
|
41
|
-
if (timer) clearTimeout(timer);
|
|
42
|
-
resolveOutcome({
|
|
43
|
-
code: 1,
|
|
44
|
-
timedOut,
|
|
45
|
-
stdout: Buffer.concat(out),
|
|
46
|
-
stderr: Buffer.from(String(error.message ?? error)),
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
child.on('close', (code) => {
|
|
50
|
-
if (timer) clearTimeout(timer);
|
|
51
|
-
resolveOutcome({
|
|
52
|
-
code: timedOut ? 124 : (code ?? 1),
|
|
53
|
-
timedOut,
|
|
54
|
-
stdout: Buffer.concat(out),
|
|
55
|
-
stderr: Buffer.concat(err),
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
if (input !== null) child.stdin.end(input);
|
|
60
|
-
else child.stdin.end();
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Extract repository-relative file paths touched by a unified git diff.
|
|
66
|
-
*
|
|
67
|
-
* @param {Buffer|string} patch
|
|
68
|
-
* @returns {string[]}
|
|
69
|
-
*/
|
|
70
|
-
export function extractPatchFiles(patch) {
|
|
71
|
-
const text = Buffer.isBuffer(patch) ? patch.toString('utf8') : String(patch ?? '');
|
|
72
|
-
const files = new Set();
|
|
73
|
-
for (const line of text.split('\n')) {
|
|
74
|
-
const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
|
|
75
|
-
if (match) files.add(match[2]);
|
|
76
|
-
}
|
|
77
|
-
return [...files].sort();
|
|
16
|
+
function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_GRADE_TIMEOUT_MS } = {}) {
|
|
17
|
+
return spawnBounded(cmd, args, { cwd, env: env ?? sanitizedEnv(), input, timeoutMs });
|
|
78
18
|
}
|
|
79
19
|
|
|
80
20
|
async function resetFilesToHead(workDir, initialHead, files) {
|
|
@@ -136,8 +76,10 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
|
|
|
136
76
|
readFile(join(taskVaultDir, 'gold.patch')).catch(() => Buffer.alloc(0)),
|
|
137
77
|
]);
|
|
138
78
|
|
|
139
|
-
//
|
|
140
|
-
//
|
|
79
|
+
// Intent-to-add every untracked file so a file an agent created is part of the diff:
|
|
80
|
+
// `git diff` reports tracked changes only and would not count a new file at all.
|
|
81
|
+
// Nothing is removed — an arm's untracked file is an edit outside the blast radius,
|
|
82
|
+
// which is what it is.
|
|
141
83
|
await runCmd('git', ['add', '-N', '.'], { cwd: workDir });
|
|
142
84
|
const [diffNamesRes, diffPatchRes] = await Promise.all([
|
|
143
85
|
runCmd('git', ['diff', '--name-only', initialHead], { cwd: workDir }),
|
|
@@ -173,6 +115,7 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
|
|
|
173
115
|
resolved: false,
|
|
174
116
|
gatesPassed: false,
|
|
175
117
|
diff,
|
|
118
|
+
f2pPassed: [],
|
|
176
119
|
goldFiles: [...goldFiles],
|
|
177
120
|
agentPatch: diffPatchRes.stdout,
|
|
178
121
|
};
|
|
@@ -184,17 +127,27 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
|
|
|
184
127
|
resolved: false,
|
|
185
128
|
gatesPassed: false,
|
|
186
129
|
diff,
|
|
130
|
+
f2pPassed: [],
|
|
187
131
|
goldFiles: [...goldFiles],
|
|
188
132
|
agentPatch: diffPatchRes.stdout,
|
|
189
133
|
};
|
|
190
134
|
}
|
|
191
135
|
|
|
192
136
|
const execSpec = runner.buildExec(workDir);
|
|
137
|
+
// Same stale-report hazard as the miner: a report left by a prior run in
|
|
138
|
+
// this workspace would be graded as this agent's result (review 0003 Q48).
|
|
139
|
+
for (const p of execSpec.reportPaths ?? []) {
|
|
140
|
+
if (existsSync(p)) await rm(p, { recursive: true, force: true });
|
|
141
|
+
}
|
|
193
142
|
const runRes = await runCmd(execSpec.cmd, execSpec.args, { cwd: workDir, timeoutMs });
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
143
|
+
// readReports walks the report *directories* Gradle and Maven write (one
|
|
144
|
+
// XML per test class); the old single-file read saw none of them, so the
|
|
145
|
+
// grader resolved on an empty parse.
|
|
146
|
+
const reportText = await readReports(execSpec.reportPaths);
|
|
197
147
|
const parsed = runner.parse(runRes.stdout.toString('utf8'), reportText);
|
|
148
|
+
// A report directed to the temporary directory belongs to this run; a report inside the
|
|
149
|
+
// workspace goes with the workspace (review 0003 Q79).
|
|
150
|
+
await cleanupReports(execSpec.reportPaths);
|
|
198
151
|
const testRun = {
|
|
199
152
|
code: runRes.code,
|
|
200
153
|
timedOut: runRes.timedOut,
|
|
@@ -226,7 +179,14 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
|
|
|
226
179
|
resolved,
|
|
227
180
|
gatesPassed,
|
|
228
181
|
diff,
|
|
182
|
+
// Which fail-to-pass tests actually passed. `resolved` is a boolean, so a regrade
|
|
183
|
+
// could only compare the verdict; the set is what a reader can check (review Q59).
|
|
184
|
+
f2pPassed: Array.isArray(task.f2p) ? task.f2p.filter((id) => passedSet.has(id)) : [],
|
|
229
185
|
goldFiles: [...goldFiles],
|
|
230
186
|
agentPatch: diffPatchRes.stdout,
|
|
231
187
|
};
|
|
232
188
|
}
|
|
189
|
+
|
|
190
|
+
// A task whose hidden test patch decides it declares the `tests` oracle; the registry is
|
|
191
|
+
// how the runner asks for that verdict without knowing this function exists.
|
|
192
|
+
registerOracle('tests', gradeWorkspace);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Oracle graders: how a task's declared oracle decides whether an attempt resolved it.
|
|
2
|
+
//
|
|
3
|
+
// `resolved` used to be computed by the only grader in the package, so a task could not
|
|
4
|
+
// state what would settle it and a second kind of task had nowhere to land (review 0003
|
|
5
|
+
// Q78). A grader is registered against an oracle type from `task-schema.mjs`; the runner
|
|
6
|
+
// asks the task's oracle, and an oracle type with no grader is refused by name rather
|
|
7
|
+
// than silently graded as if it were a test patch.
|
|
8
|
+
const ORACLE_GRADERS = new Map();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Register the grader for one oracle type.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} type the oracle type a task declares
|
|
14
|
+
* @param {(workDir: string, task: object, context: object) => Promise<object>} grade
|
|
15
|
+
*/
|
|
16
|
+
export function registerOracle(type, grade) {
|
|
17
|
+
ORACLE_GRADERS.set(String(type), grade);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The oracle types this process can grade, sorted. */
|
|
21
|
+
function oracleTypes() {
|
|
22
|
+
return [...ORACLE_GRADERS.keys()].sort();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Grade a finished workspace through the task's own oracle.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} workDir
|
|
29
|
+
* @param {object} task a normalised v2 task
|
|
30
|
+
* @param {object} context grader inputs (vault root, initial head, timeout)
|
|
31
|
+
* @returns {Promise<object>}
|
|
32
|
+
*/
|
|
33
|
+
export async function gradeWithOracle(workDir, task, context) {
|
|
34
|
+
const type = String(task?.oracle?.type ?? 'tests');
|
|
35
|
+
const grade = ORACLE_GRADERS.get(type);
|
|
36
|
+
if (!grade) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`no grader for oracle type "${type}"; this package grades: ${oracleTypes().join(', ') || '(none)'}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return grade(workDir, task, context);
|
|
42
|
+
}
|