@awebai/oats 0.25.8 → 0.25.9

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.
@@ -137,7 +137,7 @@ try {
137
137
  const manifest = JSON.parse(readFileSync(new URL("../oats.json", import.meta.url), "utf8"));
138
138
  const selected = requireCapturedAwebAction(loaded, event, manifest);
139
139
  const settings = parseBindingJson(Buffer.from(process.env.OATS_SETTINGS || "{}"));
140
- if (!settings || typeof settings !== "object" || Array.isArray(settings) || Object.keys(settings).some(k => k !== "delivery")) throw new Error("captured settings support delivery only; no identity copying or ambient fallback");
140
+ if (!settings || typeof settings !== "object" || Array.isArray(settings) || Object.keys(settings).some(k => !["delivery", "team", "root", "roots"].includes(k))) throw new Error("captured settings support delivery/team/root readiness only; no identity copying or ambient fallback");
141
141
  const checked = assessCapturedSessionReadiness({ binding: selected.binding, invocation: selected.context, settings }, {
142
142
  query(args, options) { selected.assertCurrent(); const result = querySelectedKernel(args, options); selected.assertCurrent(); return result; },
143
143
  });
@@ -194,7 +194,7 @@ function gitRootOf(startDir) {
194
194
  d = parent;
195
195
  }
196
196
  }
