A private, local-first personal todo and planning app.
Tasks, time blocks, tags, and an automation pipeline live in a single SQLite file on your machine; an optional local LLM (Ollama) adds AI briefings, task extraction, grooming, and auto-scheduling. No cloud dependency — your planning data lives on your hardware and stays there. Sign in with your Companion Portal account on a CI Hub, or with a local username and password on a self-hosted install; neither sends your data anywhere.
Get started · License · Documentation · Systems map
On crash reporting, plainly: released container images are built with a Sentry
DSN and report crashes by default — errors only, no tracing, no session replay,
no PII, and every payload scrubbed of credentials and home paths first. Your tasks,
calendar and reflections are never sent. Turn it off without a rebuild with
CI_TELEMETRY=off (what the app store's Share crash reports toggle sets) or
CI_LOCAL_ONLY=true (zero phone-home, beats everything). A source checkout or
any build made without a DSN initialises no Sentry at all and opens no outbound
connection. Full posture: docs/operations/error-reporting.md.
Companion Planning is currently at v4.2 (latest release tag v4.2.2; see CHANGELOG.md). The core app is
fully local and usable today. A standalone Google OAuth 2.1 flow (Authorization
Code + PKCE/S256, implemented in the app — not the macOS Keychain) and two-way
Google Calendar sync are built in. Enable them either in-app (an admin pastes a
Google OAuth client in Settings → Connections; needs only a 32-byte CP_KEK_B64 key
that encrypts stored secrets/tokens) or via env (GOOGLE_MODE=live +
CP_GOOGLE_CLIENT_ID / CP_GOOGLE_CLIENT_SECRET, which pins the mode and makes the
in-app setup read-only). With neither configured, Google integration is off — no
ingest, no sync. GOOGLE_MODE=fixture is an explicit dev/demo opt-in that runs fully
offline on committed sample data (refused in production unless CP_ALLOW_FIXTURE=true).
Outbound calendar write-back stays behind a second flag, CP_SYNC_WRITEBACK (off by
default — inbound read-only is the safe posture).
See docs/google-cloud-setup.md to provision a Google
client.
- Server — Koa + TypeScript (
server/), SQLite via better-sqlite3 + Drizzle, cron jobs via croner - Client — SolidJS + Tailwind PWA (
client/), built by Vite intoserver/public/ - Mobile — iOS and Android shells (
mobile/) that wrap the same client and connect to a server you run — see Mobile app - One process, one port — the server serves both the API and the UI on 3848
Source of truth for docs: this README describes the running app; SQLite is the single source of truth for data.
| Feature | What it provides |
|---|---|
| Tasks and objectives | Track owner-scoped tasks, recurrences, objectives and audit history. |
| Calendar and time blocks | Plan work locally and optionally sync to Google Calendar. |
| Local AI assistance | Use a local model (Ollama, or your CI Hub's AI) for briefings, task extraction, grooming and auto-scheduling. |
| Integration boundaries | Add Companion OIDC, calendar sync, MCP and push without moving the local SQLite records. |
The browser client uses the local planning API and SQLite as its source of truth. Optional identity, calendar and inference integrations attach at the server boundary.
flowchart LR
PWA[SolidJS planning PWA] --> API[Koa API]
API --> DB[(Local SQLite)]
API --> Jobs[Croner scheduling and review jobs]
Jobs --> DB
API -. optional .-> OIDC[Companion OIDC]
API -. optional .-> Calendar[Google Calendar sync]
API -. optional .-> Ollama[Local Ollama]
API --> Push[Web Push]
Push --> SW[Browser service worker]
From a source checkout (Node ≥ 22 + pnpm). The private @companionintelligence packages need a GitHub token in ~/.npmrc first; see Fresh clone install.
corepack enable # activates the pinned pnpm version
pnpm install
pnpm dev # API server on :3848 + client dev server on :5173Open http://localhost:5173. A dev run needs no configuration and no login
(AUTH_MODE=test is the default outside production), and the AI features simply stay
hidden until a local Ollama is reachable. In VS Code, the same
thing is one keypress: open the repo folder and press F5 (Full stack (pnpm dev)).
The full guide — prerequisites, server/.env, the VS Code launch configurations, seed
data, Ollama, production-style runs — is in Local development.
Prefer a container? See Run with Docker.
A screen-by-screen tour of the SolidJS PWA, on the
Companion Intelligence design system
— phthalo-mist primary (#0a6358 light / #c5e8dc dark), Manrope, light + dark. Dark is
the default; the sidebar toggle switches to light. Every pair below is a real headless
render of the built client served by the Koa server against a seeded SQLite database
(pnpm seed --no-llm, plus the Try with sample data set from the first-run wizard),
captured at 1440×900 in both themes — not a mockup. Every name, company and task in them is
synthetic fixture content.
The one sidebar toggle flips the whole app between the two canonical CI themes:
The day overview: the five-star day tally, the day header (previous / next day, date picker) with a capacity bar (scheduled vs. workday hours) and buttons for Due, the day's Brief, Focus and a day note, the OPEN TIME windows left in the day, and the PLANNER grid (day-type picker, Schedule, Undo, Clear, Update) for placing blocks. The Flight Plan staging list lives in the details drawer, and the floating Add button opens the wizard that hosts the one-line natural-language quick entry.
| Light | Dark |
|---|---|
![]() |
![]() |
Day / week / month grid with the current week in view, plus Export, Review, Plan my week and Schedule Day controls, an All blocks / Events only toggle, and the overlays filter.
| Light | Dark |
|---|---|
![]() |
![]() |
The full backlog — priority chips (P1–P3 · Later), tags, direction filter, and the Tasks / Objectives / Habits / Routines / Timeblock tabs. Rendered against ~40 seeded tasks plus the sample-data set. Every task created today — manual, quick-add, or auto-promoted suggestion — sits in a New Today section at the top and flows into the normal sections at the owner-local midnight rollover (the standalone Inbox view was retired in Phase AF: calendar, memory and text suggestions now auto-promote, and email suggestions wait for your approval in a Suggested from email section at the top of the tab).
Section precedence is Routine › Scheduled › New Today › Open, so scheduling a task the same day it arrived moves its card to Scheduled immediately rather than leaving it in New Today until midnight. New Today therefore means arrived today and still needs a decision.
| Light | Dark |
|---|---|
![]() |
![]() |
Hierarchical life goals — Goal → Project → Objective — with task linkage and
progress roll-up. Since Phase AF the tree lives as the Objectives tab of the
Tasks view (/tasks?tab=objectives); the standalone /objectives view is
retired. Shown with the sample-data objective (three linked steps, one done, so the
roll-up reads 33 %); New Objective opens the editor.
| Light | Dark |
|---|---|
![]() |
![]() |
Regenerating these: build the client (
pnpm --filter client build), seed a throwaway DB (DB_PATH=/tmp/shots.db pnpm --filter server seed -- --no-llm --reset --target=40—--target=40is the fixture corpus with no filler rows), start the server against it (DB_PATH=/tmp/shots.db PORT=8842 GOOGLE_MODE=off OLLAMA_URL=http://127.0.0.1:1 pnpm --filter server start), then stage it the way a first-run user would:POST /api/demo/sample-data, markonboarded_atinPATCH /api/prefs,POST /api/auto/fill+/api/auto/runfor today,POST /api/days/<today>/reflow/apply, file the intention tags into directions (PATCH /api/tags/:id), and complete one objective step. Then screenshot each route (/,/calendar,/tasks,/tasks?tab=objectives) at 1440×900, device scale 2, in both themes (localStorage['vite-ui-theme']) intodocs/ui/planning-<screen>-<theme>.png, and the Screenshots tour below at 1280×800 dark. The Tags, Jobs, and Settings screens live in that tour; Routines has no screenshot yet.
graph TB
subgraph Browser["Browser (SolidJS PWA)"]
UI[Nine Views<br/>Today · Focus · Calendar · Tasks incl. Objectives · Habits · Routines · Timeblock tabs<br/>Tags · Priorities · Balance · Jobs · Settings]
Store[Reactive Stores<br/>createResource / createSignal]
SW[Service Worker<br/>Web Push + installable PWA]
BN[Reminders<br/>in-app toast + Web Push]
end
subgraph Server["Koa Server — port 3848"]
API[REST API<br/>48 route modules]
GQL[GraphQL /api/graphql<br/>Apollo, alongside REST — Phase W]
SSE["SSE /api/events<br/>real-time push<br/>(app shells poll /api/sync/changes)"]
Engine[Auto-scheduler<br/>engine/autoplan · review · reflow]
Cron[Croner Jobs<br/>25 cron jobs · 26 kinds]
Migrate[Migration runner<br/>0001 → head on boot]
end
subgraph Data["Data Layer"]
DB[(SQLite<br/>companion.db)]
Drizzle["store() facade + Drizzle ORM<br/>over better-sqlite3"]
end
subgraph AI["AI — optional"]
Ollama[Ollama<br/>local LLM]
Small["Fast tier — default qwen2.5:0.5b<br/>extraction / triage"]
Medium["Quality tier — default gemma3n:e4b<br/>briefings, quick-add classification"]
Embed["Embedding tier — default nomic-embed-text<br/>dedupe embeddings"]
end
subgraph Sources["External Sources — off until connected"]
Gmail[Gmail ingest]
GCal[Google Calendar ingest]
Mem["CI-Server memory<br/>pull graph · push events"]
end
UI -->|"fetch /api/*"| API
UI -->|"POST /api/graphql"| GQL
Store -->|SSE stream| SSE
API --> Engine
GQL --> Drizzle
API --> Drizzle
Cron --> Drizzle
Cron --> Ollama
Cron --> Gmail
Cron --> GCal
Cron <--> Mem
Drizzle --> DB
Migrate --> DB
Ollama --> Small
Ollama --> Medium
Ollama --> Embed
SSE -->|task / block / job / reminder events| Store
Store --> BN
API -->|Web Push| SW
The class diagrams below are the domain model as the code actually declares it
(server/src/db/schema.ts plus the raw-SQL tables created by
the migrations). Every entity is owner-scoped —
owner_id is the tenant boundary, and it is omitted from the field lists below only to
keep them readable. The exceptions are the join and entity-spanning stores (task
dependencies, pattern and objective membership, tag groupings, entity_tags,
entity_categories, entity_people, …), which are scoped through the rows they point
at; possible_tasks, whose tenant column is proposed_owner_id; and the server-global
tables (scheduled_jobs, job_runs, app_config, auth_states, signup_codes).
Identifiers are UUIDv7 TEXT (CON-1; the one exception is sync_events.seq, an
autoincrementing integer), timestamps are TEXT — ISO-8601, UTC unless a field name says
local (columns defaulted from SQLite's CURRENT_TIMESTAMP use its YYYY-MM-DD HH:MM:SS
form) — and booleans are SQLite integers.
1. Core planning domain — the entities you touch on Today, Calendar, and Tasks:
classDiagram
direction LR
class Owner {
+string id
+string name
+string email
+string role operator|member|viewer
+string externalSubject OIDC sub
+string username
+string lastAutoRunId for Undo
}
class SchedulingPrefs {
+string timezone IANA
+int maxFocusBlockMinutes
+int minBreakMinutes
+int planningHorizonDays
+int recurrenceHorizonDays
+string planningMode basic|smart, independent of trustMode
+string trustMode cautious|balanced|hands_off
+string fillOrder priority|overdue|new_today
+int memoryWindowDays 1-30, reads AHEAD
+int emailLookbackDays 1-90, reads BACK
+int capacityHoursPerWeek default 56
+int sleepHoursPerWeek default 63, unschedulable
+bool autoFillWorkday
+bool autoDefenseEnabled
+bool balanceBiasEnabled
+bool trialityEnabled
+string onboardedAt null = wizard not done
}
class Task {
+string id
+string title
+string status
+int priority 1..5
+string dueAt deadline
+string scheduledFor local YYYY-MM-DD START intent
+string taskTime HH:MM START intent
+string deferUntil hidden from Today until then
+int estimatedMinutes
+int actualMinutes
+string subtype standard|habit|quick
+string parentId subtask link
+string recurrenceRule RRULE
+string motivation me|us|them|null
+string motivationSource user|llm
+int completionScore 1-5, 0 = declined, null = never asked
+string completionNote closing reflection
+string reactionAt
+bool textureEnabled striped-texture override
+string textureColorToken
+int version
}
class TimeBlock {
+string id
+string title
+string blockKind task|meeting|habit|focus|break|buffer|timeblock
+string blockType scheduled|someday
+string startAt
+string endAt
+bool allDay header lane, off the minute axis
+bool isLocked user pin, or always-on for inbound-synced blocks
+bool isAuto engine-chosen slot
+string status planned|completed|skipped|overrun
+string parentBlockId container
+string recurrenceRule RRULE
+string autoRunId placement run
+string lastMovedAt anti-thrash
+bool responsiblePinned own people-set wins even if empty
+bool beneficiariesPinned same, for beneficiaries
+bool textureEnabled striped-texture override
+string textureColorToken
+int version
}
class TimeEntry {
+string id
+string startedAt
+string endedAt null = running
+string source timer|manual|block
}
class TaskDependency {
+string taskId
+string dependsOnId
}
class FlightPlanEntry {
+bool selected
+int position
+string sourceKind
}
class DailyPlan {
+string date
+int position
+string rationale why now
+string source llm|deterministic
+string outcome deferred|missed, write-once
+string blockId
}
class Tag {
+string id
+string name
+string kind direction|intention|category
+bool muted
+bool isBase
}
class EntityTag {
+string entityKind task|time_block|routine
+string entityId
+int level 1 primary, 2 secondary
}
class EntityCategory {
+string entityKind task|time_block
+string entityId
+int position 0..2 stripe order
}
class TagPref {
+string kind direction|category
+string colorToken
+int defaultMinutes
+bool autoFill
+int[] reminderOffsets
+string iconShape
+bool showOpenTime direction-only, Open Time card
}
class Pattern {
+string id
+string name
+string recurrenceRule
+string colorToken
}
class PatternType {
+string name Habit|Routine|Ritual|Community|custom
+string iconShape
+bool isBuiltin
+bool catchAll
}
class PatternBlock {
+string dayType
+string startTime HH:MM
+string endTime HH:MM
}
class PatternDayOptout {
+string date local day the pattern was removed
}
class DayType {
+string key workday|weekend|custom
+string name
+bool isBuiltin
}
class DayOfWeekMap {
+int dow 0 = Sun to 6 = Sat
+string dayTypeKey
}
class DayAssignment {
+string date YYYY-MM-DD
+string dayTypeKey
}
class BlockTemplate {
+string name
+string tag
+int defaultMinutes
+json tasks saved with the Plan
}
class Person {
+string displayName
+string accountId the login this person is, if any
+bool confirmed false = provisional, from ingest
+string source manual|account|gcal|gmail|llm
+string notes
}
class PersonEmail {
+string email lowercased, unique per owner
}
class EntityPerson {
+string entityKind task|time_block
+string entityId
+string role responsible|beneficiary
+string source user|llm|calendar|email
+int position 0 = the lead
}
Owner "1" --> "1" SchedulingPrefs : configures
Owner "1" --> "*" Task : owns
Owner "1" --> "*" TimeBlock : owns
Owner "1" --> "*" Tag : owns
Owner "1" --> "*" DayType : defines
Owner "1" --> "*" DayOfWeekMap : weekly default
Owner "1" --> "*" DayAssignment : per-date override
Owner "1" --> "*" BlockTemplate : saves
Owner "1" --> "*" Person : directory
Task "1" --> "*" TimeBlock : placed as
Task "1" --> "*" TimeEntry : timed by
TimeEntry "*" --> "0..1" TimeBlock : started from
Task "1" --> "*" Task : subtask of
Task "1" --> "*" TaskDependency : blocked by
Task "1" --> "0..1" FlightPlanEntry : staged in
Task "1" --> "*" DailyPlan : ordered in
TimeBlock "1" --> "*" TimeBlock : contains
Tag "1" --> "*" EntityTag : labels
Tag "1" --> "*" EntityCategory : categorizes
Tag "1" --> "0..1" TagPref : configured by
Tag "1" --> "*" PatternBlock : scopes
Tag "*" --> "*" Tag : intention under a direction, tag_directions
Tag "*" --> "*" Tag : intention under a category, tag_categories
Tag ..> Tag : category opts out of a direction
Task ..> EntityTag : polymorphic, app-enforced
TimeBlock ..> EntityTag : polymorphic, app-enforced
Task ..> EntityCategory : 1..3 per entity
TimeBlock ..> EntityCategory : 1..3, else inherits task
Person "*" --> "0..1" Owner : accountId, self row when it equals the tenant
Person "1" --> "*" PersonEmail : matched by
Person "1" --> "*" EntityPerson : named on
Task ..> EntityPerson : polymorphic, app-enforced
TimeBlock ..> EntityPerson : polymorphic, inherits the task set unless pinned
PatternType "1" --> "*" Pattern : types
Pattern "*" --> "*" Task : collects, pattern_tasks
Pattern "1" --> "*" PatternDayOptout : removed on this day
DayType "1" --> "*" PatternBlock : selects windows
DayType "1" --> "*" DayOfWeekMap : weekly default, by key
DayType "1" --> "*" DayAssignment : per-date override, by key
2. Life planning, reflection, and learning — the layer above the day:
classDiagram
direction LR
class Objective {
+string id
+string title
+string level goal|project|objective, by height
+string parentId
+real progressCache null = nothing to measure
+string sourceTaskId auto-promoted
+string colorToken
+string archivedAt soft delete
+string archiveRootId cascade unit
+string purgeAfter 48h reap deadline
+int version
}
class ObjectiveTask {
+bool linked false = unlink tombstone
}
class Reflection {
+string kind today_goal|weekly_goal|day_note|evening
+string periodKey YYYY-MM-DD or YYYY-Www
+int position 0..2 for weekly
+string body
+string sourceKind manual|task|objective
+string sourceRef
+string doneAt
}
class DailyRecap {
+string date
+string prose briefing cache
+string inputsHash
+bool starObjective today goal done
+bool starHabits
+bool starTasks
+bool starBonus
+bool starNote task done and a day note
+int total 0..5
+string frozenAt permanent once set
}
class LifePriority {
+string category health|career|relationships|growth|leisure|finance
+int satisfaction 1..10
+int importance 1..10
+int targetHoursPerWeek
}
class EstimateCalibration {
+string scopeKind global|subtype|tag|tag_combo|category|objective
+string scopeRef
+real biasRatio median actual/estimated
+real p25
+real p75
+int sampleCount
}
class CalibrationComboPin {
+string scopeRef sorted tag-id tuple
}
class CalibrationHistory {
+string scopeKind
+string scopeRef
+string computedDate
+real biasRatio
+int sampleCount
}
class TrialityLog {
+string slot habitual|contextual|novel
+string actionKind task|routine|flight|template
+string actionRef
+string scores per-component JSON
+string confidence
+string shownAt
+string outcome shown|opened|staged|dismissed
}
class TrialityWeights {
+string slot
+string weights JSON
+int sampleCount
+string computedDate
}
class ClassificationLog {
+string field category|motivation
+string inputText
+string llmValue
+string finalValue null until corrected
+string assignedAt
+string correctedAt
}
class ClassificationExemplars {
+string field
+string llmValue what the model said then
+string correctValue what the owner said
+blob embedding nullable, no embedder at calibration time
+string computedDate
}
class ClassificationAccuracy {
+string field
+string computedDate
+int heldOut
+int correct
+real accuracy null = nothing measured
}
class PossibleOutcomeLog {
+string possibleTaskId no FK, evidence outlives the row
+string sourceKind
+string inputText
+string outcome dismissed|promoted
+string decidedAt load-bearing: no dismissed_at on possible_tasks
+string localDate
}
class PossibleDismissalExemplars {
+string logId
+string inputText
+string sourceKind
+blob embedding nullable, no embedder at calibration time
+string computedDate
}
class PossibleDismissalRate {
+int periodDays
+int created
+int decided
+int dismissed
+real rate null when decided = 0, never 0
+int exemplars
+bool hinted
}
class PlanLoopRun {
+string stage 'order' today
+bool passed
+bool escaped
+string escapeReason plateau|max-iterations|budget
+real score best judge median, 0..1
+int iterations
}
class PlanRevisionLog {
+string runId no FK, written before the run row exists
+string stage
+int iter
+real score
+string reason deterministic-fail|judged|passed-no-judge
+string defects JSON rule codes
+string critique
}
class PlanLoopStats {
+string stage
+string computedDate
+int periodDays
+int runs
+int passed
+int firstTryPass
+real avgIterations null when runs = 0
+real passRate null when runs = 0
}
class BalanceWeekLog {
+string weekStart owner-local Monday
+string category life_priorities.category, no FK
+int targetHours frozen at snapshot time
+int scheduledMinutes
+int timedMinutes finished time_entries
}
class PlacementLog {
+string slotKey direction tag name, or none
+string blockId no FK, evidence outlives the block
+string taskId no FK
+string chosenStartAt
+int chosenHour owner-local, 0-23
+bool fit false = forced, truncated gap
+string candidates JSON menu of free gaps weighed
+string movedStartAt null until the owner moves it
+int movedHour
+string placedAt
+string movedAt
+string localDate
}
class PlacementPreferences {
+string slotKey
+real p25Hour band where corrections landed
+real p75Hour
+int corrections
+int sampleCount
+string computedDate
}
class PlacementSurvival {
+string computedDate
+int periodDays
+int decided
+int accepted
+int corrected
+real survivalRate null when decided = 0, never 0
+int keysLearned
+bool biased
}
class PlanningSession {
+string startedAt
+string endedAt
+string entryBranch fill_week|schedule_tasks|review|onboarding
+string status open|completed|abandoned
+bool llmUsed
+string transcript JSON array, whole conversation
+string summary
}
Owner "1" --> "*" Objective : owns
Owner "1" --> "*" Reflection : writes
Owner "1" --> "*" DailyRecap : one per day
Owner "1" --> "*" LifePriority : one per category
Owner "1" --> "*" EstimateCalibration : regenerable rollup
Owner "1" --> "*" CalibrationComboPin : confirmed tag combos
Owner "1" --> "*" CalibrationHistory : append-only trend
Owner "1" --> "*" TrialityLog : append-only evidence
Owner "1" --> "*" TrialityWeights : learned, resettable
Owner "1" --> "*" ClassificationLog : append-only evidence
Owner "1" --> "*" ClassificationExemplars : learned, resettable
Owner "1" --> "*" ClassificationAccuracy : measured trend
Owner "1" --> "*" PossibleOutcomeLog : append-only evidence
Owner "1" --> "*" PossibleDismissalExemplars : learned, resettable
Owner "1" --> "*" PossibleDismissalRate : measured trend
Owner "1" --> "*" PlanLoopRun : append-only
Owner "1" --> "*" PlanRevisionLog : append-only evidence
Owner "1" --> "*" PlanLoopStats : measured trend
Owner "1" --> "*" BalanceWeekLog : frozen weekly history
Owner "1" --> "*" PlacementLog : append-only evidence
Owner "1" --> "*" PlacementPreferences : learned, resettable
Owner "1" --> "*" PlacementSurvival : measured trend
Owner "1" --> "*" PlanningSession : one per Plan my week
Objective "1" --> "*" Objective : parent of
Objective "1" --> "*" ObjectiveTask : progressed by
Task "1" --> "*" ObjectiveTask : rolls up into
Reflection ..> Task : optional source link
Reflection ..> Objective : optional source link
CalibrationComboPin ..> EstimateCalibration : only pinned combos get a tag_combo row
TrialityLog ..> TrialityWeights : weekly calibration
ClassificationLog ..> ClassificationExemplars : weekly calibration
PossibleOutcomeLog ..> PossibleDismissalExemplars : weekly calibration
PlanRevisionLog ..> PlanLoopRun : run_id, no FK
PlanLoopRun ..> PlanLoopStats : weekly calibration
BalanceWeekLog ..> LifePriority : category name match, no FK
PlacementLog ..> PlacementPreferences : weekly calibration
PlacementLog ..> TimeBlock : block_id, no FK
3. Automation, integrations, and sync — everything that runs without you:
classDiagram
direction LR
class ScheduledJob {
+string name
+string kind
+string cron
+bool enabled
+string lastRunAt
}
class JobRun {
+string startedAt
+string finishedAt
+string status running|done|error
+int itemsCreated
+int itemsUpdated
+string log
}
class PossibleTask {
+string rawTitle
+string sourceKind gmail|gcal|memory
+string sourceRef
+real confidence
+string status pending|promoted|dismissed|duplicate
+string promotedTaskId
+int proposedEstimatedMinutes
+string proposedScheduledFor
+string proposedTaskTime
}
class PossibleTaskPerson {
+string role responsible|beneficiary
+string source llm|calendar|email
+real confidence per row
}
class Reminder {
+string fireAt
+string status pending|fired|snoozed|cancelled
+string source manual|auto
}
class PushSubscription {
+string endpoint
+string p256dh
+string auth
}
class ConnectionToken {
+string provider google|ci_memory
+string account
+string ciphertext AES-256-GCM
+int keyVersion
+string status active|revoked|error
}
class OAuthState {
+string provider google|ci_memory
+string state CSRF nonce
+string codeVerifier PKCE secret
+string redirectUri
+string expiresAt
}
class ConnectionPref {
+string provider
+string account
+string colorToken
+string texture
}
class ExternalEvent {
+string provider
+string account
+string externalId
+string etag
+string direction inbound|outbound
}
class OutboundQueue {
+string provider
+string account
+string createdAt
}
class SyncState {
+string provider
+string account
+string resource
+string cursor
}
class SyncEvent {
+int seq
+string entity
+string entityId
+int version
+string event
+string payload
}
class AuditLog {
+string actor owner|system|agent
+string action
+string entity
+string at
}
class CopyHistory {
+string surface
+string signature JSON phrasing fingerprint
}
class AppConfig {
+string key google_* and vapid_* rows
+string value secrets sealed with CP_KEK_B64 when set
+string updatedAt
}
class ReplanSuggestion {
+string date
+string reason
+string summary
+int atRisk
}
class PlanHint {
+string date
+string kind tag_before|tag_after|batch
+string tag
+string time
}
ScheduledJob "1" --> "*" JobRun : logs
ScheduledJob ..> PossibleTask : ingest creates
PossibleTask "1" --> "0..1" Task : auto-promotes into
PossibleTask "*" --> "*" Tag : proposed tags
PossibleTask "1" --> "*" PossibleTaskPerson : detected people
PossibleTaskPerson ..> Person : carried onto the task at promote
Owner "1" --> "*" Reminder : scheduled for
Owner "1" --> "*" PushSubscription : devices
Owner "1" --> "*" ConnectionToken : sealed creds
ConnectionToken ..> ConnectionPref : same provider+account key
ConnectionToken ..> SyncState : per-resource cursor
Owner "1" --> "*" OAuthState : in-flight authorize
OAuthState ..> ConnectionToken : callback stores sealed creds
ExternalEvent "*" --> "0..1" TimeBlock : mirrors
TimeBlock "1" --> "*" OutboundQueue : pending push
Owner "1" --> "*" SyncEvent : SSE replay log
Owner "1" --> "*" AuditLog : append-only
Owner "1" --> "*" CopyHistory : voice anti-repetition window
Owner "1" --> "0..1" ReplanSuggestion : one nudge
Owner "1" --> "*" PlanHint : NL day windows
Reminder ..> Task : task XOR block target
Reminder ..> TimeBlock : task XOR block target
4. Identity and sign-up — who can sign in. An owner's auth mode is inferred from
which columns are set: external_subject means Companion Portal (OIDC) login,
username plus password_hash means local login, and all NULL is the system-owner
sentinel. The session itself is a stateless signed cookie carrying the owner id and
session_epoch, so there is no session table:
classDiagram
direction LR
class Owner {
+string email
+string role operator|member|viewer
+string externalSubject set = Companion Portal login
+string username with passwordHash = local login
+string passwordHash argon2id
+string backupEmail reset fallback
+int sessionEpoch bump to revoke every cookie
}
class ApiKey {
+string prefix first 5 chars, lookup and display
+string keyHash SHA-256, raw key shown once
+string scopes JSON array
+string expiresAt null = non-expiring
+string lastUsedAt
+string revokedAt
}
class AuthState {
+string state CSRF nonce
+string codeVerifier PKCE secret
+string nonce
+string redirectUri
+string expiresAt
}
class PasswordResetToken {
+string tokenHash sha256, raw only in the emailed link
+string expiresAt
+string usedAt single use
}
class SignupCode {
+string codeHash sha256, raw shown once at mint
+string note operator label
+string createdBy
+string expiresAt 7-day TTL
+string usedAt single use
+string usedBy the account it created
+string revokedAt
}
Owner "1" --> "*" ApiKey : standalone clients
Owner "1" --> "*" PasswordResetToken : resets and account invites
Owner "1" --> "*" SignupCode : mints, SET NULL on delete
SignupCode "*" --> "0..1" Owner : used by
AuthState ..> Owner : callback finds or provisions by externalSubject
Three relationships are app-enforced, not FK-enforced, because their
entity_idspans several tables:entity_tags(task · time block · routine),entity_categories(task · time block) andentity_people(task · time block).services/tags.tsowns every entity-delete path (removeEntityTagscleans all three stores), and the orphan-free tests are the gate.
server/src/lib/config.ts resolves DB_PATH at startup:
- If
DB_PATHenv var is set, use it as-is (Docker mounts/data/companion.db). - Otherwise fall back to
~/Library/Application Support/CompanionPlanning/companion.db(macOS default). On Linux or Windows, setDB_PATHexplicitly. mkdirSync(..., { recursive: true })creates the containing directory before the file is opened — so a fresh install with no prior DB just works.
server/src/db/index.ts then opens and configures the connection (through openDb in
server/src/db/open.ts):
BetterSqlite3(config.dbPath) // synchronous open — SQLite single-writer, no pool
.pragma('foreign_keys = ON') // enforced at the connection level
.pragma('busy_timeout = 5000') // wait up to 5 s on a locked database before failing
.function('uuid7', () => newId()) // UDF for raw-SQL inserts (CON-1 UUIDv7 identity)
sqliteVec.load(sqlite) // best-effort sqlite-vec extension (possible-task dedupe);
// on failure dedupe falls back to a plain cosine loop
runMigrations(sqlite) // sequential, idempotent — switches the file to WAL and
// applies any unapplied migrations from the MIGRATIONS list
setStore(createSqliteStore(sqlite)) // the async data facade (db/store.ts) most call sites use
drizzle(sqlite, { schema }) // exports `db` (Drizzle) and `sqlite` (the raw handle)
The migration runner (server/src/db/migrate.ts) keeps a schema_migrations table. On
every server start it compares the ordered MIGRATIONS array against that table and
runs any missing ones inside a transaction (the few manual migrations that rebuild a
table manage their own transaction so they can toggle foreign_keys). Migrations are
plain TypeScript functions — no .sql files, no external tool.
Most queries go through store() (server/src/db/store.ts), an async facade over this
handle; the Drizzle db is used by a handful of routes. The facade exists for the
in-progress Postgres port (DATABASE_URL / STORE_BACKEND), which is not a supported
deployment option yet — see Environment variables.
Migrations produce the tables below. The authoritative, ordered migration list — and
therefore the current head — is
server/src/db/migrations/index.ts; at the time
of writing (2026-11-01) main is at 0173_classification_entity_kind, the 169th migration (the
numbering skips 0009, 0119, 0159 and 0160).
| Table | Purpose |
|---|---|
owners |
User identity — role drives scopes (0125): operator (R+W+A) · member (R+W) · viewer (R) |
tasks |
Core work atom — UUIDv7 PK, status, priority, recurrence fields, subtype; texture_enabled/texture_color_token (0135) per-element striped-texture override; close-ritual columns completion_score/completion_note/reaction_at (0137, rescored 0161 — all nullable, the ritual is optional; completion_score is 1–5, or 0 for "prompted and declined", which is excluded from every aggregate); motivation (me/us/them, NULL = unlabeled — never read as us) + motivation_source (user | llm; a user label wins permanently) (0141) |
task_dependencies |
Blocked-by edges between tasks |
time_blocks |
Named time spans — scheduled / someday / saved states; recurrence support; same texture_enabled/texture_color_token override (0135); legacy category_id/color_token (0069) are read-only now — writes go through entity_categories |
block_templates |
Reusable Time-Block templates with saved task lists |
time_entries |
Actual work logged per task; running timer = ended_at IS NULL |
possible_tasks |
Staged AI-proposed items — calendar, memory and text suggestions are auto-promoted into tasks at the end of each ingestion run; Gmail suggestions stay pending until you approve them (services/review-gate.ts). Rows are kept as the audit/dedup trail (Phase AF) |
tags |
Cross-cutting labels; kind: direction (Home / Work / Community), intention, or category (base: the six Balance dimensions — health/career/relationships/growth/leisure/finance — re-seeded in mig 0134, superseding the old Personal/Professional/Social set) |
tag_directions |
Many-to-many: intention tags grouped under direction tags (0043 briefly renamed this to tag_patterns; reverted in 0105) |
tag_categories |
Many-to-many: intention tags filed under category tags — the membership the Tags → Time Block Categories tab edits (0069) |
category_direction_optouts |
Sparse per-category opt-out of directions (category_tag_id, direction_tag_id) — no rows means every direction is on (0069) |
entity_tags |
Polymorphic tag store for tasks, blocks, and routines (CON-3); carries created_at (NOT NULL DEFAULT CURRENT_TIMESTAMP, table rebuilt in 0110) backing GraphQL EntityTagType.taggedAt |
entity_categories |
Polymorphic category store for tasks/time_blocks — 1–3 per entity (entity_kind, entity_id, category_tag_id, position); position orders the striped-texture stripes (0134) |
people |
Per-owner directory of the people named as Responsible / Beneficiary on tasks and time blocks — owner_id is tenancy, account_id (nullable) is the login account the person is; confirmed = 0 marks people ingest created provisionally; each owner has a "me" row (account_id = owner_id) (0151; docs/people-on-work.md) |
person_emails |
Lowercased email → person index, unique per (owner_id, email) — the deterministic key for matching people detected in calendar invites and mail headers (0151) |
entity_people |
Polymorphic people store for tasks and time blocks — (entity_kind, entity_id, person_id, role) with role responsible / beneficiary and position 0 = the lead; no FK on entity_id because it spans tables. A block with no rows of its own inherits its task's people unless its responsible_pinned / beneficiaries_pinned flag is set (0151, 0153–0154) |
possible_task_tags |
Tags proposed alongside a staged suggestion |
possible_task_people |
People detected on a staged suggestion — (possible_task_id, person_id, role) with source and a per-row confidence; copied into entity_people when the suggestion is promoted (0152) |
patterns |
Routines — typed, reusable task collections with optional recurrence |
pattern_types |
Routine taxonomy: Habit ◯ · Routine ▢ · Ritual △ — load-bearing, do not rename |
pattern_tasks |
Routine membership (pattern ↔ task many-to-many) |
pattern_blocks |
Direction-scoped scheduling windows, one set per day_type (the built-in workday / weekend, or a custom day type) |
pattern_day_optouts |
Per-(owner, routine, local day) opt-out — records "removed that day" so the lazy placement on GET /api/blocks does not re-create a deleted block (0074) |
day_types · day_of_week_map · day_assignments |
Which day-type a date is (0068): a per-date assignment beats the weekly map, which beats the weekend heuristic. The resolved key selects the pattern_blocks windows Auto places into |
tag_prefs |
Per-owner × per-tag config, partitioned by kind (direction | category): shared color_token/default_minutes, plus direction-only auto_fill/reminder_offsets/icon_shape/show_open_time (whether a direction's windows count in Today's OPEN TIME summary, default on; 0170); merged from pattern_prefs + category_prefs (0097). category rows are the six Balance-dimension hues (0134) |
flight_plan |
Staged task list for the two-step Auto (select → schedule); composite PK (owner_id, task_id) with owner_id TEXT NOT NULL (0093) |
scheduling_prefs |
Focus/break bounds, capacity_hours_per_week (default 56; replaces the retired workday_start/workday_end window — per-direction windows live in pattern_blocks; 0156), model overrides, feature flags (including llm_thinking_enabled, 0155, and balance_bias_enabled, default on, 0164), recurrence_horizon_days (materialization ceiling, default 7; 0091), sleep_hours_per_week (default 63 — reserved as unschedulable; 0128), and the ingestion windows memory_window_days / email_lookback_days (0147) |
app_config |
Server-owned singleton config set in-app rather than via env — Google connection setup + the auto-provisioned Web Push VAPID keypair (0124) |
life_priorities |
Life-priorities self-audit per (owner, category) — satisfaction · importance · target hours/week; backs /priorities + /balance (0127). /balance also joins tasks by their entity_categories to show each dimension's related open tasks (0134) |
scheduled_jobs |
Croner job registry (name, cron expression, enabled) |
job_runs |
Execution log per job run (status, items created/updated, log text) |
reminders |
Scheduled reminders (fire_at, task XOR block target, source manual/auto) fired by the 1-min dispatcher (Phase H, 0044/0045) |
push_subscriptions |
Web Push endpoints per owner (VAPID), for backgrounded delivery (Phase H, 0044) |
audit_log |
Append-only mutation log (owner, actor, action, entity) written by every mutating route (Phase R, 0046) |
api_keys |
Hashed x-api-key credentials for AUTH_MODE=standalone (0049) |
auth_states |
Single-use pre-login Companion Portal OIDC PKCE/state rows, consumed at the callback (Phase U, 0102) |
password_reset_tokens |
Single-use local-login password-reset tokens (Phase V, 0104) |
signup_codes |
Single-use invite codes an operator mints (Settings → Profile → Team, AUTH_SIGNUP=invite) so someone can create their own local account at /signup — code_hash is the sha256 of a code that is shown once and never stored; 7-day expires_at, used_at / used_by, revoked_at (0172) |
connection_tokens |
Encrypted OAuth tokens per (owner, provider, account) — AES-256-GCM sealed with CP_KEK_B64; status ∈ active/revoked/error (Phase J 0050; status 0058) |
external_events |
Sync identity map linking provider events ↔ local time_blocks by (provider, account, external_id) + etag/direction (Phase J 0051) |
outbound_queue |
Local blocks pending push to a provider — one row per (block, provider, account), drained by the sync agent when CP_SYNC_WRITEBACK is on (Phase J 0057) |
oauth_states |
Single-use OAuth 2.1 PKCE/state rows, consumed at the callback (0058) |
sync_state |
Incremental-sync cursors per (owner, provider, account, resource) — calendar syncToken, Gmail watermark, dead-letter (0060) |
connection_prefs |
Per-(owner, provider, account) display config — the colour token and stripe texture the calendar paints that source's mirrored events with. Deliberately keyed on the natural key, not an FK to connection_tokens, so a disconnect/reconnect cycle restores your choice (0145) |
daily_plan |
AI-authored task order for a day — one row per (owner, date, task) with a "why now" rationale and outcome stamp (0062, 0084) |
plan_hints |
Transient per-tag day windows parsed out of the natural-language planner bar ("mornings for Deep Work") — placement reads them as a hard window, the LLM ordering folds them in as a soft constraint (0071) |
replan_suggestions |
Pending "re-plan with AI?" nudges surfaced on big-disruption reflows (0067) |
planning_sessions |
One row per "Plan my week" onboarding session — the whole conversation as a single JSON transcript, no per-turn child table; read by the Settings → Logs tab, exportable/deletable there (FTUE, 0131) |
daily_recap |
One row per (owner, date) — the day-briefing cache (prose/stats/focus, keyed by inputs_hash) and the frozen five-star score (star_*/total/frozen_at); merged from daily_summaries + day_stars (0096) |
sync_events |
Append-only SSE event log for Last-Event-ID catch-up replay (Phase L, 0077); GET /api/sync/changes?since= serves the same rows as JSON to clients that cannot hold an EventSource, such as the app shells |
reflections |
FOCUS card slots — today focus + up to 3 weekly goals per ISO week, with optional category, done-at stamp, and an optional source link to a Task/Objective (source_kind/source_ref, 0088); also holds the one-per-day kind='day_note' row — no migration, reuses the same table + the PK's existing uniqueness (Phase N, 0080; linked focus 0088) |
objectives |
Hierarchical life goals — Goal › Project › Objective (by tree height); progress derived from task completion; source_task_id marks auto-promoted nodes; version/field_ts for multi-device sync (Phase N, 0081; 0085–0086; sync versioning 0087). Archive (0129): archived_at soft-delete + archive_root_id (cascade unit) + purge_after (48h reap deadline) |
objective_tasks |
Links objectives to Tasks; enables progress roll-up (Phase N, 0081) |
estimate_calibration |
Per-owner bias stats (median actual/estimated ratio + quartiles) recomputed nightly (01:30) by the calibrate_estimates job (Phase N, 0083; scope columns + the move off a weekly cron, 0107) |
calibration_combo_pins |
Time-Sense (Phase Z) tag-combo confirmations — the system only ever suggests a divergent tag combo, computed on demand; a pinned row is the owner's explicit accept, and only pinned refs get a tag_combo row in estimate_calibration (0107) |
calibration_history |
Append-only calibration trend — one row per (owner, scope, day) written by the same job; the rollup above is regenerable, this table is never recomputed or pruned (0138) |
triality_log |
Append-only Triality outcome log — one row per suggestion shown, stamped at most once more when it is opened / staged / dismissed. Each row keeps that pick's full per-component score breakdown as JSON, which is what makes the weekly weight calibration evidence-based rather than guesswork; never recomputed, never pruned (0140). A confidence reading is stamped at show time (0144) — reported, never fed back into ranking; NULL means "shown before that column existed" permanently, and readers must exclude those rows rather than coerce them |
triality_weights |
Learned per-(owner, slot) Triality weights — regenerable, replaced wholesale by the weekly calibrate_triality job. A missing row means "score on the defaults", so deleting an owner's rows is the reset switch; the log above survives it (0142) |
classification_log |
Append-only evidence for the parse/classify LLM's category/motivation picks — what the model said, what the owner ultimately settled on (NULL until corrected), and the text judged; never recomputed, never pruned; entity_kind says whether the id is a task or a Time Block (Phase 8, 0149, 0173) |
classification_exemplars |
Regenerable few-shot retrieval index the classify prompts read (quick add and the Describe-it preview by frequency, memory enrichment by similarity), rebuilt wholesale per owner by the weekly calibrate_classification job; deleting an owner's rows resets the adaptation without losing the log above (0149) |
classification_accuracy |
One held-out accuracy score per field per calibration run — NULL (not 0) when a run measured nothing, so a trend chart draws a gap rather than a false dip (0149) |
possible_outcome_log |
Append-only evidence for dismissal learning — one row per decided suggestion (dismissed / promoted) with the text the extractor proposed and when it was decided; deliberately no FK to possible_tasks, so pruning a suggestion never deletes what it taught (0157) |
possible_dismissal_exemplars |
Regenerable retrieval index the extraction prompt reads, rebuilt wholesale per owner by the weekly calibrate_dismissals job (0157) |
possible_dismissal_rate |
One noise-rate reading per owner per calibration run, so "is the loop working" is a number (0157) |
plan_loop_runs |
One row per AI plan-ordering loop run (generate → judge → revise): stage, passed / escaped (+ escape_reason), best score, iterations (0162) |
plan_revision_log |
Append-only evidence — one row per candidate the loop scored (iter, score, reason, JSON defects, critique); run_id is deliberately not an FK, so the evidence survives a crash mid-run; the weekly calibrate_plan_loop log names its most-flagged rules (0162) |
plan_loop_stats |
Measurement — one row per (owner, stage, computed_date) written by the weekly calibrate_plan_loop job; a stage with no runs in the week gets no row; avg_iterations / pass_rate allow NULL, but the weekly job never writes one (0162) |
placement_log |
Append-only evidence — one row per engine placement with the free gaps pickGap weighed (candidates) and, once the owner drags it, where it was moved; no FKs to blocks or tasks (0166) |
placement_preferences |
Regenerable rollup — the learned hour band (p25_hour–p75_hour) per owner × direction slot that biases gap choice, rebuilt by the weekly calibrate_placement job (0166, 0167) |
placement_survival |
One reading per owner per calibration run — placements accepted vs. corrected; survival_rate is NULL when nothing was decided (0166) |
balance_week_log |
Append-only weekly history — each completed week's target / scheduled / timed minutes per life dimension, frozen by the Monday snapshot_balance job; first freeze wins, never recomputed (0165) |
copy_history |
Time-Sense voice-loop anti-repetition log — not included in owner export (0109) |
schema_migrations |
The migration runner's ledger — one row per applied migration id (applied_at, optional checksum), compared with the MIGRATIONS list on every start |
From an empty day to a planned one: capture a task, pick a priority, and let the planner place it on your calendar — no cloud round-trip, all on-device. The recording predates the current layout (it still shows the retired Inbox view); the screenshots below are current.
The first-run walkthrough has five steps: your name, your time and place, connecting a
source (Google, or sample data), your trust setting, and a first sync. The Connect a
source step also shows Your memory and AI: whether Companion Memory is connected, or
configured for this server through CI_SERVER_URL, and whether an AI model is ready. It
reads that state and changes nothing. Connect or change either under Settings →
Connections. Replay the walkthrough from Settings → Profile.
📹 A full video walkthrough is coming soon.
📸 Routines screenshot coming soon.
| Tags | Jobs |
|---|---|
![]() |
![]() |
| Direction + Intention taxonomy — group, mute, or delete; a second tab covers Time Block Categories | Background automations with their cron schedules — run any one on demand |
- Nine routed views — Today · Focus · Calendar · Tasks (hosts the Objectives + Routines tabs and a New Today section) · Tags · Priorities · Balance · Jobs · Settings, plus the unauthenticated
/login,/signupand/reset-password. The Inbox surface was retired in Phase AF (possible_tasksauto-promote);/inbox,/objectives,/patternsredirect into their host. (/routinesis not a route — it 404s.) - Full task CRUD — priorities P1–P5, due date + time, estimates, notes, tags, and subtasks
- Objectives you can undo — hierarchical life goals (Goal → Project → Objective) with task linkage and progress roll-up. Deleting an objective archives it rather than destroying it: the row is kept indefinitely, its whole live subtree is archived with it as a unit, and one click restores the lot. Permanent deletion is a separate, explicit request with a 48-hour window to change your mind — reaped hourly only once that deadline passes
- Life priorities + balance — a Priorities wizard rates each life category on satisfaction and importance and sets a target hours/week (sleep is reserved first, so the pool is real). The Balance dashboard reads that self-audit back on its Balance tab: a Balance Index of how far your satisfaction tracks what you said matters most, plus your allocated weekly targets per category, and an "N open" button per dimension → the tasks you've filed under it. That tab is stated intent; the Metrics tab beside it is measured reality — see below
- Categories, unified with Balance — tasks and time blocks can carry 1–3 categories (the same six Balance dimensions — health/career/relationships/growth/leisure/finance — picked in the Add/Edit wizard, or best-guessed by the natural-language quick-add). A block with no categories of its own inherits its linked task's, so auto-scheduled blocks stay in sync automatically. Each element can show an optional striped background in its categories' colours (even stripes, one per category, in the order you picked them) — toggle it off or override the colour per element. Adding/removing which six categories exist is deliberately locked for now (a feature flag, off); renaming and recolouring stay open
- People on work — every task and time block carries two peer people-sets, Responsible (who does the work) and Beneficiaries (who it is for), picked from a per-owner directory of people (every account is a person, but most people have no login). A suggestion carries the people ingest recognised (calendar attendees and mail headers for Responsible, the local model for Beneficiaries) onto the task when it is promoted, and a block with no people of its own inherits its task's. The me · us · them motivation label is derived from the Beneficiaries set once you set one. Design: docs/people-on-work.md
- Close ritual — finish anything — task, habit, recurring instance, subtask, a
Planner chip, the end of a Focus block — and an optional sheet asks how it went: three
faces (Bad · Fine · Good), a closing note, and how long it really took. Tapping one
face scores 1 · 3 · 5; tapping a second, adjacent face fills the gap between, giving
a full 1–5 without ever asking anyone to type a number (a face is answered instantly and
honestly where a numeric self-score invites deliberation — 0137's argument, still true,
which is why the input is still faces). The opposite pair is unreachable by construction,
so every stored value is a point on one ordered scale. The duration is pre-filled from
the block the work was allocated —
min(now, block end), so finishing early credits only the time used and finishing late credits only the time given; with no block it falls back to the timer, then the estimate. That number is a suggestion: it is recorded only if you change it, so estimate calibration learns from minutes you typed or timed, never from the schedule's own plan. Entirely skippable, and a skip is recorded (adeclinedcount, never a fabricated neutral 3) so a nagging prompt is measurable rather than invisible. Completing several at once queues them — “1 of 3”, with one tap to skip the rest. The reflection is stamped with its ownreaction_at, deliberately notcompleted_at: the gap between them is signal, since a reflection written now reads the work more accurately than one reconstructed days later - Balance → Metrics — the measured counterpart to the stated-intent Balance tab, over a 7 / 30 / 90-day window and both axes you think in (direction: Work / Home / Community; category: the six life dimensions). Task counts and timed minutes come from real rows, the rating mix only from items closed with the ritual, and estimate bias is the median actual/estimated with a 3-sample floor. Tasks and habits are reported separately — blending them would let a daily habit's twenty ratings drown out five one-off tasks. Where there's no data a tile says so instead of rendering a confident zero — an empty axis and a genuinely balanced one must never look alike
- Calibration history — the nightly bias rollup is regenerable (replaced wholesale each run, so a scope whose samples vanish stops serving stale numbers), which makes it useless for "am I getting better?". So each run also appends one immutable row per (scope, day) to a history table that is never recomputed and never pruned, keeping the trend intact
- Triality — three candidate next actions (off by default,
triality_enabled). With the flag on, Focus carries a dismissable banner with exactly three picks, one per slot: Habitual (the usual continuation), Contextual (a familiar action fitted to right now), and Novel (a less typical one you could still actually do). They are candidates, not certainties, and the copy says so — the ranking is local arithmetic over your own SQLite rows: your current situation is encoded as a balanced-ternary vector (−1opposed ·0unknown ·+1supportive — an absent signal stays 0 rather than being guessed) and dotted against each candidate, alongside habit strength, sequence fit, novelty, and a hard capacity filter. No generative model ever chooses, writes, or re-orders a pick. One honest asterisk, though: the novelty term is half categorical and half cosine distance from the centroid of your recent completions, so a local embedding model contributes to it whenever Ollama is reachable and you have at least 5 recent completions (novelty carries weight 0.30 in the Contextual and Novel slots, and none at all in Habitual). With Ollama down, novelty falls back to the categorical term alone, and the whole embedding stage is bounded — a 40-candidate cap and a 4-second budget — so a slow local model degrades the picks instead of stalling the banner. With too little history the banner refuses out loud instead of inventing three. Each pick carries one "why now" line — a deterministic template that a local model may replace, labeled either way, never blocking - Bounded learning you can undo — every suggestion shown is logged with the score
breakdown that produced it, and a weekly
calibrate_trialityjob re-tunes the per-slot weights from four signals: what you staged (+1), what you opened (+0.5), what you dismissed (−1), and — the surprising one — what you left alone for a day (−0.25; a suggestion still untouched after 24h argued weakly against itself). The log records every time a suggestion was shown, not just what you did about it, so learning first collapses those to one vote per suggestion per day: re-opening Focus cannot teach the system anything. The bounds are the point: batch only (never mid-request, so a bad week can't bend a live ranking), each weight clamped to ½×–2× of its default, then renormalized so the slot's weights still sum to the default total, inert until a slot has 20 decided outcomes, visible on Balance → Metrics (the learned numbers per slot with the sample count behind them, and an explicit "learning not started" while a slot is still on defaults), and clearable in one click — the button says Recompute from scratch, which is exactly what it does: it drops the derived weights, the outcome log survives untouched, and the next weekly pass re-derives from that same log. It is a recompute, not a permanent opt-out — turning the flag off is the way to stop learning. Tasks can also carry a motivation — me · us · them (who the work is for). Unlabeled stays unlabeled: a task nobody asked for is never recorded as shared - Classification learning — the same local LLM that best-guesses a task's categories
and motivation (above) improves from your own corrections: every judgment is logged with
what the model said and what you settled on, and a weekly
calibrate_classificationjob rebuilds a per-owner few-shot exemplar index from that history — the same batch-only, never-mid-request contract Triality's learning uses, and read/written through raw SQL only (classification_log/classification_exemplars/classification_accuracy).GET /api/classification/accuracyexposes the held-out accuracy trend, a score over held-back corrections that shows what the model got right and cannot show that the examples caused it, andDELETE /api/classification/exemplarsresets the adaptation without touching the correction log underneath it. Settings → Learning shows the trend with the number of cases behind each figure and has the clear button. No feature flag — an owner who has never corrected anything simply sees an empty trend. That trend comes from the Monday scorer, which asks a narrow one-field question; it is not a score of quick add's own prompt. Judgments are logged byPOST /api/tasks/quick, by memory enrichment (off unlessMEMORY_MODEis set) and by the Add wizard's Describe-it preview, which logs the category pick only: the preview returns the model's category pick as an opaqueclassification_claim, the wizard echoes it on Add, and changing the pick before you save, in value or in order, is recorded as a correction (a preview you never save records nothing). The same holds on the Time Block tab: a Time Block created from a Describe-it pick is logged as a Time Block (entity_kind), and changing its categories later settles that row. Settings → Learning splits the corrections it counts by what the guess was about. Tasks and Time Blocks teach the same examples. No screen in the app callsPOST /api/tasks/quicktoday, so a who-it's-for pick is logged only by that route or by memory enrichment. The examples reach a prompt in three places. The quick-add routes (/api/tasks/quick/parse, which the Describe-it line calls, and/api/tasks/quick) show the model up to 3 of your most common corrections for each of category and who-it's-for, last in the prompt, read straight from the last weekly run with no embedder on that path, but only when the model that answers is one measured to use them (gemma3n:e4b,qwen2.5:3b,gemma3:4b, and any names inQUICK_ADD_HINT_MODELS): on the three other models measured they made the answers worse, so on any other model the prompt is the stock one. Memory enrichment shows the ones most like the task when an embedder is available, and so do the Monday run's held-out scoring prompts. The loop is batch: a correction you make changes no prompt until the next Mondaycalibrate_classificationrun (or the first start after it, if the server was down; an admin can run the job early from the Jobs page, for their own account), a field needs at least 2 corrections first, and corrections on guesses made more than 90 days ago stop counting. The category part needs the AI model to name one of your categories, as a list or as one word (docs/classification-shape-fix-2026-11.md). Quick add asks the quality model,gemma3n:e4bby default (docs/quickadd-quality-tier-2026-11.md). The who-it's-for lessons were not measured, and the Add wizard does not record those picks, so on a default install that half stays empty. The measurements are indocs/classification-hint-measurement-2026-11.md,docs/quick-add-hint-learning-curve-2026-11.mdanddocs/quickadd-hint-guard-2026-11.md - Dismissal learning — what you dismiss or approve from the suggestion stream is logged
(
possible_outcome_log); once you have 5 settled dismissals a weeklycalibrate_dismissalsjob keeps a per-owner rollup of them, and up to 3 relevant ones are shown to the extraction prompt as history. It influences what is proposed and never suppresses a suggestion. One noise-rate reading per run puts a number on how many of the suggestions you decide on you dismiss (GET /api/dismissal-learning/trend, and the weekly job's line in the Jobs view);DELETE /api/dismissal-learning/exemplarsresets the rollup and leaves the log alone. Settings → Learning shows the readings, the decisions behind each, and the clear button - Placement learning — Auto records which gap it chose and the gaps it passed over, and
logs it as a correction when you move an auto-placed block. A weekly
calibrate_placementjob turns 5 or more corrections for a direction into a preferred hour band (never one wider than 6 hours), and gap choice leans toward that band by at most an hour of extra fragmentation. A block you did not move counts as accepted once its time has passed and it still stands; one that was skipped, cancelled or marked missed does not count either way.GET /api/placement/survivalshows whether placements are surviving the day untouched;DELETE /api/placement/preferencesresets the bands. Settings → Learning shows the readings with the settled blocks behind each, and the clear button - Balance targets in planning — with
balance_bias_enabled(default on), a life dimension that is at least 2 hours behind its weekly target leads the Fill candidates and the day's order (the two furthest behind at most); a weeklysnapshot_balancejob freezes each closed week intobalance_week_log, read back byGET /api/priorities/history - Memory enrichment — a per-task ✨ button (and a nightly
enrich_tasksjob) fills in a thin task from your Companion Memory: description, direction, categories. It only ever fills blanks — it never rewrites your own description or overrides a direction you chose — and when it can't help it says why ("memory is off", "nothing related") rather than silently no-opping - Reminders, scoped to today — the Focus view's reminder picker only offers tasks actually on today's plate (scheduled for today, or already placed in a time block today)
- Accounts + auth — Companion Portal OIDC (OAuth 2.1 + PKCE) and/or local username/password, with
role-based scopes: operator (read/write/admin) · member (read/write) · viewer
(read-only). An unrecognized role degrades to read-only rather than escalating. Local
self-serve sign-up at
/signupis invite-only by default: an operator mints single-use codes (valid 7 days) in Settings → Accounts;AUTH_SIGNUP=opendrops the code andoffremoves sign-up - Deterministic auto-scheduler — packs open tasks into time blocks inside your pattern-block windows (Work, Community, Home), around locked meetings, with your break and focus-block preferences. A task that has a matching Time Block goes into it before any bare window. Placement is always deterministic and reproducible
- Reactive reflow — when the day shifts (a task edited, a meeting synced, a block missed),
the schedule re-packs automatically around what's fixed: in-progress and recently-moved
blocks are protected, churn is bounded, and a debounce collapses bursts into one pass. A
low-noise toast reports what moved. Opt-out per owner (
auto_defense_enabled) - AI planning (optional, Ollama) — with "AI day ordering" on, a local LLM orders the day once (deadline / priority / momentum) into a durable plan; placement stays deterministic. Each AI-placed block carries a one-line "why now" rationale. A natural-language box turns "dentist 2–3pm" into pinned blocks the planner works around. On a big disruption the reflow never silently re-plans — it surfaces a single "re-plan with AI?" nudge, and the LLM re-author runs only if you accept
- Pattern blocks — named availability windows (Home / Work / Community) that shade the Planner and constrain auto-placement; per-pattern fill toggle and reminder offsets
- Routines — typed task collections (Routine ▢ / Habit ◯) placed via a flight-plan
staging area; Routine placement (
enable_routines) and Ritual △ (enable_rituals) are per-owner feature flags, both off by default, switched on in Settings → Preferences → Routine Settings (Habits are not flagged) - Flight-plan staging — two-step Auto: build a staged list, then schedule; lets you curate before committing
- Plan my week — a guided onboarding assistant: a self-contained modal off a fixed
3-branch menu (fill gaps · review past data · schedule tasks) that phrases its questions
and answer options through the local LLM when available and falls back to fixed
deterministic copy when it isn't — the fallback is authored first and the model only ever
rephrases it, never invents a fourth option. Every session is logged (
planning_sessions) and the Settings → Logs tab lets you read, export, or delete your own sessions - Auto-promoted suggestions — ingestion jobs propose "possible tasks" from calendar, Companion Memory and text; each carries its source and a confidence score. Suggestions are deduplicated inline (embedding-based when available) and auto-promoted into real tasks at the end of each run (Phase AF); they land in the New Today section at the top of the Tasks view, and the staging rows are kept as an audit/dedup trail. Email is the exception: a Gmail suggestion is never auto-promoted. It waits in a review queue at the top of the Tasks view until you approve or dismiss it
- Ingestion windows — two
per-owner settings bounding how much of each source a pull reads: Look ahead
(
memory_window_days, 1–30, default 2) for the Companion Memory pull, and Look back (email_lookback_days, 1–90, default 7) for a Gmail ingest. They point in opposite directions on purpose — memory mirrors upcoming events into tasks, an inbox is only ever read backwards — and the email one is a real floor: before it, a first ingest read as far back as Gmail would return - Background jobs — Gmail / Calendar ingest, nightly grooming (embedding-based
dedupe), auto-schedule, memory enrichment, the weekly learning batches (
calibrate_*,snapshot_balance) and the hourlypurge_objectivesreaper; all visible and manually runnable from the Jobs view. (Possible-task promotion no longer has a scheduled row — Phase AF made ingestion auto-promote;nightly_groomingsubsumes the sweep. The kind stays runnable programmatically.) - AI day briefing — Ollama-authored when available, deterministic template otherwise; the UI labels it honestly ("AI Briefing" vs. "Day Overview")
- Reminders & Web Push — per-pattern reminder offsets generate reminders that a 1-minute dispatcher fires exactly once: in-app via SSE (always) and via Web Push to subscribed devices (service worker + VAPID; keys auto-provisioned on first boot). Opt in from Settings; snooze reschedules; completed / cancelled tasks never fire
- Live updates — Server-Sent Events push task / block / job changes to every open
client, so the UI stays in sync without polling; the iOS / Android app shells, which
cannot hold the stream, poll
GET /api/sync/changesinstead - Installable PWA — manifest + standalone display; usable from a phone over your tailnet. There are also native iOS and Android shells that connect to your server (see Mobile app)
- Objectives hierarchy — a life-planning tree of Goals → Projects → Objectives; each leaf Objective links to Tasks whose completion rolls up into progress; height-derived level (no manual selection); a task that gains a subtask auto-promotes into its own Objective; progress is
NULLwhen there's nothing to measure — a rot signal, not a zero - FOCUS card (formerly "Goals") — a single today focus and up to three weekly goals in the Today cockpit, toggled by a
Today | This weekswitch. Each slot can link to a Task (today) or an Objective (weekly) via a typeahead, or hold freeform text; completing a linked task auto-checks its focus (two-way completion). A Today's Brief pop-up (replacing the old standalone Morning Briefing section) shows the AI prose, capacity, and top tasks (habits + routines excluded). Each slot optionally carries a category color stripe and a done stamp. - Day note — one free-text note per day (enforced structurally, not by convention — reuses the FOCUS card's
reflectionstable with a dedicatedkind); a header affordance and a calendar dot open/indicate it - Five-star day — a gamified daily score derived entirely from existing signal, never a manual toggle: a star each for the day's Intention, clearing all Habits, clearing all planned Tasks, an unplanned Bonus completion, and a Reflection (a done task + a saved day note). Shown as a small live tally in the Today header and as a heatmap on the Calendar (a perfect 5/5 day gets a single glowing star). Today computes live; once a day ends, a nightly job freezes its score — permanent, never recomputed
- Weekly Review — summary stats (scheduled vs focus minutes, capacity, block completion %) with calibration insights and objectives progress for the week; available inside the Calendar week view
- Estimate calibration — a nightly job computes your personal bias ratio (median actual/estimated), surfaced as an amber label in the Weekly Review when your pattern deviates more than 10%; helps you plan more accurately over time
- Recurrence horizon — a Settings control (7/15/30/60/90/180 days) bounding how far ahead recurring Routines/Habits/Time-Blocks materialize; lowering it prunes future auto-placed instances (snapshotted first, reversible by raising it back) without touching the underlying recurrence rule
- Clear Date Range — a Settings tool to preview-then-clear any date range from the Planner (bounded → skips just that window and stays reversible; open-ended → permanently caps the recurrence rule); every apply snapshots the DB first and is fully audited
- Multi-device sync — IndexedDB offline write log, field-level merge (not whole-record LWW) covering Tasks, Time Blocks, and Objectives (+ objective↔task links), monotonic guards so a stale offline op can't un-complete a task or revert a
scheduledtask toopen,Last-Event-IDSSE catch-up replay for reconnecting devices (Phase L) - GraphQL layer — a complete GraphQL CRUD surface at
POST /api/graphql(Apollo Server 5) alongside the authoritative REST API; it reuses the same owner-scoped service layer, so REST is untouched (Phase W, done)
Companion Planning is a small pipeline that turns raw inputs into a planned day, with a full audit trail at every step — you steer by dismissing what you don't want and by deciding when the plan runs.
- Ingest — scheduled jobs read your sources (Gmail + Calendar, once Google is
connected; a fixture mode exists for demos). Unstructured email/event text is mined by a
local LLM for action items,
with a deterministic fallback if Ollama is down. Calendar meeting blocks are
mirrored by the dedicated calendar sync path (one canonical inbound reconciler keyed
on event identity + etag), not by ingest —
ingest_gcalnow only extracts tasks. - Review — extracted items are staged as possible tasks, each tagged with its source and a confidence score, deduplicated inline, and, for calendar, memory and text sources, auto-promoted into real tasks at the end of the run (Phase AF). Everything a run promotes surfaces in the New Today section at the top of the Tasks view, so you see exactly what arrived today; the staging rows remain as an audit trail. Email suggestions are the exception: they wait in a review queue above that list until you approve or dismiss them.
- Plan — build a flight-plan staging list, then run Auto to pack open tasks into the open gaps of your workday, honoring pattern-block windows, locked meetings, focus-block limits, and minimum breaks. Deterministic — same inputs, same plan — and re-runnable any time. With AI day ordering on, a local LLM decides the order once (frozen into a durable day plan); the packing stays deterministic. Two clocks: the LLM is slow/async and only orders or re-triages; the deterministic placer is fast and defends the day.
- Defend — as the day shifts (edits, synced meetings, missed blocks), the schedule reflows automatically and deterministically around what's fixed. It never calls the LLM in this hot path; on a big disruption it raises a single "re-plan with AI?" nudge instead, and re-authors only if you accept.
- Brief — the Today view opens with an AI briefing that summarizes what's overdue, what's scheduled, and where to focus, generated on-device.
- Groom — a nightly job dedupes near-identical suggestions using text embeddings
(
sqlite-vecKNN when available, an exact cosine pass otherwise), bumps the priority of overdue tasks, and promotes any suggestion that was left behind.
Every step runs locally. The single SQLite file is the whole system of record. Lifecycle of a to-do item traces one task through that pipeline end to end, and Scheduling system covers steps 3–4 in detail. See Backup and restore for safe copy and recovery options.
The Task is the app's core work atom, and every surface — quick-add, the wizard, an
agent over GraphQL — funnels through the same owner-scoped service layer
(server/src/services/tasks.ts). The routes are transport; the service is where the rules
live, which is why REST and GraphQL cannot drift. A promoted suggestion takes the one other
way in (services/possibles.ts) and joins the lifecycle below as an open task.
stateDiagram-v2
direction LR
[*] --> recurring : POST /api/tasks with recurrence_rule
[*] --> open : create · quick-add · auto-promoted suggestion
recurring --> open : generate_recurrences materializes<br/>one instance per occurrence
open --> scheduled : a block is placed<br/>Auto · start-anchored pin · drag · routine
scheduled --> open : last active block cleared<br/>unscheduled · missed-sweep · Undo
scheduled --> open : nightly review defers a missed block<br/>scheduled_for moves out +1/3/7 days
open --> in_progress : explicit status PATCH
scheduled --> in_progress : explicit status PATCH
open --> done : checkbox or PATCH completed true
scheduled --> done : checkbox or PATCH completed true
in_progress --> done : checkbox or PATCH completed true
done --> open : reopen — revives its blocks
done --> scheduled : reopen when a block revives with it
open --> cancelled : DELETE — soft delete, row kept
scheduled --> cancelled : DELETE — active blocks cancelled too
done --> cancelled : DELETE
open --> missed : generate_recurrences flags a habit<br/>instance whose day passed unfinished
missed --> open : nightly review revives a stuck one-off<br/>with a +1/3/7 day backoff
open --> skipped : skip a recurring instance<br/>its day joins the template EXDATE
cancelled --> [*] : terminal — the row, its tags,<br/>and its audit trail are all kept
note right of done
Optional close ritual:
three faces to a 1-5 rating · closing note · real duration.
reaction_at is stamped only when a real rating
or a note was written — its gap from completed_at
is the signal. A skip stores 0 (declined) and
stamps nothing.
end note
note right of scheduled
Placement status parity: every engine path
flips open to scheduled, and reverts it when
the last active block goes away. deferred stays
in the status vocabulary and the Tasks filter,
but no server path writes it. missed is written
only for a habit instance whose day passed
unfinished; the nightly review sweeps any
other task stuck there back to open.
end note
What actually happens on each verb — including the parts that are easy to miss (the audit row, the sync version stamp, the SSE fan-out, and the debounced re-plan):
sequenceDiagram
autonumber
participant UI as SolidJS PWA
participant API as Koa route
participant Svc as services/tasks.ts
participant Eng as Scheduling engine
participant DB as SQLite
participant SSE as SSE /api/events
participant Dev as Other open devices
rect rgb(240, 248, 248)
Note over UI,Dev: CREATE
UI->>API: POST /api/tasks
API->>API: strip motivation_source — server-derived only
API->>Svc: createTask owner, input
Svc->>DB: INSERT tasks — UUIDv7 id, status open
Svc->>DB: entity_tags L1 direction plus L2 intentions
Svc->>DB: entity_categories, 1 to 3 of the six dimensions
Svc->>DB: entity_people — responsible and beneficiary sets, when sent
Svc->>DB: task_dependencies — a cycle is a 409, task row already written
Svc->>DB: derive reminders from the Reminder Defaults
Svc->>DB: audit_log create
opt auto_place true and scheduled_for set
API->>Eng: placeStartAnchoredTask
Eng->>DB: INSERT time_blocks at scheduled_for plus task_time
Eng->>DB: status open becomes scheduled
end
API->>SSE: broadcast task:created
API->>Eng: requestReplan task:created — debounced 1.5s
SSE-->>Dev: task:created
API-->>UI: 201 task with tags, categories, depends_on, blocked
end
rect rgb(244, 244, 248)
Note over UI,DB: READ
UI->>API: GET /api/tasks with status, tag, q, priority, due_before
API->>DB: owner-scoped SELECT plus tag, category, dependency hydration
API-->>UI: tasks with blocked computed from the dependency graph
end
rect rgb(248, 246, 240)
Note over UI,Dev: UPDATE
UI->>API: PATCH /api/tasks/:id, scope instance or series
API->>Svc: updateTask
Svc->>DB: UPDATE columns — cross-owner id matches nothing, 404
alt scheduled_for cleared
Svc->>Eng: clearTaskPlacement — task leaves the Planner
Eng->>DB: delete placement, status back to open
else day, time, or estimate changed
Svc->>Eng: syncBlockFromTask — the placed block moves in lockstep
end
Svc->>DB: stampRow — version plus field_ts for field-level merge
Svc->>DB: audit_log update
API->>SSE: task:updated plus block:updated per moved block
API->>Eng: requestReplan only when the change touches scheduling
SSE-->>Dev: task:updated
end
rect rgb(240, 246, 240)
Note over UI,DB: COMPLETE
UI->>API: PATCH completed true
Svc->>DB: status done, completed_at stamped
Svc->>DB: mirror the task's blocks to completed
Svc->>DB: tick any FOCUS slot linked to this task
Svc->>DB: recompute linked objectives progress
opt close ritual — entirely skippable, queued when several land at once
UI->>API: POST /api/tasks/:id/close
Svc->>DB: completion_score, completion_note, actual_minutes, reaction_at
end
end
rect rgb(250, 242, 242)
Note over UI,Dev: DELETE — soft
UI->>API: DELETE /api/tasks/:id, scope instance or following
API->>Svc: deleteTask
Svc->>DB: status cancelled — the row is kept
Svc->>DB: active blocks cancelled, auto reminders dropped, flight-plan entry removed
Svc->>DB: objectives upkeep, then audit_log delete
API->>SSE: task:updated plus block:updated
API->>Eng: requestReplan task:deleted — the freed slot re-packs
SSE-->>Dev: task:updated
end
A few properties worth calling out, because they are what make the loop safe:
- Nothing is hard-deleted.
DELETE /api/tasks/:idsetsstatus='cancelled'; the row, its tags, its time entries, and its audit trail all survive. On a recurring instance,?scope=followingadditionally truncates the series from that occurrence onward. - The block is the source of truth for placement; the task carries the intent.
Editing a task's day/time/estimate moves its placed block, and dragging the block
back-writes
scheduled_for/task_time— but only the earliest session block owns the task's intent, so dragging a later session never re-anchors the task or its reminders. - Every mutation is versioned and audited.
version+field_tsback the field-level offline merge (a stale device's queued edit can't un-complete a task or revert ascheduledtask toopen), andaudit_loggets an append-only row from every mutating route.sync_eventsgives reconnecting devicesLast-Event-IDcatch-up replay. - The re-plan is debounced and gated. A burst of edits collapses into one reflow,
a title or notes edit never churns the day (
updateTouchesScheduledecides), and the whole reactive path is opt-out per owner viaauto_defense_enabled. - Where the card lands is derived, never stored. A task's Tasks-tab section is
recomputed from
created_at, its placement, and today's date — there is no section column and no job that moves anything. Placing a task moves it out of New Today on placement rather than at midnight, andcreated_atis normalized as UTC on both ends so an evening-created task lands in the right day.
Placement is deterministic: the same inputs produce the same plan, every time. Where a local LLM is involved at all, it decides order — never where a block lands.
| Clock | What it does | When it runs | Touches the LLM? |
|---|---|---|---|
| Slow — authoring | Orders the day's staged tasks into a durable daily_plan, one row per task with a "why now" rationale |
Once per day, author-once: on the first Auto run, or forced by an accepted "re-plan with AI?" nudge | Yes, when planning_mode='smart' and Ollama is reachable — otherwise a deterministic deadline/priority sort, and the row records honestly which it was |
| Fast — placement + defence | Packs blocks into the day's real gaps, and re-packs when the day shifts | Explicitly on Auto, and reactively (debounced 1.5s) on a scheduling-relevant task edit, a calendar sync, or a missed block | Never. The hot path is pure arithmetic over SQLite rows |
The split is the whole design: a slow, fallible model can take 25 seconds to think about sequence, while the thing that defends your calendar has to answer in milliseconds and be reproducible.
Three settings are independent of one another. The trust dial (trust_mode: Cautious ·
Balanced · Hands-off, default Balanced) decides how much the planner does unasked: only
Hands-off places on its own (after a promotion, an email ingest or a calendar sync) and
plans a multi-day horizon. It also decides how much inference may reorder the day's list:
Cautious none, Balanced the balance nudge, Hands-off the balance nudge plus the day theme
and Companion Memory. planning_mode (basic · smart) decides whether the LLM loop
or the deterministic sort authors that order; since migration 0171 Hands-off no longer
forces it. fill_order (below) is the explicit sort and applies in every tier.
Auto is deliberately staged — you build a list, then commit it — so nothing lands on your
calendar that you didn't look at first. POST /api/auto/undo reverses the last placement
run in one click (each run stamps its blocks with an auto_run_id).
| Tap | Endpoint | What it does |
|---|---|---|
| 1 · Fill | POST /api/auto/fill |
Proposes up to 20 candidates into the flight plan staging area — open, top-level, not a habit, not a recurrence instance, and either undated or already dated for the target day without a live block. They arrive selected (deselect to exclude). Schedules nothing. |
| 2 · Schedule | POST /api/auto/run |
Authors the day's order (once), then places the selected staged tasks through the pass ladder below. Placed tasks leave the staging area. |
| 3 · Priority top-up | POST /api/auto/priority |
Optional, never automatic: fills leftover workday capacity with the highest-priority still-unplaced tasks, flowing around everything already placed. A no-op when auto_fill_workday is off. |
In the Hands-off trust mode, tap 2 spans planning_horizon_days (default 7, clamped
1–60) instead of one day — leftovers fan onto the next day — and only day 0 may use the
LLM, so a week's plan never fires seven model calls. (A caller can pass smart: true to
re-author every day in the horizon; nothing does by default.)
Balance bias. With balance_bias_enabled (default on), a life dimension that is at least
2 hours behind its weekly target from the Priorities wizard leads, up to the two furthest
behind. Tap 1 then reads a pool of up to 60 open tasks instead of 20 and takes those
dimensions' tasks first. The same dimensions lead the day's authored order ahead of the plain
deadline/priority sort, in Balanced and Hands-off but not Cautious.
Each pass places into a window; every pass flows around the same live obstacle set, so nothing ever stacks on existing work — manual or auto:
flowchart TD
A[Selected staged tasks<br/>ordered by daily_plan.position] --> B{task_time set, and its target day<br/>is the day being packed?}
B -->|yes| P0["Pass 0 · Pin exactly at day + time<br/>is_auto=0, so Auto never clears a stated time<br/>on conflict: nearest open gap that same day"]
B -->|no| C[Gap packer]
P0 --> C
C --> P1["Pass 1 · Tag-matched Plan containers<br/>tag-matched Plans first, catch-all Plans last<br/>placed tasks become the Plan's members"]
P1 --> P15["Pass 1.5 · Category Plans<br/>filled by their position-0 category"]
P15 --> P175["Pass 1.75 · Natural-language hint windows<br/>a same-day instruction outranks a recurring window<br/>HARD: a full window leaves the task unplaced"]
P175 --> P19["Pass 1.9 · Matching Time Block<br/>direction + category match, with room<br/>a full Time Block never auto-extends"]
P19 --> P2A["Pass 2a · Direction windows for the day-type<br/>pattern_blocks, union, earliest first<br/>honours the per-direction auto_fill gate"]
P2A --> P2B{auto_fill_workday on?}
P2B -->|yes| P2BB["Pass 2b · Workday fallback<br/>union of the Work + Community windows<br/>ordered by daily_plan, else by fill_order<br/>priority-first · overdue-first · new-today-first"]
P2B -->|no| U
P2BB --> U[Anything still unplaced<br/>reverts to the open backlog]
subgraph OBS["Obstacles every pass routes around"]
O1[locked blocks]
O2[meetings]
O3[every non-completed task block]
O4[Plan containers, in the fallback passes]
end
OBS -.-> C
Leftover fill order is the fill_order pref, exposed as the toggle above the
flight plan. It only applies to Pass 2b, and only when the day has no authored
daily_plan (an authored day already encodes its sequence):
| Value | Toggle label | Order |
|---|---|---|
priority |
Priority | P1 first, then tasks that share tags (related work stays together), then due date |
overdue |
Due Date | Soonest-due first, then priority |
new_today |
New Today | Today's arrivals take the earliest slots; each half stays deadline-first |
The gap math (server/src/engine/scheduler.ts) is small and shared by every path:
- Carve the window into free gaps between fixed obstacles, inserting
min_break_minutesafter each one. - Split a task's estimate into focus-sized sessions capped at
max_focus_block_minutes(default 90); the last session may be shorter. A task with no estimate is treated as 30 minutes. - Pick a gap best-fit — the one leaving the least leftover, which reduces fragmentation. If nothing fits, fall back to the largest remaining gap and truncate, but never carve a sliver under 15 minutes. All gaps are scanned, so a small task backfills a hole a bigger one skipped. Once an owner has a learned hour band for the task's direction (placement learning), a gap inside the band may win over the tightest fit, at a cost of at most an hour of extra leftover.
- Clamp — on the owner-local current day, no engine-chosen slot may start before now. A late-afternoon re-pack fills the remaining day instead of shoving work into past morning gaps that the missed sweep would immediately recycle.
Windows come from your own configuration: pattern blocks are direction-scoped
availability windows (Home / Work / Community), wall-clock in your timezone and selected
by the day's day type, and a day type is resolved per-date first, then from the weekly
map, then from a weekend heuristic. The workday fallback (Pass 2b) fills the union of the
Work and Community windows; the old workday_start/workday_end setting is no longer read
(0156), so a day type with no Work or Community window gives that pass nothing to fill. All
day maths is DST-aware — day bounds are computed in your zone, never as start + 24h.
When the day shifts, reflow() re-places only the day's movable auto blocks — and it is
snapshot-based: the set it clears is exactly the set it re-places, by construction. (That
invariant is load-bearing. The bug it fixes was real: editing one placed task used to wipe
every other auto-placed block, because the clear was broad and the re-place was narrow.)
Anti-thrash rules, in priority order:
- An in-progress block is never moved.
- A user-locked block holds its slot and its task — never moved, never duplicated beside the pin.
- A recently-moved block (within a 5-minute stability window) is held.
- Everything else is cleared and re-packed.
Protected blocks are passed through in memory, so a reflow never writes is_locked to
fence anything off — an interrupted reflow cannot leave a block stuck locked. A burst of
triggers collapses into one trailing pass (1.5s debounce), a re-entrancy guard stops a
reflow from re-triggering itself, and the whole path is skipped when
auto_defense_enabled is off. daily_plan is an ordering hint only — it never gates
membership, so a task staged after the day was authored still gets re-placed.
Escalation, not silent re-planning. If a reflow leaves a P1/P2 task dropped or
unplaceable, that is a big disruption: the engine records one nudge per owner per day
(replan_suggestions, so it survives a restart) and surfaces it over SSE and Web Push. The
LLM re-authors only if you accept, via POST /api/plan/reauthor. A chronically-full day of
low-priority backlog never nags.
The review_day job (01:00) looks at planned task blocks whose end time passed
un-started. Every priority now auto-defers on a +1 / +3 / +7 day backoff (by prior
defer_count): scheduled_for moves out, the stale block is consumed so it stops
lingering, and the task re-surfaces on its new day. The daily_plan.outcome stamp keeps
the priority split (missed for P≤2, deferred for P≥3) so the aggregates can still tell
an important miss from a routine slip. Hands-off owners get each deferred or revived task
booked straight into its new day; Suggest-mode owners get it back as open with a date and
nothing placed. The same engine backs the top-bar sweep button (POST /api/review/sweep).
Separately, the auto_schedule job (03:00) sweeps auto blocks whose planned time passed
without being started: it marks each one skipped, returns its task to the open backlog,
re-stages it at the end of the flight plan, and requests a reflow so the work lands in
whatever is left of the day.
Recurring Tasks, Time Blocks, and Routines are RRULE templates that materialize lazily:
a template is never rendered directly, and generate_recurrences (02:45) creates one
instance per occurrence, idempotently. recurrence_horizon_days (Settings: 7 / 15 / 30 /
60 / 90 / 180, default 7) bounds how far ahead that runs — lowering it prunes future
auto-placed instances (snapshotted first, and reversible by raising it back) without
touching the underlying rule.
25 cron rows across 26 executable kinds. Owner-scoped kinds fan out once per owner
(enumerated at run time, so a fresh account is picked up on the next tick); a kind still
running is never started again by the next tick. Every run writes a job_runs row, and
all of them are listed and manually runnable from the Jobs view.
Missed runs. The schedule only fires while the server is running. A run that fell while
it was off (a restart, a redeploy, a self-hosted machine that was switched off) is not made
up later on its own: a server that was down through Monday's learning passes (03:50 to
04:50) skipped them for that week, and a correction made the day before waited a further
week. Seven jobs are made up: calibrate_estimates, calibrate_triality,
calibrate_classification, calibrate_dismissals, calibrate_plan_loop,
snapshot_balance and calibrate_placement. About a minute after the next start, the
server checks them one at a time, in schedule order, and runs once each job whose scheduled
time passed since it last ran. Several missed weeks (or nights) still cost one run, and
snapshot_balance freezes only the latest closed week. A job whose next scheduled time is
10 minutes away or less is left to that run. A job with no recorded run, or one dated more
than five minutes ahead of the server's clock, counts as never run, so a first start runs
all seven once, over empty data. A run that ended in an error counts as having run and is
not retried. A run cut short by a restart is tried again at the next start; after two
cut-short tries it waits for its next scheduled time. A disabled job (enabled = 0 on its
scheduled_jobs row) is not scheduled and not made up. The Jobs view marks a made-up run
with "Caught up" at the start of its log, for example Caught up (missed 2026-11-02 04:10),
in the server's clock.
The server keeps one last-run time per job, not one per account. A run by any account counts for the whole server: if one account presses Run now (or Run all) after the missed time, or the server stops part-way through the accounts of a scheduled run, the accounts not reached are read as caught up too. They wait for the next scheduled time, as they did before. Five of the other jobs are not made up, because a late run is not the same as a run on time:
reset_routinesreopens every finished Routine task with no cut-off on when it was finished, so a run at 09:00 would reopen what you ticked off that morning.review_daydefers every planned task block that has already ended, so a run at 09:00 would also defer this morning's blocks.auto_schedulemarks every auto-placed block that ended today without being started as skipped and stages it again, which rewrites the day before you have seen it.nightly_groomingmoves every overdue task up one priority step each time it runs, so a second run is not the same as one.advance_objectivesstamps each objective with the time it ran, so a run on a Thursday would make the next Monday see less than a week and skip a weekly objective.
The jobs that run every minute to every six hours do the work they missed at their next
run anyway. freeze_stars, generate_recurrences, snapshot_db, push_memory,
prune_sync_events and enrich_tasks are left out for now; they are not learning passes.
A server that is off every night still loses the whole nightly set.
| Job | Cron | What it does |
|---|---|---|
fire_reminders |
* * * * * |
Claim-by-UPDATE dispatcher — fires each due reminder exactly once, in-app via SSE and via Web Push |
ingest_gmail |
*/15 * * * * |
Mine email for action items into possible_tasks. Reads from the (owner, google, account) watermark, floored at now − email_lookback_days so a first run, or the first run after an outage, can't replay months of mail. Email suggestions are not auto-promoted; they wait for your approval |
sync_calendar |
*/15 * * * * |
Calendar reconcile keyed on event identity + etag, inbound only unless CP_SYNC_WRITEBACK is on; requests a reflow when anything changed |
ingest_gcal |
*/30 * * * * |
Extract tasks from calendar text (meeting blocks are mirrored by sync_calendar, not here) |
memory_suggest |
0 */6 * * * |
Stage upcoming Companion Memory events as suggestions (needs memory_pull_enabled). Looks ahead by the configurable memory_window_days (default 2), which the daily brief reads too |
tag_tasks |
0 * * * * |
Tag untagged top-level open tasks |
purge_objectives |
0 * * * * |
Reap objectives whose 48-hour purge deadline has passed |
reset_routines |
0 0 * * * |
Midnight routine rollover |
freeze_stars |
20 0 * * * |
Freeze the closed day's five-star score — permanent, never recomputed |
review_day |
0 1 * * * |
The missed-task review + backoff above |
calibrate_estimates |
30 1 * * * |
Recompute the estimate-bias rollup and append an immutable row to calibration_history |
nightly_grooming |
0 2 * * * |
Embedding-based dedupe (two gated passes), overdue bump, straggler promotion |
snapshot_db |
0 2 * * * |
Point-in-time DB snapshot, keeping SNAPSHOT_RETAIN files |
push_memory |
0 2 * * * |
One-way mirror of completions/habits/reflections back to memory (needs memory_sync_enabled) |
generate_recurrences |
45 2 * * * |
Materialize RRULE instances up to the recurrence horizon |
auto_schedule |
0 3 * * * |
Propose flight-plan candidates + sweep missed blocks (it does not pack the backlog) |
prune_sync_events |
0 3 * * * |
Age out the SSE replay log |
enrich_tasks |
20 3 * * * |
Fill blank description / direction / categories from Companion Memory |
calibrate_triality |
50 3 * * 1 |
Weekly re-tune of the per-slot Triality weights from the outcome log |
calibrate_classification |
10 4 * * 1 |
Weekly re-tune of the classify/parse LLM's few-shot exemplars from the owner's own corrections; runs after calibrate_triality so the two weekly learning passes don't compete for one GPU |
calibrate_dismissals |
20 4 * * 1 |
Weekly rebuild of the dismissal-evidence exemplar rollup + one noise-rate reading, from settled possible_outcome_log evidence; runs after calibrate_classification for the same single-GPU reason |
calibrate_plan_loop |
30 4 * * 1 |
Weekly batch over plan_loop_runs — the §A0 authoring loop's pass rate, first-try pass rate, escapes and average iterations per stage, and in its log line the rules the checker flagged most often (read from plan_revision_log); pure SQL, no model calls, but stays inside the weekly learning block so the schedule reads as one unit |
snapshot_balance |
40 4 * * 1 |
Weekly freeze of each life dimension's target/scheduled/timed hours for the week just closed into balance_week_log — read back by GET /api/priorities/history; INSERT OR IGNORE, first-freeze-wins |
calibrate_placement |
50 4 * * 1 |
Weekly rebuild of each owner's learned hour band per direction (placement_preferences) from the moves they made to auto-placed blocks in placement_log, plus one survival reading (placement_survival); needs 5 or more corrections for a direction, makes no model calls, and runs last in the Monday learning block |
advance_objectives |
0 5 * * 1 |
Stage the next action for each active objective into the flight plan |
promote_possibles |
(no cron row) | Runnable on demand only — ingestion auto-promotes, and nightly_grooming sweeps stragglers |
Set TZ to your IANA zone: cron fires on the container clock, so it decides when each
nightly job runs. What counts as "today" inside a job comes from each owner's timezone
preference, and falls back to that same server zone when none is set.
Everything lives in the single SQLite file at DB_PATH. Three safe options:
A daily snapshot_db cron job (02:00) writes a point-in-time copy to
<DB_DIR>/backups/companion-<timestamp>.db and keeps the newest SNAPSHOT_RETAIN
files (default 7). Run one manually any time:
pnpm --filter server snapshotTo restore from a snapshot (server must be stopped first):
pnpm --filter server restore -- /data/backups/companion-2026-06-22T02-00-00-000Z.dbThe restore script validates the source file, refuses to run while the server is up (a
pidfile check plus a write-lock probe), moves the current database and its -wal/-shm
aside into <DB_DIR>/pre-restore/ instead of overwriting it, copies the snapshot to
DB_PATH, and runs any forward migrations if the snapshot is from an older version.
Litestream replicates the SQLite WAL to object storage
continuously. Edit ops/litestream.yml with your replica URL, then run it alongside
the server:
litestream replicate -config ops/litestream.ymlPoint-in-time restore:
litestream restore -config ops/litestream.yml \
-timestamp 2026-06-22T14:00:00Z /data/companion.dbSettings → Backup & Export downloads the signed-in account's data as one JSON file
(GET /api/export) and restores it (POST /api/import). Import replaces that account's data
in a single transaction, refuses a file exported by a different account, and reads the
current export format and earlier ones back to version 6. It leaves out connection tokens
(Google and Companion Memory links are re-made on the new host), API keys and the audit log,
and it covers one account only, so it is a portable copy rather than a whole-server backup.
Do not sync the live .db file with a generic file-sync tool. SQLite's WAL mode
writes across multiple files (.db, .db-wal, .db-shm). A file-sync tool copies
them at different moments and can produce a corrupt or inconsistent database. Use
pnpm --filter server snapshot or Litestream — both open a proper SQLite connection and export a
consistent snapshot.
Everything above describes the app as it runs on main, which is at migration head
0173_classification_entity_kind (v4.2, checked 2026-11-02). This section is the narrative description of
the change set that took the head from 0147 to 0150 on 2026-08-14: closing a locking gap on
synced calendar blocks, plus a new learning loop that lets the parse/classify LLM improve
from the owner's own corrections. Everything in it is still true of main. What shipped
after it is listed at the end of this section, and release notes are in
CHANGELOG.md.
agents/sync.ts has stamped is_locked = 1 on every inbound-mirrored calendar block since
before the multi-tenant migration, so the ongoing sync path was already correct.
0148_lock_synced_blocks backfills the invariant onto the rows that predate or bypass that
path: blocks adopted by the pre-Wave-0 gcal-mirror migration (0059), rows restored from an
older backup, and anything a fixture or seed inserted unlocked. It is scoped to inbound
mappings only — an outbound row is a local block the owner still owns and pushes to a
calendar, so locking those would freeze the owner's own time — and it only ever sets the
flag, never clears it, so a lock applied for another reason survives. is_locked therefore
now means two things: a deliberate user pin, and "the source calendar owns this time" for
any block mirrored in from outside.
Three new tables (classification_log / classification_exemplars /
classification_accuracy, listed above), built in the same shape triality_log /
triality_weights already established, and — like those, and like calibration_history —
read and written through raw SQL only, never mirrored in schema.ts. The new weekly
calibrate_classification job (10 4 * * 1, Monday 04:10 — deliberately after
calibrate_triality's 03:50, so the two weekly learning passes don't compete for one GPU) is
the only scheduled piece; classification itself stays request-time, never batch. Two
endpoints expose it: GET /api/classification/accuracy (the held-out accuracy trend, newest
run first — accuracy: null on a run that measured nothing, so a chart draws a gap rather
than a false dip) and DELETE /api/classification/exemplars ("reset learning" — the twin of
DELETE /api/triality/weights; the correction log survives, and the next weekly run
re-earns the adaptation from it rather than losing it). Unlike routes/triality.ts, there is
no feature flag here — an owner who has never corrected anything just has an empty trend and
an empty exemplar index, which is exactly what a fresh install reports anyway. The client for both
endpoints is Settings → Learning, which prints the number of scored cases beside every reading. Quick add's prompt
(reached through the Describe-it line) and the wizard's Add joined the loop on 2 Nov 2026; the
Classification learning bullet above says how.
The original three-part narrative below (ingestion windows, the Tasks-view section-ordering
swap, and the CURRENT_TIMESTAMP local-time bug) shipped in migration 0147 and is kept as
history — everything it describes is still true of main today, just no longer the newest
change.
How much of each upstream source a pull actually reads is per-owner configuration on
scheduling_prefs, surfaced in Settings → Connections:
| Setting | Range | Default | Direction | What it bounds |
|---|---|---|---|---|
memory_window_days |
1–30 | 2 | Ahead | How far forward the Companion Memory pull looks for upcoming events |
email_lookback_days |
1–90 | 7 | Back | How far back a Gmail ingest may reach |
The asymmetry is deliberate, not an oversight: memory suggestions mirror upcoming events into tasks, so a backward memory window would stage work for things that already happened — while an inbox is only ever read backwards.
- The memory half is a no-op upgrade. The default
2is exactly the 48 hours thatagents/memory-suggest.tsandagents/summarize.tseach hardcoded separately;memoryWindowMs()is the single authority both read, so one setting moves the suggestion agent and the daily brief together. - The email half is not. The Gmail
sincecursor previously had no floor at all, so a first ingest — or the first run after a long outage — read as far back as the MCP Gmail tool would return.sinceis nowmax(watermark, now − N days). On the healthy 15-minute cadence the watermark is always newer, so the steady state is unchanged. - Range-checked once, in
services/prefs.ts, so REST and GraphQL cannot drift. Out-of-range rejects (422 /BAD_USER_INPUT) rather than clamping — a silently clamped value would leave the Settings input showing a number the server never stored. An explicitopts.since(a manual replay) bypasses the floor: the caller named the window. - Known trade-off, stated plainly: the email floor applies always, not only on a first run. Reconnecting after a long outage pulls the configured window and the watermark jumps past the older backlog. That is what "look back N days" means, but it is a deliberate skip in a pipeline otherwise built never to skip.
Section membership precedence changed from Routine › New Today › Scheduled › Open to Routine › Scheduled › New Today › Open. Scheduling a task the same day it was created moves its card to Scheduled immediately; previously it sat in New Today until local midnight, so placing it produced no visible movement and the section read as "created today" rather than "not yet dealt with".
The New Today → Open rollover at local midnight is unchanged and still pure
derivation — no column, no job. The header's "N open" number is unchanged by the swap:
headerOpenCount loses its isScheduled parameter, which existed only to strip scheduled
tasks back out of New Today under the old order, and the partition now drains them first.
fill_order gained new_today alongside Priority and Due Date in the flight-plan toggle:
today's arrivals take the earliest slots, and each half stays deadline-first, so it
layers on top of overdue rather than replacing it. It is keyed on the owner's real
today, not the date being placed — keying it to date would make the mode a silent
no-op on every day but today, since nothing is ever created on a future date.
It is wired into both ordering paths, which is necessary rather than thorough:
placeFlightPlan skips its own fill_order branch whenever a daily_plan exists for the
day, so a value only the placer understood would be inert on exactly the days the owner
planned deliberately. deterministicIndices carries a matching band (after theme/memory,
before deadline/priority) — the same parity the existing two values already have.
tasks.created_at is defaulted by SQLite's CURRENT_TIMESTAMP, which stores
'YYYY-MM-DD HH:MM:SS' — a UTC instant with no zone designator, which new Date()
parses as local. On a server behind UTC that shifted every timestamp by the offset, so a
task created after roughly 17:00 local never appeared in New Today on the day it
actually arrived; it turned up the next day. Every task row in a real database uses this
spelling.
Fixed at both ends with the identical rule, so the Tasks tab and the new fill order cannot
disagree about the set: sqliteUtcToISO() folded into localDateInTZ server-side, and
the same normalization in isCreatedOnDay client-side. A no-op for the proper ISO strings
every other caller passes.
Migrations 0151–0171 landed between 2026-08-16 and 2026-08-30, 0172 on 2026-10-21 and 0173 on 2026-11-02. Numbers 0159 and 0160 were never used.
- 0151–0154 · People on work. A per-owner
peopledirectory (person_emailsfor matching) and two peer sets on tasks and time blocks, Responsible and Beneficiaries (entity_people). Ingest carries the people it recognised on a suggestion onto the task at promote (possible_task_people, 0152); a block inherits its task's people unless it pins its own, per role (0153, split per role in 0154). The me · us · them motivation label is projected from the Beneficiaries set. See docs/people-on-work.md - 0155 · Ollama thinking mode.
llm_thinking_enabled, per owner, off by default - 0156 · Capacity hours; the workday window is gone.
capacity_hours_per_week(default 56) feeds the AI-authoring capacity hint, and Auto's workday fallback fills the Work + Community pattern-block windows.workday_start/workday_endare no longer read - 0157–0158 · Dismissal learning.
possible_outcome_log,possible_dismissal_exemplars,possible_dismissal_rate; weeklycalibrate_dismissals(Monday 04:20);GET /api/dismissal-learning/trend,DELETE /api/dismissal-learning/exemplars - 0161 · Close-ritual rating.
tasks.completion_score(1–5, 0 = declined) replacescompletion_mood: three faces, with an optional adjacent second pick - 0162–0163 · Plan-loop evidence.
plan_loop_runs,plan_revision_log,plan_loop_stats; weeklycalibrate_plan_loop(Monday 04:30);GET /api/plan-loop/trend - 0164, 0165, 0168 · Balance targets reach the scheduler.
balance_bias_enabled(default on);balance_week_logand the weeklysnapshot_balance(Monday 04:40);GET /api/priorities/history; an index for the interval-overlap query - 0166, 0167, 0169 · Placement learning.
placement_log,placement_preferences,placement_survival; weeklycalibrate_placement(Monday 04:50); gap choice leans toward the learned hour band;GET /api/placement/survival,DELETE /api/placement/preferences; 0169 indexesscheduling_prefs(owner_id) - 0170 · Open Time per direction.
tag_prefs.show_open_time, on by default for every direction (Home windows used to be left out) - 0171 · The trust dial is an ordering policy.
planning_modeno longer followstrust_mode; owners who relied on Hands-off implying Smart were pinned tosmart - 0173 · Classification log entity kind.
classification_log.entity_kind(taskortime_block, defaulttask), so a Time Block's category picks and corrections are logged next to a task's. - 0172 · Invite-code sign-up.
signup_codes; withAUTH_SIGNUP=invite(the default) an operator mints single-use codes in Settings → Accounts for/signup - No migration. Auto fills a matching Time Block before any bare window (Pass 1.9). A
scheduled task can be given up on from the details drawer and is counted as
scheduled-and-cancelled in Balance → Metrics. The iOS and Android shells
(Mobile app) and
GET /api/sync/changesare onmainbut not yet in a tagged release. Companion Portal sign-in against the real Portal, security headers and the license change are in 4.2.0
Release notes from 4.2.0 on are in CHANGELOG.md; earlier changes are in
git log.
The latest git tag is v4.2.2 (2026-10-29). The root package.json still reads 4.2.0,
and the container image reports the version it was built with (CI_PLANNING_VERSION, set
from the tag) in GET /api/health. The migration head is 0173_classification_entity_kind: 169
migrations, 0001–0173, with no files for 0009, 0119, 0159 or 0160; the ordered list in
server/src/db/migrations/index.ts is authoritative. main is four commits past v4.2.2
(the iOS and Android shells, the polling endpoint they use, and a test fix). Release notes
are in CHANGELOG.md.
Working today:
- Koa + TypeScript backend serving both the API and the built client on port 3848
- SQLite schema (migrations 0001 → 0173 — the ordered list in
server/src/db/migrations/index.tsis authoritative): tasks, subtasks, time blocks, tags, entity_tags, tag_directions, patterns, pattern_blocks, tag_prefs, flight_plan, daily_plan, daily_recap, possible_tasks, scheduling_prefs, time_entries, block_templates, scheduled_jobs, reminders, push_subscriptions, replan_suggestions, audit_log, sync_events, reflections, objectives, objective_tasks, estimate_calibration, calibration_history, connection_tokens, external_events, copy_history, life_priorities, planning_sessions, people, entity_people, signup_codes, plus the learning tables (triality_log, triality_weights, classification_, possible_outcome_log, plan_loop_, placement_*, balance_week_log) - All nine routed SolidJS views, with full task CRUD (tags, notes, priorities, due date/time, subtasks)
- Deterministic auto-scheduler honoring pattern-block windows, locked meetings, Time Blocks and break preferences; per-pattern auto-fill gating
- Reactive reflow (
POST /api/schedule/run) — re-packs the day deterministically around protected (in-progress / recently-moved) blocks; debounced reactive triggers on task / calendar / missed-block changes, gated byauto_defense_enabled - AI planning (optional, Ollama): "AI day ordering" authors a durable
daily_planorder once with per-block "why now" rationale; placement stays deterministic.POST /api/plan/instructparses NL into pinned blocks; a big-disruption reflow raises a "re-plan with AI?" nudge (/api/plan/reauthor·/dismiss·/suggestion) that re-authors only on accept - Flight-plan staging area (two-step Auto)
- Pattern blocks — direction-scoped scheduling windows with per-pattern shape, color, fill toggle, and reminder offsets
- Notifications: a
remindersstore + 1-minutefire_remindersdispatcher (exactly-once), delivered in-app via SSE and via Web Push (VAPID) to subscribed devices; reminders are auto-derived from per-pattern reminder offsets, with a Settings opt-in toggle and snooze - Croner jobs: 25 cron-scheduled rows out of 26 executable kinds — Gmail ingest (every
15m since 0146), Calendar ingest, nightly grooming/dedupe, auto-schedule,
enrich_tasks, fire_reminders, purge_objectives, the Monday learning block (calibrate_triality03:50,calibrate_classification04:10,calibrate_dismissals04:20,calibrate_plan_loop04:30,snapshot_balance04:40,calibrate_placement04:50), and the rest. Owner-scoped kinds fan out once per owner; a kind still running is never re-started by the next tick. Full table in Scheduling system → Background jobs.promote_possiblesis the kind with no cron row (runnable on demand only);place_patternswas retired by 0026 - Ollama-backed extraction/summarization with deterministic fallbacks
- SSE events for real-time UI updates across open clients
- Tag taxonomy: direction / intention hierarchy with many-to-many grouping
- Objectives — life-planning hierarchy (Goal → Project → Objective) with task linkage, progress roll-up, and the
advance_objectivesjob that auto-stages tasks for active objectives into the flight plan - Reflections / FOCUS card — today focus + up to 3 weekly goals per ISO week, each optionally linked (
source_kind/source_ref) to a Task or Objective, with category color stripes and done/undone toggling; a Today's Brief pop-up carries the AI/deterministic prose + capacity + top tasks that used to be the standalone Morning Briefing section - Day note + five-star day (Phase O) — one note per day (
reflections.kind='day_note', PK-enforced uniqueness);computeStars()derives 5 booleans (intention/habits/tasks/bonus/reflection) with zero manual toggles, cached indaily_recapand frozen permanently by the nightlyfreeze_starsjob;GET /api/starsserves the frozen row over a live recompute - Weekly Review — scheduled/focus minutes, capacity bar, completion rate, and calibration bias insight
- Estimate calibration — the nightly (01:30)
calibrate_estimatesjob computes personal bias ratio (p25/p50/p75) from actual vs estimated minutes; surfaced in the Weekly Review. The same run appends an immutable per-(scope, day) row tocalibration_history(0138), so the regenerable rollup can keep being replaced without destroying the trend - Close ritual + Balance Metrics (0137/0138, rescored 0161) — an optional post-completion sheet (three faces → a 1–5 rating · note · real duration, stamped with its own
reaction_at) offered on every completion path and queued when several land at once, feeding a measured Metrics tab on/balance: counts, timed minutes, rating mix, and estimate bias per direction and per category over 7/30/90 days — tasks and habits in separate sections — served byGET /api/metrics/tasks+/api/metrics/calibration - Memory enrichment (0136) —
POST /api/tasks/:id/enrich(the task-card ✨ button) and the nightlyenrich_tasksjob fill blank description/direction/categories from Companion Memory; fill-blanks-only, and it reports an honest reason when it can't help - Recurrence horizon + Clear Date Range (Phase P) —
recurrence_horizon_daysbounds materialization in both recurrence engines (patterns + blocks); lowering it auto-prunes via a sharedclearPlannerRangeengine, which also backsPOST /api/planner/clear(dry-run preview, DB snapshot on apply) and the originalclear-planner-from.tsCLI script - Multi-device sync — field-level merge,
sync_eventsappend-only log,Last-Event-IDSSE catch-up (andGET /api/sync/changes?since=for clients that poll instead of streaming);POST /api/syncapplies offline op-log entries - GraphQL CRUD layer (Phase W) — a complete Apollo Server 5 surface at
POST /api/graphqlalongside REST (REST authoritative/untouched); Relay-style connections,Node/DateTime/JSONconventions, shared owner-scoped service layer, flag-gated GraphiQL sandbox - Natural scheduling (Phase AB) —
scheduled_for+task_timeare START intent that drives placement (a 2pm task lands at 2pm), withdue_ata deadline only; a sharedplaceStartAnchoredTaskhonors stated day/time across create, quick-add, and promote - Calendar reliability (Phase AD) —
task_timehonored uniformly (quick-add places on save; flight-plan pins timed tasks and packs flexible work around them); tasks inherit their container's direction tag; Alt-drag drops a block anywhere (overlap allowed) - Reflow correctness (Phase AE) — the reactive reflow is snapshot-based (clear set == re-place set), so editing one placed task can never wipe the other auto-placed blocks; placement status parity (
open↔scheduled), a not-before-now clamp, and locked-block guards across reschedule/review/sweep - Today + Tasks UI (Phase AC) — Today's single header row (Brief · Focus · Note popovers + FAB, collapsible DUE); Tasks task-chip redesign, Routine grouping with end-of-day archive and a midnight
reset_routinesjob - Memory integration (Phase AA, opt-in) — a pull-first, read-only link to the CI-Server
companion-memorygraph behindmemory_pull_enabled: thememory_suggestjob stages upcoming events as task suggestions (auto-promoted since Phase AF), the Daily Brief folds in a memory excerpt, the Hands-off trust tier's day ordering favors memory-related tasks, and memory-drafted tasks get a history-based default estimate; a one-waypush_memoryjob (behindmemory_sync_enabled) mirrors completions/habits/reflections back - People on work (0151–0154) — Responsible and Beneficiaries sets on tasks and time blocks, a per-owner people directory, and people detected on suggestions; see docs/people-on-work.md
- Learning from what you do — weekly, batch-only passes over per-owner evidence logs, never run mid-request:
calibrate_classification(category and motivation picks),calibrate_dismissals(suggestion noise),calibrate_placement(preferred hours for auto-placed blocks),calibrate_plan_loop(authoring-loop stats) and the flaggedcalibrate_triality. All but the plan-loop stats have a reset endpoint. Settings → Learning shows what the first four measured, with the number of cases behind every figure, and offers a clear button for all but the plan-loop stats (Triality's readout is on Balance, Metrics) - Balance targets in planning (0164–0165, 0168) —
balance_bias_enabled(default on) puts life dimensions that are behind their weekly target first in Fill and in the day's order;snapshot_balancefreezes each closed week intobalance_week_log - Accounts — Companion Portal sign-in and local username/password; invite-code self-serve sign-up (0172,
AUTH_SIGNUP); operator / member / viewer roles - Mobile — Capacitor iOS and Android shells in
mobile/that connect to a server you run (see Mobile app); onmain, not yet in a tagged release
Intentionally partial:
- Google
livemode (standalone OAuth + calendar sync) is implemented but opt-in: it stays dormant until an admin saves a Google OAuth client in Settings → Connections (needsCP_KEK_B64) or you setGOOGLE_MODE=livewithCP_GOOGLE_CLIENT_ID/CP_GOOGLE_CLIENT_SECRET, which pins the mode and makes the in-app setup read-only. With neither, Google integration is off entirely;GOOGLE_MODE=fixtureis an explicit dev/demo opt-in that runs on committed sample data with no network calls. See docs/google-cloud-setup.md. - Calendar write-back (pushing local blocks to Google) is behind
CP_SYNC_WRITEBACK, off by default — until you flip it on, sync is inbound read-only.CP_SYNC_DISABLEDis a master kill switch for the whole sync agent. - Web Push delivery uses a VAPID keypair that is auto-generated on first boot and persisted (see below); setting the env keys pins an explicit pair. In-app SSE reminders fire regardless
- CI-Server memory integration (Phase AA) is off by default (
memory_pull_enabled/memory_sync_enabledboth start0) and, inlivemode, needs a reachable CI-Server plus per-owner creds; withMEMORY_MODEunset (the default) the transport is off entirely, andMEMORY_MODE=fixtureis a dev/demo opt-in that runs against a committed sample graph with no network. Flag-off is byte-identical — the memory layer ships dark. - Time-Sense (Phase Z — Focus Ritual + estimate-calibration loop) is behind the
time_sense_enabledmaster flag (flag-off = behavior-neutral) - Triality (three candidate next actions + its learning loop) is behind the
triality_enabledmaster flag, off by default — with the flag off the Focus banner and the Balance → Metrics tiles never mount, and the three/api/triality/*routes plus/api/metrics/trialityall 404. Thecalibrate_trialitycron row exists either way and has nothing to do for an owner who never turned the flag on — no suggestions were shown, so there is no feedback to learn from - Routine placement (
enable_routines) and Ritual (enable_rituals) are per-owner flags, both off by default; Settings → Preferences → Routine Settings turns them on. Ollama "thinking" mode (llm_thinking_enabled) is also off by default - The iOS and Android shells are not store-ready, and Companion Portal sign-in is not available in them yet; see Mobile app
- Deferred or not started, per docs/ROADMAP.md: multi-user shared events
(M1–M5), the
patterns→routinestable rename, and timing intelligence (G-INT). The roadmap's phase map covers the phases up to AG and the tracks after it
The published image is public and multi-arch (amd64 + arm64). The image bakes
NODE_ENV=production, and production refuses the credential-free test auth mode at
boot — so you must pick a real auth mode or the container exits immediately.
Publishing is manual: docker-publish.yml is
workflow_dispatch-only. Dispatched on a release tag it pushes :latest, :sha-<short> and
:<tag>; dispatched on any other ref it pushes :latest and :sha-<short>.
Sign-in on a CI Hub install is a username and password. A self-hosted install uses the same
form with DISABLE_OIDC=true, and can add Companion Portal sign-in where Companion Portal is
the sign-in host.
1. Username and password — the CI Hub install. On a CI Hub appliance, install
Companion Planning from the marketplace and open it. The Hub has already done the
wiring: it mounts ${APP_DATA_DIR}/data at /data, gives the app its AI endpoint
(APP_OLLAMA_URL when the Hub runs Ollama, APP_OPENAI_BASE_URL when it runs vLLM or
Lemonade), sets TRUST_PROXY=true and PUBLIC_BASE_URL, and sets DISABLE_OIDC=true. The
install asks for an admin password, which seeds one local operator on first boot. Sign
in on the login screen with that username and password. The operator creates further
accounts from Settings → Accounts.
2. Password-only, no portal. For a self-hosted install with no Companion Portal, add
DISABLE_OIDC=true: the login screen offers only username/password, the admin password
is required (the container refuses to boot without a login path), and the operator
creates further accounts from Settings → Accounts (each gets a set-password link —
emailed when SMTP_* is configured, otherwise shown to the operator).
Companion Portal sign-in applies only where Companion Portal is the sign-in host for the
install. The login screen then offers it alongside the form. The first Portal sign-in on an
instance with no operator becomes the operator. AUTH_AUTO_PROVISION (off unless set)
decides what happens to a new Portal account. When it is on, the person confirms the new
Planning account on one short page first. When it is off, the sign-in returns to the login
screen and says why, and the operator creates the account under Settings → Accounts.
The portal only redirects back to on-device addresses — localhost, *.localhost,
*.local, a LAN (RFC 1918) IP — unless the host is a Hub's provisioned tunnel/tailnet name.
So a plain docker run reachable at http://<lan-ip>:3848 can use the portal; a public
hostname cannot without a Hub.
docker run -d --name ci-planning \
-p 3848:3848 \
-v ci-planning-data:/data \
-e TZ=America/Los_Angeles \
-e AUTH_MODE=sso \
-e PUBLIC_BASE_URL=http://192.168.1.20:3848 \
-e AUTH_AUTO_PROVISION=true \
-e SESSION_SECRET="$(openssl rand -hex 32)" \
-e AUTH_DEFAULT_USERNAME=you \
-e AUTH_DEFAULT_PASSWORD='pick-a-strong-password' \
ghcr.io/companionintelligence/ci-planning:latest
# open http://192.168.1.20:3848 → Continue with Companion Portal (first sign-in = operator),
# or sign in with the username/password above. Add -e DISABLE_OIDC=true for password-only.First-run notes:
- Set
TZto your IANA timezone (e.g.Europe/Berlin). The container defaults to UTC, which shifts what counts as "today" for the planner and the nightly jobs. - The username/password pair seeds one local owner on first boot only; afterwards it
is just your login (changing the env later doesn't rewrite the account — use the
in-app password reset, which needs
SMTP_*configured to actually email links). SESSION_SECRETsigns the login cookie — keep it stable across container recreations or every device gets signed out. (Leave it unset and the server generates one once and keeps it in/data/.session-secret; set it explicitly when you run more than one replica.)PUBLIC_BASE_URLis what the portal redirects back to (…/api/auth/ciportal/callback), so it must be the address you open in the browser, scheme and port included.- Google Calendar (optional): add
-e CP_KEK_B64="$(openssl rand -base64 32)"to the run command (it encrypts stored OAuth tokens — keep it stable likeSESSION_SECRET), then paste your Google OAuth client into Settings → Connections → Google OAuth client and hit Save & enable — no other env vars, no restart. Each user then connects their own Google account with the Connect button. See docs/google-cloud-setup.md for creating the client. - Web Push notifications work automatically — the server generates a VAPID keypair on
first boot and persists it (stable across restarts), so background push is live out of the
box with no
npx web-push generate-vapid-keysstep. IfCP_KEK_B64is set, the private key is sealed at rest; without it the key is stored unsealed (a low-stakes secret that only authorizes pushing to this app's own subscribers). Setting bothVAPID_PUBLIC_KEYandVAPID_PRIVATE_KEYpins an explicit keypair instead. - Crash reporting is opt-out. Released images carry a Sentry DSN and report crashes
(errors only, scrubbed, never your data) until you set
CI_TELEMETRY=off— the Hub's Share crash reports toggle — orCI_LOCAL_ONLY=true. Details in docs/operations/error-reporting.md. - Quick trial without auth (single machine, no real data): add
-e NODE_ENV=developmentinstead of the auth vars. This runs the devAUTH_MODE=testidentity — do not expose it beyond localhost. - API-only alternative (
-e AUTH_MODE=standalone): authenticates by hashedx-api-keyheader. Mint the first key withdocker exec ci-planning node_modules/.bin/tsx src/cli/issue-key.ts(orpnpm key:issuefromserver/in a source checkout) — note the browser UI does not attach API keys, so this mode is for REST/GraphQL clients and agents, not the PWA. - Operator overrides can live on the data volume. The container starts Node with
--env-file-if-exists=/data/.env, so a/data/.envfile supplies any variable the container's own environment does not already set (real-evalues win).SENTRY_DSNis baked into the image environment, so that file cannot override it — useCI_TELEMETRY=offorCI_LOCAL_ONLY=trueto switch crash reporting off. - Health check. The image declares a Docker
HEALTHCHECKthat polls the publicGET /api/healthevery 30 seconds (20-second start period, for first-boot migrations).
For a self-host stack that bundles Ollama so the AI features work out of the box, use the
docker-compose.yml at the repo root. It runs the app + an ollama
service wired together over the compose network (OLLAMA_URL=http://ollama:11434), with
model data persisted in a named volume. The compose file pins the password-only
install (AUTH_MODE=sso + DISABLE_OIDC=true): the .env username/password is the only
login, and the operator adds people from Settings → Accounts.
cp .env.example .env # set SESSION_SECRET + login
docker compose up -d # app + ollama
docker compose --profile pull run --rm ollama-pull # pull default models once
# open http://localhost:3848 → sign in with the username/password from .envThe model pull sits behind the pull profile so a plain docker compose up never triggers
multi-GB downloads — run it once (or skip it and set OLLAMA_FAST / OLLAMA_QUALITY /
OLLAMA_EMBED to models you already have). Until a reachable Ollama has the models, the app
runs fine and simply hides the AI features. This file is for the self-host path only; the
CI-Hub marketplace install ignores it (the Hub provides its own Ollama and the
Companion Portal sign-in described above).
The file also defines a postgres service behind --profile postgres, for people working on
the in-progress Postgres port (see DATABASE_URL below). A plain docker compose up never
starts it, and it is not a supported deployment option yet.
| Var | Default | Purpose |
|---|---|---|
PORT |
3848 |
HTTP port (API + UI) |
DB_PATH |
/data/companion.db in Docker; ~/Library/Application Support/CompanionPlanning/companion.db on macOS; set explicitly on Linux / Windows |
SQLite file path. The containing directory is created automatically. |
DATABASE_URL / STORE_BACKEND |
(unset) / sqlite |
Internal, in-progress Postgres cutover — not a supported deployment option yet. Both backends coexist behind db/store.ts. Nearly every call site now goes through that facade (the sqlite-vec dedupe path is the exception), but flipping STORE_BACKEND=postgres is the cutover itself and it is not finished, so boot fails closed without a DATABASE_URL rather than silently serving from a store the data isn't in. SQLite remains the single source of truth described elsewhere in this README until that migration lands. |
NODE_ENV |
(unset) | production enables fail-closed boot guards (e.g. forbids AUTH_MODE=test). |
TRUST_PROXY |
false |
Set when behind a trusted TLS-terminating reverse proxy (e.g. CI-Hub's tunnel): honors X-Forwarded-Proto (⇒ Secure session cookies over HTTPS) and X-Forwarded-For (⇒ real client IP for login rate-limiting). |
CORS_ALLOWED_ORIGINS |
(unset) | Comma-separated browser origins allowed to call /api cross-origin, echoed back exactly (never *). Empty by default: the web app is same-origin, and the mobile app needs no CORS setting either, since it calls the server through the native HTTP stack. Set it only if the web app is served from a different origin than the API. |
CSP_EXTRA_CONNECT_SRC |
(unset) | Extra origins the browser may call, added to the Content-Security-Policy connect-src (space/comma-separated). The app's own origin and the Sentry DSN origin are always allowed. Every response carries security headers (CSP, nosniff, Referrer-Policy, X-Frame-Options, Permissions-Policy; HSTS over HTTPS). |
OLLAMA_URL |
http://127.0.0.1:11434 |
Ollama endpoint. Unreachable ⇒ AI features degrade gracefully (template briefings, dedupe off). Read as APP_OLLAMA_URL when unset. When neither is set and APP_OPENAI_BASE_URL is, the AI endpoint answers instead. |
OLLAMA_FAST |
qwen2.5:0.5b |
Fast model for extraction/triage. A last-resort default: used when it is installed, otherwise the app auto-selects an installed model (see 5. Ollama under Local development). Setting it pins the tier verbatim, and an unpulled pin fails with an ollama pull … error. Legacy alias: OLLAMA_SMALL (still accepted; OLLAMA_FAST wins when both are set). |
OLLAMA_QUALITY |
gemma3n:e4b |
Quality model for briefings, planning, and the classification behind quick add and the Add dialog's Describe-it line. Same rule as OLLAMA_FAST: used when installed, otherwise auto-selected; setting it pins the tier. Legacy alias: OLLAMA_MEDIUM (still accepted; OLLAMA_QUALITY wins when both are set). |
QUICK_ADD_HINT_MODELS |
(empty) | Comma-separated model names to add to the ones quick add shows your own corrections to. The built-in list is gemma3n:e4b, qwen2.5:3b and gemma3:4b, the models it was measured to help; on any other model the Describe-it prompt is the usual one, because three other models answered worse with the examples. No wildcard. Measure a model yourself before adding it (docs/quickadd-hint-guard-2026-11.md). |
OLLAMA_EMBED |
nomic-embed-text |
Embedding model for dedupe. Same rule: used when installed, otherwise an installed embedding model is auto-selected; setting it pins the tier. |
APP_OPENAI_BASE_URL |
(unset) | An OpenAI-compatible endpoint (…/v1, added when missing) for AI, used when no Ollama URL is set. The CI Hub writes it when its AI runs on vLLM or Lemonade. Chat, embeddings and the model list all go through it. |
APP_OPENAI_API_KEY |
(unset) | Bearer token for APP_OPENAI_BASE_URL, for an endpoint that takes one. Never written to logs or errors. |
APP_CHAT_MODEL |
(unset) | Chat model on APP_OPENAI_BASE_URL. Pins both the fast and quality tiers. auto lets the endpoint choose a model it holds. |
APP_EMBED_MODEL |
(unset) | Embedding model on APP_OPENAI_BASE_URL. When unset, an embedding model the endpoint lists by name (containing embed) is used if it has one. The CI Hub does not map an embedding model into this app, so operators set this themselves. |
TZ |
UTC | Container timezone — set it so "today" and cron schedules match your day (the Compose file defaults it to America/Los_Angeles; set TZ in .env). Safety net: if the stored scheduling timezone is still the un-chosen UTC default, the web app adopts the browser's timezone on first load (once per browser). |
AUTH_MODE |
test |
Auth mode: test (dev/CI — system owner injected, forbidden in production), standalone (hashed x-api-key stored in the api_keys table; API clients only), sso (the browser login — Companion Portal sign-in and/or local username/password; both end in a signed httpOnly session cookie). The CI-Hub install and the Compose file run sso; the Docker image itself sets no default, so a plain docker run exits at boot unless you choose a mode. |
AUTH_AUTO_PROVISION |
false |
When true, an unknown Companion Portal sub provisions a new owner row instead of returning 403. The first such owner on an instance with no operator becomes the operator; later ones are members. The CI-Hub marketplace install sets it to true (the portal already limits sign-in to members of the Hub's organization). |
AUTH_RATE_WINDOW_MS |
60000 |
Sliding window, in milliseconds, for the login rate limit (AUTH_RATE_ISSUE_MAX). |
AUTH_RATE_ISSUE_MAX |
5 |
Max attempts per window per IP (one shared bucket) on local login, sign-up, password-reset request and completion, and the Companion Portal sign-in start. Reset requests are also limited per account. Held in memory, so it resets on restart. |
SESSION_SECRET |
(auto-generated) | Signs the httpOnly session cookie both AUTH_MODE=sso login paths issue. Unset ⇒ the server generates one on first boot and keeps it in .session-secret beside the database (mode 0600), reusing it on every boot; boot fails closed only if that file cannot be created (a read-only data directory). Set it explicitly when you run more than one replica (they must share one secret) or want it outside the data volume. Changing it signs everyone out. Generate with openssl rand -hex 32. Never commit it. |
DISABLE_OIDC |
false |
Meaningful only with AUTH_MODE=sso. true turns the Companion Portal login off, leaving username/password as the only login — the password-only self-host install. With it unset the login screen offers the portal and the local form (local login is served in both sub-modes). test/standalone modes ignore it. |
AUTH_DEFAULT_USERNAME |
admin |
Username of the first local owner (the operator) seeded on boot when no local owners exist yet — in both sso sub-modes. On a CI Hub this is the admin login you set at install time. |
AUTH_DEFAULT_PASSWORD |
(unset) | Password for that seeded owner. Required under DISABLE_OIDC=true with zero local owners — fail-closed if unset. Optional under Companion Portal login, where it seeds the break-glass local operator that can still sign in when the portal is unreachable; leave it unset and the first portal sign-in claims the operator role instead. Never commit it. |
AUTH_RESET_TOKEN_TTL_MS |
3600000 |
Password-reset link lifetime for local login (default 60 min, single-use). |
AUTH_SIGNUP |
invite |
Local self-serve sign-up at /signup (meaningful only with AUTH_MODE=sso): invite requires a single-use code an operator mints in Settings → Accounts (7-day TTL; the person picks their own username and password — the operator never sees it), open needs no code, off removes the page (404). An unknown value refuses to boot. Independent of Companion Portal — with the portal on, the login screen's sign-up link is the portal itself and /signup is reached by direct link. The first account on an instance with no operator becomes the operator. |
CIPORTAL_ISSUER |
https://hub.ci.computer/api/auth |
Companion Portal (CI-Portal) SSO discovery base (OAuth 2.1 + PKCE) — the production portal. Staging is https://hub.companionintel.com/api/auth, dev https://hub.companionintelligence.com/api/auth. Endpoints and the token iss are read from <issuer>/.well-known/openid-configuration. Meaningful when AUTH_MODE=sso and DISABLE_OIDC is not true; on a CI-Hub appliance the Hub injects the paired portal's value. |
CIPORTAL_CLIENT_ID |
ci-planning |
Our client registered with Companion Portal as a first-party public (PKCE) client. A fixed first-party identity, so it defaults rather than needing to be looked up; override only for a fixture portal or a differently-registered fork. |
CIPORTAL_CLIENT_SECRET |
(unset) | Only if registered as a confidential client; leave unset for public/PKCE. Never commit it. |
CIPORTAL_REDIRECT_URI |
${PUBLIC_BASE_URL}/api/auth/ciportal/callback |
Must match the URI registered with Companion Portal — so PUBLIC_BASE_URL must be the address you open in the browser (on a CI Hub the Hub sets it from APP_BASE_URL). |
CIPORTAL_SCOPES |
openid profile email |
Scopes requested at authorize. |
CIPORTAL_AUTHORIZE_URI / CIPORTAL_TOKEN_URI / CIPORTAL_JWKS_URI |
(unset) | Advanced: set all three to bypass discovery (air-gapped / no discovery doc). When overriding, CIPORTAL_ISSUER must equal the token's actual iss. |
GOOGLE_MODE |
(unset ⇒ off / in-app) | live (real Google OAuth + Calendar sync; each user connects their own account in Settings → Connections) or fixture (committed sample data, fully offline — dev/demo opt-in, refused in production unless CP_ALLOW_FIXTURE=true). Unset ⇒ off, unless an admin saves a Google OAuth client in Settings → Connections (in-app setup, needs CP_KEK_B64) — that flips live mode on at runtime with no env vars and no restart. An explicit env value pins the mode and makes the in-app setup read-only. Env live is hard-required to also supply CP_GOOGLE_CLIENT_ID and a redirect URI, or the server refuses to boot. |
CP_ALLOW_FIXTURE |
(unset) | Escape hatch: allows GOOGLE_MODE=fixture and MEMORY_MODE=fixture under NODE_ENV=production (demo appliances only — fixture data looks like real tasks). |
CP_GOOGLE_CLIENT_ID |
(unset) | Google OAuth client ID. Required when GOOGLE_MODE=live. See docs/google-cloud-setup.md. |
CP_GOOGLE_CLIENT_SECRET |
(unset) | Google OAuth client secret. Needed for a Web client (ts.net HTTPS deploy); a Desktop/installed client with a loopback redirect can run without one. Never commit it. |
PUBLIC_BASE_URL |
http://localhost:3848 |
Externally reachable base URL. Used to derive the default redirect URIs (Google, Companion Portal login, Companion Memory callback) and the default APP_BASE_URL. |
CP_OAUTH_REDIRECT_URI |
${PUBLIC_BASE_URL}/api/oauth/google/callback |
Exact redirect URI; must match what you register in the Google client. |
CP_GOOGLE_AUTH_URI |
https://accounts.google.com/o/oauth2/v2/auth |
Authorization endpoint (override only to point tests at a stub). |
CP_GOOGLE_TOKEN_URI |
https://oauth2.googleapis.com/token |
Token endpoint (override only for tests). |
CP_GOOGLE_REVOKE_URI |
https://oauth2.googleapis.com/revoke |
Token-revocation endpoint (override only for tests). |
CP_GOOGLE_JWKS_URI |
https://www.googleapis.com/oauth2/v3/certs |
JWKS used to verify the Google id_token signature at the callback (override only for tests). |
CP_GOOGLE_ENABLE_GMAIL |
false |
When true, /start also requests the Gmail scope. Gmail is a Google restricted scope (needs OAuth verification + a CASA assessment), so it is opt-in and gated separately from the calendar loop. |
CP_GOOGLE_ACCOUNT |
primary |
Account key the live Google client binds its MCP session to (until in-app multi-account selection lands). |
CP_KEK_B64 |
(unset) | Base64 of a 32-byte key-encryption key (AES-256-GCM) for the encrypted connection_tokens store. Generate with openssl rand -base64 32. Required for live token storage/refresh — loaded lazily, so a live deploy without it fails on the first token operation (not at boot). Never commit it. |
CP_KEK_VERSION |
1 |
KEK version a fresh token seal is written under (drives rotation). |
CP_KEK_B64_V{n} |
(unset) | Historical KEK for version {n}, used while rotating (e.g. CP_KEK_B64_V2). The current version may live in plain CP_KEK_B64. |
CP_SYNC_WRITEBACK |
false |
When true, sync pushes local block changes back to the provider. Off ⇒ inbound read-only. |
CP_SYNC_DISABLED |
false |
true is a master kill switch — disables the sync agent entirely. |
CP_MCP_COMMAND |
bundled MCP server | Command that launches the Calendar MCP server child the connector drives over stdio. Defaults to the bundled one (node_modules/.bin/tsx src/scripts/mcp-google-server.ts, resolved from server/), so live sync works out of the box; override only to swap in a different MCP server. See docs/google-cloud-setup.md §7. |
CP_MCP_TIMEOUT_MS |
30000 |
Per-call timeout for MCP requests. |
CP_MCP_RETRIES |
3 |
Retry budget for MCP requests. |
CP_MCP_BREAKER_THRESHOLD |
5 |
Consecutive MCP failures before the circuit breaker opens. |
CP_MCP_BREAKER_COOLDOWN_MS |
30000 |
How long the MCP circuit breaker stays open before it half-opens and retries. |
MEMORY_MODE |
(unset ⇒ off) | CI-Server memory transport (Phase AA): live (real graph API) or fixture (committed sample graph, fully offline — dev/demo opt-in). Unset ⇒ the memory transport is off and every consumer degrades to its "not connected" no-op. Availability is still creds-driven in live mode. Resolved in server/src/lib/config.ts as config.memory.mode — a getter, so it is re-read on every access and a mid-run change takes effect immediately. |
CI_SERVER_URL |
(unset) | CI-Server memory API base URL. Serves double duty: single-tenant dev/standalone fallback base, and required (503 if unset) by the CI-Portal memory connect flow (/api/memory/ciportal/start) even under multi-tenant SSO. Not ignored under SSO — see CI_SERVER_API_KEY. Read through config.memory.serverUrl (a getter, like MEMORY_MODE), never from process.env outside lib/config.ts. |
CI_SERVER_API_KEY |
(unset) | Shared API key paired with CI_SERVER_URL for the single-tenant fallback creds. This is what's ignored under multi-tenant SSO (a shared key would map every owner to one CI-Server identity); per-owner creds then come from connection_tokens, connected in Settings. |
LOG_LEVEL |
info |
debug / info / warn / error |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY |
auto-generated | Web Push keys. Auto-generated on first boot and persisted to app_config (sealed with CP_KEK_B64 when set, else stored unsealed), so push works out of the box. Set both to pin an explicit keypair; a lone half keeps push dormant. Never commit them. |
VAPID_SUBJECT |
mailto:admin@companion.local |
VAPID contact (mailto: or URL) sent with each push. Left unset, the first operator account's email is used when it has one (mailto:<email>); the default is the fallback. |
SMTP_HOST |
(unset) | Outbound SMTP host for local-login password-reset email (meaningful whenever AUTH_MODE=sso — every local username/password account, including the break-glass operator, resets this way; portal users reset their password in Companion Portal). Unset ⇒ a console transport logs the message instead of sending, so dev/CI/offline need no SMTP; a production local-login deploy without it boots with a warning (links only logged). |
SMTP_PORT |
587 |
587 (STARTTLS) or 465 (implicit TLS). |
SMTP_SECURE |
false |
true for port 465 (implicit TLS); false for 587/25 (STARTTLS). |
SMTP_USER |
(unset) | SMTP auth username (omit for an unauthenticated relay). |
SMTP_PASS |
(unset) | SMTP auth password. Never commit it. |
MAIL_FROM |
Companion Planning <no-reply@companion.local> |
From: header on reset emails. |
APP_BASE_URL |
${PUBLIC_BASE_URL} |
Base URL the reset link is built against (${APP_BASE_URL}/reset-password?token=…). Set when the externally reachable URL differs from PUBLIC_BASE_URL. |
SNAPSHOT_RETAIN |
7 |
Number of daily snapshots to keep in <DB_DIR>/backups/ |
GRAPHQL_MAX_COMPLEXITY |
1000 |
Rejects a GraphQL query whose estimated cost (fields selected) exceeds this. |
GRAPHQL_SANDBOX |
(unset) | 1 serves the self-hosted GraphiQL IDE at GET /api/graphql/sandbox. On by default outside production; in production it is opt-in via this flag. |
CIPORTAL_MEMORY_REDIRECT_URI |
${PUBLIC_BASE_URL}/api/memory/ciportal/callback |
The memory connector's own OAuth callback (AA0b) — distinct from the login callback. |
SENTRY_DSN |
(unset) | Opt-out error reporting. Set ⇒ reporting is on by default (turn it off with CI_TELEMETRY / CI_LOCAL_ONLY below). Unset ⇒ no Sentry code initialises and the process makes no outbound connection — unconfigured, not opted out. Errors only: no tracing, no session replay, no PII (sendDefaultPii: false), and every payload is scrubbed of credentials and home paths first. See docs/operations/error-reporting.md. |
CI_TELEMETRY |
(unset) | off / false / 0 / no / disabled turns reporting off even with a DSN configured — so an operator can disable a DSN baked into an image without rebuilding it. Reaches the browser too: the bundle asks GET /api/config/telemetry before it initialises. |
CI_LOCAL_ONLY |
(unset) | true / 1 / yes / on ⇒ local-only mode: no outbound crash reporting at all. Beats both CI_TELEMETRY and the DSN, on both halves. |
SENTRY_ENV / CI_PLANNING_ENVIRONMENT |
(unset) | Sentry environment. Resolution order: SENTRY_ENV → CI_PLANNING_ENVIRONMENT → NODE_ENV → development. |
SENTRY_RELEASE / CI_PLANNING_VERSION |
(unset) | Sentry release. Explicit SENTRY_RELEASE wins; otherwise it is derived as ci-planning-server@${CI_PLANNING_VERSION}. |
VITE_SENTRY_DSN / VITE_SENTRY_RELEASE / VITE_CI_PLANNING_ENVIRONMENT |
(unset) | The browser half of the above. Build-time only — Vite inlines them into the bundle, so in Docker they are --build-args, not runtime variables. A build-time DSN is not sufficient on its own: the bundle must also be told at runtime that reporting is permitted. |
TYPEORM_LOGGING |
false |
true logs every statement the TypeORM datasource issues. Only meaningful with the in-progress Postgres port (DATABASE_URL). |
POSTGRES_PASSWORD / POSTGRES_USER / POSTGRES_DB |
(unset) / planning / planning |
Compose only — read by docker-compose.yml's opt-in postgres service (--profile postgres), never by the server. The password is required when you start that profile. |
CP_FIXTURES_DIR / CP_MEMORY_FIXTURES_DIR |
server/test/fixtures / server/fixtures/memory |
Dev/tests only — where GOOGLE_MODE=fixture and MEMORY_MODE=fixture read their committed sample data. |
CP_FIXED_NOW |
(unset) | Dev/demo only — an ISO instant that pins the "now" the natural-language quick-add parser resolves dates against; an unparseable value is ignored with a warning. |
Persistence: everything lives in the single SQLite file at DB_PATH. Mount a
volume at /data (as above) and your tasks survive container recreation.
mobile/ holds two Capacitor shells, mobile/ios and mobile/android
(app id com.companionintelligence.planning, name Companion Planning). Each wraps the same
client/ bundle in a native app and talks to a CI-Planning server that you run. There is no
hosted backend and no separate mobile codebase: the app is a window onto your own instance, so it is
only useful with one the phone can reach.
| iOS | Android | |
|---|---|---|
| Path | mobile/ios |
mobile/android |
| Toolchain | macOS, Xcode 26+ (Swift Package Manager; no CocoaPods) | JDK 21, Android SDK 36 (the Gradle wrapper pins Gradle 8.14 / AGP 8.13) |
| Minimum OS | iOS 16.4 | Android 7 (API 24), target API 36, with Android System WebView 111 or newer |
- First run asks for your server. The app opens on Connect to your server: type the address you
would open in a browser (
http://192.168.1.20:3848,https://planning.example.com, a Tailscale address). It checks the publicGET /api/healthandGET /api/auth/configand saves the address only if they look like a Companion Planning server. Change it later under Settings → Profile → Server, or from the sign-in screen. Switching servers discards edits made offline that had not synced yet, because the queued edits are addressed to the old server. - The address is configuration, not a secret, and is never built into the bundle. It lives in the app's local storage on the device. Credentials are not stored by the app at all: you sign in through the server's own login screen, and the session cookie sits in the operating system's cookie jar.
- Pinch-to-zoom works in both shells (Capacitor's
zoomEnabled, which is off by default and is independent of the page's viewport tag), and text fields are at least 16px on touch devices so that focusing one does not zoom the page.client/src/mobile-zoom.test.tsfails if either is switched off again. - The web app does not change.
client/src/lib/server-config.tsis the one place that knows the difference. In a browser (nowindow.Capacitor) the API base is/apias ever; in the shell it is<your server>/api. Everything else Capacitor-specific is guarded the same way, so the Docker image andpnpm devbehave exactly as before. - Requests use the native HTTP stack (
CapacitorHttp). The app is served fromcapacitor://localhost(iOS) orhttps://localhost(Android), so a call to your server is cross-origin; going through native HTTP means the server needs no CORS configuration for them.
- Reachability. The phone must reach the server: same Wi-Fi, Tailscale, or a public
https://address. Android allows plainhttp://to any host. iOS allows it only for IP addresses,.localnames and single-label host names (App Transport Security's local-networking exemption); anything else, such as a MagicDNS name, must behttps://. In the iOS Simulator your machine islocalhost; in the Android emulator it is10.0.2.2. - Sign-in. The app is built for username and password sign-in (
AUTH_MODE=ssowithDISABLE_OIDC=true, the Docker Compose default) and for the open dev mode (AUTH_MODE=test). Its sign-in screen shows the username form and nothing else: no Companion Portal button, no sign-up link (and/signupsends you to sign-in). Companion Portal sign-in is not available in the app yet. It is a redirect out to another site and back with a cookie, and Capacitor opens any navigation off the app's own origin in the system browser, so the session would land in Safari or Chrome and never in the app. A server that only offers Portal sign-in therefore cannot be used from the app until the native handoff is built, and the app says so instead of showing a button that cannot work. Settings has the same limit in two places: Connect Google and Sign in with Companion Portal (for Companion Memory) are redirects too, so they are not offered in the app. Make those connections from a browser; they live on your server and appear in the app once made. - Live updates are polled. A browser tab holds a server-sent event stream (
/api/events); the app cannot, because the stream would be opened by the web view rather than the native HTTP stack, so it would carry no session cookie and the server allows no foreign origin. The app asks instead:GET /api/sync/changes?since=returns the samesync_eventsthe stream replays, and the app calls it every 15 seconds while it is on screen, when it comes back to the foreground or the network returns, and a moment after a change of its own (whose server-side effects, such as blocks being placed, then show up promptly). Nothing is polled while the app is in the background. The endpoint needs a server newer than v4.2.2; an older one answers 404 and the app simply has no live updates, as before: it still shows changes after its own actions and on reload. Browsers are unchanged and keep the stream. No CORS setting is needed.
Also not available in the app yet: browser reminders and Web Push (there is no service worker in the
shell), so Settings hides the push switch. The Export JSON and calendar .ics downloads are hidden
too, because the WebView cannot save a file. Native push and a share-sheet export are separate pieces of
work.
The shells are not members of the pnpm workspace (pnpm-workspace.yaml lists only server and
client). Capacitor's CLI, SwiftPM and Gradle need a conventional node_modules, and the root install
must not change because a native project exists. Each shell is its own pnpm project, with its own
lockfile, and a pnpm-workspace.yaml that stops pnpm walking up into the root workspace. The shells
consume the client only as a build artifact (webDir: ../../client/dist).
# once, at the repo root (the client's dependencies come from GitHub Packages, as for any install)
pnpm install
# build the client for the shells → client/dist. Not `pnpm build`, which writes server/public for the container.
pnpm --filter client build:mobile
# Android
cd mobile/android
pnpm install # the shell's own dependencies
pnpm exec cap sync android # copies client/dist into the project
cd android && ./gradlew assembleDebug # → app/build/outputs/apk/debug/app-debug.apk
adb install -r app/build/outputs/apk/debug/app-debug.apk
# iOS (macOS, Xcode 26+)
cd mobile/ios
pnpm install
pnpm exec cap sync ios
pnpm exec cap open ios # run from Xcode, or build for the Simulator unsigned:
xcodebuild -project ios/App/App.xcodeproj -scheme App -configuration Debug \
-sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \
-derivedDataPath ios/DerivedData CODE_SIGNING_ALLOWED=NO buildInside either shell directory, pnpm build is build:web (the client build above) followed by
cap sync. The generated ios/ and android/ projects are committed; the copies of the web bundle
and capacitor.config.json that cap sync writes into them are git-ignored.
The manual workflow .github/workflows/mobile-build.yml builds
both on clean runners and uploads a debug APK and a zipped Simulator .app as artifacts. Like every
workflow here it is workflow_dispatch only (gh workflow run mobile-build.yml --ref <branch>), and
it is independent of the Docker publish workflow and release tags.
The artifacts above are for development and QA: the APK is signed with Gradle's throwaway debug key
and the Simulator .app is unsigned. Nothing here is store-ready.
- Accounts and signing. An Apple Developer Program membership (register the bundle id; a
distribution certificate and provisioning profile; set the team in the Xcode project) and a Google
Play Console developer account. Android needs an upload keystore and a signed AAB
(
bundleRelease); the Gradle project has no release signing configured, andversionCode/versionNamedefault to the placeholders1/1.0; set them per build with-PappVersionCode=<n>and-PappVersionName=<name>. The iOSMARKETING_VERSIONandCURRENT_PROJECT_VERSIONare set per build withxcodebuild MARKETING_VERSION=<name> CURRENT_PROJECT_VERSION=<n>. Release lanes in CI need the signing secrets and must pass those values. - Artwork and listings. The app icon and launch screen were replaced in #644 and
#651. Both stores still need screenshots, descriptions, a support
URL, a privacy policy URL, content ratings, and the privacy disclosures (Apple's App Privacy
answers and Google's Data safety form). The drafts are in
docs/mobile-store/.Info.plistdeclaresITSAppUsesNonExemptEncryptionasfalse(the app's only cryptography is the operating system's TLS) and the app target carries aPrivacyInfo.xcprivacythat declares nothing collected and no required-reason APIs, checked by scanning the built binaries; both are the owner's to confirm before the first upload, and the manifest needs a new entry whenever a plugin brings one of those APIs. - Legal pages. The privacy policy is a draft in
docs/mobile-store/and is not published. The repo has no terms of use, support page or data-deletion statement yet, and both stores need public URLs. The policy must cover crash reports from the released server images (Sentry, when the CI secret is set); the mobile builds send none. It must also cover any demo server run for review. - App Review. Apple will not accept a client that cannot be exercised, so submission needs a
reachable demo server with review credentials, and a position on guideline 4.2 (a web-technology
app must feel like an app). The shell offers no way to create an account: New account, invite codes
and the sign-up links are hidden, and
/signupsends you to sign-in. Apple's in-app account-deletion rule (5.1.1(v)) has a proposed position indocs/mobile-store/app-review-notes.mdfor the owner to confirm: an operator can delete other accounts from the app, but no one can delete their own. Play will ask whyusesCleartextTrafficis on. On Android it applies to the whole app; on iOS, App Transport Security limits plain http to local-network names. - Product gaps listed above. Companion Portal sign-in (system browser with a custom-scheme or universal-link return, PKCE), native push or local notifications for reminders, and downloads through the share sheet.
- Real devices. Everything here was exercised in the iOS Simulator and an Android emulator only.
- Node.js
>=22(developed on 26 —.nvmrcpins 26.3.0 — and the Docker image ships Node 26;ci.ymlruns Node 22) - pnpm
>=11 <12— easiest via Corepack:corepack enableactivates the exact version pinned inpackage.json, no global install. Node 26 no longer bundles Corepack, so runnpm install -g corepackfirst (the Dockerfile does the same) or install pnpm 11 another way - An AI model (optional) — Ollama, or your CI Hub's AI endpoint; only the AI features need it, and everything else runs without it
The repo is a two-package pnpm workspace: server/ (Koa + TypeScript API) and
client/ (SolidJS + Vite PWA).
git clone https://github.com/companionintelligence/CI-Planning.git
cd CI-Planning
corepack enable
pnpm installpnpm install also needs a GitHub Packages token for the private @companionintelligence/*
packages: the repo .npmrc points that scope at npm.pkg.github.com, and pnpm 11 does not
expand ${NODE_AUTH_TOKEN} there, so put the token in a user-level npmrc once per machine —
see Fresh clone install.
The dev defaults are deliberately friction-free: AUTH_MODE=test signs every request
in as a local system owner (no login screen — never expose a dev instance beyond
localhost), and every integration (Google, memory, Web Push) stays off until you
configure it. To override anything, copy the annotated env template — the server
auto-loads server/.env on start:
cp server/.env.example server/.env # gitignored; every variable documented inlineThe one variable Windows and Linux users should set right away is DB_PATH —
where the single SQLite database lives. Unset, it falls back to
~/Library/Application Support/CompanionPlanning/companion.db (the macOS
convention; on other platforms this still works but drops a Library/ folder in
your home directory). Point it somewhere sensible in server/.env:
# server/.env — the conventional local-dev choice: `data/` is gitignored, so WAL
# and shm leftovers never show up in `git status`
DB_PATH=./data/companion.dbAny absolute path works too (C:\Users\you\CompanionPlanning\companion.db,
/home/you/.local/share/companion-planning/companion.db). The containing directory
is created automatically, and migrations run on every server boot — a fresh install
just works.
pnpm devThis starts both halves concurrently:
| Process | Port | What it is |
|---|---|---|
server |
3848 | Koa API, under tsx watch (restarts on file change) |
client |
5173 | Vite dev server with HMR, proxying /api → :3848 |
Open http://localhost:5173 — that is the app in dev mode. Port 3848 serves
the API plus whatever client build was last written to server/public/; in dev use
it only for direct API calls (e.g. curl http://localhost:3848/api/health).
To run the halves in separate terminals instead:
pnpm --filter server devpnpm --filter client devThe client respects VITE_PORT (dev-server port), VITE_API_TARGET (proxy target) and
VITE_ALLOWED_HOSTS (comma-separated host names Vite answers to, for reaching the dev
server by name, e.g. a Tailscale MagicDNS host; bare IPs always work) when you need a
second instance, a non-local API or named access.
Open the repo folder in VS Code. .vscode/launch.json ships
four entries in the Run and Debug panel (Ctrl+Shift+D) — pick one and press
F5:
| Configuration | What it does |
|---|---|
| Full stack (pnpm dev) | Runs pnpm dev in the integrated terminal with the JS debugger auto-attached to the spawned Node processes. The everyday choice. |
| Server (debug) | API server alone under the debugger (via tsx) — breakpoints in server/src/**/*.ts hit directly, no build step. |
| Client (Vite dev) | Vite dev server alone. |
| Server (debug) + Client | Compound of the two above — full stack with server-side breakpoints. |
The server configurations load server/.env automatically when it exists, exactly
like the CLI scripts. For client code, use the browser dev-tools (Vite serves source
maps) or attach a browser-debug session to http://localhost:5173.
Want a populated app to explore? Seed ~100 tasks from the bundled fixtures:
pnpm seed # uses Ollama extraction when available, deterministic otherwiseSeeding only adds. The script's --reset flag deletes every owner's tasks, possible tasks,
blocks and non-base tags first, and --no-llm skips Ollama. The seeded rows belong to the
dev system owner, so this is for the default AUTH_MODE=test setup
(server/src/scripts/seed-test-data.ts).
AI features (briefings, extraction, grooming, dedupe) are optional and need a local Ollama — the app degrades gracefully without it. To enable them, start the Ollama server and pull the default models once:
ollama serve # or launch the Ollama desktop app
ollama pull qwen2.5:0.5b # OLLAMA_FAST — extraction / triage
ollama pull gemma3n:e4b # OLLAMA_QUALITY — briefings / planning
ollama pull nomic-embed-text # OLLAMA_EMBED — dedupe embeddingsAlready have other models? You usually don't need to pull anything. The names above are
last-resort defaults, not requirements: when a tier isn't pinned, the app selects a model from
what ollama list actually reports — smallest for the fast tier (latency dominates triage),
largest for the quality tier, and it excludes models that would fail silently. Set
OLLAMA_FAST / OLLAMA_QUALITY / OLLAMA_EMBED in server/.env only when you want to force a
specific one; a pin is then used verbatim, and an unpulled pin fails with an actionable
ollama pull … error rather than being quietly swapped.
Two selection rules worth knowing:
thinking-capable models are never auto-selected for generation. Underformat:"json"such a model can return an empty response with its reasoning diverted elsewhere, which reads exactly like "the model had nothing to say" — so every AI feature would degrade silently. A pin or a Settings override still forces one if you want it.- Tool-capable models are preferred, because instruction tuning is the best available proxy for "can follow a structured-output prompt". Without that rule a 1B vision model once ended up driving tagging on a real install.
Check health at GET /api/llm/status, or ?fresh=1 to bypass the 30s probe cache (this is what
Settings' Re-check button calls). Two fields matter and they differ: available means
Ollama answered; ready means the models that will actually be used are installed. AI
surfaces gate on ready — a reachable Ollama with nothing pulled is not a working one. Settings →
Connections shows the same three states, names any missing model, and warns when a pick was
auto-selected rather than chosen.
Build the client into server/public/ and serve API + UI from the one Koa process
on 3848 — the same shape as the Docker image:
pnpm build && pnpm --filter server startAll from the repo root:
| Command | Purpose |
|---|---|
pnpm dev |
Server (:3848) + client (:5173) concurrently |
pnpm build |
Build the client into server/public/ |
pnpm start |
Build, then serve API + UI on :3848 |
pnpm test |
Server and client vitest suites, in sequence (pnpm --filter server test && pnpm --filter client test) |
pnpm test:server / pnpm test:client |
Just one side — the client suite has unit + dom (jsdom) projects |
pnpm typecheck |
Server and client tsc --noEmit, in sequence |
pnpm typecheck:server / pnpm typecheck:client |
Just one side |
pnpm seed |
Seed ~100 fixture tasks into the dev DB |
pnpm migrate |
Apply migrations explicitly (they also run on every server boot) |
pnpm db:generate / pnpm db:migrate |
Root-level convenience wrappers around the server's Drizzle schema-generate / migrate scripts |
pnpm snapshot |
Point-in-time DB backup into <DB_DIR>/backups/ (root shortcut for pnpm --filter server snapshot) |
pnpm --filter server restore -- <file> |
Restore a snapshot (server stopped) |
pnpm --filter server db:check-drift |
Diff schema.ts against what the migrations actually produce (exits non-zero on drift) |
pnpm --filter server key:issue |
Mint a standalone API key for AUTH_MODE=standalone (--scopes read|write, --ttl-days N, --owner <uuid>); the raw key is printed once |
pnpm --filter client build:mobile |
Build the client into client/dist for the mobile shells, not server/public |
pnpm lint:canon |
Lint the design canon (ci-lint-canon from @companionintelligence/config): --primary must be the CI canon value |
This table lists the notable endpoints, not every route; the routers are in
server/src/routes/ and are mounted by its index.ts.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/tasks?status=&tag=&q=&priority=&due_before=&owner_id= |
List/filter tasks (with tags) |
| POST | /api/tasks |
Create a task (title required). Also accepts the optional classification_claim that /api/tasks/quick/parse returned, so a changed category pick is recorded as a correction; it is validated, dropped silently if it does not check out, and never stored on the task or returned (if it checks out, its pick and text become one classification_log row) |
| PATCH | /api/tasks/:id |
Update fields; {"completed": true} marks done |
| DELETE | /api/tasks/:id |
Soft delete (status → cancelled) |
| POST | /api/tasks/:id/close |
The close ritual: completes the task if it is still open (an already-completed task is accepted too, for reflecting later). Records score 1–5, or 0 = declined · note · actual minutes; stamps reaction_at server-side for a real rating or a note. 400 on a present-but-invalid score |
| POST | /api/tasks/:id/enrich |
Fill blank description / direction / categories from Companion Memory; always resolves — enriched:false carries the reason |
| GET | /api/tasks/:id/subtasks |
List subtasks |
| POST | /api/tasks/quick/parse | /api/tasks/quick |
Natural-language quick add: /quick/parse previews the parse and writes nothing (deterministic, with an Ollama overlay when it is up; when the model chose a category it also returns an opaque classification_claim, which the wizard echoes on POST /api/tasks); /quick creates the task (no screen in the app calls it today). Both show the model your most common category and who-it's-for corrections, as of the last Monday calibrate_classification run |
| POST · GET | /api/tasks/:id/timer/start | /api/tasks/:id/timer/stop · /api/timer |
The single running timer: start (stops any other first), stop (409 when none is running), and GET /api/timer for the current entry or null |
| POST · GET | /api/tasks/:id/skip | /reschedule | /promote-habit | /promote-objective · /api/tasks/:id/streak |
Per-task actions: skip a recurring instance, bump a missed task out by days, make a task a habit, surface it in the Objectives tree, and read a habit's streak |
| POST | /api/tasks/suggest-tags | /api/tasks/suggest-subtasks | /api/tasks/auto-tag |
Best-effort Ollama helpers: tags for a title, a subtask brainstorm, and auto-tagging for the Tasks view |
| GET/POST/PATCH/DELETE | /api/blocks |
Time blocks for a date range. A create also accepts the optional classification_claim that /api/tasks/quick/parse returned, settled like a task's but logged as a Time Block |
| GET | /api/blocks/export.ics?from=&to= |
Download the range's dated blocks as an iCalendar file (defaults to today) |
| POST | /api/blocks/:id/move-group | /promote | /add-tasks · /api/days/:date/clear |
Shift a Plan and its members by delta_minutes; turn a Concept into a scheduled Plan; attach existing tasks to a Time-Block; clear the day's auto-placed task blocks only (manual, locked and calendar blocks stay) |
| POST/DELETE/GET | /api/blocks/:id/extend | /reschedule | /focus-reminders | /whats-next |
Time-Sense (Focus Ritual): extend a block (two-phase, confirm), re-stage its task, arm / clear its background cues, and the "what's next" line. 404 while time_sense_enabled is off |
| GET/POST/PATCH/DELETE | /api/block-templates · POST /api/block-templates/:id/instantiate | /apply-to-block |
Reusable Time-Block templates (a saved Plan container, optionally with its tasks), and putting one onto a day or an existing block |
| GET/POST | /api/tags |
Tags with usage counts |
| PATCH/DELETE | /api/tags/:id |
Update or delete a tag |
| GET | /api/possible-tasks |
Staged suggestions (audit trail; auto-promoted since Phase AF) |
| POST | /api/possible-tasks/:id/promote | /dismiss |
Accept/reject a proposal |
| GET | /api/summary?date=YYYY-MM-DD |
Day briefing (LLM or deterministic) |
| GET | /api/summary/top-tasks?date=YYYY-MM-DD |
Today's Brief top tasks (excludes habits + routines; 422 on a malformed date) |
| POST | /api/schedule/run |
Run the auto-scheduler for a date |
| GET/POST/PATCH/DELETE | /api/patterns · /api/pattern-types |
Routines CRUD, and the pattern types (Routine / Habit / Ritual + your own) they belong to; also member tasks (/api/patterns/:id/tasks, /fast-tasks), promote-from-* and POST /api/patterns/:id/place-today |
| GET/POST/PATCH/DELETE | /api/flight-plan · /api/flight-plan/:taskId · POST /api/flight-plan/reset |
Flight-plan staging area: add tasks (or a pattern's members), edit or remove one, reset all; the GET also carries can_undo |
| POST | /api/auto/fill |
Tap 1 — propose up to 20 candidates into the flight plan (schedules nothing) |
| POST | /api/auto/run |
Tap 2 — author the day's order once, then place the selected staged tasks |
| POST | /api/auto/priority |
Tap 3 — optional priority top-up into leftover workday capacity |
| POST | /api/auto/undo |
Reverse the last placement run in one click |
| POST | /api/plan/instruct |
Parse a natural-language planner-bar instruction into pinned blocks / per-tag day windows |
| GET | /api/plan/suggestion |
The pending "re-plan with AI?" nudge, if any |
| POST | /api/plan/reauthor | /api/plan/reauthor/dismiss |
Accept (LLM re-authors the day) or dismiss a big-disruption nudge |
| GET | /api/days/:date/conflicts |
Tasks now outside their direction's windows (Phase 7) |
| GET | /api/days/:date/reflow/preview |
Exactly what a confirmed reflow would do — read-only |
| POST | /api/days/:date/reflow/apply |
The confirmation; the only mutating path of the three |
| GET | /api/plan-week/review?anchor= |
Plan my week — deterministic weekly recap (defaults to the week that just ended); LLM-free |
| GET | /api/plan-week/candidates |
Candidate tasks/gaps the assistant's branches can offer |
| POST | /api/plan-week/next-step |
Phrase the next question + its fixed 3 options through the local LLM, with a deterministic fallback |
| POST | /api/plan-week/parse |
Parse a free-text answer within a Plan-my-week session |
| GET/POST/PATCH/DELETE | /api/plan-week/sessions |
Plan-my-week session log CRUD — backs the Settings → Logs tab (read/export/delete your own sessions) |
| POST | /api/plan-week/sessions/consolidate |
Merge/close stale sessions |
| GET · PUT | /api/pattern-blocks · /api/pattern-blocks/:dayType · PUT /api/pattern-blocks/:dirTagId/:dayType |
Direction-scoped scheduling windows per day type; a PUT atomically replaces that direction's ranges |
| GET | /api/pattern-prefs |
Per-pattern preferences (fill, reminders, shape, color) |
| PUT | /api/pattern-prefs/:patternTagId |
Update a single pattern's preferences |
| GET | /api/push/vapid-key |
Web Push public key (empty when VAPID unconfigured) |
| POST/DELETE | /api/push/subscribe |
Register / remove a browser push subscription |
| GET/POST/DELETE | /api/reminders · POST /api/reminders/:id/snooze |
Reminders CRUD + snooze |
| GET | /api/jobs · POST /api/jobs/:id/run |
List / manually run background jobs (admin-only). The Jobs view's Run all button is client-side sugar — it loops this same per-job endpoint over every listed job; no separate batch route |
| POST | /api/jobs/run-now |
FTUE onboarding only — synchronously runs the calling owner's own ingest and awaits the result (unlike /:id/run's fire-and-forget); not admin-gated, unrelated to the Jobs view's per-job trigger above |
| POST | /api/demo/sample-data |
FTUE "Try with sample data" — installs a curated demo task/block set for the signed-in owner. Admin-gated; idempotent (skipped on a second call) |
| GET/PATCH | /api/prefs |
Scheduling preferences (incl. recurrence_horizon_days; a shorten auto-prunes and returns pruned count). Also carries memory_window_days (1–30) and email_lookback_days (1–90); an out-of-range value is rejected with a 422, never clamped, so the Settings field can't show a number the server didn't store |
| POST | /api/planner/clear |
Preview (dry_run) or apply a Clear Date Range — bounded suppresses just that window, open-ended permanently caps the rule; snapshots + audits on apply (Phase P) |
| GET | /api/memory/status |
Companion Memory connection + sync state |
| POST | /api/memory/connect | /api/memory/disconnect |
Connect / disconnect the Companion Memory link |
| GET | /api/memory/ciportal/start | /api/memory/ciportal/callback |
The memory connector's own Companion Portal OAuth loop — distinct from the login callback |
| POST | /api/memory/suggest | /api/memory/sync |
Manually trigger the pull (memory_suggest) or push (push_memory) side on demand |
| GET | /api/llm/status · /api/integrations |
Ollama & integration health. Both read a probe cached for 30s; /api/llm/status?fresh=1 forces a live /api/tags probe (the Settings Re-check path). Gate AI surfaces on ready, not available. |
| GET | /api/auth/config | /api/auth/me |
Which login modes are available; the signed-in owner (or 401) |
| POST | /api/auth/login | /api/auth/logout |
Local username/password login (any AUTH_MODE=sso install — the break-glass operator under Companion Portal login, every account under DISABLE_OIDC=true) and session logout |
| POST | /api/auth/reset-request | /api/auth/reset |
Request / complete a local-login password reset |
| GET | /api/auth/signup/requirements |
Public: { enabled, codeRequired } — whether /signup is on (AUTH_SIGNUP) and whether it needs an invite code |
| POST | /api/auth/signup |
Public, rate-limited: create your own local account ({ name, username, email, password, code? }); 404 when off, 400 for a bad/spent code, 409 when the username is taken; sets the session cookie |
| GET · POST | /api/auth/ciportal/confirm-account |
First-time Companion Portal sign-in: the no-JS "Create a Companion Planning account?" page and its decision (CSRF-bound, 10-minute signed intent cookie) |
| GET · POST · DELETE | /api/accounts/signup-codes · /api/accounts/signup-codes/:id |
Operator-only: list / mint / revoke single-use invite codes for /signup (raw code returned once, hashed at rest, 7-day TTL) |
| GET | /api/auth/ciportal/start | /api/auth/ciportal/callback |
Companion Portal OIDC login (Authorization Code + PKCE); the first sign-in on an instance with no operator becomes the operator |
| GET | /api/oauth/google/start |
Begin the standalone Google OAuth flow (Auth Code + PKCE); returns { url } for the SPA to navigate to. Gated; refused under AUTH_MODE=test. |
| GET | /api/oauth/google/callback |
Public OAuth redirect target; verifies the id_token, exchanges the code, seals tokens into connection_tokens. |
| GET | /api/connections |
List every connection the owner holds, any provider — one ConnectionRecord shape: { provider, account, status, createdAt, expiresAt, lastSyncAt, lastError }, plus mappedEventCount on Google rows only (absent, not 0, elsewhere). status is the mapped connected | expired | inactive; the raw connection_tokens.status never leaves the server, and connected means the stored token actually decrypted. No secrets. |
| DELETE | /api/connections/:provider/:account |
Provider-agnostic disconnect (Google also revokes); 404 connection_not_found when the owner holds none. |
| GET | /api/connection-prefs |
Per-(provider, account) display config — color token + stripe texture the calendar paints that source's mirrored events with |
| PUT | /api/connection-prefs/:provider/:account |
Update a connection's color/texture prefs |
| GET · PUT · DELETE | /api/reflections · /api/reflections/today · /api/reflections/weekly/:pos · /api/reflections/:kind/:pos |
Today goal + weekly goals (Phase N): read both, upsert one, delete one |
| POST | /api/reflections/:kind/:pos/done |
Mark a goal done / undone (today_goal/weekly_goal only — day_note is 422'd here) |
| GET · POST · PUT | /api/reflections/evening · /api/reflections/evening/open |
Time-Sense evening reflection: the live day payload plus the stored narrative (GET), generate-and-save on open (POST; idempotent unless refresh: true), and your edits over it (PUT; an empty body deletes). 404 while time_sense_enabled is off |
| PUT | /api/reflections/note |
Upsert the day's note; empty body deletes (Phase O) |
| GET | /api/reflections/notes?from&to |
Dates in range with a non-empty day note — backs the calendar's note dots (Phase O) |
| GET | /api/stars?from&to |
Five-star day score per date in range; frozen row if present, else computed live (Phase O) |
| GET/POST/PATCH | /api/objectives |
Objectives CRUD (Phase N). ?archived=1 lists the archive (flat) |
| DELETE | /api/objectives/:id |
Archives — a soft delete, not a removal. Cascades over the live subtree; restorable indefinitely |
| POST | /api/objectives/:id/restore |
Un-archive (root-only — a cascaded member restores from its root) |
| POST/DELETE | /api/objectives/:id/purge |
Request / cancel permanent deletion. 48h window, reaped hourly by purge_objectives |
| POST/DELETE · POST | /api/objectives/:id/tasks · /api/objectives/:id/tasks/:taskId · /api/objectives/:id/recompute |
Link a task to a leaf objective (task_id) / unlink it; refresh the cached roll-up on demand (the nightly job does it for all) |
| GET/POST | /api/priorities |
Life-priorities self-audit — backs /priorities + /balance |
| GET | /api/priorities/history |
Per-dimension balance_week_log history — newest-first, capped to the last 12 weeks per category: how many weeks the target was met vs. counted, and a met flag per week. target_hours/scheduled_minutes are frozen at snapshot time, so past rows never redraw when today's target changes. Pure read; ungated |
| POST | /api/review/sweep |
Manually run the same missed-task review + backoff the review_day job runs nightly — backs the top-bar sweep button |
| GET | /api/review/week?anchor=YYYY-MM-DD |
Weekly review stats for the ISO week containing anchor (today's week when omitted) — scheduled/focus minutes, capacity, completion %, calibration (Phase N) |
| GET | /api/calibration |
Personal estimate bias stats (bias ratio, quartiles, sample count) (Phase N) |
| GET | /api/calibration/scopes |
Time-Sense: all scoped calibration rows plus current unpinned tag-combo suggestions. 404 while time_sense_enabled is off |
| GET | /api/calibration/suggest/:taskId |
Time-Sense: a suggest-only estimate nudge for one task, or {suggestion: null}. 404 while the flag is off |
| POST | /api/calibration/pins |
Time-Sense: confirm a tag-combo suggestion ({tag_ids: string[]}, 2+); recomputes immediately |
| DELETE | /api/calibration/pins/:scopeRef |
Time-Sense: unpin a combo — its calibration row is dropped immediately, not at the next nightly run |
| GET | /api/metrics/tasks?days= · /api/metrics/calibration?scope_kind=&scope_ref=&limit= |
Balance → Metrics: measured counts, timed minutes, mood mix, and estimate bias per direction / category over a window (days clamped to 1–365, default 30; the UI offers 7 / 30 / 90); and the dated estimate-calibration readings, oldest first (limit default 90, max 365) |
| GET | /api/triality/next |
The three candidate next actions (Habitual · Contextual · Novel) with every component score, the situation vector actually used, and a labeled "why now" line. Too little history is a typed refusal, not an error. 404 while triality_enabled is off — a disabled feature's surface looks absent, not broken |
| POST | /api/triality/feedback |
{log_id, outcome} — stamp opened / staged / dismissed on a shown suggestion, at most once (this is the learning evidence). 404 while the flag is off |
| DELETE | /api/triality/weights |
Reset learning — drops this owner's learned slot weights, so scoring returns to the shipped defaults. The outcome log is untouched, and the next weekly pass re-derives weights from it: this is a recompute, not a permanent opt-out. 404 while the flag is off |
| GET | /api/metrics/triality?days= |
Balance → Metrics: acceptance per slot (a suggestion undecided for less than 24h is pending and excluded from the denominator; once it is 24h old and still untouched it counts as ignored — a decision, and a weak negative), the Novel uptake trend against the prior window, how under-served categories responded, and the learned weights with their sample counts (null until learning has run). 404 while the flag is off |
| GET | /api/classification/accuracy?limit= |
The classify/parse LLM's held-out accuracy trend, newest run first, per field (category/motivation). accuracy: null on a run that measured nothing — never a 0. A field with fewer than 2 corrections in 90 days writes no row at all (not a null row), and limit counts rows across both fields, so the default of 12 is about six weeks. The response also carries evidence: the corrections the weekly pass can learn from, per field and per entity_kind (task or time_block), counted over the same 90 days from when each guess was made. No feature flag. Settings → Learning is the client |
| DELETE | /api/classification/exemplars |
Reset classification learning — drops this owner's learned few-shot index; the correction log survives and the next weekly calibrate_classification pass re-earns it. Settings → Learning has the button |
| GET · DELETE | /api/placement/survival?limit= · /api/placement/preferences |
Placement learning: the weekly trend of whether auto-placed blocks survive the day untouched, newest run first (survival_rate: null = nothing was decided that period; limit defaults to 12, max 100); and reset this owner's learned hour bands (the placement log survives). No feature flag. Settings → Learning is the client |
| GET | /api/plan-loop/trend?limit= |
The §A0 authoring loop's measured trend per stage — pass rate, first-try pass rate, escapes and average iterations — newest run first. limit defaults to 26, clamped to 1–365. A week with no runs has no row, so a chart draws a gap; the rate columns are real numbers on any row that exists. limit counts rows across stages. No feature flag, and no reset endpoint: nothing in this wave influences the loop's behavior. Settings → Learning is the client |
| GET | /api/dismissal-learning/trend?limit= |
The possible_dismissal_rate noise-rate trend, oldest → newest, defaulting to 90 rows — answers "is the review queue actually getting quieter?" rate: null on a period with decided = 0 — never a 0. Settings → Learning is the client |
| DELETE | /api/dismissal-learning/exemplars |
Reset dismissal learning — drops this owner's learned exemplar rollup only ({ok: true, cleared: <n>}); possible_outcome_log survives and the next weekly calibrate_dismissals pass re-earns it from that evidence. Settings → Learning has the button |
| POST | /api/sync |
Apply offline op-log entries from a device (Phase L) |
| GET | /api/export |
Download all owner data as a JSON file (import-compatible) |
| POST | /api/import |
Restore owner data from an export JSON body |
| GET/POST/PATCH/DELETE | /api/accounts · POST /api/accounts/:id/invite |
Multi-tenant account management — operator-only (Phase AG); invite creates a password-less owner + reset-token link |
| PATCH | /api/owner/profile · /api/owner/backup-email |
Edit the signed-in owner's display name / backup email (Phase ship-readiness) |
| GET/POST/PATCH/DELETE | /api/people · /api/people/:id · POST /api/people/:id/merge |
The person directory (see docs/people-on-work.md): CRUD, and fold a duplicate into a keeper (merge_id). Owner-scoped; a cross-tenant id reads as 404 |
| GET · POST | /api/people/accounts | /api/people/accounts/:id |
The roster of this instance's accounts for picking (no contact details or privileges), and find-or-create the directory row for the account you picked |
| GET/PUT | /api/google-config |
Read/save the in-app Google OAuth client (admin; flips connector to live at runtime, mig 0124) |
| GET/POST/PATCH/DELETE | /api/day-types · /resolve/:date · /week-map · /assignments/:date |
Day-type CRUD + per-date resolution |
| GET/PUT | /api/category-prefs |
Base category tag prefs — the six Balance dimensions (health/career/relationships/growth/leisure/finance, mig 0134) |
| GET | /api/health |
Liveness probe ({"ok":true,"release":string|null}) — release backs the client's update-available banner |
| GET | /api/config/telemetry |
Whether crash reporting is active; the browser bundle checks this before initialising Sentry |
| GET | /api/events |
SSE stream (task / block / job / possible / reminder events) |
| GET | /api/sync/changes?since= |
The same sync_events as the SSE stream, polled, for a client that cannot hold an EventSource (the iOS and Android app). No since → {events: [], cursor, resync: false}, the owner's latest cursor; since=N → {events: [{seq, event, data}], cursor, resync: false}, the events after N (at most 500); a longer gap, or a cursor ahead of everything this owner has (the database was restored or recreated) → {events: [], cursor, resync: true}, meaning refetch everything and carry on from cursor. since must be a non-negative integer (400 otherwise). Needs planning:read; never cached |
| POST | /api/graphql |
GraphQL endpoint (Apollo, alongside REST; introspection on); gated by the same requireAuth as REST (session cookie in sso mode, x-api-key in standalone) (Phase W, done). The self-hosted GraphiQL IDE is GET /api/graphql/sandbox (see GRAPHQL_SANDBOX) |
pnpm test # server suite, then client suite (Ollama-dependent tests degrade/skip)
pnpm test:server # server vitest only (test/**/*.test.ts, files run sequentially against one throwaway SQLite file)
pnpm --filter client test # client vitest — two projects: `unit` (pure logic, node) +
# `dom` (SolidJS component rendering, jsdom + vite-plugin-solid)
cd tests/container && npm i && npx playwright install chromium && APP_URL=http://localhost:3848 npx playwright test # container smoke against a running instance
tests/container/run-local.sh # the same container smoke gate (standalone, password login, Companion Portal login) from this checkout, no Docker; needs `pnpm install` and `pnpm --filter client build` firstflowchart LR
Capture[Capture task or objective] --> Schedule[Schedule against calendar and constraints]
Schedule --> Today[Review in Today view]
Today --> Update[Update status or details]
Update --> Complete[Complete, defer, or reschedule]
The detailed architecture, domain model, and scheduling sections above explain the underlying data and two-clock behavior. Use this lifecycle as the user-facing entry point when deciding where a task belongs; the Today, Calendar, Tasks, and Objectives sections describe the corresponding views.
Green CI is required before merging into main — the Typecheck + Unit +
Integration and Build, smoke-test, publish checks must pass on a PR's latest
commit (including any bot autofix/suggestion commits, which need the checks run again). Both
workflows are currently workflow_dispatch-only (#618), so nothing runs on a PR by itself:
run ci.yml by hand (gh workflow run ci.yml --ref <branch>), or run the same gate locally —
pnpm typecheck && pnpm build && pnpm test, then tests/container/run-local.sh for the
container smoke. Dispatching docker-publish.yml also publishes the image, so it is not a
dry run. See CONTRIBUTING.md for the full merge policy.
Free for noncommercial use under the PolyForm Noncommercial License 1.0.0. See LICENSE and the License FAQ. Copyright © 2019-2026 LifeScope INC, DBA Companion Intelligence.
Required Notice: Copyright LifeScope INC, DBA Companion Intelligence (https://ci.computer)
Liam Broza (@mrhegemon)















