killeros 2.0.21 → 2.1.22

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.
@@ -2,9 +2,12 @@ import { createHash } from "node:crypto";
2
2
  import type { Stats } from "node:fs";
3
3
  import { lstat, open, type FileHandle } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import type { GoalBlockerAudit, GoalFileBaseline, GoalFileVerification, GoalState, GoalStateCommon, GoalStatus } from "./runtime.ts";
5
+ import type { GoalBlockerAudit, GoalCompletionCheck, GoalFileBaseline, GoalFileVerification, GoalState, GoalStateCommon, GoalStatus } from "./runtime.ts";
6
6
 
7
+ export const DEFAULT_GOAL_MAX_TURNS = 20;
7
8
  export const GOAL_OBJECTIVE_LIMIT = 4_000;
9
+ export const GOAL_MAX_TURNS = 10_000;
10
+ export const GOAL_CHECK_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
8
11
  export const GOAL_VERSION = 1;
9
12
  const FILE_HASH_CHUNK_SIZE = 64 * 1024;
10
13
  export const FILE_HASH_LIMIT = 64 * 1024 * 1024;
@@ -25,6 +28,20 @@ function finiteNonNegative(value: unknown): value is number {
25
28
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
26
29
  }
27
30
 
31
+ function safeNonNegativeInteger(value: unknown): value is number {
32
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
33
+ }
34
+
35
+ function incrementableNonNegativeInteger(value: unknown): value is number {
36
+ return safeNonNegativeInteger(value) && value < Number.MAX_SAFE_INTEGER;
37
+ }
38
+
39
+ function addGoalMilliseconds(accumulated: number, interval: number): number {
40
+ const total = accumulated + interval;
41
+ if (!safeNonNegativeInteger(total)) throw new Error("Goal active duration exceeds the safe integer range");
42
+ return total;
43
+ }
44
+
28
45
  function isUnknownRecord(value: unknown): value is Record<string, unknown> {
29
46
  return typeof value === "object" && value !== null && !Array.isArray(value);
30
47
  }
@@ -56,12 +73,27 @@ function isGoalFileVerification(value: unknown): value is GoalFileVerification {
56
73
  && isGoalFileBaseline(value.baseline);
57
74
  }
58
75
 
76
+ function isGoalCompletionCheck(value: unknown): value is GoalCompletionCheck {
77
+ return isUnknownRecord(value)
78
+ && value.kind === "named-command"
79
+ && typeof value.name === "string"
80
+ && GOAL_CHECK_NAME_PATTERN.test(value.name)
81
+ && typeof value.configHash === "string"
82
+ && /^[a-f0-9]{64}$/u.test(value.configHash);
83
+ }
84
+
85
+ function isMaxTurns(value: unknown): value is number {
86
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= GOAL_MAX_TURNS;
87
+ }
88
+
59
89
  function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
60
90
  if (!isUnknownRecord(value)
61
91
  || typeof value.key !== "string"
62
92
  || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.key)
63
93
  || typeof value.streak !== "number" || !Number.isInteger(value.streak) || value.streak < 1 || value.streak > 3
64
- || typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns) {
94
+ || typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns
95
+ || value.evidence !== undefined && (typeof value.evidence !== "string"
96
+ || value.evidence !== value.evidence.trim() || !value.evidence || value.evidence.length > 2_000)) {
65
97
  return false;
66
98
  }
67
99
  if (status === "complete") return false;
@@ -86,21 +118,24 @@ export function parseGoalState(value: unknown): GoalState | undefined {
86
118
  resumeAfterManualCompaction,
87
119
  blockerAudit,
88
120
  verification,
121
+ completionCheck,
122
+ maxTurns,
89
123
  } = value;
