@agentproto/worktree 0.2.0 → 0.3.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.
package/dist/index.d.ts CHANGED
@@ -1,24 +1,189 @@
1
- export { cleanupWorktreeTool, provisionWorktreeTool, runGateTool } from './tools/index.js';
1
+ export { ServiceStatusOutput, cleanupWorktreeTool, listServicesTool, provisionWorktreeTool, runGateTool, runScriptTool, serviceStatusSchema, startServiceTool, stopServiceTool } from './tools/index.js';
2
2
  export { worktreeProvider } from './provider/index.js';
3
3
  import { z } from 'zod';
4
+ import http from 'node:http';
4
5
  import { RuntimeWorkflow, AgentSessionHost } from '@agentproto/workflow-runtime';
5
6
  import { StartAgentArgs, SessionDescriptor, TurnEvent, TurnResult, HarnessClient, ConnectHarnessOptions } from '@agentproto/harness';
6
7
  import '@agentproto/tool';
7
8
  import '@agentproto/driver';
8
9
 
10
+ /**
11
+ * Raised when `worktree.cleanup` can't remove the worktree: either we
12
+ * categorized the tree ourselves and found a dirty class the caller didn't
13
+ * authorize (`blocked` names it), or — the no-flags default path — git's own
14
+ * `worktree remove` refused and we never asked why (`blocked` is empty,
15
+ * `message` carries git's stderr verbatim).
16
+ */
17
+ declare class WorktreeNotRemovableError extends Error {
18
+ readonly cwd: string;
19
+ readonly blocked: readonly ("untracked" | "modified")[];
20
+ /**
21
+ * `blocked` is empty when git itself refused without us categorizing the
22
+ * tree first (the no-flags default path, PLAN.md §5.2 layer 3) — `detail`
23
+ * then carries git's own stderr instead of a named class.
24
+ */
25
+ constructor(cwd: string, blocked: readonly ("untracked" | "modified")[], detail?: string);
26
+ }
27
+
28
+ /**
29
+ * Provenance — which session(s) worked in this worktree, historically,
30
+ * including dead ones (PLAN.md §1.5). Not a fourth status axis: it never
31
+ * changes `class`, only triage ("what was the session doing when it
32
+ * stranded these files?").
33
+ *
34
+ * The join is over the EXISTING sessions registry
35
+ * (`~/.agentproto/sessions.json`, `packages/runtime/src/sessions.ts`) —
36
+ * upholding the no-registry principle (PLAN.md §1.4): nothing new is
37
+ * mirrored, this only reads a file another package already owns and
38
+ * persists. Read directly off disk (not via a running daemon) so `worktree
39
+ * ls --status` works with no daemon required, matching `worktree check`'s
40
+ * posture (PLAN.md §3.3).
41
+ *
42
+ * Two known limits, both designed in rather than hidden (PLAN.md §1.5):
43
+ * - `sessions.json` is a recency window (`HISTORY_CAP = 200`,
44
+ * `packages/runtime/src/sessions.ts:583`), not an archive — an old
45
+ * worktree's sessions may already be evicted.
46
+ * - Without a per-worktree creation marker, the join can span
47
+ * generations (a worktree removed and recreated at the same path
48
+ * inherits the old generation's sessions). PR-B writes that marker
49
+ * (`agentproto-worktree.json` in the worktree's private gitdir); this PR
50
+ * only reads it if present and is honest — `confidence: "best-effort"`
51
+ * — when it's absent, which is every worktree today.
52
+ */
53
+
54
+ declare const SESSIONS_FILE_PATH: () => string;
55
+ /** The subset of `SessionDescriptor` (`packages/runtime/src/sessions.ts`) provenance needs. */
56
+ declare const sessionRefSchema: z.ZodObject<{
57
+ id: z.ZodString;
58
+ label: z.ZodOptional<z.ZodString>;
59
+ startedAt: z.ZodString;
60
+ endedAt: z.ZodOptional<z.ZodString>;
61
+ status: z.ZodString;
62
+ cwd: z.ZodOptional<z.ZodString>;
63
+ }, z.core.$strip>;
64
+ type SessionRef = z.infer<typeof sessionRefSchema>;
65
+ type ProvenanceConfidence = "exact" | "best-effort";
66
+ interface ProvenanceInfo {
67
+ confidence: ProvenanceConfidence;
68
+ sessions: SessionRef[];
69
+ }
70
+ /**
71
+ * Read the persisted sessions snapshot. Missing file (no daemon has ever
72
+ * run) is a legitimate empty registry, not an error — mirrors
73
+ * `loadHistorySnapshot`'s own ENOENT handling in `sessions.ts`. A malformed
74
+ * file (present but unparseable) is surfaced by returning `null` so callers
75
+ * can distinguish "no history" from "history is unreadable".
76
+ */
77
+ declare function readSessionsRegistry(path?: string): Promise<SessionRef[] | null>;
78
+ /** `session.cwd == wtPath || session.cwd.startsWith(wtPath + sep)` (PLAN.md §1.5 — containment, not equality). */
79
+ declare function sessionInWorktree(session: SessionRef, worktreePath: string): boolean;
80
+ /** The creation marker PR-B writes into `$(git rev-parse --git-dir)/agentproto-worktree.json`. */
81
+ declare const worktreeMarkerSchema: z.ZodObject<{
82
+ worktreeId: z.ZodString;
83
+ createdAt: z.ZodString;
84
+ createdBySessionId: z.ZodOptional<z.ZodString>;
85
+ }, z.core.$strip>;
86
+ type WorktreeMarker = z.infer<typeof worktreeMarkerSchema>;
87
+ /**
88
+ * Read the per-worktree creation marker, if PR-B has written one. This PR
89
+ * never writes it — only reads, so a worktree created before PR-B shipped
90
+ * (all of them, today) honestly reports `confidence: "best-effort"`.
91
+ */
92
+ declare function readWorktreeMarker(worktreePath: string): Promise<WorktreeMarker | null>;
93
+ /**
94
+ * Write the creation marker into `$(git rev-parse --git-dir)/agentproto-
95
+ * worktree.json` — the worktree's own private gitdir, not the shared
96
+ * `.git/config` (PLAN.md §1.5 rejected `git config --local` for exactly
97
+ * this reason: in a linked worktree it reads/writes the main repo's shared
98
+ * config, so it wouldn't die with the worktree and would collide across
99
+ * worktrees). Called once, by `worktree.provision`, right after the
100
+ * worktree is created; nothing else ever rewrites it.
101
+ */
102
+ declare function writeWorktreeMarker(worktreePath: string, marker: WorktreeMarker): Promise<void>;
103
+ interface ComputeProvenanceOptions {
104
+ sessionsPath?: string;
105
+ }
106
+ /**
107
+ * The §1.5 join: sessions whose `cwd` is contained in `worktreePath`,
108
+ * bounded by the marker's `createdAt` when present (filters out sessions
109
+ * from a prior generation at the same path). `confidence` is `"exact"` only
110
+ * when that marker exists; otherwise `"best-effort"` — the join may span
111
+ * generations and is bounded by `HISTORY_CAP`'s recency window.
112
+ */
113
+ declare function computeProvenance(worktreePath: string, options?: ComputeProvenanceOptions): Promise<ProvenanceInfo>;
114
+
115
+ /**
116
+ * Salvage-before-discard (PLAN.md §5.2 layer 4). `worktree archive` re-earns
117
+ * its name by snapshotting a worktree's uncommitted state before it removes
118
+ * anything: `git diff HEAD` → `changes.patch`, plus a byte-copy of every
119
+ * unignored untracked file, into
120
+ * `~/.agentproto/worktree-salvage/<repoName>/<slug>-<tipSha7>-<date>/` with a
121
+ * `MANIFEST.json`. Every file is fsynced before this returns — only once the
122
+ * snapshot is durable does the caller proceed to force-remove.
123
+ *
124
+ * Deliberately forge-independent: no PR lookup here. This writer's only job
125
+ * is "don't lose bytes before deletion," not classification — `ls --status`
126
+ * (status.ts) already owns the forge-derived `integration` axis, and gc
127
+ * (PR-D) is where a salvage manifest and a status entry would be cross-
128
+ * referenced, not here.
129
+ */
130
+
131
+ declare const SALVAGE_ROOT: () => string;
132
+ interface SalvageManifest {
133
+ repo: string;
134
+ /** `null` for a detached HEAD. */
135
+ branch: string | null;
136
+ tipSha: string;
137
+ createdAt: string;
138
+ /** Relative paths of every unignored untracked file that was copied in. */
139
+ untrackedFiles: string[];
140
+ /** Whether `changes.patch` has any content (tracked-file changes existed). */
141
+ hasPatch: boolean;
142
+ provenance: ProvenanceInfo;
143
+ }
144
+ interface SalvageWorktreeInput {
145
+ repoName: string;
146
+ worktreePath: string;
147
+ branch: string | null;
148
+ tipSha: string;
149
+ /** Identifies this worktree in the salvage directory name, e.g. its slug or branch. */
150
+ slug: string;
151
+ sessionsPath?: string;
152
+ /** Injectable clock so tests can freeze `createdAt` / the directory name. */
153
+ now?: () => string;
154
+ /** Override for `~/.agentproto/worktree-salvage` — tests use a temp dir. */
155
+ salvageRoot?: string;
156
+ }
157
+ interface SalvageResult {
158
+ dir: string;
159
+ manifest: SalvageManifest;
160
+ }
161
+ /**
162
+ * Snapshot `worktreePath`'s uncommitted state into a fresh salvage
163
+ * directory. Safe to call on a clean tree (writes an empty patch, no
164
+ * untracked files, still a manifest) — every path this function returns is
165
+ * fsynced before it resolves.
166
+ */
167
+ declare function salvageWorktree(input: SalvageWorktreeInput): Promise<SalvageResult>;
168
+
9
169
  interface ExecResult {
10
170
  exitCode: number;
11
171
  stdout: string;
12
172
  stderr: string;
13
173
  }
