@gaia-ai/conductor 0.5.4 → 0.6.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,7 @@
1
+ export type { ConductorRegistryEntry } from '@gaia-ai/core';
2
+ import { conductorRegistryPath, getRegisteredConductor, listRegisteredConductors, registerConductor, removeConductor } from '@gaia-ai/core';
3
+ export { conductorRegistryPath };
4
+ export declare const list: typeof listRegisteredConductors;
5
+ export declare const get: typeof getRegisteredConductor;
6
+ export declare const register: typeof registerConductor;
7
+ export declare const remove: typeof removeConductor;
@@ -0,0 +1,6 @@
1
+ import { conductorRegistryPath, getRegisteredConductor, listRegisteredConductors, registerConductor, removeConductor, } from '@gaia-ai/core';
2
+ export { conductorRegistryPath };
3
+ export const list = listRegisteredConductors;
4
+ export const get = getRegisteredConductor;
5
+ export const register = registerConductor;
6
+ export const remove = removeConductor;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A single, discrete, testable upgrade method `vN → vN+1`, registered in order —
3
+ * exactly like a Drupal `hook_update_N` chain. `gaia upgrade` resolves a config's
4
+ * current version and applies each method in sequence up to CURRENT (it does not
5
+ * blindly overwrite), so a method can PRESERVE operator-set fields across a bump.
6
+ */
7
+ export interface ConnectionConfigMigration {
8
+ /** The schema version this method upgrades FROM. */
9
+ from: number;
10
+ /** The schema version this method upgrades TO (=== from + 1). */
11
+ to: number;
12
+ /** What shape change this method performs (human-readable). */
13
+ description: string;
14
+ /**
15
+ * Upgrade the config text from `from` to `to`, returning the new file text
16
+ * WITHOUT the version header — the runner re-stamps `@gaia-schema-version to`.
17
+ */
18
+ apply(currentText: string): string;
19
+ }
20
+ /**
21
+ * The ordered, contiguous migration chain. Ships EMPTY at v1: nothing below v1
22
+ * is a versioned config (v0 = seed / hand-authored / untouched). The FIRST real
23
+ * shape change adds the first `{ from: 1, to: 2, … }` method here, which bumps
24
+ * the derived CURRENT to 2 automatically. The extension point is real and
25
+ * exercised by tests now, so the first shape change is a one-method addition,
26
+ * never a re-plumb.
27
+ */
28
+ export declare const CONNECTION_MIGRATIONS: ConnectionConfigMigration[];
29
+ /** The current connection-config schema version — single source of truth. */
30
+ export declare const GAIA_CONFIG_SCHEMA_VERSION: number;
31
+ /** Parse the `@gaia-schema-version` marker from config text; `0` when absent. */
32
+ export declare function schemaVersionOf(text: string): number;
33
+ /**
34
+ * Read the schema version from a connection config FILE by regex (sync, never
35
+ * executes the module). `0` when the file is absent, unreadable, or carries no
36
+ * marker (legacy / hand-authored).
37
+ */
38
+ export declare function readConfigSchemaVersion(path: string): number;
39
+ /** Strip a leading `// @gaia-schema-version <N>` header line, if present. */
40
+ export declare function stripVersionHeader(text: string): string;
41
+ /** Prepend (replacing any existing) a `// @gaia-schema-version <N>` header. */
42
+ export declare function stampVersion(text: string, version: number): string;
43
+ /**
44
+ * Validate a migration chain is contiguous: each `to === from + 1`, the first
45
+ * migrates from v1, no gaps, and the chain ends at `current` (default CURRENT).
46
+ * An empty chain is valid iff `current === 1`. Throws with an actionable message.
47
+ */
48
+ export declare function assertContiguous(chain: ConnectionConfigMigration[], current?: number): void;
49
+ /**
50
+ * Walk the migration chain from `from` up to `to`, applying each method's
51
+ * `apply` in sequence. Input/output text is WITHOUT the version header — the
52
+ * caller stamps CURRENT after. `chain` is injectable for tests.
53
+ */
54
+ export declare function applyMigrations(text: string, from: number, to: number, chain?: ConnectionConfigMigration[]): string;
@@ -0,0 +1,91 @@
1
+ import { readFileSync } from 'node:fs';
2
+ // GAIA-216: schema versioning for the generated CONNECTION config
3
+ // (`gaia.config.js`, project + home). Decoupled from the npm package semver —
4
+ // the schema version bumps only when the config SHAPE changes, not every
5
+ // release. Every generated connection config carries a machine-readable header
6
+ // comment `// @gaia-schema-version <N>` (the source of truth for the upgrade
7
+ // decision, read by regex so `gaia upgrade` NEVER executes a possibly
8
+ // side-effecting config module) and a `schema_version: <N>` field for
9
+ // programmatic reads.
10
+ /** The header marker prepended to every generated connection config. */
11
+ const MARKER_RE = /@gaia-schema-version\s+(\d+)/;
12
+ const HEADER_LINE_RE = /^\/\/ @gaia-schema-version \d+\n/;
13
+ /**
14
+ * The ordered, contiguous migration chain. Ships EMPTY at v1: nothing below v1
15
+ * is a versioned config (v0 = seed / hand-authored / untouched). The FIRST real
16
+ * shape change adds the first `{ from: 1, to: 2, … }` method here, which bumps
17
+ * the derived CURRENT to 2 automatically. The extension point is real and
18
+ * exercised by tests now, so the first shape change is a one-method addition,
19
+ * never a re-plumb.
20
+ */
21
+ export const CONNECTION_MIGRATIONS = [
22
+ // { from: 1, to: 2, description: 'add site.jsonapi_prefix default', apply: (t) => … },
23
+ ];
24
+ /** The current connection-config schema version — single source of truth. */
25
+ export const GAIA_CONFIG_SCHEMA_VERSION = CONNECTION_MIGRATIONS.at(-1)?.to ?? 1;
26
+ /** Parse the `@gaia-schema-version` marker from config text; `0` when absent. */
27
+ export function schemaVersionOf(text) {
28
+ const m = MARKER_RE.exec(text);
29
+ return m ? Number.parseInt(m[1], 10) : 0;
30
+ }
31
+ /**
32
+ * Read the schema version from a connection config FILE by regex (sync, never
33
+ * executes the module). `0` when the file is absent, unreadable, or carries no
34
+ * marker (legacy / hand-authored).
35
+ */
36
+ export function readConfigSchemaVersion(path) {
37
+ try {
38
+ return schemaVersionOf(readFileSync(path, 'utf8'));
39
+ }
40
+ catch {
41
+ return 0;
42
+ }
43
+ }
44
+ /** Strip a leading `// @gaia-schema-version <N>` header line, if present. */
45
+ export function stripVersionHeader(text) {
46
+ return text.replace(HEADER_LINE_RE, '');
47
+ }
48
+ /** Prepend (replacing any existing) a `// @gaia-schema-version <N>` header. */
49
+ export function stampVersion(text, version) {
50
+ return `// @gaia-schema-version ${version}\n${stripVersionHeader(text)}`;
51
+ }
52
+ /**
53
+ * Validate a migration chain is contiguous: each `to === from + 1`, the first
54
+ * migrates from v1, no gaps, and the chain ends at `current` (default CURRENT).
55
+ * An empty chain is valid iff `current === 1`. Throws with an actionable message.
56
+ */
57
+ export function assertContiguous(chain, current = GAIA_CONFIG_SCHEMA_VERSION) {
58
+ let prev = 1;
59
+ chain.forEach((m, i) => {
60
+ if (m.to !== m.from + 1) {
61
+ throw new Error(`migration[${i}]: to (${m.to}) must equal from+1 (${m.from + 1})`);
62
+ }
63
+ const expectedFrom = i === 0 ? 1 : prev;
64
+ if (m.from !== expectedFrom) {
65
+ throw new Error(`migration[${i}]: from (${m.from}) must equal ${expectedFrom} (no gaps; chain starts at v1)`);
66
+ }
67
+ prev = m.to;
68
+ });
69
+ const end = chain.at(-1)?.to ?? 1;
70
+ if (end !== current) {
71
+ throw new Error(`migration chain ends at v${end}, expected CURRENT v${current}`);
72
+ }
73
+ }
74
+ /**
75
+ * Walk the migration chain from `from` up to `to`, applying each method's
76
+ * `apply` in sequence. Input/output text is WITHOUT the version header — the
77
+ * caller stamps CURRENT after. `chain` is injectable for tests.
78
+ */
79
+ export function applyMigrations(text, from, to, chain = CONNECTION_MIGRATIONS) {
80
+ let cur = from;
81
+ let out = text;
82
+ while (cur < to) {
83
+ const step = chain.find((m) => m.from === cur);
84
+ if (step === undefined) {
85
+ throw new Error(`no migration from v${cur} (chain incomplete)`);
86
+ }
87
+ out = step.apply(out);
88
+ cur = step.to;
89
+ }
90
+ return out;
91
+ }
@@ -0,0 +1,34 @@
1
+ import { type GaiaRemote } from '@gaia-ai/core';
2
+ import type { Command } from 'commander';
3
+ export interface GatherResult {
4
+ range: string;
5
+ resolved: Array<{
6
+ identifier: string;
7
+ uuid: string;
8
+ title: string;
9
+ }>;
10
+ unresolved: string[];
11
+ }
12
+ /** Deduped GAIA-nnn from a git log, in first-seen order. */
13
+ export declare function parseIdentifiers(gitLog: string): string[];
14
+ /**
15
+ * Compute the release batch from a git-log delta: parse GAIA-nnn identifiers and
16
+ * resolve each to a ticket. Unresolved identifiers are reported, never fatal
17
+ * (the resolver returning null is not an error) — GAIA-153 AC-2.
18
+ */
19
+ export declare function gatherBatch(opts: {
20
+ sinceRef: string;
21
+ toRef: string;
22
+ gitLog: string;
23
+ resolve: (id: string) => Promise<{
24
+ uuid: string;
25
+ title: string;
26
+ } | null>;
27
+ }): Promise<GatherResult>;
28
+ /**
29
+ * Register the `gaia deployment` command group. `tickets` computes the release's
30
+ * tickets (git-log identifier parse + resolve over the given range) and prints
31
+ * them as JSON — pure compute + report, it does not write `referenced_tickets`
32
+ * (the prepare-deployment skill does, via the documented dropsh path).
33
+ */
34
+ export declare function registerDeployment(program: Command, resolveRemote: () => Promise<GaiaRemote>, resolveProject: () => Promise<string>, cwd: () => string): void;
@@ -0,0 +1,63 @@
1
+ import { exec } from '@gaia-ai/core';
2
+ /** Deduped GAIA-nnn from a git log, in first-seen order. */
3
+ export function parseIdentifiers(gitLog) {
4
+ const seen = new Set();
5
+ const out = [];
6
+ for (const m of gitLog.matchAll(/\bGAIA-\d+\b/g)) {
7
+ if (!seen.has(m[0])) {
8
+ seen.add(m[0]);
9
+ out.push(m[0]);
10
+ }
11
+ }
12
+ return out;
13
+ }
14
+ /**
15
+ * Compute the release batch from a git-log delta: parse GAIA-nnn identifiers and
16
+ * resolve each to a ticket. Unresolved identifiers are reported, never fatal
17
+ * (the resolver returning null is not an error) — GAIA-153 AC-2.
18
+ */
19
+ export async function gatherBatch(opts) {
20
+ const resolved = [];
21
+ const unresolved = [];
22
+ for (const identifier of parseIdentifiers(opts.gitLog)) {
23
+ const hit = await opts.resolve(identifier);
24
+ if (hit) {
25
+ resolved.push({ identifier, ...hit });
26
+ }
27
+ else {
28
+ unresolved.push(identifier);
29
+ }
30
+ }
31
+ return { range: `${opts.sinceRef}..${opts.toRef}`, resolved, unresolved };
32
+ }
33
+ /**
34
+ * Register the `gaia deployment` command group. `tickets` computes the release's
35
+ * tickets (git-log identifier parse + resolve over the given range) and prints
36
+ * them as JSON — pure compute + report, it does not write `referenced_tickets`
37
+ * (the prepare-deployment skill does, via the documented dropsh path).
38
+ */
39
+ export function registerDeployment(program, resolveRemote, resolveProject, cwd) {
40
+ const dep = program
41
+ .command('deployment')
42
+ .description('release / deployment helpers');
43
+ dep
44
+ .command('tickets')
45
+ .description("the release's tickets (git-log identifier parse + resolve over a range) as JSON")
46
+ .requiredOption('--since <ref>', "the env's currently-deployed ref (git range start)")
47
+ .requiredOption('--to <ref>', 'the post-merge env branch tip (git range end)')
48
+ .action(async (opts) => {
49
+ // Prefix each commit with a record separator (%x1e) so one commit's body
50
+ // cannot run into the next commit's header line; the identifier parse is
51
+ // separator-agnostic, but the boundary keeps the log unambiguous.
52
+ const gitLog = await exec('git', ['log', '--format=%x1e%H %s%n%b', `${opts.since}..${opts.to}`], { cwd: cwd() });
53
+ const remote = await resolveRemote();
54
+ const project = await resolveProject();
55
+ const result = await gatherBatch({
56
+ sinceRef: opts.since,
57
+ toRef: opts.to,
58
+ gitLog,
59
+ resolve: (id) => remote.resolveTicketByIdentifier(project, id),
60
+ });
61
+ console.log(JSON.stringify(result));
62
+ });
63
+ }
@@ -1,3 +1,6 @@
1
+ import { type MachineContext, machineContextPath, readMachineContext } from '@gaia-ai/core';
2
+ export type { MachineContext };
3
+ export { machineContextPath, readMachineContext };
1
4
  export interface InitInputs {
2
5
  baseUrl: string;
3
6
  project: string;
@@ -21,19 +24,6 @@ export interface ScaffoldResult {
21
24
  wroteCommitted: boolean;
22
25
  machine: MachineContextResult;
23
26
  }
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
27
  export interface MachineContextOptions {
38
28
  path: string;
39
29
  userId: string;
@@ -48,35 +38,43 @@ export interface MachineContextResult {
48
38
  filledKeys: string[];
49
39
  }
50
40
  /**
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`.
41
+ * The committed, structural ENGINE conductor config (GAIA-201). It carries the
42
+ * engine wiring only remote/executor/agent/workspace + `machine_id`
43
+ * composition and NO `site`/`plugins`, which now live in the sibling
44
+ * connection config `gaia.config.js`. Connection + identity (incl. the secret)
45
+ * come from the user-global machine context (`~/.gaia/machine.config.js`);
46
+ * `machine_id` is composed as `${user_id}-${machine_id}-${project}`.
57
47
  */
58
48
  export declare function renderCommittedConfig(inputs: Pick<InitInputs, 'project'>): string;
49
+ /**
50
+ * The committed CONNECTION config `./.gaia/gaia.config.js` (GAIA-201). A
51
+ * dropsh-shaped `{ site, plugins }` read by `gaia ui`, `gaia dropsh`, and the
52
+ * conductor's auth. Reads base_url + client credentials from the user-global
53
+ * machine context (`~/.gaia/machine.config.js`) and declares the session
54
+ * (default) + pm oauth2 profiles.
55
+ */
56
+ export declare function renderGaiaConfig(): string;
59
57
  /** The user-global machine context module: identity + connection (incl. secret). */
60
58
  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
59
  /**
66
60
  * Create-if-missing / fill-only-missing the user-global machine context.
67
61
  * 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.
62
+ * defaults to hostname(). Written user-only (chmod 0600) since it holds the
63
+ * client secret.
70
64
  */
71
65
  export declare function scaffoldMachineContext(opts: MachineContextOptions): Promise<MachineContextResult>;
66
+ /** Write the sibling connection config `gaia.config.js` next to the engine
67
+ * config. Created only if missing (unless `force`). Returns its path + whether
68
+ * it was written. */
69
+ export declare function scaffoldGaiaConfig(engineConfigPath: string, force: boolean): {
70
+ path: string;
71
+ wrote: boolean;
72
+ };
72
73
  /**
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.
74
+ * Scaffold the committed ENGINE conductor.config.js (created only if missing —
75
+ * never overwritten unless `force`) and the user-global machine context
76
+ * (create-if-missing / fill-only-missing). The connection config is scaffolded
77
+ * separately by `scaffoldGaiaConfig`. `skipMachine` writes the committed config
78
+ * only; `skipCommitted` writes the machine context only.
81
79
  */
82
80
  export declare function scaffold(inputs: InitInputs, opts: ScaffoldOptions): Promise<ScaffoldResult>;
@@ -1,50 +1,51 @@
1
1
  import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { hostname } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
- import { pathToFileURL } from 'node:url';
4
+ import { machineContextPath, readMachineContext, } from '@gaia-ai/core';
5
+ import { GAIA_CONFIG_SCHEMA_VERSION } from './config-schema.js';
6
+ export { machineContextPath, readMachineContext };
5
7
  /** JS single-quoted string literal for a trusted, simple value. */
6
8
  function q(value) {
7
9
  return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
8
10
  }
9
11
  /**
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`.
12
+ * The committed, structural ENGINE conductor config (GAIA-201). It carries the
13
+ * engine wiring only remote/executor/agent/workspace + `machine_id`
14
+ * composition and NO `site`/`plugins`, which now live in the sibling
15
+ * connection config `gaia.config.js`. Connection + identity (incl. the secret)
16
+ * come from the user-global machine context (`~/.gaia/machine.config.js`);
17
+ * `machine_id` is composed as `${user_id}-${machine_id}-${project}`.
16
18
  */
17
19
  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
+ return `// Canonical GAIA conductor ENGINE config — committed. Connection + identity
21
+ // come from your user-global machine context (~/.gaia/machine.config.js:
20
22
  // { machine_id, user_id, base_url, client_id, client_secret }); machine_id is
21
23
  // 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
+ // per-repo value and is baked in below. This file is ENGINE-ONLY (GAIA-201):
25
+ // it has NO \`site\` and NO auth \`plugins\` those live in the sibling
26
+ // ./.gaia/gaia.config.js (the connection config that ui + dropsh + this
27
+ // conductor's auth all read).
24
28
  //
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:\` hereEXCEPT the \`@gaia-ai/gaia/plugins\` host
32
- // barrel (many exports → \`export: 'drupalRemote'\`) and the merged herdr
33
- // workspace slot (\`@gaia-ai/plugin-herdr\` default-exports the executor, so the
34
- // workspace names \`export: 'herdrWorkspace'\` — GAIA-139).
29
+ // IMPORT-FREE (GAIA-78): the plugin slots are \`{ plugin, with }\` descriptors
30
+ // naming the REAL published package, not \`import\`ed constructors.
31
+ // loadConductorConfig resolves each name ESLint-style (config dir → cwd →
32
+ // conductor install). Each plugin package default-exports its factory (no
33
+ // \`export:\`) EXCEPT the multi-export host barrel and the merged herdr workspace
34
+ // (\`@gaia-ai/plugin-herdr\` default-exports the executor, so the workspace names
35
+ // \`export: 'herdrWorkspace'\`GAIA-139).
35
36
 
36
37
  // The user-global machine context: identity + connection (incl. secret), shared
37
38
  // by every project on this machine. Never committed.
38
39
  async function loadMachine() {
39
40
  try {
40
- return (await import(\`\${process.env.HOME}/.config/conductor/conductor.config.machine.js\`)).default ?? {};
41
+ return (await import(\`\${process.env.HOME}/.gaia/machine.config.js\`)).default ?? {};
41
42
  } catch {}
42
43
  return {};
43
44
  }
44
45
 
45
46
  // OPTIONAL per-project override — create conductor.config.local.js beside this
46
- // file to override any field (machine_id, base_url, model, …). It is loaded only
47
- // if present and is NOT created by \`gaia conductor init\`.
47
+ // file to override any field (machine_id, model, …). Loaded only if present and
48
+ // NOT created by \`gaia conductor init\`.
48
49
  async function loadLocal() {
49
50
  try { return (await import('./conductor.config.local.js')).default ?? {}; } catch {}
50
51
  return {};
@@ -53,47 +54,74 @@ async function loadLocal() {
53
54
  const machine = await loadMachine();
54
55
  const local = await loadLocal();
55
56
  const project = local.project ?? ${q(inputs.project)};
56
- const baseUrl = local.base_url ?? machine.base_url;
57
- const clientId = local.oauth?.client_id ?? machine.client_id ?? 'gaia-agent';
58
- const clientSecret = local.oauth?.client_secret ?? machine.client_secret;
59
57
  const composedMachineId =
60
58
  machine.user_id && machine.machine_id
61
59
  ? \`\${machine.user_id}-\${machine.machine_id}-\${project}\`
62
60
  : undefined;
63
61
 
64
62
  export default {
65
- site: { base_url: baseUrl, jsonapi_prefix: local.jsonapi_prefix ?? '/jsonapi' },
66
63
  project,
67
64
  machine_id: local.machine_id ?? composedMachineId,
68
65
  states: ['spec', 'diagnose', 'coding', 'review'],
69
66
  max_parallel: 5,
70
- // Lifecycle hooks are executor-owned (GAIA-84): the executor invokes each
71
- // best-effort (logs loudly + continues, never aborts a run), so they live at
72
- // the config top level — NOT on a plugin descriptor's \`with.hooks\`.
67
+ // Lifecycle hooks are executor-owned (GAIA-84): they live at the config top
68
+ // level NOT on a plugin descriptor's \`with.hooks\`.
73
69
  hooks: { after_create: 'ddev init-worktree', after_done: 'ddev delete -Oy' },
74
70
  remote: { plugin: '@gaia-ai/gaia/plugins', export: 'drupalRemote' },
75
- // No hard-wired diff pane for review: the review diff surface is hunk
76
- // (GAIA-55) — agent-driven + opt-in in the human's interactive pane, not an
77
- // executor-forced git-diff pane. Clicking a changed file in that hunk pane
78
- // opens it editable in a spiceedit overlay (see conductor/README.md).
79
71
  executor: { plugin: '@gaia-ai/plugin-herdr' },
80
72
  agent: {
81
73
  plugin: '@gaia-ai/plugin-claude',
82
74
  with: { model: local.model ?? 'claude-opus-4-8' },
83
75
  },
84
- // GAIA-139: the herdr workspace ships in @gaia-ai/plugin-herdr now (the
85
- // separate plugin-herdr-workspace was merged in). That package default-exports
86
- // the EXECUTOR, so the workspace slot must name export: 'herdrWorkspace'.
76
+ // GAIA-139: the herdr workspace ships in @gaia-ai/plugin-herdr, which
77
+ // default-exports the EXECUTOR so the workspace slot names export: 'herdrWorkspace'.
87
78
  workspace: {
88
79
  plugin: '@gaia-ai/plugin-herdr',
89
80
  export: 'herdrWorkspace',
90
81
  },
91
- // oauth2 is a real dep of the host (npm installs it alongside @gaia-ai/gaia).
92
- // NOTE: plugins[] is consumed by DROPSH, which reloads this config with its OWN
93
- // resolver (\`export ?? 'default'\`, no sole-function auto-pick) on every
94
- // \`gaia dropsh …\` command. @dropsh/plugin-oauth2 has no default export, so
95
- // these entries MUST name \`export: 'oauth2Plugin'\` unlike the four conductor
96
- // slots above, which the conductor resolves and auto-picks.
82
+ };
83
+ `;
84
+ }
85
+ /**
86
+ * The committed CONNECTION config `./.gaia/gaia.config.js` (GAIA-201). A
87
+ * dropsh-shaped `{ site, plugins }` read by `gaia ui`, `gaia dropsh`, and the
88
+ * conductor's auth. Reads base_url + client credentials from the user-global
89
+ * machine context (`~/.gaia/machine.config.js`) and declares the session
90
+ * (default) + pm oauth2 profiles.
91
+ */
92
+ export function renderGaiaConfig() {
93
+ return `// @gaia-schema-version ${GAIA_CONFIG_SCHEMA_VERSION}
94
+ // Canonical GAIA CONNECTION config — committed (GAIA-201). A dropsh-shaped
95
+ // { site, plugins } read by 'gaia ui', 'gaia dropsh', and this conductor's auth.
96
+ // Connection + credentials come from your user-global machine context
97
+ // (~/.gaia/machine.config.js). The engine wiring lives in the sibling
98
+ // conductor.config.js (engine-only).
99
+ //
100
+ // plugins[] is consumed by DROPSH, whose resolver has no sole-function
101
+ // auto-pick, so @dropsh/plugin-oauth2 entries MUST name export: 'oauth2Plugin'.
102
+ async function loadMachine() {
103
+ try {
104
+ return (await import(\`\${process.env.HOME}/.gaia/machine.config.js\`)).default ?? {};
105
+ } catch {}
106
+ return {};
107
+ }
108
+
109
+ async function loadLocal() {
110
+ try { return (await import('./conductor.config.local.js')).default ?? {}; } catch {}
111
+ return {};
112
+ }
113
+
114
+ const machine = await loadMachine();
115
+ const local = await loadLocal();
116
+ const baseUrl = local.base_url ?? machine.base_url;
117
+ const clientId = local.oauth?.client_id ?? machine.client_id ?? 'gaia-agent';
118
+ const clientSecret = local.oauth?.client_secret ?? machine.client_secret;
119
+
120
+ export default {
121
+ // GAIA-216: the connection-config schema version — kept in sync with the
122
+ // header marker above so \`gaia upgrade\` can migrate a stale-shape config.
123
+ schema_version: ${GAIA_CONFIG_SCHEMA_VERSION},
124
+ site: { base_url: baseUrl, jsonapi_prefix: local.jsonapi_prefix ?? '/jsonapi' },
97
125
  plugins: [
98
126
  {
99
127
  plugin: '@dropsh/plugin-oauth2',
@@ -126,12 +154,12 @@ export default {
126
154
  }
127
155
  /** The user-global machine context module: identity + connection (incl. secret). */
128
156
  export function renderMachineContext(ctx) {
129
- return `// User-global conductor context — gitignored, user-only (chmod 0600), never
157
+ return `// User-global GAIA machine context — gitignored, user-only (chmod 0600), never
130
158
  // committed. A plain importable module holding your machine identity +
131
- // connection, incl. the OAuth client secret. Committed conductor.config.js files
132
- // import this to compose machine_id (\`\${user_id}-\${machine_id}-\${project}\`) and
133
- // read base_url / client_id / client_secret. Created and gap-filled by
134
- // \`gaia conductor init\`; existing values are never overwritten.
159
+ // connection, incl. the OAuth client secret. The engine conductor.config.js
160
+ // composes machine_id (\`\${user_id}-\${machine_id}-\${project}\`) from it; the
161
+ // connection gaia.config.js reads base_url / client_id / client_secret. Created
162
+ // and gap-filled by \`gaia conductor init\`; existing values are never overwritten.
135
163
  export default {
136
164
  machine_id: ${q(ctx.machine_id)},
137
165
  user_id: ${q(ctx.user_id)},
@@ -141,31 +169,11 @@ export default {
141
169
  };
142
170
  `;
143
171
  }
144
- /** The user-global machine context path: ~/.config/conductor/conductor.config.machine.js */
145
- export function machineContextPath() {
146
- return join(process.env.HOME ?? '', '.config', 'conductor', 'conductor.config.machine.js');
147
- }
148
- /** Import an existing context module's default export, or {} if absent/broken. */
149
- export async function readMachineContext(path) {
150
- if (!existsSync(path))
151
- return {};
152
- try {
153
- // Cache-bust so a re-render within one process re-reads fresh.
154
- const mod = await import(`${pathToFileURL(path).href}?t=${Date.now()}`);
155
- const raw = mod.default;
156
- return raw && typeof raw === 'object'
157
- ? raw
158
- : {};
159
- }
160
- catch {
161
- return {};
162
- }
163
- }
164
172
  /**
165
173
  * Create-if-missing / fill-only-missing the user-global machine context.
166
174
  * Existing values always win; only absent/blank keys are filled. machine_id
167
- * defaults to hostname(). A no-op (no rewrite) when the file is already complete.
168
- * The file is written user-only (chmod 0600) since it holds the client secret.
175
+ * defaults to hostname(). Written user-only (chmod 0600) since it holds the
176
+ * client secret.
169
177
  */
170
178
  export async function scaffoldMachineContext(opts) {
171
179
  const existing = await readMachineContext(opts.path);
@@ -196,20 +204,28 @@ export async function scaffoldMachineContext(opts) {
196
204
  mkdirSync(dirname(opts.path), { recursive: true });
197
205
  writeFileSync(opts.path, renderMachineContext(merged), 'utf8');
198
206
  }
199
- // Always tighten perms — the file holds a secret.
200
207
  if (existsSync(opts.path))
201
208
  chmodSync(opts.path, 0o600);
202
209
  return { path: opts.path, created, filledKeys };
203
210
  }
211
+ /** Write the sibling connection config `gaia.config.js` next to the engine
212
+ * config. Created only if missing (unless `force`). Returns its path + whether
213
+ * it was written. */
214
+ export function scaffoldGaiaConfig(engineConfigPath, force) {
215
+ const path = join(dirname(engineConfigPath), 'gaia.config.js');
216
+ mkdirSync(dirname(path), { recursive: true });
217
+ if (!existsSync(path) || force) {
218
+ writeFileSync(path, renderGaiaConfig(), 'utf8');
219
+ return { path, wrote: true };
220
+ }
221
+ return { path, wrote: false };
222
+ }
204
223
  /**
205
- * Scaffold the two conductor config files: the committed conductor.config.js
206
- * (created only if missing — never overwritten unless `force`) and the
207
- * user-global machine context (create-if-missing / fill-only-missing). The
208
- * optional per-project conductor.config.local.js is NOT generated.
209
- *
210
- * `skipMachine` writes the committed config only (project-only setup); its
211
- * mirror `skipCommitted` writes the machine context only (machine-only
212
- * onboarding, no repo). Setting both is a no-op.
224
+ * Scaffold the committed ENGINE conductor.config.js (created only if missing —
225
+ * never overwritten unless `force`) and the user-global machine context
226
+ * (create-if-missing / fill-only-missing). The connection config is scaffolded
227
+ * separately by `scaffoldGaiaConfig`. `skipMachine` writes the committed config
228
+ * only; `skipCommitted` writes the machine context only.
213
229
  */
214
230
  export async function scaffold(inputs, opts) {
215
231
  const committedPath = opts.configPath;
@@ -0,0 +1,38 @@
1
+ import { type ConnectionConfigMigration } from './config-schema.js';
2
+ export interface UpgradeReport {
3
+ /** Human-readable action lines (what changed / was already current). */
4
+ actions: string[];
5
+ /** true when nothing needed doing. */
6
+ alreadyCurrent: boolean;
7
+ }
8
+ /** The outcome of routing a single connection-config file. */
9
+ interface ConnectionUpgradeResult {
10
+ action: string;
11
+ changed: boolean;
12
+ }
13
+ /**
14
+ * Route a single connection-config `path` against the schema version (GAIA-216).
15
+ * `label` names the file for the report; `current`/`chain` are injectable for
16
+ * tests (default to the shipped registry). Never executes the config module —
17
+ * the decision is a regex read of the header marker.
18
+ *
19
+ * | existing marker | action |
20
+ * | absent (no file) | seed: render current template, stamp CURRENT |
21
+ * | `== CURRENT` | kept (byte-identical) |
22
+ * | `1..CURRENT-1` (stale) | back up `.v<old>.bak`, run chain → stamp CURRENT |
23
+ * | `0` (unversioned) | kept (do not clobber hand edits) |
24
+ * | `> CURRENT` (newer) | kept (refuse to downgrade) |
25
+ */
26
+ export declare function runConnectionUpgrade(path: string, opts?: {
27
+ dryRun?: boolean;
28
+ label?: string;
29
+ current?: number;
30
+ chain?: ConnectionConfigMigration[];
31
+ }): ConnectionUpgradeResult;
32
+ /** Run the migration. Pure w.r.t. injected `cwd`/`home`; `dryRun` suppresses writes. */
33
+ export declare function runUpgrade(opts?: {
34
+ cwd?: string;
35
+ home?: string;
36
+ dryRun?: boolean;
37
+ }): UpgradeReport;
38
+ export {};