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.
Files changed (91) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +138 -74
  3. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  4. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
  5. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  6. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  7. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  8. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  9. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  10. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  11. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  12. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  13. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  14. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  15. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  16. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  17. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  18. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  19. package/package.json +2 -2
  20. package/rules/core.md +2 -2
  21. package/src/agenthooks/policy.mjs +10 -2
  22. package/src/agenthooks/prompt.mjs +81 -0
  23. package/src/agenthooks/render.mjs +138 -17
  24. package/src/argv.mjs +34 -1
  25. package/src/bench/grade.mjs +3 -0
  26. package/src/bench/run.mjs +85 -39
  27. package/src/bench/variants.mjs +456 -0
  28. package/src/brief/assemble.mjs +346 -0
  29. package/src/brief/kind.mjs +134 -0
  30. package/src/brief/packet.mjs +197 -0
  31. package/src/ci/workflow.mjs +11 -1
  32. package/src/commands/agenthook.mjs +84 -5
  33. package/src/commands/bench.mjs +53 -16
  34. package/src/commands/brief.mjs +78 -0
  35. package/src/commands/compare.mjs +10 -2
  36. package/src/commands/decide.mjs +116 -0
  37. package/src/commands/evidence.mjs +96 -0
  38. package/src/commands/gate.mjs +165 -0
  39. package/src/commands/help.mjs +65 -3
  40. package/src/commands/hooks.mjs +45 -5
  41. package/src/commands/impact.mjs +205 -0
  42. package/src/commands/index.mjs +52 -1
  43. package/src/commands/lessons.mjs +127 -0
  44. package/src/commands/local.mjs +97 -0
  45. package/src/commands/map.mjs +126 -0
  46. package/src/commands/repoindex.mjs +55 -0
  47. package/src/commands/rules.mjs +17 -2
  48. package/src/commands/shared.mjs +398 -0
  49. package/src/commands/skills.mjs +3 -17
  50. package/src/commands/skillscompile.mjs +255 -0
  51. package/src/commands/sync.mjs +87 -3
  52. package/src/commands/tests.mjs +145 -0
  53. package/src/commands/tokens.mjs +30 -1
  54. package/src/commands/why.mjs +172 -0
  55. package/src/compare/learn.mjs +680 -0
  56. package/src/decisions/index.mjs +116 -65
  57. package/src/federation/client.mjs +188 -0
  58. package/src/federation/config.mjs +134 -0
  59. package/src/federation/query.mjs +48 -0
  60. package/src/graph/compile.mjs +475 -0
  61. package/src/graph/spec.mjs +1 -1
  62. package/src/hooks/dispatcher.mjs +15 -0
  63. package/src/hooks/shim.mjs +31 -0
  64. package/src/index/compact.mjs +228 -0
  65. package/src/index/extract.mjs +331 -0
  66. package/src/index/gate.mjs +89 -0
  67. package/src/index/govern.mjs +234 -0
  68. package/src/index/impact.mjs +186 -0
  69. package/src/index/map.mjs +627 -0
  70. package/src/index/persist.mjs +239 -0
  71. package/src/index/store.mjs +515 -0
  72. package/src/index/tests.mjs +294 -0
  73. package/src/kernel/budget.mjs +100 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/variants.mjs +353 -0
  76. package/src/ledger/index.mjs +147 -0
  77. package/src/local/client.mjs +482 -0
  78. package/src/local/config.mjs +234 -0
  79. package/src/mcp/tools.mjs +138 -1
  80. package/src/measure/context.mjs +7 -5
  81. package/src/memory/decide.mjs +462 -0
  82. package/src/memory/lessons.mjs +858 -0
  83. package/src/projection/disclosure.mjs +92 -0
  84. package/src/projection/lock.mjs +125 -0
  85. package/src/projection/manifest.mjs +52 -0
  86. package/src/projection/markers.mjs +56 -17
  87. package/src/projection/project.mjs +24 -17
  88. package/src/projection/rules.mjs +407 -49
  89. package/src/projection/shared.mjs +428 -0
  90. package/src/projection/targets.mjs +2 -1
  91. package/src/toolwords.mjs +8 -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
