@mcuste/pi-herdr-worktree 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ import { normalize } from "node:path";
2
+ import { runChecked } from "./process.js";
3
+ function comparablePath(value) {
4
+ return normalize(value).replace(/\/+$/u, "");
5
+ }
6
+ /**
7
+ * Reads the worktrees Git itself knows about. Herdr reports what it did; this is the
8
+ * independent check that the repository really changed the same way.
9
+ *
10
+ * `-z` ends every record with a NUL, so a path that contains a newline cannot pretend to be
11
+ * another record. It needs Git 2.36 or newer.
12
+ */
13
+ export async function readGitWorktrees(runner, cwd, signal) {
14
+ const result = await runChecked(runner, "git", ["worktree", "list", "--porcelain", "-z"], { cwd, signal }, "Unable to list the Git worktrees");
15
+ const entries = [];
16
+ let path;
17
+ let head = null;
18
+ let branch = null;
19
+ const flush = () => {
20
+ if (path) {
21
+ entries.push({ path: comparablePath(path), head, branch });
22
+ }
23
+ path = undefined;
24
+ head = null;
25
+ branch = null;
26
+ };
27
+ // An unrecognised record, such as `bare` or `locked`, is ignored rather than read as a path.
28
+ for (const record of result.stdout.split("\0")) {
29
+ if (record.startsWith("worktree ")) {
30
+ flush();
31
+ path = record.slice("worktree ".length);
32
+ }
33
+ else if (record.startsWith("HEAD ")) {
34
+ head = record.slice("HEAD ".length);
35
+ }
36
+ else if (record.startsWith("branch refs/heads/")) {
37
+ branch = record.slice("branch refs/heads/".length);
38
+ }
39
+ }
40
+ flush();
41
+ return entries;
42
+ }
43
+ export function findGitWorktree(entries, path) {
44
+ const wanted = comparablePath(path);
45
+ return entries.find((entry) => entry.path === wanted);
46
+ }
47
+ //# sourceMappingURL=worktree.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktree.js","sourceRoot":"","sources":["../src/worktree.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAsB,UAAU,EAAE,MAAM,cAAc,CAAC;AAQ9D,SAAS,cAAc,CAAC,KAAa;IACnC,OAAO,SAAS,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;AAC/C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,MAAqB,EACrB,GAAW,EACX,MAA+B;IAE/B,MAAM,MAAM,GAAG,MAAM,UAAU,CAC7B,MAAM,EACN,KAAK,EACL,CAAC,UAAU,EAAE,MAAM,EAAE,aAAa,EAAE,IAAI,CAAC,EACzC,EAAE,GAAG,EAAE,MAAM,EAAE,EACf,kCAAkC,CACnC,CAAC;IAEF,MAAM,OAAO,GAAuB,EAAE,CAAC;IACvC,IAAI,IAAwB,CAAC;IAC7B,IAAI,IAAI,GAAkB,IAAI,CAAC;IAC/B,IAAI,MAAM,GAAkB,IAAI,CAAC;IAEjC,MAAM,KAAK,GAAG,GAAG,EAAE;QACjB,IAAI,IAAI,EAAE,CAAC;YACT,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,cAAc,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;QAC7D,CAAC;QACD,IAAI,GAAG,SAAS,CAAC;QACjB,IAAI,GAAG,IAAI,CAAC;QACZ,MAAM,GAAG,IAAI,CAAC;IAChB,CAAC,CAAC;IAEF,6FAA6F;IAC7F,KAAK,MAAM,MAAM,IAAI,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/C,IAAI,MAAM,CAAC,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC;YACnC,KAAK,EAAE,CAAC;YACR,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QAC1C,CAAC;aAAM,IAAI,MAAM,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;YACtC,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QACtC,CAAC;aAAM,IAAI,MAAM,CAAC,UAAU,CAAC,oBAAoB,CAAC,EAAE,CAAC;YACnD,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,oBAAoB,CAAC,MAAM,CAAC,CAAC;QACrD,CAAC;IACH,CAAC;IACD,KAAK,EAAE,CAAC;IACR,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,MAAM,UAAU,eAAe,CAC7B,OAAoC,EACpC,IAAY;IAEZ,MAAM,MAAM,GAAG,cAAc,CAAC,IAAI,CAAC,CAAC;IACpC,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC;AACxD,CAAC"}
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "@mcuste/pi-herdr-worktree",
3
+ "version": "0.1.0",
4
+ "description": "Herdr Git worktree workspaces as one safe, model-callable tool for the Pi and Oh My Pi coding agents",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "oh-my-pi",
9
+ "omp",
10
+ "extension",
11
+ "herdr",
12
+ "worktree",
13
+ "git-worktree",
14
+ "workspace",
15
+ "terminal-multiplexer"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "mcuste",
19
+ "homepage": "https://github.com/mcuste/pi-herdr-worktree#readme",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/mcuste/pi-herdr-worktree.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/mcuste/pi-herdr-worktree/issues"
26
+ },
27
+ "type": "module",
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
31
+ "exports": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "files": [
34
+ "dist",
35
+ "src",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "peerDependencies": {
43
+ "typebox": "*"
44
+ },
45
+ "devDependencies": {
46
+ "@biomejs/biome": "2.5.9",
47
+ "@types/node": "^26.2.0",
48
+ "knip": "6.32.2",
49
+ "publint": "0.3.23",
50
+ "typebox": "^1.3.15",
51
+ "typescript": "^7.0.2"
52
+ },
53
+ "knip": {
54
+ "entry": [
55
+ "test/**/*.test.mjs",
56
+ "test/**/*.e2e.mjs",
57
+ "scripts/*.mjs"
58
+ ],
59
+ "project": [
60
+ "src/**/*.ts",
61
+ "test/**/*.mjs",
62
+ "scripts/*.mjs"
63
+ ]
64
+ },
65
+ "pi": {
66
+ "extensions": [
67
+ "./src/index.ts"
68
+ ]
69
+ },
70
+ "omp": {
71
+ "extensions": [
72
+ "./src/index.ts"
73
+ ]
74
+ },
75
+ "scripts": {
76
+ "build": "tsc -p tsconfig.json",
77
+ "check": "pnpm quality && pnpm package:check && pnpm test && pnpm test:integration && pnpm deadcode && pnpm security",
78
+ "deadcode": "knip",
79
+ "fix": "biome check --write .",
80
+ "format": "biome format --write .",
81
+ "lint": "biome lint .",
82
+ "package:check": "pnpm build && publint",
83
+ "quality": "biome check .",
84
+ "release": "node scripts/release.mjs",
85
+ "security": "pnpm audit --audit-level high",
86
+ "test": "pnpm build && node --test test/*.test.mjs",
87
+ "test:integration": "pnpm build && node --test test/*.e2e.mjs"
88
+ }
89
+ }
@@ -0,0 +1,183 @@
1
+ import { isAbsolute, normalize } from "node:path";
2
+ import {
3
+ CommandCancelledError,
4
+ type CommandResult,
5
+ type CommandRunner,
6
+ runChecked,
7
+ } from "./process.js";
8
+
9
+ export const MAX_BRANCH_NAME_LENGTH = 255;
10
+ export const MAX_PATH_LENGTH = 4096;
11
+ export const MAX_LABEL_LENGTH = 120;
12
+
13
+ /** The bounds come from the pattern, so the schema advertises exactly what this accepts. */
14
+ const WORKSPACE_ID = /^w[0-9A-Za-z]{1,16}$/u;
15
+ export const MIN_WORKSPACE_ID_LENGTH = 2;
16
+ export const MAX_WORKSPACE_ID_LENGTH = 17;
17
+
18
+ declare const branchNameBrand: unique symbol;
19
+ declare const revisionBrand: unique symbol;
20
+ declare const commitIdBrand: unique symbol;
21
+ declare const absolutePathBrand: unique symbol;
22
+ declare const labelBrand: unique symbol;
23
+ declare const workspaceIdBrand: unique symbol;
24
+
25
+ /** The branch name `git check-ref-format --branch` printed, so shorthand is already expanded. */
26
+ export type ParsedBranchName = string & { readonly [branchNameBrand]: true };
27
+
28
+ /** A revision that `git rev-parse --verify` resolved to a commit. */
29
+ export type ParsedRevision = string & { readonly [revisionBrand]: true };
30
+
31
+ /** A full object id that `git rev-parse` printed. */
32
+ type ParsedCommitId = string & { readonly [commitIdBrand]: true };
33
+
34
+ export type ParsedAbsolutePath = string & { readonly [absolutePathBrand]: true };
35
+
36
+ export type ParsedLabel = string & { readonly [labelBrand]: true };
37
+
38
+ export type ParsedWorkspaceId = string & { readonly [workspaceIdBrand]: true };
39
+
40
+ /**
41
+ * Every value that may become a command argument. A plain string is not a member, so unparsed
42
+ * text cannot reach a spawned command.
43
+ */
44
+ export type ParsedArgumentValue =
45
+ | ParsedAbsolutePath
46
+ | ParsedBranchName
47
+ | ParsedLabel
48
+ | ParsedRevision
49
+ | ParsedWorkspaceId;
50
+
51
+ /** A revision, together with the commit it pointed at when it was parsed. */
52
+ export interface ParsedBase {
53
+ readonly revision: ParsedRevision;
54
+ readonly commit: ParsedCommitId;
55
+ }
56
+
57
+ /** Bidirectional marks make text display as other text, and a newline forges a line of output. */
58
+ const UNSAFE_CHARACTERS = /[\p{Cc}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/u;
59
+
60
+ const COMMIT_ID = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u;
61
+
62
+ /**
63
+ * Rejects values that could turn into a command-line option, split an argument, or forge a line
64
+ * of output before Git or Herdr ever sees them.
65
+ */
66
+ function hasSafeArgumentSyntax(value: unknown, maxLength: number): value is string {
67
+ return (
68
+ typeof value === "string" &&
69
+ value.length > 0 &&
70
+ value.length <= maxLength &&
71
+ value === value.trim() &&
72
+ !value.startsWith("-") &&
73
+ !UNSAFE_CHARACTERS.test(value)
74
+ );
75
+ }
76
+
77
+ /** The runtime re-check of what the parsed types already promise, for the gate before a spawn. */
78
+ export function hasParsedArgumentSyntax(value: unknown): value is string {
79
+ return hasSafeArgumentSyntax(value, MAX_PATH_LENGTH);
80
+ }
81
+
82
+ export async function parseBranchName(
83
+ runner: CommandRunner,
84
+ value: unknown,
85
+ label: string,
86
+ cwd: string,
87
+ signal: AbortSignal | undefined,
88
+ ): Promise<ParsedBranchName> {
89
+ if (!hasSafeArgumentSyntax(value, MAX_BRANCH_NAME_LENGTH)) {
90
+ throw new Error(`${label} is not a safe branch name: ${JSON.stringify(value)}.`);
91
+ }
92
+
93
+ let result: CommandResult;
94
+ try {
95
+ result = await runChecked(
96
+ runner,
97
+ "git",
98
+ ["check-ref-format", "--branch", value],
99
+ { cwd, signal },
100
+ `${label} is not a valid Git branch name`,
101
+ );
102
+ } catch (error) {
103
+ if (error instanceof CommandCancelledError) {
104
+ throw error;
105
+ }
106
+ throw new Error(`${label} is not a valid Git branch name: ${JSON.stringify(value)}.`, {
107
+ cause: error,
108
+ });
109
+ }
110
+
111
+ // `--branch` expands shorthand such as `@{-1}`, so only the printed name names the real branch.
112
+ const expanded = result.stdout.trim();
113
+ if (!hasSafeArgumentSyntax(expanded, MAX_BRANCH_NAME_LENGTH)) {
114
+ throw new Error(`${label} did not expand to a safe branch name: ${JSON.stringify(value)}.`);
115
+ }
116
+ return expanded as ParsedBranchName;
117
+ }
118
+
119
+ /** A base for a new worktree only has to resolve to a commit, so tags and SHAs are allowed. */
120
+ export async function parseBase(
121
+ runner: CommandRunner,
122
+ value: unknown,
123
+ label: string,
124
+ cwd: string,
125
+ signal: AbortSignal | undefined,
126
+ ): Promise<ParsedBase> {
127
+ if (!hasSafeArgumentSyntax(value, MAX_BRANCH_NAME_LENGTH)) {
128
+ throw new Error(`${label} is not a safe Git revision: ${JSON.stringify(value)}.`);
129
+ }
130
+
131
+ let result: CommandResult;
132
+ try {
133
+ result = await runChecked(
134
+ runner,
135
+ "git",
136
+ ["rev-parse", "--verify", "--quiet", `${value}^{commit}`],
137
+ { cwd, signal },
138
+ `${label} does not resolve to a commit`,
139
+ );
140
+ } catch (error) {
141
+ if (error instanceof CommandCancelledError) {
142
+ throw error;
143
+ }
144
+ throw new Error(`${label} does not resolve to a commit: ${JSON.stringify(value)}.`, {
145
+ cause: error,
146
+ });
147
+ }
148
+
149
+ const commit = result.stdout.trim();
150
+ if (!COMMIT_ID.test(commit)) {
151
+ throw new Error(`${label} did not resolve to a commit id: ${JSON.stringify(value)}.`);
152
+ }
153
+ return { revision: value as ParsedRevision, commit: commit as ParsedCommitId };
154
+ }
155
+
156
+ export function parseAbsolutePath(value: unknown, label: string): ParsedAbsolutePath {
157
+ if (!hasSafeArgumentSyntax(value, MAX_PATH_LENGTH)) {
158
+ throw new Error(`${label} is not a safe path: ${JSON.stringify(value)}.`);
159
+ }
160
+ if (!isAbsolute(value)) {
161
+ throw new Error(`${label} must be an absolute path: ${JSON.stringify(value)}.`);
162
+ }
163
+ const normalized = normalize(value).replace(/\/+$/u, "");
164
+ if (!normalized || !isAbsolute(normalized)) {
165
+ throw new Error(`${label} does not normalise to an absolute path: ${JSON.stringify(value)}.`);
166
+ }
167
+ return normalized as ParsedAbsolutePath;
168
+ }
169
+
170
+ /** A label is display text, so it only has to stay a single argument on one line. */
171
+ export function parseLabel(value: unknown, label: string): ParsedLabel {
172
+ if (!hasSafeArgumentSyntax(value, MAX_LABEL_LENGTH)) {
173
+ throw new Error(`${label} must be single-line text without leading options.`);
174
+ }
175
+ return value as ParsedLabel;
176
+ }
177
+
178
+ export function parseWorkspaceId(value: unknown, label: string): ParsedWorkspaceId {
179
+ if (!hasSafeArgumentSyntax(value, MAX_WORKSPACE_ID_LENGTH) || !WORKSPACE_ID.test(value)) {
180
+ throw new Error(`${label} is not a Herdr workspace id: ${JSON.stringify(value)}.`);
181
+ }
182
+ return value as ParsedWorkspaceId;
183
+ }
@@ -0,0 +1,223 @@
1
+ import { resolve } from "node:path";
2
+ import { type ParsedAbsolutePath, parseAbsolutePath } from "./argument.js";
3
+ import {
4
+ CommandCancelledError,
5
+ type CommandResult,
6
+ type CommandRunner,
7
+ runChecked,
8
+ runCommand,
9
+ } from "./process.js";
10
+
11
+ /** Herdr injects this into every managed pane. Without it there is no session to talk to. */
12
+ const SESSION_MARKER = "HERDR_ENV";
13
+
14
+ const MINIMUM_HERDR_VERSION = [0, 8, 2] as const;
15
+ const CAPABILITY_MAX_AGE_MS = 10 * 60 * 1000;
16
+ const UNAVAILABLE_RETRY_MS = 5 * 60 * 1000;
17
+
18
+ export interface GitRepository {
19
+ readonly root: ParsedAbsolutePath;
20
+ readonly gitDir: ParsedAbsolutePath;
21
+ }
22
+
23
+ export interface HerdrCapability {
24
+ readonly repository: GitRepository;
25
+ readonly herdrVersion: string;
26
+ readonly cache: "memory" | "detected";
27
+ }
28
+
29
+ interface RememberedCapability {
30
+ readonly capability: Omit<HerdrCapability, "cache">;
31
+ readonly expiresAt: number;
32
+ }
33
+
34
+ export class HerdrUnavailableError extends Error {
35
+ constructor(message: string, options?: ErrorOptions) {
36
+ super(message, options);
37
+ this.name = "HerdrUnavailableError";
38
+ }
39
+ }
40
+
41
+ function isAtLeast(installed: readonly number[], minimum: readonly number[]): boolean {
42
+ for (const [index, required] of minimum.entries()) {
43
+ const part = installed[index] ?? 0;
44
+ if (part !== required) {
45
+ return part > required;
46
+ }
47
+ }
48
+ return true;
49
+ }
50
+
51
+ function parseHerdrVersion(result: CommandResult): string {
52
+ const raw = result.stdout.trim() || result.stderr.trim();
53
+ const match = /^(?:herdr )?(\d+)\.(\d+)\.(\d+)/u.exec(raw);
54
+ if (!match) {
55
+ throw new HerdrUnavailableError(
56
+ `Unable to read the Herdr version from ${JSON.stringify(raw)}.`,
57
+ );
58
+ }
59
+ const parts = match.slice(1, 4).map(Number);
60
+ if (!isAtLeast(parts, MINIMUM_HERDR_VERSION)) {
61
+ throw new HerdrUnavailableError(
62
+ `Herdr ${MINIMUM_HERDR_VERSION.join(".")} or newer is required, but ${parts.join(".")} is installed.`,
63
+ );
64
+ }
65
+ return parts.join(".");
66
+ }
67
+
68
+ /**
69
+ * The capability is a property of the running session, not of the repository, so it is only
70
+ * held in memory. Nothing is written to the repository.
71
+ */
72
+ export class HerdrCapabilityResolver {
73
+ readonly #runner: CommandRunner;
74
+ readonly #environment: Readonly<Record<string, string | undefined>>;
75
+ readonly #now: () => number;
76
+ readonly #memory = new Map<string, RememberedCapability>();
77
+ readonly #unavailable = new Map<string, number>();
78
+
79
+ constructor(
80
+ runner: CommandRunner = runCommand,
81
+ environment: Readonly<Record<string, string | undefined>> = process.env,
82
+ now: () => number = Date.now,
83
+ ) {
84
+ this.#runner = runner;
85
+ this.#environment = environment;
86
+ this.#now = now;
87
+ }
88
+
89
+ async ensure(cwd: string, signal?: AbortSignal): Promise<HerdrCapability> {
90
+ const lookupKey = resolve(cwd);
91
+ const remembered = this.#memory.get(lookupKey);
92
+ if (remembered && remembered.expiresAt > this.#now()) {
93
+ return { ...remembered.capability, cache: "memory" };
94
+ }
95
+ this.#memory.delete(lookupKey);
96
+
97
+ this.#assertManagedSession();
98
+ const repository = await this.#findRepository(cwd, signal);
99
+ const herdrVersion = await this.#detectHerdr(repository, signal);
100
+ const capability = { repository, herdrVersion };
101
+ this.#remember(lookupKey, capability);
102
+ return { ...capability, cache: "detected" };
103
+ }
104
+
105
+ /**
106
+ * Never throws, unlike `ensure`. A failed detection is cached for a short time, so a caller that
107
+ * runs on every agent start does not spawn `git` and `herdr` every turn.
108
+ */
109
+ async tryEnsure(cwd: string, signal?: AbortSignal): Promise<HerdrCapability | undefined> {
110
+ // The marker comes from the process environment, so the answer cannot change while it runs.
111
+ if (!this.#isManagedSession()) {
112
+ return undefined;
113
+ }
114
+
115
+ const lookupKey = resolve(cwd);
116
+ const retryAt = this.#unavailable.get(lookupKey);
117
+ if (retryAt !== undefined && retryAt > this.#now()) {
118
+ return undefined;
119
+ }
120
+ this.#unavailable.delete(lookupKey);
121
+
122
+ try {
123
+ return await this.ensure(cwd, signal);
124
+ } catch (error) {
125
+ if (error instanceof HerdrUnavailableError) {
126
+ this.#unavailable.set(lookupKey, this.#now() + UNAVAILABLE_RETRY_MS);
127
+ }
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ forget(cwd: string): void {
133
+ const lookupKey = resolve(cwd);
134
+ this.#unavailable.delete(lookupKey);
135
+ const remembered = this.#memory.get(lookupKey);
136
+ const repositoryRoot = resolve(remembered?.capability.repository.root ?? cwd);
137
+ for (const [key, entry] of this.#memory) {
138
+ if (key === lookupKey || resolve(entry.capability.repository.root) === repositoryRoot) {
139
+ this.#memory.delete(key);
140
+ }
141
+ }
142
+ this.#unavailable.delete(repositoryRoot);
143
+ }
144
+
145
+ #remember(lookupKey: string, capability: Omit<HerdrCapability, "cache">): void {
146
+ const remembered = { capability, expiresAt: this.#now() + CAPABILITY_MAX_AGE_MS };
147
+ this.#memory.set(lookupKey, remembered);
148
+ this.#memory.set(resolve(capability.repository.root), remembered);
149
+ }
150
+
151
+ #isManagedSession(): boolean {
152
+ return this.#environment[SESSION_MARKER] === "1";
153
+ }
154
+
155
+ #assertManagedSession(): void {
156
+ if (!this.#isManagedSession()) {
157
+ throw new HerdrUnavailableError(
158
+ `Herdr worktree tools only run inside a Herdr-managed pane, where ${SESSION_MARKER} is 1. Start the agent from a Herdr session and retry.`,
159
+ );
160
+ }
161
+ }
162
+
163
+ async #findRepository(cwd: string, signal: AbortSignal | undefined): Promise<GitRepository> {
164
+ let result: CommandResult;
165
+ try {
166
+ result = await runChecked(
167
+ this.#runner,
168
+ "git",
169
+ ["rev-parse", "--show-toplevel", "--absolute-git-dir"],
170
+ { cwd, signal },
171
+ "Unable to locate the Git repository",
172
+ );
173
+ } catch (error) {
174
+ if (error instanceof CommandCancelledError) {
175
+ throw error;
176
+ }
177
+ throw new HerdrUnavailableError(
178
+ "Herdr worktree actions require a Git work tree. Run the tool from inside a Git repository.",
179
+ { cause: error },
180
+ );
181
+ }
182
+
183
+ const lines = result.stdout.replace(/\r?\n$/u, "").split(/\r?\n/u);
184
+ if (lines.length !== 2) {
185
+ throw new HerdrUnavailableError(
186
+ "Git returned an invalid repository location; Herdr worktree actions cannot run reliably here.",
187
+ );
188
+ }
189
+ try {
190
+ return {
191
+ root: parseAbsolutePath(lines[0], "The repository root"),
192
+ gitDir: parseAbsolutePath(lines[1], "The Git directory"),
193
+ };
194
+ } catch (error) {
195
+ throw new HerdrUnavailableError(
196
+ "Git returned an invalid repository location; Herdr worktree actions cannot run reliably here.",
197
+ { cause: error },
198
+ );
199
+ }
200
+ }
201
+
202
+ async #detectHerdr(repository: GitRepository, signal: AbortSignal | undefined): Promise<string> {
203
+ let result: CommandResult;
204
+ try {
205
+ result = await runChecked(
206
+ this.#runner,
207
+ "herdr",
208
+ ["--version"],
209
+ { cwd: repository.root, signal },
210
+ "Unable to read the Herdr version",
211
+ );
212
+ } catch (error) {
213
+ if (error instanceof CommandCancelledError) {
214
+ throw error;
215
+ }
216
+ throw new HerdrUnavailableError(
217
+ "The Herdr CLI is unavailable. Install `herdr` and ensure it is on PATH.",
218
+ { cause: error },
219
+ );
220
+ }
221
+ return parseHerdrVersion(result);
222
+ }
223
+ }
@@ -0,0 +1,51 @@
1
+ import type { HerdrCapabilityResolver } from "./capability.js";
2
+
3
+ /**
4
+ * The tool loads on demand, so the model needs this note before it reaches for `herdr` or
5
+ * `git worktree` in bash.
6
+ */
7
+ export const HERDR_PROMPT_NOTE =
8
+ "Use the herdr_worktree tool for Git worktrees, not herdr or git worktree in bash. Create a worktree when work needs its own checkout, for example parallel multi-agent work.";
9
+
10
+ interface BeforeAgentStartEvent {
11
+ readonly systemPrompt?: unknown;
12
+ }
13
+
14
+ interface BeforeAgentStartContext {
15
+ readonly cwd?: string;
16
+ readonly signal?: AbortSignal;
17
+ }
18
+
19
+ interface BeforeAgentStartResult {
20
+ readonly systemPrompt: string;
21
+ }
22
+
23
+ export interface HerdrPromptApi {
24
+ on?(
25
+ event: "before_agent_start",
26
+ handler: (
27
+ event: BeforeAgentStartEvent,
28
+ context?: BeforeAgentStartContext,
29
+ ) => Promise<BeforeAgentStartResult | undefined>,
30
+ ): void;
31
+ }
32
+
33
+ export function registerHerdrGuidance(
34
+ pi: HerdrPromptApi,
35
+ capabilities: HerdrCapabilityResolver,
36
+ ): void {
37
+ pi.on?.("before_agent_start", async (event, context) => {
38
+ const systemPrompt = event?.systemPrompt;
39
+ // A host may chain several handlers, so the note must not be appended twice.
40
+ if (typeof systemPrompt !== "string" || systemPrompt.includes(HERDR_PROMPT_NOTE)) {
41
+ return undefined;
42
+ }
43
+
44
+ const capability = await capabilities.tryEnsure(context?.cwd ?? process.cwd(), context?.signal);
45
+ if (!capability) {
46
+ return undefined;
47
+ }
48
+
49
+ return { systemPrompt: `${systemPrompt}\n\n${HERDR_PROMPT_NOTE}` };
50
+ });
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { type HerdrExtensionApi, registerHerdrWorktreeTools } from "./tools.js";
2
+
3
+ export default function piHerdrWorktree(pi: HerdrExtensionApi): void {
4
+ registerHerdrWorktreeTools(pi);
5
+ }