@awebai/oats 0.22.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +30 -47
  2. package/bin/oats.mjs +11 -6
  3. package/capabilities/oats-authoring/LICENSE +21 -0
  4. package/capabilities/oats-authoring/oats-package.json +11 -0
  5. package/capabilities/oats-authoring/oats.json +4 -4
  6. package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +63 -0
  7. package/capabilities/oats-authoring/skills/skill-craft/SKILL.md +109 -0
  8. package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +109 -0
  9. package/capabilities/oats-aweb/oats.json +6 -6
  10. package/capabilities/oats-aweb/skills/LICENSE +21 -0
  11. package/capabilities/oats-aweb/skills/VENDORED.md +26 -0
  12. package/capabilities/oats-aweb/skills/aweb-identity/SKILL.md +201 -0
  13. package/capabilities/oats-aweb/skills/aweb-messaging/SKILL.md +161 -0
  14. package/capabilities/oats-aweb/skills/aweb-messaging/references/messaging-scenarios.md +61 -0
  15. package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +328 -0
  16. package/capabilities/oats-aweb/skills/aweb-team-membership/references/team-membership-reference.md +74 -0
  17. package/capabilities/oats-jira/oats.json +1 -1
  18. package/capabilities/oats-linear/oats.json +1 -1
  19. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +6 -0
  20. package/capabilities/oats-okf/bin/oats-okf.mjs +82 -51
  21. package/capabilities/oats-okf/oats.json +4 -1
  22. package/capabilities/oats-review/oats.json +1 -1
  23. package/docs/2026-09-03-architecture-proposal.md +642 -0
  24. package/docs/first-team-demo.md +87 -0
  25. package/docs/first-team.md +179 -0
  26. package/docs/integrations.md +83 -65
  27. package/docs/layers.md +356 -80
  28. package/docs/migration-from-oas.md +80 -116
  29. package/docs/release-notes/v0.22.1.md +106 -0
  30. package/lib/core.mjs +255 -363
  31. package/package.json +1 -1
  32. package/packages/record/README.md +76 -16
  33. package/packages/record/docs/turn-record-sot.md +1 -1
  34. package/packages/record/lib/store.mjs +207 -43
  35. package/capabilities/oats-aweb/package.json +0 -20
  36. package/capabilities/oats-jira/package.json +0 -25
  37. package/capabilities/oats-linear/README.md +0 -234
  38. package/capabilities/oats-linear/package.json +0 -29
  39. package/capabilities/oats-linear/test/oats-linear.test.mjs +0 -168
  40. package/capabilities/oats-okf/package.json +0 -22
  41. /package/capabilities/oats-okf/agents/{memory-harvest.md → memory-harvest/AGENTS.md} +0 -0
package/lib/core.mjs CHANGED
@@ -4395,7 +4395,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
4395
4395
  // OATS_INSTANCE_HOME is the runtime-neutral contract name for the
4396
4396
  // instance home (absolute). OATS_HOME predates it and stays as a
4397
4397
  // compatibility alias: shipped capability hooks read it
4398
- // (capabilities/oats-aweb, capabilities/oats-okf) and are versioned
4398
+ // (the official oats.aweb and oats.okf packages) and are versioned
4399
4399
  // independently of this kernel. Neither is OATS_HOME_DIR, which is
4400
4400
  // the package STORE root — do not conflate them.
4401
4401
  OATS_EVENT: event, OATS_INSTANCE: instance, OATS_INSTANCE_HOME: home, OATS_HOME: home, OATS_AGENT: agentName,
@@ -4665,7 +4665,7 @@ export function appendLogEntry(file, entry, title = "Log") {
4665
4665
  writeFileSync(file, lines.join("\n").replace(/\n{3,}/g, "\n\n"));
4666
4666
  }
4667
4667
 
4668
- // (soul knowledge scaffolding belongs to capabilities/oats-okf — soul-scaffold hook)
4668
+ // (soul knowledge scaffolding belongs to the oats.okf package — soul-scaffold hook)
4669
4669
 
4670
4670
  // ---------- soul scaffolding ----------
