@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.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.
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 +219 -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 +321 -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,535 @@
1
+ import { commandHint, commandInstructions } from "./goal-controls.ts";
2
+ import { randomUUID } from "node:crypto";
3
+ import { Type } from "@earendil-works/pi-ai";
4
+ import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { Text } from "@earendil-works/pi-tui";
6
+ import { extractVerificationContract, sisyphusObjectiveSufficient } from "./goal-contract.ts";
7
+ import { buildDraftConfirmationText, buildProposalSummary, buildTweakConfirmationText, goalDraftingPrompt, type GoalDraftingFocus } from "./goal-draft.ts";
8
+ import { renderConfirmationTasks } from "./goal-task-confirmation.ts";
9
+ import { deriveTasksFromObjective } from "./goal-task-derive.ts";
10
+ import { goalDetails, renderGoalResult } from "./goal-format.ts";
11
+ import { budgetReached } from "./goal-accounting.ts";
12
+ import { buildGoalCreatedReport, formatGoalBudget } from "./goal-policy.ts";
13
+ import { loadGoalSettings } from "./goal-settings.ts";
14
+ import { DIALOG_UNAVAILABLE_HINT, proposalDialogFailureMessage, formatQuestionnaireAnswers, runGoalQuestionnaire, shouldAutoConfirmProposal, showProposalDialog, type GoalQuestionnaireQuestion, type ProposalDecision } from "./goal-questionnaire.ts";
15
+ import { currentTaskIdIsPending, nowIso, validateTokenBudgetInput, type GoalRecord, type GoalTaskList } from "./goal-record.ts";
16
+ import type { GoalCore } from "./goal-state.ts";
17
+ import { convertFlatTasks, countTasks, mergeTasksWithExisting, type FlatTaskInput } from "./goal-task-tools.ts";
18
+ import { PROPOSE_DRAFT_TOOL_NAME, QUESTIONNAIRE_TOOL_NAME, QUESTION_TOOL_NAME } from "./goal-tool-names.ts";
19
+
20
+ export type GoalDraftMode = GoalDraftingFocus | "tweak";
21
+
22
+ export const DRAFT_ENTRY = "pi-goal-draft";
23
+ export const DRAFT_ENTRY_VERSION = 1;
24
+
25
+ /**
26
+ * Branch-local durable draft session, persisted through custom session entries
27
+ * so an unconfirmed draft survives compaction and tree navigation. It is never
28
+ * a project goal file or ledger event.
29
+ */
30
+ export interface GoalDraftSession {
31
+ version: 1;
32
+ mode: GoalDraftMode;
33
+ seed: string;
34
+ targetGoalId?: string;
35
+ startedAt: string;
36
+ auditorEnabled: boolean;
37
+ /** Tombstone marker: set when the draft is cancelled, confirmed, or replaced. */
38
+ clearedAt?: string;
39
+ }
40
+
41
+ export interface ActiveGoalDraft {
42
+ mode: GoalDraftMode;
43
+ originalTopic: string;
44
+ targetGoalId?: string;
45
+ startedAt: string;
46
+ auditorEnabled: boolean;
47
+ /** E5: formatted questionnaire Q&A to echo in the created-goal report. */
48
+ questionnaireEcho?: string;
49
+ }
50
+
51
+ const activeDrafts = new WeakMap<GoalCore, ActiveGoalDraft>();
52
+
53
+ function activeDraft(core: GoalCore): ActiveGoalDraft | undefined { return activeDrafts.get(core); }
54
+
55
+ export function hasActiveDraft(core: GoalCore): boolean { return activeDraft(core) !== undefined; }
56
+
57
+ function draftSessionEntry(core: GoalCore, session: GoalDraftSession): void {
58
+ try {
59
+ core.pi.appendEntry(DRAFT_ENTRY, session);
60
+ } catch {}
61
+ }
62
+
63
+ export function clearGoalDrafting(core: GoalCore, ctx: ExtensionContext): void {
64
+ if (!activeDrafts.has(core)) return;
65
+ const existing = activeDrafts.get(core)!;
66
+ activeDrafts.delete(core);
67
+ // Tombstone the durable entry: the last entry wins on rehydration.
68
+ draftSessionEntry(core, { version: 1, mode: existing.mode, seed: existing.originalTopic, targetGoalId: existing.targetGoalId, startedAt: existing.startedAt, auditorEnabled: existing.auditorEnabled, clearedAt: nowIso() });
69
+ core.installGoalToolProfile(core.tasksEnabled);
70
+ void ctx;
71
+ }
72
+
73
+ /**
74
+ * Rehydrate a durable draft on session_start / session_tree. Restores the
75
+ * draft state only for a valid, un-cleared draft whose tweak
76
+ * target still matches the focused goal; stale tweak drafts are tombstoned.
77
+ */
78
+ export function rehydrateDraft(core: GoalCore, ctx: ExtensionContext): void {
79
+ const live = activeDraft(core);
80
+ activeDrafts.delete(core);
81
+ let session: GoalDraftSession | null = null;
82
+ try {
83
+ const entries = ctx.sessionManager.getBranch();
84
+ for (let i = entries.length - 1; i >= 0; i--) {
85
+ const entry = entries[i] as { type?: string; customType?: string; data?: unknown };
86
+ if (entry.type === "custom" && entry.customType === DRAFT_ENTRY) {
87
+ session = entry.data as GoalDraftSession;
88
+ break;
89
+ }
90
+ }
91
+ } catch {
92
+ session = null;
93
+ }
94
+ if (!session || session.version !== DRAFT_ENTRY_VERSION || session.clearedAt) {
95
+ // Reconcile settings without changing tools for branch-local draft state.
96
+ core.installGoalToolProfile(core.tasksEnabled);
97
+ return;
98
+ }
99
+ if (session.mode === "tweak") {
100
+ core.reconcileFocusedGoalFromDisk(ctx);
101
+ if (!core.state.goal || core.state.goal.id !== session.targetGoalId) {
102
+ // The tweak target is gone or no longer focused — the draft is stale.
103
+ draftSessionEntry(core, { ...session, clearedAt: nowIso() });
104
+ ctx.ui.notify("The goal tweak draft is stale (its target goal changed); it was discarded.", "warning");
105
+ core.installGoalToolProfile(core.tasksEnabled);
106
+ return;
107
+ }
108
+ }
109
+ activeDrafts.set(core, { mode: session.mode, originalTopic: session.seed, targetGoalId: session.targetGoalId, startedAt: session.startedAt, auditorEnabled: session.auditorEnabled,
110
+ ...(live?.startedAt === session.startedAt && live.originalTopic === session.seed && live.mode === session.mode && live.targetGoalId === session.targetGoalId ? {questionnaireEcho: live.questionnaireEcho} : {}),
111
+ });
112
+ core.installGoalToolProfile(core.tasksEnabled);
113
+ }
114
+
115
+ async function awaitDraftChoice(core: GoalCore, ctx: ExtensionContext, label: string): Promise<"resume" | "replace" | "cancel"> {
116
+ void core;
117
+ const choices = ["Resume the existing draft", "Replace it with a new draft", "Cancel"];
118
+ core.enterGoalModal();
119
+ try {
120
+ const selected = await ctx.ui.select(`A ${label.toLowerCase()} is already active`, choices);
121
+ if (!selected || selected === choices[0]!) return "resume";
122
+ if (selected === choices[2]) return "cancel";
123
+ return "replace";
124
+ } finally {
125
+ core.exitGoalModal();
126
+ }
127
+ }
128
+
129
+ export async function startGoalDrafting(core: GoalCore, ctx: ExtensionContext, mode: GoalDraftMode, topic: string, targetGoal?: GoalRecord): Promise<void> {
130
+ const trimmed = topic.trim();
131
+ const label = mode === "sisyphus" ? "Sisyphus draft" : mode === "tweak" ? "Goal tweak draft" : "Goal draft";
132
+ // A second draft must never silently discard the first.
133
+ if (activeDraft(core)) {
134
+ const choice = ctx.hasUI
135
+ ? await awaitDraftChoice(core, ctx, label)
136
+ : "replace"; // headless: explicit new intent wins, but not silently (notified)
137
+ if (choice === "resume") {
138
+ ctx.ui.notify(commandInstructions("A draft is already active; resuming it. Use /goal-cancel to discard it.", core.loadedCommands), "info");
139
+ return;
140
+ }
141
+ if (choice === "cancel") {
142
+ ctx.ui.notify("Draft start cancelled; the existing draft stays active.", "info");
143
+ return;
144
+ }
145
+ ctx.ui.notify("Replacing the active draft with a new " + label.toLowerCase() + ".", "warning");
146
+ clearGoalDrafting(core, ctx);
147
+ }
148
+ const startedAt = nowIso();
149
+ // §tweak-persist: a tweak confirmation defaults to the target goal's
150
+ // persisted per-goal auditor setting (skipAuditor); the global `auditor
151
+ // disabled` setting is only the fallback when the goal has no per-goal
152
+ // value. Confirming a tweak must never silently re-enable or disable the
153
+ // auditor the user chose when the goal was created.
154
+ const auditorEnabled = mode === "tweak"
155
+ ? !(targetGoal?.skipAuditor ?? loadGoalSettings(ctx.cwd).disabled)
156
+ : !loadGoalSettings(ctx.cwd).disabled;
157
+ activeDrafts.set(core, { mode, originalTopic: trimmed, targetGoalId: targetGoal?.id, startedAt, auditorEnabled });
158
+ draftSessionEntry(core, { version: 1, mode, seed: trimmed, targetGoalId: targetGoal?.id, startedAt, auditorEnabled });
159
+ core.clearContinuationState();
160
+ core.clearActiveAccounting();
161
+ core.installGoalToolProfile(core.tasksEnabled);
162
+ ctx.ui.notify(label + " started" + (trimmed ? ": " + trimmed.slice(0, 60) : "") + ". The agent will clarify, propose a goal and tasks where useful, then ask you to confirm.", "info");
163
+ const prompt = mode === "tweak" ? [
164
+ "[GOAL TWEAK DRAFT]",
165
+ "The user wants to revise the focused persistent goal. Discuss requirements as needed; do not edit files or start substantive work.",
166
+ "Propose the complete revised objective with propose_goal_draft. Preserve the current goal mode. Include a complete flat task list when the revision changes a decomposable plan; omit tasks to retain the current list.",
167
+ "For budget-only changes preserve the objective, tasks, mode and auditor. token_budget: positive integer sets the total lifetime limit; null removes it; omit to retain it. Change budgets only when requested. Do not add clarification steps when the request is clear.",
168
+ formatGoalBudget(targetGoal?.tokenBudget),
169
+ "Current objective:", targetGoal?.objective ?? "(goal no longer available)",
170
+ "User request:", trimmed || "(ask what they want to change)",
171
+ ].join("\n") : goalDraftingPrompt(trimmed, mode, core.loadedCommands);
172
+ try {
173
+ core.pi.sendUserMessage(prompt, { deliverAs: ctx.isIdle() ? "followUp" : "steer" });
174
+ } catch (err) {
175
+ clearGoalDrafting(core, ctx);
176
+ ctx.ui.notify("Could not start " + label.toLowerCase() + ": " + (err instanceof Error ? err.message : String(err)), "error");
177
+ }
178
+ }
179
+
180
+ function proposedTaskList(core: GoalCore, ctx: ExtensionContext, tasks: FlatTaskInput[] | undefined, blockCompletion: boolean | undefined): { ok: true; value?: GoalTaskList } | { ok: false; message: string } {
181
+ if (tasks === undefined) return { ok: true };
182
+ if (!core.tasksEnabled) return { ok: false, message: "Task lists are disabled by settings; omit tasks from this proposal." };
183
+ const converted = convertFlatTasks(tasks, { maxSubtaskDepth: loadGoalSettings(ctx.cwd).subtaskDepth ?? 1 });
184
+ if (!converted.ok) return converted;
185
+ return { ok: true, value: { tasks: converted.tasks, blockCompletion: blockCompletion === true, proposedAt: nowIso() } };
186
+ }
187
+
188
+ export function proposalText(draft: ActiveGoalDraft, objective: string, autoContinue: boolean, taskList: GoalTaskList | undefined, current?: GoalRecord, tokenBudget?: number | null): string {
189
+ const base = draft.mode === "tweak" && current
190
+ ? buildTweakConfirmationText({ currentObjective: current.objective, newObjective: objective, changeSummary: draft.originalTopic || "Goal revised through guided drafting.", sisyphus: current.sisyphus, tasks: taskList?.tasks })
191
+ : buildDraftConfirmationText({ focus: draft.mode === "sisyphus" ? "sisyphus" : "goal", originalTopic: draft.originalTopic, objective, autoContinue });
192
+ // The task list is always shown exactly once in a proposal: explicitly
193
+ // proposed tasks, or (new drafts) tasks derived from the RAW objective, or
194
+ // (tweaks) the current goal's list when it is retained unchanged. Deriving
195
+ // from the boxed base text would never match: formatPrefixedLines prefixes
196
+ // every objective line with "│ " so checklist/ordered markers are hidden.
197
+ let tasksText = "";
198
+ if (draft.mode === "tweak") {
199
+ // buildTweakConfirmationText already renders explicit tasks exactly once
200
+ // inside its ┌─ TASKS ─┐ box; never append a second render. When no
201
+ // explicit list is proposed the current list is retained on apply
202
+ // (taskResult.value ?? goal.taskList), so preview it for confirmation.
203
+ if (!taskList && current?.taskList && current.taskList.tasks.length > 0) {
204
+ tasksText = "\n\nCurrent task list (retained unchanged):\n" + renderConfirmationTasks(current.taskList.tasks, 0).join("\n");
205
+ }
206
+ } else if (taskList && taskList.tasks.length > 0) {
207
+ tasksText = "\n\nTasks proposed for confirmation:\n" + renderConfirmationTasks(taskList.tasks, 0).join("\n");
208
+ } else {
209
+ // §single-task-set: the derived preview must derive from the SAME
210
+ // objective text the apply path persists (extracted — the Verification
211
+ // contract line removed), so shown == persisted.
212
+ const derived = deriveTasksFromObjective(extractVerificationContract(objective).objective);
213
+ if (derived && derived.length > 0) {
214
+ tasksText = "\n\nTasks derived from the objective (confirm or ask the agent to adjust):\n" + renderConfirmationTasks(derived, 0).join("\n");
215
+ }
216
+ }
217
+ return `${draft.mode === "tweak" ? `Current ${formatGoalBudget(current?.tokenBudget)}\nProposed ${formatGoalBudget(tokenBudget === undefined ? current?.tokenBudget : tokenBudget ?? undefined)}` : formatGoalBudget(tokenBudget ?? undefined)}\n\n${base}${tasksText}`;
218
+ }
219
+
220
+ function flatTaskSchema() {
221
+ return Type.Array(Type.Object({
222
+ id: Type.String({ description: "Short stable slug, for example task-1." }),
223
+ title: Type.String({ description: "Human-readable task title." }),
224
+ parent_id: Type.Optional(Type.String({ description: "Optional parent task id in this proposal." })),
225
+ verification_contract: Type.Optional(Type.String({ description: "Evidence required for this task." })),
226
+ lightweight_subtasks: Type.Optional(Type.Boolean({ description: "True only for a task with lightweight children." })),
227
+ }), { description: "Flat parent-linked task tree to confirm with the goal." });
228
+ }
229
+
230
+ export function registerDraftingTools(core: GoalCore): void {
231
+ const { pi } = core;
232
+ pi.registerTool(defineTool({
233
+ name: QUESTION_TOOL_NAME,
234
+ label: "Ask Drafting Question",
235
+ description: "Ask one structured question during a user-started goal draft.",
236
+ promptSnippet: "Ask the user one focused drafting question.",
237
+ parameters: Type.Object({
238
+ question: Type.String({ description: "The question to ask." }),
239
+ options: Type.Optional(Type.Array(Type.String({ description: "A concise answer option." }))),
240
+ recommended: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based recommended option." })),
241
+ allow_custom: Type.Optional(Type.Boolean({ description: "Allow a custom answer; defaults to true." })),
242
+ }, { additionalProperties: false }),
243
+ async execute(_id, params, _signal, _update, ctx) {
244
+ if (!activeDraft(core)) return { content: [{ type: "text", text: commandInstructions("No guided goal draft is active. Ask the user to run /goal or /sisyphus.", core.loadedCommands) }], details: goalDetails(core.state.goal) };
245
+ core.enterGoalModal();
246
+ try {
247
+ const result = await runGoalQuestionnaire(ctx, [{ id: "question", question: params.question, options: params.options ?? [], recommended: params.recommended, allowCustom: params.allow_custom }]);
248
+ if (result.unavailable === true) return { content: [{ type: "text", text: `${DIALOG_UNAVAILABLE_HINT} Ask the question in chat instead.` }], details: goalDetails(core.state.goal) };
249
+ return { content: [{ type: "text", text: result.cancelled ? "The user cancelled the question. Continue drafting conversationally." : formatQuestionnaireAnswers(result) }], details: goalDetails(core.state.goal) };
250
+ } catch (error) {
251
+ return { content: [{ type: "text", text: `Goal drafting dialog failed: ${error instanceof Error ? error.message : String(error)}. Drafting remains active; ask in chat instead of retrying the dialog.` }], details: goalDetails(core.state.goal) };
252
+ } finally {
253
+ core.exitGoalModal();
254
+ }
255
+ },
256
+ renderCall() { return new Text("goal_question", 0, 0); },
257
+ renderResult(result, _opts, theme) { return renderGoalResult(result, _opts, theme); },
258
+ }));
259
+
260
+ pi.registerTool(defineTool({
261
+ name: QUESTIONNAIRE_TOOL_NAME,
262
+ label: "Run Drafting Questionnaire",
263
+ description: "Ask a short structured questionnaire during a user-started goal draft.",
264
+ promptSnippet: "Ask only the questions needed to make the goal and task plan concrete.",
265
+ parameters: Type.Object({
266
+ questions: Type.Array(Type.Object({
267
+ id: Type.String({ description: "Stable question id." }),
268
+ question: Type.String({ description: "Question for the user." }),
269
+ context: Type.Optional(Type.String({ description: "Optional short context." })),
270
+ options: Type.Array(Type.String({ description: "Answer option." })),
271
+ recommended: Type.Optional(Type.Integer({ minimum: 0 })),
272
+ allow_custom: Type.Optional(Type.Boolean()),
273
+ })),
274
+ }, { additionalProperties: false }),
275
+ async execute(_id, params, _signal, _update, ctx) {
276
+ if (!activeDraft(core)) return { content: [{ type: "text", text: "No guided goal draft is active." }], details: goalDetails(core.state.goal) };
277
+ const questions: GoalQuestionnaireQuestion[] = params.questions.map((q: GoalQuestionnaireQuestion & { allow_custom?: boolean }) => ({ ...q, allowCustom: q.allow_custom }));
278
+ core.enterGoalModal();
279
+ try {
280
+ const result = await runGoalQuestionnaire(ctx, questions);
281
+ if (!result.cancelled) {
282
+ const active = activeDraft(core);
283
+ if (active) active.questionnaireEcho = formatQuestionnaireAnswers(result); // E5
284
+ }
285
+ if (result.unavailable === true) return { content: [{ type: "text", text: `${DIALOG_UNAVAILABLE_HINT} Ask the questions in chat instead.` }], details: goalDetails(core.state.goal) };
286
+ return { content: [{ type: "text", text: result.cancelled ? "The user cancelled the questionnaire. Continue drafting conversationally." : formatQuestionnaireAnswers(result) }], details: goalDetails(core.state.goal) };
287
+ } catch (error) {
288
+ return { content: [{ type: "text", text: `Goal drafting dialog failed: ${error instanceof Error ? error.message : String(error)}. Drafting remains active; ask in chat instead of retrying the dialog.` }], details: goalDetails(core.state.goal) };
289
+ } finally {
290
+ core.exitGoalModal();
291
+ }
292
+ },
293
+ renderCall() { return new Text("goal_questionnaire", 0, 0); },
294
+ renderResult(result, _opts, theme) { return renderGoalResult(result, _opts, theme); },
295
+ }));
296
+
297
+ pi.registerTool(defineTool({
298
+ name: PROPOSE_DRAFT_TOOL_NAME,
299
+ label: "Propose Goal Draft",
300
+ description: "Present the drafted objective and agent-selected task plan for explicit user confirmation.",
301
+ promptSnippet: "Confirm the proposed goal and any useful task tree in one dialog.",
302
+ promptGuidelines: [
303
+ commandInstructions("Use only during a /goal, /sisyphus, or /goal-tweak guided draft.", core.loadedCommands),
304
+ "Clarify ambiguity before proposing. Include tasks when the work naturally decomposes into trackable milestones; omit them for genuinely simple work.",
305
+ "Set token_budget only when the user explicitly asked for a token budget or a spend limit. Never invent, estimate, or infer one from the objective's size; omitting it creates a goal with no budget.",
306
+ "Confirmation creates or revises the goal atomically. Continue Chatting leaves drafting active for refinement.",
307
+ ],
308
+ parameters: Type.Object({
309
+ objective: Type.String({ description: "Complete proposed objective; preserve it exactly for budget-only tweaks." }),
310
+ token_budget: Type.Optional(Type.Union([Type.Integer({minimum: 1}), Type.Null()], {description: "Only on user request: positive whole-token lifetime limit; null removes it. Omit to retain the current tweak budget or create without one."})),
311
+ auto_continue: Type.Optional(Type.Boolean({ description: "Defaults to true." })),
312
+ sisyphus: Type.Optional(Type.Boolean({ description: "Must match the user-selected goal mode." })),
313
+ tasks: Type.Optional(flatTaskSchema()),
314
+ block_completion: Type.Optional(Type.Boolean({ description: "Require task completion before goal completion." })),
315
+ }, { additionalProperties: false }),
316
+ executionMode: "sequential",
317
+ async execute(_id, params, _signal, _update, ctx) {
318
+ const draft = activeDraft(core);
319
+ if (!draft) return { content: [{ type: "text", text: commandInstructions("No guided goal draft is active. Do not create a goal without the user starting /goal or /sisyphus.", core.loadedCommands) }], details: goalDetails(core.state.goal) };
320
+ if (params.token_budget !== undefined && params.token_budget !== null) {
321
+ const validation = validateTokenBudgetInput(params.token_budget);
322
+ if (!validation.ok) return {content: [{type: "text", text: validation.message}], details: goalDetails(core.state.goal)};
323
+ }
324
+ const objective = params.objective.trim();
325
+ if (!objective) return { content: [{ type: "text", text: "The proposed objective must be at least 1 character." }], details: goalDetails(core.state.goal) };
326
+ const objectiveMaxChars = loadGoalSettings(ctx.cwd).objectiveMaxChars ?? 0;
327
+ if (objectiveMaxChars > 0 && objective.length > objectiveMaxChars) return { content: [{ type: "text", text: `The proposed objective must be at most ${objectiveMaxChars} characters (${objective.length} given).` }], details: goalDetails(core.state.goal) };
328
+ const expectedSisyphus = draft.mode === "sisyphus";
329
+ if (draft.mode !== "tweak" && ((params.sisyphus === true) !== expectedSisyphus)) return { content: [{ type: "text", text: "Proposal mode does not match the command that began this draft." }], details: goalDetails(core.state.goal) };
330
+ const taskResult = proposedTaskList(core, ctx, params.tasks as FlatTaskInput[] | undefined, params.block_completion);
331
+ if (!taskResult.ok) return { content: [{ type: "text", text: taskResult.message }], details: goalDetails(core.state.goal) };
332
+ const flushError = core.goalService.flushForAudit(ctx);
333
+ if (flushError || core.goalService.isTurnBuffered()) return {content: [{type: "text", text: "Proposal not saved: " + (flushError ?? "goal changes are still buffered")}], details: goalDetails(core.state.goal)};
334
+ core.reconcileFocusedGoalFromDisk(ctx);
335
+ const target = draft.mode === "tweak" ? core.state.goal : undefined;
336
+ if (draft.mode === "tweak" && (!target || target.id !== draft.targetGoalId)) return { content: [{ type: "text", text: commandInstructions("The goal changed while drafting; review it and start /goal-tweak again.", core.loadedCommands) }], details: goalDetails(core.state.goal) };
337
+ if (target?.status === "complete") return {content: [{type: "text", text: "Completed goals cannot be tweaked."}], details: goalDetails(target)};
338
+ const proposalToken = target ? core.focusedOperationToken(target.id) : undefined;
339
+ const proposalRevision = target?.revision ?? 0;
340
+ const proposedBudget = params.token_budget === undefined ? target?.tokenBudget : params.token_budget ?? undefined;
341
+ const budgetSummary = target ? `Current ${formatGoalBudget(target.tokenBudget)}\nProposed ${formatGoalBudget(proposedBudget)}` : formatGoalBudget(proposedBudget);
342
+ if (draft.mode === "sisyphus" && !sisyphusObjectiveSufficient(objective)) return { content: [{ type: "text", text: "A Sisyphus goal needs ordered steps with explicit per-step done criteria. Refine the objective with numbered steps (1) ..., 2) ...) or Step N: blocks before proposing again." }], details: goalDetails(core.state.goal) };
343
+ let confirmation: { decision: ProposalDecision; auditorEnabled: boolean; unavailable: boolean };
344
+ if (shouldAutoConfirmProposal({ hasUI: ctx.hasUI, autoConfirmEnv: process.env.PI_GOAL_AUTO_CONFIRM })) {
345
+ confirmation = { decision: "confirm" as const, auditorEnabled: draft.auditorEnabled, unavailable: false };
346
+ } else {
347
+ core.enterGoalModal();
348
+ try {
349
+ confirmation = await showProposalDialog(ctx, proposalText(draft, objective, params.auto_continue !== false, taskResult.value, target ?? undefined, params.token_budget), draft.mode === "sisyphus" ? "sisyphus" : "goal", draft.auditorEnabled);
350
+ } catch (error) {
351
+ return { content: [{ type: "text", text: `${proposalDialogFailureMessage(error)} Do not retry the dialog until the host issue is resolved.` }], details: goalDetails(core.state.goal) };
352
+ } finally {
353
+ core.exitGoalModal();
354
+ }
355
+ }
356
+ const settings = loadGoalSettings(ctx.cwd);
357
+ const extracted = settings.disableContracts ? { objective, verificationContract: undefined } : extractVerificationContract(objective);
358
+ // §14: the durable proposal summary is part of the transcript for
359
+ // EVERY outcome (confirm / continue refining / cancel), so the user
360
+ // can review the proposal after the dialog closes.
361
+ const summary = budgetSummary + "\n\n" + buildProposalSummary({
362
+ objective: extracted.objective,
363
+ taskList: taskResult.value,
364
+ verificationContract: extracted.verificationContract,
365
+ autoContinue: params.auto_continue !== false,
366
+ auditorEnabled: confirmation.auditorEnabled,
367
+ });
368
+ if (confirmation.decision === "cancel") {
369
+ clearGoalDrafting(core, ctx);
370
+ core.runtime.markDraftResolved();
371
+ return { content: [{ type: "text", text: `${summary}\n\nDraft cancelled; no goal was created. Run ${commandHint("goal", core.loadedCommands)} or ${commandHint("sisyphus", core.loadedCommands)} to start a new draft.` }], details: goalDetails(core.state.goal) };
372
+ }
373
+ if (confirmation.unavailable) {
374
+ return { content: [{ type: "text", text: `${summary}\n\n${DIALOG_UNAVAILABLE_HINT} The goal was NOT created and drafting remains active; do not retry the dialog until the host supports it or the user explicitly restarts with PI_GOAL_AUTO_CONFIRM=1.` }], details: goalDetails(core.state.goal) };
375
+ }
376
+ if (confirmation.decision !== "confirm") {
377
+ // Continue refining: preserve the user's auditor choice for the
378
+ // next proposal (memory and the durable session entry).
379
+ if (confirmation.auditorEnabled !== draft.auditorEnabled) {
380
+ const next = { ...draft, auditorEnabled: confirmation.auditorEnabled };
381
+ activeDrafts.set(core, next);
382
+ draftSessionEntry(core, { version: 1, mode: next.mode, seed: next.originalTopic, targetGoalId: next.targetGoalId, startedAt: next.startedAt, auditorEnabled: next.auditorEnabled });
383
+ }
384
+ return { content: [{ type: "text", text: `${summary}\n\nGoal draft refinement requested. The goal was not changed; ask what the user wants revised before proposing again.` }], details: goalDetails(core.state.goal) };
385
+ }
386
+ const skipAuditor = confirmation.auditorEnabled === false;
387
+ // §14: capture the questionnaire answers BEFORE clearing draft state so
388
+ // the confirmed-goal report can include them reliably (E5).
389
+ const qaEcho = draft.questionnaireEcho;
390
+ if (draft.mode !== "tweak") {
391
+ // F2: if the confirmation carried no task plan but the objective has
392
+ // structure, bootstrap the derived tree so the goal starts trackable.
393
+ const effectiveTaskList: GoalTaskList | undefined = taskResult.value ?? (() => {
394
+ const derived = deriveTasksFromObjective(extracted.objective);
395
+ return derived && derived.length > 0 ? { tasks: derived, blockCompletion: false, proposedAt: nowIso() } : undefined;
396
+ })();
397
+ core.replaceGoal({ objective: extracted.objective, autoContinue: params.auto_continue !== false, sisyphus: expectedSisyphus, taskList: effectiveTaskList, skipAuditor }, ctx, true, extracted.verificationContract, proposedBudget);
398
+ clearGoalDrafting(core, ctx);
399
+ core.runtime.markDraftResolved();
400
+ const created = core.state.goal;
401
+ return { content: [{ type: "text", text: `${summary}\n\n${buildGoalCreatedReport({
402
+ objective: extracted.objective,
403
+ detailedSummary: qaEcho,
404
+ confirmed: true,
405
+ goalId: created?.id,
406
+ filePath: created?.activePath,
407
+ taskCount: created?.taskList ? countTasks(created.taskList.tasks) : undefined,
408
+ verificationContract: created?.verificationContract,
409
+ auditorEnabled: !skipAuditor,
410
+ tokenBudget: created?.tokenBudget,
411
+ })}` }], details: goalDetails(core.state.goal), terminate: true };
412
+ }
413
+ if (!target) return { content: [{ type: "text", text: commandInstructions("The goal changed while drafting; review it and start /goal-tweak again.", core.loadedCommands) }], details: goalDetails(core.state.goal) };
414
+ const token = proposalToken!;
415
+ const finalFlushError = core.goalService.flushForAudit(ctx);
416
+ core.reconcileFocusedGoalFromDisk(ctx);
417
+ if (finalFlushError || core.goalService.isTurnBuffered() || !core.isFocusedOperationCurrent(token) || core.state.goal?.revision !== proposalRevision || core.state.goal?.status === "complete") return {content: [{type: "text", text: "Goal tweak was not applied: the goal changed during confirmation. Review and propose again."}], details: goalDetails(core.state.goal)};
418
+ const now = nowIso();
419
+ // §7.5 merge: a goal tweak must never change the status of steps that
420
+ // persist across the tweak. The proposed list merges into the goal's
421
+ // existing tree by id (same semantics as set_goal_tasks): surviving ids
422
+ // keep status/evidence/completedAt/skippedAt/skipReason, new ids start
423
+ // pending, removed ids drop. currentTaskId survives only while its task
424
+ // is still pending in the merged tree.
425
+ // §tweak-resume: a confirmed tweak is a deliberate revision of a
426
+ // stalled goal — set when the goal actually transitions out of
427
+ // paused/blocked so the post-apply glue can resume accounting,
428
+ // continuation, and the ledger event.
429
+ let resumed = false;
430
+ const result = core.goalService.apply(ctx, {
431
+ reconcile: false, focusToken: token, refreshFromDisk: true,
432
+ mutate: (goal) => {
433
+ const proposed = taskResult.value;
434
+ const mergedTaskList = proposed && goal.taskList
435
+ ? { ...proposed, tasks: mergeTasksWithExisting(goal.taskList.tasks, proposed.tasks) }
436
+ : proposed;
437
+ const taskList = mergedTaskList ?? goal.taskList;
438
+ const currentTaskId = mergedTaskList && goal.currentTaskId && !currentTaskIdIsPending(mergedTaskList.tasks, goal.currentTaskId)
439
+ ? undefined
440
+ : goal.currentTaskId;
441
+ // §tweak-resume: resume a paused/blocked goal (mirror the
442
+ // /goal-resume transition: status active + pause metadata
443
+ // cleared). A budget-limited goal needs an explicit budget
444
+ // change that clears the resource gate; preserve scheduler limits.
445
+ const exhausted = budgetReached({...goal, tokenBudget: proposedBudget});
446
+ const wantsResume = goal.status === "paused" || goal.status === "blocked" || (goal.status === "budget_limited" && params.token_budget !== undefined && !exhausted);
447
+ const scheduler = goal.scheduler;
448
+ const owned = !scheduler || (scheduler.owner === (ctx.sessionManager.getSessionId() || "unknown-session") && !["interrupted", "claimed"].includes(scheduler.phase));
449
+ const limit = loadGoalSettings(ctx.cwd).maxAutonomousRuns;
450
+ const allowance = limit === undefined || (scheduler?.used ?? 0) < limit;
451
+ const stalled = wantsResume && !exhausted && owned && allowance;
452
+ if (stalled) resumed = true;
453
+ return {
454
+ ...goal,
455
+ tokenBudget: proposedBudget,
456
+ ...(exhausted && owned && scheduler ? {scheduler: {...scheduler, generation: randomUUID(), phase: "idle" as const, decision: undefined, dispatch: undefined, wait: undefined}} : {}),
457
+ objective: extracted.objective,
458
+ verificationContract: extracted.verificationContract ?? goal.verificationContract,
459
+ taskList, currentTaskId, skipAuditor,
460
+ status: exhausted && (goal.status === "active" || goal.status === "budget_limited" || wantsResume) ? "budget_limited" : stalled ? "active" : wantsResume ? "paused" : goal.status,
461
+ autoContinue: stalled ? true : goal.autoContinue,
462
+ stopReason: stalled ? undefined : goal.stopReason,
463
+ pauseReason: stalled ? undefined : goal.pauseReason,
464
+ pauseSuggestedAction: stalled ? undefined : goal.pauseSuggestedAction,
465
+ updatedAt: now,
466
+ };
467
+ },
468
+ ledger: (written) => [...(target.tokenBudget !== written.tokenBudget ? [{type: "goal_budget_changed" as const, goalId: written.id, oldBudget: target.tokenBudget ?? null, newBudget: written.tokenBudget ?? null, tokensUsed: written.usage.tokensUsed, at: written.updatedAt}] : []), ...(target.status !== "budget_limited" && written.status === "budget_limited" ? [{type: "goal_budget_limited" as const, goalId: written.id, budget: written.tokenBudget!, tokensUsed: written.usage.tokensUsed, at: written.updatedAt}] : []), { type: "goal_tweaked", goalId: written.id, changeSummary: "Goal revised through /goal-tweak drafting.", at: written.updatedAt }, ...(taskResult.value ? [{ type: "task_list_set" as const, goalId: written.id, taskCount: countTasks(written.taskList?.tasks), blockCompletion: taskResult.value.blockCompletion, at: written.updatedAt }] : [])],
469
+ });
470
+ if (!result.ok) return { content: [{ type: "text", text: "Goal tweak was not applied: " + result.message }], details: goalDetails(core.state.goal) };
471
+ if (resumed) {
472
+ try {
473
+ core.goalService.appendEvents(ctx, [{ type: "goal_resumed", goalId: result.goal.id, reason: "tweak", at: nowIso() }]);
474
+ } catch {
475
+ // Ledger append failure should not crash the tweak.
476
+ }
477
+ }
478
+ core.clearContinuationState();
479
+ if (!budgetReached(result.goal)) core.runtime.consumePostBudgetReminder();
480
+ else { core.runtime.armPostBudgetReminder(); core.clearActiveAccounting(); core.scheduler.cancelTimer(); }
481
+ core.updateUI(ctx);
482
+ let schedulingNote = "";
483
+ if (resumed) {
484
+ // Resume glue mirrors replaceGoal: restart accounting and queue
485
+ // the auto-continuation so the revived goal keeps going.
486
+ core.beginAccounting();
487
+ const scheduling = core.scheduler.declare(ctx, {kind: "ready"});
488
+ if (!scheduling.terminate) schedulingNote = "\n" + scheduling.content.filter(item => item.type === "text").map(item => item.text).join("\n");
489
+ core.queueContinuation(ctx, true);
490
+ }
491
+ clearGoalDrafting(core, ctx);
492
+ core.runtime.markDraftResolved();
493
+ return { content: [{ type: "text", text: `${summary}\n\nGoal tweak confirmed and applied.\n${formatGoalBudget(result.goal.tokenBudget)}${schedulingNote}${result.goal.status === "budget_limited" ? "\nConsumed tokens already meet this budget; the goal remains budget-limited." : result.goal.status === "paused" ? commandInstructions("\nGoal remains paused. Use /goal-resume to continue when scheduling permits.", core.loadedCommands) : ""}` }], details: goalDetails(result.goal), terminate: true };
494
+ },
495
+ renderCall(args, theme) {
496
+ // §proposal-presentation: the tool-call display lands in the terminal
497
+ // buffer the moment the tool call starts — BEFORE the confirmation
498
+ // dialog opens — so it is the scrollable "complete presentation" of
499
+ // the goal. It must show the full objective contract AND every task
500
+ // line; the dialog frame alone cannot (its churn-guard bound keeps
501
+ // it within the terminal height). The user can scroll up and read
502
+ // everything while the dialog is open ("the user can just scroll").
503
+ const objective = String(args?.objective ?? "");
504
+ let text = theme.fg("toolTitle", "propose_goal_draft ") + theme.fg("muted", objective);
505
+ const flatTasks = Array.isArray(args?.tasks) ? (args.tasks as FlatTaskInput[]) : [];
506
+ // §single-task-set: a proposal shows exactly ONE task set — the set
507
+ // that will actually be persisted. Explicit tasks are that set; a
508
+ // tweak without explicit tasks retains the current list (mirror
509
+ // proposalText — never a derived-from-objective phantom); a new
510
+ // draft without explicit tasks shows the F2-derived preview derived
511
+ // from the SAME objective text the apply path persists (extracted).
512
+ let taskBlock: { header: string; lines: string[] } | null = null;
513
+ if (flatTasks.length > 0) {
514
+ taskBlock = { header: "Tasks proposed for confirmation:", lines: flatTasks.map((t) => `[ ] ${t.id}: ${t.title}`) };
515
+ } else if (activeDraft(core)?.mode === "tweak") {
516
+ const retained = core.state.goal?.taskList?.tasks;
517
+ if (retained && retained.length > 0) {
518
+ taskBlock = { header: "Current task list (retained unchanged):", lines: renderConfirmationTasks(retained, 0) };
519
+ }
520
+ } else {
521
+ const derived = deriveTasksFromObjective(extractVerificationContract(objective).objective) ?? [];
522
+ if (derived.length > 0) {
523
+ taskBlock = { header: "Tasks derived from the objective (confirm or ask the agent to adjust):", lines: renderConfirmationTasks(derived, 0) };
524
+ }
525
+ }
526
+ if (taskBlock) {
527
+ text += "\n\n" + theme.fg("toolTitle", theme.bold(taskBlock.header)) + "\n" + theme.fg("toolOutput", taskBlock.lines.join("\n"));
528
+ }
529
+ if (args?.auto_continue === false) text += "\n\n" + theme.fg("muted", "Automatic continuation: disabled");
530
+ if (args?.block_completion === true) text += "\n\n" + theme.fg("muted", "Block completion on pending tasks: enabled");
531
+ return new Text(text, 0, 0);
532
+ },
533
+ renderResult(result, _opts, theme) { return renderGoalResult(result, _opts, theme); },
534
+ }));
535
+ }