@ashulab/carrier 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.
Files changed (193) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -2
  3. package/dist/chat/claude.js +367 -0
  4. package/dist/chat/codex.js +375 -0
  5. package/dist/chat/config.js +61 -0
  6. package/dist/chat/db.js +9 -0
  7. package/dist/chat/fake.js +203 -0
  8. package/dist/chat/folder.js +56 -0
  9. package/dist/chat/git.js +27 -0
  10. package/dist/chat/group.js +28 -0
  11. package/dist/chat/health.js +49 -0
  12. package/dist/chat/host.js +123 -0
  13. package/dist/chat/idle.js +20 -0
  14. package/dist/chat/migrations/0000_lame_quasar.sql +15 -0
  15. package/dist/chat/migrations/0001_quiet_penance.sql +23 -0
  16. package/dist/chat/migrations/0002_slim_ezekiel_stane.sql +1 -0
  17. package/dist/chat/migrations/0003_naive_iron_lad.sql +5 -0
  18. package/dist/chat/migrations/0004_skinny_dakota_north.sql +1 -0
  19. package/dist/chat/migrations/0005_tidy_strong_guy.sql +1 -0
  20. package/dist/chat/migrations/meta/0000_snapshot.json +121 -0
  21. package/dist/chat/migrations/meta/0001_snapshot.json +277 -0
  22. package/dist/chat/migrations/meta/0002_snapshot.json +284 -0
  23. package/dist/chat/migrations/meta/0003_snapshot.json +320 -0
  24. package/dist/chat/migrations/meta/0004_snapshot.json +327 -0
  25. package/dist/chat/migrations/meta/0005_snapshot.json +334 -0
  26. package/dist/chat/migrations/meta/_journal.json +48 -0
  27. package/dist/chat/names.js +97 -0
  28. package/dist/chat/plan.js +21 -0
  29. package/dist/chat/projects.js +242 -0
  30. package/dist/chat/runner.js +6 -0
  31. package/dist/chat/schema.js +48 -0
  32. package/dist/chat/server.js +202 -0
  33. package/dist/chat/sessions.js +766 -0
  34. package/dist/chat/sweep.js +64 -0
  35. package/dist/chat/transcript.js +31 -0
  36. package/dist/chat/words.js +20 -0
  37. package/dist/chat/workspace.js +134 -0
  38. package/dist/chat/worktree.js +142 -0
  39. package/dist/config/default-path.js +7 -0
  40. package/dist/config/engine-places.js +9 -0
  41. package/dist/config/json-schema.js +15 -0
  42. package/dist/config/load.js +48 -0
  43. package/dist/config/resolve.js +41 -0
  44. package/dist/config/schema.js +225 -0
  45. package/dist/control/epic.js +219 -0
  46. package/dist/control/events.js +48 -0
  47. package/dist/control/files.js +262 -0
  48. package/dist/control/replies.js +1 -0
  49. package/dist/control/server.js +237 -0
  50. package/dist/control/sessions.js +68 -0
  51. package/dist/doctor/doctor.js +255 -0
  52. package/dist/doctor/fixes.js +35 -0
  53. package/dist/execution/agent-runner.js +1 -0
  54. package/dist/execution/caller-result.js +61 -0
  55. package/dist/execution/events.js +19 -0
  56. package/dist/execution/failure.js +3 -0
  57. package/dist/execution/fake.js +82 -0
  58. package/dist/execution/git-workspaces.js +136 -0
  59. package/dist/execution/import-result.js +23 -0
  60. package/dist/execution/workspaces.js +1 -0
  61. package/dist/format/duration.js +12 -0
  62. package/dist/harnesses/agy.js +131 -0
  63. package/dist/harnesses/claude.js +246 -0
  64. package/dist/harnesses/codex.js +118 -0
  65. package/dist/harnesses/common.js +59 -0
  66. package/dist/harnesses/credentials.js +38 -0
  67. package/dist/harnesses/host-runner.js +225 -0
  68. package/dist/harnesses/local.js +25 -0
  69. package/dist/harnesses/names.js +9 -0
  70. package/dist/harnesses/replay-events.js +57 -0
  71. package/dist/integrations/available-repos.js +29 -0
  72. package/dist/integrations/forge-cwd.js +27 -0
  73. package/dist/integrations/github-graphql.js +216 -0
  74. package/dist/integrations/github.js +231 -0
  75. package/dist/logging/logger.js +53 -0
  76. package/dist/logging/redact.js +21 -0
  77. package/dist/orchestration/blockers.js +25 -0
  78. package/dist/orchestration/changes-slices.js +5 -0
  79. package/dist/orchestration/clock.js +7 -0
  80. package/dist/orchestration/engine.js +1068 -0
  81. package/dist/orchestration/fake.js +152 -0
  82. package/dist/orchestration/final-pr.js +15 -0
  83. package/dist/orchestration/github.js +31 -0
  84. package/dist/orchestration/reserve.js +32 -0
  85. package/dist/orchestration/resume-refused.js +7 -0
  86. package/dist/orchestration/retention.js +145 -0
  87. package/dist/orchestration/retry.js +16 -0
  88. package/dist/prompts/build-prompt.js +13 -0
  89. package/dist/prompts/fix.js +21 -0
  90. package/dist/prompts/intro.js +21 -0
  91. package/dist/prompts/invoke.js +2 -0
  92. package/dist/prompts/persona.js +12 -0
  93. package/dist/prompts/result.js +32 -0
  94. package/dist/prompts/review.js +15 -0
  95. package/dist/prompts/ship.js +8 -0
  96. package/dist/status/snapshot.js +92 -0
  97. package/dist/status/types.js +1 -0
  98. package/dist/storage/caller-results.js +7 -0
  99. package/dist/storage/db.js +8 -0
  100. package/dist/storage/epic-review-threads.js +19 -0
  101. package/dist/storage/epic-slices.js +17 -0
  102. package/dist/storage/events.js +52 -0
  103. package/dist/storage/executions.js +31 -0
  104. package/dist/storage/lock.js +21 -0
  105. package/dist/storage/migrations/0000_nosy_demogoblin.sql +5 -0
  106. package/dist/storage/migrations/0001_worried_grey_gargoyle.sql +3 -0
  107. package/dist/storage/migrations/0002_tough_squirrel_girl.sql +5 -0
  108. package/dist/storage/migrations/0003_loose_maria_hill.sql +4 -0
  109. package/dist/storage/migrations/0004_long_cerise.sql +18 -0
  110. package/dist/storage/migrations/0005_review_result.sql +2 -0
  111. package/dist/storage/migrations/0006_melted_menace.sql +10 -0
  112. package/dist/storage/migrations/0007_powerful_bloodscream.sql +2 -0
  113. package/dist/storage/migrations/0008_panel_state.sql +4 -0
  114. package/dist/storage/migrations/0009_workable_tiger_shark.sql +5 -0
  115. package/dist/storage/migrations/0010_nasty_blue_marvel.sql +4 -0
  116. package/dist/storage/migrations/0011_steep_lila_cheney.sql +13 -0
  117. package/dist/storage/migrations/0012_talented_big_bertha.sql +1 -0
  118. package/dist/storage/migrations/0013_third_mulholland_black.sql +17 -0
  119. package/dist/storage/migrations/0014_perfect_marten_broadcloak.sql +20 -0
  120. package/dist/storage/migrations/0015_long_bloodaxe.sql +1 -0
  121. package/dist/storage/migrations/0016_neat_true_believers.sql +5 -0
  122. package/dist/storage/migrations/0017_previous_swarm.sql +6 -0
  123. package/dist/storage/migrations/0018_opposite_plazm.sql +2 -0
  124. package/dist/storage/migrations/0019_hesitant_triton.sql +3 -0
  125. package/dist/storage/migrations/meta/0000_snapshot.json +49 -0
  126. package/dist/storage/migrations/meta/0001_snapshot.json +74 -0
  127. package/dist/storage/migrations/meta/0002_snapshot.json +105 -0
  128. package/dist/storage/migrations/meta/0003_snapshot.json +134 -0
  129. package/dist/storage/migrations/meta/0004_snapshot.json +257 -0
  130. package/dist/storage/migrations/meta/0005_snapshot.json +271 -0
  131. package/dist/storage/migrations/meta/0006_snapshot.json +337 -0
  132. package/dist/storage/migrations/meta/0007_snapshot.json +351 -0
  133. package/dist/storage/migrations/meta/0008_snapshot.json +379 -0
  134. package/dist/storage/migrations/meta/0009_snapshot.json +414 -0
  135. package/dist/storage/migrations/meta/0010_snapshot.json +442 -0
  136. package/dist/storage/migrations/meta/0011_snapshot.json +531 -0
  137. package/dist/storage/migrations/meta/0012_snapshot.json +538 -0
  138. package/dist/storage/migrations/meta/0013_snapshot.json +655 -0
  139. package/dist/storage/migrations/meta/0014_snapshot.json +772 -0
  140. package/dist/storage/migrations/meta/0015_snapshot.json +779 -0
  141. package/dist/storage/migrations/meta/0016_snapshot.json +793 -0
  142. package/dist/storage/migrations/meta/0017_snapshot.json +835 -0
  143. package/dist/storage/migrations/meta/0018_snapshot.json +849 -0
  144. package/dist/storage/migrations/meta/0019_snapshot.json +870 -0
  145. package/dist/storage/migrations/meta/_journal.json +146 -0
  146. package/dist/storage/schema.js +43 -0
  147. package/dist/terminal/cli/args.js +91 -0
  148. package/dist/terminal/cli/cli-error.js +11 -0
  149. package/dist/terminal/cli/commands/chat-host.js +18 -0
  150. package/dist/terminal/cli/commands/chat.js +321 -0
  151. package/dist/terminal/cli/commands/control.js +46 -0
  152. package/dist/terminal/cli/commands/doctor.js +15 -0
  153. package/dist/terminal/cli/commands/init.js +43 -0
  154. package/dist/terminal/cli/commands/restart.js +92 -0
  155. package/dist/terminal/cli/commands/run.js +20 -0
  156. package/dist/terminal/cli/commands/schema.js +4 -0
  157. package/dist/terminal/cli/commands/start.js +21 -0
  158. package/dist/terminal/cli/commands/status.js +61 -0
  159. package/dist/terminal/cli/commands/watch.js +112 -0
  160. package/dist/terminal/cli/compose.js +163 -0
  161. package/dist/terminal/cli/config-file.js +24 -0
  162. package/dist/terminal/cli/control-client.js +94 -0
  163. package/dist/terminal/cli/detached.js +41 -0
  164. package/dist/terminal/cli/interrupt.js +39 -0
  165. package/dist/terminal/cli/main.js +85 -0
  166. package/dist/terminal/cli/open-url.js +9 -0
  167. package/dist/terminal/dashboard/dashboard.js +204 -0
  168. package/dist/terminal/dashboard/history.js +38 -0
  169. package/dist/terminal/dashboard/interactive.js +60 -0
  170. package/dist/terminal/dashboard/keys.js +22 -0
  171. package/dist/terminal/dashboard/logs.js +85 -0
  172. package/dist/terminal/dashboard/panel.js +62 -0
  173. package/dist/terminal/dashboard/plain.js +32 -0
  174. package/dist/terminal/dashboard/printable.js +2 -0
  175. package/dist/terminal/dashboard/ui-state.js +110 -0
  176. package/dist/terminal/prompts/clack.js +121 -0
  177. package/dist/terminal/prompts/types.js +3 -0
  178. package/dist/terminal/wizard/build-config.js +66 -0
  179. package/dist/terminal/wizard/deps.js +94 -0
  180. package/dist/terminal/wizard/flags.js +1 -0
  181. package/dist/terminal/wizard/forge-registry.js +23 -0
  182. package/dist/terminal/wizard/prior.js +20 -0
  183. package/dist/terminal/wizard/questions/agent.js +61 -0
  184. package/dist/terminal/wizard/questions/identities.js +28 -0
  185. package/dist/terminal/wizard/questions/identity.js +30 -0
  186. package/dist/terminal/wizard/questions/options.js +93 -0
  187. package/dist/terminal/wizard/questions/out.js +16 -0
  188. package/dist/terminal/wizard/questions/persona.js +47 -0
  189. package/dist/terminal/wizard/questions/repos.js +61 -0
  190. package/dist/terminal/wizard/summary.js +31 -0
  191. package/dist/terminal/wizard/testing.js +106 -0
  192. package/dist/terminal/wizard/wizard.js +172 -0
  193. package/package.json +46 -3
