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.
- cc_pushback/__init__.py +28 -0
- cc_pushback/__main__.py +6 -0
- cc_pushback/claude.py +46 -0
- cc_pushback/cli.py +138 -0
- cc_pushback/context.py +11 -0
- cc_pushback/detectors.py +138 -0
- cc_pushback/formats.py +98 -0
- cc_pushback/markers.py +25 -0
- cc_pushback/models.py +15 -0
- cc_pushback/nav.py +31 -0
- cc_pushback/py.typed +0 -0
- cc_pushback/report.py +484 -0
- cc_pushback/scan.py +58 -0
- cc_pushback/serve.py +60 -0
- cc_pushback/spec.py +37 -0
- cc_pushback/store.py +34 -0
- cc_pushback-0.1.0.dist-info/METADATA +135 -0
- cc_pushback-0.1.0.dist-info/RECORD +21 -0
- cc_pushback-0.1.0.dist-info/WHEEL +4 -0
- cc_pushback-0.1.0.dist-info/entry_points.txt +3 -0
- cc_pushback-0.1.0.dist-info/licenses/LICENSE +133 -0
cc_pushback/__init__.py
ADDED
|
@@ -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
|
+
]
|
cc_pushback/__main__.py
ADDED
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"]
|
cc_pushback/detectors.py
ADDED
|
@@ -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
|