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 +17 -0
- package/README.md +2 -2
- package/killeros/display.ts +8 -3
- package/killeros/footer.ts +118 -25
- package/killeros/goal-state.ts +407 -0
- package/killeros/goals.ts +21 -375
- package/killeros/handoff.ts +39 -16
- package/killeros/init-evidence.ts +15 -3
- package/killeros/personal-instructions.ts +57 -5
- package/killeros/question-ui.ts +590 -0
- package/killeros/question.ts +12 -558
- package/killeros/secret-detector.ts +19 -0
- package/killeros/shell-ui.ts +1 -1
- package/package.json +5 -5
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.
|
|
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
|
|
package/killeros/display.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 {
|
package/killeros/footer.ts
CHANGED
|
@@ -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 "./
|
|
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
|
|
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 (${
|
|
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
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
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
|
-
|
|
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 =
|
|
231
|
-
const focusedDirectory =
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
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
|
+
}
|