From 1d1e7207c06e40bbe98e66cd77352b772017f7a7 Mon Sep 17 00:00:00 2001 From: jared Date: Tue, 7 Jul 2026 12:46:10 -0400 Subject: [PATCH] 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 --- .gitignore | 2 + .../03-architecture-decisions.md | 14 + docs/plans/ws3-status-convention-plugin.md | 6 +- .../add-os-status-plugin/.openspec.yaml | 2 + .../changes/add-os-status-plugin/design.md | 160 +++++++ .../changes/add-os-status-plugin/proposal.md | 71 +++ .../specs/status-banner/spec.md | 51 +++ .../specs/status-checks/spec.md | 79 ++++ .../changes/add-os-status-plugin/tasks.md | 60 +++ plugins/os-adr/hooks/hooks.json | 15 +- plugins/os-adr/hooks/session_start.py | 5 + plugins/os-status/.claude-plugin/plugin.json | 5 + plugins/os-status/hooks/checks.py | 174 ++++++++ plugins/os-status/hooks/hooks.json | 16 + plugins/os-status/hooks/session_start.py | 113 +++++ plugins/os-status/hooks/state.py | 59 +++ plugins/os-status/invariants.md | 30 ++ plugins/os-status/tests/hook_test.py | 403 ++++++++++++++++++ 18 files changed, 1250 insertions(+), 15 deletions(-) create mode 100644 openspec/changes/add-os-status-plugin/.openspec.yaml create mode 100644 openspec/changes/add-os-status-plugin/design.md create mode 100644 openspec/changes/add-os-status-plugin/proposal.md create mode 100644 openspec/changes/add-os-status-plugin/specs/status-banner/spec.md create mode 100644 openspec/changes/add-os-status-plugin/specs/status-checks/spec.md create mode 100644 openspec/changes/add-os-status-plugin/tasks.md create mode 100644 plugins/os-status/.claude-plugin/plugin.json create mode 100644 plugins/os-status/hooks/checks.py create mode 100644 plugins/os-status/hooks/hooks.json create mode 100644 plugins/os-status/hooks/session_start.py create mode 100644 plugins/os-status/hooks/state.py create mode 100644 plugins/os-status/invariants.md create mode 100644 plugins/os-status/tests/hook_test.py diff --git a/.gitignore b/.gitignore index 04b0fe3..2db615c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ __pycache__/ # autoresearch skill run logs (eval iterations) /autoresearch/ +# os-status plugin state +.cc-os/ diff --git a/docs/memory-system/03-architecture-decisions.md b/docs/memory-system/03-architecture-decisions.md index d56d80e..a4e27d0 100644 --- a/docs/memory-system/03-architecture-decisions.md +++ b/docs/memory-system/03-architecture-decisions.md @@ -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). - **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/` 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) | Tool | Why rejected for our use | diff --git a/docs/plans/ws3-status-convention-plugin.md b/docs/plans/ws3-status-convention-plugin.md index 70b4455..4aa08dd 100644 --- a/docs/plans/ws3-status-convention-plugin.md +++ b/docs/plans/ws3-status-convention-plugin.md @@ -1,6 +1,10 @@ # 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`._ ## Goal diff --git a/openspec/changes/add-os-status-plugin/.openspec.yaml b/openspec/changes/add-os-status-plugin/.openspec.yaml new file mode 100644 index 0000000..dd9a1d9 --- /dev/null +++ b/openspec/changes/add-os-status-plugin/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-06 diff --git a/openspec/changes/add-os-status-plugin/design.md b/openspec/changes/add-os-status-plugin/design.md new file mode 100644 index 0000000..6c8f501 --- /dev/null +++ b/openspec/changes/add-os-status-plugin/design.md @@ -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 `/.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/` 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. diff --git a/openspec/changes/add-os-status-plugin/proposal.md b/openspec/changes/add-os-status-plugin/proposal.md new file mode 100644 index 0000000..d82eda6 --- /dev/null +++ b/openspec/changes/add-os-status-plugin/proposal.md @@ -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 + + + +## 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). diff --git a/openspec/changes/add-os-status-plugin/specs/status-banner/spec.md b/openspec/changes/add-os-status-plugin/specs/status-banner/spec.md new file mode 100644 index 0000000..cb3ba2d --- /dev/null +++ b/openspec/changes/add-os-status-plugin/specs/status-banner/spec.md @@ -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 diff --git a/openspec/changes/add-os-status-plugin/specs/status-checks/spec.md b/openspec/changes/add-os-status-plugin/specs/status-checks/spec.md new file mode 100644 index 0000000..0b36338 --- /dev/null +++ b/openspec/changes/add-os-status-plugin/specs/status-checks/spec.md @@ -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/` 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 diff --git a/openspec/changes/add-os-status-plugin/tasks.md b/openspec/changes/add-os-status-plugin/tasks.md new file mode 100644 index 0000000..0391fb1 --- /dev/null +++ b/openspec/changes/add-os-status-plugin/tasks.md @@ -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/` 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) diff --git a/plugins/os-adr/hooks/hooks.json b/plugins/os-adr/hooks/hooks.json index f142c39..deffac9 100644 --- a/plugins/os-adr/hooks/hooks.json +++ b/plugins/os-adr/hooks/hooks.json @@ -1,16 +1,3 @@ { - "hooks": { - "SessionStart": [ - { - "matcher": "startup|resume", - "hooks": [ - { - "type": "command", - "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py", - "timeout": 5 - } - ] - } - ] - } + "hooks": {} } diff --git a/plugins/os-adr/hooks/session_start.py b/plugins/os-adr/hooks/session_start.py index bae5547..82120da 100644 --- a/plugins/os-adr/hooks/session_start.py +++ b/plugins/os-adr/hooks/session_start.py @@ -1,6 +1,11 @@ #!/usr/bin/env python3 """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. present (docs/adr/ + index) -> additionalContext note naming /os-adr:create diff --git a/plugins/os-status/.claude-plugin/plugin.json b/plugins/os-status/.claude-plugin/plugin.json new file mode 100644 index 0000000..e0208a1 --- /dev/null +++ b/plugins/os-status/.claude-plugin/plugin.json @@ -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/." +} diff --git a/plugins/os-status/hooks/checks.py b/plugins/os-status/hooks/checks.py new file mode 100644 index 0000000..b03de3d --- /dev/null +++ b/plugins/os-status/hooks/checks.py @@ -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/ 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 = ' 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), +] diff --git a/plugins/os-status/hooks/hooks.json b/plugins/os-status/hooks/hooks.json new file mode 100644 index 0000000..f142c39 --- /dev/null +++ b/plugins/os-status/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/plugins/os-status/hooks/session_start.py b/plugins/os-status/hooks/session_start.py new file mode 100644 index 0000000..5b25673 --- /dev/null +++ b/plugins/os-status/hooks/session_start.py @@ -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- + +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-; 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()) diff --git a/plugins/os-status/hooks/state.py b/plugins/os-status/hooks/state.py new file mode 100644 index 0000000..1be1b93 --- /dev/null +++ b/plugins/os-status/hooks/state.py @@ -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-), and permanent suppress markers +(suppress-). 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()) diff --git a/plugins/os-status/invariants.md b/plugins/os-status/invariants.md new file mode 100644 index 0000000..67594be --- /dev/null +++ b/plugins/os-status/invariants.md @@ -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-`, `suppress-`), 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). diff --git a/plugins/os-status/tests/hook_test.py b/plugins/os-status/tests/hook_test.py new file mode 100644 index 0000000..efda2a8 --- /dev/null +++ b/plugins/os-status/tests/hook_test.py @@ -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()