pi-goal-x 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,6 +84,7 @@ If the objective is already final and should start immediately, use:
84
84
  /goal-status Show focused goal state
85
85
  /goal-list List all open goals in .pi/goals/
86
86
  /goal-focus Choose this session's focused goal
87
+ /goal-unfocus Stop this session's goal work without modifying the shared goal
87
88
  /goal-tweak <change> Draft a revision to the focused active/paused goal
88
89
  /goal-pause Pause the focused active goal
89
90
  /goal-resume Resume a paused goal
@@ -104,7 +105,7 @@ Pressing `Esc` or aborting an active run pauses the goal so it does not remain f
104
105
  - **Branch-local focus**: because focus is reconstructed from the current session branch, `/tree` navigation can restore a different focus for a different branch.
105
106
  - **One continuation chain**: auto-continue only schedules work for the focused goal in the current session.
106
107
 
107
- Creating a goal with `/goals`, `/sisyphus`, `/goals-set`, or `/sisyphus-set` no longer clears other open goals. It creates a new active goal file and focuses it. Use `/goal-list` to inspect open goals and `/goal-focus` to switch the session focus. If the latest focus entry explicitly clears focus, or points at a missing/stale goal, a remaining single open goal is not auto-focused. By default (`autoSelectSingleGoal: false`) sessions start unfocused so goals stay session-scoped — useful when multiple sessions share the same `.pi/goals/` directory. Set `autoSelectSingleGoal: true` to restore the old behavior where a single open goal is auto-focused when no focus entry exists at all. If multiple open goals exist and the session has no valid focus, `/goal-resume`, `/goal-clear`, `/goal-abort`, `/goal-pause`, and `/goal-tweak` ask the user to choose a goal instead of acting on all of them.
108
+ Creating a goal with `/goals`, `/sisyphus`, `/goals-set`, or `/sisyphus-set` no longer clears other open goals. It creates a new active goal file and focuses it. Use `/goal-list` to inspect open goals, `/goal-focus` to switch the session focus, and `/goal-unfocus` to detach the current session without pausing, modifying, archiving, or recording a project-ledger focus change for the shared goal. Unfocus also aborts in-flight work and audits owned by that session; asynchronous lifecycle results are discarded if focus changed while they were pending. If the latest focus entry explicitly clears focus, or points at a missing/stale goal, a remaining single open goal is not auto-focused and resume does not prompt to replace that explicit choice. By default (`autoSelectSingleGoal: false`) sessions start unfocused so focus stays session-scoped — useful when multiple sessions share the same `.pi/goals/` directory. Set `autoSelectSingleGoal: true` to restore the old behavior where a single open goal is auto-focused when no focus entry exists at all. If multiple open goals exist and the session has no valid focus, `/goal-resume`, `/goal-clear`, `/goal-abort`, `/goal-pause`, and `/goal-tweak` ask the user to choose a goal instead of acting on all of them.
108
109
 
109
110
  ## Agent tools
110
111
 
@@ -77,6 +77,8 @@ let focusedGoalId: string | null = null;
77
77
  | `goalWorkToolCalledThisTurn` | empty-turn guard:只有本 turn 做了有意义工作才继续 auto-continue。 |
78
78
  | `turnStoppedFor` | pause/abort/complete/tweak 后阻止同一 turn 继续乱调用工具。 |
79
79
  | `postCompactReminderPending` | session compact 后给下一轮 agent 注入 deterministic resync prompt。 |
80
+ | `focusRevision` | session focus 改变时递增;异步 completion/tweak/task proposal 在写入前必须验证 revision,避免 unfocus 后迟到结果修改共享 goal。 |
81
+ | `hasExplicitSessionFocus` | 区分“从未选择 focus”和显式 null focus,保证 resume/tree 不覆盖 `/goal-unfocus`。 |
80
82
  | `activeGetGoalTurnsByGoalId` | 统计重复 `get_goal`,用于 soft nudge,不是 hard block。 |
81
83
 
82
84
  ## 4. 持久化:Goal 文件与 Ledger
@@ -140,6 +142,7 @@ ledger append 是 best-effort:写入失败不应该让用户的生命周期动
140
142
  | `/sisyphus-set <objective>` | 直接创建并启动 Sisyphus goal,不进入 draft discussion。 |
141
143
  | `/goal-list` | 列出 `.pi/goals/` 下所有 open goals。 |
142
144
  | `/goal-focus` | 让用户选择当前 session focus。 |
145
+ | `/goal-unfocus` | 清除当前 session focus,终止该 session 的 continuation/in-flight work/audit,并用 revision gate 丢弃迟到异步结果;不暂停、修改、归档共享 goal,也不写 project-global focus event。 |
143
146
  | `/goal-status` / `/goal` | 显示 focused goal 状态和其他 open goals 提示。 |
144
147
  | `/goal-tweak <change>` | 修改 focused goal,但也要先走 tweak drafting。 |
145
148
  | `/goal-pause` | 用户暂停 focused active goal。 |
@@ -68,6 +68,7 @@ Reusable logic is split into smaller modules:
68
68
  ├─ multiple open goals
69
69
  │ ├─ /goal-list shows the project goal pool
70
70
  │ ├─ /goal-focus chooses the session focus
71
+ │ ├─ /goal-unfocus clears only the session focus and leaves the shared goal open
71
72
  │ └─ unfocused sessions guide the user to choose instead of letting the agent decide
72
73
  │
73
74
  └─ /goal-clear or /goal-abort archives the focused goal or cancels drafting
@@ -89,7 +90,7 @@ Session focus is separate. Focus changes append a custom session entry:
89
90
  {
90
91
  version: 1,
91
92
  focusedGoalId: string | null,
92
- reason: "created" | "selected" | "resumed" | "completed" | "cleared" | "aborted" | "migrated"
93
+ reason: "created" | "selected" | "unfocused" | "resumed" | "completed" | "cleared" | "aborted" | "migrated"
93
94
  }
