Copy the design PRD, .claude config, .m config, .mcp.json, and AGENTS.md symlink from m's main working tree so the worker has the full project context before starting Phase 1 implementation.
12 KiB
projax — PRD
Status: v1 draft, 2026-05-15 Authors: m, head (dialogue) Scope: Phase-1 build sufficient to live with the system; phases 2–3 listed but deferred.
1. Purpose
projax is m's personal data backbone for self-management — areas of life, projects within them, and aggregated views over tasks that live elsewhere. It subsumes (over time) the scattered state currently held in mai.projects, CalDAV task lists, Gitea issues, and mBrian topic hubs. No interface is canonical; each is a view.
Meta-requirement: flexibility. m's self-model evolves. Identity is by UUID; everything human-readable is renameable. The data model leans on jsonb + array-typed kinds so future re-categorization doesn't require a migration.
2. Model
2.1 Two kinds, freely nestable
area ─┐
├─ project ─┐
│ ├─ project ─┐ (sub-projects allowed, any depth)
│ │ └─ …
└─ task (external ref)
- Area — a container without start/end. Long-running domain of life. Areas live at the root (
parent_id = NULL). Examples:dev,sports,home,work,health,finances,social. - Project — a bounded effort with (usually) a start and an end. Lives under an area, or under another project. Sub-projects nest to arbitrary depth (
home.spring-clean.bathroom.tilesis fine if that's how m thinks). Examples:home.spring-clean,dev.prjx,sports.giro-okt. - Task — atomic work item. Lives outside projax (CalDAV todos, Gitea issues,
mai.tasks). projax references and aggregates them; it does not own them.
Areas and projects share one table (projax.items) distinguished by the kind array column. The tree (parent_id) is unconstrained on depth; the only structural rules are:
- An area's
parent_idmust beNULL(areas are roots). - A project's
parent_idmust point to an area or another project (no project at root). - No cycles (enforced by the path trigger).
Tasks are referenced via projax.item_links.
2.2 Identity & naming
id uuid— canonical, immutable.slug text— local-only segment (no dots). Renameable freely. Examples:prjx,spring-clean,upc.deadlineswould be split as parent slugupc+ child slugdeadlines.path text— full dot-joined path computed from parent walk. Cached column maintained by trigger; not the source of truth.- Slug convention: lowercase, vowel-elided where natural (
prjx,mai,mbrnif m wishes), kebab-allowed for multi-word leaves (spring-clean). - Aliases:
aliases text[]keeps old slugs searchable after rename.
2.3 Lifecycle (thin)
For projects only — areas don't have lifecycle.
active → done → archived
That's it. Free-text in content_md covers the nuance ("waiting on Brian," "paused until June"). No rich state machine; m flagged richer schemes as rot-prone.
2.4 Relationships
- Tree (parent/child within projax):
items.parent_id uuidself-FK. Areas haveparent_id = NULL. Projects point at their area or another project. Arbitrary nesting depth. - External refs (
projax.item_links): each row links anitem_idto a typed external resource — caldav-todo, gitea-issue, mai-task, mai-project, mbrian-node, etc. Used both for aggregating tasks and for soft cross-references.
3. Schema (Postgres, msupabase, schema projax)
create schema if not exists projax;
create table projax.items (
id uuid primary key default gen_random_uuid(),
kind text[] not null default '{}', -- ['area'] or ['project'] (multi-tag allowed for future)
title text not null,
slug text not null, -- local segment, no dots
path text not null, -- computed, e.g. 'home.spring-clean'
parent_id uuid references projax.items(id) on delete restrict,
content_md text default '',
aliases text[] default '{}',
metadata jsonb not null default '{}'::jsonb,
status text not null default 'active', -- active | done | archived (projects only)
pinned boolean not null default false,
archived boolean not null default false,
start_time timestamptz,
end_time timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz,
unique (parent_id, slug)
);
create index items_path_idx on projax.items (path);
create index items_kind_idx on projax.items using gin (kind);
create index items_parent_idx on projax.items (parent_id);
create table projax.item_links (
id uuid primary key default gen_random_uuid(),
item_id uuid not null references projax.items(id) on delete cascade,
ref_type text not null, -- 'caldav-todo' | 'gitea-issue' | 'mai-task' | 'mai-project' | 'mbrian-node' | 'url' | ...
ref_id text not null, -- opaque external identifier
rel text not null default 'contains', -- 'contains' | 'related' | 'blocked-by' | 'derived-from'
note text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
unique (item_id, ref_type, ref_id, rel)
);
create index item_links_item_idx on projax.item_links (item_id);
create index item_links_ref_idx on projax.item_links (ref_type, ref_id);
3.1 The path trigger
path is maintained by trigger on insert/update: walks parent_id to the root, joins slugs with .. Recomputed for the subtree when a parent is renamed or re-parented. Keeps queries cheap.
3.2 The mai.projects adapter view
mai.projects stays untouched. projax surfaces it in the unified item stream via a read-only view:
create or replace view projax.items_unified as
select
id,
kind,
title,
slug,
path,
parent_id,
content_md,
status,
pinned,
archived,
start_time,
end_time,
'projax'::text as source,
created_at,
updated_at
from projax.items
where deleted_at is null
union all
select
('00000000-0000-0000-0000-' || substr(md5(p.id), 1, 12))::uuid as id, -- deterministic placeholder
array['project']::text[] as kind,
p.name as title,
p.id as slug,
'mai.' || p.id as path,
null::uuid as parent_id,
coalesce(p.goal, '') as content_md,
case p.status
when 'active' then 'active'
when 'sleeping' then 'archived'
when 'archived' then 'archived'
else 'active'
end as status,
false as pinned,
(p.status = 'archived') as archived,
null::timestamptz as start_time,
null::timestamptz as end_time,
'mai.projects'::text as source,
p.created_at,
p.updated_at
from mai.projects p;
UI reads items_unified. Writes target projax.items only. Once m wants to fully migrate, the mai.projects half is dropped from the view and rows are copied across with real UUIDs + proper parent assignment.
3.3 Classification overlay
For each mai.projects row, m can later promote it into projax-native (assigning area parent, real slug, kind tweak). Until promoted it appears as a top-level orphan project tagged source=mai.projects. An admin page surfaces the unmapped set and lets m one-click classify.
4. Interfaces
4.1 Phase 1 — MVP (this build)
Web frontend at https://projax.msbls.de, single binary, served by the same Go process that talks to msupabase.
Pages:
- Tree view (
/) — collapsible tree of areas → projects, readsitems_unified. Status badges, search bar (slug, title, alias, content_md). - Item detail (
/i/{path}) — full editor for projax-native items (title, slug, parent, kind, status, start/end, content_md). Read-only view for mai.projects-sourced rows with a "Promote to projax" button. - New item (
/new?parent={path}) — small form, prefilled with parent. - Classify orphans (
/admin/classify) — list of unmapped mai.projects rows, inline assign-to-area control.
Auth: shared msupabase login (matches flexsiebels precedent), single-user m.
4.2 Phase 2 — task aggregation
- CalDAV ingest — read-only mirror of m's CalDAV todo lists into
item_linkswithref_type=caldav-todo. Per-area mapping (e.g.homeaggregates from CalDAV list "Home"). Background sync, no writeback initially. - Gitea ingest — read-only mirror of issues on linked repos.
mai.projects.repofield is a hint; per-item override possible.
4.3 Phase 3 — visualization & integrations
- Excalidraw view — visual roadmap, dependencies, area-overview boards. Generated from items_unified.
- MCP —
mcp__projax__*so otto and other workers can read/write projax. Pattern follows mcp__mai__. - Otto-PWA integration — read-mostly surface for m's day-to-day. Defer until projax has lived long enough to know what otto actually needs.
5. Tech stack
- Backend: Go single binary.
pgxfor Postgres. HTMX-driven HTML rendered server-side (Gohtml/template). No frontend build step. Static assets bundled withembed. Matches m's dotfile-stated preferences. - Database: msupabase, schema
projax(new). Viewprojax.items_unifiedreads acrossprojax.*+mai.projects. RLS off for v1 (single-user). - Hosting: Dokploy on mlake, domain
projax.msbls.de. Tailscale-only network (no public exposure). - Repo:
m/projax(already exists). Branch strategy per project CLAUDE.md (main + short-lived feat/fix branches, no dev branch initially).
Alternative considered: SvelteKit + Bun (matches flexsiebels). Rejected for v1 — CRUD admin scale doesn't justify the build chain.
6. Migration plan
Three phases, smallest viable each:
1a — Schema + seed: create projax.items, projax.item_links, path trigger. Seed the seven day-one areas (dev, sports, home, work, health, finances, social) as kind=['area'], parent_id=null.
1b — Adapter view: deploy items_unified. All 28 mai.projects rows now visible in the tree as top-level orphans.
1c — Classification UI: the /admin/classify page so m can drag mai.projects rows under areas. Drag = create a projax-native item with kind=['project'] + parent_id set + item_links row pointing at the mai.projects row. mai.projects untouched; the projax row owns area assignment + projax metadata.
After 1c, m can use the system. Test rows in mai.projects either stay as orphans (ignored) or get a source-filter to hide them.
7. Out of scope
- Multi-user (single-tenant, m only)
- Mobile-first responsive (desktop browser is enough)
- Public exposure (Tailscale only)
- Generic SaaS instincts (admin panels, billing, audit logs)
- CLI surface (m has explicitly opted out)
- Bidirectional CalDAV/Gitea sync in v1 (read-only first)
- Real-time collaboration features
8. Open questions (post-PRD)
- Path-trigger correctness under cycle attempts: enforce acyclicity via check in trigger.
mai.projectstest row hiding: drop them from the view via name pattern, or surface them with a "test" tag?- Classification promotion semantics: when a mai.projects row is promoted, does the projax item replace it in the unified view, or do both still appear? Default: projax wins, view filters out adapted mai rows.
- Auth: re-use flexsiebels Supabase auth, or simpler shared-secret cookie? msupabase auth is heavier than v1 needs.
- mBrian topic-hub linkage: do we auto-suggest mbrian topic links when an item is created with a matching slug? Defer to phase 3.
9. Phase-1 deliverable checklist
projax.items+projax.item_linksmigrations indb/migrations/- Path trigger + tests
projax.items_unifiedview- Go binary: HTTP server, pgx pool, html/template + HTMX, embed static
- Pages: tree, detail, new, classify
- Auth: msupabase session cookie OR shared-secret (decide in 1a)
- Dockerfile + Dokploy config for
projax.msbls.de - Seed migration for the seven day-one areas
- README + run instructions
10. References
- Project CLAUDE.md (this repo) — purpose, constraints, gated worker flow
~/.claude/CLAUDE.md— global conventions (memory, channel routing, git strategy)mai.projectsschema (msupabase) — current state being adapted- mBrian
nodes/edgesschema — terminology source - otto session 2026-05-15 — inventory motivating this project