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.
- package/CHANGELOG.md +23 -0
- package/Killeros.ts +5 -2
- package/README.md +25 -3
- package/killeros/auto-compaction.ts +56 -15
- package/killeros/change-receipt.ts +727 -0
- package/killeros/commands.ts +1 -1
- package/killeros/footer.ts +112 -21
- package/killeros/goal-command.ts +120 -0
- package/killeros/goal-history.ts +71 -0
- package/killeros/goal-interface.ts +599 -0
- package/killeros/goal-runtime.ts +349 -0
- package/killeros/goal-settlement.ts +261 -0
- package/killeros/goal-state.ts +95 -13
- package/killeros/hooks.ts +104 -9
- package/killeros/runtime.ts +9 -0
- package/killeros/worked-for.ts +271 -61
- package/package.json +3 -2
- package/killeros/goals.ts +0 -1022
package/killeros/goal-state.ts
CHANGED
|
@@ -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
|
-
||
|
|
125
|
+
|| !incrementableNonNegativeInteger(revision) || revision < 1
|
|
92
126
|
|| typeof objective !== "string" || !objective.trim() || [...objective].length > GOAL_OBJECTIVE_LIMIT
|
|
93
127
|
|| !isGoalStatus(status)
|
|
94
|
-
|| !
|
|
95
|
-
|| !
|
|
96
|
-
|| !
|
|
97
|
-
||
|
|
128
|
+
|| !safeNonNegativeInteger(createdAt)
|
|
129
|
+
|| !safeNonNegativeInteger(updatedAt)
|
|
130
|
+
|| !incrementableNonNegativeInteger(activeMilliseconds)
|
|
131
|
+
|| !incrementableNonNegativeInteger(turns)
|
|
98
132
|
|| blockedAuditStartTurn !== undefined
|
|
99
|
-
&& (
|
|
100
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 ?? []) {
|
package/killeros/runtime.ts
CHANGED
|
@@ -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 & (
|