94
95
  ```
95
96
 
@@ -98,8 +99,8 @@ Because this is stored with `pi.appendEntry("pi-goal-focus", ...)`, it is sessio
98
99
  1. Use a valid focused id from the latest focus entry.
99
100
  2. If the latest focus entry explicitly has `focusedGoalId: null`, or points at a missing/stale goal, remain unfocused.
100
101
  3. If no focus entry exists, merge a compatible legacy `pi-goal-state { version: 3, goal }` goal and focus it. If disk already has the same id, the disk record wins and the legacy session record only supplies focus.
101
- 4. If no focus entry exists and exactly one open goal exists, auto-focus it for compatibility.
102
- 5. If multiple open goals exist and no valid focus exists, remain unfocused until `/goal-focus`, `/goal-resume`, `/goal-clear`, `/goal-abort`, `/goal-pause`, or `/goal-tweak` asks the user to choose.
102
+ 4. If no focus entry exists and `autoSelectSingleGoal` is enabled, auto-focus the sole open goal for compatibility. The default is disabled.
103
+ 5. Otherwise remain unfocused until the user explicitly selects a goal. `/goal-unfocus` appends a null focus entry so the current session stays detached without modifying the shared goal or appending a project-global focus event. Resume and tree reconstruction preserve that explicit null focus.
103
104
 
104
105
  Focus is human-owned. No agent tool can switch focus. Lifecycle tools operate only on the focused goal.
105
106
 
@@ -137,6 +138,7 @@ A deprecated optional `draftId` parameter is accepted for compatibility but igno
137
138
  - `/goals-set` and `/sisyphus-set` directly create and focus a new open goal from the supplied objective.
138
139
  - `/goal-list` prints all open goals with id, status, mode, usage, objective title, path, and a focus marker.
139
140
  - `/goal-focus` uses `ctx.ui.select` when multiple goals are open and updates only session focus.
141
+ - `/goal-unfocus` writes a null session focus entry, clears continuation/runtime state, aborts in-flight work and audits for that session, and leaves the shared active goal file and project-global focus ledger unchanged. Focus revision tokens prevent pending completion, tweak, and task-list confirmation results from mutating a goal after detachment.
140
142
  - `/goal-status` and `/goal` show the focused goal plus an `other open goals` hint.
141
143
  - `/goal-resume` resumes the focused paused goal; when unfocused with multiple open goals, it asks the user to choose. Choosing an already active goal only focuses it.
142
144
  - `/goal-clear` and `/goal-abort` archive only the focused/selected goal and never clear the whole pool at once.
@@ -1,3 +1,5 @@
1
+ import type { GoalTask } from "./goal-record.ts";
2
+
1
3
  export type GoalDraftingFocus = "goal" | "sisyphus";
2
4
 
3
5
  export interface GoalConfirmationIntentLike {
@@ -22,6 +24,45 @@ export type ToolGateDecision =
22
24
  | { block: false }
23
25
  | { block: true; reason: string };
24
26
 
27
+ // ── Shared formatting helpers ──────────────────────────────────────────────
28
+
29
+ function formatModeLabel(sisyphus: boolean): string {
30
+ return sisyphus ? "Sisyphus (prompt/criteria style)" : "Normal goal";
31
+ }
32
+
33
+ function formatPrefixedLines(content: string): string[] {
34
+ const lines: string[] = [];
35
+ for (const rawLine of content.split("\n")) {
36
+ const trimmed = rawLine.trim();
37
+ if (!trimmed) continue;
38
+ if (trimmed.startsWith("│")) {
39
+ lines.push(rawLine);
40
+ } else {
41
+ lines.push(`│ ${rawLine}`);
42
+ }
43
+ }
44
+ return lines;
45
+ }
46
+
47
+ function formatSection(title: string, content: string): string[] {
48
+ const body = formatPrefixedLines(content);
49
+ return ["", `─── ${title} ───`, "", ...body];
50
+ }
51
+
52
+ export function renderConfirmationTasks(tasks: GoalTask[], indent: number): string[] {
53
+ const prefix = " ".repeat(indent);
54
+ const lines: string[] = [];
55
+ for (const t of tasks) {
56
+ const lw = t.lightweightSubtasks ? " (lightweight)" : "";
57
+ const contract = t.verificationContract ? ` contract: ${t.verificationContract}` : "";
58
+ lines.push(`${prefix}[ ] ${t.id}: ${t.title}${lw}${contract}`);
59
+ if (t.subtasks && t.subtasks.length > 0) {
60
+ lines.push(...renderConfirmationTasks(t.subtasks, indent + 1));
61
+ }
62
+ }
63
+ return lines;
64
+ }
65
+
25
66
  export function promptSafeObjective(objective: string): string {
26
67
  return objective.replace(/<\/?untrusted_objective>/gi, (tag) => tag.replace(/</g, "&lt;").replace(/>/g, "&gt;"));
27
68
  }
@@ -84,30 +125,13 @@ export function buildDraftConfirmationText(args: {
84
125
  autoContinue: boolean;
85
126
  }): string {
86
127
  const lines: string[] = [];
87
- const modeLabel = args.focus === "sisyphus" ? "Sisyphus (prompt/criteria style)" : "Normal goal";
88
128
  lines.push("● Goal draft ready for confirmation.");
89
129
  lines.push("");
90
130
  lines.push("─── Draft Details ───");
91
- lines.push(`│ Mode: ${modeLabel}`);
131
+ lines.push(`│ Mode: ${formatModeLabel(args.focus === "sisyphus")}`);
92
132
  lines.push(`│ Auto-continue: ${args.autoContinue ? "yes" : "no"}`);
93
- lines.push("");
94
- lines.push("─── Original Topic ───");
95
- lines.push("");
96
- for (const topicLine of args.originalTopic.trim().split("\n")) {
97
- if (topicLine.trim()) lines.push(`│ ${topicLine}`);
98
- }
99
- lines.push("");
100
- lines.push("─── Proposed Goal ───");
101
- lines.push("");
102
- for (const objLine of args.objective.split("\n")) {
103
- const trimmed = objLine.trim();
104
- if (!trimmed) continue;
105
- if (trimmed.startsWith("│")) {
106
- lines.push(objLine);
107
- } else {
108
- lines.push(`│ ${objLine}`);
109
- }
110
- }
133
+ lines.push(...formatSection("Original Topic", args.originalTopic.trim()));
134
+ lines.push(...formatSection("Proposed Goal", args.objective));
111
135
  return lines.join("\n");
112
136
  }
113
137
 
@@ -116,42 +140,22 @@ export function buildTweakConfirmationText(args: {
116
140
  newObjective: string;
117
141
  changeSummary: string;
118
142
  sisyphus: boolean;
143
+ tasks?: GoalTask[];
119
144
  }): string {
120
145
  const lines: string[] = [];
121
- const modeLabel = args.sisyphus ? "Sisyphus (prompt/criteria style)" : "Normal goal";
122
146
  lines.push("● Goal tweak ready for confirmation.");
123
147
  lines.push("");
124
148
  lines.push("─── Draft Details ───");
125
- lines.push(`│ Mode: ${modeLabel}`);
126
- lines.push("");
127
- lines.push("─── Change ───");
128
- lines.push("");
129
- for (const changeLine of args.changeSummary.split("\n")) {
130
- if (changeLine.trim()) lines.push(`│ ${changeLine}`);
131
- }
132
- lines.push("");
133
- lines.push("─── Current Objective ───");
134
- lines.push("");
135
- for (const curLine of args.currentObjective.split("\n")) {
136
- const trimmed = curLine.trim();
137
- if (!trimmed) continue;
138
- if (trimmed.startsWith("│")) {
139
- lines.push(curLine);
140
- } else {
141
- lines.push(`│ ${curLine}`);
142
- }
143
- }
144
- lines.push("");
145
- lines.push("─── Proposed New Objective ───");
146
- lines.push("");
147
- for (const newLine of args.newObjective.split("\n")) {
148
- const trimmed = newLine.trim();
149
- if (!trimmed) continue;
150
- if (trimmed.startsWith("│")) {
151
- lines.push(newLine);
152
- } else {
153
- lines.push(`│ ${newLine}`);
154
- }
149
+ lines.push(`│ Mode: ${formatModeLabel(args.sisyphus)}`);
150
+ lines.push(...formatSection("Change", args.changeSummary));
151
+ lines.push(...formatSection("Current Objective", args.currentObjective));
152
+ lines.push(...formatSection("Proposed New Objective", args.newObjective));
153
+ if (args.tasks && args.tasks.length > 0) {
154
+ const taskLines = renderConfirmationTasks(args.tasks, 0);
155
+ lines.push("");
156
+ lines.push(`┌─ TASKS ─────────────────────────────────────┐`);
157
+ for (const tl of taskLines) lines.push(tl);
158
+ lines.push(`└──────────────────────────────────────────────┘`);
155
159
  }
156
160
  return lines.join("\n");
157
161
  }
@@ -2,7 +2,7 @@ export type GoalStatus = "active" | "paused" | "complete";
2
2
  export type StopReason = "user" | "agent";
3
3
  export type GoalEventKind = "checkpoint" | "stale" | "drafting";
4
4
  export type DraftingFocus = "goal" | "sisyphus";
5
- export type GoalFocusReason = "created" | "selected" | "resumed" | "completed" | "cleared" | "aborted" | "migrated";
5
+ export type GoalFocusReason = "created" | "selected" | "unfocused" | "resumed" | "completed" | "cleared" | "aborted" | "migrated";
6
6
 
7
7
  export type TaskStatus = "pending" | "complete" | "skipped";
8
8
 
@@ -146,7 +146,7 @@ export function normalizeGoalFocusEntry(value: unknown): GoalFocusEntry | null {
146
146
  ? safeIdPart(raw.focusedGoalId)
147
147
  : null;
148
148
  const reason: GoalFocusReason =
149
- raw.reason === "created" || raw.reason === "selected" || raw.reason === "resumed" || raw.reason === "completed" || raw.reason === "cleared" || raw.reason === "aborted" || raw.reason === "migrated"
149
+ raw.reason === "created" || raw.reason === "selected" || raw.reason === "unfocused" || raw.reason === "resumed" || raw.reason === "completed" || raw.reason === "cleared" || raw.reason === "aborted" || raw.reason === "migrated"
150
150
  ? raw.reason
151
151
  : "selected";
152
152
  return { version: 1, focusedGoalId, reason };
@@ -13,6 +13,7 @@ import {
13
13
  buildTweakConfirmationText,
14
14
  extractVerificationContract,
15
15
  goalDraftingPrompt,
16
+ renderConfirmationTasks,
16
17
  validateGoalDraftProposal,
17
18
  type GoalDraftingFocus,
18
19
  } from "./goal-draft.ts";
@@ -387,9 +388,39 @@ function isMeaningfulProgressToolCall(toolName: string, args: unknown): boolean
387
388
 
388
389
  // ---------- extension entry point ----------
389
390
 
390
- export default function goalExtension(pi: ExtensionAPI): void {
391
+ export default function goalExtension(
392
+ pi: ExtensionAPI,
393
+ dependencies: { runCompletionAuditor?: typeof runGoalCompletionAuditor } = {},
394
+ ): void {
391
395
  let goalsById = new Map<string, GoalRecord>();
392
396
  let focusedGoalId: string | null = null;
397
+ let focusRevision = 0;
398
+ let hasExplicitSessionFocus = false;
399
+
400
+ function assignFocusedGoalId(next: string | null): void {
401
+ if (focusedGoalId !== next) focusRevision += 1;
402
+ focusedGoalId = next;
403
+ }
404
+
405
+ function focusedOperationToken(goalId: string): { goalId: string; revision: number } {
406
+ return { goalId, revision: focusRevision };
407
+ }
408
+
409
+ function isFocusedOperationCurrent(token: { goalId: string; revision: number }): boolean {
410
+ return focusedGoalId === token.goalId && focusRevision === token.revision;
411
+ }
412
+
413
+ function focusedOperationCancelledResult(action: string, token: { goalId: string; revision: number }) {
414
+ return {
415
+ content: [{
416
+ type: "text" as const,
417
+ text: `${action} cancelled because goal ${token.goalId} is no longer focused in this session. The shared goal was not modified.`,
418
+ }],
419
+ details: goalDetails(state.goal),
420
+ terminate: true,
421
+ };
422
+ }
423
+
393
424
  const state = {
394
425
  get goal(): GoalRecord | null {
395
426
  return focusedGoalFromPool(goalsById, focusedGoalId);
@@ -397,11 +428,11 @@ export default function goalExtension(pi: ExtensionAPI): void {
397
428
  set goal(next: GoalRecord | null) {
398
429
  if (next) {
399
430
  goalsById.set(next.id, next);
400
- focusedGoalId = next.id;
431
+ assignFocusedGoalId(next.id);
401
432
  return;
402
433
  }
403
434
  if (focusedGoalId) goalsById.delete(focusedGoalId);
404
- focusedGoalId = null;
435
+ assignFocusedGoalId(null);
405
436
  },
406
437
  };
407
438
  let continuationQueuedFor: string | null = null;
@@ -426,8 +457,15 @@ export default function goalExtension(pi: ExtensionAPI): void {
426
457
  // after their successful execute. Once set, pi.on("tool_call") blocks all
427
458
  // subsequent in-turn tool calls except POST_STOP_ALLOWED_TOOLS. This is the
428
459
  // schema fix for "agent keeps writing files after pause_goal".
460
+ // turnSeq: lightweight generation counter, incremented at each turn start.
461
+ // Used to scope turnStoppedFor so stale markers from prior turns or sessions
462
+ // cannot poison a resumed active goal.
463
+ // checkpointGoalId: remembers the incoming goal id from queued continuations.
464
+ // When set but the goal is no longer active/autoContinue, work tools are blocked.
429
465
  let goalWorkToolCalledThisTurn = false;
430
- let turnStoppedFor: string | null = null;
466
+ let turnSeq = 0;
467
+ let turnStoppedFor: { goalId: string; turnSeq: number } | null = null;
468
+ let checkpointGoalId: string | null = null;
431
469
 
432
470
  // #5 post-compaction resync: when a compaction just happened, the next agent
433
471
  // turn gets an extra reminder block. Set in session_compact, consumed
@@ -542,6 +580,28 @@ export default function goalExtension(pi: ExtensionAPI): void {
542
580
  accounting.lastAccountedAt = null;
543
581
  }
544
582
 
583
+ function advanceTurnSeq(): void {
584
+ turnSeq += 1;
585
+ if (turnStoppedFor?.turnSeq !== turnSeq) turnStoppedFor = null;
586
+ }
587
+
588
+ function currentTurnStoppedGoalId(): string | null {
589
+ if (!turnStoppedFor) return null;
590
+ if (turnStoppedFor.turnSeq !== turnSeq) {
591
+ turnStoppedFor = null;
592
+ return null;
593
+ }
594
+ return turnStoppedFor.goalId;
595
+ }
596
+
597
+ function isActionableContinuationGoal(goalId: string | null | undefined): goalId is string {
598
+ return !!goalId && state.goal?.id === goalId && state.goal.status === "active" && state.goal.autoContinue;
599
+ }
600
+
601
+ function isStaleCheckpointBlockedToolCall(toolName: string): boolean {
602
+ return !POST_STOP_ALLOWED_TOOL_SET.has(toolName);
603
+ }
604
+
545
605
  function clearStoppedRuntimeState(): void {
546
606
  clearContinuationState();
547
607
  clearActiveAccounting();
@@ -572,11 +632,11 @@ export default function goalExtension(pi: ExtensionAPI): void {
572
632
  if (current && !current.activePath) {
573
633
  goalsById = fresh;
574
634
  goalsById.set(current.id, current);
575
- focusedGoalId = current.id;
635
+ assignFocusedGoalId(current.id);
576
636
  return true;
577
637
  }
578
638
  goalsById = fresh;
579
- focusedGoalId = null;
639
+ assignFocusedGoalId(null);
580
640
  clearStoppedRuntimeState();
581
641
  if (current) resetGetGoalNudgeState(current.id);
582
642
  if (tweakDraftingFor !== null) tweakDraftingFor = null;
@@ -589,19 +649,25 @@ export default function goalExtension(pi: ExtensionAPI): void {
589
649
  : diskGoal;
590
650
  goalsById = fresh;
591
651
  goalsById.set(reconciled.id, reconciled);
592
- focusedGoalId = reconciled.id;
652
+ assignFocusedGoalId(reconciled.id);
593
653
  if (reconciled.status !== "active" || !reconciled.autoContinue) clearContinuationState();
594
654
  if (reconciled.status !== "active") clearActiveAccounting();
595
655
  return true;
596
656
  }
597
657
 
598
658
  function appendFocusEntry(goalId: string | null, reason: GoalFocusReason): void {
659
+ hasExplicitSessionFocus = true;
599
660
  pi.appendEntry(FOCUS_ENTRY, goalFocusDetails(goalId, reason));
600
661
  }
601
662
 
602
- function setFocusedGoalId(goalId: string | null, ctx: ExtensionContext, reason: GoalFocusReason): void {
663
+ function setFocusedGoalId(
664
+ goalId: string | null,
665
+ ctx: ExtensionContext,
666
+ reason: GoalFocusReason,
667
+ opts: { recordLedger?: boolean } = {},
668
+ ): void {
603
669
  const previousGoalId = focusedGoalId;
604
- focusedGoalId = goalId && goalsById.has(goalId) ? goalId : null;
670
+ assignFocusedGoalId(goalId && goalsById.has(goalId) ? goalId : null);
605
671
  if (previousGoalId !== focusedGoalId) {
606
672
  clearContinuationState();
607
673
  clearActiveAccounting();
@@ -612,9 +678,9 @@ export default function goalExtension(pi: ExtensionAPI): void {
612
678
  appendFocusEntry(focusedGoalId, reason);
613
679
  // Append ledger event for focus changes
614
680
  try {
615
- if (focusedGoalId) {
681
+ if (opts.recordLedger !== false && focusedGoalId) {
616
682
  appendGoalEvent(ctx, { type: "goal_focused", goalId: focusedGoalId, reason, at: nowIso() });
617
- } else if (previousGoalId) {
683
+ } else if (opts.recordLedger !== false && previousGoalId) {
618
684
  appendGoalEvent(ctx, { type: "goal_unfocused", reason, at: nowIso() });
619
685
  }
620
686
  } catch {
@@ -627,7 +693,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
627
693
  function updateFocusedGoal(next: GoalRecord, ctx: ExtensionContext, shouldPersist = true): void {
628
694
  const previousGoalId = focusedGoalId;
629
695
  goalsById.set(next.id, next);
630
- focusedGoalId = next.id;
696
+ assignFocusedGoalId(next.id);
631
697
  if (previousGoalId !== focusedGoalId) {
632
698
  resetGetGoalNudgeState(previousGoalId);
633
699
  resetGetGoalNudgeState(focusedGoalId);
@@ -645,7 +711,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
645
711
  function removeFocusedGoal(ctx: ExtensionContext, reason: GoalFocusReason): void {
646
712
  const previousGoalId = focusedGoalId;
647
713
  if (focusedGoalId) goalsById.delete(focusedGoalId);
648
- focusedGoalId = null;
714
+ assignFocusedGoalId(null);
649
715
  clearStoppedRuntimeState();
650
716
  resetGetGoalNudgeState(previousGoalId);
651
717
  appendFocusEntry(null, reason);
@@ -829,7 +895,9 @@ export default function goalExtension(pi: ExtensionAPI): void {
829
895
 
830
896
  function loadState(ctx: ExtensionContext): void {
831
897
  goalsById = readActiveGoalPool(ctx);
832
- focusedGoalId = null;
898
+ focusRevision += 1; // Session reload/tree navigation invalidates pending async focus operations.
899
+ assignFocusedGoalId(null);
900
+ hasExplicitSessionFocus = false;
833
901
  let focusEntry: GoalFocusEntry | null = null;
834
902
  let legacyGoal: GoalRecord | null = null;
835
903
  let legacyStateSeen = false;
@@ -850,7 +918,8 @@ export default function goalExtension(pi: ExtensionAPI): void {
850
918
  legacyGoal = sanitizeGoalPaths(ctx, mergeGoalPromptFromDisk(ctx, legacyGoal));
851
919
  }
852
920
  const settings = loadGoalSettings(ctx.cwd);
853
- focusedGoalId = resolveSessionFocus({ pool: goalsById, focusEntry, legacyGoal, autoSelectSingleGoal: settings.autoSelectSingleGoal });
921
+ hasExplicitSessionFocus = focusEntry !== null;
922
+ assignFocusedGoalId(resolveSessionFocus({ pool: goalsById, focusEntry, legacyGoal, autoSelectSingleGoal: settings.autoSelectSingleGoal }));
854
923
  if (!focusEntry && focusedGoalId) {
855
924
  try {
856
925
  appendFocusEntry(focusedGoalId, legacyGoal?.id === focusedGoalId ? "migrated" : "selected");
@@ -1008,7 +1077,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
1008
1077
  state.goal = null;
1009
1078
  if (focusedGoalId === prevId) {
1010
1079
  goalsById.delete(prevId);
1011
- focusedGoalId = null;
1080
+ assignFocusedGoalId(null);
1012
1081
  }
1013
1082
  clearStoppedRuntimeState();
1014
1083
  syncGoalTools();
@@ -1245,7 +1314,7 @@ Verification contract:
1245
1314
  continuationTimer = null;
1246
1315
  continuationScheduledFor = null;
1247
1316
  syncGoalTools();
1248
- if (!state.goal || state.goal.id !== goalId || state.goal.status !== "active" || !state.goal.autoContinue) {
1317
+ if (!isActionableContinuationGoal(goalId)) {
1249
1318
  if (continuationQueuedFor === goalId) continuationQueuedFor = null;
1250
1319
  return;
1251
1320
  }
@@ -1266,16 +1335,18 @@ Verification contract:
1266
1335
  }
1267
1336
  continuationQueuedFor = goalId;
1268
1337
  const settings = loadGoalSettings(ctx.cwd);
1338
+ const goal = state.goal;
1339
+ if (!goal) return;
1269
1340
  pi.sendMessage<GoalEventDetails>(
1270
1341
  {
1271
1342
  customType: GOAL_EVENT_ENTRY,
1272
- content: continuationPrompt(state.goal, settings),
1343
+ content: continuationPrompt(goal, settings),
1273
1344
  display: false,
1274
1345
  details: {
1275
1346
  kind: "checkpoint",
1276
- goalId: state.goal.id,
1277
- status: state.goal.status,
1278
- objective: state.goal.objective,
1347
+ goalId: goal.id,
1348
+ status: goal.status,
1349
+ objective: goal.objective,
1279
1350
  timestamp: Date.now(),
1280
1351
  },
1281
1352
  },
@@ -1471,6 +1542,34 @@ Verification contract:
1471
1542
  ctx.ui.notify(`Focused goal: ${oneLineSummary(state.goal)}`, "info");
1472
1543
  }
1473
1544
 
1545
+ function unfocusGoalCommand(ctx: ExtensionContext): void {
1546
+ const runtimeGoalId = state.goal?.id ?? runningGoalId ?? checkpointGoalId;
1547
+ reconcileFocusedGoalFromDisk(ctx);
1548
+ const current = state.goal;
1549
+ const detachedGoalId = current?.id ?? runtimeGoalId;
1550
+ let wasBusy = false;
1551
+ try {
1552
+ wasBusy = !ctx.isIdle();
1553
+ } catch {}
1554
+ if (detachedGoalId && wasBusy) turnStoppedFor = { goalId: detachedGoalId, turnSeq };
1555
+ setFocusedGoalId(null, ctx, "unfocused", { recordLedger: false });
1556
+ runningGoalId = null;
1557
+ checkpointGoalId = null;
1558
+ postCompactReminderPending = false;
1559
+ if (auditAbortController) auditAbortController.abort();
1560
+ if (detachedGoalId && wasBusy) {
1561
+ try {
1562
+ ctx.abort?.();
1563
+ } catch {}
1564
+ }
1565
+ if (!current) {
1566
+ const openCount = openGoals().length;
1567
+ ctx.ui.notify(openCount > 0 ? buildUnfocusedOpenGoalsSummary(openCount) : detailedSummary(null), "info");
1568
+ return;
1569
+ }
1570
+ ctx.ui.notify(`Goal unfocused for this session. It remains open in .pi/goals: ${current.id}`, "info");
1571
+ }
1572
+
1474
1573
  async function handleGoalCommandTopic(rawTopic: string, ctx: ExtensionContext, focus: DraftingFocus, opts: { replace: boolean }): Promise<void> {
1475
1574
  const topic = rawTopic.trim();
1476
1575
  if (opts.replace) {
@@ -1729,7 +1828,7 @@ Verification contract:
1729
1828
  },
1730
1829
  };
1731
1830
  pi.registerCommand("goal", {
1732
- description: "Show focused goal status. Discuss with /goals or /sisyphus; direct-start with /goals-set or /sisyphus-set; manage with /goal-list, /goal-focus, /goal-settings, /goal-tweak, /goal-clear, /goal-abort, /goal-pause, /goal-resume.",
1831
+ description: "Show focused goal status. Discuss with /goals or /sisyphus; direct-start with /goals-set or /sisyphus-set; manage with /goal-list, /goal-focus, /goal-unfocus, /goal-settings, /goal-tweak, /goal-clear, /goal-abort, /goal-pause, /goal-resume.",
1733
1832
  handler: statusCommand.handler,
1734
1833
  });
1735
1834
  pi.registerCommand("goal-status", statusCommand);
@@ -1747,6 +1846,12 @@ Verification contract:
1747
1846
  await focusGoalCommand(ctx);
1748
1847
  },
1749
1848
  });
1849
+ pi.registerCommand("goal-unfocus", {
1850
+ description: "Stop focusing the current goal in this session without modifying or archiving the shared goal.",
1851
+ handler: async (_rawArgs, ctx) => {
1852
+ unfocusGoalCommand(ctx);
1853
+ },
1854
+ });
1750
1855
  pi.registerCommand("goal-settings", {
1751
1856
  description: "Open pi-goal settings, including auditor provider/model/thinking_level.",
1752
1857
  handler: async (_rawArgs, ctx) => {
@@ -1964,20 +2069,6 @@ Verification contract:
1964
2069
  const autoContinueFlag = params.autoContinue ?? true;
1965
2070
  const sisyphusFlag = validation.expectedSisyphus;
1966
2071
  // Build confirmation text: goal + optional task list
1967
- function renderConfirmationTasks(tasks: GoalTask[], indent: number): string[] {
1968
- const prefix = " ".repeat(indent);
1969
- const lines: string[] = [];
1970
- for (const t of tasks) {
1971
- const lw = t.lightweightSubtasks ? " (lightweight)" : "";
1972
- const contract = t.verificationContract ? ` contract: ${t.verificationContract}` : "";
1973
- lines.push(`${prefix}[ ] ${t.id}: ${t.title}${lw}${contract}`);
1974
- if (t.subtasks && t.subtasks.length > 0) {
1975
- lines.push(...renderConfirmationTasks(t.subtasks, indent + 1));
1976
- }
1977
- }
1978
- return lines;
1979
- }
1980
-
1981
2072
  let taskSummarySection = "";
1982
2073
  let tasksToCreate: GoalTask[] | undefined;
1983
2074
  if (params.tasks && params.tasks.length > 0) {
@@ -2124,10 +2215,25 @@ ${objective}` : objective,
2124
2215
  "The user will see a full plain-text report plus a [Confirm] / [Continue Chatting] choice. Confirm applies the tweak; Continue Chatting returns control to you to ask follow-up questions.",
