Wanderland

Wanderland Task API

REST service backing the detective case system. Node.js + Express, SQLite persistence, mounted at /api/tasks (plus siblings /api/recurring-templates and /api/sync). It's the authoritative store for cases; every other surface — Lantern's /cases page, the MCP proxy's detective_* tools, the Emacs/Neovim integrations — is a view into this.

Two usage frames to keep distinct: stack operations (start / complete-current / back / interrupt — push and pop the "what am I working on" call stack) vs status operations (activate / park / close / restart — flip lifecycle state). Some endpoints touch both; the headings below group by the dominant effect.

Endpoints

Cases (CRUD)

The base collection: create, list, fetch-one, mutate, delete. The list endpoint is the common query surface — it accepts filter params that mirror SimpleTask fields. The PATCH / PUT split is intentional: PATCH merges partial updates (most callers want this); PUT replaces whole-task JSON (sync path uses it). For field-by-field updates where a schema-checked allowlist is desirable, use the separate PATCH /:id/field route in the Metadata section below.

POST   /api/tasks                  Create case (status defaults to 'parked')
GET    /api/tasks                  List — filters: status, priority, case_type, detective,
                                   urgency_level, scheduled_date, due_date, is_case
GET    /api/tasks/search           Free-text search across label + notes
GET    /api/tasks/:id              Get full case with notes
PATCH  /api/tasks/:id              Partial update
PUT    /api/tasks/:id              Replace whole task (sync/import path)
DELETE /api/tasks/:id              Delete case
DELETE /api/tasks/bulk             Bulk delete (body: { ids: [...] })

Case Lifecycle

Distinguish the two axes. Stack = the implicit current-task pointer (start/complete-current/back/interrupt). Status = the lifecycle enum on the row (activate/park/close/restart/archive). A fresh case is parked; it becomes active when you activate (status-only) or start (pushes on stack and flips status). There is intentionally no /:id/complete — completion is either stack-aware (current/complete pops the stack and marks completed) or explicit (:id/close closes a specific case with a resolution note).

POST /api/tasks/:id/start          Push to stack + activate
POST /api/tasks/:id/park           Remove from stack (also flips status → parked)
POST /api/tasks/:id/activate       Status flip parked → active (no stack change)
POST /api/tasks/:id/close          Close with resolution note (status → completed)
POST /api/tasks/:id/restart        Reopen a completed case (→ active)
POST /api/tasks/:id/archive        Soft-delete (status → archived)
POST /api/tasks/current/complete   Complete the current task on the stack
POST /api/tasks/current/back       Pop the stack without completing
POST /api/tasks/interrupt          Create a new case and push it immediately
GET  /api/tasks/current            Read the current (top-of-stack) case
GET  /api/tasks/stack              Read the whole stack
GET  /api/tasks/active             List active cases (status=active filter)
POST /api/tasks/:id/pin            Pin for quick-access surfaces
POST /api/tasks/:id/unpin          Unpin
GET  /api/tasks/pinned             List pinned cases

Investigation Notes

Notes are the append-only log on each case — every significant event (created, activated, linked, escalated, closed) lands here alongside hand-authored entries. The index variant returns previews only, which is what UIs want for skimming; the paginated notes?start=X&end=Y fetches the full markdown. Notes are addressed by zero-based index within a case — stable across reads but not across deletes.

POST   /api/tasks/:id/investigation-note   Add note { note: "markdown", detective: "name" }
POST   /api/tasks/:id/notes                 Alias — same shape
GET    /api/tasks/:id/notes                 Paginated (?start=0&end=9)
GET    /api/tasks/:id/notes/index           Lightweight index with previews
GET    /api/tasks/:id/notes/:idx            Single note by index
GET    /api/tasks/:id/notes/search          Fuzzy search (?q=query&max_results=10)
PATCH  /api/tasks/:id/notes/:idx            Edit note
DELETE /api/tasks/:id/notes/:idx            Delete note (renumbers subsequent indices)

Metadata & Links

