@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,376 @@
1
+ import { type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { commandInstructions, type GoalCommands } from "./goal-controls.ts";
4
+ import {
5
+ formatDuration,
6
+ formatTokenValue,
7
+ statusLabel,
8
+ truncateText,
9
+ } from "./goal-core.ts";
10
+ import { buildTaskSummary } from "./goal-policy.ts";
11
+ import { countTaskSubtree } from "./goal-task-count.ts";
12
+ import { latestAuditorResultForGoal, latestAuditForGoal, latestEventsForGoal, type GoalLedgerEvent } from "./goal-ledger.ts";
13
+ import { GOAL_PROGRESS_TOOL_NAMES } from "./goal-tool-names.ts";
14
+ import {
15
+ asRecord,
16
+ cloneGoal,
17
+ type GoalEventDetails,
18
+ type GoalEventKind,
19
+ type GoalMode,
20
+ type GoalRecord,
21
+ type GoalStateEntry,
22
+ type GoalStatus,
23
+ type GoalTask,
24
+ } from "./goal-record.ts";
25
+
26
+ export const STATE_ENTRY = "pi-goal-state";
27
+ export const FOCUS_ENTRY = "pi-goal-focus";
28
+ export const GOAL_EVENT_ENTRY = "pi-goal-event";
29
+ export const GOAL_AUDIT_ENTRY = "pi-goal-audit-event";
30
+ export const COMPLETE_STATUS = "complete";
31
+ /**
32
+ * Tools that count as "real work" toward the active goal. If a non-tool-use
33
+ * turn ends without any of these having been called, we DO NOT queue the next
34
+ * autoContinue — the agent was just chatting. This stops infinite chat loops.
35
+ */
36
+ export const GOAL_PROGRESS_TOOL_SET = new Set<string>(GOAL_PROGRESS_TOOL_NAMES);
37
+
38
+ // ---------- summaries ----------
39
+
40
+ export function usageLines(goal: GoalRecord): string[] {
41
+ return [
42
+ `Time spent: ${formatDuration(goal.usage.activeSeconds)}`,
43
+ `Tokens used: ${formatTokenValue(goal.usage.tokensUsed)}`,
44
+ ];
45
+ }
46
+
47
+ export function detailedSummary(goal: GoalRecord | null, commands?: GoalCommands): string {
48
+ if (!goal) return commandInstructions("No goal is set. Use /goal <objective> or /sisyphus <objective> to start immediately.", commands);
49
+ const lines = [
50
+ `Goal: ${goal.objective}`,
51
+ `Status: ${statusLabel(goal)}`,
52
+ `Auto-continue: ${goal.autoContinue ? "on" : "off"}`,
53
+ ...usageLines(goal),
54
+ ];
55
+ if (goal.sisyphus) {
56
+ lines.push("Mode: Sisyphus (prompt/criteria variant; shared goal lifecycle)");
57
+ }
58
+ if (goal.taskList) {
59
+ const taskSummary = buildTaskSummary(goal.taskList);
60
+ lines.push(`Tasks: ${taskSummary}`);
61
+ // Find first pending task at any depth (BFS)
62
+ const queue = [...(goal.taskList.tasks ?? [])];
63
+ let firstPending: { id: string; title: string } | undefined;
64
+ while (queue.length > 0 && !firstPending) {
65
+ const t = queue.shift()!;
66
+ if (t.status === "pending") firstPending = t;
67
+ else if (t.subtasks) queue.push(...t.subtasks);
68
+ }
69
+ if (firstPending) {
70
+ lines.push(`Next pending task: ${firstPending.id} — ${firstPending.title}`);
71
+ }
72
+ }
73
+ if (goal.activePath) lines.push(`File: ${goal.activePath}`);
74
+ if (goal.archivedPath) lines.push(`Archive: ${goal.archivedPath}`);
75
+ if (goal.stopReason) lines.push(`Stop reason: ${goal.stopReason}`);
76
+ if (goal.pauseReason) lines.push(`Agent pause reason: ${goal.pauseReason}`);
77
+ if (goal.pauseSuggestedAction) lines.push(`Agent suggests: ${goal.pauseSuggestedAction}`);
78
+ return lines.join("\n");
79
+ }
80
+
81
+ export function oneLineSummary(goal: GoalRecord | null): string {
82
+ if (!goal) return "No goal is set.";
83
+ const tail = goal.usage.tokensUsed > 0 ? ` [${formatTokenValue(goal.usage.tokensUsed).split(" ")[0]}]` : "";
84
+ return `${statusLabel(goal)}${tail} - ${truncateText(goal.objective)}`;
85
+ }
86
+
87
+ // ---------- entry / render helpers ----------
88
+
89
+ export function goalDetails(goal: GoalRecord | null, resultDetail?: string): GoalStateEntry {
90
+ return { version: 3, goal: goal ? cloneGoal(goal) : null, ...(resultDetail ? { resultDetail } : {}) };
91
+ }
92
+
93
+ export function renderGoalResult(result: { details?: unknown; content: Array<{ type: string; text?: string }> }, options: { expanded?: boolean } | undefined, theme: Theme): Text {
94
+ const first = result.content.find((item) => item.type === "text" && typeof item.text === "string");
95
+ const firstText = first?.text ?? "";
96
+ const details = result.details as GoalStateEntry | undefined;
97
+ if (details && typeof details === "object" && "resultDetail" in details && details.resultDetail && options?.expanded) {
98
+ // E7: the collapsed heading stays byte-identical; the full reason is one
99
+ // keystroke away in the expanded tool-result detail view.
100
+ return new Text(`${firstText}\n${details.resultDetail}`, 0, 0);
101
+ }
102
+ if (!details || typeof details !== "object" || !("goal" in details)) {
103
+ return new Text(firstText, 0, 0);
104
+ }
105
+ if (
106
+ firstText.startsWith("Goal audit ")
107
+ || firstText.startsWith("Goal completion rejected")
108
+ || firstText.startsWith("Goal complete.")
109
+ || firstText.startsWith("Goal paused.")
110
+ || firstText.startsWith("Goal aborted.")
111
+ || firstText.startsWith("Goal confirmed and created.")
112
+ ) {
113
+ return new Text(firstText, 0, 0);
114
+ }
115
+ return new Text(theme.fg("accent", "Goal ") + theme.fg("muted", oneLineSummary(details.goal)), 0, 0);
116
+ }
117
+
118
+ export function normalizeGoalEventDetails(value: unknown): GoalEventDetails {
119
+ const raw = asRecord(value);
120
+ // "checkpoint" is the only kind writers emit today; "stale" remains a
121
+ // legacy read for historical checkpoint entries.
122
+ const kind: GoalEventKind = raw?.kind === "stale" ? "stale" : "checkpoint";
123
+ const goalId = typeof raw?.goalId === "string" ? raw.goalId : "unknown";
124
+ const focus: GoalMode | undefined = raw?.focus === "sisyphus" ? "sisyphus" : raw?.focus === "goal" ? "goal" : undefined;
125
+ const status = raw?.status === "active" || raw?.status === "paused" || raw?.status === "complete" ? (raw.status as GoalStatus) : undefined;
126
+ const currentStatus =
127
+ raw?.currentStatus === "active" || raw?.currentStatus === "paused" || raw?.currentStatus === "complete"
128
+ ? (raw.currentStatus as GoalStatus)
129
+ : raw?.currentStatus === null
130
+ ? null
131
+ : undefined;
132
+ return {
133
+ kind,
134
+ goalId,
135
+ status,
136
+ objective: typeof raw?.objective === "string" ? raw.objective : undefined,
137
+ timestamp: typeof raw?.timestamp === "number" ? raw.timestamp : undefined,
138
+ currentGoalId: typeof raw?.currentGoalId === "string" || raw?.currentGoalId === null ? raw.currentGoalId : undefined,
139
+ currentStatus,
140
+ focus,
141
+ };
142
+ }
143
+
144
+ export interface GoalAuditEventDetails {
145
+ phase: "started" | "approved" | "rejected" | "skipped";
146
+ goalId: string;
147
+ auditor?: string;
148
+ }
149
+
150
+ export function renderGoalEvent(message: { details?: GoalEventDetails }, options: { expanded: boolean }, theme: Theme): Text {
151
+ const details = normalizeGoalEventDetails(message.details);
152
+ const label = details.kind === "stale" ? "stale checkpoint" : "checkpoint";
153
+ if (!options.expanded) {
154
+ return new Text(theme.fg("customMessageLabel", "Goal ") + theme.fg("customMessageText", label), 0, 0);
155
+ }
156
+ const lines = [`Status: ${details.status === "active" ? "running" : details.status ?? "unknown"}`];
157
+ if (details.objective) lines.push(`Objective: ${details.objective}`);
158
+ lines.push(`Goal id: ${details.goalId}`);
159
+ if (details.currentGoalId || details.currentStatus) {
160
+ lines.push(`Current: ${details.currentGoalId ?? "none"}${details.currentStatus ? ` (${details.currentStatus})` : ""}`);
161
+ }
162
+ return new Text(
163
+ theme.fg("customMessageLabel", `Goal ${label}`) + "\n" + theme.fg("customMessageText", lines.join("\n")),
164
+ 0,
165
+ 0,
166
+ );
167
+ }
168
+
169
+ export function renderGoalAuditEvent(message: { content?: unknown; details?: GoalAuditEventDetails }, _options: { expanded: boolean }, theme: Theme): Text {
170
+ const phase = message.details?.phase ?? "started";
171
+ const label = phase === "approved" ? "approved" : phase === "rejected" ? "rejected" : phase === "skipped" ? "skipped" : "started";
172
+ const content = typeof message.content === "string" ? message.content : `Goal audit ${label}.`;
173
+ return new Text(
174
+ theme.fg("customMessageLabel", `Goal audit ${label}`) + "\n" + theme.fg("customMessageText", content),
175
+ 0,
176
+ 0,
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Issue #30 v2 markers are self-closing and carry an explicit version:
182
+ * `<pi_goal_continuation goal_id="..." kind="checkpoint" v="2"/>`.
183
+ */
184
+ function matchV2SelfClosingContinuation(text: string): string | null {
185
+ const match = text.match(/^<pi_goal_continuation\s+goal_id="([^"]+)"[^>]*v="2"\s*\/?>/);
186
+ return match?.[1] ?? null;
187
+ }
188
+
189
+ function matchLegacyContinuation(text: string): string | null {
190
+ // Phase 5 C1: structured outer marker `<pi_goal_continuation goal_id="..." kind="...">`.
191
+ // Borrowed from pi-codex-goal. More robust than bare bracket text because
192
+ // the angle brackets + attributes are nearly impossible for users to type
193
+ // by accident, and the structure is grep-able / parse-able by external tooling.
194
+ const xmlMatch = text.match(/^<pi_goal_continuation\s+goal_id="([^"]+)"/);
195
+ if (xmlMatch) return xmlMatch[1] ?? null;
196
+ return null;
197
+ }
198
+
199
+ function matchLegacyBracketCheckpoint(text: string): string | null {
200
+ const match = text.match(/^\[(?:GOAL CHECKPOINT|GOAL CONTINUATION|GOAL STALE) goalId=([^\]\s]+)\]/);
201
+ return match?.[1] ?? null;
202
+ }
203
+
204
+ export function extractGoalIdFromInjectedMessage(text: string): string | null {
205
+ return (
206
+ matchV2SelfClosingContinuation(text)
207
+ ?? matchLegacyContinuation(text)
208
+ ?? matchLegacyBracketCheckpoint(text)
209
+ );
210
+ }
211
+
212
+ export function goalEventMessageId(message: { customType?: string; details?: unknown; content?: unknown }): string | null {
213
+ if (message.customType !== GOAL_EVENT_ENTRY) return null;
214
+ const details = asRecord(message.details);
215
+ const goalId = details && typeof details.goalId === "string" ? details.goalId : null;
216
+ if (goalId) return goalId;
217
+ return typeof message.content === "string" ? extractGoalIdFromInjectedMessage(message.content) : null;
218
+ }
219
+
220
+ export function isAbortedAssistantMessage(message: unknown): boolean {
221
+ const raw = asRecord(message);
222
+ return raw?.role === "assistant" && raw.stopReason === "aborted";
223
+ }
224
+
225
+ export function isErrorAssistantMessage(message: unknown): boolean {
226
+ const raw = asRecord(message);
227
+ return raw?.role === "assistant" && raw.stopReason === "error";
228
+ }
229
+
230
+ /**
231
+ * Transient provider failures are safe for goal-level recovery backoff:
232
+ * Pi-declared network errors plus HTTP 429/5xx-style outages surfaced by
233
+ * providers (429 rate limits, 503 server_error, HTTP/2 PROTOCOL_ERROR,
234
+ * "Endpoint is unavailable", overloaded, gateway failures). Deliberately excludes auth and
235
+ * malformed-request failures (and quota/billing text), which retrying
236
+ * cannot fix.
237
+ */
238
+ const TRANSIENT_PROVIDER_ERROR_RE = new RegExp(
239
+ [
240
+ "\\bnetwork[_\\s-]?error\\b",
241
+ "\\bprotocol[_\\s-]?error\\b",
242
+ "\\bserver[_\\s]?error\\b",
243
+ "\\bfinish[_\\s-]?reason:\\s*error\\b",
244
+ "\\b(?:429|502|503|504|529)\\b",
245
+ "\\brate[_\\s-]?limited\\b",
246
+ "\\brate[_\\s-]?limit\\b",
247
+ "\\btoo many requests\\b",
248
+ "\\bservice unavailable\\b",
249
+ "\\bbad gateway\\b",
250
+ "\\bgateway timeout\\b",
251
+ "\\bupstream request failed\\b",
252
+ "\\bendpoint is unavailable\\b",
253
+ "\\boverloaded[_\\s]?error\\b",
254
+ ].join("|"),
255
+ "i",
256
+ );
257
+
258
+ /** Quota/billing exhaustion is deterministic; retrying cannot fix it. */
259
+ const NON_TRANSIENT_PROVIDER_ERROR_RE = new RegExp(
260
+ [
261
+ "\\binsufficient[_\\s]?quota\\b",
262
+ "\\bout of budget\\b",
263
+ "\\bquota exceeded\\b",
264
+ "\\bbilling\\b",
265
+ "GoUsageLimitError",
266
+ "FreeUsageLimitError",
267
+ ].join("|"),
268
+ "i",
269
+ );
270
+
271
+ /** A transient provider failure is safe for the bounded goal backoff. */
272
+ export function isNetworkErrorAssistantMessage(message: unknown): boolean {
273
+ if (!isErrorAssistantMessage(message)) return false;
274
+ const raw = asRecord(message);
275
+ const details = [raw?.rawStopReason, raw?.errorMessage]
276
+ .filter((value): value is string => typeof value === "string")
277
+ .join(" ");
278
+ // Quota/billing exhaustion is deterministic and must never be retried.
279
+ if (NON_TRANSIENT_PROVIDER_ERROR_RE.test(details)) return false;
280
+ return TRANSIENT_PROVIDER_ERROR_RE.test(details);
281
+ }
282
+
283
+ export function isToolUseAssistantMessage(message: unknown): boolean {
284
+ const raw = asRecord(message);
285
+ return raw?.role === "assistant" && raw.stopReason === "toolUse";
286
+ }
287
+
288
+ export function hasAbortedAssistantMessage(messages: unknown[]): boolean {
289
+ return messages.some(isAbortedAssistantMessage);
290
+ }
291
+
292
+ export function hasErrorAssistantMessage(messages: unknown[]): boolean {
293
+ return messages.some(isErrorAssistantMessage);
294
+ }
295
+
296
+ export function hasNetworkErrorAssistantMessage(messages: unknown[]): boolean {
297
+ return messages.some(isNetworkErrorAssistantMessage);
298
+ }
299
+
300
+ export function usageChannelTokens(value: unknown): number {
301
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
302
+ return Math.max(0, Math.trunc(value));
303
+ }
304
+
305
+ export function assistantTurnTokens(message: unknown): number {
306
+ const raw = asRecord(message);
307
+ if (!raw || raw.role !== "assistant") return 0;
308
+ const usage = asRecord(raw.usage);
309
+ if (!usage) return 0;
310
+ return usageChannelTokens(usage.input) + usageChannelTokens(usage.output);
311
+ }
312
+
313
+ export function isMeaningfulProgressToolCall(toolName: string, args: unknown): boolean {
314
+ if (!GOAL_PROGRESS_TOOL_SET.has(toolName)) return false;
315
+ if (toolName === "read") {
316
+ const path = asRecord(args)?.path;
317
+ if (typeof path === "string" && (path === ".pi/goals" || path.startsWith(".pi/goals/"))) return false;
318
+ }
319
+ if (toolName === "bash") {
320
+ const command = asRecord(args)?.command;
321
+ if (typeof command === "string" && /^\s*echo\b/.test(command)) return false;
322
+ }
323
+ return true;
324
+ }
325
+
326
+ /**
327
+ * E1: per-goal history block (last audit verdict + recent lifecycle events)
328
+ * surfaced by get_goal and /goal-status; the ledger already records it.
329
+ */
330
+ export function buildGoalHistoryBlock(goal: GoalRecord | null, ledgerEvents: GoalLedgerEvent[]): string {
331
+ if (!goal) return "";
332
+ const lines: string[] = [];
333
+ const audit = latestAuditorResultForGoal(ledgerEvents, goal.id);
334
+ if (latestAuditForGoal(ledgerEvents, goal.id)?.type === "audit_started") lines.push("Latest audit: no recorded result.");
335
+ if (audit) {
336
+ lines.push(`Last audit: ${audit.verdict} (${audit.at.slice(0, 10)}) — ${truncateText(audit.report, 90)}`);
337
+ }
338
+ const recent = latestEventsForGoal(ledgerEvents, goal.id, 6);
339
+ if (recent.length > 0) {
340
+ lines.push("Recent events:");
341
+ for (const event of recent) {
342
+ lines.push(` ${event.at.slice(11, 19)} ${event.type}`);
343
+ }
344
+ }
345
+ return lines.join("\n");
346
+ }
347
+
348
+ /**
349
+ * F1: task-detail block mirroring the widget — counts, next pending with
350
+ * contracts, recent completions with evidence. Used by get_goal.
351
+ */
352
+ export function buildGoalTaskDetailBlock(goal: GoalRecord): string {
353
+ if (!goal.taskList || goal.taskList.tasks.length === 0) return "";
354
+ const { total, complete, skipped } = countTaskSubtree(goal.taskList.tasks);
355
+ const lines: string[] = [];
356
+ const header = `${complete}/${total} tasks complete${skipped > 0 ? ` (${skipped} skipped)` : ""}`;
357
+ const pending: Array<{ task: GoalTask; depth: number }> = [];
358
+ const recent: GoalTask[] = [];
359
+ (function walk(tasks: GoalTask[], depth: number): void {
360
+ for (const t of tasks) {
361
+ if (t.status === "pending") pending.push({ task: t, depth });
362
+ if (t.status === "complete" && recent.length < 2) recent.push(t);
363
+ if (t.subtasks && t.subtasks.length > 0) walk(t.subtasks, depth + 1);
364
+ }
365
+ })(goal.taskList.tasks, 0);
366
+ lines.push(`Tasks: ${header}`);
367
+ for (const entry of pending.slice(0, 3)) {
368
+ lines.push(`${" ".repeat(entry.depth)}[ ] ${entry.task.id}: ${entry.task.title}`);
369
+ if (entry.task.verificationContract) lines.push(`${" ".repeat(entry.depth + 1)}contract: ${entry.task.verificationContract}`);
370
+ }
371
+ if (pending.length > 3) lines.push(`(+${pending.length - 3} more pending — expand the dashboard with Ctrl+Shift+T)`);
372
+ for (const t of recent) {
373
+ lines.push(`[x] ${t.id}: ${t.title}${t.evidence ? ` — ${t.evidence}` : ""}`);
374
+ }
375
+ return lines.join("\n");
376
+ }
@@ -0,0 +1,53 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { GoalCore } from "./goal-state.ts";
3
+ import { loadGoalSettings } from "./goal-settings.ts";
4
+
5
+ /** A user-stopped turn must not become a retry, nor affect a replacement goal. */
6
+ export class GoalInterruption {
7
+ private heldGoalId: string | undefined;
8
+ private abortedGoalId: string | undefined;
9
+ private finishGoalId: string | undefined;
10
+ private core: GoalCore;
11
+ constructor(core: GoalCore) { this.core = core; }
12
+ isHeld(goalId = this.core.state.goal?.id): boolean { return !!goalId && this.heldGoalId === goalId && this.core.state.goal?.id === goalId && this.core.state.goal.status === "active"; }
13
+ ignoreAbort(goalId = this.core.runningGoalId ?? this.core.state.goal?.id): boolean { return !!goalId && this.abortedGoalId === goalId; }
14
+ settled(): void { if (this.isHeld()) this.core.clearActiveAccounting(); this.abortedGoalId = undefined; this.finishGoalId = undefined; }
15
+ reset(): void { this.heldGoalId = undefined; this.finishGoalId = undefined; }
16
+ resumed(ctx: ExtensionContext): void { this.reset(); if (ctx.hasUI) ctx.ui.setStatus("goal-interrupt", undefined); }
17
+ continueWithUser(ctx: ExtensionContext): void {
18
+ if (!this.isHeld()) { this.reset(); return; }
19
+ this.core.scheduler.releaseInterrupt(ctx);
20
+ this.resumed(ctx);
21
+ }
22
+ private stopTurn(ctx: ExtensionContext): void {
23
+ const goal = this.core.state.goal;
24
+ if (!goal || goal.status !== "active") return;
25
+ this.heldGoalId = goal.id;
26
+ this.abortedGoalId = goal.id;
27
+ this.core.clearContinuationState();
28
+ this.core.scheduler.interrupt(ctx);
29
+ ctx.ui.setStatus("goal-interrupt", "Turn stopped · press pause again to pause the goal · Resume or send a message to continue");
30
+ ctx.ui.notify("Turn stopped. The goal is waiting for you; Resume or send a message to continue.", "info");
31
+ ctx.abort?.();
32
+ this.core.updateUI(ctx);
33
+ }
34
+ pause(ctx: ExtensionContext): void {
35
+ const goal = this.core.state.goal;
36
+ if (goal?.status !== "active") { this.reset(); if (!ctx.isIdle()) ctx.abort?.(); return; }
37
+ const order = loadGoalSettings(ctx.cwd).pauseOrder ?? "together";
38
+ if (order === "turn-first" && !this.isHeld()) { this.stopTurn(ctx); return; }
39
+ this.reset();
40
+ ctx.ui.setStatus("goal-interrupt", undefined);
41
+ this.core.pauseActiveGoal(ctx);
42
+ if (order === "goal-first") { if (!ctx.isIdle()) this.finishGoalId = goal.id; }
43
+ else ctx.abort?.();
44
+ }
45
+ finishTurn(ctx: ExtensionContext): void {
46
+ if (!this.finishGoalId) return;
47
+ const id = this.finishGoalId;
48
+ this.finishGoalId = undefined;
49
+ if (this.core.state.goal?.id === id && this.core.state.goal.status === "paused") ctx.abort?.();
50
+ }
51
+ /** Pi's own interrupt remains turn-only when the goal pause key is different. */
52
+ interruptTurn(ctx: ExtensionContext): void { this.stopTurn(ctx); }
53
+ }
@@ -0,0 +1,68 @@
1
+ import type { GoalLedgerEvent } from "./goal-ledger.ts";
2
+ import type { OracleConsultState } from "./goal-oracle.ts";
3
+ import { isActivityEvent, sameActivityEvent } from "./goal-activity.ts";
4
+
5
+ /** Bounded working set per goal; complete history remains in the JSONL ledger. */
6
+ export type GoalAuditEvent = Extract<GoalLedgerEvent, {type: "audit_started" | "audit_result" | "audit_skipped"}>;
7
+ export function auditApplies(latestId: string | undefined, event: GoalAuditEvent): boolean {
8
+ return event.type === "audit_started" || (event.type === "audit_skipped" && event.reason === "disabled") || latestId === undefined || event.auditId === latestId;
9
+ }
10
+
11
+ export interface GoalLedgerIndex {
12
+ recent: GoalLedgerEvent[];
13
+ activity: GoalLedgerEvent[];
14
+ lastActivityEvent?: GoalLedgerEvent;
15
+ audit?: GoalAuditEvent;
16
+ auditStarted?: GoalAuditEvent;
17
+ settledAudit?: GoalAuditEvent;
18
+ completion?: GoalLedgerEvent;
19
+ lifecycle?: GoalLedgerEvent;
20
+ oracle: Map<string, OracleConsultState>;
21
+ }
22
+ export function newGoalLedgerIndex(): GoalLedgerIndex {
23
+ return { recent: [], activity: [], oracle: new Map() };
24
+ }
25
+ const lifecycleEvents = new Set(["goal_created", "goal_paused", "goal_resumed", "goal_blocked", "goal_budget_limited", "goal_completed", "goal_aborted", "goal_archived"]);
26
+ export function indexLedgerEvent(index: Map<string, GoalLedgerIndex>, event: GoalLedgerEvent): void {
27
+ if (!("goalId" in event)) return;
28
+ let goal = index.get(event.goalId);
29
+ if (!goal) { goal = newGoalLedgerIndex(); index.set(event.goalId, goal); }
30
+ goal.recent.push(event);
31
+ if (goal.recent.length > 12) goal.recent.shift();
32
+ if (lifecycleEvents.has(event.type)) goal.lifecycle = event;
33
+ if (event.type === "audit_started" || event.type === "audit_result" || event.type === "audit_skipped") {
34
+ if (event.type === "audit_started") goal.auditStarted = event;
35
+ if (event.type === "audit_result") goal.settledAudit = event;
36
+ if (auditApplies(goal.audit?.auditId, event)) goal.audit = event;
37
+ }
38
+ if (event.type === "completion_requested") goal.completion = event;
39
+ if (isActivityEvent(event) && (!goal.lastActivityEvent || !sameActivityEvent(event, goal.lastActivityEvent))) {
40
+ goal.lastActivityEvent = event;
41
+ // Stable insertion after equal timestamps matches Array.sort's tie order.
42
+ const last = goal.activity[goal.activity.length - 1];
43
+ if (!last || event.at >= last.at) goal.activity.push(event);
44
+ else {
45
+ let low = 0; let high = goal.activity.length;
46
+ while (low < high) {
47
+ const mid = (low + high) >>> 1;
48
+ if (goal.activity[mid]!.at <= event.at) low = mid + 1;
49
+ else high = mid;
50
+ }
51
+ goal.activity.splice(low, 0, event);
52
+ }
53
+ if (goal.activity.length > 64) goal.activity.shift();
54
+ }
55
+ if ("fingerprint" in event) {
56
+ const prior = goal.oracle.get(event.fingerprint) ?? { failedAttempts: 0, followupAttempted: false };
57
+ const state = { ...prior };
58
+ if (event.type === "oracle_result") state.result = { adviceId: event.adviceId, disposition: event.disposition, summary: event.summary };
59
+ if (event.type === "oracle_failed") { state.failedAttempts++; state.lastFailure = { errorCode: event.errorCode, message: event.message }; }
60
+ if (event.type === "oracle_followup_attempted") state.followupAttempted = true;
61
+ goal.oracle.set(event.fingerprint, state);
62
+ }
63
+ }
64
+ export function buildLedgerIndex(events: readonly GoalLedgerEvent[]): Map<string, GoalLedgerIndex> {
65
+ const index = new Map<string, GoalLedgerIndex>();
66
+ for (const event of events) indexLedgerEvent(index, event);
67
+ return index;
68
+ }