@awebai/oats 0.41.1 → 0.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core.mjs CHANGED
@@ -43,10 +43,11 @@ import { attachSessionTarget } from "./session-viewer.mjs";
43
43
  import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
44
44
  import { appendEvent, liveWaiting, recordStartBoundary } from "./instance-events.mjs";
45
45
  import { killGroup } from "./process-group.mjs";
46
+ import { captureLoginEnvironment } from "./login-environment.mjs";
46
47
 
47
48
  import { oatsError, herdrInstanceBusy, herdrInstanceRemoved, herdrSettingRemoved, HERDR_REMOVED } from "./errors.mjs";
48
49
  import { envRows, recordedTeams, teamsEnv } from "./teams.mjs";
49
- import { harnessUnavailable, launchConfigUnknown, launchLayers, launchReport, selectionFrom } from "./launch-preference.mjs";
50
+ import { PANE_PATH_FIX, harnessNotOnPanePath, harnessUnavailable, launchConfigUnknown, launchLayers, launchReport, selectionFrom } from "./launch-preference.mjs";
50
51
  import { claudeTrusts, codexTrustsRoot, harnessTrustWarning } from "./harness-trust.mjs";
51
52
  async function materializePreparedDefault(prepared, home) { const m = await import("./instance-resolution.mjs"); return m.materializePrepared(prepared, home); }
52
53
  // Capability rows for a PREPARED spawn (workspace model): the one function that
@@ -60,15 +61,17 @@ import { GIT_FETCH_TIMEOUT_MS, GIT_TIMEOUT_MS, gitEnv } from "./remote.mjs";
60
61
  import { loadLocal } from "./workspace.mjs";
61
62
  import { parseConfigData } from "./config-data.mjs";
62
63
  import { renderInstructionText } from "./instruction-composition.mjs";
63
- import { APPROVED_HOOKS, PORTABLE_ENV_NAME_RE, CORE_LAUNCH_ENV, PROCESS_BOOTSTRAP_ENV, PROCESS_BOOTSTRAP_PREFIXES, manifestContractProblems } from "./capability-contract.mjs";
64
+ import { APPROVED_HOOKS, PORTABLE_ENV_NAME_RE, CORE_LAUNCH_ENV, PROCESS_BOOTSTRAP_ENV, PROCESS_BOOTSTRAP_PREFIXES, manifestContractProblems, disposableHomeRootProblem, disposableHomeRootMatches } from "./capability-contract.mjs";
64
65
  import { validateBindingInterface } from "./provider-binding.mjs";
65
- /** Retirement/rollback tree fingerprint (exported for scope tests: kernel-field neutrality is opt-in per instance home). */
66
- export { fingerprintTree };
66
+ /** Retirement/rollback tree fingerprint (exported for scope tests: kernel-field neutrality is opt-in per instance home),
67
+ * the exact digest a copied path is compared with (exported so a test holds it to the copier),
68
+ * and the receipts the stored digest passes over in a home (exported so a test holds the manifest grammar to refusing each of them). */
69
+ export { exactTreeDigest, fingerprintTree, KERNEL_HOME_RECEIPTS };
67
70
 
68
71
  import { canonicalJson, lineAt, parseStrictJson } from "./canonical-json.mjs";
69
72
  import { readPortableBytes } from "./bounded-read.mjs";
70
73
  import { copyTreeSafe } from "./tree-copy.mjs";
71
- import { assertSameWorktreeHead, worktreeCommitUnreached, worktreeHead } from "./instance-git.mjs";
74
+ import { assertSameWorktreeHead, gitRead, gitRepoRead, gitRepoRun, headName, worktreeCommitUnreached, worktreeHead } from "./instance-git.mjs";
72
75
  /** The package and capability id grammar (namespaced, lowercase): an id names a
73
76
  * directory (a home's module copy), so no path spelling fits it. */
74
77
  const PACKAGE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
@@ -572,7 +575,9 @@ const LAUNCH_CONFIG_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
572
575
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
573
576
  /** Environment the kernel sets for every launch (identity, home, roots) and
574
577
  * its reference aliases: a configuration may not name them. */
575
- export const RESERVED_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_SOUL_ID", "OATS_ROOT", "OATS_CONTEXT", "OATS_WORKSPACE", "OATS_EVENT", "OATS_SETTINGS", "OATS_SETTINGS_ORIGINS", "OATS_CLI_BIN", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT"]);
578
+ export const RESERVED_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_SOUL_ID", "OATS_ROOT", "OATS_CONTEXT", "OATS_WORKSPACE", "OATS_EVENT", "OATS_SETTINGS", "OATS_SETTINGS_ORIGINS", "OATS_CLI_BIN", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT",
579
+ // The test seam that replaces the login shell (lib/login-environment.mjs): never a launch's to set.
580
+ "OATS_TEST_LOGIN_SHELL"]);
576
581
  export const LAUNCH_REF_PREFIX = "OATS_LAUNCH_REF_";
577
582
  const reservedLaunchEnv = (n) => RESERVED_LAUNCH_ENV.has(n) || n.startsWith(LAUNCH_REF_PREFIX);
578
583
  export function validateLaunchConfig(name, entry, where) {
@@ -681,19 +686,10 @@ function validateCapabilityManifest(m, mf) {
681
686
  // has one flat name space per organisation instead (duplicate → E_CAPABILITY_AMBIGUOUS
682
687
  // at resolution), so the grammar is the only requirement.
683
688
  if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) throw new Error(`capability ID must match ^[a-z0-9][a-z0-9._-]*$: "${id}" (${mf})`);
684
- if (m.retirement !== undefined) {
685
- if (!isPlainObject(m.retirement) || !isPlainObject(m.retirement.disposable)) throw new Error(`capability ${id} manifest retirement must contain a disposable map`);
686
- const unknown = Object.keys(m.retirement).filter((key) => key !== "disposable");
687
- const scopes = Object.keys(m.retirement.disposable).filter((key) => !["home", "work"].includes(key));
688
- if (unknown.length || scopes.length) throw new Error(`capability ${id} manifest retirement has unsupported keys: ${[...unknown, ...scopes].join(", ")}`);
689
- for (const scope of ["home", "work"]) {
690
- const roots = m.retirement.disposable[scope];
691
- if (roots !== undefined && (!Array.isArray(roots) || roots.some((root) => typeof root !== "string"))) throw new Error(`capability ${id} manifest retirement.disposable.${scope} must be an array of relative roots`);
692
- }
693
- }
694
- // Launch environment and hooks: the shared manifest contract (lib/capability-contract.mjs).
689
+ // Launch environment, hooks and retirement: the shared manifest contract
690
+ // (lib/capability-contract.mjs). The error carries the problem's JSON pointer.
695
691
  const problem = manifestContractProblems(m)[0];
696
- if (problem) throw new Error(problem.message);
692
+ if (problem) throw Object.assign(new Error(problem.message), { pointer: problem.pointer });
697
693
  return id;
698
694
  }
699
695
 
