@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,61 @@
1
+ import { isAbsolute } from 'node:path';
2
+ import { localKinds, localLabel } from '../../../harnesses/local.js';
3
+ import { harnessLabel, localProvidersOf, nativeProvider } from '../../../harnesses/names.js';
4
+ import { tilde } from '../summary.js';
5
+ // Aliases the Claude CLI resolves to the latest model of each family, so they do not go stale like a model id does.
6
+ const claudeModels = [
7
+ { value: 'sonnet', label: 'sonnet', hint: 'latest Sonnet' },
8
+ { value: 'opus', label: 'opus', hint: 'latest Opus' },
9
+ { value: 'fable', label: 'fable', hint: 'latest Fable' },
10
+ ];
11
+ const otherModel = '\0other';
12
+ const native = '\0native';
13
+ const inherit = '\0inherit';
14
+ const typedPath = '\0path';
15
+ /** The config folder of a Claude or Codex option: one it found, the environment, or a path. It holds the login, so each is shown with its state. Undefined inherits the environment. */
16
+ export async function askConfigDir(ui, harness, deps, noun = 'option') {
17
+ const state = (dir) => deps.loggedIn(harness, dir).then((ok) => (ok ? 'logged in' : 'not logged in'), () => 'could not check the login');
18
+ const found = await Promise.all(deps.configFolders(harness).map(async (dir) => ({ value: dir, label: tilde(dir), hint: await state(dir) })));
19
+ const choice = await ui.select({
20
+ message: `Config folder of this ${harnessLabel[harness]} ${noun}`,
21
+ options: [...found, { value: inherit, label: 'Inherit the environment', hint: `the login Carrier runs in: ${await state()}` }, { value: typedPath, label: 'Type a path…' }],
22
+ initialValue: inherit,
23
+ });
24
+ if (choice === inherit)
25
+ return undefined;
26
+ if (choice !== typedPath)
27
+ return tilde(choice);
28
+ const typed = await ui.text({ message: `Config folder of this ${harnessLabel[harness]} ${noun} (absolute, or starting with ~/)`, validate: (v) => (isAbsolute(v.trim()) || v.trim().startsWith('~/') ? undefined : 'An absolute path, or one starting with ~/') });
29
+ return typed.trim();
30
+ }
31
+ /** Ask the model for a role (author or reviewer) on a specific harness. */
32
+ export async function askModel(ui, harness, provider, locals, deps, role, current) {
33
+ const typed = async (suggestion) => (await ui.text({ message: `Model for the ${role}`, placeholder: suggestion ?? 'model name', defaultValue: suggestion, validate: (v) => (v.trim() ? undefined : 'Required') })).trim();
34
+ const known = provider ? (locals[provider] ?? []).map((value) => ({ value, label: value })) : harness === 'claude' ? claudeModels : harness === 'codex' ? deps.codexModels() : deps.agyModels();
35
+ const configured = !provider && harness === 'codex' ? deps.codexModel() : undefined;
36
+ if (known.length === 0)
37
+ return typed(current ?? configured);
38
+ const initial = [current, configured, 'sonnet'].find((m) => m !== undefined && known.some((k) => k.value === m)) ?? known[0].value;
39
+ const choice = await ui.select({ message: `Model for the ${role}`, options: [...known, { value: otherModel, label: 'Other…', hint: 'type a model id' }], initialValue: initial });
40
+ return choice === otherModel ? await typed() : choice;
41
+ }
42
+ /** Creates a pure AI provider (harness, configDir, local provider) without a model. */
43
+ export async function askAIProvider(ui, installed, used, locals, deps) {
44
+ const harness = installed.length === 1 ? installed[0] : await ui.select({ message: 'Which agent CLI runs this provider?', options: installed.map((p) => ({ value: p, label: harnessLabel[p] })), initialValue: installed.find((h) => !used.includes(h)) ?? installed[0] });
45
+ let provider;
46
+ const servers = localKinds.filter((kind) => localProvidersOf[harness].includes(kind));
47
+ if (servers.some((kind) => locals[kind])) {
48
+ const choice = await ui.select({
49
+ message: 'Where does this provider run?',
50
+ options: [
51
+ { value: native, label: nativeProvider[harness], hint: 'cloud' },
52
+ ...servers.map((kind) => ({ value: kind, label: `${localLabel[kind]} (local)`, hint: locals[kind] ? `${locals[kind].length} models, runs on this machine` : 'not running: start its server to use it', disabled: !locals[kind] })),
53
+ ],
54
+ initialValue: native,
55
+ });
56
+ if (choice !== native)
57
+ provider = servers.find((kind) => kind === choice);
58
+ }
59
+ const configDir = harness === 'agy' ? undefined : await askConfigDir(ui, harness, deps, 'provider');
60
+ return { harness, ...(provider && { provider }), ...(configDir && { configDir }) };
61
+ }
@@ -0,0 +1,28 @@
1
+ const reach = 'Both roles, the reviewer included, act with everything your gh token can do, as you. Their signature in what they publish is what tells them apart.';
2
+ /** Separate identities (a different one per role) or one shared identity, which is the gh login. The first question of the identity step. */
3
+ export async function askIdentities(ui, flags, login, interactive, current = 'separate') {
4
+ const { identities: flag } = flags;
5
+ if (flag !== undefined && flag !== 'separate' && flag !== 'shared')
6
+ throw new Error(`invalid --identities "${flag}", expected separate or shared`);
7
+ const profiles = flags.authorProfile !== undefined || flags.reviewerProfile !== undefined;
8
+ if (flag === 'shared' && profiles)
9
+ throw new Error('--identities shared cannot go with --author-profile or --reviewer-profile: the shared identity is the gh login');
10
+ for (;;) {
11
+ // A profile flag already names one identity per role, so there is nothing to ask.
12
+ const choice = flag ?? (interactive && !profiles ? await ui.select({
13
+ message: 'GitHub identities: one for each role, or one shared?',
14
+ options: [
15
+ { value: 'separate', label: 'One for each role', hint: 'Agent Forge bots, or your gh login for one of them' },
16
+ { value: 'shared', label: 'One shared identity', hint: 'your gh login for both, when you cannot get a second one' },
17
+ ],
18
+ initialValue: current,
19
+ }) : 'separate');
20
+ if (choice === 'separate')
21
+ return choice;
22
+ if (login === undefined)
23
+ throw new Error('A shared identity is your gh login, and gh is not logged in. Run: gh auth login');
24
+ ui.note(reach, 'Shared identity');
25
+ if (flag !== undefined || (await ui.confirm('Use your gh login for both roles?')))
26
+ return choice; // the flag is the consent
27
+ }
28
+ }
@@ -0,0 +1,30 @@
1
+ import { identityText, parseIdentity } from '../build-config.js';
2
+ const addBot = '\0add';
3
+ /** Which GitHub identity a role acts as: a Forge bot or the person's own gh login (never the same for both roles). */
4
+ export async function askIdentity(ui, role, forge, readForge, login, taken, deps, interactive, current) {
5
+ for (;;) {
6
+ const options = [
7
+ ...forge.agents.map((a) => ({ value: `forge:${a.name}`, label: a.name, hint: a.label && a.label !== a.name ? a.label : 'Agent Forge bot' })),
8
+ { value: 'gh', label: 'My own gh login', hint: login },
9
+ ].filter((o) => !taken.some((t) => identityText(t) === o.value));
10
+ const canAdd = interactive && deps.which('agent-forge');
11
+ if (options.length === 0 && !canAdd) {
12
+ throw new Error('Identities need to differ and there is nothing left to pick. Add a bot with "agent-forge add" (https://github.com/AshuLab/agent-forge) and run this again.');
13
+ }
14
+ if (options.length === 1 && !canAdd) {
15
+ ui.note(`${options[0].label}${options[0].hint ? ` (${options[0].hint})` : ''} — the only identity left`, `GitHub identity for the ${role}`);
16
+ return parseIdentity(options[0].value);
17
+ }
18
+ const initialValue = options.find((o) => current && o.value === identityText(current))?.value ?? options[0]?.value;
19
+ const picked = await ui.select({
20
+ message: `GitHub identity for the ${role}`,
21
+ options: canAdd ? [...options, { value: addBot, label: 'Add a new Agent Forge bot…', hint: 'runs: agent-forge add' }] : options,
22
+ initialValue,
23
+ });
24
+ if (picked !== addBot)
25
+ return parseIdentity(picked);
26
+ if (!deps.forgeAdd())
27
+ ui.warn('agent-forge add did not finish; nothing was added.');
28
+ readForge(); // the new bot, if any, is in the list the next time around
29
+ }
30
+ }
@@ -0,0 +1,93 @@
1
+ import { basename } from 'node:path';
2
+ import { optionNamePattern } from '../../../config/schema.js';
3
+ import { parseHarnessModel, parseProvider } from '../build-config.js';
4
+ import { providerLine } from '../summary.js';
5
+ import { askAIProvider } from './agent.js';
6
+ const uniqueName = (items, base) => {
7
+ let name = base;
8
+ for (let n = 2; name in items; n += 1)
9
+ name = `${base}-${n}`;
10
+ return name;
11
+ };
12
+ /** Turn a user-typed name into a valid option code: lowercase, hyphens instead of spaces/symbols, no leading/trailing hyphens. */
13
+ export function normalizeOptionName(raw) {
14
+ return raw
15
+ .trim()
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9-]+/g, '-')
18
+ .replace(/-+/g, '-')
19
+ .replace(/^-+|-+$/g, '')
20
+ .slice(0, 40)
21
+ .replace(/-+$/, '');
22
+ }
23
+ export function suggestProviderName(providers, provider) {
24
+ const folder = (provider.configDir ? basename(provider.configDir) : '').replace(/^\.+/, '');
25
+ const raw = folder === '' ? provider.harness : folder.startsWith(provider.harness) ? folder : `${provider.harness}-${folder}`;
26
+ const slug = normalizeOptionName(raw).slice(0, 30);
27
+ return uniqueName(providers, optionNamePattern.test(slug) ? slug : provider.harness);
28
+ }
29
+ /** Pure AI providers section: CLI, config folder, and local provider. No models. */
30
+ export async function askProviders(ui, start, installed, locals, deps) {
31
+ const there = Object.entries(start).map(([name, provider]) => `${name}: ${providerLine(provider)}`);
32
+ ui.note(['Your AI accounts and local model servers (Claude Code, Codex, Antigravity). Each role will pick one.', ...(there.length > 0 ? ['', 'Already there:', ...there] : [])].join('\n'), 'AI Providers');
33
+ let providers = start;
34
+ while (Object.keys(providers).length === 0 || (await ui.confirm('Add another AI provider?'))) {
35
+ const provider = await askAIProvider(ui, installed, Object.values(providers).map((p) => p.harness), locals, deps);
36
+ const raw = await ui.text({
37
+ message: 'Name of this provider',
38
+ defaultValue: suggestProviderName(providers, provider),
39
+ validate: (v) => {
40
+ const slug = normalizeOptionName(v);
41
+ if (!optionNamePattern.test(slug))
42
+ return 'A name starting with a letter: letters, digits and hyphens, up to 40 characters';
43
+ if (slug in providers)
44
+ return 'Another provider has this name';
45
+ return undefined;
46
+ },
47
+ });
48
+ const name = normalizeOptionName(raw);
49
+ providers = { ...providers, [name]: provider };
50
+ }
51
+ return providers;
52
+ }
53
+ export async function askRoleProvider(ui, role, providers, current, other) {
54
+ const names = Object.keys(providers);
55
+ if (names.length === 1)
56
+ return names[0];
57
+ return ui.select({
58
+ message: `Which AI provider runs the ${role}?`,
59
+ options: names.map((name) => ({ value: name, label: name, hint: providerLine(providers[name]) })),
60
+ initialValue: [current, names.find((name) => name !== other)].find((name) => name !== undefined && name in providers) ?? names[0],
61
+ });
62
+ }
63
+ /** The provider and model a `--author` or `--reviewer` flag declares: reuses an existing matching provider, or adds a new one. */
64
+ export function declareFlaggedProvider(providers, role, flag, providerFlag) {
65
+ const { harness, model } = parseHarnessModel(flag);
66
+ const localProvider = providerFlag ? parseProvider(providerFlag) : undefined;
67
+ const existing = Object.keys(providers).find((name) => providers[name].harness === harness && providers[name].provider === localProvider);
68
+ if (existing !== undefined)
69
+ return { providers, providerName: existing, model };
70
+ const name = uniqueName(providers, harness in providers ? `${harness}-${role}` : harness);
71
+ return { providers: { ...providers, [name]: { harness, ...(localProvider && { provider: localProvider }) } }, providerName: name, model };
72
+ }
73
+ const done = '\0done';
74
+ /** With two or more providers: whether to move a role to another provider when its credits run out or errors occur, and in which order. */
75
+ export async function askFailover(ui, providers) {
76
+ const names = Object.keys(providers);
77
+ if (names.length < 2 || !(await ui.confirm('Turn failover on? A role that runs out of credits then moves to the next provider you list, so its code can reach another provider.')))
78
+ return undefined;
79
+ const order = [];
80
+ while (order.length < names.length) {
81
+ const pick = await ui.select({
82
+ message: `Failover order: which provider goes ${order.length === 0 ? 'first' : 'next'}?`,
83
+ options: [
84
+ ...names.filter((name) => !order.includes(name)).map((name) => ({ value: name, label: name, hint: providerLine(providers[name]) })),
85
+ ...(order.length > 0 ? [{ value: done, label: 'That is all' }] : []),
86
+ ],
87
+ });
88
+ if (pick === done)
89
+ break;
90
+ order.push(pick);
91
+ }
92
+ return { enabled: true, triggers: ['usage_limit', 'rate_limit', 'service_down'], default: order };
93
+ }
@@ -0,0 +1,16 @@
1
+ import { tilde } from '../summary.js';
2
+ const otherPath = '\0path';
3
+ /** Where the config goes: the global file, or a path typed. */
4
+ export async function askOut(ui, fallback) {
5
+ const choice = await ui.select({
6
+ message: 'Where should the config be saved?',
7
+ options: [
8
+ { value: fallback, label: tilde(fallback), hint: 'global: "carrier" finds it without --config' },
9
+ { value: otherPath, label: 'Somewhere else…', hint: 'then use --config to point at it' },
10
+ ],
11
+ initialValue: fallback,
12
+ });
13
+ if (choice !== otherPath)
14
+ return choice;
15
+ return ui.text({ message: 'Path of the config file', placeholder: 'carrier.config.json', defaultValue: 'carrier.config.json' });
16
+ }
@@ -0,0 +1,47 @@
1
+ import { persona } from '../../../config/schema.js';
2
+ /** The persona of a role: first a choice (default, or set one; when editing, keep the current), so skipping reads as an answer; then a name and a prompt, each skippable by leaving it empty; when editing, empty keeps the current value and `-` clears it. The limits are the schema's. A flag answers ahead of time and, outside the limits, is an error naming it. */
3
+ export async function askPersona(ui, role, given, interactive, current) {
4
+ const roleName = role.split(' ').map((w) => `${w[0].toUpperCase()}${w.slice(1)}`).join(' ');
5
+ const flagPrefix = role.replace(/\s+/g, '-');
6
+ const field = async (key, flag) => {
7
+ const refusal = (value) => {
8
+ const parsed = persona.shape[key].safeParse(value);
9
+ return parsed.success ? undefined : parsed.error.issues[0]?.message;
10
+ };
11
+ if (flag !== undefined) {
12
+ const problem = refusal(flag);
13
+ if (problem !== undefined)
14
+ throw new Error(`--${flagPrefix}-persona-${key}: ${problem}`);
15
+ return flag.trim();
16
+ }
17
+ if (!interactive)
18
+ return undefined;
19
+ const kept = current?.[key];
20
+ const answer = await ui.text({
21
+ message: `${roleName} persona: ${key === 'name' ? 'give it a name' : 'describe how it behaves'}`,
22
+ placeholder: `${key === 'name' ? 'a short name, e.g. Rex' : 'who it is and how it works'}; ${kept ? 'Enter keeps it, - clears it' : 'Enter to skip'}`,
23
+ defaultValue: kept ?? '',
24
+ validate: (value) => (value.trim() === '' ? undefined : value.trim() === '-' ? (kept ? undefined : 'empty to skip') : refusal(value)),
25
+ });
26
+ return answer.trim() === '-' && kept ? undefined : answer.trim() || undefined;
27
+ };
28
+ if (interactive && given.name === undefined && given.prompt === undefined) {
29
+ const summary = current && [current.name, current.prompt && (current.prompt.length > 40 ? `${current.prompt.slice(0, 39)}…` : current.prompt)].filter(Boolean).join(' · ');
30
+ const choice = await ui.select({
31
+ message: `${roleName} persona`,
32
+ options: [
33
+ ...(current ? [{ value: 'keep', label: 'Keep the current one', hint: summary }] : []),
34
+ { value: 'default', label: current ? 'Remove it' : 'Default', hint: 'no name, no extra instructions' },
35
+ { value: 'set', label: current ? 'Edit it' : 'Give it a name and a way of behaving' },
36
+ ],
37
+ initialValue: current ? 'keep' : 'default',
38
+ });
39
+ if (choice === 'keep')
40
+ return current;
41
+ if (choice === 'default')
42
+ return undefined;
43
+ }
44
+ const name = await field('name', given.name);
45
+ const prompt = await field('prompt', given.prompt);
46
+ return name || prompt ? { ...(name && { name }), ...(prompt && { prompt }) } : undefined;
47
+ }
@@ -0,0 +1,61 @@
1
+ import { repoNamePattern } from '../../../config/schema.js';
2
+ const otherRepo = '\0other';
3
+ const loadMore = '\0more';
4
+ /** The repos to watch: picked from the ones the gh login can push to (grouped by owner), or typed. */
5
+ export async function askRepos(ui, deps, current) {
6
+ const origin = deps.originRepo();
7
+ const suggested = current?.join(', ') ?? origin;
8
+ let listed = [];
9
+ let hasMore = false;
10
+ try {
11
+ ({ names: listed, hasMore } = deps.listRepos(1));
12
+ }
13
+ catch (error) {
14
+ ui.warn(`Could not list your repos (${error instanceof Error ? error.message.split('\n')[0] : String(error)}); type them instead.`);
15
+ }
16
+ const typed = async (message) => {
17
+ const answer = await ui.text({
18
+ message,
19
+ placeholder: suggested ?? 'AshuLab/my-repo',
20
+ defaultValue: suggested,
21
+ validate: (value) => (value.split(',').every((n) => repoNamePattern.test(n.trim())) ? undefined : 'Each repo must be owner/name'),
22
+ });
23
+ return answer.split(',').map((s) => s.trim());
24
+ };
25
+ // a first page emptied by the filter still has later pages while hasMore holds
26
+ if (listed.length === 0 && !hasMore)
27
+ return typed('Repos to watch (owner/name, comma separated)');
28
+ // What the config already has, or else the folder you are in, goes first and marked, even when it is not among the recent ones; one section per owner.
29
+ const first = current ?? (origin === undefined ? [] : [origin]);
30
+ const other = { value: otherRepo, label: 'Other…', hint: 'type owner/name' };
31
+ let page = 1;
32
+ let ticked = first;
33
+ let picked;
34
+ for (;;) {
35
+ const names = [...first, ...listed.filter((n) => !first.includes(n))];
36
+ const owners = [...new Set(names.map((n) => n.split('/')[0]))];
37
+ const tail = hasMore ? [{ value: loadMore, label: 'Load more…', hint: 'the next repos' }, other] : [other];
38
+ const groups = Object.fromEntries(owners.map((owner) => [owner, names.filter((n) => n.startsWith(`${owner}/`)).map((value) => ({ value, label: owners.length > 1 ? value.slice(owner.length + 1) : value }))]));
39
+ if (owners.length === 1)
40
+ groups[owners[0]].push(...tail);
41
+ else
42
+ groups.Other = tail;
43
+ picked = await ui.multiselect({ message: 'Repos to watch', options: groups, initialValues: ticked });
44
+ if (!picked.includes(loadMore))
45
+ break;
46
+ ticked = picked.filter((n) => n !== loadMore);
47
+ try {
48
+ const next = deps.listRepos(page + 1);
49
+ page += 1;
50
+ hasMore = next.hasMore;
51
+ listed = [...listed, ...next.names.filter((n) => !listed.includes(n))];
52
+ }
53
+ catch (error) {
54
+ ui.warn(`Could not load more repos (${error instanceof Error ? error.message.split('\n')[0] : String(error)}); going on with the ones loaded.`);
55
+ hasMore = false;
56
+ }
57
+ }
58
+ const chosen = picked.filter((n) => n !== otherRepo);
59
+ const extra = picked.includes(otherRepo) ? await typed('Other repos (owner/name, comma separated)') : [];
60
+ return [...new Set([...chosen, ...extra])];
61
+ }
@@ -0,0 +1,31 @@
1
+ import { homedir } from 'node:os';
2
+ import { harnessLabel } from '../../harnesses/names.js';
3
+ import { localLabel } from '../../harnesses/local.js';
4
+ /** `~` for the home folder, for what is shown to a person. */
5
+ export const tilde = (path) => (path.startsWith(`${homedir()}/`) ? `~${path.slice(homedir().length)}` : path);
6
+ /** What an AI provider runs: the CLI, where a local one is served and the config folder when it has one. */
7
+ export const providerLine = (p) => [harnessLabel[p.harness], ...(p.provider ? [`${localLabel[p.provider]} (local)`] : []), ...(p.configDir ? [tilde(p.configDir)] : [])].join(' · ');
8
+ const personaLine = ({ persona }) => (persona ? [`${persona.name ?? '(no name)'}${persona.prompt ? ` · ${persona.prompt.length > 60 ? `${persona.prompt.slice(0, 59)}…` : persona.prompt}` : ''}`] : []);
9
+ const roleLine = (r, providers) => {
10
+ const p = providers[r.provider];
11
+ const pDesc = p ? `${r.provider} (${providerLine(p)})` : r.provider;
12
+ return [`${r.identity.kind === 'gh' ? 'your gh login' : r.identity.agent} · ${pDesc} · ${r.model}`, ...personaLine(r).map((item) => `\n persona: ${item}`)].join('');
13
+ };
14
+ /** What is about to be written, in one box. */
15
+ export const summary = (repos, author, reviewer, providers, failover, out, identities = 'separate', leadReviewer) => [
16
+ `Repos ${repos.join(', ')}`,
17
+ ...(identities === 'shared' ? ['Identity shared: both roles act as your gh login'] : []),
18
+ `Providers ${Object.keys(providers).join(', ')}`,
19
+ `Author ${roleLine(author, providers)}`,
20
+ `Reviewer ${roleLine(reviewer, providers)}`,
21
+ ...(leadReviewer ? [`Lead Rev ${roleLine(leadReviewer, providers)}`] : []),
22
+ ...(failover?.enabled
23
+ ? [
24
+ `Failover ${[
25
+ ...(failover.default?.length ? [failover.roles ? `default: ${failover.default.join(' → ')}` : failover.default.join(' → ')] : []),
26
+ ...(failover.roles ? Object.entries(failover.roles).filter(([_, list]) => list && list.length > 0).map(([role, list]) => `${role}: ${list.join(' → ')}`) : []),
27
+ ].join(' · ')}`,
28
+ ]
29
+ : []),
30
+ `Saved to ${tilde(out)}`,
31
+ ].join('\n');
@@ -0,0 +1,106 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterAll } from 'vitest';
5
+ // What the init tests share: a scripted terminal, a machine with fakes in every seam, and a folder to write into.
6
+ /** A temporary folder, removed when the test file is done. */
7
+ export function useTempDir() {
8
+ const dir = mkdtempSync(join(tmpdir(), 'carrier-init-'));
9
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
10
+ return dir;
11
+ }
12
+ /** A role of a written config with the provider it points at spread in, to assert on harness, model and provider. */
13
+ export function roleOf(written, role) {
14
+ const config = JSON.parse(written);
15
+ const agent = config.agents[role];
16
+ const provider = config.providers?.[agent.provider];
17
+ return {
18
+ profile: agent.profile,
19
+ harness: provider?.harness,
20
+ model: agent.model,
21
+ ...(provider?.provider && { provider: provider.provider }),
22
+ ...(provider?.configDir && { configDir: provider.configDir }),
23
+ ...(agent.persona && { persona: agent.persona }),
24
+ };
25
+ }
26
+ export const quietUi = (scripted = {}) => {
27
+ const lists = {};
28
+ const values = {};
29
+ for (const [k, v] of Object.entries(scripted)) {
30
+ if (Array.isArray(v))
31
+ lists[k] = [...v];
32
+ else
33
+ values[k] = v;
34
+ }
35
+ const take = (k) => {
36
+ if (lists[k] && lists[k].length > 0)
37
+ return lists[k].shift();
38
+ return values[k];
39
+ };
40
+ const getAnswer = (message) => take(message);
41
+ const asked = [];
42
+ const listed = [];
43
+ const ui = {
44
+ interactive: true,
45
+ checkStart: () => { },
46
+ checksEnd: (total, failed) => void asked.push(`checks:${total}/${failed}`),
47
+ intro: () => { },
48
+ outro: (m) => asked.push(`outro:${m}`),
49
+ note: () => { },
50
+ warn: (m) => asked.push(`warn:${m}`),
51
+ check: () => { },
52
+ text: async ({ message, defaultValue }) => {
53
+ asked.push(message);
54
+ const answer = getAnswer(message);
55
+ if (typeof answer === 'string')
56
+ return answer;
57
+ if (defaultValue !== undefined)
58
+ return defaultValue;
59
+ throw new Error(`unexpected question: ${message}`);
60
+ },
61
+ select: async ({ message, options, initialValue }) => {
62
+ asked.push(message);
63
+ const answer = getAnswer(message);
64
+ return (typeof answer === 'string' ? answer : (initialValue ?? options[0].value));
65
+ },
66
+ confirm: async (message) => {
67
+ asked.push(message);
68
+ const answer = getAnswer(message);
69
+ return typeof answer === 'boolean' ? answer : message === 'Save it?'; // the final recap is accepted unless a test declines it
70
+ },
71
+ multiselect: async ({ message, options, initialValues }) => {
72
+ asked.push(message);
73
+ const answer = getAnswer(message);
74
+ listed.push(Object.entries(options).map(([group, items]) => `${group}:${items.map((o) => o.label).join(',')}`));
75
+ return typeof answer === 'string' ? answer.split(',') : (initialValues ?? []);
76
+ },
77
+ };
78
+ return { ui, asked, listed };
79
+ };
80
+ /** Adds a second provider (the machine's other CLI, on its defaults) to the first, then stops: Claude and Codex. */
81
+ export const twoOptions = { 'Add another AI provider?': [true, false] };
82
+ export const okCheck = { name: 'x', status: 'ok' };
83
+ export const fakeDeps = (over = {}) => {
84
+ const written = {};
85
+ const deps = {
86
+ which: () => true,
87
+ forgeAgents: () => [{ name: 'artanis-agent', label: 'Artanis' }, { name: 'tassadar-agent' }],
88
+ ghLogin: () => 'diego',
89
+ listRepos: () => ({ names: [], hasMore: false }),
90
+ originRepo: () => 'AshuLab/carrier',
91
+ codexModel: () => 'gpt-test',
92
+ codexModels: () => [],
93
+ agyModels: () => [],
94
+ localModels: async () => Promise.reject(new Error('not running')),
95
+ configFolders: () => [],
96
+ loggedIn: async () => true,
97
+ exists: () => false,
98
+ write: (path, content) => (written[path] = content),
99
+ doctor: async () => [okCheck],
100
+ readConfig: () => ({}),
101
+ forgeAdd: () => false,
102
+ readyEpics: async () => ({}),
103
+ ...over,
104
+ };
105
+ return { deps, written };
106
+ };