cc-pushback 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ """Collect developer pushback signals from existing Claude Code transcripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cc_pushback.context import ContextSnapshot, ContextTurn, build_snapshot
6
+ from cc_pushback.detectors import Detector, detect
7
+ from cc_pushback.models import DedupKey, FeedbackCandidate, SourceKind, dedup_key
8
+ from cc_pushback.scan import ScanReport, scan
9
+ from cc_pushback.spec import PUSHBACK_SPEC
10
+ from cc_pushback.store import FeedbackStore
11
+
12
+ # Not the retired export-control convention: this exists only so great-docs' API
13
+ # reference skips the SourceKind (Literal) and DedupKey (NewType) aliases, which its
14
+ # dynamic walker cannot render ("Cannot handle auto for object kind: TYPE_ALIAS").
15
+ # great-docs documents __all__ when present; keep it in sync with the re-exports above.
16
+ __all__ = [
17
+ "PUSHBACK_SPEC",
18
+ "ContextSnapshot",
19
+ "ContextTurn",
20
+ "Detector",
21
+ "FeedbackCandidate",
22
+ "FeedbackStore",
23
+ "ScanReport",
24
+ "build_snapshot",
25
+ "dedup_key",
26
+ "detect",
27
+ "scan",
28
+ ]
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from cc_pushback.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
cc_pushback/claude.py ADDED
@@ -0,0 +1,46 @@
1
+ """A thin shell-out to the ``claude`` CLI for a single headless completion.
2
+
3
+ Argv construction and envelope parsing come from the shared ``spawnllm`` library;
4
+ the spawn stays local (``anyio.run_process``). It uses the user's existing Claude
5
+ Code auth (no API key), so the package stays offline unless ``claude`` is
6
+ actually on the path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import subprocess
13
+
14
+ import anyio
15
+ from spawnllm import ClaudeCliBackend, parse_result_envelope
16
+
17
+ CLAUDE_TIMEOUT = 180
18
+
19
+
20
+ def claude_available() -> bool:
21
+ """Returns whether the ``claude`` CLI is on ``PATH``."""
22
+ return shutil.which("claude") is not None
23
+
24
+
25
+ async def run_claude(prompt: str, *, system: str, model: str) -> str:
26
+ """Runs one headless ``claude`` turn and returns its text result.
27
+
28
+ Args:
29
+ prompt: The user message to send.
30
+ system: The system prompt.
31
+ model: The model to run, for example ``claude-sonnet-4-6``.
32
+
33
+ Returns:
34
+ The assistant's text response — the ``result`` field of the JSON output.
35
+
36
+ Raises:
37
+ subprocess.SubprocessError: If ``claude`` exits non-zero, times out, or
38
+ reports an error in its JSON envelope.
39
+ """
40
+ argv = ClaudeCliBackend.cc_sentiment(system_prompt=system).build_argv(prompt, model=model)
41
+ try:
42
+ with anyio.fail_after(CLAUDE_TIMEOUT):
43
+ result = await anyio.run_process(argv, check=True)
44
+ except TimeoutError as exc:
45
+ raise subprocess.TimeoutExpired(argv, CLAUDE_TIMEOUT) from exc
46
+ return parse_result_envelope(result.stdout, argv=argv, stderr=result.stderr)
cc_pushback/cli.py ADDED
@@ -0,0 +1,138 @@
1
+ """The ``cc-pushback`` command-line interface: scan, stats, list, and view-samples."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from collections.abc import Awaitable, Callable
7
+ from pathlib import Path
8
+
9
+ import anyio
10
+ import click
11
+ from cc_transcript import CLAUDE_PROJECTS_DIR
12
+
13
+ from cc_pushback.models import PUSHBACK_SOURCE_KINDS, SourceKind
14
+ from cc_pushback.report import Sample, build_summary, render_html
15
+ from cc_pushback.scan import scan as run_scan
16
+ from cc_pushback.serve import serve
17
+ from cc_pushback.store import FeedbackStore
18
+
19
+ SOURCE_KINDS = [*PUSHBACK_SOURCE_KINDS]
20
+
21
+
22
+ def coro[**P, R](fn: Callable[P, Awaitable[R]]) -> Callable[P, R]:
23
+ """Adapts an async command body into the sync callback Click expects."""
24
+
25
+ @functools.wraps(fn)
26
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
27
+ return anyio.run(functools.partial(fn, *args, **kwargs))
28
+
29
+ return wrapper
30
+
31
+
32
+ @click.group()
33
+ @click.version_option(package_name="cc-pushback")
34
+ def main() -> None:
35
+ """Collect developer pushback signals from existing Claude Code transcripts."""
36
+
37
+
38
+ @main.command()
39
+ @click.option(
40
+ "--transcripts",
41
+ "transcripts",
42
+ multiple=True,
43
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
44
+ help="Transcript directories to scan. Defaults to ~/.claude/projects.",
45
+ )
46
+ @click.option("--full", is_flag=True, help="Re-scan every transcript, ignoring recorded mtimes.")
47
+ @click.option(
48
+ "--db",
49
+ type=click.Path(dir_okay=False, path_type=Path),
50
+ default=None,
51
+ help="Database path. Defaults to ~/.cc-pushback/feedback.db.",
52
+ )
53
+ @coro
54
+ async def scan(transcripts: tuple[Path, ...], full: bool, db: Path | None) -> None:
55
+ """Scan transcripts for feedback, incrementally.
56
+
57
+ Each transcript is parsed only when new or modified since the last scan, and
58
+ every candidate is inserted with ``INSERT OR IGNORE`` keyed by a content
59
+ digest, so re-running ``scan`` over unchanged inputs is a no-op. Recording a
60
+ file and inserting its candidates commit in one transaction.
61
+ """
62
+ roots = transcripts or (CLAUDE_PROJECTS_DIR,)
63
+ async with await FeedbackStore.open(db or FeedbackStore.default_path()) as store:
64
+ report = await run_scan(store, roots, full=full)
65
+ click.echo(f"scanned {report.scanned} files, {report.inserted} new rows")
66
+
67
+
68
+ @main.command()
69
+ @click.option(
70
+ "--db",
71
+ type=click.Path(dir_okay=False, path_type=Path),
72
+ default=None,
73
+ help="Database path. Defaults to ~/.cc-pushback/feedback.db.",
74
+ )
75
+ @coro
76
+ async def stats(db: Path | None) -> None:
77
+ """Print ingestion counts by source kind and the scanned-file count."""
78
+ async with await FeedbackStore.open(db or FeedbackStore.default_path()) as store:
79
+ report = await store.stats()
80
+ click.echo(f"total: {report.total} files: {report.files}")
81
+ for kind, count in report.by_source.items():
82
+ click.echo(f" {kind}: {count}")
83
+
84
+
85
+ @main.command(name="list")
86
+ @click.option(
87
+ "--source",
88
+ "source",
89
+ type=click.Choice(SOURCE_KINDS),
90
+ default=None,
91
+ help="Restrict to one source kind.",
92
+ )
93
+ @click.option("--limit", type=int, default=20, show_default=True, help="Maximum events to show.")
94
+ @click.option(
95
+ "--db",
96
+ type=click.Path(dir_okay=False, path_type=Path),
97
+ default=None,
98
+ help="Database path. Defaults to ~/.cc-pushback/feedback.db.",
99
+ )
100
+ @coro
101
+ async def list_(source: SourceKind | None, limit: int, db: Path | None) -> None:
102
+ """List recent feedback events, newest first."""
103
+ async with await FeedbackStore.open(db or FeedbackStore.default_path()) as store:
104
+ rows = await store.recent(source_kind=source, limit=limit)
105
+ for row in rows:
106
+ click.echo(f"[{row['source_kind']}] {row['occurred_at']} {str(row['text'])[:200]}")
107
+
108
+
109
+ @main.command(name="view-samples")
110
+ @click.option(
111
+ "--db",
112
+ type=click.Path(dir_okay=False, path_type=Path),
113
+ default=None,
114
+ help="Database path. Defaults to ~/.cc-pushback/feedback.db.",
115
+ )
116
+ @click.option(
117
+ "--llm/--no-llm",
118
+ default=True,
119
+ show_default=True,
120
+ help="Summarize with the claude CLI when it is on PATH, else use heuristics.",
121
+ )
122
+ @click.option("--model", default="claude-sonnet-4-6", show_default=True, help="Model for the claude CLI summary.")
123
+ @click.option("--port", type=int, default=0, show_default=True, help="Port to serve on; 0 picks a free one.")
124
+ @click.option("--open", "open_", is_flag=True, help="Open the page in a browser once serving.")
125
+ @coro
126
+ async def view_samples(db: Path | None, llm: bool, model: str, port: int, open_: bool) -> None:
127
+ """Render every collected sample into one HTML page and serve it locally.
128
+
129
+ The page leads with a corpus summary and highlights, then lists every sample
130
+ with a kind filter, a free-text search, and an expandable context window. It is
131
+ built in memory and served over a transient HTTP server whose URL is printed;
132
+ press Ctrl-C to stop. The summary is written by the ``claude`` CLI when ``--llm``
133
+ is set and ``claude`` is installed, falling back to deterministic heuristics.
134
+ """
135
+ async with await FeedbackStore.open(db or FeedbackStore.default_path()) as store:
136
+ samples = [Sample.from_row(row) for row in await store.events()]
137
+ summary = await build_summary(samples, use_llm=llm, model=model)
138
+ await serve(render_html(samples, summary).encode("utf-8"), port=port, open_browser=open_)
cc_pushback/context.py ADDED
@@ -0,0 +1,11 @@
1
+ """Re-exports the conversational-window primitive from the mining domain.
2
+
3
+ Deprecated: import these names from :mod:`cc_transcript.domains.mining`. This shim
4
+ keeps cc-pushback's historical import paths working for at least one release.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from cc_transcript.domains.mining import ContextSnapshot, ContextTurn, build_snapshot, trigger_for, turn_for
10
+
11
+ __all__ = ["ContextSnapshot", "ContextTurn", "build_snapshot", "trigger_for", "turn_for"]
@@ -0,0 +1,138 @@
1
+ """cc-pushback's detector policy: map neutral mining facts to feedback candidates.
2
+
3
+ The fact-recognition mechanism lives in :mod:`cc_transcript.domains.mining`; this
4
+ module injects cc-pushback's policy — its filter spec, its trigger-absence
5
+ disqualification, and its review formats — and maps each surviving
6
+ :class:`MiningSignal` to a :class:`FeedbackCandidate`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from cc_transcript import keep
14
+ from cc_transcript.domains.mining import (
15
+ FeedbackCandidate,
16
+ build_snapshot,
17
+ dedup_key,
18
+ iter_interrupt_marker_signals,
19
+ iter_plan_reentry_signals,
20
+ iter_plan_rejection_signals,
21
+ iter_review_comment_signals,
22
+ iter_tool_denial_signals,
23
+ iter_user_message_signals,
24
+ )
25
+
26
+ from cc_pushback.formats import formats
27
+ from cc_pushback.spec import PUSHBACK_SPEC
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Callable, Iterator, Mapping, Sequence
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from cc_transcript.domains.mining import MiningSignal
35
+ from cc_transcript.models import TranscriptEvent
36
+
37
+ type Detector = Callable[[Path, Sequence[TranscriptEvent]], Iterator[FeedbackCandidate]]
38
+
39
+ SPEC_DETECTORS = frozenset({"transcript_message", "plan_reentry", "review_comment"})
40
+
41
+
42
+ def survives(events: Sequence[TranscriptEvent], sig: MiningSignal) -> bool:
43
+ if sig.detector in SPEC_DETECTORS and not keep(events[sig.event_index], PUSHBACK_SPEC):
44
+ return False
45
+ return not (sig.detector == "transcript_message" and sig.trigger_index is None)
46
+
47
+
48
+ def parts(sig: MiningSignal) -> tuple[str, ...]:
49
+ match sig.detector:
50
+ case "transcript_message":
51
+ return (sig.session_id, "transcript_message", sig.text)
52
+ case "exit_plan_rejection":
53
+ return (sig.session_id, "plan_review", "exit_plan", sig.text)
54
+ case "plan_reentry":
55
+ return (sig.session_id, "plan_review", "plan_reentry", sig.text)
56
+ case "denial" | "interrupt":
57
+ return (sig.session_id, "interrupt_rejection", sig.text)
58
+ case "review_comment":
59
+ return (
60
+ sig.session_id,
61
+ "review_comment",
62
+ sig.evidence["file"] or "",
63
+ str(sig.evidence["line_start"] or ""),
64
+ str(sig.evidence["line_end"] or ""),
65
+ sig.text,
66
+ )
67
+ raise AssertionError(sig.detector)
68
+
69
+
70
+ def payload_of(sig: MiningSignal) -> Mapping[str, Any] | None:
71
+ match sig.detector:
72
+ case "transcript_message":
73
+ return None
74
+ case "exit_plan_rejection" | "plan_reentry" | "interrupt":
75
+ return {"detector": sig.detector}
76
+ case "denial":
77
+ return dict(sig.evidence) or None
78
+ case "review_comment":
79
+ return {key: sig.evidence[key] for key in ("format", "file", "line_start", "line_end")}
80
+ raise AssertionError(sig.detector)
81
+
82
+
83
+ def to_candidate(path: Path, events: Sequence[TranscriptEvent], sig: MiningSignal) -> FeedbackCandidate:
84
+ return FeedbackCandidate(
85
+ dedup_key=dedup_key(*parts(sig)),
86
+ source_kind=sig.kind,
87
+ occurred_at=sig.occurred_at,
88
+ text=sig.text,
89
+ context=build_snapshot(events, sig.event_index, lower_bound=sig.lower_bound),
90
+ session_id=sig.session_id,
91
+ origin_path=path,
92
+ origin_uuid=sig.event_uuid,
93
+ cc_version=sig.cc_version,
94
+ payload=payload_of(sig),
95
+ signal=sig.signal,
96
+ )
97
+
98
+
99
+ def candidates_from(
100
+ path: Path, events: Sequence[TranscriptEvent], *streams: Iterator[MiningSignal]
101
+ ) -> Iterator[FeedbackCandidate]:
102
+ return (to_candidate(path, events, sig) for stream in streams for sig in stream if survives(events, sig))
103
+
104
+
105
+ def transcript_messages(path: Path, events: Sequence[TranscriptEvent]) -> Iterator[FeedbackCandidate]:
106
+ return candidates_from(path, events, iter_user_message_signals(events))
107
+
108
+
109
+ def plan_reviews(path: Path, events: Sequence[TranscriptEvent]) -> Iterator[FeedbackCandidate]:
110
+ return candidates_from(path, events, iter_plan_rejection_signals(events), iter_plan_reentry_signals(events))
111
+
112
+
113
+ def interrupt_rejections(path: Path, events: Sequence[TranscriptEvent]) -> Iterator[FeedbackCandidate]:
114
+ return candidates_from(path, events, iter_tool_denial_signals(events), iter_interrupt_marker_signals(events))
115
+
116
+
117
+ def detect(path: Path, events: Sequence[TranscriptEvent]) -> list[FeedbackCandidate]:
118
+ """Runs every detector over one transcript's events.
119
+
120
+ Args:
121
+ path: The transcript file the events came from.
122
+ events: The transcript's full ordered event stream.
123
+
124
+ Returns:
125
+ Every feedback candidate the detectors found, in detector order.
126
+ """
127
+ return list(
128
+ candidates_from(
129
+ path,
130
+ events,
131
+ iter_user_message_signals(events),
132
+ iter_plan_rejection_signals(events),
133
+ iter_plan_reentry_signals(events),
134
+ iter_tool_denial_signals(events),
135
+ iter_interrupt_marker_signals(events),
136
+ iter_review_comment_signals(events, formats()),
137
+ )
138
+ )
cc_pushback/formats.py ADDED
@@ -0,0 +1,98 @@
1
+ """cc-pushback's concrete code-review formats over the mining domain's parser infra.
2
+
3
+ The generic :class:`ReviewComment`/:class:`ReviewFormat` types and the
4
+ format-dispatch live in :mod:`cc_transcript.domains.mining`; this module supplies
5
+ cc-pushback's policy — the three review formats it recognizes — and injects them
6
+ into the domain's :func:`extract_all`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import TYPE_CHECKING
13
+
14
+ from cc_transcript.domains.mining import ReviewComment, ReviewFormat
15
+ from cc_transcript.domains.mining import extract_all as domain_extract_all
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Iterator
19
+
20
+ SUPERSET_INLINE_RE = re.compile(
21
+ r"^In ((?=\S*[./]|\S+?:L)\S+?)(?::L(\d+)(?:-(\d+))?)?: (.+)$", re.MULTILINE
22
+ )
23
+ CONDUCTOR_FINDING_RE = re.compile(
24
+ r"^- file: (?P<file>\S+?):(?P<line>\d+)\s*$"
25
+ r"(?:\n- theme: .+$)?"
26
+ r"(?:\n- claim: (?P<claim>.+)$)?"
27
+ r"(?:\n- suggestion: (?P<suggestion>.+)$)?",
28
+ re.MULTILINE,
29
+ )
30
+ CONDUCTOR_WORKSTREAM_HEADER_RE = re.compile(
31
+ r"^### (?P<id>[A-Z][\w-]*\d*)\s*\[(?P<kind>[A-Z]+)\]\s*—\s*(?P<title>.+)$",
32
+ re.MULTILINE,
33
+ )
34
+ WORKSTREAM_BODY_RE = re.compile(r"^(?:FIX|Tests): .+$", re.MULTILINE)
35
+
36
+
37
+ def extract_superset_inline(text: str) -> tuple[ReviewComment, ...]:
38
+ return tuple(
39
+ ReviewComment(
40
+ file=match.group(1),
41
+ line_start=int(match.group(2)) if match.group(2) else None,
42
+ line_end=int(match.group(3)) if match.group(3) else None,
43
+ comment=match.group(4).strip(),
44
+ )
45
+ for match in SUPERSET_INLINE_RE.finditer(text)
46
+ )
47
+
48
+
49
+ def extract_conductor_finding(text: str) -> tuple[ReviewComment, ...]:
50
+ return tuple(
51
+ ReviewComment(
52
+ file=match.group("file"),
53
+ line_start=int(match.group("line")),
54
+ line_end=None,
55
+ comment=" ".join(part.strip() for part in (match.group("claim"), match.group("suggestion")) if part),
56
+ )
57
+ for match in CONDUCTOR_FINDING_RE.finditer(text)
58
+ )
59
+
60
+
61
+ def extract_conductor_workstream(text: str) -> tuple[ReviewComment, ...]:
62
+ headers = list(CONDUCTOR_WORKSTREAM_HEADER_RE.finditer(text))
63
+ return tuple(
64
+ ReviewComment(
65
+ file=None,
66
+ line_start=None,
67
+ line_end=None,
68
+ comment=" ".join(
69
+ [f"{header.group('id')} [{header.group('kind')}] {header.group('title').strip()}"]
70
+ + [line.group(0).strip() for line in WORKSTREAM_BODY_RE.finditer(text[header.end() : end])]
71
+ ),
72
+ )
73
+ for header, end in zip(
74
+ headers,
75
+ [*(h.start() for h in headers[1:]), len(text)],
76
+ strict=True,
77
+ )
78
+ )
79
+
80
+
81
+ def formats() -> tuple[ReviewFormat, ...]:
82
+ return (
83
+ ReviewFormat("superset-inline", SUPERSET_INLINE_RE, extract_superset_inline),
84
+ ReviewFormat("conductor-finding", CONDUCTOR_FINDING_RE, extract_conductor_finding),
85
+ ReviewFormat("conductor-workstream", CONDUCTOR_WORKSTREAM_HEADER_RE, extract_conductor_workstream),
86
+ )
87
+
88
+
89
+ def extract_all(text: str) -> Iterator[tuple[ReviewFormat, ReviewComment]]:
90
+ """Yields every ``(format, comment)`` extracted by any of cc-pushback's formats.
91
+
92
+ Args:
93
+ text: The raw review message text.
94
+
95
+ Yields:
96
+ One pair per extracted comment, across all formats whose pattern matches.
97
+ """
98
+ return domain_extract_all(text, formats())
cc_pushback/markers.py ADDED
@@ -0,0 +1,25 @@
1
+ """Re-exports the transcript marker constants from the mining domain.
2
+
3
+ Deprecated: import these names from :mod:`cc_transcript.domains.mining`. This shim
4
+ keeps cc-pushback's historical import paths working for at least one release.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from cc_transcript.domains.mining import (
10
+ DENIAL_PREFIX,
11
+ EDIT_TOOLS,
12
+ INTERRUPT_MARKER_RE,
13
+ REENTRY_LOOKBACK,
14
+ USER_SAID_MARKER,
15
+ USER_SAID_TRAILER,
16
+ )
17
+
18
+ __all__ = [
19
+ "DENIAL_PREFIX",
20
+ "EDIT_TOOLS",
21
+ "INTERRUPT_MARKER_RE",
22
+ "REENTRY_LOOKBACK",
23
+ "USER_SAID_MARKER",
24
+ "USER_SAID_TRAILER",
25
+ ]
cc_pushback/models.py ADDED
@@ -0,0 +1,15 @@
1
+ """Re-exports the feedback candidate model from the mining domain.
2
+
3
+ Deprecated: import :class:`FeedbackCandidate`, :data:`DedupKey`, :func:`dedup_key`,
4
+ and :data:`SourceKind` from :mod:`cc_transcript.domains.mining`. This shim keeps
5
+ cc-pushback's historical import paths working for at least one release.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from cc_transcript.domains.mining import DedupKey, FeedbackCandidate, SourceKind, dedup_key
11
+
12
+ PUSHBACK_SOURCE_KINDS = ("transcript_message", "plan_review", "interrupt_rejection", "review_comment")
13
+ """The source kinds cc-pushback's detectors emit, for CLI choice lists."""
14
+
15
+ __all__ = ["PUSHBACK_SOURCE_KINDS", "DedupKey", "FeedbackCandidate", "SourceKind", "dedup_key"]
cc_pushback/nav.py ADDED
@@ -0,0 +1,31 @@
1
+ """Re-exports the transcript navigation helpers from the mining domain.
2
+
3
+ Deprecated: import these names from :mod:`cc_transcript.domains.mining`. This shim
4
+ keeps cc-pushback's historical import paths working for at least one release.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from cc_transcript.domains.mining import (
10
+ denial_results,
11
+ denied_tool_payload,
12
+ embedded_user_text,
13
+ interrupt_marker,
14
+ is_bare_interrupt_marker,
15
+ last_edit_index,
16
+ marker_in,
17
+ next_user_message,
18
+ tool_uses,
19
+ )
20
+
21
+ __all__ = [
22
+ "denial_results",
23
+ "denied_tool_payload",
24
+ "embedded_user_text",
25
+ "interrupt_marker",
26
+ "is_bare_interrupt_marker",
27
+ "last_edit_index",
28
+ "marker_in",
29
+ "next_user_message",
30
+ "tool_uses",
31
+ ]
cc_pushback/py.typed ADDED
File without changes