pi-goal-x 0.13.0 → 0.15.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,13 +48,13 @@ 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
 
46
55
  - **Live progress widget** — when the auditor runs, the TUI shows a spinner, a progress bar (`[████░░░░] 40%`), step labels (`Inspecting files...`, `Verifying success criteria...`), the current tool being executed, and recent output lines. No more wondering if anything is happening.
47
56
  - **Escape to skip** — press Escape during an audit to abort it and complete the goal immediately. The skip is recorded in the ledger as `audit_skipped` with reason `user_aborted` and auditor model metadata.
48
- - **Disable the auditor entirely** — set `disabled: true` in `.pi/goal-auditor.json` (or toggle it via `/goal-settings` → `disabled`). The agent can still bypass with user confirmation by passing `confirmBypassAuditor: true` to `update_goal`.
57
+ - **Disable the auditor entirely** — set `disabled: true` in `.pi/pi-goal-x-settings.json` (or toggle it via `/goal-settings`). The agent can still bypass with user confirmation by passing `confirmBypassAuditor: true` to `update_goal`.
49
58
  - **Skipped audits are recorded** — every skip (whether disabled or Escape-aborted) is logged to the ledger with the reason, provider, model, and thinking level for full traceability.
50
59
  - **Robust abort detection** — the auditor detects aborts both from exceptions *and* from `session.prompt()` returning after an abort signal, preventing stuck goals or ghost states.
51
60
  - **Cleaner lifecycle** — `AbortSignal` is properly wired to `session.abort()`, animation timers are cleaned up, and the unsubscribe path is always executed. No more having to kill the session.
@@ -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/pi-goal-x-settings.json`, or interactively with `/goal-settings` (see [Configuration](#configuration)).
228
227
 
229
228
  The completion result prints a full report into the conversation:
230
229
 
@@ -272,11 +271,47 @@ 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
+ ## Configuration
275
+
276
+ All settings live in a single file: **`.pi/pi-goal-x-settings.json`**
277
+
278
+ Configured interactively via `/goal-settings`, or edited directly:
279
+
280
+ ```json
281
+ {
282
+ "disableTasks": false,
283
+ "disableContracts": false,
284
+ "subtaskDepth": 1,
285
+ "provider": "fireworks",
286
+ "model": "accounts/fireworks/models/deepseek-v4-flash",
287
+ "thinkingLevel": "high",
288
+ "disabled": false
289
+ }
290
+ ```
291
+
292
+ | Field | Default | Purpose |
293
+ |---|---:|---|
294
+ | `disableTasks` | `false` | Suppress task list features entirely when `true` |
295
+ | `disableContracts` | `false` | Suppress verification contract enforcement when `true` |
296
+ | `subtaskDepth` | `1` | Maximum nesting depth for subtasks |
297
+ | `provider` | system default | Provider name for the auditor agent |
298
+ | `model` | system default | Model name for the auditor agent |
299
+ | `thinkingLevel` | system default | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` |
300
+ | `disabled` | `false` | When `true`, skip the completion audit entirely |
301
+
302
+ **Env var overrides:**
303
+ - `PI_GOAL_DISABLE_TASKS=1` — disable task features (takes precedence over file)
304
+ - `PI_GOAL_DISABLE_CONTRACTS=1` — disable contract enforcement (takes precedence over file)
305
+ - `PI_GOAL_SETTINGS_FILE=custom-path.json` — alternative settings file path (relative to cwd or absolute)
306
+
275
307
  ## Environment variables
276
308
 
277
309
  | Variable | Default | Purpose |
278
310
  |---|---:|---|
279
311
  | `PI_GOAL_AUTO_CONFIRM` | unset | When `1`, auto-confirms drafts in headless/test contexts |
