@awebai/oats 0.22.2 → 0.22.3

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
@@ -671,7 +671,7 @@ export function resolveCapabilities(contextDir, soulName) {
671
671
  }
672
672
  }
673
673
  const ranked = [...list].sort((a, b) => a.specificity - b.specificity || b.scope - a.scope || a.target.localeCompare(b.target));
674
- const settings = {};
674
+ const settings = Object.create(null); // manifest-declared names such as "constructor" must read as unset
675
675
  // Setting names come from parsed config, so the RANK table — a key→info
676
676
  // lookup, unlike `settings` itself, which is data handed to consumers — is
677
677
  // null-prototype: an inherited name must not read as an already-taken rank.
@@ -685,12 +685,28 @@ export function resolveCapabilities(contextDir, soulName) {
685
685
  settings[key] = value; settingRank[key] = rank;
686
686
  }
687
687
  }
688
+ // Declared defaults (manifest `settings: { <name>: { default } }`) are part
689
+ // of the effective settings: a requirement conditional on a setting must
690
+ // see the default for a deployment that never set it, or the default path
691
+ // silently loses its requirements (aweb review of oats.aweb 1.10.0).
692
+ const declaredSettings = manifest.settings && typeof manifest.settings === "object" && !Array.isArray(manifest.settings) ? manifest.settings : {};
693
+ for (const [key, decl] of Object.entries(declaredSettings)) {
694
+ if (!decl || typeof decl !== "object") continue;
695
+ if (settings[key] === undefined && decl.default !== undefined) settings[key] = decl.default;
696
+ }
688
697
  const strongest = [...list].sort((a, b) => b.specificity - a.specificity || a.scope - b.scope || a.target.localeCompare(b.target));
689
698
  const top = strongest[0];
690
699
  const tied = strongest.filter((c) => c.specificity === top.specificity && c.scope === top.scope);
691
700
  const enabledValues = new Set(tied.map((c) => c.binding.enabled === undefined ? true : !!c.binding.enabled));
692
701
  if (enabledValues.size > 1) throw new Error(`ambiguous enabled/excluded bindings for ${id} at equal specificity (${tied.map((c) => c.target).join(", ")})`);
693
702
  if (![...enabledValues][0]) continue;
703
+ // A declared value set is enforced for an ACTIVE capability: a misspelling
704
+ // must not switch a conditional requirement off by matching nothing.
705
+ for (const [key, decl] of Object.entries(declaredSettings)) {
706
+ if (decl && typeof decl === "object" && Array.isArray(decl.values) && settings[key] !== undefined && !decl.values.some((v) => String(v) === String(settings[key]))) {
707
+ throw new Error(`capability setting ${id}.${key} is ${JSON.stringify(settings[key])}, not one of ${decl.values.map((v) => JSON.stringify(v)).join(", ")}`);
708
+ }
709
+ }
694
710
  const compatibility = capabilityCompatibility(manifest);
695
711
  if (!compatibility.compatible) throw new Error(`capability "${id}" requires OATS ${compatibility.range}; running ${compatibility.version}`);
696
712
  const trust = capabilityTrust(manifest, contextDir);
