@awebai/oats 0.25.2 → 0.25.4

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Purpose:** the one accurate view of every work stream in the redesign, what is on main, what is in flight, who owns it, and what blocks it. Lead: `oats-expert` (redesign lead). Updated whenever anything merges, is returned, or reality changes. Older per-lane boards are superseded by this file.
4
4
 
5
- **Last update:** 2026-09-23 20:00Z · **0.25.0 + 0.25.1 PUBLISHED** (workspace model A–C + team-review fixes) · Phase D plan next · **Desktop engineer paused by the human** (10B-0 uncommitted foundation preserved; resume is the human's call; 0.24.14 to be cut from a maintenance branch off `e5cdaf95` when its PR lands) · parity pipeline ⏸.
5
+ **Last update:** 2026-09-23 20:00Z · **0.25.0–0.25.3 PUBLISHED** (workspace model A–C; team-review fixes; operator-rebuild round; OATS_SOUL_ID) · **OKF 2.1.4 pending human GO** (owner pin by id, clone/timeout, retire-schedule seam; Antares writes the PRs) · Phase D plan next · **Desktop engineer paused by the human** (10B-0 uncommitted foundation preserved; resume is the human's call; 0.24.14 to be cut from a maintenance branch off `e5cdaf95` when its PR lands) · parity pipeline ⏸.
6
6
 
7
7
  Legend: ✅ on main/published · 🔄 in flight (PR/branch) · 🟡 preserved, not adopted · ⬜ not started · ⛔ blocked
8
8
 
@@ -564,3 +564,23 @@ in `next.clone` like any member that lacks a clone at the convention (the host
564
564
  is a member; a soul that lives in it may need a work clone). Under an explicit
565
565
  `oats-local.yaml` `standalone:` header the next steps say the view is standalone
566
566
  and list only that repo.
567
+
568
+ ### 0.25.3 — `OATS_SOUL_ID` (stable soul identity for providers)
569
+
570
+ The per-commit soul cache (0.25.1, M1) made `realpath(<home>/soul)` change with every
571
+ member commit; a provider that keyed durable state on that path (OKF 2.1.3 `owners.json`)
572
+ refused the next spawn (`E_OWNER`). "Members are latest" and "the owner is a path" cannot
573
+ both hold, so the kernel now hands hooks a **stable identity**:
574
+
575
+ - `OATS_SOUL_ID` in the `spawn` / `retire` / `launch` hook environment: for a workspace soul
576
+ `<repo key>#<soul name>` exactly as the canonical key is spelled (e.g.
577
+ `github.com/awebai/aweb#aweb-protocol-expert`, local fixtures `local//abs/path.git#name`);
578
+ for a classic soul the realpath of `agents/<name>/soul` (today's value — 0.24 deployments
579
+ unchanged). Also recorded as `instance.json.workspace.soul.id`.
580
+ - `OATS_SOUL` is the **content** the home links — for a workspace soul the per-commit
581
+ directory `agents/<name>/souls/<commit12>/`, never the swappable `agents/<name>/soul`
582
+ pointer. Providers read content from `OATS_SOUL` and key state on `OATS_SOUL_ID`.
583
+ - Provider contract (OKF 2.1.4): `owners[owner] = OATS_SOUL_ID ?? realpath(OATS_SOUL ?? home/soul)`;
584
+ a prior row whose value is a path under `agents/<same soul name>/(soul|souls/<commit>)` is
585
+ migrated to the id once, not refused; any other mismatch stays `E_OWNER`.
586
+
@@ -337,6 +337,13 @@ type a digest. An `--approve` that names an id or version the resolution does
337
337
  not contain is an error, not a silent skip; an entry the flags do not cover
338
338
  stays unapproved (exit `2`, as above).
339
339
 
340
+ `<version>` is the value `sync --json` reports as `approvalNeeded[].version`,
341
+ which is what the lock records as the package's `version`. For a **catalog**
342
+ package that is the published version (`oats.okf@2.1.4`). For a **git** source
343
+ pinned by commit (`git:github.com/awebai/oats-okf@<oid>`) it is the **full
344
+ commit OID**, not the `git:` reference and not a tag name — copy it from the
345
+ `approvalNeeded` line rather than from your workspace file.
346
+
340
347
  ## 7b. OKF 2: start a FRESH `state-dir` — do not re-point the old one
341
348
 
342
349
  OKF 2 pins each knowledge **owner** to a soul by path: at source registration
@@ -0,0 +1,19 @@
1
+ # OATS v0.25.3 — `OATS_SOUL_ID`
2
+
3
+ Kernel/Pi **0.25.3**. Tag `v0.25.3` → the commit carrying these notes. No API change;
4
+ `features[]` unchanged; one additive hook environment variable and one additive
5
+ `instance.json` field.
6
+
7
+ ## Kernel
8
+
9
+ - **`OATS_SOUL_ID`** — a soul's stable identity for capability hooks (`spawn`,
10
+ `retire`, `launch`): `<repo key>#<soul name>` for a workspace soul, the realpath of
11
+ `agents/<name>/soul` for a classic soul. Recorded as `instance.json.workspace.soul.id`.
12
+ Fixes the consequence of 0.25.1's per-commit soul cache found by the first outsider
13
+ rebuild: OKF 2.1.3 pins a soul's owner to `realpath(home/soul)`, which now changes with
14
+ every member commit → the next spawn of the same soul was refused `E_OWNER`. The provider
15
+ half (owners keyed by `OATS_SOUL_ID`, one-time migration of path pins) ships in OKF 2.1.4.
16
+ - **`OATS_SOUL` is the content the home links** — for a workspace soul the per-commit
17
+ directory, never the swappable `agents/<name>/soul` pointer (spawn and retire hooks agree).
18
+
19
+ Contract: `docs/design/2026-09-23-workspace-module-contracts.md` § "0.25.3 — OATS_SOUL_ID".
@@ -0,0 +1,21 @@
1
+ # OATS 0.25.4
2
+
3
+ Patch release: one kernel fix found by an operator rehearsal on 0.25.3.
4
+
5
+ ## Fixed
6
+
7
+ - **Quarantine retry never re-ran the owed retire hook on a workspace-mode
8
+ home.** When a required spawn hook fails and its compensation is incomplete,
9
+ the kernel retains the home with a cleanup descriptor
10
+ (`.oats-rollback-incomplete.json`). Module materialization had already
11
+ written a pre-hook `instance.json` stub (`{ modules, providers,
12
+ resolutionRevision }`), and `oats retire` trusted that stub over the
13
+ descriptor — so `meta.repo` was undefined, the retry reported *"retire hook
14
+ <cap>: did not run on this retry"* and *"cleanup descriptor lost its context
15
+ repo"* on every attempt, and only `--force` could remove the home (leaving
16
+ the provider's external state to be cleaned by hand). The retry now reads
17
+ the cleanup descriptor whenever the live record is not a complete spawn
18
+ record; the owed hook runs, the home stays while it fails, and is removed
19
+ once it succeeds. Regression test in `test/spawn-workspace.test.mjs`.
20
+
21
+ No contract changes. `oats version --json` is unchanged apart from the version.
package/lib/core.mjs CHANGED
@@ -640,7 +640,7 @@ const LAUNCH_CONFIG_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
640
640
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
641
641
  /** Environment the kernel sets for every launch (identity, home, roots) and
642
642
  * its reference aliases: a configuration may not name them. */
643
- export const RESERVED_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_ROOT", "OATS_CONTEXT", "OATS_WORKSPACE", "OATS_EVENT", "OATS_SETTINGS", "OATS_CLI_BIN", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT"]);
643
+ export const RESERVED_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_SOUL_ID", "OATS_ROOT", "OATS_CONTEXT", "OATS_WORKSPACE", "OATS_EVENT", "OATS_SETTINGS", "OATS_CLI_BIN", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT"]);
644
644
  export const LAUNCH_REF_PREFIX = "OATS_LAUNCH_REF_";
