---
title: Tasks (markdown outliner, in development) (part 10)
---
# Tasks (markdown outliner, in development) (part 10)

## What it is

This is part 10 of the [Tasks (markdown outliner, in development)](tasks-v2.md) page. It carries the next stretch of the material on that page, moved here because a single page is capped at 40,000 characters.

## Where to find it

Reach this part through [Tasks (markdown outliner, in development)](tasks-v2.md) — it lists every part and explains where the feature lives in the product. Everything below is reached from the same place.

## How it behaves

Everything below is the behaviour, detail and edge cases that belong to this stretch of the Tasks (markdown outliner, in development) page.

### The markdown mirror (output only)

`src/main/services/tasks/tasks-v2-mirror-writer.ts` writes `<userData>/tasks-v2.md` on every change (debounced 500 ms, atomic via write-tmp-then-rename). The pure formatter is `src/main/services/tasks/tasks-v2-mirror-format.ts` (`formatTasksV2Mirror`): a nested markdown checklist, `[x]` for done, `[ ]` for actionable, no checkbox for a row snoozed into the future, with an HTML-comment metadata tail (`<!--id=… due=… snooze=… ctx=…-->`, with `-->` inside values escaped via a zero-width space).

**The mirror is never read back to reconstruct task state.** The DB is the single source of truth; the file is for AI agents / human eyes only. The only reader is `tasks-v2:get-markdown` / `GET /tasks-v2/markdown`, which return the file's text verbatim to a UI/agent. Mirror writes are fire-and-forget — a write failure is recorded as `getLastMirrorError()` for a "Mirror file out of sync" UI chip and never blocks a DB write. Writes (and the orphan-tmp cleanup in `ensureValid()`) are skipped entirely while the feature is off, so disabling Tasks never clobbers an existing file.

### Gating mechanism

Tasks is registered as an in-development feature in the unreleased-feature registry — the `'tasks-v2'` entry in `src/shared/unreleased-features.ts` (`settingKey: 'tasksV2Enabled'`, `envVar: AMC_SHOW_TASKS_V2`, `status: 'in-development'`). Visibility is decided ONLY through `isUnreleasedFeatureVisible` (main) / `isUnreleasedFeatureVisibleInRenderer` (renderer):

```
visible = status === 'shipped' || env AMC_SHOW_TASKS_V2 === '1' || settings.tasksV2Enabled === true
```

No code reads `settings.tasksV2Enabled` directly to decide visibility — doing so would bypass the env-reveal and shipped paths and fail the gating lint. The gates: `requireTasksV2Enabled()` ([feature-flag-gates.ts](../../src/main/services/feature-flag-gates.ts)) for IPC, `tasksV2Visible()` for CLI, `isTasksV2Visible()` for the mirror writer, and the `UNRELEASED_PROJECT_GATES` entry in `src/renderer/src/stores/project-visibility.ts` for the sidebar row. To ship the feature, flip the registry `status` to `'shipped'` (then the Lab toggle disappears and everyone sees it). Full mechanism: the unreleased-feature gating page.

## For agents

### CLI routes (`/tasks-v2/*`)

Local control server (`127.0.0.1:19519`), in `src/main/services/cli/cli-server-tasks-v2-routes.ts`. Every handler runs the shared feature gate (`tasksReadAuth` / `tasksMutationGate` → `isCliFeatureVisible('tasks-v2')`) first and returns **403 with `disabled: true`** when the feature is off — deliberately not a 404, so a caller can tell "feature off" from "id not found". On these routes a **404** means only that the id is missing.

| Route                         | Auth / limit                        | Notes                                                             |
| ----------------------------- | ----------------------------------- | ----------------------------------------------------------------- |
| `GET /tasks-v2/list`          | bearer + 60 reads/min/token         | mirrors `TASKS_V2_LIST`                                           |
| `POST /tasks-v2/create`       | bearer (central) + 10 mutations/min | 201 on success; 409 if `parentId` is deleted                      |
| `POST /tasks-v2/update`       | bearer + 10/min                     | 404 if id missing                                                 |
| `POST /tasks-v2/complete`     | bearer + 10/min                     | 404 if id missing                                                 |
| `POST /tasks-v2/delete`       | bearer + 10/min                     | idempotent; `{deletedCount}`                                      |
| `GET /tasks-v2/markdown`      | bearer + 60 reads/min/token         | `text/markdown`, `no-store`, `nosniff`; empty body if file absent |
| `GET /tasks-v2-lists/list`    | bearer + 60 reads/min/token         | all lists + per-list `counts`                                     |
| `POST /tasks-v2-lists/create` | bearer + 10/min                     | 201; returns the new list                                         |
| `POST /tasks-v2-lists/update` | bearer + 10/min                     | 404 if id missing; patch name / color / master context            |
| `POST /tasks-v2-lists/delete` | bearer + 10/min                     | cascade soft-delete; `{deletedListCount, deletedTaskCount}`       |

### Setting a time on a task (`dueAt`, `snoozedUntil`, `startAt`, `waitingSince`, `followUpAt`)

**Send the plain local time. Do not convert to UTC yourself.** All three shapes are accepted and normalized to a UTC instant before storage:

| You send                    | Read as                                            | Stored                     |
| --------------------------- | -------------------------------------------------- | -------------------------- |
| `2026-08-18T15:00`          | 3 PM **in the user's own time zone** ← prefer this | `2026-08-18T19:00:00.000Z` |
| `2026-08-18T15:00:00-04:00` | the offset you stated                              | `2026-08-18T19:00:00.000Z` |
| `2026-08-18T15:00:00.000Z`  | a genuine UTC instant                              | `2026-08-18T15:00:00.000Z` |

The zoneless form is the one to reach for — it is the only shape that cannot be got wrong. The app resolves it against the user's live zone with the same wall-clock resolver alarms, scheduled messages, and cron jobs use (`shared/datetime/wall-clock-zone.ts`), so it stays correct across DST and across a change of machine zone.

**Never append `Z` to a wall-clock time you have not actually converted.** That is indistinguishable from a real UTC instant, so nothing downstream can catch it — the deadline is simply stored wrong and the reminder fires at the wrong hour. That is precisely how task "Thai — Tue 8/18 3:00 session" came to hold `due_at = 2026-08-18T15:00:00.000Z` and alert the owner at 11:00 AM Eastern, four hours early (2026-08-18).

A bare date (`2026-08-18`), a bare clock (`15:00`), and prose (`tomorrow`) are all rejected — these routes take a datetime, never a phrase. **A client running in a different time zone from the desktop app** (a phone, another machine) should send the explicit-offset form: a zoneless time is always resolved in the _desktop's_ zone, because that is where the payload is validated.

Both `/tasks-v2/reorder` AND `/tasks-v2-lists/reorder` are **deliberately not exposed** — reorder is a human-curated operation, IPC-only (matches the v1 CLI surface). The **Break it down (M4) interview** has **no CLI route at all** — `interview-run` / `interview-send` spend tokens and `interview-accept` writes tasks, so all of it is IPC-only by design, ensuring only an in-app human action can trigger spend or create subtasks. Every list mutation route schedules a mirror write. For POST routes the central bearer-auth check in `cli-server.ts`'s `handleRequest` runs **before** the per-handler feature gate, so an unauthenticated POST returns 401 even when the feature is on.

## Related

The overview, the other parts, and everything else worth reading next all sit on [Tasks (markdown outliner, in development)](tasks-v2.md).
