# Magnum Opus v0.4 — Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add local task management (VTODO files) and GTD triage (defer, file, discard, complete) to the email client. Emails and tasks appear as a unified stream — same perspectives, same triage actions.

**Architecture:** TaskStore module manages VTODO `.ics` files on disk with a SQLite cache. Emails and tasks are unified via `ItemSummary` enum. Deferrals for emails use a `deferral` table; task deferrals use the `task.deferUntil` column (derived from VTODO `DTSTART`). Labels with an `isProject` flag provide project grouping. SyncEngine resurfaces deferred items on each sync cycle.

**Tech Stack:** Swift 6 (strict concurrency), SwiftUI, GRDB.swift, iCalendar/VTODO (in-house parser)

**Design Document:** `docs/plans/2026-03-14-v0.4-gtd-tasks-design.md`

**Branch:** `feature/v0.4-gtd-tasks` from `main`.

---

## File Structure (changes from v0.3)

```
MagnumOpus/
├── Packages/
│   └── MagnumOpusCore/
│       ├── Package.swift                              ← EDIT: add TaskStore target
│       ├── Sources/
│       │   ├── Models/
│       │   │   ├── TaskSummary.swift                  ← NEW
│       │   │   ├── TaskStatus.swift                   ← NEW
│       │   │   ├── LabelInfo.swift                    ← NEW
│       │   │   └── ItemSummary.swift                  ← NEW
│       │   │
│       │   ├── TaskStore/                             ← NEW module
│       │   │   ├── VTODOParser.swift                  ← NEW: iCalendar line parser
│       │   │   ├── VTODOFormatter.swift               ← NEW: TaskRecord → .ics string
│       │   │   ├── TaskStore.swift                    ← NEW: file I/O + cache
│       │   │   └── TaskRecord.swift                   ← NEW: GRDB record
│       │   │
│       │   ├── MailStore/
│       │   │   ├── DatabaseSetup.swift                ← EDIT: add v3 migrations
│       │   │   ├── MailStore.swift                    ← EDIT: add label/deferral queries
│       │   │   └── Records/
│       │   │       ├── LabelRecord.swift              ← NEW
│       │   │       ├── ItemLabelRecord.swift          ← NEW
│       │   │       └── DeferralRecord.swift           ← NEW
│       │   │
│       │   └── SyncEngine/
│       │       └── SyncCoordinator.swift              ← EDIT: add resurfacing check
│       │
│       └── Tests/
│           ├── TaskStoreTests/                        ← NEW
│           │   ├── VTODOParserTests.swift
│           │   ├── VTODOFormatterTests.swift
│           │   └── TaskStoreTests.swift
│           └── MailStoreTests/
│               └── LabelDeferralTests.swift           ← NEW
│
├── Apps/
│   └── MagnumOpus/
│       ├── ViewModels/
│       │   ├── MailViewModel.swift                    ← EDIT: perspectives, GTD triage
│       │   └── TaskEditViewModel.swift                ← NEW
│       └── Views/
│           ├── SidebarView.swift                      ← EDIT: new perspectives
│           ├── ThreadListView.swift                   ← EDIT: unified items, GTD keys
│           ├── ThreadDetailView.swift                 ← EDIT: linked tasks inline
│           ├── DeferPicker.swift                      ← NEW
│           ├── LabelPicker.swift                      ← NEW
│           └── TaskEditView.swift                     ← NEW
```

**Dependency graph:** `SyncEngine` → `IMAPClient` + `MailStore` + `SMTPClient` + `TaskStore` → `Models`.

---

## Chunk 1: Models & Schema

New model types, database records, and migrations. No file I/O or UI — pure data layer.

### Task 1: New Model Types

**Files:**
- Create: `Packages/MagnumOpusCore/Sources/Models/TaskStatus.swift`
- Create: `Packages/MagnumOpusCore/Sources/Models/TaskSummary.swift`
- Create: `Packages/MagnumOpusCore/Sources/Models/LabelInfo.swift`
- Create: `Packages/MagnumOpusCore/Sources/Models/ItemSummary.swift`

- [ ] **Step 1: Create TaskStatus enum**

Create `Packages/MagnumOpusCore/Sources/Models/TaskStatus.swift`:

```swift
public enum TaskStatus: String, Sendable, Codable {
	case needsAction = "NEEDS-ACTION"
	case inProcess   = "IN-PROCESS"
	case completed   = "COMPLETED"
	case cancelled   = "CANCELLED"
}
```

- [ ] **Step 2: Create TaskSummary**

Create `Packages/MagnumOpusCore/Sources/Models/TaskSummary.swift`:

```swift
import Foundation

public struct TaskSummary: Sendable, Identifiable, Equatable {
	public var id: String
	public var accountId: String
	public var summary: String
	public var description: String?
	public var status: TaskStatus
	public var priority: Int
	public var dueDate: Date?
	public var deferUntil: Date?
	public var createdAt: Date
	public var linkedMessageId: String?
	public var categories: [String]
	public var isSomeday: Bool

	public init(
		id: String,
		accountId: String,
		summary: String,
		description: String? = nil,
		status: TaskStatus = .needsAction,
		priority: Int = 0,
		dueDate: Date? = nil,
		deferUntil: Date? = nil,
		createdAt: Date = Date(),
		linkedMessageId: String? = nil,
		categories: [String] = [],
		isSomeday: Bool = false
	) {
		self.id = id
		self.accountId = accountId
		self.summary = summary
		self.description = description
		self.status = status
		self.priority = priority
		self.dueDate = dueDate
		self.deferUntil = deferUntil
		self.createdAt = createdAt
		self.linkedMessageId = linkedMessageId
		self.categories = categories
		self.isSomeday = isSomeday
	}
}
```

- [ ] **Step 3: Create LabelInfo**

Create `Packages/MagnumOpusCore/Sources/Models/LabelInfo.swift`:

```swift
public struct LabelInfo: Sendable, Identifiable, Equatable {
	public var id: String
	public var name: String
	public var isProject: Bool
	public var color: String?

	public init(id: String, name: String, isProject: Bool = false, color: String? = nil) {
		self.id = id
		self.name = name
		self.isProject = isProject
		self.color = color
	}
}
```

- [ ] **Step 4: Create ItemSummary**

Create `Packages/MagnumOpusCore/Sources/Models/ItemSummary.swift`:

```swift
import Foundation

public enum ItemSummary: Sendable, Identifiable, Equatable {
	case email(MessageSummary)
	case task(TaskSummary)

	public var id: String {
		switch self {
		case .email(let msg): return "email-\(msg.id)"
		case .task(let task): return "task-\(task.id)"
		}
	}

	public var title: String {
		switch self {
		case .email(let msg): return msg.subject ?? "(no subject)"
		case .task(let task): return task.summary
		}
	}

	public var date: Date? {
		switch self {
		case .email(let msg): return msg.date
		case .task(let task): return task.createdAt
		}
	}

	public var dueDate: Date? {
		switch self {
		case .email: return nil
		case .task(let task): return task.dueDate
		}
	}

	public var isDeferred: Bool {
		switch self {
		case .email: return false  // checked via deferral table
		case .task(let task): return task.deferUntil != nil
		}
	}

	public var isSomeday: Bool {
		switch self {
		case .email: return false  // checked via deferral table
		case .task(let task): return task.isSomeday
		}
	}
}
```

- [ ] **Step 5: Verify Models compile**

```bash
swift build --package-path Packages/MagnumOpusCore --target Models
```

- [ ] **Step 6: Commit**

```bash
git add Packages/MagnumOpusCore/Sources/Models/
git commit -m "add v0.4 model types: TaskStatus, TaskSummary, LabelInfo, ItemSummary"
```

---

### Task 2: Database Records & Migrations

**Files:**
- Create: `Packages/MagnumOpusCore/Sources/TaskStore/TaskRecord.swift`
- Create: `Packages/MagnumOpusCore/Sources/MailStore/Records/LabelRecord.swift`
- Create: `Packages/MagnumOpusCore/Sources/MailStore/Records/ItemLabelRecord.swift`
- Create: `Packages/MagnumOpusCore/Sources/MailStore/Records/DeferralRecord.swift`
- Edit: `Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift`
- Edit: `Packages/MagnumOpusCore/Package.swift`

- [ ] **Step 1: Add TaskStore target to Package.swift**

Edit `Packages/MagnumOpusCore/Package.swift`:

```swift
// In products:
.library(name: "TaskStore", targets: ["TaskStore"]),

// In targets:
.target(
	name: "TaskStore",
	dependencies: [
		"Models",
		"MailStore",
		.product(name: "GRDB", package: "GRDB.swift"),
	]
),

// Add TaskStore to SyncEngine dependencies:
.target(
	name: "SyncEngine",
	dependencies: ["Models", "IMAPClient", "MailStore", "SMTPClient", "TaskStore"]
),

// Add test target:
.testTarget(name: "TaskStoreTests", dependencies: ["TaskStore", "MailStore"]),
```