+ }
@@ -164,7 +164,7 @@ export function checkSkill({ directoryName, fileName = 'SKILL.md', text, at }) {
164
164
  * characters. That is the whole failure this file exists to prevent, one level down - a
165
165
  * lint that is confidently wrong about a conforming file.
166
166
  */
167
- function readFrontmatter(text) {
167
+ export function readFrontmatter(text) {
168
168
  const meta = {};
169
169
  const present = [];
170
170
  const body = text.replace(/^/, '');
@@ -28,6 +28,7 @@ import { resolveGate } from './resolve.mjs';
28
28
  import { CLI } from '../name.mjs';
29
29
  import { MANIFEST_FILE } from '../name.mjs';
30
30
  import { assertVersionPin } from '../package-version.mjs';
31
+ import { recordRun } from '../ledger/index.mjs';
31
32
 
32
33
  const exec = promisify(execFile);
33
34
 
@@ -179,7 +180,21 @@ export async function runGates(event, { root, manifest }) {
179
180
  cwd = resolved;
180
181
  }
181
182
 
183
+ const started = Date.now();
182
184
  const outcome = await runGate(resolution, cwd);
185
+ // The ledger is not the gate. A tree that cannot be named leaves no line, and the
186
+ // claim check then finds nothing, which is the direction that cannot invent a run.
187
+ try {
188
+ const cmd = typeof resolution.command === 'string' ? resolution.command : resolution.argv.join(' ');
189
+ await recordRun(root, {
190
+ cmd,
191
+ exit: outcome.code,
192
+ ms: Date.now() - started,
193
+ output: `${outcome.stdout ?? ''}\n${outcome.stderr ?? ''}`,
194
+ });
195
+ } catch {
196
+ // Recorded by absence: the next evidence read for this tree has no line.
197
+ }
183
198
  results.push({
184
199
  name,
185
200
  status: outcome.code === 0 ? 'passed' : 'failed',
@@ -90,6 +90,37 @@ ${renderLocator(`hooks run ${event} --root "${D}ROOT"`, {
90
90
  })}`;
91
91
  }
92
92
 
93
+ /**
94
+ * The commit-msg shim. It is not a dispatcher event: those shims do not forward
95
+ * git's arguments, and this one has to, because the message file is `$1`.
96
+ *
97
+ * Written only when the manifest sets `gates.evidence`. An unset value does not
98
+ * install a hook the repository did not ask for.
99
+ *
100
+ * @param {{npm?: boolean, version?: string}} [opts]
101
+ * @returns {string} POSIX sh source, LF-terminated
102
+ */
103
+ export function renderEvidenceShim({ npm = true, version } = {}) {
104
+ const v = version ?? packageVersion();
105
+ const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : `npm i -g ${PACKAGE_NAME}@${v}`;
106
+ const D = '$';
107
+ return `#!/bin/sh
108
+ ${SHIM_MARKER}
109
+ #
110
+ # A commit message that claims a run is checked against the ledger. The message
111
+ # file is the first argument git passes, so this shim forwards it. Reinstall with:
112
+ # ${CLI} hooks install --yes
113
+
114
+ set -eu
115
+
116
+ ROOT="${D}(git rev-parse --show-toplevel 2>/dev/null || pwd)"
117
+
118
+ ${renderLocator(`evidence check "${D}1" --root "${D}ROOT"`, {
119
+ subject: 'the claim check',
120
+ remedy: `REMEDY run: ${install}, or set ${BIN_ENV} to the ${CLI} binary, or unset ${HOOKS_PATH_KEY} to opt out`,
121
+ })}`;
122
+ }
123
+
93
124
  /**
94
125
  * A line only a chained shim carries.
95
126
  *