# Phase 4: SSOT & Environment Check

> Sub-file of the session-start skill (#1157 — agentskills.io: SKILL.md core < 500 lines, procedure in `references/`). Extracted VERBATIM from `skills/session-start/SKILL.md`; `SKILL.md` keeps a one-line stub naming this phase and its gate condition.

## Phase 4: SSOT & Environment Check

> **Environment reads show key names, never values** (`.claude/rules/security.md` § SEC-008). Read env files and the process environment only by key name, presence or length, filtered inside the same pipeline before any value is emitted: `sed -n 's/^\([A-Z_][A-Z0-9_]*\)=.*/\1/p' .env.local`, `grep -c '^NAME=' .env.local`, `env | cut -s -d= -f1 | sort`. Never `cat .env*`, never bare `env` / `printenv` / `set` / `export -p` — Claude Code puts live tokens into every tool call's environment.

1. **SSOT freshness**: for each file in `ssot-files` config, check last modified date. Flag if older than `ssot-freshness-days` (default: 5) days.
2. **Quality baseline**: Run Baseline quality checks per the quality-gates skill. Commands are resolved in this order (issue #183):
   a. `.orchestrator/policy/quality-gates.json` — preferred source when present.
   b. Session Config `test-command` / `typecheck-command` / `lint-command` — fallback.
   c. Hardcoded defaults: `npm test`, `npm run typecheck`, `npm run lint`.
   Execute each complete configured shell command and report `fail` if its binary is missing (exit 127; on Windows `cmd.exe` exits 1, also `fail`). Correct the command or explicitly set it to `skip` in Session Config; explicit `skip` remains `skip`. Report baseline failures without blocking the session.
3. **Pencil design status**: if `pencil` is configured, verify the `.pen` file exists at the configured path. Report: "Pencil design configured at [path] — design-code alignment reviews will run after Impl-Core and Impl-Polish waves." If file not found, warn: "Pencil path configured but file not found at [path]."
4. **Plugin freshness**: Determine the session-orchestrator plugin directory (navigate up from this skill's base directory to the plugin root). Run `git -C <plugin-dir> log -1 --format="%ci"` to get the last commit date. If older than `plugin-freshness-days` (default: 30) days, flag a warning in the Session Overview: `"⚠ Session Orchestrator plugin last updated [N] days ago — consider pulling the latest version."` Non-blocking — present in overview, don't halt.

   **The 22-probe banner family runs ONCE, mechanically — never invoke a probe below by hand (#1351).** `hooks/on-session-start.mjs` (the `SO_DISABLE_STARTUP_PROBES` block) imports `runSessionStartProbes()` from `scripts/lib/session-start-probes.mjs` on every SessionStart, and the `PROBES` registry in that module is the single declaration site for each probe's module path, entry function, arguments, precondition and network flag. The paragraphs below are the RENDERING reference — return contract and exact banner wording per probe — not call instructions: a hand-invocation runs the same measurement a second time at full cost (`project-hygiene` alone is a `git status --ignored` plus a batched `du` over every ignored path — seconds, not milliseconds, under load; see its own paragraph below for the measurement and the host conditions it was taken under). The runner returns `bannerLines`; the hook pushes them into the ONE `systemMessage` envelope (HR-106). Read those lines and present them in the Session Overview.

   The bootstrap-lock-freshness probe (`scripts/lib/bootstrap-lock-freshness.mjs`, `checkBootstrapLockFreshness`) runs in the Phase 4 banner family as `id: 'bootstrap-lock-freshness'` in `scripts/lib/session-start-probes.mjs` `PROBES`, preconditioned on `.orchestrator/bootstrap.lock` existing and called with `currentPluginVersion` read from the plugin's `package.json` so version comparison is live. It checks lock age and plugin-version drift. When severity is `warn` or `alert`, render an additional banner alongside the plugin-freshness warning. The remediation is **reason-aware** (`result.details.reason`, #57) — a present-but-stale lock is never told to re-run `--retroactive` (idempotent no-op once `version`/`tier` already parse; see the Retroactive Flow's idempotency guard in `skills/bootstrap/SKILL.md`):
   - **warn, `reason` = `stale-age` or `unparseable-timestamp`** (age 30–89d, or timestamp missing/unparseable but not yet ≥90d): `"⚠ bootstrap.lock: age=<N>d, plugin-version=<lock-ver> (current=<plugin-ver>) — run /bootstrap --refresh-lock to acknowledge and reset the freshness clock."`
   - **warn, `reason` = `version-mismatch-unparseable`** (non-parseable version string): `"⚠ bootstrap.lock: age=<N>d, plugin-version=<lock-ver> (current=<plugin-ver>) — check for a plugin update first (git pull / marketplace update), then /bootstrap --refresh-lock to acknowledge the current version."`
   - **alert, `reason` = `stale-age` or `unparseable-timestamp`** (age ≥90d, or timestamp missing/unparseable): `"⚠ bootstrap.lock: <message> — run /bootstrap --refresh-lock to acknowledge and reset the freshness clock."`
   - **alert, `reason` = `version-mismatch-major`** (major plugin-version mismatch): `"⚠ bootstrap.lock: <message> — check for a plugin update first (git pull / marketplace update), then /bootstrap --refresh-lock to acknowledge the current version."`
   - **alert, `reason` = `missing`** (lock file absent): `"⚠ bootstrap.lock: <message> — re-run /bootstrap --retroactive is strongly recommended."` (`--retroactive` remains correct here — there is no lock to refresh)
   - **info-only version mismatch** (patch or minor version only): `"ℹ bootstrap.lock: plugin-version=<lock-ver> (current=<plugin-ver>) — minor drift only, no action required."`
   - **legacy lock without plugin-version** (soft signal only): `"ℹ bootstrap.lock: lock predates plugin-version field; consider /bootstrap --refresh-lock to stamp a current plugin-version reference."`

   The vault-staleness probe (`scripts/lib/vault-staleness-banner.mjs`, `checkVaultStaleness`) runs in the Phase 4 banner family as `id: 'vault-staleness'` in `scripts/lib/session-start-probes.mjs` `PROBES`. It reads the most recent line of `.orchestrator/metrics/vault-staleness.jsonl` when that file exists (vault-integration enabled). The helper returns THREE shapes, not two: `null` (silent no-op) when the JSONL is absent, malformed, or `stale_count === 0`; a fresh finding when `stale_count > 0` and the record itself is recent; or `{severity: 'warn', kind: 'probe-stale', message, ageDays, timestamp}` when `stale_count > 0` but the record's own `timestamp` is older than `MAX_RECORD_AGE_DAYS` (7 days, #1159) — the probe has stopped running, so its recorded findings are NOT a current finding about the repo. `severity` is `'warn'` in this case too (not a distinct `'info'` value) so a caller reading only `severity` still renders it; `kind` is what a caller uses to tell "probe went stale" apart from "projects are actually stale" (single-vocabulary fix, #1158/#1159 review N3). Render a banner alongside the bootstrap-lock warning for every non-null result:
   - **warn** (`stale_count > 0`, max `delta_hours <= 48`): `"⚠ vault-staleness: <N> projects stale (max delta: <X>h) — last run <timestamp>."`
   - **alert** (`stale_count > 0`, max `delta_hours > 48`): `"⚠ vault-staleness: <N> projects stale (max delta: <X>h) — Clank-Vault-Sync cron likely broken, see agents/vault#70 fix pattern."`
   - **probe-stale** (`kind: 'probe-stale'`): `"⚠ vault-staleness: last probe record is <N> days old (<timestamp>) — the probe has not run since; the recorded <N> stale projects are NOT a current finding."`

   Skip silently on `null`; do not block the session in any case. This passage does not restate a severity-mapping table for the probe-stale shape — the `vault-staleness` entry in the probe registry (`scripts/lib/session-start-probes.mjs`, `PROBES`) is the single declaration site for how a probe's result maps onto the rendered banner severity, and (post-N3) needs no custom mapping for this probe at all.

   The telemetry-flush-health probe (`scripts/lib/telemetry-flush-health-banner.mjs`, `checkTelemetryFlushHealth`) runs in the Phase 4 banner family as `id: 'telemetry-flush-health'` in `scripts/lib/session-start-probes.mjs` `PROBES`. It reads the LAST `orchestrator.telemetry.flush` record from `.orchestrator/metrics/events.jsonl` (tail-read, last 64 KB, scanned backwards so newest wins) and returns TWO shapes: `null` (silent no-op) when the ledger is absent, holds no flush record in that window, or the newest flush record's `reason` does not start with `sandbox:`; otherwise `{severity: 'warn', reason, message}`. Render `result.message` alongside the bootstrap-lock-freshness and vault-staleness banners:
   - **warn** (`reason` starts with `sandbox:`): `"⚠ Telemetry: last flush refused by the sandbox guard (<reason>) — the guard could not complete its checks and failed closed; no ping was sent. See docs/telemetry.md § Sandbox guard."`

   Why the probe exists (#1255): `scripts/lib/telemetry/sync.mjs` fails CLOSED — it returns `{sandbox: true, reason: 'sandbox:probe-failed'}` when its environment probe cannot complete — and stays deliberately SILENT about it, because it runs inside the SessionEnd teardown budget where a banner has no reader. The refusal reached exactly ONE sink (`hooks/on-session-end.mjs` → `emitEvent('orchestrator.telemetry.flush', …)`) and no consumer ever read it back out (W1-D6 census, 2026-09-07), so a repo that refused every flush for weeks was indistinguishable from a healthy one. This probe is the read-back, at the first moment a human is looking. Newest-wins is load-bearing: a later successful flush clears the warning by itself, so the banner reports the CURRENT state of the channel, never its history.

   The CI-status probe (`scripts/lib/ci-status-banner.mjs`, `checkCiStatus`) runs in the Phase 4 banner family as `id: 'ci-status'` in `scripts/lib/session-start-probes.mjs` `PROBES` (`network: true`, awaited by the runner — it is `export async function`). It needs a configured `origin` remote and `glab` (GitLab) or `gh` (GitHub) on PATH; without either it reports the degraded state below, never green.

   The return contract has THREE states, not two (#1031, the same shape as the mirror-issues probe below): `null` means a measured ABSENCE — no usable VCS remote, so there is no CI here; `{status, ok, details, …}` is a real reading (`green` | `red` | `unknown`); and `{severity: 'warn', ok: false, message, degraded}` means the state could NOT be read, where `degraded` is one of `cli-missing | timeout | parse-error | query-failed | git-error`. Render `result.message` verbatim in the degraded case and read it as *"CI state is unknown"* — never as green. Until #1031 all five of those collapsed onto `null`, which in the banner contract reads as all-clear; that collapse is how #1022 (`gh repo view -R` → `unknown shorthand flag`) stayed invisible on every GitHub repo.

   When `result.status === 'red'`, render a banner alongside the bootstrap-lock and vault-staleness warnings:
   - **Red** (`status === 'red'`): `"🚨 CI RED on HEAD (pipeline #<currentPipelineId>) — last green: #<lastGreen.pipelineId> (commit <SHA-7>, <redCount> pipelines ago). Failing job: <failingJobName>"`
   - **Red on GitHub** (`details.cliUsed === 'gh'`, #1727/#856): GitHub has no pipelines, so the line carries no `(pipeline #…)` and `lastGreen` has no `pipelineId`: `"🚨 CI RED on HEAD — last green: commit <SHA-7> (<ageCommits> commit(s) ago). Failing job: <failingJobName>"`. When the history query failed (`details.reason === 'last-green-query-failed'`) the middle part reads `— last green: unknown (history query failed)`; when no green commit lies in the 16-commit window it is absent. The probe reads the red verdict without the history (`checkCiStatus({ deferHistory: true })`, #1728) and fetches the history in its follow-up, inside what is left of `PROBE_BUDGET_MS`: when that runs out (`details.reason === 'last-green-budget-exceeded'`) the red line still renders, with `— last green: unknown (history not read within the probe budget)`. The reading is for the LOCAL HEAD (`git rev-parse HEAD`), never the remote default branch; an unpushed HEAD is `unknown` with reason `head-sha-not-on-remote` (Unknown branch below).
   - **Green with soft failures** (`status === 'green'` AND `result.allowFailureJobs` is present): `"⚠ CI green on HEAD, but <N> allow_failure job(s) FAILED: <names>. A pipeline reports success regardless of these — a job red on every run stays invisible at the pipeline level."` Render this even though the pipeline passed: the whole point is that pipeline status cannot express it.
   - **Degraded** (`result.degraded` present): `"⚠ ci-status: CI status for HEAD could not be determined (<reason>) — state UNKNOWN, not \"green\"."` — the probe builds this message itself; render it verbatim.
   - **Unknown** (`status === 'unknown'` — HEAD carries no pipeline, e.g. unpushed local commits; #1337): NOT silent — `"<mark> ci-status: CI status for HEAD could not be determined (<reason>) — <hint>"`, where `<mark>` is `🚨` when the last PUSHED commit's pipeline verdict is `red` (severity `alert`) and `⚠` otherwise (severity `warn`). Silence here would read as green when the pushed commit may actually be red.
   - **Green** (no `allowFailureJobs`): silent (no banner) — informational only.

   The banner is non-blocking — display in the Session Overview, do not halt the session. If `ci-status-banner.mjs` is absent (pre-#369 plugin install), skip silently.

   The QG-command-drift probe (`scripts/lib/qg-command-drift-banner.mjs`, `checkQgCommandDrift`) runs in the Phase 4 banner family as `id: 'qg-command-drift'` in `scripts/lib/session-start-probes.mjs` `PROBES`. The helper returns `null` (silent no-op) when no drift or when Session Config load fails. When a non-null result is returned, render `result.message` alongside the bootstrap-lock-freshness, vault-staleness, and CI-status banners:
   - **Drift detected** (`{ severity: 'warn', message: ... }`): render `result.message`. The message has the shape `"⚠ Session Config drift (*-command keys): <details>. Verify the overrides are intentional. See .claude/rules/quality-gates-autofix.md § Session Config Command Injection for the RCE-equivalent trust-model."`
   - **No drift**: silent (no banner).

   The banner is non-blocking — display in the Session Overview, do not halt the session. Cross-reference: `.claude/rules/quality-gates-autofix.md` § Session Config Command Injection — the banner exists because `*-command` keys are RCE-equivalent under the VCS trust-anchor model.

   The peer-cards-staleness probe (`scripts/lib/peer-cards/staleness-banner.mjs`, `checkPeerCardsStaleness`) runs in the Phase 4 banner family as `id: 'peer-cards-staleness'` in `scripts/lib/session-start-probes.mjs` `PROBES`. The helper returns `null` (silent no-op) when `.orchestrator/peers/` is absent, neither USER.md nor AGENT.md is present, no card is stale, or the reader fails. When a non-null result is returned (`{ severity: 'warn', message, stale }`), render `result.message` alongside the bootstrap-lock-freshness, vault-staleness, CI-status, and QG-command-drift banners:
   - **Stale (>30d)**: `"⚠ peer-cards: USER.md (Nd), AGENT.md (Nd) stale (>30 days) — consider running /evolve --dialectic to refresh."` (one or both targets, whichever are stale).
   - **Fresh / absent / malformed frontmatter**: silent (no banner).

   Cross-reference: `.claude/rules/owner-persona.md` (host-wide `owner.yaml` operator identity) and `skills/vault-sync/SKILL.md` (`type: peer-card` value in the vault-frontmatter enum). Peer cards complement `owner.yaml` with per-repo behavioural identity for the operator (USER.md) and agent (AGENT.md).

   The loop-readiness probe (`scripts/lib/loop-readiness-banner.mjs`, `checkLoopReadiness`) runs in the Phase 4 banner family as `id: 'loop-readiness'` in `scripts/lib/session-start-probes.mjs` `PROBES` (`env` defaults to `process.env`). The helper combines up to three independent silent-failure detections into a single null-or-warn result — never an array, never multiple banners:
   - **No loop.md anywhere**: neither `.claude/loop.md` (repo) nor `~/.claude/loop.md` (user baseline) exists — bare `/loop` falls back to Anthropic's generic maintenance prompt.
   - **`CLAUDE_CODE_DISABLE_CRON` set** (non-empty value): the cron scheduler backing `/loop` is disabled outright — fires independently of whether a loop.md file exists, so a healthy loop.md does NOT mask this finding.
   - **loop.md > 25,000 bytes**: checked independently for the repo file and the user file — Anthropic silently truncates the loaded body past this size, so an oversized file's tail is never read even though the file "exists".

   The helper returns `null` (silent no-op) only when NONE of the three conditions above are true, or on bad input. When any subset of the three findings applies, a single non-null result is returned (`{ severity: 'warn', message, repoLoopMd, userLoopMd, disableCron?, oversize? }`) whose `message` names every active finding (e.g. "no loop.md" + "DISABLE_CRON set" can co-occur in one combined message) — render `result.message` alongside the other banners. So "**Present (repo or user baseline)**: silent" from the original #633 contract now additionally requires no `CLAUDE_CODE_DISABLE_CRON` and no oversized file — a present-but-disabled-or-truncated loop.md still produces a banner.

   Cross-reference: `.claude/rules/loop-and-monitor.md` (when to use `/loop` vs Monitor vs Routines) and issues #633 (original no-loop.md detection) / #767 (DISABLE_CRON + 25KB truncation detection).

   The instruction-budget probe (`scripts/lib/instruction-budget-guard.mjs`, `checkInstructionBudget`) runs in the Phase 4 banner family as `id: 'instruction-budget'` in `scripts/lib/session-start-probes.mjs` `PROBES`. The helper returns `null` (silent no-op) when the always-on directive count is at or under the configured ceiling, or on any read failure. When a non-null result is returned (`{ severity: 'warn', message }`), render `result.message` alongside the other banners. Non-blocking. Cross-reference: "Instruction Budget Audit" (#687; archived in the private Meta-Vault).

   The maintenance-due probe (`scripts/lib/maintenance-due-banner.mjs`, `checkMaintenanceDue`) runs in the Phase 4 banner family as `id: 'maintenance-due'` in `scripts/lib/session-start-probes.mjs` `PROBES`, called with `{ repoRoot, config }`. This probe REPLACED the former `reconcile-nudge` entry in `scripts/lib/session-start-probes.mjs` (it reuses `computeReconcileNudge` wholesale as one of its seven signals) AND the two retired session-end nudges — Phase 3.6.5 auto-dream and Phase 3.6.7 auto-dialectic — which asked the operator at close for work only a housekeeping session can do. Return contract: `null` (silent no-op) or `{ severity: 'warn', message }`; never throws. `computeMaintenanceDue` exposes the same computation without the banner suppression.

   The seven signals (`MAINTENANCE_TOTAL_SIGNALS = 7`), each side-effect-free:

   | id | due when | source |
   |---|---|---|
   | `evolve` | no `orchestrator.evolve.completed` on record AND ≥ `MAINTENANCE_MIN_LEARNINGS` (20) active learnings | `events.jsonl` + `computeReconcileNudge` |
   | `sweep` | the dry-run expiry sweep would archive ≥ 1 entry | `scripts/lib/learnings/expiry-sweep.mjs` |
   | `reconcile` | `computeReconcileNudge().nudge === true` | `scripts/lib/reconcile-nudge-banner.mjs` (reused whole) |
   | `dialectic` | `shouldDispatchAutoDialectic().trigger === true` | `scripts/lib/auto-dialectic.mjs` — the side-effect-free decision function; a variant that advanced the last-run stamp would consume the signal it reports (the former recording wrapper was removed in #1288) |
   | `memory-cleanup` | `shouldDispatchAutoDream().trigger === true` | `scripts/lib/auto-dream.mjs` |
   | `pending-sidecar` | an unapplied pending dream/dialectic proposal younger than `SIDECAR_MAX_AGE_DAYS` (14) | `.orchestrator/*-pending*.md` |
   | `generated-rules-expiring` | a machine-generated rule in `.claude/rules/` is already expired or expires within `GENERATED_RULE_EXPIRY_HORIZON_DAYS` (7) days; the population comes from `listMachineGeneratedRules({ repoRoot })` — one definition of "machine-generated", shared with the byte ceiling that judges the same set. The detail names each file and its `expires-at` date (HR-106), soonest first, because those are exactly the files the operator has to open to repair it. An unreadable rules directory goes to `undeterminable`, never to "clean" | `scripts/lib/instruction-budget-guard.mjs` (via #1372) |

   Introduces NO new Session Config key — every threshold is an existing key or an existing module constant.

   - **Nothing due and everything readable**: silent (no banner).
   - **A housekeeping session completed within `HOUSEKEEPING_COOLDOWN_DAYS` (7) days**: silent, even when signals are due — the operator already did the thing the banner would ask for. This cooldown is what keeps the instrument rare (HR-101, `.claude/rules/host-resources.md`); the housekeeping session's own record IS the last-maintenance stamp.
   - **Otherwise**: render `result.message`, shaped as `⚠ maintenance due: <N> of 7 (<id>: <detail> · <id>: <detail>) — run /session housekeeping.` Every due signal carries the number or date that made it due (HR-106) — no aggregate stands alone.
   - **Unreadable inputs** append an ` · undeterminable: <id>, <id>` suffix before the `— run /session housekeeping.` tail. A signal whose inputs cannot be read is three-state: never folded into "clean" (the `never` vs `undeterminable` discipline inherited from `reconcile-nudge-banner.mjs`).

   Non-blocking. Cross-reference: `scripts/lib/reconcile-nudge-banner.mjs` (`computeReconcileNudge`), `scripts/lib/reconcile/engine.mjs` (`runReconcile`), `skills/reconcile/SKILL.md`, `skills/session-start/SKILL.md` Phase 7 (the housekeeping maintenance loop the banner routes to), and issues #723 / #1200.

   The sessions-staleness probe (`scripts/lib/sessions-staleness-banner.mjs`, `checkSessionsStaleness`) runs in the Phase 4 banner family as `id: 'sessions-staleness'` in `scripts/lib/session-start-probes.mjs` `PROBES`. This detects the "close-through" gap: sessions that end without ever writing a `.orchestrator/metrics/sessions.jsonl` ledger record. It returns `null` (silent no-op) when `.orchestrator/metrics/sessions.jsonl` or `.orchestrator/metrics/events.jsonl` are absent or all-malformed, when no foreign (pre-session) event exists, or when the gap between the last ledger entry and the newest foreign event is at or under the warn threshold. When a non-null result is returned (`{ severity, message }`), render `result.message` alongside the other banners:
   - **warn** (gap > 8h): `"⚠ sessions-staleness: last sessions.jsonl entry <ISO> is <N>h behind pre-session events.jsonl activity <ISO> — possible close-through gap (sessions ended without a ledger record; run node scripts/backfill-abandoned-sessions.mjs --dry-run)."`
   - **alert** (gap > 24h): same message with a `🚨` prefix and an appended `"— gap exceeds 24h."` clause.
   - **No gap / under threshold**: silent (no banner).

   Non-blocking. Cross-reference: `scripts/lib/session-lock.mjs` (`readLock`, `DEFAULT_TTL_HOURS` — the current session's lock `started_at` is the self-exclusion cutoff), `scripts/backfill-abandoned-sessions.mjs` (the backfill CLI the message recommends) and issue #724.

   **The backfill is mechanical since #926 — the banner's CLI hint is a fallback, not the primary path.** `hooks/on-session-start.mjs` calls `backfillOnSessionStart()` from `scripts/backfill-abandoned-sessions.mjs` on every SessionStart, which **applies** (writes) the reconstructed stubs rather than only previewing them. This decouples recovery from `/close`: `hooks/on-session-end.mjs` also backfills, but SessionEnd fires only on a REGULAR close, so a session killed by Ctrl-C, a timeout, or a crash left no ledger record until the NEXT clean close — which may never come (observed: this repo's ledger 18.9h behind events.jsonl across 8 commits). Running at start means the *next* session recovers the previous one, whatever killed it.

   Four properties make that safe to run unattended on every start:
   - **Idempotent.** Dedupe against sessions.jsonl plus an atomic `wx` marker file; repeated starts write nothing new. Synthetic ids are derived from the session's own `started_at` + a sha256 of its UUID, so they are stable across runs.
   - **Self-excluding.** It runs BEFORE this session emits `orchestrator.session.started`, so the starting session is not a candidate at all. On a clear/compact/resume re-fire (where an earlier started-event *is* present) the hook passes its own in-memory raw and semantic id (`ownSessionIds`), and the planner drops every candidate carrying either — with or without a lock, because a lead yield (#1635) or a kept-released lock (#1651) starts lockless (#1748). The ids never come from `current-session.json`, which any start in the checkout rewrites (#1746).
   - **Foreign-safe.** Liveness is evaluated against the CANDIDATE, not the running process, from two sources: a candidate holding a live lock returns `skipped-own-live-lock` before the `relaxDeadByAge` (#731) relaxation is consulted, and a lockless candidate (lead yield, released lock, lost acquire) is dropped from the plan while its host-registry entry has a heartbeat within `DEFAULT_TTL_HOURS` (#1748). A registry directory that exists but cannot be listed stamps nothing (`registry_unreadable: true`); an unparseable entry vouches by its file mtime. Residual, accepted: `sweepZombies()` removes a registry entry after 60 min without a heartbeat, so a lockless session that ends no turn for that long reads as dead; and a semantic label shared with a live session in another repo only delays a dead candidate's stub to a later start.
   - **Bounded + non-blocking.** Capped at `SESSION_START_LIMIT` (25) core calls, walked newest-first so the budget reaches the recent abandoned sessions rather than being spent on ancient already-recorded ones; **measured median 845ms** (5 steady-state runs: 713/835/845/921/984) on a 1.7MB events.jsonl / 187-candidate store, coordinator-verified 2026-07-30. Treat that as the cost this adds to every session start — it is roughly a second, not a rounding error, and it scales with the events ledger rather than the candidate count. Every failure is swallowed — a backfill error can never block a session start. Operator escape hatch: `SO_DISABLE_STARTUP_BACKFILL=1`.

   When the run reports `truncated: true` (more candidates than the per-start budget), the remainder is picked up by subsequent starts; `node scripts/backfill-abandoned-sessions.mjs --dry-run` remains the way to inspect the full backlog, and `--apply` the way to drain it in one pass.

   The sessions-integrity probe (`scripts/lib/sessions-integrity-banner.mjs`, `checkSessionsIntegrity`) runs in the Phase 4 banner family as `id: 'sessions-integrity'` in `scripts/lib/session-start-probes.mjs` `PROBES`. Where sessions-staleness above detects records that were never written, this detects records that WERE written but are schema-invalid — appended by a path that bypassed `scripts/emit-session.mjs` (which validates and would have refused). The loss is otherwise silent: `scripts/vault-mirror.mjs` reports such a record as `{"action":"skipped-invalid"}` on stdout and still exits 0, so the affected sessions simply have no vault note and nobody is told. Deliberately un-gated by Session Config (like `project-hygiene`) — a check nobody enables finds nothing. It returns `null` (silent no-op) when `.orchestrator/metrics/sessions.jsonl` is absent, empty, unreadable, or holds no parseable JSON line, and when every parseable record satisfies both validators; unparseable lines are skipped rather than reported (this probe judges schema integrity, not file corruption). The probe reports TWO populations, because measurement showed neither validator's failure set contains the other (this repo, 2026-07-31, 203 records: 3 vs 12, overlapping in only 2) — `validateSession()` treats `effectiveness` as optional while vault-mirror requires it, so reporting one alone would hide the other. The vault-mirror population is measured by invoking the real render path in a try/catch, never by re-deriving its field list. When a non-null result is returned (`{ severity, message, total, schemaInvalid, mirrorSkipped }`), render `result.message` alongside the other banners:
   - **warn** (records fail `validateSession()` but all still mirror — corruption without loss): `"⚠ sessions-integrity: <N> of <M> records fail validateSession (<ids>) — records were appended without passing scripts/emit-session.mjs …"`
   - **alert** (at least one record is dropped by vault-mirror — those sessions have no vault note right now): same message with a `🚨` prefix and an appended `"; <N> are dropped by vault-mirror as skipped-invalid — those sessions have NO vault note (<ids>)"` clause.
   - **Fully valid ledger**: silent (no banner).

   Non-blocking. Note the remedy is a re-emit of the affected records through `scripts/emit-session.mjs`, not an edit of the ledger by hand. Cross-reference: `scripts/lib/session-schema/validator.mjs` (`validateSession` — the canonical write-path schema), `scripts/lib/vault-mirror/render-sessions.mjs` (the render path whose throw becomes `skipped-invalid`), `skills/session-end/session-metrics-write.md` (the prose prohibition this banner backstops), `hooks/pre-bash-sessions-ledger-guard.mjs` (the write-guard half) and GitLab issue #958.

   The owner-config probe (`scripts/lib/owner-config-banner.mjs`, `checkOwnerConfig`) runs in the Phase 4 banner family as `id: 'owner-config'` in `scripts/lib/session-start-probes.mjs` `PROBES`, called with no arguments at all: the probe reads the host-wide `owner.yaml`, not a per-repo file. The helper returns `null` (silent no-op) on a clean load, when `owner.yaml` is simply absent, or on any internal read/parse error. When a non-null result is returned (`{ severity: 'warn', message, droppedSections?, sectionWarnings?, discarded? }`), render `result.message` alongside the other banners:
   - **Optional section(s) dropped to defaults** (`droppedSections` present): an OPTIONAL object section (`paths`, `dispatcher`) was malformed and replaced by its default value.
   - **Whole file discarded** (`discarded: true`): a REQUIRED section (`owner`, `tone`, `efficiency`, `hardware-sharing`) was invalid, so the entire file was discarded and defaults are in effect.
   - **Lenient-consumer warnings** (`sectionWarnings` present, nothing dropped): an OPTIONAL list section (`vaults`, `baselines`) has invalid entries that lenient consumers will drop at point-of-use.

   Non-blocking. Cross-reference: `.claude/rules/owner-persona.md` (host-wide `owner.yaml` schema + privacy contract) and issue #820.

   The runtime-readiness probe (`scripts/lib/runtime-readiness.mjs`, `checkRuntimeReadinessBanner`) runs in the Phase 4 banner family as `id: 'runtime-readiness'` in `scripts/lib/session-start-probes.mjs` `PROBES`, called with no arguments: it checks the plugin install the running hook code was loaded from (`ownPluginRoot()`), not the session repo. It reads that root's `package.json` and looks up every declared runtime dependency the way Node resolves a bare specifier (`<dir>/node_modules/<name>/package.json`, walking up through every ancestor). It imports only `node:*`, starts no process and installs nothing. Why: the Codex marketplace short form installs each plugin version into a NEW cache directory without `node_modules`; the harness reports the plugin installed and enabled, but every script importing `js-yaml` or `zod` fails, and a manual repair does not survive the next update. The helper returns `null` (silent no-op) only when every declared dependency resolves; it never throws. An unreadable or invalid `package.json` is `{ severity: 'warn', degraded: true }`, never `null`: a check that could not run is not a clean one. The registry entry's `telemetry` persists `missing` as `measure` (0 for `null`, `null` when degraded). When a non-null result is returned (`{ severity: 'warn', message, missing, root }`), render `result.message` alongside the other banners:
   - **Dependencies missing**: `"⚠ runtime-readiness: <N> of <M> runtime dependencies missing in <root> (<name>, … up to four names) — skills and scripts importing them fail. Repair: cd \"<root>\" && npm <ci|install> --omit=dev --ignore-scripts (repeat after every plugin update: each version gets a new cache dir)."` (`npm ci` when the root carries a lockfile, `npm install` otherwise.)
   - **degraded** (package.json unreadable or invalid): `"⚠ runtime-readiness: could not read <root>/package.json (<package-json-unreadable|package-json-invalid>) — runtime dependencies not checked, which is not the same as installed."`

   A dev checkout or CI job with `node_modules` installed stays silent. Non-blocking. The repair is `node scripts/runtime-readiness.mjs --repair` or the printed npm command; the session-start hook never runs it (no network in hooks). Cross-reference: `scripts/runtime-readiness.mjs` (CLI with import probes and `--repair`) and issue #1848.

   The MOC-staleness probe (`scripts/lib/moc-staleness-banner.mjs`, `checkMocStaleness`) runs in the Phase 4 banner family as `id: 'moc-staleness'` in `scripts/lib/session-start-probes.mjs` `PROBES`, called with `{ repoRoot, config }`. The helper returns `null` (silent no-op) when `repoRoot` is missing/non-string, when `moc-staleness.enabled` is `false` or `moc-staleness.mode` is `off` (checked BEFORE any filesystem I/O), when no vault dir resolves (neither an explicit `vaultDir` test seam nor `config['vault-integration']['vault-dir']`), when `<vaultDir>/08-topics/` is absent, when no `*-moc.md` exists there, or when every present MOC's `updated:` frontmatter is missing/unparseable. When a non-null result is returned (`{ severity: 'warn', message, stale }`), render `result.message` alongside the other banners:
   - **Stale MOC(s)** (`updated:` older than the threshold, default 90 days): `"⚠ moc-staleness: <N> MOCs stale (>90 days) — <file> (<N>d), … — review and refresh the \`updated:\` frontmatter."`
   - **Healthy / disabled / no MOCs / all excluded**: silent (no banner). A MOC whose `updated:` is missing or unparseable is deliberately EXCLUDED rather than reported — the corrective action there is "fix the frontmatter", not the banner's hint (same rule as `peer-cards/staleness-banner.mjs`).

   Non-blocking. Cross-reference: `scripts/lib/config/moc-staleness.mjs` (`_parseMocStaleness`) and issue #831.

   The context-coverage probe (`scripts/lib/context-coverage-banner.mjs`, `checkContextCoverage`) runs in the Phase 4 banner family as `id: 'context-coverage'` in `scripts/lib/session-start-probes.mjs` `PROBES`, called with `{ repoRoot, config }`. The helper returns `null` (silent no-op) when `repoRoot` is missing/non-string, when `context-coverage.enabled` is `false` or `context-coverage.mode` is `off` (checked BEFORE any filesystem I/O), when no vault dir resolves, when `<vaultDir>/01-projects/` is absent or empty, when zero registered projects exist, or when every registered project already carries a `context.md` or `_passive.md`. When a non-null result is returned (`{ severity: 'warn', message, gaps, registered, covered }`), render `result.message` alongside the other banners:
   - **Gaps found**: `"⚠ context-coverage: <N> of <M> registered projects lack context.md and _passive.md — <slug>, … — add a context.md or mark the project passive with _passive.md."` A project counts as **registered** iff its `01-projects/<slug>/` directory contains `_overview.md` — the same convention `discoverVaultRepos()` uses. Directories lacking `_overview.md` are never counted and never listed as gaps.
   - **Fully covered / no vault configured / disabled**: silent (no banner).

   Non-blocking. Cross-reference: `scripts/lib/gitlab-portfolio/vcs-detect.mjs` (`discoverVaultRepos` — the canonical "registered" definition), `scripts/lib/config/context-coverage.mjs` (`_parseContextCoverage`), and issue #831.

   The CLAUDE.md budget-lint probe (`scripts/lib/claude-md-budget-lint.mjs`, `checkClaudeMdBudgetLint`) runs in the Phase 4 banner family as `id: 'claude-md-budget-lint'` in `scripts/lib/session-start-probes.mjs` `PROBES`. This is a **warn-only** probe — its result is rendered, never gated; the underlying `lintClaudeMd()`/CLI exit-code contract (0/1/2, `--mode hard` by default) belongs to the standalone bootstrap-time lint (`skills/bootstrap/SKILL.md` § Step 2c) and is NEVER invoked here. The helper returns `null` (silent no-op) when no CLAUDE.md/AGENTS.md resolves under `repoRoot`, when the resolved file has zero violations, or on any read/parse failure. When a non-null result is returned (`{ severity: 'warn', message }`), render `result.message` alongside the other banners:
   - **Violations found**: `"⚠ CLAUDE.md budget lint: <N> violation(s) (<rule names>) in <file> — run \`node "<abs-module-path>" --repo-root "<repoRoot>" --mode warn\` for details."` — `<rule names>` is the de-duplicated set of violated rule ids (`max-lines`, `max-line-chars`, `provenance-header`) present in the file. `<abs-module-path>` is `__filename` (`fileURLToPath(import.meta.url)`) — the absolute path of the module executing right now, not a repo-root-relative `scripts/lib/…` path, which only resolves inside THIS repo's checkout and left the hint unrunnable in consumer repos (#1302). `<repoRoot>` is echoed back so the pasted command re-lints the file the banner reported on, whatever the operator's cwd. Both are quoted for paths with spaces.
   - **Clean file / no instruction file**: silent (no banner).

   Non-blocking. Cross-reference: `scripts/lib/instruction-budget-guard.mjs` (sibling directive-COUNT probe over `.claude/rules/*.md` — this probe measures raw-file PROPERTIES of CLAUDE.md/AGENTS.md itself, a distinct dimension) and issue #878 (FA2b).

   The tests:src-ratio probe (`scripts/lib/tests-src-ratio.mjs`, `checkTestsSrcRatio`) runs in the Phase 4 banner family as `id: 'tests-src-ratio'` in `scripts/lib/session-start-probes.mjs` `PROBES`. It returns `null` (silent no-op) when the ratio is inside the TV-003 corridor, when the ratio is undefined because the repo has no src code, when `repoRoot` is missing or does not exist, or when the measurement throws. It returns a warn in two cases: the ratio exceeds the ceiling, or the ratio is not measurable — 0 test files counted while test-like files are tracked (`reason: 'no-tests-found'`, `ratio: null`, message "tests:src ratio nicht messbar", #1451). The result shape is `{ severity: 'warn', message, ratio: number|null, ceiling, reason? }` — `reason` is set only on the not-measurable warn. Render `result.message` alongside the other banners.

   **Why this is a banner and not a gate.** `.claude/rules/test-value.md` § TV-003 names the ceiling as the trigger for a CONSOLIDATION wave — the rule's operative instrument. Before this wiring the trigger fired into a void: the only references were two rule files asking a human to run the command, so the condition could be true for months with nothing saying so (it was true, at 1.70, on the commit that introduced the script). The rule's refusal of a bidirectional ratchet stands unchanged — this surfaces the trigger, it does not block on it. `--check` remains deliberately unwired from CI.

   Non-blocking. Cross-reference: `.claude/rules/test-value.md` § TV-003 (the corridor rule and why a ratchet was rejected), `.claude/rules/testing.md` § Coverage Enforcement (the 70% floor that binds independently), and issue #930.

   The project-hygiene probe (`scripts/lib/project-hygiene.mjs`, `checkProjectHygiene`) runs in the Phase 4 banner family as `id: 'project-hygiene'` in `scripts/lib/session-start-probes.mjs` `PROBES`. It is the most expensive probe in the family — a `git status --ignored` plus a batched `du` over every ignored path — which is why a second hand-invocation was worth removing (#1351). Measured 4179 / 11971 / 11721 / 6857 ms in four consecutive family passes over this repo on 2026-09-13 at `5e9506b1`, with the host at 100 % CPU (1m and 5m) and 5 peer sessions live, as the resource gate logged at the same time. The 2026-09-12 figure of ~150-800 ms was taken on a lighter host, so the two readings are not comparable: read only the order of magnitude — seconds, not milliseconds, under load. Re-measure on the host in front of you before treating any of these numbers as this probe's cost. **This is the only probe in Phase 4 besides `ci-status` that inspects the PROJECT rather than the orchestrator's own substrate** — every other probe above measures vault, peer-cards, loop readiness, instruction budget, or this tool's own ledger. It is deliberately NOT config-gated: a hygiene check nobody enables finds nothing, which is how the equivalent coverage was lost before (see `skills/session-end/discovery-scan.md` — the discovery scan defaults OFF for exactly the `housekeeping` session type that most needs it).

   The helper returns `null` (silent no-op) when `repoRoot` is missing/non-string, when the path is not a git repository, or when every check passes. When a non-null result is returned (`{ severity: 'warn', message, findings, mechanical }`), render `result.message` alongside the other banners:
   - **Findings present**: render the message verbatim. It already leads with the count and the mechanically-fixable subset, then names the top 3 and summarises the remainder — this shape was chosen because a flat list stops being read past roughly 25 findings.
   - **Healthy repo**: silent (no banner).

   Use `result.mechanical` when proposing session scope: findings with `fixable: true` (aged artifacts, ignored ballast, a missing CI audit step — an audit that cannot be verified because `.gitlab-ci.yml` pulls jobs via `include:` is reported `fixable: false` instead) are safe batch work, while the rest (release cadence, absent CI, undocumented configuration) need an operator decision and belong in the Q&A, not in an auto-fix batch.

   The checks are: release-tag/CHANGELOG distance from HEAD, ignored working-tree ballast plus files that are neither tracked nor ignored, aged `.orchestrator/` artifacts, CI pipeline presence and dependency-audit coverage, and `.env.example` presence. Two high-yield checks are intentionally NOT here: **docs-drift** is already covered by `claude-md-drift-check` (it only runs at session-END, so the gap is scheduling, not implementation), and **env completeness** is omitted because diffing `process.env` reads against `.env.example` produced a 100% false-positive rate against code that reads configuration through a central schema module.

   Non-blocking. Cross-reference: `scripts/lib/ci-status-banner.mjs` (the sibling project-facing probe) and `.claude/rules/test-value.md` § TV-005 (why structural gates beat unit-test volume).

   The mirror-issues probe (`scripts/lib/mirror-issues-banner.mjs`, `checkMirrorIssues`) runs in the Phase 4 banner family as `id: 'mirror-issues'` in `scripts/lib/session-start-probes.mjs` `PROBES` (`network: true`, awaited by the runner). This is the only probe that deliberately queries the platform the session did NOT auto-detect. `skills/gitlab-ops/SKILL.md` § VCS Auto-Detection selects exactly one platform via if/else, so in a repo whose `origin` is GitLab and whose `github` remote is a public mirror, no code path ever reads the mirror's issue tracker — issues filed there by external reporters are structurally invisible to every session. The VCS family is therefore hard-pinned to `'github'` inside the module rather than auto-detected. It takes no Session Config key: `resolveRepoSpec({ repoRoot, vcs: 'github' })` derives the `gh -R` spec from `git remote`, which makes the probe self-disabling — a repo with no GitHub mirror resolves to `undefined`, returns `null`, and spawns no subprocess.

   The return contract has THREE states, not the usual two, and the third is the point: `null` means either "no mirror remote" or "queried successfully, zero open issues"; `{ severity, message, count, repoSpec, issues }` means N > 0; and `{ severity, message, repoSpec, degraded }` means the query did NOT succeed, where `degraded` is one of `cli-missing | timeout | parse-error | auth-error | query-failed`. Render `result.message` verbatim in either non-null case. A `degraded` result must be read as *"the mirror's state is unknown"* — never as clean. `scripts/lib/ci-status-banner.mjs` **was** the unmigrated side until #1031: it collapsed missing-CLI, timeout and unparseable output onto the same `null` an absent remote returns, which in the banner contract reads as "all clear", and that collapse is why this gap survived unseen. It now carries its own `DEGRADED_REASONS` enum (`cli-missing | timeout | parse-error | query-failed | git-error`) and the same three-state shape — see the CI-status paragraph above. The two enums are deliberately NOT merged: each names the failures its own probe can actually reach.

   The git-config-drift probe (`scripts/lib/git-config-drift.mjs`, `checkGitConfigDrift`) runs in the Phase 4 banner family as `id: 'git-config-drift'` in `scripts/lib/session-start-probes.mjs` `PROBES` (`env` defaults to `process.env`). It reads `git config --local --list` with a FILTERED environment, so an ambient `GIT_DIR` cannot redirect the probe itself at a foreign repository and let it call this one clean. **Three states, not two:** `null` = read and clean; `{ severity: 'warn', message, findings }` = at least one unexpected entry (a local identity override, a local `commit.gpgsign`, a remote on a reserved fixture host, a `core.hooksPath` not pointing at `.husky/_` AND not DECLARED by the repo, a `.husky/_` hooksPath whose directory git resolves to in THIS working tree has no `pre-push`, has a non-executable `pre-push`, or lacks the `h` helper (each defect is a separate finding) — the gitignored runtime a fresh linked worktree never gets, so commit and push run ungated; repair with the target repo’s canonical PM per SEC-020: `pnpm exec husky` (pnpm), `npm exec --no -- husky` (npm), `yarn exec husky` (Yarn), or `bun run husky` (Bun); when unknown, identify the canonical PM first (#1688) —, a `core.hooksPath` set in NO scope while the repo tracks `.husky/<hook>` files, so git never runs them (`hooks-path-unset`, same repair; a failed query is reported as unknown; #1692), or `GIT_DIR`/`GIT_WORK_TREE` set in the environment); `{ …, degraded }` = the config could NOT be read — **never render that as clean.** Render `result.message` alongside the other banners.

   The `core.hooksPath` trigger is narrower than "points somewhere other than `.husky/_`" — a hooksPath the repo DECLARES is accepted without any config key. Declared means `git ls-files -- <hooksPath>` finds at least one TRACKED file DIRECTLY under it (not nested deeper) whose BASENAME is a real git hook name (`pre-commit`, `pre-push`, `commit-msg`, …) — a tracked directory of ordinary source, or an untracked file merely named like a hook, does not count. This document does not restate the hook-name list — `GIT_HOOK_NAMES` in `scripts/lib/git-config-drift.mjs` (sourced from `git help hooks`) is the single declaration site (#1158 review N1 — the first cut of this rule accepted any tracked file under the path at all, which a fixture-planted `scripts/pre-commit` left untracked, sitting beside ordinary tracked source, would have bypassed silently).

   This is the only probe that inspects `.git/config`, and that is the whole point: `git status` cannot see that file. On 2026-08-19 a coordinator diagnostic exported `GIT_DIR` at this repository while the suite ran; test fixtures wrote a foreign remote and their own `user.email`/`user.name` into the local config, and the identity then authored two commits that reached both remotes. A recovery pass checked HEAD, the index and all 1614 tracked files, found everything clean, and missed it — because none of those surfaces show `.git/config`. It surfaced two hours later, from an agent measuring something else.

   The complementary halves live elsewhere and are not duplicates of this probe: `tests/setup/scrub-git-env.mjs` (wired via `setupFiles` in `vitest.config.mjs`) removes the redirecting variables before any test runs, and `scripts/lib/validate/check-test-git-config-target.mjs` censuses untargeted state-mutating git calls in `tests/**`. The census is WARN-only by measurement — its first cut was 11 hits, all false positives — and it explicitly reports `gitDirInheritable`, the population it cannot close, because the incident's own call sites passed a correct `cwd` and were redirected anyway.

   Non-blocking. Cross-reference: `scripts/lib/vcs-repo-spec.mjs` (`isQueryFailure` — the same absence-vs-query-failure split this probe's `degraded` state implements).

   The events-retention probe (`scripts/lib/events-retention-banner.mjs`, `checkEventsRetention`) runs in the Phase 4 banner family as `id: 'events-retention'` in `scripts/lib/session-start-probes.mjs` `PROBES` (#1401). `events-rotation.max-backups` bounds how MANY archives `_archive/` keeps; the readers of the rotated ledger need a TIME window, declared as `REQUIRED_EVENTS_WINDOW_DAYS` beside every reader call — `readEventsWithRotations(`, `scanEventsBackwards(` and `listEventSourcesNewestFirst(`, seven readers censused from source by `tests/lib/events-retention-banner.test.mjs` (`telemetry/sync.mjs` 1 day — a chosen ceiling, the assumed maximum age of the latest `orchestrator.session.started` when a flush reads it; `eval/engine.mjs`, `tmux-layout/telemetry-stats.mjs`, `maintenance-due-banner.mjs`, `instruction-budget-guard.mjs`, `backfill-abandoned-sessions.mjs` and `session-close-backfill.mjs` `null` — no fixed window: their questions span the whole history, and the backward scan reports a pruned history as `gaps`, so maintenance-due answers `undeterminable` instead of "never", #1489). The probe compares the span from the oldest retained archive's first timestamp to now against the largest finite declaration, and only once the archive ring is FULL (`archives >= max-backups`) — below that the rotator prunes nothing under the current config, so the ledger only grows and the probe stays silent. On a full ring the archives alone are judged: the rotator never prunes a legacy `events.jsonl.N`, so its older stamp would span the hole a pruned archive left (#1489 review). "Not full" is not "never pruned": after `max-backups` is raised, archives pruned under the old ring stay gone — the oldest retained archive's rotation record names a predecessor (`archived_as`) that no longer exists — and the silent coverage figure then starts at the oldest archive as on a full ring. The hole goes unseen only when the oldest archive's head holds no rotation record — the first archive a ledger ever wrote, before which no archive can have been pruned, or a later one whose tombstone was never written (`recordWritten: false`); measured 2026-10-02 over the 5 fleet `_archive/` dirs: 4 of 4 first archives without one, 2 of 2 later archives with it on line 0 (#1498). No Session Config key of its own. **Three states:** no live ledger and no archive in `_archive/` (absent, empty, or holding only names outside `ARCHIVE_NAME_RE`) → registry precondition, recorded `skipped: no-events-ledger` (never `ran-clean`); `_archive/` unlistable, a reader's declaration unreadable, the oldest retained stamp dated after now (a future-dated archive or a clock set back — the span would be negative), or no stamp at all (no archive and no parseable `timestamp` in the head of `events.jsonl` or a legacy `events.jsonl.N`, #1498) → `{ severity: 'warn', degraded: true }`, never rendered as covered; measured → `{ severity: 'ok', kind: 'ring-not-full' | 'no-finite-window' | 'covered' }` or a warn, and every measured answer returns `coverageDays`, `oldestEventAt`, `requiredDays` / `requiredBy`, `archives` and `maxBackups` to its caller even when silent. Of these, the registry entry's `telemetry` persists `coverage_days`, `archives`, `max_backups` and `required_days` as `measure` on this probe's element of `orchestrator.probes.completed` — on the silent answers too, which the runner records as `ran-clean` — so whether the warning could have fired is answerable from the ledger (HR-105); `oldestEventAt` and `requiredBy` are not persisted. A degraded answer persists no `measure`. Render `result.message`:
   - **warn** (full ring covers less than the required window): `"⚠ events-retention: the rotated events ledger covers <X>d (<N> archives at events-rotation.max-backups: <M>), less than the <D>d window <reader> declares as REQUIRED_EVENTS_WINDOW_DAYS — events older than <X>d are not retained; raise events-rotation.max-backups or max-size-mb."`
   - **degraded, `_archive/` unlistable**: `"⚠ events-retention: could not list _archive/ (<code>) — retention span not measured, which is not the same as covered."`
   - **degraded, a reader's declaration unreadable** (on every measured start, full ring or not — the reader modules are imported BEFORE the ring check, because `requiredDays` is part of the silent measurement; `<error>` is the import or validation error, cut to 120 characters): `"⚠ events-retention: required window not readable (<error>) — retention span not judged, which is not the same as covered."`
   - **degraded, oldest retained stamp after now**: `"⚠ events-retention: the oldest retained event is dated <ISO timestamp>, after now — retention span not measured, which is not the same as covered."`
   - **degraded, no stamp in any source** (no archive, and no parseable `timestamp` in the first 64 KiB of `events.jsonl` or a legacy `events.jsonl.N`, #1498): `"⚠ events-retention: no parseable timestamp in the first 64 KiB of events.jsonl (or of a legacy events.jsonl.N) and no _archive/ archive — retention span not measured, which is not the same as covered."`

   The security-pulse probe (`scripts/lib/security-pulse-banner.mjs`, `checkSecurityPulse`) runs in the Phase 4 banner family as `id: 'security-pulse'` in `scripts/lib/session-start-probes.mjs` `PROBES` (#1769, stage 2). It makes no network request: it reads only the host-private cache under `resolvePrivateConfigDir()/security-pulse/` (`host.json` with the Node security floors per major and the CISA KEV ids, `repos/<key>.json` with the reduced OSV lockfile findings of one repo at one HEAD; directories 0700, files 0600) plus one `git rev-parse HEAD` with a 1500 ms timeout. **Four states**, returned as `state`: `not-measured` (neither cache file readable), `degraded` (a part was measured incompletely: a host source not `ok`, a repo run not `ok`/`skipped`, or `unchecked > 0`; the reasons name the part and the unchecked count), `stale` (one part unreadable, older than the 6 h TTL, dated in the future, or the repo part recorded for another HEAD), `fresh` (Node floors, KEV and repo part each complete, the repo part at HEAD, each younger than 6 h). One rule decides freshness in the probe and in the refresher (`cachePartStatus`): a complete part is due after 6 h, a degraded part after 30 minutes, a future-dated part at once. `not-measured`, `degraded` and `stale` are severity `ok` with no banner, and none of them means "clean": the reasons travel in `reasons[]` (`host-<reason>`, `repo-<reason>`, `head-unknown`, `repo-head-moved`, `close-<reason>`). **Three findings:** (a) the running Node is below the cached security floor of its own major; (b) the findings cached for the current HEAD include a malicious package or a critical vulnerability; the KEV count covers only these listed findings, matched through the CVE aliases of the fetched OSV detail records (advisories past the detail cap or the deadline contribute only their primary id); (c) only when `security-delta.mode` is not `off`, the newest REAL close in `sessions.jsonl` (backfilled records — `_synthetic`, `_backfill_source`, `session_type: unknown` — are skipped) has no `orchestrator.security.delta` event of its own session between its `started_at` and its `completed_at` plus 5 minutes (an event whose `session_id`/`semantic_session_id` names another session never counts; without an identity on either side the time span alone decides) (it reads the last 1 MiB of `sessions.jsonl` and the last 4 MiB of `events.jsonl`; a tail too short to rule the event out is `close-events-tail-too-short`, not a finding). No finding → no banner. With one or more findings the result is `severity: 'warn'` and `result.message` joins the parts with `"; "`:
   - **warn**: `"⚠ security-pulse: <parts> (Stand vor <N> h)"` — the age suffix is left out when no cache age is known. The parts are `"Node <version> unter Sicherheitsstand <floor>"`, `"<n> Paket-Fund(e) (<m> bösartig, <k> kritisch, <j> in KEV), z. B. <package>@<version>"` (each count only when non-zero; `Paket-Fund` for one) and `"letzter Close ohne Security-Delta"`.

   The registry entry's `telemetry` persists 8 numeric keys as `measure` on this probe's element of `orchestrator.probes.completed`: `cache_state` (0 not-measured, 1 stale, 2 fresh, 3 degraded), `node_below_floor`, `repo_malicious`, `repo_critical`, `kev_hits`, `close_without_delta`, `cache_age_h` and `refresh_spawned`; a finding the probe could not measure is `null`, never 0. When the cache is not fresh the probe starts `scripts/security-pulse-refresh.mjs --repo-root <repo>` detached and never waits for it; a per-repo marker allows one spawn per repo per 10 minutes, claimed atomically (`<marker>.claim`, exclusive create, published by rename), so two sessions starting at once spawn one child; a claim that cannot be written means no spawn (`claim-failed`). The child gets an allowlisted environment (paths, home, temp, TLS, proxy, locale, `SO_*`, and only the registry keys of `npm_config_*`). `SO_SECURITY_PULSE_REFRESH=off` disables the spawn (`refresh: disabled`); the test suite sets it. Package names and versions reach the cache and the banner only when they match a strict npm/PyPI charset (otherwise `unbenannt`), and control characters are stripped from the message, because the lockfile is repo-controlled text. The refresher takes a non-blocking host lock (`refresh.lock`; a held lock exits 0 with `skipped=locked`), refetches each part older than its 6 h TTL (the repo part also when HEAD moved), prints counts only and gives up after 60 s (exit 2); `--force` ignores every TTL.

   All banners are non-blocking — display in the Session Overview, do not halt the session. If `bootstrap-lock-freshness.mjs` is absent (pre-#186 plugin install) or `peer-cards/staleness-banner.mjs` is absent (pre-#503 plugin install) or `loop-readiness-banner.mjs` is absent (pre-#633 plugin install) or `instruction-budget-guard.mjs` is absent (pre-#687 plugin install) or `maintenance-due-banner.mjs` / `reconcile-nudge-banner.mjs` are absent (pre-#723 plugin install) or `sessions-staleness-banner.mjs` is absent (pre-#724 plugin install) or `sessions-integrity-banner.mjs` is absent (pre-#958 plugin install) or `owner-config-banner.mjs` is absent (pre-#820 plugin install) or `moc-staleness-banner.mjs` / `context-coverage-banner.mjs` are absent (pre-#831 plugin install) or `claude-md-budget-lint.mjs` is absent (pre-#878 plugin install) or `mirror-issues-banner.mjs` is absent (pre-#1022 plugin install), skip silently.

