@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/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
@@ -0,0 +1,7 @@
1
+ import { renameSync, writeFileSync } from 'node:fs'
2
+
3
+ export function writeFileAtomic(path: string, data: string): void {
4
+ const temporary = `${path}.${process.pid}.tmp`
5
+ writeFileSync(temporary, data, 'utf8')
6
+ renameSync(temporary, path)
7
+ }