@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.1

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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/assets/badge-dark.svg +18 -0
  4. package/assets/badge-light.svg +18 -0
  5. package/docs/advanced-usage.md +106 -0
  6. package/docs/agent-flow-design.md +306 -0
  7. package/docs/agentic-runtime-prd.md +773 -0
  8. package/docs/architecture.md +368 -0
  9. package/docs/controls.md +140 -0
  10. package/docs/pi-autoresearch-survey.md +50 -0
  11. package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
  12. package/docs/prompt-caching.md +31 -0
  13. package/docs/unified-dashboard.md +286 -0
  14. package/extensions/auditor-selector.ts +74 -0
  15. package/extensions/goal-accounting.ts +107 -0
  16. package/extensions/goal-activity.ts +202 -0
  17. package/extensions/goal-auditor.ts +480 -0
  18. package/extensions/goal-commands.ts +866 -0
  19. package/extensions/goal-compaction.ts +197 -0
  20. package/extensions/goal-completion.ts +173 -0
  21. package/extensions/goal-context.ts +12 -0
  22. package/extensions/goal-contract.ts +58 -0
  23. package/extensions/goal-controls.ts +202 -0
  24. package/extensions/goal-core-tools.ts +549 -0
  25. package/extensions/goal-core.ts +78 -0
  26. package/extensions/goal-detail.ts +65 -0
  27. package/extensions/goal-dialog-controls.ts +31 -0
  28. package/extensions/goal-draft.ts +244 -0
  29. package/extensions/goal-drafting.ts +535 -0
  30. package/extensions/goal-events.ts +692 -0
  31. package/extensions/goal-format.ts +376 -0
  32. package/extensions/goal-interruption.ts +53 -0
  33. package/extensions/goal-ledger-index.ts +68 -0
  34. package/extensions/goal-ledger.ts +1007 -0
  35. package/extensions/goal-live-retention.ts +239 -0
  36. package/extensions/goal-oracle.ts +346 -0
  37. package/extensions/goal-policy.ts +370 -0
  38. package/extensions/goal-pool.ts +103 -0
  39. package/extensions/goal-prompt-cache.ts +80 -0
  40. package/extensions/goal-questionnaire.ts +1073 -0
  41. package/extensions/goal-record.ts +388 -0
  42. package/extensions/goal-recovery.ts +256 -0
  43. package/extensions/goal-runtime.ts +277 -0
  44. package/extensions/goal-scheduler-state.ts +100 -0
  45. package/extensions/goal-scheduler.ts +436 -0
  46. package/extensions/goal-service.ts +843 -0
  47. package/extensions/goal-session-health.ts +130 -0
  48. package/extensions/goal-session-safety.ts +65 -0
  49. package/extensions/goal-settings.ts +1370 -0
  50. package/extensions/goal-state.ts +1069 -0
  51. package/extensions/goal-status.ts +305 -0
  52. package/extensions/goal-task-confirmation.ts +174 -0
  53. package/extensions/goal-task-count.ts +59 -0
  54. package/extensions/goal-task-derive.ts +46 -0
  55. package/extensions/goal-task-index.ts +57 -0
  56. package/extensions/goal-task-tools.ts +595 -0
  57. package/extensions/goal-tool-inputs.ts +102 -0
  58. package/extensions/goal-tool-names.ts +73 -0
  59. package/extensions/goal-tools.ts +16 -0
  60. package/extensions/goal-widget.ts +455 -0
  61. package/extensions/goal.ts +42 -0
  62. package/extensions/network-error-backoff.ts +55 -0
  63. package/extensions/prompts/goal-prompts.ts +330 -0
  64. package/extensions/storage/atomic-rename.ts +52 -0
  65. package/extensions/storage/goal-files.ts +695 -0
  66. package/extensions/storage/goal-lock.ts +131 -0
  67. package/extensions/storage/goal-root.ts +95 -0
  68. package/extensions/widgets/auditor-dashboard-model.ts +174 -0
  69. package/extensions/widgets/dialog-scaffold.ts +26 -0
  70. package/extensions/widgets/goal-controls-dialog.ts +329 -0
  71. package/extensions/widgets/goal-dashboard-model.ts +530 -0
  72. package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
  73. package/extensions/widgets/goal-escape-dialog.ts +148 -0
  74. package/extensions/widgets/goal-notifications.ts +9 -0
  75. package/extensions/widgets/goal-widget.ts +723 -0
  76. package/extensions/widgets/task-list-overlay.ts +409 -0
  77. package/extensions/widgets/text-cache.ts +30 -0
  78. package/package.json +117 -4
  79. package/scripts/recover-session-checkpoints.mjs +289 -0
