@tduyng/pi-harness-loop 0.0.0-stage → 0.0.2
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/LICENSE +178 -0
- package/README.md +380 -2
- package/package.json +41 -4
- package/src/apply.ts +257 -0
- package/src/atomic.ts +7 -0
- package/src/auto.ts +483 -0
- package/src/check.ts +123 -0
- package/src/coordination.ts +30 -0
- package/src/corpus.ts +641 -0
- package/src/diversity.ts +73 -0
- package/src/effect.ts +134 -0
- package/src/evaluate.ts +81 -0
- package/src/findings.ts +141 -0
- package/src/index.ts +761 -0
- package/src/install.ts +308 -0
- package/src/ledger.ts +89 -0
- package/src/lesson-tool.ts +39 -0
- package/src/lint.ts +87 -0
- package/src/match.ts +192 -0
- package/src/producer.ts +236 -0
- package/src/prompt.ts +162 -0
- package/src/replay.ts +173 -0
- package/src/store.ts +274 -0
- package/src/target.ts +76 -0
- package/src/transcript.ts +139 -0
- package/src/tree.ts +307 -0
- package/src/types.ts +77 -0
- package/src/writer.ts +134 -0
package/src/auto.ts
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join, relative } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { type ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
6
|
+
import { type Skill } from '@earendil-works/pi-coding-agent'
|
|
7
|
+
import { Validator } from 'ata-validator'
|
|
8
|
+
import { t } from 'ata-validator/t'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
DEAD_SKILL_SESSIONS,
|
|
13
|
+
corpusCachePath,
|
|
14
|
+
scanCorpus,
|
|
15
|
+
sessionFiles,
|
|
16
|
+
signatureHistory,
|
|
17
|
+
callHistory,
|
|
18
|
+
type CorpusScan,
|
|
19
|
+
type PromptWords,
|
|
20
|
+
type Recurrence,
|
|
21
|
+
type SkillUse
|
|
22
|
+
} from './corpus.js'
|
|
23
|
+
import { writeFileAtomic } from './atomic.js'
|
|
24
|
+
import { effectFor } from './effect.js'
|
|
25
|
+
import { DAY_MS, readSpend } from './ledger.js'
|
|
26
|
+
import { rareTriggersOf } from './match.js'
|
|
27
|
+
import { producerOf } from './producer.js'
|
|
28
|
+
import { replayVerdict } from './replay.js'
|
|
29
|
+
import { buildProposalPrompt } from './prompt.js'
|
|
30
|
+
import { harnessDir, readInstalls, readSettings } from './store.js'
|
|
31
|
+
import { targetFromRecurrence, targetFromSkillUse, type Target } from './target.js'
|
|
32
|
+
import { loadHarness } from './tree.js'
|
|
33
|
+
import type { FindingKind, Install } from './types.js'
|
|
34
|
+
|
|
35
|
+
const RecurrenceSchema = t.object({
|
|
36
|
+
id: t.string(),
|
|
37
|
+
key: t.string(),
|
|
38
|
+
kind: t.union([t.literal('error'), t.literal('command'), t.literal('read')]),
|
|
39
|
+
title: t.string(),
|
|
40
|
+
sessions: t.number(),
|
|
41
|
+
occurrences: t.number(),
|
|
42
|
+
tool: t.string(),
|
|
43
|
+
sample: t.string(),
|
|
44
|
+
command: t.optional(t.string()),
|
|
45
|
+
blocked: t.optional(t.string()),
|
|
46
|
+
evidence: t.array(t.string())
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const SkillUseSchema = t.object({
|
|
50
|
+
id: t.string(),
|
|
51
|
+
name: t.string(),
|
|
52
|
+
sessions: t.number(),
|
|
53
|
+
opens: t.number()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const ScanSchema = t.object({
|
|
57
|
+
at: t.number(),
|
|
58
|
+
fingerprint: t.string(),
|
|
59
|
+
sessions: t.number(),
|
|
60
|
+
available: t.optional(t.number()),
|
|
61
|
+
recurrences: t.array(RecurrenceSchema),
|
|
62
|
+
skills: t.array(SkillUseSchema),
|
|
63
|
+
promptCount: t.number(),
|
|
64
|
+
promptWords: t.record(t.number())
|
|
65
|
+
})
|
|
66
|
+
const isScanSchema = new Validator(ScanSchema).isValidObject
|
|
67
|
+
|
|
68
|
+
export interface CachedScan {
|
|
69
|
+
at: number
|
|
70
|
+
fingerprint: string
|
|
71
|
+
sessions: number
|
|
72
|
+
available?: number
|
|
73
|
+
recurrences: Recurrence[]
|
|
74
|
+
skills: SkillUse[]
|
|
75
|
+
promptCount: number
|
|
76
|
+
promptWords: Record<string, number>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface LiveScan {
|
|
80
|
+
at: number
|
|
81
|
+
fingerprint: string
|
|
82
|
+
sessions: number
|
|
83
|
+
available: number
|
|
84
|
+
recurrences: Recurrence[]
|
|
85
|
+
skills: SkillUse[]
|
|
86
|
+
prompts: PromptWords
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SkillFacts {
|
|
90
|
+
name: string
|
|
91
|
+
description: string
|
|
92
|
+
filePath: string
|
|
93
|
+
advertised: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function notify(ctx: ExtensionContext, message: string, level: 'info' | 'warning' | 'error' = 'info'): void {
|
|
97
|
+
try {
|
|
98
|
+
ctx.ui.notify(message, level)
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function scanPath(cwd: string): string {
|
|
103
|
+
return join(harnessDir(cwd), 'scan.json')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function readScan(cwd: string): CachedScan | null {
|
|
107
|
+
const path = scanPath(cwd)
|
|
108
|
+
if (!existsSync(path)) return null
|
|
109
|
+
let parsed: unknown
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
112
|
+
} catch {
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
return isScanSchema(parsed) ? parsed : null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function writeScan(cwd: string, scan: LiveScan): void {
|
|
119
|
+
mkdirSync(harnessDir(cwd), { recursive: true })
|
|
120
|
+
const cached: CachedScan = {
|
|
121
|
+
at: scan.at,
|
|
122
|
+
fingerprint: scan.fingerprint,
|
|
123
|
+
sessions: scan.sessions,
|
|
124
|
+
available: scan.available,
|
|
125
|
+
recurrences: scan.recurrences,
|
|
126
|
+
skills: scan.skills,
|
|
127
|
+
promptCount: scan.prompts.count,
|
|
128
|
+
promptWords: Object.fromEntries(scan.prompts.words)
|
|
129
|
+
}
|
|
130
|
+
writeFileAtomic(scanPath(cwd), `${JSON.stringify(cached)}\n`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function toLive(scan: CachedScan): LiveScan {
|
|
134
|
+
return {
|
|
135
|
+
at: scan.at,
|
|
136
|
+
fingerprint: scan.fingerprint,
|
|
137
|
+
sessions: scan.sessions,
|
|
138
|
+
available: scan.available ?? scan.sessions,
|
|
139
|
+
recurrences: scan.recurrences,
|
|
140
|
+
skills: scan.skills,
|
|
141
|
+
prompts: { count: scan.promptCount, words: new Map(Object.entries(scan.promptWords)) }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function liveScan(scan: CorpusScan): LiveScan {
|
|
146
|
+
return {
|
|
147
|
+
at: Date.now(),
|
|
148
|
+
fingerprint: '',
|
|
149
|
+
sessions: scan.sessions,
|
|
150
|
+
available: scan.available,
|
|
151
|
+
recurrences: scan.recurrences,
|
|
152
|
+
skills: scan.skills,
|
|
153
|
+
prompts: scan.prompts
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The fingerprint of the sessions that are finished, so the one being written does not force a rescan. */
|
|
158
|
+
export function fingerprintOf(sessionDir: string, current: string): string {
|
|
159
|
+
const parts: string[] = []
|
|
160
|
+
for (const file of sessionFiles(sessionDir)) {
|
|
161
|
+
if (current !== '' && file.endsWith(`${current}.jsonl`)) continue
|
|
162
|
+
try {
|
|
163
|
+
const stat = statSync(file)
|
|
164
|
+
parts.push(`${file}:${stat.mtimeMs}:${stat.size}`)
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
167
|
+
return createHash('sha1').update(parts.join('\n')).digest('hex').slice(0, 16)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function freshScan(
|
|
171
|
+
cwd: string,
|
|
172
|
+
agentDir: string,
|
|
173
|
+
sessionDir: string,
|
|
174
|
+
current: string,
|
|
175
|
+
skills: SkillFacts[] = skillFacts(cwd, agentDir)
|
|
176
|
+
): LiveScan {
|
|
177
|
+
const scan = scanCorpus(
|
|
178
|
+
sessionDir,
|
|
179
|
+
skills.map((skill) => skill.name),
|
|
180
|
+
join(cwd, '.pi', 'harness', 'corpus-cache.json'),
|
|
181
|
+
current === '' ? undefined : `${current}.jsonl`
|
|
182
|
+
)
|
|
183
|
+
return {
|
|
184
|
+
at: Date.now(),
|
|
185
|
+
fingerprint: fingerprintOf(sessionDir, current),
|
|
186
|
+
sessions: scan.sessions,
|
|
187
|
+
available: scan.available,
|
|
188
|
+
recurrences: scan.recurrences,
|
|
189
|
+
skills: scan.skills,
|
|
190
|
+
prompts: scan.prompts
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function skillFacts(cwd: string, agentDir: string): SkillFacts[] {
|
|
195
|
+
return loadHarness(cwd, agentDir).skills.map((skill: Skill) => ({
|
|
196
|
+
name: skill.name,
|
|
197
|
+
description: skill.description,
|
|
198
|
+
filePath: skill.filePath,
|
|
199
|
+
advertised: !skill.disableModelInvocation
|
|
200
|
+
}))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function attempted(cwd: string, id: string): boolean {
|
|
204
|
+
return existsSync(join(harnessDir(cwd), id))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const EDIT_MATCH = /oldtext|old text|edits\[|no changes made|replacement produced identical/i
|
|
208
|
+
|
|
209
|
+
function familyOf(recurrence: Recurrence): string | null {
|
|
210
|
+
if (recurrence.kind !== 'error' || recurrence.tool !== 'edit') return null
|
|
211
|
+
return EDIT_MATCH.test(recurrence.title) || EDIT_MATCH.test(recurrence.sample) ? 'edit-match' : null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function bigger(left: Recurrence, right: Recurrence): boolean {
|
|
215
|
+
if (left.sessions !== right.sessions) return left.sessions > right.sessions
|
|
216
|
+
return left.occurrences > right.occurrences
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A new signature is not a new failure when it is a smaller variant of one the loop already
|
|
221
|
+
* tried. The edit tool has one matching mechanic, so the 2-session "oldText must be unique"
|
|
222
|
+
* is the 9-session "oldText must match" with a different error string, and a second proposal
|
|
223
|
+
* for it is the same rule said twice.
|
|
224
|
+
*/
|
|
225
|
+
function coveredByLarger(cwd: string, recurrence: Recurrence, all: Recurrence[]): boolean {
|
|
226
|
+
const family = familyOf(recurrence)
|
|
227
|
+
if (family === null) return false
|
|
228
|
+
return all.some(
|
|
229
|
+
(other) =>
|
|
230
|
+
other.id !== recurrence.id &&
|
|
231
|
+
familyOf(other) === family &&
|
|
232
|
+
bigger(other, recurrence) &&
|
|
233
|
+
(attempted(cwd, other.id) || needsCode(cwd, other))
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A failure that recurs across the sessions of the project is a property of the harness, and
|
|
239
|
+
* the loop acts on it on its own. A command or a file that shows up in every session is a
|
|
240
|
+
* habit, and a habit is a judgement call the operator makes.
|
|
241
|
+
*/
|
|
242
|
+
export function pendingTarget(cwd: string, scan: LiveScan, skills: SkillFacts[]): Target | null {
|
|
243
|
+
for (const recurrence of scan.recurrences) {
|
|
244
|
+
if (recurrence.kind !== 'error') continue
|
|
245
|
+
if (recurrence.blocked !== undefined) continue
|
|
246
|
+
if (!wantsAttempt(cwd, recurrence)) continue
|
|
247
|
+
if (replayVerdict(cwd, recurrence) === 'stale') continue
|
|
248
|
+
if (theCodeIsGone(cwd, recurrence)) continue
|
|
249
|
+
if (coveredByLarger(cwd, recurrence, scan.recurrences)) continue
|
|
250
|
+
return targetFromRecurrence(recurrence)
|
|
251
|
+
}
|
|
252
|
+
for (const use of scan.skills) {
|
|
253
|
+
if (use.opens > 0 || use.sessions < DEAD_SKILL_SESSIONS) continue
|
|
254
|
+
if (attempted(cwd, use.id)) continue
|
|
255
|
+
const live = skills.find((skill) => skill.name === use.name)
|
|
256
|
+
if (live === undefined || !live.advertised) continue
|
|
257
|
+
return targetFromSkillUse(use, live.description, live.filePath)
|
|
258
|
+
}
|
|
259
|
+
return null
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function budgetLeft(cwd: string, now = Date.now()): boolean {
|
|
263
|
+
const today = readProposals(cwd).filter((meta) => meta.at > now - DAY_MS).length
|
|
264
|
+
return today < readSettings(cwd).maxProposalsPerDay
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const OFF = new Set(['0', 'false', 'off', 'no'])
|
|
268
|
+
|
|
269
|
+
/** `PI_HARNESS_LOOP=off` stops the automatic run and leaves the command working. */
|
|
270
|
+
export function loopEnabled(): boolean {
|
|
271
|
+
const value = process.env['PI_HARNESS_LOOP']
|
|
272
|
+
return value === undefined || !OFF.has(value.trim().toLowerCase())
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const KindSchema = t.union([
|
|
276
|
+
t.literal('repeat-error'),
|
|
277
|
+
t.literal('repeat-command'),
|
|
278
|
+
t.literal('repeat-read'),
|
|
279
|
+
t.literal('no-change'),
|
|
280
|
+
t.literal('abandoned-tool'),
|
|
281
|
+
t.literal('dead-skill')
|
|
282
|
+
])
|
|
283
|
+
|
|
284
|
+
const ProposalMetaSchema = t.object({
|
|
285
|
+
job: t.optional(t.string()),
|
|
286
|
+
target: t.string(),
|
|
287
|
+
kind: KindSchema,
|
|
288
|
+
triggers: t.array(t.string()),
|
|
289
|
+
at: t.integer(),
|
|
290
|
+
count: t.optional(t.integer()),
|
|
291
|
+
dir: t.optional(t.string())
|
|
292
|
+
})
|
|
293
|
+
const isProposalMetaSchema = new Validator(ProposalMetaSchema).isValidObject
|
|
294
|
+
|
|
295
|
+
interface ProposalMeta {
|
|
296
|
+
job?: string
|
|
297
|
+
target: string
|
|
298
|
+
kind: FindingKind
|
|
299
|
+
triggers: string[]
|
|
300
|
+
at: number
|
|
301
|
+
count?: number
|
|
302
|
+
dir?: string
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function proposalsFile(cwd: string): string {
|
|
306
|
+
return join(harnessDir(cwd), 'proposals.jsonl')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function readProposals(cwd: string): ProposalMeta[] {
|
|
310
|
+
const path = proposalsFile(cwd)
|
|
311
|
+
if (!existsSync(path)) return []
|
|
312
|
+
const out: ProposalMeta[] = []
|
|
313
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
314
|
+
if (line.trim() === '') continue
|
|
315
|
+
let parsed: unknown
|
|
316
|
+
try {
|
|
317
|
+
parsed = JSON.parse(line)
|
|
318
|
+
} catch {
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
if (isProposalMetaSchema(parsed)) out.push(parsed)
|
|
322
|
+
}
|
|
323
|
+
return out
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function appendProposal(cwd: string, meta: ProposalMeta): void {
|
|
327
|
+
mkdirSync(harnessDir(cwd), { recursive: true })
|
|
328
|
+
appendFileSync(proposalsFile(cwd), `${JSON.stringify(meta)}\n`, 'utf8')
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
type AttemptState = 'new' | 'retry' | 'quiet' | 'needs-code'
|
|
332
|
+
|
|
333
|
+
/** After this many completions on one finding, another one is a budget with no ceiling. */
|
|
334
|
+
export const MAX_ATTEMPTS = 3
|
|
335
|
+
|
|
336
|
+
function proseOnly(cwd: string, finding: string): boolean {
|
|
337
|
+
const install = readInstalls(cwd)
|
|
338
|
+
.filter((entry) => entry.finding === finding && entry.ok && !entry.reverted)
|
|
339
|
+
.toSorted((a, b) => b.at - a.at)[0]
|
|
340
|
+
if (install === undefined) return false
|
|
341
|
+
return install.files.length > 0 && install.files.every((file) => file.to.endsWith('.md'))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function proseReverted(cwd: string, finding: string): boolean {
|
|
345
|
+
const install = readInstalls(cwd)
|
|
346
|
+
.filter((entry) => entry.finding === finding && entry.reverted)
|
|
347
|
+
.toSorted((a, b) => b.at - a.at)[0]
|
|
348
|
+
if (install === undefined) return false
|
|
349
|
+
return install.files.length > 0 && install.files.every((file) => file.to.endsWith('.md'))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* A finding the loop already tried comes back when the sessions after the install show the
|
|
354
|
+
* same rate of failure, or a worse one, and it stays quiet when the rate fell. A finding that
|
|
355
|
+
* never landed comes back as soon as it grows, up to the attempt ceiling. A prose part that
|
|
356
|
+
* did not carry is not tried as prose again, because the failure lives in tool behavior and a
|
|
357
|
+
* rule cannot reach it.
|
|
358
|
+
*/
|
|
359
|
+
function attemptState(cwd: string, recurrence: Recurrence): AttemptState {
|
|
360
|
+
const attempts = readProposals(cwd).filter((meta) => meta.target === recurrence.id)
|
|
361
|
+
const last = attempts[attempts.length - 1]
|
|
362
|
+
if (last === undefined) return attempted(cwd, recurrence.id) ? 'quiet' : 'new'
|
|
363
|
+
const spent = (): AttemptState => (proseOnly(cwd, recurrence.id) ? 'needs-code' : 'quiet')
|
|
364
|
+
const capped = attempts.length >= MAX_ATTEMPTS
|
|
365
|
+
const spends = readSpend(cwd).filter((spend) => spend.target === recurrence.id)
|
|
366
|
+
const outcome = spends[spends.length - 1]?.outcome
|
|
367
|
+
if (outcome === 'auto-reverted') {
|
|
368
|
+
if (proseReverted(cwd, recurrence.id)) return 'needs-code'
|
|
369
|
+
return capped ? spent() : 'retry'
|
|
370
|
+
}
|
|
371
|
+
if (outcome === 'installed') {
|
|
372
|
+
const effect = effectFor(
|
|
373
|
+
readInstalls(cwd),
|
|
374
|
+
signatureHistory(corpusCachePath(cwd)),
|
|
375
|
+
recurrence.id,
|
|
376
|
+
recurrence.key,
|
|
377
|
+
undefined,
|
|
378
|
+
callHistory(corpusCachePath(cwd))
|
|
379
|
+
)
|
|
380
|
+
if (effect === null) return 'quiet'
|
|
381
|
+
if (effect.verdict === 'flat' || effect.verdict === 'worse' || effect.verdict === 'avoided') {
|
|
382
|
+
return capped ? spent() : 'retry'
|
|
383
|
+
}
|
|
384
|
+
return 'quiet'
|
|
385
|
+
}
|
|
386
|
+
if (last.count === undefined) return 'quiet'
|
|
387
|
+
if (recurrence.occurrences <= last.count) return 'quiet'
|
|
388
|
+
return capped ? spent() : 'retry'
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function wantsAttempt(cwd: string, recurrence: Recurrence): boolean {
|
|
392
|
+
const state = attemptState(cwd, recurrence)
|
|
393
|
+
return state === 'new' || state === 'retry'
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function needsCode(cwd: string, recurrence: Recurrence): boolean {
|
|
397
|
+
return attemptState(cwd, recurrence) === 'needs-code'
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* A failure whose producer was deleted upstream cannot be prevented by a part, and writing one
|
|
402
|
+
* for it costs a turn and leaves a rule nobody can act on. The verdict is cached, because it
|
|
403
|
+
* is a search of the tree.
|
|
404
|
+
*/
|
|
405
|
+
function theCodeIsGone(cwd: string, recurrence: Recurrence): boolean {
|
|
406
|
+
if (recurrence.sample === '') return false
|
|
407
|
+
const uses = signatureHistory(corpusCachePath(cwd)).get(recurrence.key) ?? []
|
|
408
|
+
const since = uses[uses.length - 1]?.at ?? 0
|
|
409
|
+
const verdict = producerOf(cwd, [join(cwd, 'node_modules')], recurrence.sample, undefined, true, since)
|
|
410
|
+
return verdict.state === 'removed'
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The form the loop already tried, so the next brief can ask for the other one instead of a
|
|
415
|
+
* different wording of the same thing. A rule that did not carry is a routing problem, and a
|
|
416
|
+
* skill that did not carry is an always-on problem.
|
|
417
|
+
*/
|
|
418
|
+
export function formOf(install: Install): 'rule' | 'skill' | 'profile' | null {
|
|
419
|
+
for (const file of install.files) {
|
|
420
|
+
if (file.mode === 'append') return 'rule'
|
|
421
|
+
if (file.to.endsWith('SKILL.md')) return 'skill'
|
|
422
|
+
if (file.to.endsWith('.md')) return 'profile'
|
|
423
|
+
}
|
|
424
|
+
return null
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function previousForm(cwd: string, finding: string): 'rule' | 'skill' | 'profile' | null {
|
|
428
|
+
const install = readInstalls(cwd)
|
|
429
|
+
.filter((entry) => entry.finding === finding && entry.ok && !entry.reverted)
|
|
430
|
+
.toSorted((a, b) => b.at - a.at)[0]
|
|
431
|
+
return install === undefined ? null : formOf(install)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function attemptDir(cwd: string, id: string): string {
|
|
435
|
+
const attempts = readProposals(cwd).filter((meta) => meta.target === id).length
|
|
436
|
+
return attempts === 0 ? join(harnessDir(cwd), id) : join(harnessDir(cwd), `${id}-${attempts + 1}`)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* A proposal is a file, not a process. The brief is what the live session reads when it
|
|
441
|
+
* writes the part, and recording it marks the finding as tried, so the loop stays quiet
|
|
442
|
+
* until the failure doubles.
|
|
443
|
+
*/
|
|
444
|
+
export function proposeToDisk(cwd: string, target: Target, scan: LiveScan): string {
|
|
445
|
+
const dir = attemptDir(cwd, target.id)
|
|
446
|
+
mkdirSync(dir, { recursive: true })
|
|
447
|
+
const key = scan.recurrences.find((recurrence) => recurrence.id === target.id)?.key
|
|
448
|
+
const standing = readInstalls(cwd)
|
|
449
|
+
.filter((install) => install.ok && !install.reverted)
|
|
450
|
+
.reduce((total, install) => total + (install.cost ?? 0), 0)
|
|
451
|
+
const effect =
|
|
452
|
+
key === undefined
|
|
453
|
+
? null
|
|
454
|
+
: effectFor(
|
|
455
|
+
readInstalls(cwd),
|
|
456
|
+
signatureHistory(corpusCachePath(cwd)),
|
|
457
|
+
target.id,
|
|
458
|
+
key,
|
|
459
|
+
undefined,
|
|
460
|
+
callHistory(corpusCachePath(cwd))
|
|
461
|
+
)
|
|
462
|
+
writeFileSync(
|
|
463
|
+
join(dir, 'BRIEF.md'),
|
|
464
|
+
buildProposalPrompt(
|
|
465
|
+
target,
|
|
466
|
+
relative(cwd, dir),
|
|
467
|
+
scan.recurrences,
|
|
468
|
+
effect,
|
|
469
|
+
standing,
|
|
470
|
+
previousForm(cwd, target.id)
|
|
471
|
+
),
|
|
472
|
+
'utf8'
|
|
473
|
+
)
|
|
474
|
+
appendProposal(cwd, {
|
|
475
|
+
target: target.id,
|
|
476
|
+
kind: target.kind,
|
|
477
|
+
triggers: rareTriggersOf(`${target.title} ${target.cue}`, scan.prompts),
|
|
478
|
+
at: Date.now(),
|
|
479
|
+
count: target.count,
|
|
480
|
+
dir: relative(cwd, dir)
|
|
481
|
+
})
|
|
482
|
+
return dir
|
|
483
|
+
}
|
package/src/check.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { createLocalBashOperations, truncateTail } from '@earendil-works/pi-coding-agent'
|
|
5
|
+
import { Validator } from 'ata-validator'
|
|
6
|
+
import { t } from 'ata-validator/t'
|
|
7
|
+
|
|
8
|
+
export interface CheckResult {
|
|
9
|
+
command: string
|
|
10
|
+
ok: boolean
|
|
11
|
+
exitCode: number | null
|
|
12
|
+
failures: number | null
|
|
13
|
+
output: string
|
|
14
|
+
durationMs: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PackageJson = t.object({
|
|
18
|
+
scripts: t.optional(t.record(t.string()))
|
|
19
|
+
})
|
|
20
|
+
const isPackageJson = new Validator(PackageJson).isValidObject
|
|
21
|
+
|
|
22
|
+
const TEST_BY_LOCKFILE = [
|
|
23
|
+
['bun.lock', 'bun run test'],
|
|
24
|
+
['bun.lockb', 'bun run test'],
|
|
25
|
+
['pnpm-lock.yaml', 'pnpm test'],
|
|
26
|
+
['yarn.lock', 'yarn test']
|
|
27
|
+
] as const satisfies readonly (readonly [string, string])[]
|
|
28
|
+
|
|
29
|
+
function packageTest(cwd: string): string {
|
|
30
|
+
const path = join(cwd, 'package.json')
|
|
31
|
+
if (!existsSync(path)) return ''
|
|
32
|
+
let parsed: unknown
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
35
|
+
} catch {
|
|
36
|
+
return ''
|
|
37
|
+
}
|
|
38
|
+
if (!isPackageJson(parsed)) return ''
|
|
39
|
+
if (parsed.scripts?.['test'] === undefined) return ''
|
|
40
|
+
const lockfile = TEST_BY_LOCKFILE.find(([file]) => existsSync(join(cwd, file)))
|
|
41
|
+
return lockfile?.[1] ?? 'npm test'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function justTest(cwd: string): string {
|
|
45
|
+
const path = join(cwd, 'justfile')
|
|
46
|
+
if (!existsSync(path)) return ''
|
|
47
|
+
return /^test:/m.test(readFileSync(path, 'utf8')) ? 'just test' : ''
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function discoverCheck(cwd: string, configured: string): string {
|
|
51
|
+
if (configured.trim() !== '') return configured.trim()
|
|
52
|
+
return packageTest(cwd) || justTest(cwd)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function failedCount(output: string): number | null {
|
|
56
|
+
const summary = /Tests\s+(\d+)\s+failed/.exec(output)
|
|
57
|
+
if (summary?.[1] !== undefined) return Number(summary[1])
|
|
58
|
+
const generic = /(\d+)\s+(?:tests?\s+)?failed/.exec(output)
|
|
59
|
+
return generic?.[1] === undefined ? null : Number(generic[1])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function failureNames(output: string): string[] | null {
|
|
63
|
+
const names = [...output.matchAll(/^\s*(?:\(fail\)|✕|×|✗)\s+(.+)$/gm)]
|
|
64
|
+
.map((match) => (match[1] ?? '').trim())
|
|
65
|
+
.filter((name) => name !== '')
|
|
66
|
+
if (names.length > 0) return names.toSorted()
|
|
67
|
+
return failedCount(output) === 0 ? [] : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const MAX_CHECK_OUTPUT_BYTES = 1500
|
|
71
|
+
const DEFAULT_CHECK_TIMEOUT_MS = 300_000
|
|
72
|
+
|
|
73
|
+
async function runSharedCheck(
|
|
74
|
+
cwd: string,
|
|
75
|
+
command: string,
|
|
76
|
+
options: { timeoutMs?: number } = {}
|
|
77
|
+
): Promise<{ ok: boolean; exitCode: number | null; durationMs: number; output: string }> {
|
|
78
|
+
const timeoutMs = options.timeoutMs && options.timeoutMs > 0 ? options.timeoutMs : DEFAULT_CHECK_TIMEOUT_MS
|
|
79
|
+
let captured = Buffer.alloc(0)
|
|
80
|
+
const started = Date.now()
|
|
81
|
+
const finish = (exitCode: number | null, error?: string) => {
|
|
82
|
+
const durationMs = Date.now() - started
|
|
83
|
+
const output = captured.toString('utf8')
|
|
84
|
+
const body = error ? `${output}${output ? '\n' : ''}${error}` : output
|
|
85
|
+
const tail = truncateTail(body, { maxBytes: MAX_CHECK_OUTPUT_BYTES })
|
|
86
|
+
return {
|
|
87
|
+
ok: exitCode === 0,
|
|
88
|
+
exitCode,
|
|
89
|
+
durationMs,
|
|
90
|
+
output: tail.content.trim()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
let exitCode: number | null
|
|
94
|
+
try {
|
|
95
|
+
;({ exitCode } = await createLocalBashOperations().exec(command, cwd, {
|
|
96
|
+
onData: (data) => {
|
|
97
|
+
const combined = Buffer.concat([captured, data])
|
|
98
|
+
captured = combined.subarray(Math.max(0, combined.length - MAX_CHECK_OUTPUT_BYTES))
|
|
99
|
+
},
|
|
100
|
+
timeout: timeoutMs / 1000
|
|
101
|
+
}))
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
104
|
+
return finish(null, message)
|
|
105
|
+
}
|
|
106
|
+
return finish(exitCode)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function runCheck(
|
|
110
|
+
cwd: string,
|
|
111
|
+
command: string,
|
|
112
|
+
options: { timeoutMs?: number } = {}
|
|
113
|
+
): Promise<CheckResult> {
|
|
114
|
+
const result = await runSharedCheck(cwd, command, options)
|
|
115
|
+
return {
|
|
116
|
+
command,
|
|
117
|
+
ok: result.ok,
|
|
118
|
+
exitCode: result.exitCode,
|
|
119
|
+
failures: failedCount(result.output),
|
|
120
|
+
output: result.output,
|
|
121
|
+
durationMs: result.durationMs
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SessionEntry } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
import { Validator } from 'ata-validator'
|
|
4
|
+
import { t } from 'ata-validator/t'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
const GOAL_TYPE = 'goal'
|
|
8
|
+
|
|
9
|
+
const GoalData = t.object({
|
|
10
|
+
goal: t.optional(t.union([t.object({ status: t.optional(t.string()) }), t.null()]))
|
|
11
|
+
})
|
|
12
|
+
const isGoalData = new Validator(GoalData).isValidObject
|
|
13
|
+
|
|
14
|
+
/** The status of the goal in this session, or null when there is no goal. */
|
|
15
|
+
export function goalStatus(entries: SessionEntry[]): string | null {
|
|
16
|
+
let status: string | null = null
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
if (entry.type !== 'custom' || entry.customType !== GOAL_TYPE) continue
|
|
19
|
+
if (!isGoalData(entry.data)) continue
|
|
20
|
+
const goal = entry.data.goal
|
|
21
|
+
status = goal === undefined || goal === null ? null : (goal.status ?? null)
|
|
22
|
+
}
|
|
23
|
+
return status
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The goal drives the session with its own turns, so the loop stays out of its way. */
|
|
27
|
+
export function goalInTheWay(entries: SessionEntry[]): boolean {
|
|
28
|
+
const status = goalStatus(entries)
|
|
29
|
+
return status === 'active'
|
|
30
|
+
}
|