54 KiB
LifeOps Extraction — Master Execution Plan (owner-directed)
Authoritative, globally-sequenced execution plan to decompose
@elizaos/plugin-personal-assistant(PA / "LifeOps", ~137k LOC,plugins/plugin-personal-assistant/src). Synthesizes four area plans into one dependency-correct sequence. This OVERRIDES the earlier "keep the spine in the hub" recommendation inlifeops-decomposition-plan.md.Owner directive: extract the scheduling spine → new
plugin-scheduling; extract reminders → newplugin-reminders(depends on scheduling); remove the overview view; complete all cleanup follow-ups.Architecture invariants live in
../README.md/ rootAGENTS.md. The proven template isplugin-finances(plugins/plugin-finances/CLAUDE.md+plugins/plugin-finances/src/services/migration.ts). Replicate it.
0. Verified ground truth (read before touching anything)
Every claim below was checked against the actual source.
| Fact | Evidence (file:line) |
|---|---|
Spine tree lives under lifeops/, not src/scheduled-task/ |
plugins/plugin-personal-assistant/src/lifeops/scheduled-task/ (18 files, 6892 LOC) |
runner.ts is storage-agnostic; its only out-of-tree import is import type { DispatchResult } |
lifeops/scheduled-task/runner.ts:17 (type-only) |
types.ts/gate-registry.ts/completion-check-registry.ts/escalation.ts/consolidation-policy.ts/state-log.ts/index.ts import only @elizaos/core + each other |
per-file grep, all clean |
due.ts + next-fire-at.ts import only ../registries/anchor-registry.js |
grep |
scheduler.ts is the coupled tick: imports ../owner/fact-store.js, ../pending-prompts/store.js, ../registries/anchor-registry.js, ../repository.js |
grep |
runtime-wiring.ts (the ONLY hardwire point) imports 9 PA modules + @elizaos/agent + @elizaos/app-core |
runtime-wiring.ts:11-30 |
new LifeOpsRepository(runtime) is constructed in wiring (line 82) and in scheduler.ts (line 98) |
grep |
serviceType literal is "lifeops_scheduled_task_runner" |
scheduled-task/service.ts:32,47 |
processDueScheduledTasks exported at |
scheduler.ts:88; news up repo (98), reads owner facts (108), anchors (113), records pending prompt (267) |
processScheduledWork interleaves circadian/reminders/workflows/scheduled-tasks |
service-mixin-reminders.ts:5062; calls processReminders (5190) + processDueScheduledTasks (5204) |
processReminders (4995), processDueReminderDeliveries (4611), processDueReminderReviewJobs (3140) |
service-mixin-reminders.ts |
| Reminder-owned repo methods: createReminderPlan(3126) updateReminderPlan(3146) deleteReminderPlan(3159) getReminderPlan(3168) listReminderPlansForOwners(3184) createReminderAttempt(5178) listReminderAttempts(5211) listDueReminderReviewAttempts(5241) claimDueReminderReviewAttempts(5285) updateReminderAttemptOutcome(5320) upsertEscalationState(5906) getActiveEscalationState(5955) resolveEscalationState(5971) listRecentEscalationStates(5983) deleteAllEscalationStates(5998) | lifeops/repository.ts |
| Cross-domain spine reads delivery needs: listActiveDefinitions(2723) listOccurrenceViewsForOverview(2898) listChannelPolicies(3854) getChannelPolicy(3865) listCalendarEvents(4320) readCircadianState(6781) getScheduleMergedState(7046) | lifeops/repository.ts |
Spine tables in app_lifeops: life_task_definitions(88) life_task_occurrences(128) life_scheduled_tasks(1417) life_scheduled_task_log(1473) |
lifeops/schema.ts |
Reminder tables in app_lifeops: life_reminder_plans(225) life_reminder_attempts(247) life_escalation_states(834) |
lifeops/schema.ts |
Legacy overview view registered across retired modalities, id "lifeops", component LifeOpsPageView |
historical src/plugin.ts cleanup target |
Overview route GET /api/lifeops/overview → service.getOverview() |
src/routes/plugin.ts:331; src/routes/lifeops-routes.ts:2285,2298 |
VIEW_ACTION_MAP critical fact: OWNER_REMINDERS/OWNER_ALARMS/OWNER_ROUTINES map to the goals view; the lifeops view maps ONLY to PERSONAL_ASSISTANT |
packages/agent/src/runtime/view-action-affinity.ts:165,167 |
Core stub StubScheduledTaskRunner is DEAD — only 5 self-refs in its own runner.ts, exported via core/index.ts:34 |
grep repo-wide |
Goals dual writers confirmed: PA repo 10 refs + goals repo 10 refs to life_goal_* |
grep |
Calendar dual writers confirmed: PA repo 21 refs + calendar repo 11 refs to life_calendar_* |
grep |
Goals dead schema: routinesTable(42) remindersTable(61) alarmsTable(84) checkinsTable(106) — app_goals.{routines,…} NEVER queried |
plugins/plugin-goals/src/db/schema.ts |
| pending-prompts / global-pause / handoff stores are cache-only (no SQL) | lifeops/{pending-prompts,global-pause,handoff}/store.ts |
approval_requests table is owned by plugin-sql (packages/plugin-sql/src/schema/approvalRequests.ts), public schema — NO data migration |
grep; lifeops/approval-queue.ts raw SQL has no schema prefix |
app_blocker.block_rules (plugin-blocker) is registered-but-NEVER-queried; life_block_rules (PA) IS live |
grep |
Finances precedent is REAL and mirrors every step: delegation, carve, non-destructive FinancesMigrationService, ensureLifeOpsFinancesPluginRegistered, action-stays-in-PA |
plugin-finances/CLAUDE.md; migration.ts:31-41; lifeops/schema.ts:311-315; PA plugin.ts:569-572 |
Template invariants (from finances, applied throughout):
- New plugin MUST NOT import PA. Gate:
rg "@elizaos/plugin-personal-assistant" plugins/<p>/src→ comments only. - Move dependency-clean core first; inject the PA-coupled bits.
- Schema carve = focused plugin declares its own
pgSchema("app_X")with verbatim table names; a non-destructiveMigrationService(to_regclass guard + target-empty check +INSERT…SELECT NOT EXISTS+ never-drop-source) copiesapp_lifeops.*→app_X.*; raw SQL repointed via aTARGET_SCHEMAconst; PA'sLifeOpsRepositorydelegates the moved methods to the new repository. - Atomic, single-registrar handoff (runtime first-wins dedups). PA depends on the new plugin (
workspace:*) + auto-registers via anensureLifeOps<X>PluginRegisteredguard. - Preserve runtime string literals: serviceType, action names, event names. VIEW_ACTION_MAP names are guarded by the drift test (
packages/agent/src/runtime/view-action-affinity.test.ts). - Gate every slice with
plugins/plugin-personal-assistant/test/decomposition-integration.test.ts(no dropped owner action, no serviceType collision, no view shadow, every VIEW_ACTION_MAP name registered).
1. Global sequencing rationale
Order chosen to de-risk before the heavy lifts and respect the hard
dependency plugin-reminders → plugin-scheduling:
- Overview removal + safe deletions first — bounded, low-risk, shrinks the surface (fewer consumers of the spine reads before we move the spine).
- Independent runtime-service promotions (cache-backed + approval) — zero
data risk, removes PA-internal coupling that the spine seam otherwise has to
carry as injected deps. Doing these BEFORE the spine move means
scheduler.ts's pending-prompt side effect is already a resolvable runtime service. - Schema carve-outs that de-risk (goals, calendar) — independent of the spine; landing them now proves the migration template on live dual-writer tables before the reminders carve (which is the same pattern, higher stakes).
- plugin-scheduling — the spine. Pure code move (tables stay in
app_lifeopsinitially) + invert the wiring. - plugin-reminders — depends on scheduling's ports; the largest carve.
- Core stub deletion + final cleanup — only safe once the real spine type
exists in
@elizaos/plugin-scheduling.
Concurrency hazard (root AGENTS.md): multiple agents/worktrees touch
develop(see.claude/worktrees/*).lifeops/repository.ts,schema.ts,service-mixin-reminders.ts,src/plugin.ts, andview-action-affinity.tsare shared hot files. Commit each slice atomically, push proactively, never stash, and re-base before the carve slices.
2. The ordered slices
Risk legend: SAFE-NOW = high-confidence, fully verifiable, landable in one PR. NEEDS-CARE = data migration / deep coupling, must be atomic, review-gated.
Slice 1 — Remove the LifeOps overview view (SAFE-NOW, low)
Why first: bounded, no data, no new package. The overview is the legitimate cross-domain aggregation the README reserves — but the owner directed its removal.
File ops:
src/plugin.ts: delete the threeLifeOpsPageViewview descriptors (id"lifeops",componentExport: "LifeOpsPageView") and their imports. RemoveLifeOpsPageViewfrom the component export barrel (src/ui.ts/src/components/if exported there).src/routes/lifeops-routes.ts: delete theGET /api/lifeops/overviewhandler (:2285, callsservice.getOverview()at:2298). Remove the route descriptor fromsrc/routes/plugin.ts:331.- KEEP
service.getOverview()andrepository.listOccurrenceViewsForOverview— reminder DELIVERY still reads occurrence views via the spine port (slice 8). Only the VIEW + its HTTP route are removed. - Update PA
CLAUDE.md"Views" section (delete the overview-hub paragraph).
Verification gate:
rg '"lifeops"' src/plugin.tsandrg '/api/lifeops/overview' src→ no view/route.rg 'lifeops' packages/agent/src/runtime/view-action-affinity.tsconfirms thelifeopsview maps only toPERSONAL_ASSISTANT— no VIEW_ACTION_MAP entry targets the removed view component (thelifeopskey is the PERSONAL_ASSISTANT action affinity, not a component reference, so removing the view does not orphan a map entry — confirm nocomponentExport: "LifeOpsPageView"remains).bun run --cwd plugins/plugin-personal-assistant verify.decomposition-integration.test.tsgreen (no view shadow, no dropped action).
Slice 2 — Delete vestigial dead app_goals schema (SAFE-NOW, low)
File ops:
plugins/plugin-goals/src/db/schema.ts: deleteroutinesTable(42),remindersTable(61),alarmsTable(84),checkinsTable(106) and their inferred typesRoutineRow/Insert,ReminderRow/Insert,AlarmRow/Insert,CheckinRow/Insert(129-136). KEEPgoalsTable(it is the carve target for slice 6).plugins/plugin-goals/src/db/index.ts+src/index.ts: remove the four re-exports.plugins/plugin-goals/CLAUDE.md: drop the four tables from the schema bullet.
Verification gate:
rg 'routinesTable|remindersTable|alarmsTable|checkinsTable' plugins/plugin-goals/src→ empty.bun run --cwd plugins/plugin-goals verify.
Slice 3 — Promote pending-prompts / global-pause / handoff to @elizaos/agent runtime services (SAFE-NOW, low)
Why now: cache-backed (verified zero SQL), zero PA-internal coupling beyond
their own store file. Promoting them BEFORE the spine move turns
scheduler.ts's pending-prompt side effect into a resolvable runtime service the
spine can inject cleanly.
File ops (per store, follow packages/agent/src/services/knowledge-graph/service.ts exactly):
- Create
packages/agent/src/services/pending-prompts/service.ts,.../global-pause/service.ts,.../handoff/service.ts. Each: a serviceType literal const ('eliza_pending_prompts','eliza_global_pause','eliza_handoff'), aclass XService extends Service { static serviceType = … }whose body is the copied store ops (lift from PAlifeops/{pending-prompts,global-pause,handoff}/store.ts; copyruntime-cache.jsinward or use core cache directly), plusexport function resolveXService(runtime): XService | null. - Register the three classes in
packages/agent/src/runtime/eliza-plugin.tscreateElizaPlugin()services[]. - Export from
packages/agent/src/services/index.ts+packages/agent/src/index.ts(mind the TS2308 duplicate-symbol comments). - In PA: replace direct store imports/constructions with
resolveXService(runtime). Keep PA store files as one-release re-export shims or delete + rewrite call sites. Direction: agent is INNER — agent must NOT import PA store files; the bodies are COPIED inward, PA's copies shimmed/deleted.
Verification gate:
rg '@elizaos/plugin-personal-assistant' packages/agent/src→ nothing (inward-only law).bun run --cwd packages/agent verify; PAbuild:types+testgreen.decomposition-integration.test.ts: no collision on the threeeliza_*serviceTypes.
Slice 4 — Promote approval-queue to @elizaos/agent ApprovalService (SAFE-NOW, medium)
Why no data risk: approval_requests is owned by plugin-sql
(packages/plugin-sql/src/schema/approvalRequests.ts), public schema — pure code
move, NO migration.
File ops:
- Create
packages/agent/src/services/approval/service.ts:ApprovalService extends Service(serviceType'eliza_approval_queue') wrappingPgApprovalQueuelogic lifted fromplugins/plugin-personal-assistant/src/lifeops/approval-queue.ts(theALLOWED_TRANSITIONStable + enqueue/list/resolve raw SQL overapproval_requests); +resolveApprovalServicehelper. - Move
approval-queue.types.tsto@elizaos/shared(cross-package transport) orpackages/agent/src/services/approval/types.ts(agent-private). - Register in
createElizaPluginservices[]. - In PA:
actions/resolve-request.ts+ any outbound-send / document-signature path constructingnew PgApprovalQueue(and theindex.tsre-export) switch toresolveApprovalService(runtime). Delete PA'sapproval-queue.ts+ its sql copy, or leave a one-release re-export shim. Table stays owned by plugin-sql.
Verification gate:
rg '@elizaos/plugin-personal-assistant' packages/agent/src→ empty.bun run --cwd packages/agent verify; PA verify green.- Manual: a
RESOLVE_REQUESTround-trip still enqueues + resolves anapproval_requestsrow (state-machine transitions unchanged).
Slice 5 — Carve goals tables app_lifeops.life_goal_* → app_goals (NEEDS-CARE, medium)
Dual writers (verified): PA LifeOpsRepository (10 life_goal_* refs) AND
plugin-goals/src/db/goals-repository.ts (10 refs). Both must repoint atomically
in this slice or data silently splits across schemas.
File ops:
plugins/plugin-goals/src/db/schema.ts: redefinegoalsTable→ two tables matching the LIVEapp_lifeopscolumn shape —life_goal_definitions(id/agent_id/domain/subject_type/…/metadata_json/created_at/updated_at) andlife_goal_links(id/agent_id/goal_id/linked_type/linked_id/created_at) — undergoalsSchema = pgSchema('app_goals'), table names kept verbatim. Ensureplugin-goals/src/plugin.tsschemafield registers them.- Create
plugins/plugin-goals/src/services/migration.ts=GoalsMigrationService, cloned fromplugins/plugin-finances/src/services/migration.ts:SOURCE_SCHEMA='app_lifeops',TARGET_SCHEMA='app_goals',MIGRATED_GOAL_TABLES=['life_goal_definitions','life_goal_links'], serviceType'goals_migration'. Register inplugin-goals/src/plugin.tsservices[]. Addmigration.test.ts. plugins/plugin-goals/src/db/goals-repository.ts: repoint everyapp_lifeops.life_goal_*→app_goals.*via aTARGET_SCHEMAconst. KEEP thedeleteGoalcross-table writes toapp_lifeops.life_task_definitions(spine FK-nullout) andapp_lifeops.life_audit_events(audit) UNCHANGED (one-directional cross-schema, allowed).- PA
lifeops/repository.ts: repoint the 10life_goal_*refs toapp_goalsvia aGOALS_SCHEMAconst. Confirm PAservice-mixin-goals.tsonly delegates toGoalsService(it does — no direct SQL).
Verification gate:
migration.test.ts: source-missing skip / target-non-empty skip / copies-when-empty.rg 'app_lifeops.life_goal_(definitions|links)' plugins/→ ONLY theGoalsMigrationServiceSOURCE_SCHEMA literal (no live writer on app_lifeops).rg '@elizaos/plugin-personal-assistant' plugins/plugin-goals/src→ comments only.bun run --cwd plugins/plugin-goals verify; PA verify;decomposition-integration.test.tsgreen.
Slice 6 — Carve calendar tables app_lifeops.life_calendar_* → app_calendar (NEEDS-CARE, medium)
Dual writers (verified): PA LifeOpsRepository (21 life_calendar_* refs incl.
the ALTER/CREATE-INDEX bootstrap block) AND plugin-calendar/src/service/CalendarRepository.ts
(11 refs). The plugin-calendar schema (appLifeopsPgSchema('app_lifeops')) is a
dormant squat — currently NO schema field on its plugin. Both writers repoint
atomically.
File ops:
plugins/plugin-calendar/src/service/schema.ts: renameappLifeopsPgSchema('app_lifeops')→calendarSchema = pgSchema('app_calendar'); keep table nameslife_calendar_events/life_calendar_sync_statesverbatim.plugins/plugin-calendar/src/plugin.ts: actually register the schema (add theschema:field — currently absent).- Create
plugins/plugin-calendar/src/service/migration.ts=CalendarMigrationService(finances template):SOURCE_SCHEMA='app_lifeops',TARGET_SCHEMA='app_calendar', tables['life_calendar_events','life_calendar_sync_states'], serviceType'calendar_migration'. Register inplugin.tsservices[]. Addmigration.test.ts. CalendarRepository.ts: repoint the 11 refs toapp_calendarvia aTARGET_SCHEMAconst.- PA
lifeops/repository.ts: repoint the calendar refs (incl. the bootstrap ALTER/CREATE-INDEX block ~2533/4194/4257) toapp_calendarvia aCALENDAR_SCHEMAconst. Confirm PAwithCalendarmixin only orchestrates (grant registry / connector gate / audit) and delegates the event/sync STORE toCalendarService— keep the orchestration in PA.
Verification gate:
migration.test.ts(3 guard cases).rg 'app_lifeops.life_calendar_' plugins/→ ONLY the MigrationService SOURCE literal.rg '@elizaos/plugin-personal-assistant' plugins/plugin-calendar/src→ comments only.bun run --cwd plugins/plugin-calendar verify; PA verify; integration test green.
Slice 7 — Scaffold @elizaos/plugin-scheduling (SAFE-NOW, low)
File ops:
- Create
plugins/plugin-scheduling/:package.json(name@elizaos/plugin-scheduling; copyplugin-finances/package.jsonbut DROPbuild:views, vite devDeps,lucide-react/@elizaos/ui, and theelizaos.appmarker — the spine has no view since SCHEDULED_TASKS is absent from VIEW_ACTION_MAP). deps:@elizaos/core,@elizaos/agent,@elizaos/app-core,@elizaos/shared,drizzle-orm; peerDep@elizaos/plugin-sql.build:js+build:typesonly. tsconfig.json+tsconfig.build.json(copy finances, drop view refs);vitest.config.ts;CLAUDE.md+AGENTS.md(identical);README.md.src/index.ts+src/plugin.tsinitially export an emptyPlugin { name: '@elizaos/plugin-scheduling' }.plugins/*is already globbed in the workspace, sobun installlinks it.
Verification gate:
bun installsucceeds;bun run --cwd plugins/plugin-scheduling typecheckpasses on the empty shell; eliza-source resolves@elizaos/plugin-scheduling.
Slice 8 — Move the storage-agnostic spine core into plugin-scheduling (NEEDS-CARE, medium)
File ops:
git mvthe PA-clean spine files (core-only imports) intoplugins/plugin-scheduling/src/scheduled-task/:types.ts,runner.ts,gate-registry.ts,completion-check-registry.ts,escalation.ts,consolidation-policy.ts,due.ts,next-fire-at.ts,state-log.ts,index.ts- their tests (
runner.test,due.test,consolidation-policy.test,after-task-chain.test).
- their tests (
git mvlifeops/registries/anchor-registry.ts→plugin-scheduling/src/anchors/anchor-registry.ts(imports onlyscheduled-task/consolidation-policy+types— travels clean).- Rewrite intra-spine relative imports: in
due.ts+next-fire-at.ts../registries/anchor-registry.js→../anchors/anchor-registry.js. runner.ts'simport type { DispatchResult } from "../connectors/contract.js": re-home theDispatchResult/ScheduledTaskDispatchertype intoplugin-scheduling(declare it inscheduled-task/runner.tsor a newsrc/connectors/contract-types.ts); PA'sconnectors/contract.tskeeps the runtime impl and re-imports the type from@elizaos/plugin-scheduling.- In PA, repoint every internal importer of the moved files to
@elizaos/plugin-schedulinginstead of./scheduled-task/index.js/../scheduled-task/types.js:owner/fact-store.ts,signals/bus.ts,repository.ts,wave1-types.ts,first-run/defaults.ts,providers/recent-task-states.ts,actions/document.ts,actions/work-thread.ts,routes/scheduled-tasks.ts,routes/plugin.ts, and the remainingregistries/anchor-registryconsumers. - Add
@elizaos/plugin-scheduling: workspace:*to PApackage.jsondeps.
Verification gate:
rg "@elizaos/plugin-personal-assistant" plugins/plugin-scheduling/src→ comments only (the decomposition gate).bun run --cwd plugins/plugin-scheduling typecheck+test(moved runner/due/consolidation tests green).- PA typechecks against the new import paths.
Slice 9 — Invert runtime-wiring: scheduling exposes injected assembly; PA owns production defaults (NEEDS-CARE, high)
File ops:
- Create
plugin-scheduling/src/scheduled-task/runtime-wiring.tswith ONLY the PA-free assembly:createScheduledTaskRunnerFromDeps({ agentId, store, logStore, ownerFacts, globalPause, activity, subjectStore, dispatcher, hostCapabilities, channelKeys, now })— builds the 5 registries (gates / completionChecks / ladders / anchors / consolidation) and callscreateScheduledTaskRunner. Move the diagnostic shims (makeMissingActivityBusView/makeMissingSubjectStoreView) here (need onlyIAgentRuntime+ logger). - Create in PA
lifeops/scheduled-task-wiring.tsthat importscreateScheduledTaskRunnerFromDepsfrom@elizaos/plugin-schedulingand supplies the PA-coupled production deps (lifted from the currentruntime-wiring.ts):makeRepositoryBackedStores(new LifeOpsRepository)(current lines 78-…),resolveOwnerFactStore/ownerFactsToView,createGlobalPauseStore,getActivitySignalBus,createProductionScheduledTaskDispatcher(current line 242, reaches channels/send-policy/getAgentEventService),getHostExecutionCapabilities,getChannelRegistrychannelKeys.registerAppLifeOpsAnchors(current line 341) moves to PA wiring — app-lifeops anchors are PA content. - Move
ScheduledTaskRunnerService(service.ts) to plugin-scheduling but have it call an injected runner-factory: PA registers viacreateScheduledTaskRunnerService(buildRunner)OR keeps a thin PA subclass supplying the PA factory. PreserveserviceType = "lifeops_scheduled_task_runner"EXACTLY (runtime dedup +getServicekey). getHostExecutionCapabilitiesstays in@elizaos/app-core(app-core/services/task-host-capabilities); plugin-scheduling depends on app-core for it (allowed — app-core is inward of plugins). The defaulthostCapabilitiesprovider moves into plugin-scheduling's wiring helper.
Verification gate:
rg 'LifeOpsRepository|fact-store|connectors|send-policy|channels/index|global-pause' plugins/plugin-scheduling/src→ nothing.- A PA integration test:
getScheduledTaskRunner(runtime).getRunner()returns a working runner. - serviceType literal unchanged (grep
"lifeops_scheduled_task_runner"). scheduler.integration.test.tspasses (move it with injected deps, or keep in PA).bun run --cwd plugins/plugin-personal-assistant testgreen.
Slice 10 — Move processDueScheduledTasks to plugin-scheduling with injected tick deps (NEEDS-CARE, high)
File ops:
git mvlifeops/scheduled-task/scheduler.ts→plugin-scheduling/src/scheduled-task/scheduler.ts. RefactorprocessDueScheduledTasks(current:88) signature from{ runtime, agentId, now, limit }to injected deps{ agentId, now, limit, runner, store, ownerFactsProvider, anchorRegistry, onFired? }. Remove the inlinenew LifeOpsRepository(:98),resolveOwnerFactStore(:108),getAnchorRegistry(:113), andcreatePendingPromptsStoreconstructions. The pending-prompt recording (recordPendingPromptIfNeeded/shouldRecordPendingPrompt, current:55-71, triggered at:267) becomes the injectedonFired(persistedTask)callback — moverecordPendingPromptIfNeeded+shouldRecordPendingPromptinto PA (they read PA pending-prompts; after slice 3 they resolve theeliza_pending_promptsservice). The due/timeout candidate listing goes behindstore.list(already part ofScheduledTaskStore).- In PA
service-mixin-reminders.ts:5204: replace theprocessDueScheduledTasks({ runtime, agentId, now, limit })call with the injected form — pass the LifeOpsRepository-backed store, the owner-facts provider,getAnchorRegistry(runtime), and anonFiredthat records the pending prompt viaresolvePendingPromptsService(runtime). Keep theprocessScheduledWorkinterleave (circadian / reminders / workflows) entirely in PA — only the scheduled-task sub-call changes.
Verification gate:
rg 'LifeOpsRepository|pending-prompts|fact-store' plugins/plugin-scheduling/src/scheduled-task/scheduler.ts→ empty.- PA
processScheduledWorkunit test:scheduledTaskFires/scheduledTaskCompletionTimeoutsshapes unchanged. - The 60s
LIFEOPS_SCHEDULERtick fires a scheduled task end-to-end in a PA integration test (records a pending prompt via theonFiredseam).
Slice 11 — Move the SCHEDULED_TASKS action into plugin-scheduling as an injected-deps factory (NEEDS-CARE, medium)
File ops:
git mvsrc/actions/scheduled-task.ts→plugin-scheduling/src/actions/scheduled-task.ts. It importshasLifeOpsAccess(../lifeops/access.js),createPendingPromptsStore(../lifeops/pending-prompts/store.js),LifeOpsRepository(../lifeops/repository.js),OWNER_OPERATION_VALIDATE(./life.js) — all PA-coupled. Convert the default exportscheduledTaskActionintocreateScheduledTasksAction({ access, store, pendingPrompts, validate })returning the Action. Action name'SCHEDULED_TASKS'+ all subaction/simile literals UNCHANGED.- PA
plugin.ts: replaceimport { scheduledTaskAction }+...promoteSubactionsToActions(scheduledTaskAction)withcreateScheduledTasksAction({ access: hasLifeOpsAccess, store: paScheduledTaskStore(runtime), pendingPrompts: resolvePendingPromptsService, validate: OWNER_OPERATION_VALIDATE })built at plugin-init. Atomic handoff: only PA registers it (no double-register); plugin-scheduling exports the factory. - Update PA
index.ts/plugin.tsre-export block (thescheduledTaskActionexport and the big type re-export fromscheduled-task/index.js, plugin.ts:~1238-1263) to re-export from@elizaos/plugin-scheduling(shim) for one release.
Verification gate:
deterministic-action-coverage(scenario-runner) findsSCHEDULED_TASKSregistered exactly once.rg 'SCHEDULED_TASKS' packages/agent/src/runtime/view-action-affinity.tsconfirms it's NOT in VIEW_ACTION_MAP (no view-shadow concern).decomposition-integration.test.ts: no dropped owner action, no serviceType collision; PA composed action list unchanged (snapshot).
Slice 12 — Register the spine via plugin-scheduling's Plugin object; PA auto-registers it (NEEDS-CARE, medium)
File ops:
- Fill
plugin-scheduling/src/plugin.ts:Plugin { name:'@elizaos/plugin-scheduling', services:[ScheduledTaskRunnerService], actions:[...promoteSubactionsToActions( createScheduledTasksAction(defaultDeps))] }. - Lowest-risk handoff decision: PA stays the registrar — PA
plugin.tskeepsScheduledTaskRunnerService+ the composed action in its own arrays, and PA auto-registers plugin-scheduling via a newensureLifeOpsSchedulingPluginRegisteredguard (mirrorensureLifeOpsFinancesPluginRegistered, PAplugin.ts:569-572: checkruntime.plugins.some(p => p.name === schedulingPlugin.name)). plugin-scheduling's Plugin object is exported for standalone use but the runtime first-wins dedup prevents double-registration.registerLifeOpsTaskWorker(the circadian+reminders+scheduling composite tick) stays in PA. - Document in
plugin-scheduling/CLAUDE.mdthat PA owns registration during decomposition. Update PACLAUDE.mdservices/actions tables:ScheduledTaskRunnerService+SCHEDULED_TASKSnow sourced from@elizaos/plugin-scheduling.
Verification gate:
- Runtime first-wins dedup test: loading PA (which pulls plugin-scheduling) registers
ScheduledTaskRunnerService+SCHEDULED_TASKSexactly once. bun run test:servergreen;decomposition-integration.test.tsgreen.
Slice 13 — Define the spine→reminders ports in plugin-scheduling (NEEDS-CARE, high)
Why scheduling owns these: reminders must register a tick-hook + read ports INTO the spine, but scheduling must never import reminders (dependency points the wrong way otherwise). Scheduling exposes a nullable hook + read-only ports; reminders consumes them.
File ops:
- Create
plugin-scheduling/src/ports.tsdeclaring three read-only interfaces + registry, all owned by scheduling:ReminderTickHook = { processReminders(args:{ now:string; limit:number }): Promise<{ attempts: ReminderAttemptResult[] }> }+registerReminderTickHook(runtime, hook)/getReminderTickHook(runtime).SpineOccurrenceReadPort = { listActiveDefinitions, refreshDefinitionOccurrences, listOccurrenceViewsForOverview(agentId, horizonIso), listReminderPlanOwners(agentId, ownerType, ownerIds), listCalendarEvents(agentId, source, fromIso, toIso) }.ChannelPolicyReadPort+CircadianReadPort = { listChannelPolicies, getChannelPolicy, readCircadianState, getScheduleMergedState }.
- In
plugin-scheduling/src/scheduled-task/scheduler.ts, afterprocessDueScheduledTasks, add:const hook = getReminderTickHook(runtime); const reminderAttempts = hook ? (await hook.processReminders({ now, limit })).attempts : []. This inverts today's directthis.processReminderscall (service-mixin-reminders.ts:5190). NOTE: this scheduler hook is the plugin-scheduling-resident tick; the PAprocessScheduledWorkinterleave still drives circadian/workflows and calls into this — wire the hook where the scheduled-task sub-call lives (consistent with slice 10). - The IMPL of all three ports is a thin adapter over the spine's LifeOpsRepository/registries — but since the repository stays PA-side in this phase (no table carve), the port impls are constructed in PA wiring and injected; scheduling holds only the interfaces + registry. NET: scheduling depends on NOTHING of reminders.
Verification gate:
rg '@elizaos/plugin-reminders' plugins/plugin-scheduling/src→ ZERO (not even comments).- Typecheck plugin-scheduling.
- Unit test:
getReminderTickHookreturns undefined ⇒ tick still completes,reminderAttempts = [].
Slice 14 — Scaffold plugin-reminders + schema (app_reminders) + non-destructive migration (NEEDS-CARE, medium)
File ops:
- Create
plugins/plugin-reminders/:package.json(name@elizaos/plugin-reminders,dependencies: ["@elizaos/plugin-sql","@elizaos/plugin-scheduling"],workspace:*on scheduling; peerDeps@elizaos/core+@elizaos/agent);tsconfig,CLAUDE.md+AGENTS.md(identical),README. src/db/schema.ts:remindersSchema = pgSchema("app_reminders"); copylifeReminderPlans(schema.ts:225-244),lifeReminderAttempts(247-285),lifeEscalationStates(834-851) verbatim (column + index defs unchanged), re-rooted toremindersSchema.src/db/sql.ts: self-contained raw-SQL helpers copied from PA (finances pattern).src/services/migration.ts: cloneplugin-finances/src/services/migration.ts;SOURCE_SCHEMA='app_lifeops',TARGET_SCHEMA='app_reminders',MIGRATED_REMINDER_TABLES=['life_reminder_plans','life_reminder_attempts','life_escalation_states'], serviceType'reminders_migration'.src/plugin.ts:Pluginwithschema=remindersSchema,services=[RemindersMigrationService](ReminderService added in slice 16).- PA
schema.ts: leave the three reminder table DEFINITIONS in place (do NOT drop — finances precedent at schema.ts:311-315) but REMOVE them from PA's registered drizzle schema export array so plugin-reminders owns creation going forward; add the same explanatory comment. - PA
index.ts/plugin.ts: addensureLifeOpsRemindersPluginRegistered(mirror finances).
Verification gate:
bun run --cwd plugins/plugin-reminders verify.migration.test.ts: source-missing / target-non-empty / copies-when-empty per table.rg '@elizaos/plugin-personal-assistant' plugins/plugin-reminders/src→ comments only.decomposition-integration.test.ts: no collision onreminders_migration.
Slice 15 — Lift pure reminder helpers + constants + contracts into plugin-reminders (NEEDS-CARE, medium)
File ops:
git mvlifeops/service-helpers-reminder.ts(1894 LOC, near-pure: imports only../contracts,./enforcement-windows,./service-constants,./service-helpers-misc(mergeMetadata, priorityToUrgency),./service-normalize,./service-types) →plugin-reminders/src/reminder-helpers.ts. Resolve its 4 sibling deps:- REMINDER_* constants from
service-constants.ts(DEFAULT_REMINDER_INTENSITY, DEFAULT_REMINDER_PROCESS_LIMIT, GLOBAL_REMINDER_PREFERENCE_CHANNEL_REF, the ~30REMINDER_*_METADATA_KEYconsts atservice-mixin-reminders.ts:122-158) →plugin-reminders/src/reminder-constants.ts. Constants shared with the spine (OVERVIEW_HORIZON_MINUTES, PROACTIVE_TASK_QUERY_TAGS, DEFAULT_WORKFLOW_PROCESS_LIMIT) STAY in scheduling/PA, imported from@elizaos/plugin-scheduling. enforcement-windows.ts, the reminder-only helpers inservice-helpers-misc.ts(buildActiveReminders, isReminderChannelAllowedForUrgency, isWithinQuietHours), andservice-types.tsReminderActivityProfileSnapshot→ move the reminder-only ones into plugin-reminders; leave genuinely-shared ones in scheduling and import.- LifeOpsReminder* DTOs (Plan/Attempt/Preference/Intensity/Urgency/Step/ProcessingResult/
Inspection/Channel + Request DTOs) →
plugin-reminders/src/contracts.ts; re-export from@elizaos/sharedif they are cross-package transport types (they appear inlifeops-schedule-sync-contracts), else own them in reminders.
- REMINDER_* constants from
- PA re-exports the moved helpers/constants from their old paths as shims
(
from "@elizaos/plugin-reminders") so existing PA imports compile unchanged.
Verification gate:
bun run --cwd plugins/plugin-reminders typecheck.rgeach moved symbol repo-wide; callers resolve via the new package or a PA shim.- No duplicate const def (each
REMINDER_*_METADATA_KEYin exactly one source).
Slice 16 — Build ReminderRepository on app_reminders (the 12 reminder-owned methods) (NEEDS-CARE, high)
File ops:
- Create
plugin-reminders/src/db/reminder-repository.tsowning ONLY reminder-table methods, lifted fromLifeOpsRepositoryand repointedapp_lifeops.*→app_reminders.*via aTARGET_SCHEMAconst: createReminderPlan(3126), updateReminderPlan(3146), deleteReminderPlan(3159), getReminderPlan(3168), listReminderPlansForOwners(3184), createReminderAttempt(5178), listReminderAttempts(5211), listDueReminderReviewAttempts(5241), claimDueReminderReviewAttempts(5285), updateReminderAttemptOutcome(5320), plus escalation methods upsertEscalationState(5906), getActiveEscalationState(5955), resolveEscalationState(5971), listRecentEscalationStates(5983), deleteAllEscalationStates(5998), plus theensureReminderReviewColumnsALTER repair repointed toapp_reminders.life_reminder_attempts. Useplugin-reminders/src/db/sql.ts. - The cross-domain reads delivery needs (listActiveDefinitions, listOccurrenceViewsForOverview, listCalendarEvents, listChannelPolicies, getChannelPolicy, readCircadianState, getScheduleMergedState) are NOT in ReminderRepository — they come via the Spine/ChannelPolicy/Circadian read ports from slice 13.
- In PA
LifeOpsRepository, make the moved reminder methods delegate toReminderRepository(mirror theFinancesRepositorydelegation, finances CLAUDE.md) so any residual PA reader compiles.
Verification gate:
bun run --cwd plugins/plugin-reminders test(repository unit tests against app_reminders).rg 'app_lifeops' plugins/plugin-reminders/src/db/reminder-repository.ts→ zero (all repointed).- PA typecheck green via delegation.
Slice 17 — Lift the ReminderService engine + wire ports + register the tick-hook (NEEDS-CARE, high)
File ops:
- Create
plugin-reminders/src/reminder-service.ts= a Service (serviceType'reminders', confirm no collision) holding its own runtime +ReminderRepository+ injected SpineOccurrence/ChannelPolicy/Circadian read ports (resolved from@elizaos/plugin-schedulingatstart()). Lift fromservice-mixin-reminders.tsthe DELIVERY/ESCALATION/REVIEW/PREFERENCE methods ONLY: processReminders(4995), processDueReminderDeliveries(4611), processDueReminderReviewJobs(3140), dispatchDueReminderEscalation, dispatchReminderAttempt, getReminderPreference, setReminderPreference, inspectReminder, acknowledgeReminder, capturePhoneConsent, and the reminder-scoped activity-signal capture (leave non-reminder ones in PA). - Rewrite every
this.repository.<spineRead>→this.spinePort.<read>/this.channelPort.<read>/this.circadianPort.<read>; everythis.repository.<reminderOwned>→this.reminderRepo. Channel transport (registerEscalationChannelfrom@elizaos/agent,sendTwilioSms/sendTwilioVoiceCallfrom@elizaos/plugin-phone,runtime.sendMessageToTarget) imported directly — unchanged. - DELETE the moved methods from
service-mixin-reminders.ts(the PA mixin shrinks).processScheduledWorkstays in PA / scheduling. plugin-reminders/src/tick-hook.ts: implementReminderTickHook.processReminders = (a) => reminderService.processReminders(a)and callregisterReminderTickHook(runtime, hook)from plugin-remindersinit().- PRESERVE runtime strings: serviceType, action names, event names.
Verification gate:
bun run --cwd plugins/plugin-reminders test(delivery/escalation specs ported from PA).- Integration: register scheduling + reminders ⇒ scheduler tick populates
reminderAttemptsvia hook; register scheduling ALONE ⇒ tick completes,reminderAttempts = []. rg 'this.repository' plugin-reminders/src/reminder-service.ts→ onlyreminderRepo; spine reads go through ports.decomposition-integration.test.ts: serviceType'reminders'no collision;processScheduledWorkstill returns thereminderAttemptsfield (shape preserved).
Slice 18 — Route + reminder-plan-init ownership handoff (NEEDS-CARE, high)
File ops:
- Routes: repoint
/api/lifeops/reminders/process(lifeops-routes.ts:2005),/api/lifeops/reminder-preferencesGET+POST (2014/2025),/api/lifeops/reminders/acknowledge(2037),/api/lifeops/reminders/inspection(2089) to resolveReminderService(runtime.getService("reminders")) and call the same methods — URLs + response shapes IDENTICAL (finances/api/lifeops/money/*precedent). Prefer KEEP-IN-PA-DELEGATE first (smaller blast radius, matchesrunFinancesRoute). - Actions:
OWNER_REMINDERS/OWNER_ALARMS/OWNER_ROUTINESSTAY registered where spine-definition CRUD lives — theydefaultKind:"definition"(owner-surfaces.ts:227/251/342) and writelife_task_definitionsviarunLifeOperationHandler(actions/life.ts). They are SPINE-DEFINITION ops, NOT reminder-plan ops. Preserve the literal action-name strings (VIEW_ACTION_MAP maps them to thegoalsview — guarded by the drift test). - The reminder-PLAN attached at definition-create time: scheduling exposes
registerDefinitionReminderPlanInitializer(runtime, fn); plugin-reminders registers the initializer (creates the defaultlife_reminder_plansrow) so scheduling does not import reminders. Definition-create stays spine; reminder-plan creation is the injected post-create callback.
Verification gate:
- E2E:
POST /api/lifeops/reminders/processreturns the same shape; creating a reminder viaOWNER_REMINDERS⇒ alife_task_definitionsrow (spine) + alife_reminder_plansrow (app_reminders). decomposition-integration.test.ts: every VIEW_ACTION_MAP reminder action still registered; no dropped owner action; route URLs unchanged. The VIEW_ACTION_MAP git-grep drift test passes.
Slice 19 — Final reminders cleanup + PA depends on reminders (NEEDS-CARE, medium)
File ops:
- Add
@elizaos/plugin-remindersto PApackage.jsondeps (workspace:*); PA may re-export reminder shims fromindex.tsfor one release. service-mixin-reminders.tsis now a thin shim — if every method moved, remove it from theLifeOpsServicecomposition inservice.tsand delete the file; otherwise leave only the genuinely-shared residue (circadian persistence stays in the spine tick, NOT here).- Update PA
CLAUDE.md+ the boundary table inplugin-reminders/CLAUDE.md(mirror finances "What lives here vs in PA"). Updatelifeops-decomposition-plan.mdstatus.
Verification gate:
bun run --cwd plugins/plugin-reminders verify; PA verify; plugin-scheduling verify.rg '@elizaos/plugin-personal-assistant' plugins/plugin-reminders/src→ comments only.rg '@elizaos/plugin-reminders' plugins/plugin-scheduling/src→ ZERO.bun run test:client(PA + reminders lane).
Slice 20 — Delete the dead core scheduled-task stub (SAFE-NOW, low)
Why last: the real ScheduledTask type now lives in @elizaos/plugin-scheduling,
so the core stub is fully superseded and actively misleading.
File ops:
- Delete
packages/core/src/scheduled-task/{types.ts,runner.ts,index.ts,README.md}(StubScheduledTaskRunner+ the narrower duplicateScheduledTasktype — only 5 self-refs). - Remove
export * from "./scheduled-task/index.js"frompackages/core/src/index.ts:34.
Verification gate:
rg 'StubScheduledTaskRunner|scheduled-task/index|from "./scheduled-task"' packages/core/src→ nothing.bun run --cwd packages/core build(Node+browser+edge) green;testgreen.- No external importer of core's scheduled-task remains (rg across repo).
3. Deferred / blocked (NOT landable now — explicitly out of scope for this pass)
These are documented for honesty; do not attempt them in the slices above.
- Physical spine table carve (
app_lifeops.life_*→app_scheduling). The injected-store design (slices 8-12) leaves the 4 spine tables + raw SQL in PA'sLifeOpsRepository, so the spine move is a pure code move with zero migration risk. A later, optional slice can addplugin-scheduling/src/schema.ts(pgSchema('app_scheduling')) + aSchedulingMigrationService— but that requires moving the store impl + raw SQL into plugin-scheduling, a materially bigger change. Recommend deferring. This is a separate multi-PR effort. - Blocker "carve" — BLOCKED on owner decision.
app_blocker.block_rules(plugin-blocker, registered-but-NEVER-queried) andapp_lifeops.life_block_rules(PA, LIVE —website-blockerreconciler +proactive-block-bridge) have structurally incompatible columns (target/pattern/entityId vs profile/websites/gateType/gateUntilMs). There is NO row-shape under which a copy migration is valid. Options: (a) leavelife_block_rulesPA-owned focus logic, or (b) move the wholewebsite-blockerreconciler +life_block_rulesinto plugin-blocker and delete the unusedapp_blocker.block_rules. Needs owner sign-off before any code change. - Entity/relationship V1 retirement. PA
actions/entity.tsalready routes through the runtimeEntityStore/RelationshipStore(KnowledgeGraphService); plugin-relationships'KNOWLEDGE_GRAPHaction covers the same ops; agraph-migration/migration.tsalready exists. This is mostly DELETION of the dead V1 rawlife_relationshipsSQL inrepository.ts+ confirming the migrator runs. Low risk but independent of the spine/reminders work — sequence it after slice 20 or as its own PR.
4. Honest effort assessment
- Landable now, single PRs (SAFE-NOW): slices 1, 2, 3, 4, 7, 20. Six clean wins.
- Landable now but each must be ONE atomic PR (NEEDS-CARE, data/coupling): slices 5, 6 (dual-writer schema carves — both writers repoint in the same commit), 8-12 (the spine extraction — a coherent 5-slice arc that should land as a tight series, not split across weeks), 13-19 (the reminders extraction — the largest carve; 13+14 can land independently as scaffolding, but 16-18 are deeply coupled and should be reviewed together).
- Genuinely multi-PR / deferred: the physical spine table carve (
app_scheduling), the blocker decision, the entity V1 retirement. - The single biggest mechanical lift is slice 16-17 (ReminderRepository + ReminderService):
the 12 reminder-owned methods + the delivery/escalation/review engine lifted out of a
5468-LOC mixin, with every spine read rewritten to go through injected ports. It is
mechanical but large and must preserve every
reminderAttemptsshape the tick returns.
5. The single biggest risk
The reminders carve depends on a read-port seam that the spine extraction must
expose correctly, and the tick that drives both is interleaved in a single
5000+-line PA method (processScheduledWork). If slices 8-13 do not land the
injection seams cleanly — specifically, if processDueScheduledTasks's pending-prompt
onFired callback or the three reminders read-ports (SpineOccurrence/ChannelPolicy/
Circadian) leak a PA import into plugin-scheduling, or if the ReminderTickHook
inversion drops the reminderAttempts field — the dependency direction flips
(plugin-scheduling → PA or plugin-scheduling → plugin-reminders), violating the
inward-only architecture law and silently breaking the per-tick reminder delivery
(reminders stop firing with no error). Mitigation: the per-slice gates
rg "@elizaos/plugin-personal-assistant" plugins/plugin-scheduling/src (comments only)
and rg "@elizaos/plugin-reminders" plugins/plugin-scheduling/src (ZERO), plus the
"scheduling ALONE ⇒ reminderAttempts=[] / scheduling+reminders ⇒ populated" integration
test, are the non-negotiable tripwires — do not advance a slice until both pass.
6. Execution status (2026-06-18)
Landed (committed + pushed to develop):
- ✅ Slice 1 — LifeOps overview view + builtin tab removed across PA / packages-ui / app-core; route-coverage 10/10, decomposition 6/6, all typecheck clean. (
2cd13115b4) - ✅ Slice 2 — vestigial
app_goalsroutines/reminders/alarms/checkins tables deleted. (d286dbae73) - ✅ Slice 7 —
@elizaos/plugin-schedulingscaffolded (empty Plugin shell + package/tsconfig/CLAUDE;bun installlinks it; typechecks). (5b12de8344) - ✅ Slice 20 — dead
@elizaos/corescheduled-task stub deleted. (33d9918038) - Plus the earlier review:
lifeops-cleanup-review.md+ 3,578 LOC of dead graph/identity code removed (context-graph, identity-observations).
Not yet executed (the deep arcs — DELIBERATELY deferred as careful, reviewed PRs):
- Slices 3, 4 — runtime-service promotions (pending-prompts/global-pause/handoff/approval-queue →
@elizaos/agent). Standalone SAFE-NOW; next-up. - Slices 5, 6 — goals/calendar schema carve-outs (atomic dual-writer migrations).
- Slices 8–19 — the spine + reminders engine extraction. This is the bulk of "scheduling → plugin-scheduling, reminders → plugin-reminders" and is an atomic multi-PR effort: moving ~7k LOC of spine + a 5,468-LOC reminders mixin out of a 137k-LOC monolith fused to an 8.4k-LOC
repository.ts, with the per-tick reminder delivery interleaved in oneprocessScheduledWorkmethod. Rushing it on the shareddevelopbranch (where PA's typecheck baseline is already ~472 errors, masking new breakage) risks silently breaking the agent's scheduling + reminders. It must land as the tight, reviewed slice series in §2, each gated by the §0/§2 tripwires (no inward import leak;reminderAttemptsshape preserved; the "scheduling-alone ⇒ [] / scheduling+reminders ⇒ populated" integration test).
Recommendation: land slices 3–6 next (each a clean single PR), then the spine arc (8–12) as one reviewed PR, then the reminders arc (13–19) as one reviewed PR.
7. Execution status update (2026-06-18, cont.)
✅ Slice 8 LANDED (ada0c9a61d, pushed) — the storage-agnostic scheduling
spine core is now in @elizaos/plugin-scheduling: types, runner, the
gate/completion-check/escalation registries, consolidation-policy, due/
next-fire-at math, state-log, the barrel, and the anchor registry. The
DispatchResult type is re-homed there. The tick driver
(processDueScheduledTasks), the runner Service, and runtime-wiring stay
PA-side and import the spine from the new package; PA keeps a thin
lifeops/scheduled-task/index barrel re-exporting the package + the local
scheduler/service so existing importers are unchanged. Verified:
plugin-scheduling typecheck clean + 72 spine tests; PA build:types clean, tsgo
470 (≤ baseline), decomposition 6/6, 6 spine-consuming PA test files (72 tests)
green; tripwire (no PA import in plugin-scheduling/src) holds.
Architecture reached: @elizaos/plugin-scheduling is the reusable
scheduling-spine library; PA is the host that injects its
repository/owner-facts/channels and drives the tick. This is a clean, defensible
decomposition — the substantive "scheduling → plugin-scheduling."
Remaining (atomic, must each land as one reviewed PR):
- Slices 9–12 — move the tick driver + runner Service +
SCHEDULED_TASKSaction into plugin-scheduling (PA injects production deps). High-risk wiring inversion; marginal benefit over slice 8 (the reusable library is already extracted). Optional. - Slices 13–19 — reminders → plugin-reminders. The largest lift: it MUST be
atomic (the
app_lifeops.life_reminder_*→app_remindersmigration + theReminderRepositoryrepoint must land in the same change or reminder data splits across two schemas), and it lifts the delivery/escalation engine out of the 5,468-LOCservice-mixin-reminders.tswhile preserving the per-tickreminderAttemptsshape. Reminder tables verified atschema.ts:224(life_reminder_plans),:246(life_reminder_attempts),:833(life_escalation_states). Recommended approach (finances pattern): scaffold plugin-reminders (depends on plugin-scheduling + plugin-sql) →app_remindersschema (copy the 3 tables verbatim) → non-destructiveRemindersMigrationService→ self-containedReminderRepositoryoverapp_reminders→ PA'sLifeOpsRepositoryreminder methods delegate to it → PA registers plugin-reminders viaensureLifeOpsRemindersPluginRegistered. The service-mixin engine can stay PA-resident, delegating through the repository (consistent with how the spine wiring stays PA-side). - Slices 3–6 (runtime-service promotions; goals/calendar schema carves) — independent, finances-proven, landable as separate clean PRs.
8. Execution status update (2026-06-19) — data-layer carves complete
✅ All LifeOps data-layer carves LANDED + pushed. Every dual-writer /
shared-schema-squat in the LifeOps domain is now eliminated; each domain plugin
owns its own app_* schema with a non-destructive app_lifeops → app_*
migration (the finances carve pattern: to_regclass source guard +
target-empty check + INSERT … SELECT s.*, source never dropped). PA keeps the
app_lifeops defs only as the dormant migration source and auto-registers each
plugin so the schema exists + the migration runs.
| Carve | Schema | Tables | Migration service | Notes |
|---|---|---|---|---|
| reminders (slice 14-ish) | app_reminders |
life_reminder_plans / _attempts / life_escalation_states | RemindersMigrationService |
PA-exclusive tables; data layer only (engine stays PA-resident) |
| calendar (slice 6) | app_calendar |
life_calendar_events / _sync_states | CalendarMigrationService |
killed the PA↔plugin-calendar dual-writer; schema was an unregistered squat |
| goals (slice 5) | app_goals |
life_goal_definitions / _links | GoalsMigrationService |
resolved the documented shared-schema counter-position by carving cleanly; replaced the dead app_goals.goals placeholder; reminder/scheduling goal-link reads follow the repository to app_goals |
| inbox | app_inbox |
life_inbox_triage_entries / _examples / life_email_unsubscribes | InboxMigrationService |
sole writer was plugin-inbox (PA's src/inbox/repository.ts is a shim); gmail projection tables stay PA-owned; 22 lifeops.* scenarios + bootstrapSchema repointed |
Each: plugin typecheck clean + migration unit test (3-4 guard cases); PA
build:types clean; PA tsgo 470 == baseline (zero net new errors); full PA
suite 548/548; decomposition-integration 6/6 (no migration-serviceType
collision across reminders/calendar/goals/inbox/finances); tripwire holds (no PA
import in the carved plugin's src); no live writer/reader left on the
app_lifeops source tables.
Deliberately NOT done (each needs an owner decision or a dedicated focused effort — out of scope for a high-confidence cleanup sweep):
- Slices 3–4 (promote pending-prompts / global-pause / handoff / approval-queue
→
@elizaos/agent). This moves assistant-workflow state INWARD into the core runtime (every agent would carry it), the opposite direction from the carves above, and its main stated rationale was enabling the deferred spine-engine move. Architecturally debatable — wants an owner call on whether the runtime is the right home, not a unilateral sweep. - Slices 9–12 (move the tick driver + runner Service +
SCHEDULED_TASKSinto plugin-scheduling; invert runtime-wiring) — high-risk wiring inversion, marginal benefit over the slice-8 library extraction. - Slices 13/16–19 (lift the reminders delivery/escalation ENGINE out of the
5,468-LOC
service-mixin-reminders.tsbehind plugin-scheduling tick-hook ports) — the riskiest tick/engine surgery; the reminders data layer is already carved, so the headline ("reminders → plugin-reminders") is delivered.