os-status: aggregated in-process SessionStart status checks (ADR-022)

New global plugin with one SessionStart hook running all checks in-process
(hooks/checks.py, check(ctx) -> CheckResult, single registry). Three states:
ok (silent), note (near-zero-token line, never suppressed), warn (one
aggregated banner, once-per-day snooze + permanent suppress in gitignored
per-project .cc-os/). Initial checks: subagent-model-env-override (catches
the WS1 Cluster 1 incident deterministically), adr-system-present (verbatim
port of os-adr's hook, legacy .os-adr/suppress honored), vault-hub-note-present.

os-adr's own SessionStart hook moved here atomically (its hooks.json now
empty; session_start.py stays as wording source of record). Built via
OpenSpec change add-os-status-plugin; 36 model-free tests; smoke-tested
2026-07-06 incl. env-override canary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jared 2026-07-07 12:46:10 -04:00
parent c2098525fe
commit 1d1e7207c0
18 changed files with 1250 additions and 15 deletions

2
.gitignore vendored
View File

@ -19,3 +19,5 @@ __pycache__/
# autoresearch skill run logs (eval iterations) # autoresearch skill run logs (eval iterations)
/autoresearch/ /autoresearch/
# os-status plugin state
.cc-os/

View File

@ -617,6 +617,20 @@ _Date: 2026-07-03_
- **Cross-references**: `plugins/os-adr/eval/README.md` (procedure + autoresearch invocation), `docs/adr-system/06-eval-scenarios.md` (Eval B sketches, now noting the model axis), ADR-020 (pilot gate the S6 fixture mirrors). - **Cross-references**: `plugins/os-adr/eval/README.md` (procedure + autoresearch invocation), `docs/adr-system/06-eval-scenarios.md` (Eval B sketches, now noting the model axis), ADR-020 (pilot gate the S6 fixture mirrors).
- **Status**: Accepted 2026-07-03. Harness built and self-tested (all six scenarios: perfect-run PASS, untouched-sandbox FAIL). The grid itself has not yet been run against haiku/sonnet. - **Status**: Accepted 2026-07-03. Harness built and self-tested (all six scenarios: perfect-run PASS, untouched-sandbox FAIL). The grid itself has not yet been run against haiku/sonnet.
## ADR-022 — os-status plugin: aggregated in-process SessionStart status checks
_Date: 2026-07-06_
- **Context**: Two real incidents showed that cc-os plugins have per-project and per-environment preconditions nothing verifies: (1) `CLAUDE_CODE_SUBAGENT_MODEL=haiku` in `~/.claude/settings.json` silently downgraded all 23 verified subagent spawns found by the WS1 orchestration audit — and the E1 canary proved the downgrade is unobservable to the model at spawn time, so protection must be deterministic; (2) projects routinely lack artifacts plugins assume (ADR system, vault hub note), with each plugin inventing its own SessionStart reminder in isolation (os-adr, os-doc-hygiene) or having none. The WS3 plan (`docs/plans/ws3-status-convention-plugin.md`) proposed a per-plugin status-check convention plus a glue plugin; two perspective reviews (simplifier, implementer, 2026-07-06) pressure-tested where check code should live.
- **Decision**: New global plugin **`os-status`** (`plugins/os-status/`), one SessionStart hook running all checks **in-process**: plain Python functions in `hooks/checks.py` with the uniform signature `check(ctx) -> CheckResult(status, message)` and a single registry list. Three result states: `ok` (silent), `note` (near-zero-token line, never snoozed/suppressed — exists so os-adr's Eval-B-tuned usage note survives conversion verbatim), `warn` (aggregated into at most one banner; once-per-day snooze + permanent suppress in a gitignored per-project `.cc-os/` dir — state only, never code). Initial checks: `subagent-model-env-override` (env + settings.json `env` block; runs outside git projects too), `adr-system-present` (verbatim port of os-adr's hook, honoring legacy `.os-adr/suppress`), `vault-hub-note-present` (config-first slug in `.cc-os/config`, else `type/hub` + `project/<name>` facet-tag scan of the vault). os-adr's own SessionStart hook was removed atomically in the same change, with the cutover verified against the refreshed plugin cache.
- **Alternatives rejected**:
- **Option A — per-project code copies** (`.cc-os/status.d/*.py`): copies drift; the 2026-07-04 stale-cache incident is exactly this failure class; adds a per-project install step.
- **Option B as sketched — per-plugin scripts + subprocess/JSON enumeration**: overbuilt for three trivially cheap probes with one author in one repo (spawn/parse/timeout/error-normalization protocol for three registrants); and enumeration from plugin caches would silently run stale code — os-vault is wired by absolute path to git source and its cache copy is already stale (implementer finding). The uniform function signature is kept as the seam so extraction to per-plugin checks later is mechanical.
- **Blanket silent-on-ok**: would regress os-adr's present-state usage note; hence the third `note` state.
- **Deferred**: `context` injection field (no consumer yet), consolidation of `.os-adr/`/`.dochygiene/` state dirs, conversion of os-doc-hygiene's reminder, Phase 2 cache-freshness "collection manager" duties, subprocess enumeration.
- **Cross-references**: OpenSpec change `add-os-status-plugin` (proposal/design/specs/tasks), `plugins/os-status/invariants.md`, WS1 findings (`docs/orchestration-audit/2026-07-06-findings.md`), ADR-018 (marketplace-manifest procedure followed for install).
- **Status**: Accepted 2026-07-06. Built, tested (36 model-free tests), installed, smoke-tested (including the regression canary: exported `CLAUDE_CODE_SUBAGENT_MODEL=haiku` produces the override warn).
## Rejected tools (summary) ## Rejected tools (summary)
| Tool | Why rejected for our use | | Tool | Why rejected for our use |

View File

@ -1,6 +1,10 @@
# WS3 — per-plugin SessionStart status-check convention + glue plugin # WS3 — per-plugin SessionStart status-check convention + glue plugin
_Created: 2026-07-06. Status: proposed. Prereq: none (parallel with WS1). See _Created: 2026-07-06. Status: **superseded 2026-07-06** by the OpenSpec change
`add-os-status-plugin` (implemented as `plugins/os-status/`; decision record: ADR-022 in
`docs/memory-system/03-architecture-decisions.md`). The perspective reviews kept the Option B
direction but replaced the per-plugin enumeration protocol with in-process checks in a single
plugin; this doc is the historical starting sketch only. See
`2026-07-06-plugin-evals-overview.md`._ `2026-07-06-plugin-evals-overview.md`._
## Goal ## Goal

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-06

View File