Create placeholder: `mkdir -p Packages/MagnumOpusCore/Sources/TaskStore`

- [ ] **Step 2: Create TaskRecord**

Create `Packages/MagnumOpusCore/Sources/TaskStore/TaskRecord.swift`:

```swift
import Foundation
import GRDB

public struct TaskRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
	public static let databaseTableName = "task"

	public var id: String
	public var accountId: String
	public var summary: String
	public var description: String?
	public var status: String
	public var priority: Int
	public var dueDate: String?
	public var deferUntil: String?
	public var createdAt: String
	public var updatedAt: String
	public var filePath: String
	public var linkedMessageId: String?
	public var isSomeday: Bool

	public init(
		id: String,
		accountId: String,
		summary: String,
		description: String? = nil,
		status: String = "NEEDS-ACTION",
		priority: Int = 0,
		dueDate: String? = nil,
		deferUntil: String? = nil,
		createdAt: String,
		updatedAt: String,
		filePath: String,
		linkedMessageId: String? = nil,
		isSomeday: Bool = false
	) {
		self.id = id
		self.accountId = accountId
		self.summary = summary
		self.description = description
		self.status = status
		self.priority = priority
		self.dueDate = dueDate
		self.deferUntil = deferUntil
		self.createdAt = createdAt
		self.updatedAt = updatedAt
		self.filePath = filePath
		self.linkedMessageId = linkedMessageId
		self.isSomeday = isSomeday
	}
}
```

- [ ] **Step 3: Create LabelRecord**

Create `Packages/MagnumOpusCore/Sources/MailStore/Records/LabelRecord.swift`:

```swift
import GRDB

public struct LabelRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
	public static let databaseTableName = "label"

	public var id: String
	public var accountId: String
	public var name: String
	public var isProject: Bool
	public var color: String?

	public init(id: String, accountId: String, name: String, isProject: Bool = false, color: String? = nil) {
		self.id = id
		self.accountId = accountId
		self.name = name
		self.isProject = isProject
		self.color = color
	}
}
```

- [ ] **Step 4: Create ItemLabelRecord**

Create `Packages/MagnumOpusCore/Sources/MailStore/Records/ItemLabelRecord.swift`:

```swift
import GRDB

public struct ItemLabelRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
	public static let databaseTableName = "itemLabel"

	public var labelId: String
	public var itemType: String   // "email" or "task"
	public var itemId: String

	public init(labelId: String, itemType: String, itemId: String) {
		self.labelId = labelId
		self.itemType = itemType
		self.itemId = itemId
	}
}
```

- [ ] **Step 5: Create DeferralRecord**

Create `Packages/MagnumOpusCore/Sources/MailStore/Records/DeferralRecord.swift`:

```swift
import GRDB

public struct DeferralRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
	public static let databaseTableName = "deferral"

	public var id: String
	public var messageId: String
	public var deferUntil: String?      // ISO8601, NULL = someday
	public var originalMailbox: String?
	public var createdAt: String

	public init(
		id: String,
		messageId: String,
		deferUntil: String? = nil,
		originalMailbox: String? = nil,
		createdAt: String
	) {
		self.id = id
		self.messageId = messageId
		self.deferUntil = deferUntil
		self.originalMailbox = originalMailbox
		self.createdAt = createdAt
	}
}
```

- [ ] **Step 6: Add v3 migrations to DatabaseSetup.swift**

Edit `Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift` — add three new migrations after existing v2 ones:

```swift
migrator.registerMigration("v3_task") { db in
	try db.create(table: "task") { t in
		t.primaryKey("id", .text)
		t.belongsTo("account", onDelete: .cascade).notNull()
		t.column("summary", .text).notNull()
		t.column("description", .text)
		t.column("status", .text).notNull().defaults(to: "NEEDS-ACTION")
		t.column("priority", .integer).notNull().defaults(to: 0)
		t.column("dueDate", .text)
		t.column("deferUntil", .text)
		t.column("createdAt", .text).notNull()
		t.column("updatedAt", .text).notNull()
		t.column("filePath", .text).notNull()
		t.column("linkedMessageId", .text)
		t.column("isSomeday", .boolean).notNull().defaults(to: false)
	}
	try db.create(index: "idx_task_status", on: "task", columns: ["status"])
	try db.create(index: "idx_task_deferUntil", on: "task", columns: ["deferUntil"])
	try db.create(index: "idx_task_dueDate", on: "task", columns: ["dueDate"])
	try db.create(index: "idx_task_linkedMessageId", on: "task", columns: ["linkedMessageId"])
}

migrator.registerMigration("v3_label") { db in
	try db.create(table: "label") { t in
		t.primaryKey("id", .text)
		t.belongsTo("account", onDelete: .cascade).notNull()
		t.column("name", .text).notNull()
		t.column("isProject", .boolean).notNull().defaults(to: false)
		t.column("color", .text)
	}
	try db.create(index: "idx_label_name", on: "label", columns: ["accountId", "name"], unique: true)

	try db.create(table: "itemLabel") { t in
		t.column("labelId", .text).notNull().references("label", onDelete: .cascade)
		t.column("itemType", .text).notNull()
		t.column("itemId", .text).notNull()
		t.primaryKey(["labelId", "itemType", "itemId"])
	}
}

migrator.registerMigration("v3_deferral") { db in
	try db.create(table: "deferral") { t in
		t.primaryKey("id", .text)
		t.column("messageId", .text).notNull().references("message", onDelete: .cascade)
		t.column("deferUntil", .text)
		t.column("originalMailbox", .text)
		t.column("createdAt", .text).notNull()
		t.uniqueKey(["messageId"])
	}
	try db.create(index: "idx_deferral_until", on: "deferral", columns: ["deferUntil"])
}
```

- [ ] **Step 7: Add migration and record round-trip tests**

Create `Packages/MagnumOpusCore/Tests/MailStoreTests/LabelDeferralTests.swift`:

```swift
import Testing
import Foundation
@testable import MailStore

@Suite("V3 Migrations & Records")
struct LabelDeferralTests {
	@Test("v3 migrations create task, label, itemLabel, deferral tables")
	func v3TablesExist() throws {
		let db = try DatabaseSetup.openInMemoryDatabase()
		try db.read { db in
			let tables = try String.fetchAll(db, sql: "SELECT name FROM sqlite_master WHERE type='table'")
			#expect(tables.contains("task"))
			#expect(tables.contains("label"))
			#expect(tables.contains("itemLabel"))
			#expect(tables.contains("deferral"))
		}
	}

	@Test("task table has expected columns")
	func taskColumns() throws {
		let db = try DatabaseSetup.openInMemoryDatabase()
		try db.read { db in
			let columns = try db.columns(in: "task").map(\.name)
			#expect(columns.contains("summary"))
			#expect(columns.contains("status"))
			#expect(columns.contains("deferUntil"))
			#expect(columns.contains("isSomeday"))
			#expect(columns.contains("linkedMessageId"))
			#expect(columns.contains("filePath"))
		}
	}

	@Test("LabelRecord round-trip")
	func labelRoundTrip() throws {
		let db = try DatabaseSetup.openInMemoryDatabase()
		try db.write { db in
			try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
			try LabelRecord(id: "l1", accountId: "a1", name: "Work", isProject: true).insert(db)
		}
		let loaded = try db.read { db in try LabelRecord.fetchOne(db, key: "l1") }
		#expect(loaded?.name == "Work")
		#expect(loaded?.isProject == true)
	}

	@Test("ItemLabelRecord round-trip")
	func itemLabelRoundTrip() throws {
		let db = try DatabaseSetup.openInMemoryDatabase()
		try db.write { db in
			try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
			try LabelRecord(id: "l1", accountId: "a1", name: "Work").insert(db)
			try ItemLabelRecord(labelId: "l1", itemType: "email", itemId: "msg1").insert(db)
		}
		let loaded = try db.read { db in
			try ItemLabelRecord.fetchAll(db, sql: "SELECT * FROM itemLabel WHERE itemId = 'msg1'")
		}
		#expect(loaded.count == 1)
		#expect(loaded.first?.itemType == "email")
	}

	@Test("DeferralRecord round-trip with unique constraint")
	func deferralRoundTrip() throws {
		let db = try DatabaseSetup.openInMemoryDatabase()
		try db.write { db in
			try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
			try db.execute(sql: """
				INSERT INTO mailbox (id, accountId, name, uidValidity, uidNext) VALUES ('mb1', 'a1', 'INBOX', 1, 1)
			""")
			try db.execute(sql: """
				INSERT INTO message (id, accountId, mailboxId, uid, date, isRead, isFlagged, size)
				VALUES ('m1', 'a1', 'mb1', 1, '2026-03-14', 0, 0, 100)
			""")
			try DeferralRecord(id: "d1", messageId: "m1", deferUntil: "2026-03-20T00:00:00Z", originalMailbox: "INBOX", createdAt: "2026-03-14T10:00:00Z").insert(db)
		}
		let loaded = try db.read { db in try DeferralRecord.fetchOne(db, key: "d1") }
		#expect(loaded?.deferUntil == "2026-03-20T00:00:00Z")
		#expect(loaded?.originalMailbox == "INBOX")
	}

	@Test("deferral cascade deletes when message deleted")
	func deferralCascade() throws {
		let db = try DatabaseSetup.openInMemoryDatabase()
		try db.write { db in
			try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
			try db.execute(sql: """
				INSERT INTO mailbox (id, accountId, name, uidValidity, uidNext) VALUES ('mb1', 'a1', 'INBOX', 1, 1)
			""")
			try db.execute(sql: """
				INSERT INTO message (id, accountId, mailboxId, uid, date, isRead, isFlagged, size)
				VALUES ('m1', 'a1', 'mb1', 1, '2026-03-14', 0, 0, 100)
			""")
			try DeferralRecord(id: "d1", messageId: "m1", createdAt: "2026-03-14T10:00:00Z").insert(db)
			try db.execute(sql: "DELETE FROM message WHERE id = 'm1'")
		}
		let count = try db.read { db in try DeferralRecord.fetchCount(db) }
		#expect(count == 0)
	}
}
```