174
+ /** Extra `AGENTPROTO_*` (or any) vars, merged onto `process.env` for the child. */
175
+ interface ExecOptions {
176
+ env?: Record<string, string>;
177
+ }
14
178
  /** Run an argv (no shell — args pass through verbatim, no injection risk). */
15
- declare function execArgv(command: string, args: readonly string[], cwd: string): Promise<ExecResult>;
179
+ declare function execArgv(command: string, args: readonly string[], cwd: string, opts?: ExecOptions): Promise<ExecResult>;
16
180
  /**
17
181
  * Run a single command string through a shell. Callers pass `depsCmd` /
18
- * `gateCmd` as a trusted, developer-authored config value (the same trust
19
- * model as a CI job's `script:` line) — not untrusted end-user input.
182
+ * `gateCmd` / a config `setup` line as a trusted, developer-authored value
183
+ * (the same trust model as a CI job's `script:` line) — not untrusted
184
+ * end-user input. `opts.env` layers `AGENTPROTO_*` vars onto the child.
20
185
  */
21
- declare function execShell(cmd: string, cwd: string): Promise<ExecResult>;
186
+ declare function execShell(cmd: string, cwd: string, opts?: ExecOptions): Promise<ExecResult>;
22
187
  declare function execGit(repoRoot: string, args: readonly string[]): Promise<ExecResult>;
23
188
 
24
189
  /** Compile a `/`-separated glob (supporting `*`, `?`, `**`) into an anchored regex. */
@@ -26,6 +191,359 @@ declare function globToRegExp(pattern: string): RegExp;
26
191
  /** Expand a glob against `root`, returning root-relative posix paths of matching files. */
27
192
  declare function expandGlob(root: string, pattern: string): Promise<string[]>;
28
193
 
