@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.
- package/LICENSE +21 -0
- package/README.md +264 -2
- package/dist/chat/claude.js +367 -0
- package/dist/chat/codex.js +375 -0
- package/dist/chat/config.js +61 -0
- package/dist/chat/db.js +9 -0
- package/dist/chat/fake.js +203 -0
- package/dist/chat/folder.js +56 -0
- package/dist/chat/git.js +27 -0
- package/dist/chat/group.js +28 -0
- package/dist/chat/health.js +49 -0
- package/dist/chat/host.js +123 -0
- package/dist/chat/idle.js +20 -0
- package/dist/chat/migrations/0000_lame_quasar.sql +15 -0
- package/dist/chat/migrations/0001_quiet_penance.sql +23 -0
- package/dist/chat/migrations/0002_slim_ezekiel_stane.sql +1 -0
- package/dist/chat/migrations/0003_naive_iron_lad.sql +5 -0
- package/dist/chat/migrations/0004_skinny_dakota_north.sql +1 -0
- package/dist/chat/migrations/0005_tidy_strong_guy.sql +1 -0
- package/dist/chat/migrations/meta/0000_snapshot.json +121 -0
- package/dist/chat/migrations/meta/0001_snapshot.json +277 -0
- package/dist/chat/migrations/meta/0002_snapshot.json +284 -0
- package/dist/chat/migrations/meta/0003_snapshot.json +320 -0
- package/dist/chat/migrations/meta/0004_snapshot.json +327 -0
- package/dist/chat/migrations/meta/0005_snapshot.json +334 -0
- package/dist/chat/migrations/meta/_journal.json +48 -0
- package/dist/chat/names.js +97 -0
- package/dist/chat/plan.js +21 -0
- package/dist/chat/projects.js +242 -0
- package/dist/chat/runner.js +6 -0
- package/dist/chat/schema.js +48 -0
- package/dist/chat/server.js +202 -0
- package/dist/chat/sessions.js +766 -0
- package/dist/chat/sweep.js +64 -0
- package/dist/chat/transcript.js +31 -0
- package/dist/chat/words.js +20 -0
- package/dist/chat/workspace.js +134 -0
- package/dist/chat/worktree.js +142 -0
- package/dist/config/default-path.js +7 -0
- package/dist/config/engine-places.js +9 -0
- package/dist/config/json-schema.js +15 -0
- package/dist/config/load.js +48 -0
- package/dist/config/resolve.js +41 -0
- package/dist/config/schema.js +225 -0
- package/dist/control/epic.js +219 -0
- package/dist/control/events.js +48 -0
- package/dist/control/files.js +262 -0
- package/dist/control/replies.js +1 -0
- package/dist/control/server.js +237 -0
- package/dist/control/sessions.js +68 -0
- package/dist/doctor/doctor.js +255 -0
- package/dist/doctor/fixes.js +35 -0
- package/dist/execution/agent-runner.js +1 -0
- package/dist/execution/caller-result.js +61 -0
- package/dist/execution/events.js +19 -0
- package/dist/execution/failure.js +3 -0
- package/dist/execution/fake.js +82 -0
- package/dist/execution/git-workspaces.js +136 -0
- package/dist/execution/import-result.js +23 -0
- package/dist/execution/workspaces.js +1 -0
- package/dist/format/duration.js +12 -0
- package/dist/harnesses/agy.js +131 -0
- package/dist/harnesses/claude.js +246 -0
- package/dist/harnesses/codex.js +118 -0
- package/dist/harnesses/common.js +59 -0
- package/dist/harnesses/credentials.js +38 -0
- package/dist/harnesses/host-runner.js +225 -0
- package/dist/harnesses/local.js +25 -0
- package/dist/harnesses/names.js +9 -0
- package/dist/harnesses/replay-events.js +57 -0
- package/dist/integrations/available-repos.js +29 -0
- package/dist/integrations/forge-cwd.js +27 -0
- package/dist/integrations/github-graphql.js +216 -0
- package/dist/integrations/github.js +231 -0
- package/dist/logging/logger.js +53 -0
- package/dist/logging/redact.js +21 -0
- package/dist/orchestration/blockers.js +25 -0
- package/dist/orchestration/changes-slices.js +5 -0
- package/dist/orchestration/clock.js +7 -0
- package/dist/orchestration/engine.js +1068 -0
- package/dist/orchestration/fake.js +152 -0
- package/dist/orchestration/final-pr.js +15 -0
- package/dist/orchestration/github.js +31 -0
- package/dist/orchestration/reserve.js +32 -0
- package/dist/orchestration/resume-refused.js +7 -0
- package/dist/orchestration/retention.js +145 -0
- package/dist/orchestration/retry.js +16 -0
- package/dist/prompts/build-prompt.js +13 -0
- package/dist/prompts/fix.js +21 -0
- package/dist/prompts/intro.js +21 -0
- package/dist/prompts/invoke.js +2 -0
- package/dist/prompts/persona.js +12 -0
- package/dist/prompts/result.js +32 -0
- package/dist/prompts/review.js +15 -0
- package/dist/prompts/ship.js +8 -0
- package/dist/status/snapshot.js +92 -0
- package/dist/status/types.js +1 -0
- package/dist/storage/caller-results.js +7 -0
- package/dist/storage/db.js +8 -0
- package/dist/storage/epic-review-threads.js +19 -0
- package/dist/storage/epic-slices.js +17 -0
- package/dist/storage/events.js +52 -0
- package/dist/storage/executions.js +31 -0
- package/dist/storage/lock.js +21 -0
- package/dist/storage/migrations/0000_nosy_demogoblin.sql +5 -0
- package/dist/storage/migrations/0001_worried_grey_gargoyle.sql +3 -0
- package/dist/storage/migrations/0002_tough_squirrel_girl.sql +5 -0
- package/dist/storage/migrations/0003_loose_maria_hill.sql +4 -0
- package/dist/storage/migrations/0004_long_cerise.sql +18 -0
- package/dist/storage/migrations/0005_review_result.sql +2 -0
- package/dist/storage/migrations/0006_melted_menace.sql +10 -0
- package/dist/storage/migrations/0007_powerful_bloodscream.sql +2 -0
- package/dist/storage/migrations/0008_panel_state.sql +4 -0
- package/dist/storage/migrations/0009_workable_tiger_shark.sql +5 -0
- package/dist/storage/migrations/0010_nasty_blue_marvel.sql +4 -0
- package/dist/storage/migrations/0011_steep_lila_cheney.sql +13 -0
- package/dist/storage/migrations/0012_talented_big_bertha.sql +1 -0
- package/dist/storage/migrations/0013_third_mulholland_black.sql +17 -0
- package/dist/storage/migrations/0014_perfect_marten_broadcloak.sql +20 -0
- package/dist/storage/migrations/0015_long_bloodaxe.sql +1 -0
- package/dist/storage/migrations/0016_neat_true_believers.sql +5 -0
- package/dist/storage/migrations/0017_previous_swarm.sql +6 -0
- package/dist/storage/migrations/0018_opposite_plazm.sql +2 -0
- package/dist/storage/migrations/0019_hesitant_triton.sql +3 -0
- package/dist/storage/migrations/meta/0000_snapshot.json +49 -0
- package/dist/storage/migrations/meta/0001_snapshot.json +74 -0
- package/dist/storage/migrations/meta/0002_snapshot.json +105 -0
- package/dist/storage/migrations/meta/0003_snapshot.json +134 -0
- package/dist/storage/migrations/meta/0004_snapshot.json +257 -0
- package/dist/storage/migrations/meta/0005_snapshot.json +271 -0
- package/dist/storage/migrations/meta/0006_snapshot.json +337 -0
- package/dist/storage/migrations/meta/0007_snapshot.json +351 -0
- package/dist/storage/migrations/meta/0008_snapshot.json +379 -0
- package/dist/storage/migrations/meta/0009_snapshot.json +414 -0
- package/dist/storage/migrations/meta/0010_snapshot.json +442 -0
- package/dist/storage/migrations/meta/0011_snapshot.json +531 -0
- package/dist/storage/migrations/meta/0012_snapshot.json +538 -0
- package/dist/storage/migrations/meta/0013_snapshot.json +655 -0
- package/dist/storage/migrations/meta/0014_snapshot.json +772 -0
- package/dist/storage/migrations/meta/0015_snapshot.json +779 -0
- package/dist/storage/migrations/meta/0016_snapshot.json +793 -0
- package/dist/storage/migrations/meta/0017_snapshot.json +835 -0
- package/dist/storage/migrations/meta/0018_snapshot.json +849 -0
- package/dist/storage/migrations/meta/0019_snapshot.json +870 -0
- package/dist/storage/migrations/meta/_journal.json +146 -0
- package/dist/storage/schema.js +43 -0
- package/dist/terminal/cli/args.js +91 -0
- package/dist/terminal/cli/cli-error.js +11 -0
- package/dist/terminal/cli/commands/chat-host.js +18 -0
- package/dist/terminal/cli/commands/chat.js +321 -0
- package/dist/terminal/cli/commands/control.js +46 -0
- package/dist/terminal/cli/commands/doctor.js +15 -0
- package/dist/terminal/cli/commands/init.js +43 -0
- package/dist/terminal/cli/commands/restart.js +92 -0
- package/dist/terminal/cli/commands/run.js +20 -0
- package/dist/terminal/cli/commands/schema.js +4 -0
- package/dist/terminal/cli/commands/start.js +21 -0
- package/dist/terminal/cli/commands/status.js +61 -0
- package/dist/terminal/cli/commands/watch.js +112 -0
- package/dist/terminal/cli/compose.js +163 -0
- package/dist/terminal/cli/config-file.js +24 -0
- package/dist/terminal/cli/control-client.js +94 -0
- package/dist/terminal/cli/detached.js +41 -0
- package/dist/terminal/cli/interrupt.js +39 -0
- package/dist/terminal/cli/main.js +85 -0
- package/dist/terminal/cli/open-url.js +9 -0
- package/dist/terminal/dashboard/dashboard.js +204 -0
- package/dist/terminal/dashboard/history.js +38 -0
- package/dist/terminal/dashboard/interactive.js +60 -0
- package/dist/terminal/dashboard/keys.js +22 -0
- package/dist/terminal/dashboard/logs.js +85 -0
- package/dist/terminal/dashboard/panel.js +62 -0
- package/dist/terminal/dashboard/plain.js +32 -0
- package/dist/terminal/dashboard/printable.js +2 -0
- package/dist/terminal/dashboard/ui-state.js +110 -0
- package/dist/terminal/prompts/clack.js +121 -0
- package/dist/terminal/prompts/types.js +3 -0
- package/dist/terminal/wizard/build-config.js +66 -0
- package/dist/terminal/wizard/deps.js +94 -0
- package/dist/terminal/wizard/flags.js +1 -0
- package/dist/terminal/wizard/forge-registry.js +23 -0
- package/dist/terminal/wizard/prior.js +20 -0
- package/dist/terminal/wizard/questions/agent.js +61 -0
- package/dist/terminal/wizard/questions/identities.js +28 -0
- package/dist/terminal/wizard/questions/identity.js +30 -0
- package/dist/terminal/wizard/questions/options.js +93 -0
- package/dist/terminal/wizard/questions/out.js +16 -0
- package/dist/terminal/wizard/questions/persona.js +47 -0
- package/dist/terminal/wizard/questions/repos.js +61 -0
- package/dist/terminal/wizard/summary.js +31 -0
- package/dist/terminal/wizard/testing.js +106 -0
- package/dist/terminal/wizard/wizard.js +172 -0
- package/package.json +46 -3
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
// The agent's output is one JSON event per line, in a different shape for each harness. What is worth reading in each:
|
|
4
|
+
// what it said, and what tool it ran. Everything else (init, usage, step bookkeeping) is skipped.
|
|
5
|
+
const brief = (value, max = 60) => {
|
|
6
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value) ?? '';
|
|
7
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
8
|
+
};
|
|
9
|
+
// Claude's own notices: what it published and which of its background jobs ended. Starts, progress and usage are noise.
|
|
10
|
+
function summarizeNotice(e) {
|
|
11
|
+
if (e.subtype === 'code_change_published')
|
|
12
|
+
return `▲ PR #${e.identifier} ${e.action}`;
|
|
13
|
+
if (e.subtype === 'vcs_state_changed' && e.kind === 'push')
|
|
14
|
+
return '▲ pushed';
|
|
15
|
+
if (e.subtype !== 'task_notification')
|
|
16
|
+
return null;
|
|
17
|
+
const summary = String(e.summary ?? '');
|
|
18
|
+
if (e.status === 'failed')
|
|
19
|
+
return `✗ ${brief(summary, 80)}`;
|
|
20
|
+
// a sub-agent (one `ship` slice) reports its result as JSON; a finished shell command is already in the log as the line that started it
|
|
21
|
+
const slice = /"slice":\s*"([^"]+)",\s*"outcome":\s*"([^"]+)"/.exec(summary);
|
|
22
|
+
return slice ? `✓ slice ${slice[1]} ${slice[2]}` : null;
|
|
23
|
+
}
|
|
24
|
+
export function summarizeLogLine(line) {
|
|
25
|
+
if (line.trim() === '')
|
|
26
|
+
return null;
|
|
27
|
+
let e;
|
|
28
|
+
try {
|
|
29
|
+
e = JSON.parse(line);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return line.trim(); // not an event: an error message, say
|
|
33
|
+
}
|
|
34
|
+
if (e === null || typeof e !== 'object')
|
|
35
|
+
return line.trim();
|
|
36
|
+
// Claude Code (stream-json)
|
|
37
|
+
if (e.type === 'assistant') {
|
|
38
|
+
const parts = Array.isArray(e.message?.content) ? e.message.content : [];
|
|
39
|
+
const out = parts.map((c) => (c?.type === 'text' ? String(c.text).trim() : c?.type === 'tool_use' ? `▸ ${c.name} ${brief(c.input)}` : '')).filter(Boolean);
|
|
40
|
+
return out.length > 0 ? out.join(' ') : null;
|
|
41
|
+
}
|
|
42
|
+
if (e.type === 'result')
|
|
43
|
+
return `result: ${brief(e.result ?? e.subtype ?? e.status ?? '', 100)}`;
|
|
44
|
+
if (e.type === 'system')
|
|
45
|
+
return summarizeNotice(e);
|
|
46
|
+
// Codex (--json)
|
|
47
|
+
if (e.item?.type === 'agent_message')
|
|
48
|
+
return String(e.item.text).trim();
|
|
49
|
+
if (e.item?.type === 'command_execution' && e.type === 'item.started')
|
|
50
|
+
return `$ ${brief(e.item.command, 90)}`;
|
|
51
|
+
// Antigravity (stream-json)
|
|
52
|
+
if (e.event === 'step_update') {
|
|
53
|
+
const su = e.step_update ?? {};
|
|
54
|
+
if (su.tool_name && su.state === 'ACTIVE')
|
|
55
|
+
return `▸ ${su.tool_name} ${brief(su.tool_info ?? '')}`;
|
|
56
|
+
return su.state === 'DONE' && su.step_type === 'agent_response' && typeof su.text_delta === 'string' && su.text_delta.trim() ? su.text_delta.trim() : null;
|
|
57
|
+
}
|
|
58
|
+
if (e.event === 'result')
|
|
59
|
+
return `result: ${brief(e.result?.response ?? e.result?.status ?? '', 100)}`;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
/** The last `count` meaningful lines of an execution's output, read from the end of the file (it can be megabytes). */
|
|
63
|
+
export function tailExecution(logs, executionId, count, fileSizeLimit = 256 * 1024) {
|
|
64
|
+
const path = join(logs, 'executions', executionId, 'stdout.log');
|
|
65
|
+
let fd;
|
|
66
|
+
try {
|
|
67
|
+
fd = openSync(path, 'r');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return []; // nothing written yet
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const size = statSync(path).size;
|
|
74
|
+
const start = Math.max(0, size - fileSizeLimit);
|
|
75
|
+
const buffer = Buffer.alloc(size - start);
|
|
76
|
+
readSync(fd, buffer, 0, buffer.length, start);
|
|
77
|
+
const lines = buffer.toString('utf8').split('\n');
|
|
78
|
+
if (start > 0)
|
|
79
|
+
lines.shift(); // cut in the middle of a line
|
|
80
|
+
return lines.flatMap((l) => summarizeLogLine(l) ?? []).slice(-count);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
closeSync(fd);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { renderDashboard } from './dashboard.js';
|
|
2
|
+
import { renderPanel } from './plain.js';
|
|
3
|
+
// What a person reads as "the state changed": durations tick every second and do not count.
|
|
4
|
+
const stateOf = (e) => [e.status, e.stage, e.round, e.blockers, e.needsHumanReason, e.lastError].join('|');
|
|
5
|
+
/**
|
|
6
|
+
* Shows the snapshot on `out` every `intervalMs`: redrawn in place with ANSI on a terminal, one plain line
|
|
7
|
+
* per state change otherwise. No input handling: Ctrl+C is the only way to stop the process.
|
|
8
|
+
* Returns the stop function.
|
|
9
|
+
*/
|
|
10
|
+
export function startPanel(snapshot, out = process.stdout, intervalMs = 1000,
|
|
11
|
+
/** What a terminal is shown; the dashboard unless the caller draws its own (with a selection, say). */
|
|
12
|
+
render = (s, columns) => renderDashboard(s, { columns })) {
|
|
13
|
+
let drawn = 0;
|
|
14
|
+
const seen = new Map();
|
|
15
|
+
let queueSeen = '';
|
|
16
|
+
let finishedSeen = '';
|
|
17
|
+
// The panel owns the screen, so the cursor is hidden while it runs and always given back, also when the process dies on its own.
|
|
18
|
+
const hideCursor = '\x1b[?25l';
|
|
19
|
+
const showCursor = '\x1b[?25h';
|
|
20
|
+
const restoreOnExit = () => out.write(showCursor);
|
|
21
|
+
if (out.isTTY)
|
|
22
|
+
process.once('exit', restoreOnExit);
|
|
23
|
+
const redraw = (s) => {
|
|
24
|
+
// Lines wider than the terminal would wrap and break the cursor-up count; the dashboard cuts them itself.
|
|
25
|
+
const lines = render(s, out.columns);
|
|
26
|
+
out.write(`${drawn > 0 ? `\x1b[${drawn}A` : hideCursor}${lines.map((l) => `\r\x1b[2K${l}\n`).join('')}\x1b[J`);
|
|
27
|
+
drawn = lines.length;
|
|
28
|
+
};
|
|
29
|
+
const emitChanges = (s) => {
|
|
30
|
+
const at = new Date(s.now).toISOString();
|
|
31
|
+
const queue = `${s.slots.busy}/${s.slots.total}|${s.queue.map((q) => `${q.repo}#${q.issue}`).join(',')}`;
|
|
32
|
+
if (queue !== queueSeen) {
|
|
33
|
+
queueSeen = queue;
|
|
34
|
+
out.write(`${at} slots ${s.slots.busy}/${s.slots.total} queue ${s.queue.length}${s.queue.length > 0 ? `: ${s.queue.map((q) => `${q.repo}#${q.issue}`).join(', ')}` : ''}\n`);
|
|
35
|
+
}
|
|
36
|
+
for (const e of s.epics) {
|
|
37
|
+
const key = `${e.repo}#${e.issue}`;
|
|
38
|
+
if (seen.get(key) === stateOf(e))
|
|
39
|
+
continue;
|
|
40
|
+
seen.set(key, stateOf(e));
|
|
41
|
+
out.write(`${at} ${renderPanel({ ...s, epics: [e], finished: [] }).slice(1).map((l) => l.trim()).join(' | ')}\n`);
|
|
42
|
+
}
|
|
43
|
+
// The finished rows when the set changes: their "ago" ticks and does not count.
|
|
44
|
+
const finished = (s.finished ?? []).map((f) => `${f.repo}#${f.issue}`).join(',');
|
|
45
|
+
if (finished !== finishedSeen) {
|
|
46
|
+
finishedSeen = finished;
|
|
47
|
+
for (const l of renderPanel({ ...s, epics: [] }).slice(1))
|
|
48
|
+
out.write(`${at} ${l}\n`);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const tick = () => (out.isTTY ? redraw : emitChanges)(snapshot());
|
|
52
|
+
tick();
|
|
53
|
+
const timer = setInterval(tick, intervalMs);
|
|
54
|
+
const stop = () => {
|
|
55
|
+
clearInterval(timer);
|
|
56
|
+
if (out.isTTY) {
|
|
57
|
+
process.removeListener('exit', restoreOnExit);
|
|
58
|
+
out.write(showCursor);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
return Object.assign(stop, { redraw: tick });
|
|
62
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { formatDuration } from '../../format/duration.js';
|
|
2
|
+
import { printable } from './printable.js';
|
|
3
|
+
import { finishedSections } from './ui-state.js';
|
|
4
|
+
// The one-line-per-change form for a pipe (a terminal gets the dashboard). Pure: no ANSI.
|
|
5
|
+
export function renderPanel(s) {
|
|
6
|
+
const { now, slots, queue, epics } = s;
|
|
7
|
+
const lines = [`carrier slots ${slots.busy}/${slots.total} queue ${queue.length}${queue.length > 0 ? `: ${queue.map((q) => `${q.repo}#${q.issue}`).join(', ')}` : ''}`];
|
|
8
|
+
for (const e of epics) {
|
|
9
|
+
const parts = [
|
|
10
|
+
`${e.repo}#${e.issue}`,
|
|
11
|
+
e.status === 'needs_human' ? 'NEEDS HUMAN' : e.options?.choiceDeadline != null ? `waiting for its choice (starts in ${formatDuration(e.options.choiceDeadline - now)})` : (e.stage ?? 'starting'),
|
|
12
|
+
...(e.round === null ? [] : [`round ${e.round}/${e.maxReviews}`]),
|
|
13
|
+
...(e.slices && e.slices.total > 0 ? [`slices ${e.slices.done}/${e.slices.total}`] : []),
|
|
14
|
+
`blockers ${e.blockers}`,
|
|
15
|
+
`in stage ${formatDuration(now - e.stageSince)}`,
|
|
16
|
+
e.lastActivityAt === null ? 'no activity' : `activity ${formatDuration(now - e.lastActivityAt)} ago`,
|
|
17
|
+
];
|
|
18
|
+
lines.push(parts.join(' '), ` issue ${e.issueUrl}${e.prUrl ? ` PR ${e.prUrl}` : ''}`);
|
|
19
|
+
if (e.needsHumanReason)
|
|
20
|
+
lines.push(` reason: ${printable(e.needsHumanReason)}`);
|
|
21
|
+
if (e.lastError)
|
|
22
|
+
lines.push(` error: ${printable(e.lastError)}`);
|
|
23
|
+
}
|
|
24
|
+
for (const { status, rows, more } of finishedSections(s)) {
|
|
25
|
+
const kind = status === 'merged' ? 'done' : 'cancelled';
|
|
26
|
+
for (const f of rows)
|
|
27
|
+
lines.push(`${kind} ${f.repo}#${f.issue}${f.prNumber === null ? '' : ` PR #${f.prNumber}`} ${formatDuration(Math.max(0, now - f.finishedAt))} ago${f.title ? ` ${printable(f.title)}` : ''}${f.cancelReason ? ` reason: ${printable(f.cancelReason)}` : ''}`);
|
|
28
|
+
if (more > 0)
|
|
29
|
+
lines.push(`${kind} +${more} more`);
|
|
30
|
+
}
|
|
31
|
+
return lines;
|
|
32
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** How many queued epics the dashboard lists before "… and N more"; selection only reaches what is listed. */
|
|
2
|
+
export const maxListed = 5;
|
|
3
|
+
/** How many finished epics of each kind the panel lists before "+N more"; selection only reaches what is listed. */
|
|
4
|
+
export const maxFinished = 10;
|
|
5
|
+
/** The DONE and CANCELLED sections that have something to show, each cut to `maxFinished` rows with the count of the rest. */
|
|
6
|
+
export function finishedSections(s) {
|
|
7
|
+
const all = s.finished ?? [];
|
|
8
|
+
return [['DONE', 'merged'], ['CANCELLED', 'cancelled']]
|
|
9
|
+
.map(([name, status]) => ({ name, status, all: all.filter((f) => f.status === status) }))
|
|
10
|
+
.filter((x) => x.all.length > 0)
|
|
11
|
+
.map(({ name, status, all }) => ({ name, status, rows: all.slice(0, maxFinished), more: Math.max(0, all.length - maxFinished) }));
|
|
12
|
+
}
|
|
13
|
+
const issueUrl = (repo, issue) => `https://github.com/${repo}/issues/${issue}`;
|
|
14
|
+
export function itemsOf(s) {
|
|
15
|
+
const running = s.epics.filter((e) => e.status === 'running');
|
|
16
|
+
const needsYou = s.epics.filter((e) => e.status === 'needs_human');
|
|
17
|
+
return [
|
|
18
|
+
...running.map((e) => ({ key: `${e.repo}#${e.issue}`, url: e.prUrl ?? e.issueUrl, executionId: e.executionId })),
|
|
19
|
+
...s.queue.slice(0, maxListed).map((q) => ({ key: `${q.repo}#${q.issue}`, url: issueUrl(q.repo, q.issue), executionId: null })),
|
|
20
|
+
...needsYou.map((e) => ({ key: `${e.repo}#${e.issue}`, url: e.prUrl ?? e.issueUrl, executionId: e.executionId })),
|
|
21
|
+
...[...s.done, ...finishedSections(s).flatMap((x) => x.rows)].map((d) => ({ key: `${d.repo}#${d.issue}`, url: d.prNumber === null ? issueUrl(d.repo, d.issue) : `https://github.com/${d.repo}/pull/${d.prNumber}`, executionId: d.executionId })),
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
/** An epic that is waiting for its option choice, found by the key of a row. */
|
|
25
|
+
export const waitingEpic = (s, key) => s.epics.find((e) => `${e.repo}#${e.issue}` === key && e.status === 'running' && e.options !== undefined && e.options.choiceDeadline !== null);
|
|
26
|
+
export const initialUi = { selected: null, mode: 'dashboard', toast: null };
|
|
27
|
+
export const toastFor = 3000;
|
|
28
|
+
/** Pure: what a key does to the screen state, and what it asks the program to do. */
|
|
29
|
+
export function onKey(ui, key, s, now, canChoose = false) {
|
|
30
|
+
const items = itemsOf(s);
|
|
31
|
+
const say = (text) => ({ ...ui, toast: { text, until: now + toastFor } });
|
|
32
|
+
const raw = key.sequence === '?' ? '?' : key.name;
|
|
33
|
+
const name = raw === 'j' ? 'down' : raw === 'k' ? 'up' : raw; // vim keys move too
|
|
34
|
+
if ((key.ctrl && name === 'c') || name === 'q')
|
|
35
|
+
return { ui, action: { type: 'quit' } };
|
|
36
|
+
if (ui.mode === 'choose')
|
|
37
|
+
return chooseKey(ui, name, s, now);
|
|
38
|
+
if (name === '?')
|
|
39
|
+
return { ui: { ...ui, mode: ui.mode === 'help' ? 'dashboard' : 'help' } };
|
|
40
|
+
if (ui.mode !== 'dashboard') {
|
|
41
|
+
// Help and logs are one keypress away from the dashboard and take no other keys.
|
|
42
|
+
return name === 'escape' || (ui.mode === 'logs' && name === 'l') ? { ui: { ...ui, mode: 'dashboard' } } : { ui };
|
|
43
|
+
}
|
|
44
|
+
const at = items.findIndex((i) => i.key === ui.selected);
|
|
45
|
+
const current = at === -1 ? undefined : items[at];
|
|
46
|
+
switch (name) {
|
|
47
|
+
case 'down':
|
|
48
|
+
return { ui: { ...ui, selected: items.length === 0 ? null : items[Math.min(at + 1, items.length - 1)].key } };
|
|
49
|
+
case 'up':
|
|
50
|
+
return { ui: { ...ui, selected: items.length === 0 ? null : items[at <= 0 ? 0 : at - 1].key } };
|
|
51
|
+
case 'return':
|
|
52
|
+
return current ? { ui: say(`Opening ${current.url.replace('https://github.com/', '')}`), action: { type: 'open', url: current.url } } : { ui: say('Select an epic first (↑/↓)') };
|
|
53
|
+
case 'l':
|
|
54
|
+
if (!current)
|
|
55
|
+
return { ui: say('Select an epic first (↑/↓)') };
|
|
56
|
+
return current.executionId ? { ui: { ...ui, mode: 'logs' } } : { ui: say('That one has no agent output yet') };
|
|
57
|
+
case 'r':
|
|
58
|
+
return { ui: say('Checking GitHub now…'), action: { type: 'poll' } };
|
|
59
|
+
case 'o': {
|
|
60
|
+
const waiting = canChoose ? waitingEpic(s, ui.selected) : undefined;
|
|
61
|
+
if (!waiting?.options || waiting.options.names.length < 2)
|
|
62
|
+
return { ui };
|
|
63
|
+
return { ui: { ...ui, mode: 'choose', choice: { role: 'author', author: waiting.options.author ?? waiting.options.names[0], reviewer: waiting.options.reviewer ?? waiting.options.names[0] } } };
|
|
64
|
+
}
|
|
65
|
+
case 'a': {
|
|
66
|
+
const waiting = canChoose ? waitingEpic(s, ui.selected) : undefined;
|
|
67
|
+
return waiting ? { ui: say(`Starting ${ui.selected} on its defaults`), action: { type: 'choose', repo: waiting.repo, issue: waiting.issue } } : { ui };
|
|
68
|
+
}
|
|
69
|
+
case 'p':
|
|
70
|
+
return s.paused ? { ui: say('Resumed: taking epics again'), action: { type: 'pause', paused: false } } : { ui: say('Paused: no new epics will start'), action: { type: 'pause', paused: true } };
|
|
71
|
+
default:
|
|
72
|
+
return { ui };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** The keys of the choice mode: ↑↓ move between the roles, ←→ change the option of the one they are on, enter sends, esc leaves. */
|
|
76
|
+
function chooseKey(ui, name, s, now) {
|
|
77
|
+
const waiting = waitingEpic(s, ui.selected);
|
|
78
|
+
const choice = ui.choice;
|
|
79
|
+
if (!waiting?.options || !choice || name === 'escape')
|
|
80
|
+
return { ui: { ...ui, mode: 'dashboard', choice: undefined } };
|
|
81
|
+
const { names } = waiting.options;
|
|
82
|
+
switch (name) {
|
|
83
|
+
case 'up':
|
|
84
|
+
case 'down':
|
|
85
|
+
return { ui: { ...ui, choice: { ...choice, role: choice.role === 'author' ? 'reviewer' : 'author' } } };
|
|
86
|
+
case 'left':
|
|
87
|
+
case 'right': {
|
|
88
|
+
const at = names.indexOf(choice[choice.role]);
|
|
89
|
+
const next = names[(at + (name === 'right' ? 1 : names.length - 1)) % names.length];
|
|
90
|
+
return { ui: { ...ui, choice: { ...choice, [choice.role]: next } } };
|
|
91
|
+
}
|
|
92
|
+
case 'return':
|
|
93
|
+
return { ui: { ...ui, mode: 'dashboard', choice: undefined, toast: { text: `Starting ${ui.selected} on ${choice.author} and ${choice.reviewer}`, until: now + toastFor } }, action: { type: 'choose', repo: waiting.repo, issue: waiting.issue, author: choice.author, reviewer: choice.reviewer } };
|
|
94
|
+
default:
|
|
95
|
+
return { ui };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** The state to draw with: a selection that no longer exists moves to the first row, a toast past its time is dropped, logs of nothing go back. */
|
|
99
|
+
export function settle(ui, s, now) {
|
|
100
|
+
const items = itemsOf(s);
|
|
101
|
+
const current = items.find((i) => i.key === ui.selected);
|
|
102
|
+
const gone = (ui.mode === 'logs' && !current?.executionId) || (ui.mode === 'choose' && !waitingEpic(s, ui.selected)); // logs of nothing, or an epic that started meanwhile
|
|
103
|
+
return {
|
|
104
|
+
selected: current ? ui.selected : (items[0]?.key ?? null), // something is always selected, so the keys always act on a visible row
|
|
105
|
+
mode: gone ? 'dashboard' : ui.mode,
|
|
106
|
+
...(ui.mode === 'choose' && !gone && { choice: ui.choice }),
|
|
107
|
+
// The wait ended with the choice still open: the options picked are lost, so say so.
|
|
108
|
+
toast: gone && ui.mode === 'choose' ? { text: 'Time ran out: started on its defaults', until: now + toastFor } : ui.toast && ui.toast.until > now ? ui.toast : null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
2
|
+
import { autocomplete, autocompleteMultiselect, cancel, confirm, groupMultiselect, intro, isCancel, log, note, outro, select, spinner, text } from '@clack/prompts';
|
|
3
|
+
import { Cancelled } from './types.js';
|
|
4
|
+
const keep = (value) => {
|
|
5
|
+
if (isCancel(value))
|
|
6
|
+
throw new Cancelled();
|
|
7
|
+
return value;
|
|
8
|
+
};
|
|
9
|
+
const checkLine = ({ name, detail }) => (detail ? `${name} — ${detail}` : name);
|
|
10
|
+
const fixLine = (fix) => log.message(styleText('dim', `→ ${fix}`));
|
|
11
|
+
/** One line per check, with its remedy under a failure. */
|
|
12
|
+
export function printCheck(check) {
|
|
13
|
+
if (check.status === 'ok')
|
|
14
|
+
log.success(checkLine(check));
|
|
15
|
+
else if (check.status === 'warn')
|
|
16
|
+
log.warn(checkLine(check));
|
|
17
|
+
else
|
|
18
|
+
log.error(styleText('red', checkLine(check)));
|
|
19
|
+
if (check.fix)
|
|
20
|
+
fixLine(check.fix);
|
|
21
|
+
}
|
|
22
|
+
// Past this many options a plain list is a chore to scroll: type to filter instead.
|
|
23
|
+
const longList = 10;
|
|
24
|
+
/**
|
|
25
|
+
* `compact`: what passes leaves no trace but a final count, only failures are printed (init: nobody needs eight lines
|
|
26
|
+
* that say everything is fine). Not compact: one line per check (the `doctor` command, which is asked for them).
|
|
27
|
+
*/
|
|
28
|
+
export function makeClackUi({ compact = false } = {}) {
|
|
29
|
+
// A check that takes a while (a token from Forge, a call to GitHub) shows a spinner until its result arrives.
|
|
30
|
+
let spin;
|
|
31
|
+
return {
|
|
32
|
+
interactive: true,
|
|
33
|
+
intro: (title) => intro(title),
|
|
34
|
+
outro: (message) => outro(message),
|
|
35
|
+
note: (message, title) => note(message, title),
|
|
36
|
+
warn: (message) => log.warn(message),
|
|
37
|
+
checkStart: (name) => {
|
|
38
|
+
// Compact: one spinner for the whole run whose text changes, so the screen does not jump once per check.
|
|
39
|
+
if (spin === undefined) {
|
|
40
|
+
spin = spinner();
|
|
41
|
+
spin.start(name);
|
|
42
|
+
}
|
|
43
|
+
else
|
|
44
|
+
spin.message(name);
|
|
45
|
+
},
|
|
46
|
+
check: (check) => {
|
|
47
|
+
if (spin === undefined)
|
|
48
|
+
return check.status === 'ok' && compact ? undefined : printCheck(check);
|
|
49
|
+
if (check.status === 'fail')
|
|
50
|
+
spin.error(checkLine(check));
|
|
51
|
+
else if (compact)
|
|
52
|
+
return; // the spinner goes on to the next check
|
|
53
|
+
else
|
|
54
|
+
spin.stop(checkLine(check));
|
|
55
|
+
spin = undefined;
|
|
56
|
+
if (check.fix)
|
|
57
|
+
fixLine(check.fix);
|
|
58
|
+
},
|
|
59
|
+
checksEnd: (total, failed) => {
|
|
60
|
+
if (!compact || spin === undefined)
|
|
61
|
+
return;
|
|
62
|
+
spin.stop(failed === 0 ? `${total} checks passed` : `${total - failed} of ${total} checks passed`);
|
|
63
|
+
spin = undefined;
|
|
64
|
+
},
|
|
65
|
+
text: async ({ validate, ...options }) => keep(await text({ ...options, validate: validate && ((value) => validate(value ?? options.defaultValue ?? '')) })) || options.defaultValue || '',
|
|
66
|
+
select: async (options) => {
|
|
67
|
+
if (options.options.length > longList)
|
|
68
|
+
return keep(await autocomplete({ message: options.message, options: options.options, initialValue: options.initialValue, placeholder: 'Type to filter…', maxItems: 8 }));
|
|
69
|
+
return keep(await select(options));
|
|
70
|
+
},
|
|
71
|
+
confirm: async (message) => keep(await confirm({ message })),
|
|
72
|
+
multiselect: async ({ message, options, initialValues }) => {
|
|
73
|
+
const groups = Object.values(options);
|
|
74
|
+
if (groups.length === 1)
|
|
75
|
+
return keep(await autocompleteMultiselect({ message, options: groups[0], initialValues, required: true, placeholder: 'Type to filter…', maxItems: 8 }));
|
|
76
|
+
return keep(await groupMultiselect({ message, options, initialValues, required: true, maxItems: 12 }));
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** For `--yes` and for pipes: any question that was not answered by a flag is an error that names it. */
|
|
81
|
+
export const noPromptUi = {
|
|
82
|
+
...makeClackUi(),
|
|
83
|
+
interactive: false,
|
|
84
|
+
checkStart: () => { },
|
|
85
|
+
check: (check) => (check.status === 'ok' ? undefined : printCheck(check)), // like the compact one: failures only
|
|
86
|
+
checksEnd: (total, failed) => {
|
|
87
|
+
if (failed === 0)
|
|
88
|
+
log.success(`${total} checks passed`);
|
|
89
|
+
},
|
|
90
|
+
text: async ({ message, defaultValue }) => {
|
|
91
|
+
if (defaultValue !== undefined)
|
|
92
|
+
return defaultValue;
|
|
93
|
+
throw new Error(`"${message}" needs an answer and there is no terminal to ask: pass it as a flag (carrier init --help)`);
|
|
94
|
+
},
|
|
95
|
+
select: async ({ message, initialValue }) => {
|
|
96
|
+
if (initialValue !== undefined)
|
|
97
|
+
return initialValue;
|
|
98
|
+
throw new Error(`"${message}" needs an answer and there is no terminal to ask: pass it as a flag (carrier init --help)`);
|
|
99
|
+
},
|
|
100
|
+
confirm: async () => false,
|
|
101
|
+
multiselect: async ({ message, initialValues }) => {
|
|
102
|
+
if (initialValues?.length)
|
|
103
|
+
return initialValues; // the repo of the current folder
|
|
104
|
+
throw new Error(`"${message}" needs an answer and there is no terminal to ask: pass it as a flag (carrier init --help)`);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
/** "Starting…" while the engine comes up, then the line Carrier arrives with. Only on a terminal. */
|
|
108
|
+
export function announceStart({ framed }) {
|
|
109
|
+
if (!framed)
|
|
110
|
+
intro('Carrier');
|
|
111
|
+
const spin = spinner();
|
|
112
|
+
spin.start('Starting…');
|
|
113
|
+
return {
|
|
114
|
+
arrived: () => {
|
|
115
|
+
spin.stop('Carrier has arrived.');
|
|
116
|
+
outro(styleText('dim', 'Ctrl+C stops it'));
|
|
117
|
+
},
|
|
118
|
+
failed: () => spin.error('Carrier could not start'),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export const cancelled = () => cancel('✖ Cancelled');
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { localKinds } from '../../harnesses/local.js';
|
|
2
|
+
import { harnessNames } from '../../harnesses/names.js';
|
|
3
|
+
/** `forge:<agent>` or `gh`, the form used by flags and by the prompts' values. */
|
|
4
|
+
export function parseIdentity(text) {
|
|
5
|
+
if (text === 'gh')
|
|
6
|
+
return { kind: 'gh' };
|
|
7
|
+
const agent = /^forge:(.+)$/.exec(text)?.[1];
|
|
8
|
+
if (agent !== undefined)
|
|
9
|
+
return { kind: 'forge', agent };
|
|
10
|
+
throw new Error(`invalid identity "${text}", expected "forge:<agent>" or "gh"`);
|
|
11
|
+
}
|
|
12
|
+
export const identityText = (identity) => (identity.kind === 'gh' ? 'gh' : `forge:${identity.agent}`);
|
|
13
|
+
/** `<harness>:<model>`, the harness being one of `harnessNames`. */
|
|
14
|
+
export function parseHarnessModel(text) {
|
|
15
|
+
const match = new RegExp(`^(${harnessNames.join('|')}):(.+)$`).exec(text);
|
|
16
|
+
if (match === null)
|
|
17
|
+
throw new Error(`invalid agent "${text}", expected "<harness>:<model>" with harness one of ${harnessNames.join(", ")}`);
|
|
18
|
+
return { harness: match[1], model: match[2] };
|
|
19
|
+
}
|
|
20
|
+
export function parseProvider(text) {
|
|
21
|
+
const local = localKinds.find((kind) => kind === text);
|
|
22
|
+
if (local === undefined)
|
|
23
|
+
throw new Error(`invalid provider "${text}", expected ${localKinds.join(' or ')}`);
|
|
24
|
+
return local;
|
|
25
|
+
}
|
|
26
|
+
// A forge profile is named after its agent; the user's own login is just "gh".
|
|
27
|
+
const profileName = (identity) => (identity.kind === 'gh' ? 'gh' : identity.agent);
|
|
28
|
+
/** The user config (what goes in the file): only what has no default. The author's profile also acts as orchestrator. */
|
|
29
|
+
export function buildConfig({ repos, author, reviewer, leadReviewer, providers, failover, identities }) {
|
|
30
|
+
const [a, r] = [profileName(author.identity), profileName(reviewer.identity)];
|
|
31
|
+
const l = leadReviewer ? profileName(leadReviewer.identity) : undefined;
|
|
32
|
+
return {
|
|
33
|
+
repos: repos.map((name) => ({ name })),
|
|
34
|
+
...(identities === 'shared' && { identities }), // the default, separate, is not written
|
|
35
|
+
profiles: {
|
|
36
|
+
[a]: author.identity,
|
|
37
|
+
[r]: reviewer.identity,
|
|
38
|
+
...(l && leadReviewer ? { [l]: leadReviewer.identity } : {}),
|
|
39
|
+
},
|
|
40
|
+
orchestratorProfile: a,
|
|
41
|
+
providers,
|
|
42
|
+
...(failover && { failover }),
|
|
43
|
+
agents: {
|
|
44
|
+
author: {
|
|
45
|
+
profile: a,
|
|
46
|
+
provider: author.provider,
|
|
47
|
+
model: author.model,
|
|
48
|
+
...(author.persona && { persona: author.persona }),
|
|
49
|
+
},
|
|
50
|
+
reviewer: {
|
|
51
|
+
profile: r,
|
|
52
|
+
provider: reviewer.provider,
|
|
53
|
+
model: reviewer.model,
|
|
54
|
+
...(reviewer.persona && { persona: reviewer.persona }),
|
|
55
|
+
},
|
|
56
|
+
...(leadReviewer && l ? {
|
|
57
|
+
leadReviewer: {
|
|
58
|
+
profile: l,
|
|
59
|
+
provider: leadReviewer.provider,
|
|
60
|
+
model: leadReviewer.model,
|
|
61
|
+
...(leadReviewer.persona && { persona: leadReviewer.persona }),
|
|
62
|
+
},
|
|
63
|
+
} : {}),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { enginePlacesOf } from '../../config/engine-places.js';
|
|
6
|
+
import { repoNamePattern } from '../../config/schema.js';
|
|
7
|
+
import { defaults } from '../../config/load.js';
|
|
8
|
+
import { cliLoggedIn, runDoctor } from '../../doctor/doctor.js';
|
|
9
|
+
import { ghReposArgs, parseRepos } from '../../integrations/available-repos.js';
|
|
10
|
+
import { forgeCwd } from '../../integrations/forge-cwd.js';
|
|
11
|
+
import { GhGitHub } from '../../integrations/github.js';
|
|
12
|
+
import { listLocalModels } from '../../harnesses/local.js';
|
|
13
|
+
import { readForgeAgents } from './forge-registry.js';
|
|
14
|
+
export const realDeps = {
|
|
15
|
+
which: (command) => spawnSync('which', [command], { stdio: 'ignore' }).status === 0,
|
|
16
|
+
forgeAgents: () => readForgeAgents(),
|
|
17
|
+
ghLogin: () => {
|
|
18
|
+
try {
|
|
19
|
+
return execFileSync('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined; // not logged in or no gh: the doctor reports it, here it only means no hint
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
listRepos: (page) => {
|
|
26
|
+
// no config yet at init: the default bounds the call
|
|
27
|
+
const { repos, hasMore } = parseRepos(execFileSync('gh', ghReposArgs(page), { encoding: 'utf8', timeout: defaults.limits.githubCallTimeoutSeconds * 1000, stdio: ['ignore', 'pipe', 'pipe'] }));
|
|
28
|
+
return { names: repos.map((repo) => repo.name), hasMore };
|
|
29
|
+
},
|
|
30
|
+
originRepo: () => {
|
|
31
|
+
const url = spawnSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8' }).stdout.trim();
|
|
32
|
+
const repo = /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(url)?.[1];
|
|
33
|
+
return repo !== undefined && repoNamePattern.test(repo) ? repo : undefined;
|
|
34
|
+
},
|
|
35
|
+
codexModel: () => {
|
|
36
|
+
try {
|
|
37
|
+
return /^model\s*=\s*"([^"]+)"/m.exec(readFileSync(join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'config.toml'), 'utf8'))?.[1];
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
codexModels: () => {
|
|
44
|
+
try {
|
|
45
|
+
const cache = JSON.parse(readFileSync(join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'models_cache.json'), 'utf8'));
|
|
46
|
+
return (cache.models ?? []).flatMap((m) => (m.visibility === 'list' && m.slug ? [{ value: m.slug, label: m.slug, hint: m.description }] : []));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return []; // no cache yet: the model is typed
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
agyModels: () => {
|
|
53
|
+
try {
|
|
54
|
+
// "Fetching available models..." then `id<TAB>label` per line.
|
|
55
|
+
const out = execFileSync('agy', ['models'], { encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
56
|
+
return out.split('\n').flatMap((line) => {
|
|
57
|
+
const [id, label] = line.split('\t');
|
|
58
|
+
return id && label ? [{ value: id, label: id, hint: label }] : [];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return []; // not installed, not logged in or offline: the model is typed
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
localModels: (local) => listLocalModels(local),
|
|
66
|
+
configFolders: (harness) => {
|
|
67
|
+
const home = homedir();
|
|
68
|
+
const isFolder = (path) => {
|
|
69
|
+
try {
|
|
70
|
+
return statSync(path).isDirectory();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false; // a dangling link
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
return readdirSync(home)
|
|
77
|
+
.filter((name) => (harness === 'claude' ? name === '.claude' || name.startsWith('.claude-') : name === '.codex') && isFolder(join(home, name)))
|
|
78
|
+
.sort()
|
|
79
|
+
.map((name) => join(home, name));
|
|
80
|
+
},
|
|
81
|
+
loggedIn: cliLoggedIn,
|
|
82
|
+
exists: existsSync,
|
|
83
|
+
write: (path, content) => {
|
|
84
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
85
|
+
writeFileSync(path, content);
|
|
86
|
+
},
|
|
87
|
+
doctor: runDoctor,
|
|
88
|
+
readConfig: (path) => JSON.parse(readFileSync(path, 'utf8')),
|
|
89
|
+
forgeAdd: () => spawnSync('agent-forge', ['add'], { stdio: 'inherit' }).status === 0,
|
|
90
|
+
readyEpics: async (config, folder) => {
|
|
91
|
+
const github = new GhGitHub({ profile: config.profiles[config.orchestratorProfile], cwd: forgeCwd(enginePlacesOf(folder)), timeoutMs: config.limits.githubCallTimeoutSeconds * 1000 });
|
|
92
|
+
return Object.fromEntries(await Promise.all(config.repos.map(async (repo) => [repo.name, await github.listReadyEpics(repo.name).then((epics) => epics.length, () => undefined)])));
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
/** Same lookup order as Agent Forge: $AGENT_FORGE_CONFIG, the global registry, then ./agents.json. */
|
|
5
|
+
export function forgeRegistryPath(env = process.env, cwd = process.cwd()) {
|
|
6
|
+
if (env.AGENT_FORGE_CONFIG)
|
|
7
|
+
return resolve(env.AGENT_FORGE_CONFIG);
|
|
8
|
+
const global = join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'agent-forge', 'agents.json');
|
|
9
|
+
if (existsSync(global))
|
|
10
|
+
return global;
|
|
11
|
+
const local = resolve(cwd, 'agents.json');
|
|
12
|
+
return existsSync(local) ? local : undefined;
|
|
13
|
+
}
|
|
14
|
+
/** The agents Forge knows about (names only, never keys). Empty when there is no registry; throws when it is unreadable. */
|
|
15
|
+
export function readForgeAgents(path = forgeRegistryPath()) {
|
|
16
|
+
if (path === undefined || !existsSync(path))
|
|
17
|
+
return [];
|
|
18
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
19
|
+
const agents = parsed.agents;
|
|
20
|
+
if (!Array.isArray(agents))
|
|
21
|
+
return [];
|
|
22
|
+
return agents.flatMap((agent) => typeof agent?.name === 'string' ? [{ name: agent.name, ...(typeof agent.label === 'string' ? { label: agent.label } : {}) }] : []);
|
|
23
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defaults, loadConfig } from '../../config/load.js';
|
|
2
|
+
import { tilde } from './summary.js';
|
|
3
|
+
export function priorFrom(raw) {
|
|
4
|
+
const loaded = loadConfig(defaults, raw);
|
|
5
|
+
if (!loaded.ok)
|
|
6
|
+
return undefined;
|
|
7
|
+
const { config } = loaded;
|
|
8
|
+
const role = (r) => ({ identity: config.profiles[r.profile], provider: r.provider, model: r.model, ...(r.persona && { persona: r.persona }) });
|
|
9
|
+
// The loader makes a folder absolute; written back with `~` it stays portable.
|
|
10
|
+
const providers = Object.fromEntries(Object.entries(config.providers).map(([name, p]) => [name, p.configDir ? { ...p, configDir: tilde(p.configDir) } : p]));
|
|
11
|
+
return {
|
|
12
|
+
repos: config.repos.map((r) => r.name),
|
|
13
|
+
identities: config.identities,
|
|
14
|
+
providers,
|
|
15
|
+
...(config.failover && { failover: config.failover }),
|
|
16
|
+
author: role(config.agents.author),
|
|
17
|
+
reviewer: role(config.agents.reviewer),
|
|
18
|
+
...(config.agents.leadReviewer ? { leadReviewer: role(config.agents.leadReviewer) } : {}),
|
|
19
|
+
};
|
|
20
|
+
}
|