312
+ | `PI_GOAL_DISABLE_TASKS` | — | When `1`, disable task features (overrides settings file) |
313
+ | `PI_GOAL_DISABLE_CONTRACTS` | — | When `1`, disable contract enforcement (overrides settings file) |
314
+ | `PI_GOAL_SETTINGS_FILE` | `.pi/pi-goal-x-settings.json` | Alternative settings file path (relative to cwd or absolute) |
280
315
 
281
316
  ## Development
282
317
 
@@ -200,7 +200,7 @@ Before archiving, the tool starts a separate in-memory pi session with a focused
200
200
  - `<approved/>` allows archiving;
201
201
  - `<disapproved/>`, no marker, an error, or abort rejects completion and leaves the goal open.
202
202
 
203
- The auditor uses the current/default model unless `.pi/goal-auditor.json` or `PI_GOAL_AUDITOR_PROVIDER`, `PI_GOAL_AUDITOR_MODEL`, and `PI_GOAL_AUDITOR_THINKING_LEVEL` override provider/model/thinking. `/goal-settings` opens a small UI menu with an `auditor` item; inside it, `provider`, `model`, and `thinking_level` each open a free-text input and save back to `.pi/goal-auditor.json`.
203
+ The auditor uses the current/default model unless `.pi/pi-goal-x-settings.json` overrides `provider`, `model`, or `thinkingLevel`. `/goal-settings` opens a TUI showing all settings (disabled, provider, model, thinking_level, subtaskDepth, disableTasks, disableContracts); each editable field opens a free-text input and saves back to `.pi/pi-goal-x-settings.json`.
204
204
 
205
205
  The user sees:
206
206
 
@@ -13,14 +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";
17
-
18
- export interface GoalAuditorConfig {
19
- provider?: string;
20
- model?: string;
21
- thinkingLevel?: ThinkingLevel;
22
- disabled?: boolean;
23
- }
16
+ import type { GoalRecord, GoalTask, GoalTaskList } from "./goal-record.ts";
17
+ import { loadGoalSettings, type GoalSettings } from "./goal-settings.ts";
24
18
 