@@ -2002,12 +1998,15 @@ function withoutKernelEnvironment(source) {
2002
1998
  * backtick, a quote and a backslash are written the same by every version read, and are carried.
2003
1999
  *
2004
2000
  * Kept: an entry whose NAME is a plain identifier and whose VALUE is none of the above; an `unset`
2005
- * entry carries nothing. An entry is left out only once it is framed whole (its name, its value to
2006
- * the closing quote, the exact `; export NAME;` tail); its name is added to `omitted` when the
2007
- * caller passes one. null for bytes that are not valid UTF-8 or that this grammar does not consume
2008
- * to the end: a caller refuses, it never uses a partial result.
2001
+ * entry carries nothing, and its name is added to `cleared` when the caller passes one (tmux keeps
2002
+ * such an entry, and it masks the variable: a session's `unset PATH;` gives its panes no PATH). An
2003
+ * entry is left out only once it is framed whole (its name, its value to the closing quote, the
2004
+ * exact `; export NAME;` tail); its name is added to `omitted` when the caller passes one. So a
2005
+ * name is in the result, in `cleared`, in `omitted`, or absent from the text. null for bytes that
2006
+ * are not valid UTF-8 or that this grammar does not consume to the end: a caller refuses, it never
2007
+ * uses a partial result.
2009
2008
  */
2010
- export function parseTmuxShellEnvironment(bytes, omitted) {
2009
+ export function parseTmuxShellEnvironment(bytes, omitted, cleared) {
2011
2010
  let text;
2012
2011
  try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return null; }
2013
2012
  const env = {};
@@ -2017,6 +2016,7 @@ export function parseTmuxShellEnvironment(bytes, omitted) {
2017
2016
  if (take("unset ")) {
2018
2017
  const end = text.indexOf(";", i);
2019
2018
  if (end <= i || /[\s"=]/.test(text.slice(i, end))) return null;
2019
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(text.slice(i, end))) cleared?.push(text.slice(i, end));
2020
2020
  i = end + 1;
2021
2021
  } else {
2022
2022
  const assign = text.indexOf("=\"", i);
@@ -2074,11 +2074,19 @@ function callerInstance() {
2074
2074
  function creatorTmux(session) {
2075
2075
  const refuse = (why) => oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot create tmux session ${session} on the OATS tmux server: ${why}, and the tmux that creates a session is the one this process's own PATH finds; the session was not created. Run this command with a PATH that holds tmux`);
2076
2076
  if (process.env.PATH === undefined) throw refuse("PATH is not set in this process");
2077
- for (const dir of process.env.PATH.split(":")) {
2078
- const candidate = resolve(dir || ".", "tmux");
2077
+ const found = lookupOnPath("tmux", process.env.PATH);
2078
+ if (found) return found;
2079
+ throw refuse("no tmux was found on this process's PATH");
2080
+ }
2081
+ /** `name` as an exec looks it up on `path`: the first entry, in order, that holds a regular
2082
+ * executable file of that name, as an absolute path; null when none does. A relative entry, and
2083
+ * an empty one (an empty PATH is one empty entry), are `cwd`. Nothing is run. */
2084
+ function lookupOnPath(name, path, cwd = process.cwd()) {
2085
+ for (const dir of path.split(":")) {
2086
+ const candidate = resolve(cwd, dir || ".", name);
2079
2087
  try { accessSync(candidate, fsConstants.X_OK); if (statSync(candidate).isFile()) return candidate; } catch { /* not here */ }
2080
2088
  }
2081
- throw refuse("no tmux was found on this process's PATH");
2089
+ return null;
2082
2090
  }
2083
2091
  /**
2084
2092
  * The environment a `new-session` on the OATS server runs with: the only tmux call that can start
@@ -2087,80 +2095,188 @@ function creatorTmux(session) {
2087
2095
  * every pane created on it later inherits, and a new session takes the client's variables that
2088
2096
  * `update-environment` names.
2089
2097
  *
2090
- * A process inside an OATS instance never passes its own environment (its harness's variables, its
2091
- * credentials, its identity). It passes the global environment of the tmux server its home
2092
- * records: an existing baseline, chosen because a window that instance opened on that server got
2093
- * exactly it; not proof that it holds nothing old. The home's receipt is checked against
2094
- * instance.json first, as every session verb checks its endpoint, and the text is read with the
2095
- * strict reader above. When any of that fails, or no home is identified, the session is not
2096
- * created: no fallback to the caller's environment, to another server or to a built-in list. Any
2097
- * other creator (an operator's shell, a schedule runner, the Desktop) passes its own environment.
2098
- * In every case the kernel's names are removed from the final set.
2098
+ * When no server answered the lookup (`startsServer`), so that this new-session starts it, the
2099
+ * environment is the user's login environment (lib/login-environment.mjs), for every creator: what
2100
+ * the login shell from the password database sets up, from a seed that holds nothing of the creator
2101
+ * but its locale and, when it is no instance, its session variables (an instance's come from the
2102
+ * server its home records, then from the platform's user session). The kernel's names and the
2103
+ * instance-identity names are removed from what it answers, and the operator's OATS configuration
2104
+ * (OATS_ and PI_AGENTS_ names that are not the kernel's) is the creator's (an instance's: its recorded
2105
+ * server's). When it cannot be read (a creator whose HOME is not the user's home directory, a
2106
+ * timeout, a non-zero exit, a partial or malformed answer, no HOME or PATH, a shell that is not bash,
2107
+ * zsh or fish), one line on stderr says so and names the fallback, never a value, and the creator falls
2108
+ * back to what follows. That fallback is degraded: neither a login environment nor a working agent
2109
+ * socket is assured.
2110
+ *
2111
+ * Otherwise (the server runs, or the login environment could not be read): a process inside an OATS
2112
+ * instance never passes its own environment (its harness's variables, its credentials, its
2113
+ * identity). It passes the global environment of the tmux server its home records: an existing
2114
+ * baseline, chosen because a window that instance opened on that server got exactly it; not proof
2115
+ * that it holds nothing old. The home's receipt is checked against instance.json first, as every
2116
+ * session verb checks its endpoint, and the text is read with the strict reader above, which can
2117
+ * leave values out. When any of that fails, or no home is identified, the session is not created:
2118
+ * no fallback to the caller's environment, to another server or to a built-in list. Any other
2119
+ * creator (an operator's shell, a schedule runner, the Desktop) passes its own environment. In
2120
+ * every case the kernel's names are removed from the final set.
2099
2121
  *
2100
2122
  * Environment and endpoint are chosen separately: which server is reached depends only on the
2101
- * creator (its TMUX_TMPDIR, kept or absent as the creator has it; its tmux); what the server
2102
- * process gets (HOME and so its configuration file, PATH, everything else) comes from the selected
2103
- * environment, with no ambient value filling in. Values travel only as the environment of that
2104
- * tmux process: never in an argument, a message, an event or a file. docs/execution-targets.md
2105
- * owns the rule.
2123
+ * creator (its TMUX_TMPDIR, kept or absent as the creator has it, whatever the selected environment
2124
+ * says; its tmux); what the server process gets (HOME and so its configuration file, PATH,
2125
+ * everything else) comes from the selected environment, with no ambient value filling in. Values
2126
+ * travel only as the environment of that tmux process: never in an argument, a message, an event or
2127
+ * a file. docs/execution-targets.md owns the rule.
2106
2128
  */
2107
2129
  const TMUX_SERVER_START_ENV = ["HOME", "XDG_CONFIG_HOME", "PATH", "SHELL"];
2108
- function oatsSessionEnvironment(session, io) {
2130
+ /** The operator's configuration names, carried into a server started with the login environment
2131
+ * (the kernel's own names among them are removed first). */
2132
+ const OPERATOR_CONFIG_ENV = /^(OATS_|PI_AGENTS_)/;
2133
+ function oatsSessionEnvironment(session, io, { startsServer = false } = {}) {
2109
2134
  const caller = callerInstance();
2135
+ let recorded;
2136
+ const recordedServer = () => (recorded ??= recordedServerEnvironment(caller, session, io));
2137
+ const creatorsTmpdir = (env) => { if (process.env.TMUX_TMPDIR === undefined) delete env.TMUX_TMPDIR; else env.TMUX_TMPDIR = process.env.TMUX_TMPDIR; return env; };
2138
+ if (startsServer) {
2139
+ const login = captureLoginEnvironment({ creatorEnv: process.env, instance: !!caller, recorded: caller ? recordedServer().read : null, shell: process.env.OATS_TEST_LOGIN_SHELL || undefined });
2140
+ if (login.env) {
2141
+ for (const note of login.notes) process.stderr.write(`oats: warning: ${note}\n`);
2142
+ const env = withoutKernelEnvironment(login.env);
2143
+ for (const name of INSTANCE_IDENTITY_ENV) delete env[name];
2144
+ // The operator's own OATS configuration (OATS_HOME_DIR, OATS_TMUX_SESSION, OATS_PACKAGE_CATALOG,
2145
+ // any other OATS_ or PI_AGENTS_ name that is not the kernel's) stays the creator's, over what
2146
+ // the login shell set: a creator outside every instance gives its own; an instance, what the
2147
+ // server its home records has, never its own.
2148
+ const configured = caller ? recordedServer().read : process.env;
2149
+ for (const [name, value] of Object.entries(withoutKernelEnvironment(configured ?? {}))) if (OPERATOR_CONFIG_ENV.test(name)) env[name] = value;
2150
+ return creatorsTmpdir(env);
2151
+ }
2152
+ const fallback = !caller ? "this process's own environment" : recordedServer().error ? null : "a copy of the environment of the tmux server this instance's home records";
2153
+ process.stderr.write(`oats: warning: could not read your login environment (${login.failure}); ${fallback ? `the OATS tmux server is started with ${fallback} instead: a fallback, neither a login environment nor a working agent socket is assured` : "and there is no fallback from inside an instance"}\n`);
2154
+ }
2110
2155
  if (!caller) return withoutKernelEnvironment(process.env);
2156
+ const { env, error } = recordedServer();
2157
+ if (error) throw error;
2158
+ return creatorsTmpdir(env);
2159
+ }
2160
+ /** The global environment of the tmux server an instance's home records, read strictly: `read` (what
2161
+ * the reader kept, or null), and `env` (a copy without the kernel's names) or `error` (the refusal
2162
+ * a creation from inside an instance gets when it cannot use it). */
2163
+ function recordedServerEnvironment(caller, session, io) {
2111
2164
  const refuse = (why) => oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot create tmux session ${session} on the OATS tmux server from inside an OATS instance: ${why}. A tmux server and its sessions keep the environment of the process that creates them, and an instance's own environment must not become theirs; the session was not created. Create it from your own shell, outside every instance home (tmux -L ${OATS_TMUX_SERVER} new-session -d -s ${shellWord(session)} -n hq), or run this command there. tmux resolves -L ${OATS_TMUX_SERVER} with TMUX_TMPDIR: use the same TMUX_TMPDIR as this process, if it has one`);
2112
- if (!caller.home) throw refuse(`this process carries an instance's identity (${caller.evidence}) and neither OATS_INSTANCE_HOME nor the working directory names its home`);
2165
+ if (!caller.home) return { read: null, error: refuse(`this process carries an instance's identity (${caller.evidence}) and neither OATS_INSTANCE_HOME nor the working directory names its home`) };
2113
2166
  let target;
2114
2167
  try { target = instanceSessionTarget(caller.home).target; }
2115
- catch (e) { if (!oatsCoded(e)) throw e; throw refuse(`the session receipt of ${basename(caller.home)} could not be used (${e.code})`); }
2116
- if (!target?.socket) throw refuse(`the home of ${basename(caller.home)} records no tmux server`);
2168
+ catch (e) { if (!oatsCoded(e)) throw e; return { read: null, error: refuse(`the session receipt of ${basename(caller.home)} could not be used (${e.code})`) }; }
2169
+ if (!target?.socket) return { read: null, error: refuse(`the home of ${basename(caller.home)} records no tmux server`) };
2117
2170
  // Bytes, not text; and nothing of a failed read (tmux's output, its error) goes into the refusal.
2118
- let recorded = null;
2171
+ let read = null;
2119
2172
  const omitted = [];
2120
2173
  try {
2121
2174
  const printed = (io?.exec || execFileSync)("tmux", ["-u", "-S", target.socket, "show-environment", "-g", "-s"], { timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
2122
- recorded = parseTmuxShellEnvironment(Buffer.isBuffer(printed) ? printed : Buffer.from(String(printed)), omitted);
2175
+ read = parseTmuxShellEnvironment(Buffer.isBuffer(printed) ? printed : Buffer.from(String(printed)), omitted);
2123
2176
  } catch (e) { if (oatsCoded(e)) throw e; }
2124
- if (recorded === null) throw refuse(`the environment of the tmux server that ${basename(caller.home)} is recorded on (${target.socket}) could not be read`);
2177
+ if (read === null) return { read, error: refuse(`the environment of the tmux server that ${basename(caller.home)} is recorded on (${target.socket}) could not be read`) };
2125
2178
  // A variable the reader left out is never filled in from this process, and a server is never
2126
2179
  // started when the one left out decides which configuration it loads or which programs it runs.
2127
2180
  // (A recorded server that simply has none of them is copied as it is.)
2128
2181
  const undecided = TMUX_SERVER_START_ENV.find((name) => omitted.includes(name));
2129
- if (undecided) throw refuse(`the tmux server that ${basename(caller.home)} is recorded on has a ${undecided} that cannot be carried over (its value holds a $, a line break or a character tmux prints encoded), and a tmux server's configuration and programs are chosen by it`);
2130
- const env = withoutKernelEnvironment(recorded);
2131
- if (process.env.TMUX_TMPDIR === undefined) delete env.TMUX_TMPDIR; else env.TMUX_TMPDIR = process.env.TMUX_TMPDIR;
2132
- return env;
2182
+ if (undecided) return { read, error: refuse(`the tmux server that ${basename(caller.home)} is recorded on has a ${undecided} that cannot be carried over (its value holds a $, a line break or a character tmux prints encoded), and a tmux server's configuration and programs are chosen by it`) };
2183
+ return { read, env: withoutKernelEnvironment(read) };
2133
2184
  }
2134
2185
  /** The environment a `new-window` client runs with, and the `respawn-pane` client of a restart in
2135
- * place. tmux gives a pane the PATH of the client that creates its window or respawns it, and
2136
- * nothing else of that client (spawn.c). A process inside an instance
2137
- * passes PATH, without any home's kernel shim directory (the launch command puts the new home's
2138
- * own first), and the three names tmux's client reads its locale from: it does not start without
2139
- * a UTF-8 locale, and on a host that has neither en_US.UTF-8 nor C.UTF-8 only these name one.
2140
- * Nothing else of the instance travels by any route. When it has no PATH to give (none is set, or
2141
- * only shim directories were in it) no PATH is passed: an empty one would be one empty entry, the
2142
- * working directory, for the lookup of `tmux` and for the pane. A PATH that is set and empty is
2143
- * passed as it is. Any other creator passes its environment without the kernel's names. */
2186
+ * place, for every creator (an instance, the Desktop, an operator's shell): the three names tmux's
2187
+ * client reads its locale from, and nothing else. tmux's client does not start without a UTF-8
2188
+ * locale, and on a host that has neither en_US.UTF-8 nor C.UTF-8 only these name one; tmux gives
2189
+ * the pane none of them. A pane's environment is its session's over the server's global one
2190
+ * (spawn.c environ_for_session), PATH included: tmux puts the client's PATH in its place only when
2191
+ * the client has one, and this client has none. So a PATH and the state that interprets it (a
2192
+ * version manager's bookkeeping, `__MISE_DIFF`, nvm's) come from one environment, the server's or
2193
+ * the session's; OATS no longer overlays a creator's PATH onto it. That does not make an existing
2194
+ * or external server's environment, a session override or a launch configuration's PATH coherent:
2195
+ * OATS uses them as they are. The client is run by an absolute tmux (tmuxOn), since it has no PATH
2196
+ * to look one up with. docs/execution-targets.md owns the rule. */
2144
2197
  const TMUX_CLIENT_LOCALE_ENV = ["LANG", "LC_ALL", "LC_CTYPE"];
2145
2198
  function oatsWindowEnvironment() {
2146
- if (!callerInstance()) return withoutKernelEnvironment(process.env);
2147
2199
  const env = {};
2148
- const dirs = process.env.PATH === undefined ? [] : process.env.PATH.split(":").filter((dir) => !/(?:^|\/)instances\/[^/]+\/\.oats\/bin\/?$/.test(dir));
2149
- if (dirs.length) env.PATH = dirs.join(":");
2150
2200
  for (const name of TMUX_CLIENT_LOCALE_ENV) if (process.env[name] !== undefined) env[name] = process.env[name];
2151
2201
  return env;
2152
2202
  }
2153
2203
  /**
2154
- * An optimisation for a spawn or a start, to call BEFORE it creates, stops or writes anything: when
2155
- * this process is inside an instance and `session` is not on the OATS server, the environment its
2156
- * creation needs is read now, so that a caller that cannot create it is refused with nothing left
2157
- * behind; the answer goes to ensureOatsTmuxSession. undefined when there is nothing to read (the
2158
- * session exists, or the caller is no instance: no tmux is run for it). Not the guarantee:
2159
- * ensureOatsTmuxSession decides by what it finds when it creates.
2204
+ * What a spawn or a start knows, BEFORE it creates, stops or writes anything, about the session it
2205
+ * will open a window in: `{ server, present }` from the lookup and, when `session` is not on the
2206
+ * OATS server and this is no preview, `env`: the environment its creation needs, read now, so that a
2207
+ * caller that cannot create it is refused with nothing left behind; the answer goes to
2208
+ * ensureOatsTmuxSession. It is also where the harness is first looked up (expectedPanePath). Not the
2209
+ * guarantee: ensureOatsTmuxSession decides by what it finds when it creates, and the harness is
2210
+ * looked up again on the session it returns; and an environment read for a running server is read
2211
+ * again there when that server has gone. undefined when this process has no tmux to run: the
2212
+ * caller's backend check refuses that.
2160
2213
  */
2161
- function planOatsTmuxSession(session, io) {
2162
- if (!callerInstance() || oatsTmuxSessionSocket(session, io).present) return undefined;
2163
- return { env: oatsSessionEnvironment(session, io) };
2214
+ function planOatsTmuxSession(session, io, { preview = false } = {}) {
2215
+ if (!io?.exec && !which("tmux")) return undefined;
2216
+ let server, present;
2217
+ try { ({ server, present } = oatsTmuxSessionSocket(session, io)); }
2218
+ catch (e) { if (preview) return undefined; throw e; } // a preview reports, it does not refuse on a read
2219
+ if (present || preview) return { server, present };
2220
+ return { server, present, env: oatsSessionEnvironment(session, io, { startsServer: !server }), startsServer: !server };
2221
+ }
2222
+ /**
2223
+ * The PATH a pane created in `session` on `socket` starts with, read as tmux decides it (spawn.c,
2224
+ * environ.c), never computed from this process: the session's own entry when it has one, otherwise
2225
+ * the server's global PATH. `session` null reads the global one only (a session about to be created
2226
+ * on a running server). Both are read with the strict reader, as bytes.
2227
+ * → `{ path, source }`, or `{ source, problem }` when the pane's PATH cannot be established: the
2228
+ * session entry is an explicit clear (`unset PATH;`: the pane has no PATH at all; never substituted
2229
+ * by the global one or this process's), a value the strict reader leaves out (unknown), a read that
2230
+ * failed, or a server with no PATH (tmux would fall back to a compiled-in default, which OATS does not
2231
+ * assume). `source` names where the PATH comes from; no value is ever in it or in `problem`.
2232
+ */
2233
+ function panePath(socket, session, io, cwd) {
2234
+ const read = (scope) => {
2235
+ const omitted = [], cleared = [];
2236
+ let env = null;
2237
+ try {
2238
+ const printed = (io?.exec || execFileSync)("tmux", ["-u", "-S", socket, "show-environment", ...scope, "-s"], { timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
2239
+ env = parseTmuxShellEnvironment(Buffer.isBuffer(printed) ? printed : Buffer.from(String(printed)), omitted, cleared);
2240
+ } catch (e) { if (oatsCoded(e)) throw e; }
2241
+ return { env, omitted, cleared };
2242
+ };
2243
+ const decide = ({ env, omitted, cleared }, source, unset) => {
2244
+ if (env === null) return { source, problem: `${source} could not be read` };
2245
+ if (Object.hasOwn(env, "PATH")) return { path: env.PATH, source, cwd };
2246
+ if (cleared.includes("PATH")) return { source, problem: unset };
2247
+ if (omitted.includes("PATH")) return { source, problem: `${source} cannot be read as data (its value holds a $, a line break or a character tmux prints encoded)` };
2248
+ return null;
2249
+ };
2250
+ if (session) {
2251
+ const own = decide(read(["-t", `=${session}`]), `the PATH of tmux session ${session}`, `tmux session ${session} clears PATH (unset PATH), so its panes start with no PATH at all`);
2252
+ if (own) return own;
2253
+ }
2254
+ const source = `the global PATH of the OATS tmux server (${socket})`;
2255
+ return decide(read(["-g"]), source, `the OATS tmux server (${socket}) clears PATH (unset PATH), so its panes start with no PATH at all`)
2256
+ ?? { source, problem: `the OATS tmux server (${socket}) has no PATH, and tmux then gives a pane a compiled-in default that OATS does not assume` };
2257
+ }
2258
+ /** Where a launch's pane is expected to look its harness up, from a plan (planOatsTmuxSession): the
2259
+ * session's or the server's PATH when a server runs, else the PATH of the environment the server
2260
+ * will be created with. undefined when that is not known (no plan, or a preview with no server: a
2261
+ * preview reads no creation environment); the lookup then uses this process's PATH, and the launch
2262
+ * looks again on the session it actually gets. */
2263
+ function expectedPanePath(plan, session, io, cwd) {
2264
+ if (!plan) return undefined;
2265
+ if (plan.server) return panePath(plan.server, plan.present ? session : null, io, cwd);
2266
+ if (!plan.env) return undefined;
2267
+ const source = "the PATH the OATS tmux server is created with";
2268
+ return plan.env.PATH === undefined ? { source, problem: `${source} is not set, and tmux then gives a pane a compiled-in default that OATS does not assume` } : { path: plan.env.PATH, source, cwd };
2269
+ }
2270
+ /** The PATH a launch's harness is looked up on, by precedence: a PATH set in the launch
2271
+ * configuration (a literal, or a reference resolved from `env`) is what the pane's command line
2272
+ * applies, so the lookup uses it; otherwise the pane's own PATH (`pane`, from panePath or
2273
+ * expectedPanePath; a function is called only when it is needed). A declared absolute or relative
2274
+ * executable is never looked up (resolveLaunchExecutable). undefined: look up on this process's PATH. */
2275
+ function launchLookup(configEnv, env, pane, configName, cwd) {
2276
+ const own = configEnv?.PATH;
2277
+ if (typeof own === "string") return { path: own, source: `the PATH of launch configuration ${configName}`, cwd };
2278
+ if (own && typeof own === "object" && own.fromEnv) return env[own.fromEnv] === undefined ? undefined : { path: env[own.fromEnv], source: `the PATH of launch configuration ${configName} (from ${own.fromEnv})`, cwd };
2279
+ return typeof pane === "function" ? pane() : pane;
2164
2280
  }
2165
2281
  /**
2166
2282
  * Make sure `session` exists on the OATS server and return that server's absolute socket: the one
@@ -2182,7 +2298,9 @@ export function ensureOatsTmuxSession(session, hq, io, plan) {
2182
2298
  // The executable is chosen here, where the creation is about to run, and nowhere earlier: a
2183
2299
  // session that exists needs none, so nothing is refused for it.
2184
2300
  const tmux = creatorTmux(session);
2185
- const env = plan?.env ?? oatsSessionEnvironment(session, io);
2301
+ // The plan's environment, unless it was read for a running server that has gone since: a server
2302
+ // this call starts gets what a server start gets.
2303
+ const env = plan?.env && (plan.startsServer || socket) ? plan.env : oatsSessionEnvironment(session, io, { startsServer: !socket });
2186
2304
  const address = socket ? ["-S", socket] : ["-L", OATS_TMUX_SERVER];
2187
2305
  let created;
2188
2306
  try { created = (io?.exec || execFileSync)(tmux, ["-u", ...address, "new-session", "-d", "-s", session, "-n", "hq", "-c", hq, "-P", "-F", "#{socket_path}\t#{window_id}"], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], env }).trim(); }
@@ -2469,19 +2587,31 @@ const TASK_PROMPT = Object.freeze({ claude: "@TASK.md", codex: CODEX_TASK_PROMPT
2469
2587
  /** The executable a launch uses: a configuration's declared one (a bare name
2470
2588
  * on PATH; a path against the deployment directory when relative) or the
2471
2589
  * harness's name on PATH. Never executed. A new claude launch with a legacy `oats-claude-config` in reach
2472
- * of its context is refused (E_CLAUDE_CONFIG_REMOVED, 0.32), whatever it declares. */
2473
- export function resolveLaunchExecutable({ harness, declared, declaringDir, contextDir }) {
2590
+ * of its context is refused (E_CLAUDE_CONFIG_REMOVED, 0.32), whatever it declares.
2591
+ * `lookup` (launchLookup: `{ path, source, cwd }` or `{ source, problem }`) is the PATH a bare name is
2592
+ * looked up on, the one the pane runs it with; without one, this process's PATH. A result looked up
2593
+ * there carries `lookup`, so a refusal can name where it looked. */
2594
+ export function resolveLaunchExecutable({ harness, declared, declaringDir, contextDir, lookup }) {
2474
2595
  if (harness === "claude" && contextDir) { const refused = legacyClaudeConfigRefusal(contextDir); if (refused) throw refused; }
2475
- if (declared) {
2476
- if (declared.includes("/")) {
2477
- const path = isAbsolute(declared) ? declared : resolve(declaringDir || contextDir, declared);
2478
- return { path, declared, resolvedFrom: isAbsolute(declared) ? "absolute" : `relative to ${declaringDir || contextDir}`, missing: existsSync(path) ? undefined : `${declared} (${path}) does not exist` };
2479
- }
2480
- const found = which(declared);
2481
- return { path: found || null, declared, resolvedFrom: "PATH", missing: found ? undefined : `${declared} binary not found on PATH` };
2482
- }
2483
- const found = which(harness);
2484
- return { path: found || null, declared: null, resolvedFrom: "PATH", missing: found ? undefined : `${harness} binary not found on PATH` };
2596
+ if (declared?.includes("/")) {
2597
+ const path = isAbsolute(declared) ? declared : resolve(declaringDir || contextDir, declared);
2598
+ return { path, declared, resolvedFrom: isAbsolute(declared) ? "absolute" : `relative to ${declaringDir || contextDir}`, missing: existsSync(path) ? undefined : `${declared} (${path}) does not exist` };
2599
+ }
2600
+ const name = declared || harness;
2601
+ if (lookup) {
2602
+ const found = lookup.problem ? null : lookupOnPath(name, lookup.path, lookup.cwd);
2603
+ return { path: found, declared: declared || null, resolvedFrom: "PATH", lookup, missing: found ? undefined : lookup.problem ?? `${name} was not found on ${lookup.source}` };
2604
+ }
2605
+ const found = which(name);
2606
+ return { path: found || null, declared: declared || null, resolvedFrom: "PATH", missing: found ? undefined : `${name} binary not found on PATH` };
2607
+ }
2608
+ /** The refusal for an executable a launch looked up where its pane looks it up (`executable.lookup`)
2609
+ * and did not find, or could not look up: E_HARNESS_UNAVAILABLE for the harness's own name,
2610
+ * E_LAUNCH_EXECUTABLE for a configuration's declared name. Names the source and the remedies, never
2611
+ * the PATH's contents. */
2612
+ function executableNotOnPanePath(executable, { harness, config, from, at }) {
2613
+ if (!executable.declared) return harnessNotOnPanePath({ harness, from, at, why: executable.missing });
2614
+ return oatsError("E_LAUNCH_EXECUTABLE", `launch configuration ${config?.name}: ${executable.missing}; ${PANE_PATH_FIX}`);
2485
2615
  }
2486
2616
  /** null when `path` is a regular executable file; otherwise why not. */
2487
2617
  export function checkLaunchExecutable(path) {
@@ -2816,7 +2946,7 @@ function requirementsWithArgsMessage(harness, providers, config) {
2816
2946
  * and not at all for a preview. A home
2817
2947
  * that records no launch recipe (an earlier kernel spawned it) is not planned:
2818
2948
  * E_LAUNCH_LEGACY, re-spawn it from the deployment. */
2819
- export function planLaunch({ home, instance, meta, contextDir, agentLike, selection = {}, launchConfigs, resolvedCfg, env = process.env, preview = false, assertRoots, reselect = null }) {
2949
+ export function planLaunch({ home, instance, meta, contextDir, agentLike, selection = {}, launchConfigs, resolvedCfg, env = process.env, preview = false, assertRoots, reselect = null, panePath: expectedPane }) {
2820
2950
  assertRoots?.();
2821
2951
  const problems = [];
2822
2952
  const fail = (check, code, detail) => { if (!preview) throw oatsError(code, detail); problems.push({ check, ok: false, detail, code }); };
@@ -2842,16 +2972,23 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
2842
2972
  // the default no longer decides (a bare --launch-config none, another harness).
2843
2973
  const frozenYolo = frozen && !(frozen.launchConfigDefault === true && !config?.frozen) ? frozen.yolo : undefined;
2844
2974
  const yolo = resolveYolo(selection.yolo ?? chosen.configuredYolo ?? (frozen ? frozenYolo : agentLike?.yolo ?? resolvedCfg?.yolo));
2975
+ // A frozen recipe keeps its recorded path. Otherwise a bare name is looked up where the pane will
2976
+ // look it up (launchLookup: the configuration's PATH, else `expectedPane`, the start's expected
2977
+ // session or server PATH; a start looks again on the session it gets).
2845
2978
  const executable = config?.frozen
2846
2979
  ? { path: config.executablePath, declared: frozen.executableDeclared ?? null, resolvedFrom: frozen.executableResolvedFrom || "recorded", missing: existsSync(config.executablePath) ? undefined : `${config.executablePath} (recorded) does not exist` }
2847
- : resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir });
2848
- if (!executable.path && !config?.executable && launchChoice) {
2980
+ : resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir, lookup: config?.executable?.includes("/") ? undefined : launchLookup(config?.env, env, expectedPane, config?.name, home) });
2981
+ const notOnPane = !executable.path && executable.lookup ? executableNotOnPanePath(executable, { harness, config, from: launchChoice?.from, at: launchChoice?.at }) : null;
2982
+ if (notOnPane) {
2983
+ if (!preview) throw notOnPane;
2984
+ problems.push({ check: "executable", ok: false, detail: notOnPane.message, code: notOnPane.code });
2985
+ } else if (!executable.path && !config?.executable && launchChoice) {
2849
2986
  const e = harnessUnavailable({ harness, from: launchChoice.from, at: launchChoice.at, why: executable.missing });
2850
2987
  if (!preview) throw e;
2851
2988
  problems.push({ check: "executable", ok: false, detail: e.message, code: e.code });
2852
2989
  }
2853
- const exeProblem = executable.path ? checkLaunchExecutable(executable.path) : !config?.executable && launchChoice ? null : executable.missing;
2854
- if (exeProblem) fail("executable", "E_LAUNCH_EXECUTABLE", `launch configuration ${config?.name || "(harness default)"}: ${exeProblem}`); else problems.push({ check: "executable", ok: true, detail: `${executable.path} (${executable.resolvedFrom})` });
2990
+ const exeProblem = executable.path ? checkLaunchExecutable(executable.path) : notOnPane || (!config?.executable && launchChoice) ? null : executable.missing;
2991
+ if (exeProblem) fail("executable", "E_LAUNCH_EXECUTABLE", `launch configuration ${config?.name || "(harness default)"}: ${exeProblem}`); else if (!notOnPane) problems.push({ check: "executable", ok: true, detail: `${executable.path} (${executable.resolvedFrom})` });
2855
2992
  // Capability contributions: recorded at spawn with provenance; a harness
2856
2993
  // switch needs the new harness's launch args from the same capabilities.
2857
2994
  let hooks = { launch: {}, env: {}, contributions: [], pending: true };
@@ -3106,10 +3243,11 @@ function* spawnBody(root, agent, o = {}) {
3106
3243
  if (holder) throw Object.assign(oatsError("E_INSTANCE_NAME_TAKEN", `instance name "${instance}" is taken in this deployment (${holder}); instance names are unique across every soul — pick another --name`), { instance, home: holder });
3107
3244
  if (oatsTmuxWindows(session).includes(instance)) throw Object.assign(oatsError("E_INSTANCE_NAME_TAKEN", `instance name "${instance}" is taken: a live tmux window of that name exists in session ${session} — pick another --name`), { instance, session });
3108
3245
  }
3109
- // A caller inside an instance that will have to create the tmux session reads the environment
3110
- // for it here, before any scaffold, work tree, identity or hook, so that one that cannot is
3111
- // refused with nothing left behind. A caller outside every instance runs no tmux for it.
3112
- const tmuxSessionPlan = launch && o.preview !== true ? planOatsTmuxSession(session) : undefined;
3246
+ // A caller that will have to create the tmux session reads the environment for it here, before
3247
+ // any scaffold, work tree, identity or hook, so that an instance that cannot is refused with
3248
+ // nothing left behind; the harness is first looked up on the PATH this plan expects (below). A
3249
+ // preview reads only what exists.
3250
+ const tmuxSessionPlan = launch ? planOatsTmuxSession(session, undefined, { preview: o.preview === true }) : undefined;
3113
3251
 
3114
3252
  // Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
3115
3253
  // anchor the new instance to an EXISTING instance (o.relativeTo). o.parent
@@ -3393,7 +3531,12 @@ function* spawnBody(root, agent, o = {}) {
3393
3531
  // reconciliation, so this spawn-time check is the authoritative one.
3394
3532
 
3395
3533
  // Prerequisites must fail before creating a home, worktree, or identity.
3396
- const executable = resolveLaunchExecutable({ harness, declared: launchConfig?.executable, declaringDir: launchConfig?.source, contextDir: repoAbs });
3534
+ // A launched harness is looked up where its pane will look it up (launchLookup): this is the
3535
+ // preflight against the expected PATH; the launch looks again on the session it gets. Without a
3536
+ // launch (or a known destination) the lookup is this process's PATH, as before.
3537
+ const lookupFor = (pane) => launch && !launchConfig?.executable?.includes("/") ? launchLookup(launchConfig?.env, process.env, pane, launchConfig?.name, home) : undefined;
3538
+ const executable = resolveLaunchExecutable({ harness, declared: launchConfig?.executable, declaringDir: launchConfig?.source, contextDir: repoAbs, lookup: lookupFor(() => expectedPanePath(tmuxSessionPlan, session, undefined, home)) });
3539
+ if (!executable.path && executable.lookup) { previewPreflightBudget = null; throw executableNotOnPanePath(executable, { harness, config: launchConfig, from: launchChoice.from, at: launchChoice.at }); }
3397
3540
  // No executable for the chosen harness (none declared by a configuration): E_HARNESS_UNAVAILABLE names the
3398
3541
  // layer that chose it and the fix. Never a fallback to another harness.
3399
3542
  if (!executable.path && !launchConfig?.executable) { previewPreflightBudget = null; throw harnessUnavailable({ harness, from: launchChoice.from, at: launchChoice.at, why: executable.missing }); }
@@ -3902,7 +4045,7 @@ function* spawnBody(root, agent, o = {}) {
3902
4045
  assertDirectoryRoots(home, homeReal);
3903
4046
  const path = join(home, "work");
3904
4047
  if (!readdirSync(path).length) return;
3905
- const directoryFingerprint = fingerprintTree(path);
4048
+ const directoryFingerprint = directoryBytes(path);
3906
4049
  if (lastDirectoryFingerprint === directoryFingerprint) return;
3907
4050
  const receipt = preserveRetirementWork({ home, work: path, directory: true, directoryFingerprint, classes: ["directory work bytes"] }, { work, repo: repoAbs }, instance);
3908
4051
  directoryRecoveries.push(receipt.path);
@@ -4034,7 +4177,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
4034
4177
  if (triggerEventFile) recipe.env.OATS_TRIGGER_EVENT_FILE = triggerEventFile;
4035
4178
  const folderTrust = launchFolderTrust({ harness, home, meta: { workspace: { deployment: o.prepared?.deployment } }, yolo });
4036
4179
  if (folderTrust.warning) warnings.push(folderTrust.warning);
4037
- const cmdline = renderLaunchRecipe(recipe, { home, instance, trustHome: folderTrust.trustHome });
4180
+ let cmdline = renderLaunchRecipe(recipe, { home, instance, trustHome: folderTrust.trustHome });
4038
4181
 
4039
4182
  // Module skills as materialize landed them (flat, .agents/skills/<skill>/),
4040
4183
  // beside the soul's own: per-skill provenance `module:<cap>` (lead decision c3),
@@ -4133,11 +4276,26 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
4133
4276
 
4134
4277
  spawnTmux = meta.tmux;
4135
4278
  if (work === "directory") assertDirectoryRoots(home, homeReal);
4136
- const executionCommand = launch ? nativeRecordCommand(cmdline, home, harness) : null;
4279
+ let executionCommand = launch ? nativeRecordCommand(cmdline, home, harness) : null;
4137
4280
  if (launch) {
4138
4281
  // The session on the OATS server, then everything on the one socket that answered: the collision
4139
4282
  // check, the records, the window and (in compensateSpawn) its removal.
4140
4283
  const socket = ensureOatsTmuxSession(session, existsSync(root) ? root : workspaceOf(root), undefined, tmuxSessionPlan);
4284
+ // The harness looked up again on the PATH this window's pane will actually start with, whoever
4285
+ // created the session and with whatever environment (a race lost to another creator, a session
4286
+ // that overrides PATH). Not there: refused, and the spawn is compensated. Found elsewhere than
4287
+ // the preflight found it: the launch runs that one.
4288
+ if (executable.lookup) {
4289
+ const actual = resolveLaunchExecutable({ harness, declared: launchConfig?.executable, declaringDir: launchConfig?.source, lookup: lookupFor(() => panePath(socket, session, undefined, home)) });
4290
+ if (!actual.path) throw executableNotOnPanePath(actual, { harness, config: launchConfig, from: launchChoice.from, at: launchChoice.at });
4291
+ const bad = checkLaunchExecutable(actual.path);
4292
+ if (bad) throw oatsError("E_LAUNCH_EXECUTABLE", `launch configuration ${launchConfig?.name || "(harness default)"}: ${bad}`);
4293
+ if (actual.path !== recipe.executable) {
4294
+ recipe.executable = actual.path;
4295
+ meta.command = cmdline = renderLaunchRecipe(recipe, { home, instance, trustHome: folderTrust.trustHome });
4296
+ executionCommand = nativeRecordCommand(cmdline, home, harness);
4297
+ }
4298
+ }
4141
4299
  let present;
4142
4300
  try { present = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"]).split("\n").filter(Boolean); }
4143
4301
  catch (e) { throw new Error(`could not list the windows of tmux session ${session} on ${socket}: ${tmuxFailure(e, "tmux list-windows failed")}`); }
@@ -4692,43 +4850,147 @@ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
4692
4850
 
4693
4851
  /** Kernel-owned receipts the kernel itself appends to a home after the spawn
4694
4852
  * baseline (events log, stop/restart receipts). They are evidence about the
4695
- * instance, not the instance's work, so a retirement fingerprint ignores them —
4696
- * otherwise every stop or event write would read as "changed home bytes". */
4853
+ * instance, not the instance's work, so the stored digest a spawn baseline is
4854
+ * compared with passes over them — otherwise every stop or event write would
4855
+ * read as "changed home bytes". The home copy carries them, so the exact
4856
+ * digest, which says whether the retire hooks moved the home, holds them.
4857
+ * A new kernel-written top-level home entry, whether or not it belongs here,
4858
+ * MUST be added in the same change to the names a capability may not declare
4859
+ * as its own (`retirement.disposable.home`: KERNEL_HOME_NAMES and
4860
+ * KERNEL_HOME_NAME_PREFIXES in lib/capability-contract.mjs, and the schema). */
4697
4861
  const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
4698
4862
  const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
4699
4863
  /** Harness project settings in the home are configuration, not work: the
4700
4864
  * home's `.claude/` is harness layout the kernel already shapes (the skills
4701
4865
  * alias), and capabilities keep their own entries current in its
4702
- * settings.json at every launch. A retirement fingerprint ignores exactly
4703
- * that path, home-relative. */
4866
+ * settings.json at every launch. The stored digest passes over exactly that
4867
+ * path, home-relative; the exact digest holds it, as the home copy does. */
4704
4868
  const HARNESS_HOME_SETTINGS = new Set([join(".claude", "settings.json")]);
4705
- function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false, instanceHome = false } = {}) {
4706
- const hash = createHash("sha256");
4869
+ /** The refusal for an entry that is not a file, a directory or a symbolic link
4870
+ * (a socket, a FIFO, a device): it has no bytes to read or to copy. One
4871
+ * sentence for every caller of fingerprintTree, at spawn and at retire, so it
4872
+ * names neither. It does not say "remove": the entry may be a live endpoint. */
4873
+ const unsupportedEntry = (path) => oatsError("E_WORK_INSPECTION_FAILED", `${path} has an unsupported filesystem type: it is not a file, a directory or a symbolic link, so it cannot be read or copied. Safely stop the process or resource that owns it, or move the entry elsewhere, before retrying`);
4874
+ const PATH_SEPARATOR_BYTES = Buffer.from(sep);
4875
+ /** The digests of a tree, from one walk that reads each file once → { stored,
4876
+ * exact }; each only when asked for (`stored` by default).
4877
+ *
4878
+ * Both hash, for each entry in order, its path under the root, its
4879
+ * permission bits, its kind, and a file's bytes or a link's target. Entry
4880
+ * names and link targets are read and hashed as bytes, never as decoded
4881
+ * text: two trees that differ only in bytes that are not UTF-8 have two
4882
+ * digests, and an entry with such a name is read, not missed. This is about
4883
+ * what a digest tells apart. It does not make such a tree copyable:
4884
+ * copyTreeSafe reads entry names as text and cannot carry a file name that
4885
+ * is not UTF-8.
4886
+ *
4887
+ * A name's text (its bytes decoded as UTF-8, the way readdirSync decodes
4888
+ * them: replacement characters for bytes that are not UTF-8, a byte order
4889
+ * mark kept) is used only to order the entries and to test membership: the
4890
+ * names a digest passes over and `instance.json`, as they have always been
4891
+ * tested. Both digests pass over what a copy does not carry: `excludeRoot`
4892
+ * and, with `excludeGitMetadata`, `.git`. Only the stored digest also passes
4893
+ * over the kernel's receipts and the harness settings of a home.
4894
+ *
4895
+ * `stored` is the digest a spawn baseline holds, written by this kernel or
4896
+ * by an older one, so it never changes: its framing (each field followed by
4897
+ * NUL, no lengths), its order, and a home's `instance.json` without the
4898
+ * fields the kernel writes after spawn (kernelNeutralInstanceJson). For a
4899
+ * tree whose names and link targets are all valid UTF-8 it is what it was
4900
+ * when they were read as text: hashing a text hashes its UTF-8, which is
4901
+ * those bytes. It is what is compared with a baseline. Without lengths it
4902
+ * reads alike a file whose bytes spell the entry that follows it and those
4903
+ * two entries, and the neutral form reads alike two `instance.json` files
4904
+ * that parse to the same value.
4905
+ *
4906
+ * `exact` is for every comparison of a tree with itself later, or with its
4907
+ * copy: whether the retire hooks left it as it was. It is kept nowhere. It
4908
+ * holds every entry the copy carries: of a home, the kernel's receipts and
4909
+ * the harness settings too, which the copy carries and a retire hook can
4910
+ * write. Two different trees cannot give it the same stream: an entry is its
4911
+ * path, its mode, its kind and its content, where the path and the content
4912
+ * carry their length and the mode and the kind end in NUL, so the stream can
4913
+ * be read back into its entries in one way only, and a tree is the set of
4914
+ * its entries. A home's `instance.json` goes in as the bytes it has.
4915
+ *
4916
+ * The root's own permission bits, which are no entry of the walk, are in
4917
+ * the exact digest: a path the recovery copier copies is compared with its
4918
+ * own bits, because copyTreeSafe ends with a chmod of what it made.
4919
+ * `children` is for a root the copier creates itself (a home, a worktree):
4920
+ * its copy is made inside a directory the copier creates, so the root's bits
4921
+ * are no part of what it carries, and the root is compared through its
4922
+ * children alone. The stored digest never holds the root's bits. */
4923
+ function fingerprintTrees(root, { excludeRoot = new Set(), excludeGitMetadata = false, instanceHome = false, stored = true, exact = false, children = false } = {}) {
4924
+ const s = stored ? createHash("sha256") : null, x = exact ? createHash("sha256") : null;
4925
+ const digests = () => ({ stored: s ? `sha256:${s.digest("hex")}` : undefined, exact: x ? `sha256:${x.digest("hex")}` : undefined });
4926
+ /** A field of the exact digest whose length is not fixed: its length, NUL, its bytes. */
4927
+ const sized = (bytes) => { x.update(String(bytes.length)); x.update("\0"); x.update(bytes); };
4707
4928
  const rootStat = lstatSync(root);
4708
4929
  if (!rootStat.isDirectory()) {
4709
- hash.update(String(rootStat.mode & 0o7777)); hash.update("\0");
4710
- if (rootStat.isSymbolicLink()) { hash.update("link\0"); hash.update(readlinkSync(root)); }
4711
- else if (rootStat.isFile()) { hash.update("file\0"); hash.update(readFileSync(root)); }
4712
- else throw oatsError("E_WORK_INSPECTION_FAILED", `${root} has an unsupported filesystem type`);
4713
- return `sha256:${hash.digest("hex")}`;
4714
- }
4715
- const walk = (dir, rel = "") => {
4716
- for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
4717
- if ((!rel && excludeRoot.has(e.name)) || (instanceHome && !rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
4718
- const childRel = rel ? join(rel, e.name) : e.name;
4719
- if (instanceHome && HARNESS_HOME_SETTINGS.has(childRel)) continue;
4720
- const path = join(dir, e.name);
4930
+ const mode = String(rootStat.mode & 0o7777);
4931
+ s?.update(mode); s?.update("\0");
4932
+ x?.update("entry\0"); x?.update(mode); x?.update("\0");
4933
+ if (rootStat.isSymbolicLink()) { const target = readlinkSync(root, "buffer"); s?.update("link\0"); s?.update(target); if (x) { x.update("link\0"); sized(target); } }
4934
+ else if (rootStat.isFile()) { const bytes = readFileSync(root); s?.update("file\0"); s?.update(bytes); if (x) { x.update("file\0"); sized(bytes); } }
4935
+ else throw unsupportedEntry(root);
4936
+ return digests();
4937
+ }
4938
+ x?.update(children ? "tree\0" : `tree with its mode\0${rootStat.mode & 0o7777}\0`);
4939
+ // `dir` and `rel` are bytes: the directory, and its path under the root
4940
+ // (empty at the root). `relText` is `rel` as text, for the name tests.
4941
+ // `outer` is the stored hash, or null inside an entry it passes over.
4942
+ const walk = (dir, rel, relText, outer) => {
4943
+ // The order is the one it has always been: the names as text, compared
4944
+ // by localeCompare, on the listing as readdirSync returns it. The sort is
4945
+ // stable and the listing is the same whether its names come as text or
4946
+ // as bytes, so a tree whose names are all valid UTF-8 is walked in the
4947
+ // order it always was. Names that decode alike (two that are not UTF-8,
4948
+ // or one and a name that really holds the replacement character) keep
4949
+ // the listing's order. Where that order is not fixed, two reads of one
4950
+ // directory can differ, which adds a copy attempt; two different trees
4951
+ // cannot read alike for it, because the bytes of every name are hashed.
4952
+ const entries = readdirSync(dir, { encoding: "buffer" }).map((bytes) => ({ bytes, name: bytes.toString("utf8") }));
4953
+ entries.sort((a, b) => a.name.localeCompare(b.name));
4954
+ for (const { bytes: nameBytes, name } of entries) {
4955
+ if ((!relText && excludeRoot.has(name)) || (excludeGitMetadata && name === ".git")) continue;
4956
+ const childRelText = relText ? join(relText, name) : name;
4957
+ const storedPassesOver = instanceHome && ((!relText && (KERNEL_HOME_RECEIPTS.has(name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(name)))) || HARNESS_HOME_SETTINGS.has(childRelText));
4958
+ if (storedPassesOver && !x) continue;
4959
+ const s = storedPassesOver ? null : outer;
4960
+ const childRel = rel.length ? Buffer.concat([rel, PATH_SEPARATOR_BYTES, nameBytes]) : nameBytes;
4961
+ const path = Buffer.concat([dir, PATH_SEPARATOR_BYTES, nameBytes]);
4721
4962
  const st = lstatSync(path);
4722
- hash.update(childRel); hash.update("\0"); hash.update(String(st.mode & 0o7777)); hash.update("\0");
4723
- if (st.isSymbolicLink()) { hash.update("link\0"); hash.update(readlinkSync(path)); hash.update("\0"); }
4724
- else if (st.isFile()) { hash.update("file\0"); hash.update(instanceHome && !rel && e.name === "instance.json" ? kernelNeutralInstanceJson(readFileSync(path)) : readFileSync(path)); /* kernel-field neutrality applies ONLY when the tree IS an instance home; a work tree's instance.json is the agent's bytes */ hash.update("\0"); }
4725
- else if (st.isDirectory()) { hash.update("dir\0"); walk(path, childRel); }
4726
- else throw oatsError("E_WORK_INSPECTION_FAILED", `${path} has an unsupported filesystem type`);
4963
+ const mode = String(st.mode & 0o7777);
4964
+ s?.update(childRel); s?.update("\0"); s?.update(mode); s?.update("\0");
4965
+ if (x) { sized(childRel); x.update(mode); x.update("\0"); }
4966
+ if (st.isSymbolicLink()) {
4967
+ const target = readlinkSync(path, "buffer");
4968
+ s?.update("link\0"); s?.update(target); s?.update("\0");
4969
+ if (x) { x.update("link\0"); sized(target); }
4970
+ } else if (st.isFile()) {
4971
+ const bytes = readFileSync(path);
4972
+ // Kernel-field neutrality applies ONLY when the tree IS an instance home, and only to the
4973
+ // stored digest; a work tree's instance.json is the agent's bytes.
4974
+ s?.update("file\0"); s?.update(instanceHome && !relText && name === "instance.json" ? kernelNeutralInstanceJson(bytes) : bytes); s?.update("\0");
4975
+ if (x) { x.update("file\0"); sized(bytes); }
4976
+ } else if (st.isDirectory()) { s?.update("dir\0"); x?.update("dir\0"); walk(path, childRel, childRelText, s); }
4977
+ else throw unsupportedEntry(join(root, childRelText));
4727
4978
  }
4728
4979
  };
4729
- walk(root);
4730
- return `sha256:${hash.digest("hex")}`;
4980
+ walk(Buffer.from(root), Buffer.alloc(0), "", s);
4981
+ return digests();
4982
+ }
4983
+ /** The stored digest of a tree (fingerprintTrees): what a spawn baseline holds and is compared with. */
4984
+ function fingerprintTree(root, options = {}) {
4985
+ return fingerprintTrees(root, { ...options, stored: true, exact: false }).stored;
4986
+ }
4987
+ /** The exact digest of a tree (fingerprintTrees): what a tree is compared with itself later, or with its copy. */
4988
+ function exactTreeDigest(root, options = {}) {
4989
+ return fingerprintTrees(root, { ...options, stored: false, exact: true }).exact;
4731
4990
  }
4991
+ /** The bytes of a directory instance's work/, as their exact digest, with the
4992
+ * permission bits of work/ itself: what a copy of it carries. Kept nowhere. */
4993
+ const directoryBytes = (work) => exactTreeDigest(work);
4732
4994
 
4733
4995
  /** A worktree directory whose git admin entry is gone: no `.git`, an unreadable one, or a gitfile naming an
4734
4996
  * admin directory that no longer exists. Read from the gitfile, not asked of git, which would walk up into
@@ -4745,13 +5007,24 @@ function worktreeAdminMissing(work) {
4745
5007
  if (!m) return true;
4746
5008
  return !existsSync(join(resolve(work, m[1]), "HEAD"));
4747
5009
  }
4748
- function worktreeStatus(repo) {
5010
+ /** A repository's status as Git printed it, as bytes. A path that is not
5011
+ * UTF-8 is in there as it is: the work state compares these bytes
5012
+ * (repositoryGitState). Output that does not decode is not a failure; only
5013
+ * a `git status` that fails is. */
5014
+ function worktreeStatusBytes(repo) {
4749
5015
  try {
4750
- return execFileSync("git", ["-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
5016
+ return execFileSync("git", ["-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
4751
5017
  } catch (e) {
4752
5018
  throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect instance worktree: ${String(e.stderr ?? e.message ?? "").trim() || "git status failed"}`);
4753
5019
  }
4754
5020
  }
5021
+ /** The same status as text, for what reads its rows: the classes, the rows a
5022
+ * spawn baseline was taken from, the comparison of a copy with its source.
5023
+ * Bytes that are not UTF-8 read as replacement characters here, so this
5024
+ * text is never what proves a worktree unchanged. */
5025
+ function worktreeStatus(repo) {
5026
+ return worktreeStatusBytes(repo).toString("utf8");
5027
+ }
4755
5028
 
4756
5029
  /** `git status --porcelain=v1 -z` as Map<path, XY>; a rename/copy row names its source (`R ← old`). */
4757
5030
  function statusRows(z) {
@@ -4825,24 +5098,195 @@ function retirementDisposableRoots(work, capabilities) {
4825
5098
  const KERNEL_POST_SPAWN_FIELDS = ["spawnCompleted", "wake"];
4826
5099
  function kernelNeutralInstanceJson(bytes) {
4827
5100
  try {
4828
- const m = JSON.parse(String(bytes));
5101
+ // Bytes that are not UTF-8 would each read as the replacement character,
5102
+ // and two such files would then read alike: a file that does not decode
5103
+ // and encode back to the same bytes is hashed as it is, like one that is
5104
+ // not JSON.
5105
+ const text = String(bytes);
5106
+ if (!Buffer.from(text).equals(bytes)) return bytes;
5107
+ const m = JSON.parse(text);
4829
5108
  if (!m || typeof m !== "object" || Array.isArray(m)) return bytes;
4830
5109
  for (const k of KERNEL_POST_SPAWN_FIELDS) delete m[k];
4831
5110
  return Buffer.from(JSON.stringify(m));
4832
5111
  } catch { return bytes; }
4833
5112
  }
5113
+ const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
5114
+ /** The provider-owned home entries the active capabilities declare
5115
+ * (`retirement.disposable.home`, lib/capability-contract.mjs) → [{ owner, root }],
5116
+ * sorted by owner then root. Recorded in the spawn baseline: retirement reads
5117
+ * them from there and from nowhere else. */
5118
+ function retirementDisposableHome(capabilities) {
5119
+ const rows = new Map();
5120
+ for (const cap of capabilities || []) {
5121
+ for (const root of cap?.retirement?.disposable?.home || []) {
5122
+ if (disposableHomeRootProblem(root)) throw new Error(`retirement disposable home entry from ${cap.id} is not declarable: ${JSON.stringify(root)}`);
5123
+ rows.set(`${cap.id}\0${root}`, { owner: cap.id, root });
5124
+ }
5125
+ }
5126
+ return [...rows.values()].sort((a, b) => byCodeUnit(a.owner, b.owner) || byCodeUnit(a.root, b.root));
5127
+ }
5128
+ /** A baseline's `disposableHome`, or no exclusions at all: it must be an array
5129
+ * of { owner, root } whose every root passes the manifest grammar. Anything
5130
+ * else (absent, an older baseline, a malformed or widened row) excludes
5131
+ * nothing, so the home is copied whole. Never the manifest in the home's
5132
+ * module copy, the capability as it is today, or instance.json. */
5133
+ function baselineDisposableHome(baseline) {
5134
+ const rows = baseline?.disposableHome;
5135
+ if (!Array.isArray(rows) || !rows.every((r) => isPlainObject(r) && nonEmptyString(r.owner) && !disposableHomeRootProblem(r.root))) return [];
5136
+ return rows.map((r) => ({ owner: r.owner, root: r.root })).sort((a, b) => byCodeUnit(a.owner, b.owner) || byCodeUnit(a.root, b.root));
5137
+ }
5138
+ /** One pass's resolved exclusion set: the home's top-level entries that
5139
+ * `disposableHome` rows cover, matched by name (a symlink is its name; it is
5140
+ * never followed). The fingerprint, the copy and the copy's verification of
5141
+ * that pass all use this one set. → { excludeRoot: Set (work/ and the covered
5142
+ * names), notCopied: [{ scope: "home", path, owner }] sorted by path }: names
5143
+ * and owners only. Two owners of one entry: the first in owner order. */
5144
+ function resolveHomeExclusions(home, disposableHome = [], extraTrees = []) {
5145
+ const excludeRoot = new Set(["work"]);
5146
+ const notCopied = [];
5147
+ const extra = new Set(extraTrees.map((t) => t.name));
5148
+ if (!disposableHome.length && !extra.size) return { excludeRoot, notCopied };
5149
+ for (const name of readdirSync(home).sort(byCodeUnit)) {
5150
+ const owner = extra.has(name) ? EXTRA_WORKTREE_OWNER : name === "work" ? undefined : disposableHome.find((r) => disposableHomeRootMatches(r.root, name))?.owner;
5151
+ if (!owner) continue;
5152
+ excludeRoot.add(name);
5153
+ notCopied.push({ scope: "home", path: name, owner });
5154
+ }
5155
+ return { excludeRoot, notCopied };
5156
+ }
5157
+
5158
+ /** The `notCopied` owner of a verified extra tree: the retire's extra-tree
5159
+ * step handles it, not the home recovery. */
5160
+ const EXTRA_WORKTREE_OWNER = "kernel:extra-worktree";
5161
+ /** `git worktree list --porcelain -z` → [{ worktree, locked }]: `locked` is
5162
+ * the lock's reason ("" when it gives none), undefined when not locked. */
5163
+ function parseWorktreeList(out) {
5164
+ const records = [];
5165
+ for (const field of out.split("\0")) {
5166
+ if (field.startsWith("worktree ")) records.push({ worktree: field.slice("worktree ".length) });
5167
+ else if (records.length && (field === "locked" || field.startsWith("locked "))) records.at(-1).locked = field.slice("locked ".length);
5168
+ }
5169
+ return records;
5170
+ }
5171
+ /** The home's extra trees (awebai/oats#674), in every work mode: top-level
5172
+ * entries named `.work-*` that are real directories (not symlinks), whose
5173
+ * `.git` is a regular file, and that Git confirms are registered linked
5174
+ * worktrees of a repository outside the home: the git dir differs from the
5175
+ * common dir, the toplevel is the entry, and the repository's worktree list
5176
+ * names it. The repository is that list's first entry (its main worktree, or
5177
+ * the bare dir), as for the primary checkout (canonicalDeploymentPath).
5178
+ * Anything else named `.work-*` is ordinary home bytes. Read-only probes
5179
+ * (gitRead). → [{ name, path, repo, gitDir, commonDir, locked }] sorted by
5180
+ * name: `commonDir` is the repository's Git directory, which the step and
5181
+ * the reachability read name it by (bare or not). */
5182
+ function extraWorktreesOf(home) {
5183
+ const trees = [];
5184
+ let names;
5185
+ try { names = readdirSync(home); } catch { return trees; }
5186
+ const realHome = realPathOrNearest(home);
5187
+ const inHome = (p) => { const rel = relative(realHome, realPathOrNearest(p)); return rel === "" || !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)); };
5188
+ for (const name of names.sort(byCodeUnit)) {
5189
+ if (!name.startsWith(".work-")) continue;
5190
+ const path = join(home, name);
5191
+ try {
5192
+ if (!lstatSync(path).isDirectory() || !lstatSync(join(path, ".git")).isFile()) continue;
5193
+ } catch { continue; }
5194
+ const dirs = gitRead(path, ["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir", "--show-toplevel"]);
5195
+ if (!dirs.ok) continue;
5196
+ const [gitDir, commonDir, toplevel] = dirs.out.split("\n");
5197
+ if (!gitDir || !commonDir || !toplevel || realPathOrNearest(gitDir) === realPathOrNearest(commonDir)) continue;
5198
+ const real = realPathOrNearest(path);
5199
+ // A repository inside the home goes with the home: its trees are home bytes.
5200
+ if (realPathOrNearest(toplevel) !== real || inHome(commonDir)) continue;
5201
+ const list = gitRead(path, ["worktree", "list", "--porcelain", "-z"]);
5202
+ if (!list.ok) continue;
5203
+ const records = parseWorktreeList(list.out);
5204
+ const self = records.find((r) => realPathOrNearest(r.worktree) === real);
5205
+ if (!records.length || !self) continue;
5206
+ trees.push({ name, path, repo: records[0].worktree, gitDir, commonDir: realPathOrNearest(commonDir), locked: self.locked });
5207
+ }
5208
+ return trees;
5209
+ }
5210
+ /** Where a retired worktree is re-homed: `<workspace>/.agents/worktrees/<repoName>/<leaf>`,
5211
+ * the leaf being the branch, else `detached-<commit>`; when that exists (or
5212
+ * `taken` holds it), `<leaf>-2`, `-3`, … The one naming rule of work/ and of
5213
+ * the extra trees. Creates nothing. */
5214
+ function retainedWorktreeDest(workspace, repo, branch, commit, taken = new Set()) {
5215
+ const repoName = basename(realPathOrNearest(repo)).replace(/\.git$/, "") || "repo";
5216
+ const leaf = (branch ?? `detached-${(commit || "unknown").slice(0, 12)}`).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "work";
5217
+ const retainedRoot = join(workspace, ".agents", "worktrees", repoName);
5218
+ let dest = join(retainedRoot, leaf);
5219
+ for (let n = 2; existsSync(dest) || taken.has(dest); n++) dest = join(retainedRoot, `${leaf}-${n}`);
5220
+ taken.add(dest);
5221
+ return dest;
5222
+ }
5223
+ /** What retirement does with each extra tree (`trees`: extraWorktreesOf) →
5224
+ * [{ path, repo, branch, detachedAt, disposition, movedTo, reason }], one row
5225
+ * per tree in order: the retire plan's `extraWorktrees` and the binding the
5226
+ * retire checks before it acts. `remove` when the tree is clean: an empty
5227
+ * status (ignored and untracked files count), no operation in progress, and
5228
+ * a HEAD commit some ref of the repository reaches: its shared refs, never
5229
+ * the tree's own HEAD, reflog or refs/worktree/, which go with its admin
5230
+ * entry when it is removed. `retain` (to `movedTo`) otherwise, with why
5231
+ * in `reason`; a HEAD that cannot be read is not clean. `refuse` for a locked
5232
+ * tree, which Git will neither move nor remove. Read-only. */
5233
+ function extraWorktreeRows(trees, root) {
5234
+ const taken = new Set();
5235
+ return trees.map((tree) => {
5236
+ let name = null, commit = null;
5237
+ try { name = headName(tree.path); } catch { /* not clean, below */ }
5238
+ try { commit = worktreeHead(tree.path).commit; } catch { /* not clean, below */ }
5239
+ const row = { path: tree.path, repo: tree.repo, branch: name?.branch ?? null, detachedAt: name?.detached ? commit : null, disposition: "remove", movedTo: null, reason: null };
5240
+ if (tree.locked !== undefined) return { ...row, disposition: "refuse", reason: `it is locked${tree.locked ? ` (${tree.locked})` : ""}; unlock it with \`git worktree unlock\`, or move it out of the home, then retire again` };
5241
+ const why = [];
5242
+ if (!name || !commit) why.push("its HEAD could not be read");
5243
+ const status = gitRead(tree.path, ["status", "--porcelain", "-z", "--ignored", "--untracked-files=all", "--ignore-submodules=none"]);
5244
+ if (!status.ok) why.push(`its status could not be read (${status.err})`);
5245
+ else if (status.out.length) why.push("it holds uncommitted, untracked or ignored files");
5246
+ if (RECOVERABLE_GIT_ADMIN.some((n) => existsSync(join(tree.gitDir, n)))) why.push("an operation is in progress in it");
5247
+ if (commit) {
5248
+ const reach = gitRepoRead(tree.commonDir, ["for-each-ref", "--contains", commit, "--count=1", "--format=%(objectname)"]);
5249
+ if (!reach.ok) why.push(`which refs reach its HEAD commit could not be read (${reach.err})`);
5250
+ else if (!reach.out.length) why.push("its HEAD commit is reached by no ref");
5251
+ }
5252
+ if (!why.length) return row;
5253
+ return { ...row, disposition: "retain", movedTo: retainedWorktreeDest(workspaceOf(root), tree.repo, row.branch, commit, taken), reason: why.join("; ") };
5254
+ });
5255
+ }
5256
+ /** The retire plan's view of a home's extra trees: extraWorktreeRows of what
5257
+ * is there now. */
5258
+ export function extraWorktreePlan(home, root) {
5259
+ return extraWorktreeRows(extraWorktreesOf(home), root);
5260
+ }
5261
+ function unionNotCopied(...lists) {
5262
+ const byPath = new Map();
5263
+ for (const row of lists.flatMap((list) => list || [])) if (!byPath.has(row.path)) byPath.set(row.path, row);
5264
+ return [...byPath.values()].sort((a, b) => byCodeUnit(a.path, b.path));
5265
+ }
5266
+ function retirementBaselineValid(baseline, home) {
5267
+ return baseline?.version === RETIRE_BASELINE_VERSION && baseline.home === realPathOrNearest(home);
5268
+ }
5269
+ /** What a retire plan may say about recovery without hashing anything: where a
5270
+ * recovery would be written, and the home entries the spawn baseline declares
5271
+ * as not copied (none without a valid baseline). */
5272
+ export function retirementRecoveryFacts(home) {
5273
+ const baseline = readJsonOrUndefined(retirementBaselinePath(home));
5274
+ return { recoveryRoot: join(retirementStateRoot(home), "recovery"), disposableHome: retirementBaselineValid(baseline, home) ? baselineDisposableHome(baseline) : [] };
5275
+ }
4834
5276
  function writeRetirementBaseline(home, work, mode, capabilities, runtime, { exclusive = false, incarnationId, executionBinding } = {}) {
4835
5277
  if (mode === "directory") assertDirectoryRoots(home);
4836
5278
  const isWorktree = mode === "worktree";
4837
5279
  const status = isWorktree && existsSync(work) ? worktreeStatus(work) : "";
4838
5280
  const disposableReceipts = isWorktree ? retirementDisposableRoots(work, capabilities) : [];
5281
+ const disposableHome = retirementDisposableHome(capabilities);
4839
5282
  const baseline = {
4840
5283
  version: RETIRE_BASELINE_VERSION,
4841
5284
  ...(incarnationId ? { incarnationId, executionBinding } : {}),
4842
5285
  ...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
4843
5286
  home: realPathOrNearest(home),
4844
- homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }),
5287
+ homeFingerprint: fingerprintTree(home, { excludeRoot: resolveHomeExclusions(home, disposableHome).excludeRoot, instanceHome: true }),
4845
5288
  disposableReceipts,
5289
+ disposableHome,
4846
5290
  generatedWorkFingerprint: isWorktree ? generatedWorkFingerprint(work, status, disposableReceipts.map((r) => r.root)) : undefined,
4847
5291
  runtime: {
4848
5292
  launched: runtime?.launched === true,
@@ -5163,8 +5607,16 @@ export function withLaunchModel(command, model) {
5163
5607
  return renderLaunchCommand(tokens);
5164
5608
  }
5165
5609
 
5610
+ /** One tmux command on `socket`. With `env` (a window client: oatsWindowEnvironment, which has no
5611
+ * PATH) the tmux is the absolute one this process's own PATH finds, so its lookup never depends on
5612
+ * the environment it is passed; none found is reported as tmux being unavailable (ENOENT). */
5166
5613
  function tmuxOn(socket, args, io, env) {
5167
- return (io?.exec || execFileSync)("tmux", ["-u", "-S", socket, ...args], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], ...(env ? { env } : {}) });
5614
+ let tmux = "tmux";
5615
+ if (env) {
5616
+ tmux = process.env.PATH === undefined ? null : lookupOnPath("tmux", process.env.PATH);
5617
+ if (!tmux) throw Object.assign(new Error("no tmux was found on this process's PATH"), { code: "ENOENT" });
5618
+ }
5619
+ return (io?.exec || execFileSync)(tmux, ["-u", "-S", socket, ...args], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], ...(env ? { env } : {}) });
5168
5620
  }
5169
5621
 
5170
5622
  function writeJsonAtomic(path, value, mode) {
@@ -5545,6 +5997,28 @@ export function startInstanceSession(home, o = {}) {
5545
5997
  // --reselect-launch (feature launch-preference): apply the home's launch layers now (souls.launch,
5546
5998
  // the recorded soul's launch, the host default) instead of the frozen recipe.
5547
5999
  const reselect = o.reselectLaunch === true ? homeLaunchLayers(realHome, meta) : null;
6000
+ // The session this start opens its window in when it has to create one, and what its creation
6001
+ // needs (planOatsTmuxSession), read at most once: by the plan's lookup or by the creation's preflight.
6002
+ const planSession = receipt.target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
6003
+ let sessionPlanRead;
6004
+ const sessionPlan = () => {
6005
+ if (!sessionPlanRead) { try { sessionPlanRead = { plan: planOatsTmuxSession(planSession, o.io) }; } catch (e) { sessionPlanRead = { error: e }; } }
6006
+ if (sessionPlanRead.error) throw sessionPlanRead.error;
6007
+ return sessionPlanRead.plan;
6008
+ };
6009
+ // The recorded target, observed once: by the plan's lookup, which must know whether the pane will
6010
+ // be reused before it chooses where to look, or by the gate below. A lost server is a stopped one;
6011
+ // any other failure is the gate's refusal (E_SESSION_UNKNOWN), and the lookup does not decide it.
6012
+ let recordedRead;
6013
+ const recordedState = () => {
6014
+ if (!recordedRead) {
6015
+ try { recordedRead = { state: receipt.target ? inspectSessionTarget(receipt.target, o.io) : { present: false, state: "not-launched" } }; }
6016
+ catch (e) { recordedRead = lostTmuxServer(e) ? { state: { present: false, state: "stopped" } } : { error: e }; }
6017
+ }
6018
+ return recordedRead;
6019
+ };
6020
+ // A pane the start reuses where it is: one the recorded target shows, running or left as a shell.
6021
+ const reusable = (state) => !!(state?.paneId && (state.present || state.state === "stopped"));
5548
6022
  const selected = reselect !== null || o.launchConfig !== undefined || o.harness !== undefined || o.yolo !== undefined;
5549
6023
  const hasRecipe = meta.launch && typeof meta.launch === "object";
5550
6024
  let launchPlan = null, launchHooksPass = null, hookMeta, explicitModelFrom = null, warnings = [];
@@ -5559,8 +6033,23 @@ export function startInstanceSession(home, o = {}) {
5559
6033
  // the launch hook re-checks joined memberships against them.
5560
6034
  const resolvedCfg = resolvedFromHome(realHome, meta, { teams: o.teams, defaultTeam: o.defaultTeam, teamsSource: o.teamsSource });
5561
6035
  let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
5562
- const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { harness: meta.harness, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, harness: o.harness, model: o.model, yolo: o.yolo }, reselect, resolvedCfg, env: o.env || process.env, assertRoots: checkRoots });
5563
- launchPlan = { recipe: plan.recipe, command: plan.command, harness: plan.harness, model: plan.model, yolo: plan.yolo, modelFrom: modelFromOf(plan.modelSource, { at: "start", prior: meta.modelFrom ?? null }),
6036
+ // Where the plan looks a harness up by name: a pane the start will reuse, on its own recorded
6037
+ // session, whatever the OATS server holds; otherwise the session's or the server's PATH when a
6038
+ // server runs; when none does, the PATH of the environment this start will create the server
6039
+ // with (read once, here, for the plan and for the creation below). For a creator that may not
6040
+ // create the server the plan looks on this process's PATH, and its refusal comes at the
6041
+ // creation's own place, after the start's other preflights.
6042
+ const pane = () => {
6043
+ const recorded = recordedState();
6044
+ if (reusable(recorded.state)) return panePath(receipt.target.socket, receipt.target.session, o.io, realHome);
6045
+ if (recorded.error) return undefined; // the gate refuses below; the lookup does not decide it
6046
+ const preview = planOatsTmuxSession(planSession, o.io, { preview: true });
6047
+ if (!preview || preview.server) return expectedPanePath(preview, planSession, o.io, realHome);
6048
+ try { return expectedPanePath(sessionPlan(), planSession, o.io, realHome); } catch (e) { if (oatsCoded(e)) return undefined; throw e; }
6049
+ };
6050
+ const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { harness: meta.harness, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, harness: o.harness, model: o.model, yolo: o.yolo }, reselect, resolvedCfg, env: o.env || process.env, assertRoots: checkRoots,
6051
+ panePath: pane });
6052
+ launchPlan = { recipe: plan.recipe, command: plan.command, harness: plan.harness, model: plan.model, yolo: plan.yolo, executable: plan.executable, config: plan.config, launchChoice: plan.launchChoice, trustHome: plan.trustHome, modelFrom: modelFromOf(plan.modelSource, { at: "start", prior: meta.modelFrom ?? null }),
5564
6053
  ...(plan.launchChoice ? { launchFrom: plan.launchChoice.from, launchAt: plan.launchChoice.at, launchDeclared: plan.launchChoice.declared } : {}) };
5565
6054
  // The plan ran preview-aware launch hooks as a preview: their real run, over the planned
5566
6055
  // contributions, waits for the rest of preflight.
@@ -5585,19 +6074,15 @@ export function startInstanceSession(home, o = {}) {
5585
6074
  if (recipeForEnv) { const missing = missingLaunchEnvRefs(recipeForEnv.env, o.env || process.env); if (missing.length) throw oatsError("E_LAUNCH_ENV_MISSING", `this home's launch references ${missing.join(", ")}, not set on this host; nothing was started`); }
5586
6075
  const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
5587
6076
  const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
5588
- checkRoots(); // preparation has run; no backend has been observed
6077
+ checkRoots(); // preparation has run; no backend has been changed
5589
6078
  let target = receipt.target;
5590
- let state = { present: false, state: "not-launched" };
5591
- if (target) {
5592
- try { state = inspectSessionTarget(target, o.io); }
5593
- catch (e) {
5594
- if (lostTmuxServer(e)) state = { present: false, state: "stopped" };
5595
- else throw oatsError("E_SESSION_UNKNOWN", `cannot establish whether ${meta.instance} is running, so nothing was started: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`);
5596
- }
5597
- }
6079
+ const recorded = recordedState();
6080
+ if (recorded.error) { const e = recorded.error; throw oatsError("E_SESSION_UNKNOWN", `cannot establish whether ${meta.instance} is running, so nothing was started: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
6081
+ let state = recorded.state;
5598
6082
  if (state.present && state.state !== "shell" && !o.restart) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is running (${state.state}); nothing was started`);
5599
- // A caller inside an instance that will have to create the tmux session reads the environment
5600
- // for it here (anyone else runs no tmux here): after the start's preflights and its planning,
6083
+ // A caller that will have to create the tmux session reads the environment for it here, unless
6084
+ // the plan's lookup read it already with no server running (an instance that cannot is refused
6085
+ // here either way): after the start's preflights and its planning,
5601
6086
  // so each of their refusals still answers first, and before the real run of preview-aware
5602
6087
  // launch hooks, a stop and any write of the home's launch state (record, receipt, pending
5603
6088
  // start). A launch hook that does not declare launchPreview has already run and its warnings
@@ -5606,8 +6091,27 @@ export function startInstanceSession(home, o = {}) {
5606
6091
  // prepareLaunchHooks) allows such a hook idempotent provider registration, which the next
5607
6092
  // start repeats. Only when this start will have to create a window: a retained pane is reused
5608
6093
  // where it is. A restart whose window goes away during its stop is decided at the creation.
5609
- const tmuxSessionPlan = callerInstance() && !(state.paneId && (state.present || state.state === "stopped"))
5610
- ? planOatsTmuxSession(target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION, o.io) : undefined;
6094
+ const retained = reusable(state);
6095
+ const tmuxSessionPlan = retained ? undefined : sessionPlan();
6096
+ // A harness the plan looked up by name is looked up again where its pane will look it up, before
6097
+ // anything is stopped: the retained pane's own session, or the session this start will create
6098
+ // its window in (as planned). Once more on the session ensureOatsTmuxSession returns (below).
6099
+ // Not there: refused. Found elsewhere: the launch runs that one, and records it. Every executable
6100
+ // named bare (the harness's name, a declared name) is looked up again, also one the plan could only
6101
+ // look up on this process's PATH; a recorded (frozen) or a declared path never is.
6102
+ const lookAgain = (pane) => {
6103
+ if (!launchPlan || launchPlan.config?.frozen || launchPlan.config?.executable?.includes("/")) return false;
6104
+ const { config, launchChoice } = launchPlan;
6105
+ const actual = resolveLaunchExecutable({ harness: launchPlan.harness, declared: config?.executable, declaringDir: config?.source, lookup: launchLookup(config?.env, o.env || process.env, pane, config?.name, realHome) });
6106
+ if (!actual.path) throw executableNotOnPanePath(actual, { harness: launchPlan.harness, config, from: launchChoice?.from, at: launchChoice?.at });
6107
+ const bad = checkLaunchExecutable(actual.path);
6108
+ if (bad) throw oatsError("E_LAUNCH_EXECUTABLE", `launch configuration ${config?.name || "(harness default)"}: ${bad}; nothing was started`);
6109
+ if (actual.path === launchPlan.recipe.executable) return false;
6110
+ launchPlan.recipe = { ...launchPlan.recipe, executable: actual.path };
6111
+ command = launchPlan.command = renderLaunchRecipe(launchPlan.recipe, { home: realHome, instance: meta.instance, trustHome: launchPlan.trustHome });
6112
+ return true;
6113
+ };
6114
+ lookAgain(retained ? () => panePath(target.socket, target.session, o.io, realHome) : () => expectedPanePath(tmuxSessionPlan, planSession, o.io, realHome));
5611
6115
  // Every preflight has passed: the preview-aware launch hooks run for real
5612
6116
  // (they may register the home with their provider), before a restart's
5613
6117
  // stop, and must contribute exactly what they contributed as a preview,
@@ -5655,13 +6159,12 @@ export function startInstanceSession(home, o = {}) {
5655
6159
  const startedAt = new Date().toISOString();
5656
6160
  const id = randomUUID();
5657
6161
  checkRoots();
5658
- const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.harness || harness);
5659
- const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
6162
+ const windowCommand = () => paneCommand(`${nativeRecordCommand(command, realHome, launchPlan?.harness || harness)}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`);
5660
6163
  let reused = "new";
5661
6164
  const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
5662
6165
  const window = target?.window || meta.tmux?.window || meta.instance;
5663
6166
  let socket = target?.socket || meta.tmux?.socket;
5664
- const windowCmd = paneCommand(completedCommand);
6167
+ let windowCmd = windowCommand();
5665
6168
  let moved = null;
5666
6169
  // A fallback shell (no harness descendant) or a retained dead pane is
5667
6170
  // the agent's own pane: the command runs there, on the server the home
@@ -5681,6 +6184,7 @@ export function startInstanceSession(home, o = {}) {
5681
6184
  // on the OATS server, wherever the home was recorded: one socket from here to the record.
5682
6185
  const recordedSocket = socket ? resolve(socket) : null;
5683
6186
  socket = ensureOatsTmuxSession(session, hq, o.io, tmuxSessionPlan);
6187
+ if (lookAgain(() => panePath(socket, session, o.io, realHome))) { planExtra.launch = launchPlan.recipe; windowCmd = windowCommand(); }
5684
6188
  let names;
5685
6189
  try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
5686
6190
  catch (e) {
@@ -5722,6 +6226,287 @@ export function startInstanceSession(home, o = {}) {
5722
6226
  }
5723
6227
  }
5724
6228
 
6229
+ /** One Git call of a retirement inspection → its stdout, as bytes: a text
6230
+ * with one code unit per byte (latin1), so that no byte is replaced. Git
6231
+ * prints ref names and paths as the bytes they are, and decoded as UTF-8 two
6232
+ * names that differ only in bytes that are not UTF-8 would read alike: a
6233
+ * state a hook changed would compare equal, and a copy would be skipped.
6234
+ * What is compared is this text, never a decoded one. A value that is also
6235
+ * used as a path is decoded where it is used (repositoryGitState).
6236
+ * With `absent`, Git's quiet exit 1 ("no such ref", nothing on stderr)
6237
+ * gives null. Any other failure throws E_WORK_INSPECTION_FAILED: a state
6238
+ * that could not be read is never taken for an unchanged one. What a failed
6239
+ * read of the state means for the retire is inspectRetirementWork's
6240
+ * decision: not provable. */
6241
+ function inspectGit(repo, args, { absent = false, env } = {}) {
6242
+ try {
6243
+ return execFileSync("git", ["-C", repo, ...args], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, ...(env ? { env: { ...process.env, ...env } } : {}) }).toString("latin1");
6244
+ } catch (e) {
6245
+ const detail = String(e.stderr ?? "").trim();
6246
+ if (absent && e.status === 1 && !detail) return null;
6247
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect the Git state of ${repo}: ${detail || String(e.message ?? "").trim() || `git ${args[0]} failed`}`);
6248
+ }
6249
+ }
6250
+ /** Whether an entry is there, for a read of the Git state. Only "not there"
6251
+ * is absence: a test that fails for any other reason (no permission on a
6252
+ * directory above it, for example) throws, so an entry the kernel cannot see
6253
+ * is never taken for one that does not exist. A link is followed, as
6254
+ * existsSync follows it. */
6255
+ function inspectedEntryExists(path) {
6256
+ try { statSync(path); return true; }
6257
+ catch (e) {
6258
+ if (e.code === "ENOENT") return false;
6259
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect ${path}: ${e.message}`);
6260
+ }
6261
+ }
6262
+ /** How the recovery copier resolves what it reads from a repository, read by
6263
+ * the copier and by the comparison alike (repositoryGitState), so that the
6264
+ * comparison holds the files the copy carries, found where the copy finds
6265
+ * them. Git's answer decoded as UTF-8 and trimmed of white space at both
6266
+ * ends: a path that ends in white space is read without it, which Git itself
6267
+ * does not do. That is the copier's own reading, kept as it is; a copy of
6268
+ * the file Git reads would be a change to the copier.
6269
+ * A worktree whose `core.worktree` (per-worktree configuration) names a
6270
+ * directory other than `<home>/work` is outside what the retire is
6271
+ * specified for: Git's status then describes that other directory while
6272
+ * the retire reads, copies and removes `<home>/work`. copierExcludesFile
6273
+ * resolves the excludes base the copier's way for it all the same; no test
6274
+ * pins it. */
6275
+ const copierGitText = (repo, args) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
6276
+ /** The Git directory the copier copies the index and the operation entries from. */
6277
+ const copierGitDir = (repo) => copierGitText(repo, ["rev-parse", "--absolute-git-dir"]);
6278
+ /** The common directory the copier reads info/exclude, info/attributes and the stash's log from. */
6279
+ const copierCommonDir = (repo) => copierGitText(repo, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
6280
+ /** The file the copier reads for core.excludesFile; null when it reads none:
6281
+ * the value is unset, cannot be read, or is only white space. */
6282
+ function copierExcludesFile(repo) {
6283
+ let value = "";
6284
+ try { value = copierGitText(repo, ["config", "--path", "--get", "core.excludesFile"]); } catch { /* unset: Git's default applies to both repositories alike */ }
6285
+ return value ? resolve(copierGitText(repo, ["rev-parse", "--show-toplevel"]), value) : null;
6286
+ }
6287
+ /** What a recovery's work copy holds of the worktree's repository beside the
6288
+ * bytes of its files: everything copyRecoveryWork carries or restores, read
6289
+ * the way it reads it, without reading the files. The rule: the work is
6290
+ * unchanged only if everything its copy would carry is byte-for-byte equal,
6291
+ * and anything that cannot be compared exactly counts as changed.
6292
+ * - its status text (the copy is verified against it);
6293
+ * - its HEAD as the copier reads it (headName): the whole ref, as its
6294
+ * bytes, which decides whether the copy is cloned on a branch or
6295
+ * detached; and the commit;
6296
+ * - the index the copier copies, by what it holds and not by its bytes:
6297
+ * its entries as `git ls-files -s -v` lists them (mode, object, stage and path, with the
6298
+ * skip-worktree and assume-unchanged marks), its resolve-undo records
6299
+ * (`git ls-files --resolve-undo`: resolving a conflict can add them while
6300
+ * every entry, status row and byte ends up as it was), and the file's
6301
+ * mode, which copyFileSync gives the copy. Its bytes also hold a stat
6302
+ * cache that a read-only Git command rewrites, and extensions derived
6303
+ * from the entries: those are a deliberate semantic exception, never
6304
+ * compared;
6305
+ * - each path the copier copies from the Git directories (COPIED_GIT_PATHS:
6306
+ * an operation in progress, info/attributes, the stash's log), by what its
6307
+ * copy carries, the permission bits included (copiedGitPathDigest);
6308
+ * - its tags: a clone brings them, and removing the remote leaves them;
6309
+ * - the stash ref (detachRecoveryClone fetches it);
6310
+ * - its exclude rules as carryExcludes reads them: the core.excludesFile
6311
+ * value, the file carryExcludes reads for it (copierExcludesFile), and
6312
+ * info/exclude. They are written into the copy as new text, so their
6313
+ * bytes are what it carries;
6314
+ * - its status settings as carryStatusConfig reads them: the effective
6315
+ * value of each STATUS_CONFIG key. The keys, not the configuration file:
6316
+ * Git rewrites that file for an upstream or a remote, which no copy
6317
+ * carries;
6318
+ * - `cloneHead`, for a worktree whose HEAD the copier copies detached (no
6319
+ * branch, a ref that is not a branch, a name that is not UTF-8): that copy
6320
+ * is cloned without --branch, so it keeps a local branch for the HEAD of
6321
+ * the repository it is cloned from, and none when that HEAD is not on a
6322
+ * branch. That HEAD is read as its whole ref.
6323
+ * The repository's other branches are not here: they outlive the worktree,
6324
+ * and the copy does not hold them. Its tags, stash, rules and settings are
6325
+ * the shared repository's: one made there by anyone while the retire hooks
6326
+ * run adds a copy attempt. The repository's objects and the settings a clone
6327
+ * of it is served under (its shallow boundary, grafts, hidden refs) stay in
6328
+ * it and are not compared: a custody boundary, which holds because no
6329
+ * retire removes that repository or deletes a branch.
6330
+ * Every read is inspectGit's, a file read or an existence test
6331
+ * (inspectedEntryExists): only Git's quiet "not set" and an entry that is
6332
+ * not there are absence; any other failure throws, and the worktree is then
6333
+ * not provable (inspectRetirementWork).
6334
+ * Everything in the list is compared as its bytes; nothing of it goes
6335
+ * through a decoding that replaces a byte. Git's output is inspectGit's
6336
+ * text of bytes, the status is the status bytes as such a text, a file is
6337
+ * the digest of its bytes, and a copied path is its exact digest
6338
+ * (fingerprintTrees), which reads names and link targets as bytes. The
6339
+ * three `rev-parse` paths and the `core.excludesFile` value are in the state
6340
+ * as Git printed them (`paths`, and the first of `excludesFile`), compared
6341
+ * as their bytes; a `rev-parse` path that does not decode to an existing one
6342
+ * throws below, and the worktree is not provable. Every file and directory
6343
+ * the state reads is found where the copier finds it, by the copier's own
6344
+ * functions (copierGitDir, copierCommonDir, copierExcludesFile, headName):
6345
+ * the state is what the copy carries, read the way the copy reads it. A
6346
+ * file the copier does not reach is not there for the state either.
6347
+ * `status`: the repository's worktreeStatusBytes, when the caller has it.
6348
+ * `cloneSource`: the repository the copy is cloned from (meta.repo). */
6349
+ function repositoryGitState(repo, { status, cloneSource } = {}) {
6350
+ /** One of the copier's reads; any failure makes the worktree not provable. */
6351
+ const asCopier = (read) => {
6352
+ try { return read(repo); }
6353
+ catch (e) {
6354
+ if (e?.code === "E_WORK_INSPECTION_FAILED") throw e;
6355
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect the Git state of ${repo}: ${String(e?.stderr ?? "").trim() || String(e?.message ?? "").trim()}`);
6356
+ }
6357
+ };
6358
+ // Three paths, one per line. A path with a line feed in it prints more
6359
+ // lines, and taken apart wrongly every test below would read "not there",
6360
+ // before the hooks and after them alike. So: exactly three, absolute, there.
6361
+ /** A value Git printed, as the path the kernel's text paths name with it. */
6362
+ const pathOf = (value) => Buffer.from(value, "latin1").toString("utf8");
6363
+ const printed = inspectGit(repo, ["rev-parse", "--path-format=absolute", "--absolute-git-dir", "--git-common-dir", "--show-toplevel"]).split("\n");
6364
+ if (printed.at(-1) === "") printed.pop();
6365
+ const paths = printed.map(pathOf);
6366
+ if (paths.length !== 3 || !paths.every((path) => isAbsolute(path) && inspectedEntryExists(path))) {
6367
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect the Git state of ${repo}: git rev-parse did not give its Git directory, its common directory and its top level as three existing absolute paths`);
6368
+ }
6369
+ const [gitDir, commonDir] = paths;
6370
+ const bytesOf = (path) => {
6371
+ try { return createHash("sha256").update(readFileSync(path)).digest("hex"); }
6372
+ catch (e) {
6373
+ if (e.code === "ENOENT") return null;
6374
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not read ${path}: ${e.message}`);
6375
+ }
6376
+ };
6377
+ /** The permission bits a copyFileSync gives the copy of `path`; null when it is not there. */
6378
+ const modeOf = (path) => {
6379
+ try { return statSync(path).mode & 0o7777; }
6380
+ catch (e) {
6381
+ if (e.code === "ENOENT") return null;
6382
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not read ${path}: ${e.message}`);
6383
+ }
6384
+ };
6385
+ /** One value Git may not have: null when it is not set. */
6386
+ const valueOf = (...args) => inspectGit(repo, args, { absent: true })?.replace(/\n$/, "") ?? null;
6387
+ const sourceValueOf = (...args) => inspectGit(cloneSource, args, { absent: true })?.replace(/\n$/, "") ?? null;
6388
+ const head = asCopier(headName);
6389
+ const copierGit = asCopier(copierGitDir), copierCommon = asCopier(copierCommonDir);
6390
+ const excludesFile = valueOf("config", "--path", "--get", "core.excludesFile");
6391
+ const excludesRead = asCopier(copierExcludesFile);
6392
+ return {
6393
+ paths: printed,
6394
+ status: (status ?? worktreeStatusBytes(repo)).toString("latin1"),
6395
+ head: head.ref === null ? null : head.ref.toString("latin1"),
6396
+ commit: valueOf("rev-parse", "--verify", "--quiet", "HEAD"),
6397
+ // The index the copier copies (copierGitDir), read as an index: Git's
6398
+ // own, at a path that differs from it only in white space, is not what
6399
+ // the copy carries. One that is not there lists nothing.
6400
+ index: inspectGit(repo, ["ls-files", "-s", "-v", "-z"], { env: { GIT_INDEX_FILE: join(copierGit, "index") } }),
6401
+ resolveUndo: inspectGit(repo, ["ls-files", "--resolve-undo", "-z"], { env: { GIT_INDEX_FILE: join(copierGit, "index") } }),
6402
+ indexMode: modeOf(join(copierGit, "index")),
6403
+ copied: COPIED_GIT_PATHS.map((entry) => copiedGitPathDigest(entry, copiedGitPath(entry, copierGit, copierCommon))),
6404
+ tags: inspectGit(repo, ["for-each-ref", "--format=%(objectname) %(refname)", "refs/tags"]),
6405
+ stash: valueOf("rev-parse", "--verify", "--quiet", "refs/stash"),
6406
+ excludesFile: excludesFile === null && excludesRead === null ? null : [excludesFile, excludesRead, excludesRead && bytesOf(excludesRead)],
6407
+ exclude: bytesOf(join(copierCommon, "info", "exclude")),
6408
+ statusConfig: STATUS_CONFIG.map((key) => valueOf("config", "--get", key)),
6409
+ // The copy is cloned from the repository the instance was spawned from
6410
+ // (`cloneSource`, meta.repo), which can be a linked worktree of it: that
6411
+ // repository's HEAD is read there, as its whole ref, not the common
6412
+ // directory's.
6413
+ cloneHead: head.branch === null && gitDir !== commonDir && cloneSource
6414
+ ? [sourceValueOf("symbolic-ref", "--quiet", "HEAD"), sourceValueOf("rev-parse", "--verify", "--quiet", "HEAD")]
6415
+ : null,
6416
+ };
6417
+ }
6418
+ /** Every repository under a worktree, at any depth → [{ path, inner }]:
6419
+ * nestedGitRoots, and (`inner`) the repositories inside those. A recovery
6420
+ * rebuilds a nested repository from a clone; one inside it comes along as
6421
+ * plain files, its Git directory included, which the copy's verification
6422
+ * leaves out and which no read-only Git command can describe whole.
6423
+ * A directory whose `.git` cannot be tested (inspectedEntryExists throws),
6424
+ * or is a dangling symbolic link (a link `stat` cannot follow, which the
6425
+ * copy carries as a link and the digests pass over by its name), is listed
6426
+ * as { path, unknown: true }: it is not taken for a directory without a
6427
+ * repository, and not for a repository either. So every directory with a
6428
+ * `.git` entry of any kind is listed: a directory, a file, a link to
6429
+ * anything, a dangling link, and a `.git` that cannot be tested. */
6430
+ function repositoriesUnder(work) {
6431
+ const out = [];
6432
+ // Asked only where `stat` found no `.git`: whether there is an entry all the same.
6433
+ const entryIsThere = (path) => {
6434
+ try { lstatSync(path); return true; }
6435
+ catch (e) {
6436
+ if (e.code === "ENOENT") return false;
6437
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect ${path}: ${e.message}`);
6438
+ }
6439
+ };
6440
+ const walk = (dir, inner) => {
6441
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
6442
+ if (!e.isDirectory() || e.isSymbolicLink?.() || e.name === ".git") continue;
6443
+ const path = join(dir, e.name);
6444
+ let repository = false;
6445
+ try {
6446
+ repository = inspectedEntryExists(join(path, ".git"));
6447
+ if (!repository && entryIsThere(join(path, ".git"))) out.push({ path, unknown: true });
6448
+ } catch (e) {
6449
+ if (e?.code !== "E_WORK_INSPECTION_FAILED") throw e;
6450
+ out.push({ path, unknown: true });
6451
+ }
6452
+ if (repository) out.push({ path, inner });
6453
+ walk(path, inner || repository);
6454
+ }
6455
+ };
6456
+ walk(work, false);
6457
+ return out;
6458
+ }
6459
+ /** The work state of a worktree instance, without the bytes of its files: the
6460
+ * worktree's Git state (repositoryGitState), as one text. Two inspections of
6461
+ * an untouched tree give the same text. The bytes are the other part, read
6462
+ * only when needed: observedWorkBytes. A repository under the worktree is not
6463
+ * part of it: such a worktree is not provable (inspectRetirementWork). */
6464
+ function worktreeGitState(work, status, cloneSource) { // `status`: the worktree's status bytes
6465
+ return JSON.stringify({ worktree: repositoryGitState(work, { status, cloneSource }) });
6466
+ }
6467
+ /** The bytes of a worktree, Git metadata left out, as their exact digest: what a work copy is verified
6468
+ * with, and what proves that the hooks left the files as they were. One full read. Kept nowhere. */
6469
+ const worktreeBytes = (work) => exactTreeDigest(work, { excludeRoot: new Set([".git"]), excludeGitMetadata: true, children: true });
6470
+ /** The byte part of an observed worktree's work state, read at most once per
6471
+ * observation and kept on it; undefined where the observation saw no
6472
+ * worktree. An inspection never reads it. The retire reads it before it
6473
+ * writes the pre-hook recovery, the copy's verification uses it (so a pass
6474
+ * that copies the work costs no extra read), and so does the proof that the
6475
+ * retire hooks left the work as it was (movedByHooks). */
6476
+ function observedWorkBytes(observation) {
6477
+ if (observation.worktree && observation.workBytes === undefined) {
6478
+ // A read that fails is an inspection that failed, with its code: where the
6479
+ // retire reads before it writes, nothing else gives the failure one.
6480
+ try { observation.workBytes = worktreeBytes(observation.work); }
6481
+ catch (e) {
6482
+ if (e?.code === "E_WORK_INSPECTION_FAILED") throw e;
6483
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not read the worktree at ${observation.work}: ${e.message}`);
6484
+ }
6485
+ }
6486
+ return observation.workBytes;
6487
+ }
6488
+ /** What the retire hooks moved, between the observation before them and the
6489
+ * one after → { home, work }. Nothing when the later observation has nothing
6490
+ * to preserve. The home moved when its exact digest did: that is its bytes,
6491
+ * `instance.json` as it is. (The stored digest, which a baseline is compared
6492
+ * with, leaves the kernel's own fields of that file out and frames entries
6493
+ * without lengths; it reads alike some homes that differ.)
6494
+ * The work moved unless it is proven unchanged: a directory by its bytes
6495
+ * (its exact digest); a worktree by its Git state and, only when that is
6496
+ * equal, by its bytes. A status text or a class list alone proves nothing: a
6497
+ * hook can rewrite a file that was already modified. `before` must hold its
6498
+ * bytes already (the retire reads them before it writes the pre-hook
6499
+ * recovery); without them the work counts as moved. So does a worktree that
6500
+ * cannot be proven unchanged at all (`workProvable` false: a worktree that
6501
+ * holds a repository, or a read of the Git state that failed). */
6502
+ function movedByHooks(before, after) {
6503
+ if (!after.classes.length) return { home: false, work: false };
6504
+ const home = after.homeBytes !== before.homeBytes;
6505
+ let work = after.workFingerprint !== before.workFingerprint;
6506
+ if (!work && after.worktree) work = !before.workProvable || !after.workProvable || before.workBytes === undefined || observedWorkBytes(after) !== before.workBytes;
6507
+ return { home, work };
6508
+ }
6509
+
5725
6510
  function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktreeRemoval, directory = false, orphanedWork = false } = {}) {
5726
6511
  if (directory) assertDirectoryRoots(home);
5727
6512
  const classes = [];
@@ -5732,10 +6517,22 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
5732
6517
  } catch (e) {
5733
6518
  throw oatsError("E_WORK_INSPECTION_FAILED", `could not read the independent retirement baseline: ${e.message}`);
5734
6519
  }
5735
- const baselineValid = baseline?.version === RETIRE_BASELINE_VERSION && baseline.home === realPathOrNearest(home);
6520
+ const baselineValid = retirementBaselineValid(baseline, home);
6521
+ // This pass's one resolved exclusion set: provider-owned entries the spawn
6522
+ // baseline declared, and the home's verified extra trees (whatever the
6523
+ // baseline), which the retire's extra-tree step handles from this same set.
6524
+ // Every home fingerprint here and the copy made from this observation use it.
6525
+ const extraTrees = extraWorktreesOf(home);
6526
+ const homeExclusions = resolveHomeExclusions(home, baselineValid ? baselineDisposableHome(baseline) : [], extraTrees);
6527
+ // One walk of the home, two digests: the stored one for the comparison with
6528
+ // the baseline, which must not see the kernel's own fields of instance.json,
6529
+ // and the exact one for the comparison after the hooks, which must see
6530
+ // every byte.
6531
+ let homeDigests;
6532
+ const fingerprintHome = () => (homeDigests ??= fingerprintTrees(home, { excludeRoot: homeExclusions.excludeRoot, instanceHome: true, exact: true, children: true })).stored;
5736
6533
  if (!baselineValid) {
5737
6534
  classes.push("unknown instance-home provenance");
5738
- } else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true })) {
6535
+ } else if (baseline.homeFingerprint !== fingerprintHome()) {
5739
6536
  classes.push("changed instance-home bytes");
5740
6537
  }
5741
6538
  // A mutable mode must not turn owned directory bytes into an excluded shared
@@ -5745,7 +6542,7 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
5745
6542
  }
5746
6543
  let directoryFingerprint;
5747
6544
  if (directory) {
5748
- directoryFingerprint = fingerprintTree(work);
6545
+ directoryFingerprint = directoryBytes(work);
5749
6546
  // Never stamp hook-created or authored execution bytes as disposable.
5750
6547
  if (readdirSync(work).length) classes.push("directory work bytes");
5751
6548
  }
@@ -5756,24 +6553,65 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
5756
6553
  // reaches its commit. `head` is kept for the check before the removal.
5757
6554
  const unreached = worktreeRemoval?.removes && worktreeRemoval.repo && !directory && isWorktree && existsSync(work) ? worktreeCommitUnreached(worktreeRemoval.repo, work) : undefined;
5758
6555
  if (unreached?.unreached) classes.push("worktree commits no ref reaches");
5759
- if (isWorktree && existsSync(work)) {
5760
- const status = worktreeStatus(work);
6556
+ const worktree = isWorktree && existsSync(work);
6557
+ let gitState = "", workProvable = true;
6558
+ if (worktree) {
6559
+ // Read once, as bytes. The bytes go into the Git state; the rows, the
6560
+ // classes and the comparison with the spawn baseline read them as text.
6561
+ const statusBytes = worktreeStatusBytes(work);
6562
+ const status = statusBytes.toString("utf8");
5761
6563
  const rows = status.split("\0").filter(Boolean);
5762
6564
  if (rows.some((row) => !row.startsWith("?? ") && !row.startsWith("!! "))) classes.push("tracked or index worktree state");
5763
6565
  const disposableRoots = baseline?.disposableReceipts?.map((r) => r.root) || [];
5764
6566
  if (!baseline || baseline.generatedWorkFingerprint !== generatedWorkFingerprint(work, status, disposableRoots)) classes.push("untracked or ignored worktree bytes");
5765
- if (nestedGitRoots(work).length) classes.push("nested repository state");
5766
- }
5767
- // When the worktree will be removed, its HEAD and whether a ref reaches it are part of the state: a hook
5768
- // that commits on a detached HEAD, or deletes the ref that reached it, changes neither the home nor the
5769
- // status, and the commit must still be preserved again after it.
5770
- const stateFingerprint = createHash("sha256")
5771
- .update(fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }))
5772
- .update("\0").update(directory ? (directoryFingerprint || "missing") : isWorktree && existsSync(work) ? worktreeStatus(work) : "")
6567
+ const found = repositoriesUnder(work);
6568
+ if (found.some((r) => !r.unknown)) classes.push("nested repository state");
6569
+ // The one place where a read of the Git state that fails is decided. It
6570
+ // is never taken for "not set" or for "unchanged", and it does not refuse
6571
+ // either: main's inspection read none of this and let such a retire go
6572
+ // on. The worktree is then not provable, and the copy decides, as it did
6573
+ // on main: nothing to preserve retires, anything else is copied before
6574
+ // the hooks (homeOnlyRecovery) and again after them (movedByHooks).
6575
+ // `git status` is not part of this: it failed the inspection on main too.
6576
+ try { gitState = worktreeGitState(work, statusBytes, worktreeRemoval?.repo); }
6577
+ catch (e) {
6578
+ if (!e?.code) throw e;
6579
+ workProvable = false;
6580
+ }
6581
+ // A worktree that holds a repository is not provable either: a directory
6582
+ // under it with a `.git` entry of any kind (repositoriesUnder, the
6583
+ // predicate the class reads): a directory, a file, a link to anything, a
6584
+ // dangling link, and a `.git` that cannot be tested. Only a repository
6585
+ // the predicate can see adds the class; the last two make the work
6586
+ // unprovable without it, and so never home-only (homeOnlyRecovery): a
6587
+ // change to the home alone can then cause a work-copy attempt before
6588
+ // the hooks that main did not make, and any failure of that attempt can
6589
+ // refuse the retirement. The retire hooks run between the two copies and
6590
+ // can change such a repository in ways no read of its state covers (its
6591
+ // configuration, its objects, what a clone of it is shown), so its work
6592
+ // is copied again after them, whatever they did. What stays in the repository the
6593
+ // worktree belongs to needs no copy: no retire removes it or deletes a
6594
+ // branch, and a worktree commit no ref reaches is preserved (the class
6595
+ // above, `unreached`).
6596
+ if (found.length) workProvable = false;
6597
+ }
6598
+ // Two values, so the post-hook pass can tell which part a hook moved: the
6599
+ // home's bytes (its exact digest), and the work state's fingerprint. A
6600
+ // directory's work state is its bytes (`directoryFingerprint`, an exact
6601
+ // digest too). A worktree's is its Git state here, and its bytes, which an
6602
+ // inspection does not read (observedWorkBytes). `workProvable`: whether
6603
+ // that state could be read and covers everything a copy of the work would
6604
+ // carry. None of these is kept in a baseline, a receipt or recovery.json.
6605
+ // When the worktree will be removed, its HEAD (the commit and the ref's
6606
+ // bytes) and whether a ref reaches that commit are part of the work state: a
6607
+ // hook that deletes the ref that reached the commit changes nothing else
6608
+ // the state holds, and the commit must still be preserved again after it.
6609
+ fingerprintHome();
6610
+ const workFingerprint = createHash("sha256").update(directory ? (directoryFingerprint || "missing") : gitState)
5773
6611
  .update("\0").update(unreached ? `${unreached.head.commit}\0${unreached.unreached ? "unreached" : "reached"}\0` : "")
5774
6612
  .update(unreached?.head.ref ?? "")
5775
6613
  .digest("hex");
5776
- return { classes: [...new Set(classes)], home, work, directory, orphanedWork, directoryFingerprint, stateFingerprint, branchExists, head: unreached?.head, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
6614
+ return { classes: [...new Set(classes)], home, work, directory, orphanedWork, worktree, workProvable, directoryFingerprint, homeBytes: homeDigests.exact, workFingerprint, homeExclude: homeExclusions.excludeRoot, notCopied: homeExclusions.notCopied, extraTrees, branchExists, head: unreached?.head, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
5777
6615
  }
5778
6616
 
5779
6617
  function copyRecoveryTree(src, dest, { excludeRoot = new Set() } = {}) {
@@ -5789,6 +6627,44 @@ const RECOVERABLE_GIT_ADMIN = [
5789
6627
  "BISECT_LOG", "BISECT_START", "BISECT_NAMES", "rebase-apply", "rebase-merge", "sequencer",
5790
6628
  ];
5791
6629
 
6630
+ /** The paths of a repository's Git directories that a recovery's work copy
6631
+ * takes by copying them, beside the clone, the files and the index: one
6632
+ * selection, read by the copier (restoreStandaloneGitState,
6633
+ * carryStatusConfig, detachRecoveryClone) and by the comparison
6634
+ * (repositoryGitState), so that each copied path is compared by what its
6635
+ * copy carries (copiedGitPathDigest). `dir`: the repository's own Git
6636
+ * directory ("git") or its common directory ("common"). `copy`: copyTreeSafe
6637
+ * ("tree") or copyFileSync ("file").
6638
+ * The index is copied whole and is not in the selection: it is compared by
6639
+ * its entries, its resolve-undo records and its mode, never by its bytes,
6640
+ * which hold a cache that a read-only Git command rewrites. That is a
6641
+ * deliberate semantic exception (repositoryGitState). */
6642
+ const COPIED_GIT_PATHS = [
6643
+ ...RECOVERABLE_GIT_ADMIN.map((name) => ({ dir: "git", path: [name], copy: "tree" })),
6644
+ { dir: "common", path: ["info", "attributes"], copy: "file" },
6645
+ { dir: "common", path: ["logs", "refs", "stash"], copy: "file" },
6646
+ ];
6647
+ const [GIT_ATTRIBUTES, GIT_STASH_LOG] = COPIED_GIT_PATHS.slice(-2);
6648
+ const copiedGitPath = ({ dir, path }, gitDir, commonDir) => join(dir === "git" ? gitDir : commonDir, ...path);
6649
+ /** What the copy of one selected path carries, as a digest; null when the
6650
+ * path is not there. copyTreeSafe gives a copy the entry's kind, bytes or
6651
+ * target, and the bits of the entry and of everything under it: its exact
6652
+ * digest. copyFileSync follows a link and gives the copy the bytes and the
6653
+ * mode of what it reaches: those. Any failure but "not there" throws, as a
6654
+ * read of the Git state does, and so does a file path that is not a file. */
6655
+ function copiedGitPathDigest(entry, at) {
6656
+ if (!inspectedEntryExists(at)) return null;
6657
+ try {
6658
+ if (entry.copy === "tree") return exactTreeDigest(at);
6659
+ const st = statSync(at);
6660
+ if (!st.isFile()) throw new Error("it is not a file");
6661
+ return createHash("sha256").update(`${st.mode & 0o7777}\0`).update(readFileSync(at)).digest("hex");
6662
+ } catch (e) {
6663
+ if (e?.code === "E_WORK_INSPECTION_FAILED") throw e;
6664
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not read ${at}: ${e.message}`);
6665
+ }
6666
+ }
6667
+
5792
6668
  /** Object ids among `oids` that `repo` does not have, from one
5793
6669
  * `cat-file --batch-check` process. */
5794
6670
  export function missingGitObjects(repo, oids) {
@@ -5803,7 +6679,7 @@ export function missingGitObjects(repo, oids) {
5803
6679
  }
5804
6680
 
5805
6681
  function restoreStandaloneGitState(sourceWork, recoveredRepo) {
5806
- const sourceGit = execFileSync("git", ["-C", sourceWork, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6682
+ const sourceGit = copierGitDir(sourceWork);
5807
6683
  const recoveredGit = join(recoveredRepo, ".git");
5808
6684
  const indexRows = execFileSync("git", ["-C", sourceWork, "ls-files", "--stage", "-z"], { maxBuffer: GIT_MAX_BUFFER });
5809
6685
  // The staged blobs the recovered index will point at. The clone already
@@ -5827,11 +6703,13 @@ function restoreStandaloneGitState(sourceWork, recoveredRepo) {
5827
6703
  }
5828
6704
  const stillMissing = missingGitObjects(recoveredRepo, [...staged]);
5829
6705
  if (stillMissing.length) throw new Error(`recovered repository lacks ${stillMissing.length} staged object(s) after restore: ${stillMissing.slice(0, 3).join(", ")}`);
6706
+ // The index is the selection's one exception (COPIED_GIT_PATHS): copied
6707
+ // whole, compared by its entries, resolve-undo records and mode.
5830
6708
  copyFileSync(join(sourceGit, "index"), join(recoveredGit, "index"));
5831
- for (const name of RECOVERABLE_GIT_ADMIN) {
5832
- const source = join(sourceGit, name);
6709
+ for (const entry of COPIED_GIT_PATHS.filter((e) => e.dir === "git")) {
6710
+ const source = copiedGitPath(entry, sourceGit);
5833
6711
  if (!existsSync(source)) continue;
5834
- const dest = join(recoveredGit, name);
6712
+ const dest = copiedGitPath(entry, recoveredGit);
5835
6713
  rmSync(dest, { recursive: true, force: true });
5836
6714
  copyTreeSafe(source, dest);
5837
6715
  }
@@ -5844,7 +6722,6 @@ function restoreStandaloneGitState(sourceWork, recoveredRepo) {
5844
6722
  * info/exclude, which keeps Git's precedence (a later pattern wins, and info/exclude outranks
5845
6723
  * core.excludesFile). → the sources carried, [{ kind, path }]. */
5846
6724
  function carryExcludes(sourceWork, recoveredRepo) {
5847
- const git = (...args) => execFileSync("git", ["-C", sourceWork, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
5848
6725
  const carried = [], parts = [];
5849
6726
  // A file with no pattern line (Git's template info/exclude is comments only) changes nothing: not carried.
5850
6727
  const carry = (kind, file) => {
@@ -5853,12 +6730,9 @@ function carryExcludes(sourceWork, recoveredRepo) {
5853
6730
  if (!text.split("\n").some((line) => line.trim() && !line.startsWith("#"))) return;
5854
6731
  parts.push(text); carried.push({ kind, path: file });
5855
6732
  };
5856
- let excludesFile = "";
5857
- try { excludesFile = git("config", "--path", "--get", "core.excludesFile"); } catch { /* unset: Git's default applies to both repositories alike */ }
5858
- if (excludesFile) {
5859
- carry("core.excludesFile", resolve(git("rev-parse", "--show-toplevel"), excludesFile));
5860
- }
5861
- carry("info/exclude", join(git("rev-parse", "--path-format=absolute", "--git-common-dir"), "info", "exclude"));
6733
+ const excludesFile = copierExcludesFile(sourceWork);
6734
+ if (excludesFile) carry("core.excludesFile", excludesFile);
6735
+ carry("info/exclude", join(copierCommonDir(sourceWork), "info", "exclude"));
5862
6736
  if (!carried.length) return carried;
5863
6737
  mkdirSync(join(recoveredRepo, ".git", "info"), { recursive: true });
5864
6738
  writeFileSync(join(recoveredRepo, ".git", "info", "exclude"), parts.map((t) => (t.endsWith("\n") ? t : `${t}\n`)).join(""));
@@ -5885,11 +6759,12 @@ function carryStatusConfig(sourceWork, recoveredRepo) {
5885
6759
  execFileSync("git", ["-C", recoveredRepo, "config", "--local", ...(value === null ? ["--unset-all", key] : [key, value])], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
5886
6760
  carried.push({ kind: "config", key, value });
5887
6761
  }
5888
- const common = execFileSync("git", ["-C", sourceWork, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
5889
- const attributes = join(common, "info", "attributes");
6762
+ const common = copierCommonDir(sourceWork);
6763
+ const attributes = copiedGitPath(GIT_ATTRIBUTES, null, common);
5890
6764
  if (existsSync(attributes)) {
5891
- mkdirSync(join(recoveredRepo, ".git", "info"), { recursive: true });
5892
- copyFileSync(attributes, join(recoveredRepo, ".git", "info", "attributes"));
6765
+ const dest = copiedGitPath(GIT_ATTRIBUTES, null, join(recoveredRepo, ".git"));
6766
+ mkdirSync(dirname(dest), { recursive: true });
6767
+ copyFileSync(attributes, dest);
5893
6768
  carried.push({ kind: "info/attributes", path: attributes });
5894
6769
  }
5895
6770
  return carried;
@@ -5901,10 +6776,10 @@ function detachRecoveryClone(source, recovered) {
5901
6776
  catch { stash = undefined; }
5902
6777
  if (stash) {
5903
6778
  execFileSync("git", ["-C", recovered, "fetch", "--quiet", source, "refs/stash:refs/stash"], { maxBuffer: GIT_MAX_BUFFER });
5904
- const sourceCommon = execFileSync("git", ["-C", source, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
5905
- const stashLog = join(sourceCommon, "logs", "refs", "stash");
6779
+ const sourceCommon = copierCommonDir(source);
6780
+ const stashLog = copiedGitPath(GIT_STASH_LOG, null, sourceCommon);
5906
6781
  if (existsSync(stashLog)) {
5907
- const recoveredLog = join(recovered, ".git", "logs", "refs", "stash");
6782
+ const recoveredLog = copiedGitPath(GIT_STASH_LOG, null, join(recovered, ".git"));
5908
6783
  mkdirSync(dirname(recoveredLog), { recursive: true });
5909
6784
  copyFileSync(stashLog, recoveredLog);
5910
6785
  }
@@ -5965,7 +6840,133 @@ function preservedOutputs(work, directory) {
5965
6840
  return { paths, bytes: paths.reduce((n, p) => n + p.bytes, 0) };
5966
6841
  }
5967
6842
 
5968
- function preserveRetirementWork(observation, meta, instance) {
6843
+ /** The home part of a recovery at `dest`: everything but work/ and this pass's
6844
+ * excluded provider-owned entries (observation.homeExclude), verified against
6845
+ * the source with that same set. The copy is hashed whole, so an excluded
6846
+ * entry that reached it fails the verification. The stored digest is what
6847
+ * is compared: it leaves the kernel's own fields of instance.json out, so a
6848
+ * kernel write there between the copy and this check is not a disagreement.
6849
+ * That is an inherited limit, and this is not an exact verification of the
6850
+ * whole home: the stored digest also passes over the kernel's receipts and
6851
+ * the harness settings, reads instance.json through a parse, and frames its
6852
+ * entries without lengths. */
6853
+ function copyRecoveryHome(observation, dest) {
6854
+ const excludeRoot = observation.homeExclude || new Set(["work"]);
6855
+ copyRecoveryTree(observation.home, dest, { excludeRoot });
6856
+ if (fingerprintTree(observation.home, { excludeRoot, instanceHome: true }) !== fingerprintTree(dest, { instanceHome: true })) {
6857
+ throw new Error("home recovery verification disagreed with the source");
6858
+ }
6859
+ }
6860
+
6861
+ /** The work part of a recovery under `parent`, copied and verified: `repo/`, a
6862
+ * standalone clone of a worktree instance's repository carrying its
6863
+ * uncommitted state, or `work/`, a directory instance's bytes. A worktree's
6864
+ * source bytes are the observation's (observedWorkBytes), so the verified
6865
+ * value is also its byte state. → { copied, branchDrift?, excludes?,
6866
+ * statusConfig? }; `copied` is false where neither applies (another work
6867
+ * mode, or a worktree home with no recorded repo or branch). A HEAD that
6868
+ * cannot be read is an inspection failure, not a failed copy: its error is
6869
+ * put in `unreadableHeads`, and the callers throw it as it is. */
6870
+ const unreadableHeads = new WeakSet();
6871
+ function copyRecoveryWork(observation, meta, parent) {
6872
+ let branchDrift, excludes, statusConfig, copied = false;
6873
+ if (meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
6874
+ const recoveredRepo = join(parent, "repo");
6875
+ // The branch is derived from the worktree while it exists: an instance
6876
+ // that legitimately switched branches during its task must still be
6877
+ // recoverable, and the recorded spawn-time branch is only the fallback
6878
+ // when the worktree is gone. A HEAD that is detached, or on a ref OATS
6879
+ // carries no name for, recovers detached at its exact commit.
6880
+ let ref;
6881
+ try { ref = existsSync(observation.work) ? worktreeHead(observation.work) : { branch: meta.branch, commit: null }; }
6882
+ catch (e) { unreadableHeads.add(e); throw e; }
6883
+ if (ref.branch !== meta.branch) branchDrift = { recordedBranch: meta.branch, worktreeBranch: ref.branch, detachedAt: ref.branch === null ? ref.commit : null };
6884
+ if (ref.branch !== null) execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", ref.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
6885
+ else {
6886
+ execFileSync("git", ["clone", "--no-local", "--quiet", "--no-checkout", meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
6887
+ execFileSync("git", ["-C", recoveredRepo, "fetch", "--quiet", observation.work, ref.commit], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
6888
+ execFileSync("git", ["-C", recoveredRepo, "checkout", "--quiet", "--detach", ref.commit], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
6889
+ }
6890
+ const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
6891
+ detachRecoveryClone(sourceGitContext, recoveredRepo);
6892
+ if (existsSync(observation.work)) {
6893
+ restoreStandaloneGitState(observation.work, recoveredRepo);
6894
+ excludes = carryExcludes(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
6895
+ statusConfig = carryStatusConfig(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
6896
+ for (const e of readdirSync(observation.work, { withFileTypes: true })) {
6897
+ if (e.name === ".git") continue;
6898
+ const dest = join(recoveredRepo, e.name);
6899
+ rmSync(dest, { recursive: true, force: true });
6900
+ copyTreeSafe(join(observation.work, e.name), dest);
6901
+ }
6902
+ const nested = materializeNestedRepositories(observation.work, recoveredRepo);
6903
+ excludes.push(...nested.excludes); statusConfig.push(...nested.statusConfig);
6904
+ }
6905
+ if (existsSync(join(recoveredRepo, ".git", "objects", "info", "alternates"))) throw new Error("recovery clone depends on object alternates");
6906
+ if (existsSync(observation.work)) {
6907
+ // The source's bytes as this observation holds them: read before the
6908
+ // pre-hook recovery was written, or by the proof that the hooks moved
6909
+ // nothing, or else here. Either way they are kept as the observation's
6910
+ // byte state.
6911
+ if ((observation.worktree ? observedWorkBytes(observation) : worktreeBytes(observation.work)) !== worktreeBytes(recoveredRepo)) throw new Error("worktree recovery verification disagreed with the source");
6912
+ assertStatusAgrees(observation.work, recoveredRepo, "recovered Git index/status disagreed with the source", ".");
6913
+ }
6914
+ // The proof of the copy: its HEAD is the commit the worktree has checked
6915
+ // out. A branch of the repository can be at another commit with the
6916
+ // same files, so the branch's tip there proves nothing about the copy.
6917
+ // With the worktree gone there is no such commit to read, and the copy
6918
+ // is the recorded branch as the repository has it.
6919
+ const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6920
+ const sourceHead = ref.commit
6921
+ ?? execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${ref.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6922
+ if (recoveredHead !== sourceHead) throw new Error(ref.commit ? "recovery clone is not at the commit the worktree has checked out" : "recovery clone does not retain the instance branch tip");
6923
+ copied = true;
6924
+ }
6925
+ if (observation.directory && observation.directoryFingerprint) {
6926
+ const recoveredWork = join(parent, "work");
6927
+ copyTreeSafe(observation.work, recoveredWork);
6928
+ if (directoryBytes(observation.work) !== observation.directoryFingerprint || directoryBytes(recoveredWork) !== observation.directoryFingerprint) {
6929
+ throw new Error("directory recovery verification disagreed with the inspected source");
6930
+ }
6931
+ copied = true;
6932
+ }
6933
+ return { copied, branchDrift, excludes, statusConfig };
6934
+ }
6935
+
6936
+ /** The top-level entries of a recovery's home snapshot with their bytes,
6937
+ * largest first; a directory ends in `/`. What the retire summary names as
6938
+ * copied from the home. */
6939
+ function preservedHome(recoveredHome) {
6940
+ const paths = readdirSync(recoveredHome, { withFileTypes: true })
6941
+ .map((e) => ({ path: e.isDirectory() ? `${e.name}/` : e.name, bytes: treeBytes(join(recoveredHome, e.name)) }))
6942
+ .sort((a, b) => b.bytes - a.bytes || a.path.localeCompare(b.path));
6943
+ return { paths, bytes: paths.reduce((n, p) => n + p.bytes, 0) };
6944
+ }
6945
+
6946
+ /** Whether a recovery of this observation holds the home only. A home-only
6947
+ * change (notes, harness files, credentials) needs a home snapshot, not
6948
+ * another copy of an otherwise disposable clean worktree. In-progress Git
6949
+ * operations retain the full standalone recovery even when porcelain status
6950
+ * has no changed paths. An orphaned work directory (no git admin entry) stays
6951
+ * where it is, never moved or removed, and git cannot read it: only the home
6952
+ * is snapshotted. A worktree that cannot be proven unchanged (workProvable
6953
+ * false) is never home-only: after the hooks it could be neither proven nor
6954
+ * taken as copied, so its work goes into the snapshot before them. */
6955
+ function homeOnlyRecovery(observation, meta) {
6956
+ if (observation.orphanedWork === true) return true;
6957
+ if (observation.workProvable === false) return false;
6958
+ if (!(observation.classes.length === 1 && observation.classes[0] === "changed instance-home bytes" && meta.work === "worktree" && existsSync(observation.work))) return false;
6959
+ const gitDir = copierGitDir(observation.work);
6960
+ return !RECOVERABLE_GIT_ADMIN.some((name) => existsSync(join(gitDir, name)));
6961
+ }
6962
+
6963
+ /** Write one verified recovery of an observed home and work: staged beside its
6964
+ * final name and renamed into place. `phase` goes into recovery.json:
6965
+ * "before-hooks" for the snapshot a retirement takes before its retire hooks
6966
+ * (completeRetirementRecovery concludes it), "complete" for a recovery nothing
6967
+ * will add to. → { receipt, manifest }: the receipt retire reports, and
6968
+ * recovery.json as written. */
6969
+ function writeRetirementRecovery(observation, meta, instance, phase) {
5969
6970
  const recoveryRoot = join(retirementStateRoot(observation.home), "recovery");
5970
6971
  mkdirSync(recoveryRoot, { recursive: true });
5971
6972
  if (observation.directory && realpathSync(recoveryRoot) !== join(realpathSync(dirname(observation.home)), ".oats-retirement", "recovery")) {
@@ -5973,98 +6974,115 @@ function preserveRetirementWork(observation, meta, instance) {
5973
6974
  }
5974
6975
  const staging = mkdtempSync(join(recoveryRoot, `.${instance}-`));
5975
6976
  const recovery = join(recoveryRoot, basename(staging).slice(1));
5976
- let headUnreadable;
5977
6977
  try {
5978
- // A home-only change (notes, harness files, credentials) needs a home
5979
- // snapshot, not another copy of an otherwise disposable clean worktree.
5980
- // In-progress Git operations retain the full standalone recovery even
5981
- // when porcelain status has no changed paths.
5982
- // An orphaned work directory (no git admin entry) stays where it is, never moved or removed, and git
5983
- // cannot read it: only the home is snapshotted.
5984
- let homeOnly = observation.orphanedWork === true || (observation.classes.length === 1 && observation.classes[0] === "changed instance-home bytes"
5985
- && meta.work === "worktree" && existsSync(observation.work));
5986
- if (homeOnly && !observation.orphanedWork) {
5987
- const gitDir = execFileSync("git", ["-C", observation.work, "rev-parse", "--absolute-git-dir"], { encoding: "utf8", maxBuffer: GIT_MAX_BUFFER }).trim();
5988
- homeOnly = !RECOVERABLE_GIT_ADMIN.some((name) => existsSync(join(gitDir, name)));
5989
- }
6978
+ const homeOnly = homeOnlyRecovery(observation, meta);
5990
6979
  const recoveredHome = join(staging, "home");
5991
- copyRecoveryTree(observation.home, recoveredHome, { excludeRoot: new Set(["work"]) });
5992
- if (fingerprintTree(observation.home, { excludeRoot: new Set(["work"]), instanceHome: true }) !== fingerprintTree(recoveredHome, { instanceHome: true })) {
5993
- throw new Error("home recovery verification disagreed with the source");
5994
- }
5995
- let branchDrift, excludes, statusConfig;
5996
- if (!homeOnly && meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
5997
- const recoveredRepo = join(staging, "repo");
5998
- // The branch is derived from the worktree while it exists: an instance
5999
- // that legitimately switched branches during its task must still be
6000
- // recoverable, and the recorded spawn-time branch is only the fallback
6001
- // when the worktree is gone. A HEAD that is detached, or on a ref OATS
6002
- // carries no name for, recovers detached at its exact commit. A HEAD
6003
- // that cannot be read is an inspection failure, not a failed copy.
6004
- let ref;
6005
- try { ref = existsSync(observation.work) ? worktreeHead(observation.work) : { branch: meta.branch, commit: null }; }
6006
- catch (e) { headUnreadable = e; throw e; }
6007
- if (ref.branch !== meta.branch) branchDrift = { recordedBranch: meta.branch, worktreeBranch: ref.branch, detachedAt: ref.branch === null ? ref.commit : null };
6008
- if (ref.branch !== null) execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", ref.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
6009
- else {
6010
- execFileSync("git", ["clone", "--no-local", "--quiet", "--no-checkout", meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
6011
- execFileSync("git", ["-C", recoveredRepo, "fetch", "--quiet", observation.work, ref.commit], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
6012
- execFileSync("git", ["-C", recoveredRepo, "checkout", "--quiet", "--detach", ref.commit], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
6013
- }
6014
- const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
6015
- detachRecoveryClone(sourceGitContext, recoveredRepo);
6016
- if (existsSync(observation.work)) {
6017
- restoreStandaloneGitState(observation.work, recoveredRepo);
6018
- excludes = carryExcludes(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
6019
- statusConfig = carryStatusConfig(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
6020
- for (const e of readdirSync(observation.work, { withFileTypes: true })) {
6021
- if (e.name === ".git") continue;
6022
- const dest = join(recoveredRepo, e.name);
6023
- rmSync(dest, { recursive: true, force: true });
6024
- copyTreeSafe(join(observation.work, e.name), dest);
6025
- }
6026
- const nested = materializeNestedRepositories(observation.work, recoveredRepo);
6027
- excludes.push(...nested.excludes); statusConfig.push(...nested.statusConfig);
6028
- }
6029
- if (existsSync(join(recoveredRepo, ".git", "objects", "info", "alternates"))) throw new Error("recovery clone depends on object alternates");
6030
- if (existsSync(observation.work)) {
6031
- if (fingerprintTree(observation.work, { excludeRoot: new Set([".git"]), excludeGitMetadata: true }) !== fingerprintTree(recoveredRepo, { excludeRoot: new Set([".git"]), excludeGitMetadata: true })) throw new Error("worktree recovery verification disagreed with the source");
6032
- assertStatusAgrees(observation.work, recoveredRepo, "recovered Git index/status disagreed with the source", ".");
6033
- }
6034
- // The proof of the copy: its HEAD is the commit the worktree has checked
6035
- // out. A branch of the repository can be at another commit with the
6036
- // same files, so the branch's tip there proves nothing about the copy.
6037
- // With the worktree gone there is no such commit to read, and the copy
6038
- // is the recorded branch as the repository has it.
6039
- const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6040
- const sourceHead = ref.commit
6041
- ?? execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${ref.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6042
- if (recoveredHead !== sourceHead) throw new Error(ref.commit ? "recovery clone is not at the commit the worktree has checked out" : "recovery clone does not retain the instance branch tip");
6043
- }
6044
- if (observation.directory && observation.directoryFingerprint) {
6045
- const recoveredWork = join(staging, "work");
6046
- copyTreeSafe(observation.work, recoveredWork);
6047
- if (fingerprintTree(observation.work) !== observation.directoryFingerprint || fingerprintTree(recoveredWork) !== observation.directoryFingerprint) {
6048
- throw new Error("directory recovery verification disagreed with the inspected source");
6049
- }
6050
- }
6980
+ copyRecoveryHome(observation, recoveredHome);
6981
+ const { branchDrift, excludes, statusConfig } = homeOnly ? {} : copyRecoveryWork(observation, meta, staging);
6051
6982
  const repoCopy = homeOnly ? { copied: false, reason: "Only instance-home bytes changed; no work state requires a repository copy", source: meta.repo, branch: meta.branch } : undefined;
6052
- // What the copy cost: the untracked/ignored (or directory) outputs it carries, named.
6983
+ // What the copy cost: the home entries it carries and the untracked/ignored (or directory) outputs, named.
6984
+ const home = preservedHome(recoveredHome);
6053
6985
  const workCopied = observation.directory ? !!observation.directoryFingerprint : !homeOnly && meta.work === "worktree" && existsSync(observation.work);
6054
6986
  const outputs = workCopied ? preservedOutputs(observation.work, observation.directory) : undefined;
6055
- writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}), ...(branchDrift ? { branchDrift } : {}), ...(excludes?.length ? { excludes } : {}), ...(statusConfig?.length ? { statusConfig } : {}), ...(outputs ? { outputs } : {}) }, null, 2) + "\n", { mode: 0o600 });
6987
+ const notCopied = observation.notCopied?.length ? observation.notCopied : undefined;
6988
+ const manifest = { version: 1, phase, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}), ...(branchDrift ? { branchDrift } : {}), ...(excludes?.length ? { excludes } : {}), ...(statusConfig?.length ? { statusConfig } : {}), home, ...(outputs ? { outputs } : {}), ...(notCopied ? { notCopied } : {}) };
6989
+ writeFileSync(join(staging, "recovery.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 });
6056
6990
  const bytes = treeBytes(staging);
6057
6991
  mkdirSync(dirname(recovery), { recursive: true });
6058
6992
  renameSync(staging, recovery);
6059
- return { path: recovery, classes: observation.classes, bytes, ...(outputs ? { outputs } : {}), ...(repoCopy ? { repoCopy } : {}) };
6993
+ return { receipt: { path: recovery, classes: observation.classes, bytes, home, ...(outputs ? { outputs } : {}), ...(repoCopy ? { repoCopy } : {}), ...(notCopied ? { notCopied } : {}) }, manifest };
6060
6994
  } catch (e) {
6061
6995
  rmSync(staging, { recursive: true, force: true });
6062
- if (e === headUnreadable) throw e;
6996
+ if (unreadableHeads.has(e)) throw e;
6063
6997
  const details = e.statusDisagreement ? { home: observation.home, statusDisagreement: e.statusDisagreement } : undefined;
6064
6998
  throw Object.assign(oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${observation.home}; recovery could not be verified: ${e.message}`, details), details ? { details } : {});
6065
6999
  }
6066
7000
  }
6067
7001
 
7002
+ /** One complete recovery, for a caller with no retire hooks still to run
7003
+ * between the snapshot and the removal. → its receipt. */
7004
+ function preserveRetirementWork(observation, meta, instance) {
7005
+ return writeRetirementRecovery(observation, meta, instance, "complete").receipt;
7006
+ }
7007
+
7008
+ /** The post-hook check on the recovery a retirement wrote before its retire
7009
+ * hooks. Each part a hook moved (movedByHooks: the home by its bytes, the work
7010
+ * unless it is proven unchanged) is copied again, whole and verified, under
7011
+ * `after-hooks/`; nothing under the pre-hook `home/`, `repo/` or `work/` is
7012
+ * written, moved or removed. The home is not copied again merely because the
7013
+ * work is: a home whose every byte the hooks left as it was (its exact
7014
+ * digest) is in the pre-hook snapshot as it still is.
7015
+ * `after-hooks/` is staged inside the recovery under a dot-name and renamed
7016
+ * into place; an `after-hooks` entry that already exists is a failure, never
7017
+ * replaced. Then ONE atomic rewrite of recovery.json records `afterHooks` and
7018
+ * moves `phase` to "complete" (the same rewrite, without `afterHooks`, when
7019
+ * the hooks changed nothing). Until that rewrite the manifest says
7020
+ * "before-hooks", so an interrupted pass under-reports and can never read as
7021
+ * complete. A failure removes the staging, leaves the pre-hook snapshot and
7022
+ * its manifest as they were, and refuses: the caller keeps the home.
7023
+ * Directory mode: the retire hooks ran since the pre-hook pass verified that
7024
+ * the recovery sits in the owned storage beside the home, so that is verified
7025
+ * again before anything is written through its path.
7026
+ * → the receipt, updated: `classes` and `notCopied` are the union of both
7027
+ * passes, `bytes` covers the whole directory. */
7028
+ function completeRetirementRecovery({ receipt, manifest }, before, after, meta) {
7029
+ const recovery = receipt.path;
7030
+ const moved = movedByHooks(before, after);
7031
+ const wantHome = moved.home;
7032
+ const final = join(recovery, "after-hooks");
7033
+ const manifestPath = join(recovery, "recovery.json");
7034
+ const manifestTmp = join(recovery, `.recovery.json.${process.pid}.tmp`);
7035
+ const occupied = () => { try { lstatSync(final); return true; } catch { return false; } };
7036
+ const assertOwnedStorage = () => {
7037
+ if (!after.directory) return;
7038
+ let owned = false;
7039
+ try { owned = realpathSync(recovery) === join(realpathSync(dirname(after.home)), ".oats-retirement", "recovery", basename(recovery)); } catch { /* gone: not the owned storage */ }
7040
+ if (!owned) throw new Error("directory recovery storage was redirected");
7041
+ };
7042
+ let staging, manifestStarted = false;
7043
+ try {
7044
+ assertOwnedStorage();
7045
+ // Work that is not proven unchanged is copied. So is work this recovery does
7046
+ // not hold yet: a pre-hook snapshot that was home-only stands for no work,
7047
+ // so "unchanged" proves nothing about it. When the observation after the
7048
+ // hooks is not home-only by that pass's own rule, the work is copied, moved
7049
+ // or not: a class can appear from outside the work state (the baseline is
7050
+ // gone). That rule only ever adds a copy attempt here; it never skips one.
7051
+ // It asks Git, so it is decided in here: a failure refuses like any other
7052
+ // in this pass.
7053
+ const firstWorkCopy = receipt.repoCopy?.copied === false && after.classes.length > 0 && !homeOnlyRecovery(after, meta);
7054
+ const wantWork = !after.orphanedWork && (moved.work || firstWorkCopy);
7055
+ let afterHooks;
7056
+ if (wantHome || wantWork) {
7057
+ if (occupied()) throw new Error(`${final} already exists`);
7058
+ staging = mkdtempSync(join(recovery, ".after-hooks-"));
7059
+ if (wantHome) copyRecoveryHome(after, join(staging, "home"));
7060
+ const workCopied = wantWork && copyRecoveryWork(after, meta, staging).copied;
7061
+ if (wantHome || workCopied) {
7062
+ if (occupied()) throw new Error(`${final} already exists`);
7063
+ renameSync(staging, final);
7064
+ afterHooks = { home: wantHome, work: workCopied };
7065
+ } else rmSync(staging, { recursive: true, force: true });
7066
+ staging = undefined;
7067
+ }
7068
+ const classes = [...new Set([...receipt.classes, ...after.classes])];
7069
+ const notCopied = unionNotCopied(receipt.notCopied, after.notCopied);
7070
+ const { notCopied: _before, ...rest } = manifest;
7071
+ assertOwnedStorage();
7072
+ manifestStarted = true;
7073
+ writeFileSync(manifestTmp, JSON.stringify({ ...rest, phase: "complete", classes, ...(notCopied.length ? { notCopied } : {}), ...(afterHooks ? { afterHooks } : {}) }, null, 2) + "\n", { mode: 0o600 });
7074
+ renameSync(manifestTmp, manifestPath);
7075
+ const { notCopied: _was, ...kept } = receipt;
7076
+ return { ...kept, classes, bytes: treeBytes(recovery), ...(notCopied.length ? { notCopied } : {}), ...(afterHooks ? { afterHooks } : {}) };
7077
+ } catch (e) {
7078
+ if (staging) rmSync(staging, { recursive: true, force: true });
7079
+ if (manifestStarted) rmSync(manifestTmp, { force: true });
7080
+ if (unreadableHeads.has(e)) throw e;
7081
+ const details = e.statusDisagreement ? { home: after.home, statusDisagreement: e.statusDisagreement } : undefined;
7082
+ throw Object.assign(oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${after.home}; recovery could not be verified: ${e.message}`, details), details ? { details } : {});
7083
+ }
7084
+ }
7085
+
6068
7086
  /** Marker a self-retiring instance leaves BESIDE its home: the retirement is
6069
7087
  * requested and owed, and a detached completion is on its way. It is not
6070
7088
  * written into the home, so the caller changes no instance bytes and the
@@ -6109,7 +7127,8 @@ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
6109
7127
  const intent = {
6110
7128
  instance: name, agent: found.agent.name, root: resolve(root),
6111
7129
  requestedAt: new Date().toISOString(), requestedByPid: process.pid, delaySec,
6112
- options: { home: found.home, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
7130
+ // A confirmed plan's extra trees go with the intent, so the completion is bound by them as well.
7131
+ options: { home: found.home, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session, ...(Array.isArray(o.plannedExtraWorktrees) ? { plannedExtraWorktrees: o.plannedExtraWorktrees } : {}) }, resultPath,
6113
7132
  };
6114
7133
  const env = { ...process.env, OATS_RETIRE_INTENT: JSON.stringify(intent) };
6115
7134
  for (const k of CORE_LAUNCH_ENV) delete env[k];
@@ -6187,6 +7206,7 @@ export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
6187
7206
  try {
6188
7207
  result = retireInstance(intent.root, intent.instance, {
6189
7208
  home: intent.options?.home, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
7209
+ ...(Array.isArray(intent.options?.plannedExtraWorktrees) ? { plannedExtraWorktrees: intent.options.plannedExtraWorktrees } : {}),
6190
7210
  ...(obsoleteDeleteBranch ? { [OBSOLETE_DELETE_BRANCH]: true } : {}),
6191
7211
  });
6192
7212
  } catch (e) {
@@ -6330,6 +7350,16 @@ export function retireInstance(root, name, o = {}) {
6330
7350
  // Whether this retire removes the worktree, when it gets to that step.
6331
7351
  const worktreeRemoval = { removes: !!(o.discardWorktree || owesWorktree), repo: meta.repo };
6332
7352
  const initialObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
7353
+ // A locked extra tree is known from the home as it is now: Git will neither move nor remove it, so a retire that
7354
+ // would remove the home refuses here, before the session is stopped and before any retire hook runs (the hooks
7355
+ // revoke identities a kept home would still need). The extra-tree step keeps its own check, for a lock that appears
7356
+ // during the hooks. --force does not bypass it: it covers hook debt, not local work.
7357
+ if (!o.keepDir) {
7358
+ const locked = initialObservation.extraTrees.filter((t) => t.locked !== undefined);
7359
+ if (locked.length) {
7360
+ throw oatsError("E_WORK_PRESERVATION_FAILED", `${name}: ${locked.map((t) => `the extra worktree ${t.path} is locked${t.locked ? ` (${t.locked})` : ""}`).join("; ")}; Git will neither move nor remove a locked worktree. Unlock it with \`git worktree unlock\`, or move it out of the home, then retire again; nothing was run or removed`);
7361
+ }
7362
+ }
6333
7363
  // Harness identity is destructive authority. The mutable child metadata may
6334
7364
  // describe it for humans, but only the independent baseline can authorize the
6335
7365
  // endpoint that proves quiescence.
@@ -6361,6 +7391,7 @@ export function retireInstance(root, name, o = {}) {
6361
7391
  // `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
6362
7392
  // A no-launch instance is already quiesced. A launched one must have exact
6363
7393
  // window absence established before recovery copying begins.
7394
+ let sessionStopped = false;
6364
7395
  if (!self && runtimeAuthority?.launched) {
6365
7396
  const runtimeSession = runtimeAuthority.tmux.session;
6366
7397
  const runtimeWindow = runtimeAuthority.tmux.window;
@@ -6384,11 +7415,41 @@ export function retireInstance(root, name, o = {}) {
6384
7415
  if (scan.processes.length) throw unestablished(`${detail}; a process still works in this home (${scan.processes.slice(0, 5).map((p) => `pid ${p.pid} ${p.command}`).join(", ")}${scan.processes.length > 5 ? `, and ${scan.processes.length - 5} more` : ""})`);
6385
7416
  }
6386
7417
  }
7418
+ sessionStopped = true;
6387
7419
  }
6388
7420
  const stableObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
6389
- const workRecoveries = [];
6390
- if (stableObservation.classes.length) workRecoveries.push(preserveRetirementWork(stableObservation, meta, name));
6391
- let workRecovery = workRecoveries.at(-1);
7421
+ // The bytes of the worktree as they are before the hooks: after the hooks,
7422
+ // "the work did not move" is then proven, never taken from a status text.
7423
+ // Read before the recovery is written, so that a worktree that cannot be
7424
+ // read (an entry that is not a file, a directory or a symbolic link)
7425
+ // refuses here and leaves no recovery behind, however often it is retried.
7426
+ // The refusal says what this retire has and has not done by now: a launched
7427
+ // instance's session was stopped above. A pre-hook copy of the work
7428
+ // verifies itself against this same read. A worktree that cannot be proven
7429
+ // unchanged is copied again in any case, and with nothing to preserve
7430
+ // before the hooks there is nothing to prove: any class after them is
7431
+ // preserved.
7432
+ // What a refusal before the hooks tells the operator about the retire so far.
7433
+ const soFar = (kept) => `${name} is not retired and ${kept}; ${sessionStopped ? "its session has been stopped" : "this retire stopped no session"}`;
7434
+ if (stableObservation.classes.length && stableObservation.workProvable) {
7435
+ try { observedWorkBytes(stableObservation); }
7436
+ catch (e) {
7437
+ throw oatsError(e.code, `${e.message}. No recovery was written and nothing was deleted: ${soFar("its home is kept")}`);
7438
+ }
7439
+ }
7440
+ // One recovery per retirement. The snapshot taken here, before the retire
7441
+ // hooks, is the gate: it is verified before any hook runs and is never
7442
+ // rewritten. What the hooks change is added to it afterwards. A copy that
7443
+ // cannot be made or verified refuses here, with the same disclosure.
7444
+ let preHookRecovery;
7445
+ if (stableObservation.classes.length) {
7446
+ try { preHookRecovery = writeRetirementRecovery(stableObservation, meta, name, "before-hooks"); }
7447
+ catch (e) {
7448
+ if (e?.code !== "E_WORK_PRESERVATION_FAILED") throw e;
7449
+ throw Object.assign(oatsError(e.code, `${e.message}. No retire hook has run, no recovery was written and nothing was deleted: ${soFar("its home and work are kept")}`, e.provenance), e.details ? { details: e.details } : {});
7450
+ }
7451
+ }
7452
+ let workRecovery = preHookRecovery?.receipt;
6392
7453
 
6393
7454
  // Capability lifecycle hooks (retire) — run BEFORE the dir (and any package state in it,
6394
7455
  // e.g. aweb signing keys) is removed. The knowledge integration harvests notes/ here;
@@ -6463,11 +7524,19 @@ export function retireInstance(root, name, o = {}) {
6463
7524
  }
6464
7525
 
6465
7526
  // Hooks are allowed to mutate the inspected tree, so inspect again after
6466
- // them and preserve a separately verified post-hook snapshot when needed.
7527
+ // them. A recovery written before the hooks gains a separately verified
7528
+ // snapshot of each part they moved, under its after-hooks/, and its manifest
7529
+ // is concluded; a home that only now has something to preserve gets its one
7530
+ // recovery here. Nothing was preserved before the hooks only when there was
7531
+ // no class then, so a class now is something new to preserve, whether it
7532
+ // came from the home, from the work or from outside both (another ref, the
7533
+ // baseline): it does not have to be a move of the observed state. Either
7534
+ // way this finishes, or refuses and keeps the home, before the worktree
7535
+ // step and before the home is removed.
6467
7536
  const finalObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
6468
- if (finalObservation.classes.length && finalObservation.stateFingerprint !== stableObservation.stateFingerprint) {
6469
- workRecoveries.push(preserveRetirementWork(finalObservation, meta, name));
6470
- workRecovery = workRecoveries.at(-1);
7537
+ if (preHookRecovery) workRecovery = completeRetirementRecovery(preHookRecovery, stableObservation, finalObservation, meta);
7538
+ else if (finalObservation.classes.length) {
7539
+ workRecovery = preserveRetirementWork({ ...finalObservation, notCopied: unionNotCopied(stableObservation.notCopied, finalObservation.notCopied) }, meta, name);
6471
7540
  }
6472
7541
 
6473
7542
  // Lineage repair: any instance pointing at the retiree (parentInstance from a
@@ -6534,6 +7603,54 @@ export function retireInstance(root, name, o = {}) {
6534
7603
  // A failed spawn's quarantine that owes the worktree removes it; any other retire retains it unless
6535
7604
  // --discard-worktree. An orphaned work directory is never touched.
6536
7605
  const outstandingBeforeWorktree = quarantine ? retryFailures : ordinaryIncomplete;
7606
+ // The home's extra trees (awebai/oats#674), in every work mode, before the
7607
+ // work/ step so that a refusal here leaves work/ as it is. Only when the
7608
+ // home is going to be removed: not under --keep-dir, and not when it is
7609
+ // kept for a retry (the work/ step's condition). The trees are the final
7610
+ // inspection's verified set, the one its recovery left out of the home's
7611
+ // bytes. A clean tree is removed (its branch stays in its repository); any
7612
+ // other is re-homed like work/; a locked one, or one Git will not move or
7613
+ // remove, refuses and keeps the home, --force included: it covers hook
7614
+ // debt, not local work. --discard-worktree does not apply. An applied plan
7615
+ // (`plannedExtraWorktrees`) binds what is done: trees that no longer read
7616
+ // as planned refuse as stale before any of them is touched.
7617
+ let extraWorktrees;
7618
+ if (!o.keepDir && !(outstandingBeforeWorktree.length > 0 && !o.force)) {
7619
+ const rows = extraWorktreeRows(finalObservation.extraTrees, root);
7620
+ if (o.plannedExtraWorktrees && JSON.stringify(rows) !== JSON.stringify(o.plannedExtraWorktrees)) {
7621
+ throw oatsError("E_PLAN_STALE", `${name}: the home's extra worktrees changed since the retire plan was shown, so none of them was moved or removed. The retire hooks have run; the home and its work are kept. Review the fresh plan (\`oats retire ${name} --plan\`) and apply it again.`);
7622
+ }
7623
+ const refused = rows.filter((r) => r.disposition === "refuse");
7624
+ if (refused.length) {
7625
+ throw oatsError("E_WORK_PRESERVATION_FAILED", `${name}: ${refused.map((r) => `the extra worktree ${r.path}: ${r.reason}`).join("; ")}. No extra worktree was moved or removed and work/ is untouched; the retire hooks have run and the home is kept so nothing is lost.`);
7626
+ }
7627
+ extraWorktrees = [];
7628
+ const done = () => extraWorktrees.length ? ` Already done in this retire: ${extraWorktrees.map((r) => r.outcome === "removed" ? `${r.path} removed` : `${r.path} re-homed to ${r.movedTo}`).join(", ")}.` : "";
7629
+ // Each row's repository, by its Git directory: the commands run helper-free (gitRepoRun).
7630
+ const gitDirOf = new Map(finalObservation.extraTrees.map((tree) => [tree.path, tree.commonDir]));
7631
+ const git = (row, argv) => gitRepoRun(gitDirOf.get(row.path), argv);
7632
+ const failed = (row, what, e) => oatsError("E_WORK_PRESERVATION_FAILED", `${name}: the extra worktree ${row.path} ${what} (${String(e?.stderr ?? e?.message ?? e ?? "").trim()}).${done()} work/ is untouched; the retire hooks have run and the home is kept so nothing is lost — resolve and retry.`);
7633
+ for (const row of rows) {
7634
+ if (row.disposition === "remove") {
7635
+ try {
7636
+ git(row, ["worktree", "remove", row.path]);
7637
+ git(row, ["worktree", "prune"]);
7638
+ const real = realPathOrNearest(row.path);
7639
+ if (existsSync(row.path) || parseWorktreeList(git(row, ["worktree", "list", "--porcelain", "-z"])).some((r) => realPathOrNearest(r.worktree) === real)) throw new Error("it is still there after `git worktree remove`");
7640
+ } catch (e) { throw failed(row, "could not be removed, or its removal could not be verified", e); }
7641
+ extraWorktrees.push({ ...row, outcome: "removed" });
7642
+ appendEvent(found.home, { kind: "worktree-removed", data: { branch: row.branch, extra: true, path: row.path } }, { workspaceOnly: true });
7643
+ } else {
7644
+ try {
7645
+ mkdirSync(dirname(row.movedTo), { recursive: true });
7646
+ git(row, ["worktree", "move", row.path, row.movedTo]);
7647
+ } catch (e) { throw failed(row, `could not be re-homed to ${row.movedTo}`, e); }
7648
+ extraWorktrees.push({ ...row, outcome: "retained" });
7649
+ appendEvent(found.home, { kind: "worktree-retained", data: { movedTo: row.movedTo, branch: row.branch, recordedBranch: null, extra: true, path: row.path } }, { workspaceOnly: true });
7650
+ }
7651
+ }
7652
+ if (!extraWorktrees.length) extraWorktrees = undefined;
7653
+ }
6537
7654
  const worktreeStep = isWorktree && !!meta.repo && !orphanedWork;
6538
7655
  const worktreeDeferred = worktreeStep && existsSync(workPath) && outstandingBeforeWorktree.length > 0 && !o.force;
6539
7656
  const keptForRetry = worktreeDeferred ? `git worktree ${workPath}: kept for the retry; outstanding: ${outstandingBeforeWorktree.join("; ")}` : null;
@@ -6557,12 +7674,8 @@ export function retireInstance(root, name, o = {}) {
6557
7674
  shTry(`git -C ${shq(meta.repo)} worktree prune`);
6558
7675
  retention = { worktree: "removed", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
6559
7676
  } else if (existsSync(workPath)) {
6560
- const repoName = basename(realPathOrNearest(meta.repo)).replace(/\.git$/, "") || "repo";
6561
- const leaf = (verifiedBranch ?? `detached-${(ref.commit || "unknown").slice(0, 12)}`).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "work";
6562
- const retainedRoot = join(workspaceOf(root), ".agents", "worktrees", repoName);
6563
- mkdirSync(retainedRoot, { recursive: true });
6564
- let dest = join(retainedRoot, leaf);
6565
- for (let n = 2; existsSync(dest); n++) dest = join(retainedRoot, `${leaf}-${n}`);
7677
+ const dest = retainedWorktreeDest(workspaceOf(root), meta.repo, verifiedBranch, ref.commit);
7678
+ mkdirSync(dirname(dest), { recursive: true });
6566
7679
  try {
6567
7680
  execFileSync("git", ["-C", meta.repo, "worktree", "move", workPath, dest], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
6568
7681
  } catch (e) {
@@ -6673,7 +7786,7 @@ export function retireInstance(root, name, o = {}) {
6673
7786
  rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
6674
7787
  }
6675
7788
 
6676
- const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, retention, worktreeRemoved: isWorktree && !!retention && retention.worktree !== "retained", branchDeleted: false, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
7789
+ const result = { retired: name, agent: found.agent.name, workRecovery, retention, extraWorktrees, worktreeRemoved: isWorktree && !!retention && retention.worktree !== "retained", branchDeleted: false, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
6677
7790
  const w = [...(hookResults?.warnings || [])];
6678
7791
  if (o[OBSOLETE_DELETE_BRANCH]) w.push(OBSOLETE_DELETE_BRANCH_SENTENCE);
6679
7792
  if (isCapturedHome(meta) && !quarantine) {