simt-dev 0.3.0 → 0.4.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 +172 -0
- package/README.md +138 -74
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
- 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 -2
- package/rules/core.md +2 -2
- 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 +34 -1
- package/src/bench/grade.mjs +3 -0
- package/src/bench/run.mjs +85 -39
- package/src/bench/variants.mjs +456 -0
- package/src/brief/assemble.mjs +346 -0
- package/src/brief/kind.mjs +134 -0
- package/src/brief/packet.mjs +197 -0
- package/src/ci/workflow.mjs +11 -1
- package/src/commands/agenthook.mjs +84 -5
- package/src/commands/bench.mjs +53 -16
- package/src/commands/brief.mjs +78 -0
- package/src/commands/compare.mjs +10 -2
- package/src/commands/decide.mjs +116 -0
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/help.mjs +65 -3
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +52 -1
- package/src/commands/lessons.mjs +127 -0
- package/src/commands/local.mjs +97 -0
- package/src/commands/map.mjs +126 -0
- package/src/commands/repoindex.mjs +55 -0
- package/src/commands/rules.mjs +17 -2
- package/src/commands/shared.mjs +398 -0
- package/src/commands/skills.mjs +3 -17
- package/src/commands/skillscompile.mjs +255 -0
- package/src/commands/sync.mjs +87 -3
- package/src/commands/tests.mjs +145 -0
- package/src/commands/tokens.mjs +30 -1
- package/src/commands/why.mjs +172 -0
- package/src/compare/learn.mjs +680 -0
- package/src/decisions/index.mjs +116 -65
- 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/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 +89 -0
- package/src/index/govern.mjs +234 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +627 -0
- package/src/index/persist.mjs +239 -0
- package/src/index/store.mjs +515 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/kernel/variants.mjs +353 -0
- package/src/ledger/index.mjs +147 -0
- package/src/local/client.mjs +482 -0
- package/src/local/config.mjs +234 -0
- package/src/mcp/tools.mjs +138 -1
- package/src/measure/context.mjs +7 -5
- package/src/memory/decide.mjs +462 -0
- package/src/memory/lessons.mjs +858 -0
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/lock.mjs +125 -0
- package/src/projection/manifest.mjs +52 -0
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +407 -49
- package/src/projection/shared.mjs +428 -0
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +8 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// Rule text variant parsing, validation, and deterministic projection folding
|
|
2
|
+
// (plan 0003 R35).
|
|
3
|
+
//
|
|
4
|
+
// A rule line or list item may carry `<!-- simt-variants: <slot> <spec> -->`.
|
|
5
|
+
// When unlocked, projection uses the baseline variant (`a`, or the line's own
|
|
6
|
+
// prose) and strips the marker so `simt-` annotations never reach a reader.
|
|
7
|
+
// When `simt-dev.lock.json` records a locked winner for `<ruleId>.<slot>`,
|
|
8
|
+
// projection deterministically renders that winning variant.
|
|
9
|
+
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
export const VARIANTS_MARKER = 'simt-variants';
|
|
14
|
+
const LOCK_FILE_NAME = 'simt-dev.lock.json';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read the locked `variants` map from `simt-dev.lock.json` in `root`, or `{}` when absent.
|
|
18
|
+
*/
|
|
19
|
+
export async function readLockedVariants(root) {
|
|
20
|
+
if (!root) return {};
|
|
21
|
+
try {
|
|
22
|
+
const raw = await readFile(join(root, LOCK_FILE_NAME), 'utf8');
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (
|
|
25
|
+
parsed &&
|
|
26
|
+
typeof parsed === 'object' &&
|
|
27
|
+
parsed.variants &&
|
|
28
|
+
typeof parsed.variants === 'object' &&
|
|
29
|
+
!Array.isArray(parsed.variants)
|
|
30
|
+
) {
|
|
31
|
+
return parsed.variants;
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// Missing or unreadable lockfile falls back to baseline variants.
|
|
35
|
+
}
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const MARKER = /<!--\s*simt-variants:\s*([\s\S]*?)\s*-->/g;
|
|
40
|
+
const MARKER_AT = /\s*<!--\s*simt-variants:\s*[\s\S]*?-->/g;
|
|
41
|
+
const HAS_MARKER = /<!--\s*simt-variants:/;
|
|
42
|
+
const ITEM_START = /^(\s*)((?:[-*]|\d+\.)\s+)/;
|
|
43
|
+
const SLOT_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
44
|
+
const VARIANT_KEY_RE = /^[A-Za-z0-9_-]+$/;
|
|
45
|
+
|
|
46
|
+
function fenced(unit) {
|
|
47
|
+
return /^\s*(```|~~~)/.test(unit);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function markersIn(unit) {
|
|
51
|
+
return [...unit.matchAll(MARKER)].map((m) => m[1]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stripMarkers(unit) {
|
|
55
|
+
return unit.replace(MARKER_AT, '').replace(/[ \t]+$/gm, '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Lines of a body, fences kept whole, list items split from their neighbours. */
|
|
59
|
+
function units(body) {
|
|
60
|
+
const lines = String(body ?? '').split('\n');
|
|
61
|
+
const out = [];
|
|
62
|
+
let inFence = false;
|
|
63
|
+
let current = [];
|
|
64
|
+
const flush = () => {
|
|
65
|
+
if (current.length > 0) out.push(current.join('\n'));
|
|
66
|
+
current = [];
|
|
67
|
+
};
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
70
|
+
if (!inFence) flush();
|
|
71
|
+
current.push(line);
|
|
72
|
+
inFence = !inFence;
|
|
73
|
+
if (!inFence) flush();
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (inFence) {
|
|
77
|
+
current.push(line);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (line.trim() === '') {
|
|
81
|
+
flush();
|
|
82
|
+
out.push(line);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (ITEM_START.test(line) && current.length > 0) flush();
|
|
86
|
+
current.push(line);
|
|
87
|
+
}
|
|
88
|
+
flush();
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stripItemPrefix(text) {
|
|
93
|
+
const m = ITEM_START.exec(text);
|
|
94
|
+
if (!m) return { indent: '', bullet: '', content: text.trim() };
|
|
95
|
+
return {
|
|
96
|
+
indent: m[1],
|
|
97
|
+
bullet: m[2],
|
|
98
|
+
content: text.slice(m[0].length).trim(),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function qualifySlotId(ruleId, rawSlot) {
|
|
103
|
+
const slot = String(rawSlot ?? '').trim();
|
|
104
|
+
if (!slot) return { slotId: '', shortId: '' };
|
|
105
|
+
const prefix = ruleId ? `${ruleId}.` : '';
|
|
106
|
+
if (prefix && slot.startsWith(prefix)) {
|
|
107
|
+
return { slotId: slot, shortId: slot.slice(prefix.length) };
|
|
108
|
+
}
|
|
109
|
+
if (slot.includes('.')) {
|
|
110
|
+
const dot = slot.indexOf('.');
|
|
111
|
+
return { slotId: slot, shortId: slot.slice(dot + 1) };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
slotId: prefix ? `${prefix}${slot}` : slot,
|
|
115
|
+
shortId: slot,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseKvPairs(rawSpec) {
|
|
120
|
+
const map = {};
|
|
121
|
+
const pairRe = /([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')/g;
|
|
122
|
+
let matched = 0;
|
|
123
|
+
for (const m of rawSpec.matchAll(pairRe)) {
|
|
124
|
+
const key = m[1];
|
|
125
|
+
const val = (m[2] ?? m[3] ?? '').replace(/\\(["'\\])/g, '$1').trim();
|
|
126
|
+
map[key] = val;
|
|
127
|
+
matched += 1;
|
|
128
|
+
}
|
|
129
|
+
if (matched === 0) return null;
|
|
130
|
+
const leftover = rawSpec.replace(pairRe, '').replace(/[|\s,]+/g, '').trim();
|
|
131
|
+
if (leftover !== '') return null;
|
|
132
|
+
return map;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse one `simt-variants` marker expression together with the stripped unit text.
|
|
137
|
+
*/
|
|
138
|
+
export function parseVariantSpec(expr, ruleId = '', strippedUnit = '') {
|
|
139
|
+
const raw = String(expr ?? '').trim();
|
|
140
|
+
if (!raw) {
|
|
141
|
+
return { error: `${VARIANTS_MARKER} is empty; name a slot and its variants` };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let rawSlot = '';
|
|
145
|
+
let rawMap = null;
|
|
146
|
+
|
|
147
|
+
if (raw.startsWith('{')) {
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(raw);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return { error: `${VARIANTS_MARKER} JSON is invalid (${error.message})` };
|
|
153
|
+
}
|
|
154
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
155
|
+
return { error: `${VARIANTS_MARKER} must be a JSON object` };
|
|
156
|
+
}
|
|
157
|
+
const keys = Object.keys(parsed);
|
|
158
|
+
if (
|
|
159
|
+
keys.length === 1 &&
|
|
160
|
+
parsed[keys[0]] &&
|
|
161
|
+
typeof parsed[keys[0]] === 'object' &&
|
|
162
|
+
!Array.isArray(parsed[keys[0]])
|
|
163
|
+
) {
|
|
164
|
+
rawSlot = keys[0];
|
|
165
|
+
rawMap = parsed[keys[0]];
|
|
166
|
+
} else if (typeof parsed.id === 'string' || typeof parsed.slot === 'string') {
|
|
167
|
+
rawSlot = String(parsed.id ?? parsed.slot);
|
|
168
|
+
rawMap = {};
|
|
169
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
170
|
+
if (k === 'id' || k === 'slot') continue;
|
|
171
|
+
rawMap[k] = v;
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
return { error: `${VARIANTS_MARKER} JSON object must name a slot id` };
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
const firstSpace = raw.search(/\s/);
|
|
178
|
+
if (firstSpace === -1) {
|
|
179
|
+
return { error: `${VARIANTS_MARKER} "${raw}" names a slot but no variants` };
|
|
180
|
+
}
|
|
181
|
+
rawSlot = raw.slice(0, firstSpace).trim();
|
|
182
|
+
const rest = raw.slice(firstSpace + 1).trim();
|
|
183
|
+
if (rest.startsWith('{')) {
|
|
184
|
+
try {
|
|
185
|
+
rawMap = JSON.parse(rest);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return { error: `${VARIANTS_MARKER} "${rawSlot}" has invalid JSON (${error.message})` };
|
|
188
|
+
}
|
|
189
|
+
if (!rawMap || typeof rawMap !== 'object' || Array.isArray(rawMap)) {
|
|
190
|
+
return { error: `${VARIANTS_MARKER} "${rawSlot}" must map variant keys to strings` };
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
rawMap = parseKvPairs(rest);
|
|
194
|
+
if (!rawMap) {
|
|
195
|
+
return {
|
|
196
|
+
error: `${VARIANTS_MARKER} "${rawSlot}" must specify variants as JSON or key="text" pairs`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const { slotId, shortId } = qualifySlotId(ruleId, rawSlot);
|
|
203
|
+
if (!slotId || !shortId || !SLOT_ID_RE.test(slotId) || !SLOT_ID_RE.test(shortId)) {
|
|
204
|
+
return { error: `${VARIANTS_MARKER} has invalid slot id "${rawSlot}"` };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const { indent, bullet, content: baseProse } = stripItemPrefix(strippedUnit);
|
|
208
|
+
const variants = {};
|
|
209
|
+
|
|
210
|
+
if (baseProse !== '' && rawMap.a === undefined) {
|
|
211
|
+
variants.a = baseProse;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const [k, v] of Object.entries(rawMap)) {
|
|
215
|
+
if (!VARIANT_KEY_RE.test(k)) {
|
|
216
|
+
return { error: `${VARIANTS_MARKER} "${slotId}" has invalid variant key "${k}"` };
|
|
217
|
+
}
|
|
218
|
+
if (typeof v !== 'string' || v.trim() === '') {
|
|
219
|
+
return { error: `${VARIANTS_MARKER} "${slotId}" variant "${k}" must be a non-empty string` };
|
|
220
|
+
}
|
|
221
|
+
const { content } = stripItemPrefix(v.trim());
|
|
222
|
+
variants[k] = content;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const variantKeys = Object.keys(variants);
|
|
226
|
+
if (variantKeys.length < 2) {
|
|
227
|
+
return {
|
|
228
|
+
error: `${VARIANTS_MARKER} "${slotId}" needs at least 2 variants (found ${variantKeys.length})`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const baseline = variants.a !== undefined ? 'a' : variantKeys[0];
|
|
233
|
+
return {
|
|
234
|
+
slotId,
|
|
235
|
+
shortId,
|
|
236
|
+
ruleId: ruleId || (slotId.includes('.') ? slotId.slice(0, slotId.indexOf('.')) : ''),
|
|
237
|
+
baseline,
|
|
238
|
+
variants,
|
|
239
|
+
indent,
|
|
240
|
+
bullet,
|
|
241
|
+
error: null,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Report the first defect in a rule body's `simt-variants` markers, or null.
|
|
247
|
+
*/
|
|
248
|
+
export function variantsDefect(body, ruleId = '') {
|
|
249
|
+
const seenSlots = new Set();
|
|
250
|
+
for (const unit of units(body)) {
|
|
251
|
+
if (fenced(unit)) continue;
|
|
252
|
+
const found = markersIn(unit);
|
|
253
|
+
if (found.length === 0) continue;
|
|
254
|
+
if (found.length > 1) {
|
|
255
|
+
return `carries more than one ${VARIANTS_MARKER} marker on one item; one item is one variant slot`;
|
|
256
|
+
}
|
|
257
|
+
const stripped = stripMarkers(unit);
|
|
258
|
+
const parsed = parseVariantSpec(found[0], ruleId, stripped);
|
|
259
|
+
if (parsed.error) return parsed.error;
|
|
260
|
+
if (seenSlots.has(parsed.slotId)) {
|
|
261
|
+
return `declares duplicate ${VARIANTS_MARKER} slot "${parsed.slotId}"`;
|
|
262
|
+
}
|
|
263
|
+
seenSlots.add(parsed.slotId);
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Extract all declared variant slots from a rule (`{id, source, body}`).
|
|
270
|
+
*/
|
|
271
|
+
export function extractVariantsFromRule(rule) {
|
|
272
|
+
const body = String(rule?.body ?? '');
|
|
273
|
+
const ruleId = String(rule?.id ?? '');
|
|
274
|
+
if (!HAS_MARKER.test(body) || variantsDefect(body, ruleId) !== null) return [];
|
|
275
|
+
const slots = [];
|
|
276
|
+
for (const unit of units(body)) {
|
|
277
|
+
if (fenced(unit)) continue;
|
|
278
|
+
const found = markersIn(unit);
|
|
279
|
+
if (found.length !== 1) continue;
|
|
280
|
+
const stripped = stripMarkers(unit);
|
|
281
|
+
const parsed = parseVariantSpec(found[0], ruleId, stripped);
|
|
282
|
+
if (parsed.error) continue;
|
|
283
|
+
slots.push({
|
|
284
|
+
slotId: parsed.slotId,
|
|
285
|
+
shortId: parsed.shortId,
|
|
286
|
+
ruleId: parsed.ruleId,
|
|
287
|
+
source: rule?.source ?? null,
|
|
288
|
+
baseline: parsed.baseline,
|
|
289
|
+
variants: parsed.variants,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return slots;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function lookupLockedWinner(lockedVariants, slotId, shortId) {
|
|
296
|
+
if (!lockedVariants || typeof lockedVariants !== 'object') return null;
|
|
297
|
+
const entry = lockedVariants[slotId] ?? (shortId ? lockedVariants[shortId] : undefined);
|
|
298
|
+
if (typeof entry === 'string') return { winner: entry, entry: { winner: entry } };
|
|
299
|
+
if (entry && typeof entry === 'object') {
|
|
300
|
+
const w = entry.winner ?? entry.variant;
|
|
301
|
+
if (typeof w === 'string' && w !== '') return { winner: w, entry };
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Resolve every `simt-variants` slot in `body` using `lockedVariants` (or the baseline
|
|
308
|
+
* variant when unlocked) and strip the marker comments.
|
|
309
|
+
*
|
|
310
|
+
* When `body` carries no `simt-variants` marker, returns `body` by identity so
|
|
311
|
+
* untouched rules do not move a byte.
|
|
312
|
+
*
|
|
313
|
+
* @returns {{body: string, selected: {slotId: string, winner: string, baseline: string, locked: boolean, entry: object|null}[], changed: boolean}}
|
|
314
|
+
*/
|
|
315
|
+
export function foldVariants(body, ruleId = '', lockedVariants = null) {
|
|
316
|
+
if (!HAS_MARKER.test(body) || variantsDefect(body, ruleId) !== null) {
|
|
317
|
+
return { body, selected: [], changed: false };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const selected = [];
|
|
321
|
+
const kept = [];
|
|
322
|
+
|
|
323
|
+
for (const unit of units(body)) {
|
|
324
|
+
const found = fenced(unit) ? [] : markersIn(unit);
|
|
325
|
+
if (found.length === 0) {
|
|
326
|
+
kept.push(unit);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const stripped = stripMarkers(unit);
|
|
330
|
+
const parsed = parseVariantSpec(found[0], ruleId, stripped);
|
|
331
|
+
if (parsed.error) {
|
|
332
|
+
kept.push(stripped);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const locked = lookupLockedWinner(lockedVariants, parsed.slotId, parsed.shortId);
|
|
337
|
+
const hasLockedKey = locked !== null && Object.hasOwn(parsed.variants, locked.winner);
|
|
338
|
+
const chosenKey = hasLockedKey ? locked.winner : parsed.baseline;
|
|
339
|
+
const chosenText = parsed.variants[chosenKey];
|
|
340
|
+
|
|
341
|
+
kept.push(`${parsed.indent}${parsed.bullet}${chosenText}`);
|
|
342
|
+
selected.push({
|
|
343
|
+
slotId: parsed.slotId,
|
|
344
|
+
winner: chosenKey,
|
|
345
|
+
baseline: parsed.baseline,
|
|
346
|
+
locked: hasLockedKey,
|
|
347
|
+
entry: hasLockedKey ? locked.entry : null,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const next = kept.join('\n');
|
|
352
|
+
return { body: next, selected, changed: next !== body };
|
|
353
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The evidence ledger (ADR-087).
|
|
2
|
+
//
|
|
3
|
+
// A passing command is evidence about one tree and no other. The tree is taken with a
|
|
4
|
+
// private index, because `git add -A` on the real index would stage work the author had
|
|
5
|
+
// left unstaged. Editing any file afterwards produces a different tree, so the old line
|
|
6
|
+
// no longer proves anything about what is on disk now.
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { appendFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
LESSONS_FILE,
|
|
17
|
+
errorSignature,
|
|
18
|
+
firstErrorLine,
|
|
19
|
+
normaliseErrorLine,
|
|
20
|
+
recordFailThenFix,
|
|
21
|
+
} from '../memory/lessons.mjs';
|
|
22
|
+
|
|
23
|
+
const exec = promisify(execFile);
|
|
24
|
+
|
|
25
|
+
const LEDGER_FILE = '.simt-dev/ledger.jsonl';
|
|
26
|
+
|
|
27
|
+
/** Phrases that claim a run happened. `unverified` and `not verified` are removed
|
|
28
|
+
* first, because both contain the word and neither is a claim. */
|
|
29
|
+
const CLAIM = /\btests?\s+pass(?:ed)?\b|\bverified\b/i;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The tree of everything in the worktree, without staging any of it.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} root
|
|
35
|
+
* @returns {Promise<string>} the write-tree id
|
|
36
|
+
*/
|
|
37
|
+
export async function treeId(root) {
|
|
38
|
+
const dir = await mkdtemp(join(tmpdir(), 'tree-index-'));
|
|
39
|
+
const index = join(dir, 'index');
|
|
40
|
+
const env = { ...process.env, GIT_INDEX_FILE: index };
|
|
41
|
+
try {
|
|
42
|
+
// The ledger is a record of the tree, so it cannot be part of the tree it names.
|
|
43
|
+
// `git add -A` would take it once the file exists, and the line would prove a
|
|
44
|
+
// tree that stopped existing when the line was written.
|
|
45
|
+
await exec(
|
|
46
|
+
'git',
|
|
47
|
+
['add', '-A', '--', '.', `:(exclude)${LEDGER_FILE}`, `:(exclude)${LESSONS_FILE}`],
|
|
48
|
+
{ cwd: root, env },
|
|
49
|
+
);
|
|
50
|
+
const { stdout } = await exec('git', ['write-tree'], { cwd: root, env });
|
|
51
|
+
return stdout.trim();
|
|
52
|
+
} finally {
|
|
53
|
+
await rm(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Append one run. The output itself is not stored: the digest is enough to notice a
|
|
59
|
+
* change, and a ledger that kept logs would keep whatever the command printed.
|
|
60
|
+
*/
|
|
61
|
+
export async function recordRun(root, { cmd, exit, ms, output }) {
|
|
62
|
+
const { entries: prior } = await readLedger(root).catch(() => ({ entries: [] }));
|
|
63
|
+
const line = {
|
|
64
|
+
v: 1,
|
|
65
|
+
ts: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
66
|
+
tree: await treeId(root),
|
|
67
|
+
cmd,
|
|
68
|
+
exit,
|
|
69
|
+
ms,
|
|
70
|
+
outputDigest: `sha256-${createHash('sha256').update(output).digest('hex')}`,
|
|
71
|
+
summary: summarise(output, exit),
|
|
72
|
+
...(exit !== 0
|
|
73
|
+
? {
|
|
74
|
+
errorLine: normaliseErrorLine(firstErrorLine(output)),
|
|
75
|
+
signature: errorSignature(output),
|
|
76
|
+
}
|
|
77
|
+
: {}),
|
|
78
|
+
};
|
|
79
|
+
await mkdir(join(root, '.simt-dev'), { recursive: true });
|
|
80
|
+
await appendFile(join(root, LEDGER_FILE), `${JSON.stringify(line)}\n`);
|
|
81
|
+
|
|
82
|
+
if (exit === 0 && typeof cmd === 'string') {
|
|
83
|
+
let pendingFail = null;
|
|
84
|
+
for (const entry of prior) {
|
|
85
|
+
if (entry.cmd !== cmd) continue;
|
|
86
|
+
if (entry.exit !== 0) {
|
|
87
|
+
if (!pendingFail) pendingFail = entry;
|
|
88
|
+
} else {
|
|
89
|
+
pendingFail = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (pendingFail) {
|
|
93
|
+
await recordFailThenFix(root, { failEntry: pendingFail, passEntry: line }).catch(() => {});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return line;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Every well-formed line. A line that does not parse is returned as an error so a
|
|
101
|
+
* damaged ledger cannot look empty. */
|
|
102
|
+
export async function readLedger(root) {
|
|
103
|
+
let text;
|
|
104
|
+
try {
|
|
105
|
+
text = await readFile(join(root, LEDGER_FILE), 'utf8');
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error.code === 'ENOENT') return { entries: [], errors: [] };
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
const entries = [];
|
|
111
|
+
const errors = [];
|
|
112
|
+
for (const [i, raw] of text.split('\n').entries()) {
|
|
113
|
+
if (raw.trim() === '') continue;
|
|
114
|
+
try {
|
|
115
|
+
const line = JSON.parse(raw);
|
|
116
|
+
if (typeof line.tree !== 'string' || typeof line.exit !== 'number') {
|
|
117
|
+
errors.push(`line ${i + 1} has no tree or exit`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
entries.push(line);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
errors.push(`line ${i + 1} does not parse (${error.message})`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { entries, errors };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function claimsVerification(message) {
|
|
129
|
+
const text = String(message).replace(/\bunverified\b/gi, ' ').replace(/\bnot\s+verified\b/gi, ' ');
|
|
130
|
+
return CLAIM.test(text);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `block` fails the commit. Anything unset is a warning. A third spelling is an
|
|
134
|
+
* error, because a typo for `block` must not quietly warn. */
|
|
135
|
+
export function evidenceMode(manifest) {
|
|
136
|
+
const value = manifest?.gates?.evidence;
|
|
137
|
+
if (value === undefined || value === 'warn') return 'warn';
|
|
138
|
+
if (value === 'block') return 'block';
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function summarise(output, exit) {
|
|
143
|
+
const pass = /(\d+)\s+pass\b/.exec(output) ?? /\bpass\s+(\d+)/.exec(output);
|
|
144
|
+
const fail = /(\d+)\s+fail\b/.exec(output) ?? /\bfail\s+(\d+)/.exec(output);
|
|
145
|
+
if (pass || fail) return `${pass?.[1] ?? 0} pass, ${fail?.[1] ?? 0} fail`;
|
|
146
|
+
return `exit ${exit}`;
|
|
147
|
+
}
|