pi-patcher 1.0.0
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 +21 -0
- package/README.md +135 -0
- package/dist/cli.js +1090 -0
- package/package.json +62 -0
- package/patches/bootstrap-hook/PATCH.md +26 -0
- package/prompts/heal.md +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bassim Shahidy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# pi-patcher
|
|
2
|
+
|
|
3
|
+
Self-healing patches for [pi](https://github.com/earendil-works/pi-coding-agent).
|
|
4
|
+
|
|
5
|
+
Want to modify `pi` but your changes require a patch? Use `pi-patcher` to keep source patches applied across every `pi update`. When patching fails due to updates to target files, `pi-patcher` uses `pi` to self-heal: the AI updates the patch file for the new code and applies it.
|
|
6
|
+
|
|
7
|
+
## The idea
|
|
8
|
+
|
|
9
|
+
Every time pi updates, your local patches break. The old story is: re-derive the patch, re-apply, repeat forever.
|
|
10
|
+
|
|
11
|
+
pi-patcher flips that. Each patch is a tiny `oldText → newText` spec plus a plain-English `intent.md`. On every `pi update`:
|
|
12
|
+
|
|
13
|
+
1. If the spec still applies cleanly, it's re-applied. Done.
|
|
14
|
+
2. If it drifted, pi-patcher hands the intent, the old spec, and the new target file to pi and asks it to find the equivalent edit.
|
|
15
|
+
3. The healed edit is diffed back into a fresh spec and saved. Next update, it just works.
|
|
16
|
+
|
|
17
|
+
Pi patches pi. The patch maintainer is the agent.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install -g pi-patcher
|
|
23
|
+
pi-patcher init
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`pi-patcher init` applies the bundled `bootstrap-hook` patch, wiring `pi-patcher reconcile` into the end of `pi update` so your patches are re-applied automatically on every future update. Every edit is reversible via `pi-patcher remove` or `pi-patcher uninstall`, and any failed apply or heal is rolled back automatically, leaving the target file untouched.
|
|
27
|
+
|
|
28
|
+
You write patches in `~/.pi/patches/`; pi-patcher's own bundled patches live separately under `~/.pi/pi-patcher/internal-patches/` and never touch your dir.
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pi-patcher init # one-time setup: install bundled patches + wire into `pi update`
|
|
34
|
+
pi-patcher reconcile # apply pending patches; heal drifted ones
|
|
35
|
+
pi-patcher list # status + most recent heal session per patch
|
|
36
|
+
pi-patcher heal <id> # force-heal one patch (manual re-anchor)
|
|
37
|
+
pi-patcher remove <id> # revert a user patch's edits and delete its folder
|
|
38
|
+
pi-patcher uninstall # revert every patch and `npm uninstall -g pi-patcher`
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Running `pi-patcher` with no arguments prints this list.
|
|
42
|
+
|
|
43
|
+
`init` is what you run once after `npm install`. Re-running it after upgrades is safe.
|
|
44
|
+
|
|
45
|
+
`reconcile` is the workhorse for steady-state. It runs automatically after every `pi update` (via the hook `init` installs), and you can also run it by hand after authoring or editing a patch.
|
|
46
|
+
|
|
47
|
+
`remove` reverts a user patch, then deletes its folder. Bundled patches are managed by pi-patcher — use `uninstall` to remove those.
|
|
48
|
+
|
|
49
|
+
## Writing a patch
|
|
50
|
+
|
|
51
|
+
A patch is just a folder in `~/.pi/patches/` with a `PATCH.md` file:
|
|
52
|
+
|
|
53
|
+
````text
|
|
54
|
+
~/.pi/patches/<id>/
|
|
55
|
+
PATCH.md
|
|
56
|
+
````
|
|
57
|
+
|
|
58
|
+
`PATCH.md` is freeform markdown plus frontmatter and fenced edit blocks. The prose is fed to the AI when healing; mechanical apply only reads the frontmatter and edit fences:
|
|
59
|
+
|
|
60
|
+
````md
|
|
61
|
+
---
|
|
62
|
+
id: my-patch
|
|
63
|
+
summary: What this patch does
|
|
64
|
+
version: 0.1.0
|
|
65
|
+
lastUpdated: 2026-06-25
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
# My patch
|
|
69
|
+
|
|
70
|
+
Whatever prose helps explain the patch.
|
|
71
|
+
|
|
72
|
+
```patch file=dist/example.js
|
|
73
|
+
<<<<<<< SEARCH
|
|
74
|
+
old text
|
|
75
|
+
=======
|
|
76
|
+
new text
|
|
77
|
+
>>>>>>> REPLACE
|
|
78
|
+
```
|
|
79
|
+
````
|
|
80
|
+
|
|
81
|
+
Diff-style hunks are also supported:
|
|
82
|
+
|
|
83
|
+
````md
|
|
84
|
+
```diff file=dist/example.js
|
|
85
|
+
@@ optional hint @@
|
|
86
|
+
context line
|
|
87
|
+
+added line
|
|
88
|
+
```
|
|
89
|
+
````
|
|
90
|
+
|
|
91
|
+
Each derived `oldText` must appear exactly once in the target file. `newText` must be non-empty — deletion-only patches aren't supported in this version (replace the line with a comment instead).
|
|
92
|
+
|
|
93
|
+
A patch can contain multiple fenced edits, across one or more files. Mechanical apply, revert, and AI heal all iterate over every entry; heal runs one AI session per drifted replacement and rewrites only that entry in `PATCH.md`. Legacy `intent.md` + `spec.json` patch folders are still read for backwards compatibility.
|
|
94
|
+
|
|
95
|
+
JavaScript and JSON targets are syntax-checked after every edit. Any file type (markdown, plain text, etc.) is supported.
|
|
96
|
+
|
|
97
|
+
## How healing works
|
|
98
|
+
|
|
99
|
+
When the literal `oldText`/`newText` no longer matches, pi-patcher hands the work to pi:
|
|
100
|
+
|
|
101
|
+
- the target file is snapshotted first
|
|
102
|
+
- `pi -p --model ${PI_PATCHER_HEAL_MODEL:-openai-codex/gpt-5.5:low} --session-id <id>` is invoked with `prompts/heal.md` on stdin
|
|
103
|
+
- pi uses your normal session storage, so heal sessions remain discoverable anywhere `pi --session <id>` can find them
|
|
104
|
+
- pi-patcher shows a small progress spinner while the headless heal runs, but suppresses raw nested pi output by default
|
|
105
|
+
- the result is syntax-checked (JS / JSON only); the snapshot is restored on failure
|
|
106
|
+
- if pi decides the change is out of scope (feature removed, would require a redesign), it emits `===ABORT===` internally and pi-patcher prints the abort reason once
|
|
107
|
+
- on success, pi-patcher derives a fresh `oldText`/`newText` from the AI's edit and saves it back to `PATCH.md` (or `spec.json` for legacy patches)
|
|
108
|
+
|
|
109
|
+
Each heal prints the short replay command, e.g. `pi --session 019efc67-5d7d-75f1-b395-62e7ccc0eda0`.
|
|
110
|
+
|
|
111
|
+
## Uninstalling
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
pi-patcher uninstall
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Reverts every patch (yours and the bundled ones), deletes their folders, forgets state, then runs `npm uninstall -g pi-patcher`.
|
|
118
|
+
|
|
119
|
+
To *temporarily* disable pi-patcher without uninstalling, rename any patch directory to `_<id>`. The loader skips underscore-prefixed entries.
|
|
120
|
+
|
|
121
|
+
## Model configuration
|
|
122
|
+
|
|
123
|
+
`PI_PATCHER_HEAL_MODEL` overrides the default heal model (`openai-codex/gpt-5.5:low`).
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
bun test
|
|
129
|
+
bun run typecheck
|
|
130
|
+
bun run build
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import fs4 from "node:fs";
|
|
5
|
+
import path4 from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
// src/patches.ts
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
var ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
|
+
var PATCHES_DIR = path.join(os.homedir(), ".pi", "patches");
|
|
17
|
+
var HOME = path.join(os.homedir(), ".pi", "pi-patcher");
|
|
18
|
+
var STATE = path.join(HOME, "state.json");
|
|
19
|
+
var INTERNAL_PATCHES_DIR = path.join(HOME, "internal-patches");
|
|
20
|
+
var PROMPTS_DIR = path.join(ROOT, "prompts");
|
|
21
|
+
var BUNDLED_PATCHES = process.env.PI_PATCHER_BUNDLED_DIR ?? path.join(ROOT, "patches");
|
|
22
|
+
var HEAL_MODEL = process.env.PI_PATCHER_HEAL_MODEL ?? "openai-codex/gpt-5.5:low";
|
|
23
|
+
function ensureLayout() {
|
|
24
|
+
for (const dir of [PATCHES_DIR, INTERNAL_PATCHES_DIR])
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
function syncInternalPatches(baseShas, mode) {
|
|
28
|
+
ensureLayout();
|
|
29
|
+
const events = [];
|
|
30
|
+
if (!fs.existsSync(BUNDLED_PATCHES))
|
|
31
|
+
return events;
|
|
32
|
+
for (const entry of fs.readdirSync(BUNDLED_PATCHES, { withFileTypes: true })) {
|
|
33
|
+
if (!entry.isDirectory())
|
|
34
|
+
continue;
|
|
35
|
+
const id = entry.name;
|
|
36
|
+
const bundledDir = path.join(BUNDLED_PATCHES, id);
|
|
37
|
+
const bundledFile = patchDefinitionPath(bundledDir);
|
|
38
|
+
if (!bundledFile)
|
|
39
|
+
continue;
|
|
40
|
+
const bundledSha = sha(fs.readFileSync(bundledFile, "utf8"));
|
|
41
|
+
const workingDir = path.join(INTERNAL_PATCHES_DIR, id);
|
|
42
|
+
const workingFile = patchDefinitionPath(workingDir);
|
|
43
|
+
if (!workingFile) {
|
|
44
|
+
if (mode !== "seed")
|
|
45
|
+
continue;
|
|
46
|
+
replaceDir(bundledDir, workingDir);
|
|
47
|
+
baseShas[id] = bundledSha;
|
|
48
|
+
events.push({ id, action: "seeded" });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const workingSha = sha(fs.readFileSync(workingFile, "utf8"));
|
|
52
|
+
const baseSha = baseShas[id];
|
|
53
|
+
if (workingSha === baseSha && bundledSha !== baseSha) {
|
|
54
|
+
replaceDir(bundledDir, workingDir);
|
|
55
|
+
baseShas[id] = bundledSha;
|
|
56
|
+
events.push({ id, action: "refreshed" });
|
|
57
|
+
} else if (baseSha === undefined) {
|
|
58
|
+
baseShas[id] = workingSha;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return events;
|
|
62
|
+
}
|
|
63
|
+
function isInternalPatch(id) {
|
|
64
|
+
return patchDefinitionPath(path.join(INTERNAL_PATCHES_DIR, id)) !== undefined;
|
|
65
|
+
}
|
|
66
|
+
function findPiRoot() {
|
|
67
|
+
let piBin;
|
|
68
|
+
try {
|
|
69
|
+
piBin = execFileSync("which", ["pi"], { encoding: "utf8" }).trim();
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error("Could not find `pi` on PATH");
|
|
72
|
+
}
|
|
73
|
+
let current = fs.realpathSync(piBin);
|
|
74
|
+
if (fs.statSync(current).isFile())
|
|
75
|
+
current = path.dirname(current);
|
|
76
|
+
while (current !== path.dirname(current)) {
|
|
77
|
+
const pkg = path.join(current, "package.json");
|
|
78
|
+
if (fs.existsSync(pkg)) {
|
|
79
|
+
try {
|
|
80
|
+
const json = JSON.parse(fs.readFileSync(pkg, "utf8"));
|
|
81
|
+
if (json.name === "@earendil-works/pi-coding-agent")
|
|
82
|
+
return current;
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
current = path.dirname(current);
|
|
86
|
+
}
|
|
87
|
+
throw new Error("Could not resolve pi package root from `pi` on PATH");
|
|
88
|
+
}
|
|
89
|
+
function resolveTarget(piRoot, target) {
|
|
90
|
+
return path.isAbsolute(target) ? target : path.join(piRoot, target);
|
|
91
|
+
}
|
|
92
|
+
function allPatches() {
|
|
93
|
+
ensureLayout();
|
|
94
|
+
const internal = discoverPatchesIn(INTERNAL_PATCHES_DIR);
|
|
95
|
+
const user = discoverPatchesIn(PATCHES_DIR);
|
|
96
|
+
const seen = new Set(internal.map((p) => p.id));
|
|
97
|
+
return [...internal, ...user.filter((p) => !seen.has(p.id))].sort((a, b) => a.id.localeCompare(b.id));
|
|
98
|
+
}
|
|
99
|
+
function discoverPatchesIn(dir) {
|
|
100
|
+
if (!fs.existsSync(dir))
|
|
101
|
+
return [];
|
|
102
|
+
return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith("_") && patchDefinitionPath(path.join(dir, e.name)) !== undefined).map((e) => loadFromDir(path.join(dir, e.name)));
|
|
103
|
+
}
|
|
104
|
+
function loadPatch(id) {
|
|
105
|
+
ensureLayout();
|
|
106
|
+
for (const base of [INTERNAL_PATCHES_DIR, PATCHES_DIR]) {
|
|
107
|
+
const dir = path.join(base, id);
|
|
108
|
+
if (patchDefinitionPath(dir))
|
|
109
|
+
return loadFromDir(dir);
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`No patch named ${id}`);
|
|
112
|
+
}
|
|
113
|
+
function saveSpec(patch, spec) {
|
|
114
|
+
if (patch.source === "markdown") {
|
|
115
|
+
fs.writeFileSync(path.join(patch.dir, "PATCH.md"), renderPatchMd(patch, spec));
|
|
116
|
+
} else {
|
|
117
|
+
fs.writeFileSync(path.join(patch.dir, "spec.json"), `${JSON.stringify(spec, null, 2)}
|
|
118
|
+
`);
|
|
119
|
+
}
|
|
120
|
+
patch.spec = spec;
|
|
121
|
+
}
|
|
122
|
+
function removePatchDir(id) {
|
|
123
|
+
fs.rmSync(path.join(PATCHES_DIR, id), { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
function loadFromDir(dir) {
|
|
126
|
+
const id = path.basename(dir);
|
|
127
|
+
const patchMd = path.join(dir, "PATCH.md");
|
|
128
|
+
if (fs.existsSync(patchMd)) {
|
|
129
|
+
const patch = parsePatchMd(dir, fs.readFileSync(patchMd, "utf8"));
|
|
130
|
+
validateSpec(patch.id, patch.spec);
|
|
131
|
+
return patch;
|
|
132
|
+
}
|
|
133
|
+
const specPath = path.join(dir, "spec.json");
|
|
134
|
+
const intentPath = path.join(dir, "intent.md");
|
|
135
|
+
const spec = JSON.parse(fs.readFileSync(specPath, "utf8"));
|
|
136
|
+
validateSpec(id, spec);
|
|
137
|
+
return {
|
|
138
|
+
id,
|
|
139
|
+
dir,
|
|
140
|
+
intent: fs.existsSync(intentPath) ? fs.readFileSync(intentPath, "utf8") : "",
|
|
141
|
+
spec,
|
|
142
|
+
source: "json"
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function patchDefinitionPath(dir) {
|
|
146
|
+
const patchMd = path.join(dir, "PATCH.md");
|
|
147
|
+
if (fs.existsSync(patchMd))
|
|
148
|
+
return patchMd;
|
|
149
|
+
const specJson = path.join(dir, "spec.json");
|
|
150
|
+
return fs.existsSync(specJson) ? specJson : undefined;
|
|
151
|
+
}
|
|
152
|
+
function parsePatchMd(dir, markdown) {
|
|
153
|
+
const fallbackId = path.basename(dir);
|
|
154
|
+
const { attrs, body, bodyOffset } = parseFrontmatter(markdown);
|
|
155
|
+
const id = stringAttr(attrs.id) ?? fallbackId;
|
|
156
|
+
if (id !== fallbackId)
|
|
157
|
+
throw new Error(`${fallbackId}: PATCH.md id must match its directory name`);
|
|
158
|
+
const title = body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? id;
|
|
159
|
+
const parsed = parseMarkdownEdits(id, body, bodyOffset);
|
|
160
|
+
return {
|
|
161
|
+
id,
|
|
162
|
+
dir,
|
|
163
|
+
intent: markdown.trim(),
|
|
164
|
+
spec: { version: 1, files: parsed.files },
|
|
165
|
+
source: "markdown",
|
|
166
|
+
summary: stringAttr(attrs.summary),
|
|
167
|
+
title,
|
|
168
|
+
markdown,
|
|
169
|
+
markdownBlocks: parsed.blocks
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function parseFrontmatter(markdown) {
|
|
173
|
+
if (!markdown.startsWith(`---
|
|
174
|
+
`))
|
|
175
|
+
return { attrs: {}, body: markdown, bodyOffset: 0 };
|
|
176
|
+
const end = markdown.indexOf(`
|
|
177
|
+
---`, 4);
|
|
178
|
+
if (end === -1)
|
|
179
|
+
return { attrs: {}, body: markdown, bodyOffset: 0 };
|
|
180
|
+
const raw = markdown.slice(4, end).trim();
|
|
181
|
+
const attrs = {};
|
|
182
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
183
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
184
|
+
if (match)
|
|
185
|
+
attrs[match[1]] = match[2].replace(/^['"]|['"]$/g, "");
|
|
186
|
+
}
|
|
187
|
+
let bodyOffset = end + 5;
|
|
188
|
+
if (markdown.slice(bodyOffset).startsWith(`
|
|
189
|
+
`))
|
|
190
|
+
bodyOffset++;
|
|
191
|
+
return { attrs, body: markdown.slice(bodyOffset), bodyOffset };
|
|
192
|
+
}
|
|
193
|
+
function stringAttr(value) {
|
|
194
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
195
|
+
}
|
|
196
|
+
function parseMarkdownEdits(id, body, bodyOffset) {
|
|
197
|
+
const byTarget = new Map;
|
|
198
|
+
const targetOrder = [];
|
|
199
|
+
const blocks = [];
|
|
200
|
+
for (const fence of markdownFences(body)) {
|
|
201
|
+
const target = targetFromFenceInfo(fence.info);
|
|
202
|
+
if (!target)
|
|
203
|
+
continue;
|
|
204
|
+
const replacement = parseSearchReplaceBlock(fence.content) ?? parseDiffBlock(fence.content);
|
|
205
|
+
if (!replacement)
|
|
206
|
+
throw new Error(`${id}: ${target}: fence is neither a search/replace nor a hunk block`);
|
|
207
|
+
const anchorHint = noteBefore(body.slice(0, fence.start));
|
|
208
|
+
if (anchorHint)
|
|
209
|
+
replacement.anchorHint = anchorHint;
|
|
210
|
+
if (!byTarget.has(target))
|
|
211
|
+
targetOrder.push(target);
|
|
212
|
+
const replacements = byTarget.get(target) ?? [];
|
|
213
|
+
const fileIndex = targetOrder.indexOf(target);
|
|
214
|
+
const replacementIndex = replacements.length;
|
|
215
|
+
replacements.push(replacement);
|
|
216
|
+
byTarget.set(target, replacements);
|
|
217
|
+
blocks.push({
|
|
218
|
+
start: bodyOffset + fence.start,
|
|
219
|
+
end: bodyOffset + fence.end,
|
|
220
|
+
fileIndex,
|
|
221
|
+
replacementIndex,
|
|
222
|
+
target
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
files: targetOrder.map((target) => ({
|
|
227
|
+
target,
|
|
228
|
+
replacements: byTarget.get(target) ?? []
|
|
229
|
+
})),
|
|
230
|
+
blocks
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function markdownFences(markdown) {
|
|
234
|
+
const fences = [];
|
|
235
|
+
let pos = 0;
|
|
236
|
+
while (pos < markdown.length) {
|
|
237
|
+
const lineStart = pos;
|
|
238
|
+
const lineEnd = markdown.indexOf(`
|
|
239
|
+
`, lineStart);
|
|
240
|
+
const lineStop = lineEnd === -1 ? markdown.length : lineEnd;
|
|
241
|
+
const line = markdown.slice(lineStart, lineStop);
|
|
242
|
+
const open = line.match(/^\s{0,3}(`{3,})(.*)$/);
|
|
243
|
+
pos = lineEnd === -1 ? markdown.length : lineEnd + 1;
|
|
244
|
+
if (!open)
|
|
245
|
+
continue;
|
|
246
|
+
const tickCount = open[1].length;
|
|
247
|
+
const info = open[2].trim();
|
|
248
|
+
const contentStart = pos;
|
|
249
|
+
let closeStart = -1;
|
|
250
|
+
let closeEnd = -1;
|
|
251
|
+
while (pos < markdown.length) {
|
|
252
|
+
const candidateStart = pos;
|
|
253
|
+
const candidateLineEnd = markdown.indexOf(`
|
|
254
|
+
`, candidateStart);
|
|
255
|
+
const candidateStop = candidateLineEnd === -1 ? markdown.length : candidateLineEnd;
|
|
256
|
+
const candidate = markdown.slice(candidateStart, candidateStop);
|
|
257
|
+
pos = candidateLineEnd === -1 ? markdown.length : candidateLineEnd + 1;
|
|
258
|
+
if (new RegExp(`^\\s{0,3}\`{${tickCount},}\\s*$`).test(candidate)) {
|
|
259
|
+
closeStart = candidateStart;
|
|
260
|
+
closeEnd = candidateStop;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (closeStart === -1)
|
|
265
|
+
break;
|
|
266
|
+
let content = markdown.slice(contentStart, closeStart);
|
|
267
|
+
if (content.endsWith(`
|
|
268
|
+
`))
|
|
269
|
+
content = content.slice(0, -1);
|
|
270
|
+
fences.push({ start: lineStart, end: closeEnd, info, content });
|
|
271
|
+
}
|
|
272
|
+
return fences;
|
|
273
|
+
}
|
|
274
|
+
function targetFromFenceInfo(info) {
|
|
275
|
+
const match = info.match(/(?:^|\s)file=("[^"]+"|'[^']+'|\S+)/);
|
|
276
|
+
if (!match)
|
|
277
|
+
return;
|
|
278
|
+
return match[1].replace(/^['"]|['"]$/g, "").trim() || undefined;
|
|
279
|
+
}
|
|
280
|
+
function noteBefore(prefix) {
|
|
281
|
+
return prefix.match(/(?:^|\n)>\s*note:\s*([^\n]+)\n\s*$/i)?.[1]?.trim();
|
|
282
|
+
}
|
|
283
|
+
function parseSearchReplaceBlock(block) {
|
|
284
|
+
const match = block.match(/^<<<<<<< SEARCH\r?\n([\s\S]*?)^=======\r?\n([\s\S]*?)^>>>>>>> REPLACE\s*$/m);
|
|
285
|
+
return match ? { oldText: match[1], newText: match[2] } : null;
|
|
286
|
+
}
|
|
287
|
+
function parseDiffBlock(block) {
|
|
288
|
+
const lines = block.split(/\r?\n/);
|
|
289
|
+
const oldLines = [];
|
|
290
|
+
const newLines = [];
|
|
291
|
+
let changed = false;
|
|
292
|
+
for (const line of lines) {
|
|
293
|
+
if (/^@@.*@@$/.test(line))
|
|
294
|
+
continue;
|
|
295
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
296
|
+
newLines.push(line.slice(1));
|
|
297
|
+
changed = true;
|
|
298
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
299
|
+
oldLines.push(line.slice(1));
|
|
300
|
+
changed = true;
|
|
301
|
+
} else {
|
|
302
|
+
oldLines.push(line);
|
|
303
|
+
newLines.push(line);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!changed)
|
|
307
|
+
return null;
|
|
308
|
+
const oldText = oldLines.length ? `${oldLines.join(`
|
|
309
|
+
`)}
|
|
310
|
+
` : "";
|
|
311
|
+
const newText = newLines.length ? `${newLines.join(`
|
|
312
|
+
`)}
|
|
313
|
+
` : "";
|
|
314
|
+
return { oldText, newText };
|
|
315
|
+
}
|
|
316
|
+
function renderPatchMd(patch, spec) {
|
|
317
|
+
if (patch.markdown && patch.markdownBlocks?.length) {
|
|
318
|
+
let markdown = patch.markdown;
|
|
319
|
+
for (const block of [...patch.markdownBlocks].sort((a, b) => b.start - a.start)) {
|
|
320
|
+
const replacement = spec.files[block.fileIndex]?.replacements[block.replacementIndex];
|
|
321
|
+
if (!replacement)
|
|
322
|
+
continue;
|
|
323
|
+
markdown = `${markdown.slice(0, block.start)}${renderSearchReplaceFence(block.target, replacement)}${markdown.slice(block.end)}`;
|
|
324
|
+
}
|
|
325
|
+
return markdown.endsWith(`
|
|
326
|
+
`) ? markdown : `${markdown}
|
|
327
|
+
`;
|
|
328
|
+
}
|
|
329
|
+
const title = patch.title ?? patch.id;
|
|
330
|
+
const summary = patch.summary ?? title;
|
|
331
|
+
const out = [
|
|
332
|
+
"---",
|
|
333
|
+
`id: ${patch.id}`,
|
|
334
|
+
`summary: ${summary}`,
|
|
335
|
+
"---",
|
|
336
|
+
"",
|
|
337
|
+
`# ${title}`,
|
|
338
|
+
"",
|
|
339
|
+
patch.intent.trim(),
|
|
340
|
+
""
|
|
341
|
+
];
|
|
342
|
+
for (const file of spec.files) {
|
|
343
|
+
for (const replacement of file.replacements)
|
|
344
|
+
out.push(renderSearchReplaceFence(file.target, replacement), "");
|
|
345
|
+
}
|
|
346
|
+
return `${out.join(`
|
|
347
|
+
`).replace(/\n{3,}/g, `
|
|
348
|
+
|
|
349
|
+
`).trimEnd()}
|
|
350
|
+
`;
|
|
351
|
+
}
|
|
352
|
+
function renderSearchReplaceFence(target, replacement) {
|
|
353
|
+
return [
|
|
354
|
+
`\`\`\`patch file=${target}`,
|
|
355
|
+
"<<<<<<< SEARCH",
|
|
356
|
+
replacement.oldText.replace(/\n$/, ""),
|
|
357
|
+
"=======",
|
|
358
|
+
replacement.newText.replace(/\n$/, ""),
|
|
359
|
+
">>>>>>> REPLACE",
|
|
360
|
+
"```"
|
|
361
|
+
].join(`
|
|
362
|
+
`);
|
|
363
|
+
}
|
|
364
|
+
function validateSpec(id, spec) {
|
|
365
|
+
if (!Array.isArray(spec.files) || spec.files.length === 0)
|
|
366
|
+
throw new Error(`${id}: patch must define at least one edit`);
|
|
367
|
+
for (const file of spec.files) {
|
|
368
|
+
if (!file.target || !Array.isArray(file.replacements))
|
|
369
|
+
throw new Error(`${id}: each file needs a target and replacements`);
|
|
370
|
+
for (const r of file.replacements) {
|
|
371
|
+
if (typeof r.oldText !== "string" || typeof r.newText !== "string")
|
|
372
|
+
throw new Error(`${id}: replacements need oldText and newText strings`);
|
|
373
|
+
if (r.oldText === "")
|
|
374
|
+
throw new Error(`${id}: empty oldText is not supported`);
|
|
375
|
+
if (r.newText === "")
|
|
376
|
+
throw new Error(`${id}: empty newText (deletion patches) is not supported. ` + `Replace the line with a comment or no-op instead.`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function statusOf(patch, piRoot) {
|
|
381
|
+
const seen = new Set;
|
|
382
|
+
for (const file of patch.spec.files) {
|
|
383
|
+
const target = resolveTarget(piRoot, file.target);
|
|
384
|
+
if (!fs.existsSync(target)) {
|
|
385
|
+
seen.add("drift");
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const text = fs.readFileSync(target, "utf8");
|
|
389
|
+
for (const r of file.replacements)
|
|
390
|
+
seen.add(classify(r, text));
|
|
391
|
+
}
|
|
392
|
+
if (seen.has("drift"))
|
|
393
|
+
return "drift";
|
|
394
|
+
if (seen.has("pending"))
|
|
395
|
+
return "pending";
|
|
396
|
+
return "applied";
|
|
397
|
+
}
|
|
398
|
+
function classify(r, text) {
|
|
399
|
+
const newCount = count(text, r.newText);
|
|
400
|
+
if (newCount === 1)
|
|
401
|
+
return "applied";
|
|
402
|
+
if (newCount > 1)
|
|
403
|
+
return "drift";
|
|
404
|
+
return count(text, r.oldText) === 1 ? "pending" : "drift";
|
|
405
|
+
}
|
|
406
|
+
function applyEdits(patch, piRoot) {
|
|
407
|
+
return runEdits(patch, piRoot, false);
|
|
408
|
+
}
|
|
409
|
+
function revertEdits(patch, piRoot) {
|
|
410
|
+
return runEdits(patch, piRoot, true);
|
|
411
|
+
}
|
|
412
|
+
function runEdits(patch, piRoot, reverse) {
|
|
413
|
+
const originals = new Map;
|
|
414
|
+
let changed = false;
|
|
415
|
+
try {
|
|
416
|
+
for (const file of patch.spec.files) {
|
|
417
|
+
const target = resolveTarget(piRoot, file.target);
|
|
418
|
+
const original = fs.readFileSync(target, "utf8");
|
|
419
|
+
let text = original;
|
|
420
|
+
for (const r of file.replacements) {
|
|
421
|
+
const status = classify(r, text);
|
|
422
|
+
const done = reverse ? "pending" : "applied";
|
|
423
|
+
const ready = reverse ? "applied" : "pending";
|
|
424
|
+
if (status === done)
|
|
425
|
+
continue;
|
|
426
|
+
if (status === ready) {
|
|
427
|
+
text = reverse ? text.replace(r.newText, r.oldText) : text.replace(r.oldText, r.newText);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
throw new Error(`${patch.id}: ${file.target}: cannot ${reverse ? "revert" : "apply"} a replacement (expected exactly one occurrence of ${reverse ? "newText" : "oldText"})`);
|
|
431
|
+
}
|
|
432
|
+
if (text === original)
|
|
433
|
+
continue;
|
|
434
|
+
if (!originals.has(target))
|
|
435
|
+
originals.set(target, original);
|
|
436
|
+
fs.writeFileSync(target, text);
|
|
437
|
+
validateTarget(target);
|
|
438
|
+
changed = true;
|
|
439
|
+
}
|
|
440
|
+
} catch (error) {
|
|
441
|
+
for (const [target, original] of originals)
|
|
442
|
+
fs.writeFileSync(target, original);
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
return changed;
|
|
446
|
+
}
|
|
447
|
+
function validateTarget(target) {
|
|
448
|
+
const ext = path.extname(target).toLowerCase();
|
|
449
|
+
if (ext === ".js" || ext === ".mjs" || ext === ".cjs") {
|
|
450
|
+
execFileSync(process.execPath, ["--check", target], { stdio: "pipe" });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (ext === ".json") {
|
|
454
|
+
JSON.parse(fs.readFileSync(target, "utf8"));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
function count(haystack, needle) {
|
|
458
|
+
if (!needle)
|
|
459
|
+
return 0;
|
|
460
|
+
let n = 0;
|
|
461
|
+
let i = 0;
|
|
462
|
+
while ((i = haystack.indexOf(needle, i)) !== -1) {
|
|
463
|
+
n++;
|
|
464
|
+
i += needle.length;
|
|
465
|
+
}
|
|
466
|
+
return n;
|
|
467
|
+
}
|
|
468
|
+
function sha(text) {
|
|
469
|
+
return crypto.createHash("sha256").update(text).digest("hex");
|
|
470
|
+
}
|
|
471
|
+
function derivePatch(before, after) {
|
|
472
|
+
if (before === after)
|
|
473
|
+
return null;
|
|
474
|
+
let start = 0;
|
|
475
|
+
while (start < before.length && start < after.length && before[start] === after[start])
|
|
476
|
+
start++;
|
|
477
|
+
let endBefore = before.length - 1;
|
|
478
|
+
let endAfter = after.length - 1;
|
|
479
|
+
while (endBefore >= start && endAfter >= start && before[endBefore] === after[endAfter]) {
|
|
480
|
+
endBefore--;
|
|
481
|
+
endAfter--;
|
|
482
|
+
}
|
|
483
|
+
let a = start;
|
|
484
|
+
while (a > 0 && before[a - 1] !== `
|
|
485
|
+
`)
|
|
486
|
+
a--;
|
|
487
|
+
let b = endBefore + 1;
|
|
488
|
+
while (b < before.length && before[b] !== `
|
|
489
|
+
`)
|
|
490
|
+
b++;
|
|
491
|
+
if (b < before.length)
|
|
492
|
+
b++;
|
|
493
|
+
let c = start;
|
|
494
|
+
while (c > 0 && after[c - 1] !== `
|
|
495
|
+
`)
|
|
496
|
+
c--;
|
|
497
|
+
let d = endAfter + 1;
|
|
498
|
+
while (d < after.length && after[d] !== `
|
|
499
|
+
`)
|
|
500
|
+
d++;
|
|
501
|
+
if (d < after.length)
|
|
502
|
+
d++;
|
|
503
|
+
let oldText = before.slice(a, b);
|
|
504
|
+
let newText = after.slice(c, d);
|
|
505
|
+
while ((count(before, oldText) !== 1 || count(after, newText) !== 1) && (a > 0 || b < before.length || c > 0 || d < after.length)) {
|
|
506
|
+
if (a > 0) {
|
|
507
|
+
a--;
|
|
508
|
+
while (a > 0 && before[a - 1] !== `
|
|
509
|
+
`)
|
|
510
|
+
a--;
|
|
511
|
+
}
|
|
512
|
+
if (c > 0) {
|
|
513
|
+
c--;
|
|
514
|
+
while (c > 0 && after[c - 1] !== `
|
|
515
|
+
`)
|
|
516
|
+
c--;
|
|
517
|
+
}
|
|
518
|
+
if (b < before.length) {
|
|
519
|
+
while (b < before.length && before[b] !== `
|
|
520
|
+
`)
|
|
521
|
+
b++;
|
|
522
|
+
if (b < before.length)
|
|
523
|
+
b++;
|
|
524
|
+
}
|
|
525
|
+
if (d < after.length) {
|
|
526
|
+
while (d < after.length && after[d] !== `
|
|
527
|
+
`)
|
|
528
|
+
d++;
|
|
529
|
+
if (d < after.length)
|
|
530
|
+
d++;
|
|
531
|
+
}
|
|
532
|
+
oldText = before.slice(a, b);
|
|
533
|
+
newText = after.slice(c, d);
|
|
534
|
+
}
|
|
535
|
+
return { oldText, newText };
|
|
536
|
+
}
|
|
537
|
+
function replaceDir(src, dst) {
|
|
538
|
+
fs.rmSync(dst, { recursive: true, force: true });
|
|
539
|
+
copyDir(src, dst, true);
|
|
540
|
+
}
|
|
541
|
+
function copyDir(src, dst, overwrite = false) {
|
|
542
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
543
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
544
|
+
const s = path.join(src, entry.name);
|
|
545
|
+
const d = path.join(dst, entry.name);
|
|
546
|
+
if (entry.isDirectory())
|
|
547
|
+
copyDir(s, d, overwrite);
|
|
548
|
+
else if (overwrite || !fs.existsSync(d))
|
|
549
|
+
fs.copyFileSync(s, d);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// src/state.ts
|
|
554
|
+
import fs2 from "node:fs";
|
|
555
|
+
import path2 from "node:path";
|
|
556
|
+
function loadState() {
|
|
557
|
+
try {
|
|
558
|
+
return JSON.parse(fs2.readFileSync(STATE, "utf8"));
|
|
559
|
+
} catch {
|
|
560
|
+
return { patches: {} };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
function saveState(state) {
|
|
564
|
+
fs2.mkdirSync(path2.dirname(STATE), { recursive: true });
|
|
565
|
+
fs2.writeFileSync(STATE, `${JSON.stringify(state, null, 2)}
|
|
566
|
+
`);
|
|
567
|
+
}
|
|
568
|
+
function recordApplied(state, id) {
|
|
569
|
+
const entry = entryFor(state, id);
|
|
570
|
+
entry.lastAppliedAt = new Date().toISOString();
|
|
571
|
+
delete entry.lastError;
|
|
572
|
+
}
|
|
573
|
+
function recordReverted(state, id) {
|
|
574
|
+
const entry = entryFor(state, id);
|
|
575
|
+
entry.lastRevertedAt = new Date().toISOString();
|
|
576
|
+
delete entry.lastError;
|
|
577
|
+
}
|
|
578
|
+
function recordHealed(state, id) {
|
|
579
|
+
const entry = entryFor(state, id);
|
|
580
|
+
entry.lastHealedAt = new Date().toISOString();
|
|
581
|
+
delete entry.lastError;
|
|
582
|
+
}
|
|
583
|
+
function recordError(state, id, message) {
|
|
584
|
+
entryFor(state, id).lastError = message;
|
|
585
|
+
}
|
|
586
|
+
function rememberSession(state, id, sessionId) {
|
|
587
|
+
const entry = entryFor(state, id);
|
|
588
|
+
entry.lastSessions = [sessionId, ...entry.lastSessions ?? []].slice(0, 10);
|
|
589
|
+
}
|
|
590
|
+
function forgetPatch(state, id) {
|
|
591
|
+
delete state.patches[id];
|
|
592
|
+
}
|
|
593
|
+
function patchError(state, id) {
|
|
594
|
+
return state.patches[id]?.lastError;
|
|
595
|
+
}
|
|
596
|
+
function lastSession(state, id) {
|
|
597
|
+
return state.patches[id]?.lastSessions?.[0];
|
|
598
|
+
}
|
|
599
|
+
function entryFor(state, id) {
|
|
600
|
+
state.patches[id] ??= {};
|
|
601
|
+
return state.patches[id];
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/heal.ts
|
|
605
|
+
import fs3 from "node:fs";
|
|
606
|
+
import path3 from "node:path";
|
|
607
|
+
import crypto2 from "node:crypto";
|
|
608
|
+
import { spawn } from "node:child_process";
|
|
609
|
+
|
|
610
|
+
// src/ui.ts
|
|
611
|
+
var useColor = Boolean(process.stdout.isTTY && !process.env.NO_COLOR);
|
|
612
|
+
function color(code, text) {
|
|
613
|
+
return useColor ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
614
|
+
}
|
|
615
|
+
var ui = {
|
|
616
|
+
dim: (text) => color(2, text),
|
|
617
|
+
green: (text) => color(32, text),
|
|
618
|
+
yellow: (text) => color(33, text),
|
|
619
|
+
red: (text) => color(31, text),
|
|
620
|
+
cyan: (text) => color(36, text)
|
|
621
|
+
};
|
|
622
|
+
function logHeader() {
|
|
623
|
+
console.log("pi-patcher");
|
|
624
|
+
}
|
|
625
|
+
function logApplied(id) {
|
|
626
|
+
console.log(` ${ui.green("✓")} ${id} applied`);
|
|
627
|
+
}
|
|
628
|
+
function logSuccess(message) {
|
|
629
|
+
console.log(` ${ui.green("✓")} ${message}`);
|
|
630
|
+
}
|
|
631
|
+
function logWarn(message) {
|
|
632
|
+
console.log(` ${ui.yellow("⚠")} ${message}`);
|
|
633
|
+
}
|
|
634
|
+
function logFailure(message) {
|
|
635
|
+
console.log(` ${ui.red("✗")} ${message}`);
|
|
636
|
+
}
|
|
637
|
+
function logInfo(message) {
|
|
638
|
+
console.log(` ${ui.dim("•")} ${message}`);
|
|
639
|
+
}
|
|
640
|
+
function logDetail(message) {
|
|
641
|
+
console.log(` ${message}`);
|
|
642
|
+
}
|
|
643
|
+
function logLabeledDetail(label, text) {
|
|
644
|
+
const firstPrefix = ` ${label}: `;
|
|
645
|
+
const nextPrefix = " ".repeat(firstPrefix.length);
|
|
646
|
+
const columns = process.stdout.columns ?? 100;
|
|
647
|
+
const lines = wrapWords(text, Math.max(20, columns - firstPrefix.length));
|
|
648
|
+
if (lines.length === 0) {
|
|
649
|
+
console.log(firstPrefix.trimEnd());
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
console.log(`${firstPrefix}${lines[0]}`);
|
|
653
|
+
for (const line of wrapWords(lines.slice(1).join(" "), Math.max(20, columns - nextPrefix.length)))
|
|
654
|
+
console.log(`${nextPrefix}${line}`);
|
|
655
|
+
}
|
|
656
|
+
function wrapWords(text, width) {
|
|
657
|
+
const words = text.trim().split(/\s+/).filter(Boolean);
|
|
658
|
+
const lines = [];
|
|
659
|
+
let line = "";
|
|
660
|
+
for (const word of words) {
|
|
661
|
+
if (!line)
|
|
662
|
+
line = word;
|
|
663
|
+
else if (line.length + 1 + word.length <= width)
|
|
664
|
+
line += ` ${word}`;
|
|
665
|
+
else {
|
|
666
|
+
lines.push(line);
|
|
667
|
+
line = word;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (line)
|
|
671
|
+
lines.push(line);
|
|
672
|
+
return lines;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/heal.ts
|
|
676
|
+
async function heal(patch, piRoot, state) {
|
|
677
|
+
let ok = true;
|
|
678
|
+
for (let fi = 0;fi < patch.spec.files.length; fi++) {
|
|
679
|
+
const file = patch.spec.files[fi];
|
|
680
|
+
for (let ri = 0;ri < file.replacements.length; ri++) {
|
|
681
|
+
const target = resolveTarget(piRoot, file.target);
|
|
682
|
+
if (!fs3.existsSync(target)) {
|
|
683
|
+
ok = false;
|
|
684
|
+
recordError(state, patch.id, `${file.target}: target file missing`);
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
const text = fs3.readFileSync(target, "utf8");
|
|
688
|
+
const replacement = patch.spec.files[fi].replacements[ri];
|
|
689
|
+
if (classify(replacement, text) !== "drift")
|
|
690
|
+
continue;
|
|
691
|
+
if (!await healOne(patch, fi, ri, piRoot, state))
|
|
692
|
+
ok = false;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return ok;
|
|
696
|
+
}
|
|
697
|
+
async function healOne(patch, fi, ri, piRoot, state) {
|
|
698
|
+
const file = patch.spec.files[fi];
|
|
699
|
+
const replacement = file.replacements[ri];
|
|
700
|
+
const target = resolveTarget(piRoot, file.target);
|
|
701
|
+
const before = fs3.readFileSync(target, "utf8");
|
|
702
|
+
const label = healLabel(patch, fi, ri);
|
|
703
|
+
logWarn(`${label} drifted`);
|
|
704
|
+
const sessionId = crypto2.randomUUID();
|
|
705
|
+
const stopSpinner = startSpinner(`healing ${label} with ${HEAL_MODEL}`);
|
|
706
|
+
const child = await runPiHeal(["-p", "--model", HEAL_MODEL, "--session-id", sessionId], renderPrompt(patch, file.target, target, replacement));
|
|
707
|
+
stopSpinner();
|
|
708
|
+
const output = child.output;
|
|
709
|
+
const fail = (reason, inspect = true) => {
|
|
710
|
+
fs3.writeFileSync(target, before);
|
|
711
|
+
recordError(state, patch.id, reason);
|
|
712
|
+
if (inspect)
|
|
713
|
+
rememberSession(state, patch.id, sessionId);
|
|
714
|
+
logFailure(`${label} not healed`);
|
|
715
|
+
logLabeledDetail("Reason", reason);
|
|
716
|
+
if (inspect)
|
|
717
|
+
logDetail(`Inspect: ${ui.cyan(`pi --session ${sessionId}`)}`);
|
|
718
|
+
return false;
|
|
719
|
+
};
|
|
720
|
+
const abort = output.match(/===ABORT===([\s\S]*?)===END===/);
|
|
721
|
+
if (abort)
|
|
722
|
+
return fail(abort[1].trim());
|
|
723
|
+
if (child.error)
|
|
724
|
+
return fail(`failed to start pi: ${child.error.message}`, false);
|
|
725
|
+
if (child.status !== 0)
|
|
726
|
+
return fail(child.signal ? `pi heal session terminated by signal ${child.signal}` : `pi heal session exited with status ${child.status ?? "unknown"}`);
|
|
727
|
+
try {
|
|
728
|
+
validateTarget(target);
|
|
729
|
+
} catch (error) {
|
|
730
|
+
return fail(`validation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
731
|
+
}
|
|
732
|
+
const after = fs3.readFileSync(target, "utf8");
|
|
733
|
+
const derived = derivePatch(before, after);
|
|
734
|
+
if (!derived || count(before, derived.oldText) !== 1 || count(after, derived.newText) !== 1)
|
|
735
|
+
return fail("edits did not produce a derivable patch");
|
|
736
|
+
rewriteReplacement(patch, fi, ri, derived);
|
|
737
|
+
rememberSession(state, patch.id, sessionId);
|
|
738
|
+
recordHealed(state, patch.id);
|
|
739
|
+
logSuccess(`${label} healed`);
|
|
740
|
+
logDetail(`Inspect: ${ui.cyan(`pi --session ${sessionId}`)}`);
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
var MAX_CAPTURED_OUTPUT = 1024 * 1024 * 20;
|
|
744
|
+
function runPiHeal(args, input) {
|
|
745
|
+
return new Promise((resolve) => {
|
|
746
|
+
const child = spawn("pi", args, {
|
|
747
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
748
|
+
});
|
|
749
|
+
const chunks = [];
|
|
750
|
+
let captured = 0;
|
|
751
|
+
let spawnError;
|
|
752
|
+
const capture = (chunk) => {
|
|
753
|
+
if (captured >= MAX_CAPTURED_OUTPUT)
|
|
754
|
+
return;
|
|
755
|
+
const remaining = MAX_CAPTURED_OUTPUT - captured;
|
|
756
|
+
const kept = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
|
|
757
|
+
chunks.push(kept);
|
|
758
|
+
captured += kept.length;
|
|
759
|
+
};
|
|
760
|
+
child.stdout.on("data", capture);
|
|
761
|
+
child.stderr.on("data", capture);
|
|
762
|
+
child.on("error", (error) => {
|
|
763
|
+
spawnError = error;
|
|
764
|
+
});
|
|
765
|
+
child.on("close", (status, signal) => {
|
|
766
|
+
resolve({
|
|
767
|
+
output: Buffer.concat(chunks).toString("utf8"),
|
|
768
|
+
error: spawnError,
|
|
769
|
+
status,
|
|
770
|
+
signal
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
child.stdin.on("error", () => {});
|
|
774
|
+
child.stdin.end(input);
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
function startSpinner(message) {
|
|
778
|
+
if (!process.stdout.isTTY) {
|
|
779
|
+
console.log(` ${ui.dim("…")} ${message}…`);
|
|
780
|
+
return () => {
|
|
781
|
+
return;
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
const frames = ["◐", "◓", "◑", "◒"].map(ui.yellow);
|
|
785
|
+
const start = Date.now();
|
|
786
|
+
let i = 0;
|
|
787
|
+
const render = () => {
|
|
788
|
+
const elapsed = Math.floor((Date.now() - start) / 1000);
|
|
789
|
+
process.stdout.write(`\r ${frames[i++ % frames.length]} ${message} (${elapsed}s)`);
|
|
790
|
+
};
|
|
791
|
+
render();
|
|
792
|
+
const timer = setInterval(render, 120);
|
|
793
|
+
return () => {
|
|
794
|
+
clearInterval(timer);
|
|
795
|
+
process.stdout.write("\r\x1B[K");
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
function healLabel(patch, fi, ri) {
|
|
799
|
+
const file = patch.spec.files[fi];
|
|
800
|
+
if (patch.spec.files.length === 1 && file.replacements.length === 1)
|
|
801
|
+
return patch.id;
|
|
802
|
+
return `${patch.id} (${file.target}#${ri})`;
|
|
803
|
+
}
|
|
804
|
+
function renderPrompt(patch, target, targetPath, replacement) {
|
|
805
|
+
const template = fs3.readFileSync(path3.join(PROMPTS_DIR, "heal.md"), "utf8");
|
|
806
|
+
return template.replaceAll("{{patch_id}}", patch.id).replaceAll("{{target_path}}", targetPath).replaceAll("{{patch_markdown}}", patchMarkdownContext(patch, target, replacement)).replaceAll("{{validation_hint}}", validationHint(targetPath));
|
|
807
|
+
}
|
|
808
|
+
function patchMarkdownContext(patch, target, replacement) {
|
|
809
|
+
if (patch.source === "markdown") {
|
|
810
|
+
const patchMd = path3.join(patch.dir, "PATCH.md");
|
|
811
|
+
if (fs3.existsSync(patchMd))
|
|
812
|
+
return fs3.readFileSync(patchMd, "utf8").trim();
|
|
813
|
+
if (patch.markdown)
|
|
814
|
+
return patch.markdown.trim();
|
|
815
|
+
}
|
|
816
|
+
return [
|
|
817
|
+
`# ${patch.id}`,
|
|
818
|
+
"",
|
|
819
|
+
patch.intent.trim() || "Legacy spec.json patch.",
|
|
820
|
+
"",
|
|
821
|
+
`\`\`\`patch file=${target}`,
|
|
822
|
+
"<<<<<<< SEARCH",
|
|
823
|
+
replacement.oldText.replace(/\n$/, ""),
|
|
824
|
+
"=======",
|
|
825
|
+
replacement.newText.replace(/\n$/, ""),
|
|
826
|
+
">>>>>>> REPLACE",
|
|
827
|
+
"```"
|
|
828
|
+
].join(`
|
|
829
|
+
`);
|
|
830
|
+
}
|
|
831
|
+
function validationHint(targetPath) {
|
|
832
|
+
const ext = path3.extname(targetPath).toLowerCase();
|
|
833
|
+
if (ext === ".js" || ext === ".mjs" || ext === ".cjs")
|
|
834
|
+
return `The target file must pass \`node --check ${targetPath}\` after edits.`;
|
|
835
|
+
if (ext === ".json")
|
|
836
|
+
return `The target file must remain valid JSON after edits.`;
|
|
837
|
+
return `The target file must remain syntactically valid for its language; no automatic check is run for this file type.`;
|
|
838
|
+
}
|
|
839
|
+
function rewriteReplacement(patch, fi, ri, derived) {
|
|
840
|
+
const newSpec = structuredClone(patch.spec);
|
|
841
|
+
const file = newSpec.files[fi];
|
|
842
|
+
const old = file.replacements[ri];
|
|
843
|
+
file.replacements[ri] = { ...old, ...derived };
|
|
844
|
+
saveSpec(patch, newSpec);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// src/cli.ts
|
|
848
|
+
main(process.argv.slice(2)).then((code) => {
|
|
849
|
+
process.exitCode = code;
|
|
850
|
+
}, (error) => {
|
|
851
|
+
console.error(`pi-patcher: ${msg(error)}`);
|
|
852
|
+
process.exitCode = 1;
|
|
853
|
+
});
|
|
854
|
+
async function main(argv) {
|
|
855
|
+
if (argv.length === 0) {
|
|
856
|
+
console.log(helpText());
|
|
857
|
+
return 0;
|
|
858
|
+
}
|
|
859
|
+
const [cmd, ...rest] = argv;
|
|
860
|
+
switch (cmd) {
|
|
861
|
+
case "init":
|
|
862
|
+
return await cmdInit();
|
|
863
|
+
case "reconcile":
|
|
864
|
+
return await cmdReconcile();
|
|
865
|
+
case "list":
|
|
866
|
+
return await cmdList();
|
|
867
|
+
case "heal":
|
|
868
|
+
return await cmdHeal(requireArg(rest[0], "heal <id>"));
|
|
869
|
+
case "remove":
|
|
870
|
+
return await cmdRemove(requireArg(rest[0], "remove <id>"));
|
|
871
|
+
case "uninstall":
|
|
872
|
+
return await cmdUninstall();
|
|
873
|
+
case "help":
|
|
874
|
+
case "--help":
|
|
875
|
+
case "-h":
|
|
876
|
+
console.log(helpText());
|
|
877
|
+
return 0;
|
|
878
|
+
case "version":
|
|
879
|
+
case "--version":
|
|
880
|
+
case "-v":
|
|
881
|
+
console.log(version());
|
|
882
|
+
return 0;
|
|
883
|
+
default:
|
|
884
|
+
console.error(`pi-patcher: unknown command: ${cmd}`);
|
|
885
|
+
console.error(`Run \`pi-patcher --help\` for usage.`);
|
|
886
|
+
return 1;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
function helpText() {
|
|
890
|
+
return `pi-patcher ${version()}
|
|
891
|
+
Self-healing patches for pi.
|
|
892
|
+
|
|
893
|
+
Usage:
|
|
894
|
+
pi-patcher init Install bundled patches and wire into \`pi update\`
|
|
895
|
+
pi-patcher reconcile Apply pending patches; heal drifted ones
|
|
896
|
+
pi-patcher list Show status and most recent heal session
|
|
897
|
+
pi-patcher heal <id> Re-anchor a drifted patch via AI
|
|
898
|
+
pi-patcher remove <id> Revert edits and delete the patch folder
|
|
899
|
+
pi-patcher uninstall Revert every patch and uninstall the npm package
|
|
900
|
+
|
|
901
|
+
pi-patcher --help, -h Show this help
|
|
902
|
+
pi-patcher --version, -v Show version
|
|
903
|
+
|
|
904
|
+
First-run flow: \`npm install -g pi-patcher && pi-patcher init\`.
|
|
905
|
+
Docs: https://github.com/AVGVSTVS96/pi-patcher`;
|
|
906
|
+
}
|
|
907
|
+
function version() {
|
|
908
|
+
try {
|
|
909
|
+
const pkg = JSON.parse(fs4.readFileSync(path4.join(ROOT, "package.json"), "utf8"));
|
|
910
|
+
return pkg.version ?? "unknown";
|
|
911
|
+
} catch {
|
|
912
|
+
return "unknown";
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
async function cmdReconcile() {
|
|
916
|
+
return await withSession(async (piRoot, state) => {
|
|
917
|
+
logHeader();
|
|
918
|
+
state.internalBaseShas ??= {};
|
|
919
|
+
logSyncEvents(syncInternalPatches(state.internalBaseShas, "refresh-only"));
|
|
920
|
+
const summary = await applyAll(piRoot, state);
|
|
921
|
+
logSummary(summary);
|
|
922
|
+
return summary.failed ? 1 : 0;
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
class ReportedPatchFailure extends Error {
|
|
927
|
+
}
|
|
928
|
+
async function applyAll(piRoot, state) {
|
|
929
|
+
const summary = { applied: 0, healed: 0, current: 0, failed: 0 };
|
|
930
|
+
for (const patch of allPatches()) {
|
|
931
|
+
try {
|
|
932
|
+
summary[await reconcileOne(patch, piRoot, state)]++;
|
|
933
|
+
} catch (error) {
|
|
934
|
+
summary.failed++;
|
|
935
|
+
const message = msg(error);
|
|
936
|
+
recordError(state, patch.id, message);
|
|
937
|
+
if (!(error instanceof ReportedPatchFailure))
|
|
938
|
+
logFailure(`${patch.id} failed: ${message}`);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return summary;
|
|
942
|
+
}
|
|
943
|
+
async function reconcileOne(patch, piRoot, state) {
|
|
944
|
+
switch (statusOf(patch, piRoot)) {
|
|
945
|
+
case "applied":
|
|
946
|
+
delete state.patches[patch.id]?.lastError;
|
|
947
|
+
logSuccess(`${patch.id} already current`);
|
|
948
|
+
return "current";
|
|
949
|
+
case "pending": {
|
|
950
|
+
if (applyEdits(patch, piRoot))
|
|
951
|
+
recordApplied(state, patch.id);
|
|
952
|
+
logApplied(patch.id);
|
|
953
|
+
return "applied";
|
|
954
|
+
}
|
|
955
|
+
case "drift":
|
|
956
|
+
if (!await heal(patch, piRoot, state))
|
|
957
|
+
throw new ReportedPatchFailure(patchError(state, patch.id) ?? "heal failed");
|
|
958
|
+
return "healed";
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
function logSyncEvents(events) {
|
|
962
|
+
for (const e of events)
|
|
963
|
+
logInfo(e.action === "seeded" ? `seeded internal patch ${e.id}` : `refreshed internal patch ${e.id} (shipped update)`);
|
|
964
|
+
}
|
|
965
|
+
function logSummary(s) {
|
|
966
|
+
const parts = [];
|
|
967
|
+
if (s.applied)
|
|
968
|
+
parts.push(`${s.applied} applied`);
|
|
969
|
+
if (s.healed)
|
|
970
|
+
parts.push(`${s.healed} healed`);
|
|
971
|
+
if (s.current)
|
|
972
|
+
parts.push(`${s.current} already current`);
|
|
973
|
+
if (s.failed)
|
|
974
|
+
parts.push(`${s.failed} failed`);
|
|
975
|
+
console.log(` ${parts.length ? parts.join(", ") : "no patches to apply"}`);
|
|
976
|
+
}
|
|
977
|
+
async function cmdList() {
|
|
978
|
+
return await withSession((piRoot, state) => {
|
|
979
|
+
logHeader();
|
|
980
|
+
let found = false;
|
|
981
|
+
for (const patch of allPatches()) {
|
|
982
|
+
found = true;
|
|
983
|
+
const status = statusOf(patch, piRoot);
|
|
984
|
+
if (status === "applied")
|
|
985
|
+
logSuccess(`${patch.id} applied`);
|
|
986
|
+
else if (status === "pending")
|
|
987
|
+
logInfo(`${patch.id} pending`);
|
|
988
|
+
else
|
|
989
|
+
logWarn(`${patch.id} drifted`);
|
|
990
|
+
const session = lastSession(state, patch.id);
|
|
991
|
+
if (session)
|
|
992
|
+
logLabeledDetail("Last heal", `pi --session ${sessionArg(session)}`);
|
|
993
|
+
}
|
|
994
|
+
if (!found)
|
|
995
|
+
console.log(" no patches found");
|
|
996
|
+
return 0;
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
function sessionArg(session) {
|
|
1000
|
+
const base = path4.basename(session).replace(/\.jsonl$/, "");
|
|
1001
|
+
return base.includes("_") ? base.slice(base.lastIndexOf("_") + 1) : base;
|
|
1002
|
+
}
|
|
1003
|
+
async function cmdHeal(id) {
|
|
1004
|
+
return await withSession(async (piRoot, state) => await heal(loadPatch(id), piRoot, state) ? 0 : 1);
|
|
1005
|
+
}
|
|
1006
|
+
async function cmdRemove(id) {
|
|
1007
|
+
return await withSession((piRoot, state) => {
|
|
1008
|
+
if (isInternalPatch(id))
|
|
1009
|
+
throw new Error(`${id} is managed by pi-patcher; use \`pi-patcher uninstall\` to remove it`);
|
|
1010
|
+
const patch = loadPatch(id);
|
|
1011
|
+
const status = statusOf(patch, piRoot);
|
|
1012
|
+
if (status === "drift")
|
|
1013
|
+
throw new Error(`${id} has drifted; the original edit isn't where we left it. ` + `Edit the target file by hand to remove the patch's effect, then re-run \`pi-patcher remove ${id}\`. ` + `If you just want the folder gone, \`rm -rf ~/.pi/patches/${id}\`.`);
|
|
1014
|
+
if (status === "applied") {
|
|
1015
|
+
if (revertEdits(patch, piRoot))
|
|
1016
|
+
recordReverted(state, patch.id);
|
|
1017
|
+
console.log(`pi-patcher: reverted ${id}`);
|
|
1018
|
+
}
|
|
1019
|
+
removePatchDir(id);
|
|
1020
|
+
forgetPatch(state, id);
|
|
1021
|
+
console.log(`pi-patcher: removed ${id}`);
|
|
1022
|
+
return 0;
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
async function cmdInit() {
|
|
1026
|
+
return await withSession(async (piRoot, state) => {
|
|
1027
|
+
logHeader();
|
|
1028
|
+
state.internalBaseShas ??= {};
|
|
1029
|
+
logSyncEvents(syncInternalPatches(state.internalBaseShas, "seed"));
|
|
1030
|
+
const summary = await applyAll(piRoot, state);
|
|
1031
|
+
logSummary(summary);
|
|
1032
|
+
if (!summary.failed) {
|
|
1033
|
+
console.log("");
|
|
1034
|
+
console.log("pi-patcher is wired into pi update.");
|
|
1035
|
+
console.log("To remove cleanly later, run: pi-patcher uninstall");
|
|
1036
|
+
}
|
|
1037
|
+
return summary.failed ? 1 : 0;
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
async function cmdUninstall() {
|
|
1041
|
+
const cleanupExit = await withSession((piRoot, state) => {
|
|
1042
|
+
let issues = 0;
|
|
1043
|
+
for (const patch of allPatches()) {
|
|
1044
|
+
try {
|
|
1045
|
+
const status = statusOf(patch, piRoot);
|
|
1046
|
+
if (status === "applied") {
|
|
1047
|
+
revertEdits(patch, piRoot);
|
|
1048
|
+
logSuccess(`${patch.id} reverted`);
|
|
1049
|
+
} else if (status === "drift") {
|
|
1050
|
+
issues++;
|
|
1051
|
+
logWarn(`${patch.id} skipped (drifted; file already modified upstream, left as-is)`);
|
|
1052
|
+
}
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
issues++;
|
|
1055
|
+
logFailure(`${patch.id} revert failed: ${msg(error)} (continuing)`);
|
|
1056
|
+
}
|
|
1057
|
+
fs4.rmSync(patch.dir, { recursive: true, force: true });
|
|
1058
|
+
delete state.patches[patch.id];
|
|
1059
|
+
}
|
|
1060
|
+
delete state.internalBaseShas;
|
|
1061
|
+
console.log(issues ? `pi-patcher: removed ${issues === 1 ? "1 patch with caveats" : `patches (${issues} with caveats)`}; running \`npm uninstall -g pi-patcher\`…` : `pi-patcher: all patches cleaned up; running \`npm uninstall -g pi-patcher\`…`);
|
|
1062
|
+
return 0;
|
|
1063
|
+
});
|
|
1064
|
+
if (cleanupExit !== 0)
|
|
1065
|
+
return cleanupExit;
|
|
1066
|
+
const result = spawnSync("npm", ["uninstall", "-g", "pi-patcher"], {
|
|
1067
|
+
stdio: "inherit"
|
|
1068
|
+
});
|
|
1069
|
+
return result.status ?? 1;
|
|
1070
|
+
}
|
|
1071
|
+
async function withSession(fn) {
|
|
1072
|
+
ensureLayout();
|
|
1073
|
+
const piRoot = findPiRoot();
|
|
1074
|
+
const state = loadState();
|
|
1075
|
+
state.piRoot = piRoot;
|
|
1076
|
+
state.lastRunAt = new Date().toISOString();
|
|
1077
|
+
try {
|
|
1078
|
+
return await fn(piRoot, state);
|
|
1079
|
+
} finally {
|
|
1080
|
+
saveState(state);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
function requireArg(value, hint) {
|
|
1084
|
+
if (value == null)
|
|
1085
|
+
throw new Error(`Usage: pi-patcher ${hint}`);
|
|
1086
|
+
return value;
|
|
1087
|
+
}
|
|
1088
|
+
function msg(error) {
|
|
1089
|
+
return error instanceof Error ? error.message : String(error);
|
|
1090
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-patcher",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Update-resilient self-healing patches for pi installs.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Bassim Shahidy",
|
|
7
|
+
"email": "bassim@shahidy.com",
|
|
8
|
+
"url": "https://bassim.sh"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": {
|
|
12
|
+
"pi-patcher": "dist/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "bun run src/cli.ts",
|
|
16
|
+
"changeset": "changeset",
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"test": "bun run build && bun test",
|
|
19
|
+
"build": "tsc --noEmit && bun build src/cli.ts --target=node --outfile=dist/cli.js && chmod +x dist/cli.js",
|
|
20
|
+
"ci:publish": "bun run build && changeset publish",
|
|
21
|
+
"prepack": "bun run build"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"prompts",
|
|
26
|
+
"patches",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"keywords": [
|
|
31
|
+
"pi",
|
|
32
|
+
"patch",
|
|
33
|
+
"patcher",
|
|
34
|
+
"self-healing",
|
|
35
|
+
"ai",
|
|
36
|
+
"agent",
|
|
37
|
+
"cli"
|
|
38
|
+
],
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/AVGVSTVS96/pi-patcher.git"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/AVGVSTVS96/pi-patcher/issues"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/AVGVSTVS96/pi-patcher#readme",
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public",
|
|
49
|
+
"provenance": true
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=18"
|
|
53
|
+
},
|
|
54
|
+
"packageManager": "bun@1.3.9",
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@changesets/cli": "^2.31.0",
|
|
57
|
+
"@types/node": "^22.0.0",
|
|
58
|
+
"changesets-changelog-clean": "^1.4.0",
|
|
59
|
+
"typescript": "^5.5.0"
|
|
60
|
+
},
|
|
61
|
+
"license": "MIT"
|
|
62
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: bootstrap-hook
|
|
3
|
+
summary: Re-run `pi-patcher reconcile` after pi finishes updating itself.
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
lastUpdated: 2026-06-25
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Bootstrap hook
|
|
9
|
+
|
|
10
|
+
## Intent
|
|
11
|
+
|
|
12
|
+
After pi's built-in `pi update` command finishes updating pi itself, automatically run `pi-patcher reconcile` so all installed source patches are re-applied to the freshly updated pi install.
|
|
13
|
+
|
|
14
|
+
This must be a tiny, safe hook in `dist/package-manager-cli.js` immediately after the existing `console.log(chalk.green(`Updated ${APP_NAME}`));` line in the self-update path. The inserted hook must be valid in pi's compiled ESM output and must not rely on CommonJS-only globals like `require`. It should not change update behavior if `pi-patcher` is missing or fails to launch.
|
|
15
|
+
|
|
16
|
+
## Patch Edits
|
|
17
|
+
|
|
18
|
+
### dist/package-manager-cli.js
|
|
19
|
+
|
|
20
|
+
> note: end of the pi self-update branch, immediately after Updated ${APP_NAME}
|
|
21
|
+
|
|
22
|
+
```diff file=dist/package-manager-cli.js
|
|
23
|
+
@@ pi self update success branch @@
|
|
24
|
+
console.log(chalk.green(`Updated ${APP_NAME}`));
|
|
25
|
+
+ try { (await import("node:child_process")).spawnSync("pi-patcher", ["reconcile"], { stdio: "inherit" }); } catch {}
|
|
26
|
+
```
|
package/prompts/heal.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
You are healing a pi-patcher PATCH.md after upstream changed the target file.
|
|
2
|
+
Use the PATCH.md as the source of truth; re-anchor its intended edit with the smallest equivalent change, not a redesign.
|
|
3
|
+
|
|
4
|
+
Patch id: {{patch_id}}
|
|
5
|
+
Target file: {{target_path}}
|
|
6
|
+
|
|
7
|
+
## PATCH.md
|
|
8
|
+
|
|
9
|
+
````markdown
|
|
10
|
+
{{patch_markdown}}
|
|
11
|
+
````
|
|
12
|
+
|
|
13
|
+
## Task
|
|
14
|
+
The PATCH.md edit for this target no longer applies cleanly. Use the prose and fenced edit as context, then edit the current target file to restore that same intent.
|
|
15
|
+
|
|
16
|
+
Before editing, output exactly one planning block:
|
|
17
|
+
|
|
18
|
+
===PLAN===
|
|
19
|
+
A concise 2-4 sentence summary of what changed upstream and what you will edit.
|
|
20
|
+
===END===
|
|
21
|
+
|
|
22
|
+
Then perform the edit and exit.
|
|
23
|
+
|
|
24
|
+
## Abort instead of redesigning
|
|
25
|
+
If the PATCH.md intent no longer maps cleanly to this target file, do not edit. Output:
|
|
26
|
+
|
|
27
|
+
===ABORT===
|
|
28
|
+
A concise reason you are not proceeding.
|
|
29
|
+
===END===
|
|
30
|
+
|
|
31
|
+
Then exit.
|
|
32
|
+
|
|
33
|
+
## Hard constraints
|
|
34
|
+
- Edit only this file: {{target_path}}
|
|
35
|
+
- Keep the edit as small as possible.
|
|
36
|
+
- Do not modify package metadata, settings, logs, backups, or patch specs.
|
|
37
|
+
- {{validation_hint}}
|