pi-goal-x 0.13.0 → 0.14.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
@@ -19,10 +19,19 @@ All core features of [@capyup/pi-goal](https://github.com/capyup/pi-goal) are pr
19
19
  - **Auditor integration** — the independent completion auditor receives both the `verificationContract` and `verificationSummary` and cross-checks claims against real artifacts.
20
20
  - **`complete_goal` `testResults` removed** — replaced with `verificationSummary`. The old structured test results interface is gone.
21
21
 
22
- ### Task list system
22
+ ### Unified goal + task acceptance
23
23
 
24
- - **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.
25
- - **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.
24
+ - **Single-dialog confirmation** — `propose_goal_draft` now accepts an optional `tasks` array parameter. The confirmation dialog shows the goal objective AND the proposed task list together in a single rich TUI view with box-drawing panel (`┌─ TASKS ───┐`), section headers, and hierarchical indentation for subtasks.
25
+ - **Atomic creation** — one confirmation (single enter press) creates the goal AND its task list together. No need for separate `propose_goal_draft` + `propose_task_list` calls.
26
+ - **Backward compatible** — existing separate `propose_task_list` flow continues to work unchanged. Goals without tasks work as before.
27
+
28
+ ### Task list & sub-task system
29
+
30
+ - **Structured task breakdown** — the agent can propose a task list via `propose_task_list` (standalone) or `propose_goal_draft` with `tasks` (unified). Both show a Confirm / Continue Chatting dialog. Once confirmed, tasks are displayed in prompts, the widget, serialized to disk, and included in auditor review.
31
+ - **Recursive subtasks** — tasks can have nested sub-tasks via `subtasks?: GoalTask[]` (full recursive type). Subtask depth is controlled globally by `subtaskDepth` in `.pi/goal-settings.json` (default: 1 level). Too-deep subtrees are rejected at proposal.
32
+ - **Lightweight subtasks** — each task has an optional `lightweightSubtasks?: boolean` flag. When true, the parent can complete regardless of subtask status. When false/absent (full subtasks), all subtasks must be individually complete before the parent can close.
33
+ - **Per-task completion** — `complete_task` marks individual tasks done with optional evidence/verificationSummary, and `skip_task` marks tasks as skipped with a required reason. Neither stops the turn, so the agent can continue uninterrupted.
34
+ - **Hierarchical display** — task lists with subtasks render with indentation in prompts (`taskListBlock`, `goalPrompt`, `continuationPrompt`) and in the TUI widget (recursive count, BFS next-pending).
26
35
  - **Optional `taskList`** — goals without a task list work exactly as before. The feature is entirely opt-in.
27
36
  - **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).
28
37
 
@@ -39,7 +48,7 @@ All core features of [@capyup/pi-goal](https://github.com/capyup/pi-goal) are pr
39
48
  ### E2e test infrastructure
40
49
 
41
50
  - **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.
42
- - **Full coverage**: 205 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), task list policy/round-trip/render tests (50+), and verification contract tests (14).
51
+ - **Full coverage**: 281 tests total — function-level integration tests, mock-pi handler tests, file-validity checks, real `pi --fork --mode json` E2E tests, propose_goal_tweak unit/integration/e2e tests, task list policy/round-trip/render tests (including subtasks), and verification contract tests.
43
52
 
44
53
  ### Completion auditor
45
54
 
@@ -214,17 +223,7 @@ Before archiving the goal, `update_goal` starts a separate pi agent in an isolat
214
223
 
215
224
  The auditor is semantic, not a paperwork checklist: it should reject scaffold-only, alpha, generated-template, proxy-metric, build-only, or weakly verified completions when the real user outcome is not satisfied.
216
225
 
