Skip to content

Status Projection

Agents start sessions, resume, compact and spawn workers without a trustworthy picture of the workspace. The status projection derives typed facts from the workspace’s own lifecycle records, keeps each fact’s source, revision, observation time and freshness, and renders a small deterministic block that fits in agent context.

FactDerived from
Work unit headsthe lifecycle events ledger, falling back to the declared status
Priority and titlesthe declared priority (P0 to P3) and title of each work unit
Claim agethe time since a claim’s last lifecycle event in the ledger
Status countsthe same derivation, over every work unit
Blockers and blocked_bythe existing dependency readiness evaluation
Holdsblock events recorded in lifecycle state
Latest releasethe highest stable release tag the workspace holds locally
Receiptsthe done stamps the workspace holds locally

A missing record is unknown, never false, removed or complete. Each fact carries a source id, an observation time and a quality marker (d derived, a attested, r reported) and a state: ok, STALE, UNKNOWN, CONFLICT or UNAVAIL.

Observes the sources and publishes a new generation when any of them moved. Each generation is immutable; a current pointer is switched to it only after it is complete, so a crash leaves the previous generation current.

pnpm status:refresh            # publish a generation if a source moved
pnpm status:refresh --force    # publish even if nothing moved (also lifts a source revision rejection)
pnpm status:refresh --json     # print the outcome as JSON

The outcome is published, unchanged, or lost (another writer published first). All three exit 0.

The lifecycle ledger is the authority, so the projection reports it as it is. If its event count goes down (a compaction, a branch switch to a shorter ledger), the heads shown are the true heads and the ledger line in the source dictionary carries marker=source_rewound for that observation; nothing is hidden and nothing needs --force.

Renders the current generation. It never advances an observation time: reading, rendering and copying cannot make a fact fresher, only a successful observation can. Staleness is computed against the current clock when you render.

pnpm status:context                                  # plain text
pnpm status:context --event SessionStart             # one JSON hook envelope
pnpm status:context --event UserPromptSubmit --session <id>
pnpm status:context --cache-only                     # render the cache exactly as it is
pnpm status:context --full --json                    # one status-projection/1 document (machine-readable facts)
pnpm status:context --json                           # summary: generation, coverage and size
OptionMeaning
--event <event>SessionStart, SubagentStart or UserPromptSubmit: print the hook envelope instead of plain text
--session <id>Session id for UserPromptSubmit de-duplication; read from the hook JSON on stdin when omitted
--cache-onlyDo not compare source watermarks or refresh; render what is cached
--fullMachine-readable facts mode: print one lumenflow.status-projection/1 JSON document
--jsonSummary mode: print a JSON summary instead of the text (with --full, the document is printed)

Which mode prints what: no flag prints plain text; --event prints the hook envelope; --json prints the summary (kind, generation_id, reason, refreshed, chars, tokens, coverage); --full (with or without --json) prints the projection document. status:refresh --json prints a refresh outcome summary, not a projection.

On every call the command compares cheap source watermarks. If a source moved, a fact has expired, or a source that failed is due for its retry (every five minutes), it refreshes in a separate worker process and waits for it at most eight seconds in total (the call’s whole wall-clock ceiling after the CLI has started, shared by every lock and worker wait), so a change that nobody announced still reaches the next context and a slow workspace can never hold a session start longer than that. Facts whose inputs changed while they were being collected are published as mixed (shown MIXED, never current) and reconciled by the next call. A worker still running at the deadline is left to finish and publishes for the next call. A missing or corrupt cache prints status unavailable: <reason> (<code>) and exits 0: it never fails a session start. The code is one of no_generation, cache_corrupt, cache_untrusted, refresh_timeout, refresh_failed, refresh_unavailable or status_failed; error text, paths and source text never appear in it.

status:context --full --json prints exactly one lumenflow.status-projection/1 document on stdout. It is the stored generation plus two render-time fields: evaluated_at and, on every fact, effective_state, computed against the clock when the command runs (state stays the state recorded when the generation was built). The document is validated against the published schema before it is printed. Every fact carries its source (source_id, source_kind, source_ref), source_revision, observed_at, expiry, quality and state; each source carries its revision, completeness, error code, observed_order and marker, so mixed, source_rewound and observation_failed survive intact.

