@gaia-ai/conductor 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,51 @@
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). The prompt routes through the gaia skill (`ticket:run`) rather
8
+ * than pointing at WORKFLOW.md directly (GAIA-125): the intake splash + state
9
+ * engine are a skill mechanic, so a bare "follow WORKFLOW.md" pointer left the
10
+ * splash unrendered unless an external skill-forcing hook happened to fire. A
11
+ * conductor config may override via the `prompt` field.
12
+ *
13
+ * The ticket + its comments are NOT embedded (GAIA-112): embedding unbounded
14
+ * ticket content into a single typed pane line overran the PTY canonical line
15
+ * cap and truncated the dispatch command. Instead the prompt is a bounded,
16
+ * constant-size pointer and the agent reads the ticket + all comments at run
17
+ * start via `gaia dropsh read … --include comments`.
18
+ *
19
+ * HINT — the canonical cap is platform-specific: MAX_CANON is 4096 B on Linux
20
+ * but only 1024 B on macOS (a whole line >= 1024 B is silently DROPPED there).
21
+ * herdr types env-prefix + this prompt + flags as ONE line, so keep the total
22
+ * well under 1024 B — i.e. keep this prompt short (~a few hundred bytes). Do NOT
23
+ * grow it back toward the old ~1 KB, or macOS dispatch truncates silently again.
24
+ *
25
+ * The prompt is a SINGLE LINE — no newlines, no control chars (GAIA-128). herdr
26
+ * types it into the pane as one line, so a newline would submit early and any
27
+ * control char would force bash-only `$'…'` quoting that fish can't parse.
28
+ * Being single-line + bounded, plain `'…'` shell-quoting suffices (fish-safe)
29
+ * and the typed line stays well under the cap (1024 B on macOS, 4096 B on Linux)
30
+ * — so NO base64/`bash -c` wrapper and NO multiline handling are needed (that
31
+ * wrapper, GAIA-118, was the thing that re-inflated the line past the cap and
32
+ * truncated it mid-quote).
33
+ * Placeholders: `{identifier}`, `{state}`, `{runUuid}` ({state} falls back to
34
+ * `triage` for unclassified tickets).
35
+ */
36
+ export declare const DEFAULT_AGENT_PROMPT: string;
37
+ /**
38
+ * Resolve the conductor config path from `cwd`.
39
+ *
40
+ * 1. An explicit `--config` / `$GAIA_CONDUCTOR_CONFIG` wins verbatim (no walk).
41
+ * 2. Otherwise walk root-ward to the nearest `.gaia/` dir holding `*.config.js`
42
+ * — so any subdirectory of a project/worktree resolves the same dir.
43
+ * - A `--conductor <name>` / `$GAIA_CONDUCTOR` selector resolves
44
+ * `<gaiaDir>/<name>.config.js` (error listing the stems if absent).
45
+ * - No selector: exactly one config → use it (back-compat: the lone
46
+ * `conductor.config.js`); many → error naming the stems + the selector.
47
+ * 3. No `.gaia/` config anywhere up the tree → an actionable `gaia init` error
48
+ * (never leak a raw "Cannot find module" from a later import()).
49
+ */
50
+ export declare function resolveConfigPath(override?: string, cwd?: string, conductorName?: string): string;
51
+ export declare function loadConductorConfig(configFile: string): Promise<ConductorFileConfig>;