@@ -703,7 +719,7 @@ export function resolveCapabilities(contextDir, soulName) {
703
719
  active.push({
704
720
  id, capability: id, manifest, layer: manifest.layer, command: manifest.command,
705
721
  level: top.level, origin: manifest._origin, provenance: list.map((c) => `${c.target} @ ${c.level}`),
706
- settings, skills: capabilitySkillDirs(id, contextDir), inject,
722
+ settings: { ...settings }, skills: capabilitySkillDirs(id, contextDir), inject,
707
723
  // What the manifest PROMISES, so preflight can tell "declared nothing"
708
724
  // from "declared and missing" — the two are indistinguishable in the
709
725
  // resolved lists above.
@@ -717,6 +733,7 @@ export function resolveCapabilities(contextDir, soulName) {
717
733
  // what required:true claims to prevent (aggregate review at 798b156).
718
734
  requiredHooks: manifestRequiredHooks(manifest),
719
735
  environment: trust.trusted ? [...(manifest.environment || [])] : [],
736
+ environmentNamespaces: trust.trusted ? [...(manifest.environmentNamespaces || [])] : [],
720
737
  missingRequires: capabilityMissingRequires(id, contextDir), compatibility, trust, executable,
721
738
  retirement: trust.trusted ? manifest.retirement : undefined,
722
739
  _scope: top.scope,
@@ -889,6 +906,18 @@ function validateCapabilityManifest(m, mf) {
889
906
  const vendor = id.match(/^([a-z][a-z0-9]*)\./)?.[1];
890
907
  if (!vendor) throw new Error(`capability ${id} must use a lowercase dotted vendor ID to declare launch environment`);
891
908
  const prefix = `${vendor.toUpperCase()}_`;
909
+ // A capability may declare additional namespaces it speaks for, e.g.
910
+ // the official oats.aweb integration setting the aweb extensions'
911
+ // AWEB_ variables. Declared, never implied: the names are disclosed at
912
+ // trust time with the rest of the launch environment, and the reserved
913
+ // core and bootstrap namespaces can never be claimed this way.
914
+ const extraNamespaces = m.environmentNamespaces === undefined ? [] : m.environmentNamespaces;
915
+ if (!Array.isArray(extraNamespaces) || extraNamespaces.some((ns) => typeof ns !== "string")) throw new Error(`capability ${id} manifest environmentNamespaces must be an array of prefixes`);
916
+ for (const ns of extraNamespaces) {
917
+ if (!/^[A-Z][A-Z0-9]*_$/.test(ns)) throw new Error(`capability ${id} manifest environmentNamespaces entry ${JSON.stringify(ns)} must be an uppercase prefix ending in an underscore`);
918
+ if (ns === "OATS_" || ns === "PI_AGENT_" || PROCESS_BOOTSTRAP_PREFIXES.some((reserved) => ns.startsWith(reserved) || reserved.startsWith(ns))) throw new Error(`capability ${id} manifest environmentNamespaces entry ${ns} is a reserved namespace`);
919
+ }
920
+ const allowed = [prefix, ...extraNamespaces];
892
921
  for (const name of m.environment) {
893
922
  if (!PORTABLE_ENV_NAME_RE.test(name)) throw new Error(`capability ${id} manifest environment name ${JSON.stringify(name)} is invalid`);
894
923
  if (CORE_LAUNCH_ENV.has(name) || name.startsWith("OATS_") || name.startsWith("PI_AGENT_")) {
@@ -897,7 +926,7 @@ function validateCapabilityManifest(m, mf) {
897
926
  if (PROCESS_BOOTSTRAP_ENV.has(name) || PROCESS_BOOTSTRAP_PREFIXES.some((reserved) => name.startsWith(reserved))) {
898
927
  throw new Error(`capability ${id} manifest environment name ${name} collides with a reserved process bootstrap variable`);
899
928
  }
900
- if (!name.startsWith(prefix)) throw new Error(`capability ${id} manifest environment name ${name} is outside its ${prefix} namespace`);
929
+ if (!allowed.some((ns) => name.startsWith(ns))) throw new Error(`capability ${id} manifest environment name ${name} is outside its ${allowed.join(", ")} namespace${allowed.length > 1 ? "s" : ""} (declare another in environmentNamespaces)`);
901
930
  }
902
931
  }
903
932
  }
@@ -1309,6 +1338,7 @@ export function capabilityTrust(a, b) {
1309
1338
  commands: Object.keys(manifest?.commands || {}),
1310
1339
  hooks: Object.keys(manifest?.hooks || {}),
1311
1340
  environment: [...(manifest?.environment || [])],
1341
+ ...(manifest?.environmentNamespaces?.length ? { environmentNamespaces: [...manifest.environmentNamespaces] } : {}),
1312
1342
  };
1313
1343
  return { ...t, package: t.package || manifest?._package, executableSurface: surface };
1314
1344
  }
@@ -2591,6 +2621,7 @@ function executableSurfaceOf(manifest) {
2591
2621
  commands: Object.keys(manifest?.commands || {}),
2592
2622
  hooks: Object.keys(manifest?.hooks || {}),
2593
2623
  environment: [...(manifest?.environment || [])],
2624
+ ...(manifest?.environmentNamespaces?.length ? { environmentNamespaces: [...manifest.environmentNamespaces] } : {}),
2594
2625
  };
2595
2626
  }
2596
2627
  function hasExecutableSurface(manifest) {
@@ -4254,6 +4285,27 @@ export function packageSpecIdentity(spec) {
4254
4285
  /** What the runtime reports about a required package: is it there, where did it
4255
4286
  * land, and does the user's entry filter its resources? A settings row alone is
4256
4287
  * NOT proof the capability's extension will load (reviewer-8518c49). */
4288
+ /** The installed version of a runtime package: neither pi's nor Claude's
4289
+ * listing reports one, so it is read from the package.json under the
4290
+ * install directory the listing names; undefined when there is none. */
4291
+ export function installedRuntimePackageVersion(runtime, spec, status) {
4292
+ const dir = status?.dir;
4293
+ if (dir && existsSync(join(dir, "package.json"))) {
4294
+ try {
4295
+ const v = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
4296
+ if (typeof v === "string" && /^\d+\.\d+\.\d+/.test(v)) return v.match(/^\d+\.\d+\.\d+/)[0];
4297
+ } catch { /* unreadable manifest: unknowable */ }
4298
+ }
4299
+ return undefined;
4300
+ }
4301
+
4302
+ export function compareVersionTriples(a, b) {
4303
+ const pa = String(a).split(".").map((x) => Number.parseInt(x, 10) || 0);
4304
+ const pb = String(b).split(".").map((x) => Number.parseInt(x, 10) || 0);
4305
+ for (let i = 0; i < 3; i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d; }
4306
+ return 0;
4307
+ }
4308
+
4257
4309
  export function runtimePackageStatus(runtime, spec, env = process.env, opts = {}) {
4258
4310
  const mgr = RUNTIME_PACKAGE_MANAGERS[runtime];
4259
4311
  if (!mgr) return { installed: false };
@@ -4342,6 +4394,12 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
4342
4394
  throw new HookEnvironmentContractError(`${capabilityID} hook env requires a dotted lowercase alphanumeric vendor component; hyphen, @, and / forms cannot claim an environment namespace`);
4343
4395
  }
4344
4396
  const prefix = `${vendorComponent.toUpperCase()}_`;
4397
+ // The runtime half of the same contract the manifest validator enforces:
4398
+ // a hook may set names under its vendor prefix or under a namespace its
4399
+ // trusted manifest declared (environmentNamespaces), and only names the
4400
+ // manifest listed. Manifest and runtime must permit exactly the same set.
4401
+ const declared = declarations.get(capabilityID) || { names: new Set(), namespaces: [] };
4402
+ const allowed = [prefix, ...(declared.namespaces || [])];
4345
4403
  const accepted = {};
4346
4404
  for (const name of Object.keys(value).sort()) {
4347
4405
  const envValue = value[name];
@@ -4354,10 +4412,10 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
4354
4412
  if (PROCESS_BOOTSTRAP_ENV.has(name) || PROCESS_BOOTSTRAP_PREFIXES.some((reserved) => name.startsWith(reserved))) {
4355
4413
  throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} collides with a reserved process bootstrap variable`);
4356
4414
  }
4357
- if (!name.startsWith(prefix)) {
4358
- throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} is outside its ${prefix} namespace`);
4415
+ if (!allowed.some((ns) => name.startsWith(ns))) {
4416
+ throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} is outside its ${allowed.join(", ")} namespace${allowed.length > 1 ? "s" : ""}`);
4359
4417
  }
4360
- if (!(declarations.get(capabilityID) || new Set()).has(name)) {
4418
+ if (!declared.names.has(name)) {
4361
4419
  throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} is not declared in its trusted manifest environment`);
4362
4420
  }
4363
4421
  if (typeof envValue !== "string") {
@@ -4382,7 +4440,7 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
4382
4440
  export function runLifecycleHooks(event, { home, instance, agentName, soulDir, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {} }) {
4383
4441
  const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [] };
4384
4442
  const envOwners = new Map();
4385
- const envDeclarations = new Map((resolved.capabilities || []).map((cap) => [cap.id, new Set(cap.environment || [])]));
4443
+ const envDeclarations = new Map((resolved.capabilities || []).map((cap) => [cap.id, { names: new Set(cap.environment || []), namespaces: [...(cap.environmentNamespaces || [])] }]));
4386
4444
  const caps = [...(resolved.capabilities || [])];
4387
4445
  if (event === "retire") caps.reverse();
4388
4446
  for (const cap of caps) {
@@ -4999,6 +5057,11 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
4999
5057
  for (const cap of resolved.capabilities || []) {
5000
5058
  for (const raw of cap.manifest?.requires || []) {
5001
5059
  if (!raw || typeof raw !== "object" || raw.runtime !== runtime) continue;
5060
+ // A requirement may be conditional on the capability's effective
5061
+ // settings (`when: { delivery: "channel" }`): rows whose condition does
5062
+ // not hold are not requirements of this spawn at all.
5063
+ if (raw.when !== undefined && (!raw.when || typeof raw.when !== "object" || Array.isArray(raw.when))) { problems.push(`${cap.id}: a requirement's \`when\` must be an object of setting names to values`); continue; }
5064
+ if (raw.when && !Object.entries(raw.when).every(([k, v]) => String(cap.settings?.[k] ?? "") === String(v))) continue;
5002
5065
  const spec = raw.package;
5003
5066
  if (!safeRuntimePackageSpec(spec, runtime)) { problems.push(`${cap.id}: ${runtime} package spec is not a plain source token (${JSON.stringify(spec)})`); continue; }
5004
5067
  if (raw.marketplace !== undefined && !safeRuntimeSourceRef(raw.marketplace)) { problems.push(`${cap.id}: marketplace is not a plain source reference (${JSON.stringify(raw.marketplace)})`); continue; }
@@ -5007,7 +5070,10 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
5007
5070
  const stepList = mgr?.steps ? mgr.steps(spec, raw, probeOpts) : [mgr?.argv(spec, raw, probeOpts) || []];
5008
5071
  const direct = stepList.filter((a) => a.length).map((a) => a.join(" ")).join(" && ");
5009
5072
  const remedy = `run \`oats install --accept-requirement ${runtime}:${runtimePackageIdentity(runtime, spec)} --dir ${contextDir}\`${direct ? ` (or \`${direct}\` directly)` : ""}`;
5010
- if (!status.installed) { problems.push(`${cap.id} requires the ${runtime} package ${spec}, which is not installed — ${remedy}`); continue; }
5073
+ // `ifInstalled: true`: the row constrains a package that may be absent
5074
+ // (an ambient extension must honour a contract IF it is there); absence
5075
+ // satisfies it. Without the flag, absence fails as before.
5076
+ if (!status.installed) { if (raw.ifInstalled === true) continue; problems.push(`${cap.id} requires the ${runtime} package ${spec}, which is not installed — ${remedy}`); continue; }
5011
5077
  // A settings row is not proof the extension loads. Both of these leave the
5012
5078
  // capability silently absent, which is the loss this gate exists to stop.
5013
5079
  if (status.unverified) { problems.push(`${cap.id} requires the ${runtime} package ${spec}: it is configured, but OATS could not verify it is installed (${status.unverified}) — a config entry is not an installation; ${remedy}`); continue; }
@@ -5019,6 +5085,16 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
5019
5085
  }
5020
5086
  if (status.disabled) { problems.push(`${cap.id} requires the ${runtime} package ${spec}, which is installed but DISABLED, so it will not load — enable it (\`${probeOpts.bin || runtime} plugin enable ${spec}\` for Claude), or drop the capability for this soul`); continue; }
5021
5087
  if (status.extensionsDisabled) { problems.push(`${cap.id} requires the ${runtime} package ${spec}, but your ${runtime} settings entry sets "extensions": [], which loads none of them — remove that filter, or drop the capability for this soul`); continue; }
5088
+ // A floor on the installed version (`minVersion`), checked once presence
5089
+ // and loadability are settled: a package whose manifest states an older
5090
+ // version, or none, fails closed with the same remedy, since an old
5091
+ // extension that ignores a newer contract (AWEB_DELIVERY) is exactly
5092
+ // what the floor guards.
5093
+ if (raw.minVersion) {
5094
+ const have = installedRuntimePackageVersion(runtime, spec, status);
5095
+ if (!have) { problems.push(`${cap.id} requires the ${runtime} package ${spec} at ${raw.minVersion} or later, but its installed version cannot be established (no package manifest under its install directory) — ${remedy}`); continue; }
5096
+ if (compareVersionTriples(have, raw.minVersion) < 0) { problems.push(`${cap.id} requires the ${runtime} package ${spec} at ${raw.minVersion} or later; ${have} is installed — ${remedy}`); continue; }
5097
+ }
5022
5098
  if (status.extensionsFilter?.length) {
5023
5099
  // Unverifiable, not merely auditable: proving the filter selects this
5024
5100
  // capability's extension means implementing pi's glob semantics, and
@@ -5803,6 +5879,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5803
5879
  settings: cap.settings, provenance: cap.provenance, skills: cap.skills || [],
5804
5880
  hooks: Object.keys(cap.hooks || {}), trusted: !!cap.trust?.trusted,
5805
5881
  ...(cap.environment?.length ? { environment: [...cap.environment] } : {}),
5882
+ ...(cap.environmentNamespaces?.length ? { environmentNamespaces: [...cap.environmentNamespaces] } : {}),
5806
5883
  })),
5807
5884
  skills: [...chosen].sort(([a], [b]) => a.localeCompare(b)).map(([name, v]) => ({ name, source: v.source })),
5808
5885
  instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
@@ -5845,7 +5922,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5845
5922
  },
5846
5923
  capabilityRuntime: resolvedCfg.capabilities.map((cap) => ({
5847
5924
  id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
5848
- hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment,
5925
+ hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
5849
5926
  missingRequires: cap.missingRequires, trust: cap.trust,
5850
5927
  executable: cap.executable,
5851
5928
  })),
@@ -6099,7 +6176,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
6099
6176
  outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit] },
6100
6177
  capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
6101
6178
  id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
6102
- hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment,
6179
+ hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
6103
6180
  missingRequires: cap.missingRequires,
6104
6181
  trust: cap.trust, executable: cap.executable,
6105
6182
  })),
