@tduyng/pi-harness-loop 0.0.0-stage → 0.0.1

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/src/index.ts ADDED
@@ -0,0 +1,761 @@
1
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+
4
+ import {
5
+ formatSkillsForPrompt,
6
+ getAgentDir,
7
+ type ExtensionAPI,
8
+ type ExtensionContext
9
+ } from '@earendil-works/pi-coding-agent'
10
+ import { type Infer, Validator } from 'ata-validator'
11
+ import { t } from 'ata-validator/t'
12
+
13
+
14
+ import { revertApplied } from './apply.js'
15
+ import {
16
+ budgetLeft,
17
+ fingerprintOf,
18
+ freshScan,
19
+ liveScan,
20
+ loopEnabled,
21
+ needsCode,
22
+ notify,
23
+ pendingTarget,
24
+ proposeToDisk,
25
+ readScan,
26
+ skillFacts,
27
+ toLive,
28
+ writeScan,
29
+ type LiveScan
30
+ } from './auto.js'
31
+ import { DEAD_SKILL_SESSIONS, scanCorpus, type PromptWords, type Recurrence, type SkillUse } from './corpus.js'
32
+ import { findCollapse, proposalTexts, type Collapse } from './diversity.js'
33
+ import { rate, type Effect } from './effect.js'
34
+ import { evaluateInstalls, autoRevert } from './evaluate.js'
35
+ import { findFindings, type Finding } from './findings.js'
36
+ import { appendSpend, readSpend, spentToday, sumSpend, type SpendTotal } from './ledger.js'
37
+ import { confirmOf, installContext, installProposal, installRoots, standingChars } from './install.js'
38
+ import { goalInTheWay } from './coordination.js'
39
+ import { lintManifest, type LintProblem } from './lint.js'
40
+ import { matchLessons, rareTriggersOf, triggersOf } from './match.js'
41
+ import { LESSON_TOOL, LessonParameters, MAX_LESSONS_SHOWN, formatLessons, matchedLessons } from './lesson-tool.js'
42
+
43
+ type LessonParams = Infer<typeof LessonParameters>
44
+ import { readProducers } from './producer.js'
45
+ import { probeReplay } from './replay.js'
46
+ import {
47
+ acquireLock,
48
+ appendInstall,
49
+ harnessDir,
50
+ readInstalls,
51
+ readLessons,
52
+ readSettings,
53
+ recordLesson,
54
+ recordLessonUse,
55
+ writeLessons
56
+ } from './store.js'
57
+ import { targetFromFinding, targetFromRecurrence, targetFromSkillUse, type Target } from './target.js'
58
+ import { readTranscript, lastUserText } from './transcript.js'
59
+ import { loadHarness, type Harness } from './tree.js'
60
+ import { writePart, type Completion } from './writer.js'
61
+ import type { Install, Lesson } from './types.js'
62
+
63
+ const WIDGET_KEY = 'harness'
64
+ const MAX_LINES = 20
65
+ const WRITE_TIMEOUT_MS = 120_000
66
+
67
+ function rarityOf(cwd: string): PromptWords | undefined {
68
+ const scan = readScan(cwd)
69
+ return scan === null ? undefined : toLive(scan).prompts
70
+ }
71
+
72
+ // The tool list is the front of every request, so the lesson tool is armed for a run only
73
+ // when the request names a situation a lesson already covers.
74
+ function armLessonTool(pi: ExtensionAPI, ctx: ExtensionContext, prompt: string): void {
75
+ const active = pi.getActiveTools()
76
+ const armed = prompt !== '' && matchedLessons(ctx.cwd, prompt, rarityOf(ctx.cwd)).length > 0
77
+ if (armed === active.includes(LESSON_TOOL)) return
78
+ pi.setActiveTools(armed ? [...active, LESSON_TOOL] : active.filter((name) => name !== LESSON_TOOL))
79
+ }
80
+
81
+ function supersededOnDisk(lessons: Lesson[], installs: Install[]): string[] {
82
+ return lessons
83
+ .filter((lesson) => lesson.state === 'superseded')
84
+ .flatMap((lesson) => {
85
+ const install = installs.find((entry) => entry.lesson === lesson.id && !entry.reverted)
86
+ if (install === undefined) return []
87
+ return [`${lesson.cue} is still in ${install.files.map((file) => file.to).join(', ')}`]
88
+ })
89
+ }
90
+
91
+ function reportLines(
92
+ harness: Harness,
93
+ findings: Finding[],
94
+ recurrences: Recurrence[],
95
+ lessons: string[],
96
+ stale: string[],
97
+ installs: Install[],
98
+ dead: string[],
99
+ spend: SpendTotal,
100
+ deadChars: number,
101
+ collapse: Collapse | null,
102
+ notAdvertised: number,
103
+ sessions: number,
104
+ available: number,
105
+ next: string,
106
+ effects: Effect[],
107
+ staleFindings: string[],
108
+ needsCodeFindings: Recurrence[]
109
+ ): string[] {
110
+ const lines: string[] = []
111
+ const active = installs.filter((install) => install.ok && !install.reverted)
112
+ const standing = standingChars(installs)
113
+ lines.push(
114
+ `skills ${harness.skills.length}, agents ${harness.agents.length}, skill block ${harness.skillBlockChars} chars, sessions ${sessions} of ${available}`
115
+ )
116
+ lines.push(
117
+ `installed ${active.length} active, ${installs.length - active.length} reverted, ${Math.round(standing / 4)} tokens of the prompt come from this loop`
118
+ )
119
+ if (harness.problems.length === 0) {
120
+ lines.push('tree: no problems')
121
+ } else {
122
+ lines.push(`tree: ${harness.problems.length} problems`)
123
+ for (const problem of harness.problems) lines.push(` ${problem.rule} ${problem.entry}: ${problem.detail}`)
124
+ }
125
+ if (findings.length === 0) {
126
+ lines.push('findings: none')
127
+ } else {
128
+ lines.push(`findings: ${findings.length}`)
129
+ for (const finding of findings.slice(0, 3)) lines.push(` ${finding.id} ${finding.title}`)
130
+ }
131
+ if (recurrences.length > 0) {
132
+ lines.push(`recurrences: ${recurrences.length}`)
133
+ for (const recurrence of recurrences.slice(0, 5)) lines.push(` ${recurrence.id} ${recurrence.title}`)
134
+ if (recurrences.length > 5) lines.push(` and ${recurrences.length - 5} more`)
135
+ const held = recurrences.filter((recurrence) => recurrence.blocked !== undefined).length
136
+ if (held > 0) lines.push(`held: ${held} failures whose sessions edited the same files`)
137
+ }
138
+ if (next !== '') lines.push(`the loop will propose: ${next}`)
139
+ if (notAdvertised > 0) {
140
+ lines.push(`not advertised: ${notAdvertised} skills, which cost nothing`)
141
+ }
142
+ if (dead.length > 0) {
143
+ const shown = dead.slice(0, 5).join(', ')
144
+ const more = dead.length > 5 ? ` and ${dead.length - 5} more` : ''
145
+ lines.push(`never opened in ${sessions} sessions: ${shown}${more} (propose skill-<name>)`)
146
+ lines.push(`dead advertisement: ${deadChars} of ${harness.skillBlockChars} chars`)
147
+ }
148
+ for (const lesson of lessons) lines.push(`lesson ${lesson}`)
149
+ for (const entry of stale) lines.push(`superseded ${entry}`)
150
+ if (installs.length > 0) {
151
+ const kept = installs.filter((install) => install.ok && !install.reverted).length
152
+ const confirmed = installs.filter((install) => install.confirmOk === true && !install.reverted).length
153
+ lines.push(
154
+ `installs: ${kept} kept, ${installs.length - kept} reverted${confirmed > 0 ? `, ${confirmed} confirmed` : ''}`
155
+ )
156
+ }
157
+ if (effects.length > 0) {
158
+ const held = effects.filter((effect) => effect.verdict === 'held').length
159
+ const failed = effects.filter((effect) => effect.verdict === 'flat' || effect.verdict === 'worse').length
160
+ const confounded = effects.filter((effect) => effect.verdict === 'confounded').length
161
+ const dodged = effects.filter((effect) => effect.verdict === 'avoided').length
162
+ lines.push(
163
+ `fixes: ${held} held, ${failed} did not, ${dodged} avoided the tool, ${confounded} confounded, ${effects.length - held - failed - confounded - dodged} too early to judge`
164
+ )
165
+ for (const effect of effects
166
+ .filter(
167
+ (entry) => entry.verdict === 'flat' || entry.verdict === 'worse' || entry.verdict === 'avoided'
168
+ )
169
+ .slice(0, 3)) {
170
+ lines.push(` ${effect.finding} ${effect.verdict} ${rate(effect.before)} -> ${rate(effect.after)}`)
171
+ }
172
+ }
173
+ if (needsCodeFindings.length > 0) {
174
+ lines.push(
175
+ `needs code, because a prose part did not carry: ${needsCodeFindings
176
+ .slice(0, 3)
177
+ .map((recurrence) => recurrence.id)
178
+ .join(', ')}`
179
+ )
180
+ }
181
+ if (staleFindings.length > 0) {
182
+ lines.push(`stale findings, skipped: ${staleFindings.slice(0, 2).join(' | ')}`)
183
+ }
184
+ const total = spend
185
+ if (total.proposals > 0) {
186
+ lines.push(`loop: ${total.proposals} proposals, ${total.turns} turns, ${total.cost.toFixed(4)} cost`)
187
+ }
188
+ if (collapse !== null) {
189
+ lines.push(
190
+ `the loop repeats itself: ${collapse.pair[0]} and ${collapse.pair[1]} are ${Math.round(collapse.similarity * 100)}% the same`
191
+ )
192
+ }
193
+ if (lines.length > MAX_LINES) lines.push(` ... ${lines.length - MAX_LINES} more`)
194
+ return lines.slice(0, MAX_LINES)
195
+ }
196
+
197
+ function pickTarget(
198
+ findings: Finding[],
199
+ recurrences: Recurrence[],
200
+ dead: SkillUse[],
201
+ skills: Map<string, { description: string; filePath: string }>,
202
+ id: string
203
+ ): Target | null {
204
+ if (id === '') {
205
+ const finding = findings[0]
206
+ if (finding !== undefined) return targetFromFinding(finding)
207
+ const recurrence = recurrences[0]
208
+ if (recurrence !== undefined) return targetFromRecurrence(recurrence)
209
+ const skill = dead[0]
210
+ if (skill === undefined) return null
211
+ const live = skills.get(skill.name)
212
+ return targetFromSkillUse(
213
+ skill,
214
+ live?.description ?? '',
215
+ live?.filePath ?? ''
216
+ )
217
+ }
218
+ const finding = findings.find((entry) => entry.id === id || entry.kind === id)
219
+ if (finding !== undefined) return targetFromFinding(finding)
220
+ const recurrence = recurrences.find((entry) => entry.id === id)
221
+ if (recurrence !== undefined) return targetFromRecurrence(recurrence)
222
+ const skill = dead.find((entry) => entry.id === id)
223
+ if (skill === undefined) return null
224
+ const live = skills.get(skill.name)
225
+ return targetFromSkillUse(
226
+ skill,
227
+ live?.description ?? '',
228
+ live?.filePath ?? ''
229
+ )
230
+ }
231
+
232
+ async function nextTarget(ctx: ExtensionContext): Promise<{ target: Target; scan: LiveScan } | null> {
233
+ const cwd = ctx.cwd
234
+ if (!loopEnabled()) return null
235
+ evaluateInstalls(cwd)
236
+ if (!readSettings(cwd).auto) return null
237
+ if (!budgetLeft(cwd)) return null
238
+ if (goalInTheWay(ctx.sessionManager.getBranch())) return null
239
+ const sessionDir = ctx.sessionManager.getSessionDir()
240
+ const sessionId = ctx.sessionManager.getSessionId()
241
+ const fingerprint = fingerprintOf(sessionDir, sessionId)
242
+ const cached = readScan(cwd)
243
+ const stale = cached === null || cached.fingerprint !== fingerprint
244
+ const skills = skillFacts(cwd, getAgentDir())
245
+ const scan = stale ? freshScan(cwd, getAgentDir(), sessionDir, sessionId, skills) : toLive(cached)
246
+ if (stale) writeScan(cwd, scan)
247
+ const target = pendingTarget(cwd, scan, skills)
248
+ if (target === null) return null
249
+ const recurrence = scan.recurrences.find((entry) => entry.id === target.id)
250
+ if (recurrence?.command !== undefined) {
251
+ const verdict = await probeReplay(cwd, recurrence)
252
+ if (verdict === 'stale') return null
253
+ }
254
+ return { target, scan }
255
+ }
256
+
257
+ interface DriveOutcome {
258
+ message: string
259
+ level: 'info' | 'warning'
260
+ }
261
+
262
+ type SelectedModel = NonNullable<ExtensionContext['model']>
263
+
264
+ async function completeFor(
265
+ ctx: ExtensionContext,
266
+ model: SelectedModel,
267
+ systemPrompt: string,
268
+ prompt: string
269
+ ): Promise<Completion> {
270
+ const reply = await ctx.modelRegistry.complete(
271
+ model,
272
+ {
273
+ systemPrompt,
274
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }]
275
+ },
276
+ { signal: AbortSignal.timeout(WRITE_TIMEOUT_MS), sessionId: ctx.sessionManager.getSessionId() }
277
+ )
278
+ return {
279
+ text: reply.errorMessage ?? reply.content.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n'),
280
+ stop: reply.stopReason,
281
+ input: reply.usage.input,
282
+ output: reply.usage.output,
283
+ cost: reply.usage.cost.total
284
+ }
285
+ }
286
+
287
+ /** The writer bills to the parent's session, and `/cost` reads this line to say so. */
288
+ function recordOverhead(ctx: ExtensionContext, purpose: string, part: { input: number; output: number; cost: number }): void {
289
+ try {
290
+ appendFileSync(
291
+ join(getAgentDir(), 'overhead.jsonl'),
292
+ `${JSON.stringify({
293
+ at: Date.now(),
294
+ extension: 'harness-loop',
295
+ sessionId: ctx.sessionManager.getSessionId(),
296
+ purpose,
297
+ input: part.input,
298
+ output: part.output,
299
+ costUsd: part.cost
300
+ })}\n`,
301
+ 'utf8'
302
+ )
303
+ } catch {}
304
+ }
305
+
306
+ function partText(dir: string): string {
307
+ for (const path of [join(dir, 'RULE.md'), join(dir, 'global', 'RULE.md')]) {
308
+ if (!existsSync(path)) continue
309
+ const line = readFileSync(path, 'utf8')
310
+ .split('\n')
311
+ .map((entry) => entry.trim())
312
+ .find((entry) => entry !== '' && !entry.startsWith('#'))
313
+ if (line !== undefined) return line
314
+ }
315
+ return ''
316
+ }
317
+
318
+ function partLesson(cwd: string, target: Target, dir: string, scan: LiveScan): Lesson | null {
319
+ const confirm = confirmOf(dir)
320
+ if (confirm === '') return null
321
+ const text = partText(dir)
322
+ return {
323
+ id: `${target.id}-${Date.now()}`,
324
+ at: Date.now(),
325
+ cue: target.cue,
326
+ lesson: text === '' ? `the part in ${relative(cwd, dir)} covers this situation` : text,
327
+ evidence: target.title,
328
+ confidence: 'medium',
329
+ triggers: rareTriggersOf(`${target.title} ${target.cue}`, scan.prompts),
330
+ kind: target.kind,
331
+ state: 'installed',
332
+ confirm
333
+ }
334
+ }
335
+
336
+ const SubagentConfig = t.object({ model: t.optional(t.string()) })
337
+ const isSubagentConfig = new Validator(SubagentConfig).isValidObject
338
+
339
+ /** The model pi already runs chores on, so the writer does not need a setting of its own. */
340
+ export function subagentModel(cwd: string, agentDir: string): string {
341
+ for (const path of [join(cwd, '.pi', 'subagent.json'), join(agentDir, 'subagent.json')]) {
342
+ if (!existsSync(path)) continue
343
+ try {
344
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
345
+ if (!isSubagentConfig(parsed)) continue
346
+ const model = parsed.model?.trim() ?? ''
347
+ if (model !== '') return model
348
+ } catch {
349
+ continue
350
+ }
351
+ }
352
+ return ''
353
+ }
354
+
355
+ export function writerModelName(project: string, env: string, subagent: string): string {
356
+ if (project !== '') return project
357
+ if (env !== '') return env
358
+ return subagent
359
+ }
360
+
361
+ function writerModelFor(ctx: ExtensionContext): SelectedModel | undefined {
362
+ const configured = writerModelName(
363
+ readSettings(ctx.cwd).writerModel.trim(),
364
+ process.env['PI_HARNESS_WRITER_MODEL']?.trim() ?? '',
365
+ subagentModel(ctx.cwd, getAgentDir())
366
+ )
367
+ const slash = configured.indexOf('/')
368
+ if (slash < 1) return ctx.model
369
+ const found = ctx.modelRegistry.find(configured.slice(0, slash), configured.slice(slash + 1))
370
+ if (found === undefined || !ctx.modelRegistry.hasConfiguredAuth(found)) return ctx.model
371
+ return found
372
+ }
373
+
374
+ /** The whole loop for one finding: brief, part, install, record. A part nobody installs is a file. */
375
+ async function drive(ctx: ExtensionContext, target: Target, scan: LiveScan): Promise<DriveOutcome> {
376
+ const dir = proposeToDisk(ctx.cwd, target, scan)
377
+ const model = ctx.model === undefined ? undefined : writerModelFor(ctx)
378
+ if (model === undefined) {
379
+ appendSpend(ctx.cwd, {
380
+ at: Date.now(),
381
+ target: target.id,
382
+ turns: 0,
383
+ input: 0,
384
+ output: 0,
385
+ cost: 0,
386
+ outcome: 'recorded'
387
+ })
388
+ return { message: `no model is selected, so the brief stays at ${dir}`, level: 'info' }
389
+ }
390
+ const brief = readFileSync(join(dir, 'BRIEF.md'), 'utf8')
391
+ const part = await writePart(dir, brief, (systemPrompt, prompt) => completeFor(ctx, model, systemPrompt, prompt))
392
+ recordOverhead(ctx, target.id, part)
393
+ const spend = {
394
+ at: Date.now(),
395
+ target: target.id,
396
+ turns: part.turns,
397
+ input: part.input,
398
+ output: part.output,
399
+ cost: part.cost
400
+ }
401
+ if (!part.ok) {
402
+ appendSpend(ctx.cwd, { ...spend, outcome: 'recorded' })
403
+ return {
404
+ message: `wrote no part for ${target.id} (${part.message}). The brief is at ${dir}`,
405
+ level: 'info'
406
+ }
407
+ }
408
+ const recurrence = scan.recurrences.find((entry) => entry.id === target.id)
409
+ const lesson = partLesson(ctx.cwd, target, dir, scan)
410
+ const outcome = await installProposal(dir, target.id, ctx.cwd, {
411
+ context: installContext(ctx.cwd, getAgentDir()),
412
+ key: recurrence?.key,
413
+ command: recurrence?.command,
414
+ lesson: lesson?.id
415
+ })
416
+ if (outcome.state === 'installed' && lesson !== null) recordLesson(ctx.cwd, lesson)
417
+ appendSpend(ctx.cwd, {
418
+ ...spend,
419
+ outcome: outcome.state === 'rejected' ? 'rejected' : outcome.state === 'installed' ? 'installed' : 'recorded'
420
+ })
421
+ return { message: `${outcome.message} (${target.id})`, level: outcome.state === 'rejected' ? 'warning' : 'info' }
422
+ }
423
+
424
+ export default function harnessExtension(pi: ExtensionAPI): void {
425
+ let scheduled = false
426
+
427
+ pi.on('before_agent_start', (event, ctx) => {
428
+ ctx.ui.setWidget(WIDGET_KEY, undefined)
429
+ armLessonTool(pi, ctx, event.messageType === 'user' ? (event.prompt ?? '') : '')
430
+ })
431
+
432
+ pi.registerCommand('harness-loop', {
433
+ description:
434
+ 'Report what this session shows about the harness, propose a fix for one finding, install it when the check passes, revert an install, or clear the report.',
435
+ handler: async (args, ctx) => {
436
+ const cwd = ctx.cwd
437
+ const [command, id] = args.trim().split(/\s+/, 2)
438
+ if (command !== '') ctx.ui.setWidget(WIDGET_KEY, undefined)
439
+
440
+ if (command === 'clear') {
441
+ ctx.ui.notify('harness: report cleared, and the next prompt clears it too')
442
+ return
443
+ }
444
+
445
+ if (command === 'revert') {
446
+ const found = readInstalls(cwd)
447
+ .toReversed()
448
+ .find((install) => install.id === id)
449
+ if (found === undefined) {
450
+ ctx.ui.notify(id ? `no install named ${id}` : 'name the install to revert', 'error')
451
+ return
452
+ }
453
+ if (found.reverted) {
454
+ ctx.ui.notify(`install ${found.id} is already reverted`, 'info')
455
+ return
456
+ }
457
+ const release = acquireLock(cwd)
458
+ if (release === null) {
459
+ ctx.ui.notify('another install is running, so this revert waits', 'warning')
460
+ return
461
+ }
462
+ try {
463
+ const result = revertApplied(found.files, installRoots(installContext(cwd, getAgentDir())))
464
+ if (result.refused.length > 0) {
465
+ ctx.ui.notify(
466
+ `left ${result.refused.length} file(s) alone because they changed since the install: ${result.refused[0]}`,
467
+ 'warning'
468
+ )
469
+ return
470
+ }
471
+ appendInstall(cwd, { ...found, at: Date.now(), reverted: true })
472
+ ctx.ui.notify(`reverted ${found.finding}: ${result.restored.length} file(s) restored`)
473
+ } finally {
474
+ release()
475
+ }
476
+ return
477
+ }
478
+
479
+ if (command === 'install') {
480
+ if (id === undefined || id === '') {
481
+ ctx.ui.notify('name the finding to install', 'error')
482
+ return
483
+ }
484
+ const dir = join(harnessDir(cwd), id)
485
+ const recurrence = readScan(cwd)?.recurrences.find((entry) => entry.id === id)
486
+ const outcome = await installProposal(dir, id, cwd, {
487
+ forced: true,
488
+ context: installContext(cwd, getAgentDir()),
489
+ key: recurrence?.key,
490
+ command: recurrence?.command
491
+ })
492
+ ctx.ui.notify(outcome.message, outcome.state === 'rejected' ? 'warning' : 'info')
493
+ return
494
+ }
495
+
496
+ if (command === 'lint') {
497
+ const problems: LintProblem[] = [
498
+ ...loadHarness(cwd, getAgentDir()).problems.map((problem) => ({
499
+ rule: problem.rule,
500
+ entry: problem.entry,
501
+ detail: problem.detail
502
+ })),
503
+ ...lintManifest(cwd)
504
+ ]
505
+ ctx.ui.setWidget(
506
+ WIDGET_KEY,
507
+ problems.length === 0
508
+ ? ['lint: no problems']
509
+ : [
510
+ `lint: ${problems.length} problems`,
511
+ ...problems.map((problem) => ` ${problem.rule} ${problem.entry}: ${problem.detail}`)
512
+ ]
513
+ )
514
+ ctx.ui.notify(
515
+ problems.length === 0 ? 'lint: no problems' : `lint: ${problems.length} problems`,
516
+ problems.length === 0 ? 'info' : 'warning'
517
+ )
518
+ return
519
+ }
520
+
521
+ if (command !== 'propose') {
522
+ const harness = loadHarness(cwd, getAgentDir())
523
+ const branch = ctx.sessionManager.getBranch()
524
+ const advertised = harness.skills.filter((skill) => !skill.disableModelInvocation)
525
+ const scan = scanCorpus(
526
+ ctx.sessionManager.getSessionDir(),
527
+ advertised.map((skill) => skill.name),
528
+ join(cwd, '.pi', 'harness', 'corpus-cache.json')
529
+ )
530
+ const dead = scan.skills.filter((use) => use.opens === 0 && use.sessions >= DEAD_SKILL_SESSIONS)
531
+ const deadNames = new Set(dead.map((use) => use.name))
532
+ const deadChars =
533
+ formatSkillsForPrompt(advertised).length -
534
+ formatSkillsForPrompt(advertised.filter((skill) => !deadNames.has(skill.name))).length
535
+ const lessons = matchLessons(readLessons(cwd), lastUserText(branch))
536
+ .slice(0, MAX_LESSONS_SHOWN)
537
+ .map((lesson) => `${lesson.cue}: ${lesson.lesson}`)
538
+ const installs = readInstalls(cwd)
539
+ const stale = supersededOnDisk(readLessons(cwd), installs)
540
+ const effects = evaluateInstalls(cwd)
541
+ const needsCodeFindings = scan.recurrences.filter((recurrence) => needsCode(cwd, recurrence))
542
+ const staleFindings = readProducers(cwd)
543
+ .filter((verdict) => verdict.state === 'removed')
544
+ .map((verdict) => `${verdict.phrase} (${verdict.detail})`)
545
+ const pending = pendingTarget(
546
+ cwd,
547
+ liveScan(scan),
548
+ harness.skills.map((skill) => ({
549
+ name: skill.name,
550
+ description: skill.description,
551
+ filePath: skill.filePath,
552
+ advertised: !skill.disableModelInvocation
553
+ }))
554
+ )
555
+ ctx.ui.setWidget(
556
+ WIDGET_KEY,
557
+ reportLines(
558
+ harness,
559
+ findFindings(readTranscript(branch)),
560
+ scan.recurrences,
561
+ lessons,
562
+ stale,
563
+ installs,
564
+ dead.map((use) => use.name),
565
+ sumSpend(readSpend(cwd)),
566
+ deadChars,
567
+ findCollapse(proposalTexts(harnessDir(cwd))),
568
+ harness.skills.length - advertised.length,
569
+ scan.sessions,
570
+ scan.available,
571
+ pending?.title ?? '',
572
+ effects,
573
+ staleFindings,
574
+ needsCodeFindings
575
+ )
576
+ )
577
+ ctx.ui.notify(
578
+ `harness: ${advertised.length} advertised skills, ${harness.skills.length - advertised.length} not advertised, ${harness.problems.length} problems, ${scan.recurrences.length} recurrences, ${lessons.length} lessons`
579
+ )
580
+ return
581
+ }
582
+
583
+ const harness = loadHarness(cwd, getAgentDir())
584
+ const advertised = harness.skills.filter((skill) => !skill.disableModelInvocation)
585
+ const scan = scanCorpus(
586
+ ctx.sessionManager.getSessionDir(),
587
+ advertised.map((skill) => skill.name),
588
+ join(cwd, '.pi', 'harness', 'corpus-cache.json')
589
+ )
590
+ const dead = scan.skills.filter((use) => use.opens === 0 && use.sessions >= DEAD_SKILL_SESSIONS)
591
+ const skills = new Map(
592
+ harness.skills.map((skill) => [
593
+ skill.name,
594
+ { description: skill.description, filePath: skill.filePath }
595
+ ])
596
+ )
597
+ const target = pickTarget(
598
+ findFindings(readTranscript(ctx.sessionManager.getBranch())),
599
+ scan.recurrences,
600
+ dead,
601
+ skills,
602
+ id ?? ''
603
+ )
604
+ if (target === null) {
605
+ ctx.ui.notify(id ? `no finding or recurrence named ${id}` : 'nothing to propose from', 'error')
606
+ return
607
+ }
608
+ if (goalInTheWay(ctx.sessionManager.getBranch())) {
609
+ ctx.ui.notify('a goal is active, and it drives the session too. Pause it first.', 'error')
610
+ return
611
+ }
612
+ const recurrence = scan.recurrences.find((entry) => entry.id === target.id)
613
+ if (recurrence !== undefined && needsCode(cwd, recurrence)) {
614
+ ctx.ui.notify(
615
+ `a prose part already failed for ${target.id}, so the next fix has to live in the tool, not in a rule`,
616
+ 'warning'
617
+ )
618
+ return
619
+ }
620
+ if (recurrence?.command !== undefined) {
621
+ const verdict = await probeReplay(cwd, recurrence)
622
+ if (verdict === 'stale') {
623
+ ctx.ui.notify(`the failure in ${target.id} no longer reproduces, so the finding is stale`, 'info')
624
+ return
625
+ }
626
+ }
627
+ const settings = readSettings(cwd)
628
+ const today = spentToday(readSpend(cwd))
629
+ if (today.proposals >= settings.maxProposalsPerDay) {
630
+ ctx.ui.notify(
631
+ `the loop spent its budget for today (${today.proposals} proposals, ${today.turns} turns)`,
632
+ 'warning'
633
+ )
634
+ return
635
+ }
636
+
637
+ const outcome = await drive(ctx, target, liveScan(scan))
638
+ ctx.ui.notify(`harness: ${outcome.message}`, outcome.level)
639
+ }
640
+ })
641
+
642
+ pi.registerTool({
643
+ name: LESSON_TOOL,
644
+ label: 'Lesson',
645
+ defaultActive: false,
646
+ description:
647
+ 'Read the lessons this project learned from its own sessions, and record a new one. The lessons are what the past runs showed, kept in the shape of a situation rather than a task.',
648
+ promptSnippet: 'Lessons learned from earlier sessions in this project',
649
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
650
+ exposure: 'direct',
651
+ namespace: {
652
+ name: 'harness',
653
+ description: 'The lessons earlier sessions in this project left behind.'
654
+ },
655
+ promptGuidelines: [
656
+ 'When the request looks like one an earlier session in this project already hit, call lesson with action match and the request as the cue, before you start working.',
657
+ 'A lesson is a shape of situation, not a task, so it applies to work that looks the same and is not the same.',
658
+ 'Recall once per task, unless the task changes direction.'
659
+ ],
660
+ parameters: LessonParameters,
661
+ async execute(_toolCallId, params: LessonParams, _signal, _onUpdate, ctx) {
662
+ const cwd = ctx.cwd
663
+ if (params.action === 'match') {
664
+ const lessons = matchedLessons(cwd, params.cue ?? '', rarityOf(cwd))
665
+ for (const lesson of lessons) recordLessonUse(cwd, lesson.id)
666
+ return {
667
+ content: [{ type: 'text', text: formatLessons(lessons) }],
668
+ details: { matched: lessons.length }
669
+ }
670
+ }
671
+ if (params.action === 'list') {
672
+ const lessons = readLessons(cwd)
673
+ return {
674
+ content: [{ type: 'text', text: formatLessons(lessons) }],
675
+ details: { lessons: lessons.length }
676
+ }
677
+ }
678
+ if (params.action === 'record') {
679
+ if (
680
+ params.cue === undefined ||
681
+ params.lesson === undefined ||
682
+ params.confirm === undefined ||
683
+ params.evidence === undefined
684
+ ) {
685
+ return {
686
+ content: [
687
+ {
688
+ type: 'text',
689
+ text: 'a lesson needs a cue, a lesson, a way to confirm it and the evidence it rests on'
690
+ }
691
+ ],
692
+ details: { recorded: false }
693
+ }
694
+ }
695
+ const rarity = rarityOf(cwd)
696
+ const text = `${params.cue} ${params.lesson}`
697
+ const lesson: Lesson = {
698
+ id: `manual-${Date.now()}`,
699
+ at: Date.now(),
700
+ cue: params.cue,
701
+ lesson: params.lesson,
702
+ confirm: params.confirm,
703
+ evidence: params.evidence,
704
+ confidence: params.confidence ?? 'medium',
705
+ triggers: rarity === undefined ? triggersOf(text) : rareTriggersOf(text, rarity),
706
+ kind: 'repeat-error',
707
+ state: 'recorded'
708
+ }
709
+ const recorded = recordLesson(cwd, lesson)
710
+ return {
711
+ content: [
712
+ {
713
+ type: 'text',
714
+ text:
715
+ recorded.superseded === null
716
+ ? `recorded: ${lesson.cue}`
717
+ : `recorded: ${lesson.cue}, superseding ${recorded.superseded.cue}`
718
+ }
719
+ ],
720
+ details: { recorded: true }
721
+ }
722
+ }
723
+ const lessons = readLessons(cwd)
724
+ const found = lessons.find((lesson) => lesson.id === params.id)
725
+ if (found === undefined) {
726
+ return {
727
+ content: [{ type: 'text', text: `no lesson with the id ${params.id ?? ''}` }],
728
+ details: { dropped: false }
729
+ }
730
+ }
731
+ const kept = lessons.filter((lesson) => lesson.id !== found.id)
732
+ writeLessons(cwd, kept)
733
+ return {
734
+ content: [{ type: 'text', text: `dropped: ${found.cue}` }],
735
+ details: { dropped: true }
736
+ }
737
+ }
738
+ })
739
+
740
+ pi.on('agent_settled', (event, ctx) => {
741
+ if (event.aborted || !ctx.isIdle() || scheduled) return
742
+ scheduled = true
743
+ setTimeout(async () => {
744
+ scheduled = false
745
+ try {
746
+ if (readInstalls(ctx.cwd).some((entry) => entry.ok && !entry.reverted)) {
747
+ const context = installContext(ctx.cwd, getAgentDir())
748
+ for (const line of autoRevert(ctx.cwd, context, evaluateInstalls(ctx.cwd))) {
749
+ notify(ctx, line, 'info')
750
+ }
751
+ }
752
+ const target = await nextTarget(ctx)
753
+ if (target === null) return
754
+ const outcome = await drive(ctx, target.target, target.scan)
755
+ notify(ctx, `harness: ${outcome.message}`, outcome.level)
756
+ } catch (error) {
757
+ notify(ctx, `the loop failed: ${error instanceof Error ? error.message : String(error)}`, 'warning')
758
+ }
759
+ }, 0)
760
+ })
761
+ }