2125
2216
  "If the tool returns 'continue chatting', ask the user what they want changed. Do NOT re-propose the same content immediately; iterate based on their feedback first.",
2126
2217
  "Do NOT use write/edit/bash to modify the active goal file directly. propose_goal_tweak is the only sanctioned channel.",
2218
+ "tasks (optional): an array of task objects to REPLACE the current goal's task list. When omitted,",
2219
+ " the existing task list is inherited as-is. When provided, the full task list is replaced with",
2220
+ " the new tasks — add/remove/change tasks by passing the complete updated list.",
2221
+ " Each task has: {id, title, verificationContract?, lightweightSubtasks?, subtasks?}.",
2222
+ " Subtasks use the same shape recursively. The task list is displayed in the confirmation",
2223
+ " dialog alongside the objective diff, matching propose_goal_draft's rendering format.",
2224
+ "Start from the current goal's objective text and task list (the inheritance defaults), then",
2225
+ " edit/rewrite them directly rather than composing a goal from scratch.",
2127
2226
  ],
2128
2227
  parameters: Type.Object({
2129
2228
  newObjective: Type.String({ description: "The complete revised objective text. For Sisyphus goals, preserve the Sisyphus style unless the user explicitly changes it." }),
2130
- changeSummary: Type.String({ description: "One-sentence description of what was changed (used in confirmation dialog and tweak log)." }),
2229
+ changeSummary: Type.String({ description: "One-sentence description of what was changed (used in confirmation dialog, activity log, and tweak log)." }),
2230
+ tasks: Type.Optional(Type.Array(Type.Object({
2231
+ id: Type.String({ description: "Short stable slug e.g. 'task-1'" }),
2232
+ title: Type.String({ description: "Human-readable task title" }),
2233
+ verificationContract: Type.Optional(Type.String({ description: "Optional verification contract for this task." })),
2234
+ lightweightSubtasks: Type.Optional(Type.Boolean({ description: "If true, subtasks are lightweight (no completion enforcement). Default false." })),
2235
+ subtasks: Type.Optional(Type.Any({ description: "Optional recursive array of sub-tasks (same shape as parent)." })),
2236
+ }), { description: "Optional task list to replace the current goal's tasks. When omitted the existing task list is inherited. Each task supports recursive subtasks." })),
2131
2237
  }),
