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.
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: [...] })
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
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)
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.)
POST /api/tasks/:id/evidence-tag Add tag { tag: "string" }
POST /api/tasks/:id/assign-detective Assign { detective: "name" }
POST /api/tasks/:id/escalate Escalate { reason: "why" }
POST /api/tasks/:id/consciousness-links Merge links (see Data Model)
POST /api/tasks/:id/link-memory Link a memory path
POST /api/tasks/:id/link-case Link to another case { related_task_id, relationship_type, reason }
POST /api/tasks/:id/git-commit Attach a commit { commit_hash, commit_message, repository }
POST /api/tasks/:id/repository-context Set repo context
PATCH /api/tasks/:id/field Allowlisted field update (see above)
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
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)
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)
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
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}
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)
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"
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
Validated list. routine is the default.
routine → priority → urgent → critical → emergency
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)
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.
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.
Repo: /home/oculus/wanderland/wanderland-task-api/src/ (this repo). Runtime path in the container: /app.
task-api-server.js — entry point, route mounts (/api/tasks, /api/recurring-templates, /api/sync)models/SimpleTask.js — SimpleTask class; enum validators live heremodels/RecurringTemplate.js — template modelroutes/simple-tasks.js — all case endpoints (~2400 lines, the bulk of the surface area)routes/recurring-templates.js — template endpointsroutes/sync.js — sync endpointsservices/SimpleTaskService.js — business logic (stack + side-effects coordination)services/TemplateScheduler.js — recurring-template instance generationservices/CaseFileExporter.js — JSON export to ingest directorystorage/SimpleStorage.js — SQLite persistence + migrations