ai-foreman 1.0.0

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/README.md ADDED
@@ -0,0 +1,523 @@
1
+ # ai-foreman
2
+
3
+ Loop Claude Code or Codex through tickets.
4
+
5
+ - No more typing `"next step"` all day.
6
+ - QA cycle included (disable with `--no-qa`)
7
+ - Uses its own smart local ticket tracking system (support for other systems possible later)
8
+ - Project plans still work when you do not want a tracker.
9
+ - One-off task files still work too.
10
+
11
+ ## Why
12
+
13
+ - **Enforces TDD** — the builder role is compiled from rule packs that require tests before implementation, red-green-refactor discipline, and test coverage gates on every ticket.
14
+ - **Enterprise stability from day one** — security, observability, robustness, and scalability rules are baked into every builder turn, not bolted on later.
15
+ - **Rich ticket system** — tickets carry acceptance criteria, required tests, dependencies, priority, size, and risk level. The agent reads the full context before starting each step.
16
+ - **Future work tracking** — when the builder discovers out-of-scope work during a run, `ai-foreman tickets discover` captures it without derailing the current ticket. Discovered items live in a separate inbox until you promote them.
17
+ - **QA that actually gates** — QA runs after every completed ticket and checks code quality, test passing, and regression protection. The ticket only closes after QA passes. QA turns do not count against `--steps`.
18
+
19
+ ## Install
20
+
21
+ Global install:
22
+
23
+ ```bash
24
+ npm install -g ai-foreman
25
+ ```
26
+
27
+ Helpful when:
28
+
29
+ - You want `ai-foreman` available from any repo.
30
+ - You use Foreman across multiple projects.
31
+ - You do not need each repo to pin its own Foreman version.
32
+
33
+ Per-project install:
34
+
35
+ ```bash
36
+ npm install --save-dev ai-foreman
37
+ npx ai-foreman doctor .
38
+ ```
39
+
40
+ Helpful when:
41
+
42
+ - You prefer `npx ai-foreman` over a global CLI.
43
+ - You want Foreman tracked in `package.json`.
44
+ - You want teammates or CI to use the repo's installed version.
45
+
46
+ Requires:
47
+
48
+ - Node.js 20 or newer.
49
+
50
+ ## Use
51
+
52
+ - Check the target project.
53
+ - Initialize Foreman's local ticket tracker.
54
+ - Ask agent to convert existing tickets or populate them
55
+ - Run foreman for 5 tickets (and QA each one when done)
56
+
57
+ ```bash
58
+ ai-foreman doctor ./my-project
59
+ ai-foreman tickets init --project ./my-project --app-name "My App"
60
+ ai-foreman tickets populate --project ./my-project --agent codex --model gpt-5.5 --effort xhigh
61
+ ai-foreman start ./my-project --agent codex --model gpt-5.5 --effort xhigh --steps 5
62
+ ```
63
+
64
+ What each part does:
65
+
66
+ - `./my-project`: the repo Foreman will work on.
67
+ - `tickets init`: creates the local `.tickets/` tracker.
68
+ - `tickets populate`: asks Codex to convert project planning material.
69
+ - Converted output uses Foreman's schema.
70
+ - `start`: drives Codex through the next tickets one step at a time.
71
+
72
+ `tickets populate` can convert:
73
+
74
+ - plans
75
+ - TODOs
76
+ - roadmap docs
77
+ - ticket files
78
+
79
+ ## Primary Options
80
+
81
+ ### `ai-foreman start`
82
+
83
+ | Option | Common values | Default | Notes |
84
+ | --- | --- | --- | --- |
85
+ | `<project>` | `./my-project` / `../repo` | required | Target repo the agent works in. |
86
+ | `-s, --steps <n>` | `1` / `5` / `10` | required | Max tickets or implementation steps to drive. |
87
+ | `-a, --agent <agent>` | `claude` / `codex` | `claude` | Builder agent. |
88
+ | `-m, --model <model>` | `gpt-5.5` / any supported agent model | agent default | Overrides the builder model. |
89
+ | `--effort <level>` | Claude Code: `low` / `medium` / `high` / `xhigh`<br>Codex: `low` / `medium` / `high` / `xhigh` | agent default | Reasoning level. |
90
+ | `--fast` | flag | off | Lower latency. For Codex, maps to `effort=low` when `--effort` is not set. |
91
+ | `-t, --tickets <path>` | `.md` / `.txt` / `.yaml` | auto-detects standard tracker files | Sends a task file to the builder during preflight planning. |
92
+ | `-y, --yes` | flag | off | Skips preflight confirmation. |
93
+ | `--no-qa` | flag | QA on | Disables per-ticket QA review. |
94
+ | `--continue` | flag | off | Resumes the most recent logged session. |
95
+ | `-r, --resume <sessionId>` | session ID from `.foreman/` logs | none | Resumes a specific Claude/Codex session. |
96
+
97
+ Auto-detected tracker files:
98
+
99
+ - `docs/ticket-progress.md`
100
+ - `ticket-progress.md`
101
+
102
+ Resume rule:
103
+
104
+ - Use either `--continue` or `--resume`.
105
+ - Do not use both in the same command.
106
+
107
+ ### `ai-foreman tickets populate`
108
+
109
+ | Option | Common values | Default | Notes |
110
+ | --- | --- | --- | --- |
111
+ | `-p, --project <dir>` | `./my-project` / `../repo` | current directory | Target repo with `.tickets/`. |
112
+ | `-a, --agent <agent>` | `claude` / `codex` | `claude` | Builder agent. |
113
+ | `-m, --model <model>` | `gpt-5.5` / any supported agent model | agent default | Overrides the builder model. |
114
+ | `--effort <level>` | Claude Code: `low` / `medium` / `high` / `xhigh`<br>Codex: `low` / `medium` / `high` / `xhigh` | agent default | Reasoning level. |
115
+ | `--fast` | flag | off | Lower latency. |
116
+ | `-y, --yes` | flag | off | Skips confirmation before builder edits ticket files. |
117
+
118
+ ## What Foreman Does
119
+
120
+ During a run, Foreman:
121
+
122
+ - asks the builder for a short plan
123
+ - sends one ticket or implementation step at a time
124
+ - requires a final `STEP_STATUS` marker
125
+ - runs QA after each completed step by default
126
+ - writes a JSONL log under `.foreman/`
127
+
128
+ When `.tickets/config.yaml` exists, Foreman also:
129
+
130
+ - uses the local ticket tracker automatically
131
+ - marks the first eligible queue row `in_progress`
132
+ - marks the ticket `done` only after QA passes
133
+
134
+ Without tickets, Foreman still works with:
135
+
136
+ - a one-off task file
137
+ - a plain project directory
138
+
139
+ ## Agents And Task Files
140
+
141
+ Claude Code:
142
+
143
+ - Default agent.
144
+ - Uses your existing Claude Code credentials.
145
+ - Runs through the Claude Agent SDK.
146
+
147
+ Codex:
148
+
149
+ - Use `--agent codex`.
150
+ - Shells out to `codex exec`.
151
+ - Requires the `codex` CLI on your `PATH`.
152
+
153
+ Task file:
154
+
155
+ ```bash
156
+ ai-foreman start ./my-project --steps 5 --tickets ./my-project/TICKETS.md
157
+ ```
158
+
159
+ With `--tickets`:
160
+
161
+ - Point at any file.
162
+ - Foreman sends that file to the builder during preflight planning.
163
+
164
+ Common formats include:
165
+
166
+ - Markdown
167
+ - text
168
+ - YAML
169
+
170
+ ## Ticket Setup
171
+
172
+ Tickets are optional.
173
+
174
+ Use them when you want the repo itself to contain:
175
+
176
+ - the canonical implementation order
177
+ - the generated agent-facing progress document
178
+ - the shared queue
179
+ - the shared status
180
+
181
+ ### 1. Initialize Tickets
182
+
183
+ From anywhere:
184
+
185
+ ```bash
186
+ ai-foreman tickets init --project ./my-project --app-name "My App"
187
+ ```
188
+
189
+ From inside the project:
190
+
191
+ ```bash
192
+ ai-foreman tickets init --app-name "My App"
193
+ ```
194
+
195
+ Init options:
196
+
197
+ | Option | Common values | Default | Notes |
198
+ | --- | --- | --- | --- |
199
+ | `-p, --project <dir>` | `./my-project` / `../repo` | current directory | Project to initialize. |
200
+ | `--app-name <name>` | `"My App"` | none | Application name stored in tracker config. |
201
+ | `--timezone <tz>` | `America/Chicago` / `UTC` | `UTC` | IANA timezone. |
202
+ | `--queue-limit <n>` | `25` / `50` / `100` | `50` | Next-queue window size. |
203
+
204
+ This creates:
205
+
206
+ ```txt
207
+ .tickets/
208
+ config.yaml
209
+ tickets.yaml
210
+ tracker-rules.md
211
+ ticket-state.sqlite
212
+ schema/
213
+ migrations/
214
+ backups/
215
+ docs/
216
+ ticket-progress.md
217
+ ```
218
+
219
+ ### 2. Add Tickets
220
+
221
+ If the project already has planning material:
222
+
223
+ - Ask a builder to convert it into Foreman's ticket format.
224
+
225
+ Supported source material can include:
226
+
227
+ - tickets
228
+ - plans
229
+ - TODOs
230
+ - roadmap docs
231
+ - Markdown trackers
232
+
233
+ ```bash
234
+ # Claude Code
235
+ ai-foreman tickets populate --project ./my-project
236
+
237
+ # Codex
238
+ ai-foreman tickets populate --project ./my-project --agent codex
239
+ ai-foreman tickets populate --project ./my-project --agent codex --model gpt-5.5 --effort xhigh
240
+ ```
241
+
242
+ `populate` tells the builder to:
243
+
244
+ - read `.tickets/*`
245
+ - read `docs/ticket-progress.md`
246
+ - read existing planning files
247
+ - fill `.tickets/tickets.yaml`
248
+ - render the progress doc
249
+ - validate the result
250
+
251
+ If the existing content does not map cleanly:
252
+
253
+ - The builder should ask you for guidance.
254
+
255
+ You can also edit `.tickets/tickets.yaml` manually.
256
+
257
+ Storage model:
258
+
259
+ - Ticket definitions live in YAML.
260
+ - Mutable status lives in SQLite.
261
+ - Status changes should use `ai-foreman tickets` commands.
262
+
263
+ Minimal valid example:
264
+
265
+ ```yaml
266
+ tickets:
267
+ - id: T001
268
+ order: 1000
269
+ title: Add health check command
270
+ area: CLI
271
+ priority: P1
272
+ size: S
273
+ risk: Low
274
+ depends_on: []
275
+ summary: Add a command that reports whether Foreman is configured correctly.
276
+ acceptance:
277
+ - The command exits 0 when required local checks pass.
278
+ - The command prints actionable warnings for optional missing tools.
279
+ required_tests:
280
+ - Unit test for success output
281
+ - Unit test for missing optional tools
282
+ likely_files:
283
+ - src/index.ts
284
+ - test/*.test.ts
285
+ rollback: null
286
+ notes: null
287
+
288
+ - id: T002
289
+ order: 2000
290
+ title: Document health check command
291
+ area: Docs
292
+ priority: P2
293
+ size: XS
294
+ risk: Low
295
+ depends_on:
296
+ - T001
297
+ summary: Add README examples for the health check command.
298
+ acceptance:
299
+ - README shows the command in the quick start.
300
+ required_tests:
301
+ - Documentation review
302
+ likely_files:
303
+ - README.md
304
+ rollback: Revert the README section.
305
+ notes: null
306
+ ```
307
+
308
+ Ticket fields Foreman expects:
309
+
310
+ | Field | Required | Notes |
311
+ | --- | --- | --- |
312
+ | `id` | yes | Stable ticket ID, unique within the file. |
313
+ | `order` | yes | Unique implementation order. Use gaps like `1000`, `2000`. |
314
+ | `title` | yes | Short human-readable title. |
315
+ | `area` | yes | Product or code area. |
316
+ | `priority` | yes | Allowed: `P0` / `P1` / `P2` / `P3`. |
317
+ | `size` | yes | Allowed: `XS` / `S` / `M` / `L` / `XL`. |
318
+ | `risk` | yes | Allowed: `Low` / `Medium` / `High`. |
319
+ | `depends_on` | yes | Array of ticket IDs. Empty array is fine. |
320
+ | `summary` | yes | Short implementation summary. |
321
+ | `acceptance` | yes | Non-empty list of completion criteria. |
322
+ | `required_tests` | yes | Non-empty list of expected validation. |
323
+ | `likely_files` | yes | Expected files or globs. Empty only when unknown. |
324
+ | `rollback` | required for Medium/High risk | Rollback or mitigation notes. |
325
+ | `notes` | optional | Extra context. |
326
+
327
+ Do not put status fields in `.tickets/tickets.yaml`.
328
+
329
+ These belong in `.tickets/ticket-state.sqlite`:
330
+
331
+ ```txt
332
+ status
333
+ last_worked_at
334
+ completed_at
335
+ attempt_count
336
+ last_error
337
+ evidence
338
+ current_step
339
+ blocked_by
340
+ validation_result
341
+ ```
342
+
343
+ ### 3. Validate And Render
344
+
345
+ ```bash
346
+ ai-foreman tickets validate --project ./my-project
347
+ ai-foreman tickets render --project ./my-project
348
+ ai-foreman tickets queue --project ./my-project
349
+ ```
350
+
351
+ Generated output:
352
+
353
+ - `docs/ticket-progress.md` is generated from `.tickets/tickets.yaml`.
354
+ - It also includes state from `.tickets/ticket-state.sqlite`.
355
+ - Builders should read it.
356
+ - You should not manually edit generated sections.
357
+
358
+ ### 4. Run Foreman
359
+
360
+ Claude Code:
361
+
362
+ ```bash
363
+ ai-foreman start ./my-project --steps 10
364
+ ```
365
+
366
+ Codex:
367
+
368
+ ```bash
369
+ ai-foreman start ./my-project --agent codex --model gpt-5.5 --effort xhigh --steps 10
370
+ ```
371
+
372
+ ## Common Commands
373
+
374
+ ```bash
375
+ # Check environment and config
376
+ ai-foreman doctor ./my-project
377
+
378
+ # Show the latest run summary
379
+ ai-foreman status ./my-project
380
+
381
+ # Disable per-step QA
382
+ ai-foreman start ./my-project --steps 5 --no-qa
383
+
384
+ # Resume the latest session / a specific session
385
+ ai-foreman start ./my-project --steps 5 --continue
386
+ ai-foreman start ./my-project --steps 5 --resume <session-id>
387
+ ```
388
+
389
+ ## Ticket Lifecycle Commands
390
+
391
+ After `init`, `populate`, `validate`, and `render` (covered in Ticket Setup above), these commands manage tickets day-to-day:
392
+
393
+ ```bash
394
+ # Update a ticket's next action
395
+ ai-foreman tickets update T001 --project ./my-project --next-action "Add tests"
396
+
397
+ # Mark a ticket complete manually
398
+ ai-foreman tickets complete T001 --project ./my-project --evidence "pnpm test passed"
399
+
400
+ # Block / unblock
401
+ ai-foreman tickets block T001 --project ./my-project --blocked-by external-api --summary "Waiting on API key"
402
+ ai-foreman tickets unblock T001 --project ./my-project --summary "API key received"
403
+
404
+ # Capture future work discovered during a run
405
+ ai-foreman tickets discover --project ./my-project --summary "Add retry metrics" --rationale "Needed for operations"
406
+
407
+ # Promote discovered work into tickets.yaml
408
+ ai-foreman tickets accept-future-work 1 --project ./my-project --ticket-id T051 --order 51000
409
+
410
+ # Cancel / reorder / archive
411
+ ai-foreman tickets cancel T001 --project ./my-project --summary "Superseded by T002"
412
+ ai-foreman tickets reorder T051 --project ./my-project --after T050
413
+ ai-foreman tickets archive --project ./my-project --older-than-days 30
414
+ ```
415
+
416
+ ## How The Loop Works
417
+
418
+ Before implementation starts:
419
+
420
+ - Foreman asks the builder to list the next `N` steps.
421
+ - You confirm or revise that list.
422
+ - Passing `--yes` skips confirmation.
423
+
424
+ Every implementation turn must end with exactly one marker.
425
+
426
+ Marker rules:
427
+
428
+ - Put the marker on the final non-empty line.
429
+ - Use one of these forms:
430
+
431
+ ```txt
432
+ STEP_STATUS: done | ticket="T001" summary="implemented health check" next="document command"
433
+ STEP_STATUS: blocked | ticket="T001" reason="missing DATABASE_URL"
434
+ STEP_STATUS: plan_complete | ticket="T001" summary="all requested work is complete"
435
+ STEP_STATUS: needs_input | question="Which storage backend?" choices="SQLite|Postgres"
436
+ ```
437
+
438
+ When QA is enabled, Foreman asks the builder to review its own work:
439
+
440
+ ```txt
441
+ STEP_STATUS: qa_pass | summary="tests pass and acceptance criteria are met"
442
+ STEP_STATUS: qa_fail | issues="missing test for empty config"
443
+ ```
444
+
445
+ If QA fails:
446
+
447
+ - Foreman sends a fix instruction.
448
+ - Foreman reruns QA.
449
+ - QA turns do not count against `--steps`.
450
+
451
+ ## Permissions
452
+
453
+ Foreman's permission policy is deterministic.
454
+
455
+ - The policy does not use LLM judgment.
456
+
457
+ For Claude:
458
+
459
+ - The SDK surfaces tool requests to Foreman.
460
+ - Foreman classifies them with the project's `foreman.yaml`.
461
+ - Tools in `permissions.escalateTools` are denied.
462
+ - File write tools are allowed only when their target stays inside the project.
463
+ - The inside-project check depends on the SDK surfacing the path to Foreman.
464
+ - Bash is denied if it contains an always-escalate substring.
465
+ - Bash is otherwise allowed only for configured prefixes.
466
+ - Every chained segment must start with an `allowBash` prefix.
467
+ - Unknown tools are denied.
468
+ - Unknown Bash commands are denied.
469
+
470
+ For Codex:
471
+
472
+ - Codex tool calls are not currently intercepted by Foreman.
473
+ - Codex runs under `codex exec --sandbox workspace-write`.
474
+ - Codex safety depends on Codex's own sandboxing.
475
+
476
+ Default config:
477
+
478
+ - The default config lives in [foreman.yaml](./foreman.yaml).
479
+ - Copy it into a project when you need project-specific policy.
480
+
481
+ ## Development
482
+
483
+ From the monorepo root:
484
+
485
+ ```bash
486
+ pnpm install
487
+ pnpm -r build
488
+ pnpm -r test
489
+ ```
490
+
491
+ From this package:
492
+
493
+ ```bash
494
+ pnpm test
495
+ pnpm typecheck
496
+ pnpm build
497
+
498
+ pnpm dev -- start ../../examples/dummy-project --steps 2
499
+ ```
500
+
501
+ Package output:
502
+
503
+ - The package publishes an `ai-foreman` binary from `dist/index.js`.
504
+
505
+ ## Current Limitations
506
+
507
+ - One builder at a time.
508
+ - No daemon or dashboard.
509
+ - Codex tool calls are not intercepted by Foreman's permission policy.
510
+ - `ai-foreman tickets import` is currently a stub.
511
+ - Escalated actions are denied and stop the batch.
512
+ - There is no approve/deny queue yet.
513
+
514
+ ## Part of Rafi
515
+
516
+ - **`special-agents`** — library (rules + skills + agents + composition)
517
+ - **`ai-foreman`** — this runtime
518
+ - **`@rafi-ai/cli`** — CLI for `rafi create` and `rafi compile`
519
+
520
+
521
+
522
+
523
+
@@ -0,0 +1,150 @@
1
+ import { query } from "@anthropic-ai/claude-agent-sdk";
2
+ import { AsyncQueue } from "../util/asyncQueue.js";
3
+ /**
4
+ * Pure function: build the `options` object passed to `query()`.
5
+ * Extracted so tests can assert on the shape without making a live SDK call.
6
+ * `canUseTool` is omitted here — it's a closure that the constructor adds.
7
+ */
8
+ export function buildClaudeQueryOptions(opts) {
9
+ const base = {
10
+ cwd: opts.cwd,
11
+ model: opts.model,
12
+ resume: opts.resumeSessionId,
13
+ permissionMode: "acceptEdits",
14
+ effort: opts.effort,
15
+ extraArgs: opts.fast ? { fast: null } : undefined,
16
+ };
17
+ if (opts.systemPromptAppend) {
18
+ base.systemPrompt = { type: "preset", preset: "claude_code", append: opts.systemPromptAppend };
19
+ }
20
+ if (opts.skills !== undefined) {
21
+ base.skills = opts.skills;
22
+ base.settingSources = ["project"];
23
+ }
24
+ return base;
25
+ }
26
+ /**
27
+ * Drives Claude Code through the Claude Agent SDK in streaming-input mode:
28
+ * one persistent session, follow-up turns pushed as user messages, permission
29
+ * requests routed to the foreman's handler via `canUseTool`.
30
+ */
31
+ export class ClaudeAdapter {
32
+ agent = "claude";
33
+ inbox = new AsyncQueue();
34
+ eventQueue = new AsyncQueue();
35
+ query;
36
+ abort = new AbortController();
37
+ pumpDone;
38
+ _sessionId;
39
+ pending;
40
+ closed = false;
41
+ constructor(opts) {
42
+ this.query = query({
43
+ prompt: this.inbox,
44
+ options: {
45
+ ...buildClaudeQueryOptions(opts),
46
+ abortController: this.abort,
47
+ canUseTool: async (toolName, input) => {
48
+ const decision = await opts.permission({ toolName, input });
49
+ return decision.behavior === "allow"
50
+ ? { behavior: "allow" }
51
+ : { behavior: "deny", message: decision.message };
52
+ },
53
+ },
54
+ });
55
+ this.pumpDone = this.pump();
56
+ }
57
+ /** Background loop: consume the SDK message stream until it ends. */
58
+ async pump() {
59
+ try {
60
+ for await (const msg of this.query) {
61
+ this.handle(msg);
62
+ }
63
+ }
64
+ catch (err) {
65
+ // Suppress the AbortError that fires when close() aborts the stream.
66
+ const isShutdownAbort = this.closed &&
67
+ (err instanceof Error &&
68
+ (err.name === "AbortError" || err.message.includes("aborted")));
69
+ if (!isShutdownAbort) {
70
+ const message = err instanceof Error ? err.message : String(err);
71
+ this.eventQueue.push({ kind: "error", message });
72
+ this.pending?.reject(new Error(message));
73
+ this.pending = undefined;
74
+ }
75
+ }
76
+ finally {
77
+ this.eventQueue.close();
78
+ }
79
+ }
80
+ handle(msg) {
81
+ if ("session_id" in msg && typeof msg.session_id === "string") {
82
+ this._sessionId = msg.session_id;
83
+ }
84
+ if (msg.type === "assistant") {
85
+ for (const block of msg.message.content) {
86
+ if (block.type === "text" && block.text) {
87
+ this.eventQueue.push({ kind: "text", text: block.text });
88
+ }
89
+ else if (block.type === "tool_use") {
90
+ this.eventQueue.push({
91
+ kind: "tool",
92
+ name: block.name,
93
+ input: block.input,
94
+ });
95
+ }
96
+ }
97
+ }
98
+ else if (msg.type === "result") {
99
+ const text = "result" in msg && typeof msg.result === "string"
100
+ ? msg.result
101
+ : "errors" in msg
102
+ ? msg.errors.join("; ")
103
+ : "";
104
+ const result = {
105
+ text,
106
+ isError: msg.is_error,
107
+ numTurns: msg.num_turns,
108
+ costUsd: msg.total_cost_usd,
109
+ };
110
+ this.eventQueue.push({ kind: "turn-complete", result });
111
+ this.pending?.resolve(result);
112
+ this.pending = undefined;
113
+ }
114
+ }
115
+ sendTurn(text) {
116
+ if (this.closed)
117
+ return Promise.reject(new Error("builder is closed"));
118
+ if (this.pending) {
119
+ return Promise.reject(new Error("a turn is already in progress"));
120
+ }
121
+ return new Promise((resolve, reject) => {
122
+ this.pending = { resolve, reject };
123
+ this.inbox.push({
124
+ type: "user",
125
+ message: { role: "user", content: text },
126
+ parent_tool_use_id: null,
127
+ });
128
+ });
129
+ }
130
+ sessionId() {
131
+ return this._sessionId;
132
+ }
133
+ events() {
134
+ return this.eventQueue;
135
+ }
136
+ async close() {
137
+ if (this.closed)
138
+ return;
139
+ this.closed = true;
140
+ this.inbox.close();
141
+ try {
142
+ await this.query.interrupt();
143
+ }
144
+ catch {
145
+ // interrupt is best-effort — ignore if no turn is active
146
+ }
147
+ this.abort.abort();
148
+ await this.pumpDone.catch(() => { });
149
+ }
150
+ }