@awebai/oats 0.22.19 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +6 -2
  2. package/bin/oats.mjs +24 -10
  3. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +558 -0
  4. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +744 -0
  5. package/docs/design/2026-09-13-knowledge-implementation.md +127 -0
  6. package/docs/design/2026-09-13-knowledge-location-contract.md +340 -0
  7. package/docs/design/package-runtime-api.md +177 -3
  8. package/docs/desktop-cli-api.md +1 -1
  9. package/docs/execution-targets.md +16 -0
  10. package/docs/knowledge-capability-authoring.md +98 -0
  11. package/docs/knowledge-reference/acceptance.md +108 -0
  12. package/docs/knowledge-reference/adoption.md +61 -0
  13. package/docs/knowledge-reference/harvester.md +107 -0
  14. package/docs/knowledge-reference/model.md +84 -0
  15. package/docs/knowledge-reference/package-craft.md +126 -0
  16. package/docs/knowledge-reference/provider-mapping.md +77 -0
  17. package/docs/knowledge-reference/reader-capture.md +87 -0
  18. package/docs/knowledge-theory.md +20 -6
  19. package/docs/layers.md +8 -7
  20. package/docs/oats-config.schema.json +5 -2
  21. package/docs/release-notes/v0.23.0.md +93 -0
  22. package/docs/souls-and-instances.md +17 -1
  23. package/injects/work-directory.md +18 -0
  24. package/lib/core.mjs +279 -56
  25. package/lib/schedule.mjs +12 -2
  26. package/package.json +2 -2
  27. package/packages/record/README.md +19 -0
  28. package/packages/record/bin/capture.mjs +96 -48
  29. package/packages/record/bin/recall.mjs +17 -11
  30. package/packages/record/bin/record-native-start.mjs +11 -0
  31. package/packages/record/lib/capture-cc.mjs +82 -27
  32. package/packages/record/lib/capture-lock.mjs +15 -2
  33. package/packages/record/lib/formats.mjs +108 -21
  34. package/packages/record/lib/native-history.mjs +87 -0
  35. package/packages/record/lib/session-roots.mjs +90 -0
  36. package/packages/record/lib/session-snapshot.mjs +61 -0
  37. package/packages/record/lib/sessions-for-home.mjs +88 -56
  38. package/skills/oats/SKILL.md +3 -1
package/lib/core.mjs CHANGED
@@ -23,7 +23,7 @@
23
23
  * soul.yaml (flat key: value):
24
24
  * name, description, kind (persistent|local), type (optional agent-type/family, targeted by config),
25
25
  * repo (path rel. to workspace or absolute),
26
- * work (worktree|checkout|attached), runtime (pi|claude|codex), model (pi model pattern, optional)
26
+ * work (worktree|checkout|attached|workspace|directory), runtime (pi|claude|codex), model (pi model pattern, optional)
27
27
  * (attached as soul default is for service agents — spawn must supply workDir)
28
28
  */
29
29
  import { execFileSync, execSync, spawn as spawnProcess } from "node:child_process";
@@ -35,6 +35,7 @@ import { accessSync, constants as fsConstants } from "node:fs";
35
35
  import { homedir, tmpdir } from "node:os";
36
36
  import { createHash, randomUUID } from "node:crypto";
37
37
  import { fileURLToPath } from "node:url";
38
+ import { initializeNativeHistory, prepareNativeStart } from "../packages/record/lib/native-history.mjs";
38
39
  import { attachSessionTarget } from "./session-viewer.mjs";
39
40
  import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
40
41
  import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
@@ -42,7 +43,7 @@ import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, valid
42
43
  export const RESERVED = new Set(["bin", "local-agents", "tmp-agents"]);
43
44
  /** The work modes spawn accepts — also the enum a quarantine cleanup descriptor
44
45
  * must satisfy, so the retry cannot skip Git cleanup on an unrecognised value. */
45
- export const WORK_MODES = ["worktree", "checkout", "attached", "workspace"];
46
+ export const WORK_MODES = ["worktree", "checkout", "attached", "workspace", "directory"];
46
47
  /** Local (uncommitted) souls dir: <scope>/local-agents, a SIBLING of agents/.
47
48
  * Legacy nested <root>/local-agents and <root>/tmp-agents are still read. */
48
49
  export const LOCAL_AGENTS_DIR = "local-agents";
@@ -340,10 +341,12 @@ export function parseFrontmatter(text) {
340
341
  }
341
342
 
342
343
  // ---------- root discovery ----------
