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
@@ -0,0 +1,475 @@
1
+ // Skill compiler, progressive-disclosure splitter, and trigger report (plan 0003 R30).
2
+ //
3
+ // 1. Compiles a skill into the Agent Skills layout (`<name>/SKILL.md` + `references/*.md`)
4
+ // with frontmatter that passes `checkSkill` (review 0002 S44, ADR-069).
5
+ // 2. Flags every skill body over 5,000 tokens (20,000 bytes at 4 B/token) with a
6
+ // deterministic split plan, and splits markdown bodies by section into `references/`
7
+ // so `SKILL.md` stays within the 5,000-token and 500-line budget.
8
+ // 3. Reads R02 transcripts and run records to determine whether the agent opened a skill
9
+ // when the task's gold patch touched its declared `paths`, reporting trigger recall
10
+ // and precision per skill.
11
+ // 4. Builds offline, proposal-only description improvements with provenance (`model`,
12
+ // `promptHash`, `digest`) so model output never lands directly in committed text (P2).
13
+
14
+ import { createHash } from 'node:crypto';
15
+ import { checkSkill, nameProblems, readFrontmatter } from './spec.mjs';
16
+ import { matchesGlob } from '../resolve/resolve.mjs';
17
+
18
+ export const BODY_TOKEN_BUDGET = 5000;
19
+ export const BYTES_PER_TOKEN = 4;
20
+ export const BODY_BYTE_BUDGET = BODY_TOKEN_BUDGET * BYTES_PER_TOKEN;
21
+ export const BODY_LINE_BUDGET = 500;
22
+
23
+ const TARGET_MAIN_BYTES = 12_000;
24
+ const TARGET_MAIN_LINES = 350;
25
+ const TARGET_REF_BYTES = 16_000;
26
+ const SPEC_KEYS = new Set(['name', 'description', 'license', 'compatibility', 'metadata', 'allowed-tools']);
27
+
28
+ function sha256Tag(text) {
29
+ return `sha256-${createHash('sha256').update(String(text ?? ''), 'utf8').digest('hex').slice(0, 16)}`;
30
+ }
31
+
32
+ function slugify(title, index) {
33
+ const base = String(title ?? '')
34
+ .toLowerCase()
35
+ .replace(/`+/g, '')
36
+ .replace(/[^a-z0-9]+/g, '-')
37
+ .replace(/^-+|-+$/g, '')
38
+ .replace(/-{2,}/g, '-')
39
+ .slice(0, 48);
40
+ const num = String(index + 1).padStart(2, '0');
41
+ return base ? `${num}-${base}` : `${num}-section`;
42
+ }
43
+
44
+ /** Build a conforming Agent Skills description (30..1024 chars, stating what + when). */
45
+ export function buildConformingDescription(skill = {}, rawDescription = '') {
46
+ const clean = String(rawDescription ?? '').replace(/\s+/g, ' ').trim();
47
+ const summary = String(skill.summary ?? '').replace(/\s+/g, ' ').trim();
48
+ const paths = Array.isArray(skill.paths) && skill.paths.length > 0 ? skill.paths.slice(0, 4).join(', ') : '';
49
+ const keywords = Array.isArray(skill.keywords) && skill.keywords.length > 0 ? skill.keywords.slice(0, 5).join(', ') : '';
50
+
51
+ const whenClause = paths
52
+ ? `Use when touching ${paths}${keywords ? ` or working with ${keywords}` : ''}.`
53
+ : keywords
54
+ ? `Use when working with ${keywords}.`
55
+ : 'Use when a task matches this capability.';
56
+
57
+ let desc = clean || summary || `Instructions for ${skill.name ?? 'this skill'}.`;
58
+ if (!desc.endsWith('.')) desc += '.';
59
+ if (desc.length < 30 || !/\buse when\b/i.test(desc)) {
60
+ const combined = `${desc} ${whenClause}`.replace(/\s+/g, ' ').trim();
61
+ if (combined.length <= 1024) desc = combined;
62
+ }
63
+ if (desc.length < 30) desc = `${desc} ${whenClause}`.trim();
64
+ if (desc.length > 1024) desc = `${desc.slice(0, 1021).trimEnd()}...`;
65
+ return desc;
66
+ }
67
+
68
+ function splitMarkdownSections(bodyText) {
69
+ const lines = String(bodyText ?? '').split(/\r?\n/);
70
+ const preamble = [];
71
+ const sections = [];
72
+ let current = null;
73
+ let inFence = false;
74
+
75
+ for (const line of lines) {
76
+ if (line.trim().startsWith('```')) inFence = !inFence;
77
+ const h2 = !inFence ? /^##\s+(.+?)\s*$/.exec(line) : null;
78
+ if (h2) {
79
+ if (current) sections.push(current);
80
+ current = { title: h2[1].trim(), lines: [line] };
81
+ continue;
82
+ }
83
+ if (current) current.lines.push(line);
84
+ else preamble.push(line);
85
+ }
86
+ if (current) sections.push(current);
87
+
88
+ if (sections.length <= 1 && lines.length > BODY_LINE_BUDGET) {
89
+ const chunkSize = 200;
90
+ const bodyLines = sections.length === 1 ? [...preamble, ...sections[0].lines] : preamble;
91
+ const intro = bodyLines.slice(0, 80);
92
+ const rest = bodyLines.slice(80);
93
+ const chunked = [];
94
+ for (let i = 0; i < rest.length; i += chunkSize) {
95
+ const partNum = Math.floor(i / chunkSize) + 1;
96
+ chunked.push({
97
+ title: `Reference Part ${partNum}`,
98
+ lines: [`## Reference Part ${partNum}`, '', ...rest.slice(i, i + chunkSize)],
99
+ });
100
+ }
101
+ return { preamble: intro.join('\n').trim(), sections: chunked };
102
+ }
103
+ return { preamble: preamble.join('\n').trim(), sections };
104
+ }
105
+
106
+ /**
107
+ * Compute a deterministic progressive-disclosure split plan for a skill.
108
+ * Every skill over 5,000 tokens (20,000 bytes) or 500 lines is flagged with a split plan.
109
+ */
110
+ export function planSkillSplit(skill = {}, bodyText = null) {
111
+ const hasBody = typeof bodyText === 'string' && bodyText.length > 0;
112
+ const bodyBytes = hasBody ? Buffer.byteLength(bodyText, 'utf8') : 0;
113
+ const totalBytes = Math.max(Number(skill.bytes ?? 0), bodyBytes);
114
+ const totalTokens = Math.ceil(totalBytes / BYTES_PER_TOKEN);
115
+ const totalLines = hasBody ? bodyText.split(/\r?\n/).length : Math.ceil(totalBytes / 60);
116
+ const overTokens = totalTokens > BODY_TOKEN_BUDGET;
117
+ const flagged = overTokens;
118
+
119
+ if (!flagged) {
120
+ return {
121
+ flagged: false,
122
+ source: hasBody ? 'body' : 'registry',
123
+ reason: null,
124
+ totalBytes,
125
+ totalTokens,
126
+ totalLines,
127
+ mainBytes: bodyBytes || totalBytes,
128
+ mainTokens: Math.ceil((bodyBytes || totalBytes) / BYTES_PER_TOKEN),
129
+ references: [],
130
+ };
131
+ }
132
+
133
+ const reason = `${totalTokens} tokens > ${BODY_TOKEN_BUDGET} token budget`;
134
+
135
+ if (!hasBody || bodyBytes <= BODY_BYTE_BUDGET) {
136
+ const mainBytes = hasBody ? bodyBytes : Math.min(totalBytes, TARGET_MAIN_BYTES);
137
+ const mainTokens = Math.ceil(mainBytes / BYTES_PER_TOKEN);
138
+ const remainingBytes = Math.max(1024, totalBytes - mainBytes);
139
+ const topics = [...(skill.capabilities ?? []), ...(skill.keywords ?? []).slice(0, 4)];
140
+ const refCount = Math.max(1, Math.ceil(remainingBytes / TARGET_REF_BYTES));
141
+ const references = [];
142
+ let left = remainingBytes;
143
+ for (let i = 0; i < refCount; i += 1) {
144
+ const share = i === refCount - 1 ? left : Math.min(left, Math.ceil(remainingBytes / refCount));
145
+ left -= share;
146
+ const topic = topics[i] ?? `reference-${i + 1}`;
147
+ references.push({
148
+ path: `references/${slugify(topic, i)}.md`,
149
+ title: `Reference: ${topic}`,
150
+ bytes: share,
151
+ tokens: Math.ceil(share / BYTES_PER_TOKEN),
152
+ lines: Math.max(1, Math.ceil(share / 60)),
153
+ });
154
+ }
155
+ return {
156
+ flagged: true,
157
+ source: hasBody ? 'body' : 'registry',
158
+ reason,
159
+ totalBytes,
160
+ totalTokens,
161
+ totalLines,
162
+ mainBytes,
163
+ mainTokens,
164
+ references,
165
+ };
166
+ }
167
+
168
+ const parsed = readFrontmatter(bodyText);
169
+ const { preamble, sections } = splitMarkdownSections(parsed.body);
170
+ const keptSections = [];
171
+ const movedSections = [];
172
+ let runningBytes = Buffer.byteLength(preamble, 'utf8') + 512;
173
+ let runningLines = preamble.split(/\r?\n/).length + 25;
174
+
175
+ for (const section of sections) {
176
+ const secBytes = Buffer.byteLength(section.lines.join('\n').trim(), 'utf8');
177
+ const secLines = section.lines.length;
178
+ if (
179
+ movedSections.length === 0 &&
180
+ runningBytes + secBytes <= TARGET_MAIN_BYTES &&
181
+ runningLines + secLines <= TARGET_MAIN_LINES &&
182
+ sections.length > 1
183
+ ) {
184
+ keptSections.push(section);
185
+ runningBytes += secBytes;
186
+ runningLines += secLines;
187
+ } else {
188
+ movedSections.push(section);
189
+ }
190
+ }
191
+ if (movedSections.length === 0 && sections.length > 0) movedSections.push(keptSections.pop());
192
+
193
+ const references = movedSections.map((sec, idx) => {
194
+ const content = `# ${sec.title}\n\n${sec.lines.slice(1).join('\n').trim()}\n`;
195
+ const bytes = Buffer.byteLength(content, 'utf8');
196
+ return {
197
+ path: `references/${slugify(sec.title, idx)}.md`,
198
+ title: sec.title,
199
+ bytes,
200
+ tokens: Math.ceil(bytes / BYTES_PER_TOKEN),
201
+ lines: content.split('\n').length,
202
+ content,
203
+ };
204
+ });
205
+
206
+ const keptBody = [preamble, ...keptSections.map((s) => s.lines.join('\n').trim())].filter(Boolean).join('\n\n');
207
+ const refIndexLines = [
208
+ '## References',
209
+ '',
210
+ 'Progressive-disclosure reference files (load on demand when needed):',
211
+ ...references.map((r) => `- [${r.title}](${r.path}) (~${r.tokens} tokens, ${r.bytes} bytes)`),
212
+ ];
213
+ const compiledBody = `${keptBody}\n\n${refIndexLines.join('\n')}\n`;
214
+ const mainBytes = Buffer.byteLength(compiledBody, 'utf8');
215
+
216
+ return {
217
+ flagged: true,
218
+ source: 'body',
219
+ reason,
220
+ totalBytes,
221
+ totalTokens,
222
+ totalLines,
223
+ mainBytes,
224
+ mainTokens: Math.ceil(mainBytes / BYTES_PER_TOKEN),
225
+ compiledBody,
226
+ references,
227
+ };
228
+ }
229
+
230
+ function serializeFrontmatter({ name, description, license, compatibility, allowedTools, metadata }) {
231
+ const lines = ['---', `name: ${name}`, `description: ${JSON.stringify(description)}`];
232
+ if (license) lines.push(`license: ${JSON.stringify(String(license))}`);
233
+ if (compatibility) lines.push(`compatibility: ${JSON.stringify(String(compatibility).slice(0, 500))}`);
234
+ if (allowedTools) lines.push(`allowed-tools: ${JSON.stringify(String(allowedTools))}`);
235
+ const metaEntries = Object.entries(metadata ?? {}).filter(([, v]) => v !== undefined && v !== null && v !== '');
236
+ if (metaEntries.length > 0) {
237
+ lines.push('metadata:');
238
+ for (const [k, v] of metaEntries.sort(([a], [b]) => a.localeCompare(b))) {
239
+ lines.push(` ${k}: ${JSON.stringify(String(v))}`);
240
+ }
241
+ }
242
+ lines.push('---');
243
+ return lines.join('\n');
244
+ }
245
+
246
+ /** Compile a skill into Agent Skills conforming `SKILL.md` + optional `references/*.md`. */
247
+ export function compileSkill(skill = {}, rawText = null) {
248
+ const name = String(skill.name ?? '').trim();
249
+ const brokenName = nameProblems(name);
250
+ if (brokenName) throw new Error(`skill "${name}" cannot be compiled: name ${brokenName}`);
251
+
252
+ const parsed = rawText ? readFrontmatter(rawText) : { meta: {}, present: [], body: '' };
253
+ const description = buildConformingDescription(skill, parsed.meta.description);
254
+ const metadata = {};
255
+ for (const [k, v] of Object.entries(parsed.meta.metadata ?? {})) metadata[k] = String(v);
256
+ for (const key of parsed.present) {
257
+ if (!SPEC_KEYS.has(key) && parsed.meta[key] !== undefined) metadata[key] = String(parsed.meta[key]);
258
+ }
259
+
260
+ const fmBlock = serializeFrontmatter({
261
+ name,
262
+ description,
263
+ license: parsed.meta.license,
264
+ compatibility: parsed.meta.compatibility,
265
+ allowedTools: parsed.meta['allowed-tools'],
266
+ metadata,
267
+ });
268
+
269
+ const defaultBody = parsed.body.trim() || `# ${name}\n\n${skill.summary ?? description}`;
270
+ const fullCandidate = `${fmBlock}\n\n${defaultBody}\n`;
271
+ const splitPlan = planSkillSplit(skill, rawText ? fullCandidate : null);
272
+ const finalBody = splitPlan.compiledBody ? splitPlan.compiledBody.trim() : defaultBody;
273
+ const mainText = `${fmBlock}\n\n${finalBody}\n`;
274
+ const mainBytes = Buffer.byteLength(mainText, 'utf8');
275
+ const mainTokens = Math.ceil(mainBytes / BYTES_PER_TOKEN);
276
+ const conformance = checkSkill({ directoryName: name, fileName: 'SKILL.md', text: mainText, at: name });
277
+
278
+ return {
279
+ name,
280
+ description,
281
+ mainPath: `${name}/SKILL.md`,
282
+ mainText,
283
+ mainBytes,
284
+ mainTokens,
285
+ references: (splitPlan.references ?? []).map((r) => ({
286
+ path: r.path,
287
+ title: r.title,
288
+ bytes: r.bytes,
289
+ tokens: r.tokens,
290
+ lines: r.lines,
291
+ ...(r.content ? { content: r.content } : {}),
292
+ })),
293
+ splitPlan: {
294
+ flagged: splitPlan.flagged,
295
+ source: splitPlan.source,
296
+ reason: splitPlan.reason,
297
+ totalBytes: splitPlan.totalBytes,
298
+ totalTokens: splitPlan.totalTokens,
299
+ mainBytes: rawText ? mainBytes : splitPlan.mainBytes,
300
+ mainTokens: rawText ? mainTokens : splitPlan.mainTokens,
301
+ references: (splitPlan.references ?? []).map(({ path, title, bytes, tokens, lines }) => ({
302
+ path,
303
+ title,
304
+ bytes,
305
+ tokens,
306
+ lines,
307
+ })),
308
+ },
309
+ conformance,
310
+ };
311
+ }
312
+
313
+ /** Return the sorted names of skills whose declared `paths` globs match any file in `touchedPaths`. */
314
+ export function matchSkillsForPaths(skills = [], touchedPaths = []) {
315
+ const cleanPaths = (touchedPaths ?? []).map((p) => String(p ?? '').replace(/^\/+/, '').trim()).filter(Boolean);
316
+ if (cleanPaths.length === 0) return [];
317
+ const matched = [];
318
+ for (const skill of skills) {
319
+ if (!skill?.name || skill.aliasOf) continue;
320
+ const globs = Array.isArray(skill.paths) ? skill.paths : [];
321
+ if (globs.length > 0 && cleanPaths.some((file) => globs.some((glob) => matchesGlob(file, glob)))) {
322
+ matched.push(skill.name);
323
+ }
324
+ }
325
+ return [...new Set(matched)].sort();
326
+ }
327
+
328
+ /** Extract the sorted list of skill names opened by an agent from an R02 transcript. */
329
+ export function extractOpenedSkills(transcriptInput, knownSkillNames = null) {
330
+ const known = knownSkillNames ? new Set(knownSkillNames) : null;
331
+ const opened = new Set();
332
+ const recordIfKnown = (candidate) => {
333
+ const name = String(candidate ?? '').trim().toLowerCase();
334
+ if (!name || nameProblems(name) !== null) return;
335
+ if (!known || known.has(name)) opened.add(name);
336
+ };
337
+
338
+ if (Array.isArray(transcriptInput)) {
339
+ for (const item of transcriptInput) {
340
+ if (typeof item === 'string') recordIfKnown(item);
341
+ else if (item && typeof item === 'object') {
342
+ for (const s of extractOpenedSkills(JSON.stringify(item), known)) opened.add(s);
343
+ }
344
+ }
345
+ return [...opened].sort();
346
+ }
347
+
348
+ const text = typeof transcriptInput === 'string' ? transcriptInput : JSON.stringify(transcriptInput ?? '');
349
+ for (const m of text.matchAll(/(?:^|[\s"'`:=(/])skills\/([a-z0-9-]+)(?:\/SKILL\.md|\.md|\/|[\s"'`),]|$)/gi)) {
350
+ recordIfKnown(m[1]);
351
+ }
352
+ for (const m of text.matchAll(/(?:^|[\s"'`:=(/])([a-z0-9-]+)\/SKILL\.md\b/g)) recordIfKnown(m[1]);
353
+ for (const m of text.matchAll(/\bsimt(?:-dev)?\s+skills\s+show\s+([a-z0-9-]+)\b/gi)) recordIfKnown(m[1]);
354
+ for (const m of text.matchAll(/"(?:skill|skill_name|skillName)"\s*:\s*"([a-z0-9-]+)"/gi)) recordIfKnown(m[1]);
355
+ for (const m of text.matchAll(/"name"\s*:\s*"skills_show"[\s\S]{0,200}?"name"\s*:\s*"([a-z0-9-]+)"/gi)) {
356
+ recordIfKnown(m[1]);
357
+ }
358
+ return [...opened].sort();
359
+ }
360
+
361
+ /** Compute per-skill trigger recall/precision across R02 runs and attach split plans. */
362
+ export function evaluateSkillTriggers(registry, runs = [], { bodiesBySkill = new Map(), goldPathsByTask = new Map() } = {}) {
363
+ const skills = [...(registry?.skills ?? [])].sort((a, b) => a.name.localeCompare(b.name));
364
+ const knownNames = new Set(skills.map((s) => s.name));
365
+
366
+ const normalizedRuns = (runs ?? []).map((run) => {
367
+ const taskId = String(run?.task ?? '');
368
+ const expectedFromRun = Array.isArray(run?.skills?.expected)
369
+ ? run.skills.expected
370
+ : Array.isArray(run?.expectedSkills) ? run.expectedSkills : null;
371
+ const goldPaths = run?.goldFiles ?? goldPathsByTask.get(taskId) ?? [];
372
+ const expected = expectedFromRun
373
+ ? expectedFromRun.filter((n) => knownNames.has(n))
374
+ : matchSkillsForPaths(skills, goldPaths);
375
+ const openedFromRun = Array.isArray(run?.skills?.opened)
376
+ ? run.skills.opened
377
+ : Array.isArray(run?.openedSkills) ? run.openedSkills : null;
378
+ const opened = openedFromRun
379
+ ? openedFromRun.filter((n) => knownNames.has(n))
380
+ : extractOpenedSkills(run?.transcript ?? '', knownNames);
381
+ return { task: taskId, arm: String(run?.arm ?? 'default'), expected: new Set(expected), opened: new Set(opened) };
382
+ });
383
+
384
+ const rows = skills.map((skill) => {
385
+ let tp = 0;
386
+ let fp = 0;
387
+ let fn = 0;
388
+ for (const run of normalizedRuns) {
389
+ const exp = run.expected.has(skill.name);
390
+ const opn = run.opened.has(skill.name);
391
+ if (exp && opn) tp += 1;
392
+ else if (!exp && opn) fp += 1;
393
+ else if (exp && !opn) fn += 1;
394
+ }
395
+ const expected = tp + fn;
396
+ const opened = tp + fp;
397
+ const splitPlan = planSkillSplit(skill, bodiesBySkill.get(skill.name) ?? null);
398
+ return {
399
+ name: skill.name,
400
+ bytes: skill.bytes ?? 0,
401
+ tokens: splitPlan.totalTokens,
402
+ paths: skill.paths ?? [],
403
+ expected,
404
+ opened,
405
+ tp,
406
+ fp,
407
+ fn,
408
+ recall: expected > 0 ? tp / expected : null,
409
+ precision: opened > 0 ? tp / opened : null,
410
+ flagged: splitPlan.flagged,
411
+ splitPlan: {
412
+ flagged: splitPlan.flagged,
413
+ source: splitPlan.source,
414
+ reason: splitPlan.reason,
415
+ totalBytes: splitPlan.totalBytes,
416
+ totalTokens: splitPlan.totalTokens,
417
+ mainBytes: splitPlan.mainBytes,
418
+ mainTokens: splitPlan.mainTokens,
419
+ references: (splitPlan.references ?? []).map(({ path, title, bytes, tokens, lines }) => ({
420
+ path,
421
+ title,
422
+ bytes,
423
+ tokens,
424
+ lines,
425
+ })),
426
+ },
427
+ };
428
+ });
429
+
430
+ return {
431
+ runCount: normalizedRuns.length,
432
+ skillCount: rows.length,
433
+ flaggedCount: rows.filter((r) => r.flagged).length,
434
+ skills: rows,
435
+ };
436
+ }
437
+
438
+ /** Create a proposal-only description improvement with provenance (Principle P2). */
439
+ export async function proposeSkillDescription(
440
+ skill,
441
+ { currentDescription = '', triggerStats = null, proposer = null, model = 'deterministic-template' } = {},
442
+ ) {
443
+ const prompt = [
444
+ `Skill: ${skill.name}`,
445
+ `Summary: ${skill.summary ?? ''}`,
446
+ `Paths: ${(skill.paths ?? []).join(', ') || '(none)'}`,
447
+ `Keywords: ${(skill.keywords ?? []).join(', ') || '(none)'}`,
448
+ `Capabilities: ${(skill.capabilities ?? []).join(', ') || '(none)'}`,
449
+ `Current description: ${currentDescription || skill.summary || ''}`,
450
+ triggerStats
451
+ ? `Trigger stats: expected=${triggerStats.expected}, opened=${triggerStats.opened}, recall=${triggerStats.recall ?? 'n/a'}, precision=${triggerStats.precision ?? 'n/a'}`
452
+ : 'Trigger stats: unmeasured',
453
+ ].join('\n');
454
+
455
+ const promptHash = sha256Tag(prompt);
456
+ let candidate = '';
457
+ let usedModel = model;
458
+ if (typeof proposer === 'function') {
459
+ const raw = await proposer({ skill, prompt, triggerStats });
460
+ if (typeof raw === 'string' && raw.trim()) candidate = raw.trim();
461
+ else if (raw && typeof raw.description === 'string') {
462
+ candidate = raw.description.trim();
463
+ if (raw.model) usedModel = String(raw.model);
464
+ }
465
+ }
466
+ const proposedDescription = buildConformingDescription(skill, candidate);
467
+ return {
468
+ schemaVersion: 1,
469
+ skill: skill.name,
470
+ status: 'proposal',
471
+ currentDescription: currentDescription || skill.summary || '',
472
+ proposedDescription,
473
+ provenance: { model: usedModel, promptHash, digest: sha256Tag(proposedDescription) },
474
+ };
475
+ }
@@ -9,14 +9,9 @@ import { readFile } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
10
 
11
11
  import { isTestPath } from '../bench/mine.mjs';
12
+ import { langOf } from './store.mjs';
12
13
  import { importsOf } from './tests.mjs';
13
14
 
14
- const LANG = {
15
- js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
16
- ts: 'typescript', tsx: 'typescript', kt: 'kotlin', kts: 'kotlin', java: 'java',
17
- py: 'python', go: 'go', rs: 'rust', swift: 'swift', dart: 'dart', rb: 'ruby', cs: 'csharp',
18
- };
19
-
20
15
  /**
21
16
  * Test files that import one of `changed`, directly or through other files.
22
17
  * A changed file that is itself a test is included: the edit is the test.
@@ -92,10 +87,3 @@ export async function readEntries(root, files) {
92
87
  export async function readImportEdges(root, files) {
93
88
  return importEdges(await readEntries(root, files));
94
89
  }
95
-
96
- function langOf(file) {
97
- const base = file.slice(file.lastIndexOf('/') + 1);
98
- const dot = base.lastIndexOf('.');
99
- if (dot <= 0) return '';
100
- return LANG[base.slice(dot + 1)] ?? '';
101
- }
@@ -6,22 +6,35 @@
6
6
  // and a lesson do the same with their own path list. Nothing here is stored: the
7
7
  // answer is the records on disk.
8
8
 
9
+ import { parseDecisionFrontmatter, splitFrontmatter } from '../memory/decide.mjs';
10
+
9
11
  const SIBLING = /^\*{0,2}[A-Z][\w-]*(?:[ \t]+[\w-]+){0,3}:\*{0,2}(?:[ \t]|$)/;
10
12
  const HEADING = /^#{1,6}\s/;
11
13
  const TOOL = /^(?:simt-dev|simt)\b/;
12
14
 
13
- /** Backticked tokens of the Affects paragraph, in order. */
15
+ /**
16
+ * Affected tokens of a decision record: reads `simt-affects:` frontmatter first (R34),
17
+ * falling back to backticked tokens of the `Affects:` paragraph (R25).
18
+ */
14
19
  export function affectsTokens(text) {
15
- const lines = String(text).split('\n');
20
+ const fm = parseDecisionFrontmatter(text);
21
+ const { body } = splitFrontmatter(text);
22
+ const lines = String(body).split('\n');
16
23
  const start = lines.findIndex((line) => /^\s*\*{0,2}Affects:\*{0,2}/.test(line));
17
- if (start < 0) return [];
18
- const parts = [lines[start].replace(/^\s*\*{0,2}Affects:\*{0,2}\s*/, '')];
19
- for (let i = start + 1; i < lines.length; i += 1) {
20
- const line = lines[i];
21
- if (line.trim() === '' || SIBLING.test(line) || HEADING.test(line)) break;
22
- parts.push(line);
24
+ const proseTokens = [];
25
+ if (start >= 0) {
26
+ const parts = [lines[start].replace(/^\s*\*{0,2}Affects:\*{0,2}\s*/, '')];
27
+ for (let i = start + 1; i < lines.length; i += 1) {
28
+ const line = lines[i];
29
+ if (line.trim() === '' || SIBLING.test(line) || HEADING.test(line)) break;
30
+ parts.push(line);
31
+ }
32
+ proseTokens.push(...[...parts.join('\n').matchAll(/`([^`]+)`/g)].map((match) => match[1].trim()).filter(Boolean));
33
+ }
34
+ if (fm.hasAffects) {
35
+ return [...new Set([...fm.affects, ...proseTokens])];
23
36
  }
24
- return [...parts.join('\n').matchAll(/`([^`]+)`/g)].map((match) => match[1].trim()).filter(Boolean);
37
+ return proseTokens;
25
38
  }
26
39
 
27
40
  /**
@@ -205,8 +218,7 @@ function extension(file) {
205
218
  }
206
219
 
207
220
  function frontmatter(text) {
208
- const match = /^---\n([\s\S]*?)\n---/.exec(String(text));
209
- return match ? match[1] : '';
221
+ return splitFrontmatter(text).front ?? '';
210
222
  }
211
223
 
212
224
  function quotedList(front, key) {