killeros 2.0.20 → 2.0.21

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 CHANGED
@@ -4,6 +4,23 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.21] - 2026-08-29
8
+
9
+ ### Changed
10
+
11
+ - Bounded goal verification and Pi compatibility, reduced Git status polling, reserved handoff context, and separated goal state and question UI logic.
12
+ - Blocked moderate dependency advisories in CI and updated TypeBox within the supported 1.x line.
13
+ - Preserved seconds in footer and goal elapsed times after one minute.
14
+ - Added a muted top border to the prompt editor.
15
+ - Colored the footer workspace path `#F0F89A`.
16
+ - Added a live changed-file count beside the Git branch when the worktree is dirty.
17
+ - Synced `dev` back to successful `main` releases after publishing.
18
+
19
+ ### Fixed
20
+
21
+ - Kept likely secrets out of `/init`, restricted personal-instruction imports to Pi's agent directory, and replaced injectable handoff framing with validated JSON.
22
+ - Hardened releases against stale CI runs, mismatched existing npm artifacts, and tag conflicts discovered after publication.
23
+
7
24
  ## [2.0.20] - 2026-08-25
8
25
 
9
26
  ### Changed
package/README.md CHANGED
@@ -19,7 +19,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
19
19
  ## Requirements
20
20
 
21
21
  - Node.js 22.19.0+
22
- - Pi 0.84.3+
22
+ - Pi 0.84.3 or later within the 0.x release line
23
23
  - An interactive TUI session for the custom header, editor, footer, `question`, and `/init`
24
24
 
25
25
  ## Install
@@ -34,7 +34,7 @@ Or from GitHub:
34
34
  pi install git:github.com/KyrosHendrix/pi-KillerOS
35
35
  ```
36
36
 
37
- Pin a release by appending its tag, for example `@v2.0.20`. Add `-l` to install only for the current project. Restart Pi after installing.
37
+ Pin a release by appending its tag, for example `@v2.0.21`. Add `-l` to install only for the current project. Restart Pi after installing.
38
38
 
39
39
  ## Commands
40
40
 
@@ -28,9 +28,14 @@ export function formatTime(milliseconds: number): string {
28
28
  if (!Number.isFinite(milliseconds)) return "0s";
29
29
  const totalSeconds = Math.max(0, Math.floor(milliseconds / 1_000));
30
30
  if (totalSeconds < 60) return `${totalSeconds}s`;
31
- const minutes = Math.floor(totalSeconds / 60);
32
- if (minutes < 60) return `${minutes}m`;
33
- return `${Math.floor(minutes / 60)}h${minutes % 60}m`;
31
+
32
+ const seconds = totalSeconds % 60;
33
+ const totalMinutes = Math.floor(totalSeconds / 60);
34
+ if (totalMinutes < 60) return `${totalMinutes}m ${seconds.toString().padStart(2, "0")}s`;
35
+
36
+ const hours = Math.floor(totalMinutes / 60);
37
+ const minutes = totalMinutes % 60;
38
+ return `${hours}h ${minutes.toString().padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
34
39
  }
35
40
 
