Skip to content

Commit d57ff2c

Browse files
feat(cli): shared hook efficacy ledger + read-after-served adoption KPI (#777)
The sessions.db injections table becomes the shared ledger for every hook surface: new surface/category/chars columns (migrated in place on both the hook-side opener and the staging store), the Grep/Glob enrichments now log their firings, and the decisions miner is scoped to decision-surface rows so the new measurement rows never reach it. New augment_cmd/served_reads.py rides that ledger: PostToolUse on repowise MCP tools records which file line-ranges a response actually served (source bytes only, never skeletons or signatures), and a later Read whose window those ranges substantially cover logs one read_after_served row per file per session. Nothing is ever injected at that moment: served-then-read means the MCP answer did not land, and the row is the adoption metric, not a nag. The PostToolUse matcher is widened to repowise MCP tool names so served content is observable; unknown payload shapes are skipped, never guessed. Claude-Session: https://claude.ai/code/session_01AbCVfR5FFEvDxRvJU7DmsW
1 parent e3fd88b commit d57ff2c

9 files changed

Lines changed: 614 additions & 13 deletions

File tree

packages/cli/src/repowise/cli/commands/augment_cmd/command.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
from .codex import _handle_codex_context_event, _handle_post_edit_use
8484
from .read_state import _handle_edit_post, _handle_read_post, _record_edit
8585
from .search import _handle_search_post
86+
from .served_reads import _handle_mcp_read_post, _log_read_after_served
8687
from .session_start import _handle_claude_session_start
8788

8889
_EDIT_TOOL_NAMES = {"apply_patch", "Edit", "Write"}
@@ -246,11 +247,18 @@ def _handle_post_tool_use(
246247
return _handle_post_edit_use(cwd)
247248
return _handle_edit_post(tool_input, cwd, session_id)
248249
if tool_name == "Read":
250+
# Read-after-served KPI: logged to the ledger, never spoken about.
251+
_log_read_after_served(tool_input, tool_output, cwd, session_id)
249252
return _handle_read_post(tool_input, tool_output, cwd, session_id)
250253
if tool_name in ("Bash", "PowerShell"):
251254
# The PowerShell tool (Windows Claude Code) surfaces the same
252255
# stdout/stderr response shape as Bash — one handler covers both.
253256
return _handle_bash_post(tool_input, tool_output, cwd)
254257
if tool_name in ("Grep", "Glob"):
255-
return _handle_search_post(tool_name, tool_input, tool_output, cwd)
258+
return _handle_search_post(tool_name, tool_input, tool_output, cwd, session_id)
259+
if tool_name.startswith("mcp__") and "repowise" in tool_name.lower():
260+
# Served-content bookkeeping for the read-after-served KPI. Never
261+
# emits — measurement only.
262+
_handle_mcp_read_post(tool_output, cwd, session_id)
263+
return None
256264
return None

packages/cli/src/repowise/cli/commands/augment_cmd/decision_inject.py

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -547,9 +547,20 @@ def _edit_decision_notice(repo_path: Path, rel: str, session_id: str, state: dic
547547
"session_id TEXT NOT NULL, decision_id TEXT NOT NULL, "
548548
"node_id TEXT NOT NULL DEFAULT '', shown_at REAL NOT NULL, "
549549
"evaluated INTEGER NOT NULL DEFAULT 0, "
550+
"surface TEXT NOT NULL DEFAULT '', "
551+
"category TEXT NOT NULL DEFAULT '', "
552+
"chars INTEGER NOT NULL DEFAULT 0, "
550553
"PRIMARY KEY (session_id, decision_id))"
551554
)
552555

556+
#: Mirror of core.sessions.staging.INJECTIONS_LEDGER_COLUMNS — the hook path
557+
#: must not import repowise.core, so the migration is duplicated verbatim.
558+
_LEDGER_COLUMNS = (
559+
("surface", "TEXT NOT NULL DEFAULT ''"),
560+
("category", "TEXT NOT NULL DEFAULT ''"),
561+
("chars", "INTEGER NOT NULL DEFAULT 0"),
562+
)
563+
553564

554565
def _open_injections(repo_path: Path) -> sqlite3.Connection | None:
555566
db_path = repo_path / ".repowise" / "sessions" / "sessions.db"
@@ -559,6 +570,10 @@ def _open_injections(repo_path: Path) -> sqlite3.Connection | None:
559570
conn.execute("PRAGMA journal_mode=WAL")
560571
conn.execute("PRAGMA busy_timeout=1000")
561572
conn.execute(_INJECTIONS_TABLE_SQL)
573+
existing = {row[1] for row in conn.execute("PRAGMA table_info(injections)")}
574+
for name, decl in _LEDGER_COLUMNS:
575+
if name not in existing:
576+
conn.execute(f"ALTER TABLE injections ADD COLUMN {name} {decl}")
562577
return conn
563578
except (sqlite3.Error, OSError):
564579
return None
@@ -581,8 +596,9 @@ def _record_injections(
581596
try:
582597
now = time.time()
583598
conn.executemany(
584-
"INSERT OR IGNORE INTO injections (session_id, decision_id, node_id, shown_at) "
585-
"VALUES (?, ?, ?, ?)",
599+
"INSERT OR IGNORE INTO injections "
600+
"(session_id, decision_id, node_id, shown_at, surface, category) "
601+
"VALUES (?, ?, ?, ?, 'decision', 'session_start')",
586602
[(session_id, did, node_id, now) for did in decision_ids],
587603
)
588604
conn.commit()
@@ -592,6 +608,48 @@ def _record_injections(
592608
conn.close()
593609

594610

611+
def _claim_ledger(
612+
repo_path: Path,
613+
session_id: str,
614+
key: str,
615+
*,
616+
node_id: str,
617+
surface: str,
618+
category: str,
619+
chars: int,
620+
) -> tuple[bool, int]:
621+
"""Atomically claim one non-decision ledger emission.
622+
623+
Generic twin of :func:`_claim_injection` for the read/search enrichment
624+
surfaces: *key* replaces the decision id in the primary key, so INSERT OR
625+
IGNORE is the once-per-session-per-key gate. Returns ``(claimed,
626+
surface_injection_count)`` where the count covers only rows that actually
627+
carried text (``chars > 0``) on *surface* — pure measurement rows must not
628+
eat into an injection cap. Fail-closed: any error reports unclaimed.
629+
"""
630+
conn = _open_injections(repo_path)
631+
if conn is None:
632+
return False, 0
633+
try:
634+
cur = conn.execute(
635+
"INSERT OR IGNORE INTO injections "
636+
"(session_id, decision_id, node_id, shown_at, surface, category, chars) "
637+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
638+
(session_id, key, node_id, time.time(), surface, category, chars),
639+
)
640+
claimed = cur.rowcount > 0
641+
count = conn.execute(
642+
"SELECT COUNT(*) FROM injections WHERE session_id = ? AND surface = ? AND chars > 0",
643+
(session_id, surface),
644+
).fetchone()[0]
645+
conn.commit()
646+
return claimed, int(count)
647+
except sqlite3.Error:
648+
return False, 0
649+
finally:
650+
conn.close()
651+
652+
595653
def _claim_injection(
596654
repo_path: Path, session_id: str, decision_id: str, node_id: str
597655
) -> tuple[bool, int]:
@@ -608,13 +666,17 @@ def _claim_injection(
608666
return False, 0
609667
try:
610668
cur = conn.execute(
611-
"INSERT OR IGNORE INTO injections (session_id, decision_id, node_id, shown_at) "
612-
"VALUES (?, ?, ?, ?)",
669+
"INSERT OR IGNORE INTO injections "
670+
"(session_id, decision_id, node_id, shown_at, surface, category) "
671+
"VALUES (?, ?, ?, ?, 'decision', 'edit_notice')",
613672
(session_id, decision_id, node_id, time.time()),
614673
)
615674
claimed = cur.rowcount > 0
675+
# Surface-scoped: read/search enrichment rows also carry a node_id and
676+
# must not eat into the edit-notice cap.
616677
count = conn.execute(
617-
"SELECT COUNT(*) FROM injections WHERE session_id = ? AND node_id != ''",
678+
"SELECT COUNT(*) FROM injections WHERE session_id = ? AND node_id != '' "
679+
"AND surface IN ('', 'decision')",
618680
(session_id,),
619681
).fetchone()[0]
620682
conn.commit()

packages/cli/src/repowise/cli/commands/augment_cmd/read_state.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ def _load_session_state(repo_path: Path, session_id: str) -> dict:
5050
"stale_notified": [],
5151
"reread_notified": [],
5252
"decisions_shown": [],
53+
# File ranges served by repowise MCP responses this session, kept for
54+
# the read-after-served KPI (see read_enrich; rel -> [[start, end]]).
55+
"served": {},
5356
}
5457
try:
5558
state = json.loads(_session_state_path(repo_path).read_text(encoding="utf-8"))

packages/cli/src/repowise/cli/commands/augment_cmd/search.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def _handle_search_post(
2121
tool_input: dict,
2222
tool_output: object,
2323
cwd: str,
24+
session_id: str = "",
2425
) -> str | None:
2526
"""Decide whether to enrich a Grep/Glob result and how."""
2627
repo_path = _find_repo_root(Path(cwd))
@@ -40,6 +41,7 @@ def _handle_search_post(
4041
if result_count >= _DIGEST_THRESHOLD:
4142
digest = _grep_flood_digest(repo_path, output_text)
4243
if digest:
44+
_log_search_firing(repo_path, session_id, "digest", output_text, digest)
4345
return digest
4446
# Unparseable output (e.g. Glob path lists): fall through to triage.
4547

@@ -69,7 +71,38 @@ def _handle_search_post(
6971

7072
import asyncio
7173

72-
return asyncio.run(_search_enrich(repo_path, pattern, mode, result_count))
74+
enrichment = asyncio.run(_search_enrich(repo_path, pattern, mode, result_count))
75+
if enrichment:
76+
_log_search_firing(repo_path, session_id, mode, pattern, enrichment)
77+
return enrichment
78+
79+
80+
def _log_search_firing(
81+
repo_path: Path, session_id: str, category: str, keyed_on: str, text: str
82+
) -> None:
83+
"""Record one search enrichment in the shared ledger; measurement only.
84+
85+
All hook surfaces share the sessions.db efficacy ledger so the miner can
86+
classify used vs ignored firings in one pass. Keyed on the category plus a
87+
content hash — the same rescue repeated in one session logs once. Never
88+
changes what the agent sees; any failure is silent.
89+
"""
90+
if not session_id:
91+
return
92+
import hashlib
93+
94+
from .decision_inject import _claim_ledger
95+
96+
digest = hashlib.sha1(keyed_on.encode("utf-8", "replace")).hexdigest()[:12]
97+
_claim_ledger(
98+
repo_path,
99+
session_id,
100+
f"search:{category}:{digest}",
101+
node_id="",
102+
surface="search",
103+
category=category,
104+
chars=len(text),
105+
)
73106

74107

75108
def _grep_flood_digest(repo_path: Path, output_text: str) -> str | None:

0 commit comments

Comments
 (0)