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.
Files changed (60) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +271 -62
  3. package/package.json +6 -2
  4. package/rules/core.md +1 -1
  5. package/src/advice/advice.mjs +229 -0
  6. package/src/agenthooks/policy.mjs +10 -2
  7. package/src/agenthooks/prompt.mjs +81 -0
  8. package/src/agenthooks/render.mjs +138 -17
  9. package/src/argv.mjs +28 -1
  10. package/src/bench/grade.mjs +3 -0
  11. package/src/bench/run.mjs +88 -54
  12. package/src/bench/tasks.mjs +38 -0
  13. package/src/bench/variants.mjs +456 -0
  14. package/src/brief/assemble.mjs +368 -0
  15. package/src/brief/kind.mjs +134 -0
  16. package/src/brief/packet.mjs +200 -0
  17. package/src/commands/agenthook.mjs +84 -5
  18. package/src/commands/bench.mjs +72 -15
  19. package/src/commands/brief.mjs +78 -0
  20. package/src/commands/compare.mjs +10 -2
  21. package/src/commands/decide.mjs +116 -0
  22. package/src/commands/distill.mjs +36 -0
  23. package/src/commands/help.mjs +62 -3
  24. package/src/commands/index.mjs +29 -1
  25. package/src/commands/lessons.mjs +127 -0
  26. package/src/commands/local.mjs +97 -0
  27. package/src/commands/map.mjs +42 -7
  28. package/src/commands/repoindex.mjs +4 -1
  29. package/src/commands/review.mjs +65 -0
  30. package/src/commands/rules.mjs +17 -2
  31. package/src/commands/skills.mjs +5 -50
  32. package/src/commands/skillscompile.mjs +255 -0
  33. package/src/commands/sync.mjs +15 -0
  34. package/src/commands/tests.mjs +2 -49
  35. package/src/commands/why.mjs +23 -25
  36. package/src/compare/learn.mjs +680 -0
  37. package/src/decisions/index.mjs +116 -65
  38. package/src/distill/distill.mjs +246 -0
  39. package/src/distill/engine.mjs +187 -0
  40. package/src/federation/client.mjs +188 -0
  41. package/src/federation/config.mjs +134 -0
  42. package/src/federation/query.mjs +48 -0
  43. package/src/graph/compile.mjs +475 -0
  44. package/src/index/gate.mjs +1 -13
  45. package/src/index/govern.mjs +23 -11
  46. package/src/index/map.mjs +251 -17
  47. package/src/index/persist.mjs +74 -7
  48. package/src/index/store.mjs +222 -8
  49. package/src/kernel/variants.mjs +353 -0
  50. package/src/ledger/index.mjs +36 -1
  51. package/src/local/client.mjs +482 -0
  52. package/src/local/config.mjs +269 -0
  53. package/src/mcp/tools.mjs +138 -1
  54. package/src/memory/decide.mjs +462 -0
  55. package/src/memory/lessons.mjs +858 -0
  56. package/src/projection/lock.mjs +39 -7
  57. package/src/projection/rules.mjs +32 -8
  58. package/src/resolve/body.mjs +46 -0
  59. package/src/review/critic.mjs +509 -0
  60. package/src/toolwords.mjs +6 -0
@@ -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(DECISIONS_DIR)).filter((n) => /^ADR-\d{3}-.*\.md$/.test(n)).sort();
255
+ names = (await readdir(dir)).filter((n) => /^ADR-\d{3,}-.*\.md$/.test(n)).sort();
188
256
  } catch (error) {
189
- return { ok: false, decisions: [], errors: [`${DECISIONS_DIR}: unreadable (${error.message})`] };
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(DECISIONS_DIR, file), 'utf8');
197
- const id = /^ADR-(\d{3})/.exec(file)?.[0];
198
- if (!id) {
199
- errors.push(`${file}: filename does not start with ADR-NNN`);
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
- const heading = /^#\s+(.*)$/m.exec(text)?.[1] ?? null;
203
- if (!heading) {
204
- errors.push(`${file}: no level-1 heading`);
205
- continue;
206
- }
207
- const rawStatus = statusParagraph(text);
208
- if (rawStatus === null) {
209
- errors.push(`${file}: no Status: line, so currency cannot be reported`);
210
- continue;
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
+ }