343
- /** Closest agents/ dir walking up from `cwd`. Returns undefined if none. */
344
+ /** Closest agents/local-agents layout walking up from `cwd`; without one,
345
+ * the nearest non-laptop config declares a package-only deployment root. */
344
346
  export function findRoot(cwd = process.cwd()) {
345
347
  if (process.env.PI_AGENTS_ROOT) return resolve(process.env.PI_AGENTS_ROOT);
346
348
  let d = resolve(cwd);
349
+ let configuredRoot; // package-only deployments need no pre-created soul directories
347
350
  while (true) {
348
351
  if (basename(d) === "agents" && lstatSync(d).isDirectory()) return d;
349
352
  if (basename(d) === LOCAL_AGENTS_DIR && lstatSync(d).isDirectory() && basename(dirname(d)) !== "agents") {
@@ -354,8 +357,9 @@ export function findRoot(cwd = process.cwd()) {
354
357
  // A scope with only local agents is fully operable: its canonical agents
355
358
  // root is the (possibly absent) sibling agents/ dir.
356
359
  if (existsSync(join(d, LOCAL_AGENTS_DIR)) && lstatSync(join(d, LOCAL_AGENTS_DIR)).isDirectory()) return candidate;
360
+ if (!configuredRoot && d !== homedir() && existsSync(join(d, "oats-config.yaml"))) configuredRoot = candidate;
357
361
  const parent = dirname(d);
358
- if (parent === d) return undefined;
362
+ if (parent === d) return configuredRoot;
359
363
  d = parent;
360
364
  }
361
365
  }
@@ -486,7 +490,7 @@ export function ensureRoot(cwd) {
486
490
  const root = findRoot(cwd);
487
491
  if (!root) {
488
492
  throw new Error(
489
- `no agents/ or local-agents/ directory found walking up from ${resolve(cwd ?? process.cwd())} — create one (mkdir agents, or \`oats create <name> --local\`) or set PI_AGENTS_ROOT`,
493
+ `no agents/, local-agents/, or deployment oats-config.yaml found walking up from ${resolve(cwd ?? process.cwd())} — create one (mkdir agents, or \`oats create <name> --local\`) or set PI_AGENTS_ROOT`,
490
494
  );
491
495
  }
492
496
  // Deployment root ≠ invocation CWD: homes always land in the primary checkout.
@@ -4621,7 +4625,7 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
4621
4625
  return accepted;
4622
4626
  }
4623
4627
 
4624
- export function runLifecycleHooks(event, { home, instance, agentName, soulDir, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {} }) {
4628
+ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {}, assertRoots }) {
4625
4629
  const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [], contributions: [] };
4626
4630
  const envOwners = new Map();
4627
4631
  const envDeclarations = new Map((resolved.capabilities || []).map((cap) => [cap.id, { names: new Set(cap.environment || []), namespaces: [...(cap.environmentNamespaces || [])] }]));
@@ -4634,6 +4638,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
4634
4638
  if (cap.executable && !cap.trust?.trusted) results.warnings.push(`${cap.id}: executable surface disabled — ${cap.trust?.reason || "not trusted"}`);
4635
4639
  const cmd = cap.hooks?.[event];
4636
4640
  if (!cmd) continue;
4641
+ assertRoots?.();
4637
4642
  results.order.push(cap.id);
4638
4643
  try {
4639
4644
  const stdout = execSync(cmd, {
@@ -4651,6 +4656,10 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
4651
4656
  OATS_SOUL: soulDir || "", OATS_CONTEXT: contextDir, OATS_WORKSPACE: workspaceDir || "", OATS_LEVEL: cap.level || "",
4652
4657
  OATS_TEAM_NAME: resolved.team?.name || "", OATS_TEAM_ID: resolved.team?.id || "", OATS_TEAM_SCOPE: resolved.team?.scope || "",
4653
4658
  ...extraEnv,
4659
+ // Hooks also run through direct core callers (not only bin/oats).
4660
+ // Author this from the running kernel, never PATH, ambient env or
4661
+ // a caller's extraEnv: those may point at a different executable.
4662
+ OATS_CLI_BIN: realpathSync(join(PKG_ROOT, "bin", "oats.mjs")),
4654
4663
  OATS_SETTINGS: JSON.stringify(cap.settings || {}),
4655
4664
  OATS_META: JSON.stringify(priorMeta[cap.id] || {}),
4656
4665
  },
@@ -4710,6 +4719,8 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
4710
4719
  // to DETECT hook failures, not just print them (warnings are advisory).
4711
4720
  const required = (cap.requiredHooks || []).includes(event);
4712
4721
  results.failures.push({ capability: cap.id, event, message: detail, required });
4722
+ } finally {
4723
+ assertRoots?.(); // even a failing hook may have exchanged its cwd
4713
4724
  }
4714
4725
  }
4715
4726
  return results;
@@ -4899,6 +4910,18 @@ export function resolveRepo(root, repo) {
4899
4910
  return abs;
4900
4911
  }
4901
4912
 
4913
+ /** Only explicit directory execution relaxes the Git requirement. `repo` is
4914
+ * still the config/deployment context, never a directory we take ownership of. */
4915
+ function resolveExecutionContext(root, target, work) {
4916
+ if (work !== "directory") return resolveRepo(root, target);
4917
+ if (target !== undefined && (typeof target !== "string" || !target.trim() || target.includes("\0"))) {
4918
+ throw oatsError("E_BAD_ARGS", "directory mode repo must name an existing context directory");
4919
+ }
4920
+ const context = target === undefined ? workspaceOf(root) : resolve(workspaceOf(root), target);
4921
+ if (!existsSync(context) || !statSync(context).isDirectory()) throw oatsError("E_BAD_ARGS", `directory mode context is not a directory: ${context}`);
4922
+ return resolve(context);
4923
+ }
4924
+
4902
4925
  // ---------- OKF (Open Knowledge Format) helpers ----------
4903
4926
  export function todayISO() { return new Date().toISOString().slice(0, 10); }
4904
4927
 
@@ -4996,7 +5019,7 @@ export function writeSoul(root, { name, kind, repo, work, runtime, model, yolo,
4996
5019
  // The committed soul remains canonical and config-independent. Composition happens in instances.
4997
5020
  const claudeMd = join(soulDir, "CLAUDE.md");
4998
5021
  try { lstatSync(claudeMd); } catch { symlinkSync("AGENTS.md", claudeMd); }
4999
- const ctx = repo ? resolveRepo(root, repo) : (defaultRepo(root) || workspaceOf(root));
5022
+ const ctx = work === "directory" ? resolveExecutionContext(root, repo, work) : repo ? resolveRepo(root, repo) : (defaultRepo(root) || workspaceOf(root));
5000
5023
  const resolved = resolveOatsConfig(ctx, name);
5001
5024
  runSoulScaffoldHooks({
5002
5025
  home: soulDir, instance: name, agentName: name, soulDir,
@@ -5020,7 +5043,7 @@ export function createAgent(root, o) {
5020
5043
  const name = slug(o.name);
5021
5044
  if (RESERVED.has(name)) throw new Error(`"${name}" is a reserved name`);
5022
5045
  if (findAgent(root, name)) throw new Error(`agent "${name}" already exists`);
5023
- if (o.repo) resolveRepo(root, o.repo);
5046
+ if (o.repo !== undefined) resolveExecutionContext(root, o.repo, o.work);
5024
5047
  // kind: "local" → a FULL soul (memory, skills, instances) under the scope's
5025
5048
  // local-agents/ — uncommitted by contract; otherwise a committed persistent soul.
5026
5049
  const kind = o.local || o.kind === "local" ? "local" : "persistent";
@@ -5446,6 +5469,18 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
5446
5469
  return `OATS_INSTANCE=${shq(instance)} OATS_INSTANCE_HOME=${shq(home)} PI_AGENT_INSTANCE=${shq(instance)} PI_AGENT_HOME=${shq(home)}${envTokens.length ? ` ${envTokens.join(" ")}` : ""} ${cmdline}`;
5447
5470
  }
5448
5471
 
5472
+ /** Execution, not preview: mark pending before dispatch, then resolve native
5473
+ * storage inside the backend shell under the actual command environment.
5474
+ * The original executable/argv is exec'd unchanged after recording succeeds. */
5475
+ function nativeRecordCommand(command, home, runtime) {
5476
+ const { tokens, binary } = parseLaunchCommand(command);
5477
+ const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
5478
+ const id = prepareNativeStart(home, runtime);
5479
+ const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
5480
+ const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(runtime)} ${shq(JSON.stringify(args))} && exec ${tokens.slice(binary).map(t => t.text).join(" ")}`;
5481
+ return `${tokens.slice(0, binary).map(t => t.text).join(" ")} /bin/sh -c ${shq(inner)}`;
5482
+ }
5483
+
5449
5484
  /** A recorded recipe this kernel understands, or a refusal before anything
5450
5485
  * is observed or stopped. */
5451
5486
  export function assertLaunchRecipe(recipe, what) {
@@ -5481,7 +5516,8 @@ function requirementsWithArgsMessage(runtime, providers, config) {
5481
5516
  * collects every failed check into `preflight` instead of throwing. A home
5482
5517
  * that predates recipes is not planned here (E_LAUNCH_LEGACY): its frozen
5483
5518
  * command is described as is, and converted only by restart. */
5484
- export function planLaunch({ home, instance, meta, contextDir, agentLike, selection = {}, launchConfigs, resolvedCfg, env = process.env, preview = false }) {
5519
+ export function planLaunch({ home, instance, meta, contextDir, agentLike, selection = {}, launchConfigs, resolvedCfg, env = process.env, preview = false, assertRoots }) {
5520
+ assertRoots?.();
5485
5521
  const problems = [];
5486
5522
  const fail = (check, code, detail) => { if (!preview) throw oatsError(code, detail); problems.push({ check, ok: false, detail, code }); };
5487
5523
  // A recorded recipe is validated before anything is observed; a home that
@@ -5507,7 +5543,7 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
5507
5543
  // every capability that gave runtime-specific ones; recorded arguments
5508
5544
  // of a capability the scope no longer trusts are not reused.
5509
5545
  try {
5510
- hooks = prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, contextDir });
5546
+ hooks = prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, contextDir, assertRoots });
5511
5547
  const current = new Map((resolvedCfg?.capabilities || []).map((c) => [c.id, c]));
5512
5548
  const untrusted = hooks.contributions.filter((c) => c.capability && current.has(c.capability) && !current.get(c.capability).trust?.trusted).map((c) => c.capability);
5513
5549
  const inactive = hooks.contributions.filter((c) => c.capability && !current.has(c.capability)).map((c) => c.capability);
@@ -5605,6 +5641,9 @@ export function redactLaunchRecipe(recipe) {
5605
5641
  export function spawnInstance(root, agent, o = {}) {
5606
5642
  const work = o.work || agent.work || "checkout";
5607
5643
  if (!WORK_MODES.includes(work)) throw new Error(`unknown work mode "${work}" (${WORK_MODES.join("|")})`);
5644
+ if (work === "directory" && (o.workDir !== undefined || o.branch !== undefined)) {
5645
+ throw oatsError("E_BAD_ARGS", "directory mode owns only <home>/work; workDir/--work-dir and branch/--branch are not allowed");
5646
+ }
5608
5647
  if (work === "attached" && !o.workDir) throw new Error(`attached mode needs workDir — the owning instance's work tree (its <home>/work)`);
5609
5648
  if (o.task !== undefined && typeof o.task !== "string") throw new Error(`task must be a string (got ${typeof o.task}) — a flag parser handing --task's next flag through shows up here`);
5610
5649
  if (o.launchConfig !== undefined && (typeof o.launchConfig !== "string" || !o.launchConfig.trim())) throw oatsError("E_BAD_ARGS", "launchConfig must be a configuration name or none");
@@ -5613,7 +5652,7 @@ export function spawnInstance(root, agent, o = {}) {
5613
5652
  if (!["tmux", "herdr"].includes(backend)) throw new Error(`unknown session backend "${backend}" (tmux|herdr)`);
5614
5653
  if (o.herdrSocket !== undefined && (typeof o.herdrSocket !== "string" || !o.herdrSocket)) throw oatsError("E_BAD_ARGS", "herdrSocket must be a socket path");
5615
5654
  const launch = o.launch !== false;
5616
- const repoAbs = resolveRepo(root, o.repo || agent.repo);
5655
+ const repoAbs = resolveExecutionContext(root, work === "directory" ? (o.repo !== undefined ? o.repo : agent.repo) : (o.repo || agent.repo), work);
5617
5656
  if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
5618
5657
  // Launch selection: a named configuration (explicit, or the soul's
5619
5658
  // launch-config default), or none; the runtime and model follow from it.
@@ -5950,6 +5989,7 @@ export function spawnInstance(root, agent, o = {}) {
5950
5989
  const herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
5951
5990
  const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
5952
5991
 
5992
+ if (existsSync(directoryRollbackPath(homeReal))) throw oatsError("E_WORK_INSPECTION_FAILED", `directory cleanup is still owed for ${home}; restore and retire the retained home before reusing its name`);
5953
5993
  mkdirSync(home, { recursive: true });
5954
5994
  // TOCTOU: the placement checks above ran BEFORE composition and the runtime
5955
5995
  // package preflight, both of which shell out — a window in which anything able
@@ -5967,6 +6007,8 @@ export function spawnInstance(root, agent, o = {}) {
5967
6007
  throw oatsError("E_NO_CANONICAL_ROOT", `instance home ${home} was created at ${createdReal}, not at ${expectedHome} — the path changed after it was validated (a swapped instances/ link), so nothing has been written into it and the spawn is aborted`);
5968
6008
  }
5969
6009
 
6010
+ initializeNativeHistory(home);
6011
+
5970
6012
  // Body: the soul is linked for reference, while instructions are a generated instance-local view.
5971
6013
  symlinkSync(soulDir, join(home, "soul"));
5972
6014
  writeFileSync(join(home, "AGENTS.md"), composition.text);
@@ -6092,6 +6134,9 @@ export function spawnInstance(root, agent, o = {}) {
6092
6134
  const note = incomplete.length ? ` — rollback INCOMPLETE — clean up manually: ${incomplete.join("; ")}` : "";
6093
6135
  throw new Error(`git worktree add/canonicalization failed: ${original}${note}`);
6094
6136
  }
6137
+ } else if (work === "directory") {
6138
+ // An owned execution directory, not a link to the source or a fake Git repo.
6139
+ mkdirSync(join(home, "work"));
6095
6140
  } else if (work === "attached") {
6096
6141
  // Attach to ANOTHER instance's work tree (o.workDir): sibling home, shared tree.
6097
6142
  // The tree belongs to its owner — retire never removes it (work/ is a symlink).
@@ -6122,12 +6167,19 @@ export function spawnInstance(root, agent, o = {}) {
6122
6167
  catch (e) { warnings.push(`worktree setup command failed (continuing): ${String(e.message || e).slice(0, 200)}`); }
6123
6168
  }
6124
6169
 
6170
+ // Establish directory ownership while these are still the kernel's own roots.
6171
+ // A hook may replace either path; it must never mint authority for that target.
6172
+ if (work === "directory") {
6173
+ assertDirectoryRoots(home, homeReal);
6174
+ writeRetirementBaseline(home, join(home, "work"), work, wm, resolvedCfg.capabilities, { launched: false });
6175
+ }
6176
+
6125
6177
  // Capability lifecycle hooks (spawn) — the knowledge integration scaffolds instance
6126
6178
  // memory (STATE.md/log.md/notes/ are OKF conventions, not kernel ones); the
6127
6179
  // messaging integration mints the comms identity. Kernel stays memory-agnostic.
6128
6180
  const hookRes = runLifecycleHooks("spawn", {
6129
6181
  home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
6130
- workspaceDir: workspaceOf(root), resolved: resolvedCfg,
6182
+ workspaceDir: workspaceOf(root), rootDir: root, resolved: resolvedCfg,
6131
6183
  extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_RUNTIME: runtime, OATS_KIND: agent.kind || "persistent" },
6132
6184
  });
6133
6185
  warnings.push(...hookRes.warnings);
@@ -6162,7 +6214,7 @@ export function spawnInstance(root, agent, o = {}) {
6162
6214
  if (work === "worktree") { outstandingGit.add("worktree"); if (branch) outstandingGit.add("branch"); }
6163
6215
  return quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
6164
6216
  repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
6165
- launched: true, sessionTarget: spawnHerdr, recordRetirementBaseline: true });
6217
+ launched: true, sessionTarget: spawnHerdr, directoryHome: homeReal, recordRetirementBaseline: true });
6166
6218
  }
6167
6219
  }
6168
6220
  if (windowMayExist && backend === "herdr" && !spawnHerdr) {
@@ -6186,11 +6238,44 @@ export function spawnInstance(root, agent, o = {}) {
6186
6238
  return quarantineInstanceHome({
6187
6239
  home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
6188
6240
  repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
6189
- launched: true, tmux: spawnTmux, recordRetirementBaseline: true,
6241
+ launched: true, tmux: spawnTmux, directoryHome: homeReal, recordRetirementBaseline: true,
6190
6242
  });
6191
6243
  }
6192
6244
  }
6245
+ // Once hooks ran, directory execution may already hold authored results.
6246
+ // Preserve before compensation and again afterwards, just like retirement.
6247
+ // Failure to copy retains the home, never converts a failed spawn into loss.
6248
+ const directoryRecoveries = [];
6249
+ let lastDirectoryFingerprint;
6193
6250
  let compensationMeta = {};
6251
+ const retainDirectory = (error) => {
6252
+ incomplete.push(`directory preservation: ${error.message}`);
6253
+ // No successful copy means no destructive cleanup. Even after a partial
6254
+ // compensation pass, retry with the ORIGINAL spawn receipt, not its report.
6255
+ for (const cap of resolvedCfg.capabilities) {
6256
+ if (cap.hooks?.retire || (hookRes.order?.includes(cap.id) && hookRes.meta?.[cap.id])) outstandingHooks.add(cap.id);
6257
+ }
6258
+ const note = quarantineInstanceHome({
6259
+ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
6260
+ repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {}, compensationMeta,
6261
+ launched: false, directoryPreservation: true, directoryHome: homeReal,
6262
+ recordRetirementBaseline: true,
6263
+ });
6264
+ return `${note}${directoryRecoveries.length ? `; prior work recovery: ${directoryRecoveries.join(", ")}` : ""}`;
6265
+ };
6266
+ const preserveDirectory = () => {
6267
+ if (work !== "directory") return;
6268
+ assertDirectoryRoots(home, homeReal);
6269
+ const path = join(home, "work");
6270
+ if (!readdirSync(path).length) return;
6271
+ const directoryFingerprint = fingerprintTree(path);
6272
+ if (lastDirectoryFingerprint === directoryFingerprint) return;
6273
+ const receipt = preserveRetirementWork({ home, work: path, directory: true, directoryFingerprint, classes: ["directory work bytes"] }, { work, repo: repoAbs }, instance);
6274
+ directoryRecoveries.push(receipt.path);
6275
+ lastDirectoryFingerprint = directoryFingerprint;
6276
+ };
6277
+ try { preserveDirectory(); }
6278
+ catch (e) { return retainDirectory(e); }
6194
6279
  try {
6195
6280
  const comp = runLifecycleHooks("retire", {
6196
6281
  home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
@@ -6244,6 +6329,8 @@ export function spawnInstance(root, agent, o = {}) {
6244
6329
  incomplete.push(`${cap.id}: its spawn hook reported state it created, but the capability declares no retire hook, so OATS cannot undo it`);
6245
6330
  outstandingHooks.add(cap.id);
6246
6331
  }
6332
+ try { preserveDirectory(); }
6333
+ catch (e) { return retainDirectory(e); }
6247
6334
  let note;
6248
6335
  if (outstandingHooks.size || outstandingGit.size) {
6249
6336
  // Preserve credentials and the original hook receipt until cleanup
@@ -6253,14 +6340,14 @@ export function spawnInstance(root, agent, o = {}) {
6253
6340
  failed,
6254
6341
  outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg,
6255
6342
  hookMeta: hookRes.meta || {}, compensationMeta, launched: false,
6256
- recordRetirementBaseline: true,
6343
+ directoryHome: homeReal, recordRetirementBaseline: true,
6257
6344
  });
6258
6345
  } else {
6259
6346
  try { rmSync(home, { recursive: true, force: true }); } catch (e2) { incomplete.push(`instance home ${home}: ${e2.message}`); }
6260
6347
  if (existsSync(home) && !incomplete.some((m) => m.startsWith("instance home"))) incomplete.push(`instance home ${home}: still present`);
6261
6348
  note = incomplete.length ? ` — rollback INCOMPLETE, clean up manually: ${incomplete.join("; ")}` : " — spawn rolled back";
6262
6349
  }
6263
- return note;
6350
+ return `${note}${directoryRecoveries.length ? `; directory work preserved at ${directoryRecoveries.join(", ")}` : ""}`;
6264
6351
  };
6265
6352
 
6266
6353
  try {
@@ -6269,6 +6356,9 @@ export function spawnInstance(root, agent, o = {}) {
6269
6356
  const code = requiredFailures.some((f) => f.contract === "environment") ? "E_HOOK_ENVIRONMENT_CONTRACT" : "E_REQUIRED_HOOK_FAILED";
6270
6357
  throw oatsError(code, `a capability this soul activates could not configure itself:\n${detail}\n\nThe instance would have started with an invalid or missing capability configuration`);
6271
6358
  }
6359
+ // Hooks have finished, but no TASK, launch recipe, successful scaffold, or
6360
+ // backend operation may be published until the owned roots are revalidated.
6361
+ if (work === "directory") assertDirectoryRoots(home, homeReal);
6272
6362
  {
6273
6363
  const owned = Object.keys(launchConfig?.env || {}).filter((n) => Object.hasOwn(hookRes.env, n));
6274
6364
  if (owned.length) {
@@ -6281,6 +6371,8 @@ export function spawnInstance(root, agent, o = {}) {
6281
6371
  ? `a dedicated git worktree of ${repoAbs} on branch "${branch}" — commit freely there`
6282
6372
  : work === "attached"
6283
6373
  ? `ATTACHED to another instance's work tree (${o.workDir}, branch ${branch}) — you share it with that instance; make your changes and commits focused, and never switch branches`
6374
+ : work === "directory"
6375
+ ? `an instance-owned execution directory — not a Git worktree or a link to ${repoAbs}; that path supplies configuration only`
6284
6376
  : work === "workspace"
6285
6377
  ? `the WHOLE WORKSPACE (${realpathSync(join(home, "work"))}) — every member repo is read-context; you coordinate, you do not edit member repos (see your work-mode briefing)`
6286
6378
  : `a symlink to the ${repoAbs} checkout — you share it; work on the currently checked-out branch (${branch}) and do not switch branches without being asked`;
@@ -6377,14 +6469,16 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
6377
6469
  const spawnWarnings = warnings;
6378
6470
 
6379
6471
  spawnTmux = meta.tmux;
6472
+ if (work === "directory") assertDirectoryRoots(home, homeReal);
6473
+ const executionCommand = launch ? nativeRecordCommand(cmdline, home, runtime) : null;
6380
6474
  if (launch && backend === "herdr") {
6381
6475
  windowMayExist = true;
6382
6476
  spawnHerdr = allocateHerdr(herdrBase, { home, instance });
6383
6477
  meta.sessionTarget = spawnHerdr;
6384
6478
  meta.launched = true;
6385
6479
  writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
6386
- writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, sessionTarget: spawnHerdr });
6387
- try { launchHerdr(spawnHerdr, `${launchEnvExports(recipe, process.env)}${cmdline}`); }
6480
+ writeRetirementBaseline(home, join(home, "work"), work, wm, resolvedCfg.capabilities, { launched: true, sessionTarget: spawnHerdr });
6481
+ try { launchHerdr(spawnHerdr, `${launchEnvExports(recipe, process.env)}${executionCommand}`); }
6388
6482
  catch (e) { throw oatsError("E_SPAWN_LAUNCH_FAILED", `Herdr could not run the launch command for ${instance} (${e.code === "ENOENT" ? "herdr unavailable" : "pane run failed"}); the command line is withheld from this message`); }
6389
6483
  } else if (launch) {
6390
6484
  if (!tmuxAlive(session)) {
@@ -6399,17 +6493,17 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
6399
6493
  // Commit the final child metadata and its independent byte authority before
6400
6494
  // the managed runtime can write. No child-home transition follows launch.
6401
6495
  writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
6402
- writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
6496
+ writeRetirementBaseline(home, join(home, "work"), work, wm, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
6403
6497
  // Wrap the command so the window drops into an interactive shell when the
6404
6498
  // agent exits (e.g. Ctrl-C) instead of tmux killing the window.
6405
- const windowCmd = `${cmdline}; exec "\${SHELL:-/bin/zsh}"`;
6499
+ const windowCmd = `${executionCommand}; exec "\${SHELL:-/bin/zsh}"`;
6406
6500
  windowMayExist = true;
6407
6501
  try { sh(`tmux new-window -t ${shq(session)} -n ${shq(instance)} -c ${shq(home)}${launchEnvTmuxFlags(recipe, process.env)} ${shq(windowCmd)}`); }
6408
6502
  catch (e) { throw oatsError("E_SPAWN_LAUNCH_FAILED", `tmux new-window failed for ${instance} (${e.code === "ENOENT" ? "tmux unavailable" : "the window command was refused"}); the command line and tmux's output are withheld from this message because they can carry reference values; run tmux list-windows on the session to inspect`); }
6409
6503
  } else {
6410
6504
  meta.launched = false;
6411
6505
  writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
6412
- writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: false, tmux: meta.tmux });
6506
+ writeRetirementBaseline(home, join(home, "work"), work, wm, resolvedCfg.capabilities, { launched: false, tmux: meta.tmux });
6413
6507
  }
6414
6508
 
6415
6509
  // parent relation: re-point the ANCHOR's recorded lineage so its parent is
@@ -6466,7 +6560,8 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
6466
6560
  : { instance: e.name, home };
6467
6561
  // A home retained by an incomplete rollback is NOT a live instance: it
6468
6562
  // is preserved state awaiting cleanup, and must read that way.
6469
- const quarantine = join(home, ".oats-rollback-incomplete.json");
6563
+ const fallback = directoryRollbackPath(home);
6564
+ const quarantine = existsSync(fallback) ? fallback : join(home, ".oats-rollback-incomplete.json");
6470
6565
  let rollbackIncomplete;
6471
6566
  if (existsSync(quarantine)) {
6472
6567
  try { rollbackIncomplete = JSON.parse(readFileSync(quarantine, "utf8")); }
@@ -6601,11 +6696,63 @@ export const QUARANTINE_CLEANUP_VERSION = 1;
6601
6696
  /** The rollback-owned Git steps a quarantine can still owe. */
6602
6697
  export const QUARANTINE_GIT_DEBT = ["worktree", "branch"];
6603
6698
 
6699
+ // A substituted/missing home cannot hold its own receipt. This sibling fallback
6700
+ // is independent of both home bytes and the recovery storage that may have failed.
6701
+ function directoryRollbackPath(home) {
6702
+ return join(dirname(home), `.oats-directory-rollback-${basename(home)}.json`);
6703
+ }
6704
+
6705
+ function assertDirectoryHome(home, canonicalHome = realPathOrNearest(home)) {
6706
+ let st;
6707
+ try { st = lstatSync(home); } catch { /* fail closed below */ }
6708
+ if (!st?.isDirectory() || st.isSymbolicLink() || realpathSync(home) !== canonicalHome) {
6709
+ throw oatsError("E_WORK_INSPECTION_FAILED", `directory instance home was removed or exchanged: ${home}; restore the owned home before retrying cleanup`);
6710
+ }
6711
+ }
6712
+
6713
+ function assertDirectoryRoots(home, canonicalHome) {
6714
+ assertDirectoryHome(home, canonicalHome);
6715
+ const work = join(home, "work");
6716
+ let st;
6717
+ try { st = lstatSync(work); } catch { /* fail closed below */ }
6718
+ if (!st?.isDirectory() || st.isSymbolicLink()) {
6719
+ throw oatsError("E_WORK_INSPECTION_FAILED", `directory work must remain an owned directory, not missing, a link or another filesystem type: ${work}; restore the owned work root before retrying cleanup`);
6720
+ }
6721
+ }
6722
+
6723
+ function directoryIdentity(path) {
6724
+ const st = lstatSync(path);
6725
+ return { dev: st.dev, ino: st.ino };
6726
+ }
6727
+
6728
+ // Read independently of mutable instance bytes, BEFORE realpath(home), hooks,
6729
+ // lock creation or backend observation. A metadata mode edit cannot disable it.
6730
+ function sessionDirectoryGuard(home) {
6731
+ let baseline;
6732
+ try { baseline = JSON.parse(readFileSync(retirementBaselinePath(home), "utf8")); }
6733
+ catch (e) { if (e.code === "ENOENT") return () => {}; throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "independent session receipt is unreadable"); }
6734
+ const expected = join(realPathOrNearest(dirname(home)), basename(home));
6735
+ const check = () => {
6736
+ if (baseline.directoryWork === true) {
6737
+ if (baseline.version !== RETIRE_BASELINE_VERSION || baseline.home !== expected) throw oatsError("E_WORK_INSPECTION_FAILED", "invalid independent directory home authority");
6738
+ assertDirectoryRoots(home, expected);
6739
+ for (const [name, path] of [["home", home], ["work", join(home, "work")]]) {
6740
+ const actual = directoryIdentity(path), recorded = baseline.directoryRoots?.[name];
6741
+ if (!recorded || actual.dev !== recorded.dev || actual.ino !== recorded.ino) throw oatsError("E_WORK_INSPECTION_FAILED", `directory ${name} was exchanged; restore the owned root before retrying start`);
6742
+ }
6743
+ }
6744
+ let meta;
6745
+ try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch { return; } // ordinary receipt validation reports this
6746
+ if ((meta.work === "directory") !== (baseline.directoryWork === true)) throw oatsError("E_WORK_INSPECTION_FAILED", "directory work mode disagrees with independent session authority");
6747
+ };
6748
+ check();
6749
+ return check;
6750
+ }
6751
+
6604
6752
  /** Retain the home and its cleanup receipt when spawn compensation or retirement
6605
6753
  * cannot finish. Keeping the original credentials makes cleanup retryable. */
6606
- function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, sessionTarget, recordRetirementBaseline = false, reason }) {
6607
- try {
6608
- writeFileSync(join(home, ".oats-rollback-incomplete.json"), JSON.stringify({
6754
+ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, sessionTarget, recordRetirementBaseline = false, reason, directoryPreservation = false, directoryHome = realPathOrNearest(home) }) {
6755
+ const marker = {
6609
6756
  // `reason` is optional and DEFAULTS to the spawn wording, so every existing
6610
6757
  // caller is byte-identical; only a caller that supplies one differs. The
6611
6758
  // quarantine shape is now reached from two events and a fixed "spawn"
@@ -6620,7 +6767,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
6620
6767
  version: QUARANTINE_CLEANUP_VERSION,
6621
6768
  repo: repoAbs, work, branch, launched, tmux,
6622
6769
  ...(sessionTarget ? { sessionTarget } : {}),
6623
- outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit] },
6770
+ outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}) },
6624
6771
  capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
6625
6772
  id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
6626
6773
  hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
@@ -6630,11 +6777,30 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
6630
6777
  capabilityMeta: hookMeta || {},
6631
6778
  },
6632
6779
  createdAt: new Date().toISOString(),
6633
- }, null, 2) + "\n");
6634
- } catch { /* the quarantine still stands without its marker */ }
6780
+ };
6781
+ try {
6782
+ const path = join(home, ".oats-rollback-incomplete.json");
6783
+ if (work === "directory") {
6784
+ assertDirectoryHome(home, directoryHome);
6785
+ writeJsonAtomic(path, marker, 0o600);
6786
+ } else writeFileSync(path, JSON.stringify(marker, null, 2) + "\n");
6787
+ } catch {
6788
+ if (work === "directory") {
6789
+ // Never write through a substituted home (even its metadata paths).
6790
+ // The original parent is outside the disposable home; no recovery copy is
6791
+ // needed to retain the frozen hook inputs and the cleanup obligations.
6792
+ try {
6793
+ const path = directoryRollbackPath(directoryHome);
6794
+ if (realpathSync(dirname(path)) !== dirname(path)) throw new Error("directory cleanup parent was redirected; refusing to write through it");
6795
+ writeJsonAtomic(path, marker, 0o600);
6796
+ incomplete.push(`cleanup descriptor retained at ${path}; restore the home before retrying`);
6797
+ } catch (fallbackError) { incomplete.push(`cleanup descriptor could not be stored: ${fallbackError.message}`); }
6798
+ }
6799
+ }
6635
6800
  if (recordRetirementBaseline) {
6636
6801
  try {
6637
- writeRetirementBaseline(home, join(home, "work"), work === "worktree", resolveWorkMode(repoAbs, work), resolvedCfg.capabilities || [], { launched: launched === true, tmux, sessionTarget });
6802
+ if (work === "directory") assertDirectoryRoots(home, directoryHome);
6803
+ writeRetirementBaseline(home, join(home, "work"), work, resolveWorkMode(repoAbs, work), resolvedCfg.capabilities || [], { launched: launched === true, tmux, sessionTarget });
6638
6804
  } catch (e) {
6639
6805
  incomplete.push(`independent retirement authority: ${e.message}`);
6640
6806
  }
@@ -6670,7 +6836,9 @@ function usableCleanupDescriptor(marker) {
6670
6836
  if (c.work === "worktree" && !nonEmptyString(c.branch)) return false;
6671
6837
  if (c.branch !== undefined && !nonEmptyString(c.branch)) return false;
6672
6838
  if (c.capabilityMeta !== undefined && !isPlainObject(c.capabilityMeta)) return false;
6673
- if (!Array.isArray(c.capabilityRuntime) || !c.capabilityRuntime.length) return false;
6839
+ const directoryDebt = c.outstanding?.directory === true && c.work === "directory";
6840
+ if (c.outstanding?.directory !== undefined && !directoryDebt) return false;
6841
+ if (!Array.isArray(c.capabilityRuntime) || (!c.capabilityRuntime.length && !directoryDebt)) return false;
6674
6842
  if (!c.capabilityRuntime.every((cap) => isPlainObject(cap) && nonEmptyString(cap.id))) return false;
6675
6843
  if (!isPlainObject(c.outstanding) || !Array.isArray(c.outstanding.hooks) || !Array.isArray(c.outstanding.git)) return false;
6676
6844
  if (!c.outstanding.hooks.every(nonEmptyString)) return false;
@@ -6679,10 +6847,10 @@ function usableCleanupDescriptor(marker) {
6679
6847
  // other work mode describes a quarantine that could not have happened.
6680
6848
  if (c.outstanding.git.length && c.work !== "worktree") return false;
6681
6849
  // The decisive invariant: a quarantine with NOTHING outstanding is a proof
6682
- // obligation of zero — the retry would run, prove nothing, and delete the home
6683
- // and its credential (reviewer-2baa631). The producer cannot emit it, so a
6684
- // marker claiming it is not one of ours.
6685
- if (!c.outstanding.hooks.length && !c.outstanding.git.length) return false;
6850
+ // obligation of zero. Directory preservation is also real debt: the retry's
6851
+ // independent-authority inspection and verified snapshots must succeed even
6852
+ // when no capability has a retire hook. It is never a Git/shared-work escape.
6853
+ if (!c.outstanding.hooks.length && !c.outstanding.git.length && !directoryDebt) return false;
6686
6854
  // The retry must be ABLE to rerun what it must prove: an outstanding hook whose
6687
6855
  // capability is not in the set could never run, so the quarantine would never
6688
6856
  // clear — and the home would be unremovable without --force.
@@ -6701,7 +6869,7 @@ function retirementStateRoot(home) {
6701
6869
  }
6702
6870
 
6703
6871
  function retirementKey(home) {
6704
- return createHash("sha256").update(realPathOrNearest(home)).digest("hex");
6872
+ return createHash("sha256").update(join(realPathOrNearest(dirname(home)), basename(home))).digest("hex");
6705
6873
  }
6706
6874
 
6707
6875
  function retirementBaselinePath(home) {
@@ -6785,11 +6953,14 @@ function retirementDisposableRoots(work, workMode, capabilities) {
6785
6953
  return roots;
6786
6954
  }
6787
6955
 
6788
- function writeRetirementBaseline(home, work, isWorktree, workMode, capabilities, runtime) {
6956
+ function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime) {
6957
+ if (mode === "directory") assertDirectoryRoots(home);
6958
+ const isWorktree = mode === "worktree";
6789
6959
  const status = isWorktree && existsSync(work) ? worktreeStatus(work) : "";
6790
6960
  const disposableReceipts = isWorktree ? retirementDisposableRoots(work, workMode, capabilities) : [];
6791
6961
  const baseline = {
6792
6962
  version: RETIRE_BASELINE_VERSION,
6963
+ ...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
6793
6964
  home: realPathOrNearest(home),
6794
6965
  homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]) }),
6795
6966
  disposableReceipts,
@@ -7172,7 +7343,7 @@ export function capturedProviders(meta, frozen) {
7172
7343
  return { id, contribution, binding, settings: contribution?.settings ?? binding?.settings ?? {} };
7173
7344
  });
7174
7345
  }
