β οΈ Superseded for runtime truth: See SSOT: OMP Extensions.
The historical discussion below is not executable guidance. Do not copy its legacy runtime or
tasktool name.
π Current runtime contract
id: omp-taskmaster
runtime: "omp + @oh-my-pi/*"
entrypoint: ~/.omp/agent/extensions/taskmaster.ts
source: ~/.omp/agent/extensions/src/taskmaster.ts
state: .taskmaster/tasks.yml
api:
hook: before_agent_start
tool: backlog(action=list|next|add|update|expand|parse_prd)
command: /tm
canonical: "https://ssot.0rk.de/plans/omp-extensions/omptask"π Historical design discussion
| claude-task-master | Native OMP |
|---|---|
tasks.json | .taskmaster/tasks.yml |
parse_prd | task tool action:"parse_prd" |
next_task (deps β priority, dep-count, id) | task action:"next" + before_agent_start inject |
set_status | task action:"update" |
expand_task | task action:"expand" |
| MCP server + CLI | native tool + /tm (YAGNI: none) |
task-master loop | OMPβs agent loop, steered by the injected task |
ποΈ 3. State (.taskmaster/tasks.yml)
tasks:
- id: 1
title: "Establish the foundation"
status: pending # pending | in-progress | done | blocked | deferred | cancelled
priority: high # high | medium | low
deps: [] # task ids that must be done/cancelled first
description: "Optional one-liner shown to the agent."
- id: 2
title: "Build on it"
status: pending
priority: medium
deps: [1] # stays blocked until task 1 is done
subtasks: [] # optional nested tasks, same shapeπ 4. The Extension (taskmaster.ts)
100% type-safe, crash-safe (4 OMP invariants), natively executed. State is re-read every call β zero cross-agent state corruption.
// ~/.omp/agent/extensions/taskmaster.ts
// Native OMP "Taskmaster": PRD-driven, dependency- & priority-aware task backlog
// in a single YAML file. API: @earendil-works/pi-coding-agent 0.79.x. Loaded as .ts via jiti.
import * as fs from "node:fs";
import * as path from "node:path";
import { parse, stringify } from "yaml";
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
type Status = "pending" | "in-progress" | "done" | "blocked" | "deferred" | "cancelled";
type Priority = "high" | "medium" | "low";
interface Task {
id: number; title: string; status: Status; priority: Priority; deps: number[];
description?: string; details?: string; subtasks?: Task[];
}
interface Store { tasks: Task[] }
const STATE_FILE = ".taskmaster/tasks.yml"; // project-root relative
const SATISFIES = new Set<Status>(["done", "cancelled"]); // a dep is met when done/cancelled
const RANK: Record<Priority, number> = { high: 0, medium: 1, low: 2 };
const stateFile = (ctx: Pick<ExtensionContext, "cwd">) => path.join(ctx.cwd, STATE_FILE);
function load(ctx: Pick<ExtensionContext, "cwd">): Store {
try {
const data = parse(fs.readFileSync(stateFile(ctx), "utf8")) as Partial<Store> | null;
return { tasks: Array.isArray(data?.tasks) ? (data!.tasks as Task[]) : [] };
} catch {
return { tasks: [] }; // missing or invalid β empty backlog (silent, crash-safe)
}
}
function save(ctx: Pick<ExtensionContext, "cwd">, store: Store): void {
const p = stateFile(ctx);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, stringify(store), "utf8");
}
const flatten = (tasks: Task[]): Task[] => tasks.flatMap((t) => [t, ...flatten(t.subtasks ?? [])]);
const unblocked = (t: Task, byId: Map<number, Task>) =>
(t.deps ?? []).every((d) => SATISFIES.has(byId.get(d)?.status ?? "pending"));
function nextTask(store: Store): Task | undefined {
const all = flatten(store.tasks);
const byId = new Map(all.map((t) => [t.id, t]));
return all
.filter((t) => t.status === "pending" && unblocked(t, byId))
.sort((a, b) =>
(RANK[a.priority] ?? 1) - (RANK[b.priority] ?? 1) ||
(a.deps?.length ?? 0) - (b.deps?.length ?? 0) ||
a.id - b.id)[0];
}
const fmt = (t: Task) =>
`[${t.id}] (${t.status}/${t.priority}) ${t.title}${t.deps?.length ? ` deps:${t.deps.join(",")}` : ""}`;
// Minimal PRD ingestion: every markdown heading (## β¦) and bullet/checkbox becomes a pending task.
function parsePrd(md: string, startId: number): Task[] {
const out: Task[] = [];
let id = startId;
for (const raw of md.split(/\r?\n/)) {
const line = raw.trim();
const m = line.match(/^#{2,6}\s+(.+)$/) ?? line.match(/^[-*]\s+(?:\[[ xX]\]\s+)?(.+)$/);
if (m?.[1]) out.push({ id: id++, title: m[1].trim(), status: "pending", priority: "medium", deps: [] });
}
return out;
}
const Params = Type.Object({
action: StringEnum(["list", "next", "add", "update", "expand", "parse_prd"] as const),
title: Type.Optional(Type.String({ description: "Task title (add)" })),
priority: Type.Optional(StringEnum(["high", "medium", "low"] as const)),
deps: Type.Optional(Type.Array(Type.Number(), { description: "Prerequisite task IDs (add)" })),
id: Type.Optional(Type.Number({ description: "Task ID (update/expand)" })),
status: Type.Optional(StringEnum(["pending", "in-progress", "done", "blocked", "deferred", "cancelled"] as const)),
subtasks: Type.Optional(Type.Array(Type.String(), { description: "Subtask titles (expand)" })),
path: Type.Optional(Type.String({ description: "PRD markdown path rel. to cwd (parse_prd); default docs/prd.md" })),
});
export default function taskmaster(pi: ExtensionAPI) {
// 1) Auto task-awareness: inject the active task into every turn's system prompt.
pi.on("before_agent_start", async (event, ctx) => {
const store = load(ctx);
const all = flatten(store.tasks);
if (all.length === 0) return;
const done = all.filter((t) => t.status === "done").length;
const t = nextTask(store);
if (!t) {
return { systemPrompt: `${event.systemPrompt}\n\n## π― Taskmaster\n${done}/${all.length} done β no unblocked pending tasks.` };
}
return {
systemPrompt:
`${event.systemPrompt}\n\n## π― Active Task β Taskmaster (${done}/${all.length} done)\n` +
`**[${t.id}] ${t.title}** Β· priority ${t.priority}` +
(t.description ? `\n${t.description}` : "") +
(t.details ? `\n${t.details}` : "") +
`\n\nWhen done, call the \`task\` tool with action "update", id ${t.id}, status "done" to unlock dependents.`,
};
});
// 2) LLM-callable backlog tool (replaces the claude-task-master MCP tools).
pi.registerTool({
name: "task",
label: "Taskmaster",
description:
"Project task backlog in .taskmaster/tasks.yml. Actions: list | next | add | update | expand | parse_prd. Dependency- and priority-aware.",
promptSnippet: "Manage the project task backlog (list/next/add/update/expand/parse_prd)",
promptGuidelines: [
"Use the task tool action `next` to pick work; it returns the highest-priority unblocked task.",
"After finishing a task, call the task tool with action `update`, the id, and status `done` so dependents unlock.",
],
parameters: Params,
async execute(_callId, p, _signal, _onUpdate, ctx) {
const store = load(ctx);
const byId = new Map(flatten(store.tasks).map((t) => [t.id, t]));
const nextId = () => flatten(store.tasks).reduce((m, t) => Math.max(m, t.id), 0) + 1;
switch (p.action) {
case "list": {
const text = store.tasks.length ? flatten(store.tasks).map(fmt).join("\n") : "No tasks. Use parse_prd or add.";
return { content: [{ type: "text", text }], details: { action: p.action, tasks: store.tasks } };
}
case "next": {
const t = nextTask(store);
return { content: [{ type: "text", text: t ? `Next: ${fmt(t)}${t.details ? `\n${t.details}` : ""}` : "No unblocked pending tasks." }], details: { action: p.action, next: t ?? null } };
}
case "add": {
if (!p.title) throw new Error("title is required for add");
const t: Task = { id: nextId(), title: p.title, status: "pending", priority: p.priority ?? "medium", deps: p.deps ?? [] };
store.tasks.push(t); save(ctx, store);
return { content: [{ type: "text", text: `Added ${fmt(t)}` }], details: { action: p.action, task: t } };
}
case "update": {
if (p.id === undefined || !p.status) throw new Error("id and status are required for update");
const t = byId.get(p.id);
if (!t) throw new Error(`task ${p.id} not found`);
t.status = p.status; save(ctx, store);
const nxt = nextTask(store);
return { content: [{ type: "text", text: `[${p.id}] β ${p.status}.${nxt ? ` Next: ${fmt(nxt)}` : " Backlog clear."}` }], details: { action: p.action, id: p.id, status: p.status, next: nxt ?? null } };
}
case "expand": {
if (p.id === undefined || !p.subtasks?.length) throw new Error("id and subtasks are required for expand");
const t = byId.get(p.id);
if (!t) throw new Error(`task ${p.id} not found`);
let sid = nextId();
t.subtasks = [...(t.subtasks ?? []), ...p.subtasks.map((title) => ({ id: sid++, title, status: "pending" as Status, priority: t.priority, deps: [] }))];
save(ctx, store);
return { content: [{ type: "text", text: `Expanded [${p.id}] into ${p.subtasks.length} subtask(s).` }], details: { action: p.action, id: p.id, subtasks: t.subtasks } };
}
case "parse_prd": {
const rel = p.path ?? "docs/prd.md";
let md: string;
try { md = fs.readFileSync(path.join(ctx.cwd, rel), "utf8"); } catch { throw new Error(`PRD not found: ${rel}`); }
const added = parsePrd(md, nextId());
if (!added.length) throw new Error("No headings or bullets found to convert into tasks.");
store.tasks.push(...added); save(ctx, store);
return { content: [{ type: "text", text: `Ingested ${added.length} task(s) from ${rel}.` }], details: { action: p.action, added } };
}
default:
return { content: [{ type: "text", text: `Unknown action: ${(p as { action: string }).action}` }], details: { action: "list", tasks: store.tasks } };
}
},
});
// 3) Human-facing read-only summary command.
pi.registerCommand("tm", {
description: "Show the Taskmaster backlog summary and the next unblocked task",
handler: async (_args, ctx) => {
const store = load(ctx);
const all = flatten(store.tasks);
if (!all.length) { ctx.ui.notify("Taskmaster: no tasks (.taskmaster/tasks.yml). Use the task tool's parse_prd or add.", "info"); return; }
const done = all.filter((t) => t.status === "done").length;
const n = nextTask(store);
ctx.ui.notify(`Taskmaster ${done}/${all.length} done Β· next: ${n ? fmt(n) : "β (blocked/complete)"}`, "info");
},
});
}π― 5. The Loop
- Seed β write
docs/prd.md, then have the agent calltask parse_prd(ortask add). - Focus β every turn,
before_agent_startinjects the highest-priority unblocked task. - Execute β agent finishes, calls
task update id=<n> status=done. - Advance β dependents unlock; the next turn auto-focuses the new front. βΎοΈ
/tm prints done/total Β· next anytime. No daemon, no server β the YAML file is the whole system.