@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,305 @@
1
+ import { commandHint, type GoalCommands } from "./goal-controls.ts";
2
+ import type { GoalKeybindings } from "./goal-settings.ts";
3
+ /**
4
+ * /goal-status text builder (plan §13).
5
+ *
6
+ * Renders the SAME shared dashboard model as the above-editor widget: the
7
+ * standard mode composes a static compact-dashboard box, the current-task
8
+ * details block, recent activity, and the last audit result; verbose mode adds
9
+ * the full diagnostic detail (§13.2). Pure module — no TUI imports, no ctx —
10
+ * so the exact text is unit-testable and can never drift from the widget.
11
+ */
12
+
13
+ import { truncateText } from "./goal-core.ts";
14
+ import type { GoalLedgerEvent, GoalLedgerEvent as LedgerEvent } from "./goal-ledger.ts";
15
+ import { latestAuditorResultForGoal, latestAuditForGoal, latestEventsForGoal } from "./goal-ledger.ts";
16
+ import type { GoalRecord, GoalTask } from "./goal-record.ts";
17
+ import { deriveGoalDashboardModel, formatCompactTokens, formatDashboardDuration } from "./widgets/goal-dashboard-model.ts";
18
+ import {
19
+ renderActivityBlock,
20
+ renderCompactDashboard,
21
+ renderCurrentTaskBlock,
22
+ renderUnfocusedDashboard,
23
+ } from "./widgets/goal-dashboard-renderer.ts";
24
+ import { formatCheckpointHealthReport, type CheckpointHealth } from "./goal-session-health.ts";
25
+
26
+ /** Fixed rendering width for the notify text (medium layout mode). */
27
+ export const GOAL_STATUS_WIDTH = 78;
28
+
29
+ /** Identity theme: the box text is emitted as plain text for ctx.ui.notify. */
30
+ export const PLAIN_THEME = {
31
+ fg: (_color: string, value: string) => value,
32
+ bold: (value: string) => value,
33
+ } as never;
34
+
35
+ export interface GoalStatusTextOptions {
36
+ commands?: GoalCommands;
37
+ keybindings?: GoalKeybindings;
38
+ maxAutonomousRuns?: number;
39
+ idleContinuationBackoff?: boolean;
40
+ goal: GoalRecord | null;
41
+ focused: boolean;
42
+ otherOpenGoals: number;
43
+ ledgerEvents?: GoalLedgerEvent[];
44
+ ledgerMalformed?: number;
45
+ verbose?: boolean;
46
+ health?: boolean;
47
+ /** Supplied by the command layer only for the explicit health check. */
48
+ activeFilePresent?: boolean;
49
+ /** Issue #30: persisted-checkpoint report for this session (health view only). */
50
+ checkpointHealth?: CheckpointHealth | null;
51
+ checkpointSessionFile?: string;
52
+ /** Effective settings lines with provenance (verbose only). */
53
+ settingsReport?: string[];
54
+ width?: number;
55
+ }
56
+
57
+ /**
58
+ * Build the /goal-status text.
59
+ *
60
+ * Standard (§13.1): static compact-dashboard rendering + current-task details
61
+ * + recent activity + last audit result when available. The terminology and
62
+ * hierarchy match the expanded dashboard (§13.3); no effective settings by
63
+ * default.
64
+ *
65
+ * Verbose (§13.2): goal id, revision, full objective, complete task tree with
66
+ * full evidence and contracts, recent ledger history, effective settings and
67
+ * provenance, token budget detail, pause/blocker detail, paths, and the last
68
+ * audit report.
69
+ */
70
+ export function buildGoalStatusText(options: GoalStatusTextOptions): string {
71
+ const width = options.width ?? GOAL_STATUS_WIDTH;
72
+ if (options.health) return buildHealthStatus(options, width);
73
+ if (!options.goal) {
74
+ return options.otherOpenGoals > 0
75
+ ? renderUnfocusedDashboard(options.otherOpenGoals, PLAIN_THEME, width, options.commands).join("\n")
76
+ : `No goal is set. Use ${commandHint("goal", options.commands)} or ${commandHint("sisyphus", options.commands)} to draft a goal.`;
77
+ }
78
+ const events = options.ledgerEvents ?? [];
79
+ const model = deriveGoalDashboardModel(options.goal, {
80
+ focused: options.focused,
81
+ maxAutonomousRuns: options.maxAutonomousRuns,
82
+ idleContinuationBackoff: options.idleContinuationBackoff,
83
+ commands: options.commands,
84
+ otherOpenGoals: options.otherOpenGoals,
85
+ ledgerEvents: events,
86
+ });
87
+ if (!model) return "No goal is set.";
88
+
89
+ if (options.verbose) {
90
+ return buildVerboseStatus(options.goal, model, events, options.settingsReport ?? [], width);
91
+ }
92
+
93
+ const parts: string[] = [];
94
+ parts.push(renderCompactDashboard(model, PLAIN_THEME, width, { footerHint: `Run ${commandHint("goal-status", options.commands)} verbose for full detail`, keybindings: options.keybindings?.dashboard, auditorKey: options.keybindings?.toggleAuditor }).join("\n"));
95
+ if (model.currentTask) {
96
+ parts.push(renderCurrentTaskBlock(model, PLAIN_THEME, width).join("\n"));
97
+ }
98
+ if (model.recentActivity.length > 0) {
99
+ parts.push(renderActivityBlock(model.recentActivity, PLAIN_THEME, width).join("\n"));
100
+ }
101
+ const audit = latestAuditorResultForGoal(events, model.goalId);
102
+ if (latestAuditForGoal(events, model.goalId)?.type === "audit_started") parts.push("Latest audit: no recorded result.");
103
+ if (audit) {
104
+ parts.push(lastAuditBlock(audit, width));
105
+ }
106
+ return parts.join("\n\n");
107
+ }
108
+
109
+ interface HealthCheck {
110
+ label: string;
111
+ value: string;
112
+ severity: "ok" | "warn" | "error";
113
+ }
114
+
115
+ /**
116
+ * Render a concise, read-only integrity report. This deliberately checks
117
+ * storage/runtime coherence only; it does not infer that the work itself is
118
+ * complete from task counts or contracts.
119
+ */
120
+ function buildHealthStatus(options: GoalStatusTextOptions, width: number): string {
121
+ const goal = options.goal;
122
+ if (!goal) {
123
+ const focus = options.otherOpenGoals > 0
124
+ ? `WARN — no goal is focused; ${options.otherOpenGoals} open goal${options.otherOpenGoals === 1 ? "" : "s"} remain.`
125
+ : "OK — no goal is focused and there are no open goals.";
126
+ return ["Goal health: " + (options.otherOpenGoals > 0 ? "WARN" : "OK"), `Focus: ${focus}`, "Run /goal-status for the normal dashboard."].join("\n");
127
+ }
128
+
129
+ const checks: HealthCheck[] = [];
130
+ checks.push({
131
+ label: "Focus",
132
+ value: options.focused ? "focused" : "not focused",
133
+ severity: options.focused ? "ok" : "warn",
134
+ });
135
+ checks.push({
136
+ label: "Lifecycle",
137
+ value: `${statusLabelForHealth(goal)}${goal.autoContinue ? " · auto-continue on" : " · auto-continue off"}`,
138
+ severity: goal.status === "active" || goal.status === "paused" || goal.status === "blocked" || goal.status === "budget_limited" || goal.status === "complete" ? "ok" : "error",
139
+ });
140
+
141
+ if (goal.status !== "complete") {
142
+ const fileSeverity = options.activeFilePresent === false ? "error" : options.activeFilePresent === true ? "ok" : "warn";
143
+ checks.push({
144
+ label: "Goal file",
145
+ value: goal.activePath ? (options.activeFilePresent === false ? `missing · ${goal.activePath}` : goal.activePath) : "missing active path",
146
+ severity: fileSeverity,
147
+ });
148
+ }
149
+
150
+ const malformed = options.ledgerMalformed ?? 0;
151
+ checks.push({
152
+ label: "Ledger",
153
+ value: malformed > 0 ? `${malformed} malformed entr${malformed === 1 ? "y" : "ies"}` : "valid",
154
+ severity: malformed > 0 ? "warn" : "ok",
155
+ });
156
+
157
+ if (goal.taskList) {
158
+ const tasks = flattenHealthTasks(goal.taskList.tasks);
159
+ const completed = tasks.filter((task) => task.status === "complete" || task.status === "skipped").length;
160
+ const pending = tasks.length - completed;
161
+ const contractedPending = tasks.filter((task) => task.status === "pending" && Boolean(task.verificationContract?.trim())).length;
162
+ checks.push({
163
+ label: "Tasks",
164
+ value: `${completed}/${tasks.length} terminal · ${pending} pending${contractedPending > 0 ? ` · ${contractedPending} contracted` : ""}`,
165
+ severity: goal.taskList.blockCompletion && pending > 0 ? "warn" : "ok",
166
+ });
167
+ }
168
+
169
+ if (typeof goal.tokenBudget === "number" && goal.tokenBudget > 0) {
170
+ const percentage = Math.round((goal.usage.tokensUsed / goal.tokenBudget) * 100);
171
+ checks.push({
172
+ label: "Budget",
173
+ value: `${formatCompactTokensForHealth(goal.usage.tokensUsed)} / ${formatCompactTokensForHealth(goal.tokenBudget)} (${Math.max(0, percentage)}%)`,
174
+ severity: goal.status === "budget_limited" || percentage >= 100 ? "warn" : percentage >= 90 ? "warn" : "ok",
175
+ });
176
+ }
177
+
178
+ const overall = checks.some((check) => check.severity === "error") ? "ERROR" : checks.some((check) => check.severity === "warn") ? "WARN" : "OK";
179
+ const lines = [`Goal health: ${overall}`, `Goal: ${truncateText(goal.objective, Math.max(20, width - 12))}`];
180
+ for (const check of checks) lines.push(`${check.severity === "error" ? "ERROR" : check.severity === "warn" ? "WARN" : "OK"} ${check.label}: ${check.value}`);
181
+ if (options.checkpointHealth && options.checkpointHealth.total > 0) {
182
+ lines.push("");
183
+ lines.push(formatCheckpointHealthReport(options.checkpointHealth, options.checkpointSessionFile));
184
+ }
185
+ lines.push("", "This is a storage/runtime health check, not a completion verdict.");
186
+ return lines.join("\n");
187
+ }
188
+
189
+ function flattenHealthTasks(tasks: GoalTask[]): GoalTask[] {
190
+ const result: GoalTask[] = [];
191
+ for (const task of tasks) {
192
+ result.push(task);
193
+ if (task.subtasks) result.push(...flattenHealthTasks(task.subtasks));
194
+ }
195
+ return result;
196
+ }
197
+
198
+ function statusLabelForHealth(goal: GoalRecord): string {
199
+ return goal.status === "budget_limited" ? "budget limited" : goal.status;
200
+ }
201
+
202
+ function formatCompactTokensForHealth(value: number): string {
203
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
204
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
205
+ return String(Math.max(0, Math.floor(value)));
206
+ }
207
+
208
+ function lastAuditBlock(
209
+ audit: { verdict: "approved" | "disapproved" | "error"; report: string; at: string },
210
+ width: number,
211
+ ): string {
212
+ const verdictLabel = audit.verdict === "approved" ? "APPROVED" : audit.verdict === "disapproved" ? "CHANGES REQUIRED" : "ERROR";
213
+ const line = `Last audit: ${verdictLabel} (${audit.at.slice(0, 10)}) — ${truncateText(audit.report.replace(/\s+/g, " "), 90)}`;
214
+ const inner = Math.max(4, width - 2);
215
+ return [
216
+ `╭─ Audit result ─ ${verdictLabel} ${"─".repeat(Math.max(1, inner - visibleLen(`─ Audit result ─ ${verdictLabel} `) - 1))}╮`,
217
+ `│ ${truncateText(line, inner - 2)}${" ".repeat(Math.max(0, inner - 2 - truncateText(line, inner - 2).length))}│`,
218
+ `╰${"─".repeat(inner)}╯`,
219
+ ].join("\n");
220
+ }
221
+
222
+ function visibleLen(value: string): number {
223
+ return value.length;
224
+ }
225
+
226
+ // ── Verbose (§13.2) ─────────────────────────────────────────────────────────
227
+
228
+ function buildVerboseStatus(goal: GoalRecord, model: ReturnType<typeof deriveGoalDashboardModel>, events: GoalLedgerEvent[], settingsReport: string[], _width: number): string {
229
+ const lines: string[] = [];
230
+ lines.push(`Goal id: ${goal.id}`);
231
+ lines.push(...(model?.scheduling ?? []));
232
+ lines.push(`Revision: ${goal.revision ?? 0}`);
233
+ lines.push(`Status: ${model?.status.label}${model?.focused ? " (focused)" : " (not focused)"}`);
234
+ lines.push(`Mode: ${goal.sisyphus ? "sisyphus" : "goal"} · auto-continue: ${goal.autoContinue ? "on" : "off"}`);
235
+ lines.push(`Usage: ${formatDashboardDuration(goal.usage.activeSeconds)} · ${formatCompactTokens(goal.usage.tokensUsed)} tokens`);
236
+ if (model?.budget) {
237
+ lines.push(`Budget: ${formatCompactTokens(model.budget.used)} / ${formatCompactTokens(model.budget.total)} · ${model.budget.percentage}% used · ${formatCompactTokens(model.budget.remaining)} remaining`);
238
+ }
239
+ if (goal.pauseReason) lines.push(`Pause/blocker: ${goal.pauseReason}`);
240
+ if (goal.pauseSuggestedAction) lines.push(`Suggested action: ${goal.pauseSuggestedAction}`);
241
+ if (goal.activePath) lines.push(`File: ${goal.activePath}`);
242
+ if (goal.archivedPath) lines.push(`Archive: ${goal.archivedPath}`);
243
+
244
+ lines.push("", "Objective:", goal.objective.trim());
245
+
246
+ if (goal.verificationContract) {
247
+ lines.push("", `Verification contract: ${goal.verificationContract.trim()}`);
248
+ }
249
+ if (goal.taskList) {
250
+ lines.push("", `Tasks (${topLevelCount(goal.taskList.tasks)} top-level, ${goal.taskList.tasks.filter((t) => t.status === "complete" || t.status === "skipped").length} done):`);
251
+ for (const line of renderTaskTreeVerbose(goal.taskList.tasks, 0, goal.currentTaskId)) {
252
+ lines.push(line);
253
+ }
254
+ }
255
+ if (model?.currentTask) {
256
+ lines.push("", `Current task: ${model.currentTask.id} · ${model.currentTask.title}${model.currentTask.inferred ? " (inferred)" : ""}`);
257
+ if (model.currentTask.totalSubtasks > 0) {
258
+ lines.push(` subtasks: ${model.currentTask.completedSubtasks}/${model.currentTask.totalSubtasks} (${model.currentTask.subtaskPercentage}%)`);
259
+ }
260
+ if (model.currentTask.verificationContract) lines.push(` contract: ${model.currentTask.verificationContract}`);
261
+ if (model.currentTask.evidence) lines.push(` evidence: ${model.currentTask.evidence}`);
262
+ }
263
+
264
+ const recent = latestEventsForGoal(events, goal.id, 12);
265
+ if (recent.length > 0) {
266
+ lines.push("", "Recent ledger:");
267
+ for (const event of recent) {
268
+ lines.push(` ${event.at.slice(0, 19)} ${event.type}`);
269
+ }
270
+ }
271
+ const audit = latestAuditorResultForGoal(events, goal.id);
272
+ if (latestAuditForGoal(events, goal.id)?.type === "audit_started") lines.push("", "Latest audit: no recorded result.");
273
+ if (audit) {
274
+ lines.push("", `Last audit (${audit.at.slice(0, 10)}): ${audit.verdict}`);
275
+ lines.push(truncateText(audit.report.replace(/\s+/g, " "), 400));
276
+ }
277
+ if (settingsReport.length > 0) {
278
+ lines.push("", "Effective settings:");
279
+ lines.push(...settingsReport.map((line) => ` ${line}`));
280
+ }
281
+ return lines.join("\n");
282
+ }
283
+
284
+ function topLevelCount(tasks: GoalTask[]): number {
285
+ return tasks.length;
286
+ }
287
+
288
+ function renderTaskTreeVerbose(tasks: GoalTask[], depth = 0, currentTaskId?: string): string[] {
289
+ const lines: string[] = [];
290
+ const indent = " ".repeat(depth);
291
+ for (const t of tasks) {
292
+ const isCurrent = t.id === currentTaskId;
293
+ const marker = isCurrent ? "▸" : t.status === "complete" ? "✓" : t.status === "skipped" ? "~" : "·";
294
+ const contract = t.verificationContract ? ` — contract: ${t.verificationContract}` : "";
295
+ const evidence = t.status === "complete" && t.evidence ? ` — evidence: ${t.evidence}` : "";
296
+ lines.push(`${indent}${marker} ${t.id} ${t.title}${contract}${evidence}`);
297
+ if (t.subtasks && t.subtasks.length > 0) {
298
+ lines.push(...renderTaskTreeVerbose(t.subtasks, depth + 1, currentTaskId));
299
+ }
300
+ }
301
+ return lines;
302
+ }
303
+
304
+ // Re-export for consumers that want the same event type.
305
+ export type { LedgerEvent };
@@ -0,0 +1,174 @@
1
+ import { goalDialogControls } from "./goal-dialog-controls.ts";
2
+ import { truncateToWidth } from "./widgets/text-cache.ts";
3
+ import type { Component, TUI } from "@earendil-works/pi-tui";
4
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
5
+ import type { GoalTask } from "./goal-record.ts";
6
+ import { borderedLine, dialogInnerWidth, horizontalRule } from "./widgets/dialog-scaffold.ts";
7
+
8
+ /** Render a task tree for confirmation dialogs (structural view). */
9
+ export function renderConfirmationTasks(tasks: readonly GoalTask[], indent: number): string[] {
10
+ const prefix = " ".repeat(indent);
11
+ const lines: string[] = [];
12
+ for (const t of tasks) {
13
+ const lw = t.lightweightSubtasks ? " (lightweight)" : "";
14
+ const contract = t.verificationContract ? ` contract: ${t.verificationContract}` : "";
15
+ lines.push(`${prefix}[ ] ${t.id}: ${t.title}${lw}${contract}`);
16
+ if (t.subtasks && t.subtasks.length > 0) {
17
+ lines.push(...renderConfirmationTasks(t.subtasks, indent + 1));
18
+ }
19
+ }
20
+ return lines;
21
+ }
22
+
23
+ /**
24
+ * Task-only confirmation boundary (follow-up Stage 2). The complete result is
25
+ * the user's {decision: confirm | cancel} — no auditor toggle, no
26
+ * goal-creation wording, and no questionnaire state. `set_goal_tasks` confirms
27
+ * the STRUCTURAL input here and merges progress only inside GoalService.apply
28
+ * against the disk-refreshed clone. The dialog is a small self-contained
29
+ * component with neutral, task-specific labels.
30
+ */
31
+ export interface TaskConfirmationResult {
32
+ decision: "confirm" | "cancel";
33
+ }
34
+
35
+ export async function showTaskConfirmation(ctx: ExtensionContext, proposalText: string): Promise<TaskConfirmationResult> {
36
+ if (process.env.PI_GOAL_AUTO_CONFIRM === "1") return { decision: "confirm" };
37
+ if (!ctx.hasUI) return { decision: process.env.PI_GOAL_AUTO_CONFIRM === "0" ? "cancel" : "confirm" };
38
+ if (ctx.mode !== "rpc" && typeof ctx.ui.custom === "function") {
39
+ const result = await showTaskListConfirmationDialog(ctx, proposalText);
40
+ if (result) return result;
41
+ }
42
+ if (typeof ctx.ui.select !== "function") return { decision: "cancel" };
43
+ const answer = await ctx.ui.select(`Task list confirmation\n\n${proposalText}`, ["Confirm task list", "Keep current tasks"]);
44
+ return { decision: answer === "Confirm task list" ? "confirm" : "cancel" };
45
+ }
46
+
47
+ async function showTaskListConfirmationDialog(ctx: ExtensionContext, proposalText: string): Promise<TaskConfirmationResult | undefined> {
48
+ const controls = goalDialogControls(typeof ctx.cwd === "string" ? ctx.cwd : process.cwd());
49
+ return await ctx.ui.custom<TaskConfirmationResult | undefined>(
50
+ (tui: TUI, theme: Theme, _keybindings: unknown, done: (result: TaskConfirmationResult | undefined) => void): Component => {
51
+ if (!tui || typeof tui.getShowHardwareCursor !== "function" || typeof tui.setShowHardwareCursor !== "function" || typeof tui.requestRender !== "function") {
52
+ done(undefined);
53
+ return { render: () => [], invalidate: () => {} };
54
+ }
55
+ const wasHardwareCursorShown = tui.getShowHardwareCursor();
56
+ tui.setShowHardwareCursor(false);
57
+ // Pause pi's working spinner for the dialog duration: its ~80ms re-renders
58
+ // write output that snaps a scrolled-up user back to the terminal bottom.
59
+ ctx.ui.setWorkingVisible(false);
60
+
61
+ // Default: "Confirm task list" (matches the pre-existing default).
62
+ let selectedIndex = 0;
63
+
64
+ const OPTIONS: Array<{ label: string; value: TaskConfirmationResult["decision"]; description: string }> = [
65
+ {
66
+ label: "Confirm task list",
67
+ value: "confirm",
68
+ description: "Replace the current task list with this structure.",
69
+ },
70
+ {
71
+ label: "Keep current tasks",
72
+ value: "cancel",
73
+ description: "Leave the existing task list unchanged.",
74
+ },
75
+ ];
76
+
77
+ const BODY_LINES = proposalText.split("\n");
78
+ const MAX_BODY = 16;
79
+
80
+ const accent = (s: string) => theme.fg("accent", s);
81
+ const dim = (s: string) => theme.fg("dim", s);
82
+ const muted = (s: string) => theme.fg("muted", s);
83
+
84
+ // ── Component ────────────────────────────────────────────────────
85
+ const component: Component & { dispose?(): void } = {
86
+ dispose() {
87
+ tui.setShowHardwareCursor(wasHardwareCursorShown);
88
+ ctx.ui.setWorkingVisible(true);
89
+ },
90
+
91
+ invalidate(): void {
92
+ // No cached state to invalidate
93
+ },
94
+
95
+ render(width: number): string[] {
96
+ const termWidth = Math.min(width, 80);
97
+ const innerWidth = dialogInnerWidth(termWidth);
98
+
99
+ const line = (leftContent: string): string => borderedLine(accent, innerWidth, leftContent);
100
+
101
+ const horizLine = horizontalRule(innerWidth);
102
+ const lines: string[] = [];
103
+ const p = " ";
104
+
105
+ // ── Header ────────────────────────────────────────────────
106
+ lines.push(accent(`┌${horizLine}┐`));
107
+ lines.push(line(p + theme.bold("Task list confirmation")));
108
+ lines.push(accent(`├${horizLine}┤`));
109
+
110
+ // ── Body: the proposed task tree ─────────────────────────
111
+ const body = BODY_LINES.slice(0, MAX_BODY);
112
+ for (const raw of body) {
113
+ const trimmed = raw;
114
+ lines.push(line(p + truncateToWidth(muted(trimmed || " "), innerWidth - p.length, "…")));
115
+ }
116
+ if (BODY_LINES.length > MAX_BODY) {
117
+ lines.push(line(p + dim(`+${BODY_LINES.length - MAX_BODY} more lines`)));
118
+ }
119
+ lines.push(accent(`├${horizLine}┤`));
120
+
121
+ // ── Options ─────────────────────────────────────────────
122
+ OPTIONS.forEach((opt, i) => {
123
+ const isSelected = i === selectedIndex;
124
+ const marker = isSelected ? "▸ " : " ";
125
+ const label = isSelected ? theme.fg("accent", opt.label) : opt.label;
126
+ lines.push(line(p + marker + truncateToWidth(label, innerWidth - 6, "…")));
127
+ if (isSelected) {
128
+ lines.push(line(p + " ".repeat(4) + truncateToWidth(dim(opt.description), innerWidth - 10, "…")));
129
+ }
130
+ });
131
+
132
+ // ── Footer ───────────────────────────────────────────────
133
+ lines.push(accent(`├${horizLine}┤`));
134
+ lines.push(line(p + dim(`${controls.hint("confirm")} to select · ${controls.navigationHint()} to navigate · ${controls.hint("cancel")} = keep current tasks`)));
135
+ lines.push(accent(`└${horizLine}┘`));
136
+
137
+ return lines;
138
+ },
139
+
140
+ handleInput(data: string): void {
141
+ if (controls.matches(data, "up")) {
142
+ selectedIndex = (selectedIndex - 1 + OPTIONS.length) % OPTIONS.length;
143
+ tui.requestRender();
144
+ return;
145
+ }
146
+ if (controls.matches(data, "down")) {
147
+ selectedIndex = (selectedIndex + 1) % OPTIONS.length;
148
+ tui.requestRender();
149
+ return;
150
+ }
151
+ if (controls.matches(data, "confirm")) {
152
+ done({ decision: OPTIONS[selectedIndex]!.value });
153
+ return;
154
+ }
155
+ if (controls.matches(data, "cancel")) {
156
+ done({ decision: "cancel" });
157
+ return;
158
+ }
159
+ },
160
+ };
161
+
162
+ return component;
163
+ },
164
+ {
165
+ overlay: true,
166
+ overlayOptions: {
167
+ anchor: "center",
168
+ width: "70%",
169
+ minWidth: 50,
170
+ maxHeight: "60%",
171
+ },
172
+ },
173
+ );
174
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Single task-counting implementation (P1-10).
3
+ *
4
+ * Previously ~5 copies of the same subtree walker lived in goal-policy,
5
+ * goal-auditor, widgets/goal-widget, widgets/task-list-overlay, and
6
+ * prompts/goal-prompts, with subtly different shapes. All call sites now use
7
+ * this one walker. `doneIncludesSkipped` is explicit so "skipped counts as
8
+ * done" semantics can never drift silently again: today every call site uses
9
+ * false (skipped is tracked separately, matching prior behavior); the flag
10
+ * exists for surfaces that deliberately want skipped folded into done.
11
+ */
12
+
13
+ import type { GoalTask } from "./goal-record.ts";
14
+
15
+ export interface TaskSubtreeCounts {
16
+ total: number;
17
+ /** Done count: complete, or complete+skipped when doneIncludesSkipped. */
18
+ complete: number;
19
+ skipped: number;
20
+ pending: number;
21
+ /** Present only when collectPending is requested (prompts' "next pending"). */
22
+ pendingTasks?: GoalTask[];
23
+ }
24
+
25
+ export function countTaskSubtree(
26
+ tasks: readonly GoalTask[],
27
+ opts: { doneIncludesSkipped?: boolean; collectPending?: boolean } = {},
28
+ ): TaskSubtreeCounts {
29
+ let total = 0;
30
+ let rawComplete = 0;
31
+ let rawSkipped = 0;
32
+ const pendingTasks: GoalTask[] | undefined = opts.collectPending ? [] : undefined;
33
+
34
+ function walk(list: readonly GoalTask[]): void {
35
+ for (const t of list) {
36
+ total++;
37
+ if (t.status === "complete") rawComplete++;
38
+ else if (t.status === "skipped") rawSkipped++;
39
+ else if (pendingTasks) pendingTasks.push(t);
40
+ if (t.subtasks && t.subtasks.length > 0) walk(t.subtasks);
41
+ }
42
+ }
43
+ walk(tasks);
44
+
45
+ const done = opts.doneIncludesSkipped ? rawComplete + rawSkipped : rawComplete;
46
+ return {
47
+ total,
48
+ complete: done,
49
+ skipped: rawSkipped,
50
+ pending: total - rawComplete - rawSkipped,
51
+ ...(opts.collectPending ? { pendingTasks } : {}),
52
+ };
53
+ }
54
+
55
+ /** Total node count only (task-list tools / overlay header counts). */
56
+ export function countAllTasks(tasks: readonly GoalTask[] | undefined): number {
57
+ if (!tasks) return 0;
58
+ return countTaskSubtree(tasks).total;
59
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * F2: objective→task bootstrap. When a goal is created with no task list and
3
+ * the objective contains an ordered-step or checklist structure, derive a
4
+ * proposed task tree so goals start with a trackable, human-approved plan
5
+ * instead of a blank checklist.
6
+ */
7
+
8
+ import type { GoalTask } from "./goal-record.ts";
9
+
10
+ /**
11
+ * Derive a proposed task tree from an objective's structure:
12
+ * - checklist markers: `- [ ] title` / `[ ] title`
13
+ * - ordered steps: `N. title`, `N) title`, `N: title` (≥2 items)
14
+ * Returns null when the objective has no usable structure (the agent then
15
+ * proposes tasks conversationally via set_goal_tasks).
16
+ */
17
+ export function deriveTasksFromObjective(objective: string): GoalTask[] | null {
18
+ const tasks: GoalTask[] = [];
19
+ let sawChecklist = false;
20
+
21
+ for (const raw of objective.split(/\r?\n/)) {
22
+ const line = raw.trim();
23
+ if (!line) continue;
24
+ // Checklist markers are the strongest signal.
25
+ const checklist = line.match(/^(?:-\s*)?\[\s\]\s+(.+)$/);
26
+ if (checklist) {
27
+ sawChecklist = true;
28
+ tasks.push({ id: `step-${tasks.length + 1}`, title: checklist[1]!.trim(), status: "pending" });
29
+ continue;
30
+ }
31
+ // Ordered steps at line starts.
32
+ const numbered = line.match(/^(\d{1,2})\s*[.):]\s+(.+)$/);
33
+ if (numbered) {
34
+ tasks.push({ id: `step-${tasks.length + 1}`, title: numbered[2]!.trim(), status: "pending" });
35
+ }
36
+ }
37
+
38
+ if (tasks.length === 0) return null;
39
+ if (!sawChecklist && tasks.length < 2) return null; // a lone numbered line is usually a section label
40
+ return tasks;
41
+ }
42
+
43
+ /** Human-readable summary of the derived proposal (guidance text). */
44
+ export function derivedTaskCount(objective: string): number {
45
+ return deriveTasksFromObjective(objective)?.length ?? 0;
46
+ }
@@ -0,0 +1,57 @@
1
+ import type { GoalTask } from "./goal-record.ts";
2
+
3
+ export interface TaskIndex {
4
+ tasks: GoalTask[];
5
+ byId: Map<string, GoalTask>;
6
+ ordered: Array<{task: GoalTask; depth: number; parentId?: string}>;
7
+ pending: GoalTask[];
8
+ complete: number;
9
+ skipped: number;
10
+ }
11
+ const cache: Array<{index: TaskIndex; chars: number}> = [];
12
+ let cachedChars = 0;
13
+ const taskFields = ["id", "title", "status", "completedAt", "skippedAt", "evidence", "skipReason", "verificationContract", "lightweightSubtasks"] as const;
14
+
15
+ /** Compare fields without serializing long titles/contracts. Also catches in-place edits. */
16
+ function sameTasks(left: readonly GoalTask[], right: readonly GoalTask[]): boolean {
17
+ if (left.length !== right.length) return false;
18
+ for (let i = 0; i < left.length; i++) {
19
+ const a = left[i]!; const b = right[i]!;
20
+ for (const field of taskFields) if (a[field] !== b[field]) return false;
21
+ if (a.subtasks || b.subtasks) {
22
+ if (!a.subtasks || !b.subtasks || !sameTasks(a.subtasks, b.subtasks)) return false;
23
+ }
24
+ }
25
+ return true;
26
+ }
27
+ const emptyTasks: GoalTask[] = [];
28
+ /** Content validated: usage-only copies reuse derivations; mutable input never aliases the snapshot. */
29
+ export function taskIndex(tasks: readonly GoalTask[] = emptyTasks): TaskIndex {
30
+ for (let i = cache.length - 1; i >= 0; i--) {
31
+ const entry = cache[i]!;
32
+ if (sameTasks(tasks, entry.index.tasks)) return entry.index;
33
+ }
34
+ let chars = 0;
35
+ const copy = (list: readonly GoalTask[]): GoalTask[] => list.map(task => {
36
+ for (const field of taskFields) if (typeof task[field] === "string") chars += (task[field] as string).length;
37
+ return {...task, ...(task.subtasks ? {subtasks: copy(task.subtasks)} : {})};
38
+ });
39
+ const snapshot = copy(tasks);
40
+ const result: TaskIndex = {tasks: snapshot, byId: new Map(), ordered: [], pending: [], complete: 0, skipped: 0};
41
+ const walk = (list: GoalTask[], depth: number, parentId?: string): void => {
42
+ for (const task of list) {
43
+ result.byId.set(task.id, task); result.ordered.push({task, depth, parentId});
44
+ if (task.status === "pending") result.pending.push(task);
45
+ else if (task.status === "complete") result.complete++;
46
+ else result.skipped++;
47
+ if (task.subtasks) walk(task.subtasks, depth + 1, task.id);
48
+ }
49
+ };
50
+ walk(snapshot, 0);
51
+ // Bound retained text as well as entry count. Oversized plans remain fully supported.
52
+ if (chars <= 2_000_000) {
53
+ while (cache.length >= 32 || cachedChars + chars > 2_000_000) cachedChars -= cache.shift()!.chars;
54
+ cache.push({index: result, chars}); cachedChars += chars;
55
+ }
56
+ return result;
57
+ }