@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.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/src/agent-host.d.ts +45 -0
- package/dist/src/agent-host.js +9 -0
- package/dist/src/agents.d.ts +3 -0
- package/dist/src/agents.js +111 -0
- package/dist/src/config.d.ts +55 -0
- package/dist/src/config.js +27 -0
- package/dist/src/fs.d.ts +22 -0
- package/dist/src/fs.js +58 -0
- package/dist/src/index.d.ts +172 -0
- package/dist/src/index.js +708 -0
- package/dist/src/pane-layout.d.ts +37 -0
- package/dist/src/pane-layout.js +69 -0
- package/dist/src/panes.d.ts +19 -0
- package/dist/src/panes.js +73 -0
- package/dist/src/preset.d.ts +3 -0
- package/dist/src/preset.js +9 -0
- package/dist/src/root-anchor.d.ts +26 -0
- package/dist/src/root-anchor.js +40 -0
- package/dist/src/workspace.d.ts +85 -0
- package/dist/src/workspace.js +261 -0
- package/package.json +29 -0
|
@@ -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,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
|
+
}
|