@gaia-ai/conductor 0.5.5 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/dist/src/cli/config-schema.d.ts +89 -0
  3. package/dist/src/cli/config-schema.js +146 -0
  4. package/dist/src/cli/init.d.ts +31 -33
  5. package/dist/src/cli/init.js +106 -84
  6. package/dist/src/cli/migrate-addon-names.d.ts +72 -0
  7. package/dist/src/cli/migrate-addon-names.js +318 -0
  8. package/dist/src/cli/upgrade.d.ts +53 -0
  9. package/dist/src/cli/upgrade.js +222 -0
  10. package/dist/src/cli/version-check.js +5 -1
  11. package/dist/src/commands/conductor.d.ts +57 -0
  12. package/dist/src/{cli/gaia.js → commands/conductor.js} +107 -215
  13. package/dist/src/config.d.ts +65 -24
  14. package/dist/src/config.js +405 -154
  15. package/dist/src/contract.d.ts +8 -0
  16. package/dist/src/contract.js +16 -0
  17. package/dist/src/core/conductor.d.ts +16 -1
  18. package/dist/src/core/conductor.js +28 -9
  19. package/dist/src/index.d.ts +7 -5
  20. package/dist/src/index.js +26 -3
  21. package/dist/src/plugins/agent.d.ts +61 -0
  22. package/dist/src/plugins/agent.js +11 -0
  23. package/dist/src/plugins/executor.d.ts +104 -0
  24. package/dist/src/plugins/executor.js +1 -0
  25. package/dist/src/plugins/plugins.d.ts +60 -0
  26. package/dist/src/plugins/plugins.js +42 -0
  27. package/dist/src/plugins/preset.d.ts +48 -0
  28. package/dist/src/plugins/preset.js +23 -0
  29. package/dist/src/plugins/remote.d.ts +203 -0
  30. package/dist/src/plugins/remote.js +1 -0
  31. package/dist/src/plugins/workspace.d.ts +35 -0
  32. package/dist/src/plugins/workspace.js +1 -0
  33. package/dist/src/preset.d.ts +2 -0
  34. package/dist/src/preset.js +8 -0
  35. package/dist/src/types.d.ts +65 -0
  36. package/dist/src/types.js +1 -0
  37. package/package.json +8 -5
  38. package/dist/src/cli/gaia.d.ts +0 -23
  39. package/dist/src/cli/local-registry.d.ts +0 -14
  40. package/dist/src/cli/local-registry.js +0 -56