197
- function awebRoot() {
197
+ function classicAwebRoot() {
198
198
  const candidates = [];
199
199
  const push = (p) => { if (p && !candidates.includes(resolve(p))) candidates.push(resolve(p)); };
200
200
  push(process.env.OATS_TEAM_SCOPE);
@@ -206,6 +206,36 @@ function awebRoot() {
206
206
  for (const c of candidates) if (existsSync(join(c, ".aw"))) return c;
207
207
  return undefined;
208
208
  }
209
+ const hasWorkspaceV2Facts = () => !!(process.env.OATS_WORKSPACE_KEY || process.env.OATS_WORKSPACE_NAME || process.env.OATS_TEAM_LABEL);
210
+ const isClassicDeployment = () => !!process.env.OATS_TEAM_SCOPE && !hasWorkspaceV2Facts();
211
+ const teamConfigRemedy = () => `set messaging.byTeam.<label>.team in the workspace file or settings.oats.aweb.team${isClassicDeployment() ? " (classic: set team.id in oats-config.yaml)" : ""}`;
212
+ function declaredRootCandidate(team = payloadTeam().team) {
213
+ const roots = settings.roots && typeof settings.roots === "object" && !Array.isArray(settings.roots) ? settings.roots : {};
214
+ if (team && typeof roots[team] === "string" && roots[team].trim()) return { root: roots[team].trim(), key: `settings.oats.aweb.roots[${JSON.stringify(team)}]`, declared: true };
215
+ if (typeof settings.root === "string" && settings.root.trim()) return { root: settings.root.trim(), key: "settings.oats.aweb.root", declared: true };
216
+ return undefined;
217
+ }
218
+ function rootSettingCandidate(team = payloadTeam().team) {
219
+ const declared = declaredRootCandidate(team);
220
+ if (declared) return declared;
221
+ const fallback = process.env.OATS_WORKSPACE || (!isClassicDeployment() ? process.env.OATS_TEAM_SCOPE : undefined) || process.cwd();
222
+ return fallback ? { root: fallback, key: "settings.oats.aweb.root", declared: false } : undefined;
223
+ }
224
+ function awebRootProblem(candidate) {
225
+ if (!candidate?.root) return `no messaging root at ${process.cwd()}: run oats aweb setup there or set settings.oats.aweb.root`;
226
+ if (!isAbsolute(candidate.root)) return `${candidate.key} must be an absolute directory whose .aw is the aweb minting root`;
227
+ return `no messaging root at ${resolve(candidate.root)}: run oats aweb setup there or set ${candidate.key}`;
228
+ }
229
+ function resolveAwebRoot() {
230
+ const declared = declaredRootCandidate();
231
+ if (declared && isAbsolute(declared.root) && existsSync(join(resolve(declared.root), ".aw"))) return resolve(declared.root);
232
+ if (declared?.declared) return undefined;
233
+ if (isClassicDeployment()) return classicAwebRoot();
234
+ const candidate = rootSettingCandidate();
235
+ if (candidate && isAbsolute(candidate.root) && existsSync(join(resolve(candidate.root), ".aw"))) return resolve(candidate.root);
236
+ return undefined;
237
+ }
238
+ function awebRoot() { return resolveAwebRoot(); }
209
239
 
210
240
  /** Team memberships from `aw team list --json`. The current CLI returns
211
241
  * `memberships`; older output used `teams`. Spawn resolution and `oats aweb
@@ -411,9 +441,9 @@ function retainedSeatSpawn(source, takeOver) {
411
441
  const service = process.env.OATS_AWEB_URL || yamlScalar(srcWorkspace, "aweb_url");
412
442
  if (!service) fatal(`cannot determine the aweb service for ${source} (no aweb_url in its workspace.yaml)`);
413
443
  const role = yamlScalar(srcWorkspace, "role_name");
414
- let team = process.env.OATS_TEAM_ID;
444
+ let team = payloadTeam().team;
415
445
  if (!team && existsSync(join(source, "teams.yaml"))) team = yamlScalar(readFileSync(join(source, "teams.yaml"), "utf8"), "active_team") || yamlScalar(readFileSync(join(source, "teams.yaml"), "utf8"), "active");
416
- if (!team || !team.includes(":")) fatal(`cannot determine the team for the retained identity (set team.id in oats-config.yaml, or an active team in ${join(source, "teams.yaml")})`);
446
+ if (!team || !team.includes(":")) fatal(`cannot determine the team for the retained identity (${teamConfigRemedy()}, or keep an active team in ${join(source, "teams.yaml")})`);
417
447
  const dest = join(home, ".aw");
418
448
  const legacyHome = dirname(source);
419
449
  // The lock is taken FIRST: a concurrent second spawn must see it before any
@@ -490,7 +520,7 @@ function retainedSeatSpawn(source, takeOver) {
490
520
  const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
491
521
  ? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
492
522
  : undefined;
493
- const env = deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : undefined;
523
+ const env = { ...(deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : {}), AWEB_IDENTITY_HOME: dest };
494
524
  const deliveryBrief = deliveryMode === "session"
495
525
  ? ` Notification delivery: external (AWEB_DELIVERY=session); until the host wake broker registers this instance NOTHING wakes you: check \`aw mail inbox\` and \`aw chat pending\` at every task boundary.`
496
526
  : "";
@@ -500,7 +530,7 @@ function retainedSeatSpawn(source, takeOver) {
500
530
  if (hostNote) warnings.push(`oats-aweb: seated${hostNote}`);
501
531
  out({
502
532
  meta: { team, alias, retained: true, source, lock: lockPath, delivery: deliveryMode, identity: identityMeta({ mode: "global", alias, team, address: shownAddress || expectedAddress || null }), ...(takenOver ? { tookOverFrom: takenOver } : {}) },
503
- ...(env ? { env } : {}),
533
+ env,
504
534
  brief: `Comms: you are the retained seat of the existing aweb identity "${alias}" on team ${team} (same did and address as the seat you replace; its contacts, routes and conversations are yours).${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill).`,
505
535
  ...(launch ? { launch } : {}),
506
536
  ...(warnings.length ? { warning: warnings.join(" | ") } : {}),
@@ -517,7 +547,11 @@ if (event === "spawn") {
517
547
  if (identityMode === "local" && settings.identity && typeof settings.identity === "object" && settings.identity.source) retainedSeatSpawn(String(settings.identity.source), settings.identity.takeOver === true);
518
548
  let minted; // external identity, once `aw team join` succeeds
519
549
  const root = awebRoot();
520
- if (!root) fatal(`no initialized aweb root (.aw) among the bounded candidates (home, its git repo, context repo, workspace ${process.env.OATS_WORKSPACE || "?"}), so no identity could be minted and this instance would have no messaging — run \`oats aweb setup\` for guided onboarding`);
550
+ if (!root) {
551
+ const candidate = rootSettingCandidate();
552
+ const classicHint = isClassicDeployment() ? " (classic fallback also checked the bounded team-scope candidates)" : "";
553
+ fatal(`${awebRootProblem(candidate)}, so no identity could be minted and this instance would have no messaging${classicHint}`);
554
+ }
521
555
  try {
522
556
  // Team correctness: the config's `team:` block wins (id, then name), else the
523
557
  // root's active team. ALWAYS pass --team-id explicitly — never inherit whatever
@@ -527,15 +561,16 @@ if (event === "spawn") {
527
561
  const resolvedTeam = payloadTeam();
528
562
  let team = resolvedTeam.team;
529
563
  const teamPayloadMismatch = resolvedTeam.payload && resolvedTeam.env && resolvedTeam.payload !== resolvedTeam.env;
564
+ if (!team && process.env.OATS_TEAM_LABEL) fatal(`cannot determine target team for workspace team label ${JSON.stringify(process.env.OATS_TEAM_LABEL)}, so no identity could be minted — ${teamConfigRemedy()}`);
530
565
  if (!team) team = JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
531
- if (!team) fatal("cannot determine target team (no config team block, no active team at root), so no identity could be minted — set a team: block in oats-config.yaml, or activate a team at the aweb root");
566
+ if (!team) fatal(`cannot determine target team, so no identity could be minted — ${teamConfigRemedy()}, or activate a team at the aweb root`);
532
567
  // A bare team name (no namespace) resolves against the root's memberships.
533
568
  if (!team.includes(":")) {
534
569
  const teams = JSON.parse(run(["aw", "team", "list", "--json"], root));
535
570
  const match = teamIdsOf(teams).filter((tid) => String(tid).startsWith(`${team}:`));
536
571
  if (match.length === 1) team = match[0];
537
- else if (match.length > 1) fatal(`team name "${team}" is ambiguous at ${root}: ${match.join(", ")}, so no identity could be minted — set team.id in oats-config.yaml`);
538
- else fatal(`no membership matching team "${team}" at ${root}, so no identity could be minted — join or create it first (aweb-team-membership skill), or set team.id`);
572
+ else if (match.length > 1) fatal(`team name "${team}" is ambiguous at ${root}: ${match.join(", ")}, so no identity could be minted — ${teamConfigRemedy()}`);
573
+ else fatal(`no membership matching team "${team}" at ${root}, so no identity could be minted — join or create it first (aweb-team-membership skill), or ${teamConfigRemedy()}`);
539
574
  }
540
575
  // Both of these carry the invite token — one mints it, the other spends it —
541
576
  // so neither their output nor their diagnostics may reach a log.
@@ -597,7 +632,7 @@ if (event === "spawn") {
597
632
  const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
598
633
  ? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
599
634
  : undefined;
600
- const env = deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : undefined;
635
+ const env = { ...(deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : {}), AWEB_IDENTITY_HOME: join(home, ".aw") };
601
636
  const channelWarning = undefined;
602
637
  if (deliveryMode === "session") wakeRegister(home, join(home, ".aw"));
603
638
  const deliveryBrief = deliveryMode === "session"
@@ -605,7 +640,7 @@ if (event === "spawn") {
605
640
  : "";
606
641
  out({
607
642
  meta: { team: joined.team_id, alias, delivery: deliveryMode, identity: identityMeta({ mode: "local", alias, team: joined.team_id }) },
608
- ...(env ? { env } : {}),
643
+ env,
609
644
  brief: `Comms: you have an aweb identity — alias "${alias}" on team ${joined.team_id}.${mismatch}${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
610
645
  ...(launch ? { launch } : {}),
611
646
  ...(joined.team_id !== team ? { warning: `oats-aweb: team mismatch — joined ${joined.team_id}, expected ${team}` } : teamPayloadMismatch ? { warning: `oats-aweb: settings.oats.aweb.team ${resolvedTeam.payload} differs from OATS team ${resolvedTeam.env}; using payload team` } : channelWarning ? { warning: channelWarning } : {}),
@@ -673,9 +708,9 @@ if (event === "spawn") {
673
708
  // wherever they run (plus human members). Local liveness comes from
674
709
  // `oats status --team`; this is the network view.
675
710
  const root = awebRoot();
676
- if (!root) { console.error("oats aweb roster: no initialized aweb root (.aw) found"); process.exit(1); }
711
+ if (!root) { console.error(`oats aweb roster: ${awebRootProblem(rootSettingCandidate())}`); process.exit(1); }
677
712
  const team = process.env.OATS_TEAM_ID || process.env.OATS_TEAM_NAME || JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
678
- if (!team) { console.error("oats aweb roster: cannot determine team (no config team block, no active team)"); process.exit(1); }
713
+ if (!team) { console.error(`oats aweb roster: cannot determine team (${teamConfigRemedy()}, or activate a team at the aweb root)`); process.exit(1); }
679
714
  const teamFlag = team.includes(":") ? ["--team-id", team] : ["--team", team];
680
715
  const r = JSON.parse(run(["aw", "id", "team", "members", ...teamFlag, "--json"], root, 60000));
681
716
  if (process.argv.includes("--json")) { console.log(JSON.stringify(r, null, 2)); process.exit(0); }
@@ -687,18 +722,59 @@ if (event === "spawn") {
687
722
  process.exit(0);
688
723
  } else if (event === "setup") {
689
724
  // Guided onboarding — idempotent, prints what it finds and the one next step.
690
- const scope = process.env.OATS_TEAM_SCOPE || process.cwd();
691
- const teamName = process.env.OATS_TEAM_NAME;
692
- const teamId = process.env.OATS_TEAM_ID;
693
- console.log(`aweb onboarding — team scope: ${scope}${teamName ? `, config team: ${teamName}${teamId ? ` (${teamId})` : ""}` : ""}\n`);
725
+ if (isClassicDeployment() && settings.root === undefined && settings.roots === undefined && settings.team === undefined) {
726
+ const scope = process.env.OATS_TEAM_SCOPE || process.cwd();
727
+ const teamName = process.env.OATS_TEAM_NAME;
728
+ const teamId = process.env.OATS_TEAM_ID;
729
+ console.log(`aweb onboarding — team scope: ${scope}${teamName ? `, config team: ${teamName}${teamId ? ` (${teamId})` : ""}` : ""}\n`);
730
+ if (!teamName) {
731
+ console.log("1. Declare your team in the deployment scope's oats-config.yaml first:");
732
+ console.log(" team:\n name: <your-team>\n then re-run `oats aweb setup` from there.");
733
+ process.exit(0);
734
+ }
735
+ if (!existsSync(join(scope, ".aw"))) {
736
+ console.log(`No aweb workspace at the team scope yet. Initialize it (interactive — creates or connects an aweb account):`);
737
+ console.log(` cd ${scope} && aw init`);
738
+ console.log(" First time on aweb? `aw init` walks you through creating a hosted aweb.ai account.");
739
+ console.log(" Own your domain? Use `aw init --byod` (see the aweb-team-membership skill).");
740
+ process.exit(0);
741
+ }
742
+ let teams = { memberships: [] };
743
+ try { teams = JSON.parse(run(["aw", "team", "list", "--json"], scope)); } catch { /* fall through */ }
744
+ const want = teamId || teamName;
745
+ const match = teamIdsOf(teams).find((tid) => String(tid) === want || String(tid).startsWith(`${want}:`));
746
+ if (match) {
747
+ console.log(`✓ aweb workspace initialized and member of ${match}.`);
748
+ if (teams.active_team && teams.active_team !== match) console.log(` Note: active team is ${teams.active_team}; instances join ${match} explicitly, but consider \`aw team switch ${match}\`.`);
749
+ console.log(" Done — spawned instances will join this team automatically (alias = instance name).");
750
+ console.log(" Roster: `oats aweb roster` · local: `oats status --team`");
751
+ } else {
752
+ console.log(`Workspace initialized, but no membership matching "${want}".`);
753
+ console.log(` Create the team: cd ${scope} && aw team create ${teamName}`);
754
+ console.log(" Or join an existing one: get an invite token from a member, then `aw team join <token>`");
755
+ console.log(" (details: aweb-team-membership skill)");
756
+ }
757
+ process.exit(0);
758
+ }
759
+ const resolvedTeam = payloadTeam();
760
+ const teamName = resolvedTeam.team;
761
+ const teamId = typeof settings.team === "string" && settings.team.trim() ? settings.team.trim() : process.env.OATS_TEAM_ID;
762
+ const candidate = rootSettingCandidate(teamName);
763
+ const scope = candidate?.root ? resolve(candidate.root) : process.cwd();
764
+ console.log(`aweb onboarding — messaging root: ${scope}${teamName ? `, team: ${teamName}` : ""}\n`);
694
765
  if (!teamName) {
695
- console.log("1. Declare your team in the deployment scope's oats-config.yaml first:");
696
- console.log(" team:\n name: <your-team>\n then re-run `oats aweb setup` from there.");
766
+ console.log(`1. Choose the aweb team for this deployment: ${teamConfigRemedy()}.`);
767
+ console.log(" The workspace file's `messaging:` / `messaging.byTeam.<label>.team` payload is portable; host-specific overrides belong in `settings.oats.aweb.team`.");
768
+ process.exit(0);
769
+ }
770
+ if (!isAbsolute(scope)) {
771
+ console.log(`${candidate?.key || "settings.oats.aweb.root"} must be an absolute directory whose .aw is the aweb minting root.`);
697
772
  process.exit(0);
698
773
  }
