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 CHANGED
@@ -4,6 +4,23 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.4] - 2026-08-10
8
+
9
+ ### Changed
10
+
11
+ - Rebuilt `/init` around a packaged four-section guideline-synthesis workflow that preserves compatible root policy without requiring an external skill.
12
+ - Removed live blue slash-command coloring while retaining slash autocomplete, the framed multiline editor, and Shift+Enter.
13
+
14
+ ### Fixed
15
+
16
+ - Prevented `/init` from reading Git-ignored files, known secrets, private keys, linked or non-regular files, other guidance, dependencies, and files outside its frozen evidence map.
17
+ - Protected root `AGENTS.md` from concurrent replacement and reported incompatible policy conflicts without writing.
18
+ - Made `/clear` confirm before aborting active work, wait for settlement, and then start the new session.
19
+ - Preserved explicit Responses API verbosity and reasoning-summary settings while applying concise defaults only to absent fields.
20
+ - Cancelled lifecycle hook process trees with bounded cleanup when Pi aborts the parent request.
21
+ - Kept `/init` middleware from freezing shared tool input and preserved custom editors installed by other extensions.
22
+ - Removed private Pi editor-state access in favor of public rendering contracts.
23
+
7
24
  ## [2.0.3] - 2026-08-09
8
25
 
9
26
  ### Added
package/Killeros.ts CHANGED
@@ -15,10 +15,12 @@ import { createGoalRuntime, createInitRuntime } from "./killeros/runtime.ts";
15
15
  import { registerShellUi } from "./killeros/shell-ui.ts";
16
16
  import { registerVariants } from "./killeros/variants.ts";
17
17
 
18
- export { CONCISE_SYSTEM_PROMPT, isConcisedEnabled } from "./killeros/concise.ts";
18
+ export { CONCISE_SYSTEM_PROMPT, isConciseEnabled, isConcisedEnabled } from "./killeros/concise.ts";
19
19
  export { contextPercentRemaining, formatCost, formatContextProgress } from "./killeros/footer.ts";
20
20
  export { executeHook } from "./killeros/hooks.ts";
21
- export { INIT_WORKFLOW_PROMPT, writeInitAgentsFile } from "./killeros/init.ts";
21
+ export { INIT_WORKFLOW_PROMPT } from "./killeros/init.ts";
22
+ export { buildInitEvidence, listInitEvidence, readInitEvidence } from "./killeros/init-evidence.ts";
23
+ export { captureInitTargetBaseline, installInitAgentsFile, validateGeneratedGuidance, writeInitAgentsFile } from "./killeros/init-target.ts";
22
24
 
