pi-goal-x 0.6.0
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/LICENSE +21 -0
- package/README.md +307 -0
- package/docs/agent-flow-design.md +432 -0
- package/docs/agentic-runtime-prd.md +764 -0
- package/docs/architecture.md +239 -0
- package/docs/goal-ts-refactor-test-strategy.md +82 -0
- package/docs/pi-autoresearch-survey.md +45 -0
- package/extensions/goal-auditor.ts +341 -0
- package/extensions/goal-compaction.ts +124 -0
- package/extensions/goal-core.ts +77 -0
- package/extensions/goal-draft.ts +148 -0
- package/extensions/goal-ledger.ts +319 -0
- package/extensions/goal-policy.ts +152 -0
- package/extensions/goal-pool.ts +94 -0
- package/extensions/goal-questionnaire.ts +533 -0
- package/extensions/goal-record.ts +171 -0
- package/extensions/goal-tool-names.ts +69 -0
- package/extensions/goal.ts +2610 -0
- package/extensions/prompts/goal-prompts.ts +166 -0
- package/extensions/storage/goal-files.ts +267 -0
- package/extensions/widgets/goal-notifications.ts +9 -0
- package/extensions/widgets/goal-widget.ts +219 -0
- package/package.json +57 -0
|
@@ -0,0 +1,2610 @@
|
|
|
1
|
+
import { StringEnum, Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import { defineTool, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { matchesKey, Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import {
|
|
5
|
+
footerStatus,
|
|
6
|
+
formatDuration,
|
|
7
|
+
formatTokenValue,
|
|
8
|
+
statusLabel,
|
|
9
|
+
truncateText,
|
|
10
|
+
} from "./goal-core.ts";
|
|
11
|
+
import {
|
|
12
|
+
buildDraftConfirmationText,
|
|
13
|
+
goalDraftingPrompt,
|
|
14
|
+
validateGoalDraftProposal,
|
|
15
|
+
type GoalDraftingFocus,
|
|
16
|
+
} from "./goal-draft.ts";
|
|
17
|
+
import {
|
|
18
|
+
goalAuditorConfigPath,
|
|
19
|
+
loadGoalAuditorFileConfig,
|
|
20
|
+
runGoalCompletionAuditor,
|
|
21
|
+
saveGoalAuditorFileConfig,
|
|
22
|
+
type GoalAuditorConfig,
|
|
23
|
+
} from "./goal-auditor.ts";
|
|
24
|
+
import {
|
|
25
|
+
proposalDialogFailureMessage,
|
|
26
|
+
registerQuestionnaireTools,
|
|
27
|
+
shouldAutoConfirmProposal,
|
|
28
|
+
showProposalDialog,
|
|
29
|
+
} from "./goal-questionnaire.ts";
|
|
30
|
+
import {
|
|
31
|
+
ABORT_GOAL_TOOL_NAME,
|
|
32
|
+
ACTIVE_GOAL_TOOL_NAMES,
|
|
33
|
+
CREATE_GOAL_TOOL_NAME,
|
|
34
|
+
POST_STOP_ALLOWED_TOOLS,
|
|
35
|
+
PROPOSE_DRAFT_TOOL_NAME,
|
|
36
|
+
QUESTIONNAIRE_TOOL_NAME,
|
|
37
|
+
QUESTION_TOOL_NAME,
|
|
38
|
+
SISYPHUS_STEP_TOOL_NAME,
|
|
39
|
+
GOAL_PROGRESS_TOOL_NAMES,
|
|
40
|
+
lifecycleToolNamesForGoalStatus,
|
|
41
|
+
TWEAK_APPLY_TOOL_NAME,
|
|
42
|
+
} from "./goal-tool-names.ts";
|
|
43
|
+
import {
|
|
44
|
+
asRecord,
|
|
45
|
+
cloneGoal,
|
|
46
|
+
createGoal,
|
|
47
|
+
goalFocusDetails,
|
|
48
|
+
normalizeGoalRecord,
|
|
49
|
+
normalizeGoalFocusEntry,
|
|
50
|
+
nowIso,
|
|
51
|
+
type AssistantMessageLike,
|
|
52
|
+
type DraftingFocus,
|
|
53
|
+
type GoalFocusEntry,
|
|
54
|
+
type GoalFocusReason,
|
|
55
|
+
type GoalCreationConfig,
|
|
56
|
+
type GoalEventDetails,
|
|
57
|
+
type GoalEventKind,
|
|
58
|
+
type GoalRecord,
|
|
59
|
+
type GoalStateEntry,
|
|
60
|
+
type GoalStatus,
|
|
61
|
+
type StopReason,
|
|
62
|
+
} from "./goal-record.ts";
|
|
63
|
+
import {
|
|
64
|
+
appendGoalEvent,
|
|
65
|
+
latestAuditorResultForGoal,
|
|
66
|
+
readGoalLedger,
|
|
67
|
+
type GoalLedgerEvent,
|
|
68
|
+
} from "./goal-ledger.ts";
|
|
69
|
+
import { buildCompactionSummary } from "./goal-compaction.ts";
|
|
70
|
+
import {
|
|
71
|
+
archiveGoalFile,
|
|
72
|
+
mergeGoalPromptFromDisk,
|
|
73
|
+
readActiveGoalPool,
|
|
74
|
+
sanitizeGoalPaths,
|
|
75
|
+
writeActiveGoalFile,
|
|
76
|
+
} from "./storage/goal-files.ts";
|
|
77
|
+
import {
|
|
78
|
+
buildGoalListText,
|
|
79
|
+
buildUnfocusedOpenGoalsSummary,
|
|
80
|
+
focusedGoalFromPool,
|
|
81
|
+
goalSelectorLabel,
|
|
82
|
+
mergeFocusedGoalWithDisk,
|
|
83
|
+
openGoalsFromPool,
|
|
84
|
+
otherOpenGoalCount,
|
|
85
|
+
resolveSessionFocus,
|
|
86
|
+
} from "./goal-pool.ts";
|
|
87
|
+
import {
|
|
88
|
+
continuationPrompt,
|
|
89
|
+
goalPrompt,
|
|
90
|
+
goalTweakDraftingPrompt,
|
|
91
|
+
staleContinuationPrompt,
|
|
92
|
+
unfocusedOpenGoalsPrompt,
|
|
93
|
+
untrustedObjectiveBlock,
|
|
94
|
+
} from "./prompts/goal-prompts.ts";
|
|
95
|
+
import { buildGoalRunningNotification } from "./widgets/goal-notifications.ts";
|
|
96
|
+
import { GoalWidgetComponent, type AuditorWidgetProgress } from "./widgets/goal-widget.ts";
|
|
97
|
+
|
|
98
|
+
import {
|
|
99
|
+
abortGoalCommandMessage,
|
|
100
|
+
buildAbortedByAgentGoal,
|
|
101
|
+
buildCompletionReport,
|
|
102
|
+
buildGoalCreatedReport,
|
|
103
|
+
buildPausedByAgentGoal,
|
|
104
|
+
clearGoalCommandMessage,
|
|
105
|
+
shouldArmPostCompactReminder,
|
|
106
|
+
shouldInjectPostCompactReminder,
|
|
107
|
+
validateGoalAbort,
|
|
108
|
+
validateGoalCompletion,
|
|
109
|
+
validatePauseGoal,
|
|
110
|
+
validateResumeGoal,
|
|
111
|
+
} from "./goal-policy.ts";
|
|
112
|
+
|
|
113
|
+
const STATE_ENTRY = "pi-goal-state";
|
|
114
|
+
const FOCUS_ENTRY = "pi-goal-focus";
|
|
115
|
+
const GOAL_EVENT_ENTRY = "pi-goal-event";
|
|
116
|
+
const GOAL_AUDIT_ENTRY = "pi-goal-audit-event";
|
|
117
|
+
const COMPLETE_STATUS = "complete";
|
|
118
|
+
const CONTINUATION_IDLE_RETRY_MS = 50;
|
|
119
|
+
const STATUS_REFRESH_MS = 1000;
|
|
120
|
+
/**
|
|
121
|
+
* Tools that count as "real work" toward the active goal. If a non-tool-use
|
|
122
|
+
* turn ends without any of these having been called, we DO NOT queue the next
|
|
123
|
+
* autoContinue — the agent was just chatting. This stops infinite chat loops.
|
|
124
|
+
*/
|
|
125
|
+
const GOAL_PROGRESS_TOOL_SET = new Set<string>(GOAL_PROGRESS_TOOL_NAMES);
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Tools that are NEVER blocked by the post-stop in-turn block. After pause_goal,
|
|
130
|
+
* abort_goal, update_goal=complete, or apply_goal_tweak fires, the agent should
|
|
131
|
+
* yield the turn; we block all subsequent tool calls except these read-only inspections.
|
|
132
|
+
*/
|
|
133
|
+
const POST_STOP_ALLOWED_TOOL_SET = new Set<string>(POST_STOP_ALLOWED_TOOLS);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* When non-null, /goal-tweak drafting is in progress for this goal id and the
|
|
137
|
+
* agent is allowed to call apply_goal_tweak. Cleared after the tweak is applied
|
|
138
|
+
* or when a user-driven turn arrives without a tweak follow-through. This is
|
|
139
|
+
* the schema-level affordance gate that prevents the agent from "tweaking" via
|
|
140
|
+
* arbitrary write/edit calls.
|
|
141
|
+
*/
|
|
142
|
+
let tweakDraftingFor: string | null = null;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Thin session-local confirmation intent for /goals and /sisyphus.
|
|
146
|
+
* It protects mode consistency and user confirmation without turning drafting
|
|
147
|
+
* into a separate long-running runtime state machine.
|
|
148
|
+
*/
|
|
149
|
+
interface GoalConfirmationIntent {
|
|
150
|
+
focus: GoalDraftingFocus;
|
|
151
|
+
originalTopic: string;
|
|
152
|
+
startedAt: number;
|
|
153
|
+
}
|
|
154
|
+
let confirmationIntent: GoalConfirmationIntent | null = null;
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
// ---------- summaries ----------
|
|
158
|
+
|
|
159
|
+
function usageLines(goal: GoalRecord): string[] {
|
|
160
|
+
return [
|
|
161
|
+
`Time spent: ${formatDuration(goal.usage.activeSeconds)}`,
|
|
162
|
+
`Tokens used: ${formatTokenValue(goal.usage.tokensUsed)}`,
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function detailedSummary(goal: GoalRecord | null): string {
|
|
167
|
+
if (!goal) return "No goal is set. Use /goals <topic> or /sisyphus <topic> to discuss, or /goals-set <objective> / /sisyphus-set <objective> to start immediately.";
|
|
168
|
+
const lines = [
|
|
169
|
+
`Goal: ${goal.objective}`,
|
|
170
|
+
`Status: ${statusLabel(goal)}`,
|
|
171
|
+
`Auto-continue: ${goal.autoContinue ? "on" : "off"}`,
|
|
172
|
+
...usageLines(goal),
|
|
173
|
+
];
|
|
174
|
+
if (goal.sisyphus) {
|
|
175
|
+
lines.push("Mode: Sisyphus (prompt/criteria variant; shared goal lifecycle)");
|
|
176
|
+
}
|
|
177
|
+
if (goal.activePath) lines.push(`File: ${goal.activePath}`);
|
|
178
|
+
if (goal.archivedPath) lines.push(`Archive: ${goal.archivedPath}`);
|
|
179
|
+
if (goal.stopReason) lines.push(`Stop reason: ${goal.stopReason}`);
|
|
180
|
+
if (goal.pauseReason) lines.push(`Agent pause reason: ${goal.pauseReason}`);
|
|
181
|
+
if (goal.pauseSuggestedAction) lines.push(`Agent suggests: ${goal.pauseSuggestedAction}`);
|
|
182
|
+
return lines.join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function oneLineSummary(goal: GoalRecord | null): string {
|
|
186
|
+
if (!goal) return "No goal is set.";
|
|
187
|
+
const tail = goal.usage.tokensUsed > 0 ? ` [${formatTokenValue(goal.usage.tokensUsed).split(" ")[0]}]` : "";
|
|
188
|
+
return `${statusLabel(goal)}${tail} - ${truncateText(goal.objective)}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------- entry / render helpers ----------
|
|
192
|
+
|
|
193
|
+
function goalDetails(goal: GoalRecord | null): GoalStateEntry {
|
|
194
|
+
return { version: 3, goal: goal ? cloneGoal(goal) : null };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function renderGoalResult(result: { details?: unknown; content: Array<{ type: string; text?: string }> }, theme: Theme): Text {
|
|
198
|
+
const first = result.content.find((item) => item.type === "text" && typeof item.text === "string");
|
|
199
|
+
const firstText = first?.text ?? "";
|
|
200
|
+
const details = result.details as GoalStateEntry | undefined;
|
|
201
|
+
if (!details || typeof details !== "object" || !("goal" in details)) {
|
|
202
|
+
return new Text(firstText, 0, 0);
|
|
203
|
+
}
|
|
204
|
+
if (
|
|
205
|
+
firstText.startsWith("Goal audit ")
|
|
206
|
+
|| firstText.startsWith("Goal completion rejected")
|
|
207
|
+
|| firstText.startsWith("Goal complete.")
|
|
208
|
+
|| firstText.startsWith("Goal paused.")
|
|
209
|
+
|| firstText.startsWith("Goal aborted.")
|
|
210
|
+
|| firstText.startsWith("Goal confirmed and created.")
|
|
211
|
+
) {
|
|
212
|
+
return new Text(firstText, 0, 0);
|
|
213
|
+
}
|
|
214
|
+
return new Text(theme.fg("accent", "Goal ") + theme.fg("muted", oneLineSummary(details.goal)), 0, 0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizeGoalEventDetails(value: unknown): GoalEventDetails {
|
|
218
|
+
const raw = asRecord(value);
|
|
219
|
+
const kind: GoalEventKind = raw?.kind === "stale" ? "stale" : raw?.kind === "drafting" ? "drafting" : "checkpoint";
|
|
220
|
+
const goalId = typeof raw?.goalId === "string" ? raw.goalId : "unknown";
|
|
221
|
+
const focus: DraftingFocus | undefined = raw?.focus === "sisyphus" ? "sisyphus" : raw?.focus === "goal" ? "goal" : undefined;
|
|
222
|
+
const status = raw?.status === "active" || raw?.status === "paused" || raw?.status === "complete" ? (raw.status as GoalStatus) : undefined;
|
|
223
|
+
const currentStatus =
|
|
224
|
+
raw?.currentStatus === "active" || raw?.currentStatus === "paused" || raw?.currentStatus === "complete"
|
|
225
|
+
? (raw.currentStatus as GoalStatus)
|
|
226
|
+
: raw?.currentStatus === null
|
|
227
|
+
? null
|
|
228
|
+
: undefined;
|
|
229
|
+
return {
|
|
230
|
+
kind,
|
|
231
|
+
goalId,
|
|
232
|
+
status,
|
|
233
|
+
objective: typeof raw?.objective === "string" ? raw.objective : undefined,
|
|
234
|
+
timestamp: typeof raw?.timestamp === "number" ? raw.timestamp : undefined,
|
|
235
|
+
currentGoalId: typeof raw?.currentGoalId === "string" || raw?.currentGoalId === null ? raw.currentGoalId : undefined,
|
|
236
|
+
currentStatus,
|
|
237
|
+
focus,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
interface GoalAuditEventDetails {
|
|
242
|
+
phase: "started" | "approved" | "rejected" | "skipped";
|
|
243
|
+
goalId: string;
|
|
244
|
+
auditor?: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function renderGoalEvent(message: { details?: GoalEventDetails }, options: { expanded: boolean }, theme: Theme): Text {
|
|
248
|
+
const details = normalizeGoalEventDetails(message.details);
|
|
249
|
+
const label =
|
|
250
|
+
details.kind === "stale" ? "stale checkpoint"
|
|
251
|
+
: details.kind === "drafting" ? (details.focus === "sisyphus" ? "sisyphus drafting" : "goal drafting")
|
|
252
|
+
: "checkpoint";
|
|
253
|
+
if (!options.expanded) {
|
|
254
|
+
return new Text(theme.fg("customMessageLabel", "Goal ") + theme.fg("customMessageText", label), 0, 0);
|
|
255
|
+
}
|
|
256
|
+
const lines = [`Status: ${details.status === "active" ? "running" : details.status ?? "unknown"}`];
|
|
257
|
+
if (details.objective) lines.push(`Objective: ${details.objective}`);
|
|
258
|
+
lines.push(`Goal id: ${details.goalId}`);
|
|
259
|
+
if (details.currentGoalId || details.currentStatus) {
|
|
260
|
+
lines.push(`Current: ${details.currentGoalId ?? "none"}${details.currentStatus ? ` (${details.currentStatus})` : ""}`);
|
|
261
|
+
}
|
|
262
|
+
return new Text(
|
|
263
|
+
theme.fg("customMessageLabel", `Goal ${label}`) + "\n" + theme.fg("customMessageText", lines.join("\n")),
|
|
264
|
+
0,
|
|
265
|
+
0,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function renderGoalAuditEvent(message: { content?: unknown; details?: GoalAuditEventDetails }, _options: { expanded: boolean }, theme: Theme): Text {
|
|
270
|
+
const phase = message.details?.phase ?? "started";
|
|
271
|
+
const label = phase === "approved" ? "approved" : phase === "rejected" ? "rejected" : phase === "skipped" ? "skipped" : "started";
|
|
272
|
+
const content = typeof message.content === "string" ? message.content : `Goal audit ${label}.`;
|
|
273
|
+
return new Text(
|
|
274
|
+
theme.fg("customMessageLabel", `Goal audit ${label}`) + "\n" + theme.fg("customMessageText", content),
|
|
275
|
+
0,
|
|
276
|
+
0,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function extractGoalIdFromInjectedMessage(text: string): string | null {
|
|
281
|
+
// Drafting messages (new goal, sisyphus, or tweak) have no continuation goalId and
|
|
282
|
+
// must never be treated as stale-continuation triggers.
|
|
283
|
+
if (/^\[GOAL (?:DRAFTING|TWEAK DRAFTING)\b/.test(text)) return null;
|
|
284
|
+
// Phase 5 C1: structured outer marker `<pi_goal_continuation goal_id="..." kind="...">`.
|
|
285
|
+
// Borrowed from pi-codex-goal. More robust than bare bracket text because
|
|
286
|
+
// the angle brackets + attributes are nearly impossible for users to type
|
|
287
|
+
// by accident, and the structure is grep-able / parse-able by external tooling.
|
|
288
|
+
const xmlMatch = text.match(/^<pi_goal_continuation\s+goal_id=\"([^\"]+)\"/);
|
|
289
|
+
if (xmlMatch) return xmlMatch[1] ?? null;
|
|
290
|
+
const match = text.match(/^\[(?:GOAL CHECKPOINT|GOAL CONTINUATION|GOAL STALE) goalId=([^\]\s]+)\]/);
|
|
291
|
+
return match?.[1] ?? null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function goalEventMessageId(message: { customType?: string; details?: unknown; content?: unknown }): string | null {
|
|
295
|
+
if (message.customType !== GOAL_EVENT_ENTRY) return null;
|
|
296
|
+
const details = asRecord(message.details);
|
|
297
|
+
// Drafting messages never correspond to a real goal id; they must not be staleness-checked.
|
|
298
|
+
if (details?.kind === "drafting") return null;
|
|
299
|
+
const goalId = details && typeof details.goalId === "string" ? details.goalId : null;
|
|
300
|
+
if (goalId) return goalId;
|
|
301
|
+
return typeof message.content === "string" ? extractGoalIdFromInjectedMessage(message.content) : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function isAbortedAssistantMessage(message: unknown): boolean {
|
|
305
|
+
const raw = asRecord(message);
|
|
306
|
+
return raw?.role === "assistant" && raw.stopReason === "aborted";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isToolUseAssistantMessage(message: unknown): boolean {
|
|
310
|
+
const raw = asRecord(message);
|
|
311
|
+
return raw?.role === "assistant" && raw.stopReason === "toolUse";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function hasAbortedAssistantMessage(messages: unknown[]): boolean {
|
|
315
|
+
return messages.some(isAbortedAssistantMessage);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function usageChannelTokens(value: unknown): number {
|
|
319
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
|
320
|
+
return Math.max(0, Math.trunc(value));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function assistantTurnTokens(message: unknown): number {
|
|
324
|
+
const raw = asRecord(message);
|
|
325
|
+
if (!raw || raw.role !== "assistant") return 0;
|
|
326
|
+
const usage = asRecord(raw.usage);
|
|
327
|
+
if (!usage) return 0;
|
|
328
|
+
return usageChannelTokens(usage.input) + usageChannelTokens(usage.output);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isMeaningfulProgressToolCall(toolName: string, args: unknown): boolean {
|
|
332
|
+
if (!GOAL_PROGRESS_TOOL_SET.has(toolName)) return false;
|
|
333
|
+
if (toolName === "read") {
|
|
334
|
+
const path = asRecord(args)?.path;
|
|
335
|
+
if (typeof path === "string" && (path === ".pi/goals" || path.startsWith(".pi/goals/"))) return false;
|
|
336
|
+
}
|
|
337
|
+
if (toolName === "bash") {
|
|
338
|
+
const command = asRecord(args)?.command;
|
|
339
|
+
if (typeof command === "string" && /^\s*echo\b/.test(command)) return false;
|
|
340
|
+
}
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------- extension entry point ----------
|
|
345
|
+
|
|
346
|
+
export default function goalExtension(pi: ExtensionAPI): void {
|
|
347
|
+
let goalsById = new Map<string, GoalRecord>();
|
|
348
|
+
let focusedGoalId: string | null = null;
|
|
349
|
+
const state = {
|
|
350
|
+
get goal(): GoalRecord | null {
|
|
351
|
+
return focusedGoalFromPool(goalsById, focusedGoalId);
|
|
352
|
+
},
|
|
353
|
+
set goal(next: GoalRecord | null) {
|
|
354
|
+
if (next) {
|
|
355
|
+
goalsById.set(next.id, next);
|
|
356
|
+
focusedGoalId = next.id;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (focusedGoalId) goalsById.delete(focusedGoalId);
|
|
360
|
+
focusedGoalId = null;
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
let continuationQueuedFor: string | null = null;
|
|
364
|
+
let continuationScheduledFor: string | null = null;
|
|
365
|
+
let continuationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
366
|
+
let runningGoalId: string | null = null;
|
|
367
|
+
let terminalInputUnsubscribe: (() => void) | null = null;
|
|
368
|
+
let statusRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
369
|
+
let statusRefreshCtx: ExtensionContext | null = null;
|
|
370
|
+
let auditProgress: AuditorWidgetProgress | null = null;
|
|
371
|
+
let auditAnimationTimer: ReturnType<typeof setInterval> | null = null;
|
|
372
|
+
let auditAbortController: AbortController | null = null;
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
// Per-turn flags reset in turn_start (#4, C9 fix).
|
|
376
|
+
// goalWorkToolCalledThisTurn: tracks whether a real goal-work tool was called.
|
|
377
|
+
// If false at turn_end, we don't queue another autoContinue (empty chat turn).
|
|
378
|
+
// turnStoppedFor: set by pause_goal / update_goal(complete) / apply_goal_tweak
|
|
379
|
+
// after their successful execute. Once set, pi.on("tool_call") blocks all
|
|
380
|
+
// subsequent in-turn tool calls except POST_STOP_ALLOWED_TOOLS. This is the
|
|
381
|
+
// schema fix for "agent keeps writing files after pause_goal".
|
|
382
|
+
let goalWorkToolCalledThisTurn = false;
|
|
383
|
+
let turnStoppedFor: string | null = null;
|
|
384
|
+
|
|
385
|
+
// #5 post-compaction resync: when a compaction just happened, the next agent
|
|
386
|
+
// turn gets an extra reminder block. Set in session_compact, consumed
|
|
387
|
+
// (cleared) in before_agent_start.
|
|
388
|
+
let postCompactReminderPending = false;
|
|
389
|
+
|
|
390
|
+
const accounting = {
|
|
391
|
+
activeGoalId: null as string | null,
|
|
392
|
+
lastAccountedAt: null as number | null,
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const draftingHiddenWorkTools = [
|
|
396
|
+
"bash",
|
|
397
|
+
"read",
|
|
398
|
+
"write",
|
|
399
|
+
"edit",
|
|
400
|
+
"grep",
|
|
401
|
+
"find",
|
|
402
|
+
"ls",
|
|
403
|
+
SISYPHUS_STEP_TOOL_NAME,
|
|
404
|
+
TWEAK_APPLY_TOOL_NAME,
|
|
405
|
+
CREATE_GOAL_TOOL_NAME,
|
|
406
|
+
] as const;
|
|
407
|
+
const goalExecutionWorkTools = ["read", "bash", "edit", "write"] as const;
|
|
408
|
+
|
|
409
|
+
function syncGoalTools(): void {
|
|
410
|
+
try {
|
|
411
|
+
const active = new Set(pi.getActiveTools());
|
|
412
|
+
for (const name of goalExecutionWorkTools) active.add(name);
|
|
413
|
+
active.delete(QUESTION_TOOL_NAME);
|
|
414
|
+
active.delete(QUESTIONNAIRE_TOOL_NAME);
|
|
415
|
+
for (const name of ACTIVE_GOAL_TOOL_NAMES) active.delete(name);
|
|
416
|
+
const phase = confirmationIntent !== null ? "drafting" : tweakDraftingFor !== null ? "tweakDrafting" : "normal";
|
|
417
|
+
const lifecycleTools = lifecycleToolNamesForGoalStatus(state.goal?.status, phase);
|
|
418
|
+
for (const name of lifecycleTools) active.add(name);
|
|
419
|
+
// Sisyphus is now a prompt/criteria style, not a separate step-counter
|
|
420
|
+
// mechanism. Keep step_complete registered for legacy transcripts, but do
|
|
421
|
+
// not expose it as an active work tool.
|
|
422
|
+
active.delete(SISYPHUS_STEP_TOOL_NAME);
|
|
423
|
+
// apply_goal_tweak is only available during a /goal-tweak drafting flow.
|
|
424
|
+
// Note: tweak drafting can run against active OR paused goals.
|
|
425
|
+
if (state.goal && tweakDraftingFor === state.goal.id) {
|
|
426
|
+
active.add(TWEAK_APPLY_TOOL_NAME);
|
|
427
|
+
active.add(QUESTION_TOOL_NAME);
|
|
428
|
+
active.add(QUESTIONNAIRE_TOOL_NAME);
|
|
429
|
+
} else {
|
|
430
|
+
active.delete(TWEAK_APPLY_TOOL_NAME);
|
|
431
|
+
}
|
|
432
|
+
// Keep the commit tool available and let its validator enforce that a
|
|
433
|
+
// drafting flow is active. This avoids fragile hidden-tool drift after
|
|
434
|
+
// question turns, compaction, or active-tool resync.
|
|
435
|
+
active.add(PROPOSE_DRAFT_TOOL_NAME);
|
|
436
|
+
// create_goal stays hidden — hard invariant: user must confirm via propose_goal_draft.
|
|
437
|
+
active.delete(CREATE_GOAL_TOOL_NAME);
|
|
438
|
+
if (confirmationIntent !== null) {
|
|
439
|
+
active.add(QUESTION_TOOL_NAME);
|
|
440
|
+
active.add(QUESTIONNAIRE_TOOL_NAME);
|
|
441
|
+
} else if (state.goal?.status === "active") {
|
|
442
|
+
for (const name of goalExecutionWorkTools) active.add(name);
|
|
443
|
+
}
|
|
444
|
+
pi.setActiveTools(Array.from(active));
|
|
445
|
+
} catch {}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function stopAuditAnimation(): void {
|
|
449
|
+
if (auditAnimationTimer) {
|
|
450
|
+
clearInterval(auditAnimationTimer);
|
|
451
|
+
auditAnimationTimer = null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function abortAudit(ctx: ExtensionContext): void {
|
|
456
|
+
if (!auditAbortController || !auditProgress) return;
|
|
457
|
+
const auditorConfig = loadGoalAuditorFileConfig(ctx.cwd);
|
|
458
|
+
auditAbortController.abort();
|
|
459
|
+
auditAbortController = null;
|
|
460
|
+
stopAuditAnimation();
|
|
461
|
+
auditProgress = null;
|
|
462
|
+
goalWidgetComponent?.invalidate();
|
|
463
|
+
ctx.ui.notify("Audit skipped by user.", "warning");
|
|
464
|
+
if (state.goal) {
|
|
465
|
+
try {
|
|
466
|
+
appendGoalEvent(ctx, {
|
|
467
|
+
type: "audit_skipped",
|
|
468
|
+
goalId: state.goal.id,
|
|
469
|
+
reason: "user_aborted",
|
|
470
|
+
provider: auditorConfig.provider,
|
|
471
|
+
model: auditorConfig.model,
|
|
472
|
+
thinkingLevel: auditorConfig.thinkingLevel,
|
|
473
|
+
at: nowIso(),
|
|
474
|
+
});
|
|
475
|
+
} catch {
|
|
476
|
+
// Ledger append failure should not block skip
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function stopStatusRefresh(): void {
|
|
482
|
+
if (statusRefreshTimer) {
|
|
483
|
+
clearInterval(statusRefreshTimer);
|
|
484
|
+
statusRefreshTimer = null;
|
|
485
|
+
}
|
|
486
|
+
statusRefreshCtx = null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function syncStatusRefresh(ctx: ExtensionContext): void {
|
|
490
|
+
if (!ctx.hasUI || state.goal?.status !== "active") {
|
|
491
|
+
stopStatusRefresh();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
statusRefreshCtx = ctx;
|
|
495
|
+
if (statusRefreshTimer) return;
|
|
496
|
+
statusRefreshTimer = setInterval(() => {
|
|
497
|
+
if (!statusRefreshCtx || state.goal?.status !== "active") {
|
|
498
|
+
stopStatusRefresh();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const displayGoal = goalForDisplay();
|
|
502
|
+
if (displayGoal) {
|
|
503
|
+
const otherCount = otherOpenGoalCount(goalsById, focusedGoalId);
|
|
504
|
+
statusRefreshCtx.ui.setStatus("goal", `${footerStatus(displayGoal)}${otherCount > 0 ? ` (+${otherCount} open)` : ""}`);
|
|
505
|
+
}
|
|
506
|
+
// Live-tick the above-editor widget so duration/tokens update.
|
|
507
|
+
goalWidgetComponent?.update();
|
|
508
|
+
}, STATUS_REFRESH_MS);
|
|
509
|
+
statusRefreshTimer.unref?.();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function clearContinuationTimer(): void {
|
|
513
|
+
if (continuationTimer) {
|
|
514
|
+
clearTimeout(continuationTimer);
|
|
515
|
+
continuationTimer = null;
|
|
516
|
+
}
|
|
517
|
+
continuationScheduledFor = null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function clearContinuationState(): void {
|
|
521
|
+
clearContinuationTimer();
|
|
522
|
+
continuationQueuedFor = null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function clearActiveAccounting(): void {
|
|
526
|
+
accounting.activeGoalId = null;
|
|
527
|
+
accounting.lastAccountedAt = null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function clearStoppedRuntimeState(): void {
|
|
531
|
+
clearContinuationState();
|
|
532
|
+
clearActiveAccounting();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
const activeGetGoalTurnsByGoalId = new Map<string, number>();
|
|
537
|
+
|
|
538
|
+
function resetGetGoalNudgeState(goalId: string | null | undefined): void {
|
|
539
|
+
if (goalId) {
|
|
540
|
+
activeGetGoalTurnsByGoalId.delete(goalId);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function openGoals(): GoalRecord[] {
|
|
545
|
+
return openGoalsFromPool(goalsById);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function reconcileFocusedGoalFromDisk(ctx: ExtensionContext, opts: { preserveMemoryUsage?: boolean } = {}): boolean {
|
|
549
|
+
const current = state.goal;
|
|
550
|
+
const fresh = readActiveGoalPool(ctx);
|
|
551
|
+
if (!focusedGoalId) {
|
|
552
|
+
goalsById = fresh;
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
const diskGoal = fresh.get(focusedGoalId) ?? null;
|
|
556
|
+
if (!diskGoal) {
|
|
557
|
+
if (current && !current.activePath) {
|
|
558
|
+
goalsById = fresh;
|
|
559
|
+
goalsById.set(current.id, current);
|
|
560
|
+
focusedGoalId = current.id;
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
goalsById = fresh;
|
|
564
|
+
focusedGoalId = null;
|
|
565
|
+
clearStoppedRuntimeState();
|
|
566
|
+
if (current) resetGetGoalNudgeState(current.id);
|
|
567
|
+
if (tweakDraftingFor !== null) tweakDraftingFor = null;
|
|
568
|
+
syncGoalTools();
|
|
569
|
+
updateUI(ctx);
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
const reconciled = current && opts.preserveMemoryUsage
|
|
573
|
+
? mergeFocusedGoalWithDisk({ memoryGoal: current, diskGoal })
|
|
574
|
+
: diskGoal;
|
|
575
|
+
goalsById = fresh;
|
|
576
|
+
goalsById.set(reconciled.id, reconciled);
|
|
577
|
+
focusedGoalId = reconciled.id;
|
|
578
|
+
if (reconciled.status !== "active" || !reconciled.autoContinue) clearContinuationState();
|
|
579
|
+
if (reconciled.status !== "active") clearActiveAccounting();
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function appendFocusEntry(goalId: string | null, reason: GoalFocusReason): void {
|
|
584
|
+
pi.appendEntry(FOCUS_ENTRY, goalFocusDetails(goalId, reason));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function setFocusedGoalId(goalId: string | null, ctx: ExtensionContext, reason: GoalFocusReason): void {
|
|
588
|
+
const previousGoalId = focusedGoalId;
|
|
589
|
+
focusedGoalId = goalId && goalsById.has(goalId) ? goalId : null;
|
|
590
|
+
if (previousGoalId !== focusedGoalId) {
|
|
591
|
+
clearContinuationState();
|
|
592
|
+
clearActiveAccounting();
|
|
593
|
+
resetGetGoalNudgeState(previousGoalId);
|
|
594
|
+
resetGetGoalNudgeState(focusedGoalId);
|
|
595
|
+
if (tweakDraftingFor !== null && tweakDraftingFor !== focusedGoalId) tweakDraftingFor = null;
|
|
596
|
+
}
|
|
597
|
+
appendFocusEntry(focusedGoalId, reason);
|
|
598
|
+
// Append ledger event for focus changes
|
|
599
|
+
try {
|
|
600
|
+
if (focusedGoalId) {
|
|
601
|
+
appendGoalEvent(ctx, { type: "goal_focused", goalId: focusedGoalId, reason, at: nowIso() });
|
|
602
|
+
} else if (previousGoalId) {
|
|
603
|
+
appendGoalEvent(ctx, { type: "goal_unfocused", reason, at: nowIso() });
|
|
604
|
+
}
|
|
605
|
+
} catch {
|
|
606
|
+
// Ledger append failure should not crash focus change
|
|
607
|
+
}
|
|
608
|
+
syncGoalTools();
|
|
609
|
+
updateUI(ctx);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function updateFocusedGoal(next: GoalRecord, ctx: ExtensionContext, shouldPersist = true): void {
|
|
613
|
+
const previousGoalId = focusedGoalId;
|
|
614
|
+
goalsById.set(next.id, next);
|
|
615
|
+
focusedGoalId = next.id;
|
|
616
|
+
if (previousGoalId !== focusedGoalId) {
|
|
617
|
+
resetGetGoalNudgeState(previousGoalId);
|
|
618
|
+
resetGetGoalNudgeState(focusedGoalId);
|
|
619
|
+
}
|
|
620
|
+
if (shouldPersist) persist(ctx);
|
|
621
|
+
else syncGoalTools();
|
|
622
|
+
updateUI(ctx);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function armFocusedContinuation(ctx: ExtensionContext): void {
|
|
626
|
+
beginAccounting();
|
|
627
|
+
if (state.goal?.status === "active" && state.goal.autoContinue) queueContinuation(ctx, true);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function removeFocusedGoal(ctx: ExtensionContext, reason: GoalFocusReason): void {
|
|
631
|
+
const previousGoalId = focusedGoalId;
|
|
632
|
+
if (focusedGoalId) goalsById.delete(focusedGoalId);
|
|
633
|
+
focusedGoalId = null;
|
|
634
|
+
clearStoppedRuntimeState();
|
|
635
|
+
resetGetGoalNudgeState(previousGoalId);
|
|
636
|
+
appendFocusEntry(null, reason);
|
|
637
|
+
syncGoalTools();
|
|
638
|
+
updateUI(ctx);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function beginAccounting(): void {
|
|
642
|
+
if (confirmationIntent !== null || tweakDraftingFor !== null) {
|
|
643
|
+
clearActiveAccounting();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (!state.goal || (state.goal.status !== "active")) {
|
|
647
|
+
clearActiveAccounting();
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
accounting.activeGoalId = state.goal.id;
|
|
651
|
+
accounting.lastAccountedAt = Date.now();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function goalForDisplay(): GoalRecord | null {
|
|
655
|
+
if (!state.goal || state.goal.status !== "active" || accounting.activeGoalId !== state.goal.id || accounting.lastAccountedAt === null) {
|
|
656
|
+
return state.goal;
|
|
657
|
+
}
|
|
658
|
+
const liveSeconds = Math.max(0, Math.floor((Date.now() - accounting.lastAccountedAt) / 1000));
|
|
659
|
+
if (liveSeconds === 0) return state.goal;
|
|
660
|
+
const live = cloneGoal(state.goal);
|
|
661
|
+
live.usage.activeSeconds += liveSeconds;
|
|
662
|
+
return live;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function accountProgress(ctx: ExtensionContext, opts: { completedTurnTokens?: number } = {}): void {
|
|
666
|
+
if (confirmationIntent !== null || tweakDraftingFor !== null) {
|
|
667
|
+
clearActiveAccounting();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (state.goal?.activePath && !reconcileFocusedGoalFromDisk(ctx, { preserveMemoryUsage: true })) return;
|
|
671
|
+
if (!state.goal || state.goal.status !== "active" || accounting.activeGoalId !== state.goal.id) {
|
|
672
|
+
beginAccounting();
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const now = Date.now();
|
|
677
|
+
const elapsedSeconds = accounting.lastAccountedAt === null ? 0 : Math.floor((now - accounting.lastAccountedAt) / 1000);
|
|
678
|
+
accounting.lastAccountedAt = now;
|
|
679
|
+
|
|
680
|
+
const tokens = Math.max(0, Math.trunc(opts.completedTurnTokens ?? 0));
|
|
681
|
+
if (tokens === 0 && elapsedSeconds === 0) return;
|
|
682
|
+
|
|
683
|
+
const next = cloneGoal(state.goal);
|
|
684
|
+
next.usage.tokensUsed += tokens;
|
|
685
|
+
next.usage.activeSeconds += elapsedSeconds;
|
|
686
|
+
next.updatedAt = nowIso();
|
|
687
|
+
state.goal = next;
|
|
688
|
+
persist(ctx);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function syncGoalPromptFromDisk(ctx: ExtensionContext): boolean {
|
|
692
|
+
if (!state.goal || state.goal.status === "complete") return false;
|
|
693
|
+
const previousObjective = state.goal.objective;
|
|
694
|
+
state.goal = mergeGoalPromptFromDisk(ctx, state.goal);
|
|
695
|
+
return state.goal.objective !== previousObjective;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function persist(ctx?: ExtensionContext): void {
|
|
699
|
+
const current = state.goal;
|
|
700
|
+
if (current) {
|
|
701
|
+
state.goal = { ...current, updatedAt: nowIso() };
|
|
702
|
+
if (ctx) {
|
|
703
|
+
syncGoalPromptFromDisk(ctx);
|
|
704
|
+
const next = state.goal;
|
|
705
|
+
if (next) state.goal = next.status === "complete" ? archiveGoalFile(ctx, next) : writeActiveGoalFile(ctx, next);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
pi.appendEntry(STATE_ENTRY, goalDetails(state.goal));
|
|
709
|
+
syncGoalTools();
|
|
710
|
+
if (ctx) updateUI(ctx);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function refreshGoalDisplayFromDisk(ctx: ExtensionContext): void {
|
|
714
|
+
if (!state.goal || state.goal.status === "complete") return;
|
|
715
|
+
if (syncGoalPromptFromDisk(ctx)) {
|
|
716
|
+
state.goal = { ...state.goal, updatedAt: nowIso() };
|
|
717
|
+
pi.appendEntry(STATE_ENTRY, goalDetails(state.goal));
|
|
718
|
+
}
|
|
719
|
+
syncGoalTools();
|
|
720
|
+
updateUI(ctx);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Live above-editor widget for the active goal. Inspired by rpiv-todo's
|
|
725
|
+
* TodoOverlay: register the widget once with a factory, read live state
|
|
726
|
+
* via the closure at render time, and call `tui.requestRender()` on every
|
|
727
|
+
* state change so the overlay refreshes without re-registration.
|
|
728
|
+
*
|
|
729
|
+
* Layout (sisyphus, running):
|
|
730
|
+
* ◆ Sisyphus [▰▰▰▱▱] 3/5
|
|
731
|
+
* ├─ ⟡ extract validator … wire it … update tests.
|
|
732
|
+
* ├─ Status: sisyphus running · auto-continue · 14m 21s · 24.3k tokens
|
|
733
|
+
* └─ .pi/goals/active_goal_xxx.md
|
|
734
|
+
*
|
|
735
|
+
* Layout (paused with blocker):
|
|
736
|
+
* ⊘ Goal paused
|
|
737
|
+
* ├─ ⟡ improve benchmark coverage for the parser
|
|
738
|
+
* ├─ Status: paused (agent) · 2m 14s · 12.4k tokens
|
|
739
|
+
* ├─ Blocker: cannot find the tests directory
|
|
740
|
+
* └─ Suggested: ask the user for the test location
|
|
741
|
+
*/
|
|
742
|
+
const GOAL_WIDGET_KEY = "goal";
|
|
743
|
+
let widgetRegistered = false;
|
|
744
|
+
let goalWidgetComponent: GoalWidgetComponent | null = null;
|
|
745
|
+
|
|
746
|
+
function clearGoalWidget(ctx: ExtensionContext): void {
|
|
747
|
+
ctx.ui.setStatus("goal", undefined);
|
|
748
|
+
ctx.ui.setWidget(GOAL_WIDGET_KEY, undefined);
|
|
749
|
+
widgetRegistered = false;
|
|
750
|
+
goalWidgetComponent = null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function updateUI(ctx: ExtensionContext): void {
|
|
754
|
+
if (!ctx.hasUI) return;
|
|
755
|
+
const totalOpen = openGoals().length;
|
|
756
|
+
if (!state.goal && totalOpen === 0) {
|
|
757
|
+
clearGoalWidget(ctx);
|
|
758
|
+
stopStatusRefresh();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (!state.goal) {
|
|
762
|
+
ctx.ui.setStatus("goal", `goal: unfocused [${totalOpen} open] - /goal-focus`);
|
|
763
|
+
if (!widgetRegistered) {
|
|
764
|
+
ctx.ui.setWidget(
|
|
765
|
+
GOAL_WIDGET_KEY,
|
|
766
|
+
(tui, theme) => {
|
|
767
|
+
goalWidgetComponent = new GoalWidgetComponent({
|
|
768
|
+
tui,
|
|
769
|
+
theme,
|
|
770
|
+
getGoal: () => goalForDisplay() ?? state.goal,
|
|
771
|
+
getOpenGoalCount: () => openGoals().length,
|
|
772
|
+
getAuditorProgress: () => auditProgress,
|
|
773
|
+
});
|
|
774
|
+
return goalWidgetComponent;
|
|
775
|
+
},
|
|
776
|
+
{ placement: "aboveEditor" },
|
|
777
|
+
);
|
|
778
|
+
widgetRegistered = true;
|
|
779
|
+
} else {
|
|
780
|
+
goalWidgetComponent?.update();
|
|
781
|
+
}
|
|
782
|
+
stopStatusRefresh();
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const displayGoal = goalForDisplay() ?? state.goal;
|
|
787
|
+
const otherCount = otherOpenGoalCount(goalsById, focusedGoalId);
|
|
788
|
+
ctx.ui.setStatus("goal", `${footerStatus(displayGoal)}${otherCount > 0 ? ` (+${otherCount} open)` : ""}`);
|
|
789
|
+
|
|
790
|
+
if (!widgetRegistered) {
|
|
791
|
+
ctx.ui.setWidget(
|
|
792
|
+
GOAL_WIDGET_KEY,
|
|
793
|
+
(tui, theme) => {
|
|
794
|
+
goalWidgetComponent = new GoalWidgetComponent({
|
|
795
|
+
tui,
|
|
796
|
+
theme,
|
|
797
|
+
getGoal: () => goalForDisplay() ?? state.goal,
|
|
798
|
+
getOpenGoalCount: () => openGoals().length,
|
|
799
|
+
getAuditorProgress: () => auditProgress,
|
|
800
|
+
});
|
|
801
|
+
return goalWidgetComponent;
|
|
802
|
+
},
|
|
803
|
+
{ placement: "aboveEditor" },
|
|
804
|
+
);
|
|
805
|
+
widgetRegistered = true;
|
|
806
|
+
} else {
|
|
807
|
+
goalWidgetComponent?.update();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (state.goal.status === "complete") {
|
|
811
|
+
stopStatusRefresh();
|
|
812
|
+
} else {
|
|
813
|
+
syncStatusRefresh(ctx);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function loadState(ctx: ExtensionContext): void {
|
|
818
|
+
goalsById = readActiveGoalPool(ctx);
|
|
819
|
+
focusedGoalId = null;
|
|
820
|
+
let focusEntry: GoalFocusEntry | null = null;
|
|
821
|
+
let legacyGoal: GoalRecord | null = null;
|
|
822
|
+
let legacyStateSeen = false;
|
|
823
|
+
const entries = ctx.sessionManager.getBranch();
|
|
824
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
825
|
+
const entry = entries[i] as { type?: string; customType?: string; data?: unknown };
|
|
826
|
+
if (entry.type !== "custom") continue;
|
|
827
|
+
if (!focusEntry && entry.customType === FOCUS_ENTRY) {
|
|
828
|
+
focusEntry = normalizeGoalFocusEntry(entry.data);
|
|
829
|
+
}
|
|
830
|
+
if (!legacyStateSeen && entry.customType === STATE_ENTRY) {
|
|
831
|
+
legacyGoal = normalizeGoalRecord(asRecord(entry.data)?.goal);
|
|
832
|
+
legacyStateSeen = true;
|
|
833
|
+
}
|
|
834
|
+
if (focusEntry && legacyStateSeen) break;
|
|
835
|
+
}
|
|
836
|
+
if (legacyGoal && legacyGoal.status !== "complete") {
|
|
837
|
+
legacyGoal = sanitizeGoalPaths(ctx, mergeGoalPromptFromDisk(ctx, legacyGoal));
|
|
838
|
+
}
|
|
839
|
+
focusedGoalId = resolveSessionFocus({ pool: goalsById, focusEntry, legacyGoal });
|
|
840
|
+
if (!focusEntry && focusedGoalId) {
|
|
841
|
+
try {
|
|
842
|
+
appendFocusEntry(focusedGoalId, legacyGoal?.id === focusedGoalId ? "migrated" : "selected");
|
|
843
|
+
} catch {}
|
|
844
|
+
}
|
|
845
|
+
for (const [id, current] of goalsById) {
|
|
846
|
+
if (current.status === "complete") {
|
|
847
|
+
goalsById.delete(id);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
clearStoppedRuntimeState();
|
|
851
|
+
runningGoalId = null;
|
|
852
|
+
syncGoalTools();
|
|
853
|
+
updateUI(ctx);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function setGoal(next: GoalRecord | null, ctx: ExtensionContext, shouldPersist = true, focusReason?: GoalFocusReason): void {
|
|
857
|
+
const previousGoalId = state.goal?.id ?? null;
|
|
858
|
+
state.goal = next;
|
|
859
|
+
const focusChanged = previousGoalId !== focusedGoalId;
|
|
860
|
+
if (focusChanged) {
|
|
861
|
+
clearContinuationState();
|
|
862
|
+
clearActiveAccounting();
|
|
863
|
+
resetGetGoalNudgeState(previousGoalId);
|
|
864
|
+
resetGetGoalNudgeState(focusedGoalId);
|
|
865
|
+
}
|
|
866
|
+
if (focusReason && focusChanged) appendFocusEntry(focusedGoalId, focusReason);
|
|
867
|
+
if (!state.goal || (state.goal.status !== "active") || !state.goal.autoContinue) {
|
|
868
|
+
clearContinuationState();
|
|
869
|
+
}
|
|
870
|
+
if (!state.goal || state.goal.status === "paused" || state.goal.status === "complete") {
|
|
871
|
+
clearActiveAccounting();
|
|
872
|
+
}
|
|
873
|
+
if (!state.goal || state.goal.id !== previousGoalId) {
|
|
874
|
+
// Drop any stale tweak-edit-gate that didn't belong to this goal.
|
|
875
|
+
if (tweakDraftingFor !== null && tweakDraftingFor !== state.goal?.id) tweakDraftingFor = null;
|
|
876
|
+
}
|
|
877
|
+
if (shouldPersist) persist(ctx);
|
|
878
|
+
else syncGoalTools();
|
|
879
|
+
updateUI(ctx);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function archiveCurrentGoal(ctx: ExtensionContext, reason: StopReason | undefined): GoalRecord | null {
|
|
883
|
+
if (!state.goal) return null;
|
|
884
|
+
let archived = mergeGoalPromptFromDisk(ctx, state.goal);
|
|
885
|
+
archived = { ...archived, status: archived.status === "complete" ? "complete" : "paused", stopReason: reason };
|
|
886
|
+
return archiveGoalFile(ctx, archived);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function stopActiveGoal(status: Exclude<GoalStatus, "active">, reason: StopReason | undefined, ctx: ExtensionContext): void {
|
|
890
|
+
if (!state.goal) return;
|
|
891
|
+
let next = mergeGoalPromptFromDisk(ctx, state.goal);
|
|
892
|
+
next = { ...next, status, stopReason: reason, updatedAt: nowIso() };
|
|
893
|
+
setGoal(next, ctx);
|
|
894
|
+
// Append ledger event for pauses (user or agent initiated)
|
|
895
|
+
if (status === "paused") {
|
|
896
|
+
try {
|
|
897
|
+
appendGoalEvent(ctx, {
|
|
898
|
+
type: "goal_paused",
|
|
899
|
+
goalId: next.id,
|
|
900
|
+
reason: reason ?? "unknown",
|
|
901
|
+
suggestedAction: next.pauseSuggestedAction,
|
|
902
|
+
status,
|
|
903
|
+
at: next.updatedAt,
|
|
904
|
+
});
|
|
905
|
+
} catch {
|
|
906
|
+
// Ledger append failure should not crash pause
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function pauseActiveGoal(ctx: ExtensionContext): void {
|
|
912
|
+
if (!state.goal || state.goal.status !== "active") return;
|
|
913
|
+
const pausedGoalId = state.goal.id;
|
|
914
|
+
// User-initiated pause (Esc / aborted turn). Clear any stale agent pause reason.
|
|
915
|
+
state.goal = { ...state.goal, autoContinue: false, pauseReason: undefined, pauseSuggestedAction: undefined };
|
|
916
|
+
stopActiveGoal("paused", "user", ctx);
|
|
917
|
+
resetGetGoalNudgeState(pausedGoalId);
|
|
918
|
+
ctx.ui.notify("Goal paused.", "info");
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function syncTerminalInputPause(ctx: ExtensionContext): void {
|
|
922
|
+
if (!ctx.hasUI) return;
|
|
923
|
+
terminalInputUnsubscribe?.();
|
|
924
|
+
terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => {
|
|
925
|
+
// If an audit is running, Escape aborts the audit instead of pausing.
|
|
926
|
+
// Must return { consume: true } so the TUI doesn't also process the key
|
|
927
|
+
// and abort the running tool execution, which would cascade into pausing
|
|
928
|
+
// the entire goal (agent_end sees ctx.signal?.aborted and calls pauseActiveGoal).
|
|
929
|
+
if (matchesKey(data, "escape") && auditProgress) {
|
|
930
|
+
abortAudit(ctx);
|
|
931
|
+
return { consume: true };
|
|
932
|
+
}
|
|
933
|
+
if (matchesKey(data, "escape") && state.goal?.status === "active" && state.goal.autoContinue) {
|
|
934
|
+
pauseActiveGoal(ctx);
|
|
935
|
+
return { consume: true };
|
|
936
|
+
}
|
|
937
|
+
return undefined;
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function sendQueuedContinuation(ctx: ExtensionContext, goalId: string): void {
|
|
942
|
+
continuationTimer = null;
|
|
943
|
+
continuationScheduledFor = null;
|
|
944
|
+
syncGoalTools();
|
|
945
|
+
if (!state.goal || state.goal.id !== goalId || state.goal.status !== "active" || !state.goal.autoContinue) {
|
|
946
|
+
if (continuationQueuedFor === goalId) continuationQueuedFor = null;
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
let ready: boolean;
|
|
951
|
+
try {
|
|
952
|
+
ready = !ctx.hasPendingMessages() && ctx.isIdle();
|
|
953
|
+
} catch {
|
|
954
|
+
if (continuationQueuedFor === goalId) continuationQueuedFor = null;
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (!ready) {
|
|
959
|
+
continuationScheduledFor = goalId;
|
|
960
|
+
continuationTimer = setTimeout(() => sendQueuedContinuation(ctx, goalId), CONTINUATION_IDLE_RETRY_MS);
|
|
961
|
+
continuationTimer.unref?.();
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
continuationQueuedFor = goalId;
|
|
965
|
+
pi.sendMessage<GoalEventDetails>(
|
|
966
|
+
{
|
|
967
|
+
customType: GOAL_EVENT_ENTRY,
|
|
968
|
+
content: continuationPrompt(state.goal),
|
|
969
|
+
display: false,
|
|
970
|
+
details: {
|
|
971
|
+
kind: "checkpoint",
|
|
972
|
+
goalId: state.goal.id,
|
|
973
|
+
status: state.goal.status,
|
|
974
|
+
objective: state.goal.objective,
|
|
975
|
+
timestamp: Date.now(),
|
|
976
|
+
},
|
|
977
|
+
},
|
|
978
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
function queueContinuation(ctx: ExtensionContext, force = false): void {
|
|
984
|
+
if (confirmationIntent !== null || tweakDraftingFor !== null) return;
|
|
985
|
+
if (!state.goal || state.goal.status !== "active" || !state.goal.autoContinue) return;
|
|
986
|
+
const goalId = state.goal.id;
|
|
987
|
+
if (!force && (continuationQueuedFor === goalId || continuationScheduledFor === goalId)) return;
|
|
988
|
+
clearContinuationTimer();
|
|
989
|
+
let delay = CONTINUATION_IDLE_RETRY_MS;
|
|
990
|
+
try {
|
|
991
|
+
delay = ctx.isIdle() && !ctx.hasPendingMessages() ? 0 : CONTINUATION_IDLE_RETRY_MS;
|
|
992
|
+
} catch {
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
continuationScheduledFor = goalId;
|
|
996
|
+
continuationTimer = setTimeout(() => sendQueuedContinuation(ctx, goalId), delay);
|
|
997
|
+
continuationTimer.unref?.();
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function replaceGoal(config: GoalCreationConfig, ctx: ExtensionContext, startNow = true): void {
|
|
1001
|
+
setGoal(createGoal(config), ctx, true, "created");
|
|
1002
|
+
beginAccounting();
|
|
1003
|
+
// Reset continuation nudge state — this is a fresh goal.
|
|
1004
|
+
resetGetGoalNudgeState(state.goal?.id);
|
|
1005
|
+
// A goal was committed — clear pending confirmation intent if any.
|
|
1006
|
+
confirmationIntent = null;
|
|
1007
|
+
ctx.ui.notify(buildGoalRunningNotification(config), "info");
|
|
1008
|
+
if (startNow && state.goal?.autoContinue) queueContinuation(ctx, true);
|
|
1009
|
+
// Append ledger event for durable history
|
|
1010
|
+
const created = state.goal;
|
|
1011
|
+
if (created) {
|
|
1012
|
+
try {
|
|
1013
|
+
appendGoalEvent(ctx, {
|
|
1014
|
+
type: "goal_created",
|
|
1015
|
+
goalId: created.id,
|
|
1016
|
+
objective: created.objective,
|
|
1017
|
+
sisyphus: created.sisyphus,
|
|
1018
|
+
autoContinue: created.autoContinue,
|
|
1019
|
+
at: created.createdAt,
|
|
1020
|
+
});
|
|
1021
|
+
} catch {
|
|
1022
|
+
// Ledger append failure should not crash creation
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
async function startGoalTweakDrafting(hint: string, ctx: ExtensionContext): Promise<void> {
|
|
1028
|
+
reconcileFocusedGoalFromDisk(ctx);
|
|
1029
|
+
clearContinuationState();
|
|
1030
|
+
clearActiveAccounting();
|
|
1031
|
+
if (!state.goal) {
|
|
1032
|
+
if (openGoals().length > 0) {
|
|
1033
|
+
const selected = await chooseOpenGoal(ctx, "Tweak which open goal?");
|
|
1034
|
+
if (!selected) return;
|
|
1035
|
+
} else {
|
|
1036
|
+
ctx.ui.notify("No goal is set. Use /goals or /sisyphus to discuss, or /goals-set / /sisyphus-set to start immediately.", "warning");
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
const currentGoal = state.goal;
|
|
1041
|
+
if (!currentGoal) return;
|
|
1042
|
+
if (currentGoal.status === "complete") {
|
|
1043
|
+
ctx.ui.notify("Goal is complete. Use /goals to discuss a new one or /goals-set to start immediately.", "warning");
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
syncGoalPromptFromDisk(ctx);
|
|
1047
|
+
persist(ctx);
|
|
1048
|
+
const trimmed = hint.trim();
|
|
1049
|
+
const focused = state.goal;
|
|
1050
|
+
if (!focused) return;
|
|
1051
|
+
const sisyphusOn = focused.sisyphus;
|
|
1052
|
+
const label = sisyphusOn ? "Sisyphus tweak drafting" : "Goal tweak drafting";
|
|
1053
|
+
// Activate the tweak edit-gate so apply_goal_tweak is callable.
|
|
1054
|
+
tweakDraftingFor = focused.id;
|
|
1055
|
+
syncGoalTools();
|
|
1056
|
+
ctx.ui.notify(
|
|
1057
|
+
`${label} started${trimmed ? `: ${truncateText(trimmed, 60)}` : ""}. The agent will interview you and then call apply_goal_tweak.`,
|
|
1058
|
+
"info",
|
|
1059
|
+
);
|
|
1060
|
+
const draftId = `tweak-${focused.id}-${Date.now().toString(36)}`;
|
|
1061
|
+
try {
|
|
1062
|
+
pi.sendMessage<GoalEventDetails>(
|
|
1063
|
+
{
|
|
1064
|
+
customType: GOAL_EVENT_ENTRY,
|
|
1065
|
+
content: goalTweakDraftingPrompt(focused, trimmed),
|
|
1066
|
+
display: false,
|
|
1067
|
+
details: {
|
|
1068
|
+
kind: "drafting",
|
|
1069
|
+
goalId: draftId,
|
|
1070
|
+
objective: trimmed,
|
|
1071
|
+
focus: sisyphusOn ? "sisyphus" : "goal",
|
|
1072
|
+
timestamp: Date.now(),
|
|
1073
|
+
},
|
|
1074
|
+
},
|
|
1075
|
+
{ triggerTurn: true, deliverAs: ctx.isIdle() ? "followUp" : "steer" },
|
|
1076
|
+
);
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
tweakDraftingFor = null;
|
|
1079
|
+
syncGoalTools();
|
|
1080
|
+
ctx.ui.notify(`Could not start goal tweak: ${(err as Error).message}`, "error");
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function startGoalDrafting(topic: string, focus: DraftingFocus, ctx: ExtensionContext): void {
|
|
1085
|
+
clearContinuationState();
|
|
1086
|
+
clearActiveAccounting();
|
|
1087
|
+
const trimmed = topic.trim();
|
|
1088
|
+
const label = focus === "sisyphus" ? "Sisyphus intent discussion" : "Goal intent discussion";
|
|
1089
|
+
const hint = focus === "sisyphus"
|
|
1090
|
+
? "The agent will research or grill the ordered plan as needed, then propose a draft for you to Confirm. No skipping, no rushing."
|
|
1091
|
+
: "The agent will clarify, research, or grill assumptions as needed, then propose a draft for you to Confirm.";
|
|
1092
|
+
ctx.ui.notify(
|
|
1093
|
+
`${label} started${trimmed ? `: ${truncateText(trimmed, 60)}` : ""}. ${hint}`,
|
|
1094
|
+
"info",
|
|
1095
|
+
);
|
|
1096
|
+
|
|
1097
|
+
confirmationIntent = {
|
|
1098
|
+
focus,
|
|
1099
|
+
originalTopic: trimmed,
|
|
1100
|
+
startedAt: Date.now(),
|
|
1101
|
+
};
|
|
1102
|
+
syncGoalTools();
|
|
1103
|
+
try {
|
|
1104
|
+
pi.sendUserMessage(goalDraftingPrompt(trimmed, focus), { deliverAs: ctx.isIdle() ? "followUp" : "steer" });
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
ctx.ui.notify(`Could not start ${label.toLowerCase()}: ${(err as Error).message}`, "error");
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
async function chooseOpenGoal(ctx: ExtensionContext, title: string): Promise<GoalRecord | null> {
|
|
1111
|
+
reconcileFocusedGoalFromDisk(ctx);
|
|
1112
|
+
if (state.goal && state.goal.status !== "complete") return state.goal;
|
|
1113
|
+
const open = openGoals();
|
|
1114
|
+
if (open.length === 0) return null;
|
|
1115
|
+
if (open.length === 1) {
|
|
1116
|
+
const only = open[0];
|
|
1117
|
+
if (!only) return null;
|
|
1118
|
+
setFocusedGoalId(only.id, ctx, "selected");
|
|
1119
|
+
return state.goal;
|
|
1120
|
+
}
|
|
1121
|
+
if (!ctx.hasUI) {
|
|
1122
|
+
ctx.ui.notify(buildUnfocusedOpenGoalsSummary(open.length), "warning");
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
const labels = open.map((item) => goalSelectorLabel(item, focusedGoalId));
|
|
1126
|
+
const byLabel = new Map(labels.map((label, index) => [label, open[index]?.id]));
|
|
1127
|
+
const selected = await ctx.ui.select(title, labels);
|
|
1128
|
+
const selectedId = selected ? byLabel.get(selected) : undefined;
|
|
1129
|
+
if (!selectedId) {
|
|
1130
|
+
ctx.ui.notify("Goal focus unchanged.", "info");
|
|
1131
|
+
return null;
|
|
1132
|
+
}
|
|
1133
|
+
setFocusedGoalId(selectedId, ctx, "selected");
|
|
1134
|
+
return state.goal;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
async function focusGoalCommand(ctx: ExtensionContext): Promise<void> {
|
|
1138
|
+
const open = openGoals();
|
|
1139
|
+
if (open.length === 0) {
|
|
1140
|
+
ctx.ui.notify("No open goals. Use /goals or /sisyphus to discuss, or /goals-set / /sisyphus-set to start immediately.", "warning");
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
if (open.length === 1) {
|
|
1144
|
+
const only = open[0];
|
|
1145
|
+
if (!only) return;
|
|
1146
|
+
setFocusedGoalId(only.id, ctx, "selected");
|
|
1147
|
+
armFocusedContinuation(ctx);
|
|
1148
|
+
ctx.ui.notify(`Focused goal: ${oneLineSummary(only)}`, "info");
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
if (!ctx.hasUI) {
|
|
1152
|
+
ctx.ui.notify(buildGoalListText(goalsById, focusedGoalId), "info");
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
const labels = open.map((item) => goalSelectorLabel(item, focusedGoalId));
|
|
1156
|
+
const byLabel = new Map(labels.map((label, index) => [label, open[index]?.id]));
|
|
1157
|
+
const selected = await ctx.ui.select("Focus open goal", labels);
|
|
1158
|
+
const selectedId = selected ? byLabel.get(selected) : undefined;
|
|
1159
|
+
if (!selectedId) {
|
|
1160
|
+
ctx.ui.notify("Goal focus unchanged.", "info");
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
setFocusedGoalId(selectedId, ctx, "selected");
|
|
1164
|
+
armFocusedContinuation(ctx);
|
|
1165
|
+
ctx.ui.notify(`Focused goal: ${oneLineSummary(state.goal)}`, "info");
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function handleGoalCommandTopic(rawTopic: string, ctx: ExtensionContext, focus: DraftingFocus, opts: { replace: boolean }): Promise<void> {
|
|
1169
|
+
const topic = rawTopic.trim();
|
|
1170
|
+
if (opts.replace) {
|
|
1171
|
+
const replacementTarget = await chooseOpenGoal(ctx, "Replace which open goal?");
|
|
1172
|
+
if (openGoals().length > 0 && !replacementTarget) return;
|
|
1173
|
+
archiveCurrentGoal(ctx, "user");
|
|
1174
|
+
setGoal(null, ctx, true, "cleared");
|
|
1175
|
+
}
|
|
1176
|
+
startGoalDrafting(topic, focus, ctx);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function handleDirectGoalSet(rawObjective: string, ctx: ExtensionContext, focus: DraftingFocus): void {
|
|
1180
|
+
const objective = rawObjective.trim();
|
|
1181
|
+
if (!objective) {
|
|
1182
|
+
const command = focus === "sisyphus" ? "/sisyphus-set" : "/goals-set";
|
|
1183
|
+
ctx.ui.notify(`No objective provided. Use ${command} <objective>.`, "warning");
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
clearContinuationState();
|
|
1187
|
+
clearActiveAccounting();
|
|
1188
|
+
confirmationIntent = null;
|
|
1189
|
+
syncGoalTools();
|
|
1190
|
+
replaceGoal({ objective, autoContinue: true, sisyphus: focus === "sisyphus" }, ctx, true);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
async function showGoalStatus(ctx: ExtensionContext): Promise<void> {
|
|
1194
|
+
reconcileFocusedGoalFromDisk(ctx);
|
|
1195
|
+
if (state.goal) syncGoalPromptFromDisk(ctx);
|
|
1196
|
+
const view = goalForDisplay() ?? state.goal;
|
|
1197
|
+
const otherCount = otherOpenGoalCount(goalsById, focusedGoalId);
|
|
1198
|
+
const extra = view && otherCount > 0 ? `\nOther open goals: ${otherCount} (run /goal-list or /goal-focus)` : "";
|
|
1199
|
+
const text = view ? `${detailedSummary(view)}${extra}` : openGoals().length > 0 ? buildUnfocusedOpenGoalsSummary(openGoals().length) : detailedSummary(null);
|
|
1200
|
+
ctx.ui.notify(text, "info");
|
|
1201
|
+
updateUI(ctx);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async function handleGoalPause(ctx: ExtensionContext): Promise<void> {
|
|
1205
|
+
reconcileFocusedGoalFromDisk(ctx);
|
|
1206
|
+
if (!state.goal) {
|
|
1207
|
+
if (openGoals().length > 0) {
|
|
1208
|
+
const selected = await chooseOpenGoal(ctx, "Pause which open goal?");
|
|
1209
|
+
if (!selected) return;
|
|
1210
|
+
} else {
|
|
1211
|
+
ctx.ui.notify("No goal is set.", "warning");
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
const currentGoal = state.goal;
|
|
1216
|
+
if (!currentGoal) return;
|
|
1217
|
+
if (currentGoal.status === "complete") {
|
|
1218
|
+
ctx.ui.notify("Goal is complete.", "warning");
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
if (currentGoal.status === "paused") {
|
|
1222
|
+
ctx.ui.notify("Goal is already paused. Use /goal-resume to continue.", "info");
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
pauseActiveGoal(ctx);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
async function handleGoalResume(ctx: ExtensionContext): Promise<void> {
|
|
1229
|
+
reconcileFocusedGoalFromDisk(ctx);
|
|
1230
|
+
if (!state.goal && openGoals().length > 0) {
|
|
1231
|
+
const selected = await chooseOpenGoal(ctx, "Resume or focus open goal");
|
|
1232
|
+
if (!selected) return;
|
|
1233
|
+
if (selected.status === "active") {
|
|
1234
|
+
armFocusedContinuation(ctx);
|
|
1235
|
+
ctx.ui.notify(`Goal focused: ${oneLineSummary(selected)}`, "info");
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
const resumeGate = validateResumeGoal(state.goal);
|
|
1240
|
+
if (!resumeGate.ok) {
|
|
1241
|
+
const level = resumeGate.message.includes("already running") ? "info" : "warning";
|
|
1242
|
+
ctx.ui.notify(resumeGate.message, level);
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
if (!state.goal) throw new Error("Goal disappeared during resume validation.");
|
|
1246
|
+
setGoal(
|
|
1247
|
+
{
|
|
1248
|
+
...mergeGoalPromptFromDisk(ctx, state.goal),
|
|
1249
|
+
status: "active",
|
|
1250
|
+
autoContinue: true,
|
|
1251
|
+
stopReason: undefined,
|
|
1252
|
+
pauseReason: undefined,
|
|
1253
|
+
pauseSuggestedAction: undefined,
|
|
1254
|
+
},
|
|
1255
|
+
ctx,
|
|
1256
|
+
);
|
|
1257
|
+
beginAccounting();
|
|
1258
|
+
resetGetGoalNudgeState(state.goal.id);
|
|
1259
|
+
ctx.ui.notify("Goal resumed.", "info");
|
|
1260
|
+
queueContinuation(ctx, true);
|
|
1261
|
+
// Append ledger event for resumption
|
|
1262
|
+
try {
|
|
1263
|
+
appendGoalEvent(ctx, {
|
|
1264
|
+
type: "goal_resumed",
|
|
1265
|
+
goalId: state.goal.id,
|
|
1266
|
+
reason: "user",
|
|
1267
|
+
at: nowIso(),
|
|
1268
|
+
});
|
|
1269
|
+
} catch {
|
|
1270
|
+
// Ledger append failure should not crash resume
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function auditorConfigValue(config: GoalAuditorConfig, key: keyof GoalAuditorConfig): string {
|
|
1275
|
+
if (key === "disabled") return config.disabled === true ? "true" : "false";
|
|
1276
|
+
return config[key] ?? "(default)";
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function auditorSettingsLines(config: GoalAuditorConfig): string[] {
|
|
1280
|
+
return [
|
|
1281
|
+
`disabled: ${auditorConfigValue(config, "disabled")}`,
|
|
1282
|
+
`provider: ${auditorConfigValue(config, "provider")}`,
|
|
1283
|
+
`model: ${auditorConfigValue(config, "model")}`,
|
|
1284
|
+
`thinking_level: ${auditorConfigValue(config, "thinkingLevel")}`,
|
|
1285
|
+
];
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
async function handleGoalAuditorSettings(ctx: ExtensionContext): Promise<void> {
|
|
1289
|
+
if (!ctx.hasUI) {
|
|
1290
|
+
ctx.ui.notify(`Goal auditor settings file: ${goalAuditorConfigPath(ctx.cwd)}`, "info");
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const fieldLabels = ["disabled", "provider", "model", "thinking_level"] as const;
|
|
1294
|
+
while (true) {
|
|
1295
|
+
const config = loadGoalAuditorFileConfig(ctx.cwd);
|
|
1296
|
+
const options = [
|
|
1297
|
+
`disabled: ${auditorConfigValue(config, "disabled")}`,
|
|
1298
|
+
`provider: ${auditorConfigValue(config, "provider")}`,
|
|
1299
|
+
`model: ${auditorConfigValue(config, "model")}`,
|
|
1300
|
+
`thinking_level: ${auditorConfigValue(config, "thinkingLevel")}`,
|
|
1301
|
+
];
|
|
1302
|
+
const selected = await ctx.ui.select("Goal auditor settings", options);
|
|
1303
|
+
if (!selected) return;
|
|
1304
|
+
const index = options.indexOf(selected);
|
|
1305
|
+
const field = fieldLabels[index];
|
|
1306
|
+
if (!field) return;
|
|
1307
|
+
const key = field === "thinking_level" ? "thinkingLevel" : field;
|
|
1308
|
+
if (key === "disabled") {
|
|
1309
|
+
// Toggle the disabled flag
|
|
1310
|
+
const next: GoalAuditorConfig = { ...config, disabled: !config.disabled };
|
|
1311
|
+
saveGoalAuditorFileConfig(ctx.cwd, next);
|
|
1312
|
+
ctx.ui.notify(`Goal auditor settings saved:\n${auditorSettingsLines(loadGoalAuditorFileConfig(ctx.cwd)).join("\n")}`, "info");
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
const currentValue = auditorConfigValue(config, key as keyof GoalAuditorConfig);
|
|
1316
|
+
const input = await ctx.ui.input(`Set auditor ${field}`, currentValue === "(default)" ? "Leave empty for default" : currentValue);
|
|
1317
|
+
if (input === undefined) continue;
|
|
1318
|
+
const next: GoalAuditorConfig = { ...config };
|
|
1319
|
+
const trimmed = input.trim();
|
|
1320
|
+
if (!trimmed) {
|
|
1321
|
+
delete next[key as keyof GoalAuditorConfig];
|
|
1322
|
+
} else if (key === "thinkingLevel") {
|
|
1323
|
+
if (!["off", "minimal", "low", "medium", "high", "xhigh"].includes(trimmed)) {
|
|
1324
|
+
ctx.ui.notify("thinking_level must be one of: off, minimal, low, medium, high, xhigh", "warning");
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
next.thinkingLevel = trimmed as GoalAuditorConfig["thinkingLevel"];
|
|
1328
|
+
} else if (key === "provider" || key === "model") {
|
|
1329
|
+
next[key] = trimmed;
|
|
1330
|
+
}
|
|
1331
|
+
saveGoalAuditorFileConfig(ctx.cwd, next);
|
|
1332
|
+
ctx.ui.notify(`Goal auditor settings saved:\n${auditorSettingsLines(loadGoalAuditorFileConfig(ctx.cwd)).join("\n")}`, "info");
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
async function handleGoalSettings(ctx: ExtensionContext): Promise<void> {
|
|
1337
|
+
if (!ctx.hasUI) {
|
|
1338
|
+
ctx.ui.notify(`Goal settings require UI. Auditor config file: ${goalAuditorConfigPath(ctx.cwd)}`, "warning");
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
const selected = await ctx.ui.select("Goal settings", ["auditor"]);
|
|
1342
|
+
if (selected === "auditor") await handleGoalAuditorSettings(ctx);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
async function handleGoalClear(ctx: ExtensionContext): Promise<void> {
|
|
1346
|
+
if (confirmationIntent !== null || tweakDraftingFor !== null) {
|
|
1347
|
+
confirmationIntent = null;
|
|
1348
|
+
tweakDraftingFor = null;
|
|
1349
|
+
syncGoalTools();
|
|
1350
|
+
updateUI(ctx);
|
|
1351
|
+
ctx.ui.notify(clearGoalCommandMessage({ archived: false, wasDrafting: true }), "info");
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
reconcileFocusedGoalFromDisk(ctx);
|
|
1355
|
+
if (!state.goal && openGoals().length > 0) {
|
|
1356
|
+
const selected = await chooseOpenGoal(ctx, "Clear which open goal?");
|
|
1357
|
+
if (!selected) return;
|
|
1358
|
+
}
|
|
1359
|
+
const archived = archiveCurrentGoal(ctx, "user");
|
|
1360
|
+
const didArchive = !!archived;
|
|
1361
|
+
resetGetGoalNudgeState(state.goal?.id);
|
|
1362
|
+
setGoal(null, ctx, true, "cleared");
|
|
1363
|
+
// Phase 5 D: also abort any in-flight drafting so the agent's next turn
|
|
1364
|
+
// doesn't try to propose into a cleared slot.
|
|
1365
|
+
const wasDrafting = confirmationIntent !== null;
|
|
1366
|
+
confirmationIntent = null;
|
|
1367
|
+
syncGoalTools();
|
|
1368
|
+
const msg = clearGoalCommandMessage({ archived: didArchive, wasDrafting });
|
|
1369
|
+
ctx.ui.notify(msg, didArchive || wasDrafting ? "info" : "warning");
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
async function handleGoalAbort(ctx: ExtensionContext): Promise<void> {
|
|
1373
|
+
if (confirmationIntent !== null || tweakDraftingFor !== null) {
|
|
1374
|
+
confirmationIntent = null;
|
|
1375
|
+
tweakDraftingFor = null;
|
|
1376
|
+
syncGoalTools();
|
|
1377
|
+
updateUI(ctx);
|
|
1378
|
+
ctx.ui.notify(abortGoalCommandMessage({ archived: false, wasDrafting: true }), "info");
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
reconcileFocusedGoalFromDisk(ctx);
|
|
1382
|
+
if (!state.goal && openGoals().length > 0) {
|
|
1383
|
+
const selected = await chooseOpenGoal(ctx, "Abort which open goal?");
|
|
1384
|
+
if (!selected) return;
|
|
1385
|
+
}
|
|
1386
|
+
const archived = archiveCurrentGoal(ctx, "user");
|
|
1387
|
+
const didArchive = !!archived;
|
|
1388
|
+
resetGetGoalNudgeState(state.goal?.id);
|
|
1389
|
+
setGoal(null, ctx, true, "aborted");
|
|
1390
|
+
const wasDrafting = confirmationIntent !== null;
|
|
1391
|
+
confirmationIntent = null;
|
|
1392
|
+
syncGoalTools();
|
|
1393
|
+
const msg = abortGoalCommandMessage({ archived: didArchive, wasDrafting });
|
|
1394
|
+
ctx.ui.notify(msg, didArchive || wasDrafting ? "info" : "warning");
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
pi.registerMessageRenderer<GoalEventDetails>(GOAL_EVENT_ENTRY, renderGoalEvent);
|
|
1398
|
+
pi.registerMessageRenderer<GoalAuditEventDetails>(GOAL_AUDIT_ENTRY, renderGoalAuditEvent);
|
|
1399
|
+
|
|
1400
|
+
// /goal and /goal-status: read-only status display.
|
|
1401
|
+
const statusCommand = {
|
|
1402
|
+
description: "Show the current goal: objective, status, sisyphus mode, usage.",
|
|
1403
|
+
handler: async (_rawArgs: string, ctx: ExtensionContext) => {
|
|
1404
|
+
await showGoalStatus(ctx);
|
|
1405
|
+
},
|
|
1406
|
+
};
|
|
1407
|
+
pi.registerCommand("goal", {
|
|
1408
|
+
description: "Show focused goal status. Discuss with /goals or /sisyphus; direct-start with /goals-set or /sisyphus-set; manage with /goal-list, /goal-focus, /goal-settings, /goal-tweak, /goal-clear, /goal-abort, /goal-pause, /goal-resume.",
|
|
1409
|
+
handler: statusCommand.handler,
|
|
1410
|
+
});
|
|
1411
|
+
pi.registerCommand("goal-status", statusCommand);
|
|
1412
|
+
pi.registerCommand("goal-list", {
|
|
1413
|
+
description: "List all open pi goals and show which one this session is focused on.",
|
|
1414
|
+
handler: async (_rawArgs, ctx) => {
|
|
1415
|
+
reconcileFocusedGoalFromDisk(ctx);
|
|
1416
|
+
ctx.ui.notify(buildGoalListText(goalsById, focusedGoalId), "info");
|
|
1417
|
+
updateUI(ctx);
|
|
1418
|
+
},
|
|
1419
|
+
});
|
|
1420
|
+
pi.registerCommand("goal-focus", {
|
|
1421
|
+
description: "Choose which open goal this session should focus on.",
|
|
1422
|
+
handler: async (_rawArgs, ctx) => {
|
|
1423
|
+
await focusGoalCommand(ctx);
|
|
1424
|
+
},
|
|
1425
|
+
});
|
|
1426
|
+
pi.registerCommand("goal-settings", {
|
|
1427
|
+
description: "Open pi-goal settings, including auditor provider/model/thinking_level.",
|
|
1428
|
+
handler: async (_rawArgs, ctx) => {
|
|
1429
|
+
await handleGoalSettings(ctx);
|
|
1430
|
+
},
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
// /goals <topic>: discussion/research/grilling -> confirmed normal goal draft.
|
|
1434
|
+
pi.registerCommand("goals", {
|
|
1435
|
+
description: "Discuss a new goal. The agent clarifies, researches, or grills assumptions, then proposes a draft for confirmation.",
|
|
1436
|
+
handler: async (rawArgs, ctx) => {
|
|
1437
|
+
await handleGoalCommandTopic(rawArgs, ctx, "goal", { replace: false });
|
|
1438
|
+
},
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
// /sisyphus <topic>: discussion/grilling -> confirmed Sisyphus goal draft.
|
|
1442
|
+
pi.registerCommand("sisyphus", {
|
|
1443
|
+
description: "Discuss a Sisyphus goal. The agent grills ordered steps, done criteria, blockers, and boundaries before proposing a draft.",
|
|
1444
|
+
handler: async (rawArgs: string, ctx: ExtensionContext) => {
|
|
1445
|
+
await handleGoalCommandTopic(rawArgs, ctx, "sisyphus", { replace: false });
|
|
1446
|
+
},
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
// /goals-set <objective> and /sisyphus-set <objective>: direct creation, no drafting discussion.
|
|
1450
|
+
pi.registerCommand("goals-set", {
|
|
1451
|
+
description: "Immediately create and start a normal goal from the supplied objective. No draft discussion.",
|
|
1452
|
+
handler: async (rawArgs, ctx) => {
|
|
1453
|
+
handleDirectGoalSet(rawArgs, ctx, "goal");
|
|
1454
|
+
},
|
|
1455
|
+
});
|
|
1456
|
+
pi.registerCommand("sisyphus-set", {
|
|
1457
|
+
description: "Immediately create and start a Sisyphus goal from the supplied objective. No draft discussion.",
|
|
1458
|
+
handler: async (rawArgs, ctx) => {
|
|
1459
|
+
handleDirectGoalSet(rawArgs, ctx, "sisyphus");
|
|
1460
|
+
},
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
// /goal-tweak [hint]: drafting on top of the current goal -> edits the active goal file.
|
|
1464
|
+
pi.registerCommand("goal-tweak", {
|
|
1465
|
+
description: "Refine the current goal via a drafting interview. The agent asks what to change, then edits the active goal file with the revised objective.",
|
|
1466
|
+
handler: async (rawArgs, ctx) => {
|
|
1467
|
+
await startGoalTweakDrafting(rawArgs, ctx);
|
|
1468
|
+
},
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
// /goal-clear: archive the current goal.
|
|
1472
|
+
pi.registerCommand("goal-clear", {
|
|
1473
|
+
description: "Archive the current goal.",
|
|
1474
|
+
handler: async (_rawArgs, ctx) => {
|
|
1475
|
+
await handleGoalClear(ctx);
|
|
1476
|
+
},
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
// /goal-abort: abandon and archive the current goal, or cancel drafting.
|
|
1480
|
+
pi.registerCommand("goal-abort", {
|
|
1481
|
+
description: "Abort the current goal and archive it, or cancel an in-progress drafting flow.",
|
|
1482
|
+
handler: async (_rawArgs, ctx) => {
|
|
1483
|
+
await handleGoalAbort(ctx);
|
|
1484
|
+
},
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
// /goal-pause: pause the currently running goal.
|
|
1488
|
+
pi.registerCommand("goal-pause", {
|
|
1489
|
+
description: "Pause the currently running goal. Esc also pauses while a goal is running.",
|
|
1490
|
+
handler: async (_rawArgs, ctx) => {
|
|
1491
|
+
await handleGoalPause(ctx);
|
|
1492
|
+
},
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
// /goal-resume: resume a paused goal.
|
|
1496
|
+
pi.registerCommand("goal-resume", {
|
|
1497
|
+
description: "Resume a paused goal.",
|
|
1498
|
+
handler: async (_rawArgs, ctx) => {
|
|
1499
|
+
await handleGoalResume(ctx);
|
|
1500
|
+
},
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
registerQuestionnaireTools(pi);
|
|
1505
|
+
|
|
1506
|
+
pi.registerTool(defineTool({
|
|
1507
|
+
name: "get_goal",
|
|
1508
|
+
label: "Get Goal",
|
|
1509
|
+
description: "Get the current pi goal for this session: objective, status, auto-continue, usage, and local file paths.",
|
|
1510
|
+
promptSnippet: "Read the active pi goal state for the current session.",
|
|
1511
|
+
promptGuidelines: [
|
|
1512
|
+
"Use get_goal when you need the current goal before deciding whether to continue or mark it complete.",
|
|
1513
|
+
"Before marking a goal complete, compare every explicit requirement with concrete evidence from the workspace/session.",
|
|
1514
|
+
"If the returned goal has sisyphus mode on, you must execute strictly step-by-step in the order written in the objective; do not skip, combine, or rush steps, and stop to ask the user when blocked or unclear.",
|
|
1515
|
+
],
|
|
1516
|
+
parameters: Type.Object({}),
|
|
1517
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
1518
|
+
reconcileFocusedGoalFromDisk(ctx);
|
|
1519
|
+
if (state.goal) syncGoalPromptFromDisk(ctx);
|
|
1520
|
+
syncGoalTools();
|
|
1521
|
+
const view = goalForDisplay() ?? state.goal;
|
|
1522
|
+
const otherCount = otherOpenGoalCount(goalsById, focusedGoalId);
|
|
1523
|
+
let nudge = "";
|
|
1524
|
+
if (view && view.status === "active" && view.id) {
|
|
1525
|
+
const prior = activeGetGoalTurnsByGoalId.get(view.id) ?? 0;
|
|
1526
|
+
if (prior >= 2) {
|
|
1527
|
+
nudge = "\n\n[NUDGE] You have called get_goal multiple times this turn. Prefer concrete work tools (write/read/bash/edit) to advance the goal.";
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
const lifecycleHint = view && (view.status === "active" || view.status === "paused")
|
|
1531
|
+
? "\nLifecycle tools: if evidence proves the objective is satisfied, call update_goal({status: \"complete\"}); if blocked, call pause_goal({reason, suggestedAction?}); if abandoned/obsolete/unsafe, call abort_goal({reason}). For file or shell work, use the normal work tools directly (write/read/bash/edit); do not call get_goal repeatedly just to look for tools."
|
|
1532
|
+
: "";
|
|
1533
|
+
const text = view
|
|
1534
|
+
? `${detailedSummary(view)}${lifecycleHint}${nudge}${otherCount > 0 ? `\nOther open goals: ${otherCount} (human can run /goal-list or /goal-focus)` : ""}`
|
|
1535
|
+
: openGoals().length > 0
|
|
1536
|
+
? buildUnfocusedOpenGoalsSummary(openGoals().length)
|
|
1537
|
+
: detailedSummary(null);
|
|
1538
|
+
return {
|
|
1539
|
+
content: [{ type: "text", text }],
|
|
1540
|
+
details: goalDetails(view),
|
|
1541
|
+
};
|
|
1542
|
+
},
|
|
1543
|
+
renderCall(_args, theme) {
|
|
1544
|
+
return new Text(theme.fg("toolTitle", "get_goal"), 0, 0);
|
|
1545
|
+
},
|
|
1546
|
+
renderResult(result, _options, theme) {
|
|
1547
|
+
return renderGoalResult(result, theme);
|
|
1548
|
+
},
|
|
1549
|
+
}));
|
|
1550
|
+
|
|
1551
|
+
pi.registerTool(defineTool({
|
|
1552
|
+
name: "create_goal",
|
|
1553
|
+
label: "Create Goal",
|
|
1554
|
+
description: "Create a new active pi goal and focus it. Hidden outside drafting flows; propose_goal_draft is the normal commit path.",
|
|
1555
|
+
promptSnippet: "Create a persistent pi goal when the user explicitly asks for one or when a goal-drafting interview has converged.",
|
|
1556
|
+
promptGuidelines: [
|
|
1557
|
+
"Use create_goal only when the user explicitly asks to start a long-running goal, OR when a /goals or /sisyphus intent discussion has produced a concrete objective.",
|
|
1558
|
+
"Creating a new goal focuses it and leaves other open goals untouched. Do not archive or replace existing goals unless the user explicitly asks through a user command.",
|
|
1559
|
+
"Pass sisyphus=true only when the goal came out of /sisyphus intent discussion or /sisyphus-set, or when the user explicitly invoked Sisyphus mode.",
|
|
1560
|
+
],
|
|
1561
|
+
parameters: Type.Object({
|
|
1562
|
+
objective: Type.String({ description: "Concrete objective to pursue. For Sisyphus goals this MUST be the full plan including numbered steps and per-step done criteria." }),
|
|
1563
|
+
autoContinue: Type.Optional(Type.Boolean({ description: "Whether pi should keep sending continuation prompts until complete. Defaults to true." })),
|
|
1564
|
+
sisyphus: Type.Optional(Type.Boolean({ description: "When true, mark this as a Sisyphus goal: the agent must execute strictly step-by-step, no skipping, no rushing, no improvising. Default false." })),
|
|
1565
|
+
}),
|
|
1566
|
+
executionMode: "sequential",
|
|
1567
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1568
|
+
return {
|
|
1569
|
+
content: [{ type: "text", text: "create_goal REJECTED: direct agent creation is disabled. Use /goals or /sisyphus with propose_goal_draft for confirmation, or have the user invoke /goals-set or /sisyphus-set for immediate creation." }],
|
|
1570
|
+
details: goalDetails(state.goal),
|
|
1571
|
+
};
|
|
1572
|
+
},
|
|
1573
|
+
renderCall(args, theme) {
|
|
1574
|
+
const prefix = args?.sisyphus ? "create_goal sisyphus " : "create_goal ";
|
|
1575
|
+
return new Text(theme.fg("toolTitle", prefix) + theme.fg("muted", args?.objective ?? ""), 0, 0);
|
|
1576
|
+
},
|
|
1577
|
+
renderResult(result, _options, theme) {
|
|
1578
|
+
return renderGoalResult(result, theme);
|
|
1579
|
+
},
|
|
1580
|
+
}));
|
|
1581
|
+
|
|
1582
|
+
// Agent's goal-confirmation entry point. Shows the user a full plain-text
|
|
1583
|
+
// draft report with two choices: [Confirm] (creates the goal) or
|
|
1584
|
+
// [Continue Chatting] (returns control to the agent for more clarification).
|
|
1585
|
+
// Schema gates enforce focus-vs-sisyphus consistency; draftId is ignored for
|
|
1586
|
+
// one-release compatibility with older prompt residue.
|
|
1587
|
+
// In headless mode (no UI), auto-confirms — harness-friendly.
|
|
1588
|
+
pi.registerTool(defineTool({
|
|
1589
|
+
name: PROPOSE_DRAFT_TOOL_NAME,
|
|
1590
|
+
label: "Propose Goal Draft",
|
|
1591
|
+
description: "During /goals or /sisyphus intent discussion, propose the goal draft to the user. The user sees a full plain-text confirmation report and chooses Confirm (creates the goal) or Continue Chatting (returns control to you to refine). REPLACES create_goal during discussion-based creation.",
|
|
1592
|
+
promptSnippet: "Propose the drafted goal to the user with a full plain-text Confirm / Continue Chatting dialog.",
|
|
1593
|
+
promptGuidelines: [
|
|
1594
|
+
"Call propose_goal_draft when a /goals or /sisyphus intent discussion has enough information to write a concrete goal. Ask a focused question only when the request is still ambiguous.",
|
|
1595
|
+
"If an answer exposes ambiguity, keep interviewing the user — do not propose prematurely.",
|
|
1596
|
+
"The user will see a full plain-text draft report plus a [Confirm] / [Continue Chatting] choice. Confirm creates the goal; Continue Chatting returns control to you to ask follow-up questions.",
|
|
1597
|
+
"If the tool returns 'continue chatting', ask the user what they want changed. Do NOT propose again immediately with the same content; iterate based on their feedback first.",
|
|
1598
|
+
"The sisyphus field must match the user's confirmation focus: /sisyphus -> sisyphus=true, /goals -> sisyphus=false. The schema enforces this; mismatched proposals are REJECTED.",
|
|
1599
|
+
"For sisyphus goals, preserve the user's requested ordered style and completion standard. Do not add reconnaissance/preflight steps, merge steps, reorder steps, or change the mode without explicit user confirmation.",
|
|
1600
|
+
"create_goal is rejected; propose_goal_draft is the confirmation path. This is intentional — the user wants explicit say in goal creation.",
|
|
1601
|
+
],
|
|
1602
|
+
parameters: Type.Object({
|
|
1603
|
+
objective: Type.String({ description: "Full goal text. For Sisyphus goals this MUST include the user's numbered steps + per-step done criteria, taken faithfully from the user's input." }),
|
|
1604
|
+
autoContinue: Type.Optional(Type.Boolean({ description: "Whether pi should keep sending continuation prompts until complete. Default true." })),
|
|
1605
|
+
sisyphus: Type.Optional(Type.Boolean({ description: "Must equal true for /sisyphus discussion, false for /goals discussion. Schema-enforced via B1 gate." })),
|
|
1606
|
+
draftId: Type.Optional(Type.String({ description: "Deprecated compatibility field. It is accepted but ignored; current goal confirmation no longer depends on hidden draft ids." })),
|
|
1607
|
+
}),
|
|
1608
|
+
executionMode: "sequential",
|
|
1609
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1610
|
+
const validation = validateGoalDraftProposal({
|
|
1611
|
+
intent: confirmationIntent,
|
|
1612
|
+
hasUnfinishedGoal: !!state.goal && state.goal.status !== "complete",
|
|
1613
|
+
objective: params.objective,
|
|
1614
|
+
sisyphus: params.sisyphus,
|
|
1615
|
+
draftId: params.draftId,
|
|
1616
|
+
});
|
|
1617
|
+
if (!validation.ok) {
|
|
1618
|
+
if (validation.clearDrafting) {
|
|
1619
|
+
confirmationIntent = null;
|
|
1620
|
+
syncGoalTools();
|
|
1621
|
+
}
|
|
1622
|
+
return {
|
|
1623
|
+
content: [{ type: "text", text: validation.message }],
|
|
1624
|
+
details: goalDetails(state.goal),
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
const activeIntent = confirmationIntent;
|
|
1628
|
+
if (!activeIntent) throw new Error("Goal confirmation intent disappeared during proposal validation.");
|
|
1629
|
+
|
|
1630
|
+
// All schema gates passed. Decide how to confirm.
|
|
1631
|
+
const objective = validation.objective;
|
|
1632
|
+
const autoContinueFlag = params.autoContinue ?? true;
|
|
1633
|
+
const sisyphusFlag = validation.expectedSisyphus;
|
|
1634
|
+
const draftSummary = buildDraftConfirmationText({
|
|
1635
|
+
focus: activeIntent.focus,
|
|
1636
|
+
originalTopic: activeIntent.originalTopic,
|
|
1637
|
+
objective,
|
|
1638
|
+
autoContinue: autoContinueFlag,
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
const headless = shouldAutoConfirmProposal({ hasUI: ctx.hasUI, autoConfirmEnv: process.env.PI_GOAL_AUTO_CONFIRM });
|
|
1642
|
+
|
|
1643
|
+
let decision: "confirm" | "continue";
|
|
1644
|
+
if (headless) {
|
|
1645
|
+
// Headless: auto-confirm (tests and non-TUI sessions).
|
|
1646
|
+
decision = "confirm";
|
|
1647
|
+
} else {
|
|
1648
|
+
// TUI: show overlay dialog.
|
|
1649
|
+
try {
|
|
1650
|
+
decision = await showProposalDialog(ctx, draftSummary, activeIntent.focus);
|
|
1651
|
+
} catch (err) {
|
|
1652
|
+
const message = proposalDialogFailureMessage(err);
|
|
1653
|
+
ctx.ui.notify(message, "error");
|
|
1654
|
+
return {
|
|
1655
|
+
content: [{ type: "text", text: message }],
|
|
1656
|
+
details: goalDetails(state.goal),
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
if (decision === "confirm") {
|
|
1662
|
+
const config: GoalCreationConfig = {
|
|
1663
|
+
objective,
|
|
1664
|
+
autoContinue: autoContinueFlag,
|
|
1665
|
+
sisyphus: sisyphusFlag,
|
|
1666
|
+
};
|
|
1667
|
+
confirmationIntent = null;
|
|
1668
|
+
replaceGoal(config, ctx, false);
|
|
1669
|
+
syncGoalTools();
|
|
1670
|
+
return {
|
|
1671
|
+
content: [{ type: "text", text: buildGoalCreatedReport({ objective, detailedSummary: detailedSummary(state.goal) }) }],
|
|
1672
|
+
details: goalDetails(state.goal),
|
|
1673
|
+
terminate: true,
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
// "continue" — user wants to keep chatting. Drafting state stays armed.
|
|
1677
|
+
return {
|
|
1678
|
+
content: [{
|
|
1679
|
+
type: "text",
|
|
1680
|
+
text: "User clicked 'Continue Chatting'. The goal was NOT created. Ask the user what they want to change about the draft (objective, scope, criteria, steps), then revise and call propose_goal_draft again. Do not call propose_goal_draft again with the same content — wait for the user's input first.",
|
|
1681
|
+
}],
|
|
1682
|
+
details: goalDetails(state.goal),
|
|
1683
|
+
};
|
|
1684
|
+
},
|
|
1685
|
+
renderCall(args, theme) {
|
|
1686
|
+
const prefix = args?.sisyphus ? "propose_goal_draft sisyphus " : "propose_goal_draft ";
|
|
1687
|
+
return new Text(theme.fg("toolTitle", prefix) + theme.fg("muted", truncateText(args?.objective ?? "", 80)), 0, 0);
|
|
1688
|
+
},
|
|
1689
|
+
renderResult(result, _options, theme) {
|
|
1690
|
+
return renderGoalResult(result, theme);
|
|
1691
|
+
},
|
|
1692
|
+
}));
|
|
1693
|
+
|
|
1694
|
+
pi.registerTool(defineTool({
|
|
1695
|
+
name: "update_goal",
|
|
1696
|
+
label: "Update Goal",
|
|
1697
|
+
description: "Mark the current active or paused pi goal complete when the objective is actually achieved.",
|
|
1698
|
+
promptSnippet: "Mark the active or paused pi goal complete when the objective is achieved.",
|
|
1699
|
+
promptGuidelines: [
|
|
1700
|
+
"Use update_goal with status=complete only when the pi goal objective has actually been achieved and no required work remains.",
|
|
1701
|
+
"Before calling update_goal, summarize the evidence you believe proves completion; the tool will launch an independent pi auditor agent to inspect the workspace and judge the claim.",
|
|
1702
|
+
"The auditor is authoritative: completion is archived only if the auditor report ends with <approved/>. If it ends with <disapproved/> or no approval marker, update_goal is rejected and the goal remains open.",
|
|
1703
|
+
"Do not call update_goal merely because work is stopping, substantial progress was made, or tests passed without covering every requirement.",
|
|
1704
|
+
"Do not use update_goal=complete as an escape hatch when you are blocked. If you are blocked, call pause_goal({reason, suggestedAction?}) instead so the user can intervene.",
|
|
1705
|
+
"For sisyphus goals, do not mark complete until every numbered step has been executed and individually verified against its done criterion.",
|
|
1706
|
+
],
|
|
1707
|
+
parameters: Type.Object({
|
|
1708
|
+
status: StringEnum([COMPLETE_STATUS] as const, { description: "Set to complete only when the objective is achieved." }),
|
|
1709
|
+
completionSummary: Type.Optional(Type.String({ description: "Concise completion claim and evidence summary passed to the independent auditor agent." })),
|
|
1710
|
+
confirmBypassAuditor: Type.Optional(Type.Boolean({ description: "Set to true to confirm bypassing the independent auditor when it is disabled in settings." })),
|
|
1711
|
+
}),
|
|
1712
|
+
executionMode: "sequential",
|
|
1713
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
1714
|
+
reconcileFocusedGoalFromDisk(ctx);
|
|
1715
|
+
if (params.status !== COMPLETE_STATUS) throw new Error("update_goal only supports status=complete.");
|
|
1716
|
+
const completionGate = validateGoalCompletion({ goal: state.goal, runningGoalId });
|
|
1717
|
+
if (!completionGate.ok) {
|
|
1718
|
+
return {
|
|
1719
|
+
content: [{ type: "text", text: completionGate.message }],
|
|
1720
|
+
details: goalDetails(state.goal),
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
if (!state.goal) throw new Error("Goal disappeared during completion validation.");
|
|
1724
|
+
const auditTarget = mergeGoalPromptFromDisk(ctx, state.goal);
|
|
1725
|
+
// Append ledger: completion requested
|
|
1726
|
+
try {
|
|
1727
|
+
appendGoalEvent(ctx, {
|
|
1728
|
+
type: "completion_requested",
|
|
1729
|
+
goalId: auditTarget.id,
|
|
1730
|
+
summary: params.completionSummary,
|
|
1731
|
+
at: nowIso(),
|
|
1732
|
+
});
|
|
1733
|
+
} catch {
|
|
1734
|
+
// Ledger append failure should not block completion
|
|
1735
|
+
}
|
|
1736
|
+
const auditorConfig = loadGoalAuditorFileConfig(ctx.cwd);
|
|
1737
|
+
const auditorLabel = auditorConfig.provider || auditorConfig.model || auditorConfig.thinkingLevel
|
|
1738
|
+
? `${auditorConfig.provider ?? "default"}/${auditorConfig.model ?? "default"}${auditorConfig.thinkingLevel ? `:${auditorConfig.thinkingLevel}` : ""}`
|
|
1739
|
+
: "default";
|
|
1740
|
+
|
|
1741
|
+
// Check if auditor is disabled
|
|
1742
|
+
if (auditorConfig.disabled === true) {
|
|
1743
|
+
if (params.confirmBypassAuditor !== true) {
|
|
1744
|
+
return {
|
|
1745
|
+
content: [{ type: "text", text: [
|
|
1746
|
+
"The completion auditor is disabled in settings.",
|
|
1747
|
+
"",
|
|
1748
|
+
`Use \`goal_question\` to ask the user: "The independent completion auditor is disabled. Bypass independent verification and mark the goal complete?"`,
|
|
1749
|
+
"If the user confirms, call update_goal again with confirmBypassAuditor: true.",
|
|
1750
|
+
].join("\n") }],
|
|
1751
|
+
details: goalDetails(state.goal),
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
// Auditor disabled and confirmed — skip audit, complete immediately
|
|
1755
|
+
await pi.sendMessage<GoalAuditEventDetails>({
|
|
1756
|
+
customType: GOAL_AUDIT_ENTRY,
|
|
1757
|
+
content: `Auditor disabled — completion bypassed for goal ${auditTarget.id}.`,
|
|
1758
|
+
display: true,
|
|
1759
|
+
details: { phase: "skipped", goalId: auditTarget.id, auditor: auditorLabel },
|
|
1760
|
+
}, { triggerTurn: true });
|
|
1761
|
+
try {
|
|
1762
|
+
appendGoalEvent(ctx, {
|
|
1763
|
+
type: "audit_skipped",
|
|
1764
|
+
goalId: auditTarget.id,
|
|
1765
|
+
reason: "disabled",
|
|
1766
|
+
provider: auditorConfig.provider,
|
|
1767
|
+
model: auditorConfig.model,
|
|
1768
|
+
thinkingLevel: auditorConfig.thinkingLevel,
|
|
1769
|
+
at: nowIso(),
|
|
1770
|
+
});
|
|
1771
|
+
} catch {
|
|
1772
|
+
// Ledger append failure should not block completion
|
|
1773
|
+
}
|
|
1774
|
+
// Mark goal complete directly (skip audit entirely)
|
|
1775
|
+
accountProgress(ctx);
|
|
1776
|
+
state.goal = auditTarget;
|
|
1777
|
+
stopActiveGoal("complete", "agent", ctx);
|
|
1778
|
+
const completedGoal = state.goal;
|
|
1779
|
+
turnStoppedFor = completedGoal?.id ?? null;
|
|
1780
|
+
auditProgress = null;
|
|
1781
|
+
goalWidgetComponent?.invalidate();
|
|
1782
|
+
if (completedGoal) {
|
|
1783
|
+
resetGetGoalNudgeState(completedGoal.id);
|
|
1784
|
+
goalsById.delete(completedGoal.id);
|
|
1785
|
+
focusedGoalId = null;
|
|
1786
|
+
appendFocusEntry(null, "completed");
|
|
1787
|
+
syncGoalTools();
|
|
1788
|
+
updateUI(ctx);
|
|
1789
|
+
try {
|
|
1790
|
+
appendGoalEvent(ctx, {
|
|
1791
|
+
type: "goal_completed",
|
|
1792
|
+
goalId: completedGoal.id,
|
|
1793
|
+
archivePath: completedGoal.archivedPath,
|
|
1794
|
+
at: nowIso(),
|
|
1795
|
+
});
|
|
1796
|
+
} catch {
|
|
1797
|
+
// Ledger append failure should not crash completion
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
return {
|
|
1801
|
+
content: [{
|
|
1802
|
+
type: "text",
|
|
1803
|
+
text: buildCompletionReport({
|
|
1804
|
+
detailedSummary: detailedSummary(completedGoal),
|
|
1805
|
+
completionSummary: params.completionSummary,
|
|
1806
|
+
}),
|
|
1807
|
+
}],
|
|
1808
|
+
details: goalDetails(completedGoal),
|
|
1809
|
+
terminate: true,
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// Auditor is enabled — run the normal audit flow
|
|
1814
|
+
await pi.sendMessage<GoalAuditEventDetails>({
|
|
1815
|
+
customType: GOAL_AUDIT_ENTRY,
|
|
1816
|
+
content: [
|
|
1817
|
+
"Auditor: I am starting the independent completion audit.",
|
|
1818
|
+
`Goal id: ${auditTarget.id}`,
|
|
1819
|
+
`Auditor model: ${auditorLabel}`,
|
|
1820
|
+
params.completionSummary?.trim() ? `Completion claim: ${params.completionSummary.trim()}` : undefined,
|
|
1821
|
+
].filter((line): line is string => line !== undefined).join("\n"),
|
|
1822
|
+
display: true,
|
|
1823
|
+
details: { phase: "started", goalId: auditTarget.id, auditor: auditorLabel },
|
|
1824
|
+
}, { triggerTurn: true });
|
|
1825
|
+
// Append ledger: audit started
|
|
1826
|
+
try {
|
|
1827
|
+
appendGoalEvent(ctx, {
|
|
1828
|
+
type: "audit_started",
|
|
1829
|
+
goalId: auditTarget.id,
|
|
1830
|
+
provider: auditorConfig.provider,
|
|
1831
|
+
model: auditorConfig.model,
|
|
1832
|
+
thinkingLevel: auditorConfig.thinkingLevel,
|
|
1833
|
+
at: nowIso(),
|
|
1834
|
+
});
|
|
1835
|
+
} catch {
|
|
1836
|
+
// Ledger append failure should not block completion
|
|
1837
|
+
}
|
|
1838
|
+
// Set up auditor progress display (before createAgentSession)
|
|
1839
|
+
const auditStartedAt = Date.now();
|
|
1840
|
+
auditProgress = {
|
|
1841
|
+
recentOutput: [],
|
|
1842
|
+
phase: "running",
|
|
1843
|
+
elapsedMs: 0,
|
|
1844
|
+
};
|
|
1845
|
+
// Start animation timer for the spinner in the auditor widget
|
|
1846
|
+
stopAuditAnimation();
|
|
1847
|
+
auditAnimationTimer = setInterval(() => {
|
|
1848
|
+
if (!auditProgress) {
|
|
1849
|
+
stopAuditAnimation();
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
auditProgress.elapsedMs = Date.now() - auditStartedAt;
|
|
1853
|
+
goalWidgetComponent?.invalidate();
|
|
1854
|
+
}, 80);
|
|
1855
|
+
auditAnimationTimer.unref?.();
|
|
1856
|
+
|
|
1857
|
+
// Create a dedicated AbortController for the audit so it can be interrupted via Escape
|
|
1858
|
+
auditAbortController?.abort(); // Clean up any stale controller
|
|
1859
|
+
auditAbortController = new AbortController();
|
|
1860
|
+
|
|
1861
|
+
const auditor = await runGoalCompletionAuditor({
|
|
1862
|
+
ctx,
|
|
1863
|
+
goal: auditTarget,
|
|
1864
|
+
completionSummary: params.completionSummary,
|
|
1865
|
+
detailedSummary: detailedSummary(auditTarget),
|
|
1866
|
+
signal: auditAbortController.signal,
|
|
1867
|
+
onProgress: (progress) => {
|
|
1868
|
+
auditProgress = {
|
|
1869
|
+
...progress,
|
|
1870
|
+
elapsedMs: Date.now() - auditStartedAt,
|
|
1871
|
+
};
|
|
1872
|
+
goalWidgetComponent?.invalidate();
|
|
1873
|
+
},
|
|
1874
|
+
});
|
|
1875
|
+
// Clear abort controller — audit finished on its own
|
|
1876
|
+
auditAbortController = null;
|
|
1877
|
+
// Clear auditor progress display
|
|
1878
|
+
stopAuditAnimation();
|
|
1879
|
+
|
|
1880
|
+
// If the audit was aborted by the user (Esc), treat this as a user bypass
|
|
1881
|
+
// signal: skip the audit and complete the goal, mirroring the
|
|
1882
|
+
// disabled-auditor bypass pattern just above.
|
|
1883
|
+
// The GOAL_AUDIT_ENTRY (skipped) is sent here with triggerTurn:true so the
|
|
1884
|
+
// skip notification is exposed exactly once to the agent as part of the
|
|
1885
|
+
// update_goal tool execution, matching the disabled-flow pattern exactly.
|
|
1886
|
+
if (auditor.error === "Auditor aborted.") {
|
|
1887
|
+
await pi.sendMessage<GoalAuditEventDetails>({
|
|
1888
|
+
customType: GOAL_AUDIT_ENTRY,
|
|
1889
|
+
content: `Auditor aborted — completion bypassed for goal ${auditTarget.id}.`,
|
|
1890
|
+
display: true,
|
|
1891
|
+
details: { phase: "skipped", goalId: auditTarget.id, auditor: auditorLabel },
|
|
1892
|
+
}, { triggerTurn: true });
|
|
1893
|
+
// Mark goal complete directly (skip audit entirely)
|
|
1894
|
+
accountProgress(ctx);
|
|
1895
|
+
state.goal = auditTarget;
|
|
1896
|
+
stopActiveGoal("complete", "agent", ctx);
|
|
1897
|
+
const completedGoal = state.goal;
|
|
1898
|
+
turnStoppedFor = completedGoal?.id ?? null;
|
|
1899
|
+
auditProgress = null;
|
|
1900
|
+
goalWidgetComponent?.invalidate();
|
|
1901
|
+
if (completedGoal) {
|
|
1902
|
+
resetGetGoalNudgeState(completedGoal.id);
|
|
1903
|
+
goalsById.delete(completedGoal.id);
|
|
1904
|
+
focusedGoalId = null;
|
|
1905
|
+
appendFocusEntry(null, "completed");
|
|
1906
|
+
syncGoalTools();
|
|
1907
|
+
updateUI(ctx);
|
|
1908
|
+
try {
|
|
1909
|
+
appendGoalEvent(ctx, {
|
|
1910
|
+
type: "goal_completed",
|
|
1911
|
+
goalId: completedGoal.id,
|
|
1912
|
+
archivePath: completedGoal.archivedPath,
|
|
1913
|
+
at: nowIso(),
|
|
1914
|
+
});
|
|
1915
|
+
} catch {
|
|
1916
|
+
// Ledger append failure should not crash completion
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
return {
|
|
1920
|
+
content: [{
|
|
1921
|
+
type: "text",
|
|
1922
|
+
text: buildCompletionReport({
|
|
1923
|
+
detailedSummary: detailedSummary(completedGoal),
|
|
1924
|
+
completionSummary: params.completionSummary,
|
|
1925
|
+
}),
|
|
1926
|
+
}],
|
|
1927
|
+
details: goalDetails(completedGoal),
|
|
1928
|
+
terminate: true,
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// Show final audit output briefly before clearing
|
|
1933
|
+
if (auditProgress && auditor.output) {
|
|
1934
|
+
const outputLines = auditor.output.split("\n").slice(0, 8);
|
|
1935
|
+
auditProgress = {
|
|
1936
|
+
...auditProgress,
|
|
1937
|
+
phase: "done",
|
|
1938
|
+
recentOutput: outputLines,
|
|
1939
|
+
elapsedMs: Date.now() - auditStartedAt,
|
|
1940
|
+
};
|
|
1941
|
+
goalWidgetComponent?.invalidate();
|
|
1942
|
+
}
|
|
1943
|
+
// Append ledger: audit result
|
|
1944
|
+
const verdict = auditor.approved ? "approved" : auditor.error ? "error" : "disapproved" as const;
|
|
1945
|
+
try {
|
|
1946
|
+
appendGoalEvent(ctx, {
|
|
1947
|
+
type: "audit_result",
|
|
1948
|
+
goalId: auditTarget.id,
|
|
1949
|
+
verdict,
|
|
1950
|
+
report: auditor.output || "Auditor produced no output.",
|
|
1951
|
+
at: nowIso(),
|
|
1952
|
+
});
|
|
1953
|
+
} catch {
|
|
1954
|
+
// Ledger append failure should not block completion
|
|
1955
|
+
}
|
|
1956
|
+
if (!auditor.approved) {
|
|
1957
|
+
// Clear auditor progress to restore normal widget state
|
|
1958
|
+
auditProgress = null;
|
|
1959
|
+
goalWidgetComponent?.invalidate();
|
|
1960
|
+
const rejectionText = [
|
|
1961
|
+
"Goal audit rejected.",
|
|
1962
|
+
"",
|
|
1963
|
+
"Goal completion rejected by independent auditor.",
|
|
1964
|
+
auditor.model ? `Auditor model: ${auditor.model}${auditor.thinkingLevel ? `:${auditor.thinkingLevel}` : ""}` : undefined,
|
|
1965
|
+
auditor.error ? `Auditor error: ${auditor.error}` : undefined,
|
|
1966
|
+
"",
|
|
1967
|
+
auditor.output || "Auditor produced no approval marker.",
|
|
1968
|
+
].filter((line): line is string => line !== undefined).join("\n");
|
|
1969
|
+
pi.sendMessage<GoalAuditEventDetails>({
|
|
1970
|
+
customType: GOAL_AUDIT_ENTRY,
|
|
1971
|
+
content: rejectionText,
|
|
1972
|
+
display: true,
|
|
1973
|
+
details: { phase: "rejected", goalId: auditTarget.id, auditor: auditor.model },
|
|
1974
|
+
});
|
|
1975
|
+
return {
|
|
1976
|
+
content: [{ type: "text", text: rejectionText }],
|
|
1977
|
+
details: goalDetails(state.goal),
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
const approvalText = [
|
|
1981
|
+
"Auditor: I approve this completion claim.",
|
|
1982
|
+
auditor.model ? `Auditor model: ${auditor.model}${auditor.thinkingLevel ? `:${auditor.thinkingLevel}` : ""}` : undefined,
|
|
1983
|
+
"",
|
|
1984
|
+
auditor.output || "Auditor approved completion.",
|
|
1985
|
+
].filter((line): line is string => line !== undefined).join("\n");
|
|
1986
|
+
pi.sendMessage<GoalAuditEventDetails>({
|
|
1987
|
+
customType: GOAL_AUDIT_ENTRY,
|
|
1988
|
+
content: approvalText,
|
|
1989
|
+
display: true,
|
|
1990
|
+
details: { phase: "approved", goalId: auditTarget.id, auditor: auditor.model },
|
|
1991
|
+
});
|
|
1992
|
+
// Account for any remaining elapsed time before stopping.
|
|
1993
|
+
accountProgress(ctx);
|
|
1994
|
+
state.goal = auditTarget;
|
|
1995
|
+
stopActiveGoal("complete", "agent", ctx);
|
|
1996
|
+
const completedGoal = state.goal;
|
|
1997
|
+
// C9 fix: mark turn-stopped so subsequent in-turn tool calls are blocked.
|
|
1998
|
+
turnStoppedFor = completedGoal?.id ?? null;
|
|
1999
|
+
// Clear auditor progress to restore normal widget state
|
|
2000
|
+
auditProgress = null;
|
|
2001
|
+
goalWidgetComponent?.invalidate();
|
|
2002
|
+
if (completedGoal) {
|
|
2003
|
+
resetGetGoalNudgeState(completedGoal.id);
|
|
2004
|
+
goalsById.delete(completedGoal.id);
|
|
2005
|
+
focusedGoalId = null;
|
|
2006
|
+
appendFocusEntry(null, "completed");
|
|
2007
|
+
syncGoalTools();
|
|
2008
|
+
updateUI(ctx);
|
|
2009
|
+
// Append ledger: goal completed
|
|
2010
|
+
try {
|
|
2011
|
+
appendGoalEvent(ctx, {
|
|
2012
|
+
type: "goal_completed",
|
|
2013
|
+
goalId: completedGoal.id,
|
|
2014
|
+
archivePath: completedGoal.archivedPath,
|
|
2015
|
+
at: nowIso(),
|
|
2016
|
+
});
|
|
2017
|
+
} catch {
|
|
2018
|
+
// Ledger append failure should not crash completion
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
return {
|
|
2022
|
+
content: [{
|
|
2023
|
+
type: "text",
|
|
2024
|
+
text: buildCompletionReport({
|
|
2025
|
+
detailedSummary: detailedSummary(completedGoal),
|
|
2026
|
+
completionSummary: params.completionSummary,
|
|
2027
|
+
auditorReport: auditor.output,
|
|
2028
|
+
}),
|
|
2029
|
+
}],
|
|
2030
|
+
details: goalDetails(completedGoal),
|
|
2031
|
+
terminate: true,
|
|
2032
|
+
};
|
|
2033
|
+
},
|
|
2034
|
+
renderCall(args, theme) {
|
|
2035
|
+
return new Text(theme.fg("toolTitle", "update_goal ") + theme.fg("success", args.status), 0, 0);
|
|
2036
|
+
},
|
|
2037
|
+
renderResult(result, _options, theme) {
|
|
2038
|
+
return renderGoalResult(result, theme);
|
|
2039
|
+
},
|
|
2040
|
+
}));
|
|
2041
|
+
|
|
2042
|
+
pi.registerTool(defineTool({
|
|
2043
|
+
name: "pause_goal",
|
|
2044
|
+
label: "Pause Goal",
|
|
2045
|
+
description: "Pause the active pi goal and report a blocker to the user. The user must /goal-resume, /goal-tweak, or /goal-clear before work continues.",
|
|
2046
|
+
promptSnippet: "Pause the active pi goal and report a concrete blocker so the user can intervene.",
|
|
2047
|
+
promptGuidelines: [
|
|
2048
|
+
"Use pause_goal when you have hit a real blocker that you cannot resolve with one more reasonable next step: missing credentials, ambiguous or contradictory spec, a file or permission you cannot access, a sisyphus step whose precondition is not in the plan, or any irreversible / dangerous operation that requires explicit user approval.",
|
|
2049
|
+
"Do NOT use pause_goal to escape a merely hard problem; first try one concrete next step. Do not use pause_goal as a softer substitute for update_goal=complete \u2014 if the objective is achieved, complete it; if it is not, do not complete it.",
|
|
2050
|
+
"Never silently invent a workaround, fake completion, or quietly redefine the objective. Pause and report instead.",
|
|
2051
|
+
"Always pass a concrete one-sentence reason. When you know how the user can unblock you, pass suggestedAction (e.g. 'Set FOO_API_KEY env var and /goal-resume', or 'Use /goal-tweak to insert a precondition step before step 3').",
|
|
2052
|
+
"After pause_goal returns, stop. Do not call other tools in the same turn.",
|
|
2053
|
+
"For sisyphus goals: if any step is unclear, blocked, fails, or seems wrong, pause_goal is the correct action \u2014 do not skip the step or invent a workaround.",
|
|
2054
|
+
],
|
|
2055
|
+
parameters: Type.Object({
|
|
2056
|
+
reason: Type.String({ description: "One-sentence concrete blocker description. Plain language, not an apology." }),
|
|
2057
|
+
suggestedAction: Type.Optional(Type.String({ description: "Optional concrete suggestion for how the user can unblock (e.g. command to run, value to provide, /goal-tweak hint)." })),
|
|
2058
|
+
}),
|
|
2059
|
+
executionMode: "sequential",
|
|
2060
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
2061
|
+
reconcileFocusedGoalFromDisk(ctx);
|
|
2062
|
+
const reason = params.reason.trim();
|
|
2063
|
+
if (!reason) throw new Error("pause_goal requires a non-empty reason.");
|
|
2064
|
+
const pauseGate = validatePauseGoal({ goal: state.goal, runningGoalId, reason });
|
|
2065
|
+
if (!pauseGate.ok) {
|
|
2066
|
+
return {
|
|
2067
|
+
content: [{ type: "text", text: pauseGate.message }],
|
|
2068
|
+
details: goalDetails(state.goal),
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
if (!state.goal) throw new Error("Goal disappeared during pause validation.");
|
|
2072
|
+
const suggested = params.suggestedAction?.trim() || undefined;
|
|
2073
|
+
|
|
2074
|
+
// Account for any remaining elapsed time before stopping the run.
|
|
2075
|
+
accountProgress(ctx);
|
|
2076
|
+
state.goal = mergeGoalPromptFromDisk(ctx, state.goal);
|
|
2077
|
+
const next = buildPausedByAgentGoal(state.goal, { reason, suggestedAction: suggested, updatedAt: nowIso() });
|
|
2078
|
+
setGoal(next, ctx);
|
|
2079
|
+
resetGetGoalNudgeState(next.id);
|
|
2080
|
+
// C9 fix: mark turn-stopped so subsequent in-turn tool calls are blocked.
|
|
2081
|
+
// This is the schema-level closure of "agent kept writing files after pause_goal".
|
|
2082
|
+
turnStoppedFor = state.goal.id;
|
|
2083
|
+
|
|
2084
|
+
const suggestionLine = suggested ? `\nSuggested: ${truncateText(suggested, 160)}` : "";
|
|
2085
|
+
ctx.ui.notify(
|
|
2086
|
+
`Goal paused by agent.\nReason: ${truncateText(reason, 200)}${suggestionLine}\n\nUse /goal-resume to continue, /goal-tweak to revise, or /goal-clear to abandon.`,
|
|
2087
|
+
"warning",
|
|
2088
|
+
);
|
|
2089
|
+
return {
|
|
2090
|
+
content: [{
|
|
2091
|
+
type: "text",
|
|
2092
|
+
text: `Goal paused. Reason: ${reason}${suggested ? `\nSuggested: ${suggested}` : ""}\nWaiting for user to /goal-resume, /goal-tweak, or /goal-clear. Stop now; do not start another tool call.`,
|
|
2093
|
+
}],
|
|
2094
|
+
details: goalDetails(state.goal),
|
|
2095
|
+
terminate: true,
|
|
2096
|
+
};
|
|
2097
|
+
},
|
|
2098
|
+
renderCall(args, theme) {
|
|
2099
|
+
return new Text(theme.fg("toolTitle", "pause_goal ") + theme.fg("warning", truncateText(args?.reason ?? "", 80)), 0, 0);
|
|
2100
|
+
},
|
|
2101
|
+
renderResult(result, _options, theme) {
|
|
2102
|
+
return renderGoalResult(result, theme);
|
|
2103
|
+
},
|
|
2104
|
+
}));
|
|
2105
|
+
|
|
2106
|
+
pi.registerTool(defineTool({
|
|
2107
|
+
name: ABORT_GOAL_TOOL_NAME,
|
|
2108
|
+
label: "Abort Goal",
|
|
2109
|
+
description: "Abort the current active or paused pi goal and archive it without marking it complete.",
|
|
2110
|
+
promptSnippet: "Abort the current pi goal only when the user asks to abandon it or the objective is obsolete/impossible.",
|
|
2111
|
+
promptGuidelines: [
|
|
2112
|
+
"Use abort_goal only when the user explicitly asks to abandon/cancel the current goal, or when the goal is impossible, obsolete, or unsafe to continue and should not be marked complete.",
|
|
2113
|
+
"Do not use abort_goal as a substitute for update_goal(status=complete). If the objective is achieved, complete it instead.",
|
|
2114
|
+
"Do not use abort_goal for ordinary blockers that the user can resolve; use pause_goal({reason, suggestedAction?}) for that case.",
|
|
2115
|
+
"Always pass a concrete one-sentence reason. After abort_goal returns, stop and do not call other tools in the same turn.",
|
|
2116
|
+
],
|
|
2117
|
+
parameters: Type.Object({
|
|
2118
|
+
reason: Type.String({ description: "One-sentence reason for abandoning the current goal. Plain language, not an apology." }),
|
|
2119
|
+
}),
|
|
2120
|
+
executionMode: "sequential",
|
|
2121
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
2122
|
+
reconcileFocusedGoalFromDisk(ctx);
|
|
2123
|
+
const reason = params.reason.trim();
|
|
2124
|
+
if (!reason) throw new Error("abort_goal requires a non-empty reason.");
|
|
2125
|
+
const abortGate = validateGoalAbort({ goal: state.goal, runningGoalId, reason });
|
|
2126
|
+
if (!abortGate.ok) {
|
|
2127
|
+
return {
|
|
2128
|
+
content: [{ type: "text", text: abortGate.message }],
|
|
2129
|
+
details: goalDetails(state.goal),
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
if (!state.goal) throw new Error("Goal disappeared during abort validation.");
|
|
2133
|
+
const abortedGoalId = state.goal.id;
|
|
2134
|
+
|
|
2135
|
+
// Account for any remaining elapsed time before abandoning the run.
|
|
2136
|
+
accountProgress(ctx);
|
|
2137
|
+
state.goal = mergeGoalPromptFromDisk(ctx, state.goal);
|
|
2138
|
+
state.goal = buildAbortedByAgentGoal(state.goal, { reason, updatedAt: nowIso() });
|
|
2139
|
+
const archived = archiveCurrentGoal(ctx, "agent");
|
|
2140
|
+
resetGetGoalNudgeState(abortedGoalId);
|
|
2141
|
+
setGoal(null, ctx, true, "aborted");
|
|
2142
|
+
turnStoppedFor = abortedGoalId;
|
|
2143
|
+
|
|
2144
|
+
const archiveLine = archived?.archivedPath ? `\nArchive: ${archived.archivedPath}` : "";
|
|
2145
|
+
ctx.ui.notify(
|
|
2146
|
+
`Goal aborted by agent.\nReason: ${truncateText(reason, 200)}${archiveLine}`,
|
|
2147
|
+
"warning",
|
|
2148
|
+
);
|
|
2149
|
+
// Append ledger event for abort
|
|
2150
|
+
try {
|
|
2151
|
+
appendGoalEvent(ctx, {
|
|
2152
|
+
type: "goal_aborted",
|
|
2153
|
+
goalId: abortedGoalId,
|
|
2154
|
+
reason,
|
|
2155
|
+
archivePath: archived?.archivedPath,
|
|
2156
|
+
at: nowIso(),
|
|
2157
|
+
});
|
|
2158
|
+
} catch {
|
|
2159
|
+
// Ledger append failure should not crash abort
|
|
2160
|
+
}
|
|
2161
|
+
return {
|
|
2162
|
+
content: [{
|
|
2163
|
+
type: "text",
|
|
2164
|
+
text: `Goal aborted. Reason: ${reason}${archiveLine}\nThe goal has been archived and cleared. Stop now; do not start another tool call.`,
|
|
2165
|
+
}],
|
|
2166
|
+
details: goalDetails(state.goal),
|
|
2167
|
+
terminate: true,
|
|
2168
|
+
};
|
|
2169
|
+
},
|
|
2170
|
+
renderCall(args, theme) {
|
|
2171
|
+
return new Text(theme.fg("toolTitle", "abort_goal ") + theme.fg("warning", truncateText(args?.reason ?? "", 80)), 0, 0);
|
|
2172
|
+
},
|
|
2173
|
+
renderResult(result, _options, theme) {
|
|
2174
|
+
return renderGoalResult(result, theme);
|
|
2175
|
+
},
|
|
2176
|
+
}));
|
|
2177
|
+
|
|
2178
|
+
pi.registerTool(defineTool({
|
|
2179
|
+
name: SISYPHUS_STEP_TOOL_NAME,
|
|
2180
|
+
label: "Sisyphus Step Complete (Legacy)",
|
|
2181
|
+
description: "Legacy compatibility tool. Current Sisyphus mode is a prompt/criteria style and no longer uses schema-tracked step completion.",
|
|
2182
|
+
promptSnippet: "Legacy no-op: Sisyphus no longer requires step_complete.",
|
|
2183
|
+
promptGuidelines: [
|
|
2184
|
+
"Do not call this in normal operation. Sisyphus mode shares the normal goal lifecycle and completion gate.",
|
|
2185
|
+
"Complete the goal with update_goal(status=complete) only when the full objective is actually satisfied.",
|
|
2186
|
+
],
|
|
2187
|
+
parameters: Type.Object({
|
|
2188
|
+
stepIndex: Type.Integer({ minimum: 1, description: "Legacy step index. Ignored." }),
|
|
2189
|
+
evidence: Type.String({ description: "Legacy evidence text. Ignored by the schema." }),
|
|
2190
|
+
verifyCommand: Type.Optional(Type.String({ description: "Legacy field. Not executed." })),
|
|
2191
|
+
}),
|
|
2192
|
+
executionMode: "sequential",
|
|
2193
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
2194
|
+
return {
|
|
2195
|
+
content: [{ type: "text", text: "step_complete is no longer required. Sisyphus is now a prompt/criteria style that uses the normal goal lifecycle. Continue working from the objective, or call update_goal(status=complete) only when the full objective is satisfied." }],
|
|
2196
|
+
details: goalDetails(state.goal),
|
|
2197
|
+
};
|
|
2198
|
+
},
|
|
2199
|
+
renderCall(args, theme) {
|
|
2200
|
+
return new Text(theme.fg("toolTitle", "step_complete legacy ") + theme.fg("muted", `#${args?.stepIndex ?? "?"}`), 0, 0);
|
|
2201
|
+
},
|
|
2202
|
+
renderResult(result, _options, theme) {
|
|
2203
|
+
return renderGoalResult(result, theme);
|
|
2204
|
+
},
|
|
2205
|
+
}));
|
|
2206
|
+
|
|
2207
|
+
pi.registerTool(defineTool({
|
|
2208
|
+
name: TWEAK_APPLY_TOOL_NAME,
|
|
2209
|
+
label: "Apply Goal Tweak",
|
|
2210
|
+
description: "Atomically apply a /goal-tweak revision to the active goal. The ONLY way to modify an active goal's objective. Only available during a /goal-tweak drafting flow.",
|
|
2211
|
+
promptSnippet: "Apply the revised goal objective produced by a /goal-tweak drafting interview.",
|
|
2212
|
+
promptGuidelines: [
|
|
2213
|
+
"Only call apply_goal_tweak inside a /goal-tweak drafting flow (the prompt makes that explicit). It is rejected at any other time.",
|
|
2214
|
+
"newObjective must be the FULL revised objective text, formatted the same way as the original (=== Goal === or === Sisyphus Goal === block). Do NOT pass a diff or partial patch; pass the whole new objective.",
|
|
2215
|
+
"For Sisyphus goals: preserve the Sisyphus style and ordered-plan wording unless the user explicitly asks to remove it.",
|
|
2216
|
+
"changeSummary is a one-sentence description of WHAT changed (for the activity log and pause messages).",
|
|
2217
|
+
"Do NOT use write/edit/bash to modify the active goal file directly. apply_goal_tweak is the only sanctioned channel.",
|
|
2218
|
+
"After apply_goal_tweak returns, stop. Do not begin new task work in the same turn. The system will queue the next continuation.",
|
|
2219
|
+
],
|
|
2220
|
+
parameters: Type.Object({
|
|
2221
|
+
newObjective: Type.String({ description: "The complete revised objective text. For Sisyphus goals, preserve the Sisyphus style unless the user explicitly changes it." }),
|
|
2222
|
+
changeSummary: Type.String({ description: "One-sentence description of what was changed (used in UI notification and tweak log)." }),
|
|
2223
|
+
}),
|
|
2224
|
+
executionMode: "sequential",
|
|
2225
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
2226
|
+
reconcileFocusedGoalFromDisk(ctx);
|
|
2227
|
+
if (!state.goal) {
|
|
2228
|
+
return {
|
|
2229
|
+
content: [{ type: "text", text: "No goal is set; apply_goal_tweak is a no-op." }],
|
|
2230
|
+
details: goalDetails(state.goal),
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
if (tweakDraftingFor !== state.goal.id) {
|
|
2234
|
+
return {
|
|
2235
|
+
content: [{
|
|
2236
|
+
type: "text",
|
|
2237
|
+
text: "apply_goal_tweak REJECTED: no /goal-tweak drafting flow is active for this goal. " +
|
|
2238
|
+
"This tool can only be called during a /goal-tweak drafting interview that the user initiated. " +
|
|
2239
|
+
"If you want to change the goal, ask the user to run /goal-tweak.",
|
|
2240
|
+
}],
|
|
2241
|
+
details: goalDetails(state.goal),
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
if (state.goal.status !== "active" && state.goal.status !== "paused") {
|
|
2245
|
+
return {
|
|
2246
|
+
content: [{ type: "text", text: `Goal is ${statusLabel(state.goal)}; cannot apply a tweak.` }],
|
|
2247
|
+
details: goalDetails(state.goal),
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
const newObjective = params.newObjective.trim();
|
|
2251
|
+
if (!newObjective) throw new Error("apply_goal_tweak requires a non-empty newObjective.");
|
|
2252
|
+
const changeSummary = params.changeSummary.trim();
|
|
2253
|
+
if (!changeSummary) throw new Error("apply_goal_tweak requires a non-empty changeSummary.");
|
|
2254
|
+
const next: GoalRecord = {
|
|
2255
|
+
...state.goal,
|
|
2256
|
+
objective: newObjective,
|
|
2257
|
+
updatedAt: nowIso(),
|
|
2258
|
+
// Clear any prior agent pause reason — the user has redefined the work.
|
|
2259
|
+
pauseReason: undefined,
|
|
2260
|
+
pauseSuggestedAction: undefined,
|
|
2261
|
+
};
|
|
2262
|
+
// IMPORTANT: bypass setGoal() / persist() here. persist() calls
|
|
2263
|
+
// syncGoalPromptFromDisk() which would RE-READ the stale objective
|
|
2264
|
+
// from the still-old goal file on disk and clobber our new objective
|
|
2265
|
+
// before writing. apply_goal_tweak is the authoritative source for
|
|
2266
|
+
// objective changes — the disk is downstream, not upstream. Do the
|
|
2267
|
+
// minimal state update manually:
|
|
2268
|
+
// 1) write the new record to disk authoritatively
|
|
2269
|
+
// 2) update in-memory `goal` to the canonical post-write record
|
|
2270
|
+
// 3) append the state entry and re-sync tools
|
|
2271
|
+
// 4) clear the tweak drafting gate so apply_goal_tweak can't be re-used
|
|
2272
|
+
state.goal = writeActiveGoalFile(ctx, next);
|
|
2273
|
+
pi.appendEntry(STATE_ENTRY, goalDetails(state.goal));
|
|
2274
|
+
tweakDraftingFor = null;
|
|
2275
|
+
// Reset autoContinue counter — plan changed, agent gets a fresh chain.
|
|
2276
|
+
resetGetGoalNudgeState(state.goal.id);
|
|
2277
|
+
// C9 fix: mark turn-stopped so subsequent in-turn tool calls are blocked.
|
|
2278
|
+
turnStoppedFor = state.goal.id;
|
|
2279
|
+
syncGoalTools();
|
|
2280
|
+
updateUI(ctx);
|
|
2281
|
+
ctx.ui.notify(`Goal tweaked: ${truncateText(changeSummary, 160)}`, "info");
|
|
2282
|
+
// Append ledger event for tweak
|
|
2283
|
+
try {
|
|
2284
|
+
appendGoalEvent(ctx, {
|
|
2285
|
+
type: "goal_tweaked",
|
|
2286
|
+
goalId: state.goal.id,
|
|
2287
|
+
changeSummary,
|
|
2288
|
+
at: state.goal.updatedAt,
|
|
2289
|
+
});
|
|
2290
|
+
} catch {
|
|
2291
|
+
// Ledger append failure should not crash tweak
|
|
2292
|
+
}
|
|
2293
|
+
return {
|
|
2294
|
+
content: [{
|
|
2295
|
+
type: "text",
|
|
2296
|
+
text: `Goal tweak applied. ${changeSummary}\nStop now; the next continuation will arrive automatically if the goal is active.`,
|
|
2297
|
+
}],
|
|
2298
|
+
details: goalDetails(state.goal),
|
|
2299
|
+
terminate: true,
|
|
2300
|
+
};
|
|
2301
|
+
},
|
|
2302
|
+
renderCall(args, theme) {
|
|
2303
|
+
const summary = typeof args?.changeSummary === "string" ? truncateText(args.changeSummary, 80) : "";
|
|
2304
|
+
return new Text(theme.fg("toolTitle", "apply_goal_tweak ") + theme.fg("muted", summary), 0, 0);
|
|
2305
|
+
},
|
|
2306
|
+
renderResult(result, _options, theme) {
|
|
2307
|
+
return renderGoalResult(result, theme);
|
|
2308
|
+
},
|
|
2309
|
+
}));
|
|
2310
|
+
|
|
2311
|
+
syncGoalTools();
|
|
2312
|
+
|
|
2313
|
+
pi.on("context", async (event): Promise<{ messages: typeof event.messages } | undefined> => {
|
|
2314
|
+
let changed = false;
|
|
2315
|
+
const latestGoalEventIndex = new Map<string, number>();
|
|
2316
|
+
event.messages.forEach((message, index) => {
|
|
2317
|
+
const queuedGoalId = goalEventMessageId(message as { customType?: string; details?: unknown; content?: unknown });
|
|
2318
|
+
if (queuedGoalId) latestGoalEventIndex.set(queuedGoalId, index);
|
|
2319
|
+
});
|
|
2320
|
+
|
|
2321
|
+
const messages = event.messages.map((message, index) => {
|
|
2322
|
+
const candidate = message as { customType?: string; details?: unknown; content?: unknown };
|
|
2323
|
+
const queuedGoalId = goalEventMessageId(candidate);
|
|
2324
|
+
if (!queuedGoalId) return message;
|
|
2325
|
+
if (
|
|
2326
|
+
state.goal?.id === queuedGoalId
|
|
2327
|
+
&& (state.goal.status === "active")
|
|
2328
|
+
&& state.goal.autoContinue
|
|
2329
|
+
&& latestGoalEventIndex.get(queuedGoalId) === index
|
|
2330
|
+
) return message;
|
|
2331
|
+
changed = true;
|
|
2332
|
+
const details = asRecord(candidate.details) ?? {};
|
|
2333
|
+
return {
|
|
2334
|
+
...message,
|
|
2335
|
+
content: staleContinuationPrompt(queuedGoalId, state.goal),
|
|
2336
|
+
display: false,
|
|
2337
|
+
details: {
|
|
2338
|
+
...details,
|
|
2339
|
+
kind: "stale",
|
|
2340
|
+
goalId: queuedGoalId,
|
|
2341
|
+
currentGoalId: state.goal?.id ?? null,
|
|
2342
|
+
currentStatus: state.goal?.status ?? null,
|
|
2343
|
+
},
|
|
2344
|
+
} as typeof message;
|
|
2345
|
+
});
|
|
2346
|
+
return changed ? { messages } : undefined;
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
2350
|
+
// Per-turn flag resets (#4 + C9 fix).
|
|
2351
|
+
goalWorkToolCalledThisTurn = false;
|
|
2352
|
+
turnStoppedFor = null;
|
|
2353
|
+
beginAccounting();
|
|
2354
|
+
updateUI(ctx);
|
|
2355
|
+
});
|
|
2356
|
+
|
|
2357
|
+
// #4 + C9 fix + Phase 5 C3: gate in-turn tool calls based on lifecycle state.
|
|
2358
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
2359
|
+
// Post-stop in-turn block (C9 0ad8 fix): after pause_goal / abort_goal /
|
|
2360
|
+
// update_goal=complete / apply_goal_tweak fires in this turn, block all subsequent tool calls except
|
|
2361
|
+
// read-only inspection. Forces the agent to yield the turn instead of "fixing"
|
|
2362
|
+
// the situation by creating extra files etc.
|
|
2363
|
+
if (turnStoppedFor !== null && !POST_STOP_ALLOWED_TOOL_SET.has(event.toolName)) {
|
|
2364
|
+
return {
|
|
2365
|
+
block: true,
|
|
2366
|
+
reason: `The goal was already stopped earlier in this turn (goalId=${turnStoppedFor}). ` +
|
|
2367
|
+
`Do not call more tools; end the turn with a brief summary and yield to the user.`,
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
// Phase 5 soft gate relaxation: active-goal question block and repeated get_goal
|
|
2371
|
+
// block are removed. The agent is trusted to prefer work tools; prompts nudge
|
|
2372
|
+
// toward concrete work without hard-stopping the turn.
|
|
2373
|
+
if (confirmationIntent === null && tweakDraftingFor === null && state.goal?.status === "active") {
|
|
2374
|
+
if (event.toolName === "get_goal") {
|
|
2375
|
+
const prior = activeGetGoalTurnsByGoalId.get(state.goal.id) ?? 0;
|
|
2376
|
+
activeGetGoalTurnsByGoalId.set(state.goal.id, prior + 1);
|
|
2377
|
+
// Nudge only: do not hard-block, but warn in tool response via get_goal execute
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
// Track for #4 empty-turn gate.
|
|
2381
|
+
if (isMeaningfulProgressToolCall(event.toolName, asRecord(event)?.args)) {
|
|
2382
|
+
if (state.goal?.id) activeGetGoalTurnsByGoalId.delete(state.goal.id);
|
|
2383
|
+
goalWorkToolCalledThisTurn = true;
|
|
2384
|
+
} else if (state.goal?.status === "active" && state.goal.autoContinue && event.toolName !== "get_goal") {
|
|
2385
|
+
// A non-progress tool should not create an infinite retry chain.
|
|
2386
|
+
turnStoppedFor = state.goal.id;
|
|
2387
|
+
}
|
|
2388
|
+
return;
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
pi.on("tool_execution_end", async (_event, ctx) => {
|
|
2392
|
+
accountProgress(ctx);
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
2396
|
+
const message = event.message as AssistantMessageLike;
|
|
2397
|
+
if (confirmationIntent !== null || tweakDraftingFor !== null) return;
|
|
2398
|
+
const tokens = assistantTurnTokens(message);
|
|
2399
|
+
accountProgress(ctx, { completedTurnTokens: tokens });
|
|
2400
|
+
|
|
2401
|
+
if (isAbortedAssistantMessage(message)) {
|
|
2402
|
+
pauseActiveGoal(ctx);
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
refreshGoalDisplayFromDisk(ctx);
|
|
2406
|
+
// If the assistant ended a turn without queuing more tool calls, push a continuation right away.
|
|
2407
|
+
// #4: only queue if some real work was done this turn — otherwise the model is
|
|
2408
|
+
// just chatting and we should not keep firing turns on noise.
|
|
2409
|
+
if (
|
|
2410
|
+
!isToolUseAssistantMessage(message)
|
|
2411
|
+
&& state.goal?.status === "active"
|
|
2412
|
+
&& state.goal.autoContinue
|
|
2413
|
+
&& goalWorkToolCalledThisTurn
|
|
2414
|
+
) {
|
|
2415
|
+
queueContinuation(ctx);
|
|
2416
|
+
}
|
|
2417
|
+
});
|
|
2418
|
+
|
|
2419
|
+
pi.on("message_end", async (event, ctx) => {
|
|
2420
|
+
if (isAbortedAssistantMessage(event.message)) pauseActiveGoal(ctx);
|
|
2421
|
+
const raw = asRecord(event.message);
|
|
2422
|
+
if (raw?.role === "custom" && raw.customType === GOAL_EVENT_ENTRY && raw.display !== false) {
|
|
2423
|
+
return { message: { ...event.message, display: false } as typeof event.message };
|
|
2424
|
+
}
|
|
2425
|
+
});
|
|
2426
|
+
|
|
2427
|
+
pi.on("session_start", async (event, ctx) => {
|
|
2428
|
+
loadState(ctx);
|
|
2429
|
+
syncTerminalInputPause(ctx);
|
|
2430
|
+
if (event.reason === "resume" && !state.goal && openGoals().length > 1 && ctx.hasUI) {
|
|
2431
|
+
await focusGoalCommand(ctx);
|
|
2432
|
+
}
|
|
2433
|
+
// Codex behavior: prompt before reactivating a paused goal on resume.
|
|
2434
|
+
if (event.reason === "resume" && state.goal?.status === "paused" && ctx.hasUI) {
|
|
2435
|
+
const current = state.goal;
|
|
2436
|
+
const shouldResume = await ctx.ui.confirm("Resume paused goal?", `Goal: ${current.objective}`);
|
|
2437
|
+
if (shouldResume) {
|
|
2438
|
+
setGoal({ ...current, status: "active", autoContinue: true, stopReason: undefined, pauseReason: undefined, pauseSuggestedAction: undefined }, ctx);
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
beginAccounting();
|
|
2442
|
+
queueContinuation(ctx, true);
|
|
2443
|
+
});
|
|
2444
|
+
|
|
2445
|
+
pi.on("session_before_compact", async (_event, ctx) => {
|
|
2446
|
+
accountProgress(ctx);
|
|
2447
|
+
});
|
|
2448
|
+
|
|
2449
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
2450
|
+
if (confirmationIntent !== null || tweakDraftingFor !== null) return;
|
|
2451
|
+
if (state.goal) persist(ctx);
|
|
2452
|
+
beginAccounting();
|
|
2453
|
+
// Arm a deterministic compaction summary for the next agent turn.
|
|
2454
|
+
// This replaces the generic reminder with artifact-backed state.
|
|
2455
|
+
if (shouldArmPostCompactReminder(state.goal)) {
|
|
2456
|
+
postCompactReminderPending = true;
|
|
2457
|
+
}
|
|
2458
|
+
queueContinuation(ctx, true);
|
|
2459
|
+
});
|
|
2460
|
+
|
|
2461
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
2462
|
+
loadState(ctx);
|
|
2463
|
+
syncTerminalInputPause(ctx);
|
|
2464
|
+
beginAccounting();
|
|
2465
|
+
queueContinuation(ctx, true);
|
|
2466
|
+
});
|
|
2467
|
+
|
|
2468
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
2469
|
+
syncGoalTools();
|
|
2470
|
+
const currentSystemPrompt = () => ctx.getSystemPrompt?.() || event.systemPrompt;
|
|
2471
|
+
const incomingGoalId = extractGoalIdFromInjectedMessage(event.prompt ?? "");
|
|
2472
|
+
|
|
2473
|
+
if (confirmationIntent !== null) {
|
|
2474
|
+
clearContinuationState();
|
|
2475
|
+
clearActiveAccounting();
|
|
2476
|
+
runningGoalId = null;
|
|
2477
|
+
return { systemPrompt: currentSystemPrompt() };
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
if (tweakDraftingFor !== null) {
|
|
2481
|
+
clearContinuationState();
|
|
2482
|
+
clearActiveAccounting();
|
|
2483
|
+
runningGoalId = null;
|
|
2484
|
+
return { systemPrompt: currentSystemPrompt() };
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
// If this turn was triggered by a hidden goal checkpoint that no longer
|
|
2488
|
+
// matches the active goal, abort the whole turn instead of letting the
|
|
2489
|
+
// model act on a stale instruction.
|
|
2490
|
+
if (incomingGoalId !== null) {
|
|
2491
|
+
clearContinuationState();
|
|
2492
|
+
if (!state.goal || state.goal.id !== incomingGoalId || (state.goal.status !== "active") || !state.goal.autoContinue) {
|
|
2493
|
+
try {
|
|
2494
|
+
ctx.abort?.();
|
|
2495
|
+
} catch {}
|
|
2496
|
+
updateUI(ctx);
|
|
2497
|
+
return {
|
|
2498
|
+
systemPrompt: `${currentSystemPrompt()}\n\n${staleContinuationPrompt(incomingGoalId, state.goal)}`,
|
|
2499
|
+
};
|
|
2500
|
+
}
|
|
2501
|
+
} else {
|
|
2502
|
+
// A user-driven turn — clear any queued continuation so we don't
|
|
2503
|
+
// double-fire after the user's own message returns. Also reset the
|
|
2504
|
+
// autoContinue nudge state so the user always gets a fresh chain.
|
|
2505
|
+
clearContinuationState();
|
|
2506
|
+
resetGetGoalNudgeState(state.goal?.id);
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
if (!state.goal) {
|
|
2510
|
+
runningGoalId = null;
|
|
2511
|
+
const openCount = openGoals().length;
|
|
2512
|
+
if (openCount > 0) {
|
|
2513
|
+
return { systemPrompt: `${currentSystemPrompt()}\n\n${unfocusedOpenGoalsPrompt(openCount)}` };
|
|
2514
|
+
}
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
reconcileFocusedGoalFromDisk(ctx);
|
|
2518
|
+
if (!state.goal) {
|
|
2519
|
+
runningGoalId = null;
|
|
2520
|
+
const openCount = openGoals().length;
|
|
2521
|
+
if (openCount > 0) return { systemPrompt: `${currentSystemPrompt()}\n\n${unfocusedOpenGoalsPrompt(openCount)}` };
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
runningGoalId = state.goal.status === "active" ? state.goal.id : null;
|
|
2525
|
+
if (state.goal.status === "complete") return;
|
|
2526
|
+
if (state.goal.status === "paused") {
|
|
2527
|
+
const current = state.goal;
|
|
2528
|
+
const pauseExtras: string[] = [];
|
|
2529
|
+
if (current.stopReason === "agent") {
|
|
2530
|
+
pauseExtras.push("");
|
|
2531
|
+
pauseExtras.push(`Pause reason (you set this in a prior turn via pause_goal): ${current.pauseReason ?? "(unknown)"}`);
|
|
2532
|
+
if (current.pauseSuggestedAction) pauseExtras.push(`You suggested: ${current.pauseSuggestedAction}`);
|
|
2533
|
+
}
|
|
2534
|
+
// Inject durable auditor feedback if available
|
|
2535
|
+
let auditorExtra = "";
|
|
2536
|
+
try {
|
|
2537
|
+
const ledger = readGoalLedger(ctx);
|
|
2538
|
+
const auditorResult = latestAuditorResultForGoal(ledger.events, current.id);
|
|
2539
|
+
if (auditorResult && auditorResult.verdict === "disapproved") {
|
|
2540
|
+
auditorExtra = `\n\n[AUDITOR REJECTION] An independent auditor previously rejected a completion request for this goal. Reason: ${auditorResult.report.slice(0, 300)}\nAddress the auditor's objections before requesting completion again.`;
|
|
2541
|
+
}
|
|
2542
|
+
} catch {
|
|
2543
|
+
// Ledger read failure should not break the prompt
|
|
2544
|
+
}
|
|
2545
|
+
return {
|
|
2546
|
+
systemPrompt: `${currentSystemPrompt()}\n\n[PI GOAL PAUSED goalId=${current.id}]\n${untrustedObjectiveBlock(current)}${pauseExtras.join("\n")}${auditorExtra}\n\nThe goal is paused. Do not autonomously continue substantive work unless the user resumes it with /goal-resume. If the user explicitly asks to finish or abandon the paused goal, or the objective is already satisfied based on available evidence, you may call update_goal(status=complete) or abort_goal without resuming. Do not call pause_goal again.`,
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
2549
|
+
const activeGoal = state.goal;
|
|
2550
|
+
let prompt = goalPrompt(activeGoal);
|
|
2551
|
+
// Inject durable auditor feedback if the latest result was a rejection
|
|
2552
|
+
try {
|
|
2553
|
+
const ledger = readGoalLedger(ctx);
|
|
2554
|
+
const auditorResult = latestAuditorResultForGoal(ledger.events, activeGoal.id);
|
|
2555
|
+
if (auditorResult && auditorResult.verdict === "disapproved" && ledger.events.some((e) => e.type === "completion_requested" && e.goalId === activeGoal.id)) {
|
|
2556
|
+
prompt = `${prompt}\n\n[AUDITOR REJECTION goalId=${activeGoal.id}]\nAn independent auditor previously rejected a completion request for this goal. Reason: ${auditorResult.report.slice(0, 300)}\nAddress the auditor's objections before requesting completion again.`;
|
|
2557
|
+
}
|
|
2558
|
+
} catch {
|
|
2559
|
+
// Ledger read failure should not break the prompt
|
|
2560
|
+
}
|
|
2561
|
+
if (shouldInjectPostCompactReminder({ pending: postCompactReminderPending, goal: activeGoal })) {
|
|
2562
|
+
postCompactReminderPending = false;
|
|
2563
|
+
// Use deterministic compaction summary instead of generic reminder
|
|
2564
|
+
try {
|
|
2565
|
+
const ledger = readGoalLedger(ctx);
|
|
2566
|
+
const compaction = buildCompactionSummary({ goalsById, focusedGoalId, ledgerEvents: ledger.events });
|
|
2567
|
+
prompt = `${prompt}\n\n[POST-COMPACTION RESYNC goalId=${activeGoal.id}]\n${compaction}`;
|
|
2568
|
+
} catch {
|
|
2569
|
+
prompt = `${prompt}\n\n[POST-COMPACTION RESYNC goalId=${state.goal.id}]\nThe conversation was just compacted. Re-read the objective and continue from the actual artifacts/state; do not rely on memory of the prior chat.`;
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
return { systemPrompt: `${currentSystemPrompt()}\n\n${prompt}` };
|
|
2573
|
+
});
|
|
2574
|
+
|
|
2575
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
2576
|
+
if (confirmationIntent !== null || tweakDraftingFor !== null) return;
|
|
2577
|
+
const endedGoalId = runningGoalId;
|
|
2578
|
+
runningGoalId = null;
|
|
2579
|
+
|
|
2580
|
+
// Account for any tokens from aborted in-flight assistant messages so
|
|
2581
|
+
// they are not silently lost (but charge them to the original goal).
|
|
2582
|
+
const abortedTokens = event.messages
|
|
2583
|
+
.filter(isAbortedAssistantMessage)
|
|
2584
|
+
.reduce((sum, message) => sum + assistantTurnTokens(message), 0);
|
|
2585
|
+
if (abortedTokens > 0 && endedGoalId && state.goal?.id === endedGoalId) {
|
|
2586
|
+
accountProgress(ctx, { completedTurnTokens: abortedTokens });
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
continuationQueuedFor = null;
|
|
2590
|
+
if (!state.goal || state.goal.status !== "active" || !state.goal.autoContinue) return;
|
|
2591
|
+
if (endedGoalId && state.goal.id !== endedGoalId) return;
|
|
2592
|
+
if (!reconcileFocusedGoalFromDisk(ctx)) return;
|
|
2593
|
+
if (hasAbortedAssistantMessage(event.messages) || ctx.signal?.aborted) {
|
|
2594
|
+
pauseActiveGoal(ctx);
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
persist(ctx);
|
|
2598
|
+
updateUI(ctx);
|
|
2599
|
+
queueContinuation(ctx);
|
|
2600
|
+
});
|
|
2601
|
+
|
|
2602
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
2603
|
+
accountProgress(ctx);
|
|
2604
|
+
clearContinuationTimer();
|
|
2605
|
+
stopStatusRefresh();
|
|
2606
|
+
terminalInputUnsubscribe?.();
|
|
2607
|
+
terminalInputUnsubscribe = null;
|
|
2608
|
+
if (state.goal) persist(ctx);
|
|
2609
|
+
});
|
|
2610
|
+
}
|