@tonoid/agent-loop 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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +378 -0
  3. package/briefs/default/build.md +35 -0
  4. package/briefs/default/core.md +52 -0
  5. package/briefs/default/journal.optional.md +7 -0
  6. package/briefs/default/review.md +65 -0
  7. package/briefs/default/routine.md +18 -0
  8. package/briefs/default/screenshots.optional.md +10 -0
  9. package/briefs/default/subagents.optional.md +7 -0
  10. package/docs/cutover.md +121 -0
  11. package/package.json +46 -0
  12. package/src/adapters/gh.ts +77 -0
  13. package/src/adapters/git.ts +82 -0
  14. package/src/adapters/herdr.ts +121 -0
  15. package/src/adapters/run.ts +64 -0
  16. package/src/adopt.ts +47 -0
  17. package/src/brief.ts +124 -0
  18. package/src/check.ts +126 -0
  19. package/src/cli-pause.ts +16 -0
  20. package/src/cli.ts +290 -0
  21. package/src/config.ts +211 -0
  22. package/src/ctx.ts +64 -0
  23. package/src/discover.ts +344 -0
  24. package/src/effects/monitor.ts +148 -0
  25. package/src/effects/spawn.ts +246 -0
  26. package/src/effects/sweep.ts +27 -0
  27. package/src/engine/item.ts +32 -0
  28. package/src/engine/monitor.ts +117 -0
  29. package/src/engine/naming.ts +45 -0
  30. package/src/engine/spawn.ts +101 -0
  31. package/src/engine/sweep.ts +91 -0
  32. package/src/engine/tick.ts +25 -0
  33. package/src/filing.ts +56 -0
  34. package/src/globalstate.ts +228 -0
  35. package/src/journal.ts +13 -0
  36. package/src/kinds/builder.ts +128 -0
  37. package/src/kinds/index.ts +13 -0
  38. package/src/kinds/reviewer.ts +220 -0
  39. package/src/kinds/routine.ts +174 -0
  40. package/src/kinds/shared.ts +49 -0
  41. package/src/kinds/validate.ts +187 -0
  42. package/src/lock.ts +63 -0
  43. package/src/paths.ts +21 -0
  44. package/src/render.ts +42 -0
  45. package/src/router/budget.ts +81 -0
  46. package/src/router/providers/claude.ts +177 -0
  47. package/src/router/providers/codex.ts +120 -0
  48. package/src/router/providers/grok.ts +10 -0
  49. package/src/router/rate.ts +62 -0
  50. package/src/router/route.ts +179 -0
  51. package/src/router/window.ts +39 -0
  52. package/src/runtime/worker.ts +75 -0
  53. package/src/state.ts +128 -0
  54. package/src/status.ts +41 -0
  55. package/src/types.ts +225 -0
