killeros 2.1.29 → 2.1.31
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 +558 -538
- package/Killeros.ts +53 -53
- package/README.md +112 -112
- package/killeros/activity.ts +110 -110
- package/killeros/auto-compaction.ts +342 -342
- package/killeros/change-receipt.ts +676 -682
- package/killeros/codex-fast-state.ts +47 -47
- package/killeros/codex-fast.ts +36 -36
- package/killeros/commands.ts +259 -259
- package/killeros/display.ts +60 -60
- package/killeros/errors.ts +19 -19
- package/killeros/footer.ts +505 -505
- package/killeros/goal-command.ts +26 -23
- package/killeros/goal-interface.ts +560 -560
- package/killeros/goal-runtime.ts +490 -490
- package/killeros/goal-settlement.ts +348 -348
- package/killeros/goal-state.ts +660 -660
- package/killeros/handoff.ts +326 -284
- package/killeros/hooks.ts +449 -449
- package/killeros/notifications.ts +135 -135
- package/killeros/passive-git-status.ts +346 -346
- package/killeros/personal-instructions.ts +122 -122
- package/killeros/question-ui.ts +590 -590
- package/killeros/question.ts +234 -234
- package/killeros/runtime.ts +150 -150
- package/killeros/secret-detector.ts +19 -19
- package/killeros/settings.ts +141 -50
- package/killeros/shell-ui.ts +418 -418
- package/killeros/variants.ts +13 -13
- package/killeros/worked-for.ts +496 -430
- package/package.json +67 -67
- package/themes/killeros.json +87 -87
|
@@ -1,560 +1,560 @@
|
|
|
1
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
-
import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
-
import { Type } from "typebox";
|
|
5
|
-
import { BoundedText } from "./bounded-text.ts";
|
|
6
|
-
import { formatTime, formatTokens } from "./display.ts";
|
|
7
|
-
import { reportError } from "./errors.ts";
|
|
8
|
-
import { parseGoalCommand } from "./goal-command.ts";
|
|
9
|
-
import { GOAL_ENTRY_TYPE, GOAL_UPDATE_TOOL, isGoalModeSupported, isSavedSession, pauseGoalAfterFailure, persistGoalState, scheduleGoalContinuation, stopGoalRun, sumGoalTokens, syncGoalUpdateTool, transitionGoal, type GoalEntryData } from "./goal-runtime.ts";
|
|
10
|
-
import { checkpointPausedGoalState, createNewGoalState, DEFAULT_GOAL_MAX_TURNS, GOAL_EVIDENCE_LIMIT, GOAL_MAX_TURNS, goalElapsedMilliseconds, GOAL_VERSION, inferGoalVerification, normalizeGoalText, parseGoalState, recordGoalDecision, transitionGoalState, verifyGoalDeliverable } from "./goal-state.ts";
|
|
11
|
-
import type { GoalRuntime, GoalState, GoalStatus } from "./runtime.ts";
|
|
12
|
-
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
13
|
-
|
|
14
|
-
const GoalUpdateParams = Type.Object({
|
|
15
|
-
status: StringEnum(["complete", "continue", "blocked"] as const, {
|
|
16
|
-
description: "Record exactly one active-goal decision: complete, continue, or blocked",
|
|
17
|
-
}),
|
|
18
|
-
evidence: Type.String({
|
|
19
|
-
minLength: 1,
|
|
20
|
-
maxLength: GOAL_EVIDENCE_LIMIT,
|
|
21
|
-
description: "Concrete current-turn evidence for completion, progress, or the repeated blocker",
|
|
22
|
-
}),
|
|
23
|
-
nextAction: Type.Optional(Type.String({
|
|
24
|
-
minLength: 1,
|
|
25
|
-
maxLength: GOAL_EVIDENCE_LIMIT,
|
|
26
|
-
description: "One concrete action toward the unchanged objective when status is continue",
|
|
27
|
-
})),
|
|
28
|
-
blockerKey: Type.Optional(Type.String({
|
|
29
|
-
minLength: 1,
|
|
30
|
-
maxLength: 120,
|
|
31
|
-
pattern: "^[a-z0-9][a-z0-9._-]{0,119}$",
|
|
32
|
-
description: "Stable lowercase key identifying the repeated blocker",
|
|
33
|
-
})),
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
interface GoalUpdateDetails {
|
|
37
|
-
status: "complete" | "continue" | "blocked" | "blocker-audit";
|
|
38
|
-
evidence: string;
|
|
39
|
-
nextAction?: string;
|
|
40
|
-
verification?: "file" | "model-reported";
|
|
41
|
-
blockerKey?: string;
|
|
42
|
-
streak?: number;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function goalPanelActions(status: GoalStatus): Array<{ label: string; control: "pause" | "resume" | "clear" }> {
|
|
46
|
-
if (status === "active") return [{ label: "Pause goal", control: "pause" }, { label: "Clear goal", control: "clear" }];
|
|
47
|
-
if (status === "paused" || status === "blocked") {
|
|
48
|
-
return [{ label: "Resume goal", control: "resume" }, { label: "Clear goal", control: "clear" }];
|
|
49
|
-
}
|
|
50
|
-
return [{ label: "Clear goal", control: "clear" }];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
|
|
54
|
-
const usedTokens = Math.max(0, sumGoalTokens(ctx) - state.baselineTokens);
|
|
55
|
-
const turns = state.maxTurns === undefined
|
|
56
|
-
? `${state.turns} turn${state.turns === 1 ? "" : "s"}`
|
|
57
|
-
: `${state.turns}/${state.maxTurns} turns`;
|
|
58
|
-
const lines = [
|
|
59
|
-
`Goal ${state.status} · ${turns} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))} · ${formatTokens(usedTokens)} tokens`,
|
|
60
|
-
state.objective,
|
|
61
|
-
...(state.verification === undefined ? [] : [`Deliverable · ${state.verification.path}`]),
|
|
62
|
-
];
|
|
63
|
-
const decision = state.turnDecision ?? state.lastDecision;
|
|
64
|
-
if (decision?.kind === "continue") {
|
|
65
|
-
lines.push(`Progress · model-reported on turn ${decision.turn}`);
|
|
66
|
-
lines.push(`Next · ${decision.nextAction}`);
|
|
67
|
-
} else if (decision?.kind === "blocker-audit") {
|
|
68
|
-
lines.push(`Blocker audit · ${decision.streak}/3 on turn ${decision.turn}`);
|
|
69
|
-
lines.push(`Evidence · ${decision.evidence}`);
|
|
70
|
-
} else if (decision?.kind === "blocked") {
|
|
71
|
-
lines.push(`Evidence · ${decision.evidence}`);
|
|
72
|
-
} else if (decision?.kind === "complete") {
|
|
73
|
-
lines.push(`Completion · ${decision.verification === "file" ? `verified file ${state.verification?.path ?? "deliverable"}` : "model-reported"}`);
|
|
74
|
-
lines.push(`Evidence · ${decision.evidence}`);
|
|
75
|
-
}
|
|
76
|
-
if (state.status === "paused") {
|
|
77
|
-
lines.push(state.turns === 0 ? "Paused at · before turn 1" : `Paused at · turn ${state.turns}`);
|
|
78
|
-
const reason = state.stopReason ?? state.result;
|
|
79
|
-
if (reason) lines.push(`Reason · ${reason}`);
|
|
80
|
-
if (state.stopReason === "repeated continue report" && state.lastContinueReport) {
|
|
81
|
-
lines.push(`Repeated turns · ${state.lastContinueReport.turn} and ${state.turns}`);
|
|
82
|
-
}
|
|
83
|
-
} else if (state.result && decision?.kind !== "complete" && decision?.kind !== "blocked") {
|
|
84
|
-
lines.push(`Result · ${state.result}`);
|
|
85
|
-
}
|
|
86
|
-
return safeTerminalText(lines.join("\n"));
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function registerGoalInterface(
|
|
90
|
-
pi: ExtensionAPI,
|
|
91
|
-
runtime: GoalRuntime,
|
|
92
|
-
): void {
|
|
93
|
-
pi.registerEntryRenderer<GoalEntryData>(GOAL_ENTRY_TYPE, (entry, options, theme) => {
|
|
94
|
-
const data = entry.data;
|
|
95
|
-
if (!data || data.version !== GOAL_VERSION) return undefined;
|
|
96
|
-
if (data.event === "clear" || data.state === null) return new Text(theme.fg("dim", "Goal cleared"), 0, 0);
|
|
97
|
-
const event = data.event;
|
|
98
|
-
if (event !== "set" && event !== "replace" && event !== "pause" && event !== "resume" && event !== "limit" && event !== "error") return undefined;
|
|
99
|
-
const state = parseGoalState(data.state);
|
|
100
|
-
if (!state) return undefined;
|
|
101
|
-
const reason = state.stopReason ?? state.result;
|
|
102
|
-
const objective = safeTerminalText(state.objective);
|
|
103
|
-
let label: string;
|
|
104
|
-
let detail: string;
|
|
105
|
-
let color: ThemeColor;
|
|
106
|
-
switch (event) {
|
|
107
|
-
case "set":
|
|
108
|
-
label = "Goal started";
|
|
109
|
-
detail = objective;
|
|
110
|
-
color = "customMessageLabel";
|
|
111
|
-
break;
|
|
112
|
-
case "replace":
|
|
113
|
-
label = "Goal replaced";
|
|
114
|
-
detail = objective;
|
|
115
|
-
color = "customMessageLabel";
|
|
116
|
-
break;
|
|
117
|
-
case "resume":
|
|
118
|
-
label = "Goal resumed";
|
|
119
|
-
detail = objective;
|
|
120
|
-
color = "customMessageLabel";
|
|
121
|
-
break;
|
|
122
|
-
case "pause":
|
|
123
|
-
label = "Goal paused";
|
|
124
|
-
detail = reason ?? objective;
|
|
125
|
-
color = "warning";
|
|
126
|
-
break;
|
|
127
|
-
case "limit":
|
|
128
|
-
label = "Goal paused";
|
|
129
|
-
detail = reason ?? "";
|
|
130
|
-
color = "warning";
|
|
131
|
-
break;
|
|
132
|
-
case "error":
|
|
133
|
-
label = "Goal paused";
|
|
134
|
-
detail = reason ?? "";
|
|
135
|
-
color = "error";
|
|
136
|
-
break;
|
|
137
|
-
}
|
|
138
|
-
const safeDetail = safeTerminalText(detail);
|
|
139
|
-
const status = theme.fg(color, label);
|
|
140
|
-
if (!options.expanded) {
|
|
141
|
-
return new BoundedText(safeDetail ? `${status}${theme.fg("dim", " · ")}${safeDetail}` : status, 1);
|
|
142
|
-
}
|
|
143
|
-
const lines = [status, objective];
|
|
144
|
-
if (reason && reason !== objective) lines.push(theme.fg("muted", safeTerminalText(reason)));
|
|
145
|
-
return new BoundedText(lines.join("\n"));
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
|
|
149
|
-
name: GOAL_UPDATE_TOOL,
|
|
150
|
-
label: "Goal update",
|
|
151
|
-
description: "Record exactly one active-goal decision: complete after verification, continue with evidence and one next action, or audit the same blocker before blocking it.",
|
|
152
|
-
parameters: GoalUpdateParams,
|
|
153
|
-
executionMode: "sequential",
|
|
154
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
155
|
-
signal?.throwIfAborted();
|
|
156
|
-
if (!isGoalModeSupported(ctx)) throw new Error("KillerOS goals require TUI or RPC mode");
|
|
157
|
-
if (!isSavedSession(ctx)) throw new Error("KillerOS goals require a saved session");
|
|
158
|
-
const state = runtime.state;
|
|
159
|
-
if (!state || state.status !== "active") throw new Error("There is no active KillerOS goal to update");
|
|
160
|
-
if (!runtime.goalTurnInFlight
|
|
161
|
-
|| runtime.goalTurn?.turn !== state.turns
|
|
162
|
-
|| runtime.goalTurn.revision !== state.revision) {
|
|
163
|
-
throw new Error("A goal decision can only be recorded during an active KillerOS goal turn");
|
|
164
|
-
}
|
|
165
|
-
if (state.turnDecision !== undefined) {
|
|
166
|
-
throw new Error("Only one goal decision may be accepted per logical goal turn");
|
|
167
|
-
}
|
|
168
|
-
if (params.status !== "complete" && params.status !== "continue" && params.status !== "blocked") {
|
|
169
|
-
throw new Error("Goal update status is invalid");
|
|
170
|
-
}
|
|
171
|
-
if (typeof params.evidence !== "string") throw new Error("Goal evidence must be text");
|
|
172
|
-
const evidence = normalizeGoalText(params.evidence, GOAL_EVIDENCE_LIMIT, "Goal evidence");
|
|
173
|
-
|
|
174
|
-
if (params.status === "complete") {
|
|
175
|
-
if (state.verification) await verifyGoalDeliverable(state.verification);
|
|
176
|
-
signal?.throwIfAborted();
|
|
177
|
-
if (runtime.state !== state
|
|
178
|
-
|| runtime.goalTurn?.turn !== state.turns
|
|
179
|
-
|| runtime.goalTurn.revision !== state.revision
|
|
180
|
-
|| state.turnDecision !== undefined
|
|
181
|
-
|| !runtime.goalTurnInFlight) throw new Error("Goal changed while completion was being verified");
|
|
182
|
-
const verification: "file" | "model-reported" = state.verification ? "file" : "model-reported";
|
|
183
|
-
const decision = { kind: "complete" as const, turn: state.turns, evidence, verification };
|
|
184
|
-
try {
|
|
185
|
-
transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true, decision });
|
|
186
|
-
} catch (error) {
|
|
187
|
-
pauseGoalAfterFailure(pi, runtime, ctx, `goal completion could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
188
|
-
throw error;
|
|
189
|
-
}
|
|
190
|
-
const text = state.verification
|
|
191
|
-
? `Goal verified complete at ${safeTerminalText(state.verification.path)}: ${evidence}`
|
|
192
|
-
: `Goal marked complete (model-reported): ${evidence}`;
|
|
193
|
-
return {
|
|
194
|
-
content: [{ type: "text", text }],
|
|
195
|
-
details: { status: "complete", evidence, verification },
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (params.status === "continue") {
|
|
200
|
-
if (typeof params.nextAction !== "string") throw new Error("A continue decision requires a nextAction");
|
|
201
|
-
const nextAction = normalizeGoalText(params.nextAction, GOAL_EVIDENCE_LIMIT, "Goal nextAction");
|
|
202
|
-
const previous = state.lastContinueReport;
|
|
203
|
-
if (previous?.turn === state.turns - 1
|
|
204
|
-
&& previous.evidence === evidence
|
|
205
|
-
&& previous.nextAction === nextAction) {
|
|
206
|
-
pauseGoalAfterFailure(pi, runtime, ctx, "repeated continue report", "Run /goal resume only after choosing a different next action.", false);
|
|
207
|
-
ctx.ui.notify(
|
|
208
|
-
`Goal paused: repeated continue report on turns ${previous.turn} and ${state.turns}\nRun /goal resume only after choosing a different next action.`,
|
|
209
|
-
"error",
|
|
210
|
-
);
|
|
211
|
-
throw new Error("repeated continue report");
|
|
212
|
-
}
|
|
213
|
-
const decision = { kind: "continue" as const, turn: state.turns, evidence, nextAction };
|
|
214
|
-
try {
|
|
215
|
-
const next = recordGoalDecision(state, decision, Date.now());
|
|
216
|
-
persistGoalState(pi, runtime, "continue", next);
|
|
217
|
-
runtime.goalTurn = { turn: next.turns, revision: next.revision };
|
|
218
|
-
} catch (error) {
|
|
219
|
-
pauseGoalAfterFailure(pi, runtime, ctx, `goal decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
220
|
-
throw error;
|
|
221
|
-
}
|
|
222
|
-
return {
|
|
223
|
-
content: [{ type: "text", text: `Model-reported progress recorded; the goal remains active: ${evidence}` }],
|
|
224
|
-
details: { status: "continue", evidence, nextAction },
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const blockerKey = params.blockerKey;
|
|
229
|
-
if (!blockerKey || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(blockerKey)) {
|
|
230
|
-
throw new Error("A blocked goal update requires a stable lowercase blockerKey");
|
|
231
|
-
}
|
|
232
|
-
const previous = state.blockerAudit;
|
|
233
|
-
if (previous?.lastTurn === state.turns) {
|
|
234
|
-
throw new Error("Only one goal decision may be accepted per logical goal turn");
|
|
235
|
-
}
|
|
236
|
-
const consecutive = previous?.key === blockerKey && previous.lastTurn === state.turns - 1;
|
|
237
|
-
const streak = consecutive ? previous.streak + 1 : 1;
|
|
238
|
-
const blockerAudit = { key: blockerKey, streak, lastTurn: state.turns, evidence };
|
|
239
|
-
if (streak < 3) {
|
|
240
|
-
const decision = { kind: "blocker-audit" as const, turn: state.turns, blockerKey, streak, evidence };
|
|
241
|
-
try {
|
|
242
|
-
const next = {
|
|
243
|
-
...recordGoalDecision(state, decision, Date.now()),
|
|
244
|
-
blockerAudit,
|
|
245
|
-
};
|
|
246
|
-
persistGoalState(pi, runtime, "blocker-audit", next);
|
|
247
|
-
runtime.goalTurn = { turn: next.turns, revision: next.revision };
|
|
248
|
-
} catch (error) {
|
|
249
|
-
pauseGoalAfterFailure(pi, runtime, ctx, `blocker decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
250
|
-
throw error;
|
|
251
|
-
}
|
|
252
|
-
return {
|
|
253
|
-
content: [{ type: "text", text: `Blocker audit ${streak}/3 recorded; the goal remains active: ${evidence}` }],
|
|
254
|
-
details: { status: "blocker-audit", evidence, blockerKey, streak },
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
const decision = { kind: "blocked" as const, turn: state.turns, blockerKey, streak: 3 as const, evidence };
|
|
258
|
-
try {
|
|
259
|
-
transitionGoal(pi, runtime, "blocked", "blocked", evidence, { blockerAudit, decision });
|
|
260
|
-
} catch (error) {
|
|
261
|
-
pauseGoalAfterFailure(pi, runtime, ctx, `blocked decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
262
|
-
throw error;
|
|
263
|
-
}
|
|
264
|
-
return {
|
|
265
|
-
content: [{ type: "text", text: `Goal marked blocked: ${evidence}` }],
|
|
266
|
-
details: { status: "blocked", evidence, blockerKey, streak },
|
|
267
|
-
};
|
|
268
|
-
},
|
|
269
|
-
renderCall(args, theme) {
|
|
270
|
-
return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", safeTerminalText(args.status))}`, 0, 0);
|
|
271
|
-
},
|
|
272
|
-
renderResult(result, options, theme, context) {
|
|
273
|
-
if (context?.isError) {
|
|
274
|
-
const first = result.content[0];
|
|
275
|
-
const message = first?.type === "text" ? safeTerminalText(first.text) : "Goal update failed";
|
|
276
|
-
return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 1);
|
|
277
|
-
}
|
|
278
|
-
const details = result.details;
|
|
279
|
-
if (!details || typeof details.evidence !== "string") return new BoundedText(theme.fg("dim", "Goal updated"), options.expanded ? undefined : 1);
|
|
280
|
-
const label = details.status === "complete"
|
|
281
|
-
? "Goal completed"
|
|
282
|
-
: details.status === "blocked"
|
|
283
|
-
? "Goal blocked"
|
|
284
|
-
: details.status === "continue"
|
|
285
|
-
? "Progress recorded"
|
|
286
|
-
: `Blocker audit ${details.streak}/3`;
|
|
287
|
-
const color: ThemeColor = details.status === "complete"
|
|
288
|
-
? "success"
|
|
289
|
-
: details.status === "blocked"
|
|
290
|
-
? "error"
|
|
291
|
-
: details.status === "continue"
|
|
292
|
-
? "customMessageLabel"
|
|
293
|
-
: "warning";
|
|
294
|
-
const text = `${theme.fg(color, label)}${theme.fg("dim", " · ")}${safeTerminalText(details.evidence)}`;
|
|
295
|
-
return new BoundedText(text, options.expanded ? undefined : 1);
|
|
296
|
-
},
|
|
297
|
-
});
|
|
298
|
-
syncGoalUpdateTool(pi, runtime);
|
|
299
|
-
const handleGoalCommand = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
300
|
-
const command = parseGoalCommand(args);
|
|
301
|
-
if (ctx.mode === "print" || ctx.mode === "json") {
|
|
302
|
-
ctx.ui.notify("/goal requires TUI or RPC mode", "error");
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
if (command.kind === "invalid") {
|
|
306
|
-
ctx.ui.notify(command.message, "error");
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
if (!isSavedSession(ctx)) {
|
|
310
|
-
ctx.ui.notify("/goal requires a saved session", "error");
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
if (command.kind === "status") {
|
|
315
|
-
if (!runtime.state) {
|
|
316
|
-
ctx.ui.notify("No goal is set. Use /goal <objective> to start a long-running task.", "info");
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
if (ctx.mode !== "tui") {
|
|
320
|
-
ctx.ui.notify(goalStatusSummary(runtime.state, ctx), "info");
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
const actions = goalPanelActions(runtime.state.status);
|
|
324
|
-
const selected = await ctx.ui.select(goalStatusSummary(runtime.state, ctx), actions.map((action) => action.label));
|
|
325
|
-
const action = actions.find((candidate) => candidate.label === selected);
|
|
326
|
-
if (!action) return;
|
|
327
|
-
if (action.control === "clear" && !await ctx.ui.confirm("Clear goal?", safeTerminalText(runtime.state.objective))) return;
|
|
328
|
-
await handleGoalCommand(action.control, ctx);
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
if (command.kind === "clear") {
|
|
333
|
-
if (!runtime.state) {
|
|
334
|
-
ctx.ui.notify("No goal is set", "info");
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
const shouldStopGoalRun = runtime.goalTurnInFlight || runtime.continuationScheduled;
|
|
338
|
-
let saved = false;
|
|
339
|
-
try {
|
|
340
|
-
persistGoalState(pi, runtime, "clear", undefined);
|
|
341
|
-
saved = true;
|
|
342
|
-
} catch (error) {
|
|
343
|
-
if (runtime.state?.status === "active") {
|
|
344
|
-
pauseGoalAfterFailure(
|
|
345
|
-
pi,
|
|
346
|
-
runtime,
|
|
347
|
-
ctx,
|
|
348
|
-
`the requested clear could not be saved: ${error instanceof Error ? error.message : String(error)}`,
|
|
349
|
-
"Automatic continuation is stopped. Retry /goal clear to remove the goal.",
|
|
350
|
-
false,
|
|
351
|
-
);
|
|
352
|
-
} else {
|
|
353
|
-
reportError(ctx, "Goal could not be cleared", error);
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
try {
|
|
358
|
-
await stopGoalRun(runtime, ctx, shouldStopGoalRun);
|
|
359
|
-
} catch (error) {
|
|
360
|
-
reportError(ctx, saved ? "Goal cleared, but the active goal turn could not be confirmed stopped" : "Goal paused, but the active goal turn could not be confirmed stopped", error);
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
if (saved) {
|
|
364
|
-
if (ctx.mode !== "tui") ctx.ui.notify("Goal cleared", "info");
|
|
365
|
-
} else {
|
|
366
|
-
ctx.ui.notify("Goal paused: the requested clear could not be saved\nAutomatic continuation is stopped. Retry /goal clear to remove the goal.", "error");
|
|
367
|
-
}
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
if (command.kind === "pause") {
|
|
372
|
-
if (!runtime.state) {
|
|
373
|
-
ctx.ui.notify("No goal is set", "info");
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
if (runtime.state.status === "paused") {
|
|
377
|
-
if (!runtime.persistenceRetryNeeded
|
|
378
|
-
&& runtime.state.resumeAfterManualCompaction !== true
|
|
379
|
-
&& runtime.automaticCompaction === undefined) {
|
|
380
|
-
ctx.ui.notify("Goal is already paused", "info");
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
const checkpoint = checkpointPausedGoalState(runtime.state, Date.now());
|
|
384
|
-
try {
|
|
385
|
-
persistGoalState(pi, runtime, "pause", checkpoint);
|
|
386
|
-
ctx.ui.notify("Goal pause saved. Goal remains paused. Automatic compaction recovery is off.", "info");
|
|
387
|
-
} catch (error) {
|
|
388
|
-
runtime.state = checkpoint;
|
|
389
|
-
syncGoalUpdateTool(pi, runtime);
|
|
390
|
-
runtime.persistenceRetryNeeded = true;
|
|
391
|
-
runtime.continuationScheduled = false;
|
|
392
|
-
runtime.requestRender?.();
|
|
393
|
-
reportError(ctx, "Goal pause still could not be saved", error);
|
|
394
|
-
}
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
if (runtime.state.status !== "active") {
|
|
398
|
-
ctx.ui.notify(`Goal is ${runtime.state.status}; only an active goal can be paused`, "warning");
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
const shouldStopGoalRun = runtime.goalTurnInFlight || runtime.continuationScheduled;
|
|
402
|
-
let saved = false;
|
|
403
|
-
let failureReason: string | undefined;
|
|
404
|
-
try {
|
|
405
|
-
transitionGoal(pi, runtime, "pause", "paused");
|
|
406
|
-
saved = true;
|
|
407
|
-
} catch (error) {
|
|
408
|
-
failureReason = safeTerminalText(`the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
409
|
-
pauseGoalAfterFailure(
|
|
410
|
-
pi,
|
|
411
|
-
runtime,
|
|
412
|
-
ctx,
|
|
413
|
-
failureReason,
|
|
414
|
-
"Automatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.",
|
|
415
|
-
false,
|
|
416
|
-
);
|
|
417
|
-
}
|
|
418
|
-
try {
|
|
419
|
-
await stopGoalRun(runtime, ctx, shouldStopGoalRun);
|
|
420
|
-
} catch (error) {
|
|
421
|
-
reportError(ctx, "Goal paused, but the active goal turn could not be confirmed stopped", error);
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
if (saved) {
|
|
425
|
-
if (ctx.mode !== "tui") ctx.ui.notify("Goal paused. Run /goal resume to continue.", "info");
|
|
426
|
-
} else {
|
|
427
|
-
ctx.ui.notify(`Goal paused: ${failureReason}\nAutomatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.`, "error");
|
|
428
|
-
}
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if (command.kind === "resume") {
|
|
433
|
-
if (!runtime.state) {
|
|
434
|
-
ctx.ui.notify("No goal is set", "info");
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
if (runtime.state.status === "complete") {
|
|
438
|
-
ctx.ui.notify("The goal is complete. Set a new objective.", "info");
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
if (runtime.state.status === "active") {
|
|
442
|
-
ctx.ui.notify("Goal is already active", "info");
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
const currentMax = runtime.state.maxTurns;
|
|
446
|
-
if (currentMax !== undefined && runtime.state.turns >= currentMax) {
|
|
447
|
-
if (runtime.state.turns >= GOAL_MAX_TURNS) {
|
|
448
|
-
ctx.ui.notify(`Goal reached the lifetime limit (${runtime.state.turns}/${GOAL_MAX_TURNS}). Set a new objective.`, "warning");
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
const renewed = Math.min(Math.max(currentMax, runtime.state.turns) + DEFAULT_GOAL_MAX_TURNS, GOAL_MAX_TURNS);
|
|
452
|
-
try {
|
|
453
|
-
const base = transitionGoalState(runtime.state, "active", undefined, { resetBlockedAudit: true }, Date.now());
|
|
454
|
-
persistGoalState(pi, runtime, "resume", { ...base, maxTurns: renewed });
|
|
455
|
-
runtime.continuationScheduled = false;
|
|
456
|
-
if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") ctx.ui.notify("Goal resumed", "info");
|
|
457
|
-
} catch (error) {
|
|
458
|
-
reportError(ctx, "Goal could not be resumed", error);
|
|
459
|
-
}
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
try {
|
|
463
|
-
transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
|
|
464
|
-
runtime.continuationScheduled = false;
|
|
465
|
-
if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") ctx.ui.notify("Goal resumed", "info");
|
|
466
|
-
} catch (error) {
|
|
467
|
-
reportError(ctx, "Goal could not be resumed", error);
|
|
468
|
-
}
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
switch (command.kind) {
|
|
473
|
-
case "objective":
|
|
474
|
-
break;
|
|
475
|
-
default: {
|
|
476
|
-
const unhandled: never = command;
|
|
477
|
-
return unhandled;
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
const objective = command.objective;
|
|
481
|
-
|
|
482
|
-
const unfinished = runtime.state && runtime.state.status !== "complete";
|
|
483
|
-
if (unfinished) {
|
|
484
|
-
if (!ctx.hasUI) {
|
|
485
|
-
ctx.ui.notify("Clear the current goal before replacing it outside TUI mode", "error");
|
|
486
|
-
return;
|
|
487
|
-
}
|
|
488
|
-
const replace = await ctx.ui.confirm("Replace active goal", "Replace the current unfinished goal and discard its continuation state?");
|
|
489
|
-
if (!replace) return;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
runtime.continuationHeld = true;
|
|
493
|
-
let waitError: unknown;
|
|
494
|
-
try {
|
|
495
|
-
await ctx.waitForIdle();
|
|
496
|
-
} catch (error) {
|
|
497
|
-
waitError = error;
|
|
498
|
-
} finally {
|
|
499
|
-
runtime.continuationHeld = false;
|
|
500
|
-
}
|
|
501
|
-
if (waitError) {
|
|
502
|
-
reportError(ctx, "Goal could not wait for the active turn", waitError);
|
|
503
|
-
scheduleGoalContinuation(pi, runtime, ctx);
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
let verification: Awaited<ReturnType<typeof inferGoalVerification>>;
|
|
507
|
-
try {
|
|
508
|
-
verification = await inferGoalVerification(objective, ctx.cwd);
|
|
509
|
-
} catch (error) {
|
|
510
|
-
if (!unfinished) {
|
|
511
|
-
reportError(ctx, "Goal could not be started", error);
|
|
512
|
-
} else {
|
|
513
|
-
reportError(ctx, "Goal could not be replaced", error);
|
|
514
|
-
scheduleGoalContinuation(pi, runtime, ctx);
|
|
515
|
-
}
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
|
-
try {
|
|
519
|
-
const state = createNewGoalState(objective, sumGoalTokens(ctx), verification, Date.now(), {
|
|
520
|
-
maxTurns: DEFAULT_GOAL_MAX_TURNS,
|
|
521
|
-
});
|
|
522
|
-
persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
|
|
523
|
-
// The replacement supersedes any recovery still parked after waitForIdle().
|
|
524
|
-
if (unfinished) runtime.automaticCompaction = undefined;
|
|
525
|
-
if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") {
|
|
526
|
-
ctx.ui.notify(`${unfinished ? "Goal replaced" : "Goal started"}. Each turn must record continue, complete, or a blocker decision before another turn starts.`, "info");
|
|
527
|
-
}
|
|
528
|
-
} catch (error) {
|
|
529
|
-
if (!unfinished) {
|
|
530
|
-
reportError(ctx, "Goal could not be started", error);
|
|
531
|
-
} else if (runtime.state?.status === "active") {
|
|
532
|
-
pauseGoalAfterFailure(
|
|
533
|
-
pi,
|
|
534
|
-
runtime,
|
|
535
|
-
ctx,
|
|
536
|
-
`Goal could not be replaced: ${error instanceof Error ? error.message : String(error)}`,
|
|
537
|
-
"Automatic continuation is stopped. Retry replacement after session storage recovers.",
|
|
538
|
-
);
|
|
539
|
-
} else {
|
|
540
|
-
reportError(ctx, "Goal could not be replaced", error);
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
};
|
|
544
|
-
|
|
545
|
-
pi.registerCommand("goal", {
|
|
546
|
-
description: "Set a non-command objective or view the current goal",
|
|
547
|
-
getArgumentCompletions: (prefix) => {
|
|
548
|
-
const normalized = prefix.trimStart().toLowerCase();
|
|
549
|
-
const actions = [
|
|
550
|
-
{ value: "clear", description: "Remove the current goal" },
|
|
551
|
-
{ value: "pause", description: "Stop automatic continuation" },
|
|
552
|
-
{ value: "resume", description: "Resume automatic continuation" },
|
|
553
|
-
];
|
|
554
|
-
return actions
|
|
555
|
-
.filter((action) => action.value.startsWith(normalized))
|
|
556
|
-
.map((action) => ({ ...action, label: action.value.trimEnd() }));
|
|
557
|
-
},
|
|
558
|
-
handler: handleGoalCommand,
|
|
559
|
-
});
|
|
560
|
-
}
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { BoundedText } from "./bounded-text.ts";
|
|
6
|
+
import { formatTime, formatTokens } from "./display.ts";
|
|
7
|
+
import { reportError } from "./errors.ts";
|
|
8
|
+
import { parseGoalCommand } from "./goal-command.ts";
|
|
9
|
+
import { GOAL_ENTRY_TYPE, GOAL_UPDATE_TOOL, isGoalModeSupported, isSavedSession, pauseGoalAfterFailure, persistGoalState, scheduleGoalContinuation, stopGoalRun, sumGoalTokens, syncGoalUpdateTool, transitionGoal, type GoalEntryData } from "./goal-runtime.ts";
|
|
10
|
+
import { checkpointPausedGoalState, createNewGoalState, DEFAULT_GOAL_MAX_TURNS, GOAL_EVIDENCE_LIMIT, GOAL_MAX_TURNS, goalElapsedMilliseconds, GOAL_VERSION, inferGoalVerification, normalizeGoalText, parseGoalState, recordGoalDecision, transitionGoalState, verifyGoalDeliverable } from "./goal-state.ts";
|
|
11
|
+
import type { GoalRuntime, GoalState, GoalStatus } from "./runtime.ts";
|
|
12
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
13
|
+
|
|
14
|
+
const GoalUpdateParams = Type.Object({
|
|
15
|
+
status: StringEnum(["complete", "continue", "blocked"] as const, {
|
|
16
|
+
description: "Record exactly one active-goal decision: complete, continue, or blocked",
|
|
17
|
+
}),
|
|
18
|
+
evidence: Type.String({
|
|
19
|
+
minLength: 1,
|
|
20
|
+
maxLength: GOAL_EVIDENCE_LIMIT,
|
|
21
|
+
description: "Concrete current-turn evidence for completion, progress, or the repeated blocker",
|
|
22
|
+
}),
|
|
23
|
+
nextAction: Type.Optional(Type.String({
|
|
24
|
+
minLength: 1,
|
|
25
|
+
maxLength: GOAL_EVIDENCE_LIMIT,
|
|
26
|
+
description: "One concrete action toward the unchanged objective when status is continue",
|
|
27
|
+
})),
|
|
28
|
+
blockerKey: Type.Optional(Type.String({
|
|
29
|
+
minLength: 1,
|
|
30
|
+
maxLength: 120,
|
|
31
|
+
pattern: "^[a-z0-9][a-z0-9._-]{0,119}$",
|
|
32
|
+
description: "Stable lowercase key identifying the repeated blocker",
|
|
33
|
+
})),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
interface GoalUpdateDetails {
|
|
37
|
+
status: "complete" | "continue" | "blocked" | "blocker-audit";
|
|
38
|
+
evidence: string;
|
|
39
|
+
nextAction?: string;
|
|
40
|
+
verification?: "file" | "model-reported";
|
|
41
|
+
blockerKey?: string;
|
|
42
|
+
streak?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function goalPanelActions(status: GoalStatus): Array<{ label: string; control: "pause" | "resume" | "clear" }> {
|
|
46
|
+
if (status === "active") return [{ label: "Pause goal", control: "pause" }, { label: "Clear goal", control: "clear" }];
|
|
47
|
+
if (status === "paused" || status === "blocked") {
|
|
48
|
+
return [{ label: "Resume goal", control: "resume" }, { label: "Clear goal", control: "clear" }];
|
|
49
|
+
}
|
|
50
|
+
return [{ label: "Clear goal", control: "clear" }];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
|
|
54
|
+
const usedTokens = Math.max(0, sumGoalTokens(ctx) - state.baselineTokens);
|
|
55
|
+
const turns = state.maxTurns === undefined
|
|
56
|
+
? `${state.turns} turn${state.turns === 1 ? "" : "s"}`
|
|
57
|
+
: `${state.turns}/${state.maxTurns} turns`;
|
|
58
|
+
const lines = [
|
|
59
|
+
`Goal ${state.status} · ${turns} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))} · ${formatTokens(usedTokens)} tokens`,
|
|
60
|
+
state.objective,
|
|
61
|
+
...(state.verification === undefined ? [] : [`Deliverable · ${state.verification.path}`]),
|
|
62
|
+
];
|
|
63
|
+
const decision = state.turnDecision ?? state.lastDecision;
|
|
64
|
+
if (decision?.kind === "continue") {
|
|
65
|
+
lines.push(`Progress · model-reported on turn ${decision.turn}`);
|
|
66
|
+
lines.push(`Next · ${decision.nextAction}`);
|
|
67
|
+
} else if (decision?.kind === "blocker-audit") {
|
|
68
|
+
lines.push(`Blocker audit · ${decision.streak}/3 on turn ${decision.turn}`);
|
|
69
|
+
lines.push(`Evidence · ${decision.evidence}`);
|
|
70
|
+
} else if (decision?.kind === "blocked") {
|
|
71
|
+
lines.push(`Evidence · ${decision.evidence}`);
|
|
72
|
+
} else if (decision?.kind === "complete") {
|
|
73
|
+
lines.push(`Completion · ${decision.verification === "file" ? `verified file ${state.verification?.path ?? "deliverable"}` : "model-reported"}`);
|
|
74
|
+
lines.push(`Evidence · ${decision.evidence}`);
|
|
75
|
+
}
|
|
76
|
+
if (state.status === "paused") {
|
|
77
|
+
lines.push(state.turns === 0 ? "Paused at · before turn 1" : `Paused at · turn ${state.turns}`);
|
|
78
|
+
const reason = state.stopReason ?? state.result;
|
|
79
|
+
if (reason) lines.push(`Reason · ${reason}`);
|
|
80
|
+
if (state.stopReason === "repeated continue report" && state.lastContinueReport) {
|
|
81
|
+
lines.push(`Repeated turns · ${state.lastContinueReport.turn} and ${state.turns}`);
|
|
82
|
+
}
|
|
83
|
+
} else if (state.result && decision?.kind !== "complete" && decision?.kind !== "blocked") {
|
|
84
|
+
lines.push(`Result · ${state.result}`);
|
|
85
|
+
}
|
|
86
|
+
return safeTerminalText(lines.join("\n"));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function registerGoalInterface(
|
|
90
|
+
pi: ExtensionAPI,
|
|
91
|
+
runtime: GoalRuntime,
|
|
92
|
+
): void {
|
|
93
|
+
pi.registerEntryRenderer<GoalEntryData>(GOAL_ENTRY_TYPE, (entry, options, theme) => {
|
|
94
|
+
const data = entry.data;
|
|
95
|
+
if (!data || data.version !== GOAL_VERSION) return undefined;
|
|
96
|
+
if (data.event === "clear" || data.state === null) return new Text(theme.fg("dim", "Goal cleared"), 0, 0);
|
|
97
|
+
const event = data.event;
|
|
98
|
+
if (event !== "set" && event !== "replace" && event !== "pause" && event !== "resume" && event !== "limit" && event !== "error") return undefined;
|
|
99
|
+
const state = parseGoalState(data.state);
|
|
100
|
+
if (!state) return undefined;
|
|
101
|
+
const reason = state.stopReason ?? state.result;
|
|
102
|
+
const objective = safeTerminalText(state.objective);
|
|
103
|
+
let label: string;
|
|
104
|
+
let detail: string;
|
|
105
|
+
let color: ThemeColor;
|
|
106
|
+
switch (event) {
|
|
107
|
+
case "set":
|
|
108
|
+
label = "Goal started";
|
|
109
|
+
detail = objective;
|
|
110
|
+
color = "customMessageLabel";
|
|
111
|
+
break;
|
|
112
|
+
case "replace":
|
|
113
|
+
label = "Goal replaced";
|
|
114
|
+
detail = objective;
|
|
115
|
+
color = "customMessageLabel";
|
|
116
|
+
break;
|
|
117
|
+
case "resume":
|
|
118
|
+
label = "Goal resumed";
|
|
119
|
+
detail = objective;
|
|
120
|
+
color = "customMessageLabel";
|
|
121
|
+
break;
|
|
122
|
+
case "pause":
|
|
123
|
+
label = "Goal paused";
|
|
124
|
+
detail = reason ?? objective;
|
|
125
|
+
color = "warning";
|
|
126
|
+
break;
|
|
127
|
+
case "limit":
|
|
128
|
+
label = "Goal paused";
|
|
129
|
+
detail = reason ?? "";
|
|
130
|
+
color = "warning";
|
|
131
|
+
break;
|
|
132
|
+
case "error":
|
|
133
|
+
label = "Goal paused";
|
|
134
|
+
detail = reason ?? "";
|
|
135
|
+
color = "error";
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
const safeDetail = safeTerminalText(detail);
|
|
139
|
+
const status = theme.fg(color, label);
|
|
140
|
+
if (!options.expanded) {
|
|
141
|
+
return new BoundedText(safeDetail ? `${status}${theme.fg("dim", " · ")}${safeDetail}` : status, 1);
|
|
142
|
+
}
|
|
143
|
+
const lines = [status, objective];
|
|
144
|
+
if (reason && reason !== objective) lines.push(theme.fg("muted", safeTerminalText(reason)));
|
|
145
|
+
return new BoundedText(lines.join("\n"));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
|
|
149
|
+
name: GOAL_UPDATE_TOOL,
|
|
150
|
+
label: "Goal update",
|
|
151
|
+
description: "Record exactly one active-goal decision: complete after verification, continue with evidence and one next action, or audit the same blocker before blocking it.",
|
|
152
|
+
parameters: GoalUpdateParams,
|
|
153
|
+
executionMode: "sequential",
|
|
154
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
155
|
+
signal?.throwIfAborted();
|
|
156
|
+
if (!isGoalModeSupported(ctx)) throw new Error("KillerOS goals require TUI or RPC mode");
|
|
157
|
+
if (!isSavedSession(ctx)) throw new Error("KillerOS goals require a saved session");
|
|
158
|
+
const state = runtime.state;
|
|
159
|
+
if (!state || state.status !== "active") throw new Error("There is no active KillerOS goal to update");
|
|
160
|
+
if (!runtime.goalTurnInFlight
|
|
161
|
+
|| runtime.goalTurn?.turn !== state.turns
|
|
162
|
+
|| runtime.goalTurn.revision !== state.revision) {
|
|
163
|
+
throw new Error("A goal decision can only be recorded during an active KillerOS goal turn");
|
|
164
|
+
}
|
|
165
|
+
if (state.turnDecision !== undefined) {
|
|
166
|
+
throw new Error("Only one goal decision may be accepted per logical goal turn");
|
|
167
|
+
}
|
|
168
|
+
if (params.status !== "complete" && params.status !== "continue" && params.status !== "blocked") {
|
|
169
|
+
throw new Error("Goal update status is invalid");
|
|
170
|
+
}
|
|
171
|
+
if (typeof params.evidence !== "string") throw new Error("Goal evidence must be text");
|
|
172
|
+
const evidence = normalizeGoalText(params.evidence, GOAL_EVIDENCE_LIMIT, "Goal evidence");
|
|
173
|
+
|
|
174
|
+
if (params.status === "complete") {
|
|
175
|
+
if (state.verification) await verifyGoalDeliverable(state.verification);
|
|
176
|
+
signal?.throwIfAborted();
|
|
177
|
+
if (runtime.state !== state
|
|
178
|
+
|| runtime.goalTurn?.turn !== state.turns
|
|
179
|
+
|| runtime.goalTurn.revision !== state.revision
|
|
180
|
+
|| state.turnDecision !== undefined
|
|
181
|
+
|| !runtime.goalTurnInFlight) throw new Error("Goal changed while completion was being verified");
|
|
182
|
+
const verification: "file" | "model-reported" = state.verification ? "file" : "model-reported";
|
|
183
|
+
const decision = { kind: "complete" as const, turn: state.turns, evidence, verification };
|
|
184
|
+
try {
|
|
185
|
+
transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true, decision });
|
|
186
|
+
} catch (error) {
|
|
187
|
+
pauseGoalAfterFailure(pi, runtime, ctx, `goal completion could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
const text = state.verification
|
|
191
|
+
? `Goal verified complete at ${safeTerminalText(state.verification.path)}: ${evidence}`
|
|
192
|
+
: `Goal marked complete (model-reported): ${evidence}`;
|
|
193
|
+
return {
|
|
194
|
+
content: [{ type: "text", text }],
|
|
195
|
+
details: { status: "complete", evidence, verification },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (params.status === "continue") {
|
|
200
|
+
if (typeof params.nextAction !== "string") throw new Error("A continue decision requires a nextAction");
|
|
201
|
+
const nextAction = normalizeGoalText(params.nextAction, GOAL_EVIDENCE_LIMIT, "Goal nextAction");
|
|
202
|
+
const previous = state.lastContinueReport;
|
|
203
|
+
if (previous?.turn === state.turns - 1
|
|
204
|
+
&& previous.evidence === evidence
|
|
205
|
+
&& previous.nextAction === nextAction) {
|
|
206
|
+
pauseGoalAfterFailure(pi, runtime, ctx, "repeated continue report", "Run /goal resume only after choosing a different next action.", false);
|
|
207
|
+
ctx.ui.notify(
|
|
208
|
+
`Goal paused: repeated continue report on turns ${previous.turn} and ${state.turns}\nRun /goal resume only after choosing a different next action.`,
|
|
209
|
+
"error",
|
|
210
|
+
);
|
|
211
|
+
throw new Error("repeated continue report");
|
|
212
|
+
}
|
|
213
|
+
const decision = { kind: "continue" as const, turn: state.turns, evidence, nextAction };
|
|
214
|
+
try {
|
|
215
|
+
const next = recordGoalDecision(state, decision, Date.now());
|
|
216
|
+
persistGoalState(pi, runtime, "continue", next);
|
|
217
|
+
runtime.goalTurn = { turn: next.turns, revision: next.revision };
|
|
218
|
+
} catch (error) {
|
|
219
|
+
pauseGoalAfterFailure(pi, runtime, ctx, `goal decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
content: [{ type: "text", text: `Model-reported progress recorded; the goal remains active: ${evidence}` }],
|
|
224
|
+
details: { status: "continue", evidence, nextAction },
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const blockerKey = params.blockerKey;
|
|
229
|
+
if (!blockerKey || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(blockerKey)) {
|
|
230
|
+
throw new Error("A blocked goal update requires a stable lowercase blockerKey");
|
|
231
|
+
}
|
|
232
|
+
const previous = state.blockerAudit;
|
|
233
|
+
if (previous?.lastTurn === state.turns) {
|
|
234
|
+
throw new Error("Only one goal decision may be accepted per logical goal turn");
|
|
235
|
+
}
|
|
236
|
+
const consecutive = previous?.key === blockerKey && previous.lastTurn === state.turns - 1;
|
|
237
|
+
const streak = consecutive ? previous.streak + 1 : 1;
|
|
238
|
+
const blockerAudit = { key: blockerKey, streak, lastTurn: state.turns, evidence };
|
|
239
|
+
if (streak < 3) {
|
|
240
|
+
const decision = { kind: "blocker-audit" as const, turn: state.turns, blockerKey, streak, evidence };
|
|
241
|
+
try {
|
|
242
|
+
const next = {
|
|
243
|
+
...recordGoalDecision(state, decision, Date.now()),
|
|
244
|
+
blockerAudit,
|
|
245
|
+
};
|
|
246
|
+
persistGoalState(pi, runtime, "blocker-audit", next);
|
|
247
|
+
runtime.goalTurn = { turn: next.turns, revision: next.revision };
|
|
248
|
+
} catch (error) {
|
|
249
|
+
pauseGoalAfterFailure(pi, runtime, ctx, `blocker decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
content: [{ type: "text", text: `Blocker audit ${streak}/3 recorded; the goal remains active: ${evidence}` }],
|
|
254
|
+
details: { status: "blocker-audit", evidence, blockerKey, streak },
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const decision = { kind: "blocked" as const, turn: state.turns, blockerKey, streak: 3 as const, evidence };
|
|
258
|
+
try {
|
|
259
|
+
transitionGoal(pi, runtime, "blocked", "blocked", evidence, { blockerAudit, decision });
|
|
260
|
+
} catch (error) {
|
|
261
|
+
pauseGoalAfterFailure(pi, runtime, ctx, `blocked decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
content: [{ type: "text", text: `Goal marked blocked: ${evidence}` }],
|
|
266
|
+
details: { status: "blocked", evidence, blockerKey, streak },
|
|
267
|
+
};
|
|
268
|
+
},
|
|
269
|
+
renderCall(args, theme) {
|
|
270
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", safeTerminalText(args.status))}`, 0, 0);
|
|
271
|
+
},
|
|
272
|
+
renderResult(result, options, theme, context) {
|
|
273
|
+
if (context?.isError) {
|
|
274
|
+
const first = result.content[0];
|
|
275
|
+
const message = first?.type === "text" ? safeTerminalText(first.text) : "Goal update failed";
|
|
276
|
+
return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 1);
|
|
277
|
+
}
|
|
278
|
+
const details = result.details;
|
|
279
|
+
if (!details || typeof details.evidence !== "string") return new BoundedText(theme.fg("dim", "Goal updated"), options.expanded ? undefined : 1);
|
|
280
|
+
const label = details.status === "complete"
|
|
281
|
+
? "Goal completed"
|
|
282
|
+
: details.status === "blocked"
|
|
283
|
+
? "Goal blocked"
|
|
284
|
+
: details.status === "continue"
|
|
285
|
+
? "Progress recorded"
|
|
286
|
+
: `Blocker audit ${details.streak}/3`;
|
|
287
|
+
const color: ThemeColor = details.status === "complete"
|
|
288
|
+
? "success"
|
|
289
|
+
: details.status === "blocked"
|
|
290
|
+
? "error"
|
|
291
|
+
: details.status === "continue"
|
|
292
|
+
? "customMessageLabel"
|
|
293
|
+
: "warning";
|
|
294
|
+
const text = `${theme.fg(color, label)}${theme.fg("dim", " · ")}${safeTerminalText(details.evidence)}`;
|
|
295
|
+
return new BoundedText(text, options.expanded ? undefined : 1);
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
syncGoalUpdateTool(pi, runtime);
|
|
299
|
+
const handleGoalCommand = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
300
|
+
const command = parseGoalCommand(args);
|
|
301
|
+
if (ctx.mode === "print" || ctx.mode === "json") {
|
|
302
|
+
ctx.ui.notify("/goal requires TUI or RPC mode", "error");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (command.kind === "invalid") {
|
|
306
|
+
ctx.ui.notify(command.message, "error");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!isSavedSession(ctx)) {
|
|
310
|
+
ctx.ui.notify("/goal requires a saved session", "error");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (command.kind === "status") {
|
|
315
|
+
if (!runtime.state) {
|
|
316
|
+
ctx.ui.notify("No goal is set. Use /goal <objective> to start a long-running task.", "info");
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (ctx.mode !== "tui") {
|
|
320
|
+
ctx.ui.notify(goalStatusSummary(runtime.state, ctx), "info");
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const actions = goalPanelActions(runtime.state.status);
|
|
324
|
+
const selected = await ctx.ui.select(goalStatusSummary(runtime.state, ctx), actions.map((action) => action.label));
|
|
325
|
+
const action = actions.find((candidate) => candidate.label === selected);
|
|
326
|
+
if (!action) return;
|
|
327
|
+
if (action.control === "clear" && !await ctx.ui.confirm("Clear goal?", safeTerminalText(runtime.state.objective))) return;
|
|
328
|
+
await handleGoalCommand(action.control, ctx);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (command.kind === "clear") {
|
|
333
|
+
if (!runtime.state) {
|
|
334
|
+
ctx.ui.notify("No goal is set", "info");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const shouldStopGoalRun = runtime.goalTurnInFlight || runtime.continuationScheduled;
|
|
338
|
+
let saved = false;
|
|
339
|
+
try {
|
|
340
|
+
persistGoalState(pi, runtime, "clear", undefined);
|
|
341
|
+
saved = true;
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (runtime.state?.status === "active") {
|
|
344
|
+
pauseGoalAfterFailure(
|
|
345
|
+
pi,
|
|
346
|
+
runtime,
|
|
347
|
+
ctx,
|
|
348
|
+
`the requested clear could not be saved: ${error instanceof Error ? error.message : String(error)}`,
|
|
349
|
+
"Automatic continuation is stopped. Retry /goal clear to remove the goal.",
|
|
350
|
+
false,
|
|
351
|
+
);
|
|
352
|
+
} else {
|
|
353
|
+
reportError(ctx, "Goal could not be cleared", error);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
await stopGoalRun(runtime, ctx, shouldStopGoalRun);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
reportError(ctx, saved ? "Goal cleared, but the active goal turn could not be confirmed stopped" : "Goal paused, but the active goal turn could not be confirmed stopped", error);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (saved) {
|
|
364
|
+
if (ctx.mode !== "tui") ctx.ui.notify("Goal cleared", "info");
|
|
365
|
+
} else {
|
|
366
|
+
ctx.ui.notify("Goal paused: the requested clear could not be saved\nAutomatic continuation is stopped. Retry /goal clear to remove the goal.", "error");
|
|
367
|
+
}
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (command.kind === "pause") {
|
|
372
|
+
if (!runtime.state) {
|
|
373
|
+
ctx.ui.notify("No goal is set", "info");
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (runtime.state.status === "paused") {
|
|
377
|
+
if (!runtime.persistenceRetryNeeded
|
|
378
|
+
&& runtime.state.resumeAfterManualCompaction !== true
|
|
379
|
+
&& runtime.automaticCompaction === undefined) {
|
|
380
|
+
ctx.ui.notify("Goal is already paused", "info");
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const checkpoint = checkpointPausedGoalState(runtime.state, Date.now());
|
|
384
|
+
try {
|
|
385
|
+
persistGoalState(pi, runtime, "pause", checkpoint);
|
|
386
|
+
ctx.ui.notify("Goal pause saved. Goal remains paused. Automatic compaction recovery is off.", "info");
|
|
387
|
+
} catch (error) {
|
|
388
|
+
runtime.state = checkpoint;
|
|
389
|
+
syncGoalUpdateTool(pi, runtime);
|
|
390
|
+
runtime.persistenceRetryNeeded = true;
|
|
391
|
+
runtime.continuationScheduled = false;
|
|
392
|
+
runtime.requestRender?.();
|
|
393
|
+
reportError(ctx, "Goal pause still could not be saved", error);
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (runtime.state.status !== "active") {
|
|
398
|
+
ctx.ui.notify(`Goal is ${runtime.state.status}; only an active goal can be paused`, "warning");
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const shouldStopGoalRun = runtime.goalTurnInFlight || runtime.continuationScheduled;
|
|
402
|
+
let saved = false;
|
|
403
|
+
let failureReason: string | undefined;
|
|
404
|
+
try {
|
|
405
|
+
transitionGoal(pi, runtime, "pause", "paused");
|
|
406
|
+
saved = true;
|
|
407
|
+
} catch (error) {
|
|
408
|
+
failureReason = safeTerminalText(`the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
409
|
+
pauseGoalAfterFailure(
|
|
410
|
+
pi,
|
|
411
|
+
runtime,
|
|
412
|
+
ctx,
|
|
413
|
+
failureReason,
|
|
414
|
+
"Automatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.",
|
|
415
|
+
false,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
await stopGoalRun(runtime, ctx, shouldStopGoalRun);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
reportError(ctx, "Goal paused, but the active goal turn could not be confirmed stopped", error);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (saved) {
|
|
425
|
+
if (ctx.mode !== "tui") ctx.ui.notify("Goal paused. Run /goal resume to continue.", "info");
|
|
426
|
+
} else {
|
|
427
|
+
ctx.ui.notify(`Goal paused: ${failureReason}\nAutomatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.`, "error");
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (command.kind === "resume") {
|
|
433
|
+
if (!runtime.state) {
|
|
434
|
+
ctx.ui.notify("No goal is set", "info");
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (runtime.state.status === "complete") {
|
|
438
|
+
ctx.ui.notify("The goal is complete. Set a new objective.", "info");
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (runtime.state.status === "active") {
|
|
442
|
+
ctx.ui.notify("Goal is already active", "info");
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const currentMax = runtime.state.maxTurns;
|
|
446
|
+
if (currentMax !== undefined && runtime.state.turns >= currentMax) {
|
|
447
|
+
if (runtime.state.turns >= GOAL_MAX_TURNS) {
|
|
448
|
+
ctx.ui.notify(`Goal reached the lifetime limit (${runtime.state.turns}/${GOAL_MAX_TURNS}). Set a new objective.`, "warning");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const renewed = Math.min(Math.max(currentMax, runtime.state.turns) + DEFAULT_GOAL_MAX_TURNS, GOAL_MAX_TURNS);
|
|
452
|
+
try {
|
|
453
|
+
const base = transitionGoalState(runtime.state, "active", undefined, { resetBlockedAudit: true }, Date.now());
|
|
454
|
+
persistGoalState(pi, runtime, "resume", { ...base, maxTurns: renewed });
|
|
455
|
+
runtime.continuationScheduled = false;
|
|
456
|
+
if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") ctx.ui.notify("Goal resumed", "info");
|
|
457
|
+
} catch (error) {
|
|
458
|
+
reportError(ctx, "Goal could not be resumed", error);
|
|
459
|
+
}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
|
|
464
|
+
runtime.continuationScheduled = false;
|
|
465
|
+
if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") ctx.ui.notify("Goal resumed", "info");
|
|
466
|
+
} catch (error) {
|
|
467
|
+
reportError(ctx, "Goal could not be resumed", error);
|
|
468
|
+
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
switch (command.kind) {
|
|
473
|
+
case "objective":
|
|
474
|
+
break;
|
|
475
|
+
default: {
|
|
476
|
+
const unhandled: never = command;
|
|
477
|
+
return unhandled;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const objective = command.objective;
|
|
481
|
+
|
|
482
|
+
const unfinished = runtime.state && runtime.state.status !== "complete";
|
|
483
|
+
if (unfinished) {
|
|
484
|
+
if (!ctx.hasUI) {
|
|
485
|
+
ctx.ui.notify("Clear the current goal before replacing it outside TUI mode", "error");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const replace = await ctx.ui.confirm("Replace active goal", "Replace the current unfinished goal and discard its continuation state?");
|
|
489
|
+
if (!replace) return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
runtime.continuationHeld = true;
|
|
493
|
+
let waitError: unknown;
|
|
494
|
+
try {
|
|
495
|
+
await ctx.waitForIdle();
|
|
496
|
+
} catch (error) {
|
|
497
|
+
waitError = error;
|
|
498
|
+
} finally {
|
|
499
|
+
runtime.continuationHeld = false;
|
|
500
|
+
}
|
|
501
|
+
if (waitError) {
|
|
502
|
+
reportError(ctx, "Goal could not wait for the active turn", waitError);
|
|
503
|
+
scheduleGoalContinuation(pi, runtime, ctx);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
let verification: Awaited<ReturnType<typeof inferGoalVerification>>;
|
|
507
|
+
try {
|
|
508
|
+
verification = await inferGoalVerification(objective, ctx.cwd);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
if (!unfinished) {
|
|
511
|
+
reportError(ctx, "Goal could not be started", error);
|
|
512
|
+
} else {
|
|
513
|
+
reportError(ctx, "Goal could not be replaced", error);
|
|
514
|
+
scheduleGoalContinuation(pi, runtime, ctx);
|
|
515
|
+
}
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
const state = createNewGoalState(objective, sumGoalTokens(ctx), verification, Date.now(), {
|
|
520
|
+
maxTurns: DEFAULT_GOAL_MAX_TURNS,
|
|
521
|
+
});
|
|
522
|
+
persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
|
|
523
|
+
// The replacement supersedes any recovery still parked after waitForIdle().
|
|
524
|
+
if (unfinished) runtime.automaticCompaction = undefined;
|
|
525
|
+
if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") {
|
|
526
|
+
ctx.ui.notify(`${unfinished ? "Goal replaced" : "Goal started"}. Each turn must record continue, complete, or a blocker decision before another turn starts.`, "info");
|
|
527
|
+
}
|
|
528
|
+
} catch (error) {
|
|
529
|
+
if (!unfinished) {
|
|
530
|
+
reportError(ctx, "Goal could not be started", error);
|
|
531
|
+
} else if (runtime.state?.status === "active") {
|
|
532
|
+
pauseGoalAfterFailure(
|
|
533
|
+
pi,
|
|
534
|
+
runtime,
|
|
535
|
+
ctx,
|
|
536
|
+
`Goal could not be replaced: ${error instanceof Error ? error.message : String(error)}`,
|
|
537
|
+
"Automatic continuation is stopped. Retry replacement after session storage recovers.",
|
|
538
|
+
);
|
|
539
|
+
} else {
|
|
540
|
+
reportError(ctx, "Goal could not be replaced", error);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
pi.registerCommand("goal", {
|
|
546
|
+
description: "Set a non-command objective or view the current goal",
|
|
547
|
+
getArgumentCompletions: (prefix) => {
|
|
548
|
+
const normalized = prefix.trimStart().toLowerCase();
|
|
549
|
+
const actions = [
|
|
550
|
+
{ value: "clear", description: "Remove the current goal" },
|
|
551
|
+
{ value: "pause", description: "Stop automatic continuation" },
|
|
552
|
+
{ value: "resume", description: "Resume automatic continuation" },
|
|
553
|
+
];
|
|
554
|
+
return actions
|
|
555
|
+
.filter((action) => action.value.startsWith(normalized))
|
|
556
|
+
.map((action) => ({ ...action, label: action.value.trimEnd() }));
|
|
557
|
+
},
|
|
558
|
+
handler: handleGoalCommand,
|
|
559
|
+
});
|
|
560
|
+
}
|