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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tree.ts ADDED
@@ -0,0 +1,307 @@
1
+ import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { basename, dirname, join, resolve } from 'node:path'
4
+
5
+ import { formatSkillsForPrompt, loadSkills, type Skill } from '@earendil-works/pi-coding-agent'
6
+
7
+ export interface AgentProfile {
8
+ name: string
9
+ description: string
10
+ file: string
11
+ body: string
12
+ }
13
+
14
+ export interface Problem {
15
+ entry: string
16
+ file: string
17
+ rule: string
18
+ detail: string
19
+ }
20
+
21
+ export interface Harness {
22
+ skills: Skill[]
23
+ agents: AgentProfile[]
24
+ problems: Problem[]
25
+ skillBlockChars: number
26
+ }
27
+
28
+ interface Frontmatter {
29
+ fields: Record<string, string>
30
+ body: string
31
+ }
32
+
33
+ const RUN_COMMAND = /(?:python3?|bash|sh|node)\s+([^\s`'"]+)/g
34
+
35
+ function skillDirs(cwd: string, agentDir: string): string[] {
36
+ const dirs = [join(agentDir, 'skills'), join(cwd, '.pi', 'skills')]
37
+ let dir = resolve(cwd)
38
+ while (true) {
39
+ dirs.push(join(dir, '.agents', 'skills'))
40
+ const parent = join(dir, '..')
41
+ if (parent === dir || existsSync(join(dir, '.git'))) break
42
+ dir = parent
43
+ }
44
+ dirs.push(join(homedir(), '.agents', 'skills'))
45
+ return dirs.filter((candidate) => existsSync(candidate))
46
+ }
47
+
48
+ function agentDirs(cwd: string, agentDir: string): string[] {
49
+ return [join(agentDir, 'agents'), join(cwd, '.pi', 'agents')].filter((dir) => existsSync(dir))
50
+ }
51
+
52
+ function frontmatter(raw: string): Frontmatter {
53
+ const match = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(raw)
54
+ if (!match?.[1]) return { fields: {}, body: raw }
55
+ const fields: Record<string, string> = {}
56
+ for (const line of match[1].split('\n')) {
57
+ const entry = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line)
58
+ if (entry?.[1] !== undefined) fields[entry[1]] = (entry[2] ?? '').trim().replace(/^["']|["']$/g, '')
59
+ }
60
+ return { fields, body: raw.slice(match[0].length) }
61
+ }
62
+
63
+ export function loadAgents(cwd: string, agentDir: string): AgentProfile[] {
64
+ const agents: AgentProfile[] = []
65
+ for (const dir of agentDirs(cwd, agentDir)) {
66
+ let entries: string[]
67
+ try {
68
+ entries = readdirSync(dir)
69
+ } catch {
70
+ continue
71
+ }
72
+ for (const entry of entries.toSorted()) {
73
+ if (!entry.endsWith('.md')) continue
74
+ const file = join(dir, entry)
75
+ let raw: string
76
+ try {
77
+ raw = readFileSync(file, 'utf8')
78
+ } catch {
79
+ continue
80
+ }
81
+ const { fields, body } = frontmatter(raw)
82
+ agents.push({
83
+ name: fields['name'] || basename(entry, '.md'),
84
+ description: fields['description'] ?? '',
85
+ file,
86
+ body: body.trim()
87
+ })
88
+ }
89
+ }
90
+ return agents
91
+ }
92
+
93
+ function resolveHelper(target: string, skillDir: string, searchDirs: string[]): string | null {
94
+ const candidates: string[] = []
95
+ if (target.startsWith('~/')) candidates.push(join(homedir(), target.slice(2)))
96
+ else if (target.startsWith('/')) candidates.push(target)
97
+ else {
98
+ candidates.push(resolve(skillDir, target))
99
+ for (const dir of searchDirs) candidates.push(resolve(dir, target))
100
+ }
101
+ return candidates.find((candidate) => existsSync(candidate)) ?? null
102
+ }
103
+
104
+ export interface SkillFiles {
105
+ filePath: string
106
+ baseDir: string
107
+ }
108
+
109
+ export function missingHelpers(skill: SkillFiles, searchDirs: string[]): string[] {
110
+ let body: string
111
+ try {
112
+ body = readFileSync(skill.filePath, 'utf8')
113
+ } catch {
114
+ return []
115
+ }
116
+ const missing = new Set<string>()
117
+ for (const match of body.matchAll(RUN_COMMAND)) {
118
+ const target = match[1]
119
+ if (!target?.includes('scripts/')) continue
120
+ if (resolveHelper(target, skill.baseDir, searchDirs) === null) missing.add(target)
121
+ }
122
+ return [...missing]
123
+ }
124
+
125
+ function findSkillFiles(dir: string): string[] {
126
+ const files: string[] = []
127
+ let entries: Dirent[]
128
+ try {
129
+ entries = readdirSync(dir, { withFileTypes: true })
130
+ } catch {
131
+ return files
132
+ }
133
+ for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) {
134
+ const full = join(dir, entry.name)
135
+ if (entry.isFile() && entry.name === 'SKILL.md') {
136
+ files.push(full)
137
+ continue
138
+ }
139
+ if (entry.isDirectory()) files.push(...findSkillFiles(full))
140
+ }
141
+ return files
142
+ }
143
+
144
+ export function validateProposal(dir: string, cwd: string): Problem[] {
145
+ const problems: Problem[] = []
146
+ for (const file of findSkillFiles(dir)) {
147
+ const raw = readFileSync(file, 'utf8')
148
+ const { fields } = frontmatter(raw)
149
+ const name = fields['name'] ?? ''
150
+ const description = fields['description'] ?? ''
151
+ if (description.trim() === '') {
152
+ problems.push({
153
+ entry: name,
154
+ file,
155
+ rule: 'no-description',
156
+ detail: 'pi drops a skill with no description at load, so it never reaches the model'
157
+ })
158
+ }
159
+ if (name !== '' && !/^[a-z0-9-]+$/.test(name)) {
160
+ problems.push({
161
+ entry: name,
162
+ file,
163
+ rule: 'bad-name',
164
+ detail: 'a skill name is lowercase letters, digits and hyphens'
165
+ })
166
+ }
167
+ for (const helper of missingHelpers({ filePath: file, baseDir: dirname(file) }, [cwd])) {
168
+ problems.push({
169
+ entry: name,
170
+ file,
171
+ rule: 'missing-helper',
172
+ detail: `the skill tells the agent to run ${helper}, and no such file exists`
173
+ })
174
+ }
175
+ }
176
+ for (const file of findRuleFiles(dir)) problems.push(...ruleProblems(file))
177
+ return problems
178
+ }
179
+
180
+ const RULE_MAX_LINES = 30
181
+ const CREDENTIAL = /\b(api[_-]?key|access[_-]?token|secret|password|passwd|bearer)\b\s*[:=]\s*\S+/i
182
+
183
+ /**
184
+ * A rule lands in a file every request of every project reads, so the project check cannot
185
+ * fail for it and the lint is the only gate it passes.
186
+ */
187
+ function ruleProblems(file: string): Problem[] {
188
+ const problems: Problem[] = []
189
+ let raw: string
190
+ try {
191
+ raw = readFileSync(file, 'utf8')
192
+ } catch {
193
+ return problems
194
+ }
195
+ const body = raw.trim()
196
+ if (!/^##\s+\S/m.test(body)) {
197
+ problems.push({
198
+ entry: 'RULE',
199
+ file,
200
+ rule: 'no-heading',
201
+ detail: 'a rule is addressed by its heading, and without one the loop cannot replace it later'
202
+ })
203
+ }
204
+ const lines = body.split('\n').length
205
+ if (lines > RULE_MAX_LINES) {
206
+ problems.push({
207
+ entry: 'RULE',
208
+ file,
209
+ rule: 'too-long',
210
+ detail: `the rule is ${lines} lines and every request of every project pays for it`
211
+ })
212
+ }
213
+ if (CREDENTIAL.test(body)) {
214
+ problems.push({
215
+ entry: 'RULE',
216
+ file,
217
+ rule: 'credential',
218
+ detail: 'the rule carries a credential and lands in a file every session reads'
219
+ })
220
+ }
221
+ return problems
222
+ }
223
+
224
+ function findRuleFiles(dir: string): string[] {
225
+ const files: string[] = []
226
+ for (const path of [join(dir, 'RULE.md'), join(dir, 'global', 'RULE.md')]) {
227
+ if (existsSync(path)) files.push(path)
228
+ }
229
+ return files
230
+ }
231
+
232
+ export function loadHarness(cwd: string, agentDir: string): Harness {
233
+ const { skills, diagnostics } = loadSkills({
234
+ agentDir,
235
+ skillPaths: skillDirs(cwd, agentDir),
236
+ includeDefaults: true,
237
+ cwd
238
+ })
239
+ const problems: Problem[] = []
240
+ for (const diagnostic of diagnostics) {
241
+ problems.push({
242
+ entry: diagnostic.path ?? '',
243
+ file: diagnostic.path ?? '',
244
+ rule: diagnostic.type,
245
+ detail: diagnostic.message
246
+ })
247
+ }
248
+
249
+ const searchDirs = [cwd, agentDir]
250
+ const names = new Map<string, string>()
251
+ for (const skill of skills) {
252
+ const dir = basename(skill.baseDir)
253
+ if (skill.name !== dir) {
254
+ problems.push({
255
+ entry: skill.name,
256
+ file: skill.filePath,
257
+ rule: 'name-differs-from-directory',
258
+ detail: `pi advertises the frontmatter name "${skill.name}" and the folder is "${dir}", so a prompt that names the folder does not name the skill`
259
+ })
260
+ }
261
+ const previous = names.get(skill.name)
262
+ if (previous !== undefined) {
263
+ problems.push({
264
+ entry: skill.name,
265
+ file: skill.filePath,
266
+ rule: 'duplicate-name',
267
+ detail: `pi keeps the first skill named "${skill.name}" (${previous}) and drops this one`
268
+ })
269
+ } else {
270
+ names.set(skill.name, skill.filePath)
271
+ }
272
+ for (const helper of missingHelpers(skill, searchDirs)) {
273
+ problems.push({
274
+ entry: skill.name,
275
+ file: skill.filePath,
276
+ rule: 'missing-helper',
277
+ detail: `the skill tells the agent to run ${helper}, and no such file exists`
278
+ })
279
+ }
280
+ }
281
+
282
+ const agents = loadAgents(cwd, agentDir)
283
+ const agentNames = new Map<string, string>()
284
+ for (const agent of agents) {
285
+ if (agent.description.trim() === '') {
286
+ problems.push({
287
+ entry: agent.name,
288
+ file: agent.file,
289
+ rule: 'no-description',
290
+ detail: 'a profile with no description is listed as "no description", so the model cannot choose it'
291
+ })
292
+ }
293
+ const previous = agentNames.get(agent.name)
294
+ if (previous !== undefined) {
295
+ problems.push({
296
+ entry: agent.name,
297
+ file: agent.file,
298
+ rule: 'duplicate-name',
299
+ detail: `this profile replaces the earlier one named "${agent.name}" (${previous})`
300
+ })
301
+ } else {
302
+ agentNames.set(agent.name, agent.file)
303
+ }
304
+ }
305
+
306
+ return { skills, agents, problems, skillBlockChars: formatSkillsForPrompt(skills).length }
307
+ }
package/src/types.ts ADDED
@@ -0,0 +1,77 @@
1
+ export type Confidence = 'high' | 'medium' | 'low'
2
+
3
+ export type FindingKind =
4
+ | 'repeat-error'
5
+ | 'repeat-command'
6
+ | 'repeat-read'
7
+ | 'no-change'
8
+ | 'abandoned-tool'
9
+ | 'dead-skill'
10
+
11
+ export type LessonState = 'recorded' | 'installed' | 'failed' | 'rejected' | 'superseded'
12
+
13
+ export type InstallState = 'recorded' | 'installed' | 'rejected'
14
+
15
+ export interface Lesson {
16
+ id: string
17
+ at: number
18
+ cue: string
19
+ lesson: string
20
+ evidence: string
21
+ confidence: Confidence
22
+ triggers: string[]
23
+ kind: FindingKind
24
+ state: LessonState
25
+ confirm?: string
26
+ supersedes?: string
27
+ uses?: number
28
+ lastUsed?: number
29
+ }
30
+
31
+ export interface HarnessSettings {
32
+ check: string
33
+ /** Whether the loop starts a proposal on its own. The manual command works either way. */
34
+ auto: boolean
35
+ autoInstall: boolean
36
+ /** Whether a part may reach outside the project, into the skills or the context every project reads. */
37
+ global: boolean
38
+ maxInstallsPerDay: number
39
+ maxProposalsPerDay: number
40
+ maxTurnsPerProposal: number
41
+ /** Tokens of always-on prompt the active installs may add, all of them together. */
42
+ maxStandingTokens: number
43
+ /** `provider/model` for the writer, so a hard session does not pay its model for a short part. */
44
+ writerModel: string
45
+ }
46
+
47
+ export interface InstalledFile {
48
+ to: string
49
+ existed: boolean
50
+ before: string
51
+ after?: string
52
+ afterHash?: string
53
+ block?: string
54
+ mode: 'replace' | 'append'
55
+ }
56
+
57
+ export interface Install {
58
+ id: string
59
+ at: number
60
+ finding: string
61
+ dir: string
62
+ files: InstalledFile[]
63
+ check: string
64
+ ok: boolean
65
+ reverted: boolean
66
+ lesson?: string
67
+ key?: string
68
+ /** Characters the part added to the always-on prompt, which is what it costs on every later request. */
69
+ cost?: number
70
+ confirm?: string
71
+ confirmOk?: boolean
72
+ confirmOutput?: string
73
+ replay?: string
74
+ replayOk?: boolean
75
+ replayOutput?: string
76
+ note?: string
77
+ }
package/src/writer.ts ADDED
@@ -0,0 +1,134 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ export interface Completion {
5
+ text: string
6
+ stop: string
7
+ input: number
8
+ output: number
9
+ cost: number
10
+ }
11
+
12
+ export type Complete = (systemPrompt: string, prompt: string) => Promise<Completion>
13
+
14
+ export interface WriteOutcome {
15
+ ok: boolean
16
+ message: string
17
+ files: string[]
18
+ turns: number
19
+ input: number
20
+ output: number
21
+ cost: number
22
+ }
23
+
24
+ export interface PartFile {
25
+ path: string
26
+ content: string
27
+ }
28
+
29
+ const FILE_BLOCK = /<file path="([^"]+)">\r?\n([\s\S]*?)<\/file>/g
30
+ const MAX_PART_CHARS = 40_000
31
+ const MAX_FILE_CHARS = 20_000
32
+ const ATTEMPTS = 2
33
+
34
+ const REPLY = [
35
+ 'Reply with one block per file, and nothing outside them:',
36
+ '',
37
+ '<file path="RULE.md">',
38
+ '## Heading',
39
+ 'the rule',
40
+ '</file>',
41
+ '',
42
+ 'A path is relative to the proposal directory: RULE.md, CONFIRM.md, <name>/SKILL.md,',
43
+ '<name>.md for a profile, <name>/scripts/<file> for a helper, and the same under global/ for',
44
+ 'every project. Reply KEEP_BASE alone when the evidence does not argue for a change.'
45
+ ].join('\n')
46
+
47
+ function nameFile(name: string): boolean {
48
+ return /^[a-z0-9][a-z0-9._-]*$/.test(name)
49
+ }
50
+
51
+ /** The paths a part may write, so a reply cannot reach outside its proposal directory. */
52
+ export function proposalPath(raw: string): string | null {
53
+ const path = raw.trim().replaceAll('\\', '/')
54
+ if (path === '' || path.startsWith('/') || path.includes('..') || path.includes(' ')) return null
55
+ const parts = path.split('/').filter((part) => part !== '')
56
+ const tail = parts[0] === 'global' ? parts.slice(1) : parts
57
+ if (tail.length === 1) {
58
+ const single = tail[0] ?? ''
59
+ if (single === 'RULE.md' || single === 'CONFIRM.md') return path
60
+ return single.endsWith('.md') && nameFile(single.slice(0, -3)) ? path : null
61
+ }
62
+ if (tail.length === 2 && (tail[1] === 'SKILL.md' || tail[1] === 'RULE.md')) {
63
+ return nameFile(tail[0] ?? '') ? path : null
64
+ }
65
+ if (tail.length === 3 && tail[1] === 'scripts') {
66
+ return nameFile(tail[0] ?? '') && nameFile(tail[2] ?? '') ? path : null
67
+ }
68
+ return null
69
+ }
70
+
71
+ export function parsePart(reply: string): PartFile[] {
72
+ const files: PartFile[] = []
73
+ let total = 0
74
+ for (const match of reply.matchAll(FILE_BLOCK)) {
75
+ const path = proposalPath(match[1] ?? '')
76
+ const body = (match[2] ?? '').trimEnd()
77
+ if (path === null || body.trim() === '' || body.length > MAX_FILE_CHARS) continue
78
+ total += body.length
79
+ if (total > MAX_PART_CHARS) return []
80
+ if (files.some((file) => file.path === path)) continue
81
+ files.push({ path, content: `${body}\n` })
82
+ }
83
+ return files
84
+ }
85
+
86
+ export function keptBase(reply: string): boolean {
87
+ return /^\s*KEEP_BASE\s*[.!]?\s*$/m.test(reply)
88
+ }
89
+
90
+ /**
91
+ * One completion writes the part, because a proposal that waits for a person to read a brief
92
+ * is a proposal that never lands. A reply is parsed into files, never executed.
93
+ */
94
+ export async function writePart(dir: string, brief: string, complete: Complete): Promise<WriteOutcome> {
95
+ const system = `${brief}\n\n# HOW TO REPLY\n\n${REPLY}`
96
+ let problem = ''
97
+ let input = 0
98
+ let output = 0
99
+ let cost = 0
100
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
101
+ const prompt =
102
+ problem === ''
103
+ ? 'Write the part now.'
104
+ : `The last reply was rejected: ${problem}. Reply again with the file blocks.`
105
+ const reply = await complete(system, prompt)
106
+ input += reply.input
107
+ output += reply.output
108
+ cost += reply.cost
109
+ if (keptBase(reply.text)) {
110
+ return { ok: false, message: 'the writer kept the base', files: [], turns: attempt, input, output, cost }
111
+ }
112
+ const files = parsePart(reply.text)
113
+ if (files.length === 0) {
114
+ writeFileSync(join(dir, 'REPLY.txt'), `[${reply.stop}]\n${reply.text}`, 'utf8')
115
+ problem = 'the reply held no <file> block with an allowed path'
116
+ continue
117
+ }
118
+ for (const file of files) {
119
+ const to = join(dir, file.path)
120
+ mkdirSync(dirname(to), { recursive: true })
121
+ writeFileSync(to, file.content, 'utf8')
122
+ }
123
+ return {
124
+ ok: true,
125
+ message: `wrote ${files.map((file) => file.path).join(', ')}`,
126
+ files: files.map((file) => file.path),
127
+ turns: attempt,
128
+ input,
129
+ output,
130
+ cost
131
+ }
132
+ }
133
+ return { ok: false, message: problem, files: [], turns: ATTEMPTS, input, output, cost }
134
+ }