simt-dev 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +157 -0
- package/README.md +271 -62
- package/package.json +6 -2
- package/rules/core.md +1 -1
- package/src/advice/advice.mjs +229 -0
- package/src/agenthooks/policy.mjs +10 -2
- package/src/agenthooks/prompt.mjs +81 -0
- package/src/agenthooks/render.mjs +138 -17
- package/src/argv.mjs +28 -1
- package/src/bench/grade.mjs +3 -0
- package/src/bench/run.mjs +88 -54
- package/src/bench/tasks.mjs +38 -0
- package/src/bench/variants.mjs +456 -0
- package/src/brief/assemble.mjs +368 -0
- package/src/brief/kind.mjs +134 -0
- package/src/brief/packet.mjs +200 -0
- package/src/commands/agenthook.mjs +84 -5
- package/src/commands/bench.mjs +72 -15
- package/src/commands/brief.mjs +78 -0
- package/src/commands/compare.mjs +10 -2
- package/src/commands/decide.mjs +116 -0
- package/src/commands/distill.mjs +36 -0
- package/src/commands/help.mjs +62 -3
- package/src/commands/index.mjs +29 -1
- package/src/commands/lessons.mjs +127 -0
- package/src/commands/local.mjs +97 -0
- package/src/commands/map.mjs +42 -7
- package/src/commands/repoindex.mjs +4 -1
- package/src/commands/review.mjs +65 -0
- package/src/commands/rules.mjs +17 -2
- package/src/commands/skills.mjs +5 -50
- package/src/commands/skillscompile.mjs +255 -0
- package/src/commands/sync.mjs +15 -0
- package/src/commands/tests.mjs +2 -49
- package/src/commands/why.mjs +23 -25
- package/src/compare/learn.mjs +680 -0
- package/src/decisions/index.mjs +116 -65
- package/src/distill/distill.mjs +246 -0
- package/src/distill/engine.mjs +187 -0
- package/src/federation/client.mjs +188 -0
- package/src/federation/config.mjs +134 -0
- package/src/federation/query.mjs +48 -0
- package/src/graph/compile.mjs +475 -0
- package/src/index/gate.mjs +1 -13
- package/src/index/govern.mjs +23 -11
- package/src/index/map.mjs +251 -17
- package/src/index/persist.mjs +74 -7
- package/src/index/store.mjs +222 -8
- package/src/kernel/variants.mjs +353 -0
- package/src/ledger/index.mjs +36 -1
- package/src/local/client.mjs +482 -0
- package/src/local/config.mjs +269 -0
- package/src/mcp/tools.mjs +138 -1
- package/src/memory/decide.mjs +462 -0
- package/src/memory/lessons.mjs +858 -0
- package/src/projection/lock.mjs +39 -7
- package/src/projection/rules.mjs +32 -8
- package/src/resolve/body.mjs +46 -0
- package/src/review/critic.mjs +509 -0
- package/src/toolwords.mjs +6 -0
|
@@ -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
|
+
}
|