Vigil
Vigil — the always-on monitoring layer
Vigil — the always-on monitoring layer
Every subsystem writes what happened to one log. A reader checks that log every five minutes, folds it with the principal’s current state, and decides whether to say something. The name is the point: a vigil is watching that does not sleep.
Vigil answers the question “what is going on right now” from one place. Before it, each part of LifeOS watched its own corner and told the principal in its own way, or not at all. Criteria and the current build state live in the ISA: LIFEOS/VIGIL/ISA.md (maintainer-side; the ISA does not ship). This doc explains the shape.
Release note: this document is the concept — the design every LifeOS install builds its own monitoring layer against. The engine (
LIFEOS/VIGIL/, CLIVigil.ts, andskills/Vigil/with itsSetup.md) ships in the public release payload; the maintainer’s tests, ISA and Worker build state do not. Per-instance monitoring state, policy and personal adapters live in the user tree (LIFEOS/USER/VIGIL/).
In the quality loop (concept: ../LifeOs/HillClimbing.md):
- Owns: the single event log and the decision about whether to interrupt the principal.
- Never does: judges an output, or runs a check. It carries what the loop’s gates and reconciler observed.
- Reads: events from emitters, including the quality loop’s: a gate that errored, an output that escaped every gate, a send blocked in a headless run, and an override with no matching release.
- Writes: routed notifications (text, digest, dashboard), each logged back as an event.
In the life climb (concept: ../LifeOs/HillClimbing.md § 13): Vigil is the nervous system for the principal’s ideal state. Workflows do the work and the measuring; Vigil hears their results and speaks when a target changes state: off pace, a pursuit that stopped running, or a new target with nothing pursuing it.
The place in the stack
| Layer | System | Job |
|---|---|---|
| Record | STATVS | keep everything that happened, once; Vigil moves onto reading it (../Statvs/StatvsSystem.md) |
| Senses | Conduit, Feed, the reader, health sync | notice the world and the principal |
| Capture | Synapse | route things the principal deliberately captured |
| Watch | Vigil | log things that happened, and decide whether to interrupt |
| Answer | Socrates | answer standing questions on a schedule |
| Remember | Cortex | keep what is worth keeping |
Vigil is not Synapse. Synapse takes inputs the principal chose (a link, a spoken idea) and routes them to a home. Vigil takes events the system observed (a job died, a customer complained, a vulnerability landed) and routes them to the principal, or to silence. Socrates is an emitter into Vigil, not a competitor: when a standing question flips to fail, that flip is an event.
The loop
emitters ──emit()──▶ local outbox ──forward()──▶ Worker + D1 (the store)
│ │
└──────── tick (5 min) ◀───────┘
│
route by severity floor, ceiling, trusted sources
│
┌───────────────────────┼─────────────────────────┐
text (Relay) digest (medium) dashboard only
│
every decision logged back as an event
- Emitters post one event in one schema (
LIFEOS/VIGIL/Schema.ts): source, time, kind, severity, title, summary, a pointer into the emitter’s own store, a privacy class, and a dedup key. Emitters are adapters over ledgers that already exist, one per subsystem. The engine’s own adapters read LifeOS’s own ledgers: standing questions, vulnerabilities, background services, complaints, TELOS and the interview cadence, scheduled jobs, contact sync, the work board, running work (process explosions and stray processes, from SessionTree’s verdicts), overage spend, Assay’s judgment layer, Workflows runs, Pulse’s cron jobs, Cloudflare Workers (which names the deploy a failure followed, from the record’s deploy stream), Atlas’s estate notices (theestateadapter, read from the record’s observe stream), and Vigil’s own readers, which report an adapter that has failed every read for 30 minutes. Relay and deploys have no Vigil adapter now: each writes its own rows to STATVS. Readers of an install’s personal sources (app health, backups, health, mood, money, chat, news, Synapse) are personal adapters, described below. The registry isLIFEOS/VIGIL/adapters/index.ts. A few subsystems callemit()directly instead of keeping a ledger for an adapter: Location (sourcelocation) reports the phone going stale, coming back, its Worker failing, and its read bearer being refused (Location/LocationSystem.md). - Where adapters live. The engine’s own adapters, the ones that read LifeOS’s own ledgers, sit in
LIFEOS/VIGIL/adapters/. Adapters that read one install’s personal sources (its money, its chat workspaces, its wearable, its own products) sit inLIFEOS/USER/STATVS/adapters/or, until an install moves them there,LIFEOS/USER/VIGIL/adapters/. Both are private by construction, so a personal adapter never ships and needs no release line to keep it back. The registry discovers the engine folder and the first of those two that holds an adapter; an absent USER folder is simply no personal adapters, and a name in both the engine and the personal folder stops discovery with an error rather than picking one. The same adapters also run under STATVS’s collection runner (Statvs.ts collect), which shares their cursor state and the one set of late reads with the tick. Two header markers adjust discovery:@vigil-adapter retiredkeeps a file out of the registry, and@vigil-adapter part-of <name>reads a companion file under another adapter’s name. - Writing a personal adapter. Put
<name>.tsin the personal adapter folder above; the file name is the event source. It exportsread(kit), and the engine passes the kit (LIFEOS/VIGIL/adapters/kit.ts):event,withCursor,jsonFile,need,clip,clock,loadPolicy,instanceSetting, the state and credential helpers,invariant, andinference. Through the kit,inferencealways runs on the Claude subscription with no vendor fallback, andloadPolicyandinstanceSettingread only the collection keys. The adapter takes those from the kit and never imports engine code by a relative path, becauseLIFEOS/USERis a symlink and a relative import resolves from its real folder outside the engine.import typelines are fine. The convention is abind(kit)export thatreadcalls first, so tests can bind the kit and calldifforlivedirectly. A personal adapter that needs another engine helper adds it to the kit. - A blind adapter is loud. An adapter whose source is missing, or whose records lack the fields it reads, records a read error that
/vigilshows in red. An adapter that reads cleanly and saw nothing change shows as watching. An adapter whose source this install never had, with none of its sources present, is recorded as not installed: its tick ping is marked, it never counts as a success, and no blind event is raised. One that read successfully before, or found another of its own sources, and then lost a source is installed and broken, and stays loud. Health, mood, and money events carry bands, directions, and day-counts, never a measured value or an amount; customer events carry ids and counts, never a name. - The outbox is written first, with no network call, so a Vigil outage never breaks an emitter.
emit()is now a shim over STATVSwriteEvent: it validates the event, appends it to the outbox and, while the install’s dual-write switch is on (LIFEOS/USER/STATVS/cutover.json), records it in STATVS. Vigil’s own decision rows go to the outbox only. The shim is deleted when the move to STATVS finishes. The forwarder ships unsent lines in batches and marks them sent only on success. - The store is a Cloudflare Worker over D1 with a bearer per emitter; each bearer carries a privacy ceiling, and the emitter name is recorded beside the event’s own source, never in place of it. The Worker runs the same Restricted Data refusal as the emitter library, so free-text titles and summaries cannot smuggle values in from a compromised emitter. The D1 database id is install data, not code: a deploy reads it from
LIFEOS/USER/VIGIL/worker.jsonand stops, naming the file, when it is missing or does not hold a database id. The store keeps every row: its daily job only re-counts the tally and deletes nothing, because the record is permanent. - The tick is a headless job that runs every five minutes: the
vigil-tickworkflow on an install that has it, otherwise the launchd agentcom.lifeos.vigil.InstallVigil.tsrefuses to add the agent beside the workflow, because the tick has no lock and two clocks would route and text twice. It runs the adapters, forwards, reads new events since its cursor, routes them, and advances the cursor only after routing. The first tick against a fresh cursor writes a baseline and routes nothing. - Routing starts deterministic: a severity floor from
LIFEOS/USER/VIGIL/policy.json, a rolling 24-hour text ceiling, per-source severity ceilings, hourly caps and optional daily text caps (source_text_cap_per_24h, critical events exempt), a list of sources allowed to text at all, a 72-hour hold on a story already texted, quiet hours (a text is held unless the event is critical), and a daily cap for the watch sources. An event flagged for the work repo at or aboveissue_min_severityis also filed there as an issue, at mostissue_ceiling_per_24ha day. On top of the floor sits an optional editor (Editor.ts,editor_enabledin the policy): one model call per tick reads the events the floor did not text and may promote at most one to a text with a one-line reason, or move a worthwhile one to the digest. It can promote, never demote, and every verdict is logged as an event. A scheduled job’s own text passes through the same decider (decider_mode: shadow records what Vigil would have done, enforce makes Vigil send or hold the job’s draft), under one daily text budget across every origin. - Every notification is an event. Sent, held, and failed decisions go back into the log with the freshness of the state inputs the reader had. The log counts its own interruptions, and a dead reader is visible as a missing heartbeat.
- Reading the record. The tick is moving from its own outbox to STATVS as its one source of new state.
Reader.tsreads rows from theeventandcloudstreams, only in the classes the policy’sread_classesnames and only rows that carry a severity. It never reads Vigil’s ownvigilandvigil-logstreams, so Vigil never answers its own decisions, and it commits a row’s position only after the answer is written. Every row ends in one of six outcomes (Outcome.ts): let be, Pulse, research, prepare, background, or tell now. The severity floor produces let be, Pulse and tell now; a medium event routed to the digest is a Pulse row marked for the morning brief, andlet_beis a policy route value for a severity that is recorded and shown nowhere. Each answer is onedecisionrow in streamvigil(Decision.ts) that names the row it answers and copies none of its words. At this stage the live tick still routes from its own outbox. While dual-write is on it also writes a tick row to the log and runs a best-effort shadow pass that decides the record’s new rows and compares the result with what the tick did, writing no decision rows. - The advocate. Beside the floor,
Advocate.tsreads the principal’s TELOS goals and the latest current-to-ideal gap, and asks Assay’s judgment layer (callervigil-advocate) whether a row deserves research, preparation or a background run, and which goal or gap it would serve. It runs in shadow: the verdict is recorded beside the floor’s outcome, and it acts only after Assay shows agreement and the principal promotes both the caller and the policy’sadvocate_mode. When it acts,Handoff.tsemitsvigil.<kind>to Workflows once per answered row, and refuses the hand-off unless every subscribed workflow is local, read-only, free of outbound, money and deploy effects, on trusted-vendor lanes, and never holds a local-read tool and an outbound tool together. Results come back asadvocate-*events, capped at medium and never text-trusted; a change an advocate run proposes becomes a recommendation for the principal, so no advocate run changes a system, spends money or contacts anyone on its own. A weekly report turns used, rejected and ignored outcomes into Assay labels. - The weekly goal pace text. The
vigil-pace-checkworkflow runs on Sunday.PaceCheck.tsreads the daily ideal-state reconcile result and the TELOS goals, picks at most one slipping goal or off-track target, and drafts one short text that quotes the goal in TELOS’s words, says why it is slipping from the recorded evidence, and asks one first move as a question the principal answers with “y”. A week where every goal is pursued and no target is off track calls no model and writes no event. The text leaves only as a high-severity STATVS event from sourceideal-state, so Vigil’s own one-a-day cap for that source decides whether it reaches the phone. - World watch. The principal keeps a watchlist of named groups (holdings themes, products and estate, goals, companies and people to watch), each a list of match terms, in
LIFEOS/USER/VIGIL/watchlist.json. Before routing, the tick (WorldWatch.ts) reads it fresh and checks every event from the story feed (surface) and everywatch-*source. A term that appears in the title, summary or labels on word boundaries, ignoring case, tags the event with its group and term and raises it one severity step, never above high. The match is plain code over text already in the event: no model runs per event, and the editor stays at one call per tick. The watchlist holds themes and names only; a term that looks like an amount, a share count or a position size is refused. No file means the feature is off, and a bad file records a failedworld-watchping while events route as they came. Every match is recorded, andbun LIFEOS/VIGIL/Vigil.ts world-watch-report [--days 7] [--json]prints, per group, the matches, bursts, texts and the principal’sgood,noiseandmissedlabels, so a noisy group can be cut. Burst counts stay at zero until the story feed can cluster one story across sources. - Blocker texts. When a session has stopped on a hard limit, the
BlockerTexthook can text the principal through Vigil, but only while the principal is away: the location service puts the principal on another device, or this Mac has had no keyboard or mouse input for 10 minutes. A blocker is Claude Code’s own permission prompt, or a final-answer item marked critical with 🚨. A plain question, an “I’d say yes” confirmation or an idle prompt never counts. It texts at most once per session and blocker, names the project and the blocker in one plain line, and carries no home-directory path or credential. It runs async, never delays a turn, and fails open. Indry-runmode, the default whenLIFEOS/USER/CONFIG/blocker-text.jsonis absent, it only logs an info event and a would-send line; inlivemode it goes out as a decision-class job text that still obeys the daily budget and quiet hours.bun hooks/BlockerText.hook.ts --report [--days N]shows the would-send counts.
Incidents
An incident is one production problem followed from detection to a closed issue. It has one id (INC-NNNN), and the same id sits on a Vigil event, an Errata entry and an issue in the install’s work repo, so each of the three leads to the other two. The record lives in LIFEOS/TOOLS/Incident.ts (not in the public release); its shape is LIFEOS/TOOLS/IncidentTypes.ts (not in the public release).
journey probe / Bunker alert / Workers failure
│ open (one per key; a repeat adds a note)
▼
filed → picked-up → diagnosed → fix-ready → awaiting-approval → deployed → verified → closed
- Detection. The journey probe (
UxProbe.ts, maintainer-side; not in the public release) signs in as an app’s test identities and times the load from request start to the first real content, against a budget per journey (journeys are install data:LIFEOS/USER/CONFIG/ux-journeys.json). A page that renders its content in the browser names the data calls it makes (then); they are timed to the end of their bodies, so the clock stops at real content, never at an empty shell. A public page has nologinand loads anonymously, as a reader meets it. Two misses in a row open an incident atmedium; a miss past the severe budget, or a load that fails outright, opens it athigh.Incident.ts from-vigilturns production-service events already in this log (a Bunker confirmed alert, a failing Worker) into incidents the same way. People report problems too:CommunityReports.ts(maintainer-side; not in the public release) reads the community chat’s watched channels every 10 minutes, and a member’s message that names a configured product (or sits in its channel) and reads as a problem report opens ahighincident keyeddiscord:<service>. Products are install data (LIFEOS/USER/CONFIG/community-reports.json). An Assay judgment question decides once it is enforced; until then a phrase gate decides and the judgment is recorded in shadow. A repeat report notes the open incident instead of opening another, and the full message link stays in a local ledger because a chat link is three long ids the refusal rejects. - Work. The triage worker (
IncidentTriage.ts, maintainer-side; not in the public release) picks up filed incidents, at most two a run, fresh ones before any retry. An incident with no repo is worked against the repo its service names in the journey or product config. A worker diagnoses each in a throwaway clone of the service’s repo and prepares the smallest fix with a test; the patch is kept beside the incident record. It never deploys, pushes or writes the live repo. A ready fix moves toawaiting-approval, which is ahighevent, so the principal is asked for the yes. - Close. The principal (or a session acting on the principal’s yes) deploys and moves the incident to
deployed. The next passing probe run after that time moves it toverifiedandclosed(a community report on a service closes on that service’s passing journey, with the patch’s regression test run by the gated deploy as the proof of the bug itself), which comments the evidence on the issue and closes it. Only the principal can close an incident as no-fix. - Record. Every move is a Vigil event (
source: incident, keyincident:<id>:<state>) and an issue comment. Titles and summaries pass the same Restricted Data refusal as any other event before anything is written.
Three workflows run it: ux-probe and watch-community-reports every 10 minutes, and incident-triage every 30. How app health itself is probed and when it pages: ../Bunker/BunkerSystem.md § What pages.
Surfaces
- CLI:
bun LIFEOS/VIGIL/Vigil.ts emit|forward|tick|status|list|heartbeat|queue|replay|shadow-report|world-watch-report|feedback - Skill:
Skill("Vigil")with Status, Emit, Digest, Install - Pulse:
/vigil(events newest first, source and severity filters, tick health) - Replay:
bun LIFEOS/VIGIL/Vigil.ts replay --days 7 --with <source>is read-only. It replays the last days of real texts with a new source’s worst-case events added, and exits 1 when the source would text more than once a day or hold a text that went out. Run it before a new source may text. - Advocate and goal pace:
bun LIFEOS/VIGIL/Advocate.ts context|report,bun LIFEOS/VIGIL/Handoff.ts check <research|prepare|background>,bun LIFEOS/VIGIL/PaceCheck.ts run [--dry-run] [--json] - Schedule: the
vigil-tickworkflow (LIFEOS/USER/WORKFLOWS/), or on an install without it the launchd agent frombun LIFEOS/VIGIL/InstallVigil.ts, listed byLIFEOS/TOOLS/Services.ts; thevigil-pace-checkworkflow runs weekly on Sunday
What is not built yet
Local news and sensitive OSINT wait on a collector. The call path (the DA phoning the principal) waits on a conversational leg in the outbound-call tool. State fusion, the part where the same event routes differently on a bad week, is not built; the planned vigil-route judgment caller would score each route in shadow first. The advocate is built but runs in shadow, and the tick’s live routing still reads its own outbox, with the reader on the record running only as a shadow comparison. The scheduler consolidation the principal raised alongside Vigil is held in the ISA as fog. All of it is listed, with what resolves each, in the ISA’s ## Not yet specified.
Related
- Concept:
../LifeOs/HillClimbing.md§ 9 (gates are few and only read; escapes show as gaps). - Feeds me:
../Assay/AssaySystem.md(gate errors, outputs that escaped every gate, headless send blocks, an override with no matching release);../Errata/ErrataSystem.md(skills with outputs and no receipts). - I feed: the principal, by text, digest or dashboard.
- ISA:
LIFEOS/VIGIL/ISA.md(maintainer-side; NOT in the public release payload).
