Commit graph

765 commits

Author SHA1 Message Date
Till JS
87b567eec9 i18n: fix IT/FR/ES parity gaps in dashboard + memoro
- dashboard: +5 Einträge pro Sprache für die beiden neuen Widgets
  activity_feed + articles_unread.
- memoro: +1 Eintrag pro Sprache für memo.load_more.

Damit sind dashboard (111) und memoro auf gleichem Stand wie DE/EN.
Verbleibende Drift (app_slider-Legacy-Keys in memoro IT/FR/ES,
common/auth-Legacy in calendar/times) ist strukturell und bleibt
einem Folge-Cleanup vorbehalten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:19:59 +02:00
Till JS
d49ad239d9 feat(writing): M8 — AI tools exposed through the shared catalog
Writing is now programmatically accessible from the foreground mission
runner, personas, and Claude Desktop / MCP. Eight tools land:

Auto (read-only):
- list_drafts         — filtered by kind/status + word-count summary
- get_draft           — briefing + current version body, ready for reading
- list_writing_styles — 9 presets + user customs, ids usable in create_draft

Propose (human approval per agent policy):
- create_draft            — briefing only, no generation yet
- generate_draft_content  — wraps generationsStore.startDraftGeneration;
                            writes a new LocalDraftVersion + pointer flip
- refine_draft_selection  — wraps refineSelection + applyRefinement in
                            one call; operations: shorten/expand/tone/
                            rewrite/translate with op-specific params
- set_draft_status        — draft/refining/complete/published
- save_draft_as_article   — hand-off to articlesStore.saveFromExtracted
                            with internal://writing/<id> as originalUrl,
                            records publishedTo + emits WritingDraftPublished

Schemas live in @mana/shared-ai/src/tools/schemas.ts (the SSOT that the
web-app policy layer + mana-ai planner derive from). Executors live in
modules/writing/tools.ts and delegate to the existing stores so the
encryption + event pipeline runs once regardless of who called the tool.
Registration added to data/tools/init.ts.

107 shared-ai tests still pass. CLAUDE.md tool-coverage table bumped:
67→75 tools, 21→22 modules.