Two flavors here. The simple ones (tag, assign, escalate, git-commit) mutate a single field or append a single value. The link endpoints integrate with the broader consciousness graph — memory, case, and resource links land in the consciousness_links bag documented below. The :id/field route is the audited field-updater: only a specific allowlist is writable this way, which makes it safe for remote / automation callers. Fields outside the list require PATCH /:id.

Allowlist for PATCH /:id/field: label, case_type, repository_context, urgency_level, detective, scheduled_date, due_date. (status and priority are NOT allowlisted — use the lifecycle endpoints or a full PATCH.)

Views & Stats

Pre-canned queries used by dashboards and MCP tooling. Everything here is derivable from the base list query — these exist because callers want a single URL that doesn't need filter construction, and because a few (statistics, export-stats) aggregate across all cases.

GET  /api/tasks/cases/search                  Search cases with structured filters
GET  /api/tasks/cases/statistics              Counts by status / type / detective / urgency
GET  /api/tasks/cases/export-stats            Export history stats
GET  /api/tasks/cases/urgent                  Urgent cases (urgency_level ≥ urgent)
GET  /api/tasks/cases/emergency               Emergency cases
GET  /api/tasks/cases/detective/:detective    Cases for one detective
GET  /api/tasks/cases/type/:type              Cases of a given case_type
GET  /api/tasks/activity/today                Cases touched today
GET  /api/tasks/system/status                 Service + storage status
POST /api/tasks/system/archive-completed      Bulk-archive all completed cases
POST /api/tasks/active-case/refresh           Re-sync the current-case channel
POST /api/tasks/:id/reload-case-channel       Re-publish this case to the stream

Sync (Peer-to-Peer)

Mounted at /api/sync, not /api/tasks/sync. Enables offline-first clients (Doom Emacs, Neovim) to reconcile local state with the server without pushing through the side-effecting CRUD endpoints. The flow is manifest-first: client hashes its local tasks, server returns a diff of what's newer on each side, then pull/push reconciles the gaps.

POST /api/sync/manifest    Compare client/server manifests → { pull: [...], push: [...] }
POST /api/sync/pull        Client pulls specific tasks/notes
POST /api/sync/push        Client pushes tasks/notes (bypasses side effects)

Recurring Templates

Mounted at /api/recurring-templates. A template defines a shape (label, case_type, default notes, schedule) and emits fresh cases on cadence. This is how "standup", "weekly review", and similar boilerplate cases get generated without hand-authoring each instance. Trigger fires the scheduler for one template and returns the created case.

GET    /api/recurring-templates              List templates
GET    /api/recurring-templates/enabled      Only enabled templates
GET    /api/recurring-templates/upcoming     Next-due templates
GET    /api/recurring-templates/search       Search
POST   /api/recurring-templates              Create template
GET    /api/recurring-templates/:id          Read one
PUT    /api/recurring-templates/:id          Replace
DELETE /api/recurring-templates/:id          Delete
GET    /api/recurring-templates/:id/instances  Cases generated from this template
POST   /api/recurring-templates/:id/enable
POST   /api/recurring-templates/:id/disable
POST   /api/recurring-templates/:id/trigger  Generate a case now (bypass schedule)

Export

Cases can be exported as standalone JSON files into an ingest directory, either on demand or as an auto-effect of mutations. export-version bumps the version counter in the exported artifact without mutating the row, used when a case's semantic content changes without a field changing.

POST /api/tasks/:id/export          Export case to ingest directory
POST /api/tasks/:id/export-version  Export with version bump
POST /api/tasks/cases/export-all    Export all cases
GET  /api/tasks/cases/export-stats  Export statistics

Data Model

Case (Full JSON)

What GET /api/tasks/:id returns. Most fields are optional or defaulted; required at create time is only label. Dates (scheduled_date, due_date) are date-only strings in local convention; the render layer (/cases compact rows) does the past/today/future color routing. recurring_* fields are populated only when a case was generated from a template.

