@awebai/oats 0.42.0 → 0.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/oats.mjs CHANGED
@@ -53,7 +53,7 @@ import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_
53
53
 
54
54
  import { observeInstanceGit, diffInstanceFile } from "../lib/instance-git.mjs";
55
55
  import { planStop, applyStop, planRetire, resolveInstance as resolveInstanceForCli } from "../lib/instance-lifecycle.mjs";
56
- import { formatBytes, workRecoveryLines } from "../lib/retire-output.mjs";
56
+ import { extraWorktreeLines, formatBytes, workRecoveryLines } from "../lib/retire-output.mjs";
57
57
  const await_import_lifecycle = () => ({ resolveInstance: resolveInstanceForCli });
58
58
  import { homeTarget, soulTarget, isWorkspaceContext, inspectDocument, readinessDocument, policyOf, policySoul, manifestMissingRequires, INSPECT_OPERATIONS_API } from "../lib/instance-inspect.mjs";
59
59
  import { readEvents, setWaiting, incarnationOf } from "../lib/instance-events.mjs";
@@ -2498,7 +2498,7 @@ function retireCmd() {
2498
2498
  const planRev = flag("plan-revision"), idemKey = flag("idempotency-key");
2499
2499
  if (planRev === true || idemKey === true) die("--plan-revision and --idempotency-key need values");
2500
2500
  if ((planRev !== undefined) !== (idemKey !== undefined)) die("--plan-revision and --idempotency-key go together");
2501
- let replayPath = null, childrenStopped = null;
2501
+ let replayPath = null, childrenStopped = null, plannedExtraWorktrees;
2502
2502
  if (planRev !== undefined) {
2503
2503
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(idemKey)) die("--idempotency-key: 1-128 chars of [A-Za-z0-9._:-]");
2504
2504
  // Replay first: after a successful retire the home is gone, so the receipt
@@ -2508,6 +2508,9 @@ function retireCmd() {
2508
2508
  let fresh;
2509
2509
  try { fresh = planRetire(dirFlag(), root, name, { home: homeFlag }); } catch (e) { return args.includes("--json") ? jsonFail(e.code || "E_LIFECYCLE_FAILED", e.message, e.details) : die(e.message); }
2510
2510
  replayPath = join(dirname(fresh.home), `.oats-retire-receipt.${idemKey}.json`);
2511
+ // The extra trees the confirmed plan names, as it names them: the retire refuses as stale rather than
2512
+ // move or remove one that no longer reads that way when it gets to them.
2513
+ plannedExtraWorktrees = fresh.facts.extraWorktrees;
2511
2514
  if (fresh.planRevision !== planRev) return args.includes("--json") ? jsonFail("E_PLAN_STALE", `the retire plan changed since it was shown (${planRev} → ${fresh.planRevision}); review the fresh plan`, { plan: fresh }) : die(`the retire plan changed since it was shown; re-run oats retire ${name} --plan`);
2512
2515
  // The plan promised: recorded children are STOPPED first (bounded, never
2513
2516
  // escalated) and retained. A child still running after the grace refuses
@@ -2521,7 +2524,7 @@ function retireCmd() {
2521
2524
  if (running.length) return args.includes("--json") ? jsonFail("E_CHILDREN_RUNNING", `${running.map((k) => k.instance).join(", ")} ${running.length === 1 ? "is" : "are"} still running after a bounded stop; nothing was retired and nothing was escalated`, { childrenStopped, plan: fresh }) : die(`children still running: ${running.map((k) => k.instance).join(", ")}; nothing retired`);
2522
2525
  }
2523
2526
  let r;
2524
- try { r = retireInstance(root, name, { home: homeFlag, self: isSelf, discardWorktree: args.includes("--discard-worktree"), keepDir: args.includes("--keep-dir"), force: args.includes("--force") }); }
2527
+ try { r = retireInstance(root, name, { home: homeFlag, self: isSelf, discardWorktree: args.includes("--discard-worktree"), keepDir: args.includes("--keep-dir"), force: args.includes("--force"), ...(plannedExtraWorktrees ? { plannedExtraWorktrees } : {}) }); }
2525
2528
  catch (e) { if (!e?.code) throw e; return args.includes("--json") ? jsonFail(e.code, e.message, e.candidates ? { ...e.details, candidates: e.candidates } : e.details) : die(e.message); }
2526
2529
  if (childrenStopped) r.childrenStopped = childrenStopped;
2527
2530
  if (replayPath) { r.planRevision = planRev; r.idempotencyKey = idemKey; r.replayed = false; try { writeFileAtomic(replayPath, JSON.stringify(r, null, 2)); } catch { /* receipt is evidence, not authority */ } }
@@ -2563,6 +2566,7 @@ function retireCmd() {
2563
2566
  // Preserving work and not saying so leaves the operator believing it is gone,
2564
2567
  // which is most of the harm of deleting it. Name the classes and the path.
2565
2568
  for (const line of workRecoveryLines(r)) console.log(line);
2569
+ for (const line of extraWorktreeLines(r)) console.log(line);
2566
2570
  for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
2567
2571
  if (isSelf) console.log("This window dies in ~8s — say any goodbyes now.");
2568
2572
  }
@@ -3714,6 +3718,7 @@ async function serverRouteCmd() {
3714
3718
  }
3715
3719
  console.log(`Retired ${r.retired} on ${id}${r.deferred ? " (deferred completion scheduled there)" : ""}${r.rollbackIncomplete ? " — cleanup INCOMPLETE on the server, home retained there" : ""}`);
3716
3720
  for (const line of workRecoveryLines(r, { host: target.sshHost })) console.log(line);
3721
+ for (const line of extraWorktreeLines(r, { host: target.sshHost })) console.log(line);
3717
3722
  if (r.rollbackIncomplete) {
3718
3723
  for (const f of r.rollbackIncomplete) console.error(` ${f}`);
3719
3724
  const branch = failedSpawnBranchLine(r, r.rollbackIncomplete, { host: target.sshHost });
@@ -1923,7 +1923,11 @@ route target:
1923
1923
  "running":true,"identity":{"alias":"dev-a","address":"acme/dev-a"},"identityAddress":"acme/dev-a",
1924
1924
  "teams":[{"label":"default","team":"acme:team"}],"startedAt":"2026-09-29T10:00:00.000Z","createdAt":"2026-09-29T09:58:12.004Z",
1925
1925
  "model":"opus","runtimeState":null,"parentInstance":"lead","siblingInstance":null,"relation":"child","relativeTo":"lead",
1926
- "spawnOrigin":"instance","retirePending":false,"rollbackIncomplete":false,
1926
+ "spawnOrigin":"instance","work":"worktree","repo":"/srv/team/ws","branch":"agents/dev-a","modelFrom":"soul",
1927
+ "soul":{"repoKey":"github.com/acme/team","commit":"66566512…","current":"9c1e04ab…","status":"moved"},
1928
+ "modules":[{"name":"oats.okf","from":{"kind":"package","package":"oats.okf","version":"2.1.3","commit":"ab897841…","integrity":"sha256-bada35…","repoKey":"github.com/awebai/oats-okf"},
1929
+ "commit":"ab897841…","current":{"commit":"ab897841…","version":"2.1.3"},"status":"current"}],
1930
+ "retirePending":false,"rollbackIncomplete":false,
1927
1931
  "savedRoute":false,"addressable":true,"missingRemotely":false}],
1928
1932
  "retireFailures":[]}
1929
1933
  ```
@@ -1940,9 +1944,16 @@ route target:
1940
1944
  - **Instance rows** relay the host's own `status --json` row: `identity`,
1941
1945
  `identityAddress`, `teams`, `startedAt`, `createdAt`, `model`,
1942
1946
  `runtimeState`, `parentInstance`, `siblingInstance`, `relation`,
1943
- `relativeTo` and `spawnOrigin` are always present, `null` when the host
1944
- does not supply them (a host before 0.31, a fact it never recorded, or a
1945
- saved route the host no longer lists). Nothing is derived on this side.
1947
+ `relativeTo` and `spawnOrigin`, and (relayed from 0.42.1) `work`, `repo`,
1948
+ `branch`, `modelFrom`, `soul` and `modules`, are always present, `null`
1949
+ when the host does not supply them (an older host, a fact it never
1950
+ recorded, or a saved route the host no longer lists). Each is the
1951
+ [local row's](#the-roster-oats-status---json) fact of the same name,
1952
+ relayed as the host answered it. Nothing is derived on this side: `repo`
1953
+ is a path on the host, never read here, and `soul` and `modules` are the
1954
+ host's own drift observation (against its own members and lock), not
1955
+ recomputed: `modules` is the drift rows, or the recorded map when the host
1956
+ could not read its workspace, as on a local row.
1946
1957
  - **`waitingOnYou`** (0.40.2, [Waiting on you](#waiting-on-you)) is on a row
1947
1958
  only when the host's kernel reports it: a row from a host before 0.40.0,
1948
1959
  and a saved route the host did not list, have no such key. Absent means
@@ -2219,6 +2230,15 @@ oats instance events <instance> [--limit <n>] [--since <iso>] [--home <abs>] [--
2219
2230
  `--delete-branch` (since 0.41.0: `this self-retire was requested with
2220
2231
  --delete-branch by an older OATS; retirement no longer deletes branches, so
