@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/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
+ }