@ -0,0 +1,160 @@
# Design: add-os-status-plugin
## Context
cc-os ships four global plugins (os-vault, os-adr, os-orchestration, os-doc-hygiene), all in
one repo, maintained by one person. Each has per-project or per-environment preconditions;
today two plugins carry their own SessionStart reminders (os-adr existence check,
os-doc-hygiene staleness reminder) and two carry none (os-vault hub-note presence,
os-orchestration env-override detection). The WS3 plan
(`docs/plans/ws3-status-convention-plugin.md`) proposed a convention: per-plugin status checks
aggregated into one banner.
Two 2026-07-06 perspective reviews (perspectives:simplifier, perspectives:implementer, both
sonnet) pressure-tested the plan's central tension — where check code lives. Their verified
findings shape this design:
- Per-project code copies (the plan's Option A) are the proven failure class: the 2026-07-04
stale-cache incident was copy-drift.
- A subprocess + JSON stdout enumeration protocol (Option B as sketched) is overbuilt for
three trivially cheap checks (path-exists, env read, settings grep) with one author.
- The three existing plugins already disagree on hook wiring: os-adr/os-doc-hygiene run from
the plugin cache via `hooks/hooks.json` + `${CLAUDE_PLUGIN_ROOT}`; os-vault is wired by
absolute path in `~/.claude/settings.json` straight to git source, and its cache copy is
already stale. Any design that enumerates other plugins' caches inherits this hazard.
- "Silent on ok" as a blanket rule would regress os-adr's present-state usage note (a
near-zero-token pointer to `/os-adr:create` + `/os-adr:find` whose wording was tuned by the
Eval B campaign and validated by Eval C).
- Migration must cut over hooks atomically per plugin or both old and new hooks fire
(double banner), and cache must be verified post-cutover (`claude plugin details`), not just
source-edited.
User decision 2026-07-06: adopt the in-process, convention-shaped design.
## Goals / Non-Goals
**Goals:**
- One aggregated SessionStart status pass over all cc-os precondition checks; at most one
short warning banner per session.
- Catch, deterministically and at session start, the two incident classes that motivated
this: missing per-project artifacts (ADR system, vault hub note) and silent model-forcing
env overrides (`CLAUDE_CODE_SUBAGENT_MODEL`).
- Preserve os-adr's tuned present-state usage note verbatim (no behavioral regression).
- Keep adding a check cheap: one function + one registry entry in one file, same repo.
- Model-free tests + invariants.md (same rigor as os-adr's hook; no behavioral eval — the
code is deterministic).
**Non-Goals:**
- No subprocess boundary, no JSON wire contract, no installed-plugin enumeration (revisit
only if a future check needs isolation: slow, network-touching, or conflicting deps).
- No `context` session-injection field (no consumer yet; injection economics unexamined).
- No consolidation of `.os-adr/` / `.dochygiene/` state dirs (works today, separable).
- No conversion of os-doc-hygiene's reminder in this change (follow-up; keeps migration
surface to one plugin).
- No cache-freshness "collection manager" duties (Phase 2, separate change).
- No fixing of os-vault's absolute-path hook wiring here (documented hazard, not a blocker —
this design reads nothing from other plugins' caches).
## Decisions
### D1 — Code location: in-process functions in the `os-status` plugin
All checks are plain Python functions living in `plugins/os-status/hooks/checks.py`, imported
and called in a loop by a single SessionStart entry point. No per-plugin `status/check.py`,
no discovery.
- Why over Option A (project-local `.cc-os/status.d/*.py`): copies drift; per-project install
step; 2026-07-04 incident is this exact class.
- Why over Option B-as-sketched (per-plugin source + subprocess enumeration): three checks,
one author, one repo — enumeration adds a protocol (spawn, parse, timeout, error
normalization) with only three registrants; and enumeration-from-cache would silently run
os-vault's stale cache copy (implementer finding). In-process reads nothing from other
plugins' installs.
- Convention preserved as a seam: every check has the uniform signature
`check(ctx) -> CheckResult(status, message)` with `ctx` carrying project root + config.
Extraction to subprocess later is mechanical because the contract already exists as a
function boundary.
### D2 — Result semantics: `ok` / `note` / `warn` (three states, not two)
- `ok` → contributes nothing, silent.
- `note` → near-zero-token informational line, NOT subject to snooze/suppress; exists
precisely so os-adr's present-state usage note survives conversion unchanged (implementer
finding: blanket silent-on-ok is a regression).
- `warn` → aggregated into the single banner with the corrective action
("X missing — run /os-…:… to fix"); subject to snooze/suppress.
- A check that raises or exceeds its time budget is reported as `warn` with a generic
message; it never blocks the session and never suppresses other checks' results
(per-check try/except in the runner).
### D3 — Ship as a new `os-status` plugin, hooks.json-wired
New plugin `plugins/os-status/` with `hooks/hooks.json` using `${CLAUDE_PLUGIN_ROOT}`
(matching os-adr/os-doc-hygiene — the cache-correct pattern), thin `session_start.py` entry
point + deep modules (`checks.py`, `state.py`), mirroring os-vault's module style.
- Why a new plugin over piggybacking on os-orchestration (simplifier floated this):
os-orchestration's domain is session-orchestration wording; status checking is a distinct
domain and the naming convention (`os-[domain]`) favors a dedicated home. Name `os-status`
over `os-core`: narrower, honest about scope; "core/collection manager" duties are Phase 2
and can justify a rename or a second plugin then.
### D4 — State: `.cc-os/` per-project dir, state only
Snooze (once-per-day) and permanent suppress files live in `<project>/.cc-os/` (gitignored;
the check runner adds it to `.git/info/exclude` opportunistically or documents the gitignore
line). Never code. Existing `.os-adr/`, `.dochygiene/` untouched. Outside a git project, the
hook exits silently (os-adr precedent).
### D5 — Atomic os-adr cutover in this change
os-adr's SessionStart entry is deleted from its `hooks/hooks.json` in the same change that
os-status ships its equivalent check (existence → init/migrate suggestion with once-per-day
snooze honoring the existing `.os-adr/suppress`; presence → the verbatim usage note as a
`note`). Post-cutover verification: `bin/refresh-plugins`, then `claude plugin details
os-adr@local-plugins` must show the SessionStart hook gone from the resolved cache, then a
fresh-session smoke test shows exactly one emission.
### D6 — Initial check registry
1. `adr_system_present(ctx)` — port of `plugins/os-adr/hooks/session_start.py` logic,
including its `.os-adr/suppress` + once-per-day behavior and the Eval-B-tuned PRESENT_NOTE
wording verbatim.
2. `vault_hub_note_present(ctx)` — project has a hub note in the vault (lookup strategy:
match `project/<name>` facet tag or configured hub slug in
`~/Documents/SecondBrain`); missing → `warn` naming the corrective skill; present → `ok`
(no injection — see Non-Goals).
3. `subagent_model_env_override(ctx)``warn` if `CLAUDE_CODE_SUBAGENT_MODEL` is in the
process environment or in the `env` block of `~/.claude/settings.json`.
## Risks / Trade-offs
- [Adding a check means editing os-status, not the owning plugin] → Accepted: one author, one
repo; the uniform signature keeps a later extraction mechanical. Revisit at the first check
that needs isolation.
- [os-status must know about other plugins' domains (hub notes, ADR dirs)] → Accepted
coupling; checks are read-only path/env probes, not imports of other plugins' code.
- [Double emission during cutover if caches go stale] → D5's verify-the-cache step is a
mandatory task, not cleanup; the 2026-07-04 incident is the template for what happens
otherwise.
- [Hub-note lookup heuristic may mis-detect] → keep the matcher trivial (explicit config in
`.cc-os/config` wins over inference); a false `warn` is one banner line, suppressible.
- [SessionStart latency] → in-process, three cheap probes, one interpreter spawn; budget the
hook at 5s like os-adr's. No parallelism machinery needed at this scale.
## Migration Plan
1. Build os-status complete with tests green (no hook registered yet).
2. Register os-status in the local-plugins marketplace manifest, install, refresh, verify via
`claude plugin details`.
3. Cut over os-adr (D5) in one commit; refresh; verify cache; fresh-session smoke test.
4. Rollback = revert the commit + `bin/refresh-plugins` (restores os-adr's own hook).
## Open Questions
- Does the hub-note check also fire usefully in non-project cwds (e.g. `~`)? Current answer:
git-project-only, matching os-adr; revisit with real usage.
- os-vault's absolute-path hook wiring (stale cache copy) — normalize onto hooks.json in a
separate housekeeping change? Recommended but out of scope here.

View File

@ -0,0 +1,71 @@
# Proposal: add-os-status-plugin
## Why
Two real incidents show that cc-os plugins have per-project and per-environment preconditions
that nothing verifies: (1) the WS1 orchestration audit found `CLAUDE_CODE_SUBAGENT_MODEL=haiku`
had silently downgraded all 23 verified subagent spawns for weeks — the E1 canary proved this
is unobservable to the model at spawn time, so protection must be deterministic; (2) projects
routinely lack the artifacts plugins assume (no ADR system, no vault hub note), and each plugin
currently invents its own SessionStart reminder in isolation (os-adr, os-doc-hygiene) or has
none (os-vault hub notes, os-orchestration env checks). A single aggregated status check at
SessionStart closes this class of gap; WS2's eval work also depends on it (hub-note presence
becomes a checked artifact).
## What Changes
- New global plugin `os-status` (per the cc-os `os-[domain]` naming convention): one
SessionStart hook that runs all cc-os status checks **in-process** and emits at most one
short banner.
- Checks are plain Python functions in a shared module inside `os-status` — NOT per-plugin
subprocess scripts and NOT project-local copies. This was pressure-tested 2026-07-06 by
simplifier and implementer perspective reviews (see `design.md`): per-project copies are the
proven copy-drift failure class, and a subprocess/JSON enumeration protocol is overbuilt for
the current three checks. The function signature is convention-shaped (each check returns a
status + message), so extraction to per-plugin `status/check.py` enumeration later is
mechanical if a check ever needs isolation.
- Initial checks (Phase 1):
- `adr-system-present` — refactors os-adr's existing existence-check behavior into a
conforming check; **BREAKING (internal)**: os-adr's own SessionStart hook is removed in the
same change (atomic cutover, to avoid double banners).
- `vault-hub-note-present` — new: warns when the current project has no hub note in the
SecondBrain vault, naming the corrective skill.
- `subagent-model-env-override` — new: warns when `CLAUDE_CODE_SUBAGENT_MODEL` (or a
model-forcing equivalent) is set in the environment or `~/.claude/settings.json`. Would
have caught the WS1 Cluster 1 incident at SessionStart.
- Per-project state dir `.cc-os/` (gitignored) for snooze/suppress of warnings — state only,
never code. Existing `.os-adr/` and `.dochygiene/` dirs are left untouched (consolidation
deliberately deferred).
- Explicitly deferred to a later change: `context` injection field (no consumer yet),
state-dir consolidation, cache-freshness verification (Phase 2 "collection manager"),
subprocess/enumeration protocol.
## Capabilities
### New Capabilities
- `status-checks`: the in-process check contract (function signature, ok/warn/note semantics,
timeout/failure-isolation rules) and the three initial checks.
- `status-banner`: SessionStart aggregation — silence rules, single-banner output, per-project
snooze/suppress state in `.cc-os/`.
### Modified Capabilities
<!-- none — os-adr's hook behavior moves, but no existing openspec/specs/ capability covers it;
the os-adr change is captured under status-checks' migration requirements. -->
## Impact
- New: `plugins/os-status/` (hooks/hooks.json + session_start.py entry point, shared checks
module, model-free Python tests, invariants.md).
- Modified: `plugins/os-adr/hooks/hooks.json` + `hooks/session_start.py` (SessionStart entry
removed; usage-note behavior preserved via the new check's `note` semantics — the perspective
review flagged that a naive "silent on ok" would regress os-adr's present-state usage note).
- Unchanged: os-doc-hygiene's reminder (converted in a follow-up, not this change, to keep the
migration surface small); os-vault hooks (its wiring inconsistency — absolute-path source
wiring vs cache — is documented in design.md as a known issue but not a blocker for the
in-process shape, which reads nothing from other plugins' caches).
- Requires `bin/refresh-plugins` + marketplace manifest entry + `claude plugin install` for the
new plugin; fresh-session smoke test.
- ADR to be added in `docs/memory-system/03-architecture-decisions.md` (status-check
convention + in-process code-location decision).

View File

@ -0,0 +1,51 @@
# status-banner — SessionStart aggregation, silence rules, snooze/suppress state
## ADDED Requirements
### Requirement: Single aggregated banner
The os-status SessionStart hook SHALL run all registered checks in-process and emit at most
one warning banner per session, containing one short line per `warn` result with its
corrective action. `note` results SHALL be emitted as individual near-zero-token lines outside
the banner. When every check returns `ok` (and no `note`s exist), the hook SHALL emit nothing.
#### Scenario: All checks ok
- **WHEN** every registered check returns `ok`
- **THEN** the hook produces no output
#### Scenario: Multiple warns aggregate
- **WHEN** two or more checks return `warn`
- **THEN** exactly one banner is emitted containing one line per warning
#### Scenario: Note passes through without banner
- **WHEN** one check returns `note` and all others return `ok`
- **THEN** the note line is emitted and no warning banner appears
### Requirement: Per-project snooze and suppress for warns
`warn` output SHALL be governed by per-project state in a gitignored `.cc-os/` directory:
each warning is emitted at most once per day (snooze), and a permanent suppress marker
silences a given check's warnings in that project until removed. `note` results SHALL NOT be
subject to snooze or suppress. Outside a git project, project-scoped checks SHALL be skipped
and no state SHALL be written.
#### Scenario: Warn snoozed same day
- **WHEN** a check's warning was already emitted today in this project
- **THEN** the warning is not emitted again and the banner omits it
#### Scenario: Permanent suppress
- **WHEN** `.cc-os/` contains a suppress marker for a check
- **THEN** that check's warnings are never emitted in this project while the marker exists
#### Scenario: Not a git project
- **WHEN** the session starts outside a git project
- **THEN** project-scoped checks are skipped, environment-scoped checks still run, and no
`.cc-os/` directory is created
### Requirement: Hook safety envelope
The SessionStart hook SHALL complete within its configured timeout, SHALL exit 0 even when
checks warn or fail internally, and SHALL never write to any file outside the project's
`.cc-os/` state directory.
#### Scenario: Internal failure stays safe
- **WHEN** the checks module itself fails to import or crash-loops
- **THEN** the hook exits 0 with either no output or a single generic warning line, and the
session starts normally

View File

@ -0,0 +1,79 @@
# status-checks — in-process check contract + initial checks
## ADDED Requirements
### Requirement: Uniform in-process check contract
Every status check SHALL be a Python function in the os-status plugin with the signature
`check(ctx) -> CheckResult`, where `ctx` provides the project root (or None outside a git
project) and per-project config, and `CheckResult` carries `status` (`ok` | `note` | `warn`)
and `message` (empty for `ok`). Checks SHALL be registered in a single registry list; adding
a check SHALL require only the function and one registry entry.
#### Scenario: Check returns ok
- **WHEN** a registered check finds its precondition satisfied and has no informational output
- **THEN** it returns `status: ok` and contributes no output to the session
#### Scenario: Check failure is isolated
- **WHEN** a registered check raises an exception or exceeds the per-check time budget
- **THEN** the runner records it as `warn` with a generic message naming the check, continues
running the remaining checks, and never blocks or fails the session
### Requirement: ADR system check preserves os-adr behavior
The `adr_system_present` check SHALL reproduce os-adr's SessionStart behavior: in a git
project with `docs/adr/` present it returns `note` with the existing Eval-B-tuned usage-note
wording verbatim; with `docs/adr/` absent it returns `warn` suggesting init/migrate at most
once per day, permanently silenced by `.os-adr/suppress`; outside a git project it returns
`ok`.
#### Scenario: ADR system present
- **WHEN** the session starts in a git project containing `docs/adr/`
- **THEN** the check returns `note` whose message is byte-identical to os-adr's current
PRESENT_NOTE wording
#### Scenario: ADR system absent, not suppressed
- **WHEN** the session starts in a git project without `docs/adr/` and no `.os-adr/suppress`
exists and no suggestion was emitted today
- **THEN** the check returns `warn` naming `/os-adr:init` and `/os-adr:migrate`
#### Scenario: ADR suggestion suppressed
- **WHEN** `.os-adr/suppress` exists in the project
- **THEN** the check returns `ok`
### Requirement: Vault hub-note check
The `vault_hub_note_present` check SHALL determine whether the current project has a hub note
in the SecondBrain vault, preferring an explicit hub slug from per-project config over
inference by `project/<name>` facet tag. Missing hub note in a git project SHALL yield `warn`
naming the corrective action; present SHALL yield `ok` with no context injection.
#### Scenario: Hub note missing
- **WHEN** the session starts in a git project with no matching hub note in the vault
- **THEN** the check returns `warn` telling the user which skill to run to create one
#### Scenario: Hub note present
- **WHEN** a hub note matching the project exists in the vault
- **THEN** the check returns `ok` and injects nothing
### Requirement: Subagent model env-override check
The `subagent_model_env_override` check SHALL return `warn` when `CLAUDE_CODE_SUBAGENT_MODEL`
is set in the process environment or present in the `env` block of `~/.claude/settings.json`,
identifying where it was found; otherwise `ok`. This check SHALL run regardless of whether the
cwd is a git project.
#### Scenario: Override set in settings.json
- **WHEN** `~/.claude/settings.json` contains `CLAUDE_CODE_SUBAGENT_MODEL` in its `env` block
- **THEN** the check returns `warn` naming the variable, its value, and the file it was found in
#### Scenario: No override
- **WHEN** the variable is absent from both the environment and settings.json
- **THEN** the check returns `ok`
### Requirement: os-adr SessionStart hook removed atomically
In the same change that ships the `adr_system_present` check, os-adr's own SessionStart hook
entry SHALL be removed from `plugins/os-adr/hooks/hooks.json`, and the cutover SHALL be
verified against the refreshed plugin cache (not only source), such that exactly one component
emits the ADR status per session.
#### Scenario: No double emission after cutover
- **WHEN** a fresh session starts after `bin/refresh-plugins` with os-status and os-adr both
installed
- **THEN** the ADR usage note or suggestion appears exactly once

View File

@ -0,0 +1,60 @@
# Tasks: add-os-status-plugin
## 1. Plugin scaffold
- [x] 1.1 Create `plugins/os-status/` with `.claude-plugin/plugin.json` (name `os-status`, per
naming convention; no `name:` in any SKILL.md — no skills in this change anyway)
- [x] 1.2 Add `hooks/hooks.json` registering SessionStart → `session_start.py` via
`${CLAUDE_PLUGIN_ROOT}` (5s timeout, matching os-adr's pattern)
- [x] 1.3 Write shared modules in Sandi-Metz-lean Python matching os-vault's style:
`hooks/checks.py` (CheckResult, registry, the three checks), `hooks/state.py`
(`.cc-os/` snooze/suppress, git-project detection), thin `hooks/session_start.py`
entry point (run checks, aggregate, print)
## 2. Checks
- [x] 2.1 `subagent_model_env_override`: env var + `~/.claude/settings.json` `env` block scan;
runs outside git projects too
- [x] 2.2 `adr_system_present`: port logic from `plugins/os-adr/hooks/session_start.py`
PRESENT_NOTE wording byte-identical (copy, don't paraphrase); absent-path once-per-day
snooze honoring existing `.os-adr/suppress`
- [x] 2.3 `vault_hub_note_present`: config-first hub lookup (`.cc-os/config` hub slug), else
`project/<name>` facet-tag scan of the vault; missing → warn naming the corrective skill
## 3. Tests + invariants
- [x] 3.1 `tests/hook_test.py`-style model-free Python tests: contract (ok/note/warn routing,
failure isolation, exit-0 envelope), each check against fixture dirs/fake settings.json,
snooze/suppress state transitions, non-git-cwd behavior
- [x] 3.2 `invariants.md`: single-banner, note-not-suppressed, exit-0-always,
state-only-in-.cc-os, PRESENT_NOTE byte-identical to os-adr's tuned wording
## 4. os-adr cutover (atomic)
- [x] 4.1 Remove the SessionStart entry from `plugins/os-adr/hooks/hooks.json` (keep the
script file until archive; it is the wording source of record for 2.2)
- [x] 4.2 Confirm os-adr's tests still pass (hook_test.py may need pruning of
SessionStart-registration assertions)
## 5. Install + verify
- [x] 5.1 Add `os-status` to `~/.claude/plugins/.claude-plugin/marketplace.json`;
`claude plugin marketplace update local-plugins`; `claude plugin install
os-status@local-plugins`
- [x] 5.2 `bin/refresh-plugins`; `claude plugin details os-status@local-plugins` (hook
registered) and `claude plugin details os-adr@local-plugins` (SessionStart gone from
resolved cache)
- [x] 5.3 Fresh-session smoke test in cc-os (expect: ADR note once, hub-note ok once hub
exists, no env warn) and in a project without `docs/adr/` (expect: one banner)
- [x] 5.4 Regression canary: export `CLAUDE_CODE_SUBAGENT_MODEL=haiku` in a test shell,
fresh session, expect the override warn
## 6. Documentation
- [x] 6.1 ADR in `docs/memory-system/03-architecture-decisions.md`: status-check convention,
in-process code-location decision (Options A/B rejected with the perspective-review
reasoning), deferred items (context field, state-dir consolidation, Phase 2)
- [x] 6.2 Update cc-os `CLAUDE.md`: Implemented Components entry for os-status; note os-adr's
SessionStart behavior moved
- [x] 6.3 Mark WS3 plan doc (`docs/plans/ws3-status-convention-plugin.md`) as superseded by
this change (pointer, status line)

View File

@ -1,16 +1,3 @@
{ {
"hooks": { "hooks": {}
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py",
"timeout": 5
}
]
}
]
}
} }