@@ -0,0 +1,25 @@
1
+ import type { Ctx, Job, Decision } from "../types"
2
+ import { sweepAll } from "./sweep"
3
+ import { monitorAll } from "./monitor"
4
+ import { spawnOne } from "./spawn"
5
+
6
+ export interface TickOpts { paused: boolean; pausedJobs?: string[]; gcDays?: number }
7
+
8
+ export async function runTick(ctx: Ctx, jobs: Job[], opts: TickOpts): Promise<Decision[]> {
9
+ const started = Date.now()
10
+ const out: Decision[] = []
11
+ const emit = (ds: Decision[]) => {
12
+ for (const d of ds) {
13
+ out.push(d)
14
+ ctx.log(d)
15
+ }
16
+ }
17
+
18
+ emit([{ pass: "gc", removed: ctx.marks.gc(opts.gcDays ?? 14) }])
19
+ emit(await sweepAll(ctx, jobs))
20
+ emit(await monitorAll(ctx, jobs))
21
+ if (!opts.paused) emit(await spawnOne(ctx, jobs, opts.pausedJobs ?? []))
22
+
23
+ emit([{ pass: "tick", workspace: ctx.workspace.name, ms: Date.now() - started }])
24
+ return out
25
+ }
package/src/filing.ts ADDED
@@ -0,0 +1,56 @@
1
+ import type { Ctx, Job } from "./types"
2
+ import { issues } from "./kinds/shared"
3
+
4
+ export type { FilingConfig } from "./types"
5
+
6
+ // openQueue is the consumer's own discover() result: unclaimed and unparked by
7
+ // construction, because that is what a job offers the spawn walk. Cached under
8
+ // the engine's key so the consumer's own spawn pass in this same tick pays for
9
+ // the read once.
10
+ export async function filingBudget(
11
+ ctx: Ctx,
12
+ job: Job,
13
+ ): Promise<{ openQueue: number; filingBudget: number }> {
14
+ const f = job.filing
15
+ if (!f) return { openQueue: 0, filingBudget: 0 }
16
+ const consumer = ctx.workspace.jobs.find((j) => j.name === f.queue)
17
+ if (!consumer) throw new Error(`job "${job.name}": filing.queue "${f.queue}" names no job in this workspace`)
18
+ const items = await ctx.cache(`engine:discover:${consumer.name}`, () => consumer.discover(ctx))
19
+ const openQueue = items.length
20
+ return {
21
+ openQueue,
22
+ filingBudget: openQueue >= f.maxOpen ? 0 : Math.min(f.perRound, f.maxOpen - openQueue),
23
+ }
24
+ }
25
+
26
+ // Enforcement stays out of the engine (spec 5.2): a job that overfiles has a
27
+ // brief problem, and a loop that blocked `gh issue create` would break the one
28
+ // write the fences deliberately allow. So this counts and reports.
29
+ //
30
+ // tradeoff: attribution is by time window, not by author, so two workers of the
31
+ // same job filing in the same window share the count. Per-worker attribution
32
+ // needs the forge to record who filed what, which is a query this does not have.
33
+ export async function auditFiling(
34
+ ctx: Ctx,
35
+ job: Job,
36
+ key: string,
37
+ ): Promise<{ filed: number; budget: number } | null> {
38
+ const f = job.filing
39
+ if (!f) return null
40
+ const consumer = ctx.workspace.jobs.find((j) => j.name === f.queue)
41
+ if (!consumer) return null
42
+ // How long ago this worker started. No mark means the loop never spawned this
43
+ // key (a reboot, a hand-run), so there is no run to audit.
44
+ const age = ctx.marks.age(job.name, key, "spawned")
45
+ if (age === null) return null
46
+ const since = ctx.now.getTime() - age * 60000
47
+ // The shared helper, so this shares the consumer's own cached list rather
48
+ // than issuing the same --state all read again under a second key.
49
+ const items = await issues(ctx, consumer, "all")
50
+ const filed = items.filter((i) => i.createdAt && Date.parse(i.createdAt) >= since).length
51
+ // perRound rather than the budget the brief carried: the budget is recomputed
52
+ // from a queue depth that has moved since, and perRound is the half of it that
53
+ // does not move. A run that filed within perRound but above the room left is
54
+ // therefore not flagged.
55
+ return { filed, budget: f.perRound }
56
+ }
@@ -0,0 +1,228 @@
1
+ // src/globalstate.ts
2
+ import { Database } from "bun:sqlite"
3
+ import type { Window } from "./types"
4
+
5
+ export interface UsageSample {
6
+ percent: number
7
+ at: number
8
+ }
9
+
10
+ export interface GlobalStore {
11
+ recordUsage(accountId: string, w: Window): void
12
+ lastUsage(accountId: string, kind: string, beforeMs: number): UsageSample | null
13
+ rate(provider: string, kind: string): number | null
14
+ observeRate(provider: string, kind: string, sample: number): number
15
+ spawnAdd(accountId: string, workspace: string, job: string, key: string, at: Date): void
16
+ reserve(accountId: string, workspace: string, job: string, key: string, at: Date, cap: number, sinceMs: number): boolean
17
+ confirm(job: string, key: string, at: Date): void
18
+ release(job: string, key: string, at: Date): void
19
+ // Scoped by workspace: job names and item-derived keys collide freely across
20
+ // repos, so two services each running a job called "build" keyed on issue
21
+ // numbers both hold a "b7". Unscoped, a restart in one looks up the other's
22
+ // account and starts the wrong agent kind against the wrong config dir.
23
+ accountFor(workspace: string, job: string, pathKey: string): string | null
24
+ spawnsSince(sinceMs: number): number
25
+ close(): void
26
+ }
27
+
28
+ // One sample moves the estimate a third of the way. High enough to follow a
29
+ // model or plan change within a few ticks, low enough that a single noisy
30
+ // interval does not swing the whole fleet's concurrency.
31
+ export const EWMA_ALPHA = 0.3
32
+
33
+ export function openGlobalState(path: string): GlobalStore {
34
+ const db = new Database(path)
35
+ migrate(db)
36
+ db.exec(`
37
+ CREATE TABLE IF NOT EXISTS usage (
38
+ account TEXT NOT NULL,
39
+ kind TEXT NOT NULL,
40
+ percent REAL NOT NULL,
41
+ resets_at INTEGER NOT NULL,
42
+ window_minutes INTEGER NOT NULL,
43
+ at INTEGER NOT NULL,
44
+ PRIMARY KEY (account, kind, at)
45
+ );
46
+ CREATE TABLE IF NOT EXISTS rates (
47
+ provider TEXT NOT NULL,
48
+ kind TEXT NOT NULL,
49
+ ewma REAL NOT NULL,
50
+ samples INTEGER NOT NULL,
51
+ PRIMARY KEY (provider, kind)
52
+ );
53
+ CREATE TABLE IF NOT EXISTS spawns (
54
+ account TEXT NOT NULL,
55
+ workspace TEXT NOT NULL DEFAULT '',
56
+ job TEXT NOT NULL,
57
+ key TEXT NOT NULL,
58
+ at INTEGER NOT NULL,
59
+ pending INTEGER NOT NULL DEFAULT 0,
60
+ PRIMARY KEY (job, key, at)
61
+ );
62
+ `)
63
+ // This database is shared across workspaces (spec section 7), and the
64
+ // router now writes a usage row and a rate EWMA update on every tick, so
65
+ // two workspaces ticking in the same minute will collide on a write lock.
66
+ // Without a busy timeout that is SQLITE_BUSY on a normal day; WAL lets
67
+ // reads and writes overlap and the timeout gives a blocked writer room to
68
+ // wait its turn instead of failing immediately.
69
+ db.exec(`
70
+ PRAGMA journal_mode = WAL;
71
+ PRAGMA busy_timeout = 5000;
72
+ `)
73
+
74
+ const insUsage = db.query(
75
+ `INSERT OR IGNORE INTO usage (account, kind, percent, resets_at, window_minutes, at)
76
+ VALUES (?, ?, ?, ?, ?, ?)`,
77
+ )
78
+ const prevUsage = db.query<UsageSample, [string, string, number]>(
79
+ `SELECT percent, at FROM usage
80
+ WHERE account = ? AND kind = ? AND at < ?
81
+ ORDER BY at DESC LIMIT 1`,
82
+ )
83
+ const getRate = db.query<{ ewma: number }, [string, string]>(
84
+ "SELECT ewma FROM rates WHERE provider = ? AND kind = ?",
85
+ )
86
+ const putRate = db.query(
87
+ `INSERT INTO rates (provider, kind, ewma, samples) VALUES (?, ?, ?, 1)
88
+ ON CONFLICT (provider, kind) DO UPDATE SET ewma = ?, samples = samples + 1`,
89
+ )
90
+ const insSpawn = db.query(
91
+ "INSERT OR IGNORE INTO spawns (account, workspace, job, key, at) VALUES (?, ?, ?, ?, ?)",
92
+ )
93
+ const insPending = db.query(
94
+ "INSERT OR IGNORE INTO spawns (account, workspace, job, key, at, pending) VALUES (?, ?, ?, ?, ?, 1)",
95
+ )
96
+ // Scoped by `at`, not just job/key: job names and item-derived keys collide
97
+ // freely across repos, so two workspaces can each hold a distinct pending
98
+ // row for the same (job, key) at different `at` values. Each workspace gets
99
+ // its own `now`, so the instant is what separates them; without the `at`
100
+ // predicate one workspace's confirm or release would match the other's live
101
+ // reservation.
102
+ const setConfirmed = db.query(
103
+ "UPDATE spawns SET pending = 0 WHERE job = ? AND key = ? AND at = ? AND pending = 1",
104
+ )
105
+ const delPending = db.query(
106
+ "DELETE FROM spawns WHERE job = ? AND key = ? AND at = ? AND pending = 1",
107
+ )
108
+ // The exact key, or a directory that carries a title slug after it.
109
+ // A legacy row (no workspace, from before the column existed) never
110
+ // matches here: it carries no workspace to disambiguate, so resolving it
111
+ // for any workspace's query is the cross-workspace collision this column
112
+ // exists to prevent. It still counts toward the daily cap (spawnsSince is
113
+ // workspace-agnostic); it just never hands back an account.
114
+ const findAccount = db.query<{ account: string }, [string, string, string, string]>(
115
+ `SELECT account FROM spawns
116
+ WHERE workspace = ? AND job = ? AND (key = ? OR ? LIKE key || '-%')
117
+ ORDER BY at DESC LIMIT 1`,
118
+ )
119
+ const countSince = db.query<{ n: number }, [number]>(
120
+ "SELECT COUNT(*) AS n FROM spawns WHERE at >= ?",
121
+ )
122
+
123
+ return {
124
+ recordUsage(accountId, w) {
125
+ insUsage.run(
126
+ accountId,
127
+ w.kind,
128
+ w.percent,
129
+ w.resetsAt.getTime(),
130
+ w.windowMinutes,
131
+ w.observedAt.getTime(),
132
+ )
133
+ },
134
+ lastUsage(accountId, kind, beforeMs) {
135
+ return prevUsage.get(accountId, kind, beforeMs)
136
+ },
137
+ rate(provider, kind) {
138
+ return getRate.get(provider, kind)?.ewma ?? null
139
+ },
140
+ observeRate(provider, kind, sample) {
141
+ const prev = getRate.get(provider, kind)?.ewma
142
+ const next = prev === undefined ? sample : EWMA_ALPHA * sample + (1 - EWMA_ALPHA) * prev
143
+ putRate.run(provider, kind, next, next)
144
+ return next
145
+ },
146
+ spawnAdd(accountId, workspace, job, key, at) {
147
+ insSpawn.run(accountId, workspace, job, key, at.getTime())
148
+ },
149
+ // tradeoff: nothing reaps an orphaned pending row (one left by a hard
150
+ // kill between reserve and confirm/release). It sits consuming a cap
151
+ // slot until spawnsSince's window rolls past UTC midnight, at which
152
+ // point it stops counting on its own. Add a reaper if that daily
153
+ // self-heal turns out not to be good enough in practice.
154
+ reserve(accountId, workspace, job, key, at, cap, sinceMs) {
155
+ // BEGIN IMMEDIATE takes the write lock before the count, so two
156
+ // workspaces ticking in the same minute cannot both read "one slot left"
157
+ // and both take it.
158
+ db.exec("BEGIN IMMEDIATE")
159
+ try {
160
+ const used = countSince.get(sinceMs)?.n ?? 0
161
+ if (used >= cap) {
162
+ db.exec("ROLLBACK")
163
+ return false
164
+ }
165
+ // OR IGNORE means the insert can silently do nothing (an exact
166
+ // account/job/key/at row already exists), and a caller must not be
167
+ // told it holds a reservation with no row backing it.
168
+ const { changes } = insPending.run(accountId, workspace, job, key, at.getTime())
169
+ db.exec("COMMIT")
170
+ return changes === 1
171
+ } catch (err) {
172
+ try {
173
+ db.exec("ROLLBACK")
174
+ } catch {
175
+ // SQLite may have already rolled back on its own; that must not
176
+ // replace the original error, which is the one that explains what
177
+ // actually went wrong.
178
+ }
179
+ throw err
180
+ }
181
+ },
182
+ confirm(job, key, at) {
183
+ setConfirmed.run(job, key, at.getTime())
184
+ },
185
+ release(job, key, at) {
186
+ // Only a pending row is deletable: a confirmed spawn stays on the record
187
+ // even if a later step decides to tidy up.
188
+ delPending.run(job, key, at.getTime())
189
+ },
190
+ accountFor(workspace, job, pathKey) {
191
+ return findAccount.get(workspace, job, pathKey, pathKey)?.account ?? null
192
+ },
193
+ spawnsSince(sinceMs) {
194
+ return countSince.get(sinceMs)?.n ?? 0
195
+ },
196
+ close() {
197
+ db.close()
198
+ },
199
+ }
200
+ }
201
+
202
+ // A database written before the ledger-to-spawns rename keeps a populated
203
+ // `ledger` table, and CREATE TABLE IF NOT EXISTS happily creates an empty
204
+ // `spawns` beside it: the daily cap resets to zero, every account reads zero
205
+ // in flight and over-admits, and accountFor returns null for every worker
206
+ // already running, which throws in applyRestart and tombstones the item on the
207
+ // next tick. So the rename happens first, before any CREATE runs.
208
+ function migrate(db: Database): void {
209
+ const has = (name: string) =>
210
+ db.query<{ n: number }, [string]>(
211
+ "SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = ?",
212
+ ).get(name)!.n > 0
213
+
214
+ if (has("ledger") && !has("spawns")) {
215
+ db.exec("ALTER TABLE ledger RENAME TO spawns")
216
+ db.exec("ALTER TABLE spawns RENAME COLUMN plugin TO job")
217
+ }
218
+ if (!has("spawns")) return
219
+ const columns = db.query<{ name: string }, []>("PRAGMA table_info(spawns)").all()
220
+ if (!columns.some((c) => c.name === "workspace")) {
221
+ // Pre-existing rows carry no workspace, and the empty string is the
222
+ // honest value for them: accountFor never matches it, so a legacy row
223
+ // still counts toward the daily cap but resolves no account. A worker
224
+ // that was in flight across the upgrade then fails once, restartable by
225
+ // hand, rather than risk resolving the wrong workspace's account.
226
+ db.exec("ALTER TABLE spawns ADD COLUMN workspace TEXT NOT NULL DEFAULT ''")
227
+ }
228
+ }
package/src/journal.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs"
2
+ import { dirname } from "node:path"
3
+ import type { Ctx } from "./types"
4
+
5
+ // The journal is markdown at ~/.agent-loop/<workspace>/journal.md, the one file
6
+ // the fences let a worker write outside its worktree (spec 8). The engine
7
+ // appends to the same file, so the operator reads one story.
8
+ export function appendJournal(ctx: Ctx, line: string): void {
9
+ if (!ctx.live) return
10
+ const path = ctx.workspace.journalPath
11
+ mkdirSync(dirname(path), { recursive: true })
12
+ appendFileSync(path, `${ctx.now.toISOString()} ${line}\n`)
13
+ }
@@ -0,0 +1,128 @@
1
+ import type { Job, WorkItem, Ctx } from "../types"
2
+ import type { Kind } from "./validate"
3
+ import { issues, prs, unblocked, byPriority, newestByHead } from "./shared"
4
+ import { renderBrief } from "../brief"
5
+ import { branchName } from "../engine/naming"
6
+
7
+ interface Options {
8
+ base: string
9
+ reviewDebt: number
10
+ debtIgnoreLabels: string[]
11
+ issueLabel: string
12
+ deleteRemote: boolean
13
+ sweepIgnoresWorking: boolean
14
+ copyIntoWorktree: string[]
15
+ journal: boolean
16
+ screenshots: boolean
17
+ }
18
+
19
+ export const builder: Kind = {
20
+ name: "builder",
21
+ workload: "builder",
22
+ fields: [
23
+ { name: "base", type: "string", default: "origin/main", doc: "the ref new work branches from" },
24
+ { name: "reviewDebt", type: "number", default: 0, doc: "stop taking issues at this many open pull requests, 0 to never stop" },
25
+ { name: "debtIgnoreLabels", type: "string[]", default: [], doc: "labels that mean a pull request is no longer the reviewer's debt, typically a reviewer's passLabel" },
26
+ { name: "issueLabel", type: "string", default: "", doc: "only take issues carrying this label, empty for all of them" },
27
+ { name: "deleteRemote", type: "boolean", default: true, doc: "delete the pushed branch when the work is swept" },
28
+ { name: "sweepIgnoresWorking", type: "boolean", default: true, doc: "sweep even while an agent works, because a merge is definitive here" },
29
+ { name: "copyIntoWorktree", type: "string[]", default: [], doc: "files copied from the repository into a fresh worktree" },
30
+ { name: "journal", type: "boolean", default: false, doc: "ask the worker to append one line to the journal" },
31
+ { name: "screenshots", type: "boolean", default: false, doc: "allow screenshots on an orphan asset branch" },
32
+ ],
33
+ build(spec) {
34
+ const o = spec.options as unknown as Options
35
+ const optional = [o.journal ? "journal" : "", o.screenshots ? "screenshots" : ""].filter(Boolean)
36
+
37
+ // One place the key format is written. It names the branch, the worktree
38
+ // and the pull request lookup, so a second spelling of it is a bug that
39
+ // only shows up as work rebuilt from scratch.
40
+ const keyFor = (item: WorkItem): string => `b${item.number}`
41
+
42
+ // A pull request whose head is this job's branch for that key, newest
43
+ // first: a retried issue's old closed pull request must not answer for the
44
+ // live one.
45
+ const prFor = async (ctx: Ctx, key: string): Promise<WorkItem | null> =>
46
+ newestByHead(await prs(ctx, job, "all"), branchName(job.name, key))
47
+
48
+ const job: Job = {
49
+ name: spec.name,
50
+ dir: spec.dir,
51
+ repo: spec.repo,
52
+ workload: "builder",
53
+ deleteRemote: o.deleteRemote,
54
+ sweepIgnoresWorking: o.sweepIgnoresWorking,
55
+ copyIntoWorktree: o.copyIntoWorktree,
56
+
57
+ // The throttle reads the reviewer's queue rather than this job's own
58
+ // item, which is why it is admit() and not guard(). Spec 5.1: the queue
59
+ // is every open pull request a reviewer still owes work on, claimed ones
60
+ // very much included, minus the two states a human owns. Excluding
61
+ // claimed items would count the queue as empty exactly when it is
62
+ // busiest, and two parked items would otherwise deadlock the pipeline
63
+ // forever, the builder throttled and the reviewer idle.
64
+ async admit(ctx) {
65
+ if (!o.reviewDebt) return null
66
+ const l = ctx.workspace.naming.labels
67
+ // debtIgnoreLabels alongside the two the spec names: a pass label is
68
+ // terminal for the reviewer, and under mergeMode none such a pull
69
+ // request stays open until continuous integration merges it, so
70
+ // counting it would throttle the builder on finished work.
71
+ const done = new Set([l.park, l.failed, ...o.debtIgnoreLabels].filter(Boolean))
72
+ const open = (await prs(ctx, job, "open")).filter(
73
+ (p) => !p.labels.some((name) => done.has(name)),
74
+ )
75
+ return open.length >= o.reviewDebt ? `review debt ${open.length}/${o.reviewDebt}` : null
76
+ },
77
+
78
+ async discover(ctx) {
79
+ const open = await issues(ctx, job, "open")
80
+ const mine = o.issueLabel ? open.filter((i) => i.labels.includes(o.issueLabel)) : open
81
+ return byPriority(ctx, unblocked(ctx, mine))
82
+ },
83
+
84
+ // --state all, per spec 4.5: a claim label left on a closed issue would
85
+ // otherwise never be seen, and its slot would be held forever.
86
+ async discoverClaimed(ctx) {
87
+ const claim = ctx.workspace.naming.labels.claim
88
+ return (await issues(ctx, job, "all")).filter((i) => i.labels.includes(claim))
89
+ },
90
+
91
+ key: async (_ctx, item) => keyFor(item),
92
+ base: async () => o.base,
93
+
94
+ // done() releases the claim as soon as the pull request exists, and the
95
+ // issue stays open until the merge closes it, so without this the next
96
+ // tick re-picks the issue and the spawn's pre-clean destroys the
97
+ // worktree the reviewer's rounds are still working in.
98
+ guard: async (ctx, item) => {
99
+ const pr = await prFor(ctx, keyFor(item))
100
+ // A closed, unmerged pull request is a legitimate retry.
101
+ return pr === null || pr.state === "CLOSED"
102
+ },
103
+
104
+ // The work is over when the pull request exists: what happens to it after
105
+ // that belongs to the reviewer, and holding the claim would count this
106
+ // issue against the builder's slots through every review round.
107
+ async done(ctx, item) {
108
+ return (await prFor(ctx, keyFor(item))) !== null
109
+ },
110
+
111
+ // Not done: the worktree has to survive until the pull request is
112
+ // finished, because the reviewer's rounds ask the builder's worker for
113
+ // changes in it.
114
+ async sweepOk(ctx, rawKey) {
115
+ const pr = await prFor(ctx, rawKey)
116
+ return pr !== null && pr.state !== "OPEN"
117
+ },
118
+
119
+ brief: (ctx, item) =>
120
+ renderBrief(ctx, job, item, keyFor(item), {
121
+ extends: spec.brief?.extends ?? "default/build",
122
+ optional,
123
+ append: spec.brief?.append,
124
+ }),
125
+ }
126
+ return job
127
+ },
128
+ }
@@ -0,0 +1,13 @@
1
+ import type { Kind } from "./validate"
2
+ import { builder } from "./builder"
3
+ import { reviewer } from "./reviewer"
4
+ import { routine } from "./routine"
5
+
6
+ // The shipped kinds, and the whole registry. Nothing under a project folder is
7
+ // imported, so this is a contract between the engine and these three rather
8
+ // than an extension point (spec 5).
9
+ export const KINDS: Record<string, Kind> = { builder, reviewer, routine }
10
+
11
+ // The public surface of "./kinds" is unchanged: every existing importer, the
12
+ // loader included, still reads the field table and the helpers from here.
13
+ export * from "./validate"