2132
2238
  executionMode: "sequential",
2133
2239
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -2149,6 +2255,41 @@ ${objective}` : objective,
2149
2255
  const changeSummary = params.changeSummary.trim();
2150
2256
  if (!changeSummary) throw new Error("propose_goal_tweak requires a non-empty changeSummary.");
2151
2257
 
2258
+ // Resolve tasks: inherit from current goal if not provided, or normalize/validate the passed tasks.
2259
+ let tweakTasks: GoalTask[] | undefined;
2260
+ if (params.tasks && Array.isArray(params.tasks) && params.tasks.length > 0) {
2261
+ tweakTasks = params.tasks.map((t: Record<string, unknown>) => {
2262
+ const task: GoalTask = {
2263
+ id: t.id as string,
2264
+ title: t.title as string,
2265
+ status: "pending",
2266
+ verificationContract: t.verificationContract as string | undefined,
2267
+ lightweightSubtasks: t.lightweightSubtasks === true ? true : undefined,
2268
+ };
2269
+ const rawSubtasks = t.subtasks;
2270
+ if (Array.isArray(rawSubtasks) && rawSubtasks.length > 0) {
2271
+ task.subtasks = rawSubtasks
2272
+ .map((s: Record<string, unknown>) => normalizeTaskItem(s))
2273
+ .filter((s: GoalTask | undefined): s is GoalTask => !!s);
2274
+ }
2275
+ return task;
2276
+ });
2277
+ // Validate task list
2278
+ if (tweakTasks.length > 50) {
2279
+ return { content: [{ type: "text", text: "Task list cannot exceed 50 tasks." }], details: goalDetails(state.goal) };
2280
+ }
2281
+ const settings = loadGoalSettings(ctx.cwd);
2282
+ const depthViolation = findSubtaskDepthViolation(tweakTasks, settings.subtaskDepth ?? 1);
2283
+ if (depthViolation) {
2284
+ return { content: [{ type: "text", text: depthViolation }], details: goalDetails(state.goal) };
2285
+ }
2286
+ } else {
2287
+ // Inherit current task list when no tasks parameter provided
2288
+ if (state.goal.taskList && state.goal.taskList.tasks.length > 0) {
2289
+ tweakTasks = state.goal.taskList.tasks;
2290
+ }
2291
+ }
2292
+
2152
2293
  // Auto-start the tweak drafting flow if not already active.
2153
2294
  if (tweakDraftingFor !== state.goal.id) {
2154
2295
  await startGoalTweakDrafting(changeSummary, ctx);
@@ -2168,9 +2309,11 @@ ${objective}` : objective,
2168
2309
  newObjective,
