killeros 2.0.3 → 2.0.5
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 +37 -0
- package/Killeros.ts +4 -2
- package/README.md +15 -11
- package/killeros/commands.ts +3 -10
- package/killeros/concise.ts +12 -8
- package/killeros/goals.ts +146 -28
- package/killeros/hooks.ts +69 -22
- package/killeros/init-evidence.ts +276 -0
- package/killeros/init-target.ts +289 -0
- package/killeros/init.ts +139 -356
- package/killeros/notifications.ts +1 -1
- package/killeros/question.ts +24 -9
- package/killeros/runtime.ts +25 -6
- package/killeros/shell-ui.ts +56 -152
- package/package.json +1 -1
package/killeros/hooks.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
5
|
import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import { reportError } from "./errors.ts";
|
|
6
7
|
import { MAX_NODE_TIMER_MS } from "./limits.ts";
|
|
@@ -22,6 +23,7 @@ interface HookExecutionResult {
|
|
|
22
23
|
stdout: string;
|
|
23
24
|
stderr: string;
|
|
24
25
|
timedOut: boolean;
|
|
26
|
+
cancelled: boolean;
|
|
25
27
|
exitUnconfirmed: boolean;
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -43,6 +45,10 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
|
43
45
|
const candidates = parsed.hooks?.[event];
|
|
44
46
|
if (!Array.isArray(candidates)) continue;
|
|
45
47
|
hooks[event] = candidates.filter((hook, index) => {
|
|
48
|
+
if (event === "agent_settled" && hook?.matcher !== undefined) {
|
|
49
|
+
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: matchers are only valid for tool events`, "warning");
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
46
52
|
const valid = hook
|
|
47
53
|
&& typeof hook.command === "string"
|
|
48
54
|
&& hook.command.trim().length > 0
|
|
@@ -79,9 +85,19 @@ function matchesHook(hook: KillerosHook, value: string): boolean {
|
|
|
79
85
|
}
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
interface HookOutputBuffer {
|
|
89
|
+
bytes: number;
|
|
90
|
+
decoder: StringDecoder;
|
|
91
|
+
text: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function appendBounded(output: HookOutputBuffer, chunk: Buffer | string): void {
|
|
95
|
+
const remaining = HOOK_OUTPUT_LIMIT - output.bytes;
|
|
96
|
+
if (remaining <= 0) return;
|
|
97
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
98
|
+
const captured = bytes.subarray(0, remaining);
|
|
99
|
+
output.bytes += captured.length;
|
|
100
|
+
output.text += output.decoder.write(captured);
|
|
85
101
|
}
|
|
86
102
|
|
|
87
103
|
function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean): void {
|
|
@@ -109,7 +125,17 @@ function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean):
|
|
|
109
125
|
}
|
|
110
126
|
}
|
|
111
127
|
|
|
112
|
-
export function executeHook(
|
|
128
|
+
export function executeHook(
|
|
129
|
+
command: string,
|
|
130
|
+
cwd: string,
|
|
131
|
+
environment: Record<string, string>,
|
|
132
|
+
timeoutMs = 30_000,
|
|
133
|
+
spawnProcess: typeof spawn = spawn,
|
|
134
|
+
signal?: AbortSignal,
|
|
135
|
+
): Promise<HookExecutionResult> {
|
|
136
|
+
if (signal?.aborted) {
|
|
137
|
+
return Promise.resolve({ code: 130, stdout: "", stderr: "", timedOut: false, cancelled: true, exitUnconfirmed: false });
|
|
138
|
+
}
|
|
113
139
|
return new Promise((resolve) => {
|
|
114
140
|
const child = spawnProcess(command, {
|
|
115
141
|
cwd,
|
|
@@ -119,39 +145,51 @@ export function executeHook(command: string, cwd: string, environment: Record<st
|
|
|
119
145
|
stdio: ["ignore", "pipe", "pipe"],
|
|
120
146
|
windowsHide: true,
|
|
121
147
|
});
|
|
122
|
-
|
|
123
|
-
|
|
148
|
+
const stdout: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
|
|
149
|
+
const stderr: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
|
|
124
150
|
let completed = false;
|
|
125
|
-
let
|
|
126
|
-
let exitUnconfirmed = false;
|
|
151
|
+
let termination: "timeout" | "cancelled" | undefined;
|
|
127
152
|
let timer: NodeJS.Timeout | undefined;
|
|
128
153
|
let forceTimer: NodeJS.Timeout | undefined;
|
|
129
154
|
let settleTimer: NodeJS.Timeout | undefined;
|
|
130
|
-
const finish = (code: number,
|
|
155
|
+
const finish = (code: number, exitUnconfirmed = false): void => {
|
|
131
156
|
if (completed) return;
|
|
132
157
|
completed = true;
|
|
133
|
-
exitUnconfirmed = unconfirmed;
|
|
134
158
|
if (timer) clearTimeout(timer);
|
|
135
159
|
if (forceTimer) clearTimeout(forceTimer);
|
|
136
160
|
if (settleTimer) clearTimeout(settleTimer);
|
|
137
|
-
|
|
161
|
+
signal?.removeEventListener("abort", abort);
|
|
162
|
+
resolve({
|
|
163
|
+
code,
|
|
164
|
+
stdout: stdout.text,
|
|
165
|
+
stderr: stderr.text,
|
|
166
|
+
timedOut: termination === "timeout",
|
|
167
|
+
cancelled: termination === "cancelled",
|
|
168
|
+
exitUnconfirmed,
|
|
169
|
+
});
|
|
138
170
|
};
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
finish(timedOut ? 124 : 1);
|
|
144
|
-
});
|
|
145
|
-
child.once("close", (code) => finish(timedOut ? 124 : code ?? 1));
|
|
146
|
-
timer = setTimeout(() => {
|
|
147
|
-
timedOut = true;
|
|
171
|
+
const terminationCode = (): number => termination === "cancelled" ? 130 : 124;
|
|
172
|
+
const beginTermination = (reason: "timeout" | "cancelled"): void => {
|
|
173
|
+
if (completed || termination) return;
|
|
174
|
+
termination = reason;
|
|
148
175
|
terminateHookProcess(child, false);
|
|
149
176
|
forceTimer = setTimeout(() => {
|
|
150
177
|
if (completed) return;
|
|
151
178
|
terminateHookProcess(child, true);
|
|
152
|
-
settleTimer = setTimeout(() => finish(
|
|
179
|
+
settleTimer = setTimeout(() => finish(terminationCode(), true), 1_000);
|
|
153
180
|
}, 1_000);
|
|
154
|
-
}
|
|
181
|
+
};
|
|
182
|
+
const abort = (): void => beginTermination("cancelled");
|
|
183
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
184
|
+
if (signal?.aborted) beginTermination("cancelled");
|
|
185
|
+
child.stdout.on("data", (chunk) => appendBounded(stdout, chunk));
|
|
186
|
+
child.stderr.on("data", (chunk) => appendBounded(stderr, chunk));
|
|
187
|
+
child.on("error", (error) => {
|
|
188
|
+
appendBounded(stderr, error.message);
|
|
189
|
+
finish(termination ? terminationCode() : 1);
|
|
190
|
+
});
|
|
191
|
+
child.once("close", (code) => finish(termination ? terminationCode() : code ?? 1));
|
|
192
|
+
timer = setTimeout(() => beginTermination("timeout"), Math.max(1_000, Math.min(timeoutMs, 300_000)));
|
|
155
193
|
});
|
|
156
194
|
}
|
|
157
195
|
|
|
@@ -180,7 +218,10 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
180
218
|
ctx.cwd,
|
|
181
219
|
hookEnvironment("tool_call", event.toolName, event.input),
|
|
182
220
|
hook.timeoutMs,
|
|
221
|
+
spawn,
|
|
222
|
+
ctx.signal,
|
|
183
223
|
);
|
|
224
|
+
if (result.cancelled) return { block: true, reason: "Hook cancelled because the parent request was aborted" };
|
|
184
225
|
if (result.code !== 0) {
|
|
185
226
|
const reason = hookFailureMessage(hook, result);
|
|
186
227
|
ctx.ui.notify(reason, "error");
|
|
@@ -200,7 +241,10 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
200
241
|
isError: event.isError,
|
|
201
242
|
}),
|
|
202
243
|
hook.timeoutMs,
|
|
244
|
+
spawn,
|
|
245
|
+
ctx.signal,
|
|
203
246
|
);
|
|
247
|
+
if (result.cancelled) break;
|
|
204
248
|
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(hook, result), "error");
|
|
205
249
|
}
|
|
206
250
|
});
|
|
@@ -212,7 +256,10 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
212
256
|
ctx.cwd,
|
|
213
257
|
hookEnvironment("agent_settled"),
|
|
214
258
|
hook.timeoutMs,
|
|
259
|
+
spawn,
|
|
260
|
+
ctx.signal,
|
|
215
261
|
);
|
|
262
|
+
if (result.cancelled) break;
|
|
216
263
|
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(hook, result), "error");
|
|
217
264
|
}
|
|
218
265
|
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { StringDecoder } from "node:string_decoder";
|
|
5
|
+
|
|
6
|
+
export const INIT_READ_TOOL = "killeros_init_read";
|
|
7
|
+
export const INIT_LIST_TOOL = "killeros_init_list";
|
|
8
|
+
|
|
9
|
+
const SNAPSHOT_LIMIT = 40 * 1024;
|
|
10
|
+
const AUTOMATIC_FILE_LIMIT = 8 * 1024;
|
|
11
|
+
const READ_LIMIT = 32 * 1024;
|
|
12
|
+
const PATH_LIMIT = 400;
|
|
13
|
+
const DIRECTORY_LIMIT = 120;
|
|
14
|
+
const DEPTH_LIMIT = 4;
|
|
15
|
+
const EXCLUDED_DIRS = new Set([
|
|
16
|
+
".agents", ".claude", ".git", ".next", ".pi", ".pytest_cache", ".turbo", ".venv", "__pycache__", "archive", "build", "coverage", "data", "dist", "logs", "node_modules", "target", "test-results", "vendor",
|
|
17
|
+
]);
|
|
18
|
+
const EXCLUDED_GUIDANCE = new Set([
|
|
19
|
+
".cursorrules", "agents.md", "agents.local.md", "claude.md", "claude.local.md", "copilot-instructions.md", "gemini.md", "memory.md", "skill.md",
|
|
20
|
+
]);
|
|
21
|
+
const ROOT_EVIDENCE = [
|
|
22
|
+
"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",
|
|
23
|
+
] as const;
|
|
24
|
+
const NESTED_EVIDENCE = new Set(["package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod"]);
|
|
25
|
+
|
|
26
|
+
export interface InitEvidenceIndex {
|
|
27
|
+
projectRoot: string;
|
|
28
|
+
canonicalPaths: ReadonlyMap<string, string>;
|
|
29
|
+
snapshot: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface InitEvidenceBuildResult {
|
|
33
|
+
index: InitEvidenceIndex;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function evidenceKey(relativePath: string): string {
|
|
37
|
+
const normalized = relativePath.replaceAll("\\", "/");
|
|
38
|
+
return process.platform === "win32" ? normalized.toLocaleLowerCase() : normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sensitiveEvidencePath(relativePath: string): boolean {
|
|
42
|
+
const normalized = relativePath.replaceAll("\\", "/").toLocaleLowerCase();
|
|
43
|
+
const name = path.posix.basename(normalized);
|
|
44
|
+
return /^\.env(?:\.|$)/u.test(name)
|
|
45
|
+
|| [".npmrc", ".pypirc", ".netrc", "id_rsa", "id_ed25519", "credentials.json"].includes(name)
|
|
46
|
+
|| /^service-account.*\.json$/u.test(name)
|
|
47
|
+
|| /\.(?:pem|key|p12|pfx|jks|keystore)$/u.test(name);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function excludedPath(relativePath: string): boolean {
|
|
51
|
+
const segments = relativePath.replaceAll("\\", "/").split("/");
|
|
52
|
+
return segments.some((segment, index) =>
|
|
53
|
+
(index < segments.length - 1 && EXCLUDED_DIRS.has(segment.toLocaleLowerCase()))
|
|
54
|
+
|| EXCLUDED_GUIDANCE.has(segment.toLocaleLowerCase()))
|
|
55
|
+
|| sensitiveEvidencePath(relativePath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function collectCandidates(projectRoot: string): Promise<string[]> {
|
|
59
|
+
const files: string[] = [];
|
|
60
|
+
const queue: Array<{ relativePath: string; depth: number }> = [{ relativePath: "", depth: 0 }];
|
|
61
|
+
let directoriesRead = 0;
|
|
62
|
+
while (queue.length && files.length < PATH_LIMIT && directoriesRead < DIRECTORY_LIMIT) {
|
|
63
|
+
const current = queue.shift()!;
|
|
64
|
+
directoriesRead += 1;
|
|
65
|
+
let entries;
|
|
66
|
+
try {
|
|
67
|
+
entries = await fs.readdir(path.join(projectRoot, current.relativePath), { withFileTypes: true });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!current.relativePath) throw error;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (files.length >= PATH_LIMIT) break;
|
|
75
|
+
const relativePath = path.posix.join(current.relativePath.replaceAll("\\", "/"), entry.name);
|
|
76
|
+
if (entry.isDirectory()) {
|
|
77
|
+
if (current.depth < DEPTH_LIMIT && !EXCLUDED_DIRS.has(entry.name.toLocaleLowerCase())) {
|
|
78
|
+
queue.push({ relativePath, depth: current.depth + 1 });
|
|
79
|
+
}
|
|
80
|
+
} else if (entry.isFile() && !excludedPath(relativePath)) {
|
|
81
|
+
try {
|
|
82
|
+
const stat = await fs.lstat(path.join(projectRoot, relativePath));
|
|
83
|
+
if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1) files.push(relativePath);
|
|
84
|
+
} catch {
|
|
85
|
+
// Files may disappear while the bounded map is collected.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return files;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function gitIgnoredPaths(projectRoot: string, candidates: readonly string[]): Promise<ReadonlySet<string>> {
|
|
94
|
+
if (!candidates.length) return new Set();
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
let settled = false;
|
|
97
|
+
let stdout = Buffer.alloc(0);
|
|
98
|
+
const child = spawn("git", ["-C", projectRoot, "check-ignore", "--stdin", "-z"], {
|
|
99
|
+
shell: false,
|
|
100
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
101
|
+
windowsHide: true,
|
|
102
|
+
});
|
|
103
|
+
const fail = (): void => {
|
|
104
|
+
if (settled) return;
|
|
105
|
+
settled = true;
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
reject(new Error("Git ignore inspection failed; /init did not build repository evidence"));
|
|
108
|
+
};
|
|
109
|
+
const succeed = (value: ReadonlySet<string>): void => {
|
|
110
|
+
if (settled) return;
|
|
111
|
+
settled = true;
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
resolve(value);
|
|
114
|
+
};
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
child.kill("SIGKILL");
|
|
117
|
+
fail();
|
|
118
|
+
}, 2_000);
|
|
119
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
120
|
+
if (stdout.length + chunk.length > 256 * 1024) {
|
|
121
|
+
child.kill("SIGKILL");
|
|
122
|
+
fail();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
stdout = Buffer.concat([stdout, chunk]);
|
|
126
|
+
});
|
|
127
|
+
child.once("error", fail);
|
|
128
|
+
child.stdin.once("error", () => {
|
|
129
|
+
child.kill("SIGKILL");
|
|
130
|
+
fail();
|
|
131
|
+
});
|
|
132
|
+
child.once("close", (code) => {
|
|
133
|
+
if (code === 1) {
|
|
134
|
+
if (stdout.length) fail();
|
|
135
|
+
else succeed(new Set());
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (code !== 0 || !stdout.length || stdout.at(-1) !== 0) {
|
|
139
|
+
fail();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let values: string[];
|
|
143
|
+
try {
|
|
144
|
+
values = new TextDecoder("utf-8", { fatal: true }).decode(stdout.subarray(0, -1)).split("\0");
|
|
145
|
+
} catch {
|
|
146
|
+
fail();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const candidateSet = new Set(candidates.map(evidenceKey));
|
|
150
|
+
const ignored = new Set(values.map(evidenceKey));
|
|
151
|
+
if (values.some((value) => !value || !candidateSet.has(evidenceKey(value))) || ignored.size !== values.length) {
|
|
152
|
+
fail();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
succeed(ignored);
|
|
156
|
+
});
|
|
157
|
+
child.stdin.end(`${candidates.join("\0")}\0`);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function decodeCompleteUtf8(bytes: Buffer): string {
|
|
162
|
+
return new StringDecoder("utf8").write(bytes);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function appendWithinLimit(current: string, section: string, limit: number): string {
|
|
166
|
+
const remaining = limit - Buffer.byteLength(current, "utf8");
|
|
167
|
+
if (remaining <= 0) return current;
|
|
168
|
+
const bytes = Buffer.from(section, "utf8");
|
|
169
|
+
return current + decodeCompleteUtf8(bytes.subarray(0, remaining));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function validateAndRead(projectRoot: string, absolutePath: string, limit: number): Promise<{ content: string; truncated: boolean }> {
|
|
173
|
+
const relative = path.relative(projectRoot, absolutePath);
|
|
174
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
175
|
+
throw new Error("path is not available to /init");
|
|
176
|
+
}
|
|
177
|
+
let current = projectRoot;
|
|
178
|
+
for (const segment of relative.split(path.sep)) {
|
|
179
|
+
current = path.join(current, segment);
|
|
180
|
+
const stat = await fs.lstat(current);
|
|
181
|
+
if (stat.isSymbolicLink()) throw new Error("/init rejects symbolic-link and junction paths");
|
|
182
|
+
}
|
|
183
|
+
const pathStat = await fs.lstat(absolutePath);
|
|
184
|
+
if (!pathStat.isFile() || pathStat.nlink !== 1) throw new Error("/init rejects linked and non-regular files");
|
|
185
|
+
const handle = await fs.open(absolutePath, "r");
|
|
186
|
+
try {
|
|
187
|
+
const openedStat = await handle.stat();
|
|
188
|
+
if (!openedStat.isFile() || openedStat.nlink !== 1
|
|
189
|
+
|| openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
|
|
190
|
+
throw new Error("/init file changed while it was being opened");
|
|
191
|
+
}
|
|
192
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
193
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
194
|
+
const data = buffer.subarray(0, Math.min(bytesRead, limit));
|
|
195
|
+
if (data.includes(0)) throw new Error("/init rejects binary files");
|
|
196
|
+
return { content: decodeCompleteUtf8(data), truncated: bytesRead > limit };
|
|
197
|
+
} finally {
|
|
198
|
+
await handle.close();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeRequestedPath(requestedPath: string): string {
|
|
203
|
+
if (!requestedPath || requestedPath.trim() !== requestedPath || requestedPath.startsWith("~")
|
|
204
|
+
|| /^file:/iu.test(requestedPath) || path.isAbsolute(requestedPath)) {
|
|
205
|
+
throw new Error("path is not available to /init");
|
|
206
|
+
}
|
|
207
|
+
const normalized = requestedPath.replaceAll("\\", "/");
|
|
208
|
+
if (!normalized || normalized.split("/").some((segment) => segment === ".." || segment === "")) {
|
|
209
|
+
throw new Error("path is not available to /init");
|
|
210
|
+
}
|
|
211
|
+
return normalized.replace(/^\.\//u, "");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function buildInitEvidence(projectRoot: string): Promise<InitEvidenceBuildResult> {
|
|
215
|
+
const candidates = await collectCandidates(projectRoot);
|
|
216
|
+
const ignored = await gitIgnoredPaths(projectRoot, candidates);
|
|
217
|
+
const canonicalPaths = new Map<string, string>();
|
|
218
|
+
for (const relativePath of candidates) {
|
|
219
|
+
if (!ignored.has(evidenceKey(relativePath))) canonicalPaths.set(evidenceKey(relativePath), relativePath);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let snapshot = [
|
|
223
|
+
"# KillerOS repository snapshot",
|
|
224
|
+
"Root AGENTS.md is protected policy and is intentionally not part of this untrusted evidence.",
|
|
225
|
+
"",
|
|
226
|
+
"## Project files",
|
|
227
|
+
[...canonicalPaths.values()].join("\n"),
|
|
228
|
+
].join("\n");
|
|
229
|
+
snapshot = appendWithinLimit("", snapshot, SNAPSHOT_LIMIT);
|
|
230
|
+
const automatic = new Set<string>(ROOT_EVIDENCE);
|
|
231
|
+
for (const relativePath of canonicalPaths.values()) {
|
|
232
|
+
const name = path.posix.basename(relativePath);
|
|
233
|
+
if (NESTED_EVIDENCE.has(name) || /^\.github\/workflows\/[^/]+\.ya?ml$/iu.test(relativePath)) automatic.add(relativePath);
|
|
234
|
+
}
|
|
235
|
+
for (const requested of automatic) {
|
|
236
|
+
const relativePath = canonicalPaths.get(evidenceKey(requested));
|
|
237
|
+
if (!relativePath || Buffer.byteLength(snapshot, "utf8") >= SNAPSHOT_LIMIT) continue;
|
|
238
|
+
try {
|
|
239
|
+
const result = await validateAndRead(projectRoot, path.join(projectRoot, relativePath), AUTOMATIC_FILE_LIMIT);
|
|
240
|
+
const suffix = result.truncated ? "\n[truncated by /init]" : "";
|
|
241
|
+
snapshot = appendWithinLimit(snapshot, `\n\n## ${relativePath}\n${result.content}${suffix}`, SNAPSHOT_LIMIT);
|
|
242
|
+
} catch {
|
|
243
|
+
// A mapped file may become unsafe or disappear before snapshot creation.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { index: { projectRoot, canonicalPaths, snapshot } };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function readInitEvidence(index: InitEvidenceIndex, requestedPath: string): Promise<string> {
|
|
250
|
+
const normalized = normalizeRequestedPath(requestedPath);
|
|
251
|
+
const relativePath = index.canonicalPaths.get(evidenceKey(normalized));
|
|
252
|
+
if (!relativePath) throw new Error(`${requestedPath} is not available to /init`);
|
|
253
|
+
const result = await validateAndRead(index.projectRoot, path.join(index.projectRoot, relativePath), READ_LIMIT);
|
|
254
|
+
return result.truncated ? `${result.content}\n[truncated by /init at ${READ_LIMIT} bytes]` : result.content;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function readGeneratedInitTarget(projectRoot: string, targetPath: string): Promise<string> {
|
|
258
|
+
const result = await validateAndRead(projectRoot, targetPath, READ_LIMIT);
|
|
259
|
+
return result.truncated ? `${result.content}\n[truncated by /init at ${READ_LIMIT} bytes]` : result.content;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function listInitEvidence(index: InitEvidenceIndex, requestedPath = "."): string[] {
|
|
263
|
+
const prefix = requestedPath === "." ? "" : normalizeRequestedPath(requestedPath).replace(/\/$/u, "");
|
|
264
|
+
const prefixWithSlash = prefix ? `${prefix}/` : "";
|
|
265
|
+
const children = new Set<string>();
|
|
266
|
+
let found = !prefix;
|
|
267
|
+
for (const relativePath of index.canonicalPaths.values()) {
|
|
268
|
+
if (!relativePath.startsWith(prefixWithSlash)) continue;
|
|
269
|
+
const remainder = relativePath.slice(prefixWithSlash.length);
|
|
270
|
+
if (!remainder) continue;
|
|
271
|
+
found = true;
|
|
272
|
+
children.add(remainder.split("/")[0]!);
|
|
273
|
+
}
|
|
274
|
+
if (!found) throw new Error(`${requestedPath} is not available to /init`);
|
|
275
|
+
return [...children].sort((left, right) => left.localeCompare(right)).slice(0, PATH_LIMIT);
|
|
276
|
+
}
|