7175
- export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, contextDir, extraEnv = {} }) {
7346
+ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, contextDir, extraEnv = {}, assertRoots }) {
7176
7347
  const contributions = (frozen.hooks?.contributions || []).map((c) => ({ ...c }));
7177
7348
  const env = { ...(frozen.hooks?.env || {}) };
7178
7349
  const refreshed = [];
@@ -7194,7 +7365,7 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
7194
7365
  if (hooks.launch) withLaunchHook.push({ id: p.id, capability: p.id, manifest, layer: p.contribution?.layer ?? p.binding?.layer ?? manifest.layer ?? null, level: p.contribution?.level ?? p.binding?.level ?? null, settings: p.settings, hooks, trust, environment: [...(manifest.environment || [])], environmentNamespaces: [...(manifest.environmentNamespaces || [])], missingRequires: [] });
7195
7366
  }
7196
7367
  if (withLaunchHook.length) {
7197
- const res = runLifecycleHooks("launch", { home, instance: meta.instance, agentName: meta.agent, contextDir: ctx, rootDir: dirname(dirname(dirname(home))), resolved: { ...(resolvedCfg || {}), capabilities: withLaunchHook }, priorMeta: meta.capabilityMeta || {}, extraEnv: { OATS_RUNTIME: runtime, OATS_PREVIOUS_RUNTIME: frozen.runtime || "", ...extraEnv } });
7368
+ const res = runLifecycleHooks("launch", { assertRoots, home, instance: meta.instance, agentName: meta.agent, contextDir: ctx, rootDir: dirname(dirname(dirname(home))), resolved: { ...(resolvedCfg || {}), capabilities: withLaunchHook }, priorMeta: meta.capabilityMeta || {}, extraEnv: { OATS_RUNTIME: runtime, OATS_PREVIOUS_RUNTIME: frozen.runtime || "", ...extraEnv } });
7198
7369
  const failed = (res.failures || []).map((f) => `${f.capability}: ${f.message}`);
7199
7370
  if (failed.length) throw oatsError("E_LAUNCH_PREPARATION", `a capability could not prepare the ${runtime} launch:\n ${failed.join("\n ")}`);
7200
7371
  // Ownership holds across retained AND refreshed contributions, as the
@@ -7263,7 +7434,15 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
7263
7434
  export function restartInstanceSession(home, o = {}) { return startInstanceSession(home, { ...o, restart: true }); }
7264
7435
  export function startInstanceSession(home, o = {}) {
7265
7436
  if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session start needs an absolute instance home");
7437
+ // Check the sibling receipt BEFORE resolving a possibly substituted home.
7438
+ if (existsSync(directoryRollbackPath(home))) throw oatsError("E_INSTANCE_RETIRING", `${home} has retained directory cleanup; restore and retire it before starting anything there`);
7439
+ const checkRoots = sessionDirectoryGuard(home);
7266
7440
  const realHome = realPathOrNearest(home);
7441
+ let originalHomeIdentity;
7442
+ try { originalHomeIdentity = directoryIdentity(realHome); } catch { /* missing home reported below */ }
7443
+ const rawIo = o.io;
7444
+ const guardedExec = (...args) => { checkRoots(); return (rawIo?.exec || execFileSync)(...args); };
7445
+ o = { ...o, io: { ...rawIo, exec: guardedExec, kill: (...args) => { checkRoots(); return (rawIo?.kill || process.kill)(...args); } } };
7267
7446
  const metaPath = join(realHome, "instance.json");
7268
7447
  if (!existsSync(metaPath)) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `${realHome} is not an OATS instance home (no instance.json); nothing was started`);
7269
7448
  const lock = join(realHome, ".oats-start.lock");
@@ -7282,6 +7461,7 @@ export function startInstanceSession(home, o = {}) {
7282
7461
  // mutable metadata; both tmp+rename. A failure between them is what the
7283
7462
  // pending receipt exists for.
7284
7463
  const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop }, clearPending = true) => {
7464
+ checkRoots();
7285
7465
  const baselinePath = retirementBaselinePath(realHome);
7286
7466
  let baseline;
7287
7467
  try { baseline = JSON.parse(readFileSync(baselinePath, "utf8")); } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or unreadable for ${realHome}: ${e.message}`); }
@@ -7333,6 +7513,7 @@ export function startInstanceSession(home, o = {}) {
7333
7513
  if (!validReceipt) throw oatsError("E_SESSION_UNKNOWN", `an earlier start left an unreadable or invalid receipt at ${pendingPath}; inspect it before retrying; nothing was started`);
7334
7514
  const pbackend = pending.target.backend === "herdr" ? "herdr" : "tmux";
7335
7515
  let st;
7516
+ checkRoots();
7336
7517
  try { st = inspectSessionTarget(pending.target, o.io); }
7337
7518
  catch (e) {
7338
7519
  if (pbackend === "tmux" && lostTmuxServer(e)) st = { present: false, state: "stopped" };
@@ -7385,7 +7566,7 @@ export function startInstanceSession(home, o = {}) {
7385
7566
  const context = meta.repo && existsSync(meta.repo) ? resolve(meta.repo) : dirname(dirname(dirname(dirname(realHome))));
7386
7567
  const resolvedCfg = resolveOatsConfig(context, meta.agent);
7387
7568
  let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
7388
- const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { runtime: meta.runtime, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model, yolo: o.yolo }, resolvedCfg, env: o.env || process.env });
7569
+ const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { runtime: meta.runtime, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model, yolo: o.yolo }, resolvedCfg, env: o.env || process.env, assertRoots: checkRoots });
7389
7570
  launchPlan = { recipe: plan.recipe, command: plan.command, runtime: plan.runtime, model: plan.model, yolo: plan.yolo };
7390
7571
  command = launchPlan.command; model = launchPlan.model;
7391
7572
  } else if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
@@ -7401,6 +7582,7 @@ export function startInstanceSession(home, o = {}) {
7401
7582
  const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
7402
7583
  const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
7403
7584
  const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
7585
+ checkRoots(); // launch hooks/preparation have run; no backend has been observed
7404
7586
  const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo } : {};
7405
7587
  let target = receipt.target;
7406
7588
  let state = { present: false, state: "not-launched" };
@@ -7426,7 +7608,9 @@ export function startInstanceSession(home, o = {}) {
7426
7608
  }
7427
7609
  const startedAt = new Date().toISOString();
7428
7610
  const id = randomUUID();
7429
- const completedCommand = `${command}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
7611
+ checkRoots();
7612
+ const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.runtime || runtime);
7613
+ const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
7430
7614
  let reused = "new";