645
645
  const reservedLaunchEnv = (n) => RESERVED_LAUNCH_ENV.has(n) || n.startsWith(LAUNCH_REF_PREFIX);
646
646
  export function validateLaunchConfig(name, entry, where) {
@@ -4806,7 +4806,31 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
4806
4806
  return accepted;
4807
4807
  }
4808
4808
 
4809
- export function runLifecycleHooks(event, { home, instance, agentName, soulDir, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {}, assertRoots }) {
4809
+ /**
4810
+ * A soul's STABLE identity for capability hooks (OATS_SOUL_ID): what a provider
4811
+ * may key durable state on. Workspace soul: `<repo key>#<soul name>` — it does not
4812
+ * change when the member commits (the per-commit soul directory does) or when the
4813
+ * copy moves. Classic soul: the realpath of agents/<name>/soul, i.e. today's value,
4814
+ * so 0.24 deployments are unchanged. Order: an explicit `soulId` (a prepared spawn),
4815
+ * the home's recorded `instance.json.workspace.soul` (retire/launch of a workspace
4816
+ * home), else the path.
4817
+ */
4818
+ export function stableSoulId({ soulId, home, soulDir, agentName } = {}) {
4819
+ if (typeof soulId === "string" && soulId) return soulId;
4820
+ if (home) {
4821
+ try {
4822
+ const meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8"));
4823
+ const s = meta?.workspace?.soul;
4824
+ if (typeof s?.id === "string" && s.id) return s.id;
4825
+ if (typeof s?.repoKey === "string" && s.repoKey) return `${s.repoKey}#${meta.agent ?? agentName ?? ""}`;
4826
+ } catch { /* not a workspace home */ }
4827
+ }
4828
+ if (soulDir) { try { return realpathSync(soulDir); } catch { return soulDir; } }
4829
+ return "";
4830
+ }
4831
+ export const workspaceSoulId = (repoKey, name) => `${repoKey}#${name}`;
4832
+
4833
+ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, soulId, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {}, assertRoots }) {
4810
4834
  const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [], contributions: [] };