@@ -0,0 +1,152 @@
1
+ import { AuthError, GitHubUnavailable } from './github.js';
2
+ export class FakeGitHub {
3
+ /** What rateLimit answers. */
4
+ limit = null;
5
+ rateLimit() {
6
+ return this.limit;
7
+ }
8
+ /** A ready epic's labels are the issue's, as GitHub lists them. */
9
+ ready = {};
10
+ unavailable = null;
11
+ /** The open epics with their slice progress, by repo; the title defaults to empty and the labels to the issue's. */
12
+ openEpics = {};
13
+ /** Makes listOpenEpics fail while the rest works. */
14
+ openEpicsUnavailable = null;
15
+ /** What readSlices answers, by `repo#issue`; an epic not listed has no slices. */
16
+ slices = {};
17
+ /** Makes readSlices fail while the rest works. */
18
+ slicesUnavailable = null;
19
+ /** Every slice read that reached the port, as `repo#issue`. */
20
+ sliceReads = [];
21
+ slicesGate = null;
22
+ /** What readPullRequest answers, by `repo#number`; a pull request not listed does not exist. */
23
+ pullRequestBlocks = {};
24
+ /** The review threads readPullRequest answers with, by `repo#number`; a pull request not listed has none. */
25
+ reviewThreads = {};
26
+ /** Makes readPullRequest fail while the rest works. */
27
+ pullRequestUnavailable = null;
28
+ /** Every pull request read that reached the port, as `repo#number`. */
29
+ pullRequestReads = [];
30
+ /** Makes setLabels fail while the rest works. */
31
+ setLabelsUnavailable = null;
32
+ issues = {};
33
+ pulls = {};
34
+ repoLabels = {};
35
+ created = [];
36
+ /** The colour and description each label was created with. */
37
+ styles = {};
38
+ calls = 0;
39
+ /** Makes every call fail like Forge rejecting the orchestrator's profile. */
40
+ authError = null;
41
+ /** Every call that reached the port, failed or not. */
42
+ attempts = 0;
43
+ guard() {
44
+ this.attempts++;
45
+ if (this.authError)
46
+ throw this.authError;
47
+ }
48
+ /** An issue not set up by the test is open and ready. */
49
+ issue(repo, issue) {
50
+ return (this.issues[`${repo}#${issue}`] ??= { state: 'open', labels: ['solve:epic:ready'] });
51
+ }
52
+ async listReadyEpics(repo) {
53
+ this.guard();
54
+ this.calls++;
55
+ if (this.unavailable)
56
+ throw this.unavailable;
57
+ return (this.ready[repo] ?? []).filter((e) => this.issue(repo, e.issue).labels.includes('solve:epic:ready')).map((e) => ({ ...e, labels: [...this.issue(repo, e.issue).labels] })); // like GitHub: the label decides
58
+ }
59
+ async listOpenEpics(repo) {
60
+ this.guard();
61
+ const failure = this.unavailable ?? this.openEpicsUnavailable;
62
+ if (failure)
63
+ throw failure;
64
+ return (this.openEpics[repo] ?? []).map((epic) => ({ title: '', ...epic, labels: [...(this.issues[`${repo}#${epic.issue}`]?.labels ?? [])] }));
65
+ }
66
+ async readSlices(repo, issue) {
67
+ this.guard();
68
+ this.sliceReads.push(`${repo}#${issue}`);
69
+ if (this.slicesUnavailable)
70
+ throw this.slicesUnavailable;
71
+ const found = this.slices[`${repo}#${issue}`];
72
+ await this.slicesGate; // a test holds the answer here: it was read above, it arrives when the gate opens
73
+ return { slices: found?.slices.map((s) => ({ ...s })) ?? [], truncated: found?.truncated ?? false };
74
+ }
75
+ async readPullRequest(repo, number) {
76
+ this.guard();
77
+ this.pullRequestReads.push(`${repo}#${number}`);
78
+ if (this.pullRequestUnavailable)
79
+ throw this.pullRequestUnavailable;
80
+ const found = this.pullRequestBlocks[`${repo}#${number}`];
81
+ if (found === undefined)
82
+ return null;
83
+ const read = this.reviewThreads[`${repo}#${number}`];
84
+ return { pr: { ...found, checks: found.checks && { ...found.checks } }, threads: read?.threads.map((t) => ({ ...t })) ?? [], truncated: read?.truncated ?? false };
85
+ }
86
+ async getIssue(repo, issue) {
87
+ this.guard();
88
+ const { state, labels } = this.issue(repo, issue);
89
+ return { state, labels: [...labels] };
90
+ }
91
+ async getPullRequest(repo, number) {
92
+ this.guard();
93
+ if (this.unavailable)
94
+ throw this.unavailable;
95
+ const pull = this.pulls[`${repo}#${number}`];
96
+ return pull ? { ...pull, headRef: pull.headRef ?? 'feat/epic/epic' } : null;
97
+ }
98
+ async findPullRequest(repo, issue) {
99
+ this.guard();
100
+ if (this.unavailable)
101
+ throw this.unavailable;
102
+ for (const [key, { state, body, headSha }] of Object.entries(this.pulls)) {
103
+ if (key.startsWith(`${repo}#`) && new RegExp(`\\bcloses #${issue}(?!\\d)`, 'i').test(body))
104
+ return { number: Number(key.split('#')[1]), state, headSha };
105
+ }
106
+ return null;
107
+ }
108
+ async setLabels(repo, issue, { add, remove }) {
109
+ this.guard();
110
+ if (this.setLabelsUnavailable)
111
+ throw this.setLabelsUnavailable;
112
+ const found = this.issue(repo, issue);
113
+ found.labels = [...new Set([...found.labels.filter((l) => !remove.includes(l)), ...add])];
114
+ }
115
+ async listLabels(repo) {
116
+ this.guard();
117
+ return [...(this.repoLabels[repo] ?? [])];
118
+ }
119
+ async createLabel(repo, name, style) {
120
+ this.guard();
121
+ this.styles[name] = style;
122
+ (this.repoLabels[repo] ??= []).push(name);
123
+ this.created.push(`${repo}:${name}`);
124
+ }
125
+ }
126
+ export class FakeClock {
127
+ time = 0;
128
+ timers = [];
129
+ now() {
130
+ return this.time;
131
+ }
132
+ after(ms, fn) {
133
+ const timer = { at: this.time + ms, fn };
134
+ this.timers.push(timer);
135
+ return () => {
136
+ this.timers = this.timers.filter((t) => t !== timer);
137
+ };
138
+ }
139
+ /** Moves time forward, running due timers in order and waiting for each to finish. */
140
+ async advance(ms) {
141
+ const target = this.time + ms;
142
+ for (;;) {
143
+ const next = this.timers.filter((t) => t.at <= target).sort((a, b) => a.at - b.at)[0];
144
+ if (!next)
145
+ break;
146
+ this.timers = this.timers.filter((t) => t !== next);
147
+ this.time = next.at;
148
+ await next.fn();
149
+ }
150
+ this.time = target;
151
+ }
152
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Did the epic end outside Carrier? Only the epic issue and the final PR (the one saved when `ship`
3
+ * was validated) count: a slice PR or slice issue never shows up here. A merge is checked first
4
+ * because merging the final PR closes the epic issue too.
5
+ */
6
+ export async function endOf(github, { repo, issue, prNumber }) {
7
+ const pr = prNumber === null ? null : await github.getPullRequest(repo, prNumber);
8
+ if (pr?.state === 'merged')
9
+ return { status: 'merged', reason: `the final PR #${prNumber} was merged` };
10
+ if (pr?.state === 'closed')
11
+ return { status: 'cancelled', reason: `the final PR #${prNumber} was closed without merging` };
12
+ if ((await github.getIssue(repo, issue)).state === 'closed')
13
+ return { status: 'cancelled', reason: 'the epic issue was closed without merging' };
14
+ return null;
15
+ }
@@ -0,0 +1,31 @@
1
+ export class GitHubUnavailable extends Error {
2
+ kind;
3
+ retryAfterMs;
4
+ // No parameter property: Node strips types at runtime and cannot run that syntax.
5
+ constructor(kind, detail, retryAfterMs) {
6
+ super(`GitHub unavailable (${kind}: ${detail})`);
7
+ this.kind = kind;
8
+ this.retryAfterMs = retryAfterMs;
9
+ }
10
+ }
11
+ /** Forge reported an authentication error for the profile the port acts as (the orchestrator's). Thrown by any GitHub call. */
12
+ export class AuthError extends Error {
13
+ profile;
14
+ constructor(profile) {
15
+ super('auth error');
16
+ this.profile = profile;
17
+ }
18
+ }
19
+ // Labels Carrier owns (it creates them) and the one Solve owns (it never does).
20
+ export const ownedLabels = ['solve:epic:running', 'solve:epic:ready-for-test', 'solve:epic:needs-human'];
21
+ export const readyLabel = 'solve:epic:ready';
22
+ export const epicLabel = 'solve:epic';
23
+ /** How Carrier creates its labels (GitHub limits a description to 100 characters). `needs-human` is the loud one: it is the state that most needs to be seen. */
24
+ export const labelStyles = {
25
+ 'solve:epic:running': { color: '0969da', description: 'An orchestrator is building this epic' },
26
+ 'solve:epic:ready-for-test': { color: '8957e5', description: 'Reviewed on the current head; a person tests and merges' },
27
+ 'solve:epic:needs-human': { color: 'd93f0b', description: 'Stopped: a person has to step in (reason in the issue or PR)' },
28
+ };
29
+ export const runningLabel = ownedLabels[0];
30
+ export const readyForTestLabel = ownedLabels[1];
31
+ export const needsHumanLabel = ownedLabels[2];
@@ -0,0 +1,32 @@
1
+ import { and, asc, count, eq, inArray } from 'drizzle-orm';
2
+ import { epics } from '../storage/schema.js';
3
+ // Atomic: the check of free slots, the pick of the oldest queued epic and the move to `running`
4
+ // happen in one write transaction, so two connections to the same file can never reserve the
5
+ // same epic or exceed the limit. Only queued epics are picked, so a running one is never reserved again.
6
+ // With two or more options the epic is also marked as waiting for its choice for `choiceMs`; each role starts on its configured option.
7
+ export function reserve(db, { maxConcurrent, now, repos, choiceMs }) {
8
+ return db.transaction((tx) => {
9
+ const running = tx.select({ n: count() }).from(epics).where(eq(epics.status, 'running')).get().n;
10
+ if (running >= maxConcurrent)
11
+ return null;
12
+ const next = tx
13
+ .select()
14
+ .from(epics)
15
+ .where(and(eq(epics.status, 'queued'), inArray(epics.repo, repos.map((r) => r.name))))
16
+ .orderBy(asc(epics.id))
17
+ .get();
18
+ if (!next)
19
+ return null;
20
+ const config = repos.find((r) => r.name === next.repo);
21
+ return tx
22
+ .update(epics)
23
+ .set({
24
+ status: 'running', startedAt: now, config, labelPending: true,
25
+ authorOption: config.agents.author.option ?? null, reviewerOption: config.agents.reviewer.option ?? null,
26
+ choiceDeadline: Object.keys((config.providers ?? config.options) ?? {}).length >= 2 ? now + choiceMs : null,
27
+ })
28
+ .where(eq(epics.id, next.id))
29
+ .returning()
30
+ .get();
31
+ }, { behavior: 'immediate' });
32
+ }
@@ -0,0 +1,7 @@
1
+ export class ResumeRefused extends Error {
2
+ reason;
3
+ constructor(reason) {
4
+ super(reason);
5
+ this.reason = reason;
6
+ }
7
+ }
@@ -0,0 +1,145 @@
1
+ import { readdirSync, realpathSync, rmSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join, sep } from 'node:path';
4
+ import { createScopedLogger } from '@ashulab/universal-logger';
5
+ import { and, eq, inArray, isNotNull, lte } from 'drizzle-orm';
6
+ import { executionEvents } from '../storage/events.js';
7
+ import { executions } from '../storage/executions.js';
8
+ import { epics } from '../storage/schema.js';
9
+ const DAY = 24 * 60 * 60 * 1000;
10
+ export const retentionMs = 30 * DAY;
11
+ // Only an epic that is over expires; queued, running, needs_human and repo_removed keep everything.
12
+ const finished = ['ready_for_test', 'merged', 'cancelled'];
13
+ // Ids come from SQLite but end up in a path: an allowlist keeps `..` and separators out.
14
+ const safeId = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
15
+ export const sessionDirsFrom = (env = process.env) => ({
16
+ claude: env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'),
17
+ codex: env.CODEX_HOME ?? join(homedir(), '.codex'),
18
+ });
19
+ const log = createScopedLogger('retention');
20
+ // Entries of a folder; absent means that CLI never ran here, anything else (permissions, IO) is logged: a purge that cannot look is not a clean one.
21
+ const entries = (dir) => {
22
+ try {
23
+ return readdirSync(dir, { withFileTypes: true });
24
+ }
25
+ catch (error) {
26
+ if (error.code !== 'ENOENT')
27
+ log.error('cannot list a session folder', error, { dir });
28
+ return [];
29
+ }
30
+ };
31
+ // Folders to walk down into: a symlink is never followed out of the CLI's own tree.
32
+ const folders = (dir) => entries(dir).filter((e) => e.isDirectory()).map((e) => e.name);
33
+ const files = (dir) => entries(dir).map((e) => e.name);
34
+ /**
35
+ * The files of a session, found by its id (docs/research/headless-agents.md): Claude keeps
36
+ * `projects/<cwd with / and . as ->/<id>.jsonl` (plus `<id>/` for what it spawned, only beside its transcript),
37
+ * Codex `sessions/YYYY/MM/DD/rollout-<date>-<id>.jsonl`. Nothing a CLI reported is used as a path, only the id.
38
+ */
39
+ function sessionFiles({ claude, codex }, ids) {
40
+ const found = [];
41
+ const wanted = new Set(ids);
42
+ const projects = join(claude, 'projects');
43
+ for (const folder of folders(projects)) {
44
+ const present = files(join(projects, folder));
45
+ for (const entry of present) {
46
+ if (!entry.endsWith('.jsonl') || !wanted.has(entry.slice(0, -'.jsonl'.length)))
47
+ continue;
48
+ found.push(join(projects, folder, entry));
49
+ const spawned = entry.slice(0, -'.jsonl'.length);
50
+ if (present.includes(spawned))
51
+ found.push(join(projects, folder, spawned));
52
+ }
53
+ }
54
+ const sessions = join(codex, 'sessions');
55
+ for (const year of folders(sessions))
56
+ for (const month of folders(join(sessions, year)))
57
+ for (const day of folders(join(sessions, year, month))) {
58
+ for (const file of files(join(sessions, year, month, day))) {
59
+ const id = /^rollout-\d{4}-\d\d-\d\dT\d\d-\d\d-\d\d-(.+)\.jsonl$/.exec(file)?.[1];
60
+ if (id !== undefined && wanted.has(id))
61
+ found.push(join(sessions, year, month, day, file));
62
+ }
63
+ }
64
+ return found;
65
+ }
66
+ /**
67
+ * Deletes the execution logs (`<logs>/executions/<executionId>/`) and the sessions the CLIs saved
68
+ * for them, of epics finished or cancelled 30 days or more before `now`, and the events stored from those logs.
69
+ * Never touches other SQLite rows nor workspaces; a symlink is removed itself, never followed. Returns the paths deleted.
70
+ */
71
+ export function purgeExpired(db, logs, now, sessionDirs = sessionDirsFrom()) {
72
+ const rows = db
73
+ .select({ executionId: executions.executionId, sessionId: executions.sessionId, configDir: executions.configDir })
74
+ .from(executions)
75
+ .innerJoin(epics, eq(epics.id, executions.epicId))
76
+ .where(and(inArray(epics.status, finished), isNotNull(epics.finishedAt), lte(epics.finishedAt, now - retentionMs)))
77
+ .all();
78
+ // The events are a copy of the logs: they go when the logs do.
79
+ db.transaction((tx) => {
80
+ for (const { executionId } of rows)
81
+ tx.delete(executionEvents).where(eq(executionEvents.executionId, executionId)).run();
82
+ });
83
+ const deleted = [];
84
+ const remove = (target) => {
85
+ rmSync(target, { recursive: true, force: true });
86
+ deleted.push(target);
87
+ };
88
+ // Each session is looked up in the folder of the execution that wrote it; a row that predates the column uses the environment's own.
89
+ const idsByFolder = new Map();
90
+ for (const { sessionId, configDir } of rows) {
91
+ if (sessionId === null || !safeId.test(sessionId))
92
+ continue;
93
+ if (configDir !== null && !isAbsolute(configDir)) {
94
+ log.warn('ignoring a recorded config folder that is not an absolute path', { configDir });
95
+ continue;
96
+ }
97
+ idsByFolder.set(configDir, [...(idsByFolder.get(configDir) ?? []), sessionId]);
98
+ }
99
+ for (const [folder, ids] of idsByFolder) {
100
+ for (const file of sessionFiles(folder === null ? sessionDirs : { claude: folder, codex: folder }, ids))
101
+ if (!deleted.includes(file))
102
+ remove(file);
103
+ }
104
+ let real;
105
+ try {
106
+ real = realpathSync(logs);
107
+ }
108
+ catch {
109
+ return deleted; // no logs folder: no logs were ever written
110
+ }
111
+ try {
112
+ // A folder that resolves outside the logs folder (symlinked) is not ours to delete from.
113
+ if (!realpathSync(join(logs, 'executions')).startsWith(real + sep))
114
+ return deleted;
115
+ }
116
+ catch {
117
+ return deleted; // folder absent
118
+ }
119
+ for (const { executionId } of rows)
120
+ if (safeId.test(executionId))
121
+ remove(join(logs, 'executions', executionId));
122
+ return deleted;
123
+ }
124
+ /** Purges now and every 24 hours on the injected clock; a failed purge is logged and tried again next day. */
125
+ export function startRetention(db, logs, clock, sessionDirs) {
126
+ let cancel = () => { };
127
+ let stopped = false;
128
+ const run = async () => {
129
+ try {
130
+ const deleted = purgeExpired(db, logs, clock.now(), sessionDirs);
131
+ if (deleted.length > 0)
132
+ log.info('purged expired logs and sessions', { count: deleted.length });
133
+ }
134
+ catch (error) {
135
+ log.error('retention purge failed', error);
136
+ }
137
+ if (!stopped)
138
+ cancel = clock.after(DAY, run);
139
+ };
140
+ void run();
141
+ return () => {
142
+ stopped = true;
143
+ cancel();
144
+ };
145
+ }
@@ -0,0 +1,16 @@
1
+ import { desc, eq, sql } from 'drizzle-orm';
2
+ import { executions } from '../storage/executions.js';
3
+ /**
4
+ * The epic's latest executions that ended in a retryable technical failure, newest first. The streak
5
+ * stops at the first execution without `error` (a finished one, or one that is not retried), so
6
+ * findings, rounds and GitHub query errors never count: only technical failures do.
7
+ */
8
+ export function failureStreak(db, epicId) {
9
+ const streak = [];
10
+ for (const execution of db.select().from(executions).where(eq(executions.epicId, epicId)).orderBy(desc(sql `rowid`)).all()) {
11
+ if (execution.error === null)
12
+ break;
13
+ streak.push(execution);
14
+ }
15
+ return streak;
16
+ }
@@ -0,0 +1,13 @@
1
+ import { fix } from './fix.js';
2
+ import { intro } from './intro.js';
3
+ import { resultContract } from './result.js';
4
+ import { review } from './review.js';
5
+ import { ship } from './ship.js';
6
+ // Only used when the task has no PR yet (ship); the engine validates the real PR against GitHub.
7
+ const placeholderPr = { number: 1, headSha: '0'.repeat(40) };
8
+ /** The prompt of one run: who you are, what this stage asks, and how to hand the result back. */
9
+ export function buildPrompt(task, { forgeAgent } = {}) {
10
+ const pr = task.context.pr ?? placeholderPr;
11
+ const stage = task.stage === 'ship' ? ship(task) : task.stage === 'fix' ? fix(task, pr) : review(task, pr);
12
+ return [...intro(task, forgeAgent), ...stage, ...resultContract(task, pr)].join('\n');
13
+ }
@@ -0,0 +1,21 @@
1
+ import { invoke } from './invoke.js';
2
+ const item = (b) => `- ${b.title}${b.url ? ` (${b.url})` : ''}`;
3
+ /** Stage `fix`: answer the blockers of the last review, or, when it only left observations, reply to them. */
4
+ export const fix = (task, pr) => {
5
+ const observations = task.context.observations ?? [];
6
+ if (observations.length > 0) {
7
+ return [
8
+ `Invoke ${invoke(task, 'code-resolve')} on PR #${pr.number}, answering the observations the last review left and fixing what applies.`,
9
+ 'Reply on each thread with your decision, whether or not it changes the code. Leave every thread open: only the reviewer can say it is satisfied, you do not resolve them.',
10
+ 'Never merge the final PR.',
11
+ 'Observations of the last review:',
12
+ ...observations.map(item),
13
+ ];
14
+ }
15
+ return [
16
+ `Invoke ${invoke(task, 'code-resolve')} on PR #${pr.number}, answering every finding of the last review and fixing what applies.`,
17
+ 'Never merge the final PR. Leave the blockers open: the reviewer verifies them, you do not resolve them.',
18
+ 'Blockers of the last review:',
19
+ ...(task.context.blockers ?? []).map(item),
20
+ ];
21
+ };
@@ -0,0 +1,21 @@
1
+ import { personaLines } from './persona.js';
2
+ /** Who the agent is, the ground rules, and what happened in earlier turns of this epic. */
3
+ export function intro(task, forgeAgent) {
4
+ const { executionId, repo, epic, stage, context } = task;
5
+ const lines = [
6
+ `You are the ${stage === 'review' ? 'reviewer' : 'author'} of the final integration PR of epic #${epic} in ${repo}. Execution ${executionId}, stage ${stage}.`,
7
+ ...personaLines(task),
8
+ 'Nobody answers questions during this run: decide, and use needs_human when you cannot.',
9
+ 'Apply the repo\'s own rules (its docs, agent instructions and conventions) to choose and evaluate tests, CI and blockers. Do not assume commands from other repos.',
10
+ ];
11
+ if (forgeAgent) {
12
+ lines.push(`Your GitHub token expires about an hour after this run starts. If a \`gh\` call fails with 401 (Bad credentials), run it again as \`agent-forge gh ${forgeAgent} <the same arguments>\` from inside the workspace. git renews its own credentials.`);
13
+ }
14
+ if (context.verifyStateFirst) {
15
+ lines.push('A previous attempt of this stage was interrupted. Before acting, verify what is already done (branch, commits, PR, comments, threads) and continue from there instead of repeating work.');
16
+ }
17
+ if (context.previousSummaries.length > 0) {
18
+ lines.push('Summaries of the previous turns, oldest first:', ...context.previousSummaries.map((s, i) => `${i + 1}. ${s}`));
19
+ }
20
+ return lines;
21
+ }
@@ -0,0 +1,2 @@
1
+ /** How a harness spells "run this skill": Claude Code and Antigravity expand /skill, Codex $skill. */
2
+ export const invoke = (task, skill) => `${task.harness === 'codex' ? '$' : '/'}${skill}`;
@@ -0,0 +1,12 @@
1
+ export const signatureOf = (role, name) => `🤖 ${role === 'author' ? 'Author' : 'Reviewer'}${name ? ` [${name}]` : ''} via Carrier`;
2
+ /** Who the user says this role is: its name, then its prompt as written. Under a shared identity, the rule to sign what it publishes. */
3
+ export function personaLines({ persona, sharedIdentity, stage }) {
4
+ const signature = signatureOf(stage === 'review' ? 'reviewer' : 'author', persona?.name);
5
+ return [
6
+ ...(persona?.name ? [`Your name is ${persona.name}.`] : []),
7
+ ...(persona?.prompt ? [persona.prompt] : []),
8
+ ...(sharedIdentity
9
+ ? [`The GitHub account you publish with is shared with the person running Carrier and with the other role. End everything you publish on GitHub with this signature on its own last line: review comments and bodies, the pull request body, replies on threads, and any other comment, issue or issue body you post. In a commit message make it the last line of the body, before any trailers such as Co-Authored-By: ${signature}`]
10
+ : []),
11
+ ];
12
+ }
@@ -0,0 +1,32 @@
1
+ const json = (result) => '```json\n' + JSON.stringify(result, null, 2) + '\n```';
2
+ /** The contract: where to write the result and which JSON shapes Carrier accepts for this stage. */
3
+ export function resultContract(task, pr) {
4
+ const { executionId, repo, epic, stage, resultPath } = task;
5
+ const reviewing = stage === 'review';
6
+ const common = { executionId, repo, epic, stage, summary: '<one or two sentences for the next agent of this epic: what you did and concluded>' };
7
+ const completed = (evidence) => ({
8
+ ...common,
9
+ outcome: 'completed',
10
+ pr,
11
+ verification: { ci: 'passed', evidence },
12
+ ...(reviewing && { observations: [{ id: '<stable-id>', title: '<non-blocking finding>', url: `https://github.com/${repo}/pull/${pr.number}#discussion_r456` }] }),
13
+ });
14
+ const changesRequested = {
15
+ ...common,
16
+ stage: 'review',
17
+ outcome: 'changes_requested',
18
+ pr,
19
+ blockers: [{ id: '<stable-id>', title: '<blocker>', url: `https://github.com/${repo}/pull/${pr.number}#discussion_r123` }],
20
+ resolved: ['<id of an earlier blocker that is now fixed>'],
21
+ };
22
+ const needsHuman = { ...common, outcome: 'needs_human', reason: '<why a person has to step in>' };
23
+ const examples = reviewing
24
+ ? [completed('<one line: which verifications you confirmed on this commit>'), changesRequested, needsHuman]
25
+ : [completed('<one line: which verifications you ran and their result>'), needsHuman];
26
+ return [
27
+ `Before finishing, write exactly one JSON object to ${resultPath}. That path is also in the environment variable CARRIER_RESULT_PATH: write to "$CARRIER_RESULT_PATH" instead of retyping it, because a path with one segment missing is a result nobody reads. Repeat executionId, repo, epic and stage as given. Allowed outputs:`,
28
+ ...examples.map(json),
29
+ ...(reviewing ? [] : ['Replace pr.number and pr.headSha with the real final PR and its current head (git rev-parse HEAD). A failing CI is not a completed result.']),
30
+ 'verification.ci is "absent" when the repo has no CI; say so in evidence.',
31
+ ];
32
+ }
@@ -0,0 +1,15 @@
1
+ import { invoke } from './invoke.js';
2
+ /** Stage `review`: judge the final PR with a different identity than the author's. */
3
+ export const review = (task, pr) => [
4
+ `Review PR #${pr.number}${task.context.round ? `, round ${task.context.round}` : ''}. First confirm the PR head is ${pr.headSha}; if it is not, return needs_human.`,
5
+ `Invoke ${invoke(task, 'code-review')} against the epic's spec and tickets, checking that earlier findings were fixed and nothing regressed, then publish the findings with ${invoke(task, 'code-post')}.`,
6
+ ...(task.context.blockers?.length
7
+ ? [
8
+ 'Blockers of the previous round (id and title):',
9
+ ...task.context.blockers.map((b) => `- ${b.id ?? '(no id)'}: ${b.title}`),
10
+ 'Give every blocker you return a short stable id. When a finding above persists, reuse its id; never reuse an id for a different finding. List in "resolved" the ids of the blockers above that are now fixed.',
11
+ ]
12
+ : ['Give every blocker you return a short stable id (for example "token-expiry"), so a later round can say whether it persists or was fixed.']),
13
+ 'Findings that do not stop approval (what you would post as a nitpick or a suggestion) are observations: list them in "observations" of a completed result, each with a title, a link when you have one, and a short stable id by the same rule as blockers (reuse it when the finding persists; never reuse an id for a different finding). Leave the field out when there are none.',
14
+ 'Return changes_requested with the blockers if there are any. Return completed only if the review is conclusive, there are no blockers and the repo\'s verifications are confirmed on that same commit. If the review is inconclusive (missing context, cannot verify), return needs_human.',
15
+ ];
@@ -0,0 +1,8 @@
1
+ import { invoke } from './invoke.js';
2
+ /** Stage `ship`: drain the epic into its own worktree and leave the final PR open. */
3
+ export const ship = (task) => [
4
+ `Invoke ${invoke(task, 'ship')} for epic #${task.epic} and tell it to worktree this epic, whatever mode the repo's solve.md has: the epic must live in its own git worktree. Leave the final integration PR open.`,
5
+ 'Never merge the final PR: a person tests it and decides the merge.',
6
+ // Carrier may run on another machine: a file on someone's disk is not a source. The remote is the only shared channel.
7
+ 'The epic\'s design artifacts (spec, ADRs, glossary) must already be on the remote, on the epic branch or the base branch. Do not look for uncommitted files in other directories of this machine. If the epic refers to a spec or ADR that is not there, return needs_human naming what is missing.',
8
+ ];
@@ -0,0 +1,92 @@
1
+ import { and, asc, count, desc, eq, gte, inArray, sql } from 'drizzle-orm';
2
+ import { latestBlockers } from '../orchestration/blockers.js';
3
+ import { executions } from '../storage/executions.js';
4
+ import { epics } from '../storage/schema.js';
5
+ const agentOf = ({ harness, model, provider }) => ({ harness, model, provider: provider ?? null });
6
+ const agentsOf = (config) => ({ author: agentOf(config.agents.author), reviewer: agentOf(config.agents.reviewer) });
7
+ /** The options of an epic as the panel and the epic's reply list them; undefined for one saved before options existed. */
8
+ export const optionsOf = ({ config, authorOption, reviewerOption, choiceDeadline }) => {
9
+ const directory = config?.providers ?? config?.options;
10
+ return directory && { names: Object.keys(directory), author: authorOption, reviewer: reviewerOption, choiceDeadline };
11
+ };
12
+ /** One row per watched repo: the agents it resolves to and its last poll (not checked yet: null and ok). */
13
+ export function watchedOf(repos, lastCheck) {
14
+ return repos.map((r) => {
15
+ const check = lastCheck(r.name);
16
+ return { repo: r.name, lastCheckedAt: check?.at ?? null, ok: check?.ok ?? true, error: check?.error ?? null, agents: agentsOf(r) };
17
+ });
18
+ }
19
+ const maxBlockerTitles = 10;
20
+ const maxBlockerTitleLength = 200;
21
+ /**
22
+ * What the panel shows, read from SQLite: slots, the queue (oldest first) and each epic that is running or waiting for a person.
23
+ * What the engine does not keep in SQLite comes in through the optional inputs and is empty (null or []) without them.
24
+ */
25
+ export function buildSnapshot(db, { now, maxConcurrentEpics, maxReviews, finishedWindowDays, startedAt, repos, nextCheckAt, problem, paused, slices, version, pollIntervalMs, rateLimit = null, watched = [], lastEvent = () => null, }) {
26
+ const busy = db.select({ n: count() }).from(epics).where(eq(epics.status, 'running')).get().n;
27
+ const queue = db.select({ repo: epics.repo, issue: epics.issue, title: epics.title, queuedAt: epics.queuedAt }).from(epics).where(eq(epics.status, 'queued')).orderBy(asc(epics.id)).all()
28
+ .map((q) => ({ repo: q.repo, issue: q.issue, title: q.title, queuedAt: q.queuedAt }));
29
+ const shown = db.select().from(epics).where(inArray(epics.status, ['running', 'needs_human'])).orderBy(asc(epics.id)).all();
30
+ const lastExecution = (epicId) => db.select({ id: executions.executionId }).from(executions).where(eq(executions.epicId, epicId)).orderBy(desc(executions.startedAt), sql `rowid desc`).limit(1).get()?.id ?? null;
31
+ const done = db.select({ id: epics.id, repo: epics.repo, issue: epics.issue, title: epics.title, labels: epics.labels, prNumber: epics.prNumber, finishedAt: epics.finishedAt }).from(epics).where(eq(epics.status, 'ready_for_test')).orderBy(desc(epics.finishedAt)).limit(5).all()
32
+ .map(({ id, labels, ...rest }) => ({ ...rest, labels: labels ?? [], executionId: lastExecution(id) }));
33
+ const finished = db.select({ id: epics.id, repo: epics.repo, issue: epics.issue, title: epics.title, labels: epics.labels, prNumber: epics.prNumber, finishedAt: epics.finishedAt, status: epics.status, cancelReason: epics.cancelReason }).from(epics)
34
+ .where(and(inArray(epics.status, ['merged', 'cancelled']), gte(epics.finishedAt, now - finishedWindowDays * 86_400_000))).orderBy(desc(epics.finishedAt)).all()
35
+ .map(({ id, labels, finishedAt, status, cancelReason, ...rest }) => ({ ...rest, labels: labels ?? [], finishedAt: finishedAt, status: status, cancelReason: status === 'cancelled' ? cancelReason : null, executionId: lastExecution(id) }));
36
+ const epicAgents = (e) => e.config && agentsOf(e.config);
37
+ const sessions = shown.flatMap((e) => {
38
+ const agent = epicAgents(e);
39
+ const running = db.select({ executionId: executions.executionId, stage: executions.stage, startedAt: executions.startedAt }).from(executions)
40
+ .where(and(eq(executions.epicId, e.id), eq(executions.status, 'running'))).orderBy(desc(executions.startedAt), sql `rowid desc`).limit(1).get();
41
+ if (e.status !== 'running' || !running || !agent)
42
+ return [];
43
+ const { harness, model } = agent[running.stage === 'review' ? 'reviewer' : 'author'];
44
+ return [{ executionId: running.executionId, repo: e.repo, issue: e.issue, stage: running.stage, round: e.round, harness, model, startedAt: running.startedAt, last: lastEvent(running.executionId) }];
45
+ });
46
+ return {
47
+ now,
48
+ startedAt,
49
+ repos,
50
+ nextCheckAt,
51
+ problem,
52
+ paused,
53
+ done,
54
+ slots: { busy, total: maxConcurrentEpics },
55
+ queue,
56
+ engine: { version, pollIntervalMs, maxConcurrentEpics, rateLimit },
57
+ watched,
58
+ sessions,
59
+ finished,
60
+ epics: shown.map((e) => {
61
+ const blockers = latestBlockers(db, e.id);
62
+ return {
63
+ repo: e.repo,
64
+ issue: e.issue,
65
+ status: e.status,
66
+ stage: e.stage,
67
+ round: e.round,
68
+ maxReviews,
69
+ blockers: blockers.length,
70
+ stageSince: e.stageStartedAt ?? e.startedAt ?? now,
71
+ lastActivityAt: e.lastActivityAt,
72
+ issueUrl: `https://github.com/${e.repo}/issues/${e.issue}`,
73
+ prUrl: e.prNumber === null ? null : `https://github.com/${e.repo}/pull/${e.prNumber}`,
74
+ lastError: e.lastError,
75
+ needsHumanReason: e.needsHumanReason,
76
+ slices: slices(e.repo, e.issue),
77
+ executionId: lastExecution(e.id),
78
+ title: e.title,
79
+ labels: e.labels ?? [],
80
+ queuedAt: e.queuedAt,
81
+ startedAt: e.startedAt,
82
+ finishedAt: e.finishedAt,
83
+ branch: e.branch,
84
+ workspace: e.workspace,
85
+ needsHumanKind: e.needsHumanKind,
86
+ blockerTitles: blockers.slice(0, maxBlockerTitles).map((b) => b.title.slice(0, maxBlockerTitleLength)),
87
+ agents: epicAgents(e),
88
+ options: optionsOf(e),
89
+ };
90
+ }),
91
+ };
92
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
2
+ // One row per execution: the primary key is what makes importing a result idempotent.
3
+ export const callerResults = sqliteTable('caller_results', {
4
+ executionId: text().primaryKey(),
5
+ outcome: text().notNull(),
6
+ result: text().notNull(), // the validated JSON
7
+ });