- [ ] **Step 8: Verify build and run tests**

```bash
swift build --package-path Packages/MagnumOpusCore
swift test --package-path Packages/MagnumOpusCore --filter LabelDeferralTests
```

- [ ] **Step 9: Commit**

```bash
git add -A
git commit -m "add v0.4 schema: task, label, itemLabel, deferral tables with migrations and tests"
```

---

### Task 3: MailStore Query Extensions

**Files:**
- Edit: `Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift`

- [ ] **Step 1: Add label CRUD to MailStore**

```swift
// MARK: - Labels

public func insertLabel(_ label: LabelRecord) throws {
	try dbWriter.write { db in try label.insert(db) }
}

public func labels(accountId: String) throws -> [LabelRecord] {
	try dbWriter.read { db in
		try LabelRecord
			.filter(Column("accountId") == accountId)
			.order(Column("name").asc)
			.fetchAll(db)
	}
}

public func projectLabels(accountId: String) throws -> [LabelRecord] {
	try dbWriter.read { db in
		try LabelRecord
			.filter(Column("accountId") == accountId)
			.filter(Column("isProject") == true)
			.order(Column("name").asc)
			.fetchAll(db)
	}
}

public func deleteLabel(id: String) throws {
	_ = try dbWriter.write { db in try LabelRecord.deleteOne(db, key: id) }
}

public func labelByName(_ name: String, accountId: String) throws -> LabelRecord? {
	try dbWriter.read { db in
		try LabelRecord
			.filter(Column("accountId") == accountId)
			.filter(Column("name") == name)
			.fetchOne(db)
	}
}
```

- [ ] **Step 2: Add itemLabel junction CRUD**

```swift
// MARK: - Item Labels

public func attachLabel(labelId: String, itemType: String, itemId: String) throws {
	try dbWriter.write { db in
		try ItemLabelRecord(labelId: labelId, itemType: itemType, itemId: itemId).insert(db)
	}
}

public func detachLabel(labelId: String, itemType: String, itemId: String) throws {
	_ = try dbWriter.write { db in
		try db.execute(
			sql: "DELETE FROM itemLabel WHERE labelId = ? AND itemType = ? AND itemId = ?",
			arguments: [labelId, itemType, itemId]
		)
	}
}

public func labelsForItem(itemType: String, itemId: String) throws -> [LabelRecord] {
	try dbWriter.read { db in
		try LabelRecord.fetchAll(db, sql: """
			SELECT label.* FROM label
			JOIN itemLabel ON itemLabel.labelId = label.id
			WHERE itemLabel.itemType = ? AND itemLabel.itemId = ?
			ORDER BY label.name ASC
		""", arguments: [itemType, itemId])
	}
}

public func itemsWithLabel(labelId: String) throws -> [(itemType: String, itemId: String)] {
	try dbWriter.read { db in
		let rows = try Row.fetchAll(db, sql: """
			SELECT itemType, itemId FROM itemLabel WHERE labelId = ?
		""", arguments: [labelId])
		return rows.map { (itemType: $0["itemType"], itemId: $0["itemId"]) }
	}
}
```

- [ ] **Step 3: Add deferral CRUD**

```swift
// MARK: - Deferrals (email only)

public func insertDeferral(_ deferral: DeferralRecord) throws {
	try dbWriter.write { db in try deferral.insert(db) }
}

public func deleteDeferral(id: String) throws {
	_ = try dbWriter.write { db in try DeferralRecord.deleteOne(db, key: id) }
}

public func deleteDeferralForMessage(messageId: String) throws {
	_ = try dbWriter.write { db in
		try db.execute(sql: "DELETE FROM deferral WHERE messageId = ?", arguments: [messageId])
	}
}

public func deferralForMessage(messageId: String) throws -> DeferralRecord? {
	try dbWriter.read { db in
		try DeferralRecord
			.filter(Column("messageId") == messageId)
			.fetchOne(db)
	}
}

public func expiredDeferrals(beforeDate: String) throws -> [DeferralRecord] {
	try dbWriter.read { db in
		try DeferralRecord.fetchAll(db, sql: """
			SELECT * FROM deferral WHERE deferUntil IS NOT NULL AND deferUntil <= ?
		""", arguments: [beforeDate])
	}
}

public func somedayDeferrals() throws -> [DeferralRecord] {
	try dbWriter.read { db in
		try DeferralRecord
			.filter(Column("deferUntil") == nil)
			.fetchAll(db)
	}
}
```

- [ ] **Step 4: Add task cache queries to MailStore**

The task table lives in the same SQLite database. Add queries for task records:

```swift
// MARK: - Task Cache (used by perspectives)

public func insertTask(_ task: TaskRecord) throws {
	try dbWriter.write { db in try task.insert(db) }
}

public func updateTask(_ task: TaskRecord) throws {
	try dbWriter.write { db in try task.update(db) }
}

public func deleteTask(id: String) throws {
	_ = try dbWriter.write { db in try TaskRecord.deleteOne(db, key: id) }
}

public func task(id: String) throws -> TaskRecord? {
	try dbWriter.read { db in try TaskRecord.fetchOne(db, key: id) }
}

public func inboxTasks(accountId: String) throws -> [TaskRecord] {
	try dbWriter.read { db in
		try TaskRecord.fetchAll(db, sql: """
			SELECT * FROM task
			WHERE accountId = ? AND status = 'NEEDS-ACTION'
			AND deferUntil IS NULL AND isSomeday = 0
			ORDER BY createdAt DESC
		""", arguments: [accountId])
	}
}

public func todayTasks(accountId: String, todayDate: String) throws -> [TaskRecord] {
	try dbWriter.read { db in
		try TaskRecord.fetchAll(db, sql: """
			SELECT * FROM task
			WHERE accountId = ? AND status IN ('NEEDS-ACTION', 'IN-PROCESS')
			AND dueDate = ?
		""", arguments: [accountId, todayDate])
	}
}

public func upcomingTasks(accountId: String, afterDate: String) throws -> [TaskRecord] {
	try dbWriter.read { db in
		try TaskRecord.fetchAll(db, sql: """
			SELECT * FROM task
			WHERE accountId = ? AND status IN ('NEEDS-ACTION', 'IN-PROCESS')
			AND dueDate > ?
			ORDER BY dueDate ASC
		""", arguments: [accountId, afterDate])
	}
}

public func somedayTasks(accountId: String) throws -> [TaskRecord] {
	try dbWriter.read { db in
		try TaskRecord.fetchAll(db, sql: """
			SELECT * FROM task WHERE accountId = ? AND isSomeday = 1
			ORDER BY createdAt DESC
		""", arguments: [accountId])
	}
}

public func archivedTasks(accountId: String) throws -> [TaskRecord] {
	try dbWriter.read { db in
		try TaskRecord.fetchAll(db, sql: """
			SELECT * FROM task WHERE accountId = ? AND status IN ('COMPLETED', 'CANCELLED')
			ORDER BY updatedAt DESC
		""", arguments: [accountId])
	}
}

public func tasksLinkedToThread(threadId: String) throws -> [TaskRecord] {
	try dbWriter.read { db in
		try TaskRecord.fetchAll(db, sql: """
			SELECT task.* FROM task
			WHERE task.linkedMessageId IN (
				SELECT message.messageId FROM message
				JOIN threadMessage ON threadMessage.messageId = message.id
				WHERE threadMessage.threadId = ?
			)
			ORDER BY task.createdAt ASC
		""", arguments: [threadId])
	}
}

public func expiredDeferredTasks(beforeDate: String) throws -> [TaskRecord] {
	try dbWriter.read { db in
		try TaskRecord.fetchAll(db, sql: """
			SELECT * FROM task
			WHERE deferUntil IS NOT NULL AND deferUntil <= ? AND isSomeday = 0
		""", arguments: [beforeDate])
	}
}

public func clearTaskDeferral(id: String) throws {
	try dbWriter.write { db in
		try db.execute(
			sql: "UPDATE task SET deferUntil = NULL WHERE id = ?",
			arguments: [id]
		)
	}
}

public func deleteAllTasks() throws {
	_ = try dbWriter.write { db in try TaskRecord.deleteAll(db) }
}

public func deleteAllItemLabelsForTasks() throws {
	_ = try dbWriter.write { db in
		try db.execute(sql: "DELETE FROM itemLabel WHERE itemType = 'task'")
	}
}
```