25
19
  export interface AuditorProgress {
26
20
  /** Current tool being executed by the auditor, if any */
@@ -54,10 +48,6 @@ export interface GoalAuditorResult {
54
48
 
55
49
  const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh"]);
56
50
 
57
- export function goalAuditorConfigPath(cwd: string): string {
58
- return path.join(cwd, ".pi", "goal-auditor.json");
59
- }
60
-
61
51
  function asNonEmptyString(value: unknown): string | undefined {
62
52
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
63
53
  }
@@ -67,59 +57,7 @@ function asThinkingLevel(value: unknown): ThinkingLevel | undefined {
67
57
  return text && THINKING_LEVELS.has(text) ? text as ThinkingLevel : undefined;
68
58
  }
69
59
 
70
- export function parseGoalAuditorConfig(raw: unknown): GoalAuditorConfig {
71
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
72
- const record = raw as Record<string, unknown>;
73
- const config: GoalAuditorConfig = {};
74
- const provider = asNonEmptyString(record.provider);
75
- const model = asNonEmptyString(record.model);
76
- const thinkingLevel = asThinkingLevel(record.thinkingLevel ?? record.thinking_level);
77
- if (provider) config.provider = provider;
78
- if (model) config.model = model;
79
- if (thinkingLevel) config.thinkingLevel = thinkingLevel;
80
- if (record.disabled === true || record.disabled === "true") config.disabled = true;
81
- return config;
82
- }
83
-
84
- export function loadGoalAuditorFileConfig(cwd: string): GoalAuditorConfig {
85
- try {
86
- const configPath = goalAuditorConfigPath(cwd);
87
- if (fs.existsSync(configPath)) return parseGoalAuditorConfig(JSON.parse(fs.readFileSync(configPath, "utf8")));
88
- } catch {
89
- return {};
90
- }
91
- return {};
92
- }
93
60
 
94
- export function loadGoalAuditorConfig(cwd: string, env: NodeJS.ProcessEnv = process.env): GoalAuditorConfig {
95
- const fileConfig = loadGoalAuditorFileConfig(cwd);
96
- return {
97
- ...fileConfig,
98
- provider: asNonEmptyString(env.PI_GOAL_AUDITOR_PROVIDER) ?? fileConfig.provider,
99
- model: asNonEmptyString(env.PI_GOAL_AUDITOR_MODEL) ?? fileConfig.model,
100
- thinkingLevel: asThinkingLevel(env.PI_GOAL_AUDITOR_THINKING_LEVEL ?? env.PI_GOAL_AUDITOR_THINKING) ?? fileConfig.thinkingLevel,
101
- };
102
- }
103
-
104
- export function saveGoalAuditorFileConfig(cwd: string, config: GoalAuditorConfig): GoalAuditorConfig {
105
- const clean: GoalAuditorConfig = {};
106
- const provider = asNonEmptyString(config.provider);
107
- const model = asNonEmptyString(config.model);
108
- const thinkingLevel = asThinkingLevel(config.thinkingLevel);
109
- if (provider) clean.provider = provider;
110
- if (model) clean.model = model;
111
- if (thinkingLevel) clean.thinkingLevel = thinkingLevel;
112
- if (config.disabled === true) clean.disabled = true;
113
- const configPath = goalAuditorConfigPath(cwd);
114
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
115
- const persisted: Record<string, unknown> = {};
116
- if (clean.provider) persisted.provider = clean.provider;
117
- if (clean.model) persisted.model = clean.model;
118
- if (clean.thinkingLevel) persisted.thinking_level = clean.thinkingLevel;
119
- if (clean.disabled) persisted.disabled = true;
120
- fs.writeFileSync(configPath, `${JSON.stringify(persisted, null, 2)}\n`, "utf8");
121
- return clean;
122
- }
123
61
 
124
62
  export function parseAuditorDecision(output: string): { approved: boolean; disapproved: boolean } {
125
63
  const approved = /<approved\s*\/>/.test(output);
@@ -134,18 +72,43 @@ export interface AuditorVerificationEvidence {
134
72
  contract?: string;
135
73
  }
136
74
 
75
+ function renderAuditorTaskTree(tasks: GoalTask[], indent: number): string[] {
76
+ const prefix = " ".repeat(indent);
77
+ const lines: string[] = [];
78
+ for (const task of tasks) {
79
+ const marker = task.status === "complete" ? "[x]" : task.status === "skipped" ? "[~]" : "[ ]";
80
+ lines.push(`${prefix}${marker} ${task.id}: ${task.title}`);
81
+ if (task.subtasks && task.subtasks.length > 0) {
82
+ lines.push(...renderAuditorTaskTree(task.subtasks, indent + 1));
83
+ }
84
+ }
85
+ return lines;
86
+ }
87
+
88
+ function countAuditorTasks(tasks: GoalTask[]): { total: number; complete: number; skipped: number; pending: number } {
89
+ let total = 0;
90
+ let complete = 0;
91
+ let skipped = 0;
92
+ for (const t of tasks) {
93
+ total++;
94
+ if (t.status === "complete") complete++;
95
+ else if (t.status === "skipped") skipped++;
96
+ if (t.subtasks && t.subtasks.length > 0) {
97
+ const child = countAuditorTasks(t.subtasks);
98
+ total += child.total;
99
+ complete += child.complete;
100
+ skipped += child.skipped;
101
+ }
102
+ }
103
+ return { total, complete, skipped, pending: total - complete - skipped };
104
+ }
105
+
137
106
  function taskSummaryBlock(taskList?: GoalTaskList | null): string {
138
107
  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");
108
+ const { total, complete, skipped, pending } = countAuditorTasks(taskList.tasks);
143
109
  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" : "";
110
+ lines.push(...renderAuditorTaskTree(taskList.tasks, 0));
111
+ const gate = taskList.blockCompletion && pending > 0 ? " | TASK GATE: pending tasks block completion" : "";
149
112
  lines[0] = lines[0]! + gate;
150
113
  return lines.join("\n");
151
114
  }
@@ -155,6 +118,7 @@ export function buildGoalAuditorPrompt(args: {
155
118
  completionSummary?: string | null;
156
119
  detailedSummary: string;
157
120
  verificationSummary?: string | null;
121
+ settings?: GoalSettings;
158
122
  }): string {
159
123
  return [
160
124
  "You are the independent completion auditor for pi-goal.",
@@ -180,7 +144,7 @@ export function buildGoalAuditorPrompt(args: {
180
144
  "Current goal metadata:",
181
145
  "<goal_details>",
182
146
  args.detailedSummary,
183
- ...(taskSummaryBlock(args.goal.taskList) ? ["", taskSummaryBlock(args.goal.taskList)] : []),
147
+ ...(!args.settings?.disableTasks && taskSummaryBlock(args.goal.taskList) ? ["", taskSummaryBlock(args.goal.taskList)] : []),
184
148
  "</goal_details>",
185
149
  ...(args.verificationSummary?.trim() ? [
186
150
  "",
@@ -189,7 +153,7 @@ export function buildGoalAuditorPrompt(args: {
189
153
  args.verificationSummary.trim(),
190
154
  "</verification_summary>",
191
155
  ] : []),
192
- ...(args.goal.verificationContract?.trim() ? [
156
+ ...(!args.settings?.disableContracts && args.goal.verificationContract?.trim() ? [
193
157
  "",
194
158
  "Goal verification contract (what the executor was required to verify):",
195
159
  "<verification_contract>",
@@ -204,7 +168,7 @@ export function buildGoalAuditorPrompt(args: {
204
168
  ...(args.verificationSummary?.trim()
205
169
  ? ["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
170
  : []),
207
- ...(args.goal.verificationContract?.trim()
171
+ ...(!args.settings?.disableContracts && args.goal.verificationContract?.trim()
208
172
  ? ["4. Verify that the executor has satisfied every item in the <verification_contract>. If any item is missing or weakly addressed, disapprove."]
209
173
  : []),
210
174
  "5. Explain missing or weak evidence, especially scaffold-vs-final quality gaps.",
@@ -255,7 +219,7 @@ function makeAuditorResourceLoader(): ResourceLoader {
255
219
  };
256
220
  }
257
221
 
258
- function resolveAuditorModel(ctx: ExtensionContext, config: GoalAuditorConfig): { model: Model<any> | undefined; error?: string } {
222
+ function resolveAuditorModel(ctx: ExtensionContext, config: GoalSettings): { model: Model<any> | undefined; error?: string } {
259
223
  if (!config.model && !config.provider) return { model: ctx.model };
260
224
  if (config.provider && config.model) {
261
225
  const model = ctx.modelRegistry.find(config.provider, config.model);
@@ -288,6 +252,7 @@ export async function runGoalCompletionAuditor(args: {
288
252
  completionSummary?: string | null;
289
253
  detailedSummary: string;
290
254
  verificationSummary?: string | null;
255
+ settings?: GoalSettings;
291
256
  signal?: AbortSignal;
292
257
  onProgress?: AuditorProgressCallback;
293
258
  /**
@@ -297,7 +262,7 @@ export async function runGoalCompletionAuditor(args: {
297
262
  */
298
263
  createSession?: typeof createAgentSession;
299
264
  }): Promise<GoalAuditorResult> {
300
- const config = loadGoalAuditorConfig(args.ctx.cwd);
265
+ const config = loadGoalSettings(args.ctx.cwd);
301
266
  const resolved = resolveAuditorModel(args.ctx, config);
302
267
  const model = resolved.model;
303
268
  const thinkingLevel = config.thinkingLevel;
@@ -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,