When no generation is available the command prints nothing on stdout, a reason code on stderr, and exits 1, so a consumer never has to tell prose from a document.

import { execFileSync } from 'node:child_process';
import {
  parseStatusProjection,
  verifyStatusProjectionHash,
} from '@hellmai/lumenflow-control-plane-sdk/status-projection';

const output = execFileSync('pnpm', ['status:context', '--full', '--json'], { encoding: 'utf8' });
const received = JSON.parse(output);
const projection = parseStatusProjection(received); // throws if it is oversized or not a valid document
verifyStatusProjectionHash(received); // true: verified over the received content, unknown fields included
const stale = projection.facts.filter((fact) => fact.effective_state !== 'current');

The documents are published by @hellmai/lumenflow-control-plane-sdk under the ./status-projection subpath: zod schemas (StatusFactSchema, StatusProjectionSchema), TypeScript types, the fixed vocabularies (fact state including mixed, quality, source completeness including mixed, error codes, markers), parseStatusFact, parseStatusProjection, their safeParse forms, safeReadStatusProjection, verifyStatusProjectionHash and evaluateFactFreshness. The software delivery pack imports them from there; there is one definition. JSON Schema for both documents is generated from the zod source into the ./schema-bundle families status-fact and status-projection, with the stable identifiers https://schemas.lumenflow.dev/status-projection/status-fact.v1.schema.json and https://schemas.lumenflow.dev/status-projection/status-projection.v1.schema.json. The label rule (inert text) cannot be expressed in JSON Schema, so use the parse helpers or enforce it yourself.

Versioning: an additive optional field keeps /1. Any removal, rename, type change or vocabulary narrowing needs /2, published alongside /1. Frozen fixtures and a frozen field description pin the /1 fields, nested fields and vocabularies.

Compatibility direction: two-way within /1.

Reader rule (must-ignore). A reader of /1 accepts any later /1 document, so reader and producer versions may differ in either direction. The parse helpers (1) refuse oversized or over-deep raw input before any hashing (depth 12, 64 keys per object, 20 000 array items, 4 096 characters per string, 1 500 000 values, 32 MiB of text; the MAX_INPUT_* constants), (2) verify generation_hash over the document as received, unknown fields included (verifyStatusProjectionHash(raw), or safeReadStatusProjection(raw), which returns hashVerified), and (3) return a value built from the unknown-key-stripping view of the same schema, so an unknown field never reaches the parsed value. Tampering with an unknown field fails the hash. Verify the raw document, not the parsed one: the parsed value no longer holds the unknown fields. An unknown member of a fixed vocabulary is handled per field: status, state and effective_state become unknown; a source error becomes observation_failed; an unknown priority or cause is read as absent; fact_key, entity_type, source_kind, quality, completeness, marker, expiry.policy and the value.kind discriminator have no safe fallback and the document is refused. A missing required field or a retyped field is always refused. The input is copied once into plain JSON before it is bounded, hashed and parsed, so a getter, accessor or Proxy cannot show the hash check one thing and the parser another: those are refused, and a property set to undefined counts as absent, as in JSON. parse* throws StatusInputBoundsError (not ZodError) for refused input and ZodError for an invalid document; the safeParse* forms and safeReadStatusProjection never throw and return a failure. Every new /1 field is inside the generation hash. hashVerified is integrity, not freshness: evaluated_at and effective_state are outside the hash, so a forged effective_state: 'current' still verifies. A consumer recomputes freshness with evaluateFactFreshness. The local cache read inside the pack stays strict: a cache written by a newer producer is treated as corrupt and rebuilt (or reported cache_corrupt under --no-refresh), and the --full emit gate validates with the strict schema, so an emitter bug fails as status_failed instead of shipping a remapped document.

Producer rule (strict). The exported *Schema values stay strictObject: only allowlisted fields enter a generation and an out-of-vocabulary value is refused before it is written. Both views are derived from one definition. The generated JSON Schema describes this strict producer shape (additionalProperties: false); a consumer that validates with it directly must drop that keyword to follow the reader rule, or use the parse helpers.

