killeros 2.1.26 → 2.1.28
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 +38 -0
- package/Killeros.ts +6 -13
- package/README.md +8 -10
- package/killeros/activity.ts +16 -7
- package/killeros/auto-compaction.ts +1 -2
- package/killeros/change-receipt.ts +109 -37
- package/killeros/codex-fast.ts +8 -3
- package/killeros/footer.ts +82 -31
- package/killeros/goal-interface.ts +198 -60
- package/killeros/goal-runtime.ts +192 -49
- package/killeros/goal-settlement.ts +140 -49
- package/killeros/goal-state.ts +250 -34
- 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 +51 -35
- package/killeros/shell-ui.ts +13 -4
- package/killeros/worked-for.ts +4 -3
- package/package.json +1 -1
- package/themes/killeros.json +3 -2
- package/killeros/init-evidence.ts +0 -291
- package/killeros/init-target.ts +0 -309
- package/killeros/init.ts +0 -285
package/killeros/goal-state.ts
CHANGED
|
@@ -2,10 +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, GoalContinueReport, GoalFileBaseline, GoalFileVerification, GoalPendingDecision, GoalState, GoalStateCommon, GoalStatus, GoalTurnDecision, GoalTurnPhase } from "./runtime.ts";
|
|
6
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
6
7
|
|
|
7
8
|
export const DEFAULT_GOAL_MAX_TURNS = 20;
|
|
8
9
|
export const GOAL_OBJECTIVE_LIMIT = 4_000;
|
|
10
|
+
export const GOAL_EVIDENCE_LIMIT = 2_000;
|
|
9
11
|
export const GOAL_MAX_TURNS = 10_000;
|
|
10
12
|
export const GOAL_VERSION = 1;
|
|
11
13
|
const FILE_HASH_CHUNK_SIZE = 64 * 1024;
|
|
@@ -18,6 +20,10 @@ export interface GoalTransitionOptions {
|
|
|
18
20
|
resetBlockedAudit?: boolean;
|
|
19
21
|
resumeAfterManualCompaction?: true;
|
|
20
22
|
blockerAudit?: GoalBlockerAudit;
|
|
23
|
+
keepTurnForRecovery?: true;
|
|
24
|
+
resumeInterruptedTurn?: true;
|
|
25
|
+
preserveTurnAuthorization?: true;
|
|
26
|
+
decision?: GoalTurnDecision;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
function isGoalStatus(value: unknown): value is GoalStatus {
|
|
@@ -46,6 +52,79 @@ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
|
|
46
52
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
function graphemeCount(value: string): number {
|
|
56
|
+
let length = 0;
|
|
57
|
+
for (const _ of graphemeSegmenter.segment(value)) length += 1;
|
|
58
|
+
return length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isSafePersistedText(value: unknown, limit: number, required = true): value is string {
|
|
62
|
+
return typeof value === "string"
|
|
63
|
+
&& (required ? Boolean(value.trim()) : true)
|
|
64
|
+
&& value === value.trim()
|
|
65
|
+
&& safeTerminalText(value) === value
|
|
66
|
+
&& graphemeCount(value) <= limit;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Normalizes model or user text before it crosses the persisted goal boundary. */
|
|
70
|
+
export function normalizeGoalText(value: string, limit: number, label: string): string {
|
|
71
|
+
if (graphemeCount(value) > limit) throw new Error(`${label} must not exceed ${limit} characters`);
|
|
72
|
+
const normalized = safeTerminalText(value).trim();
|
|
73
|
+
if (!normalized) throw new Error(`${label} must not be empty`);
|
|
74
|
+
if (graphemeCount(normalized) > limit) throw new Error(`${label} must not exceed ${limit} characters`);
|
|
75
|
+
return normalized;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isGoalTurnPhase(value: unknown): value is GoalTurnPhase {
|
|
79
|
+
return value === "ready" || value === "in-flight" || value === "authorized";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isGoalContinueReport(value: unknown, turns: number): value is GoalContinueReport {
|
|
83
|
+
return isUnknownRecord(value)
|
|
84
|
+
&& safeNonNegativeInteger(value.turn)
|
|
85
|
+
&& value.turn >= 1
|
|
86
|
+
&& value.turn <= turns
|
|
87
|
+
&& isSafePersistedText(value.evidence, GOAL_EVIDENCE_LIMIT)
|
|
88
|
+
&& isSafePersistedText(value.nextAction, GOAL_EVIDENCE_LIMIT);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isGoalPendingDecision(value: unknown, turns: number): value is GoalPendingDecision {
|
|
92
|
+
if (!isUnknownRecord(value)
|
|
93
|
+
|| !safeNonNegativeInteger(value.turn)
|
|
94
|
+
|| value.turn < 1
|
|
95
|
+
|| value.turn > turns
|
|
96
|
+
|| !isSafePersistedText(value.evidence, GOAL_EVIDENCE_LIMIT)) return false;
|
|
97
|
+
if (value.kind === "continue") return isSafePersistedText(value.nextAction, GOAL_EVIDENCE_LIMIT);
|
|
98
|
+
return value.kind === "blocker-audit"
|
|
99
|
+
&& typeof value.blockerKey === "string"
|
|
100
|
+
&& /^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.blockerKey)
|
|
101
|
+
&& typeof value.streak === "number"
|
|
102
|
+
&& Number.isInteger(value.streak)
|
|
103
|
+
&& value.streak >= 1
|
|
104
|
+
&& value.streak < 3;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isGoalTurnDecision(value: unknown, turns: number): value is GoalTurnDecision {
|
|
108
|
+
if (!isUnknownRecord(value)
|
|
109
|
+
|| !safeNonNegativeInteger(value.turn)
|
|
110
|
+
|| value.turn < 1
|
|
111
|
+
|| value.turn > turns
|
|
112
|
+
|| !isSafePersistedText(value.evidence, GOAL_EVIDENCE_LIMIT)) return false;
|
|
113
|
+
if (value.kind === "continue") return isSafePersistedText(value.nextAction, GOAL_EVIDENCE_LIMIT);
|
|
114
|
+
if (value.kind === "complete") return value.verification === "file" || value.verification === "model-reported";
|
|
115
|
+
return (value.kind === "blocker-audit"
|
|
116
|
+
&& typeof value.blockerKey === "string"
|
|
117
|
+
&& /^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.blockerKey)
|
|
118
|
+
&& typeof value.streak === "number"
|
|
119
|
+
&& Number.isInteger(value.streak)
|
|
120
|
+
&& value.streak >= 1
|
|
121
|
+
&& value.streak < 3)
|
|
122
|
+
|| (value.kind === "blocked"
|
|
123
|
+
&& typeof value.blockerKey === "string"
|
|
124
|
+
&& /^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.blockerKey)
|
|
125
|
+
&& value.streak === 3);
|
|
126
|
+
}
|
|
127
|
+
|
|
49
128
|
function isGoalFileBaseline(value: unknown): value is GoalFileBaseline {
|
|
50
129
|
if (!isUnknownRecord(value)) return false;
|
|
51
130
|
if (value.exists === false) {
|
|
@@ -75,8 +154,7 @@ function stripUnquotedPathPunctuation(value: string): string {
|
|
|
75
154
|
function isGoalFileVerification(value: unknown): value is GoalFileVerification {
|
|
76
155
|
return isUnknownRecord(value)
|
|
77
156
|
&& value.kind === "file"
|
|
78
|
-
&&
|
|
79
|
-
&& value.path === value.path.trim()
|
|
157
|
+
&& isSafePersistedText(value.path, GOAL_OBJECTIVE_LIMIT)
|
|
80
158
|
&& isAbsoluteFilePath(value.path)
|
|
81
159
|
&& isGoalFileBaseline(value.baseline);
|
|
82
160
|
}
|
|
@@ -85,22 +163,13 @@ function isMaxTurns(value: unknown): value is number {
|
|
|
85
163
|
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= GOAL_MAX_TURNS;
|
|
86
164
|
}
|
|
87
165
|
|
|
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
|
-
|
|
96
166
|
function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
|
|
97
167
|
if (!isUnknownRecord(value)
|
|
98
168
|
|| typeof value.key !== "string"
|
|
99
169
|
|| !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.key)
|
|
100
170
|
|| typeof value.streak !== "number" || !Number.isInteger(value.streak) || value.streak < 1 || value.streak > 3
|
|
101
171
|
|| typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns
|
|
102
|
-
|| value.evidence !== undefined && (
|
|
103
|
-
|| value.evidence !== value.evidence.trim() || !value.evidence || exceedsBlockerEvidenceLimit(value.evidence))) {
|
|
172
|
+
|| value.evidence !== undefined && !isSafePersistedText(value.evidence, GOAL_EVIDENCE_LIMIT)) {
|
|
104
173
|
return false;
|
|
105
174
|
}
|
|
106
175
|
if (status === "complete") return false;
|
|
@@ -126,10 +195,16 @@ export function parseGoalState(value: unknown): GoalState | undefined {
|
|
|
126
195
|
blockerAudit,
|
|
127
196
|
verification,
|
|
128
197
|
maxTurns,
|
|
198
|
+
turnPhase,
|
|
199
|
+
turnDecision,
|
|
200
|
+
lastDecision,
|
|
201
|
+
lastContinueReport,
|
|
202
|
+
stopReason,
|
|
129
203
|
} = value;
|
|
130
204
|
if (version !== GOAL_VERSION
|
|
131
205
|
|| !incrementableNonNegativeInteger(revision) || revision < 1
|
|
132
|
-
|| typeof objective !== "string" || !objective.trim() ||
|
|
206
|
+
|| typeof objective !== "string" || !objective.trim() || safeTerminalText(objective) !== objective
|
|
207
|
+
|| graphemeCount(objective.trim()) > GOAL_OBJECTIVE_LIMIT
|
|
133
208
|
|| !isGoalStatus(status)
|
|
134
209
|
|| !safeNonNegativeInteger(createdAt)
|
|
135
210
|
|| !safeNonNegativeInteger(updatedAt)
|
|
@@ -138,14 +213,37 @@ export function parseGoalState(value: unknown): GoalState | undefined {
|
|
|
138
213
|
|| blockedAuditStartTurn !== undefined
|
|
139
214
|
&& (!safeNonNegativeInteger(blockedAuditStartTurn) || blockedAuditStartTurn > turns)
|
|
140
215
|
|| !safeNonNegativeInteger(baselineTokens)
|
|
141
|
-
|| result !== undefined &&
|
|
216
|
+
|| result !== undefined && !isSafePersistedText(result, GOAL_EVIDENCE_LIMIT, false)
|
|
142
217
|
|| verification !== undefined && !isGoalFileVerification(verification)
|
|
143
218
|
|| maxTurns !== undefined && !isMaxTurns(maxTurns)
|
|
144
219
|
|| resumeAfterManualCompaction !== undefined && resumeAfterManualCompaction !== true
|
|
145
|
-
|| blockerAudit !== undefined && !isGoalBlockerAudit(blockerAudit, turns, status)
|
|
220
|
+
|| blockerAudit !== undefined && !isGoalBlockerAudit(blockerAudit, turns, status)
|
|
221
|
+
|| turnPhase !== undefined && !isGoalTurnPhase(turnPhase)
|
|
222
|
+
|| turnDecision !== undefined && !isGoalPendingDecision(turnDecision, turns)
|
|
223
|
+
|| lastDecision !== undefined && !isGoalTurnDecision(lastDecision, turns)
|
|
224
|
+
|| lastContinueReport !== undefined && !isGoalContinueReport(lastContinueReport, turns)
|
|
225
|
+
|| stopReason !== undefined && !isSafePersistedText(stopReason, GOAL_EVIDENCE_LIMIT)) {
|
|
146
226
|
return undefined;
|
|
147
227
|
}
|
|
148
228
|
|
|
229
|
+
const pending = turnDecision;
|
|
230
|
+
if (pending !== undefined && pending.turn !== turns) return undefined;
|
|
231
|
+
if (isGoalTurnDecision(lastDecision, turns)) {
|
|
232
|
+
if (lastDecision.kind === "complete") {
|
|
233
|
+
if (status !== "complete") return undefined;
|
|
234
|
+
if ((lastDecision.verification === "file") !== (verification !== undefined)) return undefined;
|
|
235
|
+
}
|
|
236
|
+
if (lastDecision.kind === "blocked" && status !== "blocked") return undefined;
|
|
237
|
+
}
|
|
238
|
+
if (pending !== undefined && turnPhase !== "authorized") return undefined;
|
|
239
|
+
if (turnPhase === "authorized" && pending === undefined) return undefined;
|
|
240
|
+
if (turnPhase === "ready" && pending !== undefined) return undefined;
|
|
241
|
+
if (status === "complete" && (turnPhase !== undefined || pending !== undefined)) return undefined;
|
|
242
|
+
if (status === "blocked" && (turnPhase !== undefined || pending !== undefined)) return undefined;
|
|
243
|
+
if (status === "paused" && turnPhase === "ready" && pending !== undefined) return undefined;
|
|
244
|
+
// A pause reason is only current while paused; on any other status it contradicts the state.
|
|
245
|
+
if (stopReason !== undefined && status !== "paused") return undefined;
|
|
246
|
+
|
|
149
247
|
const common: GoalStateCommon = {
|
|
150
248
|
version: GOAL_VERSION,
|
|
151
249
|
revision,
|
|
@@ -158,6 +256,11 @@ export function parseGoalState(value: unknown): GoalState | undefined {
|
|
|
158
256
|
baselineTokens,
|
|
159
257
|
...(verification === undefined ? {} : { verification }),
|
|
160
258
|
...(maxTurns === undefined ? {} : { maxTurns }),
|
|
259
|
+
...(turnPhase === undefined ? {} : { turnPhase }),
|
|
260
|
+
...(pending === undefined ? {} : { turnDecision: pending }),
|
|
261
|
+
...(lastDecision === undefined ? {} : { lastDecision }),
|
|
262
|
+
...(lastContinueReport === undefined ? {} : { lastContinueReport }),
|
|
263
|
+
...(stopReason === undefined ? {} : { stopReason }),
|
|
161
264
|
};
|
|
162
265
|
switch (status) {
|
|
163
266
|
case "active":
|
|
@@ -242,6 +345,45 @@ export async function captureGoalFileBaseline(
|
|
|
242
345
|
}
|
|
243
346
|
}
|
|
244
347
|
|
|
348
|
+
const GOAL_URL_PATTERN = /(?:https?|file):\/\/[^\s"'`]+/giu;
|
|
349
|
+
const GOAL_QUOTED_PATTERN = /`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'/gu;
|
|
350
|
+
const GOAL_ABSOLUTE_PATTERN = /(?:^|[\s"'`(\[{,;])([A-Za-z]:[\\/][^\s,;'"`]+|\/[^\s,;'"`]+)/gu;
|
|
351
|
+
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;
|
|
352
|
+
|
|
353
|
+
function isQuotedGoalFileMention(raw: string): boolean {
|
|
354
|
+
const value = raw.trim();
|
|
355
|
+
if (!value || /[\s\p{Cc}]/u.test(value) || /^(?:https?|file):\/\//iu.test(value) || /[\\/]$/u.test(value)) return false;
|
|
356
|
+
return /[\/\\.:]/u.test(value);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function normalizeGoalFileMention(raw: string, cwd: string): string | undefined {
|
|
360
|
+
const value = raw.trim().replace(/[.,;:!?)\]}]+$/u, "");
|
|
361
|
+
if (!value || /[\s\p{Cc}]/u.test(value)) return undefined;
|
|
362
|
+
const absolute = path.isAbsolute(value) || path.win32.isAbsolute(value) ? value : path.resolve(cwd, value);
|
|
363
|
+
if (!isAbsoluteFilePath(absolute)) return undefined;
|
|
364
|
+
return process.platform === "win32" ? absolute.toLowerCase() : absolute;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Counts distinct path-shaped mentions so several files cannot bind proof to one. */
|
|
368
|
+
function countGoalTargetFiles(objective: string, cwd: string, target: string): number {
|
|
369
|
+
const prose = objective.replace(GOAL_URL_PATTERN, " ");
|
|
370
|
+
const mentions = new Set<string>();
|
|
371
|
+
const add = (raw: string): void => {
|
|
372
|
+
const normalized = normalizeGoalFileMention(raw, cwd);
|
|
373
|
+
if (normalized) mentions.add(normalized);
|
|
374
|
+
};
|
|
375
|
+
add(target);
|
|
376
|
+
for (const match of prose.matchAll(GOAL_QUOTED_PATTERN)) {
|
|
377
|
+
const raw = match[1] ?? match[2] ?? match[3] ?? "";
|
|
378
|
+
if (isQuotedGoalFileMention(raw)) add(raw);
|
|
379
|
+
}
|
|
380
|
+
for (const match of prose.matchAll(GOAL_ABSOLUTE_PATTERN)) {
|
|
381
|
+
if (match[1]) add(stripUnquotedPathPunctuation(match[1].trim()));
|
|
382
|
+
}
|
|
383
|
+
for (const match of prose.matchAll(GOAL_LIST_FILE_PATTERN)) add(match[1] ?? match[2] ?? match[3] ?? match[4] ?? "");
|
|
384
|
+
return mentions.size;
|
|
385
|
+
}
|
|
386
|
+
|
|
245
387
|
/** Captures one explicit output path so goal completion can verify its creation or modification. */
|
|
246
388
|
export async function inferGoalVerification(objective: string, cwd: string): Promise<GoalFileVerification | undefined> {
|
|
247
389
|
const candidates: string[] = [];
|
|
@@ -262,8 +404,12 @@ export async function inferGoalVerification(objective: string, cwd: string): Pro
|
|
|
262
404
|
if (isAbsoluteFilePath(absolute)) resolved.push(absolute);
|
|
263
405
|
}
|
|
264
406
|
const unique = [...new Set(resolved)];
|
|
265
|
-
|
|
266
|
-
|
|
407
|
+
if (unique.length !== 1) return undefined;
|
|
408
|
+
// A goal that names several files must not verify from one file alone.
|
|
409
|
+
// Repeated references to the same file count as one target.
|
|
410
|
+
const filePath = unique[0];
|
|
411
|
+
if (countGoalTargetFiles(objective, cwd, filePath) > 1) return undefined;
|
|
412
|
+
return { kind: "file", path: filePath, baseline: await captureGoalFileBaseline(filePath) };
|
|
267
413
|
}
|
|
268
414
|
|
|
269
415
|
export async function verifyGoalDeliverable(verification: GoalFileVerification): Promise<void> {
|
|
@@ -296,10 +442,19 @@ export async function verifyGoalDeliverable(verification: GoalFileVerification):
|
|
|
296
442
|
}
|
|
297
443
|
}
|
|
298
444
|
|
|
445
|
+
export function boundGoalText(value: string, limit = GOAL_EVIDENCE_LIMIT): string {
|
|
446
|
+
const safe = safeTerminalText(value).trim();
|
|
447
|
+
return [...graphemeSegmenter.segment(safe)]
|
|
448
|
+
.slice(0, limit)
|
|
449
|
+
.map(({ segment }) => segment)
|
|
450
|
+
.join("")
|
|
451
|
+
.trimEnd();
|
|
452
|
+
}
|
|
453
|
+
|
|
299
454
|
export function validateGoalObjective(input: string): string | undefined {
|
|
300
455
|
const objective = input.trim();
|
|
301
|
-
if (!objective) return undefined;
|
|
302
|
-
return
|
|
456
|
+
if (!objective || safeTerminalText(objective) !== objective) return undefined;
|
|
457
|
+
return graphemeCount(objective) <= GOAL_OBJECTIVE_LIMIT ? objective : undefined;
|
|
303
458
|
}
|
|
304
459
|
|
|
305
460
|
export function goalElapsedMilliseconds(state: GoalState, now: number): number {
|
|
@@ -320,6 +475,11 @@ export function commonGoalState(state: GoalState): GoalStateCommon {
|
|
|
320
475
|
baselineTokens: state.baselineTokens,
|
|
321
476
|
...(state.verification === undefined ? {} : { verification: state.verification }),
|
|
322
477
|
...(state.maxTurns === undefined ? {} : { maxTurns: state.maxTurns }),
|
|
478
|
+
...(state.turnPhase === undefined ? {} : { turnPhase: state.turnPhase }),
|
|
479
|
+
...(state.turnDecision === undefined ? {} : { turnDecision: state.turnDecision }),
|
|
480
|
+
...(state.lastDecision === undefined ? {} : { lastDecision: state.lastDecision }),
|
|
481
|
+
...(state.lastContinueReport === undefined ? {} : { lastContinueReport: state.lastContinueReport }),
|
|
482
|
+
...(state.status === "paused" && state.stopReason !== undefined ? { stopReason: state.stopReason } : {}),
|
|
323
483
|
};
|
|
324
484
|
}
|
|
325
485
|
|
|
@@ -349,16 +509,33 @@ export function createNewGoalState(
|
|
|
349
509
|
turns: 0,
|
|
350
510
|
blockedAuditStartTurn: 0,
|
|
351
511
|
baselineTokens,
|
|
512
|
+
turnPhase: "ready",
|
|
352
513
|
...(verification === undefined ? {} : { verification }),
|
|
353
514
|
...(controls.maxTurns === undefined ? {} : { maxTurns: controls.maxTurns }),
|
|
354
515
|
};
|
|
355
516
|
}
|
|
356
517
|
|
|
518
|
+
function nextRevision(current: { revision: number }): number {
|
|
519
|
+
if (!incrementableNonNegativeInteger(current.revision)) {
|
|
520
|
+
throw new Error("Goal revision cannot advance safely");
|
|
521
|
+
}
|
|
522
|
+
return current.revision + 1;
|
|
523
|
+
}
|
|
524
|
+
|
|
357
525
|
export function beginGoalTurnState(
|
|
358
526
|
current: Extract<GoalState, { status: "active" }>,
|
|
359
527
|
now: number,
|
|
360
528
|
): GoalState {
|
|
361
|
-
|
|
529
|
+
if (!incrementableNonNegativeInteger(current.turns)) throw new Error("Goal turn counter cannot advance safely");
|
|
530
|
+
const { turnDecision: _decision, turnPhase: _phase, ...withoutPending } = current;
|
|
531
|
+
return {
|
|
532
|
+
...withoutPending,
|
|
533
|
+
revision: nextRevision(current),
|
|
534
|
+
turns: current.turns + 1,
|
|
535
|
+
updatedAt: now,
|
|
536
|
+
turnPhase: "in-flight",
|
|
537
|
+
...(current.turnDecision === undefined ? {} : { lastDecision: current.turnDecision }),
|
|
538
|
+
};
|
|
362
539
|
}
|
|
363
540
|
|
|
364
541
|
export function checkpointActiveGoalState(
|
|
@@ -367,7 +544,7 @@ export function checkpointActiveGoalState(
|
|
|
367
544
|
): GoalState {
|
|
368
545
|
return {
|
|
369
546
|
...stopGoalClock(current, now),
|
|
370
|
-
revision: current
|
|
547
|
+
revision: nextRevision(current),
|
|
371
548
|
status: "active",
|
|
372
549
|
updatedAt: now,
|
|
373
550
|
activeStartedAt: now,
|
|
@@ -381,12 +558,17 @@ export function pauseGoalState(
|
|
|
381
558
|
result: string | undefined,
|
|
382
559
|
now: number,
|
|
383
560
|
resumeAfterManualCompaction = false,
|
|
561
|
+
preserveTurnAuthorization = false,
|
|
384
562
|
): GoalState {
|
|
385
563
|
const common = stopGoalClock(current, now);
|
|
564
|
+
const { turnDecision: _decision, turnPhase: _phase, ...withoutPending } = common;
|
|
386
565
|
return {
|
|
387
|
-
...
|
|
566
|
+
...withoutPending,
|
|
388
567
|
status: "paused",
|
|
568
|
+
turnPhase: preserveTurnAuthorization ? current.turnPhase : "ready",
|
|
569
|
+
...(preserveTurnAuthorization && current.turnDecision !== undefined ? { turnDecision: current.turnDecision } : {}),
|
|
389
570
|
...(result === undefined ? {} : { result }),
|
|
571
|
+
...(result === undefined ? {} : { stopReason: boundGoalText(result) }),
|
|
390
572
|
...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
|
|
391
573
|
...(resumeAfterManualCompaction ? { resumeAfterManualCompaction: true as const } : {}),
|
|
392
574
|
};
|
|
@@ -397,15 +579,26 @@ export function checkpointPausedGoalState(
|
|
|
397
579
|
now: number,
|
|
398
580
|
): GoalState {
|
|
399
581
|
const { resumeAfterManualCompaction: _resume, ...paused } = current;
|
|
400
|
-
return { ...paused, revision:
|
|
582
|
+
return { ...paused, revision: nextRevision(current), updatedAt: now };
|
|
401
583
|
}
|
|
402
584
|
|
|
403
|
-
export function
|
|
585
|
+
export function recordGoalDecision(
|
|
404
586
|
state: Extract<GoalState, { status: "active" }>,
|
|
405
|
-
|
|
587
|
+
decision: GoalPendingDecision,
|
|
406
588
|
now: number,
|
|
407
|
-
): GoalState {
|
|
408
|
-
|
|
589
|
+
): Extract<GoalState, { status: "active" }> {
|
|
590
|
+
if (decision.turn !== state.turns) throw new Error("Goal decision does not match the active turn");
|
|
591
|
+
return {
|
|
592
|
+
...state,
|
|
593
|
+
revision: nextRevision(state),
|
|
594
|
+
updatedAt: now,
|
|
595
|
+
turnPhase: "authorized",
|
|
596
|
+
turnDecision: decision,
|
|
597
|
+
lastDecision: decision,
|
|
598
|
+
...(decision.kind === "continue"
|
|
599
|
+
? { lastContinueReport: { turn: decision.turn, evidence: decision.evidence, nextAction: decision.nextAction } }
|
|
600
|
+
: {}),
|
|
601
|
+
};
|
|
409
602
|
}
|
|
410
603
|
|
|
411
604
|
export function transitionGoalState(
|
|
@@ -418,27 +611,50 @@ export function transitionGoalState(
|
|
|
418
611
|
const stopped = stopGoalClock(current, now);
|
|
419
612
|
const common: GoalStateCommon = {
|
|
420
613
|
...stopped,
|
|
421
|
-
revision: stopped
|
|
614
|
+
revision: nextRevision(stopped),
|
|
422
615
|
updatedAt: now,
|
|
423
616
|
blockedAuditStartTurn: options.resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
|
|
424
617
|
};
|
|
425
618
|
const blockerAudit = options.resetBlockedAudit ? undefined : options.blockerAudit ?? current.blockerAudit;
|
|
619
|
+
const pending = options.preserveTurnAuthorization ? current.turnDecision : undefined;
|
|
620
|
+
const { turnDecision: _decision, turnPhase: _phase, stopReason: _stopReason, ...withoutPending } = common;
|
|
426
621
|
switch (status) {
|
|
427
622
|
case "active":
|
|
428
|
-
return {
|
|
623
|
+
return {
|
|
624
|
+
...withoutPending,
|
|
625
|
+
...(current.status === "blocked" ? { lastDecision: undefined } : {}),
|
|
626
|
+
status,
|
|
627
|
+
activeStartedAt: now,
|
|
628
|
+
turnPhase: pending !== undefined ? "authorized" : options.resumeInterruptedTurn ? "in-flight" : "ready",
|
|
629
|
+
...(pending === undefined ? {} : { turnDecision: pending }),
|
|
630
|
+
...(blockerAudit === undefined ? {} : { blockerAudit }),
|
|
631
|
+
};
|
|
429
632
|
case "paused":
|
|
430
633
|
return {
|
|
431
|
-
...
|
|
634
|
+
...withoutPending,
|
|
432
635
|
status,
|
|
433
|
-
|
|
636
|
+
turnPhase: options.keepTurnForRecovery ? current.turnPhase : "ready",
|
|
637
|
+
...(options.keepTurnForRecovery && current.turnDecision !== undefined ? { turnDecision: current.turnDecision } : {}),
|
|
638
|
+
...(result === undefined ? {} : { result, stopReason: boundGoalText(result) }),
|
|
434
639
|
...(blockerAudit === undefined ? {} : { blockerAudit }),
|
|
435
640
|
...(options.resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction: true }),
|
|
436
641
|
};
|
|
437
642
|
case "blocked":
|
|
438
643
|
if (result === undefined) throw new Error("A blocked goal requires a result");
|
|
439
|
-
return {
|
|
644
|
+
return {
|
|
645
|
+
...withoutPending,
|
|
646
|
+
status,
|
|
647
|
+
result,
|
|
648
|
+
...(options.decision === undefined ? {} : { lastDecision: options.decision }),
|
|
649
|
+
...(blockerAudit === undefined ? {} : { blockerAudit }),
|
|
650
|
+
};
|
|
440
651
|
case "complete":
|
|
441
652
|
if (result === undefined) throw new Error("A complete goal requires a result");
|
|
442
|
-
return {
|
|
653
|
+
return {
|
|
654
|
+
...withoutPending,
|
|
655
|
+
status,
|
|
656
|
+
result,
|
|
657
|
+
...(options.decision === undefined ? {} : { lastDecision: options.decision }),
|
|
658
|
+
};
|
|
443
659
|
}
|
|
444
660
|
}
|
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;
|