@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 +2 -1
- package/extensions/tasks.ts +163 -9
- package/package.json +1 -1
- package/scripts/enforcement/task_board_core_pi.js +74 -3
- package/skills/tasks/SKILL.md +16 -0
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.
|
package/extensions/tasks.ts
CHANGED
|
@@ -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
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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.
|
|
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
|
|
3033
|
-
: deleteCard({ boardPath: activeBoardPath, cardId: input.cardId, authority: input?.authority, registries
|
|
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)] };
|
package/skills/tasks/SKILL.md
CHANGED
|
@@ -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
|