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