2221
2232
  the branch and the worktree were left`).
2233
+ - `worktree-retained` (`data: {movedTo, branch, recordedBranch}`) and
2234
+ `worktree-removed` (`data: {branch}`) are written to the workspace log only,
2235
+ by the retire's worktree step. Since 0.42.1 the retire also writes one per
2236
+ extra tree it handled
2237
+ ([extra trees at retire](souls-and-instances.md#extra-trees-at-retire)),
2238
+ with `extra: true` and the tree's absolute `path` added:
2239
+ `worktree-retained` `{movedTo, branch, recordedBranch: null, extra: true,
2240
+ path}` and `worktree-removed` `{branch, extra: true, path}`. A row without
2241
+ `extra` is about `work/`.
2222
2242
  - **Incarnation.** Each row carries the writing home's `createdAt` (or
2223
2243
  `null` for old rows); the top-level `incarnation` is the current home's (or
2224
2244
  `null`). Earlier incarnations are returned as this address's history.
@@ -2438,7 +2458,9 @@ oats retire <instance> --plan [--home <abs>] [--dir <d>] --json
2438
2458
  "upstream":{"ref":null,"ahead":null,"behind":null},"base":{"ref":null,"ahead":null,"behind":null},"remote":null},
2439
2459
  "workMode":"worktree","repo":"/w/one","recordedBranch":"agents/dev-1",
2440
2460
  "children":[{"instance":"dev-1-child","agent":"dev","home":"/w/agents/dev/instances/dev-1-child","session":{"state":"shell","present":true,"backend":"tmux","established":true}}],
2441
- "ambiguous":[],"pullRequest":"unknown"},
2461
+ "ambiguous":[],"pullRequest":"unknown",
2462
+ "extraWorktrees":[{"path":"/w/agents/dev/instances/dev-1/.work-docs","repo":"/w/docs","branch":"agents/dev-1-docs","detachedAt":null,
2463
+ "disposition":"retain","movedTo":"/w/.agents/worktrees/docs/agents-dev-1-docs","reason":"…"}]},
2442
2464
  "defaults":{"retainWorktree":true,"deleteBranch":false,"stopChildren":true,"retainChildren":true},
2443
2465
  "planRevision":"4e5f6a7b8c9d0e1f2a3b4c5d","notes":["the worktree is on feat/x, not the recorded agents/dev-1; …"]}
2444
2466
  ```
@@ -2476,10 +2498,35 @@ oats retire <instance> --plan [--home <abs>] [--dir <d>] --json
2476
2498
  there when it is not empty` in directory mode, and absent in checkout,
2477
2499
  attached and workspace modes. They are strings in `notes`: no other key
2478
2500
  changes, and `planRevision` is unaffected.
2501
+ - `facts.extraWorktrees` (0.42.1) lists the instance's
2502
+ [extra trees](souls-and-instances.md#extra-trees-at-retire): linked
2503
+ worktrees at `<home>/.work-*` that Git confirms. It is an array, empty when
2504
+ there are none, in every work mode. Each row is `{path, repo, branch,
2505
+ detachedAt, disposition, movedTo, reason}`:
2506
+ - `path`: the tree's absolute path in the home.
2507
+ - `repo`: its repository, the first entry of that repository's `git
2508
+ worktree list` (the main worktree, or the bare repository).
2509
+ - `branch`: the branch its HEAD is on, or `null`. `detachedAt`: the commit
2510
+ when HEAD is detached, else `null`.
2511
+ - `disposition`: `"remove"` (the tree is clean: it would be removed, its
2512
+ branch kept), `"retain"` (it would be moved to `movedTo`, as `work/` is
2513
+ retained) or `"refuse"` (the retire would refuse with
2514
+ `E_WORK_PRESERVATION_FAILED` and keep the home).
2515
+ - `movedTo`: the target for `"retain"`, else `null`.
2516
+ - `reason`: `null` for `"remove"`, why the tree is not clean for
2517
+ `"retain"`, why it is refused for `"refuse"`.
2518
+
2519
+ `notes` also carries one string per tree that says the same. The trees are
2520
+ part of `planRevision`: a tree created, removed, dirtied or cleaned between
2521
+ the plan and the apply, or a change of its disposition or target (another
2522
+ directory taking the `<leaf>-N` it would move to, for example), refuses a
2523
+ guarded apply with `E_PLAN_STALE` before anything runs. A reader that does
2524
+ not know the key can ignore it; the `notes` strings say the same.
2479
2525
 
2480
2526
  Plain `retire` keeps a worktree-mode instance's work: the worktree is moved
2481
2527
  (`git worktree move`) to `<deployment>/.agents/worktrees/<repo>/<branch>` (a
2482
- `-2` suffix if taken; `detached-<oid12>` when detached), state intact.
2528
+ `-2` suffix if taken; `detached-<oid12>` when detached), state intact. An
2529
+ extra tree that is not clean is moved the same way; a clean one is removed.
2483
2530
 
2484
2531
  ```text
2485
2532
  oats retire <instance> [--plan-revision <rev> --idempotency-key <key>] [--discard-worktree] [--home <abs>] --json
@@ -2535,6 +2582,21 @@ A first retire prints the **raw receipt**, not an envelope:
2535
2582
  retire (hooks run, a recovery copied), and neither says that one happened:
2536
2583
  an error here is not proof that nothing happened, nor that a recovery
2537
2584
  exists.
2585
+ - `extraWorktrees` (0.42.1): the extra trees the retire handled, present only
2586
+ when it handled at least one. Each row is the plan's row (`{path, repo,
2587
+ branch, detachedAt, disposition, movedTo, reason}`) plus `outcome`:
2588
+ `"removed"` or `"retained"`, with `movedTo` where a retained tree went. The
2589
+ step runs only when the home is removed (not with `--keep-dir`, not when the
2590
+ home is kept for a retry), after the hooks and before the worktree step of
2591
+ `work/`. `--discard-worktree` does not apply to it. A locked tree
2592
+ (`"refuse"` in the plan) stops the retire with `E_WORK_PRESERVATION_FAILED`
2593
+ naming the tree before anything runs (no session stop, no retire hook);
2594
+ a lock that appears during the hooks, or a move or removal Git refuses,
2595
+ stops it at the step, after the hooks. `--force` does not bypass either;
2596
+ the home and `work/` are kept, and trees already handled stay handled. A tree
2597
+ that no longer matches what the applied plan said refuses with
2598
+ `E_PLAN_STALE`, the home kept, rather than be moved or removed unplanned.
2599
+ The key is additive.
2538
2600
  - `workRecovery`: `{path, classes, bytes, home, outputs?, repoCopy?,
2539
2601
  notCopied?, afterHooks?}`, present when a recovery was written. One retire
2540
2602
  writes at most one recovery directory, and `path` is that directory.
@@ -2560,10 +2622,12 @@ A first retire prints the **raw receipt**, not an envelope:
2560
2622
  names: a prefix that matches several entries lists each one, and a
2561
2623
  declared entry that does not exist is not listed. `owner` is the
2562
2624
  declaring capability; when two declare the same entry it is the first in
2563
- capability-name order. Each entry has exactly these three keys: names and
2564
- owners only, no sizes, hashes, modes or contents of what was left out.
2565
- `scope` is always `"home"` in this release. Present only when there is
2566
- at least one.
2625
+ capability-name order. Since 0.42.1 the list also holds each verified
2626
+ extra tree (`.work-<purpose>`), with `owner: "kernel:extra-worktree"`: the
2627
+ retire handles it at its own step, never in the copy. Each entry has
2628
+ exactly these three keys: names and owners only, no sizes, hashes, modes
2629
+ or contents of what was left out. `scope` is always `"home"` in this
2630
+ release. Present only when there is at least one.
2567
2631
  - `afterHooks`: `{home: boolean, work: boolean}`, saying which parts were
2568
2632
  copied again under `after-hooks/`: `after-hooks/home/` when a retire hook
2569
2633
  changed the home (its bytes and permission bits, the kernel's own
@@ -2615,8 +2679,9 @@ A first retire prints the **raw receipt**, not an envelope:
2615
2679
  `idempotencyKey` and `replayed: false`.
2616
2680
 
2617
2681
  Refusals (envelopes): `E_PLAN_STALE`, `E_CHILDREN_RUNNING`,
2618
- `E_WORK_PRESERVATION_FAILED` (the home is kept; retry or
2619
- `--discard-worktree`), `E_WORK_INSPECTION_FAILED` (the home is kept; the
2682
+ `E_WORK_PRESERVATION_FAILED` (the home is kept; retry, or
2683
+ `--discard-worktree` when it is `work/` that could not be re-homed: it does
2684
+ not apply to an extra tree), `E_WORK_INSPECTION_FAILED` (the home is kept; the
2620
2685
  message names the entry or the state that could not be read),
2621
2686
  `E_SESSION_UNKNOWN`, `E_AMBIGUOUS_INSTANCE`,
2622
2687
  `E_NO_ROOT`, `E_LIFECYCLE_FAILED`. A recovery whose Git status disagrees with
package/docs/desktop.md CHANGED
@@ -249,14 +249,18 @@ for a password or key, and never runs anything over ssh itself.
249
249
 
250
250
  Every instance a registered server reports shows in its workspace's roster, whoever spawned it. You
251
251
  can open its terminal, start, restart, stop and remove it, and read its readiness, activity, Git
252
- and diffs, as for a local one. Only its pull request is not read here, since the forge reads this
252
+ and diffs, as for a local one. The context panel's Soul tab and its Messaging & Teams list read
253
+ through the server too (`oats inspect --server <id> --home <path>`), and its Work card, "model
254
+ from" line and "older build" chip show the facts the server relays. Only its pull request is not read here, since the forge reads this
253
255
  computer's clones. Every command goes to the server by the instance's home (`--server <id> --home