194
+ /**
195
+ * `agentproto.json` — the declarative per-repo worktree lifecycle.
196
+ *
197
+ * SECURITY: this file is always read from the **committed tree of the base
198
+ * ref** (`git show <base>:agentproto.json`), never from a worktree's working
199
+ * tree. A branch (or an agent editing files inside a worktree) therefore
200
+ * cannot inject setup/teardown hooks or service commands that run on the host
201
+ * — only what a reviewer merged into the base branch executes.
202
+ */
203
+ declare const CONFIG_FILENAME = "agentproto.json";
204
+ /** `scripts.<name>` — a command, optionally a long-running service with a port. */
205
+ declare const scriptSchema: z.ZodObject<{
206
+ command: z.ZodString;
207
+ type: z.ZodOptional<z.ZodLiteral<"service">>;
208
+ port: z.ZodOptional<z.ZodNumber>;
209
+ }, z.core.$strip>;
210
+ declare const configSchema: z.ZodObject<{
211
+ worktree: z.ZodOptional<z.ZodObject<{
212
+ setup: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
213
+ teardown: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
214
+ }, z.core.$strip>>;
215
+ scripts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
216
+ command: z.ZodString;
217
+ type: z.ZodOptional<z.ZodLiteral<"service">>;
218
+ port: z.ZodOptional<z.ZodNumber>;
219
+ }, z.core.$strip>>>;
220
+ }, z.core.$strip>;
221
+ type ScriptConfig = z.infer<typeof scriptSchema>;
222
+ type AgentprotoConfig = z.infer<typeof configSchema>;
223
+ /** A validation failure with the offending file surfaced for error messages. */
224
+ declare class ConfigError extends Error {
225
+ constructor(message: string);
226
+ }
227
+ /**
228
+ * Parse + validate raw `agentproto.json` text. Throws {@link ConfigError} on
229
+ * malformed JSON or a schema violation.
230
+ */
231
+ declare function parseConfig(raw: string): AgentprotoConfig;
232
+ /** Normalise a hook (string | string[] | undefined) into an ordered command list. */
233
+ declare function normalizeHook(hook: string | readonly string[] | undefined): string[];
234
+ /** A named script, with its config attached — the shape supervisor/tools consume. */
235
+ interface NamedScript extends ScriptConfig {
236
+ name: string;
237
+ }
238
+ /** All declared scripts as a list. */
239
+ declare function listScripts(config: AgentprotoConfig): NamedScript[];
240
+ /** Only the `type: "service"` scripts. */
241
+ declare function listServices(config: AgentprotoConfig): NamedScript[];
242
+ /** Look up a single named script, or `undefined` if it isn't declared. */
243
+ declare function getScript(config: AgentprotoConfig, name: string): NamedScript | undefined;
244
+ /**
245
+ * Load `agentproto.json` from the **committed tree** of `base` (default
246
+ * `origin/main`) via `git show <base>:agentproto.json`. Returns `null` when
247
+ * the base tree has no such file (the common "repo hasn't opted in" case).
248
+ * Throws {@link ConfigError} on a present-but-invalid file.
249
+ */
250
+ declare function loadConfigFromBase(repoRoot: string, base?: string): Promise<AgentprotoConfig | null>;
251
+
252
+ /**
253
+ * Typed env surface for worktree lifecycle hooks, scripts, and services.
254
+ *
255
+ * Every `AGENTPROTO_*` variable the lifecycle feature injects is named here
256
+ * once — bodies compose env via {@link hookEnv} / {@link serviceEnv} rather
257
+ * than reading `process.env` (or writing literal `"AGENTPROTO_..."` keys)
258
+ * ad hoc. Keeps the contract in one place and the derivations testable.
259
+ */
260
+ /** The `AGENTPROTO_*` variables injected into every hook / script / service. */
261
+ declare const ENV_VARS: {
262
+ /** Absolute path to the original repo checkout the worktree was cut from. */
263
+ readonly sourceCheckoutPath: "AGENTPROTO_SOURCE_CHECKOUT_PATH";
264
+ /** Absolute path to the worktree directory. */
265
+ readonly worktreePath: "AGENTPROTO_WORKTREE_PATH";
266
+ /** The worktree's branch name. */
267
+ readonly branchName: "AGENTPROTO_BRANCH_NAME";
268
+ /** A service's own allocated port. */
269
+ readonly port: "AGENTPROTO_PORT";
270
+ /** A service's own reverse-proxy URL. */
271
+ readonly url: "AGENTPROTO_URL";
272
+ };
273
+ /** The shared context every worktree lifecycle command runs against. */
274
+ interface WorktreeEnvContext {
275
+ /** Absolute path to the original repo checkout. */
276
+ sourceCheckoutPath: string;
277
+ /** Absolute path to the worktree directory. */
278
+ worktreePath: string;
279
+ /** The worktree's branch name. */
280
+ branchName: string;
281
+ }
282
+ /**
283
+ * Base env shared by hooks, scripts, and services — the worktree location and
284
+ * the branch/checkout it derives from. Values are strings only (never
285
+ * `undefined`), so the result merges cleanly onto `process.env`.
286
+ */
287
+ declare function hookEnv(ctx: WorktreeEnvContext): Record<string, string>;
288
+ /** A sibling service, as seen by a peer's env. */
289
+ interface PeerService {
290
+ /** The declared script name (e.g. `"web"`). */
291
+ name: string;
292
+ /** The port the service listens on. */
293
+ port: number;
294
+ /** The service's reverse-proxy URL. */
295
+ url: string;
296
+ }
297
+ /**
298
+ * Upper-case a service name and replace every non-alphanumeric run with a
299
+ * single `_`, for use inside an env var key (`web-api` → `WEB_API`).
300
+ */
301
+ declare function serviceEnvToken(name: string): string;
302
+ /** `AGENTPROTO_SERVICE_<TOKEN>_PORT` / `..._URL` for a named peer. */
303
+ declare function peerEnvKey(name: string, suffix: "PORT" | "URL"): string;
304
+ /**
305
+ * Peer-discovery env for a set of sibling services: each contributes
306
+ * `AGENTPROTO_SERVICE_<NAME>_PORT` and `AGENTPROTO_SERVICE_<NAME>_URL`.
307
+ */
308
+ declare function peerEnv(peers: readonly PeerService[]): Record<string, string>;
309
+ /**
310
+ * Full env for a running service: the shared {@link hookEnv}, the service's
311
+ * own `AGENTPROTO_PORT` / `AGENTPROTO_URL`, and {@link peerEnv} for every
312
+ * sibling.
313
+ */
314
+ declare function serviceEnv(args: {
315
+ ctx: WorktreeEnvContext;
316
+ self: PeerService;
317
+ peers: readonly PeerService[];
318
+ }): Record<string, string>;
319
+
320
+ /** One hook command's result, kept for error reporting / logging. */
321
+ interface HookRun {
322
+ command: string;
323
+ result: ExecResult;
324
+ }
325
+ /** Raised when a `setup` hook exits non-zero — carries the captured output. */
326
+ declare class HookError extends Error {
327
+ readonly command: string;
328
+ readonly result: ExecResult;
329
+ constructor(phase: "setup" | "teardown", run: HookRun);
330
+ }
331
+ /**
332
+ * Run the `worktree.setup` hooks sequentially in the worktree, with the
333
+ * `AGENTPROTO_*` context env injected. The first non-zero exit throws
334
+ * {@link HookError} (setup failure must fail provisioning). No-op when the
335
+ * repo declares no setup.
336
+ */
337
+ declare function runSetup(config: AgentprotoConfig, ctx: WorktreeEnvContext): Promise<HookRun[]>;
338
+ /**
339
+ * Run the `worktree.teardown` hooks sequentially. Unlike setup, a failing
340
+ * teardown hook does NOT throw — cleanup must proceed — so failures are
341
+ * returned for the caller to log. No-op when no teardown is declared.
342
+ */
343
+ declare function runTeardown(config: AgentprotoConfig, ctx: WorktreeEnvContext): Promise<HookRun[]>;
344
+
345
+ /**
346
+ * The proxy routing table: `<hostname>` → local service port.
347
+ *
348
+ * Kept separate from the HTTP server (see `proxy-server.ts`) so the supervisor
349
+ * can register/unregister routes without owning a live socket, and so routing
350
+ * can be unit-tested without binding a port.
351
+ */
352
+ declare class ProxyTable {
353
+ private readonly routes;
354
+ /** Register (or replace) the target port for a hostname. */
355
+ set(hostname: string, port: number): void;
356
+ /** Remove a hostname's route. Returns whether it was present. */
357
+ delete(hostname: string): boolean;
358
+ /** The target port registered for an exact hostname, if any. */
359
+ get(hostname: string): number | undefined;
360
+ /**
361
+ * Resolve a raw `Host` header (which may carry a `:port` suffix, and for
362
+ * IPv6 a bracketed host) to a target port.
363
+ */
364
+ lookup(hostHeader: string | undefined): number | undefined;
365
+ /** All `[hostname, port]` routes currently registered. */
366
+ entries(): Array<[string, number]>;
367
+ /** Number of registered routes. */
368
+ get size(): number;
369
+ }
370
+ /** Strip a trailing `:port` from a Host header, honouring `[ipv6]:port`. */
371
+ declare function stripPort(hostHeader: string): string;
372
+
373
+ /** A declared service with its resolved port / hostname / proxy URL. */
374
+ interface ServiceRuntime {
375
+ name: string;
376
+ command: string;
377
+ hostname: string;
378
+ port: number;
379
+ url: string;
380
+ }
381
+ /** The observable state of a service (v1: no restart — just track the exit). */
382
+ interface ServiceStatus {
383
+ name: string;
384
+ hostname: string;
385
+ port: number;
386
+ url: string;
387
+ pid: number | null;
388
+ status: "running" | "exited";
389
+ exitCode: number | null;
390
+ startedAt: string | null;
391
+ }
392
+ interface SupervisorOptions {
393
+ ctx: WorktreeEnvContext;
394
+ /** The `type: "service"` scripts from the repo's `agentproto.json`. */
395
+ services: readonly NamedScript[];
396
+ /** Repo label for the hostname (typically the repo dir basename). */
397
+ repo: string;
398
+ /** Whether the worktree's branch is the repo default (drops the branch label). */
399
+ isDefaultBranch: boolean;
400
+ /** Port the reverse proxy listens on — used to build each service's URL. */
401
+ proxyPort: number;
402
+ /** When given, service hostnames are (de)registered here as they start/stop. */
403
+ proxyTable?: ProxyTable;
404
+ }
405
+ /**
406
+ * Supervises the long-running services declared for one worktree. Ports are
407
+ * allocated up front for every declared service so peer discovery is complete
408
+ * (a service started later is visible to one started earlier only if its port
409
+ * was reserved — which it is), and hostnames register with the proxy table as
410
+ * services come up.
411
+ *
412
+ * v1 scope: no crash-restart loop. A service that exits is marked `exited`
413
+ * with its code; callers decide what to do.
414
+ */
415
+ declare class ServiceSupervisor {
416
+ private readonly opts;
417
+ private readonly tracked;
418
+ private constructor();
419
+ /**
420
+ * Build a supervisor, allocating a port for every declared service (declared
421
+ * port if free, else ephemeral) and resolving each one's hostname + URL.
422
+ * Nothing is spawned yet.
423
+ */
424
+ static create(opts: SupervisorOptions): Promise<ServiceSupervisor>;
425
+ /** Every declared service's resolved runtime (ports/urls), regardless of state. */
426
+ runtimes(): ServiceRuntime[];
427
+ /** Peers of `name` — every other declared service, as `PeerService` records. */
428
+ private peersOf;
429
+ private toStatus;
430
+ /**
431
+ * Start a declared service. Idempotent: returns the current status if it's
432
+ * already running. Injects `AGENTPROTO_PORT` / `AGENTPROTO_URL` for itself
433
+ * plus `AGENTPROTO_SERVICE_<PEER>_PORT|URL` for every sibling.
434
+ */
435
+ start(name: string): ServiceStatus;
436
+ /** Stop a running service (SIGTERM). Returns whether one was running. */
437
+ stop(name: string): Promise<boolean>;
438
+ /** Stop every running service. */
439
+ stopAll(): Promise<void>;
440
+ /** Current status of a single service, or `undefined` if not declared. */
441
+ get(name: string): ServiceStatus | undefined;
442
+ /** Current status of every declared service. */
443
+ list(): ServiceStatus[];
444
+ }
445
+
446
+ /** Build (but don't start) the proxy HTTP server backed by `table`. */
447
+ declare function createProxyServer(table: ProxyTable): http.Server;
448
+ interface RunningProxy {
449
+ /** The port the proxy is listening on. */
450
+ port: number;
451
+ /** The underlying server. */
452
+ server: http.Server;
453
+ /** Stop the proxy. */
454
+ close(): Promise<void>;
455
+ }
456
+ /** Start the proxy server on `port` (0 → OS-assigned). Resolves once listening. */
457
+ declare function startProxy(table: ProxyTable, port?: number): Promise<RunningProxy>;
458
+
459
+ /**
460
+ * Hostname derivation for the `*.localhost` reverse proxy.
461
+ *
462
+ * A service is reachable at:
463
+ * http://<script>--<branch-slug>--<repo-slug>.localhost:<proxy-port>
464
+ * except on the repo's default branch, where the branch label is dropped:
465
+ * http://<script>--<repo-slug>.localhost:<proxy-port>
466
+ *
467
+ * `*.localhost` resolves to 127.0.0.1 on modern systems, so no DNS work is
468
+ * needed — the proxy just matches on the Host header.
469
+ */
470
+ /** Lowercase, non-alphanumerics → `-`, collapse repeats, trim leading/trailing `-`. */
471
+ declare function slugify(input: string): string;
472
+ interface HostnameParts {
473
+ /** The script/service name (e.g. `"web"`). */
474
+ script: string;
475
+ /** The worktree's branch (e.g. `"wt/fix-flaky"`). */
476
+ branch: string;
477
+ /** The repo identifier (e.g. its directory basename). */
478
+ repo: string;
479
+ /** True when `branch` is the repo's default branch — drops the branch label. */
480
+ isDefaultBranch: boolean;
481
+ }
482
+ /** The bare hostname (no scheme, no port) a service is proxied under. */
483
+ declare function serviceHostname(parts: HostnameParts): string;
484
+ /** The full proxy URL (`http://<hostname>:<proxy-port>`) for a service. */
485
+ declare function serviceUrl(parts: HostnameParts, proxyPort: number): string;
486
+
487
+ /**
488
+ * Port allocation for supervised services.
489
+ *
490
+ * A service declares a preferred `port`; we use it if free, otherwise we fall
491
+ * back to an OS-assigned ephemeral port. Allocation binds a probe socket on
492
+ * 127.0.0.1 to decide — there's an inherent TOCTOU window between the probe
493
+ * closing and the service binding, but for local dev supervision that's the
494
+ * same race every "is this port free" check has, and the service will simply
495
+ * fail to bind if something grabbed it in between.
496
+ */
497
+ /** Resolve to `true` if a TCP listen on `127.0.0.1:<port>` succeeds. */
498
+ declare function isPortFree(port: number): Promise<boolean>;
499
+ /** Ask the OS for a free ephemeral port (bind to 0, read the assigned port). */
500
+ declare function ephemeralPort(): Promise<number>;
501
+ /**
502
+ * Allocate a port for a service: the declared `preferred` if it's free,
503
+ * otherwise an OS-assigned ephemeral port. Ports in `reserved` (already handed
504
+ * out to sibling services this session) are treated as taken.
505
+ */
506
+ declare function allocatePort(preferred: number | undefined, reserved?: ReadonlySet<number>): Promise<number>;
507
+
508
+ /**
509
+ * Derive the hostname-facing repo label from a repo root path — its directory
510
+ * basename (the proxy slugs it further).
511
+ */
512
+ declare function repoLabel(repoRoot: string): string;
513
+ /**
514
+ * Best-effort detection of a repo's default branch, for deciding whether the
515
+ * branch label is dropped from a service hostname. Prefers the symbolic
516
+ * `origin/HEAD` ref, falls back to `main` / `master` presence, then `main`.
517
+ */
518
+ declare function detectDefaultBranch(repoRoot: string): Promise<string>;
519
+
520
+ /** The shared reverse-proxy routing table (hostname → service port). */
521
+ declare const sharedProxyTable: ProxyTable;
522
+ /** Default reverse-proxy port when a caller doesn't pin one. */
523
+ declare const DEFAULT_PROXY_PORT = 18780;
524
+ interface ResolveSupervisorInput {
525
+ repoRoot: string;
526
+ worktreePath: string;
527
+ branch: string;
528
+ base?: string;
529
+ proxyPort?: number;
530
+ }
531
+ /** The context the service tools resolve before touching the supervisor. */
532
+ interface ResolvedWorktreeRuntime {
533
+ supervisor: ServiceSupervisor;
534
+ config: AgentprotoConfig;
535
+ }
536
+ /**
537
+ * Resolve (creating on first use) the supervisor for a worktree: read the
538
+ * committed `agentproto.json` from `base`, build service runtimes with
539
+ * allocated ports, and cache it keyed by worktree path.
540
+ */
541
+ declare function resolveSupervisor(input: ResolveSupervisorInput): Promise<ResolvedWorktreeRuntime>;
542
+ /** The cached supervisor for a worktree, if one has been resolved. */
543
+ declare function getSupervisor(worktreePath: string): ServiceSupervisor | undefined;
544
+ /** Stop all services for a worktree and drop its supervisor (used by cleanup). */
545
+ declare function disposeSupervisor(worktreePath: string): Promise<void>;
546
+
29
547
  /**
30
548
  * Input to the worktree-agent workflow: launch a coding agent inside a
31
549
  * fresh git worktree, gate its work, and — on approval — clean up.
@@ -50,6 +568,410 @@ type WorktreeAgentInput = z.infer<typeof worktreeAgentInputSchema>;
50
568
  */