- [ ] **Step 5: Verify build and all tests pass**

```bash
swift build --package-path Packages/MagnumOpusCore
swift test --package-path Packages/MagnumOpusCore
```

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "add MailStore queries: labels, item-labels, deferrals, task cache perspectives"
```

---

## Chunk 2: TaskStore Module

VTODO parser, formatter, and file-backed task store with cache management.

### Task 4: VTODO Parser

**Files:**
- Create: `Packages/MagnumOpusCore/Sources/TaskStore/VTODOParser.swift`
- Create: `Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOParserTests.swift`

- [ ] **Step 1: Create VTODOParser**

Create `Packages/MagnumOpusCore/Sources/TaskStore/VTODOParser.swift`:

```swift
import Foundation

public enum VTODOParser {
	/// Parse an iCalendar string containing a VTODO into a dictionary of properties.
	public static func parse(_ icsContent: String) -> [String: String]? {
		// Unfold: lines starting with space/tab are continuations
		let unfolded = icsContent
			.replacingOccurrences(of: "\r\n ", with: "")
			.replacingOccurrences(of: "\r\n\t", with: "")
			.replacingOccurrences(of: "\n ", with: "")
			.replacingOccurrences(of: "\n\t", with: "")

		let lines = unfolded.components(separatedBy: .newlines)

		// Find VTODO block
		guard let startIdx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "BEGIN:VTODO" }),
			  let endIdx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "END:VTODO" }),
			  startIdx < endIdx
		else { return nil }

		var properties: [String: String] = [:]
		for i in (startIdx + 1)..<endIdx {
			let line = lines[i].trimmingCharacters(in: .whitespaces)
			guard !line.isEmpty else { continue }

			// Split on first colon — property name may include parameters (e.g., DTSTART;VALUE=DATE:20260315)
			guard let colonIdx = line.firstIndex(of: ":") else { continue }
			let rawName = String(line[..<colonIdx])
			let value = String(line[line.index(after: colonIdx)...])

			// Strip parameters from name (e.g., "DTSTART;VALUE=DATE" → "DTSTART")
			let name = rawName.split(separator: ";").first.map(String.init) ?? rawName
			// Unescape iCalendar text values
			properties[name] = unescapeText(value)
		}

		return properties
	}

	/// Unescape iCalendar text values
	public static func unescapeText(_ text: String) -> String {
		text.replacingOccurrences(of: "\\n", with: "\n")
			.replacingOccurrences(of: "\\,", with: ",")
			.replacingOccurrences(of: "\\;", with: ";")
			.replacingOccurrences(of: "\\\\", with: "\\")
	}

	/// Parse an iCalendar date string (DATE or DATE-TIME format)
	public static func parseDate(_ value: String) -> Date? {
		// DATE-TIME: 20260315T090000Z
		let dtFormatter = DateFormatter()
		dtFormatter.locale = Locale(identifier: "en_US_POSIX")
		dtFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
		dtFormatter.timeZone = TimeZone(identifier: "UTC")
		if let date = dtFormatter.date(from: value) { return date }

		// DATE-TIME without Z (local time)
		dtFormatter.dateFormat = "yyyyMMdd'T'HHmmss"
		dtFormatter.timeZone = .current
		if let date = dtFormatter.date(from: value) { return date }

		// DATE: 20260315
		dtFormatter.dateFormat = "yyyyMMdd"
		dtFormatter.timeZone = .current
		if let date = dtFormatter.date(from: value) { return date }

		return nil
	}

	/// Format a Date into iCalendar DATE-TIME format (UTC)
	public static func formatDate(_ date: Date) -> String {
		let formatter = DateFormatter()
		formatter.locale = Locale(identifier: "en_US_POSIX")
		formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
		formatter.timeZone = TimeZone(identifier: "UTC")
		return formatter.string(from: date)
	}

	/// Format a Date into iCalendar DATE format (no time)
	public static func formatDateOnly(_ date: Date) -> String {
		let formatter = DateFormatter()
		formatter.locale = Locale(identifier: "en_US_POSIX")
		formatter.dateFormat = "yyyyMMdd"
		formatter.timeZone = .current
		return formatter.string(from: date)
	}
}
```

- [ ] **Step 2: Create VTODOParserTests**

Create `Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOParserTests.swift`:

```swift
import Testing
import Foundation
@testable import TaskStore

@Suite("VTODOParser")
struct VTODOParserTests {
	let sampleVTODO = """
	BEGIN:VCALENDAR
	VERSION:2.0
	PRODID:-//MagnumOpus//v0.4//EN
	BEGIN:VTODO
	UID:test-123
	DTSTAMP:20260314T100000Z
	CREATED:20260314T100000Z
	LAST-MODIFIED:20260314T120000Z
	SUMMARY:Buy groceries
	DESCRIPTION:Milk\\, eggs\\, bread
	STATUS:NEEDS-ACTION
	PRIORITY:3
	DTSTART;VALUE=DATE:20260320
	DUE;VALUE=DATE:20260325
	CATEGORIES:Shopping,Personal
	ATTACH:mid:abc-123@example.com
	END:VTODO
	END:VCALENDAR
	"""

	@Test("parses all VTODO properties")
	func parseProperties() {
		let props = VTODOParser.parse(sampleVTODO)
		#expect(props != nil)
		#expect(props?["UID"] == "test-123")
		#expect(props?["SUMMARY"] == "Buy groceries")
		#expect(props?["STATUS"] == "NEEDS-ACTION")
		#expect(props?["PRIORITY"] == "3")
		#expect(props?["CATEGORIES"] == "Shopping,Personal")
		#expect(props?["ATTACH"] == "mid:abc-123@example.com")
	}

	@Test("parses DATE format")
	func parseDateOnly() {
		let date = VTODOParser.parseDate("20260315")
		#expect(date != nil)
		let cal = Calendar.current
		#expect(cal.component(.year, from: date!) == 2026)
		#expect(cal.component(.month, from: date!) == 3)
		#expect(cal.component(.day, from: date!) == 15)
	}

	@Test("parses DATE-TIME format with Z")
	func parseDateTimeUTC() {
		let date = VTODOParser.parseDate("20260315T090000Z")
		#expect(date != nil)
	}

	@Test("parses DATE-TIME format without Z")
	func parseDateTimeLocal() {
		let date = VTODOParser.parseDate("20260315T090000")
		#expect(date != nil)
	}

	@Test("returns nil for invalid content")
	func invalidContent() {
		#expect(VTODOParser.parse("not ical") == nil)
		#expect(VTODOParser.parse("BEGIN:VCALENDAR\nEND:VCALENDAR") == nil)
	}

	@Test("handles line unfolding")
	func lineUnfolding() {
		let folded = """
		BEGIN:VCALENDAR
		BEGIN:VTODO
		UID:test
		DESCRIPTION:This is a very long description that has been
		 folded across multiple lines
		END:VTODO
		END:VCALENDAR
		"""
		let props = VTODOParser.parse(folded)
		#expect(props?["DESCRIPTION"] == "This is a very long description that has been folded across multiple lines")
	}

	@Test("date round-trip")
	func dateRoundTrip() {
		let now = Date()
		let formatted = VTODOParser.formatDate(now)
		let parsed = VTODOParser.parseDate(formatted)
		#expect(parsed != nil)
		// Within 1 second tolerance (formatting drops sub-seconds)
		#expect(abs(now.timeIntervalSince(parsed!)) < 1.0)
	}
}
```

- [ ] **Step 3: Run parser tests**

```bash
swift test --package-path Packages/MagnumOpusCore --filter VTODOParserTests
```

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "add VTODO parser with line unfolding, date parsing, tests"
```