@@ -0,0 +1,549 @@
1
+ import { planUpdateGoal } from "./goal-tool-inputs.ts";
2
+ import { commandHint } from "./goal-controls.ts";
3
+ import { schedulerSummary } from "./goal-scheduler-state.ts";
4
+ import { goalDetailPage, type GoalDetailSection } from "./goal-detail.ts";
5
+ import { taskIndex } from "./goal-task-index.ts";
6
+ import { StringEnum, Type } from "@earendil-works/pi-ai";
7
+ import { defineTool, type AgentToolResult, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
8
+ import { Text } from "@earendil-works/pi-tui";
9
+ import { formatDuration, formatTokenValue, statusLabel, truncateText } from "./goal-core.ts";
10
+ import { extractVerificationContract } from "./goal-contract.ts";
11
+ import { detailedSummary, goalDetails, renderGoalResult } from "./goal-format.ts";
12
+ import { modelBudgetLine } from "./goal-accounting.ts";
13
+ import { buildGoalCreatedReport, buildTaskSummary, findTaskInTree, validateGoalAgentPause, validateGoalBlock } from "./goal-policy.ts";
14
+ import { buildUnfocusedOpenGoalsSummary, otherOpenGoalCount } from "./goal-pool.ts";
15
+ import { readGoalLedger, goalOracleState } from "./goal-ledger.ts";
16
+ import { loadGoalSettings } from "./goal-settings.ts";
17
+ import { buildGoalHistoryBlock, buildGoalTaskDetailBlock } from "./goal-format.ts";
18
+ import { sisyphusStepProgress } from "./goal-policy.ts";
19
+ import { deriveTasksFromObjective } from "./goal-task-derive.ts";
20
+ import { nowIso, type GoalRecord, type GoalTask, validateTokenBudgetInput } from "./goal-record.ts";
21
+ import type { GoalCore } from "./goal-state.ts";
22
+ import { promptProfile } from "./prompts/goal-prompts.ts";
23
+ import {
24
+ armOracleAdvice,
25
+ buildBlockerFingerprint,
26
+ consumeOracleFollowupMarker,
27
+ hasPendingOracleAdviceForFocusedGoal,
28
+ renderActionableOracleAdvice,
29
+ renderOracleAdviceReminder,
30
+ runBlockerOracle,
31
+ } from "./goal-oracle.ts";
32
+ import { loadSettingsSnapshot, type ResolvedGoalOracleSettings } from "./goal-settings.ts";
33
+
34
+ /** Current + first-pending (excluding current) task pointers for concise get_goal. */
35
+ function conciseTaskPointers(goal: GoalRecord): { findCurrentTask?: GoalTask; firstPendingTask?: GoalTask } {
36
+ if (!goal.taskList) return {};
37
+ const index = taskIndex(goal.taskList.tasks);
38
+ const firstPendingTask = index.pending.find(task => task.id !== goal.currentTaskId);
39
+ const findCurrentTask = goal.currentTaskId ? index.byId.get(goal.currentTaskId) : undefined;
40
+ return { findCurrentTask, firstPendingTask };
41
+ }
42
+
43
+ export function registerCoreTools(
44
+ core: GoalCore,
45
+ deps: {
46
+ runGoalCompletionFlow: (core: GoalCore, ctx: ExtensionContext, completionSummary?: string) => Promise<AgentToolResult<unknown>>;
47
+ },
48
+ ): void {
49
+ const { pi } = core;
50
+
51
+ pi.registerTool(defineTool({
52
+ name: "get_goal",
53
+ label: "Get Goal",
54
+ description: "Read focused goal state. Default: compact summary. Retrieve full objective/contracts, tasks, or history in 4000-character pages with section and cursor. verbose preserves full legacy output.",
55
+ promptSnippet: "Inspect goal state or retrieve omitted requirements.",
56
+ promptGuidelines: [],
57
+ parameters: Type.Object({
58
+ section: Type.Optional(StringEnum(["summary", "objective", "tasks", "history"] as const)),
59
+ task_id: Type.Optional(Type.String({description: "With section=tasks, retrieve one task."})),
60
+ cursor: Type.Optional(Type.String({maxLength: 256, description: "Next page; repeat section/task."})),
61
+ verbose: Type.Optional(Type.Boolean({ description: "Full detail mode." })),
62
+ include_history: Type.Optional(Type.Boolean()),
63
+ }, { additionalProperties: false }),
64
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
65
+ core.reconcileFocusedGoalFromDisk(ctx);
66
+ if (core.state.goal) core.syncGoalPromptFromDisk(ctx);
67
+ const view = core.goalForDisplay() ?? core.state.goal;
68
+ const params = (_params ?? {}) as { verbose?: boolean; include_history?: boolean; section?: "summary" | GoalDetailSection; task_id?: string; cursor?: string };
69
+ const taskId = params.task_id?.trim() || undefined;
70
+ // PR E profile: legacy-v1 keeps pre-optimization verbose-by-default output;
71
+ // compact-v2 (default) returns a concise state line set because the full
72
+ // policy already lives in the injected active-goal system block.
73
+ const verbose = promptProfile() === "legacy-v1" || params.verbose === true;
74
+ const includeHistory = params.include_history === true || verbose;
75
+ const otherCount = otherOpenGoalCount(core.goalsById, core.focusedGoalId);
76
+ if (!view) {
77
+ const text = otherOpenGoalCount(core.goalsById, null) > 0
78
+ ? `${buildUnfocusedOpenGoalsSummary(otherOpenGoalCount(core.goalsById, null))}\n\nCall create_goal with the objective to create and focus a new goal, or ask the user to run ${commandHint("goal-focus", core.loadedCommands)} to choose an open goal.`
79
+ : "No goal is set in this session. Call create_goal with the objective when the user explicitly asks to start a persistent goal.";
80
+ return {
81
+ content: [{ type: "text", text }],
82
+ details: goalDetails(view),
83
+ };
84
+ }
85
+ if (params.section && params.section !== "summary") {
86
+ if (!["objective", "tasks", "history"].includes(params.section)) return {content: [{type: "text", text: "Unknown goal section."}], details: goalDetails(view)};
87
+ const history = params.section === "history" ? readGoalLedger(ctx) : undefined;
88
+ const page = goalDetailPage(view, {section: params.section, task_id: taskId, cursor: params.cursor}, history?.events, history?.revision);
89
+ return {content: [{type: "text", text: page.text}], details: {...goalDetails(view), ...(page.ok ? {page: {content: page.content, nextCursor: page.nextCursor, totalChars: page.totalChars}} : {})}};
90
+ }
91
+ if (params.cursor || taskId) return {content: [{type: "text", text: "Use section=objective, tasks, or history for detail retrieval; task_id requires tasks."}], details: goalDetails(view)};
92
+ if (verbose && !params.section) {
93
+ const lines: string[] = [`Goal ${view.id}: ${statusLabel(view)}, ${view.sisyphus ? "sisyphus" : "regular"}`, schedulerSummary(view.scheduler, loadGoalSettings(ctx.cwd).maxAutonomousRuns, loadGoalSettings(ctx.cwd).showAutonomousRuns, loadGoalSettings(ctx.cwd).idleContinuationBackoff === true, core.loadedCommands)];
94
+ lines.push(`Objective: ${view.objective}`, "");
95
+ lines.push(`Status: ${statusLabel(view)}`);
96
+ lines.push(`Mode: ${view.sisyphus ? "sisyphus" : "regular"}`);
97
+ if (view.sisyphus) {
98
+ // E6: sisyphus ordered-step progress.
99
+ const steps = sisyphusStepProgress(view);
100
+ if (steps) lines.push(`At step: ${steps.current} of ${steps.total}`);
101
+ }
102
+ const usageBits: string[] = [];
103
+ if (view.usage.activeSeconds > 0) usageBits.push(formatDuration(view.usage.activeSeconds));
104
+ if (view.usage.tokensUsed > 0) usageBits.push(formatTokenValue(view.usage.tokensUsed));
105
+ lines.push(`Cumulative goal usage (not context occupancy): ${usageBits.length > 0 ? usageBits.join(" · ") : "none"}`);
106
+ const budget = modelBudgetLine(view);
107
+ if (budget) lines.push(`Budget: ${budget}`);
108
+ if (view.taskList) {
109
+ lines.push(`Tasks: ${buildTaskSummary(view.taskList)}`);
110
+ // F1: task-detail block mirroring the widget.
111
+ const detail = buildGoalTaskDetailBlock(view);
112
+ if (detail) lines.push("", detail);
113
+ }
114
+ if (view.verificationContract?.trim()) lines.push(`Verification contract: ${view.verificationContract.trim()}`);
115
+ if (view.status === "paused" || view.status === "blocked") {
116
+ if (view.pauseReason) lines.push(`Blocker: ${view.pauseReason}`);
117
+ if (view.pauseSuggestedAction) lines.push(`Suggested action: ${view.pauseSuggestedAction}`);
118
+ }
119
+ if (view.activePath) lines.push(`Path: ${view.activePath}`);
120
+ if (view.archivedPath) lines.push(`Archive: ${view.archivedPath}`);
121
+ if (otherCount > 0) lines.push(`Other open goals: ${otherCount} (user can run ${commandHint("goal-list", core.loadedCommands)} or ${commandHint("goal-focus", core.loadedCommands)})`);
122
+ lines.push("");
123
+ lines.push("Lifecycle: call update_goal({status: \"complete\"}) only when every requirement is satisfied — the independent auditor verifies from actual evidence. Call update_goal({status: \"blocked\"}) only after the same blocker recurs on three consecutive goal turns. User commands handle pause/resume/clear/focus.");
124
+ // E1: goal history (last audit verdict + recent lifecycle events).
125
+ if (includeHistory) {
126
+ const history = buildGoalHistoryBlock(view, readGoalLedger(ctx).events);
127
+ if (history) lines.push("", history);
128
+ }
129
+ return {
130
+ content: [{ type: "text", text: lines.join("\n") }],
131
+ details: goalDetails(view),
132
+ };
133
+ }
134
+
135
+ // Compact state read; full requirements remain available through detail pages.
136
+ const lines: string[] = [`Goal ${view.id}: ${statusLabel(view)}, ${view.sisyphus ? "sisyphus" : "regular"}`, schedulerSummary(view.scheduler, loadGoalSettings(ctx.cwd).maxAutonomousRuns, loadGoalSettings(ctx.cwd).showAutonomousRuns, loadGoalSettings(ctx.cwd).idleContinuationBackoff === true, core.loadedCommands)];
137
+ lines.push(`Objective: ${truncateText(view.objective, 180)}${view.objective.length > 180 ? " (full: get_goal section=objective)" : ""}`);
138
+ if (view.taskList) {
139
+ const { findCurrentTask, firstPendingTask } = conciseTaskPointers(view);
140
+ if (findCurrentTask) {
141
+ const contract = findCurrentTask.verificationContract ? ` — contract: ${truncateText(findCurrentTask.verificationContract, 240)}` : "";
142
+ lines.push(`Current task: ${findCurrentTask.id} — ${truncateText(findCurrentTask.title, 180)}${contract}`);
143
+ }
144
+ if (firstPendingTask) {
145
+ lines.push(`Next pending: ${firstPendingTask.id} — ${truncateText(firstPendingTask.title, 180)}`);
146
+ }
147
+ lines.push(`Tasks: ${buildTaskSummary(view.taskList)}`);
148
+ } else if (view.currentTaskId) {
149
+ lines.push(`Current task: ${view.currentTaskId}`);
150
+ }
151
+ const budget = modelBudgetLine(view);
152
+ if (budget) lines.push(`Budget: ${budget}`);
153
+ if ((view.status === "paused" || view.status === "blocked") && view.pauseReason) {
154
+ lines.push(`Blocker: ${view.pauseReason}`);
155
+ }
156
+ if (params.include_history) {
157
+ const history = buildGoalHistoryBlock(view, readGoalLedger(ctx).events);
158
+ if (history) lines.push(history);
159
+ }
160
+ return {
161
+ content: [{ type: "text", text: lines.join("\n") }],
162
+ details: goalDetails(view),
163
+ };
164
+ },
165
+ renderCall(_args, theme) {
166
+ return new Text(theme.fg("toolTitle", "get_goal"), 0, 0);
167
+ },
168
+ renderResult(result, _options, theme) {
169
+ return renderGoalResult(result, _options, theme);
170
+ },
171
+ }));
172
+
173
+ pi.registerTool(defineTool({
174
+ name: "create_goal",
175
+ label: "Create Goal",
176
+ description: "Create and focus a persistent goal only on explicit user request; other open goals remain unchanged.",
177
+ promptSnippet: "Create a goal only when explicitly requested.",
178
+ promptGuidelines: ["Never infer persistent goals, Sisyphus mode, or token budgets from an ordinary task. The objective must faithfully preserve all user requirements and ordered steps."],
179
+ parameters: Type.Object({
180
+ objective: Type.String({ description: "Full objective; preserve ordered steps and done criteria." }),
181
+ mode: Type.Optional(StringEnum(["regular", "sisyphus"] as const, { description: "Default regular; sisyphus only if requested." })),
182
+ token_budget: Type.Optional(Type.Integer({ minimum: 1, description: "Whole-token budget, only if supplied by the user." })),
183
+ }, { additionalProperties: false }),
184
+ executionMode: "sequential",
185
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
186
+ core.reconcileFocusedGoalFromDisk(ctx);
187
+ const objective = params.objective.trim();
188
+ if (!objective) throw new Error("create_goal requires a non-empty objective.");
189
+ const objectiveMaxChars = loadGoalSettings(ctx.cwd).objectiveMaxChars ?? 0;
190
+ if (objectiveMaxChars > 0 && objective.length > objectiveMaxChars) {
191
+ return {
192
+ content: [{ type: "text", text: `create_goal objective exceeds ${objectiveMaxChars} characters (${objective.length}). Shorten the objective or raise the max objective length setting.` }],
193
+ details: goalDetails(core.state.goal),
194
+ };
195
+ }
196
+ const sisyphusFlag = params.mode === "sisyphus";
197
+ let tokenBudget: number | undefined;
198
+ if (params.token_budget !== undefined) {
199
+ // Tool callers are untrusted: re-validate the budget beyond the schema.
200
+ const budgetGate = validateTokenBudgetInput(params.token_budget);
201
+ if (!budgetGate.ok) {
202
+ return {
203
+ content: [{ type: "text", text: budgetGate.message }],
204
+ details: goalDetails(core.state.goal),
205
+ };
206
+ }
207
+ tokenBudget = budgetGate.value;
208
+ }
209
+ const { objective: cleanedObjective, verificationContract } = extractVerificationContract(objective);
210
+ core.replaceGoal(
211
+ { objective: cleanedObjective, autoContinue: true, sisyphus: sisyphusFlag },
212
+ ctx,
213
+ true,
214
+ verificationContract,
215
+ tokenBudget,
216
+ );
217
+ const created = core.state.goal;
218
+ const otherCount = otherOpenGoalCount(core.goalsById, core.focusedGoalId);
219
+ const otherLine = otherCount > 0
220
+ ? `\n${otherCount} other open goal${otherCount === 1 ? "" : "s"} remain in .pi/goals — this goal is now the session focus.`
221
+ : "";
222
+ // F2: the agent path stays tool-driven — surface the derivable task plan
223
+ // as guidance so the agent proposes it via set_goal_tasks.
224
+ const derived = !created?.taskList ? deriveTasksFromObjective(cleanedObjective) : null;
225
+ const bootstrapLine = derived && derived.length > 0
226
+ ? `\n\nThe objective contains ${derived.length} ordered step${derived.length === 1 ? "" : "s"}; propose them as the task tree with set_goal_tasks if the user wants tracked milestones.`
227
+ : "";
228
+ return {
229
+ content: [{ type: "text", text: `${buildGoalCreatedReport({ objective: created?.objective ?? objective, detailedSummary: detailedSummary(created), tokenBudget: created?.tokenBudget })}${bootstrapLine}${otherLine}` }],
230
+ details: goalDetails(created),
231
+ terminate: true,
232
+ };
233
+ },
234
+ renderCall(args, theme) {
235
+ const prefix = args?.mode === "sisyphus" ? "create_goal sisyphus " : "create_goal ";
236
+ return new Text(theme.fg("toolTitle", prefix) + theme.fg("muted", args?.objective ?? ""), 0, 0);
237
+ },
238
+ renderResult(result, _options, theme) {
239
+ return renderGoalResult(result, _options, theme);
240
+ },
241
+ }));
242
+
243
+ // ── update_goal: the model's terminal-outcome surface (Stage 3) ────────
244
+ // complete → the independent auditor verifies from actual evidence (no
245
+ // paperwork field); blocked → a distinct agent-blocked state that stops
246
+ // continuation. The three-consecutive-turn blocker rule is prompt policy.
247
+ async function runGoalBlockedFlow(ctx: ExtensionContext, reasonInput?: string, attemptedActions: string[] = []): Promise<AgentToolResult<unknown>> {
248
+ core.reconcileFocusedGoalFromDisk(ctx);
249
+ const gate = validateGoalBlock({ goal: core.state.goal, runningGoalId: core.runningGoalId });
250
+ if (!gate.ok) {
251
+ return {
252
+ content: [{ type: "text", text: gate.message }],
253
+ details: goalDetails(core.state.goal),
254
+ };
255
+ }
256
+ if (!core.state.goal) throw new Error("Goal disappeared during blocked validation.");
257
+
258
+ // Issue #26: the blocker reason is now required for update_goal(blocked) —
259
+ // it feeds the fingerprint, the ledger event, and the user-facing report.
260
+ const reason = reasonInput?.trim() ?? "";
261
+ if (!reason) {
262
+ return {
263
+ content: [{ type: "text", text: 'update_goal({ status: "blocked" }) requires a "reason" describing the concrete blocker. The goal remains active.' }],
264
+ details: goalDetails(core.state.goal),
265
+ terminate: false,
266
+ };
267
+ }
268
+
269
+ const commitBlocked = (): AgentToolResult<unknown> => {
270
+ const result = core.goalService.apply(ctx, {
271
+ reconcile: false,
272
+ refreshFromDisk: true,
273
+ mutate: (g) => ({
274
+ ...g,
275
+ status: "blocked" as const,
276
+ stopReason: "agent" as const,
277
+ pauseReason: reason,
278
+ updatedAt: nowIso(),
279
+ }),
280
+ ledger: (written) => [{
281
+ type: "goal_blocked",
282
+ goalId: written.id,
283
+ reason: written.pauseReason ?? "blocked",
284
+ source: "agent",
285
+ at: written.updatedAt,
286
+ }],
287
+ });
288
+ if (!result.ok) {
289
+ return {
290
+ content: [{ type: "text", text: `Goal blocked state update failed: ${result.message ?? "the state mutation was rejected"}. The goal was NOT marked blocked. Retry after resolving the conflict.` }],
291
+ details: goalDetails(core.state.goal),
292
+ terminate: false,
293
+ };
294
+ }
295
+ core.clearContinuationState();
296
+ core.clearActiveAccounting();
297
+ if (result.goal) core.runtime.markTurnStopped(result.goal.id);
298
+ core.updateUI(ctx);
299
+ return {
300
+ content: [{
301
+ type: "text",
302
+ text: "Goal blocked. Continuation stopped; the goal is waiting for the user to resume, revise, or clear it. Stop now; do not start another tool call.",
303
+ }],
304
+ details: goalDetails(core.state.goal),
305
+ terminate: true,
306
+ };
307
+ };
308
+
309
+ // Oracle disabled: existing block transition, unchanged behavior.
310
+ const settingsSnapshot = loadSettingsSnapshot(ctx.cwd);
311
+ const oracleSettings = settingsSnapshot.value.oracle;
312
+ if (!oracleSettings?.enabled) return commitBlocked();
313
+
314
+ // ── opt-in Oracle flow (issue #26) ───────────────────────────────────
315
+ const goalAtBlock = core.state.goal;
316
+ const focusToken = core.focusedOperationToken(goalAtBlock.id);
317
+ const fingerprint = buildBlockerFingerprint(goalAtBlock, reason);
318
+ const consult = goalOracleState(ctx, goalAtBlock.id, fingerprint);
319
+
320
+ // One actionable result already exists.
321
+ if (consult.result?.disposition === "actionable") {
322
+ if (!consult.followupAttempted && !hasPendingOracleAdviceForFocusedGoal(goalAtBlock.id)) {
323
+ // Re-arm without consulting again and refuse the block.
324
+ const adviceText = renderOracleAdviceReminder({
325
+ goalId: goalAtBlock.id,
326
+ fingerprint,
327
+ adviceId: consult.result.adviceId,
328
+ text: consult.result.summary,
329
+ });
330
+ return {
331
+ content: [{ type: "text", text: `${adviceText}\n\nThe goal was NOT marked blocked.` }],
332
+ details: goalDetails(goalAtBlock),
333
+ terminate: false,
334
+ };
335
+ }
336
+ return commitBlocked();
337
+ }
338
+ if (consult.result?.disposition === "needs_human" || consult.result?.disposition === "insufficient_context") {
339
+ return commitBlocked();
340
+ }
341
+ if (consult.failedAttempts >= oracleSettings.maxFailedAttemptsPerBlocker) {
342
+ try {
343
+ core.goalService.appendEvents(ctx, [{
344
+ type: "oracle_failed",
345
+ goalId: goalAtBlock.id,
346
+ fingerprint,
347
+ attempt: consult.failedAttempts,
348
+ errorCode: consult.lastFailure?.errorCode ?? "provider",
349
+ message: "failure limit reached; blocking with durable Oracle failure annotation",
350
+ at: nowIso(),
351
+ }]);
352
+ } catch { /* best-effort annotation */ }
353
+ return commitBlocked();
354
+ }
355
+
356
+ // Consult once.
357
+ try {
358
+ core.goalService.appendEvents(ctx, [{
359
+ type: "oracle_started",
360
+ goalId: goalAtBlock.id,
361
+ fingerprint,
362
+ provider: oracleSettings.provider ?? "(unresolved)",
363
+ model: oracleSettings.model ?? "(unresolved)",
364
+ thinkingLevel: oracleSettings.thinkingLevel,
365
+ reason: reason.slice(0, 1000),
366
+ at: nowIso(),
367
+ }]);
368
+ } catch { /* ledger append is best-effort for the started marker */ }
369
+
370
+ const run = await runBlockerOracle({
371
+ ctx,
372
+ goal: goalAtBlock,
373
+ reason,
374
+ attemptedActions: attemptedActions.slice(0, 8),
375
+ settings: oracleSettings as ResolvedGoalOracleSettings,
376
+ recentEvidence: "",
377
+ });
378
+
379
+ if (!core.isFocusedOperationCurrent(focusToken)) {
380
+ return core.focusedOperationCancelledResult("Blocker Oracle", focusToken);
381
+ }
382
+
383
+ if (!run.ok) {
384
+ if (run.errorCode === "aborted") {
385
+ try {
386
+ core.goalService.appendEvents(ctx, [{
387
+ type: "oracle_failed",
388
+ goalId: goalAtBlock.id,
389
+ fingerprint,
390
+ attempt: consult.failedAttempts + 1,
391
+ errorCode: "aborted",
392
+ message: run.message.slice(0, 300),
393
+ at: nowIso(),
394
+ }]);
395
+ } catch { /* best effort */ }
396
+ return {
397
+ content: [{ type: "text", text: "Oracle consultation was aborted; the goal remains active." }],
398
+ details: goalDetails(goalAtBlock),
399
+ terminate: false,
400
+ };
401
+ }
402
+ try {
403
+ core.goalService.appendEvents(ctx, [{
404
+ type: "oracle_failed",
405
+ goalId: goalAtBlock.id,
406
+ fingerprint,
407
+ attempt: consult.failedAttempts + 1,
408
+ errorCode: run.errorCode,
409
+ message: run.message.slice(0, 300),
410
+ at: nowIso(),
411
+ }]);
412
+ } catch { /* best effort */ }
413
+ const retryable = consult.failedAttempts + 1 < oracleSettings.maxFailedAttemptsPerBlocker;
414
+ return {
415
+ content: [{ type: "text", text: `Oracle consultation failed (${run.errorCode}): ${run.message.slice(0, 200)}${retryable ? " The goal remains active; try again or continue working." : ""}` }],
416
+ details: goalDetails(goalAtBlock),
417
+ terminate: false,
418
+ };
419
+ }
420
+
421
+ // Persist bounded result + arm/remind per disposition.
422
+ const advice = run.advice;
423
+ const adviceId = armOracleAdvice(goalAtBlock.id, fingerprint, advice);
424
+ const recommendedTitle = advice.alternatives[advice.recommendedIndex]?.title;
425
+ try {
426
+ core.goalService.appendEvents(ctx, [{
427
+ type: "oracle_result",
428
+ goalId: goalAtBlock.id,
429
+ fingerprint,
430
+ adviceId,
431
+ disposition: advice.disposition,
432
+ summary: advice.diagnosis.slice(0, 500),
433
+ recommendedTitle: recommendedTitle?.slice(0, 200),
434
+ at: nowIso(),
435
+ }]);
436
+ } catch { /* best effort */ }
437
+
438
+ if (advice.disposition === "needs_human" || advice.disposition === "insufficient_context") {
439
+ consumeOracleFollowupMarker(goalAtBlock.id);
440
+ return commitBlocked();
441
+ }
442
+
443
+ return {
444
+ content: [{ type: "text", text: renderActionableOracleAdvice(advice) }],
445
+ details: goalDetails(goalAtBlock),
446
+ terminate: false,
447
+ };
448
+ }
449
+
450
+ /**
451
+ * Agent-initiated immediate pause (Stage 5.1-C): update_goal({status:
452
+ * "paused"}) with a required reason pauses an active goal right away and
453
+ * records goal_paused with source "agent". It is distinct from blocked
454
+ * (three-consecutive-turn gate) and from the user-owned pause/resume
455
+ * commands.
456
+ */
457
+ async function runGoalAgentPauseFlow(ctx: ExtensionContext, reason: string | undefined, suggestedAction: string | undefined): Promise<AgentToolResult<unknown>> {
458
+ core.reconcileFocusedGoalFromDisk(ctx);
459
+ const gate = validateGoalAgentPause({ goal: core.state.goal, runningGoalId: core.runningGoalId });
460
+ if (!gate.ok) {
461
+ return { content: [{ type: "text", text: gate.message }], details: goalDetails(core.state.goal) };
462
+ }
463
+ const trimmedReason = reason?.trim() ?? "";
464
+ if (!trimmedReason) {
465
+ return { content: [{ type: "text", text: 'update_goal({ status: "paused" }) requires a "reason" describing why the work is pausing.' }], details: goalDetails(core.state.goal) };
466
+ }
467
+ if (!core.state.goal) throw new Error("Goal disappeared during pause validation.");
468
+ core.accountProgress(ctx);
469
+ const trimmedAction = suggestedAction?.trim();
470
+ const result = core.goalService.apply(ctx, {
471
+ reconcile: false,
472
+ refreshFromDisk: true,
473
+ mutate: (g) => ({
474
+ ...g,
475
+ status: "paused" as const,
476
+ autoContinue: false,
477
+ stopReason: "agent" as const,
478
+ pauseReason: trimmedReason,
479
+ pauseSuggestedAction: trimmedAction || undefined,
480
+ updatedAt: nowIso(),
481
+ }),
482
+ ledger: (written) => [{
483
+ type: "goal_paused" as const,
484
+ goalId: written.id,
485
+ reason: written.pauseReason ?? trimmedReason,
486
+ suggestedAction: written.pauseSuggestedAction,
487
+ source: "agent",
488
+ at: written.updatedAt,
489
+ }],
490
+ });
491
+ if (result.ok) {
492
+ core.clearContinuationState();
493
+ core.clearActiveAccounting();
494
+ if (result.goal) core.runtime.markTurnStopped(result.goal.id);
495
+ core.updateUI(ctx);
496
+ const suggestion = trimmedAction ? ` Suggested next step: ${trimmedAction}` : "";
497
+ return {
498
+ content: [{ type: "text", text: `Goal paused by the agent: ${trimmedReason}.${suggestion} Stop now; the user can resume with ${commandHint("goal-resume", core.loadedCommands)} or revise with ${commandHint("goal-tweak", core.loadedCommands)}.` }],
499
+ details: goalDetails(core.state.goal, `Pause reason: ${trimmedReason}${trimmedAction ? `\nSuggested action: ${trimmedAction}` : ""}`), // E7
500
+ terminate: true,
501
+ };
502
+ }
503
+ // The mutation failed: surface the conflict instead of claiming the goal is
504
+ // paused, and keep the turn alive so the agent can retry.
505
+ return {
506
+ content: [{ type: "text", text: `Goal pause update failed: ${result.message ?? "the state mutation was rejected"}. The goal was NOT paused. Retry after resolving the conflict.` }],
507
+ details: goalDetails(core.state.goal),
508
+ terminate: false,
509
+ };
510
+ }
511
+
512
+ pi.registerTool(defineTool({
513
+ name: "update_goal",
514
+ label: "Update Goal",
515
+ description: "Update lifecycle status or optionally save continuation. New waits require strictExecutionContract opt-in. Wait: future ISO deadline; reuse wait_id/deadline without polling on recheck. Completion is audited.",
516
+ promptSnippet: "Complete, block, pause, or optionally save a scheduling decision.",
517
+ promptGuidelines: [],
518
+ parameters: Type.Object({
519
+ status: Type.Optional(StringEnum(["complete", "blocked", "paused"] as const, { description: "Exclusive with continuation." })),
520
+ continuation: Type.Optional(Type.Union([
521
+ Type.Object({ kind: Type.Literal("ready") }, { additionalProperties: false }),
522
+ Type.Object({ kind: Type.Literal("wait"), reason: Type.String({ minLength: 1, maxLength: 2000 }), deadline: Type.String(), wait_id: Type.Optional(Type.String()), polling: Type.Optional(Type.Object({ interval_seconds: Type.Integer({ minimum: 1, maximum: 2147483 }), max_checks: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }) }, { additionalProperties: false })) }, { additionalProperties: false }),
523
+ ])),
524
+ reason: Type.Optional(Type.String({ description: "Required when status is paused or blocked: describe the concrete blocker." })),
525
+ attempted_actions: Type.Optional(Type.Array(Type.String({ maxLength: 240 }), { maxItems: 8, description: "Actions attempted against the blocker." })),
526
+ suggested_action: Type.Optional(Type.String({ description: "Optional suggested next step when status is paused." })),
527
+ completion_summary: Type.Optional(Type.String({ description: "Untrusted completion claim; never evidence." })),
528
+ }, { additionalProperties: false }),
529
+ executionMode: "sequential",
530
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
531
+ const planned = planUpdateGoal(params);
532
+ if (!planned.ok) return {isError: true, content: [{type: "text", text: planned.message}], details: {}, terminate: false};
533
+ core.flushGoalTransaction(ctx);
534
+ const plan = planned.plan;
535
+ switch (plan.action) {
536
+ case "continuation": return core.scheduler.declare(ctx, plan.continuation);
537
+ case "blocked": return runGoalBlockedFlow(ctx, plan.reason, plan.attemptedActions);
538
+ case "paused": return runGoalAgentPauseFlow(ctx, plan.reason, plan.suggestedAction);
539
+ case "complete": return deps.runGoalCompletionFlow(core, ctx, plan.completionSummary);
540
+ }
541
+ },
542
+ renderCall(args, theme) {
543
+ return new Text(theme.fg("toolTitle", "update_goal ") + theme.fg("muted", args?.status ?? ""), 0, 0);
544
+ },
545
+ renderResult(result, _options, theme) {
546
+ return renderGoalResult(result, _options, theme);
547
+ },
548
+ }));
549
+ }
@@ -0,0 +1,78 @@
1
+ export interface GoalUsageLike {
2
+ tokensUsed: number;
3
+ activeSeconds: number;
4
+ }
5
+
6
+ export interface GoalDisplayRecordLike {
7
+ objective: string;
8
+ status: "active" | "paused" | "blocked" | "budget_limited" | "complete";
9
+ autoContinue: boolean;
10
+ usage: GoalUsageLike;
11
+ sisyphus: boolean;
12
+ stopReason?: "user" | "agent";
13
+ }
14
+
15
+
16
+
17
+ export function truncateText(value: string, max = 120): string {
18
+ const oneLine = value.replace(/\s+/g, " ").trim();
19
+ return oneLine.length > max ? `${oneLine.slice(0, max - 3)}...` : oneLine;
20
+ }
21
+
22
+ export function displayObjectiveTitle(objective: string): string {
23
+ const lines = objective.replace(/\r/g, "").split("\n").map((line) => line.trim()).filter(Boolean);
24
+ const sectionHeader = /^(success criteria|boundaries|constraints|steps|order rules|don'ts|if blocked|if blocked \/ unclear \/ failing|sisyphus reminder)\s*[::]/i;
25
+ for (const line of lines) {
26
+ if (/^=+\s*(?:sisyphus\s+)?goal\s*=+$/i.test(line)) continue;
27
+ const objectiveMatch = line.match(/^(?:objective|目标)\s*[::]\s*(.+)$/i);
28
+ if (objectiveMatch?.[1]) return objectiveMatch[1].trim();
29
+ if (sectionHeader.test(line)) continue;
30
+ return line;
31
+ }
32
+ return truncateText(objective);
33
+ }
34
+
35
+ export function formatTokenValue(value: number): string {
36
+ const safe = Math.max(0, Math.floor(value));
37
+ const compact =
38
+ safe >= 1_000_000_000
39
+ ? `${(safe / 1_000_000_000).toFixed(safe >= 10_000_000_000 ? 0 : 1).replace(/\.0$/, "")}B`
40
+ : safe >= 1_000_000
41
+ ? `${(safe / 1_000_000).toFixed(safe >= 10_000_000 ? 0 : 1).replace(/\.0$/, "")}M`
42
+ : safe >= 10_000
43
+ ? `${(safe / 1_000).toFixed(0)}K`
44
+ : safe >= 1_000
45
+ ? `${(safe / 1_000).toFixed(1).replace(/\.0$/, "")}K`
46
+ : String(safe);
47
+ const exact = safe.toLocaleString("en-US");
48
+ if (compact === exact) return `${exact} tokens`;
49
+ return `${compact} (${exact}) tokens`;
50
+ }
51
+
52
+ export function formatDuration(seconds: number): string {
53
+ const total = Math.max(0, Math.floor(seconds));
54
+ const hours = Math.floor(total / 3600);
55
+ const minutes = Math.floor((total % 3600) / 60);
56
+ const secs = total % 60;
57
+ if (hours > 0) return `${hours}h${minutes.toString().padStart(2, "0")}m${secs.toString().padStart(2, "0")}s`;
58
+ if (minutes > 0) return `${minutes}m${secs.toString().padStart(2, "0")}s`;
59
+ return `${secs}s`;
60
+ }
61
+
62
+ export function statusLabel(goal: Pick<GoalDisplayRecordLike, "sisyphus" | "status" | "autoContinue" | "stopReason">): string {
63
+ const prefix = goal.sisyphus ? "sisyphus " : "";
64
+ if (goal.status === "active" && goal.autoContinue) return `${prefix}running`;
65
+ if (goal.status === "paused" && goal.stopReason === "agent") return `${prefix}paused (agent)`;
66
+ if (goal.status === "blocked") return `${prefix}blocked`;
67
+ if (goal.status === "budget_limited") return `${prefix}budget limited`;
68
+ return `${prefix}${goal.status}`;
69
+ }
70
+
71
+ export function footerStatus(goal: GoalDisplayRecordLike): string {
72
+ const usageBits: string[] = [];
73
+ if (goal.usage.activeSeconds > 0) usageBits.push(formatDuration(goal.usage.activeSeconds));
74
+ if (goal.usage.tokensUsed > 0) usageBits.push(formatTokenValue(goal.usage.tokensUsed).split(" ")[0]!);
75
+ const usage = usageBits.length > 0 ? ` [${usageBits.join(" ")}]` : "";
76
+ const prefix = goal.sisyphus ? "goal✊" : "goal";
77
+ return `${prefix}: ${statusLabel(goal)}${usage} - ${truncateText(goal.objective, 60)}`;
78
+ }