36
41
  export function formatTokens(value: number): string {
@@ -1,14 +1,98 @@
1
+ import { execFile } from "node:child_process";
1
2
  import { type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
3
  import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
3
4
  import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
4
5
  import { formatCwd, formatTime, formatTokens, padRight } from "./display.ts";
5
- import { goalElapsedMilliseconds } from "./goals.ts";
6
+ import { goalElapsedMilliseconds } from "./goal-state.ts";
6
7
  import type { GoalRuntime, GoalState } from "./runtime.ts";
7
8
  import { safeTerminalText } from "./safe-terminal-text.ts";
8
9
  import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
9
10
 
10
- const FOOTER_REFRESH_INTERVAL_MS = 1_000;
11
+ const GIT_STATUS_REFRESH_INTERVAL_MS = 30_000;
11
12
  const CODEX_PROVIDER = "openai-codex";
13
+ const colorDirectory = (text: string): string => `\x1B[38;2;240;248;154m${text}\x1B[39m`;
14
+
15
+ function resolveUncommittedFileCount(cwd: string): Promise<number | undefined> {
16
+ return new Promise((resolve) => {
17
+ execFile(
18
+ "git",
19
+ ["-C", cwd, "status", "--porcelain=v1", "-z", "--untracked-files=all"],
20
+ {
21
+ encoding: "utf8",
22
+ maxBuffer: 4 * 1024 * 1024,
23
+ timeout: 1_000,
24
+ windowsHide: true,
25
+ },
26
+ (error, stdout) => {
27
+ if (error) {
28
+ resolve(undefined);
29
+ return;
30
+ }
31
+
32
+ const entries = stdout.split("\0");
33
+ let count = 0;
34
+ for (let index = 0; index < entries.length; index += 1) {
35
+ const entry = entries[index];
36
+ if (!entry) continue;
37
+ count += 1;
38
+ if (entry[0] === "R" || entry[0] === "C" || entry[1] === "R" || entry[1] === "C") index += 1;
39
+ }
40
+ resolve(count);
41
+ },
42
+ );
43
+ });
44
+ }
45
+
46
+ /** Coalesces Git status requests to one active scan and one queued follow-up. */
47
+ export function createGitStatusRefresh(
48
+ cwd: string,
49
+ onCount: (count: number | undefined) => void,
50
+ resolveCount: (cwd: string) => Promise<number | undefined> = resolveUncommittedFileCount,
51
+ ): { request: () => void; dispose: () => void } {
52
+ let disposed = false;
53
+ let pending = false;
54
+ let queued = false;
55
+ const request = (): void => {
56
+ if (disposed) return;
57
+ if (pending) {
58
+ queued = true;
59
+ return;
60
+ }
61
+ pending = true;
62
+ void resolveCount(cwd).then((count) => {
63
+ if (!disposed) onCount(count);
64
+ }).finally(() => {
65
+ pending = false;
66
+ if (!disposed && queued) {
67
+ queued = false;
68
+ request();
69
+ }
70
+ });
71
+ };
72
+ return {
73
+ request,
74
+ dispose() {
75
+ disposed = true;
76
+ queued = false;
77
+ },
78
+ };
79
+ }
80
+
81
+ type ScheduleFallback = (refresh: () => void, intervalMs: number) => () => void;
82
+
83
+ const scheduleFallback: ScheduleFallback = (refresh, intervalMs) => {
84
+ const timer = setInterval(refresh, intervalMs);
85
+ timer.unref?.();
86
+ return () => clearInterval(timer);
87
+ };
88
+
89
+ /** Schedules the fallback Git scan independently from footer rendering. */
90
+ export function scheduleGitStatusFallback(
91
+ refresh: () => void,
92
+ schedule: ScheduleFallback = scheduleFallback,
93
+ ): () => void {
94
+ return schedule(refresh, GIT_STATUS_REFRESH_INTERVAL_MS);
95
+ }
12
96
 
13
97
  export function formatCost(usd: number): string {
14
98
  if (!Number.isFinite(usd)) return "$—";
@@ -142,23 +226,10 @@ function renderFooter(rows: string[], width: number, theme: Theme): string[] {
142
226
  return [theme.fg("borderMuted", "─".repeat(width)), ...rows];
143
227
  }
144
228
 
145
- function formatGoalElapsed(milliseconds: number): string {
146
- const totalSeconds = Number.isFinite(milliseconds) ? Math.max(0, Math.floor(milliseconds / 1_000)) : 0;
147
- if (totalSeconds < 60) return `${totalSeconds}s`;
148
-
149
- const seconds = totalSeconds % 60;
150
- const totalMinutes = Math.floor(totalSeconds / 60);
151
- if (totalMinutes < 60) return `${totalMinutes}m ${seconds.toString().padStart(2, "0")}s`;
152
-
153
- const hours = Math.floor(totalMinutes / 60);
154
- const minutes = totalMinutes % 60;
155
- return `${hours}h ${minutes.toString().padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
156
- }
157
-
158
229
  function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
159
230
  if (!state) return "";
160
231
  if (state.status === "active") {
161
- return theme.fg("warning", `/goal is active (${formatGoalElapsed(goalElapsedMilliseconds(state))})`);
232
+ return theme.fg("warning", `/goal is active (${formatTime(goalElapsedMilliseconds(state, Date.now()))})`);
162
233
  }
163
234
  if (state.status === "paused") return theme.fg("warning", "/goal is paused");
164
235
  if (state.status === "blocked") return theme.fg("error", "/goal is blocked");
@@ -172,6 +243,7 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
172
243
  let cachedSessionCost = 0;
173
244
  let sessionCostDirty = true;
174
245
  let unsubscribeCodexFast: (() => void) | undefined;
246
+ let requestGitStatusRefresh: (() => void) | undefined;
175
247
  const resetSessionCost = (): void => {
176
248
  cachedSessionCost = 0;
177
249
  sessionCostDirty = true;
@@ -201,13 +273,25 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
201
273
 
202
274
  ctx.ui.setFooter((tui, theme, footerData) => {
203
275
  activeTui = tui;
204
- const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
205
- const refreshTimer = setInterval(() => tui.requestRender(), FOOTER_REFRESH_INTERVAL_MS);
206
- refreshTimer.unref?.();
276
+ let uncommittedFileCount: number | undefined;
277
+ const gitStatus = createGitStatusRefresh(ctx.cwd, (count) => {
278
+ if (count === uncommittedFileCount) return;
279
+ uncommittedFileCount = count;
280
+ tui.requestRender();
281
+ });
282
+ requestGitStatusRefresh = gitStatus.request;
283
+ const unsubscribe = footerData.onBranchChange(() => {
284
+ gitStatus.request();
285
+ tui.requestRender();
286
+ });
287
+ gitStatus.request();
288
+ const stopFallback = scheduleGitStatusFallback(gitStatus.request);
207
289
  return {
208
290
  dispose() {
209
291
  unsubscribe();
210
- clearInterval(refreshTimer);
292
+ stopFallback();
293
+ gitStatus.dispose();
294
+ if (requestGitStatusRefresh === gitStatus.request) requestGitStatusRefresh = undefined;
211
295
  if (activeTui === tui) activeTui = undefined;
212
296
  },
213
297
  invalidate() {},
@@ -227,8 +311,8 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
227
311
  const context = formatContextProgress(usage?.tokens ?? null, contextWindow, theme);
228
312
  const branch = footerData.getGitBranch();
229
313
  const signature = formatModel(model, theme, true, isCodexFastEnabled());
230
- const fullDirectory = theme.fg("dim", cwd);
231
- const focusedDirectory = theme.fg("dim", compactDirectory(cwd));
314
+ const fullDirectory = colorDirectory(cwd);
315
+ const focusedDirectory = colorDirectory(compactDirectory(cwd));
232
316
  const goal = formatGoalFooter(goalRuntime.state, theme);
233
317
  const primary = joinFooterParts([
234
318
  signature,
@@ -248,7 +332,11 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
248
332
  : footerRowFits(primaryFocused, "", width)
249
333
  ? renderFooterRow(primaryFocused, "", width)
250
334
  : renderFooterRow(essentialModel, context, width);
251
- const branchLabel = branch ? theme.fg("dim", branch) : "";
335
+ const branchLabel = branch
336
+ ? uncommittedFileCount
337
+ ? `${theme.fg("dim", `${branch} · `)}${theme.fg("warning", `${uncommittedFileCount} changed`)}`
338
+ : theme.fg("dim", branch)
339
+ : "";
252
340
  const workspaceRight = goal || fullDirectory;
253
341
  const secondaryRow = footerRowFits(branchLabel, workspaceRight, width)
254
342
  ? renderFooterRow(branchLabel, workspaceRight, width)
@@ -271,8 +359,12 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
271
359
  thinkingLevel = event.level;
272
360
  activeTui?.requestRender();
273
361
  });
274
- pi.on("turn_end", invalidateSessionCost);
275
- pi.on("session_compact", invalidateSessionCost);
362
+ const refreshAfterActivity = (): void => {
363
+ invalidateSessionCost();
364
+ requestGitStatusRefresh?.();
365
+ };
366
+ pi.on("turn_end", refreshAfterActivity);
367
+ pi.on("session_compact", refreshAfterActivity);
276
368
  pi.on("session_tree", () => {
277
369
  resetSessionCost();
278
370
  activeTui?.requestRender();
@@ -282,6 +374,7 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
282
374
  unsubscribeCodexFast = undefined;
283
375
  resetSessionCost();
284
376
  activeTui = undefined;
377
+ requestGitStatusRefresh = undefined;
285
378
  goalRuntime.requestRender = undefined;
286
379
  });
287
380
  }
@@ -0,0 +1,407 @@
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, GoalFileBaseline, GoalFileVerification, GoalState, GoalStateCommon, GoalStatus } from "./runtime.ts";
6
+
7
+ export const GOAL_OBJECTIVE_LIMIT = 4_000;
8
+ export const GOAL_VERSION = 1;
9
+ const FILE_HASH_CHUNK_SIZE = 64 * 1024;
10
+ export const FILE_HASH_LIMIT = 64 * 1024 * 1024;
11
+ type OpenGoalFile = (filePath: string) => Promise<FileHandle>;
12
+ const openGoalFile: OpenGoalFile = (filePath) => open(filePath, "r");
13
+
14
+ export interface GoalTransitionOptions {
15
+ resetBlockedAudit?: boolean;
16
+ resumeAfterManualCompaction?: true;
17
+ blockerAudit?: GoalBlockerAudit;
18
+ }
19
+
20
+ function isGoalStatus(value: unknown): value is GoalStatus {
21
+ return value === "active" || value === "paused" || value === "blocked" || value === "complete";
22
+ }
23
+
24
+ function finiteNonNegative(value: unknown): value is number {
25
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
26
+ }
27
+
28
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === "object" && value !== null && !Array.isArray(value);
30
+ }
31
+
32
+ function isGoalFileBaseline(value: unknown): value is GoalFileBaseline {
33
+ if (!isUnknownRecord(value)) return false;
34
+ if (value.exists === false) {
35
+ return value.size === undefined && value.mtimeMs === undefined && value.contentHash === undefined;
36
+ }
37
+ return value.exists === true
38
+ && finiteNonNegative(value.size)
39
+ && finiteNonNegative(value.mtimeMs)
40
+ && (value.contentHash === undefined
41
+ || value.contentHash === null
42
+ || typeof value.contentHash === "string" && /^[a-f0-9]{64}$/u.test(value.contentHash));
43
+ }
44
+
45
+ function isAbsoluteFilePath(value: string): boolean {
46
+ if (!value || /^(?:https?|file):\/\//iu.test(value) || /[\\\/]$/u.test(value)) return false;
47
+ return path.isAbsolute(value) || path.win32.isAbsolute(value);
48
+ }
49
+
50
+ function isGoalFileVerification(value: unknown): value is GoalFileVerification {
51
+ return isUnknownRecord(value)
52
+ && value.kind === "file"
53
+ && typeof value.path === "string"
54
+ && value.path === value.path.trim()
55
+ && isAbsoluteFilePath(value.path)
56
+ && isGoalFileBaseline(value.baseline);
57
+ }
58
+
59
+ function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
60
+ if (!isUnknownRecord(value)
61
+ || typeof value.key !== "string"
62
+ || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.key)
63
+ || typeof value.streak !== "number" || !Number.isInteger(value.streak) || value.streak < 1 || value.streak > 3
64
+ || typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns) {
65
+ return false;
66
+ }
67
+ if (status === "complete") return false;
68
+ return status === "blocked" ? value.streak === 3 : value.streak < 3;
69
+ }
70
+
71
+ export function parseGoalState(value: unknown): GoalState | undefined {
72
+ if (!isUnknownRecord(value)) return undefined;
73
+ const {
74
+ version,
75
+ revision,
76
+ objective,
77
+ status,
78
+ createdAt,
79
+ updatedAt,
80
+ activeMilliseconds,
81
+ activeStartedAt,
82
+ turns,
83
+ blockedAuditStartTurn,
84
+ baselineTokens,
85
+ result,
86
+ resumeAfterManualCompaction,
87
+ blockerAudit,
88
+ verification,
89
+ } = value;
90
+ if (version !== GOAL_VERSION
91
+ || typeof revision !== "number" || !Number.isInteger(revision) || revision < 1
92
+ || typeof objective !== "string" || !objective.trim() || [...objective].length > GOAL_OBJECTIVE_LIMIT
93
+ || !isGoalStatus(status)
94
+ || !finiteNonNegative(createdAt)
95
+ || !finiteNonNegative(updatedAt)
96
+ || !finiteNonNegative(activeMilliseconds)
97
+ || typeof turns !== "number" || !Number.isInteger(turns) || turns < 0
98
+ || blockedAuditStartTurn !== undefined
99
+ && (typeof blockedAuditStartTurn !== "number" || !Number.isInteger(blockedAuditStartTurn)
100
+ || blockedAuditStartTurn < 0 || blockedAuditStartTurn > turns)
101
+ || !finiteNonNegative(baselineTokens)
102
+ || result !== undefined && typeof result !== "string"
103
+ || verification !== undefined && !isGoalFileVerification(verification)
104
+ || resumeAfterManualCompaction !== undefined && resumeAfterManualCompaction !== true
105
+ || blockerAudit !== undefined && !isGoalBlockerAudit(blockerAudit, turns, status)) {
106
+ return undefined;
107
+ }
108
+
109
+ const common: GoalStateCommon = {
110
+ version: GOAL_VERSION,
111
+ revision,
112
+ objective: objective.trim(),
113
+ createdAt,
114
+ updatedAt,
115
+ activeMilliseconds,
116
+ turns,
117
+ blockedAuditStartTurn: blockedAuditStartTurn ?? 0,
118
+ baselineTokens,
119
+ ...(verification === undefined ? {} : { verification }),
120
+ };
121
+ switch (status) {
122
+ case "active":
123
+ if (!finiteNonNegative(activeStartedAt) || resumeAfterManualCompaction !== undefined) return undefined;
124
+ return {
125
+ ...common,
126
+ status,
127
+ activeStartedAt,
128
+ ...(result === undefined ? {} : { result }),
129
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
130
+ };
131
+ case "paused":
132
+ if (activeStartedAt !== undefined) return undefined;
133
+ return {
134
+ ...common,
135
+ status,
136
+ ...(result === undefined ? {} : { result }),
137
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
138
+ ...(resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction }),
139
+ };
140
+ case "blocked":
141
+ if (activeStartedAt !== undefined || resumeAfterManualCompaction !== undefined || typeof result !== "string") return undefined;
142
+ return { ...common, status, result, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
143
+ case "complete":
144
+ if (activeStartedAt !== undefined || resumeAfterManualCompaction !== undefined
145
+ || typeof result !== "string" || blockerAudit !== undefined) return undefined;
146
+ return { ...common, status, result };
147
+ }
148
+ }
149
+
150
+ function sameFile(left: Stats, right: Stats): boolean {
151
+ return left.dev === right.dev
152
+ && left.ino === right.ino
153
+ && left.size === right.size
154
+ && left.mtimeMs === right.mtimeMs;
155
+ }
156
+
157
+ async function hashFile(handle: FileHandle, inspected: Stats): Promise<string> {
158
+ const hash = createHash("sha256");
159
+ const buffer = Buffer.allocUnsafe(FILE_HASH_CHUNK_SIZE);
160
+ let position = 0;
161
+ while (position < inspected.size) {
162
+ const length = Math.min(buffer.length, inspected.size - position);
163
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
164
+ if (bytesRead === 0) throw new Error("Goal deliverable changed while it was being inspected");
165
+ hash.update(buffer.subarray(0, bytesRead));
166
+ position += bytesRead;
167
+ }
168
+ if (!sameFile(inspected, await handle.stat())) {
169
+ throw new Error("Goal deliverable changed while it was being inspected");
170
+ }
171
+ return hash.digest("hex");
172
+ }
173
+
174
+ /** Captures a file baseline with bounded asynchronous I/O and descriptor identity checks. */
175
+ export async function captureGoalFileBaseline(
176
+ filePath: string,
177
+ openFile: OpenGoalFile = openGoalFile,
178
+ ): Promise<GoalFileBaseline> {
179
+ let inspected: Stats;
180
+ try {
181
+ inspected = await lstat(filePath);
182
+ } catch (error) {
183
+ if (isUnknownRecord(error) && error.code === "ENOENT") return { exists: false };
184
+ throw error;
185
+ }
186
+ const baseline = { exists: true as const, size: inspected.size, mtimeMs: inspected.mtimeMs };
187
+ if (!inspected.isFile() || inspected.size > FILE_HASH_LIMIT) return baseline;
188
+
189
+ let handle: FileHandle | undefined;
190
+ try {
191
+ handle = await openFile(filePath);
192
+ if (!sameFile(inspected, await handle.stat())) {
193
+ throw new Error("Goal deliverable changed while it was being inspected");
194
+ }
195
+ return { ...baseline, contentHash: await hashFile(handle, inspected) };
196
+ } catch (error) {
197
+ if (error instanceof Error && error.message === "Goal deliverable changed while it was being inspected") throw error;
198
+ return { ...baseline, contentHash: null };
199
+ } finally {
200
+ await handle?.close();
201
+ }
202
+ }
203
+
204
+ /** Captures one explicit absolute output path so goal completion can verify its creation or modification. */
205
+ export async function inferGoalVerification(objective: string): Promise<GoalFileVerification | undefined> {
206
+ 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;
207
+ const paths = [...objective.matchAll(destination)]
208
+ .map((match) => (match[1] ?? match[2] ?? match[3] ?? match[4] ?? "").trim())
209
+ .filter(isAbsoluteFilePath);
210
+ const unique = [...new Set(paths)];
211
+ const filePath = unique.length === 1 ? unique[0] : undefined;
212
+ return filePath ? { kind: "file", path: filePath, baseline: await captureGoalFileBaseline(filePath) } : undefined;
213
+ }
214
+
215
+ export async function verifyGoalDeliverable(verification: GoalFileVerification): Promise<void> {
216
+ let artifact: Stats;
217
+ try {
218
+ artifact = await lstat(verification.path);
219
+ } catch {
220
+ throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
221
+ }
222
+ if (!artifact.isFile()) {
223
+ throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
224
+ }
225
+ if (!verification.baseline.exists) return;
226
+ if (verification.baseline.contentHash === null) {
227
+ throw new Error(`Goal deliverable content cannot be verified: ${verification.path}`);
228
+ }
229
+ if (verification.baseline.contentHash !== undefined) {
230
+ const current = await captureGoalFileBaseline(verification.path);
231
+ if (!current.exists || current.contentHash === null) {
232
+ throw new Error(`Goal deliverable content cannot be verified: ${verification.path}`);
233
+ }
234
+ if (current.contentHash === verification.baseline.contentHash) {
235
+ throw new Error(`Goal deliverable has not changed since the goal started: ${verification.path}`);
236
+ }
237
+ }
238
+ if (verification.baseline.contentHash === undefined
239
+ && artifact.size === verification.baseline.size
240
+ && artifact.mtimeMs === verification.baseline.mtimeMs) {
241
+ throw new Error(`Goal deliverable has not changed since the goal started: ${verification.path}`);
242
+ }
243
+ }
244
+
245
+ export function validateGoalObjective(input: string): string | undefined {
246
+ const objective = input.trim();
247
+ if (!objective) return undefined;
248
+ return [...objective].length <= GOAL_OBJECTIVE_LIMIT ? objective : undefined;
249
+ }
250
+
251
+ export function goalElapsedMilliseconds(state: GoalState, now: number): number {
252
+ const activeInterval = state.status === "active" ? Math.max(0, now - state.activeStartedAt) : 0;
253
+ return state.activeMilliseconds + activeInterval;
254
+ }
255
+
256
+ export function commonGoalState(state: GoalState): GoalStateCommon {
257
+ return {
258
+ version: state.version,
259
+ revision: state.revision,
260
+ objective: state.objective,
261
+ createdAt: state.createdAt,
262
+ updatedAt: state.updatedAt,
263
+ activeMilliseconds: state.activeMilliseconds,
264
+ turns: state.turns,
265
+ blockedAuditStartTurn: state.blockedAuditStartTurn,
266
+ baselineTokens: state.baselineTokens,
267
+ ...(state.verification === undefined ? {} : { verification: state.verification }),
268
+ };
269
+ }
270
+
271
+ export function stopGoalClock(state: GoalState, now: number): GoalStateCommon {
272
+ const common = commonGoalState(state);
273
+ return state.status === "active"
274
+ ? { ...common, activeMilliseconds: common.activeMilliseconds + Math.max(0, now - state.activeStartedAt) }
275
+ : common;
276
+ }
277
+
278
+ export function createNewGoalState(
279
+ objective: string,
280
+ baselineTokens: number,
281
+ verification: GoalFileVerification | undefined,
282
+ now: number,
283
+ ): GoalState {
284
+ return {
285
+ version: GOAL_VERSION,
286
+ revision: 1,
287
+ objective,
288
+ status: "active",
289
+ createdAt: now,
290
+ updatedAt: now,
291
+ activeMilliseconds: 0,
292
+ activeStartedAt: now,
293
+ turns: 0,
294
+ blockedAuditStartTurn: 0,
295
+ baselineTokens,
296
+ ...(verification === undefined ? {} : { verification }),
297
+ };
298
+ }
299
+
300
+ export function editGoalState(
301
+ state: GoalState,
302
+ objective: string,
303
+ verification: GoalFileVerification | undefined,
304
+ now: number,
305
+ ): GoalState {
306
+ const current = stopGoalClock(state, now);
307
+ const { verification: _previousVerification, ...common } = current;
308
+ return {
309
+ ...common,
310
+ revision: current.revision + 1,
311
+ objective,
312
+ status: "active",
313
+ updatedAt: now,
314
+ activeStartedAt: now,
315
+ blockedAuditStartTurn: current.turns,
316
+ ...(verification === undefined ? {} : { verification }),
317
+ };
318
+ }
319
+
320
+ export function beginGoalTurnState(
321
+ current: Extract<GoalState, { status: "active" }>,
322
+ now: number,
323
+ ): GoalState {
324
+ return { ...current, revision: current.revision + 1, turns: current.turns + 1, updatedAt: now };
325
+ }
326
+
327
+ export function checkpointActiveGoalState(
328
+ current: Extract<GoalState, { status: "active" }>,
329
+ now: number,
330
+ ): GoalState {
331
+ return {
332
+ ...stopGoalClock(current, now),
333
+ revision: current.revision + 1,
334
+ status: "active",
335
+ updatedAt: now,
336
+ activeStartedAt: now,
337
+ ...(current.result === undefined ? {} : { result: current.result }),
338
+ ...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
339
+ };
340
+ }
341
+
342
+ export function pauseGoalState(
343
+ current: GoalState,
344
+ result: string | undefined,
345
+ now: number,
346
+ resumeAfterManualCompaction = false,
347
+ ): GoalState {
348
+ const common = stopGoalClock(current, now);
349
+ return {
350
+ ...common,
351
+ status: "paused",
352
+ ...(result === undefined ? {} : { result }),
353
+ ...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
354
+ ...(resumeAfterManualCompaction ? { resumeAfterManualCompaction: true as const } : {}),
355
+ };
356
+ }
357
+
358
+ export function checkpointPausedGoalState(
359
+ current: Extract<GoalState, { status: "paused" }>,
360
+ now: number,
361
+ ): GoalState {
362
+ const { resumeAfterManualCompaction: _resume, ...paused } = current;
363
+ return { ...paused, revision: paused.revision + 1, updatedAt: now };
364
+ }
365
+
366
+ export function recordGoalBlockerAudit(
367
+ state: Extract<GoalState, { status: "active" }>,
368
+ blockerAudit: GoalBlockerAudit,
369
+ now: number,
370
+ ): GoalState {
371
+ return { ...state, revision: state.revision + 1, updatedAt: now, blockerAudit };
372
+ }
373
+
374
+ export function transitionGoalState(
375
+ current: GoalState,
376
+ status: GoalStatus,
377
+ result: string | undefined,
378
+ options: GoalTransitionOptions,
379
+ now: number,
380
+ ): GoalState {
381
+ const stopped = stopGoalClock(current, now);
382
+ const common: GoalStateCommon = {
383
+ ...stopped,
384
+ revision: stopped.revision + 1,
385
+ updatedAt: now,
386
+ blockedAuditStartTurn: options.resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
387
+ };
388
+ const blockerAudit = options.resetBlockedAudit ? undefined : options.blockerAudit ?? current.blockerAudit;
389
+ switch (status) {
390
+ case "active":
391
+ return { ...common, status, activeStartedAt: now, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
392
+ case "paused":
393
+ return {
394
+ ...common,
395
+ status,
396
+ ...(result === undefined ? {} : { result }),
397
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
398
+ ...(options.resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction: true }),
399
+ };
400
+ case "blocked":
401
+ if (result === undefined) throw new Error("A blocked goal requires a result");
402
+ return { ...common, status, result, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
403
+ case "complete":
404
+ if (result === undefined) throw new Error("A complete goal requires a result");
405
+ return { ...common, status, result };
406
+ }
407
+ }