From 24cac91ac2cff96372a12e89a827f4328030054f Mon Sep 17 00:00:00 2001 From: "Carlos D. Escobar-Valbuena" Date: Thu, 14 May 2026 08:35:00 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20v0.4.1=20=E2=80=94=20meta-progression?= =?UTF-8?q?=20nudges=20(coverage=20hook=20+=20intake=20suggestion)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap from v0.4.0. Per-prompt routing was wired; observability existed; but nothing nudged agents to RUN the analysis or notice when the registry undercovered their work. v0.4.1 ships two complementary nudges, both non-blocking and silent-when-healthy. Added: - `role-x coverage` subcommand — brief registry-health summary suitable for a SessionStart hook. Silent (exit 0, no output) when fire-rate >= 30% AND sanitized capture is on. Otherwise prints a 3-5 line nudge with concrete next-step hints. - `scripts/role-x-coverage-hook.sh` — Claude Code SessionStart wrapper. 24h cooldown via stamp file. Graceful-fail on missing Python / PyYAML / CLI. Always exits 0. - In-prompt authoring nudge: when intake routes to _meta only AND the prompt is domain-rich (>= 8 words, >= 4 distinct meaningful tokens), the agent's working-context output appends a one-line "role-x init " suggestion with auto-derived slug. - 8 new tests (30 -> 38 total): intake nudge fires / suppressed / no-op, coverage silent / reports / force / below-min-events. - SKILL.md "When to invoke" expanded with the meta-progression discipline table (SessionStart cadence, per-prompt trigger, rule-of-three). - CHANGELOG v0.4.1 with rationale + workspace wiring instructions. Verified end-to-end against live events.jsonl (43 events / 7d): - coverage subcommand correctly flags 12% fire-rate as low + sanitized capture off - intake nudge fires on "draft a one-page strategic brief..." with suggested slug "draft-one-page" - short prompt ("what does this function do") correctly suppresses nudge - backward compat: 30 prior tests unchanged and green Workspace wiring lands in a separate PR on broomva/workspace (.claude/settings.json SessionStart entry). Until that merges, the hook script is installed but inert. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 91 +++++++++++++++++++++ README.md | 4 +- SKILL.md | 31 +++++++- scripts/role-x-coverage-hook.sh | 54 +++++++++++++ scripts/role-x.py | 133 +++++++++++++++++++++++++++++++ tests/test_role_x.py | 137 ++++++++++++++++++++++++++++++++ 6 files changed, 447 insertions(+), 3 deletions(-) create mode 100755 scripts/role-x-coverage-hook.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 826e9c0..603cc11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,97 @@ All notable changes to `role-x` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.1] — 2026-05-14 + +Closes the meta-progression gap. The per-prompt routing was wired in v0.2.0; +v0.4.0 shipped the observability substrate; v0.4.1 wires the agent-facing +nudges so the *expression* of the system progresses naturally — without +requiring the user to remember to run `role-x suggest` or notice when the +registry undercovers their work. + +### Added + +- **In-prompt authoring nudge** — when intake routes to `_meta` only **AND** + the prompt is "domain-rich" (≥8 words, ≥4 distinct meaningful tokens), the + agent's working-context output appends one line: + + ``` + Note: no domain lens scored ≥2 for this prompt. If this kind of work + recurs, consider expanding the registry: `role-x init ` (status: + candidate). + ``` + + The slug is auto-derived from the first 2 distinctive tokens. Tuning + knobs: `DOMAIN_RICH_MIN_WORDS = 8`, `DOMAIN_RICH_MIN_TOKENS = 4`. + +- **`role-x coverage`** — brief registry-health summary suitable for a + SessionStart hook. Silent (exit 0, no output) when fire-rate ≥30% AND + sanitized capture is enabled. Surfaces a 3-5 line nudge otherwise. + + ``` + role-x coverage [--since 7d --min-events N --force --events-path PATH] + ``` + +- **`scripts/role-x-coverage-hook.sh`** — Claude Code `SessionStart` hook + wrapper. 24h cooldown via `~/.config/broomva/role/coverage-stamp` (override + via `ROLE_X_COVERAGE_COOLDOWN_HOURS`). Graceful-fail on missing Python / + missing PyYAML / missing CLI. Always exits 0. Wired into workspace via + `.claude/settings.json` `SessionStart` entry (separate PR on + broomva/workspace). + +- **8 new tests** (30 → **38 total**): + - `intake_nudges_for_meta_only_domain_rich_prompt` + - `intake_no_nudge_when_lens_fires` + - `intake_no_nudge_for_short_prompt` + - `coverage_silent_when_healthy` + - `coverage_reports_when_no_sanitized_capture` + - `coverage_reports_low_fire_rate` + - `coverage_silent_below_min_events` + - `coverage_force_prints_when_below_min` + +### Why this exists (the gap closed) + +v0.4.0 made `role-x suggest` available, but nothing reminded agents to *run* +it. The system captured 31 unrouted prompts over 7 days but no one noticed +the pattern. v0.4.1 fixes that with two complementary nudges: + +| Cadence | Mechanism | Trigger | +|---|---|---| +| Per-prompt | Intake context appendix | `_meta`-only AND domain-rich prompt | +| Per-session (≤1/24h) | SessionStart hook → `coverage` | Fire-rate < 30% OR sanitized capture off | + +Both are non-blocking and exit silently when the registry is healthy. Like +P8 skill-freshness, they nudge but never gate. + +### Backward compatibility + +- All v0.1.0-v0.4.0 lenses work unchanged. +- Event schema unchanged — nudge is computed at runtime, not stored. +- CLI signatures preserved. +- The intake context output has a new optional appendix; agents that don't + consume it experience no change. + +### Workspace wiring (separate PR on broomva/workspace) + +To enable the SessionStart hook, add to `.claude/settings.json`: + +```json +"SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$HOME/.agents/skills/role-x/scripts/role-x-coverage-hook.sh", + "timeout": 5 + } + ] + } +] +``` + +If the role-x install doesn't have v0.4.1+ yet, the hook silently exits 0. +No new failure mode introduced. + ## [0.4.0] — 2026-05-14 Observability for organic lens growth. Closes the half-loop from v0.3.0 — the diff --git a/README.md b/README.md index be3e99d..d85645e 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ Selection and mode-decision are **reasoning-enforced** (bstack-idiom, same as P1 | `role-x intake [--prompt … --workspace … --session …]` | **v0.2.0+** — `UserPromptSubmit` hook entry point. Scores lenses against current signals (git + prompt content), walks `extends:` chain, decides mode, emits event to `~/.config/broomva/role/events.jsonl`, prints agent-context to stdout. Reads JSON from stdin if `--prompt` omitted (the Claude Code hook protocol). | | `role-x suggest [--since 7d --threshold N --limit M --events-path PATH]` | **v0.4.0+** — analyze `events.jsonl` over a window. Reports fire-rate (lens-fired vs `_meta`-only), per-lens drift (fires + sessions + avg prompt length), and (when sanitized capture is on) emergent keyword clusters in unrouted events with suggested lens names. Read-only. | | `role-x init [--keywords K1,K2 --paths P1,P2 --threshold N --extends NAME ...]` | **v0.4.0+** — scaffold a new `status: candidate` lens under `roles/.md` from CLI flags. Scaffolded file passes `validate` immediately. Author edits, then promotes to `status: active` after ≥3 positive-outcome uses (P16). | +| `role-x coverage [--since 7d --min-events 10 --force]` | **v0.4.1+** — brief registry-health summary. Silent (exit 0, no output) when fire-rate ≥30% AND sanitized capture is on. Surfaces a 3-5 line nudge otherwise. Designed as the entry point for the `SessionStart` hook with a 24h cooldown — `scripts/role-x-coverage-hook.sh` wires this. | ## Hook integration (v0.2.0+) @@ -264,7 +265,8 @@ git add roles/ && git commit -m "feat(roles): add deploy-vercel-env candidate le - **v0.1.0** — Markdown lens registry + CLI (`validate`, `list`, `index`) + reference docs - **v0.2.0** — `intake` subcommand + `UserPromptSubmit` hook + `~/.config/broomva/role/events.jsonl` capture - **v0.3.0** — Per-lens `threshold:` override + per-signal-type `signals.weights:` -- **v0.4.0** (this release) — Observability for organic growth: `role-x suggest` + `role-x init` + opt-in sanitized prompt capture +- **v0.4.0** — Observability for organic growth: `role-x suggest` + `role-x init` + opt-in sanitized prompt capture +- **v0.4.1** (this release) — Meta-progression nudges: `role-x coverage` SessionStart hook + per-prompt authoring suggestion when intake routes to `_meta` only on a domain-rich prompt - **v0.5.0** — `role-x tune ` + `role-x propose-lens ` (PR-driven lens updates) - **v0.6.0** — P13 dream cycle: `role-x-replay.py` with replay-against-frozen-substrate; `status.json` per-lens stats cache; auto-promote candidates on rule-of-three positive outcomes - **v0.7.0** — `PostToolUse` + `Stop` outcome hooks → quality signals per lens-use diff --git a/SKILL.md b/SKILL.md index 64c3e7b..d836e31 100644 --- a/SKILL.md +++ b/SKILL.md @@ -28,7 +28,14 @@ description: | - `role-x list` — list all available lenses with status + extends + default_mode - `role-x validate ` — validate lens YAML frontmatter against schema - `role-x index` — regenerate `roles/_index.md` discovery file -5. **Reference docs** (`references/`): + - `role-x intake` (v0.2.0) — `UserPromptSubmit` hook entry point + - `role-x suggest` (v0.4.0) — analyze events.jsonl; surface fire-rate + drift + emergent clusters + - `role-x init ` (v0.4.0) — scaffold a `status: candidate` lens from CLI flags + - `role-x coverage` (v0.4.1) — brief registry-health summary; silent when healthy (SessionStart hook entry point) +5. **Hooks** (`scripts/*-hook.sh`): + - `role-x-intake-hook.sh` (v0.2.0) — `UserPromptSubmit` wrapper + - `role-x-coverage-hook.sh` (v0.4.1) — `SessionStart` wrapper with 24h cooldown +6. **Reference docs** (`references/`): - `lens-schema.md` — YAML frontmatter field reference - `selection-algorithm.md` — scoring algorithm in detail - `mode-selection.md` — augment/rewrite/decompose decision tree @@ -36,10 +43,30 @@ description: | ## When to invoke -Always — at the start of every session, before responding to substantive user input. P17 is a reflexive primitive. The skill exists to make the lens registry and CLI helpers discoverable; the *behavior* is enforced by reasoning. +### Intake reflex (every prompt) + +Always — at the start of every session, before responding to substantive user input. P17 is a reflexive primitive. The skill exists to make the lens registry and CLI helpers discoverable; the *behavior* is enforced by reasoning + the UserPromptSubmit hook. Carve-outs (no role-x intake needed): single-line typo fixes, pure read questions ("what does this function do?"), conversation continuation without new substantive request. +### Meta-progression discipline (v0.4.1+) + +The intake reflex routes prompts in real-time. The meta-progression discipline ensures the *registry itself* grows from real telemetry: + +| When | Action | Cadence | +|---|---|---| +| **SessionStart** in a workspace with the role-x coverage hook wired | `role-x coverage --since 7d` fires automatically; surfaces fire-rate + config hints when registry health drops | ≤1 nudge per 24h | +| **Per substantive prompt** | If intake routes to `_meta` only **AND** prompt is domain-rich (≥8 words, ≥4 distinct meaningful tokens) | Agent sees a 1-line `role-x init ` suggestion appended to the intake context | +| **When the agent observes a recurring `_meta`-only pattern** within a session (e.g. 3+ unrouted prompts about the same domain) | Propose `role-x init ` to the user as the rule-of-three trigger | At the agent's discretion, surfaced as a one-line note | +| **Weekly (or after collecting ~50+ events)** | Run `role-x suggest --since 7d` for the full report — fire-rate, per-lens drift, emergent keyword clusters (requires sanitized capture on) | Manual, with telemetry signal from the SessionStart nudge | +| **After ≥3 positive-outcome uses of a `status: candidate` lens** | Author promotes the lens to `status: active` (P16 rule-of-three) | Manual, candidate ledger tracks instances | + +### When NOT to invoke meta-actions + +- Single-prompt sessions where the intake nudge is purely informational — don't pause work to author lenses mid-flow +- Edits to existing active lenses unless `role-x tune ` (v0.5.0+) surfaces concrete drift signals +- New lenses without ≥3 distinct-session evidence in events.jsonl (avoids cargo-cult lens proliferation) + ## When NOT to invoke - One-shot read questions answered from context alone diff --git a/scripts/role-x-coverage-hook.sh b/scripts/role-x-coverage-hook.sh new file mode 100755 index 0000000..49300c1 --- /dev/null +++ b/scripts/role-x-coverage-hook.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# role-x-coverage-hook.sh — Claude Code SessionStart hook for v0.4.1 onwards. +# +# Once-per-session-ish nudge surfacing registry health when it looks under- +# covered. Always exits 0; never blocks. Cooldown via stamp file. +# +# Installed by: `npx skills add broomva/role-x` +# Canonical location: ~/.agents/skills/role-x/scripts/role-x-coverage-hook.sh +# Wired from: $WORKSPACE/.claude/settings.json under "SessionStart" + +set -eu + +PYTHON_BIN="${ROLE_X_PYTHON:-python3}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROLE_X_PY="$SCRIPT_DIR/role-x.py" + +# Cooldown — at most one report per N hours (default 24h) +COOLDOWN_HOURS="${ROLE_X_COVERAGE_COOLDOWN_HOURS:-24}" +STAMP_FILE="${HOME}/.config/broomva/role/coverage-stamp" + +# Graceful-fail if Python or the CLI are absent +if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then + exit 0 +fi +if [ ! -f "$ROLE_X_PY" ]; then + exit 0 +fi +if ! "$PYTHON_BIN" -c "import yaml" >/dev/null 2>&1; then + exit 0 +fi + +# Cooldown check (Darwin and Linux paths) +if [ -f "$STAMP_FILE" ]; then + if [ "$(uname)" = "Darwin" ]; then + last_run=$(stat -f %m "$STAMP_FILE" 2>/dev/null || echo 0) + else + last_run=$(stat -c %Y "$STAMP_FILE" 2>/dev/null || echo 0) + fi + now=$(date +%s) + elapsed=$((now - last_run)) + cooldown=$((COOLDOWN_HOURS * 3600)) + if [ "$elapsed" -lt "$cooldown" ]; then + exit 0 + fi +fi + +# Run the coverage summary. The subcommand stays silent when healthy. +"$PYTHON_BIN" "$ROLE_X_PY" coverage --since 7d 2>/dev/null || true + +# Refresh the stamp regardless of whether we printed anything +mkdir -p "$(dirname "$STAMP_FILE")" +touch "$STAMP_FILE" + +exit 0 diff --git a/scripts/role-x.py b/scripts/role-x.py index a90ef19..3c350fe 100644 --- a/scripts/role-x.py +++ b/scripts/role-x.py @@ -263,6 +263,18 @@ def cmd_index(args: argparse.Namespace) -> int: CARVE_OUT_MIN_WORDS = 3 # prompts shorter than this skip the intake reflex + +# v0.4.1 — "domain-rich" heuristic. When intake routes to _meta-only AND the +# prompt is non-trivial AND has enough distinct meaningful tokens, surface a +# one-line nudge: "consider role-x init ". Closes the gap where agents +# saw _meta-only repeatedly but never aggregated the signal. +DOMAIN_RICH_MIN_WORDS = 8 +DOMAIN_RICH_MIN_TOKENS = 4 + +# v0.4.1 — coverage thresholds for the SessionStart hook's silent-when-healthy +# logic. If recent fire-rate is at-or-above this floor AND there's no +# clusters-above-threshold visible, the coverage hook stays silent. +COVERAGE_HEALTHY_FIRE_RATE = 30 # percent EVENTS_PATH = Path.home() / ".config" / "broomva" / "role" / "events.jsonl" # v0.4.0 — observability config. Privacy-by-default: no sanitized prompt @@ -651,9 +663,56 @@ def _format_intake_context(selection: dict) -> str: "Agents: apply the quality_bar entries as the P14 enumeration template for this response. " "If mode != augment, surface the rewrite/decompose proposal to the user before proceeding." ) + # v0.4.1: when no domain lens fired AND the prompt is domain-rich enough + # to plausibly merit one, surface a one-line "consider authoring a lens" + # nudge. Pure suggestion — agent decides whether to act on it. + nudge = selection.get("authoring_nudge") + if nudge: + lines.append("") + lines.append(nudge) return "\n".join(lines) +def _domain_rich(prompt: str, word_count: int) -> bool: + """Heuristic: is the prompt substantive enough to plausibly need a lens?""" + if word_count < DOMAIN_RICH_MIN_WORDS: + return False + tokens = { + tok.lower() + for tok in re.findall(r"[A-Za-z][A-Za-z0-9_-]+", prompt) + if len(tok) > 3 # filter trivial connectors + } + return len(tokens) >= DOMAIN_RICH_MIN_TOKENS + + +def _build_authoring_nudge(prompt: str, selection: dict) -> str | None: + """v0.4.1 — return a 1-line role-x init suggestion when warranted.""" + if selection.get("lenses_selected"): + return None # a domain lens already fired — no nudge + word_count = len(prompt.split()) + if not _domain_rich(prompt, word_count): + return None + # Pick a short candidate slug from the prompt's most distinctive tokens + tokens = [ + tok.lower() + for tok in re.findall(r"[A-Za-z][A-Za-z0-9_-]+", prompt) + if len(tok) > 3 + ] + # Dedupe in order + seen: set[str] = set() + ordered: list[str] = [] + for tok in tokens: + if tok not in seen: + seen.add(tok) + ordered.append(tok) + slug = "-".join(ordered[:2]) if ordered else "candidate" + return ( + f"Note: no domain lens scored ≥2 for this prompt. If this kind of " + f"work recurs, consider expanding the registry: " + f"`role-x init {slug}` (status: candidate)." + ) + + def cmd_intake(args: argparse.Namespace) -> int: """Intake subcommand — UserPromptSubmit hook entry point. @@ -688,6 +747,8 @@ def cmd_intake(args: argparse.Namespace) -> int: signals = _git_signals(workspace) selection = _select_lenses(roles_dir, signals, prompt) + # v0.4.1: attach authoring nudge for _meta-only domain-rich prompts + selection["authoring_nudge"] = _build_authoring_nudge(prompt, selection) _emit_event(session_id, prompt, selection) print(_format_intake_context(selection)) return 0 @@ -879,6 +940,59 @@ def cmd_suggest(args: argparse.Namespace) -> int: return 0 +### coverage subcommand (v0.4.1 — SessionStart-friendly silent-when-healthy report) ### + + +def cmd_coverage(args: argparse.Namespace) -> int: + """Brief registry-health summary suitable for a SessionStart hook. + + Silent (exit 0, no output) when registry coverage looks healthy. Prints a + 3-5 line nudge when fire-rate is below the floor OR when sanitized capture + is off so cluster discovery can't run. + """ + events_path = Path(args.events_path) if args.events_path else EVENTS_PATH + since_seconds = _parse_duration(args.since) + events = _read_events_since(events_path, since_seconds) + + total = len(events) + if total < args.min_events and not args.force: + return 0 # not enough events to report meaningfully + + fired = sum(1 for ev in events if ev.get("lenses_selected")) + unrouted = total - fired + pct_fired = (100.0 * fired / total) if total else 0.0 + + has_sanitized = any( + (ev.get("prompt_sanitized") or {}).get("strategy") == "keywords" + for ev in events + ) + + is_healthy = pct_fired >= COVERAGE_HEALTHY_FIRE_RATE and has_sanitized + if is_healthy and not args.force: + return 0 # silent — registry coverage looks fine + + # Build a tight nudge (≤5 lines including the action hints) + print( + f"[role-x coverage] {total} events over {args.since}. " + f"Fire-rate: {pct_fired:.0f}% " + f"({'low' if pct_fired < COVERAGE_HEALTHY_FIRE_RATE else 'ok'})." + ) + if not has_sanitized: + print( + " Sanitized prompt capture is OFF — cluster discovery disabled. " + f"Enable: {CONFIG_PATH}" + ) + print( + ' Body: {"capture_sanitized_prompt": true, ' + '"sanitization_strategy": "keywords"}' + ) + elif pct_fired < COVERAGE_HEALTHY_FIRE_RATE: + # We have sanitized data but coverage is still low — gesture toward suggest + print(" Run `role-x suggest` for emergent cluster + drift report.") + print(" Author next: `role-x init ` (status: candidate, promote on rule-of-three).") + return 0 + + ### init subcommand (v0.4.0 — scaffold a new lens from CLI args) ### @@ -1054,6 +1168,25 @@ def build_parser() -> argparse.ArgumentParser: ) p_intake.set_defaults(func=cmd_intake) + p_coverage = sub.add_parser( + "coverage", + help="(v0.4.1) brief registry-health summary; silent when coverage is healthy", + ) + p_coverage.add_argument( + "--since", default="7d", help="window (default 7d)", + ) + p_coverage.add_argument( + "--events-path", default=None, help=f"path to events.jsonl (default: {EVENTS_PATH})", + ) + p_coverage.add_argument( + "--min-events", type=int, default=10, + help="minimum events in window before reporting (default 10)", + ) + p_coverage.add_argument( + "--force", action="store_true", help="always print, even when healthy", + ) + p_coverage.set_defaults(func=cmd_coverage) + p_suggest = sub.add_parser( "suggest", help="(v0.4.0) analyze events.jsonl; suggest new lenses + per-lens drift signals", diff --git a/tests/test_role_x.py b/tests/test_role_x.py index a07a171..283b0e0 100644 --- a/tests/test_role_x.py +++ b/tests/test_role_x.py @@ -683,6 +683,143 @@ def test_intake_does_not_record_sanitized_when_config_absent(tmp_path): assert "prompt_sanitized" not in event +# --- v0.4.1: intake authoring nudge (meta-progression) --- + + +def test_intake_nudges_for_meta_only_domain_rich_prompt(tmp_path): + """When no domain lens fires AND prompt is substantive, surface a role-x init suggestion.""" + workspace = _seed_workspace(tmp_path) + env = {"HOME": str(tmp_path)} + rc, out, _ = run_cli( + "intake", + # Substantive but no lens-matching keywords — should route to _meta + "--prompt", "draft a thorough strategic brief about quarterly rollout plans for partner onboarding initiatives", + "--workspace", str(workspace), + "--session", "nudge-test", + env=env, + ) + assert rc == 0 + assert "_meta only" in out # routed to _meta + assert "role-x init" in out # nudge present + assert "no domain lens scored" in out + + +def test_intake_no_nudge_when_lens_fires(tmp_path): + """When a domain lens DOES fire, no authoring nudge — registry covered.""" + workspace = _seed_workspace(tmp_path) + env = {"HOME": str(tmp_path)} + rc, out, _ = run_cli( + "intake", + "--prompt", "implement rust cargo tokio async runtime with proper error handling", + "--workspace", str(workspace), + "--session", "no-nudge-when-fired", + env=env, + ) + assert rc == 0 + assert "rust" in out # lens fired + assert "role-x init" not in out # no nudge + + +def test_intake_no_nudge_for_short_prompt(tmp_path): + """Short prompts don't trigger the authoring nudge even when _meta-only.""" + workspace = _seed_workspace(tmp_path) + env = {"HOME": str(tmp_path)} + rc, out, _ = run_cli( + "intake", + "--prompt", "what does this do briefly", # 5 words — below DOMAIN_RICH_MIN_WORDS + "--workspace", str(workspace), + "--session", "no-nudge-short", + env=env, + ) + assert rc == 0 + assert "role-x init" not in out + + +# --- v0.4.1: coverage subcommand --- + + +def test_coverage_silent_when_healthy(tmp_path): + """Coverage subcommand stays silent when fire-rate >= floor and sanitized capture is on.""" + events_path = tmp_path / "events.jsonl" + from datetime import datetime, timezone + now = datetime.now(timezone.utc).isoformat() + # 10 events, 5 lens-fired (50%) + sanitized capture present → healthy + events = [] + for i in range(5): + events.append(_make_event( + now, session=f"s{i}", lenses=["rust"], + sanitized_keywords=["rust", "cargo"], + digest=f"sha256:f{i}", + )) + for i in range(5): + events.append(_make_event( + now, session=f"u{i}", lenses=[], + sanitized_keywords=["something", "else"], + digest=f"sha256:u{i}", + )) + _write_events(events_path, events) + rc, out, _ = run_cli("coverage", "--since", "1d", "--events-path", str(events_path)) + assert rc == 0 + assert out.strip() == "" # silent + + +def test_coverage_reports_when_no_sanitized_capture(tmp_path): + """Coverage prints config hint when sanitized capture is off — even with healthy fire-rate.""" + events_path = tmp_path / "events.jsonl" + from datetime import datetime, timezone + now = datetime.now(timezone.utc).isoformat() + events = [_make_event(now, session=f"s{i}", lenses=["rust"], digest=f"sha256:n{i}") for i in range(15)] + _write_events(events_path, events) + rc, out, _ = run_cli("coverage", "--since", "1d", "--events-path", str(events_path)) + assert rc == 0 + assert "capture_sanitized_prompt" in out # config hint surfaced + assert "role-x init" in out + + +def test_coverage_reports_low_fire_rate(tmp_path): + """Coverage prints nudge when fire-rate is below the floor.""" + events_path = tmp_path / "events.jsonl" + from datetime import datetime, timezone + now = datetime.now(timezone.utc).isoformat() + # 15 events, 1 fired (7%) + sanitized → low coverage + events = [_make_event(now, session=f"u{i}", lenses=[], + sanitized_keywords=["foo", "bar"], digest=f"sha256:l{i}") + for i in range(14)] + events.append(_make_event(now, session="hit", lenses=["rust"], + sanitized_keywords=["rust"], digest="sha256:hit")) + _write_events(events_path, events) + rc, out, _ = run_cli("coverage", "--since", "1d", "--events-path", str(events_path)) + assert rc == 0 + assert "low" in out.lower() + assert "suggest" in out.lower() or "role-x init" in out + + +def test_coverage_silent_below_min_events(tmp_path): + """Coverage stays silent when there's not enough data to draw a conclusion.""" + events_path = tmp_path / "events.jsonl" + from datetime import datetime, timezone + now = datetime.now(timezone.utc).isoformat() + events = [_make_event(now, session="s1", lenses=[], digest="sha256:f1")] + _write_events(events_path, events) + rc, out, _ = run_cli("coverage", "--since", "1d", "--events-path", str(events_path)) + assert rc == 0 + assert out.strip() == "" # below default min-events floor + + +def test_coverage_force_prints_when_below_min(tmp_path): + """--force overrides the min-events silent threshold.""" + events_path = tmp_path / "events.jsonl" + from datetime import datetime, timezone + now = datetime.now(timezone.utc).isoformat() + events = [_make_event(now, session="s1", lenses=[], digest="sha256:f1")] + _write_events(events_path, events) + rc, out, _ = run_cli( + "coverage", "--since", "1d", "--events-path", str(events_path), "--force", + ) + assert rc == 0 + assert out.strip() != "" + + # --- intake subcommand (M2) --- def test_intake_short_prompt_exits_silently(tmp_path):