23
25
  export interface KillerosOptions {
24
26
  completionNotifications?: CompletionNotificationDependencies;
package/README.md CHANGED
@@ -31,7 +31,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
31
31
  Pin an install to a release:
32
32
 
33
33
  ```bash
34
- pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.3
34
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.4
35
35
  ```
36
36
 
37
37
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -41,17 +41,17 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
41
41
  - 52-column Compact startup card with inline version, polished model/provider identity, adjacent `/model`, directory, conditional Git branch, and a shuffled session-stable tip
42
42
  - Cohesive dark theme with coral accents and neutral tool-call containers across pending, success, and error states
43
43
  - Animated orange 12-frame activity glyph loop at 120 ms per frame, with orange shuffled Claude-adjacent verbs changing every 2.5 seconds, a gray `(esc to interrupt · thinking)` status with bold `esc`, and a quiet hidden-thinking label
44
- - Framed multiline editor with Shift+Enter support and live command-blue highlighting for recognized slash command prefixes
44
+ - Framed multiline editor with Shift+Enter support and slash-command autocomplete; KillerOS preserves an editor factory configured by another extension
45
45
  - Responsive footer with polished model/provider identity, plain-language context, and active goal state remaining; reasoning, Git branch, elapsed time, cost, and path cut down by available width
46
46
  - Pi-owned context compaction with active goals continuing from Pi's settled boundary after manual, threshold, and overflow compaction
47
47
  - Optional completion sounds after successful or failed settled requests, excluding manual aborts
48
48
  - `/variants` selector and direct reasoning-level arguments
49
49
  - Codex-style `/goal` with an interactive status/action panel, durable objectives, pause, resume, edit, confirmed panel clearing, automatic continuation, and explicit completion
50
- - Claude Code-style `/init` that scans the repository and generates a concise root `AGENTS.md` without setup questions
50
+ - Automatic `/init` guideline synthesis with a frozen safe evidence map, protected existing policy, and the four packaged behavioral sections adapted from `writing-great-guidelines`
51
51
  - `question` tool with height-bounded option windows, configured Pi keybindings, live option/input progress, proposal previews, custom answers, history, cancellation, and compact expandable transcript rendering
52
52
  - Mid-prompt slash completion with current Pi `0.82.1` commands, extensions, prompts, and skills; paths, URLs, and invalid commands remain plain text
53
- - `/clear` for a confirmed new session, plus `/exit` for graceful shutdown
54
- - Concise system-prompt guidance without modifying completed assistant messages
53
+ - Goal-aware `/clear` that confirms, aborts active work, waits for settlement, and starts a new session, plus `/exit` for graceful shutdown
54
+ - Concise system-prompt guidance and supported native concise defaults that preserve explicit provider settings
55
55
 
56
56
  ## Commands
57
57
 
@@ -72,7 +72,9 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
72
72
 
73
73
  `/goal` requires a saved session in TUI or RPC mode. Goal state is stored in versioned session entries on the active branch and restored after reload, resume, fork, or tree navigation. Active goals inject their unchanged objective every turn and continue one settled turn at a time. The model must use KillerOS’s private goal tool to mark verified completion or a blocker repeated across at least three goal turns; final prose alone does not end the loop. Aborted turns, provider failures, and continuation failures pause safely. Replacing unfinished work requires confirmation, and `/goal edit` requires TUI mode.
74
74
 
75
- `/init` builds a bounded project map, reads high-value manifests, documentation, and CI configuration, and lets the active model inspect additional implementation files before generating root `AGENTS.md`. Existing `AGENTS.md` and `CLAUDE.md` content is intentionally excluded so stale guidance is not inherited. The command asks no setup questions, starts no second model process, writes only `AGENTS.md`, and reloads Pi resources when finished.
75
+ `/init` freezes a safe project-file map and exposes only dedicated read and list operations while it generates root `AGENTS.md`. Git-ignored files, known secret paths, private-key formats, other guidance, dependencies, links, non-regular files, and files outside that map are unavailable. Existing root `AGENTS.md` is separate protected policy: compatible rules are preserved, a real policy conflict leaves it unchanged with a reason, and any concurrent target change aborts installation without replacing the newer file.
76
+
77
+ The generated file uses the four behavioral sections adapted from `writing-great-guidelines`; no external skill installation is required. `/init` asks no setup questions, starts no second model process, writes no other file, and reloads Pi resources only after a successful write.
76
78
 
77
79
  Supported reasoning levels are `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. KillerOS limits choices to levels supported by the current model.
78
80
 
@@ -90,7 +92,7 @@ Manual `/compact` aborts the current goal turn before summarization, so KillerOS
90
92
 
91
93
  For trusted projects, KillerOS loads `AGENTS.local.md` after Pi's shared repository context. A one-line `@path` or `@~/path` file imports personal guidance from another location.
92
94
 
93
- Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supported event keys are `tool_call`, `tool_result`, and `agent_settled`; matchers are JavaScript regular expressions over Pi tool names. Hook commands run from the repository root with `KILLEROS_EVENT`, `KILLEROS_TOOL`, and `KILLEROS_PAYLOAD` environment variables. Failed `tool_call` hooks block the tool, while later-event failures notify the user.
95
+ Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supported event keys are `tool_call`, `tool_result`, and `agent_settled`; matchers are JavaScript regular expressions over Pi tool names. Hook commands run from the repository root with `KILLEROS_EVENT`, `KILLEROS_TOOL`, and `KILLEROS_PAYLOAD` environment variables. Failed `tool_call` hooks block the tool, while later-event failures notify the user. Aborting the parent request stops the hook process tree with bounded graceful and forced cleanup without reporting cancellation as a hook failure.
94
96
 
95
97
  ## Behavior by mode
96
98
 
@@ -8,8 +8,9 @@ async function confirmNewSession(ctx: ExtensionCommandContext): Promise<boolean>
8
8
 
9
9
  export function registerAliases(pi: ExtensionAPI): void {
10
10
  const startNewSession = async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {
11
- await ctx.waitForIdle();
12
11
  if (!await confirmNewSession(ctx)) return;
12
+ if (!ctx.isIdle()) ctx.abort();
13
+ await ctx.waitForIdle();
13
14
  await ctx.newSession();
14
15
  };
15
16
  pi.registerCommand("clear", { description: "Start a new session after confirmation", handler: startNewSession });
@@ -54,13 +55,6 @@ const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
54
55
  { name: "quit", description: "Quit Pi" },
55
56
  ];
56
57
 
57
- export function availableCommandNames(pi: ExtensionAPI): ReadonlySet<string> {
58
- return new Set([
59
- ...BUILTIN_COMMANDS.map((command) => command.name),
60
- ...pi.getCommands().map((command) => command.name),
61
- ]);
62
- }
63
-
64
58
  const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
65
59
  goal: "/goal [objective|clear|edit|pause|resume]",
66
60
  variants: "/variants [level]",
@@ -41,22 +41,26 @@ function applyConciseModelSettings(payload: unknown, api: unknown, modelId: unkn
41
41
  const supportsVerbosity = api === "openai-codex-responses"
42
42
  || api === "openai-responses" && typeof modelId === "string" && /^gpt-5(?:[.-]|$)/u.test(modelId);
43
43
  let updated = payload;
44
- if (supportsVerbosity) {
45
- updated = {
46
- ...updated,
47
- text: { ...(isRecord(payload.text) ? payload.text : {}), verbosity: "low" },
48
- };
44
+ const text = isRecord(payload.text) ? payload.text : undefined;
45
+ if (supportsVerbosity && !Object.hasOwn(text ?? {}, "verbosity")) {
46
+ updated = { ...updated, text: { ...(text ?? {}), verbosity: "low" } };
49
47
  }
50
- if (supportsSummary && isRecord(payload.reasoning)) {
51
- updated = { ...updated, reasoning: { ...payload.reasoning, summary: "concise" } };
48
+ const reasoning = isRecord(payload.reasoning) ? payload.reasoning : undefined;
49
+ if (supportsSummary && reasoning && !Object.hasOwn(reasoning, "summary")) {
50
+ updated = { ...updated, reasoning: { ...reasoning, summary: "concise" } };
52
51
  }
53
52
  return updated;
54
53
  }
55
54
 
56
- export function isConcisedEnabled(): boolean {
55
+ export function isConciseEnabled(): boolean {
57
56
  return true;
58
57
  }
59
58
 
59
+ /** @deprecated Use isConciseEnabled instead. */
60
+ export function isConcisedEnabled(): boolean {
61
+ return isConciseEnabled();
62
+ }
63
+
60
64
  export function registerConcisePrompt(pi: ExtensionAPI): void {
61
65
  pi.on("before_agent_start", (event) => ({
62
66
  systemPrompt: `${event.systemPrompt}\n\n${CONCISE_SYSTEM_PROMPT}`,
package/killeros/goals.ts CHANGED
@@ -3,7 +3,6 @@ import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import { MAX_NODE_TIMER_MS } from "./limits.ts";
5
5
  import { BoundedText } from "./bounded-text.ts";
6
- import { CONCISE_SYSTEM_PROMPT } from "./concise.ts";
7
6
  import { formatTime, formatTokens } from "./display.ts";
8
7
  import { reportError } from "./errors.ts";
9
8
  import { resolvePersonalInstructions } from "./personal-instructions.ts";
@@ -355,7 +354,6 @@ function goalContinuationMessage(state: GoalState, ctx: ExtensionContext): strin
355
354
  sections.push(`<personal_instructions source=${JSON.stringify(personal.source)}>\n${personal.content}\n</personal_instructions>`);
356
355
  }
357
356
  }
358
- sections.push(CONCISE_SYSTEM_PROMPT);
359
357
  return sections.join("\n\n");
360
358
  }
361
359
 
package/killeros/hooks.ts CHANGED
@@ -22,6 +22,7 @@ interface HookExecutionResult {
22
22
  stdout: string;
23
23
  stderr: string;
24
24
  timedOut: boolean;
25
+ cancelled: boolean;
25
26
  exitUnconfirmed: boolean;
26
27
  }
27
28
 
@@ -109,7 +110,17 @@ function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean):
109
110
  }
110
111
  }
111
112
 
112
- export function executeHook(command: string, cwd: string, environment: Record<string, string>, timeoutMs = 30_000, spawnProcess: typeof spawn = spawn): Promise<HookExecutionResult> {
113
+ export function executeHook(
114
+ command: string,
115
+ cwd: string,
116
+ environment: Record<string, string>,
117
+ timeoutMs = 30_000,
118
+ spawnProcess: typeof spawn = spawn,
119
+ signal?: AbortSignal,
120
+ ): Promise<HookExecutionResult> {
121
+ if (signal?.aborted) {
122
+ return Promise.resolve({ code: 130, stdout: "", stderr: "", timedOut: false, cancelled: true, exitUnconfirmed: false });
123
+ }
113
124
  return new Promise((resolve) => {
114
125
  const child = spawnProcess(command, {
115
126
  cwd,
@@ -122,36 +133,48 @@ export function executeHook(command: string, cwd: string, environment: Record<st
122
133
  let stdout = "";
123
134
  let stderr = "";
124
135
  let completed = false;
125
- let timedOut = false;
126
- let exitUnconfirmed = false;
136
+ let termination: "timeout" | "cancelled" | undefined;
127
137
  let timer: NodeJS.Timeout | undefined;
128
138
  let forceTimer: NodeJS.Timeout | undefined;
129
139
  let settleTimer: NodeJS.Timeout | undefined;
130
- const finish = (code: number, unconfirmed = false): void => {
140
+ const finish = (code: number, exitUnconfirmed = false): void => {
131
141
  if (completed) return;
132
142
  completed = true;
133
- exitUnconfirmed = unconfirmed;
134
143
  if (timer) clearTimeout(timer);
135
144
  if (forceTimer) clearTimeout(forceTimer);
136
145
  if (settleTimer) clearTimeout(settleTimer);
137
- resolve({ code, stdout, stderr, timedOut, exitUnconfirmed });
146
+ signal?.removeEventListener("abort", abort);
147
+ resolve({
148
+ code,
149
+ stdout,
150
+ stderr,
151
+ timedOut: termination === "timeout",
152
+ cancelled: termination === "cancelled",
153
+ exitUnconfirmed,
154
+ });
138
155
  };
139
- child.stdout.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); });
140
- child.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); });
141
- child.on("error", (error) => {
142
- stderr = appendBounded(stderr, error.message);
143
- finish(timedOut ? 124 : 1);
144
- });
145
- child.once("close", (code) => finish(timedOut ? 124 : code ?? 1));
146
- timer = setTimeout(() => {
147
- timedOut = true;
156
+ const terminationCode = (): number => termination === "cancelled" ? 130 : 124;
157
+ const beginTermination = (reason: "timeout" | "cancelled"): void => {
158
+ if (completed || termination) return;
159
+ termination = reason;
148
160
  terminateHookProcess(child, false);
149
161
  forceTimer = setTimeout(() => {
150
162
  if (completed) return;
151
163
  terminateHookProcess(child, true);
152
- settleTimer = setTimeout(() => finish(124, true), 1_000);
164
+ settleTimer = setTimeout(() => finish(terminationCode(), true), 1_000);
153
165
  }, 1_000);
154
- }, Math.max(1_000, Math.min(timeoutMs, 300_000)));
166
+ };
167
+ const abort = (): void => beginTermination("cancelled");
168
+ signal?.addEventListener("abort", abort, { once: true });
169
+ if (signal?.aborted) beginTermination("cancelled");
170
+ child.stdout.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); });
171
+ child.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); });
172
+ child.on("error", (error) => {
173
+ stderr = appendBounded(stderr, error.message);
174
+ finish(termination ? terminationCode() : 1);
175
+ });
176
+ child.once("close", (code) => finish(termination ? terminationCode() : code ?? 1));
177
+ timer = setTimeout(() => beginTermination("timeout"), Math.max(1_000, Math.min(timeoutMs, 300_000)));
155
178
  });
156
179
  }
157
180
 
@@ -180,7 +203,10 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
180
203
  ctx.cwd,
181
204
  hookEnvironment("tool_call", event.toolName, event.input),
182
205
  hook.timeoutMs,
206
+ spawn,
207
+ ctx.signal,
183
208
  );
209
+ if (result.cancelled) return { block: true, reason: "Hook cancelled because the parent request was aborted" };
184
210
  if (result.code !== 0) {
185
211
  const reason = hookFailureMessage(hook, result);
186
212
  ctx.ui.notify(reason, "error");
@@ -200,7 +226,10 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
200
226
  isError: event.isError,
201
227
  }),
202
228
  hook.timeoutMs,
229
+ spawn,
230
+ ctx.signal,
203
231
  );
232
+ if (result.cancelled) break;
204
233
  if (result.code !== 0) ctx.ui.notify(hookFailureMessage(hook, result), "error");
205
234
  }
206
235
  });
@@ -212,7 +241,10 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
212
241
  ctx.cwd,
213
242
  hookEnvironment("agent_settled"),
214
243
  hook.timeoutMs,
244
+ spawn,
245
+ ctx.signal,
215
246
  );
247
+ if (result.cancelled) break;
216
248
  if (result.code !== 0) ctx.ui.notify(hookFailureMessage(hook, result), "error");
217
249
  }
218
250
  });
@@ -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
+ }