@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.
@@ -0,0 +1,501 @@
1
+ // The building blocks of the default `implement` flow: find a queued issue,
2
+ // claim it, prepare a worktree, let the agent implement it, run the local gate,
3
+ // open the pull request, wait for CI and hand off for review. Each block is an
4
+ // activity, so a flow can reorder them, leave one out, or put its own step
5
+ // from .chernabog/stages/ in between.
6
+
7
+ import { createHash } from 'node:crypto';
8
+
9
+ import * as DateTime from 'effect/DateTime';
10
+ import * as Duration from 'effect/Duration';
11
+ import * as Effect from 'effect/Effect';
12
+ import * as Option from 'effect/Option';
13
+ import * as ChildProcess from 'effect/process/ChildProcess';
14
+ import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
15
+ import * as Schema from 'effect/Schema';
16
+ import * as Stream from 'effect/Stream';
17
+
18
+ import { NeedsHuman, Skip, StepFailed } from './errors.ts';
19
+ import { activity, sleep } from './flow.ts';
20
+ import type { Json } from './json.ts';
21
+ import { type ClaimMarker, claimMarker, latestMarker, sessionMarker } from './markers.ts';
22
+ import { pathsMatching } from './paths.ts';
23
+ import { renderPrompt } from './prompt.ts';
24
+ import { Agent, type AgentResult, Git, GitHub, type Issue, Repo, Run, RunLog, Usage, type WorkItem, Worktree } from './services.ts';
25
+
26
+ export const branchFor = (issue: number) => `auto/issue-${issue}`;
27
+
28
+ export const IssueItem = Schema.Struct({ issue: Schema.Finite });
29
+
30
+ export const issueItem = (issue: Issue): WorkItem => ({
31
+ key: `issue-${issue.number}`,
32
+ title: issue.title,
33
+ url: issue.url,
34
+ data: { issue: issue.number },
35
+ });
36
+
37
+ // The issue number a work item from `trigger` carries.
38
+ export const issueOf = (item: WorkItem) =>
39
+ Schema.decodeUnknownEffect(IssueItem)(item.data).pipe(
40
+ Effect.map((data) => data.issue),
41
+ Effect.mapError((issue) => new StepFailed({ step: 'work item', message: `${item.key} is not an issue: ${issue.message}` })),
42
+ );
43
+
44
+ // A stable session id per run and purpose, so a rerun after a crash resumes
45
+ // the agent's session instead of starting over.
46
+ export const sessionIdFor = (runId: string, purpose: string) => {
47
+ const hex = createHash('sha256').update(`${runId}/${purpose}`).digest('hex');
48
+ const variant = ((Number.parseInt(hex[16], 16) & 0x3) | 0x8).toString(16);
49
+
50
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
51
+ };
52
+
53
+ // Issues labelled eligible, oldest first.
54
+ export const trigger = GitHub.use((github) => github.queuedIssues).pipe(Effect.map((issues) => issues.map(issueItem)));
55
+
56
+ // The account is the one machine config expects, and it has room for a task.
57
+ export const gate = Effect.gen(function* () {
58
+ const usage = yield* Usage;
59
+ yield* usage.checkAccount;
60
+ const decision = yield* usage.gate;
61
+
62
+ if (!decision.launch) return yield* new Skip({ reason: `usage gate: ${decision.reason}` });
63
+ });
64
+
65
+ // Issues are often public, so the comment says nothing about the machine: no
66
+ // hostname, and no worktree path with the user's home in it.
67
+ export const claimComment = (claim: ClaimMarker) =>
68
+ [
69
+ `Claimed by Chernabog at ${claim.claimedAt}, on branch \`${claim.branch}\`.`,
70
+ '',
71
+ `- Run: \`${claim.runId}\``,
72
+ '',
73
+ claimMarker.write(claim),
74
+ ].join('\n');
75
+
76
+ // Whether the claim on an existing branch is this run's. No marker at all means
77
+ // this run crashed between creating the branch and commenting, while the issue
78
+ // still says it is queued.
79
+ const holdsClaim = (issue: number, runId: string) =>
80
+ Effect.gen(function* () {
81
+ const github = yield* GitHub;
82
+ const repo = yield* Repo;
83
+
84
+ const latest = latestMarker(
85
+ claimMarker.read,
86
+ (yield* github.comments(issue)).map((comment) => comment.body),
87
+ );
88
+
89
+ if (Option.isSome(latest)) return { ours: latest.value.runId === runId, commented: true };
90
+
91
+ return { ours: (yield* github.issue(issue)).labels.includes(repo.labels.eligible), commented: false };
92
+ });
93
+
94
+ // Creates the issue's branch, which only one run can do, then labels, assigns
95
+ // and comments as a person picking it up would. Safe to repeat: a run that
96
+ // already holds the claim keeps it.
97
+ export const claim = (issue: number) =>
98
+ activity(
99
+ 'claim',
100
+ Effect.gen(function* () {
101
+ const github = yield* GitHub;
102
+ const repo = yield* Repo;
103
+ const run = yield* Run;
104
+ const branch = branchFor(issue);
105
+ const baseSha = yield* github.branchSha(repo.baseBranch);
106
+
107
+ if (Option.isNone(baseSha)) return yield* new StepFailed({ step: 'claim', message: `base branch ${repo.baseBranch} not found` });
108
+
109
+ if ((yield* github.createBranch(branch, baseSha.value)) === 'already-exists') {
110
+ const held = yield* holdsClaim(issue, run.runId);
111
+
112
+ if (!held.ours) return yield* new Skip({ reason: `#${issue} is already claimed (${branch} exists)` });
113
+
114
+ if (held.commented) return { branch };
115
+ }
116
+
117
+ yield* github.addLabel(issue, repo.labels.inProgress);
118
+ yield* github.removeLabel(issue, repo.labels.eligible);
119
+
120
+ if (Option.isSome(repo.board)) yield* github.moveToStatus(issue, repo.board.value.inProgressStatus);
121
+
122
+ yield* github.assignSelf(issue);
123
+ const claimedAt = DateTime.formatIso(yield* DateTime.now);
124
+ yield* github.comment(issue, claimComment({ runId: run.runId, claimedAt, branch }));
125
+
126
+ return { branch };
127
+ }),
128
+ Schema.Struct({ branch: Schema.String }),
129
+ );
130
+
131
+ // Runs a repo command, such as the install or a local gate check, and keeps
132
+ // what it printed, stdout and stderr interleaved.
133
+ export const shell = (command: string, cwd: string, env: Readonly<Record<string, string>>) =>
134
+ Effect.gen(function* () {
135
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
136
+ const handle = yield* spawner.spawn(ChildProcess.make('sh', ['-c', command], { cwd, env: { ...env, CI: '1' }, extendEnv: true, forceKillAfter: '5 seconds' }));
137
+ const [output, exitCode] = yield* Effect.all([Stream.mkString(Stream.decodeText(handle.all)), handle.exitCode], { concurrency: 'unbounded' });
138
+
139
+ return { exitCode: Number(exitCode), output };
140
+ }).pipe(
141
+ Effect.scoped,
142
+ Effect.mapError((error) => new StepFailed({ step: command, message: error.message })),
143
+ );
144
+
145
+ const tail = (text: string, lines = 80) => text.trimEnd().split('\n').slice(-lines).join('\n');
146
+
147
+ // The worktree for the claim branch, with dependencies installed.
148
+ export const prepareWorktree = (branch: string) =>
149
+ activity(
150
+ 'prepare-worktree',
151
+ Effect.gen(function* () {
152
+ const repo = yield* Repo;
153
+ const path = yield* (yield* Worktree).ensure(branch);
154
+
155
+ if (Option.isSome(repo.install)) {
156
+ const installed = yield* shell(repo.install.value, path, repo.env);
157
+
158
+ if (installed.exitCode !== 0) return yield* new StepFailed({ step: 'install', message: tail(installed.output, 30) });
159
+ }
160
+
161
+ return path;
162
+ }),
163
+ Schema.String,
164
+ );
165
+
166
+ export const ImplementOutcome = Schema.Struct({
167
+ status: Schema.Literals(['done', 'needs-clarification']),
168
+ summary: Schema.String,
169
+ title: Schema.String,
170
+ body: Schema.String,
171
+ questions: Schema.optionalKey(Schema.String),
172
+ });
173
+
174
+ export type ImplementOutcome = typeof ImplementOutcome.Type;
175
+
176
+ export const implementOutcomeJsonSchema = {
177
+ type: 'object',
178
+ properties: {
179
+ status: { type: 'string', enum: ['done', 'needs-clarification'] },
180
+ summary: { type: 'string', description: 'What changed and how it was tested, for the issue comment' },
181
+ title: { type: 'string', description: 'The pull request title, following the repo commit convention' },
182
+ body: { type: 'string', description: 'The pull request body in Markdown, without the Closes line' },
183
+ questions: { type: 'string', description: 'For needs-clarification: numbered questions, each led by a recommendation' },
184
+ },
185
+ required: ['status', 'summary', 'title', 'body'],
186
+ additionalProperties: false,
187
+ } satisfies Json;
188
+
189
+ const RESUME_PROMPT = 'You were interrupted. Continue the task from where you left off, then give your final answer in the required format.';
190
+
191
+ const decodeOutcome = (result: AgentResult) =>
192
+ Schema.decodeUnknownEffect(ImplementOutcome)(result.output).pipe(Effect.mapError((issue) => new StepFailed({ step: 'agent', message: `agent answer: ${issue.message}` })));
193
+
194
+ // The agent implements the issue in the worktree and commits, from
195
+ // .chernabog/prompts/implement.md. It answers done, with the pull request's
196
+ // title and body, or needs-clarification, with its questions.
197
+ export const implementWithAgent = (issue: number, worktree: string) =>
198
+ activity(
199
+ 'implement',
200
+ Effect.gen(function* () {
201
+ const github = yield* GitHub;
202
+ const repo = yield* Repo;
203
+ const run = yield* Run;
204
+ const details = yield* github.issue(issue);
205
+ const comments = yield* github.trustedComments(issue);
206
+
207
+ const prompt = yield* renderPrompt('implement', {
208
+ repo: repo.slug,
209
+ issue,
210
+ title: details.title,
211
+ body: details.body,
212
+ comments: comments.map((comment) => `${comment.author}: ${comment.body}`),
213
+ branch: branchFor(issue),
214
+ base: repo.baseBranch,
215
+ localGate: repo.localGate.map((command) => `\`${command}\``),
216
+ humanOnly: repo.humanOnly,
217
+ notes: repo.notes,
218
+ });
219
+
220
+ const result = yield* (yield* Agent).run({
221
+ cwd: worktree,
222
+ prompt,
223
+ sessionId: sessionIdFor(run.runId, 'implement'),
224
+ resumePrompt: Option.some(RESUME_PROMPT),
225
+ outputSchema: Option.some(implementOutcomeJsonSchema),
226
+ adapter: Option.none(),
227
+ });
228
+
229
+ return yield* decodeOutcome(result);
230
+ }),
231
+ ImplementOutcome,
232
+ );
233
+
234
+ // Which session a fix goes to: this run's implement session when none is given.
235
+ export type AuthorSession = { readonly sessionId: Option.Option<string>; readonly adapter: Option.Option<string> };
236
+
237
+ export const thisRunsSession: AuthorSession = { sessionId: Option.none(), adapter: Option.none() };
238
+
239
+ // Sends the agent back into its session with a failure to fix.
240
+ export const fixWithAgent = (name: string, worktree: string, prompt: string, session: AuthorSession) =>
241
+ activity(
242
+ name,
243
+ Effect.gen(function* () {
244
+ const run = yield* Run;
245
+
246
+ const result = yield* (yield* Agent).run({
247
+ cwd: worktree,
248
+ prompt,
249
+ sessionId: Option.getOrElse(session.sessionId, () => sessionIdFor(run.runId, 'implement')),
250
+ resumePrompt: Option.some(prompt),
251
+ outputSchema: Option.some(implementOutcomeJsonSchema),
252
+ adapter: session.adapter,
253
+ });
254
+
255
+ return yield* decodeOutcome(result);
256
+ }),
257
+ ImplementOutcome,
258
+ );
259
+
260
+ const requeueHint = (board: Option.Option<{ readonly readyStatus: string }>) =>
261
+ Option.match(board, { onNone: () => 'apply', onSome: (found) => `set Status back to ${found.readyStatus} and apply` });
262
+
263
+ // Posts the agent's questions, releases the claim and stops, as a person would
264
+ // who needs an answer before going on.
265
+ export const askForClarification = (issue: number, outcome: ImplementOutcome) =>
266
+ activity(
267
+ 'needs-clarification',
268
+ Effect.gen(function* () {
269
+ const github = yield* GitHub;
270
+ const repo = yield* Repo;
271
+ const questions = outcome.questions ?? outcome.summary;
272
+ yield* github.comment(issue, [questions, '', `To requeue: answer the questions above, then ${requeueHint(repo.board)} \`${repo.labels.eligible}\` again.`].join('\n'));
273
+ yield* github.addLabel(issue, repo.labels.needsClarification);
274
+ yield* github.removeLabel(issue, repo.labels.inProgress);
275
+ yield* github.deleteBranch(branchFor(issue));
276
+ yield* (yield* Worktree).remove(branchFor(issue));
277
+
278
+ return `#${issue} needs clarification`;
279
+ }),
280
+ Schema.String,
281
+ );
282
+
283
+ const LocalGateResult = Schema.Struct({ ok: Schema.Boolean, failure: Schema.String });
284
+
285
+ const runLocalGate = (worktree: string, round: number) =>
286
+ activity(
287
+ `local-gate-${round}`,
288
+ Effect.gen(function* () {
289
+ const repo = yield* Repo;
290
+ const log = yield* RunLog;
291
+
292
+ for (const command of repo.localGate) {
293
+ yield* log.log(`local gate: ${command}`);
294
+ const result = yield* shell(command, worktree, repo.env);
295
+
296
+ if (result.exitCode !== 0) return { ok: false, failure: `\`${command}\` exited ${result.exitCode}:\n\n${tail(result.output)}` };
297
+ }
298
+
299
+ return { ok: true, failure: '' };
300
+ }),
301
+ LocalGateResult,
302
+ );
303
+
304
+ // Runs the repo's local gate, and sends the agent back to fix what fails, up to
305
+ // limits.localGateRounds times.
306
+ export const localGate = (worktree: string) =>
307
+ Effect.gen(function* () {
308
+ const repo = yield* Repo;
309
+
310
+ for (let round = 1; ; round++) {
311
+ const result = yield* runLocalGate(worktree, round);
312
+
313
+ if (result.ok) return;
314
+
315
+ if (round > repo.limits.localGateRounds) {
316
+ return yield* new NeedsHuman({ reason: `the local gate still fails after ${repo.limits.localGateRounds} fix attempts:\n\n${result.failure}` });
317
+ }
318
+
319
+ yield* fixWithAgent(`fix-local-gate-${round}`, worktree, `The local gate failed. Fix the cause, commit, and answer again.\n\n${result.failure}`, thisRunsSession);
320
+ }
321
+ });
322
+
323
+ // Checks the branch has commits, touches no human-only paths, and pushes it.
324
+ export const push = (name: string, worktree: string, branch: string) =>
325
+ activity(
326
+ name,
327
+ Effect.gen(function* () {
328
+ const git = yield* Git;
329
+ const repo = yield* Repo;
330
+
331
+ if ((yield* git.commitsAhead(worktree, repo.baseBranch)) === 0) {
332
+ return yield* new NeedsHuman({ reason: `the agent finished without committing anything on ${branch}` });
333
+ }
334
+
335
+ const touched = pathsMatching(yield* git.changedFiles(worktree, repo.baseBranch), repo.humanOnlyPaths);
336
+
337
+ if (touched.length > 0) {
338
+ return yield* new NeedsHuman({ reason: `the change touches paths a person must handle: ${touched.map((path) => `\`${path}\``).join(', ')}` });
339
+ }
340
+
341
+ yield* git.push(worktree, branch);
342
+ }),
343
+ );
344
+
345
+ export const PullRequestRef = Schema.Struct({ number: Schema.Finite, url: Schema.String });
346
+
347
+ export type PullRequestRef = typeof PullRequestRef.Type;
348
+
349
+ // Opens the pull request for the branch, or finds the one already open.
350
+ export const openPullRequest = (issue: number, outcome: ImplementOutcome) =>
351
+ activity(
352
+ 'open-pull-request',
353
+ Effect.gen(function* () {
354
+ const github = yield* GitHub;
355
+ const repo = yield* Repo;
356
+ const branch = branchFor(issue);
357
+ const existing = Option.filter(yield* github.pullRequestForBranch(branch), (pull) => pull.state === 'open');
358
+
359
+ if (Option.isSome(existing)) return { number: existing.value.number, url: existing.value.url };
360
+
361
+ const opened = yield* github.openPullRequest({
362
+ head: branch,
363
+ base: repo.baseBranch,
364
+ title: outcome.title,
365
+ body: `Closes #${issue}\n\n${outcome.body}\n\n---\nOpened by Chernabog for an unattended run. A person reviews and merges.`,
366
+ });
367
+
368
+ return { number: opened.number, url: opened.url };
369
+ }),
370
+ PullRequestRef,
371
+ );
372
+
373
+ const CI_POLL = Duration.minutes(1);
374
+
375
+ const CiReading = Schema.Union([
376
+ Schema.Struct({ state: Schema.Literal('pending'), waitingOn: Schema.Array(Schema.String) }),
377
+ Schema.Struct({ state: Schema.Literal('green') }),
378
+ Schema.Struct({ state: Schema.Literal('red'), failedChecks: Schema.Array(Schema.String) }),
379
+ ]);
380
+
381
+ type CiReading = typeof CiReading.Type;
382
+
383
+ // Polls the pull request's checks durably until they stop pending, or a person is needed.
384
+ const awaitChecks = (pullRequest: number, round: number) =>
385
+ Effect.gen(function* () {
386
+ const repo = yield* Repo;
387
+ const maxPolls = Math.max(1, Math.ceil(Duration.toMillis(repo.limits.ciTimeout) / Duration.toMillis(CI_POLL)));
388
+ let reading: CiReading = { state: 'pending', waitingOn: [] };
389
+
390
+ for (let poll = 1; reading.state === 'pending'; poll++) {
391
+ if (poll > maxPolls) {
392
+ return yield* new NeedsHuman({ reason: `CI on #${pullRequest} is still pending after ${Duration.format(Duration.fromInputUnsafe(repo.limits.ciTimeout))}` });
393
+ }
394
+
395
+ if (poll > 1) yield* sleep(`ci-${round}-wait-${poll}`, CI_POLL);
396
+ reading = yield* activity(`ci-${round}-${poll}`, GitHub.use((github) => github.ciState(pullRequest)), CiReading);
397
+ }
398
+
399
+ return reading;
400
+ });
401
+
402
+ export type CiBranch = AuthorSession & { readonly branch: string };
403
+
404
+ // Waits durably for the pull request's checks. On red, the agent reads the
405
+ // failure, fixes, commits, and the branch is pushed again, up to
406
+ // limits.ciRounds times.
407
+ export const waitForCi = (pullRequest: number, worktree: string, target: CiBranch) =>
408
+ Effect.gen(function* () {
409
+ const repo = yield* Repo;
410
+
411
+ for (let round = 1; ; round++) {
412
+ const reading = yield* awaitChecks(pullRequest, round);
413
+
414
+ if (reading.state !== 'red') return;
415
+
416
+ const failed = reading.failedChecks.join(', ');
417
+
418
+ if (round > repo.limits.ciRounds) return yield* new NeedsHuman({ reason: `CI is still red after ${repo.limits.ciRounds} fix attempts: ${failed}` });
419
+
420
+ yield* fixWithAgent(
421
+ `fix-ci-${round}`,
422
+ worktree,
423
+ [
424
+ `CI failed on pull request #${pullRequest}: ${failed}.`,
425
+ `Read why with \`gh run list --branch ${target.branch} --limit 5\` and \`gh run view <run id> --log-failed\`.`,
426
+ 'Fix the cause and commit. A failure unrelated to your change may be re-run once with `gh run rerun <run id> --failed`. Then answer again.',
427
+ ].join('\n'),
428
+ target,
429
+ );
430
+ yield* push(`push-ci-${round}`, worktree, target.branch);
431
+ }
432
+ });
433
+
434
+ // Hands the green pull request to review: the review trigger if the repo has
435
+ // one, the needs-review label, and a closing comment on the issue.
436
+ export const handOff = (issue: number, pullRequest: PullRequestRef, outcome: ImplementOutcome) =>
437
+ activity(
438
+ 'hand-off',
439
+ Effect.gen(function* () {
440
+ const github = yield* GitHub;
441
+ const repo = yield* Repo;
442
+ const run = yield* Run;
443
+
444
+ if (Option.isSome(repo.reviewTrigger)) yield* github.comment(pullRequest.number, repo.reviewTrigger.value);
445
+
446
+ // The fix flow resumes this session to answer a review.
447
+ const session = { adapter: repo.agent, sessionId: sessionIdFor(run.runId, 'implement'), issue, branch: branchFor(issue) };
448
+ yield* github.comment(pullRequest.number, `Implemented by ${repo.agent} for #${issue}. ${sessionMarker.write(session)}`);
449
+ yield* github.addLabel(pullRequest.number, repo.labels.needsReview);
450
+ yield* github.removeLabel(issue, repo.labels.inProgress);
451
+ yield* github.comment(issue, `Pull request ready for review: ${pullRequest.url}\n\n${outcome.summary}`);
452
+
453
+ return `#${issue}: ${pullRequest.url} is ready for review`;
454
+ }),
455
+ Schema.String,
456
+ );
457
+
458
+ // Labels the work item for a person and says why. No flow picks it up again.
459
+ export const handToHuman = (issue: number, reason: string, pullRequest: Option.Option<number>) =>
460
+ activity(
461
+ 'needs-human',
462
+ Effect.gen(function* () {
463
+ const github = yield* GitHub;
464
+ const repo = yield* Repo;
465
+ yield* github.addLabel(issue, repo.labels.needsHuman);
466
+ yield* github.removeLabel(issue, repo.labels.inProgress);
467
+
468
+ if (Option.isSome(pullRequest)) yield* github.addLabel(pullRequest.value, repo.labels.needsHuman);
469
+
470
+ yield* github.comment(issue, `Chernabog stopped and needs a person: ${reason}\n\nThe branch \`${branchFor(issue)}\` and its worktree are left as they are.`);
471
+
472
+ return `#${issue} needs a person: ${reason}`;
473
+ }),
474
+ Schema.String,
475
+ );
476
+
477
+ // Ends the run as waiting on a person, once the issue says why.
478
+ export const endForPerson = (summary: string) => Effect.fail(new NeedsHuman({ reason: summary }));
479
+
480
+ // The whole default flow, for a flow file that wants it as it is.
481
+ export const workflow = (item: WorkItem) =>
482
+ Effect.gen(function* () {
483
+ const issue = yield* issueOf(item);
484
+ const { branch } = yield* claim(issue);
485
+ const worktree = yield* prepareWorktree(branch);
486
+ const outcome = yield* implementWithAgent(issue, worktree);
487
+
488
+ if (outcome.status === 'needs-clarification') return yield* askForClarification(issue, outcome).pipe(Effect.flatMap(endForPerson));
489
+
490
+ let pullRequest = Option.none<number>();
491
+
492
+ return yield* Effect.gen(function* () {
493
+ yield* localGate(worktree);
494
+ yield* push('push', worktree, branch);
495
+ const opened = yield* openPullRequest(issue, outcome);
496
+ pullRequest = Option.some(opened.number);
497
+ yield* waitForCi(opened.number, worktree, { ...thisRunsSession, branch });
498
+
499
+ return yield* handOff(issue, opened, outcome);
500
+ }).pipe(Effect.catchTag('NeedsHuman', (needsHuman) => handToHuman(issue, needsHuman.reason, pullRequest).pipe(Effect.flatMap(endForPerson))));
501
+ });
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from './errors.ts';
2
+ export * from './flow.ts';
3
+ export * as Implement from './implement.ts';
4
+ export * from './json.ts';
5
+ export * from './markers.ts';
6
+ export * from './paths.ts';
7
+ export * from './prompt.ts';
8
+ export * as Review from './review.ts';
9
+ export * from './services.ts';
10
+ export * from './stage.ts';
package/src/json.ts ADDED
@@ -0,0 +1,18 @@
1
+ // JSON at the edges: every parse and print goes through a Schema codec, so a
2
+ // value is checked the moment it crosses the boundary.
3
+
4
+ import * as Option from 'effect/Option';
5
+ import * as Schema from 'effect/Schema';
6
+
7
+ export type Json = Schema.Json;
8
+
9
+ const JsonText = Schema.fromJsonString(Schema.Json);
10
+
11
+ // Text another program wrote, read as JSON; none when it isn't JSON.
12
+ export const parseJson = (text: string): Option.Option<Json> => Schema.decodeOption(JsonText)(text);
13
+
14
+ export const printJson = (value: Json): string => Schema.encodeSync(JsonText)(value);
15
+
16
+ // Each line of a JSON Lines file that the schema accepts; the others are skipped.
17
+ export const decodeJsonLines = <A>(schema: Schema.Codec<A, string>, text: string): ReadonlyArray<A> =>
18
+ text.split('\n').flatMap((line) => Option.toArray(Schema.decodeOption(schema)(line)));
package/src/markers.ts ADDED
@@ -0,0 +1,39 @@
1
+ // Hidden markers in pull request comments, so a later run can find what an
2
+ // earlier one left: who wrote the pull request, and what each review saw.
3
+
4
+ import * as Arr from 'effect/Array';
5
+ import * as Option from 'effect/Option';
6
+ import * as Schema from 'effect/Schema';
7
+
8
+ import type { Json } from './json.ts';
9
+
10
+ export const SessionMarker = Schema.Struct({ adapter: Schema.String, sessionId: Schema.String, issue: Schema.Finite, branch: Schema.String });
11
+
12
+ export type SessionMarker = typeof SessionMarker.Type;
13
+
14
+ export const ReviewMarker = Schema.Struct({ sha: Schema.String, round: Schema.Finite, verdict: Schema.Literals(['approve', 'request-changes']) });
15
+
16
+ export type ReviewMarker = typeof ReviewMarker.Type;
17
+
18
+ const marker = <A>(name: string, schema: Schema.Codec<A, Json>) => {
19
+ const pattern = new RegExp(`<!-- chernabog-${name} (\\{.*?\\}) -->`);
20
+ const codec = Schema.fromJsonString(schema);
21
+
22
+ return {
23
+ write: (value: A) => `<!-- chernabog-${name} ${Schema.encodeSync(codec)(value)} -->`,
24
+ read: (body: string): Option.Option<A> =>
25
+ Option.fromNullishOr(body.match(pattern)).pipe(Option.flatMap((match) => Schema.decodeOption(codec)(match[1]))),
26
+ };
27
+ };
28
+
29
+ export const ClaimMarker = Schema.Struct({ runId: Schema.String, claimedAt: Schema.String, branch: Schema.String });
30
+
31
+ export type ClaimMarker = typeof ClaimMarker.Type;
32
+
33
+ export const claimMarker = marker('claim', ClaimMarker);
34
+ export const sessionMarker = marker('session', SessionMarker);
35
+ export const reviewMarker = marker('review', ReviewMarker);
36
+
37
+ // The newest marker among comment bodies, oldest first.
38
+ export const latestMarker = <A>(read: (body: string) => Option.Option<A>, bodies: ReadonlyArray<string>): Option.Option<A> =>
39
+ Arr.last(bodies.flatMap((body) => Option.toArray(read(body))));
package/src/paths.ts ADDED
@@ -0,0 +1,38 @@
1
+ // Path rules such as humanOnlyPaths: `.github/**`, `**/package.json`, `RELEASE.md`.
2
+
3
+ const escape = (text: string) => text.replace(/[.+^${}()|[\]\\]/g, '\\$&');
4
+
5
+ const doubleStar = (glob: string, index: number) => {
6
+ if (glob[index + 2] === '/') return { pattern: '(?:.*/)?', skip: 2 };
7
+
8
+ return { pattern: '.*', skip: 1 };
9
+ };
10
+
11
+ export const globToRegExp = (glob: string) => {
12
+ let pattern = '';
13
+
14
+ for (let index = 0; index < glob.length; index++) {
15
+ const char = glob[index];
16
+
17
+ if (char === '*' && glob[index + 1] === '*') {
18
+ const star = doubleStar(glob, index);
19
+ pattern += star.pattern;
20
+ index += star.skip;
21
+ } else if (char === '*') pattern += '[^/]*';
22
+ else if (char === '?') pattern += '[^/]';
23
+ else pattern += escape(char);
24
+ }
25
+
26
+ // A directory rule covers everything under it.
27
+ let rest = '(?:/.*)?';
28
+
29
+ if (glob.endsWith('/')) rest = '.*';
30
+
31
+ return new RegExp(`^${pattern}${rest}$`);
32
+ };
33
+
34
+ export const pathsMatching = (paths: ReadonlyArray<string>, globs: ReadonlyArray<string>) => {
35
+ const rules = globs.map(globToRegExp);
36
+
37
+ return paths.filter((path) => rules.some((rule) => rule.test(path)));
38
+ };
package/src/prompt.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Prompts are agent-neutral Markdown in .chernabog/prompts/, filled in with
2
+ // {{name}} placeholders. A list renders as one `- ` line per item.
3
+
4
+ import * as Effect from 'effect/Effect';
5
+ import * as FileSystem from 'effect/FileSystem';
6
+ import * as Option from 'effect/Option';
7
+ import * as Predicate from 'effect/Predicate';
8
+
9
+ import { StepFailed } from './errors.ts';
10
+ import { Repo } from './services.ts';
11
+
12
+ export type PromptValue = string | number | ReadonlyArray<string> | Option.Option<string>;
13
+
14
+ const render = (value: PromptValue): string => {
15
+ if (Option.isOption(value)) return Option.getOrElse(value, () => '');
16
+
17
+ if (Predicate.isString(value) || Predicate.isNumber(value)) return String(value);
18
+
19
+ if (value.length === 0) return '- none';
20
+
21
+ return value.map((item) => `- ${item}`).join('\n');
22
+ };
23
+
24
+ export const fillTemplate = (template: string, values: Readonly<Record<string, PromptValue>>) =>
25
+ template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (placeholder, key: string) =>
26
+ Option.match(Option.fromUndefinedOr(values[key]), { onNone: () => placeholder, onSome: render }),
27
+ );
28
+
29
+ export const renderPrompt = (name: string, values: Readonly<Record<string, PromptValue>>) =>
30
+ Effect.gen(function* () {
31
+ const repo = yield* Repo;
32
+ const fs = yield* FileSystem.FileSystem;
33
+ const path = `${repo.configDir}/prompts/${name}.md`;
34
+ const template = yield* fs.readFileString(path).pipe(Effect.mapError(() => new StepFailed({ step: `prompt ${name}`, message: `no prompt at ${path}` })));
35
+
36
+ return fillTemplate(template, values);
37
+ });