simt-dev 0.2.2 → 0.3.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 +100 -0
- package/README.md +129 -75
- package/bin/simt-dev.mjs +21 -2
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
- package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
- package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
- package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
- package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
- package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
- package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
- package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
- package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
- package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
- package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
- package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
- package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
- package/package.json +2 -1
- package/rules/core.md +1 -1
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +54 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/ci/workflow.mjs +11 -1
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +371 -0
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/harness.mjs +156 -5
- package/src/commands/help.mjs +89 -1
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +60 -1
- package/src/commands/map.mjs +91 -0
- package/src/commands/plan.mjs +99 -0
- package/src/commands/repoindex.mjs +52 -0
- package/src/commands/shared.mjs +398 -0
- package/src/commands/sync.mjs +72 -3
- package/src/commands/tests.mjs +192 -0
- package/src/commands/tokens.mjs +61 -1
- package/src/commands/why.mjs +177 -0
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +1 -1
- package/src/hooks/dispatcher.mjs +15 -0
- package/src/hooks/shim.mjs +31 -0
- package/src/index/compact.mjs +228 -0
- package/src/index/extract.mjs +331 -0
- package/src/index/gate.mjs +101 -0
- package/src/index/govern.mjs +222 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +393 -0
- package/src/index/persist.mjs +172 -0
- package/src/index/store.mjs +301 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/ledger/index.mjs +112 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/server.mjs +37 -6
- package/src/mcp/tools.mjs +84 -0
- package/src/measure/context.mjs +184 -11
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/lock.mjs +93 -0
- package/src/projection/manifest.mjs +78 -3
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +452 -43
- package/src/projection/shared.mjs +428 -0
- package/src/projection/surfaces.mjs +22 -8
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +71 -0
- package/templates/plan/tracker.json +36 -0
package/src/ci/workflow.mjs
CHANGED
|
@@ -148,6 +148,16 @@ function uses(action) {
|
|
|
148
148
|
return ` - uses: ${repo}@${commit} # ${release}`;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/** This repository's own gate runs `npm test`, and two of those tests read
|
|
152
|
+
* history. A consumer's gate does not, so its checkout stays shallow. */
|
|
153
|
+
function checkoutFull() {
|
|
154
|
+
return `${uses('checkout')}
|
|
155
|
+
with:
|
|
156
|
+
# \`bench mine\` counts commits, and the Affects snapshot reads
|
|
157
|
+
# 4c27514. A depth-1 checkout fails both, and both run in \`npm test\`.
|
|
158
|
+
fetch-depth: 0`;
|
|
159
|
+
}
|
|
160
|
+
|
|
151
161
|
/** The YAML every copy shares, so the two cannot drift on triggers, permissions or
|
|
152
162
|
* concurrency. Only `name`, the runner and `steps` vary, and only because they have
|
|
153
163
|
* to. The own gate also varies the platforms and the Node versions, because this
|
|
@@ -246,7 +256,7 @@ export function renderWorkflow({ own = false, npm = true, version } = {}) {
|
|
|
246
256
|
return `${header(true)}${skeleton(
|
|
247
257
|
'simt-dev own gate',
|
|
248
258
|
[
|
|
249
|
-
|
|
259
|
+
checkoutFull(),
|
|
250
260
|
setupNode('${{ matrix.node }}'),
|
|
251
261
|
// The two checks `.githooks/pre-push` runs, named here rather than discovered,
|
|
252
262
|
// because a reader of this file has to be able to answer "what stops a merge"
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// simt-dev agent-hook <event> | agent-hooks install | agent-hooks status
|
|
2
|
+
//
|
|
3
|
+
// Three commands, one subject: the rules the tool projects, enforced while the agent is
|
|
4
|
+
// working rather than when git is committing. `agent-hook` is the entry point a harness
|
|
5
|
+
// calls; it reads one JSON payload on stdin and answers in that harness's dialect.
|
|
6
|
+
// `install` writes the entry into each harness's own config, and `status` reports what
|
|
7
|
+
// each harness will do and what still has to be trusted by hand.
|
|
8
|
+
//
|
|
9
|
+
// The entry point is on a hot path — it runs before every shell command an agent wants
|
|
10
|
+
// to run — so it imports the policy module, the decision, the renderer and nothing that
|
|
11
|
+
// walks the repository. A hook that measured the tree before answering would be exactly
|
|
12
|
+
// the latency the plan's acceptance criterion exists to prevent.
|
|
13
|
+
//
|
|
14
|
+
// A denial exits 2, which is what every harness in the table reads as "block". A mistake
|
|
15
|
+
// in the hook's own invocation must NOT exit 2: a typo in a config would then block every
|
|
16
|
+
// tool call. Failures of this command exit 1, which a harness reports as a failed hook,
|
|
17
|
+
// and that is the right blast radius for a hook that is broken rather than refusing.
|
|
18
|
+
|
|
19
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
23
|
+
import { CLI } from '../name.mjs';
|
|
24
|
+
import { ALLOW_ENV, EVENTS, POLICY_IDS } from '../agenthooks/policy.mjs';
|
|
25
|
+
import { decide, factsFor } from '../agenthooks/decide.mjs';
|
|
26
|
+
import {
|
|
27
|
+
adapter,
|
|
28
|
+
adapters,
|
|
29
|
+
decisionJson,
|
|
30
|
+
factsFrom,
|
|
31
|
+
hookPath,
|
|
32
|
+
hookRows,
|
|
33
|
+
installedIn,
|
|
34
|
+
rendered,
|
|
35
|
+
} from '../agenthooks/render.mjs';
|
|
36
|
+
|
|
37
|
+
export async function agentHookCommand(args, flags) {
|
|
38
|
+
const event = args[0];
|
|
39
|
+
if (!EVENTS.includes(event)) {
|
|
40
|
+
return fail(`unknown agent event \`${event ?? ''}\``, EXIT.FAIL, `known events: ${EVENTS.join(', ')}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const id = flags.harness ?? args[1];
|
|
44
|
+
const row = id ? adapter(id) : null;
|
|
45
|
+
if (row === null) {
|
|
46
|
+
return fail(
|
|
47
|
+
id === undefined ? `agent-hook needs --harness` : `unknown harness \`${id}\``,
|
|
48
|
+
EXIT.FAIL,
|
|
49
|
+
`--harness <id>, one of: ${adapters().map((a) => a.id).join(', ')}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { payload, empty, error } = await readPayload();
|
|
54
|
+
if (empty || error) {
|
|
55
|
+
// Nothing to check is not a violation. A harness that calls the hook with nothing on
|
|
56
|
+
// stdin, or with a payload we cannot parse, has not told us that anything is wrong;
|
|
57
|
+
// blocking there would stop real work on no evidence, which is how a hook gets
|
|
58
|
+
// uninstalled the same afternoon it was installed.
|
|
59
|
+
note(`note ${id}: ${empty ? 'no payload on stdin' : `payload is not JSON (${error})`}; nothing to check`);
|
|
60
|
+
return EXIT.OK;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { root, command, writes } = factsFrom(row, payload);
|
|
64
|
+
const facts = await factsFor(root, { command, writes });
|
|
65
|
+
const decision = decide({ event, facts, env: process.env });
|
|
66
|
+
|
|
67
|
+
// Both notes are about a conclusion the author is about to draw: a lift that did
|
|
68
|
+
// nothing because of a typo, and a lift that is the reason the command they expected to
|
|
69
|
+
// be stopped was not.
|
|
70
|
+
if (decision.unknown.length > 0) {
|
|
71
|
+
note(`note ${ALLOW_ENV} names no policy: ${decision.unknown.join(', ')}; known: ${POLICY_IDS.join(', ')}`);
|
|
72
|
+
}
|
|
73
|
+
if (decision.lifted.length > 0) {
|
|
74
|
+
note(`note allowed by ${ALLOW_ENV}: ${decision.lifted.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const json = decisionJson(row, decision);
|
|
78
|
+
if (json === null) return EXIT.OK;
|
|
79
|
+
|
|
80
|
+
emit(JSON.stringify(json));
|
|
81
|
+
for (const message of decision.messages) note(`blocked ${message}`);
|
|
82
|
+
return row.denyExit;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function agentHooksCommand(sub, args, flags) {
|
|
86
|
+
const root = flags.root ?? args[0] ?? process.cwd();
|
|
87
|
+
if (sub === 'install') return installHooks(root, flags);
|
|
88
|
+
if (sub === 'status') return hooksStatus(root, flags);
|
|
89
|
+
return fail(`unknown agent-hooks subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} agent-hooks status`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function installHooks(root, flags) {
|
|
93
|
+
const mode = writeMode(flags);
|
|
94
|
+
const plan = [];
|
|
95
|
+
for (const row of adapters()) {
|
|
96
|
+
const path = hookPath(row.id);
|
|
97
|
+
const existing = await readIfExists(join(root, path));
|
|
98
|
+
const result = rendered(row, existing);
|
|
99
|
+
plan.push({ id: row.id, path, action: result.error ? 'refused' : result.action, error: result.error, text: result.text });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The readers this tool does not write an entry for, reported rather than skipped in
|
|
103
|
+
// silence: a command that writes four files and says nothing about the fifth harness
|
|
104
|
+
// in the repository reads as "that one is covered too".
|
|
105
|
+
const others = [];
|
|
106
|
+
for (const r of hookRows()) {
|
|
107
|
+
if (r.path === null) others.push({ id: r.id, path: null, state: 'none', note: r.note });
|
|
108
|
+
else if (adapter(r.id) === null) others.push({ id: r.id, path: r.path, state: 'other', note: r.note });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const refused = plan.filter((p) => p.error);
|
|
112
|
+
|
|
113
|
+
if (!mode.apply) {
|
|
114
|
+
if (isJson(flags)) {
|
|
115
|
+
emitJson({
|
|
116
|
+
ok: refused.length === 0,
|
|
117
|
+
command: 'agent-hooks install',
|
|
118
|
+
root,
|
|
119
|
+
dryRun: true,
|
|
120
|
+
plan: plan.map(({ text, ...rest }) => rest),
|
|
121
|
+
notWritten: others,
|
|
122
|
+
filesWritten: false,
|
|
123
|
+
});
|
|
124
|
+
return refused.length > 0 ? EXIT.FAIL : EXIT.OK;
|
|
125
|
+
}
|
|
126
|
+
emit(
|
|
127
|
+
[
|
|
128
|
+
`repo ${root}`,
|
|
129
|
+
...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}${p.error ? ` (${p.error})` : ''}`),
|
|
130
|
+
...others.map((o) => `${o.state === 'none' ? 'NO FILE ' : 'NOT OURS'} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
|
|
131
|
+
'',
|
|
132
|
+
refused.length > 0
|
|
133
|
+
? `REFUSED ${refused.length} config file(s) could not be read; nothing written`
|
|
134
|
+
: `PLAN nothing written; apply with: ${CLI} agent-hooks install --yes`,
|
|
135
|
+
].join('\n'),
|
|
136
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
137
|
+
);
|
|
138
|
+
return refused.length > 0 ? EXIT.FAIL : EXIT.OK;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Refused beats partial: a config this tool cannot parse is one it must not rewrite,
|
|
142
|
+
// and writing the other three while one is unreadable leaves the repository in a state
|
|
143
|
+
// where the report it just printed is no longer true.
|
|
144
|
+
if (refused.length > 0) {
|
|
145
|
+
return fail(`refused to write ${refused.map((p) => `${p.path} (${p.error})`).join('; ')}`, EXIT.FAIL);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const written = [];
|
|
149
|
+
for (const p of plan.filter((x) => x.action !== 'unchanged')) {
|
|
150
|
+
await mkdir(join(root, dirname(p.path)), { recursive: true });
|
|
151
|
+
await writeFile(join(root, p.path), p.text);
|
|
152
|
+
written.push(p);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const notes = [];
|
|
156
|
+
for (const p of written) {
|
|
157
|
+
const trust = adapter(p.id).trust;
|
|
158
|
+
if (trust) notes.push(`${p.id}: ${trust}`);
|
|
159
|
+
}
|
|
160
|
+
if (written.length === 0) notes.push('every documented hooks file already carried this tool’s entry');
|
|
161
|
+
|
|
162
|
+
if (isJson(flags)) {
|
|
163
|
+
emitJson({
|
|
164
|
+
ok: true,
|
|
165
|
+
command: 'agent-hooks install',
|
|
166
|
+
root,
|
|
167
|
+
plan: plan.map(({ text, ...rest }) => rest),
|
|
168
|
+
notWritten: others,
|
|
169
|
+
written: written.map((p) => p.path),
|
|
170
|
+
filesWritten: true,
|
|
171
|
+
notes,
|
|
172
|
+
});
|
|
173
|
+
return EXIT.OK;
|
|
174
|
+
}
|
|
175
|
+
emit(
|
|
176
|
+
[
|
|
177
|
+
`repo ${root}`,
|
|
178
|
+
...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}`),
|
|
179
|
+
...others.map((o) => `${o.state === 'none' ? 'NO FILE ' : 'NOT OURS'} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
|
|
180
|
+
'',
|
|
181
|
+
`WROTE ${written.length} hooks file(s); each one runs ${CLI} agent-hook before the tool call`,
|
|
182
|
+
].join('\n'),
|
|
183
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
184
|
+
);
|
|
185
|
+
for (const o of others) note(`note ${o.id}: not written - ${o.note}`);
|
|
186
|
+
for (const n of notes) note(`note ${n}`);
|
|
187
|
+
note(`note never committed or pushed (ADR-001); review, then commit yourself`);
|
|
188
|
+
return EXIT.OK;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function hooksStatus(root, flags) {
|
|
192
|
+
const rows = [];
|
|
193
|
+
const problems = [];
|
|
194
|
+
|
|
195
|
+
for (const row of adapters()) {
|
|
196
|
+
const path = hookPath(row.id);
|
|
197
|
+
const existing = await readIfExists(join(root, path));
|
|
198
|
+
if (existing === null) {
|
|
199
|
+
rows.push({ id: row.id, path, state: 'absent', entries: 0, note: null });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
let config;
|
|
203
|
+
try {
|
|
204
|
+
config = JSON.parse(existing);
|
|
205
|
+
} catch (e) {
|
|
206
|
+
rows.push({ id: row.id, path, state: 'unreadable', entries: 0, note: e.message });
|
|
207
|
+
problems.push(`${path} is not valid JSON: ${e.message}`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
// A file that parses but is not an object cannot hold a hooks table, and `install`
|
|
211
|
+
// refuses to write into one. Status has to agree with it, or one question gets two
|
|
212
|
+
// answers and the quiet one is the one a reader acts on.
|
|
213
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
214
|
+
rows.push({ id: row.id, path, state: 'unreadable', entries: 0, note: 'not a JSON object' });
|
|
215
|
+
problems.push(`${path} is not a JSON object, so this tool will not write into it`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const found = installedIn(row, config).length;
|
|
219
|
+
rows.push({
|
|
220
|
+
id: row.id,
|
|
221
|
+
path,
|
|
222
|
+
state: found > 0 ? 'present' : 'absent',
|
|
223
|
+
entries: found,
|
|
224
|
+
note: found > 0 ? (row.trust ?? null) : null,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The readers whose hooks are not a file this tool can write — plugins, or a vendor
|
|
229
|
+
// page that documents none — are reported from the same table that documents them,
|
|
230
|
+
// rather than from a second list of the harnesses this command happens to know.
|
|
231
|
+
for (const r of hookRows()) {
|
|
232
|
+
if (r.path === null) rows.push({ id: r.id, path: null, state: 'none', entries: 0, note: r.note });
|
|
233
|
+
else if (adapter(r.id) === null) rows.push({ id: r.id, path: r.path, state: 'other', entries: 0, note: r.note });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (isJson(flags)) {
|
|
237
|
+
emitJson({ ok: problems.length === 0, command: 'agent-hooks status', root, harnesses: rows, problems });
|
|
238
|
+
return problems.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const lines = [`repo ${root}`];
|
|
242
|
+
for (const r of rows) {
|
|
243
|
+
const state = { present: 'PRESENT ', absent: 'ABSENT ', unreadable: 'INVALID ', none: 'NO FILE ', other: 'NOT OURS' }[r.state];
|
|
244
|
+
lines.push(`${state} ${r.id.padEnd(12)} ${r.path ?? '(no hooks file is documented)'}`);
|
|
245
|
+
if (r.state === 'absent') lines.push(` ${' '.repeat(12)} install: ${CLI} agent-hooks install --yes`);
|
|
246
|
+
if (r.note) lines.push(` ${' '.repeat(12)} ${r.note}`);
|
|
247
|
+
}
|
|
248
|
+
if (problems.length > 0) {
|
|
249
|
+
lines.push('', 'problems:');
|
|
250
|
+
for (const p of problems) lines.push(` ${p}`);
|
|
251
|
+
}
|
|
252
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
253
|
+
return problems.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function readIfExists(path) {
|
|
257
|
+
try {
|
|
258
|
+
return await readFile(path, 'utf8');
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Read the one payload the harness has already written to stdin.
|
|
266
|
+
*
|
|
267
|
+
* A terminal on stdin means a person ran this by hand, and reading would hang until they
|
|
268
|
+
* pressed Ctrl-D — so the isTTY check is the difference between a diagnostic and a
|
|
269
|
+
* command that looks like it has frozen.
|
|
270
|
+
*/
|
|
271
|
+
async function readPayload() {
|
|
272
|
+
if (process.stdin.isTTY) return { payload: {}, empty: true };
|
|
273
|
+
const chunks = [];
|
|
274
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
275
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
276
|
+
if (text === '') return { payload: {}, empty: true };
|
|
277
|
+
try {
|
|
278
|
+
return { payload: JSON.parse(text) };
|
|
279
|
+
} catch (e) {
|
|
280
|
+
return { payload: {}, error: e.message };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// `simt bench` - benchmark task mining (`bench mine`, R01), runner (`bench run`, R02)
|
|
2
|
+
// and statistical report (`bench report`, R03).
|
|
3
|
+
//
|
|
4
|
+
// Like every writer in this CLI, `bench mine` and `bench run` are previews by
|
|
5
|
+
// default and write artifacts to `.simt-dev/bench/` only when authorised with
|
|
6
|
+
// `--yes` (and not `--dry-run`). `bench report` is read-only.
|
|
7
|
+
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { CLI } from '../name.mjs';
|
|
11
|
+
import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
|
|
12
|
+
import { DEFAULT_MINE_TARGET, mineTasks } from '../bench/mine.mjs';
|
|
13
|
+
import { regradeRun, runBench } from '../bench/run.mjs';
|
|
14
|
+
import { buildBenchReport } from '../bench/report.mjs';
|
|
15
|
+
import { formatRecall, measureRecall } from '../index/map.mjs';
|
|
16
|
+
import { ablateBench } from '../bench/ablate.mjs';
|
|
17
|
+
import { runBenchProbes } from '../bench/probes.mjs';
|
|
18
|
+
import { listFlag } from './skills.mjs';
|
|
19
|
+
|
|
20
|
+
export async function benchMineCommand(positional, flags) {
|
|
21
|
+
const root = resolve(flags.root ?? positional[0] ?? process.cwd());
|
|
22
|
+
const { dryRun, apply } = writeMode(flags);
|
|
23
|
+
|
|
24
|
+
const result = await mineTasks(root, {
|
|
25
|
+
apply,
|
|
26
|
+
target: DEFAULT_MINE_TARGET,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!result.ok) {
|
|
30
|
+
return fail(
|
|
31
|
+
`${root}: ${result.error}`,
|
|
32
|
+
EXIT.CONFIG,
|
|
33
|
+
`point --root at a git repository and rerun: ${CLI} bench mine --root <path> --yes`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (isJson(flags)) {
|
|
38
|
+
emitJson({
|
|
39
|
+
ok: true,
|
|
40
|
+
command: 'bench mine',
|
|
41
|
+
root: result.root,
|
|
42
|
+
dryRun,
|
|
43
|
+
applied: result.applied,
|
|
44
|
+
candidates: result.candidatesCount,
|
|
45
|
+
tasksDir: result.tasksDir,
|
|
46
|
+
vaultRoot: result.vaultRoot,
|
|
47
|
+
tasks: result.tasks,
|
|
48
|
+
});
|
|
49
|
+
return EXIT.OK;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lines = [
|
|
53
|
+
`bench mine ${result.root}`,
|
|
54
|
+
`mode ${result.applied ? 'applied' : dryRun ? 'dry-run (writes nothing)' : 'preview (pass --yes to validate and write)'}`,
|
|
55
|
+
`commits ${result.candidatesCount} candidate(s) touch both test and implementation paths`,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
if (result.applied) {
|
|
59
|
+
lines.push(`mined ${result.tasks.length} verified task(s)`);
|
|
60
|
+
lines.push(`tasks ${result.tasksDir}`);
|
|
61
|
+
lines.push(`vault ${result.vaultRoot}`);
|
|
62
|
+
for (const task of result.tasks) {
|
|
63
|
+
const subject = task.prompt.split('\n')[0];
|
|
64
|
+
lines.push(
|
|
65
|
+
`task ${task.id} f2p=${task.f2p.length} p2p=${task.f2p.length} files=${task.gold.files} +${task.gold.linesAdded}/-${task.gold.linesRemoved} ${subject}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
lines.push(`preview ${result.tasks.length} candidate(s) queued for RED/GOLD worktree validation`);
|
|
70
|
+
for (const item of result.tasks) {
|
|
71
|
+
lines.push(`queue ${item.id} tests=${item.testFiles.length} impl=${item.implFiles.length} ${item.subject}`);
|
|
72
|
+
}
|
|
73
|
+
lines.push(`remedy run: ${CLI} bench mine --yes`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
77
|
+
return EXIT.OK;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function benchRunCommand(positional, flags) {
|
|
81
|
+
const root = resolve(flags.root ?? positional[0] ?? process.cwd());
|
|
82
|
+
const { dryRun, apply } = writeMode(flags);
|
|
83
|
+
|
|
84
|
+
if (flags.regrade !== undefined) {
|
|
85
|
+
let result;
|
|
86
|
+
try {
|
|
87
|
+
result = await regradeRun(root, flags.regrade, { apply });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return fail(
|
|
90
|
+
`${root}: ${error.message ?? error}`,
|
|
91
|
+
EXIT.CONFIG,
|
|
92
|
+
`pass a stored run name under .simt-dev/bench/runs/: ${CLI} bench run --regrade <run>`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const code = result.allReproduced ? EXIT.OK : EXIT.DRIFT;
|
|
97
|
+
if (isJson(flags)) {
|
|
98
|
+
emitJson({
|
|
99
|
+
ok: code === EXIT.OK,
|
|
100
|
+
command: 'bench run',
|
|
101
|
+
mode: 'regrade',
|
|
102
|
+
root: result.root,
|
|
103
|
+
target: result.target,
|
|
104
|
+
dryRun,
|
|
105
|
+
applied: result.applied,
|
|
106
|
+
regraded: result.regraded,
|
|
107
|
+
reproducedCount: result.reproducedCount,
|
|
108
|
+
allReproduced: result.allReproduced,
|
|
109
|
+
runs: result.runs,
|
|
110
|
+
});
|
|
111
|
+
return code;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const lines = [
|
|
115
|
+
`bench regrade ${result.target}`,
|
|
116
|
+
`mode ${result.applied ? 'applied' : 'verify (pass --yes to rewrite stored grades)'}`,
|
|
117
|
+
`summary ${result.reproducedCount}/${result.regraded} reproduced exactly`,
|
|
118
|
+
];
|
|
119
|
+
for (const run of result.runs) {
|
|
120
|
+
lines.push(
|
|
121
|
+
`run ${run.runId}/${run.task} arm=${run.arm} resolved=${run.resolved} gates=${run.gatesPassed} reproduced=${run.reproduced}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
125
|
+
return code;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const arms = listFlag(flags.arm);
|
|
129
|
+
const tasks = listFlag(flags.task);
|
|
130
|
+
const harness = flags.harness ?? 'gold';
|
|
131
|
+
const model = flags.model;
|
|
132
|
+
const reps = flags.reps ?? 3;
|
|
133
|
+
|
|
134
|
+
let result;
|
|
135
|
+
try {
|
|
136
|
+
result = await runBench(root, {
|
|
137
|
+
apply,
|
|
138
|
+
arms: arms.length > 0 ? arms : ['current'],
|
|
139
|
+
harness,
|
|
140
|
+
model,
|
|
141
|
+
reps,
|
|
142
|
+
tasks,
|
|
143
|
+
});
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return fail(
|
|
146
|
+
`${root}: ${error.message ?? error}`,
|
|
147
|
+
EXIT.CONFIG,
|
|
148
|
+
`mine tasks first with \`${CLI} bench mine --yes\` or check --arm / --harness flags`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (isJson(flags)) {
|
|
153
|
+
emitJson({
|
|
154
|
+
ok: true,
|
|
155
|
+
command: 'bench run',
|
|
156
|
+
root: result.root,
|
|
157
|
+
dryRun,
|
|
158
|
+
applied: result.applied,
|
|
159
|
+
harness: result.harness,
|
|
160
|
+
model: result.model,
|
|
161
|
+
arms: result.arms,
|
|
162
|
+
reps: result.reps,
|
|
163
|
+
taskCount: result.taskCount,
|
|
164
|
+
runs: result.runs,
|
|
165
|
+
});
|
|
166
|
+
return EXIT.OK;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const lines = [
|
|
170
|
+
`bench run ${result.root}`,
|
|
171
|
+
`mode ${result.applied ? 'applied' : dryRun ? 'dry-run (writes nothing)' : 'preview (pass --yes to execute and write runs)'}`,
|
|
172
|
+
`harness ${result.harness} (model: ${result.model})`,
|
|
173
|
+
`arms ${result.arms.join(', ')} (reps: ${result.reps}, tasks: ${result.taskCount})`,
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
if (result.applied) {
|
|
177
|
+
const resolvedCount = result.runs.filter((r) => r.resolved).length;
|
|
178
|
+
const gatesCount = result.runs.filter((r) => r.gatesPassed).length;
|
|
179
|
+
lines.push(`summary resolved=${resolvedCount}/${result.runs.length} gates=${gatesCount}/${result.runs.length}`);
|
|
180
|
+
for (const run of result.runs) {
|
|
181
|
+
lines.push(
|
|
182
|
+
`run ${run.runId}/${run.task} arm=${run.arm} rep=${run.rep} resolved=${run.resolved} gates=${run.gatesPassed} files=${run.diff.files} outside=${run.diff.outsideBlastRadius}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
lines.push(`planned ${result.runs.length} isolated task run(s)`);
|
|
187
|
+
lines.push(`remedy run: ${CLI} bench run --yes`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
191
|
+
return EXIT.OK;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function benchReportCommand(positional, flags) {
|
|
195
|
+
const compareItems = listFlag(flags.compare);
|
|
196
|
+
const pos = [...positional];
|
|
197
|
+
if (compareItems.length === 1 && pos.length >= 1) {
|
|
198
|
+
compareItems.push(pos.shift());
|
|
199
|
+
}
|
|
200
|
+
if (flags.compare !== undefined && compareItems.length !== 2) {
|
|
201
|
+
return fail(
|
|
202
|
+
'--compare requires two arm names: --compare <arm-a> <arm-b>',
|
|
203
|
+
EXIT.USAGE,
|
|
204
|
+
`pass two arms to compare: ${CLI} bench report --compare bare current`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const root = resolve(flags.root ?? pos[0] ?? process.cwd());
|
|
209
|
+
const arms = listFlag(flags.arm);
|
|
210
|
+
|
|
211
|
+
let report;
|
|
212
|
+
try {
|
|
213
|
+
report = await buildBenchReport(root, {
|
|
214
|
+
arms,
|
|
215
|
+
harness: flags.harness,
|
|
216
|
+
model: flags.model,
|
|
217
|
+
compare: compareItems.length === 2 ? compareItems : null,
|
|
218
|
+
});
|
|
219
|
+
} catch (error) {
|
|
220
|
+
return fail(
|
|
221
|
+
`${root}: ${error.message ?? error}`,
|
|
222
|
+
EXIT.CONFIG,
|
|
223
|
+
`run benchmark arms first with \`${CLI} bench run --yes\` or fix .simt-dev/bench/prices.json`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const mapRecall = await recallOrUnmeasured(root);
|
|
228
|
+
|
|
229
|
+
if (isJson(flags)) {
|
|
230
|
+
emitJson({
|
|
231
|
+
ok: true,
|
|
232
|
+
command: 'bench report',
|
|
233
|
+
root: report.root,
|
|
234
|
+
pricesLoaded: report.pricesLoaded,
|
|
235
|
+
runCount: report.runCount,
|
|
236
|
+
arms: report.arms,
|
|
237
|
+
comparison: report.comparison,
|
|
238
|
+
mapRecall,
|
|
239
|
+
});
|
|
240
|
+
return EXIT.OK;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
emit(`${report.markdown}\n\n### Map\n\n${formatRecall(mapRecall)}`, { maxBytes: flags.maxBytes ?? 0 });
|
|
244
|
+
return EXIT.OK;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function recallOrUnmeasured(root) {
|
|
248
|
+
try {
|
|
249
|
+
return await measureRecall(root);
|
|
250
|
+
} catch {
|
|
251
|
+
return { measured: false, reason: 'not run', tasks: 0, missing: 0, recall: null };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function benchAblateCommand(positional, flags) {
|
|
256
|
+
const root = resolve(flags.root ?? positional[0] ?? process.cwd());
|
|
257
|
+
const { dryRun, apply } = writeMode(flags);
|
|
258
|
+
const harness = flags.harness ?? 'gold';
|
|
259
|
+
const model = flags.model;
|
|
260
|
+
const reps = flags.reps ?? 1;
|
|
261
|
+
|
|
262
|
+
let result;
|
|
263
|
+
try {
|
|
264
|
+
result = await ablateBench(root, {
|
|
265
|
+
apply,
|
|
266
|
+
harness,
|
|
267
|
+
model,
|
|
268
|
+
reps,
|
|
269
|
+
includeAll: flags.all === true,
|
|
270
|
+
});
|
|
271
|
+
} catch (error) {
|
|
272
|
+
return fail(
|
|
273
|
+
`${root}: ${error.message ?? error}`,
|
|
274
|
+
EXIT.CONFIG,
|
|
275
|
+
`mine tasks first with \`${CLI} bench mine --yes\` and rerun \`${CLI} bench ablate --yes\``,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (isJson(flags)) {
|
|
280
|
+
emitJson({
|
|
281
|
+
ok: true,
|
|
282
|
+
command: 'bench ablate',
|
|
283
|
+
root: result.root,
|
|
284
|
+
dryRun,
|
|
285
|
+
applied: result.applied,
|
|
286
|
+
pricesLoaded: result.pricesLoaded,
|
|
287
|
+
canarySize: result.canarySize,
|
|
288
|
+
canaryTasks: result.canaryTasks,
|
|
289
|
+
totalTasks: result.totalTasks,
|
|
290
|
+
sections: result.sections,
|
|
291
|
+
ranking: result.ranking,
|
|
292
|
+
deletionCandidates: result.deletionCandidates,
|
|
293
|
+
});
|
|
294
|
+
return EXIT.OK;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const lines = [
|
|
298
|
+
`bench ablate ${result.root}`,
|
|
299
|
+
`mode ${result.applied ? 'applied' : dryRun ? 'dry-run (writes nothing)' : 'preview / stored runs (pass --yes to execute canary and escalate)'}`,
|
|
300
|
+
`sections ${result.sections.length} (canary: ${result.canaryTasks}/${result.totalTasks} tasks)`,
|
|
301
|
+
`delete ${result.deletionCandidates.length > 0 ? result.deletionCandidates.join(', ') : '(none)'}`,
|
|
302
|
+
'',
|
|
303
|
+
result.markdown,
|
|
304
|
+
];
|
|
305
|
+
if (!result.applied) {
|
|
306
|
+
lines.push('');
|
|
307
|
+
lines.push(`remedy run: ${CLI} bench ablate --yes`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
311
|
+
return EXIT.OK;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function benchProbesCommand(positional, flags) {
|
|
315
|
+
const root = resolve(flags.root ?? positional[0] ?? process.cwd());
|
|
316
|
+
const { dryRun, apply } = writeMode(flags);
|
|
317
|
+
const arms = listFlag(flags.arm);
|
|
318
|
+
const probes = listFlag(flags.task);
|
|
319
|
+
const harness = flags.harness ?? 'gold';
|
|
320
|
+
const reps = flags.reps ?? 1;
|
|
321
|
+
|
|
322
|
+
let result;
|
|
323
|
+
try {
|
|
324
|
+
result = await runBenchProbes(root, {
|
|
325
|
+
apply,
|
|
326
|
+
arms: arms.length > 0 ? arms : ['bare', 'current'],
|
|
327
|
+
probes,
|
|
328
|
+
harness,
|
|
329
|
+
reps,
|
|
330
|
+
});
|
|
331
|
+
} catch (error) {
|
|
332
|
+
return fail(
|
|
333
|
+
`${root}: ${error.message ?? error}`,
|
|
334
|
+
EXIT.CONFIG,
|
|
335
|
+
`check --arm and --harness flags and rerun \`${CLI} bench probes --yes\``,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (isJson(flags)) {
|
|
340
|
+
emitJson({
|
|
341
|
+
ok: true,
|
|
342
|
+
command: 'bench probes',
|
|
343
|
+
root: result.root,
|
|
344
|
+
dryRun,
|
|
345
|
+
applied: result.applied,
|
|
346
|
+
probeCount: result.probeCount,
|
|
347
|
+
probes: result.probes,
|
|
348
|
+
arms: result.arms,
|
|
349
|
+
runs: result.runs,
|
|
350
|
+
});
|
|
351
|
+
return EXIT.OK;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const lines = [
|
|
355
|
+
`bench probes ${result.root}`,
|
|
356
|
+
`mode ${result.applied ? 'applied' : dryRun ? 'dry-run (writes nothing)' : 'preview / stored runs (pass --yes to execute probes)'}`,
|
|
357
|
+
`probes ${result.probeCount}`,
|
|
358
|
+
'',
|
|
359
|
+
result.markdown,
|
|
360
|
+
];
|
|
361
|
+
if (!result.applied) {
|
|
362
|
+
lines.push('');
|
|
363
|
+
lines.push(`remedy run: ${CLI} bench probes --yes`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
367
|
+
return EXIT.OK;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
|