@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/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 +73 -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 +297 -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 +268 -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/apply.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
rmdirSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
type Dirent
|
|
11
|
+
} from 'node:fs'
|
|
12
|
+
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
13
|
+
|
|
14
|
+
import { readSettings } from './store.js'
|
|
15
|
+
import type { InstalledFile } from './types.js'
|
|
16
|
+
|
|
17
|
+
export interface AppliedFile extends InstalledFile {
|
|
18
|
+
after: string
|
|
19
|
+
block: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface InstallTarget {
|
|
23
|
+
to: string
|
|
24
|
+
mode: 'replace' | 'append'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface OwnedFile {
|
|
28
|
+
after?: string
|
|
29
|
+
blocks: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface InstallContext {
|
|
33
|
+
cwd: string
|
|
34
|
+
agentDir: string
|
|
35
|
+
homeSkills: string
|
|
36
|
+
live: Map<string, string>
|
|
37
|
+
owned: Map<string, OwnedFile>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Proposal {
|
|
41
|
+
skills: { name: string; files: string[] }[]
|
|
42
|
+
profiles: { name: string; file: string }[]
|
|
43
|
+
rule: string | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const RESERVED = new Set(['RULE.md', 'BRIEF.md', 'CONFIRM.md', 'REPLY.txt'])
|
|
47
|
+
const GLOBAL = 'global'
|
|
48
|
+
const CONTEXT_NAMES = ['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD', 'CLAUDE.md', 'CLAUDE.MD']
|
|
49
|
+
|
|
50
|
+
function contextFile(dir: string): string {
|
|
51
|
+
for (const name of CONTEXT_NAMES) {
|
|
52
|
+
const path = join(dir, name)
|
|
53
|
+
if (existsSync(path)) return path
|
|
54
|
+
}
|
|
55
|
+
return join(dir, 'AGENTS.md')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function firstHeading(text: string): string | null {
|
|
59
|
+
for (const line of text.split('\n')) {
|
|
60
|
+
const match = /^(#+)\s+\S/.exec(line)
|
|
61
|
+
if (match?.[1] !== undefined) return line.trim()
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A rule that is revised replaces the rule it revises, because a second copy
|
|
68
|
+
* of the same heading is the same rule said twice, and the context file pays
|
|
69
|
+
* for both.
|
|
70
|
+
*/
|
|
71
|
+
export function appendRule(before: string, content: string): string {
|
|
72
|
+
const heading = firstHeading(content)
|
|
73
|
+
if (heading === null) return `${before.trimEnd()}\n\n${content.trim()}\n`
|
|
74
|
+
const lines = before.split('\n')
|
|
75
|
+
const start = lines.findIndex((line) => line.trim() === heading)
|
|
76
|
+
if (start === -1) return `${before.trimEnd()}\n\n${content.trim()}\n`
|
|
77
|
+
const level = heading.match(/^(#+)/)?.[1]?.length ?? 2
|
|
78
|
+
let end = lines.length
|
|
79
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
80
|
+
const match = /^(#+)\s/.exec(lines[index] ?? '')
|
|
81
|
+
if (match?.[1] !== undefined && match[1].length <= level) {
|
|
82
|
+
end = index
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...lines.slice(0, start), ...content.trim().split('\n'), '', ...lines.slice(end)]
|
|
87
|
+
.join('\n')
|
|
88
|
+
.trimEnd()
|
|
89
|
+
.concat('\n')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function filesUnder(dir: string): string[] {
|
|
93
|
+
const out: string[] = []
|
|
94
|
+
let entries: Dirent[]
|
|
95
|
+
try {
|
|
96
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
97
|
+
} catch {
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) {
|
|
101
|
+
const full = join(dir, entry.name)
|
|
102
|
+
if (entry.isDirectory()) out.push(...filesUnder(full))
|
|
103
|
+
else if (entry.isFile()) out.push(full)
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function readProposal(dir: string): Proposal {
|
|
109
|
+
const skills: { name: string; files: string[] }[] = []
|
|
110
|
+
const profiles: { name: string; file: string }[] = []
|
|
111
|
+
let rule: string | null = null
|
|
112
|
+
for (const file of filesUnder(dir)) {
|
|
113
|
+
const parts = relative(dir, file).split('/')
|
|
114
|
+
const root = parts[0] ?? ''
|
|
115
|
+
if (parts.length === 1 && RESERVED.has(root)) {
|
|
116
|
+
if (root === 'RULE.md') rule = file
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (parts.length === 1 && root.endsWith('.md')) {
|
|
120
|
+
profiles.push({ name: basename(file, '.md'), file })
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
const name = parts[0] === GLOBAL ? (parts[1] ?? '') : root
|
|
124
|
+
if (name === '') continue
|
|
125
|
+
const found = skills.find((skill) => skill.name === name)
|
|
126
|
+
if (found) found.files.push(file)
|
|
127
|
+
else skills.push({ name, files: [file] })
|
|
128
|
+
}
|
|
129
|
+
return { skills, profiles, rule }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function liveTarget(file: string, dir: string, context: InstallContext): InstallTarget | null {
|
|
133
|
+
const parts = relative(dir, file).split('/')
|
|
134
|
+
const root = parts[0] ?? ''
|
|
135
|
+
const global = root === GLOBAL
|
|
136
|
+
const name = global ? (parts[1] ?? '') : root
|
|
137
|
+
if (parts.length === 1 && root === 'RULE.md') {
|
|
138
|
+
return { to: contextFile(context.cwd), mode: 'append' }
|
|
139
|
+
}
|
|
140
|
+
if (global && name === 'RULE.md') {
|
|
141
|
+
return { to: contextFile(context.agentDir), mode: 'append' }
|
|
142
|
+
}
|
|
143
|
+
if (RESERVED.has(root)) return null
|
|
144
|
+
if (parts.length === 1 && root.endsWith('.md')) {
|
|
145
|
+
return { to: join(context.cwd, '.pi', 'agents', root), mode: 'replace' }
|
|
146
|
+
}
|
|
147
|
+
if (name === '') return null
|
|
148
|
+
const livePath = context.live.get(name)
|
|
149
|
+
if (livePath !== undefined) return { to: join(dirname(livePath), ...parts.slice(global ? 2 : 1)), mode: 'replace' }
|
|
150
|
+
if (global) return { to: join(context.homeSkills, ...parts.slice(1)), mode: 'replace' }
|
|
151
|
+
return { to: join(context.cwd, '.agents', 'skills', ...parts), mode: 'replace' }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The loop writes into files a person also writes in. It replaces a file it wrote itself, and
|
|
156
|
+
* it refuses the ones it did not write, because a rule the loop did not author is not the
|
|
157
|
+
* loop's to overwrite.
|
|
158
|
+
*/
|
|
159
|
+
function refusal(
|
|
160
|
+
target: InstallTarget,
|
|
161
|
+
existed: boolean,
|
|
162
|
+
before: string,
|
|
163
|
+
block: string,
|
|
164
|
+
context: InstallContext
|
|
165
|
+
): string | null {
|
|
166
|
+
if (!existed) return null
|
|
167
|
+
const claim = context.owned.get(target.to)
|
|
168
|
+
if (target.mode === 'replace') {
|
|
169
|
+
if (claim === undefined) return 'the loop did not write it'
|
|
170
|
+
if (claim.after !== undefined && claim.after !== before) return 'it changed since the loop wrote it'
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
const heading = firstHeading(block)
|
|
174
|
+
if (heading === null || !before.includes(heading)) return null
|
|
175
|
+
const mine = claim?.blocks.some((entry) => firstHeading(entry) === heading && before.includes(entry)) ?? false
|
|
176
|
+
return mine ? null : `the rule ${heading} was not written by the loop`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function applyProposal(dir: string, context: InstallContext) {
|
|
180
|
+
const applied: AppliedFile[] = []
|
|
181
|
+
const refused: string[] = []
|
|
182
|
+
const settings = readSettings(context.cwd)
|
|
183
|
+
const project = resolve(context.cwd)
|
|
184
|
+
for (const file of filesUnder(dir)) {
|
|
185
|
+
const target = liveTarget(file, dir, context)
|
|
186
|
+
if (target === null) continue
|
|
187
|
+
if (!settings.global && !resolve(target.to).startsWith(`${project}/`)) {
|
|
188
|
+
refused.push(
|
|
189
|
+
`${target.to}: the part reaches outside the project, set global in .pi/harness.json to allow it`
|
|
190
|
+
)
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
const existed = existsSync(target.to)
|
|
194
|
+
const before = existed ? readFileSync(target.to, 'utf8') : ''
|
|
195
|
+
const content = readFileSync(file, 'utf8')
|
|
196
|
+
const block = target.mode === 'append' ? content.trim() : ''
|
|
197
|
+
const reason = refusal(target, existed, before, block, context)
|
|
198
|
+
if (reason !== null) {
|
|
199
|
+
refused.push(`${target.to}: ${reason}`)
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
const next =
|
|
203
|
+
target.mode === 'append'
|
|
204
|
+
? before.trim() === ''
|
|
205
|
+
? `${content.trim()}\n`
|
|
206
|
+
: appendRule(before, content)
|
|
207
|
+
: content
|
|
208
|
+
mkdirSync(dirname(target.to), { recursive: true })
|
|
209
|
+
writeFileSync(target.to, next, 'utf8')
|
|
210
|
+
applied.push({ to: target.to, existed, before, after: next, afterHash: hashOf(next), block, mode: target.mode })
|
|
211
|
+
}
|
|
212
|
+
return { applied, refused }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function hashOf(text: string): string {
|
|
216
|
+
return createHash('sha256').update(text).digest('hex')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function revertApplied(applied: InstalledFile[], roots: string[] = [], force = false) {
|
|
220
|
+
const restored: string[] = []
|
|
221
|
+
const refused: string[] = []
|
|
222
|
+
for (const file of applied.toReversed()) {
|
|
223
|
+
if (!force && file.afterHash !== undefined && existsSync(file.to)) {
|
|
224
|
+
if (hashOf(readFileSync(file.to, 'utf8')) !== file.afterHash) {
|
|
225
|
+
refused.push(file.to)
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (file.existed) {
|
|
230
|
+
mkdirSync(dirname(file.to), { recursive: true })
|
|
231
|
+
writeFileSync(file.to, file.before, 'utf8')
|
|
232
|
+
} else {
|
|
233
|
+
rmSync(file.to, { force: true })
|
|
234
|
+
removeEmptyParents(file.to, roots)
|
|
235
|
+
}
|
|
236
|
+
restored.push(file.to)
|
|
237
|
+
}
|
|
238
|
+
return { restored, refused }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The directories an install created go with it, so a reverted part leaves nothing behind. */
|
|
242
|
+
function removeEmptyParents(path: string, roots: string[]): void {
|
|
243
|
+
for (const root of roots) {
|
|
244
|
+
if (!path.startsWith(root)) continue
|
|
245
|
+
let dir = dirname(path)
|
|
246
|
+
while (dir.length > root.length) {
|
|
247
|
+
try {
|
|
248
|
+
if (readdirSync(dir).length > 0) break
|
|
249
|
+
rmdirSync(dir)
|
|
250
|
+
} catch {
|
|
251
|
+
break
|
|
252
|
+
}
|
|
253
|
+
dir = dirname(dir)
|
|
254
|
+
}
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
}
|
package/src/atomic.ts
ADDED