When a user rejects a tool call, kills a plan, or interrupts mid-turn, the transcript records a correction — the most direct feedback a session produces. The mining layer, cc_transcript.mining, turns those shapes into data. Each detector recognizes one transcript shape and yields MiningSignals: neutral facts carrying the correction text, a calibrated confidence verdict, and evidence, but no policy. The detectors run inside the Rust engine, which parses and mines raw transcript bytes in one pass. Your app decides what clears the bar, maps survivors to FeedbackCandidates, and persists them. This page mines one denial end to end: fixture, detector, confidence, candidate, filter, verdict.
Run the detectors
Mining is spec-driven: a MiningSpec carries the whole policy as data — which detectors run, the confidence stages each folds (the defaults wire in CALIBRATED_SPEC, and USER_MESSAGE_SPEC for user turns), and the review-format policy. mine hands the parsed events and the serialized spec to the Rust engine, which runs every detector over them in one pass. Here the denial detector fires: it pairs each denial result with the tool_use it rejects, pulls the embedded “user said” text, and yields one signal per denial:
from cc_transcript.mining import MiningSpec, mine
spec = MiningSpec()
[signal] = mine(events, spec)
(signal.kind, signal.detector, signal.text, signal.trigger_index, signal.evidence)
# -> ('interrupt_rejection', 'denial', 'use uv, not pip', 0, {'tool': 'Bash', 'file_path': None})
('interrupt_rejection',
'denial',
'use uv, not pip',
0,
{'tool': 'Bash', 'file_path': None})
kind is a SourceKind — the descriptive category (interrupt_rejection here); detector names the exact shape that fired. text is the user’s verbatim correction, trigger_index points at the assistant turn it answers, and evidence preserves detector-specific metadata (the denied tool, the file it targeted). detector is one of seven ids, and MiningSpec.detectors — a frozenset of those ids — decides which run:
"denial" # a denied tool call, embedded reason when present
"exit_plan_rejection" # an ExitPlanMode plan rejected with embedded text
"plan_reentry" # plan mode re-entered after an edit cycle
"interrupt" # [Request interrupted by user] plus the correction after it
"transcript_message" # every substantive user turn, scored by length and proximity
"review_comment" # inline review comments, via the spec's review formats
"ask_user_question" # an AskUserQuestion answer, an option pick or a freeform reply
The default MiningSpec enables all seven, so mine(events, MiningSpec()) runs the standard bundle. Review comments stay silent until configured: what a review message looks like is app policy, so you fill the spec’s ReviewSpec with formats — a declarative RegexReviewFormat, or a CallableReviewFormat with a Python extractor — and the detector yields one signal per parsed comment.
Confidence: anchors and reason codes
Every signal carries a CandidateSignal: a Confidence score in [0, 1] plus the reason codes that produced it. Five anchors name the band — NONE (0.0), LOW (0.25), MEDIUM (0.5), HIGH (0.75), VERY_HIGH (0.95) — and NOISE_FLOOR sits at LOW. A detector starts each signal at an anchor and moves it one 0.25 step per named reason: substantive (more than two non-structural words) promotes, hedged (“maybe”, “up to you”) and short_followup demote, trigger_proximate promotes, and structural_only collapses straight to NONE. The reasons travel with the score, so an app can filter on either. Our denial embeds a real correction, which is the strongest shape this detector produces:
from cc_transcript.mining import NOISE_FLOOR
(signal.signal, signal.signal.confidence >= NOISE_FLOOR)
# -> (CandidateSignal(confidence=0.75, reasons=('embedded_text', 'substantive'), durable=True), True)
(CandidateSignal(confidence=0.75, reasons=('embedded_text', 'substantive'), durable=True),
True)
Contrast a denial with no embedded reason, where the only follow-up is a <system-reminder> injection — the denial detector still reports the fact, but scores it as noise. The transcript-message detector fires on the reminder line too, so pick the denial signal by its detector id:
followup_raw = b"\n".join([
line(envelope("assistant", "a2", 20, {
"role": "assistant", "model": "claude-opus-4-7", "stop_reason": "tool_use",
"content": [{"type": "tool_use", "id": "t2", "name": "Bash",
"input": {"command": "pip install lxml"}}]})),
line(envelope("user", "u2", 24, {
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "t2",
"content": f"{DENIAL_PREFIX}.", "is_error": True}]})),
line(envelope("user", "u3", 25, {
"role": "user", "content": "<system-reminder>stop hook output</system-reminder>"})),
])
followup_events = parse_events_from_bytes(followup_raw)
[structural] = [s for s in mine(followup_events, spec) if s.detector == "denial"]
(structural.signal, structural.signal.confidence >= NOISE_FLOOR)
# -> (CandidateSignal(confidence=0.0, reasons=('structural_only',), durable=True), False)
(CandidateSignal(confidence=0.0, reasons=('structural_only',), durable=True),
False)
Every signal carries its score and reasons together, so a floor check is a plain comparison on signal.confidence.
From signal to candidate
A signal is a transient fact; a FeedbackCandidate is the durable record an app persists. Two helpers bridge the gap. dedup_key derives a SHA-256 key from content — session, kind, text — not from the entry uuid or file path, so the same pushback recorded under two transcript entries collapses to one row and the database stays idempotent across file moves:
from cc_transcript.mining import dedup_key
key = dedup_key("sess-1", signal.kind, signal.text)
(key == dedup_key("sess-1", signal.kind, signal.text), len(key))
# -> (True, 64)
capture_window captures the conversational window around the feedback as a ContextWindow — refs, not prose. Hand it the raw transcript bytes and an EventRef anchored on the signal’s event; the native core parses and lifts the turns itself, and each one becomes a TurnRef carrying resolvable references back into the transcript plus a preview rendered now, at an explicit budget. Together the key, the window, and the anchor fill a candidate:
from cc_transcript import EventRef, capture_window
from cc_transcript.mining import FeedbackCandidate
anchor = EventRef(signal.session_id, signal.event_uuid)
window = capture_window(raw, anchor)
candidate = FeedbackCandidate(
dedup_key=key,
source_kind=signal.kind,
occurred_at=signal.occurred_at,
text=signal.text,
window=window,
ref=anchor,
session_id=signal.session_id,
cc_version=signal.cc_version,
payload={"detector": signal.detector, **signal.evidence},
signal=signal.signal,
)
(window.fidelity, window.trigger.role, window.trigger.preview)
# -> ('full', 'assistant', 'pip install requests')
('full', 'assistant', 'pip install requests')
The trigger preview keeps the pip install requests command itself — when a judge later reads this candidate, it sees what the assistant did, not just that the user objected. But the preview is the fallback, not the record. The window’s refs point back into the transcript, so while it lives, hydrate resolves them to real turns for full-fidelity rendering — and returns None, never raises, once the transcript is gone:
from cc_transcript import Budget
hydrated = window.hydrate()
(hydrated, window.render_preview(budget=Budget()))
# -> (None, 'pip install requests')
(None, 'pip install requests')
This fixture session was never on disk, so hydration degrades and render_preview serves the persisted previews. A window persisted past its transcript’s lifetime carries summary fidelity, and its preview then leads with the [summary fidelity — transcript unavailable] label — consumers always know which fidelity they are reading.
Verdicts and audits
Detectors are recall-heavy by design — they report facts and let confidence de-noise. For precision, apps run an LLM judge over stored candidates. run_verdicts is the fan-out: it awaits one prompt per row (the builder is async, so a prompt can hydrate its context window first), runs the judge concurrently, and persists each verdict as it lands, so a failed call is retried on the next pass. Map the structural denial from earlier into a candidate too — the same key derivation and window capture — so the fan-out below carries one substantive row and one noise row:
noisy_anchor = EventRef(structural.session_id, structural.event_uuid)
noisy = FeedbackCandidate(
dedup_key=dedup_key("sess-1", structural.kind, structural.text),
source_kind=structural.kind,
occurred_at=structural.occurred_at,
text=structural.text,
window=capture_window(followup_raw, noisy_anchor),
ref=noisy_anchor,
signal=structural.signal,
)
noisy.signal.reasons
# -> ('structural_only',)
The judge is any async callable returning an object with category, summary, confidence, rationale, and accepted (the VerdictLike shape). A deterministic stub keeps this page hermetic:
from dataclasses import dataclass
from cc_transcript.judge import run_verdicts
@dataclass(frozen=True)
class Verdict:
category: str
summary: str
confidence: float
rationale: str
accepted: bool
async def prompt_for(row):
return f"Is this developer pushback worth learning from?\n\n{row['text']}"
async def stub_judge(prompt):
pushback = "<system-reminder>" not in prompt
return Verdict(
category="tooling" if pushback else "noise",
summary="stub verdict",
confidence=0.9,
rationale="names a concrete alternative" if pushback else "structural text only",
accepted=pushback,
)
verdicts = {}
async def persist(row, verdict):
verdicts[row["dedup_key"]] = verdict
rows = [{"dedup_key": c.dedup_key, "text": c.text} for c in (candidate, noisy)]
judged, failed = await run_verdicts(rows, prompt_for, stub_judge, persist, concurrency=2)
(judged, failed, verdicts[candidate.dedup_key].accepted, verdicts[noisy.dedup_key].accepted)
# -> (2, 0, True, False)
In production the judge is structured_judge from cc_transcript.judge (it loads its model client lazily), rows come from FeedbackStore.unjudged, and persist is record_verdict — keyed by (dedup_key, role, prompt_version, model), so re-running a pass judges only new rows. Verdicts are fidelity-aware: each records whether its context window rendered at full fidelity or from summary previews, unjudged(refresh_summary=True) re-yields summary-fidelity rows once their windows hydrate again, and a full-fidelity re-judge replaces the summary verdict. Evaluating the judge itself is mechanical too: sample_audit draws a seeded, stratified audit sample over judged rows (a uniform core for headline numbers, a low-confidence oversample for diagnosis), and Metrics folds the audit into precision, contamination with its exact Clopper-Pearson upper bound, and a derived recall estimate, gated by a frozen golden fixture.
Persistence runs through FeedbackStore, which layers a feedback_events table on the file-mtime ledger so a scan is atomic and re-scanning an unchanged file is a no-op. The store is async: a native engine owns the database on its own worker thread, every call is an await, and these cells run on Jupyter’s event loop — so it runs right here, against a throwaway database:
import tempfile
from pathlib import Path
from cc_transcript.mining import FeedbackStore
async with await FeedbackStore.open(Path(tempfile.mkdtemp()) / "feedback.db") as store:
inserted = await store.record_file_scan("sess-1.jsonl", 1.0, [candidate])
stats = await store.stats()
(inserted, stats)
# -> (1, Stats(total=1, files=1, by_source={'interrupt_rejection': 1}))
(1, Stats(total=1, files=1, by_source={'interrupt_rejection': 1}))
Next steps
The shape of this layer matches the rest of the platform: the core ships detectors, calibration, and storage; the floor, the kinds, and the judge prompts are yours. For how to lay that policy out as a module, see Compose your own policy; for every exported name, see the API reference.