@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.1
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/LICENSE +21 -0
- package/README.md +131 -2
- package/assets/badge-dark.svg +18 -0
- package/assets/badge-light.svg +18 -0
- package/docs/advanced-usage.md +106 -0
- package/docs/agent-flow-design.md +306 -0
- package/docs/agentic-runtime-prd.md +773 -0
- package/docs/architecture.md +368 -0
- package/docs/controls.md +140 -0
- package/docs/pi-autoresearch-survey.md +50 -0
- package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
- package/docs/prompt-caching.md +31 -0
- package/docs/unified-dashboard.md +286 -0
- package/extensions/auditor-selector.ts +74 -0
- package/extensions/goal-accounting.ts +107 -0
- package/extensions/goal-activity.ts +202 -0
- package/extensions/goal-auditor.ts +480 -0
- package/extensions/goal-commands.ts +866 -0
- package/extensions/goal-compaction.ts +197 -0
- package/extensions/goal-completion.ts +173 -0
- package/extensions/goal-context.ts +12 -0
- package/extensions/goal-contract.ts +58 -0
- package/extensions/goal-controls.ts +202 -0
- package/extensions/goal-core-tools.ts +549 -0
- package/extensions/goal-core.ts +78 -0
- package/extensions/goal-detail.ts +65 -0
- package/extensions/goal-dialog-controls.ts +31 -0
- package/extensions/goal-draft.ts +244 -0
- package/extensions/goal-drafting.ts +535 -0
- package/extensions/goal-events.ts +692 -0
- package/extensions/goal-format.ts +376 -0
- package/extensions/goal-interruption.ts +53 -0
- package/extensions/goal-ledger-index.ts +68 -0
- package/extensions/goal-ledger.ts +1007 -0
- package/extensions/goal-live-retention.ts +239 -0
- package/extensions/goal-oracle.ts +346 -0
- package/extensions/goal-policy.ts +370 -0
- package/extensions/goal-pool.ts +103 -0
- package/extensions/goal-prompt-cache.ts +80 -0
- package/extensions/goal-questionnaire.ts +1073 -0
- package/extensions/goal-record.ts +388 -0
- package/extensions/goal-recovery.ts +256 -0
- package/extensions/goal-runtime.ts +277 -0
- package/extensions/goal-scheduler-state.ts +100 -0
- package/extensions/goal-scheduler.ts +436 -0
- package/extensions/goal-service.ts +843 -0
- package/extensions/goal-session-health.ts +130 -0
- package/extensions/goal-session-safety.ts +65 -0
- package/extensions/goal-settings.ts +1370 -0
- package/extensions/goal-state.ts +1069 -0
- package/extensions/goal-status.ts +305 -0
- package/extensions/goal-task-confirmation.ts +174 -0
- package/extensions/goal-task-count.ts +59 -0
- package/extensions/goal-task-derive.ts +46 -0
- package/extensions/goal-task-index.ts +57 -0
- package/extensions/goal-task-tools.ts +595 -0
- package/extensions/goal-tool-inputs.ts +102 -0
- package/extensions/goal-tool-names.ts +73 -0
- package/extensions/goal-tools.ts +16 -0
- package/extensions/goal-widget.ts +455 -0
- package/extensions/goal.ts +42 -0
- package/extensions/network-error-backoff.ts +55 -0
- package/extensions/prompts/goal-prompts.ts +330 -0
- package/extensions/storage/atomic-rename.ts +52 -0
- package/extensions/storage/goal-files.ts +695 -0
- package/extensions/storage/goal-lock.ts +131 -0
- package/extensions/storage/goal-root.ts +95 -0
- package/extensions/widgets/auditor-dashboard-model.ts +174 -0
- package/extensions/widgets/dialog-scaffold.ts +26 -0
- package/extensions/widgets/goal-controls-dialog.ts +329 -0
- package/extensions/widgets/goal-dashboard-model.ts +530 -0
- package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
- package/extensions/widgets/goal-escape-dialog.ts +148 -0
- package/extensions/widgets/goal-notifications.ts +9 -0
- package/extensions/widgets/goal-widget.ts +723 -0
- package/extensions/widgets/task-list-overlay.ts +409 -0
- package/extensions/widgets/text-cache.ts +30 -0
- package/package.json +117 -4
- package/scripts/recover-session-checkpoints.mjs +289 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task-tool support for the Stage 4 consolidation:
|
|
3
|
+
* flat parent-linked `set_goal_tasks` input → recursive GoalTask[] tree,
|
|
4
|
+
* with the same validation rules the recursive path enforced (unique ids,
|
|
5
|
+
* non-empty titles, existing parents, acyclic, ≤50 tasks, configured depth,
|
|
6
|
+
* valid lightweight-subtask placement), plus id-stable merging that preserves
|
|
7
|
+
* status/evidence/timestamps for matching ids.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { StringEnum, Type } from "@earendil-works/pi-ai";
|
|
11
|
+
import { defineTool, type AgentToolResult, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
import type { GoalTaskUpdateSpec } from "./goal-service.ts";
|
|
15
|
+
import { goalDetails, renderGoalResult } from "./goal-format.ts";
|
|
16
|
+
import { statusLabel, truncateText } from "./goal-core.ts";
|
|
17
|
+
import { loadGoalSettings } from "./goal-settings.ts";
|
|
18
|
+
import { buildTaskSummary, checkSubtasksComplete, findSubtaskDepthViolation, findTaskInTree, skipAllSubtasks } from "./goal-policy.ts";
|
|
19
|
+
import { showTaskConfirmation, type TaskConfirmationResult } from "./goal-task-confirmation.ts";
|
|
20
|
+
import { normalizeTaskProgress, type TaskProgressInput } from "./goal-tool-inputs.ts";
|
|
21
|
+
export type { TaskProgressInput } from "./goal-tool-inputs.ts";
|
|
22
|
+
import {
|
|
23
|
+
SET_GOAL_TASKS_TOOL_NAME,
|
|
24
|
+
UPDATE_GOAL_TASK_TOOL_NAME,
|
|
25
|
+
} from "./goal-tool-names.ts";
|
|
26
|
+
import { nowIso, currentTaskIdIsPending, type GoalTask, type GoalTaskList } from "./goal-record.ts";
|
|
27
|
+
|
|
28
|
+
export const MAX_TASKS = 50;
|
|
29
|
+
|
|
30
|
+
export interface FlatTaskInput {
|
|
31
|
+
id: string;
|
|
32
|
+
title: string;
|
|
33
|
+
parent_id?: string;
|
|
34
|
+
verification_contract?: string;
|
|
35
|
+
lightweight_subtasks?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FlatTaskListInput {
|
|
39
|
+
tasks: FlatTaskInput[];
|
|
40
|
+
block_completion?: boolean;
|
|
41
|
+
change_summary?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type FlatTaskConversion =
|
|
45
|
+
| { ok: true; tasks: GoalTask[] }
|
|
46
|
+
| { ok: false; message: string };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convert a flat parent-linked task list into the recursive GoalTask[]
|
|
50
|
+
* representation, validating:
|
|
51
|
+
* - non-empty unique ids and titles;
|
|
52
|
+
* - parent_id references an existing task in the same input;
|
|
53
|
+
* - acyclic parent relationships;
|
|
54
|
+
* - at most MAX_TASKS tasks total;
|
|
55
|
+
* - subtask depth within maxDepth (subtaskDepth setting, default 1);
|
|
56
|
+
* - lightweight_subtasks is only set on tasks that actually have children.
|
|
57
|
+
*/
|
|
58
|
+
export function convertFlatTasks(flat: FlatTaskInput[], opts: { maxSubtaskDepth?: number } = {}): FlatTaskConversion {
|
|
59
|
+
if (!Array.isArray(flat)) return { ok: false, message: "tasks must be an array." };
|
|
60
|
+
if (flat.length > MAX_TASKS) return { ok: false, message: `Task list cannot exceed ${MAX_TASKS} tasks.` };
|
|
61
|
+
|
|
62
|
+
const ids = new Set<string>();
|
|
63
|
+
for (const item of flat) {
|
|
64
|
+
const id = typeof item.id === "string" ? item.id.trim() : "";
|
|
65
|
+
if (!id) return { ok: false, message: "All tasks must have a non-empty id." };
|
|
66
|
+
if (ids.has(id)) return { ok: false, message: `Duplicate task id: "${id}".` };
|
|
67
|
+
ids.add(id);
|
|
68
|
+
const title = typeof item.title === "string" ? item.title.trim() : "";
|
|
69
|
+
if (!title) return { ok: false, message: `Task "${id}" must have a non-empty title.` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Parent must exist and relationships must be acyclic.
|
|
73
|
+
const byId = new Map<string, FlatTaskInput>(flat.map((item) => [item.id.trim(), item]));
|
|
74
|
+
for (const item of flat) {
|
|
75
|
+
const parentId = typeof item.parent_id === "string" && item.parent_id.trim() ? item.parent_id.trim() : undefined;
|
|
76
|
+
if (parentId && !byId.has(parentId)) {
|
|
77
|
+
return { ok: false, message: `Task "${item.id.trim()}" references missing parent "${parentId}".` };
|
|
78
|
+
}
|
|
79
|
+
if (parentId) {
|
|
80
|
+
// Walk up; if we return to the node itself, there is a cycle.
|
|
81
|
+
let cursor: FlatTaskInput | undefined = byId.get(parentId);
|
|
82
|
+
const seen = new Set<string>([item.id.trim()]);
|
|
83
|
+
while (cursor) {
|
|
84
|
+
if (seen.has(cursor.id.trim())) {
|
|
85
|
+
return { ok: false, message: `Cyclic parent relationship involving task "${cursor.id.trim()}".` };
|
|
86
|
+
}
|
|
87
|
+
seen.add(cursor.id.trim());
|
|
88
|
+
const up = typeof cursor.parent_id === "string" && cursor.parent_id.trim() ? cursor.parent_id.trim() : undefined;
|
|
89
|
+
cursor = up ? byId.get(up) : undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Build the tree.
|
|
95
|
+
const childrenOf = new Map<string, FlatTaskInput[]>();
|
|
96
|
+
const roots: FlatTaskInput[] = [];
|
|
97
|
+
for (const item of flat) {
|
|
98
|
+
const parentId = typeof item.parent_id === "string" && item.parent_id.trim() ? item.parent_id.trim() : undefined;
|
|
99
|
+
if (parentId) {
|
|
100
|
+
const siblings = childrenOf.get(parentId) ?? [];
|
|
101
|
+
siblings.push(item);
|
|
102
|
+
childrenOf.set(parentId, siblings);
|
|
103
|
+
} else {
|
|
104
|
+
roots.push(item);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildNode(item: FlatTaskInput): GoalTask {
|
|
109
|
+
const node: GoalTask = {
|
|
110
|
+
id: item.id.trim(),
|
|
111
|
+
title: item.title.trim(),
|
|
112
|
+
status: "pending",
|
|
113
|
+
verificationContract: typeof item.verification_contract === "string" && item.verification_contract.trim()
|
|
114
|
+
? item.verification_contract.trim()
|
|
115
|
+
: undefined,
|
|
116
|
+
lightweightSubtasks: item.lightweight_subtasks === true ? true : undefined,
|
|
117
|
+
};
|
|
118
|
+
const children = childrenOf.get(node.id) ?? [];
|
|
119
|
+
if (children.length > 0) {
|
|
120
|
+
node.subtasks = children.map(buildNode);
|
|
121
|
+
}
|
|
122
|
+
return node;
|
|
123
|
+
}
|
|
124
|
+
// Both buckets were populated in input order, so sorting would repeat that work.
|
|
125
|
+
const tasks = roots.map(buildNode);
|
|
126
|
+
|
|
127
|
+
// Lightweight placement: lightweight_subtasks must be on a task with children.
|
|
128
|
+
for (const item of flat) {
|
|
129
|
+
if (item.lightweight_subtasks === true) {
|
|
130
|
+
const children = childrenOf.get(item.id.trim());
|
|
131
|
+
if (!children || children.length === 0) {
|
|
132
|
+
return { ok: false, message: `Task "${item.id.trim()}" sets lightweight_subtasks but has no subtasks.` };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const maxDepth = opts.maxSubtaskDepth ?? 1;
|
|
138
|
+
const depthViolation = findSubtaskDepthViolation(tasks, maxDepth);
|
|
139
|
+
if (depthViolation) return { ok: false, message: depthViolation };
|
|
140
|
+
|
|
141
|
+
return { ok: true, tasks };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Merge converted tasks into an existing tree. Matching ids preserve runtime
|
|
146
|
+
* progress ONLY (status, evidence, completion/skip timestamps, skip reason);
|
|
147
|
+
* incoming structural fields are authoritative and omission clears them
|
|
148
|
+
* (verification contract, lightweight flag, parentage, child structure).
|
|
149
|
+
*/
|
|
150
|
+
export function mergeTasksWithExisting(existing: GoalTask[] | undefined, incoming: GoalTask[]): GoalTask[] {
|
|
151
|
+
const existingById = new Map<string, GoalTask>();
|
|
152
|
+
function index(tasks: GoalTask[]): void {
|
|
153
|
+
for (const t of tasks) {
|
|
154
|
+
existingById.set(t.id, t);
|
|
155
|
+
if (t.subtasks) index(t.subtasks);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
index(existing ?? []);
|
|
159
|
+
|
|
160
|
+
function mergeTask(input: GoalTask): GoalTask {
|
|
161
|
+
const prior = existingById.get(input.id);
|
|
162
|
+
const progress: Pick<GoalTask, "status" | "evidence" | "completedAt" | "skippedAt" | "skipReason"> = prior
|
|
163
|
+
? {
|
|
164
|
+
status: prior.status,
|
|
165
|
+
evidence: prior.evidence,
|
|
166
|
+
completedAt: prior.completedAt,
|
|
167
|
+
skippedAt: prior.skippedAt,
|
|
168
|
+
skipReason: prior.skipReason,
|
|
169
|
+
}
|
|
170
|
+
: { status: "pending" };
|
|
171
|
+
const base: GoalTask = {
|
|
172
|
+
id: input.id,
|
|
173
|
+
title: input.title,
|
|
174
|
+
// Structural fields are authoritative; undefined (omitted) clears.
|
|
175
|
+
verificationContract: input.verificationContract,
|
|
176
|
+
lightweightSubtasks: input.lightweightSubtasks,
|
|
177
|
+
...progress,
|
|
178
|
+
};
|
|
179
|
+
if (input.subtasks && input.subtasks.length > 0) {
|
|
180
|
+
base.subtasks = input.subtasks.map((child) => mergeTask(child));
|
|
181
|
+
} else if (prior?.subtasks) {
|
|
182
|
+
// Structural removal of all children for this id.
|
|
183
|
+
delete base.subtasks;
|
|
184
|
+
}
|
|
185
|
+
return base;
|
|
186
|
+
}
|
|
187
|
+
return incoming.map(mergeTask);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Count every node in a task tree (roots + all descendants). */
|
|
191
|
+
export function countTasks(tasks: readonly GoalTask[] | undefined): number {
|
|
192
|
+
if (!tasks) return 0;
|
|
193
|
+
let total = 0;
|
|
194
|
+
function walk(list: readonly GoalTask[]): void {
|
|
195
|
+
for (const t of list) {
|
|
196
|
+
total += 1;
|
|
197
|
+
if (t.subtasks) walk(t.subtasks);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
walk(tasks);
|
|
201
|
+
return total;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function progressSpec(input: TaskProgressInput, core: import("./goal-state.ts").GoalCore, ctx: ExtensionContext): GoalTaskUpdateSpec {
|
|
205
|
+
const settings = loadGoalSettings(ctx.cwd);
|
|
206
|
+
const now = nowIso();
|
|
207
|
+
const evidence = input.evidence?.trim().slice(0, 200) || undefined;
|
|
208
|
+
const reason = input.reason?.trim();
|
|
209
|
+
return {
|
|
210
|
+
taskId: input.task_id, focusToken: core.focusedOperationToken(core.state.goal!.id),
|
|
211
|
+
...(input.status === "start" ? {setCurrentTaskId: input.task_id} : {}),
|
|
212
|
+
validate: task => {
|
|
213
|
+
if (input.status === "start" && task.status !== "pending") return {ok: false, message: `Task "${task.id}" is ${task.status}; only pending tasks can be started.`};
|
|
214
|
+
if (input.status !== "start" && task.status === "complete") return {ok: false, message: `Task "${task.id}" is already complete and cannot be reopened.`};
|
|
215
|
+
if (input.status === "complete") {
|
|
216
|
+
if (task.status === "skipped") return {ok: false, message: `Task "${task.id}" was already skipped.`};
|
|
217
|
+
if (!settings.disableContracts && task.verificationContract && !evidence) return {ok: false, message: `Task "${task.id}" has a verification contract; provide evidence to complete it.`};
|
|
218
|
+
const gate = checkSubtasksComplete(task);
|
|
219
|
+
if (gate) return {ok: false, message: gate};
|
|
220
|
+
}
|
|
221
|
+
if (input.status === "skipped" && !reason) return {ok: false, message: "status=skipped requires a non-empty reason."};
|
|
222
|
+
if (input.status === "pending" && task.status !== "skipped") return {ok: false, message: "Only skipped tasks can be reopened with status=pending."};
|
|
223
|
+
return {ok: true};
|
|
224
|
+
},
|
|
225
|
+
update: task => {
|
|
226
|
+
if (input.status === "start") return task;
|
|
227
|
+
if (input.status === "complete") return {...task, status: "complete", completedAt: now, evidence};
|
|
228
|
+
if (input.status === "skipped") {
|
|
229
|
+
const next: GoalTask = {...task, status: "skipped", skippedAt: now, skipReason: reason};
|
|
230
|
+
return task.subtasks?.length && !task.lightweightSubtasks ? skipAllSubtasks(next, now, reason!) : next;
|
|
231
|
+
}
|
|
232
|
+
const {skippedAt, skipReason, ...rest} = task;
|
|
233
|
+
return {...rest, status: "pending"};
|
|
234
|
+
},
|
|
235
|
+
ledger: written => {
|
|
236
|
+
const base = {goalId: written.id, taskId: input.task_id, at: written.updatedAt};
|
|
237
|
+
if (input.status === "start") return [{...base, type: "task_started"}];
|
|
238
|
+
if (input.status === "complete") return [{...base, type: "task_complete", evidence}];
|
|
239
|
+
if (input.status === "skipped") return [{...base, type: "task_skipped", reason: reason!}];
|
|
240
|
+
return [{...base, type: "task_reopened"}];
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Tool registration (moved from goal-tools.ts in the Stage 5 module split) ─
|
|
246
|
+
|
|
247
|
+
export function registerTaskTools(core: import("./goal-state.ts").GoalCore): void {
|
|
248
|
+
const { pi } = core;
|
|
249
|
+
|
|
250
|
+
// ── set_goal_tasks: flat parent-linked structural task-tree tool ───────────
|
|
251
|
+
pi.registerTool(defineTool({
|
|
252
|
+
name: SET_GOAL_TASKS_TOOL_NAME,
|
|
253
|
+
label: "Set Goal Tasks",
|
|
254
|
+
description: "Set or restructure the task tree with user confirmation. Matching IDs retain progress; removed IDs are deleted. Confirmation stops the turn; continuation follows.",
|
|
255
|
+
promptSnippet: "Set the goal task tree with confirmation.",
|
|
256
|
+
promptGuidelines: ["Use tasks only for useful milestones. Restructure an existing tree only when the user asks or requirements structurally change. Input is a flat parent-linked tree, at most 50 tasks, within configured depth. lightweight_subtasks is valid only on parents."],
|
|
257
|
+
parameters: Type.Object({
|
|
258
|
+
tasks: Type.Array(Type.Object({
|
|
259
|
+
id: Type.String({ description: "Short stable slug e.g. 'task-1'" }),
|
|
260
|
+
title: Type.String({ description: "Human-readable task title" }),
|
|
261
|
+
parent_id: Type.Optional(Type.String({ description: "Parent id; omit for roots." })),
|
|
262
|
+
verification_contract: Type.Optional(Type.String({ description: "Required completion evidence." })),
|
|
263
|
+
lightweight_subtasks: Type.Optional(Type.Boolean({ description: "Children do not gate parent completion." })),
|
|
264
|
+
}), { description: "Flat parent-linked task list" }),
|
|
265
|
+
block_completion: Type.Optional(Type.Boolean({ description: "Require all tasks resolved; default false." })),
|
|
266
|
+
change_summary: Type.Optional(Type.String({ description: "Optional summary of the task list change" })),
|
|
267
|
+
}, { additionalProperties: false }),
|
|
268
|
+
executionMode: "sequential",
|
|
269
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
270
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
271
|
+
if (!core.state.goal) {
|
|
272
|
+
return {
|
|
273
|
+
content: [{ type: "text", text: "No goal is set; set_goal_tasks requires a focused active or paused goal." }],
|
|
274
|
+
details: goalDetails(core.state.goal),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (loadGoalSettings(ctx.cwd).disableTasks) {
|
|
278
|
+
return {
|
|
279
|
+
content: [{ type: "text", text: "set_goal_tasks is disabled by settings (disableTasks: true)." }],
|
|
280
|
+
details: goalDetails(core.state.goal),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (core.state.goal.status !== "active" && core.state.goal.status !== "paused") {
|
|
284
|
+
return {
|
|
285
|
+
content: [{ type: "text", text: `set_goal_tasks applies to an active or paused goal; this goal is ${statusLabel(core.state.goal)}.` }],
|
|
286
|
+
details: goalDetails(core.state.goal),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const settings = loadGoalSettings(ctx.cwd);
|
|
290
|
+
const converted = convertFlatTasks(params.tasks as FlatTaskInput[], { maxSubtaskDepth: settings.subtaskDepth });
|
|
291
|
+
if (!converted.ok) {
|
|
292
|
+
return {
|
|
293
|
+
content: [{ type: "text", text: converted.message }],
|
|
294
|
+
details: goalDetails(core.state.goal),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const blockCompletion = params.block_completion === true;
|
|
298
|
+
const now = nowIso();
|
|
299
|
+
|
|
300
|
+
// Render the proposed STRUCTURAL tree for the confirmation dialog.
|
|
301
|
+
// Progress merge happens inside GoalService.apply against the
|
|
302
|
+
// disk-refreshed clone.
|
|
303
|
+
function renderTaskLines(tasks: GoalTask[], indent = 0): string[] {
|
|
304
|
+
const prefix = " ".repeat(indent);
|
|
305
|
+
const lines: string[] = [];
|
|
306
|
+
for (const t of tasks) {
|
|
307
|
+
const marker = t.status === "complete" ? "[x]" : t.status === "skipped" ? "[~]" : "[ ]";
|
|
308
|
+
const lw = t.lightweightSubtasks ? " (lightweight)" : "";
|
|
309
|
+
lines.push(`${prefix}${marker} ${t.id}: ${t.title}${lw}`);
|
|
310
|
+
if (t.subtasks && t.subtasks.length > 0) {
|
|
311
|
+
lines.push(...renderTaskLines(t.subtasks, indent + 1));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return lines;
|
|
315
|
+
}
|
|
316
|
+
const taskLines = renderTaskLines(converted.tasks);
|
|
317
|
+
const gateLabel = blockCompletion ? " (blockCompletion enabled)" : "";
|
|
318
|
+
const proposalText = [`Proposed task list${gateLabel}:`, "", ...taskLines].join("\n");
|
|
319
|
+
const taskListFocus = core.focusedOperationToken(core.state.goal.id);
|
|
320
|
+
// Task-only confirmation: the complete result is the user's decision.
|
|
321
|
+
// No auditor toggle and no goal-state mutation happen here.
|
|
322
|
+
core.enterGoalModal();
|
|
323
|
+
let dialogResult: TaskConfirmationResult;
|
|
324
|
+
try {
|
|
325
|
+
dialogResult = await showTaskConfirmation(ctx, proposalText);
|
|
326
|
+
} finally {
|
|
327
|
+
core.exitGoalModal();
|
|
328
|
+
}
|
|
329
|
+
if (!core.isFocusedOperationCurrent(taskListFocus)) {
|
|
330
|
+
return core.focusedOperationCancelledResult("Task list proposal", taskListFocus);
|
|
331
|
+
}
|
|
332
|
+
if (dialogResult.decision !== "confirm") {
|
|
333
|
+
return {
|
|
334
|
+
content: [{ type: "text", text: "Task list kept unchanged." }],
|
|
335
|
+
details: goalDetails(core.state.goal),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const applyResult = core.goalService.apply(ctx, {
|
|
339
|
+
reconcile: false,
|
|
340
|
+
focusToken: taskListFocus,
|
|
341
|
+
refreshFromDisk: true,
|
|
342
|
+
// Merge the confirmed structural input against the disk-refreshed
|
|
343
|
+
// clone so a concurrent external edit is preserved unless the
|
|
344
|
+
// requested operation changes the same task.
|
|
345
|
+
mutate: (g) => {
|
|
346
|
+
const merged = mergeTasksWithExisting(g.taskList?.tasks, converted.tasks);
|
|
347
|
+
// §7.5: preserve currentTaskId only when the same id remains pending;
|
|
348
|
+
// otherwise clear it. Dashboard state recomputes on the next render.
|
|
349
|
+
const currentTaskId =
|
|
350
|
+
g.currentTaskId && currentTaskIdIsPending(merged, g.currentTaskId) ? g.currentTaskId : undefined;
|
|
351
|
+
return { ...g, currentTaskId, taskList: { tasks: merged, blockCompletion, proposedAt: now }, updatedAt: now };
|
|
352
|
+
},
|
|
353
|
+
ledger: (written) => [{
|
|
354
|
+
type: "task_list_set",
|
|
355
|
+
goalId: written.id,
|
|
356
|
+
taskCount: countTasks(written.taskList?.tasks),
|
|
357
|
+
blockCompletion,
|
|
358
|
+
at: written.updatedAt,
|
|
359
|
+
}],
|
|
360
|
+
});
|
|
361
|
+
if (!applyResult.ok) {
|
|
362
|
+
// A stale writer receives a typed conflict carrying the current
|
|
363
|
+
// revision; whole-tree replacement is authoritative and must not
|
|
364
|
+
// silently merge unknown new structure (follow-up Stage 4).
|
|
365
|
+
return {
|
|
366
|
+
content: [{ type: "text", text: `Task list not applied: ${applyResult.message ?? "the state mutation was rejected"}. Review the current goal and re-propose the task list.` }],
|
|
367
|
+
details: goalDetails(core.state.goal),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
core.runtime.markTurnStopped(core.state.goal.id);
|
|
371
|
+
core.updateUI(ctx);
|
|
372
|
+
const confirmedCount = countTasks(core.state.goal.taskList?.tasks);
|
|
373
|
+
return {
|
|
374
|
+
content: [{ type: "text", text: `Task list set and confirmed. ${confirmedCount} task${confirmedCount === 1 ? "" : "s"}.${gateLabel}` }],
|
|
375
|
+
details: goalDetails(core.state.goal),
|
|
376
|
+
terminate: true,
|
|
377
|
+
};
|
|
378
|
+
},
|
|
379
|
+
renderCall(args, theme) {
|
|
380
|
+
const summary = args?.change_summary ? truncateText(args.change_summary, 80) : `${args?.tasks?.length ?? 0} tasks`;
|
|
381
|
+
return new Text(theme.fg("toolTitle", "set_goal_tasks ") + theme.fg("muted", summary), 0, 0);
|
|
382
|
+
},
|
|
383
|
+
renderResult(result, _options, theme) {
|
|
384
|
+
return renderGoalResult(result, _options, theme);
|
|
385
|
+
},
|
|
386
|
+
}));
|
|
387
|
+
|
|
388
|
+
// ── update_goal_task: discriminated per-task status tool ───────────────────
|
|
389
|
+
pi.registerTool(defineTool({
|
|
390
|
+
name: UPDATE_GOAL_TASK_TOOL_NAME,
|
|
391
|
+
label: "Update Goal Task",
|
|
392
|
+
description: "Update task progress without stopping the turn. Use ordered updates for an atomic batch, or task_id/status for one task. An invalid update rejects the whole batch.",
|
|
393
|
+
promptSnippet: "Start, complete, skip, or reopen tasks; batch related progress.",
|
|
394
|
+
promptGuidelines: ["start requires pending and sets current task. complete requires evidence for contracted tasks and completed/skipped non-lightweight children. skipped requires a reason and explicit user direction or a hard contradiction; never skip to avoid work. pending reopens skipped tasks only; completed tasks are immutable. Completing/skipping the current task clears focus."],
|
|
395
|
+
parameters: Type.Object({
|
|
396
|
+
task_id: Type.Optional(Type.String({ description: "Single-task form; with updates, any non-empty root fields must repeat one batch entry." })),
|
|
397
|
+
status: Type.Optional(StringEnum(["start", "complete", "skipped", "pending", ""] as const)),
|
|
398
|
+
updates: Type.Optional(Type.Array(Type.Object({task_id: Type.String(), status: StringEnum(["start", "complete", "skipped", "pending"] as const), evidence: Type.Optional(Type.String()), reason: Type.Optional(Type.String())}, {additionalProperties: false}), {maxItems: 100, description: "Ordered atomic batch. An empty array is allowed only with valid single-task fields."})),
|
|
399
|
+
evidence: Type.Optional(Type.String({ description: "Completion evidence; max 200 chars." })),
|
|
400
|
+
reason: Type.Optional(Type.String({ description: "Required for skipped." })),
|
|
401
|
+
}, { additionalProperties: false }),
|
|
402
|
+
executionMode: "sequential",
|
|
403
|
+
async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
|
|
404
|
+
const fail = (text: string) => ({content: [{type: "text" as const, text}], details: goalDetails(core.state.goal)});
|
|
405
|
+
const normalized = normalizeTaskProgress(rawParams);
|
|
406
|
+
if (!normalized.ok) return fail(normalized.message);
|
|
407
|
+
if (normalized.batch) {
|
|
408
|
+
const updates = normalized.updates;
|
|
409
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
410
|
+
if (loadGoalSettings(ctx.cwd).disableTasks) return fail("update_goal_task is disabled by settings (disableTasks: true).");
|
|
411
|
+
if (!core.state.goal) return fail("No goal is focused.");
|
|
412
|
+
if (core.state.goal.status !== "active") return fail(`update_goal_task applies only to an active goal (current status: ${core.state.goal.status}).`);
|
|
413
|
+
const result = core.goalService.updateTasks(ctx, updates.map(u => progressSpec(u, core, ctx)));
|
|
414
|
+
if (!result.ok) return fail(result.message);
|
|
415
|
+
core.updateUI(ctx);
|
|
416
|
+
return fail(`${updates.map(u => `${u.task_id} ${u.status}`).join("; ")}. ${buildTaskSummary(result.goal.taskList!)}.`);
|
|
417
|
+
}
|
|
418
|
+
const params = normalized.updates[0]!;
|
|
419
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
420
|
+
if (loadGoalSettings(ctx.cwd).disableTasks) {
|
|
421
|
+
return {
|
|
422
|
+
content: [{ type: "text", text: "update_goal_task is disabled by settings (disableTasks: true)." }],
|
|
423
|
+
details: goalDetails(core.state.goal),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
// update_goal_task applies only to an active goal with an existing task
|
|
427
|
+
// list; invalid lifecycle calls return a state-aware failure.
|
|
428
|
+
if (!core.state.goal) {
|
|
429
|
+
return { content: [{ type: "text", text: "No goal is focused." }], details: goalDetails(core.state.goal) };
|
|
430
|
+
}
|
|
431
|
+
if (core.state.goal.status !== "active") {
|
|
432
|
+
return {
|
|
433
|
+
content: [{ type: "text", text: `update_goal_task applies only to an active goal (current status: ${core.state.goal.status}).` }],
|
|
434
|
+
details: goalDetails(core.state.goal),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
if (!core.state.goal.taskList) {
|
|
438
|
+
return { content: [{ type: "text", text: "The goal has no task list." }], details: goalDetails(core.state.goal) };
|
|
439
|
+
}
|
|
440
|
+
const settings = loadGoalSettings(ctx.cwd);
|
|
441
|
+
const now = nowIso();
|
|
442
|
+
const taskFocus = core.focusedOperationToken(core.state.goal.id);
|
|
443
|
+
|
|
444
|
+
if (params.status === "start") {
|
|
445
|
+
const result = core.goalService.updateTask(ctx, {
|
|
446
|
+
focusToken: taskFocus,
|
|
447
|
+
taskId: params.task_id,
|
|
448
|
+
validate: (task) => {
|
|
449
|
+
if (task.status !== "pending") {
|
|
450
|
+
return { ok: false, message: `Task "${params.task_id}" is ${task.status}; only pending tasks can be started.` };
|
|
451
|
+
}
|
|
452
|
+
return { ok: true };
|
|
453
|
+
},
|
|
454
|
+
update: (task) => task,
|
|
455
|
+
// §8.1: set explicit execution focus; a later start replaces it, and
|
|
456
|
+
// completing/skipping this task clears it.
|
|
457
|
+
setCurrentTaskId: params.task_id,
|
|
458
|
+
ledger: (written) => [{
|
|
459
|
+
type: "task_started",
|
|
460
|
+
goalId: written.id,
|
|
461
|
+
taskId: params.task_id,
|
|
462
|
+
at: written.updatedAt,
|
|
463
|
+
}],
|
|
464
|
+
});
|
|
465
|
+
if (!result.ok) {
|
|
466
|
+
return { content: [{ type: "text", text: result.message }], details: goalDetails(core.state.goal) };
|
|
467
|
+
}
|
|
468
|
+
core.updateUI(ctx);
|
|
469
|
+
// §8.1: surface the task contract so the next continuation prompt and
|
|
470
|
+
// the dashboard can show what starting this task requires.
|
|
471
|
+
const started = findTaskInTree(core.state.goal.taskList?.tasks ?? [], params.task_id);
|
|
472
|
+
const contract = started?.verificationContract ? ` Contract: ${started.verificationContract}` : "";
|
|
473
|
+
return {
|
|
474
|
+
content: [{ type: "text", text: `Started ${params.task_id}${contract}. ${buildTaskSummary(core.state.goal.taskList!)}.` }],
|
|
475
|
+
details: goalDetails(core.state.goal),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (params.status === "complete") {
|
|
480
|
+
const evidence = params.evidence?.trim().slice(0, 200) || undefined;
|
|
481
|
+
const result = core.goalService.updateTask(ctx, {
|
|
482
|
+
focusToken: taskFocus,
|
|
483
|
+
taskId: params.task_id,
|
|
484
|
+
validate: (task) => {
|
|
485
|
+
if (task.status === "complete") return { ok: false, message: `Task "${params.task_id}" is already complete.` };
|
|
486
|
+
if (task.status === "skipped") return { ok: false, message: `Task "${params.task_id}" was already skipped.` };
|
|
487
|
+
if (!settings.disableContracts && task.verificationContract && !evidence) {
|
|
488
|
+
return { ok: false, message: `Task "${params.task_id}" has a verification contract; provide evidence to complete it.` };
|
|
489
|
+
}
|
|
490
|
+
const subtaskGate = checkSubtasksComplete(task);
|
|
491
|
+
if (subtaskGate) return { ok: false, message: subtaskGate };
|
|
492
|
+
return { ok: true };
|
|
493
|
+
},
|
|
494
|
+
update: (task) => ({ ...task, status: "complete" as const, completedAt: now, evidence }),
|
|
495
|
+
ledger: (written) => [{
|
|
496
|
+
type: "task_complete",
|
|
497
|
+
goalId: written.id,
|
|
498
|
+
taskId: params.task_id,
|
|
499
|
+
evidence,
|
|
500
|
+
at: written.updatedAt,
|
|
501
|
+
}],
|
|
502
|
+
});
|
|
503
|
+
if (!result.ok) {
|
|
504
|
+
return { content: [{ type: "text", text: result.message }], details: goalDetails(core.state.goal) };
|
|
505
|
+
}
|
|
506
|
+
core.updateUI(ctx);
|
|
507
|
+
return {
|
|
508
|
+
content: [{ type: "text", text: `${params.task_id} complete. ${buildTaskSummary(core.state.goal.taskList!)}.` }],
|
|
509
|
+
details: goalDetails(core.state.goal),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (params.status === "skipped") {
|
|
514
|
+
const reason = params.reason?.trim();
|
|
515
|
+
if (!reason) {
|
|
516
|
+
return {
|
|
517
|
+
content: [{ type: "text", text: "update_goal_task(status=skipped) requires a non-empty reason." }],
|
|
518
|
+
details: goalDetails(core.state.goal),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
const result = core.goalService.updateTask(ctx, {
|
|
522
|
+
focusToken: taskFocus,
|
|
523
|
+
taskId: params.task_id,
|
|
524
|
+
validate: (task) => {
|
|
525
|
+
if (task.status === "complete") return { ok: false, message: `Task "${params.task_id}" is already complete.` };
|
|
526
|
+
return { ok: true };
|
|
527
|
+
},
|
|
528
|
+
update: (task) => {
|
|
529
|
+
const base = { ...task, status: "skipped" as const, skippedAt: now, skipReason: reason };
|
|
530
|
+
if (task.subtasks && task.subtasks.length > 0 && !task.lightweightSubtasks) {
|
|
531
|
+
return skipAllSubtasks(base, now, reason);
|
|
532
|
+
}
|
|
533
|
+
return base;
|
|
534
|
+
},
|
|
535
|
+
ledger: (written) => [{
|
|
536
|
+
type: "task_skipped",
|
|
537
|
+
goalId: written.id,
|
|
538
|
+
taskId: params.task_id,
|
|
539
|
+
reason,
|
|
540
|
+
at: written.updatedAt,
|
|
541
|
+
}],
|
|
542
|
+
});
|
|
543
|
+
if (!result.ok) {
|
|
544
|
+
return { content: [{ type: "text", text: result.message }], details: goalDetails(core.state.goal) };
|
|
545
|
+
}
|
|
546
|
+
core.updateUI(ctx);
|
|
547
|
+
return {
|
|
548
|
+
content: [{ type: "text", text: `${params.task_id} skipped. ${buildTaskSummary(core.state.goal.taskList!)}.` }],
|
|
549
|
+
details: goalDetails(core.state.goal),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// status === "pending": reopen a skipped task; completed tasks are immutable.
|
|
554
|
+
const result = core.goalService.updateTask(ctx, {
|
|
555
|
+
focusToken: taskFocus,
|
|
556
|
+
taskId: params.task_id,
|
|
557
|
+
validate: (task) => {
|
|
558
|
+
if (task.status === "complete") {
|
|
559
|
+
return { ok: false, message: `Task "${params.task_id}" is complete and cannot be reopened through update_goal_task.` };
|
|
560
|
+
}
|
|
561
|
+
if (task.status !== "skipped") {
|
|
562
|
+
return { ok: false, message: `Task "${params.task_id}" is not skipped; only skipped tasks can be reopened with status=pending.` };
|
|
563
|
+
}
|
|
564
|
+
return { ok: true };
|
|
565
|
+
},
|
|
566
|
+
update: (task) => {
|
|
567
|
+
const { skippedAt, skipReason, ...rest } = task;
|
|
568
|
+
return { ...rest, status: "pending" as const };
|
|
569
|
+
},
|
|
570
|
+
ledger: (written) => [{
|
|
571
|
+
type: "task_reopened",
|
|
572
|
+
goalId: written.id,
|
|
573
|
+
taskId: params.task_id,
|
|
574
|
+
at: written.updatedAt,
|
|
575
|
+
}],
|
|
576
|
+
});
|
|
577
|
+
if (!result.ok) {
|
|
578
|
+
return { content: [{ type: "text", text: result.message }], details: goalDetails(core.state.goal) };
|
|
579
|
+
}
|
|
580
|
+
core.updateUI(ctx);
|
|
581
|
+
return {
|
|
582
|
+
content: [{ type: "text", text: `${params.task_id} reopened. ${buildTaskSummary(core.state.goal.taskList!)}.` }],
|
|
583
|
+
details: goalDetails(core.state.goal),
|
|
584
|
+
};
|
|
585
|
+
},
|
|
586
|
+
renderCall(args, theme) {
|
|
587
|
+
const summary = args?.updates?.length ? `${args.updates.length} updates` : `${args?.task_id ?? ""} ${args?.status ?? ""}`;
|
|
588
|
+
return new Text(theme.fg("toolTitle", "update_goal_task ") + theme.fg("muted", summary), 0, 0);
|
|
589
|
+
},
|
|
590
|
+
renderResult(result, _options, theme) {
|
|
591
|
+
return renderGoalResult(result, _options, theme);
|
|
592
|
+
},
|
|
593
|
+
}));
|
|
594
|
+
|
|
595
|
+
}
|