killeros 2.0.2 → 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 +34 -0
- package/Killeros.ts +17 -10
- package/README.md +21 -13
- package/killeros/commands.ts +2 -8
- package/killeros/concise.ts +12 -8
- package/killeros/footer.ts +46 -1
- package/killeros/goals.ts +118 -57
- 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/notifications.ts +167 -0
- package/killeros/question.ts +16 -1
- package/killeros/runtime.ts +19 -31
- package/killeros/shell-ui.ts +18 -141
- package/package.json +2 -2
- package/killeros/context-compaction.ts +0 -614
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const INIT_READ_TOOL = "killeros_init_read";
|
|
6
|
+
export const INIT_LIST_TOOL = "killeros_init_list";
|
|
7
|
+
|
|
8
|
+
const SNAPSHOT_LIMIT = 40 * 1024;
|
|
9
|
+
const AUTOMATIC_FILE_LIMIT = 8 * 1024;
|
|
10
|
+
const READ_LIMIT = 32 * 1024;
|
|
11
|
+
const PATH_LIMIT = 400;
|
|
12
|
+
const DIRECTORY_LIMIT = 120;
|
|
13
|
+
const DEPTH_LIMIT = 4;
|
|
14
|
+
const EXCLUDED_DIRS = new Set([
|
|
15
|
+
".agents", ".claude", ".git", ".next", ".pi", ".pytest_cache", ".turbo", ".venv", "__pycache__", "archive", "build", "coverage", "data", "dist", "logs", "node_modules", "target", "test-results", "vendor",
|
|
16
|
+
]);
|
|
17
|
+
const EXCLUDED_GUIDANCE = new Set([
|
|
18
|
+
".cursorrules", "agents.md", "agents.local.md", "claude.md", "claude.local.md", "copilot-instructions.md", "gemini.md", "memory.md", "skill.md",
|
|
19
|
+
]);
|
|
20
|
+
const ROOT_EVIDENCE = [
|
|
21
|
+
"README.md", "README.rst", "README.txt", "CONTRIBUTING.md", "package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod", "Makefile", "Dockerfile", "compose.yaml", "compose.yml", "config.yaml", "config.yml", "tsconfig.json", "vite.config.ts", "vite.config.js", "eslint.config.js", "eslint.config.mjs",
|
|
22
|
+
] as const;
|
|
23
|
+
const NESTED_EVIDENCE = new Set(["package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod"]);
|
|
24
|
+
|
|
25
|
+
export interface InitEvidenceIndex {
|
|
26
|
+
projectRoot: string;
|
|
27
|
+
canonicalPaths: ReadonlyMap<string, string>;
|
|
28
|
+
snapshot: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface InitEvidenceBuildResult {
|
|
32
|
+
index: InitEvidenceIndex;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function evidenceKey(relativePath: string): string {
|
|
36
|
+
const normalized = relativePath.replaceAll("\\", "/");
|
|
37
|
+
return process.platform === "win32" ? normalized.toLocaleLowerCase() : normalized;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sensitiveEvidencePath(relativePath: string): boolean {
|
|
41
|
+
const normalized = relativePath.replaceAll("\\", "/").toLocaleLowerCase();
|
|
42
|
+
const name = path.posix.basename(normalized);
|
|
43
|
+
return /^\.env(?:\.|$)/u.test(name)
|
|
44
|
+
|| [".npmrc", ".pypirc", ".netrc", "id_rsa", "id_ed25519", "credentials.json"].includes(name)
|
|
45
|
+
|| /^service-account.*\.json$/u.test(name)
|
|
46
|
+
|| /\.(?:pem|key|p12|pfx|jks|keystore)$/u.test(name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function excludedPath(relativePath: string): boolean {
|
|
50
|
+
const segments = relativePath.replaceAll("\\", "/").split("/");
|
|
51
|
+
return segments.some((segment, index) =>
|
|
52
|
+
(index < segments.length - 1 && EXCLUDED_DIRS.has(segment.toLocaleLowerCase()))
|
|
53
|
+
|| EXCLUDED_GUIDANCE.has(segment.toLocaleLowerCase()))
|
|
54
|
+
|| sensitiveEvidencePath(relativePath);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function collectCandidates(projectRoot: string): Promise<string[]> {
|
|
58
|
+
const files: string[] = [];
|
|
59
|
+
const queue: Array<{ relativePath: string; depth: number }> = [{ relativePath: "", depth: 0 }];
|
|
60
|
+
let directoriesRead = 0;
|
|
61
|
+
while (queue.length && files.length < PATH_LIMIT && directoriesRead < DIRECTORY_LIMIT) {
|
|
62
|
+
const current = queue.shift()!;
|
|
63
|
+
directoriesRead += 1;
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = await fs.readdir(path.join(projectRoot, current.relativePath), { withFileTypes: true });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (!current.relativePath) throw error;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (files.length >= PATH_LIMIT) break;
|
|
74
|
+
const relativePath = path.posix.join(current.relativePath.replaceAll("\\", "/"), entry.name);
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
if (current.depth < DEPTH_LIMIT && !EXCLUDED_DIRS.has(entry.name.toLocaleLowerCase())) {
|
|
77
|
+
queue.push({ relativePath, depth: current.depth + 1 });
|
|
78
|
+
}
|
|
79
|
+
} else if (entry.isFile() && !excludedPath(relativePath)) {
|
|
80
|
+
try {
|
|
81
|
+
const stat = await fs.lstat(path.join(projectRoot, relativePath));
|
|
82
|
+
if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1) files.push(relativePath);
|
|
83
|
+
} catch {
|
|
84
|
+
// Files may disappear while the bounded map is collected.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return files;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function gitIgnoredPaths(projectRoot: string, candidates: readonly string[]): Promise<ReadonlySet<string>> {
|
|
93
|
+
if (!candidates.length) return new Set();
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
let settled = false;
|
|
96
|
+
let stdout = Buffer.alloc(0);
|
|
97
|
+
const child = spawn("git", ["-C", projectRoot, "check-ignore", "--stdin", "-z"], {
|
|
98
|
+
shell: false,
|
|
99
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
100
|
+
windowsHide: true,
|
|
101
|
+
});
|
|
102
|
+
const finish = (value: ReadonlySet<string>): void => {
|
|
103
|
+
if (settled) return;
|
|
104
|
+
settled = true;
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
resolve(value);
|
|
107
|
+
};
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
child.kill("SIGKILL");
|
|
110
|
+
finish(new Set());
|
|
111
|
+
}, 2_000);
|
|
112
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
113
|
+
if (stdout.length <= 256 * 1024) stdout = Buffer.concat([stdout, chunk]);
|
|
114
|
+
});
|
|
115
|
+
child.once("error", () => finish(new Set()));
|
|
116
|
+
child.once("close", (code) => {
|
|
117
|
+
if (code === 1) return finish(new Set());
|
|
118
|
+
if (code !== 0 || stdout.length > 256 * 1024 || stdout.at(-1) !== 0) return finish(new Set());
|
|
119
|
+
const candidateSet = new Set(candidates.map(evidenceKey));
|
|
120
|
+
const values = stdout.subarray(0, -1).toString("utf8").split("\0");
|
|
121
|
+
if (values.some((value) => !value || !candidateSet.has(evidenceKey(value)))) return finish(new Set());
|
|
122
|
+
finish(new Set(values.map(evidenceKey)));
|
|
123
|
+
});
|
|
124
|
+
child.stdin.on("error", () => {});
|
|
125
|
+
child.stdin.end(`${candidates.join("\0")}\0`);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function appendWithinLimit(current: string, section: string, limit: number): string {
|
|
130
|
+
const remaining = limit - Buffer.byteLength(current, "utf8");
|
|
131
|
+
if (remaining <= 0) return current;
|
|
132
|
+
const bytes = Buffer.from(section, "utf8");
|
|
133
|
+
return current + bytes.subarray(0, remaining).toString("utf8");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function validateAndRead(projectRoot: string, absolutePath: string, limit: number): Promise<{ content: string; truncated: boolean }> {
|
|
137
|
+
const relative = path.relative(projectRoot, absolutePath);
|
|
138
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
139
|
+
throw new Error("path is not available to /init");
|
|
140
|
+
}
|
|
141
|
+
let current = projectRoot;
|
|
142
|
+
for (const segment of relative.split(path.sep)) {
|
|
143
|
+
current = path.join(current, segment);
|
|
144
|
+
const stat = await fs.lstat(current);
|
|
145
|
+
if (stat.isSymbolicLink()) throw new Error("/init rejects symbolic-link and junction paths");
|
|
146
|
+
}
|
|
147
|
+
const pathStat = await fs.lstat(absolutePath);
|
|
148
|
+
if (!pathStat.isFile() || pathStat.nlink !== 1) throw new Error("/init rejects linked and non-regular files");
|
|
149
|
+
const handle = await fs.open(absolutePath, "r");
|
|
150
|
+
try {
|
|
151
|
+
const openedStat = await handle.stat();
|
|
152
|
+
if (!openedStat.isFile() || openedStat.nlink !== 1
|
|
153
|
+
|| openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
|
|
154
|
+
throw new Error("/init file changed while it was being opened");
|
|
155
|
+
}
|
|
156
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
157
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
158
|
+
const data = buffer.subarray(0, Math.min(bytesRead, limit));
|
|
159
|
+
if (data.includes(0)) throw new Error("/init rejects binary files");
|
|
160
|
+
return { content: data.toString("utf8"), truncated: bytesRead > limit };
|
|
161
|
+
} finally {
|
|
162
|
+
await handle.close();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeRequestedPath(requestedPath: string): string {
|
|
167
|
+
if (!requestedPath || requestedPath.trim() !== requestedPath || requestedPath.startsWith("~")
|
|
168
|
+
|| /^file:/iu.test(requestedPath) || path.isAbsolute(requestedPath)) {
|
|
169
|
+
throw new Error("path is not available to /init");
|
|
170
|
+
}
|
|
171
|
+
const normalized = requestedPath.replaceAll("\\", "/");
|
|
172
|
+
if (!normalized || normalized.split("/").some((segment) => segment === ".." || segment === "")) {
|
|
173
|
+
throw new Error("path is not available to /init");
|
|
174
|
+
}
|
|
175
|
+
return normalized.replace(/^\.\//u, "");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function buildInitEvidence(projectRoot: string): Promise<InitEvidenceBuildResult> {
|
|
179
|
+
const candidates = await collectCandidates(projectRoot);
|
|
180
|
+
const ignored = await gitIgnoredPaths(projectRoot, candidates);
|
|
181
|
+
const canonicalPaths = new Map<string, string>();
|
|
182
|
+
for (const relativePath of candidates) {
|
|
183
|
+
if (!ignored.has(evidenceKey(relativePath))) canonicalPaths.set(evidenceKey(relativePath), relativePath);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let snapshot = [
|
|
187
|
+
"# KillerOS repository snapshot",
|
|
188
|
+
"Root AGENTS.md is protected policy and is intentionally not part of this untrusted evidence.",
|
|
189
|
+
"",
|
|
190
|
+
"## Project files",
|
|
191
|
+
[...canonicalPaths.values()].join("\n"),
|
|
192
|
+
].join("\n");
|
|
193
|
+
snapshot = appendWithinLimit("", snapshot, SNAPSHOT_LIMIT);
|
|
194
|
+
const automatic = new Set<string>(ROOT_EVIDENCE);
|
|
195
|
+
for (const relativePath of canonicalPaths.values()) {
|
|
196
|
+
const name = path.posix.basename(relativePath);
|
|
197
|
+
if (NESTED_EVIDENCE.has(name) || /^\.github\/workflows\/[^/]+\.ya?ml$/iu.test(relativePath)) automatic.add(relativePath);
|
|
198
|
+
}
|
|
199
|
+
for (const requested of automatic) {
|
|
200
|
+
const relativePath = canonicalPaths.get(evidenceKey(requested));
|
|
201
|
+
if (!relativePath || Buffer.byteLength(snapshot, "utf8") >= SNAPSHOT_LIMIT) continue;
|
|
202
|
+
try {
|
|
203
|
+
const result = await validateAndRead(projectRoot, path.join(projectRoot, relativePath), AUTOMATIC_FILE_LIMIT);
|
|
204
|
+
const suffix = result.truncated ? "\n[truncated by /init]" : "";
|
|
205
|
+
snapshot = appendWithinLimit(snapshot, `\n\n## ${relativePath}\n${result.content}${suffix}`, SNAPSHOT_LIMIT);
|
|
206
|
+
} catch {
|
|
207
|
+
// A mapped file may become unsafe or disappear before snapshot creation.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { index: { projectRoot, canonicalPaths, snapshot } };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function readInitEvidence(index: InitEvidenceIndex, requestedPath: string): Promise<string> {
|
|
214
|
+
const normalized = normalizeRequestedPath(requestedPath);
|
|
215
|
+
const relativePath = index.canonicalPaths.get(evidenceKey(normalized));
|
|
216
|
+
if (!relativePath) throw new Error(`${requestedPath} is not available to /init`);
|
|
217
|
+
const result = await validateAndRead(index.projectRoot, path.join(index.projectRoot, relativePath), READ_LIMIT);
|
|
218
|
+
return result.truncated ? `${result.content}\n[truncated by /init at ${READ_LIMIT} bytes]` : result.content;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function readGeneratedInitTarget(projectRoot: string, targetPath: string): Promise<string> {
|
|
222
|
+
const result = await validateAndRead(projectRoot, targetPath, READ_LIMIT);
|
|
223
|
+
return result.truncated ? `${result.content}\n[truncated by /init at ${READ_LIMIT} bytes]` : result.content;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function listInitEvidence(index: InitEvidenceIndex, requestedPath = "."): string[] {
|
|
227
|
+
const prefix = requestedPath === "." ? "" : normalizeRequestedPath(requestedPath).replace(/\/$/u, "");
|
|
228
|
+
const prefixWithSlash = prefix ? `${prefix}/` : "";
|
|
229
|
+
const children = new Set<string>();
|
|
230
|
+
let found = !prefix;
|
|
231
|
+
for (const relativePath of index.canonicalPaths.values()) {
|
|
232
|
+
if (!relativePath.startsWith(prefixWithSlash)) continue;
|
|
233
|
+
const remainder = relativePath.slice(prefixWithSlash.length);
|
|
234
|
+
if (!remainder) continue;
|
|
235
|
+
found = true;
|
|
236
|
+
children.add(remainder.split("/")[0]!);
|
|
237
|
+
}
|
|
238
|
+
if (!found) throw new Error(`${requestedPath} is not available to /init`);
|
|
239
|
+
return [...children].sort((left, right) => left.localeCompare(right)).slice(0, PATH_LIMIT);
|
|
240
|
+
}
|
|
@@ -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
|
+
}
|