killeros 2.0.6 → 2.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.
- package/CHANGELOG.md +38 -0
- package/Killeros.ts +22 -5
- package/README.md +21 -16
- package/killeros/activity.ts +97 -0
- package/killeros/decision-gated-workflow.ts +76 -0
- package/killeros/footer.ts +31 -45
- package/killeros/goals.ts +85 -17
- package/killeros/init.ts +23 -2
- package/killeros/question.ts +56 -26
- package/killeros/runtime.ts +9 -0
- package/killeros/shell-ui.ts +14 -68
- package/killeros/variants.ts +74 -32
- package/killeros/worked-for.ts +69 -14
- package/killeros/workflow-gate.ts +347 -0
- package/package.json +7 -6
- package/killeros/concise.ts +0 -69
package/killeros/goals.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
3
5
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
6
|
import { Type } from "typebox";
|
|
@@ -7,10 +9,11 @@ import { BoundedText } from "./bounded-text.ts";
|
|
|
7
9
|
import { formatTime, formatTokens } from "./display.ts";
|
|
8
10
|
import { reportError } from "./errors.ts";
|
|
9
11
|
import { resolvePersonalInstructions } from "./personal-instructions.ts";
|
|
10
|
-
import type { GoalBlockerAudit, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
|
|
12
|
+
import type { GoalBlockerAudit, GoalFileVerification, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
|
|
11
13
|
|
|
12
14
|
const GOAL_ENTRY_TYPE = "killeros-goal";
|
|
13
15
|
const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
|
|
16
|
+
const GOAL_UPDATE_TOOL = "killeros_goal_update";
|
|
14
17
|
const GOAL_OBJECTIVE_LIMIT = 4_000;
|
|
15
18
|
const GOAL_VERSION = 1;
|
|
16
19
|
|
|
@@ -29,7 +32,6 @@ interface GoalTransitionOptions {
|
|
|
29
32
|
|
|
30
33
|
interface RestoredGoalState {
|
|
31
34
|
state?: GoalState;
|
|
32
|
-
recoveryProven: boolean;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
const GoalUpdateParams = Type.Object({
|
|
@@ -52,6 +54,7 @@ const GoalUpdateParams = Type.Object({
|
|
|
52
54
|
interface GoalUpdateDetails {
|
|
53
55
|
status: "complete" | "blocked" | "blocker-audit";
|
|
54
56
|
evidence: string;
|
|
57
|
+
verification?: "file" | "model-reported";
|
|
55
58
|
blockerKey?: string;
|
|
56
59
|
streak?: number;
|
|
57
60
|
}
|
|
@@ -64,6 +67,41 @@ function finiteNonNegative(value: unknown): value is number {
|
|
|
64
67
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
function isGoalFileVerification(value: unknown): value is GoalFileVerification {
|
|
71
|
+
if (!value || typeof value !== "object") return false;
|
|
72
|
+
const candidate = value as Partial<GoalFileVerification>;
|
|
73
|
+
return candidate.kind === "file"
|
|
74
|
+
&& typeof candidate.path === "string"
|
|
75
|
+
&& candidate.path === candidate.path.trim()
|
|
76
|
+
&& isAbsoluteFilePath(candidate.path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isAbsoluteFilePath(value: string): boolean {
|
|
80
|
+
if (!value || /^(?:https?|file):\/\//iu.test(value) || /[\\\/]$/u.test(value)) return false;
|
|
81
|
+
return path.isAbsolute(value) || path.win32.isAbsolute(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function inferGoalVerification(objective: string): GoalFileVerification | undefined {
|
|
85
|
+
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;
|
|
86
|
+
const paths = [...objective.matchAll(destination)]
|
|
87
|
+
.map((match) => (match[1] ?? match[2] ?? match[3] ?? match[4] ?? "").trim())
|
|
88
|
+
.filter(isAbsoluteFilePath);
|
|
89
|
+
const unique = [...new Set(paths)];
|
|
90
|
+
return unique.length === 1 ? { kind: "file", path: unique[0]! } : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function verifyGoalDeliverable(verification: GoalFileVerification): void {
|
|
94
|
+
let artifact: ReturnType<typeof lstatSync>;
|
|
95
|
+
try {
|
|
96
|
+
artifact = lstatSync(verification.path);
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
|
|
99
|
+
}
|
|
100
|
+
if (!artifact.isFile()) {
|
|
101
|
+
throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
67
105
|
function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
|
|
68
106
|
if (!value || typeof value !== "object") return false;
|
|
69
107
|
const candidate = value as Partial<GoalBlockerAudit>;
|
|
@@ -92,6 +130,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
|
|
|
92
130
|
|| !finiteNonNegative(candidate.baselineTokens)
|
|
93
131
|
|| candidate.activeStartedAt !== undefined && !finiteNonNegative(candidate.activeStartedAt)
|
|
94
132
|
|| candidate.result !== undefined && typeof candidate.result !== "string"
|
|
133
|
+
|| candidate.verification !== undefined && !isGoalFileVerification(candidate.verification)
|
|
95
134
|
|| candidate.blockerAudit !== undefined && !isGoalBlockerAudit(candidate.blockerAudit, candidate.turns!, candidate.status)
|
|
96
135
|
|| candidate.resumeAfterManualCompaction !== undefined && candidate.resumeAfterManualCompaction !== true
|
|
97
136
|
|| candidate.resumeAfterManualCompaction === true && candidate.status !== "paused") {
|
|
@@ -112,6 +151,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
|
|
|
112
151
|
result: candidate.result,
|
|
113
152
|
resumeAfterManualCompaction: candidate.resumeAfterManualCompaction,
|
|
114
153
|
blockerAudit: candidate.blockerAudit,
|
|
154
|
+
verification: candidate.verification,
|
|
115
155
|
};
|
|
116
156
|
}
|
|
117
157
|
|
|
@@ -130,19 +170,17 @@ function restoreGoalState(ctx: ExtensionContext): RestoredGoalState {
|
|
|
130
170
|
if (entry?.type !== "custom" || entry.customType !== GOAL_ENTRY_TYPE) continue;
|
|
131
171
|
const data = entry.data as Partial<GoalEntryData> | undefined;
|
|
132
172
|
if (!data || data.version !== GOAL_VERSION || data.state === null) {
|
|
133
|
-
return { state: undefined
|
|
173
|
+
return { state: undefined };
|
|
134
174
|
}
|
|
135
175
|
const restored = parseGoalState(data.state);
|
|
136
|
-
if (!restored) return { state: undefined
|
|
176
|
+
if (!restored) return { state: undefined };
|
|
137
177
|
const state = restored.status === "active"
|
|
138
178
|
? { ...restored, activeStartedAt: Date.now() }
|
|
139
179
|
: { ...restored, activeStartedAt: undefined };
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
&& entries.slice(index + 1).some((candidate) => candidate.type === "compaction");
|
|
143
|
-
return { state, recoveryProven };
|
|
180
|
+
if (state.status === "paused") state.resumeAfterManualCompaction = undefined;
|
|
181
|
+
return { state };
|
|
144
182
|
}
|
|
145
|
-
return { state: undefined
|
|
183
|
+
return { state: undefined };
|
|
146
184
|
}
|
|
147
185
|
|
|
148
186
|
export function goalElapsedMilliseconds(state: GoalState, now = Date.now()): number {
|
|
@@ -173,6 +211,21 @@ function sumGoalTokens(ctx: ExtensionContext): number {
|
|
|
173
211
|
return total;
|
|
174
212
|
}
|
|
175
213
|
|
|
214
|
+
function setGoalUpdateToolActive(pi: ExtensionAPI, active: boolean): void {
|
|
215
|
+
const api = pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
|
|
216
|
+
if (!api.getActiveTools || !api.setActiveTools) return;
|
|
217
|
+
const activeTools = api.getActiveTools();
|
|
218
|
+
const isActive = activeTools.includes(GOAL_UPDATE_TOOL);
|
|
219
|
+
if (active === isActive) return;
|
|
220
|
+
api.setActiveTools(active
|
|
221
|
+
? [...activeTools, GOAL_UPDATE_TOOL]
|
|
222
|
+
: activeTools.filter((name) => name !== GOAL_UPDATE_TOOL));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function syncGoalUpdateTool(pi: ExtensionAPI, runtime: GoalRuntime): void {
|
|
226
|
+
setGoalUpdateToolActive(pi, runtime.state?.status === "active");
|
|
227
|
+
}
|
|
228
|
+
|
|
176
229
|
function persistGoalState(
|
|
177
230
|
pi: ExtensionAPI,
|
|
178
231
|
runtime: GoalRuntime,
|
|
@@ -182,6 +235,7 @@ function persistGoalState(
|
|
|
182
235
|
const data: GoalEntryData = { version: GOAL_VERSION, event, state: state ?? null };
|
|
183
236
|
pi.appendEntry(GOAL_ENTRY_TYPE, data);
|
|
184
237
|
runtime.state = state;
|
|
238
|
+
syncGoalUpdateTool(pi, runtime);
|
|
185
239
|
runtime.persistenceRetryNeeded = false;
|
|
186
240
|
runtime.requestRender?.();
|
|
187
241
|
}
|
|
@@ -276,6 +330,7 @@ export function pauseGoalAfterFailure(
|
|
|
276
330
|
result: reason,
|
|
277
331
|
resumeAfterManualCompaction: undefined,
|
|
278
332
|
} : undefined;
|
|
333
|
+
syncGoalUpdateTool(pi, runtime);
|
|
279
334
|
runtime.persistenceRetryNeeded = true;
|
|
280
335
|
runtime.continuationScheduled = false;
|
|
281
336
|
runtime.requestRender?.();
|
|
@@ -301,6 +356,7 @@ function pauseGoalForPossibleManualCompaction(
|
|
|
301
356
|
result: reason,
|
|
302
357
|
resumeAfterManualCompaction: true,
|
|
303
358
|
} : undefined;
|
|
359
|
+
syncGoalUpdateTool(pi, runtime);
|
|
304
360
|
runtime.persistenceRetryNeeded = true;
|
|
305
361
|
runtime.continuationScheduled = false;
|
|
306
362
|
runtime.requestRender?.();
|
|
@@ -465,7 +521,7 @@ export function registerGoal(
|
|
|
465
521
|
});
|
|
466
522
|
|
|
467
523
|
pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
|
|
468
|
-
name:
|
|
524
|
+
name: GOAL_UPDATE_TOOL,
|
|
469
525
|
label: "Goal update",
|
|
470
526
|
description: "Mark the active KillerOS long-running goal complete after verification, or record the same blocker key on three consecutive goal turns before blocking it.",
|
|
471
527
|
parameters: GoalUpdateParams,
|
|
@@ -478,10 +534,14 @@ export function registerGoal(
|
|
|
478
534
|
const evidence = params.evidence.trim();
|
|
479
535
|
if (!evidence) throw new Error("Goal evidence must not be empty");
|
|
480
536
|
if (params.status === "complete") {
|
|
537
|
+
if (state.verification) verifyGoalDeliverable(state.verification);
|
|
538
|
+
const verification = state.verification ? "file" : "model-reported";
|
|
481
539
|
transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true });
|
|
482
540
|
return {
|
|
483
|
-
content: [{ type: "text", text:
|
|
484
|
-
|
|
541
|
+
content: [{ type: "text", text: state.verification
|
|
542
|
+
? `Goal verified complete at ${state.verification.path}: ${evidence}`
|
|
543
|
+
: `Goal marked complete (model-reported): ${evidence}` }],
|
|
544
|
+
details: { status: "complete", evidence, verification },
|
|
485
545
|
};
|
|
486
546
|
}
|
|
487
547
|
if (!runtime.goalTurnInFlight) throw new Error("A blocker audit can only be recorded during an active KillerOS goal turn");
|
|
@@ -516,7 +576,12 @@ export function registerGoal(
|
|
|
516
576
|
renderCall(args, theme) {
|
|
517
577
|
return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", args.status)}`, 0, 0);
|
|
518
578
|
},
|
|
519
|
-
renderResult(result, options, theme) {
|
|
579
|
+
renderResult(result, options, theme, context) {
|
|
580
|
+
if (context?.isError) {
|
|
581
|
+
const first = result.content[0];
|
|
582
|
+
const message = first?.type === "text" ? first.text : "Goal update failed";
|
|
583
|
+
return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 3);
|
|
584
|
+
}
|
|
520
585
|
const details = result.details;
|
|
521
586
|
if (!details) return new BoundedText(theme.fg("dim", "Goal updated"));
|
|
522
587
|
const label = details.status === "complete" ? "✓ Complete" : details.status === "blocked" ? "! Blocked" : `! Blocker audit ${details.streak}/3`;
|
|
@@ -527,7 +592,8 @@ export function registerGoal(
|
|
|
527
592
|
|
|
528
593
|
const restoreGoal = (ctx: ExtensionContext): void => {
|
|
529
594
|
const restored = restoreGoalState(ctx);
|
|
530
|
-
runtime.state = restored.state;
|
|
595
|
+
runtime.state = isGoalModeSupported(ctx) ? restored.state : undefined;
|
|
596
|
+
syncGoalUpdateTool(pi, runtime);
|
|
531
597
|
runtime.continuationScheduled = false;
|
|
532
598
|
runtime.continuationHeld = false;
|
|
533
599
|
runtime.goalTurnInFlight = false;
|
|
@@ -536,9 +602,7 @@ export function registerGoal(
|
|
|
536
602
|
runtime.lastStopReason = undefined;
|
|
537
603
|
runtime.lastError = undefined;
|
|
538
604
|
runtime.requestRender?.();
|
|
539
|
-
if (
|
|
540
|
-
recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
|
|
541
|
-
} else if (runtime.state?.status === "active") {
|
|
605
|
+
if (runtime.state?.status === "active") {
|
|
542
606
|
setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
|
|
543
607
|
}
|
|
544
608
|
};
|
|
@@ -561,6 +625,7 @@ export function registerGoal(
|
|
|
561
625
|
}
|
|
562
626
|
}
|
|
563
627
|
runtime.state = undefined;
|
|
628
|
+
syncGoalUpdateTool(pi, runtime);
|
|
564
629
|
runtime.continuationScheduled = false;
|
|
565
630
|
runtime.continuationHeld = false;
|
|
566
631
|
runtime.goalTurnInFlight = false;
|
|
@@ -680,6 +745,7 @@ export function registerGoal(
|
|
|
680
745
|
ctx.ui.notify("Goal pause saved. Goal remains paused. Automatic compaction recovery is off.", "info");
|
|
681
746
|
} catch (error) {
|
|
682
747
|
runtime.state = checkpoint;
|
|
748
|
+
syncGoalUpdateTool(pi, runtime);
|
|
683
749
|
runtime.persistenceRetryNeeded = true;
|
|
684
750
|
runtime.continuationScheduled = false;
|
|
685
751
|
runtime.requestRender?.();
|
|
@@ -798,6 +864,7 @@ export function registerGoal(
|
|
|
798
864
|
activeStartedAt: now,
|
|
799
865
|
blockedAuditStartTurn: current.turns,
|
|
800
866
|
blockerAudit: undefined,
|
|
867
|
+
verification: inferGoalVerification(objective),
|
|
801
868
|
result: undefined,
|
|
802
869
|
resumeAfterManualCompaction: undefined,
|
|
803
870
|
};
|
|
@@ -869,6 +936,7 @@ export function registerGoal(
|
|
|
869
936
|
turns: 0,
|
|
870
937
|
blockedAuditStartTurn: 0,
|
|
871
938
|
baselineTokens: sumGoalTokens(ctx),
|
|
939
|
+
verification: inferGoalVerification(objective),
|
|
872
940
|
};
|
|
873
941
|
try {
|
|
874
942
|
persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
|
package/killeros/init.ts
CHANGED
|
@@ -133,8 +133,10 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
133
133
|
|
|
134
134
|
pi.on("session_start", () => setInitTools(pi, initState, false));
|
|
135
135
|
pi.on("session_shutdown", () => {
|
|
136
|
+
const settle = initState.settle;
|
|
136
137
|
setInitTools(pi, initState, false);
|
|
137
138
|
resetInitRuntime(initState);
|
|
139
|
+
settle?.({ kind: "cancelled" });
|
|
138
140
|
});
|
|
139
141
|
pi.on("before_agent_start", () => {
|
|
140
142
|
if (initState.active) setInitTools(pi, initState, true);
|
|
@@ -160,7 +162,7 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
160
162
|
ctx.ui.notify("/init requires interactive TUI mode", "error");
|
|
161
163
|
return;
|
|
162
164
|
}
|
|
163
|
-
if (initState.active) {
|
|
165
|
+
if (initState.active || initState.starting) {
|
|
164
166
|
ctx.ui.notify("/init is already running", "warning");
|
|
165
167
|
return;
|
|
166
168
|
}
|
|
@@ -172,28 +174,45 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
172
174
|
ctx.ui.notify("Trust this project before running /init", "error");
|
|
173
175
|
return;
|
|
174
176
|
}
|
|
175
|
-
|
|
177
|
+
const starting = Symbol();
|
|
178
|
+
initState.starting = starting;
|
|
179
|
+
try {
|
|
180
|
+
await ctx.waitForIdle();
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (initState.starting !== starting) return;
|
|
183
|
+
initState.starting = undefined;
|
|
184
|
+
reportError(ctx, "/init could not wait for active work", error);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (initState.starting !== starting) return;
|
|
176
188
|
|
|
177
189
|
let projectRoot: string;
|
|
178
190
|
try {
|
|
179
191
|
projectRoot = await fs.realpath(ctx.cwd);
|
|
180
192
|
} catch (error) {
|
|
193
|
+
if (initState.starting !== starting) return;
|
|
194
|
+
initState.starting = undefined;
|
|
181
195
|
reportError(ctx, "/init could not resolve the project root", error);
|
|
182
196
|
return;
|
|
183
197
|
}
|
|
198
|
+
if (initState.starting !== starting) return;
|
|
184
199
|
const targetPath = path.join(projectRoot, "AGENTS.md");
|
|
185
200
|
try {
|
|
186
201
|
const [{ index: evidence }, baseline] = await Promise.all([
|
|
187
202
|
buildInitEvidence(projectRoot),
|
|
188
203
|
captureInitTargetBaseline(targetPath),
|
|
189
204
|
]);
|
|
205
|
+
if (initState.starting !== starting) return;
|
|
190
206
|
initState.active = true;
|
|
191
207
|
initState.projectRoot = projectRoot;
|
|
192
208
|
initState.targetPath = targetPath;
|
|
193
209
|
initState.evidence = evidence;
|
|
194
210
|
initState.baseline = baseline;
|
|
195
211
|
initState.outcome = { kind: "pending" };
|
|
212
|
+
initState.starting = undefined;
|
|
196
213
|
} catch (error) {
|
|
214
|
+
if (initState.starting !== starting) return;
|
|
215
|
+
initState.starting = undefined;
|
|
197
216
|
reportError(ctx, "/init could not capture safe repository evidence", error);
|
|
198
217
|
return;
|
|
199
218
|
}
|
|
@@ -234,6 +253,8 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
234
253
|
case "policy-conflict":
|
|
235
254
|
ctx.ui.notify(`/init left AGENTS.md unchanged: ${outcome.reason}`, "warning");
|
|
236
255
|
break;
|
|
256
|
+
case "cancelled":
|
|
257
|
+
break;
|
|
237
258
|
default:
|
|
238
259
|
reportError(ctx, "/init did not generate AGENTS.md", "the model completed without a write or policy-conflict outcome");
|
|
239
260
|
}
|
package/killeros/question.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
type ThemeColor,
|
|
6
|
+
type ToolDefinition,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
3
8
|
import {
|
|
4
9
|
decodeKittyPrintable,
|
|
5
10
|
Editor,
|
|
@@ -9,7 +14,7 @@ import {
|
|
|
9
14
|
wrapTextWithAnsi,
|
|
10
15
|
type EditorTheme,
|
|
11
16
|
} from "@earendil-works/pi-tui";
|
|
12
|
-
import { Type } from "typebox";
|
|
17
|
+
import { Type, type Static } from "typebox";
|
|
13
18
|
import { BoundedText } from "./bounded-text.ts";
|
|
14
19
|
|
|
15
20
|
const OptionSchema = Type.Object({
|
|
@@ -31,16 +36,42 @@ const QuestionParams = Type.Object({
|
|
|
31
36
|
minSelections: Type.Optional(Type.Integer({
|
|
32
37
|
minimum: 1,
|
|
33
38
|
maximum: 10,
|
|
34
|
-
description: "Minimum answers required in multiple mode; defaults to 1",
|
|
39
|
+
description: "Minimum answers required in multiple mode; defaults to 1. Single mode accepts only an explicit 1/1 bounds pair",
|
|
35
40
|
})),
|
|
36
41
|
maxSelections: Type.Optional(Type.Integer({
|
|
37
42
|
minimum: 1,
|
|
38
43
|
maximum: 10,
|
|
39
|
-
description: "Maximum answers allowed in multiple mode; defaults to all options plus one custom answer",
|
|
44
|
+
description: "Maximum answers allowed in multiple mode; defaults to all options plus one custom answer. Single mode accepts only an explicit 1/1 bounds pair",
|
|
40
45
|
})),
|
|
41
46
|
});
|
|
42
47
|
|
|
43
|
-
type
|
|
48
|
+
export type QuestionParamsValue = Static<typeof QuestionParams>;
|
|
49
|
+
|
|
50
|
+
type NormalizedQuestionSelection =
|
|
51
|
+
| { mode: "single"; minSelections: 1; maxSelections: 1 }
|
|
52
|
+
| { mode: "multiple"; minSelections: number; maxSelections: number };
|
|
53
|
+
|
|
54
|
+
function normalizeQuestionSelection(params: QuestionParamsValue): NormalizedQuestionSelection {
|
|
55
|
+
const mode = params.mode ?? "single";
|
|
56
|
+
if (mode === "single") {
|
|
57
|
+
const hasSelectionBounds = params.minSelections !== undefined || params.maxSelections !== undefined;
|
|
58
|
+
if (hasSelectionBounds && (params.minSelections !== 1 || params.maxSelections !== 1)) {
|
|
59
|
+
throw new Error("Single-select question bounds must be omitted or both be 1");
|
|
60
|
+
}
|
|
61
|
+
return { mode, minSelections: 1, maxSelections: 1 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const maximumAvailable = params.options.length + 1;
|
|
65
|
+
const minSelections = params.minSelections ?? 1;
|
|
66
|
+
const maxSelections = params.maxSelections ?? maximumAvailable;
|
|
67
|
+
if (minSelections > maxSelections) {
|
|
68
|
+
throw new Error("Question minimum selections cannot exceed maximum selections");
|
|
69
|
+
}
|
|
70
|
+
if (maxSelections > maximumAvailable) {
|
|
71
|
+
throw new Error(`Question allows at most ${maximumAvailable} selections including one custom answer`);
|
|
72
|
+
}
|
|
73
|
+
return { mode, minSelections, maxSelections };
|
|
74
|
+
}
|
|
44
75
|
|
|
45
76
|
interface DisplayOption {
|
|
46
77
|
label: string;
|
|
@@ -69,7 +100,11 @@ interface MultipleQuestionDetails {
|
|
|
69
100
|
cancelled?: boolean;
|
|
70
101
|
}
|
|
71
102
|
|
|
72
|
-
type QuestionDetails = SingleQuestionDetails | MultipleQuestionDetails;
|
|
103
|
+
export type QuestionDetails = SingleQuestionDetails | MultipleQuestionDetails;
|
|
104
|
+
|
|
105
|
+
export interface QuestionRunner {
|
|
106
|
+
ask(params: QuestionParamsValue, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<QuestionDetails>;
|
|
107
|
+
}
|
|
73
108
|
|
|
74
109
|
type QuestionSelection =
|
|
75
110
|
| { kind: "selected"; answer: string; originalIndex: number }
|
|
@@ -184,7 +219,7 @@ class MultipleResultText {
|
|
|
184
219
|
invalidate(): void {}
|
|
185
220
|
}
|
|
186
221
|
|
|
187
|
-
export function registerQuestionTool(pi: ExtensionAPI):
|
|
222
|
+
export function registerQuestionTool(pi: ExtensionAPI): QuestionRunner {
|
|
188
223
|
const customInputHistory: string[] = [];
|
|
189
224
|
let customInputHistoryBytes = 0;
|
|
190
225
|
const clearCustomInputHistory = (): void => {
|
|
@@ -216,7 +251,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
216
251
|
pi.on("session_tree", clearCustomInputHistory);
|
|
217
252
|
pi.on("session_shutdown", clearCustomInputHistory);
|
|
218
253
|
|
|
219
|
-
|
|
254
|
+
const questionTool: ToolDefinition<typeof QuestionParams, QuestionDetails> = {
|
|
220
255
|
name: "question",
|
|
221
256
|
label: "Question",
|
|
222
257
|
description: `Ask one interactive multiple-choice question. Provide 1-9 concise options. Single-select is the default; opt into bounded multi-select with mode "multiple". The user can filter options or type a custom answer. Filter queries are limited to ${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} characters and ${FILTER_QUERY_MAX_BYTES.toLocaleString()} bytes.`,
|
|
@@ -229,20 +264,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
229
264
|
executionMode: "sequential",
|
|
230
265
|
|
|
231
266
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
232
|
-
const mode
|
|
233
|
-
const hasSelectionBounds = params.minSelections !== undefined || params.maxSelections !== undefined;
|
|
234
|
-
if (mode === "single" && hasSelectionBounds) {
|
|
235
|
-
throw new Error("Question selection bounds require mode \"multiple\"");
|
|
236
|
-
}
|
|
237
|
-
const maximumAvailable = params.options.length + 1;
|
|
238
|
-
const minSelections = params.minSelections ?? 1;
|
|
239
|
-
const maxSelections = params.maxSelections ?? maximumAvailable;
|
|
240
|
-
if (minSelections > maxSelections) {
|
|
241
|
-
throw new Error("Question minimum selections cannot exceed maximum selections");
|
|
242
|
-
}
|
|
243
|
-
if (maxSelections > maximumAvailable) {
|
|
244
|
-
throw new Error(`Question allows at most ${maximumAvailable} selections including one custom answer`);
|
|
245
|
-
}
|
|
267
|
+
const { mode, minSelections, maxSelections } = normalizeQuestionSelection(params);
|
|
246
268
|
if (ctx.mode !== "tui") throw new Error("The question tool requires interactive TUI mode");
|
|
247
269
|
if (signal?.aborted) throw new Error("Question cancelled before it opened");
|
|
248
270
|
|
|
@@ -703,9 +725,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
703
725
|
},
|
|
704
726
|
|
|
705
727
|
renderCall(args, theme, context) {
|
|
706
|
-
const
|
|
707
|
-
const
|
|
708
|
-
const maximum = args.maxSelections ?? args.options.length + 1;
|
|
728
|
+
const { mode, minSelections: minimum, maxSelections: maximum } = normalizeQuestionSelection(args);
|
|
729
|
+
const multiple = mode === "multiple";
|
|
709
730
|
if (!context.expanded) {
|
|
710
731
|
const title = multiple ? "question (multi-select) " : "question ";
|
|
711
732
|
const detail = multiple ? `${args.options.length} options · choose ${minimum}–${maximum}` : `${args.options.length} option${args.options.length === 1 ? "" : "s"}`;
|
|
@@ -740,5 +761,14 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
740
761
|
}
|
|
741
762
|
return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("accent", answer)}`);
|
|
742
763
|
},
|
|
743
|
-
}
|
|
764
|
+
};
|
|
765
|
+
pi.registerTool(questionTool);
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
async ask(params, signal, ctx): Promise<QuestionDetails> {
|
|
769
|
+
const result = await questionTool.execute("killeros-workflow-gate", params, signal, undefined, ctx);
|
|
770
|
+
if (!result.details) throw new Error("Question did not return a structured result");
|
|
771
|
+
return result.details;
|
|
772
|
+
},
|
|
773
|
+
};
|
|
744
774
|
}
|
package/killeros/runtime.ts
CHANGED
|
@@ -5,10 +5,12 @@ export type InitOutcome =
|
|
|
5
5
|
| { kind: "pending" }
|
|
6
6
|
| { kind: "written" }
|
|
7
7
|
| { kind: "policy-conflict"; reason: string }
|
|
8
|
+
| { kind: "cancelled" }
|
|
8
9
|
| { kind: "no-outcome" };
|
|
9
10
|
|
|
10
11
|
export interface InitRuntime {
|
|
11
12
|
active: boolean;
|
|
13
|
+
starting?: symbol;
|
|
12
14
|
targetPath?: string;
|
|
13
15
|
projectRoot?: string;
|
|
14
16
|
activeTools?: string[];
|
|
@@ -26,6 +28,11 @@ export interface GoalBlockerAudit {
|
|
|
26
28
|
lastTurn: number;
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
export interface GoalFileVerification {
|
|
32
|
+
kind: "file";
|
|
33
|
+
path: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
export interface GoalState {
|
|
30
37
|
version: 1;
|
|
31
38
|
revision: number;
|
|
@@ -41,6 +48,7 @@ export interface GoalState {
|
|
|
41
48
|
result?: string;
|
|
42
49
|
resumeAfterManualCompaction?: true;
|
|
43
50
|
blockerAudit?: GoalBlockerAudit;
|
|
51
|
+
verification?: GoalFileVerification;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export interface GoalRuntime {
|
|
@@ -71,6 +79,7 @@ export function createGoalRuntime(): GoalRuntime {
|
|
|
71
79
|
|
|
72
80
|
export function resetInitRuntime(state: InitRuntime): void {
|
|
73
81
|
state.active = false;
|
|
82
|
+
state.starting = undefined;
|
|
74
83
|
state.targetPath = undefined;
|
|
75
84
|
state.projectRoot = undefined;
|
|
76
85
|
state.activeTools = undefined;
|
package/killeros/shell-ui.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import {
|
|
4
4
|
CustomEditor,
|
|
5
|
-
DynamicBorder,
|
|
6
5
|
VERSION,
|
|
7
6
|
type ExtensionAPI,
|
|
8
7
|
type ExtensionContext,
|
|
@@ -10,9 +9,7 @@ import {
|
|
|
10
9
|
type Theme,
|
|
11
10
|
} from "@earendil-works/pi-coding-agent";
|
|
12
11
|
import {
|
|
13
|
-
Container,
|
|
14
12
|
CURSOR_MARKER,
|
|
15
|
-
Text,
|
|
16
13
|
truncateToWidth,
|
|
17
14
|
visibleWidth,
|
|
18
15
|
wrapTextWithAnsi,
|
|
@@ -182,7 +179,7 @@ function isBorderLine(line: string): boolean {
|
|
|
182
179
|
|
|
183
180
|
function isScrolledTopBorder(line: string): boolean {
|
|
184
181
|
const unstyled = stripAnsi(line);
|
|
185
|
-
return unstyled.includes("↑")
|
|
182
|
+
return unstyled.includes("↑");
|
|
186
183
|
}
|
|
187
184
|
|
|
188
185
|
class PiCodeEditor extends CustomEditor {
|
|
@@ -219,10 +216,9 @@ class PiCodeEditor extends CustomEditor {
|
|
|
219
216
|
|
|
220
217
|
override render(width: number): string[] {
|
|
221
218
|
if (width <= 0) return [];
|
|
222
|
-
|
|
223
|
-
const innerWidth = width - 2;
|
|
219
|
+
const innerWidth = Math.max(1, width - 2);
|
|
224
220
|
const lines = super.render(innerWidth);
|
|
225
|
-
if (lines.length < 2) return lines.map((line) => truncateToWidth(line, width, ""));
|
|
221
|
+
if (lines.length < 2) return ["", ...lines.map((line) => truncateToWidth(line, width, ""))];
|
|
226
222
|
let bottomBorderIndex = lines.length - 1;
|
|
227
223
|
for (let index = lines.length - 1; index >= 1; index -= 1) {
|
|
228
224
|
if (isBorderLine(lines[index] ?? "")) {
|
|
@@ -231,44 +227,40 @@ class PiCodeEditor extends CustomEditor {
|
|
|
231
227
|
}
|
|
232
228
|
}
|
|
233
229
|
|
|
234
|
-
const
|
|
235
|
-
const
|
|
230
|
+
const dim = (text: string): string => this.runtimeTheme.fg("dim", text);
|
|
231
|
+
const rendered: string[] = [];
|
|
236
232
|
const top = stripAnsi(lines[0] ?? "");
|
|
237
233
|
const isScrolledHeader = isScrolledTopBorder(lines[0] ?? "");
|
|
238
234
|
if (isScrolledHeader) {
|
|
239
235
|
const count = top.match(/↑\s*(\d+)/)?.[1] ?? "";
|
|
240
|
-
|
|
241
|
-
framed.push(truncateToWidth(indicator, width, ""));
|
|
242
|
-
} else {
|
|
243
|
-
framed.push(gray("─".repeat(width)));
|
|
236
|
+
rendered.push(truncateToWidth(dim(` ↑ ${count} more`), width, ""));
|
|
244
237
|
}
|
|
245
238
|
|
|
246
239
|
for (let index = 1; index < bottomBorderIndex; index += 1) {
|
|
247
240
|
const isPromptLine = index === 1 && !isScrolledHeader;
|
|
248
|
-
const prefix = isPromptLine
|
|
241
|
+
const prefix = isPromptLine
|
|
242
|
+
? this.runtimeTheme.fg(this.focused ? "accent" : "dim", "❯\u00A0")
|
|
243
|
+
: " ";
|
|
249
244
|
let content = lines[index] ?? "";
|
|
250
245
|
if (isPromptLine && this.getText() === "") {
|
|
251
246
|
const first = this.suggestion.slice(0, 1);
|
|
252
247
|
const rest = this.suggestion.slice(1);
|
|
253
248
|
const cursorMarker = this.focused ? CURSOR_MARKER : "";
|
|
254
|
-
content = `${cursorMarker}\x1B[7m${
|
|
249
|
+
content = `${cursorMarker}\x1B[7m${dim(first)}\x1B[27m${dim(rest)}`;
|
|
255
250
|
}
|
|
256
|
-
|
|
251
|
+
rendered.push(`${prefix}${padRight(content, innerWidth)}`);
|
|
257
252
|
}
|
|
258
253
|
|
|
259
254
|
const bottom = stripAnsi(lines[bottomBorderIndex] ?? "");
|
|
260
255
|
if (bottom.includes("↓")) {
|
|
261
256
|
const count = bottom.match(/↓\s*(\d+)/)?.[1] ?? "";
|
|
262
|
-
|
|
263
|
-
framed.push(truncateToWidth(indicator, width, ""));
|
|
264
|
-
} else {
|
|
265
|
-
framed.push(gray("─".repeat(width)));
|
|
257
|
+
rendered.push(truncateToWidth(dim(` ↓ ${count} more`), width, ""));
|
|
266
258
|
}
|
|
267
259
|
|
|
268
260
|
for (let index = bottomBorderIndex + 1; index < lines.length; index += 1) {
|
|
269
|
-
|
|
261
|
+
rendered.push(` ${padRight(lines[index] ?? "", innerWidth)}`);
|
|
270
262
|
}
|
|
271
|
-
return
|
|
263
|
+
return ["", ...rendered.map((line) => truncateToWidth(line, width, ""))];
|
|
272
264
|
}
|
|
273
265
|
}
|
|
274
266
|
|
|
@@ -277,39 +269,11 @@ const ACTIVITY_FRAMES = [
|
|
|
277
269
|
"✽", "✻", "✶", "✱", "✢", "·",
|
|
278
270
|
] as const;
|
|
279
271
|
const ACTIVITY_FRAME_INTERVAL_MS = 120;
|
|
280
|
-
const ACTIVITY_WORDS = ["Brewing", "Pondering", "Tinkering", "Wrangling", "Noodling", "Cooking"] as const;
|
|
281
|
-
|
|
282
|
-
function formatActivityMessage(word: string, theme: Theme): string {
|
|
283
|
-
return `${theme.fg("accent", `${word}…`)} ${theme.fg("dim", `(${theme.bold("esc")} to interrupt · thinking)`)}`;
|
|
284
|
-
}
|
|
285
272
|
|
|
286
273
|
let killerosEditorFactory: ReturnType<ExtensionContext["ui"]["getEditorComponent"]>;
|
|
287
274
|
|
|
288
275
|
export function registerShellUi(pi: ExtensionAPI): void {
|
|
289
276
|
let activeHeader: PiStartupHeader | undefined;
|
|
290
|
-
let activityDeck: string[] = [];
|
|
291
|
-
let lastActivityWord: string | undefined;
|
|
292
|
-
let activityTimer: ReturnType<typeof setInterval> | undefined;
|
|
293
|
-
const refillActivityDeck = (): void => {
|
|
294
|
-
activityDeck = [...ACTIVITY_WORDS];
|
|
295
|
-
for (let index = activityDeck.length - 1; index > 0; index -= 1) {
|
|
296
|
-
const swapIndex = Math.floor(Math.random() * (index + 1));
|
|
297
|
-
[activityDeck[index], activityDeck[swapIndex]] = [activityDeck[swapIndex]!, activityDeck[index]!];
|
|
298
|
-
}
|
|
299
|
-
if (activityDeck.length > 1 && activityDeck.at(-1) === lastActivityWord) {
|
|
300
|
-
[activityDeck[0], activityDeck[activityDeck.length - 1]] = [activityDeck.at(-1)!, activityDeck[0]!];
|
|
301
|
-
}
|
|
302
|
-
};
|
|
303
|
-
const nextActivityWord = (): string => {
|
|
304
|
-
if (activityDeck.length === 0) refillActivityDeck();
|
|
305
|
-
const word = activityDeck.pop() ?? ACTIVITY_WORDS[0];
|
|
306
|
-
lastActivityWord = word;
|
|
307
|
-
return word;
|
|
308
|
-
};
|
|
309
|
-
const clearActivityTimer = (): void => {
|
|
310
|
-
if (activityTimer) clearInterval(activityTimer);
|
|
311
|
-
activityTimer = undefined;
|
|
312
|
-
};
|
|
313
277
|
|
|
314
278
|
pi.on("session_start", (_event, ctx) => {
|
|
315
279
|
if (ctx.mode !== "tui") return;
|
|
@@ -321,7 +285,6 @@ export function registerShellUi(pi: ExtensionAPI): void {
|
|
|
321
285
|
activeHeader = new PiStartupHeader(pi, ctx, startupTip, tui);
|
|
322
286
|
return activeHeader;
|
|
323
287
|
});
|
|
324
|
-
clearActivityTimer();
|
|
325
288
|
ctx.ui.setWorkingIndicator({
|
|
326
289
|
frames: ACTIVITY_FRAMES.map((frame) => ctx.ui.theme.fg("accent", frame)),
|
|
327
290
|
intervalMs: ACTIVITY_FRAME_INTERVAL_MS,
|
|
@@ -339,25 +302,8 @@ export function registerShellUi(pi: ExtensionAPI): void {
|
|
|
339
302
|
}
|
|
340
303
|
});
|
|
341
304
|
|
|
342
|
-
pi.on("agent_start", (_event, ctx) => {
|
|
343
|
-
if (ctx.mode !== "tui") return;
|
|
344
|
-
clearActivityTimer();
|
|
345
|
-
const updateWorkingWord = (): void => ctx.ui.setWorkingMessage(formatActivityMessage(nextActivityWord(), ctx.ui.theme));
|
|
346
|
-
updateWorkingWord();
|
|
347
|
-
activityTimer = setInterval(updateWorkingWord, 2_500);
|
|
348
|
-
activityTimer.unref?.();
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
pi.on("agent_end", (_event, ctx) => {
|
|
352
|
-
clearActivityTimer();
|
|
353
|
-
if (ctx.mode === "tui") ctx.ui.setWorkingMessage();
|
|
354
|
-
});
|
|
355
|
-
|
|
356
305
|
pi.on("session_shutdown", () => {
|
|
357
|
-
clearActivityTimer();
|
|
358
306
|
activeHeader?.dispose();
|
|
359
307
|
activeHeader = undefined;
|
|
360
|
-
activityDeck = [];
|
|
361
|
-
lastActivityWord = undefined;
|
|
362
308
|
});
|
|
363
309
|
}
|