4671
4671
  function fileSnapshot(dir) {
@@ -5333,6 +5333,13 @@ export function spawnInstance(root, agent, o = {}) {
5333
5333
  // reconciliation, so this spawn-time check is the authoritative one.
5334
5334
  const runtimePackages = verifyRuntimePackages(runtime, resolvedCfg, repoAbs);
5335
5335
 
5336
+ // Prerequisites must fail before creating a home, worktree, or identity.
5337
+ const claudeBin = runtime === "claude" ? resolveClaudeBinary(repoAbs) : undefined;
5338
+ const bin = which(runtime === "claude" ? claudeBin : "pi");
5339
+ if (!bin) throw new Error(`${runtime === "claude" ? claudeBin : runtime} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}`);
5340
+ if (launch && !which("tmux")) throw new Error("tmux not installed (brew install tmux)");
5341
+ const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
5342
+
5336
5343
  mkdirSync(home, { recursive: true });
5337
5344
  // TOCTOU: the placement checks above ran BEFORE composition and the runtime
5338
5345
  // package preflight, both of which shell out — a window in which anything able
@@ -5508,41 +5515,54 @@ export function spawnInstance(root, agent, o = {}) {
5508
5515
  // Capability lifecycle hooks (spawn) — the knowledge integration scaffolds instance
5509
5516
  // memory (STATE.md/log.md/notes/ are OKF conventions, not kernel ones); the
5510
5517
  // messaging integration mints the comms identity. Kernel stays memory-agnostic.
5511
- const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
5512
5518
  const hookRes = runLifecycleHooks("spawn", {
5513
5519
  home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
5514
5520
  workspaceDir: workspaceOf(root), resolved: resolvedCfg,
5515
5521
  extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_RUNTIME: runtime, OATS_KIND: agent.kind || "persistent" },
5516
5522
  });
5517
5523
  warnings.push(...hookRes.warnings);
5518
- // A REQUIRED spawn hook that failed means an active capability is not actually
5519
- // configured — aweb without a minted identity is an agent that believes it can
5520
- // be woken by mail and cannot. Fail the spawn and roll back, rather than hand
5521
- // over a half-configured instance. Nothing is launched yet, so compensation is
5522
- // retire hooks + worktree/branch + home; the same three-state verification as
5523
- // the anchor-write path, because a cleanup we cannot confirm must never be
5524
- // reported as done.
5525
5524
  const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
5526
- if (requiredFailures.length) {
5527
- const incomplete = [];
5525
+ let windowMayExist = false;
5526
+ let spawnTmux;
5527
+ const ancillaryCleanup = [];
5528
+ // One compensation owner, from the first hook result through launch and
5529
+ // the final lineage write. Preserve the original failure and retain any
5530
+ // credentials/metadata whose cleanup could not be confirmed.
5531
+ const compensateSpawn = () => {
5532
+ const failed = requiredFailures.length
5533
+ ? requiredFailures.map((f) => ({ capability: f.capability, event: f.event, ...(f.contract ? { contract: f.contract } : {}) }))
5534
+ : [{ capability: "oats.kernel", event: "spawn" }];
5535
+ const incomplete = [...ancillaryCleanup];
5528
5536
  const probe = (argv) => {
5529
5537
  try { return { ok: true, out: execFileSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) }; }
5530
- // With encoding:"utf8" a silent command yields stderr === "" — FALSY — so
5531
- // `e2.stderr || e2.message` fell through to "Command failed: " and made
5532
- // every clean probe look like a failed one. `git rev-parse --verify
5533
- // --quiet` on an absent ref is exactly that case, so a successful branch
5534
- // deletion could never be confirmed and rollback always reported
5535
- // INCOMPLETE. Distinguish "no output" from "no stderr captured".
5536
- catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
5538
+ // An absent ref exits 1 with empty stderr; preserve that distinction.
5539
+ catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
5537
5540
  };
5538
- // Which capabilities still owe cleanup, as IDS the retry can verify against
5539
- // the prose in `incomplete` tells a human what happened, but a retry needs
5540
- // something it can check. Without this, a retry that resolves no capabilities
5541
- // at all (a descriptor naming none, or config drift since the spawn) runs zero
5542
- // hooks, finds zero failures, and clears the quarantine having done nothing
5543
- // (reviewer-dd03a98).
5541
+ // Retain cleanup owners so a retry must actually discharge their debt.
5544
5542
  const outstandingHooks = new Set();
5545
5543
  const outstandingGit = new Set();
5544
+ // A failed new-window command may still have created its window. Verify
5545
+ // quiescence before removing credentials or work that runtime may be using.
5546
+ if (windowMayExist) {
5547
+ shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
5548
+ const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
5549
+ const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
5550
+ if (unresolved) {
5551
+ incomplete.push(!winProbe.ok
5552
+ ? `tmux window ${session}:${instance}: could not verify removal (${winProbe.err || "list-windows failed"})`
5553
+ : `tmux window ${session}:${instance} still running`);
5554
+ for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
5555
+ if (work === "worktree") {
5556
+ outstandingGit.add("worktree");
5557
+ if (branch) outstandingGit.add("branch");
5558
+ }
5559
+ return quarantineInstanceHome({
5560
+ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
5561
+ repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
5562
+ launched: true, tmux: spawnTmux, recordRetirementBaseline: true,
5563
+ });
5564
+ }
5565
+ }
5546
5566
  let compensationMeta = {};
5547
5567
  try {
5548
5568
  const comp = runLifecycleHooks("retire", {
@@ -5597,25 +5617,13 @@ export function spawnInstance(root, agent, o = {}) {
5597
5617
  incomplete.push(`${cap.id}: its spawn hook reported state it created, but the capability declares no retire hook, so OATS cannot undo it`);
5598
5618
  outstandingHooks.add(cap.id);
5599
5619
  }
5600
- const detail = requiredFailures.map((f) => ` ${f.capability} ${f.event} ${f.contract === "environment" ? "environment contract" : "hook (declared required)"}: ${f.message}`).join("\n");
5601
5620
  let note;
5602
- if (incomplete.length) {
5603
- // QUARANTINE, do not delete. Compensation could not finish, and the home
5604
- // holds the very credentials and metadata a retry needs — for aweb,
5605
- // <instance-home>/.aw is the only signing key that can self-delete the
5606
- // remote identity. Removing it converts a transient cleanup failure into
5607
- // permanent remote residue (aggregate review at 798b156). The worktree and
5608
- // branch are already gone where that was independently safe; nothing is
5609
- // launched.
5610
- // A quarantine cannot promise to prove zero work. If a diagnostic exists
5611
- // but neither category identified its owner, conservatively require every
5612
- // retire hook to succeed on retry.
5613
- if (!outstandingHooks.size && !outstandingGit.size) {
5614
- for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
5615
- }
5621
+ if (outstandingHooks.size || outstandingGit.size) {
5622
+ // Preserve credentials and the original hook receipt until cleanup
5623
+ // succeeds. The runtime is stopped; Git cleanup was independently safe.
5616
5624
  note = quarantineInstanceHome({
5617
5625
  home, instance, agent, incomplete,
5618
- failed: requiredFailures.map((f) => ({ capability: f.capability, event: f.event, ...(f.contract ? { contract: f.contract } : {}) })),
5626
+ failed,
5619
5627
  outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg,
5620
5628
  hookMeta: hookRes.meta || {}, compensationMeta, launched: false,
5621
5629
  recordRetirementBaseline: true,
@@ -5625,18 +5633,24 @@ export function spawnInstance(root, agent, o = {}) {
5625
5633
  if (existsSync(home) && !incomplete.some((m) => m.startsWith("instance home"))) incomplete.push(`instance home ${home}: still present`);
5626
5634
  note = incomplete.length ? ` — rollback INCOMPLETE, clean up manually: ${incomplete.join("; ")}` : " — spawn rolled back";
5627
5635
  }
5628
- const code = requiredFailures.some((f) => f.contract === "environment") ? "E_HOOK_ENVIRONMENT_CONTRACT" : "E_REQUIRED_HOOK_FAILED";
5629
- 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${note}`);
5630
- }
5631
- const briefLines = hookRes.briefs.length ? `\n${hookRes.briefs.join("\n")}` : "";
5632
- const workDesc = work === "worktree"
5633
- ? `a dedicated git worktree of ${repoAbs} on branch "${branch}" commit freely there`
5634
- : work === "attached"
5635
- ? `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`
5636
- : work === "workspace"
5637
- ? `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)`
5638
- : `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`;
5639
- writeFileSync(join(home, "TASK.md"), `# Instance briefing: ${instance}
5636
+ return note;
5637
+ };
5638
+
5639
+ try {
5640
+ if (requiredFailures.length) {
5641
+ const detail = requiredFailures.map((f) => ` ${f.capability} ${f.event} ${f.contract === "environment" ? "environment contract" : "hook (declared required)"}: ${f.message}`).join("\n");
5642
+ const code = requiredFailures.some((f) => f.contract === "environment") ? "E_HOOK_ENVIRONMENT_CONTRACT" : "E_REQUIRED_HOOK_FAILED";
5643
+ 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`);
5644
+ }
5645
+ const briefLines = hookRes.briefs.length ? `\n${hookRes.briefs.join("\n")}` : "";
5646
+ const workDesc = work === "worktree"
5647
+ ? `a dedicated git worktree of ${repoAbs} on branch "${branch}" — commit freely there`
5648
+ : work === "attached"
5649
+ ? `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`
5650
+ : work === "workspace"
5651
+ ? `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)`
5652
+ : `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`;
5653
+ writeFileSync(join(home, "TASK.md"), `# Instance briefing: ${instance}
5640
5654
 
5641
5655
  You are instance "${instance}" of agent "${agent.name}".
5642
5656
  - Home: ${home}${resolvedCfg.team ? `\n- Team: ${resolvedCfg.team.name}${resolvedCfg.team.id ? ` (${resolvedCfg.team.id})` : ""} — see teammates with \`oats status --team\`` : ""}
@@ -5644,315 +5658,198 @@ You are instance "${instance}" of agent "${agent.name}".
5644
5658
  - Do all repository work inside ./work. Read ./work/AGENTS.md or ./work/CLAUDE.md first if present.${briefLines}
5645
5659
  ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`);
5646
5660
 
5647
- // Launch command. Spawn IS session start: this command is persisted in
5648
- // instance.json and executed in the instance's tmux window. Capabilities may
5649
- // contribute runtime-specific arguments via their spawn hook's `launch` map
5650
- // (e.g. aweb's Claude Code channel plugin flags).
5651
- const claudeBin = runtime === "claude" ? resolveClaudeBinary(repoAbs) : undefined;
5652
- const bin = which(runtime === "claude" ? claudeBin : "pi");
5653
- if (!bin) throw new Error(`${runtime === "claude" ? claudeBin : runtime} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}`);
5654
- const hookArgs = hookRes.launch?.[runtime] ? ` ${hookRes.launch[runtime]}` : "";
5655
- let cmdline;
5656
- if (runtime === "claude") {
5657
- // .claude/skills already links the OATS-composed instance skill set.
5658
- // "--" terminates option parsing BEFORE the prompt: capability launch
5659
- // hooks can contribute greedy/variadic flags (e.g. aweb's
5660
- // --dangerously-load-development-channels), and without the separator
5661
- // the TASK.md text is swallowed as that flag's next value — claude
5662
- // errors out ("entries must be tagged: <task text>") and the window
5663
- // drops to the fallback shell, which reads as a silently stuck spawn.
5664
- cmdline = `${shq(bin)}${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
5665
- } else {
5666
- // STRICT CURRICULUM (pi): the OATS-composed set no user, ancestor, project
5667
- // or package skill catalogs, and no auto-discovered AGENTS.md/CLAUDE.md.
5668
- // NOT "nothing else can contribute": extensions stay ambient by founder
5669
- // ruling (see below), and an extension's resources_discover hook can add
5670
- // skill paths that survive --no-skills. The OATS-managed root is exact; the
5671
- // extension surface is the operator's, and stating otherwise here would
5672
- // contradict the paragraph twelve lines down (reviewer-aggregate2).
5673
- //
5674
- // --no-skills ends discovery; --skill stays additive.
5675
- // --no-context-files stops ancestor AGENTS.md/CLAUDE.md auto-injection.
5676
- // It also stops the instance's OWN composed AGENTS.md
5677
- // loading, so that is delivered explicitly via
5678
- // --append-system-prompt. The work tree's AGENTS.md
5679
- // stays READABLE by the read tool: readable, not
5680
- // auto-injected, is the contract.
5681
- // --no-prompt-templates same posture for ambient prompt templates.
5682
- //
5683
- // Built-in tools and pi's native interaction model are untouched — OATS
5684
- // curates the curriculum, it does not cripple the runtime.
5685
- //
5686
- // EXTENSIONS STAY AMBIENT, by founder ruling: operators run cross-agent pi
5687
- // extensions (web search, output formatting) that every instance should
5688
- // keep. So no --no-extensions, and no -e flags either — pi discovers the
5689
- // installed extensions itself, and passing them explicitly as well would
5690
- // load the same extension twice.
5691
- //
5692
- // The trade-off is deliberate and narrow: an extension's
5693
- // `resources_discover` hook can contribute skill paths that survive
5694
- // --no-skills. Today only the OATS bridge does that, and inside an instance
5695
- // it contributes that instance's OWN .agents/skills, so the composed set is
5696
- // unchanged. A third-party extension that contributes skills WOULD add them,
5697
- // which is the accepted residue of keeping shared extensions working.
5698
- // Capability-required runtime packages are still verified and recorded
5699
- // (verifyRuntimePackages), so "aweb on pi requires the aweb pi package"
5700
- // still holds it is loaded by pi's own discovery rather than by flag.
5701
- // pi has no `--` end-of-options marker (it rejects `--` in every position),
5702
- // so the task positional goes AHEAD of capability-contributed options:
5703
- // nothing preceding it is waiting for a value, so a trailing variadic
5704
- // contributed flag cannot consume the task.
5705
- cmdline = `${shq(bin)} --no-skills --skill ${shq(join(home, ".agents", "skills"))}`
5706
- + ` --no-context-files --no-prompt-templates`
5707
- + ` --append-system-prompt ${shq(join(home, "AGENTS.md"))}`
5708
- + ` --approve --name ${shq(instance)}${model ? ` --model ${shq(model)}` : ""} ${shq("@TASK.md")}${hookArgs}`;
5709
- }
5710
- // OATS_INSTANCE_HOME is the runtime-neutral contract name (absolute path to
5711
- // the instance home) exported to EVERY runtime. PI_AGENT_HOME/PI_AGENT_INSTANCE
5712
- // are pi-branded predecessors kept as compatibility aliases: the separately
5713
- // published @awebai/oats-pi extension and bin/oats.mjs still read them, and
5714
- // an older installed extension must keep working against a newer kernel.
5715
- const hookEnv = Object.keys(hookRes.env).sort().map((name) => `${name}=${shq(hookRes.env[name])}`).join(" ");
5716
- cmdline = `OATS_INSTANCE=${shq(instance)} OATS_INSTANCE_HOME=${shq(home)} PI_AGENT_INSTANCE=${shq(instance)} PI_AGENT_HOME=${shq(home)}${hookEnv ? ` ${hookEnv}` : ""} ${cmdline}`;
5717
-
5718
- const meta = {
5719
- agent: agent.name, kind: agent.kind || "persistent", instance, home,
5720
- repo: repoAbs, work, branch, runtime, model: model || undefined,
5721
- team: resolvedCfg.team || undefined,
5722
- parentInstance: parentInstance && parentInstance !== instance ? parentInstance : undefined,
5723
- siblingInstance: siblingInstance && siblingInstance !== instance ? siblingInstance : undefined,
5724
- relation: relation || undefined,
5725
- relativeTo: relation ? relativeTo : undefined,
5726
- spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
5727
- capabilityMeta: Object.keys(hookRes.meta).length ? hookRes.meta : undefined,
5728
- layers: Object.keys(resolvedCfg.provenance).length ? resolvedCfg.provenance : undefined,
5729
- capabilities: resolvedCfg.capabilities.map((cap) => ({
5730
- id: cap.id, layer: cap.layer, command: cap.command, origin: cap.origin, level: cap.level,
5731
- settings: cap.settings, provenance: cap.provenance, skills: cap.skills || [],
5732
- hooks: Object.keys(cap.hooks || {}), trusted: !!cap.trust?.trusted,
5733
- ...(cap.environment?.length ? { environment: [...cap.environment] } : {}),
5734
- })),
5735
- skills: [...chosen].sort(([a], [b]) => a.localeCompare(b)).map(([name, v]) => ({ name, source: v.source })),
5736
- instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
5737
- // The auditable record of the curriculum: what the resolved composition
5738
- // PROMISED, and what actually landed. Both are asserted equal before launch;
5739
- // keeping both makes an instance's surface reviewable after the fact without
5740
- // re-resolving config that may since have changed.
5741
- composition: {
5742
- expected: expectedResources.map((r) => ({ type: r.type, source: r.source, declared: r.declared, resolved: r.path, origin: r.origin, level: r.level })),
5743
- materialized: {
5744
- skills: materialized.map((m) => ({ name: m.name, source: m.source, from: m.from })),
5745
- instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
5746
- // `filtered` records that the operator's settings entry narrows this
5747
- // package's resources. A non-empty filter is a deliberate choice whose
5748
- // glob semantics belong to the runtime, so it is auditable here rather
5749
- // than second-guessed at spawn.
5750
- runtimePackages: runtimePackages.map((x) => ({ capability: x.capability, runtime: x.runtime, package: x.package, dir: x.dir, filtered: x.filtered, loadedBy: "runtime-discovery" })),
5751
- // What this instance ACTUALLY sees beyond the OATS-composed set. Recorded
5752
- // so the deviation from strict composition is auditable instead of
5753
- // implied the honest contract, not an aspiration.
5754
- runtimePosture: runtime === "claude"
5755
- ? {
5756
- oatsComposed: "skills via .claude/skills -> ../.agents/skills; instructions via CLAUDE.md -> AGENTS.md",
5757
- ambient: ["user skills", "project and ancestor skills to the repository root", "user and project plugins", "user and project settings", "user and ancestor CLAUDE.md"],
5758
- why: "founder ruling: Claude Code's own global and per-repo configuration stays enabled — it is powerful, and the operator decides. An all-OATS setup is the way to opt out.",
5759
- }
5760
- : {
5761
- oatsComposed: "skills via --skill <instance-home>/.agents/skills; instructions via --append-system-prompt",
5762
- curtailed: ["user skills", "project and ancestor skills", "package skills", "ambient AGENTS.md/CLAUDE.md discovery", "ambient prompt templates"],
5763
- ambient: ["globally configured pi extensions, and any resources they contribute"],
5764
- why: "founder ruling: shared cross-agent pi extensions (web search, output formatting) stay available to every instance.",
5765
- },
5766
- canonicalSkillTree: join(home, ".agents", "skills"),
5767
- skillAlias: { path: join(home, ".claude", "skills"), target: join("..", ".agents", "skills") },
5661
+ // Launch command. Spawn IS session start: this command is persisted in
5662
+ // instance.json and executed in the instance's tmux window. Capabilities may
5663
+ // contribute runtime-specific arguments via their spawn hook's `launch` map
5664
+ // (e.g. aweb's Claude Code channel plugin flags).
5665
+ const hookArgs = hookRes.launch?.[runtime] ? ` ${hookRes.launch[runtime]}` : "";
5666
+ let cmdline;
5667
+ if (runtime === "claude") {
5668
+ // .claude/skills already links the OATS-composed instance skill set.
5669
+ // "--" terminates option parsing BEFORE the prompt: capability launch
5670
+ // hooks can contribute greedy/variadic flags (e.g. aweb's
5671
+ // --dangerously-load-development-channels), and without the separator
5672
+ // the TASK.md text is swallowed as that flag's next value — claude
5673
+ // errors out ("entries must be tagged: <task text>") and the window
5674
+ // drops to the fallback shell, which reads as a silently stuck spawn.
5675
+ cmdline = `${shq(bin)}${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
5676
+ } else {
5677
+ // STRICT CURRICULUM (pi): the OATS-composed set no user, ancestor, project
5678
+ // or package skill catalogs, and no auto-discovered AGENTS.md/CLAUDE.md.
5679
+ // NOT "nothing else can contribute": extensions stay ambient by founder
5680
+ // ruling (see below), and an extension's resources_discover hook can add
5681
+ // skill paths that survive --no-skills. The OATS-managed root is exact; the
5682
+ // extension surface is the operator's, and stating otherwise here would
5683
+ // contradict the paragraph twelve lines down (reviewer-aggregate2).
5684
+ //
5685
+ // --no-skills ends discovery; --skill stays additive.
5686
+ // --no-context-files stops ancestor AGENTS.md/CLAUDE.md auto-injection.
5687
+ // It also stops the instance's OWN composed AGENTS.md
5688
+ // loading, so that is delivered explicitly via
5689
+ // --append-system-prompt. The work tree's AGENTS.md
5690
+ // stays READABLE by the read tool: readable, not
5691
+ // auto-injected, is the contract.
5692
+ // --no-prompt-templates same posture for ambient prompt templates.
5693
+ //
5694
+ // Built-in tools and pi's native interaction model are untouched — OATS
5695
+ // curates the curriculum, it does not cripple the runtime.
5696
+ //
5697
+ // EXTENSIONS STAY AMBIENT, by founder ruling: operators run cross-agent pi
5698
+ // extensions (web search, output formatting) that every instance should
5699
+ // keep. So no --no-extensions, and no -e flags either — pi discovers the
5700
+ // installed extensions itself, and passing them explicitly as well would
5701
+ // load the same extension twice.
5702
+ //
5703
+ // The trade-off is deliberate and narrow: an extension's
5704
+ // `resources_discover` hook can contribute skill paths that survive
5705
+ // --no-skills. Today only the OATS bridge does that, and inside an instance
5706
+ // it contributes that instance's OWN .agents/skills, so the composed set is
5707
+ // unchanged. A third-party extension that contributes skills WOULD add them,
5708
+ // which is the accepted residue of keeping shared extensions working.
5709
+ // Capability-required runtime packages are still verified and recorded
5710
+ // (verifyRuntimePackages), so "aweb on pi requires the aweb pi package"
5711
+ // still holds — it is loaded by pi's own discovery rather than by flag.
5712
+ // pi has no `--` end-of-options marker (it rejects `--` in every position),
5713
+ // so the task positional goes AHEAD of capability-contributed options:
5714
+ // nothing preceding it is waiting for a value, so a trailing variadic
5715
+ // contributed flag cannot consume the task.
5716
+ cmdline = `${shq(bin)} --no-skills --skill ${shq(join(home, ".agents", "skills"))}`
5717
+ + ` --no-context-files --no-prompt-templates`
5718
+ + ` --append-system-prompt ${shq(join(home, "AGENTS.md"))}`
5719
+ + ` --approve --name ${shq(instance)}${model ? ` --model ${shq(model)}` : ""} ${shq("@TASK.md")}${hookArgs}`;
5720
+ }
5721
+ // OATS_INSTANCE_HOME is the runtime-neutral contract name (absolute path to
5722
+ // the instance home) exported to EVERY runtime. PI_AGENT_HOME/PI_AGENT_INSTANCE
5723
+ // are pi-branded predecessors kept as compatibility aliases: the separately
5724
+ // published @awebai/oats-pi extension and bin/oats.mjs still read them, and
5725
+ // an older installed extension must keep working against a newer kernel.
5726
+ const hookEnv = Object.keys(hookRes.env).sort().map((name) => `${name}=${shq(hookRes.env[name])}`).join(" ");
5727
+ cmdline = `OATS_INSTANCE=${shq(instance)} OATS_INSTANCE_HOME=${shq(home)} PI_AGENT_INSTANCE=${shq(instance)} PI_AGENT_HOME=${shq(home)}${hookEnv ? ` ${hookEnv}` : ""} ${cmdline}`;
5728
+
5729
+ const meta = {
5730
+ agent: agent.name, kind: agent.kind || "persistent", instance, home,
5731
+ repo: repoAbs, work, branch, runtime, model: model || undefined,
5732
+ team: resolvedCfg.team || undefined,
5733
+ parentInstance: parentInstance && parentInstance !== instance ? parentInstance : undefined,
5734
+ siblingInstance: siblingInstance && siblingInstance !== instance ? siblingInstance : undefined,
5735
+ relation: relation || undefined,
5736
+ relativeTo: relation ? relativeTo : undefined,
5737
+ spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
5738
+ capabilityMeta: Object.keys(hookRes.meta).length ? hookRes.meta : undefined,
5739
+ layers: Object.keys(resolvedCfg.provenance).length ? resolvedCfg.provenance : undefined,
5740
+ capabilities: resolvedCfg.capabilities.map((cap) => ({
5741
+ id: cap.id, layer: cap.layer, command: cap.command, origin: cap.origin, level: cap.level,
5742
+ settings: cap.settings, provenance: cap.provenance, skills: cap.skills || [],
5743
+ hooks: Object.keys(cap.hooks || {}), trusted: !!cap.trust?.trusted,
5744
+ ...(cap.environment?.length ? { environment: [...cap.environment] } : {}),
5745
+ })),
5746
+ skills: [...chosen].sort(([a], [b]) => a.localeCompare(b)).map(([name, v]) => ({ name, source: v.source })),
5747
+ instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
5748
+ // The auditable record of the curriculum: what the resolved composition
5749
+ // PROMISED, and what actually landed. Both are asserted equal before launch;
5750
+ // keeping both makes an instance's surface reviewable after the fact without
5751
+ // re-resolving config that may since have changed.
5752
+ composition: {
5753
+ expected: expectedResources.map((r) => ({ type: r.type, source: r.source, declared: r.declared, resolved: r.path, origin: r.origin, level: r.level })),
5754
+ materialized: {
5755
+ skills: materialized.map((m) => ({ name: m.name, source: m.source, from: m.from })),
5756
+ instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
5757
+ // `filtered` records that the operator's settings entry narrows this
5758
+ // package's resources. A non-empty filter is a deliberate choice whose
5759
+ // glob semantics belong to the runtime, so it is auditable here rather
5760
+ // than second-guessed at spawn.
5761
+ runtimePackages: runtimePackages.map((x) => ({ capability: x.capability, runtime: x.runtime, package: x.package, dir: x.dir, filtered: x.filtered, loadedBy: "runtime-discovery" })),
5762
+ // What this instance ACTUALLY sees beyond the OATS-composed set. Recorded
5763
+ // so the deviation from strict composition is auditable instead of
5764
+ // implied the honest contract, not an aspiration.
5765
+ runtimePosture: runtime === "claude"
5766
+ ? {
5767
+ oatsComposed: "skills via .claude/skills -> ../.agents/skills; instructions via CLAUDE.md -> AGENTS.md",
5768
+ ambient: ["user skills", "project and ancestor skills to the repository root", "user and project plugins", "user and project settings", "user and ancestor CLAUDE.md"],
5769
+ why: "founder ruling: Claude Code's own global and per-repo configuration stays enabled — it is powerful, and the operator decides. An all-OATS setup is the way to opt out.",
5770
+ }
5771
+ : {
5772
+ oatsComposed: "skills via --skill <instance-home>/.agents/skills; instructions via --append-system-prompt",
5773
+ curtailed: ["user skills", "project and ancestor skills", "package skills", "ambient AGENTS.md/CLAUDE.md discovery", "ambient prompt templates"],
5774
+ ambient: ["globally configured pi extensions, and any resources they contribute"],
5775
+ why: "founder ruling: shared cross-agent pi extensions (web search, output formatting) stay available to every instance.",
5776
+ },
5777
+ canonicalSkillTree: join(home, ".agents", "skills"),
5778
+ skillAlias: { path: join(home, ".claude", "skills"), target: join("..", ".agents", "skills") },
5779
+ },
5768
5780
  },
5769
- },
5770
- capabilityRuntime: resolvedCfg.capabilities.map((cap) => ({
5771
- id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
5772
- hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment,
5773
- missingRequires: cap.missingRequires, trust: cap.trust,
5774
- executable: cap.executable,
5775
- })),
5776
- tmux: { session, window: instance },
5777
- command: cmdline, createdAt: new Date().toISOString(),
5778
- };
5779
- const spawnWarnings = warnings;
5780
-
5781
- let launched = false;
5782
- if (launch) {
5783
- if (!which("tmux")) throw new Error("tmux not installed (brew install tmux)");
5784
- if (!tmuxAlive(session)) {
5785
- const hq = existsSync(root) ? root : workspaceOf(root); // all-local scopes may have no agents/ dir
5786
- sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
5787
- shTry(`tmux set-option -t ${shq(session)} -g window-size latest`);
5788
- shTry(`tmux set-option -t ${shq(session)} -g aggressive-resize on`);
5789
- }
5790
- if (tmuxWindows(session).includes(instance)) throw new Error(`tmux window "${instance}" already exists in session ${session}`);
5791
- meta.tmux.socket = tmuxSocket(session);
5792
- meta.launched = true;
5793
- // Commit the final child metadata and its independent byte authority before
5794
- // the managed runtime can write. No child-home transition follows launch.
5795
- writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
5796
- writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
5797
- // Wrap the command so the window drops into an interactive shell when the
5798
- // agent exits (e.g. Ctrl-C) instead of tmux killing the window.
5799
- const windowCmd = `${cmdline}; exec "\${SHELL:-/bin/zsh}"`;
5800
- sh(`tmux new-window -t ${shq(session)} -n ${shq(instance)} -c ${shq(home)} ${shq(windowCmd)}`);
5801
- launched = true;
5802
- } else {
5803
- meta.launched = false;
5804
- writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
5805
- writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: false, tmux: meta.tmux });
5806
- }
5807
-
5808
- // parent relation: re-point the ANCHOR's recorded lineage so its parent is
5809
- // the new instance (e.g. a maintainer reviewing the spawner sits above it).
5810
- // Committed LAST — after every other fallible step incl. launch — so a
5811
- // failed spawn (missing tmux, window collision, new-window error) never
5812
- // leaves the anchor's graph pointing at a zombie. --no-launch reaches here
5813
- // too: the scaffold itself succeeded, which is that path's definition of
5814
- // success. The write ITSELF is fallible (anchor retired concurrently,
5815
- // unwritable file): on failure the spawn is COMPENSATED — kill the launched
5816
- // window and remove the scaffold — so the operation stays all-or-nothing:
5817
- // either agent live + lineage recorded, or neither.
5818
- if (relation === "parent" && anchorMeta && anchorMetaPath) {
5819
- // Atomic anchor update: writeFileSync truncates-then-writes, so a mid-write
5820
- // failure (ENOSPC, I/O) could leave the anchor's instance.json empty.
5821
- // Write a same-directory temp file and rename it over the anchor — rename
5822
- // is atomic on POSIX, so the anchor is always either old or new, never
5823
- // truncated.
5824
- const tmpPath = `${anchorMetaPath}.tmp-${instance}`;
5825
- try {
5826
- anchorMeta.parentInstance = instance;
5827
- delete anchorMeta.siblingInstance; // the new parent carries the old sibling link
5828
- writeFileSync(tmpPath, JSON.stringify(anchorMeta, null, 2) + "\n");
5829
- renameSync(tmpPath, anchorMetaPath);
5830
- } catch (e) {
5831
- // Compensation steps are each independent and best-effort: no step may
5832
- // abort the remaining rollback or mask the original anchor-write error
5833
- // (rmSync force:true only suppresses ENOENT — EPERM/IO still throw).
5834
- // Failures are COLLECTED and reported: the thrown message must never
5835
- // claim a cleanup that did not happen.
5836
- const incomplete = [];
5837
- // Verification probes are argv-based (no shell interpolation — branch
5838
- // names may contain valid-but-hostile metacharacters like $(…)) and
5839
- // THREE-STATE: confirmed-absent | still-present | could-not-verify.
5840
- // Both of the latter are reported — a failed probe must never pass as
5841
- // a confirmed cleanup (fail closed).
5842
- const probe = (argv) => {
5843
- try { return { ok: true, out: execFileSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) }; }
5844
- // With encoding:"utf8" a silent command yields stderr === "" — FALSY — so
5845
- // `e2.stderr || e2.message` fell through to "Command failed: …" and made
5846
- // every clean probe look like a failed one. `git rev-parse --verify
5847
- // --quiet` on an absent ref is exactly that case, so a successful branch
5848
- // deletion could never be confirmed and rollback always reported
5849
- // INCOMPLETE. Distinguish "no output" from "no stderr captured".
5850
- catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
5851
- };
5852
- let windowUnresolved = false;
5853
- try { rmSync(tmpPath, { force: true }); } catch (e2) { incomplete.push(`temp file ${tmpPath}: ${e2.message}`); }
5854
- if (launched) {
5855
- // shTry returns "" on success and undefined on failure — neither is a
5856
- // reliable signal for kill-window, so verify the EFFECT unconditionally.
5857
- shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
5858
- const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
5859
- if (!winProbe.ok) { incomplete.push(`tmux window ${session}:${instance}: could not verify removal (${winProbe.err || "list-windows failed"})`); windowUnresolved = true; }
5860
- else if (winProbe.out.split("\n").includes(instance)) { incomplete.push(`tmux window ${session}:${instance} still running`); windowUnresolved = true; }
5781
+ capabilityRuntime: resolvedCfg.capabilities.map((cap) => ({
5782
+ id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
5783
+ hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment,
5784
+ missingRequires: cap.missingRequires, trust: cap.trust,
5785
+ executable: cap.executable,
5786
+ })),
5787
+ tmux: { session, window: instance },
5788
+ command: cmdline, createdAt: new Date().toISOString(),
5789
+ };
5790
+ const spawnWarnings = warnings;
5791
+
5792
+ spawnTmux = meta.tmux;
5793
+ if (launch) {
5794
+ if (!tmuxAlive(session)) {
5795
+ const hq = existsSync(root) ? root : workspaceOf(root); // all-local scopes may have no agents/ dir
5796
+ sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
5797
+ shTry(`tmux set-option -t ${shq(session)} -g window-size latest`);
5798
+ shTry(`tmux set-option -t ${shq(session)} -g aggressive-resize on`);
5861
5799
  }
5862
- // Outstanding debt as IDS, so a retry can verify it — same contract the
5863
- // required-hook rollback records.
5864
- const outstandingHooks = new Set();
5865
- const outstandingGit = new Set();
5866
- let compMeta = hookRes.meta || {};
5800
+ if (tmuxWindows(session).includes(instance)) throw new Error(`tmux window "${instance}" already exists in session ${session}`);
5801
+ meta.tmux.socket = tmuxSocket(session);
5802
+ meta.launched = true;
5803
+ // Commit the final child metadata and its independent byte authority before
5804
+ // the managed runtime can write. No child-home transition follows launch.
5805
+ writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
5806
+ writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
5807
+ // Wrap the command so the window drops into an interactive shell when the
5808
+ // agent exits (e.g. Ctrl-C) instead of tmux killing the window.
5809
+ const windowCmd = `${cmdline}; exec "\${SHELL:-/bin/zsh}"`;
5810
+ windowMayExist = true;
5811
+ sh(`tmux new-window -t ${shq(session)} -n ${shq(instance)} -c ${shq(home)} ${shq(windowCmd)}`);
5812
+ } else {
5813
+ meta.launched = false;
5814
+ writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
5815
+ writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: false, tmux: meta.tmux });
5816
+ }
5817
+
5818
+ // parent relation: re-point the ANCHOR's recorded lineage so its parent is
5819
+ // the new instance (e.g. a maintainer reviewing the spawner sits above it).
5820
+ // Committed LAST — after every other fallible step incl. launch — so a
5821
+ // failed spawn (missing tmux, window collision, new-window error) never
5822
+ // leaves the anchor's graph pointing at a zombie. --no-launch reaches here
5823
+ // too: the scaffold itself succeeded, which is that path's definition of
5824
+ // success. The write ITSELF is fallible (anchor retired concurrently,
5825
+ // unwritable file): on failure the spawn is COMPENSATED — kill the launched
5826
+ // window and remove the scaffold — so the operation stays all-or-nothing:
5827
+ // either agent live + lineage recorded, or neither.
5828
+ if (relation === "parent" && anchorMeta && anchorMetaPath) {
5829
+ // Atomic anchor update: writeFileSync truncates-then-writes, so a mid-write
5830
+ // failure (ENOSPC, I/O) could leave the anchor's instance.json empty.
5831
+ // Write a same-directory temp file and rename it over the anchor — rename
5832
+ // is atomic on POSIX, so the anchor is always either old or new, never
5833
+ // truncated.
5834
+ const tmpPath = `${anchorMetaPath}.tmp-${instance}`;
5867
5835
  try {
5868
- const comp = runLifecycleHooks("retire", {
5869
- home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
5870
- workspaceDir: workspaceOf(root), rootDir: root, resolved: resolvedCfg,
5871
- priorMeta: hookRes.meta || {},
5872
- });
5873
- // runLifecycleHooks catches hook errors internally detect them via
5874
- // the structured failures field, not this outer catch.
5875
- for (const f of comp.failures || []) { incomplete.push(`retire hook ${f.capability}: ${f.message}`); outstandingHooks.add(f.capability); }
5876
- // Exit 0 while reporting "not retired" is also unfinished cleanup.
5877
- for (const [capId, m] of Object.entries(comp.meta || {})) {
5878
- if (m && typeof m === "object" && m.retired === false && m.reason !== "nothing-to-delete") {
5879
- incomplete.push(`retire hook ${capId}: reported incomplete cleanup${m.reason ? ` (${m.reason})` : ""} — external state may remain`);
5880
- outstandingHooks.add(capId);
5881
- }
5882
- }
5883
- compMeta = { ...compMeta, ...(comp.meta || {}) };
5884
- } catch (e2) {
5885
- incomplete.push(`retire hooks: ${e2.message}`);
5886
- for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
5887
- }
5888
- if (work === "worktree") {
5889
- const wt = join(home, "work");
5890
- // Git canonicalizes symlinked parent components when registering a
5891
- // worktree. Use the path captured immediately after successful add —
5892
- // retire-hook compensation may already have removed/inaccessible'd wt.
5893
- const wtCanonical = worktreeCanonical;
5894
- if (!wtCanonical) incomplete.push(`git worktree ${wt}: could not verify removal (canonical path unavailable)`);
5895
- probe(["git", "-C", repoAbs, "worktree", "remove", "--force", wt]);
5896
- probe(["git", "-C", repoAbs, "worktree", "prune"]);
5897
- // Verify effects, not exit codes: parse exact NUL-delimited `worktree`
5898
- // records (never substring-match a lexical path against Git's canonical
5899
- // registered path). The tree must be deregistered or later rmSync(home)
5900
- // strands stale Git metadata.
5901
- const wtProbe = probe(["git", "-C", repoAbs, "worktree", "list", "--porcelain", "-z"]);
5902
- if (!wtProbe.ok) { incomplete.push(`git worktree ${wtCanonical}: could not verify removal (${wtProbe.err || "worktree list failed"})`); outstandingGit.add("worktree"); }
5903
- else {
5904
- const registered = wtProbe.out.split("\0").filter((field) => field.startsWith("worktree ")).map((field) => field.slice("worktree ".length));
5905
- if (wtCanonical && registered.includes(wtCanonical)) { incomplete.push(`git worktree ${wtCanonical}: still registered`); outstandingGit.add("worktree"); }
5906
- }
5907
- if (branch) {
5908
- probe(["git", "-C", repoAbs, "branch", "-D", branch]);
5909
- // rev-parse --verify: exit 0 = ref exists; exit 1 with empty stderr
5910
- // under --quiet = confirmed absent; anything else = could not verify.
5911
- const brProbe = probe(["git", "-C", repoAbs, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
5912
- if (brProbe.ok) { incomplete.push(`git branch ${branch}: still exists`); outstandingGit.add("branch"); }
5913
- else if (brProbe.status !== 1 || brProbe.err) { incomplete.push(`git branch ${branch}: could not verify deletion (${brProbe.err || `rev-parse exit ${brProbe.status}`})`); outstandingGit.add("branch"); }
5914
- }
5915
- }
5916
- // QUARANTINE, do not delete. This path deleted the home unconditionally —
5917
- // including while its own retire hook was reporting failure — which is
5918
- // exactly the credential destruction the required-hook path was fixed to
5919
- // avoid (reviewer-terminal54a87fd). A quarantine with nothing outstanding
5920
- // would be a proof obligation of zero, so the record is filled
5921
- // conservatively when both categories somehow came up empty.
5922
- // Quarantine only for state that COMPENSATION owns and did not finish: a
5923
- // retire hook that failed, Git the rollback could not undo, or a window
5924
- // still running. Litter beside the anchor (a leftover temp file) is
5925
- // reported but is not the child's external state, and retaining a home
5926
- // for it would turn an ordinary failure into a --force cleanup.
5927
- const unresolved = outstandingHooks.size > 0 || outstandingGit.size > 0 || windowUnresolved;
5928
- let rollbackNote;
5929
- if (unresolved) {
5930
- if (!outstandingHooks.size && !outstandingGit.size) {
5931
- for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
5932
- }
5933
- rollbackNote = quarantineInstanceHome({
5934
- home, instance, agent, incomplete,
5935
- failed: [{ capability: "oats.kernel", event: "spawn" }],
5936
- outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg,
5937
- // The SPAWN metadata — the aweb alias a retry needs to delete the
5938
- // remote identity. Passing the compensation result overwrote it with
5939
- // `{retired:false}`, discarding the very handle cleanup requires.
5940
- hookMeta: hookRes.meta || {}, compensationMeta: compMeta,
5941
- launched: windowUnresolved, tmux: meta.tmux,
5942
- recordRetirementBaseline: true,
5943
- }).replace(/^ — /, "");
5944
- } else {
5945
- try { rmSync(home, { recursive: true, force: true }); } catch (e2) { incomplete.push(`instance home ${home}: ${e2.message}`); }
5946
- if (existsSync(home) && !incomplete.some((m) => m.startsWith("instance home"))) incomplete.push(`instance home ${home}: still present`);
5947
- rollbackNote = incomplete.length
5948
- ? `rollback INCOMPLETE — clean up manually: ${incomplete.join("; ")}`
5949
- : "spawn rolled back (window killed, hooks compensated, scaffold removed)";
5836
+ anchorMeta.parentInstance = instance;
5837
+ delete anchorMeta.siblingInstance; // the new parent carries the old sibling link
5838
+ writeFileSync(tmpPath, JSON.stringify(anchorMeta, null, 2) + "\n");
5839
+ renameSync(tmpPath, anchorMetaPath);
5840
+ } catch (e) {
5841
+ try { rmSync(tmpPath, { force: true }); }
5842
+ catch (cleanupError) { ancillaryCleanup.push(`temp file ${tmpPath}: ${cleanupError.message}`); }
5843
+ throw new Error(`relation "parent": failed to re-point anchor "${relativeTo}" (${e.message})`);
5950
5844
  }
5951
- throw new Error(`relation "parent": failed to re-point anchor "${relativeTo}" (${e.message}) — ${rollbackNote}`);
5952
5845
  }
5953
- }
5954
5846
 
5955
- return { ...meta, attach: `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
5847
+ return { ...meta, attach: `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
5848
+ } catch (error) {
5849
+ const note = compensateSpawn();
5850
+ error.message += note;
5851
+ throw error;
5852
+ }
5956
5853
  }
5957
5854
 
5958
5855
  export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
@@ -6068,13 +5965,8 @@ export const QUARANTINE_CLEANUP_VERSION = 1;
6068
5965
  /** The rollback-owned Git steps a quarantine can still owe. */
6069
5966
  export const QUARANTINE_GIT_DEBT = ["worktree", "branch"];
6070
5967
 
6071
- /** Retain a half-built instance home and mark it, so `oats retire <instance>` can
6072
- * finish the cleanup that failed. THE one implementation: a spawn has two
6073
- * rollback paths — a failed required hook, and a failure after the instance was
6074
- * already launched (re-pointing a parent anchor) — and the second one deleted the
6075
- * home while its own retire hook was reporting failure, destroying the credential
6076
- * needed to undo the external state that survived (reviewer-terminal54a87fd).
6077
- * Two copies of this logic is how that divergence happened; there is now one. */
5968
+ /** Retain the home and its cleanup receipt when spawn compensation or retirement
5969
+ * cannot finish. Keeping the original credentials makes cleanup retryable. */
6078
5970
  function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, recordRetirementBaseline = false, reason }) {
6079
5971
  try {
6080
5972
  writeFileSync(join(home, ".oats-rollback-incomplete.json"), JSON.stringify({