killeros 2.1.31 → 2.1.32
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 +568 -558
- package/Killeros.ts +53 -53
- package/README.md +112 -112
- package/killeros/activity.ts +110 -110
- package/killeros/auto-compaction.ts +342 -342
- package/killeros/change-receipt.ts +676 -676
- package/killeros/codex-fast-state.ts +47 -47
- package/killeros/codex-fast.ts +36 -36
- package/killeros/commands.ts +259 -259
- package/killeros/display.ts +60 -60
- package/killeros/errors.ts +19 -19
- package/killeros/footer.ts +505 -505
- package/killeros/goal-command.ts +26 -26
- package/killeros/goal-interface.ts +560 -560
- package/killeros/goal-runtime.ts +490 -490
- package/killeros/goal-settlement.ts +348 -348
- package/killeros/goal-state.ts +664 -660
- package/killeros/handoff.ts +326 -326
- package/killeros/hooks.ts +449 -449
- package/killeros/notifications.ts +135 -135
- package/killeros/passive-git-status.ts +346 -346
- package/killeros/personal-instructions.ts +122 -122
- package/killeros/question-ui.ts +590 -590
- package/killeros/question.ts +234 -234
- package/killeros/runtime.ts +150 -150
- package/killeros/secret-detector.ts +19 -19
- package/killeros/settings.ts +141 -141
- package/killeros/shell-ui.ts +418 -418
- package/killeros/variants.ts +13 -13
- package/killeros/worked-for.ts +489 -496
- package/package.json +68 -67
- package/themes/killeros.json +87 -87
package/killeros/goal-state.ts
CHANGED
|
@@ -1,660 +1,664 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import type { Stats } from "node:fs";
|
|
3
|
-
import { lstat, open, type FileHandle } from "node:fs/promises";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import type { GoalBlockerAudit, GoalContinueReport, GoalFileBaseline, GoalFileVerification, GoalPendingDecision, GoalState, GoalStateCommon, GoalStatus, GoalTurnDecision, GoalTurnPhase } from "./runtime.ts";
|
|
6
|
-
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
7
|
-
|
|
8
|
-
export const DEFAULT_GOAL_MAX_TURNS = 20;
|
|
9
|
-
export const GOAL_OBJECTIVE_LIMIT = 4_000;
|
|
10
|
-
export const GOAL_EVIDENCE_LIMIT = 2_000;
|
|
11
|
-
export const GOAL_MAX_TURNS = 10_000;
|
|
12
|
-
export const GOAL_VERSION = 1;
|
|
13
|
-
const FILE_HASH_CHUNK_SIZE = 64 * 1024;
|
|
14
|
-
export const FILE_HASH_LIMIT = 64 * 1024 * 1024;
|
|
15
|
-
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
16
|
-
type OpenGoalFile = (filePath: string) => Promise<FileHandle>;
|
|
17
|
-
const openGoalFile: OpenGoalFile = (filePath) => open(filePath, "r");
|
|
18
|
-
|
|
19
|
-
export interface GoalTransitionOptions {
|
|
20
|
-
resetBlockedAudit?: boolean;
|
|
21
|
-
resumeAfterManualCompaction?: true;
|
|
22
|
-
blockerAudit?: GoalBlockerAudit;
|
|
23
|
-
keepTurnForRecovery?: true;
|
|
24
|
-
resumeInterruptedTurn?: true;
|
|
25
|
-
preserveTurnAuthorization?: true;
|
|
26
|
-
decision?: GoalTurnDecision;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function isGoalStatus(value: unknown): value is GoalStatus {
|
|
30
|
-
return value === "active" || value === "paused" || value === "blocked" || value === "complete";
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function finiteNonNegative(value: unknown): value is number {
|
|
34
|
-
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function safeNonNegativeInteger(value: unknown): value is number {
|
|
38
|
-
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function incrementableNonNegativeInteger(value: unknown): value is number {
|
|
42
|
-
return safeNonNegativeInteger(value) && value < Number.MAX_SAFE_INTEGER;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function addGoalMilliseconds(accumulated: number, interval: number): number {
|
|
46
|
-
const total = accumulated + interval;
|
|
47
|
-
if (!safeNonNegativeInteger(total)) throw new Error("Goal active duration exceeds the safe integer range");
|
|
48
|
-
return total;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
|
52
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
53
|
-
}
|
|
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
|
-
|
|
128
|
-
function isGoalFileBaseline(value: unknown): value is GoalFileBaseline {
|
|
129
|
-
if (!isUnknownRecord(value)) return false;
|
|
130
|
-
if (value.exists === false) {
|
|
131
|
-
return value.size === undefined && value.mtimeMs === undefined && value.contentHash === undefined;
|
|
132
|
-
}
|
|
133
|
-
return value.exists === true
|
|
134
|
-
&& finiteNonNegative(value.size)
|
|
135
|
-
&& finiteNonNegative(value.mtimeMs)
|
|
136
|
-
&& (value.contentHash === undefined
|
|
137
|
-
|| value.contentHash === null
|
|
138
|
-
|| typeof value.contentHash === "string" && /^[a-f0-9]{64}$/u.test(value.contentHash));
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function isAbsoluteFilePath(value: string): boolean {
|
|
142
|
-
if (!value || /^(?:https?|file):\/\//iu.test(value) || /[\\\/]$/u.test(value)) return false;
|
|
143
|
-
return path.isAbsolute(value) || path.win32.isAbsolute(value);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function stripUnquotedPathPunctuation(value: string): string {
|
|
147
|
-
const pathWithoutMarks = value.replace(/[.!?]+$/u, "");
|
|
148
|
-
const trailingClosers = pathWithoutMarks.match(/\)+$/u)?.[0].length ?? 0;
|
|
149
|
-
const unmatchedClosers = Math.max(0, pathWithoutMarks.split(")").length - pathWithoutMarks.split("(").length);
|
|
150
|
-
const punctuationLength = Math.min(trailingClosers, unmatchedClosers);
|
|
151
|
-
return pathWithoutMarks.slice(0, punctuationLength ? -punctuationLength : undefined);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function isGoalFileVerification(value: unknown): value is GoalFileVerification {
|
|
155
|
-
return isUnknownRecord(value)
|
|
156
|
-
&& value.kind === "file"
|
|
157
|
-
&& isSafePersistedText(value.path, GOAL_OBJECTIVE_LIMIT)
|
|
158
|
-
&& isAbsoluteFilePath(value.path)
|
|
159
|
-
&& isGoalFileBaseline(value.baseline);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function isMaxTurns(value: unknown): value is number {
|
|
163
|
-
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= GOAL_MAX_TURNS;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
|
|
167
|
-
if (!isUnknownRecord(value)
|
|
168
|
-
|| typeof value.key !== "string"
|
|
169
|
-
|| !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.key)
|
|
170
|
-
|| typeof value.streak !== "number" || !Number.isInteger(value.streak) || value.streak < 1 || value.streak > 3
|
|
171
|
-
|| typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns
|
|
172
|
-
|| value.evidence !== undefined && !isSafePersistedText(value.evidence, GOAL_EVIDENCE_LIMIT)) {
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
if (status === "complete") return false;
|
|
176
|
-
return status === "blocked" ? value.streak === 3 : value.streak < 3;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export function parseGoalState(value: unknown): GoalState | undefined {
|
|
180
|
-
if (!isUnknownRecord(value)) return undefined;
|
|
181
|
-
const {
|
|
182
|
-
version,
|
|
183
|
-
revision,
|
|
184
|
-
objective,
|
|
185
|
-
status,
|
|
186
|
-
createdAt,
|
|
187
|
-
updatedAt,
|
|
188
|
-
activeMilliseconds,
|
|
189
|
-
activeStartedAt,
|
|
190
|
-
turns,
|
|
191
|
-
blockedAuditStartTurn,
|
|
192
|
-
baselineTokens,
|
|
193
|
-
result,
|
|
194
|
-
resumeAfterManualCompaction,
|
|
195
|
-
blockerAudit,
|
|
196
|
-
verification,
|
|
197
|
-
maxTurns,
|
|
198
|
-
turnPhase,
|
|
199
|
-
turnDecision,
|
|
200
|
-
lastDecision,
|
|
201
|
-
lastContinueReport,
|
|
202
|
-
stopReason,
|
|
203
|
-
} = value;
|
|
204
|
-
if (version !== GOAL_VERSION
|
|
205
|
-
|| !incrementableNonNegativeInteger(revision) || revision < 1
|
|
206
|
-
|| typeof objective !== "string" || !objective.trim() || safeTerminalText(objective) !== objective
|
|
207
|
-
|| graphemeCount(objective.trim()) > GOAL_OBJECTIVE_LIMIT
|
|
208
|
-
|| !isGoalStatus(status)
|
|
209
|
-
|| !safeNonNegativeInteger(createdAt)
|
|
210
|
-
|| !safeNonNegativeInteger(updatedAt)
|
|
211
|
-
|| !incrementableNonNegativeInteger(activeMilliseconds)
|
|
212
|
-
|| !incrementableNonNegativeInteger(turns)
|
|
213
|
-
|| blockedAuditStartTurn !== undefined
|
|
214
|
-
&& (!safeNonNegativeInteger(blockedAuditStartTurn) || blockedAuditStartTurn > turns)
|
|
215
|
-
|| !safeNonNegativeInteger(baselineTokens)
|
|
216
|
-
|| result !== undefined && !isSafePersistedText(result, GOAL_EVIDENCE_LIMIT, false)
|
|
217
|
-
|| verification !== undefined && !isGoalFileVerification(verification)
|
|
218
|
-
|| maxTurns !== undefined && !isMaxTurns(maxTurns)
|
|
219
|
-
|| resumeAfterManualCompaction !== undefined && resumeAfterManualCompaction !== true
|
|
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)) {
|
|
226
|
-
return undefined;
|
|
227
|
-
}
|
|
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
|
-
|
|
247
|
-
const common: GoalStateCommon = {
|
|
248
|
-
version: GOAL_VERSION,
|
|
249
|
-
revision,
|
|
250
|
-
objective: objective.trim(),
|
|
251
|
-
createdAt,
|
|
252
|
-
updatedAt,
|
|
253
|
-
activeMilliseconds,
|
|
254
|
-
turns,
|
|
255
|
-
blockedAuditStartTurn: blockedAuditStartTurn ?? 0,
|
|
256
|
-
baselineTokens,
|
|
257
|
-
...(verification === undefined ? {} : { verification }),
|
|
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 }),
|
|
264
|
-
};
|
|
265
|
-
switch (status) {
|
|
266
|
-
case "active":
|
|
267
|
-
if (!safeNonNegativeInteger(activeStartedAt) || resumeAfterManualCompaction !== undefined) return undefined;
|
|
268
|
-
return {
|
|
269
|
-
...common,
|
|
270
|
-
status,
|
|
271
|
-
activeStartedAt,
|
|
272
|
-
...(result === undefined ? {} : { result }),
|
|
273
|
-
...(blockerAudit === undefined ? {} : { blockerAudit }),
|
|
274
|
-
};
|
|
275
|
-
case "paused":
|
|
276
|
-
if (activeStartedAt !== undefined) return undefined;
|
|
277
|
-
return {
|
|
278
|
-
...common,
|
|
279
|
-
status,
|
|
280
|
-
...(result === undefined ? {} : { result }),
|
|
281
|
-
...(blockerAudit === undefined ? {} : { blockerAudit }),
|
|
282
|
-
...(resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction }),
|
|
283
|
-
};
|
|
284
|
-
case "blocked":
|
|
285
|
-
if (activeStartedAt !== undefined || resumeAfterManualCompaction !== undefined || typeof result !== "string") return undefined;
|
|
286
|
-
return { ...common, status, result, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
|
|
287
|
-
case "complete":
|
|
288
|
-
if (activeStartedAt !== undefined || resumeAfterManualCompaction !== undefined
|
|
289
|
-
|| typeof result !== "string" || blockerAudit !== undefined) return undefined;
|
|
290
|
-
return { ...common, status, result };
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function sameFile(left: Stats, right: Stats): boolean {
|
|
295
|
-
return left.dev === right.dev
|
|
296
|
-
&& left.ino === right.ino
|
|
297
|
-
&& left.size === right.size
|
|
298
|
-
&& left.mtimeMs === right.mtimeMs;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
async function hashFile(handle: FileHandle, inspected: Stats): Promise<string> {
|
|
302
|
-
const hash = createHash("sha256");
|
|
303
|
-
const buffer = Buffer.allocUnsafe(FILE_HASH_CHUNK_SIZE);
|
|
304
|
-
let position = 0;
|
|
305
|
-
while (position < inspected.size) {
|
|
306
|
-
const length = Math.min(buffer.length, inspected.size - position);
|
|
307
|
-
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
308
|
-
if (bytesRead === 0) throw new Error("Goal deliverable changed while it was being inspected");
|
|
309
|
-
hash.update(buffer.subarray(0, bytesRead));
|
|
310
|
-
position += bytesRead;
|
|
311
|
-
}
|
|
312
|
-
if (!sameFile(inspected, await handle.stat())) {
|
|
313
|
-
throw new Error("Goal deliverable changed while it was being inspected");
|
|
314
|
-
}
|
|
315
|
-
return hash.digest("hex");
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/** Captures a file baseline with bounded asynchronous I/O and descriptor identity checks. */
|
|
319
|
-
export async function captureGoalFileBaseline(
|
|
320
|
-
filePath: string,
|
|
321
|
-
openFile: OpenGoalFile = openGoalFile,
|
|
322
|
-
): Promise<GoalFileBaseline> {
|
|
323
|
-
let inspected: Stats;
|
|
324
|
-
try {
|
|
325
|
-
inspected = await lstat(filePath);
|
|
326
|
-
} catch (error) {
|
|
327
|
-
if (isUnknownRecord(error) && error.code === "ENOENT") return { exists: false };
|
|
328
|
-
throw error;
|
|
329
|
-
}
|
|
330
|
-
const baseline = { exists: true as const, size: inspected.size, mtimeMs: inspected.mtimeMs };
|
|
331
|
-
if (!inspected.isFile() || inspected.size > FILE_HASH_LIMIT) return baseline;
|
|
332
|
-
|
|
333
|
-
let handle: FileHandle | undefined;
|
|
334
|
-
try {
|
|
335
|
-
handle = await openFile(filePath);
|
|
336
|
-
if (!sameFile(inspected, await handle.stat())) {
|
|
337
|
-
throw new Error("Goal deliverable changed while it was being inspected");
|
|
338
|
-
}
|
|
339
|
-
return { ...baseline, contentHash: await hashFile(handle, inspected) };
|
|
340
|
-
} catch (error) {
|
|
341
|
-
if (error instanceof Error && error.message === "Goal deliverable changed while it was being inspected") throw error;
|
|
342
|
-
return { ...baseline, contentHash: null };
|
|
343
|
-
} finally {
|
|
344
|
-
await handle?.close();
|
|
345
|
-
}
|
|
346
|
-
}
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
if (
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const
|
|
411
|
-
if (
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
if (
|
|
427
|
-
throw new Error(`Goal deliverable
|
|
428
|
-
}
|
|
429
|
-
if (verification.baseline.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
return
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
...(state.
|
|
481
|
-
...(state.
|
|
482
|
-
...(state.
|
|
483
|
-
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
current
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
...
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
...(
|
|
574
|
-
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
...(
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
...
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
...
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
...
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
3
|
+
import { lstat, open, type FileHandle } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { GoalBlockerAudit, GoalContinueReport, GoalFileBaseline, GoalFileVerification, GoalPendingDecision, GoalState, GoalStateCommon, GoalStatus, GoalTurnDecision, GoalTurnPhase } from "./runtime.ts";
|
|
6
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_GOAL_MAX_TURNS = 20;
|
|
9
|
+
export const GOAL_OBJECTIVE_LIMIT = 4_000;
|
|
10
|
+
export const GOAL_EVIDENCE_LIMIT = 2_000;
|
|
11
|
+
export const GOAL_MAX_TURNS = 10_000;
|
|
12
|
+
export const GOAL_VERSION = 1;
|
|
13
|
+
const FILE_HASH_CHUNK_SIZE = 64 * 1024;
|
|
14
|
+
export const FILE_HASH_LIMIT = 64 * 1024 * 1024;
|
|
15
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
16
|
+
type OpenGoalFile = (filePath: string) => Promise<FileHandle>;
|
|
17
|
+
const openGoalFile: OpenGoalFile = (filePath) => open(filePath, "r");
|
|
18
|
+
|
|
19
|
+
export interface GoalTransitionOptions {
|
|
20
|
+
resetBlockedAudit?: boolean;
|
|
21
|
+
resumeAfterManualCompaction?: true;
|
|
22
|
+
blockerAudit?: GoalBlockerAudit;
|
|
23
|
+
keepTurnForRecovery?: true;
|
|
24
|
+
resumeInterruptedTurn?: true;
|
|
25
|
+
preserveTurnAuthorization?: true;
|
|
26
|
+
decision?: GoalTurnDecision;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isGoalStatus(value: unknown): value is GoalStatus {
|
|
30
|
+
return value === "active" || value === "paused" || value === "blocked" || value === "complete";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function finiteNonNegative(value: unknown): value is number {
|
|
34
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function safeNonNegativeInteger(value: unknown): value is number {
|
|
38
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function incrementableNonNegativeInteger(value: unknown): value is number {
|
|
42
|
+
return safeNonNegativeInteger(value) && value < Number.MAX_SAFE_INTEGER;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function addGoalMilliseconds(accumulated: number, interval: number): number {
|
|
46
|
+
const total = accumulated + interval;
|
|
47
|
+
if (!safeNonNegativeInteger(total)) throw new Error("Goal active duration exceeds the safe integer range");
|
|
48
|
+
return total;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
|
52
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
53
|
+
}
|
|
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
|
+
|
|
128
|
+
function isGoalFileBaseline(value: unknown): value is GoalFileBaseline {
|
|
129
|
+
if (!isUnknownRecord(value)) return false;
|
|
130
|
+
if (value.exists === false) {
|
|
131
|
+
return value.size === undefined && value.mtimeMs === undefined && value.contentHash === undefined;
|
|
132
|
+
}
|
|
133
|
+
return value.exists === true
|
|
134
|
+
&& finiteNonNegative(value.size)
|
|
135
|
+
&& finiteNonNegative(value.mtimeMs)
|
|
136
|
+
&& (value.contentHash === undefined
|
|
137
|
+
|| value.contentHash === null
|
|
138
|
+
|| typeof value.contentHash === "string" && /^[a-f0-9]{64}$/u.test(value.contentHash));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isAbsoluteFilePath(value: string): boolean {
|
|
142
|
+
if (!value || /^(?:https?|file):\/\//iu.test(value) || /[\\\/]$/u.test(value)) return false;
|
|
143
|
+
return path.isAbsolute(value) || path.win32.isAbsolute(value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function stripUnquotedPathPunctuation(value: string): string {
|
|
147
|
+
const pathWithoutMarks = value.replace(/[.!?]+$/u, "");
|
|
148
|
+
const trailingClosers = pathWithoutMarks.match(/\)+$/u)?.[0].length ?? 0;
|
|
149
|
+
const unmatchedClosers = Math.max(0, pathWithoutMarks.split(")").length - pathWithoutMarks.split("(").length);
|
|
150
|
+
const punctuationLength = Math.min(trailingClosers, unmatchedClosers);
|
|
151
|
+
return pathWithoutMarks.slice(0, punctuationLength ? -punctuationLength : undefined);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isGoalFileVerification(value: unknown): value is GoalFileVerification {
|
|
155
|
+
return isUnknownRecord(value)
|
|
156
|
+
&& value.kind === "file"
|
|
157
|
+
&& isSafePersistedText(value.path, GOAL_OBJECTIVE_LIMIT)
|
|
158
|
+
&& isAbsoluteFilePath(value.path)
|
|
159
|
+
&& isGoalFileBaseline(value.baseline);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isMaxTurns(value: unknown): value is number {
|
|
163
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= GOAL_MAX_TURNS;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
|
|
167
|
+
if (!isUnknownRecord(value)
|
|
168
|
+
|| typeof value.key !== "string"
|
|
169
|
+
|| !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.key)
|
|
170
|
+
|| typeof value.streak !== "number" || !Number.isInteger(value.streak) || value.streak < 1 || value.streak > 3
|
|
171
|
+
|| typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns
|
|
172
|
+
|| value.evidence !== undefined && !isSafePersistedText(value.evidence, GOAL_EVIDENCE_LIMIT)) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
if (status === "complete") return false;
|
|
176
|
+
return status === "blocked" ? value.streak === 3 : value.streak < 3;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function parseGoalState(value: unknown): GoalState | undefined {
|
|
180
|
+
if (!isUnknownRecord(value)) return undefined;
|
|
181
|
+
const {
|
|
182
|
+
version,
|
|
183
|
+
revision,
|
|
184
|
+
objective,
|
|
185
|
+
status,
|
|
186
|
+
createdAt,
|
|
187
|
+
updatedAt,
|
|
188
|
+
activeMilliseconds,
|
|
189
|
+
activeStartedAt,
|
|
190
|
+
turns,
|
|
191
|
+
blockedAuditStartTurn,
|
|
192
|
+
baselineTokens,
|
|
193
|
+
result,
|
|
194
|
+
resumeAfterManualCompaction,
|
|
195
|
+
blockerAudit,
|
|
196
|
+
verification,
|
|
197
|
+
maxTurns,
|
|
198
|
+
turnPhase,
|
|
199
|
+
turnDecision,
|
|
200
|
+
lastDecision,
|
|
201
|
+
lastContinueReport,
|
|
202
|
+
stopReason,
|
|
203
|
+
} = value;
|
|
204
|
+
if (version !== GOAL_VERSION
|
|
205
|
+
|| !incrementableNonNegativeInteger(revision) || revision < 1
|
|
206
|
+
|| typeof objective !== "string" || !objective.trim() || safeTerminalText(objective) !== objective
|
|
207
|
+
|| graphemeCount(objective.trim()) > GOAL_OBJECTIVE_LIMIT
|
|
208
|
+
|| !isGoalStatus(status)
|
|
209
|
+
|| !safeNonNegativeInteger(createdAt)
|
|
210
|
+
|| !safeNonNegativeInteger(updatedAt)
|
|
211
|
+
|| !incrementableNonNegativeInteger(activeMilliseconds)
|
|
212
|
+
|| !incrementableNonNegativeInteger(turns)
|
|
213
|
+
|| blockedAuditStartTurn !== undefined
|
|
214
|
+
&& (!safeNonNegativeInteger(blockedAuditStartTurn) || blockedAuditStartTurn > turns)
|
|
215
|
+
|| !safeNonNegativeInteger(baselineTokens)
|
|
216
|
+
|| result !== undefined && !isSafePersistedText(result, GOAL_EVIDENCE_LIMIT, false)
|
|
217
|
+
|| verification !== undefined && !isGoalFileVerification(verification)
|
|
218
|
+
|| maxTurns !== undefined && !isMaxTurns(maxTurns)
|
|
219
|
+
|| resumeAfterManualCompaction !== undefined && resumeAfterManualCompaction !== true
|
|
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)) {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
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
|
+
|
|
247
|
+
const common: GoalStateCommon = {
|
|
248
|
+
version: GOAL_VERSION,
|
|
249
|
+
revision,
|
|
250
|
+
objective: objective.trim(),
|
|
251
|
+
createdAt,
|
|
252
|
+
updatedAt,
|
|
253
|
+
activeMilliseconds,
|
|
254
|
+
turns,
|
|
255
|
+
blockedAuditStartTurn: blockedAuditStartTurn ?? 0,
|
|
256
|
+
baselineTokens,
|
|
257
|
+
...(verification === undefined ? {} : { verification }),
|
|
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 }),
|
|
264
|
+
};
|
|
265
|
+
switch (status) {
|
|
266
|
+
case "active":
|
|
267
|
+
if (!safeNonNegativeInteger(activeStartedAt) || resumeAfterManualCompaction !== undefined) return undefined;
|
|
268
|
+
return {
|
|
269
|
+
...common,
|
|
270
|
+
status,
|
|
271
|
+
activeStartedAt,
|
|
272
|
+
...(result === undefined ? {} : { result }),
|
|
273
|
+
...(blockerAudit === undefined ? {} : { blockerAudit }),
|
|
274
|
+
};
|
|
275
|
+
case "paused":
|
|
276
|
+
if (activeStartedAt !== undefined) return undefined;
|
|
277
|
+
return {
|
|
278
|
+
...common,
|
|
279
|
+
status,
|
|
280
|
+
...(result === undefined ? {} : { result }),
|
|
281
|
+
...(blockerAudit === undefined ? {} : { blockerAudit }),
|
|
282
|
+
...(resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction }),
|
|
283
|
+
};
|
|
284
|
+
case "blocked":
|
|
285
|
+
if (activeStartedAt !== undefined || resumeAfterManualCompaction !== undefined || typeof result !== "string") return undefined;
|
|
286
|
+
return { ...common, status, result, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
|
|
287
|
+
case "complete":
|
|
288
|
+
if (activeStartedAt !== undefined || resumeAfterManualCompaction !== undefined
|
|
289
|
+
|| typeof result !== "string" || blockerAudit !== undefined) return undefined;
|
|
290
|
+
return { ...common, status, result };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function sameFile(left: Stats, right: Stats): boolean {
|
|
295
|
+
return left.dev === right.dev
|
|
296
|
+
&& left.ino === right.ino
|
|
297
|
+
&& left.size === right.size
|
|
298
|
+
&& left.mtimeMs === right.mtimeMs;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function hashFile(handle: FileHandle, inspected: Stats): Promise<string> {
|
|
302
|
+
const hash = createHash("sha256");
|
|
303
|
+
const buffer = Buffer.allocUnsafe(FILE_HASH_CHUNK_SIZE);
|
|
304
|
+
let position = 0;
|
|
305
|
+
while (position < inspected.size) {
|
|
306
|
+
const length = Math.min(buffer.length, inspected.size - position);
|
|
307
|
+
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
308
|
+
if (bytesRead === 0) throw new Error("Goal deliverable changed while it was being inspected");
|
|
309
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
310
|
+
position += bytesRead;
|
|
311
|
+
}
|
|
312
|
+
if (!sameFile(inspected, await handle.stat())) {
|
|
313
|
+
throw new Error("Goal deliverable changed while it was being inspected");
|
|
314
|
+
}
|
|
315
|
+
return hash.digest("hex");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Captures a file baseline with bounded asynchronous I/O and descriptor identity checks. */
|
|
319
|
+
export async function captureGoalFileBaseline(
|
|
320
|
+
filePath: string,
|
|
321
|
+
openFile: OpenGoalFile = openGoalFile,
|
|
322
|
+
): Promise<GoalFileBaseline> {
|
|
323
|
+
let inspected: Stats;
|
|
324
|
+
try {
|
|
325
|
+
inspected = await lstat(filePath);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (isUnknownRecord(error) && error.code === "ENOENT") return { exists: false };
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
const baseline = { exists: true as const, size: inspected.size, mtimeMs: inspected.mtimeMs };
|
|
331
|
+
if (!inspected.isFile() || inspected.size > FILE_HASH_LIMIT) return baseline;
|
|
332
|
+
|
|
333
|
+
let handle: FileHandle | undefined;
|
|
334
|
+
try {
|
|
335
|
+
handle = await openFile(filePath);
|
|
336
|
+
if (!sameFile(inspected, await handle.stat())) {
|
|
337
|
+
throw new Error("Goal deliverable changed while it was being inspected");
|
|
338
|
+
}
|
|
339
|
+
return { ...baseline, contentHash: await hashFile(handle, inspected) };
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error instanceof Error && error.message === "Goal deliverable changed while it was being inspected") throw error;
|
|
342
|
+
return { ...baseline, contentHash: null };
|
|
343
|
+
} finally {
|
|
344
|
+
await handle?.close();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
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
|
+
const GOAL_RELATIVE_FILE_PATTERN = /(?:^|[\s"'`(\[{,;])((?:\.{1,2}[\\/])*[A-Za-z0-9_][A-Za-z0-9_.-]*(?:[\\/][A-Za-z0-9_.-]+)*\.[A-Za-z0-9]{1,12})\b/gu;
|
|
353
|
+
|
|
354
|
+
function isQuotedGoalFileMention(raw: string): boolean {
|
|
355
|
+
const value = raw.trim();
|
|
356
|
+
if (!value || /[\s\p{Cc}]/u.test(value) || /^(?:https?|file):\/\//iu.test(value) || /[\\/]$/u.test(value)) return false;
|
|
357
|
+
return /[\/\\.:]/u.test(value);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function normalizeGoalFileMention(raw: string, cwd: string): string | undefined {
|
|
361
|
+
const value = raw.trim().replace(/[.,;:!?)\]}]+$/u, "");
|
|
362
|
+
if (!value || /[\s\p{Cc}]/u.test(value)) return undefined;
|
|
363
|
+
const absolute = path.isAbsolute(value) || path.win32.isAbsolute(value) ? value : path.resolve(cwd, value);
|
|
364
|
+
if (!isAbsoluteFilePath(absolute)) return undefined;
|
|
365
|
+
return process.platform === "win32" ? absolute.toLowerCase() : absolute;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Counts distinct path-shaped mentions so several files cannot bind proof to one. */
|
|
369
|
+
function countGoalTargetFiles(objective: string, cwd: string, target: string): number {
|
|
370
|
+
const prose = objective.replace(GOAL_URL_PATTERN, " ");
|
|
371
|
+
const mentions = new Set<string>();
|
|
372
|
+
const add = (raw: string): void => {
|
|
373
|
+
const normalized = normalizeGoalFileMention(raw, cwd);
|
|
374
|
+
if (normalized) mentions.add(normalized);
|
|
375
|
+
};
|
|
376
|
+
add(target);
|
|
377
|
+
for (const match of prose.matchAll(GOAL_QUOTED_PATTERN)) {
|
|
378
|
+
const raw = match[1] ?? match[2] ?? match[3] ?? "";
|
|
379
|
+
if (isQuotedGoalFileMention(raw)) add(raw);
|
|
380
|
+
}
|
|
381
|
+
for (const match of prose.matchAll(GOAL_ABSOLUTE_PATTERN)) {
|
|
382
|
+
if (match[1]) add(stripUnquotedPathPunctuation(match[1].trim()));
|
|
383
|
+
}
|
|
384
|
+
for (const match of prose.matchAll(GOAL_LIST_FILE_PATTERN)) add(match[1] ?? match[2] ?? match[3] ?? match[4] ?? "");
|
|
385
|
+
for (const match of prose.replace(GOAL_QUOTED_PATTERN, " ").matchAll(GOAL_RELATIVE_FILE_PATTERN)) {
|
|
386
|
+
if (match[1]) add(match[1]);
|
|
387
|
+
}
|
|
388
|
+
return mentions.size;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Captures one explicit output path so goal completion can verify its creation or modification. */
|
|
392
|
+
export async function inferGoalVerification(objective: string, cwd: string): Promise<GoalFileVerification | undefined> {
|
|
393
|
+
const candidates: string[] = [];
|
|
394
|
+
const destination = /\b(?:create|write|save|generate)\b[^\r\n]{0,160}?\b(?:file|document|markdown|report|spreadsheet|presentation|image)\b\s+(?:to|at|as|destination(?:\s+is)?|output(?:\s+(?:to|at))?)\b\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z]:\\[^\s,;]+|\/[^\s,;]+))/giu;
|
|
395
|
+
for (const match of objective.matchAll(destination)) {
|
|
396
|
+
const quoted = match[1] ?? match[2] ?? match[3];
|
|
397
|
+
candidates.push(quoted !== undefined ? quoted.trim() : stripUnquotedPathPunctuation((match[4] ?? "").trim()));
|
|
398
|
+
}
|
|
399
|
+
const direct = /\b(?:update|edit|fix|refactor|migrate)\s+(`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)')/giu;
|
|
400
|
+
for (const match of objective.matchAll(direct)) {
|
|
401
|
+
const quoted = match[2] ?? match[3] ?? match[4];
|
|
402
|
+
if (quoted !== undefined) candidates.push(quoted.trim());
|
|
403
|
+
}
|
|
404
|
+
const resolved: string[] = [];
|
|
405
|
+
for (const raw of candidates) {
|
|
406
|
+
if (!raw || /^(?:https?|file):\/\//iu.test(raw) || /[\\\/]$/u.test(raw)) continue;
|
|
407
|
+
const absolute = path.isAbsolute(raw) || path.win32.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
|
|
408
|
+
if (isAbsoluteFilePath(absolute)) resolved.push(absolute);
|
|
409
|
+
}
|
|
410
|
+
const unique = [...new Set(resolved)];
|
|
411
|
+
if (unique.length !== 1) return undefined;
|
|
412
|
+
// A goal that names several files must not verify from one file alone.
|
|
413
|
+
// Repeated references to the same file count as one target.
|
|
414
|
+
const filePath = unique[0];
|
|
415
|
+
if (countGoalTargetFiles(objective, cwd, filePath) > 1) return undefined;
|
|
416
|
+
return { kind: "file", path: filePath, baseline: await captureGoalFileBaseline(filePath) };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function verifyGoalDeliverable(verification: GoalFileVerification): Promise<void> {
|
|
420
|
+
let artifact: Stats;
|
|
421
|
+
try {
|
|
422
|
+
artifact = await lstat(verification.path);
|
|
423
|
+
} catch {
|
|
424
|
+
throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
|
|
425
|
+
}
|
|
426
|
+
if (!artifact.isFile()) {
|
|
427
|
+
throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
|
|
428
|
+
}
|
|
429
|
+
if (!verification.baseline.exists) return;
|
|
430
|
+
if (verification.baseline.contentHash === null) {
|
|
431
|
+
throw new Error(`Goal deliverable content cannot be verified: ${verification.path}`);
|
|
432
|
+
}
|
|
433
|
+
if (verification.baseline.contentHash !== undefined) {
|
|
434
|
+
const current = await captureGoalFileBaseline(verification.path);
|
|
435
|
+
if (!current.exists || current.contentHash === null) {
|
|
436
|
+
throw new Error(`Goal deliverable content cannot be verified: ${verification.path}`);
|
|
437
|
+
}
|
|
438
|
+
if (current.contentHash === verification.baseline.contentHash) {
|
|
439
|
+
throw new Error(`Goal deliverable has not changed since the goal started: ${verification.path}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (verification.baseline.contentHash === undefined
|
|
443
|
+
&& artifact.size === verification.baseline.size
|
|
444
|
+
&& artifact.mtimeMs === verification.baseline.mtimeMs) {
|
|
445
|
+
throw new Error(`Goal deliverable has not changed since the goal started: ${verification.path}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function boundGoalText(value: string, limit = GOAL_EVIDENCE_LIMIT): string {
|
|
450
|
+
const safe = safeTerminalText(value).trim();
|
|
451
|
+
return [...graphemeSegmenter.segment(safe)]
|
|
452
|
+
.slice(0, limit)
|
|
453
|
+
.map(({ segment }) => segment)
|
|
454
|
+
.join("")
|
|
455
|
+
.trimEnd();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function validateGoalObjective(input: string): string | undefined {
|
|
459
|
+
const objective = input.trim();
|
|
460
|
+
if (!objective || safeTerminalText(objective) !== objective) return undefined;
|
|
461
|
+
return graphemeCount(objective) <= GOAL_OBJECTIVE_LIMIT ? objective : undefined;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function goalElapsedMilliseconds(state: GoalState, now: number): number {
|
|
465
|
+
const activeInterval = state.status === "active" ? Math.max(0, now - state.activeStartedAt) : 0;
|
|
466
|
+
return addGoalMilliseconds(state.activeMilliseconds, activeInterval);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function commonGoalState(state: GoalState): GoalStateCommon {
|
|
470
|
+
return {
|
|
471
|
+
version: state.version,
|
|
472
|
+
revision: state.revision,
|
|
473
|
+
objective: state.objective,
|
|
474
|
+
createdAt: state.createdAt,
|
|
475
|
+
updatedAt: state.updatedAt,
|
|
476
|
+
activeMilliseconds: state.activeMilliseconds,
|
|
477
|
+
turns: state.turns,
|
|
478
|
+
blockedAuditStartTurn: state.blockedAuditStartTurn,
|
|
479
|
+
baselineTokens: state.baselineTokens,
|
|
480
|
+
...(state.verification === undefined ? {} : { verification: state.verification }),
|
|
481
|
+
...(state.maxTurns === undefined ? {} : { maxTurns: state.maxTurns }),
|
|
482
|
+
...(state.turnPhase === undefined ? {} : { turnPhase: state.turnPhase }),
|
|
483
|
+
...(state.turnDecision === undefined ? {} : { turnDecision: state.turnDecision }),
|
|
484
|
+
...(state.lastDecision === undefined ? {} : { lastDecision: state.lastDecision }),
|
|
485
|
+
...(state.lastContinueReport === undefined ? {} : { lastContinueReport: state.lastContinueReport }),
|
|
486
|
+
...(state.status === "paused" && state.stopReason !== undefined ? { stopReason: state.stopReason } : {}),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function stopGoalClock(state: GoalState, now: number): GoalStateCommon {
|
|
491
|
+
const common = commonGoalState(state);
|
|
492
|
+
return state.status === "active"
|
|
493
|
+
? { ...common, activeMilliseconds: addGoalMilliseconds(common.activeMilliseconds, Math.max(0, now - state.activeStartedAt)) }
|
|
494
|
+
: common;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function createNewGoalState(
|
|
498
|
+
objective: string,
|
|
499
|
+
baselineTokens: number,
|
|
500
|
+
verification: GoalFileVerification | undefined,
|
|
501
|
+
now: number,
|
|
502
|
+
controls: { maxTurns?: number } = {},
|
|
503
|
+
): GoalState {
|
|
504
|
+
return {
|
|
505
|
+
version: GOAL_VERSION,
|
|
506
|
+
revision: 1,
|
|
507
|
+
objective,
|
|
508
|
+
status: "active",
|
|
509
|
+
createdAt: now,
|
|
510
|
+
updatedAt: now,
|
|
511
|
+
activeMilliseconds: 0,
|
|
512
|
+
activeStartedAt: now,
|
|
513
|
+
turns: 0,
|
|
514
|
+
blockedAuditStartTurn: 0,
|
|
515
|
+
baselineTokens,
|
|
516
|
+
turnPhase: "ready",
|
|
517
|
+
...(verification === undefined ? {} : { verification }),
|
|
518
|
+
...(controls.maxTurns === undefined ? {} : { maxTurns: controls.maxTurns }),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function nextRevision(current: { revision: number }): number {
|
|
523
|
+
if (!incrementableNonNegativeInteger(current.revision)) {
|
|
524
|
+
throw new Error("Goal revision cannot advance safely");
|
|
525
|
+
}
|
|
526
|
+
return current.revision + 1;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function beginGoalTurnState(
|
|
530
|
+
current: Extract<GoalState, { status: "active" }>,
|
|
531
|
+
now: number,
|
|
532
|
+
): GoalState {
|
|
533
|
+
if (!incrementableNonNegativeInteger(current.turns)) throw new Error("Goal turn counter cannot advance safely");
|
|
534
|
+
const { turnDecision: _decision, turnPhase: _phase, ...withoutPending } = current;
|
|
535
|
+
return {
|
|
536
|
+
...withoutPending,
|
|
537
|
+
revision: nextRevision(current),
|
|
538
|
+
turns: current.turns + 1,
|
|
539
|
+
updatedAt: now,
|
|
540
|
+
turnPhase: "in-flight",
|
|
541
|
+
...(current.turnDecision === undefined ? {} : { lastDecision: current.turnDecision }),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function checkpointActiveGoalState(
|
|
546
|
+
current: Extract<GoalState, { status: "active" }>,
|
|
547
|
+
now: number,
|
|
548
|
+
): GoalState {
|
|
549
|
+
return {
|
|
550
|
+
...stopGoalClock(current, now),
|
|
551
|
+
revision: nextRevision(current),
|
|
552
|
+
status: "active",
|
|
553
|
+
updatedAt: now,
|
|
554
|
+
activeStartedAt: now,
|
|
555
|
+
...(current.result === undefined ? {} : { result: current.result }),
|
|
556
|
+
...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function pauseGoalState(
|
|
561
|
+
current: GoalState,
|
|
562
|
+
result: string | undefined,
|
|
563
|
+
now: number,
|
|
564
|
+
resumeAfterManualCompaction = false,
|
|
565
|
+
preserveTurnAuthorization = false,
|
|
566
|
+
): GoalState {
|
|
567
|
+
const common = stopGoalClock(current, now);
|
|
568
|
+
const { turnDecision: _decision, turnPhase: _phase, ...withoutPending } = common;
|
|
569
|
+
return {
|
|
570
|
+
...withoutPending,
|
|
571
|
+
status: "paused",
|
|
572
|
+
turnPhase: preserveTurnAuthorization ? current.turnPhase : "ready",
|
|
573
|
+
...(preserveTurnAuthorization && current.turnDecision !== undefined ? { turnDecision: current.turnDecision } : {}),
|
|
574
|
+
...(result === undefined ? {} : { result }),
|
|
575
|
+
...(result === undefined ? {} : { stopReason: boundGoalText(result) }),
|
|
576
|
+
...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
|
|
577
|
+
...(resumeAfterManualCompaction ? { resumeAfterManualCompaction: true as const } : {}),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function checkpointPausedGoalState(
|
|
582
|
+
current: Extract<GoalState, { status: "paused" }>,
|
|
583
|
+
now: number,
|
|
584
|
+
): GoalState {
|
|
585
|
+
const { resumeAfterManualCompaction: _resume, ...paused } = current;
|
|
586
|
+
return { ...paused, revision: nextRevision(current), updatedAt: now };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function recordGoalDecision(
|
|
590
|
+
state: Extract<GoalState, { status: "active" }>,
|
|
591
|
+
decision: GoalPendingDecision,
|
|
592
|
+
now: number,
|
|
593
|
+
): Extract<GoalState, { status: "active" }> {
|
|
594
|
+
if (decision.turn !== state.turns) throw new Error("Goal decision does not match the active turn");
|
|
595
|
+
return {
|
|
596
|
+
...state,
|
|
597
|
+
revision: nextRevision(state),
|
|
598
|
+
updatedAt: now,
|
|
599
|
+
turnPhase: "authorized",
|
|
600
|
+
turnDecision: decision,
|
|
601
|
+
lastDecision: decision,
|
|
602
|
+
...(decision.kind === "continue"
|
|
603
|
+
? { lastContinueReport: { turn: decision.turn, evidence: decision.evidence, nextAction: decision.nextAction } }
|
|
604
|
+
: {}),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function transitionGoalState(
|
|
609
|
+
current: GoalState,
|
|
610
|
+
status: GoalStatus,
|
|
611
|
+
result: string | undefined,
|
|
612
|
+
options: GoalTransitionOptions,
|
|
613
|
+
now: number,
|
|
614
|
+
): GoalState {
|
|
615
|
+
const stopped = stopGoalClock(current, now);
|
|
616
|
+
const common: GoalStateCommon = {
|
|
617
|
+
...stopped,
|
|
618
|
+
revision: nextRevision(stopped),
|
|
619
|
+
updatedAt: now,
|
|
620
|
+
blockedAuditStartTurn: options.resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
|
|
621
|
+
};
|
|
622
|
+
const blockerAudit = options.resetBlockedAudit ? undefined : options.blockerAudit ?? current.blockerAudit;
|
|
623
|
+
const pending = options.preserveTurnAuthorization ? current.turnDecision : undefined;
|
|
624
|
+
const { turnDecision: _decision, turnPhase: _phase, stopReason: _stopReason, ...withoutPending } = common;
|
|
625
|
+
switch (status) {
|
|
626
|
+
case "active":
|
|
627
|
+
return {
|
|
628
|
+
...withoutPending,
|
|
629
|
+
...(current.status === "blocked" ? { lastDecision: undefined } : {}),
|
|
630
|
+
status,
|
|
631
|
+
activeStartedAt: now,
|
|
632
|
+
turnPhase: pending !== undefined ? "authorized" : options.resumeInterruptedTurn ? "in-flight" : "ready",
|
|
633
|
+
...(pending === undefined ? {} : { turnDecision: pending }),
|
|
634
|
+
...(blockerAudit === undefined ? {} : { blockerAudit }),
|
|
635
|
+
};
|
|
636
|
+
case "paused":
|
|
637
|
+
return {
|
|
638
|
+
...withoutPending,
|
|
639
|
+
status,
|
|
640
|
+
turnPhase: options.keepTurnForRecovery ? current.turnPhase : "ready",
|
|
641
|
+
...(options.keepTurnForRecovery && current.turnDecision !== undefined ? { turnDecision: current.turnDecision } : {}),
|
|
642
|
+
...(result === undefined ? {} : { result, stopReason: boundGoalText(result) }),
|
|
643
|
+
...(blockerAudit === undefined ? {} : { blockerAudit }),
|
|
644
|
+
...(options.resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction: true }),
|
|
645
|
+
};
|
|
646
|
+
case "blocked":
|
|
647
|
+
if (result === undefined) throw new Error("A blocked goal requires a result");
|
|
648
|
+
return {
|
|
649
|
+
...withoutPending,
|
|
650
|
+
status,
|
|
651
|
+
result,
|
|
652
|
+
...(options.decision === undefined ? {} : { lastDecision: options.decision }),
|
|
653
|
+
...(blockerAudit === undefined ? {} : { blockerAudit }),
|
|
654
|
+
};
|
|
655
|
+
case "complete":
|
|
656
|
+
if (result === undefined) throw new Error("A complete goal requires a result");
|
|
657
|
+
return {
|
|
658
|
+
...withoutPending,
|
|
659
|
+
status,
|
|
660
|
+
result,
|
|
661
|
+
...(options.decision === undefined ? {} : { lastDecision: options.decision }),
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
}
|