2169
2310
  changeSummary,
2170
2311
  sisyphus: !!state.goal.sisyphus,
2312
+ tasks: tweakTasks,
2171
2313
  });
2172
2314
 
2173
2315
  const headless = shouldAutoConfirmProposal({ hasUI: ctx.hasUI, autoConfirmEnv: process.env.PI_GOAL_AUTO_CONFIRM });
2316
+ const tweakFocus = focusedOperationToken(state.goal.id);
2174
2317
 
2175
2318
  let decision: { decision: "confirm" | "continue"; auditorEnabled: boolean };
2176
2319
  if (headless) {
@@ -2187,6 +2330,9 @@ ${objective}` : objective,
2187
2330
  };
2188
2331
  }
2189
2332
  }
2333
+ if (!isFocusedOperationCurrent(tweakFocus)) {
2334
+ return focusedOperationCancelledResult("Goal tweak", tweakFocus);
2335
+ }
2190
2336
 
2191
2337
  if (decision.decision === "confirm") {
2192
2338
  // Persist any auditor toggle change
@@ -2204,6 +2350,9 @@ ${objective}` : objective,
2204
2350
  // Clear any prior agent pause reason — the user has redefined the work.
2205
2351
  pauseReason: undefined,
2206
2352
  pauseSuggestedAction: undefined,
2353
+ taskList: tweakTasks && tweakTasks.length > 0
2354
+ ? { tasks: tweakTasks, blockCompletion: false, proposedAt: nowIso() }
2355
+ : undefined,
2207
2356
  };