4811
4835
  const envOwners = new Map();
4812
4836
  const envDeclarations = new Map((resolved.capabilities || []).map((cap) => [cap.id, { names: new Set(cap.environment || []), namespaces: [...(cap.environmentNamespaces || [])] }]));
@@ -4834,7 +4858,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
4834
4858
  // the package STORE root — do not conflate them.
4835
4859
  OATS_EVENT: event, OATS_INSTANCE: instance, OATS_INSTANCE_HOME: home, OATS_HOME: home, OATS_AGENT: agentName,
4836
4860
  OATS_CAPABILITY: cap.id, OATS_LAYER: cap.layer || "", OATS_ROOT: rootDir || "",
4837
- OATS_SOUL: soulDir || "", OATS_CONTEXT: contextDir, OATS_WORKSPACE: workspaceDir || "", OATS_LEVEL: cap.level || "",
4861
+ OATS_SOUL: soulDir || "", OATS_SOUL_ID: stableSoulId({ soulId, home, soulDir, agentName }), OATS_CONTEXT: contextDir, OATS_WORKSPACE: workspaceDir || "", OATS_LEVEL: cap.level || "",
4838
4862
  OATS_TEAM_NAME: resolved.team?.name || "", OATS_TEAM_ID: resolved.team?.id || "", OATS_TEAM_SCOPE: resolved.team?.scope || "",
4839
4863
  ...extraEnv,
4840
4864
  // Hooks also run through direct core callers (not only bin/oats).
@@ -6938,8 +6962,12 @@ function* spawnBody(root, agent, o = {}) {
6938
6962
  // Capability lifecycle hooks (spawn) — the knowledge integration scaffolds instance
6939
6963
  // memory (STATE.md/log.md/notes/ are OKF conventions, not kernel ones); the
6940
6964
  // messaging integration mints the comms identity. Kernel stays memory-agnostic.
6965
+ const preparedSoulId = o.prepared ? workspaceSoulId(o.prepared.soulEntry.repoKey, o.prepared.soulEntry.name) : undefined;
6966
+ // Hooks read the soul the HOME links (the per-commit directory for a workspace
6967
+ // soul), never the swappable agents/<name>/soul pointer: a provider that pins a
6968
+ // path must pin this instance's content, and OATS_SOUL_ID is what it keys on.
6941
6969
  const hookRes = runLifecycleHooks("spawn", {
6942
- home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
6970
+ home, instance, agentName: agent.name, soulDir: homeSoulTarget, soulId: preparedSoulId, contextDir: repoAbs,
6943
6971
  workspaceDir: workspaceOf(root), rootDir: root, resolved: resolvedCfg,
6944
6972
  extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_RUNTIME: runtime, OATS_KIND: agent.kind || "persistent" },
6945
6973
  });
