@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/match.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { Lesson } from './types.js'
|
|
2
|
+
import type { PromptWords } from './corpus.js'
|
|
3
|
+
|
|
4
|
+
/** A trigger that shows up in more than this share of the prompts of the project says nothing. */
|
|
5
|
+
const COMMON_RARITY = 0.02
|
|
6
|
+
|
|
7
|
+
function commonIn(word: string, rarity?: PromptWords): boolean {
|
|
8
|
+
if (rarity === undefined || rarity.count === 0) return false
|
|
9
|
+
return (rarity.words.get(word) ?? 0) / rarity.count > COMMON_RARITY
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const STOP = new Set([
|
|
13
|
+
'about',
|
|
14
|
+
'above',
|
|
15
|
+
'after',
|
|
16
|
+
'again',
|
|
17
|
+
'against',
|
|
18
|
+
'all',
|
|
19
|
+
'also',
|
|
20
|
+
'and',
|
|
21
|
+
'another',
|
|
22
|
+
'any',
|
|
23
|
+
'anyway',
|
|
24
|
+
'are',
|
|
25
|
+
'around',
|
|
26
|
+
'ask',
|
|
27
|
+
'asks',
|
|
28
|
+
'away',
|
|
29
|
+
'back',
|
|
30
|
+
'because',
|
|
31
|
+
'been',
|
|
32
|
+
'before',
|
|
33
|
+
'being',
|
|
34
|
+
'below',
|
|
35
|
+
'both',
|
|
36
|
+
'but',
|
|
37
|
+
'can',
|
|
38
|
+
'calls',
|
|
39
|
+
'could',
|
|
40
|
+
'did',
|
|
41
|
+
'does',
|
|
42
|
+
'doing',
|
|
43
|
+
'down',
|
|
44
|
+
'during',
|
|
45
|
+
'each',
|
|
46
|
+
'every',
|
|
47
|
+
'from',
|
|
48
|
+
'further',
|
|
49
|
+
'get',
|
|
50
|
+
'gets',
|
|
51
|
+
'had',
|
|
52
|
+
'has',
|
|
53
|
+
'have',
|
|
54
|
+
'here',
|
|
55
|
+
'how',
|
|
56
|
+
'into',
|
|
57
|
+
'its',
|
|
58
|
+
'just',
|
|
59
|
+
'least',
|
|
60
|
+
'less',
|
|
61
|
+
'like',
|
|
62
|
+
'made',
|
|
63
|
+
'make',
|
|
64
|
+
'makes',
|
|
65
|
+
'many',
|
|
66
|
+
'more',
|
|
67
|
+
'most',
|
|
68
|
+
'much',
|
|
69
|
+
'must',
|
|
70
|
+
'not',
|
|
71
|
+
'now',
|
|
72
|
+
'only',
|
|
73
|
+
'onto',
|
|
74
|
+
'other',
|
|
75
|
+
'our',
|
|
76
|
+
'out',
|
|
77
|
+
'over',
|
|
78
|
+
'own',
|
|
79
|
+
'per',
|
|
80
|
+
'put',
|
|
81
|
+
'same',
|
|
82
|
+
'see',
|
|
83
|
+
'set',
|
|
84
|
+
'she',
|
|
85
|
+
'should',
|
|
86
|
+
'some',
|
|
87
|
+
'such',
|
|
88
|
+
'than',
|
|
89
|
+
'that',
|
|
90
|
+
'the',
|
|
91
|
+
'their',
|
|
92
|
+
'them',
|
|
93
|
+
'then',
|
|
94
|
+
'there',
|
|
95
|
+
'these',
|
|
96
|
+
'they',
|
|
97
|
+
'this',
|
|
98
|
+
'those',
|
|
99
|
+
'through',
|
|
100
|
+
'too',
|
|
101
|
+
'two',
|
|
102
|
+
'under',
|
|
103
|
+
'until',
|
|
104
|
+
'used',
|
|
105
|
+
'uses',
|
|
106
|
+
'using',
|
|
107
|
+
'very',
|
|
108
|
+
'want',
|
|
109
|
+
'was',
|
|
110
|
+
'way',
|
|
111
|
+
'were',
|
|
112
|
+
'what',
|
|
113
|
+
'when',
|
|
114
|
+
'where',
|
|
115
|
+
'which',
|
|
116
|
+
'while',
|
|
117
|
+
'who',
|
|
118
|
+
'whom',
|
|
119
|
+
'whose',
|
|
120
|
+
'will',
|
|
121
|
+
'with',
|
|
122
|
+
'would',
|
|
123
|
+
'yes',
|
|
124
|
+
'yet',
|
|
125
|
+
'you',
|
|
126
|
+
'your'
|
|
127
|
+
])
|
|
128
|
+
|
|
129
|
+
export function triggersOf(text: string, limit = 8): string[] {
|
|
130
|
+
const words = text.toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) ?? []
|
|
131
|
+
const counts = new Map<string, number>()
|
|
132
|
+
for (const word of words) {
|
|
133
|
+
if (STOP.has(word)) continue
|
|
134
|
+
counts.set(word, (counts.get(word) ?? 0) + 1)
|
|
135
|
+
}
|
|
136
|
+
return [...counts.entries()]
|
|
137
|
+
.toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
138
|
+
.slice(0, limit)
|
|
139
|
+
.map(([word]) => word)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const MIN_TRIGGER_PROMPTS = 2
|
|
143
|
+
|
|
144
|
+
function plausible(word: string, count: number): boolean {
|
|
145
|
+
if (count >= MIN_TRIGGER_PROMPTS) return true
|
|
146
|
+
if (word.length < 8) return false
|
|
147
|
+
return !/[bcdfghjklmnpqrstvwxz]{4,}/.test(word)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The trigger words are the ones that say the situation, not the ones every
|
|
152
|
+
* request has. The corpus says which is which: a word the prompts never used
|
|
153
|
+
* cannot route to anything, so it is not a trigger, and a word most of them
|
|
154
|
+
* carry would put the tool in every request. A token seen once has to look like
|
|
155
|
+
* a word, because a typo is the rarest token in the corpus and routes nowhere.
|
|
156
|
+
*/
|
|
157
|
+
export function rareTriggersOf(cue: string, rarity: PromptWords, limit = 6): string[] {
|
|
158
|
+
const words = [
|
|
159
|
+
...new Set((cue.toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) ?? []).filter((word) => !STOP.has(word)))
|
|
160
|
+
]
|
|
161
|
+
const seen = words
|
|
162
|
+
.map((word) => ({ word, count: rarity.words.get(word) ?? 0 }))
|
|
163
|
+
.filter((entry) => entry.count > 0 && plausible(entry.word, entry.count))
|
|
164
|
+
.toSorted((a, b) => a.count - b.count || a.word.localeCompare(b.word))
|
|
165
|
+
const rare = seen.filter((entry) => entry.count <= Math.max(1, Math.floor(rarity.count * COMMON_RARITY)))
|
|
166
|
+
const kept = rare.length > 0 ? rare : seen.slice(0, 3)
|
|
167
|
+
return kept.slice(0, limit).map((entry) => entry.word)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function rareEnough(word: string, rarity?: PromptWords): boolean {
|
|
171
|
+
if (rarity === undefined || rarity.count === 0) return word.length >= 6
|
|
172
|
+
return (rarity.words.get(word) ?? 0) / rarity.count <= 0.005
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One word is a coincidence: a request that says "key" is not a request about
|
|
177
|
+
* credentials. Two triggers, or one long rare one, is a situation.
|
|
178
|
+
*/
|
|
179
|
+
export function matchLessons(lessons: Lesson[], prompt: string, rarity?: PromptWords): Lesson[] {
|
|
180
|
+
const words = new Set(prompt.toLowerCase().match(/[a-z][a-z0-9_-]{2,}/g) ?? [])
|
|
181
|
+
const scored = lessons
|
|
182
|
+
.filter((lesson) => lesson.state !== 'superseded' && lesson.state !== 'rejected' && lesson.state !== 'failed')
|
|
183
|
+
.map((lesson) => {
|
|
184
|
+
const triggers = lesson.triggers.length > 0 ? lesson.triggers : triggersOf(lesson.cue)
|
|
185
|
+
const matched = triggers.filter((word) => words.has(word) && !commonIn(word, rarity))
|
|
186
|
+
const strong = matched.some((word) => word.length >= 6 && rareEnough(word, rarity))
|
|
187
|
+
return { lesson, hits: matched.length, strong }
|
|
188
|
+
})
|
|
189
|
+
.filter((entry) => entry.hits >= 2 || (entry.hits === 1 && entry.strong))
|
|
190
|
+
.toSorted((a, b) => b.hits - a.hits || b.lesson.at - a.lesson.at)
|
|
191
|
+
return scored.map((entry) => entry.lesson)
|
|
192
|
+
}
|
package/src/producer.ts
ADDED
|
@@ -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
|
+
}
|