2208
2357
  // IMPORTANT: bypass setGoal() / persist() here. persist() calls
2209
2358
  // syncGoalPromptFromDisk() which would RE-READ the stale objective
@@ -2220,7 +2369,7 @@ ${objective}` : objective,
2220
2369
  // Reset autoContinue counter — plan changed, agent gets a fresh chain.
2221
2370
  resetGetGoalNudgeState(state.goal.id);
2222
2371
  // Mark turn-stopped so subsequent in-turn tool calls are blocked.
2223
- turnStoppedFor = state.goal.id;
2372
+ turnStoppedFor = { goalId: state.goal.id, turnSeq };
2224
2373
  syncGoalTools();
2225
2374
  updateUI(ctx);
2226
2375
  ctx.ui.notify(`Goal tweaked: ${truncateText(changeSummary, 160)}`, "info");
@@ -2235,6 +2384,20 @@ ${objective}` : objective,
2235
2384
  } catch {
2236
2385
  // Ledger append failure should not crash tweak
2237
2386
  }
2387
+ // Append ledger event for task list change if tasks were provided
2388
+ if (params.tasks && Array.isArray(params.tasks) && params.tasks.length > 0) {
2389
+ try {
2390
+ appendGoalEvent(ctx, {
2391
+ type: "task_list_set",
2392
+ goalId: state.goal.id,
2393
+ taskCount: params.tasks.length,
2394
+ blockCompletion: false,
2395
+ at: state.goal.updatedAt,
2396
+ });
2397
+ } catch {
2398
+ // Ledger append failure should not crash tweak
2399
+ }
2400
+ }
2238
2401
  return {
2239
2402
  content: [{
2240
2403
  type: "text",
@@ -2332,6 +2495,7 @@ ${objective}` : objective,
2332
2495
  }
2333
2496
 
2334
2497
  const auditTarget = mergeGoalPromptFromDisk(ctx, state.goal);
2498
+ const completionFocus = focusedOperationToken(auditTarget.id);
2335
2499
  // Append ledger: completion requested
2336
2500
  try {
2337
2501
  appendGoalEvent(ctx, {
@@ -2379,7 +2543,7 @@ ${objective}` : objective,
2379
2543
  updatedAt: nowIso(),
2380
2544
  };
2381
2545
  state.goal = writeActiveGoalFile(ctx, state.goal);
2382
- turnStoppedFor = state.goal?.id ?? null;
2546
+ turnStoppedFor = state.goal ? { goalId: state.goal.id, turnSeq } : null;
2383
2547
  resetGetGoalNudgeState(state.goal?.id);
2384
2548
  syncGoalTools();
2385
2549
  updateUI(ctx);
@@ -2445,7 +2609,7 @@ ${objective}` : objective,
2445
2609
  updatedAt: nowIso(),
2446
2610
  };
2447
2611
  state.goal = writeActiveGoalFile(ctx, state.goal);
2448
- turnStoppedFor = state.goal?.id ?? null;
2612
+ turnStoppedFor = state.goal ? { goalId: state.goal.id, turnSeq } : null;
2449
2613
  resetGetGoalNudgeState(state.goal?.id);
2450
2614
  syncGoalTools();
2451
2615
  updateUI(ctx);
@@ -2476,6 +2640,9 @@ ${objective}` : objective,
2476
2640
  display: true,
2477
2641
  details: { phase: "started", goalId: auditTarget.id, auditor: auditorLabel },
2478
2642
  }, { triggerTurn: true });
2643
+ if (!isFocusedOperationCurrent(completionFocus)) {
2644
+ return focusedOperationCancelledResult("Goal completion", completionFocus);
2645
+ }
2479
2646
  // Append ledger: audit started
2480
2647
  try {
2481
2648
  appendGoalEvent(ctx, {
@@ -2510,16 +2677,17 @@ ${objective}` : objective,
2510
2677
 
2511
2678
  // Create a dedicated AbortController for the audit so it can be interrupted via Escape
2512
2679
  auditAbortController?.abort(); // Clean up any stale controller
2513
- auditAbortController = new AbortController();
2680
+ const completionAuditController = new AbortController();
2681
+ auditAbortController = completionAuditController;
2514
2682
 
2515
- const auditor = await runGoalCompletionAuditor({
2683
+ const auditor = await (dependencies.runCompletionAuditor ?? runGoalCompletionAuditor)({
2516
2684
  ctx,
2517
2685
  goal: auditTarget,
2518
2686
  completionSummary: params.completionSummary,
2519
2687
  detailedSummary: detailedSummary(auditTarget),
2520
2688
  verificationSummary: params.verificationSummary,
2521
2689
  settings: loadGoalSettings(ctx.cwd),
2522
- signal: auditAbortController.signal,
2690
+ signal: completionAuditController.signal,
2523
2691
  onProgress: (progress) => {
2524
2692
  auditProgress = {
2525
2693
  ...progress,
@@ -2529,9 +2697,14 @@ ${objective}` : objective,
2529
2697
  },
2530
2698
  });
2531
2699
  // Clear abort controller — audit finished on its own
