⚠️ Superseded for runtime truth: See SSOT: OMP Extensions.

The historical discussion below is not executable guidance. Do not copy its legacy runtime or task tool 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-masterNative OMP
tasks.json.taskmaster/tasks.yml
parse_prdtask tool action:"parse_prd"
next_task (deps β†’ priority, dep-count, id)task action:"next" + before_agent_start inject
set_statustask action:"update"
expand_tasktask action:"expand"
MCP server + CLInative tool + /tm (YAGNI: none)
task-master loopOMP’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

  1. Seed β€” write docs/prd.md, then have the agent call task parse_prd (or task add).
  2. Focus β€” every turn, before_agent_start injects the highest-priority unblocked task.
  3. Execute β€” agent finishes, calls task update id=<n> status=done.
  4. 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.