@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.
- package/LICENSE +21 -0
- package/README.md +131 -2
- package/assets/badge-dark.svg +18 -0
- package/assets/badge-light.svg +18 -0
- package/docs/advanced-usage.md +106 -0
- package/docs/agent-flow-design.md +306 -0
- package/docs/agentic-runtime-prd.md +773 -0
- package/docs/architecture.md +368 -0
- package/docs/controls.md +140 -0
- package/docs/pi-autoresearch-survey.md +50 -0
- package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
- package/docs/prompt-caching.md +31 -0
- package/docs/unified-dashboard.md +286 -0
- package/extensions/auditor-selector.ts +74 -0
- package/extensions/goal-accounting.ts +107 -0
- package/extensions/goal-activity.ts +202 -0
- package/extensions/goal-auditor.ts +480 -0
- package/extensions/goal-commands.ts +866 -0
- package/extensions/goal-compaction.ts +197 -0
- package/extensions/goal-completion.ts +173 -0
- package/extensions/goal-context.ts +12 -0
- package/extensions/goal-contract.ts +58 -0
- package/extensions/goal-controls.ts +202 -0
- package/extensions/goal-core-tools.ts +549 -0
- package/extensions/goal-core.ts +78 -0
- package/extensions/goal-detail.ts +65 -0
- package/extensions/goal-dialog-controls.ts +31 -0
- package/extensions/goal-draft.ts +244 -0
- package/extensions/goal-drafting.ts +535 -0
- package/extensions/goal-events.ts +692 -0
- package/extensions/goal-format.ts +376 -0
- package/extensions/goal-interruption.ts +53 -0
- package/extensions/goal-ledger-index.ts +68 -0
- package/extensions/goal-ledger.ts +1007 -0
- package/extensions/goal-live-retention.ts +219 -0
- package/extensions/goal-oracle.ts +346 -0
- package/extensions/goal-policy.ts +370 -0
- package/extensions/goal-pool.ts +103 -0
- package/extensions/goal-prompt-cache.ts +80 -0
- package/extensions/goal-questionnaire.ts +1073 -0
- package/extensions/goal-record.ts +388 -0
- package/extensions/goal-recovery.ts +256 -0
- package/extensions/goal-runtime.ts +277 -0
- package/extensions/goal-scheduler-state.ts +100 -0
- package/extensions/goal-scheduler.ts +436 -0
- package/extensions/goal-service.ts +843 -0
- package/extensions/goal-session-health.ts +130 -0
- package/extensions/goal-session-safety.ts +65 -0
- package/extensions/goal-settings.ts +1370 -0
- package/extensions/goal-state.ts +1069 -0
- package/extensions/goal-status.ts +305 -0
- package/extensions/goal-task-confirmation.ts +174 -0
- package/extensions/goal-task-count.ts +59 -0
- package/extensions/goal-task-derive.ts +46 -0
- package/extensions/goal-task-index.ts +57 -0
- package/extensions/goal-task-tools.ts +595 -0
- package/extensions/goal-tool-inputs.ts +102 -0
- package/extensions/goal-tool-names.ts +73 -0
- package/extensions/goal-tools.ts +16 -0
- package/extensions/goal-widget.ts +455 -0
- package/extensions/goal.ts +42 -0
- package/extensions/network-error-backoff.ts +55 -0
- package/extensions/prompts/goal-prompts.ts +321 -0
- package/extensions/storage/atomic-rename.ts +52 -0
- package/extensions/storage/goal-files.ts +695 -0
- package/extensions/storage/goal-lock.ts +131 -0
- package/extensions/storage/goal-root.ts +95 -0
- package/extensions/widgets/auditor-dashboard-model.ts +174 -0
- package/extensions/widgets/dialog-scaffold.ts +26 -0
- package/extensions/widgets/goal-controls-dialog.ts +329 -0
- package/extensions/widgets/goal-dashboard-model.ts +530 -0
- package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
- package/extensions/widgets/goal-escape-dialog.ts +148 -0
- package/extensions/widgets/goal-notifications.ts +9 -0
- package/extensions/widgets/goal-widget.ts +723 -0
- package/extensions/widgets/task-list-overlay.ts +409 -0
- package/extensions/widgets/text-cache.ts +30 -0
- package/package.json +117 -4
- 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
|
+
}
|