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 +2 -1
- package/docs/agent-flow-design.md +3 -0
- package/docs/architecture.md +5 -3
- package/extensions/goal-draft.ts +55 -51
- package/extensions/goal-record.ts +2 -2
- package/extensions/goal.ts +258 -56
- package/extensions/prompts/goal-prompts.ts +11 -0
- package/package.json +1 -1
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
|
|
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。 |
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
102
|
-
5.
|
|
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.
|
package/extensions/goal-draft.ts
CHANGED
|
@@ -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, "<").replace(/>/g, ">"));
|
|
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: ${
|
|
131
|
+
lines.push(`│ Mode: ${formatModeLabel(args.focus === "sisyphus")}`);
|
|
92
132
|
lines.push(`│ Auto-continue: ${args.autoContinue ? "yes" : "no"}`);
|
|
93
|
-
lines.push("");
|
|
94
|
-
lines.push("
|
|
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: ${
|
|
126
|
-
lines.push("");
|
|
127
|
-
lines.push("
|
|
128
|
-
lines.push("");
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 };
|
package/extensions/goal.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
431
|
+
assignFocusedGoalId(next.id);
|
|
401
432
|
return;
|
|
402
433
|
}
|
|
403
434
|
if (focusedGoalId) goalsById.delete(focusedGoalId);
|
|
404
|
-
|
|
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
|
|
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
|
-
|
|
635
|
+
assignFocusedGoalId(current.id);
|
|
576
636
|
return true;
|
|
577
637
|
}
|
|
578
638
|
goalsById = fresh;
|
|
579
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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(
|
|
1343
|
+
content: continuationPrompt(goal, settings),
|
|
1273
1344
|
display: false,
|
|
1274
1345
|
details: {
|
|
1275
1346
|
kind: "checkpoint",
|
|
1276
|
-
goalId:
|
|
1277
|
-
status:
|
|
1278
|
-
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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=${
|
|
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
|
-
|
|
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 (!
|
|
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.
|
|
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",
|