View File

@ -1,6 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""session_start.py — deterministic SessionStart existence check for os-adr. """session_start.py — deterministic SessionStart existence check for os-adr.
NO LONGER WIRED (2026-07-06): the SessionStart entry moved to the os-status
plugin's aggregated check (`adr_system_present` in os-status/hooks/checks.py,
which copies PRESENT_NOTE/ABSENT_NOTE byte-identically). This file stays as
the wording source of record until the add-os-status-plugin change archives.
Pure function of the project's filesystem state; no model, no network, no Ruby. Pure function of the project's filesystem state; no model, no network, no Ruby.
present (docs/adr/ + index) -> additionalContext note naming /os-adr:create present (docs/adr/ + index) -> additionalContext note naming /os-adr:create

View File

@ -0,0 +1,5 @@
{
"name": "os-status",
"version": "0.1.0",
"description": "Aggregated deterministic SessionStart status checks for the cc-os plugin family: per-project artifacts (ADR system, vault hub note) and environment hazards (subagent model env override). At most one banner per session; snooze/suppress state in .cc-os/."
}

View File

@ -0,0 +1,174 @@
"""Status checks for the os-status plugin (deep module).
Each check is a pure function of Ctx returning a CheckResult; REGISTRY is the
single list the SessionStart runner iterates. Adding a check is one function
plus one Check entry here no discovery, no subprocess protocol (design D1).
PRESENT_NOTE and ABSENT_NOTE are copied byte-identical from os-adr's
hooks/session_start.py, the wording source of record (Eval-B-tuned; see
invariants.md). Do not paraphrase them.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
PRESENT_NOTE = (
"[os-adr] This project records architecture decisions in docs/adr/ "
"(index: docs/adr/README.md). WHEN your task involves making an "
"architecture-level choice, or changing/replacing an approach that "
"already exists in this codebase (persistence, protocols, libraries, "
"conventions, error handling) -> FIRST run /os-adr:find to check whether "
"a decision already covers it. Mechanical rule: BEFORE your first edit "
"to any existing source or config file -> run /os-adr:find on the paths "
"you are about to touch (one cheap deterministic CLI call; additions "
"count — a new method can bypass a decided constraint). WHEN "
"you make such a choice -> record it "
"with /os-adr:create. If find shows an Accepted ADR covering the approach "
"you are changing, you are reversing a recorded decision: the task is "
"NOT COMPLETE until the superseding ADR is created with /os-adr:create "
"(supersedes: NNNN) — code changed but ADR unrecorded means the decision "
"history now asserts the opposite of what the code does."
)
ABSENT_NOTE = (
"[os-adr] No ADR system in this project. /os-adr:init sets one up; "
"/os-adr:migrate converts existing decision docs. "
"(touch .os-adr/suppress to silence this permanently)"
)
ENV_VAR = "CLAUDE_CODE_SUBAGENT_MODEL"
DEFAULT_VAULT_PATH = "~/Documents/SecondBrain"
VAULT_SKIP_DIRS = {"graphify-out", "_templates", ".graphify"}
@dataclass(frozen=True)
class Ctx:
"""Everything a check may read: project root (None outside a git
project), the user's settings.json path, the vault path, per-project
.cc-os/config, and the process environment."""
project_root: Optional[Path]
settings_path: Path
vault_path: Path
config: dict
environ: dict
@dataclass(frozen=True)
class CheckResult:
status: str # "ok" | "note" | "warn"
message: str = ""
OK = CheckResult("ok")
def note(message: str) -> CheckResult:
return CheckResult("note", message)
def warn(message: str) -> CheckResult:
return CheckResult("warn", message)
def _settings_env(path: Path) -> dict:
try:
env = json.loads(path.read_text()).get("env") or {}
return env if isinstance(env, dict) else {}
except Exception:
return {}
def subagent_model_env_override(ctx: Ctx) -> CheckResult:
"""Warn when CLAUDE_CODE_SUBAGENT_MODEL silently forces every subagent
to one model (the WS1 Cluster 1 incident). Runs outside git projects too."""
found = []
if ENV_VAR in ctx.environ:
found.append(f"the process environment (={ctx.environ[ENV_VAR]})")
settings_env = _settings_env(ctx.settings_path)
if ENV_VAR in settings_env:
found.append(f"the env block of {ctx.settings_path} (={settings_env[ENV_VAR]})")
if not found:
return OK
return warn(
f"{ENV_VAR} is set in " + " and in ".join(found)
+ " — every subagent spawn is silently forced to that model, overriding"
" explicit Agent model params. Remove it unless intended."
)
def adr_system_present(ctx: Ctx) -> CheckResult:
"""Port of os-adr's SessionStart behavior: present -> the verbatim usage
note; absent -> init/migrate suggestion (runner snoozes it once per day),
honoring the legacy .os-adr/suppress marker."""
root = ctx.project_root
adr_dir = root / "docs" / "adr"
if adr_dir.is_dir() and (adr_dir / "README.md").is_file():
return note(PRESENT_NOTE)
if (root / ".os-adr" / "suppress").exists():
return OK
return warn(ABSENT_NOTE)
def _vault_notes(vault: Path):
for path in vault.rglob("*.md"):
parts = path.relative_to(vault).parts
if any(p.startswith(".") or p in VAULT_SKIP_DIRS for p in parts[:-1]):
continue
yield path
def _is_hub_for(text: str, project: str) -> bool:
return bool(
re.search(r"(?m)^\s*-\s*type/hub\s*$", text)
and re.search(rf"(?m)^\s*-\s*project/{re.escape(project)}\s*$", text)
)
def vault_hub_note_present(ctx: Ctx) -> CheckResult:
"""An explicit hub slug in .cc-os/config wins; otherwise infer by scanning
vault frontmatter for type/hub + project/<dirname> facet tags."""
vault = ctx.vault_path
if not vault.is_dir():
return OK # no vault on this machine — nothing to check
slug = str(ctx.config.get("hub", "")).strip()
if slug:
if (vault / f"{slug}.md").is_file():
return OK
return warn(
f".cc-os/config names hub note '{slug}' but {slug}.md does not"
f" exist in the vault ({vault}) — fix the slug or create the note"
" with /os-vault:write."
)
project = ctx.project_root.name
for path in _vault_notes(vault):
try:
head = path.read_text(errors="replace")[:2048]
except Exception:
continue
if _is_hub_for(head, project):
return OK
return warn(
f"No vault hub note found for project '{project}' — run /os-vault:write"
f" to create one (tags: type/hub + project/{project}), or set"
" 'hub = <slug>' in .cc-os/config if one exists under another name."
)
@dataclass(frozen=True)
class Check:
name: str
fn: Callable
project_scoped: bool
REGISTRY = [
Check("subagent-model-env-override", subagent_model_env_override, project_scoped=False),
Check("adr-system-present", adr_system_present, project_scoped=True),
Check("vault-hub-note-present", vault_hub_note_present, project_scoped=True),
]

