pi-goal-x 0.11.0 → 0.12.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
@@ -10,6 +10,13 @@ The extension is designed around one rule: **the user owns intent; the agent exe
10
10
 
11
11
  All core features of [@capyup/pi-goal](https://github.com/capyup/pi-goal) are preserved. The following changes are specific to pi-goal-x:
12
12
 
13
+ ### Task list system
14
+
15
+ - **Structured task breakdown** — the agent can propose a task list via `propose_task_list`, which shows the user a Confirm / Continue Chatting dialog (mirrors the `propose_goal_draft` pattern). Once confirmed, tasks are displayed in prompts, the widget, serialized to disk, and included in auditor review.
16
+ - **Per-task completion** — `complete_task` marks individual tasks done with optional evidence, and `skip_task` marks tasks as skipped with a required reason. Neither stops the turn, so the agent can continue uninterrupted.
17
+ - **Optional `taskList`** — goals without a task list work exactly as before. The feature is entirely opt-in.
18
+ - **Soft `complete_goal` gate** — when `blockCompletion: true` is set, `complete_goal` surfaces a warning if pending tasks remain (prompt-level only; the agent can still complete).
19
+
13
20
  ### Goal objective is immutable
14
21
 
15
22
  - The goal objective is immutable — the agent **must not** modify it autonomously. Objective changes are only possible through `propose_goal_tweak`, which presents the user with a Confirm / Continue Chatting dialog matching the `propose_goal_draft` confirmation pattern. This prevents the agent from silently changing the goal contract.
@@ -23,7 +30,7 @@ All core features of [@capyup/pi-goal](https://github.com/capyup/pi-goal) are pr
23
30
  ### E2e test infrastructure
24
31
 
25
32
  - **Deterministic fork tests using `--mode json`**: the e2e suite spawns a real `pi --fork --mode json` session, parses structured `tool_execution_start`/`tool_execution_end` JSON events for field-level assertions — no free-text AI output parsing. Uses `--append-system-prompt` + `--tools` to force deterministic tool calls.
26
- - **Full coverage**: 143 tests total — function-level integration tests (12), mock-pi handler tests (4), file-validity checks (6), real `pi --fork --mode json` tests (3 scenarios: quick-sync, combined sync+complete, deferred archival), and propose_goal_tweak unit/integration/e2e tests (15).
33
+ - **Full coverage**: 193 tests total — function-level integration tests (12), mock-pi handler tests (4), file-validity checks (6), real `pi --fork --mode json` tests (3 scenarios), propose_goal_tweak unit/integration/e2e tests (15), and task list policy/round-trip/render tests (50+).
27
34
 
28
35
  ### Completion auditor
29
36
 
@@ -158,9 +165,12 @@ The extension exposes tools only when they make sense for the current lifecycle
158
165
  | `get_goal` | always | Read the focused goal state; mentions other open goals when present |
159
166
  | `propose_goal_draft` | drafting only (goal creation) | Submit a concrete draft for user confirmation |
160
167
  | `propose_goal_tweak` | tweak drafting only | Submit a revision to an existing goal (shows Confirm / Continue Chatting dialog) |
161
- | `update_goal` | focused active or paused goal | Mark the focused goal complete when all requirements are satisfied. When the auditor is disabled, supply `confirmBypassAuditor: true` after user confirmation to bypass the audit |
168
+ | `complete_goal` | focused active or paused goal | Mark the focused goal complete — only when every requirement is satisfied. When the auditor is disabled, supply `confirmBypassAuditor: true` after user confirmation to bypass the audit |
162
169
  | `pause_goal` | focused active goal | Pause the focused goal because of a real blocker |
163
170
  | `abort_goal` | focused active or paused goal | Abort/archive an obsolete, impossible, unsafe, or user-cancelled focused goal |
171
+ | `propose_task_list` | active or paused goal | Propose a structured task list for user confirmation (stops the turn) |
172
+ | `complete_task` | active or paused goal | Mark a task complete with optional evidence (does not stop turn) |
173
+ | `skip_task` | active or paused goal | Mark a task skipped with a required reason (does not stop turn) |
164
174
  | `propose_goal_tweak` | tweak drafting only | Submit a revision to the focused goal (shows Confirm / Continue Chatting dialog) |
165
175
  | `step_complete` | hidden / legacy | Compatibility no-op; Sisyphus no longer requires a step counter |
166
176
  | `create_goal` | hidden | Direct calls are rejected; normal creation goes through `propose_goal_draft` |
@@ -228,7 +238,7 @@ The shipped gates are intentionally small and mechanical.
228
238
  | Completion auditor gate | Archiving completion unless an independent pi auditor agent returns `<approved/>` |
229
239
  | Abort gate | Aborting missing, stale, completed, or reasonless goals |
230
240
  | Direct-create rejection | Hidden `create_goal` calls creating goals without the confirmation flow |
231
- | Post-stop block | Continuing to call tools after `pause_goal`, `abort_goal`, `update_goal`, or `propose_goal_tweak` stops the turn |
241
+ | Post-stop block | Continuing to call tools after `pause_goal`, `abort_goal`, `complete_goal`, or `propose_goal_tweak` stops the turn |
232
242
  | Empty-turn guard | Pure chat loops that would keep auto-continuing without meaningful goal work |
233
243
  | Abort pause | Active goals staying active after user abort / Ctrl-C |
234
244
  | Disk reconciliation | External pause/archive/delete/status changes being ignored or overwritten by stale memory |
@@ -28,7 +28,7 @@
28
28
  -> runtime 重新计算 prompt 与 tool surface
29
29
  -> 执行 agent 按 focused goal 工作
30
30
  -> tool call / turn event 更新 accounting 与 ledger
31
- -> 执行 agent 调用 update_goal 请求完成
31
+ -> 执行 agent 调用 complete_goal 请求完成
32
32
  -> 独立 auditor agent 检查完成声明
33
33
  -> 只有 auditor approval 才归档为 complete
34
34
  ```
@@ -43,7 +43,7 @@
43
43
  -> 用户确认
44
44
  -> 写入 active goal 文件并设置 focus
45
45
  -> agent 跨一个或多个 turn 执行工作
46
- -> agent 调用 update_goal(status="complete")
46
+ -> agent 调用 complete_goal(status="complete")
47
47
  -> 对话中出现 Goal audit started
48
48
  -> auditor session 检查真实产物
49
49
  -> 对话中出现 Goal audit approved
@@ -200,7 +200,7 @@ interface GoalConfirmationIntent {
200
200
  | `goal_question` / `goal_questionnaire` | goal confirmation / tweak drafting 中的结构化用户对话。 |
201
201
  | `propose_goal_draft` | 提交 goal 草案给用户确认;没有 confirmation intent 时会被 validator 拒绝。 |
202
202
  | `apply_goal_tweak` | 提交并应用 goal 修改。 |
203
- | `update_goal` | 请求完成目标,并触发独立审计。 |
203
+ | `complete_goal` | 请求完成目标,并触发独立审计。 |
204
204
  | `pause_goal` | agent 因真实 blocker 暂停目标。 |
205
205
  | `abort_goal` | agent 因目标废弃、不可行、不安全等原因中止目标。 |
206
206
  | `step_complete` | 隐藏的 legacy no-op;Sisyphus 不再使用 step counter。 |
@@ -267,7 +267,7 @@ completion 不信任执行 agent 单方声明,而是一个双 agent 协议。
267
267
  }
268
268
  ```
269
269
 
270
- `update_goal` 会先校验 focused goal 是否可以完成,然后写入 `completion_requested` ledger event。
270
+ `complete_goal` 会先校验 focused goal 是否可以完成,然后写入 `completion_requested` ledger event。
271
271
 
272
272
  ### 9.2 对话中出现 audit started
273
273
 
@@ -281,7 +281,7 @@ Auditor model: ...
281
281
  Completion claim: ...
282
282
  ```
283
283
 
284
- 这让 audit 成为 transcript 里一个明确的 agentic 阶段,而不是隐藏在 `update_goal` tool result 里。
284
+ 这让 audit 成为 transcript 里一个明确的 agentic 阶段,而不是隐藏在 `complete_goal` tool result 里。
285
285
 
286
286
  ### 9.3 独立 auditor session
287
287
 
@@ -343,7 +343,7 @@ Audit Report 或 rejection reason
343
343
 
344
344
  agent 可以在真实 blocker 下调用 `pause_goal`。用户也可以用 `/goal-pause` 或 abort active run 来暂停目标。
345
345
 
346
- `pause_goal`、`abort_goal`、`update_goal`、`apply_goal_tweak` 成功后,会设置 `turnStoppedFor`。之后同一个 turn 里,`tool_call` hook 会阻止额外的非允许工具调用。这个 hard gate 仍然保留:生命周期已经 stop 后,agent 应该总结并交还控制,而不是继续修改文件。
346
+ `pause_goal`、`abort_goal`、`complete_goal`、`apply_goal_tweak` 成功后,会设置 `turnStoppedFor`。之后同一个 turn 里,`tool_call` hook 会阻止额外的非允许工具调用。这个 hard gate 仍然保留:生命周期已经 stop 后,agent 应该总结并交还控制,而不是继续修改文件。
347
347
 
348
348
  pause 与 abort 的区别:
349
349
 
@@ -391,7 +391,7 @@ Execution runtime
391
391
  v
392
392
  Executor agent
393
393
  |-- 正常 read/write/bash/edit 工作
394
- |-- pause_goal / abort_goal / update_goal
394
+ |-- pause_goal / abort_goal / complete_goal
395
395
  v
396
396
  Completion request
397
397
  |-- 对话中出现 Goal audit started
@@ -131,7 +131,7 @@ The following behaviors remain runtime-enforced:
131
131
  4. **Mode consistency.** A draft proposal cannot silently change `/goals` into Sisyphus or `/sisyphus` into a regular goal.
132
132
  5. **Stale continuation protection.** A queued continuation for an old goal cannot perform work for a different current goal.
133
133
  6. **Human-owned focus.** The agent cannot silently switch focus between open goals.
134
- 7. **Completion audit.** `update_goal(status="complete")` archives only if the independent auditor returns exactly one approving marker.
134
+ 7. **Completion audit.** `complete_goal(status="complete")` archives only if the independent auditor returns exactly one approving marker.
135
135
  8. **Path safety.** Goal files and archives must remain under expected `.pi/goals` paths.
136
136
  9. **Post-stop transaction boundary.** After pause, abort, approved completion, or applied tweak, the same turn should not continue substantive work.
137
137
  10. **No hard cost control/cap lifecycle.** Resource-control is outside this runtime; auto-continue uses semantic stop conditions and the empty-turn guard.
@@ -211,7 +211,7 @@ The runtime keeps tools for irreversible transitions:
211
211
 
212
212
  - `propose_goal_draft`
213
213
  - `get_goal`
214
- - `update_goal`
214
+ - `complete_goal`
215
215
  - `pause_goal`
216
216
  - `abort_goal`
217
217
  - `apply_goal_tweak`
@@ -193,7 +193,7 @@ Continuation prompts include a goal id so stale prompts can be detected and neut
193
193
 
194
194
  ## Completion output
195
195
 
196
- Completion is intentionally verbose in the tool result and guarded by an independent auditor agent. `update_goal(status="complete")` is valid for active and paused goals; paused goals do not need to be resumed just to record completion when existing evidence is sufficient.
196
+ Completion is intentionally verbose in the tool result and guarded by an independent auditor agent. `complete_goal(status="complete")` is valid for active and paused goals; paused goals do not need to be resumed just to record completion when existing evidence is sufficient.
197
197
 
198
198
  Before archiving, the tool starts a separate in-memory pi session with a focused auditor prompt. The auditor receives the objective, executor completion summary, and goal metadata, can inspect the workspace with `read`, `grep`, `find`, `ls`, and `bash`, and must end with exactly one marker:
199
199
 
@@ -13,7 +13,7 @@ import {
13
13
  type ExtensionContext,
14
14
  type ResourceLoader,
15
15
  } from "@earendil-works/pi-coding-agent";
16
- import type { GoalRecord } from "./goal-record.ts";
16
+ import type { GoalRecord, GoalTaskList } from "./goal-record.ts";
17
17
 
18
18
  export interface GoalAuditorConfig {
19
19
  provider?: string;
@@ -138,6 +138,22 @@ export interface AuditorTestResults {
138
138
  timestamp?: string;
139
139
  }
140
140
 
141
+ function taskSummaryBlock(taskList?: GoalTaskList | null): string {
142
+ if (!taskList || taskList.tasks.length === 0) return "";
143
+ const total = taskList.tasks.length;
144
+ const complete = taskList.tasks.filter((t) => t.status === "complete").length;
145
+ const skipped = taskList.tasks.filter((t) => t.status === "skipped").length;
146
+ const pending = taskList.tasks.filter((t) => t.status === "pending");
147
+ const lines: string[] = [`Tasks: ${complete}/${total} complete${skipped > 0 ? `, ${skipped} skipped` : ""}`];
148
+ for (const task of taskList.tasks) {
149
+ const marker = task.status === "complete" ? "[x]" : task.status === "skipped" ? "[~]" : "[ ]";
150
+ lines.push(` ${marker} ${task.id}: ${task.title}`);
151
+ }
152
+ const gate = taskList.blockCompletion && pending.length > 0 ? " | TASK GATE: pending tasks block completion" : "";
153
+ lines[0] = lines[0]! + gate;
154
+ return lines.join("\n");
155
+ }
156
+
141
157
  export function buildGoalAuditorPrompt(args: {
142
158
  goal: GoalRecord;
143
159
  completionSummary?: string | null;
@@ -168,6 +184,7 @@ export function buildGoalAuditorPrompt(args: {
168
184
  "Current goal metadata:",
169
185
  "<goal_details>",
170
186
  args.detailedSummary,
187
+ ...(taskSummaryBlock(args.goal.taskList) ? ["", taskSummaryBlock(args.goal.taskList)] : []),
171
188
  "</goal_details>",
172
189
  ...(args.testResults ? [
173
190
  "",
@@ -46,6 +46,15 @@ export function buildGoalCompactSummary(goal: GoalRecord, events: GoalLedgerEven
46
46
  case "goal_completed":
47
47
  lines.push(" - completed");
48
48
  break;
49
+ case "task_list_set":
50
+ lines.push(` - task list set: ${event.taskCount} tasks${event.blockCompletion ? " (blocking)" : ""}`);
51
+ break;
52
+ case "task_complete":
53
+ lines.push(` - task complete: ${event.taskId}${event.evidence ? ` — ${truncateText(event.evidence, 60)}` : ""}`);
54
+ break;
55
+ case "task_skipped":
56
+ lines.push(` - task skipped: ${event.taskId} — ${truncateText(event.reason, 60)}`);
57
+ break;
49
58
  case "goal_aborted":
50
59
  lines.push(` - aborted: ${event.reason}`);
51
60
  break;
@@ -131,6 +131,7 @@ export function goalDraftingPrompt(topic: string, focus: GoalDraftingFocus): str
131
131
  "- If the topic is already concrete, you may proceed directly to propose_goal_draft.",
132
132
  "- The goal contract should make the objective, success criteria, boundaries, constraints, and blocker rule explicit.",
133
133
  "- Keep grilling assumptions until the objective, success criteria, boundaries, constraints, and blocker rule are clear enough to confirm.",
134
+ "- After a goal is confirmed, you may call propose_task_list on the first continuation turn if the objective naturally decomposes into trackable milestones. Do not add a task list for simple, single-step goals.",
134
135
  "- propose_goal_draft opens the user's Confirm / Continue Chatting dialog. Confirm creates and focuses the goal; Continue Chatting means keep refining through normal proposal cycles.",
135
136
  "- create_goal is not a shortcut. Direct create_goal calls are rejected so the user keeps explicit say in goal creation.",
136
137
  ];
@@ -16,7 +16,10 @@ export type GoalLedgerEvent =
16
16
  | { type: "audit_result"; goalId: string; verdict: "approved" | "disapproved" | "error"; report: string; at: string }
17
17
  | { type: "audit_skipped"; goalId: string; reason: "disabled" | "user_aborted"; provider?: string; model?: string; thinkingLevel?: string; at: string }
18
18
  | { type: "goal_completed"; goalId: string; archivePath?: string; at: string }
19
- | { type: "goal_aborted"; goalId: string; reason: string; archivePath?: string; at: string };
19
+ | { type: "goal_aborted"; goalId: string; reason: string; archivePath?: string; at: string }
20
+ | { type: "task_list_set"; goalId: string; taskCount: number; blockCompletion: boolean; at: string }
21
+ | { type: "task_complete"; goalId: string; taskId: string; evidence?: string; at: string }
22
+ | { type: "task_skipped"; goalId: string; taskId: string; reason: string; at: string };
20
23
 
21
24
  export interface GoalLedgerContext {
22
25
  cwd: string;
@@ -147,6 +150,12 @@ function isValidLedgerEvent(value: unknown): value is GoalLedgerEvent {
147
150
  return typeof obj.goalId === "string" && (obj.archivePath === undefined || typeof obj.archivePath === "string");
148
151
  case "goal_aborted":
149
152
  return typeof obj.goalId === "string" && typeof obj.reason === "string" && (obj.archivePath === undefined || typeof obj.archivePath === "string");
153
+ case "task_list_set":
154
+ return typeof obj.goalId === "string" && typeof obj.taskCount === "number" && typeof obj.blockCompletion === "boolean";
155
+ case "task_complete":
156
+ return typeof obj.goalId === "string" && typeof obj.taskId === "string" && (obj.evidence === undefined || typeof obj.evidence === "string");
157
+ case "task_skipped":
158
+ return typeof obj.goalId === "string" && typeof obj.taskId === "string" && typeof obj.reason === "string";
150
159
  default:
151
160
  return false;
152
161
  }
@@ -176,6 +185,12 @@ function sanitizeEvent(event: GoalLedgerEvent): GoalLedgerEvent {
176
185
  return { ...event, goalId: safeGoalId(event.goalId) };
177
186
  case "goal_aborted":
178
187
  return { ...event, goalId: safeGoalId(event.goalId) };
188
+ case "task_list_set":
189
+ return { ...event, goalId: safeGoalId(event.goalId) };
190
+ case "task_complete":
191
+ return { ...event, goalId: safeGoalId(event.goalId) };
192
+ case "task_skipped":
193
+ return { ...event, goalId: safeGoalId(event.goalId) };
179
194
  case "goal_unfocused":
180
195
  return event;
181
196
  }
@@ -1,4 +1,5 @@
1
1
  import { statusLabel, type GoalDisplayRecordLike } from "./goal-core.ts";
2
+ import type { GoalTaskList, TaskStatus } from "./goal-record.ts";
2
3
 
3
4
  export type GoalStatusLike = "active" | "paused" | "complete";
4
5
  export type StopReasonLike = "user" | "agent";
@@ -9,6 +10,7 @@ export interface GoalPolicyRecordLike extends GoalDisplayRecordLike {
9
10
  updatedAt?: string;
10
11
  pauseReason?: string;
11
12
  pauseSuggestedAction?: string;
13
+ taskList?: GoalTaskList;
12
14
  }
13
15
 
14
16
  export type PolicyValidation =
@@ -39,7 +41,7 @@ export function validateGoalCompletion(args: {
39
41
  const { goal, runningGoalId } = args;
40
42
  if (!goal) return { ok: false, message: "No goal is set." };
41
43
  if (runningGoalId && goal.id !== runningGoalId) return { ok: false, message: "The active goal changed during this run; not marking it complete." };
42
- if (!isCompletableStatus(goal.status)) return { ok: false, message: `Goal is ${statusLabel(goal)}; update_goal does not apply.` };
44
+ if (!isCompletableStatus(goal.status)) return { ok: false, message: `Goal is ${statusLabel(goal)}; complete_goal does not apply.` };
43
45
  return { ok: true };
44
46
  }
45
47
 
@@ -124,7 +126,68 @@ export function abortGoalCommandMessage(args: { archived: boolean; wasDrafting:
124
126
  return args.archived ? "Goal aborted and archived." : args.wasDrafting ? "Drafting cancelled." : "No goal is set.";
125
127
  }
126
128
 
127
- export function buildCompletionReport(args: { detailedSummary: string; completionSummary?: string | null; auditorReport?: string | null; auditSkippedReason?: string | null }): string {
129
+ export function buildTaskSummary(taskList: GoalTaskList): string {
130
+ const total = taskList.tasks.length;
131
+ const complete = taskList.tasks.filter((t) => t.status === "complete").length;
132
+ const skipped = taskList.tasks.filter((t) => t.status === "skipped").length;
133
+ if (total === 0) return "No tasks";
134
+ const parts: string[] = [`${complete}/${total} tasks complete`];
135
+ if (skipped > 0) parts.push(`(${skipped} skipped)`);
136
+ return parts.join(" ");
137
+ }
138
+
139
+ export function taskCompletionBlockWarning(taskList: GoalTaskList): string | null {
140
+ if (!taskList.blockCompletion) return null;
141
+ const pending = taskList.tasks.filter((t) => t.status === "pending");
142
+ if (pending.length === 0) return null;
143
+ return `${pending.length} task${pending.length > 1 ? "s" : ""} still pending with blockCompletion enabled. Complete or skip all pending tasks before finishing the goal.`;
144
+ }
145
+
146
+ export function validateTaskCompletion(args: {
147
+ goal: GoalPolicyRecordLike | null;
148
+ taskId: string;
149
+ }): PolicyValidation {
150
+ if (!args.goal) return { ok: false, message: "No goal is set." };
151
+ if (!args.goal.taskList) return { ok: false, message: "Goal has no task list." };
152
+ const task = args.goal.taskList.tasks.find((t) => t.id === args.taskId);
153
+ if (!task) return { ok: false, message: `Task "${args.taskId}" not found.` };
154
+ if (task.status === "complete") return { ok: false, message: `Task "${args.taskId}" is already complete.` };
155
+ if (task.status === "skipped") return { ok: false, message: `Task "${args.taskId}" was already skipped.` };
156
+ return { ok: true };
157
+ }
158
+
159
+ export function validateTaskSkip(args: {
160
+ goal: GoalPolicyRecordLike | null;
161
+ taskId: string;
162
+ reason: string;
163
+ }): PolicyValidation {
164
+ if (!args.goal) return { ok: false, message: "No goal is set." };
165
+ if (!args.goal.taskList) return { ok: false, message: "Goal has no task list." };
166
+ const task = args.goal.taskList.tasks.find((t) => t.id === args.taskId);
167
+ if (!task) return { ok: false, message: `Task "${args.taskId}" not found.` };
168
+ if (task.status === "complete") return { ok: false, message: `Task "${args.taskId}" is already complete.` };
169
+ if (task.status === "skipped") return { ok: false, message: `Task "${args.taskId}" was already skipped.` };
170
+ if (!args.reason.trim()) return { ok: false, message: "skip_task requires a non-empty reason." };
171
+ return { ok: true };
172
+ }
173
+
174
+ export function validateTaskListProposal(args: {
175
+ goal: GoalPolicyRecordLike | null;
176
+ tasks: { id: string; title: string }[];
177
+ }): PolicyValidation {
178
+ if (!args.goal) return { ok: false, message: "No goal is set." };
179
+ if (args.tasks.length > 50) return { ok: false, message: "Task list cannot exceed 50 tasks." };
180
+ const ids = new Set<string>();
181
+ for (const t of args.tasks) {
182
+ if (!t.id.trim()) return { ok: false, message: "All tasks must have a non-empty id." };
183
+ if (!t.title.trim()) return { ok: false, message: `Task "${t.id}" must have a non-empty title.` };
184
+ if (ids.has(t.id)) return { ok: false, message: `Duplicate task id: "${t.id}".` };
185
+ ids.add(t.id);
186
+ }
187
+ return { ok: true };
188
+ }
189
+
190
+ export function buildCompletionReport(args: { detailedSummary: string; completionSummary?: string | null; auditorReport?: string | null; auditSkippedReason?: string | null; taskSummary?: string | null }): string {
128
191
  const auditSkipped = args.auditSkippedReason?.trim();
129
192
  const auditorReport = args.auditorReport?.trim();
130
193
  const lines = auditSkipped
@@ -136,6 +199,10 @@ export function buildCompletionReport(args: { detailedSummary: string; completio
136
199
  if (summary) {
137
200
  lines.push("", "Completion summary:", summary);
138
201
  }
202
+ const taskSummary = args.taskSummary?.trim();
203
+ if (taskSummary) {
204
+ lines.push("", `Task summary: ${taskSummary}`);
205
+ }
139
206
  lines.push("", args.detailedSummary);
140
207
  return lines.join("\n");
141
208
  }
@@ -318,7 +318,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
318
318
  const selected = i === optionIndex;
319
319
  const prefix = selected ? theme.fg("accent", "> ") : " ";
320
320
  const recTag = !opt.isCustom && q?.recommended === i ? theme.fg("success", " ★") : "";
321
- add(prefix + theme.fg(selected ? "accent" : "text", `${i + 1}. ${opt.label}`) + recTag);
321
+ addWrapped(prefix + theme.fg(selected ? "accent" : "text", `${i + 1}. ${opt.label}`) + recTag);
322
322
  }
323
323
  }
324
324
 
@@ -4,6 +4,24 @@ export type GoalEventKind = "checkpoint" | "stale" | "drafting";
4
4
  export type DraftingFocus = "goal" | "sisyphus";
5
5
  export type GoalFocusReason = "created" | "selected" | "resumed" | "completed" | "cleared" | "aborted" | "migrated";
6
6
 
7
+ export type TaskStatus = "pending" | "complete" | "skipped";
8
+
9
+ export interface GoalTask {
10
+ id: string;
11
+ title: string;
12
+ status: TaskStatus;
13
+ completedAt?: string;
14
+ skippedAt?: string;
15
+ evidence?: string;
16
+ skipReason?: string;
17
+ }
18
+
19
+ export interface GoalTaskList {
20
+ tasks: GoalTask[];
21
+ blockCompletion: boolean;
22
+ proposedAt: string;
23
+ }
24
+
7
25
  export interface GoalUsage {
8
26
  tokensUsed: number;
9
27
  activeSeconds: number;
@@ -24,6 +42,7 @@ export interface GoalRecord {
24
42
  // Set by the agent's pause_goal tool. Cleared when the goal becomes active again.
25
43
  pauseReason?: string;
26
44
  pauseSuggestedAction?: string;
45
+ taskList?: GoalTaskList;
27
46
  }
28
47
 
29
48
  export interface GoalStateEntry {
@@ -90,7 +109,13 @@ export function emptyUsage(): GoalUsage {
90
109
  }
91
110
 
92
111
  export function cloneGoal(goal: GoalRecord): GoalRecord {
93
- return { ...goal, usage: { ...goal.usage } };
112
+ return {
113
+ ...goal,
114
+ usage: { ...goal.usage },
115
+ taskList: goal.taskList
116
+ ? { ...goal.taskList, tasks: goal.taskList.tasks.map(t => ({ ...t })) }
117
+ : undefined,
118
+ };
94
119
  }
95
120
 
96
121
  export function goalFocusDetails(focusedGoalId: string | null, reason: GoalFocusReason): GoalFocusEntry {
@@ -136,6 +161,37 @@ export function normalizeUsage(value: unknown): GoalUsage {
136
161
  return { tokensUsed, activeSeconds };
137
162
  }
138
163
 
164
+ export function normalizeTaskList(value: unknown): GoalTaskList | undefined {
165
+ const raw = asRecord(value);
166
+ if (!raw) return undefined;
167
+ const tasksRaw = raw.tasks;
168
+ if (!Array.isArray(tasksRaw)) return undefined;
169
+ const tasks: GoalTask[] = [];
170
+ for (const item of tasksRaw) {
171
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
172
+ const t = item as Record<string, unknown>;
173
+ const id = typeof t.id === "string" && t.id.trim() ? t.id.trim() : "";
174
+ const title = typeof t.title === "string" ? t.title.trim() : "";
175
+ if (!id || !title) continue;
176
+ const status: TaskStatus = t.status === "complete" ? "complete" : t.status === "skipped" ? "skipped" : "pending";
177
+ tasks.push({
178
+ id,
179
+ title,
180
+ status,
181
+ completedAt: typeof t.completedAt === "string" ? t.completedAt : undefined,
182
+ skippedAt: typeof t.skippedAt === "string" ? t.skippedAt : undefined,
183
+ evidence: typeof t.evidence === "string" ? t.evidence : undefined,
184
+ skipReason: typeof t.skipReason === "string" ? t.skipReason : undefined,
185
+ });
186
+ }
187
+ if (tasks.length === 0) return undefined;
188
+ return {
189
+ tasks,
190
+ blockCompletion: raw.blockCompletion === true,
191
+ proposedAt: typeof raw.proposedAt === "string" ? raw.proposedAt : nowIso(),
192
+ };
193
+ }
194
+
139
195
  export function normalizeGoalRecord(value: unknown): GoalRecord | null {
140
196
  const raw = asRecord(value);
141
197
  if (!raw) return null;
@@ -167,5 +223,6 @@ export function normalizeGoalRecord(value: unknown): GoalRecord | null {
167
223
  stopReason: raw.stopReason === "agent" || raw.stopReason === "user" ? raw.stopReason : undefined,
168
224
  pauseReason: typeof raw.pauseReason === "string" && raw.pauseReason.trim() ? raw.pauseReason : undefined,
169
225
  pauseSuggestedAction: typeof raw.pauseSuggestedAction === "string" && raw.pauseSuggestedAction.trim() ? raw.pauseSuggestedAction : undefined,
226
+ taskList: normalizeTaskList(raw.taskList),
170
227
  };
171
228
  }
@@ -5,16 +5,22 @@ export const CREATE_GOAL_TOOL_NAME = "create_goal";
5
5
  export const QUESTION_TOOL_NAME = "goal_question";
6
6
  export const QUESTIONNAIRE_TOOL_NAME = "goal_questionnaire";
7
7
  export const ABORT_GOAL_TOOL_NAME = "abort_goal";
8
+ export const PROPOSE_TASK_LIST_TOOL_NAME = "propose_task_list";
9
+ export const COMPLETE_TASK_TOOL_NAME = "complete_task";
10
+ export const SKIP_TASK_TOOL_NAME = "skip_task";
8
11
 
9
- export const ACTIVE_GOAL_TOOL_NAMES = ["get_goal", "update_goal", "pause_goal", ABORT_GOAL_TOOL_NAME] as const;
10
- export const PAUSED_GOAL_TOOL_NAMES = ["get_goal", "update_goal", ABORT_GOAL_TOOL_NAME] as const;
12
+ export const ACTIVE_GOAL_TOOL_NAMES = ["get_goal", "complete_goal", "pause_goal", ABORT_GOAL_TOOL_NAME, PROPOSE_TWEAK_TOOL_NAME, PROPOSE_TASK_LIST_TOOL_NAME, COMPLETE_TASK_TOOL_NAME, SKIP_TASK_TOOL_NAME] as const;
13
+ export const PAUSED_GOAL_TOOL_NAMES = ["get_goal", "complete_goal", ABORT_GOAL_TOOL_NAME, PROPOSE_TWEAK_TOOL_NAME, PROPOSE_TASK_LIST_TOOL_NAME] as const;
11
14
  export const NO_FOCUSED_GOAL_TOOL_NAMES = ["get_goal"] as const;
12
15
 
13
16
  export const GOAL_WORK_TOOL_NAMES = [
14
- "update_goal",
17
+ "complete_goal",
15
18
  "pause_goal",
16
19
  ABORT_GOAL_TOOL_NAME,
17
20
  PROPOSE_TWEAK_TOOL_NAME,
21
+ PROPOSE_TASK_LIST_TOOL_NAME,
22
+ COMPLETE_TASK_TOOL_NAME,
23
+ SKIP_TASK_TOOL_NAME,
18
24
  CREATE_GOAL_TOOL_NAME,
19
25
  PROPOSE_DRAFT_TOOL_NAME,
20
26
  QUESTION_TOOL_NAME,
@@ -30,9 +36,11 @@ export const GOAL_WORK_TOOL_NAMES = [
30
36
  ] as const;
31
37
 
32
38
  export const GOAL_PROGRESS_TOOL_NAMES = [
33
- "update_goal",
39
+ "complete_goal",
34
40
  "pause_goal",
35
41
  ABORT_GOAL_TOOL_NAME,
42
+ COMPLETE_TASK_TOOL_NAME,
43
+ SKIP_TASK_TOOL_NAME,
36
44
  "write",
37
45
  "edit",
38
46
  "bash",