254
256
  <path>`), never by a bare name. Stop and Remove show the plan the server makes, and confirm
255
257
  against it.
256
258
 
257
259
  A read waits for the server: the view says "Reading from <server>…", and gives up after about
258
260
  45 seconds with "Couldn't reach <server>." When the server refuses, you see its code and message;
259
- nothing is read from this computer in its place. A row that can't be opened says why on the row:
261
+ nothing is read from this computer in its place. When the server's OATS, or this computer's, lacks
262
+ what a part of the panel needs, that part names the server and says which OATS to update, rather
263
+ than showing nothing. A row that can't be opened says why on the row:
260
264
  Herdr no longer supported, gone from <server>, not reachable on <server>, or <server> not reached.
261
265
  For an instance a server no longer lists, the reason names the command that removes it from this
262
266
  computer (`oats server forget <server> --instance <name>`).
@@ -0,0 +1,110 @@
1
+ # OATS 0.42.1
2
+
3
+ ## Changed
4
+
5
+ - **Remote roster rows carry the local row's work, repository, branch,
6
+ model-from, soul and module drift facts**
7
+ ([#675](https://github.com/awebai/oats/issues/675)). A remote instance
8
+ row (`oats server roster --json`, and the Desktop's remote roster) now
9
+ relays `work`, `repo`, `branch`, `modelFrom`, `soul` and `modules` from
10
+ the host's own `oats status --json` row, with the same names and meaning
11
+ as on a local row. `soul` and `modules` are the host's own drift
12
+ observation, relayed as it answered; nothing is recomputed on this side,
13
+ and `repo` is the host's path. Each is always present, `null` from a host
14
+ that doesn't report it (an older host, a fact it never recorded, or a saved
15
+ route the host no longer lists). See
16
+ [the remote roster](../desktop-cli-api.md#the-remote-roster-oats-server-roster---json).
17
+
18
+ - **The `worktree` and `checkout` briefings allow extra trees, and give the
19
+ command** ([#674](https://github.com/awebai/oats/issues/674)). Both
20
+ briefings forbade extra Git worktrees: `worktree` said "Don't create extra
21
+ worktrees; `work/` is your one tree", and `checkout` said to ask for a
22
+ worktree-mode instance for work that needs its own branch. That
23
+ contradicted the shipped `oats.engineering` package, whose developer
24
+ briefing and `/worktrees` skill say "your work-mode briefing has the
25
+ command", with `.work-<purpose>` in the home as the default place. No
26
+ briefing gave that command. The case that needs it is a developer whose
27
+ one task touches several repositories of the deployment.
28
+
29
+ Both briefings now carry the same short paragraph. An extra tree is created
30
+ in the home, from any clone of the deployment (`oats-local.yaml` `clones:`,
31
+ or `<deployment>/<repo>`), from the remote's current state:
32
+
33
+ ```bash
34
+ git -C <clone> worktree add --detach "$OATS_INSTANCE_HOME/.work-<purpose>"
35
+ git -C "$OATS_INSTANCE_HOME/.work-<purpose>" fetch --refmap= origin <base>
36
+ git -C "$OATS_INSTANCE_HOME/.work-<purpose>" switch -c <branch> FETCH_HEAD
37
+ ```
38
+
39
+ The fetch runs inside the new tree, which has its own `FETCH_HEAD`, so
40
+ creating it moves none of the clone's refs. The paragraph also covers
41
+ branch names (the repository's rules, else `agents/<instance>-<purpose>`;
42
+ never `-C` or `-B`), pushing a tree without an upstream
43
+ (`git push origin HEAD:<remote-branch>`), and closing: merge each tree into
44
+ the PR branch or push it and name it in the hand-back, then
45
+ `git -C <clone> worktree remove`. `git switch` needs Git 2.23 or later.
46
+
47
+ `worktree` mode now says never to work in a shared checkout (the main
48
+ checkout, or any clone others use), and that changes happen in `work/` or
49
+ in the extra trees. It drops "Parallel work means your human spawns
50
+ another instance". `checkout` mode now says that work needing its own
51
+ branch goes in an extra tree, not in `work/`. The `workspace`, `directory`
52
+ and `attached` briefings do not change, and there is no new `oats`
53
+ command. The instance-boundary block every instance receives said repository
54
+ work happens in `work/` "and only there"; it now says "there, or in the
55
+ extra trees your mode block grants, and nowhere else", so the two blocks
56
+ no longer disagree. Instances spawned before the upgrade keep the briefing
57
+ they were spawned with. See
58
+ [souls and instances](../souls-and-instances.md#extra-trees).
59
+
60
+ - **Retire protects extra trees in the home**
61
+ ([#674](https://github.com/awebai/oats/issues/674)). An extra tree is a
62
+ top-level `.work-*` entry of the home that Git confirms is a linked
63
+ worktree of some repository. Before, retire treated it as home bytes: it
64
+ copied it to recovery and removed it with the home, leaving the
65
+ repository's admin entry behind. Now, in every work mode:
66
+
67
+ - A verified extra tree is not copied to recovery. `workRecovery.notCopied`
68
+ lists it with `owner: "kernel:extra-worktree"`. A `.work-*` entry that
69
+ does not verify (a plain directory, a symbolic link, a full clone), or
70
+ that belongs to a repository inside the home, is copied as before.
71
+ - When the home is removed, the retire handles each tree after its hooks
72
+ and before the `work/` worktree step. A clean tree (empty status,
73
+ ignored and untracked files included; no operation in progress; HEAD
74
+ reached by a ref of its repository, not only by the tree's own) is
75
+ removed with `git worktree remove` and pruned. Its branch is kept. Any
76
+ other tree is moved, as `work/` is retained, to
77
+ `<deployment>/.agents/worktrees/<repo>/<branch>`.
78
+ - A locked tree refuses the retire with `E_WORK_PRESERVATION_FAILED`
79
+ before anything runs: no session is stopped and no retire hook runs. A
80
+ move or removal Git refuses refuses it after the hooks. Either way the
81
+ home is kept. `--force` does not bypass it, and `--discard-worktree` does
82
+ not apply to extra trees.
83
+ - With `--keep-dir`, or when the home is kept for a retry, the trees stay
84
+ where they are.
85
+
86
+ `oats retire <instance> --plan` adds `facts.extraWorktrees` (`[{path, repo,
87
+ branch, detachedAt, disposition, movedTo, reason}]`, empty when there are
88
+ none) and one `notes` string per tree. The trees are part of
89
+ `planRevision`: a tree created, removed, dirtied or cleaned between the plan
90
+ and the apply refuses the apply with `E_PLAN_STALE`. The retire receipt adds
91
+ `extraWorktrees` (the plan's row plus `outcome`), present when a tree was
92
+ handled. The `worktree-removed` and `worktree-retained` events for an extra
93
+ tree carry `extra: true` and its `path`. All keys are additive. See
94
+ [souls and instances](../souls-and-instances.md#extra-trees-at-retire) and
95
+ [the CLI API](../desktop-cli-api.md#retire).
96
+
97
+ ## Fixed
98
+
99
+ - **The context panel shows the soul and teams of an instance on a server**
100
+ ([#675](https://github.com/awebai/oats/issues/675)). The Soul tab and the
101
+ Messaging & Teams list were empty for an instance on a registered server.
102
+ They now read through the server by the instance's home, as the soul page
103
+ does, and say "Reading from <server>…" while they wait. The Soul tab's
104
+ header shows the soul's description from the server's roster. The Work
105
+ card, the "model from" line and the "older build" chip show the facts the
106
+ server relays. When a part can't be shown, it says why and what to update:
107
+ this computer's OATS when it can't route the read or doesn't relay the
108
+ facts, the server's OATS when it lacks the feature or doesn't report them.
109
+ A server's other refusals show its code and message, as the rest of the
110
+ panel does.
package/docs/servers.md CHANGED
@@ -300,7 +300,10 @@ before 0.36.0, `null` when it reports none or the probe failed), the remote soul
300
300
  the host's own facts from its `status --json`: `identity`,
301
301
  `identityAddress`, `teams`, `startedAt`, `createdAt`, `model`,
302
302
  `runtimeState`, `parentInstance`, `siblingInstance`, `relation`,
303
- `relativeTo` and `spawnOrigin`. A fact the host does not supply is `null`
303
+ `relativeTo`, `spawnOrigin`, and (0.42.1) `work`, `repo`, `branch`,
304
+ `modelFrom`, `soul` and `modules`: the local row's facts, with `soul` and
305
+ `modules` the host's own drift observation, never recomputed here, and
306
+ `repo` a path on the host. A fact the host does not supply is `null`
304
307
  (an older host, or a saved route the host no longer lists). A row also
305
308
  carries `waitingOnYou` (needs input) when the host's kernel reports it, and
306
309
  only then: an older host's row has no such key. A removed or edited registration keeps
@@ -809,6 +809,76 @@ commit yet cannot be read that way: a retire that would remove it, or that
809
809
  has work of it to copy, refuses with `E_WORK_INSPECTION_FAILED` and removes
810
810
  nothing.
811
811
 
812
+ #### Extra trees at retire
813
+
814
+ An instance can hold [extra trees](#extra-trees) in its home beside `work/`.
815
+ Retire handles them itself, so the home removal never deletes their work.
816
+
817
+ An **extra tree** is a top-level entry of the home named `.work-*` that is a
818
+ real directory (not a symbolic link), whose `.git` is a regular file, and that
819
+ Git confirms is a registered linked worktree of some repository: its Git
820
+ directory differs from its common directory, its top level is the entry
821
+ itself, and that repository's `git worktree list` names it. Its repository is
822
+ the first entry of that list (the main worktree, or the bare repository).
823
+ Anything named `.work-*` that does not verify (a plain directory, an orphaned
824
+ `.git` file, a symbolic link, a nested full clone), or that is a worktree of a
825
+ repository inside the home itself, is ordinary home bytes, and the home's
826
+ recovery copies it as before (with that repository). This holds in every work mode,
827
+ `directory` included.
828
+
829
+ A verified extra tree is not part of the home's recovery bytes: it does not
830
+ count as changed instance-home bytes, it is not copied, and the recovery's
831
+ `notCopied` lists it as `{scope: "home", path: ".work-<purpose>", owner:
832
+ "kernel:extra-worktree"}`.
833
+
834
+ The extra-tree step runs only when the home is going to be removed: not with
835
+ `--keep-dir`, and not when the retire keeps the home for a retry. That is the
836
+ condition of the `work/` worktree step (nothing outstanding, or `--force`).
837
+ It runs after the retire hooks and before the `work/` step, so a refusal
838
+ leaves `work/` untouched. For each tree:
839
+
840
+ - **A clean tree is removed.** Clean means that `git status`, ignored and
841
+ untracked files included, is empty; no merge, rebase, cherry-pick, revert,
842
+ bisect or sequencer operation is in progress; and its HEAD commit is
843
+ reached by a ref of its repository (the tree's own HEAD, reflog and
844
+ `refs/worktree/` refs do not count: they go with its admin entry). The retire runs `git worktree remove` (without
845
+ `--force`) and `git worktree prune`, and verifies that the tree is gone from
846
+ `git worktree list`. Every Git command of this step runs helper-free (no
847
+ fsmonitor, hooks or external diff the repository's configuration names). Its branch is never deleted: a commit on the branch that
848
+ was not pushed stays in the clone, on that branch.
849
+ - **Any other tree is re-homed**, as `work/` is by default: `git worktree
850
+ move` to `<deployment>/.agents/worktrees/<repo>/<leaf>`, where `<leaf>` is
851
+ the branch name with characters outside `A-Za-z0-9._-` replaced by `-`, or
852
+ `detached-<12 hex>` when HEAD is detached. A target that exists gets `-2`,
853
+ `-3`, and so on. A tree whose HEAD cannot be read is re-homed too.
854
+ - **A tree the retire cannot handle refuses it.** A locked tree (`git worktree
855
+ lock`) refuses before anything runs: a retire that would remove the home
856
+ finds the lock in its first inspection, before the session is stopped and
857
+ before any retire hook, and stops with "nothing was run or removed". A lock
858
+ that appears while the hooks run is refused at the step, before any tree is
859
+ touched. A move or a removal that Git refuses (a tree with submodules, for
860
+ example), or a removal that cannot be verified, refuses at the step, after
861
+ the hooks. Each refusal is `E_WORK_PRESERVATION_FAILED` naming the tree,
862
+ and the home is kept. At the step, trees already handled in that pass stay
863
+ handled, and the message says what was done. `--force` does not bypass it:
864
+ it forces past hook cleanup, not past local work.
865
+
866
+ `--discard-worktree` applies to `work/` only: a tree that is not clean is
867
+ always re-homed. The retire writes one workspace event per handled tree
868
+ (`worktree-removed` or `worktree-retained`, with `extra: true` and the tree's
869
+ `path`), and its summary prints one line per tree: removed, or re-homed to
870
+ the new path.
871
+
872
+ `oats retire <instance> --plan` lists the trees and what the retire would do
873
+ with each, and they are part of the plan's revision: a tree created, removed,
874
+ dirtied or cleaned between the plan and a guarded apply, or a new target for
875
+ it, refuses the apply with `E_PLAN_STALE` before anything runs. The retire
876
+ checks the trees again at the step itself and refuses with `E_PLAN_STALE`,
877
+ keeping the home, rather than move or remove a tree in a way the plan did
878
+ not say. The retire hooks have run by then, and no tree was moved or
879
+ removed. The fields are in
880
+ [the CLI API](desktop-cli-api.md#retire).
881
+
812
882
  ## Work modes
813
883
 
814
884
  A work mode decides what `./work` points at and what discipline the agent must
@@ -824,7 +894,8 @@ instructions state first (`injects/instance-boundary.md`):
824
894
  target another one deliberately).
825
895
  - `<instance-home>/work` — the repository or workspace view — is where
826
896
  repository reading, editing, building, testing, git and commits happen, to the
827
- extent the mode below permits.
897
+ extent the mode below permits. In `worktree` and `checkout` mode, the
898
+ instance's [extra trees](#extra-trees) in the home serve the same purpose.
828
899
  - The home has no soul link: the composed `AGENTS.md` already carries the
829
900
  soul's instructions, and `instance.json` `soulDir` records the (read-only,
830
901
  per-commit) soul directory every hook and dispatched command receives as
@@ -863,10 +934,12 @@ Use this for agents that will edit code or docs independently.
863
934
  Rules:
864
935
 
865
936
  - Build, test, and commit from `work/`, on your own branch.
866
- - Never run git from the repo's main checkout — it resolves to the wrong branch
867
- and skips review.
868
- - Do not create extra worktrees. Ask for another instance if parallel work is
869
- needed.
937
+ - Never work in a shared checkout (the repo's main checkout, or any clone
938
+ others use): do not edit, commit or switch branches there. Against a clone,
939
+ run only `git worktree add` and `git worktree remove`, as in
940
+ [extra trees](#extra-trees).
941
+ - Everything you change happens in `work/` or in your extra trees.
942
+ - Leave your branch and the worktree list clean when your task closes.
870
943
 
871
944
  ### `checkout` — shared current branch
872
945
 
@@ -880,7 +953,10 @@ Rules:
880
953
 
881
954
  - Stay on the currently checked-out branch.
882
955
  - Do not switch branches unless explicitly asked.
883
- - Avoid destructive git operations unless the human explicitly asks.
956
+ - No destructive git operations (`reset --hard`, rebase, force-push, checkout
957
+ of another branch) unless the human explicitly asks.
958
+ - Work that needs its own branch goes in an [extra tree](#extra-trees), not
959
+ in `work/`.
884
960
 
885
961
  ### `attached` — another instance's tree
886
962
 
@@ -935,6 +1011,50 @@ Rules:
935
1011
 
936
1012
  The instance records no branch: the workspace is not a Git tree.
937
1013
 
1014
+ ### Extra trees
1015
+
1016
+ An instance in `worktree` or `checkout` mode can create extra trees: linked
1017
+ Git worktrees in its home, beside `work/`. It does so when the work needs
1018
+ another branch, or another repository of the deployment (one task that
1019
+ touches several repositories). The `worktree` and `checkout` briefings give
1020
+ the command; the `workspace`, `directory` and `attached` briefings do not.
1021
+ There is no `oats` command for it.
1022
+
1023
+ `<clone>` is any clone of the deployment (`oats-local.yaml` `clones:`, or
1024
+ `<deployment>/<repo>`), `origin` is its remote for that repository, and
1025
+ `<base>` is the remote branch the work starts from (the branch itself, to
1026
+ rework an existing one):
1027
+
1028
+ ```bash
1029
+ git -C <clone> worktree add --detach "$OATS_INSTANCE_HOME/.work-<purpose>"
1030
+ git -C "$OATS_INSTANCE_HOME/.work-<purpose>" fetch --refmap= origin <base>
1031
+ git -C "$OATS_INSTANCE_HOME/.work-<purpose>" switch -c <branch> FETCH_HEAD
1032
+ ```
1033
+
1034
+ - The tree starts from the remote's current state, never from a local branch
1035
+ of the clone, which may be stale.
1036
+ - Creating it moves none of the clone's refs. The fetch runs inside the new
1037
+ linked tree, which has its own `FETCH_HEAD`, and `--refmap=` keeps it from
1038
+ updating remote-tracking refs. The clone's `FETCH_HEAD`, branches and work
1039
+ tree are not touched, so creation does not race with others who use the
1040
+ clone. The fetched objects go to the repository's shared object store.
1041
+ - `git switch` needs Git 2.23 or later.
1042
+ - `<branch>` follows the repository's own naming rules, else
1043
+ `agents/<instance>-<purpose>`. If that branch already exists in the clone,
1044
+ `switch -c` refuses: use `<instance>/<branch>`. Never `-C` or `-B`, which
1045
+ reset a branch someone else may own.
1046
+ - The tree has no upstream. Push with `git push origin HEAD:<remote-branch>`
1047
+ (`<base>` when reworking an existing branch). A push does update the
1048
+ clone's `refs/remotes/origin/<remote-branch>`, as any push does.
1049
+ - Before the task closes, merge each extra tree into the PR branch, or push
1050
+ its branch and name it in the hand-back; then
1051
+ `git -C <clone> worktree remove "$OATS_INSTANCE_HOME/.work-<purpose>"`.
1052
+
1053
+ `$OATS_INSTANCE_HOME` is set in every harness session OATS launches (Claude
1054
+ Code, Codex, pi), not only in hooks. Retirement removes a clean extra tree
1055
+ and re-homes one that holds work, but the briefing tells the agent not to
1056
+ rely on it: see [extra trees at retire](#extra-trees-at-retire).
1057
+
938
1058
  ## Agents root
939
1059
 
940
1060
  Instance homes live under the deployment's agents root,
@@ -26,9 +26,10 @@ root, and not the work tree. Anything that says "your home" means this directory
26
26
  **`<instance-home>/work` is your repository or workspace view** — whatever your
27
27
  work mode grants you of the code.
28
28
 
29
- - **Repository work happens there and only there**: reading, editing, building,
30
- testing, git and commits, on repository content. Never from the main checkout
31
- or from your home root.
29
+ - **Repository work happens there, or in the extra trees your mode block
30
+ grants, and nowhere else**: reading, editing, building, testing, git and
31
+ commits, on repository content. Never from the main checkout or from your
32
+ home root, beyond what your mode block names.
32
33
  - **What your mode permits is the mode block's call**, immediately below. Some
33
34
  modes are read-only, some share a tree with others, and that block is the
34
35
  authority on which operations are yours to perform.
@@ -7,6 +7,28 @@ in the same tree as the human and possibly other agents.
7
7
  explicitly asked.**
8
8
  - No destructive git operations (reset --hard, rebase, force-push, checkout
9
9
  of another branch) without an explicit human instruction.
10
- - This mode fits integrator/coordinator/advisory roles operating on the
11
- repo's *current state*; if your task needs its own branch, ask your human
12
- for a worktree-mode instance instead.
10
+ - Work that needs its own branch goes in an extra tree, not in `work/`.
11
+
12
+ ### Extra trees
13
+
14
+ When the work needs another branch, or another repository of this deployment,
15
+ create an extra tree in your home. `<clone>` is any clone of this deployment
16
+ (`oats-local.yaml` `clones:`, or `<deployment>/<repo>`); `origin` is its remote
17
+ for that repository; `<base>` is the remote branch the work starts from (the
18
+ branch itself, when you rework an existing one):
19
+
20
+ git -C <clone> worktree add --detach "$OATS_INSTANCE_HOME/.work-<purpose>"
21
+ git -C "$OATS_INSTANCE_HOME/.work-<purpose>" fetch --refmap= origin <base>
22
+ git -C "$OATS_INSTANCE_HOME/.work-<purpose>" switch -c <branch> FETCH_HEAD
23
+
24
+ - This starts from the remote's current state and moves none of the clone's
25
+ refs. Never start from a local branch of the clone, which may be stale.
26
+ - Name `<branch>` by the repository's own rules, else `agents/<instance>-<purpose>`.
27
+ If it already exists in that clone, `switch -c` refuses: use `<instance>/<branch>`.
28
+ Never `-C`/`-B`, which reset a branch someone else may own.
29
+ - The tree has no upstream: push with `git push origin HEAD:<remote-branch>`
30
+ (`<base>` when you rework an existing branch).
31
+ - Before your task closes, merge each extra tree into your PR branch, or push its
32
+ branch and name it in your hand-back; then
33
+ `git -C <clone> worktree remove "$OATS_INSTANCE_HOME/.work-<purpose>"`.
34
+ Retirement keeps a tree that still holds uncommitted work, but don't rely on it.
@@ -3,11 +3,34 @@
3
3
  Your `./work` is a **git worktree on your own branch** — a full checkout that is
4
4
  yours alone: build, test and commit there, on your branch.
5
5
 
6
- - **Never run git from the repo's main checkout**: it resolves to the wrong
7
- branch and skips review. If unsure, `pwd`.
8
- - Everything you change happens in `work/` — **including your own soul** when
9
- it lives in this repo: soul edits are branch changes, reviewed and merged
10
- like code.
11
- - Don't create extra worktrees; `work/` is your one tree. Parallel work means
12
- your human spawns another instance.
6
+ - **Never work in a shared checkout** (the repo's main checkout, or any clone
7
+ others use): don't edit, commit or switch branches there. Against a clone you
8
+ run only `worktree add`/`remove` below. If unsure, `pwd`.
9
+ - Everything you change happens in `work/` or your extra trees — **including your
10
+ own soul** when it lives in this repo: soul edits are branch changes, reviewed
11
+ and merged like code.
13
12
  - Leave your branch and the worktree list clean when your task closes.
13
+
14
+ ### Extra trees
15
+
16
+ When the work needs another branch, or another repository of this deployment,
17
+ create an extra tree in your home. `<clone>` is any clone of this deployment
18
+ (`oats-local.yaml` `clones:`, or `<deployment>/<repo>`); `origin` is its remote
19
+ for that repository; `<base>` is the remote branch the work starts from (the
20
+ branch itself, when you rework an existing one):
21
+
22
+ git -C <clone> worktree add --detach "$OATS_INSTANCE_HOME/.work-<purpose>"
23
+ git -C "$OATS_INSTANCE_HOME/.work-<purpose>" fetch --refmap= origin <base>
24
+ git -C "$OATS_INSTANCE_HOME/.work-<purpose>" switch -c <branch> FETCH_HEAD
25
+
26
+ - This starts from the remote's current state and moves none of the clone's
27
+ refs. Never start from a local branch of the clone, which may be stale.
28
+ - Name `<branch>` by the repository's own rules, else `agents/<instance>-<purpose>`.
29
+ If it already exists in that clone, `switch -c` refuses: use `<instance>/<branch>`.
30
+ Never `-C`/`-B`, which reset a branch someone else may own.
31
+ - The tree has no upstream: push with `git push origin HEAD:<remote-branch>`
32
+ (`<base>` when you rework an existing branch).
33
+ - Before your task closes, merge each extra tree into your PR branch, or push its
34
+ branch and name it in your hand-back; then
35
+ `git -C <clone> worktree remove "$OATS_INSTANCE_HOME/.work-<purpose>"`.
36
+ Retirement keeps a tree that still holds uncommitted work, but don't rely on it.
package/lib/core.mjs CHANGED
@@ -71,7 +71,7 @@ export { exactTreeDigest, fingerprintTree, KERNEL_HOME_RECEIPTS };
71
71
  import { canonicalJson, lineAt, parseStrictJson } from "./canonical-json.mjs";
72
72
  import { readPortableBytes } from "./bounded-read.mjs";
73
73
  import { copyTreeSafe } from "./tree-copy.mjs";
74
- import { assertSameWorktreeHead, headName, worktreeCommitUnreached, worktreeHead } from "./instance-git.mjs";
74
+ import { assertSameWorktreeHead, gitRead, gitRepoRead, gitRepoRun, headName, worktreeCommitUnreached, worktreeHead } from "./instance-git.mjs";
75
75
  /** The package and capability id grammar (namespaced, lowercase): an id names a
76
76
  * directory (a home's module copy), so no path spelling fits it. */
77
77
  const PACKAGE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
@@ -5141,18 +5141,123 @@ function baselineDisposableHome(baseline) {
5141
5141
  * that pass all use this one set. → { excludeRoot: Set (work/ and the covered
5142
5142
  * names), notCopied: [{ scope: "home", path, owner }] sorted by path }: names
5143
5143
  * and owners only. Two owners of one entry: the first in owner order. */
5144
- function resolveHomeExclusions(home, disposableHome = []) {
5144
+ function resolveHomeExclusions(home, disposableHome = [], extraTrees = []) {
5145
5145
  const excludeRoot = new Set(["work"]);
5146
5146
  const notCopied = [];
5147
- if (!disposableHome.length) return { excludeRoot, notCopied };
5147
+ const extra = new Set(extraTrees.map((t) => t.name));
5148
+ if (!disposableHome.length && !extra.size) return { excludeRoot, notCopied };
5148
5149
  for (const name of readdirSync(home).sort(byCodeUnit)) {
5149
- const row = name === "work" ? undefined : disposableHome.find((r) => disposableHomeRootMatches(r.root, name));
5150
- if (!row) continue;
5150
+ const owner = extra.has(name) ? EXTRA_WORKTREE_OWNER : name === "work" ? undefined : disposableHome.find((r) => disposableHomeRootMatches(r.root, name))?.owner;
5151
+ if (!owner) continue;
5151
5152
  excludeRoot.add(name);
5152
- notCopied.push({ scope: "home", path: name, owner: row.owner });
5153
+ notCopied.push({ scope: "home", path: name, owner });
5153
5154
  }
5154
5155
  return { excludeRoot, notCopied };
5155
5156
  }
5157
+
5158
+ /** The `notCopied` owner of a verified extra tree: the retire's extra-tree
5159
+ * step handles it, not the home recovery. */
5160
+ const EXTRA_WORKTREE_OWNER = "kernel:extra-worktree";
5161
+ /** `git worktree list --porcelain -z` → [{ worktree, locked }]: `locked` is
5162
+ * the lock's reason ("" when it gives none), undefined when not locked. */
5163
+ function parseWorktreeList(out) {
5164
+ const records = [];
5165
+ for (const field of out.split("\0")) {
5166
+ if (field.startsWith("worktree ")) records.push({ worktree: field.slice("worktree ".length) });
5167
+ else if (records.length && (field === "locked" || field.startsWith("locked "))) records.at(-1).locked = field.slice("locked ".length);
5168
+ }
5169
+ return records;
5170
+ }
5171
+ /** The home's extra trees (awebai/oats#674), in every work mode: top-level
5172
+ * entries named `.work-*` that are real directories (not symlinks), whose
5173
+ * `.git` is a regular file, and that Git confirms are registered linked
5174
+ * worktrees of a repository outside the home: the git dir differs from the
5175
+ * common dir, the toplevel is the entry, and the repository's worktree list
5176
+ * names it. The repository is that list's first entry (its main worktree, or
5177
+ * the bare dir), as for the primary checkout (canonicalDeploymentPath).
5178
+ * Anything else named `.work-*` is ordinary home bytes. Read-only probes
5179
+ * (gitRead). → [{ name, path, repo, gitDir, commonDir, locked }] sorted by
5180
+ * name: `commonDir` is the repository's Git directory, which the step and
5181
+ * the reachability read name it by (bare or not). */
5182
+ function extraWorktreesOf(home) {
5183
+ const trees = [];
5184
+ let names;
5185
+ try { names = readdirSync(home); } catch { return trees; }
5186
+ const realHome = realPathOrNearest(home);
5187
+ const inHome = (p) => { const rel = relative(realHome, realPathOrNearest(p)); return rel === "" || !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)); };
5188
+ for (const name of names.sort(byCodeUnit)) {
5189
+ if (!name.startsWith(".work-")) continue;
5190
+ const path = join(home, name);
5191
+ try {
5192
+ if (!lstatSync(path).isDirectory() || !lstatSync(join(path, ".git")).isFile()) continue;
5193
+ } catch { continue; }
5194
+ const dirs = gitRead(path, ["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir", "--show-toplevel"]);
5195
+ if (!dirs.ok) continue;
5196
+ const [gitDir, commonDir, toplevel] = dirs.out.split("\n");
5197
+ if (!gitDir || !commonDir || !toplevel || realPathOrNearest(gitDir) === realPathOrNearest(commonDir)) continue;
5198
+ const real = realPathOrNearest(path);
5199
+ // A repository inside the home goes with the home: its trees are home bytes.
5200
+ if (realPathOrNearest(toplevel) !== real || inHome(commonDir)) continue;
5201
+ const list = gitRead(path, ["worktree", "list", "--porcelain", "-z"]);
5202
+ if (!list.ok) continue;
5203
+ const records = parseWorktreeList(list.out);
5204
+ const self = records.find((r) => realPathOrNearest(r.worktree) === real);
5205
+ if (!records.length || !self) continue;
5206
+ trees.push({ name, path, repo: records[0].worktree, gitDir, commonDir: realPathOrNearest(commonDir), locked: self.locked });
5207
+ }
5208
+ return trees;
5209
+ }
5210
+ /** Where a retired worktree is re-homed: `<workspace>/.agents/worktrees/<repoName>/<leaf>`,
5211
+ * the leaf being the branch, else `detached-<commit>`; when that exists (or
5212
+ * `taken` holds it), `<leaf>-2`, `-3`, … The one naming rule of work/ and of
5213
+ * the extra trees. Creates nothing. */
5214
+ function retainedWorktreeDest(workspace, repo, branch, commit, taken = new Set()) {
5215
+ const repoName = basename(realPathOrNearest(repo)).replace(/\.git$/, "") || "repo";
5216
+ const leaf = (branch ?? `detached-${(commit || "unknown").slice(0, 12)}`).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "work";
5217
+ const retainedRoot = join(workspace, ".agents", "worktrees", repoName);
5218
+ let dest = join(retainedRoot, leaf);
5219
+ for (let n = 2; existsSync(dest) || taken.has(dest); n++) dest = join(retainedRoot, `${leaf}-${n}`);
5220
+ taken.add(dest);
5221
+ return dest;
5222
+ }
5223
+ /** What retirement does with each extra tree (`trees`: extraWorktreesOf) →
5224
+ * [{ path, repo, branch, detachedAt, disposition, movedTo, reason }], one row
5225
+ * per tree in order: the retire plan's `extraWorktrees` and the binding the
5226
+ * retire checks before it acts. `remove` when the tree is clean: an empty
5227
+ * status (ignored and untracked files count), no operation in progress, and
5228
+ * a HEAD commit some ref of the repository reaches: its shared refs, never
5229
+ * the tree's own HEAD, reflog or refs/worktree/, which go with its admin
5230
+ * entry when it is removed. `retain` (to `movedTo`) otherwise, with why
5231
+ * in `reason`; a HEAD that cannot be read is not clean. `refuse` for a locked
5232
+ * tree, which Git will neither move nor remove. Read-only. */
5233
+ function extraWorktreeRows(trees, root) {
5234
+ const taken = new Set();
5235
+ return trees.map((tree) => {
5236
+ let name = null, commit = null;
5237
+ try { name = headName(tree.path); } catch { /* not clean, below */ }
5238
+ try { commit = worktreeHead(tree.path).commit; } catch { /* not clean, below */ }
5239
+ const row = { path: tree.path, repo: tree.repo, branch: name?.branch ?? null, detachedAt: name?.detached ? commit : null, disposition: "remove", movedTo: null, reason: null };
5240
+ if (tree.locked !== undefined) return { ...row, disposition: "refuse", reason: `it is locked${tree.locked ? ` (${tree.locked})` : ""}; unlock it with \`git worktree unlock\`, or move it out of the home, then retire again` };
5241
+ const why = [];
5242
+ if (!name || !commit) why.push("its HEAD could not be read");
5243
+ const status = gitRead(tree.path, ["status", "--porcelain", "-z", "--ignored", "--untracked-files=all", "--ignore-submodules=none"]);
5244
+ if (!status.ok) why.push(`its status could not be read (${status.err})`);
5245
+ else if (status.out.length) why.push("it holds uncommitted, untracked or ignored files");
5246
+ if (RECOVERABLE_GIT_ADMIN.some((n) => existsSync(join(tree.gitDir, n)))) why.push("an operation is in progress in it");
5247
+ if (commit) {
5248
+ const reach = gitRepoRead(tree.commonDir, ["for-each-ref", "--contains", commit, "--count=1", "--format=%(objectname)"]);
5249
+ if (!reach.ok) why.push(`which refs reach its HEAD commit could not be read (${reach.err})`);
5250
+ else if (!reach.out.length) why.push("its HEAD commit is reached by no ref");
5251
+ }
5252
+ if (!why.length) return row;
5253
+ return { ...row, disposition: "retain", movedTo: retainedWorktreeDest(workspaceOf(root), tree.repo, row.branch, commit, taken), reason: why.join("; ") };
5254
+ });
5255
+ }
5256
+ /** The retire plan's view of a home's extra trees: extraWorktreeRows of what
5257
+ * is there now. */
5258
+ export function extraWorktreePlan(home, root) {
5259
+ return extraWorktreeRows(extraWorktreesOf(home), root);
5260
+ }
5156
5261
  function unionNotCopied(...lists) {
5157
5262
  const byPath = new Map();
5158
5263
  for (const row of lists.flatMap((list) => list || [])) if (!byPath.has(row.path)) byPath.set(row.path, row);
@@ -6414,9 +6519,11 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
6414
6519
  }
