My Cosmos — Celestial Companion Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the single "sprout" companion with My Cosmos — an opt-in celestial companion (born a nebula, grows into a chosen Star or Moon) with name + color customization, a re-themed "Stardust" earn economy, buyable adornments, a create prompt, and an expanded setting.
Architecture: SDK-up. Reshape the Companion model + CompanionService + StoragePort/adapters (one compile unit), keeping the SP‑C economy tables/triggers intact (user-facing rename only). Refactor the sprout _CreaturePainter into a CompanionType → painter seam that reuses the already-decoupled face + idle/bounce + ambient + float. Add the opt-in create prompt, create flow, expanded My Cosmos setting, and a clean-cutover schema migration.
Tech Stack: Flutter 3.44 / Dart 3.9 (FVM: fvm flutter test), bloc/cubit, Drift (local) + Supabase (cloud) adapters, CustomPainter (no Rive), flow_test harness.
Global Constraints
- One data path: Bloc → Repository → Client facade → Service → Adapter; presentation never imports
drift/supabase. - SP‑C invariants preserved in service AND schema: Stardust sourced only from
ChoreCompletion, never convertible to tokens, append-onlycompanion_earn/companion_ledger, zero-floor debit (service check + SQL trigger), self-only mutation, parental read. - DB economy columns keep names (
dewdrops_amount,dewdrops_cost); mapped to SDKstardust*at the adapter boundary — do NOT touchcredit_companion_earn/enforce_companion_zero_floor. - Custom paint only (no Rive/asset pipeline); every visual is golden-testable.
- FVM only (
fvm flutter testfor app AND packages — all useflutter_test, neverfvm dart test). No brand strings in package/class/file names. All user copy viaStrings. Typed errors only (on <SpecificException>; never barecatch, never catchError). - Companion is per-member; device-local enable via
SharedPrefsEffectProvider; keyed on authenticated member id. - Growth stage is a pure function of lifetime completions (thresholds 0 / 10 / 30), never regresses.
- Migrations are file-only under
infra/supabase/migrations/; prod apply is DEPLOY-GATED (owner-authorized). Model tests run against the in-memory/fake adapters. - Flow tests: ONE
flowTestper EPIC+FEATURE SET carrying MULTIPLE stories. Baselines must not drop (SDK ~1214, app ~847 at start).
File Structure
client_sdk (models/service/adapters):
packages/client_sdk/lib/src/models/companion.dart— MODIFY:CompanionTypeenum, reshapeCompanion(type,colorKey,name,adornments),CompanionViewstardust rename, celestialkCompanionPalette,kCompanionAdornmentscatalog (replaceskCompanionCosmetics),stageNamesFor.packages/client_sdk/lib/src/services/companion_service.dart— MODIFY: not-created sentinel,createCompanion,setType,setColor, renamedpurchaseAdornment/equipAdornment, stardust projection.packages/client_sdk/lib/src/adapters/adapter.dart— MODIFY:StoragePortcompanion verb signatures (field renames only).- 5 adapters (in-memory, fake, cached, drift, supabase) — MODIFY: field renames +
color_key/type/adornmentsmapping.
design_system (visuals):
packages/design_system/lib/src/graphics/companion/companion_type.dart— CREATE:DsCompanionTypeenum +companionBodyPainter.packages/design_system/lib/src/graphics/companion/nebula_painter.dart,star_painter.dart,moon_painter.dart— CREATE.packages/design_system/lib/src/graphics/companion/adornment_painter.dart— CREATE: ring/orbit/trail/aurora overlays.packages/design_system/lib/src/graphics/ds_companion_creature.dart— MODIFY: type-aware; delegates body tocompanionBodyPainter; keeps face + idle/bounce.packages/design_system/lib/src/graphics/ds_cosmic_dim.dart— CREATE (replacesds_room_tidiness.dart, which is deleted).packages/design_system/lib/src/graphics/ds_companion_scene.dart— MODIFY: type param, stardust float, cosmic-dim.
app (state + UI):
app/lib/inside/blocs/companion/*— MODIFY: cubit + state (not-created, create/setType/setColor).app/lib/inside/routes/authenticated/home/widgets/meet_companion_card.dart— CREATE: the Today prompt.app/lib/inside/routes/authenticated/cosmos/create_companion_sheet.dart— CREATE: create flow.app/lib/inside/routes/authenticated/cosmos/my_cosmos_page.dart— CREATE: expanded setting.app/lib/inside/routes/authenticated/shell/companion_layer.dart+shell/page.dart— MODIFY: opt-in mount gate.app/lib/inside/i18n/slang source (+ generatedstrings.dart) — MODIFY: My Cosmos copy.
infra:
infra/supabase/migrations/20260725000100_my_cosmos.sql— CREATE.
Task 1: Model — CompanionType, reshaped Companion, palette + adornments catalog
Files:
- Modify:
packages/client_sdk/lib/src/models/companion.dart - Test:
packages/client_sdk/test/companion_model_test.dart
Interfaces produced:
-
enum CompanionType { nebula, star, moon }(planet/comet reserved — omit until fast follow). -
class Companion { final String memberId, householdId; final CompanionType type; final String? name; final String colorKey; final List<String> adornments; final DateTime? createdAt; Companion copyWith({CompanionType? type, String? name, String? colorKey, List<String>? adornments}); } -
const List<String> kCompanionPalette=['gold','ember','rose','lilac','teal','aqua','ice','silver'];const String kDefaultColorKey = 'gold'; -
class CompanionAdornment { final String id; final String displayName; final int stardustCost; }+const List<CompanionAdornment> kCompanionAdornments(idsring,moonlet,trail,aurora) +CompanionAdornment? companionAdornmentById(String id). -
List<String> stageNamesFor(CompanionType)→ star['Mote','Spark','Starling'], moon['Sliver','Crescent','Moon'], nebula['Nebula','Nebula','Nebula']. -
CompanionViewfields renamed:dewdropsEarned→stardustEarned,dewdropsSpent→stardustSpent,dewdropsBalance→stardustBalance,ownedCosmeticIds→ownedAdornmentIds; ADDbool isCreated; ADDCompanionView.notCreated({required memberId, required householdId}). -
Keep
CompanionGrowthStage,stageFor, messiness enums,CompanionEarnEntry,CompanionLedgerEntry(itscosmeticIdfield name stays; it maps to thecosmetic_idcolumn). -
Step 1: Write failing tests in
companion_model_test.dart:
import 'package:client_sdk/client_sdk.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('Companion defaults to nebula type + default color', () {
const c = Companion(memberId: 'm', householdId: 'h', type: CompanionType.nebula, colorKey: kDefaultColorKey);
expect(c.type, CompanionType.nebula);
expect(c.colorKey, 'gold');
expect(c.adornments, isEmpty);
});
test('copyWith swaps type + preserves rest', () {
const c = Companion(memberId: 'm', householdId: 'h', type: CompanionType.nebula, name: 'Sparky', colorKey: 'teal');
final s = c.copyWith(type: CompanionType.star);
expect(s.type, CompanionType.star);
expect(s.name, 'Sparky');
expect(s.colorKey, 'teal');
});
test('palette has 8 keys incl gold', () {
expect(kCompanionPalette.length, 8);
expect(kCompanionPalette, contains('gold'));
});
test('adornments catalog has the four sinks with positive cost', () {
expect(kCompanionAdornments.map((a) => a.id).toSet(), {'ring', 'moonlet', 'trail', 'aurora'});
expect(kCompanionAdornments.every((a) => a.stardustCost > 0), isTrue);
expect(companionAdornmentById('ring'), isNotNull);
expect(companionAdornmentById('nope'), isNull);
});
test('stage names are per-type', () {
expect(stageNamesFor(CompanionType.star), ['Mote', 'Spark', 'Starling']);
expect(stageNamesFor(CompanionType.moon), ['Sliver', 'Crescent', 'Moon']);
});
test('notCreated sentinel has isCreated false', () {
final v = CompanionView.notCreated(memberId: 'm', householdId: 'h');
expect(v.isCreated, isFalse);
});
}
- Step 2: Run — expect FAIL (types not defined):
cd packages/client_sdk && fvm flutter test test/companion_model_test.dart - Step 3: Implement the reshape: replace
kCompanionSpecies/specieswithCompanionType; renameequipped→adornments; addcolorKey+kCompanionPalette+kDefaultColorKey; replacekCompanionCosmetics/CompanionCosmetic/CompanionCosmeticSlotwithkCompanionAdornments/CompanionAdornment; addstageNamesFor; rename theCompanionViewdewdrop fields → stardust + addisCreated+CompanionView.notCreated. - Step 4: Run — expect PASS.
- Step 5: Commit
feat(sdk): reshape Companion model to celestial types + palette + adornments.
Task 2: StoragePort + 5 adapters — field renames (one compile unit)
Files:
- Modify:
packages/client_sdk/lib/src/adapters/adapter.dart(port) + the 5 adapters. Locate withgrep -rl "getCompanion\|insertCompanion\|updateCompanion" packages/client_sdk packages/client_sdk_testing. - Test:
packages/client_sdk/test/companion_port_test.dart(+ existing adapter tests recompile).
Interfaces consumed: Task 1 Companion/CompanionType.
Interfaces produced: StoragePort companion verbs unchanged in shape; Drift + Supabase mappers read/write type(text), color_key(text), adornments(text[]) columns and map dewdrops_amount/dewdrops_cost → stardust* in the earn/ledger DTOs.
- Step 1: Write failing test:
test('in-memory adapter round-trips type + colorKey + adornments', () async {
final a = InMemoryStorageAdapter();
const c = Companion(memberId: 'm', householdId: 'h', type: CompanionType.moon, colorKey: 'ice', adornments: ['ring']);
await a.insertCompanion(c);
final got = await a.getCompanion('m');
expect(got!.type, CompanionType.moon);
expect(got.colorKey, 'ice');
expect(got.adornments, ['ring']);
});
- Step 2: Run — expect FAIL (compile error:
species/equippedgone). - Step 3: Implement the rename through the port + all 5 adapters. In-memory/fake: store new fields. Cached: pass-through. Drift: rename companion columns in the Drift table (
species→type,equipped→adornments, addcolorKey), regenerate with--build-filterscoped to the companion.g.dartONLY, then restore any clobbered hand-maintained.g.dart/.gr.dartfrom HEAD (Drift-regen rule). Supabase: maptype/color_key/adornments; earn/ledger DTO mapsdewdrops_amount→stardustAmount,dewdrops_cost→stardustCost. - Step 4: Run the companion adapter/port tests +
fvm flutter analyze— PASS/clean. - Step 5: Commit
feat(sdk): thread celestial fields through StoragePort + 5 adapters.
Task 3: CompanionService — opt-in create, setType/setColor, adornments
Files:
- Modify:
packages/client_sdk/lib/src/services/companion_service.dart - Test:
packages/client_sdk/test/companion_service_test.dart
Interfaces produced:
-
getCompanion(memberId)→ returnsCompanionView.notCreated(...)when no row (NO auto-insert). -
createCompanion(memberId, {required CompanionType type, required String colorKey, String? name})— inserts; throwsValidationExceptionon nebula type, empty/too-long name, or unknown colorKey. -
setType(memberId, type)— throwsValidationExceptionon nebula; stage derived so progress preserved. -
setColor(memberId, colorKey)— throwsValidationExceptionon unknown key. -
renameCompanion(memberId, name)— unchanged rule. -
purchaseAdornment(memberId, adornmentId)/equipAdornment(...)— renamed cosmetic verbs; same zero-floor/append-only/ownership overkCompanionAdornments+ Stardust balance. RenameUnknownCosmeticException→UnknownAdornmentException,CosmeticAlreadyOwnedException→AdornmentAlreadyOwnedException,CosmeticNotOwnedException→AdornmentNotOwnedException. KeepInsufficientDewdropsException(internal name). -
Step 1: Write failing tests (FakePort; seed household+member; helper to seed N completions for stage):
test('getCompanion returns not-created sentinel before create', () async {
final v = await service.getCompanion('kid');
expect(v.isCreated, isFalse);
});
test('createCompanion persists type+color+name', () async {
final v = await service.createCompanion('kid', type: CompanionType.star, colorKey: 'gold', name: 'Sparky');
expect(v.isCreated, isTrue);
expect(v.companion.type, CompanionType.star);
});
test('createCompanion rejects nebula type', () async {
await expectLater(() => service.createCompanion('kid', type: CompanionType.nebula, colorKey: 'gold'),
throwsA(isA<ValidationException>()));
});
test('setType preserves growth (stage derived from completions)', () async {
await service.createCompanion('kid', type: CompanionType.star, colorKey: 'gold');
await seedCompletions('kid', 10); // stage two
final v = await service.setType('kid', CompanionType.moon);
expect(v.companion.type, CompanionType.moon);
expect(v.stage, CompanionGrowthStage.sprout);
});
test('purchaseAdornment enforces stardust zero-floor', () async {
await service.createCompanion('kid', type: CompanionType.star, colorKey: 'gold');
await expectLater(() => service.purchaseAdornment('kid', 'aurora'),
throwsA(isA<InsufficientDewdropsException>()));
});
- Step 2: Run — expect FAIL.
- Step 3: Implement: remove create-on-read (return
CompanionView.notCreated); addcreateCompanion/setType/setColor; rename cosmetic verbs → adornment verbs overkCompanionAdornments; keep_assertActorSelf/_assertActorMayReadgates + the earn/ledger reads + zero-floor + append-only checks unchanged. - Step 4: Run — expect PASS.
- Step 5: Commit
feat(sdk): opt-in create + celestial verbs on CompanionService.
Task 4: App repository + cubit — not-created state, create/setType/setColor
Files:
- Modify:
app/lib/outside/repositories/companion/companion_repository.dart,app/lib/inside/blocs/companion/*(cubit + state). - Test:
app/test/blocs/companion_cubit_test.dart
Interfaces produced: CompanionCubit: load(), create({type,colorKey,name}), setType(type), setColor(colorKey), rename(name), purchase(id), equip(id), setEnabled(bool). State carries CompanionView? view (with view.isCreated).
- Step 1: Failing bloc test:
load()on a fresh member emitsview.isCreated == false;create(...)emitsisCreated == truewith the chosen type; a guarded-failure verb emits a failure state. - Step 2: Run — expect FAIL.
- Step 3: Implement repository forwarders (create/setType/setColor/purchaseAdornment/equipAdornment) + cubit events, mapping
DomainRuleException/ValidationExceptionto a failure state (existing_runGuardedpattern). - Step 4: Run — PASS.
- Step 5: Commit
feat(app): companion cubit — opt-in create + celestial verbs.
Task 5: DS — CompanionType → painter seam (nebula/star/moon)
Files:
- Create:
packages/design_system/lib/src/graphics/companion/companion_type.dart,nebula_painter.dart,star_painter.dart,moon_painter.dart. - Modify:
packages/design_system/lib/src/graphics/ds_companion_creature.dart(delegate body tocompanionBodyPainter; keep face + idle-bob + happy-bounce exactly). - Test:
packages/design_system/test/golden/companion_body_golden_test.dart
Interfaces produced:
-
enum DsCompanionType { nebula, star, moon }. -
CustomPainter companionBodyPainter({required DsCompanionType type, required int stage, required Color body, required Color glow})— draws ONLY body+glow (no face); the creature widget overlays the shared face. -
Star: stage1 glowing disc → stage2 4-point sparkle → stage3 5-point star + 2 orbit sparkles. Moon: crescent-mask fractions
[0.22, 0.42, 1.0]+ craters at stage≥2 (reuse the launch-icon crescent math). Nebula: 3-lobe blurred cloud. -
Step 1: Write golden tests pumping
DsCompanionCreature(type:..., stage:...)for nebula + star(1..3) + moon(1..3) withmatchesGoldenFile(...)(reduced-motion static pose). -
Step 2: Author baselines with
fvm flutter test --update-goldens ...; then run WITHOUT to confirm PASS (this is the RED→GREEN for custom-paint visuals). -
Step 3: Implement the painters + factory; refactor
DsCompanionCreatureto keep itsAnimationControllers (idle/bounce) + face-drawing but callcompanionBodyPainterby type;tint/glowstay parameterized. -
Step 4: Run goldens — PASS;
fvm flutter analyzeclean. -
Step 5: Commit
feat(ds): companion type→painter seam (nebula/star/moon) reusing face+anim.
Task 6: DS — adornment overlays + cosmic-dim ambient + stardust float
Files:
- Create:
packages/design_system/lib/src/graphics/companion/adornment_painter.dart,ds_cosmic_dim.dart. - Delete:
packages/design_system/lib/src/graphics/ds_room_tidiness.dart. - Modify:
ds_companion_scene.dart(type param; compose adornments over the body; swap tidiness→cosmic-dim; rename dewdrop→stardust float). - Test:
packages/design_system/test/golden/companion_scene_golden_test.dart
Interfaces produced:
-
CustomPainter adornmentPainter({required List<String> equipped, required Color accent})drawingring/moonlet/trail/auroraas type-agnostic overlays. -
class DsCosmicDim extends StatelessWidget { const DsCosmicDim({required int tier, double height}); }— tier 0 full twinkles, tier 1 fewer, tier 2 sparse + softer glow (capped, deterministic scatter for goldens). -
DsCompanionScene({required DsCompanionType type, required int stage, required int dimTier, Color? tint, List<String> adornments = const [], int reactTick = 0, int? stardustDelta, VoidCallback? onTap, double height}). -
Step 1: Golden tests for each adornment on a star body + the three cosmic-dim tiers + the "+N" stardust float.
-
Step 2: Author goldens (
--update-goldens), verify without. -
Step 3: Implement overlays +
DsCosmicDim(port the deterministic-scatter approach fromds_room_tidiness.dartbefore deleting it) + scene wiring + float rename. -
Step 4: Run goldens — PASS.
-
Step 5: Commit
feat(ds): adornment overlays + cosmic-dim ambient + stardust float.
Task 7: Shell mount gate — opt-in (created + enabled)
Files:
- Modify:
app/lib/inside/routes/authenticated/shell/companion_layer.dart(mapview→DsCompanionScene; rename dewdrop→stardust mapping),app/lib/inside/routes/authenticated/shell/page.dart(mount only whencompanionEnabledFor(memberId)ANDview?.isCreated == true). - Test:
app/test/widget/companion_layer_test.dart
Interfaces consumed: Task 4 cubit state (view.isCreated), Task 5/6 scene.
- Step 1: Update the widget test: layer renders NOTHING when
view.isCreated == falseeven if enabled; renders the scene when created + enabled; unmounts when disabled. (Update the ADR-2026-07-20 default-on tests: default is now opt-in/off-until-created — this is an intentional inversion, not green-forcing.) - Step 2: Run — expect FAIL on the new not-created assertion.
- Step 3: Implement the gate + view→scene mapping: type from
view.companion.type, stage index fromview.stage, adornments fromview.companion.adornments, dim tier fromview.messiness, tint resolved fromcolorKey(add acolorForCompanionKey(String, ColorTokens)helper in DS or app). - Step 4: Run — PASS.
- Step 5: Commit
feat(app): opt-in companion mount gate (created + enabled).
Task 8: "Meet your companion" Today prompt + create flow
Files:
- Create:
app/lib/inside/routes/authenticated/home/widgets/meet_companion_card.dart,app/lib/inside/routes/authenticated/cosmos/create_companion_sheet.dart. - Modify:
app/lib/inside/routes/authenticated/home/page.dart(render the card whenview.isCreated == falseAND not dismissed this session),SharedPrefsEffectProvider(add a session-scopedcompanionPromptDismissedFor(memberId)+ reusesetCompanionEnabled(memberId,false)on dismiss). - Test:
app/test/flows/my_cosmos_test.dart(stories: create-from-prompt, dismiss-keeps-off).
Interfaces produced: MeetCompanionCard with static keys (MeetCompanionCard.create, MeetCompanionCard.dismiss); showCreateCompanionSheet(context) → drives cubit.create(type,colorKey,name).
- Step 1: Failing flow stories — card visible when not created; tap Create → sheet → pick Star + color + name → Save → card gone + companion mounts; separately tap Dismiss → card gone + companion stays off.
- Step 2: Run — expect FAIL.
- Step 3: Implement the card (dismissible; nebula preview via
DsCompanionCreature(type: DsCompanionType.nebula)) + the create sheet (typeDsSegmented[Star/Moon], color palette swatches,DsTextFieldname,DsButtonSave). Copy viaStrings. - Step 4: Run — PASS.
- Step 5: Commit
feat(app): Meet-your-companion prompt + create flow.
Task 9: "My Cosmos" setting page
Files:
- Create:
app/lib/inside/routes/authenticated/cosmos/my_cosmos_page.dart(+ auto_route entry; keep the route name generic — no brand string). - Modify: the More/Admin hub to add a "My Cosmos" entry.
- Test:
app/test/widget/my_cosmos_page_test.dart+ a settings-path flow story inmy_cosmos_test.dart.
Interfaces produced: MyCosmosPage with: enable toggle, name editor, color palette, type re-choose (→ nebula re-form via cubit.setType), ambient-intensity DsSegmented (full/subtle/off), adornments tray (buy/equip via Stardust), read-only stage label (stageNamesFor(type)[stageIndex]) + Stardust balance.
- Step 1: Failing widget tests — toggling enable calls
setCompanionEnabled; changing color callscubit.setColor; re-choosing type callscubit.setType; buying an affordable adornment callscubit.purchase. - Step 2: Run — expect FAIL.
- Step 3: Implement the page from DS atoms (
DsSegmented,DsSwitch, palette swatches,DsTextField, adornment tiles previewingadornmentPainter). Copy viaStrings. - Step 4: Run — PASS.
- Step 5: Commit
feat(app): expanded My Cosmos setting page.
Task 10: Schema migration (clean cutover) + Strings + whole-suite green
Files:
- Create:
infra/supabase/migrations/20260725000100_my_cosmos.sql - Modify:
app/lib/inside/i18n/slang source (My Cosmos / Stardust / stage / adornment copy) → regeneratestrings.dart. - Test: full app + SDK suites; the consolidated
my_cosmos_test.dartflow (all stories).
Migration (file-only; prod apply DEPLOY-GATED):
-- 20260725000100_my_cosmos.sql — celestial companion cutover (pre-users; clean reset).
alter table public.member_companion drop constraint if exists member_companion_species_check;
alter table public.member_companion rename column species to type;
alter table public.member_companion alter column type set default 'nebula';
alter table public.member_companion add column if not exists color_key text not null default 'gold';
alter table public.member_companion rename column equipped to adornments;
-- clean slate (no real users):
truncate table public.member_companion, public.companion_earn, public.companion_ledger restart identity cascade;
- Step 1: Write the migration file (above). Do NOT apply to prod (owner-gated).
- Step 2: Add all Strings, regenerate slang,
fvm flutter analyzeclean. - Step 3: Finalize the ONE
my_cosmos_test.dartflowTest with 5 stories (create-from-prompt / dismiss-keeps-off / grow-a-stage / re-choose-type / buy-an-adornment) + the settings-path story. - Step 4: Run FULL suites:
cd packages/client_sdk && fvm flutter test, thencd app && fvm flutter test. Expect green; baselines not dropped. - Step 5: Commit
feat: My Cosmos migration + strings + flow tests.
Self-Review (author checklist — completed)
Spec coverage: opt-in create (T3,T7,T8) · types + re-choose (T1,T3,T5) · growth/stage names (T1,T5) · Stardust rename + economy intact (T1,T2,T3) · color palette (T1,T8,T9) · adornments sink (T1,T3,T6,T9) · type→painter seam reusing face/anim/float (T5,T6) · cosmic-dim ambient (T6) · create prompt + flow (T8) · My Cosmos setting (T9) · schema cutover + truncate (T10) · adapters one-compile-unit (T2) · flow tests (T8,T9,T10). All spec sections mapped.
Placeholder scan: each task carries concrete signatures, test code, and exact files. Golden tasks (T5,T6) use --update-goldens authoring as their RED→GREEN (correct for custom-paint visuals; the existing companion golden test uses the same pattern).
Type consistency: CompanionType/DsCompanionType, colorKey, adornments, stardust*, isCreated, createCompanion/setType/setColor/purchaseAdornment/equipAdornment used consistently across tasks. DB columns keep dewdrops_*, mapped at the adapter (T2) — stated in Global Constraints.
Deviation flagged for the reviewer: existing companion tests assert create-on-read + default-on (ADR 2026-07-20); T3/T7 intentionally invert these to opt-in and UPDATE those tests — not a green-force. The final whole-branch review should confirm no economy/RLS coverage was lost in the rename.