@awebai/oats 0.39.0 → 0.39.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.
@@ -468,6 +468,17 @@ confirmed member (`evidence: {repoKey, workspace, commit}`); `fail` when not
468
468
  (the remedy names `oats-membership.yaml`); `unknown` when the workspace could
469
469
  not be read. External souls and standalone views are `not-applicable`,
470
470
  `required: false` (a standalone item carries `evidence.standaloneReason`).
471
+ A package soul is trusted through the workspace's `packages:` pin, never
472
+ through membership ([the non-collapse rule](workspaces.md#member-tier-vs-package-tier-the-non-collapse-rule)):
473
+ its item is `package <package>/<soul>` (producer `workspace lock`,
474
+ `evidence: {repoKey, workspace, commit, version, integrity, from}` with
475
+ `from.kind: "package"`). It passes when the package is declared and locked
476
+ and ships the soul at the locked commit, and the soul's copy is intact: for
477
+ `--soul`, the copy a spawn would link matches the locked digest; for
478
+ `--home`, the home's copy matches the digest it recorded, and a lock still
479
+ at that commit records the same digest. The copy is digested on every read.
480
+ It fails otherwise, with `code: "E_PACKAGE_INTEGRITY"` on a digest mismatch
481
+ and the remedy naming `oats sync` or a fresh spawn.
471
482
 
472
483
  **`providers`** (producer `provider binding check`): for each module whose
473
484
  manifest declares `binding`, the kernel runs its `binding.check` and relays
@@ -41,7 +41,7 @@ arrives from.
41
41
  # oats-workspace.yaml: one default per slot, for every soul
42
42
  packages:
43
43
  oats.okf: v4.1.1
44
- oats.aweb: v1.21.0
44
+ oats.aweb: v1.21.1
45
45
  oats.linear: v1.0.1
46
46
  oats.jira: v1.0.1
47
47
  defaults:
@@ -89,7 +89,13 @@ operations in full.
89
89
  `knowledge-maintainer` package soul reviews the resulting PRs.
90
90
  - **`oats.aweb`** mints a messaging identity for each instance at spawn and
91
91
  removes it at retire, contributes the aweb messaging skills, and wires the
92
- channel so sessions are woken by mail. `oats aweb roster` lists the team:
92
+ channel so sessions are woken by mail. Under `delivery: channel`, Claude Code
93
+ loads the channel plugin with `--dangerously-load-development-channels`,
94
+ since `aweb-channel` is not on its approved channel list, so every such
95
+ Claude start waits at Claude Code's
96
+ development-channels confirmation until someone answers it in the
97
+ instance's terminal; the start's warnings and readiness say so
98
+ (`channel-dev-confirmation`). `oats aweb roster` lists the team:
93
99
  its membership certificates and workspaces, one entry per alias with its
94
100
  sources, status and kind (global identities first), and says when either
95
101
  source is incomplete. After `oats server connect`, `oats aweb connect
@@ -11,7 +11,7 @@ or workspace membership alone does not make a package official.
11
11
  |---|---|---|---|
12
12
  | `oats.framework` | `oats-framework/v1.5.0` (this repository) | `oats.core`, `oats.setup`, `oats.knowledge-theory` | `knowledge-theory-expert` |
13
13
  | `oats.okf` | `v4.1.1` | `oats.okf` (knowledge), `oats.okf-harvest`, `oats.okf-maintenance` | `knowledge-harvester`, `knowledge-maintainer` |
14
- | `oats.aweb` | `v1.21.0` | `oats.aweb` (messaging) | |
14
+ | `oats.aweb` | `v1.21.1` | `oats.aweb` (messaging) | |
15
15
  | `oats.engineering` | `v1.5.0` | `oats.engineering-expert`, `oats.developer`, `oats.code-review` | `code-reviewer` |
16
16
  | `oats.authoring` | `v1.0.3` | `oats.authoring` | |
17
17
  | `oats.jira` | `v1.0.1` | `oats.jira` (tasks) | |
package/docs/packages.md CHANGED
@@ -76,7 +76,7 @@ members:
76
76
  packages:
77
77
  oats.framework: v1.5.0
78
78
  oats.okf: v4.1.1
79
- oats.aweb: v1.21.0
79
+ oats.aweb: v1.21.1
80
80
  teams:
81
81
  platform: { team: "platform:acme.aweb.ai", description: Platform engineering }
82
82
  defaults:
@@ -134,7 +134,7 @@ Declaring a package in the workspace's `packages:` is the trust decision
134
134
  ## `oats package add | remove`
135
135
 
136
136
  ```bash
137
- oats package add oats.aweb v1.21.0 # a catalog version
137
+ oats package add oats.aweb v1.21.1 # a catalog version
138
138
  oats package add acme.tools git:github.com/acme/tools@v0.4.0
139
139
  oats package remove acme.tools
140
140
  ```
@@ -0,0 +1,42 @@
1
+ # OATS 0.39.1
2
+
3
+ ## Changed
4
+
5
+ - **oats.aweb 1.21.1: a Claude Code start says that it will wait at Claude
6
+ Code's development-channels confirmation** (awebai/oats-aweb#44). Under
7
+ `delivery: channel`, Claude Code loads the `aweb-channel` plugin with
8
+ `--dangerously-load-development-channels`, because the plugin is not on
9
+ Claude Code's approved channel list. Claude Code then stops at its "Loading
10
+ development channels" confirmation before the session starts, and waits
11
+ until someone answers it in the instance's terminal. Unattended starts
12
+ (Desktop starts and restarts, automations, successors) waited there with
13
+ nothing saying so. Now:
14
+ - every spawn or start that adds the flag carries the warning
15
+ `channel-dev-confirmation` once;
16
+ - `oats readiness` reports it, without changing the status, for a home
17
+ whose last start was Claude Code under `channel`.
18
+
19
+ Nothing answers the confirmation on the human's behalf. Launch arguments
20
+ and settings are unchanged. `--channels` registers only plugins on Claude
21
+ Code's approved list; for a plugin not on it, Claude Code prints a startup
22
+ warning and the channel does not register. That list is Anthropic's
23
+ default, or a Team/Enterprise organization's managed
24
+ `allowedChannelPlugins`, which replaces the default and requires
25
+ `channelsEnabled: true`.
26
+
27
+ ## Fixed
28
+
29
+ - **`oats readiness` no longer fails package souls on membership**
30
+ (awebai/oats#533). A package soul (`oats.okf/knowledge-maintainer`,
31
+ `oats.engineering/code-reviewer`, …) failed its `member` check with
32
+ "member <its package's repository>: not a member of the workspace", and the
33
+ remedy asked to list the package's repository in `members:`. A package
34
+ soul is trusted through the workspace's `packages:` pin, never through
35
+ membership (see [the non-collapse rule](../workspaces.md#member-tier-vs-package-tier-the-non-collapse-rule)).
36
+ Its `member` item is now `package <package>/<soul>`. It passes when the
37
+ package is declared and locked, ships the soul at the locked commit, and
38
+ the soul's copy matches its digest: for `--soul`, the copy a spawn would
39
+ link against the lock; for `--home`, the home's copy against the digest it
40
+ recorded, and the lock against that same digest while it still pins the
41
+ same commit. A mismatch fails with `E_PACKAGE_INTEGRITY`. Member and
42
+ external souls are unchanged. Spawn already applied this rule.
@@ -15,7 +15,7 @@ import { accessSync, constants as fsConstants, existsSync, readFileSync, realpat
15
15
  import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { capabilityManifests, sessionDefaults, homeLaunchLayers, instanceSoulDir, launchConfigsAt, launchFolderTrust, launchReportFor, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, upgradeHomeMeta, withConfigFile } from "./core.mjs";
18
- import { agentDirOf, discoverOrStandalone, ensureWorkspaceSoul, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
18
+ import { agentDirOf, discoverOrStandalone, ensureWorkspaceSoul, findSoulEntry, liveTeams, prepareInstance, soulCopyDigests } from "./instance-resolution.mjs";
19
19
  import { declaredSettings } from "./capability-contract.mjs";
20
20
  import { kernelCompatibility } from "./resolve.mjs";
21
21
  import { discoveredTeamKeys, isRemovedTeamKeys, isTeamRefusal, recordedTeams, reportRows, soulKeyOf, teamKeyOf, soulTeams, teamModel, teamProblems } from "./teams.mjs";
@@ -133,7 +133,8 @@ export async function homeTarget(home, meta, { remoteOptions, discover = true, l
133
133
  kind: "instance", home: realHome, meta, deployment, agentsRoot, teams: live.teams, defaultTeam: live.defaultTeam ?? null, teamsSource: live.source, launch, launchCurrent, session: newSessionDefaults(deployment),
134
134
  recordedDefaultTeam: recordedTeams(meta).defaultTeam, teamModel: model, teamKey,
135
135
  subject: { kind: "instance", instance: meta.instance, home, soul: meta.agent },
136
- soul: { name: meta.agent, repoKey: ws.soul?.repoKey ?? null, commit: ws.soul?.commit ?? null, external, path: null, soulDir, definition, problems: [...problems, ...loadProblems] },
136
+ soul: { name: meta.agent, repoKey: ws.soul?.repoKey ?? null, commit: ws.soul?.commit ?? null, external, path: null, soulDir, definition, problems: [...problems, ...loadProblems],
137
+ ...(obj(ws.soul?.package) && typeof ws.soul.package.id === "string" ? { package: { id: ws.soul.package.id, soul: ws.soul.name ?? meta.agent, version: ws.soul.package.version ?? null, commit: ws.soul.package.commit ?? ws.soul.commit ?? null, integrity: ws.soul.package.digest ?? null } } : {}) },
137
138
  workspace: { key: ws.key ?? null, name: typeof ws.name === "string" ? ws.name : discovery?.workspace?.name ?? null, deployment, commit: ws.commit ?? null, standalone: ws.standalone === true },
138
139
  modules: Object.keys(modules).sort().map((name) => ({ name, from: modules[name]?.from ?? null, manifest: manifests[name] ?? null, dir: join(realHome, ".oats", "modules", name) })),
139
140
  payloads: obj(meta.providers) ? meta.providers : {},
@@ -189,7 +190,8 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
189
190
  kind: "soul", home: null, meta: null, deployment, agentsRoot: join(deployment, "agents"), teams, defaultTeam, teamsSource: "live", teamModel: model, teamKey, launch, session: newSessionDefaults(deployment),
190
191
  subject: { kind: "soul", soul: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null },
191
192
  soul: { name: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null, external: soulEntry.external === true, path: soulEntry.path ?? null,
192
- soulDir, definition: soulEntry.definition ?? null, problems: [], ...(soulCopyError ? { copyError: soulCopyError } : {}) },
193
+ soulDir, definition: soulEntry.definition ?? null, problems: [], ...(soulCopyError ? { copyError: soulCopyError } : {}),
194
+ ...(typeof soulEntry.package === "string" ? { package: { id: soulEntry.package, soul: soulEntry.name, version: soulEntry.version ?? null, commit: soulEntry.commit ?? null, integrity: soulEntry.digest ?? null } } : {}) },
193
195
  workspace: { key: discovery?.key ?? null, name: discovery?.workspace?.name ?? null, deployment, commit: discovery?.commit ?? null, standalone: discovery?.standalone === true },
194
196
  modules: (res?.modules || []).map((m) => ({ name: m.name, from: m.from ?? null, manifest: m.manifest ?? null, dir: null, module: m })).sort((a, b) => a.name.localeCompare(b.name)),
195
197
  payloads: obj(res?.payloads) ? res.payloads : {}, payloadOrigins: obj(res?.payloadOrigins) ? res.payloadOrigins : {}, slots: obj(res?.slots) ? res.slots : Object.fromEntries(LAYERS.map((l) => [l, null])), slotsFrom: obj(res?.slotsFrom) ? res.slotsFrom : {},
@@ -404,6 +406,48 @@ export function runProviderCheck(t, mod, dir, { timeoutMs = 30000 } = {}) {
404
406
  || (res.status === "ready" && res.problems.length)) return invalid();
405
407
  return { outcome: "result", result: { status: res.status, problems: codeMessages(res.problems), warnings: codeMessages(res.warnings ?? []) } };
406
408
  }
409
+ /** The `member` item of a package soul. A package soul is trusted through the workspace's
410
+ * `packages:` pin (the lock's commit and integrity), never through membership: its
411
+ * repository is not a member and need not be (docs/workspaces.md, the non-collapse rule).
412
+ * It passes when the discovery lists the soul from the lock (the package declared and
413
+ * locked, the soul shipped at the locked commit) and the soul's copy is intact: for a
414
+ * soul, the copy a spawn would link matches the locked digest; for a home, its copy
415
+ * matches the digest it recorded, and a lock still at that commit records the same one.
416
+ * A copy is digested every read: a complete copy is reused, never refetched. */
417
+ function packageSoulItem(t, d) {
418
+ const p = t.soul.package;
419
+ const subject = `package ${p.id}/${p.soul}`;
420
+ const from = { kind: "package", package: p.id, version: p.version, commit: p.commit, integrity: p.integrity };
421
+ const evidence = { repoKey: t.soul.repoKey, workspace: d?.key ?? null, commit: p.commit, version: p.version, integrity: p.integrity, from };
422
+ if (!d) return item(subject, "unknown", { producer: "workspace discovery", reason: t.discoveryError ? `the workspace could not be read: ${t.discoveryError.message}` : "no workspace observation", evidence });
423
+ if (d.standalone === true) return item(subject, "not-applicable", { required: false, producer: "workspace discovery", reason: `standalone view (${d.standaloneReason ?? "explicit"}): the workspace's packages: is not read, so the package pin is recorded, not observed`, evidence });
424
+ const shipped = (d.packageSouls || []).filter((s) => s.package === p.id);
425
+ const live = shipped.find((s) => s.name === p.soul);
426
+ if (!live) {
427
+ return item(subject, "fail", { producer: "workspace lock", evidence,
428
+ reason: shipped.length ? `the version of ${p.id} locked in this workspace does not ship the soul ${p.soul}` : `${p.id} is not locked in this workspace's packages:`,
429
+ remedy: shipped.length ? `pin a release of ${p.id} that ships ${p.soul} in the workspace's packages:, then oats sync` : `declare ${p.id} in the workspace's packages:, then oats sync` });
430
+ }
431
+ const integrity = (reason, remedy) => item(subject, "fail", { producer: "workspace lock", code: "E_PACKAGE_INTEGRITY", reason, evidence, remedy });
432
+ const lockRemedy = "oats sync (a lock that was edited: remove the entry and sync again)";
433
+ const copy = t.soul.copyError;
434
+ if (copy?.code === "E_PACKAGE_INTEGRITY") return integrity(copy.message, lockRemedy);
435
+ const dir = t.soul.soulDir;
436
+ if (!dir) return item(subject, "unknown", { producer: "workspace lock", reason: "the soul's copy is not available, so its integrity cannot be checked", evidence });
437
+ const expected = t.home ? "recorded" : "locked";
438
+ let digests;
439
+ try { digests = soulCopyDigests(dir); }
440
+ catch (e) { return integrity(`the soul's copy at ${dir} cannot be digested: ${e.message}`, `move ${dir} aside, then spawn again (a fresh copy is fetched and verified)`); }
441
+ if (!digests.includes(p.integrity)) {
442
+ return integrity(`the soul's copy at ${dir} digests ${digests.at(-1)}, not the ${expected} ${p.integrity}${t.home ? "" : " (the lock's)"}`,
443
+ t.home ? `the copy changed after its spawn, and a spawn reuses it: move ${dir} aside, then spawn a new instance of ${p.id}/${p.soul} (a fresh copy is fetched and verified against the lock)` : `move ${dir} aside, then spawn again (a fresh copy is fetched and verified against the lock); if the lock was edited: ${lockRemedy}`);
444
+ }
445
+ if (t.home && live.commit === p.commit && live.digest !== p.integrity) {
446
+ return integrity(`the lock records ${live.digest} for the same commit ${String(p.commit).slice(0, 12)}; this home recorded ${p.integrity} at its spawn`, lockRemedy);
447
+ }
448
+ return item(subject, "pass", { producer: "workspace lock", evidence });
449
+ }
450
+
407
451
  /** The total time the providers check may take in one readiness read. */
408
452
  export const PROVIDER_CHECK_BUDGET_MS = 60_000;
409
453
 
@@ -430,9 +474,11 @@ export async function readinessDocument(t, { selector = null, remoteOptions, cat
430
474
  }
431
475
  configured.push(...teamItems(t), ...launchItems(t), ...folderTrustItems(t));
432
476
  // member — the soul's member repository is a confirmed member of the workspace
433
- // (oats-membership.yaml backlink observed over the remotes).
477
+ // (oats-membership.yaml backlink observed over the remotes); a package soul's is its
478
+ // package instead (packageSoulItem).
434
479
  const d = t.discovery;
435
- if (t.soul.external) member.push(item(`soul ${t.soul.name}`, "not-applicable", { required: false, producer: "workspace discovery", reason: "an external soul is declared by the workspace; it has no member repository", evidence: { repoKey: t.soul.repoKey } }));
480
+ if (t.soul.package) member.push(packageSoulItem(t, d));
481
+ else if (t.soul.external) member.push(item(`soul ${t.soul.name}`, "not-applicable", { required: false, producer: "workspace discovery", reason: "an external soul is declared by the workspace; it has no member repository", evidence: { repoKey: t.soul.repoKey } }));
436
482
  else if (!d) member.push(item(`member ${t.soul.repoKey ?? "?"}`, "unknown", { producer: "workspace discovery", reason: t.discoveryError ? `the workspace could not be read: ${t.discoveryError.message}` : "no workspace observation", evidence: { repoKey: t.soul.repoKey } }));
437
483
  // A standalone view is an allowed mode (decision 10): membership cannot be confirmed
438
484
  // there by definition, so it is not a readiness requirement (as for an external soul).
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * Nothing here reads `oats-config.yaml`, an installed-capability directory or a
18
18
  * per-soul `source:` — those do not exist in this model. */
19
- import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from "node:fs";
19
+ import { existsSync, readFileSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
20
20
  import { join, resolve as resolvePath, dirname, relative, isAbsolute, sep } from "node:path";
21
21
  import { oatsError } from "./errors.mjs";
22
22
  import { loadLocal, discoverWorkspace, discoverRepo, standaloneRepo, observeWorkspace } from "./workspace.mjs";
@@ -25,7 +25,7 @@ import { BY_TEAM_REMOVED, isRemovedTeamKeys, isTeamRefusal, memberNameOf, record
25
25
  import { launchLayers } from "./launch-preference.mjs";
26
26
  import { declaredSettings } from "./capability-contract.mjs";
27
27
  import { materialize, moduleSkills, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
28
- import { fetchRemoteTree } from "./remote.mjs";
28
+ import { contentDigest, fetchRemoteTree } from "./remote.mjs";
29
29
  import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, symlinkSync, statSync } from "node:fs";
30
30
  import { tmpdir } from "node:os";
31
31
  import { spawnSync } from "node:child_process";
@@ -232,6 +232,19 @@ async function fetchSoulSource(prepared, dest) {
232
232
  if (!existsSync(join(dest, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(dest, "CLAUDE.md"));
233
233
  }
234
234
 
235
+ /** The digests a soul copy may carry as its source was fetched (fetchSoulSource): the copy itself, and,
236
+ * when it holds the CLAUDE.md → AGENTS.md alias, the copy without it, since fetchSoulSource adds that
237
+ * alias to a source that has none. A package soul's copy is intact when one of them is its digest. */
238
+ export function soulCopyDigests(dir) {
239
+ const options = { allowSymlinks: SOUL_ALIAS_SYMLINK };
240
+ const alias = join(dir, "CLAUDE.md");
241
+ let aliased = false;
242
+ try { aliased = lstatSync(alias).isSymbolicLink() && readlinkSync(alias) === "AGENTS.md"; } catch { aliased = false; }
243
+ const digests = [contentDigest(dir, options)];
244
+ if (aliased) digests.push(contentDigest(dir, { ...options, omit: (rel) => rel === "CLAUDE.md" }));
245
+ return digests;
246
+ }
247
+
235
248
  /** A preview's soul source. A preview writes NOTHING in the deployment (K6b), so it
236
249
  * never populates the per-commit cache or moves the `soul` pointer: it reads the
237
250
  * cache entry for the prepared commit when one is already complete, else fetches
package/lib/remote.mjs CHANGED
@@ -2139,7 +2139,8 @@ function createDigest() {
2139
2139
  }
2140
2140
 
2141
2141
  /** Same digest as fetchRemoteTree, computed over a local directory. Symlinks and
2142
- * non-regular entries are refused (E_REMOTE_TREE_UNSAFE); a missing dir → E_REMOTE_PATH_MISSING. */
2142
+ * non-regular entries are refused (E_REMOTE_TREE_UNSAFE); a missing dir → E_REMOTE_PATH_MISSING.
2143
+ * `omit(relPath, absPath)` → true leaves an entry out (what a copy gained after its fetch). */
2143
2144
  function posixNormalize(p) {
2144
2145
  const out = [];
2145
2146
  for (const seg of p.split("/")) { if (!seg || seg === ".") continue; if (seg === "..") { if (out.length && out.at(-1) !== "..") out.pop(); else out.push(".."); } else out.push(seg); }
@@ -2155,7 +2156,7 @@ function posixNormalize(p) {
2155
2156
  */
2156
2157
  export const OATS_ALIAS_SYMLINK = (relPath) => typeof relPath === "string" && (relPath === "CLAUDE.md" || relPath.endsWith("/CLAUDE.md"));
2157
2158
 
2158
- export function contentDigest(dir, { allowSymlinks = null } = {}) {
2159
+ export function contentDigest(dir, { allowSymlinks = null, omit = null } = {}) {
2159
2160
  if (typeof dir !== "string" || !isAbsolute(dir)) throw fail("E_REPO_REF", "contentDigest requires an absolute directory path", { path: dir });
2160
2161
  let st;
2161
2162
  try { st = lstatSync(dir); } catch { throw fail("E_REMOTE_PATH_MISSING", `${dir} does not exist`, { path: dir }); }
@@ -2165,6 +2166,7 @@ export function contentDigest(dir, { allowSymlinks = null } = {}) {
2165
2166
  for (const name of readdirSync(abs)) {
2166
2167
  if (!rel && name === ".git") continue; // a local checkout's metadata is not content
2167
2168
  const p = join(abs, name), r = rel ? `${rel}/${name}` : name, s = lstatSync(p);
2169
+ if (typeof omit === "function" && omit(r, p)) continue;
2168
2170
  if (s.isSymbolicLink()) {
2169
2171
  // Refused by default (contract §1). The same narrow opt-in fetchRemoteTree
2170
2172
  // honours (a relative, non-escaping alias such as CLAUDE.md → AGENTS.md)
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "oats.aweb": {
10
10
  "url": "https://github.com/awebai/oats-aweb.git",
11
- "ref": "v1.21.0",
11
+ "ref": "v1.21.1",
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.39.0",
3
+ "version": "0.39.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",