killeros 2.1.25 → 2.1.27
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 +28 -0
- package/Killeros.ts +5 -12
- package/README.md +6 -8
- package/killeros/auto-compaction.ts +0 -2
- package/killeros/change-receipt.ts +111 -63
- package/killeros/codex-fast.ts +8 -3
- package/killeros/footer.ts +76 -27
- package/killeros/goal-interface.ts +8 -15
- package/killeros/goal-runtime.ts +3 -6
- package/killeros/goal-settlement.ts +17 -29
- package/killeros/goal-state.ts +55 -3
- package/killeros/handoff.ts +36 -4
- package/killeros/passive-git-status.ts +206 -0
- package/killeros/personal-instructions.ts +2 -3
- package/killeros/runtime.ts +0 -38
- package/killeros/shell-ui.ts +13 -4
- package/killeros/worked-for.ts +3 -2
- package/package.json +1 -1
- package/killeros/init-evidence.ts +0 -291
- package/killeros/init-target.ts +0 -298
- package/killeros/init.ts +0 -281
|
@@ -8,7 +8,7 @@ import { reportError } from "./errors.ts";
|
|
|
8
8
|
import { parseGoalCommand } from "./goal-command.ts";
|
|
9
9
|
import { GOAL_ENTRY_TYPE, GOAL_UPDATE_TOOL, isGoalModeSupported, isSavedSession, pauseGoalAfterFailure, persistGoalState, scheduleGoalContinuation, stopGoalRun, sumGoalTokens, syncGoalUpdateTool, transitionGoal, type GoalEntryData } from "./goal-runtime.ts";
|
|
10
10
|
import { checkpointPausedGoalState, createNewGoalState, DEFAULT_GOAL_MAX_TURNS, GOAL_MAX_TURNS, goalElapsedMilliseconds, GOAL_VERSION, inferGoalVerification, parseGoalState, recordGoalBlockerAudit, transitionGoalState, verifyGoalDeliverable } from "./goal-state.ts";
|
|
11
|
-
import type { GoalRuntime, GoalState, GoalStatus
|
|
11
|
+
import type { GoalRuntime, GoalState, GoalStatus } from "./runtime.ts";
|
|
12
12
|
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
13
13
|
|
|
14
14
|
const GoalUpdateParams = Type.Object({
|
|
@@ -65,7 +65,6 @@ function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
|
|
|
65
65
|
export function registerGoalInterface(
|
|
66
66
|
pi: ExtensionAPI,
|
|
67
67
|
runtime: GoalRuntime,
|
|
68
|
-
initState: InitRuntime,
|
|
69
68
|
): void {
|
|
70
69
|
pi.registerEntryRenderer<GoalEntryData>(GOAL_ENTRY_TYPE, (entry, options, theme) => {
|
|
71
70
|
const data = entry.data;
|
|
@@ -90,6 +89,7 @@ export function registerGoalInterface(
|
|
|
90
89
|
parameters: GoalUpdateParams,
|
|
91
90
|
executionMode: "sequential",
|
|
92
91
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
92
|
+
signal?.throwIfAborted();
|
|
93
93
|
if (!isGoalModeSupported(ctx)) throw new Error("KillerOS goals require TUI or RPC mode");
|
|
94
94
|
if (!isSavedSession(ctx)) throw new Error("KillerOS goals require a saved session");
|
|
95
95
|
const state = runtime.state;
|
|
@@ -98,6 +98,7 @@ export function registerGoalInterface(
|
|
|
98
98
|
if (!evidence) throw new Error("Goal evidence must not be empty");
|
|
99
99
|
if (params.status === "complete") {
|
|
100
100
|
if (state.verification) await verifyGoalDeliverable(state.verification);
|
|
101
|
+
signal?.throwIfAborted();
|
|
101
102
|
if (runtime.state !== state) throw new Error("Goal changed while completion was being verified");
|
|
102
103
|
const verification = state.verification ? "file" : "model-reported";
|
|
103
104
|
transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true });
|
|
@@ -284,10 +285,6 @@ export function registerGoalInterface(
|
|
|
284
285
|
}
|
|
285
286
|
|
|
286
287
|
if (command.kind === "resume") {
|
|
287
|
-
if (initState.active) {
|
|
288
|
-
ctx.ui.notify("Wait for /init to finish before resuming a goal", "error");
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
288
|
if (!runtime.state) {
|
|
292
289
|
ctx.ui.notify("No goal is set", "info");
|
|
293
290
|
return;
|
|
@@ -311,7 +308,7 @@ export function registerGoalInterface(
|
|
|
311
308
|
const base = transitionGoalState(runtime.state, "active", undefined, { resetBlockedAudit: true }, Date.now());
|
|
312
309
|
persistGoalState(pi, runtime, "resume", { ...base, maxTurns: renewed });
|
|
313
310
|
runtime.continuationScheduled = false;
|
|
314
|
-
if (scheduleGoalContinuation(pi, runtime,
|
|
311
|
+
if (scheduleGoalContinuation(pi, runtime, ctx)) ctx.ui.notify("Goal resumed", "info");
|
|
315
312
|
} catch (error) {
|
|
316
313
|
reportError(ctx, "Goal could not be resumed", error);
|
|
317
314
|
}
|
|
@@ -320,17 +317,13 @@ export function registerGoalInterface(
|
|
|
320
317
|
try {
|
|
321
318
|
transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
|
|
322
319
|
runtime.continuationScheduled = false;
|
|
323
|
-
if (scheduleGoalContinuation(pi, runtime,
|
|
320
|
+
if (scheduleGoalContinuation(pi, runtime, ctx)) ctx.ui.notify("Goal resumed", "info");
|
|
324
321
|
} catch (error) {
|
|
325
322
|
reportError(ctx, "Goal could not be resumed", error);
|
|
326
323
|
}
|
|
327
324
|
return;
|
|
328
325
|
}
|
|
329
326
|
|
|
330
|
-
if (initState.active) {
|
|
331
|
-
ctx.ui.notify("Wait for /init to finish before starting a goal", "error");
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
327
|
switch (command.kind) {
|
|
335
328
|
case "objective":
|
|
336
329
|
break;
|
|
@@ -362,7 +355,7 @@ export function registerGoalInterface(
|
|
|
362
355
|
}
|
|
363
356
|
if (waitError) {
|
|
364
357
|
reportError(ctx, "Goal could not wait for the active turn", waitError);
|
|
365
|
-
scheduleGoalContinuation(pi, runtime,
|
|
358
|
+
scheduleGoalContinuation(pi, runtime, ctx);
|
|
366
359
|
return;
|
|
367
360
|
}
|
|
368
361
|
let verification: Awaited<ReturnType<typeof inferGoalVerification>>;
|
|
@@ -373,7 +366,7 @@ export function registerGoalInterface(
|
|
|
373
366
|
reportError(ctx, "Goal could not be started", error);
|
|
374
367
|
} else {
|
|
375
368
|
reportError(ctx, "Goal could not be replaced", error);
|
|
376
|
-
scheduleGoalContinuation(pi, runtime,
|
|
369
|
+
scheduleGoalContinuation(pi, runtime, ctx);
|
|
377
370
|
}
|
|
378
371
|
return;
|
|
379
372
|
}
|
|
@@ -382,7 +375,7 @@ export function registerGoalInterface(
|
|
|
382
375
|
maxTurns: DEFAULT_GOAL_MAX_TURNS,
|
|
383
376
|
});
|
|
384
377
|
persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
|
|
385
|
-
if (scheduleGoalContinuation(pi, runtime,
|
|
378
|
+
if (scheduleGoalContinuation(pi, runtime, ctx)) {
|
|
386
379
|
ctx.ui.notify("Goal active. KillerOS will continue until completion, a repeated blocker, or pause.", "info");
|
|
387
380
|
}
|
|
388
381
|
} catch (error) {
|
package/killeros/goal-runtime.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext
|
|
|
2
2
|
import { reportError } from "./errors.ts";
|
|
3
3
|
import { beginGoalTurnState, checkpointActiveGoalState, GOAL_VERSION, parseGoalState, pauseGoalState, transitionGoalState, type GoalTransitionOptions } from "./goal-state.ts";
|
|
4
4
|
import { resolvePersonalInstructions } from "./personal-instructions.ts";
|
|
5
|
-
import type { GoalRuntime, GoalState, GoalStatus
|
|
5
|
+
import type { GoalRuntime, GoalState, GoalStatus } from "./runtime.ts";
|
|
6
6
|
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
7
7
|
|
|
8
8
|
export const GOAL_ENTRY_TYPE = "killeros-goal";
|
|
@@ -204,7 +204,6 @@ function beginGoalTurn(
|
|
|
204
204
|
export function scheduleGoalContinuation(
|
|
205
205
|
pi: ExtensionAPI,
|
|
206
206
|
runtime: GoalRuntime,
|
|
207
|
-
initState: InitRuntime,
|
|
208
207
|
ctx: ExtensionContext,
|
|
209
208
|
): boolean {
|
|
210
209
|
if (isGoalModeSupported(ctx) && isSavedSession(ctx) && pauseGoalAtTurnLimit(pi, runtime, ctx)) return false;
|
|
@@ -214,7 +213,6 @@ export function scheduleGoalContinuation(
|
|
|
214
213
|
|| runtime.continuationScheduled
|
|
215
214
|
|| runtime.continuationHeld
|
|
216
215
|
|| runtime.goalTurnInFlight
|
|
217
|
-
|| initState.active
|
|
218
216
|
|| !ctx.isIdle()
|
|
219
217
|
|| ctx.hasPendingMessages()) return false;
|
|
220
218
|
runtime.continuationScheduled = true;
|
|
@@ -282,7 +280,6 @@ export function isSavedSession(ctx: ExtensionContext): boolean {
|
|
|
282
280
|
export function registerGoalRuntime(
|
|
283
281
|
pi: ExtensionAPI,
|
|
284
282
|
runtime: GoalRuntime,
|
|
285
|
-
initState: InitRuntime,
|
|
286
283
|
): void {
|
|
287
284
|
const restoreGoal = (ctx: ExtensionContext): void => {
|
|
288
285
|
const restored = restoreGoalState(ctx);
|
|
@@ -298,7 +295,7 @@ export function registerGoalRuntime(
|
|
|
298
295
|
runtime.lastError = undefined;
|
|
299
296
|
runtime.requestRender?.();
|
|
300
297
|
if (runtime.state?.status === "active") {
|
|
301
|
-
setImmediate(() => scheduleGoalContinuation(pi, runtime,
|
|
298
|
+
setImmediate(() => scheduleGoalContinuation(pi, runtime, ctx));
|
|
302
299
|
}
|
|
303
300
|
};
|
|
304
301
|
|
|
@@ -330,7 +327,7 @@ export function registerGoalRuntime(
|
|
|
330
327
|
runtime.continuationScheduled = false;
|
|
331
328
|
if (!runtime.goalTurnInFlight && isGoalModeSupported(ctx) && isSavedSession(ctx) && pauseGoalAtTurnLimit(pi, runtime, ctx)) return;
|
|
332
329
|
const current = runtime.state;
|
|
333
|
-
if (!isGoalModeSupported(ctx) || !isSavedSession(ctx) || !current || current.status !== "active"
|
|
330
|
+
if (!isGoalModeSupported(ctx) || !isSavedSession(ctx) || !current || current.status !== "active") return;
|
|
334
331
|
if (runtime.goalTurnInFlight) return { systemPrompt: `${event.systemPrompt}\n\n${goalSystemPrompt(current)}` };
|
|
335
332
|
const next = beginGoalTurn(pi, runtime, ctx, current);
|
|
336
333
|
if (!next) return;
|
|
@@ -3,7 +3,7 @@ import type { AutoCompactionGoalHandlers } from "./auto-compaction.ts";
|
|
|
3
3
|
import { reportError } from "./errors.ts";
|
|
4
4
|
import { isGoalModeSupported, isSavedSession, pauseGoalAfterFailure, pauseGoalAtTurnLimit, scheduleGoalContinuation, syncGoalUpdateTool, transitionGoal } from "./goal-runtime.ts";
|
|
5
5
|
import { pauseGoalState } from "./goal-state.ts";
|
|
6
|
-
import type { GoalRuntime
|
|
6
|
+
import type { GoalRuntime } from "./runtime.ts";
|
|
7
7
|
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
8
8
|
|
|
9
9
|
function pauseGoalForPossibleManualCompaction(
|
|
@@ -36,12 +36,10 @@ function pauseGoalForPossibleManualCompaction(
|
|
|
36
36
|
function recoverGoalAfterManualCompaction(
|
|
37
37
|
pi: ExtensionAPI,
|
|
38
38
|
runtime: GoalRuntime,
|
|
39
|
-
initState: InitRuntime,
|
|
40
39
|
ctx: ExtensionContext,
|
|
41
40
|
): boolean {
|
|
42
41
|
if (runtime.state?.status !== "paused"
|
|
43
|
-
|| runtime.state.resumeAfterManualCompaction !== true
|
|
44
|
-
|| initState.active) return false;
|
|
42
|
+
|| runtime.state.resumeAfterManualCompaction !== true) return false;
|
|
45
43
|
try {
|
|
46
44
|
transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
|
|
47
45
|
} catch (error) {
|
|
@@ -52,7 +50,7 @@ function recoverGoalAfterManualCompaction(
|
|
|
52
50
|
runtime.continuationScheduled = false;
|
|
53
51
|
runtime.automaticCompaction = undefined;
|
|
54
52
|
ctx.ui.notify("Manual compaction complete. Goal resumed.", "info");
|
|
55
|
-
setImmediate(() => scheduleGoalContinuation(pi, runtime,
|
|
53
|
+
setImmediate(() => scheduleGoalContinuation(pi, runtime, ctx));
|
|
56
54
|
return true;
|
|
57
55
|
}
|
|
58
56
|
|
|
@@ -60,7 +58,6 @@ function recoverGoalAfterManualCompaction(
|
|
|
60
58
|
function finalizeAutomaticCompaction(
|
|
61
59
|
pi: ExtensionAPI,
|
|
62
60
|
runtime: GoalRuntime,
|
|
63
|
-
initState: InitRuntime,
|
|
64
61
|
ctx: ExtensionContext,
|
|
65
62
|
): void {
|
|
66
63
|
const recovery = runtime.automaticCompaction;
|
|
@@ -68,8 +65,7 @@ function finalizeAutomaticCompaction(
|
|
|
68
65
|
const skipped = recovery.outcome === "skipped";
|
|
69
66
|
runtime.automaticCompaction = undefined;
|
|
70
67
|
if (runtime.state?.status !== "paused"
|
|
71
|
-
|| runtime.state.revision !== recovery.pausedRevision
|
|
72
|
-
|| initState.active) return;
|
|
68
|
+
|| runtime.state.revision !== recovery.pausedRevision) return;
|
|
73
69
|
try {
|
|
74
70
|
transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
|
|
75
71
|
} catch (error) {
|
|
@@ -80,31 +76,29 @@ function finalizeAutomaticCompaction(
|
|
|
80
76
|
return;
|
|
81
77
|
}
|
|
82
78
|
runtime.continuationScheduled = false;
|
|
83
|
-
setImmediate(() => scheduleGoalContinuation(pi, runtime,
|
|
79
|
+
setImmediate(() => scheduleGoalContinuation(pi, runtime, ctx));
|
|
84
80
|
}
|
|
85
81
|
|
|
86
82
|
/** Records Pi's successful compaction callback and attempts guarded recovery. */
|
|
87
83
|
function completeAutomaticCompaction(
|
|
88
84
|
pi: ExtensionAPI,
|
|
89
85
|
runtime: GoalRuntime,
|
|
90
|
-
initState: InitRuntime,
|
|
91
86
|
ctx: ExtensionContext,
|
|
92
87
|
): void {
|
|
93
88
|
if (!runtime.automaticCompaction) return;
|
|
94
89
|
runtime.automaticCompaction.outcome = "completed";
|
|
95
|
-
finalizeAutomaticCompaction(pi, runtime,
|
|
90
|
+
finalizeAutomaticCompaction(pi, runtime, ctx);
|
|
96
91
|
}
|
|
97
92
|
|
|
98
93
|
/** Records Pi's expected session-too-small rejection and resumes without claiming compaction succeeded. */
|
|
99
94
|
function skipAutomaticCompaction(
|
|
100
95
|
pi: ExtensionAPI,
|
|
101
96
|
runtime: GoalRuntime,
|
|
102
|
-
initState: InitRuntime,
|
|
103
97
|
ctx: ExtensionContext,
|
|
104
98
|
): void {
|
|
105
99
|
if (!runtime.automaticCompaction) return;
|
|
106
100
|
runtime.automaticCompaction.outcome = "skipped";
|
|
107
|
-
finalizeAutomaticCompaction(pi, runtime,
|
|
101
|
+
finalizeAutomaticCompaction(pi, runtime, ctx);
|
|
108
102
|
}
|
|
109
103
|
|
|
110
104
|
/** Consumes automatic recovery and records its failure on the eligible paused goal. */
|
|
@@ -151,7 +145,6 @@ function failAutomaticCompaction(
|
|
|
151
145
|
export function registerGoalSettlement(
|
|
152
146
|
pi: ExtensionAPI,
|
|
153
147
|
runtime: GoalRuntime,
|
|
154
|
-
initState: InitRuntime,
|
|
155
148
|
): AutoCompactionGoalHandlers {
|
|
156
149
|
pi.on("agent_settled", (_event, ctx) => {
|
|
157
150
|
const wasGoalTurn = runtime.goalTurnInFlight;
|
|
@@ -182,15 +175,15 @@ export function registerGoalSettlement(
|
|
|
182
175
|
return;
|
|
183
176
|
}
|
|
184
177
|
runtime.automaticCompaction.turnSettled = true;
|
|
185
|
-
finalizeAutomaticCompaction(pi, runtime,
|
|
178
|
+
finalizeAutomaticCompaction(pi, runtime, ctx);
|
|
186
179
|
return;
|
|
187
180
|
}
|
|
188
181
|
|
|
189
|
-
if (!wasGoalTurn || runtime.state?.status !== "active"
|
|
190
|
-
if (continuationWasScheduled && runtime.state?.status === "active"
|
|
182
|
+
if (!wasGoalTurn || runtime.state?.status !== "active") {
|
|
183
|
+
if (continuationWasScheduled && runtime.state?.status === "active") {
|
|
191
184
|
pauseGoalAfterFailure(pi, runtime, ctx, "the goal continuation ended before an agent turn started");
|
|
192
|
-
} else if (runtime.state?.status === "active"
|
|
193
|
-
scheduleGoalContinuation(pi, runtime,
|
|
185
|
+
} else if (runtime.state?.status === "active") {
|
|
186
|
+
scheduleGoalContinuation(pi, runtime, ctx);
|
|
194
187
|
}
|
|
195
188
|
return;
|
|
196
189
|
}
|
|
@@ -215,24 +208,19 @@ export function registerGoalSettlement(
|
|
|
215
208
|
runtime.lastStopReason = undefined;
|
|
216
209
|
runtime.lastError = undefined;
|
|
217
210
|
if (pauseGoalAtTurnLimit(pi, runtime, ctx)) return;
|
|
218
|
-
scheduleGoalContinuation(pi, runtime,
|
|
211
|
+
scheduleGoalContinuation(pi, runtime, ctx);
|
|
219
212
|
});
|
|
220
213
|
|
|
221
214
|
pi.on("session_compact", (event, ctx) => {
|
|
222
215
|
if (runtime.automaticCompaction !== undefined) return;
|
|
223
216
|
if (event.reason !== "manual") return;
|
|
224
|
-
recoverGoalAfterManualCompaction(pi, runtime,
|
|
217
|
+
recoverGoalAfterManualCompaction(pi, runtime, ctx);
|
|
225
218
|
});
|
|
226
219
|
|
|
227
|
-
const resetAutomaticRecovery = (): void => { runtime.automaticCompaction = undefined; };
|
|
228
|
-
pi.on("session_before_switch", resetAutomaticRecovery);
|
|
229
|
-
pi.on("session_before_fork", resetAutomaticRecovery);
|
|
230
|
-
|
|
231
220
|
return {
|
|
232
221
|
isActive: (ctx: ExtensionContext): boolean => isGoalModeSupported(ctx)
|
|
233
222
|
&& isSavedSession(ctx)
|
|
234
|
-
&& runtime.state?.status === "active"
|
|
235
|
-
&& !initState.active,
|
|
223
|
+
&& runtime.state?.status === "active",
|
|
236
224
|
onRequested: (): void => {
|
|
237
225
|
if (runtime.state?.status !== "active") return;
|
|
238
226
|
try {
|
|
@@ -254,8 +242,8 @@ export function registerGoalSettlement(
|
|
|
254
242
|
throw error;
|
|
255
243
|
}
|
|
256
244
|
},
|
|
257
|
-
onCompleted: (ctx: ExtensionContext): void => completeAutomaticCompaction(pi, runtime,
|
|
245
|
+
onCompleted: (ctx: ExtensionContext): void => completeAutomaticCompaction(pi, runtime, ctx),
|
|
258
246
|
onFailed: (ctx: ExtensionContext, error: unknown): void => failAutomaticCompaction(pi, runtime, ctx, error),
|
|
259
|
-
onSkipped: (ctx: ExtensionContext): void => skipAutomaticCompaction(pi, runtime,
|
|
247
|
+
onSkipped: (ctx: ExtensionContext): void => skipAutomaticCompaction(pi, runtime, ctx),
|
|
260
248
|
};
|
|
261
249
|
}
|
package/killeros/goal-state.ts
CHANGED
|
@@ -10,6 +10,7 @@ export const GOAL_MAX_TURNS = 10_000;
|
|
|
10
10
|
export const GOAL_VERSION = 1;
|
|
11
11
|
const FILE_HASH_CHUNK_SIZE = 64 * 1024;
|
|
12
12
|
export const FILE_HASH_LIMIT = 64 * 1024 * 1024;
|
|
13
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
13
14
|
type OpenGoalFile = (filePath: string) => Promise<FileHandle>;
|
|
14
15
|
const openGoalFile: OpenGoalFile = (filePath) => open(filePath, "r");
|
|
15
16
|
|
|
@@ -84,6 +85,14 @@ function isMaxTurns(value: unknown): value is number {
|
|
|
84
85
|
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= GOAL_MAX_TURNS;
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
function exceedsBlockerEvidenceLimit(value: string): boolean {
|
|
89
|
+
let length = 0;
|
|
90
|
+
for (const _ of graphemeSegmenter.segment(value)) {
|
|
91
|
+
if (++length > 2_000) return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
87
96
|
function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
|
|
88
97
|
if (!isUnknownRecord(value)
|
|
89
98
|
|| typeof value.key !== "string"
|
|
@@ -91,7 +100,7 @@ function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus):
|
|
|
91
100
|
|| typeof value.streak !== "number" || !Number.isInteger(value.streak) || value.streak < 1 || value.streak > 3
|
|
92
101
|
|| typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns
|
|
93
102
|
|| value.evidence !== undefined && (typeof value.evidence !== "string"
|
|
94
|
-
|| value.evidence !== value.evidence.trim() || !value.evidence || value.evidence
|
|
103
|
+
|| value.evidence !== value.evidence.trim() || !value.evidence || exceedsBlockerEvidenceLimit(value.evidence))) {
|
|
95
104
|
return false;
|
|
96
105
|
}
|
|
97
106
|
if (status === "complete") return false;
|
|
@@ -233,6 +242,45 @@ export async function captureGoalFileBaseline(
|
|
|
233
242
|
}
|
|
234
243
|
}
|
|
235
244
|
|
|
245
|
+
const GOAL_URL_PATTERN = /(?:https?|file):\/\/[^\s"'`]+/giu;
|
|
246
|
+
const GOAL_QUOTED_PATTERN = /`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'/gu;
|
|
247
|
+
const GOAL_ABSOLUTE_PATTERN = /(?:^|[\s"'`(\[{,;])([A-Za-z]:[\\/][^\s,;'"`]+|\/[^\s,;'"`]+)/gu;
|
|
248
|
+
const GOAL_LIST_FILE_PATTERN = /(?:\band\b|\bor\b|[,;&+])\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z0-9_][A-Za-z0-9_.-]*(?:[\\/][A-Za-z0-9_.-]+)*\.[A-Za-z0-9]{1,12})\b)/giu;
|
|
249
|
+
|
|
250
|
+
function isQuotedGoalFileMention(raw: string): boolean {
|
|
251
|
+
const value = raw.trim();
|
|
252
|
+
if (!value || /[\s\p{Cc}]/u.test(value) || /^(?:https?|file):\/\//iu.test(value) || /[\\/]$/u.test(value)) return false;
|
|
253
|
+
return /[\/\\.:]/u.test(value);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function normalizeGoalFileMention(raw: string, cwd: string): string | undefined {
|
|
257
|
+
const value = raw.trim().replace(/[.,;:!?)\]}]+$/u, "");
|
|
258
|
+
if (!value || /[\s\p{Cc}]/u.test(value)) return undefined;
|
|
259
|
+
const absolute = path.isAbsolute(value) || path.win32.isAbsolute(value) ? value : path.resolve(cwd, value);
|
|
260
|
+
if (!isAbsoluteFilePath(absolute)) return undefined;
|
|
261
|
+
return process.platform === "win32" ? absolute.toLowerCase() : absolute;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Counts distinct path-shaped mentions so several files cannot bind proof to one. */
|
|
265
|
+
function countGoalTargetFiles(objective: string, cwd: string, target: string): number {
|
|
266
|
+
const prose = objective.replace(GOAL_URL_PATTERN, " ");
|
|
267
|
+
const mentions = new Set<string>();
|
|
268
|
+
const add = (raw: string): void => {
|
|
269
|
+
const normalized = normalizeGoalFileMention(raw, cwd);
|
|
270
|
+
if (normalized) mentions.add(normalized);
|
|
271
|
+
};
|
|
272
|
+
add(target);
|
|
273
|
+
for (const match of prose.matchAll(GOAL_QUOTED_PATTERN)) {
|
|
274
|
+
const raw = match[1] ?? match[2] ?? match[3] ?? "";
|
|
275
|
+
if (isQuotedGoalFileMention(raw)) add(raw);
|
|
276
|
+
}
|
|
277
|
+
for (const match of prose.matchAll(GOAL_ABSOLUTE_PATTERN)) {
|
|
278
|
+
if (match[1]) add(stripUnquotedPathPunctuation(match[1].trim()));
|
|
279
|
+
}
|
|
280
|
+
for (const match of prose.matchAll(GOAL_LIST_FILE_PATTERN)) add(match[1] ?? match[2] ?? match[3] ?? match[4] ?? "");
|
|
281
|
+
return mentions.size;
|
|
282
|
+
}
|
|
283
|
+
|
|
236
284
|
/** Captures one explicit output path so goal completion can verify its creation or modification. */
|
|
237
285
|
export async function inferGoalVerification(objective: string, cwd: string): Promise<GoalFileVerification | undefined> {
|
|
238
286
|
const candidates: string[] = [];
|
|
@@ -253,8 +301,12 @@ export async function inferGoalVerification(objective: string, cwd: string): Pro
|
|
|
253
301
|
if (isAbsoluteFilePath(absolute)) resolved.push(absolute);
|
|
254
302
|
}
|
|
255
303
|
const unique = [...new Set(resolved)];
|
|
256
|
-
|
|
257
|
-
|
|
304
|
+
if (unique.length !== 1) return undefined;
|
|
305
|
+
// A goal that names several files must not verify from one file alone.
|
|
306
|
+
// Repeated references to the same file count as one target.
|
|
307
|
+
const filePath = unique[0];
|
|
308
|
+
if (countGoalTargetFiles(objective, cwd, filePath) > 1) return undefined;
|
|
309
|
+
return { kind: "file", path: filePath, baseline: await captureGoalFileBaseline(filePath) };
|
|
258
310
|
}
|
|
259
311
|
|
|
260
312
|
export async function verifyGoalDeliverable(verification: GoalFileVerification): Promise<void> {
|
package/killeros/handoff.ts
CHANGED
|
@@ -104,6 +104,33 @@ function hasRequiredHandoffContent(document: string, focus: string): boolean {
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
function handoffAvailable(ctx: ExtensionCommandContext, goalRuntime: GoalRuntime): boolean {
|
|
108
|
+
return ctx.isIdle() && !ctx.hasPendingMessages() && goalRuntime.state?.status !== "active";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sourceHandoffAvailable(
|
|
112
|
+
ctx: ExtensionCommandContext,
|
|
113
|
+
goalRuntime: GoalRuntime,
|
|
114
|
+
sourceSession: string,
|
|
115
|
+
sourceLeaf: string | undefined,
|
|
116
|
+
): boolean {
|
|
117
|
+
try {
|
|
118
|
+
return ctx.sessionManager.getSessionFile() === sourceSession
|
|
119
|
+
&& handoffAvailable(ctx, goalRuntime)
|
|
120
|
+
&& ctx.sessionManager.buildContextEntries().at(-1)?.id === sourceLeaf;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function notifyHandoffUnavailable(ctx: ExtensionCommandContext): void {
|
|
127
|
+
try {
|
|
128
|
+
ctx.ui.notify(HANDOFF_UNAVAILABLE, "error");
|
|
129
|
+
} catch {
|
|
130
|
+
// A session replaced during generation has no valid source UI to notify.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
107
134
|
function assertHandoffContextReserve(ctx: ExtensionCommandContext, maxTokens: number): void {
|
|
108
135
|
let usage: ReturnType<ExtensionCommandContext["getContextUsage"]>;
|
|
109
136
|
try {
|
|
@@ -160,8 +187,8 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime, hand
|
|
|
160
187
|
pi.registerCommand("handoff", {
|
|
161
188
|
description: "Create a fresh session with a continuation handoff",
|
|
162
189
|
handler: async (args, ctx) => {
|
|
163
|
-
if (!
|
|
164
|
-
ctx
|
|
190
|
+
if (!handoffAvailable(ctx, goalRuntime)) {
|
|
191
|
+
notifyHandoffUnavailable(ctx);
|
|
165
192
|
return;
|
|
166
193
|
}
|
|
167
194
|
|
|
@@ -175,8 +202,9 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime, hand
|
|
|
175
202
|
let document: string;
|
|
176
203
|
let focus: string;
|
|
177
204
|
try {
|
|
178
|
-
const
|
|
179
|
-
const
|
|
205
|
+
const entries = ctx.sessionManager.buildContextEntries();
|
|
206
|
+
const sourceLeaf = entries.at(-1)?.id;
|
|
207
|
+
const conversation = serializeConversation(convertToLlm(entries.flatMap(sessionEntryToContextMessages)));
|
|
180
208
|
if (!conversation.trim()) throw new Error("No usable session context is available");
|
|
181
209
|
focus = safeTerminalText(args).trim();
|
|
182
210
|
let maxTokens = handoffMaxTokens;
|
|
@@ -215,6 +243,10 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime, hand
|
|
|
215
243
|
if (!hasRequiredHandoffContent(document, focus)) {
|
|
216
244
|
throw new Error("The handoff summary did not contain every required section");
|
|
217
245
|
}
|
|
246
|
+
if (!sourceHandoffAvailable(ctx, goalRuntime, sourceSession, sourceLeaf)) {
|
|
247
|
+
notifyHandoffUnavailable(ctx);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
218
250
|
} catch (error) {
|
|
219
251
|
reportError(ctx, "Handoff failed", error);
|
|
220
252
|
return;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Passive Git inspection disables fsmonitor, known clean/process filters,
|
|
5
|
+
// promisor fetches, and repository-supplied Git executables. Config discovery
|
|
6
|
+
// and status run in separate Git processes, so a filter configured between
|
|
7
|
+
// them would be absent from the safety overrides. Automatic Git children run
|
|
8
|
+
// without PATH resolution, and each scan rejects results when the effective
|
|
9
|
+
// filter set changed. Absolute filter commands remain possible, so product
|
|
10
|
+
// callers run these scans only after project trust is granted.
|
|
11
|
+
//
|
|
12
|
+
// Executable discovery itself is passive: it never starts a command shell,
|
|
13
|
+
// locator process, or other helper executable, and never executes a bare
|
|
14
|
+
// program name. It scans absolute search-path entries for an absolute Git
|
|
15
|
+
// path outside the inspected repository and fails closed when none exists.
|
|
16
|
+
export const PASSIVE_GIT_CONFIG_ARGS = ["config", "--includes", "--null", "--name-only", "--list"] as const;
|
|
17
|
+
|
|
18
|
+
const SAFE_FILTER_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
19
|
+
|
|
20
|
+
function searchPathEntries(env: NodeJS.ProcessEnv): string[] {
|
|
21
|
+
const values: string[] = [];
|
|
22
|
+
for (const [key, value] of Object.entries(env)) {
|
|
23
|
+
if (key.toLowerCase() === "path" && typeof value === "string") values.push(value);
|
|
24
|
+
}
|
|
25
|
+
const entries: string[] = [];
|
|
26
|
+
for (const value of values) entries.push(...value.split(path.delimiter));
|
|
27
|
+
return entries;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pathExtensionEntries(env: NodeJS.ProcessEnv): string[] {
|
|
31
|
+
let raw: string | undefined;
|
|
32
|
+
for (const [key, value] of Object.entries(env)) {
|
|
33
|
+
if (key.toLowerCase() === "pathext" && typeof value === "string") {
|
|
34
|
+
raw = value;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
raw ??= ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.CPL";
|
|
39
|
+
return raw.split(";").map((entry) => entry.trim()).filter(Boolean).map((entry) => entry.startsWith(".") ? entry : `.${entry}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function unquoted(entry: string): string {
|
|
43
|
+
if (entry.length >= 2) {
|
|
44
|
+
const first = entry[0];
|
|
45
|
+
const last = entry[entry.length - 1];
|
|
46
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) return entry.slice(1, -1);
|
|
47
|
+
}
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function inspectedRoot(cwd: string): string | undefined {
|
|
52
|
+
if (!cwd || typeof cwd !== "string") return undefined;
|
|
53
|
+
let start: string;
|
|
54
|
+
try {
|
|
55
|
+
start = path.resolve(cwd);
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
let base: string;
|
|
60
|
+
try {
|
|
61
|
+
base = realpathSync(start);
|
|
62
|
+
} catch {
|
|
63
|
+
base = start;
|
|
64
|
+
}
|
|
65
|
+
let current = base;
|
|
66
|
+
for (;;) {
|
|
67
|
+
try {
|
|
68
|
+
if (existsSync(path.join(current, ".git"))) {
|
|
69
|
+
try {
|
|
70
|
+
return realpathSync(current);
|
|
71
|
+
} catch {
|
|
72
|
+
return current;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Unreadable directory: keep walking toward the filesystem root.
|
|
77
|
+
}
|
|
78
|
+
const parent = path.dirname(current);
|
|
79
|
+
if (parent === current) break;
|
|
80
|
+
current = parent;
|
|
81
|
+
}
|
|
82
|
+
return base;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function insideInspected(candidate: string, root: string): boolean {
|
|
86
|
+
if (process.platform === "win32") {
|
|
87
|
+
const normalizedCandidate = path.win32.normalize(candidate).toLowerCase();
|
|
88
|
+
const normalizedRoot = path.win32.normalize(root).toLowerCase();
|
|
89
|
+
const trimmed = normalizedRoot.length > 3 && normalizedRoot.endsWith(path.win32.sep)
|
|
90
|
+
? normalizedRoot.slice(0, -1)
|
|
91
|
+
: normalizedRoot;
|
|
92
|
+
if (normalizedCandidate === trimmed) return true;
|
|
93
|
+
return normalizedCandidate.startsWith(`${trimmed}${path.win32.sep}`);
|
|
94
|
+
}
|
|
95
|
+
const normalizedCandidate = path.normalize(candidate);
|
|
96
|
+
const normalizedRoot = path.normalize(root);
|
|
97
|
+
const trimmed = normalizedRoot.length > 1 && normalizedRoot.endsWith(path.sep)
|
|
98
|
+
? normalizedRoot.slice(0, -1)
|
|
99
|
+
: normalizedRoot;
|
|
100
|
+
if (normalizedCandidate === trimmed) return true;
|
|
101
|
+
return normalizedCandidate.startsWith(`${trimmed}${path.sep}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Absolute Git binary outside the inspected repository, or undefined when
|
|
105
|
+
// no safe candidate exists. Never starts a helper process and never
|
|
106
|
+
// returns a bare command name, so opening a repository cannot execute a
|
|
107
|
+
// repository-local locator or Git executable. Empty and relative
|
|
108
|
+
// search-path entries are ignored because they can resolve against the
|
|
109
|
+
// current directory. A candidate that resolves through a link returns its
|
|
110
|
+
// final path only when that path is also outside the repository.
|
|
111
|
+
export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
112
|
+
const root = inspectedRoot(cwd);
|
|
113
|
+
if (!root) return undefined;
|
|
114
|
+
const entries = searchPathEntries(env);
|
|
115
|
+
if (entries.length === 0) return undefined;
|
|
116
|
+
const windows = process.platform === "win32";
|
|
117
|
+
const baseNames = windows ? ["git", ...pathExtensionEntries(env).map((extension) => `git${extension}`)] : ["git"];
|
|
118
|
+
for (const raw of entries) {
|
|
119
|
+
if (raw === "" || raw.trim() === "") continue;
|
|
120
|
+
const directory = unquoted(raw);
|
|
121
|
+
if (directory === "" || directory.trim() === "") continue;
|
|
122
|
+
if (!path.isAbsolute(directory)) continue;
|
|
123
|
+
for (const base of baseNames) {
|
|
124
|
+
const candidate = path.join(directory, base);
|
|
125
|
+
try {
|
|
126
|
+
if (!statSync(candidate).isFile()) continue;
|
|
127
|
+
} catch {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!windows) {
|
|
131
|
+
try {
|
|
132
|
+
accessSync(candidate, constants.X_OK);
|
|
133
|
+
} catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
let resolved: string;
|
|
138
|
+
try {
|
|
139
|
+
resolved = realpathSync(candidate);
|
|
140
|
+
} catch {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!path.isAbsolute(resolved)) continue;
|
|
144
|
+
if (insideInspected(resolved, root)) continue;
|
|
145
|
+
return resolved;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Lists effective clean/process filter drivers in discovery order, or
|
|
152
|
+
// undefined when discovery output is incomplete or names a driver the
|
|
153
|
+
// safety overrides cannot represent.
|
|
154
|
+
export function passiveFilterNames(config: string): string[] | undefined {
|
|
155
|
+
const records = config.split("\0");
|
|
156
|
+
if (records.at(-1) !== "") return undefined;
|
|
157
|
+
const names = new Set<string>();
|
|
158
|
+
for (const key of records) {
|
|
159
|
+
if (!key) continue;
|
|
160
|
+
const name = /^filter\.(.*)\.(?:clean|process)$/us.exec(key)?.[1];
|
|
161
|
+
if (name === undefined) continue;
|
|
162
|
+
if (!SAFE_FILTER_NAME.test(name)) return undefined;
|
|
163
|
+
names.add(name);
|
|
164
|
+
}
|
|
165
|
+
return [...names];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Builds safe overrides from null-delimited `git config --name-only --list`
|
|
169
|
+
// output. Returns undefined when output is incomplete or names an unsafe
|
|
170
|
+
// filter, so the caller skips the status call.
|
|
171
|
+
export function passiveStatusSafetyArgs(config: string): string[] | undefined {
|
|
172
|
+
const names = passiveFilterNames(config);
|
|
173
|
+
if (!names) return undefined;
|
|
174
|
+
return ["-c", "core.fsmonitor=false", ...names.flatMap((name) => ["-c", `filter.${name}.clean=`, "-c", `filter.${name}.process=`, "-c", `filter.${name}.required=false`])];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function samePassiveFilters(before: string, after: string): boolean {
|
|
178
|
+
const earlier = passiveFilterNames(before);
|
|
179
|
+
const later = passiveFilterNames(after);
|
|
180
|
+
if (!earlier || !later) return false;
|
|
181
|
+
if (earlier.length !== later.length) return false;
|
|
182
|
+
const ordered = [...later].sort();
|
|
183
|
+
return [...earlier].sort().every((name, index) => name === ordered[index]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Environment for automatic Git children: no optional locks, no lazy fetch
|
|
187
|
+
// from a promisor remote, and no PATH so a filter command that becomes
|
|
188
|
+
// effective after discovery cannot resolve a bare command name. Absolute
|
|
189
|
+
// filter paths are still possible; trusted-project callers detect mid-scan
|
|
190
|
+
// config changes with samePassiveFilters and skip those results.
|
|
191
|
+
export function passiveGitEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
192
|
+
const env: NodeJS.ProcessEnv = {
|
|
193
|
+
...base,
|
|
194
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
195
|
+
GIT_NO_LAZY_FETCH: "1",
|
|
196
|
+
};
|
|
197
|
+
let hasPath = false;
|
|
198
|
+
for (const key of Object.keys(env)) {
|
|
199
|
+
if (key.toLowerCase() === "path") {
|
|
200
|
+
env[key] = "";
|
|
201
|
+
hasPath = true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (!hasPath) env.PATH = "";
|
|
205
|
+
return env;
|
|
206
|
+
}
|