@awebai/oats 0.41.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -421,7 +421,8 @@ model or GitHub: processing continues after the home is gone.
421
421
 
422
422
  Before any retire hook runs, retire preserves the instance's uncommitted and
423
423
  unmerged work: a verified recovery under `.oats-retirement/recovery/`, named in
424
- the summary. A worktree recovery is a standalone clone that carries the
424
+ the summary. One retire writes at most one recovery directory. A worktree
425
+ recovery is a standalone clone that carries the
425
426
  repository's local exclude rules (`info/exclude`, a configured
426
427
  `core.excludesFile`), its `info/attributes` and the settings that change what
427
428
  status reports (`core.fileMode`, `core.ignoreCase`, …), so its Git status
@@ -431,6 +432,315 @@ home. **`--force` does not skip work preservation.** It forces only past a
431
432
  missing or unusable cleanup marker and past incomplete hook cleanup
432
433
  ([capabilities.md](capabilities.md)).
433
434
 
435
+ The recovery holds the state before the retire hooks at its top level, and
436
+ what the hooks changed under `after-hooks/`:
437
+
438
+ ```text
439
+ <recovery>/
440
+ recovery.json
441
+ home/ the home before the retire hooks
442
+ repo/ or work/ the work before the retire hooks (worktree, directory)
443
+ after-hooks/
444
+ home/ the home again, only if a hook changed home bytes
445
+ repo/ or work/ the work again, unless it is proven unchanged
446
+ ```
447
+
448
+ After the hooks, retire copies again each part the hooks moved. The home
449
+ moved when its bytes did. The work is copied again unless it is proven
450
+ unchanged. A Git status with the same rows is not that proof: a hook can
451
+ rewrite a file that was already modified, and the row stays the same. In
452
+ directory mode the work state is the bytes of `work/`, with the permission
453
+ bits of each entry and of `work/` itself. In worktree mode it
454
+ is what a work copy of the worktree holds, each file read where the copy
455
+ reads it:
456
+
457
+ - its Git status, the ref its HEAD is on and the commit, and, when the copy
458
+ is made detached, the branch the repository's own HEAD is on;
459
+ - its index: the entries (what `git ls-files -s` lists, with the
460
+ skip-worktree and assume-unchanged marks), the resolve-undo records (what
461
+ `git ls-files --resolve-undo` lists) and the index file's permission bits;
462
+ - what the copy takes from the Git directories as files, each with its bytes
463
+ and its permission bits: the state of an operation in progress (a merge, a
464
+ rebase, a cherry-pick, a revert, a bisect), `info/attributes` and the
465
+ stash's log;
466
+ - its tags and its stash;
467
+ - its exclude rules (`core.excludesFile` with the file the copy reads for it,
468
+ and `info/exclude`) and the settings that change what `git status` reports
469
+ (`core.fileMode`, `core.ignoreCase`, `core.precomposeUnicode`,
470
+ `core.symlinks`, `core.autocrlf`, `core.eol`);
471
+ - the bytes and the permission bits of its files, Git metadata left out.
472
+
473
+ The rule is that the work is unchanged only if everything its copy would
474
+ carry is equal byte for byte, and anything that cannot be compared exactly
475
+ counts as changed. Two things bound it, and neither is a claim of byte
476
+ equality:
477
+
478
+ - **The index's derived data, a deliberate semantic exception.** The index
479
+ file also holds a cache of each file's stat data, which a read-only Git
480
+ command rewrites, and extensions derived from its entries. It is compared
481
+ by what it holds (its entries, its resolve-undo records and its mode), not
482
+ by its bytes.
483
+ - **The shared repository, a custody boundary.** The repository the worktree
484
+ belongs to stays where it is: its objects, its other branches and the
485
+ settings a clone of it is served under (a shallow boundary, grafts, hidden
486
+ refs) are not compared. That holds because no retire removes that
487
+ repository or deletes a branch, and a commit of the worktree that no ref
488
+ reaches is preserved before the worktree is removed.
489
+
490
+ The permission bits of the home directory and of the worktree directory
491
+ themselves are not compared: their copies are made in directories the copier
492
+ creates, which do not carry them.
493
+
494
+ Every part of that state is compared as its bytes, never as decoded text. A
495
+ ref name, a path in the index or in the status, a file name or the target of
496
+ a symbolic link need not be valid UTF-8, and two states that differ only in
497
+ such bytes are two states. This is what the proof compares, not what a copy
498
+ can hold: a recovery cannot hold a file whose name is not valid UTF-8. A
499
+ copy of a home or a worktree that has one fails, and the retire refuses with
500
+ nothing lost. A clean worktree that has one, with only the home to preserve,
501
+ needs no work copy and retires.
502
+
503
+ A worktree's tags, stash, exclude rules and settings are kept by the
504
+ repository it belongs to. They are part of the state because a work copy
505
+ carries them, so a tag or a stash made in that repository while the retire
506
+ hooks run adds a copy attempt. That repository's other branches are
507
+ not part of the state: they outlive the worktree, and a recovery does not
508
+ hold them. The one exception is a worktree whose copy is made detached (its
509
+ `HEAD` is detached, on a ref that is not a branch, or on a branch whose name
510
+ is not UTF-8): its copy holds the branch the `HEAD` of the repository it is
511
+ cloned from is on, so that branch is part of the state. When the retire removes the worktree, whether a ref that outlives
512
+ it reaches the commit `HEAD` is at is part of the state too: a hook that
513
+ deletes the one ref that reached it adds a copy attempt, though the
514
+ files, the status and `HEAD` did not move.
515
+
516
+ **A worktree that holds a repository is not provable.** A directory under
517
+ the worktree that holds a `.git` entry of any kind, a dangling symbolic link
518
+ included, makes the worktree not provable. One whose `.git` is a directory, a
519
+ file or a link that leads to one is a repository: one made by `git init` or
520
+ a clone, a submodule, or a linked worktree of another repository placed in
521
+ the work. One whose `.git` is a dangling symbolic link, or could not be
522
+ tested, is not read as a repository and counts all the same: the copy
523
+ carries such a link as a link, and no comparison reads it. A repository's
524
+ state is not compared:
525
+ the retire hooks run between the two copies and can change it in ways no read
526
+ of its state covers (its configuration, its objects, what a clone of it is
527
+ shown). So the work of such a worktree is in the pre-hook snapshot and is
528
+ copied again after the hooks, whatever they did. `afterHooks.work` is then
529
+ `true` on a successful completion: it says that a work copy was made after
530
+ the hooks, not that a hook changed the work. The copy after the hooks can
531
+ fail where the one before did not, because a hook changed the nested
532
+ repository; the retire then refuses with `E_WORK_PRESERVATION_FAILED` after
533
+ the hooks, and the home, the work and the recovery written before the hooks
534
+ are kept.
535
+
536
+ A worktree with a `.git` entry under it that OATS cannot read as a
537
+ repository (a dangling symbolic link, or a `.git` it cannot test) is never
538
+ home-only, and no class is added for it. So a change to the home alone can
539
+ now cause a work-copy attempt before the hooks that OATS 0.41 did not make,
540
+ and any failure of that attempt can refuse the retirement: it refuses with
541
+ `E_WORK_PRESERVATION_FAILED` before any retire hook runs, with no recovery
542
+ written and the home and the work kept. What the retire did before the copy
543
+ stays done, and the refusal says so: a launched instance's session has been
544
+ stopped by then.
545
+
546
+ Copied again is not "nothing is lost": a nested repository with its own Git
547
+ directory is removed with the worktree, and its copy is a clone. What the copy of a nested repository
548
+ holds of each kind:
549
+
550
+ | Of the nested repository | The copy holds |
551
+ |---|---|
552
+ | the branch `HEAD` is on | the branch and its commits |
553
+ | every other branch | its commits, without the branch name: `git fsck --unreachable` in the copy lists them |
554
+ | tags | the tags and what they name |
555
+ | the stash | its latest entry and the stash's log; the commits of older entries are not there |
556
+ | remote-tracking refs, notes, any other namespace | nothing beyond what a branch or a tag reaches |
557
+ | a repository inside it | its files, its Git directory included, as plain files |
558
+
559
+ A commit the copy holds without a name is lost to `git gc` in the copy.
560
+ `git fsck --unreachable` in the copy lists such commits, and
561
+ `git branch <name> <commit>` there gives one a name again. What the copier
562
+ cannot carry at all (a file whose name is not valid UTF-8, an entry that is
563
+ not a file, a directory or a symbolic link) refuses the copy, here as
564
+ anywhere.
565
+
566
+ **A worktree that cannot be proven unchanged.** Two things make a worktree
567
+ not provable:
568
+
569
+ - a repository under it (above);
570
+ - a read of the state that fails, while `git status` works: one of the Git
571
+ commands the list above is read with, or one of the files it is read from
572
+ (`info/attributes`, the stash's log, the index, an exclude file, the
573
+ operation state). A worktree whose path has a line feed in it is such a
574
+ case: Git prints its directories over more than one line. A read that
575
+ fails is never taken for "not set" or for "unchanged", and neither is a
576
+ file or directory that the retire cannot test for (no permission, for
577
+ example): only one that is not there is absent.
578
+
579
+ A worktree that is not provable always has its work in the pre-hook
580
+ snapshot, also when only the home has something to preserve, and the work is
581
+ copied again after the hooks whenever there is something to preserve. Its
582
+ recovery is therefore larger. With nothing to preserve it retires like any
583
+ other. The copy reads what the proof reads, so where the proof failed the
584
+ copy may fail too: the retire then refuses with `E_WORK_PRESERVATION_FAILED`
585
+ before any retire hook runs. No recovery was written and nothing was
586
+ deleted; the retire has already stopped the session of a launched instance
587
+ by then, the instance is not retired, and its home and work are kept.
588
+
589
+ A retire hook can leave the worktree in that state too. The snapshot before
590
+ the hooks was then taken of a provable worktree, and may hold the home only.
591
+ After the hooks the work is copied under `after-hooks/`, whether or not
592
+ anything in it moved. When that copy cannot be made, the retire refuses with
593
+ `E_WORK_PRESERVATION_FAILED` after the hooks have run, and the home, the
594
+ work and the recovery written before the hooks are all kept.
595
+
596
+ Every refusal of the copy made before the hooks, whatever its cause, ends by
597
+ saying what the retire has done by then: no retire hook has run, no recovery
598
+ was written and nothing was deleted, the instance is not retired, its home
599
+ and work are kept, and its session has been stopped, or this retire stopped
600
+ no session. A refusal after the hooks does not say that.
601
+
602
+ A snapshot that holds the home only (the work had nothing to preserve
603
+ before the hooks) has no work copy to stand for it. It gets the work under
604
+ `after-hooks/` when the hooks moved it, and also when they did not but
605
+ something beyond the home is there to preserve after them, such as a
606
+ retirement baseline that is gone.
607
+
608
+ The Git state is read at every inspection. When there is something to
609
+ preserve before the hooks, the files are read once before the recovery is
610
+ written, and a pre-hook copy of the work is verified against that read. They
611
+ are read at most once more after the hooks: to prove the work unchanged when
612
+ the Git state did not move, or to verify the copy when it did. With nothing
613
+ preserved before the hooks, nothing is compared: a
614
+ recovery is written after them whenever there is something to preserve.
615
+
616
+ A worktree whose `git status` fails refuses the retire with
617
+ `E_WORK_INSPECTION_FAILED` at the first inspection: no recovery was written,
618
+ nothing was deleted, and the retire has not stopped the instance's session.
619
+ A directory in place of `info/exclude`, or of the file `core.excludesFile`
620
+ names, is such a case: Git itself refuses to use it. Any other read of the
621
+ state that fails does not refuse here: it makes the worktree not provable,
622
+ as described above.
623
+
624
+ **A socket, a FIFO or a device file in the worktree.** An entry that is not
625
+ a file, a directory or a symbolic link has no bytes to read or to copy, and
626
+ Git prints no status row for it, so a worktree that holds one can read as
627
+ clean. Such an entry refuses the retire, at one of three points:
628
+
629
+ - **at the first inspection**, when the entry is where that inspection reads:
630
+ inside a directory that Git reports as ignored whole (unless a capability
631
+ declared it a disposable work root), in the `work/` of a directory
632
+ instance, or in the home itself. The code is `E_WORK_INSPECTION_FAILED`.
633
+ The message names the entry and says what to do, and ends there. The first
634
+ inspection runs before the retire stops the session: the session is still
635
+ running, no recovery was written and nothing was deleted;
636
+ - **before the hooks**, when the entry is anywhere else in a worktree and
637
+ there is something to preserve, with `E_WORK_INSPECTION_FAILED`: no hook
638
+ has run, no recovery was written and nothing was deleted, however often the
639
+ retire is retried. The message names the entry and says what to do: safely
640
+ stop the process or resource that owns it, or move the entry elsewhere,
641
+ then retry. The entry may be a live endpoint, so deleting it is not the
642
+ advice. The retire has already stopped the session of a launched instance
643
+ by then: the instance's session is stopped, the instance is not retired,
644
+ and its home is kept. The message says so, and says that this retire
645
+ stopped no session when the instance had none to stop. To continue, deal
646
+ with the entry and run `oats retire <instance>` again, or start the session
647
+ again in the same home with `oats session start --home <abs>`. A
648
+ self-retire (`--self`) is completed by its detached completion, which stops
649
+ the session and refuses in the same way; the refusal is recorded beside the
650
+ home, as described below. After a refused self-retire only `oats retire
651
+ <instance>` continues: `oats session start` refuses while the self-retire's
652
+ pending marker is there, and a retire clears it. Of the refusals at this
653
+ point, only that of `--self --keep-dir`, which stays in the calling
654
+ process, comes with the session still running;
655
+ - **after the hooks**, when a retire hook left the entry behind: the hooks
656
+ have run, and the home, the work and the recovery written before the hooks
657
+ are all kept. The code is `E_WORK_INSPECTION_FAILED` when the Git state is
658
+ as it was, and `E_WORK_PRESERVATION_FAILED` when the hook also moved the
659
+ Git state, because the copy then meets the entry before the files are
660
+ read; that message names the entry too.
661
+
662
+ A worktree that cannot be proven unchanged is copied without the read, so
663
+ there the refusal comes from the copy, as `E_WORK_PRESERVATION_FAILED`.
664
+
665
+ **A file in the worktree that cannot be read.** The same read refuses a file
666
+ it cannot read: one without read permission, or one over 2 GiB, which a
667
+ single read cannot take. Two kinds of file are read although they are nothing
668
+ to preserve: a tracked file that is unchanged, and a file under a work root
669
+ that a capability declared disposable (`retirement.disposable.work`). So a
670
+ retire that has only the home to preserve refuses for such a file, with
671
+ `E_WORK_INSPECTION_FAILED`, before any retire hook runs, with or without
672
+ `--force`: no recovery was written and nothing was deleted. As for a socket
673
+ or a FIFO refused before the hooks, the retire has already stopped the
674
+ session of a launched instance by then, the instance is not retired, and its
675
+ home is kept. The message is `could not read the worktree at <work>:
676
+ <reason>`. For a file without read permission the reason names the file. For
677
+ a file over 2 GiB it gives the size, and `find <work> -type f -size
678
+ +2147483647c` finds the file. To continue, move the file out of the worktree
679
+ or make it readable, then run `oats retire <instance>` again. With nothing to
680
+ preserve, the files are not read and the retire goes through.
681
+
682
+ The home is not copied again because the work is, and the work is not copied
683
+ again because the home is. The home's comparison after the hooks holds every
684
+ entry the home copy carries, as its bytes and permission bits, the kernel's
685
+ own records included: `.oats-events.jsonl`, `.oats-stop.json`,
686
+ `.oats-stop-receipt.json` and `.oats-stop-receipt.*.json`,
687
+ `.oats-restart.json`, `.oats-agents-md.*.previous`, `.claude/settings.json`,
688
+ and every field of `instance.json`. A hook that writes one of them has the
689
+ home copied again. Those records are left out only of the comparison with the
690
+ spawn baseline, which decides whether the home has anything to preserve at
691
+ all. When a hook moved only the work, the recovery holds them as of the
692
+ pre-hook snapshot. The retire's own events are written to the workspace log
693
+ (`<deployment>/.agents/events/`), never to the home's. A home copy is
694
+ verified against the digest the baseline uses, which passes over those
695
+ records: an inherited limit, not an exact verification of the whole home.
696
+ A work copy's verification passes over every entry named `.git`: a dangling
697
+ `.git` symbolic link under the worktree is copied as a link and not verified.
698
+
699
+ Each part under `after-hooks/` is whole and verified, not a delta, and it is
700
+ verified before the worktree step and before the home is removed.
701
+ `after-hooks/` is not a complete picture of the instance after the hooks: its
702
+ `home/` exists only when the home's own bytes moved, and a part that is not
703
+ there is the one in the pre-hook snapshot. Nothing in
704
+ the pre-hook `home/`, `repo/` or `work/` is rewritten. If that copy fails or
705
+ cannot be verified, retire refuses with `E_WORK_PRESERVATION_FAILED`, keeps
706
+ the home and leaves the pre-hook recovery intact. An instance with nothing to
707
+ preserve before the hooks, and something after them, gets its one recovery
708
+ then: the hooks' bytes are under `home/`, `repo/` or `work/`, and there is no
709
+ `after-hooks/`.
710
+
711
+ The summary prints the recovery once. The path and the `after-hooks/` line
712
+ are how to find both snapshots; each line after the path appears only when
713
+ it applies:
714
+
715
+ ```text
716
+ Retired dev-1 (agent dev)
717
+ Work that was not committed has been preserved: changed instance-home bytes, untracked or ignored worktree bytes
718
+ /w/agents/dev/instances/.oats-retirement/recovery/dev-1-AbC123 (46.2 MiB)
719
+ copied from the home: .oats/ (854.2 KiB), .agents/ (138.0 KiB), notes/ (2.0 KiB), STATE.md (512 B) — 994.7 KiB in total
720
+ copied outputs: scratch/ (1.2 MiB), note.txt (12 B) — 1.2 MiB in total
721
+ not copied: .aw, .oats-aweb (oats.aweb)
722
+ after the retire hooks: home copied again under after-hooks/
723
+ ```
724
+
725
+ `recovery.json` records the recovery's `phase`. It is `"before-hooks"` when the
726
+ pre-hook snapshot is written, and `"complete"` once the post-hook check has
727
+ concluded, together with `afterHooks: { home, work }` when `after-hooks/` was
728
+ written. `after-hooks/` counts only when `recovery.json` lists it. A retried
729
+ retire, after incomplete cleanup or after a refused or interrupted attempt,
730
+ starts over: it writes its own recovery, its receipt names only that one, and
731
+ it never reads, amends or deletes an earlier one. A directory that an earlier
732
+ attempt left at `before-hooks` is not corrupt: it is a complete, verified
733
+ pre-hook snapshot.
734
+
735
+ Home entries that a capability declared in `retirement.disposable.home`
736
+ ([capabilities.md](capabilities.md#manifest)) are provider-owned state, not
737
+ the instance's work, and are not copied: neither before nor after the hooks.
738
+ They stay in the home until the home is removed, retire hooks still see them,
739
+ and a home kept for a retry keeps them. The summary lists the ones that
740
+ exist under "not copied", by name, with the declaring capability. The
741
+ declaration is recorded at spawn: a home spawned before its capability
742
+ declared them gains no exclusion from a package update, and is copied whole.
743
+
434
744
  Retire stops the harness through the home's session receipt. A home spawned
435
745
  before 0.25.9 has none. It retires only when its session is observably gone:
436
746
  instance.json records no launch, or the recorded tmux server is not running,
@@ -595,7 +905,10 @@ used. No implicit fallback changes the other modes.
595
905
  `--work-dir` and `--branch` are rejected. Canonical instructions, skill
596
906
  composition, provider trust and harness preflight still apply. Retirement preserves nonempty work in verified recovery storage beside the
597
907
  home (`workRecovery.path/work`) before deleting it, including files created by
598
- hooks; directory work has no disposable-root exemptions. The work-root cannot be
908
+ hooks; directory work has no disposable-root exemptions. A retire hook's later
909
+ change to work is in the same recovery, under
910
+ `workRecovery.path/after-hooks/work` (under `workRecovery.path/work` when
911
+ nothing needed preserving before the hooks). The work-root cannot be
599
912
  exchanged for a symlink. Recovery does not replace the worker's delivery protocol.
600
913
 
601
914
  ### `workspace` — cross-repo coordinator
@@ -86,9 +86,65 @@ export function manifestContractProblems(m) {
86
86
  else if (isObject(value) && value.required === true && event !== "spawn") bad(`${at}/required`, `hook "${event}" cannot be required — only the spawn hook is enforced (retire, launch and soul-scaffold run outside a spawn transaction)`);
87
87
  if (hookScriptEscapes(command)) bad(isObject(value) ? `${at}/command` : at, `hook "${event}" script ${JSON.stringify(command.trim().split(/\s+/)[0])} escapes the capability directory; a hook script is a path inside it`);
88
88
  }
89
+
90
+ if (m.retirement !== undefined) {
91
+ if (!isObject(m.retirement) || !isObject(m.retirement.disposable)) bad("/retirement", "manifest retirement must contain a disposable map");
92
+ else {
93
+ const unknown = Object.keys(m.retirement).filter((key) => key !== "disposable");
94
+ const scopes = Object.keys(m.retirement.disposable).filter((key) => !["home", "work"].includes(key));
95
+ if (unknown.length || scopes.length) bad("/retirement", `manifest retirement has unsupported keys: ${[...unknown, ...scopes].join(", ")}`);
96
+ for (const scope of ["home", "work"]) {
97
+ const roots = m.retirement.disposable[scope];
98
+ if (roots === undefined) continue;
99
+ const at = `/retirement/disposable/${scope}`;
100
+ if (!Array.isArray(roots) || roots.some((root) => typeof root !== "string")) { bad(at, `manifest retirement.disposable.${scope} must be an array of relative roots`); continue; }
101
+ if (scope !== "home") continue;
102
+ roots.forEach((root, i) => {
103
+ const problem = disposableHomeRootProblem(root);
104
+ if (problem === "shape") bad(`${at}/${i}`, `manifest retirement.disposable.home entry ${JSON.stringify(root)} must name one hidden top-level home entry (".name", or ".prefix-*")`);
105
+ else if (problem === "kernel-owned") bad(`${at}/${i}`, `manifest retirement.disposable.home entry ${JSON.stringify(root)} covers a kernel-owned home entry`);
106
+ });
107
+ }
108
+ }
109
+ }
89
110
  return problems;
90
111
  }
91
112
 
113
+ /** `retirement.disposable.home`: provider-owned top-level entries of an instance
114
+ * home that retirement neither fingerprints nor copies to recovery. An entry is
115
+ * one hidden top-level name (`.aw`) or a prefix (`.aweb-identity-*`: every
116
+ * top-level name starting with the text before `*`). No separator, no
117
+ * traversal, no other glob, and nothing that covers a name the kernel owns in
118
+ * a home. The published schema (docs/capability-manifest.schema.json) states
119
+ * the same grammar. */
120
+ const DISPOSABLE_HOME_EXACT_RE = /^\.[A-Za-z0-9_][A-Za-z0-9._-]*$/;
121
+ const DISPOSABLE_HOME_PREFIX_RE = /^\.[A-Za-z0-9_][A-Za-z0-9._-]*-\*$/;
122
+ /** The top-level entries the KERNEL writes in an instance home, as a
123
+ * declaration may not cover them: these exact names, and every exact name
124
+ * starting with one of these prefixes (the events log; the stop, restart and
125
+ * start receipts and locks; the rollback marker; AGENTS.md backups; the
126
+ * attachments directory). This is the one list: a new kernel-written top-level
127
+ * home entry MUST be added here and to the `not` patterns of
128
+ * `retirement.disposable.home` in docs/capability-manifest.schema.json. A
129
+ * prefix declaration can reach none of them except through `.oats-`, which is
130
+ * refused whole; `.oats-<provider>` as an exact name stays declarable. */
131
+ const KERNEL_HOME_NAMES = new Set([".oats", ".agents", ".claude"]);
132
+ const KERNEL_HOME_NAME_PREFIXES = [".oats-events", ".oats-stop", ".oats-restart", ".oats-rollback", ".oats-agents-md", ".oats-start", ".oats-attachments"];
133
+
134
+ /** Why `root` is not a declarable home entry: "shape" (not one hidden top-level
135
+ * name or prefix), "kernel-owned", or undefined when it is sound. */
136
+ export function disposableHomeRootProblem(root) {
137
+ if (typeof root !== "string") return "shape";
138
+ if (DISPOSABLE_HOME_PREFIX_RE.test(root)) return root.startsWith(".oats-") ? "kernel-owned" : undefined;
139
+ if (!DISPOSABLE_HOME_EXACT_RE.test(root)) return "shape";
140
+ return KERNEL_HOME_NAMES.has(root) || KERNEL_HOME_NAME_PREFIXES.some((prefix) => root.startsWith(prefix)) ? "kernel-owned" : undefined;
141
+ }
142
+
143
+ /** Whether the declared `root` (already sound) covers the top-level home entry `name`. */
144
+ export function disposableHomeRootMatches(root, name) {
145
+ return root.endsWith("*") ? name.startsWith(root.slice(0, -1)) : name === root;
146
+ }
147
+
92
148
  /** The setting keys a manifest declares (`settings.<key>`), sorted: names only, never their
93
149
  * descriptions or defaults. The spawn preview and inspect expose them so a client can gate
94
150
  * a choice on a declared key (e.g. a messaging provider's `join`). */