Not in M8 (deferred): agent.defaultWritingStyleId linkage (needs a
Persona schema change + runner wiring), mana-tool-registry Zod specs
(add when a non-web MCP client needs richer validation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:19:30 +02:00
Till JS
27c1860f82 feat(comic): M1 — Datenschicht + Modul-Registrierung
Neues Comic-Modul: aus Text-Inputs (Journal / Notes / Writing / Library
/ Calendar) entsteht ein mehrseitiger Comic, generiert mit gpt-image-2
über die bestehende /picture/generate-with-reference-Route. Plan in
docs/plans/comic-module.md (M1–M5 + optional M6–M8).

M1 schafft die Datenschicht ohne UI:
- Dexie v44 `comicStories` (space-scoped, Indices createdAt/style/
  isFavorite/isArchived). Story hält `panelImageIds: string[]` und
  `panelMeta: Record<panelImageId, {caption, dialogue, promptUsed,
  sourceInput?}>` — Panels selbst sind picture.images-Rows mit
  comicStoryId + comicPanelIndex Back-Refs.
- Fünf Stil-Presets (comic / manga / cartoon / graphic-novel / webtoon)
  mit Prompt-Prefix-Templates in styles.ts; composePanelPrompt webt
  Stil + Panel-Prompt + Caption + Dialog zusammen. Sprechblasen
  werden von gpt-image-2 direkt ins Bild gerendert — kein SVG-Overlay.
- Encryption-Registry-Eintrag: title / description / storyContext /
  tags / panelMeta als JSON-Blob. Struktur (id, style, character-
  MediaIds, panelImageIds, Flags, visibility) bleibt plaintext.
- Module-Registry registriert appId='comic', verifyMediaOwnership auf
  der /picture/generate-with-reference-Route akzeptiert jetzt
  ['me', 'wardrobe', 'comic'] — 'comic'-Slot ist reserviert für M6+
  Anchor-/Backdrop-Uploads.
- Space-Allowlist: comic in brand (Marken-Storys), club (Vereins-
  geschichte), family (Kinder-Abenteuer), team (Release-Comics),
  practice (Patienten-Aufklärung). Personal via '*'-Sentinel.
- mana-apps.ts Eintrag mit comic-Icon (Sprechblase + Lightning-Bolt,
  f97316→dc2626 Gradient). Lokal tier='guest' mit LOCAL TIER PATCH-
  Comment wie Wardrobe, canonical ist 'beta'.

Visibility-System von Anfang an adopted (setVisibility-Methode im
Store, unlistedToken-Generierung inklusive). appendPanel() als
Vorarbeit für M2 bereits da, ohne Aufrufer.

5 Encryption-Roundtrip-Tests grün (panelMeta nested JSON, leeres
panelMeta, partielle panelMeta ohne sourceInput, null-description).
pnpm run check + validate:all sauber (207 Dexie-Tabellen klassifiziert,
comicStories unter den 106 encrypted).

Kein UI, keine Panel-Generierung, keine MCP-Tools — alles M2/M3/M5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:29:51 +02:00
Till JS
3c3b2ebbc7 feat(writing): M1+M2 — new Ghostwriter module with manual draft CRUD
M1 (skeleton):
- Module `writing` registered: 4 Dexie tables (writingDrafts,
  writingDraftVersions, writingGenerations, writingStyles) in v43,
  encrypted via typed registry entries, space-scoped via the Dexie hook.
- App entry in mana-apps.ts (sky-cyan #0ea5e9, LOCAL TIER PATCH guest),
  fountain-pen icon in app-icons.ts.
- Plan: docs/plans/writing-module.md — 12 milestones, Ghostwriter-first
  with Canvas deferred to M9, Picture-pattern analogue (Draft + Version
  + Generation), 9 preset styles, Space-Kontext-as-default.

M2 (manual CRUD):
- drafts store: createDraft (atomic draft + initial v1), updateBriefing,
  setStatus, toggleFavorite, deleteDraft (cascade soft-delete versions),
  updateVersionContent (live edit), createCheckpointVersion,
  restoreVersion (pointer flip, non-destructive), setVisibility.
- styles store: createStyle, updateStyle, upsertExtractedPrinciples,
  setSpaceDefault (exclusive flip), deleteStyle.
- queries: useAllDrafts, useDraft, useVersionsForDraft,
  useCurrentVersionForDraft (follows the pointer so restoreVersion shows
  up in the editor), useGenerationsForDraft, useAllStyles + helpers.
- UI: KindTabs (shows only kinds with drafts), StatusBadge, StatusFilter,
  DraftCard (<button> for a11y), BriefingForm (topic/kind/audience/tone/
  length/language/extra), VersionEditor (500ms debounce + onBlur flush),
  VersionHistory (restore button per version).
- Routes: /writing list + /writing/draft/[id] with {#key id} remounting.

User flow: create draft from briefing → land in detail view → type →
autosave → "Als Checkpoint speichern" for a new version → restore any
older version from the history panel. No AI yet; M3 wires mana-llm for
short-form generation and M7 switches to mana-ai missions for long-form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:59:56 +02:00
Till JS
259f6fb316 fix(shared-privacy): default all new records to 'space', not 'private'
Regression reported in testing: tasks and calendar events created via
the Workbench homepage widgets appeared there but vanished from their
respective module sub-routes (/todo, /calendar).

Root cause: my M4.b + M4.a shipped `defaultVisibilityFor('personal') →
'private'` based on the original plan ("personal space default is
private"). That collides with the pre-existing 2-tier visibility filter
in `apps/mana/apps/web/src/lib/data/scope/visibility.ts`, which treats
'private' records as "only the authorId sees them, even inside the
same space". Its applyVisibility() drops any 'private' record whose
authorId doesn't exactly match getCurrentUserId() — and the homepage-
widget cross-app queries in cross-app-queries.ts don't run that filter
while /todo/useAllTasks() does, creating the asymmetry the user saw.

Why the match can fail in practice: during auth bootstrap,
getEffectiveUserId() returns the 'guest' sentinel (which the Dexie
creating-hook stamps onto authorId), while getCurrentUserId() can
already resolve to the real user id by the time /todo's query runs.
authorId='guest' !== currentUserId=<real> → record filtered out.

Fix: defaultVisibilityFor() now returns 'space' regardless of space
type. Rationale:
- In a personal space there's exactly one member, so 'space' and
  'private' are effectively equivalent — both mean "only the owner
  sees it".
- In a multi-member space, 'space' is the desired default (otherwise
  every collaborative record would need a manual toggle).
- 'private' becomes an *active* user decision for drafts in shared
  spaces — click the VisibilityPicker to enable it.
- The parameter is retained (as `_spaceType`) for forward-compat so
  future space types can differentiate without touching call sites.

Impact on shipped modules: all 8 consumers (Library, Picture,
Calendar, Todo, Goals, Places, Recipes, Wardrobe) call
defaultVisibilityFor(activeSpace.type) at create time — they inherit
the fix automatically. No store edits required.

Existing records with visibility='private' from the testing window
stay as they are; user can flip them to 'Bereich' via the
VisibilityPicker, or reset the local Dexie to pick up the new default.

Plan doc updated with the full rationale (docs/plans/
visibility-system.md §Entscheidung).

Verified:
- pnpm test @mana/shared-privacy: 15/15 (defaults.test.ts updated)
- pnpm check (web): 7464 files, 0 errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:46:48 +02:00
Till JS
218cf45005 feat(wardrobe): M5.c — outfits adopt the unified visibility system
Eighth consumer of @mana/shared-privacy. Wardrobe outfits now carry a
VisibilityLevel flipped via <VisibilityPicker compact> in the outfit
detail page; the wardrobe.outfits embed powers the style-portfolio
use-case on the owner's website.

Scope: outfits only, not individual garments. Outfits are the composite
unit users curate for public presentation (an outfit is an intentional
composition; a single garment rarely is). Garments inherit their outfit
visibility implicitly — a public outfit reveals the look, the garment
pieces behind it stay private at the record level.

Changes:
- wardrobe/types: visibility + unlistedToken + visibilityChangedAt +
  visibilityChangedBy on LocalWardrobeOutfit; Outfit (UI) requires
  visibility; toOutfit converter forwards with 'space' fallback
- wardrobe/stores/outfits: createOutfit stamps
  defaultVisibilityFor(activeSpace.type); new setVisibility(id, level)
  mints/clears the unlisted token on the transition boundary and emits
  cross-module VisibilityChanged
- wardrobe/views/DetailOutfitView: <VisibilityPicker compact> in the
  metadata header row, left of the favourite/edit icons — keeps the
  action rail tight while making exposure state glanceable

website embed:
- website-blocks/moduleEmbed/schema: 'wardrobe.outfits' added to
  EmbedSourceSchema
- website/embeds: resolveWardrobeOutfits gates hard on
  canEmbedOnWebsite, filters archived + deleted, optional isFavorite /
  tagIds filters, favourites-first then newest. Inlines title +
  occasion/season meta + the lastTryOn.imageUrl (the AI-generated
  wearing shot). Description, garment details, and internal tag labels
  stay out of the public snapshot

Verified:
- pnpm check (web): 7450 files, 0 errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:08:32 +02:00
Till JS
0e0d48acec feat(recipes): M5.b — recipes adopt the unified visibility system
Seventh consumer of @mana/shared-privacy. Recipes now carry a
VisibilityLevel; the recipes.recipes embed powers "my cookbook" /
"tested recipes" sections on the owner's website.

Changes:
- recipes/types: visibility + unlistedToken + visibilityChangedAt +
  visibilityChangedBy on LocalRecipe; Recipe (UI) requires visibility
- recipes/queries: toRecipe forwards visibility with 'space' fallback
- recipes/stores/recipes: createRecipe stamps
  defaultVisibilityFor(activeSpace.type); duplicateRecipe resets to
  the space default (copies don't inherit public status — same rule
  as picture boards); new setVisibility(id, level) emits
  cross-module VisibilityChanged
- recipes/ListView: <VisibilityPicker> as the first row of the
  detail-panel when a card is expanded. Recipes has no dedicated
  detail route so inline-expand is the canonical surface

website embed:
- website-blocks/moduleEmbed/schema: 'recipes.recipes' added to
  EmbedSourceSchema
- website/embeds: resolveRecipes gates hard on canEmbedOnWebsite,
  optional isFavorite + tagIds filters, favourites-first then newest,
  inlines { title, subtitle ('30 Min · 4 Port.'), imageUrl }.
  Ingredients + steps + internal tag labels stay out of the snapshot —
  the embed is a teaser; full recipes are a later M8 unlisted-page
  feature.

Verified:
- pnpm check (web): 7450 files, 0 errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:04:14 +02:00
Till JS
2af2a4d5c0 feat(places): M5.a — places adopt the unified visibility system
Sixth consumer of @mana/shared-privacy. Places now carry a VisibilityLevel
flipped via <VisibilityPicker> in the Places DetailView; the new
places.places embed powers "my favourite cafes" / "rehearsal rooms" /
"gyms I train at" sections on the owner's website.

Changes:
- places/types: visibility + unlistedToken + visibilityChangedAt +
  visibilityChangedBy on LocalPlace; Place (UI type) requires visibility
- places/queries: toPlace forwards visibility with 'space' fallback for
  legacy rows
- places/stores/places: createPlace stamps
  defaultVisibilityFor(activeSpace.type); new setVisibility(id, level)
  mints/clears the unlisted token on the transition boundary and emits
  cross-module VisibilityChanged
- places/views/DetailView: <VisibilityPicker> as the first field-row,
  above Kategorie

website embed:
- website-blocks/moduleEmbed/schema: 'places.places' added to
  EmbedSourceSchema; filter docstring describes the places-specific
  reuse of existing kind/isFavorite/tagIds filter fields
- website/embeds: resolvePlaces gates hard on canEmbedOnWebsite,
  applies optional kind (→ PlaceCategory) / isFavorite / tagIds
  filters, sorts favourites-first then alphabetical.

Privacy: Whitelist (title + address only). Latitude/longitude are
explicitly NOT inlined — 10m precision of a home or workplace can
identify someone, and silently publishing coords on a visibility flip
would be the classic leak the design was built to prevent (plan §2).

Verified:
- pnpm check (web): 7450 files, 0 errors

Next: M5.b — Events (socialEvents), Recipes, Wardrobe-Outfits, Habits,
Quiz, Invoices-Clients. Same pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:59:15 +02:00
Till JS
95e85bdffd feat(goals): M4.c — goals adopt the unified visibility system
Fifth consumer of @mana/shared-privacy, completing the M4 trio
(Calendar + Todo + Goals). Goals live under $lib/companion/goals/
(legacy path, pre-rename to 'ai') instead of the standard /modules/
tree, so the adoption lands in its own commit.

Enables the "public progress page" use case — a fitness / learning /
build-in-public goal with its current-period progress inlined on the
owner's website, rendered as "4 / 5 · Woche".

Changes:
- companion/goals/types: visibility + unlistedToken +
  visibilityChangedAt + visibilityChangedBy on LocalGoal (LocalGoal
  doubles as the UI type here, no separate plaintext variant)
- companion/goals/store: createFromTemplate and create both stamp
  defaultVisibilityFor(activeSpace.type) at insert; new
  setVisibility(id, level) mints/clears the unlisted token on the
  transition boundary and emits cross-module VisibilityChanged
- modules/goals/ListView: <VisibilityPicker compact> on each active
  goal card, sitting between the title and the pause button (goals
  have no dedicated detail view — list-inline is the natural spot)

website embed:
- website-blocks/moduleEmbed/schema: 'goals.goals' added to
  EmbedSourceSchema; filter docstring describes the active-vs-
  completed split that power users can use to section their progress
  page
- website/embeds: resolveGoals gates hard on canEmbedOnWebsite,
  filters by optional status ('active' | 'completed' | 'paused' |
  'abandoned'), sorts active-first then by target descending so
  milestone goals land on top. Inlined EmbedItem is whitelist-only —
  title + compact progress line like "4 / 5 · Woche". Description,
  metric configuration (event types, filter fields), and internal
  tracking state stay out of the snapshot; the goal's implementation
  detail leaks what the user is measuring, not just the milestone

Verified:
- pnpm check (web): 7450 files, 0 errors
- pnpm test goals + website: 29/29
- pnpm run validate:all green

M4 is done. Next: M5 — Places + Events + Recipes + Habits + Quiz +
Wardrobe + Invoices-Clients. Same pattern, one module at a time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:41:27 +02:00
Till JS
015a2c18ee feat(todo): M4.b — tasks adopt the unified visibility system
Fourth consumer of @mana/shared-privacy. Tasks now carry a
VisibilityLevel flipped via <VisibilityPicker> in the Todo DetailView;
a new todo.tasks embed source powers the "public roadmap" use-case
(mark a handful of tasks public, drop the embed on the Website).

Changes:
- todo/types: visibility + unlistedToken + visibilityChangedAt +
  visibilityChangedBy on LocalTask; Task (UI type) requires visibility
- todo/queries: toTask forwards visibility with 'space' fallback for
  legacy rows (pre-M4.b records have no field set; Dexie hook stamped
  'space' since spaces-foundation v28)
- todo/stores/tasks: createTask stamps
  defaultVisibilityFor(activeSpace.type); new setVisibility(id, level)
  mints/clears the unlisted token on the transition boundary and
  emits cross-module VisibilityChanged
- todo/views/DetailView: <VisibilityPicker> dropped in as the first
  prop-row above Priorität so the user sees exposure state at a glance
  whenever they open a task

website embed:
- website-blocks/moduleEmbed/schema: 'todo.tasks' added to
  EmbedSourceSchema; filter docstring explains the todo-specific shape
  (status + tagIds for the typical "shipped items with #public" filter)
- website/embeds: resolveTodoTasks gates hard on canEmbedOnWebsite,
  maps the optional status filter ('completed' → isCompleted=true),
  joins the N:N taskTags table for the optional tagIds filter, sorts
  newest-first with id as stable tiebreaker. Inlined EmbedItem is
  whitelist-only — title + status label ('Erledigt' / 'In Arbeit').
  Description, subtasks, LLM-labels, due-dates, and project
  memberships stay out of the public snapshot (per plan §2 redaction
  policy)

Verified:
- pnpm check (web): 7450 files, 0 errors
- pnpm test todo + website: 38/38

Next: M4.c — Goals. Lives under $lib/companion/goals/ (not in the
standard /modules/ tree), so the adoption path is slightly different
and gets its own commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:37:32 +02:00
Till JS
ac44d51363 feat(calendar): M4.a — events adopt the unified visibility system
Third consumer of @mana/shared-privacy. Calendar events now carry a
VisibilityLevel the owner flips from the EventDetailModal via
<VisibilityPicker>; a new calendar.events embed source lets the user
drop a moduleEmbed block on their website that pulls their public
events in.

This unblocks concrete use-cases the Website-Builder audit surfaced:
band tour dates, public workshops, public rehearsals on a team-space
website, meeting-with-the-host pages.

Changes:
- calendar/types: visibility + unlistedToken + visibilityChangedAt +
  visibilityChangedBy on LocalEvent; CalendarEvent (UI type) requires
  visibility. timeBlockToCalendarEvent forwards the field; cross-module
  TimeBlocks (tasks, habits, time entries) without an owning
  LocalEvent fall back to 'space' so they stay off the public embed
- calendar/stores/events: createEvent stamps
  defaultVisibilityFor(activeSpace.type); createDraftEvent seeds a
  'private' draft until the user explicitly opts in; new
  setVisibility(id, level) mints/clears the unlisted token on the
  transition boundary and emits cross-module VisibilityChanged
- calendar/components/EventDetailModal: <VisibilityPicker compact>
  sits in the modal-actions row left of copy/edit/delete

website embed:
- website-blocks/moduleEmbed/schema: EmbedSourceSchema adds
  'calendar.events'; the filter shape gains optional `upcomingDays`
  (1-365) and `tagIds` (up to 16). Old filters (isFavorite/status/kind)
  remain — each source uses only its own subset
- website/embeds: resolveCalendarEvents gates hard on
  canEmbedOnWebsite(event.visibility ?? 'private'), joins each event
  to its LocalTimeBlock for the real start/end, applies the optional
  upcomingDays window and tag-id AND-filter, sorts upcoming-first with
  id as stable tiebreaker

Redaction is whitelist-per-design (plan §2): the inlined snapshot
carries only title, formatted date range, and location — NOT
description, reminders, tag labels, or the guest list. Fields that
typically hold private context stay out of the public blob regardless
of the visibility toggle.

Verified:
- pnpm check (web): 7450 files, 0 errors
- pnpm test calendar + website: 26/26
- pnpm run validate:all green

Next: M4.b — Todo, M4.c — Goals. Same pattern; split out because
goals lives under $lib/companion/goals/ with its own structure and
Todo has a complex view-column/filter surface that warrants its own PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:32:25 +02:00
Till JS
49935c9628 feat(shared-privacy): M1 — visibility foundation package
Scaffold the unified visibility/privacy layer introduced by docs/plans/
visibility-system.md. No module adopts it yet — this is the foundation
PR (M1). Module rollout lands in follow-ups starting with Library (M2).

What ships:
- @mana/shared-privacy package
  - VisibilityLevel enum ('private' | 'space' | 'unlisted' | 'public')
  - VisibilityLevelSchema + UnlistedTokenSchema (zod)
  - defaultVisibilityFor(spaceType): personal → private, else → space
  - predicates: canEmbedOnWebsite, isReachableByLink,
    isVisibleToSpaceMember, canAiAccessCrossUser (always false in P1)
  - generateUnlistedToken() — 32-char base64url, CSPRNG, ~192 bits
  - VISIBILITY_METADATA: German labels + descriptions + phosphor icon
    names so non-UI surfaces (audit logs, CLI) label levels consistently
  - <VisibilityPicker> svelte component: compact lock/globe trigger with
    4-option menu, full descriptions, optional compact + disabledLevels
- VisibilityChangedPayload type for the domain-event catalog (consumer
  registers it when the first module adopts the system)
- .claude/guidelines/visibility.md — step-by-step for module authors
  (schema migrations + store wiring + picker placement + embed resolver +
  legacy isPublic migration), with a pre-PR checklist
- Plan-doc "Offene Fragen" section rewritten as "Designentscheidungen"
  with the seven resolutions the user approved
- CLAUDE.md: shared-privacy listed in the packages table; visibility.md
  listed in the guidelines table
- 15 unit tests covering predicates (one-and-only-one 'public' for
  embed; phase-1 AI always-deny), defaults (personal vs multi-member,
  null fallback), token uniqueness + schema round-trip

Key constraints honored:
- `visibility` stays plaintext (NOT added to the encryption registry)
  so RLS predicates and publish resolvers can read it without the user's
  master key
- Publish flow remains "decrypt client-side, inline plaintext into
  snapshot" — the pattern picture.board already uses in embeds.ts
- Deny-by-default everywhere (personal default = private; unknown space
  type defaults to private; cross-user AI always false)

Not in this PR (per plan):
- No schema migrations in any module (M2–M6)
- No RLS predicate updates (arrives with M2)
- No /settings/privacy overview (M7)
- No unlisted share routes (M8)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:59:11 +02:00
Till JS
e66654068f feat(auth): error-classification layer + passkey end-to-end
Two interlocking fixes driven by a production lockout incident.

## Bug that motivated this

A fresh schema-drift column (auth.users.onboarding_completed_at) made
every Better Auth query crash with Postgres 42703. The /login wrapper
swallowed the non-2xx and mapped it onto a generic "401 Invalid
credentials" AND bumped the password lockout counter — so 5 legit
login attempts against a broken DB would have locked every real user
out of their own account. Same wrapper pattern on /register, /refresh,
/reset-password etc. The 30-minute hunt ended in a one-off repro
script that finally surfaced the real Postgres error.

The user-facing passkey button additionally returned generic 404s on
every login-page mount because the route wasn't registered (the DB
schema existed, the Better Auth plugin wasn't wired).

## Phase 1 — Error classification (services/mana-auth/src/lib/auth-errors)

- 19-code AuthErrorCode taxonomy (INVALID_CREDENTIALS, EMAIL_NOT_VERIFIED,
  ACCOUNT_LOCKED, SERVICE_UNAVAILABLE, PASSKEY_VERIFICATION_FAILED, …)
- classifyFromResponse/classifyFromError handle: Better Auth APIError
  (duck-typed on `name === 'APIError'`), Postgres errors (23505 unique,
  42703/08xxx → infra), ZodError, fetch/ECONNREFUSED network errors,
  bare Error, unknown.
- respondWithError routes the structured response, logs at the right
  level, fires the correct security event, and CRITICALLY only bumps
  the lockout counter for actual credential failures — SERVICE_UNAVAILABLE
  and INTERNAL never touch lockout.
- All 12 endpoints in routes/auth.ts refactored (/login, /register,
  /logout, /session-to-token, /refresh, /validate, /forgot-password,
  /reset-password, /resend-verification, /profile GET+POST,
  /change-email, /change-password, /account DELETE).
- Fixed pre-existing auth.api.forgetPassword typo (→ requestPasswordReset).
- shared-logger + requestLogger middleware wired in index.ts; all
  console.* calls in the service removed.

## Phase 2 — Passkey end-to-end (@better-auth/passkey 1.6+)

- sql/007_passkey_bootstrap.sql: idempotent schema alignment —
  friendly_name→name, +aaguid, transports jsonb→text, +method column
  on login_attempts.
- better-auth.config.ts: passkey plugin wired with rpID/rpName/origin
  from new webauthn config section. rpID defaults to mana.how in prod
  (from COOKIE_DOMAIN), localhost in dev.
- routes/passkeys.ts: 7 wrapper endpoints (capability probe,
  register/options+verify, authenticate/options+verify with JWT mint,
  list, delete, rename). Each routes errors through the classifier;
  authenticate/verify promotes generic INVALID_CREDENTIALS to
  PASSKEY_VERIFICATION_FAILED.
- PasskeyRateLimitService: in-memory per-IP (options: 20/min) and
  per-credential (verify: 10 failures/min → 5 min cooldown) buckets.
  Deliberately separate from the password lockout — different factor,
  different blast radius.
- Client: authService.getPasskeyCapability() async probe, memoised per
  session. authStore.passkeyAvailable reactive state. LoginPage gates
  on === true so a slow probe doesn't flash the button in.
- AuthResult grew a code: AuthErrorCode field; handleAuthError in
  shared-auth prefers the server envelope over the legacy message
  heuristics.

## Tests

- 30 unit tests for the classifier covering every branch (including
  the exact Postgres 42703 shape that started this).
- 9 unit tests for the rate limiter.
- 14 integration tests for the auth routes — the regression test
  explicitly asserts "upstream 500 → 503 + zero lockout bumps".
- 101 tests pass, 0 fail, 30 pre-existing skips unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:52:51 +02:00
Till JS
f9ca6ca44b chore(branding): drop wardrobe tier to guest for local testing [LOCAL PATCH]
Wardrobe landed with requiredTier: 'beta' — correct for production,
but in the local dev loop the user's account is 'standard' and
clicking into /wardrobe/garment/[id] or /wardrobe/compose gets blocked
by the AuthGate with "Dein Zugang: Standard · Benötigt: Beta".

Flip to 'guest' to match the existing local tier-patch pattern that
already covers 55 of the 63 apps (see git log for earlier patches,
and memory note `project_tier_patch_resolved`).

Marked inline with `// LOCAL TIER PATCH — revert to 'beta' before
release` so the pre-release grep sweep finds it alongside the others.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:31:28 +02:00
Till JS
1198d01263 feat(onboarding): M4 — Screen 3 (Templates) + finish handler
- packages/shared-branding/onboarding-templates.ts:
  * 7 templates: Alltag / Arbeit / Health / Sport / Lernen / Entdecken
    / Erinnern — each with a phosphor icon name, German name/desc and
    an ordered moduleIds list
  * resolveModulesForTemplates() — deduplicates the union of selected
    templates' modules (priority-ordered) and caps at 8 (2×4 grid)
- packages/shared-branding/onboarding-templates.spec.ts: 10 tests
  covering order preservation, dedup-across-templates, cap honouring,
  unknown-id tolerance
- /onboarding/templates/+page.svelte:
  * Multi-select grid of 7 tiles (checkmark + primary border when on)
  * Finish handler: runs resolveModulesForTemplates → creates a new
    "Zuhause" scene with those apps → onboardingStatus.markComplete()
    → navigates to /
  * Skip still marks complete (no scene — user lands on DEFAULT_HOME_APPS)
  * Prefills selection from onboardingFlow store so back-nav is stable

With this, the 3-screen flow runs end-to-end for a new user:
signup → /onboarding/name → /look → /templates → / with a curated
home scene.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:03:00 +02:00
Till JS
f20ace0358 test(website): broad automated coverage across the builder surface
83 new tests across 5 files — pure-logic, fast, run on every
push. Caught one real bug + motivated one small refactor.

Coverage:

- apps/mana/.../website/constants.test.ts (8): isValidSlug + RESERVED_SLUGS
  + isValidPath. Caught the 1-char-slug bug (regex allowed length 1;
  UI + plan say min 2). Fixed the regex in both the webapp and the
  mirrored server list.
- apps/mana/.../website/publish.test.ts extended (8 total): adds
  self-parent cycle, 3-level nesting, all-orphans, empty-input cases
  on top of the original determinism + orphan-drop tests.
- apps/mana/.../website/templates.test.ts (7): parameterised over each
  of the 4 bundled templates — clone produces fresh UUIDs, page +
  block counts match, navConfig populated. Plus unknown-template and
  duplicate-slug rejection. Container-nesting is punted to the smoke
  test (none of the bundled templates use columns yet).
- packages/website-blocks/src/schemas.test.ts (38): every block
  (11) + sanity-checks (defaults satisfy own schema, enum + length
  bounds, required fields). Pure Zod — no Svelte runtime needed.
- packages/website-blocks/src/themes/themes.test.ts (12): preset
  parity, resolveTheme overrides, themeCssVars output format +
  heading-font fallback.
- apps/api/src/modules/website/reserved-slugs.test.ts (10): mirror of
  the client tests for the server SSOT, plus new hostname validation
  cases (.mana.how reservation, length, malformed edges).

Refactor:

- apps/api/src/modules/website/reserved-slugs.ts now owns
  isValidHostname + RESERVED_HOSTNAMES. domains.ts imports them.
  Pure functions live next to the other pure validators; easier to
  test + share.

All 83 new tests green. Web-app svelte-check + apps/api type-check
both clean. Existing publish.test.ts / website-blocks tests still
pass (the monorepo-wide count is now well above 83 — these are
the new ones from this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:07:40 +02:00
Till JS
101af462a8 feat(shared-ai): LLM-facing task tool wrapper for runSubAgent (M3.2)
Exposes runSubAgent() as a tool the planner LLM can call natively,
matching Claude Code's `Task` tool shape: { subagent_type, description,
prompt } -> single-string summary.

New exports from @mana/shared-ai:

  - TASK_TOOL_NAME = 'task'
  - TASK_TOOL_SCHEMA — ToolSchema ready to drop into a runPlannerLoop
    `tools` array. subagent_type enum = research|plan|general;
    description+prompt required; defaultPolicy: 'auto' (control-flow,
    not a user-data write).
  - createTaskToolHandler(opts) — factory returning:
      - handle(call): structured ToolResult with the sub-agent's
        summary as message + data {subAgentType, toolsCalled,
        rounds, stopReason, usage}
      - cumulativeUsage(): rolled-up TokenUsage across all sub-agent
        invocations — parent budget accounting reads from here
      - invocationCount(): metric-ready counter

Why not in mana-tool-registry: `task` is a loop-internal control-flow
primitive, not a user-data operation. Registry is for habits/notes/etc.
where MCP exposure and space-scoping matter. task never touches mana-
sync and never crosses the MCP boundary.

Recursion guard is defense-in-depth: the primitive throws
SubAgentRecursionError, this handler catches parentDepth >=
MAX_SUB_AGENT_DEPTH up front and returns a structured ToolResult
instead so the LLM sees it as regular tool-feedback.

Exceptions from the sub-agent (provider down, network) get wrapped
as `{ success: false, message: 'Sub-agent failed: ...' }`. The parent
loop's round continues.

14 new tests covering schema shape, recursion rejection, argument
validation (4 cases), happy path with tool dispatch, cumulative
usage tracking across multiple invocations, exception wrapping,
and parent-dispatcher routing.

107 shared-ai tests green total (was 93).

M3.3 consumer wiring follows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:05:09 +02:00
Till JS
7e3f53f8a5 feat(tool-registry): wardrobe.* MCP tools — listGarments/listOutfits/createOutfit/tryOn (M5)
M5 of docs/plans/wardrobe-module.md — exposes the Wardrobe feature
through the shared tool-registry so MCP clients (Claude Desktop)
and the mana-ai mission runner can browse, compose, and try on
outfits alongside the built-in UI. Follows the pattern M5 of the
me-images plan established in packages/mana-tool-registry/src/
modules/me.ts — encrypted reads via mana-sync pull + client-side
filter on `row.spaceId === ctx.spaceId`, writes via pushInsert
with encryptRecordFields, HTTP proxy for the try-on endpoint.

Four tools in packages/mana-tool-registry/src/modules/wardrobe.ts:

- wardrobe.listGarments(category?, tags?, limit?) — read. Pulls
  wardrobeGarments from mana-sync, filters to the active space,
  decrypts name/brand/color/size/material/tags/notes, applies
  optional category + intersection-tag filters, caps at 200 rows
  (50 default). Archived + soft-deleted items excluded.

- wardrobe.listOutfits(occasion?, favoriteOnly?, limit?) — read.
  Same shape, filters by occasion (closed enum, plaintext —
  unencrypted filter) and favorite. garmentIds arrive plaintext
  so the agent can immediately resolve them via listGarments when
  it needs more than ids.

- wardrobe.createOutfit({ name, garmentIds, occasion?, tags?,
  description? }) — write. Encrypts name/description/tags, pushes
  an insert tagged with ctx.spaceId. No cross-space validation of
  the garmentIds — the calling agent is expected to have called
  listGarments first; dangling refs surface visually in the UI
  rather than as a hard server error.

- wardrobe.tryOn({ outfitId, prompt?, accessoryOnly?, quality? }) —
  write (consumes credits). Biggest tool of the set: pulls the
  outfit, its garments, and the caller's meImages in three
  separate mana-sync pulls, resolves the primary face-ref +
  body-ref, auto-detects accessoryOnly from garment categories
  (FACE_ONLY_CATEGORIES: accessory/glasses/jewelry/hat), composes
  refs respecting the 8-slot server cap, composes a default DE
  prompt from the outfit name + occasion, and proxies to
  /api/v1/picture/generate-with-reference with the user's JWT.
  Returns the resulting image's URL + mediaId + prompt + model.

  Deliberately does NOT persist a picture.images row or update
  outfit.lastTryOn from the tool — those live on the client's
  imagesStore / wardrobeOutfitsStore and doing them server-side
  would race with a user who's also looking at the outfit page.
  Agents use tryOn as a preview/inspection primitive; the user
  commits from the UI.

Types: 'wardrobe' added to the ModuleId union. registerWardrobeTools
wired into registerAllModules — mana-mcp's createMcpServerForUser
iterates the registry and exposes any user-space tool automatically.

Credit model: quality defaults to 'medium' (10 credits per render),
same tarif as text-to-image generation. The agent pays for the
generation out of the calling user's credit balance via the
standard validateCredits/consumeCredits chain on the server endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:59:31 +02:00
Till JS
66b7e08df2 feat(shared-ai): runSubAgent() primitive — Claude-Code I2A pattern (M3.1)
New packages/shared-ai/src/planner/sub-agent.ts implementing the
"one level deep, fresh messages, restricted tools, single-string
return" sub-agent contract from Claude Code's KN5/I2A launcher.

Four invariants enforced at the primitive level:

  1. FRESH messages[] — parent's history never leaks in. The sub-agent
     only sees its own system prompt + the task description. Hundreds
     of scanned files stay inside the sub-agent.
  2. RESTRICTED tool-whitelist — parent's full catalog is filtered
     per SubAgentType ('research' = auto-policy only, 'general' =
     everything, 'plan' = auto-policy + 3-round cap). Custom filter
     overrides the type default.
  3. SINGLE RETURN VALUE — sub-agent returns summary:string for
     the parent to render as task-tool-result. Individual tool calls
     stay in rawResult for debug capture but never cross the boundary.
  4. ONE LEVEL DEEP — MAX_SUB_AGENT_DEPTH = 1. parentDepth >= 1 throws
     SubAgentRecursionError; the consumer task-tool handler will
     also check, this is defense-in-depth.

Model is required (no default) — routing to a cheaper tier like the
compactor does is an explicit decision, not a sneaky default.

Belt-and-suspenders wrapper on onToolCall rejects any tool call
whose name isn't in the whitelist, even if the LLM fabricates one.

14 new tests covering recursion guard, tool filtering per type,
custom filter, whitelist rejection, fresh-messages isolation, usage
roll-up, default summary on max-rounds, type-specific system prompt,
system-prompt override, and end-to-end tool-call -> result -> summary.

93 shared-ai tests green total (was 79).

M3.2 (task tool in registry) and M3.3 (consumer wiring) follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:59:05 +02:00
Till JS
4966ca69f0 feat(tool-registry): add mood module (log/today/recent)
Third encrypted module in @mana/tool-registry, brings the registry to
16 tools across 7 modules. Lets the Anna / Sofia / Maya personas
(whose moduleMix puts mood at 20–30 %) actually exercise their
daily-tracking routine when the runner ticks.

Three tools, all encrypted per the web-app registry
(moodEntries: entry<LocalMoodEntry>(['withWhom', 'notes'])):

- mood.log
    Write a mood entry. `level` 1–10, `emotion` + `secondaryEmotions`
    from the taxonomy copied verbatim from apps/mana/.../modules/mood/
    types.ts (keep in sync if new emotions/activities get added). date
    + time default to server-clock now; personas logging
    retrospectively pass them explicitly.

- mood.today
    Return every entry for today (or `{ date }`) sorted by time.
    Multiple entries per day are normal — the web app timelines them.

- mood.recent
    Last N days (default 7), newest first. Useful for
    self-reflection turns like "how has your week been?".

Scope decisions

Calendar was on the shortlist but dropped: `events` writes couple to
`timeBlocks` (a separate table/appId), so one tool call becomes two
sync pushes with a shared transaction concern — worth a careful
session, not a drive-by. Goals dropped because `companionGoals` is
owned by the Companion Brain, not a regular module, and has no clear
mana-sync appId convention. Both candidates for a focused follow-up.

Verified

- `pnpm run validate:all` green (crypto registry 202/202, encrypted-
  tools audit 9/9 including the 3 new mood tools)
- type-check across tool-registry + mcp + runner green
- registerAllModules → 16 tools, 7 modules:
    habits: create/list/update/archive
    journal: add 🔐
    me: listReferenceImages 🔐 / generateWithReference
    mood: log 🔐 / today 🔐 / recent 🔐
    notes: create 🔐 / search 🔐
    spaces: list
    todo: create 🔐 / list 🔐 / complete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:39:00 +02:00
Till JS
4fc9d6c59c feat(wardrobe): module foundation — garments + outfits space-scoped data layer (M1)
M1 of docs/plans/wardrobe-module.md — pure data layer + backend plumbing,
zero UI (that's M2). A user can now hold a digital wardrobe per space:
brand merch, club Trikots, family Kleiderschrank, team Kostüme, practice
Dresscode, and personal closet all live as separate pools under the same
Dexie tables, space-scoped like tags/scenes/agents after Phase 2c.

Data model — two tables, no join:

- wardrobeGarments (Dexie v41): single clothing items / accessories.
  Indexed on `category` + `createdAt` + `isArchived`. Encrypted:
  name/brand/color/size/material/tags/notes. Plaintext: category,
  mediaIds, counters, timestamps — all indexed or structural.
  `mediaIds[0]` is the primary photo used for try-on; additional
  ids are alternate views (back, detail) for M7.

- wardrobeOutfits (Dexie v41): named compositions referencing
  garment ids. Encrypted: name/description/tags. Plaintext:
  garmentIds (FK array), occasion (closed enum — useful for
  undecrypted filtering), season, booleans, lastTryOn snapshot.

- picture.images gains `wardrobeOutfitId?: string | null` as a
  plaintext back-reference. Try-on results land in the Picture
  gallery like any other generation; the outfit detail view
  queries them via this id rather than maintaining a third table.

Space scope:

- `wardrobe` added to all five explicit allowlists in shared-types/
  spaces.ts (personal is wildcard, no edit needed). Each space type
  gets a one-line comment explaining the real-world use case.
- App registry: `wardrobe` entry in shared-branding/mana-apps.ts
  with a rose→fuchsia gradient icon (T-shirt on hanger silhouette),
  color #e11d48, tier 'beta', status 'beta'.
- Module registry: wardrobeModuleConfig imported + appended to
  MODULE_CONFIGS so SYNC_APP_MAP picks it up automatically.

Backend:

- MAX_REFERENCE_IMAGES bumped 4 → 8 in picture/generate-with-
  reference (plus the client-side default in ReferenceImagePicker).
  Justified with a comment: face + body + top + bottom + shoes +
  outerwear + 2 accessories = 8. Cost doesn't scale with ref count
  (OpenAI bills per output), so the bump is a pure capability
  expansion with no credit-side risk.
- New POST /api/v1/wardrobe/garments/upload wraps uploadImageToMedia
  with app='wardrobe'. Registered under /api/v1/wardrobe in index.ts.
  Pattern 1:1 with the profile/me-images/upload endpoint; tier-gating
  falls out of wardrobe NOT being in RESOURCE_MODULES (tier='guest'
  works — consistent with picture's plain CRUD).

Stores emit domain events (WardrobeGarmentAdded, WardrobeOutfitCreated,
WardrobeOutfitTryOn, etc.) so later mana-ai missions can observe
activity without polling.

No UI in this commit. M2 (Garments-Grundlayer) wires the route + grid
+ upload-zone; M3 the Outfit composer; M4 the Try-On integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:27:37 +02:00
Till JS
f7536bc0b9 feat(shared-ai): route compactor to Haiku-tier model by default (M2.5)
compactHistory() now defaults to DEFAULT_COMPACT_MODEL =
'google/gemini-2.5-flash-lite' when the caller doesn't override. Lite
is ~3–5x cheaper than gemini-2.5-flash with near-identical
summarisation quality — summarisation doesn't need the same tier as
reasoning + tool-calling, and the compactor fires exactly when token
spend is highest, so the cheaper route saves exactly where it matters.

CompactHistoryOptions.model is now optional. All three consumers
(mana-ai tick, webapp Companion, webapp Mission runner) drop their
explicit gemini-2.5-flash override and let the default apply.

This is the pragmatic M2.5: no mana-llm changes. The "tier" abstraction
(X-Model-Tier header, env-routed aliases) from the Claude-Code report
makes sense only once multiple utility tasks need cheaper routing —
topic-detection, classification, command-injection checks. Today only
the compactor wants it, and a model constant is the simplest contract
that works.

2 new tests (default applied + override honoured). 79 shared-ai tests
green, all three consumers type-check clean. One pre-existing unrelated
type error in apps/mana/apps/web/src/lib/modules/wardrobe/queries.ts
(not touched by this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:26:50 +02:00
Till JS
cb9a9bb42e refactor(profile,tool-registry): flip meImages from user-scoped to space-scoped (v40)
Flips `meImages` out of USER_LEVEL_TABLES so it lives under the same
tenancy model as every other data table (tags, scenes, tasks, …).
Precursor to the Wardrobe module, which is space-scoped across all
six space types — leaving meImages user-global would leave an
inconsistency where the Wardrobe catalog is per-space but its
reference input is cross-space, plus a latent privacy leak in shared
spaces (agents in a brand-space would see the owner's entire pool).

Plan: docs/plans/me-images-space-scope-migration.md.

Key decisions:

- Strict scope, no cross-space fallback. Switching into a brand-space
  with no uploaded face shows an empty state and links back to
  /profile/me-images; it does not quietly reach into the personal-
  space pool. Keeps the mental model clean.
- auth.users.image remains pinned to personal-space primary-avatar.
  Only a primary change inside personal space triggers the Better
  Auth sync; brand/club/family/team/practice primaries stay local.
- Single Dexie v40 upgrade: stamps `spaceId=_personal:<uid>`
  sentinel, `authorId=<uid>`, `visibility='space'` on every existing
  row and drops the legacy `userId` column. Dexie upgrades block app
  startup, so by the time the new code's scopedForModule reads run,
  every row is already space-stamped. reconcileSentinels() on the
  next active-space bootstrap rewrites `_personal:<uid>` to the real
  personal-space id, same path v28 used.
- Legacy-avatar migration (M2.5) now pins its row to
  `_personal:<uid>` explicitly — the legacy avatar is the user's
  global SSO identity and belongs in the personal space even if the
  migration happens to fire while the user is in a brand space.

Code changes:

- types.ts: LocalMeImage gains spaceId/authorId/visibility (all
  optional — stamped by hook). Public MeImage exposes spaceId for
  queries that want to branch on space type.
- database.ts: meImages out of USER_LEVEL_TABLES; new v40 upgrade
  block that stamps sentinels + drops userId in one pass.
- queries.ts: all four hooks (useAllMeImages, useMeImagesByKind,
  useReferenceImages, useImageByPrimary) read via scopedForModule.
  Scope-switch triggers automatic re-render via the existing
  scopedTable filter path.
- stores/me-images.svelte.ts: setPrimaryInTx uses scopedForModule so
  a setPrimary in Brand-space never clears Personal-space's holder.
  syncAvatarToAuth gates on activeSpace.type==='personal' so non-
  personal primary changes don't leak into Better Auth.
  createMeImage accepts optional spaceId override — the legacy-
  avatar migration uses it, regular uploads let the hook stamp the
  active space.
- migration/legacy-avatar.ts: explicitly passes
  spaceId=_personal:<uid> to pin the legacy row into personal space.
- MeImagesView.svelte: subtle badge in the intro card shows the
  active space ("Persönlich" for personal, space name otherwise) so
  users notice when the pool changes on space switch.
- packages/mana-tool-registry/src/modules/me.ts: me.listReferenceImages
  filters pulled rows by row.spaceId === ctx.spaceId. mana-sync
  returns all spaces the user belongs to; the tool only wants the
  active space's subset.

No schema/index change on meImages (non-indexed fields, pool size
small enough for in-memory scopedTable filter). If perf matters
later, adding [spaceId+kind] is a 5-minute follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:09:57 +02:00
Till JS
72f7978ed4 feat(agent-loop): expose compactionsDone + compactedReminder producer
Closes the loop on M2: when the compactor fires, the LLM needs to know
it's now seeing a <compact-summary> instead of raw turns so it
doesn't waste a turn asking about lost details or re-executing tools
whose responses are gone.

shared-ai:
  - LoopState grows `compactionsDone: number` (cap-1 by current loop
    policy, but shape kept as count for future multi-compact cycles).
  - runPlannerLoop populates it on each reminder-channel call. New
    loop test asserts [0, 1] sequence: round 1 before compaction,
    round 2 after.

mana-ai:
  - New producer `compactedReminder` — fires severity=info when
    compactionsDone >= 1, wrapped in a German one-liner ("frag nicht
    nach verlorenen Details").
  - Injected FIRST in buildReminderChannel so the LLM frames the rest
    of the round with "I'm looking at a summary" context. Metric
    surface stays `{producer='compacted', severity='info'}`.

4 new reminder tests (3 pure producer + 1 composition-ordering) +
1 loop-wiring test. 77 shared-ai, 20 reminders.test.ts — green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:36:21 +02:00
Till JS
700861f682 feat(pill-nav): show Anmelden pill for guests next to menu icon
Before: guests had to open the user-menu dropdown to find the login
button. Now the login CTA renders as a visible primary pill immediately
right of the (icon-only) user-menu trigger, so signing in is one click.
Removed the duplicate Anmelden entry from userMenuBarItems — theme,
mode toggle, and language stay in the bar for signed-out users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:36:09 +02:00
Till JS
3d8214a147 feat(shared-ai): wire compactor into runPlannerLoop (M2.2)
PlannerLoopInput grows an optional compactor:

  compactor?: {
    maxContextTokens: number;
    threshold?: number;        // default 0.92, matches Claude Code wU2
    compact: (messages) => Promise<{ messages, compactedTurns }>;
  }

Before each LLM call the loop checks whether promptTokens+completion
has crossed threshold × maxContextTokens. If yes AND we haven't
compacted this run yet, the callback runs, its returned messages
REPLACE the live history, and compactionsDone flips to 1 so a
runaway tool can't re-trigger.

Design choices:
  - Fires at most ONCE per loop run. If the fresh (compacted)
    history hits the threshold again in the same run, the LLM
    round budget will hit first; better to terminate than to
    recursively compact a summary.
  - No reminder emitted automatically — the caller can wire
    that via reminderChannel by reading compactionsDone from
    LoopState (next PR; compactionsDone isn't exposed yet to
    keep the state surface small).
  - compactor callback is injectable, not hardcoded to
    compactHistory() from compact.ts. Lets mana-ai route the
    compactor LLM call to a cheaper model (Haiku) without
    changing the loop.
  - Zero maxContextTokens → skip silently (same contract as
    shouldCompact()).

Also cleaned up the isParallelSafe non-null-assertion warning by
hoisting the predicate to a local with proper narrowing.

5 new loop tests: below-threshold no-op, single-fire replacement,
once-per-run idempotency, zero-cap bail, no-op when compactor
returns 0 turns. 76 shared-ai tests total, green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:25:35 +02:00
Till JS
13361eb083 feat(shared-ai): compactHistory() — context-window compactor primitive (M2.1)
The Claude-Code wU2 pattern: when token usage hits ~92% of the provider's
context budget, fold all pre-tail turns into a single structured summary
(Goal / Decisions / Tools Called / Current Progress) so subsequent
rounds see a synopsis instead of the raw log.

This commit ships ONLY the primitive. Wiring it into runPlannerLoop
(auto-trigger before the next LLM call when shouldCompact() fires)
is M2.2 so the surface stays small and testable.

New exports from @mana/shared-ai:

  - shouldCompact(totalTokens, maxContextTokens, threshold?)
      → boolean; DEFAULT_COMPACT_THRESHOLD = 0.92, matching Claude Code.
      Bails safely when maxContextTokens is missing (local models often
      don't report usage).

  - compactHistory(messages, { llm, model, keepRecent?, temperature? })
      → { messages, summary, compactedTurns, usage? }
      Preserves: [0]=system, [1]=first user, [last N]=recent turns
      (default 4). Everything between gets sent through the compact
      agent with COMPACT_SYSTEM_PROMPT — a fixed 4-section Markdown
      schema. Temperature default 0.2 because we want summarisation,
      not creativity.

  - parseCompactSummary / renderCompactSummary — round-trip helpers.
      Parser is tolerant (missing sections → empty string) so a partial
      compaction still produces a usable summary.

The summary replaces the middle as a single role='assistant' message
wrapped in <compact-summary> tags. Assistant role (not system) because
some providers reject arbitrary system messages deep in history.

Tests: 17 new across the 4 exports (trigger logic, Markdown round-trip,
structural preservation of anchors + tail, usage passthrough, custom
keepRecent). All 71 shared-ai tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:21:10 +02:00
Till JS
13efae8cd2 feat(website): M5 — AI tools + starter templates
Two things:

1. AI tools (9) in the website module — writes go through the standard
   proposal flow, reads run auto during planning.
   - shared-ai/src/tools/schemas.ts: AI_TOOL_CATALOG entries with
     defaultPolicy propose/auto.
   - webapp modules/website/tools.ts: execute functions wired to the
     existing stores. ModuleTool[] registered in data/tools/init.ts.
   - Propose: create_website, apply_website_template, create_website_page,
     add_website_block, update_website_block, publish_website
   - Auto: list_websites, list_website_pages, list_website_blocks
   Server-side mana-tool-registry integration (mana-mcp, mana-ai) is
   a M5.x follow-up — webapp flow unblocks the missions-based use case.

2. Starter templates — clone into a fresh site with new UUIDs.
   - templates/types.ts: SiteTemplate shape with localId / parentLocalId
     so container→child references survive the clone.
   - 4 templates: portfolio (4 pages), personal-linktree (1 page, 6 CTAs),
     event (3 pages incl. RSVP form), blank (1 empty page). Deferred:
     smb-corporate + product-landing (need team/pricing/testimonials
     blocks, M6+).
   - sitesStore.applyTemplate: walks template, bulk-inserts new rows,
     remaps parent refs. Sets navConfig items from template pages.
   - TemplatePicker component + /website/new route. Replaces the old
     quick-create modal; ListView now links to /new. AppRegistry
     context-menu action points there too.

AiProposalInbox integration deferred — the component doesn't exist in
the webapp yet (the plan mentions it aspirationally). defaultPolicy
'propose' is already set so writes stage correctly once the UI catches
up.

Validation:
- pnpm run validate:all: 6/6 gates green
- pnpm run check (web): 0 errors, 0 warnings
- apps/api + packages/shared-ai type-check: green

Plan: docs/plans/website-builder.md (M5 shipped)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:14:45 +02:00
Till JS
8f283726b1 feat(agent-loop): activate retryLoopReminder via LoopState.recentCalls
Extends LoopState with a sliding window of the last N ExecutedCalls
(oldest-first), capped at LOOP_STATE_RECENT_CALLS_WINDOW = 5. The loop
maintains the window automatically; reminderChannel producers read it
without touching internal state.

This activates retryLoopReminder which was shape-only in faa472be9.
The guard now fires end-to-end: when round >= 3 and the tail-2 calls
both returned success:false, the LLM sees a "stop retrying, write a
summary instead" <reminder> on the next turn. The tail-2 check rather
than window-wide is deliberate — a flaky run with intermittent success
(F, F, F, OK, F) is not a retry loop, just flaky tools.

Why window=5: retry loops usually manifest within 2-3 consecutive
rounds; a 5-deep window gives room for burst-detection and
stale-tool heuristics without bloating the reminder channel. Cap
keeps the reminder producers O(5) regardless of loop length.

Tests: 3 new (sliding-window cap + slide + order in shared-ai, retry
composition + budget+retry chain + tail-only heuristic in mana-ai).
Total agent-loop tests now 74 across both packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:02:40 +02:00
Till JS
fc635f9830 feat(tool-registry): me.listReferenceImages + me.generateWithReference (M5)
Closes M5 of docs/plans/me-images-and-reference-generation.md —
exposes the meImages feature through the shared tool-registry so MCP
clients (Claude Desktop) and the mana-ai mission runner can drive it
alongside the built-in webapp UI.

Two tools in packages/mana-tool-registry/src/modules/me.ts:

- me.listReferenceImages(kind?) — scope: user-space, read. Pulls the
  user's meImages rows from mana-sync (app='profile'), filters to
  usage.aiReference=true and soft-live records, decrypts the `label`
  and `tags` fields with the caller's master key (same pattern as
  notes.search). Returns mediaIds + kind + primary-slot info so a
  persona can pick references intelligently. ZK users will see this
  fail at getMasterKey() — correct, because the label is truly
  unrecoverable server-side for them.

- me.generateWithReference({prompt, referenceMediaIds, quality,
  size, n}) — scope: user-space, write. Thin proxy over the M3
  endpoint POST /api/v1/picture/generate-with-reference in apps/api:
  forwards the JWT, lets apps/api re-verify ownership, and returns
  the generated images' mediaIds + URLs. Credits are consumed at
  the same 3/10/25 tarif as text-to-image, so a persona plan pass
  should gate this behind explicit budget rather than leaving it on
  auto-policy.

Registered in modules/index.ts + adds 'me' to the ModuleId union in
types.ts. No other wiring needed — mana-mcp's createMcpServerForUser
iterates the registry and exposes any user-space tool, so both tools
become available to Claude Desktop immediately on next deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:43:56 +02:00
Till JS
57be0f61b1 feat(website): M4 — forms + moduleEmbed
Adds two new block types and the server-side infrastructure for
untrusted input + cross-module data embedding.

Forms:
- packages/website-blocks/src/form: declarative fields (text, email,
  tel, url, textarea, number) with required / maxLength / placeholder
  per field. Honeypot hidden input in the renderer; public-mode POST
  to a same-origin SvelteKit proxy that forwards to mana-api.
- apps/api: website.submissions table (schema.ts + 0001_submissions.sql)
  + POST /public/submit/:siteSlug/:blockId. Loads the current published
  snapshot, finds the form block, validates payload against its
  declared fields (trim, type check, length cap), rejects honeypot
  submissions silently, rate-limits per IP (10 / 5 min) in-memory.
  Unknown keys are dropped — clients can only submit declared fields.
- Owner-facing: GET/DELETE /sites/:id/submissions + SubmissionsView
  component + /(app)/website/[siteId]/submissions route. Shows
  incoming submissions with status pill + payload preview + delete.
- apps/mana/.../routes/s/[siteSlug]/__submit/[blockId]/+server.ts:
  same-origin proxy so form posts don't trigger CORS and IP / user-
  agent headers are forwarded via SvelteKit's trusted getClientAddress.

M4 first-pass does NOT wire target-module delivery (contacts / notify).
Submissions stay in the inbox until owner-side tool handlers land
(M4.x). `target` enum is intentionally `['inbox']` only for now.

moduleEmbed:
- packages/website-blocks/src/moduleEmbed: source dropdown
  (picture.board | library.entries), max-items, layout (grid | list),
  optional filter object. The `resolved` field on props is populated at
  publish time by the editor-side resolver — public renderer reads it
  directly, no Dexie / API round-trip needed.
- apps/mana/.../website/embeds.ts: per-source resolvers. picture.board
  enforces `isPublic=true`; library.entries respects filter.isFavorite
  / kind / status so owners can expose a subset (e.g. "my favorites").
- buildSnapshot() walks the tree after assembly and fills in
  block.props.resolved for every moduleEmbed. Publish slower, public
  visits fast. No cross-service call at render time.

Validation:
- pnpm run validate:all: 6/6 gates green
- pnpm run check (web): 0 errors, 0 warnings
- apps/api type-check: green

Apply Postgres with:
  psql "$DATABASE_URL" -f apps/api/drizzle/website/0001_submissions.sql

Plan: docs/plans/website-builder.md (M4 shipped)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:36:52 +02:00
Till JS
7a4f8894e1 feat(website): M3 — 5 more blocks, containers, upload, themes
Expands the builder from 3 M1 blocks to 8. Containers (columns) and
media blocks (image, gallery) are the structural additions; cta and faq
round out the content coverage.

packages/website-blocks:
- image, cta, faq, columns (container), gallery — each with Zod schema,
  renderer (mode-aware for edit/preview/public), and fallback inspector.
- Block type extended with optional `children` + `renderChild` snippet
  so containers render their children through the same chrome the
  outer renderer provides (click-to-select, public-path tagging).
- themes/: 3 presets (classic light, modern dark, warm) with
  `resolveTheme` + `themeCssVars` helpers. Public layout now emits
  CSS vars via `style=` on the root; block components read
  `var(--wb-primary)` / `var(--wb-bg)` / `var(--wb-fg)` / etc.
- Registry updated; new exports + `./themes` subpath export.

apps/mana/apps/web/src/lib/modules/website:
- upload.ts: multipart POST to mana-media with `app=website` scope,
  returns { mediaId, url }. 25 MB cap, non-image rejection client-side.
- components/ImageInspector + GalleryInspector: app-side overrides
  wired to upload. Registered via `CUSTOM_INSPECTORS` in BlockInspector
  so block.type → app-side inspector, fallback to registry otherwise.
- components/SiteSettingsDialog: theme preset picker + color overrides
  for primary/bg/fg + footer text. Mounted from a ⚙ button in the
  editor's left pane.
- components/BlockRenderer: rebuilt around a byParent map + recursive
  `renderBlock` snippet so container blocks can render their children
  through the same click-to-select wrapper as top-level blocks.
- routes/s/[siteSlug]: rename `[[...path]]` → `[...path]` (SvelteKit
  treats rest segments as optional automatically — double-bracket form
  errored at sync time). +page.svelte renders snapshot trees
  recursively so published pages match the editor.

apps/api: unchanged.

Validation:
- pnpm run validate:all: all 6 gates green
- pnpm run check (web): 0 errors, 0 warnings
- apps/api type-check: green
- website-blocks tsc: green

Plan: docs/plans/website-builder.md (M3 block shipped)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:27:49 +02:00
Till JS
54a12ffd5c feat(webapp): wire isParallelSafe in Companion chat + Mission runner
Enables the M1 parallel-reads optimisation on the webapp side. Both
consumers of runPlannerLoop pass an isParallelSafe predicate derived
from the tool catalog:

  isParallelSafe: (name) =>
    AI_TOOL_CATALOG_BY_NAME.get(name)?.defaultPolicy === 'auto'

Auto-policy tools (list_tasks, get_habits, nutrition_summary, …) run
via Promise.all in batches of 10 when the LLM fans them out in one
round. Propose-policy tools — which surface to the user as Proposal
cards — stay sequential so intent ordering in the inbox is preserved
and pre-execute guardrails can reason about prior-step state.

Tests: 31 existing companion + mission tests pass unchanged; the
parallel path is exercised via the new loop.test.ts cases shipped
with the M1 commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:11:24 +02:00
Till JS
faa472be91 feat(mana-ai): first live reminder producers — token budget + retry-loop
Wires the M1 reminderChannel into the mana-ai mission runner with two
initial producers in services/mana-ai/src/planner/reminders.ts:

- tokenBudgetReminder — warns at 75% of the agent's daily cap, emits a
  stronger "wrap up NOW" message at/above 100%. Uses pretick usage +
  accumulated round usage so the warning tracks drift during a long
  plan.
- retryLoopReminder — shape is in place (round≥3 + last 2 failures),
  currently limited to the single lastCall LoopState exposes. Extends
  cleanly once LoopState carries the full failure window.

buildReminderChannel composes active producers; the tick hoists
pretickUsage24h so the channel has the baseline. Each round the loop
re-evaluates the producers, so usage drift across rounds surfaces on
the NEXT turn.

Also exports LoopState + ReminderChannel from @mana/shared-ai top-level
so consumers don't need to reach into /planner.

Tests: 13 new bun tests covering thresholds, pretick+round summing,
composition, and per-round re-evaluation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:00:04 +02:00
Till JS
e5d230e599 feat(agent-loop): M1 — policy gate + reminder channel + parallel reads
Three Claude-Code-inspired primitives for runPlannerLoop, derived from the
reverse-engineering reports in docs/reports/:

1. **Policy gate** (@mana/tool-registry) — evaluatePolicy() gates every tool
   dispatch: denies admin-scope, denies destructive tools not in the user's
   opt-in list, rate-limits per tool (30/60s default), flags prompt-injection
   markers in freetext without blocking. Wired into mana-mcp with a
   per-user rolling invocation log and POLICY_MODE env (off|log-only|enforce,
   default log-only). mana-ai uses detectInjectionMarker only — tool dispatch
   there is plan-only, so rate-limit/destructive checks don't apply yet.

2. **Reminder channel** (packages/shared-ai/src/planner/loop.ts) — new
   reminderChannel callback in PlannerLoopInput. Called once per round with
   LoopState snapshot (round, toolCallCount, usage, lastCall); returned
   strings wrap in <reminder> tags and inject as transient system messages
   into THIS LLM request only. Never pushed to messages[] — the Claude-Code
   <system-reminder> pattern that keeps the KV-cache prefix stable.

3. **Parallel reads** (loop.ts) — isParallelSafe predicate enables
   Promise.all dispatch when every tool_call in a round is parallel-safe,
   in batches of PARALLEL_TOOL_BATCH_SIZE=10. Any non-safe call downgrades
   the whole round to sequential. messages[] always appends in source
   order, never completion order, so the debug log stays linear.
   Default-off (undefined predicate) preserves pre-M1 behaviour.

Tests: 21 new in tool-registry (policy), 9 new in shared-ai (5 parallel,
4 reminder). All 74 green, type-check clean across 4 packages.

Design/plan: docs/plans/agent-loop-improvements-m1.md
Reports: docs/reports/claude-code-architecture.md,
         docs/reports/mana-agent-improvements-from-claude-code.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:56:40 +02:00
Till JS
16c8818338 feat(mcp): M1+M1.5 MCP gateway + tool-registry + shared-crypto
Foundation for autonomous Claude-driven testing. Plan:
docs/plans/mana-mcp-and-personas.md.

New packages
- @mana/tool-registry — schema-first ToolSpec<InputSchema, OutputSchema>
  with zod generics, scope ('user-space' | 'admin') and policyHint
  ('read' | 'write' | 'destructive'). sync-client helpers speak the
  mana-sync push/pull protocol directly so RLS and field-level LWW are
  preserved. MasterKeyClient fetches per-user MKs via the existing
  mana-auth GET /api/v1/me/encryption-vault/key endpoint (JWT-gated,
  ZK-aware, already audited) — no new service-key endpoint built.
  ZeroKnowledgeUserError surfaced as a typed throw.
- @mana/shared-crypto — AES-GCM-256 primitives extracted from the web
  app's $lib/data/crypto/aes.ts so the server-side tool handlers and the
  browser produce byte-for-byte identical wire format
  (enc:1:{b64(iv)}.{b64(ct)}). Web app aes.ts now re-exports from
  shared-crypto — 5 existing importers unchanged, svelte-check stays
  green.

New service
- services/mana-mcp (:3069, Bun/Hono) — MCP Streamable HTTP gateway.
  JWKS auth against mana-auth, per-user session isolation (session-id
  belongs to the user who opened it — cross-user access returns 403),
  admin-scoped tools filtered out before registration. MasterKeyClient
  cached per process with a 5-minute TTL.

11 tools registered
- habits.{create,list,update,archive}, spaces.list (plaintext, M1)
- todo.{create,list,complete}, notes.{create,search}, journal.add
  (encrypted — field lists match
  apps/mana/apps/web/src/lib/data/crypto/registry.ts verbatim)

Infra
- Port 3069 added to docs/PORT_SCHEMA.md
- services/mana-mcp/CLAUDE.md with architecture, auth model,
  tool-authoring recipe, local smoke-test steps
- Root CLAUDE.md services list updated

Type-check green across shared-crypto, mana-tool-registry, mana-mcp.
svelte-check on apps/mana/apps/web stays at 0 errors / 0 warnings.
Boot smoke verified: /health returns registry.loaded=true, unauthed
/mcp → 401, invalid-JWT /mcp → 401 with descriptive message.

Decisions locked in for later milestones (per plan D1–D10):
- Personas will be real mana-auth users (users.kind='persona'), no
  service-key bypass (D1, D2)
- Tool-registry is the SSOT; mana-ai and the legacy
  apps/api/src/mcp/server.ts get merged into it in M4 (three current
  parallel tool catalogs collapse to one)
- Persona-runner (:3070) will be a separate service using the Claude
  Agent SDK + MCP client (D5)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:18:35 +02:00
Till JS
f10a95e842 feat(mana-research): add Gemini 3.1 Pro Deep Research async providers
- New providers gemini-deep-research + gemini-deep-research-max on the
  Interactions API (preview-04-2026). Submit/poll split, tier parameter
  selects between standard (~minutes, $1–3) and max (up to 60 min, $3–7).
- Parser matches the real response shape: flat `outputs` array of
  thought|text|image items, url_citation annotations without title,
  `usage.total_input_tokens` / `total_output_tokens`.
- Route generalisation: /v1/research/async accepts `provider` with
  default 'openai-deep-research' (backward compatible) and dispatches
  to the right submit/poll pair.
- New internal service-to-service endpoint /v1/internal/research/async
  gated by X-Service-Key + X-User-Id for credit accounting. Enables
  mana-ai to drive deep-research jobs on the mission owner's wallet
  without requiring a user JWT.
- Pricing: 300 credits (standard) / 1500 credits (max). Conservative
  markup over the ~$3/$7 ceiling so the first runs can't surprise us.
- Docs: AGENT_PROVIDER_IDS + pricing + env map + auto-router stay in
  sync; CLAUDE.md Phase 3b now current; API_KEYS.md references the
  new providers under GOOGLE_GENAI_API_KEY.

Verified with a real smoke test against the Gemini API: submit + poll
both succeed, completed response parsed cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:55:30 +02:00
Till JS
52af8c0cec refactor(theming): migrate who semantic colours to theme tokens
PlayView used Tailwind palette classes for game-status feedback:

  bg-emerald-500/10 + text-emerald-300   (won)    → bg-success/10 + text-success
  bg-amber-500/10 + text-amber-300       (lost)   → bg-warning/10 + text-warning
  border-red-500/20 + bg-red-500/10 +
    text-red-300                         (error)  → border-error/20 + bg-error/10 + text-error
  placeholder-white/30 focus:border-purple-400/50 → placeholder:text-muted-foreground/60 focus:border-primary/50

Semantic status now tracks the theme (errors are red in dark, darker red
in light, etc.) instead of being fixed hex ramps.

The `bg-purple-500` / `bg-purple-500/30` / `hover:bg-purple-600` classes
on the user's chat bubble and submit buttons STAY — purple is the who
module's primary identity colour (historical-deck accent `#a855f7` is
semantically the same hue). Documented in brand-literals.md §who.

Also harden two validators against mid-rename states where git ls-files
returns paths that aren't on disk yet — both now skip unreadable files
instead of crashing the pre-commit hook (caught while migrating who).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:19:53 +02:00
Till JS
430aa30cbf refactor(theming): re-apply theme validator suite after parallel rebase
The plan-doc commits 129971ffc + 9db044178 dropped the
audit-theme-tokens → validate-theme-variables rename, the
validate-theme-tokens → validate-theme-utilities rename, the new
validate-theme-parity script, brand-literals.md, and the corresponding
package.json + lint-staged.config.js + themes.css wiring. The files
still existed on disk (git mv changes survived) but were untracked.

Restore the validator suite so `pnpm run validate:all` works again:
  - validate:theme-variables (CSS var names: --muted → --color-muted)
  - validate:theme-utilities (Tailwind: no white/N, no neutral palette)
  - validate:theme-parity    (every --color-* in :root ⇔ .dark + each
                              [data-theme="..."])

All three wired into validate:all and lint-staged. `pnpm run validate:all`
is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:07:48 +02:00
Till JS
8a991f7c39 feat(articles): M7 share-target + bookmarklet — save from anywhere
@mana/shared-pwa gains PWAShareTarget + PWAShareTargetParams types
plus ManifestConfig.share_target pass-through. createPWAConfig now
accepts an optional `shareTarget` and threads it into the generated
manifest. Other apps keep working unchanged — the field is omitted
unless set.

Web app wiring:
 - vite.config.ts passes shareTarget: { action: '/articles/add',
   method: 'GET', params: { title, text, url } } so the installed PWA
   shows up as a destination in the Android / Chromium share sheet.
 - AddUrlForm reads ?url / ?text / ?title in onMount; falls back to
   the first URL-shaped token in ?text because some senders (Chrome
   Android, WhatsApp) put the shared link there instead of ?url. When
   a URL is pre-filled the Readability preview auto-triggers, so the
   user just hits "In Leseliste speichern" to confirm.
 - New /articles/settings route hosts the bookmarklet (drag-to-
   bookmarks-bar button + copy-to-clipboard + expandable snippet
   viewer) and a short Share-Target explainer with an iOS-Safari
   caveat. Linked from the ListView via a new gear button next to
   "+ Neu speichern".

Bookmarklet form (origin-prefixed so it works across tenants):
  javascript:void(window.open('${origin}/articles/add?url='+…))

Not in scope (plan marked optional): _pendingUrls offline queue.
Share without internet shows the existing error + retry state today;
can slot in as M7b if users hit it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:03:33 +02:00
Till JS
177734a860 fix(tsconfig): unblock shared-types consumers
shared-types/src/index.ts re-exports with explicit .ts extensions
(Tailwind v4 module resolver needs them). TS 5.7 requires consumers
to opt in via allowImportingTsExtensions. The flag only type-checks
when noEmit:true; the NestJS builder also needs
rewriteRelativeImportExtensions so tsc still emits valid JS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:53:55 +02:00
Till JS
88eca8a759 feat(spaces): Spaces as workbench card + canonical /spaces route
Extract member management from /spaces/members into a reusable
workbench-card ListView so users can drop the surface into any scene.

- lib/modules/spaces/ListView.svelte — hint + invite + members + pending
  invitations, all theme-token driven
- APP_ICONS.spaces icon (three-silhouette cluster, teal→indigo)
- MANA_APPS entry id=spaces (beta tier, shared-space management)
- registerApp({ id: 'spaces' }) so the card is scene-droppable
- /spaces/+page.svelte as the new canonical route wrapper
- /spaces/members/+page.svelte kept as legacy alias
- SpaceSwitcher menu now links to /spaces

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:53:03 +02:00
Till JS
5924f4fac3 feat(articles): M6 AI tools — list / save / archive / tag / highlight
Five new entries in AI_TOOL_CATALOG (shared-ai/src/tools/schemas.ts):

  list_articles            auto     Read-only listing with status +
                                    query filter. Default hides
                                    archived; 'all' includes them.
  save_article             propose  URL → Readability → encrypted save.
                                    Delegates to articlesStore.saveFromUrl
                                    which already handles scope-aware
                                    dedupe. Duplicates surface as
                                    success:true with duplicate:true.
  archive_article          propose  setStatus('archived') after
                                    scoped existence check.
  tag_article              propose  Case-insensitive dedupe over
                                    globalTags; tagMutations.createTag
                                    fills in when missing. Junction
                                    write via articleTagOps.addTag.
  add_article_highlight    propose  Snaps to the first verbatim
                                    occurrence of `text` in the
                                    decrypted article.content. Fails
                                    cleanly when the snippet isn't
                                    found — no orphan highlights.

Policy, client executor, and server planner derive automatically from
the catalog (see root CLAUDE.md §"AI Tool Catalog") so no manual
registration in policy.ts / services/mana-ai is needed.

Skipped from the M6 plan: <AiProposalInbox module="articles" />. The
component doesn't exist in the current codebase — after the
pendingProposals-table drop in Dexie v29 the inbox surface moved to
the mission-detail cross-module view, and articles proposals show up
there automatically. Documented in docs/plans/articles-module.md.

Also updated: plan doc now marks M1–M6 as DONE with commit refs and
the next-step pointer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:46:13 +02:00
Till JS
12be75e6a6 fix(broadcast): track route paths + shared-branding tsconfig
Two fixes surfaced by the end-to-end smoke test.

1. broadcast-track.ts: inner route paths double-prefixed.
   Routes were declared as '/track/open/:token' etc, then
   mounted at '/api/v1/track', yielding '/api/v1/track/track/open/:token'
   — every tracking endpoint returned 404. Dropped the redundant
   '/track/' prefix so the full path is now
   '/api/v1/track/{open,click,unsubscribe}/:token' as the
   orchestrator + client both expect.

   Verified with live curl:
   - /track/open/BAD → 200 image/gif 42 bytes (graceful no-signal)
   - /track/click/?url missing → 400 missing url
   - /track/click?url=javascript: → 400 bad url
   - /track/click?url=https://ok + bad token → 302 graceful
   - /track/unsubscribe/BAD GET → 400 HTML
   - /track/unsubscribe/BAD POST → 400 (RFC 8058)

2. shared-branding/tsconfig.json: allowImportingTsExtensions
   missing. shared-types/src/index.ts uses explicit .ts
   imports (intentional, for Tailwind's module resolver); any
   downstream tsconfig without allowImportingTsExtensions emits
   8 errors. shared-auth already had this fix — shared-branding
   gets the same treatment. noEmit:true is set, so no rewrite
   flag needed.

   Verified: shared-branding pnpm check → 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:30:47 +02:00
Till JS
9d6a5a53a8 feat(apps): register agents + timeline as MANA_APPS + broadcast URL fix + members page chrome
Three modules move from "dedicated route only" to "first-class
apps in the launcher". After this they show up in the AppDrawer
pill, can be pinned to workbench scenes, and get a direct URL from
the app switcher.

MANA_APPS entries added:
- agents  (/agents)    — AI agent management. Icon: smiling robot head
                         with antenna dot. violet→fuchsia gradient, status
                         beta, requiredTier beta.
- timeline (/timeline) — Chronological view across modules. Icon: vertical
                         event dots with connecting axis. amber→orange,
                         status beta, requiredTier beta.

Plus: broadcast's MANA_APPS entry already existed but had no URL
override, so the auto-derived /broadcast didn't match the real route
at /broadcasts. Added an APP_URL_OVERRIDES entry mapping
id='broadcast' → '/broadcasts' so the app switcher lands the user on
the right page. Icon + module.config stay singular.

Route wiring:
- /agents previously only had /agents/templates/ as a subroute. Added
  /agents/+page.svelte that renders the existing ai-agents ListView
  (at $lib/modules/ai-agents/), so the top-level URL works from the
  AppDrawer.
- /timeline already had a root +page.svelte — no work there.
- /broadcasts already had a root +page.svelte — no work there.

/spaces/members page chrome:
- Swapped the hand-rolled header for @mana/shared-ui PageHeader with
  backHref="/", breadcrumb "Workbench › Mitglieder verwalten", and the
  space name + type as the description. Feels like a native Mana page
  now instead of an orphaned admin route.
- Dropped the ~60 lines of unused .type-chip CSS (moved the chip info
  into the PageHeader description string).
- Container bumped to 720px max-width to match other admin pages.

0 errors across 7236 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:32:41 +02:00
Till JS
3357e88a1c feat(articles): new read-it-later module — save / read / highlight
Pocket-style module for saving arbitrary web URLs, extracting readable
content server-side via @mana/shared-rss (Readability + JSDOM), and
storing it AES-GCM encrypted in IndexedDB for offline reading.

M1 skeleton: Dexie v33 (articles, articleHighlights, articleTags),
crypto registry entries, module registration, app-registry entry with
orange icon, empty-state ListView. articleTags is a pure junction
into the existing globalTags system (appId 'tags') — same pattern as
noteTags, eventTags, placeTags.

M2 URL save + reader: POST /api/v1/articles/extract (one endpoint,
not two — client caches the preview payload to avoid a double
server fetch). AddUrlForm with scope-aware dedupe, DetailView with
ReaderView typography shell (serif/sans, light/sepia/dark, size
slider), auto-tracked reading progress with scroll restore.

M3 highlights: TreeWalker-based plain-text offset resolution
(lib/offsets.ts), highlights store, floating HighlightMenu with
create + edit modes, HighlightLayer orchestrator that wraps/unwraps
highlight spans whenever highlights or htmlVersion changes. Four
colours (yellow/green/blue/pink), optional notes, click-to-edit,
dark-mode-aware overlay colours.

Drive-by: removed stale 'pendingProposals' entry from the plaintext
allowlist — the table was dropped in Dexie v29 and the allowlist
audit was flagging it as a dead entry.

Plan: docs/plans/articles-module.md. M4 (tags + filter + progress),
M5 (news:type='saved' migration), M6 (AI tools), M7 (share target),
M8 (highlights view + stats) still open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:20:23 +02:00
Till JS
75832faef7 feat(broadcast): enhanced ListView + dashboard widget + AI tools
Closes the M7/M9/M10 plan items in one pass since they share patterns.

ListView (M7)
- 4 stats cards at the top: versendet YTD, Ø Öffnungsrate, Ø Klickrate,
  Entwürfe. Same layout pattern as invoices for consistency.
- Status filter chips with live counts per status.
- Search across name + subject.
- Row now shows open-rate per-campaign when available.
- Settings gear in the header matches the invoices polish.

Dashboard widget (M10)
- BroadcastsWidget.svelte: 2x stats (sent YTD + avg open rate), next
  scheduled link, last sent link with open-rate badge. Empty state
  nudges toward creating a first campaign.
- Registered as 'broadcasts' in WIDGET_REGISTRY and the component map.
- Medium default size, no requiredBackend (reads from Dexie only;
  stats are mirrored from the last DetailView poll so no server
  round-trip for the widget).

AI tools (M9)
- 3 tools added to @mana/shared-ai's AI_TOOL_CATALOG:
  - create_campaign_draft (propose) — generates HTML body from a
    topic, lands as a draft; user picks audience + sends via UI
  - list_campaigns (auto) — id/name/subject/status/recipients
  - get_campaign_stats (auto) — rates as 0..1 floats
- broadcast/tools.ts: execute handlers with an HTML→CampaignContent
  shim (stores both html and a minimal Tiptap JSON placeholder so
  ListView renders without the editor having to remount). stripHtml
  helper derives plaintext.
- Registered in data/tools/init.ts after library.

Suggest-style tools (suggest_subject_lines) deliberately omitted —
they're pure generative and don't need an executor. The LLM can
produce subject ideas without a tool call.

Verified:
- pnpm check: 0 broadcast errors (4 pre-existing errors in articles
  module from parallel work, not mine)
- shared-ai test suite: 44/44 green (function-schema roundtrips the
  expanded catalog cleanly)
- mana-ai drift guard: 41/41 green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:27:59 +02:00
Till JS
8e677c9066 feat(ai): add Library AI tools (create / rate / status / list)
Some checks failed
CI / Build mana-sync (push) Has been cancelled
CI / Build mana-notify (push) Has been cancelled
CI / Build mana-api-gateway (push) Has been cancelled
CI / Build mana-crawler (push) Has been cancelled
CI / Build mana-media (push) Has been cancelled
CI / Build mana-credits (push) Has been cancelled
CI / Build mana-web (push) Has been cancelled
CI / Build chat-backend (push) Has been cancelled
CI / Build chat-web (push) Has been cancelled
CI / Build todo-backend (push) Has been cancelled
CI / Build todo-web (push) Has been cancelled
CI / Build calendar-backend (push) Has been cancelled
CI / Build calendar-web (push) Has been cancelled
CI / Build clock-web (push) Has been cancelled
CI / Build contacts-backend (push) Has been cancelled
CI / Build contacts-web (push) Has been cancelled
CI / Build presi-web (push) Has been cancelled
CI / Build storage-backend (push) Has been cancelled
CI / Build storage-web (push) Has been cancelled
CI / Build telegram-stats-bot (push) Has been cancelled
CI / Build food-backend (push) Has been cancelled
CI / Build food-web (push) Has been cancelled
CI / Build skilltree-web (push) Has been cancelled
Docker Validate / Build calendar-web (push) Has been cancelled
Docker Validate / Build quotes-web (push) Has been cancelled
Docker Validate / Build todo-backend (push) Has been cancelled
Docker Validate / Build todo-web (push) Has been cancelled
Docker Validate / Build mana-auth (push) Has been cancelled
Docker Validate / Build mana-sync (push) Has been cancelled
Docker Validate / Build mana-media (push) Has been cancelled
Library module had no AI tool coverage post the M1 skeleton. Adds
four tools so the agent can curate the reading/watch list alongside
other modules:

- create_library_entry (propose) — books/movies/series/comics with
  creators, year, status, rating, tags, genres. Default status
  "planned" covers the most common flow ("add to watchlist").
- update_library_entry_status (propose) — status transitions
  planned → active → completed (also paused / dropped). Auto-
  stamps startedAt/completedAt on the matching transitions so the
  existing Dexie projections (streaks, progress) fire correctly.
- rate_library_entry (propose) — 1-5 stars, thin wrapper over the
  store's rate() method.
- list_library_entries (auto) — id/kind/title/status/rating/year,
  filterable by kind + status.

Coverage table in apps/mana/CLAUDE.md updated (+library, +invoices
row that wasn't listed). Total now 67 tools / 21 modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:23:19 +02:00
Till JS
fabd45bd87 feat(spaces): move Space-Switcher into the PillNav start slot
Repositions the switcher from its floating spot in the top right of
the workbench into the bottom-fixed PillNav so it sits with the rest
of the nav chrome. Matches how every other persistent nav control
(app switcher, AI tier, sync status) lives in the PillNav.

Mechanics:
- @mana/shared-ui PillNavigation gains a `startSlot?: Snippet` prop
  rendered inside .pill-nav-container, before AppDrawer. Generic slot
  — any host component drops in.
- (app)/+layout.svelte passes the existing <SpaceSwitcher /> as the
  snippet (authenticated only). The old .space-bar wrapper above
  <main> is removed along with its CSS.
- SpaceSwitcher trigger is restyled to match Pill conventions: pill
  radius 999px, 32px height, 0.8125rem text, tighter paddings, shorter
  name cap (7rem). Visually merges with the surrounding Pills.
- Dropdown menu flips upward (bottom: calc(100% + 4px)) because the
  PillNav is position:fixed bottom — opening downward would land
  off-screen.

Type-check: 0 errors across 7200 files.
Scope tests: 10/10 pass.
Go tests + bun tests (mana-auth): all pass.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:54:41 +02:00
Till JS
264c4c3087 feat(broadcast): M2 audience + editor + compose wizard
Core authoring loop works end-to-end: create a draft, filter an audience
from contacts, write content in a rich-text editor, save. Send is still
stubbed (M4 gets mana-mail's bulk endpoint).

Dependencies
- @tiptap/core + starter-kit + image + link + placeholder (3.22.4)
- shared-auth/tsconfig: allowImportingTsExtensions +
  rewriteRelativeImportExtensions so tsc accepts shared-types' explicit
  .ts imports. Was blocking EVERY pnpm install postinstall hook in the
  repo — fixing it here unblocks everyone, not just broadcast.

Module
- queries.ts: useAllCampaigns / useAllTemplates with scoped-db + crypto,
  computeStats (counts + open/click rates per year), formatRate helper
- stores/settings.svelte.ts: singleton with ensure/get/update, same
  pattern as invoices settings
- stores/campaigns.svelte.ts: createCampaign (pulls sender defaults from
  settings), updateCampaign / updateContent / updateAudience (draft-only
  edit guard), schedule / cancel / duplicate / deleteCampaign, plus an
  applyServerStatus hook for M4's orchestrator to write back progress

Audience
- audience/segment-builder.ts: pure matchContact / filterAudience /
  countAudience / describeAudience. AND semantics across filters. Drops
  contacts without a usable email so estimatedCount never inflates.
- audience/AudienceBuilder.svelte: tag-chip UI with live count, dedup
  (same tag twice toggles op instead of stacking), greys out already-
  referenced tags in the picker

Editor
- editor/Editor.svelte: Tiptap wrapper with onMount / onDestroy, toolbar
  (bold/italic/H1/H2/lists/link/image), bind on content (Tiptap JSON +
  derived HTML/plaintext). Image upload reuses invoices' mana-media
  uploader pragmatically; extract to @mana/shared-uload later.

Compose wizard
- views/ComposeView.svelte: 4-step stepper (Audience → Content →
  Preflight → Send). Steps 3+4 stubbed pragmatically. Autosave on step
  change so content survives navigation. Step 3/4 gated on earlier
  readiness so the user can't skip.

Routes
- /broadcasts/new: bootstraps a draft + redirects to edit
- /broadcasts/[id]/edit: guarded on status=='draft'
- ListView: working "+ Neue Kampagne" button, rows open edit

Tests
- 17 unit tests for segment-builder covering tag has/not-has/AND,
  email eq/contains case-insensitivity, no-email filtering, no-mutation,
  describeAudience resolver + fallback

Plan: docs/plans/broadcast-module.md §M2.
Next: M3 HTML-render with email-safe inlining + preview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:41:09 +02:00