killeros 3.0.6 → 3.0.8

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.
@@ -1,724 +1,753 @@
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
- let stripped = value;
148
- while (true) {
149
- const withoutMarks = stripped.replace(/[.,;:!?]+$/u, "");
150
- if (withoutMarks !== stripped) {
151
- stripped = withoutMarks;
152
- continue;
153
- }
154
- const closer = stripped.at(-1);
155
- const opener = closer === ")" ? "(" : closer === "]" ? "[" : closer === "}" ? "{" : undefined;
156
- if (closer === undefined || opener === undefined
157
- || stripped.split(closer).length <= stripped.split(opener).length) return stripped;
158
- stripped = stripped.slice(0, -1);
159
- }
160
- }
161
-
162
- function isGoalFileVerification(value: unknown): value is GoalFileVerification {
163
- return isUnknownRecord(value)
164
- && value.kind === "file"
165
- && isSafePersistedText(value.path, GOAL_OBJECTIVE_LIMIT)
166
- && isAbsoluteFilePath(value.path)
167
- && isGoalFileBaseline(value.baseline);
168
- }
169
-
170
- function isMaxTurns(value: unknown): value is number {
171
- return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= GOAL_MAX_TURNS;
172
- }
173
-
174
- function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
175
- if (!isUnknownRecord(value)
176
- || typeof value.key !== "string"
177
- || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.key)
178
- || typeof value.streak !== "number" || !Number.isInteger(value.streak) || value.streak < 1 || value.streak > 3
179
- || typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns
180
- || value.evidence !== undefined && !isSafePersistedText(value.evidence, GOAL_EVIDENCE_LIMIT)) {
181
- return false;
182
- }
183
- if (status === "complete") return false;
184
- return status === "blocked" ? value.streak === 3 : value.streak < 3;
185
- }
186
-
187
- export function parseGoalState(value: unknown): GoalState | undefined {
188
- if (!isUnknownRecord(value)) return undefined;
189
- const {
190
- version,
191
- revision,
192
- objective,
193
- status,
194
- createdAt,
195
- updatedAt,
196
- activeMilliseconds,
197
- activeStartedAt,
198
- turns,
199
- blockedAuditStartTurn,
200
- baselineTokens,
201
- result,
202
- resumeAfterManualCompaction,
203
- blockerAudit,
204
- verification,
205
- maxTurns,
206
- turnPhase,
207
- turnDecision,
208
- lastDecision,
209
- lastContinueReport,
210
- stopReason,
211
- } = value;
212
- if (version !== GOAL_VERSION
213
- || !incrementableNonNegativeInteger(revision) || revision < 1
214
- || typeof objective !== "string" || !objective.trim() || safeTerminalText(objective) !== objective
215
- || graphemeCount(objective.trim()) > GOAL_OBJECTIVE_LIMIT
216
- || !isGoalStatus(status)
217
- || !safeNonNegativeInteger(createdAt)
218
- || !safeNonNegativeInteger(updatedAt)
219
- || !incrementableNonNegativeInteger(activeMilliseconds)
220
- || !incrementableNonNegativeInteger(turns)
221
- || blockedAuditStartTurn !== undefined
222
- && (!safeNonNegativeInteger(blockedAuditStartTurn) || blockedAuditStartTurn > turns)
223
- || !safeNonNegativeInteger(baselineTokens)
224
- || result !== undefined && !isSafePersistedText(result, GOAL_EVIDENCE_LIMIT, false)
225
- || verification !== undefined && !isGoalFileVerification(verification)
226
- || maxTurns !== undefined && !isMaxTurns(maxTurns)
227
- || resumeAfterManualCompaction !== undefined && resumeAfterManualCompaction !== true
228
- || blockerAudit !== undefined && !isGoalBlockerAudit(blockerAudit, turns, status)
229
- || turnPhase !== undefined && !isGoalTurnPhase(turnPhase)
230
- || turnDecision !== undefined && !isGoalPendingDecision(turnDecision, turns)
231
- || lastDecision !== undefined && !isGoalTurnDecision(lastDecision, turns)
232
- || lastContinueReport !== undefined && !isGoalContinueReport(lastContinueReport, turns)
233
- || stopReason !== undefined && !isSafePersistedText(stopReason, GOAL_EVIDENCE_LIMIT)) {
234
- return undefined;
235
- }
236
-
237
- const pending = turnDecision;
238
- if (pending !== undefined && pending.turn !== turns) return undefined;
239
- if (isGoalTurnDecision(lastDecision, turns)) {
240
- if (lastDecision.kind === "complete") {
241
- if (status !== "complete") return undefined;
242
- if ((lastDecision.verification === "file") !== (verification !== undefined)) return undefined;
243
- }
244
- if (lastDecision.kind === "blocked" && status !== "blocked") return undefined;
245
- }
246
- if (pending !== undefined && turnPhase !== "authorized") return undefined;
247
- if (turnPhase === "authorized" && pending === undefined) return undefined;
248
- if (turnPhase === "ready" && pending !== undefined) return undefined;
249
- if (status === "complete" && (turnPhase !== undefined || pending !== undefined)) return undefined;
250
- if (status === "blocked" && (turnPhase !== undefined || pending !== undefined)) return undefined;
251
- if (status === "paused" && turnPhase === "ready" && pending !== undefined) return undefined;
252
- // A pause reason is only current while paused; on any other status it contradicts the state.
253
- if (stopReason !== undefined && status !== "paused") return undefined;
254
-
255
- const common: GoalStateCommon = {
256
- version: GOAL_VERSION,
257
- revision,
258
- objective: objective.trim(),
259
- createdAt,
260
- updatedAt,
261
- activeMilliseconds,
262
- turns,
263
- blockedAuditStartTurn: blockedAuditStartTurn ?? 0,
264
- baselineTokens,
265
- ...(verification === undefined ? {} : { verification }),
266
- ...(maxTurns === undefined ? {} : { maxTurns }),
267
- ...(turnPhase === undefined ? {} : { turnPhase }),
268
- ...(pending === undefined ? {} : { turnDecision: pending }),
269
- ...(lastDecision === undefined ? {} : { lastDecision }),
270
- ...(lastContinueReport === undefined ? {} : { lastContinueReport }),
271
- ...(stopReason === undefined ? {} : { stopReason }),
272
- };
273
- switch (status) {
274
- case "active":
275
- if (!safeNonNegativeInteger(activeStartedAt) || resumeAfterManualCompaction !== undefined) return undefined;
276
- return {
277
- ...common,
278
- status,
279
- activeStartedAt,
280
- ...(result === undefined ? {} : { result }),
281
- ...(blockerAudit === undefined ? {} : { blockerAudit }),
282
- };
283
- case "paused":
284
- if (activeStartedAt !== undefined) return undefined;
285
- return {
286
- ...common,
287
- status,
288
- ...(result === undefined ? {} : { result }),
289
- ...(blockerAudit === undefined ? {} : { blockerAudit }),
290
- ...(resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction }),
291
- };
292
- case "blocked":
293
- if (activeStartedAt !== undefined || resumeAfterManualCompaction !== undefined || typeof result !== "string") return undefined;
294
- return { ...common, status, result, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
295
- case "complete":
296
- if (activeStartedAt !== undefined || resumeAfterManualCompaction !== undefined
297
- || typeof result !== "string" || blockerAudit !== undefined) return undefined;
298
- return { ...common, status, result };
299
- }
300
- }
301
-
302
- function sameFile(left: Stats, right: Stats): boolean {
303
- return left.dev === right.dev
304
- && left.ino === right.ino
305
- && left.size === right.size
306
- && left.mtimeMs === right.mtimeMs;
307
- }
308
-
309
- async function hashFile(handle: FileHandle, inspected: Stats): Promise<string> {
310
- const hash = createHash("sha256");
311
- const buffer = Buffer.allocUnsafe(FILE_HASH_CHUNK_SIZE);
312
- let position = 0;
313
- while (position < inspected.size) {
314
- const length = Math.min(buffer.length, inspected.size - position);
315
- const { bytesRead } = await handle.read(buffer, 0, length, position);
316
- if (bytesRead === 0) throw new Error("Goal deliverable changed while it was being inspected");
317
- hash.update(buffer.subarray(0, bytesRead));
318
- position += bytesRead;
319
- }
320
- if (!sameFile(inspected, await handle.stat())) {
321
- throw new Error("Goal deliverable changed while it was being inspected");
322
- }
323
- return hash.digest("hex");
324
- }
325
-
326
- /** Captures a file baseline with bounded asynchronous I/O and descriptor identity checks. */
327
- export async function captureGoalFileBaseline(
328
- filePath: string,
329
- openFile: OpenGoalFile = openGoalFile,
330
- ): Promise<GoalFileBaseline> {
331
- let inspected: Stats;
332
- try {
333
- inspected = await lstat(filePath);
334
- } catch (error) {
335
- if (isUnknownRecord(error) && error.code === "ENOENT") return { exists: false };
336
- throw error;
337
- }
338
- const baseline = { exists: true as const, size: inspected.size, mtimeMs: inspected.mtimeMs };
339
- if (!inspected.isFile() || inspected.size > FILE_HASH_LIMIT) return baseline;
340
-
341
- let handle: FileHandle | undefined;
342
- try {
343
- handle = await openFile(filePath);
344
- if (!sameFile(inspected, await handle.stat())) {
345
- throw new Error("Goal deliverable changed while it was being inspected");
346
- }
347
- return { ...baseline, contentHash: await hashFile(handle, inspected) };
348
- } catch (error) {
349
- if (error instanceof Error && error.message === "Goal deliverable changed while it was being inspected") throw error;
350
- return { ...baseline, contentHash: null };
351
- } finally {
352
- await handle?.close();
353
- }
354
- }
355
-
356
- const GOAL_URL_PATTERN = /(?:https?|file):\/\/[^\s"'`]+/giu;
357
- const GOAL_QUOTED_PATTERN = /`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'/gu;
358
- const GOAL_ABSOLUTE_PATTERN = /(?:^|[\s"'`(\[{,;])([A-Za-z]:[\\/][^\s,;'"`]+|\/[^\s,;'"`]+)/gu;
359
- const GOAL_LIST_FILE_PATTERN = /(?:\band\b|\bor\b|\bplus\b|\balong\s+with\b|[,;&+])\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|((?:\.{1,2}[\\/])*[A-Za-z0-9_][A-Za-z0-9_.-]*(?:[\\/][A-Za-z0-9_.-]+)*\.[A-Za-z0-9]{1,12})\b|(Makefile|Dockerfile|\.gitignore)\b)/giu;
360
- 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;
361
-
362
- function isQuotedGoalFileMention(raw: string): boolean {
363
- const value = raw.trim();
364
- if (!value || /\p{Cc}/u.test(value) || /^(?:https?|file):\/\//iu.test(value) || /[\\/]$/u.test(value)) return false;
365
- return /[\/\\.:]/u.test(value);
366
- }
367
-
368
- function isPathShapedGoalCandidate(raw: string): boolean {
369
- if (/\p{Cc}/u.test(raw)) return false;
370
- const value = raw.trim();
371
- if (!value || /[\\\/]$/u.test(value)) return false;
372
- if (path.isAbsolute(value) || path.win32.isAbsolute(value)) return true;
373
- if (URL.canParse(value)) return false;
374
- return /[\\\/]/u.test(value)
375
- || value.startsWith(".") && value !== "." && value !== ".."
376
- || /\.[A-Za-z0-9]{1,12}$/u.test(value);
377
- }
378
-
379
- function goalPathIdentity(filePath: string): string {
380
- const nativePath = process.platform === "win32" ? filePath.replaceAll("/", "\\") : filePath;
381
- // Parent segments may traverse symlinks; keep them distinct without filesystem proof.
382
- const normalized = nativePath.split(path.sep).includes("..") ? nativePath : path.normalize(nativePath);
383
- return process.platform === "win32" ? normalized.toLowerCase() : normalized;
384
- }
385
-
386
- function absoluteGoalFilePath(value: string, cwd: string): string {
387
- if (path.isAbsolute(value) || path.win32.isAbsolute(value)) return value;
388
- const nativePath = process.platform === "win32" ? value.replaceAll("/", "\\") : value;
389
- // Resolving parent segments lexically can select a different file across a directory symlink.
390
- return nativePath.split(path.sep).includes("..")
391
- ? `${path.resolve(cwd)}${path.sep}${nativePath}`
392
- : path.resolve(cwd, value);
393
- }
394
-
395
- function normalizeGoalFileMention(raw: string, cwd: string, quoted = false): string | undefined {
396
- const trimmed = raw.trim();
397
- const value = quoted ? trimmed : stripUnquotedPathPunctuation(trimmed);
398
- if (!value || /\p{Cc}/u.test(value) || !quoted && /\s/u.test(value)) return undefined;
399
- const absolute = absoluteGoalFilePath(value, cwd);
400
- if (!isAbsoluteFilePath(absolute)) return undefined;
401
- return goalPathIdentity(absolute);
402
- }
403
-
404
- /** Counts distinct path-shaped mentions so several files cannot bind proof to one. */
405
- function countGoalTargetFiles(objective: string, cwd: string, target: string, targetQuoted: boolean): number {
406
- const source = objective.replace(GOAL_URL_PATTERN, " ");
407
- // ponytail: Only these exact prose forms are excluded; quote ambiguous bare filenames for exact proof.
408
- const prose = source.replace(GOAL_QUOTED_PATTERN, " ")
409
- .replace(/(?<![A-Za-z0-9_.\\/-])version\s+\d+(?:\.\d+)+(?=$|[\s,;:!?)\]}]|\.(?=$|[\s,;:!?)\]}]))/giu, " ")
410
- .replace(/(?<![A-Za-z0-9_.\\/-])for\s+Node\.js(?!\s+compatibility\.[A-Za-z0-9])(?=$|[\s,;:!?)\]}]|\.(?=$|[\s,;:!?)\]}]))|(?<![A-Za-z0-9_.\\/-])Node\.js\s+compatibility(?=$|[\s,;:!?)\]}]|\.(?=$|[\s,;:!?)\]}]))/giu, " ");
411
- const mentions = new Set<string>();
412
- const add = (raw: string, quoted = false): void => {
413
- const normalized = normalizeGoalFileMention(raw, cwd, quoted);
414
- if (normalized) mentions.add(normalized);
415
- };
416
- add(target, targetQuoted);
417
- for (const match of source.matchAll(GOAL_QUOTED_PATTERN)) {
418
- const raw = match[1] ?? match[2] ?? match[3] ?? "";
419
- if (isQuotedGoalFileMention(raw)) add(raw, true);
420
- }
421
- for (const match of source.matchAll(GOAL_LIST_FILE_PATTERN)) {
422
- const quoted = match[1] ?? match[2] ?? match[3];
423
- if (quoted !== undefined) add(quoted, true);
424
- }
425
- for (const match of prose.matchAll(GOAL_ABSOLUTE_PATTERN)) {
426
- if (match[1]) add(stripUnquotedPathPunctuation(match[1].trim()));
427
- }
428
- for (const match of prose.matchAll(GOAL_LIST_FILE_PATTERN)) {
429
- if (match[4]) add(match[4]);
430
- if (match[5]) add(match[5]);
431
- }
432
- for (const match of prose.matchAll(GOAL_RELATIVE_FILE_PATTERN)) {
433
- if (match[1]) add(match[1]);
434
- }
435
- return mentions.size;
436
- }
437
-
438
- /** Captures one explicit output path so goal completion can verify its creation or modification. */
439
- export async function inferGoalVerification(objective: string, cwd: string): Promise<GoalFileVerification | undefined> {
440
- const candidates: Array<{ raw: string; quoted: boolean }> = [];
441
- 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;
442
- for (const match of objective.matchAll(destination)) {
443
- const quoted = match[1] ?? match[2] ?? match[3];
444
- candidates.push({
445
- raw: quoted !== undefined ? quoted : stripUnquotedPathPunctuation((match[4] ?? "").trim()),
446
- quoted: quoted !== undefined,
447
- });
448
- }
449
- const direct = /\b(?:update|edit|fix|refactor|migrate)\s+(`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)')/giu;
450
- for (const match of objective.matchAll(direct)) {
451
- const quoted = match[2] ?? match[3] ?? match[4];
452
- if (quoted !== undefined) candidates.push({ raw: quoted, quoted: true });
453
- }
454
- const operands = new Set<string>();
455
- const resolved = new Map<string, { filePath: string; quoted: boolean }>();
456
- for (const candidate of candidates) {
457
- const value = candidate.raw.trim();
458
- if (!value || /\p{Cc}/u.test(value)) continue;
459
- const alreadyAbsolute = path.isAbsolute(value) || path.win32.isAbsolute(value);
460
- if (!alreadyAbsolute && URL.canParse(value)) continue;
461
- const absolute = absoluteGoalFilePath(value, cwd);
462
- const identity = goalPathIdentity(absolute);
463
- operands.add(identity);
464
- if (!isPathShapedGoalCandidate(candidate.raw) || !isAbsoluteFilePath(absolute)) continue;
465
- const existing = resolved.get(identity);
466
- resolved.set(identity, {
467
- filePath: existing?.filePath ?? absolute,
468
- quoted: candidate.quoted || existing?.quoted === true,
469
- });
470
- }
471
- if (operands.size !== 1 || resolved.size !== 1) return undefined;
472
- // A goal that names several files must not verify from one file alone.
473
- // Repeated references to the same file count as one target.
474
- const target = resolved.values().next().value;
475
- if (target === undefined || countGoalTargetFiles(objective, cwd, target.filePath, target.quoted) > 1) return undefined;
476
- return { kind: "file", path: target.filePath, baseline: await captureGoalFileBaseline(target.filePath) };
477
- }
478
-
479
- export async function verifyGoalDeliverable(verification: GoalFileVerification): Promise<void> {
480
- let artifact: Stats;
481
- try {
482
- artifact = await lstat(verification.path);
483
- } catch {
484
- throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
485
- }
486
- if (!artifact.isFile()) {
487
- throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
488
- }
489
- if (!verification.baseline.exists) return;
490
- if (verification.baseline.contentHash === null) {
491
- throw new Error(`Goal deliverable content cannot be verified: ${verification.path}`);
492
- }
493
- if (verification.baseline.contentHash !== undefined) {
494
- const current = await captureGoalFileBaseline(verification.path);
495
- if (!current.exists || current.contentHash === null) {
496
- throw new Error(`Goal deliverable content cannot be verified: ${verification.path}`);
497
- }
498
- if (current.contentHash === verification.baseline.contentHash) {
499
- throw new Error(`Goal deliverable has not changed since the goal started: ${verification.path}`);
500
- }
501
- }
502
- if (verification.baseline.contentHash === undefined
503
- && artifact.size === verification.baseline.size
504
- && artifact.mtimeMs === verification.baseline.mtimeMs) {
505
- throw new Error(`Goal deliverable has not changed since the goal started: ${verification.path}`);
506
- }
507
- }
508
-
509
- export function boundGoalText(value: string, limit = GOAL_EVIDENCE_LIMIT): string {
510
- const safe = safeTerminalText(value).trim();
511
- return [...graphemeSegmenter.segment(safe)]
512
- .slice(0, limit)
513
- .map(({ segment }) => segment)
514
- .join("")
515
- .trimEnd();
516
- }
517
-
518
- export function validateGoalObjective(input: string): string | undefined {
519
- const objective = input.trim();
520
- if (!objective || safeTerminalText(objective) !== objective) return undefined;
521
- return graphemeCount(objective) <= GOAL_OBJECTIVE_LIMIT ? objective : undefined;
522
- }
523
-
524
- export function goalElapsedMilliseconds(state: GoalState, now: number): number {
525
- const activeInterval = state.status === "active" ? Math.max(0, now - state.activeStartedAt) : 0;
526
- return addGoalMilliseconds(state.activeMilliseconds, activeInterval);
527
- }
528
-
529
- export function commonGoalState(state: GoalState): GoalStateCommon {
530
- return {
531
- version: state.version,
532
- revision: state.revision,
533
- objective: state.objective,
534
- createdAt: state.createdAt,
535
- updatedAt: state.updatedAt,
536
- activeMilliseconds: state.activeMilliseconds,
537
- turns: state.turns,
538
- blockedAuditStartTurn: state.blockedAuditStartTurn,
539
- baselineTokens: state.baselineTokens,
540
- ...(state.verification === undefined ? {} : { verification: state.verification }),
541
- ...(state.maxTurns === undefined ? {} : { maxTurns: state.maxTurns }),
542
- ...(state.turnPhase === undefined ? {} : { turnPhase: state.turnPhase }),
543
- ...(state.turnDecision === undefined ? {} : { turnDecision: state.turnDecision }),
544
- ...(state.lastDecision === undefined ? {} : { lastDecision: state.lastDecision }),
545
- ...(state.lastContinueReport === undefined ? {} : { lastContinueReport: state.lastContinueReport }),
546
- ...(state.status === "paused" && state.stopReason !== undefined ? { stopReason: state.stopReason } : {}),
547
- };
548
- }
549
-
550
- export function stopGoalClock(state: GoalState, now: number): GoalStateCommon {
551
- const common = commonGoalState(state);
552
- return state.status === "active"
553
- ? { ...common, activeMilliseconds: addGoalMilliseconds(common.activeMilliseconds, Math.max(0, now - state.activeStartedAt)) }
554
- : common;
555
- }
556
-
557
- export function createNewGoalState(
558
- objective: string,
559
- baselineTokens: number,
560
- verification: GoalFileVerification | undefined,
561
- now: number,
562
- controls: { maxTurns?: number } = {},
563
- ): GoalState {
564
- return {
565
- version: GOAL_VERSION,
566
- revision: 1,
567
- objective,
568
- status: "active",
569
- createdAt: now,
570
- updatedAt: now,
571
- activeMilliseconds: 0,
572
- activeStartedAt: now,
573
- turns: 0,
574
- blockedAuditStartTurn: 0,
575
- baselineTokens,
576
- turnPhase: "ready",
577
- ...(verification === undefined ? {} : { verification }),
578
- ...(controls.maxTurns === undefined ? {} : { maxTurns: controls.maxTurns }),
579
- };
580
- }
581
-
582
- function nextRevision(current: { revision: number }): number {
583
- if (!incrementableNonNegativeInteger(current.revision)) {
584
- throw new Error("Goal revision cannot advance safely");
585
- }
586
- return current.revision + 1;
587
- }
588
-
589
- export function beginGoalTurnState(
590
- current: Extract<GoalState, { status: "active" }>,
591
- now: number,
592
- ): GoalState {
593
- if (!incrementableNonNegativeInteger(current.turns)) throw new Error("Goal turn counter cannot advance safely");
594
- const { turnDecision: _decision, turnPhase: _phase, ...withoutPending } = current;
595
- return {
596
- ...withoutPending,
597
- revision: nextRevision(current),
598
- turns: current.turns + 1,
599
- updatedAt: now,
600
- turnPhase: "in-flight",
601
- ...(current.turnDecision === undefined ? {} : { lastDecision: current.turnDecision }),
602
- };
603
- }
604
-
605
- export function checkpointActiveGoalState(
606
- current: Extract<GoalState, { status: "active" }>,
607
- now: number,
608
- ): GoalState {
609
- return {
610
- ...stopGoalClock(current, now),
611
- revision: nextRevision(current),
612
- status: "active",
613
- updatedAt: now,
614
- activeStartedAt: now,
615
- ...(current.result === undefined ? {} : { result: current.result }),
616
- ...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
617
- };
618
- }
619
-
620
- export function pauseGoalState(
621
- current: GoalState,
622
- result: string | undefined,
623
- now: number,
624
- resumeAfterManualCompaction = false,
625
- preserveTurnAuthorization = false,
626
- ): GoalState {
627
- const common = stopGoalClock(current, now);
628
- const { turnDecision: _decision, turnPhase: _phase, ...withoutPending } = common;
629
- return {
630
- ...withoutPending,
631
- status: "paused",
632
- turnPhase: preserveTurnAuthorization ? current.turnPhase : "ready",
633
- ...(preserveTurnAuthorization && current.turnDecision !== undefined ? { turnDecision: current.turnDecision } : {}),
634
- ...(result === undefined ? {} : { result }),
635
- ...(result === undefined ? {} : { stopReason: boundGoalText(result) }),
636
- ...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
637
- ...(resumeAfterManualCompaction ? { resumeAfterManualCompaction: true as const } : {}),
638
- };
639
- }
640
-
641
- export function checkpointPausedGoalState(
642
- current: Extract<GoalState, { status: "paused" }>,
643
- now: number,
644
- ): GoalState {
645
- const { resumeAfterManualCompaction: _resume, ...paused } = current;
646
- return { ...paused, revision: nextRevision(current), updatedAt: now };
647
- }
648
-
649
- export function recordGoalDecision(
650
- state: Extract<GoalState, { status: "active" }>,
651
- decision: GoalPendingDecision,
652
- now: number,
653
- ): Extract<GoalState, { status: "active" }> {
654
- if (decision.turn !== state.turns) throw new Error("Goal decision does not match the active turn");
655
- return {
656
- ...state,
657
- revision: nextRevision(state),
658
- updatedAt: now,
659
- turnPhase: "authorized",
660
- turnDecision: decision,
661
- lastDecision: decision,
662
- ...(decision.kind === "continue"
663
- ? { lastContinueReport: { turn: decision.turn, evidence: decision.evidence, nextAction: decision.nextAction } }
664
- : {}),
665
- };
666
- }
667
-
668
- export function transitionGoalState(
669
- current: GoalState,
670
- status: GoalStatus,
671
- result: string | undefined,
672
- options: GoalTransitionOptions,
673
- now: number,
674
- ): GoalState {
675
- const stopped = stopGoalClock(current, now);
676
- const common: GoalStateCommon = {
677
- ...stopped,
678
- revision: nextRevision(stopped),
679
- updatedAt: now,
680
- blockedAuditStartTurn: options.resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
681
- };
682
- const blockerAudit = options.resetBlockedAudit ? undefined : options.blockerAudit ?? current.blockerAudit;
683
- const pending = options.preserveTurnAuthorization ? current.turnDecision : undefined;
684
- const { turnDecision: _decision, turnPhase: _phase, stopReason: _stopReason, ...withoutPending } = common;
685
- switch (status) {
686
- case "active":
687
- return {
688
- ...withoutPending,
689
- ...(current.status === "blocked" ? { lastDecision: undefined } : {}),
690
- status,
691
- activeStartedAt: now,
692
- turnPhase: pending !== undefined ? "authorized" : options.resumeInterruptedTurn ? "in-flight" : "ready",
693
- ...(pending === undefined ? {} : { turnDecision: pending }),
694
- ...(blockerAudit === undefined ? {} : { blockerAudit }),
695
- };
696
- case "paused":
697
- return {
698
- ...withoutPending,
699
- status,
700
- turnPhase: options.keepTurnForRecovery ? current.turnPhase : "ready",
701
- ...(options.keepTurnForRecovery && current.turnDecision !== undefined ? { turnDecision: current.turnDecision } : {}),
702
- ...(result === undefined ? {} : { result, stopReason: boundGoalText(result) }),
703
- ...(blockerAudit === undefined ? {} : { blockerAudit }),
704
- ...(options.resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction: true }),
705
- };
706
- case "blocked":
707
- if (result === undefined) throw new Error("A blocked goal requires a result");
708
- return {
709
- ...withoutPending,
710
- status,
711
- result,
712
- ...(options.decision === undefined ? {} : { lastDecision: options.decision }),
713
- ...(blockerAudit === undefined ? {} : { blockerAudit }),
714
- };
715
- case "complete":
716
- if (result === undefined) throw new Error("A complete goal requires a result");
717
- return {
718
- ...withoutPending,
719
- status,
720
- result,
721
- ...(options.decision === undefined ? {} : { lastDecision: options.decision }),
722
- };
723
- }
724
- }
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
+ let stripped = value;
148
+ while (true) {
149
+ const withoutMarks = stripped.replace(/[.,;:!?]+$/u, "");
150
+ if (withoutMarks !== stripped) {
151
+ stripped = withoutMarks;
152
+ continue;
153
+ }
154
+ const closer = stripped.at(-1);
155
+ const opener = closer === ")" ? "(" : closer === "]" ? "[" : closer === "}" ? "{" : undefined;
156
+ if (closer === undefined || opener === undefined
157
+ || stripped.split(closer).length <= stripped.split(opener).length) return stripped;
158
+ stripped = stripped.slice(0, -1);
159
+ }
160
+ }
161
+
162
+ function isGoalFileVerification(value: unknown): value is GoalFileVerification {
163
+ return isUnknownRecord(value)
164
+ && value.kind === "file"
165
+ && isSafePersistedText(value.path, GOAL_OBJECTIVE_LIMIT)
166
+ && isAbsoluteFilePath(value.path)
167
+ && isGoalFileBaseline(value.baseline);
168
+ }
169
+
170
+ function isMaxTurns(value: unknown): value is number {
171
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= GOAL_MAX_TURNS;
172
+ }
173
+
174
+ function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
175
+ if (!isUnknownRecord(value)
176
+ || typeof value.key !== "string"
177
+ || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.key)
178
+ || typeof value.streak !== "number" || !Number.isInteger(value.streak) || value.streak < 1 || value.streak > 3
179
+ || typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns
180
+ || value.evidence !== undefined && !isSafePersistedText(value.evidence, GOAL_EVIDENCE_LIMIT)) {
181
+ return false;
182
+ }
183
+ if (status === "complete") return false;
184
+ return status === "blocked" ? value.streak === 3 : value.streak < 3;
185
+ }
186
+
187
+ export function parseGoalState(value: unknown): GoalState | undefined {
188
+ if (!isUnknownRecord(value)) return undefined;
189
+ const {
190
+ version,
191
+ revision,
192
+ objective,
193
+ status,
194
+ createdAt,
195
+ updatedAt,
196
+ activeMilliseconds,
197
+ activeStartedAt,
198
+ turns,
199
+ blockedAuditStartTurn,
200
+ baselineTokens,
201
+ result,
202
+ resumeAfterManualCompaction,
203
+ blockerAudit,
204
+ verification,
205
+ maxTurns,
206
+ turnPhase,
207
+ turnDecision,
208
+ lastDecision,
209
+ lastContinueReport,
210
+ stopReason,
211
+ } = value;
212
+ if (version !== GOAL_VERSION
213
+ || !incrementableNonNegativeInteger(revision) || revision < 1
214
+ || typeof objective !== "string" || !objective.trim() || safeTerminalText(objective) !== objective
215
+ || graphemeCount(objective.trim()) > GOAL_OBJECTIVE_LIMIT
216
+ || !isGoalStatus(status)
217
+ || !safeNonNegativeInteger(createdAt)
218
+ || !safeNonNegativeInteger(updatedAt)
219
+ || !incrementableNonNegativeInteger(activeMilliseconds)
220
+ || !incrementableNonNegativeInteger(turns)
221
+ || blockedAuditStartTurn !== undefined
222
+ && (!safeNonNegativeInteger(blockedAuditStartTurn) || blockedAuditStartTurn > turns)
223
+ || !safeNonNegativeInteger(baselineTokens)
224
+ || result !== undefined && !isSafePersistedText(result, GOAL_EVIDENCE_LIMIT, false)
225
+ || verification !== undefined && !isGoalFileVerification(verification)
226
+ || maxTurns !== undefined && !isMaxTurns(maxTurns)
227
+ || resumeAfterManualCompaction !== undefined && resumeAfterManualCompaction !== true
228
+ || blockerAudit !== undefined && !isGoalBlockerAudit(blockerAudit, turns, status)
229
+ || turnPhase !== undefined && !isGoalTurnPhase(turnPhase)
230
+ || turnDecision !== undefined && !isGoalPendingDecision(turnDecision, turns)
231
+ || lastDecision !== undefined && !isGoalTurnDecision(lastDecision, turns)
232
+ || lastContinueReport !== undefined && !isGoalContinueReport(lastContinueReport, turns)
233
+ || stopReason !== undefined && !isSafePersistedText(stopReason, GOAL_EVIDENCE_LIMIT)) {
234
+ return undefined;
235
+ }
236
+
237
+ const pending = turnDecision;
238
+ if (pending !== undefined && pending.turn !== turns) return undefined;
239
+ if (isGoalTurnDecision(lastDecision, turns)) {
240
+ if (lastDecision.kind === "complete") {
241
+ if (status !== "complete") return undefined;
242
+ if ((lastDecision.verification === "file") !== (verification !== undefined)) return undefined;
243
+ }
244
+ if (lastDecision.kind === "blocked" && status !== "blocked") return undefined;
245
+ }
246
+ if (pending !== undefined && turnPhase !== "authorized") return undefined;
247
+ if (turnPhase === "authorized" && pending === undefined) return undefined;
248
+ if (turnPhase === "ready" && pending !== undefined) return undefined;
249
+ if (status === "complete" && (turnPhase !== undefined || pending !== undefined)) return undefined;
250
+ if (status === "blocked" && (turnPhase !== undefined || pending !== undefined)) return undefined;
251
+ if (status === "paused" && turnPhase === "ready" && pending !== undefined) return undefined;
252
+ // A pause reason is only current while paused; on any other status it contradicts the state.
253
+ if (stopReason !== undefined && status !== "paused") return undefined;
254
+
255
+ const common: GoalStateCommon = {
256
+ version: GOAL_VERSION,
257
+ revision,
258
+ objective: objective.trim(),
259
+ createdAt,
260
+ updatedAt,
261
+ activeMilliseconds,
262
+ turns,
263
+ blockedAuditStartTurn: blockedAuditStartTurn ?? 0,
264
+ baselineTokens,
265
+ ...(verification === undefined ? {} : { verification }),
266
+ ...(maxTurns === undefined ? {} : { maxTurns }),
267
+ ...(turnPhase === undefined ? {} : { turnPhase }),
268
+ ...(pending === undefined ? {} : { turnDecision: pending }),
269
+ ...(lastDecision === undefined ? {} : { lastDecision }),
270
+ ...(lastContinueReport === undefined ? {} : { lastContinueReport }),
271
+ ...(stopReason === undefined ? {} : { stopReason }),
272
+ };
273
+ switch (status) {
274
+ case "active":
275
+ if (!safeNonNegativeInteger(activeStartedAt) || resumeAfterManualCompaction !== undefined) return undefined;
276
+ return {
277
+ ...common,
278
+ status,
279
+ activeStartedAt,
280
+ ...(result === undefined ? {} : { result }),
281
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
282
+ };
283
+ case "paused":
284
+ if (activeStartedAt !== undefined) return undefined;
285
+ return {
286
+ ...common,
287
+ status,
288
+ ...(result === undefined ? {} : { result }),
289
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
290
+ ...(resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction }),
291
+ };
292
+ case "blocked":
293
+ if (activeStartedAt !== undefined || resumeAfterManualCompaction !== undefined || typeof result !== "string") return undefined;
294
+ return { ...common, status, result, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
295
+ case "complete":
296
+ if (activeStartedAt !== undefined || resumeAfterManualCompaction !== undefined
297
+ || typeof result !== "string" || blockerAudit !== undefined) return undefined;
298
+ return { ...common, status, result };
299
+ }
300
+ }
301
+
302
+ function sameFile(left: Stats, right: Stats): boolean {
303
+ return left.dev === right.dev
304
+ && left.ino === right.ino
305
+ && left.size === right.size
306
+ && left.mtimeMs === right.mtimeMs;
307
+ }
308
+
309
+ async function hashFile(handle: FileHandle, inspected: Stats): Promise<string> {
310
+ const hash = createHash("sha256");
311
+ const buffer = Buffer.allocUnsafe(FILE_HASH_CHUNK_SIZE);
312
+ let position = 0;
313
+ while (position < inspected.size) {
314
+ const length = Math.min(buffer.length, inspected.size - position);
315
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
316
+ if (bytesRead === 0) throw new Error("Goal deliverable changed while it was being inspected");
317
+ hash.update(buffer.subarray(0, bytesRead));
318
+ position += bytesRead;
319
+ }
320
+ if (!sameFile(inspected, await handle.stat())) {
321
+ throw new Error("Goal deliverable changed while it was being inspected");
322
+ }
323
+ return hash.digest("hex");
324
+ }
325
+
326
+ /** Captures a file baseline with bounded asynchronous I/O and descriptor identity checks. */
327
+ export async function captureGoalFileBaseline(
328
+ filePath: string,
329
+ openFile: OpenGoalFile = openGoalFile,
330
+ ): Promise<GoalFileBaseline> {
331
+ let inspected: Stats;
332
+ try {
333
+ inspected = await lstat(filePath);
334
+ } catch (error) {
335
+ if (isUnknownRecord(error) && error.code === "ENOENT") return { exists: false };
336
+ throw error;
337
+ }
338
+ const baseline = { exists: true as const, size: inspected.size, mtimeMs: inspected.mtimeMs };
339
+ if (!inspected.isFile() || inspected.size > FILE_HASH_LIMIT) return baseline;
340
+
341
+ let handle: FileHandle | undefined;
342
+ try {
343
+ handle = await openFile(filePath);
344
+ if (!sameFile(inspected, await handle.stat())) {
345
+ throw new Error("Goal deliverable changed while it was being inspected");
346
+ }
347
+ return { ...baseline, contentHash: await hashFile(handle, inspected) };
348
+ } catch (error) {
349
+ if (error instanceof Error && error.message === "Goal deliverable changed while it was being inspected") throw error;
350
+ return { ...baseline, contentHash: null };
351
+ } finally {
352
+ await handle?.close();
353
+ }
354
+ }
355
+
356
+ const GOAL_URL_PATTERN = /(?:https?|file):\/\/[^\s"'`]+/giu;
357
+ const GOAL_QUOTED_PATTERN = /`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'/gu;
358
+ const GOAL_ABSOLUTE_PATTERN = /(?:^|[\s"'`(\[{,;])([A-Za-z]:[\\/][^\s,;'"`]+|\/[^\s,;'"`]+)/gu;
359
+ const GOAL_LIST_FILE_PATTERN = /(?:\band\b|\bor\b|\bplus\b|\balong\s+with\b|[,;&+])\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|((?:\.{1,2}[\\/])*[A-Za-z0-9_][A-Za-z0-9_.-]*(?:[\\/][A-Za-z0-9_.-]+)*\.[A-Za-z0-9]{1,12})\b|(Makefile|Dockerfile|\.gitignore)\b)/giu;
360
+ 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;
361
+
362
+ function isQuotedGoalFileMention(raw: string): boolean {
363
+ const value = raw.trim();
364
+ if (!value || /\p{Cc}/u.test(value) || /^(?:https?|file):\/\//iu.test(value) || /[\\/]$/u.test(value)) return false;
365
+ return /[\/\\.:]/u.test(value);
366
+ }
367
+
368
+ function isPathShapedGoalCandidate(raw: string): boolean {
369
+ if (/\p{Cc}/u.test(raw)) return false;
370
+ const value = raw.trim();
371
+ if (!value || /[\\\/]$/u.test(value)) return false;
372
+ if (path.isAbsolute(value) || path.win32.isAbsolute(value)) return true;
373
+ if (URL.canParse(value)) return false;
374
+ return /[\\\/]/u.test(value)
375
+ || value.startsWith(".") && value !== "." && value !== ".."
376
+ || /\.[A-Za-z0-9]{1,12}$/u.test(value);
377
+ }
378
+
379
+ function goalPathIdentity(filePath: string): string {
380
+ const nativePath = filePath.replaceAll("/", "\\");
381
+ // Parent segments may traverse symlinks; keep them distinct without filesystem proof.
382
+ const normalized = nativePath.split(path.sep).includes("..") ? nativePath : path.normalize(nativePath);
383
+ return normalized.toLowerCase();
384
+ }
385
+
386
+ function absoluteGoalFilePath(value: string, cwd: string): string {
387
+ if (path.isAbsolute(value) || path.win32.isAbsolute(value)) return value;
388
+ const nativePath = value.replaceAll("/", "\\");
389
+ // Resolving parent segments lexically can select a different file across a directory symlink.
390
+ return nativePath.split(path.sep).includes("..")
391
+ ? `${path.resolve(cwd)}${path.sep}${nativePath}`
392
+ : path.resolve(cwd, value);
393
+ }
394
+
395
+ function normalizeGoalFileMention(raw: string, cwd: string, quoted = false): string | undefined {
396
+ const trimmed = raw.trim();
397
+ const value = quoted ? trimmed : stripUnquotedPathPunctuation(trimmed);
398
+ if (!value || /\p{Cc}/u.test(value) || !quoted && /\s/u.test(value)) return undefined;
399
+ const absolute = absoluteGoalFilePath(value, cwd);
400
+ if (!isAbsoluteFilePath(absolute)) return undefined;
401
+ return goalPathIdentity(absolute);
402
+ }
403
+
404
+ /** Counts distinct path-shaped mentions so several files cannot bind proof to one. */
405
+ function countGoalTargetFiles(objective: string, cwd: string, target: string, targetQuoted: boolean): number {
406
+ const source = objective.replace(GOAL_URL_PATTERN, " ");
407
+ // ponytail: Only these exact prose forms are excluded; quote ambiguous bare filenames for exact proof.
408
+ const prose = source.replace(GOAL_QUOTED_PATTERN, " ")
409
+ .replace(/(?<![A-Za-z0-9_.\\/-])version\s+\d+(?:\.\d+)+(?=$|[\s,;:!?)\]}]|\.(?=$|[\s,;:!?)\]}]))/giu, " ")
410
+ .replace(/(?<![A-Za-z0-9_.\\/-])for\s+Node\.js(?!\s+compatibility\.[A-Za-z0-9])(?=$|[\s,;:!?)\]}]|\.(?=$|[\s,;:!?)\]}]))|(?<![A-Za-z0-9_.\\/-])Node\.js\s+compatibility(?=$|[\s,;:!?)\]}]|\.(?=$|[\s,;:!?)\]}]))/giu, " ");
411
+ const mentions = new Set<string>();
412
+ const add = (raw: string, quoted = false): void => {
413
+ const normalized = normalizeGoalFileMention(raw, cwd, quoted);
414
+ if (normalized) mentions.add(normalized);
415
+ };
416
+ add(target, targetQuoted);
417
+ for (const match of source.matchAll(GOAL_QUOTED_PATTERN)) {
418
+ const raw = match[1] ?? match[2] ?? match[3] ?? "";
419
+ if (isQuotedGoalFileMention(raw)) add(raw, true);
420
+ }
421
+ for (const match of source.matchAll(GOAL_LIST_FILE_PATTERN)) {
422
+ const quoted = match[1] ?? match[2] ?? match[3];
423
+ if (quoted !== undefined) add(quoted, true);
424
+ }
425
+ for (const match of prose.matchAll(GOAL_ABSOLUTE_PATTERN)) {
426
+ if (match[1]) add(stripUnquotedPathPunctuation(match[1].trim()));
427
+ }
428
+ for (const match of prose.matchAll(GOAL_LIST_FILE_PATTERN)) {
429
+ if (match[4]) add(match[4]);
430
+ if (match[5]) add(match[5]);
431
+ }
432
+ for (const match of prose.matchAll(GOAL_RELATIVE_FILE_PATTERN)) {
433
+ if (match[1]) add(match[1]);
434
+ }
435
+ return mentions.size;
436
+ }
437
+
438
+ /** Captures one explicit output path so goal completion can verify its creation or modification. */
439
+ export async function inferGoalVerification(objective: string, cwd: string): Promise<GoalFileVerification | undefined> {
440
+ const candidates: Array<{ raw: string; quoted: boolean }> = [];
441
+ 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;
442
+ for (const match of objective.matchAll(destination)) {
443
+ const quoted = match[1] ?? match[2] ?? match[3];
444
+ candidates.push({
445
+ raw: quoted !== undefined ? quoted : stripUnquotedPathPunctuation((match[4] ?? "").trim()),
446
+ quoted: quoted !== undefined,
447
+ });
448
+ }
449
+ const direct = /\b(?:update|edit|fix|refactor|migrate)\s+(`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)')/giu;
450
+ for (const match of objective.matchAll(direct)) {
451
+ const quoted = match[2] ?? match[3] ?? match[4];
452
+ if (quoted !== undefined) candidates.push({ raw: quoted, quoted: true });
453
+ }
454
+ const operands = new Set<string>();
455
+ const resolved = new Map<string, { filePath: string; quoted: boolean }>();
456
+ for (const candidate of candidates) {
457
+ const value = candidate.raw.trim();
458
+ if (!value || /\p{Cc}/u.test(value)) continue;
459
+ const alreadyAbsolute = path.isAbsolute(value) || path.win32.isAbsolute(value);
460
+ if (!alreadyAbsolute && URL.canParse(value)) continue;
461
+ const absolute = absoluteGoalFilePath(value, cwd);
462
+ const identity = goalPathIdentity(absolute);
463
+ operands.add(identity);
464
+ if (!isPathShapedGoalCandidate(candidate.raw) || !isAbsoluteFilePath(absolute)) continue;
465
+ const existing = resolved.get(identity);
466
+ resolved.set(identity, {
467
+ filePath: existing?.filePath ?? absolute,
468
+ quoted: candidate.quoted || existing?.quoted === true,
469
+ });
470
+ }
471
+ if (operands.size !== 1 || resolved.size !== 1) return undefined;
472
+ // A goal that names several files must not verify from one file alone.
473
+ // Repeated references to the same file count as one target.
474
+ const target = resolved.values().next().value;
475
+ if (target === undefined || countGoalTargetFiles(objective, cwd, target.filePath, target.quoted) > 1) return undefined;
476
+ return { kind: "file", path: target.filePath, baseline: await captureGoalFileBaseline(target.filePath) };
477
+ }
478
+
479
+ export async function verifyGoalDeliverable(verification: GoalFileVerification): Promise<void> {
480
+ let artifact: Stats;
481
+ try {
482
+ artifact = await lstat(verification.path);
483
+ } catch {
484
+ throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
485
+ }
486
+ if (!artifact.isFile()) {
487
+ throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
488
+ }
489
+ if (!verification.baseline.exists) return;
490
+ if (verification.baseline.contentHash === null) {
491
+ throw new Error(`Goal deliverable content cannot be verified: ${verification.path}`);
492
+ }
493
+ if (verification.baseline.contentHash !== undefined) {
494
+ const current = await captureGoalFileBaseline(verification.path);
495
+ if (!current.exists || current.contentHash === null) {
496
+ throw new Error(`Goal deliverable content cannot be verified: ${verification.path}`);
497
+ }
498
+ if (current.contentHash === verification.baseline.contentHash) {
499
+ throw new Error(`Goal deliverable has not changed since the goal started: ${verification.path}`);
500
+ }
501
+ }
502
+ if (verification.baseline.contentHash === undefined
503
+ && artifact.size === verification.baseline.size
504
+ && artifact.mtimeMs === verification.baseline.mtimeMs) {
505
+ throw new Error(`Goal deliverable has not changed since the goal started: ${verification.path}`);
506
+ }
507
+ }
508
+
509
+ export function boundGoalText(value: string, limit = GOAL_EVIDENCE_LIMIT): string {
510
+ const safe = safeTerminalText(value).trim();
511
+ return [...graphemeSegmenter.segment(safe)]
512
+ .slice(0, limit)
513
+ .map(({ segment }) => segment)
514
+ .join("")
515
+ .trimEnd();
516
+ }
517
+
518
+ export function validateGoalObjective(input: string): string | undefined {
519
+ const objective = input.trim();
520
+ if (!objective || safeTerminalText(objective) !== objective) return undefined;
521
+ return graphemeCount(objective) <= GOAL_OBJECTIVE_LIMIT ? objective : undefined;
522
+ }
523
+
524
+ export function goalElapsedMilliseconds(state: GoalState, now: number): number {
525
+ const activeInterval = state.status === "active" ? Math.max(0, now - state.activeStartedAt) : 0;
526
+ return addGoalMilliseconds(state.activeMilliseconds, activeInterval);
527
+ }
528
+
529
+ export function commonGoalState(state: GoalState): GoalStateCommon {
530
+ return {
531
+ version: state.version,
532
+ revision: state.revision,
533
+ objective: state.objective,
534
+ createdAt: state.createdAt,
535
+ updatedAt: state.updatedAt,
536
+ activeMilliseconds: state.activeMilliseconds,
537
+ turns: state.turns,
538
+ blockedAuditStartTurn: state.blockedAuditStartTurn,
539
+ baselineTokens: state.baselineTokens,
540
+ ...(state.verification === undefined ? {} : { verification: state.verification }),
541
+ ...(state.maxTurns === undefined ? {} : { maxTurns: state.maxTurns }),
542
+ ...(state.turnPhase === undefined ? {} : { turnPhase: state.turnPhase }),
543
+ ...(state.turnDecision === undefined ? {} : { turnDecision: state.turnDecision }),
544
+ ...(state.lastDecision === undefined ? {} : { lastDecision: state.lastDecision }),
545
+ ...(state.lastContinueReport === undefined ? {} : { lastContinueReport: state.lastContinueReport }),
546
+ ...(state.status === "paused" && state.stopReason !== undefined ? { stopReason: state.stopReason } : {}),
547
+ };
548
+ }
549
+
550
+ export function stopGoalClock(state: GoalState, now: number): GoalStateCommon {
551
+ const common = commonGoalState(state);
552
+ return state.status === "active"
553
+ ? { ...common, activeMilliseconds: addGoalMilliseconds(common.activeMilliseconds, Math.max(0, now - state.activeStartedAt)) }
554
+ : common;
555
+ }
556
+
557
+ export function createNewGoalState(
558
+ objective: string,
559
+ baselineTokens: number,
560
+ verification: GoalFileVerification | undefined,
561
+ now: number,
562
+ controls: { maxTurns?: number } = {},
563
+ ): GoalState {
564
+ return {
565
+ version: GOAL_VERSION,
566
+ revision: 1,
567
+ objective,
568
+ status: "active",
569
+ createdAt: now,
570
+ updatedAt: now,
571
+ activeMilliseconds: 0,
572
+ activeStartedAt: now,
573
+ turns: 0,
574
+ blockedAuditStartTurn: 0,
575
+ baselineTokens,
576
+ turnPhase: "ready",
577
+ ...(verification === undefined ? {} : { verification }),
578
+ ...(controls.maxTurns === undefined ? {} : { maxTurns: controls.maxTurns }),
579
+ };
580
+ }
581
+
582
+ function nextRevision(current: { revision: number }): number {
583
+ if (!incrementableNonNegativeInteger(current.revision)) {
584
+ throw new Error("Goal revision cannot advance safely");
585
+ }
586
+ return current.revision + 1;
587
+ }
588
+
589
+ export function beginGoalTurnState(
590
+ current: Extract<GoalState, { status: "active" }>,
591
+ now: number,
592
+ ): GoalState {
593
+ if (!incrementableNonNegativeInteger(current.turns)) throw new Error("Goal turn counter cannot advance safely");
594
+ const { turnDecision: _decision, turnPhase: _phase, ...withoutPending } = current;
595
+ return {
596
+ ...withoutPending,
597
+ revision: nextRevision(current),
598
+ turns: current.turns + 1,
599
+ updatedAt: now,
600
+ turnPhase: "in-flight",
601
+ ...(current.turnDecision === undefined ? {} : { lastDecision: current.turnDecision }),
602
+ };
603
+ }
604
+
605
+ export function checkpointActiveGoalState(
606
+ current: Extract<GoalState, { status: "active" }>,
607
+ now: number,
608
+ ): GoalState {
609
+ return {
610
+ ...stopGoalClock(current, now),
611
+ revision: nextRevision(current),
612
+ status: "active",
613
+ updatedAt: now,
614
+ activeStartedAt: now,
615
+ ...(current.result === undefined ? {} : { result: current.result }),
616
+ ...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
617
+ };
618
+ }
619
+
620
+ export function pauseGoalState(
621
+ current: GoalState,
622
+ result: string | undefined,
623
+ now: number,
624
+ resumeAfterManualCompaction = false,
625
+ preserveTurnAuthorization = false,
626
+ ): GoalState {
627
+ const common = stopGoalClock(current, now);
628
+ const { turnDecision: _decision, turnPhase: _phase, ...withoutPending } = common;
629
+ return {
630
+ ...withoutPending,
631
+ status: "paused",
632
+ turnPhase: preserveTurnAuthorization ? current.turnPhase : "ready",
633
+ ...(preserveTurnAuthorization && current.turnDecision !== undefined ? { turnDecision: current.turnDecision } : {}),
634
+ ...(result === undefined ? {} : { result }),
635
+ ...(result === undefined ? {} : { stopReason: boundGoalText(result) }),
636
+ ...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
637
+ ...(resumeAfterManualCompaction ? { resumeAfterManualCompaction: true as const } : {}),
638
+ };
639
+ }
640
+
641
+ export function checkpointPausedGoalState(
642
+ current: Extract<GoalState, { status: "paused" }>,
643
+ now: number,
644
+ ): GoalState {
645
+ const { resumeAfterManualCompaction: _resume, ...paused } = current;
646
+ return { ...paused, revision: nextRevision(current), updatedAt: now };
647
+ }
648
+
649
+ export function recordGoalDecision(
650
+ state: Extract<GoalState, { status: "active" }>,
651
+ decision: GoalPendingDecision,
652
+ now: number,
653
+ ): Extract<GoalState, { status: "active" }> {
654
+ if (decision.turn !== state.turns) throw new Error("Goal decision does not match the active turn");
655
+ return {
656
+ ...state,
657
+ revision: nextRevision(state),
658
+ updatedAt: now,
659
+ turnPhase: "authorized",
660
+ turnDecision: decision,
661
+ lastDecision: decision,
662
+ ...(decision.kind === "continue"
663
+ ? { lastContinueReport: { turn: decision.turn, evidence: decision.evidence, nextAction: decision.nextAction } }
664
+ : {}),
665
+ };
666
+ }
667
+
668
+ /** Replaces the objective in place for an editable goal; pending decisions and audits belong to the previous objective. */
669
+ export function editGoalState(
670
+ current: GoalState,
671
+ objective: string,
672
+ verification: GoalFileVerification | undefined,
673
+ now: number,
674
+ ): GoalState {
675
+ if (current.status !== "active" && current.status !== "paused") {
676
+ throw new Error("Only an active or paused goal can be edited");
677
+ }
678
+ const common: GoalStateCommon = {
679
+ version: current.version,
680
+ revision: nextRevision(current),
681
+ objective,
682
+ createdAt: current.createdAt,
683
+ updatedAt: now,
684
+ activeMilliseconds: current.activeMilliseconds,
685
+ turns: current.turns,
686
+ blockedAuditStartTurn: current.turns,
687
+ baselineTokens: current.baselineTokens,
688
+ turnPhase: "ready",
689
+ ...(current.maxTurns === undefined ? {} : { maxTurns: current.maxTurns }),
690
+ ...(verification === undefined ? {} : { verification }),
691
+ };
692
+ return current.status === "active"
693
+ ? { ...common, status: "active", activeStartedAt: current.activeStartedAt, ...(current.result === undefined ? {} : { result: current.result }) }
694
+ : { ...common, status: "paused", ...(current.result === undefined ? {} : { result: current.result }) };
695
+ }
696
+
697
+ export function transitionGoalState(
698
+ current: GoalState,
699
+ status: GoalStatus,
700
+ result: string | undefined,
701
+ options: GoalTransitionOptions,
702
+ now: number,
703
+ ): GoalState {
704
+ const stopped = stopGoalClock(current, now);
705
+ const common: GoalStateCommon = {
706
+ ...stopped,
707
+ revision: nextRevision(stopped),
708
+ updatedAt: now,
709
+ blockedAuditStartTurn: options.resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
710
+ };
711
+ const blockerAudit = options.resetBlockedAudit ? undefined : options.blockerAudit ?? current.blockerAudit;
712
+ const pending = options.preserveTurnAuthorization ? current.turnDecision : undefined;
713
+ const { turnDecision: _decision, turnPhase: _phase, stopReason: _stopReason, ...withoutPending } = common;
714
+ switch (status) {
715
+ case "active":
716
+ return {
717
+ ...withoutPending,
718
+ ...(current.status === "blocked" ? { lastDecision: undefined } : {}),
719
+ status,
720
+ activeStartedAt: now,
721
+ turnPhase: pending !== undefined ? "authorized" : options.resumeInterruptedTurn ? "in-flight" : "ready",
722
+ ...(pending === undefined ? {} : { turnDecision: pending }),
723
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
724
+ };
725
+ case "paused":
726
+ return {
727
+ ...withoutPending,
728
+ status,
729
+ turnPhase: options.keepTurnForRecovery ? current.turnPhase : "ready",
730
+ ...(options.keepTurnForRecovery && current.turnDecision !== undefined ? { turnDecision: current.turnDecision } : {}),
731
+ ...(result === undefined ? {} : { result, stopReason: boundGoalText(result) }),
732
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
733
+ ...(options.resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction: true }),
734
+ };
735
+ case "blocked":
736
+ if (result === undefined) throw new Error("A blocked goal requires a result");
737
+ return {
738
+ ...withoutPending,
739
+ status,
740
+ result,
741
+ ...(options.decision === undefined ? {} : { lastDecision: options.decision }),
742
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
743
+ };
744
+ case "complete":
745
+ if (result === undefined) throw new Error("A complete goal requires a result");
746
+ return {
747
+ ...withoutPending,
748
+ status,
749
+ result,
750
+ ...(options.decision === undefined ? {} : { lastDecision: options.decision }),
751
+ };
752
+ }
753
+ }