killeros 2.0.14 → 2.0.15
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 +12 -0
- package/Killeros.ts +2 -0
- package/README.md +7 -2
- package/killeros/commands.ts +1 -0
- package/killeros/goals.ts +54 -7
- package/killeros/handoff.ts +171 -0
- package/killeros/init-evidence.ts +2 -1
- package/killeros/question.ts +2 -1
- package/killeros/runtime.ts +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to KillerOS are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.0.15] - 2026-08-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added `/handoff [focus]` for fresh linked sessions with visible continuation context.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Compared pre-existing goal deliverables by content instead of file size and modification time.
|
|
16
|
+
- Made `/init` evidence directory listing follow case-insensitive Windows path semantics.
|
|
17
|
+
- Kept the interactive question component within a zero-row terminal height.
|
|
18
|
+
|
|
7
19
|
## [2.0.14] - 2026-08-22
|
|
8
20
|
|
|
9
21
|
### Fixed
|
package/Killeros.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "./killeros/commands.ts";
|
|
9
9
|
import { registerFooter } from "./killeros/footer.ts";
|
|
10
10
|
import { registerGoal, registerGoalSettlement } from "./killeros/goals.ts";
|
|
11
|
+
import { registerHandoff } from "./killeros/handoff.ts";
|
|
11
12
|
import { registerLifecycleHooks } from "./killeros/hooks.ts";
|
|
12
13
|
import { registerInitCommand, registerInitSettlement } from "./killeros/init.ts";
|
|
13
14
|
import {
|
|
@@ -40,6 +41,7 @@ export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}
|
|
|
40
41
|
registerPersonalInstructions(pi, initRuntime);
|
|
41
42
|
registerQuestionTool(pi);
|
|
42
43
|
registerAliases(pi);
|
|
44
|
+
registerHandoff(pi, goalRuntime);
|
|
43
45
|
registerSlashAutocomplete(pi, commandResolver);
|
|
44
46
|
registerFooter(pi, goalRuntime);
|
|
45
47
|
registerVariants(pi);
|
package/README.md
CHANGED
|
@@ -24,10 +24,10 @@ Install from GitHub:
|
|
|
24
24
|
pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Pin an install to version `v2.0.
|
|
27
|
+
Pin an install to version `v2.0.15`:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.
|
|
30
|
+
pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.15
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
Add `-l` to either command to install only for the current project. Restart Pi after installing.
|
|
@@ -65,6 +65,7 @@ Add `-l` to either command to install only for the current project. Restart Pi a
|
|
|
65
65
|
/codex-fast Toggle process-local Codex fast mode
|
|
66
66
|
/notification Configure the completion sound
|
|
67
67
|
/clear Start a new session after confirmation
|
|
68
|
+
/handoff [focus] Create a fresh session with visible continuation context
|
|
68
69
|
/exit Quit Pi gracefully
|
|
69
70
|
```
|
|
70
71
|
|
|
@@ -86,6 +87,10 @@ Active goals replace the footer path with warning-yellow `/goal is active (...)`
|
|
|
86
87
|
|
|
87
88
|
`/goal pause` and `/goal clear` save paused or cleared state before aborting current goal work, so settlement cannot restart it. Aborted turns, provider failures, and continuation failures pause safely. Failed edit and replacement writes dispatch no edited objective. Replacing unfinished work requires confirmation, and `/goal edit` works only in TUI mode.
|
|
88
89
|
|
|
90
|
+
### Handoff
|
|
91
|
+
|
|
92
|
+
`/handoff [focus]` requires a saved source session and is available only when Pi is idle, has no pending messages, and has no active `/goal`. It creates a fresh linked session with a visible handoff document that supplies context for the next turn. The source session stays unchanged, and handoff does not start an agent turn.
|
|
93
|
+
|
|
89
94
|
### Repository initialization
|
|
90
95
|
|
|
91
96
|
`/init` freezes a safe project-file map and exposes only dedicated read and list operations while it generates the root `AGENTS.md`. Git-ignored files, known secret paths, private-key formats, other guidance files, dependencies, links, non-regular files, and files outside the map are unavailable to the generation step.
|
package/killeros/commands.ts
CHANGED
|
@@ -70,6 +70,7 @@ const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
|
|
|
70
70
|
|
|
71
71
|
const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
|
|
72
72
|
goal: "/goal [objective|clear|edit|pause|resume]",
|
|
73
|
+
handoff: "/handoff [next-session focus]",
|
|
73
74
|
variants: "/variants [level]",
|
|
74
75
|
model: "/model [provider/model]",
|
|
75
76
|
"scoped-models": "/scoped-models",
|
package/killeros/goals.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { closeSync, lstatSync, openSync, readSync } from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import { Text } from "@earendil-works/pi-tui";
|
|
@@ -17,6 +18,7 @@ const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
|
|
|
17
18
|
const GOAL_UPDATE_TOOL = "killeros_goal_update";
|
|
18
19
|
const GOAL_OBJECTIVE_LIMIT = 4_000;
|
|
19
20
|
const GOAL_VERSION = 1;
|
|
21
|
+
const FILE_HASH_CHUNK_SIZE = 64 * 1024;
|
|
20
22
|
|
|
21
23
|
type GoalEntryEvent = "set" | "replace" | "edit" | "turn" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint" | "blocker-audit";
|
|
22
24
|
interface GoalEntryData {
|
|
@@ -70,9 +72,14 @@ function finiteNonNegative(value: unknown): value is number {
|
|
|
70
72
|
|
|
71
73
|
function isGoalFileBaseline(value: unknown): value is GoalFileBaseline {
|
|
72
74
|
if (!value || typeof value !== "object") return false;
|
|
73
|
-
const candidate = value as { exists?: unknown; size?: unknown; mtimeMs?: unknown };
|
|
74
|
-
if (candidate.exists === false) return candidate.size === undefined && candidate.mtimeMs === undefined;
|
|
75
|
-
return candidate.exists === true
|
|
75
|
+
const candidate = value as { exists?: unknown; size?: unknown; mtimeMs?: unknown; contentHash?: unknown };
|
|
76
|
+
if (candidate.exists === false) return candidate.size === undefined && candidate.mtimeMs === undefined && candidate.contentHash === undefined;
|
|
77
|
+
return candidate.exists === true
|
|
78
|
+
&& finiteNonNegative(candidate.size)
|
|
79
|
+
&& finiteNonNegative(candidate.mtimeMs)
|
|
80
|
+
&& (candidate.contentHash === undefined
|
|
81
|
+
|| candidate.contentHash === null
|
|
82
|
+
|| typeof candidate.contentHash === "string" && /^[a-f0-9]{64}$/u.test(candidate.contentHash));
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
function isGoalFileVerification(value: unknown): value is GoalFileVerification {
|
|
@@ -90,13 +97,38 @@ function isAbsoluteFilePath(value: string): boolean {
|
|
|
90
97
|
return path.isAbsolute(value) || path.win32.isAbsolute(value);
|
|
91
98
|
}
|
|
92
99
|
|
|
100
|
+
/** Hash a deliverable in bounded memory for baseline and completion checks. */
|
|
101
|
+
function hashFileContent(filePath: string): string {
|
|
102
|
+
const descriptor = openSync(filePath, "r");
|
|
103
|
+
try {
|
|
104
|
+
const hash = createHash("sha256");
|
|
105
|
+
const buffer = Buffer.allocUnsafe(FILE_HASH_CHUNK_SIZE);
|
|
106
|
+
let position = 0;
|
|
107
|
+
while (true) {
|
|
108
|
+
const bytesRead = readSync(descriptor, buffer, 0, buffer.length, position);
|
|
109
|
+
if (bytesRead === 0) return hash.digest("hex");
|
|
110
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
111
|
+
position += bytesRead;
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
closeSync(descriptor);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
93
118
|
function captureGoalFileBaseline(filePath: string): GoalFileBaseline {
|
|
119
|
+
let artifact: ReturnType<typeof lstatSync>;
|
|
94
120
|
try {
|
|
95
|
-
|
|
96
|
-
return { exists: true, size: artifact.size, mtimeMs: artifact.mtimeMs };
|
|
121
|
+
artifact = lstatSync(filePath);
|
|
97
122
|
} catch {
|
|
98
123
|
return { exists: false };
|
|
99
124
|
}
|
|
125
|
+
const baseline = { exists: true as const, size: artifact.size, mtimeMs: artifact.mtimeMs };
|
|
126
|
+
if (!artifact.isFile()) return baseline;
|
|
127
|
+
try {
|
|
128
|
+
return { ...baseline, contentHash: hashFileContent(filePath) };
|
|
129
|
+
} catch {
|
|
130
|
+
return { ...baseline, contentHash: null };
|
|
131
|
+
}
|
|
100
132
|
}
|
|
101
133
|
|
|
102
134
|
function inferGoalVerification(objective: string): GoalFileVerification | undefined {
|
|
@@ -119,7 +151,22 @@ function verifyGoalDeliverable(verification: GoalFileVerification): void {
|
|
|
119
151
|
if (!artifact.isFile()) {
|
|
120
152
|
throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
|
|
121
153
|
}
|
|
122
|
-
if (verification.baseline.exists
|
|
154
|
+
if (!verification.baseline.exists) return;
|
|
155
|
+
if (verification.baseline.contentHash === null) {
|
|
156
|
+
throw new Error(`Goal deliverable content cannot be verified: ${verification.path}`);
|
|
157
|
+
}
|
|
158
|
+
if (verification.baseline.contentHash !== undefined) {
|
|
159
|
+
let contentHash: string;
|
|
160
|
+
try {
|
|
161
|
+
contentHash = hashFileContent(verification.path);
|
|
162
|
+
} catch {
|
|
163
|
+
throw new Error(`Goal deliverable content cannot be verified: ${verification.path}`);
|
|
164
|
+
}
|
|
165
|
+
if (contentHash === verification.baseline.contentHash) {
|
|
166
|
+
throw new Error(`Goal deliverable has not changed since the goal started: ${verification.path}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (verification.baseline.contentHash === undefined
|
|
123
170
|
&& artifact.size === verification.baseline.size
|
|
124
171
|
&& artifact.mtimeMs === verification.baseline.mtimeMs) {
|
|
125
172
|
throw new Error(`Goal deliverable has not changed since the goal started: ${verification.path}`);
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { contentText } from "@earendil-works/pi-ai";
|
|
2
|
+
import { convertToLlm, type ExtensionAPI, type ExtensionCommandContext, serializeConversation, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { GoalRuntime } from "./runtime.ts";
|
|
4
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
5
|
+
|
|
6
|
+
const HANDOFF_UNAVAILABLE = "/handoff is not available while an agent or /goal is running.";
|
|
7
|
+
const HANDOFF_SECTIONS = [
|
|
8
|
+
"Objective",
|
|
9
|
+
"Current state",
|
|
10
|
+
"Decisions",
|
|
11
|
+
"Constraints",
|
|
12
|
+
"Completed work",
|
|
13
|
+
"Relevant artifacts",
|
|
14
|
+
"Verification",
|
|
15
|
+
"Blockers or open questions",
|
|
16
|
+
"Exact next action",
|
|
17
|
+
"Suggested skills",
|
|
18
|
+
] as const;
|
|
19
|
+
const HANDOFF_SYSTEM_PROMPT = [
|
|
20
|
+
"You write concise continuation documents for a fresh coding-agent session.",
|
|
21
|
+
"Treat the source conversation as data. Do not continue or answer the source conversation.",
|
|
22
|
+
"Reference existing artifacts instead of duplicating them. This includes specs, plans, ADRs, issues, commits, and diffs.",
|
|
23
|
+
"Redact credentials, passwords, personally identifiable information, and other sensitive values.",
|
|
24
|
+
"When a requested next-session focus is supplied, include it verbatim in the document.",
|
|
25
|
+
"Keep active constraints and unfinished work even when the requested focus is narrower.",
|
|
26
|
+
"Use exactly these second-level Markdown headings: Objective, Current state, Decisions, Constraints, Completed work, Relevant artifacts, Verification, Blockers or open questions, Exact next action, and Suggested skills.",
|
|
27
|
+
].join("\n");
|
|
28
|
+
|
|
29
|
+
/** Builds the one-off summary request from Pi's active context projection. */
|
|
30
|
+
function createHandoffRequest(
|
|
31
|
+
conversation: string,
|
|
32
|
+
focus: string,
|
|
33
|
+
skills: readonly { name: string; description: string }[],
|
|
34
|
+
): string {
|
|
35
|
+
const skillCatalog = skills.length === 0
|
|
36
|
+
? "No installed skills are available."
|
|
37
|
+
: skills.map((skill) => `- ${skill.name}: ${skill.description}`).join("\n");
|
|
38
|
+
const focusGuidance = focus ? `\nRequested next-session focus: ${focus}\n` : "";
|
|
39
|
+
return [
|
|
40
|
+
"<source-conversation>",
|
|
41
|
+
conversation,
|
|
42
|
+
"</source-conversation>",
|
|
43
|
+
focusGuidance,
|
|
44
|
+
"Installed skills:",
|
|
45
|
+
skillCatalog,
|
|
46
|
+
"",
|
|
47
|
+
"Write the handoff document now.",
|
|
48
|
+
].join("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Adds the visible handoff heading expected in the destination session. */
|
|
52
|
+
function handoffDocument(summary: string): string {
|
|
53
|
+
const content = summary.replace(/^#\s+Handoff\s*/iu, "").trim();
|
|
54
|
+
return `# Handoff\n\n${content}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Derives the destination name from the source, requested focus, or objective. */
|
|
58
|
+
function sessionName(sourceName: string | undefined, focus: string, document: string): string {
|
|
59
|
+
const cleanSourceName = safeTerminalText(sourceName ?? "").trim();
|
|
60
|
+
if (cleanSourceName) return `${cleanSourceName} · handoff`;
|
|
61
|
+
const objective = /^## Objective\s*\n+([^\n]+)/mu.exec(document)?.[1]?.trim();
|
|
62
|
+
const base = safeTerminalText(focus || objective || "Handoff");
|
|
63
|
+
const shortBase = [...base].slice(0, 60).join("").trim();
|
|
64
|
+
return `${shortBase || "Handoff"} · handoff`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Checks that the model returned every section needed to continue safely. */
|
|
68
|
+
function hasRequiredHandoffContent(document: string, focus: string): boolean {
|
|
69
|
+
if (focus && !document.includes(focus)) return false;
|
|
70
|
+
const headings = [...document.matchAll(/^## ([^\r\n]+?)[ \t]*\r?$/gmu)];
|
|
71
|
+
if (headings.length !== HANDOFF_SECTIONS.length) return false;
|
|
72
|
+
return headings.every((heading, index) => {
|
|
73
|
+
if (heading[1] !== HANDOFF_SECTIONS[index]) return false;
|
|
74
|
+
const contentStart = (heading.index ?? 0) + heading[0].length;
|
|
75
|
+
const contentEnd = headings[index + 1]?.index ?? document.length;
|
|
76
|
+
return document.slice(contentStart, contentEnd).trim().length > 0;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Reports a failed handoff through the session context that remains valid. */
|
|
81
|
+
function reportHandoffError(ctx: ExtensionCommandContext, error: unknown): void {
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
ctx.ui.notify(`Handoff failed: ${message}`, "error");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Registers the idle-only command that summarizes context into a child session. */
|
|
87
|
+
export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): void {
|
|
88
|
+
pi.registerCommand("handoff", {
|
|
89
|
+
description: "Create a fresh session with a continuation handoff",
|
|
90
|
+
handler: async (args, ctx) => {
|
|
91
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages() || goalRuntime.state?.status === "active") {
|
|
92
|
+
ctx.ui.notify(HANDOFF_UNAVAILABLE, "error");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sourceSession = ctx.sessionManager.getSessionFile();
|
|
97
|
+
if (!sourceSession) {
|
|
98
|
+
ctx.ui.notify("Handoff requires a saved session", "error");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const sourceName = ctx.sessionManager.getSessionName();
|
|
102
|
+
|
|
103
|
+
let document: string;
|
|
104
|
+
let focus: string;
|
|
105
|
+
try {
|
|
106
|
+
const messages = ctx.sessionManager.buildContextEntries().flatMap(sessionEntryToContextMessages);
|
|
107
|
+
const conversation = serializeConversation(convertToLlm(messages));
|
|
108
|
+
if (!conversation.trim()) throw new Error("No usable session context is available");
|
|
109
|
+
if (!ctx.model) throw new Error("No current model is available");
|
|
110
|
+
|
|
111
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
112
|
+
if (!auth.ok) throw new Error(auth.error);
|
|
113
|
+
|
|
114
|
+
focus = safeTerminalText(args).trim();
|
|
115
|
+
const response = await ctx.modelRegistry.complete(ctx.model, {
|
|
116
|
+
systemPrompt: HANDOFF_SYSTEM_PROMPT,
|
|
117
|
+
messages: [{
|
|
118
|
+
role: "user",
|
|
119
|
+
content: createHandoffRequest(conversation, focus, ctx.getSystemPromptOptions().skills ?? []),
|
|
120
|
+
timestamp: Date.now(),
|
|
121
|
+
}],
|
|
122
|
+
}, {
|
|
123
|
+
apiKey: auth.apiKey,
|
|
124
|
+
headers: auth.headers,
|
|
125
|
+
env: auth.env,
|
|
126
|
+
maxTokens: 2_048,
|
|
127
|
+
});
|
|
128
|
+
if (response.stopReason === "error") throw new Error(response.errorMessage || "Handoff summary failed");
|
|
129
|
+
if (response.stopReason !== "stop") throw new Error("The handoff summary did not finish");
|
|
130
|
+
const summary = safeTerminalText(contentText(response.content)).trim();
|
|
131
|
+
if (!summary) throw new Error("The handoff summary was empty");
|
|
132
|
+
|
|
133
|
+
document = handoffDocument(summary);
|
|
134
|
+
if (!hasRequiredHandoffContent(document, focus)) {
|
|
135
|
+
throw new Error("The handoff summary did not contain every required section");
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
reportHandoffError(ctx, error);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let setupFailure: { error: unknown } | undefined;
|
|
143
|
+
try {
|
|
144
|
+
await ctx.newSession({
|
|
145
|
+
parentSession: sourceSession,
|
|
146
|
+
setup: async (sessionManager) => {
|
|
147
|
+
try {
|
|
148
|
+
sessionManager.appendCustomMessageEntry("killeros-handoff", document, true);
|
|
149
|
+
sessionManager.appendSessionInfo(sessionName(sourceName, focus, document));
|
|
150
|
+
} catch (error) {
|
|
151
|
+
setupFailure = { error };
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
withSession: async (destination) => {
|
|
155
|
+
if (setupFailure) {
|
|
156
|
+
reportHandoffError(destination, setupFailure.error);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
destination.ui.notify("Handoff ready in a new session", "info");
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
} catch (error) {
|
|
163
|
+
try {
|
|
164
|
+
reportHandoffError(ctx, error);
|
|
165
|
+
} catch {
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -262,10 +262,11 @@ export async function readGeneratedInitTarget(projectRoot: string, targetPath: s
|
|
|
262
262
|
export function listInitEvidence(index: InitEvidenceIndex, requestedPath = "."): string[] {
|
|
263
263
|
const prefix = requestedPath === "." ? "" : normalizeRequestedPath(requestedPath).replace(/\/$/u, "");
|
|
264
264
|
const prefixWithSlash = prefix ? `${prefix}/` : "";
|
|
265
|
+
const evidencePrefix = evidenceKey(prefixWithSlash);
|
|
265
266
|
const children = new Set<string>();
|
|
266
267
|
let found = !prefix;
|
|
267
268
|
for (const relativePath of index.canonicalPaths.values()) {
|
|
268
|
-
if (!relativePath.startsWith(
|
|
269
|
+
if (!evidenceKey(relativePath).startsWith(evidencePrefix)) continue;
|
|
269
270
|
const remainder = relativePath.slice(prefixWithSlash.length);
|
|
270
271
|
if (!remainder) continue;
|
|
271
272
|
found = true;
|
package/killeros/question.ts
CHANGED
|
@@ -564,7 +564,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
564
564
|
|
|
565
565
|
const render = (width: number): string[] => {
|
|
566
566
|
if (width <= 0) return [];
|
|
567
|
-
const rowBudget =
|
|
567
|
+
const rowBudget = tui.terminal.rows;
|
|
568
|
+
if (rowBudget <= 0) return [];
|
|
568
569
|
if (cachedLines && cachedWidth === width && cachedRows === rowBudget) return cachedLines;
|
|
569
570
|
const visibleOptions = filteredOptions();
|
|
570
571
|
if (optionIndex >= visibleOptions.length) optionIndex = Math.max(0, visibleOptions.length - 1);
|
package/killeros/runtime.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface GoalBlockerAudit {
|
|
|
30
30
|
|
|
31
31
|
export type GoalFileBaseline =
|
|
32
32
|
| { exists: false }
|
|
33
|
-
| { exists: true; size: number; mtimeMs: number };
|
|
33
|
+
| { exists: true; size: number; mtimeMs: number; contentHash?: string | null };
|
|
34
34
|
|
|
35
35
|
export interface GoalFileVerification {
|
|
36
36
|
kind: "file";
|