Scope: Persistent, file-backed task management for Matrixx agent orchestration. Audience: Engineers evolving the task system: storage, tools, hooks, scheduling, and agent integration. Version: 2.6.10, verified against source. Canonical config lives in
tasks.*(src/config/schema/tasks.ts); the file-backed task system is unconditional andmorpheus.tasks.*remains a lower-precedence legacy fallback for storage keys (see §3). The legacy todo system is gone: seedocs/legacy-todo-migration.mdfor the retired hooks, the config keys that are now accepted no-ops, and why nothing reads session todo state any longer.
Every normative claim below traces to the source path cited beside it. Where behavior belongs to another doc, this spec cross-links instead of duplicating: hook internals → docs/hooks.md, orchestration and wave planning flow → docs/orchestration.md, command reference → docs/command-reference.md.
The Task System replaces OpenCode's ephemeral session-memory todos with file-backed tasks that survive restarts, support explicit dependencies, and drive automatic parallelization.
| Principle | Implication |
|---|---|
| File is the source of truth | No in-memory registry, no DB. One T-{uuid}.json per task in getTaskDir(). Stateless CRUD keeps reasoning and recovery simple. |
| Atomicity over speed | Every write is write tmp + renameSync. Lock file uses wx creation with stale eviction. Correctness under concurrent agents is non-negotiable. |
| Additive dependencies | addBlocks/addBlockedBy append via Set, never replace. Prevents races when two agents update deps concurrently. |
| One task, one owner, one transition | task_update(in_progress) immediately before work, completed immediately after. No batch completions. Enforced by prompt plus continuation hook. |
| Blocked means schedulable | task_list filters blockedBy to unresolved entries only. The scheduler can skip blocked tasks without an extra query. |
| No dual substrate | There is exactly one task store. The legacy todo system was removed, so no prompt, tool registry, or hook can route work to a second substrate. |
| Drop-in upgrade | Enabling flips tool registry, hooks, agent prompts, and storage without touching agent business logic. |
matrixx.jsonc
tasks { scope?, storage_path?, task_list_id?,
stale_after_hours?, background_stale_after_hours?,
session_scoped?, pollTimeoutMs? } (canonical)
morpheus.tasks.* / task.pollTimeoutMs (legacy fallback for storage keys only)
│
├─── Tool Registry (src/plugin/tool-registry.ts)
│ always registers 5 tools:
│ task_create · task_get · task_list · task_update · task_cleanup
│
├─── Hook Wiring (summaries here, internals in docs/hooks.md)
│ ├─ createContinuationHooks → taskContinuationEnforcer (event:idle, 2s countdown)
│ ├─ createToolGuardHooks → taskEditGuard (tool.execute.before, Write/Edit/Read/bash guard)
│ └─ createSessionHooks → taskResumeInfo (tool.execute.after, resume hint)
│ → delegateTaskRetry, emptyTaskResponseDetector
│
├─── Agent Prompts (dynamic-agent-prompt-builder, morpheus/keymaker/mouse factories)
│ always task discipline (`task_create` / `task_update`)
│
└─── Runtime
task_create → lock → T-{uuid}.json (pending) → unlock
task_update → lock → merge fields → validate → atomic write → unlock
task_list → readdir → validate → filter active → resolve blockedBy
task_get → readFile → validate
task_cleanup → readdir → filter completed + olderThan → unlink
| Component | Path | Behavior |
|---|---|---|
| Tool registry | src/plugin/tool-registry.ts |
5 task tools registered |
Tool config (tool-config-handler) |
src/plugin-handlers/tool-config-handler.ts |
The two legacy todo tools stay denied globally plus per-agent, so nothing re-introduces the retired substrate |
task-edit-guard |
src/hooks/task-edit-guard/ |
Blocks generic Write/Edit/Read on .matrixx/plans, plus bash mutation patterns on .matrixx/plans and .matrixx/tasks |
| Agent prompts | src/agents/, dynamic-agent-prompt-builder |
task discipline (task_create/task_update workflow) |
| Storage | src/features/task-storage/storage.ts |
file system (.matrixx/tasks/ or global) |
| Continuation | src/hooks/task-continuation-enforcer/ |
event:idle countdown, unconditional |
| Persistence | — | survives restart, migratable |
| Plan files | src/tools/plan/ (plan_create/read/update/list/delete + plan_tasks for manifest) |
.matrixx/plans/*.md edited only via plan_* tools with LINE#ID anchors (see §9) |
Canonical source: TasksConfigSchema in src/config/schema/tasks.ts. Resolution: resolveTasksConfig() in src/shared/task-system-gating.ts. Predicate: isTaskSystemEnabled() in the same file.
There is no enable/disable switch. resolveTasksConfig() always resolves enabled: TASK_SYSTEM_DEFAULT (true); the 5 task tools are always registered and the enforcer is always wired. The three config keys that used to toggle the system are still parsed so old config files keep loading, but they are ignored and produce a single one-time deprecation log plus toast per session (collectLegacyKeyWarnings in src/shared/task-system-gating.ts). See docs/legacy-todo-migration.md for which keys those are.
- Canonical predicate:
isTaskSystemEnabled(config)returnsresolveTasksConfig(config).enabled, which is alwaystrue. Keep calling the predicate rather than reading config fields, so a future gate has one place to land. - Wiring:
createContinuationHooksgatestask-continuation-enforceron this predicate.task-edit-guardandtask-resume-infoare unconditional (they match on their own path patterns). - First-load migration: the
_migrationsmarkertask_system_default_truestill marks fresh clones; no user action needed.
{
"tasks": {
"storage_path": "/custom/path", // absolute path used verbatim; relative path joins cwd
"task_list_id": "my-project", // explicit list ID, alternative to ULTRAWORK_TASK_LIST_ID
"scope": "project", // "project" | "global"
"stale_after_hours": 24, // stale-task threshold for task-continuation enforcer
"background_stale_after_hours": 2, // background completion-gate staleness window
"session_scoped": true, // enforcer sees only current session + live subagents
"pollTimeoutMs": 600000 // blocking task() poll budget, minimum 60000
}
}| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Retained-deprecated. Parsed but ignored: the system is unconditional. Setting it to false logs a one-time deprecation warning. |
storage_path |
string |
none | Absolute path used verbatim; relative path resolves as join(cwd, storage_path). When set, bypasses scope/listId resolution. |
task_list_id |
string |
none | Explicit list ID. Alternative to the ULTRAWORK_TASK_LIST_ID env var. Sanitized to [a-zA-Z0-9_-]. |
scope |
"project" | "global" |
"project" |
project → .matrixx/tasks in the project root. global → ~/.config/opencode/tasks/{listId} via getOpenCodeConfigDir(). |
stale_after_hours |
number, min 0.25 |
24 |
Pending/in_progress tasks with no file activity for this many hours count as stale. Stale-only queues skip the continuation directive; mixed queues annotate stale entries. Fractional hours are accepted (floor 0.25 = 15 minutes), because the background gate's window is too coarse at a 1-hour floor. |
background_stale_after_hours |
number, min 0.25 |
2 |
Window used only by the background-agent completion gates. A pending/in_progress task with no file activity for this long stops blocking completion of its background handle. |
session_scoped |
boolean |
true |
When true, the task-continuation-enforcer only considers tasks created by the current session (or its live subagent sessions). When false, all project tasks count regardless of origin. |
pollTimeoutMs |
number, min 60000 |
600000 (10 min) |
Poll timeout for blocking task() calls. Raise for agents that delegate to subagents with long wall times. |
claude_code_compat |
boolean |
false |
Legacy morpheus.tasks flag, reserved path-compatibility marker. No behavior is keyed on it in the current tree. |
Legacy mirror: MorpheusTasksConfigSchema in src/config/schema/morpheus.ts keeps storage_path, task_list_id, scope, stale_after_hours, session_scoped as fallbacks. Resolution order per key: tasks.* → morpheus.tasks.* → default. task.pollTimeoutMs falls back to tasks.pollTimeoutMs the same way. background_stale_after_hours has no legacy mirror: it is canonical-only and resolves to its default when the canonical key is absent.
Two staleness windows. The two thresholds are independent knobs, resolved by different functions in src/hooks/task-continuation-enforcer/staleness.ts, and they exist because their failure costs differ:
| Window | Default | Consumer | Why |
|---|---|---|---|
stale_after_hours |
24 | task-continuation-enforcer |
A long-running task is normal here, and a missed continuation nudge is cheap. The 24h default stays. |
background_stale_after_hours |
2 | Background-agent completion gates (session.idle, polling, reconcile probe) |
A task that stays pending/in_progress with no file activity while its worker is gone is a wedged handle, not a slow one. Releasing it is what lets the session complete. |
Both read the same basis, the task file's mtime. The wall-clock backstop is a third, separate knob and is unchanged: background_task.wallClockTimeoutMs, default 0 (off).
Implemented in getTaskDir() plus resolveTaskListId() in src/features/task-storage/storage.ts.
Priority for listId:
ULTRAWORK_TASK_LIST_IDenv (trimmed)config.tasks.task_list_id, else legacyconfig.morpheus.tasks.task_list_id(trimmed)basename(process.cwd()), sanitized
Sanitization is sanitizePathSegment(): every char outside [a-zA-Z0-9_-] becomes -; an empty result becomes "default". There is no CLAUDE_CODE_TASK_LIST_ID lookup in the current source; do not set it.
Priority for directory:
if storage_path is absolute → storage_path
else if storage_path is relative → join(cwd, storage_path)
else if scope === "global" OR !directory → join(getOpenCodeConfigDir(), "tasks", sanitizedListId)
else → join(directory, ".matrixx", "tasks") // default: project-scoped
ensureDir() (mkdirSync -p) runs before every read and write. getProjectTaskDir(directory) is the shorthand for the default branch.
# Env override check (global scope only changes the path; project scope keeps .matrixx/tasks)
ULTRAWORK_TASK_LIST_ID=my-list opencode
# Log grep (after enabling the task system)
grep -E "task.*dir|getTaskDir|migrateLegacy" /tmp/matrixx.log | tail -n 100The task store (.matrixx/tasks/) is a write channel separate from the codebase. Who may reach it is decided in exactly one place: src/plugin-handlers/task-permissions.ts, applied during applyToolConfig.
The criterion:
An agent receives
task_*iffMODE ∈ {primary, all}and its work is multi-step.
The code half of that is isTaskStorePrimary(agent) = agent.mode === "primary" || agent.mode === "all". It is derived from the agent's declared mode (AgentMode in src/agents/types.ts), never from a hardcoded list of names. A name list is precisely what let the original drift go unnoticed: keymaker's prompt called the task store "your execution backbone" while its permission arm held no task_* at all, and cipher's prompt told users to delegate "via the task() tool" while task was denied everywhere. Documentation and implementation agreed with each other, and both were wrong.
The criterion is ONE-DIRECTIONAL. "iff" is being used loosely and readers should know it: the code tests only the sufficient direction (mode ∈ {primary, all} ⇒ grant). There is no biconditional test, nothing scans prompts to decide whether an agent's work "is multi-step", and no test may assert the converse.
The Mouse carve-out. Mouse is mode: "subagent" and is nonetheless granted full task permissions. It executes delegated multi-step work inside its own child session and must own the resulting task-store records. Mouse is the standing counter-example: if you add a test or a lint rule that asserts the converse of the criterion, Mouse will fail it correctly.
The permission split. Two exported objects, both built on denyTodoTools (todowrite/todoread denied — the legacy todo API is never granted to a task-capable agent):
| Object | Contents | Meaning |
|---|---|---|
taskStorePermissions |
task_* |
Tracking only. Read and write own records, no delegation. |
fullTaskPermissions |
task_* + task + teammate |
Tracking plus outgoing delegation. |
deriveTaskPermissions picks taskStorePermissions when the agent declares permission.task === "deny" for itself, and fullTaskPermissions otherwise. That is why a read-only agent such as Sentinel is safe without a special-case arm and without a name list: it restricts itself, and the self-declared denial is honoured automatically. Sentinel's read-only mandate covers files; the task store is not a source file, it is how a read-only auditor hands findings to an implementer without touching the code.
grantTaskPermissions merges fill-only via ??=. A factory value or an explicit user override in an agent's own config is never overwritten.
task_create returns one shape. { tasks: [...], errors: [...] }, always, including when a single task is created. This is a breaking change for any caller reading the old single-object { task: { id, subject } } return.
Batches are partial-success: an invalid item fails only itself and is reported in errors with its index; the remaining items are still created. Do not treat a non-empty errors array as "nothing was created".
priority has no reader. It is stored on the task object and round-trips through create and update, but nothing currently sorts, filters or renders on it. Treat it as inert: do not build a plan, a workflow, or a UI that depends on it having an effect.
| Layer | File | Type | Notes |
|---|---|---|---|
| Storage | src/features/task-storage/types.ts |
Task (TaskSchema) |
Slim storage model. id is a plain string; threadID optional. strict(), unknown keys rejected. |
| API | src/tools/task/types.ts |
TaskObject (TaskObjectSchema) |
Tool I/O model. blocks/blockedBy default to []; threadID is required. Alias TaskSchema = TaskObjectSchema exists for Claude-style compat. strict() likewise. |
Mapping: TaskObject is Task plus a required threadID (auto-set to the calling session ID), repoURL, and parentID. When evolving the model, add the field to the storage TaskSchema first, then extend TaskObjectSchema, keeping both in sync.
Source: src/tools/task/types.ts.
TaskObjectSchema = z.object({
id: z.string(), // T-{uuid}, written by generateTaskId()
subject: z.string(), // imperative: "Implement auth"
description: z.string(), // default ""
status: z.enum(["pending", "in_progress", "completed", "deleted"]),
activeForm: z.string().optional(), // "Implementing auth"
blocks: z.array(z.string()).default([]), // IDs this task blocks
blockedBy: z.array(z.string()).default([]), // IDs that block this task
owner: z.string().optional(), // agent name
metadata: z.record(z.string(), z.unknown()).optional(),
repoURL: z.string().optional(),
parentID: z.string().optional(), // parent task for subtasks
threadID: z.string(), // REQUIRED, auto-set to context.sessionID
projectRoot: z.string().optional(), // auto-set to ctx.directory
}).strict()ID pattern: TASK_ID_PATTERN = /^T-[A-Za-z0-9]+(-[A-Za-z0-9]+)*$/ (src/tools/task/constants.ts). That is T- followed by dash-separated alphanumeric segments. It rejects truncated IDs (T-), trailing dashes (T-abc-), and double dashes (T-a--b). generateTaskId() emits T-{randomUUID()} (src/features/task-storage/storage.ts). task_get/task_update run input IDs through parseTaskId() and return { error: "invalid_task_id" } on mismatch.
Input schemas (same file):
TaskCreateInputSchema:subject(required),description?,activeForm?,blocks?/blockedBy?(arrays ofTaskIdSchema),owner?,metadata?,repoURL?,parentID?(TaskIdSchema). NothreadID: the tool sets it from the session.TaskUpdateInputSchema:id(required, plain string; pattern-checked in the handler),subject?,description?,status?,activeForm?,addBlocks?/addBlockedBy?(arrays ofTaskIdSchema),owner?,metadata?. The tool surface exposesadd*additive fields, not full replacement.repoURL/parentIDare stored model fields; set them at create time.TaskGetInputSchema:id(required, plain string; pattern-checked in the handler).TaskListInputSchema:status?,parentID?(both optional filters). Note the registeredtask_listtool surface exposes onlyparentIDas a declared arg;statusfiltering is schema-level.
task_create
│
▼
┌─────────┐
│ pending │◄──────────────────────────────┐
└────┬────┘ │
│ task_update({status:"in_progress"}) │
▼ │
┌──────────────┐ │
│ in_progress │──task_update({status:"pending"})──┘ (re-queue)
└──────┬───────┘
│ task_update({status:"completed"})
▼
┌───────────┐
│ completed │ ── task_cleanup({olderThan}) ──► unlinked
└───────────┘
│ task_update({status:"deleted"})
▼
┌─────────┐
│ deleted │ (terminal; removed by manual cleanup)
└─────────┘
- Active means
pendingorin_progress.task_listreturns only active tasks (plus the schema-levelstatusfilter path). - Terminal means
completedordeleted.task_cleanupdeletes onlycompletedfiles (honoringolderThan);deletedmarks terminal intent and is filtered the same way. - Discipline:
in_progressmeans exactly one task at a time per agent. Markin_progressbefore work,completedimmediately after, never batch completions.
Matches TaskObjectSchema above (threadID present and required, blocks/blockedBy defaulted):
{
"id": "T-2a200c59-1a36-4dad-a9c3-3064d180f694",
"subject": "Implement user authentication",
"description": "Add JWT-based auth to API endpoints",
"status": "pending",
"activeForm": "Implementing user authentication",
"blocks": [],
"blockedBy": ["T-9f31c2aa-77b0-4e11-9c2d-41c8e5a6b012"],
"owner": "morpheus",
"threadID": "ses_abc123",
"projectRoot": "/home/user/project",
"metadata": { "priority": "high" }
}Source: src/features/task-storage/storage.ts unless noted.
# scope=project (default)
.matrixx/tasks/
T-2a200c59-....json
T-abc12345-....json
.lock # ephemeral {id, timestamp}
# scope=global
~/.config/opencode/tasks/{sanitizedListId}/
T-*.json
.lock
There is no index file. The directory listing is the index; the T-*.json glob is the query. listTaskFiles() maps that glob to IDs.
writeJsonAtomic(path, data):
- Serialize with
JSON.stringify(data, null, 2). writeFileSync(tmpPath, content)wheretmpPath = path + ".tmp." + Date.now().renameSync(tmpPath, path), atomic on POSIX.- On failure, best-effort
unlinkSync(tmpPath); the caller surfacesinternal_error.
Guarantee: readers never see a half-written file.
acquireLock(dir) and acquireLockWithRetry(dir):
- Create:
writeFileSync(.lock, JSON.stringify({id: uuid, timestamp: Date.now()}), {flag: "wx"}). Fails withEEXISTwhen another writer holds the lock. - Stale eviction:
STALE_LOCK_THRESHOLD_MS = 30000(30 seconds). OnEEXIST, the holder reads.lock; whenDate.now() - timestamp > 30s(or the lock is unreadable), it unlinks.lockand retries once. This bounds deadlock from crashed agents at 30 seconds. - Release:
release()re-reads.lockand unlinks only when the storedidmatches the holder's ownid, so a slow holder never deletes a successor's lock. Releasing a failed acquisition is a no-op. - Retry:
acquireLockWithRetry(thetask_createpath) attempts up to 4 times with backoff sleeps of15 * 2 ** attemptms (15, 30, 60), then returns{ acquired: false }.task_updateuses single-attemptacquireLockand returns{ error: "task_lock_unavailable" }when contended; the caller retries.
migrateLegacyTasksIfNeeded(config, directory):
- Triggers on
task_create/task_listwhen the project dir is empty or missing and the legacy global dir holdsT-*.jsonfiles. - Copies each missing
T-*.json(never overwrites). Logsmigrated N tasks. - One way only (global to project) and idempotent.
Session helpers live in src/features/task-storage/session-storage.ts: thin wrappers over storage.ts plus TaskObjectSchema validation for session-scoped reads used by the continuation enforcer.
Session liveness and orphans: tasks carry threadID (owning session). Subagent sessions are tracked in src/features/session-state/state.ts via registerSubagentSession / unregisterSubagentSession; getSubagentSessionIDs(parent) returns only live subagent sessions. The enforcer's session filter (filterTasksBySession in src/hooks/task-continuation-enforcer/todo.ts) admits a task only when its threadID is the current session or a live subagent of it. Tasks orphaned by a dead subagent session are excluded from continuation directives. unregisterSubagentSession prunes both the live set and the parent map, so dead sessions never leak into the filter. When tasks.session_scoped is false, this filter is bypassed and all project tasks count.
All five are ToolDefinition factories createTask*(config, ctx) in src/tools/task/, registered in src/plugin/tool-registry.ts when isTaskSystemEnabled(config). Tool contexts take ctx.directory (project root) and context.sessionID (stamped as threadID).
Source: src/tools/task/task-create.ts.
Input: TaskCreateInputSchema: subject (required), description?, activeForm?, blockedBy?, blocks?, owner?, metadata?, repoURL?, parentID?.
Output: { task: { id, subject }, deduplicated? } or { error: "task_lock_unavailable" | "validation_error" | "internal_error" }.
Behavior:
- Validate input (
TaskCreateInputSchema). migrateLegacyTasksIfNeeded()when the project dir is empty.acquireLockWithRetry(dir)(4 attempts, backoff).- Dedup: scan existing
T-*.jsonfiles. When a task with the same trimmedsubject, sameprojectRoot, statuspending/in_progress, and file mtime insideDEDUP_WINDOW_MS(10 minutes,src/tools/task/constants.ts) exists, return{ task: { id, subject }, deduplicated: true }with no new file. id = T-{randomUUID()}; build theTaskObjectwithstatus: "pending",blocks/blockedBydefaulting to[],threadID = context.sessionID,projectRoot = ctx.directory.- Validate the full object (
TaskObjectSchema). writeJsonAtomic(join(dir, id + ".json"), task).release().
Invariants: id unique; status always pending on create; blockedBy/blocks default to []; the file appears atomically.
task_create({ subject: "Build frontend" }) // → { task: { id:"T-…", subject:"Build frontend" } }
task_create({ subject: "Integration tests", blockedBy:["T-001","T-002"] })Source: src/tools/task/task-get.ts.
Input: TaskGetInputSchema: id (required string).
Output: { task: TaskObject | null }, { error: "invalid_task_id" } on pattern mismatch, or { error: "invalid_arguments" | "unknown_error" }.
Behavior: validate id → parseTaskId() against TASK_ID_PATTERN → readJsonSafe(join(dir, id + ".json"), TaskObjectSchema) → return null when missing or malformed (not an error; the caller handles null).
Source: src/tools/task/task-list.ts.
Input: declared tool arg is parentID? (subtask filter). Schema level (TaskListInputSchema) also defines status?.
Output: { tasks: TaskSummary[], reminder: string } where TaskSummary = { id, subject, status, owner?, blockedBy, parentID? } (summaries, not full TaskObject).
Behavior:
readdirSync(dir)→ keepT-*.json.- Per file:
readJsonSafeplusTaskObjectSchema.safeParse; silently skip invalid files. - Filter: drop
completedanddeleted(schema-levelstatusaside); whenparentIDis set, keep only matching subtasks. - Resolve blockers: per active task,
blockedBy = blockedBy.filter(id => tasksById[id]?.status !== "completed"). Missing blocker files count as unresolved (kept). This filtered field is the scheduling primitive. - Return summaries plus the reminder:
"1 task = 1 task. Maximize parallel execution…".
Errors: never throws on malformed files; skips them. Empty or missing dir returns { tasks: [] }.
Source: src/tools/task/task-update.ts.
Input: declared tool args are id (required), subject?, description?, status?, activeForm?, owner?, addBlocks?, addBlockedBy?, metadata?.
Output: { task: TaskObject } or { error: "invalid_task_id" | "task_not_found" | "task_lock_unavailable" | "validation_error" | "internal_error" }.
Behavior:
- Validate input; pattern-check
id. acquireLock(dir)(single attempt; contended →task_lock_unavailable).- Read the existing file → validate.
- Apply updates:
- Scalars (
subject,description,status,activeForm,owner): direct replace when provided. addBlocks/addBlockedBy: additive,new Set([...existing, ...added])(dedup, append-only).metadata: shallow merge; anullvalue deletes that key, anything else sets it.
- Scalars (
- Re-validate against
TaskObjectSchema. writeJsonAtomic→release()(release runs in afinally).
Critical: dependencies are additive only. There is no removeBlockedBy or full replace; this is intentional to avoid races. To unblock a task, complete the blocker and let the task_list filter hide it. To change deps, set them in task_create.
task_update({ id:"T-003", addBlockedBy:["T-001"] }) // additive
task_update({ id:"T-001", status:"completed" }) // unblocks T-003 via task_list filter
task_update({ id:"T-001", metadata:{ priority:null } }) // delete key
task_update({ id:"T-001", status:"in_progress", owner:"morpheus" })Source: src/tools/task/task-cleanup.ts.
Input: declared args are olderThan? (format ^(\d+)(d|h|m)$, e.g. "7d", "24h", "30m") and all? (delete all completed; default true when olderThan is unset). There is no per-id delete; removals are bulk by age and status.
Output: { deleted: number, remaining: number, deletedIds: string[] }, or { error: "validation_error", message } on a bad olderThan string.
Behavior:
readdirSync(dir)→ parse eachT-*.json(skip invalid; continue past unreadable files).- Keep only
status === "completed"(never touchespending,in_progress, ordeleted). - When
olderThanis set:parseOlderThan(s)→ ms; per completed task,getTaskTimestamp(task)readstime_updated ?? time_created ?? updatedAt ?? createdAt ?? timeUpdated ?? timeCreated, coerces numerics, and falls back toDate.now(); keep only tasks withDate.now() - ts > threshold. unlinkSynceach selected file; log individual failures and continue.- Return counts.
Invariants: only completed files are deleted; malformed files are ignored; empty olderThan deletes all completed.
task_cleanup({ olderThan:"7d" }) // delete completed older than 7 days
task_cleanup({}) // delete all completed| Code | Tool | Cause | Caller action |
|---|---|---|---|
invalid_task_id |
task_get, task_update |
id fails TASK_ID_PATTERN |
Fix the ID |
task_not_found |
task_update |
file missing | Check task_list |
task_lock_unavailable |
task_create, task_update |
.lock held and not stale |
Retry after backoff |
validation_error / invalid_arguments |
any | Zod parse fails (strict schemas) | Fix the payload |
internal_error / unknown_error |
task_create, task_update, task_cleanup, task_get |
FS error, atomic-write failure, unexpected throw | Check /tmp/matrixx.log |
Tool errors return as { error, message? } JSON; they are never thrown to the model. Hooks are the exception: task-edit-guard throws to stop raw bash or generic file edits of .matrixx/plans and .matrixx/tasks.
blocks: string[]lists IDs this task blocks (forward edge). Informational;task_listdoes not filter on it.blockedBy: string[]lists IDs blocking this task (backward edge). This is the scheduling edge:task_listresolves it to unresolved entries only.- Bidirectional sync:
src/tools/delegate-task/sync-task-deps.tskeeps the reverse edge consistent: aftertask_create({blockedBy:[T-1]}), the caller also updatesT-1withaddBlocks:[newId]. Storage does not enforce this; it is convention (seedocs/orchestration.mdfor the delegation flow).
task_update merges addBlocks/addBlockedBy through Set([...old, ...added]). There is no removal API. Rationale: two agents adding deps concurrently would otherwise clobber each other. Current discipline for evolving a graph is "complete the blocker" rather than rewriting edges.
Source: src/tools/task/task-list.ts. Per active task:
const unresolvedBlockedBy = task.blockedBy.filter((blockerId) => {
const blocker = allTasks.find((t) => t.id === blockerId)
return blocker?.status !== "completed" // missing blocker file counts as unresolved
})A missing blocker ID (deleted file) stays unresolved. Defend against stale IDs by completing blockers rather than deleting them before dependents finish.
Morpheus decomposes work into waves that maximize parallelism. Full flow, examples, and the delegate integration live in docs/orchestration.md; the storage-level rules are:
- Create independent tasks first (
blockedBy: []); they can run in parallel. - Set
blockedByonly when the task truly needs the blocker's output. - Keep chains short; every edge is a serialization point.
- Check
task_list()after each wave;blockedBy: []on apendingtask means runnable now. - Orchestrator discipline: update the parent task status as each wave completes, even when the work was delegated. A parent left
in_progressafter its work finished keeps the enforcer firing. - Reconcile dead subagents: when a subagent session dies mid-work, mark its orphaned tasks
completed/deletedbefore continuing. The enforcer excludes tasks owned by dead sessions, but reconciliation keeps the store honest.
// Wave 1: parallel
const t1 = task_create({ subject:"Build frontend" }) // T-001
const t2 = task_create({ subject:"Build backend" }) // T-002
// Wave 2: blocked
const t3 = task_create({ subject:"Integration tests", blockedBy:[t1.task.id, t2.task.id] })
// Execute wave 1 in parallel via delegate_task, then:
task_update({ id:t1.task.id, status:"completed" })
task_update({ id:t2.task.id, status:"completed" })
// task_list now shows T-003 with blockedBy:[] → runnable
task_update({ id:t3.task.id, status:"in_progress", owner:"morpheus" })
// ... work ...
task_update({ id:t3.task.id, status:"completed" })One-paragraph summaries here. Full hook internals (state machines, prompts, countdown mechanics) belong to docs/hooks.md.
Files: src/hooks/task-continuation-enforcer/ (handler.ts, idle-event.ts, continuation-injection.ts, countdown.ts, session-state.ts, staleness.ts, todo.ts, abort-detection.ts, message-directory.ts, non-idle-events.ts, types.ts, constants.ts).
Wiring: src/plugin/hooks/create-continuation-hooks.ts, gated on isTaskSystemEnabled(config) (always true) plus isHookEnabled("task-continuation-enforcer"). Trigger: event:idle (session idle).
Constants (constants.ts): HOOK_NAME = "task-continuation-enforcer", DEFAULT_SKIP_AGENTS = ["oracle", "compaction"], COUNTDOWN_SECONDS = 2, TOAST_DURATION_MS = 900, COUNTDOWN_GRACE_PERIOD_MS = 500, ABORT_WINDOW_MS = 3000, CONTINUATION_COOLDOWN_MS = 30_000, MAX_CONSECUTIVE_FAILURES = 5, FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000 (5 min).
Behavior: on idle, the handler skips during recovery, right after an abort, while background tasks run, when continuation is stopped, past the failure circuit breaker, or inside cooldown. Otherwise it counts incomplete tasks: zero → done; all stale → skip with a log line; otherwise a 2-second countdown injects the CONTINUATION_PROMPT directive (work next pending task, mark in_progress/completed, respect blockedBy). Injection failures increment the consecutive-failure counter.
Cross-session scope and liveness: the enforcer reads the project-wide store, so a directive reflects the union of sessions in the project. With tasks.session_scoped: true (default), tasks are admitted only when their threadID is the current session or a live subagent of it; orphaned tasks from dead sessions are excluded. Pre-migration tasks without threadID are always included. Stale handling keys on tasks.stale_after_hours (enforcer default 24): all-stale queues skip on both the idle path and the post-countdown injection path; mixed queues annotate stale entries with a (stale: Nh) suffix plus an orphan-suspect note.
Durable ancestry scope (background completion gate). Session scoping follows the durable parentID chain, not just live-session membership. src/features/task-session-scope/ancestry.ts walks parentID ancestors up to DEFAULT_ANCESTRY_DEPTH = 3 hops, so a delegated subagent's or grandchild's task still counts toward the gate even when the intermediate session is already gone. The walk stops on a cycle, and only an in-scope ancestor admits a descendant, so an unrelated open task can never wedge a handle. The default applies at every call site; no config key selects the depth. Staleness composes with the expansion rather than bypassing it, so an expanded-but-stale grandchild still drops out — and it drops out on the gate's own window, tasks.background_stale_after_hours (default 2), not the enforcer's 24. The two differ because a missed continuation nudge is cheap while a handle held running past its worker is not. The wall-clock backstop (background_task.wallClockTimeoutMs, default 0, off) is unchanged.
Subtask rollup: a task with parentID is a subtask. Subtasks whose parent is completed/deleted count as resolved and leave the incomplete set (dropSubtasksWithResolvedParent in todo.ts, applied before session filtering on both paths). A parent with incomplete subtasks stays incomplete through its own status.
Files: src/hooks/task-edit-guard/ (hook.ts, constants.ts). Type: tool.execute.before. Unconditional (not gated on the task system; it matches on its own path patterns).
Three blocks:
- Generic
Write/Editto any path containing.matrixx/plans→ throwPLAN_WRITE_WARN, directing toplan_create/plan_read/plan_update/plan_list/plan_delete. - Generic
Readof any path containing.matrixx/plans→ throwPLAN_READ_WARN, directing toplan_read(hashline-tagged output) plusplan_listfor discovery. bashcommands matchingBLOCKED_PATTERNS(constants.ts):sed,python3?,echo,cat >,mv,rm(tasksT-*.jsonand plans),cp,tee,touch,truncate,printf, each scoped to.matrixx/plansor.matrixx/taskspaths. Puregrepreads that hit no pattern pass through via theisOnlyGrepfast path.
| Hook | Trigger | Behavior |
|---|---|---|
task-resume-info (src/hooks/task-resume-info/) |
tool.execute.after for delegate targets |
Extracts session_id via SESSION_ID_PATTERNS, appends to continue: task(session_id="…") unless present; ignores Error: outputs. Always registered (create-session-hooks.ts). |
empty-task-response-detector (src/hooks/empty-task-response-detector.ts) |
response analysis | Detects an empty assistant message while tasks remain; triggers a re-prompt |
delegate-task-retry (src/hooks/delegate-task-retry/) |
delegate_task failure |
Retries transient LLM failures via pattern matching in patterns.ts |
Files: src/features/task-toast-manager/ (manager.ts, types.ts, index.ts).
TaskToastManager tracks delegated background agent tasks (the delegate_task background path), not file-backed T-*.json rows. Entries are TrackedTask records: { id, description, agent, status, startedAt, isBackground, category?, skills?, modelInfo? } with status in running | queued | completed | error (types.ts). Lifecycle: addTask() on launch (shows a toast), updateTask() on status change, removeTask() when a task completes or errors, getRunningTasks() / getQueuedTasks() for TUI surfacing. Toast display options are TaskToastOptions (title, message, variant, duration?).
Files: src/hooks/task-notepad-writer/ (hook.ts, notepad-path.ts, constants.ts).
Alongside the task store sits a second, write-mostly artefact: a plain-markdown notepad per task under
.matrixx/notepads/. task-notepad-writer is a tool.execute.after hook that maintains them. It never blocks
and never rewrites tool output; every failure is a log line.
It fires in two places. After a non-deduplicated task_create it writes a scaffolded file, and after a
task_update whose status is completed it appends a ## Completion stamp. Any other status, or any other
tool, writes nothing.
The scaffold routes to one of two buckets. A task goes to .matrixx/notepads/<planName>/ only when
metadata.planName is set and .matrixx/plans/<planName>.md exists; everything else falls back to
.matrixx/notepads/adhoc/. There is no recency scan, so a dangling planName never gets attached to a
guessed plan. Files are flat and named <n>-<slug>.md, which is a hard requirement rather than a style choice:
the architect reads notepads with the non-recursive glob .matrixx/notepads/{plan-name}/*.md, so a
subdirectory would be invisible to it.
Idempotency comes from markers in the file, not from cached state: the scaffold is keyed on its
**Task ID** line and the stamp on the existing **Status**: completed heading, so retries and restarts
neither duplicate a file nor a stamp.
This hook replaces task-notepad, which was removed in commit f8de08cfb because session.todo() is
permanently unavailable. The old name survives only as a retained legacy schema literal and must not be
reused. See task-notepad-writer.md for the full layout rules, the exact scaffold and
stamp literals, and worked examples captured from the running hook.
Plans are markdown files managed only through the plan_* tools (src/tools/plan/: plan-create.ts, plan-read.ts, plan-update.ts, plan-list.ts, plan-delete.ts). Generic Read/Write/Edit on .matrixx/plans is blocked by task-edit-guard (see §8.2); raw bash mutation of plans is blocked by the same hook's BLOCKED_PATTERNS.
- Discovery:
plan_listlists plan files;plan_readreturns hashline-tagged output where each line carries aLINE#IDanchor. - Edits:
plan_updatetakes hashline edits (pos/endinLINE#IDformat;replacerequires aposanchor). Edits are scoped to the plans dir and validated before write. - Task checkboxes: plan bodies use markdown checkboxes. Numbered task items (
1. [ ] …/1. [x] …) are progress-tracked items; plain- [ ]bullets mark meta items (definition of done, final checklist) that do not count toward numeric progress. Keep the two shapes distinct so progress readers count only numbered tasks. - Task files stay separate:
.matrixx/tasks/T-*.jsonrows are runtime state edited only viatask_*tools; plan files are human-readable intent edited only viaplan_*tools. Never hand-edit either with bash.
| Command | Template | Tool called | Behavior |
|---|---|---|---|
/task-list |
src/features/builtin-commands/templates/task-list.ts |
task_list |
Renders TaskList summaries. Ignores global search-mode. |
/cleanup-tasks |
src/features/builtin-commands/templates/cleanup-tasks.ts |
task_cleanup |
Deletes completed tasks. Also ignores global search-mode. |
Full command reference lives in docs/command-reference.md. TUI behavior: completed tasks are hidden; only active tasks display.
Source: src/shared/task-system-gating.ts.
export const TASK_SYSTEM_DEFAULT = true as const
export function resolveTasksConfig(config) { /* tasks.* wins; legacy fills storage-key gaps; enabled is constant */ }
export function isTaskSystemEnabled(config): boolean {
return resolveTasksConfig(config).enabled
}Used by: create-continuation-hooks, tool-registry. Never read config.tasks fields directly; always go through isTaskSystemEnabled (enablement) or resolveTasksConfig (full options).
- Claude Code alignment: field names (
subject,blockedBy,blocks) follow Claude Code's Task tool shape. Matrixx'sTaskObjectis a superset (addsactiveForm,repoURL,parentID, atomic storage, additive deps, metadata merge,task_cleanup). - No master switch:
tasks.enabledno longer gates anything. The file-backed store is the only substrate. See §3.1 anddocs/legacy-todo-migration.md. - No
morpheus.tasks.enabled: the legacyMorpheusTasksConfigSchemahas noenabledfield, and it no longer needs one. Do not addenabledundermorpheus.tasks. - Removed
todo-sync.tsdual-write: bulk Task→Todo mirroring (syncAllTasksToTodos/syncTaskTodoUpdate, formerlysrc/tools/task/todo-sync.ts) was removed when the enforcers decoupled. Do not reintroduce dual-write without revisiting that rationale.
| File | Purpose |
|---|---|
src/tools/task/task-create.ts |
task_create: lock + T-{uuid} + dedup + atomic write |
src/tools/task/task-get.ts |
task_get: read single JSON + validate, null when missing |
src/tools/task/task-list.ts |
task_list: readdir + filter active + resolve blockedBy to unresolved |
src/tools/task/task-update.ts |
task_update: additive deps + metadata merge + atomic write |
src/tools/task/task-cleanup.ts |
task_cleanup: delete completed + olderThan age filter |
src/tools/task/types.ts |
Zod schemas (TaskObjectSchema, TaskCreate/Update/Get/ListInputSchema, TaskIdSchema) |
src/tools/task/constants.ts |
TASK_ID_PATTERN, DEDUP_WINDOW_MS (10 min) |
src/tools/task/index.ts |
Barrel re-exports |
src/features/task-storage/storage.ts |
getTaskDir, resolveTaskListId, sanitizePathSegment, readJsonSafe, writeJsonAtomic, acquireLock, acquireLockWithRetry, generateTaskId, listTaskFiles, migrateLegacyTasksIfNeeded; STALE_LOCK_THRESHOLD_MS = 30000 |
src/features/task-storage/types.ts |
Storage TaskSchema / Task type |
src/features/task-storage/session-storage.ts |
Session-scoped read helpers |
src/features/task-toast-manager/manager.ts |
Background-task toast lifecycle |
src/features/task-toast-manager/types.ts |
TrackedTask, TaskStatus (running|queued|completed|error), TaskToastOptions |
src/tools/plan/plan-read.ts, plan-update.ts |
plan_read (hashline output), plan_update (LINE#ID edits) |
src/hooks/task-continuation-enforcer/ |
Enforcer factory, idle handler, injection, countdown, staleness, session filter |
src/hooks/task-edit-guard/ |
Write/Edit/Read + bash guard for .matrixx/plans and .matrixx/tasks |
src/hooks/task-resume-info/ |
Resume hint task(session_id="…") |
src/tools/delegate-task/sync-task-deps.ts |
Bidirectional dep sync |
src/config/schema/tasks.ts |
Canonical TasksConfigSchema |
src/config/schema/morpheus.ts |
Legacy MorpheusTasksConfigSchema fallback |
src/config/schema/hooks.ts |
HookNameSchema (includes task-continuation-enforcer, task-edit-guard, etc., plus 4 retained-deprecated legacy literals) |
src/shared/migration/hook-names.ts |
HOOK_NAME_MAP — maps the 4 retired legacy hook names to null so old disabled_hooks lists strip cleanly |
src/plugin/tool-registry.ts |
Registration of the 5 task tools |
src/plugin/hooks/create-continuation-hooks.ts |
Gates taskContinuationEnforcer |
src/plugin/hooks/create-session-hooks.ts |
Registers taskResumeInfo (always) |
src/shared/task-system-gating.ts |
resolveTasksConfig, isTaskSystemEnabled, TASK_SYSTEM_DEFAULT |
src/features/builtin-commands/templates/task-list.ts |
/task-list command |
src/features/builtin-commands/templates/cleanup-tasks.ts |
/cleanup-tasks command |
Key dependencies: zod@4 (schemas), @opencode-ai/plugin (tool framework, PluginInput), node:crypto (randomUUID), node:fs (atomic ops), node:path.
- Add to
src/features/task-storage/types.ts: TaskSchema(storage layer). - Add to
src/tools/task/types.ts: TaskObjectSchema(API layer); keep them in sync. - Update
task_createdefaults andtask_updateapply logic when the field is mutable. - Update
task_list/task_getreturn mapping when it should appear in summaries. - Run
bun run typecheck && bun run lint && bun test. - Update this doc (§4.2, §6, §12) and
matrixx.example.jsoncwhen config-adjacent.
- Tool: new file
src/tools/task/task-*.ts+ Zod input schema intypes.ts+ barrel inindex.ts+ registration insrc/plugin/tool-registry.ts+ hook wiring when needed + tests alongside source (*.test.tswith//#given//#when//#then). - Hook: new dir
src/hooks/<name>/+ entry inHookNameSchema(src/config/schema/hooks.ts) + factorycreateXxxHook+ registration in the matchingsrc/plugin/hooks/create-*-hooks.ts+isTaskSystemEnabledgate when task-related.
- Strict schemas: storage
TaskSchemaandTaskObjectSchemaare.strict(); unknown keys are rejected. Do not loosen. - Additive deps only:
addBlocks/addBlockedBymerge throughSet. No full replace, no inline removal. - Atomic writes: always
writeJsonAtomic(tmp + rename). NeverwriteFileSyncdirectly toT-*.json. - Lock verification:
release()checks holderidbefore unlink. Never delete.lockunconditionally. Stale threshold is 30 s; do not lengthen without contention data. - Single owner: one
in_progresstask per agent at a time (prompt invariant the enforcer assumes). - Unresolved filter is the scheduler:
task_listmust filterblockedByto unresolved entries; changing this breaks wave planning. - Liveness filter:
getSubagentSessionIDsreturns only live subagent sessions;filterTasksBySessionmust never admit tasks owned by dead sessions (prevents false continuation directives).tasks.session_scoped: falseis the only bypass. - Gating via predicate: always
isTaskSystemEnabled(config)/resolveTasksConfig(config); never read config fields inline. - No bash edits:
.matrixx/tasks/T-*.jsonand.matrixx/plans/*.mdare guarded bytask-edit-guard; usetask_*andplan_*tools.
- Mock-heavy isolation: tests with
mock.module()run isolated. Add new such files to both.github/workflows/ci.ymlandpublish.ymlmock-heavy lists and thegrep -v -Fexclusion inscript/run-ci.sh. Source of truth:script/run-ci.sh. - Preload:
tests/test-setup.tscalls_resetForTesting()before each test. - Existing suites:
src/tools/task/task-cleanup.test.ts,src/features/task-storage/*.test.ts,src/hooks/task-continuation-enforcer/*.test.ts(awaiting-user,continuation-injection,countdown,idle-event,staleness,todo,ulw-bootstrap),src/shared/task-system-gating.test.ts,tests/e2e-smoke-task-system.test.ts.
| Date / Commit | Change | Rationale |
|---|---|---|
d004d84 feat(hooks): mirror Task→Todo |
Initial file→Todo API mirror | Tasks visible in OpenCode TUI |
72abccb / 401f336 |
Direct DB fallback + debounce | Reliability under TUI load |
0798df4 host-blessed SessionTodo.Service dual-write |
Correct writer via PluginInput |
Align with OpenCode SDK |
4e694d0 migrate to project-scoped storage |
.matrixx/tasks default; global only when scope=global |
Project isolation, no cross-project leakage |
0682bce isolation suite (11 tests) |
Verify project isolation | n/a |
d16dc27 task-edit-guard |
Block raw bash on .matrixx/tasks and .matrixx/plans |
Prevent bypass of locking and validation |
3d44108 hide completed from TUI |
TUI shows only active tasks | Reduce noise |
d8ca206 decouple the two continuation enforcers |
Dedicated enforcer per system; remove todo-sync.ts dual-write |
Enforcers independent; no coupling debt |
62e7061 wire enforcer to event bus and correct injection |
handleSessionIdle via onAbort/onRecoveryComplete callbacks |
Abort-aware, background-task-aware continuation |
5459ef9 merge the no-todo-tools branch |
Remove stale specs, repair compaction | n/a |
75fedac default task_system=true + canonical gating |
isTaskSystemEnabled + _migrations marker |
Zero-config for fresh clones |
8509b84 to 82f9f38 /task-list and /cleanup-tasks search-mode fixes |
Ignore global search-mode |
Commands must not leak into global search |
| task config consolidation (2.6.x) | Canonical tasks.* section; morpheus.tasks.* and task.pollTimeoutMs become fallbacks via resolveTasksConfig() |
One home for task config; keeps old files parsing |
| legacy todo system removed (v2.7) | The 4 legacy todo hooks, the dual useTaskSystem prompt fork, and the master gate are gone; tasks.* is unconditional |
One substrate, no split-brain todos. History and retained no-ops: docs/legacy-todo-migration.md |
Known tech debt:
- No dedicated
TaskManagerclass: CRUD is stateless I/O plus.lock. Intentional simplicity; do not introduce a manager unless contention profiling justifies it.
End of Task System Engineering Specification. For hook internals see docs/hooks.md; for orchestration see docs/orchestration.md; for commands see docs/command-reference.md; for config reference see docs/configurations.md; for the removed legacy todo system see docs/legacy-todo-migration.md.