pi-goala 0.2.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.
@@ -0,0 +1,931 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ configuredModels,
8
+ formatConfig,
9
+ GOALA_SETUP_PRESETS,
10
+ loadConfig,
11
+ writeConfig,
12
+ type ModelProfile,
13
+ type ReviewPolicy,
14
+ type ThinkingLevel,
15
+ } from "./config.ts";
16
+ import { buildPhaseContext } from "./context.ts";
17
+ import {
18
+ memoryHealth,
19
+ newGoalId,
20
+ recentMemories,
21
+ repositoryIdentity,
22
+ searchMemories,
23
+ setMemoryStatus,
24
+ } from "./memory.ts";
25
+ import {
26
+ displayPlan,
27
+ displayStatus,
28
+ formatPlanForReview,
29
+ formatState,
30
+ registerPresenters,
31
+ truncate,
32
+ updateGoalUi,
33
+ } from "./presenters.ts";
34
+ import { enforceToolPolicy } from "./policy.ts";
35
+ import {
36
+ findRecoverableGoals,
37
+ formatRecoveryStatus,
38
+ } from "./recovery.ts";
39
+ import {
40
+ moveToFreshSession,
41
+ slicePhaseContext,
42
+ } from "./session.ts";
43
+ import {
44
+ formatSourceDrift,
45
+ inspectGoalSources,
46
+ parseGoalRequest,
47
+ resolveGoalSources,
48
+ type GoalSource,
49
+ } from "./sources.ts";
50
+ import {
51
+ GOALA_TOOLS,
52
+ registerGoalaTools,
53
+ toolsForPhase,
54
+ type PendingAction,
55
+ } from "./tools.ts";
56
+ import {
57
+ emptyState,
58
+ GOAL_STATE_ENTRY,
59
+ normalizeState,
60
+ type GoalState,
61
+ } from "./workflow.ts";
62
+
63
+ function now(): string {
64
+ return new Date().toISOString();
65
+ }
66
+
67
+ export { formatPlanForReview } from "./presenters.ts";
68
+
69
+ export default function goala(pi: ExtensionAPI): void {
70
+ let config = loadConfig();
71
+ let state = emptyState();
72
+ let pendingAction: PendingAction | undefined;
73
+ let sessionDefaultModel:
74
+ | { provider: string; id: string; thinkingLevel?: ThinkingLevel }
75
+ | undefined;
76
+ let baselineTools: string[] = [];
77
+ let fallbackNoticeShown = false;
78
+
79
+ registerPresenters(pi);
80
+
81
+ function persist(ctx?: ExtensionContext): void {
82
+ state.updatedAt = now();
83
+ pi.appendEntry(GOAL_STATE_ENTRY, structuredClone(state));
84
+ if (ctx) updateGoalUi(ctx, state);
85
+ }
86
+
87
+ function restore(ctx: ExtensionContext): void {
88
+ const entry = [...ctx.sessionManager.getBranch()]
89
+ .reverse()
90
+ .find(
91
+ (item: { type: string; customType?: string }) =>
92
+ item.type === "custom" && item.customType === GOAL_STATE_ENTRY,
93
+ ) as { data?: GoalState } | undefined;
94
+ state = normalizeState(entry?.data);
95
+ pendingAction = undefined;
96
+ updateGoalUi(ctx, state);
97
+ }
98
+
99
+ function displayPlanForReview(): void {
100
+ displayPlan(pi, state);
101
+ }
102
+
103
+ function profileForPhase(): ModelProfile {
104
+ if (
105
+ state.phase === "planning" ||
106
+ state.phase === "awaiting-execution" ||
107
+ state.phase === "awaiting-review"
108
+ ) return config.planner;
109
+ if (state.phase === "verifying-step") return config.stepVerifier;
110
+ if (state.phase === "verifying") return config.verifier;
111
+ if (
112
+ state.phase === "executing" &&
113
+ state.repairCycles >= config.fallbackExecutor.afterRepairCycle
114
+ ) {
115
+ return config.fallbackExecutor;
116
+ }
117
+ return config.executor;
118
+ }
119
+
120
+ async function statusForSession(
121
+ ctx: ExtensionCommandContext,
122
+ ): Promise<string> {
123
+ if (state.phase !== "idle") return formatState(state);
124
+ const recoverable = await findRecoverableGoals(
125
+ ctx.cwd,
126
+ ctx.sessionManager.getSessionDir(),
127
+ );
128
+ return formatRecoveryStatus(recoverable);
129
+ }
130
+
131
+ async function applyPhase(ctx: ExtensionContext): Promise<boolean> {
132
+ const validTools = new Set(pi.getAllTools().map((tool) => tool.name));
133
+ pi.setActiveTools(toolsForPhase(state.phase, baselineTools).filter((tool) => validTools.has(tool)));
134
+ if (
135
+ state.phase === "idle" ||
136
+ state.phase === "paused" ||
137
+ state.phase === "needs-attention" ||
138
+ state.phase === "complete"
139
+ ) {
140
+ updateGoalUi(ctx, state);
141
+ return true;
142
+ }
143
+
144
+ const profile = profileForPhase();
145
+ let model = ctx.modelRegistry.find(config.provider, profile.model);
146
+ if (!model && config.allowCurrentModelFallback) {
147
+ const fallback = sessionDefaultModel ?? (ctx.model
148
+ ? { provider: ctx.model.provider, id: ctx.model.id }
149
+ : undefined);
150
+ if (fallback) {
151
+ model = ctx.modelRegistry.find(fallback.provider, fallback.id);
152
+ if (model && !fallbackNoticeShown) {
153
+ ctx.ui.notify(
154
+ `Goala: ${config.provider}/${profile.model} is unavailable; using ${fallback.provider}/${fallback.id}. Run /goala-setup to configure model roles.`,
155
+ "warning",
156
+ );
157
+ fallbackNoticeShown = true;
158
+ }
159
+ }
160
+ }
161
+ if (!model) {
162
+ ctx.ui.notify(
163
+ `Goala: model not found: ${config.provider}/${profile.model}. Run /goala-setup current or edit the namespaced config.`,
164
+ "error",
165
+ );
166
+ updateGoalUi(ctx, state);
167
+ return false;
168
+ }
169
+
170
+ const selected = await pi.setModel(model);
171
+ if (!selected) {
172
+ ctx.ui.notify(`Goala: authenticate ${config.provider} before using ${profile.model}`, "warning");
173
+ updateGoalUi(ctx, state);
174
+ return false;
175
+ }
176
+ pi.setThinkingLevel(profile.thinkingLevel);
177
+ updateGoalUi(ctx, state);
178
+ return true;
179
+ }
180
+
181
+ async function restoreSessionDefaults(ctx: ExtensionContext): Promise<void> {
182
+ const validTools = new Set(pi.getAllTools().map((tool) => tool.name));
183
+ pi.setActiveTools(baselineTools.filter((tool) => validTools.has(tool)));
184
+ if (!sessionDefaultModel) return;
185
+ const model = ctx.modelRegistry.find(sessionDefaultModel.provider, sessionDefaultModel.id);
186
+ if (!model) return;
187
+ if (await pi.setModel(model)) {
188
+ if (sessionDefaultModel.thinkingLevel) {
189
+ pi.setThinkingLevel(sessionDefaultModel.thinkingLevel);
190
+ }
191
+ }
192
+ }
193
+
194
+ function planningPrompt(extra?: string): string {
195
+ return `[GOALA PHASE:PLANNING]\nInspect the active goal and repository, then submit a structured, testable plan with submit_plan.
196
+ Make each step a meaningful, independently reviewable milestone rather than a micro-task or a final-check-only step.${extra ? `\n\nRefinement requested:\n${extra}` : ""}`;
197
+ }
198
+
199
+ function executionPrompt(): string {
200
+ const remaining = state.plan.filter((step) => step.status !== "done");
201
+ const current = remaining[0];
202
+ const repairNote =
203
+ state.verification?.verdict === "fail"
204
+ ? `\n\nRepair the verifier's defects before resubmitting:\n${state.verification.defects.map((defect) => `- ${defect}`).join("\n")}`
205
+ : "";
206
+ const reviewNote = state.reviewFeedback
207
+ ? `\n\nThe user returned this step for revision:\n${state.reviewFeedback}`
208
+ : "";
209
+ if (
210
+ state.reviewPolicy === "per-step" &&
211
+ state.verification?.verdict !== "fail" &&
212
+ current
213
+ ) {
214
+ return `[GOALA PHASE:EXECUTING]\nImplement only plan step ${current.id}: ${current.title}.
215
+ Do not begin later plan steps. Run the step's declared checks, then call goal_progress complete_step with concrete evidence. Goala will pause for human review before continuing.${reviewNote}`;
216
+ }
217
+ return `[GOALA PHASE:EXECUTING]\nImplement the approved plan, starting with: ${current?.title ?? "repair the verified defects"}.
218
+ Record completed steps with goal_progress. When implementation checks pass, submit ready_for_verification.${repairNote}${reviewNote}`;
219
+ }
220
+
221
+ function verificationPrompt(): string {
222
+ return `[GOALA PHASE:VERIFYING]
223
+ Independently verify the actual result against every acceptance criterion, then submit_verification with concrete evidence.
224
+ Include only distilled decisions, discoveries, or pitfalls that you personally confirmed from current files or checks in findings. Use an empty findings array when nothing is likely to help a later related task.`;
225
+ }
226
+
227
+ function stepVerificationPrompt(): string {
228
+ const step = state.plan.find((candidate) => candidate.status === "implemented");
229
+ return `[GOALA PHASE:STEP-VERIFYING]\nIndependently verify only plan step ${step?.id ?? "unknown"}: ${step?.title ?? "unknown step"}.
230
+ Required method: ${step?.verification ?? "Inspect the actual result and run relevant checks."}
231
+ Do not edit files or rely on executor claims. Finish with submit_step_verification and concrete evidence.`;
232
+ }
233
+
234
+ async function beginGoal(
235
+ objective: string,
236
+ sources: GoalSource[],
237
+ ctx: ExtensionContext,
238
+ ): Promise<void> {
239
+ const repo = repositoryIdentity(ctx.cwd);
240
+ const recalledMemories =
241
+ config.memory.enabled && config.memory.autoRecall
242
+ ? searchMemories(objective, ctx.cwd, config.memory)
243
+ : [];
244
+ state = {
245
+ ...emptyState(config.reviewPolicy),
246
+ goalId: newGoalId(),
247
+ objective,
248
+ sources,
249
+ phase: "planning",
250
+ startedAt: now(),
251
+ startCommit: repo.commit,
252
+ recalledMemories,
253
+ };
254
+ pendingAction = undefined;
255
+ pi.setSessionName(`Goal: ${truncate(objective, 56)}`);
256
+ persist(ctx);
257
+ const kickoff = planningPrompt();
258
+ if (await moveToFreshSession(ctx, config, state, kickoff, "plan")) return;
259
+ await applyPhase(ctx);
260
+ pi.sendUserMessage(kickoff);
261
+ }
262
+
263
+ function requestedGoal(input: string, ctx: ExtensionContext): {
264
+ objective: string;
265
+ sources: GoalSource[];
266
+ } | undefined {
267
+ try {
268
+ const request = parseGoalRequest(input);
269
+ if (!request.objective) {
270
+ ctx.ui.notify("Usage: /goal <objective>", "warning");
271
+ return;
272
+ }
273
+ return {
274
+ objective: request.objective,
275
+ sources: resolveGoalSources(ctx.cwd, request.sourcePaths),
276
+ };
277
+ } catch (error) {
278
+ ctx.ui.notify(
279
+ error instanceof Error ? error.message : String(error),
280
+ "warning",
281
+ );
282
+ }
283
+ }
284
+
285
+ async function confirmGoalReplacement(ctx: ExtensionContext): Promise<boolean> {
286
+ if (state.phase === "idle" || state.phase === "complete") return true;
287
+ if (!ctx.hasUI) {
288
+ ctx.ui.notify("An active goal already exists. Run /goal clear before replacing it.", "warning");
289
+ return false;
290
+ }
291
+ return ctx.ui.confirm(
292
+ "Replace active goal?",
293
+ "This discards the current structured goal state. Repository changes are not reverted.",
294
+ );
295
+ }
296
+
297
+ async function startExecution(
298
+ ctx: ExtensionContext,
299
+ reviewPolicy: ReviewPolicy = state.reviewPolicy,
300
+ ): Promise<void> {
301
+ if (state.plan.length === 0) {
302
+ ctx.ui.notify("No approved plan exists. Run /plan first.", "warning");
303
+ return;
304
+ }
305
+ if (state.phase !== "awaiting-execution") {
306
+ ctx.ui.notify(`The plan cannot start from phase ${state.phase}.`, "warning");
307
+ return;
308
+ }
309
+ state.reviewPolicy = reviewPolicy;
310
+ state.phase = "executing";
311
+ state.blockedReason = undefined;
312
+ pendingAction = undefined;
313
+ persist(ctx);
314
+ const kickoff = executionPrompt();
315
+ if (await moveToFreshSession(ctx, config, state, kickoff, "execute")) return;
316
+ await applyPhase(ctx);
317
+ pi.sendUserMessage(kickoff, { deliverAs: "followUp" });
318
+ }
319
+
320
+ async function startVerification(ctx: ExtensionContext, allowFreshSession = true): Promise<void> {
321
+ state.phase = "verifying";
322
+ pendingAction = undefined;
323
+ persist(ctx);
324
+ const kickoff = verificationPrompt();
325
+ if (allowFreshSession && await moveToFreshSession(ctx, config, state, kickoff, "verify")) return;
326
+ await applyPhase(ctx);
327
+ pi.sendUserMessage(kickoff, { deliverAs: "followUp" });
328
+ }
329
+
330
+ async function startStepVerification(ctx: ExtensionContext, allowFreshSession = true): Promise<void> {
331
+ const step = state.plan.find((candidate) => candidate.status === "implemented");
332
+ if (!step) {
333
+ ctx.ui.notify("No implemented step is waiting for verification.", "warning");
334
+ return;
335
+ }
336
+ state.phase = "verifying-step";
337
+ pendingAction = undefined;
338
+ persist(ctx);
339
+ const kickoff = stepVerificationPrompt();
340
+ if (
341
+ allowFreshSession &&
342
+ await moveToFreshSession(ctx, config, state, kickoff, `verify-step-${step.id}`)
343
+ ) return;
344
+ await applyPhase(ctx);
345
+ pi.sendUserMessage(kickoff, { deliverAs: "followUp" });
346
+ }
347
+
348
+ async function approveReviewedStep(ctx: ExtensionContext): Promise<void> {
349
+ if (state.phase !== "awaiting-review") {
350
+ ctx.ui.notify("No completed step is awaiting human approval.", "warning");
351
+ return;
352
+ }
353
+ const step = state.plan.find(
354
+ (candidate) => candidate.status === "implemented" || candidate.status === "verified",
355
+ );
356
+ if (!step?.evidence?.trim()) {
357
+ ctx.ui.notify("The current step has no concrete validation evidence.", "warning");
358
+ return;
359
+ }
360
+ if (step.review && step.review.verdict !== "pass") {
361
+ ctx.ui.notify("The current step's independent review has not passed.", "warning");
362
+ return;
363
+ }
364
+ step.status = "done";
365
+ state.stepRepairCycles = 0;
366
+ state.reviewFeedback = undefined;
367
+ state.blockedReason = undefined;
368
+
369
+ const next = state.plan.find((candidate) => candidate.status !== "done");
370
+ if (next) {
371
+ state.phase = "awaiting-execution";
372
+ persist(ctx);
373
+ await startExecution(ctx, "per-step");
374
+ return;
375
+ }
376
+
377
+ persist(ctx);
378
+ if (config.autoVerify) {
379
+ await startVerification(ctx);
380
+ return;
381
+ }
382
+ state.phase = "verifying";
383
+ persist(ctx);
384
+ await applyPhase(ctx);
385
+ ctx.ui.notify("All steps are approved. Run /verify for final goal verification.", "info");
386
+ }
387
+
388
+ async function reviseReviewedStep(feedback: string, ctx: ExtensionContext): Promise<void> {
389
+ if (state.phase !== "awaiting-review") {
390
+ ctx.ui.notify("No completed step is awaiting revision.", "warning");
391
+ return;
392
+ }
393
+ const step = state.plan.find(
394
+ (candidate) => candidate.status === "implemented" || candidate.status === "verified",
395
+ );
396
+ if (!step) {
397
+ ctx.ui.notify("No reviewed step was found.", "warning");
398
+ return;
399
+ }
400
+ if (!feedback.trim()) {
401
+ ctx.ui.notify("Usage: /goal revise <feedback for the executor>", "warning");
402
+ return;
403
+ }
404
+ step.status = "pending";
405
+ state.reviewFeedback = feedback.trim().slice(0, 4000);
406
+ state.phase = "awaiting-execution";
407
+ state.blockedReason = undefined;
408
+ persist(ctx);
409
+ await startExecution(ctx, "per-step");
410
+ }
411
+
412
+
413
+
414
+
415
+
416
+
417
+
418
+ registerGoalaTools(pi, {
419
+ getState: () => state,
420
+ getConfig: () => config,
421
+ setPendingAction: (action) => {
422
+ pendingAction = action;
423
+ },
424
+ persist,
425
+ applyPhase,
426
+ displayPlanForReview,
427
+ });
428
+
429
+ pi.registerCommand("goal", {
430
+ description: "Start or manage a persistent goal; use --source <path> -- <objective> for authoritative documents",
431
+ handler: async (args, ctx) => {
432
+ const input = args.trim();
433
+ const command = input.toLowerCase();
434
+
435
+ if (!input || command === "status") {
436
+ displayStatus(pi, await statusForSession(ctx));
437
+ return;
438
+ }
439
+ if (command === "approve") {
440
+ await approveReviewedStep(ctx);
441
+ return;
442
+ }
443
+ if (command === "revise" || command.startsWith("revise ")) {
444
+ await reviseReviewedStep(input.slice("revise".length).trim(), ctx);
445
+ return;
446
+ }
447
+ if (command === "clear") {
448
+ const cleared = state;
449
+ state = {
450
+ ...emptyState(),
451
+ goalId: cleared.goalId,
452
+ objective: cleared.objective,
453
+ startedAt: cleared.startedAt,
454
+ };
455
+ pendingAction = undefined;
456
+ persist(ctx);
457
+ await applyPhase(ctx);
458
+ await restoreSessionDefaults(ctx);
459
+ ctx.ui.notify("Goal cleared. Normal coding mode restored.", "info");
460
+ return;
461
+ }
462
+ if (command === "pause") {
463
+ if (state.phase === "idle" || state.phase === "complete") {
464
+ ctx.ui.notify("There is no active goal to pause.", "warning");
465
+ return;
466
+ }
467
+ state.pausedFrom = state.phase;
468
+ state.phase = "paused";
469
+ pendingAction = undefined;
470
+ persist(ctx);
471
+ await applyPhase(ctx);
472
+ await restoreSessionDefaults(ctx);
473
+ ctx.ui.notify("Goal paused. State has been preserved.", "info");
474
+ return;
475
+ }
476
+ if (command === "resume") {
477
+ if (state.phase !== "paused" && state.phase !== "needs-attention") {
478
+ ctx.ui.notify("The goal is not paused or waiting for attention.", "warning");
479
+ return;
480
+ }
481
+ const resumePhase =
482
+ state.phase === "paused" && state.pausedFrom && state.pausedFrom !== "paused"
483
+ ? state.pausedFrom
484
+ : state.plan.length === 0
485
+ ? "planning"
486
+ : "executing";
487
+ state.pausedFrom = undefined;
488
+ state.blockedReason = undefined;
489
+
490
+ if (resumePhase === "awaiting-execution" || resumePhase === "awaiting-review") {
491
+ state.phase = resumePhase;
492
+ persist(ctx);
493
+ await applyPhase(ctx);
494
+ ctx.ui.notify(
495
+ resumePhase === "awaiting-review"
496
+ ? "Goal resumed at the human review checkpoint."
497
+ : "Goal resumed with its plan awaiting execution approval.",
498
+ "info",
499
+ );
500
+ } else if (resumePhase === "verifying-step") {
501
+ state.phase = "verifying-step";
502
+ persist(ctx);
503
+ await startStepVerification(ctx);
504
+ } else if (resumePhase === "verifying") {
505
+ state.phase = "verifying";
506
+ persist(ctx);
507
+ await startVerification(ctx);
508
+ } else if (resumePhase === "planning" || state.plan.length === 0) {
509
+ state.phase = "planning";
510
+ persist(ctx);
511
+ const kickoff = planningPrompt("Resume planning the preserved goal.");
512
+ if (await moveToFreshSession(ctx, config, state, kickoff, "plan-resume")) return;
513
+ await applyPhase(ctx);
514
+ pi.sendUserMessage(kickoff);
515
+ } else {
516
+ state.phase = "executing";
517
+ persist(ctx);
518
+ const kickoff = executionPrompt();
519
+ if (await moveToFreshSession(ctx, config, state, kickoff, "execute-resume")) return;
520
+ await applyPhase(ctx);
521
+ pi.sendUserMessage(kickoff);
522
+ }
523
+ return;
524
+ }
525
+
526
+ const request = requestedGoal(input, ctx);
527
+ if (request && await confirmGoalReplacement(ctx)) {
528
+ await beginGoal(request.objective, request.sources, ctx);
529
+ }
530
+ },
531
+ });
532
+
533
+ pi.registerCommand("goal-status", {
534
+ description: "Show the active goal or find a recoverable goal for this project",
535
+ handler: async (_args, ctx) => displayStatus(pi, await statusForSession(ctx)),
536
+ });
537
+
538
+ pi.registerCommand("goal-plan", {
539
+ description: "Show the complete goal plan, acceptance criteria, risks, and verification methods",
540
+ handler: async (_args, ctx) => {
541
+ if (state.phase === "idle" || !state.objective) {
542
+ ctx.ui.notify("There is no active goal. Run /goal <objective> first.", "warning");
543
+ return;
544
+ }
545
+ if (state.plan.length === 0) {
546
+ ctx.ui.notify("The active goal does not have a submitted plan yet. Finish planning, then run /goal-plan again.", "warning");
547
+ return;
548
+ }
549
+ displayPlanForReview();
550
+ },
551
+ });
552
+
553
+ async function configureGoala(
554
+ args: string,
555
+ ctx: ExtensionCommandContext,
556
+ ): Promise<void> {
557
+ let choice = args.trim().toLowerCase();
558
+ if (!choice && ctx.hasUI) {
559
+ const selected = await ctx.ui.select("Goala setup", [
560
+ ...GOALA_SETUP_PRESETS.map((preset) => preset.label),
561
+ "Show current configuration",
562
+ ]);
563
+ choice = selected === "Show current configuration"
564
+ ? "status"
565
+ : GOALA_SETUP_PRESETS.find((preset) => preset.label === selected)?.id ?? "";
566
+ }
567
+
568
+ if (!choice || choice === "status") {
569
+ ctx.ui.notify(formatConfig(config), "info");
570
+ return;
571
+ }
572
+
573
+ const preset = GOALA_SETUP_PRESETS.find((candidate) => candidate.id === choice);
574
+ if (!preset) {
575
+ ctx.ui.notify(
576
+ `Usage: /goala-setup [status | ${GOALA_SETUP_PRESETS.map((candidate) => candidate.id).join(" | ")}]`,
577
+ "warning",
578
+ );
579
+ return;
580
+ }
581
+ const current = ctx.model
582
+ ? { provider: ctx.model.provider, id: ctx.model.id }
583
+ : sessionDefaultModel;
584
+ const proposed = preset.create(current);
585
+ if (!proposed) {
586
+ ctx.ui.notify(`Preset "${preset.label}" requires a current model.`, "warning");
587
+ return;
588
+ }
589
+ const missing = configuredModels(proposed).filter(
590
+ (model) => !ctx.modelRegistry.find(model.provider, model.id),
591
+ );
592
+ if (missing.length > 0) {
593
+ ctx.ui.notify(
594
+ `Preset not saved; unavailable model(s): ${missing.map((model) => `${model.provider}/${model.id}`).join(", ")}.`,
595
+ "warning",
596
+ );
597
+ return;
598
+ }
599
+ config = proposed;
600
+
601
+ const target = writeConfig(config);
602
+ fallbackNoticeShown = false;
603
+ await applyPhase(ctx);
604
+ ctx.ui.notify(`Goala configuration saved to ${target}\n\n${formatConfig(config)}`, "info");
605
+ }
606
+
607
+ pi.registerCommand("goala-setup", {
608
+ description: `Configure model roles: /goala-setup [status | ${GOALA_SETUP_PRESETS.map((preset) => preset.id).join(" | ")}]`,
609
+ handler: configureGoala,
610
+ });
611
+
612
+ pi.registerCommand("memory-status", {
613
+ description: "Show memory health and recent active episodes",
614
+ handler: async (_args, ctx) => {
615
+ if (!config.memory.enabled) {
616
+ ctx.ui.notify("Goala memory is disabled in the namespaced configuration.", "info");
617
+ return;
618
+ }
619
+ const memories = recentMemories(ctx.cwd, 20, true);
620
+ const health = memoryHealth();
621
+ const lines = memories.map(
622
+ (memory) =>
623
+ `${memory.id} · ${memory.status ?? "verified"} · ${memory.learnings.length} findings · ${truncate(memory.intent, 52)} · ${memory.repoKey} · ${memory.provenance ?? "unknown"} · ${memory.verifiedAt.slice(0, 10)}`,
624
+ );
625
+ ctx.ui.notify(
626
+ [
627
+ `Memory: ${health.ok ? "healthy" : "attention needed"} · ${health.verified} active · ${health.retired} retired`,
628
+ `Database: ${health.database}`,
629
+ health.lastError ? `Last error: ${health.lastError}` : "",
630
+ lines.length > 0
631
+ ? `Recent episodes:\n${lines.join("\n")}`
632
+ : "No verified or retired episodes are stored.",
633
+ ].filter(Boolean).join("\n"),
634
+ health.ok ? "info" : "warning",
635
+ );
636
+ },
637
+ });
638
+
639
+ pi.registerCommand("memory", {
640
+ description: "Retire or restore a durable memory episode",
641
+ handler: async (args, ctx) => {
642
+ if (!config.memory.enabled) {
643
+ ctx.ui.notify("Goala memory is disabled in the namespaced configuration.", "info");
644
+ return;
645
+ }
646
+ const [action, id, ...extra] = args.trim().split(/\s+/);
647
+ if (
648
+ extra.length > 0 ||
649
+ (action !== "retire" && action !== "restore") ||
650
+ !id
651
+ ) {
652
+ ctx.ui.notify("Usage: /memory retire <memory-id> or /memory restore <memory-id>", "warning");
653
+ return;
654
+ }
655
+ const status = action === "retire" ? "retired" : "verified";
656
+ if (!setMemoryStatus(id, status)) {
657
+ ctx.ui.notify(`Memory ${id} was not found or could not be updated. Run /memory-status for diagnostics.`, "warning");
658
+ return;
659
+ }
660
+ ctx.ui.notify(
661
+ `Memory ${id} is now ${status}. ${status === "retired" ? "It will no longer be recalled." : "It can be recalled again."}`,
662
+ "info",
663
+ );
664
+ },
665
+ });
666
+
667
+ pi.registerCommand("plan", {
668
+ description: "Start planning, or explicitly replace a progressed plan with /plan --replace",
669
+ handler: async (args, ctx) => {
670
+ const argument = args.trim();
671
+ const replace = argument === "--replace";
672
+ const objective = replace ? state.objective : argument || state.objective;
673
+ if (!objective) {
674
+ ctx.ui.notify("Usage: /goal <objective> or /plan <objective>", "warning");
675
+ return;
676
+ }
677
+ if (argument && !replace) {
678
+ const request = requestedGoal(argument, ctx);
679
+ if (request && await confirmGoalReplacement(ctx)) {
680
+ await beginGoal(request.objective, request.sources, ctx);
681
+ }
682
+ return;
683
+ }
684
+ const hasProgress = state.plan.some(
685
+ (step) => step.status !== "pending" || Boolean(step.evidence) || Boolean(step.review),
686
+ );
687
+ if (hasProgress && !replace) {
688
+ ctx.ui.notify(
689
+ "The plan has progress or review evidence. Use /plan --replace to discard that structured history.",
690
+ "warning",
691
+ );
692
+ return;
693
+ }
694
+ state.phase = "planning";
695
+ state.plan = [];
696
+ state.acceptanceCriteria = [];
697
+ state.risks = [];
698
+ state.verification = undefined;
699
+ state.blockedReason = undefined;
700
+ state.repairCycles = 0;
701
+ pendingAction = undefined;
702
+ persist(ctx);
703
+ const kickoff = planningPrompt("Re-plan the goal from the current repository state.");
704
+ if (await moveToFreshSession(ctx, config, state, kickoff, "replan")) return;
705
+ await applyPhase(ctx);
706
+ pi.sendUserMessage(kickoff);
707
+ },
708
+ });
709
+
710
+ pi.registerCommand("execute", {
711
+ description: "Approve and execute the plan: /execute [final | per-step]",
712
+ handler: async (args, ctx) => {
713
+ const requested = args.trim().toLowerCase();
714
+ if (requested && requested !== "final" && requested !== "per-step") {
715
+ ctx.ui.notify("Usage: /execute [final | per-step]", "warning");
716
+ return;
717
+ }
718
+ const reviewPolicy: ReviewPolicy =
719
+ requested === "per-step" || requested === "final"
720
+ ? requested
721
+ : config.reviewPolicy;
722
+ await startExecution(ctx, reviewPolicy);
723
+ },
724
+ });
725
+
726
+ pi.registerCommand("verify", {
727
+ description: "Run optional step verification at a checkpoint, or final goal verification",
728
+ handler: async (_args, ctx) => {
729
+ if (!state.objective || state.plan.length === 0) {
730
+ ctx.ui.notify("There is no planned goal to verify.", "warning");
731
+ return;
732
+ }
733
+ if (state.phase === "verifying-step") {
734
+ await startStepVerification(ctx);
735
+ return;
736
+ }
737
+ if (state.phase === "awaiting-review") {
738
+ const implemented = state.plan.find((step) => step.status === "implemented");
739
+ if (!implemented) {
740
+ ctx.ui.notify("The current step has already passed independent verification.", "info");
741
+ return;
742
+ }
743
+ await startStepVerification(ctx);
744
+ return;
745
+ }
746
+ if (state.plan.some((step) => step.status !== "done")) {
747
+ ctx.ui.notify("Final verification requires every plan step to be completed and approved.", "warning");
748
+ return;
749
+ }
750
+ await startVerification(ctx);
751
+ },
752
+ });
753
+
754
+ pi.on("tool_call", async (event, ctx) => enforceToolPolicy(state.phase, event, ctx));
755
+
756
+ pi.on("before_agent_start", async (event, ctx) => {
757
+ if (state.phase === "idle") return;
758
+ const sourceDrift = formatSourceDrift(
759
+ inspectGoalSources(ctx.cwd, state.sources),
760
+ );
761
+
762
+ let phaseInstructions = "";
763
+ switch (state.phase) {
764
+ case "planning":
765
+ phaseInstructions = `You are the PLANNER. Use read-only exploration only. Do not edit, install, commit, or change external state.
766
+ Resolve uncertainty by inspecting the repository. State material assumptions and risks.
767
+ Read every authoritative goal source in the phase context and cover its requirements in the acceptance criteria and plan.
768
+ Make plan steps meaningful, independently reviewable milestones; do not create micro-steps or a separate step whose only purpose is final regression checking.
769
+ Use recalled memory only as leads; confirm it against current files. Use memory_search for targeted history.
770
+ Your final action must be submit_plan with a structured, testable plan.`;
771
+ break;
772
+ case "awaiting-execution":
773
+ phaseInstructions = "The structured plan is awaiting user approval. Do not begin implementation.";
774
+ break;
775
+ case "executing":
776
+ phaseInstructions =
777
+ state.reviewPolicy === "per-step" && state.verification?.verdict !== "fail"
778
+ ? `You are the EXECUTOR. Implement only the first unapproved plan step shown in the phase context, or report a real blocker.
779
+ Read the current authoritative goal sources before implementation and preserve their acceptance contract.
780
+ Do not begin later steps. Use goal_progress complete_step with concrete evidence when this step's checks pass.
781
+ Use memory_search only when prior work is relevant.`
782
+ : `You are the EXECUTOR. Continue until the approved plan is implemented or a real blocker requires user input.
783
+ Read the current authoritative goal sources before implementation and preserve their acceptance contract.
784
+ Use goal_progress to record evidence for completed steps. Do not self-certify the goal.
785
+ Use memory_search only when prior work is relevant.
786
+ After all implementation work and checks are complete, submit ready_for_verification.`;
787
+ break;
788
+ case "verifying-step":
789
+ phaseInstructions = `You are the independent STEP VERIFIER. Verify only the implemented plan step identified in the phase context.
790
+ Read the authoritative goal sources and check the step against their current requirements.
791
+ Do not edit files. Treat executor evidence as untrusted and inspect the actual repository.
792
+ Your final action must be submit_step_verification. PASS requires concrete passing evidence for the step's verification method.`;
793
+ break;
794
+ case "awaiting-review":
795
+ phaseInstructions = `The completed step is awaiting human approval. Discuss its implementation, executor validation evidence, and tradeoffs using read-only inspection.
796
+ Use the authoritative goal sources as the requirements reference.
797
+ Do not edit or advance the plan. The user can run /goal approve, /goal revise <feedback>, or /verify for an optional independent step review.`;
798
+ break;
799
+ case "verifying":
800
+ phaseInstructions = `You are the independent VERIFIER. Treat prior completion claims as untrusted.
801
+ Read every authoritative goal source and independently verify the result against its current requirements as well as the structured acceptance criteria.
802
+ Do not edit files. Inspect actual changes and run relevant validation.
803
+ Your final action must be submit_verification. PASS requires concrete passing evidence for every acceptance criterion.
804
+ The findings field is the only learning path into durable episodic memory. Include only concise future-useful decisions, discoveries, or pitfalls that your own inspection confirmed, with evidence and an optional source path. Do not restate the goal or generic success. Return an empty findings array when there is no durable lesson.`;
805
+ break;
806
+ case "paused":
807
+ phaseInstructions = "The goal is paused. Preserve it but do not advance it unless the user explicitly resumes.";
808
+ break;
809
+ case "needs-attention":
810
+ phaseInstructions = "The goal needs user attention. Explain the blocker concisely; do not claim completion.";
811
+ break;
812
+ case "complete":
813
+ phaseInstructions = "The goal is verified complete. Do not reopen it unless the user explicitly asks.";
814
+ break;
815
+ }
816
+
817
+ return {
818
+ systemPrompt: `${event.systemPrompt}
819
+
820
+ ## Goala
821
+ ${phaseInstructions}
822
+
823
+ ${buildPhaseContext(state, config.memory)}
824
+
825
+ ${sourceDrift}`.trim(),
826
+ };
827
+ });
828
+
829
+ pi.on("context", async (event) => {
830
+ if (!config.freshSessionPerPhase) return;
831
+ const messages = slicePhaseContext(event.messages, state.phase);
832
+ if (messages) return { messages };
833
+ });
834
+
835
+ pi.on("agent_end", async (_event, ctx) => {
836
+ const action = pendingAction;
837
+ pendingAction = undefined;
838
+ if (!action) return;
839
+
840
+ if (action === "start-verification") {
841
+ // Automatic phase changes happen inside the executor's replacement
842
+ // session. Starting another physical session here can deadlock RPC/TUI
843
+ // session replacement. The context hook below gives the verifier an
844
+ // equivalent clean logical context in the current session.
845
+ await startVerification(ctx, false);
846
+ return;
847
+ }
848
+ if (action === "start-step-repair") {
849
+ const kickoff = executionPrompt();
850
+ await applyPhase(ctx);
851
+ pi.sendUserMessage(kickoff, { deliverAs: "followUp" });
852
+ return;
853
+ }
854
+ if (action === "start-repair") {
855
+ const kickoff = executionPrompt();
856
+ await applyPhase(ctx);
857
+ pi.sendUserMessage(kickoff, { deliverAs: "followUp" });
858
+ return;
859
+ }
860
+ if (action === "announce-step-review") {
861
+ await restoreSessionDefaults(ctx);
862
+ await applyPhase(ctx);
863
+ updateGoalUi(ctx, state);
864
+ const step = state.plan.find(
865
+ (candidate) => candidate.status === "implemented" || candidate.status === "verified",
866
+ );
867
+ pi.sendMessage(
868
+ {
869
+ customType: "goala-step-review",
870
+ content: `Step ready for review\n\n${step ? `${step.id}. ${step.title}\n${step.review?.summary ?? step.evidence ?? ""}` : formatState(state)}\n\nDiscuss the result, then run /goal approve or /goal revise <feedback>. Run /verify first when an independent step review is warranted.`,
871
+ display: true,
872
+ },
873
+ { triggerTurn: false },
874
+ );
875
+ return;
876
+ }
877
+ if (action === "announce-complete") {
878
+ await restoreSessionDefaults(ctx);
879
+ updateGoalUi(ctx, state);
880
+ pi.sendMessage(
881
+ {
882
+ customType: "goala-complete",
883
+ content: `✓ Verified complete\n\n${formatState(state)}`,
884
+ display: true,
885
+ },
886
+ { triggerTurn: false },
887
+ );
888
+ return;
889
+ }
890
+ if (action === "announce-needs-attention") {
891
+ await restoreSessionDefaults(ctx);
892
+ updateGoalUi(ctx, state);
893
+ pi.sendMessage(
894
+ {
895
+ customType: "goala-attention",
896
+ content: `Goal needs attention\n\n${formatState(state)}`,
897
+ display: true,
898
+ },
899
+ { triggerTurn: false },
900
+ );
901
+ }
902
+ });
903
+
904
+ pi.on("session_start", async (_event, ctx) => {
905
+ if (baselineTools.length === 0) {
906
+ baselineTools = pi.getActiveTools().filter((tool) => !GOALA_TOOLS.has(tool));
907
+ }
908
+ if (!sessionDefaultModel && ctx.model) {
909
+ sessionDefaultModel = {
910
+ provider: ctx.model.provider,
911
+ id: ctx.model.id,
912
+ thinkingLevel: ctx.thinkingLevel as ThinkingLevel | undefined,
913
+ };
914
+ }
915
+ restore(ctx);
916
+ const sessionFile = ctx.sessionManager.getSessionFile();
917
+ if (state.phase !== "idle" && sessionFile && !state.sessionFiles.includes(sessionFile)) {
918
+ state.sessionFiles.push(sessionFile);
919
+ persist(ctx);
920
+ }
921
+ await applyPhase(ctx);
922
+ });
923
+
924
+ pi.on("session_tree", async (_event, ctx) => {
925
+ restore(ctx);
926
+ // State entries can move the session leaf while a tool-driven turn is
927
+ // still active. Do not switch models mid-response (for example, from the
928
+ // verifier to the default executor before its final answer).
929
+ if (ctx.isIdle()) await applyPhase(ctx);
930
+ });
931
+ }