@bpinternal/overwatch 0.1.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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +451 -0
  3. package/dist/actuators/actuator.d.ts +13 -0
  4. package/dist/actuators/actuator.js +30 -0
  5. package/dist/actuators/actuator.js.map +7 -0
  6. package/dist/actuators/agent-pr.d.ts +25 -0
  7. package/dist/actuators/agent-pr.js +119 -0
  8. package/dist/actuators/agent-pr.js.map +7 -0
  9. package/dist/actuators/comments.d.ts +3 -0
  10. package/dist/actuators/comments.js +42 -0
  11. package/dist/actuators/comments.js.map +7 -0
  12. package/dist/actuators/index.d.ts +54 -0
  13. package/dist/actuators/index.js +40 -0
  14. package/dist/actuators/index.js.map +7 -0
  15. package/dist/actuators/instructions.d.ts +9 -0
  16. package/dist/actuators/instructions.js +62 -0
  17. package/dist/actuators/instructions.js.map +7 -0
  18. package/dist/actuators.d.ts +72 -0
  19. package/dist/actuators.js +173 -0
  20. package/dist/actuators.js.map +7 -0
  21. package/dist/agents.d.ts +53 -0
  22. package/dist/agents.js +107 -0
  23. package/dist/agents.js.map +7 -0
  24. package/dist/claims.d.ts +16 -0
  25. package/dist/claims.js +51 -0
  26. package/dist/claims.js.map +7 -0
  27. package/dist/cli.d.ts +13 -0
  28. package/dist/cli.js +94 -0
  29. package/dist/cli.js.map +7 -0
  30. package/dist/control-loop.d.ts +50 -0
  31. package/dist/control-loop.js +285 -0
  32. package/dist/control-loop.js.map +7 -0
  33. package/dist/github.d.ts +98 -0
  34. package/dist/github.js +258 -0
  35. package/dist/github.js.map +7 -0
  36. package/dist/index.d.ts +11 -0
  37. package/dist/index.js +69 -0
  38. package/dist/index.js.map +7 -0
  39. package/dist/log.d.ts +22 -0
  40. package/dist/log.js +87 -0
  41. package/dist/log.js.map +7 -0
  42. package/dist/pickers/busiest-file.d.ts +9 -0
  43. package/dist/pickers/busiest-file.js +48 -0
  44. package/dist/pickers/busiest-file.js.map +7 -0
  45. package/dist/pickers/count.d.ts +6 -0
  46. package/dist/pickers/count.js +29 -0
  47. package/dist/pickers/count.js.map +7 -0
  48. package/dist/pickers/index.d.ts +12 -0
  49. package/dist/pickers/index.js +41 -0
  50. package/dist/pickers/index.js.map +7 -0
  51. package/dist/pickers.d.ts +21 -0
  52. package/dist/pickers.js +61 -0
  53. package/dist/pickers.js.map +7 -0
  54. package/dist/repo-handle.d.ts +35 -0
  55. package/dist/repo-handle.js +75 -0
  56. package/dist/repo-handle.js.map +7 -0
  57. package/dist/sandbox.d.ts +33 -0
  58. package/dist/sandbox.js +91 -0
  59. package/dist/sandbox.js.map +7 -0
  60. package/dist/sensors/ast-grep.d.ts +41 -0
  61. package/dist/sensors/ast-grep.js +65 -0
  62. package/dist/sensors/ast-grep.js.map +7 -0
  63. package/dist/sensors/grep.d.ts +31 -0
  64. package/dist/sensors/grep.js +39 -0
  65. package/dist/sensors/grep.js.map +7 -0
  66. package/dist/sensors/index.d.ts +27 -0
  67. package/dist/sensors/index.js +38 -0
  68. package/dist/sensors/index.js.map +7 -0
  69. package/dist/sensors/react-doctor.d.ts +43 -0
  70. package/dist/sensors/react-doctor.js +71 -0
  71. package/dist/sensors/react-doctor.js.map +7 -0
  72. package/dist/sensors/script.d.ts +7 -0
  73. package/dist/sensors/script.js +32 -0
  74. package/dist/sensors/script.js.map +7 -0
  75. package/dist/sensors.d.ts +117 -0
  76. package/dist/sensors.js +126 -0
  77. package/dist/sensors.js.map +7 -0
  78. package/dist/types.d.ts +119 -0
  79. package/dist/types.js +17 -0
  80. package/dist/types.js.map +7 -0
  81. package/package.json +64 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Botpress, Inc.
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 ADDED
@@ -0,0 +1,451 @@
1
+ # Overwatch
2
+
3
+ A headless library for building **control loops**: scheduled bots that scan a repo for one
4
+ class of anomaly, fix a few instances with a coding agent, and open a PR. There's no server or
5
+ UI — you call `loop.run()` from whatever scheduler you already have (cron, CI, a serverless
6
+ handler), and each call does one full cycle:
7
+
8
+ ```
9
+ sense → filter claimed → pick → actuator acts
10
+ └─ default: agent per signal → verify (re-sense) → commit → PR
11
+ ```
12
+
13
+ Everything runs inside a fresh [Daytona](https://daytona.io) sandbox: the repo is cloned in,
14
+ an agent CLI (Claude Code or Codex) is installed and driven non-interactively, and the sandbox
15
+ is torn down at the end of the run.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pnpm add @bpinternal/overwatch # or: npm install / yarn add
21
+ ```
22
+
23
+ Ships as a compiled build (CommonJS, with type declarations) that works from both `require` and
24
+ `import` on Node 18+. To hack on the library itself, clone the repo and run `pnpm install`
25
+ (pnpm is the supported package manager; the version is pinned via `packageManager`).
26
+
27
+ You'll need, at minimum:
28
+
29
+ - `DAYTONA_API_KEY` (and optionally `DAYTONA_API_URL` / `DAYTONA_TARGET`) — sandbox creation
30
+ - `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` — whichever agent you use
31
+ - A GitHub token with push + PR permissions on the target repo, if you want the loop to open
32
+ PRs rather than just report
33
+
34
+ ## Quick start
35
+
36
+ ```ts
37
+ import { ControlLoop, Github, Codex, AgentPrActuator, sensors } from "@bpinternal/overwatch";
38
+
39
+ const loop = new ControlLoop({
40
+ // Slugified and applied as a label on every PR this loop opens; also what
41
+ // config.maxOpenPrCount counts against.
42
+ label: "Naked Error Should be ServiceError",
43
+
44
+ config: {
45
+ maxOpenPrCount: 1,
46
+ git: new Github({
47
+ repo: "https://github.com/my-org/my-repo.git",
48
+ branch: "main",
49
+ key: process.env.GITHUB_TOKEN,
50
+ }),
51
+ agent: new Codex({ apiKey: process.env.OPENAI_API_KEY!, model: "gpt-5.5" }),
52
+ hooks: {
53
+ setup: "bun install",
54
+ preCommit: "bun run fix:format",
55
+ },
56
+ },
57
+
58
+ // Detects anomalies. Scans src/ for raw `throw new Error(...)`.
59
+ sensor: sensors.astGrep({
60
+ pattern: "throw new Error($$$)",
61
+ language: "ts",
62
+ paths: ["src/"],
63
+ }),
64
+
65
+ // The actuator decides what to do with the picked signals. The default AgentPrActuator
66
+ // runs the agent per signal, verifies by re-sensing, then commits and opens a PR. Its
67
+ // `instructions` builder turns one signal into the agent's prompt — see "Writing a
68
+ // custom actuator" below for the built-in builders and for how to swap in an actuator
69
+ // that does something other than open a PR.
70
+ actuator: new AgentPrActuator({
71
+ instructions: (signal) => ({
72
+ instructions: [
73
+ `Fix this in ${signal.location?.file ?? "the codebase"}.`,
74
+ `Issue: ${signal.message}`,
75
+ "Make the smallest change that resolves it — no unrelated refactors.",
76
+ ].join("\n"),
77
+ }),
78
+ }),
79
+ });
80
+
81
+ // Expose the built-in CLI (see "Running from CI" below). Or call loop.run() directly.
82
+ loop.cli();
83
+ ```
84
+
85
+ Run it with your TypeScript runner — e.g. `pnpm dlx tsx my-loop.ts run` — on whatever cadence
86
+ you like (a cron trigger, a GitHub Action on a schedule, etc.); the loop itself doesn't stay
87
+ resident between runs.
88
+
89
+ See `examples/` for fuller, realistic configurations (shared config in `examples/common.ts`,
90
+ loops built on top of it).
91
+
92
+ ## Running from CI
93
+
94
+ A loop is a plain script, so any TypeScript runner can run it. Rather than wiring up argument
95
+ handling yourself, end the file with **`loop.cli()`** — it turns the script into a small
96
+ command-line tool with two subcommands:
97
+
98
+ ```ts
99
+ const loop = new ControlLoop({ /* ... */ });
100
+
101
+ loop.cli(); // parses process.argv and runs the matching subcommand
102
+ ```
103
+
104
+ ```bash
105
+ tsx my-loop.ts run # one full cycle: sense → pick → actuate (→ PR)
106
+ tsx my-loop.ts apply-comments 1234 # apply new review comments on PR #1234, push in place
107
+ tsx my-loop.ts --help # usage (works on each subcommand too)
108
+ ```
109
+
110
+ - **`run`** exits **non-zero** if a fix couldn't be verified (`fix-failed`) or on any thrown
111
+ error, so a CI step fails loudly. Every other outcome — `clean`, `skipped`, `pr-opened` —
112
+ exits `0`. The result is also printed as a one-line summary.
113
+ - **`apply-comments <pr>`** reads comments newer than the PR's head commit and applies them to
114
+ the PR's branch (see **PR review comments** above). Wire it to a `pull_request_review_comment`
115
+ trigger, passing the PR number.
116
+
117
+ Help and argument parsing are handled by [commander](https://github.com/tj/commander.js), so
118
+ `--help`, unknown-command errors, and per-command usage all come for free.
119
+
120
+ A GitHub Actions workflow that runs the loop hourly and handles review comments as they land:
121
+
122
+ ```yaml
123
+ name: control-loop
124
+ on:
125
+ schedule:
126
+ - cron: "0 * * * *" # hourly
127
+ pull_request_review_comment:
128
+ types: [created]
129
+
130
+ jobs:
131
+ loop:
132
+ runs-on: ubuntu-latest
133
+ steps:
134
+ - uses: actions/checkout@v4
135
+ - uses: pnpm/action-setup@v4
136
+ - uses: actions/setup-node@v4
137
+ with:
138
+ node-version: 20
139
+ cache: pnpm
140
+ - run: pnpm install
141
+ - name: run
142
+ if: github.event_name == 'schedule'
143
+ run: pnpm dlx tsx my-loop.ts run
144
+ env:
145
+ DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
146
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
147
+ GITHUB_TOKEN: ${{ secrets.GH_PUSH_TOKEN }}
148
+ - name: apply review comments
149
+ if: github.event_name == 'pull_request_review_comment'
150
+ run: pnpm dlx tsx my-loop.ts apply-comments ${{ github.event.pull_request.number }}
151
+ env:
152
+ DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
153
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
154
+ GITHUB_TOKEN: ${{ secrets.GH_PUSH_TOKEN }}
155
+ ```
156
+
157
+ Pass `config.maxOpenPrCount` to keep a scheduled `run` from piling up PRs faster than they merge.
158
+
159
+ ## Core concepts
160
+
161
+ A control loop is five pieces of configuration wired into `ControlLoop`:
162
+
163
+ | Piece | Type | Purpose |
164
+ | --- | --- | --- |
165
+ | `sensor` | `Sensor` | Scans the repo, returns `Signal[]` — the anomalies found |
166
+ | `picker` | `Picker` (optional) | Chooses which signals to act on *this run* |
167
+ | `actuator` | `Actuator` | Acts on the picked signals; the default runs the agent and opens a PR |
168
+ | `config.agent` | `Agent` | Drives a coding agent CLI inside the sandbox |
169
+ | `config.git` | `GitSource` | Where to clone from, where to push and open the PR |
170
+
171
+ A **`Signal`** is the unit everything else operates on:
172
+
173
+ ```ts
174
+ interface Signal<TData = unknown> {
175
+ // Where the anomaly is. Optional — some anomalies are repo-wide. When present,
176
+ // `location.file` anchors the signal's identity (see below).
177
+ location?: { file: string; line?: number };
178
+ message: string;
179
+ priority?: "low" | "medium" | "high";
180
+ data?: TData; // anything sensor-specific; carried through to picker and actuator untouched
181
+ }
182
+ ```
183
+
184
+ Signals are matched across runs by **`location.file + message` only** — `line` is informational and
185
+ naturally shifts as fixes land, so never bake a line number or timestamp into `message`. This
186
+ identity is what lets the loop tell "still broken, retry" from "fixed" during verification, and
187
+ what lets it skip signals an already-open PR has claimed.
188
+
189
+ ### What one `run()` does, in order
190
+
191
+ 1. Skip early if `config.maxOpenPrCount` open PRs already carry this loop's label.
192
+ 2. Spin up a sandbox, clone the repo, run `config.hooks.setup`.
193
+ 3. Run the sensor. No signals → done (`status: "clean"`).
194
+ 4. Drop signals already claimed by another open PR of this loop (see **Memory & claiming**
195
+ below).
196
+ 5. Run the picker (default: `pickers.count(1)`, i.e. the first signal in sensor order). No
197
+ signals picked → done.
198
+ 6. Hand the picked signals to `actuator.act(...)` and return whatever it reports. Steps 6a–6c
199
+ below are what the **default `AgentPrActuator`** does — a different actuator owns this step
200
+ entirely and may do none of them:
201
+ - **6a.** Check out a branch, run the agent once per picked signal using that signal's
202
+ instructions, then re-run the sensor to check which signals are gone. Anything still
203
+ present is retried (with a "try a different approach" nudge) up to `config.maxFixAttempts`
204
+ times (default 3).
205
+ - **6b.** If anything's still unresolved after all attempts → `status: "fix-failed"`,
206
+ nothing is pushed.
207
+ - **6c.** Otherwise: run `config.hooks.preCommit`, commit everything with `--no-verify`,
208
+ push, open the PR (labeled, with a claim marker in the body) → `status: "pr-opened"`.
209
+
210
+ `run()` resolves to a `ControlLoopRunResult` — always check `.status` rather than assuming
211
+ success; a clean sensor pass and a "we tried and failed" pass are both normal outcomes, not
212
+ exceptions.
213
+
214
+ ### PR review comments
215
+
216
+ `loop.applyPrComments(prNumber)` is the other entry point — wire it to your GitHub webhook (or
217
+ poll for it) for comment activity on PRs the loop opened. It fetches comments newer than the
218
+ PR's head commit, has the agent apply them on the PR's existing branch, and pushes in place.
219
+ Comments older than the head are treated as already addressed, so re-triggering on the same
220
+ thread is safe (`status: "no-new-comments"`).
221
+
222
+ A comment starting with `/feedback` is applied like any other comment, but its text (prefix
223
+ stripped) is also persisted into `.github/control-loop/memory-<label>.md` in the repo. That
224
+ file is read on every future run of the same loop and appended to every agent instruction as
225
+ standing guidance — this is how you correct a loop's behavior once and have it stick, since the
226
+ memory file ships with the PR that recorded it.
227
+
228
+ ### Claiming (safe concurrency)
229
+
230
+ Every opened PR embeds an invisible marker listing the signal keys it fixes. On the next run,
231
+ signals already claimed by an *open* PR of the same loop are excluded before picking — so
232
+ running the loop again before a previous PR merges won't produce a duplicate fix for the same
233
+ issue. A merged PR removes the signal at the source (the sensor stops reporting it); a
234
+ closed-without-merging PR releases its claim automatically since it's no longer open.
235
+
236
+ ## Writing a custom sensor
237
+
238
+ A sensor is either a plain function or a script uploaded into the sandbox. Use `sensors.*` for
239
+ common cases; write your own when they don't fit.
240
+
241
+ **Host-side function** (`SensorFn`) — runs on your machine/CI runner, with a `RepoHandle` that
242
+ proxies `exec`, `readFile`, `glob`, and `grep` into the sandbox:
243
+
244
+ ```ts
245
+ import type { RepoHandle } from "@bpinternal/overwatch";
246
+ import type { Signal } from "@bpinternal/overwatch";
247
+
248
+ const myTodoSensor = async (repo: RepoHandle): Promise<Signal[]> => {
249
+ const matches = await repo.grep("FIXME:", ["src/"]);
250
+ return matches.map((match) => ({
251
+ location: { file: match.file, line: match.line },
252
+ message: `unresolved FIXME: ${match.content.trim()}`,
253
+ priority: "low",
254
+ }));
255
+ };
256
+
257
+ const loop = new ControlLoop({ /* ... */ sensor: myTodoSensor, /* ... */ });
258
+ ```
259
+
260
+ Use this shape whenever the check is cheap and expressible with `exec`/`grep`/`glob` — closures
261
+ and host-side imports work normally, since it never leaves your process.
262
+
263
+ **Sandbox script** (`sensors.script(localPath)`) — for heavier scanners (custom static
264
+ analysis, project-specific tooling) that should run *inside* the sandbox with the repo root as
265
+ cwd, rather than round-tripping through the host:
266
+
267
+ ```ts
268
+ // sensors/find-unhandled-promises.ts
269
+ export default async function (): Promise<import("@bpinternal/overwatch").Signal[]> {
270
+ // this file is uploaded and run with `bun` inside the sandbox; cwd is the repo root
271
+ // ... your scanning logic ...
272
+ return [];
273
+ }
274
+ ```
275
+
276
+ ```ts
277
+ sensor: sensors.script("./sensors/find-unhandled-promises.ts"),
278
+ ```
279
+
280
+ The default export must be `() => Promise<Signal[]>`. The loop wraps it in a small runner that
281
+ writes the result to a JSON file rather than stdout, so the script is free to `console.log` for
282
+ its own debugging.
283
+
284
+ **Built-in sensors** (`sensors.grep`, `sensors.astGrep`, `sensors.reactDoctor`) cover plain-text
285
+ search, structural AST search, and [react-doctor](https://react.doctor) diagnostics
286
+ respectively — check `src/sensors/` for their full option shapes before writing a custom one;
287
+ a `grep`/`astGrep` call with a custom `message` function often replaces what would otherwise be
288
+ a bespoke sensor.
289
+
290
+ Whatever shape you pick: keep `paths` (or the equivalent scope) intentional. An unscoped sensor
291
+ turns "fix this one thing" into "fix everything, everywhere, forever."
292
+
293
+ ## Writing a custom picker
294
+
295
+ A picker narrows the sensed (and unclaimed) signals down to what one run should act on:
296
+
297
+ ```ts
298
+ type Picker = (signals: Signal[]) => Promise<Signal[]> | Signal[];
299
+ ```
300
+
301
+ ```ts
302
+ import type { Signal } from "@bpinternal/overwatch";
303
+
304
+ const highPriorityFirst = (max: number) => (signals: Signal[]): Signal[] =>
305
+ [...signals]
306
+ .sort((a, b) => rank(b.priority) - rank(a.priority))
307
+ .slice(0, max);
308
+
309
+ function rank(p: Signal["priority"]): number {
310
+ return { high: 2, medium: 1, low: 0 }[p];
311
+ }
312
+ ```
313
+
314
+ Built-ins: `pickers.count(n)` (first `n` in sensor order — the default) and
315
+ `pickers.busiestFile(maxCount?)` (every signal in whichever single file has the most, so a run's
316
+ fixes land together in one coherent PR). Sort inside your sensor, or write a picker like the one
317
+ above, to change what "first" means.
318
+
319
+ ## Actuators
320
+
321
+ The **actuator** owns everything after picking — it decides what actually *happens* to the
322
+ signals a run selected, and reports the outcome. It's an abstract class with one method:
323
+
324
+ ```ts
325
+ abstract class Actuator<TData = unknown> {
326
+ abstract act(input: ActuatorInput<TData>): Promise<ControlLoopRunResult<TData>>;
327
+ }
328
+ ```
329
+
330
+ `act` receives the picked `signals`, the provisioned `sandbox`, an `AgentContext` (`ctx`) for
331
+ running commands / writing files in it, a `sense()` callback to re-run the sensor, the loop's
332
+ `config`, its `label`/`displayLabel`, and the run `log` — everything needed to do the work and
333
+ return a `ControlLoopRunResult`.
334
+
335
+ ### The default: `AgentPrActuator`
336
+
337
+ Most loops want the built-in behavior: run the agent on each signal, verify by re-sensing (with
338
+ retries), then commit and open one PR. That's `AgentPrActuator`, and it takes an
339
+ **instructions builder** — a function turning one signal into the agent's prompt:
340
+
341
+ ```ts
342
+ type InstructionBuilder<TData = unknown> =
343
+ (signal: Signal<TData>) => Promise<{ instructions: string }> | { instructions: string };
344
+ ```
345
+
346
+ ```ts
347
+ import { AgentPrActuator, actuators } from "@bpinternal/overwatch";
348
+
349
+ new AgentPrActuator({
350
+ instructions: (signal) => ({
351
+ instructions: [
352
+ `Fix this in ${signal.location?.file ?? "the codebase"}.`,
353
+ `Issue: ${signal.message}`,
354
+ "Make the smallest change that resolves it — no unrelated refactors.",
355
+ ].join("\n"),
356
+ }),
357
+ });
358
+ ```
359
+
360
+ Reach into `signal.data` for anything sensor-specific — e.g. `sensors.reactDoctor` attaches the
361
+ full diagnostic (including its `help` text) there, and `sensors.astGrep`/`sensors.grep` attach
362
+ the raw match.
363
+
364
+ Two builders ship under `actuators` (pass one as `instructions`, or omit it — the default is
365
+ `fromSignal()`):
366
+
367
+ - `actuators.fromSignal()` — minimal instructions built from the signal alone; a good default.
368
+ - `actuators.fromFile(localPath)` — reads a local markdown/text file once (cached) and uses it as
369
+ the instructions, with the signal's details appended. Use this when the fix follows a
370
+ house style that's easier to write as a doc than to reconstruct per-signal — see
371
+ `examples/blank-errors-instructions.md` for a real one (a full migration guide with a mapping
372
+ table and before/after snippets).
373
+
374
+ `AgentPrActuator` appends two things to every instruction automatically: a retry nudge on
375
+ repeated attempts, and this loop's accumulated `/feedback` memory (see **PR review comments**
376
+ above). Don't duplicate either concern in your instructions builder.
377
+
378
+ ### Writing a custom actuator
379
+
380
+ Subclass `Actuator` when you want a run to do something *other than* open a PR — post a report to
381
+ Slack, write a summary file, call an API, open an issue instead of a PR. The actuator need not
382
+ touch a coding agent at all; it just has to return a `ControlLoopRunResult`:
383
+
384
+ ```ts
385
+ import { Actuator, type ActuatorInput, type ControlLoopRunResult } from "@bpinternal/overwatch";
386
+
387
+ class ReportOnly extends Actuator {
388
+ async act({ signals, log }: ActuatorInput): Promise<ControlLoopRunResult> {
389
+ log.info(`found ${signals.length} signal(s) — reporting without touching the repo`);
390
+ await postToSlack(signals.map((s) => `• ${s.location?.file ?? "repo"}: ${s.message}`).join("\n"));
391
+ return { status: "clean" }; // nothing was changed or opened
392
+ }
393
+ }
394
+ ```
395
+
396
+ The loop still handles sensing, claim-filtering, picking, and the `maxOpenPrCount` gate around
397
+ your actuator — those key off PRs carrying the loop's label, so they simply no-op for an actuator
398
+ that never opens one.
399
+
400
+ ## Config reference (`ControlLoopConfig`)
401
+
402
+ The static, per-loop settings passed as `config`:
403
+
404
+ - **`git`** (`GitSource`, required) — use `new Github({ repo, branch, key })`. `key` is
405
+ optional for cloning/counting PRs on a public repo, required to push or open a PR.
406
+ - **`agent`** (`Agent`, required) — `new Claude({ apiKey, model?, timeoutSec? })` or
407
+ `new Codex({ apiKey, model?, timeoutSec? })`. Extend the abstract `Agent` class
408
+ (`prepare`, `executeAgent`) to plug in another CLI.
409
+ - **`maxOpenPrCount`** — skip the run outright if this many PRs with the loop's label are
410
+ already open.
411
+ - **`branchPrefix`** — default `"control-loop"`; pushed branches look like
412
+ `<prefix>/<label-slug>-<run-id>`.
413
+ - **`hooks.setup`** — shell command run right after cloning (e.g. `"bun install"`). Non-zero
414
+ exit aborts the run.
415
+ - **`hooks.preCommit`** — shell command run after the agent, before committing (e.g. your
416
+ repo's auto-formatter). Normalizes both agent edits and anything the loop wrote, so the PR
417
+ passes formatting CI. Non-zero exit aborts the run.
418
+ - **`env`** — env vars set on the sandbox at creation (available to `hooks` and the agent).
419
+ - **`maxFixAttempts`** — default 3; see step 6 above.
420
+ - **`sandbox.snapshot`** — a Daytona snapshot to create the sandbox from instead of the default
421
+ image. Pre-bake your toolchain and the agent CLI here to skip per-run installs. Note:
422
+ resource sizing must be baked into the snapshot itself — it can't be requested at creation
423
+ time when using one.
424
+
425
+ ## Logging
426
+
427
+ Every run narrates its steps to stderr via `RunLog` (sandbox creation, cloning, sensing,
428
+ per-signal agent runs, commit/push, PR open) — nothing to configure, just watch stderr when
429
+ running a loop manually. Set `CONTROL_LOOP_SILENT=1` to mute it; colors otherwise follow
430
+ `NO_COLOR` and TTY detection.
431
+
432
+ ## Extending
433
+
434
+ - **New sensor flavor**: add a file to `src/sensors/` returning `SensorFn` and re-export it from
435
+ `src/sensors/index.ts` (or use `sensors.script`/a raw function inline — you don't need a
436
+ built-in for one-off scans).
437
+ - **New picker**: any `(signals: Signal[]) => Signal[] | Promise<Signal[]>` works inline; add a
438
+ file to `src/pickers/` (re-exported from its `index.ts`) only if it's broadly reusable.
439
+ - **New instructions style**: add an `InstructionBuilder` factory to `src/actuators/instructions.ts`
440
+ (like `fromFile`/`fromSignal`), or pass one inline to `AgentPrActuator`.
441
+ - **New actuator kind** (doesn't open a PR / doesn't run an agent): add a file to `src/actuators/`
442
+ with a class that subclasses the abstract `Actuator`, re-exported from `src/actuators/index.ts`.
443
+ - **New agent CLI**: extend the abstract `Agent` class in `src/agents.ts`.
444
+ - **New forge** (GitLab, etc.): implement the `GitSource` interface from `src/github.ts`.
445
+
446
+ ## Disclaimer ⚠️
447
+
448
+ This package is published under the `@bpinternal` organization. All packages of this organization are meant to be used by the [Botpress](https://github.com/botpress/botpress) team internally and are not meant for our community. Since the packages are catered to our own use-cases, they might have less stable APIs, receive breaking changes without much warning, have minimal documentation and lack community-focused support. However, these packages were still left intentionally public for an important reason : We Love Open-Source. Therefore, if you wish to install or fork this package feel absolutly free to do it. We strongly recommend that you tag your versions properly.
449
+
450
+ The Botpress Engineering team.
451
+
@@ -0,0 +1,13 @@
1
+ import type { ControlLoopRunResult } from "../types";
2
+ import type { ActuatorInput } from "./index";
3
+ /**
4
+ * Owns the "act on the picked signals" step of a run and reports the outcome. The loop
5
+ * senses, filters, and picks; the actuator decides what actually happens next.
6
+ *
7
+ * The default {@link AgentPrActuator} runs a coding agent per signal, verifies via
8
+ * re-sensing, and opens a PR — but an actuator is free to do anything (post a message,
9
+ * write a report, call an API) and need not touch an agent or open a PR at all.
10
+ */
11
+ export declare abstract class Actuator<TData = unknown> {
12
+ abstract act(input: ActuatorInput<TData>): Promise<ControlLoopRunResult<TData>>;
13
+ }
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var actuator_exports = {};
20
+ __export(actuator_exports, {
21
+ Actuator: () => Actuator
22
+ });
23
+ module.exports = __toCommonJS(actuator_exports);
24
+ class Actuator {
25
+ }
26
+ // Annotate the CommonJS export names for ESM import in node:
27
+ 0 && (module.exports = {
28
+ Actuator
29
+ });
30
+ //# sourceMappingURL=actuator.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/actuators/actuator.ts"],
4
+ "sourcesContent": ["import type { ControlLoopRunResult } from \"../types\";\nimport type { ActuatorInput } from \"./index\";\n\n/**\n * Owns the \"act on the picked signals\" step of a run and reports the outcome. The loop\n * senses, filters, and picks; the actuator decides what actually happens next.\n *\n * The default {@link AgentPrActuator} runs a coding agent per signal, verifies via\n * re-sensing, and opens a PR \u2014 but an actuator is free to do anything (post a message,\n * write a report, call an API) and need not touch an agent or open a PR at all.\n */\nexport abstract class Actuator<TData = unknown> {\n abstract act(input: ActuatorInput<TData>): Promise<ControlLoopRunResult<TData>>;\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAWO,MAAe,SAA0B;AAEhD;",
6
+ "names": []
7
+ }
@@ -0,0 +1,25 @@
1
+ import type { ControlLoopRunResult } from "../types";
2
+ import { Actuator } from "./actuator";
3
+ import type { ActuatorInput, InstructionBuilder } from "./index";
4
+ export interface AgentPrActuatorProps {
5
+ /** Builds the agent's instructions per signal. Defaults to {@link fromSignal}. */
6
+ instructions?: InstructionBuilder;
7
+ }
8
+ /**
9
+ * The default actuator: for each picked signal it runs the configured coding agent, then
10
+ * re-senses to verify the signal is gone (retrying up to `config.maxFixAttempts`), and
11
+ * finally commits every fix to a fresh branch and opens one PR — claiming the signals it
12
+ * fixed in the PR body so concurrent runs don't duplicate the work.
13
+ *
14
+ * This is the behavior the loop used to hard-code; it's now a swappable actuator.
15
+ */
16
+ export declare class AgentPrActuator<TData = unknown> extends Actuator<TData> {
17
+ private readonly instructions;
18
+ constructor(props?: AgentPrActuatorProps);
19
+ act(input: ActuatorInput<TData>): Promise<ControlLoopRunResult<TData>>;
20
+ /**
21
+ * Runs the agent on each signal, then re-senses to verify. Signals still reported
22
+ * by the sensor get retried (with a nudge appended) up to maxFixAttempts times.
23
+ */
24
+ private runFixLoop;
25
+ }
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var agent_pr_exports = {};
20
+ __export(agent_pr_exports, {
21
+ AgentPrActuator: () => AgentPrActuator
22
+ });
23
+ module.exports = __toCommonJS(agent_pr_exports);
24
+ var import_claims = require("../claims");
25
+ var import_sandbox = require("../sandbox");
26
+ var import_actuator = require("./actuator");
27
+ var import_instructions = require("./instructions");
28
+ class AgentPrActuator extends import_actuator.Actuator {
29
+ instructions;
30
+ constructor(props = {}) {
31
+ super();
32
+ this.instructions = props.instructions ?? (0, import_instructions.fromSignal)();
33
+ }
34
+ async act(input) {
35
+ const { signals: picked, sandbox, ctx, sense, config, log, label, displayLabel } = input;
36
+ const prefix = (config.branchPrefix ?? "control-loop").replace(/\/+$/, "");
37
+ const branch = `${prefix}/${label}-${Date.now().toString(36)}`;
38
+ await log.step(`checking out branch ${branch}`, async () => {
39
+ await sandbox.git.createBranch(import_sandbox.REPO_PATH, branch);
40
+ await sandbox.git.checkoutBranch(import_sandbox.REPO_PATH, branch);
41
+ });
42
+ await log.step("preparing agent", () => config.agent.prepare(ctx));
43
+ const memory = await (0, import_sandbox.readMemory)(ctx, label);
44
+ const unresolved = await this.runFixLoop(picked, ctx, sense, memory, log, config);
45
+ if (unresolved.length > 0) {
46
+ log.finish(`fix failed \u2014 ${unresolved.length} signal(s) still present`);
47
+ return { status: "fix-failed", unresolved };
48
+ }
49
+ await ctx.exec(`rm -rf ${import_sandbox.SCRATCH_DIR}`);
50
+ await (0, import_sandbox.runConfiguredCommand)("preCommit", config, ctx, log);
51
+ await log.step("committing & pushing", async () => {
52
+ await (0, import_sandbox.commitAll)(ctx, prTitle(displayLabel, picked));
53
+ await config.git.push(sandbox, import_sandbox.REPO_PATH, branch);
54
+ });
55
+ const prUrl = await log.step(
56
+ "opening pull request",
57
+ () => config.git.openPr({
58
+ branch,
59
+ label,
60
+ title: prTitle(displayLabel, picked),
61
+ body: prBody(displayLabel, picked)
62
+ })
63
+ );
64
+ log.finish(`PR opened \u2192 ${prUrl}`);
65
+ return { status: "pr-opened", prUrl, fixed: picked };
66
+ }
67
+ /**
68
+ * Runs the agent on each signal, then re-senses to verify. Signals still reported
69
+ * by the sensor get retried (with a nudge appended) up to maxFixAttempts times.
70
+ */
71
+ async runFixLoop(picked, ctx, sense, memory, log, config) {
72
+ const maxAttempts = config.maxFixAttempts ?? 3;
73
+ let remaining = picked;
74
+ let iteration = 0;
75
+ for (let attempt = 1; attempt <= maxAttempts && remaining.length > 0; attempt++) {
76
+ for (const signal of remaining) {
77
+ const { instructions } = await this.instructions(signal);
78
+ const retryNote = attempt === 1 ? "" : "\n\nA previous attempt did not resolve this issue \u2014 the sensor still reports it. Look again and try a different approach.";
79
+ const where = signalWhere(signal) || signal.message;
80
+ iteration++;
81
+ await log.step(
82
+ `agent fixing ${where} (iteration ${iteration})`,
83
+ () => config.agent.executeAgent(instructions + retryNote + (0, import_sandbox.memoryNote)(memory), ctx)
84
+ );
85
+ }
86
+ const stillPresent = new Set((await log.step("re-sensing to verify", sense)).map(import_claims.signalKey));
87
+ remaining = remaining.filter((signal) => stillPresent.has((0, import_claims.signalKey)(signal)));
88
+ }
89
+ return remaining;
90
+ }
91
+ }
92
+ function signalWhere(signal) {
93
+ const { location } = signal;
94
+ if (!location) return "";
95
+ return `${location.file}${location.line ? `:${location.line}` : ""}`;
96
+ }
97
+ function prTitle(label, fixed) {
98
+ return fixed.length === 1 ? `fix: ${fixed[0].message}` : `fix: ${label}: ${fixed.length} issues`;
99
+ }
100
+ function prBody(label, fixed) {
101
+ const lines = fixed.map((signal) => {
102
+ const where = signalWhere(signal);
103
+ const priority = signal.priority ? ` (${signal.priority})` : "";
104
+ return `- ${where ? `\`${where}\` \u2014 ` : ""}${signal.message}${priority}`;
105
+ });
106
+ return [
107
+ `Automated fix opened by the **${label}** control loop.`,
108
+ "",
109
+ "Signals resolved (verified by re-running the sensor):",
110
+ ...lines,
111
+ "",
112
+ (0, import_claims.claimMarker)(fixed)
113
+ ].join("\n");
114
+ }
115
+ // Annotate the CommonJS export names for ESM import in node:
116
+ 0 && (module.exports = {
117
+ AgentPrActuator
118
+ });
119
+ //# sourceMappingURL=agent-pr.js.map