@@ -1,17 +1,21 @@
1
- import { existsSync, readdirSync } from 'node:fs';
2
- import { createRequire } from 'node:module';
3
- import { dirname, join, resolve } from 'node:path';
1
+ import { readdirSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
4
3
  import { pathToFileURL } from 'node:url';
4
+ import { discoverAddons, emptyContributions, resolveConductorSlots, resolveModuleEslintStyle, } from '@gaia-ai/core';
5
+ import { narrowConductorContributions } from './plugins/preset.js';
6
+ // GAIA-201: the `.gaia/` walk-up + connection resolution moved to `@gaia-ai/core`
7
+ // (`resolveConfigPath` / `resolveGaiaConfigPath` / `findGaiaDir`). Re-export the
8
+ // engine resolver here so existing conductor callers/tests keep importing it
9
+ // from `../config.js`.
10
+ export { findGaiaDir, resolveConfigPath, } from '@gaia-ai/core';
5
11
  /**
6
- * Default agent prompt - the GAIA run contract. One run works EXACTLY one state;
12
+ * Default agent prompt - the project instruction contract. One run works EXACTLY one state;
7
13
  * the agent must stop instead of running the whole flow in one session. The run
8
14
  * is closed automatically when the ticket state changes on the next claim - the
9
15
  * agent does not release it. Run mechanics live here (not in the repo's
10
- * WORKFLOW.md). The prompt routes through the gaia skill (`ticket:run`) rather
11
- * than pointing at WORKFLOW.md directly (GAIA-125): the intake line + state
12
- * engine are a skill mechanic, so a bare "follow WORKFLOW.md" pointer left the
13
- * intake line unprinted unless an external skill-forcing hook happened to fire. A
14
- * conductor config may override via the `prompt` field.
16
+ * WORKFLOW.md). The prompt points directly at the matching project-owned state
17
+ * section and does not prescribe an agent skill. A conductor config may override
18
+ * it via the `prompt` field.
15
19
  *
16
20
  * The ticket + its comments are NOT embedded (GAIA-112): embedding unbounded
17
21
  * ticket content into a single typed pane line overran the PTY canonical line
@@ -34,15 +38,12 @@ import { pathToFileURL } from 'node:url';
34
38
  * wrapper, GAIA-118, was the thing that re-inflated the line past the cap and
35
39
  * truncated it mid-quote).
36
40
  * Placeholders: `{identifier}`, `{state}`, `{runUuid}` ({state} falls back to
37
- * `triage` for unclassified tickets).
41
+ * `qualification` for unclassified tickets).
38
42
  */
39
- export const DEFAULT_AGENT_PROMPT = `You are a GAIA agent working ticket {identifier}, current state: {state}. ` +
40
- `Invoke the gaia skill and run \`ticket:run {identifier} {state}\` it first ` +
41
- `reads the ticket + all comments ` +
42
- `(gaia dropsh read gaia_ticket/gaia_ticket/$GAIA_ID --include comments), ` +
43
- `prints an intake line, runs the {state} engine, and applies ` +
44
- `WORKFLOW.md's {state} policy. Do ONLY the {state} work — never start or ` +
45
- `prepare a later state.`;
43
+ export const DEFAULT_AGENT_PROMPT = `You are working ticket {identifier}, current state: {state}. Read the ticket and all comments ` +
44
+ `(gaia dropsh --format md read gaia_ticket/gaia_ticket/$GAIA_ID --include comments), ` +
45
+ `then read ./WORKFLOW.md and execute only its ordered "State: {state}" section. ` +
46
+ `Do not start or prepare a later state.`;
46
47
  function requirePlugin(value, kind) {
47
48
  if (!isRecord(value) || value.kind !== kind) {
48
49
  throw new Error(`conductor config requires a ${kind} plugin in the "${kind}" slot`);
@@ -73,16 +74,7 @@ async function loadNamedPlugin(entry, configPath) {
73
74
  pathToFileURL(`${process.cwd()}/`).href,
74
75
  import.meta.url,
75
76
  ];
76
- let resolved;
77
- for (const base of bases) {
78
- try {
79
- resolved = createRequire(base).resolve(entry.plugin);
80
- break;
81
- }
82
- catch {
83
- // try the next base
84
- }
85
- }
77
+ const resolved = resolveModuleEslintStyle(entry.plugin, bases);
86
78
  if (resolved === undefined) {
87
79
  throw new Error(`conductor config cannot resolve plugin '${entry.plugin}'`);
88
80
  }
@@ -144,23 +136,6 @@ export async function resolveAgents(raw, configPath) {
144
136
  ? Promise.all(raw.map(resolveOne))
145
137
  : resolveOne(raw);
146
138
  }
147
- /**
148
- * Resolve the `plugins[]` array: descriptor entries are constructed, already-
149
- * constructed entries pass through. No kind-guard (plugins are not slotted).
150
- *
151
- * A factory may return one plugin OR an array of plugins — an aggregator built
152
- * with dropsh's `composePlugins(a(), b())` returns the flat child list. dropsh's
153
- * own `loadConfig` flattens such a nested entry one level; we mirror that here so
154
- * `buildProgram({ plugins })` — which reads each entry's hooks/renderers per
155
- * top-level element and does NOT recurse — sees every child.
156
- */
157
- async function resolvePlugins(raw, configPath) {
158
- if (!Array.isArray(raw)) {
159
- return [];
160
- }
161
- const resolved = await Promise.all(raw.map((entry) => isPluginDescriptor(entry) ? loadNamedPlugin(entry, configPath) : entry));
162
- return resolved.flat();
163
- }
164
139
  function isRecord(value) {
165
140
  return typeof value === 'object' && value !== null && !Array.isArray(value);
166
141
  }
@@ -179,98 +154,15 @@ function optionalPositiveInteger(value, fallback, key) {
179
154
  }
180
155
  return value;
181
156
  }
182
- /** A repo's config files live in this dir, one conductor config per conductor. */
183
- const GAIA_DIR = '.gaia';
184
- /** The default conductor's file name; its stem is `conductor`. */
185
- const DEFAULT_CONFIG = 'conductor.config.js';
186
- /** Variant files are `<stem>.conductor.config.js`. */
187
- const VARIANT_SUFFIX = '.conductor.config.js';
188
- /**
189
- * List the conductor-config stems in a `.gaia/` dir. A file is a conductor
190
- * config iff it is exactly `conductor.config.js` (stem `conductor`, the
191
- * default) or ends with `.conductor.config.js` (stem = the leading part).
192
- * Every other file (`vite.config.js`, the near-miss `myconductor.config.js`)
193
- * is ignored.
194
- */
195
- function configStems(gaiaDir) {
196
- return readdirSync(gaiaDir)
197
- .map((f) => f === DEFAULT_CONFIG
198
- ? 'conductor'
199
- : f.endsWith(VARIANT_SUFFIX)
200
- ? f.slice(0, -VARIANT_SUFFIX.length)
201
- : undefined)
202
- .filter((s) => s !== undefined)
203
- .sort();
204
- }
205
- /** Map a conductor stem back to its file name. */
206
- function fileForStem(stem) {
207
- return stem === 'conductor' ? DEFAULT_CONFIG : `${stem}${VARIANT_SUFFIX}`;
208
- }
209
- /**
210
- * Walk from `cwd` root-ward (git/eslint style) to the nearest ancestor whose
211
- * `.gaia/` dir holds at least one conductor config; return that `.gaia/` dir, or
212
- * `undefined` if none is found up to the filesystem root.
213
- */
214
- function findGaiaDir(cwd) {
215
- let dir = resolve(cwd);
216
- for (;;) {
217
- const gaiaDir = join(dir, GAIA_DIR);
218
- if (existsSync(gaiaDir) && configStems(gaiaDir).length > 0) {
219
- return gaiaDir;
220
- }
221
- const parent = dirname(dir);
222
- if (parent === dir) {
223
- return undefined;
224
- }
225
- dir = parent;
226
- }
227
- }
228
157
  /**
229
- * Resolve the conductor config path from `cwd`.
230
- *
231
- * 1. An explicit `--config` / `$GAIA_CONDUCTOR_CONFIG` wins verbatim (no walk).
232
- * 2. Otherwise walk root-ward to the nearest `.gaia/` dir holding a conductor config
233
- * so any subdirectory of a project/worktree resolves the same dir.
234
- * - A `--conductor <name>` / `$GAIA_CONDUCTOR` selector resolves the stem's
235
- * file (`conductor` `conductor.config.js`, else
236
- * `<name>.conductor.config.js`; error listing the stems if absent).
237
- * - No selector: `conductor.config.js` present → it is the default; else
238
- * exactly one config → use it (back-compat); else → error naming the
239
- * stems + the selector.
240
- * 3. No `.gaia/` config anywhere up the tree → an actionable `gaia init` error
241
- * (never leak a raw "Cannot find module" from a later import()).
158
+ * Load the ENGINE half of a conductor config (GAIA-201 split model). Reads
159
+ * remote/executor/agent/workspace + project/states/machine_id/label/prompt/
160
+ * scheduler/hooks and deliberately NOT `site` or the auth `plugins`, which now
161
+ * live in a `gaia.config.js` connection config. The conductor command composes
162
+ * the two via `composeConductorConfig`. A legacy `conductor.config.js` still
163
+ * carrying `site`/`plugins` loads fine those two keys are simply ignored here
164
+ * (the connection loader reads them, back-compat).
242
165
  */
243
- export function resolveConfigPath(override, cwd = process.cwd(), conductorName) {
244
- const explicit = override ?? process.env.GAIA_CONDUCTOR_CONFIG;
245
- if (explicit) {
246
- return explicit;
247
- }
248
- const gaiaDir = findGaiaDir(cwd);
249
- if (gaiaDir === undefined) {
250
- throw new Error(`no .gaia/conductor.config.js found from ${cwd} upward; run \`gaia init\``);
251
- }
252
- const stems = configStems(gaiaDir);
253
- const name = conductorName ?? process.env.GAIA_CONDUCTOR;
254
- if (name !== undefined && name !== '') {
255
- const candidate = join(gaiaDir, fileForStem(name));
256
- if (!existsSync(candidate)) {
257
- throw new Error(`no conductor '${name}' in ${gaiaDir}; available: ${stems.join(', ')}`);
258
- }
259
- return candidate;
260
- }
261
- // No selector: the file named conductor.config.js is the default (AC-1, AC-2).
262
- // Two conductor.config.js files cannot coexist in one dir, so ">1 default"
263
- // (AC-5) is structurally impossible — no ambiguity branch is needed.
264
- if (stems.includes('conductor')) {
265
- return join(gaiaDir, DEFAULT_CONFIG);
266
- }
267
- // Back-compat: a lone config resolves even without the default name.
268
- if (stems.length === 1) {
269
- return join(gaiaDir, fileForStem(stems[0]));
270
- }
271
- throw new Error(`${stems.length} conductors in ${gaiaDir} (${stems.join(', ')}); ` +
272
- 'select one with --conductor <name> or $GAIA_CONDUCTOR');
273
- }
274
166
  export async function loadConductorConfig(configFile) {
275
167
  const configPath = resolve(configFile);
276
168
  const module = (await import(pathToFileURL(configPath).href));
@@ -278,14 +170,23 @@ export async function loadConductorConfig(configFile) {
278
170
  if (!isRecord(raw)) {
279
171
  throw new Error('conductor config default export must be an object');
280
172
  }
173
+ // GAIA-218 (AC-6): the engine config must not carry connection/auth material.
174
+ // A legacy config still declaring `site`/`plugins` gets exactly ONE actionable
175
+ // warning naming the file + the offending key(s); the values are never merged
176
+ // (`ConductorEngineConfig` omits both structurally).
177
+ const leftover = ['site', 'plugins'].filter((k) => k in raw);
178
+ if (leftover.length > 0) {
179
+ console.warn(`warning: ${configPath} declares ${leftover.join('/')} — ignored; ` +
180
+ 'auth/connection belong in the sibling gaia.config.js (GAIA-218)');
181
+ }
281
182
  const config = raw;
282
- const site = isRecord(config.site) ? config.site : {};
283
- const baseUrl = requireNonEmptyString(site.base_url, 'site.base_url');
284
183
  const project = requireNonEmptyString(config.project, 'project');
285
- if (!Array.isArray(config.states) || config.states.length === 0) {
286
- throw new Error('conductor config requires non-empty states');
287
- }
288
- const states = config.states.map((state) => requireNonEmptyString(state, 'states'));
184
+ // `states` is optional. An empty (or absent) list means the conductor serves
185
+ // every claimable state in its project; a non-empty list narrows it to those
186
+ // states (GAIA-207). When present it must be an array of non-empty strings.
187
+ const states = Array.isArray(config.states)
188
+ ? config.states.map((state) => requireNonEmptyString(state, 'states'))
189
+ : [];
289
190
  // machine_id is required — the config MUST set it; there is no derived
290
191
  // fallback. The id is defined in a single place (config.machine_id), which
291
192
  // the CLI lifecycle commands and registration all read, never re-derive. The
@@ -298,21 +199,51 @@ export async function loadConductorConfig(configFile) {
298
199
  const label = typeof config.label === 'string' && config.label.trim() !== ''
299
200
  ? config.label
300
201
  : machineId;
202
+ // GAIA-215: discover the conductor `addons: []` surface (last-wins singletons,
203
+ // agent candidate list). A pre-existing per-slot descriptor still loads and
204
+ // WINS over a discovered contributor of the same kind (back-compat additive).
205
+ // GAIA-224: `discoverAddons` is surface-AGNOSTIC — it returns opaque
206
+ // accumulators. This is the single, reviewed narrowing seam where the engine
207
+ // reinterprets them as its own concrete conductor contributions; the runtime
208
+ // `.kind` guard inside `resolveConductorSlots` still fails loudly on a
209
+ // mis-declared contribution.
210
+ const discovered = narrowConductorContributions(Array.isArray(config.addons)
211
+ ? await discoverAddons(config.addons, 'conductor', [
212
+ pathToFileURL(configPath).href,
213
+ pathToFileURL(`${process.cwd()}/`).href,
214
+ import.meta.url,
215
+ ])
216
+ : emptyContributions());
217
+ const wired = resolveConductorSlots(discovered);
218
+ const remote = config.remote !== undefined
219
+ ? await resolveSlot(config.remote, 'remote', configPath)
220
+ : wired.remote;
221
+ if (!remote) {
222
+ throw new Error('conductor config resolves no remote — name a remote addon in addons[] or set the remote slot');
223
+ }
224
+ const executor = config.executor !== undefined
225
+ ? await resolveSlot(config.executor, 'executor', configPath)
226
+ : wired.executor;
227
+ if (!executor) {
228
+ throw new Error('conductor config resolves no executor — name an executor addon in addons[] or set the executor slot');
229
+ }
230
+ const workspace = config.workspace !== undefined
231
+ ? await resolveSlot(config.workspace, 'workspace', configPath)
232
+ : wired.workspace;
233
+ if (!workspace) {
234
+ throw new Error('conductor config resolves no workspace — name a workspace addon in addons[] or set the workspace slot');
235
+ }
236
+ const agent = config.agent !== undefined
237
+ ? await resolveAgents(config.agent, configPath)
238
+ : wired.agents;
239
+ if (Array.isArray(agent) && agent.length === 0) {
240
+ throw new Error('conductor config resolves no agent — name an agent addon in addons[] or set the agent slot');
241
+ }
301
242
  return {
302
- site: {
303
- base_url: baseUrl,
304
- jsonapi_prefix: typeof site.jsonapi_prefix === 'string' &&
305
- site.jsonapi_prefix.trim() !== ''
306
- ? site.jsonapi_prefix
307
- : '/jsonapi',
308
- },
309
- ...(Array.isArray(config.plugins)
310
- ? { plugins: await resolvePlugins(config.plugins, configPath) }
311
- : {}),
312
- remote: await resolveSlot(config.remote, 'remote', configPath),
313
- executor: await resolveSlot(config.executor, 'executor', configPath),
314
- agent: await resolveAgents(config.agent, configPath),
315
- workspace: await resolveSlot(config.workspace, 'workspace', configPath),
243
+ remote,
244
+ executor,
245
+ agent,
246
+ workspace,
316
247
  label,
317
248
  machine_id: machineId,
318
249
  project,
@@ -327,3 +258,323 @@ export async function loadConductorConfig(configFile) {
327
258
  config_path: configPath,
328
259
  };
329
260
  }
261
+ /**
262
+ * Compose the full `ConductorFileConfig` the engine consumes from the engine
263
+ * half (`loadConductorConfig`) and the connection half (`loadGaiaConfig`). The
264
+ * connection supplies `site` + the auth `plugins`; `ensureAuthenticated` /
265
+ * `selectRemote` read them exactly as before — the fields just arrive from
266
+ * `gaia.config.js` now.
267
+ */
268
+ export function composeConductorConfig(engine, connection) {
269
+ return {
270
+ ...engine,
271
+ site: connection.site,
272
+ ...(connection.plugins.length > 0 ? { plugins: connection.plugins } : {}),
273
+ };
274
+ }
275
+ const DEFAULT_CONFIG = 'conductor.config.js';
276
+ const VARIANT_SUFFIX = '.conductor.config.js';
277
+ /**
278
+ * The conductor **stems** in a `.gaia/` dir — the file naming predicate of
279
+ * GAIA-137: exactly `conductor.config.js` (stem `conductor`) or
280
+ * `<variant>.conductor.config.js` (stem `<variant>`). The resolution itself
281
+ * lives in `@gaia-ai/core` (re-exported above); this is the enumeration.
282
+ */
283
+ export function configStems(gaiaDir) {
284
+ return readdirSync(gaiaDir)
285
+ .map((f) => f === DEFAULT_CONFIG
286
+ ? 'conductor'
287
+ : f.endsWith(VARIANT_SUFFIX)
288
+ ? f.slice(0, -VARIANT_SUFFIX.length)
289
+ : undefined)
290
+ .filter((s) => s !== undefined)
291
+ .sort();
292
+ }
293
+ /** Map a conductor stem back to its file name. */
294
+ export function fileForStem(stem) {
295
+ return stem === 'conductor' ? DEFAULT_CONFIG : `${stem}${VARIANT_SUFFIX}`;
296
+ }
297
+ /**
298
+ * List the conductor-config **file names** in a `.gaia/` dir, using the same
299
+ * naming predicate the loader uses (`configStems`). This is the only supported
300
+ * enumeration for the `@gaia/upgrade-project` states-strip: it visits the
301
+ * default `conductor.config.js` + every `<variant>.conductor.config.js`, and
302
+ * never an unrelated JS config (`vite.config.js`, the near-miss
303
+ * `myconductor.config.js`).
304
+ */
305
+ export function listConductorConfigFiles(gaiaDir) {
306
+ return configStems(gaiaDir).map(fileForStem);
307
+ }
308
+ /**
309
+ * Remove the obsolete `states:` array property from a conductor-config JS
310
+ * source (GAIA-207 made `states` optional — empty ⇒ serve all claimable
311
+ * states — so `@gaia/upgrade-project` drops the now-dead key). Pure string
312
+ * transform: it strips the `states: [ ... ]` property (line form or inline in
313
+ * an object literal) with its trailing comma, and rewrites nothing else. A
314
+ * `substates:`/other key containing "states" is not matched.
315
+ */
316
+ export function stripStatesFromConfigSource(source) {
317
+ return source.replace(/\n?[ \t]*(?<![A-Za-z0-9_$])states[ \t]*:[ \t]*\[[^\]]*\][ \t]*,?/g, '');
318
+ }
319
+ // ---------------------------------------------------------------------------
320
+ // GAIA-218 (AC-7): strip legacy connection/auth from an ENGINE config source.
321
+ //
322
+ // Unlike `stripStatesFromConfigSource`'s flat `\[[^\]]*\]` regex (nested-unsafe),
323
+ // this is a balanced-delimiter, comment-aware source transform. The engine
324
+ // config's `plugins: [ … ]` array holds nested `{}` (oauth2 profiles AND
325
+ // `@gaia-ai/addon-essentials`), a sibling `agent: [ … ]` array, and the word
326
+ // "plugins" also appears in comments — so the removal keys on the REAL `site:` /
327
+ // `plugins:` *properties* of the default-export object, scanning each value from
328
+ // its opening delimiter to the matching close while skipping string/comment
329
+ // bytes. It also removes the now-orphaned connection preamble consts
330
+ // (`baseUrl`/`clientId`/`clientSecret`) and a connection-only `loadMachine`
331
+ // (with its `const machine = await loadMachine()`) when they become unreferenced.
332
+ // ---------------------------------------------------------------------------
333
+ const CLOSER = { '{': '}', '[': ']', '(': ')' };
334
+ /** Skip a `//`-line comment; return the index of the terminating newline (or EOF). */
335
+ function skipLineComment(src, i) {
336
+ const nl = src.indexOf('\n', i);
337
+ return nl === -1 ? src.length : nl;
338
+ }
339
+ /** Skip a `/* *\/` block comment; return the index just past it. */
340
+ function skipBlockComment(src, i) {
341
+ const end = src.indexOf('*/', i + 2);
342
+ return end === -1 ? src.length : end + 2;
343
+ }
344
+ /** Skip a string/template literal (honouring `\` escapes and `${ … }`
345
+ * interpolations in backtick strings); return the index just past the close. */
346
+ function skipString(src, i) {
347
+ const quote = src[i];
348
+ i++;
349
+ while (i < src.length) {
350
+ const c = src[i];
351
+ if (c === '\\') {
352
+ i += 2;
353
+ continue;
354
+ }
355
+ if (quote === '`' && c === '$' && src[i + 1] === '{') {
356
+ i = matchDelimiter(src, i + 1) + 1;
357
+ continue;
358
+ }
359
+ if (c === quote)
360
+ return i + 1;
361
+ i++;
362
+ }
363
+ return i;
364
+ }
365
+ /** Given the index of an opening `{`/`[`/`(`, return the index of its matching
366
+ * close, skipping nested delimiters, strings and comments. -1 if unbalanced. */
367
+ function matchDelimiter(src, openIdx) {
368
+ const stack = [CLOSER[src[openIdx]]];
369
+ let i = openIdx + 1;
370
+ while (i < src.length && stack.length > 0) {
371
+ const c = src[i];
372
+ if (c === '/' && src[i + 1] === '/') {
373
+ i = skipLineComment(src, i);
374
+ continue;
375
+ }
376
+ if (c === '/' && src[i + 1] === '*') {
377
+ i = skipBlockComment(src, i);
378
+ continue;
379
+ }
380
+ if (c === "'" || c === '"' || c === '`') {
381
+ i = skipString(src, i);
382
+ continue;
383
+ }
384
+ if (c === '{' || c === '[' || c === '(') {
385
+ stack.push(CLOSER[c]);
386
+ i++;
387
+ continue;
388
+ }
389
+ if (c === '}' || c === ']' || c === ')') {
390
+ if (c === stack[stack.length - 1])
391
+ stack.pop();
392
+ i++;
393
+ continue;
394
+ }
395
+ i++;
396
+ }
397
+ return stack.length === 0 ? i - 1 : -1;
398
+ }
399
+ /** Skip whitespace + comments starting at `i` (bounded by `limit`). */
400
+ function skipTrivia(src, i, limit) {
401
+ while (i < limit) {
402
+ const c = src[i];
403
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
404
+ i++;
405
+ continue;
406
+ }
407
+ if (c === '/' && src[i + 1] === '/') {
408
+ i = skipLineComment(src, i);
409
+ continue;
410
+ }
411
+ if (c === '/' && src[i + 1] === '*') {
412
+ i = skipBlockComment(src, i);
413
+ continue;
414
+ }
415
+ break;
416
+ }
417
+ return i;
418
+ }
419
+ /** Read a property key (bare identifier or quoted string) at `i`. */
420
+ function readKey(src, i) {
421
+ const c = src[i];
422
+ if (c === "'" || c === '"') {
423
+ const end = skipString(src, i);
424
+ return { name: src.slice(i + 1, end - 1), end };
425
+ }
426
+ const m = /^[A-Za-z0-9_$]+/.exec(src.slice(i));
427
+ if (m)
428
+ return { name: m[0], end: i + m[0].length };
429
+ return { end: i };
430
+ }
431
+ /** From `from`, scan to just past the next top-level `,` (or to `limit` when the
432
+ * value runs to the object close), skipping nested delimiters/strings/comments. */
433
+ function scanToTopLevelComma(src, from, limit) {
434
+ let i = from;
435
+ while (i < limit) {
436
+ const c = src[i];
437
+ if (c === '/' && src[i + 1] === '/') {
438
+ i = skipLineComment(src, i);
439
+ continue;
440
+ }
441
+ if (c === '/' && src[i + 1] === '*') {
442
+ i = skipBlockComment(src, i);
443
+ continue;
444
+ }
445
+ if (c === "'" || c === '"' || c === '`') {
446
+ i = skipString(src, i);
447
+ continue;
448
+ }
449
+ if (c === '{' || c === '[' || c === '(') {
450
+ i = matchDelimiter(src, i) + 1;
451
+ continue;
452
+ }
453
+ if (c === ',')
454
+ return i + 1;
455
+ i++;
456
+ }
457
+ return limit;
458
+ }
459
+ /** Locate the `export default { … }` object; return the body span (exclusive of
460
+ * the braces) or undefined when the default export is not an object literal. */
461
+ function findDefaultExportObject(src) {
462
+ const m = /export\s+default\s*/.exec(src);
463
+ if (!m)
464
+ return undefined;
465
+ const braceIdx = m.index + m[0].length;
466
+ if (src[braceIdx] !== '{')
467
+ return undefined;
468
+ const end = matchDelimiter(src, braceIdx);
469
+ if (end === -1)
470
+ return undefined;
471
+ return { bodyStart: braceIdx + 1, bodyEnd: end };
472
+ }
473
+ /** Enumerate the top-level properties of the object body [bodyStart, bodyEnd). */
474
+ function scanTopLevelProperties(src, bodyStart, bodyEnd) {
475
+ const entries = [];
476
+ let i = bodyStart;
477
+ while (i < bodyEnd) {
478
+ const entryStart = i;
479
+ const keyPos = skipTrivia(src, i, bodyEnd);
480
+ if (keyPos >= bodyEnd)
481
+ break;
482
+ const key = readKey(src, keyPos);
483
+ let j = skipTrivia(src, key.end, bodyEnd);
484
+ if (src[j] !== ':') {
485
+ // Not a `key: value` property (spread/computed/shorthand) — keep it.
486
+ const term = scanToTopLevelComma(src, keyPos, bodyEnd);
487
+ entries.push({ key: undefined, start: entryStart, end: term });
488
+ i = term;
489
+ continue;
490
+ }
491
+ j++;
492
+ const term = scanToTopLevelComma(src, j, bodyEnd);
493
+ entries.push({ key: key.name, start: entryStart, end: term });
494
+ i = term;
495
+ }
496
+ return entries;
497
+ }
498
+ /** Count word-boundary occurrences of an identifier in the source. */
499
+ function countIdent(src, name) {
500
+ const re = new RegExp(`(?<![A-Za-z0-9_$])${name}(?![A-Za-z0-9_$])`, 'g');
501
+ return (src.match(re) ?? []).length;
502
+ }
503
+ /** Remove a single-line `const <name> = …;` declaration (no-op if absent). */
504
+ function removeConstDecl(src, name) {
505
+ const re = new RegExp(`^[ \\t]*const ${name}\\b[^\\n]*\\n`, 'm');
506
+ return src.replace(re, '');
507
+ }
508
+ /** Remove an `[async ]function <name>(…) { … }` declaration (balanced body). */
509
+ function removeFunctionDecl(src, name) {
510
+ const re = new RegExp(`(?:async[ \\t]+)?function[ \\t]+${name}[ \\t]*\\(`);
511
+ const m = re.exec(src);
512
+ if (!m)
513
+ return src;
514
+ const braceIdx = src.indexOf('{', m.index);
515
+ if (braceIdx === -1)
516
+ return src;
517
+ const end = matchDelimiter(src, braceIdx);
518
+ if (end === -1)
519
+ return src;
520
+ let e = end + 1;
521
+ if (src[e] === '\n')
522
+ e++;
523
+ return src.slice(0, m.index) + src.slice(e);
524
+ }
525
+ /**
526
+ * Is `machine` referenced anywhere OTHER than its own `const machine = …;` decl
527
+ * and inside the `loadMachine` function body? (The body's `…/machine.config.js`
528
+ * path string must not count as a live reference.) Used to decide whether a
529
+ * connection-only `loadMachine` has become orphaned.
530
+ */
531
+ function machineReferencedElsewhere(src) {
532
+ let probe = removeFunctionDecl(src, 'loadMachine');
533
+ probe = removeConstDecl(probe, 'machine');
534
+ return countIdent(probe, 'machine') > 0;
535
+ }
536
+ /** Drop connection preamble that is orphaned once `site`/`plugins` are gone. */
537
+ function removeOrphanedPreamble(src) {
538
+ let text = src;
539
+ for (const name of ['baseUrl', 'clientId', 'clientSecret']) {
540
+ if (countIdent(text, name) <= 1)
541
+ text = removeConstDecl(text, name);
542
+ }
543
+ // A connection-only `loadMachine`: remove `const machine = await loadMachine()`
544
+ // + the function itself only when `machine` is otherwise unreferenced (so an
545
+ // engine config that still composes `machine_id` from `machine.user_id` keeps
546
+ // its loadMachine — the "connection-only" qualifier).
547
+ if (/\bconst machine\b/.test(text) &&
548
+ /\bfunction loadMachine\b/.test(text) &&
549
+ !machineReferencedElsewhere(text)) {
550
+ text = removeConstDecl(text, 'machine');
551
+ text = removeFunctionDecl(text, 'loadMachine');
552
+ }
553
+ return text;
554
+ }
555
+ /**
556
+ * Strip the `site:` and `plugins:` properties from an engine config's
557
+ * `export default { … }` (with their leading comment/whitespace), plus the
558
+ * now-orphaned connection preamble consts. Returns the rewritten source and the
559
+ * list of object keys removed (`[]` — source returned unchanged — when there is
560
+ * nothing connection-related to remove). remote/executor/agent/workspace/hooks/
561
+ * project/machine_id/loadLocal are left intact.
562
+ */
563
+ export function stripLegacyConnectionFromConfigSource(source) {
564
+ const obj = findDefaultExportObject(source);
565
+ if (!obj)
566
+ return { text: source, removed: [] };
567
+ const entries = scanTopLevelProperties(source, obj.bodyStart, obj.bodyEnd);
568
+ const targets = new Set(['site', 'plugins']);
569
+ const toRemove = entries.filter((e) => e.key !== undefined && targets.has(e.key));
570
+ if (toRemove.length === 0)
571
+ return { text: source, removed: [] };
572
+ let text = source;
573
+ for (const e of [...toRemove].sort((a, b) => b.start - a.start)) {
574
+ text = text.slice(0, e.start) + text.slice(e.end);
575
+ }
576
+ text = removeOrphanedPreamble(text);
577
+ // Report in source order for a stable `site/plugins` message.
578
+ const removed = ['site', 'plugins'].filter((k) => toRemove.some((e) => e.key === k));
579
+ return { text, removed };
580
+ }
@@ -0,0 +1,8 @@
1
+ export { type AgentFootprint, emptyAgentFootprint, type GaiaAgent, } from './plugins/agent.js';
2
+ export type { ExecutorCapabilities, GaiaExecutor, HookContext, HookName, SpawnedSession, SpawnRunInput, } from './plugins/executor.js';
3
+ export type { AgentCandidate, AgentPlugin, ExecutorDeps, ExecutorPlugin, RemotePlugin, ResolvedAgent, WorkspacePlugin, } from './plugins/plugins.js';
4
+ export { selectAgent, selectAgents, selectExecutor, selectRemote, selectWorkspace, } from './plugins/plugins.js';
5
+ export { type ConductorAddonEntry, type ConductorContributions, narrowConductorContributions, type Preset, } from './plugins/preset.js';
6
+ export type { ActiveRun, ClaimedRun, ClaimOptions, ConductorRegistration, ConductorStatus, FinalizableRun, GaiaRemote, RunMetrics, RunWriteAttributes, Ticket, UncleanTicket, } from './plugins/remote.js';
7
+ export type { EnsuredWorkspace, GaiaWorkspace, } from './plugins/workspace.js';
8
+ export type { ConductorEngineConfig, ConductorFileConfig, ConductorSettings, } from './types.js';
@@ -0,0 +1,16 @@
1
+ // GAIA-224 (Finding 6): the CONDUCTOR-SURFACE CONTRACT, exposed as the
2
+ // runtime-light subpath `@gaia-ai/conductor/contract`.
3
+ //
4
+ // This is the module a conductor-surface addon (`addons/remote-drupal`,
5
+ // `addons/workspace-git`, `addons/herdr`, `addons/claude`, …) imports. It carries
6
+ // ONLY the surface interfaces, the config contract, the preset view, and the two
7
+ // tiny runtime helpers (`emptyAgentFootprint`, the `select*` slot selectors) — it
8
+ // imports NO engine, NO commander, NO dropsh runtime. Importing it therefore
9
+ // costs an addon nothing at boot, unlike the package main (`@gaia-ai/conductor`),
10
+ // which pulls the whole run engine + the `conductor` command plugin.
11
+ //
12
+ // The package main re-exports everything here too, so `import type { GaiaRemote }
13
+ // from '@gaia-ai/conductor'` keeps working for type-only consumers.
14
+ export { emptyAgentFootprint, } from './plugins/agent.js';
15
+ export { selectAgent, selectAgents, selectExecutor, selectRemote, selectWorkspace, } from './plugins/plugins.js';
16
+ export { narrowConductorContributions, } from './plugins/preset.js';
@@ -1,4 +1,9 @@
1
- import { type ConductorFileConfig, type ConductorLogger, type GaiaExecutor, type GaiaRemote, type GaiaWorkspace, type ResolvedAgent } from '@gaia-ai/core';
1
+ import { type ConductorLogger } from '@gaia-ai/core';
2
+ import type { GaiaExecutor } from '../plugins/executor.js';
3
+ import { type ResolvedAgent } from '../plugins/plugins.js';
4
+ import type { GaiaRemote } from '../plugins/remote.js';
5
+ import type { GaiaWorkspace } from '../plugins/workspace.js';
6
+ import type { ConductorFileConfig } from '../types.js';
2
7
  /**
3
8
  * Resolve the environment a run executes in (GAIA-99): parse the ticket's
4
9
  * effective env_vars, drop any reserved key (loud warn — key NAME only, never
@@ -66,5 +71,15 @@ export declare class Conductor {
66
71
  reap(): Promise<void>;
67
72
  private dispatch;
68
73
  serve(signal?: AbortSignal): Promise<void>;
74
+ /**
75
+ * True once this conductor's own checkout has been removed from disk (its
76
+ * worktree was reaped while the process kept running). Such a conductor is a
77
+ * pure liability: it still heartbeats and still claims runs, but every
78
+ * dispatch fails — no git command and no relative path can resolve from a
79
+ * deleted directory — so each claim burns one attempt and three of them park
80
+ * the ticket behind the circuit breaker. Observed as 30 orphans claiming and
81
+ * failing tickets they could never dispatch.
82
+ */
83
+ private checkoutGone;
69
84
  private pollLoop;
70
85
  }