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
package/src/decisions/index.mjs
CHANGED
|
@@ -21,6 +21,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
21
21
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
22
22
|
import { CLI } from '../name.mjs';
|
|
23
23
|
import { PREFIX_ARGUING, TOOL_WORDS } from '../toolwords.mjs';
|
|
24
|
+
import { PARTIAL_CLAUSE, parseDecisionFrontmatter, splitFrontmatter } from '../memory/decide.mjs';
|
|
25
|
+
import { affectsTokens } from '../index/govern.mjs';
|
|
24
26
|
|
|
25
27
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
26
28
|
export const DECISIONS_DIR = resolve(HERE, '..', '..', 'docs', 'decisions');
|
|
@@ -96,19 +98,6 @@ const ADR_IN_TEXT = /\bADR-(\d{3})\b/g;
|
|
|
96
98
|
*/
|
|
97
99
|
const SIBLING_FIELD = /^\*{0,2}[A-Z][\w]*(?:[ \t]+[\w]+){0,3}:\*{0,2}(?:[ \t]|$)/;
|
|
98
100
|
|
|
99
|
-
/**
|
|
100
|
-
* `ruling N <verb> by <ADR>`, in every form the corpus uses.
|
|
101
|
-
*
|
|
102
|
-
* Four spellings, all of them real: `ruling 3 superseded by ADR-046`, `rulings 1 and 2
|
|
103
|
-
* narrowed by ADR-026`, `rulings 3 and 4 narrowed by the same record`, and `one ruling
|
|
104
|
-
* narrowed by ADR-045` - which names no number at all, because the record does not say
|
|
105
|
-
* which ruling it meant. That last one is carried with `ruling: null` rather than
|
|
106
|
-
* dropped: a clause the tool can see but cannot resolve is reported as unresolved, and
|
|
107
|
-
* the corpus test below pins that there are none.
|
|
108
|
-
*/
|
|
109
|
-
const PARTIAL_CLAUSE =
|
|
110
|
-
/\b(?:one ruling|rulings?\s+(\d+(?:\s*(?:,|and)\s*\d+)*))\s+(superseded|narrowed|withdrawn)\s+by\s+(ADR-\d{3}|the same record)\b/gi;
|
|
111
|
-
|
|
112
101
|
/** How a record says a ruling was numbered, when it did not use a digit. */
|
|
113
102
|
const ORDINALS = ['One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten'];
|
|
114
103
|
const ORDINAL_NUMBER = new Map(ORDINALS.map((w, i) => [w, i + 1]));
|
|
@@ -173,6 +162,85 @@ export function citations(text) {
|
|
|
173
162
|
return [...found].sort();
|
|
174
163
|
}
|
|
175
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Parse one ADR markdown document, reading structured `simt-affects` and
|
|
167
|
+
* `simt-supersedes` frontmatter first (plan 0003 R34).
|
|
168
|
+
*
|
|
169
|
+
* @param {string} file filename (e.g. `ADR-097-example.md`)
|
|
170
|
+
* @param {string} text raw markdown content
|
|
171
|
+
* @param {{all?: boolean}} [options]
|
|
172
|
+
* @returns {{ok: boolean, decision?: object, error?: string, rawBody?: string}}
|
|
173
|
+
*/
|
|
174
|
+
export function parseDecision(file, text, { all = false } = {}) {
|
|
175
|
+
const fm = parseDecisionFrontmatter(text);
|
|
176
|
+
const { body } = splitFrontmatter(text);
|
|
177
|
+
const fileId = /^ADR-(\d{3,})/i.exec(file)?.[0]?.toUpperCase();
|
|
178
|
+
const heading = /^#\s+(.*)$/m.exec(body)?.[1] ?? null;
|
|
179
|
+
const headId = heading ? /^(ADR-\d{3,})/i.exec(heading)?.[1]?.toUpperCase() : null;
|
|
180
|
+
const id = fileId ?? headId ?? null;
|
|
181
|
+
if (!id) {
|
|
182
|
+
return { ok: false, error: `${file}: filename does not start with ADR-NNN` };
|
|
183
|
+
}
|
|
184
|
+
if (!heading) {
|
|
185
|
+
return { ok: false, error: `${file}: no level-1 heading` };
|
|
186
|
+
}
|
|
187
|
+
const rawStatus = statusParagraph(body);
|
|
188
|
+
if (rawStatus === null) {
|
|
189
|
+
return { ok: false, error: `${file}: no Status: line, so currency cannot be reported` };
|
|
190
|
+
}
|
|
191
|
+
const status = stripMarkdown(rawStatus);
|
|
192
|
+
const selfNum = Number(id.replace(/^ADR-/i, ''));
|
|
193
|
+
|
|
194
|
+
const fmPartial = [];
|
|
195
|
+
const fmWholeSupersedes = [];
|
|
196
|
+
for (const entry of fm.supersedes) {
|
|
197
|
+
const otherNum = Number(entry.adr.replace(/^ADR-/i, ''));
|
|
198
|
+
if (entry.ruling === null) {
|
|
199
|
+
if (otherNum < selfNum) fmWholeSupersedes.push(entry.adr);
|
|
200
|
+
} else if (otherNum > selfNum || entry.adr === id) {
|
|
201
|
+
fmPartial.push({
|
|
202
|
+
ruling: entry.ruling,
|
|
203
|
+
kind: entry.kind ?? 'superseded',
|
|
204
|
+
by: entry.adr === id ? null : entry.adr,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const prosePartial = partialSupersessions(status);
|
|
210
|
+
const partial = [...fmPartial];
|
|
211
|
+
for (const p of prosePartial) {
|
|
212
|
+
if (!partial.some((x) => x.ruling === p.ruling && x.by === p.by)) {
|
|
213
|
+
partial.push(p);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const proseSupersedes = [...status.matchAll(/supersedes ADR-(\d{3,})(?!\s+[Ss]?rulings?\b)/g)].map(
|
|
218
|
+
(m) => `ADR-${m[1]}`,
|
|
219
|
+
);
|
|
220
|
+
const supersedes = [...new Set([...fmWholeSupersedes, ...proseSupersedes])];
|
|
221
|
+
const title = heading.replace(/^ADR-\d{3,}\s*[—–-]\s*/i, '').trim();
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
rawBody: body,
|
|
226
|
+
decision: {
|
|
227
|
+
id,
|
|
228
|
+
file,
|
|
229
|
+
title,
|
|
230
|
+
status: redactInternalPaths(redactCommand(status)),
|
|
231
|
+
accepted: /^accepted\b/i.test(status),
|
|
232
|
+
supersededBy: firstMatch(status, /^superseded by ADR-(\d{3,})/i),
|
|
233
|
+
supersedes,
|
|
234
|
+
supersedesRulings: fm.supersedes,
|
|
235
|
+
affects: affectsTokens(text),
|
|
236
|
+
frontmatter: fm.hasFrontmatter ? { affects: fm.affects, supersedes: fm.supersedes } : null,
|
|
237
|
+
partial,
|
|
238
|
+
summary: summarise(body, partial, { all, file }),
|
|
239
|
+
bytes: Buffer.byteLength(text),
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
176
244
|
/**
|
|
177
245
|
* Load every ADR record once.
|
|
178
246
|
*
|
|
@@ -181,69 +249,52 @@ export function citations(text) {
|
|
|
181
249
|
*
|
|
182
250
|
* @returns {Promise<{ok: boolean, decisions: object[], errors: string[]}>}
|
|
183
251
|
*/
|
|
184
|
-
export async function loadDecisions({ all = false } = {}) {
|
|
252
|
+
export async function loadDecisions({ all = false, dir = DECISIONS_DIR } = {}) {
|
|
185
253
|
let names;
|
|
186
254
|
try {
|
|
187
|
-
names = (await readdir(
|
|
255
|
+
names = (await readdir(dir)).filter((n) => /^ADR-\d{3,}-.*\.md$/.test(n)).sort();
|
|
188
256
|
} catch (error) {
|
|
189
|
-
return { ok: false, decisions: [], errors: [`${
|
|
257
|
+
return { ok: false, decisions: [], errors: [`${dir}: unreadable (${error.message})`] };
|
|
190
258
|
}
|
|
191
259
|
|
|
192
260
|
const decisions = [];
|
|
261
|
+
const rawBodies = new Map();
|
|
193
262
|
const errors = [];
|
|
194
263
|
|
|
195
264
|
for (const file of names) {
|
|
196
|
-
const text = await readFile(join(
|
|
197
|
-
const
|
|
198
|
-
if (!
|
|
199
|
-
errors.push(
|
|
265
|
+
const text = await readFile(join(dir, file), 'utf8');
|
|
266
|
+
const parsed = parseDecision(file, text, { all });
|
|
267
|
+
if (!parsed.ok) {
|
|
268
|
+
errors.push(parsed.error);
|
|
200
269
|
continue;
|
|
201
270
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
271
|
+
rawBodies.set(parsed.decision.id, parsed.rawBody);
|
|
272
|
+
decisions.push(parsed.decision);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const index = byId(decisions);
|
|
276
|
+
for (const d of decisions) {
|
|
277
|
+
const selfNum = Number(d.id.replace(/^ADR-/i, ''));
|
|
278
|
+
for (const entry of d.supersedesRulings ?? []) {
|
|
279
|
+
const otherNum = Number(entry.adr.replace(/^ADR-/i, ''));
|
|
280
|
+
if (otherNum >= selfNum) continue;
|
|
281
|
+
const target = index.get(entry.adr);
|
|
282
|
+
if (!target) continue;
|
|
283
|
+
if (entry.ruling === null) {
|
|
284
|
+
target.supersededBy = d.id;
|
|
285
|
+
target.accepted = false;
|
|
286
|
+
} else if (!target.partial.some((p) => p.ruling === entry.ruling && p.by === d.id)) {
|
|
287
|
+
target.partial.unshift({
|
|
288
|
+
ruling: entry.ruling,
|
|
289
|
+
kind: entry.kind ?? 'superseded',
|
|
290
|
+
by: d.id,
|
|
291
|
+
});
|
|
292
|
+
target.summary = summarise(rawBodies.get(target.id) ?? '', target.partial, {
|
|
293
|
+
all,
|
|
294
|
+
file: target.file,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
211
297
|
}
|
|
212
|
-
// Every field below is read off the flattened paragraph, with markdown already
|
|
213
|
-
// removed. The link form and the bare form are then one spelling, so a record that
|
|
214
|
-
// writes `superseded by ADR-046` and one that writes `superseded by
|
|
215
|
-
// [ADR-046](...)` are read by the same line of code rather than by two regexes that
|
|
216
|
-
// have to be kept in step.
|
|
217
|
-
const status = stripMarkdown(rawStatus);
|
|
218
|
-
const partial = partialSupersessions(status);
|
|
219
|
-
// The heading repeats the id; the decision title is what follows the dash.
|
|
220
|
-
const title = heading.replace(/^ADR-\d{3}\s*[—–-]\s*/, '').trim();
|
|
221
|
-
|
|
222
|
-
decisions.push({
|
|
223
|
-
id,
|
|
224
|
-
file,
|
|
225
|
-
title,
|
|
226
|
-
// Everything structural above this line is derived from the record's own prose and
|
|
227
|
-
// is therefore unredacted: `partial` in particular has to say "ruling 1 superseded
|
|
228
|
-
// by ADR-053", and a ruling number is not a location. What a reader in another
|
|
229
|
-
// repository receives is the status line, so that is what is scrubbed — here, once,
|
|
230
|
-
// rather than at each of the four places that print it.
|
|
231
|
-
status: redactInternalPaths(redactCommand(status)),
|
|
232
|
-
// Anchored to the start of the status, and this is the whole of the rule. A record
|
|
233
|
-
// is superseded only when its own status says so first. Unanchored, the clause
|
|
234
|
-
// "ruling 2 superseded by [ADR-024]" inside ADR-023's paragraph matched, and the
|
|
235
|
-
// command would then answer "ruling 2" by printing ADR-024's rulings and
|
|
236
|
-
// suppressing the five in ADR-023 that still stand - a partial supersession
|
|
237
|
-
// silently becoming a total one, which is the reverse of what the status says.
|
|
238
|
-
accepted: /^accepted\b/i.test(status),
|
|
239
|
-
supersededBy: firstMatch(status, /^superseded by ADR-(\d{3})/i),
|
|
240
|
-
supersedes: [...status.matchAll(/supersedes ADR-(\d{3})(?!\s+[Ss]?rulings?\b)/g)].map(
|
|
241
|
-
(m) => `ADR-${m[1]}`,
|
|
242
|
-
),
|
|
243
|
-
partial,
|
|
244
|
-
summary: summarise(text, partial, { all, file }),
|
|
245
|
-
bytes: Buffer.byteLength(text),
|
|
246
|
-
});
|
|
247
298
|
}
|
|
248
299
|
|
|
249
300
|
return { ok: errors.length === 0, decisions, errors };
|
|
@@ -524,7 +575,7 @@ export function redactEstate(text) {
|
|
|
524
575
|
* Only a real verb follows, which is what keeps `simt-dev.json`, `simt-dev-check.yml` and
|
|
525
576
|
* `simt-dev's` untouched - those are the project's artefacts and the project keeps its name.
|
|
526
577
|
*/
|
|
527
|
-
const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|mcp|rules|skills|graph|tokens|harness|bench|shared|evidence|index|map|tests|why|gate|impact|--[a-z-]+)';
|
|
578
|
+
const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|agent-hook|agent-hooks|mcp|rules|skills|graph|tokens|harness|bench|shared|evidence|index|map|tests|why|gate|impact|lessons|decide|brief|plan|--[a-z-]+)';
|
|
528
579
|
|
|
529
580
|
function redactCommand(text) {
|
|
530
581
|
return String(text ?? '').replace(new RegExp(`\\bsimt-dev (?=${VERB})\\b`, 'g'), `${CLI} `);
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// The distiller's orchestrator (plan 0003 R38): one target in, one proposal out.
|
|
2
|
+
//
|
|
3
|
+
// R37 gave this repository a local model client, and R38 is the first consumer of
|
|
4
|
+
// it: distillation is the cheapest thing a local model can do that the
|
|
5
|
+
// deterministic engine cannot — abstractive compression — and the one that most
|
|
6
|
+
// needs the guardrails here, because its output is the kind a reader would paste
|
|
7
|
+
// into a pack and forget to check.
|
|
8
|
+
//
|
|
9
|
+
// Nothing here writes outside `.simt-dev/distilled/`, and nothing here edits a
|
|
10
|
+
// source file (ADR-090, ADR-075). A proposal that a human did not merge is a dead
|
|
11
|
+
// file, which is the intended failure mode: proposals expire, projections do not.
|
|
12
|
+
|
|
13
|
+
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { EXIT, fail } from '../output/index.mjs';
|
|
17
|
+
import { CLI } from '../name.mjs';
|
|
18
|
+
import { loadRegistry } from '../graph/load.mjs';
|
|
19
|
+
import { nearMatches } from '../resolve/near.mjs';
|
|
20
|
+
import { bodyFile, sourcePath } from '../resolve/body.mjs';
|
|
21
|
+
import { resolveWorkspace } from '../workspace.mjs';
|
|
22
|
+
import { readLocalConfig, chatEndpoint } from '../local/config.mjs';
|
|
23
|
+
import { localChat } from '../local/client.mjs';
|
|
24
|
+
import {
|
|
25
|
+
ENGINE_LOCAL,
|
|
26
|
+
ENGINE_OUTLINE,
|
|
27
|
+
buildLocalPrompt,
|
|
28
|
+
digestOf,
|
|
29
|
+
outlineSummary,
|
|
30
|
+
proposalFrontmatter,
|
|
31
|
+
promptHash,
|
|
32
|
+
sourceDigest,
|
|
33
|
+
verifyCitations,
|
|
34
|
+
} from './engine.mjs';
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_BUDGET = 2048;
|
|
37
|
+
const DISTILL_DIR = '.simt-dev/distilled';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The row's acceptance metric, stated where a caller reads results (ADR-076):
|
|
41
|
+
* "bench shows distilled body plus references match the full body on R at lower
|
|
42
|
+
* C, or the feature stays experimental." It is the second of the two until the
|
|
43
|
+
* bench measures it, so every report says so rather than implying parity.
|
|
44
|
+
*/
|
|
45
|
+
const METRIC_NOTE = 'parity at lower C unmeasured on R; experimental (plan 0003 R38)';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve `<skill|doc>` to an absolute source path.
|
|
49
|
+
*
|
|
50
|
+
* A name first: `distill core-rule` is read as the skill that `skills show` would
|
|
51
|
+
* read, so both commands answering differently is the defect. A path second:
|
|
52
|
+
* everything else is a file under the root, and one that escapes the root is
|
|
53
|
+
* refused — a proposal whose provenance names `../../etc` is provenance nobody
|
|
54
|
+
* should trust.
|
|
55
|
+
*/
|
|
56
|
+
async function resolveTarget(target, root, flags) {
|
|
57
|
+
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
58
|
+
if (!ok) return { exit: fail(errors[0], EXIT.REGISTRY) };
|
|
59
|
+
|
|
60
|
+
const skill = registry.skills.find((s) => s.name === target);
|
|
61
|
+
if (skill) {
|
|
62
|
+
const ws = await resolveWorkspace(flags);
|
|
63
|
+
const full = await sourcePath(skill, ws.root ?? null, root);
|
|
64
|
+
const readable = full !== null && (full === root || bodyFile(full).startsWith(root + sep));
|
|
65
|
+
if (!readable) {
|
|
66
|
+
const near = nearMatches(target, registry.skills.map((s) => s.name));
|
|
67
|
+
return {
|
|
68
|
+
exit: fail(
|
|
69
|
+
`skill "${target}" resolved to ${full ?? 'no body'} and that body is not under ${root}`,
|
|
70
|
+
EXIT.FAIL,
|
|
71
|
+
`run ${CLI} skills show ${target} to see how the body resolves, or distill a doc path`,
|
|
72
|
+
),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
|
|
76
|
+
return {
|
|
77
|
+
path: bodyFile(full),
|
|
78
|
+
name: canonical ? `${canonical.repo}/${canonical.path}` : skill.name,
|
|
79
|
+
how: 'skill',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const candidate = isAbsolute(target) ? target : resolve(root, target);
|
|
84
|
+
if (candidate !== root && !candidate.startsWith(root + sep)) {
|
|
85
|
+
return {
|
|
86
|
+
exit: fail(`${target} is outside ${root}`, EXIT.FAIL, 'distill reads docs under the repository root only'),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const text = await readFile(bodyFile(candidate), 'utf8');
|
|
91
|
+
return {
|
|
92
|
+
path: candidate,
|
|
93
|
+
name: relative(root, candidate) || target,
|
|
94
|
+
how: 'doc',
|
|
95
|
+
text,
|
|
96
|
+
};
|
|
97
|
+
} catch {
|
|
98
|
+
const near = nearMatches(target, registry.skills.map((s) => s.name));
|
|
99
|
+
return {
|
|
100
|
+
exit: fail(
|
|
101
|
+
`no skill named "${target}" and no file ${relative(root, candidate) ?? target}`,
|
|
102
|
+
EXIT.FAIL,
|
|
103
|
+
near.length ? `did you mean the skill ${near.join(', ')}?` : `run ${CLI} skills index`,
|
|
104
|
+
),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The engine dispatch, including the model path's guardrails.
|
|
111
|
+
*
|
|
112
|
+
* `--local` without a usable `local.chat` is a configuration error, not a silent
|
|
113
|
+
* fallback to the deterministic engine: a caller who asked for the model and got
|
|
114
|
+
* the outline would read the outline's confidence as the model's — and the
|
|
115
|
+
* reverse, reading model output as deterministic, is worse.
|
|
116
|
+
*/
|
|
117
|
+
async function runEngine(kind, text, budget, context) {
|
|
118
|
+
if (kind === ENGINE_OUTLINE) return outlineSummary(text, budget);
|
|
119
|
+
|
|
120
|
+
const { root, allowRemote, name } = context;
|
|
121
|
+
const config = await readLocalConfig(root, { allowRemote });
|
|
122
|
+
// The shared guard arrived from `local/config.mjs` when the critic became the
|
|
123
|
+
// second real user of the same rules (plan 0003 R39, ADR-002).
|
|
124
|
+
const endpoint = chatEndpoint(config);
|
|
125
|
+
if (endpoint.exit !== undefined) return endpoint;
|
|
126
|
+
|
|
127
|
+
const prompt = buildLocalPrompt(name, text, budget);
|
|
128
|
+
const chat = await localChat(config.chat, {
|
|
129
|
+
system: 'You compress documents for software agents. Follow the rules the user message states exactly.',
|
|
130
|
+
prompt,
|
|
131
|
+
temperature: 0,
|
|
132
|
+
maxTokens: budget === 0 ? 2048 : Math.min(2048, Math.max(256, Math.ceil(budget / 4))),
|
|
133
|
+
allowRemote,
|
|
134
|
+
});
|
|
135
|
+
if (!chat.ok) {
|
|
136
|
+
return {
|
|
137
|
+
exit: fail(
|
|
138
|
+
`local chat failed: ${chat.error}`,
|
|
139
|
+
EXIT.FAIL,
|
|
140
|
+
'fix the endpoint, or run without --local for the deterministic engine',
|
|
141
|
+
),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { engine: ENGINE_LOCAL, model: chat.model, body: String(chat.content).trim(), prompt };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseExistingFrontmatter(text) {
|
|
148
|
+
if (!text.startsWith('---\n')) return {};
|
|
149
|
+
const head = text.split('\n---', 1)[0];
|
|
150
|
+
const fields = {};
|
|
151
|
+
for (const line of head.split('\n').slice(1)) {
|
|
152
|
+
const m = /^([a-z]+):\s?(.*)$/.exec(line);
|
|
153
|
+
if (m) fields[m[1]] = m[2];
|
|
154
|
+
}
|
|
155
|
+
return fields;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function countCitations(text) {
|
|
159
|
+
return (text.match(/\[L\d+(-L\d+)?\]/g) ?? []).length;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Returns either the report object (ok) or an exit code (fail) — the same shape
|
|
164
|
+
* the command layer returns, so a failure passes through unchanged.
|
|
165
|
+
*/
|
|
166
|
+
export async function distillTarget(args, flags) {
|
|
167
|
+
const target = args[0];
|
|
168
|
+
if (!target) {
|
|
169
|
+
return fail(
|
|
170
|
+
'distill needs a skill name or a doc path',
|
|
171
|
+
EXIT.USAGE,
|
|
172
|
+
`usage: ${CLI} distill <skill|doc> [--budget <bytes>]`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const root = resolve(flags.root ?? process.cwd());
|
|
177
|
+
const source = await resolveTarget(target, root, flags);
|
|
178
|
+
if (source.exit !== undefined) return source.exit;
|
|
179
|
+
|
|
180
|
+
const text = source.text ?? (await readFile(bodyFile(source.path), 'utf8'));
|
|
181
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
182
|
+
const budget = Number.isInteger(flags.budget) ? flags.budget : DEFAULT_BUDGET;
|
|
183
|
+
const allowRemote = flags.allowRemote === true;
|
|
184
|
+
const engineKind = flags.local === true ? ENGINE_LOCAL : ENGINE_OUTLINE;
|
|
185
|
+
const digest = digestOf(text, engineKind, budget);
|
|
186
|
+
const relProposal = `${DISTILL_DIR}/${digest}.md`;
|
|
187
|
+
const absProposal = join(root, relProposal);
|
|
188
|
+
const lineCount = text.split('\n').length;
|
|
189
|
+
|
|
190
|
+
const cached = flags.refresh === true ? null : await readFile(absProposal, 'utf8').catch(() => null);
|
|
191
|
+
if (cached !== null) {
|
|
192
|
+
const fields = parseExistingFrontmatter(cached);
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
command: 'distill',
|
|
196
|
+
target: { kind: source.how, source: source.name, bytes, lines: lineCount },
|
|
197
|
+
digest: sourceDigest(text),
|
|
198
|
+
engine: fields.engine ?? ENGINE_OUTLINE,
|
|
199
|
+
budget: Number(fields.budget ?? budget),
|
|
200
|
+
citations: countCitations(cached),
|
|
201
|
+
verified: fields.verified !== 'false',
|
|
202
|
+
metric: { note: METRIC_NOTE },
|
|
203
|
+
proposal: { path: relProposal, state: 'cached', bytes: Buffer.byteLength(cached, 'utf8') },
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const outcome = await runEngine(engineKind, text, budget, { root, allowRemote, name: source.name });
|
|
208
|
+
if (outcome.exit !== undefined) return outcome.exit;
|
|
209
|
+
|
|
210
|
+
const check = verifyCitations(outcome.body, lineCount);
|
|
211
|
+
const faults = [...check.faults];
|
|
212
|
+
const bodyBytes = Buffer.byteLength(outcome.body, 'utf8');
|
|
213
|
+
if (budget > 0 && bodyBytes > budget) {
|
|
214
|
+
faults.push(`summary is ${bodyBytes} bytes against a ${budget}-byte budget`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const provenance =
|
|
218
|
+
outcome.engine === ENGINE_LOCAL
|
|
219
|
+
? `${ENGINE_LOCAL}:${outcome.model} (prompt ${promptHash(outcome.prompt)})`
|
|
220
|
+
: ENGINE_OUTLINE;
|
|
221
|
+
const proposal =
|
|
222
|
+
proposalFrontmatter({
|
|
223
|
+
source: source.name,
|
|
224
|
+
digest,
|
|
225
|
+
engine: provenance,
|
|
226
|
+
budget,
|
|
227
|
+
verified: faults.length === 0,
|
|
228
|
+
}) + `\n${outcome.body}\n`;
|
|
229
|
+
|
|
230
|
+
await mkdir(join(root, DISTILL_DIR), { recursive: true });
|
|
231
|
+
await writeFile(absProposal, proposal);
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
ok: true,
|
|
235
|
+
command: 'distill',
|
|
236
|
+
target: { kind: source.how, source: source.name, bytes, lines: lineCount },
|
|
237
|
+
digest: sourceDigest(text),
|
|
238
|
+
engine: provenance,
|
|
239
|
+
budget,
|
|
240
|
+
citations: check.ranges.length,
|
|
241
|
+
verified: faults.length === 0,
|
|
242
|
+
...(faults.length > 0 ? { faults } : {}),
|
|
243
|
+
metric: { note: METRIC_NOTE },
|
|
244
|
+
proposal: { path: relProposal, state: 'fresh', bytes: Buffer.byteLength(proposal, 'utf8') },
|
|
245
|
+
};
|
|
246
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// The distiller's engines (plan 0003 R38).
|
|
2
|
+
//
|
|
3
|
+
// `simt distill` writes a budgeted summary of a skill body or doc, with every claim
|
|
4
|
+
// citing the source line range it rests on. Two engines produce that shape:
|
|
5
|
+
//
|
|
6
|
+
// - `outline` (default): a deterministic, extractive summary — the heading
|
|
7
|
+
// skeleton plus the first content of each section, cut to the budget. No
|
|
8
|
+
// model, no network, byte-stable: the same text at the same budget produces
|
|
9
|
+
// the same bytes forever (P1, P4).
|
|
10
|
+
// - `local`: the configured local chat model (R37) proposes a abstractive
|
|
11
|
+
// summary. It is opt-in (`--local`), loopback-only by default, and its output
|
|
12
|
+
// is verified against the source before anything is written.
|
|
13
|
+
//
|
|
14
|
+
// Both engines write the same artefact: a *proposal*, never a projection. The
|
|
15
|
+
// proposal file carries its provenance in frontmatter (source, digest, engine,
|
|
16
|
+
// prompt hash for the model path); a human merges it into the pack or repo, and
|
|
17
|
+
// nothing in this module ever edits a source file (ADR-090/ADR-075, principle P2).
|
|
18
|
+
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
export const ENGINE_OUTLINE = 'outline';
|
|
22
|
+
export const ENGINE_LOCAL = 'local';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The cache key: source digest plus the recipe that shaped the output. Two
|
|
26
|
+
* proposals of the same text at different budgets are different answers, so one
|
|
27
|
+
* key carries both rather than letting a second write collide with the first.
|
|
28
|
+
*/
|
|
29
|
+
export function digestOf(text, engine, budget) {
|
|
30
|
+
const h = createHash('sha256');
|
|
31
|
+
h.update(`${engine}\0${budget}\0${text}`);
|
|
32
|
+
return `sha256-${h.digest('hex').slice(0, 16)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Same function for the source itself, where the recipe does not apply. */
|
|
36
|
+
export function sourceDigest(text) {
|
|
37
|
+
return `sha256-${createHash('sha256').update(text).digest('hex').slice(0, 16)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The hash of the prompt sent to a model engine — the provenance P2 asks for. */
|
|
41
|
+
export function promptHash(prompt) {
|
|
42
|
+
return `sha256-${createHash('sha256').update(prompt).digest('hex').slice(0, 16)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const FENCE = /^\s*(```|~~~)/;
|
|
46
|
+
const HEADING = /^#{1,6}\s/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the prompt a local model receives (ENGINE_LOCAL only).
|
|
50
|
+
*
|
|
51
|
+
* The source ships with line numbers printed, because the only citation format
|
|
52
|
+
* the verifier can check is `[L<n>-<m>]` against those numbers. A model asked to
|
|
53
|
+
* "cite the source" but given unnumbered text can only guess, and every guess is
|
|
54
|
+
* a citation that looks true and cannot be checked — which is the defect this
|
|
55
|
+
* command exists to prevent.
|
|
56
|
+
*/
|
|
57
|
+
export function buildLocalPrompt(fileName, text, budget) {
|
|
58
|
+
const numbered = text
|
|
59
|
+
.split('\n')
|
|
60
|
+
.map((line, i) => `${i + 1}: ${line}`)
|
|
61
|
+
.join('\n');
|
|
62
|
+
return [
|
|
63
|
+
'Distill the document below into what an agent must know to act on it.',
|
|
64
|
+
`The summary must not exceed ${budget} bytes. Rules:`,
|
|
65
|
+
'- Every claim ends with a citation to a source line range, written [L12] or [L12-L15], using the line numbers printed below.',
|
|
66
|
+
'- Cite only lines that exist in the source. Never cite a line you cannot see.',
|
|
67
|
+
'- Copy commands, paths, thresholds and names verbatim; do not paraphrase them.',
|
|
68
|
+
'- Do not invent content, and do not add advice the source does not contain.',
|
|
69
|
+
`- Reply with the summary only; nothing else.`,
|
|
70
|
+
'',
|
|
71
|
+
`SOURCE ${fileName}`,
|
|
72
|
+
numbered,
|
|
73
|
+
].join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The deterministic extractive engine (ENGINE_OUTLINE).
|
|
78
|
+
*
|
|
79
|
+
* Selection, readable as a score: every heading first (they are the document's
|
|
80
|
+
* own structure), then the immediate non-empty content under each heading. The
|
|
81
|
+
* result is the document in its own words, in document order, at a fraction of
|
|
82
|
+
* the bytes — which is the comparison the acceptance criterion asks the bench to
|
|
83
|
+
* run, and the honest thing an offline engine can claim.
|
|
84
|
+
*
|
|
85
|
+
* Bounds: the returned body never exceeds `budget` bytes (0 = unlimited), and
|
|
86
|
+
* every line it renders carries a citation the verifier below can check.
|
|
87
|
+
*/
|
|
88
|
+
export function outlineSummary(text, budget) {
|
|
89
|
+
const lines = text.split('\n');
|
|
90
|
+
const picks = [];
|
|
91
|
+
let inline = false;
|
|
92
|
+
|
|
93
|
+
// Pass 1: headings.
|
|
94
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
95
|
+
const line = lines[i];
|
|
96
|
+
if (FENCE.test(line)) inline = !inline;
|
|
97
|
+
if (inline) continue;
|
|
98
|
+
if (HEADING.test(line)) picks.push(i);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Pass 2: the first content line under each heading.
|
|
102
|
+
for (const i of [...picks]) {
|
|
103
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
104
|
+
if (FENCE.test(lines[j])) break;
|
|
105
|
+
if (HEADING.test(lines[j])) break;
|
|
106
|
+
if (lines[j].trim() === '') continue;
|
|
107
|
+
picks.push(j);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (picks.length === 0 && lines.some((l) => l.trim() !== '')) {
|
|
113
|
+
// No headings at all: a doc of prose. The whole thing is one section, so the
|
|
114
|
+
// first content line is the citation anchor rather than an empty summary.
|
|
115
|
+
const first = lines.findIndex((l) => l.trim() !== '' && !FENCE.test(l));
|
|
116
|
+
if (first !== -1) picks.push(first);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
picks.sort((a, b) => a - b);
|
|
120
|
+
|
|
121
|
+
const body = [];
|
|
122
|
+
let used = 0;
|
|
123
|
+
for (const i of picks) {
|
|
124
|
+
const text = lines[i].trimEnd();
|
|
125
|
+
const bullet = `>[L${i + 1}] ${text}`;
|
|
126
|
+
const cost = Buffer.byteLength(`${bullet}\n`, 'utf8');
|
|
127
|
+
if (budget > 0 && used + cost > budget) break;
|
|
128
|
+
used += cost;
|
|
129
|
+
body.push(bullet);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { engine: ENGINE_OUTLINE, body: body.join('\n'), citations: body.length, budgetUsed: used };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check a summary's citations against the source that supposedly backs them.
|
|
137
|
+
*
|
|
138
|
+
* A citation naming a line past the end of the source is fabricated; a summary
|
|
139
|
+
* with any such citation is returned with `--json` detailed faults and is never
|
|
140
|
+
* silently trusted. The proposal still writes (it is only a proposal), but its
|
|
141
|
+
* provenance records `verified: false` so a reader cannot mistake it for checked
|
|
142
|
+
* output (ADR-083).
|
|
143
|
+
*/
|
|
144
|
+
export function verifyCitations(body, sourceLineCount) {
|
|
145
|
+
const faults = [];
|
|
146
|
+
const ranges = [];
|
|
147
|
+
const re = /\[L(\d+)(?:-L?(\d+))?\]/g;
|
|
148
|
+
let match;
|
|
149
|
+
while ((match = re.exec(body)) !== null) {
|
|
150
|
+
const start = Number(match[1]);
|
|
151
|
+
const end = match[2] === undefined ? start : Number(match[2]);
|
|
152
|
+
const kind =
|
|
153
|
+
start < 1 || start > sourceLineCount
|
|
154
|
+
? `start`
|
|
155
|
+
: end < start
|
|
156
|
+
? `order`
|
|
157
|
+
: end > sourceLineCount
|
|
158
|
+
? `end`
|
|
159
|
+
: null;
|
|
160
|
+
if (kind !== null) {
|
|
161
|
+
faults.push(`citation [L${start}-L${end}] ${kind} outside the source (1..${sourceLineCount})`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
ranges.push({ start, end });
|
|
165
|
+
}
|
|
166
|
+
return { ok: faults.length === 0, ranges, faults };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The proposal file's frontmatter. No wall-clock value here: byte-stable (P4). */
|
|
170
|
+
export function proposalFrontmatter({ source, digest, engine, budget, prompt, verified, verdict, advisory }) {
|
|
171
|
+
return [
|
|
172
|
+
'---',
|
|
173
|
+
`source: ${source}`,
|
|
174
|
+
`digest: ${digest}`,
|
|
175
|
+
`engine: ${engine}`,
|
|
176
|
+
`budget: ${budget}`,
|
|
177
|
+
prompt ? `prompt: ${prompt}` : null,
|
|
178
|
+
verdict ? `verdict: ${verdict}` : null,
|
|
179
|
+
advisory ? 'advisory: true' : null,
|
|
180
|
+
verified === false ? 'verified: false' : 'verified: true',
|
|
181
|
+
'status: proposal',
|
|
182
|
+
'---',
|
|
183
|
+
'',
|
|
184
|
+
]
|
|
185
|
+
.filter((l) => l !== null)
|
|
186
|
+
.join('\n');
|
|
187
|
+
}
|