whobrokeit 0.0.0-stage → 0.1.1

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Berkan Atik
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.
package/README.md CHANGED
@@ -1,3 +1,143 @@
1
- # Temporary Holding Version
1
+ # whobrokeit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Find the commit that broke it, and prove it.**
4
+
5
+ English · [Türkçe](README.tr.md)
6
+
7
+ Describe a bug in one sentence. An AI agent writes a test that shows it, whobrokeit walks your git history to the first commit where that test fails, and proves the result: the test fails at that commit and passes at its parent. Then it explains what went wrong and suggests a fix, which it shows only after the test passes with it.
8
+
9
+ <p align="center">
10
+ <img src="assets/demo.gif" alt="whobrokeit finds the commit that broke subtotal(), in 37 seconds" width="860">
11
+ </p>
12
+
13
+ ```sh
14
+ npx whobrokeit demo # try it on a generated repo with a hidden bug (no AI needed)
15
+ npx whobrokeit demo --ai # the same, with an AI agent writing the test
16
+ ```
17
+
18
+ ## Use it
19
+
20
+ Describe the bug, from anywhere inside your repo:
21
+
22
+ ```sh
23
+ whobrokeit "Adding 3 mugs costs the same as 1 mug."
24
+ ```
25
+
26
+ This needs [Claude Code](https://github.com/anthropics/claude-code) or [Codex](https://github.com/openai/codex) installed and logged in. whobrokeit uses whichever it finds.
27
+
28
+ Or bring your own failing test, and no AI is involved:
29
+
30
+ ```sh
31
+ whobrokeit --test "npm test -- cart.test.js"
32
+ whobrokeit --test "pytest tests/test_cart.py -x"
33
+ whobrokeit --test "node repro.mjs" --with repro.mjs # a test you haven't committed yet
34
+ ```
35
+
36
+ Any command works. Exit code 0 means it works, 125 means this commit can't be tested (skip it), anything else means broken. Add `--explain` to get the explanation and a fix in this mode too.
37
+
38
+ ## On GitHub issues
39
+
40
+ whobrokeit can also run as a GitHub Action. When you label an issue `whobrokeit`, or comment `/whobrokeit`, it looks for the commit that broke it and replies on the issue with the culprit, the proof and a fix it has tested.
41
+
42
+ Add an `ANTHROPIC_API_KEY` secret to the repo, then save this as `.github/workflows/whobrokeit.yml`:
43
+
44
+ ```yaml
45
+ name: whobrokeit
46
+
47
+ on:
48
+ issues:
49
+ types: [labeled]
50
+ issue_comment:
51
+ types: [created]
52
+
53
+ permissions:
54
+ contents: read
55
+ issues: write
56
+
57
+ jobs:
58
+ whobrokeit:
59
+ # Only people who can label issues, or members and collaborators who comment /whobrokeit.
60
+ if: >-
61
+ (github.event_name == 'issues' && github.event.label.name == 'whobrokeit') ||
62
+ (github.event_name == 'issue_comment' && !github.event.issue.pull_request &&
63
+ startsWith(github.event.comment.body, '/whobrokeit') &&
64
+ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
65
+ runs-on: ubuntu-latest
66
+ timeout-minutes: 60
67
+ steps:
68
+ - uses: actions/checkout@v4
69
+ with:
70
+ fetch-depth: 0 # whobrokeit needs the history
71
+ persist-credentials: false
72
+ - uses: actions/setup-node@v4
73
+ with:
74
+ node-version: 22 # whatever your project uses
75
+ - uses: BerkanAtik/whobrokeit@v0
76
+ with:
77
+ anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
78
+ ```
79
+
80
+ By default it works from the issue's title and body. Text after `/whobrokeit` replaces them, so you can put the bug more precisely: `/whobrokeit Adding 3 mugs costs the same as 1 mug.`
81
+
82
+ | Input | |
83
+ |---|---|
84
+ | `anthropic-api-key` | Anthropic API key. Or `claude-code-oauth-token`, made with `claude setup-token`, to use a Claude subscription. |
85
+ | `bug` | What is broken. Default: the comment text after `/whobrokeit`, else the issue. |
86
+ | `test` | Your own failing test command. No AI writes the test then. |
87
+ | `explain` | `auto` (default), `true` or `false`. |
88
+ | `agent`, `model`, `good`, `bad`, `setup`, `timeout` | As on the command line. `setup: none` turns installs off. |
89
+ | `working-directory` | The repository to search, if it is not the workspace root. |
90
+ | `comment` | Set to `false` to skip the issue comment. The report is in the job summary either way. |
91
+
92
+ Outputs: `exit-code`, `commit` (short hash of the culprit) and `report` (path to the Markdown report). The job fails only on errors. A bug it could not reproduce is reported on the issue like any other result.
93
+
94
+ Read an issue before you start a run on it. The agent only reads code, but whobrokeit runs the test it writes, and issue text can try to steer the agent. The tests whobrokeit runs never get the AI key or the GitHub token in their environment, and the token can't change anything but comments. Code on the runner runs as the same user, though, so use an API key with a spending limit.
95
+
96
+ ## What it does
97
+
98
+ 1. **Reproduce.** The agent reads your code and writes a small script that prints a `WHOBROKEIT_BUG:` line when it sees the bug. whobrokeit runs it at `HEAD` and only goes on if it fails there for that reason. A crash without that line counts as "can't tell", so a test that calls a function older commits don't have never blames the wrong commit.
99
+ 2. **Find a commit where it still worked.** It walks back 1, 2, 4, 8… commits until the test passes. Or pass `--good <ref>`.
100
+ 3. **Bisect.** `git bisect` narrows it down to the first bad commit, skipping commits the test can't tell about.
101
+ 4. **Prove it.** It runs the test again at the culprit (fails) and at its parent (passes).
102
+ 5. **Explain and fix.** The agent says why the change broke things and proposes an edit. whobrokeit applies it, runs the test, and shows the fix only if the test passes. It saves it as a patch you can `git apply`.
103
+
104
+ ## Tested on real bugs
105
+
106
+ I ran it on 12 closed regression issues from commander, semver, postcss, luxon, marked, express, markdown-it, fastify and yargs. It got only the issue text, exactly as the reporter wrote it. It proved the commit the maintainers had identified in **11 of 12** cases, in about 100 seconds each. The cases, the one miss and how to rerun everything are in [bench/](bench).
107
+
108
+ ## Safe to run on your repo
109
+
110
+ - **Your working copy is never touched.** Every check runs in a temporary `git worktree`, which is removed at the end. Uncommitted changes, open files and your current branch stay as they are.
111
+ - **The agent can only read.** Claude Code runs with the Read, Grep and Glob tools only, and with your hooks, plugins and MCP servers turned off. Codex runs in its read-only sandbox. whobrokeit runs the test the agent writes, so check it if you don't trust the repo.
112
+ - **Nothing is taken on faith.** The test has to fail at `HEAD` for the reported reason, the culprit has to be proven against its parent, and a fix has to make the test pass.
113
+ - **Dependencies are installed per commit** when `package.json` or a lockfile changes (npm, pnpm, yarn, bun). Use `--setup "<cmd>"` for anything else, or `--no-setup`.
114
+
115
+ ## Options
116
+
117
+ | Option | |
118
+ |---|---|
119
+ | `-t, --test <cmd>` | A command that fails while the bug is present. |
120
+ | `--agent <name>` | Who writes the test: `claude`, `codex`, or any command that reads a prompt on stdin and answers on stdout. Default: `claude`, else `codex`. |
121
+ | `--model <name>` | Model for the agent, e.g. `sonnet`. |
122
+ | `--explain` / `--no-explain` | Explain the culprit and test a fix. On by default when you describe a bug, off with `--test`. |
123
+ | `-w, --with <file>` | Copy this file from your working copy into every commit tested. Repeatable. |
124
+ | `-g, --good <ref>` | A commit where it still worked. Default: found automatically. |
125
+ | `-b, --bad <ref>` | A commit where it is broken. Default: `HEAD`. |
126
+ | `--setup <cmd>` / `--no-setup` | Install command, re-run when dependency files change. Default: auto. |
127
+ | `--timeout <sec>` | Time limit for each test run. Default: 600, or 300 for agent-written tests. |
128
+ | `--report <file>` | Also write a Markdown report, ready to paste into an issue or PR. |
129
+ | `--json` | Print the result as JSON. |
130
+
131
+ Exit codes: `0` found and proven · `1` error · `2` bug not reproduced · `3` no working commit found · `4` inconclusive.
132
+
133
+ ## Good to know
134
+
135
+ - Requires Node.js 20+ and git. No other dependencies.
136
+ - The test can be in any language your project uses. Automatic dependency install covers JavaScript package managers; for other stacks, use `--setup`.
137
+ - With an agent, a run usually makes two requests: one to write the test, one to explain the culprit (each is retried once if its result doesn't hold up). On the demo repo the whole run takes 30 to 60 seconds.
138
+ - If the project commits no lockfile, old commits get today's versions of their dependencies. A bug that depends on a dependency's version can then be traced to the wrong commit.
139
+ - After a run, the agent's test and the fix are in `.git/whobrokeit/`, outside your working tree.
140
+
141
+ ## License
142
+
143
+ [MIT](LICENSE)
package/README.tr.md ADDED
@@ -0,0 +1,143 @@
1
+ # whobrokeit
2
+
3
+ **Hatayı getiren commit'i bulur ve kanıtlar.**
4
+
5
+ [English](README.md) · Türkçe
6
+
7
+ Hatayı tek cümleyle anlatırsın. Bir yapay zekâ ajanı hatayı gösteren bir test yazar; whobrokeit git geçmişinde bu testin ilk başarısız olduğu commit'e kadar gider ve sonucu kanıtlar: test o commit'te başarısız, bir öncekinde başarılıdır. Ardından neyin bozulduğunu açıklar ve bir düzeltme önerir. Düzeltmeyi ancak test onunla geçtikten sonra gösterir.
8
+
9
+ <p align="center">
10
+ <img src="assets/demo.gif" alt="whobrokeit, subtotal() fonksiyonunu bozan commit'i 37 saniyede buluyor" width="860">
11
+ </p>
12
+
13
+ ```sh
14
+ npx whobrokeit demo # içine hata gizlenmiş örnek bir repoda dene (yapay zekâ gerekmez)
15
+ npx whobrokeit demo --ai # aynısı, testi yapay zekâ ajanı yazar
16
+ ```
17
+
18
+ ## Kullanım
19
+
20
+ Reponun içindeyken hatayı anlat:
21
+
22
+ ```sh
23
+ whobrokeit "Sepete 3 kupa ekleyince fiyat 1 kupa kadar çıkıyor."
24
+ ```
25
+
26
+ Bunun için [Claude Code](https://github.com/anthropics/claude-code) ya da [Codex](https://github.com/openai/codex) kurulu ve giriş yapılmış olmalı. whobrokeit hangisini bulursa onu kullanır. Açıklama, hatayı hangi dilde yazdıysan o dilde gelir.
27
+
28
+ Ya da başarısız olan testi kendin ver, o zaman yapay zekâ hiç devreye girmez:
29
+
30
+ ```sh
31
+ whobrokeit --test "npm test -- cart.test.js"
32
+ whobrokeit --test "pytest tests/test_cart.py -x"
33
+ whobrokeit --test "node repro.mjs" --with repro.mjs # henüz commit'lemediğin bir test
34
+ ```
35
+
36
+ Her komut olur. Çıkış kodu 0 çalışıyor, 125 bu commit test edilemiyor (atla), diğer her şey bozuk demektir. Bu modda da açıklama ve düzeltme istersen `--explain` ekle.
37
+
38
+ ## GitHub issue'larında
39
+
40
+ whobrokeit bir GitHub Action olarak da çalışır. Bir issue'ya `whobrokeit` etiketini koyduğunda ya da `/whobrokeit` yorumunu yazdığında hatayı getiren commit'i arar. Sonra issue'ya bir yorum yazar: suçlu commit, kanıtı ve test edilmiş bir düzeltme.
41
+
42
+ Repoya `ANTHROPIC_API_KEY` adında bir secret ekle, sonra şunu `.github/workflows/whobrokeit.yml` olarak kaydet:
43
+
44
+ ```yaml
45
+ name: whobrokeit
46
+
47
+ on:
48
+ issues:
49
+ types: [labeled]
50
+ issue_comment:
51
+ types: [created]
52
+
53
+ permissions:
54
+ contents: read
55
+ issues: write
56
+
57
+ jobs:
58
+ whobrokeit:
59
+ # Yalnız issue etiketleyebilenler ya da /whobrokeit yazan üyeler ve katkıcılar.
60
+ if: >-
61
+ (github.event_name == 'issues' && github.event.label.name == 'whobrokeit') ||
62
+ (github.event_name == 'issue_comment' && !github.event.issue.pull_request &&
63
+ startsWith(github.event.comment.body, '/whobrokeit') &&
64
+ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
65
+ runs-on: ubuntu-latest
66
+ timeout-minutes: 60
67
+ steps:
68
+ - uses: actions/checkout@v4
69
+ with:
70
+ fetch-depth: 0 # whobrokeit geçmişe ihtiyaç duyar
71
+ persist-credentials: false
72
+ - uses: actions/setup-node@v4
73
+ with:
74
+ node-version: 22 # projen hangisini kullanıyorsa
75
+ - uses: BerkanAtik/whobrokeit@v0
76
+ with:
77
+ anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
78
+ ```
79
+
80
+ Varsayılan olarak issue'nun başlığı ve metniyle çalışır. `/whobrokeit`'ten sonra bir metin yazarsan onların yerine o kullanılır. Böylece hatayı daha net anlatabilirsin: `/whobrokeit Sepete 3 kupa ekleyince fiyat 1 kupa kadar çıkıyor.`
81
+
82
+ | Girdi | |
83
+ |---|---|
84
+ | `anthropic-api-key` | Anthropic API anahtarı. Claude aboneliğini kullanmak için onun yerine `claude setup-token` ile alınan `claude-code-oauth-token`. |
85
+ | `bug` | Bozuk olan şey. Varsayılan: yorumda `/whobrokeit`'ten sonraki metin, o da yoksa issue. |
86
+ | `test` | Başarısız olan kendi test komutun. O zaman testi yapay zekâ yazmaz. |
87
+ | `explain` | `auto` (varsayılan), `true` ya da `false`. |
88
+ | `agent`, `model`, `good`, `bad`, `setup`, `timeout` | Komut satırındakiyle aynı. `setup: none` kurulumu kapatır. |
89
+ | `working-directory` | Aranacak repo, çalışma alanının kökü değilse. |
90
+ | `comment` | Issue'ya yorum yazmasın istersen `false`. Rapor her durumda job özetinde de olur. |
91
+
92
+ Çıktılar: `exit-code`, `commit` (suçlu commit'in kısa hash'i) ve `report` (Markdown raporun yolu). Job yalnız bir hata olursa başarısız olur. Yeniden üretilemeyen bir hata da diğer sonuçlar gibi issue'ya yazılır.
93
+
94
+ Bir issue'da çalıştırmadan önce issue'yu oku. Ajan yalnız kodu okur, ama yazdığı testi whobrokeit çalıştırır, ve issue metni ajanı yönlendirmeye çalışabilir. whobrokeit'in çalıştırdığı testlerin ortamında AI anahtarı da GitHub token'ı da olmaz, token da yorumlar dışında hiçbir şeyi değiştiremez. Yine de runner'daki kod aynı kullanıcıyla çalışır; harcama sınırı olan bir API anahtarı kullan.
95
+
96
+ ## Ne yapar
97
+
98
+ 1. **Hatayı yeniden üretir.** Ajan kodu okur ve hatayı görünce `WHOBROKEIT_BUG:` satırı basan küçük bir betik yazar. whobrokeit onu `HEAD`'de çalıştırır, yalnız bu sebeple başarısız olursa devam eder. Bu satır olmadan gelen bir çökme "belli değil" sayılır; eski commit'lerde olmayan bir fonksiyonu çağıran test yüzünden yanlış commit suçlanmaz.
99
+ 2. **Hâlâ çalıştığı bir commit bulur.** Test geçene kadar 1, 2, 4, 8… commit geri gider. İstersen `--good <ref>` ile kendin verirsin.
100
+ 3. **Bisect yapar.** `git bisect` ile ilk bozuk commit'e iner, testin karar veremediği commit'leri atlar.
101
+ 4. **Kanıtlar.** Testi suçlu commit'te (başarısız) ve bir öncekinde (başarılı) yeniden çalıştırır.
102
+ 5. **Açıklar ve düzeltir.** Ajan değişikliğin neyi bozduğunu anlatır ve bir düzenleme önerir. whobrokeit düzenlemeyi uygular, testi çalıştırır, yalnız test geçerse gösterir. `git apply` ile uygulanabilen bir patch olarak da kaydeder.
103
+
104
+ ## Gerçek hatalarda denendi
105
+
106
+ commander, semver, postcss, luxon, marked, express, markdown-it, fastify ve yargs projelerinden kapanmış 12 regresyon issue'sunda çalıştırdım. Araca yalnız issue metni verildi, raporu yazan kişinin yazdığı haliyle. **12 vakanın 11'inde** proje sahiplerinin belirlediği commit'i kanıtladı; her biri yaklaşık 100 saniye sürdü. Vakalar, tutmayan tek vaka ve hepsini yeniden çalıştırma adımları [bench/](bench) klasöründe.
107
+
108
+ ## Reponda güvenle çalışır
109
+
110
+ - **Çalışma kopyana dokunmaz.** Her kontrol geçici bir `git worktree` içinde yapılır ve sonunda silinir. Commit'lenmemiş değişikliklerin, açık dosyaların ve bulunduğun branch olduğu gibi kalır.
111
+ - **Ajan yalnız okuyabilir.** Claude Code yalnız Read, Grep ve Glob araçlarıyla, hook'ların, eklentilerin ve MCP sunucuların kapalıyken çalışır. Codex kendi salt okunur sandbox'ında çalışır. Ajanın yazdığı testi whobrokeit çalıştırır; güvenmediğin bir repoda bu testi kontrol et.
112
+ - **Hiçbir şey sözle kabul edilmez.** Test `HEAD`'de bildirilen sebeple başarısız olmalı, suçlu commit bir öncekiyle karşılaştırılarak kanıtlanmalı, düzeltme de testi geçirmeli.
113
+ - **Bağımlılıklar commit başına kurulur:** `package.json` ya da lockfile değiştiğinde (npm, pnpm, yarn, bun). Başka bir şey için `--setup "<komut>"`, hiç kurmamak için `--no-setup`.
114
+
115
+ ## Seçenekler
116
+
117
+ | Seçenek | |
118
+ |---|---|
119
+ | `-t, --test <cmd>` | Hata varken başarısız olan bir komut. |
120
+ | `--agent <name>` | Testi kim yazar: `claude`, `codex` ya da stdin'den talimat alıp stdout'a cevap yazan herhangi bir komut. Varsayılan: `claude`, yoksa `codex`. |
121
+ | `--model <name>` | Ajanın modeli, örn. `sonnet`. |
122
+ | `--explain` / `--no-explain` | Suçlu commit'i açıkla ve düzeltme dene. Hatayı anlattığında açık, `--test` ile kapalı. |
123
+ | `-w, --with <file>` | Bu dosyayı çalışma kopyandan test edilen her commit'e kopyala. Birden çok verilebilir. |
124
+ | `-g, --good <ref>` | Hâlâ çalıştığı bir commit. Varsayılan: otomatik bulunur. |
125
+ | `-b, --bad <ref>` | Bozuk olduğu bir commit. Varsayılan: `HEAD`. |
126
+ | `--setup <cmd>` / `--no-setup` | Kurulum komutu, bağımlılık dosyaları değişince yeniden çalışır. Varsayılan: otomatik. |
127
+ | `--timeout <sec>` | Her test çalıştırması için süre sınırı. Varsayılan: 600, ajanın yazdığı testlerde 300. |
128
+ | `--report <file>` | Ayrıca issue ya da PR'a yapıştırmaya hazır bir Markdown rapor yaz. |
129
+ | `--json` | Sonucu JSON olarak bas. |
130
+
131
+ Çıkış kodları: `0` bulundu ve kanıtlandı · `1` hata · `2` hata yeniden üretilemedi · `3` çalışan commit bulunamadı · `4` sonuçsuz.
132
+
133
+ ## Bilmekte fayda var
134
+
135
+ - Node.js 20+ ve git gerekir. Başka bağımlılık yok.
136
+ - Test, projenin kullandığı herhangi bir dilde olabilir. Otomatik bağımlılık kurulumu JavaScript paket yöneticilerini kapsar; diğerleri için `--setup` kullan.
137
+ - Ajanla bir çalıştırma genelde iki istek yapar: biri testi yazmak, biri suçlu commit'i açıklamak için (sonuç tutmazsa her biri bir kez daha denenir). Demo reposunda tüm süreç 30–60 saniye sürer.
138
+ - Proje lockfile commit'lemiyorsa eski commit'ler bağımlılıklarının bugünkü sürümleriyle kurulur. Bir bağımlılığın sürümüne bağlı hatalarda yanlış commit bulunabilir.
139
+ - İş bitince ajanın yazdığı test ve düzeltme `.git/whobrokeit/` içinde durur, çalışma klasörünün dışında.
140
+
141
+ ## Lisans
142
+
143
+ [MIT](LICENSE)
package/dist/agent.js ADDED
@@ -0,0 +1,185 @@
1
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { isAbsolute, join, relative, sep } from 'node:path';
4
+ import { UserError } from './errors.js';
5
+ import { runCommand } from './exec.js';
6
+ const PRESETS = ['claude', 'codex'];
7
+ const LABELS = { claude: 'Claude Code', codex: 'Codex' };
8
+ /**
9
+ * `undefined` picks the first installed preset. A preset name picks that one.
10
+ * Anything else is a custom command: it gets the prompt on stdin and answers on stdout.
11
+ */
12
+ export async function pickAgent(choice, cwd, model) {
13
+ if (model !== undefined && !/^[\w.:/@[\]-]+$/.test(model))
14
+ throw new UserError(`--model ${model} is not a valid model name.`);
15
+ if (choice !== undefined && !PRESETS.includes(choice)) {
16
+ if (model !== undefined)
17
+ throw new UserError('--model only works with --agent claude or --agent codex.');
18
+ return customAgent(choice);
19
+ }
20
+ const candidates = choice ? [choice] : [...PRESETS];
21
+ for (const name of candidates) {
22
+ const probe = await runCommand(`${name} --version`, cwd, 30_000);
23
+ if (probe.code === 0)
24
+ return name === 'claude' ? claudeAgent(model) : codexAgent(model);
25
+ }
26
+ if (choice) {
27
+ throw new UserError(`${LABELS[choice]} (\`${choice}\`) is not installed or not on your PATH.`);
28
+ }
29
+ throw new UserError('Describing a bug in plain words needs Claude Code or Codex installed.', 'Install one of them, use --agent "<command>" for another tool, or pass your own test with --test "<command>".');
30
+ }
31
+ function claudeAgent(model) {
32
+ const label = LABELS.claude;
33
+ const command = [
34
+ 'claude -p',
35
+ '--output-format stream-json --verbose',
36
+ // Read-only tools, no hooks, plugins or MCP servers, nothing left in the session list.
37
+ '--tools Read,Grep,Glob',
38
+ '--permission-prompts none',
39
+ '--safe-mode --strict-mcp-config',
40
+ '--no-session-persistence',
41
+ model ? `--model ${model}` : '',
42
+ ]
43
+ .filter(Boolean)
44
+ .join(' ');
45
+ return {
46
+ label,
47
+ async ask(prompt, cwd, { timeoutMs, onActivity }) {
48
+ let result;
49
+ const lines = lineSplitter((line) => {
50
+ if (!line.startsWith('{'))
51
+ return;
52
+ let event;
53
+ try {
54
+ event = JSON.parse(line);
55
+ }
56
+ catch {
57
+ return;
58
+ }
59
+ if (event.type === 'assistant') {
60
+ for (const part of event.message?.content ?? []) {
61
+ if (part.type === 'tool_use')
62
+ onActivity?.(describeTool(part.name, part.input ?? {}, cwd));
63
+ }
64
+ }
65
+ else if (event.type === 'result') {
66
+ result = { text: event.result ?? '', isError: Boolean(event.is_error), subtype: event.subtype };
67
+ }
68
+ });
69
+ // The user's `language` setting survives --safe-mode and beats the prompt's "write in the bug
70
+ // report's language" rule, so a Turkish setting turned English reports into Turkish answers.
71
+ const dir = await mkdtemp(join(tmpdir(), 'whobrokeit-claude-'));
72
+ const settings = join(dir, 'settings.json');
73
+ let run;
74
+ try {
75
+ await writeFile(settings, JSON.stringify({ language: '' }));
76
+ run = await runCommand(`${command} --settings "${settings}"`, cwd, timeoutMs, { input: prompt, onStdout: lines.push });
77
+ }
78
+ finally {
79
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
80
+ }
81
+ lines.flush();
82
+ // Its own error message (a bad key, no credit) says more than the exit code does.
83
+ if (!result?.isError || run.timedOut)
84
+ failIfBroken(label, run, timeoutMs, result === undefined);
85
+ if (result?.isError) {
86
+ throw new UserError(`${label} stopped with an error: ${result.text || result.subtype || 'unknown error'}`);
87
+ }
88
+ return result?.text ?? '';
89
+ },
90
+ };
91
+ }
92
+ function codexAgent(model) {
93
+ const label = LABELS.codex;
94
+ return {
95
+ label,
96
+ async ask(prompt, cwd, { timeoutMs }) {
97
+ const dir = await mkdtemp(join(tmpdir(), 'whobrokeit-codex-'));
98
+ const answerFile = join(dir, 'answer.txt');
99
+ try {
100
+ const command = [
101
+ 'codex exec',
102
+ '--sandbox read-only --skip-git-repo-check --ephemeral --color never',
103
+ model ? `-m ${model}` : '',
104
+ `-o "${answerFile}"`,
105
+ '-',
106
+ ]
107
+ .filter(Boolean)
108
+ .join(' ');
109
+ const run = await runCommand(command, cwd, timeoutMs, { input: prompt });
110
+ const answer = await readFile(answerFile, 'utf8').catch(() => null);
111
+ failIfBroken(label, run, timeoutMs, answer === null);
112
+ return answer ?? '';
113
+ }
114
+ finally {
115
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
116
+ }
117
+ },
118
+ };
119
+ }
120
+ function customAgent(command) {
121
+ return {
122
+ label: command,
123
+ async ask(prompt, cwd, { timeoutMs }) {
124
+ let stdout = '';
125
+ const run = await runCommand(command, cwd, timeoutMs, { input: prompt, onStdout: (text) => (stdout += text) });
126
+ failIfBroken(command, run, timeoutMs, false);
127
+ return stdout;
128
+ },
129
+ };
130
+ }
131
+ function failIfBroken(label, run, timeoutMs, noAnswer) {
132
+ if (run.timedOut) {
133
+ throw new UserError(`${label} did not answer within ${Math.round(timeoutMs / 60_000)} minutes.`);
134
+ }
135
+ if (run.code === 0 && !noAnswer)
136
+ return;
137
+ // Drop the JSON event stream; keep what a human would read.
138
+ const said = run.output
139
+ .split('\n')
140
+ .map((line) => line.trim())
141
+ .filter((line) => line && !line.startsWith('{'))
142
+ .slice(-8)
143
+ .join('\n');
144
+ throw new UserError(`${label} failed (exit code ${run.code}).${said ? `\n${said}` : ''}`, 'Check that it works on its own in this terminal (is it logged in?), or pass your own test with --test.');
145
+ }
146
+ function describeTool(name, input, cwd) {
147
+ const str = (key) => (typeof input[key] === 'string' ? input[key] : '');
148
+ const where = (path) => {
149
+ if (!path)
150
+ return '';
151
+ const rel = isAbsolute(path) ? relative(cwd, path) : path;
152
+ if (!rel)
153
+ return '.';
154
+ return rel.startsWith('..') ? path : rel.split(sep).join('/');
155
+ };
156
+ switch (name) {
157
+ case 'Read':
158
+ return `reading ${where(str('file_path'))}`;
159
+ case 'Grep':
160
+ return `searching for "${str('pattern')}"${str('path') ? ` in ${where(str('path'))}` : ''}`;
161
+ case 'Glob':
162
+ return `looking for ${str('pattern')}${str('path') ? ` in ${where(str('path'))}` : ''}`;
163
+ default:
164
+ return `using ${name ?? 'a tool'}`;
165
+ }
166
+ }
167
+ /** Turns arbitrary stdout chunks into whole lines. */
168
+ function lineSplitter(onLine) {
169
+ let buffer = '';
170
+ return {
171
+ push: (text) => {
172
+ buffer += text;
173
+ let newline;
174
+ while ((newline = buffer.indexOf('\n')) !== -1) {
175
+ onLine(buffer.slice(0, newline).trim());
176
+ buffer = buffer.slice(newline + 1);
177
+ }
178
+ },
179
+ flush: () => {
180
+ if (buffer.trim())
181
+ onLine(buffer.trim());
182
+ buffer = '';
183
+ },
184
+ };
185
+ }