@chernabog/sdk 0.0.0-stage → 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.
package/src/review.ts ADDED
@@ -0,0 +1,398 @@
1
+ // The building blocks of the default `review` and `fix` flows. A review run
2
+ // stays inside one workflow: after each review it waits on whichever comes
3
+ // first, the author's push or the staleness timeout. It ends when the pull
4
+ // request closes, the timeout labels it stale, or the loop cap hands it to a
5
+ // person. The fix flow is the author, for pull requests an agent wrote: it
6
+ // resumes the session that implemented them, addresses the review and pushes.
7
+
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Option from 'effect/Option';
10
+ import * as Record from 'effect/Record';
11
+ import * as Schema from 'effect/Schema';
12
+
13
+ import { NeedsHuman, StepFailed } from './errors.ts';
14
+ import { activity, awaitSignal } from './flow.ts';
15
+ import { fixWithAgent, push, sessionIdFor, waitForCi } from './implement.ts';
16
+ import type { Json } from './json.ts';
17
+ import { latestMarker, reviewMarker, SessionMarker, sessionMarker } from './markers.ts';
18
+ import { renderPrompt } from './prompt.ts';
19
+ import { Agent, Git, GitHub, type PullRequest, Repo, type ReviewNote, Run, type WorkItem, Worktree } from './services.ts';
20
+
21
+ export { type ReviewMarker, reviewMarker, type SessionMarker, sessionMarker } from './markers.ts';
22
+
23
+ // Agent-written pull requests come from claim branches.
24
+ export const isAgentBranch = (branch: string) => branch.startsWith('auto/');
25
+
26
+ export const ReviewItem = Schema.Struct({ pullRequest: Schema.Finite, headRef: Schema.String, agentWritten: Schema.Boolean });
27
+
28
+ export type ReviewItem = typeof ReviewItem.Type;
29
+
30
+ export const FixItem = Schema.Struct({ ...ReviewItem.fields, round: Schema.Finite, reviewedSha: Schema.String });
31
+
32
+ export type FixItem = typeof FixItem.Type;
33
+
34
+ const itemData =
35
+ <A>(schema: Schema.Codec<A, Json>) =>
36
+ (item: WorkItem) =>
37
+ Schema.decodeEffect(schema)(item.data).pipe(Effect.mapError((issue) => new StepFailed({ step: 'work item', message: `${item.key}: ${issue.message}` })));
38
+
39
+ export const pullRequestOf = itemData(ReviewItem);
40
+ const fixItemOf = itemData(FixItem);
41
+
42
+ const reviewItem = (pull: PullRequest): WorkItem => ({
43
+ key: `pr-${pull.number}`,
44
+ title: pull.title,
45
+ url: pull.url,
46
+ data: { pullRequest: pull.number, headRef: pull.headRef, agentWritten: isAgentBranch(pull.headRef) },
47
+ });
48
+
49
+ // The head the latest review saw.
50
+ const reviewedSha = (pull: PullRequest) =>
51
+ GitHub.use((github) => github.comments(pull.number)).pipe(
52
+ Effect.map((comments) =>
53
+ latestMarker(
54
+ reviewMarker.read,
55
+ comments.map((comment) => comment.body),
56
+ ).pipe(Option.map((marker) => marker.sha)),
57
+ ),
58
+ );
59
+
60
+ const changedSinceReview = (pull: PullRequest) => reviewedSha(pull).pipe(Effect.map(Option.exists((sha) => sha !== pull.headSha)));
61
+
62
+ // Pull requests to review, and the signals that wake reviews already waiting:
63
+ // "pushed" when a pull request changed since its last review (for one an agent
64
+ // wrote, once the fix flow handed it back), "closed" when it merged or closed.
65
+ export const trigger = Effect.gen(function* () {
66
+ const github = yield* GitHub;
67
+ const repo = yield* Repo;
68
+ const items: Array<WorkItem> = [];
69
+
70
+ for (const pull of yield* github.pullRequestsLabelled(repo.labels.needsReview)) {
71
+ const item = reviewItem(pull);
72
+
73
+ if (yield* changedSinceReview(pull)) items.push({ ...item, signals: ['pushed'] });
74
+ else items.push(item);
75
+ }
76
+
77
+ for (const pull of yield* github.pullRequestsLabelled(repo.labels.changesRequested)) {
78
+ if (!isAgentBranch(pull.headRef) && (yield* changedSinceReview(pull))) items.push({ ...reviewItem(pull), signals: ['pushed'], wakeOnly: true });
79
+ }
80
+
81
+ for (const label of [repo.labels.changesRequested, repo.labels.approved, repo.labels.needsReview]) {
82
+ for (const pull of yield* github.closedPullRequestsLabelled(label)) items.push({ ...reviewItem(pull), signals: ['closed'], wakeOnly: true });
83
+ }
84
+
85
+ return items;
86
+ });
87
+
88
+ export const Verdict = Schema.Struct({
89
+ verdict: Schema.Literals(['approve', 'request-changes']),
90
+ summary: Schema.String,
91
+ comments: Schema.Array(Schema.Struct({ path: Schema.String, line: Schema.Finite, body: Schema.String })),
92
+ });
93
+
94
+ export type Verdict = typeof Verdict.Type;
95
+
96
+ export const verdictJsonSchema = {
97
+ type: 'object',
98
+ properties: {
99
+ verdict: { type: 'string', enum: ['approve', 'request-changes'] },
100
+ summary: { type: 'string', description: 'The review: what is right, what must change and why, in Markdown' },
101
+ comments: {
102
+ type: 'array',
103
+ description: 'Comments on lines the pull request changed',
104
+ items: {
105
+ type: 'object',
106
+ properties: { path: { type: 'string' }, line: { type: 'number' }, body: { type: 'string' } },
107
+ required: ['path', 'line', 'body'],
108
+ additionalProperties: false,
109
+ },
110
+ },
111
+ },
112
+ required: ['verdict', 'summary', 'comments'],
113
+ additionalProperties: false,
114
+ } satisfies Json;
115
+
116
+ const ReviewResult = Schema.Struct({ verdict: Schema.Literals(['approve', 'request-changes']), sha: Schema.String });
117
+
118
+ const withoutMarkers = (body: string) => body.replace(/<!--.*?-->/gs, '').trim();
119
+
120
+ const describeNote = (note: ReviewNote) =>
121
+ Option.match(note.location, {
122
+ onNone: () => `${note.author}: ${withoutMarkers(note.body)}`,
123
+ onSome: (location) => `${note.author} on ${location.path}:${location.line}: ${withoutMarkers(note.body)}`,
124
+ });
125
+
126
+ const headingFor = (verdict: Verdict['verdict'], reviewer: string) => {
127
+ if (verdict === 'approve') return `Approved by ${reviewer}`;
128
+
129
+ return `Changes requested by ${reviewer}`;
130
+ };
131
+
132
+ // One review of the pull request's current head, in a detached worktree, by the
133
+ // flow's agent (flows.review.agent) in a fresh session that is given the earlier
134
+ // reviews. Posts it and moves the labels.
135
+ export const reviewRound = (pullRequest: number, round: number) =>
136
+ activity(
137
+ `review-${round}`,
138
+ Effect.gen(function* () {
139
+ const github = yield* GitHub;
140
+ const repo = yield* Repo;
141
+ const run = yield* Run;
142
+ const pull = yield* github.pullRequest(pullRequest);
143
+ const worktree = yield* (yield* Worktree).checkout(`review-pr-${pullRequest}`, `origin/${pull.headRef}`);
144
+ const earlier = (yield* github.reviewComments(pullRequest)).filter((note) => note.trusted);
145
+
146
+ const prompt = yield* renderPrompt('review', {
147
+ repo: repo.slug,
148
+ pullRequest,
149
+ title: pull.title,
150
+ body: pull.body,
151
+ base: pull.baseRef,
152
+ round,
153
+ earlier: earlier.map(describeNote),
154
+ humanOnly: repo.humanOnly,
155
+ notes: repo.notes,
156
+ });
157
+
158
+ const adapter = Record.get(repo.flowAgents, run.flow);
159
+
160
+ const result = yield* (yield* Agent).run({
161
+ cwd: worktree,
162
+ prompt,
163
+ sessionId: sessionIdFor(run.runId, `review-${round}`),
164
+ resumePrompt: Option.none(),
165
+ outputSchema: Option.some(verdictJsonSchema),
166
+ adapter,
167
+ });
168
+
169
+ const verdict = yield* Schema.decodeUnknownEffect(Verdict)(result.output).pipe(
170
+ Effect.mapError((issue) => new StepFailed({ step: `review-${round}`, message: `reviewer answer: ${issue.message}` })),
171
+ );
172
+
173
+ const heading = headingFor(verdict.verdict, Option.getOrElse(adapter, () => repo.agent));
174
+ const marker = reviewMarker.write({ sha: pull.headSha, round, verdict: verdict.verdict });
175
+ yield* github.review(pullRequest, { body: `**${heading}** (review ${round})\n\n${verdict.summary}\n\n${marker}`, commitSha: pull.headSha, comments: verdict.comments });
176
+ // The marker also goes in a plain comment, which the trigger reads back.
177
+ yield* github.comment(pullRequest, `${heading} at \`${pull.headSha.slice(0, 7)}\`. ${marker}`);
178
+ yield* github.removeLabel(pullRequest, repo.labels.needsReview);
179
+
180
+ if (verdict.verdict === 'approve') yield* github.addLabel(pullRequest, repo.labels.approved);
181
+ else yield* github.addLabel(pullRequest, repo.labels.changesRequested);
182
+
183
+ return { verdict: verdict.verdict, sha: pull.headSha };
184
+ }),
185
+ ReviewResult,
186
+ );
187
+
188
+ // Moves a pull request from some labels to another, saying why when there is something to say.
189
+ type Relabel = { readonly remove: ReadonlyArray<string>; readonly add: string; readonly comment: Option.Option<string> };
190
+
191
+ const relabel = (name: string, pullRequest: number, change: Relabel) =>
192
+ activity(
193
+ name,
194
+ Effect.gen(function* () {
195
+ const github = yield* GitHub;
196
+
197
+ for (const label of change.remove) yield* github.removeLabel(pullRequest, label);
198
+
199
+ yield* github.addLabel(pullRequest, change.add);
200
+
201
+ if (Option.isSome(change.comment)) yield* github.comment(pullRequest, change.comment.value);
202
+ }),
203
+ );
204
+
205
+ // Removes the worktrees of a pull request that merged or closed: the branch's own and the review's.
206
+ export const cleanUp = (pullRequest: number, headRef: string) =>
207
+ activity(
208
+ 'clean-up',
209
+ Effect.gen(function* () {
210
+ const worktree = yield* Worktree;
211
+ yield* worktree.remove(`review-pr-${pullRequest}`);
212
+ yield* worktree.remove(headRef);
213
+
214
+ return `#${pullRequest} closed; worktrees removed`;
215
+ }),
216
+ Schema.String,
217
+ );
218
+
219
+ // How long an approved pull request's worktrees wait for it to merge before the run lets go.
220
+ const APPROVED_WAIT = '14 days';
221
+
222
+ const afterApproval = (pullRequest: number, headRef: string) =>
223
+ Effect.gen(function* () {
224
+ const closed = yield* awaitSignal(['closed'], { id: 'approved', timeout: APPROVED_WAIT });
225
+
226
+ if (closed === 'closed') return yield* cleanUp(pullRequest, headRef);
227
+
228
+ return `#${pullRequest} approved; a person merges`;
229
+ });
230
+
231
+ export const workflow = (item: WorkItem) =>
232
+ Effect.gen(function* () {
233
+ const repo = yield* Repo;
234
+ const { headRef, pullRequest } = yield* pullRequestOf(item);
235
+
236
+ for (let round = 1; ; round++) {
237
+ const { verdict } = yield* reviewRound(pullRequest, round);
238
+
239
+ if (verdict === 'approve') return yield* afterApproval(pullRequest, headRef);
240
+
241
+ if (round >= repo.limits.reviewRounds) {
242
+ yield* relabel('needs-human', pullRequest, {
243
+ remove: [repo.labels.changesRequested],
244
+ add: repo.labels.needsHuman,
245
+ comment: Option.some(`Still changes requested after ${round} reviews, so a person takes it from here.`),
246
+ });
247
+
248
+ return yield* new NeedsHuman({ reason: `#${pullRequest}: still changes requested after ${round} reviews` });
249
+ }
250
+
251
+ const woke = yield* awaitSignal(['pushed', 'closed'], { id: `round-${round}`, timeout: repo.limits.staleAfter });
252
+
253
+ if (woke === 'closed') return yield* cleanUp(pullRequest, headRef);
254
+
255
+ if (woke === 'timeout') {
256
+ yield* relabel(`stale-${round}`, pullRequest, {
257
+ remove: [repo.labels.changesRequested],
258
+ add: repo.labels.stale,
259
+ comment: Option.some('No push since the last review, so this is marked stale.'),
260
+ });
261
+
262
+ return `#${pullRequest} went stale waiting for a push`;
263
+ }
264
+
265
+ yield* relabel(`re-review-${round}`, pullRequest, { remove: [repo.labels.changesRequested], add: repo.labels.needsReview, comment: Option.none() });
266
+ }
267
+ });
268
+
269
+ // The fix flow's work items: agent-written pull requests with changes requested
270
+ // and no push since, one per review that asked.
271
+ export const fixTrigger = Effect.gen(function* () {
272
+ const github = yield* GitHub;
273
+ const repo = yield* Repo;
274
+ const items: Array<WorkItem> = [];
275
+
276
+ for (const pull of yield* github.pullRequestsLabelled(repo.labels.changesRequested)) {
277
+ const reviewed = latestMarker(
278
+ reviewMarker.read,
279
+ (yield* github.comments(pull.number)).map((comment) => comment.body),
280
+ );
281
+
282
+ if (isAgentBranch(pull.headRef) && Option.isSome(reviewed) && reviewed.value.sha === pull.headSha) {
283
+ items.push({
284
+ key: `pr-${pull.number}-review-${reviewed.value.round}`,
285
+ title: pull.title,
286
+ url: pull.url,
287
+ data: { pullRequest: pull.number, headRef: pull.headRef, agentWritten: true, round: reviewed.value.round, reviewedSha: reviewed.value.sha },
288
+ });
289
+ }
290
+ }
291
+
292
+ return items;
293
+ });
294
+
295
+ // The author's session, from the marker the implement run left; a fresh one if there is none.
296
+ const authorSession = (pullRequest: number) =>
297
+ activity(
298
+ 'find-author',
299
+ Effect.gen(function* () {
300
+ const github = yield* GitHub;
301
+ const run = yield* Run;
302
+ const repo = yield* Repo;
303
+
304
+ const found = latestMarker(
305
+ sessionMarker.read,
306
+ (yield* github.comments(pullRequest)).map((comment) => comment.body),
307
+ );
308
+
309
+ return Option.getOrElse(found, () => ({ adapter: repo.agent, sessionId: sessionIdFor(run.runId, 'fix'), issue: 0, branch: '' }));
310
+ }),
311
+ SessionMarker,
312
+ );
313
+
314
+ const prepareFix = (headRef: string) =>
315
+ activity(
316
+ 'prepare-worktree',
317
+ Effect.gen(function* () {
318
+ const path = yield* (yield* Worktree).ensure(headRef);
319
+ yield* (yield* Git).pull(path, headRef);
320
+
321
+ return path;
322
+ }),
323
+ Schema.String,
324
+ );
325
+
326
+ const describeReviewLine = (note: ReviewNote) =>
327
+ Option.match(note.location, {
328
+ onNone: () => withoutMarkers(note.body),
329
+ onSome: (location) => `${location.path}:${location.line}: ${withoutMarkers(note.body)}`,
330
+ });
331
+
332
+ const readReview = (fix: FixItem) =>
333
+ activity(
334
+ 'read-review',
335
+ Effect.gen(function* () {
336
+ const notes = (yield* (yield* GitHub).reviewComments(fix.pullRequest)).filter((note) => note.trusted);
337
+
338
+ return yield* renderPrompt('fix', { pullRequest: fix.pullRequest, round: fix.round, review: notes.map(describeReviewLine) });
339
+ }),
340
+ Schema.String,
341
+ );
342
+
343
+ const checkChanged = (fix: FixItem, worktree: string) =>
344
+ activity(
345
+ 'check-changed',
346
+ Effect.gen(function* () {
347
+ if ((yield* (yield* Git).headSha(worktree)) === fix.reviewedSha) {
348
+ return yield* new NeedsHuman({ reason: `#${fix.pullRequest}: the author agent committed nothing in answer to review ${fix.round}` });
349
+ }
350
+ }),
351
+ );
352
+
353
+ const fixPullRequest = (fix: FixItem) =>
354
+ Effect.gen(function* () {
355
+ const repo = yield* Repo;
356
+ const author = yield* authorSession(fix.pullRequest);
357
+ const session = { sessionId: Option.some(author.sessionId), adapter: Option.some(author.adapter) };
358
+ const worktree = yield* prepareFix(fix.headRef);
359
+ const prompt = yield* readReview(fix);
360
+ const outcome = yield* fixWithAgent(`address-review-${fix.round}`, worktree, prompt, session);
361
+
362
+ if (outcome.status === 'needs-clarification') {
363
+ return yield* new NeedsHuman({ reason: `the author agent needs a person to answer review ${fix.round}:\n\n${outcome.questions ?? outcome.summary}` });
364
+ }
365
+
366
+ yield* checkChanged(fix, worktree);
367
+ yield* push(`push-fix-${fix.round}`, worktree, fix.headRef);
368
+ yield* waitForCi(fix.pullRequest, worktree, { ...session, branch: fix.headRef });
369
+ yield* relabel('hand-back', fix.pullRequest, {
370
+ remove: [repo.labels.changesRequested],
371
+ add: repo.labels.needsReview,
372
+ comment: Option.some(`Addressed review ${fix.round}: ${outcome.summary}`),
373
+ });
374
+
375
+ return `#${fix.pullRequest}: addressed review ${fix.round}`;
376
+ });
377
+
378
+ // Resumes the author's session with the review, pushes the fix once CI is
379
+ // green, and hands the pull request back for another review.
380
+ export const fixWorkflow = (item: WorkItem) =>
381
+ Effect.gen(function* () {
382
+ const fix = yield* fixItemOf(item);
383
+
384
+ return yield* fixPullRequest(fix).pipe(
385
+ Effect.catchTag('NeedsHuman', (needsHuman) =>
386
+ Effect.gen(function* () {
387
+ const repo = yield* Repo;
388
+ yield* relabel('needs-human', fix.pullRequest, {
389
+ remove: [repo.labels.changesRequested],
390
+ add: repo.labels.needsHuman,
391
+ comment: Option.some(`Chernabog stopped and needs a person: ${needsHuman.reason}`),
392
+ });
393
+
394
+ return yield* needsHuman;
395
+ }),
396
+ ),
397
+ );
398
+ });
@@ -0,0 +1,273 @@
1
+ // The services a flow, trigger or step can use. Chernabog provides each one;
2
+ // a team can replace any of them with its own layer, and tests can use fakes.
3
+
4
+ import * as Context from 'effect/Context';
5
+ import type * as Effect from 'effect/Effect';
6
+ import * as Option from 'effect/Option';
7
+ import * as Schema from 'effect/Schema';
8
+
9
+ import type { AgentError, GitError, GitHubError, UsageError } from './errors.ts';
10
+ import type { Json } from './json.ts';
11
+
12
+ export const Labels = Schema.Struct({
13
+ eligible: Schema.String,
14
+ inProgress: Schema.String,
15
+ needsReview: Schema.String,
16
+ changesRequested: Schema.String,
17
+ approved: Schema.String,
18
+ needsHuman: Schema.String,
19
+ needsClarification: Schema.String,
20
+ stale: Schema.String,
21
+ });
22
+
23
+ export type Labels = typeof Labels.Type;
24
+
25
+ export const DEFAULT_LABELS: Labels = {
26
+ eligible: 'auto:eligible',
27
+ inProgress: 'auto:in-progress',
28
+ needsReview: 'auto:needs-review',
29
+ changesRequested: 'auto:changes-requested',
30
+ approved: 'auto:approved',
31
+ needsHuman: 'auto:needs-human',
32
+ needsClarification: 'auto:needs-clarification',
33
+ stale: 'auto:stale',
34
+ };
35
+
36
+ export const Board = Schema.Struct({ projectNumber: Schema.Finite, readyStatus: Schema.String, inProgressStatus: Schema.String });
37
+
38
+ export type Board = typeof Board.Type;
39
+
40
+ // configDir none is the agent's default home (~/.claude, ~/.codex).
41
+ export const Account = Schema.Struct({ name: Schema.String, configDir: Schema.Option(Schema.String), expectedEmail: Schema.Option(Schema.String) });
42
+
43
+ export type Account = typeof Account.Type;
44
+
45
+ export const Limits = Schema.Struct({
46
+ // Agent attempts to get the local gate passing before a person takes over.
47
+ localGateRounds: Schema.Finite,
48
+ // Pushes to fix red CI before a person takes over.
49
+ ciRounds: Schema.Finite,
50
+ // Review rounds on one pull request before a person takes over.
51
+ reviewRounds: Schema.Finite,
52
+ // How long a pull request waits for its author's push before it is stale.
53
+ staleAfter: Schema.Duration,
54
+ // How long CI may stay pending before a person looks.
55
+ ciTimeout: Schema.Duration,
56
+ });
57
+
58
+ export type Limits = typeof Limits.Type;
59
+
60
+ const Strings = Schema.Array(Schema.String);
61
+
62
+ // A launcher of your own, from machine config: command templates with
63
+ // {command}, {cwd}, {title} and {handle}.
64
+ export const LauncherCommands = Schema.Struct({ start: Strings, close: Strings, alive: Schema.Option(Strings), attach: Schema.Option(Strings) });
65
+
66
+ export type LauncherCommands = typeof LauncherCommands.Type;
67
+
68
+ // Where interactive agent sessions run (orca, tmux, a custom one, or headless
69
+ // for none), which config layer chose it, and a custom launcher's commands.
70
+ export const ResolvedLauncher = Schema.Struct({ name: Schema.String, from: Schema.String, custom: Schema.Option(LauncherCommands) });
71
+
72
+ export type ResolvedLauncher = typeof ResolvedLauncher.Type;
73
+
74
+ export const HEADLESS: ResolvedLauncher = { name: 'headless', from: 'default', custom: Option.none() };
75
+
76
+ // Everything a run knows about the repo it works on, merged from the repo's
77
+ // committed config, the person's override and machine config.
78
+ export const RepoSettings = Schema.Struct({
79
+ name: Schema.String,
80
+ slug: Schema.String,
81
+ path: Schema.String,
82
+ baseBranch: Schema.String,
83
+ account: Account,
84
+ // The adapter that implements, and the account each adapter runs under.
85
+ agent: Schema.String,
86
+ agentAccounts: Schema.Record(Schema.String, Account),
87
+ launcher: ResolvedLauncher,
88
+ // The adapter a flow asked for in config (flows.<name>.agent), such as a Codex reviewer.
89
+ flowAgents: Schema.Record(Schema.String, Schema.String),
90
+ labels: Labels,
91
+ install: Schema.Option(Schema.String),
92
+ localGate: Strings,
93
+ humanOnly: Strings,
94
+ humanOnlyPaths: Strings,
95
+ notes: Strings,
96
+ board: Schema.Option(Board),
97
+ reviewTrigger: Schema.Option(Schema.String),
98
+ limits: Limits,
99
+ // Extra environment for commands run in the repo, such as PATH with its pinned Node.
100
+ env: Schema.Record(Schema.String, Schema.String),
101
+ // The trusted copy of the repo's .chernabog/, read from the base branch.
102
+ configDir: Schema.String,
103
+ });
104
+
105
+ export type RepoSettings = typeof RepoSettings.Type;
106
+
107
+ export class Repo extends Context.Service<Repo, RepoSettings>()('@chernabog/sdk/Repo') {}
108
+
109
+ // One thing a flow works on: an issue, a pull request. `key` identifies it
110
+ // across ticks, so it never has two runs at once. `signals` are events the
111
+ // trigger saw for it this tick, such as "pushed", which wake a waiting run.
112
+ export const WorkItem = Schema.Struct({
113
+ key: Schema.String,
114
+ title: Schema.optionalKey(Schema.String),
115
+ url: Schema.optionalKey(Schema.String),
116
+ data: Schema.Json,
117
+ signals: Schema.optionalKey(Schema.Array(Schema.String)),
118
+ // Only wakes a run already waiting on it; never starts one.
119
+ wakeOnly: Schema.optionalKey(Schema.Boolean),
120
+ });
121
+
122
+ export type WorkItem = typeof WorkItem.Type;
123
+
124
+ export const RunInfo = Schema.Struct({ flow: Schema.String, runId: Schema.String, executionId: Schema.String, item: WorkItem });
125
+
126
+ export type RunInfo = typeof RunInfo.Type;
127
+
128
+ export class Run extends Context.Service<Run, RunInfo>()('@chernabog/sdk/Run') {}
129
+
130
+ export type Issue = {
131
+ readonly number: number;
132
+ readonly title: string;
133
+ readonly body: string;
134
+ readonly url: string;
135
+ readonly createdAt: string;
136
+ readonly labels: ReadonlyArray<string>;
137
+ };
138
+
139
+ export type Comment = { readonly id: number; readonly author: string; readonly body: string; readonly createdAt: string; readonly trusted: boolean };
140
+
141
+ // A review summary, or a comment on one line of the diff.
142
+ export type ReviewNote = Comment & { readonly location: Option.Option<{ readonly path: string; readonly line: number }> };
143
+
144
+ export type PullRequest = {
145
+ readonly number: number;
146
+ readonly title: string;
147
+ readonly body: string;
148
+ readonly url: string;
149
+ readonly state: 'open' | 'closed' | 'merged';
150
+ readonly headRef: string;
151
+ readonly headSha: string;
152
+ readonly baseRef: string;
153
+ readonly labels: ReadonlyArray<string>;
154
+ readonly author: string;
155
+ };
156
+
157
+ export type ReviewComment = { readonly path: string; readonly line: number; readonly body: string };
158
+
159
+ export type NewPullRequest = { readonly head: string; readonly base: string; readonly title: string; readonly body: string };
160
+
161
+ export type NewReview = { readonly body: string; readonly commitSha: string; readonly comments: ReadonlyArray<ReviewComment> };
162
+
163
+ export type CiState = { state: 'pending'; waitingOn: Array<string> } | { state: 'green' } | { state: 'red'; failedChecks: Array<string> };
164
+
165
+ export class GitHub extends Context.Service<
166
+ GitHub,
167
+ {
168
+ // Open issues labelled eligible (and ready on the board, when the repo has one), oldest first.
169
+ readonly queuedIssues: Effect.Effect<ReadonlyArray<Issue>, GitHubError>;
170
+ readonly oldestQueued: Effect.Effect<Option.Option<number>, GitHubError>;
171
+ issue(number: number): Effect.Effect<Issue, GitHubError>;
172
+ // Comments by the repo's owners, members and collaborators only; anyone can comment on a public repo.
173
+ trustedComments(number: number): Effect.Effect<ReadonlyArray<Comment>, GitHubError>;
174
+ comments(number: number): Effect.Effect<ReadonlyArray<Comment>, GitHubError>;
175
+ comment(number: number, body: string): Effect.Effect<void, GitHubError>;
176
+ addLabel(number: number, label: string): Effect.Effect<void, GitHubError>;
177
+ removeLabel(number: number, label: string): Effect.Effect<void, GitHubError>;
178
+ // Moves an issue to a Status on the repo's project board. Does nothing without a board.
179
+ moveToStatus(number: number, status: string): Effect.Effect<void, GitHubError>;
180
+ assignSelf(number: number): Effect.Effect<void, GitHubError>;
181
+ readonly login: Effect.Effect<string, GitHubError>;
182
+ branchSha(branch: string): Effect.Effect<Option.Option<string>, GitHubError>;
183
+ // Whoever creates a branch first owns it: the claim.
184
+ createBranch(branch: string, sha: string): Effect.Effect<'created' | 'already-exists', GitHubError>;
185
+ deleteBranch(branch: string): Effect.Effect<void, GitHubError>;
186
+ pullRequest(number: number): Effect.Effect<PullRequest, GitHubError>;
187
+ pullRequestForBranch(branch: string): Effect.Effect<Option.Option<PullRequest>, GitHubError>;
188
+ pullRequestsLabelled(label: string): Effect.Effect<ReadonlyArray<PullRequest>, GitHubError>;
189
+ openPullRequest(pullRequest: NewPullRequest): Effect.Effect<PullRequest, GitHubError>;
190
+ ciState(pullRequest: number): Effect.Effect<CiState, GitHubError>;
191
+ // Changed paths of a pull request, for path rules such as humanOnlyPaths.
192
+ pullRequestFiles(pullRequest: number): Effect.Effect<ReadonlyArray<string>, GitHubError>;
193
+ // Recently closed or merged pull requests carrying the label, newest first.
194
+ closedPullRequestsLabelled(label: string): Effect.Effect<ReadonlyArray<PullRequest>, GitHubError>;
195
+ // Posts a review as a comment (an account cannot approve its own pull request; labels carry the verdict).
196
+ review(pullRequest: number, review: NewReview): Effect.Effect<void, GitHubError>;
197
+ // Review summaries and line comments on a pull request.
198
+ reviewComments(pullRequest: number): Effect.Effect<ReadonlyArray<ReviewNote>, GitHubError>;
199
+ // Any other call, for what the service does not cover.
200
+ request(method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', path: string, body: Option.Option<Json>): Effect.Effect<Json, GitHubError>;
201
+ }
202
+ >()('@chernabog/sdk/GitHub') {}
203
+
204
+ export class Git extends Context.Service<
205
+ Git,
206
+ {
207
+ run(args: ReadonlyArray<string>, cwd: string): Effect.Effect<string, GitError>;
208
+ headSha(cwd: string): Effect.Effect<string, GitError>;
209
+ // Commits on HEAD that origin/<base> does not have.
210
+ commitsAhead(cwd: string, base: string): Effect.Effect<number, GitError>;
211
+ changedFiles(cwd: string, base: string): Effect.Effect<ReadonlyArray<string>, GitError>;
212
+ push(cwd: string, branch: string): Effect.Effect<void, GitError>;
213
+ // Brings the checked-out branch up to its remote, fast-forward only.
214
+ pull(cwd: string, branch: string): Effect.Effect<void, GitError>;
215
+ }
216
+ >()('@chernabog/sdk/Git') {}
217
+
218
+ export class Worktree extends Context.Service<
219
+ Worktree,
220
+ {
221
+ // The worktree for a branch, created from origin/<base> the first time and reused after.
222
+ ensure(branch: string): Effect.Effect<string, GitError>;
223
+ pathFor(branch: string): string;
224
+ remove(branch: string): Effect.Effect<void, GitError>;
225
+ // A detached worktree named `name`, reset to `ref` (fetched first) every call: for reading, such as a review.
226
+ checkout(name: string, ref: string): Effect.Effect<string, GitError>;
227
+ }
228
+ >()('@chernabog/sdk/Worktree') {}
229
+
230
+ export type LaunchDecision = { readonly launch: boolean; readonly reason: string };
231
+
232
+ export class Usage extends Context.Service<
233
+ Usage,
234
+ {
235
+ // Whether the repo's account has room for one more task.
236
+ readonly gate: Effect.Effect<LaunchDecision, UsageError>;
237
+ // The account's logged-in identity; fails when it is not the expected one.
238
+ readonly checkAccount: Effect.Effect<string, UsageError>;
239
+ }
240
+ >()('@chernabog/sdk/Usage') {}
241
+
242
+ export type AgentResult = { readonly sessionId: string; readonly output: Json; readonly costUsd: Option.Option<number> };
243
+
244
+ export type AgentRequest = {
245
+ readonly cwd: string;
246
+ readonly prompt: string;
247
+ // A stable id per piece of work, so a rerun after a crash resumes the same session.
248
+ readonly sessionId: string;
249
+ // Sent instead of `prompt` when the session already exists.
250
+ readonly resumePrompt: Option.Option<string>;
251
+ // JSON Schema the agent's final answer must match.
252
+ readonly outputSchema: Option.Option<Json>;
253
+ // Another adapter than the repo's own, such as a Codex reviewer.
254
+ readonly adapter: Option.Option<string>;
255
+ };
256
+
257
+ export class Agent extends Context.Service<
258
+ Agent,
259
+ {
260
+ run(request: AgentRequest): Effect.Effect<AgentResult, AgentError>;
261
+ readonly adapters: ReadonlyArray<string>;
262
+ }
263
+ >()('@chernabog/sdk/Agent') {}
264
+
265
+ // Writes to the run's log file, which `chernabog logs <run>` prints.
266
+ export class RunLog extends Context.Service<
267
+ RunLog,
268
+ {
269
+ log(line: string): Effect.Effect<void>;
270
+ step(name: string): Effect.Effect<void>;
271
+ stepDone(name: string): Effect.Effect<void>;
272
+ }
273
+ >()('@chernabog/sdk/RunLog') {}