@gaia-ai/addon-herdr 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ import { type PaneSpec } from './config.js';
2
+ export type HerdrExec = (args: string[], opts?: {
3
+ redactArgs?: (args: string[]) => string[];
4
+ }) => Promise<string>;
5
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
6
+ export declare function parseJson(output: string, command: string): unknown;
7
+ export declare function parsePaneSplit(output: string): string;
8
+ /**
9
+ * A pane layout: an optional command run in the root/main pane, plus optional
10
+ * split panes. Shared by the executor's per-run tab (`startRun`) and the
11
+ * workspace's initial open pane (`herdrWorkspace` `open`) — one code path, no
12
+ * duplication. The per-state `StateConfig` (executor) and the workspace `open`
13
+ * option both narrow to this shape (the executor adds `tabLabel`; there is only
14
+ * one open layout, not a per-state map).
15
+ */
16
+ export interface PaneLayout {
17
+ /** Command run in the initial root/main pane (typed verbatim, not templated). */
18
+ command?: string;
19
+ /** Extra split panes off the root; each command is `renderTemplate`'d. */
20
+ panes?: PaneSpec[];
21
+ }
22
+ /**
23
+ * Apply a {@link PaneLayout} to an existing root pane: run `command` in the root
24
+ * pane, then split each configured pane (with `--cwd cwd`) and run its
25
+ * `renderTemplate`'d command. The root command is typed VERBATIM (the caller
26
+ * pre-assembles it — e.g. the executor's env-prefixed agent command, GAIA-101/128);
27
+ * only split commands are templated from `vars`.
28
+ *
29
+ * `opts.redactCommand` supplies a log-safe rewrite of the root command (env
30
+ * VALUES masked, key names kept — GAIA-99): when it returns a different string a
31
+ * `redactArgs` scrubber is passed so a failed `pane run` exec-log never carries a
32
+ * secret, while the command herdr actually runs stays real. An empty layout
33
+ * (`{}`) issues no calls at all — the caller's unconfigured default is preserved.
34
+ */
35
+ export declare function applyPaneLayout(execHerdr: HerdrExec, rootPaneId: string, layout: PaneLayout, cwd: string, vars: Record<string, string>, opts?: {
36
+ redactCommand?: (command: string) => string;
37
+ }): Promise<void>;
@@ -0,0 +1,69 @@
1
+ import { renderTemplate } from './config.js';
2
+ export function isRecord(value) {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ }
5
+ export function parseJson(output, command) {
6
+ try {
7
+ return JSON.parse(output);
8
+ }
9
+ catch {
10
+ throw new Error(`herdr ${command} returned invalid JSON`);
11
+ }
12
+ }
13
+ export function parsePaneSplit(output) {
14
+ const parsed = parseJson(output, 'pane split');
15
+ const paneId = isRecord(parsed)
16
+ ? parsed.result?.pane
17
+ ?.pane_id
18
+ : undefined;
19
+ if (typeof paneId !== 'string') {
20
+ throw new Error('herdr pane split returned invalid schema');
21
+ }
22
+ return paneId;
23
+ }
24
+ /**
25
+ * Apply a {@link PaneLayout} to an existing root pane: run `command` in the root
26
+ * pane, then split each configured pane (with `--cwd cwd`) and run its
27
+ * `renderTemplate`'d command. The root command is typed VERBATIM (the caller
28
+ * pre-assembles it — e.g. the executor's env-prefixed agent command, GAIA-101/128);
29
+ * only split commands are templated from `vars`.
30
+ *
31
+ * `opts.redactCommand` supplies a log-safe rewrite of the root command (env
32
+ * VALUES masked, key names kept — GAIA-99): when it returns a different string a
33
+ * `redactArgs` scrubber is passed so a failed `pane run` exec-log never carries a
34
+ * secret, while the command herdr actually runs stays real. An empty layout
35
+ * (`{}`) issues no calls at all — the caller's unconfigured default is preserved.
36
+ */
37
+ export async function applyPaneLayout(execHerdr, rootPaneId, layout, cwd, vars, opts) {
38
+ if (layout.command) {
39
+ const command = layout.command;
40
+ const runArgs = ['pane', 'run', rootPaneId, command];
41
+ const redacted = opts?.redactCommand?.(command);
42
+ if (redacted !== undefined && redacted !== command) {
43
+ await execHerdr(runArgs, {
44
+ redactArgs: (args) => args.map((a) => (a === command ? redacted : a)),
45
+ });
46
+ }
47
+ else {
48
+ await execHerdr(runArgs);
49
+ }
50
+ }
51
+ for (const spec of layout.panes ?? []) {
52
+ const splitPaneId = parsePaneSplit(await execHerdr([
53
+ 'pane',
54
+ 'split',
55
+ rootPaneId,
56
+ '--direction',
57
+ spec.direction,
58
+ '--cwd',
59
+ cwd,
60
+ spec.focus ? '--focus' : '--no-focus',
61
+ ]));
62
+ await execHerdr([
63
+ 'pane',
64
+ 'run',
65
+ splitPaneId,
66
+ renderTemplate(spec.command, vars),
67
+ ]);
68
+ }
69
+ }
@@ -0,0 +1,19 @@
1
+ /** A herdr pane and the tab it sits in. */
2
+ export interface HerdrPaneRef {
3
+ paneId: string;
4
+ tabId: string;
5
+ }
6
+ /** A herdr pane plus its label. */
7
+ export interface HerdrPaneInfo extends HerdrPaneRef {
8
+ label: string;
9
+ }
10
+ /** GAIA_* env vars as `--env KEY=VALUE` args (never dump the full environment). */
11
+ export declare function envArgs(env: Record<string, string | undefined>): string[];
12
+ /** `{ paneId, tabId }` from a `herdr tab create` envelope. */
13
+ export declare function parseTabCreate(output: string): HerdrPaneRef;
14
+ /** `{ paneId, tabId }` from a `result.pane` envelope (pane current / split). */
15
+ export declare function parsePaneInfo(output: string): HerdrPaneRef;
16
+ /** The id of the first tab whose label equals `label`, else null. */
17
+ export declare function findTabIdByLabel(output: string, label: string): string | null;
18
+ /** Panes in `tabId` as `[{ paneId, tabId, label }]` from a `pane list` envelope. */
19
+ export declare function parsePanesInTab(output: string, tabId: string): HerdrPaneInfo[];
@@ -0,0 +1,73 @@
1
+ // Herdr pane/tab CLI envelope parsers (GAIA-190) — the building blocks the
2
+ // intent-level `herdrAgentHost` (agents.ts) uses to talk to herdr. Kept small
3
+ // and herdr-internal; the conductor/TUI never see these pane shapes.
4
+ function isRecord(v) {
5
+ return typeof v === 'object' && v !== null;
6
+ }
7
+ /** GAIA_* env vars as `--env KEY=VALUE` args (never dump the full environment). */
8
+ export function envArgs(env) {
9
+ const out = [];
10
+ for (const [key, value] of Object.entries(env)) {
11
+ if (key.startsWith('GAIA_') && value != null) {
12
+ out.push('--env', `${key}=${value}`);
13
+ }
14
+ }
15
+ return out;
16
+ }
17
+ /** `{ paneId, tabId }` from a `herdr tab create` envelope. */
18
+ export function parseTabCreate(output) {
19
+ const parsed = JSON.parse(output);
20
+ const result = isRecord(parsed)
21
+ ? parsed.result
22
+ : undefined;
23
+ const paneId = result?.root_pane?.pane_id;
24
+ const tabId = result?.tab?.tab_id;
25
+ if (typeof paneId !== 'string' || typeof tabId !== 'string') {
26
+ throw new Error('herdr tab create returned invalid schema');
27
+ }
28
+ return { paneId, tabId };
29
+ }
30
+ /** `{ paneId, tabId }` from a `result.pane` envelope (pane current / split). */
31
+ export function parsePaneInfo(output) {
32
+ const parsed = JSON.parse(output);
33
+ const pane = isRecord(parsed)
34
+ ? parsed.result
35
+ ?.pane
36
+ : undefined;
37
+ const paneId = pane?.pane_id;
38
+ const tabId = pane?.tab_id;
39
+ if (typeof paneId !== 'string' || typeof tabId !== 'string') {
40
+ throw new Error('herdr returned no pane info');
41
+ }
42
+ return { paneId, tabId };
43
+ }
44
+ /** The id of the first tab whose label equals `label`, else null. */
45
+ export function findTabIdByLabel(output, label) {
46
+ const parsed = JSON.parse(output);
47
+ const tabs = isRecord(parsed)
48
+ ? parsed.result?.tabs
49
+ : undefined;
50
+ if (!Array.isArray(tabs))
51
+ return null;
52
+ const match = tabs
53
+ .filter(isRecord)
54
+ .find((t) => t.label === label && typeof t.tab_id === 'string');
55
+ return match ? match.tab_id : null;
56
+ }
57
+ /** Panes in `tabId` as `[{ paneId, tabId, label }]` from a `pane list` envelope. */
58
+ export function parsePanesInTab(output, tabId) {
59
+ const parsed = JSON.parse(output);
60
+ const panes = isRecord(parsed)
61
+ ? parsed.result?.panes
62
+ : undefined;
63
+ if (!Array.isArray(panes))
64
+ return [];
65
+ return panes
66
+ .filter(isRecord)
67
+ .filter((p) => typeof p.pane_id === 'string' && p.tab_id === tabId)
68
+ .map((p) => ({
69
+ paneId: p.pane_id,
70
+ tabId,
71
+ label: typeof p.label === 'string' ? p.label : '',
72
+ }));
73
+ }
@@ -0,0 +1,3 @@
1
+ import type { Preset } from '@gaia-ai/conductor/contract';
2
+ export declare const executors: Preset['executors'];
3
+ export declare const workspaces: Preset['workspaces'];
@@ -0,0 +1,9 @@
1
+ import { herdrExecutor, herdrWorkspace } from './index.js';
2
+ export const executors = (acc, opts) => [
3
+ ...acc,
4
+ herdrExecutor(opts),
5
+ ];
6
+ export const workspaces = (acc, opts) => [
7
+ ...acc,
8
+ herdrWorkspace(opts),
9
+ ];
@@ -0,0 +1,26 @@
1
+ /** Runs `git <args>` in a cwd; resolves stdout, rejects on non-zero exit. */
2
+ export type RootGitExec = (args: string[], cwd: string) => Promise<string>;
3
+ /** Default git runner for root derivation: real `git` in the given cwd. */
4
+ export declare const defaultRootGitExec: RootGitExec;
5
+ /**
6
+ * Derive the main-clone root that herdr requires as the `--cwd` for every
7
+ * `worktree list/open/create`. This is the single anchor shared by BOTH the
8
+ * `herdrExecutor` and `herdrWorkspace` factories (GAIA-177 AC-3).
9
+ *
10
+ * The config path is NOT a safe anchor: `.gaia/` is checked in, so config
11
+ * discovery walks up to the *worktree-local* config, whose dirname IS the linked
12
+ * worktree — and herdr rejects a linked worktree as that `--cwd`
13
+ * (`linked_worktree_source`). Instead ask git: `git rev-parse
14
+ * --path-format=absolute --git-common-dir`, run from the config dir, always
15
+ * resolves to the shared main `.git` even from a linked worktree; its parent is
16
+ * the main clone.
17
+ *
18
+ * `override` (a factory `root:` option) always wins and skips git entirely.
19
+ * Best-effort: a missing `config_path` or a git failure (e.g. not a repo) falls
20
+ * back to `dirname(config_path)` — the previous behaviour — and finally
21
+ * `process.cwd()`.
22
+ */
23
+ export declare function deriveHerdrRoot(configPath: string | undefined, opts?: {
24
+ override?: string;
25
+ execGit?: RootGitExec;
26
+ }): Promise<string>;
@@ -0,0 +1,40 @@
1
+ import { dirname } from 'node:path';
2
+ import { exec } from '@gaia-ai/core';
3
+ /** Default git runner for root derivation: real `git` in the given cwd. */
4
+ export const defaultRootGitExec = (args, cwd) => exec('git', args, { cwd });
5
+ /**
6
+ * Derive the main-clone root that herdr requires as the `--cwd` for every
7
+ * `worktree list/open/create`. This is the single anchor shared by BOTH the
8
+ * `herdrExecutor` and `herdrWorkspace` factories (GAIA-177 AC-3).
9
+ *
10
+ * The config path is NOT a safe anchor: `.gaia/` is checked in, so config
11
+ * discovery walks up to the *worktree-local* config, whose dirname IS the linked
12
+ * worktree — and herdr rejects a linked worktree as that `--cwd`
13
+ * (`linked_worktree_source`). Instead ask git: `git rev-parse
14
+ * --path-format=absolute --git-common-dir`, run from the config dir, always
15
+ * resolves to the shared main `.git` even from a linked worktree; its parent is
16
+ * the main clone.
17
+ *
18
+ * `override` (a factory `root:` option) always wins and skips git entirely.
19
+ * Best-effort: a missing `config_path` or a git failure (e.g. not a repo) falls
20
+ * back to `dirname(config_path)` — the previous behaviour — and finally
21
+ * `process.cwd()`.
22
+ */
23
+ export async function deriveHerdrRoot(configPath, opts = {}) {
24
+ if (opts.override !== undefined) {
25
+ return opts.override;
26
+ }
27
+ const configDir = configPath ? dirname(configPath) : undefined;
28
+ const fallback = configDir ?? process.cwd();
29
+ if (configDir === undefined) {
30
+ return fallback;
31
+ }
32
+ const execGit = opts.execGit ?? defaultRootGitExec;
33
+ try {
34
+ const commonDir = (await execGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], configDir)).trim();
35
+ return commonDir ? dirname(commonDir) : fallback;
36
+ }
37
+ catch {
38
+ return fallback;
39
+ }
40
+ }
@@ -0,0 +1,85 @@
1
+ import type { EnsuredWorkspace, GaiaWorkspace, WorkspacePlugin } from '@gaia-ai/conductor/contract';
2
+ import type { ConductorLogger } from '@gaia-ai/core';
3
+ import type { AgentLaunchHost } from './agent-host.js';
4
+ import type { PaneSpec } from './config.js';
5
+ import { type RootGitExec } from './root-anchor.js';
6
+ export type HerdrExec = (args: string[]) => Promise<string>;
7
+ export type GitExec = (args: string[]) => Promise<string>;
8
+ /**
9
+ * Layout applied to the workspace's initial (root) pane on a FRESH worktree open
10
+ * (GAIA-139): `command` launches a tool in the main pane (e.g. `spiceedit`) so
11
+ * the human lands in an editor instead of a bare shell; `panes[]` are optional
12
+ * splits off it. Same shape as the executor's per-state layout minus `tabLabel`
13
+ * — there is only ONE open layout, not a per-ticket-state map. Unset → the bare
14
+ * empty tab opens unchanged (the current good default).
15
+ */
16
+ export interface OpenLayout {
17
+ command?: string;
18
+ panes?: PaneSpec[];
19
+ }
20
+ export interface HerdrWorkspaceOptions {
21
+ /** Main clone (a git repo): herdr `--cwd` target + worktree base. */
22
+ root: string;
23
+ /** Relative dir (under root) for new worktrees. Default: `.gaia-worktrees`. */
24
+ worktreeDir?: string;
25
+ /**
26
+ * Base ref new worktree branches are created from. Default: the remote
27
+ * tracking branch of the repo's checked-out branch (e.g. `origin/develop`),
28
+ * so runs start from the pushed remote state, not the host's local branch.
29
+ */
30
+ baseBranch?: string;
31
+ /**
32
+ * Startup layout for the initial pane, applied on a FRESH worktree open only
33
+ * (GAIA-139). Unset → the bare empty tab is left unchanged.
34
+ */
35
+ open?: OpenLayout;
36
+ /** Overrideable herdr exec fn — defaults to the real `herdr` binary. */
37
+ execHerdr?: HerdrExec;
38
+ /** Overrideable git exec fn — defaults to the real `git` binary. */
39
+ execGit?: GitExec;
40
+ /** Optional logger for the best-effort open-layout path (defaults to noop). */
41
+ logger?: ConductorLogger;
42
+ }
43
+ export declare class HerdrWorkspace implements GaiaWorkspace {
44
+ private readonly options;
45
+ private readonly execHerdr;
46
+ private readonly execGit;
47
+ private readonly root;
48
+ private readonly worktreeDir;
49
+ private readonly open;
50
+ private readonly logger;
51
+ /** Herdr-backed intent-level agent host for `gaia ui` (GAIA-190). */
52
+ readonly agentHost: AgentLaunchHost;
53
+ constructor(options: HerdrWorkspaceOptions);
54
+ /**
55
+ * Resolve the base ref for a new worktree branch. Prefers the configured
56
+ * `baseBranch`; otherwise the remote tracking branch of the repo's HEAD
57
+ * (e.g. `origin/develop`). Best-effort: a repo with no upstream returns
58
+ * `undefined`, leaving herdr to fall back to the parent workspace HEAD.
59
+ */
60
+ private resolveBaseRef;
61
+ /**
62
+ * Fetch the base ref's remote branch so the new worktree starts from the
63
+ * latest pushed state. Best-effort: offline / unknown remote is non-fatal.
64
+ */
65
+ private fetchBaseRef;
66
+ /**
67
+ * Verify a base-ref candidate resolves on the remote (after fetching it).
68
+ * Returns the ref when it exists, else undefined so the caller falls back.
69
+ */
70
+ private verifyRemoteRef;
71
+ ensure(_identifier: string, branch?: string, baseRefOverride?: string): Promise<EnsuredWorkspace>;
72
+ }
73
+ export declare function herdrWorkspace(opts?: {
74
+ /**
75
+ * Explicit repo-root override; wins over the git-derived anchor. Otherwise
76
+ * the main clone is derived from the git common dir (GAIA-177).
77
+ */
78
+ root?: string;
79
+ worktreeDir?: string;
80
+ baseBranch?: string;
81
+ open?: OpenLayout;
82
+ /** Overrideable git exec for root derivation (tests). */
83
+ execGit?: RootGitExec;
84
+ }): WorkspacePlugin;
85
+ export default herdrWorkspace;
@@ -0,0 +1,261 @@
1
+ import { resolve } from 'node:path';
2
+ import { loadInstructions } from '@gaia-ai/addon-workspace-git';
3
+ import { exec } from '@gaia-ai/core';
4
+ import { herdrAgentHost } from './agents.js';
5
+ import { applyPaneLayout, isRecord, parseJson } from './pane-layout.js';
6
+ import { deriveHerdrRoot } from './root-anchor.js';
7
+ const defaultExec = (args) => exec('herdr', args);
8
+ const defaultGitExec = (args) => exec('git', args);
9
+ const noopLogger = {
10
+ debug() { },
11
+ info() { },
12
+ warn() { },
13
+ error() { },
14
+ };
15
+ /** Parse `herdr worktree list --json` → array of on-disk worktrees. */
16
+ function parseWorktreeList(output) {
17
+ const parsed = parseJson(output, 'worktree list');
18
+ const worktrees = isRecord(parsed)
19
+ ? parsed.result?.worktrees
20
+ : undefined;
21
+ if (!Array.isArray(worktrees)) {
22
+ throw new Error('herdr worktree list returned invalid schema');
23
+ }
24
+ return worktrees
25
+ .filter(isRecord)
26
+ .filter((w) => typeof w.branch === 'string' && typeof w.path === 'string')
27
+ .map((w) => ({
28
+ branch: w.branch,
29
+ path: w.path,
30
+ ...(typeof w.open_workspace_id === 'string'
31
+ ? { open_workspace_id: w.open_workspace_id }
32
+ : {}),
33
+ }));
34
+ }
35
+ /** Parse `worktree create`/`worktree open` → the created/opened worktree path. */
36
+ function parseWorktreePath(output, command) {
37
+ const parsed = parseJson(output, command);
38
+ const path = isRecord(parsed)
39
+ ? parsed.result?.worktree
40
+ ?.path
41
+ : undefined;
42
+ if (typeof path !== 'string') {
43
+ throw new Error(`herdr ${command} returned invalid schema`);
44
+ }
45
+ return path;
46
+ }
47
+ /**
48
+ * Parse `worktree create` → the created tab's root pane id
49
+ * (`result.root_pane.pane_id`), sibling of {@link parseWorktreePath}. Returns
50
+ * null when the create JSON carries none, so the open-layout caller can skip
51
+ * silently rather than throw (best-effort).
52
+ */
53
+ function parseWorktreeRootPane(output) {
54
+ const parsed = parseJson(output, 'worktree create');
55
+ const paneId = isRecord(parsed)
56
+ ? parsed.result
57
+ ?.root_pane?.pane_id
58
+ : undefined;
59
+ return typeof paneId === 'string' && paneId ? paneId : null;
60
+ }
61
+ /** Git-safe branch validation: safe ref chars only, no leading dot, no `..`. */
62
+ function validateBranch(branch) {
63
+ if (!/^[A-Za-z0-9._/-]+$/.test(branch) ||
64
+ branch.startsWith('.') ||
65
+ branch.includes('..')) {
66
+ throw new Error(`invalid workspace branch: ${branch}`);
67
+ }
68
+ }
69
+ export class HerdrWorkspace {
70
+ options;
71
+ execHerdr;
72
+ execGit;
73
+ root;
74
+ worktreeDir;
75
+ open;
76
+ logger;
77
+ /** Herdr-backed intent-level agent host for `gaia ui` (GAIA-190). */
78
+ agentHost;
79
+ constructor(options) {
80
+ this.options = options;
81
+ this.execHerdr = options.execHerdr ?? defaultExec;
82
+ this.execGit = options.execGit ?? defaultGitExec;
83
+ this.root = resolve(options.root);
84
+ this.worktreeDir = options.worktreeDir ?? '.gaia-worktrees';
85
+ this.open = options.open;
86
+ this.logger = options.logger ?? noopLogger;
87
+ this.agentHost = herdrAgentHost(this.execHerdr);
88
+ }
89
+ /**
90
+ * Resolve the base ref for a new worktree branch. Prefers the configured
91
+ * `baseBranch`; otherwise the remote tracking branch of the repo's HEAD
92
+ * (e.g. `origin/develop`). Best-effort: a repo with no upstream returns
93
+ * `undefined`, leaving herdr to fall back to the parent workspace HEAD.
94
+ */
95
+ async resolveBaseRef() {
96
+ if (this.options.baseBranch) {
97
+ return this.options.baseBranch;
98
+ }
99
+ try {
100
+ const ref = (await this.execGit([
101
+ '-C',
102
+ this.root,
103
+ 'rev-parse',
104
+ '--abbrev-ref',
105
+ '--symbolic-full-name',
106
+ '@{u}',
107
+ ])).trim();
108
+ return ref || undefined;
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ }
114
+ /**
115
+ * Fetch the base ref's remote branch so the new worktree starts from the
116
+ * latest pushed state. Best-effort: offline / unknown remote is non-fatal.
117
+ */
118
+ async fetchBaseRef(baseRef) {
119
+ const slash = baseRef.indexOf('/');
120
+ if (slash <= 0) {
121
+ return;
122
+ }
123
+ const remote = baseRef.slice(0, slash);
124
+ const branch = baseRef.slice(slash + 1);
125
+ try {
126
+ await this.execGit(['-C', this.root, 'fetch', remote, branch]);
127
+ }
128
+ catch {
129
+ // Non-fatal: use whatever the local tracking ref already points at.
130
+ }
131
+ }
132
+ /**
133
+ * Verify a base-ref candidate resolves on the remote (after fetching it).
134
+ * Returns the ref when it exists, else undefined so the caller falls back.
135
+ */
136
+ async verifyRemoteRef(ref) {
137
+ await this.fetchBaseRef(ref);
138
+ try {
139
+ await this.execGit([
140
+ '-C',
141
+ this.root,
142
+ 'rev-parse',
143
+ '--verify',
144
+ '--quiet',
145
+ `${ref}^{commit}`,
146
+ ]);
147
+ return ref;
148
+ }
149
+ catch {
150
+ return undefined;
151
+ }
152
+ }
153
+ async ensure(_identifier, branch, baseRefOverride) {
154
+ if (branch === undefined) {
155
+ throw new Error('herdr workspace requires a branch name');
156
+ }
157
+ validateBranch(branch);
158
+ const worktrees = parseWorktreeList(await this.execHerdr(['worktree', 'list', '--cwd', this.root, '--json']));
159
+ const existing = worktrees.find((w) => w.branch === branch);
160
+ if (existing) {
161
+ // Reuse: an existing worktree is never re-created, so created=false — the
162
+ // core skips the after_create hook for it. The open layout is NOT
163
+ // re-applied on reuse (GAIA-139): no duplicate editor process on reattach.
164
+ let path = existing.path;
165
+ if (!existing.open_workspace_id) {
166
+ // On-disk worktree with no open herdr workspace — open it.
167
+ path = parseWorktreePath(await this.execHerdr([
168
+ 'worktree',
169
+ 'open',
170
+ '--cwd',
171
+ this.root,
172
+ '--branch',
173
+ branch,
174
+ '--no-focus',
175
+ '--json',
176
+ ]), 'worktree open');
177
+ }
178
+ return { path, instructions: loadInstructions(path), created: false };
179
+ }
180
+ // Fresh: create the git worktree + herdr workspace in one call. Base the
181
+ // new branch on the (freshly fetched) remote tracking branch, not the
182
+ // host's local checked-out branch. A ticket passes its `origin/<base_branch>`
183
+ // as the override; use it only when it resolves on the remote, else fall
184
+ // back to the default base.
185
+ let baseRef;
186
+ if (baseRefOverride !== undefined) {
187
+ baseRef = await this.verifyRemoteRef(baseRefOverride);
188
+ }
189
+ if (baseRef === undefined) {
190
+ baseRef = await this.resolveBaseRef();
191
+ if (baseRef !== undefined) {
192
+ await this.fetchBaseRef(baseRef);
193
+ }
194
+ }
195
+ const createOutput = await this.execHerdr([
196
+ 'worktree',
197
+ 'create',
198
+ '--cwd',
199
+ this.root,
200
+ '--branch',
201
+ branch,
202
+ ...(baseRef !== undefined ? ['--base', baseRef] : []),
203
+ '--label',
204
+ branch,
205
+ // ABSOLUTE, never relative: herdr canonicalizes a relative `--path`
206
+ // against its own `current_dir()`, which fails with
207
+ // `Os { code: 2, kind: NotFound }` whenever the CALLER's cwd has been
208
+ // deleted — e.g. a conductor still polling from a reaped worktree. That
209
+ // killed every dispatch with an unattributable herdr exit 1. `resolve`
210
+ // (not `join`) so an absolute `worktreeDir` override still wins.
211
+ '--path',
212
+ resolve(this.root, this.worktreeDir, branch),
213
+ '--no-focus',
214
+ '--json',
215
+ ]);
216
+ const path = parseWorktreePath(createOutput, 'worktree create');
217
+ // GAIA-139: on a FRESH create, apply the configured open layout to the
218
+ // initial root pane (launch e.g. `spiceedit` instead of a bare shell). The
219
+ // pane's cwd is the worktree checkout, passed explicitly on the splits (the
220
+ // root pane's cwd is already the checkout). Best-effort — mirrors the
221
+ // `fetchBaseRef` pattern above: a broken layout is logged and swallowed so a
222
+ // startup-command failure can NEVER wedge worktree creation / run dispatch.
223
+ if (this.open) {
224
+ try {
225
+ const rootPaneId = parseWorktreeRootPane(createOutput);
226
+ if (rootPaneId) {
227
+ await applyPaneLayout(this.execHerdr, rootPaneId, this.open, path, {
228
+ branch,
229
+ worktreePath: path,
230
+ });
231
+ }
232
+ }
233
+ catch (err) {
234
+ this.logger.warn({ branch, worktree: path, err: String(err) }, 'herdr open-layout failed (best-effort, ignored)');
235
+ }
236
+ }
237
+ // The `after_create` hook is run by the executor (GAIA-84), not here — the
238
+ // workspace only reports that it created a fresh worktree.
239
+ return { path, instructions: loadInstructions(path), created: true };
240
+ }
241
+ }
242
+ export function herdrWorkspace(opts = {}) {
243
+ return {
244
+ kind: 'workspace',
245
+ id: 'herdr',
246
+ requiredModules: [],
247
+ async createWorkspace(config) {
248
+ const root = await deriveHerdrRoot(config.config_path, {
249
+ ...(opts.root !== undefined ? { override: opts.root } : {}),
250
+ ...(opts.execGit ? { execGit: opts.execGit } : {}),
251
+ });
252
+ return new HerdrWorkspace({
253
+ root,
254
+ ...(opts.worktreeDir ? { worktreeDir: opts.worktreeDir } : {}),
255
+ ...(opts.baseBranch ? { baseBranch: opts.baseBranch } : {}),
256
+ ...(opts.open ? { open: opts.open } : {}),
257
+ });
258
+ },
259
+ };
260
+ }
261
+ export default herdrWorkspace;
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@gaia-ai/addon-herdr",
3
+ "version": "0.6.1",
4
+ "description": "GAIA herdr integration: spawn executor + per-ticket worktree workspace.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./dist/src/index.js",
9
+ "./preset": "./dist/src/preset.js"
10
+ },
11
+ "files": [
12
+ "dist/src"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://git.key-tec.de/keytec/gaia.git",
20
+ "directory": "gaia-cli/addons/herdr"
21
+ },
22
+ "dependencies": {
23
+ "@gaia-ai/addon-workspace-git": "^0.6.1"
24
+ },
25
+ "peerDependencies": {
26
+ "@gaia-ai/conductor": "^0.6.1",
27
+ "@gaia-ai/core": "^0.6.1"
28
+ }
29
+ }