killeros 2.0.22 → 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
  }
@@ -265,7 +306,7 @@ export function executeHook(
265
306
  command,
266
307
  cwd,
267
308
  environment,
268
- timeoutMs = 30_000,
309
+ timeoutMs = HOOK_TIMEOUT_DEFAULT_MS,
269
310
  spawnProcess = spawn,
270
311
  signal,
271
312
  } = options;
@@ -331,18 +372,19 @@ export function executeHook(
331
372
  }, 2_000);
332
373
  return;
333
374
  }
334
- terminateHookProcess(child, false);
335
375
  forceTimer = setTimeout(() => {
336
376
  if (completed) return;
337
377
  terminateHookProcess(child, true);
338
378
  settleTimer = setTimeout(() => finish(terminationCode(), true), 1_000);
339
379
  }, 1_000);
380
+ terminateHookProcess(child, false);
340
381
  };
341
382
  const abort = (): void => beginTermination("cancelled");
342
- signal?.addEventListener("abort", abort, { once: true });
343
- if (signal?.aborted) beginTermination("cancelled");
344
383
  child.stdout.on("data", (chunk) => appendBounded(stdout, chunk));
345
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);
346
388
  child.on("error", (error) => {
347
389
  appendBounded(stderr, error.message);
348
390
  if (!windowsCleanupPending) finish(termination ? terminationCode() : 1);
@@ -350,12 +392,23 @@ export function executeHook(
350
392
  child.once("close", (code) => {
351
393
  if (!windowsCleanupPending) finish(termination ? terminationCode() : code ?? 1);
352
394
  });
353
- 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");
354
402
  });
355
403
  }
356
404
 
357
405
  function serializeHookPayload(payload: unknown): string {
358
- 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
+ }
359
412
  if (serialized.length <= HOOK_PAYLOAD_LIMIT) return serialized;
360
413
  const previewLength = Math.floor((HOOK_PAYLOAD_LIMIT - 64) / 2);
361
414
  return JSON.stringify({ truncated: true, preview: serialized.slice(0, previewLength) });
@@ -374,9 +427,51 @@ function hookFailureMessage(result: HookExecutionResult): string {
374
427
  return safeTerminalText(`Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}\n${detail}`);
375
428
  }
376
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
+
377
472
  export function registerLifecycleHooks(pi: ExtensionAPI): void {
378
473
  let config: KillerosHookConfig = {};
379
- pi.on("session_start", (_event, ctx) => { config = loadKillerosHooks(ctx); });
474
+ pi.on("session_start", (_event, ctx) => { config = loadKillerosConfig(ctx); });
380
475
 
381
476
  pi.on("tool_call", async (event, ctx) => {
382
477
  for (const hook of config.hooks?.tool_call ?? []) {
@@ -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 & (