2532
- auditAbortController = null;
2700
+ if (auditAbortController === completionAuditController) auditAbortController = null;
2533
2701
  // Clear auditor progress display
2534
2702
  stopAuditAnimation();
2703
+ if (!isFocusedOperationCurrent(completionFocus)) {
2704
+ auditProgress = null;
2705
+ goalWidgetComponent?.invalidate();
2706
+ return focusedOperationCancelledResult("Goal completion", completionFocus);
2707
+ }
2535
2708
 
2536
2709
  // If the audit was aborted by the user (Esc), show a TUI dialog letting
2537
2710
  // the user choose: mark complete without audit, or continue working.
@@ -2543,6 +2716,9 @@ ${objective}` : objective,
2543
2716
  showingEscapeDialog = true;
2544
2717
  const userChoice: EscapeDialogResult = await showEscapeDialog(ctx, auditTarget.objective);
2545
2718
  showingEscapeDialog = false;
2719
+ if (!isFocusedOperationCurrent(completionFocus)) {
2720
+ return focusedOperationCancelledResult("Goal completion", completionFocus);
2721
+ }
2546
2722
 
2547
2723
  if (userChoice === "complete_without_audit") {
2548
2724
  // ── Mark complete without audit ────────────────────────────
@@ -2574,7 +2750,7 @@ ${objective}` : objective,
2574
2750
  updatedAt: nowIso(),
2575
2751
  };
2576
2752
  state.goal = writeActiveGoalFile(ctx, state.goal);
2577
- turnStoppedFor = state.goal?.id ?? null;
2753
+ turnStoppedFor = state.goal ? { goalId: state.goal.id, turnSeq } : null;
2578
2754
  resetGetGoalNudgeState(state.goal?.id);
2579
2755
  syncGoalTools();
2580
2756
  updateUI(ctx);
@@ -2592,7 +2768,7 @@ ${objective}` : objective,
2592
2768
  } else {
2593
2769
  // ── Continue working → pause the goal ──────────────
2594
2770
  pauseActiveGoal(ctx);
2595
- turnStoppedFor = state.goal?.id ?? null;
2771
+ turnStoppedFor = state.goal ? { goalId: state.goal.id, turnSeq } : null;
2596
2772
  return {
2597
2773
  content: [{ type: "text", text: "Goal paused — user chose to continue working after skipping audit." }],
2598
2774
  details: state.goal ? goalDetails(state.goal) : undefined,
@@ -2674,7 +2850,7 @@ ${objective}` : objective,
2674
2850
  updatedAt: nowIso(),
2675
2851
  };
2676
2852
  state.goal = writeActiveGoalFile(ctx, state.goal);
2677
- turnStoppedFor = state.goal?.id ?? null;
2853
+ turnStoppedFor = state.goal ? { goalId: state.goal.id, turnSeq } : null;
2678
2854
  resetGetGoalNudgeState(state.goal?.id);
2679
2855
  syncGoalTools();
2680
2856
  updateUI(ctx);
@@ -2741,7 +2917,7 @@ ${objective}` : objective,
2741
2917
  resetGetGoalNudgeState(next.id);
2742
2918
  // C9 fix: mark turn-stopped so subsequent in-turn tool calls are blocked.
2743
2919
  // This is the schema-level closure of "agent kept writing files after pause_goal".
2744
- turnStoppedFor = state.goal.id;
2920
+ turnStoppedFor = { goalId: state.goal.id, turnSeq };
2745
2921
 
2746
2922
  const suggestionLine = suggested ? `\nSuggested: ${truncateText(suggested, 160)}` : "";
2747
2923
  ctx.ui.notify(
@@ -2801,7 +2977,7 @@ ${objective}` : objective,
2801
2977
  const archived = archiveCurrentGoal(ctx, "agent");
2802
2978
  resetGetGoalNudgeState(abortedGoalId);
2803
2979
  setGoal(null, ctx, true, "aborted");
2804
- turnStoppedFor = abortedGoalId;
2980
+ turnStoppedFor = { goalId: abortedGoalId, turnSeq };
2805
2981
 
2806
2982
  const archiveLine = archived?.archivedPath ? `\nArchive: ${archived.archivedPath}` : "";
2807
2983
  ctx.ui.notify(
@@ -2967,8 +3143,12 @@ ${objective}` : objective,
2967
3143
  const taskLines = renderTaskLines(taskList.tasks);
2968
3144
  const gateLabel = blockCompletion ? " (blockCompletion enabled)" : "";
2969
3145
  const proposalText = [`Proposed task list${gateLabel}:`, "", ...taskLines].join("\n");
3146
+ const taskListFocus = focusedOperationToken(state.goal.id);
2970
3147
 
2971
3148
  const dialogResult = await showProposalDialog(ctx, proposalText, "goal", !state.goal?.skipAuditor);
3149
+ if (!isFocusedOperationCurrent(taskListFocus)) {
3150
+ return focusedOperationCancelledResult("Task list proposal", taskListFocus);
3151
+ }
2972
3152
  if (dialogResult.decision !== "confirm") {
2973
3153
  return {
2974
3154
  content: [{ type: "text", text: "Task list proposal declined." }],
@@ -2990,7 +3170,7 @@ ${objective}` : objective,
2990
3170
  }
2991
3171
  state.goal = { ...state.goal, taskList, updatedAt: now };
2992
3172
  setGoal(state.goal, ctx);
2993
- turnStoppedFor = state.goal.id;
3173
+ turnStoppedFor = { goalId: state.goal.id, turnSeq };
2994
3174
  resetGetGoalNudgeState(state.goal.id);
2995
3175
  syncGoalTools();
2996
3176
  updateUI(ctx);