---

### Task 5: VTODO Formatter

**Files:**
- Create: `Packages/MagnumOpusCore/Sources/TaskStore/VTODOFormatter.swift`
- Create: `Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOFormatterTests.swift`

- [ ] **Step 1: Create VTODOFormatter**

Create `Packages/MagnumOpusCore/Sources/TaskStore/VTODOFormatter.swift`:

```swift
import Foundation
import Models

public enum VTODOFormatter {
	/// Format a TaskRecord into an iCalendar VTODO string
	public static func format(
		uid: String,
		summary: String,
		description: String? = nil,
		status: String = "NEEDS-ACTION",
		priority: Int = 0,
		dueDate: Date? = nil,
		deferUntil: Date? = nil,
		categories: [String] = [],
		linkedMessageId: String? = nil,
		isSomeday: Bool = false,
		createdAt: Date,
		updatedAt: Date
	) -> String {
		var lines: [String] = []
		lines.append("BEGIN:VCALENDAR")
		lines.append("VERSION:2.0")
		lines.append("PRODID:-//MagnumOpus//v0.4//EN")
		lines.append("BEGIN:VTODO")
		lines.append("UID:\(uid)")
		lines.append("DTSTAMP:\(VTODOParser.formatDate(updatedAt))")
		lines.append("CREATED:\(VTODOParser.formatDate(createdAt))")
		lines.append("LAST-MODIFIED:\(VTODOParser.formatDate(updatedAt))")
		lines.append("SUMMARY:\(escapeText(summary))")

		if let desc = description, !desc.isEmpty {
			lines.append("DESCRIPTION:\(escapeText(desc))")
		}

		lines.append("STATUS:\(status)")
		lines.append("PRIORITY:\(priority)")

		if let defer_ = deferUntil {
			lines.append("DTSTART;VALUE=DATE:\(VTODOParser.formatDateOnly(defer_))")
		}

		if let due = dueDate {
			lines.append("DUE;VALUE=DATE:\(VTODOParser.formatDateOnly(due))")
		}

		if !categories.isEmpty {
			lines.append("CATEGORIES:\(categories.joined(separator: ","))")
		}

		if let msgId = linkedMessageId {
			lines.append("ATTACH:mid:\(msgId)")
		}

		if isSomeday {
			lines.append("X-MAGNUM-SOMEDAY:TRUE")
		}

		lines.append("END:VTODO")
		lines.append("END:VCALENDAR")

		// Fold lines longer than 75 octets per RFC 5545
		return lines.map(foldLine).joined(separator: "\r\n") + "\r\n"
	}

	static func escapeText(_ text: String) -> String {
		text.replacingOccurrences(of: "\\", with: "\\\\")
			.replacingOccurrences(of: ",", with: "\\,")
			.replacingOccurrences(of: ";", with: "\\;")
			.replacingOccurrences(of: "\n", with: "\\n")
	}

	static func foldLine(_ line: String) -> String {
		let maxOctets = 75
		let utf8 = Array(line.utf8)
		guard utf8.count > maxOctets else { return line }

		var result = ""
		var offset = 0
		var isFirst = true
		while offset < utf8.count {
			let limit = isFirst ? maxOctets : maxOctets - 1  // continuation has leading space
			let end = min(offset + limit, utf8.count)
			let chunk = utf8[offset..<end]
			if isFirst {
				result += String(bytes: chunk, encoding: .utf8) ?? ""
				isFirst = false
			} else {
				result += "\r\n " + (String(bytes: chunk, encoding: .utf8) ?? "")
			}
			offset = end
		}
		return result
	}
}
```

- [ ] **Step 2: Create VTODOFormatterTests**

Create `Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOFormatterTests.swift`:

```swift
import Testing
import Foundation
@testable import TaskStore

@Suite("VTODOFormatter")
struct VTODOFormatterTests {
	@Test("formats minimal VTODO")
	func minimalFormat() {
		let now = Date()
		let result = VTODOFormatter.format(
			uid: "test-1",
			summary: "Buy milk",
			createdAt: now,
			updatedAt: now
		)
		#expect(result.contains("BEGIN:VCALENDAR"))
		#expect(result.contains("BEGIN:VTODO"))
		#expect(result.contains("UID:test-1"))
		#expect(result.contains("SUMMARY:Buy milk"))
		#expect(result.contains("STATUS:NEEDS-ACTION"))
		#expect(result.contains("PRIORITY:0"))
		#expect(result.contains("END:VTODO"))
		#expect(result.contains("END:VCALENDAR"))
	}

	@Test("includes optional fields when set")
	func optionalFields() {
		let now = Date()
		let due = Calendar.current.date(byAdding: .day, value: 7, to: now)!
		let result = VTODOFormatter.format(
			uid: "test-2",
			summary: "Review PR",
			description: "Check the auth changes",
			status: "IN-PROCESS",
			priority: 1,
			dueDate: due,
			categories: ["Work", "Code"],
			linkedMessageId: "msg-abc@example.com",
			createdAt: now,
			updatedAt: now
		)
		#expect(result.contains("DESCRIPTION:Check the auth changes"))
		#expect(result.contains("STATUS:IN-PROCESS"))
		#expect(result.contains("PRIORITY:1"))
		#expect(result.contains("DUE;VALUE=DATE:"))
		#expect(result.contains("CATEGORIES:Work,Code"))
		#expect(result.contains("ATTACH:mid:msg-abc@example.com"))
	}

	@Test("includes someday flag")
	func somedayFlag() {
		let now = Date()
		let result = VTODOFormatter.format(
			uid: "test-3",
			summary: "Learn piano",
			isSomeday: true,
			createdAt: now,
			updatedAt: now
		)
		#expect(result.contains("X-MAGNUM-SOMEDAY:TRUE"))
	}

	@Test("escapes special characters")
	func specialChars() {
		let now = Date()
		let result = VTODOFormatter.format(
			uid: "test-4",
			summary: "Meeting, 3pm; room 5",
			description: "Line 1\nLine 2",
			createdAt: now,
			updatedAt: now
		)
		#expect(result.contains("SUMMARY:Meeting\\, 3pm\\; room 5"))
		#expect(result.contains("DESCRIPTION:Line 1\\nLine 2"))
	}

	@Test("round-trip: format then parse")
	func roundTrip() {
		let now = Date()
		let formatted = VTODOFormatter.format(
			uid: "rt-1",
			summary: "Round trip test",
			status: "NEEDS-ACTION",
			priority: 5,
			categories: ["Test"],
			createdAt: now,
			updatedAt: now
		)
		let parsed = VTODOParser.parse(formatted)
		#expect(parsed?["UID"] == "rt-1")
		#expect(parsed?["SUMMARY"] == "Round trip test")
		#expect(parsed?["STATUS"] == "NEEDS-ACTION")
		#expect(parsed?["PRIORITY"] == "5")
		#expect(parsed?["CATEGORIES"] == "Test")
	}
}
```

- [ ] **Step 3: Run formatter tests**

```bash
swift test --package-path Packages/MagnumOpusCore --filter VTODOFormatterTests
```

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "add VTODO formatter with escaping, line folding, round-trip tests"
```

---

### Task 6: TaskStore (File I/O + Cache)

**Files:**
- Create: `Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift`
- Create: `Packages/MagnumOpusCore/Tests/TaskStoreTests/TaskStoreTests.swift`

- [ ] **Step 1: Create TaskStore**

Create `Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift`:

```swift
import Foundation
import Models
import GRDB

public struct TaskStore: Sendable {
	private let taskDirectory: URL
	private let dbWriter: any DatabaseWriter

	public init(taskDirectory: URL, dbWriter: any DatabaseWriter) {
		self.taskDirectory = taskDirectory
		self.dbWriter = dbWriter
	}

	// MARK: - Write

