@bakers-build/addon-herdr 0.0.0-stage → 0.1.0-next.42

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,185 @@
1
+ import type { BbWorkspace, EnsuredWorkspace, WorkspacePlugin } from '@bakers-build/conductor/contract';
2
+ import type { ConductorLogger } from '@bakers-build/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
+ * (BB-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.
14
+ *
15
+ * BB-234: `{identifier}`, `{branch}` and `{worktreePath}` expand in `command`
16
+ * **and** in every `panes[].command`. The root command is rendered here, by
17
+ * `ensure`, before the finished layout reaches `applyPaneLayout` — that shared
18
+ * helper types its root command VERBATIM on purpose, because its other caller
19
+ * (the executor) pre-assembles `KEY='value' <agent>` env prefixes whose values
20
+ * may legitimately contain braces (spec D2).
21
+ *
22
+ * BB-374: `tabLabel` / `panelLabel` are chrome strings applied after the
23
+ * layout via `herdr tab rename` / `herdr pane rename` ("panel label" = pane
24
+ * label). Defaults ship when keys are absent on an active layout; `open: {}`
25
+ * remains the full opt-out (no command, no splits, no labels). The overview
26
+ * **tab** default is `{identifier} · {title}` with `{title}` capped at
27
+ * {@link OPEN_TAB_TITLE_MAX} so the always-visible tab bar stays readable;
28
+ * the pane/panel label still gets the full title.
29
+ *
30
+ * Unset → {@link DEFAULT_OPEN_LAYOUT}, the ticket deep link. `{}` → the bare
31
+ * empty tab (`applyPaneLayout` issues no calls for an empty layout).
32
+ */
33
+ export interface OpenLayout {
34
+ command?: string;
35
+ panes?: PaneSpec[];
36
+ /**
37
+ * Overview tab label template. Default: `{identifier} · {title}` (title
38
+ * truncated to {@link OPEN_TAB_TITLE_MAX} when rendering).
39
+ */
40
+ tabLabel?: string;
41
+ /**
42
+ * Overview root-pane label template (`herdr pane rename`). Default:
43
+ * `{identifier} · {title}` (full title).
44
+ */
45
+ panelLabel?: string;
46
+ }
47
+ /** Max `{title}` length substituted into the overview **tab** label (BB-374). */
48
+ export declare const OPEN_TAB_TITLE_MAX = 30;
49
+ /** Cap overview-tab `{title}` and append `...` when truncated. */
50
+ export declare function truncateOpenTabTitle(title: string): string;
51
+ /** Default overview tab chrome (BB-374) — always-visible tab bar identity. */
52
+ export declare const DEFAULT_OPEN_TAB_LABEL = "{identifier} \u00B7 {title}";
53
+ /** Default overview pane/panel label template (BB-374). */
54
+ export declare const DEFAULT_OPEN_PANEL_LABEL = "{identifier} \u00B7 {title}";
55
+ /**
56
+ * The shipped default open layout (BB-234): a fresh worktree's initial pane
57
+ * renders the ticket it belongs to instead of an anonymous shell prompt.
58
+ *
59
+ * It lives in **code, not config**, because a default each repo has to opt into
60
+ * would leave the defect in place for every other consumer of this addon; the
61
+ * addon already ships opinionated defaults of this kind (`BUILTIN_DEFAULTS` in
62
+ * `./config.js`), and `bakers ui` is a command of the very host that ships it.
63
+ *
64
+ * `open: {}` is the documented opt-out back to the pre-BB-234 bare tab, so
65
+ * the default is not a one-way door.
66
+ */
67
+ export declare const DEFAULT_OPEN_LAYOUT: OpenLayout;
68
+ export interface HerdrWorkspaceOptions {
69
+ /** Main clone (a git repo): herdr `--cwd` target + worktree base. */
70
+ root: string;
71
+ /** Relative dir (under root) for new worktrees. Default: `.bakers-worktrees`. */
72
+ worktreeDir?: string;
73
+ /**
74
+ * Base ref new worktree branches are created from. Default: the remote
75
+ * tracking branch of the repo's checked-out branch (e.g. `origin/develop`),
76
+ * so runs start from the pushed remote state, not the host's local branch.
77
+ */
78
+ baseBranch?: string;
79
+ /**
80
+ * Startup layout for the initial pane, applied on a FRESH worktree open only
81
+ * (BB-139). Unset → {@link DEFAULT_OPEN_LAYOUT} (`bakers ui --ticket <ID>`,
82
+ * BB-234); `{}` → the bare empty tab is left unchanged.
83
+ */
84
+ open?: OpenLayout;
85
+ /** Overrideable herdr exec fn — defaults to the real `herdr` binary. */
86
+ execHerdr?: HerdrExec;
87
+ /** Overrideable git exec fn — defaults to the real `git` binary. */
88
+ execGit?: GitExec;
89
+ /** Optional logger for the best-effort open-layout path (defaults to noop). */
90
+ logger?: ConductorLogger;
91
+ }
92
+ export declare class HerdrWorkspace implements BbWorkspace {
93
+ private readonly options;
94
+ private readonly execHerdr;
95
+ private readonly execGit;
96
+ private readonly root;
97
+ private readonly worktreeDir;
98
+ private readonly open;
99
+ private readonly logger;
100
+ /**
101
+ * Root panes captured by a FRESH `ensure` create, keyed by worktree path,
102
+ * awaiting {@link applyOpenLayout} (BB-237). One-shot: consumed on apply.
103
+ * Optional `tabId` powers the BB-374 overview tab rename.
104
+ */
105
+ private readonly pendingRootPanes;
106
+ /** Herdr-backed intent-level agent host for `bakers ui` (BB-190). */
107
+ readonly agentHost: AgentLaunchHost;
108
+ constructor(options: HerdrWorkspaceOptions);
109
+ /**
110
+ * Resolve the base ref for a new worktree branch. Prefers the configured
111
+ * `baseBranch`; otherwise the remote tracking branch of the repo's HEAD
112
+ * (e.g. `origin/develop`). Best-effort: a repo with no upstream returns
113
+ * `undefined`, leaving herdr to fall back to the parent workspace HEAD.
114
+ */
115
+ private resolveBaseRef;
116
+ /**
117
+ * Fetch the base ref's remote branch so the new worktree starts from the
118
+ * latest pushed state. Best-effort: offline / unknown remote is non-fatal.
119
+ */
120
+ private fetchBaseRef;
121
+ /**
122
+ * Verify a base-ref candidate resolves on the remote (after fetching it).
123
+ * Returns the ref when it exists, else undefined so the caller falls back.
124
+ */
125
+ private verifyRemoteRef;
126
+ /**
127
+ * Ensure the branch's worktree + herdr workspace exist.
128
+ *
129
+ * `_identifier` is unused HERE: the worktree is located by branch, and since
130
+ * BB-237 the open layout — the one thing that consumed the identifier, to
131
+ * render `{identifier}` (BB-234) — is applied by {@link applyOpenLayout},
132
+ * which takes it on its own `ctx`. The parameter stays because it is the
133
+ * `BbWorkspace.ensure` contract position (the git workspace does use it).
134
+ * It is still taken as-passed rather than parsed back out of `branch`, which
135
+ * remains the reason the contract carries it at all.
136
+ */
137
+ ensure(_identifier: string, branch?: string, baseRefOverride?: string): Promise<EnsuredWorkspace>;
138
+ /**
139
+ * Apply the open layout to the root pane captured by the most recent
140
+ * {@link ensure} create for `path` — by default (BB-234) the ticket's
141
+ * `bakers ui` deep link, so the tab shows the ticket instead of a bare shell.
142
+ * The pane's cwd is the worktree checkout, passed explicitly on the splits
143
+ * (the root pane's cwd is already the checkout).
144
+ *
145
+ * BB-374 then renames the overview tab (`{identifier} · {title}` with title
146
+ * capped at {@link OPEN_TAB_TITLE_MAX}) and root pane (full
147
+ * `{identifier} · {title}`) — fresh-create only, same gate as the layout.
148
+ *
149
+ * NO stored pane ⇒ silent no-op. That is the reused-worktree case: `ensure`
150
+ * took the reuse branch, there is no fresh root pane, and re-applying would
151
+ * start a duplicate editor process on reattach (BB-139). The capture is
152
+ * one-shot — consumed here — so a repeat call cannot double-apply and the map
153
+ * cannot grow without bound.
154
+ *
155
+ * Best-effort — mirrors the `fetchBaseRef` pattern: a broken layout is logged
156
+ * and swallowed so a startup-command failure can NEVER wedge dispatch.
157
+ */
158
+ applyOpenLayout(path: string, ctx: {
159
+ identifier: string;
160
+ branch: string;
161
+ title: string;
162
+ }): Promise<void>;
163
+ /**
164
+ * Best-effort overview tab + pane renames (BB-374). Skipped for the
165
+ * `open: {}` opt-out. A missing create-time tab id falls back to `pane get`.
166
+ */
167
+ private applyOverviewLabels;
168
+ /** `pane get <id>` → `result.pane.tab_id` when present. */
169
+ private resolveTabIdForPane;
170
+ }
171
+ export declare function herdrWorkspace(opts?: {
172
+ /**
173
+ * Explicit repo-root override; wins over the git-derived anchor. Otherwise
174
+ * the main clone is derived from the git common dir (BB-177).
175
+ */
176
+ root?: string;
177
+ worktreeDir?: string;
178
+ baseBranch?: string;
179
+ open?: OpenLayout;
180
+ /** Overrideable git exec for root derivation (tests). */
181
+ execGit?: RootGitExec;
182
+ /** Overrideable herdr exec (tests) — sibling of `execGit`. */
183
+ execHerdr?: HerdrExec;
184
+ }): WorkspacePlugin;
185
+ export default herdrWorkspace;
@@ -0,0 +1,415 @@
1
+ import { resolve } from 'node:path';
2
+ import { loadInstructions } from '@bakers-build/addon-workspace-git';
3
+ import { exec } from '@bakers-build/core';
4
+ import { herdrAgentHost } from './agents.js';
5
+ import { renderTemplate } from './config.js';
6
+ import { applyPaneLayout, isRecord, parseJson } from './pane-layout.js';
7
+ import { deriveHerdrRoot } from './root-anchor.js';
8
+ /** Max `{title}` length substituted into the overview **tab** label (BB-374). */
9
+ export const OPEN_TAB_TITLE_MAX = 30;
10
+ /** Cap overview-tab `{title}` and append `...` when truncated. */
11
+ export function truncateOpenTabTitle(title) {
12
+ if (title.length <= OPEN_TAB_TITLE_MAX) {
13
+ return title;
14
+ }
15
+ return `${title.slice(0, OPEN_TAB_TITLE_MAX)}...`;
16
+ }
17
+ /** Default overview tab chrome (BB-374) — always-visible tab bar identity. */
18
+ export const DEFAULT_OPEN_TAB_LABEL = '{identifier} · {title}';
19
+ /** Default overview pane/panel label template (BB-374). */
20
+ export const DEFAULT_OPEN_PANEL_LABEL = '{identifier} · {title}';
21
+ /**
22
+ * The shipped default open layout (BB-234): a fresh worktree's initial pane
23
+ * renders the ticket it belongs to instead of an anonymous shell prompt.
24
+ *
25
+ * It lives in **code, not config**, because a default each repo has to opt into
26
+ * would leave the defect in place for every other consumer of this addon; the
27
+ * addon already ships opinionated defaults of this kind (`BUILTIN_DEFAULTS` in
28
+ * `./config.js`), and `bakers ui` is a command of the very host that ships it.
29
+ *
30
+ * `open: {}` is the documented opt-out back to the pre-BB-234 bare tab, so
31
+ * the default is not a one-way door.
32
+ */
33
+ export const DEFAULT_OPEN_LAYOUT = {
34
+ command: 'bakers ui --ticket {identifier}',
35
+ tabLabel: DEFAULT_OPEN_TAB_LABEL,
36
+ panelLabel: DEFAULT_OPEN_PANEL_LABEL,
37
+ };
38
+ const defaultExec = (args) => exec('herdr', args);
39
+ const defaultGitExec = (args) => exec('git', args);
40
+ const noopLogger = {
41
+ debug() { },
42
+ info() { },
43
+ warn() { },
44
+ error() { },
45
+ };
46
+ /** Parse `herdr worktree list --json` → array of on-disk worktrees. */
47
+ function parseWorktreeList(output) {
48
+ const parsed = parseJson(output, 'worktree list');
49
+ const worktrees = isRecord(parsed)
50
+ ? parsed.result?.worktrees
51
+ : undefined;
52
+ if (!Array.isArray(worktrees)) {
53
+ throw new Error('herdr worktree list returned invalid schema');
54
+ }
55
+ return worktrees
56
+ .filter(isRecord)
57
+ .filter((w) => typeof w.branch === 'string' && typeof w.path === 'string')
58
+ .map((w) => ({
59
+ branch: w.branch,
60
+ path: w.path,
61
+ ...(typeof w.open_workspace_id === 'string'
62
+ ? { open_workspace_id: w.open_workspace_id }
63
+ : {}),
64
+ }));
65
+ }
66
+ /** Parse `worktree create`/`worktree open` → the created/opened worktree path. */
67
+ function parseWorktreePath(output, command) {
68
+ const parsed = parseJson(output, command);
69
+ const path = isRecord(parsed)
70
+ ? parsed.result?.worktree
71
+ ?.path
72
+ : undefined;
73
+ if (typeof path !== 'string') {
74
+ throw new Error(`herdr ${command} returned invalid schema`);
75
+ }
76
+ return path;
77
+ }
78
+ /**
79
+ * Parse `worktree create` → root pane id (+ optional tab id for BB-374
80
+ * overview tab rename). Sibling of {@link parseWorktreePath}. Returns null when
81
+ * the create JSON carries no pane id, so the open-layout caller can skip
82
+ * silently rather than throw (best-effort).
83
+ */
84
+ function parseWorktreeRootPane(output) {
85
+ const parsed = parseJson(output, 'worktree create');
86
+ if (!isRecord(parsed) || !isRecord(parsed.result))
87
+ return null;
88
+ const result = parsed.result;
89
+ const paneId = result.root_pane?.pane_id;
90
+ if (typeof paneId !== 'string' || !paneId)
91
+ return null;
92
+ const tabIdRaw = result.root_pane?.tab_id ?? result.tab?.tab_id;
93
+ const tabId = typeof tabIdRaw === 'string' && tabIdRaw ? tabIdRaw : undefined;
94
+ return tabId !== undefined ? { paneId, tabId } : { paneId };
95
+ }
96
+ /** Git-safe branch validation: safe ref chars only, no leading dot, no `..`. */
97
+ function validateBranch(branch) {
98
+ if (!/^[A-Za-z0-9._/-]+$/.test(branch) ||
99
+ branch.startsWith('.') ||
100
+ branch.includes('..')) {
101
+ throw new Error(`invalid workspace branch: ${branch}`);
102
+ }
103
+ }
104
+ export class HerdrWorkspace {
105
+ options;
106
+ execHerdr;
107
+ execGit;
108
+ root;
109
+ worktreeDir;
110
+ open;
111
+ logger;
112
+ /**
113
+ * Root panes captured by a FRESH `ensure` create, keyed by worktree path,
114
+ * awaiting {@link applyOpenLayout} (BB-237). One-shot: consumed on apply.
115
+ * Optional `tabId` powers the BB-374 overview tab rename.
116
+ */
117
+ pendingRootPanes = new Map();
118
+ /** Herdr-backed intent-level agent host for `bakers ui` (BB-190). */
119
+ agentHost;
120
+ constructor(options) {
121
+ this.options = options;
122
+ this.execHerdr = options.execHerdr ?? defaultExec;
123
+ this.execGit = options.execGit ?? defaultGitExec;
124
+ this.root = resolve(options.root);
125
+ this.worktreeDir = options.worktreeDir ?? '.bakers-worktrees';
126
+ // `??`, not `||`: an explicit `open: {}` must survive as the opt-out.
127
+ this.open = options.open ?? DEFAULT_OPEN_LAYOUT;
128
+ this.logger = options.logger ?? noopLogger;
129
+ this.agentHost = herdrAgentHost(this.execHerdr);
130
+ }
131
+ /**
132
+ * Resolve the base ref for a new worktree branch. Prefers the configured
133
+ * `baseBranch`; otherwise the remote tracking branch of the repo's HEAD
134
+ * (e.g. `origin/develop`). Best-effort: a repo with no upstream returns
135
+ * `undefined`, leaving herdr to fall back to the parent workspace HEAD.
136
+ */
137
+ async resolveBaseRef() {
138
+ if (this.options.baseBranch) {
139
+ return this.options.baseBranch;
140
+ }
141
+ try {
142
+ const ref = (await this.execGit([
143
+ '-C',
144
+ this.root,
145
+ 'rev-parse',
146
+ '--abbrev-ref',
147
+ '--symbolic-full-name',
148
+ '@{u}',
149
+ ])).trim();
150
+ return ref || undefined;
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ }
156
+ /**
157
+ * Fetch the base ref's remote branch so the new worktree starts from the
158
+ * latest pushed state. Best-effort: offline / unknown remote is non-fatal.
159
+ */
160
+ async fetchBaseRef(baseRef) {
161
+ const slash = baseRef.indexOf('/');
162
+ if (slash <= 0) {
163
+ return;
164
+ }
165
+ const remote = baseRef.slice(0, slash);
166
+ const branch = baseRef.slice(slash + 1);
167
+ try {
168
+ await this.execGit(['-C', this.root, 'fetch', remote, branch]);
169
+ }
170
+ catch {
171
+ // Non-fatal: use whatever the local tracking ref already points at.
172
+ }
173
+ }
174
+ /**
175
+ * Verify a base-ref candidate resolves on the remote (after fetching it).
176
+ * Returns the ref when it exists, else undefined so the caller falls back.
177
+ */
178
+ async verifyRemoteRef(ref) {
179
+ await this.fetchBaseRef(ref);
180
+ try {
181
+ await this.execGit([
182
+ '-C',
183
+ this.root,
184
+ 'rev-parse',
185
+ '--verify',
186
+ '--quiet',
187
+ `${ref}^{commit}`,
188
+ ]);
189
+ return ref;
190
+ }
191
+ catch {
192
+ return undefined;
193
+ }
194
+ }
195
+ /**
196
+ * Ensure the branch's worktree + herdr workspace exist.
197
+ *
198
+ * `_identifier` is unused HERE: the worktree is located by branch, and since
199
+ * BB-237 the open layout — the one thing that consumed the identifier, to
200
+ * render `{identifier}` (BB-234) — is applied by {@link applyOpenLayout},
201
+ * which takes it on its own `ctx`. The parameter stays because it is the
202
+ * `BbWorkspace.ensure` contract position (the git workspace does use it).
203
+ * It is still taken as-passed rather than parsed back out of `branch`, which
204
+ * remains the reason the contract carries it at all.
205
+ */
206
+ async ensure(_identifier, branch, baseRefOverride) {
207
+ if (branch === undefined) {
208
+ throw new Error('herdr workspace requires a branch name');
209
+ }
210
+ validateBranch(branch);
211
+ const worktrees = parseWorktreeList(await this.execHerdr(['worktree', 'list', '--cwd', this.root, '--json']));
212
+ const existing = worktrees.find((w) => w.branch === branch);
213
+ if (existing) {
214
+ // Reuse: an existing worktree is never re-created, so created=false — the
215
+ // core skips after_create only when a success is recorded for it. The open layout is NOT
216
+ // re-applied on reuse (BB-139): no duplicate editor process on reattach.
217
+ let path = existing.path;
218
+ if (!existing.open_workspace_id) {
219
+ // On-disk worktree with no open herdr workspace — open it.
220
+ path = parseWorktreePath(await this.execHerdr([
221
+ 'worktree',
222
+ 'open',
223
+ '--cwd',
224
+ this.root,
225
+ '--branch',
226
+ branch,
227
+ '--no-focus',
228
+ '--json',
229
+ ]), 'worktree open');
230
+ }
231
+ return { path, instructions: loadInstructions(path), created: false };
232
+ }
233
+ // Fresh: create the git worktree + herdr workspace in one call. Base the
234
+ // new branch on the (freshly fetched) remote tracking branch, not the
235
+ // host's local checked-out branch. A ticket passes its `origin/<base_branch>`
236
+ // as the override; use it only when it resolves on the remote, else fall
237
+ // back to the default base.
238
+ let baseRef;
239
+ if (baseRefOverride !== undefined) {
240
+ baseRef = await this.verifyRemoteRef(baseRefOverride);
241
+ }
242
+ if (baseRef === undefined) {
243
+ baseRef = await this.resolveBaseRef();
244
+ if (baseRef !== undefined) {
245
+ await this.fetchBaseRef(baseRef);
246
+ }
247
+ }
248
+ const createOutput = await this.execHerdr([
249
+ 'worktree',
250
+ 'create',
251
+ '--cwd',
252
+ this.root,
253
+ '--branch',
254
+ branch,
255
+ ...(baseRef !== undefined ? ['--base', baseRef] : []),
256
+ '--label',
257
+ branch,
258
+ // ABSOLUTE, never relative: herdr canonicalizes a relative `--path`
259
+ // against its own `current_dir()`, which fails with
260
+ // `Os { code: 2, kind: NotFound }` whenever the CALLER's cwd has been
261
+ // deleted — e.g. a conductor still polling from a reaped worktree. That
262
+ // killed every dispatch with an unattributable herdr exit 1. `resolve`
263
+ // (not `join`) so an absolute `worktreeDir` override still wins.
264
+ '--path',
265
+ resolve(this.root, this.worktreeDir, branch),
266
+ '--no-focus',
267
+ '--json',
268
+ ]);
269
+ const path = parseWorktreePath(createOutput, 'worktree create');
270
+ // BB-237: CAPTURE the fresh root pane here, but do NOT lay it out yet.
271
+ // The layout's startup command names the bakers CLI, which the `after_create`
272
+ // hook builds — and that hook runs after `ensure()` returns. Applying the
273
+ // layout here fired the command before its binary existed, which is why the
274
+ // pane command needed a sentinel wait loop. The conductor now calls
275
+ // `applyOpenLayout` after the hook; a create with no root pane in its output
276
+ // stores nothing, so the layout is a no-op exactly as before.
277
+ const pending = parseWorktreeRootPane(createOutput);
278
+ if (pending) {
279
+ this.pendingRootPanes.set(path, pending);
280
+ }
281
+ // The `after_create` hook is run by the executor (BB-84), not here — the
282
+ // workspace only reports that it created a fresh worktree.
283
+ return { path, instructions: loadInstructions(path), created: true };
284
+ }
285
+ /**
286
+ * Apply the open layout to the root pane captured by the most recent
287
+ * {@link ensure} create for `path` — by default (BB-234) the ticket's
288
+ * `bakers ui` deep link, so the tab shows the ticket instead of a bare shell.
289
+ * The pane's cwd is the worktree checkout, passed explicitly on the splits
290
+ * (the root pane's cwd is already the checkout).
291
+ *
292
+ * BB-374 then renames the overview tab (`{identifier} · {title}` with title
293
+ * capped at {@link OPEN_TAB_TITLE_MAX}) and root pane (full
294
+ * `{identifier} · {title}`) — fresh-create only, same gate as the layout.
295
+ *
296
+ * NO stored pane ⇒ silent no-op. That is the reused-worktree case: `ensure`
297
+ * took the reuse branch, there is no fresh root pane, and re-applying would
298
+ * start a duplicate editor process on reattach (BB-139). The capture is
299
+ * one-shot — consumed here — so a repeat call cannot double-apply and the map
300
+ * cannot grow without bound.
301
+ *
302
+ * Best-effort — mirrors the `fetchBaseRef` pattern: a broken layout is logged
303
+ * and swallowed so a startup-command failure can NEVER wedge dispatch.
304
+ */
305
+ async applyOpenLayout(path, ctx) {
306
+ const pending = this.pendingRootPanes.get(path);
307
+ if (!pending) {
308
+ return;
309
+ }
310
+ this.pendingRootPanes.delete(path);
311
+ const { paneId: rootPaneId } = pending;
312
+ try {
313
+ // Render the ROOT command here (spec D2). `applyPaneLayout` types its
314
+ // root command verbatim for the executor's brace-bearing env-prefixed
315
+ // agent command, so templating it there would corrupt that; the split
316
+ // commands it already renders from the same `vars`.
317
+ const vars = {
318
+ identifier: ctx.identifier,
319
+ branch: ctx.branch,
320
+ title: ctx.title,
321
+ worktreePath: path,
322
+ };
323
+ const layout = {
324
+ ...this.open,
325
+ ...(this.open.command !== undefined
326
+ ? { command: renderTemplate(this.open.command, vars) }
327
+ : {}),
328
+ };
329
+ await applyPaneLayout(this.execHerdr, rootPaneId, layout, path, vars);
330
+ await this.applyOverviewLabels(rootPaneId, pending.tabId, vars);
331
+ }
332
+ catch (err) {
333
+ this.logger.warn({ branch: ctx.branch, worktree: path, err: String(err) }, 'herdr open-layout failed (best-effort, ignored)');
334
+ }
335
+ }
336
+ /**
337
+ * Best-effort overview tab + pane renames (BB-374). Skipped for the
338
+ * `open: {}` opt-out. A missing create-time tab id falls back to `pane get`.
339
+ */
340
+ async applyOverviewLabels(rootPaneId, createTabId, vars) {
341
+ const emptyOptOut = this.open.command === undefined &&
342
+ !this.open.panes?.length &&
343
+ this.open.tabLabel === undefined &&
344
+ this.open.panelLabel === undefined;
345
+ if (emptyOptOut) {
346
+ return;
347
+ }
348
+ // Tab bar is always visible; cap title length and mark truncation with
349
+ // `...`. Pane/panel label keeps the full title for pane borders.
350
+ const tabVars = {
351
+ ...vars,
352
+ title: truncateOpenTabTitle(vars.title ?? ''),
353
+ };
354
+ const tabLabel = renderTemplate(this.open.tabLabel ?? DEFAULT_OPEN_TAB_LABEL, tabVars);
355
+ const panelLabel = renderTemplate(this.open.panelLabel ?? DEFAULT_OPEN_PANEL_LABEL, vars);
356
+ let tabId = createTabId;
357
+ if (!tabId) {
358
+ try {
359
+ tabId = (await this.resolveTabIdForPane(rootPaneId)) ?? undefined;
360
+ }
361
+ catch (err) {
362
+ this.logger.warn({ paneId: rootPaneId, err: String(err) }, 'herdr overview tab id lookup failed (best-effort, ignored)');
363
+ }
364
+ }
365
+ if (tabId) {
366
+ try {
367
+ await this.execHerdr(['tab', 'rename', tabId, tabLabel]);
368
+ }
369
+ catch (err) {
370
+ this.logger.warn({ tabId, err: String(err) }, 'herdr overview tab rename failed (best-effort, ignored)');
371
+ }
372
+ }
373
+ try {
374
+ await this.execHerdr(['pane', 'rename', rootPaneId, panelLabel]);
375
+ }
376
+ catch (err) {
377
+ this.logger.warn({ paneId: rootPaneId, err: String(err) }, 'herdr overview pane rename failed (best-effort, ignored)');
378
+ }
379
+ }
380
+ /** `pane get <id>` → `result.pane.tab_id` when present. */
381
+ async resolveTabIdForPane(paneId) {
382
+ const output = await this.execHerdr(['pane', 'get', paneId, '--json']);
383
+ const parsed = parseJson(output, 'pane get');
384
+ const tabId = isRecord(parsed)
385
+ ? parsed.result?.pane
386
+ ?.tab_id
387
+ : undefined;
388
+ return typeof tabId === 'string' && tabId ? tabId : null;
389
+ }
390
+ }
391
+ export function herdrWorkspace(opts = {}) {
392
+ return {
393
+ kind: 'workspace',
394
+ id: 'herdr',
395
+ requiredModules: [],
396
+ // BB-234: `deps` carries the conductor logger, so the best-effort
397
+ // open-layout failure is actually logged instead of vanishing into the noop
398
+ // logger. Optional per the contract — absent, the noop default stays.
399
+ async createWorkspace(config, deps) {
400
+ const root = await deriveHerdrRoot(config.config_path, {
401
+ ...(opts.root !== undefined ? { override: opts.root } : {}),
402
+ ...(opts.execGit ? { execGit: opts.execGit } : {}),
403
+ });
404
+ return new HerdrWorkspace({
405
+ root,
406
+ ...(opts.worktreeDir ? { worktreeDir: opts.worktreeDir } : {}),
407
+ ...(opts.baseBranch ? { baseBranch: opts.baseBranch } : {}),
408
+ ...(opts.open ? { open: opts.open } : {}),
409
+ ...(opts.execHerdr ? { execHerdr: opts.execHerdr } : {}),
410
+ ...(deps?.logger ? { logger: deps.logger } : {}),
411
+ });
412
+ },
413
+ };
414
+ }
415
+ export default herdrWorkspace;
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-herdr",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-next.42",
4
+ "description": "bakers.build 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/bakers-build/bakers-build.git",
20
+ "directory": "bakers-cli/addons/herdr"
21
+ },
22
+ "dependencies": {
23
+ "@bakers-build/addon-workspace-git": "^0.1.0-next.42"
24
+ },
25
+ "peerDependencies": {
26
+ "@bakers-build/conductor": "^0.1.0-next.42",
27
+ "@bakers-build/core": "^0.1.0-next.42"
28
+ }
29
+ }