7431
7615
  if (backend === "herdr") {
7432
7616
  if (!target) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `this never-launched Herdr home has no saved server endpoint; no tmux fallback was started`);
@@ -7437,6 +7621,7 @@ export function startInstanceSession(home, o = {}) {
7437
7621
  catch (e) { throw oatsError("E_SESSION_UNKNOWN", `Herdr server on ${base.socket} is not reachable, so nothing was started: ${e.message}`); }
7438
7622
  target = allocateHerdr(base, { home: realHome, instance: meta.instance }, o.io);
7439
7623
  }
7624
+ checkRoots();
7440
7625
  writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
7441
7626
  try { launchHerdr(target, `${paneEnvExports}cd ${shq(realHome)} && ${completedCommand}; exit "$oats_start_status"`, o.io); }
7442
7627
  catch (e) { throw launchFailure("Herdr", e); }
@@ -7449,6 +7634,7 @@ export function startInstanceSession(home, o = {}) {
7449
7634
  // the agent's own pane: the command runs there, no other window touched.
7450
7635
  const inPlace = state.paneId && (state.present || state.state === "stopped");
7451
7636
  if (inPlace) {
7637
+ checkRoots();
7452
7638
  writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
7453
7639
  try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
7454
7640
  catch (e) { throw launchFailure("tmux", e); }
@@ -7456,14 +7642,20 @@ export function startInstanceSession(home, o = {}) {
7456
7642
  } else {
7457
7643
  const instancesRoot = dirname(realHome);
7458
7644
  const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
7645
+ checkRoots();
7459
7646
  if (!socket) {
7460
7647
  // Never launched (--no-launch): the default server, as spawn uses.
7461
- if (!tmuxAlive(session)) {
7462
- sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
7463
- shTry(`tmux set-option -t ${shq(session)} -g window-size latest`);
7464
- shTry(`tmux set-option -t ${shq(session)} -g aggressive-resize on`);
7648
+ const defaultTmux = args => guardedExec("tmux", args, { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }).trim();
7649
+ let alive = false;
7650
+ try { defaultTmux(["has-session", "-t", session]); alive = true; } catch (e) { checkRoots(); }
7651
+ if (!alive) {
7652
+ defaultTmux(["new-session", "-d", "-s", session, "-n", "hq", "-c", hq]);
7653
+ for (const option of [["window-size", "latest"], ["aggressive-resize", "on"]]) {
7654
+ try { defaultTmux(["set-option", "-t", session, "-g", ...option]); } catch (e) { checkRoots(); }
7655
+ }
7465
7656
  }
7466
- socket = tmuxSocket(session);
7657
+ socket = defaultTmux(["display-message", "-p", "-t", session, "#{socket_path}"]);
7658
+ if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "tmux did not report its socket");
7467
7659
  } else if (serverGone) {
7468
7660
  // The recorded server is gone (a reboot): the same socket path again.
7469
7661
  mkdirSync(dirname(socket), { recursive: true });
@@ -7479,6 +7671,7 @@ export function startInstanceSession(home, o = {}) {
7479
7671
  }
7480
7672
  if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
7481
7673
  target = { backend: "tmux", session, window, socket: resolve(socket) };
7674
+ checkRoots();
7482
7675
  writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
7483
7676
  try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
7484
7677
  catch (e) { throw launchFailure("tmux", e); }
@@ -7494,11 +7687,17 @@ export function startInstanceSession(home, o = {}) {
7494
7687
  throw oatsError("E_SESSION_START_INCOMPLETE", `${meta.instance} was started (${backend === "herdr" ? `Herdr pane ${target.paneId}` : `tmux ${target.session}:${target.window} on ${target.socket}`}) but its metadata could not be recorded: ${e.message}; the actual target is kept in ${pendingPath} and the next start adopts it instead of allocating another`);
7495
7688
  }
7496
7689
  } finally {
7497
- rmSync(lock, { recursive: true, force: true });
7690
+ // A hook may have replaced the home itself. Never follow that replacement
7691
+ // to remove a target's lock; keep the original retry state with its home.
7692
+ try {
7693
+ const st = lstatSync(realHome);
7694
+ if (st.isDirectory() && !st.isSymbolicLink() && st.dev === originalHomeIdentity?.dev && st.ino === originalHomeIdentity?.ino) rmSync(lock, { recursive: true, force: true });
7695
+ } catch { /* retain retry state when its authority is lost */ }
7498
7696
  }
7499
7697
  }
7500
7698
 
7501
- function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {}) {
7699
+ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directory = false } = {}) {
7700
+ if (directory) assertDirectoryRoots(home);
7502
7701
  const classes = [];
7503
7702
  let baseline;
7504
7703
  const path = retirementBaselinePath(home);
@@ -7513,7 +7712,18 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {})
7513
7712
  } else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]) })) {
7514
7713
  classes.push("changed instance-home bytes");
7515
7714
  }