	/// Create or update a task: write .ics file + update SQLite cache
	public func writeTask(
		id: String,
		accountId: String,
		summary: String,
		description: String? = nil,
		status: TaskStatus = .needsAction,
		priority: Int = 0,
		dueDate: Date? = nil,
		deferUntil: Date? = nil,
		linkedMessageId: String? = nil,
		categories: [String] = [],
		isSomeday: Bool = false
	) throws {
		let now = Date()
		let filePath = taskFilePath(id: id)

		// Preserve original createdAt if updating an existing task
		let existingCreatedAt: Date? = try? dbWriter.read { db in
			try TaskRecord.fetchOne(db, key: id)
		}.flatMap { record in
			ISO8601DateFormatter().date(from: record.createdAt)
		}
		let createdAt = existingCreatedAt ?? now

		// Ensure directory exists
		try FileManager.default.createDirectory(at: taskDirectory, withIntermediateDirectories: true)

		// Write .ics file
		let icsContent = VTODOFormatter.format(
			uid: id,
			summary: summary,
			description: description,
			status: status.rawValue,
			priority: priority,
			dueDate: dueDate,
			deferUntil: deferUntil,
			categories: categories,
			linkedMessageId: linkedMessageId,
			isSomeday: isSomeday,
			createdAt: createdAt,
			updatedAt: now
		)
		try icsContent.write(to: filePath, atomically: true, encoding: .utf8)

		// Update cache
		let isoFormatter = ISO8601DateFormatter()
		let record = TaskRecord(
			id: id,
			accountId: accountId,
			summary: summary,
			description: description,
			status: status.rawValue,
			priority: priority,
			dueDate: dueDate.map { isoFormatter.string(from: $0) },
			deferUntil: deferUntil.map { isoFormatter.string(from: $0) },
			createdAt: isoFormatter.string(from: createdAt),
			updatedAt: isoFormatter.string(from: now),
			filePath: filePath.path,
			linkedMessageId: linkedMessageId,
			isSomeday: isSomeday
		)
		try dbWriter.write { db in
			try record.save(db)  // insert or update
		}
	}

	/// Update task status (and rewrite .ics file)
	public func updateStatus(id: String, status: TaskStatus) throws {
		guard var record = try dbWriter.read({ db in try TaskRecord.fetchOne(db, key: id) }) else { return }
		record.status = status.rawValue
		let isoFormatter = ISO8601DateFormatter()
		record.updatedAt = isoFormatter.string(from: Date())
		try dbWriter.write { db in try record.update(db) }
		try rewriteFile(for: record)
	}

	/// Update task defer date (and rewrite .ics file)
	public func updateDeferral(id: String, deferUntil: Date?, isSomeday: Bool) throws {
		guard var record = try dbWriter.read({ db in try TaskRecord.fetchOne(db, key: id) }) else { return }
		let isoFormatter = ISO8601DateFormatter()
		record.deferUntil = deferUntil.map { isoFormatter.string(from: $0) }
		record.isSomeday = isSomeday
		record.updatedAt = isoFormatter.string(from: Date())
		try dbWriter.write { db in try record.update(db) }
		try rewriteFile(for: record)
	}

	/// Delete task: remove .ics file + cache record
	public func deleteTask(id: String) throws {
		let filePath = taskFilePath(id: id)
		try? FileManager.default.removeItem(at: filePath)
		_ = try dbWriter.write { db in try TaskRecord.deleteOne(db, key: id) }
	}

	// MARK: - Cache Rebuild

	/// Scan task directory, rebuild SQLite cache from .ics files
	public func rebuildCache(accountId: String) throws {
		// Clear existing cache
		_ = try dbWriter.write { db in
			try TaskRecord.filter(Column("accountId") == accountId).deleteAll(db)
			try db.execute(sql: "DELETE FROM itemLabel WHERE itemType = 'task'")
		}

		let fm = FileManager.default
		guard fm.fileExists(atPath: taskDirectory.path) else { return }

		let files = try fm.contentsOfDirectory(at: taskDirectory, includingPropertiesForKeys: nil)
			.filter { $0.pathExtension == "ics" }

		let isoFormatter = ISO8601DateFormatter()

		for file in files {
			let content = try String(contentsOf: file, encoding: .utf8)
			guard let props = VTODOParser.parse(content) else { continue }
			guard let uid = props["UID"] else { continue }

			let record = TaskRecord(
				id: uid,
				accountId: accountId,
				summary: props["SUMMARY"] ?? "(untitled)",
				description: props["DESCRIPTION"],
				status: props["STATUS"] ?? "NEEDS-ACTION",
				priority: Int(props["PRIORITY"] ?? "0") ?? 0,
				dueDate: props["DUE"].flatMap(VTODOParser.parseDate).map { isoFormatter.string(from: $0) },
				deferUntil: props["DTSTART"].flatMap(VTODOParser.parseDate).map { isoFormatter.string(from: $0) },
				createdAt: props["CREATED"].flatMap(VTODOParser.parseDate).map { isoFormatter.string(from: $0) }
					?? isoFormatter.string(from: Date()),
				updatedAt: props["LAST-MODIFIED"].flatMap(VTODOParser.parseDate).map { isoFormatter.string(from: $0) }
					?? isoFormatter.string(from: Date()),
				filePath: file.path,
				linkedMessageId: props["ATTACH"]?.hasPrefix("mid:") == true
					? String(props["ATTACH"]!.dropFirst(4)) : nil,
				isSomeday: props["X-MAGNUM-SOMEDAY"] == "TRUE"
			)
			try dbWriter.write { db in try record.insert(db) }

			// Rebuild labels from CATEGORIES
			if let cats = props["CATEGORIES"], !cats.isEmpty {
				let categoryNames = cats.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
				try dbWriter.write { db in
					for catName in categoryNames {
						// Find or create label
						var label = try LabelRecord.filter(Column("accountId") == accountId)
							.filter(Column("name") == catName)
							.fetchOne(db)
						if label == nil {
							label = LabelRecord(id: UUID().uuidString, accountId: accountId, name: catName, isProject: true)
							try label!.insert(db)
						}
						try ItemLabelRecord(labelId: label!.id, itemType: "task", itemId: uid).insert(db)
					}
				}
			}
		}
	}

	// MARK: - Helpers

	private func taskFilePath(id: String) -> URL {
		taskDirectory.appendingPathComponent("\(id).ics")
	}

	private func rewriteFile(for record: TaskRecord) throws {
		let isoFormatter = ISO8601DateFormatter()
		let icsContent = VTODOFormatter.format(
			uid: record.id,
			summary: record.summary,
			description: record.description,
			status: record.status,
			priority: record.priority,
			dueDate: record.dueDate.flatMap { isoFormatter.date(from: $0) },
			deferUntil: record.deferUntil.flatMap { isoFormatter.date(from: $0) },
			categories: [],  // Will be populated from itemLabel junction if needed
			linkedMessageId: record.linkedMessageId,
			isSomeday: record.isSomeday,
			createdAt: isoFormatter.date(from: record.createdAt) ?? Date(),
			updatedAt: isoFormatter.date(from: record.updatedAt) ?? Date()
		)
		let filePath = URL(fileURLWithPath: record.filePath)
		try icsContent.write(to: filePath, atomically: true, encoding: .utf8)
	}
}
```

Note: `TaskStore` needs `import MailStore` for `LabelRecord` and `ItemLabelRecord` access in `rebuildCache`. Add this import. Alternatively, if circular dependencies are an issue, the rebuild logic that touches labels can live in the SyncEngine (which imports both). Adjust Package.swift if needed — TaskStore may need to depend on MailStore, or the label-rebuild logic moves to SyncEngine.

- [ ] **Step 2: Create TaskStoreTests**

Create `Packages/MagnumOpusCore/Tests/TaskStoreTests/TaskStoreTests.swift`:

```swift
import Testing
import Foundation
@testable import TaskStore
@testable import MailStore
import Models

@Suite("TaskStore")
struct TaskStoreTests {
	func makeTestDir() throws -> URL {
		let dir = FileManager.default.temporaryDirectory
			.appendingPathComponent("MagnumOpusTests-\(UUID().uuidString)")
			.appendingPathComponent("tasks")
		return dir
	}

	func makeStore() throws -> (TaskStore, any DatabaseWriter) {
		let db = try DatabaseSetup.openInMemoryDatabase()
		let dir = try makeTestDir()
		let store = TaskStore(taskDirectory: dir, dbWriter: db)
		// Seed account
		try db.write { db in
			try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
		}
		return (store, db)
	}

	@Test("writeTask creates .ics file and cache record")
	func writeTask() throws {
		let (store, db) = try makeStore()
		try store.writeTask(id: "t1", accountId: "a1", summary: "Buy milk")

		// Verify cache
		let record = try db.read { db in try TaskRecord.fetchOne(db, key: "t1") }
		#expect(record?.summary == "Buy milk")
		#expect(record?.status == "NEEDS-ACTION")

		// Verify file
		let filePath = record!.filePath
		#expect(FileManager.default.fileExists(atPath: filePath))
		let content = try String(contentsOfFile: filePath, encoding: .utf8)
		#expect(content.contains("SUMMARY:Buy milk"))
	}

