@awebai/oats 0.27.0 → 0.27.2

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.
@@ -953,6 +953,12 @@ location. The receipt says so:
953
953
  discarding the worktree (a checked-out branch cannot be deleted).
954
954
  - A failed move keeps the home and refuses `E_WORK_PRESERVATION_FAILED` —
955
955
  nothing is lost; retry or pass `--discard-worktree`.
956
+ - When a recovery's Git status disagrees with the source's (0.27.2),
957
+ `E_WORK_PRESERVATION_FAILED` carries `details: {home, statusDisagreement:
958
+ {repo, rows: [{path, source, recovery}], total}}`. `repo` is `.` or a nested
959
+ repository's path. `source`/`recovery` are the porcelain `XY` codes, or
960
+ `null` where that side has no row. `rows` holds the first 10 paths, sorted,
961
+ and `total` counts all of them. The message names the same rows.
956
962
  - Non-worktree modes report `retention: null`. Quarantine/rollback paths keep
957
963
  their removal semantics.
958
964
  - A recovery (`workRecovery`, `workRecoveries[]`) is `{path, classes, bytes,
package/docs/packages.md CHANGED
@@ -74,7 +74,7 @@ members:
74
74
  packages:
75
75
  oats.framework: v1.1.3
76
76
  oats.okf: v2.1.5
77
- oats.aweb: v1.13.1
77
+ oats.aweb: v1.14.2
78
78
  teams:
79
79
  global: { description: Org-wide }
80
80
  engineering: { description: Platform }
@@ -0,0 +1,29 @@
1
+ # OATS 0.27.1
2
+
3
+ ## Changed
4
+
5
+ - **oats.aweb 1.14.2 is bundled and pinned** (was 1.13.1): **joining teams
6
+ beyond the personal one now works.** An instance joins the eligible teams it
7
+ is given at spawn (`--provider oats.aweb join=<labels>`) or later with
8
+ `oats aweb join <labels>` / the Desktop's Teams controls, and leaves with
9
+ `oats aweb leave <labels>`. Each joined team gets its own identity under the
10
+ instance home. Joined teams **poll** (their mail is read between tasks); live
11
+ receive for joined teams is planned for oats.aweb 1.15. Joining needs
12
+ **aw 1.36.12 or later** (older aw answers `E_TEAM_AW_FLOOR`; the primary
13
+ identity still mints). A leave removes the local identity only after aweb
14
+ confirms the membership is released, so a failed leave can be retried.
15
+ Retire leaves every joined team before releasing the primary identity.
16
+ - The framework workspace (`oats-workspace.yaml`) pins oats.aweb v1.14.2.
17
+ - Skip oats.aweb 1.14.0 and 1.14.1 (tagged, never pinned by an OATS release): their binding check answered a `teams` key that OATS rejects, so `oats readiness` showed messaging unknown; 1.14.2 fixes it, and 1.14.0's joins were refused by aw.
18
+
19
+ ## Known limitations
20
+
21
+ - The bundled oats.okf is still 2.1.5: its harvest worker spawns with `--runtime`, so each harvest answers one `deprecated-runtime-name` warning on 0.27.x. It's harmless; oats.okf 2.1.6 passes `--harness`.
22
+
23
+ - A per-workspace personal team still needs oats.aweb 1.15 (the aweb service
24
+ and CLI already carry personal enrollment); until then "personal" is the
25
+ person's active aweb team.
26
+
27
+ ## Upgrading from 0.27.0
28
+
29
+ - To join teams: upgrade aw to 1.36.12 or later (and restart the host's wake daemon), then `oats sync` so the deployment's lock takes oats.aweb 1.14.2. Existing instances keep their primary identity; they join teams with `oats aweb join <labels>`.
@@ -0,0 +1,51 @@
1
+ # OATS 0.27.2
2
+
3
+ ## Fixed
4
+
5
+ - **Retiring a worktree instance whose repository excludes paths locally.**
6
+ Retire refused with `E_WORK_PRESERVATION_FAILED` ("recovered Git
7
+ index/status disagreed with the source") when the repository ignored a path
8
+ only through its local rules: the common Git directory's `info/exclude`, or
9
+ a `core.excludesFile` set in the repository's config. The recovery clone had
10
+ neither, so such a path was ignored (`!!`) in the source but untracked, or
11
+ absent for an empty directory, in the clone. Preservation runs before the
12
+ retire hooks and `--force` does not bypass it, so the instance could not be
13
+ retired and its identities were never revoked. The recovery now carries
14
+ those patterns into its own `.git/info/exclude` (nested repositories too),
15
+ and `recovery.json` lists them under `excludes`
16
+ (`[{ repo, kind, path }]`).
17
+ When a recovery's status still disagrees, the refusal now **names the
18
+ differing rows** from both sides, for example
19
+ `.scratch/ (source !!, recovery absent)`: the first 10, then
20
+ `; and N more`. Under `--json` they are in
21
+ `details.statusDisagreement` (`{repo, rows: [{path, source, recovery}],
22
+ total}`; see [desktop-cli-api.md](../desktop-cli-api.md)).
23
+ **On an older kernel:** remove the disposable paths that
24
+ `git -C <home>/work status --porcelain --ignored=matching` lists as `!!`
25
+ before retiring.
26
+
27
+ - **Retiring a worktree instance whose repository changes how status reads
28
+ its files.** The recovery clone probed its own settings and lacked the
29
+ common Git directory's `info/attributes`. A repository with, for example,
30
+ `core.fileMode=false` and a mode-only change was clean in the source but
31
+ ` M` in the clone, so retire refused. The recovery now takes the source's
32
+ `core.fileMode`, `core.ignoreCase`, `core.precomposeUnicode`,
33
+ `core.symlinks`, `core.autocrlf` and `core.eol` (set, or unset where the
34
+ source leaves them unset) and its `info/attributes`, nested repositories
35
+ included. `recovery.json` lists them under `statusConfig`
36
+ (`[{ repo, kind: "config", key, value } | { repo, kind: "info/attributes", path }]`).
37
+
38
+ ## Known limitations
39
+
40
+ - The bundled oats.okf is still 2.1.5 (its harvest answers one `deprecated-runtime-name` warning per spawn; harmless). oats.okf 2.1.6 follows in a later patch.
41
+
42
+ - **A `core.attributesFile` or filter driver set in the repository's own
43
+ config is not carried** into a recovery. Merging its patterns would change
44
+ their precedence, and a pointer would tie the recovery to a host file. Such
45
+ a repository can still make retire refuse, and the refusal names the rows.
46
+ Workaround: commit or restore the files it re-reads before retiring.
47
+
48
+ ## Documentation
49
+
50
+ - `oats retire --force` does **not** skip work preservation
51
+ ([souls-and-instances.md](../souls-and-instances.md#retire)).
@@ -302,6 +302,18 @@ uncertified capture retains the home for retry. Successful retirement enqueues
302
302
  evidence but never waits for a model or GitHub: independent processing and
303
303
  source-targeted inspection continue after the home disappears.
304
304
 
305
+ Before any retire hook runs, retire preserves the instance's uncommitted and
306
+ unmerged work: a verified recovery under `.oats-retirement/recovery/`, named in
307
+ the summary. A worktree recovery is a standalone clone that carries the
308
+ repository's local exclude rules (`info/exclude`, a configured
309
+ `core.excludesFile`), its `info/attributes` and the settings that change what
310
+ status reports (`core.fileMode`, `core.ignoreCase`, …), so its Git status
311
+ matches the source's. A recovery that
312
+ cannot be verified refuses with `E_WORK_PRESERVATION_FAILED` and keeps the
313
+ home. **`--force` does not skip work preservation.** It forces only past a
314
+ missing or unusable cleanup marker and past incomplete hook cleanup
315
+ ([capabilities.md](capabilities.md)).
316
+
305
317
  `oats retire <instance> --self` lets an instance retire itself when the human
306
318
  or briefing says it is done. A live harness cannot give a stable final
307
319
  inspection of its own work, so the calling process inspects, runs, and removes
package/lib/core.mjs CHANGED
@@ -4092,6 +4092,36 @@ function worktreeStatus(repo) {
4092
4092
  }
4093
4093
  }
4094
4094
 
4095
+ /** `git status --porcelain=v1 -z` as Map<path, XY>; a rename/copy row names its source (`R ← old`). */
4096
+ function statusRows(z) {
4097
+ const rows = new Map(), parts = z.split("\0");
4098
+ for (let i = 0; i < parts.length; i++) {
4099
+ const row = parts[i];
4100
+ if (!row) continue;
4101
+ const xy = row.slice(0, 2), path = row.slice(3);
4102
+ rows.set(path, xy[0] === "R" || xy[0] === "C" ? `${xy} ← ${parts[++i]}` : xy);
4103
+ }
4104
+ return rows;
4105
+ }
4106
+ const STATUS_DISAGREEMENT_CAP = 10;
4107
+ /** Where two statuses disagree, by path: { rows: [{ path, source, recovery }] (null = absent; sorted, the
4108
+ * first `cap`), total }. What E_WORK_PRESERVATION_FAILED shows, so an operator sees `!! .scratch/` against
4109
+ * an absent row directly. */
4110
+ export function statusDisagreement(sourceStatus, recoveredStatus, cap = STATUS_DISAGREEMENT_CAP) {
4111
+ const a = statusRows(sourceStatus), b = statusRows(recoveredStatus);
4112
+ const paths = [...new Set([...a.keys(), ...b.keys()])].filter((p) => a.get(p) !== b.get(p)).sort();
4113
+ return { rows: paths.slice(0, cap).map((path) => ({ path, source: a.get(path) ?? null, recovery: b.get(path) ?? null })), total: paths.length };
4114
+ }
4115
+ /** Refuse a recovery whose Git status is not the source's, naming the differing rows (capped). */
4116
+ function assertStatusAgrees(source, recovered, what, repo) {
4117
+ const sourceStatus = worktreeStatus(source), recoveredStatus = worktreeStatus(recovered);
4118
+ if (sourceStatus === recoveredStatus) return;
4119
+ const diff = statusDisagreement(sourceStatus, recoveredStatus);
4120
+ const shown = diff.rows.map((r) => `${r.path} (source ${r.source ?? "absent"}, recovery ${r.recovery ?? "absent"})`).join("; ");
4121
+ const more = diff.total > diff.rows.length ? `; and ${diff.total - diff.rows.length} more` : "";
4122
+ throw Object.assign(new Error(`${what}${shown ? `: ${shown}${more}` : ""}`), { statusDisagreement: { repo, ...diff } });
4123
+ }
4124
+
4095
4125
  function generatedWorkFingerprint(work, status, disposableRoots = []) {
4096
4126
  const owned = (path) => disposableRoots.some((root) => path === root || path.startsWith(`${root}${sep}`));
4097
4127
  const paths = status.split("\0").filter(Boolean)
@@ -4971,6 +5001,64 @@ function restoreStandaloneGitState(sourceWork, recoveredRepo) {
4971
5001
  }
4972
5002
  }
4973
5003
 
5004
+ /** Give a recovery clone the source's effective exclude rules, so the status comparison sees the same
5005
+ * ignored paths. A fresh clone has neither the common dir's info/exclude nor a repository-configured
5006
+ * core.excludesFile, so a path excluded only there is `!!` in the source and `??` in the clone. Both are
5007
+ * written into the clone's own info/exclude (self-contained): core.excludesFile first, then
5008
+ * info/exclude, which keeps Git's precedence (a later pattern wins, and info/exclude outranks
5009
+ * core.excludesFile). → the sources carried, [{ kind, path }]. */
5010
+ function carryExcludes(sourceWork, recoveredRepo) {
5011
+ const git = (...args) => execFileSync("git", ["-C", sourceWork, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
5012
+ const carried = [], parts = [];
5013
+ // A file with no pattern line (Git's template info/exclude is comments only) changes nothing: not carried.
5014
+ const carry = (kind, file) => {
5015
+ if (!existsSync(file)) return;
5016
+ const text = readFileSync(file, "utf8");
5017
+ if (!text.split("\n").some((line) => line.trim() && !line.startsWith("#"))) return;
5018
+ parts.push(text); carried.push({ kind, path: file });
5019
+ };
5020
+ let excludesFile = "";
5021
+ try { excludesFile = git("config", "--path", "--get", "core.excludesFile"); } catch { /* unset: Git's default applies to both repositories alike */ }
5022
+ if (excludesFile) {
5023
+ carry("core.excludesFile", resolve(git("rev-parse", "--show-toplevel"), excludesFile));
5024
+ }
5025
+ carry("info/exclude", join(git("rev-parse", "--path-format=absolute", "--git-common-dir"), "info", "exclude"));
5026
+ if (!carried.length) return carried;
5027
+ mkdirSync(join(recoveredRepo, ".git", "info"), { recursive: true });
5028
+ writeFileSync(join(recoveredRepo, ".git", "info", "exclude"), parts.map((t) => (t.endsWith("\n") ? t : `${t}\n`)).join(""));
5029
+ return carried;
5030
+ }
5031
+
5032
+ /** Repository settings that change what `git status` reports for the same bytes and index. */
5033
+ const STATUS_CONFIG = ["core.fileMode", "core.ignoreCase", "core.precomposeUnicode", "core.symlinks", "core.autocrlf", "core.eol"];
5034
+ /** Give a recovery clone the source's status-affecting settings, so the status comparison judges the same
5035
+ * bytes the same way. A fresh clone probes its own core.fileMode/ignoreCase/… and lacks the common dir's
5036
+ * info/attributes, so e.g. core.fileMode=false with a mode-only change is clean in the source and ` M` in
5037
+ * the clone. Each key whose effective value differs is set (or, unset in the source, unset) in the
5038
+ * clone's local config; info/attributes is copied to the clone's (the same precedence). → what was
5039
+ * carried: [{ kind: "config", key, value } | { kind: "info/attributes", path }]. */
5040
+ function carryStatusConfig(sourceWork, recoveredRepo) {
5041
+ const get = (repo, key) => {
5042
+ try { return execFileSync("git", ["-C", repo, "config", "--get", key], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).replace(/\n$/, ""); }
5043
+ catch (e) { if (e.status === 1) return null; throw e; }
5044
+ };
5045
+ const carried = [];
5046
+ for (const key of STATUS_CONFIG) {
5047
+ const value = get(sourceWork, key);
5048
+ if (value === get(recoveredRepo, key)) continue;
5049
+ execFileSync("git", ["-C", recoveredRepo, "config", "--local", ...(value === null ? ["--unset-all", key] : [key, value])], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
5050
+ carried.push({ kind: "config", key, value });
5051
+ }
5052
+ const common = execFileSync("git", ["-C", sourceWork, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
5053
+ const attributes = join(common, "info", "attributes");
5054
+ if (existsSync(attributes)) {
5055
+ mkdirSync(join(recoveredRepo, ".git", "info"), { recursive: true });
5056
+ copyFileSync(attributes, join(recoveredRepo, ".git", "info", "attributes"));
5057
+ carried.push({ kind: "info/attributes", path: attributes });
5058
+ }
5059
+ return carried;
5060
+ }
5061
+
4974
5062
  function detachRecoveryClone(source, recovered) {
4975
5063
  let stash;
4976
5064
  try { stash = execFileSync("git", ["-C", source, "rev-parse", "--verify", "--quiet", "refs/stash"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER }).trim(); }
@@ -4989,6 +5077,7 @@ function detachRecoveryClone(source, recovered) {
4989
5077
  }
4990
5078
 
4991
5079
  function materializeNestedRepositories(sourceWork, recoveredRepo) {
5080
+ const excludes = [], statusConfig = [];
4992
5081
  for (const source of nestedGitRoots(sourceWork)) {
4993
5082
  const rel = relative(sourceWork, source);
4994
5083
  const dest = join(recoveredRepo, rel);
@@ -4998,6 +5087,8 @@ function materializeNestedRepositories(sourceWork, recoveredRepo) {
4998
5087
  execFileSync("git", ["-C", dest, "checkout", "--quiet", head], { maxBuffer: GIT_MAX_BUFFER });
4999
5088
  detachRecoveryClone(source, dest);
5000
5089
  restoreStandaloneGitState(source, dest);
5090
+ for (const c of carryExcludes(source, dest)) excludes.push({ repo: rel, ...c });
5091
+ for (const c of carryStatusConfig(source, dest)) statusConfig.push({ repo: rel, ...c });
5001
5092
  for (const e of readdirSync(source, { withFileTypes: true })) {
5002
5093
  if (e.name === ".git") continue;
5003
5094
  const target = join(dest, e.name);
@@ -5005,8 +5096,9 @@ function materializeNestedRepositories(sourceWork, recoveredRepo) {
5005
5096
  copyTreeSafe(join(source, e.name), target);
5006
5097
  }
5007
5098
  if (existsSync(join(dest, ".git", "objects", "info", "alternates"))) throw new Error(`nested recovery ${rel} depends on object alternates`);
5008
- if (worktreeStatus(source) !== worktreeStatus(dest)) throw new Error(`nested recovery ${rel} Git state disagreed with source`);
5099
+ assertStatusAgrees(source, dest, `nested recovery ${rel} Git state disagreed with source`, rel);
5009
5100
  }
5101
+ return { excludes, statusConfig };
5010
5102
  }
5011
5103
 
5012
5104
  /** Bytes a path holds, never following a symlink (a link counts as its own entry). */
@@ -5061,7 +5153,7 @@ function preserveRetirementWork(observation, meta, instance) {
5061
5153
  if (fingerprintTree(observation.home, { excludeRoot: new Set(["work"]), instanceHome: true }) !== fingerprintTree(recoveredHome, { instanceHome: true })) {
5062
5154
  throw new Error("home recovery verification disagreed with the source");
5063
5155
  }
5064
- let branchDrift;
5156
+ let branchDrift, excludes, statusConfig;
5065
5157
  if (!homeOnly && meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
5066
5158
  const recoveredRepo = join(staging, "repo");
5067
5159
  // The branch is derived from the worktree while it exists: an instance
@@ -5080,18 +5172,21 @@ function preserveRetirementWork(observation, meta, instance) {
5080
5172
  detachRecoveryClone(sourceGitContext, recoveredRepo);
5081
5173
  if (existsSync(observation.work)) {
5082
5174
  restoreStandaloneGitState(observation.work, recoveredRepo);
5175
+ excludes = carryExcludes(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
5176
+ statusConfig = carryStatusConfig(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
5083
5177
  for (const e of readdirSync(observation.work, { withFileTypes: true })) {
5084
5178
  if (e.name === ".git") continue;
5085
5179
  const dest = join(recoveredRepo, e.name);
5086
5180
  rmSync(dest, { recursive: true, force: true });
5087
5181
  copyTreeSafe(join(observation.work, e.name), dest);
5088
5182
  }
5089
- materializeNestedRepositories(observation.work, recoveredRepo);
5183
+ const nested = materializeNestedRepositories(observation.work, recoveredRepo);
5184
+ excludes.push(...nested.excludes); statusConfig.push(...nested.statusConfig);
5090
5185
  }
5091
5186
  if (existsSync(join(recoveredRepo, ".git", "objects", "info", "alternates"))) throw new Error("recovery clone depends on object alternates");
5092
5187
  if (existsSync(observation.work)) {
5093
5188
  if (fingerprintTree(observation.work, { excludeRoot: new Set([".git"]), excludeGitMetadata: true }) !== fingerprintTree(recoveredRepo, { excludeRoot: new Set([".git"]), excludeGitMetadata: true })) throw new Error("worktree recovery verification disagreed with the source");
5094
- if (worktreeStatus(observation.work) !== worktreeStatus(recoveredRepo)) throw new Error("recovered Git index/status disagreed with the source");
5189
+ assertStatusAgrees(observation.work, recoveredRepo, "recovered Git index/status disagreed with the source", ".");
5095
5190
  }
5096
5191
  const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
5097
5192
  const sourceHead = ref.branch === null ? ref.oid
@@ -5109,14 +5204,15 @@ function preserveRetirementWork(observation, meta, instance) {
5109
5204
  // What the copy cost: the untracked/ignored (or directory) outputs it carries, named.
5110
5205
  const workCopied = observation.directory ? !!observation.directoryFingerprint : !homeOnly && meta.work === "worktree" && existsSync(observation.work);
5111
5206
  const outputs = workCopied ? preservedOutputs(observation.work, observation.directory) : undefined;
5112
- writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}), ...(branchDrift ? { branchDrift } : {}), ...(outputs ? { outputs } : {}) }, null, 2) + "\n", { mode: 0o600 });
5207
+ writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}), ...(branchDrift ? { branchDrift } : {}), ...(excludes?.length ? { excludes } : {}), ...(statusConfig?.length ? { statusConfig } : {}), ...(outputs ? { outputs } : {}) }, null, 2) + "\n", { mode: 0o600 });
5113
5208
  const bytes = treeBytes(staging);
5114
5209
  mkdirSync(dirname(recovery), { recursive: true });
5115
5210
  renameSync(staging, recovery);
5116
5211
  return { path: recovery, classes: observation.classes, bytes, ...(outputs ? { outputs } : {}), ...(repoCopy ? { repoCopy } : {}) };
5117
5212
  } catch (e) {
5118
5213
  rmSync(staging, { recursive: true, force: true });
5119
- throw oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${observation.home}; recovery could not be verified: ${e.message}`);
5214
+ const details = e.statusDisagreement ? { home: observation.home, statusDisagreement: e.statusDisagreement } : undefined;
5215
+ throw Object.assign(oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${observation.home}; recovery could not be verified: ${e.message}`, details), details ? { details } : {});
5120
5216
  }
5121
5217
  }
5122
5218
 
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "oats.aweb": {
10
10
  "url": "https://github.com/awebai/oats-aweb.git",
11
- "ref": "v1.13.1",
11
+ "ref": "v1.14.2",
12
12
  "path": "oats-package"
13
13
  },
14
14
  "oats.jira": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.27.0",
3
+ "version": "0.27.2",
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",