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,466 @@
|
|
|
1
|
+
// How a benchmarked harness is driven, and how its output is read.
|
|
2
|
+
//
|
|
3
|
+
// Adapter v1 was a bag of fields with two behaviours compiled into the runner: the Gemini
|
|
4
|
+
// CLI's per-model token totals, and the event names that three JSONL streams happen to use
|
|
5
|
+
// for a turn and a tool call. Both are properties of one harness, so both belong in the
|
|
6
|
+
// adapter's own record and not in the runner (ADR-013). Adapter v2 declares prompt
|
|
7
|
+
// delivery, the environment allowlist, the timeout, the usage counters and the transcript
|
|
8
|
+
// sources; the runner reads that declaration and knows no harness by name.
|
|
9
|
+
//
|
|
10
|
+
// A v1 entry is normalised rather than refused: `args` becomes argv delivery, `format` and
|
|
11
|
+
// `fields` become the usage counter, and a bare `env` array becomes the allowlist.
|
|
12
|
+
import { readFile } from 'node:fs/promises';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
import { CLI } from '../name.mjs';
|
|
17
|
+
import { sanitizedEnv, spawnBounded } from '../sys/proc.mjs';
|
|
18
|
+
|
|
19
|
+
const BENCH_DIR = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const ADAPTERS_PATH = join(BENCH_DIR, 'adapters.json');
|
|
21
|
+
|
|
22
|
+
export const ADAPTER_SCHEMA_VERSION = 2;
|
|
23
|
+
export const DEFAULT_AGENT_TIMEOUT_MS = 120_000;
|
|
24
|
+
|
|
25
|
+
/** The isolation levels a run may record; `sandbox` is declared, not implemented. */
|
|
26
|
+
const ISOLATION_LEVELS = new Set(['none', 'env', 'sandbox']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What every benchmarked agent inherits from the operator's shell.
|
|
30
|
+
*
|
|
31
|
+
* `PATH` and the locale are what a CLI needs to run at all. Nothing here names a
|
|
32
|
+
* credential or a state directory: those come from the adapter's own `env.allow` list.
|
|
33
|
+
*/
|
|
34
|
+
const ENV_BASELINE = [
|
|
35
|
+
'PATH',
|
|
36
|
+
'LANG',
|
|
37
|
+
'LC_ALL',
|
|
38
|
+
'LC_CTYPE',
|
|
39
|
+
'TZ',
|
|
40
|
+
'TERM',
|
|
41
|
+
'SHELL',
|
|
42
|
+
'TMPDIR',
|
|
43
|
+
'TEMP',
|
|
44
|
+
'TMP',
|
|
45
|
+
'SystemRoot',
|
|
46
|
+
'WINDIR',
|
|
47
|
+
'USER',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
let cachedAdapters = null;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The event vocabulary adapter v1 compiled into the runner, as the contract a v1 entry
|
|
54
|
+
* was written against. It applies only to a record that declares no `usage.events`, so a
|
|
55
|
+
* v2 adapter decides its own counters and this table is never consulted for one.
|
|
56
|
+
*/
|
|
57
|
+
const V1_EVENT_DEFAULTS = {
|
|
58
|
+
turns: [{ type: 'turn.completed' }, { type: 'step_finish' }, { event: 'message_send' }],
|
|
59
|
+
toolCalls: [
|
|
60
|
+
{ type: 'tool_use' },
|
|
61
|
+
{ type: 'item.completed', 'item.type': ['command_execution', 'tool_call', 'mcp_tool_call'] },
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Bring one adapter entry to v2. Pure and total: an entry missing a section keeps the
|
|
67
|
+
* documented default rather than a half-filled field.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} raw a v1 or v2 adapter entry
|
|
70
|
+
* @returns {object} the v2 entry
|
|
71
|
+
*/
|
|
72
|
+
function normaliseAdapter(raw = {}) {
|
|
73
|
+
const id = String(raw.id ?? '');
|
|
74
|
+
const kind = raw.kind === 'builtin' || raw.kind === 'oracle' ? 'oracle' : 'agent';
|
|
75
|
+
const prompt = raw.prompt ?? {};
|
|
76
|
+
const env = raw.env ?? {};
|
|
77
|
+
const usage = raw.usage ?? {};
|
|
78
|
+
const allow = Array.isArray(env) ? env : Array.isArray(env?.allow) ? env.allow : [];
|
|
79
|
+
const timeoutMs = Number(raw.timeoutMs ?? prompt.timeoutMs);
|
|
80
|
+
const format = String(usage.format ?? raw.format ?? (kind === 'oracle' ? 'none' : 'json'));
|
|
81
|
+
return {
|
|
82
|
+
id,
|
|
83
|
+
label: String(raw.label ?? id),
|
|
84
|
+
kind,
|
|
85
|
+
bin: raw.bin ?? null,
|
|
86
|
+
oracle: kind === 'oracle' ? { from: raw.oracle?.from ?? (id === 'gold' ? 'vault' : 'none') } : null,
|
|
87
|
+
prompt: {
|
|
88
|
+
delivery: prompt.delivery ?? 'argv',
|
|
89
|
+
args: [...(prompt.args ?? raw.args ?? [])].map(String),
|
|
90
|
+
modelArgs: [...(prompt.modelArgs ?? raw.modelArgs ?? [])].map(String),
|
|
91
|
+
file: prompt.file ?? null,
|
|
92
|
+
},
|
|
93
|
+
env: {
|
|
94
|
+
allow: allow.map(String),
|
|
95
|
+
extra: { ...(env.extra ?? {}) },
|
|
96
|
+
},
|
|
97
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_AGENT_TIMEOUT_MS,
|
|
98
|
+
isolation: ISOLATION_LEVELS.has(raw.isolation) ? raw.isolation : 'none',
|
|
99
|
+
usage: {
|
|
100
|
+
format,
|
|
101
|
+
fields: { ...(usage.fields ?? raw.fields ?? {}) },
|
|
102
|
+
totals: [...(usage.totals ?? [])],
|
|
103
|
+
fixed: { ...(usage.fixed ?? {}) },
|
|
104
|
+
events: {
|
|
105
|
+
turns: [...(usage.events?.turns ?? V1_EVENT_DEFAULTS.turns)],
|
|
106
|
+
toolCalls: [...(usage.events?.toolCalls ?? V1_EVENT_DEFAULTS.toolCalls)],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
transcript: {
|
|
110
|
+
sources: [...(raw.transcript?.sources ?? (kind === 'oracle' ? [] : ['stdout']))].map(String),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Load the declarative harness adapter table (`src/bench/adapters.json`).
|
|
117
|
+
*
|
|
118
|
+
* @returns {Promise<Record<string, object>>}
|
|
119
|
+
*/
|
|
120
|
+
export async function loadAdapters() {
|
|
121
|
+
if (cachedAdapters) return cachedAdapters;
|
|
122
|
+
const raw = JSON.parse(await readFile(ADAPTERS_PATH, 'utf8'));
|
|
123
|
+
const table = {};
|
|
124
|
+
for (const [key, entry] of Object.entries(raw.adapters ?? {})) {
|
|
125
|
+
table[key] = normaliseAdapter({ id: key, ...entry });
|
|
126
|
+
}
|
|
127
|
+
cachedAdapters = table;
|
|
128
|
+
return cachedAdapters;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The environment an arm's agent sees: the baseline, the adapter's declared keys, and
|
|
133
|
+
* nothing else.
|
|
134
|
+
*
|
|
135
|
+
* The agent used to inherit everything the operator had — the vault directory, the cache
|
|
136
|
+
* root, `HOME`, and every credential in the shell. A run that can read the vault can read
|
|
137
|
+
* the hidden tests it is graded against, and a run sharing the operator's HOME writes into
|
|
138
|
+
* their own configuration, so the number it produced described the machine rather than the
|
|
139
|
+
* arm (review 0003 Q49).
|
|
140
|
+
*
|
|
141
|
+
* @param {object} adapter a v1 or v2 adapter entry
|
|
142
|
+
* @param {{home?: string, extra?: Record<string, string>, base?: NodeJS.ProcessEnv}} [options]
|
|
143
|
+
* @returns {Record<string, string>}
|
|
144
|
+
*/
|
|
145
|
+
export function agentEnv(adapter, { home, extra = {}, base = process.env } = {}) {
|
|
146
|
+
const declared = adapter?.env;
|
|
147
|
+
const allow = Array.isArray(declared) ? declared : (declared?.allow ?? []);
|
|
148
|
+
const allowed = new Set([...ENV_BASELINE, ...allow]);
|
|
149
|
+
const env = {};
|
|
150
|
+
for (const key of allowed) {
|
|
151
|
+
if (typeof base[key] === 'string') env[key] = base[key];
|
|
152
|
+
}
|
|
153
|
+
const adapterExtra = Array.isArray(declared) ? {} : (declared?.extra ?? {});
|
|
154
|
+
for (const [key, value] of Object.entries({ ...adapterExtra, ...extra })) {
|
|
155
|
+
if (typeof value === 'string') env[key] = value;
|
|
156
|
+
}
|
|
157
|
+
if (typeof home === 'string' && home.length > 0) env.HOME = home;
|
|
158
|
+
env.SIMT_DEV_WORKSPACE = 'off';
|
|
159
|
+
for (const key of Object.keys(env)) {
|
|
160
|
+
if (key.startsWith('NODE_TEST')) delete env[key];
|
|
161
|
+
}
|
|
162
|
+
return env;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The isolation an adapter's runs provide, as the record states it.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} adapter
|
|
169
|
+
* @returns {'none'|'env'|'sandbox'}
|
|
170
|
+
*/
|
|
171
|
+
export function adapterIsolation(adapter) {
|
|
172
|
+
const declared = adapter?.isolation;
|
|
173
|
+
return ISOLATION_LEVELS.has(declared) ? declared : 'none';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function getByPath(obj, pathStr) {
|
|
177
|
+
if (!obj || typeof obj !== 'object') return undefined;
|
|
178
|
+
let cur = obj;
|
|
179
|
+
for (const part of String(pathStr).split('.')) {
|
|
180
|
+
if (cur === null || cur === undefined || typeof cur !== 'object') return undefined;
|
|
181
|
+
cur = cur[part];
|
|
182
|
+
}
|
|
183
|
+
return cur;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function firstNumeric(obj, paths = []) {
|
|
187
|
+
for (const pathStr of paths) {
|
|
188
|
+
const value = getByPath(obj, pathStr);
|
|
189
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseJsonObject(text) {
|
|
195
|
+
const trimmed = String(text ?? '').trim();
|
|
196
|
+
if (!trimmed) return null;
|
|
197
|
+
try {
|
|
198
|
+
return JSON.parse(trimmed);
|
|
199
|
+
} catch {
|
|
200
|
+
const lines = trimmed.split('\n').reverse();
|
|
201
|
+
for (const line of lines) {
|
|
202
|
+
const candidate = line.trim();
|
|
203
|
+
if (candidate.startsWith('{') && candidate.endsWith('}')) {
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(candidate);
|
|
206
|
+
} catch {
|
|
207
|
+
// try an earlier line
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const start = trimmed.indexOf('{');
|
|
212
|
+
const end = trimmed.lastIndexOf('}');
|
|
213
|
+
if (start !== -1 && end > start) {
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(trimmed.slice(start, end + 1));
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A declared event condition: every named field equals the declared value (a list is any). */
|
|
225
|
+
function matchesWhen(event, when) {
|
|
226
|
+
const keys = Object.keys(when ?? {});
|
|
227
|
+
if (keys.length === 0) return false;
|
|
228
|
+
for (const key of keys) {
|
|
229
|
+
const got = getByPath(event, key);
|
|
230
|
+
const wanted = Array.isArray(when[key]) ? when[key] : [when[key]];
|
|
231
|
+
if (!wanted.includes(got)) return false;
|
|
232
|
+
}
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function emptyUsage() {
|
|
237
|
+
return {
|
|
238
|
+
usage: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, turns: 0, toolCalls: 0 },
|
|
239
|
+
costUsd: null,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Parse token usage, turns, tool calls and USD cost from a harness's output.
|
|
245
|
+
*
|
|
246
|
+
* The counter is the adapter's: a JSON object read through `usage.fields` with
|
|
247
|
+
* `usage.totals` summed over nested per-model blocks, or a JSONL stream whose turns and
|
|
248
|
+
* tool calls are the declared `usage.events` conditions. No harness is named here.
|
|
249
|
+
*
|
|
250
|
+
* @param {string|object} adapterOrId
|
|
251
|
+
* @param {string} stdoutText
|
|
252
|
+
* @param {string} [analyticsText]
|
|
253
|
+
* @returns {{usage: {input: number, cacheWrite: number, cacheRead: number, output: number, turns: number, toolCalls: number}, costUsd: number|null}}
|
|
254
|
+
*/
|
|
255
|
+
export function parseAdapterUsage(adapterOrId, stdoutText = '', analyticsText = '') {
|
|
256
|
+
const result = emptyUsage();
|
|
257
|
+
const adapter =
|
|
258
|
+
typeof adapterOrId === 'string'
|
|
259
|
+
? (cachedAdapters?.[adapterOrId] ?? normaliseAdapter({ id: adapterOrId }))
|
|
260
|
+
: normaliseAdapter(adapterOrId ?? { id: 'unknown' });
|
|
261
|
+
|
|
262
|
+
if (adapter.usage.format === 'none') {
|
|
263
|
+
for (const [metric, value] of Object.entries(adapter.usage.fixed ?? {})) {
|
|
264
|
+
if (metric in result.usage) result.usage[metric] = Number(value) || 0;
|
|
265
|
+
}
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const fields = adapter.usage.fields ?? {};
|
|
270
|
+
|
|
271
|
+
if (adapter.usage.format === 'json') {
|
|
272
|
+
const obj = parseJsonObject(stdoutText);
|
|
273
|
+
if (!obj || typeof obj !== 'object') return result;
|
|
274
|
+
|
|
275
|
+
result.usage.input = firstNumeric(obj, fields.input) ?? 0;
|
|
276
|
+
result.usage.cacheWrite = firstNumeric(obj, fields.cacheWrite) ?? 0;
|
|
277
|
+
result.usage.cacheRead = firstNumeric(obj, fields.cacheRead) ?? 0;
|
|
278
|
+
result.usage.output = firstNumeric(obj, fields.output) ?? 0;
|
|
279
|
+
result.usage.turns = firstNumeric(obj, fields.turns) ?? 0;
|
|
280
|
+
result.usage.toolCalls = firstNumeric(obj, fields.toolCalls) ?? 0;
|
|
281
|
+
result.costUsd = firstNumeric(obj, fields.costUsd) ?? null;
|
|
282
|
+
|
|
283
|
+
// A harness that nests its totals per model (Gemini's `stats.models.<name>.tokens`)
|
|
284
|
+
// declares that block; the counter sums it only where the flat field was absent.
|
|
285
|
+
for (const block of adapter.usage.totals ?? []) {
|
|
286
|
+
const group = getByPath(obj, block.root ?? '');
|
|
287
|
+
if (!group || typeof group !== 'object' || Array.isArray(group)) continue;
|
|
288
|
+
for (const [metric, key] of Object.entries(block.keys ?? {})) {
|
|
289
|
+
if (!(metric in result.usage) || result.usage[metric] !== 0) continue;
|
|
290
|
+
let sum = 0;
|
|
291
|
+
for (const entry of Object.values(group)) {
|
|
292
|
+
sum += Number(getByPath(entry, key) ?? 0) || 0;
|
|
293
|
+
}
|
|
294
|
+
if (sum !== 0) result.usage[metric] = sum;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const rawStream = adapter.usage.format === 'analytics-log' && analyticsText ? analyticsText : stdoutText;
|
|
301
|
+
const lines = String(rawStream ?? '')
|
|
302
|
+
.split('\n')
|
|
303
|
+
.map((line) => line.trim())
|
|
304
|
+
.filter(Boolean);
|
|
305
|
+
|
|
306
|
+
for (const line of lines) {
|
|
307
|
+
let event;
|
|
308
|
+
try {
|
|
309
|
+
event = JSON.parse(line);
|
|
310
|
+
} catch {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!event || typeof event !== 'object') continue;
|
|
314
|
+
|
|
315
|
+
const input = firstNumeric(event, fields.input);
|
|
316
|
+
const cacheWrite = firstNumeric(event, fields.cacheWrite);
|
|
317
|
+
const cacheRead = firstNumeric(event, fields.cacheRead);
|
|
318
|
+
const output = firstNumeric(event, fields.output);
|
|
319
|
+
const turns = firstNumeric(event, fields.turns);
|
|
320
|
+
const toolCalls = firstNumeric(event, fields.toolCalls);
|
|
321
|
+
const cost = firstNumeric(event, fields.costUsd);
|
|
322
|
+
|
|
323
|
+
if (input !== null) result.usage.input += input;
|
|
324
|
+
if (cacheWrite !== null) result.usage.cacheWrite += cacheWrite;
|
|
325
|
+
if (cacheRead !== null) result.usage.cacheRead += cacheRead;
|
|
326
|
+
if (output !== null) result.usage.output += output;
|
|
327
|
+
if (cost !== null) result.costUsd = (result.costUsd ?? 0) + cost;
|
|
328
|
+
|
|
329
|
+
if (turns !== null) {
|
|
330
|
+
result.usage.turns = Math.max(result.usage.turns, turns);
|
|
331
|
+
} else if (adapter.usage.events.turns.some((when) => matchesWhen(event, when))) {
|
|
332
|
+
result.usage.turns += 1;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (toolCalls !== null) {
|
|
336
|
+
result.usage.toolCalls = Math.max(result.usage.toolCalls, toolCalls);
|
|
337
|
+
} else if (adapter.usage.events.toolCalls.some((when) => matchesWhen(event, when))) {
|
|
338
|
+
result.usage.toolCalls += 1;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (result.costUsd !== null) result.costUsd = Number(result.costUsd.toFixed(6));
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_AGENT_TIMEOUT_MS } = {}) {
|
|
347
|
+
return spawnBounded(cmd, args, { cwd, env: sanitizedEnv(env), input, timeoutMs });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Apply a patch to a workspace, falling back to a three-way merge.
|
|
352
|
+
*
|
|
353
|
+
* The gold oracle and the regrade path land operator-supplied bytes the same way. They
|
|
354
|
+
* used to disagree (`--binary` on one side only), so a patch that applied under one read
|
|
355
|
+
* as unreproducible under the other (review 0003 Q59).
|
|
356
|
+
*
|
|
357
|
+
* @param {string} workDir
|
|
358
|
+
* @param {Buffer|string} patch
|
|
359
|
+
* @param {{timeoutMs?: number}} [options]
|
|
360
|
+
*/
|
|
361
|
+
export async function applyPatch(workDir, patch, { timeoutMs = DEFAULT_AGENT_TIMEOUT_MS } = {}) {
|
|
362
|
+
if (!patch || patch.length === 0) return { code: 0 };
|
|
363
|
+
const applied = await runCmd('git', ['apply', '--binary', '-'], { cwd: workDir, input: patch, timeoutMs });
|
|
364
|
+
if (applied.code === 0) return applied;
|
|
365
|
+
return runCmd('git', ['apply', '--3way', '-'], { cwd: workDir, input: patch, timeoutMs });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function buildAdapterArgs(adapter, { prompt, turns, model, analyticsLog, promptFile }) {
|
|
369
|
+
const replaceToken = (token) =>
|
|
370
|
+
String(token)
|
|
371
|
+
.replaceAll('{prompt}', prompt)
|
|
372
|
+
.replaceAll('{turns}', String(turns))
|
|
373
|
+
.replaceAll('{model}', String(model ?? ''))
|
|
374
|
+
.replaceAll('{analyticsLog}', String(analyticsLog ?? ''))
|
|
375
|
+
.replaceAll('{promptFile}', String(promptFile ?? ''));
|
|
376
|
+
|
|
377
|
+
const args = adapter.prompt.args.map(replaceToken);
|
|
378
|
+
if (model && model !== 'default' && model !== adapter.id && adapter.prompt.modelArgs.length > 0) {
|
|
379
|
+
args.push(...adapter.prompt.modelArgs.map(replaceToken));
|
|
380
|
+
}
|
|
381
|
+
return args;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Drive one adapter against one task in `workDir`.
|
|
386
|
+
*
|
|
387
|
+
* An oracle adapter scripts the workspace instead of asking an agent: `from: 'vault'`
|
|
388
|
+
* applies the task's reference patch, `from: 'none'` changes nothing. An agent adapter
|
|
389
|
+
* receives its prompt through the declared delivery — argv, stdin or a file.
|
|
390
|
+
*
|
|
391
|
+
* @param {object} adapter a v2 adapter entry
|
|
392
|
+
* @param {string} workDir
|
|
393
|
+
* @param {object} task
|
|
394
|
+
* @param {{vaultRoot: string, model?: string, maxTurns?: number, env?: object,
|
|
395
|
+
* timeoutMs?: number, slotDir: string, promptPrefix?: string}} options
|
|
396
|
+
* @returns {Promise<{usage: object, costUsd: number|null, transcript: string,
|
|
397
|
+
* agent: {exitCode: number, timedOut: boolean, wallMs: number}}>}
|
|
398
|
+
*/
|
|
399
|
+
export async function executeAdapter(
|
|
400
|
+
adapter,
|
|
401
|
+
workDir,
|
|
402
|
+
task,
|
|
403
|
+
{ vaultRoot, model, maxTurns = 25, env, timeoutMs, slotDir, promptPrefix = '' },
|
|
404
|
+
) {
|
|
405
|
+
// A caller may hand over a stored v1 entry or a partial stub; the runner reads one
|
|
406
|
+
// shape, so the normalisation happens at the boundary rather than in every branch.
|
|
407
|
+
adapter = normaliseAdapter(adapter ?? {});
|
|
408
|
+
const runTimeout = timeoutMs ?? adapter.timeoutMs;
|
|
409
|
+
if (adapter.kind === 'oracle') {
|
|
410
|
+
if (adapter.oracle?.from === 'vault') {
|
|
411
|
+
const startedAt = Date.now();
|
|
412
|
+
const goldPatch = await readFile(join(vaultRoot, task.id, 'gold.patch'));
|
|
413
|
+
await applyPatch(workDir, goldPatch, { timeoutMs: runTimeout });
|
|
414
|
+
return {
|
|
415
|
+
...parseAdapterUsage(adapter, ''),
|
|
416
|
+
transcript: '',
|
|
417
|
+
agent: { exitCode: 0, timedOut: false, wallMs: Date.now() - startedAt },
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
...parseAdapterUsage(adapter, ''),
|
|
422
|
+
transcript: '',
|
|
423
|
+
agent: { exitCode: 0, timedOut: false, wallMs: 0 },
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const analyticsLog = join(slotDir, 'analytics.jsonl');
|
|
428
|
+
// The arm's context arrives as a prefix rather than a file the agent must be told to
|
|
429
|
+
// read, so the prompt an adapter receives is the whole thing it was given.
|
|
430
|
+
const prompt = [String(promptPrefix ?? ''), String(task.prompt ?? '')]
|
|
431
|
+
.filter((part) => part.length > 0)
|
|
432
|
+
.join('\n\n');
|
|
433
|
+
const delivery = adapter.prompt.delivery;
|
|
434
|
+
const promptFile = delivery === 'file' ? join(workDir, adapter.prompt.file ?? `.-prompt.md`) : null;
|
|
435
|
+
if (promptFile !== null) {
|
|
436
|
+
const { writeFile } = await import('node:fs/promises');
|
|
437
|
+
await writeFile(promptFile, `${prompt}\n`, 'utf8');
|
|
438
|
+
}
|
|
439
|
+
const args = buildAdapterArgs(adapter, { prompt, turns: maxTurns, model, analyticsLog, promptFile });
|
|
440
|
+
const outcome = await runCmd(adapter.bin, args, {
|
|
441
|
+
cwd: workDir,
|
|
442
|
+
env: agentEnv(adapter, { home: slotDir, extra: env }),
|
|
443
|
+
input: delivery === 'stdin' ? `${prompt}\n` : null,
|
|
444
|
+
timeoutMs: runTimeout,
|
|
445
|
+
});
|
|
446
|
+
// A binary that could not start produced no run. Grading its empty workspace would
|
|
447
|
+
// record an unresolved arm instead of the missing harness it is (review 0003 Q47).
|
|
448
|
+
if (outcome.spawnError) {
|
|
449
|
+
throw new Error(`harness ${adapter.id} could not start ${adapter.bin}: ${outcome.spawnError}`);
|
|
450
|
+
}
|
|
451
|
+
const stdoutText = outcome.stdout.toString('utf8');
|
|
452
|
+
const analyticsText =
|
|
453
|
+
adapter.usage.format === 'analytics-log' ? await readFile(analyticsLog, 'utf8').catch(() => '') : '';
|
|
454
|
+
const sources = { stdout: stdoutText, stderr: outcome.stderr.toString('utf8'), analytics: analyticsText };
|
|
455
|
+
const transcript = adapter.transcript.sources
|
|
456
|
+
.map((source) => sources[source] ?? '')
|
|
457
|
+
.filter((text) => text.length > 0)
|
|
458
|
+
.join('\n');
|
|
459
|
+
return {
|
|
460
|
+
...parseAdapterUsage(adapter, stdoutText, analyticsText),
|
|
461
|
+
transcript,
|
|
462
|
+
// A run's own exit code, deadline and wall time belong in its record: the old one
|
|
463
|
+
// stored none, so a timed-out run was indistinguishable from a completed one.
|
|
464
|
+
agent: { exitCode: outcome.code, timedOut: outcome.timedOut, wallMs: outcome.wallMs },
|
|
465
|
+
};
|
|
466
|
+
}
|