simt-dev 0.3.1 → 0.5.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 +157 -0
- package/README.md +271 -62
- package/package.json +6 -2
- package/rules/core.md +1 -1
- package/src/advice/advice.mjs +229 -0
- package/src/agenthooks/policy.mjs +10 -2
- package/src/agenthooks/prompt.mjs +81 -0
- package/src/agenthooks/render.mjs +138 -17
- package/src/argv.mjs +28 -1
- package/src/bench/grade.mjs +3 -0
- package/src/bench/run.mjs +88 -54
- package/src/bench/tasks.mjs +38 -0
- package/src/bench/variants.mjs +456 -0
- package/src/brief/assemble.mjs +368 -0
- package/src/brief/kind.mjs +134 -0
- package/src/brief/packet.mjs +200 -0
- package/src/commands/agenthook.mjs +84 -5
- package/src/commands/bench.mjs +72 -15
- package/src/commands/brief.mjs +78 -0
- package/src/commands/compare.mjs +10 -2
- package/src/commands/decide.mjs +116 -0
- package/src/commands/distill.mjs +36 -0
- package/src/commands/help.mjs +62 -3
- package/src/commands/index.mjs +29 -1
- package/src/commands/lessons.mjs +127 -0
- package/src/commands/local.mjs +97 -0
- package/src/commands/map.mjs +42 -7
- package/src/commands/repoindex.mjs +4 -1
- package/src/commands/review.mjs +65 -0
- package/src/commands/rules.mjs +17 -2
- package/src/commands/skills.mjs +5 -50
- package/src/commands/skillscompile.mjs +255 -0
- package/src/commands/sync.mjs +15 -0
- package/src/commands/tests.mjs +2 -49
- package/src/commands/why.mjs +23 -25
- package/src/compare/learn.mjs +680 -0
- package/src/decisions/index.mjs +116 -65
- package/src/distill/distill.mjs +246 -0
- package/src/distill/engine.mjs +187 -0
- package/src/federation/client.mjs +188 -0
- package/src/federation/config.mjs +134 -0
- package/src/federation/query.mjs +48 -0
- package/src/graph/compile.mjs +475 -0
- package/src/index/gate.mjs +1 -13
- package/src/index/govern.mjs +23 -11
- package/src/index/map.mjs +251 -17
- package/src/index/persist.mjs +74 -7
- package/src/index/store.mjs +222 -8
- package/src/kernel/variants.mjs +353 -0
- package/src/ledger/index.mjs +36 -1
- package/src/local/client.mjs +482 -0
- package/src/local/config.mjs +269 -0
- package/src/mcp/tools.mjs +138 -1
- package/src/memory/decide.mjs +462 -0
- package/src/memory/lessons.mjs +858 -0
- package/src/projection/lock.mjs +39 -7
- package/src/projection/rules.mjs +32 -8
- package/src/resolve/body.mjs +46 -0
- package/src/review/critic.mjs +509 -0
- package/src/toolwords.mjs +6 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
// The local critic (plan 0003 R39, ADR-090, ADR-008).
|
|
2
|
+
//
|
|
3
|
+
// `simt review` builds the critic's brief — the staged diff plus everything that
|
|
4
|
+
// governs it (R25) — with no model and no socket. `simt review --local` is its first
|
|
5
|
+
// consumer: it sends the brief to the endpoint named in the manifest (R37) and asks
|
|
6
|
+
// for an ADR-008 verdict skeleton, then verifies the verdict vocabulary and every
|
|
7
|
+
// cited id against that brief before the reply becomes a proposal under
|
|
8
|
+
// `.simt-dev/reviews/`. A proposal a human never reads is a dead file, which is the
|
|
9
|
+
// intended failure mode (ADR-075, ADR-090).
|
|
10
|
+
//
|
|
11
|
+
// Nothing here writes outside `.simt-dev/reviews/`, and nothing here edits a source
|
|
12
|
+
// file — the model is a critic, not an author. The verdict is advisory by
|
|
13
|
+
// construction; no gate reads it. The row's own promotion rule names the test:
|
|
14
|
+
// precision at least 0.8 on a labelled set of historical diffs. Until
|
|
15
|
+
// `simt bench critic` measures that, every report says the gate has not been
|
|
16
|
+
// earned, the same honesty ADR-076 asked of R38's metric note.
|
|
17
|
+
|
|
18
|
+
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { execFile } from 'node:child_process';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
21
|
+
import { promisify } from 'node:util';
|
|
22
|
+
|
|
23
|
+
import { EXIT, fail } from '../output/index.mjs';
|
|
24
|
+
import { CLI } from '../name.mjs';
|
|
25
|
+
import { readLocalConfig, chatEndpoint } from '../local/config.mjs';
|
|
26
|
+
import { localChat } from '../local/client.mjs';
|
|
27
|
+
import { decisionRecords, loadCorpus, tracked } from '../commands/why.mjs';
|
|
28
|
+
import { decisionHits, scopeHits, sortHits, sourceRoots } from '../index/govern.mjs';
|
|
29
|
+
// `digestOf`, `promptHash` and `proposalFrontmatter` landed with R38's engine and
|
|
30
|
+
// the critic is their second real user; sharing them beats a third copy (ADR-002).
|
|
31
|
+
import { digestOf, promptHash, proposalFrontmatter } from '../distill/engine.mjs';
|
|
32
|
+
|
|
33
|
+
const exec = promisify(execFile);
|
|
34
|
+
|
|
35
|
+
// Locked on-disk beneath `.simt-dev`, like `.simt-dev/distilled` (R38) — a cache of
|
|
36
|
+
// proposals keyed by content digest, never projected and never committed.
|
|
37
|
+
const REVIEW_DIR = '.simt-dev/reviews';
|
|
38
|
+
const ENGINE_REVIEW_LOCAL = 'review-local';
|
|
39
|
+
|
|
40
|
+
/** The diff brief's byte cap; `--budget` overrides it, `0` means unlimited. */
|
|
41
|
+
export const DEFAULT_BUDGET = 8192;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The row-level acceptance metric, stated where a caller reads results (ADR-076):
|
|
45
|
+
* the critic is a gate only at precision >= 0.8 on >= 30 labelled diffs, which no
|
|
46
|
+
* bench here has measured yet, so nothing advertises the gate.
|
|
47
|
+
*/
|
|
48
|
+
const METRIC_NOTE = 'advisory only; gate requires precision >= 0.8 on >= 30 labelled diffs, unmeasured (plan 0003 R39)';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The system prompt (ENGINE_REVIEW_LOCAL only). ADR-008's payload is in the user
|
|
52
|
+
* message, next to the diff it governs — the model gets the vocabulary and the
|
|
53
|
+
* diff in one read, not from two memories.
|
|
54
|
+
*/
|
|
55
|
+
const REVIEW_SYSTEM = 'You critique a staged diff for a software agent. Follow the rules the user message states exactly.';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One governing entry in the prompt, `id detail`. Ids are the citation currency:
|
|
59
|
+
* the reply may cite only these, and the verifier strips anything else.
|
|
60
|
+
*/
|
|
61
|
+
function briefLine(item) {
|
|
62
|
+
return `${item.id} ${item.detail}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the critic prompt: the diff (capped), then what governs it.
|
|
67
|
+
*
|
|
68
|
+
* The diff comes last among truncations and the truncation is said out loud in
|
|
69
|
+
* ADR-008's terms — an honest reviewer of a partial diff ends at
|
|
70
|
+
* `APPROVE WITH RESIDUAL RISK`, never at `APPROVE`, because blocks they could not
|
|
71
|
+
* see say nothing either way.
|
|
72
|
+
*/
|
|
73
|
+
export function buildReviewPrompt(diff, items, budget) {
|
|
74
|
+
const diffBytes = Buffer.byteLength(diff, 'utf8');
|
|
75
|
+
const cap = budget > 0 ? budget : diffBytes;
|
|
76
|
+
const head = diffBytes > cap ? diff.slice(0, cap) : diff;
|
|
77
|
+
const truncated = diffBytes > cap;
|
|
78
|
+
return [
|
|
79
|
+
'Review the staged diff below as a critic. Reply with the skeleton only — no preamble.',
|
|
80
|
+
'',
|
|
81
|
+
"Verdict vocabulary (ADR-008), three values exactly:",
|
|
82
|
+
'- BLOCK — a concrete defect, missing mandatory gate, partial test pass, or missing test delta; each finding pairs with a file:line or command reference from the diff.',
|
|
83
|
+
'- APPROVE WITH RESIDUAL RISK — no blocker found; the findings MUST list what was not reviewed.',
|
|
84
|
+
'- APPROVE — fully reviewed, no blocker.',
|
|
85
|
+
'',
|
|
86
|
+
'Reply format, nothing else:',
|
|
87
|
+
'verdict: <one of the three values>',
|
|
88
|
+
'- <finding>',
|
|
89
|
+
'- ...more findings',
|
|
90
|
+
'',
|
|
91
|
+
'Cite governance by writing the id prefixed with @, e.g. @ADR-001, and only ids listed under WHAT GOVERNS THIS CHANGE. An id not in that list cannot govern the change and the verifier names it a fabrication.',
|
|
92
|
+
truncated
|
|
93
|
+
? `NOTE: the diff is truncated to ${cap} bytes; treat the omitted remainder as not reviewed.`
|
|
94
|
+
: '',
|
|
95
|
+
'',
|
|
96
|
+
'WHAT GOVERNS THIS CHANGE',
|
|
97
|
+
...items.map((item) => `- ${item.id} ${item.detail}`),
|
|
98
|
+
'',
|
|
99
|
+
'STAGED DIFF',
|
|
100
|
+
head.endsWith('\n') ? head : `${head}\n`,
|
|
101
|
+
]
|
|
102
|
+
.filter((line) => line !== null)
|
|
103
|
+
.join('\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Everything a staged diff is governed by (R25), one entry per id.
|
|
108
|
+
*
|
|
109
|
+
* ADR-008 is always in the brief because the verdict cites the vocabulary itself
|
|
110
|
+
* even when no Affects line names the diff — that is one record, looked up by id,
|
|
111
|
+
* not a hardcoded title, so its own wording travels with it.
|
|
112
|
+
*/
|
|
113
|
+
async function governingItems(root, files, commands = []) {
|
|
114
|
+
const [records, corpus] = await Promise.all([decisionRecords(root), loadCorpus(root)]);
|
|
115
|
+
const fileSet = new Set(files);
|
|
116
|
+
const ctx = { files: fileSet, commands: new Set(commands), roots: sourceRoots(files) };
|
|
117
|
+
const items = new Map();
|
|
118
|
+
for (const file of files) {
|
|
119
|
+
const { hits } = decisionHits(records, file, ctx);
|
|
120
|
+
for (const hit of sortHits([...hits, ...scopeHits(file, corpus)])) {
|
|
121
|
+
if (!items.has(hit.id)) items.set(hit.id, hit);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const vocabulary = records.find((record) => record.id === 'ADR-008');
|
|
125
|
+
if (vocabulary && !items.has('ADR-008')) {
|
|
126
|
+
items.set('ADR-008', { kind: 'decision', id: 'ADR-008', detail: vocabulary.title });
|
|
127
|
+
}
|
|
128
|
+
return [...items.values()];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** ADR-008's three verdicts, as the reply must name them. */
|
|
132
|
+
export const VERDICTS = ['BLOCK', 'APPROVE', 'APPROVE WITH RESIDUAL RISK'];
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Normalize one verdict line's value. Same three values ADR-008 adopted; the
|
|
136
|
+
* reply may vary in case or inner whitespace, in nothing else.
|
|
137
|
+
*/
|
|
138
|
+
export function parseVerdictValue(raw) {
|
|
139
|
+
const verbatim = String(raw).trim().replace(/\s+/g, ' ').toUpperCase();
|
|
140
|
+
return VERDICTS.includes(verbatim) ? verbatim : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Verify a model reply into a verdict skeleton.
|
|
145
|
+
*
|
|
146
|
+
* What counts as a fault here is what ADR-008 makes mandatory: one of the three
|
|
147
|
+
* values; a BLOCK whose findings carry file:line or a command reference — the
|
|
148
|
+
* reply format pins the pair, so a BLOCK without findings is a vocabulary
|
|
149
|
+
* violation, not an empty approval; the residual-risk verdict listing what was
|
|
150
|
+
* not reviewed verbatim ("never converts a known failure into an approval");
|
|
151
|
+
* and every @-cited id resolved against the brief. The @ marker exists because
|
|
152
|
+
* backticks cannot carry the citation: ADR-008 requires a BLOCK to pair with a
|
|
153
|
+
* file:line reference, and those arrive in backticks too — a citation of
|
|
154
|
+
* governance that does not govern is a fabrication the human would otherwise
|
|
155
|
+
* skim over, but a file:line reference is the evidence ADR-008 demanded, not a
|
|
156
|
+
* governance claim the verifier may police.
|
|
157
|
+
*
|
|
158
|
+
* Returns `{ verdict, findings, cited, faults }`; `verdict` is null when the
|
|
159
|
+
* vocabulary was not met, and the caller writes the proposal anyway with
|
|
160
|
+
* `verified: false` — the faults are the diff between what the model claimed
|
|
161
|
+
* and what the repository proves.
|
|
162
|
+
*/
|
|
163
|
+
export function parseVerdictReply(text, briefIds) {
|
|
164
|
+
const lines = String(text).trim().split('\n').filter((line) => line.trim() !== '');
|
|
165
|
+
const faults = [];
|
|
166
|
+
const verdictLine = lines.find((line) => /^verdict:/i.test(line.trim()));
|
|
167
|
+
let verdict = null;
|
|
168
|
+
if (!verdictLine) faults.push('no verdict: line');
|
|
169
|
+
else {
|
|
170
|
+
verdict = parseVerdictValue(verdictLine.slice(verdictLine.indexOf(':') + 1));
|
|
171
|
+
if (verdict === null) {
|
|
172
|
+
faults.push(`verdict "${verdictLine.trim()}" is not one of ${VERDICTS.join(' / ')}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const findings = lines.filter((line) => /^\s*-\s+/.test(line)).map((line) => line.trim());
|
|
176
|
+
// Governance citations carry an explicit @ marker. Backticks cannot be the
|
|
177
|
+
// marker: ADR-008 pairs BLOCK with file:line references, which arrive in
|
|
178
|
+
// backticks too, and calling those fabrications would make every honest
|
|
179
|
+
// BLOCK read as unverifiable (the fault this parser once misreported).
|
|
180
|
+
const cited = [...new Set([...text.matchAll(/@([A-Za-z][\w-]*)/g)].map((match) => match[1]))];
|
|
181
|
+
for (const token of cited) {
|
|
182
|
+
if (!briefIds.has(token)) faults.push(`cites @${token} and nothing governs the change by that id`);
|
|
183
|
+
}
|
|
184
|
+
if (verdict === 'BLOCK' && findings.length === 0) {
|
|
185
|
+
faults.push('a BLOCK verdict carries no finding paired with a file:line or command reference');
|
|
186
|
+
}
|
|
187
|
+
if (verdict === 'APPROVE WITH RESIDUAL RISK' && findings.length === 0) {
|
|
188
|
+
faults.push('APPROVE WITH RESIDUAL RISK must list what was not reviewed');
|
|
189
|
+
}
|
|
190
|
+
return { verdict, findings, cited, faults };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Frontmatter of a cached proposal, same parser the distiller's cache reads. */
|
|
194
|
+
export function parseExistingFrontmatter(text) {
|
|
195
|
+
if (!text.startsWith('---\n')) return {};
|
|
196
|
+
const head = text.split('\n---', 1)[0];
|
|
197
|
+
const fields = {};
|
|
198
|
+
for (const line of head.split('\n').slice(1)) {
|
|
199
|
+
const m = /^([a-z-]+):\s?(.*)$/.exec(line);
|
|
200
|
+
if (m) fields[m[1]] = m[2];
|
|
201
|
+
}
|
|
202
|
+
return fields;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Returns either the report object (ok) or an exit code (fail) — the same shape
|
|
207
|
+
* the command layer returns, so a failure passes through unchanged.
|
|
208
|
+
*
|
|
209
|
+
* With `write` false the critic runs and reports but persists nothing: the bench
|
|
210
|
+
* (its other caller) answers for history as a whole and cannot be a source of
|
|
211
|
+
* proposals for a tree those commits are not in.
|
|
212
|
+
*/
|
|
213
|
+
export async function reviewTarget(args, flags, { write = true, commands = [] } = {}) {
|
|
214
|
+
if (args.length > 0) {
|
|
215
|
+
return fail(`unknown review argument ${args[0]}`, EXIT.USAGE, `usage: ${CLI} review [--local]`);
|
|
216
|
+
}
|
|
217
|
+
const root = resolve(flags.root ?? process.cwd());
|
|
218
|
+
const listed = await tracked(root);
|
|
219
|
+
if (listed.error) return listed.error;
|
|
220
|
+
|
|
221
|
+
let patch;
|
|
222
|
+
let names;
|
|
223
|
+
try {
|
|
224
|
+
const [diff, changed] = await Promise.all([
|
|
225
|
+
exec('git', ['diff', '--staged'], { cwd: root, maxBuffer: 64 << 20 }),
|
|
226
|
+
exec('git', ['diff', '--staged', '--name-only'], { cwd: root, maxBuffer: 8 << 20 }),
|
|
227
|
+
]);
|
|
228
|
+
patch = diff.stdout;
|
|
229
|
+
names = changed.stdout.split('\n').filter(Boolean);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return fail(
|
|
232
|
+
`could not read the staged diff here (${error.message})`,
|
|
233
|
+
EXIT.UNSUPPORTED,
|
|
234
|
+
'run review inside a git repository, or stage what needs critiqued first',
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const bytes = Buffer.byteLength(patch, 'utf8');
|
|
238
|
+
const staged = { files: names, bytes };
|
|
239
|
+
|
|
240
|
+
if (names.length === 0 || bytes === 0) {
|
|
241
|
+
return {
|
|
242
|
+
ok: true,
|
|
243
|
+
command: 'review',
|
|
244
|
+
root,
|
|
245
|
+
staged,
|
|
246
|
+
governs: [],
|
|
247
|
+
verdict: null,
|
|
248
|
+
verified: true,
|
|
249
|
+
// The brief marker is what the report layer branches on: without it the
|
|
250
|
+
// renderer would read a proposal that an empty stage never produces.
|
|
251
|
+
brief: 'nothing is staged',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const items = await governingItems(root, names, commands);
|
|
256
|
+
const briefIds = new Set(items.map((item) => item.id));
|
|
257
|
+
|
|
258
|
+
if (flags.local !== true) {
|
|
259
|
+
// The deterministic path stops at the brief: the ruling the reply would need
|
|
260
|
+
// is a model output, and model output is what the default path must not need.
|
|
261
|
+
return {
|
|
262
|
+
ok: true,
|
|
263
|
+
command: 'review',
|
|
264
|
+
root,
|
|
265
|
+
staged,
|
|
266
|
+
governs: items,
|
|
267
|
+
verdict: null,
|
|
268
|
+
verified: true,
|
|
269
|
+
brief: 'the brief is what the critic reads; a verdict needs the model',
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const allowRemote = flags.allowRemote === true;
|
|
274
|
+
const budget = Number.isInteger(flags.budget) ? flags.budget : DEFAULT_BUDGET;
|
|
275
|
+
const prompt = buildReviewPrompt(patch, items, budget);
|
|
276
|
+
// The recipe is the prompt itself; a different budget, diff or governance list
|
|
277
|
+
// changes the prompt and with it the cache key, so nothing collides.
|
|
278
|
+
const digest = digestOf(prompt, ENGINE_REVIEW_LOCAL, budget);
|
|
279
|
+
const relProposal = `${REVIEW_DIR}/${digest}.md`;
|
|
280
|
+
const absProposal = join(root, relProposal);
|
|
281
|
+
|
|
282
|
+
const cached = flags.refresh === true ? null : await readFile(absProposal, 'utf8').catch(() => null);
|
|
283
|
+
if (cached !== null) {
|
|
284
|
+
const fields = parseExistingFrontmatter(cached);
|
|
285
|
+
const cachedVerdict = parseVerdictValue(fields.verdict ?? '');
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
command: 'review',
|
|
289
|
+
root,
|
|
290
|
+
staged,
|
|
291
|
+
governs: items,
|
|
292
|
+
digest,
|
|
293
|
+
verdict: cachedVerdict,
|
|
294
|
+
verified: fields.verified !== 'false',
|
|
295
|
+
engine: fields.engine ?? ENGINE_REVIEW_LOCAL,
|
|
296
|
+
budget: Number(fields.budget ?? budget),
|
|
297
|
+
metric: { note: METRIC_NOTE },
|
|
298
|
+
proposal: { path: relProposal, state: 'cached', bytes: Buffer.byteLength(cached, 'utf8') },
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const config = await readLocalConfig(root, { allowRemote });
|
|
303
|
+
const endpoint = chatEndpoint(config);
|
|
304
|
+
if (endpoint.exit !== undefined) return endpoint.exit;
|
|
305
|
+
|
|
306
|
+
const chat = await localChat(endpoint.chat, {
|
|
307
|
+
system: REVIEW_SYSTEM,
|
|
308
|
+
prompt,
|
|
309
|
+
temperature: 0,
|
|
310
|
+
maxTokens: budget === 0 ? 2048 : Math.min(2048, Math.max(256, Math.ceil(budget / 4))),
|
|
311
|
+
allowRemote,
|
|
312
|
+
});
|
|
313
|
+
if (!chat.ok) {
|
|
314
|
+
// A command-contract number: the chat guard's sentinel stays inside the
|
|
315
|
+
// engine boundary only; anything leaving reviewTarget is numeric or a report,
|
|
316
|
+
// matching how distillTarget unwraps its engine sentinels (plan 0003 R38).
|
|
317
|
+
return fail(
|
|
318
|
+
`local chat failed: ${chat.error}`,
|
|
319
|
+
EXIT.FAIL,
|
|
320
|
+
'fix the endpoint, or remove --local for the deterministic brief',
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
const reply = String(chat.content).trim();
|
|
324
|
+
const check = parseVerdictReply(reply, briefIds);
|
|
325
|
+
const faults = [...check.faults];
|
|
326
|
+
|
|
327
|
+
const provenance = `${ENGINE_REVIEW_LOCAL}:${chat.model} (prompt ${promptHash(prompt)})`;
|
|
328
|
+
const proposal =
|
|
329
|
+
proposalFrontmatter({
|
|
330
|
+
source: 'staged diff',
|
|
331
|
+
digest,
|
|
332
|
+
engine: provenance,
|
|
333
|
+
budget,
|
|
334
|
+
verified: faults.length === 0,
|
|
335
|
+
verdict: check.verdict ?? undefined,
|
|
336
|
+
advisory: true,
|
|
337
|
+
}) + `\n${reply}\n`;
|
|
338
|
+
if (write) {
|
|
339
|
+
// One mkdir round-trip per help: the directory exists only for proposals.
|
|
340
|
+
await mkdir(join(root, REVIEW_DIR), { recursive: true });
|
|
341
|
+
await writeFile(absProposal, proposal);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
ok: true,
|
|
346
|
+
command: 'review',
|
|
347
|
+
root,
|
|
348
|
+
staged,
|
|
349
|
+
governs: items,
|
|
350
|
+
digest,
|
|
351
|
+
verdict: check.verdict,
|
|
352
|
+
findings: check.findings.length,
|
|
353
|
+
cited: check.cited.length,
|
|
354
|
+
verified: faults.length === 0,
|
|
355
|
+
...(faults.length > 0 ? { faults } : {}),
|
|
356
|
+
engine: provenance,
|
|
357
|
+
budget,
|
|
358
|
+
metric: { note: METRIC_NOTE },
|
|
359
|
+
proposal: write
|
|
360
|
+
? { path: relProposal, state: 'fresh', bytes: Buffer.byteLength(proposal, 'utf8') }
|
|
361
|
+
: { path: relProposal, state: 'unwritten', bytes: Buffer.byteLength(proposal, 'utf8') },
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The verdict binary the bench evaluates: a gate would block the change exactly
|
|
367
|
+
* when the verdict is `BLOCK`, and ADR-008's other two values are one
|
|
368
|
+
* non-blocking answer to the same question, so precision and recall measure the
|
|
369
|
+
* critic against `BLOCK` and nothing finer.
|
|
370
|
+
*
|
|
371
|
+
* `null` says the pair cannot be judged — a label outside the vocabulary, or a
|
|
372
|
+
* reply that produced none — and the bench counts it as unmeasured rather than
|
|
373
|
+
* folding it into a negative.
|
|
374
|
+
*/
|
|
375
|
+
export function verdictBinary(labelVerdict, criticVerdict) {
|
|
376
|
+
const truth = parseVerdictValue(labelVerdict);
|
|
377
|
+
const said = parseVerdictValue(criticVerdict);
|
|
378
|
+
if (truth === null || said === null) return null;
|
|
379
|
+
return { truthCritical: truth === 'BLOCK' ? 1 : 0, saidCritical: said === 'BLOCK' ? 1 : 0 };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* `simt bench critic` (plan 0003 R39's acceptance).
|
|
384
|
+
*
|
|
385
|
+
* Reads a labelled set of historical diffs — one `{sha, verdict}` pair per line,
|
|
386
|
+
* the verdict a human assigned under ADR-008 — runs the critic over each diff
|
|
387
|
+
* from its commit, and reports the confusion counts with precision and recall.
|
|
388
|
+
* Precision and recall are reported, or `null` with the missing-inputs named:
|
|
389
|
+
* the metric is measured here or nowhere, never inferred.
|
|
390
|
+
*/
|
|
391
|
+
export async function criticBench(root, { labelsPath, flags, commands = [] }) {
|
|
392
|
+
const labels = await readLabels(root, labelsPath);
|
|
393
|
+
if (labels.exit !== undefined) return labels.exit;
|
|
394
|
+
|
|
395
|
+
const config = await readLocalConfig(root, { allowRemote: flags.allowRemote === true });
|
|
396
|
+
const endpoint = chatEndpoint(config);
|
|
397
|
+
if (endpoint.exit !== undefined) return endpoint.exit;
|
|
398
|
+
|
|
399
|
+
const confusion = { tp: 0, fp: 0, fn: 0, tn: 0 };
|
|
400
|
+
let measured = 0;
|
|
401
|
+
const errors = [];
|
|
402
|
+
for (const label of labels.rows) {
|
|
403
|
+
const pass = await peer(root, label.sha, flags, endpoint, commands);
|
|
404
|
+
if (pass.failed) {
|
|
405
|
+
errors.push(`review ${label.sha} rejected: ${pass.exitMessage}`);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const pair = verdictBinary(label.verdict, pass.verdict);
|
|
409
|
+
if (pair === null) {
|
|
410
|
+
errors.push(`review ${label.sha} did not produce a vocabulary verdict${pass.faults?.length ? `: ${pass.faults[0]}` : ''}`);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
measured += 1;
|
|
414
|
+
if (pair.truthCritical === 1 && pair.saidCritical === 1) confusion.tp += 1;
|
|
415
|
+
else if (pair.truthCritical === 0 && pair.saidCritical === 1) confusion.fp += 1;
|
|
416
|
+
else if (pair.truthCritical === 1 && pair.saidCritical === 0) confusion.fn += 1;
|
|
417
|
+
else confusion.tn += 1;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const precision = confusion.tp + confusion.fp > 0 ? confusion.tp / (confusion.tp + confusion.fp) : null;
|
|
421
|
+
const recall = confusion.tp + confusion.fn > 0 ? confusion.tp / (confusion.tp + confusion.fn) : null;
|
|
422
|
+
return {
|
|
423
|
+
ok: true,
|
|
424
|
+
command: 'bench critic',
|
|
425
|
+
root,
|
|
426
|
+
labels: labels.rows.length,
|
|
427
|
+
labelsPath,
|
|
428
|
+
measured,
|
|
429
|
+
errors,
|
|
430
|
+
...confusion,
|
|
431
|
+
precision,
|
|
432
|
+
recall,
|
|
433
|
+
gated: precision !== null && precision >= 0.8 && labels.rows.length >= 30 && errors.length === 0,
|
|
434
|
+
labelsNote: labels.rows.length < 30 ? `below the 30-diff acceptance floor (${labels.rows.length})` : null,
|
|
435
|
+
engine: endpoint.chat.model,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** One critic pass over one historical commit — the staged-diff machinery at that sha. */
|
|
440
|
+
async function peer(root, sha, flags, endpoint, commands) {
|
|
441
|
+
let diff;
|
|
442
|
+
try {
|
|
443
|
+
diff = (await exec('git', ['show', '--unified=2', sha], { cwd: root, maxBuffer: 64 << 20 })).stdout;
|
|
444
|
+
} catch (error) {
|
|
445
|
+
return { failed: true, exitMessage: error.message };
|
|
446
|
+
}
|
|
447
|
+
// `git show` names each touched file on its own `diff --git a/… b/…` line, so one
|
|
448
|
+
// pass yields both the patch and the file list — no second git call per label.
|
|
449
|
+
const names = [...diff.matchAll(/^diff --git a\/(\S+) b\/(\S+)/gm)].map((match) => match[2]);
|
|
450
|
+
if (names.length === 0) return { failed: true, exitMessage: 'the commit carries no file diff' };
|
|
451
|
+
|
|
452
|
+
const items = await governingItems(root, names, commands);
|
|
453
|
+
const briefIds = new Set(items.map((item) => item.id));
|
|
454
|
+
const budget = Number.isInteger(flags.budget) ? flags.budget : DEFAULT_BUDGET;
|
|
455
|
+
const prompt = buildReviewPrompt(diff, items, budget);
|
|
456
|
+
const chat = await localChat(endpoint.chat, {
|
|
457
|
+
system: REVIEW_SYSTEM,
|
|
458
|
+
prompt,
|
|
459
|
+
temperature: 0,
|
|
460
|
+
maxTokens: budget === 0 ? 2048 : Math.min(2048, Math.max(256, Math.ceil(budget / 4))),
|
|
461
|
+
allowRemote: flags.allowRemote === true,
|
|
462
|
+
});
|
|
463
|
+
if (!chat.ok) return { failed: true, exitMessage: chat.error };
|
|
464
|
+
return parseVerdictReply(String(chat.content).trim(), briefIds);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function readLabels(root, labelsPath) {
|
|
468
|
+
let text;
|
|
469
|
+
try {
|
|
470
|
+
text = await readFile(resolve(root, labelsPath), 'utf8');
|
|
471
|
+
} catch {
|
|
472
|
+
return {
|
|
473
|
+
exit: fail(
|
|
474
|
+
`no labelled set at ${labelsPath}`,
|
|
475
|
+
EXIT.FAIL,
|
|
476
|
+
`annotate historical diffs: one {"sha":"<commit>","verdict":"BLOCK|APPROVE|APPROVE WITH RESIDUAL RISK"} per line, labelled by a human under ADR-008 — the bench reports precision and recall over them`,
|
|
477
|
+
),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const rows = [];
|
|
481
|
+
const faults = [];
|
|
482
|
+
for (const [index, line] of text.split('\n').filter((l) => l.trim() !== '').entries()) {
|
|
483
|
+
let row;
|
|
484
|
+
try {
|
|
485
|
+
row = JSON.parse(line);
|
|
486
|
+
} catch {
|
|
487
|
+
faults.push(`line ${index + 1} is not JSON: ${JSON.stringify(line)}`);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (typeof row.sha !== 'string' || !/^[0-9a-f]{7,40}$/i.test(row.sha)) {
|
|
491
|
+
faults.push(`line ${index + 1} has no commit sha`);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (parseVerdictValue(row.verdict ?? '') === null) {
|
|
495
|
+
faults.push(`line ${index + 1} verdict is not one of ${VERDICTS.join(' / ')}`);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
rows.push({ sha: row.sha, verdict: parseVerdictValue(row.verdict) });
|
|
499
|
+
}
|
|
500
|
+
if (faults.length > 0) {
|
|
501
|
+
return {
|
|
502
|
+
exit: fail(`${labelsPath}: ${faults.join('; ')}`, EXIT.FAIL, 'every label is one {sha, verdict} pair on its own line'),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
if (rows.length === 0) {
|
|
506
|
+
return { exit: fail(`${labelsPath} names no labelled diffs`, EXIT.FAIL, `label historical diffs in ${labelsPath}`) };
|
|
507
|
+
}
|
|
508
|
+
return { rows };
|
|
509
|
+
}
|
package/src/toolwords.mjs
CHANGED
|
@@ -18,6 +18,9 @@ import { KNOWN_KEYS } from './projection/mdc.mjs';
|
|
|
18
18
|
import { PROFILE_KEYS } from './projection/rules.mjs';
|
|
19
19
|
import { INFERABLE_MARKER } from './kernel/inferability.mjs';
|
|
20
20
|
import { CHECK_MARKER } from './kernel/checks.mjs';
|
|
21
|
+
import { VARIANTS_MARKER } from './kernel/variants.mjs';
|
|
22
|
+
import { LESSON_KEYS } from './memory/lessons.mjs';
|
|
23
|
+
import { DECISION_KEYS } from './memory/decide.mjs';
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* `simt-` names that belong to this tool and never to a repository.
|
|
@@ -31,8 +34,11 @@ import { CHECK_MARKER } from './kernel/checks.mjs';
|
|
|
31
34
|
export const TOOL_WORDS = new Set([
|
|
32
35
|
...KNOWN_KEYS,
|
|
33
36
|
...PROFILE_KEYS,
|
|
37
|
+
...LESSON_KEYS,
|
|
38
|
+
...DECISION_KEYS,
|
|
34
39
|
INFERABLE_MARKER,
|
|
35
40
|
CHECK_MARKER,
|
|
41
|
+
VARIANTS_MARKER,
|
|
36
42
|
'simt-dev',
|
|
37
43
|
'simt-dev-check',
|
|
38
44
|
'simt-hooks',
|