699
774
  if (!existsSync(join(scope, ".aw"))) {
700
- console.log(`No aweb workspace at the team scope yet. Initialize it (interactive — creates or connects an aweb account):`);
775
+ console.log(`No aweb workspace at the messaging root yet (${candidate?.key || "settings.oats.aweb.root"}). Initialize it (interactive — creates or connects an aweb account):`);
701
776
  console.log(` cd ${scope} && aw init`);
777
+ console.log(" Or set settings.oats.aweb.root to an absolute directory whose .aw is the aweb minting root, then re-run `oats aweb setup`.");
702
778
  console.log(" First time on aweb? `aw init` walks you through creating a hosted aweb.ai account.");
703
779
  console.log(" Own your domain? Use `aw init --byod` (see the aweb-team-membership skill).");
704
780
  process.exit(0);
@@ -1,3 +1,5 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { isAbsolute, join, resolve } from 'node:path';
1
3
  import { TextDecoder } from 'node:util';
2
4
  import { assessCapturedSessionReadiness } from './session-readiness.mjs';
3
5
  import {
@@ -79,8 +81,12 @@ export function parseBindingJson(bytes,limits=BINDING_WIRE_LIMITS) {
79
81
  function settings(value) {
80
82
  if(!obj(value)) wireError('invalid-binding');
81
83
  if(Object.hasOwn(value,'identity')) wireError('provider-not-qualified');
82
- keys(value,['delivery'],[]);
84
+ keys(value,['delivery','team','root','roots'],[]);
83
85
  if(value.delivery!==undefined && !['channel','session'].includes(value.delivery)) wireError('needs-configuration');
86
+ if(value.team!==undefined && (typeof value.team!=='string' || !value.team.trim())) wireError('needs-configuration');
87
+ if(value.root!==undefined && (typeof value.root!=='string' || !value.root.trim())) wireError('needs-configuration');
88
+ if(value.roots!==undefined && !obj(value.roots)) wireError('needs-configuration');
89
+ if(obj(value.roots)) for(const [team,root] of Object.entries(value.roots)) if(!team || typeof root!=='string' || !root.trim()) wireError('needs-configuration');
84
90
  return value;
85
91
  }
86
92
  function request(value,phase) {
@@ -142,6 +148,23 @@ function binding(value) {
142
148
  return value;
143
149
  }
144
150
  function checkResult(message) {return {status:'needs-configuration',problems:[{code:'needs-configuration',message}]};}
151
+ function checkProblems(problems) {return problems.length?{status:'needs-configuration',problems}:null;}
152
+ function teamFromSettings(settings) {return typeof settings.team==='string' && settings.team.trim()?settings.team.trim():(process.env.OATS_TEAM_ID || process.env.OATS_TEAM_NAME || undefined);}
153
+ function classicEnv() {return !!process.env.OATS_TEAM_SCOPE && !(process.env.OATS_WORKSPACE_KEY || process.env.OATS_WORKSPACE_NAME || process.env.OATS_TEAM_LABEL);}
154
+ function rootCandidate(settings,team) {
155
+ const roots=obj(settings.roots)?settings.roots:{};
156
+ if(team && typeof roots[team]==='string' && roots[team].trim()) return {root:roots[team].trim(),key:`settings.oats.aweb.roots[${JSON.stringify(team)}]`,declared:true};
157
+ if(typeof settings.root==='string' && settings.root.trim()) return {root:settings.root.trim(),key:'settings.oats.aweb.root',declared:true};
158
+ const candidates=classicEnv()?[process.env.OATS_TEAM_SCOPE,process.env.OATS_WORKSPACE].filter(Boolean):[process.env.OATS_WORKSPACE || process.env.OATS_TEAM_SCOPE || process.cwd()];
159
+ for(const root of candidates) if(isAbsolute(root) && existsSync(join(resolve(root),'.aw'))) return {root,key:'settings.oats.aweb.root',declared:false};
160
+ return {root:candidates[0] || process.cwd(),key:'settings.oats.aweb.root',declared:false};
161
+ }
162
+ function readinessFromSettings(settings) {
163
+ const team=teamFromSettings(settings),candidate=rootCandidate(settings,team),problems=[];
164
+ if(!candidate.root || !isAbsolute(candidate.root) || !existsSync(join(resolve(candidate.root),'.aw'))) problems.push({code:'needs-configuration',message:`no messaging root at ${candidate.root?resolve(candidate.root):process.cwd()}: run oats aweb setup there or set ${candidate.key}`});
165
+ if(!team) problems.push({code:'needs-configuration',message:'no team: set messaging.byTeam.<label>.team in the workspace file or settings.oats.aweb.team'});
166
+ return checkProblems(problems) || {status:'ready',problems:[]};
167
+ }
145
168
  function checkPhase(req) {
146
169
  keys(req.input,['binding','context','action','invocation'],['binding','context','action']);
147
170
  if(!obj(req.input.action) || typeof req.input.action.kind!=='string') wireError('invalid-binding');
@@ -150,6 +173,9 @@ function checkPhase(req) {
150
173
  const invocation=Object.hasOwn(req.input,'invocation')?validateAwebInvocationContext(req.input.invocation,current,{context:req.input.context,action:req.input.action}):null;
151
174
  if(['command','hook','operation'].includes(req.input.action.kind) && (!invocation || invocation.instance===null || invocation.intent===null)) return checkResult('an admitted captured instance intent is required for execution');
152
175
  if(current.payload.privateTeam===null) return checkResult('an explicit private-team binding is required');
176
+ const hostReady=readinessFromSettings(req.settings);
177
+ if(hostReady.status!=='ready') return hostReady;
178
+ if(!invocation) return hostReady;
153
179
  // Read-only public kernel observations, not an account/grant attestation.
154
180
  // Native setup is performed only by the separately admitted execution path.
155
181
  return assessCapturedSessionReadiness({binding:current,invocation,settings:req.settings});
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "capability": "oats.aweb",
3
3
  "command": "aweb",
4
- "version": "1.12.0",
4
+ "version": "1.12.1",
5
5
  "compatibility": {
6
6
  "oats": ">=0.24.4"
7
7
  },
@@ -139,6 +139,12 @@
139
139
  "team": {
140
140
  "description": "Target aweb team id for identity lifecycle. In workspace v2 spawns this payload value wins over OATS_TEAM_ID/OATS_TEAM_NAME; if both are set and differ the hook warns and uses this setting."
141
141
  },
142
+ "root": {
143
+ "description": "Host-owned absolute directory whose .aw is the aweb minting root. Put this only in oats-local.yaml settings.oats.aweb.root; use oats aweb setup to initialize it."
144
+ },
145
+ "roots": {
146
+ "description": "Host-owned map of aweb team id to absolute directory whose .aw is that team's minting root, for deployments that mint into several teams. Put this only in oats-local.yaml settings.oats.aweb.roots."
147
+ },
142
148
  "identity": {
143
149
  "default": {
144
150
  "mode": "local"
@@ -175,7 +175,7 @@ else {
175
175
  } else if(event==='unlock') result=unlock(resolve(flags.lock),flags.token);
176
176
  else fail('E_USAGE',`unknown command ${event}; see --help`);
177
177
  answer=hook?result:{schemaVersion:1,ok:true,result};
178
- } catch(e) {exit=1;answer=hook?{meta:{...(event==='retire'?{retired:false,reason:e.message}:{})},warning:`oats-okf: ${e.message}`}:{schemaVersion:1,ok:false,error:{code:e.code || 'E_OKF',message:e.message}};}
178
+ } catch(e) {const code=e.code || 'E_OKF';exit=1;answer=hook?{meta:{...(event==='retire'?{retired:false,reason:e.message}:{})},warning:`oats-okf ${code}: ${e.message}`}:{schemaVersion:1,ok:false,error:{code,message:e.message}};}
179
179
  // Let Node drain the pipe; no process.exit after a possibly large view.
180
180
  process.stdout.write(JSON.stringify(answer)+'\n');process.exitCode=exit;
181
181
  }
@@ -291,7 +291,7 @@ function checkPhase(req) {
291
291
  if(gitBases.length) scratch=fs.mkdtempSync(join(fs.realpathSync(tmpdir()),'oats-okf-binding-check-'));
292
292
  for(const [alias,base] of Object.entries(bindings.bases)) {
293
293
  stage=base.kind==='directory'?'validate':'stage';
294
- accepted[alias]=(base.kind==='directory'?validateBase(base.path,base):stageBase(base,join(scratch,alias))).meta;
294
+ accepted[alias]=(base.kind==='directory'?validateBase(base.path,base):stageBase(base,join(scratch,alias),{alias})).meta;
295
295
  }
296
296
  stage='runtime';
297
297
  checkKnowledgeRuntime({rendered:runtime,accepted});
@@ -33,7 +33,7 @@ export function migrate(bindings,{legacy,alias,node,output}) {
33
33
  // Stage the accepted base before any record exists: a base that cannot be
34
34
  // read leaves nothing behind. From the record on, a failure is recorded in
35
35
  // it rather than left as an unexplained directory.
36
- const stage=stageBase(base,output);
36
+ const stage=stageBase(base,output,{alias});
37
37
  fs.mkdirSync(bindings.stateDir,{recursive:true,mode:0o700});
38
38
  const id=randomUUID();const dir=join(bindings.stateDir,'migrations',id);fs.mkdirSync(dir,{recursive:true,mode:0o700});
39
39
  save(join(dir,'legacy.json'),original); // byte-preserving backup BEFORE any delivery
@@ -106,7 +106,7 @@ export function cutoverMigration(file,soul) {
106
106
  try {
107
107
  for(const alias of new Set([...decl.owns,...decl.reads].map(r=>splitRef(r)[0]))) {
108
108
  if(!Object.hasOwn(bindings.bases,alias)) fail('E_CONFIG',`unresolved base: ${alias}`);
109
- const staged=stageBase(bindings.bases[alias],join(scratch,alias));
109
+ const staged=stageBase(bindings.bases[alias],join(scratch,alias),{alias});
110
110
  accepted[alias]=staged.meta;
111
111
  if(alias===m.alias) {
112
112
  if(hash(staged.meta.nodes[m.node] || null)!==hash(frozen)) fail('E_OWNER','accepted migration ownership/path differs from delivered node');
@@ -101,7 +101,7 @@ export function views(bindings, decl, target) {
101
101
  for(const [alias,base] of Object.entries(bindings.bases)) {
102
102
  const scratch=fs.mkdtempSync(join(bindings.stateDir,'read-'));
103
103
  try {
104
- const staged=stageBase(base,join(scratch,'base'));
104
+ const staged=stageBase(base,join(scratch,'base'),{alias});
105
105
  all[alias]=staged.meta;
106
106
  const path=`bases/${alias}`;
107
107
  materialize(join(pending,path),staged.files);
@@ -192,7 +192,8 @@ export function register(home) {
192
192
  if(fs.existsSync(join(home,'knowledge'))) fail('E_VIEW','unregistered knowledge view exists; preserve it and inspect before registering');
193
193
  if(['.okf-harvest-record.json','.okf-harvest-record.next.json'].some(p=>fs.existsSync(join(home,p))) && !fs.existsSync(join(home,'.okf-v1-migration.json'))) fail('E_MIGRATION','legacy source watermarks require explicit oats okf migrate --source-home PATH before v2 registration; no cursor is silently trusted');
194
194
  const meta=fs.existsSync(join(home,'instance.json'))?readJSON(join(home,'instance.json')):{};
195
- const soul=fs.realpathSync(process.env.OATS_SOUL || join(home,'soul'));
195
+ if(!process.env.OATS_SOUL) fail('E_OATS_SOUL_MISSING','OATS_SOUL is not set; oats.okf hooks and commands run only under the OATS kernel');
196
+ const soul=fs.realpathSync(process.env.OATS_SOUL);
196
197
  const work=fs.existsSync(join(home,'work'))?fs.realpathSync(join(home,'work')):join(home,'work');
197
198
  const decl=declaration(soul);
198
199
  const soulId=process.env.OATS_SOUL_ID || null;
@@ -204,7 +205,6 @@ export function register(home) {
204
205
  if(!agent || !instance) fail('E_SOURCE','source instance/agent required');
205
206
  fs.mkdirSync(bindings.stateDir,{recursive:true,mode:0o700});
206
207
  const ownersFile=join(bindings.stateDir,'owners.json');
207
- pinOwner(ownersFile,decl.owner,{id:soulId,soulName:agent,path:soul});
208
208
  const id=randomUUID(); const dir=join(bindings.stateDir,'sources',id);
209
209
  // Copy only the role document, never instance.json wholesale, launch recipes,
210
210
  // environment, credentials, source worktree, or third-party message stores.
@@ -217,6 +217,7 @@ export function register(home) {
217
217
  fs.mkdirSync(dir,{recursive:true,mode:0o700});
218
218
  try {
219
219
  source.acceptedView=views(bindings,decl,pending);
220
+ pinOwner(ownersFile,decl.owner,{id:soulId,soulName:agent,path:soul});
220
221
  source.acceptedNodes=Object.fromEntries(Object.entries(source.acceptedView).map(([alias,r])=>[alias,r.nodes]));
221
222
  save(file,source);
222
223
  save(join(dir,'status.json'),{version:1,captured:{notes:[],threads:{},inputs:[]},processed:[],delivered:{},accepted:{},retired:false,auto:true,activeRun:null});
@@ -245,7 +246,7 @@ export function pinOwner(ownersFile,owner,{id,soulName,path}) {
245
246
  if(!obj(owners)) fail('E_OWNER','invalid owner registry');
246
247
  const prior=Object.hasOwn(owners,owner)?owners[owner]:undefined;
247
248
  const samePath=typeof prior==='string' && new RegExp(`/agents/${soulName.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}/(soul|souls/[^/]+)$`).test(prior);
248
- if(prior!==undefined && prior!==value && !(id && samePath)) fail('E_OWNER','stable owner ID already identifies a different soul in this state namespace');
249
+ if(prior!==undefined && prior!==value && !(id && samePath)) fail('E_OWNER',`stable owner ID already identifies a different soul in this state namespace: existing soul ${prior}; new soul ${value}. Remedies: retire the existing registration first, or use a fresh state directory.`);
249
250
  if(prior!==value) {owners[owner]=value;save(ownersFile,owners);}
250
251
  return value;
251
252
  });
@@ -9,6 +9,31 @@ const validator = fileURLToPath(new URL('../skills/okf/scripts/okf-validate.mjs'
9
9
  // transport subprocesses. Override even an explicitly supplied command env.
10
10
  const gitEnv = (env = cleanEnv()) => ({...env,GIT_NO_REPLACE_OBJECTS:'1'});
11
11
  export const git = (cwd,args,opts={}) => exec('git',['--no-replace-objects','-c','core.hooksPath=/dev/null','-c','protocol.ext.allow=never','-C',cwd,...args],{cwd,...opts,env:gitEnv(opts.env)});
12
+ function baseError(code,message,base,alias,step,reason) {throw Object.assign(new Error(message),{code,base:alias,repository:base.repository,step,reason});}
13
+ function baseRemedy(alias) {return `fix the binding for base alias "${alias}" in the bindings file, or remove the base from the bindings`;}
14
+ function classifyGitFailure(error) {
15
+ const text=String(error?.message || '');
16
+ if(error?.code==='ETIMEDOUT' || /ETIMEDOUT|timed out|timeout/i.test(text)) return 'timeout';
17
+ if(/authentication|permission denied|publickey|access denied|could not read Username|authorization|not authorized/i.test(text)) return 'auth';
18
+ if(/not found|repository .*not exist|does not exist|no such (file|repository)|couldn't find remote ref|Repository not found/i.test(text)) return 'not-found';
19
+ if(/could not resolve host|failed to connect|connection refused|network is unreachable|no route to host|proxy/i.test(text)) return 'network';
20
+ return 'unknown';
21
+ }
22
+ function unavailable(base,alias,step,error) {
23
+ const reason=classifyGitFailure(error),detail=reason==='unknown'?`; original Git failure: ${String(error?.message || 'unknown failure')}`:'';
24
+ const message=`Git base "${alias}" repository "${base.repository}" is required by the deployment's bindings, but ${step} failed (reason: ${reason}${detail}); ${baseRemedy(alias)}`;
25
+ baseError('E_BASE_UNAVAILABLE',message,base,alias,step,reason);
26
+ }
27
+ function requireNotShallow(base,alias,cwd) {
28
+ const shallow=git(cwd,['rev-parse','--is-shallow-repository']);
29
+ if(shallow==='true') baseError('E_BASE_SHALLOW',`Git base "${alias}" repository "${base.repository}" is required by the deployment's bindings, but the repository is shallow; oats.okf requires full accepted history before staging; ${baseRemedy(alias)}`,base,alias,'clone','shallow');
30
+ }
31
+ function preflightLocalRepository(base,alias) {
32
+ if(!base.repository.startsWith('/')) return;
33
+ if(!fs.existsSync(base.repository)) unavailable(base,alias,'clone',Object.assign(new Error('repository not found'),{code:'ENOENT'}));
34
+ try { requireNotShallow(base,alias,base.repository); }
35
+ catch(e) { if(e.code==='E_BASE_SHALLOW') throw e; unavailable(base,alias,'clone',e); }
36
+ }
12
37
  export const baseLock = b => `${b.path}.okf-lock`;
13
38
  export const journalPath = b => `${b.path}.okf-publication.json`;
14
39
  export function validateBase(root, base) {
@@ -53,9 +78,10 @@ function materializeGitObjects(base,dest,head) {
53
78
  writeBlob(dest,entry.oid,target,entry.mode==='100755'?0o755:0o644);
54
79
  }
55
80
  }
56
- function clone(base, dest, selectedHead) {
81
+ function clone(base, dest, selectedHead, { alias = base.id } = {}) {
57
82
  safePath(dest);
58
83
  if(fs.existsSync(dest)) fail('E_PATH',`staging destination exists: ${dest}`);
84
+ preflightLocalRepository(base,alias);
59
85
  fs.mkdirSync(dirname(dest),{recursive:true});
60
86
  // Fetch only what the store reads: the accepted branch, trees now and blobs
61
87
  // on demand. Only a remote that does not offer object filtering gets a plain
@@ -64,25 +90,30 @@ function clone(base, dest, selectedHead) {
64
90
  const cloneArgs=['clone','--no-hardlinks','--no-checkout','--single-branch','--branch',base.acceptedBranch];
65
91
  try { git(dirname(dest),[...cloneArgs,'--filter=blob:none','--',base.repository,dest],{timeout:gitTimeoutMs()}); }
66
92
  catch(e) {
67
- if(e.code!=='E_COMMAND' || !/filter/i.test(e.message)) throw e;
68
- fs.rmSync(dest,{recursive:true,force:true});
69
- git(dirname(dest),[...cloneArgs,'--',base.repository,dest],{timeout:gitTimeoutMs()});
93
+ if(e.code==='E_COMMAND' && /filter/i.test(e.message)) {
94
+ fs.rmSync(dest,{recursive:true,force:true});
95
+ try { git(dirname(dest),[...cloneArgs,'--',base.repository,dest],{timeout:gitTimeoutMs()}); }
96
+ catch(error) { unavailable(base,alias,'clone',error); }
97
+ } else unavailable(base,alias,'clone',e);
70
98
  }
71
- git(dest,['fetch','origin',`refs/heads/${base.acceptedBranch}`],{timeout:gitTimeoutMs()});
99
+ try { requireNotShallow(base,alias,dest); }
100
+ catch(e) { if(e.code==='E_BASE_SHALLOW') throw e; unavailable(base,alias,'clone',e); }
101
+ try { git(dest,['fetch','origin',`refs/heads/${base.acceptedBranch}`],{timeout:gitTimeoutMs()}); }
102
+ catch(e) { unavailable(base,alias,'fetch',e); }
72
103
  const head=selectedHead ?? git(dest,['rev-parse','FETCH_HEAD']);
73
- verifyRemote(base,dest);
74
- materializeGitObjects(base,dest,head);
104
+ try { requireNotShallow(base,alias,dest);verifyRemote(base,dest);materializeGitObjects(base,dest,head); }
105
+ catch(e) { if(['E_BASE_SHALLOW','E_OWNER','E_CONFIRM','E_PATH','E_BASE','E_VALIDATION'].includes(e.code)) throw e; unavailable(base,alias,'checkout',e); }
75
106
  // Reject a linked bundle even if Git happily checked the link out.
76
107
  safePath(join(dest,base.root));
77
108
  return head;
78
109
  }
79
- export function stageBase(base, dest) {
110
+ export function stageBase(base, dest, { alias = base.id } = {}) {
80
111
  safePath(dest);
81
112
  if(fs.existsSync(dest)) fail('E_PATH',`staging destination exists: ${dest}`);
82
113
  const acceptedPath=base.kind==='directory'?base.path:(base.repository.startsWith('/')?resolve(base.repository,base.root):null);
83
114
  if(acceptedPath && overlaps(acceptedPath,dest)) fail('E_PATH','stage overlaps accepted base');
84
115
  if(base.kind==='git') {
85
- const head=clone(base,dest); const root=join(dest,base.root);
116
+ const head=clone(base,dest,undefined,{alias}); const root=join(dest,base.root);
86
117
  const validated=validateBase(root,base);
87
118
  verifyPublicationTree(base,dest,head,head,validated.files);
88
119
  return { ...validated, head, root, checkout:dest };
@@ -142,7 +142,7 @@ function prepareWorker(source,run) {
142
142
  for(const [alias,base] of Object.entries(source.bindings.bases)) {
143
143
  if(run.settled?.includes(alias)) continue;
144
144
  const dest=join(work,'bases',alias);
145
- const staged=stageBase(base,dest);
145
+ const staged=stageBase(base,dest,{alias});
146
146
  const owned=source.decl.owns.map(splitRef).filter(([a])=>a===alias).map(([,n])=>n);
147
147
  for(const n of owned) if(staged.meta.nodes[n]?.owner!==source.owner || JSON.stringify(staged.meta.nodes[n])!==JSON.stringify(source.acceptedNodes[alias][n])) fail('E_OWNER','accepted ownership/path changed from frozen destination; explicit migration required');
148
148
  run.stages[alias]={root:staged.root,checkout:staged.checkout,head:staged.head,baseline:staged.files,digest:staged.digest,owned};persist(source,run);
@@ -235,7 +235,7 @@ export function complete(source,id,judgmentFile,opts={}) {
235
235
  if(base.kind==='git') verifyGitScope(base,s.checkout,s.head);
236
236
  const checkDir=fs.mkdtempSync(join(source.bindings.stateDir,'baseline-'));
237
237
  try {
238
- const current=stageBase(base,join(checkDir,'base'));
238
+ const current=stageBase(base,join(checkDir,'base'),{alias});
239
239
  if(current.digest!==s.digest || (base.kind==='git' && current.head!==s.head)) fail('E_BASELINE','accepted base changed; rejudge on fresh baseline');
240
240
  } finally {fs.rmSync(checkDir,{recursive:true,force:true});}
241
241
  const claims=judgment.outcomes.flatMap(o=>o.concepts).filter(c=>c.base===alias).map(c=>c.path);
@@ -310,7 +310,7 @@ export function retry(source,{run:id,rejudge=false,launch=false,adoptHome}={}) {
310
310
  const stages={...run.stages},outstanding=Object.keys(stages).filter(a=>!settled.includes(a));
311
311
  for(const alias of outstanding) {
312
312
  const base=source.bindings.bases[alias];
313
- const staged=stageBase(base,join(work,'rejudgments',attempt,'bases',alias));
313
+ const staged=stageBase(base,join(work,'rejudgments',attempt,'bases',alias),{alias});
314
314
  const owned=run.stages[alias].owned;
315
315
  for(const n of owned) if(staged.meta.nodes[n]?.owner!==source.owner || JSON.stringify(staged.meta.nodes[n])!==JSON.stringify(source.acceptedNodes[alias][n])) fail('E_OWNER','accepted ownership/path changed from frozen destination; explicit migration required');
316
316
  stages[alias]={root:staged.root,checkout:staged.checkout,head:staged.head,baseline:staged.files,digest:staged.digest,owned};
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "capability": "oats.okf",
3
3
  "command": "okf",
4
- "version": "2.1.4",
4
+ "version": "2.1.5",
5
5
  "compatibility": {
6
6
  "oats": ">=0.24.4"
7
7
  },
@@ -87,15 +87,26 @@ the purpose field becomes a name field that shows `<soul>-<purpose>` live and
87
87
  then the kernel's final name from the preview. A no-prefix toggle maps to
88
88
  `spawn --name <slug>`, gated on the `spawn-name` feature; `--purpose` stays the
89
89
  default. **Runtime and model** are always visible, with their resolved value and
90
- its source. **Work** comes from the soul's `work:` mode, and a `checkout` soul is
91
- offered "Use a worktree instead?" (`--work worktree`). There is **no**
92
- modules/capabilities list and no attach-knowledge / child-spawn / open-PR
93
- toggles, because those behaviours come from capabilities. There is **no**
94
- preview button either. The preview runs in the background to fill the real
95
- defaults, and apply still binds with `--expect-decision` (`E_DECISION_STALE`
96
- re-previews). A collapsed **Advanced** section holds harness permissions, launch
97
- config, relation, messaging identity (`--provider <cap> identity.mode=…`,
98
- decision 27), branch/base overrides and the execution server. Every modal gets a darker backdrop.
90
+ its source. **Relationship** sits in the main form, shown by default: None /
91
+ Child of / Sibling of / Parent of. There is **no** modules/capabilities list and
92
+ no attach-knowledge / child-spawn / open-PR toggles, because those behaviours
93
+ come from capabilities. There is **no** preview button either. The preview runs
94
+ in the background to fill the real defaults, and apply still binds with
95
+ `--expect-decision` (`E_DECISION_STALE` re-previews). A collapsed section named
96
+ **Developer settings** holds:
97
+ - **work**: base | branch and the worktree path. A `checkout` soul is offered
98
+ "Use a worktree instead?" (`--work worktree`); the work mode itself comes
99
+ from the soul's `work:`.
100
+ - harness permissions
101
+ - launch config
102
+ - session backend
103
+ - Run on (the execution server)
104
+ - wake-up
105
+ - messaging identity (`--provider <cap> identity.mode=…`, decision 27)
106
+
107
+ (Second human redirect, same day: relationship moved into the main form, work
108
+ moved into the collapsed section, and the section was renamed from "Advanced".)
109
+ Every modal gets a darker backdrop.
99
110
 
100
111
  **F4 — Instance card and roster on v2 facts.** The served-identity line (`acts
101
112
  as <address> via grant, expires <t>` / `alias <a> on <team>`); module rows with
@@ -42,6 +42,26 @@ pushes. Agreed by both on 2026-09-24:
42
42
  - `main` on these repositories carries no branch or tag protection: the parity
43
43
  gate and the cross-review are the only things between a merge and `main`.
44
44
 
45
+ ## Human decision (2026-09-24): no package approval
46
+
47
+ **Package approval is removed from the kernel and from the Desktop.** People
48
+ install a package only when they trust it. Declaring it in the workspace's
49
+ `packages:` IS the trust decision, so there's no second, per-version approval
50
+ step. This supersedes the "executables approved once per version" rule in
51
+ `docs/workspaces.md` (§ Packages, lock, approval, catalog) and everything built
52
+ on it:
53
+ - `oats sync` exit 2 for pending approvals and `approvalNeeded`
54
+ - `--approve <id>@<version>` and the interactive prompt
55
+ - the lock's `approved` record
56
+ - `E_PACKAGE_UNAPPROVED` at spawn and dispatch
57
+ - the Desktop F2 approval flow (`E_APPROVAL_STALE`)
58
+ - the planned pinned `--approve …=<digest>`
59
+
60
+ What stays: the lock still pins each package to the exact commit and integrity,
61
+ and restore still refuses drift (`E_PACKAGE_INTEGRITY`). Reproducibility is not
62
+ approval. It's a breaking contract change, so it ships in a minor release, and
63
+ the Desktop requires that kernel.
64
+
45
65
  ## Slices, in order
46
66
 
47
67
  ### D1 — Knowledge centralisation (IN PROGRESS)
@@ -960,9 +960,15 @@ between preview and apply is `E_DECISION_STALE`):
960
960
  (unchanged since the newest previous instance), or
961
961
  `{ instance, was }` (`was` = the previous commit, or `null` when the previous
962
962
  instance had no such module).
963
- - `capabilities[]` / `skills[]` keep their Preview-1 meaning; on a workspace
964
- spawn the authoritative module set is `modules[]` (`capabilities[]` may be
965
- empty there, since capability rows are filled after materialization).
963
+ - `capabilities[]` / `skills[]` on a **workspace** spawn are **objects**, not
964
+ Preview-1's strings: `capabilities[]` is `{ name, origin }` (`origin` =
965
+ `package:<id>@<version>` or `member:<repoKey>@<commit>`), and `skills[]` is
966
+ `{ name, source }`. Neither is a binding surface, because the authoritative
967
+ module set is `modules[]` and what apply binds is `decision.effective` /
968
+ `decision.resolution`. Consumers should read those fields, not project
969
+ `capabilities[]` / `skills[]`. (Corrected 2026-09-24: this said "keep their
970
+ Preview-1 meaning", which read as strings; found by the Desktop engineer in
971
+ F3.)
966
972
  - `workspace` is the workspace host's canonical key, `team` the soul's label
967
973
  (or `null`), `resolution` the 24-hex revision `decision.resolution` binds.
968
974
  - `--provider <cap> <key>=<value>` (repeatable; `a.b=c` nests) is accepted by
@@ -218,27 +218,52 @@ warning naming the fresh-purpose remedy. On an older `aw` the pre-1.36.1
218
218
  report stands (`aliasReusable: false`, warning naming aweb-abim), because
219
219
  that CLI cannot revoke the certificate.
220
220
 
221
- ## oats.aweb settings (1.12.0)
221
+ ## oats.aweb settings (1.12.1)
222
222
 
223
223
  Set in `oats-local.yaml` under `settings.oats.aweb.<key>` (host-owned), in the
224
224
  soul's `messaging:` payload (true of every instance), or per spawn with
225
225
  `oats spawn … --provider oats.aweb <key>=<value>`. The effective payload is
226
226
  merged in order: workspace messaging, `byTeam[team]`, soul messaging,
227
227
  `oats-local.yaml` `settings.oats.aweb`, then per-spawn `--provider` values.
228
- `residents` is host-file-only: put custody paths only in `oats-local.yaml`,
229
- never in a committed workspace or soul file (current kernels document this rule
230
- but do not yet enforce provenance in the hook payload).
228
+ `root`, `roots`, and `residents` are host-file-only: put absolute root/custody
229
+ paths only in `oats-local.yaml`, never in a committed workspace or soul file
230
+ (current kernels document this rule but do not yet enforce provenance in the
231
+ hook payload; the manifest schema does not yet carry a host-only marker).
231
232
 
232
233
  - `team: <team id>`. The payload team wins over `OATS_TEAM_ID`/
233
234
  `OATS_TEAM_NAME`; if both are set and differ, the hook warns and uses the
234
235
  payload. Workspace v2 spawns can have an empty `OATS_TEAM_ID`, so set this in
235
- the payload for global grants.
236
+ the workspace file's `messaging:` / `messaging.byTeam.<label>.team`, or in
237
+ `settings.oats.aweb.team` for a host override.
238
+ - `root: /absolute/dir`. Host-owned absolute directory whose `.aw` is the aweb
239
+ minting root. A declared root without `.aw` is fatal; run `oats aweb setup`
240
+ there or set `settings.oats.aweb.root` to the initialized root.
241
+ - `roots: { <team id>: /absolute/dir }`. Host-owned map for deployments that
242
+ mint into several aweb teams. When a team is known, `roots[team]` wins over
243
+ `root`.
244
+ - Minting root resolution for spawn and setup is: `roots[team]` when the team is
245
+ known and present, else `root`. With no declared root, workspace v2 uses
246
+ `<OATS_WORKSPACE>` (the deployment directory whose `.aw` is used) and never
247
+ searches above it; classic deployments with `OATS_TEAM_SCOPE` keep the
248
+ historical bounded candidate search order exactly (team scope, home, home git
249
+ root, context, context git root, then workspace).
250
+ - `binding-check` answers `needs-configuration` before spawn with one problem
251
+ per missing item: `no messaging root at <dir>: run oats aweb setup there or
252
+ set settings.oats.aweb.root`; `no team: set messaging.byTeam.<label>.team in
253
+ the workspace file or settings.oats.aweb.team`. With both present it answers
254
+ `ready` (subject to captured-session checks when an invocation is supplied).
255
+ In classic deployments this readiness check approximates the full bounded
256
+ spawn search by checking `OATS_TEAM_SCOPE` before `OATS_WORKSPACE`; the spawn
257
+ hook itself still keeps the exact 1.12.0 bounded candidate order.
236
258
  - `identity.mode: local | global` (default `local`). Any other value is fatal.
237
259
  Local mode is the historical behavior: a spawned team identity is minted for
238
260
  the instance, or `identity.source` uses the existing retained-seat flow below.
239
261
  Its spawn meta includes `identity: { mode: "local", alias, team, address:
240
262
  null, resident: null }` beside the existing top-level `alias`, `team`, and
241
- `delivery` keys.
263
+ `delivery` keys. Local-mode spawn output contributes
264
+ `env.AWEB_IDENTITY_HOME=<home>/.aw` (and retained-seat local mode contributes
265
+ the same path) so `aw mail`, `aw chat`, `aw whoami`, `aw wake`, and
266
+ `aw workspace status` work from the instance's `work/` or any other cwd.
242
267
  - `identity.mode: global` makes the instance act as a resident global identity
243
268
  through an aweb session grant; it never mints a new global identity and never
244
269
  copies root keys into the instance home. `identity.resident` is required and
@@ -77,8 +77,8 @@ members:
77
77
  - git:github.com/acme/platform
78
78
  packages:
79
79
  oats.framework: v1.1.3
80
- oats.okf: v2.1.4
81
- oats.aweb: v1.12.0
80
+ oats.okf: v2.1.5
81
+ oats.aweb: v1.12.1
82
82
  teams:
83
83
  global: { description: Org-wide }
84
84
  engineering: { description: Platform }
@@ -328,7 +328,7 @@ Member capabilities need no approval: membership is the trust.
328
328
  **Non-interactive approval (CI, scripted rebuilds):**
329
329
 
330
330
  ```bash
331
- oats sync --approve oats.okf@v2.1.4 --approve oats.aweb@v1.12.0
331
+ oats sync --approve oats.okf@v2.1.5 --approve oats.aweb@v1.12.1
332
332
  ```
333
333
 
334
334
  `--approve <id>@<version>` is repeatable and approves **exactly** the entry the
@@ -340,7 +340,7 @@ stays unapproved (exit `2`, as above).
340
340
 
341
341
  `<version>` is the value `sync --json` reports as `approvalNeeded[].version`,
342
342
  which is what the lock records as the package's `version`. For a **catalog**
343
- package that is the published version (`oats.okf@2.1.4`). For a **git** source
343
+ package that is the published version (`oats.okf@2.1.5`). For a **git** source
344
344
  pinned by commit (`git:github.com/awebai/oats-okf@<oid>`) it is the **full
345
345
  commit OID**, not the `git:` reference and not a tag name — copy it from the
346
346
  `approvalNeeded` line rather than from your workspace file.
@@ -0,0 +1,23 @@
1
+ # OATS 0.25.9
2
+
3
+ A pins-only patch: the kernel is unchanged since 0.25.8.
4
+
5
+ ## Updated
6
+
7
+ - **`oats.aweb` 1.12.1** (catalog pin + bundled copy; #145). The messaging
8
+ root is explicit: `settings.oats.aweb.root` / `roots[team]` in
9
+ `oats-local.yaml`. On a workspace-model deployment it defaults to the
10
+ deployment directory and is never searched for upward. Error texts name the
11
+ workspace-model remedies. In local mode an instance gets `AWEB_IDENTITY_HOME`,
12
+ so `aw` works from any directory. `binding-check` answers
13
+ `needs-configuration` before spawn, with one problem per missing item (no
14
+ messaging root; no team), and an unmapped workspace team label fails the spawn
15
+ closed instead of inheriting the root's active team.
16
+ - **`oats.okf` 2.1.5** (catalog pin + bundled copy; #148).
17
+ - Every configured base must be usable at spawn. A shallow base is refused
18
+ with `E_BASE_SHALLOW`; clone, fetch and checkout failures are
19
+ `E_BASE_UNAVAILABLE` naming the alias, repository, step and reason, never a
20
+ raw `ETIMEDOUT`.
21
+ - A missing `OATS_SOUL` is `E_OATS_SOUL_MISSING`; there's no fallback to the
22
+ home's soul link.
23
+ - Refusing an owner rename names both souls and both remedies.
@@ -3,12 +3,12 @@
3
3
  "packages": {
4
4
  "oats.okf": {
5
5
  "url": "https://github.com/awebai/oats-okf.git",
6
- "ref": "v2.1.4",
6
+ "ref": "v2.1.5",
7
7
  "path": "oats-package"
8
8
  },
9
9
  "oats.aweb": {
10
10
  "url": "https://github.com/awebai/oats-aweb.git",
11
- "ref": "v1.12.0",
11
+ "ref": "v1.12.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.25.8",
3
+ "version": "0.25.9",
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",