6415
6520
  const baselineValid = retirementBaselineValid(baseline, home);
6416
6521
  // This pass's one resolved exclusion set: provider-owned entries the spawn
6417
- // baseline declared. Every home fingerprint here and the copy made from this
6418
- // observation use it; without a valid baseline there is none.
6419
- const homeExclusions = resolveHomeExclusions(home, baselineValid ? baselineDisposableHome(baseline) : []);
6522
+ // baseline declared, and the home's verified extra trees (whatever the
6523
+ // baseline), which the retire's extra-tree step handles from this same set.
6524
+ // Every home fingerprint here and the copy made from this observation use it.
6525
+ const extraTrees = extraWorktreesOf(home);
6526
+ const homeExclusions = resolveHomeExclusions(home, baselineValid ? baselineDisposableHome(baseline) : [], extraTrees);
6420
6527
  // One walk of the home, two digests: the stored one for the comparison with
6421
6528
  // the baseline, which must not see the kernel's own fields of instance.json,
6422
6529
  // and the exact one for the comparison after the hooks, which must see
@@ -6504,7 +6611,7 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
6504
6611
  .update("\0").update(unreached ? `${unreached.head.commit}\0${unreached.unreached ? "unreached" : "reached"}\0` : "")
6505
6612
  .update(unreached?.head.ref ?? "")
