@gaia-ai/conductor 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ export interface InitInputs {
2
+ baseUrl: string;
3
+ project: string;
4
+ clientId: string;
5
+ /** The resolved OAuth client secret value (stored in the machine context). */
6
+ secret: string;
7
+ machineId?: string;
8
+ userId?: string;
9
+ }
10
+ export interface ScaffoldOptions {
11
+ configPath: string;
12
+ force: boolean;
13
+ machinePath?: string;
14
+ /** When true, scaffold the committed config only — never read/write the machine context. */
15
+ skipMachine?: boolean;
16
+ /** When true, scaffold the machine context only — never write the committed config. */
17
+ skipCommitted?: boolean;
18
+ }
19
+ export interface ScaffoldResult {
20
+ committedPath: string;
21
+ wroteCommitted: boolean;
22
+ machine: MachineContextResult;
23
+ }
24
+ /**
25
+ * The user-global machine context: a plain importable module carrying the
26
+ * developer's machine identity and connection (incl. the OAuth client secret).
27
+ * Committed configs import it to compose machine_id and read
28
+ * base_url / client_id / client_secret. Gitignored, user-only (chmod 0600).
29
+ */
30
+ export interface MachineContext {
31
+ machine_id: string;
32
+ user_id: string;
33
+ base_url: string;
34
+ client_id: string;
35
+ client_secret: string;
36
+ }
37
+ export interface MachineContextOptions {
38
+ path: string;
39
+ userId: string;
40
+ baseUrl: string;
41
+ clientId: string;
42
+ secret: string;
43
+ machineId?: string;
44
+ }
45
+ export interface MachineContextResult {
46
+ path: string;
47
+ created: boolean;
48
+ filledKeys: string[];
49
+ }
50
+ /**
51
+ * The committed, structural conductor config. `project` is the only per-repo
52
+ * value and is baked in here; connection + identity (incl. the secret) come from
53
+ * the user-global machine context (~/.config/conductor/conductor.config.machine.js),
54
+ * and machine_id is composed as `${user_id}-${machine_id}-${project}`. An
55
+ * optional, gitignored conductor.config.local.js beside this file may override
56
+ * any field — it is loaded if present but never created by `gaia conductor init`.
57
+ */
58
+ export declare function renderCommittedConfig(inputs: Pick<InitInputs, 'project'>): string;
59
+ /** The user-global machine context module: identity + connection (incl. secret). */
60
+ export declare function renderMachineContext(ctx: MachineContext): string;
61
+ /** The user-global machine context path: ~/.config/conductor/conductor.config.machine.js */
62
+ export declare function machineContextPath(): string;
63
+ /** Import an existing context module's default export, or {} if absent/broken. */
64
+ export declare function readMachineContext(path: string): Promise<Partial<MachineContext>>;
65
+ /**
66
+ * Create-if-missing / fill-only-missing the user-global machine context.
67
+ * Existing values always win; only absent/blank keys are filled. machine_id
68
+ * defaults to hostname(). A no-op (no rewrite) when the file is already complete.
69
+ * The file is written user-only (chmod 0600) since it holds the client secret.
70
+ */
71
+ export declare function scaffoldMachineContext(opts: MachineContextOptions): Promise<MachineContextResult>;
72
+ /**
73
+ * Scaffold the two conductor config files: the committed conductor.config.js
74
+ * (created only if missing — never overwritten unless `force`) and the
75
+ * user-global machine context (create-if-missing / fill-only-missing). The
76
+ * optional per-project conductor.config.local.js is NOT generated.
77
+ *
78
+ * `skipMachine` writes the committed config only (project-only setup); its
79
+ * mirror `skipCommitted` writes the machine context only (machine-only
80
+ * onboarding, no repo). Setting both is a no-op.
81
+ */
82
+ export declare function scaffold(inputs: InitInputs, opts: ScaffoldOptions): Promise<ScaffoldResult>;
@@ -0,0 +1,232 @@
1
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { hostname } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ /** JS single-quoted string literal for a trusted, simple value. */
6
+ function q(value) {
7
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
8
+ }
9
+ /**
10
+ * The committed, structural conductor config. `project` is the only per-repo
11
+ * value and is baked in here; connection + identity (incl. the secret) come from
12
+ * the user-global machine context (~/.config/conductor/conductor.config.machine.js),
13
+ * and machine_id is composed as `${user_id}-${machine_id}-${project}`. An
14
+ * optional, gitignored conductor.config.local.js beside this file may override
15
+ * any field — it is loaded if present but never created by `gaia conductor init`.
16
+ */
17
+ export function renderCommittedConfig(inputs) {
18
+ return `// Canonical GAIA conductor config — committed. Connection + identity come from
19
+ // your user-global machine context (~/.config/conductor/conductor.config.machine.js:
20
+ // { machine_id, user_id, base_url, client_id, client_secret }); machine_id is
21
+ // composed here as \`\${user_id}-\${machine_id}-\${project}\`. \`project\` is the only
22
+ // per-repo value and is baked in below. The client_secret is read from the
23
+ // machine context (gitignored, user-only) — never committed here.
24
+ //
25
+ // IMPORT-FREE (GAIA-78): the plugin slots + plugins[] are \`{ plugin, with }\`
26
+ // descriptors naming the REAL published package (\`@gaia-ai/plugin-*\`,
27
+ // \`@dropsh/plugin-*\`), not \`import\`ed constructors. loadConductorConfig
28
+ // resolves each name ESLint-style (config dir → cwd → conductor install), so
29
+ // config load never depends on a \`node_modules/@gaia-ai\` symlink beside this
30
+ // file. Each plugin package default-exports its factory, so the resolver's
31
+ // auto-pick needs no \`export:\` here — only the \`@gaia-ai/gaia/plugins\` host
32
+ // barrel (many exports) still names one via \`export: 'drupalRemote'\`.
33
+
34
+ // The user-global machine context: identity + connection (incl. secret), shared
35
+ // by every project on this machine. Never committed.
36
+ async function loadMachine() {
37
+ try {
38
+ return (await import(\`\${process.env.HOME}/.config/conductor/conductor.config.machine.js\`)).default ?? {};
39
+ } catch {}
40
+ return {};
41
+ }
42
+
43
+ // OPTIONAL per-project override — create conductor.config.local.js beside this
44
+ // file to override any field (machine_id, base_url, model, …). It is loaded only
45
+ // if present and is NOT created by \`gaia conductor init\`.
46
+ async function loadLocal() {
47
+ try { return (await import('./conductor.config.local.js')).default ?? {}; } catch {}
48
+ return {};
49
+ }
50
+
51
+ const machine = await loadMachine();
52
+ const local = await loadLocal();
53
+ const project = local.project ?? ${q(inputs.project)};
54
+ const baseUrl = local.base_url ?? machine.base_url;
55
+ const clientId = local.oauth?.client_id ?? machine.client_id ?? 'gaia-agent';
56
+ const clientSecret = local.oauth?.client_secret ?? machine.client_secret;
57
+ const composedMachineId =
58
+ machine.user_id && machine.machine_id
59
+ ? \`\${machine.user_id}-\${machine.machine_id}-\${project}\`
60
+ : undefined;
61
+
62
+ export default {
63
+ site: { base_url: baseUrl, jsonapi_prefix: local.jsonapi_prefix ?? '/jsonapi' },
64
+ project,
65
+ machine_id: local.machine_id ?? composedMachineId,
66
+ states: ['spec', 'diagnose', 'coding', 'review'],
67
+ max_parallel: 5,
68
+ // Lifecycle hooks are executor-owned (GAIA-84): the executor invokes each
69
+ // best-effort (logs loudly + continues, never aborts a run), so they live at
70
+ // the config top level — NOT on a plugin descriptor's \`with.hooks\`.
71
+ hooks: { after_create: 'ddev init-worktree', after_done: 'ddev delete -Oy' },
72
+ remote: { plugin: '@gaia-ai/gaia/plugins', export: 'drupalRemote' },
73
+ // No hard-wired diff pane for review: the review diff surface is hunk
74
+ // (GAIA-55) — agent-driven + opt-in in the human's interactive pane, not an
75
+ // executor-forced git-diff pane. Clicking a changed file in that hunk pane
76
+ // opens it editable in a spiceedit overlay (see conductor/README.md).
77
+ executor: { plugin: '@gaia-ai/plugin-herdr' },
78
+ agent: {
79
+ plugin: '@gaia-ai/plugin-claude',
80
+ with: { model: local.model ?? 'claude-opus-4-8' },
81
+ },
82
+ workspace: {
83
+ plugin: '@gaia-ai/plugin-herdr-workspace',
84
+ },
85
+ // oauth2 is a real dep of the host (npm installs it alongside @gaia-ai/gaia).
86
+ // NOTE: plugins[] is consumed by DROPSH, which reloads this config with its OWN
87
+ // resolver (\`export ?? 'default'\`, no sole-function auto-pick) on every
88
+ // \`gaia dropsh …\` command. @dropsh/plugin-oauth2 has no default export, so
89
+ // these entries MUST name \`export: 'oauth2Plugin'\` — unlike the four conductor
90
+ // slots above, which the conductor resolves and auto-picks.
91
+ plugins: [
92
+ {
93
+ plugin: '@dropsh/plugin-oauth2',
94
+ export: 'oauth2Plugin',
95
+ with: {
96
+ id: 'session',
97
+ default: true,
98
+ type: 'oauth2_client_credentials',
99
+ client_id: clientId,
100
+ client_secret: clientSecret,
101
+ token_url: \`\${baseUrl}/oauth/token\`,
102
+ scope: 'gaia:session',
103
+ },
104
+ },
105
+ {
106
+ plugin: '@dropsh/plugin-oauth2',
107
+ export: 'oauth2Plugin',
108
+ with: {
109
+ id: 'pm',
110
+ type: 'oauth2_client_credentials',
111
+ client_id: clientId,
112
+ client_secret: clientSecret,
113
+ token_url: \`\${baseUrl}/oauth/token\`,
114
+ scope: 'gaia:project_manager',
115
+ },
116
+ },
117
+ ],
118
+ };
119
+ `;
120
+ }
121
+ /** The user-global machine context module: identity + connection (incl. secret). */
122
+ export function renderMachineContext(ctx) {
123
+ return `// User-global conductor context — gitignored, user-only (chmod 0600), never
124
+ // committed. A plain importable module holding your machine identity +
125
+ // connection, incl. the OAuth client secret. Committed conductor.config.js files
126
+ // import this to compose machine_id (\`\${user_id}-\${machine_id}-\${project}\`) and
127
+ // read base_url / client_id / client_secret. Created and gap-filled by
128
+ // \`gaia conductor init\`; existing values are never overwritten.
129
+ export default {
130
+ machine_id: ${q(ctx.machine_id)},
131
+ user_id: ${q(ctx.user_id)},
132
+ base_url: ${q(ctx.base_url)},
133
+ client_id: ${q(ctx.client_id)},
134
+ client_secret: ${q(ctx.client_secret)},
135
+ };
136
+ `;
137
+ }
138
+ /** The user-global machine context path: ~/.config/conductor/conductor.config.machine.js */
139
+ export function machineContextPath() {
140
+ return join(process.env.HOME ?? '', '.config', 'conductor', 'conductor.config.machine.js');
141
+ }
142
+ /** Import an existing context module's default export, or {} if absent/broken. */
143
+ export async function readMachineContext(path) {
144
+ if (!existsSync(path))
145
+ return {};
146
+ try {
147
+ // Cache-bust so a re-render within one process re-reads fresh.
148
+ const mod = await import(`${pathToFileURL(path).href}?t=${Date.now()}`);
149
+ const raw = mod.default;
150
+ return raw && typeof raw === 'object'
151
+ ? raw
152
+ : {};
153
+ }
154
+ catch {
155
+ return {};
156
+ }
157
+ }
158
+ /**
159
+ * Create-if-missing / fill-only-missing the user-global machine context.
160
+ * Existing values always win; only absent/blank keys are filled. machine_id
161
+ * defaults to hostname(). A no-op (no rewrite) when the file is already complete.
162
+ * The file is written user-only (chmod 0600) since it holds the client secret.
163
+ */
164
+ export async function scaffoldMachineContext(opts) {
165
+ const existing = await readMachineContext(opts.path);
166
+ const derived = {
167
+ machine_id: opts.machineId ?? hostname(),
168
+ user_id: opts.userId,
169
+ base_url: opts.baseUrl,
170
+ client_id: opts.clientId,
171
+ client_secret: opts.secret,
172
+ };
173
+ const filledKeys = [];
174
+ const merged = { ...derived, ...existing };
175
+ for (const key of [
176
+ 'machine_id',
177
+ 'user_id',
178
+ 'base_url',
179
+ 'client_id',
180
+ 'client_secret',
181
+ ]) {
182
+ const cur = existing[key];
183
+ if (typeof cur !== 'string' || cur.trim() === '') {
184
+ merged[key] = derived[key];
185
+ filledKeys.push(key);
186
+ }
187
+ }
188
+ const created = !existsSync(opts.path);
189
+ if (filledKeys.length > 0 || created) {
190
+ mkdirSync(dirname(opts.path), { recursive: true });
191
+ writeFileSync(opts.path, renderMachineContext(merged), 'utf8');
192
+ }
193
+ // Always tighten perms — the file holds a secret.
194
+ if (existsSync(opts.path))
195
+ chmodSync(opts.path, 0o600);
196
+ return { path: opts.path, created, filledKeys };
197
+ }
198
+ /**
199
+ * Scaffold the two conductor config files: the committed conductor.config.js
200
+ * (created only if missing — never overwritten unless `force`) and the
201
+ * user-global machine context (create-if-missing / fill-only-missing). The
202
+ * optional per-project conductor.config.local.js is NOT generated.
203
+ *
204
+ * `skipMachine` writes the committed config only (project-only setup); its
205
+ * mirror `skipCommitted` writes the machine context only (machine-only
206
+ * onboarding, no repo). Setting both is a no-op.
207
+ */
208
+ export async function scaffold(inputs, opts) {
209
+ const committedPath = opts.configPath;
210
+ let wroteCommitted = false;
211
+ if (!opts.skipCommitted) {
212
+ mkdirSync(dirname(committedPath), { recursive: true });
213
+ if (!existsSync(committedPath) || opts.force) {
214
+ writeFileSync(committedPath, renderCommittedConfig(inputs), 'utf8');
215
+ wroteCommitted = true;
216
+ }
217
+ }
218
+ const machinePath = opts.machinePath ?? machineContextPath();
219
+ const machine = opts.skipMachine
220
+ ? { path: machinePath, created: false, filledKeys: [] }
221
+ : await scaffoldMachineContext({
222
+ path: machinePath,
223
+ userId: inputs.userId ?? '',
224
+ baseUrl: inputs.baseUrl,
225
+ clientId: inputs.clientId,
226
+ secret: inputs.secret,
227
+ ...(inputs.machineId !== undefined
228
+ ? { machineId: inputs.machineId }
229
+ : {}),
230
+ });
231
+ return { committedPath, wroteCommitted, machine };
232
+ }
@@ -0,0 +1,14 @@
1
+ export interface ConductorRegistryEntry {
2
+ id: string;
3
+ path: string;
4
+ project: string;
5
+ label: string;
6
+ host: 'herdr' | 'process' | 'systemd';
7
+ handle: string;
8
+ }
9
+ /** `${GAIA_HOME ?? ~}/.gaia/conductors.json`. */
10
+ export declare function registryPath(): string;
11
+ export declare function register(entry: ConductorRegistryEntry): Promise<void>;
12
+ export declare function remove(id: string): Promise<void>;
13
+ export declare function list(): Promise<ConductorRegistryEntry[]>;
14
+ export declare function get(id: string): Promise<ConductorRegistryEntry | null>;
@@ -0,0 +1,56 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ /** `${GAIA_HOME ?? ~}/.gaia/conductors.json`. */
5
+ export function registryPath() {
6
+ const home = process.env.GAIA_HOME ?? homedir();
7
+ return join(home, '.gaia', 'conductors.json');
8
+ }
9
+ async function read() {
10
+ let raw;
11
+ try {
12
+ raw = await readFile(registryPath(), 'utf8');
13
+ }
14
+ catch {
15
+ return {};
16
+ }
17
+ if (raw.trim() === '') {
18
+ return {};
19
+ }
20
+ try {
21
+ const parsed = JSON.parse(raw);
22
+ if (typeof parsed === 'object' &&
23
+ parsed !== null &&
24
+ !Array.isArray(parsed)) {
25
+ return parsed;
26
+ }
27
+ return {};
28
+ }
29
+ catch {
30
+ return {};
31
+ }
32
+ }
33
+ async function write(data) {
34
+ const path = registryPath();
35
+ await mkdir(dirname(path), { recursive: true });
36
+ await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
37
+ }
38
+ export async function register(entry) {
39
+ const data = await read();
40
+ data[entry.id] = entry;
41
+ await write(data);
42
+ }
43
+ export async function remove(id) {
44
+ const data = await read();
45
+ if (data[id]) {
46
+ delete data[id];
47
+ await write(data);
48
+ }
49
+ }
50
+ export async function list() {
51
+ return Object.values(await read());
52
+ }
53
+ export async function get(id) {
54
+ const data = await read();
55
+ return data[id] ?? null;
56
+ }
@@ -0,0 +1,23 @@
1
+ import { type ConductorFileConfig } from '@gaia-ai/core';
2
+ /**
3
+ * Default agent prompt - the GAIA run contract. One run works EXACTLY one state;
4
+ * the agent must stop instead of running the whole flow in one session. The run
5
+ * is closed automatically when the ticket state changes on the next claim - the
6
+ * agent does not release it. Run mechanics live here (not in the repo's
7
+ * WORKFLOW.md). A conductor config may override via the `prompt` field.
8
+ * Placeholders: `{identifier}`, `{state}`, `{runUuid}`, `{comments}` ({state}
9
+ * falls back to `triage` for unclassified tickets; {comments} is the ticket's
10
+ * comments, oldest first, or a "no comments yet" note).
11
+ */
12
+ export declare const DEFAULT_AGENT_PROMPT: string;
13
+ /**
14
+ * Resolve the conductor config path from `cwd`. An explicit `--config`
15
+ * override or `$GAIA_CONDUCTOR_CONFIG` wins verbatim (short-circuit, no
16
+ * filesystem walk). Otherwise walk from `cwd` root-ward (git/eslint style) to
17
+ * the nearest ancestor holding `.gaia/conductor.config.js` and return that
18
+ * absolute path — so any subdirectory of a project/worktree resolves the same
19
+ * config. If none is found up to the filesystem root, throw a clear, actionable
20
+ * error instead of leaking a raw "Cannot find module" from a later import().
21
+ */
22
+ export declare function resolveConfigPath(override?: string, cwd?: string): string;
23
+ export declare function loadConductorConfig(configFile: string): Promise<ConductorFileConfig>;
@@ -0,0 +1,217 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { conductorId, } from '@gaia-ai/core';
6
+ /**
7
+ * Default agent prompt - the GAIA run contract. One run works EXACTLY one state;
8
+ * the agent must stop instead of running the whole flow in one session. The run
9
+ * is closed automatically when the ticket state changes on the next claim - the
10
+ * agent does not release it. Run mechanics live here (not in the repo's
11
+ * WORKFLOW.md). A conductor config may override via the `prompt` field.
12
+ * Placeholders: `{identifier}`, `{state}`, `{runUuid}`, `{comments}` ({state}
13
+ * falls back to `triage` for unclassified tickets; {comments} is the ticket's
14
+ * comments, oldest first, or a "no comments yet" note).
15
+ */
16
+ export const DEFAULT_AGENT_PROMPT = `You are a GAIA agent working ticket {identifier}, current state: {state}. ` +
17
+ `Follow the "{state}" section of ./WORKFLOW.md in this repository. ` +
18
+ `This run covers EXACTLY the {state} state - do only its work. Do not start, ` +
19
+ `prepare, or perform any later state's work, even if WORKFLOW.md mentions ` +
20
+ `transitioning onward. When the {state} work is done and you have no open ` +
21
+ `questions, STOP - do not pick up or work any further state or ticket. If ` +
22
+ `blocked or you need a human, stop and surface the blocker. After you have ` +
23
+ `written the ticket's next state, run /exit to end this session.\n\n` +
24
+ `The ticket's comments are included below (oldest first). Read them before ` +
25
+ `acting: the latest \`summary\` is the review feedback you MUST address, and ` +
26
+ `any \`spec\`/\`debug_diagnose\` is the agreed plan/diagnosis. Treat their ` +
27
+ `points as in-scope acceptance criteria.\n\n--- Ticket comments ---\n{comments}`;
28
+ function requirePlugin(value, kind) {
29
+ if (!isRecord(value) || value.kind !== kind) {
30
+ throw new Error(`conductor config requires a ${kind} plugin in the "${kind}" slot`);
31
+ }
32
+ return value;
33
+ }
34
+ /**
35
+ * A named-plugin descriptor `{ plugin, export?, with?/options? }` — the
36
+ * import-free form. `config.ts` resolves it into a constructed plugin, so a
37
+ * committed conductor.config.js need not `import` its plugin packages (which
38
+ * would resolve relative to the config file, not the CLI — the source of the
39
+ * "unknown command dropsh" breakage after the @gaia → @gaia-ai scope rename).
40
+ * Mirror of dropsh 0.4.1 `loadNamedPlugin` / `resolveSlot`.
41
+ */
42
+ function isPluginDescriptor(entry) {
43
+ return isRecord(entry) && typeof entry.plugin === 'string';
44
+ }
45
+ /**
46
+ * Resolve + construct a descriptor. Resolution base order, ESLint-style:
47
+ * config dir → cwd → conductor install (`import.meta.url`) — the first
48
+ * `createRequire(base).resolve(name)` that succeeds wins. Then `import` the
49
+ * module, pick `export` (default 'default'), and call the factory with
50
+ * `with` (falling back to `options`).
51
+ */
52
+ async function loadNamedPlugin(entry, configPath) {
53
+ const bases = [
54
+ pathToFileURL(configPath).href,
55
+ pathToFileURL(`${process.cwd()}/`).href,
56
+ import.meta.url,
57
+ ];
58
+ let resolved;
59
+ for (const base of bases) {
60
+ try {
61
+ resolved = createRequire(base).resolve(entry.plugin);
62
+ break;
63
+ }
64
+ catch {
65
+ // try the next base
66
+ }
67
+ }
68
+ if (resolved === undefined) {
69
+ throw new Error(`conductor config cannot resolve plugin '${entry.plugin}'`);
70
+ }
71
+ const mod = (await import(pathToFileURL(resolved).href));
72
+ let factory;
73
+ if (typeof entry.export === 'string') {
74
+ factory = mod[entry.export];
75
+ if (typeof factory !== 'function') {
76
+ throw new Error(`plugin '${entry.plugin}' has no callable export '${entry.export}'`);
77
+ }
78
+ }
79
+ else if (typeof mod.default === 'function') {
80
+ factory = mod.default;
81
+ }
82
+ else {
83
+ const fns = Object.keys(mod).filter((k) => typeof mod[k] === 'function');
84
+ if (fns.length === 1) {
85
+ factory = mod[fns[0]];
86
+ }
87
+ else {
88
+ throw new Error(`plugin '${entry.plugin}' has no default export and ${fns.length} function exports (${fns.join(', ')}); specify "export"`);
89
+ }
90
+ }
91
+ return factory(entry.with ?? entry.options);
92
+ }
93
+ /**
94
+ * Resolve a slot value: a descriptor is constructed via `loadNamedPlugin`, an
95
+ * already-constructed plugin passes straight through (backward-compat). Either
96
+ * way the kind-guard runs, so a wrong-target descriptor still errors.
97
+ */
98
+ async function resolveSlot(value, kind, configPath) {
99
+ const resolved = isPluginDescriptor(value)
100
+ ? await loadNamedPlugin(value, configPath)
101
+ : value;
102
+ return requirePlugin(resolved, kind);
103
+ }
104
+ /**
105
+ * Resolve the `plugins[]` array: descriptor entries are constructed, already-
106
+ * constructed entries pass through. No kind-guard (plugins are not slotted).
107
+ */
108
+ async function resolvePlugins(raw, configPath) {
109
+ if (!Array.isArray(raw)) {
110
+ return [];
111
+ }
112
+ const resolved = await Promise.all(raw.map((entry) => isPluginDescriptor(entry) ? loadNamedPlugin(entry, configPath) : entry));
113
+ return resolved;
114
+ }
115
+ function isRecord(value) {
116
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
117
+ }
118
+ function requireNonEmptyString(value, key) {
119
+ if (typeof value !== 'string' || value.trim() === '') {
120
+ throw new Error(`conductor config requires non-empty ${key}`);
121
+ }
122
+ return value;
123
+ }
124
+ function optionalPositiveInteger(value, fallback, key) {
125
+ if (value === undefined) {
126
+ return fallback;
127
+ }
128
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
129
+ throw new Error(`conductor config requires positive integer ${key}`);
130
+ }
131
+ return value;
132
+ }
133
+ /** The conductor config lives at .gaia/conductor.config.js inside a project root. */
134
+ const CONFIG_RELATIVE_PATH = join('.gaia', 'conductor.config.js');
135
+ /**
136
+ * Resolve the conductor config path from `cwd`. An explicit `--config`
137
+ * override or `$GAIA_CONDUCTOR_CONFIG` wins verbatim (short-circuit, no
138
+ * filesystem walk). Otherwise walk from `cwd` root-ward (git/eslint style) to
139
+ * the nearest ancestor holding `.gaia/conductor.config.js` and return that
140
+ * absolute path — so any subdirectory of a project/worktree resolves the same
141
+ * config. If none is found up to the filesystem root, throw a clear, actionable
142
+ * error instead of leaking a raw "Cannot find module" from a later import().
143
+ */
144
+ export function resolveConfigPath(override, cwd = process.cwd()) {
145
+ const explicit = override ?? process.env.GAIA_CONDUCTOR_CONFIG;
146
+ if (explicit) {
147
+ return explicit;
148
+ }
149
+ let dir = resolve(cwd);
150
+ for (;;) {
151
+ const candidate = join(dir, CONFIG_RELATIVE_PATH);
152
+ if (existsSync(candidate)) {
153
+ return candidate;
154
+ }
155
+ const parent = dirname(dir);
156
+ if (parent === dir) {
157
+ break;
158
+ }
159
+ dir = parent;
160
+ }
161
+ throw new Error(`no .gaia/conductor.config.js found from ${cwd} upward; run \`gaia init\``);
162
+ }
163
+ export async function loadConductorConfig(configFile) {
164
+ const configPath = resolve(configFile);
165
+ const module = (await import(pathToFileURL(configPath).href));
166
+ const raw = module.default;
167
+ if (!isRecord(raw)) {
168
+ throw new Error('conductor config default export must be an object');
169
+ }
170
+ const config = raw;
171
+ const site = isRecord(config.site) ? config.site : {};
172
+ const baseUrl = requireNonEmptyString(site.base_url, 'site.base_url');
173
+ const project = requireNonEmptyString(config.project, 'project');
174
+ if (!Array.isArray(config.states) || config.states.length === 0) {
175
+ throw new Error('conductor config requires non-empty states');
176
+ }
177
+ const states = config.states.map((state) => requireNonEmptyString(state, 'states'));
178
+ const checkoutRoot = dirname(configPath);
179
+ // Resolve the effective machine_id once (config override or hostname+path
180
+ // hash) so the id is defined in a single place — the CLI lifecycle commands
181
+ // and registration all read config.machine_id, never re-derive it. The
182
+ // conductor label defaults to this id (a conductor is identified by it).
183
+ const machineId = typeof config.machine_id === 'string' && config.machine_id.trim() !== ''
184
+ ? config.machine_id
185
+ : conductorId(checkoutRoot);
186
+ const label = typeof config.label === 'string' && config.label.trim() !== ''
187
+ ? config.label
188
+ : machineId;
189
+ return {
190
+ site: {
191
+ base_url: baseUrl,
192
+ jsonapi_prefix: typeof site.jsonapi_prefix === 'string' &&
193
+ site.jsonapi_prefix.trim() !== ''
194
+ ? site.jsonapi_prefix
195
+ : '/jsonapi',
196
+ },
197
+ ...(Array.isArray(config.plugins)
198
+ ? { plugins: await resolvePlugins(config.plugins, configPath) }
199
+ : {}),
200
+ remote: await resolveSlot(config.remote, 'remote', configPath),
201
+ executor: await resolveSlot(config.executor, 'executor', configPath),
202
+ agent: await resolveSlot(config.agent, 'agent', configPath),
203
+ workspace: await resolveSlot(config.workspace, 'workspace', configPath),
204
+ label,
205
+ machine_id: machineId,
206
+ project,
207
+ states,
208
+ prompt: typeof config.prompt === 'string' && config.prompt.trim() !== ''
209
+ ? config.prompt
210
+ : DEFAULT_AGENT_PROMPT,
211
+ max_parallel: optionalPositiveInteger(config.max_parallel, 1, 'max_parallel'),
212
+ poll_interval_ms: optionalPositiveInteger(config.poll_interval_ms, 5000, 'poll_interval_ms'),
213
+ lease_seconds: optionalPositiveInteger(config.lease_seconds, 300, 'lease_seconds'),
214
+ ...(config.hooks !== undefined ? { hooks: config.hooks } : {}),
215
+ config_path: configPath,
216
+ };
217
+ }