Per head's parallel-prep brief while m/mBrian#73 (migration script +
[schema] node) is being built mBrian-side. NO mBrian-MCP-backed
implementation yet — the migration worker may refine the landed
node/edge shape and building the impl now risks rework.
Built ONLY the parts stable regardless of mBrian internals:
1. CONSUMER INVENTORY (docs/plans/slice-b-adapter-contract.md §1)
- Every *store.Store read method (15 methods) with signature + semantics
- Every call site across web/, internal/aggregate/, mcp/ — table form
- Item / ItemLink field-by-field shape contract: which fields come
direct from node columns, which from edge-walk, which from
metadata-unpack
- Direct pgxpool access flagged out-of-scope (admin counts, bulk
tx, links event-date update — slice C reworks those)
- Views (5j) explicitly NOT in scope per m's Q5=(a)
2. INTERFACE CONTRACT (store/adapter.go)
- ItemReader Go interface — 15 methods, pure projax-shaped structs
in/out, zero mBrian type leakage
- var _ ItemReader = (*Store)(nil) compile-time assertion proving the
existing pgx-backed *Store satisfies the contract today
3. SKELETON (store/adapter.go MBrianReader)
- Empty struct (mBrian client choice deferred to slice B impl)
- All 15 methods stubbed, return errNotImplementedSliceB
- var _ ItemReader = (*MBrianReader)(nil) keeps the stubs in lockstep
with the interface as slice B grows
- Each stub carries a one-line comment naming the §3 gap(s) it
resolves at impl time
- `go build ./...` green; `go vet ./store/` green
4. GAP FLAGS (docs/plans/slice-b-adapter-contract.md §3)
- item_links.rel free-form annotation → mBrian edge.note (add to
m/mBrian#73 §1 for the migration script)
- ItemLink.RefID per-rel-type extraction rule (caldav URL vs gitea
owner/repo vs mai project uuid)
- paths[] recomputation cost (per-request memoisation)
- AllTags aggregation (full-scan ok at m's scale; tag-graph deferred
per m's Q8)
- Roots / MaiOrphans "no outbound child_of edge" predicate
- ItemsCreatedInRange scoped to projax_origin marker
- Item.Source / SourceRefID constant + mai-edge-derived fields
- ItemLinkWithItem join shape (two queries + in-memory join vs bulk
MCP helper)
- Admin counts — recommend adding Counts(ctx) to ItemReader for cohesion
Stays parked after this. Slice B IMPL (mBrian-MCP client wiring + per-
method bodies + handler rename from s.Store.X to s.Items.X) waits on
the migration completing and uuid map landing.
m answered all 11 §10 questions; every inventor pick confirmed.
m's overriding directive: "keep the database simple so it remains
easily modifiable."
Head verified the live mBrian schema after m's answers — original §3
was built off stale db/001_initial_schema.sql. Three of the six asks
turned out already-satisfied:
- MB-A (edges.metadata jsonb) — already added in db/010, GIN-indexed,
used by migs 039/040. Drop the ask.
- MB-C (project type) — already in live schema, mig 033 confirms.
Drop the ask.
- MB-D (per-user slug uniqueness) — already enforced by idx_nodes_slug
in db/001. Drop the ask.
Plus 'area' as a separate mBrian type is killed per m's "keep it
simple": areas reuse type=['project'] with metadata.projax.kind='area'.
Zero DDL.
Remaining mBrian-side artifact compresses to ONE [schema] convention
node under a new [topic] projax-integration hub, plus mBrian-side
ownership of the one-shot data-migration script (per m's "mbrian must
own the migration").
Re-sequenced §8: six slices.
0 (projax snapshot helper) → A (mBrian [schema] node + script run)
→ B (projax read-path adapter) → C (projax write-path)
→ D (mai bridge worker) → E (drop projax tables).
CalDAV/Gitea integrations stay where they are (m's Q3=(a)). No slice
F needed in the original sense.
§2 + §2.1 + §7 + §9 + §10 + §14 updated. §3 fully rewritten.
No code changes; this branch ships docs only. Slice 0 is the smallest
first projax-side step but waits for head's greenlight after the
m/mBrian issue is filed.
m's decision on issue m/projax#5 (2026-05-29): Option A — full backend
migration to mBrian. mBrian becomes the canonical store for projax
data; projax UI surfaces stay (Tiles dashboard, calendar grid,
timeline spine, the just-shipped 5j /views routes) but read+write
goes through mBrian instead of projax.items.
The plan covers:
- §1 diagnosis: closing the parallel-knowledge-surface gap
- §2 column-by-column schema mapping (projax.items → mBrian nodes +
metadata, projax.item_links → mBrian edges + new edges.metadata)
- §3 mBrian-side requirements: schema fragments to add (edges.metadata
column, projax edge relations + types schema-nodes)
- §4 read-path replacement: store adapter over mBrian, UI shape stable
- §5 write-path replacement: every handler + MCP write rewired
- §6 integrations disposition: CalDAV/Gitea stay projax-handled at
consumption; mai.projects sync moves to a handler-layer bridge
- §7 migration mechanics: hard-cut script per m's loss tolerance
- §8 six-slice plan: A (mBrian schema) → B (data migration) →
C (read-path) → D (write-path) → E (drop projax tables) → F
(integrations)
- §9 cross-repo coordination protocol via otto/head (no mBrian/head
worker exists today)
- §10 eleven open questions for m, batched for head delegation
- §11 risk register
- §12 test plan headlines
Slice A is mBrian-side and is the hard gate — projax B–F cannot start
until mBrian's schema fragments land. Cross-repo coordination request
filed alongside the m delegation.
No code changes; this branch ships docs only. Coder shifts wait on
m's sign-off on §10 + mBrian-side slice A.
m's feedback on 5i (verbatim): "It's not really what I wanted. It
should like the paliad custom views, not of the existing views a
variant but individually created views."
5i modelled views as overlays on existing pages (?view=<uuid>). m wants
the paliad model: views are first-class URLs (/views/{slug}), each one
its own page. System defaults (dashboard, calendar, timeline, ...)
share the route shape with reserved slugs; user-created views land
beside them.
Plan covers: schema redesign (slug as URL key, drop is_default_for +
pinned, add icon + sort_order + show_count + last_used_at), four-route
table (landing with MRU redirect, render, editor blank/edit), system-
view shape (hybrid alias recommendation under Q1), editor surface
(dedicated pages, not modal), migration path from 5i (drop table +
delete overlay code; keep view_type enum and per-view_type renderers),
seven-slice implementation chain (A schema → B routes → C system views
→ D editor → E sidebar → F cleanup → G polish).
11 open questions batched in §9 — head delegation pending. NO chip-
picker without head's explicit re-grant (5i permission was one-time).
No code changes; this branch ships docs only. Coder shifts wait on m's
sign-off via head's relay.
m answered every open question directly via AskUserQuestion (greenlit
for inventor 2026-05-26 13:12). New §8.5 captures the picks + slice
implications. Inventor picks held on 6 of 9; m differed on Q5 (project
filter descendants) — wants an include-descendants toggle on the chip
rather than always-on, so Slice A grows an `IncludeDescendants` field
on TreeFilter + a toggle on the picker chip.
view_type enum locks at 5 (card/list/calendar/kanban/timeline). All
four out-of-scope items stay parked. No other slice changes.
Final slice of Phase 5h — documents the Tiles + view switcher
contract in design.md as the source of truth so future workers
understand what Phase 5h shipped without archaeology.
New section §19 covers:
- URL contract (view, scope, refresh, filter param interaction)
- dashboardProject rollup fields + LastActivity max-across-sources rule
- IsCurrent predicate (the 14d window)
- Tiles layout + the minmax(0,1fr) + min-width:0 + overflow-wrap
containment recipe (explaining the mid-rollout horizontal-scroll fix)
- Quiet (N) ▾ fold replacing the standalone Stale card
- Scope chip mechanics (Tiles-only)
- Tasks tab (today minus Stale) and Events tab (promoted with summary
header + bigger day headings)
- Cache key composition + pin-flip InvalidateAll
- Mobile breakpoints + touch targets
- Explicit non-goals for Phase 5h (Activity tab, sortable rows, project
filter dim, saved views — those are 5i with kahn)
References block updated to point at docs/plans/dashboard-overhaul.md
for the design rationale + m's chip picks.
Phase A (design) of Phase 5i — project filter dim, view-type as a
parameter, saved views, and per-page bindings. Five-slice implementation
plan (A: project filter → B: view-type URL → D: saved-views schema → C:
kanban → E: defaults). Nine open questions for m batched in §9 ready
for head delegation.
No code changes; this branch ships docs only. Coder shifts wait on m's
sign-off via head.
Phase 5g slice B. Fills the ≤767px gap left by slice A (sidebar
display:none on mobile) with a fixed-bottom 5-slot nav + a drawer for
overflow items. iOS PWA install respects safe-area-inset-bottom so the
nav clears the home indicator.
web/templates/layout.tmpl:
- New <nav class="projax-bottom-nav"> with five slots:
Tree (/) → Dashboard (/dashboard) → +New (/new, raised circle)
→ Calendar (/calendar) → Menu (drawer).
- Center "+ New" slot is a raised .capture-circle (margin-top: -10px,
44×44px, accent background) — mBrian's capture-button pattern, but
pointing at /new because projax has no separate capture flow.
- Menu slot is a <details class="projax-mobile-drawer"> whose <summary>
IS the bottom-nav-item. Tapping pops a drawer-sheet absolutely
positioned 8px above the bottom-nav with overflow items: Timeline,
Graph, Admin, theme toggle, sign-out. Browser-default <details>
handles open/close + tap-outside-dismiss — no JS, no gesture wiring.
- Active class on bottom-nav-item + drawer-item via same .Path-driven
server-side pattern slice A introduced.
- Theme toggle handler now binds to BOTH #theme-toggle (sidebar) AND
#theme-toggle-drawer (drawer). Flipping either updates the icon on
both buttons, sets data-theme on <html>, writes the cookie.
web/static/style.css:
- .projax-bottom-nav: fixed bottom, height = calc(56px +
env(safe-area-inset-bottom, 0)), flex justify-around, z-index 1021.
- .bottom-nav-item: 44×44px min, column-flex, touch-action: none for the
capture-button so iOS doesn't intercept the tap.
- .capture-circle: 44×44px raised circle, accent background.
- .projax-mobile-drawer .drawer-sheet: fixed, bottom-right anchored
above the nav, min(260px, calc(100vw - 16px)) wide, slide-up animation
via @keyframes projax-drawer-up (translateY 8→0, 160ms ease-out).
- @media (min-width: 768px): bottom-nav hidden.
- @media (max-width: 767px): main.projax-main gets padding-bottom =
calc(56px + 1rem + env(safe-area-inset-bottom)) so rows aren't hidden
behind the nav.
docs/design.md:
- New §18 (Layout: sidebar + bottom-nav, Phase 5g). Documents both
surfaces' breakpoints, the .Path-driven active marker, the pre-paint
localStorage restore, the theme-toggle dual-binding, and the four
features I deliberately did not port from mBrian (resize handle,
capture modal, quick-switcher/saved-searches/Today/Work, slide-up
gesture).
Tests (web/layout_test.go):
- TestLayoutBottomNavMarkup: 5 slots present in documented order, +New
is .capture-btn with .capture-circle, Menu is <details>, drawer holds
Timeline/Graph/Admin/theme/sign-out.
- TestLayoutBottomNavActiveClass: /calendar render highlights Calendar
slot only.
- TestLayoutThemeToggleBoundToBothButtons: handler enumerates both
button ids so flipping either flips the theme.
All 10 layout tests pass (7 from slice A + 3 from slice B). Full web
suite green. No test source edits to pre-existing tests — the bottom-
nav is additive markup.
Phase 5e slice B. Polish pass on the month grid: HTMX-swappable filter
chip strip, mobile breakpoint that collapses the 7-column table into a
vertical list of days, refined CSS for hover/today/adjacent-month, and
the docs/design.md §17 entry that pins the contract.
Templates:
- web/templates/calendar_section.tmpl (new) — extracted #calendar-section
partial. Houses the filter chip strip (form with hx-get=/calendar
hx-target=#calendar-section), counts line, and the grid <table>.
- web/templates/calendar.tmpl trimmed to the page chrome (h1, prev/next
nav, today link) + {{template "calendar-section" .}}. Chrome stays
outside the HTMX swap because chip filtering preserves the month
context.
web/calendar.go:
- handleCalendar now branches on HX-Request: HTMX → calendar_section
fragment, full GET → calendar (chrome + section). Same pattern as
/timeline and /dashboard.
- calendarDay gains LongLabel ("Mi., 14. Mai") — populated by new
formatCalendarLongLabel helper. Hidden on desktop via CSS; revealed at
the ≤480px breakpoint where the column header drops out.
web/server.go:
- Calendar template now bundles the section partial. New calendar_section
template registered as a standalone fragment for HTMX swaps. New
render() entry case "calendar_section" → "calendar-section".
web/static/style.css:
- Refined .calendar-nav (tabular numerals, transition, no surface-alt
fallback fighting the theme).
- New #calendar-filterbar layout (flex, gap, counts pushed right).
- .calendar-cell hover background, adjacent-month opacity bump (0.4→0.45
+ 0.7 on hover so it doesn't disappear when reading lead-in days).
- .today-pill line-height fix so it sits flush in the cell header.
- .cell-row min-width on .time slot, tighter line-height, 0.82em font.
- @media (max-width: 480px) breakpoint: grid + thead + tbody + tr + th +
td all → display:block. Thead hidden; .day-label revealed. Adjacent-
month cells DISPLAY:NONE on mobile (their value on desktop is grid
rectangularity; on a vertical list they're just confusing). Cell rows
bump to 0.95em for readability.
docs/design.md:
- New §17 Calendar view (Phase 5e). Documents sources (VEVENT/VTODO/
dated item_links), what's excluded (creation markers + Gitea + untimed),
the layout calculation, filter integration via TreeFilter, cache key,
the mobile breakpoint, and the German register choice.
Tests (additive, all passing):
- TestFormatCalendarLongLabel — pins the German weekday + day + month
abbreviation (Mo./Di./.../So., 1.–31., Jan/Feb/März/.../Dez).
- TestCalendarFilterChipStripRenders — chip strip present + hx-target +
hx-get + hidden month input + tag/mgmt/kind multi-selects.
- TestCalendarHTMXReturnsSectionOnly — HX-Request returns #calendar-
section only (no <body>, no .calendar-nav chrome).
- TestCalendarCellCarriesLongLabel — May 4 cell ("Mo., 4. Mai") present
in HTML so the mobile breakpoint CSS reveal works.
Net: +315 / -61.
Phase 5c slice A. Pulls the structural rules out of the Postgres
triggers into a Go-side validator. The trigger stays as defence in
depth; the validator is the human-facing error path.
- docs/plans/itemwrite-validation.md enumerates every rule the
triggers in 0001 + 0010 enforce, with the ValidationError.Kind
callers will see for each. Eleven rules total (two SQL-only safety
rails kept untranslated).
- internal/itemwrite/itemwrite.go: ValidationError + Input + Reader
interface + ValidateFormat (pure: missing fields, slug format,
status whitelist, self-parent) + ValidateAgainstStore (DB-aware:
unknown-parent, slug-collision under any common parent, cycle via
ancestor-closure DFS capped at 64 hops to mirror the trigger).
- Eight kind constants exported: missing-required, invalid-slug-format,
invalid-status, slug-collision, cycle, self-parent, unknown-parent,
unresolvable-path.
Tests cover every kind on both happy and reject paths: missing /
whitespace fields, slug containing dot / upper / whitespace, invalid
status enum, self-parent guard, unknown parent id, root slug collision,
sibling slug collision under common parent, cycle on ancestor closure,
and the "Reader returns ListAll error → validator returns nil" path
(callers see the infra error later, validator doesn't mask it).
No caller migrates yet. Same Go-linker DCE caveat as 5a/5b slice A:
`strings <binary> | grep internal/itemwrite` returns 0 until slice B
imports.
Task: t-projax-5c-itemwrite
Phase 5a slice A: a new package that concentrates the "fan out across
linked items" pattern web/dashboard.go, web/timeline.go and mcp/tools.go
each had separate copies of. No callers touch it yet — slices B/C/D
migrate them in turn.
- Aggregator with five methods (Todos/Events/Issues/Docs/Creations) plus
All convenience for the MCP timeline. Each method takes a *store.Item
slice and (optionally) a Window, returns typed Row slices.
- Row types embed the underlying caldav.Todo / caldav.Event / gitea.Issue
so existing html/template field accesses (.Todo.UID, .Event.Summary,
…) keep resolving via Go field promotion in slices B/C.
- TimelineRow sum-type wrapper (with pointer slots per Kind) plus the
flat template-friendly fields. Lifted-but-untouched from web/.
- BuildTimelineDays + SortTimelineRows + EventStartLabel +
EventDurationHint lifted near-verbatim from web/timeline.go.
- CalDAV/Gitea/Store interfaces in the aggregator so unit tests stub IO
cleanly. Real *caldav.Client / *gitea.Client / *store.Store satisfy
by method set.
- Per-source error handling preserved: log at WARN + skip the bad
fetch, return surviving rows.
Tests cover empty inputs, fan-out call counts, per-source error
recovery, window narrowing for todos, issue-cache hit path, doc/creation
allow-list filtering, BuildTimelineDays asc/desc order, sticky pills,
far-future fade, within-day sort.
Plan doc captures the slicing strategy + design decisions:
docs/plans/aggregator-refactor.md.
Task: t-projax-5a-aggregator
m's stated use case: home VTODOs (shopping list) shouldn't pollute the
chronological /timeline by default, but they should stay visible on the
home detail page itself. This adds an item-level switch with four kinds
and a URL override to peek at everything when wanted.
## Schema (migration 0015)
- timeline_exclude text[] NOT NULL DEFAULT '{}'
- items_timeline_exclude_idx GIN
- items_unified view rebuilt to surface the new column
- Behaviour-neutral: empty array = unchanged from today. m flips the
toggle himself via /admin/bulk or the detail-page form.
## Aggregation
- web/timeline.go: pre-compute the per-kind keep-list via keepFor(kind)
before fanning out — items with the kind in their exclude array are
dropped entirely (no CalDAV call wasted on excluded sources). Doc and
creation rows check the per-item flag inline. `?include_excluded=1`
(URL) and `include_excluded:true` (MCP arg) override the filter.
- store.Item.ExcludesTimelineKind(kind) helper accepts either singular
("todo") or plural ("todos") to bridge the kind-constant / persisted-
value naming choice — see comment for the why.
## UI
- /i/{path} grows a "Timeline behaviour" collapsible section with four
checkboxes (todos / events / docs / creation) and helper text. Open by
default when any kind is excluded, so m can see at a glance what's
hidden for this item.
- /admin/bulk gains a "timeline todos" select with "Exclude from timeline"
and "Re-include on timeline" — the other three kinds stay editable
per-item only per the task brief (most common use case is just todos).
## MCP
- update_item accepts timeline_exclude as a partial-update field with an
enum-restricted whitelist; unknown values dropped silently.
- itemView always emits timeline_exclude (defaults to []) so consumers
can render the toggle state without a second round-trip.
## Tests
- Migration + GIN index landed
- Item with timeline_exclude=['todos'] hides the VTODO from /timeline
- ?include_excluded=1 brings it back
- Bulk action toggles the array idempotently in both directions
- Detail page renders all 4 checkbox affordances
## docs/design.md
§12 gains a "Per-item exclusion" subsection documenting semantics, the
URL override, the bulk action, and the "detail page still shows everything"
invariant.
## Out of scope (per task brief)
- Per-tag exclusion (per-item is clearer)
- Per-day exclusion (overkill)
- Dashboard exclusion (m only flagged timeline; dashboard's "today" view
should still show shopping today if it's due today)
- Auto-seeding home with timeline_exclude=['todos'] (m runs once himself
via /admin/bulk after the deploy — schema change stays behaviour-neutral)
Each major section on /i/{path} is now wrapped in a native <details>
element with a smart-default `open` attribute. The inline JS overrides
the default from localStorage so m's per-item collapse state survives
reloads.
## Smart defaults (server-rendered open attr)
- Tasks: open if any linked calendar has >=1 open VTODO
- Issues: open if total open issues <= 10
- Documents: open if dated link count <= 5
- Public listing: closed by default
## Persistence
localStorage["projax.section." + item_id + "." + section] = "open" | "closed".
Inline JS reads on boot, writes on toggle. The "reset section state" link
in the form actions wipes every key for the current item and reloads —
smart defaults take over again.
## What's not collapsed
- Title + status/tags chip line (always visible breadcrumb)
- The inline edit form's standard fields (title/slug/parents/content)
Only the auxiliary sections — Tasks, Issues, Documents, Public listing —
collapse. m always sees what an item *is* without expanding anything.
## Tests
- TestDetailIncludesSectionToggleScript — script fragments ship
- TestDetailSectionsWrappedInDetails — every section has its wrapper
- TestDetailDocumentsClosedDefaultsWhenManyItems — 0-doc baseline is open
## docs/design.md
New section before §15 documents thresholds, persistence semantics, and
the non-collapsible carve-outs.
Adds five additive columns on projax.items and propagates them through
every read/write path. flexsiebels.de (and any future portfolio renderer)
can now pull the public set via the MCP `list_items(public=true)` filter
and stop hard-coding project lists.
## Schema (migration 0014)
- public boolean default false (partial index when true)
- public_description text default ''
- public_live_url text default ''
- public_source_url text default ''
- public_screenshots text[] default '{}'
- items_unified view rebuilt to include the five new columns
- items_public_idx PARTIAL INDEX WHERE public = true (5% of rows)
## Store
- Item struct + scan/scanItems extended (5 cols)
- UpdateInput accepts the new fields with full-replace semantics
- new SetPublic(ids, bool) for bulk write
- SearchFilters gains Public *bool — nil = no filter
## MCP
- list_items: new `public` boolean filter (input schema + handler)
- update_item: 5 new partial-update fields (nil pointer = leave alone)
- itemView always emits the 5 fields (even when public=false) so consumers
can preview "what would publish" without a second round-trip
- 2 new integration tests against the DB
## Web
- /i/{path} grows a "Public listing" fieldset: toggle + textarea + 2 URL
inputs + screenshot list editor with add/remove rows + inline JS for
the editor. Values persist when public is off so toggling never
destroys typed-in content.
- /admin/bulk action bar gains "Make public" / "Make private" via a new
select; SQL update is a single statement per action.
- /?public=1 and /?public=0 chip parameters narrow the tree page.
Active() + QueryString() + TogglePublic() round-trip the state.
- parseScreenshotList helper trims + drops empties + preserves order
- 5 integration tests: migration landed, form round-trip, bulk action
round-trip, detail-page affordances, tree-filter narrowing
## docs/design.md §15
Documents the schema, MCP contract, UI surfaces, flexsiebels consumption
pattern, and what's NOT in scope (flexsiebels-side render, asset hosting,
approval workflows).
## Out of scope (per task brief)
- Flexsiebels rendering — separate task in m/flexsiebels.de after this ships
- Asset hosting (projax stores URLs, never bytes — same PER discipline)
- Multi-stage publish workflow (boolean is enough)
Exposes projax's /timeline aggregation (Phase 4a) over MCP-RPC so the
PWA (mAi#228) can fetch it without a session cookie against
projax.msbls.de. Same tool surface m's other agents already use.
## Changes
- web/timeline.go: export TimelineQuery, TimelinePayload, add typed
TimelineArgs + BuildTimelinePayloadFromArgs entrypoint. The web cache
stays scoped to the HTTP handler; MCP path re-aggregates per call.
- mcp/tools.go: register `timeline` tool when a TimelineBuilder is
passed. Output mirrors the web template's shape but stringifies
timestamps to YYYY-MM-DD or ISO-8601 UTC so JSON-RPC consumers don't
need Go time semantics.
- mcp/tools_test.go: existing tests pass nil builder (no behaviour
change to the rest of the tool surface).
- mcp/timeline_test.go: 7 unit tests covering registration, arg
forwarding, error propagation, empty payload, and view serialisation.
- cmd/projax/main.go: pass the running *web.Server as the third arg so
the timeline tool registers on the live server (CalDAV-aware).
- docs/design.md §14: documents the tool, schema, output shape, cache
semantics.
## Out of scope
- Caching the MCP path (rejected — re-aggregation per call is cheap;
divergent cache keys aren't worth invalidation complexity).
- Wrapping CalDAV writes (S2 — separate slice once m greenlights).
- PWA backend bridge + frontend (S2/S3 — m/mAi side, after this deploys).
Phase A of the 4c task brief. Survey found the integration already
exists and ships (mAi#228, 2026-05-15) — the question is which slice
deepens it next.
Plan covers:
- Otto-PWA structural notes (Go backend + Bun/TS frontend in m/mAi, not
m/otto — ADR-006 moved PWA code into mAi)
- Existing MCP-consumption pattern (Bearer-token JSON-RPC bridge,
graceful 501 degradation, 4 endpoints registered, frontend shells +
client TS, live at https://otto.msbls.de/projax/)
- 3 deepening slices: (S1) timeline surface, (S2) CalDAV writeback,
(S3) dated docs quick-add
- Recommendation: ship (S1) first — Phase 4a just landed /timeline
in projax web, the data + aggregation logic exist, exposing via MCP
is a clean wrap with no schema or auth model change
- Impl plan if greenlit: 3 slices across projax + mAi with cross-repo
deploy verification
Out of scope until head greenlights: writing any code in m/mAi.
## Slice A — explicit dark/light toggle
projax now ships with two palettes and a 1y cookie to remember the choice.
Dark is the new default; ☀ button in the header nav flips to light and
writes projax_theme=light. Server reads the cookie via themeFromRequest(r)
and injects Theme + ThemeColor into every template via the centralised
render(w, r, …) path, so first paint never flashes the wrong theme. Inline
JS in layout.tmpl handles the toggle without a server roundtrip.
Every panel colour now lives in a CSS variable under
:root[data-theme=dark|light]; the only hardcoded hex values left are
inside those two :root blocks. A future palette tweak is one edit, not
30 selectors. Graph node colours, kind-badges, highlights and warn/ok/bad
all have parallel dark/light values picked for contrast.
Standalone SVG download bakes the light palette inline because the
downloaded asset has no parent :root providing vars — m's existing
snapshots stay print-friendly regardless of his current cookie.
Login page keeps its embedded dark CSS — it's the gateway, intentionally
always dark.
Tests: TestThemeDefaultIsDark, TestThemeCookieRoundTrips,
TestThemeCookieUnknownFallsBackToDark, TestThemeTogglePagesShareSameTheme,
TestThemeToggleScriptPresent, TestThemeColorMetaHelper. Full suite green.
## Slice B — file-upload permanently out of scope (m, 2026-05-17)
docs/design.md moves "File uploads / in-projax storage" from the §3c
parked list to a permanent "Out of scope (decided 2026-05-17)" clause
with the rationale: PER is the cross-reference index, not the file
system. docs/standards/per.md gains the same explicit clause so future
shifts working from the PER standard see the constraint where they
look. Memory note filed so future workers don't re-propose multipart
uploads, attachments tables, or documents buckets.
## docs/design.md §13 Theming
Documents the toggle approach, cookie semantics, palette table, the
standalone-SVG carve-out, the login-page exception, and the 4b
out-of-scope (prefers-color-scheme detection, per-page overrides,
transitions on swap).
/timeline braids every dated thing in projax into a single chronological spine:
CalDAV VTODOs (DUE anchor), VEVENTs (DTSTART), dated item_links (event_date),
and item-creation markers. Default window past-30d to future-90d; ?order=
toggles asc/desc; ?kind= narrows by row type; tree filter (?tag/?mgmt/?has)
applies across kinds. Today / Tomorrow get sticky pills; rows > today+30d
fade. 90s in-memory TTL cache keyed by (filter, window, order, kinds);
busted on any VTODO writeback or dated-link change.
Scope expansion (per head message during 4a): the dashboard Tasks card now
has edit + delete affordances on every row, matching the detail page. New
/dashboard/task/{edit,delete} endpoints share a writeback path with /done.
Timeline VTODO rows reuse the same handlers; HX-Target=timeline-section
selects the re-render surface. Timeline item_link rows reuse the existing
/i/{path}/links/remove handler with the same surface-switch.
VEVENT rows on the timeline remain read-only at v1 (3l decision stands).
Item-creation events render as muted "added X to projax" markers.
Tests cover empty state, dated-doc surfacing, kind-filter narrowing, order
toggle, mixed CalDAV todos + all-day events (with the (2 days) duration
hint), and tag-filter cross-kind. New dashboard test asserts the edit/
delete affordances are wired up.
docs/design.md gains §12 with the full source list, layout rules, time
window, filter integration, cache TTL, and deferred items.
The three admin pages (classify, caldav, bulk) had no shared entry point —
m navigated around and couldn't find them. /admin is now their index:
- 3 cards, each linking to the underlying tool, with live counts
(orphan count via projax.items_unified predicate; calendar count via
ListCalendars; item count via projax.items where deleted_at IS NULL
AND archived = false)
- CalDAV card auto-disables when DAV_URL isn't configured
- System panel: version (build-time ldflags hook), last migration
(projax.schema_migrations top row), MCP status (token present
yes/no — token itself never displayed), upstream health (DAV +
Gitea + Supabase, parallel-probed with 1s HTTP timeout each,
cached 30s)
web/admin.go houses the handler + cache + probeURL helper + count
queries. Templates/admin.tmpl renders the cards + system grid.
admin_test.go covers /admin render + nav-link presence on every
chrome-bearing route.
Nav consolidation: the three separate admin links in layout.tmpl
collapse to one /admin entry. Pre-existing TestTreeRenders updated
to assert the new shape.
Probe-URL caveat: probeURL counts any HTTP response as "alive" (incl.
4xx) — the admin panel measures reachability, not authorisation. CalDAV
returns 401 on bare GET; Gitea returns 200 at the root; Supabase same.
All show green when alive.
Per docs/plans/mgmt-teardown.md §4 steps 5 + 6.
Step 5: deploy/dokploy.yaml — stale "federated with mgmt.msbls.de" line
in the header comment replaced with the current host-scoped /login cookie
model. The mgmt federation never happened in projax anyway (projax
cookies are host-scoped, no Domain attribute).
Step 6: append a "DONE 2026-05-16" section to docs/plans/mgmt-teardown.md
recording every step's commit hash across both repos, the head-approved
deviation from §4 step 1 (SvelteKit-side redirect instead of Dokploy
Traefik labels — Dokploy config is UI-only), verification curls, and the
post-teardown janitorial that's out of scope for the worker (env-var
cleanup in Dokploy, DNS at m's leisure).
m/msbls.de side merged separately (86bfa61) — three commits:
2941dc4 (redirect), <previous step's commit covers the rest>.
caldav package:
- Event struct: UID, Summary, Start, End, AllDay, Location, Description,
Recurring, URL — read-only, no writeback
- ListEvents(ctx, calendarURL, ListEventsOpts{TimeMin, TimeMax}) issues
REPORT calendar-query with server-side <c:time-range> filter
- parseVEvents handles DATE vs DATE-TIME (via hasDateOnlyParam since
splitLine strips ;VALUE=DATE), RRULE-present → Recurring=true with NO
expansion (literal DTSTART only)
- 2 unit tests: full parse (DATE-TIME, all-day, recurring), hasDateOnlyParam
web dashboard:
- dashboardEvent / dashboardEventGroup types
- collectEvents fans out 4-worker pool across every caldav-list link,
fixed 7-day window from now, sort start-asc, cap 50, group by day
- dayLabelFor: Today / Tomorrow / weekday-day-month
- Events card on /dashboard between Tasks and Issues, with empty-collapse
- 2 integration tests with stubbed CalDAV: surfaces upcoming + DATE/RRULE
rendering; empty-collapse with no links
design.md §5 (CalDAV) + §Dashboard updated; mgmt-teardown plan's one
blocking gap is now closed.
Research-only output: audit of every /mgmt/* route + auth shell + server
libs in m/msbls.de, mapping to projax equivalents, gap list, migration
sequence, risk register.
Headline:
- 4 mgmt routes audited (root, /login, /self redirect, /mgmt/* guard)
- 3 already at parity on projax (login, auth guard, CalDAV VTODOs)
- 1 small gap (VEVENTs on dashboard) is the only blocker — Phase 3l candidate
- 2 further "gaps" (mWorkRepo cards, mBrian topic cards) recommended
park-forever; mgmt never shipped them either
- Cross-repo grep confirms ZERO external dependencies on /mgmt/* — only
one stale comment in projax/deploy/dokploy.yaml
No code touched. m reads the plan + decides go/no-go on Gap 1 + migration
sequence (§4) before any teardown work.
- web/static/manifest.webmanifest: name/short_name/start_url=/dashboard/
display=standalone/theme_color/background_color + three icons (192, 512,
512-maskable with ~12% safe-zone padding)
- web/static/sw.js: minimal SW — install caches /static/* shell assets,
fetch is network-first with cache fallback on GETs only, skips /mcp/
and non-GETs entirely. CACHE_NAME versioned for clean activate-time
prune.
- cmd/icongen: stdlib-only generator that produces the three PNG icons
from a stylised "p" monogram. Run once at brand-change, commit output.
- web.init() registers .webmanifest → application/manifest+json with
mime.AddExtensionType so Chrome accepts the manifest at all
- layout.tmpl + login.tmpl: manifest link, apple-touch-icon, theme-color,
apple-mobile-web-app-* metas, inline SW-register on load (silent on
failure — older browsers still work)
- design.md gets §"PWA install (Phase 3j)"; CLAUDE.md "Out of scope"
drops the Phase-3j line and adds push/background-sync as the
remaining Otto-PWA territory
- 4 new tests cover manifest MIME, sw.js delivery, all 3 icons, layout
meta tags
- viewport meta on layout.tmpl + login.tmpl (iOS won't render legibly without)
- two breakpoints: tablet (≤768px), phone (≤480px)
- chip strips: horizontal-scroll with sticky labels instead of wrapping
- tables → card lists: classify + bulk render as stacked cards on mobile
- forms: single column on phone; min 44px touch targets on buttons
- dashboard: cards already 1-col, polish for narrow widths; grid jumps to
2 columns at ≥1280px with stale card spanning both
- /graph: SVG scrolls inside .graph-canvas (max-width 100vw, max-height
75vh, overflow auto); "fit to screen" toggle flips natural vs viewport
- TestLayoutHasViewportMeta verifies every chrome-bearing route ships the
meta tag
- CLAUDE.md "Out of scope" drops mobile/Otto-PWA exclusion (head approved
on m/mAi#1861); replaced with native-PWA-install line for Phase 3j
- design.md adds §"Mobile responsiveness" with breakpoint + principle notes
- gitea pkg: CloseIssue, ReopenIssue, CreateIssue, AddComment + ErrForbidden
classification on 401/403. Client.do sets Content-Type on non-empty bodies.
- web handler: POST /i/{path}/issues/{close|reopen|comment|create}
- authorisation guard: repo form value must match a gitea-repo item_link
on the target item (rejects form-crafted writes to unrelated repos)
- HTMX re-renders issues_section partial after each action
- busts gitea per-repo cache (open + closed-recent) and dashboard 60s TTL
- templates: ✓ close button + reopen + collapsible comment box on every
issue row; "+ new issue" disclosure per repo
- design.md §6 retitled "Phase 2.d read; 3h writeback" with auth/perm
semantics + parked list
- 5 unit tests in gitea/, 5 integration tests in web/ covering happy paths
+ 403 → inline banner fallback
- gitea.GetRepo returns FullName + UpdatedAt for the stale-card probe
- dashboard collectStale: mai-managed items + linked-repo updated_at >60d
+ zero open tasks + zero open issues. Sorted longest-stale first, ≤20.
Multi-repo items need ALL repos quiet to count as stale. Reuses the
4-worker pool + the already-aggregated task/issue counts from the
Tasks / Issues cards (no extra DAV/Gitea fetches).
- dashboardCache.invalidate(key) busts a single filter's cache entry;
?refresh=1 routes through it so ↻ button gets fresh data.
- "updated Nm ago · cached/fresh" label + ↻ refresh link in dashboard
chrome.
- Empty-card collapse: with no filter + zero rows the card renders as
a one-line muted note instead of full chrome. Filter-active cards
keep chrome so m can tell "filter hid it" from "nothing there".
- design.md §"Dashboard / daily-driver view" extended with the 4 new
surfaces; the 3e "stale (3f)" out-of-scope line dropped.
- 5 new tests: stale-surface, stale-skip-recent, refresh-busts-cache,
empty-collapse, filter-keeps-chrome. 2 unit tests for gitea.GetRepo.
- migration 0012: one-shot populate empty tags from each item's area-roots
(so chips on /?tag=work etc. actually filter the 40+ mai-backfilled rows)
- migration 0013: cleanup 12 orphan item_links + BEFORE-UPDATE trigger that
cascades soft-delete to item_links going forward — closes the data drift
that made TestItemsUnifiedSurfacesMaiPointer fail since 3c
- /admin/bulk page: flat filter+checkbox list with one-tx Apply for add/
remove tag, set management, set status. Per-row inline chip add/remove
via /admin/bulk/chip. Reuses tree_filter URL params 1:1.
- design.md §3.2 + §4.1 updated; tag+management section notes 0012
- bulk + tag-backfill + soft-delete-cascade tests cover the new surface
migration 0011_item_links_event_date.sql: ADD event_date date + partial
index (idempotent). Day granularity by design per the PER spec; the
column lands NULL on every existing row, no backfill.
store:
- ItemLink gains an EventDate *time.Time (every read path scans it).
- AddLinkDated(ctx, item, refType, refID, rel, note, date, metadata)
upserts with COALESCE(new, old) for note + event_date so partial
callers don't clobber prior state.
- DatedLinks(item) returns event_date IS NOT NULL ordered DESC.
web:
- per.go: parsePER strips a trailing .YYMMDD (rejects invalid dates like
Feb 30); collisionTag yields a/b/.../z/aa/ab/...; computePERs walks
DatedLinks output and assigns render-time collision tags inside each
date group. Tags are never stored.
- handleDetail: 404 retry with PER stripped — /i/mfin.house1.260515
resolves to the house1 item with HighlightDate=2026-05-15.
- documents_section.tmpl: add-form (ref_type/date/ref_id/note),
date-sorted rows with computed PER, ref-type badge, remove × with
anti-forgery item-id check, highlight row when HighlightDate matches.
- POST /i/{path}/links/add and /links/remove handlers; HTMX swap on the
fragment, redirect for non-HTMX callers.
mcp:
- add_link accepts event_date: "YYYY-MM-DD" (parsed strict, hands back
fmt.Errorf on bad form). linkView.event_date surfaces it on responses.
- Existing add_link callers without event_date keep working unchanged.
docs:
- docs/standards/per.md gains an Implementation section pointing at
item_links.event_date + ref_types + render-time collision policy.
- docs/design.md adds a Documents/dated artifacts section with the
schema delta, conflict policy, and URL routing rules.
tests:
- per_test.go: parsePER (valid/invalid dates, non-numeric, wrong
length); collisionTag (1..53); computePERs (bare-then-.a, skips
undated, multi-date grouping).
Tree page (/) gains every navigation dimension m asked for:
- Debounced search input matching title/slug/aliases/content_md/paths
case-insensitively (?q=…)
- Tag chip row (?tag=a,b — AND within tags, as before)
- Management chip row with ?mgmt=mai,self,external,unmanaged
(OR within management; "unmanaged" is the synthetic empty-array case)
- Status chip row with ?status=active,done,archived (default = active;
archived rows only surface when the separate show-archived toggle is on)
- Has-link chip row ?has=caldav-list,gitea-repo
- Each chip carries the count it would yield if toggled — honest user
cue, computed via per-dimension recomputation in pure Go (cheap at
m's scale)
- URL is the source of truth — every filter goes through the query
string, so any view is bookmarkable; HTMX swaps the tree-section in
place with hx-push-url=true on every chip click and on search keyup
- Empty-state copy with a clear-all link
Implementation:
- web/tree_filter.go new: TreeFilter struct + ParseTreeFilter +
QueryString/URL + Toggle* helpers + Matches + applyTreeFilter
(replacement for buildForest) + computeChipCounts.
- web/tree_filter_test.go: parse defaults + every dimension's match +
URL round-trip + ancestor-keep semantics + chip counting.
- Linkages: linkKindsByItem on Server fans across the two has-link
ref_types in one pass and feeds the filter.
- tree.tmpl reduced to a one-liner that calls tree-section; new
tree_section partial powers both the initial page render and HTMX
fragment swaps (matches the pattern from phases 2.a/b/d).
docs/design.md §4: tree-filter contract — URL keys, AND/OR rules,
count semantics, archived ergonomics.
mcp package (new): minimal JSON-RPC 2.0 + MCP-protocol server, tools
delegate to *store.Store (no business-logic duplication).
- handler.go: handleRPC routes initialize / tools/list / tools/call /
ping / notifications/initialized; Bearer-token middleware; results
flow through the standard MCP content[].text envelope; tool errors
surface as isError: true (transport errors stay JSON-RPC errors).
- tools.go: 10 tools — list_items / get_item / create_item /
update_item / delete_item / list_links / add_link / remove_link /
search / tree. Multi-parent in/out — parent_paths[] string array,
resolved per call. itemView/linkView keep the wire shape snake_case
and stable.
- mcp_test.go + tools_test.go: protocol primitives (no DB) plus a
full create → get → search → delete round-trip skipping cleanly
when the DB env is absent. Multi-parent assertion discovers the
test pair from the live DB rather than hard-coding a row.
store extensions:
- ListByFilters(SearchFilters) with parent_path/tags/management/kind/
status/q/has_repo/has_caldav predicates.
- Search(q, limit) ranked across title/slug/aliases/content_md.
- GetByPathOrSlug for callers that don't know the full path.
- SoftDeleteCascade refuses on live descendants unless cascade=true.
web:
- New optional Server.MCP http.Handler. main.go mounts an mcp.Server
when PROJAX_MCP_TOKEN is set; /mcp/* gets a StripPrefix and bypasses
the Supabase-cookie auth middleware (its own Bearer auth applies).
- Off cleanly when the token is unset.
ops:
- ~/.claude/mcp/projax.sh stdio→HTTP bridge (NDJSON in, NDJSON out,
Bearer header).
- .mcp.json adds an http-transport entry for clients that speak
HTTP+MCP natively.
- deploy/dokploy.yaml advertises PROJAX_MCP_TOKEN as a secret.
- docs/design.md §7 added: tool list, multi-parent semantics, env
contract, transport + bridge.
caldav package:
- Todo carries URL, ETag, Raw so ListTodos rows can be PUT/DELETEd in place
- BuildVTodoICS for new VTODOs, ApplyVTodoEdit for in-place edits that
preserve unknown properties (DESCRIPTION, CATEGORIES, X-*)
- PutTodo/DeleteTodo with If-Match optimistic concurrency
- ErrPreconditionFailed/ErrNotFound for 412/404
- RFC 5545 fold-at-75 + CRLF + text escape, hand-rolled UUID v4
- httptest round-trip (create -> list -> complete -> delete) plus 412 path
web:
- POST /i/{path}/caldav/todo/{complete,reopen,edit,delete,todo-create}
- Re-fetches the live ETag before each PUT/DELETE so ordinary use never
trips 412; on actual 412 the section reloads with a banner
- Calendar URL must already be linked to the item (anti-forgery guard)
- tasks_section partial drives both the initial page render and HTMX
swaps; detail.tmpl reduces to a one-liner template call
docs/design.md §5: rewrite for full read/write semantics + ETag concurrency.
m's CalDAV server (dav.msbls.de, SabreDAV) now feeds projax via a thin
read-only-plus-create-on-demand integration. No background sync; tasks
fetched live on detail-page render.
New caldav/ package
- ListCalendars (PROPFIND Depth: 1, filters non-calendar collections)
- ListTodos (REPORT calendar-query for VTODO; hand-rolled iCalendar
parser for UID/SUMMARY/STATUS/DUE/PRIORITY/LAST-MODIFIED — RFC 5545
line-folding aware)
- CreateCalendar (MKCALENDAR, 405 → ErrCalendarExists for the "link
instead" branch)
- httptest-stubbed tests cover all four paths.
Store
- ItemLink shape + LinksByType / LinksByRefType / AddLink / DeleteLink.
AddLink upserts on (item_id, ref_type, ref_id, rel) so re-linking the
same calendar is idempotent.
Web
- GET /admin/caldav — discovery + auto-suggested matches + manual
linker. Suggestion = lowercased displayname == projax slug or title.
- POST /admin/caldav/link — insert item_links row.
- POST /admin/caldav/unlink — delete by link id.
- POST /i/{path}/caldav/create — MKCALENDAR at <base>/<slug>/, then
AddLink. On 405 (already exists), fall back to link-only.
- Detail page Tasks section: per-calendar block with open VTODOs +
collapsed completed (30d window). Errors per calendar logged and
skipped, so one bad calendar does not blank the page.
- nav adds /admin/caldav link.
main.go
- DAV_URL + DAV_USER + DAV_PASSWORD optional. Missing DAV_URL → CalDAV
off (admin page renders "not configured" notice). DAV_URL set but
user/pass missing → fail fast at boot.
docs/design.md gains §5 documenting the integration shape.
deploy/dokploy.yaml lists the two new secrets + the env var.
Phase 2.b (writeback / two-way / background sync) is parked.
Big task. Five migrations, full store + web rewrite, and a model upgrade
that turns the parent_id tree into a parent_ids[] DAG.
Schema (db/migrations)
- 0006_tags_management_unify: adds tags + management text[] (GIN-indexed),
collapses the area/project distinction (kind keeps the slot but 'area'
is no longer a special value), drops the structural rules from the
path trigger so root projects + non-root projects are both legal.
- 0007_backfill_mai_projects: one-shot, idempotent — for every row in
mai.projects without a 'mai-project' item_link, create a projax.items
row under a heuristic-chosen area (mhealth→health, msports/manjin→
sports, kanzlai/hlckm/work/mworkrepo/paliad or HL/* repo→work,
mhome→home, default→dev), insert the item_link, and tag the row
management=['mai']. Also flips management='mai' on any already-linked
pre-Phase-1.5 promotions.
- 0008_mai_projects_sync: bidirectional triggers. sync_to_mai runs as
projax_admin and writes mai.projects directly (after the operator-run
grant + RLS policy widening — documented in the migration header).
sync_from_mai is SECURITY DEFINER so writes by the mai role fan out
into projax.items. pg_trigger_depth() + projax.in_sync GUC keep the
cycle suppressed. Slug stays the join key for new rows; the
item_link pointer survives renames.
- 0009_items_unified_simplify: view collapses to a thin projection over
projax.items now that mai.projects is a derived projection.
- 0010_multi_parent: parent_id → parent_ids uuid[], path → paths text[].
compute_item_paths walks via parents' precomputed paths (no recursive
CTE in the hot path; cycle detection uses one). New triggers:
items_check_slug_collision (multi-parent uniqueness),
items_after_delete (manual cascade since arrays don't carry FK).
Trigger refresh_item_paths_recursive does parent-first DFS over
descendants, guarded by projax.refreshing_paths GUC.
Go store + handlers
- Item gains ParentIDs []string + Paths []string. PrimaryPath /
OtherPaths helpers feed the detail breadcrumb. Source always
'projax' now; SourceRefDeref still surfaces the mai-id pointer.
- Update / Reparent / Create take ParentIDs []string. AddParent helper
for the multi-parent UI's "also list under" action.
- GetByPath uses '$1 = any(paths)' so /i/work.paliad and /i/dev.paliad
resolve to the same row.
- buildForest renders a multi-parent item under each of its parents
(duplicated nodes in distinct branches). Tag-filter prune is
branch-preserving.
Templates
- detail.tmpl: multi-select parents, tags + management chip inputs,
"Also at: …" breadcrumb for multi-parent items.
- new.tmpl: same multi-select + chip inputs.
- tree.tmpl: tag-filter chip bar, "×N" badge on multi-parent rows,
management chips visible on every row.
- classify.tmpl: re-parent workflow (no more promote-to-projax — the
bidirectional sync removed the dichotomy).
Tests (DB + HTTP, all skip without env)
- TestMultiParentResolvesBothPaths inserts an item with two parents,
asserts both inherited paths.
- TestSlugCollisionUnderCommonParent refuses a sibling clash.
- TestMultiParentBothPathsRouteToSameRow HTTP-level: /i/dev.X and
/i/work.X both 200, same row.
- TestReparentRoundTrip rewritten for parent_ids[] semantics.
- TestPathTriggerNestAndRename / Reparent rewritten to query paths[].
Docs (docs/design.md)
- §2 rewritten: items in a DAG, no area/project distinction.
- §3 schema: parent_ids + paths + tags + management + indices.
- §3.1 path-trigger overhaul incl. cycle detection via recursive CTE
and slug-collision-under-common-parent guard.
- §3.2 view simplified.
- §3.4 NEW: mai.projects bidirectional sync, including the manual
prereq.
- §4.1 + §4.2: classify becomes re-parent, tags+management UI section.
mai head start / mai hire / mai status / mai instruct keep working
because mai.projects retains its FK-target shape; the projax sync just
mirrors the row in lock-step.
External-citable references for letters, invoices, filing labels, email
subjects, PDF filenames, bank-transfer Verwendungszweck.
Format: <area>[.<project>...][.<YYMMDD>][.<collision-tag>]
- Date suffix YYMMDD (compact, not ISO).
- Collision-tag (.a, .b, ...) only when same <path>[.<date>] repeats.
- No doc-type taxonomy in v0.1 — collision-tag covers the disambiguation
case at lower memorisation cost.
- Lookup case-insensitive; display in m's preferred camelCase.
- Rename stability via projax.items.aliases[].
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.