id:                    "task-<uuid>"
label:                 "The Case Title"
priority:              1..5 (integer, default 3)
status:                parked | active | completed | archived  (default: parked)
case_type:             see enum below (nullable)
detective:             "assigned person" (nullable)
urgency_level:         routine | priority | urgent | critical | emergency  (default: routine)
repository_context:    string (nullable)
classification:        "work" | "idea"  (default: work)
pinned:                boolean (default: false)
created_at:            ISO8601
updated_at:            ISO8601
version:               integer (auto-incremented)
notes:                 "general markdown notes" (nullable)
evidence_tags:         ["tag1", "tag2"]
auto_detected_keywords: ["keyword1"]
case_links:            ["task-id-1", "task-id-2"]
scheduled_date:        "YYYY-MM-DD" (nullable) — when to work on it
due_date:              "YYYY-MM-DD" (nullable) — when it must be done
recurring_template_id: "rt-<uuid>" (nullable)
recurrence_date:       "YYYY-MM-DD" (nullable)
previous_instance_id:  "task-<uuid>" (nullable)
investigation_notes:   [Note objects]
consciousness_links:   {Links object}

Investigation Note

Each entry carries its own event type so the note stream doubles as an audit trail. Hand-authored notes are note_added; the rest are system events. Edited notes keep their original author + timestamp, with edited_* filled in on the mutation.

note_id:     "note-<uuid8>"
timestamp:   ISO8601
event_type:  note_added | case_created | interrupted | resumed | memory_linked |
             case_linked | field_updated | parked | closed | version_updated |
             urgency_escalated
content:     "Markdown content"
detective:   "who wrote it"
edited_at:   ISO8601 (if edited)
edited_by:   "who edited" (if edited)

Consciousness Links

Flat bag of typed arrays; arbitrary references to entities in the broader system. All fields are optional; unknown keys are silently ignored by validateConsciousnessLinks. Callers should merge on top of the existing bag (via POST /:id/consciousness-links) rather than replace — the endpoint is additive.

memories:        []
insights:        []
case_files:      []
related_cases:   []
scratch_pads:    []
web_links:       []
jira_tickets:    []
git_commits:     []
stuffy_channel:  []
jenkins_builds:  []
pull_requests:   []
aws_resources:   []
oculus_nodes:    []
context:         null | "string"

Enums

Case Types

Canonical list (enforced by validateCaseType — invalid values throw on create/update). No keyword auto-detection anymore; pass an explicit value.

Work: bug, story, feature, task, investigation Detective: emergency-response, surveillance, analysis, report, follow-up, routine-patrol, evidence-review, witness-interview, case-closure Planning: idea, guide, epic

Urgency Levels

Validated list. routine is the default.

routinepriorityurgentcriticalemergency

Status Transitions

The stack-vs-status distinction shows up here: some endpoints only move the stack (start, current/back, current/complete), others only change status, and a few do both. The archival path is terminal for lifecycle purposes but the row remains queryable.

parked    ──→ active     (activate or start)
active    ──→ parked     (park — also removes from stack)
active    ──→ completed  (current/complete or close)
completed ──→ active     (restart)
*         ──→ archived   (archive)

Authentication

No auth on the internal Docker network (task-api:3000) — containers talk to it directly. Public reach is via Caddy at https://i.loss.dev/api/tasks/* under HTTP basic auth (wonderfullyweird). The MCP proxy is the third entry point and routes through the same gateway when used remotely, bare internal HTTP when used locally.

Storage

Single SQLite file behind SimpleStorage.js. The table schema is flat — most JSON-shaped fields (investigation_notes, consciousness_links, evidence_tags, auto_detected_keywords, case_links) are stored as stringified JSON text columns and rehydrated on read. Schema migrations are additive (see the ALTER TABLE ... ADD COLUMN block in SimpleStorage.js); older rows null-coerce their way up.

Source

Repo: /home/oculus/wanderland/wanderland-task-api/src/ (this repo). Runtime path in the container: /app.