217
- By default the auditor uses the current/default pi model. Configure it interactively with `/goal-settings` -> `auditor`, then click `provider`, `model`, or `thinking_level` and type the value directly. The settings are saved to `.pi/goal-auditor.json`. You can also edit the file or override it with environment variables:
218
-
219
- ```json
220
- {
221
- "provider": "fireworks",
222
- "model": "accounts/fireworks/routers/kimi-k2p6-turbo",
223
- "thinking_level": "high"
224
- }
225
- ```
226
-
227
- Environment variables `PI_GOAL_AUDITOR_PROVIDER`, `PI_GOAL_AUDITOR_MODEL`, and `PI_GOAL_AUDITOR_THINKING_LEVEL` take precedence over `/goal-settings`.
226
+ By default the auditor uses the current/default pi model. Configure it via `.pi/goal-auditor.json`, interactively with `/goal-settings` `auditor`, or environment variables (see [Settings files](#settings-files)).
228
227
 
229
228
  The completion result prints a full report into the conversation:
230
229
 
@@ -272,6 +271,52 @@ Before commands, tools, and lifecycle hooks act on a focused goal, the runtime r
272
271
 
273
272
  Goal paths are constrained to `.pi/goals/` and `.pi/goals/archived/`; absolute paths, traversal, NUL bytes, symlinks, and unsafe metadata paths are rejected.
274
273
 
274
+ ## Settings files
275
+
276
+ Configuration is split across two files under `.pi/`.
277
+
278
+ ### `.pi/goal-settings.json`
279
+
280
+ Configured interactively via `/goal-settings`, or edited directly:
281
+
282
+ ```json
283
+ {
284
+ "disableTasks": false,
285
+ "disableContracts": false,
286
+ "subtaskDepth": 1
287
+ }
288
+ ```
289
+
290
+ | Field | Default | Purpose |
291
+ |---|---:|---|
292
+ | `disableTasks` | `false` | Suppress task list features entirely when `true` |
293
+ | `disableContracts` | `false` | Suppress verification contract enforcement when `true` |
294
+ | `subtaskDepth` | `1` | Maximum nesting depth for subtasks (`1` = tasks → subtasks, `2` = tasks → subtasks → sub-subtasks) |
295
+
296
+ **Env var overrides:** `PI_GOAL_DISABLE_TASKS=1` and `PI_GOAL_DISABLE_CONTRACTS=1` take precedence over the file. Set to any truthy string to disable.
297
+
298
+ ### `.pi/goal-auditor.json`
299
+
300
+ Configured interactively via `/goal-settings` → `auditor`, or edited directly:
301
+
302
+ ```json
303
+ {
304
+ "provider": "fireworks",
305
+ "model": "accounts/fireworks/models/deepseek-v4-flash",
306
+ "thinkingLevel": "high",
307
+ "disabled": false
308
+ }
309
+ ```
310
+
311
+ | Field | Default | Purpose |
312
+ |---|---:|---|
313
+ | `provider` | system default | Provider name for the auditor agent (`anthropic`, `fireworks`, `google`, `groq`, etc.) |
314
+ | `model` | system default | Model name for the auditor agent |
315
+ | `thinkingLevel` | system default | Thinking level: `none`, `low`, `medium`, `high` |
316
+ | `disabled` | `false` | When `true`, skip the completion audit entirely |
317
+
318
+ **Env var overrides:** `PI_GOAL_AUDITOR_PROVIDER`, `PI_GOAL_AUDITOR_MODEL`, and `PI_GOAL_AUDITOR_THINKING_LEVEL` take precedence over file config. `PI_GOAL_AUDITOR_THINKING` is also accepted as an alias for the thinking level.
319
+
275
320
  ## Environment variables
276
321
 
277
322
  | Variable | Default | Purpose |
@@ -13,7 +13,8 @@ import {
13
13
  type ExtensionContext,
14
14
  type ResourceLoader,
15
15
  } from "@earendil-works/pi-coding-agent";
16
- import type { GoalRecord, GoalTaskList } from "./goal-record.ts";
16
+ import type { GoalRecord, GoalTask, GoalTaskList } from "./goal-record.ts";
17
+ import type { GoalSettings } from "./goal-settings.ts";
17
18
 
18
19
  export interface GoalAuditorConfig {
19
20
  provider?: string;
@@ -134,18 +135,43 @@ export interface AuditorVerificationEvidence {
134
135
  contract?: string;
135
136
  }
136
137
 
138
+ function renderAuditorTaskTree(tasks: GoalTask[], indent: number): string[] {
139
+ const prefix = " ".repeat(indent);
140
+ const lines: string[] = [];
141
+ for (const task of tasks) {
142
+ const marker = task.status === "complete" ? "[x]" : task.status === "skipped" ? "[~]" : "[ ]";
143
+ lines.push(`${prefix}${marker} ${task.id}: ${task.title}`);
144
+ if (task.subtasks && task.subtasks.length > 0) {
145
+ lines.push(...renderAuditorTaskTree(task.subtasks, indent + 1));
146
+ }
147
+ }
148
+ return lines;
149
+ }
150
+
151
+ function countAuditorTasks(tasks: GoalTask[]): { total: number; complete: number; skipped: number; pending: number } {
152
+ let total = 0;
153
+ let complete = 0;
154
+ let skipped = 0;
155
+ for (const t of tasks) {
156
+ total++;
157
+ if (t.status === "complete") complete++;
158
+ else if (t.status === "skipped") skipped++;
159
+ if (t.subtasks && t.subtasks.length > 0) {
160
+ const child = countAuditorTasks(t.subtasks);
161
+ total += child.total;
162
+ complete += child.complete;
163
+ skipped += child.skipped;
164
+ }
165
+ }
166
+ return { total, complete, skipped, pending: total - complete - skipped };
167
+ }
168
+
137
169
  function taskSummaryBlock(taskList?: GoalTaskList | null): string {
138
170
  if (!taskList || taskList.tasks.length === 0) return "";
139
- const total = taskList.tasks.length;
140
- const complete = taskList.tasks.filter((t) => t.status === "complete").length;
141
- const skipped = taskList.tasks.filter((t) => t.status === "skipped").length;
142
- const pending = taskList.tasks.filter((t) => t.status === "pending");
171
+ const { total, complete, skipped, pending } = countAuditorTasks(taskList.tasks);
143
172
  const lines: string[] = [`Tasks: ${complete}/${total} complete${skipped > 0 ? `, ${skipped} skipped` : ""}`];
144
- for (const task of taskList.tasks) {
145
- const marker = task.status === "complete" ? "[x]" : task.status === "skipped" ? "[~]" : "[ ]";
146
- lines.push(` ${marker} ${task.id}: ${task.title}`);
147
- }
148
- const gate = taskList.blockCompletion && pending.length > 0 ? " | TASK GATE: pending tasks block completion" : "";
173
+ lines.push(...renderAuditorTaskTree(taskList.tasks, 0));
174
+ const gate = taskList.blockCompletion && pending > 0 ? " | TASK GATE: pending tasks block completion" : "";
149
175
  lines[0] = lines[0]! + gate;
150
176
  return lines.join("\n");
151
177
  }
@@ -155,6 +181,7 @@ export function buildGoalAuditorPrompt(args: {
155
181
  completionSummary?: string | null;
156
182
  detailedSummary: string;
157
183
  verificationSummary?: string | null;
184
+ settings?: GoalSettings;
158
185
  }): string {
159
186
  return [
160
187
  "You are the independent completion auditor for pi-goal.",
@@ -180,7 +207,7 @@ export function buildGoalAuditorPrompt(args: {
180
207
  "Current goal metadata:",
181
208
  "<goal_details>",
182
209
  args.detailedSummary,
183
- ...(taskSummaryBlock(args.goal.taskList) ? ["", taskSummaryBlock(args.goal.taskList)] : []),
210
+ ...(!args.settings?.disableTasks && taskSummaryBlock(args.goal.taskList) ? ["", taskSummaryBlock(args.goal.taskList)] : []),
184
211
  "</goal_details>",
185
212
  ...(args.verificationSummary?.trim() ? [
186
213
  "",
@@ -189,7 +216,7 @@ export function buildGoalAuditorPrompt(args: {
189
216
  args.verificationSummary.trim(),
190
217
  "</verification_summary>",
191
218
  ] : []),
192
- ...(args.goal.verificationContract?.trim() ? [
219
+ ...(!args.settings?.disableContracts && args.goal.verificationContract?.trim() ? [
193
220
  "",
194
221
  "Goal verification contract (what the executor was required to verify):",
195
222
  "<verification_contract>",
@@ -204,7 +231,7 @@ export function buildGoalAuditorPrompt(args: {
204
231
  ...(args.verificationSummary?.trim()
205
232
  ? ["3. Check the <verification_summary> against real artifacts. If the executor claims to have run tests or searched for references, verify those claims with actual file/shell evidence. The summary is a claim, not proof — cross-check it."]
206
233
  : []),
207
- ...(args.goal.verificationContract?.trim()
234
+ ...(!args.settings?.disableContracts && args.goal.verificationContract?.trim()
208
235
  ? ["4. Verify that the executor has satisfied every item in the <verification_contract>. If any item is missing or weakly addressed, disapprove."]
209
236
  : []),
210
237
  "5. Explain missing or weak evidence, especially scaffold-vs-final quality gaps.",
@@ -288,6 +315,7 @@ export async function runGoalCompletionAuditor(args: {
288
315
  completionSummary?: string | null;
289
316
  detailedSummary: string;
290
317
  verificationSummary?: string | null;
318
+ settings?: GoalSettings;
291
319
  signal?: AbortSignal;
292
320
  onProgress?: AuditorProgressCallback;
293
321
  /**
@@ -1,5 +1,5 @@
1
1
  import { statusLabel, type GoalDisplayRecordLike } from "./goal-core.ts";
2
- import type { GoalTaskList, TaskStatus } from "./goal-record.ts";
2
+ import type { GoalTask, GoalTaskList, TaskStatus } from "./goal-record.ts";
3
3
 
4
4
  export type GoalStatusLike = "active" | "paused" | "complete";
5
5
  export type StopReasonLike = "user" | "agent";
@@ -126,10 +126,27 @@ export function abortGoalCommandMessage(args: { archived: boolean; wasDrafting:
126
126
  return args.archived ? "Goal aborted and archived." : args.wasDrafting ? "Drafting cancelled." : "No goal is set.";
127
127
  }
128
128
 
129
+ /** Count tasks in subtree recursively */
130
+ function countSubtreeTasks(tasks: GoalTask[]): { total: number; complete: number; skipped: number; pending: number } {
131
+ let total = 0;
132
+ let complete = 0;
133
+ let skipped = 0;
134
+ for (const t of tasks) {
135
+ total++;
136
+ if (t.status === "complete") complete++;
137
+ else if (t.status === "skipped") skipped++;
138
+ if (t.subtasks && t.subtasks.length > 0) {
139
+ const child = countSubtreeTasks(t.subtasks);
140
+ total += child.total;
141
+ complete += child.complete;
142
+ skipped += child.skipped;
143
+ }
144
+ }
145
+ return { total, complete, skipped, pending: total - complete - skipped };
146
+ }
147
+
129
148
  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;
149
+ const { total, complete, skipped } = countSubtreeTasks(taskList.tasks);
133
150
  if (total === 0) return "No tasks";
134
151
  const parts: string[] = [`${complete}/${total} tasks complete`];
135
152
  if (skipped > 0) parts.push(`(${skipped} skipped)`);
@@ -138,9 +155,9 @@ export function buildTaskSummary(taskList: GoalTaskList): string {
138
155
 
139
156
  export function taskCompletionBlockWarning(taskList: GoalTaskList): string | null {
140
157
  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.`;
158
+ const { pending } = countSubtreeTasks(taskList.tasks);
159
+ if (pending === 0) return null;
160
+ return `${pending} task${pending > 1 ? "s" : ""} still pending with blockCompletion enabled. Complete or skip all pending tasks before finishing the goal.`;
144
161
  }
145
162
 
146
163
  /**
@@ -190,9 +207,43 @@ export function validateTaskSkip(args: {
190
207
  return { ok: true };
191
208
  }
192
209
 
210
+ /**
211
+ * Count the maximum nesting depth of a task's subtask tree.
212
+ * Root level = 0. Returns the deepest nesting depth found.
213
+ */
214
+ export function measureSubtaskDepth(task: GoalTask): number {
215
+ if (!task.subtasks || task.subtasks.length === 0) return 0;
216
+ let maxChild = 0;
217
+ for (const child of task.subtasks) {
218
+ const childDepth = measureSubtaskDepth(child);
219
+ if (childDepth > maxChild) maxChild = childDepth;
220
+ }
221
+ return maxChild + 1;
222
+ }
223
+
224
+ /**
225
+ * Validate that a task's subtask tree does not exceed the configured max depth.
226
+ * maxDepth is the subtaskDepth setting (default 1) — how many levels of nesting are allowed.
227
+ * Returns the first violation found, or undefined if valid.
228
+ */
229
+ export function findSubtaskDepthViolation(tasks: GoalTask[], maxDepth: number): string | undefined {
230
+ for (const task of tasks) {
231
+ const depth = measureSubtaskDepth(task);
232
+ if (depth > maxDepth) {
233
+ return `Task "${task.id}" has subtask nesting depth ${depth}, exceeding the configured maximum of ${maxDepth}`;
234
+ }
235
+ if (task.subtasks) {
236
+ const childViolation = findSubtaskDepthViolation(task.subtasks, maxDepth);
237
+ if (childViolation) return childViolation;
238
+ }
239
+ }
240
+ return undefined;
241
+ }
242
+
193
243
  export function validateTaskListProposal(args: {
194
244
  goal: GoalPolicyRecordLike | null;
195
- tasks: { id: string; title: string }[];
245
+ tasks: GoalTask[];
246
+ maxSubtaskDepth?: number;
196
247
  }): PolicyValidation {
197
248
  if (!args.goal) return { ok: false, message: "No goal is set." };
198
249
  if (args.tasks.length > 50) return { ok: false, message: "Task list cannot exceed 50 tasks." };
@@ -203,9 +254,78 @@ export function validateTaskListProposal(args: {
203
254
  if (ids.has(t.id)) return { ok: false, message: `Duplicate task id: "${t.id}".` };
204
255
  ids.add(t.id);
205
256
  }
257
+ // Check subtask depth limit
258
+ const maxDepth = args.maxSubtaskDepth ?? 1;
259
+ const depthViolation = findSubtaskDepthViolation(args.tasks, maxDepth);
260
+ if (depthViolation) return { ok: false, message: depthViolation };
206
261
  return { ok: true };
207
262
  }
208
263
 
264
+ /**
265
+ * Recursively find a task by ID in a task tree.
266
+ */
267
+ export function findTaskInTree(tasks: GoalTask[], taskId: string): GoalTask | undefined {
268
+ for (const t of tasks) {
269
+ if (t.id === taskId) return t;
270
+ if (t.subtasks) {
271
+ const found = findTaskInTree(t.subtasks, taskId);
272
+ if (found) return found;
273
+ }
274
+ }
275
+ return undefined;
276
+ }
277
+
278
+ /**
279
+ * Recursively update a task by ID in a task tree using an updater function.
280
+ */
281
+ export function updateTaskInTree(tasks: GoalTask[], taskId: string, updater: (task: GoalTask) => GoalTask): GoalTask[] {
282
+ return tasks.map((t) => {
283
+ if (t.id === taskId) return updater(t);
284
+ if (t.subtasks) {
285
+ return { ...t, subtasks: updateTaskInTree(t.subtasks, taskId, updater) };
286
+ }
287
+ return t;
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Check if all subtasks of a task are complete (for full subtasks only).
293
+ * Returns undefined when all are complete/skipped, or an error message.
294
+ */
295
+ export function checkSubtasksComplete(task: GoalTask): string | undefined {
296
+ if (!task.subtasks || task.subtasks.length === 0 || task.lightweightSubtasks) return undefined;
297
+ for (const child of task.subtasks) {
298
+ if (child.status === "pending") {
299
+ return `Task "${task.id}" has pending subtask "${child.id}". Complete or skip all subtasks first.`;
300
+ }
301
+ // Check recursively
302
+ const childCheck = checkSubtasksComplete(child);
303
+ if (childCheck) return childCheck;
304
+ }
305
+ return undefined;
306
+ }
307
+
308
+ /**
309
+ * Recursively skip all subtasks of a task.
310
+ * Returns a set of all skipped task IDs.
311
+ */
312
+ export function skipAllSubtasks(task: GoalTask, now: string, reason: string): GoalTask {
313
+ if (!task.subtasks || task.subtasks.length === 0) return task;
314
+ return {
315
+ ...task,
316
+ subtasks: task.subtasks.map((child) => {
317
+ if (child.status === "complete") return child;
318
+ const skipped = {
319
+ ...child,
320
+ status: "skipped" as const,
321
+ skippedAt: now,
322
+ skipReason: reason,
323
+ };
324
+ return skipAllSubtasks(skipped, now, reason);
325
+ }),
326
+ };
327
+ }
328
+
209
329
  export function buildCompletionReport(args: { detailedSummary: string; completionSummary?: string | null; auditorReport?: string | null; auditSkippedReason?: string | null; taskSummary?: string | null }): string {
210
330
  const auditSkipped = args.auditSkippedReason?.trim();
211
331
  const auditorReport = args.auditorReport?.trim();
@@ -92,6 +92,11 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
92
92
  const totalTabs = questions.length + 1;
93
93
 
94
94
  return await ctx.ui.custom<GoalQuestionnaireResult>((tui, theme, _kb, done) => {
95
+ // Suppress hardware cursor during dialog to reduce TUI auto-scroll
96
+ // (the TUI render loop runs at ~60fps and writes ANSI cursor positioning
97
+ // sequences every cycle, which can cause terminal viewport snapping).
98
+ const wasHardwareCursorShown = tui.getShowHardwareCursor();
99
+ tui.setShowHardwareCursor(false);
95
100
  let currentTab = 0;
96
101
  let optionIndex = 0;
97
102
  let inputMode = false;
@@ -118,6 +123,8 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
118
123
  }
119
124
 
120
125
  function submit(cancelled: boolean) {
126
+ // Restore hardware cursor now that the dialog is closing
127
+ tui.setShowHardwareCursor(wasHardwareCursorShown);
121
128
  const ordered = questions.map((q) => answers.get(q.id)).filter((a): a is GoalQuestionnaireAnswer => !!a);
122
129
  done({ questions, answers: ordered, cancelled });
123
130
  }
@@ -15,6 +15,8 @@ export interface GoalTask {
15
15
  evidence?: string;
16
16
  skipReason?: string;
17
17
  verificationContract?: string;
18
+ lightweightSubtasks?: boolean;
19
+ subtasks?: GoalTask[];
18
20
  }
19
21
 
20
22
  export interface GoalTaskList {
@@ -111,12 +113,19 @@ export function emptyUsage(): GoalUsage {
111
113
  return { tokensUsed: 0, activeSeconds: 0 };
112
114
  }
113
115
 
116
+ function cloneGoalTask(task: GoalTask): GoalTask {
117
+ return {
118
+ ...task,
119
+ subtasks: task.subtasks ? task.subtasks.map(cloneGoalTask) : undefined,
120
+ };
121
+ }
122
+
114
123
  export function cloneGoal(goal: GoalRecord): GoalRecord {
115
124
  return {
116
125
  ...goal,
117
126
  usage: { ...goal.usage },
118
127
  taskList: goal.taskList
119
- ? { ...goal.taskList, tasks: goal.taskList.tasks.map(t => ({ ...t })) }
128
+ ? { ...goal.taskList, tasks: goal.taskList.tasks.map(cloneGoalTask) }
120
129
  : undefined,
121
130
  };
122
131
  }
@@ -164,30 +173,41 @@ export function normalizeUsage(value: unknown): GoalUsage {
164
173
  return { tokensUsed, activeSeconds };
165
174
  }
166
175
 
176
+ export function normalizeTaskItem(raw: Record<string, unknown>): GoalTask | undefined {
177
+ const id = typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : "";
178
+ const title = typeof raw.title === "string" ? raw.title.trim() : "";
179
+ if (!id || !title) return undefined;
180
+ const status: TaskStatus = raw.status === "complete" ? "complete" : raw.status === "skipped" ? "skipped" : "pending";
181
+ const subtasksRaw = raw.subtasks;
182
+ let subtasks: GoalTask[] | undefined;
183
+ if (Array.isArray(subtasksRaw)) {
184
+ subtasks = subtasksRaw
185
+ .map((item) => (item && typeof item === "object" ? normalizeTaskItem(item as Record<string, unknown>) : undefined))
186
+ .filter((t): t is GoalTask => !!t);
187
+ if (subtasks.length === 0) subtasks = undefined;
188
+ }
189
+ return {
190
+ id,
191
+ title,
192
+ status,
193
+ completedAt: typeof raw.completedAt === "string" ? raw.completedAt : undefined,
194
+ skippedAt: typeof raw.skippedAt === "string" ? raw.skippedAt : undefined,
195
+ evidence: typeof raw.evidence === "string" ? raw.evidence : undefined,
196
+ skipReason: typeof raw.skipReason === "string" ? raw.skipReason : undefined,
197
+ verificationContract: typeof raw.verificationContract === "string" ? raw.verificationContract : undefined,
198
+ lightweightSubtasks: raw.lightweightSubtasks === true ? true : undefined,
199
+ subtasks,
200
+ };
201
+ }
202
+
167
203
  export function normalizeTaskList(value: unknown): GoalTaskList | undefined {
168
204
  const raw = asRecord(value);
169
205
  if (!raw) return undefined;
170
206
  const tasksRaw = raw.tasks;
171
207
  if (!Array.isArray(tasksRaw)) return undefined;
172
- const tasks: GoalTask[] = [];
173
- for (const item of tasksRaw) {
174
- if (!item || typeof item !== "object" || Array.isArray(item)) continue;
175
- const t = item as Record<string, unknown>;
176
- const id = typeof t.id === "string" && t.id.trim() ? t.id.trim() : "";
177
- const title = typeof t.title === "string" ? t.title.trim() : "";
178
- if (!id || !title) continue;
179
- const status: TaskStatus = t.status === "complete" ? "complete" : t.status === "skipped" ? "skipped" : "pending";
180
- tasks.push({
181
- id,
182
- title,
183
- status,
184
- completedAt: typeof t.completedAt === "string" ? t.completedAt : undefined,
185
- skippedAt: typeof t.skippedAt === "string" ? t.skippedAt : undefined,
186
- evidence: typeof t.evidence === "string" ? t.evidence : undefined,
187
- skipReason: typeof t.skipReason === "string" ? t.skipReason : undefined,
188
- verificationContract: typeof t.verificationContract === "string" ? t.verificationContract : undefined,
189
- });
190
- }
208
+ const tasks: GoalTask[] = tasksRaw
209
+ .map((item) => (item && typeof item !== "object" || Array.isArray(item) ? undefined : normalizeTaskItem(item as Record<string, unknown>)))
210
+ .filter((t): t is GoalTask => !!t);
191
211
  if (tasks.length === 0) return undefined;
192
212
  return {
193
213
  tasks,
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Global goal settings: config file + env var overrides for disabling
3
+ * task lists and/or verification contracts.
4
+ *
5
+ * Reads `.pi/goal-settings.json` with env var overrides:
6
+ * PI_GOAL_DISABLE_TASKS — "true" to disable, any other value = use file config
7
+ * PI_GOAL_DISABLE_CONTRACTS — "true" to disable, any other value = use file config
8
+ *
9
+ * Pattern mirrors `goalAuditorConfig` in goal-auditor.ts.
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+
15
+ export interface GoalSettings {
16
+ disableTasks?: boolean;
17
+ disableContracts?: boolean;
18
+ subtaskDepth?: number;
19
+ }
20
+
21
+ /**
22
+ * Resolve the path to the global goal-settings.json file.
23
+ */
24
+ export function goalSettingsPath(cwd: string): string {
25
+ return path.join(cwd, ".pi", "goal-settings.json");
26
+ }
27
+
28
+ function asNonEmptyString(value: unknown): string | undefined {
29
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
30
+ }
31
+
32
+ function asBool(value: unknown): boolean | undefined {
33
+ if (value === true || value === "true") return true;
34
+ if (value === false || value === "false") return false;
35
+ return undefined;
36
+ }
37
+
38
+ function asPositiveInt(value: unknown): number | undefined {
39
+ if (typeof value === "number" && Number.isInteger(value) && value >= 1) return value;
40
+ if (typeof value === "string") {
41
+ const n = parseInt(value, 10);
42
+ if (!isNaN(n) && n >= 1) return n;
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ const ALLOWED_SETTINGS_KEYS = new Set(["disableTasks", "disableContracts", "subtaskDepth"]);
48
+
49
+ /**
50
+ * Parse raw (deserialized JSON) into a GoalSettings object.
51
+ * Rejects unknown keys (additionalProperties: false semantics).
52
+ */
53
+ export function parseGoalSettings(raw: unknown): GoalSettings {
54
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
55
+ const record = raw as Record<string, unknown>;
56
+ const unknownKeys = Object.keys(record).filter((k) => !ALLOWED_SETTINGS_KEYS.has(k));
57
+ if (unknownKeys.length > 0) {
58
+ throw new Error(`Unknown goal-settings.json key(s): ${unknownKeys.join(", ")}`);
59
+ }
60
+ const settings: GoalSettings = {};
61
+ const disableTasks = asBool(record.disableTasks);
62
+ const disableContracts = asBool(record.disableContracts);
63
+ const subtaskDepth = asPositiveInt(record.subtaskDepth);
64
+ if (disableTasks !== undefined) settings.disableTasks = disableTasks;
65
+ if (disableContracts !== undefined) settings.disableContracts = disableContracts;
66
+ if (subtaskDepth !== undefined) settings.subtaskDepth = subtaskDepth;
67
+ return settings;
68
+ }
69
+
70
+ /**
71
+ * Load settings from the file on disk. Returns {} if file missing or invalid.
72
+ */
73
+ export function loadGoalSettingsFileConfig(cwd: string): GoalSettings {
74
+ try {
75
+ const configPath = goalSettingsPath(cwd);
76
+ if (fs.existsSync(configPath)) return parseGoalSettings(JSON.parse(fs.readFileSync(configPath, "utf8")));
77
+ } catch {
78
+ // file missing, malformed JSON, etc. — use defaults
79
+ }
80
+ return {};
81
+ }
82
+
83
+ /**
84
+ * Load settings with env var overrides.
85
+ * Env vars take precedence over file config.
86
+ * Default: both flags false (features enabled).
87
+ */
88
+ export function loadGoalSettings(cwd: string, env: NodeJS.ProcessEnv = process.env): GoalSettings {
89
+ const fileConfig = loadGoalSettingsFileConfig(cwd);
90
+ return {
91
+ disableTasks: asBool(env.PI_GOAL_DISABLE_TASKS) ?? fileConfig.disableTasks ?? false,
92
+ disableContracts: asBool(env.PI_GOAL_DISABLE_CONTRACTS) ?? fileConfig.disableContracts ?? false,
93
+ subtaskDepth: fileConfig.subtaskDepth ?? 1,
94
+ };
95
+ }
@@ -23,6 +23,7 @@ import {
23
23
  saveGoalAuditorFileConfig,
24
24
  type GoalAuditorConfig,
25
25
  } from "./goal-auditor.ts";
26
+ import { loadGoalSettings, type GoalSettings } from "./goal-settings.ts";
26
27
  import {
27
28
  proposalDialogFailureMessage,
28
29
  registerQuestionnaireTools,
@@ -52,6 +53,7 @@ import {
52
53
  goalFocusDetails,
53
54
  normalizeGoalRecord,
54
55
  normalizeGoalFocusEntry,
56
+ normalizeTaskItem,
55
57
  nowIso,
56
58
  type AssistantMessageLike,
57
59
  type DraftingFocus,
@@ -64,6 +66,7 @@ import {
64
66
  type GoalStateEntry,
65
67
  type GoalStatus,
66
68
  type StopReason,
69
+ type GoalTask,
67
70
  type GoalTaskList,
68
71
  } from "./goal-record.ts";
69
72
  import {
@@ -115,6 +118,11 @@ import {
115
118
  validateGoalAbort,
116
119
  validateGoalCompletion,
117
120
  validatePauseGoal,
121
+ checkSubtasksComplete,
122
+ findSubtaskDepthViolation,
123
+ findTaskInTree,
124
+ skipAllSubtasks,
125
+ updateTaskInTree,
118
126
  validateResumeGoal,
119
127
  validateTaskCompletion,
120
128
  validateTaskListProposal,
@@ -187,12 +195,18 @@ function detailedSummary(goal: GoalRecord | null): string {
187
195
  lines.push("Mode: Sisyphus (prompt/criteria variant; shared goal lifecycle)");
188
196
  }
189
197
  if (goal.taskList) {
190
- const total = goal.taskList.tasks.length;
191
- const pending = goal.taskList.tasks.filter((t) => t.status === "pending");
192
198
  const taskSummary = buildTaskSummary(goal.taskList);
193
199
  lines.push(`Tasks: ${taskSummary}`);
194
- if (pending.length > 0) {
195
- lines.push(`Next pending task: ${pending[0]!.id} ${pending[0]!.title}`);
200
+ // Find first pending task at any depth (BFS)
201
+ const queue = [...(goal.taskList.tasks ?? [])];
202
+ let firstPending: { id: string; title: string } | undefined;
203
+ while (queue.length > 0 && !firstPending) {
204
+ const t = queue.shift()!;
205
+ if (t.status === "pending") firstPending = t;
206
+ else if (t.subtasks) queue.push(...t.subtasks);
207
+ }
208
+ if (firstPending) {
209
+ lines.push(`Next pending task: ${firstPending.id} — ${firstPending.title}`);
196
210
  }
197
211
  }
198
212
  if (goal.activePath) lines.push(`File: ${goal.activePath}`);
@@ -785,6 +799,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
785
799
  getGoal: () => goalForDisplay() ?? state.goal,
786
800
  getOpenGoalCount: () => openGoals().length,
787
801
  getAuditorProgress: () => auditProgress,
802
+ getSettings: () => loadGoalSettings(ctx.cwd),
788
803
  });
789
804
  return goalWidgetComponent;
790
805
  },
@@ -812,6 +827,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
812
827
  getGoal: () => goalForDisplay() ?? state.goal,
813
828
  getOpenGoalCount: () => openGoals().length,
814
829
  getAuditorProgress: () => auditProgress,
830
+ getSettings: () => loadGoalSettings(ctx.cwd),
815
831
  });
816
832
  return goalWidgetComponent;
817
833
  },
@@ -977,10 +993,11 @@ export default function goalExtension(pi: ExtensionAPI): void {
977
993
  return;
978
994
  }
979
995
  continuationQueuedFor = goalId;
996
+ const settings = loadGoalSettings(ctx.cwd);
980
997
  pi.sendMessage<GoalEventDetails>(
981
998
  {
982
999
  customType: GOAL_EVENT_ENTRY,
983
- content: continuationPrompt(state.goal),
1000
+ content: continuationPrompt(state.goal, settings),
984
1001
  display: false,
985
1002
  details: {
986
1003
  kind: "checkpoint",
@@ -1622,6 +1639,13 @@ export default function goalExtension(pi: ExtensionAPI): void {
1622
1639
  objective: Type.String({ description: "Full goal text. For Sisyphus goals this MUST include the user's numbered steps + per-step done criteria, taken faithfully from the user's input." }),
1623
1640
  autoContinue: Type.Optional(Type.Boolean({ description: "Whether pi should keep sending continuation prompts until complete. Default true." })),
1624
1641
  sisyphus: Type.Optional(Type.Boolean({ description: "Must equal true for /sisyphus discussion, false for /goals discussion. Schema-enforced via B1 gate." })),
1642
+ tasks: Type.Optional(Type.Array(Type.Object({
1643
+ id: Type.String({ description: "Short stable slug e.g. 'task-1'" }),
1644
+ title: Type.String({ description: "Human-readable task title" }),
1645
+ verificationContract: Type.Optional(Type.String({ description: "Optional verification contract for this task." })),
1646
+ lightweightSubtasks: Type.Optional(Type.Boolean({ description: "If true, subtasks are lightweight (no completion enforcement). Default false." })),
1647
+ subtasks: Type.Optional(Type.Any({ description: "Optional recursive array of sub-tasks (same shape as parent)." })),
1648
+ }), { description: "Optional task list to confirm together with the goal in a single step. Each task supports recursive subtasks." })),
1625
1649
  draftId: Type.Optional(Type.String({ description: "Deprecated compatibility field. It is accepted but ignored; current goal confirmation no longer depends on hidden draft ids." })),
1626
1650
  }),
1627
1651
  executionMode: "sequential",
@@ -1650,10 +1674,55 @@ export default function goalExtension(pi: ExtensionAPI): void {
1650
1674
  const objective = validation.objective;
1651
1675
  const autoContinueFlag = params.autoContinue ?? true;
1652
1676
  const sisyphusFlag = validation.expectedSisyphus;
1677
+ // Build confirmation text: goal + optional task list
1678
+ function renderConfirmationTasks(tasks: GoalTask[], indent: number): string[] {
1679
+ const prefix = " ".repeat(indent);
1680
+ const lines: string[] = [];
1681
+ for (const t of tasks) {
1682
+ const lw = t.lightweightSubtasks ? " (lightweight)" : "";
1683
+ const contract = t.verificationContract ? ` contract: ${t.verificationContract}` : "";
1684
+ lines.push(`${prefix}[ ] ${t.id}: ${t.title}${lw}${contract}`);
1685
+ if (t.subtasks && t.subtasks.length > 0) {
1686
+ lines.push(...renderConfirmationTasks(t.subtasks, indent + 1));
1687
+ }
1688
+ }
1689
+ return lines;
1690
+ }
1691
+
1692
+ let taskSummarySection = "";
1693
+ let tasksToCreate: GoalTask[] | undefined;
1694
+ if (params.tasks && params.tasks.length > 0) {
1695
+ tasksToCreate = params.tasks.map((t) => {
1696
+ const task: GoalTask = {
1697
+ id: (t as Record<string, unknown>).id as string,
1698
+ title: (t as Record<string, unknown>).title as string,
1699
+ status: "pending",
1700
+ verificationContract: (t as Record<string, unknown>).verificationContract as string | undefined,
1701
+ lightweightSubtasks: (t as Record<string, unknown>).lightweightSubtasks === true ? true : undefined,
1702
+ };
1703
+ const rawSubtasks = (t as Record<string, unknown>).subtasks;
1704
+ if (Array.isArray(rawSubtasks) && rawSubtasks.length > 0) {
1705
+ task.subtasks = rawSubtasks.map((s) => normalizeTaskItem(s as Record<string, unknown>)).filter((s): s is GoalTask => !!s);
1706
+ }
1707
+ return task;
1708
+ });
1709
+ // Validate subtask depth BEFORE showing dialog (consistent with propose_task_list)
1710
+ const settings = loadGoalSettings(ctx.cwd);
1711
+ const depthViolation = findSubtaskDepthViolation(tasksToCreate, settings.subtaskDepth ?? 1);
1712
+ if (depthViolation) {
1713
+ return {
1714
+ content: [{ type: "text", text: depthViolation }],
1715
+ details: goalDetails(state.goal),
1716
+ };
1717
+ }
1718
+ const taskLines = renderConfirmationTasks(tasksToCreate, 0);
1719
+ taskSummarySection = `\n\n┌─ TASKS ─────────────────────────────────────┐\n${taskLines.join("\n")}\n└──────────────────────────────────────────────┘`;
1720
+ }
1721
+
1653
1722
  const draftSummary = buildDraftConfirmationText({
1654
1723
  focus: activeIntent.focus,
1655
1724
  originalTopic: activeIntent.originalTopic,
1656
- objective,
1725
+ objective: objective + taskSummarySection,
1657
1726
  autoContinue: autoContinueFlag,
1658
1727
  });
1659
1728
 
@@ -1687,6 +1756,36 @@ export default function goalExtension(pi: ExtensionAPI): void {
1687
1756
  };
1688
1757
  confirmationIntent = null;
1689
1758
  replaceGoal(config, ctx, false, verificationContract);
1759
+
1760
+ // Set task list if provided
1761
+ if (tasksToCreate && tasksToCreate.length > 0 && state.goal) {
1762
+ const now = nowIso();
1763
+ state.goal = {
1764
+ ...state.goal,
1765
+ taskList: {
1766
+ tasks: tasksToCreate,
1767
+ blockCompletion: false,
1768
+ proposedAt: now,
1769
+ },
1770
+ updatedAt: now,
1771
+ };
1772
+ setGoal(state.goal, ctx);
1773
+ // Append ledger event for task list
1774
+ try {
1775
+ appendGoalEvent(ctx, {
1776
+ type: "task_list_set",
1777
+ goalId: state.goal.id,
1778
+ taskCount: tasksToCreate.length,
1779
+ blockCompletion: false,
1780
+ at: now,
1781
+ });
1782
+ } catch {
1783
+ // Ledger failure should not block creation
1784
+ }
1785
+ syncGoalTools();
1786
+ updateUI(ctx);
1787
+ }
1788
+
1690
1789
  syncGoalTools();
1691
1790
  return {
1692
1791
  content: [{ type: "text", text: buildGoalCreatedReport({ objective, detailedSummary: detailedSummary(state.goal) }) }],
@@ -1902,25 +2001,30 @@ export default function goalExtension(pi: ExtensionAPI): void {
1902
2001
  if (!state.goal) throw new Error("Goal disappeared during completion validation.");
1903
2002
 
1904
2003
  // Task gate: warn if blockCompletion is enabled and tasks remain pending
1905
- const taskWarning = state.goal.taskList ? taskCompletionBlockWarning(state.goal.taskList) : null;
1906
- const taskSummaryText = state.goal.taskList ? buildTaskSummary(state.goal.taskList) : null;
1907
- if (taskWarning) {
1908
- return {
1909
- content: [{ type: "text", text: taskWarning }],
1910
- details: goalDetails(state.goal),
1911
- };
2004
+ const disableTasksSettings = loadGoalSettings(ctx.cwd).disableTasks;
2005
+ if (!disableTasksSettings) {
2006
+ const taskWarning = state.goal.taskList ? taskCompletionBlockWarning(state.goal.taskList) : null;
2007
+ if (taskWarning) {
2008
+ return {
2009
+ content: [{ type: "text", text: taskWarning }],
2010
+ details: goalDetails(state.goal),
2011
+ };
2012
+ }
1912
2013
  }
1913
2014
 
1914
2015
  // Verification contract gate: if the goal has a contract, verificationSummary must be non-empty
1915
- const contractGate = validateVerificationSummary({
1916
- verificationContract: state.goal.verificationContract,
1917
- verificationSummary: params.verificationSummary,
1918
- });
1919
- if (!contractGate.ok) {
1920
- return {
1921
- content: [{ type: "text", text: contractGate.message }],
1922
- details: goalDetails(state.goal),
1923
- };
2016
+ const disableContractsSettings = loadGoalSettings(ctx.cwd).disableContracts;
2017
+ if (!disableContractsSettings) {
2018
+ const contractGate = validateVerificationSummary({
2019
+ verificationContract: state.goal.verificationContract,
2020
+ verificationSummary: params.verificationSummary,
2021
+ });
2022
+ if (!contractGate.ok) {
2023
+ return {
2024
+ content: [{ type: "text", text: contractGate.message }],
2025
+ details: goalDetails(state.goal),
2026
+ };
2027
+ }
1924
2028
  }
1925
2029
 
1926
2030
  const auditTarget = mergeGoalPromptFromDisk(ctx, state.goal);
@@ -2061,6 +2165,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
2061
2165
  completionSummary: params.completionSummary,
2062
2166
  detailedSummary: detailedSummary(auditTarget),
2063
2167
  verificationSummary: params.verificationSummary,
2168
+ settings: loadGoalSettings(ctx.cwd),
2064
2169
  signal: auditAbortController.signal,
2065
2170
  onProgress: (progress) => {
2066
2171
  auditProgress = {
@@ -2397,7 +2502,9 @@ export default function goalExtension(pi: ExtensionAPI): void {
2397
2502
  id: Type.String({ description: "Short stable slug e.g. 'task-1'" }),
2398
2503
  title: Type.String({ description: "Human-readable task title" }),
2399
2504
  verificationContract: Type.Optional(Type.String({ description: "Optional verification contract for this task — what evidence is required before marking it complete." })),
2400
- }), { description: "Array of task objects with id and title" }),
2505
+ lightweightSubtasks: Type.Optional(Type.Boolean({ description: "If true, subtasks are lightweight (no completion enforcement). Default false (full subtasks)." })),
2506
+ subtasks: Type.Optional(Type.Any({ description: "Optional recursive array of sub-tasks (same shape as parent). Nested up to subtaskDepth (default 1, from .pi/goal-settings.json)." })),
2507
+ }), { description: "Array of task objects with id, title, optional subtasks" }),
2401
2508
  blockCompletion: Type.Optional(Type.Boolean({ description: "If true, warns when pending tasks remain during complete_goal. Default false." })),
2402
2509
  changeSummary: Type.Optional(Type.String({ description: "Optional summary of the task list proposal" })),
2403
2510
  }),
@@ -2410,7 +2517,15 @@ export default function goalExtension(pi: ExtensionAPI): void {
2410
2517
  details: goalDetails(state.goal),
2411
2518
  };
2412
2519
  }
2413
- const gate = validateTaskListProposal({ goal: state.goal, tasks: params.tasks });
2520
+ // Reject if task lists are disabled via settings
2521
+ if (loadGoalSettings(ctx.cwd).disableTasks) {
2522
+ return {
2523
+ content: [{ type: "text", text: "propose_task_list is disabled by .pi/goal-settings.json (disableTasks: true)." }],
2524
+ details: goalDetails(state.goal),
2525
+ };
2526
+ }
2527
+ const settings = loadGoalSettings(ctx.cwd);
2528
+ const gate = validateTaskListProposal({ goal: state.goal, tasks: params.tasks as GoalTask[], maxSubtaskDepth: settings.subtaskDepth });
2414
2529
  if (!gate.ok) {
2415
2530
  return {
2416
2531
  content: [{ type: "text", text: gate.message }],
@@ -2420,25 +2535,31 @@ export default function goalExtension(pi: ExtensionAPI): void {
2420
2535
  const blockCompletion = params.blockCompletion === true;
2421
2536
  const now = nowIso();
2422
2537
  const existingTasks = state.goal.taskList?.tasks ?? [];
2538
+ const existingById = new Map(existingTasks.map((t) => [t.id, t]));
2423
2539
 
2424
2540
  // Merge: existing tasks with matching IDs preserve status/timestamps
2425
- const existingById = new Map(existingTasks.map((t) => [t.id, t]));
2426
- const mergedTasks = params.tasks.map((p) => {
2427
- const existing = existingById.get(p.id);
2428
- if (existing) {
2429
- return {
2541
+ function mergeTask(input: GoalTask): GoalTask {
2542
+ const existing = existingById.get(input.id);
2543
+ const base: GoalTask = existing
2544
+ ? {
2430
2545
  ...existing,
2431
- title: p.title,
2432
- verificationContract: p.verificationContract ?? existing.verificationContract,
2546
+ title: input.title,
2547
+ verificationContract: input.verificationContract ?? existing.verificationContract,
2548
+ lightweightSubtasks: input.lightweightSubtasks ?? existing.lightweightSubtasks,
2549
+ }
2550
+ : {
2551
+ id: input.id,
2552
+ title: input.title,
2553
+ status: "pending" as const,
2554
+ verificationContract: input.verificationContract || undefined,
2555
+ lightweightSubtasks: input.lightweightSubtasks || undefined,
2433
2556
  };
2557
+ if (input.subtasks && input.subtasks.length > 0) {
2558
+ base.subtasks = input.subtasks.map((child) => mergeTask(child));
2434
2559
  }
2435
- return {
2436
- id: p.id,
2437
- title: p.title,
2438
- status: "pending" as const,
2439
- verificationContract: p.verificationContract || undefined,
2440
- };
2441
- });
2560
+ return base;
2561
+ }
2562
+ const mergedTasks = params.tasks.map((p) => mergeTask(p as GoalTask));
2442
2563
 
2443
2564
  const taskList: GoalTaskList = {
2444
2565
  tasks: mergedTasks,
@@ -2446,11 +2567,21 @@ export default function goalExtension(pi: ExtensionAPI): void {
2446
2567
  proposedAt: now,
2447
2568
  };
2448
2569
 
2449
- // Show full proposed task list in confirmation dialog
2450
- const taskLines = taskList.tasks.map((t) => {
2451
- const marker = t.status === "complete" ? "[x]" : t.status === "skipped" ? "[~]" : "[ ]";
2452
- return ` ${marker} ${t.id}: ${t.title}`;
2453
- });
2570
+ // Show full proposed task list in confirmation dialog (with subtasks)
2571
+ function renderTaskLines(tasks: GoalTask[], indent = 0): string[] {
2572
+ const prefix = " ".repeat(indent);
2573
+ const lines: string[] = [];
2574
+ for (const t of tasks) {
2575
+ const marker = t.status === "complete" ? "[x]" : t.status === "skipped" ? "[~]" : "[ ]";
2576
+ const lw = t.lightweightSubtasks ? " (lightweight)" : "";
2577
+ lines.push(`${prefix}${marker} ${t.id}: ${t.title}${lw}`);
2578
+ if (t.subtasks && t.subtasks.length > 0) {
2579
+ lines.push(...renderTaskLines(t.subtasks, indent + 1));
2580
+ }
2581
+ }
2582
+ return lines;
2583
+ }
2584
+ const taskLines = renderTaskLines(taskList.tasks);
2454
2585
  const gateLabel = blockCompletion ? " (blockCompletion enabled)" : "";
2455
2586
  const proposalText = [`Proposed task list${gateLabel}:`, "", ...taskLines].join("\n");
2456
2587
 
@@ -2524,6 +2655,12 @@ export default function goalExtension(pi: ExtensionAPI): void {
2524
2655
  executionMode: "sequential",
2525
2656
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2526
2657
  reconcileFocusedGoalFromDisk(ctx);
2658
+ if (loadGoalSettings(ctx.cwd).disableTasks) {
2659
+ return {
2660
+ content: [{ type: "text", text: "complete_task is disabled by .pi/goal-settings.json (disableTasks: true)." }],
2661
+ details: goalDetails(state.goal),
2662
+ };
2663
+ }
2527
2664
  const gate = validateTaskCompletion({ goal: state.goal, taskId: params.taskId });
2528
2665
  if (!gate.ok) {
2529
2666
  return {
@@ -2533,24 +2670,41 @@ export default function goalExtension(pi: ExtensionAPI): void {
2533
2670
  }
2534
2671
  if (!state.goal?.taskList) throw new Error("Task list disappeared during task completion.");
2535
2672
 
2536
- // Check verification contract for the task
2537
- const taskToComplete = state.goal.taskList.tasks.find((t) => t.id === params.taskId);
2538
- const contractGate = validateVerificationSummary({
2539
- verificationContract: taskToComplete?.verificationContract,
2540
- verificationSummary: params.verificationSummary,
2541
- });
2542
- if (!contractGate.ok) {
2543
- return {
2544
- content: [{ type: "text", text: contractGate.message }],
2545
- details: goalDetails(state.goal),
2546
- };
2673
+ // Check verification contract for the task (skip if contracts disabled)
2674
+ const settings = loadGoalSettings(ctx.cwd);
2675
+ const taskToComplete = findTaskInTree(state.goal.taskList.tasks, params.taskId);
2676
+ if (!settings.disableContracts) {
2677
+ const contractGate = validateVerificationSummary({
2678
+ verificationContract: taskToComplete?.verificationContract,
2679
+ verificationSummary: params.verificationSummary,
2680
+ });
2681
+ if (!contractGate.ok) {
2682
+ return {
2683
+ content: [{ type: "text", text: contractGate.message }],
2684
+ details: goalDetails(state.goal),
2685
+ };
2686
+ }
2547
2687
  }
2688
+
2689
+ // Check subtask completion (full subtasks only)
2690
+ if (taskToComplete) {
2691
+ const subtaskGate = checkSubtasksComplete(taskToComplete);
2692
+ if (subtaskGate) {
2693
+ return {
2694
+ content: [{ type: "text", text: subtaskGate }],
2695
+ details: goalDetails(state.goal),
2696
+ };
2697
+ }
2698
+ }
2699
+
2548
2700
  const now = nowIso();
2549
2701
  const evidence = params.evidence?.trim().slice(0, 200) || undefined;
2550
- const updatedTasks = state.goal.taskList.tasks.map((t) => {
2551
- if (t.id !== params.taskId) return t;
2552
- return { ...t, status: "complete" as const, completedAt: now, evidence };
2553
- });
2702
+ const updatedTasks = updateTaskInTree(state.goal.taskList.tasks, params.taskId, (t) => ({
2703
+ ...t,
2704
+ status: "complete" as const,
2705
+ completedAt: now,
2706
+ evidence,
2707
+ }));
2554
2708
  state.goal = mergeGoalPromptFromDisk(ctx, state.goal);
2555
2709
  if (!state.goal || !state.goal.taskList) throw new Error("Goal disappeared during task completion.");
2556
2710
  state.goal = {
@@ -2606,6 +2760,12 @@ export default function goalExtension(pi: ExtensionAPI): void {
2606
2760
  executionMode: "sequential",
2607
2761
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2608
2762
  reconcileFocusedGoalFromDisk(ctx);
2763
+ if (loadGoalSettings(ctx.cwd).disableTasks) {
2764
+ return {
2765
+ content: [{ type: "text", text: "skip_task is disabled by .pi/goal-settings.json (disableTasks: true)." }],
2766
+ details: goalDetails(state.goal),
2767
+ };
2768
+ }
2609
2769
  const gate = validateTaskSkip({ goal: state.goal, taskId: params.taskId, reason: params.reason });
2610
2770
  if (!gate.ok) {
2611
2771
  return {
@@ -2615,9 +2775,13 @@ export default function goalExtension(pi: ExtensionAPI): void {
2615
2775
  }
2616
2776
  if (!state.goal?.taskList) throw new Error("Task list disappeared during task skip.");
2617
2777
  const now = nowIso();
2618
- const updatedTasks = state.goal.taskList.tasks.map((t) => {
2619
- if (t.id !== params.taskId) return t;
2620
- return { ...t, status: "skipped" as const, skippedAt: now, skipReason: params.reason.trim() };
2778
+ const updatedTasks = updateTaskInTree(state.goal.taskList.tasks, params.taskId, (t) => {
2779
+ // Cascade skip to all subtasks (full subtasks only)
2780
+ const base = { ...t, status: "skipped" as const, skippedAt: now, skipReason: params.reason.trim() };
2781
+ if (t.subtasks && t.subtasks.length > 0 && !t.lightweightSubtasks) {
2782
+ return skipAllSubtasks(base, now, params.reason.trim());
2783
+ }
2784
+ return base;
2621
2785
  });
2622
2786
  state.goal = mergeGoalPromptFromDisk(ctx, state.goal);
2623
2787
  if (!state.goal || !state.goal.taskList) throw new Error("Goal disappeared during task skip.");
@@ -2921,7 +3085,8 @@ export default function goalExtension(pi: ExtensionAPI): void {
2921
3085
  };
2922
3086
  }
2923
3087
  const activeGoal = state.goal;
2924
- let prompt = goalPrompt(activeGoal);
3088
+ const settings = loadGoalSettings(ctx.cwd);
3089
+ let prompt = goalPrompt(activeGoal, settings);
2925
3090
  // Inject durable auditor feedback if the latest result was a rejection
2926
3091
  try {
2927
3092
  const ledger = readGoalLedger(ctx);
@@ -3,7 +3,8 @@ import {
3
3
  truncateText,
4
4
  } from "../goal-core.ts";
5
5
  import { promptSafeObjective } from "../goal-draft.ts";
6
- import type { GoalRecord, TaskStatus } from "../goal-record.ts";
6
+ import type { GoalRecord, GoalTask, TaskStatus } from "../goal-record.ts";
7
+ import type { GoalSettings } from "../goal-settings.ts";
7
8
 
8
9
  function taskMarker(status: TaskStatus): string {
9
10
  if (status === "complete") return "[x]";
@@ -11,23 +12,55 @@ function taskMarker(status: TaskStatus): string {
11
12
  return "[ ]";
12
13
  }
13
14
 
14
- export function taskListBlock(goal: GoalRecord): string {
15
- if (!goal.taskList || goal.taskList.tasks.length === 0) return "";
16
- const total = goal.taskList.tasks.length;
17
- const complete = goal.taskList.tasks.filter((t) => t.status === "complete").length;
18
- const skipped = goal.taskList.tasks.filter((t) => t.status === "skipped").length;
19
- const pending = goal.taskList.tasks.filter((t) => t.status === "pending");
15
+ /** Count tasks in subtree recursively */
16
+ function countSubtree(tasks: GoalTask[]): { total: number; complete: number; skipped: number; pending: GoalTask[] } {
17
+ let total = 0;
18
+ let complete = 0;
19
+ let skipped = 0;
20
+ const pending: GoalTask[] = [];
21
+ for (const t of tasks) {
22
+ total++;
23
+ if (t.status === "complete") complete++;
24
+ else if (t.status === "skipped") skipped++;
25
+ else pending.push(t);
26
+ if (t.subtasks && t.subtasks.length > 0) {
27
+ const child = countSubtree(t.subtasks);
28
+ total += child.total;
29
+ complete += child.complete;
30
+ skipped += child.skipped;
31
+ pending.push(...child.pending);
32
+ }
33
+ }
34
+ return { total, complete, skipped, pending };
35
+ }
36
+
37
+ /** Render task subtree recursively */
38
+ function renderTaskTree(tasks: GoalTask[], indent: number): string[] {
39
+ const prefix = " ".repeat(indent);
20
40
  const lines: string[] = [];
21
- lines.push(`[TASK LIST ${complete}/${total} tasks complete${skipped > 0 ? ` (${skipped} skipped)` : ""}]`);
22
- for (const task of goal.taskList.tasks) {
41
+ for (const task of tasks) {
23
42
  let suffix = "";
24
43
  if (task.status === "complete" && task.evidence) suffix = ` — ${task.evidence}`;
25
44
  if (task.status === "skipped" && task.skipReason) suffix = ` — skipped: ${task.skipReason}`;
26
- lines.push(` ${taskMarker(task.status)} ${task.id}: ${task.title}${suffix}`);
27
- if ((task.status === "pending") && task.verificationContract) {
28
- lines.push(` contract: ${task.verificationContract}`);
45
+ const lw = task.lightweightSubtasks ? " (lightweight)" : "";
46
+ lines.push(`${prefix}${taskMarker(task.status)} ${task.id}: ${task.title}${lw}${suffix}`);
47
+ if (task.status === "pending" && task.verificationContract) {
48
+ lines.push(`${prefix} contract: ${task.verificationContract}`);
49
+ }
50
+ if (task.subtasks && task.subtasks.length > 0) {
51
+ lines.push(...renderTaskTree(task.subtasks, indent + 1));
29
52
  }
30
53
  }
54
+ return lines;
55
+ }
56
+
57
+ export function taskListBlock(goal: GoalRecord, settings?: GoalSettings): string {
58
+ if (settings?.disableTasks) return "";
59
+ if (!goal.taskList || goal.taskList.tasks.length === 0) return "";
60
+ const { total, complete, skipped, pending } = countSubtree(goal.taskList.tasks);
61
+ const lines: string[] = [];
62
+ lines.push(`[TASK LIST — ${complete}/${total} tasks complete${skipped > 0 ? ` (${skipped} skipped)` : ""}]`);
63
+ lines.push(...renderTaskTree(goal.taskList.tasks, 0));
31
64
  if (goal.taskList.blockCompletion && pending.length > 0) {
32
65
  lines.push(` TASK GATE: do not call complete_goal while tasks remain in [ ] pending state`);
33
66
  }
@@ -41,7 +74,8 @@ export function taskListBlock(goal: GoalRecord): string {
41
74
  * Render a VERIFICATION CONTRACT section for the agent's prompts.
42
75
  * This is shown when the goal has a verificationContract defined.
43
76
  */
44
- export function verificationContractBlock(goal: GoalRecord): string {
77
+ export function verificationContractBlock(goal: GoalRecord, settings?: GoalSettings): string {
78
+ if (settings?.disableContracts) return "";
45
79
  if (!goal.verificationContract?.trim()) return "";
46
80
  return [
47
81
  "",
@@ -84,10 +118,10 @@ export function sisyphusDisciplineBlock(goal: GoalRecord): string {
84
118
  ].join("\n");
85
119
  }
86
120
 
87
- export function goalPrompt(goal: GoalRecord): string {
88
- const taskBlock = taskListBlock(goal);
121
+ export function goalPrompt(goal: GoalRecord, settings?: GoalSettings): string {
122
+ const taskBlock = taskListBlock(goal, settings);
89
123
  const taskInjection = taskBlock ? `\n${taskBlock}` : "";
90
- const contractBlock = verificationContractBlock(goal);
124
+ const contractBlock = verificationContractBlock(goal, settings);
91
125
  const contractInjection = contractBlock ? `\n${contractBlock}` : "";
92
126
  return `[PI GOAL ACTIVE goalId=${goal.id}]${taskInjection}${contractInjection}
93
127
  Status: ${statusLabel(goal)}
@@ -117,9 +151,9 @@ Do NOT silently invent workarounds, fake completion, or quietly redefine the obj
117
151
  Goal evolution: if the user gives requirements, feedback, or corrections that differ from the goal objective, the goal is stale. The goal objective is immutable — the agent must NOT modify it autonomously. Propose the updated objective concisely and ask the user to run /goal-tweak to revise it. Do NOT mark the goal complete with a stale objective.${sisyphusDisciplineBlock(goal) ? `\n${sisyphusDisciplineBlock(goal)}` : ""}`;
118
152
  }
119
153
 
120
- export function continuationPrompt(goal: GoalRecord): string {
121
- const taskBlock = taskListBlock(goal);
122
- const contractBlock = verificationContractBlock(goal);
154
+ export function continuationPrompt(goal: GoalRecord, settings?: GoalSettings): string {
155
+ const taskBlock = taskListBlock(goal, settings);
156
+ const contractBlock = verificationContractBlock(goal, settings);
123
157
  return [
124
158
  // Phase 5 C1: structured outer marker (pi-codex-goal pattern).
125
159
  `<pi_goal_continuation goal_id="${goal.id}" kind="checkpoint">`,
@@ -8,7 +8,8 @@ import {
8
8
  truncateText,
9
9
  type GoalDisplayRecordLike,
10
10
  } from "../goal-core.ts";
11
- import type { GoalTaskList, TaskStatus } from "../goal-record.ts";
11
+ import type { GoalTask, GoalTaskList, TaskStatus } from "../goal-record.ts";
12
+ import type { GoalSettings } from "../goal-settings.ts";
12
13
 
13
14
  type GoalWidgetColor = Extract<ThemeColor, "accent" | "warning" | "success" | "error" | "dim" | "muted" | "text">;
14
15
 
@@ -17,7 +18,7 @@ export interface GoalWidgetRecord extends GoalDisplayRecordLike {
17
18
  archivedPath?: string | null;
18
19
  pauseReason?: string;
19
20
  pauseSuggestedAction?: string;
20
- taskList?: { tasks: Array<{ id: string; title: string; status: TaskStatus }>; blockCompletion: boolean } | null;
21
+ taskList?: GoalTaskList | null;
21
22
  }
22
23
 
23
24
  export interface AuditorWidgetProgress {
@@ -39,6 +40,7 @@ export interface GoalWidgetOptions {
39
40
  getGoal: () => GoalWidgetRecord | null;
40
41
  getOpenGoalCount?: () => number;
41
42
  getAuditorProgress?: () => AuditorWidgetProgress | null;
43
+ getSettings?: () => GoalSettings;
42
44
  }
43
45
 
44
46
  function fit(value: string, width: number): string {
@@ -75,14 +77,38 @@ function displayIcon(goal: GoalWidgetRecord): { icon: string; color: GoalWidgetC
75
77
  return goal.autoContinue ? { icon: "●", color: "accent", label: "goal running" } : { icon: "○", color: "muted", label: "goal idle" };
76
78
  }
77
79
 
78
- function headingMeta(goal: GoalWidgetRecord, otherOpenGoalCount = 0): string {
80
+ function countFlatTasks(tasks: GoalTask[]): { total: number; done: number } {
81
+ let total = 0;
82
+ let done = 0;
83
+ for (const t of tasks) {
84
+ total++;
85
+ if (t.status === "complete" || t.status === "skipped") done++;
86
+ if (t.subtasks && t.subtasks.length > 0) {
87
+ const child = countFlatTasks(t.subtasks);
88
+ total += child.total;
89
+ done += child.done;
90
+ }
91
+ }
92
+ return { total, done };
93
+ }
94
+
95
+ function findFirstPending(tasks: GoalTask[]): GoalTask | undefined {
96
+ const queue = [...tasks];
97
+ while (queue.length > 0) {
98
+ const t = queue.shift()!;
99
+ if (t.status === "pending") return t;
100
+ if (t.subtasks) queue.push(...t.subtasks);
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ function headingMeta(goal: GoalWidgetRecord, otherOpenGoalCount = 0, disableTasks = false): string {
79
106
  const bits: string[] = [];
80
107
  if (goal.status === "active" && goal.autoContinue) bits.push("auto");
81
108
  if (goal.usage.activeSeconds > 0) bits.push(formatDuration(goal.usage.activeSeconds));
82
109
  if (goal.usage.tokensUsed > 0) bits.push(formatTokenValue(goal.usage.tokensUsed));
83
- if (goal.taskList && goal.taskList.tasks.length > 0) {
84
- const total = goal.taskList.tasks.length;
85
- const done = goal.taskList.tasks.filter((t) => t.status === "complete" || t.status === "skipped").length;
110
+ if (!disableTasks && goal.taskList && goal.taskList.tasks.length > 0) {
111
+ const { total, done } = countFlatTasks(goal.taskList.tasks);
86
112
  bits.push(`${done}/${total} tasks`);
87
113
  }
88
114
  if (otherOpenGoalCount > 0) bits.push(`+${otherOpenGoalCount} open`);
@@ -190,7 +216,7 @@ export function renderAuditorWidgetLines(progress: AuditorWidgetProgress, theme:
190
216
  return lines;
191
217
  }
192
218
 
193
- export function renderGoalWidgetLines(goal: GoalWidgetRecord | null, theme: Theme, width: number, options: { openGoalCount?: number; auditorProgress?: AuditorWidgetProgress | null } = {}): string[] {
219
+ export function renderGoalWidgetLines(goal: GoalWidgetRecord | null, theme: Theme, width: number, options: { openGoalCount?: number; auditorProgress?: AuditorWidgetProgress | null; disableTasks?: boolean } = {}): string[] {
194
220
  // When auditor progress is active, show auditor display instead of normal goal widget
195
221
  if (options.auditorProgress) {
196
222
  return renderAuditorWidgetLines(options.auditorProgress, theme, width);
@@ -209,7 +235,7 @@ export function renderGoalWidgetLines(goal: GoalWidgetRecord | null, theme: Them
209
235
  const mode = goal.sisyphus ? "Sisyphus" : "Goal";
210
236
  const headingLeft = `${theme.fg(color, icon)} ${theme.fg(color, theme.bold(mode))} ${theme.fg("muted", label.replace(/^sisyphus |^goal /, ""))}`;
211
237
  const otherOpenGoalCount = Math.max(0, (options.openGoalCount ?? (goal ? 1 : 0)) - 1);
212
- const headingRight = theme.fg("muted", headingMeta(goal, otherOpenGoalCount));
238
+ const headingRight = theme.fg("muted", headingMeta(goal, otherOpenGoalCount, options.disableTasks));
213
239
  const lines: string[] = [heading(theme, safeWidth, headingLeft, headingRight)];
214
240
  const body: string[] = [];
215
241
 
@@ -217,14 +243,15 @@ export function renderGoalWidgetLines(goal: GoalWidgetRecord | null, theme: Them
217
243
  const objective = truncateText(displayObjectiveTitle(goal.objective), titleWidth);
218
244
  body.push(`${theme.fg("accent", "⟡")} ${theme.fg("text", objective)}`);
219
245
 
220
- if (goal.taskList && goal.taskList.tasks.length > 0) {
221
- const total = goal.taskList.tasks.length;
222
- const done = goal.taskList.tasks.filter((t) => t.status === "complete" || t.status === "skipped").length;
223
- const pending = goal.taskList.tasks.filter((t) => t.status === "pending");
246
+ if (!options.disableTasks && goal.taskList && goal.taskList.tasks.length > 0) {
247
+ const { total, done } = countFlatTasks(goal.taskList.tasks);
224
248
  if (done === total) {
225
249
  body.push(`${theme.fg("success", "✓")} ${theme.fg("muted", "All tasks complete")}`);
226
- } else if (pending.length > 0) {
227
- body.push(`${theme.fg("warning", "◻")} ${theme.fg("muted", `${pending[0]!.id}: ${truncateText(pending[0]!.title, Math.max(8, safeWidth - 20))} (next)`)}`);
250
+ } else {
251
+ const firstPending = findFirstPending(goal.taskList.tasks);
252
+ if (firstPending) {
253
+ body.push(`${theme.fg("warning", "◻")} ${theme.fg("muted", `${firstPending.id}: ${truncateText(firstPending.title, Math.max(8, safeWidth - 20))} (next)`)}`);
254
+ }
228
255
  }
229
256
  }
230
257
 
@@ -254,12 +281,15 @@ export class GoalWidgetComponent implements Component {
254
281
  private getOpenGoalCount: () => number;
255
282
  private getAuditorProgress: () => AuditorWidgetProgress | null;
256
283
 
284
+ private getSettings: () => GoalSettings;
285
+
257
286
  constructor(options: GoalWidgetOptions) {
258
287
  this.theme = options.theme;
259
288
  this.tui = options.tui;
260
289
  this.getGoal = options.getGoal;
261
290
  this.getOpenGoalCount = options.getOpenGoalCount ?? (() => (this.getGoal() ? 1 : 0));
262
291
  this.getAuditorProgress = options.getAuditorProgress ?? (() => null);
292
+ this.getSettings = options.getSettings ?? (() => ({}));
263
293
  }
264
294
 
265
295
  update(): void {
@@ -267,9 +297,11 @@ export class GoalWidgetComponent implements Component {
267
297
  }
268
298
 
269
299
  render(width: number): string[] {
300
+ const settings = this.getSettings();
270
301
  return renderGoalWidgetLines(this.getGoal(), this.theme, width, {
271
302
  openGoalCount: this.getOpenGoalCount(),
272
303
  auditorProgress: this.getAuditorProgress(),
304
+ disableTasks: settings.disableTasks,
273
305
  });
274
306
  }
275
307
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",