51
569
  declare const worktreeAgentWorkflow: RuntimeWorkflow;
52
570
 
571
+ /**
572
+ * `ForgeClient` — the only network-facing surface the reconciliation rule
573
+ * (`status.ts`) depends on. Kept as an interface (not a concrete `gh` call)
574
+ * so:
575
+ * 1. `status.ts` stays testable with a fake — the fixture-repo tests never
576
+ * shell out to a real forge or touch the network.
577
+ * 2. The shape is forge-portable by construction (PLAN.md §7.1): GitLab has
578
+ * the same two facts — "PRs/MRs whose head is this branch" and "PRs/MRs
579
+ * that contain this commit" — under different verbs
580
+ * (`refs/merge-requests/<n>/head`, `GET /merge_requests?…`). Only
581
+ * GitHub ships today; a `GitlabForgeClient` is a second implementation
582
+ * of this same interface, not a redesign.
583
+ *
584
+ * Two implementations ship: `GhCliForgeClient` (shells out to the `gh` CLI,
585
+ * inheriting the caller's own `gh auth` session — zero config) and
586
+ * `RestForgeClient` (plain `fetch` against the GitHub REST API using
587
+ * `GITHUB_TOKEN` — no new npm dependency). `createForgeClient` picks
588
+ * whichever is usable; when neither is, callers get a client that always
589
+ * reports itself unreachable, which the reconciliation rule already knows
590
+ * how to fall back from (PLAN.md §1.3 step 5: memo, else `unknown(offline)`,
591
+ * never a guess).
592
+ */
593
+ /** A pull/merge request as the reconciliation rule needs to see it. */
594
+ interface ForgePullRequestRef {
595
+ number: number;
596
+ state: "open" | "closed";
597
+ merged: boolean;
598
+ mergedAt: string | null;
599
+ headRefName: string;
600
+ headRefOid: string;
601
+ }
602
+ /**
603
+ * Thrown by a `ForgeClient` method when the forge could not be reached or
604
+ * queried at all (network failure, missing/unauthenticated CLI, non-2xx HTTP
605
+ * response). Never thrown for a legitimate empty result (a branch/commit
606
+ * with no PRs is a valid, resolvable answer — `[]`, not an error). Callers
607
+ * treat this as PLAN.md §1.3 step 5: "never guess."
608
+ */
609
+ declare class ForgeUnavailableError extends Error {
610
+ constructor(message: string, options?: {
611
+ cause?: unknown;
612
+ });
613
+ }
614
+ interface ForgeClient {
615
+ /** PRs/MRs (any state) whose head branch is exactly `branch`. */
616
+ pullRequestsForBranch(branch: string): Promise<ForgePullRequestRef[]>;
617
+ /**
618
+ * PRs/MRs associated with a specific commit SHA — the rename/retarget-proof
619
+ * signal (PLAN.md §1.3 step 2b): finds the PR even if the branch was
620
+ * renamed or deleted before merge.
621
+ */
622
+ pullRequestsForCommit(sha: string): Promise<ForgePullRequestRef[]>;
623
+ /**
624
+ * Ensure `oid` (a PR's `headRefOid`) is present in the local object
625
+ * database, fetching `refs/pull/<number>/head` if it isn't. Always
626
+ * fetchable even after the head branch was deleted on the remote
627
+ * (PLAN.md §1.3 step 2, verified for #273/#312/#325/#271).
628
+ */
629
+ ensurePullHeadFetched(prNumber: number, oid: string): Promise<void>;
630
+ }
631
+ /** A client that always reports itself unreachable — the "no gh, no token" case. */
632
+ declare class UnreachableForgeClient implements ForgeClient {
633
+ private readonly reason;
634
+ constructor(reason: string);
635
+ pullRequestsForBranch(): Promise<ForgePullRequestRef[]>;
636
+ pullRequestsForCommit(): Promise<ForgePullRequestRef[]>;
637
+ ensurePullHeadFetched(): Promise<void>;
638
+ }
639
+ /** Runs `gh` in `repoRoot` so it auto-detects owner/repo from the `origin` remote there. */
640
+ declare class GhCliForgeClient implements ForgeClient {
641
+ private readonly repoRoot;
642
+ constructor(repoRoot: string);
643
+ private runRaw;
644
+ private run;
645
+ private parseOutput;
646
+ pullRequestsForBranch(branch: string): Promise<ForgePullRequestRef[]>;
647
+ pullRequestsForCommit(sha: string): Promise<ForgePullRequestRef[]>;
648
+ ensurePullHeadFetched(prNumber: number, oid: string): Promise<void>;
649
+ }
650
+ /** Parse `owner/repo` out of a `git remote get-url origin` value (ssh or https form). */
651
+ declare function parseGithubOwnerRepo(remoteUrl: string): {
652
+ owner: string;
653
+ repo: string;
654
+ } | null;
655
+ declare class RestForgeClient implements ForgeClient {
656
+ private readonly owner;
657
+ private readonly repo;
658
+ private readonly repoRoot;
659
+ private readonly token;
660
+ private readonly fetchFn;
661
+ constructor(owner: string, repo: string, repoRoot: string, token: string, fetchFn?: typeof fetch);
662
+ private headers;
663
+ private get;
664
+ private pullRequestList;
665
+ pullRequestsForBranch(branch: string): Promise<ForgePullRequestRef[]>;
666
+ pullRequestsForCommit(sha: string): Promise<ForgePullRequestRef[]>;
667
+ ensurePullHeadFetched(prNumber: number, oid: string): Promise<void>;
668
+ }
669
+ /**
670
+ * Pick the best available `ForgeClient` for `repoRoot`: `gh` CLI first (zero
671
+ * config, inherits the caller's own auth), else `GITHUB_TOKEN` REST, else a
672
+ * client that always reports unreachable — the reconciliation rule already
673
+ * knows how to fall back from that (memo, else `unknown(offline)`).
674
+ */
675
+ declare function createForgeClient(repoRoot: string): Promise<ForgeClient>;
676
+
677
+ /**
678
+ * The status engine (PLAN.md §1–§1.5): a worktree's status is derived at
679
+ * read time from three orthogonal axes — `tree`, `integration`, `liveness`
680
+ * — plus a `provenance` field that never affects classification. Nothing
681
+ * here is a registry: every value is recomputed from git, the forge, and
682
+ * the sessions snapshot on every call (PLAN.md §1.4's no-registry
683
+ * principle). The one piece of durable state, the verdict memo
684
+ * (`~/.agentproto/worktree-verdicts.json`), is a memo of *immutable* facts
685
+ * only (a merged PR stays merged) — deleting it changes nothing but speed
686
+ * and offline honesty, the acid test a real registry fails.
687
+ *
688
+ * The reconciliation rule (`reconcileIntegration`, PLAN.md §1.3) is the
689
+ * heart of this module: it is why ad-hoc shell answers to "how many
690
+ * worktrees are stale?" went 5 → 22 → 21+8 in one session, and why this is
691
+ * code with tests instead. Every step is a pure function of `(T,
692
+ * origin/main, the forge's merged-PR records)` — content-addressed inputs,
693
+ * so two runs at the same repo state produce byte-identical answers.
694
+ */
695
+
696
+ type TreeState = {
697
+ state: "clean";
698
+ } | {
699
+ state: "dirty";
700
+ modified: number;
701
+ staged: number;
702
+ untracked: number;
703
+ /**
704
+ * Newest mtime (epoch ms) among the worktree's own dirty paths
705
+ * (unstaged, staged, and untracked), or `null` if it couldn't be
706
+ * determined. Feeds `classify`'s `RECENT_WRITE_HOLD_WINDOW_MS` guard —
707
+ * a liveness-independent "someone touched this a moment ago" signal.
708
+ */
709
+ newestMtimeMs: number | null;
710
+ };
711
+ /**
712
+ * `git status --porcelain=v2` in the worktree itself (not `repoRoot` — tree
713
+ * state is per-worktree). Gitignored files never appear here (no
714
+ * `--ignored` flag passed), matching git's own non-`--force` `worktree
715
+ * remove` tolerance for them (PLAN.md §0.5) — the two notions of "clean"
716
+ * agree by construction.
717
+ */
718
+ declare function computeTreeState(worktreePath: string): Promise<TreeState>;
719
+ declare const integrationStateSchema: z.ZodUnion<readonly [z.ZodObject<{
720
+ state: z.ZodLiteral<"fresh">;
721
+ checkedAt: z.ZodString;
722
+ }, z.core.$strip>, z.ZodObject<{
723
+ state: z.ZodLiteral<"merged">;
724
+ via: z.ZodLiteral<"squash">;
725
+ pr: z.ZodNumber;
726
+ offline: z.ZodBoolean;
727
+ checkedAt: z.ZodString;
728
+ }, z.core.$strip>, z.ZodObject<{
729
+ state: z.ZodLiteral<"partial">;
730
+ pr: z.ZodNumber;
731
+ aheadBy: z.ZodNumber;
732
+ offline: z.ZodBoolean;
733
+ checkedAt: z.ZodString;
734
+ }, z.core.$strip>, z.ZodObject<{
735
+ state: z.ZodLiteral<"open">;
736
+ pr: z.ZodNumber;
737
+ offline: z.ZodBoolean;
738
+ checkedAt: z.ZodString;
739
+ }, z.core.$strip>, z.ZodObject<{
740
+ state: z.ZodLiteral<"pushed-no-pr">;
741
+ checkedAt: z.ZodString;
742
+ }, z.core.$strip>, z.ZodObject<{
743
+ state: z.ZodLiteral<"unpushed">;
744
+ aheadBy: z.ZodNumber;
745
+ checkedAt: z.ZodString;
746
+ }, z.core.$strip>, z.ZodObject<{
747
+ state: z.ZodLiteral<"local-only">;
748
+ checkedAt: z.ZodString;
749
+ }, z.core.$strip>, z.ZodObject<{
750
+ state: z.ZodLiteral<"gone-unexplained">;
751
+ checkedAt: z.ZodString;
752
+ }, z.core.$strip>, z.ZodObject<{
753
+ state: z.ZodLiteral<"diverged">;
754
+ offline: z.ZodBoolean;
755
+ checkedAt: z.ZodString;
756
+ }, z.core.$strip>, z.ZodObject<{
757
+ state: z.ZodLiteral<"detached">;
758
+ checkedAt: z.ZodString;
759
+ }, z.core.$strip>, z.ZodObject<{
760
+ state: z.ZodLiteral<"unknown">;
761
+ reason: z.ZodLiteral<"offline">;
762
+ checkedAt: z.ZodString;
763
+ }, z.core.$strip>]>;
764
+ /** PLAN.md §1.2's `integration` axis, in full — every state the rule can produce. */
765
+ type IntegrationState = z.infer<typeof integrationStateSchema>;
766
+ declare const verdictMemoRecordSchema: z.ZodObject<{
767
+ repo: z.ZodString;
768
+ branch: z.ZodString;
769
+ tipSha: z.ZodString;
770
+ verdict: z.ZodUnion<readonly [z.ZodObject<{
771
+ state: z.ZodLiteral<"fresh">;
772
+ checkedAt: z.ZodString;
773
+ }, z.core.$strip>, z.ZodObject<{
774
+ state: z.ZodLiteral<"merged">;
775
+ via: z.ZodLiteral<"squash">;
776
+ pr: z.ZodNumber;
777
+ offline: z.ZodBoolean;
778
+ checkedAt: z.ZodString;
779
+ }, z.core.$strip>, z.ZodObject<{
780
+ state: z.ZodLiteral<"partial">;
781
+ pr: z.ZodNumber;
782
+ aheadBy: z.ZodNumber;
783
+ offline: z.ZodBoolean;
784
+ checkedAt: z.ZodString;
785
+ }, z.core.$strip>, z.ZodObject<{
786
+ state: z.ZodLiteral<"open">;
787
+ pr: z.ZodNumber;
788
+ offline: z.ZodBoolean;
789
+ checkedAt: z.ZodString;
790
+ }, z.core.$strip>, z.ZodObject<{
791
+ state: z.ZodLiteral<"pushed-no-pr">;
792
+ checkedAt: z.ZodString;
793
+ }, z.core.$strip>, z.ZodObject<{
794
+ state: z.ZodLiteral<"unpushed">;
795
+ aheadBy: z.ZodNumber;
796
+ checkedAt: z.ZodString;
797
+ }, z.core.$strip>, z.ZodObject<{
798
+ state: z.ZodLiteral<"local-only">;
799
+ checkedAt: z.ZodString;
800
+ }, z.core.$strip>, z.ZodObject<{
801
+ state: z.ZodLiteral<"gone-unexplained">;
802
+ checkedAt: z.ZodString;
803
+ }, z.core.$strip>, z.ZodObject<{
804
+ state: z.ZodLiteral<"diverged">;
805
+ offline: z.ZodBoolean;
806
+ checkedAt: z.ZodString;
807
+ }, z.core.$strip>, z.ZodObject<{
808
+ state: z.ZodLiteral<"detached">;
809
+ checkedAt: z.ZodString;
810
+ }, z.core.$strip>, z.ZodObject<{
811
+ state: z.ZodLiteral<"unknown">;
812
+ reason: z.ZodLiteral<"offline">;
813
+ checkedAt: z.ZodString;
814
+ }, z.core.$strip>]>;
815
+ checkedAt: z.ZodString;
816
+ }, z.core.$strip>;
817
+ type VerdictMemoRecord = z.infer<typeof verdictMemoRecordSchema>;
818
+ declare const VERDICT_MEMO_PATH: () => string;
819
+ /** Only forge-derived verdicts are ever memoised (PLAN.md §1.4/§2) — local-only classifications need no cache. */
820
+ interface VerdictMemoStore {
821
+ get(repo: string, branch: string, tipSha: string): Promise<VerdictMemoRecord | null>;
822
+ set(record: VerdictMemoRecord): Promise<void>;
823
+ }
824
+ /**
825
+ * `~/.agentproto/worktree-verdicts.json` — entries keyed by `(repo, branch,
826
+ * tipSha)`. This is NOT a third status authority (PLAN.md §1.4): every
827
+ * entry memoises an immutable fact (a containment check between two fixed
828
+ * SHAs has one eternal answer; a merged PR stays merged). Deleting the file
829
+ * changes nothing but speed and offline honesty.
830
+ */
831
+ declare class FileVerdictMemoStore implements VerdictMemoStore {
832
+ private readonly path;
833
+ private cache;
834
+ constructor(path?: string);
835
+ private load;
836
+ get(repo: string, branch: string, tipSha: string): Promise<VerdictMemoRecord | null>;
837
+ set(record: VerdictMemoRecord): Promise<void>;
838
+ }
839
+ /** In-memory-only store — for tests, or callers that don't want a durable memo. */
840
+ declare class InMemoryVerdictMemoStore implements VerdictMemoStore {
841
+ private readonly map;
842
+ get(repo: string, branch: string, tipSha: string): Promise<VerdictMemoRecord | null>;
843
+ set(record: VerdictMemoRecord): Promise<void>;
844
+ }
845
+ interface GitWorktreeRef {
846
+ path: string;
847
+ /** `null` for a detached HEAD. */
848
+ branch: string | null;
849
+ head: string;
850
+ }
851
+ /** Parses `git worktree list --porcelain` — every linked worktree of `repoRoot`, wherever it lives. */
852
+ declare function listGitWorktrees(repoRoot: string): Promise<GitWorktreeRef[]>;
853
+ interface ReconcileIntegrationInput {
854
+ repoRoot: string;
855
+ repoName: string;
856
+ /** `null` for a detached HEAD — short-circuits to `{ state: "detached" }` (step 0). */
857
+ branch: string | null;
858
+ tipSha: string;
859
+ forge: ForgeClient;
860
+ memo: VerdictMemoStore;
861
+ /** Default `"origin/main"` — matches `loadConfigFromBase`'s own default. */
862
+ defaultBranchRef?: string;
863
+ /** Injectable clock so callers (the determinism test) can freeze `checkedAt`. */
864
+ now?: () => string;
865
+ }
866
+ /**
867
+ * PLAN.md §1.3 — the heart of the design. Implements the six-step rule
868
+ * exactly: local ancestry first (cheap, no forge), then the forge's
869
+ * merged-PR containment check (the squash-proof signal), then open PRs,
870
+ * then local upstream tracking, with the verdict memo as the only fallback
871
+ * when the forge is unreachable. Never guesses (step 5): a memo miss while
872
+ * offline is `unknown(offline)`, full stop.
873
+ */
874
+ declare function reconcileIntegration(input: ReconcileIntegrationInput): Promise<IntegrationState>;
875
+ /** `services` is always empty in PR-A — service liveness needs the daemon's in-process `ServiceSupervisor` (out of scope, see below). */
876
+ type LivenessState = {
877
+ state: "idle";
878
+ sessions: ProvenanceInfo["sessions"];
879
+ services: string[];
880
+ } | {
881
+ state: "sessions";
882
+ sessions: ProvenanceInfo["sessions"];
883
+ services: string[];
884
+ } | {
885
+ state: "daemon-unreachable";
886
+ sessions: ProvenanceInfo["sessions"];
887
+ services: string[];
888
+ };
889
+ /**
890
+ * Live sessions whose `cwd` is contained in this worktree, read off the
891
+ * same persisted sessions snapshot as provenance (PLAN.md §1.5) — no
892
+ * running daemon required, matching `worktree check`'s no-daemon posture
893
+ * (PLAN.md §3.3). Service liveness needs the daemon's in-process
894
+ * `ServiceSupervisor` and is out of scope here (`services` is always `[]`);
895
+ * `daemon-unreachable` fires only when the sessions snapshot itself is
896
+ * present but unreadable (corrupt/permission-denied), not merely absent —
897
+ * "no daemon has ever run" is a legitimate empty registry, not an error.
898
+ */
899
+ declare function computeLiveness(worktreePath: string, options?: ComputeProvenanceOptions): Promise<LivenessState>;
900
+ type GateState = {
901
+ state: "none";
902
+ };
903
+ type WorktreeClass = "reclaim" | "salvage" | "hold";
904
+ interface Classification {
905
+ reclaimable: boolean;
906
+ class: WorktreeClass;
907
+ }
908
+ /**
909
+ * `reclaimable ⇔ tree=clean ∧ integration ∈ {merged, fresh} ∧ liveness ∈
910
+ * {idle, daemon-unreachable}` (PLAN.md §1.2). `daemon-unreachable` is
911
+ * allowed because the hard guarantee against losing work is git's own
912
+ * non-`--force` removal refusal, not the liveness probe. `fresh` reclaims
913
+ * when clean for the same reason: a clean tree has nothing uncommitted to
914
+ * lose, so it doesn't matter that `fresh` can't distinguish "never had
915
+ * commits" from "real commits, fast-forwarded into main" (see
916
+ * `reconcileIntegration` step 1) — either way there's nothing at risk.
917
+ *
918
+ * Salvage is narrower than reclaim on purpose: only `merged` (always
919
+ * forge-confirmed — see `reconcileIntegration`) ever licenses destroying
920
+ * dirty, uncommitted work — `fresh` never does, clean or not, because the
921
+ * ambiguity in what `fresh` can prove is exactly what destroyed a live
922
+ * worktree via `gc --apply --salvage-dirty` on 2026-07-15: a branch with
923
+ * zero commits of its own read as "merged" and its uncommitted work was
924
+ * salvaged-then-removed out from under a running agent. A `merged ∧ dirty`
925
+ * worktree written to inside `RECENT_WRITE_HOLD_WINDOW_MS` also holds
926
+ * instead of salvaging — see that constant's doc. Everything else is
927
+ * `hold`.
928
+ */
929
+ declare function classify(tree: TreeState, integration: IntegrationState, liveness: LivenessState, nowMs?: number): Classification;
930
+ interface WorktreeStatusEntry {
931
+ path: string;
932
+ branch: string | null;
933
+ head: string;
934
+ tree: TreeState;
935
+ integration: IntegrationState;
936
+ liveness: LivenessState;
937
+ provenance: ProvenanceInfo;
938
+ gate: GateState;
939
+ reclaimable: boolean;
940
+ class: WorktreeClass;
941
+ }
942
+ interface ComputeWorktreeStatusInput {
943
+ repoRoot: string;
944
+ repoName: string;
945
+ worktree: GitWorktreeRef;
946
+ forge: ForgeClient;
947
+ memo: VerdictMemoStore;
948
+ defaultBranchRef?: string;
949
+ sessionsPath?: string;
950
+ now?: () => string;
951
+ /**
952
+ * Clock for `classify`'s `RECENT_WRITE_HOLD_WINDOW_MS` guard — deliberately
953
+ * separate from `now`. `now` freezes `checkedAt` stamps for deterministic
954
+ * tests and is often a fixed past instant; file mtimes are always real
955
+ * wall-clock, so comparing them against a frozen-past `now` would make
956
+ * every dirty fixture look like it was written in the future (a negative,
957
+ * and therefore always-"recent", delta). Defaults to `Date.now()`.
958
+ */
959
+ nowMs?: number;
960
+ }
961
+ /** Computes all three axes + provenance + classification for one worktree. */
962
+ declare function computeWorktreeStatus(input: ComputeWorktreeStatusInput): Promise<WorktreeStatusEntry>;
963
+ interface ListWorktreeStatusesInput {
964
+ repoRoot: string;
965
+ repoName: string;
966
+ forge: ForgeClient;
967
+ memo: VerdictMemoStore;
968
+ defaultBranchRef?: string;
969
+ sessionsPath?: string;
970
+ now?: () => string;
971
+ }
972
+ /** Enumerates every linked worktree of `repoRoot` (`git worktree list`) and computes its status. */
973
+ declare function listWorktreeStatuses(input: ListWorktreeStatusesInput): Promise<WorktreeStatusEntry[]>;
974
+
53
975
  /** The subset of `HarnessClient` the host drives — narrowed for fake-client tests. */