@@ -6414,17 +6491,44 @@ const RECOVERABLE_GIT_ADMIN = [
6414
6491
  "BISECT_LOG", "BISECT_START", "BISECT_NAMES", "rebase-apply", "rebase-merge", "sequencer",
6415
6492
  ];
6416
6493
 
6494
+ /** Object ids among `oids` that `repo` does not have, from one
6495
+ * `cat-file --batch-check` process. */
6496
+ export function missingGitObjects(repo, oids) {
6497
+ if (!oids.length) return [];
6498
+ const out = execFileSync("git", ["-C", repo, "cat-file", "--batch-check=%(objectname) %(objecttype)"], { input: oids.join("\n") + "\n", encoding: "utf8", maxBuffer: GIT_MAX_BUFFER });
6499
+ const missing = [];
6500
+ for (const line of out.split("\n")) {
6501
+ if (!line.endsWith(" missing")) continue;
6502
+ missing.push(line.slice(0, line.indexOf(" ")));
6503
+ }
6504
+ return missing;
6505
+ }
6506
+
6417
6507
  function restoreStandaloneGitState(sourceWork, recoveredRepo) {
6418
6508
  const sourceGit = execFileSync("git", ["-C", sourceWork, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6419
6509
  const recoveredGit = join(recoveredRepo, ".git");
6420
6510
  const indexRows = execFileSync("git", ["-C", sourceWork, "ls-files", "--stage", "-z"], { maxBuffer: GIT_MAX_BUFFER });
6511
+ // The staged blobs the recovered index will point at. The clone already
6512
+ // holds every blob reachable from a commit; only content that is staged but
6513
+ // never committed is missing, so ask once which objects the recovered
6514
+ // repository lacks (one process for the whole index) and copy only those.
6515
+ // Copying every row cost two Git processes per index entry: a clean 9,500
6516
+ // file tree took ~19,000 launches and looked like a hang. Gitlink rows
6517
+ // (mode 160000) name commits of nested repositories, which
6518
+ // materializeNestedRepositories restores; they are never blobs here.
6519
+ const staged = new Set();
6421
6520
  for (const row of indexRows.toString("utf8").split("\0").filter(Boolean)) {
6422
- const match = row.match(/^\d+ ([0-9a-f]+) \d+\t/);
6423
- if (!match) continue;
6424
- const blob = execFileSync("git", ["-C", sourceWork, "cat-file", "blob", match[1]], { maxBuffer: GIT_MAX_BUFFER });
6521
+ const match = row.match(/^(\d+) ([0-9a-f]+) \d+\t/);
6522
+ if (!match || match[1] === "160000") continue;
6523
+ staged.add(match[2]);
6524
+ }
6525
+ for (const oid of missingGitObjects(recoveredRepo, [...staged])) {
6526
+ const blob = execFileSync("git", ["-C", sourceWork, "cat-file", "blob", oid], { maxBuffer: GIT_MAX_BUFFER });
6425
6527
  const restored = execFileSync("git", ["-C", recoveredRepo, "hash-object", "-w", "--stdin"], { input: blob, encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6426
- if (restored !== match[1]) throw new Error(`recovered Git object ${restored} did not match source ${match[1]}`);
6528
+ if (restored !== oid) throw new Error(`recovered Git object ${restored} did not match source ${oid}`);
6427
6529
  }
6530
+ const stillMissing = missingGitObjects(recoveredRepo, [...staged]);
6531
+ if (stillMissing.length) throw new Error(`recovered repository lacks ${stillMissing.length} staged object(s) after restore: ${stillMissing.slice(0, 3).join(", ")}`);
6428
6532
  copyFileSync(join(sourceGit, "index"), join(recoveredGit, "index"));
6429
6533
  for (const name of RECOVERABLE_GIT_ADMIN) {
6430
6534
  const source = join(sourceGit, name);
@@ -6479,12 +6583,22 @@ function preserveRetirementWork(observation, meta, instance) {
6479
6583
  const staging = mkdtempSync(join(recoveryRoot, `.${instance}-`));
6480
6584
  const recovery = join(recoveryRoot, basename(staging).slice(1));
6481
6585
  try {
6586
+ // A home-only change (notes, runtime files, credentials) needs a home
6587
+ // snapshot, not another copy of an otherwise disposable clean worktree.
6588
+ // In-progress Git operations retain the full standalone recovery even
6589
+ // when porcelain status has no changed paths.
6590
+ let homeOnly = observation.classes.length === 1 && observation.classes[0] === "changed instance-home bytes"
6591
+ && meta.work === "worktree" && existsSync(observation.work);
6592
+ if (homeOnly) {
6593
+ const gitDir = execFileSync("git", ["-C", observation.work, "rev-parse", "--absolute-git-dir"], { encoding: "utf8", maxBuffer: GIT_MAX_BUFFER }).trim();
6594
+ homeOnly = !RECOVERABLE_GIT_ADMIN.some((name) => existsSync(join(gitDir, name)));
6595
+ }
6482
6596
  const recoveredHome = join(staging, "home");
6483
6597
  copyRecoveryTree(observation.home, recoveredHome, { excludeRoot: new Set(["work"]) });
6484
6598
  if (fingerprintTree(observation.home, { excludeRoot: new Set(["work"]) }) !== fingerprintTree(recoveredHome)) {
6485
6599
  throw new Error("home recovery verification disagreed with the source");
6486
6600
  }
6487
- if (meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
6601
+ if (!homeOnly && meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
6488
6602
  const recoveredRepo = join(staging, "repo");
6489
6603
  execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", meta.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
6490
6604
  const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
@@ -6508,10 +6622,11 @@ function preserveRetirementWork(observation, meta, instance) {
6508
6622
  const sourceHead = execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${meta.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6509
6623
  if (recoveredHead !== sourceHead) throw new Error("recovery clone does not retain the instance branch tip");
6510
6624
  }
6511
- writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString() }, null, 2) + "\n", { mode: 0o600 });
6625
+ 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;
6626
+ 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 });
6512
6627
  mkdirSync(dirname(recovery), { recursive: true });
6513
6628
  renameSync(staging, recovery);
6514
- return { path: recovery, classes: observation.classes };
6629
+ return { path: recovery, classes: observation.classes, ...(repoCopy ? { repoCopy } : {}) };
6515
6630
  } catch (e) {
6516
6631
  rmSync(staging, { recursive: true, force: true });
6517
6632
  throw oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${observation.home}; recovery could not be verified: ${e.message}`);
@@ -6562,12 +6677,12 @@ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
6562
6677
  const intent = {
6563
6678
  instance: name, agent: found.agent.name, root: resolve(root),
6564
6679
  requestedAt: new Date().toISOString(), requestedByPid: process.pid, delaySec,
6565
- options: { deleteBranch: !!o.deleteBranch, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
6680
+ options: { home: found.home, deleteBranch: !!o.deleteBranch, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
6566
6681
  };
6567
6682
  const env = { ...process.env, OATS_RETIRE_INTENT: JSON.stringify(intent) };
6568
6683
  for (const k of CORE_LAUNCH_ENV) delete env[k];
6569
6684
  rmSync(resultPath, { force: true });
6570
- const scheduleFailed = (why) => oatsError("E_SELF_RETIRE_SCHEDULE_FAILED", `could not start the deferred retirement of ${name}: ${why}. Nothing was inspected, run, or removed; the instance is still live and can be retired externally with \`oats retire ${name}\``);
6685
+ const scheduleFailed = (why) => oatsError("E_SELF_RETIRE_SCHEDULE_FAILED", `could not start the deferred retirement of ${name}: ${why}. Nothing was inspected, run, or removed; the instance is still live and can be retired externally with \`oats retire ${name} --home ${found.home}\``);
6571
6686
  let fd, child;
6572
6687
  try {
6573
6688
  fd = openSync(logPath, "w");
@@ -6586,7 +6701,7 @@ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
6586
6701
  // debt instead of an uncaught exception behind a success message.
6587
6702
  child.on("error", (e) => {
6588
6703
  try {
6589
- writeFileSync(resultPath, JSON.stringify({ instance: name, agent: found.agent.name, requestedAt: intent.requestedAt, completedAt: new Date().toISOString(), ok: false, error: { code: e.code, message: `the deferred retirement could not start: ${e.message}` }, retry: `oats retire ${name}` }, null, 2) + "\n");
6704
+ writeFileSync(resultPath, JSON.stringify({ instance: name, agent: found.agent.name, requestedAt: intent.requestedAt, completedAt: new Date().toISOString(), ok: false, error: { code: e.code, message: `the deferred retirement could not start: ${e.message}` }, retry: `oats retire ${name} --home ${found.home}` }, null, 2) + "\n");
6590
6705
  } catch { /* the marker alone then shows RETIRING; status names its age */ }
6591
6706
  });
6592
6707
  child.unref();
@@ -6625,20 +6740,21 @@ export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
6625
6740
  let result;
6626
6741
  try {
6627
6742
  result = retireInstance(intent.root, intent.instance, {
6628
- deleteBranch: !!intent.options?.deleteBranch, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
6743
+ home: intent.options?.home, deleteBranch: !!intent.options?.deleteBranch, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
6629
6744
  });
6630
6745
  } catch (e) {
6631
- record({ ok: false, error: { code: e.code, message: e.message }, retry: `oats retire ${intent.instance}` });
6746
+ record({ ok: false, error: { code: e.code, message: e.message }, retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
6632
6747
  console.error(`deferred retirement of ${intent.instance} failed: ${e.message}`);
6633
6748
  return false;
6634
6749
  }
6635
6750
  if (result.rollbackIncomplete) {
6636
- record({ ok: false, result, retry: `oats retire ${intent.instance}` });
6751
+ record({ ok: false, result, retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
6637
6752
  console.error(`deferred retirement of ${intent.instance} is INCOMPLETE; the home is retained:\n ${result.rollbackIncomplete.join("\n ")}`);
6638
6753
  return false;
6639
6754
  }
6640
6755
  if (intent.options?.keepDir) {
6641
- const retained = findInstanceHome(intent.root, intent.instance);
6756
+ // The kept home is the one the intent names, never a same-named twin.
6757
+ const retained = intent.options?.home ? { home: intent.options.home } : findInstanceHome(intent.root, intent.instance);
6642
6758
  if (retained) rmSync(retirePendingMarkerPath(retained.home), { force: true });
6643
6759
  }
6644
6760
  // Success leaves nothing beside the home: the home is gone, the marker went
@@ -6655,8 +6771,23 @@ export function retireInstance(root, name, o = {}) {
6655
6771
  // retirement is deferred to a detached external completion (below); with
6656
6772
  // --keep-dir the old in-process path runs and kills the window LAST.
6657
6773
  const self = o.self === true;
6658
- const found = findInstanceHome(root, name);
6659
- if (!found) throw new Error(`no instance named "${name}"`);
6774
+ // Names are unique per agent dir only: two agents can own an instance of
6775
+ // the same name (dev --purpose foo-1 and agent dev-foo). Retirement is
6776
+ // destructive, so a name that resolves to several homes is refused unless
6777
+ // the caller says which home (--home), and a home is accepted only when it
6778
+ // is one of that name's homes under this root.
6779
+ const matches = findInstanceHomes(root, name);
6780
+ if (!matches.length) throw new Error(`no instance named "${name}"`);
6781
+ const sameHome = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return resolve(a) === resolve(b); } };
6782
+ let found;
6783
+ if (o.home) {
6784
+ found = matches.find((m) => sameHome(m.home, o.home));
6785
+ if (!found) throw oatsError("E_HOME_MISMATCH", `instance "${name}" has no home at ${o.home} under this agents root (its home${matches.length === 1 ? " is" : "s are"} ${matches.map((m) => m.home).join(", ")})`);
6786
+ } else if (matches.length > 1) {
6787
+ throw oatsError("E_AMBIGUOUS_INSTANCE", `"${name}" names ${matches.length} instances under this agents root (${matches.map((m) => `${m.agent.name}: ${m.home}`).join("; ")}); retire with --home <path> to say which`);
6788
+ } else {
6789
+ found = matches[0];
6790
+ }
6660
6791
  const metaPath = join(found.home, "instance.json");
6661
6792
  // A QUARANTINED home (spawn failed after a required hook and compensation did
6662
6793
  // not finish) has no instance.json — it never got that far. Its marker carries