pi-goal-x 0.20.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -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";
@@ -426,8 +427,15 @@ export default function goalExtension(pi: ExtensionAPI): void {
426
427
  // after their successful execute. Once set, pi.on("tool_call") blocks all
427
428
  // subsequent in-turn tool calls except POST_STOP_ALLOWED_TOOLS. This is the
428
429
  // schema fix for "agent keeps writing files after pause_goal".
430
+ // turnSeq: lightweight generation counter, incremented at each turn start.
431
+ // Used to scope turnStoppedFor so stale markers from prior turns or sessions
432
+ // cannot poison a resumed active goal.
433
+ // checkpointGoalId: remembers the incoming goal id from queued continuations.
434
+ // When set but the goal is no longer active/autoContinue, work tools are blocked.
429
435
  let goalWorkToolCalledThisTurn = false;
430
- let turnStoppedFor: string | null = null;
436
+ let turnSeq = 0;
437
+ let turnStoppedFor: { goalId: string; turnSeq: number } | null = null;
438
+ let checkpointGoalId: string | null = null;
431
439
 
432
440
  // #5 post-compaction resync: when a compaction just happened, the next agent
433
441
  // turn gets an extra reminder block. Set in session_compact, consumed
@@ -542,6 +550,28 @@ export default function goalExtension(pi: ExtensionAPI): void {
542
550
  accounting.lastAccountedAt = null;
543
551
  }
544
552
 
553
+ function advanceTurnSeq(): void {
554
+ turnSeq += 1;
555
+ if (turnStoppedFor?.turnSeq !== turnSeq) turnStoppedFor = null;
556
+ }
557
+
558
+ function currentTurnStoppedGoalId(): string | null {
559
+ if (!turnStoppedFor) return null;
560
+ if (turnStoppedFor.turnSeq !== turnSeq) {
561
+ turnStoppedFor = null;
562
+ return null;
563
+ }
564
+ return turnStoppedFor.goalId;
565
+ }
566
+
567
+ function isActionableContinuationGoal(goalId: string | null | undefined): goalId is string {
568
+ return !!goalId && state.goal?.id === goalId && state.goal.status === "active" && state.goal.autoContinue;
569
+ }
570
+
571
+ function isStaleCheckpointBlockedToolCall(toolName: string): boolean {
572
+ return !POST_STOP_ALLOWED_TOOL_SET.has(toolName);
573
+ }
574
+
545
575
  function clearStoppedRuntimeState(): void {
546
576
  clearContinuationState();
547
577
  clearActiveAccounting();
@@ -1245,7 +1275,7 @@ Verification contract:
1245
1275
  continuationTimer = null;
1246
1276
  continuationScheduledFor = null;
1247
1277
  syncGoalTools();
1248
- if (!state.goal || state.goal.id !== goalId || state.goal.status !== "active" || !state.goal.autoContinue) {
1278
+ if (!isActionableContinuationGoal(goalId)) {
1249
1279
  if (continuationQueuedFor === goalId) continuationQueuedFor = null;
1250
1280
  return;
1251
1281
  }
@@ -1266,16 +1296,18 @@ Verification contract:
1266
1296
  }
1267
1297
  continuationQueuedFor = goalId;
1268
1298
  const settings = loadGoalSettings(ctx.cwd);
1299
+ const goal = state.goal;
1300
+ if (!goal) return;
1269
1301
  pi.sendMessage<GoalEventDetails>(
1270
1302
  {
1271
1303
  customType: GOAL_EVENT_ENTRY,
1272
- content: continuationPrompt(state.goal, settings),
1304
+ content: continuationPrompt(goal, settings),
1273
1305
  display: false,
1274
1306
  details: {
1275
1307
  kind: "checkpoint",
1276
- goalId: state.goal.id,
1277
- status: state.goal.status,
1278
- objective: state.goal.objective,
1308
+ goalId: goal.id,
1309
+ status: goal.status,
1310
+ objective: goal.objective,
1279
1311
  timestamp: Date.now(),
1280
1312
  },
1281
1313
  },
@@ -1964,20 +1996,6 @@ Verification contract:
1964
1996
  const autoContinueFlag = params.autoContinue ?? true;
1965
1997
  const sisyphusFlag = validation.expectedSisyphus;
1966
1998
  // 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
1999
  let taskSummarySection = "";
1982
2000
  let tasksToCreate: GoalTask[] | undefined;
1983
2001
  if (params.tasks && params.tasks.length > 0) {
@@ -2124,10 +2142,25 @@ ${objective}` : objective,
2124
2142
  "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
2143
  "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
2144
  "Do NOT use write/edit/bash to modify the active goal file directly. propose_goal_tweak is the only sanctioned channel.",
2145
+ "tasks (optional): an array of task objects to REPLACE the current goal's task list. When omitted,",
2146
+ " the existing task list is inherited as-is. When provided, the full task list is replaced with",
2147
+ " the new tasks — add/remove/change tasks by passing the complete updated list.",
2148
+ " Each task has: {id, title, verificationContract?, lightweightSubtasks?, subtasks?}.",
2149
+ " Subtasks use the same shape recursively. The task list is displayed in the confirmation",
2150
+ " dialog alongside the objective diff, matching propose_goal_draft's rendering format.",
2151
+ "Start from the current goal's objective text and task list (the inheritance defaults), then",
2152
+ " edit/rewrite them directly rather than composing a goal from scratch.",
2127
2153
  ],
2128
2154
  parameters: Type.Object({
2129
2155
  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)." }),
2156
+ changeSummary: Type.String({ description: "One-sentence description of what was changed (used in confirmation dialog, activity log, and tweak log)." }),
2157
+ tasks: Type.Optional(Type.Array(Type.Object({
2158
+ id: Type.String({ description: "Short stable slug e.g. 'task-1'" }),
2159
+ title: Type.String({ description: "Human-readable task title" }),
2160
+ verificationContract: Type.Optional(Type.String({ description: "Optional verification contract for this task." })),
2161
+ lightweightSubtasks: Type.Optional(Type.Boolean({ description: "If true, subtasks are lightweight (no completion enforcement). Default false." })),
2162
+ subtasks: Type.Optional(Type.Any({ description: "Optional recursive array of sub-tasks (same shape as parent)." })),
2163
+ }), { 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
2164
  }),
2132
2165
  executionMode: "sequential",
2133
2166
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -2149,6 +2182,41 @@ ${objective}` : objective,
2149
2182
  const changeSummary = params.changeSummary.trim();
2150
2183
  if (!changeSummary) throw new Error("propose_goal_tweak requires a non-empty changeSummary.");
2151
2184
 
2185
+ // Resolve tasks: inherit from current goal if not provided, or normalize/validate the passed tasks.
2186
+ let tweakTasks: GoalTask[] | undefined;
2187
+ if (params.tasks && Array.isArray(params.tasks) && params.tasks.length > 0) {
2188
+ tweakTasks = params.tasks.map((t: Record<string, unknown>) => {
2189
+ const task: GoalTask = {
2190
+ id: t.id as string,
2191
+ title: t.title as string,
2192
+ status: "pending",
2193
+ verificationContract: t.verificationContract as string | undefined,
2194
+ lightweightSubtasks: t.lightweightSubtasks === true ? true : undefined,
2195
+ };
2196
+ const rawSubtasks = t.subtasks;
2197
+ if (Array.isArray(rawSubtasks) && rawSubtasks.length > 0) {
2198
+ task.subtasks = rawSubtasks
2199
+ .map((s: Record<string, unknown>) => normalizeTaskItem(s))
2200
+ .filter((s: GoalTask | undefined): s is GoalTask => !!s);
2201
+ }
2202
+ return task;
2203
+ });
2204
+ // Validate task list
2205
+ if (tweakTasks.length > 50) {
2206
+ return { content: [{ type: "text", text: "Task list cannot exceed 50 tasks." }], details: goalDetails(state.goal) };
2207
+ }
2208
+ const settings = loadGoalSettings(ctx.cwd);
2209
+ const depthViolation = findSubtaskDepthViolation(tweakTasks, settings.subtaskDepth ?? 1);
2210
+ if (depthViolation) {
2211
+ return { content: [{ type: "text", text: depthViolation }], details: goalDetails(state.goal) };
2212
+ }
2213
+ } else {
2214
+ // Inherit current task list when no tasks parameter provided
2215
+ if (state.goal.taskList && state.goal.taskList.tasks.length > 0) {
2216
+ tweakTasks = state.goal.taskList.tasks;
2217
+ }
2218
+ }
2219
+
2152
2220
  // Auto-start the tweak drafting flow if not already active.
2153
2221
  if (tweakDraftingFor !== state.goal.id) {
2154
2222
  await startGoalTweakDrafting(changeSummary, ctx);
@@ -2168,6 +2236,7 @@ ${objective}` : objective,
2168
2236
  newObjective,
2169
2237
  changeSummary,
2170
2238
  sisyphus: !!state.goal.sisyphus,
2239
+ tasks: tweakTasks,
2171
2240
  });
2172
2241
 
2173
2242
  const headless = shouldAutoConfirmProposal({ hasUI: ctx.hasUI, autoConfirmEnv: process.env.PI_GOAL_AUTO_CONFIRM });
@@ -2204,6 +2273,9 @@ ${objective}` : objective,
2204
2273
  // Clear any prior agent pause reason — the user has redefined the work.
2205
2274
  pauseReason: undefined,
2206
2275
  pauseSuggestedAction: undefined,
2276
+ taskList: tweakTasks && tweakTasks.length > 0
2277
+ ? { tasks: tweakTasks, blockCompletion: false, proposedAt: nowIso() }
2278
+ : undefined,
2207
2279
  };
2208
2280
  // IMPORTANT: bypass setGoal() / persist() here. persist() calls
2209
2281
  // syncGoalPromptFromDisk() which would RE-READ the stale objective
@@ -2220,7 +2292,7 @@ ${objective}` : objective,
2220
2292
  // Reset autoContinue counter — plan changed, agent gets a fresh chain.
2221
2293
  resetGetGoalNudgeState(state.goal.id);
2222
2294
  // Mark turn-stopped so subsequent in-turn tool calls are blocked.
2223
- turnStoppedFor = state.goal.id;
2295
+ turnStoppedFor = { goalId: state.goal.id, turnSeq };
2224
2296
  syncGoalTools();
2225
2297
  updateUI(ctx);
2226
2298
  ctx.ui.notify(`Goal tweaked: ${truncateText(changeSummary, 160)}`, "info");
@@ -2235,6 +2307,20 @@ ${objective}` : objective,
2235
2307
  } catch {
2236
2308
  // Ledger append failure should not crash tweak
2237
2309
  }
2310
+ // Append ledger event for task list change if tasks were provided
2311
+ if (params.tasks && Array.isArray(params.tasks) && params.tasks.length > 0) {
2312
+ try {
2313
+ appendGoalEvent(ctx, {
2314
+ type: "task_list_set",
2315
+ goalId: state.goal.id,
2316
+ taskCount: params.tasks.length,
2317
+ blockCompletion: false,
2318
+ at: state.goal.updatedAt,
2319
+ });
2320
+ } catch {
2321
+ // Ledger append failure should not crash tweak
2322
+ }
2323
+ }
2238
2324
  return {
2239
2325
  content: [{
2240
2326
  type: "text",
@@ -2379,7 +2465,7 @@ ${objective}` : objective,
2379
2465
  updatedAt: nowIso(),
2380
2466
  };
2381
2467
  state.goal = writeActiveGoalFile(ctx, state.goal);
2382
- turnStoppedFor = state.goal?.id ?? null;
2468
+ turnStoppedFor = state.goal ? { goalId: state.goal.id, turnSeq } : null;
2383
2469
  resetGetGoalNudgeState(state.goal?.id);
2384
2470
  syncGoalTools();
2385
2471
  updateUI(ctx);
@@ -2445,7 +2531,7 @@ ${objective}` : objective,
2445
2531
  updatedAt: nowIso(),
2446
2532
  };
2447
2533
  state.goal = writeActiveGoalFile(ctx, state.goal);
2448
- turnStoppedFor = state.goal?.id ?? null;
2534
+ turnStoppedFor = state.goal ? { goalId: state.goal.id, turnSeq } : null;
2449
2535
  resetGetGoalNudgeState(state.goal?.id);
2450
2536
  syncGoalTools();
2451
2537
  updateUI(ctx);
@@ -2574,7 +2660,7 @@ ${objective}` : objective,
2574
2660
  updatedAt: nowIso(),
2575
2661
  };
2576
2662
  state.goal = writeActiveGoalFile(ctx, state.goal);
2577
- turnStoppedFor = state.goal?.id ?? null;
2663
+ turnStoppedFor = state.goal ? { goalId: state.goal.id, turnSeq } : null;
2578
2664
  resetGetGoalNudgeState(state.goal?.id);
2579
2665
  syncGoalTools();
2580
2666
  updateUI(ctx);
@@ -2592,7 +2678,7 @@ ${objective}` : objective,
2592
2678
  } else {
2593
2679
  // ── Continue working → pause the goal ──────────────
2594
2680
  pauseActiveGoal(ctx);
2595
- turnStoppedFor = state.goal?.id ?? null;
2681
+ turnStoppedFor = state.goal ? { goalId: state.goal.id, turnSeq } : null;
2596
2682
  return {
2597
2683
  content: [{ type: "text", text: "Goal paused — user chose to continue working after skipping audit." }],
2598
2684
  details: state.goal ? goalDetails(state.goal) : undefined,
@@ -2674,7 +2760,7 @@ ${objective}` : objective,
2674
2760
  updatedAt: nowIso(),
2675
2761
  };
2676
2762
  state.goal = writeActiveGoalFile(ctx, state.goal);
2677
- turnStoppedFor = state.goal?.id ?? null;
2763
+ turnStoppedFor = state.goal ? { goalId: state.goal.id, turnSeq } : null;
2678
2764
  resetGetGoalNudgeState(state.goal?.id);
2679
2765
  syncGoalTools();
2680
2766
  updateUI(ctx);
@@ -2741,7 +2827,7 @@ ${objective}` : objective,
2741
2827
  resetGetGoalNudgeState(next.id);
2742
2828
  // C9 fix: mark turn-stopped so subsequent in-turn tool calls are blocked.
2743
2829
  // This is the schema-level closure of "agent kept writing files after pause_goal".
2744
- turnStoppedFor = state.goal.id;
2830
+ turnStoppedFor = { goalId: state.goal.id, turnSeq };
2745
2831
 
2746
2832
  const suggestionLine = suggested ? `\nSuggested: ${truncateText(suggested, 160)}` : "";
2747
2833
  ctx.ui.notify(
@@ -2801,7 +2887,7 @@ ${objective}` : objective,
2801
2887
  const archived = archiveCurrentGoal(ctx, "agent");
2802
2888
  resetGetGoalNudgeState(abortedGoalId);
2803
2889
  setGoal(null, ctx, true, "aborted");
2804
- turnStoppedFor = abortedGoalId;
2890
+ turnStoppedFor = { goalId: abortedGoalId, turnSeq };
2805
2891
 
2806
2892
  const archiveLine = archived?.archivedPath ? `\nArchive: ${archived.archivedPath}` : "";
2807
2893
  ctx.ui.notify(
@@ -2990,7 +3076,7 @@ ${objective}` : objective,
2990
3076
  }
2991
3077
  state.goal = { ...state.goal, taskList, updatedAt: now };
2992
3078
  setGoal(state.goal, ctx);
2993
- turnStoppedFor = state.goal.id;
3079
+ turnStoppedFor = { goalId: state.goal.id, turnSeq };
2994
3080
  resetGetGoalNudgeState(state.goal.id);
2995
3081
  syncGoalTools();
2996
3082
  updateUI(ctx);
@@ -3267,8 +3353,8 @@ promptGuidelines: [
3267
3353
 
3268
3354
  pi.on("turn_start", async (_event, ctx) => {
3269
3355
  // Per-turn flag resets (#4 + C9 fix).
3356
+ advanceTurnSeq();
3270
3357
  goalWorkToolCalledThisTurn = false;
3271
- turnStoppedFor = null;
3272
3358
  syncGoalTools();
3273
3359
  beginAccounting();
3274
3360
  updateUI(ctx);
@@ -3276,17 +3362,29 @@ promptGuidelines: [
3276
3362
 
3277
3363
  // #4 + C9 fix + Phase 5 C3: gate in-turn tool calls based on lifecycle state.
3278
3364
  pi.on("tool_call", async (event, ctx) => {
3365
+ const stoppedGoalId = currentTurnStoppedGoalId();
3279
3366
  // Post-stop in-turn block (C9 0ad8 fix): after pause_goal / abort_goal /
3280
3367
  // complete_goal / propose_goal_tweak fires in this turn, block all subsequent tool calls except
3281
3368
  // read-only inspection. Forces the agent to yield the turn instead of "fixing"
3282
3369
  // the situation by creating extra files etc.
3283
- if (turnStoppedFor !== null && !POST_STOP_ALLOWED_TOOL_SET.has(event.toolName)) {
3370
+ if (stoppedGoalId !== null && !POST_STOP_ALLOWED_TOOL_SET.has(event.toolName)) {
3284
3371
  return {
3285
3372
  block: true,
3286
- reason: `The goal was already stopped earlier in this turn (goalId=${turnStoppedFor}). ` +
3373
+ reason: `The goal was already stopped earlier in this turn (goalId=${stoppedGoalId}). ` +
3287
3374
  `Do not call more tools; end the turn with a brief summary and yield to the user.`,
3288
3375
  };
3289
3376
  }
3377
+ // Stale checkpoint guard: if the turn was triggered by a queued continuation
3378
+ // for a goal that is no longer active/autoContinue, block work tools.
3379
+ if (checkpointGoalId !== null && !isActionableContinuationGoal(checkpointGoalId) && isStaleCheckpointBlockedToolCall(event.toolName)) {
3380
+ // Block the tool call with a stale-checkpoint message.
3381
+ return {
3382
+ block: true,
3383
+ reason: `Cannot call ${event.toolName}: the goal checkpoint that triggered this turn is no longer active. ` +
3384
+ `Goal ${checkpointGoalId} has been paused, cleared, or replaced. ` +
3385
+ `End the turn with a brief summary and yield to the user.`,
3386
+ };
3387
+ }
3290
3388
  // Phase 5 soft gate relaxation: active-goal question block and repeated get_goal
3291
3389
  // block are removed. The agent is trusted to prefer work tools; prompts nudge
3292
3390
  // toward concrete work without hard-stopping the turn.
@@ -3369,6 +3467,7 @@ promptGuidelines: [
3369
3467
  pi.on("session_start", async (event, ctx) => {
3370
3468
  syncGoalTools();
3371
3469
  loadState(ctx);
3470
+ syncGoalTools();
3372
3471
  syncTerminalInputPause(ctx);
3373
3472
  if (event.reason === "resume" && !state.goal && openGoals().length > 1 && ctx.hasUI) {
3374
3473
  await focusGoalCommand(ctx);
@@ -3409,11 +3508,13 @@ promptGuidelines: [
3409
3508
  });
3410
3509
 
3411
3510
  pi.on("before_agent_start", async (event, ctx) => {
3511
+ advanceTurnSeq();
3412
3512
  syncGoalTools();
3413
3513
  const currentSystemPrompt = () => ctx.getSystemPrompt?.() || event.systemPrompt;
3414
3514
  const incomingGoalId = extractGoalIdFromInjectedMessage(event.prompt ?? "");
3415
3515
 
3416
3516
  if (confirmationIntent !== null) {
3517
+ checkpointGoalId = null;
3417
3518
  clearContinuationState();
3418
3519
  clearActiveAccounting();
3419
3520
  runningGoalId = null;
@@ -3421,6 +3522,7 @@ promptGuidelines: [
3421
3522
  }
3422
3523
 
3423
3524
  if (tweakDraftingFor !== null) {
3525
+ checkpointGoalId = null;
3424
3526
  clearContinuationState();
3425
3527
  clearActiveAccounting();
3426
3528
  runningGoalId = null;
@@ -3431,8 +3533,12 @@ promptGuidelines: [
3431
3533
  // matches the active goal, abort the whole turn instead of letting the
3432
3534
  // model act on a stale instruction.
3433
3535
  if (incomingGoalId !== null) {
3536
+ // Reconcile from disk to pick up any external state changes before
3537
+ // evaluating whether the checkpoint is actionable.
3538
+ reconcileFocusedGoalFromDisk(ctx);
3539
+ checkpointGoalId = incomingGoalId;
3434
3540
  clearContinuationState();
3435
- if (!state.goal || state.goal.id !== incomingGoalId || (state.goal.status !== "active") || !state.goal.autoContinue) {
3541
+ if (!isActionableContinuationGoal(incomingGoalId)) {
3436
3542
  try {
3437
3543
  ctx.abort?.();
3438
3544
  } catch {}
@@ -3441,10 +3547,12 @@ promptGuidelines: [
3441
3547
  systemPrompt: `${currentSystemPrompt()}\n\n${staleContinuationPrompt(incomingGoalId, state.goal)}`,
3442
3548
  };
3443
3549
  }
3550
+ checkpointGoalId = null;
3444
3551
  } else {
3445
3552
  // A user-driven turn — clear any queued continuation so we don't
3446
3553
  // double-fire after the user's own message returns. Also reset the
3447
3554
  // autoContinue nudge state so the user always gets a fresh chain.
3555
+ checkpointGoalId = null;
3448
3556
  clearContinuationState();
3449
3557
  resetGetGoalNudgeState(state.goal?.id);
3450
3558
  }
@@ -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.20.1",
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",