View File

@ -0,0 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py",
"timeout": 5
}
]
}
]
}
}

View File

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""session_start.py — aggregated deterministic status pass for the cc-os
plugin family. Runs every registered check in-process (checks.REGISTRY) and
emits at most one warning banner plus any near-zero-token notes.
ok -> silent
note -> additionalContext line (never snoozed or suppressed)
warn -> one line in the single systemMessage banner; snoozed once per day
and permanently suppressible via .cc-os/suppress-<check>
Outside a git project only environment-scoped checks run and no state is
written. Always exits 0 the hook must never block a session.
"""
from __future__ import annotations
import json
import os
import signal
import sys
from datetime import date
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from checks import DEFAULT_VAULT_PATH, REGISTRY, CheckResult, Ctx # noqa: E402
from state import StateDir, find_project_root, read_config # noqa: E402
CHECK_BUDGET_SECONDS = 2
BANNER_HEADER = "[os-status] Session preconditions need attention:"
def run_isolated(check, ctx) -> CheckResult:
"""Failure isolation: an exception or a blown time budget becomes a
generic warn naming the check; the remaining checks keep running."""
def timed_out(signum, frame):
raise TimeoutError
alarmed = hasattr(signal, "SIGALRM")
if alarmed:
previous = signal.signal(signal.SIGALRM, timed_out)
signal.alarm(CHECK_BUDGET_SECONDS)
try:
result = check.fn(ctx)
if not isinstance(result, CheckResult):
raise TypeError("check returned a non-CheckResult")
return result
except Exception:
return CheckResult("warn", f"status check '{check.name}' failed to run")
finally:
if alarmed:
signal.alarm(0)
signal.signal(signal.SIGALRM, previous)
def run(ctx: Ctx, state, today: date, out) -> None:
notes, warns = [], []
for check in REGISTRY:
if check.project_scoped and ctx.project_root is None:
continue
result = run_isolated(check, ctx)
if result.status == "note":
notes.append(result.message)
elif result.status == "warn":
if state and (
state.suppressed(check.name) or state.snoozed(check.name, today)
):
continue
warns.append((check.name, result.message))
payload = {}
if warns:
lines = [BANNER_HEADER]
lines.extend(f" - {message}" for _, message in warns)
if state:
names = ", ".join(name for name, _ in warns)
lines.append(
f" (silence one permanently: touch .cc-os/suppress-<name>; warned: {names})"
)
for name, _ in warns:
state.stamp(name, today)
payload["systemMessage"] = "\n".join(lines)
if notes:
payload["hookSpecificOutput"] = {
"hookEventName": "SessionStart",
"additionalContext": "\n".join(notes),
}
if payload:
out.write(json.dumps(payload))
def main() -> int:
try:
root = find_project_root(Path.cwd())
config = read_config(root) if root else {}
ctx = Ctx(
project_root=root,
settings_path=Path.home() / ".claude" / "settings.json",
vault_path=Path(
os.path.expanduser(config.get("vault_path", DEFAULT_VAULT_PATH))
),
config=config,
environ=dict(os.environ),
)
state = StateDir(root) if root else None
run(ctx, state, date.today(), sys.stdout)
except Exception:
pass # never block a session
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,59 @@
"""Per-project state for os-status (deep module).
Owns the gitignored .cc-os/ directory contract: key=value config, per-check
once-per-day snooze stamps (snooze-<check>), and permanent suppress markers
(suppress-<check>). State only, never code; nothing here writes outside
.cc-os/. Fail-open throughout: unreadable state reads as unset.
"""
from __future__ import annotations
from datetime import date
from pathlib import Path
from typing import Optional
def find_project_root(cwd: Path) -> Optional[Path]:
"""Nearest ancestor (including cwd) containing .git, else None."""
for candidate in [cwd, *cwd.parents]:
if (candidate / ".git").exists():
return candidate
return None
def read_config(root: Path) -> dict:
"""Parse .cc-os/config as '#'-commented key = value lines."""
config = {}
try:
text = (root / ".cc-os" / "config").read_text()
except Exception:
return config
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
config[key.strip()] = value.strip()
return config
class StateDir:
"""The gitignored per-project .cc-os/ state directory."""
def __init__(self, root: Path) -> None:
self._dir = root / ".cc-os"
def suppressed(self, check: str) -> bool:
return (self._dir / f"suppress-{check}").exists()
def snoozed(self, check: str, today: date) -> bool:
try:
stamped = date.fromisoformat(
(self._dir / f"snooze-{check}").read_text().strip()
)
except Exception:
return False
return stamped == today
def stamp(self, check: str, today: date) -> None:
self._dir.mkdir(exist_ok=True)
(self._dir / f"snooze-{check}").write_text(today.isoformat())

View File

@ -0,0 +1,30 @@
# os-status behavioral invariants
Reversion protection for the SessionStart status hook. Any change that breaks one of these
is a regression, not a refactor. Enforced by `tests/hook_test.py` (model-free;
`python3 tests/hook_test.py`).
1. **Single banner.** At most one `systemMessage` warning banner per session, containing one
short line per surviving `warn`. Multiple warns never produce multiple banners.
2. **Notes are never suppressed.** `note` results bypass snooze and suppress entirely and are
emitted as `additionalContext`, not inside the banner. This exists so os-adr's
Eval-B-tuned usage note survives conversion unchanged (design D2).
3. **Exit 0, always.** The hook exits 0 on every path — checks warning, checks raising, the
checks module failing to import, malformed settings.json, no git project. A session is
never blocked by os-status.
4. **Failure isolation.** A check that raises or blows its time budget becomes a generic
`warn` naming the check; it never hides other checks' results and never leaks exception
text into the banner.
5. **State only in `.cc-os/`.** The hook writes nothing outside the project's `.cc-os/`
directory, and writes nothing at all outside a git project. `.cc-os/` holds state
(config, `snooze-<check>`, `suppress-<check>`), never code. Add `.cc-os/` to the
project's `.gitignore`.
6. **PRESENT_NOTE / ABSENT_NOTE byte-identical to os-adr.** `checks.py` carries verbatim
copies of the wording in `plugins/os-adr/hooks/session_start.py` (the wording source of
record, tuned by the Eval B campaign and validated by Eval C). Never paraphrase; if the
wording changes there, copy it here byte-for-byte (the test compares the two files).
7. **Legacy suppress honored.** The `adr-system-present` check returns `ok` when
`.os-adr/suppress` exists, so projects silenced under the old os-adr hook stay silent.
8. **Convention-shaped seam.** Every check keeps the uniform signature
`check(ctx) -> CheckResult(status, message)` with registration in `checks.REGISTRY`
only — so later extraction to per-plugin subprocess checks stays mechanical (design D1).

View File

@ -0,0 +1,403 @@
"""Tests for the os-status hooks. Run: python3 tests/hook_test.py"""
import importlib.util
import io
import json
import os
import subprocess
import sys
import unittest
from datetime import date
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
PLUGIN_ROOT = Path(__file__).resolve().parent.parent
REPO_PLUGINS = PLUGIN_ROOT.parent
sys.path.insert(0, str(PLUGIN_ROOT / "hooks"))
import checks # noqa: E402
import session_start # noqa: E402
from checks import ( # noqa: E402
ABSENT_NOTE,
ENV_VAR,
PRESENT_NOTE,
Check,
CheckResult,
Ctx,
adr_system_present,
subagent_model_env_override,
vault_hub_note_present,
)
from state import StateDir, find_project_root, read_config # noqa: E402
TODAY = date(2026, 7, 6)
YESTERDAY = date(2026, 7, 5)
def make_ctx(
project_root=None,
settings_path=Path("/nonexistent/settings.json"),
vault_path=Path("/nonexistent/vault"),
config=None,
environ=None,
):
return Ctx(
project_root=project_root,
settings_path=settings_path,
vault_path=vault_path,
config=config or {},
environ=environ or {},
)
def git_project(tmp):
root = Path(tmp)
(root / ".git").mkdir(parents=True)
return root
class EnvOverrideCheckTest(unittest.TestCase):
def test_ok_when_unset_everywhere(self):
self.assertEqual("ok", subagent_model_env_override(make_ctx()).status)
def test_warns_on_process_environment(self):
result = subagent_model_env_override(make_ctx(environ={ENV_VAR: "haiku"}))
self.assertEqual("warn", result.status)
self.assertIn(ENV_VAR, result.message)
self.assertIn("haiku", result.message)
def test_warns_on_settings_env_block_naming_the_file(self):
with TemporaryDirectory() as tmp:
settings = Path(tmp) / "settings.json"
settings.write_text(json.dumps({"env": {ENV_VAR: "haiku"}}))
result = subagent_model_env_override(make_ctx(settings_path=settings))
self.assertEqual("warn", result.status)
self.assertIn(str(settings), result.message)
self.assertIn("haiku", result.message)
def test_names_both_sources_when_both_set(self):
with TemporaryDirectory() as tmp:
settings = Path(tmp) / "settings.json"
settings.write_text(json.dumps({"env": {ENV_VAR: "haiku"}}))
result = subagent_model_env_override(
make_ctx(settings_path=settings, environ={ENV_VAR: "sonnet"})
)
self.assertIn("environment", result.message)
self.assertIn(str(settings), result.message)
def test_malformed_settings_is_ignored(self):
with TemporaryDirectory() as tmp:
settings = Path(tmp) / "settings.json"
settings.write_text("{not json")
self.assertEqual(
"ok", subagent_model_env_override(make_ctx(settings_path=settings)).status
)
class AdrCheckTest(unittest.TestCase):
def test_present_notes_verbatim_wording(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
(root / "docs" / "adr").mkdir(parents=True)
(root / "docs" / "adr" / "README.md").write_text("index")
result = adr_system_present(make_ctx(project_root=root))
self.assertEqual(CheckResult("note", PRESENT_NOTE), result)
def test_dir_without_index_counts_as_absent(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
(root / "docs" / "adr").mkdir(parents=True)
result = adr_system_present(make_ctx(project_root=root))
self.assertEqual(CheckResult("warn", ABSENT_NOTE), result)
def test_absent_warns_with_init_and_migrate(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
result = adr_system_present(make_ctx(project_root=root))
self.assertEqual("warn", result.status)
self.assertIn("/os-adr:init", result.message)
self.assertIn("/os-adr:migrate", result.message)
def test_legacy_os_adr_suppress_is_honored(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
(root / ".os-adr").mkdir()
(root / ".os-adr" / "suppress").write_text("")
self.assertEqual("ok", adr_system_present(make_ctx(project_root=root)).status)
class HubNoteCheckTest(unittest.TestCase):
def vault_with(self, tmp, name, body):
vault = Path(tmp) / "vault"
vault.mkdir(exist_ok=True)
(vault / name).write_text(body)
return vault
def hub_note(self, project):
return (
f"---\ntype: hub\ntags:\n - type/hub\n - project/{project}\n---\n# hub\n"
)
def test_missing_vault_is_silent(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
ctx = make_ctx(project_root=root, vault_path=Path(tmp) / "no-vault")
self.assertEqual("ok", vault_hub_note_present(ctx).status)
def test_facet_tag_scan_finds_hub(self):
with TemporaryDirectory() as tmp:
root = git_project(Path(tmp) / "myproj")
vault = self.vault_with(tmp, "myproj-hub.md", self.hub_note("myproj"))
ctx = make_ctx(project_root=root, vault_path=vault)
self.assertEqual("ok", vault_hub_note_present(ctx).status)
def test_no_hub_warns_naming_corrective_skill(self):
with TemporaryDirectory() as tmp:
root = git_project(Path(tmp) / "myproj")
vault = self.vault_with(tmp, "other.md", self.hub_note("otherproj"))
result = vault_hub_note_present(make_ctx(project_root=root, vault_path=vault))
self.assertEqual("warn", result.status)
self.assertIn("/os-vault:write", result.message)
self.assertIn("myproj", result.message)
def test_project_name_is_not_prefix_matched(self):
with TemporaryDirectory() as tmp:
root = git_project(Path(tmp) / "myproj")
vault = self.vault_with(tmp, "other.md", self.hub_note("myproj2"))
result = vault_hub_note_present(make_ctx(project_root=root, vault_path=vault))
self.assertEqual("warn", result.status)
def test_config_slug_wins_over_inference(self):
with TemporaryDirectory() as tmp:
root = git_project(Path(tmp) / "myproj")
vault = self.vault_with(tmp, "custom-hub.md", "any content")
ctx = make_ctx(
project_root=root, vault_path=vault, config={"hub": "custom-hub"}
)
self.assertEqual("ok", vault_hub_note_present(ctx).status)
def test_config_slug_pointing_nowhere_warns(self):
with TemporaryDirectory() as tmp:
root = git_project(Path(tmp) / "myproj")
vault = self.vault_with(tmp, "other.md", "content")
ctx = make_ctx(project_root=root, vault_path=vault, config={"hub": "ghost"})
result = vault_hub_note_present(ctx)
self.assertEqual("warn", result.status)
self.assertIn("ghost", result.message)
def test_templates_and_graphify_out_are_skipped(self):
with TemporaryDirectory() as tmp:
root = git_project(Path(tmp) / "myproj")
vault = Path(tmp) / "vault"
(vault / "_templates").mkdir(parents=True)
(vault / "_templates" / "t.md").write_text(self.hub_note("myproj"))
(vault / "graphify-out").mkdir()
(vault / "graphify-out" / "g.md").write_text(self.hub_note("myproj"))
result = vault_hub_note_present(make_ctx(project_root=root, vault_path=vault))
self.assertEqual("warn", result.status)
class RunnerTest(unittest.TestCase):
"""run() contract: routing, aggregation, snooze/suppress, isolation."""
def run_with(self, registry, ctx, state=None, today=TODAY):
out = io.StringIO()
with mock.patch.object(session_start, "REGISTRY", registry):
session_start.run(ctx, state, today, out)
raw = out.getvalue()
return json.loads(raw) if raw else {}
def check(self, name, result, project_scoped=False):
return Check(name, lambda ctx: result, project_scoped)
def test_all_ok_emits_nothing(self):
registry = [self.check("a", CheckResult("ok")), self.check("b", CheckResult("ok"))]
self.assertEqual({}, self.run_with(registry, make_ctx()))
def test_note_passes_through_as_additional_context_without_banner(self):
registry = [self.check("a", CheckResult("note", "hello"))]
payload = self.run_with(registry, make_ctx())
self.assertEqual("hello", payload["hookSpecificOutput"]["additionalContext"])
self.assertNotIn("systemMessage", payload)
def test_multiple_warns_aggregate_into_one_banner(self):
registry = [
self.check("a", CheckResult("warn", "first problem")),
self.check("b", CheckResult("warn", "second problem")),
]
payload = self.run_with(registry, make_ctx())
banner = payload["systemMessage"]
self.assertIn("first problem", banner)
self.assertIn("second problem", banner)
self.assertEqual(1, banner.count(session_start.BANNER_HEADER))
def test_raising_check_becomes_generic_warn_and_others_still_run(self):
def boom(ctx):
raise RuntimeError("kaboom")
registry = [
Check("broken", boom, False),
self.check("healthy", CheckResult("note", "still here")),
]
payload = self.run_with(registry, make_ctx())
self.assertIn("broken", payload["systemMessage"])
self.assertNotIn("kaboom", payload["systemMessage"])
self.assertIn("still here", payload["hookSpecificOutput"]["additionalContext"])
def test_non_checkresult_return_becomes_generic_warn(self):
registry = [Check("weird", lambda ctx: "nope", False)]
payload = self.run_with(registry, make_ctx())
self.assertIn("weird", payload["systemMessage"])
def test_project_scoped_checks_skipped_outside_git_project(self):
registry = [self.check("proj", CheckResult("warn", "boom"), project_scoped=True)]
self.assertEqual({}, self.run_with(registry, make_ctx(project_root=None)))
def test_warn_stamps_snooze_and_second_run_same_day_is_silent(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
state = StateDir(root)
registry = [self.check("a", CheckResult("warn", "boom"))]
ctx = make_ctx(project_root=root)
first = self.run_with(registry, ctx, state)
self.assertIn("boom", first["systemMessage"])
self.assertTrue((root / ".cc-os" / "snooze-a").exists())
self.assertEqual({}, self.run_with(registry, ctx, state))
def test_next_day_warns_again(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
state = StateDir(root)
registry = [self.check("a", CheckResult("warn", "boom"))]
ctx = make_ctx(project_root=root)
self.run_with(registry, ctx, state, today=YESTERDAY)
self.assertIn("boom", self.run_with(registry, ctx, state)["systemMessage"])
def test_suppress_marker_silences_permanently(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
(root / ".cc-os").mkdir()
(root / ".cc-os" / "suppress-a").write_text("")
state = StateDir(root)
registry = [self.check("a", CheckResult("warn", "boom"))]
self.assertEqual({}, self.run_with(registry, make_ctx(project_root=root), state))
def test_notes_are_never_snoozed_or_suppressed(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
(root / ".cc-os").mkdir()
(root / ".cc-os" / "suppress-a").write_text("")
(root / ".cc-os" / "snooze-a").write_text(TODAY.isoformat())
state = StateDir(root)
registry = [self.check("a", CheckResult("note", "always"))]
payload = self.run_with(registry, make_ctx(project_root=root), state)
self.assertEqual("always", payload["hookSpecificOutput"]["additionalContext"])
def test_snoozed_warn_does_not_suppress_other_warns(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
(root / ".cc-os").mkdir()
(root / ".cc-os" / "snooze-a").write_text(TODAY.isoformat())
state = StateDir(root)
registry = [
self.check("a", CheckResult("warn", "old news")),
self.check("b", CheckResult("warn", "new problem")),
]
banner = self.run_with(registry, make_ctx(project_root=root), state)["systemMessage"]
self.assertNotIn("old news", banner)
self.assertIn("new problem", banner)
class StateTest(unittest.TestCase):
def test_read_config_parses_key_value_and_skips_comments(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".cc-os").mkdir()
(root / ".cc-os" / "config").write_text(
"# comment\nhub = my-hub\nvault_path=/somewhere\nbadline\n"
)
self.assertEqual(
{"hub": "my-hub", "vault_path": "/somewhere"}, read_config(root)
)
def test_read_config_missing_is_empty(self):
with TemporaryDirectory() as tmp:
self.assertEqual({}, read_config(Path(tmp)))
def test_corrupt_snooze_stamp_reads_as_unset(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".cc-os").mkdir()
(root / ".cc-os" / "snooze-a").write_text("not-a-date")
self.assertFalse(StateDir(root).snoozed("a", TODAY))
def test_find_project_root_walks_up(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".git").mkdir()
nested = root / "a" / "b"
nested.mkdir(parents=True)
self.assertEqual(root, find_project_root(nested))
def test_find_project_root_none_outside_git(self):
with TemporaryDirectory() as tmp:
self.assertIsNone(find_project_root(Path(tmp)))
class WordingInvariantTest(unittest.TestCase):
"""PRESENT_NOTE / ABSENT_NOTE must stay byte-identical to os-adr's
hooks/session_start.py, the wording source of record (invariants.md)."""
def os_adr_module(self):
source = REPO_PLUGINS / "os-adr" / "hooks" / "session_start.py"
spec = importlib.util.spec_from_file_location("os_adr_session_start", source)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_notes_byte_identical_to_os_adr(self):
os_adr = self.os_adr_module()
self.assertEqual(os_adr.PRESENT_NOTE, PRESENT_NOTE)
self.assertEqual(os_adr.ABSENT_NOTE, ABSENT_NOTE)
class ExitZeroEnvelopeTest(unittest.TestCase):
def test_main_returns_zero_even_when_run_raises(self):
with mock.patch.object(session_start, "run", side_effect=RuntimeError):
self.assertEqual(0, session_start.main())
def test_subprocess_in_non_git_cwd_exits_zero_silently(self):
with TemporaryDirectory() as tmp:
env = {k: v for k, v in os.environ.items() if k != ENV_VAR}
env["HOME"] = tmp # no settings.json, no vault
proc = subprocess.run(
[sys.executable, str(PLUGIN_ROOT / "hooks" / "session_start.py")],
cwd=tmp,
env=env,
capture_output=True,
text=True,
)
self.assertEqual(0, proc.returncode)
self.assertEqual("", proc.stdout)
self.assertFalse((Path(tmp) / ".cc-os").exists())
def test_subprocess_with_env_override_warns_but_exits_zero(self):
with TemporaryDirectory() as tmp:
env = dict(os.environ)
env["HOME"] = tmp
env[ENV_VAR] = "haiku"
proc = subprocess.run(
[sys.executable, str(PLUGIN_ROOT / "hooks" / "session_start.py")],
cwd=tmp,
env=env,
capture_output=True,
text=True,
)
self.assertEqual(0, proc.returncode)
payload = json.loads(proc.stdout)
self.assertIn(ENV_VAR, payload["systemMessage"])
if __name__ == "__main__":
unittest.main()