54
976
  type DaemonClient = Pick<HarnessClient, "start" | "prompt" | "output" | "kill" | "waitForAny" | "close">;
55
977
  interface DaemonAgentSessionHost extends AgentSessionHost {
@@ -88,4 +1010,152 @@ declare function connectDaemonAgentSessionHost(opts?: ConnectHarnessOptions): Pr
88
1010
  /** Build the host over an already-connected client (exported for tests with a fake client). */
89
1011
  declare function makeDaemonAgentSessionHost(client: DaemonClient): DaemonAgentSessionHost;
90
1012
 
91
- export { type DaemonAgentSessionHost, type DaemonClient, type ExecResult, type WorktreeAgentInput, connectDaemonAgentSessionHost, execArgv, execGit, execShell, expandGlob, globToRegExp, makeDaemonAgentSessionHost, worktreeAgentInputSchema, worktreeAgentWorkflow };
1013
+ /**
1014
+ * `gc` (PLAN.md §5, PR-D): plan → apply → salvage over the status engine's
1015
+ * classification (`status.ts`, PR-A). This module owns no new git knowledge
1016
+ * — it reuses `listGitWorktrees` / `computeWorktreeStatus` / `classify` for
1017
+ * every fact and `salvageWorktree` / `worktree.cleanup` (PR-C) for every
1018
+ * mutation. Its only original logic is sequencing the four safety layers
1019
+ * (PLAN.md §5.2):
1020
+ *
1021
+ * 1. `planGc` is pure read: it classifies every worktree and returns a
1022
+ * plan. Nothing is mutated by calling it — the CLI's dry-run default
1023
+ * *is* just "call planGc, print it, stop."
1024
+ * 2. `applyGc` re-derives each worktree's class from scratch immediately
1025
+ * before touching it (a fresh `listGitWorktrees` + `computeWorktreeStatus`,
1026
+ * not a reuse of the plan's stale snapshot) and refuses to act if the
1027
+ * class changed since the plan was made.
1028
+ * 3. The actual removal is `worktree.cleanup` with NO discard flags for a
1029
+ * reclaim-class entry — git's own non-`--force` refusal is the backstop
1030
+ * this module leans on, never a force flag "just in case."
1031
+ * 4. A salvage-class entry is only ever touched via `salvageWorktree`
1032
+ * followed by `worktree.cleanup` with both discard flags — snapshot
1033
+ * first, removal only after the snapshot is durable (mirrors `worktree
1034
+ * archive`'s CLI verb, PR-C).
1035
+ *
1036
+ * The main worktree (`repoRoot` itself) is never part of the plan: `git
1037
+ * worktree list`'s first entry is always the main checkout, and a repo
1038
+ * sitting on an up-to-date default branch is trivially `fresh ∧ clean` —
1039
+ * exactly what `reclaim` looks for. Git itself refuses to remove
1040
+ * the main working tree ("is a main working tree", verified empirically),
1041
+ * but this module doesn't lean on that as the only guard: the main worktree
1042
+ * is filtered out before classification even runs, so it can never appear
1043
+ * in a plan, let alone be attempted.
1044
+ */
1045
+
1046
+ type GcClass = "reclaim" | "salvage" | "hold";
1047
+ interface ClassifyForGcOptions {
1048
+ /**
1049
+ * A clean, idle/daemon-unreachable DETACHED worktree moves from `hold` to
1050
+ * `reclaim` (PLAN.md §5.1). This is the only reclassification power any
1051
+ * flag has: every other hold reason — partial, open, unknown(offline),
1052
+ * gone-unexplained, diverged — is untouched by any flag, ever. Default
1053
+ * false.
1054
+ */
1055
+ includeDetached?: boolean;
1056
+ /** Threaded straight through to `classify`'s `RECENT_WRITE_HOLD_WINDOW_MS` guard. Defaults to `Date.now()`. */
1057
+ nowMs?: number;
1058
+ }
1059
+ /**
1060
+ * gc's own classifier, layered on top of `classify` (status.ts) rather than
1061
+ * reimplementing it — reuse first (AGENTS.md, PLAN.md §0.6). The one thing
1062
+ * `classify` cannot know is `--include-detached`, a gc-specific flag; that
1063
+ * override lives here, not in the read-only status engine `ls --status`
1064
+ * also depends on.
1065
+ */
1066
+ declare function classifyForGc(tree: TreeState, integration: IntegrationState, liveness: LivenessState, options?: ClassifyForGcOptions): GcClass;
1067
+ interface GcPlanEntry {
1068
+ path: string;
1069
+ branch: string | null;
1070
+ head: string;
1071
+ tree: TreeState;
1072
+ integration: IntegrationState;
1073
+ liveness: LivenessState;
1074
+ /** Classification per PLAN.md §5.1, after `--include-detached` (if set). */
1075
+ class: GcClass;
1076
+ }
1077
+ interface PlanGcInput {
1078
+ repoRoot: string;
1079
+ repoName: string;
1080
+ forge: ForgeClient;
1081
+ memo: VerdictMemoStore;
1082
+ defaultBranchRef?: string;
1083
+ sessionsPath?: string;
1084
+ includeDetached?: boolean;
1085
+ now?: () => string;
1086
+ /** See `ComputeWorktreeStatusInput.nowMs` (status.ts) — kept separate from `now`. Defaults to `Date.now()`. */
1087
+ nowMs?: number;
1088
+ }
1089
+ /**
1090
+ * The dry-run plan (PLAN.md §5.2 layer 1): classify every linked worktree,
1091
+ * mutate nothing. This is the human's decision surface — `gc` with no flags
1092
+ * is exactly "call this, print it, stop."
1093
+ */
1094
+ declare function planGc(input: PlanGcInput): Promise<GcPlanEntry[]>;
1095
+ interface ApplyGcOptions {
1096
+ repoRoot: string;
1097
+ repoName: string;
1098
+ forge: ForgeClient;
1099
+ memo: VerdictMemoStore;
1100
+ defaultBranchRef?: string;
1101
+ sessionsPath?: string;
1102
+ includeDetached?: boolean;
1103
+ /** Archive (salvage-then-remove) every salvage-class entry. Default false — salvage entries are left untouched. */
1104
+ salvageDirty?: boolean;
1105
+ now?: () => string;
1106
+ /** See `ComputeWorktreeStatusInput.nowMs` (status.ts) — kept separate from `now`. Defaults to `Date.now()`. */
1107
+ nowMs?: number;
1108
+ /** Override for `~/.agentproto/worktree-salvage` — tests use a temp dir. */
1109
+ salvageRoot?: string;
1110
+ }
1111
+ type GcApplyOutcome = {
1112
+ path: string;
1113
+ branch: string | null;
1114
+ result: "reclaimed";
1115
+ } | {
1116
+ path: string;
1117
+ branch: string | null;
1118
+ result: "salvaged";
1119
+ salvageDir: string;
1120
+ } | {
1121
+ path: string;
1122
+ branch: string | null;
1123
+ result: "held";
1124
+ }
1125
+ /** Salvage-class, but `--salvage-dirty` was not passed: left untouched by design. */
1126
+ | {
1127
+ path: string;
1128
+ branch: string | null;
1129
+ result: "skipped-dirty";
1130
+ }
1131
+ /** Layer 2: re-classified at apply time to something other than the plan said — refuses rather than acting on a stale plan. */
1132
+ | {
1133
+ path: string;
1134
+ branch: string | null;
1135
+ result: "aborted-reclassified";
1136
+ from: GcClass;
1137
+ to: GcClass;
1138
+ }
1139
+ /** The worktree named in the plan no longer exists (already removed by something else since the plan was made). */
1140
+ | {
1141
+ path: string;
1142
+ branch: string | null;
1143
+ result: "aborted-vanished";
1144
+ }
1145
+ /** git itself (or the salvage snapshot) refused — the TOCTOU backstop actually firing, or a real I/O failure. */
1146
+ | {
1147
+ path: string;
1148
+ branch: string | null;
1149
+ result: "failed";
1150
+ message: string;
1151
+ };
1152
+ /**
1153
+ * Execute a plan (PLAN.md §5.2 layers 2–4). Every entry is re-classified
1154
+ * from scratch immediately before it's touched; a `hold` entry is never
1155
+ * touched, a `salvage` entry is only touched when `salvageDirty` is true,
1156
+ * and every removal goes through `worktree.cleanup` — never a hand-rolled
1157
+ * `git worktree remove`.
1158
+ */
1159
+ declare function applyGc(plan: readonly GcPlanEntry[], options: ApplyGcOptions): Promise<GcApplyOutcome[]>;
1160
+
1161
+ export { type AgentprotoConfig, type ApplyGcOptions, CONFIG_FILENAME, type Classification, type ClassifyForGcOptions, type ComputeProvenanceOptions, type ComputeWorktreeStatusInput, ConfigError, DEFAULT_PROXY_PORT, type DaemonAgentSessionHost, type DaemonClient, ENV_VARS, type ExecOptions, type ExecResult, FileVerdictMemoStore, type ForgeClient, type ForgePullRequestRef, ForgeUnavailableError, type GateState, type GcApplyOutcome, type GcClass, type GcPlanEntry, GhCliForgeClient, type GitWorktreeRef, HookError, type HookRun, type HostnameParts, InMemoryVerdictMemoStore, type IntegrationState, type ListWorktreeStatusesInput, type LivenessState, type NamedScript, type PeerService, type PlanGcInput, type ProvenanceConfidence, type ProvenanceInfo, ProxyTable, type ReconcileIntegrationInput, type ResolveSupervisorInput, type ResolvedWorktreeRuntime, RestForgeClient, type RunningProxy, SALVAGE_ROOT, SESSIONS_FILE_PATH, type SalvageManifest, type SalvageResult, type SalvageWorktreeInput, type ScriptConfig, type ServiceRuntime, type ServiceStatus, ServiceSupervisor, type SessionRef, type SupervisorOptions, type TreeState, UnreachableForgeClient, VERDICT_MEMO_PATH, type VerdictMemoRecord, type VerdictMemoStore, type WorktreeAgentInput, type WorktreeClass, type WorktreeEnvContext, type WorktreeMarker, WorktreeNotRemovableError, type WorktreeStatusEntry, allocatePort, applyGc, classify, classifyForGc, computeLiveness, computeProvenance, computeTreeState, computeWorktreeStatus, connectDaemonAgentSessionHost, createForgeClient, createProxyServer, detectDefaultBranch, disposeSupervisor, ephemeralPort, execArgv, execGit, execShell, expandGlob, getScript, getSupervisor, globToRegExp, hookEnv, isPortFree, listGitWorktrees, listScripts, listServices, listWorktreeStatuses, loadConfigFromBase, makeDaemonAgentSessionHost, normalizeHook, parseConfig, parseGithubOwnerRepo, peerEnv, peerEnvKey, planGc, readSessionsRegistry, readWorktreeMarker, reconcileIntegration, repoLabel, resolveSupervisor, runSetup, runTeardown, salvageWorktree, serviceEnv, serviceEnvToken, serviceHostname, serviceUrl, sessionInWorktree, sharedProxyTable, slugify, startProxy, stripPort, worktreeAgentInputSchema, worktreeAgentWorkflow, writeWorktreeMarker };