@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.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/src/cli/gaia.d.ts +19 -0
- package/dist/src/cli/gaia.js +644 -0
- package/dist/src/cli/init.d.ts +82 -0
- package/dist/src/cli/init.js +232 -0
- package/dist/src/cli/local-registry.d.ts +14 -0
- package/dist/src/cli/local-registry.js +56 -0
- package/dist/src/config.d.ts +23 -0
- package/dist/src/config.js +217 -0
- package/dist/src/core/conductor.d.ts +71 -0
- package/dist/src/core/conductor.js +410 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +5 -0
- package/package.json +34 -0
|
@@ -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
|
+
}
|