pushback 0.1.0__tar.gz

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.
pushback-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Intikhab Azam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.4
2
+ Name: pushback
3
+ Version: 0.1.0
4
+ Summary: How often do you correct your coding agent, and at what kind of work? Measured from your own Claude Code transcripts.
5
+ Author: Intikhab Azam
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/intikhab49/pushback
8
+ Project-URL: Repository, https://github.com/intikhab49/pushback
9
+ Project-URL: Issues, https://github.com/intikhab49/pushback/issues
10
+ Keywords: claude-code,coding-agents,ai-agents,llm,claude,claude-md,dpo,preference-data,llm-evaluation
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Topic :: Software Development
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: anthropic>=1.0
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=8; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # pushback
26
+
27
+ How often do you correct your coding agent, and on what kind of work?
28
+
29
+ `pushback` reads your Claude Code transcripts, labels every message you sent
30
+ with Claude, and tells you what share of your messages were corrections,
31
+ broken down by the kind of work in progress (code, writing, media, research,
32
+ ops, meta). A hand-check step tells you how far to trust the labels.
33
+
34
+ On the author's own 1,633 messages:
35
+
36
+ | task | messages | corrections | rate | 95% CI |
37
+ |---|---:|---:|---:|---|
38
+ | media | 114 | 49 | 43.0% | 34.3%–52.2% |
39
+ | writing | 429 | 106 | 24.7% | 20.9%–29.0% |
40
+ | research | 188 | 13 | 6.9% | 4.1%–11.5% |
41
+ | code | 340 | 20 | 5.9% | 3.8%–8.9% |
42
+ | meta | 297 | 14 | 4.7% | 2.8%–7.8% |
43
+ | ops | 265 | 12 | 4.5% | 2.6%–7.7% |
44
+
45
+ Against 91 hand-checked messages the labels had precision 0.97 and recall 0.80.
46
+ That's one person's logs. Run it on yours.
47
+
48
+ These rates describe a workflow, not a model. The author's code work runs
49
+ through skills, reference files, memory and a plan before the agent writes
50
+ anything, and CI catches mistakes before a human has to. Writing got none of
51
+ that. A low rate means the process around the agent is doing its job.
52
+
53
+ ## Run it
54
+
55
+ ```
56
+ pip install pushback # or, from a clone: pip install -e .
57
+ pushback extract # reads ~/.claude/projects, writes ./pushback-data/
58
+ pushback label # sends your messages to Claude, resumable
59
+ pushback audit # hand-check 40 messages
60
+ pushback report --markdown # the table, with the audit folded in
61
+ pushback rules # draft CLAUDE.md rules from your recurring corrections
62
+ pushback export # your corrections as prompt/chosen/rejected pairs
63
+ ```
64
+
65
+ `label` uses the Anthropic SDK, so it picks up `ANTHROPIC_API_KEY` or an
66
+ `ant auth login` profile. It defaults to `claude-opus-5` at low effort. Change
67
+ it with `--model`. To send through a gateway, set `ANTHROPIC_BASE_URL`.
68
+
69
+ ## Turn recurring corrections into rules
70
+
71
+ ```
72
+ pushback rules # drafts rules into pushback-data/rules.md
73
+ pushback rules --existing CLAUDE.md notes/*.md # also check against the rules you already have
74
+ pushback rules --prompt-file # no API key? writes the request to a file instead
75
+ pushback rules --from-response reply.json # ...and reads Claude's answer back
76
+ ```
77
+
78
+ The model groups corrections that share a cause and drafts one CLAUDE.md
79
+ instruction per group. Support is counted from the correction ids it cites,
80
+ not from its own numbers, and a rule needs at least 3 real corrections.
81
+
82
+ When you pass your existing rule files, the output splits in two:
83
+
84
+ - **Rules you already have that keep getting broken.** These are the useful
85
+ ones. A rule that exists and still gets corrected isn't working: it's too
86
+ vague, or the agent doesn't read it at the right moment.
87
+ - **New rules to consider.** Recurring corrections with no rule behind them.
88
+
89
+ No API key: `--prompt-file` writes the whole request to `rules-prompt.md`.
90
+ Ask Claude Code to answer it, save the JSON reply, then run `--from-response`.
91
+
92
+ ## Export your corrections as preference pairs
93
+
94
+ ```
95
+ pushback export # all pairs -> pushback-data/dpo.jsonl
96
+ pushback export --ctype writing_content tone_style # the cleanest pairs
97
+ pushback export --minimal # only prompt/chosen/rejected (TRL DPO columns)
98
+ ```
99
+
100
+ Each correction you made becomes one row:
101
+
102
+ - `prompt`: your message the agent was answering
103
+ - `rejected`: the agent turn you corrected
104
+ - `chosen`: the agent's reply to your correction, kept only if your next message wasn't another correction
105
+ - `feedback`: the correction itself, for critique-and-revise formats
106
+
107
+ On the author's logs, 214 corrections gave 124 pairs. The biggest loss:
108
+ 70 corrections (a third) had their fix corrected too, so there was no
109
+ accepted answer to pair with.
110
+
111
+ What to know before you train on it:
112
+
113
+ - "You didn't correct it again" is weak evidence that the fix was good.
114
+ - The fix was written after seeing your feedback. For corrections of a wrong
115
+ assumption ("I already sent that"), `chosen` answers the correction rather
116
+ than the prompt. Those make poor DPO pairs, so filter with `--ctype`.
117
+ - Only agent text is exported. File edits and commands are tool calls, so a
118
+ code correction's pair may hold the explanation without the diff. Use
119
+ `--max-tools` to drop tool-heavy turns.
120
+ - Secrets are scrubbed on a best-effort basis (API key shapes, tokens,
121
+ `NAME_KEY=value`). Read the file before you use it.
122
+ - Your transcripts probably contain other people's information. Keep the
123
+ export local unless every conversation in it is yours to share.
124
+ - If the agent is Claude, Anthropic's terms restrict using its outputs to
125
+ build competing models. Treat this as a personal dataset or eval set.
126
+
127
+ ## What counts as a correction
128
+
129
+ A message where you reject, fix or redirect something the agent just did,
130
+ said, wrote or proposed. New tasks, answers to its questions, picking between
131
+ its options, approvals and pasted logs don't count. The full rubric is in
132
+ `src/pushback/prompt.py`. If you change it, run `audit` again.
133
+
134
+ Rejected tool calls and interrupts carry no text, so they're counted
135
+ separately and bucketed by the action you stopped (a code edit, a shell
136
+ command, and so on).
137
+
138
+ ## Privacy
139
+
140
+ - `extract` and `report` never leave your machine. `report` prints counts only.
141
+ - `label` sends each message, plus the end of the agent reply before it, to the
142
+ model provider. If your logs contain client work, check that provider's data
143
+ policy first. `label` asks before it sends anything.
144
+ - `pushback-data/` holds your raw messages. It's in `.gitignore`. Keep it there.
145
+
146
+ ## Your history is shorter than you think
147
+
148
+ Claude Code deletes transcripts older than 30 days by default. To keep more,
149
+ set `cleanupPeriodDays` in `~/.claude/settings.json`, for example
150
+ `"cleanupPeriodDays": 365`. It only affects transcripts that still exist.
151
+
152
+ ## How the numbers are computed
153
+
154
+ - Rates per task come with Wilson 95% intervals.
155
+ - `audit` samples half from messages the model flagged and half from the rest.
156
+ The report estimates the true count as
157
+ `flagged × precision + unflagged × miss rate`, with a range built from both
158
+ strata's intervals.
159
+ - A batch that fails stays unlabelled and is retried on the next run. It's
160
+ never counted as "not a correction".
161
+
162
+ ## Limits
163
+
164
+ - Claude Code transcripts only, for now.
165
+ - Task type is judged from the last agent reply and your message, not the whole session.
166
+ - The rubric was tuned on one person's logs.
167
+
168
+ MIT licensed.
@@ -0,0 +1,144 @@
1
+ # pushback
2
+
3
+ How often do you correct your coding agent, and on what kind of work?
4
+
5
+ `pushback` reads your Claude Code transcripts, labels every message you sent
6
+ with Claude, and tells you what share of your messages were corrections,
7
+ broken down by the kind of work in progress (code, writing, media, research,
8
+ ops, meta). A hand-check step tells you how far to trust the labels.
9
+
10
+ On the author's own 1,633 messages:
11
+
12
+ | task | messages | corrections | rate | 95% CI |
13
+ |---|---:|---:|---:|---|
14
+ | media | 114 | 49 | 43.0% | 34.3%–52.2% |
15
+ | writing | 429 | 106 | 24.7% | 20.9%–29.0% |
16
+ | research | 188 | 13 | 6.9% | 4.1%–11.5% |
17
+ | code | 340 | 20 | 5.9% | 3.8%–8.9% |
18
+ | meta | 297 | 14 | 4.7% | 2.8%–7.8% |
19
+ | ops | 265 | 12 | 4.5% | 2.6%–7.7% |
20
+
21
+ Against 91 hand-checked messages the labels had precision 0.97 and recall 0.80.
22
+ That's one person's logs. Run it on yours.
23
+
24
+ These rates describe a workflow, not a model. The author's code work runs
25
+ through skills, reference files, memory and a plan before the agent writes
26
+ anything, and CI catches mistakes before a human has to. Writing got none of
27
+ that. A low rate means the process around the agent is doing its job.
28
+
29
+ ## Run it
30
+
31
+ ```
32
+ pip install pushback # or, from a clone: pip install -e .
33
+ pushback extract # reads ~/.claude/projects, writes ./pushback-data/
34
+ pushback label # sends your messages to Claude, resumable
35
+ pushback audit # hand-check 40 messages
36
+ pushback report --markdown # the table, with the audit folded in
37
+ pushback rules # draft CLAUDE.md rules from your recurring corrections
38
+ pushback export # your corrections as prompt/chosen/rejected pairs
39
+ ```
40
+
41
+ `label` uses the Anthropic SDK, so it picks up `ANTHROPIC_API_KEY` or an
42
+ `ant auth login` profile. It defaults to `claude-opus-5` at low effort. Change
43
+ it with `--model`. To send through a gateway, set `ANTHROPIC_BASE_URL`.
44
+
45
+ ## Turn recurring corrections into rules
46
+
47
+ ```
48
+ pushback rules # drafts rules into pushback-data/rules.md
49
+ pushback rules --existing CLAUDE.md notes/*.md # also check against the rules you already have
50
+ pushback rules --prompt-file # no API key? writes the request to a file instead
51
+ pushback rules --from-response reply.json # ...and reads Claude's answer back
52
+ ```
53
+
54
+ The model groups corrections that share a cause and drafts one CLAUDE.md
55
+ instruction per group. Support is counted from the correction ids it cites,
56
+ not from its own numbers, and a rule needs at least 3 real corrections.
57
+
58
+ When you pass your existing rule files, the output splits in two:
59
+
60
+ - **Rules you already have that keep getting broken.** These are the useful
61
+ ones. A rule that exists and still gets corrected isn't working: it's too
62
+ vague, or the agent doesn't read it at the right moment.
63
+ - **New rules to consider.** Recurring corrections with no rule behind them.
64
+
65
+ No API key: `--prompt-file` writes the whole request to `rules-prompt.md`.
66
+ Ask Claude Code to answer it, save the JSON reply, then run `--from-response`.
67
+
68
+ ## Export your corrections as preference pairs
69
+
70
+ ```
71
+ pushback export # all pairs -> pushback-data/dpo.jsonl
72
+ pushback export --ctype writing_content tone_style # the cleanest pairs
73
+ pushback export --minimal # only prompt/chosen/rejected (TRL DPO columns)
74
+ ```
75
+
76
+ Each correction you made becomes one row:
77
+
78
+ - `prompt`: your message the agent was answering
79
+ - `rejected`: the agent turn you corrected
80
+ - `chosen`: the agent's reply to your correction, kept only if your next message wasn't another correction
81
+ - `feedback`: the correction itself, for critique-and-revise formats
82
+
83
+ On the author's logs, 214 corrections gave 124 pairs. The biggest loss:
84
+ 70 corrections (a third) had their fix corrected too, so there was no
85
+ accepted answer to pair with.
86
+
87
+ What to know before you train on it:
88
+
89
+ - "You didn't correct it again" is weak evidence that the fix was good.
90
+ - The fix was written after seeing your feedback. For corrections of a wrong
91
+ assumption ("I already sent that"), `chosen` answers the correction rather
92
+ than the prompt. Those make poor DPO pairs, so filter with `--ctype`.
93
+ - Only agent text is exported. File edits and commands are tool calls, so a
94
+ code correction's pair may hold the explanation without the diff. Use
95
+ `--max-tools` to drop tool-heavy turns.
96
+ - Secrets are scrubbed on a best-effort basis (API key shapes, tokens,
97
+ `NAME_KEY=value`). Read the file before you use it.
98
+ - Your transcripts probably contain other people's information. Keep the
99
+ export local unless every conversation in it is yours to share.
100
+ - If the agent is Claude, Anthropic's terms restrict using its outputs to
101
+ build competing models. Treat this as a personal dataset or eval set.
102
+
103
+ ## What counts as a correction
104
+
105
+ A message where you reject, fix or redirect something the agent just did,
106
+ said, wrote or proposed. New tasks, answers to its questions, picking between
107
+ its options, approvals and pasted logs don't count. The full rubric is in
108
+ `src/pushback/prompt.py`. If you change it, run `audit` again.
109
+
110
+ Rejected tool calls and interrupts carry no text, so they're counted
111
+ separately and bucketed by the action you stopped (a code edit, a shell
112
+ command, and so on).
113
+
114
+ ## Privacy
115
+
116
+ - `extract` and `report` never leave your machine. `report` prints counts only.
117
+ - `label` sends each message, plus the end of the agent reply before it, to the
118
+ model provider. If your logs contain client work, check that provider's data
119
+ policy first. `label` asks before it sends anything.
120
+ - `pushback-data/` holds your raw messages. It's in `.gitignore`. Keep it there.
121
+
122
+ ## Your history is shorter than you think
123
+
124
+ Claude Code deletes transcripts older than 30 days by default. To keep more,
125
+ set `cleanupPeriodDays` in `~/.claude/settings.json`, for example
126
+ `"cleanupPeriodDays": 365`. It only affects transcripts that still exist.
127
+
128
+ ## How the numbers are computed
129
+
130
+ - Rates per task come with Wilson 95% intervals.
131
+ - `audit` samples half from messages the model flagged and half from the rest.
132
+ The report estimates the true count as
133
+ `flagged × precision + unflagged × miss rate`, with a range built from both
134
+ strata's intervals.
135
+ - A batch that fails stays unlabelled and is retried on the next run. It's
136
+ never counted as "not a correction".
137
+
138
+ ## Limits
139
+
140
+ - Claude Code transcripts only, for now.
141
+ - Task type is judged from the last agent reply and your message, not the whole session.
142
+ - The rubric was tuned on one person's logs.
143
+
144
+ MIT licensed.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pushback"
7
+ version = "0.1.0"
8
+ description = "How often do you correct your coding agent, and at what kind of work? Measured from your own Claude Code transcripts."
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ dependencies = ["anthropic>=1.0"]
13
+ authors = [{name = "Intikhab Azam"}]
14
+ keywords = ["claude-code", "coding-agents", "ai-agents", "llm", "claude", "claude-md", "dpo", "preference-data", "llm-evaluation"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Topic :: Software Development",
21
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/intikhab49/pushback"
26
+ Repository = "https://github.com/intikhab49/pushback"
27
+ Issues = "https://github.com/intikhab49/pushback/issues"
28
+
29
+ [project.optional-dependencies]
30
+ test = ["pytest>=8"]
31
+
32
+ [project.scripts]
33
+ pushback = "pushback.cli:main"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,63 @@
1
+ """Hand-check a stratified sample so the report can say how far to trust the labels.
2
+
3
+ Half the sample comes from messages the model called corrections, half from
4
+ the rest. Your answers never leave your machine.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import random
12
+
13
+ PROMPT = "Is this a correction of the agent? [y]es / [n]o / [s]kip / [q]uit: "
14
+
15
+
16
+ def load_audit(path: str) -> dict[str, dict]:
17
+ audit: dict[str, dict] = {}
18
+ if os.path.exists(path):
19
+ with open(path, encoding="utf-8") as fh:
20
+ for line in fh:
21
+ if line.strip():
22
+ d = json.loads(line)
23
+ audit[d["id"]] = d
24
+ return audit
25
+
26
+
27
+ def sample(messages: dict[str, dict], labels: dict[str, dict], done: set[str], n: int, seed: int) -> list[tuple[str, str]]:
28
+ rng = random.Random(seed)
29
+ pos = sorted(i for i, d in labels.items() if d["correction"] and i not in done and i in messages)
30
+ neg = sorted(i for i, d in labels.items() if not d["correction"] and i not in done and i in messages)
31
+ rng.shuffle(pos)
32
+ rng.shuffle(neg)
33
+ half = n // 2
34
+ picked = [(i, "flagged") for i in pos[:half]] + [(i, "unflagged") for i in neg[: n - half]]
35
+ rng.shuffle(picked) # don't let the order hint at the model's answer
36
+ return picked
37
+
38
+
39
+ def run(messages, labels, audit_path, n=40, seed=0, ask=input, show=print) -> int:
40
+ done = load_audit(audit_path)
41
+ todo = sample(messages, labels, set(done), n, seed)
42
+ if not todo:
43
+ show("nothing left to audit")
44
+ return 0
45
+ answered = 0
46
+ with open(audit_path, "a", encoding="utf-8") as out:
47
+ for k, (i, stratum) in enumerate(todo, 1):
48
+ m = messages[i]
49
+ show(f"\n--- {k}/{len(todo)} ---")
50
+ show(f"AGENT: ...{m['prev_assistant'][-300:]}")
51
+ show(f"YOU: {m['user']}")
52
+ while True:
53
+ a = ask(PROMPT).strip().lower()[:1]
54
+ if a in ("y", "n", "s", "q"):
55
+ break
56
+ if a == "q":
57
+ break
58
+ if a == "s":
59
+ continue
60
+ out.write(json.dumps({"id": i, "stratum": stratum, "human": a == "y"}) + "\n")
61
+ out.flush()
62
+ answered += 1
63
+ return answered
@@ -0,0 +1,231 @@
1
+ """pushback: how often do you correct your coding agent, and at what kind of work?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+
10
+ from . import audit as audit_mod
11
+ from . import export as export_mod
12
+ from . import extract as extract_mod
13
+ from . import label as label_mod
14
+ from . import report as report_mod
15
+ from . import rules as rules_mod
16
+
17
+ DATA = "pushback-data"
18
+
19
+
20
+ def _paths(data_dir: str) -> dict[str, str]:
21
+ return {k: os.path.join(data_dir, f) for k, f in {
22
+ "messages": "messages.jsonl", "labels": "labels.jsonl",
23
+ "audit": "audit.jsonl", "silent": "silent.json", "report": "report.md",
24
+ "turns": "turns.jsonl", "dpo": "dpo.jsonl",
25
+ "rules": "rules.md", "rules_items": "rules-items.json", "rules_prompt": "rules-prompt.md",
26
+ }.items()}
27
+
28
+
29
+ def _load_messages(path: str) -> dict[str, dict]:
30
+ if not os.path.exists(path):
31
+ sys.exit(f"{path} not found. Run `pushback extract` first.")
32
+ with open(path, encoding="utf-8") as fh:
33
+ return {m["id"]: m for m in map(json.loads, fh) if m}
34
+
35
+
36
+ def cmd_extract(a):
37
+ p = _paths(a.data)
38
+ os.makedirs(a.data, exist_ok=True)
39
+ ex = extract_mod.extract(a.root, tuple(a.exclude or ()))
40
+ with open(p["messages"], "w", encoding="utf-8") as fh:
41
+ for m in ex.messages:
42
+ fh.write(json.dumps(m, ensure_ascii=False) + "\n")
43
+ with open(p["silent"], "w", encoding="utf-8") as fh:
44
+ json.dump({"rejections": ex.rejections, "interrupts": ex.interrupts}, fh)
45
+ with open(p["turns"], "w", encoding="utf-8") as fh:
46
+ for mid, t in ex.turns.items():
47
+ fh.write(json.dumps({"id": mid, **t}, ensure_ascii=False) + "\n")
48
+ print(f"{ex.sessions} sessions -> {len(ex.messages)} messages, "
49
+ f"{sum(ex.rejections.values())} rejected tool calls, {sum(ex.interrupts.values())} interrupts")
50
+ print(f"written to {a.data}/ (this folder holds your raw messages; keep it out of git)")
51
+
52
+
53
+ def cmd_label(a):
54
+ p = _paths(a.data)
55
+ messages = list(_load_messages(p["messages"]).values())
56
+ base = os.environ.get("ANTHROPIC_BASE_URL")
57
+ print(f"Sending {len(messages)} messages to {base or 'the Anthropic API'} with model {a.model}.")
58
+ print("Your messages leave this machine for that provider. Check its data policy before using client logs.")
59
+ if not a.yes and input("Continue? [y/N] ").strip().lower() != "y":
60
+ sys.exit("aborted")
61
+ written, failed = label_mod.run(messages, p["labels"], a.model, a.batch_size, a.workers, a.effort)
62
+ print(f"done: {written} new labels, {failed} failed batches" + (" (re-run to retry them)" if failed else ""))
63
+
64
+
65
+ def cmd_audit(a):
66
+ p = _paths(a.data)
67
+ messages = _load_messages(p["messages"])
68
+ labels = label_mod.load_labels(p["labels"])
69
+ if not labels:
70
+ sys.exit("no labels yet. Run `pushback label` first.")
71
+ n = audit_mod.run(messages, labels, p["audit"], a.n, a.seed)
72
+ print(f"\nsaved {n} answers to {p['audit']}")
73
+
74
+
75
+ def cmd_report(a):
76
+ p = _paths(a.data)
77
+ labels = label_mod.load_labels(p["labels"])
78
+ if not labels:
79
+ sys.exit("no labels yet. Run `pushback label` first.")
80
+ silent = json.load(open(p["silent"], encoding="utf-8")) if os.path.exists(p["silent"]) else None
81
+ text = report_mod.render(report_mod.build(labels, audit_mod.load_audit(p["audit"]), silent))
82
+ print(text)
83
+ if a.markdown:
84
+ with open(p["report"], "w", encoding="utf-8") as fh:
85
+ fh.write(text + "\n")
86
+ print(f"\nwritten to {p['report']} (counts only, safe to share)")
87
+
88
+
89
+ def cmd_export(a):
90
+ p = _paths(a.data)
91
+ messages = list(_load_messages(p["messages"]).values())
92
+ labels = label_mod.load_labels(p["labels"])
93
+ if not labels:
94
+ sys.exit("no labels yet. Run `pushback label` first.")
95
+ if not os.path.exists(p["turns"]):
96
+ sys.exit(f"{p['turns']} not found. Re-run `pushback extract` (it now saves full agent turns).")
97
+ with open(p["turns"], encoding="utf-8") as fh:
98
+ turns = {t["id"]: t for t in map(json.loads, fh)}
99
+ pairs, skipped = export_mod.build_pairs(
100
+ messages, labels, turns,
101
+ tasks=set(a.task) if a.task else None, ctypes=set(a.ctype) if a.ctype else None,
102
+ high_only=a.high_only, max_tools=a.max_tools,
103
+ )
104
+ redacted = export_mod.scrub(pairs)
105
+ if a.minimal:
106
+ pairs = [{k: x[k] for k in ("prompt", "chosen", "rejected")} for x in pairs]
107
+ out = a.out or p["dpo"]
108
+ with open(out, "w", encoding="utf-8") as fh:
109
+ for x in pairs:
110
+ fh.write(json.dumps(x, ensure_ascii=False) + "\n")
111
+ corrections = sum(1 for d in labels.values() if d["correction"])
112
+ print(f"{len(pairs)} pairs from {corrections} corrections -> {out}")
113
+ for reason, n in skipped.most_common():
114
+ print(f" skipped {n}: {reason}")
115
+ print(f" {redacted} likely secrets replaced with [REDACTED] (best effort: read the file before using it)")
116
+ print("This file holds raw agent and user text. It stays on your machine unless you move it.")
117
+
118
+
119
+ def _default_existing() -> list[str]:
120
+ path = os.path.join(os.path.expanduser("~"), ".claude", "CLAUDE.md")
121
+ return [path] if os.path.exists(path) else []
122
+
123
+
124
+ def cmd_rules(a):
125
+ p = _paths(a.data)
126
+ labels = label_mod.load_labels(p["labels"])
127
+ if not labels:
128
+ sys.exit("no labels yet. Run `pushback label` first.")
129
+
130
+ if a.from_response:
131
+ with open(p["rules_items"], encoding="utf-8") as fh:
132
+ items = json.load(fh)
133
+ with open(a.from_response, encoding="utf-8") as fh:
134
+ text = fh.read()
135
+ raw = json.loads(text[text.find("{"): text.rfind("}") + 1]) # tolerate prose or fences around the JSON
136
+ else:
137
+ if not os.path.exists(p["turns"]):
138
+ sys.exit(f"{p['turns']} not found. Re-run `pushback extract`.")
139
+ with open(p["turns"], encoding="utf-8") as fh:
140
+ turns = {t["id"]: t for t in map(json.loads, fh)}
141
+ items = rules_mod.collect(labels, turns, set(a.task) if a.task else None,
142
+ set(a.ctype) if a.ctype else None, a.limit)
143
+ if not items:
144
+ sys.exit("no corrections match those filters.")
145
+ existing_paths = a.existing if a.existing is not None else _default_existing()
146
+ existing = "\n".join(open(x, encoding="utf-8", errors="ignore").read() for x in existing_paths)
147
+ request = rules_mod.build_request(items, existing, a.model, a.effort)
148
+ with open(p["rules_items"], "w", encoding="utf-8") as fh:
149
+ json.dump(items, fh, ensure_ascii=False)
150
+ print(f"{len(items)} corrections, checked against {len(existing_paths)} existing rule file(s)")
151
+
152
+ if a.prompt_file:
153
+ with open(p["rules_prompt"], "w", encoding="utf-8") as fh:
154
+ fh.write(request["system"] + "\n\n" + request["messages"][0]["content"] + "\n\n"
155
+ + "Reply with only a JSON object matching this schema:\n"
156
+ + json.dumps(rules_mod.SCHEMA, indent=2) + "\n")
157
+ print(f"prompt written to {p['rules_prompt']}")
158
+ print("Give it to Claude (for example: ask Claude Code to answer the file), save the JSON reply,")
159
+ print("then run: pushback rules --from-response <reply file>")
160
+ return
161
+
162
+ base = os.environ.get("ANTHROPIC_BASE_URL")
163
+ print(f"Sending them to {base or 'the Anthropic API'} with model {a.model}.")
164
+ if not a.yes and input("Continue? [y/N] ").strip().lower() != "y":
165
+ sys.exit("aborted")
166
+ import anthropic
167
+ raw = rules_mod.ask(anthropic.Anthropic(), request)
168
+
169
+ found = rules_mod.parse(raw, items, a.min_support)
170
+ with open(p["rules"], "w", encoding="utf-8") as fh:
171
+ fh.write(rules_mod.render(found, len(items)))
172
+ broken = sum(1 for r in found if r["covered_by"])
173
+ print(f"{len(found)} rules ({broken} you already have but keep breaking) -> {p['rules']}")
174
+
175
+
176
+ def main(argv=None):
177
+ ap = argparse.ArgumentParser(prog="pushback", description=__doc__)
178
+ ap.add_argument("--data", default=DATA, help=f"working folder (default: ./{DATA})")
179
+ sub = ap.add_subparsers(dest="cmd", required=True)
180
+
181
+ e = sub.add_parser("extract", help="pull your messages out of Claude Code transcripts")
182
+ e.add_argument("--root", default=extract_mod.DEFAULT_ROOT, help="transcripts folder")
183
+ e.add_argument("--exclude", nargs="*", help="session id prefixes to skip")
184
+ e.set_defaults(func=cmd_extract)
185
+
186
+ lb = sub.add_parser("label", help="label each message with Claude (resumable)")
187
+ lb.add_argument("--model", default=label_mod.DEFAULT_MODEL)
188
+ lb.add_argument("--effort", default="low", choices=["low", "medium", "high"])
189
+ lb.add_argument("--batch-size", type=int, default=40)
190
+ lb.add_argument("--workers", type=int, default=4)
191
+ lb.add_argument("-y", "--yes", action="store_true", help="skip the data-leaves-your-machine prompt")
192
+ lb.set_defaults(func=cmd_label)
193
+
194
+ au = sub.add_parser("audit", help="hand-check a sample so the report can bound the error")
195
+ au.add_argument("-n", type=int, default=40)
196
+ au.add_argument("--seed", type=int, default=0)
197
+ au.set_defaults(func=cmd_audit)
198
+
199
+ ex = sub.add_parser("export", help="write correction pairs as prompt/chosen/rejected JSONL")
200
+ ex.add_argument("--task", nargs="*", help="only these tasks, e.g. --task writing media")
201
+ ex.add_argument("--ctype", nargs="*",
202
+ help="only these correction types; writing_content and tone_style make the cleanest pairs")
203
+ ex.add_argument("--high-only", action="store_true", help="only high-confidence labels")
204
+ ex.add_argument("--max-tools", type=int, help="drop pairs where either turn made more tool calls than this")
205
+ ex.add_argument("--minimal", action="store_true", help="only prompt/chosen/rejected (TRL DPO columns)")
206
+ ex.add_argument("--out", help="output path (default: <data>/dpo.jsonl)")
207
+ ex.set_defaults(func=cmd_export)
208
+
209
+ ru = sub.add_parser("rules", help="draft CLAUDE.md rules from your recurring corrections")
210
+ ru.add_argument("--task", nargs="*")
211
+ ru.add_argument("--ctype", nargs="*")
212
+ ru.add_argument("--limit", type=int, default=400, help="most recent N corrections (default 400)")
213
+ ru.add_argument("--min-support", type=int, default=3, help="corrections needed per rule (default 3)")
214
+ ru.add_argument("--existing", nargs="*", help="rule files to check against (default: ~/.claude/CLAUDE.md)")
215
+ ru.add_argument("--model", default=label_mod.DEFAULT_MODEL)
216
+ ru.add_argument("--effort", default="high", choices=["low", "medium", "high"])
217
+ ru.add_argument("--prompt-file", action="store_true", help="write the request to a file instead of calling the API")
218
+ ru.add_argument("--from-response", help="read the model's JSON reply from this file")
219
+ ru.add_argument("-y", "--yes", action="store_true")
220
+ ru.set_defaults(func=cmd_rules)
221
+
222
+ rp = sub.add_parser("report", help="print the correction-rate table")
223
+ rp.add_argument("--markdown", action="store_true", help="also write report.md")
224
+ rp.set_defaults(func=cmd_report)
225
+
226
+ args = ap.parse_args(argv)
227
+ args.func(args)
228
+
229
+
230
+ if __name__ == "__main__":
231
+ main()