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,858 @@
1
+ // Deterministic lesson candidate ledger and promotion to `docs/lessons/NNNN-<slug>.md` (plan 0003 R32).
2
+ //
3
+ // Four deterministic candidate sources write to `.simt-dev/lessons.jsonl`:
4
+ // 1. `fail-then-fix`: a gate or test that fails and then passes in one session,
5
+ // recorded as `error signature -> fix diff` (where the signature is the sha256
6
+ // hash of the normalised first error line);
7
+ // 2. `revert`: reverted commits (`Revert "..."` / `This reverts commit <sha>`);
8
+ // 3. `review-block`: ADR-008 `BLOCK` review findings;
9
+ // 4. `hook-denial`: agent-time hook denials (R15).
10
+ //
11
+ // `simt lessons review` promotes a candidate to `docs/lessons/NNNN-<slug>.md` with the
12
+ // Appendix A frontmatter (`simt-trigger`, `simt-evidence`, `simt-expires`).
13
+
14
+ import { createHash } from 'node:crypto';
15
+ import { execFile } from 'node:child_process';
16
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
17
+ import { dirname, join } from 'node:path';
18
+ import { promisify } from 'node:util';
19
+
20
+ import { postDeliveryJson } from '../agenthooks/render.mjs';
21
+ import { lessonPaths } from '../index/govern.mjs';
22
+ import { splitFrontmatter } from './decide.mjs';
23
+
24
+ const exec = promisify(execFile);
25
+
26
+ export const LESSONS_FILE = '.simt-dev/lessons.jsonl';
27
+ export const LESSONS_DIR = 'docs/lessons';
28
+ export const LESSON_KEYS = new Set(['simt-trigger', 'simt-evidence', 'simt-expires']);
29
+ const DEFAULT_EXPIRES = '2027-04-01';
30
+
31
+ const STOP_WORDS = new Set([
32
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'when', 'were',
33
+ 'have', 'has', 'had', 'not', 'are', 'was', 'but', 'all', 'any', 'can', 'will',
34
+ 'error', 'failed', 'fail', 'failure', 'test', 'tests', 'pass', 'passed', 'exit',
35
+ ]);
36
+
37
+ /**
38
+ * Strip ANSI escape sequences and collapse whitespace for deterministic hashing.
39
+ *
40
+ * @param {string} line
41
+ * @returns {string}
42
+ */
43
+ export function normaliseErrorLine(line) {
44
+ return String(line ?? '')
45
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
46
+ .trim()
47
+ .replace(/\s+/g, ' ');
48
+ }
49
+
50
+ /**
51
+ * Extract the first error-bearing line from command output.
52
+ *
53
+ * Skips TAP preamble and passing (`ok N`) lines so TAP streams resolve to the first
54
+ * failing (`not ok`) or error line rather than the header.
55
+ *
56
+ * @param {string} output
57
+ * @returns {string}
58
+ */
59
+ export function firstErrorLine(output) {
60
+ const clean = String(output ?? '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
61
+ const rawLines = clean
62
+ .split(/\r?\n/)
63
+ .map((line) => line.trim())
64
+ .filter(Boolean);
65
+ if (rawLines.length === 0) return '';
66
+
67
+ const filtered = rawLines.filter(
68
+ (line) =>
69
+ !/^TAP version \d+$/i.test(line) &&
70
+ !/^1\.\.\d+$/.test(line) &&
71
+ !/^# Subtest:/i.test(line) &&
72
+ !/^---\s*$/.test(line) &&
73
+ !/^\.\.\.\s*$/.test(line) &&
74
+ !/^ok\b/i.test(line),
75
+ );
76
+ const pool = filtered.length > 0 ? filtered : rawLines;
77
+
78
+ const explicit = pool.find((line) =>
79
+ /^(?:not ok\b|FAIL\b|ERROR\b|Error:|AssertionError\b|TypeError\b|SyntaxError\b|ReferenceError\b|RangeError\b|✗|×|✖|❌)|\b(?:AssertionError|TypeError|SyntaxError|ReferenceError|RangeError|Error):/i.test(
80
+ line,
81
+ ),
82
+ );
83
+ if (explicit) return normaliseErrorLine(explicit);
84
+
85
+ const secondary = pool.find(
86
+ (line) => /\b(?:failed|failure|error|blocked|denied)\b/i.test(line) && !/^\d+\s+pass\b/i.test(line),
87
+ );
88
+ if (secondary) return normaliseErrorLine(secondary);
89
+
90
+ return normaliseErrorLine(pool[0]);
91
+ }
92
+
93
+ /**
94
+ * Deterministic `sha256-<hex>` signature of the normalised first error line.
95
+ *
96
+ * @param {string} outputOrLine
97
+ * @returns {string}
98
+ */
99
+ export function errorSignature(outputOrLine) {
100
+ const line = normaliseErrorLine(firstErrorLine(outputOrLine));
101
+ return `sha256-${createHash('sha256').update(line, 'utf8').digest('hex')}`;
102
+ }
103
+
104
+ function extractKeywords(text, paths = []) {
105
+ const words = new Set();
106
+ for (const p of paths) {
107
+ const base = String(p)
108
+ .split('/')
109
+ .pop()
110
+ ?.replace(/\.[^.]+$/, '');
111
+ if (base && base.length >= 3 && !STOP_WORDS.has(base.toLowerCase())) {
112
+ words.add(base.toLowerCase());
113
+ }
114
+ }
115
+ const matches = String(text ?? '')
116
+ .toLowerCase()
117
+ .match(/[a-z][a-z0-9_-]{2,}/g);
118
+ for (const token of matches ?? []) {
119
+ if (!STOP_WORDS.has(token) && !/^sha256/.test(token)) {
120
+ words.add(token);
121
+ }
122
+ if (words.size >= 6) break;
123
+ }
124
+ return [...words];
125
+ }
126
+
127
+ function slugify(text) {
128
+ const slug = String(text ?? '')
129
+ .toLowerCase()
130
+ .replace(/^not ok\s+\d+\s*-?\s*/i, '')
131
+ .replace(/^(?:error|assertionerror|fail|revert|blocked)[:\s-]*/i, '')
132
+ .replace(/[^a-z0-9]+/g, '-')
133
+ .replace(/^-+|-+$/g, '')
134
+ .slice(0, 48)
135
+ .replace(/-+$/g, '');
136
+ return slug || 'lesson';
137
+ }
138
+
139
+ /**
140
+ * Read all candidate records from `.simt-dev/lessons.jsonl`.
141
+ *
142
+ * @param {string} root
143
+ * @returns {Promise<{candidates: Array<object>, errors: string[]}>}
144
+ */
145
+ export async function readLessonCandidates(root) {
146
+ let text;
147
+ try {
148
+ text = await readFile(join(root, LESSONS_FILE), 'utf8');
149
+ } catch (error) {
150
+ if (error.code === 'ENOENT') return { candidates: [], errors: [] };
151
+ throw error;
152
+ }
153
+ const candidates = [];
154
+ const errors = [];
155
+ for (const [i, raw] of text.split('\n').entries()) {
156
+ if (raw.trim() === '') continue;
157
+ try {
158
+ const item = JSON.parse(raw);
159
+ if (typeof item.kind !== 'string' || typeof item.signature !== 'string') {
160
+ errors.push(`line ${i + 1} has no kind or signature`);
161
+ continue;
162
+ }
163
+ candidates.push(item);
164
+ } catch (error) {
165
+ errors.push(`line ${i + 1} does not parse (${error.message})`);
166
+ }
167
+ }
168
+ return { candidates, errors };
169
+ }
170
+
171
+ async function writeLessonCandidates(root, candidates) {
172
+ const full = join(root, LESSONS_FILE);
173
+ await mkdir(dirname(full), { recursive: true });
174
+ const body = candidates.map((c) => JSON.stringify(c)).join('\n');
175
+ await writeFile(full, body ? `${body}\n` : '', 'utf8');
176
+ }
177
+
178
+ function sameEvidence(a = [], b = []) {
179
+ if (a.length !== b.length) return false;
180
+ return a.every((v, i) => v === b[i]);
181
+ }
182
+
183
+ /**
184
+ * Append one deterministic candidate to `.simt-dev/lessons.jsonl`, deduplicating by
185
+ * `(kind, signature, evidence)`.
186
+ *
187
+ * @param {string} root
188
+ * @param {object} candidate
189
+ * @returns {Promise<object>}
190
+ */
191
+ export async function appendLessonCandidate(root, candidate) {
192
+ const { candidates } = await readLessonCandidates(root);
193
+ const kind = String(candidate.kind ?? 'fail-then-fix');
194
+ const errorLine = normaliseErrorLine(candidate.errorLine ?? firstErrorLine(candidate.output ?? candidate.summary ?? ''));
195
+ const signature = candidate.signature ?? errorSignature(errorLine);
196
+ const evidence = Array.isArray(candidate.evidence) ? candidate.evidence.map(String).filter(Boolean) : [];
197
+
198
+ const existing = candidates.find(
199
+ (c) =>
200
+ c.kind === kind &&
201
+ c.signature === signature &&
202
+ (sameEvidence(c.evidence ?? [], evidence) || (c.cmd && candidate.cmd && c.cmd === candidate.cmd && c.evidence?.[0] === evidence[0])),
203
+ );
204
+ if (existing) return existing;
205
+
206
+ const paths = Array.isArray(candidate.paths)
207
+ ? [...new Set(candidate.paths.map(String).filter(Boolean))].sort()
208
+ : [];
209
+ const keywords = Array.isArray(candidate.keywords) && candidate.keywords.length > 0
210
+ ? [...new Set(candidate.keywords.map(String).filter(Boolean))]
211
+ : extractKeywords(`${errorLine} ${candidate.cmd ?? ''}`, paths);
212
+
213
+ const nextIndex = candidates.length + 1;
214
+ const record = {
215
+ v: 1,
216
+ id: candidate.id ?? `c-${String(nextIndex).padStart(4, '0')}`,
217
+ ts: candidate.ts ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
218
+ kind,
219
+ signature,
220
+ errorLine,
221
+ summary: String(candidate.summary ?? (errorLine || `${kind} candidate`)).trim(),
222
+ cmd: candidate.cmd ?? null,
223
+ paths,
224
+ keywords,
225
+ evidence,
226
+ fixDiff: String(candidate.fixDiff ?? ''),
227
+ status: candidate.status ?? 'candidate',
228
+ promotedTo: candidate.promotedTo ?? null,
229
+ };
230
+
231
+ candidates.push(record);
232
+ await writeLessonCandidates(root, candidates);
233
+ return record;
234
+ }
235
+
236
+ async function diffBetweenTrees(root, fromRef, toRef) {
237
+ if (!fromRef || !toRef || fromRef === toRef) {
238
+ return { paths: [], diff: '' };
239
+ }
240
+ try {
241
+ const [namesOut, diffOut] = await Promise.all([
242
+ exec('git', ['diff', '--name-only', fromRef, toRef, '--', '.', ':(exclude).simt-dev'], {
243
+ cwd: root,
244
+ maxBuffer: 8 << 20,
245
+ }),
246
+ exec('git', ['diff', '--unified=3', fromRef, toRef, '--', '.', ':(exclude).simt-dev'], {
247
+ cwd: root,
248
+ maxBuffer: 8 << 20,
249
+ }),
250
+ ]);
251
+ const paths = namesOut.stdout
252
+ .split('\n')
253
+ .map((s) => s.trim())
254
+ .filter(Boolean)
255
+ .sort();
256
+ return { paths, diff: diffOut.stdout.trim() };
257
+ } catch {
258
+ return { paths: [], diff: '' };
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Record a `fail-then-fix` candidate when a command that failed in the session
264
+ * subsequently passes on a fixed tree.
265
+ *
266
+ * @param {string} root
267
+ * @param {{failEntry: object, passEntry: object, failOutput?: string, fixDiff?: string, paths?: string[]}} params
268
+ * @returns {Promise<object>}
269
+ */
270
+ export async function recordFailThenFix(root, { failEntry, passEntry, failOutput, fixDiff, paths }) {
271
+ const rawError = failOutput ?? failEntry?.errorLine ?? failEntry?.summary ?? failEntry?.cmd ?? '';
272
+ const errorLine = normaliseErrorLine(failEntry?.errorLine ?? firstErrorLine(rawError));
273
+ const signature = failEntry?.signature ?? errorSignature(errorLine);
274
+ const failTree = String(failEntry?.tree ?? '');
275
+ const fixTree = String(passEntry?.tree ?? '');
276
+
277
+ let resolvedPaths = Array.isArray(paths) ? paths : null;
278
+ let resolvedDiff = typeof fixDiff === 'string' ? fixDiff : null;
279
+ if (resolvedPaths === null || resolvedDiff === null) {
280
+ const treeDiff = await diffBetweenTrees(root, failTree, fixTree);
281
+ resolvedPaths ??= treeDiff.paths;
282
+ resolvedDiff ??= treeDiff.diff;
283
+ }
284
+
285
+ return appendLessonCandidate(root, {
286
+ kind: 'fail-then-fix',
287
+ cmd: passEntry?.cmd ?? failEntry?.cmd ?? null,
288
+ signature,
289
+ errorLine,
290
+ summary: errorLine || `${passEntry?.cmd ?? 'gate'} failed then passed`,
291
+ evidence: [failTree, fixTree].filter(Boolean),
292
+ paths: resolvedPaths,
293
+ fixDiff: resolvedDiff,
294
+ });
295
+ }
296
+
297
+ /**
298
+ * Scan `.simt-dev/ledger.jsonl` for any fail-then-fix sequences in the session and
299
+ * ensure each is recorded in `.simt-dev/lessons.jsonl`.
300
+ *
301
+ * @param {string} root
302
+ * @param {Array<object>} [entries]
303
+ * @returns {Promise<Array<object>>}
304
+ */
305
+ export async function captureFromLedger(root, entries = null) {
306
+ let list = entries;
307
+ if (!Array.isArray(list)) {
308
+ try {
309
+ const raw = await readFile(join(root, '.simt-dev', 'ledger.jsonl'), 'utf8');
310
+ list = raw
311
+ .split('\n')
312
+ .map((s) => s.trim())
313
+ .filter(Boolean)
314
+ .map((s) => JSON.parse(s));
315
+ } catch {
316
+ list = [];
317
+ }
318
+ }
319
+
320
+ const pendingFailByCmd = new Map();
321
+ const captured = [];
322
+
323
+ for (const entry of list) {
324
+ if (!entry || typeof entry.cmd !== 'string' || typeof entry.exit !== 'number') continue;
325
+ if (entry.exit !== 0) {
326
+ // Keep the first failing run in a fail streak so `failTree` points at the broken state
327
+ // and the signature reflects the initial failure before edits fixed it.
328
+ if (!pendingFailByCmd.has(entry.cmd)) {
329
+ pendingFailByCmd.set(entry.cmd, entry);
330
+ }
331
+ } else if (pendingFailByCmd.has(entry.cmd)) {
332
+ const failEntry = pendingFailByCmd.get(entry.cmd);
333
+ pendingFailByCmd.delete(entry.cmd);
334
+ const record = await recordFailThenFix(root, { failEntry, passEntry: entry });
335
+ captured.push(record);
336
+ }
337
+ }
338
+ return captured;
339
+ }
340
+
341
+ /**
342
+ * Capture reverted commits from git history into `.simt-dev/lessons.jsonl`.
343
+ *
344
+ * @param {string} root
345
+ * @param {{limit?: number}} [options]
346
+ * @returns {Promise<Array<object>>}
347
+ */
348
+ export async function captureRevertedCommits(root, options = {}) {
349
+ const limit = options.limit ?? 50;
350
+ let stdout = '';
351
+ try {
352
+ const res = await exec(
353
+ 'git',
354
+ ['log', '--no-merges', `-n`, String(limit), '--format=%H%x00%s%x00%b%x1e'],
355
+ { cwd: root, maxBuffer: 8 << 20 },
356
+ );
357
+ stdout = res.stdout;
358
+ } catch {
359
+ return [];
360
+ }
361
+
362
+ const captured = [];
363
+ const records = stdout
364
+ .split('\x1e')
365
+ .map((s) => s.trim())
366
+ .filter(Boolean);
367
+
368
+ for (const raw of records.reverse()) {
369
+ const [sha, subject = '', body = ''] = raw.split('\x00');
370
+ const revertBodyMatch = /This reverts commit ([0-9a-f]{7,40})/i.exec(body);
371
+ const isRevertSubject = /^Revert\s+".+"/i.test(subject.trim());
372
+ if (!revertBodyMatch && !isRevertSubject) continue;
373
+
374
+ const failSha = revertBodyMatch ? revertBodyMatch[1] : `${sha}^`;
375
+ const errorLine = normaliseErrorLine(subject);
376
+ const signature = errorSignature(errorLine);
377
+
378
+ let paths = [];
379
+ let fixDiff = '';
380
+ try {
381
+ const [namesRes, diffRes] = await Promise.all([
382
+ exec('git', ['diff-tree', '--no-commit-id', '--name-only', '-r', sha], { cwd: root, maxBuffer: 8 << 20 }),
383
+ exec('git', ['show', '--format=', '--unified=3', sha], { cwd: root, maxBuffer: 8 << 20 }),
384
+ ]);
385
+ paths = namesRes.stdout
386
+ .split('\n')
387
+ .map((s) => s.trim())
388
+ .filter(Boolean)
389
+ .sort();
390
+ fixDiff = diffRes.stdout.trim();
391
+ } catch {
392
+ // ignore diff failure
393
+ }
394
+
395
+ const record = await appendLessonCandidate(root, {
396
+ kind: 'revert',
397
+ cmd: 'git revert',
398
+ signature,
399
+ errorLine,
400
+ summary: errorLine,
401
+ evidence: [failSha, sha],
402
+ paths,
403
+ fixDiff,
404
+ });
405
+ captured.push(record);
406
+ }
407
+ return captured;
408
+ }
409
+
410
+ /**
411
+ * Record an ADR-008 `BLOCK` review finding in `.simt-dev/lessons.jsonl`.
412
+ *
413
+ * @param {string} root
414
+ * @param {{summary: string, paths?: string[], evidence?: string[], fixDiff?: string, keywords?: string[]}} finding
415
+ * @returns {Promise<object>}
416
+ */
417
+ export async function recordReviewBlock(root, finding) {
418
+ const raw = String(finding?.summary ?? finding?.errorLine ?? 'BLOCK review finding').trim();
419
+ const errorLine = normaliseErrorLine(firstErrorLine(raw));
420
+ const signature = errorSignature(errorLine);
421
+ const inferredPaths = [...raw.matchAll(/\b([\w./-]+\.(?:mjs|js|ts|tsx|py|go|kt|java|rs|md|json))(?::\d+)?/g)].map(
422
+ (m) => m[1],
423
+ );
424
+ const paths = [...new Set([...(finding?.paths ?? []), ...inferredPaths])].sort();
425
+
426
+ return appendLessonCandidate(root, {
427
+ kind: 'review-block',
428
+ cmd: 'review',
429
+ signature,
430
+ errorLine,
431
+ summary: errorLine,
432
+ evidence: Array.isArray(finding?.evidence) ? finding.evidence : [],
433
+ paths,
434
+ keywords: finding?.keywords,
435
+ fixDiff: finding?.fixDiff ?? '',
436
+ });
437
+ }
438
+
439
+ /**
440
+ * Record an agent-time hook denial (R15) in `.simt-dev/lessons.jsonl`.
441
+ *
442
+ * @param {string} root
443
+ * @param {{harness?: string, event?: string, policies?: string[], messages?: string[], command?: string, writes?: string[]}} denial
444
+ * @returns {Promise<object>}
445
+ */
446
+ export async function recordHookDenial(root, denial) {
447
+ const firstMsg = denial?.messages?.[0] ?? `blocked by ${(denial?.policies ?? []).join(', ') || 'policy'}`;
448
+ const errorLine = normaliseErrorLine(firstMsg);
449
+ const signature = errorSignature(errorLine);
450
+ const paths = Array.isArray(denial?.writes) ? denial.writes.filter(Boolean) : [];
451
+ const policies = Array.isArray(denial?.policies) ? denial.policies.filter(Boolean) : [];
452
+ const keywords = [...new Set([...policies, ...extractKeywords(errorLine, paths)])];
453
+ const evidence = [denial?.command, ...paths].filter(Boolean);
454
+
455
+ return appendLessonCandidate(root, {
456
+ kind: 'hook-denial',
457
+ cmd: denial?.command ?? (`agent-hook ${denial?.event ?? 'pre-tool-use'}`),
458
+ signature,
459
+ errorLine,
460
+ summary: errorLine,
461
+ evidence,
462
+ paths,
463
+ keywords,
464
+ fixDiff: '',
465
+ });
466
+ }
467
+
468
+ /**
469
+ * Render a promoted lesson Markdown document with Appendix A frontmatter:
470
+ *
471
+ * ```yaml
472
+ * ---
473
+ * simt-trigger: { paths: ["src/hooks/**"], signature: "sha256-…", keywords: ["restage"] }
474
+ * simt-evidence: ["<sha-fail>", "<sha-fix>"]
475
+ * simt-expires: 2027-04-01
476
+ * ---
477
+ * ```
478
+ *
479
+ * @param {object} candidate
480
+ * @param {{title?: string, paths?: string[], keywords?: string[], expires?: string}} [options]
481
+ * @returns {string}
482
+ */
483
+ export function renderLessonMarkdown(candidate, options = {}) {
484
+ const paths =
485
+ Array.isArray(options.paths) && options.paths.length > 0
486
+ ? options.paths
487
+ : Array.isArray(candidate.paths) && candidate.paths.length > 0
488
+ ? candidate.paths
489
+ : ['**'];
490
+ const keywords =
491
+ Array.isArray(options.keywords) && options.keywords.length > 0
492
+ ? options.keywords
493
+ : Array.isArray(candidate.keywords) && candidate.keywords.length > 0
494
+ ? candidate.keywords
495
+ : extractKeywords(candidate.errorLine ?? candidate.summary ?? '', paths);
496
+ const evidence = Array.isArray(candidate.evidence) ? candidate.evidence : [];
497
+ const expires = options.expires ?? DEFAULT_EXPIRES;
498
+ const title = String(options.title ?? candidate.summary ?? candidate.errorLine ?? 'Lesson').trim();
499
+
500
+ const pathsLiteral = paths.map((p) => JSON.stringify(String(p))).join(', ');
501
+ const keywordsLiteral = keywords.map((k) => JSON.stringify(String(k))).join(', ');
502
+ const evidenceLiteral = evidence.map((e) => JSON.stringify(String(e))).join(', ');
503
+
504
+ const lines = [
505
+ '---',
506
+ `simt-trigger: { paths: [${pathsLiteral}], signature: ${JSON.stringify(candidate.signature)}, keywords: [${keywordsLiteral}] }`,
507
+ `simt-evidence: [${evidenceLiteral}]`,
508
+ `simt-expires: ${expires}`,
509
+ '---',
510
+ '',
511
+ `# ${title}`,
512
+ '',
513
+ `When \`${candidate.cmd ?? candidate.kind}\` fails with \`${candidate.errorLine || candidate.signature}\`, apply the recorded fix across ${paths.map((p) => `\`${p}\``).join(', ')}.`,
514
+ ];
515
+
516
+ if (candidate.fixDiff) {
517
+ lines.push('', '```diff', candidate.fixDiff.trim(), '```');
518
+ }
519
+ lines.push('');
520
+ return lines.join('\n');
521
+ }
522
+
523
+ /**
524
+ * Parse a `docs/lessons/NNNN-<slug>.md` file into structured trigger, evidence,
525
+ * expiry, title and first body paragraph.
526
+ *
527
+ * @param {string} id
528
+ * @param {string} text
529
+ * @returns {object}
530
+ */
531
+ export function parseLessonFile(id, text) {
532
+ const { front: rawFront, body: rawBody } = splitFrontmatter(text);
533
+ const front = rawFront ?? '';
534
+ const body = rawBody.trim();
535
+
536
+ const paths = lessonPaths(text);
537
+ const sigMatch = /signature:\s*['"]?(sha256-[0-9a-f]+)['"]?/i.exec(front);
538
+ const signature = sigMatch ? sigMatch[1] : null;
539
+ const kwMatch = /keywords:\s*\[([^\]]*)\]/.exec(front);
540
+ const keywords = kwMatch
541
+ ? [...kwMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1])
542
+ : [];
543
+ const evMatch = /^simt-evidence:\s*\[([^\]]*)\]/m.exec(front);
544
+ const evidence = evMatch
545
+ ? [...evMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1])
546
+ : [];
547
+ const expMatch = /^simt-expires:\s*(\S+)/m.exec(front);
548
+ const expires = expMatch ? expMatch[1].trim() : null;
549
+
550
+ const titleMatch = /^#\s+(.+)$/m.exec(body);
551
+ const title = titleMatch ? titleMatch[1].trim() : id;
552
+ const paragraphs = body
553
+ .replace(/^#\s+.+$/m, '')
554
+ .trim()
555
+ .split(/\n\s*\n/)
556
+ .map((p) => p.trim())
557
+ .filter((p) => p.length > 0 && !p.startsWith('```'));
558
+ const paragraph = paragraphs[0] ?? title;
559
+
560
+ return {
561
+ id,
562
+ title,
563
+ paragraph,
564
+ paths,
565
+ signature,
566
+ keywords,
567
+ evidence,
568
+ expires,
569
+ };
570
+ }
571
+
572
+ /**
573
+ * List all promoted lessons in `docs/lessons/*.md`.
574
+ *
575
+ * @param {string} root
576
+ * @returns {Promise<Array<object>>}
577
+ */
578
+ export async function listPromotedLessons(root) {
579
+ const dir = join(root, LESSONS_DIR);
580
+ let names = [];
581
+ try {
582
+ names = (await readdir(dir)).filter((n) => n.endsWith('.md')).sort();
583
+ } catch {
584
+ return [];
585
+ }
586
+ const lessons = [];
587
+ for (const name of names) {
588
+ const text = await readFile(join(dir, name), 'utf8');
589
+ const id = name.replace(/\.md$/, '');
590
+ lessons.push(parseLessonFile(id, text));
591
+ }
592
+ return lessons;
593
+ }
594
+
595
+ async function nextLessonNumber(root) {
596
+ const dir = join(root, LESSONS_DIR);
597
+ let names = [];
598
+ try {
599
+ names = await readdir(dir);
600
+ } catch {
601
+ return '0001';
602
+ }
603
+ let max = 0;
604
+ for (const name of names) {
605
+ const match = /^(\d{4})-.*\.md$/.exec(name);
606
+ if (match) {
607
+ const n = Number(match[1]);
608
+ if (n > max) max = n;
609
+ }
610
+ }
611
+ return String(max + 1).padStart(4, '0');
612
+ }
613
+
614
+ /**
615
+ * Promote a lesson candidate from `.simt-dev/lessons.jsonl` to `docs/lessons/NNNN-<slug>.md`.
616
+ *
617
+ * @param {string} root
618
+ * @param {{id?: string, apply?: boolean, paths?: string[], expires?: string, title?: string}} [options]
619
+ * @returns {Promise<{ok: boolean, error?: string, candidate?: object, path?: string, markdown?: string, written?: boolean}>}
620
+ */
621
+ export async function promoteLesson(root, options = {}) {
622
+ await captureFromLedger(root);
623
+ const { candidates } = await readLessonCandidates(root);
624
+ if (candidates.length === 0) {
625
+ return { ok: false, error: 'no lesson candidates in .simt-dev/lessons.jsonl' };
626
+ }
627
+
628
+ let target = null;
629
+ if (options.id) {
630
+ target = candidates.find(
631
+ (c) => c.id === options.id || c.signature === options.id || c.signature.startsWith(options.id),
632
+ );
633
+ if (!target) {
634
+ return { ok: false, error: `no lesson candidate matching "${options.id}"` };
635
+ }
636
+ } else {
637
+ target = candidates.find((c) => c.status !== 'promoted') ?? candidates[0];
638
+ }
639
+
640
+ const num = await nextLessonNumber(root);
641
+ const slug = slugify(options.title ?? target.errorLine ?? target.summary ?? target.kind);
642
+ const relPath = `${LESSONS_DIR}/${num}-${slug}.md`;
643
+ const markdown = renderLessonMarkdown(target, options);
644
+
645
+ if (options.apply) {
646
+ const fullPath = join(root, relPath);
647
+ await mkdir(dirname(fullPath), { recursive: true });
648
+ await writeFile(fullPath, markdown, 'utf8');
649
+ target.status = 'promoted';
650
+ target.promotedTo = relPath;
651
+ await writeLessonCandidates(root, candidates);
652
+ }
653
+
654
+ return {
655
+ ok: true,
656
+ candidate: target,
657
+ path: relPath,
658
+ markdown,
659
+ written: Boolean(options.apply),
660
+ };
661
+ }
662
+
663
+ /**
664
+ * Format a promoted lesson as a single paragraph for after-tool hook injection (plan 0003 R33).
665
+ *
666
+ * @param {object} lesson
667
+ * @returns {string}
668
+ */
669
+ export function formatLessonParagraph(lesson) {
670
+ const id = String(lesson?.id ?? 'lesson').trim();
671
+ const title = String(lesson?.title ?? id).replace(/\s+/g, ' ').trim();
672
+ const body = String(lesson?.paragraph ?? '').replace(/\s+/g, ' ').trim();
673
+ if (!body || body === title) {
674
+ return `Lesson ${id}: ${title}`;
675
+ }
676
+ return `Lesson ${id} (${title}): ${body}`;
677
+ }
678
+
679
+ function hashLine(line) {
680
+ const norm = normaliseErrorLine(line);
681
+ return `sha256-${createHash('sha256').update(norm, 'utf8').digest('hex')}`;
682
+ }
683
+
684
+ function firstDefinedNumber(values) {
685
+ for (const v of values) {
686
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
687
+ }
688
+ return undefined;
689
+ }
690
+
691
+ function extractFailureSignals(payloadOrOutput) {
692
+ if (typeof payloadOrOutput === 'string') {
693
+ const trimmed = payloadOrOutput.trim();
694
+ if (!trimmed) return { failed: false, reason: 'empty tool output', signatures: [] };
695
+ const sigs = new Set([errorSignature(trimmed)]);
696
+ for (const line of trimmed.split(/\r?\n/)) {
697
+ const norm = normaliseErrorLine(line);
698
+ if (norm) sigs.add(hashLine(norm));
699
+ }
700
+ return { failed: true, signatures: [...sigs] };
701
+ }
702
+
703
+ if (!payloadOrOutput || typeof payloadOrOutput !== 'object') {
704
+ return { failed: false, reason: 'empty tool output', signatures: [] };
705
+ }
706
+
707
+ const p = payloadOrOutput;
708
+ const resp =
709
+ p.tool_response ?? p.tool_output ?? p.tool_result ?? p.toolResponse ?? null;
710
+ const respObj = resp && typeof resp === 'object' && !Array.isArray(resp) ? resp : {};
711
+
712
+ const exitCode = firstDefinedNumber([
713
+ p.exit_code,
714
+ p.exitCode,
715
+ p.exit,
716
+ p.status,
717
+ respObj.exit_code,
718
+ respObj.exitCode,
719
+ respObj.exit,
720
+ respObj.status,
721
+ respObj.code,
722
+ ]);
723
+ const isError =
724
+ p.is_error ?? p.isError ?? respObj.is_error ?? respObj.isError ?? undefined;
725
+
726
+ if (exitCode === 0 && isError !== true) {
727
+ return { failed: false, reason: 'the tool call exited 0', signatures: [] };
728
+ }
729
+ if (isError === false && exitCode === undefined) {
730
+ return { failed: false, reason: 'the tool call reported no error', signatures: [] };
731
+ }
732
+
733
+ const sigs = new Set();
734
+ for (const rawSig of [p.signature, respObj.signature]) {
735
+ if (typeof rawSig === 'string' && rawSig.startsWith('sha256-')) {
736
+ sigs.add(rawSig.trim());
737
+ }
738
+ }
739
+
740
+ for (const errLine of [p.errorLine, respObj.errorLine]) {
741
+ if (typeof errLine === 'string' && errLine.trim()) {
742
+ sigs.add(hashLine(errLine));
743
+ sigs.add(errorSignature(errLine));
744
+ }
745
+ }
746
+
747
+ const textParts = [];
748
+ if (typeof resp === 'string') textParts.push(resp);
749
+ for (const field of [
750
+ respObj.stderr,
751
+ respObj.stdout,
752
+ respObj.output,
753
+ respObj.error,
754
+ respObj.text,
755
+ typeof respObj.content === 'string'
756
+ ? respObj.content
757
+ : Array.isArray(respObj.content)
758
+ ? respObj.content.map((c) => (typeof c === 'string' ? c : (c?.text ?? ''))).join('\n')
759
+ : '',
760
+ p.stderr,
761
+ p.stdout,
762
+ p.output,
763
+ p.error,
764
+ p.summary,
765
+ ]) {
766
+ if (typeof field === 'string' && field.trim()) textParts.push(field);
767
+ }
768
+
769
+ if (typeof p.summary === 'string') {
770
+ const inner =
771
+ /^\d+\s+fail\s+\((.+)\)$/.exec(p.summary.trim())?.[1] ??
772
+ /^exit\s+-?\d+\s+\((.+)\)$/.exec(p.summary.trim())?.[1];
773
+ if (inner) {
774
+ sigs.add(hashLine(inner));
775
+ sigs.add(errorSignature(inner));
776
+ }
777
+ }
778
+
779
+ const combined = textParts.join('\n').trim();
780
+ if (combined) {
781
+ sigs.add(errorSignature(combined));
782
+ for (const line of combined.split(/\r?\n/)) {
783
+ const norm = normaliseErrorLine(line);
784
+ if (norm) sigs.add(hashLine(norm));
785
+ }
786
+ }
787
+
788
+ if (sigs.size === 0) {
789
+ return { failed: false, reason: 'the payload carries no failing tool output', signatures: [] };
790
+ }
791
+
792
+ return { failed: true, signatures: [...sigs] };
793
+ }
794
+
795
+ /**
796
+ * Match a failing tool output or replayed failure payload against promoted lessons by error signature.
797
+ *
798
+ * @param {Array<object>} lessons
799
+ * @param {string|object} payloadOrOutput
800
+ * @returns {{lesson: object, paragraph: string, signature: string}|null}
801
+ */
802
+ export function matchLessonByOutput(lessons, payloadOrOutput) {
803
+ if (!Array.isArray(lessons) || lessons.length === 0) return null;
804
+ const signals = extractFailureSignals(payloadOrOutput);
805
+ if (!signals.failed || signals.signatures.length === 0) return null;
806
+
807
+ const sigSet = new Set(signals.signatures);
808
+ for (const lesson of lessons) {
809
+ if (lesson?.signature && sigSet.has(lesson.signature)) {
810
+ return {
811
+ lesson,
812
+ paragraph: formatLessonParagraph(lesson),
813
+ signature: lesson.signature,
814
+ };
815
+ }
816
+ }
817
+ return null;
818
+ }
819
+
820
+ /**
821
+ * Recall and inject a matching lesson as one paragraph when an after-tool hook (`post-tool-use`)
822
+ * sees failing output whose signature matches a promoted lesson in `docs/lessons/*.md` (plan 0003 R33).
823
+ *
824
+ * @param {object} row harness adapter row from `src/agenthooks/render.mjs`
825
+ * @param {object} payload hook stdin JSON payload
826
+ * @param {{root?: string}} [options]
827
+ * @returns {Promise<{json?: object, root?: string, lesson?: object, paragraph?: string, signature?: string, skipped?: string}>}
828
+ */
829
+ export async function deliverLessonRecall(row, payload, options = {}) {
830
+ const root = row?.root?.(payload) ?? payload?.root ?? options.root ?? process.cwd();
831
+ const signals = extractFailureSignals(payload);
832
+ if (!signals.failed) {
833
+ return { skipped: signals.reason ?? 'no failing tool output' };
834
+ }
835
+
836
+ const lessons = await listPromotedLessons(root);
837
+ if (lessons.length === 0) {
838
+ return { skipped: 'no promoted lessons in docs/lessons' };
839
+ }
840
+
841
+ const matched = matchLessonByOutput(lessons, payload);
842
+ if (!matched) {
843
+ return { skipped: 'no lesson matches the failure signature' };
844
+ }
845
+
846
+ const json = postDeliveryJson(row, matched.paragraph);
847
+ if (json === null) {
848
+ return { skipped: `${row?.id ?? 'harness'} has no post-tool event that can inject` };
849
+ }
850
+
851
+ return {
852
+ json,
853
+ root,
854
+ lesson: matched.lesson,
855
+ paragraph: matched.paragraph,
856
+ signature: matched.signature,
857
+ };
858
+ }