# 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

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`.
   Before running, perform a **command-availability check**: for each resolved command, extract the binary (first token) and run `command -v <binary>`. If absent, skip that check and log `⚠ Quality baseline: <binary> not found — skipping <variant>`. Report results but do not block 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 19-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>"`
   - **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 core's `skipped-own-live-lock` guard catches it against the lock bootstrapped moments earlier.
   - **Foreign-safe.** Lock ownership is evaluated against the CANDIDATE, not the running process: a candidate holding a live lock returns `skipped-own-live-lock` before the `relaxDeadByAge` (#731) relaxation is consulted. A running foreign session is therefore never recorded as `abandoned`. Residual, accepted: a live session that does NOT hold the lock (it lost the acquire race) AND has emitted no event for longer than `DEFAULT_TTL_HOURS` (4h) can still be relaxed past — a candidate the system's own liveness model already treats as dead.
   - **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 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 `repoRoot` is missing, or on any measurement failure. When a non-null result is returned (`{ severity: 'warn', message, ratio, ceiling }`), 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) 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, 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).

   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.