90
124
  if (version !== GOAL_VERSION
91
- || typeof revision !== "number" || !Number.isInteger(revision) || revision < 1
125
+ || !incrementableNonNegativeInteger(revision) || revision < 1
92
126
  || typeof objective !== "string" || !objective.trim() || [...objective].length > GOAL_OBJECTIVE_LIMIT
93
127
  || !isGoalStatus(status)
94
- || !finiteNonNegative(createdAt)
95
- || !finiteNonNegative(updatedAt)
96
- || !finiteNonNegative(activeMilliseconds)
97
- || typeof turns !== "number" || !Number.isInteger(turns) || turns < 0
128
+ || !safeNonNegativeInteger(createdAt)
129
+ || !safeNonNegativeInteger(updatedAt)
130
+ || !incrementableNonNegativeInteger(activeMilliseconds)
131
+ || !incrementableNonNegativeInteger(turns)
98
132
  || blockedAuditStartTurn !== undefined
99
- && (typeof blockedAuditStartTurn !== "number" || !Number.isInteger(blockedAuditStartTurn)
100
- || blockedAuditStartTurn < 0 || blockedAuditStartTurn > turns)
101
- || !finiteNonNegative(baselineTokens)
133
+ && (!safeNonNegativeInteger(blockedAuditStartTurn) || blockedAuditStartTurn > turns)
134
+ || !safeNonNegativeInteger(baselineTokens)
102
135
  || result !== undefined && typeof result !== "string"
103
136
  || verification !== undefined && !isGoalFileVerification(verification)
137
+ || completionCheck !== undefined && !isGoalCompletionCheck(completionCheck)
138
+ || maxTurns !== undefined && !isMaxTurns(maxTurns)
104
139
  || resumeAfterManualCompaction !== undefined && resumeAfterManualCompaction !== true
105
140
  || blockerAudit !== undefined && !isGoalBlockerAudit(blockerAudit, turns, status)) {
106
141
  return undefined;
@@ -117,10 +152,12 @@ export function parseGoalState(value: unknown): GoalState | undefined {
117
152
  blockedAuditStartTurn: blockedAuditStartTurn ?? 0,
118
153
  baselineTokens,
119
154
  ...(verification === undefined ? {} : { verification }),
155
+ ...(completionCheck === undefined ? {} : { completionCheck }),
156
+ ...(maxTurns === undefined ? {} : { maxTurns }),
120
157
  };
121
158
  switch (status) {
122
159
  case "active":
123
- if (!finiteNonNegative(activeStartedAt) || resumeAfterManualCompaction !== undefined) return undefined;
160
+ if (!safeNonNegativeInteger(activeStartedAt) || resumeAfterManualCompaction !== undefined) return undefined;
124
161
  return {
125
162
  ...common,
126
163
  status,
@@ -250,7 +287,7 @@ export function validateGoalObjective(input: string): string | undefined {
250
287
 
251
288
  export function goalElapsedMilliseconds(state: GoalState, now: number): number {
252
289
  const activeInterval = state.status === "active" ? Math.max(0, now - state.activeStartedAt) : 0;
253
- return state.activeMilliseconds + activeInterval;
290
+ return addGoalMilliseconds(state.activeMilliseconds, activeInterval);
254
291
  }
255
292
 
256
293
  export function commonGoalState(state: GoalState): GoalStateCommon {
@@ -265,13 +302,15 @@ export function commonGoalState(state: GoalState): GoalStateCommon {
265
302
  blockedAuditStartTurn: state.blockedAuditStartTurn,
266
303
  baselineTokens: state.baselineTokens,
267
304
  ...(state.verification === undefined ? {} : { verification: state.verification }),
305
+ ...(state.completionCheck === undefined ? {} : { completionCheck: state.completionCheck }),
306
+ ...(state.maxTurns === undefined ? {} : { maxTurns: state.maxTurns }),
268
307
  };
269
308
  }
270
309
 
271
310
  export function stopGoalClock(state: GoalState, now: number): GoalStateCommon {
272
311
  const common = commonGoalState(state);
273
312
  return state.status === "active"
274
- ? { ...common, activeMilliseconds: common.activeMilliseconds + Math.max(0, now - state.activeStartedAt) }
313
+ ? { ...common, activeMilliseconds: addGoalMilliseconds(common.activeMilliseconds, Math.max(0, now - state.activeStartedAt)) }
275
314
  : common;
276
315
  }
277
316
 
@@ -280,6 +319,7 @@ export function createNewGoalState(
280
319
  baselineTokens: number,
281
320
  verification: GoalFileVerification | undefined,
282
321
  now: number,
322
+ controls: { completionCheck?: GoalCompletionCheck; maxTurns?: number } = {},
283
323
  ): GoalState {
284
324
  return {
285
325
  version: GOAL_VERSION,
@@ -294,6 +334,8 @@ export function createNewGoalState(
294
334
  blockedAuditStartTurn: 0,
295
335
  baselineTokens,
296
336
  ...(verification === undefined ? {} : { verification }),
337
+ ...(controls.completionCheck === undefined ? {} : { completionCheck: controls.completionCheck }),
338
+ ...(controls.maxTurns === undefined ? {} : { maxTurns: controls.maxTurns }),
297
339
  };
298
340
  }
299
341
 
@@ -317,6 +359,46 @@ export function editGoalState(
317
359
  };
318
360
  }
319
361
 
362
+ export function updateGoalControlsState(
363
+ state: Exclude<GoalState, { status: "complete" }>,
364
+ controls: { completionCheck?: GoalCompletionCheck; maxTurns?: number },
365
+ now: number,
366
+ ): GoalState {
367
+ const stopped = stopGoalClock(state, now);
368
+ const { completionCheck: _completionCheck, maxTurns: _maxTurns, ...common } = stopped;
369
+ const nextCommon: GoalStateCommon = {
370
+ ...common,
371
+ revision: common.revision + 1,
372
+ updatedAt: now,
373
+ ...(controls.completionCheck === undefined ? {} : { completionCheck: controls.completionCheck }),
374
+ ...(controls.maxTurns === undefined ? {} : { maxTurns: controls.maxTurns }),
375
+ };
376
+ if (state.status === "active") {
377
+ return {
378
+ ...nextCommon,
379
+ status: "active",
380
+ activeStartedAt: now,
381
+ ...(state.result === undefined ? {} : { result: state.result }),
382
+ ...(state.blockerAudit === undefined ? {} : { blockerAudit: state.blockerAudit }),
383
+ };
384
+ }
385
+ if (state.status === "paused") {
386
+ return {
387
+ ...nextCommon,
388
+ status: "paused",
389
+ ...(state.result === undefined ? {} : { result: state.result }),
390
+ ...(state.blockerAudit === undefined ? {} : { blockerAudit: state.blockerAudit }),
391
+ ...(state.resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction: true }),
392
+ };
393
+ }
394
+ return {
395
+ ...nextCommon,
396
+ status: "blocked",
397
+ result: state.result,
398
+ ...(state.blockerAudit === undefined ? {} : { blockerAudit: state.blockerAudit }),
399
+ };
400
+ }
401
+
320
402
  export function beginGoalTurnState(
321
403
  current: Extract<GoalState, { status: "active" }>,
322
404
  now: number,
package/killeros/hooks.ts CHANGED
@@ -1,12 +1,16 @@
1
+ import { createHash } from "node:crypto";
1
2
  import {
2
3
  spawn,
3
4
  type SpawnOptionsWithStdioTuple,
4
5
  } from "node:child_process";
6
+ import { EventEmitter } from "node:events";
5
7
  import { closeSync, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
6
8
  import path from "node:path";
7
9
  import { StringDecoder } from "node:string_decoder";
8
10
  import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
9
11
  import { errorMessage, reportError } from "./errors.ts";
12
+ import { GOAL_CHECK_NAME_PATTERN } from "./goal-state.ts";
13
+ import type { GoalCompletionCheck } from "./runtime.ts";
10
14
  import { safeTerminalText } from "./safe-terminal-text.ts";
11
15
 
12
16
  type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
@@ -17,8 +21,14 @@ interface KillerosHook {
17
21
  timeoutMs?: number;
18
22
  }
19
23
 
24
+ interface KillerosGoalCheck {
25
+ command: string;
26
+ timeoutMs?: number;
27
+ }
28
+
20
29
  interface KillerosHookConfig {
21
30
  hooks?: Partial<Record<KillerosHookEvent, KillerosHook[]>>;
31
+ goalChecks?: Record<string, KillerosGoalCheck>;
22
32
  }
23
33
 
24
34
  interface HookExecutionResult {
@@ -58,7 +68,10 @@ const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "
58
68
  const HOOK_CONFIG_LIMIT = 64 * 1024;
59
69
  const HOOK_OUTPUT_LIMIT = 16 * 1024;
60
70
  const HOOK_PAYLOAD_LIMIT = 8_000;
71
+ const HOOK_TIMEOUT_DEFAULT_MS = 30_000;
61
72
  const HOOK_TIMEOUT_MAX_MS = 300_000;
73
+ const GOAL_CHECK_LIMIT = 32;
74
+ const GOAL_CHECK_COMMAND_LIMIT = 8_000;
62
75
 
63
76
  // Reads executable project configuration through a bounded, project-local file descriptor.
64
77
  function readHookConfig(configPath: string, projectRoot: string): string {
@@ -109,11 +122,12 @@ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
109
122
  return typeof value === "object" && value !== null && !Array.isArray(value);
110
123
  }
111
124
 
112
- function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
125
+ function loadKillerosConfig(ctx: ExtensionContext, strictGoalChecks = false): KillerosHookConfig {
113
126
  const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
114
127
  if (!existsSync(configPath)) return {};
115
128
  const displayPath = safeTerminalText(configPath).replaceAll("\n", "");
116
129
  if (!ctx.isProjectTrusted()) {
130
+ if (strictGoalChecks) throw new Error("Goal completion checks require a trusted project");
117
131
  ctx.ui.notify(`Ignored untrusted project hooks in ${displayPath}`, "warning");
118
132
  return {};
119
133
  }
@@ -166,8 +180,35 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
166
180
  }
167
181
  hooks[event] = accepted;
168
182
  }
169
- return { hooks };
183
+
184
+ let goalChecks: Record<string, KillerosGoalCheck> | undefined;
185
+ try {
186
+ const candidates = parsed.goalChecks;
187
+ if (candidates !== undefined) {
188
+ if (!isUnknownRecord(candidates)) throw new Error("goalChecks must contain a JSON object");
189
+ const entries = Object.entries(candidates);
190
+ if (entries.length > GOAL_CHECK_LIMIT) throw new Error(`goalChecks may contain at most ${GOAL_CHECK_LIMIT} checks`);
191
+ goalChecks = {};
192
+ for (const [name, candidate] of entries) {
193
+ if (!GOAL_CHECK_NAME_PATTERN.test(name)) throw new Error(`Invalid goal check name: ${JSON.stringify(name)}`);
194
+ if (!isUnknownRecord(candidate)) throw new Error(`Goal check ${name} must contain a JSON object`);
195
+ const { command, timeoutMs } = candidate;
196
+ if (typeof command !== "string" || command.trim().length < 1 || command.trim().length > GOAL_CHECK_COMMAND_LIMIT) {
197
+ throw new Error(`Goal check ${name} command must contain 1 to ${GOAL_CHECK_COMMAND_LIMIT} characters`);
198
+ }
199
+ if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > HOOK_TIMEOUT_MAX_MS)) {
200
+ throw new Error(`Goal check ${name} timeoutMs must be an integer from 1 to ${HOOK_TIMEOUT_MAX_MS}`);
201
+ }
202
+ goalChecks[name] = { command: command.trim(), ...(timeoutMs === undefined ? {} : { timeoutMs }) };
203
+ }
204
+ }
205
+ } catch (error) {
206
+ if (strictGoalChecks) throw error;
207
+ reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json goalChecks`, error);
208
+ }
209
+ return { hooks, ...(goalChecks === undefined ? {} : { goalChecks }) };
170
210
  } catch (error) {
211
+ if (strictGoalChecks) throw error;
171
212
  reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json`, error);
172
213
  return {};
173
214
  }
@@ -197,16 +238,27 @@ function appendBounded(output: HookOutputBuffer, chunk: Buffer | string): void {
197
238
  output.text += output.decoder.write(captured);
198
239
  }
199
240
 
241
+ /** Terminates a Windows hook tree without depending on the caller's PATH. */
242
+ function terminateWindowsHookTree(child: HookChildProcess): Promise<boolean> {
243
+ return new Promise((resolve) => {
244
+ try {
245
+ const taskkill = process.env.SystemRoot
246
+ ? path.join(process.env.SystemRoot, "System32", "taskkill.exe")
247
+ : "taskkill";
248
+ const killer = spawn(taskkill, ["/pid", String(child.pid), "/T", "/F"], {
249
+ shell: false,
250
+ stdio: "ignore",
251
+ windowsHide: true,
252
+ });
253
+ killer.once("error", () => resolve(false));
254
+ killer.once("close", (code) => resolve(code === 0));
255
+ } catch {
256
+ resolve(false);
257
+ }
258
+ });
259
+ }
260
+
200
261
  function terminateHookProcess(child: HookChildProcess, force: boolean): void {
201
- if (process.platform === "win32" && force && child.pid) {
202
- const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
203
- shell: false,
204
- stdio: "ignore",
205
- windowsHide: true,
206
- });
207
- killer.unref();
208
- return;
209
- }
210
262
  if (process.platform !== "win32" && child.pid) {
211
263
  try {
212
264
  process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
@@ -254,7 +306,7 @@ export function executeHook(
254
306
  command,
255
307
  cwd,
256
308
  environment,
257
- timeoutMs = 30_000,
309
+ timeoutMs = HOOK_TIMEOUT_DEFAULT_MS,
258
310
  spawnProcess = spawn,
259
311
  signal,
260
312
  } = options;
@@ -282,6 +334,7 @@ export function executeHook(
282
334
  let timer: NodeJS.Timeout | undefined;
283
335
  let forceTimer: NodeJS.Timeout | undefined;
284
336
  let settleTimer: NodeJS.Timeout | undefined;
337
+ let windowsCleanupPending = false;
285
338
  const finish = (code: number, exitUnconfirmed = false): void => {
286
339
  if (completed) return;
287
340
  completed = true;
@@ -304,29 +357,58 @@ export function executeHook(
304
357
  const beginTermination = (reason: "timeout" | "cancelled"): void => {
305
358
  if (completed || termination) return;
306
359
  termination = reason;
307
- terminateHookProcess(child, false);
360
+ if (process.platform === "win32" && child.pid) {
361
+ windowsCleanupPending = true;
362
+ void terminateWindowsHookTree(child).then((confirmed) => {
363
+ if (completed) return;
364
+ if (!confirmed) terminateHookProcess(child, true);
365
+ finish(terminationCode(), !confirmed);
366
+ windowsCleanupPending = false;
367
+ });
368
+ settleTimer = setTimeout(() => {
369
+ terminateHookProcess(child, true);
370
+ finish(terminationCode(), true);
371
+ windowsCleanupPending = false;
372
+ }, 2_000);
373
+ return;
374
+ }
308
375
  forceTimer = setTimeout(() => {
309
376
  if (completed) return;
310
377
  terminateHookProcess(child, true);
311
378
  settleTimer = setTimeout(() => finish(terminationCode(), true), 1_000);
312
379
  }, 1_000);
380
+ terminateHookProcess(child, false);
313
381
  };
314
382
  const abort = (): void => beginTermination("cancelled");
315
- signal?.addEventListener("abort", abort, { once: true });
316
- if (signal?.aborted) beginTermination("cancelled");
317
383
  child.stdout.on("data", (chunk) => appendBounded(stdout, chunk));
318
384
  child.stderr.on("data", (chunk) => appendBounded(stderr, chunk));
385
+ const captureStreamError = (error: Error): void => appendBounded(stderr, error.message);
386
+ if (child.stdout instanceof EventEmitter) child.stdout.on("error", captureStreamError);
387
+ if (child.stderr instanceof EventEmitter) child.stderr.on("error", captureStreamError);
319
388
  child.on("error", (error) => {
320
389
  appendBounded(stderr, error.message);
321
- finish(termination ? terminationCode() : 1);
390
+ if (!windowsCleanupPending) finish(termination ? terminationCode() : 1);
391
+ });
392
+ child.once("close", (code) => {
393
+ if (!windowsCleanupPending) finish(termination ? terminationCode() : code ?? 1);
322
394
  });
323
- child.once("close", (code) => finish(termination ? terminationCode() : code ?? 1));
324
- timer = setTimeout(() => beginTermination("timeout"), Math.max(1, Math.min(timeoutMs, HOOK_TIMEOUT_MAX_MS)));
395
+ if (completed) return;
396
+ const boundedTimeoutMs = Number.isNaN(timeoutMs)
397
+ ? HOOK_TIMEOUT_DEFAULT_MS
398
+ : Math.max(1, Math.min(timeoutMs, HOOK_TIMEOUT_MAX_MS));
399
+ timer = setTimeout(() => beginTermination("timeout"), boundedTimeoutMs);
400
+ signal?.addEventListener("abort", abort, { once: true });
401
+ if (signal?.aborted) beginTermination("cancelled");
325
402
  });
326
403
  }
327
404
 
328
405
  function serializeHookPayload(payload: unknown): string {
329
- const serialized = JSON.stringify(payload) ?? "null";
406
+ let serialized: string;
407
+ try {
408
+ serialized = JSON.stringify(payload) ?? "null";
409
+ } catch {
410
+ return JSON.stringify({ serializationError: true });
411
+ }
330
412
  if (serialized.length <= HOOK_PAYLOAD_LIMIT) return serialized;
331
413
  const previewLength = Math.floor((HOOK_PAYLOAD_LIMIT - 64) / 2);
332
414
  return JSON.stringify({ truncated: true, preview: serialized.slice(0, previewLength) });
@@ -345,9 +427,51 @@ function hookFailureMessage(result: HookExecutionResult): string {
345
427
  return safeTerminalText(`Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}\n${detail}`);
346
428
  }
347
429
 
430
+ function goalCheckHash(check: KillerosGoalCheck): string {
431
+ return createHash("sha256")
432
+ .update(JSON.stringify({ command: check.command, timeoutMs: check.timeoutMs ?? HOOK_TIMEOUT_DEFAULT_MS }))
433
+ .digest("hex");
434
+ }
435
+
436
+ /** Lists validated completion-check names without exposing their definitions. */
437
+ export function listGoalCompletionChecks(ctx: ExtensionContext): readonly string[] {
438
+ if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
439
+ return Object.keys(loadKillerosConfig(ctx, true).goalChecks ?? {}).sort();
440
+ }
441
+
442
+ export function resolveGoalCompletionCheck(ctx: ExtensionContext, name: string): GoalCompletionCheck {
443
+ if (!GOAL_CHECK_NAME_PATTERN.test(name)) throw new Error("Invalid goal completion check name");
444
+ if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
445
+ const check = loadKillerosConfig(ctx, true).goalChecks?.[name];
446
+ if (!check) throw new Error(`Unknown goal completion check: ${safeTerminalText(name)}`);
447
+ return { kind: "named-command", name, configHash: goalCheckHash(check) };
448
+ }
449
+
450
+ export async function runGoalCompletionCheck(
451
+ ctx: ExtensionContext,
452
+ bound: GoalCompletionCheck,
453
+ signal?: AbortSignal,
454
+ ): Promise<void> {
455
+ if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
456
+ const check = loadKillerosConfig(ctx, true).goalChecks?.[bound.name];
457
+ if (!check) throw new Error(`Unknown goal completion check: ${safeTerminalText(bound.name)}`);
458
+ if (goalCheckHash(check) !== bound.configHash) {
459
+ throw new Error(`Goal completion check ${safeTerminalText(bound.name)} changed; run /goal check ${safeTerminalText(bound.name)} to approve it`);
460
+ }
461
+ const result = await executeHook({
462
+ command: check.command,
463
+ cwd: ctx.cwd,
464
+ environment: { KILLEROS_EVENT: "goal_check", KILLEROS_GOAL_CHECK: bound.name },
465
+ timeoutMs: check.timeoutMs,
466
+ signal,
467
+ });
468
+ if (result.cancelled) throw new Error(`Goal completion check ${safeTerminalText(bound.name)} was cancelled`);
469
+ if (result.code !== 0) throw new Error(hookFailureMessage(result).replace(/^Hook failed/u, `Goal completion check ${safeTerminalText(bound.name)} failed`));
470
+ }
471
+
348
472
  export function registerLifecycleHooks(pi: ExtensionAPI): void {
349
473
  let config: KillerosHookConfig = {};
350
- pi.on("session_start", (_event, ctx) => { config = loadKillerosHooks(ctx); });
474
+ pi.on("session_start", (_event, ctx) => { config = loadKillerosConfig(ctx); });
351
475
 
352
476
  pi.on("tool_call", async (event, ctx) => {
353
477
  for (const hook of config.hooks?.tool_call ?? []) {
@@ -11,7 +11,12 @@ const PERSONAL_INSTRUCTIONS_LIMIT = 32 * 1024;
11
11
  function readBoundedText(filePath: string, limit = PERSONAL_INSTRUCTIONS_LIMIT): string | undefined {
12
12
  let descriptor: number | undefined;
13
13
  try {
14
+ const pathStat = lstatSync(filePath);
15
+ if (!pathStat.isFile() || pathStat.isSymbolicLink() || pathStat.nlink !== 1) return undefined;
14
16
  descriptor = openSync(filePath, "r");
17
+ const openedStat = fstatSync(descriptor);
18
+ if (!openedStat.isFile() || openedStat.nlink !== 1
19
+ || openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) return undefined;
15
20
  const buffer = Buffer.alloc(limit + 1);
16
21
  const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
17
22
  const decoder = new StringDecoder("utf8");
@@ -26,6 +26,7 @@ export interface GoalBlockerAudit {
26
26
  key: string;
27
27
  streak: number;
28
28
  lastTurn: number;
29
+ evidence?: string;
29
30
  }
30
31
 
31
32
  export type GoalFileBaseline =
@@ -38,6 +39,12 @@ export interface GoalFileVerification {
38
39
  baseline: GoalFileBaseline;
39
40
  }
40
41
 
42
+ export interface GoalCompletionCheck {
43
+ kind: "named-command";
44
+ name: string;
45
+ configHash: string;
46
+ }
47
+
41
48
  export interface GoalStateCommon {
42
49
  version: 1;
43
50
  revision: number;
@@ -49,6 +56,8 @@ export interface GoalStateCommon {
49
56
  blockedAuditStartTurn: number;
50
57
  baselineTokens: number;
51
58
  verification?: GoalFileVerification;
59
+ completionCheck?: GoalCompletionCheck;
60
+ maxTurns?: number;
52
61
  }
53
62
 
54
63
  export type GoalState = GoalStateCommon & (