	@Test("updateStatus changes cache and file")
	func updateStatus() throws {
		let (store, db) = try makeStore()
		try store.writeTask(id: "t1", accountId: "a1", summary: "Test")
		try store.updateStatus(id: "t1", status: .completed)

		let record = try db.read { db in try TaskRecord.fetchOne(db, key: "t1") }
		#expect(record?.status == "COMPLETED")

		let content = try String(contentsOfFile: record!.filePath, encoding: .utf8)
		#expect(content.contains("STATUS:COMPLETED"))
	}

	@Test("deleteTask removes file and cache")
	func deleteTask() throws {
		let (store, db) = try makeStore()
		try store.writeTask(id: "t1", accountId: "a1", summary: "Test")
		let filePath = try db.read { db in try TaskRecord.fetchOne(db, key: "t1")!.filePath }

		try store.deleteTask(id: "t1")

		let record = try db.read { db in try TaskRecord.fetchOne(db, key: "t1") }
		#expect(record == nil)
		#expect(!FileManager.default.fileExists(atPath: filePath))
	}

	@Test("rebuildCache restores tasks from .ics files")
	func rebuildCache() throws {
		let (store, db) = try makeStore()

		// Create tasks
		try store.writeTask(id: "t1", accountId: "a1", summary: "Task One", categories: ["Work"])
		try store.writeTask(id: "t2", accountId: "a1", summary: "Task Two")

		// Clear cache (simulate corruption)
		_ = try db.write { db in try TaskRecord.deleteAll(db) }

		// Rebuild
		try store.rebuildCache(accountId: "a1")

		// Verify restored
		let tasks = try db.read { db in try TaskRecord.fetchAll(db) }
		#expect(tasks.count == 2)
		#expect(tasks.contains(where: { $0.summary == "Task One" }))
		#expect(tasks.contains(where: { $0.summary == "Task Two" }))
	}

	@Test("updateDeferral changes defer date")
	func updateDeferral() throws {
		let (store, db) = try makeStore()
		try store.writeTask(id: "t1", accountId: "a1", summary: "Test")

		let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())!
		try store.updateDeferral(id: "t1", deferUntil: tomorrow, isSomeday: false)

		let record = try db.read { db in try TaskRecord.fetchOne(db, key: "t1") }
		#expect(record?.deferUntil != nil)
		#expect(record?.isSomeday == false)
	}
}
```

- [ ] **Step 3: Run TaskStore tests**

```bash
swift test --package-path Packages/MagnumOpusCore --filter TaskStoreTests
```

- [ ] **Step 4: Run all tests to check for regressions**

```bash
swift test --package-path Packages/MagnumOpusCore
```

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "add TaskStore: file I/O, cache management, rebuild from .ics files"
```

---

## Chunk 3: Deferral Resurfacing & SyncEngine

### Task 7: Deferral Resurfacing in SyncCoordinator

**Files:**
- Edit: `Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift`

- [ ] **Step 1: Add resurfacing check to performSync**

Add a `resurfaceDeferrals()` method to SyncCoordinator that:
1. Gets current ISO8601 date string
2. Queries `expiredDeferrals(beforeDate:)` from MailStore → delete each deferral record
3. Queries `expiredDeferredTasks(beforeDate:)` from MailStore → clear deferUntil for each task
4. If TaskStore is available, rewrite the .ics file for each resurfaced task

Call `resurfaceDeferrals()` at the start of `performSync()`, before flushing the action queue.

```swift
// Add to SyncCoordinator:
private let taskStore: TaskStore?

// Update init:
public init(
	accountConfig: AccountConfig,
	imapClient: any IMAPClientProtocol,
	store: MailStore,
	actionQueue: ActionQueue? = nil,
	taskStore: TaskStore? = nil
) { ... }

// Add method:
private func resurfaceDeferrals() {
	let isoFormatter = ISO8601DateFormatter()
	let now = isoFormatter.string(from: Date())

	do {
		// Resurface emails
		let expiredDeferrals = try store.expiredDeferrals(beforeDate: now)
		for deferral in expiredDeferrals {
			try store.deleteDeferral(id: deferral.id)
		}

		// Resurface tasks
		let expiredTasks = try store.expiredDeferredTasks(beforeDate: now)
		for task in expiredTasks {
			try store.clearTaskDeferral(id: task.id)
			try taskStore?.updateDeferral(id: task.id, deferUntil: nil, isSomeday: false)
		}
	} catch {
		// Resurfacing is non-fatal — log and continue with sync
		print("[SyncCoordinator] resurfacing failed: \(error)")
	}
}
```

- [ ] **Step 2: Call resurfaceDeferrals in performSync**

```swift
private func performSync() async throws {
	// Resurface deferred items
	resurfaceDeferrals()

	// Flush pending actions
	if let queue = actionQueue {
		await queue.flush()
	}

	// ... existing IMAP sync code ...
}
```

- [ ] **Step 3: Verify build and existing tests pass**

```bash
swift build --package-path Packages/MagnumOpusCore
swift test --package-path Packages/MagnumOpusCore
```

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "add deferral resurfacing to SyncCoordinator: emails and tasks"
```

---

## Chunk 4: App UI — Perspectives & Task Views

### Task 8: Sidebar Perspectives

**Files:**
- Edit: `Apps/MagnumOpus/ViewModels/MailViewModel.swift`
- Edit: `Apps/MagnumOpus/Views/SidebarView.swift`

- [ ] **Step 1: Update Perspective enum in MailViewModel**

Replace the existing Perspective enum with:

```swift
enum Perspective: String, CaseIterable, Identifiable {
	case inbox
	case today
	case upcoming
	case projects
	case someday
	case archive

	var id: String { rawValue }

	var label: String {
		switch self {
		case .inbox: return "Inbox"
		case .today: return "Today"
		case .upcoming: return "Upcoming"
		case .projects: return "Projects"
		case .someday: return "Someday"
		case .archive: return "Archive"
		}
	}

	var systemImage: String {
		switch self {
		case .inbox: return "tray"
		case .today: return "star"
		case .upcoming: return "calendar"
		case .projects: return "folder"
		case .someday: return "archivebox"
		case .archive: return "checkmark.circle"
		}
	}
}
```

- [ ] **Step 2: Add unified item loading per perspective**

Add methods to MailViewModel that load `[ItemSummary]` for each perspective, combining emails and tasks from MailStore queries. Each perspective uses the appropriate queries defined in Task 3.

- [ ] **Step 3: Update SidebarView with new perspectives**

Update SidebarView to show all 6 perspectives with icons and unread/item counts. The Projects perspective shows project labels as sub-items.

- [ ] **Step 4: Verify app compiles**

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "add sidebar perspectives: inbox, today, upcoming, projects, someday, archive"
```

---

### Task 9: Task Creation Views

**Files:**
- Create: `Apps/MagnumOpus/ViewModels/TaskEditViewModel.swift`
- Create: `Apps/MagnumOpus/Views/TaskEditView.swift`

- [ ] **Step 1: Create TaskEditViewModel**

`@Observable @MainActor` class with:
- `title: String`, `body: String`, `dueDate: Date?`, `hasDueDate: Bool`
- `linkedMessageId: String?` (set when creating from email)
- `save() throws` → calls `TaskStore.writeTask()`
- Constructor takes `accountConfig`, `taskStore`, optional `linkedMessageId` and pre-filled `title`

- [ ] **Step 2: Create TaskEditView**

Compact form view:
- Title TextField (required)
- Body TextEditor (optional, expandable)
- Due date toggle + DatePicker
- Save button (⌘⏎), Cancel button
- Sheet presentation on iOS, popover on macOS

- [ ] **Step 3: Wire task creation to keyboard shortcuts**

In ContentView or appropriate parent:
- `⌘⇧N` → open TaskEditView (standalone)
- `t` (when thread selected) → open TaskEditView with linkedMessageId and pre-filled title

- [ ] **Step 4: Verify app compiles**

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "add task creation: TaskEditViewModel, TaskEditView, keyboard shortcuts"
```

---

### Task 10: Defer Picker & Label Picker

**Files:**
- Create: `Apps/MagnumOpus/Views/DeferPicker.swift`
- Create: `Apps/MagnumOpus/Views/LabelPicker.swift`

- [ ] **Step 1: Create DeferPicker**

Compact popover with:
- "Tomorrow" button → defer to tomorrow
- "Next Week" button → defer to next Monday
- "Pick Date" → inline DatePicker
- "Someday" button → defer to someday

```swift
struct DeferPicker: View {
	let onDefer: (Date?) -> Void  // nil = someday
	@Environment(\.dismiss) private var dismiss
	@State private var showDatePicker = false
	@State private var customDate = Date()

