killeros 2.0.3 → 2.0.4
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/CHANGELOG.md +17 -0
- package/Killeros.ts +4 -2
- package/README.md +9 -7
- package/killeros/commands.ts +2 -8
- package/killeros/concise.ts +12 -8
- package/killeros/goals.ts +0 -2
- package/killeros/hooks.ts +49 -17
- package/killeros/init-evidence.ts +240 -0
- package/killeros/init-target.ts +289 -0
- package/killeros/init.ts +139 -356
- package/killeros/runtime.ts +18 -6
- package/killeros/shell-ui.ts +17 -141
- package/package.json +1 -1
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
const TARGET_LIMIT = 128 * 1024;
|
|
8
|
+
const REQUIRED_GUIDANCE_HEADINGS = [
|
|
9
|
+
"# AGENTS.md",
|
|
10
|
+
"## 1. Think Before Coding",
|
|
11
|
+
"## 2. Simplicity First",
|
|
12
|
+
"## 3. Surgical Changes",
|
|
13
|
+
"## 4. Goal-Driven Execution",
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export interface InitTargetBaseline {
|
|
17
|
+
exists: boolean;
|
|
18
|
+
content?: string;
|
|
19
|
+
digest?: string;
|
|
20
|
+
dev?: number;
|
|
21
|
+
ino?: number;
|
|
22
|
+
mode?: number;
|
|
23
|
+
nlink?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface InitInstallOperations {
|
|
27
|
+
renameFile?: typeof fs.rename;
|
|
28
|
+
linkFile?: typeof fs.link;
|
|
29
|
+
unlinkFile?: typeof fs.unlink;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function digest(content: Buffer): string {
|
|
33
|
+
return createHash("sha256").update(content).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function captureExistingTarget(targetPath: string): Promise<InitTargetBaseline> {
|
|
37
|
+
const pathStat = await fs.lstat(targetPath);
|
|
38
|
+
if (pathStat.isSymbolicLink() || !pathStat.isFile() || pathStat.nlink !== 1) {
|
|
39
|
+
throw new Error("/init requires root AGENTS.md to be absent or a regular, non-linked file");
|
|
40
|
+
}
|
|
41
|
+
if (pathStat.size > TARGET_LIMIT) throw new Error(`/init root AGENTS.md exceeds ${TARGET_LIMIT} bytes`);
|
|
42
|
+
const handle = await fs.open(targetPath, constants.O_RDONLY);
|
|
43
|
+
try {
|
|
44
|
+
const openedStat = await handle.stat();
|
|
45
|
+
if (!openedStat.isFile() || openedStat.nlink !== 1
|
|
46
|
+
|| openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
|
|
47
|
+
throw new Error("/init target changed while /init was inspecting it");
|
|
48
|
+
}
|
|
49
|
+
if (openedStat.size > TARGET_LIMIT) throw new Error(`/init root AGENTS.md exceeds ${TARGET_LIMIT} bytes`);
|
|
50
|
+
const bytes = await handle.readFile();
|
|
51
|
+
if (bytes.length > TARGET_LIMIT) throw new Error(`/init root AGENTS.md exceeds ${TARGET_LIMIT} bytes`);
|
|
52
|
+
return {
|
|
53
|
+
exists: true,
|
|
54
|
+
content: bytes.toString("utf8"),
|
|
55
|
+
digest: digest(bytes),
|
|
56
|
+
dev: openedStat.dev,
|
|
57
|
+
ino: openedStat.ino,
|
|
58
|
+
mode: openedStat.mode,
|
|
59
|
+
nlink: openedStat.nlink,
|
|
60
|
+
};
|
|
61
|
+
} finally {
|
|
62
|
+
await handle.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function captureInitTargetBaseline(targetPath: string): Promise<InitTargetBaseline> {
|
|
67
|
+
try {
|
|
68
|
+
return await captureExistingTarget(targetPath);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false };
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sameBaseline(left: InitTargetBaseline, right: InitTargetBaseline): boolean {
|
|
76
|
+
if (left.exists !== right.exists) return false;
|
|
77
|
+
if (!left.exists) return true;
|
|
78
|
+
return left.digest === right.digest
|
|
79
|
+
&& left.dev === right.dev
|
|
80
|
+
&& left.ino === right.ino
|
|
81
|
+
&& left.mode === right.mode
|
|
82
|
+
&& left.nlink === right.nlink;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function installedCandidateMatches(targetPath: string, candidate: InitTargetBaseline): Promise<boolean> {
|
|
86
|
+
try {
|
|
87
|
+
const pathStat = await fs.lstat(targetPath);
|
|
88
|
+
if (pathStat.isSymbolicLink() || !pathStat.isFile() || pathStat.nlink !== 2) return false;
|
|
89
|
+
const handle = await fs.open(targetPath, constants.O_RDONLY);
|
|
90
|
+
try {
|
|
91
|
+
const openedStat = await handle.stat();
|
|
92
|
+
if (!openedStat.isFile() || openedStat.nlink !== 2
|
|
93
|
+
|| openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino
|
|
94
|
+
|| openedStat.size > TARGET_LIMIT) return false;
|
|
95
|
+
const bytes = await handle.readFile();
|
|
96
|
+
return bytes.length <= TARGET_LIMIT
|
|
97
|
+
&& openedStat.dev === candidate.dev
|
|
98
|
+
&& openedStat.ino === candidate.ino
|
|
99
|
+
&& openedStat.mode === candidate.mode
|
|
100
|
+
&& digest(bytes) === candidate.digest;
|
|
101
|
+
} finally {
|
|
102
|
+
await handle.close();
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function removeCandidateName(candidatePath: string, unlinkFile: typeof fs.unlink): Promise<void> {
|
|
111
|
+
try {
|
|
112
|
+
await unlinkFile(candidatePath);
|
|
113
|
+
} catch {
|
|
114
|
+
// The target is already committed through an exclusive hard link. Temporary-name
|
|
115
|
+
// cleanup is best effort and must not turn that successful commit into a false failure.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function validateGeneratedGuidance(content: string): string | undefined {
|
|
120
|
+
const size = Buffer.byteLength(content, "utf8");
|
|
121
|
+
if (size < 1 || size > TARGET_LIMIT) return `/init output must be between 1 and ${TARGET_LIMIT} UTF-8 bytes`;
|
|
122
|
+
if (content.split("\n", 1)[0] !== "# AGENTS.md") return "generated guidance must start with the exact # AGENTS.md heading";
|
|
123
|
+
let previous = -1;
|
|
124
|
+
for (const heading of REQUIRED_GUIDANCE_HEADINGS) {
|
|
125
|
+
const matches = [...content.matchAll(new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}$`, "gmu"))];
|
|
126
|
+
if (matches.length !== 1) return `generated guidance must contain ${heading} exactly once`;
|
|
127
|
+
const index = matches[0]!.index;
|
|
128
|
+
if (index <= previous) return "generated guidance headings must occur in the required order";
|
|
129
|
+
previous = index;
|
|
130
|
+
}
|
|
131
|
+
if (/\[(?:FILL IN|exact|confirmed)/iu.test(content)) return "generated guidance contains an unresolved template marker";
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function recoveryPath(targetPath: string): string {
|
|
136
|
+
return path.join(path.dirname(targetPath), `.killeros-init-recovery-${randomUUID()}.md`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
140
|
+
try {
|
|
141
|
+
await fs.lstat(filePath);
|
|
142
|
+
return true;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function installInitAgentsFile(
|
|
150
|
+
targetPath: string,
|
|
151
|
+
content: string,
|
|
152
|
+
baseline: InitTargetBaseline,
|
|
153
|
+
operations: InitInstallOperations = {},
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const validationError = validateGeneratedGuidance(content);
|
|
156
|
+
if (validationError) throw new Error(validationError);
|
|
157
|
+
const renameFile = operations.renameFile ?? fs.rename;
|
|
158
|
+
const linkFile = operations.linkFile ?? fs.link;
|
|
159
|
+
const unlinkFile = operations.unlinkFile ?? fs.unlink;
|
|
160
|
+
|
|
161
|
+
return withFileMutationQueue(targetPath, async () => {
|
|
162
|
+
const current = await captureInitTargetBaseline(targetPath);
|
|
163
|
+
if (!sameBaseline(current, baseline)) {
|
|
164
|
+
throw new Error("/init target changed while /init was generating; the newer AGENTS.md was preserved");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const tempDirectory = await fs.mkdtemp(path.join(path.dirname(targetPath), ".killeros-init-"));
|
|
168
|
+
const candidatePath = path.join(tempDirectory, "candidate.md");
|
|
169
|
+
const heldPath = path.join(tempDirectory, "held.md");
|
|
170
|
+
let held = false;
|
|
171
|
+
let installed = false;
|
|
172
|
+
let candidate: InitTargetBaseline | undefined;
|
|
173
|
+
let retainedRecovery: string | undefined;
|
|
174
|
+
try {
|
|
175
|
+
const handle = await fs.open(candidatePath, "wx", 0o600);
|
|
176
|
+
try {
|
|
177
|
+
await handle.writeFile(content, "utf8");
|
|
178
|
+
await handle.sync();
|
|
179
|
+
} finally {
|
|
180
|
+
await handle.close();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
candidate = await captureExistingTarget(candidatePath);
|
|
184
|
+
if (!baseline.exists) {
|
|
185
|
+
try {
|
|
186
|
+
await linkFile(candidatePath, targetPath);
|
|
187
|
+
installed = true;
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
190
|
+
throw new Error("/init target changed while /init was generating; the newer AGENTS.md was preserved");
|
|
191
|
+
}
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
if (!await installedCandidateMatches(targetPath, candidate)) {
|
|
195
|
+
throw new Error("/init target changed while /init was generating; the newer AGENTS.md was preserved");
|
|
196
|
+
}
|
|
197
|
+
await removeCandidateName(candidatePath, unlinkFile);
|
|
198
|
+
installed = false;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Node cannot lock arbitrary external writers. The exclusive links, held-target
|
|
203
|
+
// boundary, final held-file hash, and Pi mutation queue make installation fail closed.
|
|
204
|
+
await renameFile(targetPath, heldPath);
|
|
205
|
+
held = true;
|
|
206
|
+
const moved = await captureExistingTarget(heldPath);
|
|
207
|
+
if (!sameBaseline(moved, baseline)) {
|
|
208
|
+
if (!await pathExists(targetPath)) {
|
|
209
|
+
await renameFile(heldPath, targetPath);
|
|
210
|
+
held = false;
|
|
211
|
+
throw new Error("/init target changed while /init was generating; the newer AGENTS.md was preserved");
|
|
212
|
+
}
|
|
213
|
+
retainedRecovery = recoveryPath(targetPath);
|
|
214
|
+
await renameFile(heldPath, retainedRecovery);
|
|
215
|
+
held = false;
|
|
216
|
+
throw new Error(`/init target changed while /init was generating; both versions were preserved; recovery file: ${retainedRecovery}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
await linkFile(candidatePath, targetPath);
|
|
221
|
+
installed = true;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
224
|
+
retainedRecovery = recoveryPath(targetPath);
|
|
225
|
+
await renameFile(heldPath, retainedRecovery);
|
|
226
|
+
held = false;
|
|
227
|
+
throw new Error(`/init target changed while /init was generating; the newer AGENTS.md was preserved; recovery file: ${retainedRecovery}`);
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const finalHeld = await captureExistingTarget(heldPath);
|
|
233
|
+
if (!sameBaseline(finalHeld, baseline) || !await installedCandidateMatches(targetPath, candidate)) {
|
|
234
|
+
throw new Error("/init target changed while /init was generating; the newer AGENTS.md was preserved");
|
|
235
|
+
}
|
|
236
|
+
await unlinkFile(heldPath);
|
|
237
|
+
held = false;
|
|
238
|
+
await removeCandidateName(candidatePath, unlinkFile);
|
|
239
|
+
installed = false;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (held) {
|
|
242
|
+
if (installed && candidate && await installedCandidateMatches(targetPath, candidate)) {
|
|
243
|
+
try {
|
|
244
|
+
await unlinkFile(targetPath);
|
|
245
|
+
installed = false;
|
|
246
|
+
} catch {
|
|
247
|
+
// A different writer may have replaced the linked candidate.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (!await pathExists(targetPath)) {
|
|
251
|
+
try {
|
|
252
|
+
await renameFile(heldPath, targetPath);
|
|
253
|
+
held = false;
|
|
254
|
+
} catch {
|
|
255
|
+
// The original error remains primary; cleanup below retains bytes if needed.
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (held) {
|
|
259
|
+
retainedRecovery = recoveryPath(targetPath);
|
|
260
|
+
try {
|
|
261
|
+
await renameFile(heldPath, retainedRecovery);
|
|
262
|
+
held = false;
|
|
263
|
+
} catch {
|
|
264
|
+
// Leave the non-empty temporary directory rather than deleting held bytes.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (retainedRecovery && error instanceof Error && !error.message.includes(retainedRecovery)) {
|
|
269
|
+
throw new Error(`${error.message}; recovery file: ${retainedRecovery}`, { cause: error });
|
|
270
|
+
}
|
|
271
|
+
throw error;
|
|
272
|
+
} finally {
|
|
273
|
+
try {
|
|
274
|
+
await fs.rm(tempDirectory, { recursive: !held, force: !held });
|
|
275
|
+
} catch {
|
|
276
|
+
// A retained held file is safer than deleting ambiguous user bytes.
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function writeInitAgentsFile(
|
|
283
|
+
targetPath: string,
|
|
284
|
+
content: string,
|
|
285
|
+
renameFile: typeof fs.rename = fs.rename,
|
|
286
|
+
): Promise<void> {
|
|
287
|
+
const baseline = await captureInitTargetBaseline(targetPath);
|
|
288
|
+
return installInitAgentsFile(targetPath, content, baseline, { renameFile });
|
|
289
|
+
}
|