# 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 in `lifeops-decomposition-plan.md`.
>
> Owner directive: extract the **scheduling spine** → new `plugin-scheduling`;
> extract **reminders** → new `plugin-reminders` (depends on scheduling); **remove
> the overview view**; complete all cleanup follow-ups.
>
> Architecture invariants live in `../README.md` / root `AGENTS.md`. The proven
> template is `plugin-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):**
1. New plugin **MUST NOT** import PA. Gate: `rg "@elizaos/plugin-personal-assistant" plugins/
/src` → comments only.
2. Move dependency-clean core first; inject the PA-coupled bits.
3. Schema carve = focused plugin declares its own `pgSchema("app_X")` with **verbatim table names**; a non-destructive `MigrationService` (to_regclass guard + target-empty check + `INSERT…SELECT NOT EXISTS` + never-drop-source) copies `app_lifeops.*` → `app_X.*`; raw SQL repointed via a `TARGET_SCHEMA` const; PA's `LifeOpsRepository` **delegates** the moved methods to the new repository.
4. Atomic, single-registrar handoff (runtime first-wins dedups). PA depends on the new plugin (`workspace:*`) + auto-registers via an `ensureLifeOpsPluginRegistered` guard.
5. 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`).
6. 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`:
1. **Overview removal + safe deletions first** — bounded, low-risk, shrinks the
surface (fewer consumers of the spine reads before we move the spine).
2. **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.
3. **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).
4. **plugin-scheduling** — the spine. Pure code move (tables stay in
`app_lifeops` initially) + invert the wiring.
5. **plugin-reminders** — depends on scheduling's ports; the largest carve.
6. **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`, and `view-action-affinity.ts`
> are 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 three `LifeOpsPageView` view descriptors (id
`"lifeops"`, `componentExport: "LifeOpsPageView"`) and their imports. Remove `LifeOpsPageView` from the component
export barrel (`src/ui.ts` / `src/components/` if exported there).
- `src/routes/lifeops-routes.ts`: delete the `GET /api/lifeops/overview` handler
(`:2285`, calls `service.getOverview()` at `:2298`). Remove the route descriptor
from `src/routes/plugin.ts:331`.
- **KEEP** `service.getOverview()` and `repository.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.ts` and `rg '/api/lifeops/overview' src` → no view/route.
- `rg 'lifeops' packages/agent/src/runtime/view-action-affinity.ts` confirms the
`lifeops` view maps only to `PERSONAL_ASSISTANT` — **no VIEW_ACTION_MAP entry
targets the removed view component** (the `lifeops` key is the
PERSONAL_ASSISTANT action affinity, not a component reference, so removing the
view does not orphan a map entry — confirm no `componentExport: "LifeOpsPageView"`
remains).
- `bun run --cwd plugins/plugin-personal-assistant verify`.
- `decomposition-integration.test.ts` green (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`: delete `routinesTable`(42),
`remindersTable`(61), `alarmsTable`(84), `checkinsTable`(106) and their inferred
types `RoutineRow/Insert`, `ReminderRow/Insert`, `AlarmRow/Insert`,
`CheckinRow/Insert` (129-136). **KEEP `goalsTable`** (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'`), a `class XService extends Service { static serviceType = … }`
whose body is the **copied** store ops (lift from PA
`lifeops/{pending-prompts,global-pause,handoff}/store.ts`; copy `runtime-cache.js`
inward or use core cache directly), plus
`export function resolveXService(runtime): XService | null`.
- Register the three classes in `packages/agent/src/runtime/eliza-plugin.ts`
`createElizaPlugin()` `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`; PA `build:types` + `test` green.
- `decomposition-integration.test.ts`: no collision on the three `eliza_*` 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'`) wrapping `PgApprovalQueue` logic
lifted from `plugins/plugin-personal-assistant/src/lifeops/approval-queue.ts`
(the `ALLOWED_TRANSITIONS` table + enqueue/list/resolve raw SQL over
`approval_requests`); + `resolveApprovalService` helper.
- Move `approval-queue.types.ts` to `@elizaos/shared` (cross-package transport) or
`packages/agent/src/services/approval/types.ts` (agent-private).
- Register in `createElizaPlugin` `services[]`.
- In PA: `actions/resolve-request.ts` + any outbound-send / document-signature path
constructing `new PgApprovalQueue` (and the `index.ts` re-export) switch to
`resolveApprovalService(runtime)`. Delete PA's `approval-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_REQUEST` round-trip still enqueues + resolves an
`approval_requests` row (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`: redefine `goalsTable` → two tables
matching the LIVE `app_lifeops` column shape — `life_goal_definitions`
(id/agent_id/domain/subject_type/…/metadata_json/created_at/updated_at) and
`life_goal_links` (id/agent_id/goal_id/linked_type/linked_id/created_at) — under
`goalsSchema = pgSchema('app_goals')`, **table names kept verbatim**. Ensure
`plugin-goals/src/plugin.ts` `schema` field registers them.
- Create `plugins/plugin-goals/src/services/migration.ts` = `GoalsMigrationService`,
cloned from `plugins/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 in `plugin-goals/src/plugin.ts` `services[]`.
Add `migration.test.ts`.
- `plugins/plugin-goals/src/db/goals-repository.ts`: repoint every
`app_lifeops.life_goal_*` → `app_goals.*` via a `TARGET_SCHEMA` const. **KEEP**
the `deleteGoal` cross-table writes to `app_lifeops.life_task_definitions` (spine
FK-nullout) and `app_lifeops.life_audit_events` (audit) UNCHANGED (one-directional
cross-schema, allowed).
- PA `lifeops/repository.ts`: repoint the 10 `life_goal_*` refs to `app_goals` via a
`GOALS_SCHEMA` const. Confirm PA `service-mixin-goals.ts` only delegates to
`GoalsService` (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 the
`GoalsMigrationService` SOURCE_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.ts` green.
---
### 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`: rename
`appLifeopsPgSchema('app_lifeops')` → `calendarSchema = pgSchema('app_calendar')`;
keep table names `life_calendar_events` / `life_calendar_sync_states` verbatim.
- `plugins/plugin-calendar/src/plugin.ts`: **actually register** the schema (add the
`schema:` 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 in `plugin.ts` `services[]`. Add `migration.test.ts`.
- `CalendarRepository.ts`: repoint the 11 refs to `app_calendar` via a `TARGET_SCHEMA`
const.
- PA `lifeops/repository.ts`: repoint the calendar refs (incl. the bootstrap
ALTER/CREATE-INDEX block ~2533/4194/4257) to `app_calendar` via a `CALENDAR_SCHEMA`
const. Confirm PA `withCalendar` mixin only orchestrates (grant registry / connector
gate / audit) and delegates the event/sync STORE to `CalendarService` — 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`;
copy `plugin-finances/package.json` but **DROP** `build:views`, vite devDeps,
`lucide-react`/`@elizaos/ui`, and the `elizaos.app` marker — 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:types` only.
- `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.ts` initially export an empty
`Plugin { name: '@elizaos/plugin-scheduling' }`. `plugins/*` is already globbed in
the workspace, so `bun install` links it.
**Verification gate:**
- `bun install` succeeds; `bun run --cwd plugins/plugin-scheduling typecheck` passes
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 mv` the PA-clean spine files (core-only imports) into
`plugins/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`).
- `git mv` `lifeops/registries/anchor-registry.ts` → `plugin-scheduling/src/anchors/anchor-registry.ts`
(imports only `scheduled-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`'s `import type { DispatchResult } from "../connectors/contract.js"`:
re-home the `DispatchResult`/`ScheduledTaskDispatcher` **type** into
`plugin-scheduling` (declare it in `scheduled-task/runner.ts` or a new
`src/connectors/contract-types.ts`); PA's `connectors/contract.ts` keeps 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-scheduling` instead 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 remaining
`registries/anchor-registry` consumers.
- Add `@elizaos/plugin-scheduling: workspace:*` to PA `package.json` deps.
**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.ts` with 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 calls `createScheduledTaskRunner`. Move the diagnostic
shims (`makeMissingActivityBusView` / `makeMissingSubjectStoreView`) here (need only
`IAgentRuntime` + logger).
- Create in PA `lifeops/scheduled-task-wiring.ts` that imports
`createScheduledTaskRunnerFromDeps` from `@elizaos/plugin-scheduling` and supplies
the PA-coupled production deps (lifted from the current `runtime-wiring.ts`):
`makeRepositoryBackedStores(new LifeOpsRepository)` (current lines 78-…),
`resolveOwnerFactStore`/`ownerFactsToView`, `createGlobalPauseStore`,
`getActivitySignalBus`, `createProductionScheduledTaskDispatcher` (current line 242,
reaches channels/send-policy/`getAgentEventService`), `getHostExecutionCapabilities`,
`getChannelRegistry` channelKeys. `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 via
`createScheduledTaskRunnerService(buildRunner)` OR keeps a thin PA subclass
supplying the PA factory. **Preserve `serviceType = "lifeops_scheduled_task_runner"`
EXACTLY** (runtime dedup + `getService` key).
- `getHostExecutionCapabilities` stays 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 default `hostCapabilities`
provider 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.ts` passes (move it with injected deps, or keep in PA).
- `bun run --cwd plugins/plugin-personal-assistant test` green.
---
### Slice 10 — Move `processDueScheduledTasks` to plugin-scheduling with injected tick deps (`NEEDS-CARE`, high)
**File ops:**
- `git mv` `lifeops/scheduled-task/scheduler.ts` → `plugin-scheduling/src/scheduled-task/scheduler.ts`.
Refactor `processDueScheduledTasks` (current `:88`) signature from
`{ runtime, agentId, now, limit }` to injected deps `{ agentId, now, limit, runner,
store, ownerFactsProvider, anchorRegistry, onFired? }`. Remove the inline
`new LifeOpsRepository` (`:98`), `resolveOwnerFactStore` (`:108`), `getAnchorRegistry`
(`:113`), and `createPendingPromptsStore` constructions. The pending-prompt recording
(`recordPendingPromptIfNeeded` / `shouldRecordPendingPrompt`, current `:55-71`,
triggered at `:267`) becomes the injected `onFired(persistedTask)` callback —
**move `recordPendingPromptIfNeeded` + `shouldRecordPendingPrompt` into PA** (they read
PA pending-prompts; after slice 3 they resolve the `eliza_pending_prompts` service).
The due/timeout candidate listing goes behind `store.list` (already part of
`ScheduledTaskStore`).
- In PA `service-mixin-reminders.ts:5204`: replace the
`processDueScheduledTasks({ runtime, agentId, now, limit })` call with the injected
form — pass the LifeOpsRepository-backed store, the owner-facts provider,
`getAnchorRegistry(runtime)`, and an `onFired` that records the pending prompt via
`resolvePendingPromptsService(runtime)`. **Keep the `processScheduledWork` interleave
(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 `processScheduledWork` unit test: `scheduledTaskFires` / `scheduledTaskCompletionTimeouts` shapes unchanged.
- The 60s `LIFEOPS_SCHEDULER` tick fires a scheduled task end-to-end in a PA integration test (records a pending prompt via the `onFired` seam).
---
### Slice 11 — Move the `SCHEDULED_TASKS` action into plugin-scheduling as an injected-deps factory (`NEEDS-CARE`, medium)
**File ops:**
- `git mv` `src/actions/scheduled-task.ts` → `plugin-scheduling/src/actions/scheduled-task.ts`.
It imports `hasLifeOpsAccess` (`../lifeops/access.js`), `createPendingPromptsStore`
(`../lifeops/pending-prompts/store.js`), `LifeOpsRepository` (`../lifeops/repository.js`),
`OWNER_OPERATION_VALIDATE` (`./life.js`) — all PA-coupled. Convert the default export
`scheduledTaskAction` into `createScheduledTasksAction({ access, store, pendingPrompts,
validate })` returning the Action. **Action name `'SCHEDULED_TASKS'` + all
subaction/simile literals UNCHANGED.**
- PA `plugin.ts`: replace `import { scheduledTaskAction }` +
`...promoteSubactionsToActions(scheduledTaskAction)` with
`createScheduledTasksAction({ 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.ts` re-export block (the `scheduledTaskAction` export and
the big type re-export from `scheduled-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) finds `SCHEDULED_TASKS` registered exactly once.
- `rg 'SCHEDULED_TASKS' packages/agent/src/runtime/view-action-affinity.ts` confirms 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.ts` keeps
`ScheduledTaskRunnerService` + the composed action in its own arrays, and PA
auto-registers plugin-scheduling via a new
`ensureLifeOpsSchedulingPluginRegistered` guard (mirror
`ensureLifeOpsFinancesPluginRegistered`, PA `plugin.ts:569-572`: check
`runtime.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.md` that PA owns registration during
decomposition. Update PA `CLAUDE.md` services/actions tables:
`ScheduledTaskRunnerService` + `SCHEDULED_TASKS` now sourced from
`@elizaos/plugin-scheduling`.
**Verification gate:**
- Runtime first-wins dedup test: loading PA (which pulls plugin-scheduling) registers
`ScheduledTaskRunnerService` + `SCHEDULED_TASKS` exactly once.
- `bun run test:server` green; `decomposition-integration.test.ts` green.
---
### 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.ts` declaring 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`, **after**
`processDueScheduledTasks`, add: `const hook = getReminderTickHook(runtime); const
reminderAttempts = hook ? (await hook.processReminders({ now, limit })).attempts : []`.
This **inverts** today's direct `this.processReminders` call
(`service-mixin-reminders.ts:5190`). NOTE: this scheduler hook is the
plugin-scheduling-resident tick; the PA `processScheduledWork` interleave 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: `getReminderTickHook` returns 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")`; copy
`lifeReminderPlans` (schema.ts:225-244), `lifeReminderAttempts` (247-285),
`lifeEscalationStates` (834-851) **verbatim** (column + index defs unchanged), re-rooted
to `remindersSchema`.
- `src/db/sql.ts`: self-contained raw-SQL helpers copied from PA (finances pattern).
- `src/services/migration.ts`: clone `plugin-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`: `Plugin` with `schema=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`: add `ensureLifeOpsRemindersPluginRegistered` (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 on `reminders_migration`.
---
### Slice 15 — Lift pure reminder helpers + constants + contracts into plugin-reminders (`NEEDS-CARE`, medium)
**File ops:**
- `git mv` `lifeops/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
~30 `REMINDER_*_METADATA_KEY` consts at `service-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 in `service-helpers-misc.ts`
(buildActiveReminders, isReminderChannelAllowedForUrgency, isWithinQuietHours), and
`service-types.ts` `ReminderActivityProfileSnapshot` → 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/shared` if they are cross-package transport types (they appear in
`lifeops-schedule-sync-contracts`), else own them in reminders.
- 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`.
- `rg` each moved symbol repo-wide; callers resolve via the new package or a PA shim.
- No duplicate const def (each `REMINDER_*_METADATA_KEY` in 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.ts` owning ONLY reminder-table
methods, lifted from `LifeOpsRepository` and repointed `app_lifeops.*` → `app_reminders.*`
via a `TARGET_SCHEMA` const: 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 the
`ensureReminderReviewColumns` ALTER repair repointed to `app_reminders.life_reminder_attempts`.
Use `plugin-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** to
`ReminderRepository` (mirror the `FinancesRepository` delegation, 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-scheduling` at `start()`). Lift from `service-mixin-reminders.ts` the
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.` → `this.spinePort.` /
`this.channelPort.` / `this.circadianPort.`; every `this.repository.`
→ `this.reminderRepo`. Channel transport (`registerEscalationChannel` from `@elizaos/agent`,
`sendTwilioSms`/`sendTwilioVoiceCall` from `@elizaos/plugin-phone`,
`runtime.sendMessageToTarget`) imported directly — unchanged.
- DELETE the moved methods from `service-mixin-reminders.ts` (the PA mixin shrinks).
`processScheduledWork` stays in PA / scheduling.
- `plugin-reminders/src/tick-hook.ts`: implement `ReminderTickHook.processReminders =
(a) => reminderService.processReminders(a)` and call `registerReminderTickHook(runtime, hook)`
from plugin-reminders `init()`.
- 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 `reminderAttempts`
via hook; register scheduling ALONE ⇒ tick completes, `reminderAttempts = []`.
- `rg 'this.repository' plugin-reminders/src/reminder-service.ts` → only `reminderRepo`; spine
reads go through ports.
- `decomposition-integration.test.ts`: serviceType `'reminders'` no collision; `processScheduledWork`
still returns the `reminderAttempts` field (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-preferences` GET+POST (2014/2025), `/api/lifeops/reminders/acknowledge`
(2037), `/api/lifeops/reminders/inspection` (2089) to resolve `ReminderService`
(`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, matches `runFinancesRoute`).
- Actions: `OWNER_REMINDERS`/`OWNER_ALARMS`/`OWNER_ROUTINES` STAY registered where
spine-definition CRUD lives — they `defaultKind:"definition"` (owner-surfaces.ts:227/251/342)
and write `life_task_definitions` via `runLifeOperationHandler` (actions/life.ts). They are
SPINE-DEFINITION ops, NOT reminder-plan ops. **Preserve the literal action-name strings**
(VIEW_ACTION_MAP maps them to the `goals` view — 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 default `life_reminder_plans` row) 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/process` returns the same shape; creating a reminder via
`OWNER_REMINDERS` ⇒ a `life_task_definitions` row (spine) + a `life_reminder_plans` row
(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-reminders` to PA `package.json` deps (`workspace:*`); PA may re-export
reminder shims from `index.ts` for one release.
- `service-mixin-reminders.ts` is now a thin shim — if every method moved, remove it from the
`LifeOpsService` composition in `service.ts` and 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 in `plugin-reminders/CLAUDE.md` (mirror finances
"What lives here vs in PA"). Update `lifeops-decomposition-plan.md` status.
**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 duplicate `ScheduledTask` type — only 5 self-refs).
- Remove `export * from "./scheduled-task/index.js"` from `packages/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; `test` green.
- 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's
`LifeOpsRepository`, so the spine move is a **pure code move with zero migration
risk**. A later, optional slice can add `plugin-scheduling/src/schema.ts`
(`pgSchema('app_scheduling')`) + a `SchedulingMigrationService` — 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) and `app_lifeops.life_block_rules`
(PA, LIVE — `website-blocker` reconciler + `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) leave `life_block_rules` PA-owned focus logic, or
(b) move the whole `website-blocker` reconciler + `life_block_rules` into
plugin-blocker and delete the unused `app_blocker.block_rules`. **Needs owner sign-off
before any code change.**
- **Entity/relationship V1 retirement.** PA `actions/entity.ts` already routes through
the runtime `EntityStore`/`RelationshipStore` (KnowledgeGraphService); plugin-relationships'
`KNOWLEDGE_GRAPH` action covers the same ops; a `graph-migration/migration.ts` already
exists. This is mostly DELETION of the dead V1 raw `life_relationships` SQL in
`repository.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 `reminderAttempts` shape 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_goals` routines/reminders/alarms/checkins tables deleted. (`d286dbae73`)
- ✅ **Slice 7** — `@elizaos/plugin-scheduling` scaffolded (empty Plugin shell + package/tsconfig/CLAUDE; `bun install` links it; typechecks). (`5b12de8344`)
- ✅ **Slice 20** — dead `@elizaos/core` scheduled-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 one `processScheduledWork` method. Rushing it on the shared `develop` branch (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; `reminderAttempts` shape 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_TASKS` action
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_reminders` migration + the
`ReminderRepository` repoint 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-LOC `service-mixin-reminders.ts` while preserving the per-tick
`reminderAttempts` shape. Reminder tables verified at `schema.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_reminders`
schema (copy the 3 tables verbatim) → non-destructive `RemindersMigrationService`
→ self-contained `ReminderRepository` over `app_reminders` → PA's
`LifeOpsRepository` reminder methods delegate to it → PA registers
plugin-reminders via `ensureLifeOpsRemindersPluginRegistered`. 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_TASKS` into
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.ts` behind 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.