6506
6613
  .digest("hex");
6507
- return { classes: [...new Set(classes)], home, work, directory, orphanedWork, worktree, workProvable, directoryFingerprint, homeBytes: homeDigests.exact, workFingerprint, homeExclude: homeExclusions.excludeRoot, notCopied: homeExclusions.notCopied, branchExists, head: unreached?.head, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
6614
+ return { classes: [...new Set(classes)], home, work, directory, orphanedWork, worktree, workProvable, directoryFingerprint, homeBytes: homeDigests.exact, workFingerprint, homeExclude: homeExclusions.excludeRoot, notCopied: homeExclusions.notCopied, extraTrees, branchExists, head: unreached?.head, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
6508
6615
  }
6509
6616
 
6510
6617
  function copyRecoveryTree(src, dest, { excludeRoot = new Set() } = {}) {
@@ -7020,7 +7127,8 @@ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
7020
7127
  const intent = {
7021
7128
  instance: name, agent: found.agent.name, root: resolve(root),
7022
7129
  requestedAt: new Date().toISOString(), requestedByPid: process.pid, delaySec,
7023
- options: { home: found.home, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
7130
+ // A confirmed plan's extra trees go with the intent, so the completion is bound by them as well.
7131
+ options: { home: found.home, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session, ...(Array.isArray(o.plannedExtraWorktrees) ? { plannedExtraWorktrees: o.plannedExtraWorktrees } : {}) }, resultPath,
7024
7132
  };
7025
7133
  const env = { ...process.env, OATS_RETIRE_INTENT: JSON.stringify(intent) };
7026
7134
  for (const k of CORE_LAUNCH_ENV) delete env[k];
@@ -7098,6 +7206,7 @@ export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
7098
7206
  try {
7099
7207
  result = retireInstance(intent.root, intent.instance, {
7100
7208
  home: intent.options?.home, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
7209
+ ...(Array.isArray(intent.options?.plannedExtraWorktrees) ? { plannedExtraWorktrees: intent.options.plannedExtraWorktrees } : {}),
7101
7210
  ...(obsoleteDeleteBranch ? { [OBSOLETE_DELETE_BRANCH]: true } : {}),
7102
7211
  });
7103
7212
  } catch (e) {
@@ -7241,6 +7350,16 @@ export function retireInstance(root, name, o = {}) {
7241
7350
  // Whether this retire removes the worktree, when it gets to that step.
7242
7351
  const worktreeRemoval = { removes: !!(o.discardWorktree || owesWorktree), repo: meta.repo };
7243
7352
  const initialObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
7353
+ // A locked extra tree is known from the home as it is now: Git will neither move nor remove it, so a retire that
7354
+ // would remove the home refuses here, before the session is stopped and before any retire hook runs (the hooks
7355
+ // revoke identities a kept home would still need). The extra-tree step keeps its own check, for a lock that appears
7356
+ // during the hooks. --force does not bypass it: it covers hook debt, not local work.
7357
+ if (!o.keepDir) {
7358
+ const locked = initialObservation.extraTrees.filter((t) => t.locked !== undefined);
7359
+ if (locked.length) {
7360
+ throw oatsError("E_WORK_PRESERVATION_FAILED", `${name}: ${locked.map((t) => `the extra worktree ${t.path} is locked${t.locked ? ` (${t.locked})` : ""}`).join("; ")}; Git will neither move nor remove a locked worktree. Unlock it with \`git worktree unlock\`, or move it out of the home, then retire again; nothing was run or removed`);
7361
+ }
7362
+ }
7244
7363
  // Harness identity is destructive authority. The mutable child metadata may
7245
7364
  // describe it for humans, but only the independent baseline can authorize the
7246
7365
  // endpoint that proves quiescence.
@@ -7484,6 +7603,54 @@ export function retireInstance(root, name, o = {}) {
7484
7603
  // A failed spawn's quarantine that owes the worktree removes it; any other retire retains it unless
7485
7604
  // --discard-worktree. An orphaned work directory is never touched.
7486
7605
  const outstandingBeforeWorktree = quarantine ? retryFailures : ordinaryIncomplete;
7606
+ // The home's extra trees (awebai/oats#674), in every work mode, before the
7607
+ // work/ step so that a refusal here leaves work/ as it is. Only when the
7608
+ // home is going to be removed: not under --keep-dir, and not when it is
7609
+ // kept for a retry (the work/ step's condition). The trees are the final
7610
+ // inspection's verified set, the one its recovery left out of the home's
7611
+ // bytes. A clean tree is removed (its branch stays in its repository); any
7612
+ // other is re-homed like work/; a locked one, or one Git will not move or
7613
+ // remove, refuses and keeps the home, --force included: it covers hook
7614
+ // debt, not local work. --discard-worktree does not apply. An applied plan
7615
+ // (`plannedExtraWorktrees`) binds what is done: trees that no longer read
7616
+ // as planned refuse as stale before any of them is touched.
7617
+ let extraWorktrees;
7618
+ if (!o.keepDir && !(outstandingBeforeWorktree.length > 0 && !o.force)) {
7619
+ const rows = extraWorktreeRows(finalObservation.extraTrees, root);
7620
+ if (o.plannedExtraWorktrees && JSON.stringify(rows) !== JSON.stringify(o.plannedExtraWorktrees)) {
7621
+ throw oatsError("E_PLAN_STALE", `${name}: the home's extra worktrees changed since the retire plan was shown, so none of them was moved or removed. The retire hooks have run; the home and its work are kept. Review the fresh plan (\`oats retire ${name} --plan\`) and apply it again.`);
7622
+ }
7623
+ const refused = rows.filter((r) => r.disposition === "refuse");
7624
+ if (refused.length) {
7625
+ throw oatsError("E_WORK_PRESERVATION_FAILED", `${name}: ${refused.map((r) => `the extra worktree ${r.path}: ${r.reason}`).join("; ")}. No extra worktree was moved or removed and work/ is untouched; the retire hooks have run and the home is kept so nothing is lost.`);
7626
+ }
7627
+ extraWorktrees = [];
7628
+ const done = () => extraWorktrees.length ? ` Already done in this retire: ${extraWorktrees.map((r) => r.outcome === "removed" ? `${r.path} removed` : `${r.path} re-homed to ${r.movedTo}`).join(", ")}.` : "";
7629
+ // Each row's repository, by its Git directory: the commands run helper-free (gitRepoRun).
7630
+ const gitDirOf = new Map(finalObservation.extraTrees.map((tree) => [tree.path, tree.commonDir]));
7631
+ const git = (row, argv) => gitRepoRun(gitDirOf.get(row.path), argv);
7632
+ const failed = (row, what, e) => oatsError("E_WORK_PRESERVATION_FAILED", `${name}: the extra worktree ${row.path} ${what} (${String(e?.stderr ?? e?.message ?? e ?? "").trim()}).${done()} work/ is untouched; the retire hooks have run and the home is kept so nothing is lost — resolve and retry.`);
7633
+ for (const row of rows) {
7634
+ if (row.disposition === "remove") {
7635
+ try {
7636
+ git(row, ["worktree", "remove", row.path]);
7637
+ git(row, ["worktree", "prune"]);
7638
+ const real = realPathOrNearest(row.path);
7639
+ if (existsSync(row.path) || parseWorktreeList(git(row, ["worktree", "list", "--porcelain", "-z"])).some((r) => realPathOrNearest(r.worktree) === real)) throw new Error("it is still there after `git worktree remove`");
7640
+ } catch (e) { throw failed(row, "could not be removed, or its removal could not be verified", e); }
7641
+ extraWorktrees.push({ ...row, outcome: "removed" });
7642
+ appendEvent(found.home, { kind: "worktree-removed", data: { branch: row.branch, extra: true, path: row.path } }, { workspaceOnly: true });
7643
+ } else {
7644
+ try {
7645
+ mkdirSync(dirname(row.movedTo), { recursive: true });
7646
+ git(row, ["worktree", "move", row.path, row.movedTo]);
7647
+ } catch (e) { throw failed(row, `could not be re-homed to ${row.movedTo}`, e); }
7648
+ extraWorktrees.push({ ...row, outcome: "retained" });
7649
+ appendEvent(found.home, { kind: "worktree-retained", data: { movedTo: row.movedTo, branch: row.branch, recordedBranch: null, extra: true, path: row.path } }, { workspaceOnly: true });
7650
+ }
7651
+ }
7652
+ if (!extraWorktrees.length) extraWorktrees = undefined;
7653
+ }
7487
7654
  const worktreeStep = isWorktree && !!meta.repo && !orphanedWork;
7488
7655
  const worktreeDeferred = worktreeStep && existsSync(workPath) && outstandingBeforeWorktree.length > 0 && !o.force;
7489
7656
  const keptForRetry = worktreeDeferred ? `git worktree ${workPath}: kept for the retry; outstanding: ${outstandingBeforeWorktree.join("; ")}` : null;
@@ -7507,12 +7674,8 @@ export function retireInstance(root, name, o = {}) {
7507
7674
  shTry(`git -C ${shq(meta.repo)} worktree prune`);
7508
7675
  retention = { worktree: "removed", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
7509
7676
  } else if (existsSync(workPath)) {
7510
- const repoName = basename(realPathOrNearest(meta.repo)).replace(/\.git$/, "") || "repo";
7511
- const leaf = (verifiedBranch ?? `detached-${(ref.commit || "unknown").slice(0, 12)}`).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "work";
7512
- const retainedRoot = join(workspaceOf(root), ".agents", "worktrees", repoName);
7513
- mkdirSync(retainedRoot, { recursive: true });
7514
- let dest = join(retainedRoot, leaf);
7515
- for (let n = 2; existsSync(dest); n++) dest = join(retainedRoot, `${leaf}-${n}`);
7677
+ const dest = retainedWorktreeDest(workspaceOf(root), meta.repo, verifiedBranch, ref.commit);
7678
+ mkdirSync(dirname(dest), { recursive: true });
7516
7679
  try {
7517
7680
  execFileSync("git", ["-C", meta.repo, "worktree", "move", workPath, dest], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
7518
7681
  } catch (e) {
@@ -7623,7 +7786,7 @@ export function retireInstance(root, name, o = {}) {
7623
7786
  rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
7624
7787
  }
7625
7788
 
7626
- const result = { retired: name, agent: found.agent.name, workRecovery, retention, worktreeRemoved: isWorktree && !!retention && retention.worktree !== "retained", branchDeleted: false, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
7789
+ const result = { retired: name, agent: found.agent.name, workRecovery, retention, extraWorktrees, worktreeRemoved: isWorktree && !!retention && retention.worktree !== "retained", branchDeleted: false, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
7627
7790
  const w = [...(hookResults?.warnings || [])];
7628
7791
  if (o[OBSOLETE_DELETE_BRANCH]) w.push(OBSOLETE_DELETE_BRANCH_SENTENCE);
7629
7792
  if (isCapturedHome(meta) && !quarantine) {
@@ -43,6 +43,28 @@ function git(cwd, argv, { allowFail = false, input, diffExit = false } = {}) {
43
43
  }
44
44
  }
45
45
  const trim = (s) => (s === null ? null : s.trim());
46
+ /** One helper-free Git probe run like the module's other reads
47
+ * (READ_ONLY_GIT, gitEnv, argv, no shell) → { ok, out, err }. `out` is text.
48
+ * Never throws: the caller decides what a failure means. `cwd`: the tree
49
+ * `-C` names; gitRepoRead names the repository by its Git directory instead. */
50
+ export function gitRead(cwd, argv) { return gitProbe(["-C", cwd], argv); }
51
+ /** gitRead against the repository whose (common) Git directory is `gitDir`,
52
+ * named explicitly (`--git-dir`), so that a bare repository is read too
53
+ * (READ_ONLY_GIT sets safe.bareRepository=explicit). */
54
+ export function gitRepoRead(gitDir, argv) { return gitProbe(["--git-dir", gitDir], argv); }
55
+ /** A Git command that changes the repository whose Git directory is `gitDir`
56
+ * (a worktree move, remove or prune), under the same discipline as the
57
+ * reads: the repository's own configuration names no helper that runs
58
+ * (fsmonitor, hooks, external diff; they reach the commands Git starts for
59
+ * it through GIT_CONFIG_PARAMETERS), and nothing of the caller's Git
60
+ * environment is passed. → stdout; throws the error execFileSync throws. */
61
+ export function gitRepoRun(gitDir, argv) {
62
+ return execFileSync("git", ["--git-dir", gitDir, ...READ_ONLY_GIT, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, shell: false, timeout: 120_000, env: gitEnv() });
63
+ }
64
+ function gitProbe(where, argv) {
65
+ const r = spawnSync("git", [...where, ...READ_ONLY_GIT, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, shell: false, timeout: 30_000, env: gitEnv() });
66
+ return !r.error && r.status === 0 ? { ok: true, out: r.stdout } : { ok: false, out: "", err: String(r.error?.message ?? r.stderr ?? "").trim() || `git ${argv[0]} exited with ${r.status}` };
67
+ }
46
68
 
47
69
  const BRANCH_REFS = Buffer.from("refs/heads/");
48
70
  /** The two reads of HEAD, as bytes. They run like the module's other reads:
@@ -10,7 +10,7 @@
10
10
  import { createHash } from "node:crypto";
11
11
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { basename, join } from "node:path";
13
- import { findInstanceHomes, inspectInstanceSession, listAgents, listInstances, observeSessionWithoutReceipt, retirePendingMarkerPath, retirementRecoveryFacts, stopInstanceSession } from "./core.mjs";
13
+ import { extraWorktreePlan, findInstanceHomes, inspectInstanceSession, listAgents, listInstances, observeSessionWithoutReceipt, retirePendingMarkerPath, retirementRecoveryFacts, stopInstanceSession } from "./core.mjs";
14
14
  import { groupedByOwner } from "./retire-output.mjs";
15
15
  import { observeInstanceGit } from "./instance-git.mjs";
16
16
  import { appendEvent } from "./instance-events.mjs";
@@ -165,6 +165,13 @@ function writeFileSyncAtomic(path, value) {
165
165
  writeFileSync(tmp, JSON.stringify(value, null, 2)); renameSync(tmp, path);
166
166
  }
167
167
 
168
+ /** A retire plan's note for one extra tree (core extraWorktreePlan row). */
169
+ function extraWorktreeNote(t) {
170
+ const at = t.branch ? `branch ${t.branch}` : t.detachedAt ? `detached at ${t.detachedAt.slice(0, 12)}` : "a HEAD that could not be read";
171
+ if (t.disposition === "remove") return `extra worktree ${t.path} (${at}) is clean and would be removed; its branch and commits stay in ${t.repo}`;
172
+ if (t.disposition === "retain") return `extra worktree ${t.path} (${at}) would be re-homed to ${t.movedTo}: ${t.reason}`;
173
+ return `extra worktree ${t.path} (${at}): retire refuses, ${t.reason}`;
174
+ }
168
175
  /** How many declared roots a retire plan's recovery note lists before `, and N more`. */
169
176
  const PLAN_NOT_COPIED_CAP = 16;
170
177
  /** Facts for Remove (retire): what retirement would touch, with the design's
@@ -178,12 +185,19 @@ export function planRetire(ctx, root, name, { home } = {}) {
178
185
  const target = targetFacts({ ...me, instance: name, depth: 0 });
179
186
  target.session = retireSessionFacts(me.home, target.session);
180
187
  const meta = readJson(join(me.home, "instance.json")) || {};
188
+ // The home's extra trees and what retire does with each: listed, and bound
189
+ // into the revision (below), so that an apply refuses as stale when a tree
190
+ // was created, removed, dirtied or cleaned, or its target was taken.
191
+ const extraWorktrees = extraWorktreePlan(me.home, me.root);
181
192
  const facts = { session: target.session, work: target.work, workMode: meta.work ?? null, repo: meta.repo ?? null, recordedBranch: meta.branch ?? null,
193
+ extraWorktrees,
182
194
  children: kids.map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, session: sessionFacts(c.home) })),
183
195
  ambiguous: (kids.ambiguous || []).map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, reason: c.reason })),
184
196
  pullRequest: "unknown" /* forge facts are the ADE's (P1); the kernel never claims 'no PR' */ };
185
197
  const defaults = { retainWorktree: meta.work === "worktree", deleteBranch: false, stopChildren: true, retainChildren: true };
186
- const safety = [me.home, target.session.state, target.launched, facts.work.observed ? [facts.work.revision, facts.work.branch, facts.work.changed, facts.work.untracked] : null, kids.map((c) => c.home)];
198
+ const safety = [me.home, target.session.state, target.launched, facts.work.observed ? [facts.work.revision, facts.work.branch, facts.work.changed, facts.work.untracked] : null, kids.map((c) => c.home),
199
+ // Only when there are any: a home without extra trees keeps the revision it had.
200
+ ...(extraWorktrees.length ? [extraWorktrees] : [])];
187
201
  // What retire would preserve and where, read from the spawn baseline and the
188
202
  // work mode: nothing is hashed, and the plan revision does not depend on it.
189
203
  // The declared roots are listed as written, capped so one note stays short.
@@ -195,6 +209,7 @@ export function planRetire(ctx, root, name, { home } = {}) {
195
209
  notes: [
196
210
  ...(facts.work.observed && facts.work.drift ? [`the worktree is on ${facts.work.branch ?? (facts.work.detached ? "a detached HEAD" : "a ref OATS carries no branch name for")}, not the recorded ${facts.recordedBranch}`] : []),
197
211
  ...(facts.work.observed && facts.work.changed + facts.work.untracked > 0 ? [`${facts.work.changed} changed and ${facts.work.untracked} untracked file(s) would be retained with the worktree`] : []),
212
+ ...extraWorktrees.map(extraWorktreeNote),
198
213
  ...(kids.length ? [`${kids.length} recorded child instance(s) are stopped first (bounded SIGTERM, never escalated) and retained (their homes are not removed); a child still running after the grace refuses the retirement`] : []),
199
214
  ...((kids.ambiguous || []).length ? [`${kids.ambiguous.length} instance(s) record this name as parent but the name is not unique under this root; they are listed under ambiguous and NOT acted on`] : []),
200
215
  ...(target.session.note ? [target.session.established ? `session absent: ${target.session.note}; nothing needs quiescing` : `session not observably absent: ${target.session.note}; retire refuses until it is stopped`] : []),
@@ -1,6 +1,7 @@
1
1
  /** What `oats retire` says about the work it preserved. One function renders
2
2
  * the lines for the local and the remote path of bin/oats.mjs, from the
3
- * receipt's `workRecovery` (lib/core.mjs, retireInstance). A receipt from an
3
+ * receipt's `workRecovery` (lib/core.mjs, retireInstance); another renders
4
+ * its `extraWorktrees`. A receipt from an
4
5
  * older kernel, or a stored one, may carry `workRecoveries[]` instead: one
5
6
  * block is printed per entry. Dependency-free. */
6
7
 
@@ -48,3 +49,15 @@ export function workRecoveryLines(receipt, { host } = {}) {
48
49
  }
49
50
  return lines;
50
51
  }
52
+
53
+ /** The lines `oats retire` prints for the home's extra trees it handled
54
+ * (the receipt's `extraWorktrees`): one per tree, removed or re-homed. */
55
+ export function extraWorktreeLines(receipt, { host } = {}) {
56
+ const rows = Array.isArray(receipt?.extraWorktrees) ? receipt.extraWorktrees : [];
57
+ return rows.map((t) => {
58
+ const at = t.branch ? `branch ${t.branch}` : t.detachedAt ? `detached at ${t.detachedAt}` : "a HEAD that could not be read";
59
+ return t.outcome === "removed"
60
+ ? `Extra worktree ${t.path} removed (${at}); its branch and commits stay in ${t.repo}${host ? ` on ${host}` : ""}`
61
+ : `Extra worktree ${t.path} re-homed${host ? ` on ${host}` : ""} to ${t.movedTo} (${at}): ${t.reason}`;
62
+ });
63
+ }
package/lib/servers.mjs CHANGED
@@ -783,8 +783,11 @@ function listSnapshotServers() {
783
783
  * action authority. */
784
784
  /** The facts a remote row relays from the host's `status --json` row, as the
785
785
  * host reported them; a fact the host does not supply is null, never
786
- * derived here. */
787
- export const REMOTE_ROW_FACTS = ["identity", "identityAddress", "teams", "startedAt", "createdAt", "model", "runtimeState", "parentInstance", "siblingInstance", "relation", "relativeTo", "spawnOrigin"];
786
+ * derived here. `work`, `repo`, `branch` and `modelFrom` are the host's own
787
+ * record (`repo` is a path on the host, never read here); `soul` and `modules`
788
+ * carry the host's drift observation against its own members and lock, not
789
+ * recomputed here (#675). */
790
+ export const REMOTE_ROW_FACTS = ["identity", "identityAddress", "teams", "startedAt", "createdAt", "model", "runtimeState", "parentInstance", "siblingInstance", "relation", "relativeTo", "spawnOrigin", "work", "repo", "branch", "modelFrom", "soul", "modules"];
788
791
  const rowFacts = (i = {}) => Object.fromEntries(REMOTE_ROW_FACTS.map((k) => [k, i[k] ?? null]));
789
792
  /** `waitingOnYou` (feature waiting-on-you) is relayed ONLY when the host's row has the
790
793
  * key, so it is not one of REMOTE_ROW_FACTS: a host whose kernel predates the feature
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.42.0",
3
+ "version": "0.42.1",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",