@@ -7043,7 +7071,7 @@ function* spawnBody(root, agent, o = {}) {
7043
7071
  catch (e) { return retainDirectory(e); }
7044
7072
  try {
7045
7073
  const comp = runLifecycleHooks("retire", {
7046
- home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
7074
+ home, instance, agentName: agent.name, soulDir: homeSoulTarget, soulId: preparedSoulId, contextDir: repoAbs,
7047
7075
  workspaceDir: workspaceOf(root), rootDir: root, resolved: resolvedCfg,
7048
7076
  priorMeta: hookRes.meta || {},
7049
7077
  });
@@ -7242,7 +7270,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
7242
7270
  // digests) in instance.json before this metadata is assembled — carry them.
7243
7271
  if (o.prepared) {
7244
7272
  try { const prior = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); if (prior.modules) meta.modules = prior.modules; if (prior.providers) meta.providers = prior.providers; } catch { /* materialize wrote it; absent means nothing to carry */ }
7245
- meta.workspace = { key: o.prepared.discovery?.key ?? null, commit: o.prepared.discovery?.commit ?? null, resolution: o.prepared.resolution.revision, standalone: o.prepared.discovery?.standalone === true, soul: { repoKey: o.prepared.soulEntry.repoKey, commit: o.prepared.soulEntry.commit, team: o.prepared.soulEntry.team ?? null } };
7273
+ meta.workspace = { key: o.prepared.discovery?.key ?? null, commit: o.prepared.discovery?.commit ?? null, resolution: o.prepared.resolution.revision, standalone: o.prepared.discovery?.standalone === true, soul: { id: workspaceSoulId(o.prepared.soulEntry.repoKey, o.prepared.soulEntry.name), repoKey: o.prepared.soulEntry.repoKey, commit: o.prepared.soulEntry.commit, team: o.prepared.soulEntry.team ?? null } };
7246
7274
  }
7247
7275
  const spawnWarnings = warnings;
7248
7276
 
@@ -9273,7 +9301,17 @@ export function retireInstance(root, name, o = {}) {
9273
9301
  } catch { markerUnusable = true; }
9274
9302
  }
9275
9303
  const liveMeta = existsSync(metaPath) ? JSON.parse(readFileSync(metaPath, "utf8")) : undefined;
9276
- const meta = liveMeta || quarantine?.cleanup || {};
9304
+ // A quarantined home may carry an instance.json that is NOT the spawn record:
9305
+ // module materialization writes a pre-hook stub ({ modules, providers,
9306
+ // resolutionRevision }) before the spawn hooks run, and a required-hook
9307
+ // failure retains the home with that stub in place. Trusting it over the
9308
+ // cleanup descriptor left meta.repo undefined, so the retry never re-ran the
9309
+ // owed retire hook and reported "cleanup descriptor lost its context repo"
9310
+ // on every attempt — only --force could clear the home (found by an operator
9311
+ // rehearsal on 0.25.3). The descriptor is written FOR this retry; a live
9312
+ // record only wins when it is a complete spawn record (repo + work present).
9313
+ const liveIsSpawnRecord = !!liveMeta && nonEmptyString(liveMeta.repo) && WORK_MODES.includes(liveMeta.work);
9314
+ const meta = liveIsSpawnRecord || !quarantine ? (liveMeta || quarantine?.cleanup || {}) : { ...liveMeta, ...quarantine.cleanup };
9277
9315
  // Compensation metadata is the SPAWN's, from whichever record survived — never
9278
9316
  // a failed retry's report, which says nothing about what still needs undoing.
9279
9317
  if (!liveMeta?.capabilityMeta && quarantine?.cleanup?.capabilityMeta) meta.capabilityMeta = quarantine.cleanup.capabilityMeta;
@@ -9366,9 +9404,12 @@ export function retireInstance(root, name, o = {}) {
9366
9404
  const resolved = meta.capabilityRuntime
9367
9405
  ? { capabilities: meta.capabilityRuntime }
9368
9406
  : resolveOatsConfig(meta.repo, found.agent.name);
9407
+ // The soul this HOME links (a workspace home links its per-commit directory;
9408
+ // agents/<name>/soul may since point elsewhere) — the same value spawn's hook saw.
9409
+ const homeSoulLink = (() => { try { return realpathSync(join(found.home, "soul")); } catch { return null; } })();
9369
9410
  hookResults = runLifecycleHooks("retire", {
9370
9411
  home: found.home, instance: name, agentName: found.agent.name,
9371
- soulDir: found.agent._soulDir || join(found.agent._dir, "soul"),
9412
+ soulDir: homeSoulLink || found.agent._soulDir || join(found.agent._dir, "soul"),
9372
9413
  contextDir: meta.repo, workspaceDir: workspaceOf(root), rootDir: root, resolved, priorMeta: meta.capabilityMeta || {},
9373
9414
  });
9374
9415
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.25.2",
3
+ "version": "0.25.4",
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",