@gaia-ai/gaia 0.1.4

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/dist/index.js ADDED
@@ -0,0 +1,2041 @@
1
+ // src/cli/gaia.ts
2
+ import { dirname as dirname4 } from "node:path";
3
+ import { createInterface } from "node:readline";
4
+ import { Command } from "commander";
5
+ import { authStatus, buildProgram as buildDropshProgram } from "dropsh";
6
+
7
+ // src/config.ts
8
+ import { dirname, resolve } from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+
11
+ // src/core/conductor-id.ts
12
+ import { createHash } from "node:crypto";
13
+ import { hostname } from "node:os";
14
+ function conductorId(absPath) {
15
+ return createHash("sha256").update(`${hostname()}:${absPath}`).digest("hex").slice(0, 16);
16
+ }
17
+
18
+ // src/config.ts
19
+ var DEFAULT_AGENT_PROMPT = `You are a GAIA agent working ticket {identifier}, current state: {state}. Follow the "{state}" section of ./WORKFLOW.md in this repository. This run covers EXACTLY the {state} state - do only its work. Do not start, prepare, or perform any later state's work, even if WORKFLOW.md mentions transitioning onward. When the {state} work is done and you have no open questions, STOP - do not pick up or work any further state or ticket. If blocked or you need a human, stop and surface the blocker. After you have written the ticket's next state, run /exit to end this session.
20
+
21
+ The ticket's comments are included below (oldest first). Read them before acting: the latest \`summary\` is the review feedback you MUST address, and any \`spec\`/\`debug_diagnose\` is the agreed plan/diagnosis. Treat their points as in-scope acceptance criteria.
22
+
23
+ --- Ticket comments ---
24
+ {comments}`;
25
+ function requirePlugin(value, kind) {
26
+ if (!isRecord(value) || value.kind !== kind) {
27
+ throw new Error(
28
+ `conductor config requires a ${kind} plugin in the "${kind}" slot`
29
+ );
30
+ }
31
+ return value;
32
+ }
33
+ function isRecord(value) {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ }
36
+ function requireNonEmptyString(value, key) {
37
+ if (typeof value !== "string" || value.trim() === "") {
38
+ throw new Error(`conductor config requires non-empty ${key}`);
39
+ }
40
+ return value;
41
+ }
42
+ function optionalPositiveInteger(value, fallback, key) {
43
+ if (value === void 0) {
44
+ return fallback;
45
+ }
46
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
47
+ throw new Error(`conductor config requires positive integer ${key}`);
48
+ }
49
+ return value;
50
+ }
51
+ async function loadConductorConfig(configFile) {
52
+ const configPath = resolve(configFile);
53
+ const module = await import(pathToFileURL(configPath).href);
54
+ const raw = module.default;
55
+ if (!isRecord(raw)) {
56
+ throw new Error("conductor config default export must be an object");
57
+ }
58
+ const config = raw;
59
+ const site = isRecord(config.site) ? config.site : {};
60
+ const baseUrl = requireNonEmptyString(site.base_url, "site.base_url");
61
+ const project = requireNonEmptyString(config.project, "project");
62
+ if (!Array.isArray(config.states) || config.states.length === 0) {
63
+ throw new Error("conductor config requires non-empty states");
64
+ }
65
+ const states = config.states.map(
66
+ (state) => requireNonEmptyString(state, "states")
67
+ );
68
+ const checkoutRoot = dirname(configPath);
69
+ const machineId = typeof config.machine_id === "string" && config.machine_id.trim() !== "" ? config.machine_id : conductorId(checkoutRoot);
70
+ const label = typeof config.label === "string" && config.label.trim() !== "" ? config.label : machineId;
71
+ return {
72
+ site: {
73
+ base_url: baseUrl,
74
+ jsonapi_prefix: typeof site.jsonapi_prefix === "string" && site.jsonapi_prefix.trim() !== "" ? site.jsonapi_prefix : "/jsonapi"
75
+ },
76
+ ...Array.isArray(config.plugins) ? { plugins: config.plugins } : {},
77
+ remote: requirePlugin(config.remote, "remote"),
78
+ executor: requirePlugin(config.executor, "executor"),
79
+ agent: requirePlugin(config.agent, "agent"),
80
+ workspace: requirePlugin(config.workspace, "workspace"),
81
+ label,
82
+ machine_id: machineId,
83
+ project,
84
+ states,
85
+ prompt: typeof config.prompt === "string" && config.prompt.trim() !== "" ? config.prompt : DEFAULT_AGENT_PROMPT,
86
+ max_parallel: optionalPositiveInteger(
87
+ config.max_parallel,
88
+ 1,
89
+ "max_parallel"
90
+ ),
91
+ poll_interval_ms: optionalPositiveInteger(
92
+ config.poll_interval_ms,
93
+ 5e3,
94
+ "poll_interval_ms"
95
+ ),
96
+ lease_seconds: optionalPositiveInteger(
97
+ config.lease_seconds,
98
+ 300,
99
+ "lease_seconds"
100
+ ),
101
+ ...config.hooks !== void 0 ? { hooks: config.hooks } : {},
102
+ config_path: configPath
103
+ };
104
+ }
105
+
106
+ // src/core/conductor.ts
107
+ function sleep(ms, signal) {
108
+ return new Promise((resolve5) => {
109
+ if (signal?.aborted) {
110
+ resolve5();
111
+ return;
112
+ }
113
+ const timer = setTimeout(() => {
114
+ signal?.removeEventListener("abort", onAbort);
115
+ resolve5();
116
+ }, ms);
117
+ const onAbort = () => {
118
+ clearTimeout(timer);
119
+ resolve5();
120
+ };
121
+ signal?.addEventListener("abort", onAbort, { once: true });
122
+ });
123
+ }
124
+ function renderComments(comments) {
125
+ if (comments.length === 0) {
126
+ return "(no comments on this ticket yet)";
127
+ }
128
+ return comments.map((c, i) => {
129
+ const when = c.created ? ` (${c.created})` : "";
130
+ return `[#${i + 1}] type=${c.type}${when}
131
+ ${c.body}`;
132
+ }).join("\n\n---\n\n");
133
+ }
134
+ function renderPrompt(template, values) {
135
+ return template.replaceAll("{identifier}", values.identifier).replaceAll("{state}", values.state).replaceAll("{runUuid}", values.runUuid).replaceAll("{comments}", renderComments(values.comments));
136
+ }
137
+ var Conductor = class {
138
+ constructor(config, remote, executor, workspace, agent, logger, checkoutRoot = process.cwd()) {
139
+ this.config = config;
140
+ this.remote = remote;
141
+ this.executor = executor;
142
+ this.workspace = workspace;
143
+ this.agent = agent;
144
+ this.logger = logger;
145
+ this.checkoutRoot = checkoutRoot;
146
+ }
147
+ config;
148
+ remote;
149
+ executor;
150
+ workspace;
151
+ agent;
152
+ logger;
153
+ checkoutRoot;
154
+ uuid = null;
155
+ get id() {
156
+ return this.config.machine_id ?? conductorId(this.checkoutRoot);
157
+ }
158
+ /** This conductor's registration payload, built from config. */
159
+ registration() {
160
+ return {
161
+ id: this.id,
162
+ project: this.config.project,
163
+ states: this.config.states,
164
+ workspace: this.checkoutRoot,
165
+ label: this.config.label,
166
+ max_parallel: this.config.max_parallel
167
+ };
168
+ }
169
+ async start() {
170
+ this.uuid = await this.remote.registerConductor(this.registration());
171
+ this.logger.info(
172
+ { conductorId: this.id, uuid: this.uuid, project: this.config.project },
173
+ "conductor started"
174
+ );
175
+ }
176
+ async tick() {
177
+ if (!this.uuid) throw new Error("not started");
178
+ let count = await this.remote.activeRunCount(this.id);
179
+ await this.remote.heartbeat(
180
+ this.registration(),
181
+ count,
182
+ this.config.lease_seconds
183
+ );
184
+ this.logger.info({ conductorId: this.id, active: count }, "conductor tick");
185
+ await this.finalizeDoneRuns();
186
+ await this.cleanupDoneTickets();
187
+ let claimed = 0;
188
+ while (count < this.config.max_parallel) {
189
+ const run = await this.remote.claimNext({
190
+ leaseSeconds: this.config.lease_seconds,
191
+ conductorId: this.id
192
+ });
193
+ if (!run) break;
194
+ this.logger.info(
195
+ { conductorId: this.id, run: run.runUuid, ticket: run.ticketUuid },
196
+ "claimed run"
197
+ );
198
+ try {
199
+ await this.dispatch(run);
200
+ } catch (err) {
201
+ this.logger.error(
202
+ { run: run.runUuid, err: String(err) },
203
+ "dispatch failed"
204
+ );
205
+ }
206
+ count += 1;
207
+ claimed += 1;
208
+ }
209
+ if (claimed === 0) {
210
+ this.logger.info(
211
+ {
212
+ conductorId: this.id,
213
+ active: count,
214
+ capacity: this.config.max_parallel
215
+ },
216
+ count >= this.config.max_parallel ? "at capacity, nothing claimed" : "idle, nothing to claim"
217
+ );
218
+ }
219
+ }
220
+ /**
221
+ * One-shot finalisation: for each of this conductor's runs that is
222
+ * state=done but not yet closed, read the agent transcript from its worktree
223
+ * and write it as the final log, then mark the run closed. Keyed off the
224
+ * durable `state=done AND closed=false` query — no in-memory state, so a
225
+ * restarted conductor still finalises any unclosed done run on its next tick.
226
+ */
227
+ async finalizeDoneRuns() {
228
+ const runs = await this.remote.fetchFinalizableRuns(this.id);
229
+ for (const r of runs) {
230
+ try {
231
+ if (this.executor.capabilities().persistent) {
232
+ const branch = await this.remote.getRunTicketBranchName(r.runUuid);
233
+ if (branch) {
234
+ try {
235
+ await this.executor.stopAgent(branch);
236
+ } catch (err) {
237
+ this.logger.warn(
238
+ { run: r.runUuid, err: String(err) },
239
+ "agent stop failed"
240
+ );
241
+ }
242
+ }
243
+ }
244
+ const log = r.worktreePath ? await this.agent.getRunLog(r.worktreePath) : "";
245
+ await this.remote.finalizeRun(r.runUuid, log);
246
+ this.logger.info({ run: r.runUuid }, "run finalised");
247
+ } catch (err) {
248
+ this.logger.warn(
249
+ { run: r.runUuid, err: String(err) },
250
+ "run finalise failed"
251
+ );
252
+ }
253
+ }
254
+ }
255
+ /**
256
+ * One-shot ticket cleanup, symmetric to {@link finalizeDoneRuns} on the
257
+ * ticket axis: for each of this conductor's tickets that is state=done but not
258
+ * yet closed, run the workspace's `after_done` hook in the ticket's worktree,
259
+ * tear the worktree down (herdr), then mark the ticket closed. Keyed off the
260
+ * durable `state=done AND closed=false` query — no in-memory state, so a
261
+ * restarted conductor still cleans up.
262
+ *
263
+ * Error handling mirrors finalise: each ticket is isolated in a try/catch so
264
+ * one failure never wedges the tick. The `after_done` hook itself is
265
+ * best-effort — its failure is logged but does not block the teardown/close
266
+ * (reclaiming the worktree matters more than a clean environment teardown).
267
+ */
268
+ async cleanupDoneTickets() {
269
+ const tickets = await this.remote.fetchFinalizableTickets(this.id);
270
+ for (const t of tickets) {
271
+ try {
272
+ if (t.worktreePath) {
273
+ try {
274
+ await this.workspace.afterDone(t.worktreePath);
275
+ } catch (err) {
276
+ this.logger.warn(
277
+ { ticket: t.ticketUuid, err: String(err) },
278
+ "ticket after_done hook failed"
279
+ );
280
+ }
281
+ }
282
+ if (this.executor.capabilities().persistent) {
283
+ try {
284
+ await this.executor.removeWorktree(
285
+ t.branchName,
286
+ t.worktreePath || void 0
287
+ );
288
+ } catch (err) {
289
+ this.logger.warn(
290
+ {
291
+ ticket: t.ticketUuid,
292
+ branch: t.branchName,
293
+ worktreePath: t.worktreePath,
294
+ err: String(err)
295
+ },
296
+ "worktree teardown failed"
297
+ );
298
+ }
299
+ }
300
+ await this.remote.closeTicket(t.ticketUuid);
301
+ this.logger.info({ ticket: t.ticketUuid }, "ticket cleaned up");
302
+ } catch (err) {
303
+ this.logger.warn(
304
+ { ticket: t.ticketUuid, err: String(err) },
305
+ "ticket cleanup failed"
306
+ );
307
+ }
308
+ }
309
+ }
310
+ async dispatch(run) {
311
+ const t = await this.remote.getTicket(run.ticketUuid);
312
+ const baseRef = t.baseBranch ? `origin/${t.baseBranch}` : void 0;
313
+ const ws = await this.workspace.ensure(t.identifier, t.branchName, baseRef);
314
+ await this.workspace.beforeRun(ws.path);
315
+ await this.executor.retire(t.branchName);
316
+ const prompt = ws.instructions ? renderPrompt(this.config.prompt, {
317
+ identifier: t.identifier,
318
+ state: t.state || "triage",
319
+ runUuid: run.runUuid,
320
+ comments: t.comments
321
+ }) : "";
322
+ await this.executor.startRun({
323
+ ticket: {
324
+ uuid: t.uuid,
325
+ identifier: t.identifier,
326
+ title: t.title,
327
+ branchName: t.branchName,
328
+ state: t.state,
329
+ ...t.issueUrl ? { url: t.issueUrl } : {}
330
+ },
331
+ run: { uuid: run.runUuid, id: run.runId, handler: run.handler },
332
+ workspacePath: ws.path,
333
+ instructions: ws.instructions,
334
+ command: this.agent.launchCommand(prompt),
335
+ env: {
336
+ GAIA_URL: this.config.site.base_url,
337
+ GAIA_ID: run.ticketUuid,
338
+ GAIA_RUN_UUID: run.runUuid,
339
+ WORKSPACE_ROOT: this.checkoutRoot,
340
+ ...t.issueUrl ? { TICKET_URL: t.issueUrl } : {}
341
+ }
342
+ });
343
+ this.logger.info(
344
+ {
345
+ ticket: t.identifier,
346
+ run: run.runUuid,
347
+ state: t.state,
348
+ workspace: ws.path
349
+ },
350
+ "dispatched run"
351
+ );
352
+ await this.remote.markRunning(run.runUuid, { worktree_path: ws.path });
353
+ }
354
+ async serve(signal) {
355
+ await this.pollLoop(signal);
356
+ }
357
+ async pollLoop(signal) {
358
+ while (!signal?.aborted) {
359
+ try {
360
+ await this.tick();
361
+ } catch (err) {
362
+ this.logger.error(
363
+ { conductorId: this.id, err: String(err) },
364
+ "tick failed"
365
+ );
366
+ }
367
+ await sleep(this.config.poll_interval_ms, signal);
368
+ }
369
+ }
370
+ };
371
+
372
+ // src/core/exec.ts
373
+ import { execFile } from "node:child_process";
374
+ import { promisify } from "node:util";
375
+ var execFileAsync = promisify(execFile);
376
+ var ExecError = class extends Error {
377
+ constructor(file, args, exitCode, stderr, options) {
378
+ super(`exec failed: ${file}`, options);
379
+ this.file = file;
380
+ this.args = args;
381
+ this.exitCode = exitCode;
382
+ this.stderr = stderr;
383
+ this.name = "ExecError";
384
+ }
385
+ file;
386
+ args;
387
+ exitCode;
388
+ stderr;
389
+ };
390
+ var CommandRunner = class {
391
+ constructor(logger) {
392
+ this.logger = logger;
393
+ }
394
+ logger;
395
+ async run(file, args, opts = {}) {
396
+ try {
397
+ const { stdout } = await execFileAsync(file, args, {
398
+ encoding: "utf8",
399
+ ...opts
400
+ });
401
+ this.logger.debug({ file, args, cwd: opts.cwd }, "exec ok");
402
+ return stdout;
403
+ } catch (err) {
404
+ const e = err;
405
+ const exitCode = typeof e.code === "number" ? e.code : null;
406
+ const stderr = e.stderr ?? "";
407
+ this.logger.error(
408
+ { file, args, cwd: opts.cwd, exitCode, stderr },
409
+ "exec failed"
410
+ );
411
+ throw new ExecError(file, args, exitCode, stderr, { cause: err });
412
+ }
413
+ }
414
+ };
415
+ var noopLogger = {
416
+ debug() {
417
+ },
418
+ info() {
419
+ },
420
+ warn() {
421
+ },
422
+ error() {
423
+ }
424
+ };
425
+ var defaultRunner = new CommandRunner(noopLogger);
426
+ function setDefaultCommandRunner(r) {
427
+ defaultRunner = r;
428
+ }
429
+ function exec(file, args, opts = {}) {
430
+ return defaultRunner.run(file, args, opts);
431
+ }
432
+
433
+ // src/core/logger.ts
434
+ import { join } from "node:path";
435
+ import pino from "pino";
436
+ import pretty from "pino-pretty";
437
+ function resolveSink(env, isTTY, checkoutRoot, override) {
438
+ const fileSink = {
439
+ kind: "file",
440
+ path: join(checkoutRoot, "log.txt")
441
+ };
442
+ const choice = override ?? env.GAIA_CONDUCTOR_LOG;
443
+ if (choice === "stdout") return { kind: "stdout" };
444
+ if (choice === "file") return fileSink;
445
+ return isTTY ? { kind: "stdout" } : fileSink;
446
+ }
447
+ function resolveLevel(env, override) {
448
+ return override ?? env.GAIA_LOG_LEVEL ?? "info";
449
+ }
450
+ function createLogger(opts) {
451
+ const env = opts.env ?? process.env;
452
+ const isTTY = opts.isTTY ?? Boolean(process.stdout.isTTY);
453
+ const level = resolveLevel(env, opts.level);
454
+ const sink = resolveSink(env, isTTY, opts.checkoutRoot, opts.sink);
455
+ if (sink.kind === "stdout") {
456
+ return pino({ level }, pretty({ colorize: true, sync: true }));
457
+ }
458
+ return pino(
459
+ { level },
460
+ pino.destination({ dest: sink.path, append: true, mkdir: true })
461
+ );
462
+ }
463
+
464
+ // src/plugins/plugins.ts
465
+ async function selectRemote(config) {
466
+ return config.remote.createRemote(config);
467
+ }
468
+ async function selectExecutor(config) {
469
+ return config.executor.createExecutor(config);
470
+ }
471
+ async function selectWorkspace(config) {
472
+ return config.workspace.createWorkspace(config);
473
+ }
474
+ async function selectAgent(config) {
475
+ return config.agent.createAgent(config);
476
+ }
477
+
478
+ // src/cli/init.ts
479
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
480
+ import { hostname as hostname2 } from "node:os";
481
+ import { dirname as dirname2, join as join2 } from "node:path";
482
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
483
+ function q(value) {
484
+ return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
485
+ }
486
+ function renderCommittedConfig(inputs) {
487
+ return `// Canonical GAIA conductor config \u2014 committed. Connection + identity come from
488
+ // your user-global machine context (~/.config/conductor/conductor.config.machine.js:
489
+ // { machine_id, user_id, base_url, client_id, client_secret }); machine_id is
490
+ // composed here as \`\${user_id}-\${machine_id}-\${project}\`. \`project\` is the only
491
+ // per-repo value and is baked in below. The client_secret is read from the
492
+ // machine context (gitignored, user-only) \u2014 never committed here.
493
+ // A global \`@gaia-ai/gaia\` install exposes the whole plugin surface from one entry.
494
+ import {
495
+ claudeAgent,
496
+ drupalRemote,
497
+ herdrExecutor,
498
+ herdrWorkspace,
499
+ oauth2Plugin,
500
+ } from '@gaia-ai/gaia/plugins';
501
+
502
+ // The user-global machine context: identity + connection (incl. secret), shared
503
+ // by every project on this machine. Never committed.
504
+ async function loadMachine() {
505
+ try {
506
+ return (await import(\`\${process.env.HOME}/.config/conductor/conductor.config.machine.js\`)).default ?? {};
507
+ } catch {}
508
+ return {};
509
+ }
510
+
511
+ // OPTIONAL per-project override \u2014 create conductor.config.local.js beside this
512
+ // file to override any field (machine_id, base_url, model, \u2026). It is loaded only
513
+ // if present and is NOT created by \`gaia conductor init\`.
514
+ async function loadLocal() {
515
+ try { return (await import('./conductor.config.local.js')).default ?? {}; } catch {}
516
+ return {};
517
+ }
518
+
519
+ const machine = await loadMachine();
520
+ const local = await loadLocal();
521
+ const project = local.project ?? ${q(inputs.project)};
522
+ const baseUrl = local.base_url ?? machine.base_url;
523
+ const clientId = local.oauth?.client_id ?? machine.client_id ?? 'gaia-agent';
524
+ const clientSecret = local.oauth?.client_secret ?? machine.client_secret;
525
+ const composedMachineId =
526
+ machine.user_id && machine.machine_id
527
+ ? \`\${machine.user_id}-\${machine.machine_id}-\${project}\`
528
+ : undefined;
529
+
530
+ export default {
531
+ site: { base_url: baseUrl, jsonapi_prefix: local.jsonapi_prefix ?? '/jsonapi' },
532
+ project,
533
+ machine_id: local.machine_id ?? composedMachineId,
534
+ states: ['spec', 'diagnose', 'coding', 'review'],
535
+ max_parallel: 5,
536
+ remote: drupalRemote(),
537
+ executor: herdrExecutor({
538
+ states: {
539
+ review: { panes: [{ direction: 'right', command: 'git -C {workspacePath} diff' }] },
540
+ },
541
+ }),
542
+ agent: claudeAgent({ model: local.model ?? 'claude-opus-4-8' }),
543
+ workspace: herdrWorkspace({
544
+ hooks: { after_create: 'ddev init-worktree', after_done: 'ddev delete -Oy' },
545
+ }),
546
+ plugins: [
547
+ oauth2Plugin({
548
+ id: 'session',
549
+ default: true,
550
+ type: 'oauth2_client_credentials',
551
+ client_id: clientId,
552
+ client_secret: clientSecret,
553
+ token_url: \`\${baseUrl}/oauth/token\`,
554
+ scope: 'gaia:session',
555
+ }),
556
+ oauth2Plugin({
557
+ id: 'pm',
558
+ type: 'oauth2_client_credentials',
559
+ client_id: clientId,
560
+ client_secret: clientSecret,
561
+ token_url: \`\${baseUrl}/oauth/token\`,
562
+ scope: 'gaia:project_manager',
563
+ }),
564
+ ],
565
+ };
566
+ `;
567
+ }
568
+ function renderMachineContext(ctx) {
569
+ return `// User-global conductor context \u2014 gitignored, user-only (chmod 0600), never
570
+ // committed. A plain importable module holding your machine identity +
571
+ // connection, incl. the OAuth client secret. Committed conductor.config.js files
572
+ // import this to compose machine_id (\`\${user_id}-\${machine_id}-\${project}\`) and
573
+ // read base_url / client_id / client_secret. Created and gap-filled by
574
+ // \`gaia conductor init\`; existing values are never overwritten.
575
+ export default {
576
+ machine_id: ${q(ctx.machine_id)},
577
+ user_id: ${q(ctx.user_id)},
578
+ base_url: ${q(ctx.base_url)},
579
+ client_id: ${q(ctx.client_id)},
580
+ client_secret: ${q(ctx.client_secret)},
581
+ };
582
+ `;
583
+ }
584
+ function machineContextPath() {
585
+ return join2(
586
+ process.env.HOME ?? "",
587
+ ".config",
588
+ "conductor",
589
+ "conductor.config.machine.js"
590
+ );
591
+ }
592
+ async function readMachineContext(path) {
593
+ if (!existsSync(path)) return {};
594
+ try {
595
+ const mod = await import(`${pathToFileURL2(path).href}?t=${Date.now()}`);
596
+ const raw = mod.default;
597
+ return raw && typeof raw === "object" ? raw : {};
598
+ } catch {
599
+ return {};
600
+ }
601
+ }
602
+ async function scaffoldMachineContext(opts) {
603
+ const existing = await readMachineContext(opts.path);
604
+ const derived = {
605
+ machine_id: opts.machineId ?? hostname2(),
606
+ user_id: opts.userId,
607
+ base_url: opts.baseUrl,
608
+ client_id: opts.clientId,
609
+ client_secret: opts.secret
610
+ };
611
+ const filledKeys = [];
612
+ const merged = { ...derived, ...existing };
613
+ for (const key of [
614
+ "machine_id",
615
+ "user_id",
616
+ "base_url",
617
+ "client_id",
618
+ "client_secret"
619
+ ]) {
620
+ const cur = existing[key];
621
+ if (typeof cur !== "string" || cur.trim() === "") {
622
+ merged[key] = derived[key];
623
+ filledKeys.push(key);
624
+ }
625
+ }
626
+ const created = !existsSync(opts.path);
627
+ if (filledKeys.length > 0 || created) {
628
+ mkdirSync(dirname2(opts.path), { recursive: true });
629
+ writeFileSync(opts.path, renderMachineContext(merged), "utf8");
630
+ }
631
+ if (existsSync(opts.path)) chmodSync(opts.path, 384);
632
+ return { path: opts.path, created, filledKeys };
633
+ }
634
+ async function scaffold(inputs, opts) {
635
+ const committedPath = opts.configPath;
636
+ mkdirSync(dirname2(committedPath), { recursive: true });
637
+ let wroteCommitted = false;
638
+ if (!existsSync(committedPath) || opts.force) {
639
+ writeFileSync(committedPath, renderCommittedConfig(inputs), "utf8");
640
+ wroteCommitted = true;
641
+ }
642
+ const machine = await scaffoldMachineContext({
643
+ path: opts.machinePath ?? machineContextPath(),
644
+ userId: inputs.userId ?? "",
645
+ baseUrl: inputs.baseUrl,
646
+ clientId: inputs.clientId,
647
+ secret: inputs.secret,
648
+ ...inputs.machineId !== void 0 ? { machineId: inputs.machineId } : {}
649
+ });
650
+ return { committedPath, wroteCommitted, machine };
651
+ }
652
+
653
+ // src/cli/local-registry.ts
654
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
655
+ import { homedir } from "node:os";
656
+ import { dirname as dirname3, join as join3 } from "node:path";
657
+ function registryPath() {
658
+ const home = process.env.GAIA_HOME ?? homedir();
659
+ return join3(home, ".gaia", "conductors.json");
660
+ }
661
+ async function read() {
662
+ let raw;
663
+ try {
664
+ raw = await readFile(registryPath(), "utf8");
665
+ } catch {
666
+ return {};
667
+ }
668
+ if (raw.trim() === "") {
669
+ return {};
670
+ }
671
+ try {
672
+ const parsed = JSON.parse(raw);
673
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
674
+ return parsed;
675
+ }
676
+ return {};
677
+ } catch {
678
+ return {};
679
+ }
680
+ }
681
+ async function write(data) {
682
+ const path = registryPath();
683
+ await mkdir(dirname3(path), { recursive: true });
684
+ await writeFile(path, `${JSON.stringify(data, null, 2)}
685
+ `, "utf8");
686
+ }
687
+ async function register(entry) {
688
+ const data = await read();
689
+ data[entry.id] = entry;
690
+ await write(data);
691
+ }
692
+ async function remove(id) {
693
+ const data = await read();
694
+ if (data[id]) {
695
+ delete data[id];
696
+ await write(data);
697
+ }
698
+ }
699
+ async function list() {
700
+ return Object.values(await read());
701
+ }
702
+ async function get(id) {
703
+ const data = await read();
704
+ return data[id] ?? null;
705
+ }
706
+
707
+ // src/cli/gaia.ts
708
+ function defaultConfigPath(override) {
709
+ return override ?? process.env.GAIA_CONDUCTOR_CONFIG ?? "./.gaia/conductor.config.js";
710
+ }
711
+ async function resolveConfig(deps, configPathOverride) {
712
+ if (deps.config) {
713
+ return deps.config;
714
+ }
715
+ return loadConductorConfig(defaultConfigPath(configPathOverride));
716
+ }
717
+ async function resolveRemote(deps, config) {
718
+ return deps.remote ?? await selectRemote(config);
719
+ }
720
+ function checkoutRootOf(config) {
721
+ return dirname4(config.config_path);
722
+ }
723
+ function conductorIdOf(config) {
724
+ return config.machine_id ?? conductorId(checkoutRootOf(config));
725
+ }
726
+ function logOptsOf(cmd) {
727
+ const opts = cmd.optsWithGlobals();
728
+ return {
729
+ ...opts.logLevel !== void 0 ? { level: opts.logLevel } : {},
730
+ ...opts.logSink !== void 0 ? { sink: opts.logSink } : {}
731
+ };
732
+ }
733
+ function loggerFor(checkoutRoot, log = {}) {
734
+ const logger = createLogger({ checkoutRoot, ...log });
735
+ setDefaultCommandRunner(new CommandRunner(logger));
736
+ return logger;
737
+ }
738
+ function parseHerdrJson(output, command) {
739
+ try {
740
+ return JSON.parse(output);
741
+ } catch {
742
+ throw new Error(`herdr ${command} returned invalid JSON`);
743
+ }
744
+ }
745
+ async function spawnViaHerdr(label, cwd, cmd) {
746
+ const created = await exec("herdr", [
747
+ "workspace",
748
+ "create",
749
+ "--cwd",
750
+ cwd,
751
+ "--label",
752
+ label,
753
+ "--no-focus"
754
+ ]);
755
+ const parsed = parseHerdrJson(created, "workspace create");
756
+ const paneId = parsed.result?.root_pane?.pane_id;
757
+ if (!paneId) {
758
+ throw new Error("herdr workspace create returned no pane id");
759
+ }
760
+ await exec("herdr", ["pane", "run", paneId, cmd]);
761
+ }
762
+ async function killViaHerdr(label) {
763
+ const listed = await exec("herdr", ["workspace", "list"]);
764
+ const parsed = parseHerdrJson(listed, "workspace list");
765
+ const ws = (parsed.result?.workspaces ?? []).find((w) => w.label === label);
766
+ if (ws?.workspace_id) {
767
+ await exec("herdr", ["workspace", "close", ws.workspace_id]);
768
+ }
769
+ }
770
+ var DEFAULT_FRESH_S = 120;
771
+ function freshnessThresholdS(config) {
772
+ return config ? Math.max(DEFAULT_FRESH_S, config.lease_seconds * 2) : DEFAULT_FRESH_S;
773
+ }
774
+ function classify(hub, freshS) {
775
+ if (!hub) {
776
+ return "registry-only";
777
+ }
778
+ if (hub.status === "offline") {
779
+ return "stopped";
780
+ }
781
+ const nowS = Math.floor(Date.now() / 1e3);
782
+ const fresh = hub.lastSeen > 0 && nowS - hub.lastSeen <= freshS;
783
+ return fresh ? "running" : "wedged";
784
+ }
785
+ async function buildLsRows(remote, config, onlyId) {
786
+ const entries = await list();
787
+ let hub = [];
788
+ try {
789
+ hub = await remote.listConductors("me");
790
+ } catch {
791
+ hub = [];
792
+ }
793
+ const hubById = new Map(hub.map((c) => [c.id, c]));
794
+ const freshS = freshnessThresholdS(config);
795
+ const ids = /* @__PURE__ */ new Set();
796
+ for (const e of entries) {
797
+ ids.add(e.id);
798
+ }
799
+ for (const c of hub) {
800
+ ids.add(c.id);
801
+ }
802
+ const rows = [];
803
+ for (const id of ids) {
804
+ if (onlyId && id !== onlyId) {
805
+ continue;
806
+ }
807
+ const entry = entries.find((e) => e.id === id);
808
+ const h = hubById.get(id);
809
+ rows.push({
810
+ id,
811
+ project: entry?.project ?? h?.project ?? "",
812
+ label: entry?.label ?? h?.label ?? "",
813
+ host: entry?.host ?? "-",
814
+ status: classify(h, freshS)
815
+ });
816
+ }
817
+ return rows;
818
+ }
819
+ function printRows(rows) {
820
+ if (rows.length === 0) {
821
+ console.log("no conductors registered");
822
+ return;
823
+ }
824
+ for (const r of rows) {
825
+ console.log(`${r.id} ${r.status} ${r.host} ${r.project} ${r.label}`);
826
+ }
827
+ }
828
+ async function ensureAuthenticated(config, logger) {
829
+ const st = await authStatus({
830
+ baseUrl: config.site.base_url,
831
+ plugins: config.plugins ?? []
832
+ });
833
+ if (!st.loggedIn) {
834
+ logger.error(
835
+ { baseUrl: config.site.base_url },
836
+ `not logged in against ${config.site.base_url} \u2014 run 'gaia dropsh auth login'`
837
+ );
838
+ return false;
839
+ }
840
+ return true;
841
+ }
842
+ async function cmdPoll(deps, log = {}) {
843
+ const config = await resolveConfig(deps);
844
+ const checkoutRoot = checkoutRootOf(config);
845
+ const logger = loggerFor(checkoutRoot, log);
846
+ if (!await ensureAuthenticated(config, logger)) return;
847
+ const remote = await resolveRemote(deps, config);
848
+ const executor = deps.executor ?? await selectExecutor(config);
849
+ const workspace = deps.workspace ?? await selectWorkspace(config);
850
+ const agent = deps.agent ?? await selectAgent(config);
851
+ const conductor = new Conductor(
852
+ config,
853
+ remote,
854
+ executor,
855
+ workspace,
856
+ agent,
857
+ logger,
858
+ checkoutRoot
859
+ );
860
+ await conductor.start();
861
+ await conductor.tick();
862
+ }
863
+ async function cmdStartForeground(deps, log = {}) {
864
+ const config = await resolveConfig(deps);
865
+ const checkoutRoot = checkoutRootOf(config);
866
+ const logger = loggerFor(checkoutRoot, log);
867
+ if (!await ensureAuthenticated(config, logger)) return;
868
+ const remote = await resolveRemote(deps, config);
869
+ const executor = deps.executor ?? await selectExecutor(config);
870
+ const workspace = deps.workspace ?? await selectWorkspace(config);
871
+ const agent = deps.agent ?? await selectAgent(config);
872
+ const conductor = new Conductor(
873
+ config,
874
+ remote,
875
+ executor,
876
+ workspace,
877
+ agent,
878
+ logger,
879
+ checkoutRoot
880
+ );
881
+ await conductor.start();
882
+ const controller = new AbortController();
883
+ const onSignal = () => controller.abort();
884
+ process.once("SIGINT", onSignal);
885
+ process.once("SIGTERM", onSignal);
886
+ try {
887
+ await conductor.serve(controller.signal);
888
+ } finally {
889
+ process.removeListener("SIGINT", onSignal);
890
+ process.removeListener("SIGTERM", onSignal);
891
+ }
892
+ }
893
+ async function cmdStart(deps, log = {}) {
894
+ const config = await resolveConfig(deps);
895
+ const checkoutRoot = checkoutRootOf(config);
896
+ const logger = loggerFor(checkoutRoot, log);
897
+ if (!await ensureAuthenticated(config, logger)) return;
898
+ const remote = await resolveRemote(deps, config);
899
+ const id = conductorIdOf(config);
900
+ const existing = await get(id);
901
+ if (existing) {
902
+ const hubStatus = await remote.getConductorStatus(id);
903
+ if (hubStatus !== null && hubStatus !== "offline") {
904
+ console.log(`conductor already running for ${config.project}`);
905
+ return;
906
+ }
907
+ }
908
+ const handle = `gaia-conductor:${id}`;
909
+ await register({
910
+ id,
911
+ path: checkoutRoot,
912
+ project: config.project,
913
+ label: config.label,
914
+ host: "herdr",
915
+ handle
916
+ });
917
+ const fgFlags = [
918
+ log.level ? `--log-level ${log.level}` : "",
919
+ log.sink ? `--log-sink ${log.sink}` : ""
920
+ ].filter(Boolean).join(" ");
921
+ const fgCmd = `GAIA_CONDUCTOR_LOG=file gaia conductor ${fgFlags} start --foreground`.replace(
922
+ /\s+/g,
923
+ " "
924
+ );
925
+ try {
926
+ await spawnViaHerdr(handle, checkoutRoot, fgCmd);
927
+ logger.info({ id, handle }, "started conductor via herdr");
928
+ } catch (err) {
929
+ logger.error(
930
+ { id, err: err.message },
931
+ "could not start via herdr"
932
+ );
933
+ logger.warn(
934
+ {},
935
+ "Hint: run `gaia conductor start --foreground` under a service manager (systemd-user / docker) on hosts without herdr."
936
+ );
937
+ }
938
+ }
939
+ async function cmdStop(deps, now) {
940
+ const config = await resolveConfig(deps);
941
+ const remote = await resolveRemote(deps, config);
942
+ const id = conductorIdOf(config);
943
+ if (now) {
944
+ const entry2 = await get(id);
945
+ if (entry2 && entry2.host === "herdr") {
946
+ await killViaHerdr(entry2.handle);
947
+ console.log(`hard-stopped conductor ${id} (${entry2.handle})`);
948
+ } else {
949
+ console.log(`no herdr-hosted conductor to hard-stop for ${id}`);
950
+ }
951
+ return;
952
+ }
953
+ const entry = await get(id);
954
+ if (entry && entry.host === "herdr") {
955
+ await killViaHerdr(entry.handle);
956
+ }
957
+ await remote.setConductorStatus(id, "offline");
958
+ console.log(`stopped conductor ${id} (offline)`);
959
+ }
960
+ async function cmdLs(deps) {
961
+ const config = deps.config ?? await tryConfig(deps);
962
+ const remote = await resolveRemote(
963
+ deps,
964
+ config ?? await resolveConfig(deps)
965
+ );
966
+ const rows = await buildLsRows(remote, config);
967
+ printRows(rows);
968
+ }
969
+ async function cmdStatus(deps) {
970
+ const config = await resolveConfig(deps);
971
+ const remote = await resolveRemote(deps, config);
972
+ const id = conductorIdOf(config);
973
+ const rows = await buildLsRows(remote, config, id);
974
+ printRows(rows);
975
+ }
976
+ async function cmdRm(deps) {
977
+ const config = await resolveConfig(deps);
978
+ const id = conductorIdOf(config);
979
+ await remove(id);
980
+ console.log(`removed conductor ${id} from registry`);
981
+ }
982
+ async function tryConfig(deps) {
983
+ if (deps.config) {
984
+ return deps.config;
985
+ }
986
+ try {
987
+ return await loadConductorConfig(defaultConfigPath());
988
+ } catch {
989
+ return void 0;
990
+ }
991
+ }
992
+ async function promptUserId() {
993
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
994
+ try {
995
+ const answer = await new Promise(
996
+ (resolve5) => rl.question("Your K\xFCrzel / user id: ", resolve5)
997
+ );
998
+ return answer.trim();
999
+ } finally {
1000
+ rl.close();
1001
+ }
1002
+ }
1003
+ async function promptSecret() {
1004
+ const rl = createInterface({
1005
+ input: process.stdin,
1006
+ output: process.stdout,
1007
+ terminal: true
1008
+ });
1009
+ rl._writeToOutput = (s) => {
1010
+ if (!rl.muted || s.includes("\n")) process.stdout.write(s);
1011
+ };
1012
+ try {
1013
+ const answer = await new Promise((resolve5) => {
1014
+ rl.question("OAuth client secret: ", (a) => {
1015
+ process.stdout.write("\n");
1016
+ resolve5(a);
1017
+ });
1018
+ rl.muted = true;
1019
+ });
1020
+ return answer.trim();
1021
+ } finally {
1022
+ rl.close();
1023
+ }
1024
+ }
1025
+ function buildProgram(deps) {
1026
+ const program = new Command();
1027
+ program.name("gaia").description("GAIA conductor + client CLI");
1028
+ const conductor = program.command("conductor").description("node-agent lifecycle + local registry").option(
1029
+ "--log-level <level>",
1030
+ "log verbosity: debug | info | warn | error (overrides GAIA_LOG_LEVEL)"
1031
+ ).option(
1032
+ "--log-sink <sink>",
1033
+ "log sink: stdout | file (overrides GAIA_CONDUCTOR_LOG)"
1034
+ ).addHelpText(
1035
+ "after",
1036
+ `
1037
+ Logging (precedence: CLI flag > env var > default):
1038
+ --log-level <level> debug | info (default) | warn | error.
1039
+ Set debug to see per-poll ticks, claims, and idle cycles.
1040
+ --log-sink <sink> stdout | file (writes <checkoutRoot>/log.txt).
1041
+ Default: stdout on a TTY, file otherwise.
1042
+ GAIA_LOG_LEVEL env fallback for --log-level.
1043
+ GAIA_CONDUCTOR_LOG env fallback for --log-sink.
1044
+
1045
+ Examples:
1046
+ gaia conductor --log-level debug start --foreground
1047
+ gaia conductor --log-level debug poll
1048
+ GAIA_LOG_LEVEL=debug gaia conductor poll`
1049
+ );
1050
+ conductor.command("start").description("start the conductor loop (herdr-hosted by default)").option("--foreground", "run the loop in this process", false).action(async function(opts) {
1051
+ if (opts.foreground) {
1052
+ await cmdStartForeground(deps, logOptsOf(this));
1053
+ } else {
1054
+ await cmdStart(deps, logOptsOf(this));
1055
+ }
1056
+ });
1057
+ conductor.command("poll").description("run one conductor cycle then exit").action(async function() {
1058
+ await cmdPoll(deps, logOptsOf(this));
1059
+ });
1060
+ conductor.command("stop").description(
1061
+ "stop the conductor: graceful offline (default) or hard kill (--now)"
1062
+ ).option(
1063
+ "--now",
1064
+ "hard-kill via host and let the cron reaper mark it offline",
1065
+ false
1066
+ ).action(async (opts) => {
1067
+ await cmdStop(deps, opts.now);
1068
+ });
1069
+ conductor.command("ls").description("list conductors on this machine + status").action(async () => {
1070
+ await cmdLs(deps);
1071
+ });
1072
+ conductor.command("status").description("status of the conductor for this checkout").action(async () => {
1073
+ await cmdStatus(deps);
1074
+ });
1075
+ conductor.command("rm").description("deregister the conductor for this checkout").action(async () => {
1076
+ await cmdRm(deps);
1077
+ });
1078
+ conductor.command("init").description(
1079
+ "scaffold the committed .gaia/conductor.config.js for this repo plus the user-global conductor.config.machine.js context (identity + connection incl. secret)"
1080
+ ).requiredOption(
1081
+ "--base-url <url>",
1082
+ "control-plane base URL (site.base_url)"
1083
+ ).requiredOption("--project <name>", "GAIA project name").option(
1084
+ "--secret-env <VAR>",
1085
+ "env var name to read the oauth client secret from (else TTY prompt)"
1086
+ ).option("--client-id <id>", "oauth consumer id", "gaia-agent").option(
1087
+ "--machine-id <id>",
1088
+ "machine host token for the context (defaults to hostname())"
1089
+ ).option(
1090
+ "--user-id <kuerzel>",
1091
+ "developer K\xFCrzel for the user-global context"
1092
+ ).option(
1093
+ "--machine-path <path>",
1094
+ "user-global machine context path (defaults to ~/.config/conductor/conductor.config.machine.js)"
1095
+ ).option(
1096
+ "--config <path>",
1097
+ "target committed config path",
1098
+ "./.gaia/conductor.config.js"
1099
+ ).option("--force", "overwrite an existing committed config", false).action(
1100
+ async (opts) => {
1101
+ let userId = opts.userId;
1102
+ if (userId === void 0 || userId.trim() === "") {
1103
+ if (process.stdin.isTTY) {
1104
+ userId = await promptUserId();
1105
+ }
1106
+ if (userId === void 0 || userId.trim() === "") {
1107
+ throw new Error(
1108
+ "--user-id is required (or run in a TTY to be prompted)"
1109
+ );
1110
+ }
1111
+ }
1112
+ const machinePath = opts.machinePath ?? machineContextPath();
1113
+ const existing = await readMachineContext(machinePath);
1114
+ let secret = "";
1115
+ if (typeof existing.client_secret !== "string" || existing.client_secret.trim() === "") {
1116
+ secret = opts.secretEnv !== void 0 ? process.env[opts.secretEnv] ?? "" : "";
1117
+ if (secret.trim() === "") {
1118
+ if (process.stdin.isTTY) {
1119
+ secret = await promptSecret();
1120
+ }
1121
+ if (secret.trim() === "") {
1122
+ throw new Error(
1123
+ "OAuth client secret required: pass --secret-env <VAR> (exported) or run in a TTY to be prompted"
1124
+ );
1125
+ }
1126
+ }
1127
+ }
1128
+ const inputs = {
1129
+ baseUrl: opts.baseUrl,
1130
+ project: opts.project,
1131
+ clientId: opts.clientId,
1132
+ secret,
1133
+ userId,
1134
+ ...opts.machineId !== void 0 ? { machineId: opts.machineId } : {}
1135
+ };
1136
+ const res = await scaffold(inputs, {
1137
+ configPath: opts.config,
1138
+ force: opts.force,
1139
+ machinePath
1140
+ });
1141
+ console.log(
1142
+ res.wroteCommitted ? `wrote ${res.committedPath}` : `kept ${res.committedPath} (exists \u2014 pass --force to replace)`
1143
+ );
1144
+ const m = res.machine;
1145
+ console.log(
1146
+ m.created ? `wrote ${m.path} (user-global machine context)` : m.filledKeys.length > 0 ? `updated ${m.path} (filled: ${m.filledKeys.join(", ")})` : `kept ${m.path} (already complete)`
1147
+ );
1148
+ console.log(
1149
+ `
1150
+ Next steps:
1151
+ gaia dropsh auth login --provider session
1152
+ gaia dropsh auth login --provider pm
1153
+ gaia dropsh auth status # both profiles present`
1154
+ );
1155
+ }
1156
+ );
1157
+ return program;
1158
+ }
1159
+ async function attachDropsh(program, deps) {
1160
+ const config = deps.config ?? await tryConfig(deps);
1161
+ if (!config) {
1162
+ return;
1163
+ }
1164
+ if (!process.env.DROPSH_CONFIG) {
1165
+ process.env.DROPSH_CONFIG = config.config_path;
1166
+ }
1167
+ program.addCommand(
1168
+ buildDropshProgram({ plugins: config.plugins ?? [] })
1169
+ );
1170
+ }
1171
+ async function runGaiaCli(argv, deps = {}) {
1172
+ const program = buildProgram(deps);
1173
+ await attachDropsh(program, deps);
1174
+ await program.parseAsync(argv, { from: "user" });
1175
+ }
1176
+ async function main(argv) {
1177
+ await runGaiaCli(argv.slice(2));
1178
+ }
1179
+
1180
+ // src/plugins/remote/drupal.ts
1181
+ import { resolveAuth } from "dropsh";
1182
+ import { createHttpClient, createJsonApiClient } from "dropsh/plugin";
1183
+ var ACTIVE = ["claimed", "running"];
1184
+ function parseTicketComments(included) {
1185
+ if (!Array.isArray(included)) {
1186
+ return [];
1187
+ }
1188
+ const comments = [];
1189
+ for (const res of included) {
1190
+ if (res?.type !== "gaia_comment--gaia_comment") {
1191
+ continue;
1192
+ }
1193
+ const attrs = res.attributes ?? {};
1194
+ const body = attrs.body;
1195
+ const value = typeof body === "string" ? body : typeof body?.value === "string" ? body.value : "";
1196
+ comments.push({
1197
+ type: typeof attrs.gaia_comment_type === "string" ? attrs.gaia_comment_type : "comment",
1198
+ body: value,
1199
+ created: typeof attrs.created === "string" ? attrs.created : ""
1200
+ });
1201
+ }
1202
+ return comments;
1203
+ }
1204
+ var DrupalGaiaRemote = class {
1205
+ constructor(api) {
1206
+ this.api = api;
1207
+ }
1208
+ api;
1209
+ async fetchActiveRuns(id) {
1210
+ const rows = await this.api.collection("gaia_run").where("conductor_id.machine_id", "=", id).whereIn("state", ACTIVE).fields(["state", "state_at_start", "worktree_path"]).page(100).list();
1211
+ return Promise.all(
1212
+ rows.map(async (r) => ({
1213
+ runUuid: r.id,
1214
+ ticketUuid: r.rel("ticket_id") ?? "",
1215
+ ticketIdentifier: await this.getRunTicketIdentifier(r.id),
1216
+ branchName: await this.getRunTicketBranchName(r.id),
1217
+ state: r.attr("state") ?? "",
1218
+ stateAtStart: r.attr("state_at_start") ?? "",
1219
+ worktreePath: r.attr("worktree_path") ?? ""
1220
+ }))
1221
+ );
1222
+ }
1223
+ async activeRunCount(id) {
1224
+ return this.api.collection("gaia_run").where("conductor_id.machine_id", "=", id).whereIn("state", ACTIVE).fields(["drupal_internal__id"]).page(100).count();
1225
+ }
1226
+ async claimNext(c) {
1227
+ const res = await this.api.post("gaia/claim-next", {
1228
+ data: {
1229
+ attributes: {
1230
+ lease_seconds: c.leaseSeconds,
1231
+ // Server (`ConductorResolverTrait`) narrows to the caller's conductor
1232
+ // by the `machine_id` attribute; `c.conductorId` IS the machine_id.
1233
+ machine_id: c.conductorId
1234
+ }
1235
+ }
1236
+ });
1237
+ if (!res?.data) {
1238
+ return null;
1239
+ }
1240
+ const d = res.data;
1241
+ const rawId = d.attributes?.drupal_internal__id;
1242
+ if (rawId === void 0 || rawId === null) {
1243
+ throw new Error(`claimNext: missing drupal_internal__id on run ${d.id}`);
1244
+ }
1245
+ return {
1246
+ runUuid: d.id,
1247
+ runId: Number(rawId),
1248
+ ticketUuid: d.relationships?.ticket_id?.data?.id ?? "",
1249
+ stateAtStart: String(d.attributes?.state_at_start ?? ""),
1250
+ // handler_id was dropped; the handler is the work the run does, i.e. the
1251
+ // ticket state the run started in.
1252
+ handler: String(d.attributes?.state_at_start ?? "")
1253
+ };
1254
+ }
1255
+ async getTicket(uuid) {
1256
+ const doc = await this.api.get(
1257
+ `gaia_ticket/gaia_ticket/${uuid}?include=comments`
1258
+ );
1259
+ const attrs = doc.data?.attributes ?? {};
1260
+ const issueUrl = typeof attrs.origin === "string" ? attrs.origin : void 0;
1261
+ return {
1262
+ uuid: doc.data?.id ?? uuid,
1263
+ identifier: typeof attrs.identifier === "string" ? attrs.identifier : uuid,
1264
+ title: typeof attrs.title === "string" ? attrs.title : "",
1265
+ state: typeof attrs.state === "string" ? attrs.state : "",
1266
+ branchName: typeof attrs.branch_name === "string" ? attrs.branch_name : "",
1267
+ ...typeof attrs.base_branch === "string" && attrs.base_branch !== "" ? { baseBranch: attrs.base_branch } : {},
1268
+ comments: parseTicketComments(doc.included),
1269
+ ...issueUrl ? { issueUrl } : {}
1270
+ };
1271
+ }
1272
+ async getRunWorktree(uuid) {
1273
+ const r = await this.api.resource("gaia_run", uuid);
1274
+ return r.attr("worktree_path") ?? "";
1275
+ }
1276
+ async getRunTicketIdentifier(uuid) {
1277
+ const run = await this.api.resource("gaia_run", uuid);
1278
+ const ticketUuid = run.rel("ticket_id");
1279
+ if (!ticketUuid) {
1280
+ return "";
1281
+ }
1282
+ const ticket = await this.api.resource("gaia_ticket", ticketUuid);
1283
+ return ticket.attr("identifier") ?? "";
1284
+ }
1285
+ async getRunTicketBranchName(uuid) {
1286
+ const run = await this.api.resource("gaia_run", uuid);
1287
+ const ticketUuid = run.rel("ticket_id");
1288
+ if (!ticketUuid) {
1289
+ return "";
1290
+ }
1291
+ const ticket = await this.api.resource("gaia_ticket", ticketUuid);
1292
+ return ticket.attr("branch_name") ?? "";
1293
+ }
1294
+ async registerConductor(reg) {
1295
+ const r = await this.api.upsert(
1296
+ "gaia_conductor",
1297
+ { path: "machine_id", value: reg.id },
1298
+ {
1299
+ attributes: {
1300
+ machine_id: reg.id,
1301
+ status: "online",
1302
+ workspace_root: reg.workspace,
1303
+ label: reg.label,
1304
+ states: reg.states,
1305
+ max_parallel: reg.max_parallel
1306
+ },
1307
+ relationships: {
1308
+ owner_user_id: {
1309
+ data: { type: "user--user", id: await this.api.me() }
1310
+ },
1311
+ project_id: {
1312
+ data: {
1313
+ type: "gaia_project--gaia_project",
1314
+ id: await this.projectUuid(reg.project)
1315
+ }
1316
+ }
1317
+ }
1318
+ }
1319
+ );
1320
+ return r.id;
1321
+ }
1322
+ async heartbeat(reg, load, lease = 300) {
1323
+ const res = await this.api.post("gaia/heartbeat", {
1324
+ data: {
1325
+ attributes: {
1326
+ machine_id: reg.id,
1327
+ project: reg.project,
1328
+ states: reg.states,
1329
+ workspace_root: reg.workspace,
1330
+ label: reg.label,
1331
+ max_parallel: reg.max_parallel,
1332
+ current_load: load,
1333
+ lease_seconds: lease
1334
+ }
1335
+ }
1336
+ });
1337
+ const status = res?.data?.attributes?.status;
1338
+ return typeof status === "string" ? status : "online";
1339
+ }
1340
+ async getConductorStatus(id) {
1341
+ const c = await this.api.collection("gaia_conductor").where("machine_id", "=", id).fields(["status"]).first();
1342
+ return c ? c.attr("status") ?? null : null;
1343
+ }
1344
+ async setConductorStatus(id, status) {
1345
+ const c = await this.api.collection("gaia_conductor").where("machine_id", "=", id).first();
1346
+ if (c) {
1347
+ await this.api.update("gaia_conductor", c.id, { attributes: { status } });
1348
+ }
1349
+ }
1350
+ async listConductors(owner) {
1351
+ let col = this.api.collection("gaia_conductor");
1352
+ if (owner === "me") {
1353
+ col = col.where("owner_user_id.id", "=", await this.api.me());
1354
+ }
1355
+ const rows = await col.page(200).list();
1356
+ return rows.map((r) => ({
1357
+ id: r.attr("machine_id") ?? r.id,
1358
+ project: "",
1359
+ label: r.attr("label") ?? "",
1360
+ status: r.attr("status") ?? "",
1361
+ lastSeen: r.attr("last_seen") ?? 0,
1362
+ load: r.attr("current_load") ?? 0
1363
+ }));
1364
+ }
1365
+ async markRunning(uuid, attrs) {
1366
+ const t = Math.floor(Date.now() / 1e3);
1367
+ await this.api.update("gaia_run", uuid, {
1368
+ attributes: {
1369
+ state: "running",
1370
+ heartbeat: t,
1371
+ ...attrs?.worktree_path ? { worktree_path: attrs.worktree_path } : {}
1372
+ }
1373
+ });
1374
+ }
1375
+ async fetchFinalizableRuns(id) {
1376
+ const rows = await this.api.collection("gaia_run").where("conductor_id.machine_id", "=", id).where("state", "=", "done").where("closed", "=", "0").fields(["worktree_path"]).page(100).list();
1377
+ return rows.map((r) => ({
1378
+ runUuid: r.id,
1379
+ worktreePath: r.attr("worktree_path") ?? ""
1380
+ }));
1381
+ }
1382
+ async finalizeRun(uuid, log) {
1383
+ const t = Math.floor(Date.now() / 1e3);
1384
+ await this.api.update("gaia_run", uuid, {
1385
+ attributes: {
1386
+ closed: true,
1387
+ closed_date: t,
1388
+ ...log ? { log } : {}
1389
+ }
1390
+ });
1391
+ }
1392
+ async fetchFinalizableTickets(id) {
1393
+ const rows = await this.api.collection("gaia_ticket").where("conductor_id.machine_id", "=", id).where("state", "=", "done").where("closed", "=", "0").fields(["branch_name"]).page(100).list();
1394
+ return Promise.all(
1395
+ rows.map(async (r) => ({
1396
+ ticketUuid: r.id,
1397
+ branchName: r.attr("branch_name") ?? "",
1398
+ worktreePath: await this.latestRunWorktree(r.id)
1399
+ }))
1400
+ );
1401
+ }
1402
+ async closeTicket(uuid) {
1403
+ const t = Math.floor(Date.now() / 1e3);
1404
+ await this.api.update("gaia_ticket", uuid, {
1405
+ attributes: { closed: true, closed_date: t }
1406
+ });
1407
+ }
1408
+ /**
1409
+ * Absolute worktree path of the ticket's latest run (highest run id with a
1410
+ * non-empty worktree_path), or '' when none — the cwd the cleanup command
1411
+ * runs in. The conductor persists worktree_path on markRunning, so a done
1412
+ * ticket's run carries the path even after the run closed.
1413
+ */
1414
+ async latestRunWorktree(ticketUuid) {
1415
+ const rows = await this.api.collection("gaia_run").where("ticket_id.id", "=", ticketUuid).fields(["worktree_path", "drupal_internal__id"]).sort("-drupal_internal__id").page(100).list();
1416
+ for (const r of rows) {
1417
+ const path = r.attr("worktree_path");
1418
+ if (path) {
1419
+ return path;
1420
+ }
1421
+ }
1422
+ return "";
1423
+ }
1424
+ async projectUuid(name) {
1425
+ const p = await this.api.collection("gaia_project").where("name", "=", name).first();
1426
+ if (!p) {
1427
+ throw new Error(`gaia project "${name}" not found`);
1428
+ }
1429
+ return p.id;
1430
+ }
1431
+ };
1432
+ function drupalRemote() {
1433
+ return {
1434
+ kind: "remote",
1435
+ id: "drupal",
1436
+ requiredModules: [],
1437
+ async createRemote(config) {
1438
+ const http = createHttpClient();
1439
+ const auth = await resolveAuth({
1440
+ baseUrl: config.site.base_url,
1441
+ plugins: config.plugins ?? [],
1442
+ http,
1443
+ now: Date.now
1444
+ // stateDir omitted → dropsh defaultStateDir() (~/.config/dropsh)
1445
+ });
1446
+ return new DrupalGaiaRemote(
1447
+ createJsonApiClient({
1448
+ baseUrl: config.site.base_url,
1449
+ prefix: config.site.jsonapi_prefix,
1450
+ http,
1451
+ auth
1452
+ })
1453
+ );
1454
+ }
1455
+ };
1456
+ }
1457
+
1458
+ // src/plugins/remote/fake.ts
1459
+ var ACTIVE2 = ["claimed", "running"];
1460
+ var FakeGaiaRemote = class {
1461
+ calls = {
1462
+ markRunning: [],
1463
+ finalizeRun: [],
1464
+ closeTicket: []
1465
+ };
1466
+ /**
1467
+ * Override for reconcile tests: when set, `fetchActiveRuns` returns this
1468
+ * list verbatim instead of deriving it from the internal runs map.
1469
+ */
1470
+ activeRuns = null;
1471
+ runs = /* @__PURE__ */ new Map();
1472
+ queue = [];
1473
+ tickets;
1474
+ conductorStatus = "online";
1475
+ /** Stable counter for assigning numeric ids to unseeded runs. */
1476
+ runIdCounter = 0;
1477
+ constructor(seed = {}) {
1478
+ this.tickets = seed.tickets ?? {};
1479
+ for (const r of seed.runs ?? []) {
1480
+ const handler = r.handler ?? "code";
1481
+ const runId = r.id ?? ++this.runIdCounter;
1482
+ this.runs.set(r.runUuid, {
1483
+ runUuid: r.runUuid,
1484
+ runId,
1485
+ ticketUuid: r.ticketUuid,
1486
+ handler,
1487
+ state: r.state ?? r.stateAtStart,
1488
+ stateAtStart: r.stateAtStart,
1489
+ ...r.worktreePath !== void 0 ? { worktreePath: r.worktreePath } : {},
1490
+ ...r.closed !== void 0 ? { closed: r.closed } : {}
1491
+ });
1492
+ this.queue.push({
1493
+ runUuid: r.runUuid,
1494
+ runId,
1495
+ ticketUuid: r.ticketUuid,
1496
+ stateAtStart: r.stateAtStart,
1497
+ handler
1498
+ });
1499
+ }
1500
+ }
1501
+ async registerConductor(_reg) {
1502
+ this.conductorStatus = "online";
1503
+ return "fake-conductor-uuid";
1504
+ }
1505
+ async heartbeat() {
1506
+ this.conductorStatus = "online";
1507
+ return this.conductorStatus;
1508
+ }
1509
+ async getConductorStatus(_conductorId) {
1510
+ return this.conductorStatus;
1511
+ }
1512
+ async setConductorStatus(_conductorId, status) {
1513
+ this.conductorStatus = status;
1514
+ }
1515
+ async listConductors(_owner) {
1516
+ return [];
1517
+ }
1518
+ async activeRunCount(_conductorId) {
1519
+ return this.internalActiveRuns().length;
1520
+ }
1521
+ async fetchActiveRuns(_conductorId) {
1522
+ if (this.activeRuns !== null) {
1523
+ return this.activeRuns;
1524
+ }
1525
+ return this.internalActiveRuns().map((r) => {
1526
+ const ticket = this.tickets[r.ticketUuid];
1527
+ const identifier = ticket?.identifier ?? "";
1528
+ const branchName = ticket?.branchName ?? (identifier ? `gaia/${identifier.toLowerCase()}` : "");
1529
+ return {
1530
+ runUuid: r.runUuid,
1531
+ ticketUuid: r.ticketUuid,
1532
+ ticketIdentifier: identifier,
1533
+ branchName,
1534
+ state: r.state,
1535
+ stateAtStart: r.stateAtStart,
1536
+ worktreePath: r.worktreePath ?? ""
1537
+ };
1538
+ });
1539
+ }
1540
+ async claimNext(_claim) {
1541
+ return this.queue.shift() ?? null;
1542
+ }
1543
+ async getTicket(ticketUuid) {
1544
+ const t = this.tickets[ticketUuid];
1545
+ if (!t) {
1546
+ throw new Error(`fake remote: ticket ${ticketUuid} not seeded`);
1547
+ }
1548
+ const branchName = t.branchName ?? `gaia/${t.identifier.toLowerCase()}`;
1549
+ return {
1550
+ uuid: ticketUuid,
1551
+ identifier: t.identifier,
1552
+ title: t.title,
1553
+ state: t.state,
1554
+ branchName,
1555
+ ...t.baseBranch ? { baseBranch: t.baseBranch } : {},
1556
+ comments: t.comments ?? [],
1557
+ ...t.url ? { issueUrl: t.url } : {}
1558
+ };
1559
+ }
1560
+ async getRunWorktree(runUuid) {
1561
+ return this.runs.get(runUuid)?.worktreePath ?? "";
1562
+ }
1563
+ async getRunTicketIdentifier(runUuid) {
1564
+ const ticketUuid = this.runs.get(runUuid)?.ticketUuid;
1565
+ if (!ticketUuid) {
1566
+ return "";
1567
+ }
1568
+ return this.tickets[ticketUuid]?.identifier ?? "";
1569
+ }
1570
+ async getRunTicketBranchName(runUuid) {
1571
+ const ticketUuid = this.runs.get(runUuid)?.ticketUuid;
1572
+ if (!ticketUuid) {
1573
+ return "";
1574
+ }
1575
+ const ticket = this.tickets[ticketUuid];
1576
+ if (!ticket) {
1577
+ return "";
1578
+ }
1579
+ return ticket.branchName ?? `gaia/${ticket.identifier.toLowerCase()}`;
1580
+ }
1581
+ async markRunning(runUuid, attrs) {
1582
+ this.calls.markRunning.push({
1583
+ runUuid,
1584
+ ...attrs ? {
1585
+ attrs: {
1586
+ ...attrs.worktree_path ? { worktree_path: attrs.worktree_path } : {}
1587
+ }
1588
+ } : {}
1589
+ });
1590
+ const run = this.runs.get(runUuid);
1591
+ if (run) {
1592
+ run.state = "running";
1593
+ if (attrs?.worktree_path) {
1594
+ run.worktreePath = attrs.worktree_path;
1595
+ }
1596
+ }
1597
+ }
1598
+ async fetchFinalizableRuns(_conductorId) {
1599
+ return [...this.runs.values()].filter((r) => r.state === "done" && !r.closed).map((r) => ({ runUuid: r.runUuid, worktreePath: r.worktreePath ?? "" }));
1600
+ }
1601
+ async finalizeRun(runUuid, log) {
1602
+ this.calls.finalizeRun.push({ runUuid, log });
1603
+ const run = this.runs.get(runUuid);
1604
+ if (run) {
1605
+ run.closed = true;
1606
+ if (log) run.log = log;
1607
+ }
1608
+ }
1609
+ async fetchFinalizableTickets(_conductorId) {
1610
+ return Object.entries(this.tickets).filter(([, t]) => t.state === "done" && !t.closed).map(([ticketUuid, t]) => ({
1611
+ ticketUuid,
1612
+ branchName: t.branchName ?? `gaia/${t.identifier.toLowerCase()}`,
1613
+ worktreePath: this.latestRunWorktree(ticketUuid)
1614
+ }));
1615
+ }
1616
+ async closeTicket(uuid) {
1617
+ this.calls.closeTicket.push(uuid);
1618
+ const t = this.tickets[uuid];
1619
+ if (t) {
1620
+ t.closed = true;
1621
+ }
1622
+ }
1623
+ /** Worktree path of the ticket's most recently seeded run, or '' when none. */
1624
+ latestRunWorktree(ticketUuid) {
1625
+ let worktree = "";
1626
+ for (const r of this.runs.values()) {
1627
+ if (r.ticketUuid === ticketUuid && r.worktreePath) {
1628
+ worktree = r.worktreePath;
1629
+ }
1630
+ }
1631
+ return worktree;
1632
+ }
1633
+ internalActiveRuns() {
1634
+ return [...this.runs.values()].filter((r) => ACTIVE2.includes(r.state));
1635
+ }
1636
+ };
1637
+ function fakeRemote(seed = {}) {
1638
+ const remote = new FakeGaiaRemote(seed);
1639
+ return {
1640
+ kind: "remote",
1641
+ id: "fake",
1642
+ requiredModules: [],
1643
+ async createRemote(_config) {
1644
+ return remote;
1645
+ }
1646
+ };
1647
+ }
1648
+
1649
+ // src/plugins/workspace/fake.ts
1650
+ var FakeWorkspace = class {
1651
+ async ensure(identifier, _title) {
1652
+ return { path: `/fake/${identifier}`, instructions: null };
1653
+ }
1654
+ async beforeRun(_path) {
1655
+ }
1656
+ async afterRun(_path) {
1657
+ }
1658
+ async afterDone(_path) {
1659
+ }
1660
+ };
1661
+ function fakeWorkspace() {
1662
+ const workspace = new FakeWorkspace();
1663
+ return {
1664
+ kind: "workspace",
1665
+ id: "fake",
1666
+ requiredModules: [],
1667
+ async createWorkspace(_config) {
1668
+ return workspace;
1669
+ }
1670
+ };
1671
+ }
1672
+
1673
+ // src/plugins/workspace/git.ts
1674
+ import { existsSync as existsSync3 } from "node:fs";
1675
+ import { dirname as dirname5, join as join4, resolve as resolve3 } from "node:path";
1676
+
1677
+ // src/core/slug.ts
1678
+ var DEFAULT_SLUG_MAX_LENGTH = 40;
1679
+ function slugify(text, maxLength = DEFAULT_SLUG_MAX_LENGTH) {
1680
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1681
+ if (slug.length <= maxLength) {
1682
+ return slug;
1683
+ }
1684
+ return slug.slice(0, maxLength).replace(/-+$/g, "");
1685
+ }
1686
+
1687
+ // src/plugins/workspace/instructions.ts
1688
+ import { createHash as createHash2 } from "node:crypto";
1689
+ import { existsSync as existsSync2, readFileSync } from "node:fs";
1690
+ import { resolve as resolve2 } from "node:path";
1691
+ function loadInstructions(workspacePath) {
1692
+ const path = resolve2(workspacePath, "WORKFLOW.md");
1693
+ if (!existsSync2(path)) {
1694
+ return null;
1695
+ }
1696
+ const text = readFileSync(path, "utf8");
1697
+ return {
1698
+ path,
1699
+ sha256: createHash2("sha256").update(text).digest("hex"),
1700
+ text
1701
+ };
1702
+ }
1703
+
1704
+ // src/plugins/workspace/git.ts
1705
+ var DEFAULT_BRANCH_TEMPLATE = "{branchPrefix}{key}-{titleSlug}";
1706
+ function renderBranchTemplate(template, vars) {
1707
+ const rendered = template.replace(/\{(\w+)\}/g, (whole, key) => {
1708
+ const value = vars[key];
1709
+ return value === void 0 ? whole : value;
1710
+ });
1711
+ return rendered.replace(/[-_./]+$/g, "");
1712
+ }
1713
+ var defaultHookRunner = async (command, cwd) => {
1714
+ await exec("sh", ["-c", command], { cwd });
1715
+ };
1716
+ var defaultGitRunner = async (args, cwd) => {
1717
+ await exec("git", args, { cwd });
1718
+ };
1719
+ var GitWorkspace = class {
1720
+ constructor(options) {
1721
+ this.options = options;
1722
+ this.runHook = options.runHook ?? defaultHookRunner;
1723
+ this.runGit = options.runGit ?? defaultGitRunner;
1724
+ this.root = resolve3(options.root);
1725
+ this.worktreesRoot = options.worktreesRoot ? resolve3(options.worktreesRoot) : `${this.root}-worktrees`;
1726
+ this.branchPrefix = options.branchPrefix ?? "gaia/";
1727
+ this.branchTemplate = options.branchTemplate ?? DEFAULT_BRANCH_TEMPLATE;
1728
+ }
1729
+ options;
1730
+ runHook;
1731
+ runGit;
1732
+ root;
1733
+ worktreesRoot;
1734
+ branchPrefix;
1735
+ branchTemplate;
1736
+ /** Render the branch name for a ticket from the configured template. */
1737
+ branchFor(identifier, title) {
1738
+ return renderBranchTemplate(this.branchTemplate, {
1739
+ branchPrefix: this.branchPrefix,
1740
+ key: this.keyFor(identifier),
1741
+ identifier,
1742
+ titleSlug: slugify(title ?? "")
1743
+ });
1744
+ }
1745
+ keyFor(identifier) {
1746
+ return identifier.replace(/[^A-Za-z0-9._-]/g, "_");
1747
+ }
1748
+ pathFor(key) {
1749
+ return resolve3(join4(this.worktreesRoot, key));
1750
+ }
1751
+ async ensure(identifier, title, _baseRef) {
1752
+ const key = this.keyFor(identifier);
1753
+ if (key === "" || key.startsWith(".")) {
1754
+ throw new Error(`invalid workspace identifier: ${identifier}`);
1755
+ }
1756
+ const path = this.pathFor(key);
1757
+ if (existsSync3(path)) {
1758
+ return { path, instructions: loadInstructions(path) };
1759
+ }
1760
+ const branch = this.branchFor(identifier, title);
1761
+ await this.runGit(
1762
+ ["-C", this.root, "worktree", "add", "--force", "-B", branch, path],
1763
+ this.root
1764
+ );
1765
+ if (this.options.hooks?.after_create) {
1766
+ await this.runHook(this.options.hooks.after_create, path);
1767
+ }
1768
+ return { path, instructions: loadInstructions(path) };
1769
+ }
1770
+ async remove(identifier) {
1771
+ const key = this.keyFor(identifier);
1772
+ const path = this.pathFor(key);
1773
+ await this.runGit(
1774
+ ["-C", this.root, "worktree", "remove", "--force", path],
1775
+ this.root
1776
+ );
1777
+ }
1778
+ async beforeRun(path) {
1779
+ if (this.options.hooks?.before_run) {
1780
+ await this.runHook(this.options.hooks.before_run, path);
1781
+ }
1782
+ }
1783
+ async afterRun(path) {
1784
+ if (this.options.hooks?.after_run) {
1785
+ await this.runHook(this.options.hooks.after_run, path);
1786
+ }
1787
+ }
1788
+ async afterDone(path) {
1789
+ if (this.options.hooks?.after_done) {
1790
+ await this.runHook(this.options.hooks.after_done, path);
1791
+ }
1792
+ }
1793
+ };
1794
+ function gitWorkspace(opts = {}) {
1795
+ return {
1796
+ kind: "workspace",
1797
+ id: "git",
1798
+ requiredModules: [],
1799
+ async createWorkspace(config) {
1800
+ const root = config.config_path ? dirname5(config.config_path) : process.cwd();
1801
+ return new GitWorkspace({
1802
+ root,
1803
+ ...opts.hooks ? { hooks: opts.hooks } : {},
1804
+ ...opts.branchPrefix ? { branchPrefix: opts.branchPrefix } : {},
1805
+ ...opts.branchTemplate ? { branchTemplate: opts.branchTemplate } : {}
1806
+ });
1807
+ }
1808
+ };
1809
+ }
1810
+
1811
+ // src/plugins/workspace/herdr.ts
1812
+ import { dirname as dirname6, join as join5, resolve as resolve4 } from "node:path";
1813
+ var defaultExec = (args) => exec("herdr", args);
1814
+ var defaultGitExec = (args) => exec("git", args);
1815
+ var defaultHookRunner2 = async (command, cwd) => {
1816
+ await exec("sh", ["-c", command], { cwd });
1817
+ };
1818
+ function isRecord2(value) {
1819
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1820
+ }
1821
+ function parseJson(output, command) {
1822
+ try {
1823
+ return JSON.parse(output);
1824
+ } catch {
1825
+ throw new Error(`herdr ${command} returned invalid JSON`);
1826
+ }
1827
+ }
1828
+ function parseWorktreeList(output) {
1829
+ const parsed = parseJson(output, "worktree list");
1830
+ const worktrees = isRecord2(parsed) ? parsed.result?.worktrees : void 0;
1831
+ if (!Array.isArray(worktrees)) {
1832
+ throw new Error("herdr worktree list returned invalid schema");
1833
+ }
1834
+ return worktrees.filter(isRecord2).filter((w) => typeof w.branch === "string" && typeof w.path === "string").map((w) => ({
1835
+ branch: w.branch,
1836
+ path: w.path,
1837
+ ...typeof w.open_workspace_id === "string" ? { open_workspace_id: w.open_workspace_id } : {}
1838
+ }));
1839
+ }
1840
+ function parseWorktreePath(output, command) {
1841
+ const parsed = parseJson(output, command);
1842
+ const path = isRecord2(parsed) ? parsed.result?.worktree?.path : void 0;
1843
+ if (typeof path !== "string") {
1844
+ throw new Error(`herdr ${command} returned invalid schema`);
1845
+ }
1846
+ return path;
1847
+ }
1848
+ function validateBranch(branch) {
1849
+ if (!/^[A-Za-z0-9._/-]+$/.test(branch) || branch.startsWith(".") || branch.includes("..")) {
1850
+ throw new Error(`invalid workspace branch: ${branch}`);
1851
+ }
1852
+ }
1853
+ var HerdrWorkspace = class {
1854
+ constructor(options) {
1855
+ this.options = options;
1856
+ this.execHerdr = options.execHerdr ?? defaultExec;
1857
+ this.execGit = options.execGit ?? defaultGitExec;
1858
+ this.runHook = options.runHook ?? defaultHookRunner2;
1859
+ this.root = resolve4(options.root);
1860
+ this.worktreeDir = options.worktreeDir ?? ".gaia-worktrees";
1861
+ }
1862
+ options;
1863
+ execHerdr;
1864
+ execGit;
1865
+ runHook;
1866
+ root;
1867
+ worktreeDir;
1868
+ /**
1869
+ * Resolve the base ref for a new worktree branch. Prefers the configured
1870
+ * `baseBranch`; otherwise the remote tracking branch of the repo's HEAD
1871
+ * (e.g. `origin/develop`). Best-effort: a repo with no upstream returns
1872
+ * `undefined`, leaving herdr to fall back to the parent workspace HEAD.
1873
+ */
1874
+ async resolveBaseRef() {
1875
+ if (this.options.baseBranch) {
1876
+ return this.options.baseBranch;
1877
+ }
1878
+ try {
1879
+ const ref = (await this.execGit([
1880
+ "-C",
1881
+ this.root,
1882
+ "rev-parse",
1883
+ "--abbrev-ref",
1884
+ "--symbolic-full-name",
1885
+ "@{u}"
1886
+ ])).trim();
1887
+ return ref || void 0;
1888
+ } catch {
1889
+ return void 0;
1890
+ }
1891
+ }
1892
+ /**
1893
+ * Fetch the base ref's remote branch so the new worktree starts from the
1894
+ * latest pushed state. Best-effort: offline / unknown remote is non-fatal.
1895
+ */
1896
+ async fetchBaseRef(baseRef) {
1897
+ const slash = baseRef.indexOf("/");
1898
+ if (slash <= 0) {
1899
+ return;
1900
+ }
1901
+ const remote = baseRef.slice(0, slash);
1902
+ const branch = baseRef.slice(slash + 1);
1903
+ try {
1904
+ await this.execGit(["-C", this.root, "fetch", remote, branch]);
1905
+ } catch {
1906
+ }
1907
+ }
1908
+ /**
1909
+ * Verify a base-ref candidate resolves on the remote (after fetching it).
1910
+ * Returns the ref when it exists, else undefined so the caller falls back.
1911
+ */
1912
+ async verifyRemoteRef(ref) {
1913
+ await this.fetchBaseRef(ref);
1914
+ try {
1915
+ await this.execGit([
1916
+ "-C",
1917
+ this.root,
1918
+ "rev-parse",
1919
+ "--verify",
1920
+ "--quiet",
1921
+ `${ref}^{commit}`
1922
+ ]);
1923
+ return ref;
1924
+ } catch {
1925
+ return void 0;
1926
+ }
1927
+ }
1928
+ async ensure(_identifier, branch, baseRefOverride) {
1929
+ if (branch === void 0) {
1930
+ throw new Error("herdr workspace requires a branch name");
1931
+ }
1932
+ validateBranch(branch);
1933
+ const worktrees = parseWorktreeList(
1934
+ await this.execHerdr(["worktree", "list", "--cwd", this.root, "--json"])
1935
+ );
1936
+ const existing = worktrees.find((w) => w.branch === branch);
1937
+ if (existing) {
1938
+ let path2 = existing.path;
1939
+ if (!existing.open_workspace_id) {
1940
+ path2 = parseWorktreePath(
1941
+ await this.execHerdr([
1942
+ "worktree",
1943
+ "open",
1944
+ "--cwd",
1945
+ this.root,
1946
+ "--branch",
1947
+ branch,
1948
+ "--no-focus",
1949
+ "--json"
1950
+ ]),
1951
+ "worktree open"
1952
+ );
1953
+ }
1954
+ return { path: path2, instructions: loadInstructions(path2) };
1955
+ }
1956
+ let baseRef;
1957
+ if (baseRefOverride !== void 0) {
1958
+ baseRef = await this.verifyRemoteRef(baseRefOverride);
1959
+ }
1960
+ if (baseRef === void 0) {
1961
+ baseRef = await this.resolveBaseRef();
1962
+ if (baseRef !== void 0) {
1963
+ await this.fetchBaseRef(baseRef);
1964
+ }
1965
+ }
1966
+ const path = parseWorktreePath(
1967
+ await this.execHerdr([
1968
+ "worktree",
1969
+ "create",
1970
+ "--cwd",
1971
+ this.root,
1972
+ "--branch",
1973
+ branch,
1974
+ ...baseRef !== void 0 ? ["--base", baseRef] : [],
1975
+ "--label",
1976
+ branch,
1977
+ "--path",
1978
+ join5(this.worktreeDir, branch),
1979
+ "--no-focus",
1980
+ "--json"
1981
+ ]),
1982
+ "worktree create"
1983
+ );
1984
+ if (this.options.hooks?.after_create) {
1985
+ await this.runHook(this.options.hooks.after_create, path);
1986
+ }
1987
+ return { path, instructions: loadInstructions(path) };
1988
+ }
1989
+ async beforeRun(path) {
1990
+ if (this.options.hooks?.before_run) {
1991
+ await this.runHook(this.options.hooks.before_run, path);
1992
+ }
1993
+ }
1994
+ async afterRun(path) {
1995
+ if (this.options.hooks?.after_run) {
1996
+ await this.runHook(this.options.hooks.after_run, path);
1997
+ }
1998
+ }
1999
+ async afterDone(path) {
2000
+ if (this.options.hooks?.after_done) {
2001
+ await this.runHook(this.options.hooks.after_done, path);
2002
+ }
2003
+ }
2004
+ };
2005
+ function herdrWorkspace(opts = {}) {
2006
+ return {
2007
+ kind: "workspace",
2008
+ id: "herdr",
2009
+ requiredModules: [],
2010
+ async createWorkspace(config) {
2011
+ const root = config.config_path ? dirname6(config.config_path) : process.cwd();
2012
+ return new HerdrWorkspace({
2013
+ root,
2014
+ ...opts.hooks ? { hooks: opts.hooks } : {},
2015
+ ...opts.worktreeDir ? { worktreeDir: opts.worktreeDir } : {},
2016
+ ...opts.baseBranch ? { baseBranch: opts.baseBranch } : {}
2017
+ });
2018
+ }
2019
+ };
2020
+ }
2021
+ export {
2022
+ Conductor,
2023
+ DEFAULT_AGENT_PROMPT,
2024
+ DrupalGaiaRemote,
2025
+ FakeGaiaRemote,
2026
+ FakeWorkspace,
2027
+ GitWorkspace,
2028
+ HerdrWorkspace,
2029
+ conductorId,
2030
+ drupalRemote,
2031
+ fakeRemote,
2032
+ fakeWorkspace,
2033
+ gitWorkspace,
2034
+ herdrWorkspace,
2035
+ loadConductorConfig,
2036
+ main,
2037
+ runGaiaCli,
2038
+ selectExecutor,
2039
+ selectRemote,
2040
+ selectWorkspace
2041
+ };