Scope: Persistent, file-backed task management for Matrixx agent orchestration. Audience: Engineers evolving the task system — storage, tools, hooks, scheduling, and agent integration. Version: 2026-09 (branch
dev@bb3bd59) — postd8ca206decouple,4e694d0project-scoped storage,d16dc27edit guard,82f9f38global-scope fixes.
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 = easy reasoning, easy recovery. |
| Atomicity over speed | Every write is write tmp + renameSync. Lock file with wx + stale eviction. Correctness under concurrent agents is non-negotiable. |
| Additive dependencies | addBlocks/addBlockedBy append via Set — never replace. Prevents race when two agents update deps concurrently. |
| One task, one owner, one transition | TaskUpdate(in_progress) immediately before work, completed immediately after. No batch completions. Enforced by prompt + continuation hook. |
| Blocked = schedulable | task_list filters blockedBy to unresolved only. Scheduler can skip blocked tasks without extra query. |
| Graceful degradation | experimental.task_system=false restores TodoWrite/TodoRead everywhere — tool registry, hooks, prompts all dual-mode via isTaskSystemEnabled(). |
| Drop-in upgrade | Enabling flips tool registry + hooks + agent prompts + storage without touching agent business logic. |
matrixx.jsonc
experimental.task_system (default true ── isTaskSystemEnabled)
morpheus.tasks { storage_path?, task_list_id?, scope?, claude_code_compat? }
│
├─── Tool Registry (src/plugin/tool-registry.ts)
│ if enabled → register 5 tools:
│ task_create · task_get · task_list · task_update · task_cleanup
│
├─── Hook Wiring
│ ├─ createContinuationHooks → taskContinuationEnforcer (event:idle, 2s countdown)
│ ├─ createToolGuardHooks → tasksTodowriteDisabler (tool.execute.before, BLOCKING)
│ │ → taskEditGuard (tool.execute.before, bash edit guard)
│ └─ createSessionHooks → taskResumeInfo (tool.execute.after, resume hint)
│ → delegateTaskRetry, taskNotepad, emptyTaskResponseDetector
│
├─── Agent Prompts (dynamic-agent-prompt-builder, morpheus/keymaker/mouse factories)
│ useTaskSystem=true → task discipline; false → todo discipline
│
└─── 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 | Disabled (task_system=false) |
Enabled (task_system=true) |
|---|---|---|
| Tool registry | 0 task tools (todos only) | 5 task tools registered |
tasks-todowrite-disabler |
no-op | tool.execute.before throws on TodoWrite/TodoRead |
task-edit-guard |
always active (own patterns) | same — blocks sed/echo/cat/mv on .matrixx/tasks & .matrixx/plans |
Tool config (tool-config-handler) |
default | todowrite:false, todoread:false global + per-agent deny |
| Agent prompts | todo discipline | task discipline (task_create/task_update workflow) |
| Storage | session memory (OpenCode Todo API) | file system (.matrixx/tasks/ or global) |
| Continuation | todo-continuation-enforcer only |
task-continuation-enforcer + todo-continuation-enforcer independently |
| Persistence | lost on restart | survives restart, migratable |
- Canonical predicate:
isTaskSystemEnabled(config)insrc/shared/task-system-gating.ts— single source of truth. Returnsconfig?.experimental?.task_system ?? true. - First-load migration: missing field auto-set via
_migrationsmarkertask_system_default_true(no user action). - Wiring:
createContinuationHooksandcreateToolGuardHooksgatetask-continuation-enforcerandtasks-todowrite-disableron this predicate.task-edit-guardandtask-resume-infoare unconditional (own patterns).
{
"morpheus": {
"tasks": {
"storage_path": "/custom/path", // absolute or relative override
"task_list_id": "my-project", // override env/default
"scope": "project", // "project" | "global"
"claude_code_compat": false
}
}
}| Option | Type | Default | Description |
|---|---|---|---|
storage_path |
string |
— | Absolute path used verbatim; relative path join(cwd, storage_path). When set, bypasses scope/listId resolution. |
task_list_id |
string |
— | Explicit list ID. Alternative to ULTRAWORK_TASK_LIST_ID env. Sanitized to [a-zA-Z0-9_-]. |
scope |
"project" | "global" |
"project" |
project → .matrixx/tasks in the project root. global → ~/.config/opencode/tasks/{listId} via getOpenCodeConfigDir(). |
claude_code_compat |
boolean |
false |
Claude Code path compatibility flag (reserved). |
Schema: MorpheusTasksConfigSchema in src/config/schema/morpheus.ts (storage_path?: string, task_list_id?: string, scope?: enum, claude_code_compat?: boolean).
Implemented in src/features/task-storage/storage.ts: getTaskDir() + resolveTaskListId().
Priority for listId:
ULTRAWORK_TASK_LIST_IDenvCLAUDE_CODE_TASK_LIST_IDenvconfig.morpheus.tasks.task_list_idbasename(process.cwd())sanitized
Sanitization: sanitizePathSegment() — [^a-zA-Z0-9_-] replaced with -.
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) is called before every read/write. getProjectTaskDir(directory) is the shorthand for the default branch.
# Env override check
ULTRAWORK_TASK_LIST_ID=my-list opencode # → ~/.config/opencode/tasks/my-list (if scope=global)
# or .matrixx/tasks (if scope=project, env ignored for path but used for migration)
# Log grep (after enabling task_system)
tail -n 100 /tmp/matrixx.log | grep -E "task.*dir|getTaskDir|migrateLegacy"| Layer | File | Type | Fields | Notes |
|---|---|---|---|---|
| Storage | src/features/task-storage/types.ts |
Task (TaskSchema) |
id, subject, description, status, activeForm?, blocks, blockedBy, owner?, metadata?, projectRoot? |
Slim storage model. strict() — unknown keys rejected. |
| API | src/tools/task/types.ts |
TaskObject (TaskObjectSchema) |
same + repoURL?, parentID?, threadID |
Superset for tool I/O. Alias TaskSchema = TaskObjectSchema for Claude compat. strict() likewise. |
Mapping: TaskObject is Task + threadID (auto sessionID), repoURL, parentID. When evolving, keep both in sync — add field to TaskSchema first, then extend TaskObjectSchema.
TaskObjectSchema = z.object({
id: z.string().regex(/^T-[A-Za-z0-9-]+$/), // T-{uuid}
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()), // IDs this task blocks
blockedBy: z.array(z.string()), // 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 sub-tasks
threadID: z.string().optional(), // auto-set to ctx.sessionID
projectRoot: z.string().optional(), // auto-set to ctx.directory
}).strict()ID pattern: TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/ (src/tools/task/constants.ts). task_create generates T-{uuid} via crypto.randomUUID() (generateTaskId()); task_get/task_update validate and return { error: "invalid_task_id" } on mismatch.
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 │ ── task_cleanup ──► unlinked
└─────────┘
- Active =
pendingorin_progress. Returned bytask_list(others filtered). - Terminal =
completedordeleted. Onlytask_cleanupphysically removescompleted(respectsolderThan);deletedis treated identically in filters but not yet auto-purged — usetask_cleanupor manualdeleted+ cleanup. - Discipline:
in_progressmeans exactly one task at a time per agent (Morpheus prompt invariant). Markin_progressbefore work,completedimmediately after — no batching.
{
"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-abc12345-1a36-4dad-a9c3-3064d180f694"],
"owner": "morpheus",
"threadID": "ses_abc123",
"projectRoot": "/home/user/project",
"metadata": { "priority": "high" }
}# scope=project (default)
.matrixx/tasks/
T-2a200c59-....json
T-abc12345-....json
.lock # ephemeral {id, timestamp}
# scope=global
~/.config/opencode/tasks/{sanitizedListId}/
T-*.json
.lock
No index file. Directory listing is the index. T-*.json glob is the query.
writeJsonAtomic(path, data) in storage.ts:
- Serialize
JSON.stringify(data, null, 2). writeFileSync(tmpPath, content)wheretmpPath = path + ".tmp." + Date.now().renameSync(tmpPath, path)— atomic on POSIX.- On failure,
unlinkSync(tmpPath)best-effort cleanup. Caller surfacesinternal_error.
Guarantee: readers never see a half-written file.
acquireLock(dir) / acquireLockWithRetry(dir) in storage.ts:
- Create:
writeFileSync(.lock, JSON.stringify({id: uuid, timestamp: Date.now()}), {flag:"wx"})— fails withEEXISTif another writer holds the lock. - Stale eviction:
STALE_LOCK_THRESHOLD_MS = 30_000. OnEEXIST, read.lock; ifDate.now() - timestamp > 30s,unlinkSync(.lock)and retry once. Prevents deadlock on crashed agents. - Release:
release()verifiesidmatches beforeunlinkSync(.lock)— avoids deleting a successor's lock. - Retry:
acquireLockWithRetry(task_createpath) loops with short sleep until acquired or timeout.task_updateuses single-attemptacquireLockand returns{ error: "task_lock_unavailable" }if contended — caller should retry.
migrateLegacyTasksIfNeeded(config, directory):
- Triggers on
task_create/task_listwhengetTaskDir()is empty/non-existent and~/.config/opencode/tasks/{listId}containsT-*.json. - Copies each
T-*.jsonnot already present (no overwrite). Logsmigrated N tasks. - One-way: global → project. No reverse. Idempotent.
src/features/task-storage/session-storage.ts — helpers for session-scoped reads (used by continuation enforcer). Not a separate store; thin wrapper over storage.ts + TaskObjectSchema validation.
All tools are ToolDefinition factories createTask*Tool(config, ctx) registered conditionally in src/plugin/tool-registry.ts when isTaskSystemEnabled(config). Tool contexts receive ctx.directory (project root) and context.sessionID (for threadID).
Input: TaskCreateInputSchema — subject: string (required), description?: string, activeForm?: string, blockedBy?: string[], blocks?: string[], metadata?: record, repoURL?: string, parentID?: string.
Output: { task: { id: string, subject: string } } or { error: "task_lock_unavailable" | "validation_error" | "internal_error" }.
Behavior:
- Validate input (
TaskCreateInputSchema). migrateLegacyTasksIfNeeded()if project dir empty.acquireLockWithRetry(dir).id = T-{randomUUID()}; constructTaskObjectwithstatus:"pending",blocks:[],blockedBy:[]defaults,threadID=context.sessionID,projectRoot=ctx.directory.- Validate full object (
TaskObjectSchema). writeJsonAtomic(join(dir, id+".json"), task).release().
Invariants: id unique; status always pending on create; blockedBy/blocks default []; file appears atomically.
task_create({ subject: "Build frontend" }) // → { task: { id:"T-001", subject:"Build frontend" } }
task_create({ subject: "Integration tests", blockedBy:["T-001","T-002"] })Input: TaskGetInputSchema — id: string (required, TASK_ID_PATTERN).
Output: { task: TaskObject | null } or { error: "invalid_task_id" }.
Behavior: Validate id → readJsonSafe(join(dir, id+".json")) → validate against TaskObjectSchema → return null if missing/malformed (not an error — caller handles null).
Input: TaskListInputSchema — status?: TaskStatus, parentID?: string (both optional filters).
Output: { tasks: TaskSummary[], reminder: string } where TaskSummary = { id, subject, status, owner?, blockedBy } (note: not full TaskObject).
Behavior:
readdirSync(dir)→ filterT-*.json.- For each file:
readJsonSafe+TaskObjectSchema.safeParse— silently skip invalid. - Filter: exclude
status==="completed"andstatus==="deleted"unlessinput.statusexplicitly asks for them; ifparentIDset, filter to matching. - Resolve blockers: for each active task,
blockedBy = blockedBy.filter(id => tasksById[id]?.status !== "completed")— only unresolved blockers returned. This is the scheduling primitive. - Return summaries + reminder:
"1 task = 1 task. Maximize parallel execution…".
Error: never throws on malformed files — skips them.
Input: TaskUpdateInputSchema — id: string (required), subject?, description?, status?, activeForm?, owner?, addBlocks?: string[], addBlockedBy?: string[], metadata?: record, repoURL?, parentID?.
Output: { task: TaskObject } or { error: "invalid_task_id" | "task_not_found" | "task_lock_unavailable" | "validation_error" | "internal_error" }.
Behavior:
- Validate input +
idpattern. acquireLock(dir)(single attempt).- Read existing file → validate.
- Apply updates:
- Scalar fields (
subject,description,status,activeForm,owner,repoURL,parentID): direct replace if provided. addBlocks/addBlockedBy: additive —new Set([...existing, ...add])(dedup, append-only).metadata: shallow merge;key: nulldeletes the key; otherwise sets.
- Scalar fields (
- Re-validate against
TaskObjectSchema. writeJsonAtomic→release().
Critical: Dependencies are additive only. There is no removeBlockedBy or full-replace — intentional to avoid races. To "unblock" a task, complete the blocker; task_list will hide it. To change deps before creation, 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" })Input: TaskDeleteInputSchema (source: task-cleanup.ts) — olderThan?: string (pattern ^(\d+)(d|h|m)$ → ms, e.g. "7d", "24h", "30m"). No id — deletions are bulk by age/status.
Output: { deleted: number, remaining: number, deletedIds: string[] }.
Behavior:
readdirSync(dir)→ parse eachT-*.json(validate, skip invalid).- Filter
status==="completed"only (never deletespending/in_progress/deleted). - If
olderThanprovided:parseOlderThan(s)→ ms → for each completed task, computegetTaskTimestamp(task)fallbacktime_updated ?? time_created ?? updatedAt ?? createdAt ?? 0→ keep onlyDate.now() - ts > threshold. unlinkSynceach selected file.- Return counts.
Invariants: Only completed deleted; pending/in_progress never touched; malformed files 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 ID |
task_not_found |
task_update |
file missing | Check task_list |
task_lock_unavailable |
task_create, task_update |
.lock held, not stale |
Retry after ~100ms |
validation_error |
all | Zod parse fails (strict) | Fix payload |
internal_error |
task_create, task_update, task_cleanup |
FS error, atomic write fail | Check /tmp/matrixx.log |
All errors are returned as { error: string, message?: string } — never thrown as exceptions to the LLM (hooks are the exception: they throw to block TodoWrite/bash).
blocks: string[]— IDs this task blocks (forward edge). Informational;task_listdoes not filter on it.blockedBy: string[]— IDs blocking this task (backward edge). Scheduling edge —task_listresolves to unresolved only.- Bidirectional sync:
src/tools/delegate-task/sync-task-deps.ts— whentask_create({blockedBy:[T-1]}), caller should also updateT-1withaddBlocks:[newId]to keep graph consistent. Not enforced by storage — convention.
task_update with addBlocks/addBlockedBy does Set([...old, ...added]). No removal API. Rationale: two agents adding deps concurrently would otherwise clobber each other. To evolve this, consider a removeBlockedBy that is also additive via a tombstone set — but current discipline is "complete the blocker."
task_list computes for each active task:
const tasksById = Map(allTasks.map(t => [t.id, t]))
const unresolvedBlockedBy = task.blockedBy.filter(id => tasksById.get(id)?.status !== "completed")Missing blocker ID (deleted file) is treated as unresolved — defend against stale IDs by completing blockers rather than deleting them before dependents finish.
Morpheus decomposes work into waves that maximize parallelism:
Wave 1 (parallel): T-001 Build frontend blockedBy:[]
T-002 Build backend blockedBy:[]
Wave 2 (blocked): T-003 Integration tests blockedBy:[T-001,T-002]
Wave 3 (blocked): T-004 Deploy blockedBy:[T-003]
Rules:
- Create independent tasks first (
blockedBy:[]) — they can run viadelegate_task(category=…)in parallel. 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."
Full example:
// 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" })Files: src/hooks/task-continuation-enforcer/{hook.ts, idle-event.ts, countdown.ts, session-state.ts, todo.ts, abort-detection.ts, types.ts, constants.ts}
Wiring: src/plugin/hooks/create-continuation-hooks.ts — gated on isTaskSystemEnabled(config) && isHookEnabled("task-continuation-enforcer").
Trigger: event:idle (session idle). Decoupled from todo-continuation-enforcer in d8ca206 (dedicated enforcer per system).
State per session: SessionStateStore (session-state.ts) — { abortDetectedAt?, consecutiveFailures, lastFailureAt, isRecovering }.
Constants:
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 = 300_000
CONTINUATION_PROMPT = systemDirective(TASK_CONTINUATION)
+ "Incomplete Matrixx tasks remain. Continue…"
+ "- Proceed without asking for permission"
+ "- Mark each task in_progress before starting, completed immediately after"
+ "- Respect blockedBy dependencies (skip blocked tasks)"
+ "- Do not stop until all tasks are done"
State Machine (handleSessionIdle in idle-event.ts):
event:idle
│
├─ isRecovering? ──► skip (log)
├─ abortDetectedAt && now - abort < 3s ──► clear flag, skip
├─ backgroundManager.getTasksByParentSession(sessionID) has running? ──► skip
├─ ctx.client.session.messages → isLastAssistantMessageAborted? ──► skip (API fallback)
├─ isContinuationStopped(sessionID)? ──► skip
├─ consecutiveFailures >=5 && now - lastFailure < 5min ──► skip (circuit breaker)
├─ now - lastContinuation < 30s ──► skip (cooldown)
│
├─ getIncompleteTaskCount(dir) // task_list filtered count via storage
│ count == 0 ──► done (no injection)
│ count > 0 ──► startCountdown(2s) → injectContinuation(CONTINUATION_PROMPT)
│
└─ on injection failure → increment consecutiveFailures, record lastFailureAt
Countdown: startCountdown(2s) in countdown.ts — 2-second toast countdown with 500ms grace. Cancels if new tool activity arrives.
Recovery integration: sessionRecovery.setOnAbortCallback/markRecovering and setOnRecoveryCompleteCallback in create-continuation-hooks.ts — abort detection via onAbortCallbacks, recovery flag via onRecoveryCompleteCallbacks.
Files: src/hooks/tasks-todowrite-disabler/{hook.ts, constants.ts}
Hook type: tool.execute.before — BLOCKING (throw new Error(REPLACEMENT_MESSAGE)).
Trigger: tool in ["TodoWrite","TodoRead"] when isTaskSystemEnabled(config).
Triple-Layer Enforcement:
| Layer | Mechanism | Location |
|---|---|---|
| 1. Hook | throw on TodoWrite/TodoRead |
hooks/tasks-todowrite-disabler/hook.ts |
| 2. Global tool config | todowrite:false, todoread:false |
plugin-handlers/tool-config-handler.ts |
| 3. Per-agent config | todowrite:"deny", todoread:"deny" on morpheus/keymaker/architect/oracle/mouse |
same handler, 5 agents |
Error message (4-step workflow):
Use
TaskCreate→TaskUpdate(in_progress)→ do work →TaskUpdate(completed). "DO NOT retry TodoWrite. Convert to TaskCreate NOW. Even trivial tasks MUST be registered."
Files: src/hooks/task-edit-guard/{hook.ts, constants.ts}
Hook type: tool.execute.before for bash. Unconditional (not gated on task_system).
Patterns: BLOCKED_PATTERNS regex — sed|python|echo|cat|mv operating on .matrixx/plans or .matrixx/tasks paths. Throws — instructs to use Edit (hashline IDs) for .matrixx/plans/*.md and task_create/task_update/task_cleanup for .matrixx/tasks/T-*.json. grep read-only is allowed via isOnlyGrep check.
Duplicate hook name: HookNameSchema in src/config/schema/hooks.ts lists task-edit-guard twice (lines 66/67) — harmless but should be deduped.
| Hook | Trigger | Behavior |
|---|---|---|
task-notepad (src/hooks/task-notepad/) |
session start | Injects .matrixx/tasks context fragment (task counts) into prompt |
task-resume-info (src/hooks/task-resume-info/) |
tool.execute.after for delegate targets |
Extracts session_id via SESSION_ID_PATTERNS and appends to continue: task(session_id="…") if not already present; ignores Error: outputs. Always registered (create-session-hooks.ts). |
empty-task-response-detector (src/hooks/empty-task-response-detector.ts) |
response analysis | Detects empty assistant message while tasks remain — triggers re-prompt |
delegate-task-retry (src/hooks/delegate-task-retry/) |
delegate_task failure |
Retries on transient LLM failures via pattern matching in patterns.ts |
task-toast-manager (src/features/task-toast-manager/) |
task_create/task_update |
Toast UI — created on task_create, removed on task_update(completed) |
Parallel system for SessionTodo (src/hooks/todo-continuation-enforcer/). Same countdown mechanics but counts Todo not tasks. Both enforcers run independently when enabled; gated separately by task_system vs todo config. Decoupled in d8ca206 (previously mirrored tasks to todos via todo-sync.ts dual-write — removed in 928440c).
buildTaskManagementSection(useTaskSystem) in src/agents/morpheus.ts (and dynamic-agent-prompt-builder.ts).
When enabled:
- Workflow:
TaskCreate→TaskUpdate(in_progress)→ work →TaskUpdate(completed)with "Why Non-Negotiable" (visibility, drift prevention, recovery, accountability). - Hook note switches from
TODO CONTINUATIONtoTASK CONTINUATION. - Decomposition: creates waves with
blockedByto maximize parallelism; launchesdelegate_task(category=…)for each parallel wave.
5 model variants (src/agents/mouse/{default,gpt,deepseek,mimo,qwen}.ts) + shared utils (shared.ts):
buildConstraintsSection(useTaskSystem): allowed tools list switches totask_create/task_update/task_list/task_get/task_cleanup.buildTodoDisciplineSection(useTaskSystem): task vs todo discipline.buildVerificationTable(useTaskSystem): verification referencesTaskUpdatevstodowrite.- Invariant: Mouse cannot spawn sub-agents (
tasktool blocked) — implementation in-house only.
src/tools/delegate-task/{background-task.ts, sync-task.ts, sync-task-deps.ts, unstable-agent-task.ts}:
- Background path:
executeBackgroundTask→BackgroundManager.launch(...)withparentSessionID/messageID/model/agent/tools. Waits up toWAIT_FOR_SESSION_TIMEOUT_MSforsessionIDto materialize, then storessessionIdinctx.metadatafor TUITasktool UI (props.metadata.sessionIdlookup). - Sync path:
executeSyncTask→ ephemeral session, runs agent, aborts session after to preventtodo-continuationre-awakening. - Dep sync:
sync-task-deps.ts— aftertask_create({blockedBy:[T-1]}), syncs reverse edge viatask_update({id:T-1, addBlocks:[newId]}). Bidirectional graph maintenance. - Task metadata:
task(session_id="ses_…")continuation hint injected viatask-resume-infohook.
| Command | Template | Tool Called | Behavior |
|---|---|---|---|
/task-list |
src/features/builtin-commands/templates/task-list.ts |
task_list |
Renders TaskList summaries. Ignores global search-mode (fixed in b7fbcf6, cc9e70d). |
/cleanup-tasks |
src/features/builtin-commands/templates/cleanup-tasks.ts |
task_cleanup |
Deletes completed tasks. Also ignores global search-mode. |
Toast manager: src/features/task-toast-manager/manager.ts — createTaskToastManager shows toast on task_create, removes on task_update(completed) (also in sync-task.ts finally block: toastManager.removeTask(taskId)).
TUI fix: 3d44108 hid completed tasks from TUI; only active tasks display.
src/shared/task-system-gating.ts:
export const TASK_SYSTEM_DEFAULT = true as const
export function isTaskSystemEnabled(config: Partial<MatrixxConfig> | undefined | null): boolean {
return config?.experimental?.task_system ?? TASK_SYSTEM_DEFAULT
}Used by: create-continuation-hooks, create-tool-guard-hooks, tasks-todowrite-disabler. Never check config.experimental.task_system directly — always via isTaskSystemEnabled.
- Claude Code alignment: Field names (
subject,blockedBy,blocks) follow Claude Code's Task tool signatures. Anthropic has not published official docs for these tools — Matrixx'sTaskObjectis a superset (addsactiveForm,repoURL,parentID, atomic storage, additive deps, metadata merge,task_cleanup). - No
morpheus.tasks.enabled: Despite legacy docs mention,MorpheusTasksConfigSchemahas noenabledfield. The toggle isexperimental.task_systemonly. Do not addenabledundermorpheus.tasks. - Pre-existing
todo-sync.tsremoved: Bulk syncsyncAllTasksToTodos/syncTaskTodoUpdateexisted insrc/tools/task/todo-sync.ts(205 lines) for Todo API mirroring (d004d84–0798df4). Removed in928440c/d8ca206when enforcers decoupled. Do not reintroduce dual-write without revisiting the decouple rationale (debounce, direct DB fallback, host-blessedSessionTodo.Servicewriter).
| File | Purpose | Lines |
|---|---|---|
src/tools/task/task-create.ts |
task_create — lock + T-{uuid} + atomic write |
113 |
src/tools/task/task-get.ts |
task_get — read single JSON + validate |
46 |
src/tools/task/task-list.ts |
task_list — readdir + filter active + resolve blockedBy |
77 |
src/tools/task/task-update.ts |
task_update — additive deps + metadata merge + atomic write |
151 |
src/tools/task/task-cleanup.ts |
task_cleanup — delete completed + olderThan age filter |
~120 |
src/tools/task/types.ts |
Zod schemas (TaskObjectSchema, TaskCreate/Update/Get/ListInputSchema) |
77 |
src/tools/task/constants.ts |
TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/ |
— |
src/tools/task/index.ts |
Barrel re-exports | — |
src/features/task-storage/storage.ts |
getTaskDir, resolveTaskListId, writeJsonAtomic, acquireLock, migrateLegacy |
169 |
src/features/task-storage/types.ts |
Storage TaskSchema / Task type |
— |
src/features/task-storage/session-storage.ts |
Session-scoped helpers | — |
src/features/task-toast-manager/manager.ts |
Toast lifecycle | — |
src/features/background-agent/task-history.ts |
Background-task history persisted alongside tasks | — |
src/hooks/task-continuation-enforcer/hook.ts |
Enforcer factory + CONTINUATION_PROMPT |
— |
src/hooks/task-continuation-enforcer/idle-event.ts |
handleSessionIdle state machine |
— |
src/hooks/task-continuation-enforcer/countdown.ts |
2s countdown + grace | — |
src/hooks/task-continuation-enforcer/session-state.ts |
Per-session {abortDetectedAt, consecutiveFailures, lastFailureAt, isRecovering} |
— |
src/hooks/task-continuation-enforcer/constants.ts |
COUNTDOWN_SECONDS, ABORT_WINDOW_MS, etc. |
— |
src/hooks/tasks-todowrite-disabler/hook.ts |
BLOCKING hook on TodoWrite/TodoRead |
33 |
src/hooks/tasks-todowrite-disabler/constants.ts |
REPLACEMENT_MESSAGE (4-step workflow) |
30 |
src/hooks/task-edit-guard/hook.ts |
Bash edit guard for .matrixx/tasks & .matrixx/plans |
— |
src/hooks/task-notepad/hook.ts |
Task notepad fragment injection | — |
src/hooks/task-resume-info/hook.ts |
Resume hint task(session_id="…") |
— |
src/hooks/todo-continuation-enforcer/ |
Sibling Todo enforcer (independent) | — |
src/tools/delegate-task/background-task.ts |
delegate_task background path |
— |
src/tools/delegate-task/sync-task-deps.ts |
Bidirectional dep sync | — |
src/config/schema/experimental.ts |
task_system?: boolean = true |
— |
src/config/schema/morpheus.ts |
morpheus.tasks.{storage_path, task_list_id, scope, claude_code_compat} |
— |
src/config/schema/hooks.ts |
HookNameSchema (includes task-continuation-enforcer, tasks-todowrite-disabler, task-edit-guard, etc.) |
— |
src/plugin/tool-registry.ts |
Conditional registration of 5 task tools | — |
src/plugin/hooks/create-continuation-hooks.ts |
Gates taskContinuationEnforcer |
— |
src/plugin/hooks/create-tool-guard-hooks.ts |
Gates tasksTodowriteDisabler |
— |
src/plugin/hooks/create-session-hooks.ts |
Registers taskResumeInfo (always) |
— |
src/shared/task-system-gating.ts |
isTaskSystemEnabled canonical predicate |
— |
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 if the field is mutable. - Update
task_list/task_getreturn mapping if it should appear in summaries. - Run
bun run typecheck && bun run lint && bun test. - Update this doc (§4.2, §6, §12) and
matrixx.example.jsoncif 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 if 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 appropriatesrc/plugin/hooks/create-*-hooks.ts+isTaskSystemEnabledgate if task-related.
- Strict schemas:
TaskSchemaandTaskObjectSchemaare.strict()— unknown keys rejected. Do not loosen. - Additive deps only:
addBlocks/addBlockedByviaSet. No full-replace, no inline removal. - Atomic writes: Always
writeJsonAtomic(tmp + rename). NeverwriteFileSyncdirectly toT-*.json. - Lock verification:
release()checksidbefore unlink. Never delete.lockunconditionally. - Single owner: One
in_progresstask per agent at a time (prompt invariant +task-continuation-enforcerexpects this). - Unresolved filter is the scheduler:
task_listmust filterblockedByto unresolved; changing this breaks wave planning. - Gating via predicate: Always
isTaskSystemEnabled(config)— never readconfig.experimental.task_systeminline. - No bash edits:
.matrixx/tasks/T-*.jsonguarded bytask-edit-guard— use tools, notsed/echo.
- Mock-heavy isolation: Tests with
mock.module()run isolated — add new such files to both.github/workflows/ci.yml+publish.ymlmock-heavy list 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/*.test.ts,src/features/task-storage/*.test.ts,src/hooks/task-continuation-enforcer/*.test.ts,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 if scope=global |
Project isolation, no cross-project leakage |
0682bce isolation suite (11 tests) |
Verify project isolation | — |
d16dc27 task-edit-guard |
Block raw bash on .matrixx/tasks & .matrixx/plans |
Prevent bypass of locking/validation |
3d44108 hide completed from TUI |
TUI shows only active tasks | Reduce noise |
d8ca206 decouple task-continuation-enforcer from todo-continuation-enforcer |
Dedicated enforcer per system; remove todo-sync.ts dual-write |
Enforcers independent; no coupling debt |
62e7061 wire enforcer to event bus & correct injection |
handleSessionIdle via onAbort/onRecoveryComplete callbacks |
Abort-aware, background-task-aware continuation |
5459ef9 merge feat/task-system-no-todowrite |
Remove stale specs, repair compaction | — |
75fedac default task_system=true + canonical gating |
isTaskSystemEnabled + _migrations marker |
Zero-config for fresh clones |
8509b84–82f9f38 /task-list & /cleanup-tasks search-mode fixes |
Ignore global search-mode |
Commands must not leak into global search |
Known tech debt:
- Duplicate
task-edit-guardinHookNameSchema(lines 66/67) — dedup pending. - No dedicated
TaskManagerclass — CRUD is stateless I/O +.lock. Intentional simplicity; do not introduce a manager unless contention profiling justifies it.
End of Task System Engineering Specification. For config reference see docs/configurations.md; for orchestration see docs/orchestration-guide.md and docs/agent-architecture.md.