7516
- const branchCommits = branchDeletion?.delete ? branchOnlyCommits(branchDeletion.repo, branchDeletion.branch) : undefined;
7715
+ // A mutable mode must not turn owned directory bytes into an excluded shared
7716
+ // tree (or authorize Git deletion). Require the independent spawn authority.
7717
+ if (directory !== (baselineValid && baseline.directoryWork === true)) {
7718
+ throw oatsError("E_WORK_INSPECTION_FAILED", "directory work mode disagrees with independent retirement authority");
7719
+ }
7720
+ let directoryFingerprint;
7721
+ if (directory) {
7722
+ directoryFingerprint = fingerprintTree(work);
7723
+ // Never stamp hook-created or authored execution bytes as disposable.
7724
+ if (readdirSync(work).length) classes.push("directory work bytes");
7725
+ }
7726
+ const branchCommits = !directory && branchDeletion?.delete ? branchOnlyCommits(branchDeletion.repo, branchDeletion.branch) : undefined;
7517
7727
  if (branchCommits?.length) classes.push("branch-only local commits");
7518
7728
  if (isWorktree && existsSync(work)) {
7519
7729
  const status = worktreeStatus(work);
@@ -7525,9 +7735,9 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {})
7525
7735
  }
7526
7736
  const stateFingerprint = createHash("sha256")