@@ -3267,8 +3447,8 @@ promptGuidelines: [
3267
3447
 
3268
3448
  pi.on("turn_start", async (_event, ctx) => {
3269
3449
  // Per-turn flag resets (#4 + C9 fix).
3450
+ advanceTurnSeq();
3270
3451
  goalWorkToolCalledThisTurn = false;
3271
- turnStoppedFor = null;
3272
3452
  syncGoalTools();
3273
3453
  beginAccounting();
3274
3454
  updateUI(ctx);
@@ -3276,17 +3456,29 @@ promptGuidelines: [
3276
3456
 
3277
3457
  // #4 + C9 fix + Phase 5 C3: gate in-turn tool calls based on lifecycle state.
3278
3458
  pi.on("tool_call", async (event, ctx) => {
3459
+ const stoppedGoalId = currentTurnStoppedGoalId();
3279
3460
  // Post-stop in-turn block (C9 0ad8 fix): after pause_goal / abort_goal /
3280
3461
  // complete_goal / propose_goal_tweak fires in this turn, block all subsequent tool calls except
3281
3462
  // read-only inspection. Forces the agent to yield the turn instead of "fixing"
3282
3463
  // the situation by creating extra files etc.
3283
- if (turnStoppedFor !== null && !POST_STOP_ALLOWED_TOOL_SET.has(event.toolName)) {
3464
+ if (stoppedGoalId !== null && !POST_STOP_ALLOWED_TOOL_SET.has(event.toolName)) {
3284
3465
  return {
3285
3466
  block: true,
3286
- reason: `The goal was already stopped earlier in this turn (goalId=${turnStoppedFor}). ` +
3467
+ reason: `The goal was already stopped earlier in this turn (goalId=${stoppedGoalId}). ` +
3287
3468
  `Do not call more tools; end the turn with a brief summary and yield to the user.`,
3288
3469
  };
3289
3470
  }
3471
+ // Stale checkpoint guard: if the turn was triggered by a queued continuation
3472
+ // for a goal that is no longer active/autoContinue, block work tools.
3473
+ if (checkpointGoalId !== null && !isActionableContinuationGoal(checkpointGoalId) && isStaleCheckpointBlockedToolCall(event.toolName)) {
3474
+ // Block the tool call with a stale-checkpoint message.
3475
+ return {
3476
+ block: true,
3477
+ reason: `Cannot call ${event.toolName}: the goal checkpoint that triggered this turn is no longer active. ` +
3478
+ `Goal ${checkpointGoalId} has been paused, cleared, or replaced. ` +
3479
+ `End the turn with a brief summary and yield to the user.`,
3480
+ };
3481
+ }
3290
3482
  // Phase 5 soft gate relaxation: active-goal question block and repeated get_goal
3291
3483
  // block are removed. The agent is trusted to prefer work tools; prompts nudge
3292
3484
  // toward concrete work without hard-stopping the turn.
@@ -3329,7 +3521,7 @@ promptGuidelines: [
3329
3521
  const archived = archiveGoalFile(ctx, completedGoal);
3330
3522
  resetGetGoalNudgeState(completedGoal.id);
3331
3523
  goalsById.delete(completedGoal.id);
3332
- focusedGoalId = null;
3524
+ assignFocusedGoalId(null);
3333
3525
  appendFocusEntry(null, "completed");
3334
3526
  syncGoalTools();
3335
3527
  updateUI(ctx);
@@ -3369,8 +3561,9 @@ promptGuidelines: [
3369
3561
  pi.on("session_start", async (event, ctx) => {
3370
3562
  syncGoalTools();
3371
3563
  loadState(ctx);
3564
+ syncGoalTools();
3372
3565
  syncTerminalInputPause(ctx);
3373
- if (event.reason === "resume" && !state.goal && openGoals().length > 1 && ctx.hasUI) {
3566
+ if (event.reason === "resume" && !state.goal && !hasExplicitSessionFocus && openGoals().length > 1 && ctx.hasUI) {
3374
3567
  await focusGoalCommand(ctx);
3375
3568
  }
3376
3569
  // Codex behavior: prompt before reactivating a paused goal on resume.
@@ -3409,11 +3602,13 @@ promptGuidelines: [
3409
3602
  });
3410
3603
 
3411
3604
  pi.on("before_agent_start", async (event, ctx) => {
3605
+ advanceTurnSeq();
3412
3606
  syncGoalTools();
3413
3607
  const currentSystemPrompt = () => ctx.getSystemPrompt?.() || event.systemPrompt;
3414
3608
  const incomingGoalId = extractGoalIdFromInjectedMessage(event.prompt ?? "");
3415
3609
 
3416
3610
  if (confirmationIntent !== null) {
3611
+ checkpointGoalId = null;
3417
3612
  clearContinuationState();
3418
3613
  clearActiveAccounting();
3419
3614
  runningGoalId = null;
@@ -3421,6 +3616,7 @@ promptGuidelines: [
3421
3616
  }
3422
3617
 
3423
3618
  if (tweakDraftingFor !== null) {
3619
+ checkpointGoalId = null;
3424
3620
  clearContinuationState();
3425
3621
  clearActiveAccounting();
3426
3622
  runningGoalId = null;
@@ -3431,8 +3627,12 @@ promptGuidelines: [
3431
3627
  // matches the active goal, abort the whole turn instead of letting the
3432
3628
  // model act on a stale instruction.
3433
3629
  if (incomingGoalId !== null) {
3630
+ // Reconcile from disk to pick up any external state changes before
3631
+ // evaluating whether the checkpoint is actionable.
3632
+ reconcileFocusedGoalFromDisk(ctx);
3633
+ checkpointGoalId = incomingGoalId;
3434
3634
  clearContinuationState();
3435
- if (!state.goal || state.goal.id !== incomingGoalId || (state.goal.status !== "active") || !state.goal.autoContinue) {
3635
+ if (!isActionableContinuationGoal(incomingGoalId)) {
3436
3636
  try {
3437
3637
  ctx.abort?.();
3438
3638
  } catch {}
@@ -3441,10 +3641,12 @@ promptGuidelines: [
3441
3641
  systemPrompt: `${currentSystemPrompt()}\n\n${staleContinuationPrompt(incomingGoalId, state.goal)}`,
3442
3642
  };
3443
3643
  }
3644
+ checkpointGoalId = null;
3444
3645
  } else {
3445
3646
  // A user-driven turn — clear any queued continuation so we don't
3446
3647
  // double-fire after the user's own message returns. Also reset the
3447
3648
  // autoContinue nudge state so the user always gets a fresh chain.
3649
+ checkpointGoalId = null;
3448
3650
  clearContinuationState();
3449
3651
  resetGetGoalNudgeState(state.goal?.id);
3450
3652
  }
@@ -238,14 +238,21 @@ export function goalTweakDraftingPrompt(current: GoalRecord, hint: string): stri
238
238
  "<current_objective>",
239
239
  promptSafeObjective(current.objective),
240
240
  "</current_objective>",
241
+ ...(current.taskList && current.taskList.tasks.length > 0
242
+ ? ["", taskListBlock(current), ""]
243
+ : []),
241
244
  `Sisyphus mode: ${sisyphusOn ? "on (prompt/criteria style)" : "off"}`,
242
245
  "",
243
246
  "User's tweak hint (may be empty):",
247
+ "User's tweak hint (may be empty):",
244
248
  "<tweak_hint>",
245
249
  safeHint,
246
250
  "</tweak_hint>",
247
251
  "",
248
252
  "Drafting protocol:",
253
+ "- Start from the EXISTING goal — you are editing the current goal, not writing from scratch.",
254
+ " The current objective (above) and task list (if any) are your starting point. Edit/rewrite",
255
+ " them directly, preserving what works and changing what needs to change.",
249
256
  "- Apply common sense: if the hint is fully self-explanatory, acknowledge in one sentence and apply the tweak immediately. Do not invent unnecessary questions.",
250
257
  "- Otherwise ask focused questions (1-3 rounds) to clarify exactly what to change. Prefer numbered options or yes/no.",
251
258
  "- Do NOT call create_goal (a goal already exists).",
@@ -264,6 +271,10 @@ export function goalTweakDraftingPrompt(current: GoalRecord, hint: string): stri
264
271
  ? " === Sisyphus Goal === block (Objective / Success criteria / Boundaries / Constraints / If blocked / Sisyphus reminder)."
265
272
  : " === Goal === block (Objective / Success criteria / Boundaries / Constraints / If blocked)."),
266
273
  " - changeSummary: one sentence describing what changed.",
274
+ " - tasks (optional): an array of task objects to REPLACE the current goal's task list. If omitted,",
275
+ " the existing task list is inherited as-is. If you need to add/remove/change tasks, pass the",
276
+ " full updated task list here. Each task has {id, title, verificationContract?, lightweightSubtasks?, subtasks?}.",
277
+ " Subtasks use the same shape recursively.",
267
278
  "2. propose_goal_tweak opens the user's Confirm / Continue Chatting dialog.",
268
279
  " - Confirm applies the tweak. Stop; the next continuation will arrive automatically if the goal is active.",
269
280
  " - Continue Chatting means the drafting stays active — ask the user what they want changed, then revise and call propose_goal_tweak again.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Goal mode extension for pi: persistent long-running objectives, /goal-set drafting, Sisyphus prompt style, autoContinue, and an above-editor status overlay. Fork of @capyup/pi-goal.",
5
5
  "license": "MIT",
6
6
  "author": "pi-goal-x contributors",