	var body: some View {
		VStack(spacing: 8) {
			Button("Tomorrow") {
				onDefer(Calendar.current.date(byAdding: .day, value: 1, to: Date()))
				dismiss()
			}
			Button("Next Week") {
				onDefer(nextMonday())
				dismiss()
			}
			if showDatePicker {
				DatePicker("", selection: $customDate, displayedComponents: .date)
					.datePickerStyle(.graphical)
				Button("Defer to Selected Date") {
					onDefer(customDate)
					dismiss()
				}
			} else {
				Button("Pick Date...") { showDatePicker = true }
			}
			Divider()
			Button("Someday") {
				onDefer(nil)
				dismiss()
			}
		}
		.padding()
		#if os(macOS)
		.frame(width: 250)
		#endif
	}

	private func nextMonday() -> Date {
		let cal = Calendar.current
		let today = Date()
		let weekday = cal.component(.weekday, from: today)
		// Monday = 2 in Calendar
		let daysUntilMonday = (9 - weekday) % 7
		return cal.date(byAdding: .day, value: daysUntilMonday == 0 ? 7 : daysUntilMonday, to: today)!
	}
}
```

- [ ] **Step 2: Create LabelPicker**

Compact popover with:
- Searchable list of existing project labels
- Type-to-create: "Create project: <name>" option when no match
- On select: callback with label

```swift
struct LabelPicker: View {
	let labels: [LabelInfo]
	let onSelect: (LabelInfo) -> Void
	let onCreate: (String) -> Void
	@Environment(\.dismiss) private var dismiss
	@State private var searchText = ""

	var filtered: [LabelInfo] {
		if searchText.isEmpty { return labels }
		return labels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
	}

	var showCreateOption: Bool {
		!searchText.isEmpty && !labels.contains(where: { $0.name.lowercased() == searchText.lowercased() })
	}

	var body: some View {
		VStack {
			TextField("Search or create...", text: $searchText)
				.textFieldStyle(.roundedBorder)
				.padding(.horizontal)

			List {
				if showCreateOption {
					Button("Create project: \(searchText)") {
						onCreate(searchText)
						dismiss()
					}
				}
				ForEach(filtered) { label in
					Button(label.name) {
						onSelect(label)
						dismiss()
					}
				}
			}
		}
		#if os(macOS)
		.frame(width: 250, height: 300)
		#endif
	}
}
```

- [ ] **Step 3: Verify app compiles**

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "add DeferPicker, LabelPicker views for GTD triage"
```

---

### Task 11: GTD Triage Actions in MailViewModel

**Files:**
- Edit: `Apps/MagnumOpus/ViewModels/MailViewModel.swift`
- Edit: `Apps/MagnumOpus/Views/ThreadListView.swift`

- [ ] **Step 1: Add GTD triage methods to MailViewModel**

```swift
// MARK: - GTD Triage

func deferSelectedItem(until date: Date?) async {
	// If date is nil → someday
	guard let item = selectedItem else { return }
	switch item {
	case .email(let msg):
		let deferral = DeferralRecord(
			id: UUID().uuidString,
			messageId: msg.id,
			deferUntil: date.map { ISO8601DateFormatter().string(from: $0) },
			originalMailbox: selectedMailbox?.name,
			createdAt: ISO8601DateFormatter().string(from: Date())
		)
		try? store?.insertDeferral(deferral)
	case .task(let task):
		try? taskStore?.updateDeferral(id: task.id, deferUntil: date, isSomeday: date == nil)
	}
	autoAdvance()
}

func fileSelectedItem(to label: LabelInfo) async {
	guard let item = selectedItem else { return }
	let (itemType, itemId) = itemTypeAndId(item)
	try? store?.attachLabel(labelId: label.id, itemType: itemType, itemId: itemId)
	// For tasks, also update VTODO CATEGORIES
	if case .task(let task) = item {
		let allLabels = (try? store?.labelsForItem(itemType: "task", itemId: task.id)) ?? []
		let catNames = allLabels.map(\.name)
		// Rewrite .ics with updated categories via TaskStore
		// (TaskStore.rewriteFile would need a categories parameter, or
		//  read labels from DB during rewrite. Simplest: re-call writeTask
		//  with current values + updated categories.)
	}
	autoAdvance()
}

func discardSelectedItem() async {
	guard let item = selectedItem else { return }
	switch item {
	case .email:
		await archiveSelectedThread()  // existing v0.3 action
	case .task(let task):
		try? taskStore?.updateStatus(id: task.id, status: .cancelled)
	}
	autoAdvance()
}

func completeSelectedItem() async {
	guard let item = selectedItem else { return }
	switch item {
	case .email:
		await archiveSelectedThread()
	case .task(let task):
		try? taskStore?.updateStatus(id: task.id, status: .completed)
	}
	autoAdvance()
}
```

- [ ] **Step 2: Add GTD keyboard shortcuts to ThreadListView**

Add toolbar buttons and keyboard shortcuts for the new GTD actions:
- `d` → show DeferPicker
- `⇧D` → defer to someday immediately
- `p` → show LabelPicker
- `⌘⏎` → complete item

Update `⌫` behavior: in GTD perspectives, call `discardSelectedItem()` instead of `deleteSelectedThread()`.

- [ ] **Step 3: Verify app compiles**

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "add GTD triage actions: defer, file to project, discard, complete"
```

---

### Task 12: Thread Detail — Linked Tasks

**Files:**
- Edit: `Apps/MagnumOpus/Views/ThreadDetailView.swift`

- [ ] **Step 1: Query linked tasks for the current thread**

When displaying a thread, also query `store.tasksLinkedToThread(threadId:)` and interleave task items in the timeline:

```swift
// In thread detail, after loading messages:
let linkedTasks = try? store.tasksLinkedToThread(threadId: thread.id)
```

- [ ] **Step 2: Render tasks inline with distinct style**

Show tasks with:
- Checkbox icon (filled if completed, empty if active)
- Task summary as title
- Status badge (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
- Tapping a task could toggle its status

- [ ] **Step 3: Verify app compiles**

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "show linked tasks inline in thread detail view"
```

---

## Chunk 5: Integration & Polish

### Task 13: End-to-End Wiring

**Files:**
- Edit: `Apps/MagnumOpus/ViewModels/MailViewModel.swift`
- Edit: `Apps/MagnumOpus/ContentView.swift`

- [ ] **Step 1: Initialize TaskStore in MailViewModel.setup()**

```swift
// In setup(config:credentials:):
let taskDir = Self.taskDirectory(accountId: config.id)
// Note: MailStore needs a public `databaseWriter` accessor added. Currently `dbWriter`
// is internal. Add `public var databaseWriter: any DatabaseWriter { dbWriter }` to MailStore.
let taskStore = TaskStore(taskDirectory: taskDir, dbWriter: store.databaseWriter)
self.taskStore = taskStore
```

- [ ] **Step 2: Pass TaskStore to SyncCoordinator**

```swift
let coordinator = SyncCoordinator(
	accountConfig: config,
	imapClient: imapClient,
	store: store,
	actionQueue: queue,
	taskStore: taskStore
)
```

- [ ] **Step 3: Wire TaskEditView presentation**

Connect `⌘⇧N` and `t` shortcuts to TaskEditView presentation in ContentView.

- [ ] **Step 4: Verify full flow works**

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "wire end-to-end: TaskStore in MailViewModel, SyncCoordinator, task creation"
```

---

### Task 14: Final Tests & Cleanup

- [ ] **Step 1: Run all package tests**

```bash
swift test --package-path Packages/MagnumOpusCore
```

Fix any failures.

- [ ] **Step 2: Verify app builds for macOS and iOS**

- [ ] **Step 3: Remove any leftover placeholder files**

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "fix test failures, verify builds for macOS and iOS"
```

---

### Task 15: Version Bump

- [ ] **Step 1: Bump CalVer to 2026.03.14+1**

Update version in relevant config files.

- [ ] **Step 2: Commit**

```bash
git add -A
git commit -m "bump calver to 2026.03.14+1, v0.4: GTD tasks, unified triage, deferrals, labels"
```

---

## Summary

| Chunk | Tasks | Focus |
|-------|-------|-------|
| 1: Models & Schema | 1–3 | Model types, records, migrations, MailStore queries |
| 2: TaskStore Module | 4–6 | VTODO parser, formatter, file I/O, cache rebuild |
| 3: Deferral & Sync | 7 | Resurfacing in SyncCoordinator |
| 4: App UI | 8–12 | Perspectives, task creation, defer/label pickers, GTD triage, thread detail |
| 5: Integration | 13–15 | Wiring, tests, version bump |

**Parallelizable:** Chunks 1 and 2 (schema + TaskStore) are partially independent — the VTODO parser/formatter (Tasks 4-5) have no schema dependency and can run alongside Task 2. Chunk 3 depends on both. Chunk 4 depends on everything. Chunk 5 depends on everything.
