@evoclock/pi-agentic-driver 0.9.4 → 0.10.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/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.1",
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",
@@ -379,6 +379,75 @@ function safeJsonParse(text) {
379
379
  }
380
380
  }
381
381
 
382
+ const CAPABILITY_REGISTRY_PATH = ".vogelkop/capabilities.json";
383
+
384
+ function capabilityRegistryError(registryPath, reason) {
385
+ return Object.assign(new Error(`capability registry is invalid at ${registryPath}: ${reason}`), {
386
+ code: "capability-registry-invalid",
387
+ });
388
+ }
389
+
390
+ // The workspace registry is deliberately loaded at tool-call time. A missing
391
+ // registry is an empty declaration; an unreadable or malformed present file is
392
+ // not silently downgraded to empty. Capability-less boards are independent of
393
+ // the registry, so they retain the writer's existing behavior even while an
394
+ // unused registry file is malformed.
395
+ function loadWorkspaceCapabilityNames(boardPath) {
396
+ const registryPath = join(dirname(boardPath), CAPABILITY_REGISTRY_PATH);
397
+ let text;
398
+ try {
399
+ text = readFileSync(registryPath, "utf8");
400
+ } catch (error) {
401
+ if (error?.code === "ENOENT") return [];
402
+ throw capabilityRegistryError(registryPath, String(error?.message || error).slice(0, 256));
403
+ }
404
+ let entries;
405
+ try {
406
+ entries = JSON.parse(text);
407
+ } catch (error) {
408
+ throw capabilityRegistryError(registryPath, `invalid JSON: ${String(error?.message || error).slice(0, 256)}`);
409
+ }
410
+ if (!Array.isArray(entries)) {
411
+ throw capabilityRegistryError(registryPath, "expected a JSON array");
412
+ }
413
+ const names = [];
414
+ for (const [index, entry] of entries.entries()) {
415
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)
416
+ || typeof entry.name !== "string" || entry.name.trim() === ""
417
+ || (entry.description !== undefined && typeof entry.description !== "string")) {
418
+ throw capabilityRegistryError(registryPath, `entry ${index} must contain a non-empty string name and an optional string description`);
419
+ }
420
+ names.push(entry.name);
421
+ }
422
+ return names;
423
+ }
424
+
425
+ function boardHasCapabilities(boardPath) {
426
+ if (!existsSync(boardPath)) return false;
427
+ try {
428
+ const parsed = parseBoard(readFileSync(boardPath, "utf8"));
429
+ return parsed.cards.some((card) => Array.isArray(card.capabilities) && card.capabilities.length > 0);
430
+ } catch {
431
+ // Let the writer report the board error; do not use an unreadable board to
432
+ // justify ignoring a malformed capability registry.
433
+ return true;
434
+ }
435
+ }
436
+
437
+ function workspaceRegistriesFor(boardPath, requestedCapabilities) {
438
+ try {
439
+ return { capabilities: loadWorkspaceCapabilityNames(boardPath) };
440
+ } catch (error) {
441
+ const capabilityRequested = requestedCapabilities !== undefined
442
+ && requestedCapabilities !== null
443
+ && (!Array.isArray(requestedCapabilities) || requestedCapabilities.length > 0);
444
+ if (error?.code === "capability-registry-invalid" && !capabilityRequested && !boardHasCapabilities(boardPath)) {
445
+ return { capabilities: [] };
446
+ }
447
+ throw error;
448
+ }
449
+ }
450
+
382
451
  // ---------------------------------------------------------------------------
383
452
  // Closed validator (§1, §6 gate 2). Validate before persist; decline on
384
453
  // violation; duplicate cardId is a validation error; decorative use of the
@@ -2898,11 +2967,12 @@ export function registerKanbanBoardTools(pi, { boardPath = null, resolveBoardPat
2898
2967
  // reason), never raw throws — the model sees the governance reason.
2899
2968
  let result;
2900
2969
  try {
2970
+ const registries = workspaceRegistriesFor(activeBoardPath, input?.capabilities);
2901
2971
  result = writeCard({
2902
2972
  boardPath: activeBoardPath,
2903
2973
  input: writerInput,
2904
2974
  authority: input?.authority,
2905
- registries: {},
2975
+ registries,
2906
2976
  surface: "tasks",
2907
2977
  // No requireExistingBoard: the write tool bootstraps a fresh
2908
2978
  // board when none exists. Recreation is safe — fresh content only,
@@ -3028,9 +3098,10 @@ export function registerKanbanBoardTools(pi, { boardPath = null, resolveBoardPat
3028
3098
  }
3029
3099
  let result;
3030
3100
  try {
3101
+ const registries = workspaceRegistriesFor(activeBoardPath, operation === "update" ? input?.capabilities : undefined);
3031
3102
  result = operation === "update"
3032
- ? updateCard({ boardPath: activeBoardPath, cardId: input.cardId, changes, authority: input?.authority, registries: {}, surface: "tasks" })
3033
- : deleteCard({ boardPath: activeBoardPath, cardId: input.cardId, authority: input?.authority, registries: {}, surface: "tasks" });
3103
+ ? updateCard({ boardPath: activeBoardPath, cardId: input.cardId, changes, authority: input?.authority, registries, surface: "tasks" })
3104
+ : deleteCard({ boardPath: activeBoardPath, cardId: input.cardId, authority: input?.authority, registries, surface: "tasks" });
3034
3105
  } catch (error) {
3035
3106
  const code = typeof error?.code === "string" ? error.code : "writer-error";
3036
3107
  const value = { ok: false, persisted: false, code, reason: String(error?.message || error).slice(0, 512), errors: [String(error?.message || error).slice(0, 512)] };
@@ -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