@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.
@@ -0,0 +1,236 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import { Validator } from 'ata-validator'
6
+ import { t } from 'ata-validator/t'
7
+
8
+
9
+ import { writeFileAtomic } from './atomic.js'
10
+ import { harnessDir } from './store.js'
11
+
12
+ export type ProducerState = 'live' | 'upstream' | 'removed' | 'unknown'
13
+
14
+ export interface ProducerVerdict {
15
+ state: ProducerState
16
+ phrase: string
17
+ detail: string
18
+ }
19
+
20
+ /** Returns the command output, an empty string when it found nothing, and null when the tool is not there. */
21
+ export type Runner = (file: string, args: string[], cwd: string) => string | null
22
+ const SEARCH_SKIP = ['!node_modules', '!.git', '!.pi', '!dist', '!build', '!target']
23
+ const PHRASE_SIZE = 40
24
+
25
+ function isHole(token: string): boolean {
26
+ return /[\\/:]/.test(token) || /\d/.test(token) || token.startsWith('$')
27
+ }
28
+
29
+ function runsOf(line: string): string[] {
30
+ const runs: string[] = []
31
+ let run: string[] = []
32
+ for (const token of line.split(/\s+/)) {
33
+ if (token === '') continue
34
+ if (isHole(token)) {
35
+ if (run.length > 0) runs.push(run.join(' '))
36
+ run = []
37
+ continue
38
+ }
39
+ run.push(token)
40
+ }
41
+ if (run.length > 0) runs.push(run.join(' '))
42
+ return runs
43
+ }
44
+
45
+ /**
46
+ * A message is the part of it that the code writes as text. The ids, the paths and the
47
+ * numbers are the run's, so they are cut out, and the longest run left is what a search
48
+ * can find in a producer.
49
+ */
50
+ export function phrasesOf(body: string, limit = 3): string[] {
51
+ const out: string[] = []
52
+ for (const line of body.split('\n')) {
53
+ for (const run of runsOf(line.trim())) {
54
+ const phrase = run
55
+ .replace(/^[.,;:]+|[.,;:]+$/g, '')
56
+ .slice(0, PHRASE_SIZE)
57
+ .trim()
58
+ if (phrase.split(' ').length < 4) continue
59
+ if (!out.includes(phrase)) out.push(phrase)
60
+ }
61
+ }
62
+ return out.toSorted((a, b) => b.length - a.length).slice(0, limit)
63
+ }
64
+
65
+ const runRg: Runner = (file, args, cwd) => {
66
+ try {
67
+ return execFileSync(file, args, {
68
+ cwd,
69
+ encoding: 'utf8',
70
+ stdio: ['ignore', 'pipe', 'ignore'],
71
+ timeout: 10_000
72
+ })
73
+ } catch (error) {
74
+ const code = (error as { code?: string }).code
75
+ return code === 'ENOENT' ? null : ''
76
+ }
77
+ }
78
+
79
+ function search(run: Runner, cwd: string, phrase: string, root: string, skip: boolean): boolean {
80
+ const globs = skip ? SEARCH_SKIP.flatMap((glob) => ['--glob', glob]) : []
81
+ const out = run('rg', ['-F', '-l', '-m1', '--no-messages', ...globs, '--', phrase, root], cwd)
82
+ return out !== null && out.trim() !== ''
83
+ }
84
+
85
+ function removedAt(run: Runner, cwd: string, phrase: string): { commit: string; at: number } | null {
86
+ const out = run('git', ['log', '-S', phrase, '--format=%h %ct', '-n', '1'], cwd)
87
+ if (out === null) return null
88
+ const [commit, at] = out.trim().split(' ')
89
+ if (commit === undefined || at === undefined) return null
90
+ const seconds = Number(at)
91
+ return Number.isNaN(seconds) ? null : { commit, at: seconds * 1000 }
92
+ }
93
+
94
+ /**
95
+ * `since` is the newest session that hit the failure. A commit that touched the
96
+ * message before that session did not remove it, so the message still comes from
97
+ * somewhere, and a commit after it is the producer going away.
98
+ */
99
+ export function producerOf(
100
+ cwd: string,
101
+ roots: string[],
102
+ body: string,
103
+ run: Runner = runRg,
104
+ cache = true,
105
+ since = 0
106
+ ): ProducerVerdict {
107
+ const phrases = phrasesOf(body)
108
+ if (phrases.length === 0) return { state: 'unknown', phrase: '', detail: '' }
109
+ const key = `${phrases[0] ?? ''}@${since}`
110
+ const head = cache ? cleanHead(run, cwd) : undefined
111
+ if (head !== undefined) {
112
+ const hit = readCache(cwd, head).get(key)
113
+ if (hit !== undefined) return hit
114
+ }
115
+ const verdict = decide(cwd, roots, phrases, run, since)
116
+ if (head !== undefined && verdict.state !== 'unknown') writeCache(cwd, head, key, verdict)
117
+ return verdict
118
+ }
119
+
120
+ function decide(cwd: string, roots: string[], phrases: string[], run: Runner, since: number): ProducerVerdict {
121
+ for (const phrase of phrases) {
122
+ if (search(run, cwd, phrase, cwd, true)) {
123
+ return { state: 'live', phrase, detail: 'the string is in the tree' }
124
+ }
125
+ }
126
+ for (const phrase of phrases) {
127
+ if (roots.some((root) => search(run, cwd, phrase, root, false))) {
128
+ return { state: 'upstream', phrase, detail: 'the string is in a dependency' }
129
+ }
130
+ }
131
+ if (run('git', ['rev-parse', '--git-dir'], cwd) === null) {
132
+ return { state: 'unknown', phrase: phrases[0] ?? '', detail: 'no git and no match' }
133
+ }
134
+ for (const phrase of phrases) {
135
+ const gone = removedAt(run, cwd, phrase)
136
+ if (gone !== null && gone.at >= since) {
137
+ return { state: 'removed', phrase, detail: `removed by ${gone.commit}` }
138
+ }
139
+ }
140
+ return { state: 'unknown', phrase: phrases[0] ?? '', detail: 'no producer found' }
141
+ }
142
+
143
+ const CacheSchema = t.object({
144
+ head: t.optional(t.string()),
145
+ entries: t.record(t.object({
146
+ state: t.union([
147
+ t.literal('live'),
148
+ t.literal('upstream'),
149
+ t.literal('removed'),
150
+ t.literal('unknown')
151
+ ]),
152
+ phrase: t.string(),
153
+ detail: t.string()
154
+ })
155
+ )
156
+ })
157
+ const isCacheSchema = new Validator(CacheSchema).isValidObject
158
+
159
+ interface ProducerCache {
160
+ head?: string
161
+ entries: Record<string, ProducerVerdict>
162
+ }
163
+
164
+ function cacheFile(cwd: string): string {
165
+ return join(harnessDir(cwd), 'producers.json')
166
+ }
167
+
168
+ function headOf(run: Runner, cwd: string): string | undefined {
169
+ const head = run('git', ['rev-parse', 'HEAD'], cwd)?.trim()
170
+ return head ? head : undefined
171
+ }
172
+
173
+ /**
174
+ * A verdict is only reusable when the tree is the commit it was made on. Search results depend
175
+ * on uncommitted edits, which a HEAD key cannot see, so a dirty tree is never read or written.
176
+ */
177
+ function cleanHead(run: Runner, cwd: string): string | undefined {
178
+ const head = headOf(run, cwd)
179
+ if (head === undefined) return undefined
180
+ return run('git', ['status', '--porcelain'], cwd) === '' ? head : undefined
181
+ }
182
+
183
+ function readCache(cwd: string, head: string | undefined): Map<string, ProducerVerdict> {
184
+ const out = new Map<string, ProducerVerdict>()
185
+ const path = cacheFile(cwd)
186
+ if (!existsSync(path)) return out
187
+ let parsed: unknown
188
+ try {
189
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
190
+ } catch {
191
+ return out
192
+ }
193
+ if (!isCacheSchema(parsed)) return out
194
+ if (head === undefined || parsed.head !== head) return out
195
+ for (const [phrase, entry] of Object.entries(parsed.entries)) out.set(phrase, entry)
196
+ return out
197
+ }
198
+
199
+ export function readProducers(cwd: string): ProducerVerdict[] {
200
+ return [...readCache(cwd, headOf(runRg, cwd)).values()]
201
+ }
202
+
203
+ const confoundCache = new Map<string, string | null>()
204
+
205
+ export function commitInWindow(cwd: string, key: string, since: number): string | null {
206
+ const phrase = phrasesOf(key)[0]
207
+ if (phrase === undefined) return null
208
+ const cacheKey = `${cwd}:${headOf(runRg, cwd) ?? ''}:${phrase}:${since}`
209
+ if (confoundCache.has(cacheKey)) return confoundCache.get(cacheKey) ?? null
210
+ const out = runRg('git', ['log', '-S', phrase, '--format=%h %ct', '-n', '20'], cwd)
211
+ let found: string | null = null
212
+ if (out !== null) {
213
+ for (const line of out.trim().split('\n')) {
214
+ const [commit, at] = line.trim().split(' ')
215
+ if (commit === undefined || at === undefined) continue
216
+ if (Number(at) * 1000 >= since) {
217
+ found = commit
218
+ break
219
+ }
220
+ }
221
+ }
222
+ confoundCache.set(cacheKey, found)
223
+ return found
224
+ }
225
+
226
+ function writeCache(cwd: string, head: string, key: string, verdict: ProducerVerdict): void {
227
+ const path = cacheFile(cwd)
228
+ const current = readCache(cwd, head)
229
+ current.set(key, verdict)
230
+ const cache: ProducerCache = {
231
+ head,
232
+ entries: Object.fromEntries(current)
233
+ }
234
+ mkdirSync(harnessDir(cwd), { recursive: true })
235
+ writeFileAtomic(path, `${JSON.stringify(cache)}\n`)
236
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,162 @@
1
+ import type { Recurrence } from './corpus.js'
2
+ import { rate, type Effect } from './effect.js'
3
+ import type { Target } from './target.js'
4
+
5
+ export function buildProposalPrompt(
6
+ target: Target,
7
+ dir: string,
8
+ recurrences: Recurrence[] = [],
9
+ effect: Effect | null = null,
10
+ standing = 0,
11
+ form: 'rule' | 'skill' | 'profile' | null = null
12
+ ): string {
13
+ const evidence = target.evidence.map((line) => `- ${line}`).join('\n')
14
+ const across = recurrences
15
+ .filter((recurrence) => recurrence.id !== target.id)
16
+ .map((recurrence) => `- ${recurrence.title} (${recurrence.occurrences} times)`)
17
+ return [
18
+ '# WHAT YOU ARE EDITING',
19
+ '',
20
+ "pi's harness is the set of files that shape every run: the skills it advertises, the agent",
21
+ 'profiles it can delegate to, the context files it reads at startup, and the extensions that',
22
+ `sit in the tool loop. You are writing one piece of it, and it goes in ${dir}.`,
23
+ '',
24
+ '# THE FINDING',
25
+ '',
26
+ target.title,
27
+ '',
28
+ `${target.count} occurrences in ${target.scope}. The evidence:`,
29
+ '',
30
+ evidence,
31
+ '',
32
+ ...(target.sample === '' ? [] : ['The failure itself:', '', '```', target.sample.slice(0, 600), '```', '']),
33
+ ...(target.tool === '' ? [] : [`The tool that produced it: ${target.tool}.`, '']),
34
+ ...(across.length === 0
35
+ ? []
36
+ : [
37
+ '# THE SAME THING IN EARLIER SESSIONS',
38
+ '',
39
+ 'These recur across the sessions of this project, so they are a property of the',
40
+ 'harness rather than of one run:',
41
+ '',
42
+ ...across,
43
+ ''
44
+ ]),
45
+ ...(effect === null || (effect.verdict !== 'flat' && effect.verdict !== 'worse')
46
+ ? []
47
+ : [
48
+ '# THE EARLIER ATTEMPT',
49
+ '',
50
+ `A part was installed for this finding already, and the sessions after it show`,
51
+ `${effect.verdict === 'worse' ? 'more' : 'the same'} failure: ${rate(effect.before)} before it and`,
52
+ `${rate(effect.after)} after it. The form of that part did not carry, so write a`,
53
+ 'different form, or say KEEP_BASE when the evidence does not argue for one.',
54
+ ...(form === null
55
+ ? []
56
+ : [
57
+ '',
58
+ `That part was a ${form}.`,
59
+ form === 'rule'
60
+ ? 'A rule that did not carry is a situation the model cannot place, so a skill that routes on it is the stronger form.'
61
+ : 'A skill that did not carry is a situation the model does not route to, so a rule that always applies is what is left.'
62
+ ]),
63
+ ''
64
+ ]),
65
+ '# WHAT TO WRITE',
66
+ '',
67
+ ...(target.kind === 'dead-skill'
68
+ ? [
69
+ 'pi advertises every skill by name and description in the system prompt, so a skill',
70
+ 'nobody opens is a tax on every request of every session. The forms:',
71
+ '',
72
+ `- rewrite ${dir}/<name>/SKILL.md with \`disable-model-invocation: true\` in the`,
73
+ ' frontmatter. The skill stays reachable through /skill:name and stops being',
74
+ ' advertised.',
75
+ '- rewrite the description so it names the situation, when the description is what',
76
+ ' keeps the model from choosing it. The description is the only part of a skill the',
77
+ ' model sees before it decides.',
78
+ '- move the skill to a project skills directory, so it is advertised only in the',
79
+ ' projects that need it.',
80
+ '- say KEEP_BASE, when the skill earns its place on the situations that rarely come',
81
+ ' up and a router would miss it.',
82
+ '',
83
+ 'A skill that was never opened in the sessions on disk might still be the right',
84
+ 'tool for a task that has not come up yet. Say so if you think so.'
85
+ ]
86
+ : [
87
+ 'One of:',
88
+ '',
89
+ `- a skill: ${dir}/<name>/SKILL.md, with frontmatter \`name:\` and \`description:\`, and any`,
90
+ ` helper it runs under ${dir}/<name>/scripts/. The description is what the model routes on,`,
91
+ ' so it says what the skill does and when it applies.',
92
+ `- an agent profile: ${dir}/<name>.md, with frontmatter \`name:\` and \`description:\`.`,
93
+ `- a rule: ${dir}/RULE.md, appended to the project context file.`,
94
+ '',
95
+ `Put a skill or a rule under ${dir}/global/ when it has to hold in every project.`,
96
+ 'A skill under global/ goes to the skills every project loads, and a global rule',
97
+ 'goes to the context file every project reads.',
98
+ '',
99
+ 'Both forms cost standing text on every request. A skill costs its name and',
100
+ 'description, about 300 characters, and a rule costs its length. The difference is',
101
+ 'that a rule always applies while a skill only applies when the model routes to',
102
+ 'it, and a skill body costs nothing until it is opened. Say how many characters',
103
+ 'the part adds when it goes into an always-on file, because that number is a tax',
104
+ 'on every request from then on.',
105
+ '',
106
+ ...(standing === 0
107
+ ? []
108
+ : [
109
+ `The parts installed for this project already cost ${standing} characters on`,
110
+ 'every request. The cheaper form that still holds is the right one.',
111
+ ''
112
+ ])
113
+ ]),
114
+ '',
115
+ '# THE CHECK',
116
+ '',
117
+ `Write ${dir}/CONFIRM.md with one shell command that exits 0 only when the failure above`,
118
+ 'can no longer happen. It must not write files, install anything, or use the network. The loop',
119
+ 'runs it after the project check passes, and a confirmation that fails puts the part back.',
120
+ 'When you cannot name a command that proves the change, leave the file out and say why.',
121
+ '',
122
+ ...(target.command === undefined
123
+ ? []
124
+ : [
125
+ 'The command that produced the failure is replayed after the install, so a fix that',
126
+ 'does not change its result is visible in the record:',
127
+ '',
128
+ '```',
129
+ target.command.slice(0, 300),
130
+ '```',
131
+ ''
132
+ ]),
133
+ '# THE OUTPUT',
134
+ '',
135
+ 'The loop writes these files itself when the finding comes from the automatic run. A person or',
136
+ 'a session may write them by hand instead, and nothing else is installed.',
137
+ '',
138
+ '```',
139
+ '<file path="RULE.md">',
140
+ '## Heading',
141
+ 'the rule',
142
+ '</file>',
143
+ '```',
144
+ '',
145
+ `A path is relative to ${dir}: RULE.md, CONFIRM.md, <name>/SKILL.md, <name>.md for a`,
146
+ 'profile, <name>/scripts/<file> for a helper, and the same under global/ for every project.',
147
+ '',
148
+ '# THE RULES',
149
+ '',
150
+ '1. Every change traces to a moment in the evidence above. Name the moment. A change you',
151
+ ' cannot trace is speculation, and speculation in a harness is paid for on every later run.',
152
+ '2. KEEP_BASE is a real answer. When the evidence does not argue for a change, write nothing',
153
+ ' and reply KEEP_BASE.',
154
+ '3. The harness changes how pi works, not what the work produces. Do not change the shape of',
155
+ ' the deliverable.',
156
+ '4. Every helper your component names ships with it, or the component is dead on arrival.',
157
+ '5. The part is read by every later run, so it never names the session files, the finding id, or',
158
+ ' the harness directory. It is a rule about the work, not a report of the evidence.',
159
+ '6. Reply with the file blocks and nothing outside them. A reply that names the moment in a',
160
+ ' comment instead of a file installs nothing.'
161
+ ].join('\n')
162
+ }
package/src/replay.ts ADDED
@@ -0,0 +1,173 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { Validator } from 'ata-validator'
5
+ import { t } from 'ata-validator/t'
6
+
7
+
8
+ import { runCheck } from './check.js'
9
+ import type { Recurrence } from './corpus.js'
10
+ import { harnessDir } from './store.js'
11
+
12
+ export const REPLAY_TTL_MS = 24 * 60 * 60 * 1000
13
+ export const REPLAY_TIMEOUT_MS = 60_000
14
+
15
+ export interface Replay {
16
+ at: number
17
+ id: string
18
+ command: string
19
+ ok: boolean
20
+ exitCode: number | null
21
+ output: string
22
+ }
23
+
24
+ export type ReplayVerdict = 'stale' | 'live' | 'unknown'
25
+
26
+ const ReplaySchema = t.object({
27
+ at: t.number(),
28
+ id: t.string(),
29
+ command: t.string(),
30
+ ok: t.boolean(),
31
+ exitCode: t.union([t.number(), t.null()]),
32
+ output: t.string()
33
+ })
34
+ const isReplaySchema = new Validator(ReplaySchema).isValidObject
35
+
36
+ const READ_ONLY_BINS = new Set([
37
+ '[',
38
+ 'awk',
39
+ 'basename',
40
+ 'cat',
41
+ 'comm',
42
+ 'cut',
43
+ 'date',
44
+ 'diff',
45
+ 'dirname',
46
+ 'echo',
47
+ 'fd',
48
+ 'fdfind',
49
+ 'grep',
50
+ 'head',
51
+ 'id',
52
+ 'jq',
53
+ 'ls',
54
+ 'printenv',
55
+ 'printf',
56
+ 'pwd',
57
+ 'realpath',
58
+ 'rg',
59
+ 'sort',
60
+ 'stat',
61
+ 'tail',
62
+ 'test',
63
+ 'tr',
64
+ 'type',
65
+ 'uniq',
66
+ 'wc',
67
+ 'which'
68
+ ])
69
+
70
+ const GIT_READ = new Set([
71
+ 'blame',
72
+ 'branch',
73
+ 'describe',
74
+ 'diff',
75
+ 'grep',
76
+ 'log',
77
+ 'ls-files',
78
+ 'remote',
79
+ 'rev-parse',
80
+ 'show',
81
+ 'status'
82
+ ])
83
+
84
+ const SHELL_SYNTAX = /[<>&;`\n]|\$\(/
85
+
86
+ function segmentAllowed(segment: string): boolean {
87
+ const [bin, ...args] = segment.split(/\s+/)
88
+ if (bin === undefined) return false
89
+ if (args.some((arg) => arg.startsWith('--output'))) return false
90
+ if (bin === 'git') {
91
+ const sub = args[0] ?? ''
92
+ if (!GIT_READ.has(sub)) return false
93
+ if (sub === 'branch') {
94
+ return args.length === 1 || args.slice(1).every((arg) => /^(-a|-r|-v|--list|--all|--remotes)$/.test(arg))
95
+ }
96
+ if (sub === 'remote') return args.length === 1 || args[1] === '-v' || args[1] === 'show'
97
+ return true
98
+ }
99
+ if (bin === 'sed') return !args.some((arg) => arg === '-i' || arg.startsWith('--in-place'))
100
+ if (bin === 'npx') return args[0] === 'tsc' && args.includes('--noEmit')
101
+ if (bin === 'node') return args.length === 1 && args[0] === '--version'
102
+ if (bin === 'find') return !args.some((arg) => /^-(delete|exec|execdir|ok)$/.test(arg))
103
+ if (bin === 'awk') return !args.some((arg) => arg.includes('system('))
104
+ return READ_ONLY_BINS.has(bin)
105
+ }
106
+
107
+ export function canReplay(command: string): boolean {
108
+ const trimmed = command
109
+ .trim()
110
+ .replace(/\s*2>&1\s*/g, ' ')
111
+ .trim()
112
+ if (trimmed === '' || trimmed.length > 600 || SHELL_SYNTAX.test(trimmed)) return false
113
+ return trimmed.split('|').every((segment) => segmentAllowed(segment.trim()))
114
+ }
115
+
116
+ function replayFile(cwd: string): string {
117
+ return join(harnessDir(cwd), 'replay.jsonl')
118
+ }
119
+
120
+ export function readReplays(cwd: string): Replay[] {
121
+ const path = replayFile(cwd)
122
+ if (!existsSync(path)) return []
123
+ const out: Replay[] = []
124
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
125
+ if (line.trim() === '') continue
126
+ let parsed: unknown
127
+ try {
128
+ parsed = JSON.parse(line)
129
+ } catch {
130
+ continue
131
+ }
132
+ if (isReplaySchema(parsed)) out.push(parsed)
133
+ }
134
+ return out
135
+ }
136
+
137
+ export function appendReplay(cwd: string, replay: Replay): void {
138
+ mkdirSync(harnessDir(cwd), { recursive: true })
139
+ appendFileSync(replayFile(cwd), `${JSON.stringify(replay)}\n`, 'utf8')
140
+ }
141
+
142
+ export function replayVerdict(cwd: string, recurrence: Recurrence): ReplayVerdict {
143
+ if (recurrence.command === undefined) return 'unknown'
144
+ const latest = readReplays(cwd)
145
+ .filter((replay) => replay.id === recurrence.id && replay.command === recurrence.command)
146
+ .toSorted((a, b) => b.at - a.at)[0]
147
+ if (latest === undefined || Date.now() - latest.at > REPLAY_TTL_MS) return 'unknown'
148
+ return latest.ok ? 'stale' : 'live'
149
+ }
150
+
151
+ export async function runReplay(cwd: string, id: string, command: string): Promise<Replay | null> {
152
+ if (!canReplay(command)) return null
153
+ const result = await runCheck(cwd, command, { timeoutMs: REPLAY_TIMEOUT_MS })
154
+ const replay: Replay = {
155
+ at: Date.now(),
156
+ id,
157
+ command,
158
+ ok: result.ok,
159
+ exitCode: result.exitCode,
160
+ output: result.output.slice(0, 2000)
161
+ }
162
+ appendReplay(cwd, replay)
163
+ return replay
164
+ }
165
+
166
+ export async function probeReplay(cwd: string, recurrence: Recurrence): Promise<ReplayVerdict> {
167
+ const cached = replayVerdict(cwd, recurrence)
168
+ if (cached !== 'unknown') return cached
169
+ if (recurrence.command === undefined) return 'unknown'
170
+ const replay = await runReplay(cwd, recurrence.id, recurrence.command)
171
+ if (replay === null) return 'unknown'
172
+ return replay.ok ? 'stale' : 'live'
173
+ }