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 +51 -16
- package/docs/architecture.md +1 -1
- package/extensions/goal-auditor.ts +43 -78
- package/extensions/goal-policy.ts +128 -8
- package/extensions/goal-questionnaire.ts +7 -0
- package/extensions/goal-record.ts +40 -20
- package/extensions/goal-settings.ts +173 -0
- package/extensions/goal.ts +304 -127
- package/extensions/prompts/goal-prompts.ts +53 -19
- package/extensions/widgets/goal-widget.ts +46 -14
- package/package.json +1 -1
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
|
-
###
|
|
22
|
+
### Unified goal + task acceptance
|
|
23
23
|
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
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**:
|
|
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-
|
|
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
|
|
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
|
|
package/docs/architecture.md
CHANGED
|
@@ -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-
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
|
142
|
-
if (pending
|
|
143
|
-
return `${pending
|
|
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:
|
|
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(
|
|
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
|
-
|
|
174
|
-
|
|
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,
|