7527
7737
  .update(fingerprintTree(home, { excludeRoot: new Set(["work"]) }))
7528
- .update("\0").update(isWorktree && existsSync(work) ? worktreeStatus(work) : "")
7738
+ .update("\0").update(directory ? (directoryFingerprint || "missing") : isWorktree && existsSync(work) ? worktreeStatus(work) : "")
7529
7739
  .digest("hex");
7530
- return { classes: [...new Set(classes)], home, work, stateFingerprint, branchExists: branchCommits !== null, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
7740
+ return { classes: [...new Set(classes)], home, work, directory, directoryFingerprint, stateFingerprint, branchExists: branchCommits !== null, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
7531
7741
  }
7532
7742
 
7533
7743
  function copyRecoveryTree(src, dest, { excludeRoot = new Set() } = {}) {
@@ -7632,6 +7842,9 @@ function materializeNestedRepositories(sourceWork, recoveredRepo) {
7632
7842
  function preserveRetirementWork(observation, meta, instance) {
7633
7843
  const recoveryRoot = join(retirementStateRoot(observation.home), "recovery");
7634
7844
  mkdirSync(recoveryRoot, { recursive: true });
7845
+ if (observation.directory && realpathSync(recoveryRoot) !== join(realpathSync(dirname(observation.home)), ".oats-retirement", "recovery")) {
7846
+ throw oatsError("E_WORK_PRESERVATION_FAILED", "directory recovery storage was redirected; retain the source home rather than copying into an unowned or disposable location");
7847
+ }
7635
7848
  const staging = mkdtempSync(join(recoveryRoot, `.${instance}-`));
7636
7849
  const recovery = join(recoveryRoot, basename(staging).slice(1));
7637
7850
  try {
@@ -7674,6 +7887,13 @@ function preserveRetirementWork(observation, meta, instance) {
7674
7887
  const sourceHead = execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${meta.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
7675
7888
  if (recoveredHead !== sourceHead) throw new Error("recovery clone does not retain the instance branch tip");
7676
7889
  }
7890
+ if (observation.directory && observation.directoryFingerprint) {
7891
+ const recoveredWork = join(staging, "work");
7892
+ copyTreeSafe(observation.work, recoveredWork);
7893
+ if (fingerprintTree(observation.work) !== observation.directoryFingerprint || fingerprintTree(recoveredWork) !== observation.directoryFingerprint) {
7894
+ throw new Error("directory recovery verification disagreed with the inspected source");
7895
+ }
7896
+ }
7677
7897
  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;
7678
7898
  writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}) }, null, 2) + "\n", { mode: 0o600 });
7679
7899
  mkdirSync(dirname(recovery), { recursive: true });
@@ -7845,7 +8065,8 @@ export function retireInstance(root, name, o = {}) {
7845
8065
  // not finish) has no instance.json — it never got that far. Its marker carries
7846
8066
  // the cleanup descriptor in the same shape, so retire can rerun compensation
7847
8067
  // instead of silently skipping every hook and deleting the credentials.
7848
- const quarantinePath = join(found.home, ".oats-rollback-incomplete.json");
8068
+ const directoryFallbackPath = directoryRollbackPath(found.home);
8069
+ const quarantinePath = existsSync(directoryFallbackPath) ? directoryFallbackPath : join(found.home, ".oats-rollback-incomplete.json");
7849
8070
  let quarantine;
7850
8071
  let markerUnusable = false;
7851
8072
  // A marker means the home is quarantined, WHETHER OR NOT instance.json exists:
@@ -7892,8 +8113,9 @@ export function retireInstance(root, name, o = {}) {
7892
8113
  }
7893
8114
 
7894
8115
  const workPath = join(found.home, "work");
7895
- const isWorktree = meta.work === "worktree" ||
7896
- (existsSync(workPath) && !lstatSync(workPath).isSymbolicLink());
8116
+ const directory = meta.work === "directory";
8117
+ const isWorktree = !directory && (meta.work === "worktree" ||
8118
+ (existsSync(workPath) && !lstatSync(workPath).isSymbolicLink()));
7897
8119
  // A live runtime cannot establish a stable final work inspection of itself,
7898
8120
  // so self-retire never inspects, runs hooks, or removes anything here. It
7899
8121
  // persists the intent and hands the whole retirement to a detached process
@@ -7905,7 +8127,7 @@ export function retireInstance(root, name, o = {}) {
7905
8127
  // First inspection is non-destructive. Only after it succeeds may OATS quiesce
7906
8128
  // the managed runtime; recovery copying never races a live managed Pi.
7907
8129
  const branchDeletion = { delete: !!(o.deleteBranch || quarantine), repo: meta.repo, branch: meta.branch };
7908
- const initialObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion });
8130
+ const initialObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion, directory });
7909
8131
  // Runtime identity is destructive authority. The mutable child metadata may
