@ashulab/carrier 0.0.0-stage → 0.1.1

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 +265 -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,255 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { createScopedLogger } from '@ashulab/universal-logger';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { socketPathFits } from '../control/server.js';
7
+ import { enginePlacesOf } from '../config/engine-places.js';
8
+ import { resolveRepoConfig, sameAccount } from '../config/resolve.js';
9
+ import { profileToken } from '../execution/git-workspaces.js';
10
+ import { forgeCwd } from '../integrations/forge-cwd.js';
11
+ import { GhGitHub } from '../integrations/github.js';
12
+ import { listLocalModels, localLabel, localUrl } from '../harnesses/local.js';
13
+ import { configDirVariable } from '../harnesses/names.js';
14
+ import { fixes, installFix } from './fixes.js';
15
+ const log = createScopedLogger('doctor');
16
+ const LOGIN_TIMEOUT_MS = 20_000;
17
+ const AGY_TIMEOUT_MS = 30_000; // logged in answers in about five seconds
18
+ const AGY_LOGGED_OUT = 'Authentication required';
19
+ // Antigravity has no status command, so ask for /usage: it costs nothing. The stderr is never kept past the first line (it holds the login URL).
20
+ function startAgy() {
21
+ const cwd = mkdtempSync(join(tmpdir(), 'carrier-agy-'));
22
+ const child = spawn('agy', ['-p', '/usage', '--output-format', 'json'], { cwd, env: { ...process.env, BROWSER: 'true' }, stdio: ['ignore', 'pipe', 'pipe'] });
23
+ child.once('close', () => rmSync(cwd, { recursive: true, force: true }));
24
+ return child;
25
+ }
26
+ /** 'in' on status SUCCESS, 'out' at the first stderr line asking for login (the process is killed then, not awaited), 'unknown' otherwise. */
27
+ function agyLogin(deps) {
28
+ return new Promise((resolve, reject) => {
29
+ const child = deps.startAgy();
30
+ let out = '';
31
+ let err = '';
32
+ let settled = false;
33
+ const settle = (verdict) => {
34
+ if (settled)
35
+ return; // killing the process closes it, which comes back here
36
+ settled = true;
37
+ clearTimeout(timer);
38
+ resolve(verdict);
39
+ child.kill();
40
+ };
41
+ const timer = setTimeout(() => settle('unknown'), AGY_TIMEOUT_MS);
42
+ child.once('error', (error) => {
43
+ clearTimeout(timer);
44
+ reject(error);
45
+ });
46
+ child.stdout.on('data', (chunk) => (out += chunk));
47
+ child.stderr.on('data', (chunk) => {
48
+ if (err.length < AGY_LOGGED_OUT.length)
49
+ err += chunk; // only the start of the first line matters
50
+ if (err.startsWith(AGY_LOGGED_OUT))
51
+ settle('out');
52
+ });
53
+ child.once('close', () => {
54
+ try {
55
+ settle(JSON.parse(out).status === 'SUCCESS' ? 'in' : 'unknown');
56
+ }
57
+ catch {
58
+ settle('unknown');
59
+ }
60
+ });
61
+ });
62
+ }
63
+ // Reads only the verdict: the output holds an email and an organisation, which never leave this function.
64
+ // Async, so the wizard and the doctor's spinner go on while the CLI answers. After the timeout the whole process group is killed and the
65
+ // probe rejects at once: a CLI that traps SIGTERM, or leaves a child holding the pipe, cannot keep it waiting.
66
+ export function cliLoggedIn(harness, configDir, timeoutMs = LOGIN_TIMEOUT_MS) {
67
+ const env = configDir ? { ...process.env, [configDirVariable[harness]]: configDir } : process.env;
68
+ const [command, args] = harness === 'claude' ? ['claude', ['auth', 'status']] : ['codex', ['login', 'status']];
69
+ return new Promise((resolve, reject) => {
70
+ const child = spawn(command, args, { env, stdio: ['ignore', 'pipe', 'ignore'], detached: true });
71
+ let out = '';
72
+ child.stdout.setEncoding('utf8');
73
+ child.stdout.on('data', (chunk) => (out += chunk));
74
+ const timer = setTimeout(() => {
75
+ try {
76
+ process.kill(-child.pid, 'SIGKILL');
77
+ }
78
+ catch {
79
+ child.kill('SIGKILL'); // no process group to signal (not POSIX), or it is already gone
80
+ }
81
+ reject(new Error(`${command} ${args.join(' ')} did not answer in ${timeoutMs / 1000} s`));
82
+ }, timeoutMs);
83
+ child.once('error', (error) => {
84
+ clearTimeout(timer);
85
+ reject(error);
86
+ });
87
+ child.once('close', (status) => {
88
+ clearTimeout(timer);
89
+ if (harness === 'codex')
90
+ return resolve(status === 0);
91
+ try {
92
+ resolve(JSON.parse(out).loggedIn === true);
93
+ }
94
+ catch {
95
+ // the parse error carries the start of the output: only this fixed text is logged and thrown
96
+ log.warn('could not read the output of claude auth status');
97
+ reject(new Error('could not read the output of claude auth status'));
98
+ }
99
+ });
100
+ });
101
+ }
102
+ const realDeps = {
103
+ localModels: (local) => listLocalModels(local),
104
+ loggedIn: cliLoggedIn,
105
+ startAgy,
106
+ which: (command) => spawnSync('which', [command]).status === 0,
107
+ mint: (profile, repo) => profileToken(profile)(repo),
108
+ listLabels: (config, repo, folder) => new GhGitHub({
109
+ profile: config.profiles[config.orchestratorProfile],
110
+ cwd: forgeCwd(enginePlacesOf(folder)),
111
+ timeoutMs: config.limits.githubCallTimeoutSeconds * 1000,
112
+ }).listLabels(repo),
113
+ };
114
+ const identity = (profile) => (profile.kind === 'forge' ? `forge agent ${profile.agent}` : 'your gh login');
115
+ const message = (error) => (error instanceof Error ? error.message : String(error));
116
+ /** What the machine has to be able to do, and whether each identity can act on each repo. Reads only; creates nothing on GitHub. */
117
+ export async function runDoctor(config, folder, overrides = {}, onCheck = () => { }, onStart = () => { }) {
118
+ const deps = { ...realDeps, ...overrides };
119
+ const checks = [];
120
+ const report = (check) => {
121
+ if (check.fix === undefined)
122
+ delete check.fix; // a remedy that does not apply is no key at all
123
+ checks.push(check);
124
+ onCheck(check);
125
+ return check.status === 'ok';
126
+ };
127
+ const missing = new Set();
128
+ const installed = (command) => {
129
+ const name = `${command} installed`;
130
+ onStart(name);
131
+ const found = deps.which(command);
132
+ if (!found)
133
+ missing.add(command);
134
+ return report(found ? { name, status: 'ok' } : { name, status: 'fail', detail: 'not found on PATH', fix: installFix[command] });
135
+ };
136
+ const repos = config.repos.map((repo) => resolveRepoConfig(config, repo));
137
+ const orchestrator = config.profiles[config.orchestratorProfile];
138
+ const rolesOf = (repo) => [repo.agents.author, repo.agents.reviewer, ...(repo.agents.leadReviewer ? [repo.agents.leadReviewer] : [])];
139
+ const profiles = [orchestrator, ...repos.flatMap((repo) => rolesOf(repo).map((agent) => config.profiles[agent.profile]))];
140
+ // Only a path that does not fit is reported: Carrier works without the socket, it just has none for the app to use.
141
+ const socket = enginePlacesOf(folder).socket;
142
+ const { fits, bytes } = socketPathFits(socket);
143
+ if (fits === false)
144
+ report({ name: 'control socket path fits', status: 'warn', detail: `${socket} is ${bytes} bytes and a socket path holds 103, so Carrier starts without its control socket`, fix: fixes.socketPath });
145
+ const hasGit = installed('git');
146
+ const hasGh = installed('gh');
147
+ for (const harness of new Set(repos.flatMap((repo) => rolesOf(repo).map((agent) => agent.harness))))
148
+ installed(harness);
149
+ const hasForge = profiles.some((profile) => profile.kind === 'forge') ? installed('agent-forge') : true;
150
+ // A local model needs its server up and the model downloaded; one check per distinct server and model.
151
+ const locals = new Map();
152
+ for (const repo of repos)
153
+ for (const { provider, model } of rolesOf(repo))
154
+ if (provider)
155
+ locals.set(`${provider}|${model}`, { provider, model });
156
+ for (const { provider, model } of locals.values()) {
157
+ const name = `${localLabel[provider]} has ${model}`;
158
+ onStart(name);
159
+ try {
160
+ const have = await deps.localModels(provider);
161
+ report(have.includes(model) ? { name, status: 'ok' } : { name, status: 'fail', detail: `not downloaded there (it has: ${have.join(', ') || 'nothing'})`, fix: fixes.modelMissing(model) });
162
+ }
163
+ catch (error) {
164
+ log.error('a local model server did not answer', error, { provider });
165
+ report({ name, status: 'fail', detail: `${localLabel[provider]} is not answering at ${localUrl[provider]}; start its server`, fix: fixes.serverDown });
166
+ }
167
+ }
168
+ // Every Claude and Codex provider, whether a role uses it or not: a login that has run out shows here before an epic needs it.
169
+ // A role's provider that is not logged in fails; any other only warns. Antigravity has no folder and a behaviour test instead.
170
+ const roleProviders = new Set(repos.flatMap((repo) => rolesOf(repo).flatMap((agent) => [agent.option, agent.providerName].filter((v) => v !== undefined))));
171
+ for (const [provider, { harness, configDir }] of Object.entries(config.providers)) {
172
+ if (missing.has(harness))
173
+ continue;
174
+ if (harness === 'agy') {
175
+ const name = `agy provider ${provider} is logged in`;
176
+ onStart(name);
177
+ try {
178
+ const login = await agyLogin(deps);
179
+ if (login === 'in')
180
+ report({ name, status: 'ok' });
181
+ else if (login === 'out')
182
+ report({ name, status: roleProviders.has(provider) ? 'fail' : 'warn', detail: 'not logged in', fix: fixes.login('agy', undefined) });
183
+ else
184
+ report({ name, status: 'warn', detail: 'could not tell whether agy is logged in (it neither answered nor asked for a login in time)' });
185
+ }
186
+ catch (error) {
187
+ log.error('could not run agy to check the login', error, { provider });
188
+ report({ name, status: 'warn', detail: 'could not run agy to check the login', fix: installFix.agy });
189
+ }
190
+ continue;
191
+ }
192
+ if (harness !== 'claude' && harness !== 'codex')
193
+ continue;
194
+ const name = `${harness} provider ${provider} is logged in`;
195
+ const status = roleProviders.has(provider) ? 'fail' : 'warn';
196
+ const where = configDir ? `at ${configDir}` : 'in the environment Carrier runs in';
197
+ onStart(name);
198
+ try {
199
+ report((await deps.loggedIn(harness, configDir)) ? { name, status: 'ok' } : { name, status, detail: `not logged in ${where}`, fix: fixes.login(harness, configDir) });
200
+ }
201
+ catch (error) {
202
+ log.error(`could not run ${harness} to check the login`, error, { provider });
203
+ report({ name, status, detail: `could not run ${harness} to check the login ${where}`, fix: installFix[harness] });
204
+ }
205
+ }
206
+ // The schema compares profile names; two names can still point at the same bot or the same login. A shared identity allows it.
207
+ if (config.identities === 'separate') {
208
+ onStart('author and reviewer are different identities');
209
+ const clashes = repos.flatMap((repo) => {
210
+ const author = config.profiles[repo.agents.author.profile];
211
+ const reviewer = config.profiles[repo.agents.reviewer.profile];
212
+ const lead = repo.agents.leadReviewer ? config.profiles[repo.agents.leadReviewer.profile] : undefined;
213
+ const issues = [];
214
+ if (sameAccount(author, reviewer))
215
+ issues.push(`${repo.name}: author and reviewer both use ${identity(author)}`);
216
+ if (lead && sameAccount(author, lead))
217
+ issues.push(`${repo.name}: author and lead reviewer both use ${identity(author)}`);
218
+ return issues;
219
+ });
220
+ report(clashes.length === 0 ? { name: 'author and reviewer are different identities', status: 'ok' } : { name: 'author and reviewer are different identities', status: 'fail', detail: clashes.join('; '), fix: fixes.clash });
221
+ }
222
+ // Without gh, or without agent-forge for a forge profile, the checks below would only repeat the same failure.
223
+ const usable = (profile) => hasGit && hasGh && (profile.kind === 'gh' || hasForge);
224
+ const minted = new Set();
225
+ for (const repo of repos) {
226
+ for (const agent of rolesOf(repo)) {
227
+ const profile = config.profiles[agent.profile];
228
+ const key = `${identity(profile)}|${repo.name}`;
229
+ if (minted.has(key) || !usable(profile))
230
+ continue;
231
+ minted.add(key);
232
+ const name = `${identity(profile)} can mint a token for ${repo.name}`;
233
+ onStart(name);
234
+ try {
235
+ await deps.mint(profile, repo.name);
236
+ report({ name, status: 'ok' });
237
+ }
238
+ catch (error) {
239
+ report({ name, status: 'fail', detail: message(error), fix: fixes.mint(profile, repo.name, message(error)) });
240
+ }
241
+ }
242
+ if (!usable(orchestrator))
243
+ continue;
244
+ const name = `${identity(orchestrator)} can read ${repo.name}`;
245
+ onStart(name);
246
+ try {
247
+ await deps.listLabels(config, repo.name, folder);
248
+ report({ name, status: 'ok' });
249
+ }
250
+ catch (error) {
251
+ report({ name, status: 'fail', detail: message(error), fix: fixes.read(repo.name, message(error)) });
252
+ }
253
+ }
254
+ return checks;
255
+ }
@@ -0,0 +1,35 @@
1
+ import { configDirVariable } from '../harnesses/names.js';
2
+ // Every remedy lives here. They only repeat what a check already knows (names, owners): nothing from a child's output.
3
+ export const installFix = {
4
+ git: 'install git (xcode-select --install on macOS)',
5
+ gh: 'install the GitHub CLI (https://cli.github.com) and run: gh auth login',
6
+ claude: 'install Claude Code (https://claude.com/claude-code) and sign in once',
7
+ codex: 'install the Codex CLI and run: codex login',
8
+ agy: 'install Antigravity and run agy once to sign in',
9
+ 'agent-forge': 'install it: pnpm add -g @ashulab/agent-forge (https://github.com/AshuLab/agent-forge)',
10
+ };
11
+ export const fixes = {
12
+ mint(profile, repo, detail) {
13
+ if (profile.kind === 'gh')
14
+ return 'run: gh auth login';
15
+ if (/agent not found/i.test(detail))
16
+ return 'agent-forge --list shows your agents; add one with: agent-forge add';
17
+ if (/installation/i.test(detail))
18
+ return `install the GitHub App of that agent on ${repo.split('/')[0]}`;
19
+ if (/private key/i.test(detail))
20
+ return 'check privateKeyPath of that agent in your Forge registry';
21
+ return `run: agent-forge token --agent ${profile.agent} from a clone of the repo to see the full error`;
22
+ },
23
+ read(repo, detail) {
24
+ if (detail.includes('404'))
25
+ return `the repo does not exist or this identity cannot see it: install the App on ${repo.split('/')[0]} and give it access to ${repo}`;
26
+ if (detail.includes('401'))
27
+ return "the identity's credentials were rejected: for gh run gh auth login, for a bot check its App in Forge";
28
+ return undefined;
29
+ },
30
+ login: (harness, configDir) => (harness === 'agy' ? 'log in with: agy' : `log in with: ${configDir ? `${configDirVariable[harness]}=${configDir} ` : ''}${harness === 'claude' ? 'claude /login' : 'codex login'}`),
31
+ socketPath: 'move the Carrier folder to a shorter path (CARRIER_HOME, at most 77 bytes)',
32
+ clash: 'use two different bots (agent-forge add) or one bot and your gh login, or set identities to "shared" to use one account for both',
33
+ serverDown: 'start the server (LM Studio: lms server start, Ollama: ollama serve) and download the model',
34
+ modelMissing: (model) => `download it in LM Studio / ollama pull ${model}`,
35
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { z } from 'zod';
2
+ const Sha = z.string().regex(/^[0-9a-f]{40}$/);
3
+ const Pr = z.object({ number: z.number().int().positive(), headSha: Sha });
4
+ const Id = z.string().min(1).max(64);
5
+ const MAX_LIST = 50; // the same cap the `epic` reply puts on a round's blockers
6
+ const Resolved = z.array(Id).max(MAX_LIST).optional();
7
+ const Finding = z.object({ id: Id.optional(), title: z.string().min(1), url: z.string().url().optional() });
8
+ const distinct = (ids) => new Set(ids).size === ids.length;
9
+ const Common = {
10
+ executionId: z.string().min(1),
11
+ repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/),
12
+ epic: z.number().int().positive(),
13
+ stage: z.enum(['ship', 'review', 'fix']),
14
+ summary: z.string().min(1),
15
+ };
16
+ export const CallerResult = z.discriminatedUnion('outcome', [
17
+ z.object({
18
+ ...Common,
19
+ outcome: z.literal('completed'),
20
+ pr: Pr,
21
+ verification: z.object({ ci: z.enum(['passed', 'absent']), evidence: z.string().min(1) }),
22
+ /** Reviewer only: ids of earlier blockers it considers fixed. Optional, so results that predate it still validate. */
23
+ resolved: Resolved,
24
+ /** Reviewer only: findings that do not stop approval (nitpicks, suggestions). Same shape as a blocker; optional. */
25
+ observations: z.array(Finding).max(MAX_LIST).optional(),
26
+ }).refine((r) => distinct(r.resolved ?? []), { message: 'resolved has a duplicate id' })
27
+ .refine((r) => distinct((r.observations ?? []).flatMap(({ id }) => id ?? [])), { message: 'two observations share an id' }),
28
+ z.object({
29
+ ...Common,
30
+ outcome: z.literal('changes_requested'),
31
+ stage: z.literal('review'),
32
+ pr: Pr,
33
+ // `id` is stable across rounds: the reviewer reuses it when a finding persists. Optional, like `resolved`.
34
+ blockers: z.array(Finding).min(1).max(MAX_LIST),
35
+ resolved: Resolved,
36
+ }).refine((r) => distinct(r.blockers.flatMap(({ id }) => id ?? [])), { message: 'two blockers share an id' })
37
+ .refine((r) => distinct([...r.blockers.flatMap(({ id }) => id ?? []), ...(r.resolved ?? [])]), { message: 'resolved has a duplicate id or one that is still a blocker' }),
38
+ z.object({ ...Common, outcome: z.literal('needs_human'), reason: z.string().min(1) }),
39
+ ]);
40
+ /** `raw` is the file content, or null when the caller left no file. */
41
+ export function parseCallerResult(raw, expected) {
42
+ if (raw === null)
43
+ return { ok: false, kind: 'invalid', message: 'result file is missing' };
44
+ let json;
45
+ try {
46
+ json = JSON.parse(raw);
47
+ }
48
+ catch {
49
+ return { ok: false, kind: 'invalid', message: 'result is not valid JSON' };
50
+ }
51
+ const parsed = CallerResult.safeParse(json);
52
+ if (!parsed.success)
53
+ return { ok: false, kind: 'invalid', message: z.prettifyError(parsed.error) };
54
+ const result = parsed.data;
55
+ for (const key of ['executionId', 'repo', 'epic', 'stage']) {
56
+ if (result[key] !== expected[key]) {
57
+ return { ok: false, kind: 'mismatch', message: `${key} is ${result[key]}, expected ${expected[key]}` };
58
+ }
59
+ }
60
+ return { ok: true, result };
61
+ }
@@ -0,0 +1,19 @@
1
+ import { redact } from '../logging/redact.js';
2
+ export const createEventState = () => ({ calls: new Map(), tasks: new Map(), lanes: new Map(), text: new Map() });
3
+ // Characters, not bytes.
4
+ export const caps = { arg: 1024, summary: 200, text: 4096, output: 4096, diffLines: 200, diffLine: 200 };
5
+ /** Redacts first, so a secret is never cut in a way that hides its shape, then cuts. */
6
+ export function clean(text, max) {
7
+ const safe = redact(text);
8
+ return safe.length <= max ? safe : `${safe.slice(0, max - 1)}…`;
9
+ }
10
+ /** A command's result: all of it when small, else the first and last half of the budget around a gap. */
11
+ export function boundOutput(text) {
12
+ const safe = redact(text).trimEnd();
13
+ if (safe === '')
14
+ return [];
15
+ if (safe.length <= caps.output)
16
+ return safe.split('\n');
17
+ const half = Math.floor((caps.output - 3) / 2); // the gap line and its two newlines count too
18
+ return [...safe.slice(0, half).split('\n'), '…', ...safe.slice(-half).split('\n')];
19
+ }
@@ -0,0 +1,3 @@
1
+ import { redact } from '../logging/redact.js';
2
+ /** Redacted first, then one line, then cut: in that order, so a token cut in half cannot slip through. */
3
+ export const failureDetail = (text) => redact(text).replace(/\s+/g, ' ').trim().slice(0, 300);
@@ -0,0 +1,82 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ const crash = { kind: 'crashed', detail: 'exit code 1', exitCode: 1, signal: null, hostFreeMemMb: 8000, hostLoad: 1.5 };
4
+ export class FakeAgentRunner {
5
+ tasks = [];
6
+ stopped = [];
7
+ /** Executions alive: the running ones, plus whatever a test adds to play a previous process's orphans. */
8
+ alive = new Set();
9
+ /** What each run does; by default it hangs, so a test sees the execution while it is running. */
10
+ behavior = () => ({ exit: 'hang' });
11
+ eventSinks = new Map();
12
+ /** Delivers a batch of events from a run later on, when the test decides. */
13
+ emit(executionId, events) {
14
+ for (const cb of this.eventSinks.get(executionId) ?? [])
15
+ cb(events);
16
+ }
17
+ run(task) {
18
+ this.tasks.push(task);
19
+ this.alive.add(task.executionId);
20
+ const behavior = this.behavior(task);
21
+ const { sessionId, output = [], events = [], write, exit = 'ok', when } = behavior;
22
+ const failure = 'failure' in behavior ? behavior.failure : exit === 'failed' ? crash : undefined;
23
+ const sessions = [];
24
+ const outputs = [];
25
+ const eventSinks = [];
26
+ this.eventSinks.set(task.executionId, eventSinks);
27
+ let resolveDone;
28
+ const finish = (result) => {
29
+ this.alive.delete(task.executionId);
30
+ resolveDone(result);
31
+ };
32
+ const done = new Promise((resolve) => (resolveDone = resolve));
33
+ // Runs after the caller registered its callbacks.
34
+ queueMicrotask(() => {
35
+ if (sessionId)
36
+ for (const cb of sessions)
37
+ cb(sessionId);
38
+ for (const [stream, chunk] of output)
39
+ for (const cb of outputs)
40
+ cb(stream, chunk);
41
+ for (const batch of events)
42
+ for (const cb of eventSinks)
43
+ cb(batch);
44
+ if (write !== undefined) {
45
+ mkdirSync(dirname(task.resultPath), { recursive: true });
46
+ writeFileSync(task.resultPath, typeof write === 'string' ? write : JSON.stringify(write));
47
+ }
48
+ if (when && exit !== 'hang')
49
+ void when.then(() => finish({ exit, sessionId, failure }));
50
+ else if (exit !== 'hang')
51
+ finish({ exit, sessionId, failure });
52
+ });
53
+ return {
54
+ onSession: (cb) => void sessions.push(cb),
55
+ onOutput: (cb) => void outputs.push(cb),
56
+ onEvent: (cb) => void eventSinks.push(cb),
57
+ done,
58
+ stop: async () => {
59
+ this.stopped.push(task.executionId);
60
+ finish({ exit: 'stopped', sessionId });
61
+ },
62
+ };
63
+ }
64
+ async listActive() {
65
+ return [...this.alive];
66
+ }
67
+ async stop(executionId) {
68
+ this.stopped.push(executionId);
69
+ this.alive.delete(executionId);
70
+ }
71
+ }
72
+ export class FakeWorkspaces {
73
+ async prepareAuthor(repo, epic) {
74
+ return `/work/${repo}/${epic}/author`;
75
+ }
76
+ async prepareReviewer(repo, epic, pr) {
77
+ return `/work/${repo}/${epic}/reviewer-${pr}`;
78
+ }
79
+ async findWorktree(repo, branch) {
80
+ return `/work/${repo}/worktrees/${branch}`;
81
+ }
82
+ }
@@ -0,0 +1,136 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, mkdtemp, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { repoNamePattern } from '../config/schema.js';
8
+ const execFileAsync = promisify(execFile);
9
+ const defaultRemoteUrl = (repo) => `https://github.com/${repo}.git`;
10
+ async function git(args, opts = {}) {
11
+ try {
12
+ const { stdout } = await execFileAsync('git', args, {
13
+ cwd: opts.cwd,
14
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...opts.env },
15
+ timeout: opts.timeoutMs,
16
+ maxBuffer: 16 * 1024 * 1024,
17
+ encoding: 'utf8',
18
+ });
19
+ return stdout.trim();
20
+ }
21
+ catch (error) {
22
+ // execFile's own message repeats the argv; the credentials only travel in the environment, which it never prints.
23
+ const failed = error;
24
+ throw new Error(`git ${args[0]} failed: ${(failed.stderr || failed.message).trim().slice(0, 500)}`);
25
+ }
26
+ }
27
+ /**
28
+ * Token of a GitHub profile. Forge picks the App installation from the `origin` of the cwd, so it runs in a
29
+ * throwaway repo pointing at the target: that works before the clone exists, too.
30
+ */
31
+ export function profileToken(profile, remoteUrl = defaultRemoteUrl) {
32
+ return async (repo) => {
33
+ if (profile.kind === 'gh')
34
+ return (await execFileAsync('gh', ['auth', 'token'], { encoding: 'utf8' })).stdout.trim();
35
+ const scratch = await mkdtemp(join(tmpdir(), 'carrier-token-'));
36
+ try {
37
+ await git(['init', '-q'], { cwd: scratch });
38
+ await git(['remote', 'add', 'origin', remoteUrl(repo)], { cwd: scratch });
39
+ return (await execFileAsync('agent-forge', ['token', '--agent', profile.agent], { cwd: scratch, encoding: 'utf8' })).stdout.trim();
40
+ }
41
+ catch (error) {
42
+ // Never echo the child's output or argv beyond this: they are the only place a token could appear.
43
+ throw new Error(`could not mint a token for ${profile.kind === 'forge' ? profile.agent : 'gh'}: ${error.stderr?.trim().slice(0, 300) ?? 'failed'}`);
44
+ }
45
+ finally {
46
+ await rm(scratch, { recursive: true, force: true });
47
+ }
48
+ };
49
+ }
50
+ export class GitWorkspaces {
51
+ places;
52
+ token;
53
+ remoteUrl;
54
+ reviewRef;
55
+ timeoutMs;
56
+ queues = new Map();
57
+ constructor(options) {
58
+ this.places = options.places;
59
+ this.token = options.token;
60
+ this.remoteUrl = options.remoteUrl ?? defaultRemoteUrl;
61
+ this.reviewRef = options.reviewRef ?? ((pr) => `refs/pull/${pr}/head`);
62
+ this.timeoutMs = options.timeoutMs ?? 10 * 60_000;
63
+ }
64
+ clonePath(repo) {
65
+ return join(this.places.repos, repo);
66
+ }
67
+ /** One git operation at a time per repo: concurrent fetches and worktree adds on one clone collide on its locks. */
68
+ serial(repo, work) {
69
+ const run = (this.queues.get(repo) ?? Promise.resolve()).catch(() => { }).then(work);
70
+ this.queues.set(repo, run.catch(() => { }));
71
+ return run;
72
+ }
73
+ /** Git over the network with the profile's token in the environment (not in argv, not in any config file). */
74
+ async remote(repo, args, cwd) {
75
+ const env = {};
76
+ if (this.token) {
77
+ const basic = Buffer.from(`x-access-token:${await this.token(repo)}`).toString('base64');
78
+ Object.assign(env, { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.extraHeader', GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}` });
79
+ }
80
+ return git(args, { cwd, env, timeoutMs: this.timeoutMs });
81
+ }
82
+ /** Clones or fetches; returns the clone. Callers hold the repo's queue. */
83
+ async ensureClone(repo) {
84
+ if (!repoNamePattern.test(repo))
85
+ throw new Error(`invalid repo "${repo}", expected owner/name`);
86
+ const path = this.clonePath(repo);
87
+ if (existsSync(join(path, '.git')))
88
+ await this.remote(repo, ['fetch', '--prune', 'origin'], path);
89
+ else {
90
+ await mkdir(dirname(path), { recursive: true });
91
+ await this.remote(repo, ['clone', this.remoteUrl(repo), path]);
92
+ }
93
+ return path;
94
+ }
95
+ /** The clone, up to date. `ship` runs here and puts the epic's worktree where the repo's solve.md says. */
96
+ async prepareAuthor(repo, epic) {
97
+ assertCount(epic, 'epic');
98
+ return this.serial(repo, () => this.ensureClone(repo));
99
+ }
100
+ async findWorktree(repo, branch) {
101
+ return this.serial(repo, async () => {
102
+ const list = await git(['worktree', 'list', '--porcelain'], { cwd: this.clonePath(repo) });
103
+ // One block per worktree: `worktree <path>`, then `branch refs/heads/<name>` unless it is detached.
104
+ // The first is the clone itself: other epics of the repo share it, so an epic that was not isolated has no workspace.
105
+ for (const block of list.split('\n\n').slice(1)) {
106
+ const lines = block.split('\n');
107
+ if (lines.includes(`branch refs/heads/${branch}`))
108
+ return lines.find((l) => l.startsWith('worktree ')).slice('worktree '.length);
109
+ }
110
+ return null;
111
+ });
112
+ }
113
+ /** A checkout detached at the PR's head, apart from the author's and from Solve's `worktrees/`; each round re-points it. */
114
+ async prepareReviewer(repo, epic, pr) {
115
+ assertCount(epic, 'epic');
116
+ assertCount(pr, 'pr');
117
+ return this.serial(repo, async () => {
118
+ const clone = await this.ensureClone(repo);
119
+ await this.remote(repo, ['fetch', 'origin', this.reviewRef(pr)], clone);
120
+ const sha = await git(['rev-parse', '--verify', 'FETCH_HEAD^{commit}'], { cwd: clone });
121
+ const path = join(this.places.reviews, repo.split('/')[1], String(epic));
122
+ if (existsSync(join(path, '.git')))
123
+ await git(['switch', '--detach', sha], { cwd: path });
124
+ else {
125
+ await mkdir(dirname(path), { recursive: true });
126
+ await git(['worktree', 'prune'], { cwd: clone });
127
+ await git(['worktree', 'add', '--detach', path, sha], { cwd: clone });
128
+ }
129
+ return path;
130
+ });
131
+ }
132
+ }
133
+ function assertCount(value, name) {
134
+ if (!Number.isSafeInteger(value) || value <= 0)
135
+ throw new Error(`invalid ${name} ${value}`);
136
+ }
@@ -0,0 +1,23 @@
1
+ import { callerResults } from '../storage/caller-results.js';
2
+ import { parseCallerResult } from './caller-result.js';
3
+ /**
4
+ * Validates the result, stores it and runs `transition` in one transaction: if `transition` throws,
5
+ * neither is kept. Importing the same executionId again changes nothing and skips `transition`.
6
+ */
7
+ export function importResult(db, raw, expected, transition) {
8
+ const parsed = parseCallerResult(raw, expected);
9
+ if (!parsed.ok)
10
+ return parsed;
11
+ return db.transaction((tx) => {
12
+ const { result } = parsed;
13
+ const inserted = tx
14
+ .insert(callerResults)
15
+ .values({ executionId: result.executionId, outcome: result.outcome, result: JSON.stringify(result) })
16
+ .onConflictDoNothing()
17
+ .run();
18
+ if (inserted.changes === 0)
19
+ return { ...parsed, duplicate: true };
20
+ transition(tx, result);
21
+ return parsed;
22
+ });
23
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ // "45s", "3m 05s", "2h 03m", "1d 4h": the two largest units, for the panel and anything else that shows elapsed time.
2
+ export function formatDuration(ms) {
3
+ const s = Math.floor(Math.max(0, ms) / 1000);
4
+ const [d, h, m] = [Math.floor(s / 86_400), Math.floor((s % 86_400) / 3600), Math.floor((s % 3600) / 60)];
5
+ if (d > 0)
6
+ return `${d}d ${h}h`;
7
+ if (h > 0)
8
+ return `${h}h ${String(m).padStart(2, '0')}m`;
9
+ if (m > 0)
10
+ return `${m}m ${String(s % 60).padStart(2, '0')}s`;
11
+ return `${s}s`;
12
+ }