@evoclock/pi-agentic-driver 0.9.3 → 0.10.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.
@@ -0,0 +1,724 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ // TASKS provider extension (tasks #60 + #63, revised v1). Registers the
5
+ // session task tools (TaskCreate/TaskGet/TaskList/TaskUpdate — same names,
6
+ // schema, result text, and storage model as picc-tasks) and the
7
+ // owner-initiated TaskPromote bridge to the trusted board writer.
8
+ //
9
+ // Governance invariants (revised v1 scope):
10
+ // - Promotion is NEVER automatic, never a session-shutdown hook, and
11
+ // never worker-authorized. It requires a fresh exact owner
12
+ // quotedInstruction supplied through this tool call.
13
+ // - Preview is mandatory before mutation; missing required board fields
14
+ // are structured refusals, never invented from task text.
15
+ // - The single trusted-writer path: this extension forwards the preview's
16
+ // card input plus the owner's authority record to writeCard /
17
+ // updateCard. It never serializes or writes TASKS.md directly.
18
+ // - Completed tasks are skipped by default; survivors default to the
19
+ // backlog lane (dispatchable). in-progress lane placement does not
20
+ // make a card dispatchable by itself.
21
+
22
+ import {
23
+ SESSION_TASKS_SCHEMA, TASK_PROMOTION_SCHEMA,
24
+ resolveTaskListId, tasksMirrorPath, loadSnapshotFile, persistSnapshot,
25
+ validateSnapshot, validatePromotionRequest,
26
+ buildPromotionPreview, buildBatchPromotionPreview,
27
+ reconcileDependencies, applyDependencyEdges,
28
+ promotionIdempotencyKey, forwardLinkFor, findExistingPromotedCard,
29
+ resolveCallerOrigin, canPromote, hasTasksCapability,
30
+ } from "../scripts/enforcement/session_tasks_core_pi.js";
31
+ import { writeCard, updateCard, parseBoard } from "../scripts/enforcement/task_board_core_pi.js";
32
+
33
+ const TASK_STATE_ENTRY = "picc-tasks-state";
34
+
35
+ // Board resolution for promotion targets the CANONICAL TASKS.md only. The
36
+ // board.md projection is a derived view and is never a write target: if only
37
+ // a projection exists, the canonical path is returned so the writer
38
+ // bootstraps (or refuses) on the canonical file, never the projection.
39
+ function resolveBoardPath(cwd) {
40
+ if (typeof cwd !== "string" || cwd === "") return null;
41
+ return join(cwd, "TASKS.md");
42
+ }
43
+
44
+ const { existsSync, readFileSync } = await import("node:fs");
45
+ const { join } = await import("node:path");
46
+
47
+ function textResult(value) {
48
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
49
+ }
50
+
51
+ function failResult(code, reason, extra = {}) {
52
+ return textResult({ ok: false, code, reason, errors: [reason], ...extra });
53
+ }
54
+
55
+ export default async function tasksPi(pi) {
56
+ const core = await import(new URL("../scripts/enforcement/session_tasks_core_pi.js", import.meta.url).href);
57
+ const board = await import(new URL("../scripts/enforcement/task_board_core_pi.js", import.meta.url).href);
58
+ const { isNativeTuiContext } = await import(new URL("../scripts/enforcement/native_tui_context.js", import.meta.url).href);
59
+
60
+ // Session task state: the picc-tasks model (in-memory list + high-water
61
+ // mark, session JSONL snapshot entries, disk mirror).
62
+ let tasks = [];
63
+ let highWaterMark = 0;
64
+ let taskListId = null;
65
+ let mirrorPath = null;
66
+ let lastCtx = null;
67
+
68
+ const WIDGET_KEY = "picc-tasks";
69
+ const TASK_ICONS = Object.freeze({ pending: "▫", in_progress: "▪", completed: "✓" });
70
+
71
+ const isVisible = (task) => task?.metadata?._internal !== true;
72
+ const unresolvedTaskIds = () => new Set(
73
+ tasks.filter((task) => task.status !== "completed").map((task) => task.id),
74
+ );
75
+
76
+ const renderTaskListLine = (task) => {
77
+ const parts = [
78
+ `${TASK_ICONS[task.status] ?? "?"} #${task.id}`,
79
+ `[${task.status}]`,
80
+ task.subject,
81
+ ];
82
+ if (task.owner) parts.push(`(${task.owner})`);
83
+ let line = parts.join(" ");
84
+ const unresolved = unresolvedTaskIds();
85
+ const live = task.blockedBy.filter((id) => unresolved.has(id));
86
+ if (live.length > 0) line += ` [blocked by ${live.map((id) => `#${id}`).join(", ")}]`;
87
+ return line;
88
+ };
89
+
90
+ const renderTaskListLineForLLM = (task) => {
91
+ const parts = [`#${task.id}`, `[${task.status}]`, task.subject];
92
+ if (task.owner) parts.push(`(${task.owner})`);
93
+ let line = parts.join(" ");
94
+ const unresolved = unresolvedTaskIds();
95
+ const live = task.blockedBy.filter((id) => unresolved.has(id));
96
+ if (live.length > 0) line += ` [blocked by ${live.map((id) => `#${id}`).join(", ")}]`;
97
+ return line;
98
+ };
99
+
100
+ const themed = (theme, method, args, fallback) => {
101
+ try {
102
+ if (typeof theme?.[method] === "function") return theme[method](...args);
103
+ } catch { /* UI styling is best-effort */ }
104
+ return fallback;
105
+ };
106
+
107
+ const renderWidgetTaskLine = (theme, task, unresolved) => {
108
+ const live = task.blockedBy.filter((id) => unresolved.has(id));
109
+ const parts = [
110
+ TASK_ICONS[task.status] ?? "?",
111
+ `[${task.status}]`,
112
+ task.subject,
113
+ ];
114
+ if (task.owner) parts.push(`(${task.owner})`);
115
+ let line = parts.join(" ");
116
+ if (live.length > 0) line += ` [blocked by ${live.map((id) => `#${id}`).join(", ")}]`;
117
+ if (task.status === "completed") {
118
+ return themed(theme, "strikethrough", [line], line);
119
+ }
120
+ if (task.status === "in_progress") {
121
+ return themed(theme, "bold", [line], line);
122
+ }
123
+ return themed(theme, "fg", [live.length > 0 ? "dim" : "text", line], line);
124
+ };
125
+
126
+ // UI is deliberately a best-effort projection of the task state. A context
127
+ // can become stale during /new, /fork, /resume, or /reload; task mutations
128
+ // must still succeed and the next session event will render the projection.
129
+ const refreshUI = (ctx = null) => {
130
+ if (ctx) lastCtx = ctx;
131
+ let uiHost;
132
+ try {
133
+ uiHost = ctx ?? lastCtx;
134
+ if (!uiHost || uiHost.hasUI === false || !uiHost.ui) return;
135
+ } catch {
136
+ return;
137
+ }
138
+
139
+ const visible = tasks.filter(isVisible);
140
+ try {
141
+ if (typeof uiHost.ui.setWidget === "function") {
142
+ if (visible.length === 0) {
143
+ uiHost.ui.setWidget(WIDGET_KEY, undefined);
144
+ } else {
145
+ const counts = {
146
+ pending: visible.filter((task) => task.status === "pending").length,
147
+ in_progress: visible.filter((task) => task.status === "in_progress").length,
148
+ completed: visible.filter((task) => task.status === "completed").length,
149
+ };
150
+ const header = `Tasks ${counts.pending} pending · ${counts.in_progress} in progress · ${counts.completed} done`;
151
+ const unresolved = unresolvedTaskIds();
152
+ const theme = uiHost.ui.theme;
153
+ const rows = [...visible]
154
+ .sort((a, b) => Number(a.id) - Number(b.id) || a.id.localeCompare(b.id))
155
+ .map((task) => renderWidgetTaskLine(theme, task, unresolved));
156
+ uiHost.ui.setWidget(WIDGET_KEY, [themed(theme, "fg", ["dim", header], header), ...rows], { placement: "aboveEditor" });
157
+ }
158
+ }
159
+ } catch {
160
+ // A stale UI host must never turn a successful tool mutation into an error.
161
+ }
162
+
163
+ try {
164
+ if (typeof uiHost.ui.setStatus === "function") {
165
+ if (visible.length === 0) {
166
+ uiHost.ui.setStatus(WIDGET_KEY, undefined);
167
+ } else {
168
+ const active = visible.filter((task) => task.status === "in_progress").length;
169
+ const done = visible.filter((task) => task.status === "completed").length;
170
+ const total = visible.length;
171
+ uiHost.ui.setStatus(WIDGET_KEY, active > 0 ? `${active} active / ${done}/${total} tasks` : `${done}/${total} tasks`);
172
+ }
173
+ }
174
+ } catch {
175
+ // Footer refresh is independent of the widget and equally best-effort.
176
+ }
177
+ };
178
+
179
+ const syncState = (ctx) => {
180
+ if (ctx) lastCtx = ctx;
181
+ taskListId = core.resolveTaskListId({ session: ctx?.sessionManager?.getSessionId?.() ?? null });
182
+ mirrorPath = core.tasksMirrorPath(taskListId);
183
+ // Replay: the branch's last picc-tasks-state entry wins; disk mirror is
184
+ // the fallback (three-way merge on highWaterMark, as picc-tasks does).
185
+ let fromBranch = null;
186
+ try {
187
+ const branch = ctx?.sessionManager?.getBranch?.() ?? [];
188
+ const snapshots = branch.filter((e) => e?.type === "custom" && e?.customType === TASK_STATE_ENTRY);
189
+ if (snapshots.length > 0) fromBranch = snapshots[snapshots.length - 1]?.data ?? null;
190
+ } catch { /* branch not ready; disk fallback */ }
191
+ const fromDisk = mirrorPath ? core.loadSnapshotFile(mirrorPath) : null;
192
+ const branchHwm = Number(fromBranch?.highWaterMark) || 0;
193
+ const diskHwm = Number(fromDisk?.highWaterMark) || 0;
194
+ if (branchHwm === 0 && diskHwm === 0) {
195
+ tasks = []; highWaterMark = 0;
196
+ } else if (diskHwm > branchHwm) {
197
+ tasks = fromDisk.tasks; highWaterMark = diskHwm;
198
+ } else {
199
+ tasks = fromBranch.tasks; highWaterMark = branchHwm;
200
+ }
201
+ refreshUI(ctx);
202
+ };
203
+
204
+ const commitChange = (ctx) => {
205
+ if (ctx) lastCtx = ctx;
206
+ const snapshot = { tasks: tasks.map((t) => ({ ...t })), highWaterMark };
207
+ try {
208
+ pi.appendEntry(TASK_STATE_ENTRY, snapshot);
209
+ } catch { /* stale ctx after /new, /fork, /resume: disk mirror holds the snapshot */ }
210
+ if (mirrorPath) {
211
+ try { core.persistSnapshot(mirrorPath, snapshot); } catch { /* non-fatal */ }
212
+ }
213
+ refreshUI(ctx);
214
+ };
215
+
216
+ const findTask = (id) => tasks.find((t) => t.id === id);
217
+
218
+ const stampOrigin = (task) => {
219
+ const observed = core.resolveCallerOrigin();
220
+ // Origin is observed audit metadata, never trusted caller-supplied input.
221
+ const metadata = { ...(task.metadata ?? {}) };
222
+ delete metadata.originClaimed;
223
+ if (observed.proven) metadata.origin = observed.origin;
224
+ else if (metadata.origin !== "coordinator" && metadata.origin !== "worker") metadata.origin = core.LEGACY_UNKNOWN_ORIGIN;
225
+ return { ...task, metadata };
226
+ };
227
+
228
+ if (typeof pi?.registerTool === "function") {
229
+ // --- TaskCreate (picc-tasks-compatible) ---
230
+ pi.registerTool({
231
+ name: "TaskCreate",
232
+ label: "TaskCreate",
233
+ description: "Use this tool to create a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.\n\nIt also helps the user understand the progress of the task and overall progress of their requests.\n\n## When to Use This Tool\n\nUse this tool proactively in these scenarios:\n\n- Complex multi-step tasks - When a task requires 3 or more distinct steps or actions\n- Non-trivial and complex tasks - Tasks that require careful planning or multiple operations\n- Plan mode - When using plan mode, create a task list to track the work\n- User explicitly requests todo list - When the user directly asks you to use the todo list\n- User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)\n- After receiving new instructions - Immediately capture user requirements as tasks\n- When you start working on a task - Mark it as in_progress BEFORE beginning work\n- After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation\n\n## When NOT to Use This Tool\n\nSkip using this tool when:\n- There is only a single, straightforward task\n- The task can be completed in less than 3 trivial steps\n- The task is purely conversational or informational\n\nNOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.\n\n## Task Fields\n\n- **subject**: A brief, actionable title in imperative form (e.g., \"Fix authentication bug in login flow\")\n- **description**: What needs to be done\n- **activeForm** (optional): Present continuous form shown in the spinner when the task is in_progress (e.g., \"Fixing authentication bug\"). If omitted, the spinner shows the subject instead.\n\nAll tasks are created with status `pending`.\n\n## Tips\n\n- Create tasks with clear, specific subjects that describe the outcome\n- After creating tasks, use TaskUpdate to set up dependencies (blocks/blockedBy) if needed\n- Check TaskList first to avoid creating duplicate tasks\n",
234
+ parameters: {
235
+ type: "object",
236
+ additionalProperties: false,
237
+ properties: {
238
+ subject: { type: "string", description: "A brief title for the task" },
239
+ description: { type: "string", description: "What needs to be done" },
240
+ activeForm: { type: "string", description: "Present continuous form shown in spinner when in_progress (e.g., \"Running tests\")" },
241
+ metadata: { type: "object", description: "Arbitrary metadata to attach to the task", additionalProperties: true, properties: {} },
242
+ },
243
+ required: ["subject", "description"],
244
+ },
245
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
246
+ highWaterMark += 1;
247
+ let task = {
248
+ id: String(highWaterMark),
249
+ subject: params.subject,
250
+ description: params.description,
251
+ status: "pending",
252
+ blocks: [],
253
+ blockedBy: [],
254
+ ...(params.activeForm !== undefined ? { activeForm: params.activeForm } : {}),
255
+ ...(params.metadata !== undefined ? { metadata: params.metadata } : {}),
256
+ };
257
+ task = stampOrigin(task);
258
+ tasks.push(task);
259
+ commitChange(ctx);
260
+ return {
261
+ content: [{ type: "text", text: `Task #${task.id} created successfully: ${task.subject}` }],
262
+ details: { task: { id: task.id, subject: task.subject } },
263
+ };
264
+ },
265
+ });
266
+
267
+ // --- TaskGet (picc-tasks-compatible) ---
268
+ pi.registerTool({
269
+ name: "TaskGet",
270
+ label: "TaskGet",
271
+ description: "Use this tool to retrieve a task by its ID from the task list.\n\n## When to Use This Tool\n\n- When you need the full description and context before starting work on a task\n- To understand task dependencies (what it blocks, what blocks it)\n- After being assigned a task, to get complete requirements\n\n## Output\n\nReturns full task details:\n- **subject**: Task title\n- **description**: Detailed requirements and context\n- **status**: 'pending', 'in_progress', or 'completed'\n- **blocks**: Tasks waiting on this one to complete\n- **blockedBy**: Tasks that must complete before this one can start\n\n## Tips\n\n- After fetching a task, verify its blockedBy list is empty before beginning work.\n- Use TaskList to see all tasks in summary form.",
272
+ parameters: { type: "object", additionalProperties: false, properties: { taskId: { type: "string", description: "The ID of the task to retrieve" } }, required: ["taskId"] },
273
+ async execute(_toolCallId, params) {
274
+ const task = findTask(params.taskId);
275
+ if (!task) return { content: [{ type: "text", text: "Task not found" }], details: { task: null } };
276
+ const lines = [`Task #${task.id}: ${task.subject}`, `Status: ${task.status}`, `Description: ${task.description}`];
277
+ if (task.blockedBy.length > 0) lines.push(`Blocked by: ${task.blockedBy.map((id) => `#${id}`).join(", ")}`);
278
+ if (task.blocks.length > 0) lines.push(`Blocks: ${task.blocks.map((id) => `#${id}`).join(", ")}`);
279
+ // picc-tasks v0.2.0 TaskGet details carry the restricted task shape
280
+ // only: {id, subject, description, status, blocks, blockedBy}. No
281
+ // metadata, activeForm, or owner leak through this surface.
282
+ return {
283
+ content: [{ type: "text", text: lines.join("\n") }],
284
+ details: {
285
+ task: {
286
+ id: task.id,
287
+ subject: task.subject,
288
+ description: task.description,
289
+ status: task.status,
290
+ blocks: [...task.blocks],
291
+ blockedBy: [...task.blockedBy],
292
+ },
293
+ },
294
+ };
295
+ },
296
+ });
297
+
298
+ // --- TaskList (picc-tasks-compatible) ---
299
+ pi.registerTool({
300
+ name: "TaskList",
301
+ label: "TaskList",
302
+ description: "Use this tool to list all tasks in the task list.\n\n## When to Use This Tool\n\n- To see what tasks are available to work on (status: 'pending', no owner, not blocked)\n- To check overall progress on the project\n- To find tasks that are blocked and need dependencies resolved\n- After completing a task, to check for newly unblocked work or claim the next available task\n- **Prefer working on tasks in ID order** (lowest ID first) when multiple tasks are available, as earlier tasks often set up context for later ones\n\n## Output\n\nReturns a summary of each task:\n- **id**: Task identifier (use with TaskGet, TaskUpdate)\n- **subject**: Brief description of the task\n- **status**: 'pending', 'in_progress', or 'completed'\n- **owner**: Agent ID if assigned, empty if available\n- **blockedBy**: List of open task IDs that must be resolved first (tasks with blockedBy cannot be claimed until dependencies resolve)\n\nUse TaskGet with a specific task ID to view full details including description and comments.",
303
+ parameters: { type: "object", additionalProperties: false, properties: {} },
304
+ async execute() {
305
+ const visible = tasks.filter(isVisible);
306
+ if (visible.length === 0) return { content: [{ type: "text", text: "No tasks found" }], details: { tasks: [] } };
307
+ const unresolved = unresolvedTaskIds();
308
+ const list = visible.map((t) => ({
309
+ id: t.id,
310
+ subject: t.subject,
311
+ status: t.status,
312
+ ...(t.owner !== undefined ? { owner: t.owner } : {}),
313
+ blockedBy: t.blockedBy.filter((id) => unresolved.has(id)),
314
+ }));
315
+ const lines = list.map(renderTaskListLineForLLM);
316
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { tasks: list } };
317
+ },
318
+ });
319
+
320
+ // --- TaskUpdate (picc-tasks-compatible) ---
321
+ pi.registerTool({
322
+ name: "TaskUpdate",
323
+ label: "TaskUpdate",
324
+ description: "Use this tool to update a task in the task list.\n\n## When to Use This Tool\n\n**Mark tasks as resolved:**\n- When you have completed the work described in a task\n- When a task is no longer needed or has been superseded\n- IMPORTANT: Always mark your assigned tasks as resolved when you finish them\n- After resolving, call TaskList to find your next task\n\n- ONLY mark a task as completed when you have FULLY accomplished it\n- If you encounter errors, blockers, or cannot finish, keep the task as in_progress\n- When blocked, create a new task describing what needs to be resolved\n- Never mark a task as completed if:\n - Tests are failing\n - Implementation is partial\n - You encountered unresolved errors\n - You couldn't find necessary files or dependencies\n\n**Delete tasks:**\n- When a task is no longer relevant or was created in error\n- Setting status to `deleted` permanently removes the task\n\n**Update task details:**\n- When requirements change or become clearer\n- When establishing dependencies between tasks\n\n## Fields You Can Update\n\n- **status**: The task status (see Status Workflow below)\n- **subject**: Change the task title (imperative form, e.g., \"Run tests\")\n- **description**: Change the task description\n- **activeForm**: Present continuous form shown in spinner when in_progress (e.g., \"Running tests\")\n- **owner**: Change the task owner (agent name)\n- **metadata**: Merge metadata keys into the task (set a key to null to delete it)\n- **addBlocks**: Mark tasks that cannot start until this one completes\n- **addBlockedBy**: Mark tasks that must complete before this one can start\n\n## Status Workflow\n\nStatus progresses: `pending` → `in_progress` → `completed`\n\nUse `deleted` to permanently remove a task.\n\n## Staleness\n\nMake sure to read a task's latest state using `TaskGet` before updating it.\n\n## Examples\n\nMark task as in progress when starting work:\n```json\n{\"taskId\": \"1\", \"status\": \"in_progress\"}\n```\n\nMark task as completed after finishing work:\n```json\n{\"taskId\": \"1\", \"status\": \"completed\"}\n```\n\nDelete a task:\n```json\n{\"taskId\": \"1\", \"status\": \"deleted\"}\n```\n\nClaim a task by setting owner:\n```json\n{\"taskId\": \"1\", \"owner\": \"my-name\"}\n```\n\nSet up task dependencies:\n```json\n{\"taskId\": \"2\", \"addBlockedBy\": [\"1\"]}\n```",
325
+ parameters: {
326
+ type: "object",
327
+ additionalProperties: false,
328
+ properties: {
329
+ taskId: { type: "string", description: "The ID of the task to update" },
330
+ subject: { type: "string", description: "New subject for the task" },
331
+ description: { type: "string", description: "New description for the task" },
332
+ activeForm: { type: "string", description: "Present continuous form shown in spinner when in_progress (e.g., \"Running tests\")" },
333
+ owner: { type: "string", description: "New owner for the task" },
334
+ metadata: { type: "object", description: "Metadata keys to merge into the task. Set a key to null to delete it.", additionalProperties: true, properties: {} },
335
+ status: { type: "string", enum: ["pending", "in_progress", "completed", "deleted"], description: "New status for the task" },
336
+ addBlocks: { type: "array", items: { type: "string" }, description: "Task IDs that this task blocks" },
337
+ addBlockedBy: { type: "array", items: { type: "string" }, description: "Task IDs that block this task" },
338
+ },
339
+ required: ["taskId"],
340
+ },
341
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
342
+ const idx = tasks.findIndex((t) => t.id === params.taskId);
343
+ if (idx === -1) {
344
+ return { content: [{ type: "text", text: "Task not found" }], details: { success: false, taskId: params.taskId, error: "Task not found", updatedFields: [] } };
345
+ }
346
+ const task = tasks[idx];
347
+ const updatedFields = [];
348
+ let statusChange;
349
+ if (params.subject !== undefined && params.subject !== task.subject) { task.subject = params.subject; updatedFields.push("subject"); }
350
+ if (params.description !== undefined && params.description !== task.description) { task.description = params.description; updatedFields.push("description"); }
351
+ if (params.activeForm !== undefined && params.activeForm !== task.activeForm) { task.activeForm = params.activeForm; updatedFields.push("activeForm"); }
352
+ if (params.owner !== undefined && params.owner !== task.owner) { task.owner = params.owner; updatedFields.push("owner"); }
353
+ if (params.metadata !== undefined) {
354
+ const merged = { ...(task.metadata ?? {}) };
355
+ for (const [k, v] of Object.entries(params.metadata)) {
356
+ if (v === null) delete merged[k]; else merged[k] = v;
357
+ }
358
+ if (Object.keys(merged).length > 0) task.metadata = merged; else delete task.metadata;
359
+ updatedFields.push("metadata");
360
+ }
361
+ if (params.status !== undefined && params.status !== task.status) {
362
+ if (params.status === "deleted") {
363
+ statusChange = { from: task.status, to: "deleted" };
364
+ tasks.splice(idx, 1);
365
+ const n = Number.parseInt(task.id, 10);
366
+ if (!Number.isNaN(n)) highWaterMark = Math.max(highWaterMark, n);
367
+ commitChange(ctx);
368
+ return { content: [{ type: "text", text: `Updated task #${task.id} deleted` }], details: { success: true, taskId: task.id, updatedFields: ["deleted"], statusChange } };
369
+ }
370
+ statusChange = { from: task.status, to: params.status };
371
+ task.status = params.status;
372
+ updatedFields.push("status");
373
+ }
374
+ // Dependency edges (picc-tasks v0.2.0 semantics): append-only with
375
+ // dedupe, target validation, and mirror inverse maintenance. Unknown
376
+ // targets are skipped, never added as dangling references.
377
+ if (Array.isArray(params.addBlocks) && params.addBlocks.length > 0) {
378
+ const validTargets = new Set(tasks.map((t) => t.id));
379
+ const before = task.blocks.length;
380
+ for (const targetId of params.addBlocks) {
381
+ if (!validTargets.has(targetId)) continue;
382
+ if (!task.blocks.includes(targetId)) task.blocks.push(targetId);
383
+ const target = tasks.find((t) => t.id === targetId);
384
+ if (target && !target.blockedBy.includes(task.id)) target.blockedBy.push(task.id);
385
+ }
386
+ if (task.blocks.length > before) updatedFields.push("blocks");
387
+ }
388
+ if (Array.isArray(params.addBlockedBy) && params.addBlockedBy.length > 0) {
389
+ const validTargets = new Set(tasks.map((t) => t.id));
390
+ const before = task.blockedBy.length;
391
+ for (const upstreamId of params.addBlockedBy) {
392
+ if (!validTargets.has(upstreamId)) continue;
393
+ if (!task.blockedBy.includes(upstreamId)) task.blockedBy.push(upstreamId);
394
+ const upstream = tasks.find((t) => t.id === upstreamId);
395
+ if (upstream && !upstream.blocks.includes(task.id)) upstream.blocks.push(task.id);
396
+ }
397
+ if (task.blockedBy.length > before) updatedFields.push("blockedBy");
398
+ }
399
+ commitChange(ctx);
400
+ const details: Record<string, unknown> = { success: true, taskId: task.id, updatedFields };
401
+ if (statusChange) details.statusChange = statusChange;
402
+ return { content: [{ type: "text", text: `Updated task #${task.id} ${updatedFields.join(", ")}` }], details };
403
+ },
404
+ });
405
+
406
+ // --- TaskPromote (owner-initiated; preview token + native confirmation + trusted writer) ---
407
+ pi.registerTool({
408
+ name: "TaskPromote",
409
+ label: "TaskPromote",
410
+ description:
411
+ "Promote session tasks to the durable TASKS.md board through the trusted board writer. OWNER-INITIATED ONLY, two mandatory steps: (1) mode 'preview' returns a previewToken bound to the exact task IDs, contents, target TASKS.md, card fields, session identity, and authority input; (2) mode 'commit' must present that exact token AND the host presents one native confirmation dialog to the owner before any write. A model-supplied quotedInstruction is never owner proof. Workers cannot promote. Surviving tasks default to the backlog lane; lane overrides are owner-explicit. definitionOfDone, stoppingPoint, and scopePaths are owner-supplied, never invented.",
412
+ parameters: {
413
+ type: "object",
414
+ additionalProperties: false,
415
+ properties: {
416
+ mode: { type: "string", enum: ["preview", "commit"], description: "preview (mandatory first) or commit (requires the matching previewToken plus owner confirmation)." },
417
+ taskIds: { type: "array", items: { type: "string" }, description: "Session task IDs to promote. Omit for a batch of all non-completed, non-promoted tasks." },
418
+ previewToken: { type: "string", description: "commit only: the exact previewToken returned by the immediately preceding preview for this same state." },
419
+ definitionOfDone: { type: "string", description: "commit only: the card's definition of done (owner-supplied)." },
420
+ stoppingPoint: { type: "string", description: "commit only: the card's stopping point (owner-supplied)." },
421
+ scopePaths: { type: "array", items: { type: "string" }, description: "commit only: repository-relative scope paths (owner-supplied)." },
422
+ laneOverrides: { type: "object", description: "Optional per-task lane override (backlog | in-progress), keyed by task id. Owner-explicit only; the default is backlog for every surviving task.", additionalProperties: { type: "string", enum: ["backlog", "in-progress"] }, properties: {} },
423
+ completedPolicy: { type: "string", enum: ["skip", "promote-as-done"], description: "Default skip (v1)." },
424
+ authority: {
425
+ type: "object",
426
+ description: "commit only, REQUIRED: { source: 'instruction', sessionOrReportId, quotedInstruction }. The quotedInstruction must match the owner-confirmed instruction from the native confirmation step; a model-invented quote is not owner proof and is rejected.",
427
+ additionalProperties: false,
428
+ properties: {
429
+ source: { type: "string", enum: ["instruction"] },
430
+ sessionOrReportId: { type: "string" },
431
+ quotedInstruction: { type: "string" },
432
+ },
433
+ required: ["source", "sessionOrReportId", "quotedInstruction"],
434
+ },
435
+ },
436
+ required: ["mode"],
437
+ },
438
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
439
+ const cwd = ctx?.cwd ?? process.cwd();
440
+ const boardPath = resolveBoardPath(cwd);
441
+ const resolvedListId = taskListId ?? core.resolveTaskListId({ session: ctx?.sessionManager?.getSessionId?.() ?? null });
442
+ if (!resolvedListId) return failResult("task-list-unavailable", "no taskListId could be resolved (PI_TASK_LIST_ID, then CLAUDE_CODE_TASK_LIST_ID, then the session id)");
443
+
444
+ // Origin observation: audit metadata only. A proven worker pane is
445
+ // refused outright; unproven identity still requires the full owner
446
+ // confirmation boundary (the confirmation is the gate, not origin).
447
+ const origin = core.resolveCallerOrigin();
448
+ if (!core.canPromote(origin)) {
449
+ return failResult("worker-promotion-refused", "a worker session cannot promote; promotion requires the owner in an owner session");
450
+ }
451
+
452
+ // Capability gate (enforced, not informational): commit requires the
453
+ // session to declare the tasks capability marker. Session tools stay
454
+ // available without it; the authority-bearing surface does not.
455
+ if (input?.mode === "commit" && !core.hasTasksCapability({})) {
456
+ return failResult("capability-required", `commit requires the session capability marker ${core.TASKS_CAPABILITY_ENV}; package presence is not the loading contract`);
457
+ }
458
+
459
+ // Select tasks: explicit IDs or the non-promoted, non-completed batch.
460
+ let selected;
461
+ if (Array.isArray(input?.taskIds) && input.taskIds.length > 0) {
462
+ selected = input.taskIds.map((id) => findTask(id)).filter((t) => t !== undefined);
463
+ const missing = input.taskIds.filter((id) => findTask(id) === undefined);
464
+ if (missing.length > 0) return failResult("task-not-found", `task(s) not found: ${missing.join(", ")}`);
465
+ } else {
466
+ selected = tasks.filter((t) => t.status !== "completed" && !t.metadata?.promotedCardId);
467
+ }
468
+
469
+ const boardMarkdown = boardPath && existsSync(boardPath) ? readFileSync(boardPath, "utf8") : "";
470
+ const metadataByTaskId = Object.fromEntries(tasks.map((t) => [t.id, t.metadata ?? {}]));
471
+
472
+ // Retry reconciliation FIRST (before any preview): a task whose
473
+ // forward link already exists on the board is recovered as an
474
+ // idempotent success — the card ID comes from the board's importedId
475
+ // (authoritative), and a missing reverse link is repaired below.
476
+ const recovered = [];
477
+ const stillToPromote = [];
478
+ for (const task of selected) {
479
+ const key = core.promotionIdempotencyKey(resolvedListId, task.id);
480
+ const existingCard = boardMarkdown !== "" ? core.findExistingPromotedCard(boardMarkdown, key) : null;
481
+ if (existingCard) {
482
+ recovered.push({ taskId: task.id, cardId: existingCard.cardId, recovered: true, importedIdAuthoritative: true });
483
+ } else {
484
+ stillToPromote.push(task);
485
+ }
486
+ }
487
+
488
+ const batch = core.buildBatchPromotionPreview({
489
+ tasks: stillToPromote,
490
+ taskListId: resolvedListId,
491
+ boardPath,
492
+ sessionOrReportId: input?.authority?.sessionOrReportId ?? resolvedListId,
493
+ laneOverrides: input?.laneOverrides ?? {},
494
+ completedPolicy: input?.completedPolicy ?? "skip",
495
+ existingKeys: () => null, // already handled by the recovery pass above
496
+ metadataByTaskId,
497
+ });
498
+
499
+ if (input?.mode === "preview" || input?.mode === undefined) {
500
+ const target = { repositoryRoot: cwd, canonicalTasksMdPath: boardPath };
501
+ const token = core.previewToken({
502
+ previews: batch.previews,
503
+ skipped: batch.skipped,
504
+ recovered,
505
+ target,
506
+ sessionOrReportId: input?.authority?.sessionOrReportId ?? resolvedListId,
507
+ authorityInput: input?.authority ?? null,
508
+ });
509
+ return textResult({
510
+ ok: true,
511
+ mode: "preview",
512
+ previewOnly: true,
513
+ previewToken: token,
514
+ target,
515
+ previews: batch.previews,
516
+ skipped: batch.skipped,
517
+ recovered,
518
+ note: "Preview only — nothing was written. Commit requires this exact previewToken, the owner-supplied fields, and one native owner confirmation presented by the host.",
519
+ });
520
+ }
521
+
522
+ // --- commit path ---
523
+ if (input?.mode === "commit") {
524
+ // 1. Authority record shape: the record itself must be a genuine
525
+ // instruction with a verbatim quotedInstruction (a digest alone
526
+ // is not accepted). Validated independently of the preview list
527
+ // so a recovery-only commit (nothing left to write) is still
528
+ // authority-gated. The writer enforces this again per call.
529
+ if (!input?.authority || !core.isValidAuthoritySource(input.authority)
530
+ || typeof input.authority.quotedInstruction !== "string"
531
+ || input.authority.quotedInstruction.trim() === "") {
532
+ return failResult("authority-required", "commit requires a genuine owner authority record: { source: 'instruction', sessionOrReportId, quotedInstruction }; a digest alone is not accepted");
533
+ }
534
+ // 2. Matching prior preview is MANDATORY. The token binds task IDs,
535
+ // task contents, the target repo/TASKS.md, all card fields,
536
+ // session identity, and the authority input. Commit never
537
+ // constructs a fresh preview in place of the caller's; it
538
+ // recomputes the digest of the state the caller's preview saw
539
+ // and refuses any mismatch (stale, drifted, or missing token).
540
+ if (typeof input?.previewToken !== "string" || input.previewToken === "") {
541
+ return failResult("preview-token-required", "commit requires the previewToken from the immediately preceding preview; run mode 'preview' first");
542
+ }
543
+ const expectedToken = core.previewToken({
544
+ previews: batch.previews,
545
+ skipped: batch.skipped,
546
+ recovered,
547
+ target: { repositoryRoot: cwd, canonicalTasksMdPath: boardPath },
548
+ sessionOrReportId: input?.authority?.sessionOrReportId ?? resolvedListId,
549
+ authorityInput: input?.authority ?? null,
550
+ });
551
+ if (input.previewToken !== expectedToken) {
552
+ return failResult("preview-token-mismatch", "the previewToken does not match the current promotion state; the tasks, target, card fields, or authority input changed since the preview — run a fresh preview");
553
+ }
554
+ // 3. Owner confirmation boundary: a model-supplied
555
+ // quotedInstruction is NOT owner proof. The host presents one
556
+ // native confirmation dialog showing the exact preview state;
557
+ // the confirmed instruction text is what the writer records.
558
+ // Headless contexts fail closed.
559
+ if (!isNativeTuiContext(ctx) || typeof ctx?.ui?.confirm !== "function") {
560
+ return failResult("native-confirmation-required", "commit requires the interactive native TUI so the owner can confirm the exact promotion (fails closed)");
561
+ }
562
+ let confirmed;
563
+ try {
564
+ const previewLines = batch.previews.map((p) => `- task #${p.taskId}: "${p.cardInput.title}" → ${p.cardInput.lane} @ ${p.target.canonicalTasksMdPath}`);
565
+ confirmed = await ctx.ui.confirm(
566
+ "Promote session tasks to TASKS.md",
567
+ [
568
+ `Board: ${boardPath}`,
569
+ previewLines.length ? previewLines.join("\n") : "(nothing left to promote)",
570
+ `Authority to record: "${input.authority.quotedInstruction.slice(0, 200)}"`,
571
+ "One confirmation for this exact preview; changed state requires a fresh preview.",
572
+ ].join("\n"),
573
+ );
574
+ } catch (error) {
575
+ return failResult("confirmation-failed", String(error?.message || error).slice(0, 256));
576
+ }
577
+ if (confirmed !== true) {
578
+ return textResult({ ok: false, mode: "commit", cancelled: true, code: "owner-cancelled", reason: "the owner did not confirm this promotion", errors: ["the owner did not confirm this promotion"], persisted: false });
579
+ }
580
+ if (batch.previews.length === 0 && recovered.length === 0) {
581
+ return textResult({ ok: true, mode: "commit", promoted: [], recovered, skipped: batch.skipped, note: "nothing to promote" });
582
+ }
583
+ const missingOwner = batch.previews.filter((p) =>
584
+ !input.definitionOfDone || !input.stoppingPoint || !Array.isArray(input.scopePaths) || input.scopePaths.length === 0);
585
+ if (missingOwner.length > 0) {
586
+ return failResult("owner-fields-required", "commit requires owner-supplied definitionOfDone, stoppingPoint, and scopePaths; they are never invented from task text");
587
+ }
588
+ // 4. One trusted-writer call per task, in preview order. NOT
589
+ // atomic: partial success is reported and reconciled via the
590
+ // idempotency key.
591
+ const writerResults = [];
592
+ for (const preview of batch.previews) {
593
+ const result = board.writeCard({
594
+ boardPath,
595
+ input: {
596
+ title: preview.cardInput.title,
597
+ description: preview.cardInput.description,
598
+ spec: preview.cardInput.specification,
599
+ definitionOfDone: input.definitionOfDone,
600
+ stoppingPoint: input.stoppingPoint,
601
+ scope: input.scopePaths,
602
+ lane: preview.cardInput.lane,
603
+ dependencies: [],
604
+ importedId: preview.cardInput.importedId,
605
+ provenance: `session-task ${preview.source.taskListId}/${preview.source.taskId}`,
606
+ },
607
+ authority: input.authority,
608
+ surface: "tasks",
609
+ });
610
+ writerResults.push({
611
+ ok: result.ok,
612
+ taskId: preview.taskId,
613
+ idempotencyKey: preview.idempotencyKey,
614
+ cardId: result.ok ? result.cardId : null,
615
+ code: result.ok ? null : result.code,
616
+ errors: result.ok ? [] : (result.errors ?? []),
617
+ });
618
+ }
619
+ // 5. Dependency edges AFTER writer-minted IDs. Every resolved
620
+ // blocker for a dependent card is AGGREGATED into one update
621
+ // with the complete list — blockers are never silently
622
+ // discarded. Non-batch blockers are dropped and recorded
623
+ // (never dangling).
624
+ const rec = core.reconcileDependencies({
625
+ previews: batch.previews,
626
+ writerResults,
627
+ sessionTasks: selected,
628
+ // Blockers promoted in an earlier run resolve from the board's
629
+ // authoritative forward link — never silently discarded.
630
+ resolveBlockerCardId: (blockerKey) =>
631
+ core.findExistingPromotedCard(existsSync(boardPath) ? readFileSync(boardPath, "utf8") : "", blockerKey)?.cardId ?? null,
632
+ });
633
+ const cardIdByKey = new Map(writerResults.filter((r) => r.ok).map((r) => [r.idempotencyKey, r.cardId]));
634
+ const edgeUpdates = core.applyDependencyEdges({
635
+ edges: rec.edges.map((edge) => ({
636
+ ...edge,
637
+ dependentCardId: cardIdByKey.get(core.promotionIdempotencyKey(resolvedListId, edge.taskId)) ?? null,
638
+ })),
639
+ authority: input.authority,
640
+ currentDependencies: (cardId) => {
641
+ const markdown = existsSync(boardPath) ? readFileSync(boardPath, "utf8") : "";
642
+ const parsed = board.parseBoard(markdown);
643
+ return parsed.cards.find((c) => c.cardId === cardId)?.dependencies ?? [];
644
+ },
645
+ updateCardFn: ({ cardId, changes, authority }) => board.updateCard({ boardPath, cardId, changes, authority, surface: "tasks" }),
646
+ });
647
+ // 6. Reverse links: set metadata.promotedCardId on promoted tasks.
648
+ // The forward link (importedId/provenance on the card) is
649
+ // authoritative; the reverse link is a cache repaired from it.
650
+ const promoted = [];
651
+ for (const result of writerResults) {
652
+ if (!result.ok) continue;
653
+ const task = findTask(result.taskId);
654
+ if (task) {
655
+ task.metadata = { ...(task.metadata ?? {}), promotedCardId: result.cardId };
656
+ promoted.push({ taskId: result.taskId, cardId: result.cardId });
657
+ }
658
+ }
659
+ for (const result of recovered) {
660
+ const task = findTask(result.taskId);
661
+ if (task) task.metadata = { ...(task.metadata ?? {}), promotedCardId: result.cardId };
662
+ }
663
+ commitChange(ctx);
664
+ return textResult({
665
+ ok: promoted.length > 0 || recovered.length > 0 || writerResults.every((r) => r.ok),
666
+ mode: "commit",
667
+ atomic: false,
668
+ target: { repositoryRoot: cwd, canonicalTasksMdPath: boardPath },
669
+ promoted,
670
+ recovered,
671
+ failed: writerResults.filter((r) => !r.ok),
672
+ skipped: batch.skipped,
673
+ dependencyEdges: { applied: edgeUpdates.applied, failed: edgeUpdates.failed, dropped: rec.dropped },
674
+ note: "Batch promotion is a sequence of independent writer calls; re-run the same promotion to reconcile via the idempotency key.",
675
+ });
676
+ }
677
+ return failResult("invalid-input", "mode must be 'preview' or 'commit'");
678
+ },
679
+ });
680
+ }
681
+
682
+ // Rich, read-only view for the owner. It intentionally includes internal
683
+ // tasks and presentation fields that the model-facing TaskList omits.
684
+ if (typeof pi?.registerCommand === "function") {
685
+ pi.registerCommand("tasks", {
686
+ description: "Show all tasks (richer view than the LLM-facing TaskList).",
687
+ argumentHint: "",
688
+ handler: async (_args, ctx) => {
689
+ try {
690
+ if (tasks.length === 0) {
691
+ ctx?.ui?.notify?.("No tasks yet. Use TaskCreate to add one.", "info");
692
+ return;
693
+ }
694
+ const blocks = [];
695
+ for (const task of tasks) {
696
+ const internal = task.metadata?._internal === true ? " [internal]" : "";
697
+ blocks.push(`${renderTaskListLine(task)}${internal}`);
698
+ blocks.push(` ${task.description}`);
699
+ if (task.activeForm) blocks.push(` active: ${task.activeForm}`);
700
+ }
701
+ ctx?.ui?.notify?.(blocks.join("\n"), "info");
702
+ } catch (error) {
703
+ // Informational only: a stale context after session replacement is
704
+ // not a command failure and must never surface to the user/agent.
705
+ const message = error instanceof Error ? error.message : String(error);
706
+ if (!message.includes("stale after session replacement")) {
707
+ // Other UI failures are also non-fatal; the command has no state
708
+ // mutation to roll back.
709
+ }
710
+ }
711
+ },
712
+ });
713
+ }
714
+
715
+ pi.on?.("session_start", async (_event, ctx) => { syncState(ctx); });
716
+ pi.on?.("session_tree", async (_event, ctx) => { syncState(ctx); });
717
+ pi.on?.("session_shutdown", () => { lastCtx = null; });
718
+ syncState(typeof pi?.ctx !== "undefined" ? pi.ctx : null);
719
+
720
+ return {
721
+ registered: ["TaskCreate", "TaskGet", "TaskList", "TaskUpdate", "TaskPromote"],
722
+ capability: { marker: core.TASKS_CAPABILITY_ENV, skill: core.TASKS_SKILL_CAPABILITY, loaded: core.hasTasksCapability({}) },
723
+ };
724
+ }