@evoclock/pi-agentic-driver 0.9.4 → 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.
package/README.md CHANGED
@@ -101,7 +101,8 @@ only, not a runtime dependency).
101
101
 
102
102
  Plans stay close to the work. They do not become board work by accident. The tasks skill gives Pi a familiar session list. It adds an owner-approved way to keep selected tasks in the durable `TASKS.md` board.
103
103
 
104
- - **Keep the working list simple.** Use `TaskCreate`, `TaskGet`, `TaskList`, and `TaskUpdate` as usual.
104
+ - **Keep the working list simple.** Use `TaskCreate`, `TaskGet`, `TaskList`, and `TaskUpdate` as usual; the above-editor widget and footer pill stay live as tasks change.
105
+ - **Keep the Claude-like view.** `/tasks` provides descriptions, active forms, and internal-task markers without mutating the plan.
105
106
  - **Make durable work deliberate.** `TaskPromote` never runs automatically. It previews the exact cards and destination before the owner confirms.
106
107
  - **Keep authority clear.** The trusted board writer creates and updates `TASKS.md`. The skill does not bypass it.
107
108
  - **Make migration gentle.** Existing task files remain readable. Moving to the driver-owned skill does not strand current plans.
@@ -63,8 +63,121 @@ export default async function tasksPi(pi) {
63
63
  let highWaterMark = 0;
64
64
  let taskListId = null;
65
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
+ };
66
178
 
67
179
  const syncState = (ctx) => {
180
+ if (ctx) lastCtx = ctx;
68
181
  taskListId = core.resolveTaskListId({ session: ctx?.sessionManager?.getSessionId?.() ?? null });
69
182
  mirrorPath = core.tasksMirrorPath(taskListId);
70
183
  // Replay: the branch's last picc-tasks-state entry wins; disk mirror is
@@ -85,9 +198,11 @@ export default async function tasksPi(pi) {
85
198
  } else {
86
199
  tasks = fromBranch.tasks; highWaterMark = branchHwm;
87
200
  }
201
+ refreshUI(ctx);
88
202
  };
89
203
 
90
204
  const commitChange = (ctx) => {
205
+ if (ctx) lastCtx = ctx;
91
206
  const snapshot = { tasks: tasks.map((t) => ({ ...t })), highWaterMark };
92
207
  try {
93
208
  pi.appendEntry(TASK_STATE_ENTRY, snapshot);
@@ -95,6 +210,7 @@ export default async function tasksPi(pi) {
95
210
  if (mirrorPath) {
96
211
  try { core.persistSnapshot(mirrorPath, snapshot); } catch { /* non-fatal */ }
97
212
  }
213
+ refreshUI(ctx);
98
214
  };
99
215
 
100
216
  const findTask = (id) => tasks.find((t) => t.id === id);
@@ -186,15 +302,18 @@ export default async function tasksPi(pi) {
186
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.",
187
303
  parameters: { type: "object", additionalProperties: false, properties: {} },
188
304
  async execute() {
189
- if (tasks.length === 0) return { content: [{ type: "text", text: "No tasks found" }], details: { tasks: [] } };
190
- const unresolved = new Set(tasks.filter((t) => t.status !== "completed").map((t) => t.id));
191
- const lines = tasks.map((t) => {
192
- let line = `#${t.id} [${t.status}] ${t.subject}`;
193
- if (t.owner) line += ` (${t.owner})`;
194
- if (t.blockedBy.length > 0) line += ` [blocked by ${t.blockedBy.filter((id) => unresolved.has(id)).map((id) => `#${id}`).join(", ")}]`;
195
- return line;
196
- });
197
- return { content: [{ type: "text", text: lines.join("\n") }], details: { tasks: tasks.map((t) => ({ id: t.id, subject: t.subject, status: t.status, ...(t.owner !== undefined ? { owner: t.owner } : {}), blockedBy: t.blockedBy.filter((id) => unresolved.has(id)) })) } };
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 } };
198
317
  },
199
318
  });
200
319
 
@@ -560,7 +679,42 @@ export default async function tasksPi(pi) {
560
679
  });
561
680
  }
562
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
+
563
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; });
564
718
  syncState(typeof pi?.ctx !== "undefined" ? pi.ctx : null);
565
719
 
566
720
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evoclock/pi-agentic-driver",
3
- "version": "0.9.4",
3
+ "version": "0.10.0",
4
4
  "description": "Guardrail extensions for Agentic Driver: advisory review, bounded Herdr communication, and guarded worker lifecycle.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -15,6 +15,22 @@ copied; this is a clean AGPL-3.0-only reimplementation to the same behavior
15
15
  spec. picc-tasks is an independent project by its author, used as reference;
16
16
  it is not affiliated with, nor does it endorse, pi-agentic-driver.
17
17
 
18
+ ## Interactive task view
19
+
20
+ The driver-owned skill preserves picc-tasks' owner-facing UI as well as its
21
+ session tools:
22
+
23
+ - the above-editor widget shows visible tasks with `▫`/`▪`/`✓` status icons,
24
+ status, subject, owner, live unresolved blockers, and a count header;
25
+ - the footer status pill shows active and done/total counts;
26
+ - `/tasks` is a richer, read-only view with descriptions, `activeForm`, and
27
+ `[internal]` markers.
28
+
29
+ The widget and pill refresh after every task mutation and on `session_start` or
30
+ `session_tree`. UI refresh is best-effort: a stale context after session
31
+ replacement is swallowed and never turns a successful mutation or `/tasks`
32
+ command into an error.
33
+
18
34
  ## What this skill owns
19
35
 
20
36
  1. The **session task list** — working memory for the current session. Every