With --event, the command prints exactly one JSON object, which is the shape agent clients accept for the SessionStart, SubagentStart and UserPromptSubmit hook events:

{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "LUMENFLOW STATUS gen=g00000012-..."
  }
}

For SubagentStart it always prints the current view and never touches the parent session’s record. For UserPromptSubmit it prints only when the generation or the freshness boundary changed since the last emission for that session; otherwise it prints nothing. Per-session bookkeeping is concurrency safe and keeps a hash of the session id, never the id itself.

The compact view is ordered so that the first lines are the ones to act on.

  • READY is startable work. A ready work unit appears under READY only when every predecessor it lists in blocked_by is done. One that waits on an unfinished predecessor appears under WAITING ON PREDECESSORS (or under ATTENTION when a predecessor is not on record or the dependencies form a cycle), never under both. The coverage line states both numbers, for example ready startable=58 waiting=12.
  • READY is ordered by priority. P0, P1, P2, P3, then work with no declared priority; within a priority, the most recent lifecycle event first; then the work unit id, so the order is the same every time. Each line shows the work unit id, its priority and a bounded title, for example ID P1 "Fix the thing". The title is sanitised and limited to 72 characters, and the priority is never part of the title.
  • IN PROGRESS shows claim age. Each claim shows idle= with the time since its last lifecycle event in the ledger (a claim, checkpoint, block or any other event), not the time you render it. A claim idle for at least the threshold also shows stale-claim. The default threshold is 3 days: live work records an event within a working day, and three days spans a weekend. The marker is a label only. A claim stays listed for as long as the ledger says it is in progress; it is never hidden, expired or treated as complete because of its age.
  • The budget favours priority. Every section is ordered by priority, and a tight budget omits from the end, so a P0 or P1 item is never left out while a lower-priority item in the same section is shown. The OVERFLOW: line states how many items were omitted per priority, for example by priority P2=4 P3=11.
  • RELEASE. With no release tag, the line says no release tag is on record, that it covers local stable version tags only, and that deploy and receipt facts come from attested sources in phase 2. A workspace that deploys by commit rather than by tag therefore sees an explanation, not just “none”.

Set the threshold in workspace.yaml:

software_delivery:
  status_projection:
    stale_claim_after_days: 3 # default; fractions of a day are allowed, maximum 365

The compact view is aimed at 1000 to 1800 measured tokens and never exceeds 7000 characters, well under the limits agent clients place on injected context. Blocking and not-current items are listed first. If something does not fit, an OVERFLOW: line says how many items were left out and how many of those were blocking or not current, and names status:context --full, which prints every fact. A coverage line states how many sources are complete and how many facts are shown.

LumenFlow does not write or install hooks, and it never edits a tool-owned file. The command only prints. You own the wrapper and the client configuration that calls it.

A project-owned wrapper (POSIX shell). Pass the hook event as the first argument; the hook’s JSON input on stdin is inherited, so the session id reaches the command:

#!/bin/sh
# .hooks/status-context.sh -- owned by the project, not by LumenFlow
exec pnpm exec status-context --event "${1:-SessionStart}"

Register that wrapper for the SessionStart, SubagentStart and UserPromptSubmit events in your agent client’s own hook configuration, with a timeout comfortably above the refresh budget. The same wrapper works for each event because the envelope is the same. On Windows, point the hook at a .cmd or PowerShell wrapper that runs pnpm exec status-context --event <Event>.

Under the workspace’s configured LumenFlow state directory, in an untracked status-projection/ folder with the current pointer, the immutable generations/, and a sessions/ folder. The newest four generations are kept.

The projection treats lifecycle prose as data: only allowlisted fields are stored, free-text labels are bounded and neutralised (control characters removed, angle brackets replaced, credential shapes redacted), and every read re-validates the cache and shows status unavailable rather than trusting a damaged file. A process running as the same user can still replace the cache or the records it reads; the projection reports what the workspace records say, it does not authenticate them.

This is phase 1: local only. Cross-workspace aggregation, deploy and runtime receipts, and remote delivery are phase 2, which is Cloud aggregation.