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 +60 -15
- package/extensions/goal-auditor.ts +41 -13
- 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 +95 -0
- package/extensions/goal.ts +227 -62
- 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,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**:
|
|
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`
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
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
|
|
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,
|
|
@@ -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
|
+
}
|
package/extensions/goal.ts
CHANGED
|
@@ -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
|
-
|
|
195
|
-
|
|
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
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
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
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2426
|
-
|
|
2427
|
-
const
|
|
2428
|
-
|
|
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:
|
|
2432
|
-
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
|
-
|
|
2437
|
-
|
|
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
|
-
|
|
2451
|
-
const
|
|
2452
|
-
|
|
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
|
|
2538
|
-
const
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
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.
|
|
2551
|
-
|
|
2552
|
-
|
|
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.
|
|
2619
|
-
|
|
2620
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const 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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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?:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
227
|
-
|
|
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.
|
|
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",
|