7910
8132
  // describe it for humans, but only the independent baseline can authorize the
7911
8133
  // endpoint that proves quiescence.
@@ -7946,7 +8168,7 @@ export function retireInstance(root, name, o = {}) {
7946
8168
  if (!/no server running|failed to connect|can't find session|no sessions/i.test(detail)) throw oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that ${runtimeSession}:${runtimeWindow} stopped on ${runtimeSocket}: ${detail || "tmux inspection failed"}`);
7947
8169
  }
7948
8170
  }
7949
- const stableObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion });
8171
+ const stableObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion, directory });
7950
8172
  const workRecoveries = [];
7951
8173
  if (stableObservation.classes.length) workRecoveries.push(preserveRetirementWork(stableObservation, meta, name));
7952
8174
  let workRecovery = workRecoveries.at(-1);
@@ -7993,7 +8215,7 @@ export function retireInstance(root, name, o = {}) {
7993
8215
 
7994
8216
  // Hooks are allowed to mutate the inspected tree, so inspect again after
7995
8217
  // them and preserve a separately verified post-hook snapshot when needed.
7996
- const finalObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion });
8218
+ const finalObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion, directory });
7997
8219
  if (finalObservation.classes.length && finalObservation.stateFingerprint !== stableObservation.stateFingerprint) {
7998
8220
  workRecoveries.push(preserveRetirementWork(finalObservation, meta, name));
7999
8221
  workRecovery = workRecoveries.at(-1);
@@ -8164,6 +8386,7 @@ export function retireInstance(root, name, o = {}) {
8164
8386
  const forced = !!(stillIncomplete && o.force);
8165
8387
  if (!o.keepDir && (!stillIncomplete || forced)) {
8166
8388
  rmSync(found.home, { recursive: true, force: true });
8389
+ rmSync(directoryFallbackPath, { force: true });
8167
8390
  // The owed retirement is paid: clear the pending marker, and the failed
8168
8391
  // outcome an earlier deferred attempt may have left beside the home; a
8169
8392
  // deferred completion writes its own outcome after this returns.