@awebai/oats 0.29.2 → 0.29.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.
Files changed (47) hide show
  1. package/bin/oats.mjs +9 -4
  2. package/capabilities/oats-aweb/bin/oats-aweb.mjs +19 -18
  3. package/capabilities/oats-aweb/injects/aweb.md +4 -4
  4. package/capabilities/oats-aweb/lib/binding-wire.mjs +1 -3
  5. package/capabilities/oats-aweb/lib/wake-receive.mjs +1 -1
  6. package/capabilities/oats-aweb/oats.json +3 -3
  7. package/capabilities/oats-aweb/skills/VENDORED.md +1 -1
  8. package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +9 -9
  9. package/capabilities/oats-aweb/skills/aweb-team-membership/references/team-membership-reference.md +1 -1
  10. package/capabilities/oats-aweb/skills/oats-aweb/SKILL.md +13 -14
  11. package/capabilities/oats-okf/bin/oats-okf.mjs +9 -6
  12. package/capabilities/oats-okf/lib/config.mjs +2 -1
  13. package/capabilities/oats-okf/lib/consult.mjs +26 -4
  14. package/capabilities/oats-okf/lib/harvest-switch.mjs +16 -3
  15. package/capabilities/oats-okf/lib/io.mjs +9 -1
  16. package/capabilities/oats-okf/lib/sources.mjs +18 -1
  17. package/capabilities/oats-okf/lib/stores.mjs +8 -6
  18. package/capabilities/oats-okf/lib/worker.mjs +2 -1
  19. package/capabilities/oats-okf/oats.json +1 -1
  20. package/capabilities/oats-okf-harvest/oats.json +1 -1
  21. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +35 -14
  22. package/capabilities/oats-okf-maintenance/lib/provenance.mjs +6 -1
  23. package/capabilities/oats-okf-maintenance/oats.json +1 -1
  24. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +16 -1
  25. package/docs/capabilities.md +3 -4
  26. package/docs/capability-manifest.schema.json +3 -1
  27. package/docs/design/2026-09-24-desktop-phase-f-boundary.md +2 -0
  28. package/docs/design/2026-09-24-phase-d-plan.md +2 -0
  29. package/docs/design/2026-09-25-teams-contract.md +36 -4
  30. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +4 -4
  31. package/docs/design/2026-09-27-team-model-v2.md +136 -0
  32. package/docs/official-catalog.md +2 -2
  33. package/docs/packages.md +5 -5
  34. package/docs/release-notes/v0.29.3.md +53 -0
  35. package/docs/release-notes/v0.29.4.md +90 -0
  36. package/docs/schedules.md +18 -4
  37. package/docs/workspaces.md +4 -4
  38. package/lib/automations.mjs +7 -0
  39. package/lib/core.mjs +3 -3
  40. package/lib/packages.mjs +2 -5
  41. package/lib/resolve.mjs +2 -2
  42. package/lib/schedule.mjs +31 -17
  43. package/lib/triggers.mjs +51 -15
  44. package/lib/workspace.mjs +3 -3
  45. package/package-catalog.json +3 -3
  46. package/package.json +1 -1
  47. package/capabilities/oats-aweb/lib/personal-team.mjs +0 -19
package/bin/oats.mjs CHANGED
@@ -55,7 +55,8 @@ const rawArgs = process.argv.slice(2);
55
55
  const KERNEL_SWITCHES = new Set(["allow-child-spawns", "apply", "check", "clear", "delete-branch", "discard-worktree", "dry-run", "ephemeral", "force", "help", "host", "json", "keep-dir", "keep-env", "no-child-spawns", "no-launch", "no-recursive", "no-yolo", "plan", "policy", "preview", "print", "replace", "self", "verbose", "yes", "yolo"]);
56
56
  /** `--flag=value` is `--flag value`: every kernel reader (flag(), valueFlag(), the onboard and
57
57
  * routed-command loops) then applies the spaced form's validation to it. `problem` is an empty
58
- * `--flag=` or a switch given a value. */
58
+ * `--flag=`, a switch given a value, or a value that is itself an option (`--model=--yolo`):
59
+ * expanded, it would be a flag token every reader sees, which the spaced form can never carry. */
59
60
  function expandInlineValues(argv) {
60
61
  const out = [];
61
62
  let problem;
@@ -63,7 +64,7 @@ function expandInlineValues(argv) {
63
64
  const eq = a.indexOf("=");
64
65
  if (!a.startsWith("--") || eq <= 2) { out.push(a); continue; }
65
66
  const name = a.slice(2, eq), value = a.slice(eq + 1);
66
- problem ??= KERNEL_SWITCHES.has(name) ? `--${name} takes no value (got ${a})` : value === "" ? `--${name}= needs a value` : undefined;
67
+ problem ??= KERNEL_SWITCHES.has(name) ? `--${name} takes no value (got ${a})` : value === "" ? `--${name}= needs a value` : value.startsWith("--") ? `--${name}= takes a value, not an option (got ${a})` : undefined;
67
68
  out.push(`--${name}`, value);
68
69
  }
69
70
  return { argv: out, problem };
@@ -2144,7 +2145,7 @@ async function scheduleCmd() {
2144
2145
  if (args.includes("--host")) return out(tickHost({ io, dryRun }));
2145
2146
  const reg = readRegistry();
2146
2147
  const tctx = scopeAutomations(ws(), { io, refresh: !dryRun });
2147
- const considered = withHostLock(() => [...(tctx.refresh && !tctx.refresh.ok ? [{ workspace: ws(), action: "error", error: `automations refresh: ${tctx.refresh.error}` }] : []), ...tickWorkspace(ws(), { io, reg, wsList: reg.workspaces.includes(ws()) ? reg.workspaces : [...reg.workspaces, ws()], dryRun, ctx: tctx }), ...tickTriggers(ws(), { io, dryRun, ctx: tctx })]);
2148
+ const considered = withHostLock(() => [...(tctx.refresh && !tctx.refresh.ok ? [{ workspace: ws(), action: "error", error: `automations refresh: ${tctx.refresh.error}` }] : []), ...tickWorkspace(ws(), { io, reg, wsList: reg.workspaces.includes(ws()) ? reg.workspaces : [...reg.workspaces, ws()], dryRun, ctx: tctx }), ...tickTriggers(ws(), { io, dryRun, ctx: tctx, reg, wsList: reg.workspaces.includes(ws()) ? reg.workspaces : [...reg.workspaces, ws()] })]);
2148
2149
  return out({ tickedAt: new Date().toISOString(), considered, scheduler: schedulerStatus(ws(), io) });
2149
2150
  }
2150
2151
  case "host": {
@@ -2503,7 +2504,11 @@ async function capabilityCommand() {
2503
2504
  const teamCtx = teamEnv(resolvedFromPrepared(hit.prepared, hit.deployment));
2504
2505
  // No home, so no recorded soul: the soul's per-commit copy is OATS_SOUL when a spawn
2505
2506
  // already fetched exactly this commit; otherwise the command gets none (never ambient).
2506
- const cachedSoul = hit.soul?.commit ? join(hit.deployment, "agents", hit.soul.name, "souls", String(hit.soul.commit).slice(0, 12)) : null;
2507
+ // The agent directory is the soul entry's own (a package soul's is `<package>--<soul>`), never
2508
+ // the bare name, which a same-named member soul's copy may occupy (as instance-inspect does).
2509
+ const { agentDirOf } = await import("../lib/instance-resolution.mjs");
2510
+ const entry = hit.prepared?.soulEntry;
2511
+ const cachedSoul = entry?.commit ? join(hit.deployment, "agents", agentDirOf(entry), "souls", String(entry.commit).slice(0, 12)) : null;
2507
2512
  return runManifestCommand({ capability: hit.module.name, ...hit.manifest }, { settings: hit.settings, origins: hit.resolution?.payloadOrigins?.[hit.module.name] }, teamCtx, hit.ensureTree, cachedSoul && existsSync(join(cachedSoul, "soul.yaml")) ? realpathSync(cachedSoul) : undefined);
2508
2513
  }
2509
2514
 
@@ -45,7 +45,6 @@ import { assessCapturedSessionReadiness, querySelectedKernel } from "../lib/sess
45
45
  import { runCapturedNative } from "../lib/captured-native.mjs";
46
46
  import { CUSTODY_ATTACH_MIN, grantYamlCustodySocket, parseBindingJson } from "../lib/binding-wire.mjs";
47
47
  import { custodyPreflight } from "../lib/grant-custody.mjs";
48
- import { PERSONAL_ROOT_DEFERRED_WARNING, personalRootDeclared } from "../lib/personal-team.mjs";
49
48
  import { runtimeDeliveryFor, wakeRegistration } from "../lib/wake-receive.mjs";
50
49
 
51
50
  /** Run a command as ARGV — never a shell string. Team ids, aliases, instance
@@ -498,7 +497,7 @@ function globalGrantSpawn() {
498
497
  const teamWarnings = [];
499
498
  const unmappedPrimary = !team ? unmappedPrimaryRow() : undefined;
500
499
  if (!team) team = activeTeamAt(custody);
501
- if (unmappedPrimary && team) teamWarnings.push(`oats-aweb: team-unmapped — workspace label ${unmappedPrimary.label} is not mapped; using personal team ${team}`);
500
+ if (unmappedPrimary && team) teamWarnings.push(`oats-aweb: team-unmapped — workspace label ${unmappedPrimary.label} is not mapped; using the default team ${team}`);
502
501
  if (!team) fatal("identity.mode \"global\" requires settings.oats.aweb.team or an active team at the resident custody root before minting a grant");
503
502
  const grantHome = join(home, ".aweb-identity");
504
503
  if (existsSync(grantHome)) fatal(`${grantHome} already exists; refusing to overwrite an existing aweb session grant home`);
@@ -758,9 +757,9 @@ function validateJoinLabels(labels, { action = "join" } = {}) {
758
757
  const eligible = eligibleTeams();
759
758
  const byLabel = new Map(eligible.map((t) => [t.label, t]));
760
759
  for (const label of labels) {
761
- if (action === "leave" && label === "personal") {
762
- const error = new Error(`E_TEAM_PERSONAL: ${label} is the personal team and cannot be left`);
763
- error.code = "E_TEAM_PERSONAL";
760
+ if (action === "leave" && label === "default") {
761
+ const error = new Error(`E_TEAM_DEFAULT: ${label} is the workspace's default team and cannot be left`);
762
+ error.code = "E_TEAM_DEFAULT";
764
763
  throw error;
765
764
  }
766
765
  if (!LABEL_RE.test(label) || !byLabel.has(label)) {
@@ -801,14 +800,19 @@ function readCapabilityMeta() {
801
800
  if (process.env.OATS_META) { try { return withProviderTeams(JSON.parse(process.env.OATS_META || "{}")); } catch { return withProviderTeams({}); } }
802
801
  try { return withProviderTeams(JSON.parse(readFileSync(join(home, "instance.json"), "utf8")).capabilityMeta?.["oats.aweb"] || {}); } catch { return withProviderTeams({}); }
803
802
  }
803
+ function defaultTeamSource(meta = {}) {
804
+ const recorded = meta.defaultTeam?.source;
805
+ if (recorded === "setting" || recorded === "root") return recorded;
806
+ return payloadTeam().team ? "setting" : "root";
807
+ }
804
808
  function teamsDocument(meta = readCapabilityMeta()) {
805
809
  const teams = parseOatsTeams();
806
810
  const joined = joinedTeamsOf(meta);
807
811
  const joinedLabels = new Set(joined.map((j) => j.label));
808
812
  const primary = primaryTeamLabel();
809
- const personalTeam = meta.personal?.team || meta.team || meta.identity?.team || payloadTeam().team || null;
813
+ const defaultTeam = meta.defaultTeam?.team || meta.team || meta.identity?.team || payloadTeam().team || null;
810
814
  return {
811
- personal: { team: personalTeam, ...(meta.personal?.source ? { source: meta.personal.source } : {}) },
815
+ defaultTeam: { team: defaultTeam, source: defaultTeamSource(meta) },
812
816
  primary,
813
817
  eligible: teams.filter((t) => t.mapped && t.team).map((t) => ({ label: t.label, team: t.team, joined: joinedLabels.has(t.label) })),
814
818
  joined: joined.map((j) => ({ label: j.label, team: j.team, identityHome: j.identityHome, receive: j.receive || "poll", since: j.since })),
@@ -888,7 +892,7 @@ function parseHomeCommandArgs(argv = process.argv.slice(3)) {
888
892
  }
889
893
  function outputTeamsDocument(doc, json) {
890
894
  if (json) { console.log(JSON.stringify(doc)); return; }
891
- console.log(`personal: ${doc.personal.team || "unknown"}`);
895
+ console.log(`default team: ${doc.defaultTeam.team || "unknown"}`);
892
896
  for (const row of doc.eligible) console.log(`${row.joined ? "joined" : "eligible"}: ${row.label} (${row.team})`);
893
897
  for (const label of doc.unmapped) console.log(`unmapped: ${label}`);
894
898
  }
@@ -934,9 +938,7 @@ function runTeamsCommand(kind) {
934
938
 
935
939
  /** The primary identity's team and the root that mints it, exactly as 1.14.2:
936
940
  * the config's `team:` (id, then name) wins, else the root's active team.
937
- * Per-workspace personal-team enrollment is deferred to 1.16, so this makes no
938
- * `aw auth` or `aw team ensure` call and a declared roots.personal is ignored
939
- * with a warning. Exits through fatal() on refusal. */
941
+ * Exits through fatal() on refusal. */
940
942
  function resolvePrimaryTeam() {
941
943
  const warnings = [];
942
944
  const root = awebRoot();
@@ -945,7 +947,7 @@ function resolvePrimaryTeam() {
945
947
  const source = team ? "setting" : "root";
946
948
  const unmappedPrimary = !team ? unmappedPrimaryRow() : undefined;
947
949
  if (!team) team = JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
948
- if (unmappedPrimary && team) warnings.push(`oats-aweb: team-unmapped — workspace label ${unmappedPrimary.label} is not mapped; using personal team ${team}`);
950
+ if (unmappedPrimary && team) warnings.push(`oats-aweb: team-unmapped — workspace label ${unmappedPrimary.label} is not mapped; using the default team ${team}`);
949
951
  if (!team) fatal(`cannot determine target team, so no identity could be minted — ${teamConfigRemedy()}, or activate a team at the aweb root`);
950
952
  // A bare team name (no namespace) resolves against the root's memberships.
951
953
  if (!team.includes(":")) {
@@ -955,7 +957,6 @@ function resolvePrimaryTeam() {
955
957
  else if (match.length > 1) fatal(`team name "${team}" is ambiguous at ${root}: ${match.join(", ")}, so no identity could be minted — ${teamConfigRemedy()}`);
956
958
  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()}`);
957
959
  }
958
- if (personalRootDeclared(settings)) warnings.push(`oats-aweb: personal-root-deferred — ${PERSONAL_ROOT_DEFERRED_WARNING}`);
959
960
  return { team, root, source, warnings };
960
961
  }
961
962
 
@@ -1110,19 +1111,19 @@ if (event === "launch") {
1110
1111
  const deliveryBrief = deliveryMode === "session"
1111
1112
  ? ` Notification delivery: external (AWEB_DELIVERY=session): the host wake broker (aw wake) is registered for this home and nudges you when mail or chat arrives; the native aweb channel is not running. If you have waited long with nothing arriving, check \`aw mail inbox\` and \`aw chat pending\` yourself at task boundaries.`
1112
1113
  : "";
1113
- let meta = { team: joined.team_id, alias, delivery: deliveryMode, personal: { team: joined.team_id, source: primary.source }, ...(process.env.OATS_RUNTIME ? { runtime: process.env.OATS_RUNTIME } : {}), identity: identityMeta({ mode: "local", alias, team: joined.team_id }) };
1114
+ let meta = { team: joined.team_id, alias, delivery: deliveryMode, defaultTeam: { team: joined.team_id, source: primary.source }, ...(process.env.OATS_RUNTIME ? { runtime: process.env.OATS_RUNTIME } : {}), identity: identityMeta({ mode: "local", alias, team: joined.team_id }) };
1114
1115
  const joinFloorProblem = joinRows.length ? joinedTeamsAwFloorProblem() : undefined;
1115
1116
  if (joinFloorProblem) warnings.push(`oats-aweb: ${joinFloorProblem}`);
1116
1117
  else for (const row of joinRows) { const result = mintJoinedTeam(row, meta); meta = result.meta; spawnMeta = meta; writeProviderTeamsState(meta); if (result.warning) warnings.push(`oats-aweb: ${result.warning}`); }
1117
1118
  if (joinedTeamsOf(meta).length) { const synced = syncWakeReceive(meta); meta = synced.meta; for (const w of synced.warnings) warnings.push(`oats-aweb: ${w}`); }
1118
1119
  writeProviderTeamsState(meta);
1119
- const personalBrief = meta.personal.source === "setting" ? "the team this deployment configured for you" : "your personal team (the messaging root's active team)";
1120
+ const defaultTeamBrief = meta.defaultTeam.source === "setting" ? "the team this deployment configured for you" : "the workspace's default team (the messaging root's active team)";
1120
1121
  const joinedNow = joinedTeamsOf(meta);
1121
1122
  const joinedBrief = joinedNow.length ? ` Joined teams: ${joinedNow.map((j) => `${j.label} (${j.team}, receive ${j.receive}, send with \`aw --identity-home ${j.identityHome} mail|chat ...\`)`).join("; ")}.` : "";
1122
1123
  out({
1123
1124
  meta,
1124
1125
  env,
1125
- brief: `Comms: you have an aweb identity — alias "${alias}" on team ${joined.team_id}, ${personalBrief}.${mismatch}${deliveryBrief}${joinedBrief} Load the oats-aweb skill before messaging: \`oats aweb teams --json\` shows your teams, \`oats aweb roster\` who you can reach. Coordination stays in your deployment's task layer.`,
1126
+ brief: `Comms: you have an aweb identity — alias "${alias}" on team ${joined.team_id}, ${defaultTeamBrief}.${mismatch}${deliveryBrief}${joinedBrief} Load the oats-aweb skill before messaging: \`oats aweb teams --json\` shows your teams, \`oats aweb roster\` who you can reach. Coordination stays in your deployment's task layer.`,
1126
1127
  ...(launch ? { launch } : {}),
1127
1128
  ...(joined.team_id !== team ? { warning: `oats-aweb: team mismatch — joined ${joined.team_id}, expected ${team}` } : warnings.length ? { warning: warnings.join(" | ") } : channelWarning ? { warning: channelWarning } : {}),
1128
1129
  });
@@ -1206,7 +1207,7 @@ if (event === "launch") {
1206
1207
  // alias = instance name, so the team's member roster lists live instances
1207
1208
  // wherever they run (plus human members). Local liveness comes from
1208
1209
  // `oats status` in the deployment; this is the network view.
1209
- // Default: this instance's personal team, listed from the root that minted
1210
+ // Default: this instance's default team, listed from the root that minted
1210
1211
  // it. `--label <label>` lists an eligible (joined or not) workspace team from
1211
1212
  // the host root that holds it.
1212
1213
  const argv = process.argv.slice(3);
@@ -1220,7 +1221,7 @@ if (event === "launch") {
1220
1221
  team = row.team; root = awebRootForTeam(team);
1221
1222
  if (!root) { console.error(`oats aweb roster: ${awebRootProblem(rootSettingCandidate(team))}, so the roster of ${label} cannot be read from this host`); process.exit(1); }
1222
1223
  } else {
1223
- team = meta.personal?.team || meta.identity?.team || meta.team || process.env.OATS_TEAM_ID;
1224
+ team = meta.defaultTeam?.team || meta.identity?.team || meta.team || process.env.OATS_TEAM_ID;
1224
1225
  root = awebRoot();
1225
1226
  if (!root) { console.error(`oats aweb roster: ${awebRootProblem(rootSettingCandidate())}`); process.exit(1); }
1226
1227
  if (!team) team = JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
@@ -1,9 +1,9 @@
1
1
  ## Messaging: aweb
2
2
 
3
3
  Your messaging layer is **aweb**. Your identity's alias is your instance name,
4
- and it lives in the personal team of the person you work for (the `Comms:`
5
- line of your TASK.md names it). Wider workspace teams are joined explicitly,
6
- each with its own identity home.
4
+ and it lives in the workspace's default team (the `Comms:` line of your TASK.md
5
+ names it). Wider workspace teams are joined explicitly, each with its own
6
+ identity home.
7
7
 
8
8
  **Load the `oats-aweb` skill before your first `aw mail`/`aw chat` of a session,
9
9
  whenever an aweb wake or channel event arrives, and whenever messaging or a
@@ -14,7 +14,7 @@ memory; if a flag looks wrong run `aw <command> --help`.
14
14
  Quick crib (run from your instance home, never from `./work`):
15
15
 
16
16
  ```bash
17
- oats aweb teams --json # personal, eligible, joined teams
17
+ oats aweb teams --json # defaultTeam, eligible, joined teams
18
18
  oats aweb roster # who you can reach
19
19
  aw mail inbox # UNREAD mail only (--show-all: history)
20
20
  aw mail send --to <alias> --subject "..." --body-file <f> # recipient needs --to
@@ -4,7 +4,6 @@ import { isAbsolute, join, resolve } from 'node:path';
4
4
  import { TextDecoder } from 'node:util';
5
5
  import { assessCapturedSessionReadiness } from './session-readiness.mjs';
6
6
  import { custodyPreflight } from './grant-custody.mjs';
7
- import { PERSONAL_ROOT_DEFERRED_WARNING, personalRootDeclared } from './personal-team.mjs';
8
7
  import { joinedReceiveModes } from './wake-receive.mjs';
9
8
  import {
10
9
  MESSAGING_CONTRACT,
@@ -220,9 +219,8 @@ function readinessDetails(settings,{deployment,env=process.env}={}) {
220
219
  const initialTeam=typeof settings.team==='string' && settings.team.trim()?settings.team.trim():undefined;
221
220
  const candidate=rootCandidate(settings,initialTeam,{deployment,env}),team=teamFromSettings(settings,candidate,{env}),problems=[],warnings=[];
222
221
  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}`});
223
- const unmapped=unmappedPrimary(env);if(unmapped&&team)warnings.push({code:'team-unmapped',message:`workspace label ${unmapped.label} is not mapped; using personal team ${team}`});
222
+ const unmapped=unmappedPrimary(env);if(unmapped&&team)warnings.push({code:'team-unmapped',message:`workspace label ${unmapped.label} is not mapped; using the default team ${team}`});
224
223
  if(!team) problems.push({code:'needs-configuration',message:'no team: set settings.oats.aweb.team or keep an active team at the aweb root'});
225
- if(personalRootDeclared(settings)) warnings.push({code:'personal-root-deferred',message:PERSONAL_ROOT_DEFERRED_WARNING});
226
224
  return {team,candidate,warnings,result:checkProblems(problems) || {status:'ready',problems:[]}};
227
225
  }
228
226
  function readinessFromSettings(settings,options) {return readinessDetails(settings,options).result;}
@@ -30,7 +30,7 @@ export function wakeRegistration({home, primaryIdentityHome, delivery, runtime,
30
30
  if (!rt || !joined.length) return null;
31
31
  const receive = joined.map((j) => ({identity_home: j.identityHome, label: j.label, event_classes: JOINED_EVENT_CLASSES}));
32
32
  const doc = rt === 'external-session'
33
- ? {home, delivery: 'session', runtime_delivery: rt, identity_home: primaryIdentityHome, receive_identities: [{identity_home: primaryIdentityHome, label: 'personal', controls: true}, ...receive]}
33
+ ? {home, delivery: 'session', runtime_delivery: rt, identity_home: primaryIdentityHome, receive_identities: [{identity_home: primaryIdentityHome, label: 'default', controls: true}, ...receive]}
34
34
  : {home, delivery: rt, runtime_delivery: rt, primary_identity_home: primaryIdentityHome, receive_identities: receive};
35
35
  if (backend) doc.backend = backend;
36
36
  return doc;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "capability": "oats.aweb",
3
3
  "command": "aweb",
4
- "version": "1.15.0",
4
+ "version": "1.16.0",
5
5
  "compatibility": {
6
6
  "oats": ">=0.26.0"
7
7
  },
@@ -142,7 +142,7 @@
142
142
  "description": "channel: the native aweb channel packages wake the instance (default). session: delivery is external (AWEB_DELIVERY=session), no channel flag; the host wake broker registers the instance once it exists."
143
143
  },
144
144
  "team": {
145
- "description": "Target aweb team id for the primary personal identity lifecycle. In workspace v2 spawns this payload value wins over OATS_TEAM_ID; if both are set and differ the hook warns and uses this setting. Unset means the aweb root's active team."
145
+ "description": "Target aweb team id for the primary identity lifecycle. In workspace v2 spawns this payload value wins over OATS_TEAM_ID; if both are set and differ the hook warns and uses this setting. Unset means the aweb root's active team."
146
146
  },
147
147
  "root": {
148
148
  "hostOnly": true,
@@ -150,7 +150,7 @@
150
150
  },
151
151
  "roots": {
152
152
  "hostOnly": true,
153
- "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. The key \"personal\" is reserved for per-workspace personal-team enrollment (oats.aweb 1.16); 1.15 ignores it with a readiness warning."
153
+ "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. Keys are team ids only."
154
154
  },
155
155
  "identity": {
156
156
  "default": {
@@ -16,7 +16,7 @@ Vendored trees:
16
16
  - `aweb-identity/`
17
17
 
18
18
  Not vendored: `oats-aweb/` is this package's own OATS playbook (identity,
19
- personal and joined teams, roster, delivery and wakes, etiquette,
19
+ default and joined teams, roster, delivery and wakes, etiquette,
20
20
  troubleshooting). Every `aw` invocation it and the vendored skills cite is
21
21
  checked against a real published aw by `test/oats-aweb-1-15.test.mjs`.
22
22
 
@@ -23,12 +23,12 @@ retire cleanup, readiness, and Desktop operations consistent.
23
23
  Use the provider commands from the instance home (or with `--home <path>`):
24
24
 
25
25
  ```bash
26
- oats aweb teams --json # personal, eligible, joined, unmapped
26
+ oats aweb teams --json # defaultTeam, eligible, joined, unmapped
27
27
  oats aweb join --labels <label>[,<label>] # join eligible workspace labels
28
28
  oats aweb leave --labels <label>[,<label>] # leave joined wider-team labels
29
29
  ```
30
30
 
31
- - The personal team cannot be left; attempting it is `E_TEAM_PERSONAL`.
31
+ - The workspace's default team cannot be left; attempting it with label `default` is `E_TEAM_DEFAULT`.
32
32
  - A label that is not eligible for this soul/workspace is `E_TEAM_NOT_ELIGIBLE`.
33
33
  - Joined wider teams use a local identity home such as
34
34
  `<home>/.aweb-identity-<label>`. Joined teams require aw >= 1.36.12. The
@@ -62,15 +62,15 @@ aw id cert show
62
62
 
63
63
  Interpret common states:
64
64
 
65
- - `teams.personal.team` is the primary identity's team, wired to the harness:
66
- the aweb root's active team (`personal.source: root`) or a deployment-pinned
67
- team (`setting`). A team per workspace arrives in oats.aweb 1.16.
65
+ - `teams.defaultTeam.team` is the primary identity's team, wired to the harness:
66
+ the aweb root's active team (`defaultTeam.source: root`) or a deployment-pinned
67
+ team (`setting`).
68
68
  - `eligible[]` are labels this soul/workspace may explicitly join; the primary
69
69
  label may appear here and is joinable/leavable like any other wider team.
70
70
  - `joined[]` are provider-created wider-team memberships; each has an
71
71
  `identityHome`, `since`, and `receive` (`native` or `poll`).
72
72
  - `unmapped[]` labels are present on the soul but not mapped by the workspace.
73
- An unmapped primary falls back to the personal/root active team with a
73
+ An unmapped primary falls back to the default/root active team with a
74
74
  `team-unmapped` warning; it is not a spawn blocker.
75
75
  - `teams-unverified` on launch means the kernel supplied recorded/unknown team
76
76
  data, so the provider kept memberships instead of leaving anything.
@@ -81,9 +81,9 @@ Interpret common states:
81
81
  `default:oats.aweb.ai`).
82
82
  - **Team certificate**: a signed membership statement for an identity; stored in
83
83
  `.aw/team-certs/` for native identities.
84
- - **Personal team**: the default team for the instance's primary identity: the
85
- aweb root's active team, or `settings.oats.aweb.team` when the deployment
86
- pins one. Per-workspace personal-team enrollment arrives in oats.aweb 1.16.
84
+ - **Default team**: the workspace default team for the instance's primary identity:
85
+ the aweb root's active team, or `settings.oats.aweb.team` when the deployment
86
+ pins one.
87
87
  - **Joined team**: an explicit wider team joined through `oats aweb join`, with a
88
88
  separate local identity home.
89
89
 
@@ -13,7 +13,7 @@ These layers can combine in multiple ways. Do not assume one from another. The c
13
13
 
14
14
  Fully Hosted means aweb operates namespace and team authority for hosted domains such as `*.aweb.ai`. It can mint hosted team certificates and provide simple onboarding. This is the simple default for most users.
15
15
 
16
- Hosted OAuth/MCP flows provision custodial addressed/global identities, personal team membership, and harness credentials before a local CLI workspace exists. Team API-key CLI bootstrap is different: it creates a local self-custodial CLI workspace in a hosted team. In OAuth/MCP flows, use CLI checks for diagnosis only when a local workspace is actually involved; do not force BYOT setup.
16
+ Hosted OAuth/MCP flows provision custodial addressed/global identities, default team membership, and harness credentials before a local CLI workspace exists. Team API-key CLI bootstrap is different: it creates a local self-custodial CLI workspace in a hosted team. In OAuth/MCP flows, use CLI checks for diagnosis only when a local workspace is actually involved; do not force BYOT setup.
17
17
 
18
18
  ## BYOT
19
19
 
@@ -23,16 +23,16 @@ joined team, put `--identity-home <identityHome>` before the subcommand.
23
23
  | Fact | Where to read it |
24
24
  |---|---|
25
25
  | Your alias | your instance name; the `Comms:` line of `TASK.md`; `aw whoami` |
26
- | Your personal team | `oats aweb teams --json` → `personal.team` (`personal.source`) |
26
+ | Your default team | `oats aweb teams --json` → `defaultTeam.team` (`defaultTeam.source`) |
27
27
  | Teams you may join | `oats aweb teams --json` → `eligible[]` |
28
28
  | Teams you have joined | `oats aweb teams --json` → `joined[]` (each with `identityHome`, `receive`) |
29
29
  | How mail reaches you | the `Comms:` line of `TASK.md` (see section 4) |
30
30
 
31
- - **Personal team.** Your primary identity lives in the personal team of the
32
- person you work for: the aweb root's active team (`personal.source: root`),
33
- or the team the deployment pinned (`source: setting`). Everyone this
34
- deployment spawns into that team is there with you. (A separate team per
35
- workspace arrives in oats.aweb 1.16.)
31
+ - **Default team.** Your primary identity lives in the workspace's default team:
32
+ the aweb root's active team (`defaultTeam.source: root`), or the team the
33
+ deployment pinned (`defaultTeam.source: setting`). `defaultTeam.source` is
34
+ always present and is only `root` or `setting`. Everyone this deployment
35
+ spawns into that team is there with you.
36
36
  - **Joined teams.** A wider team the workspace defines, joined explicitly. Each
37
37
  gives you a **separate identity** with the same alias in that team, kept
38
38
  under `<home>/.aweb-identity-<label>`. You act as that team only with
@@ -42,7 +42,7 @@ joined team, put `--identity-home <identityHome>` before the subcommand.
42
42
  ## 2. Find who to talk to
43
43
 
44
44
  ```bash
45
- oats aweb roster # your personal team's members (instances + humans), across machines
45
+ oats aweb roster # your default team's members (instances + humans), across machines
46
46
  oats aweb roster --label <label> # an eligible workspace team's members
47
47
  oats status # live OATS instances on this machine
48
48
  ```
@@ -52,7 +52,7 @@ oats status # live OATS instances on this machine
52
52
  - Outside your team use a full address, `namespace/alias` (`--to-address`), only
53
53
  when you were given one.
54
54
  - A name that is not on the roster of the team you send from will not resolve:
55
- pick the identity (personal or joined) whose team holds the recipient.
55
+ pick the identity (default-team or joined) whose team holds the recipient.
56
56
 
57
57
  ## 3. Send, reply, chat
58
58
 
@@ -126,7 +126,7 @@ mail (`--show-all`).
126
126
  ## 5. Teams: join and leave
127
127
 
128
128
  ```bash
129
- oats aweb teams --json # {personal, primary, eligible, joined, unmapped}
129
+ oats aweb teams --json # {defaultTeam, primary, eligible, joined, unmapped}
130
130
  oats aweb join --labels <label>[,<label>]
131
131
  oats aweb leave --labels <label>[,<label>]
132
132
  ```
@@ -134,7 +134,7 @@ oats aweb leave --labels <label>[,<label>]
134
134
  - Join only when your human, coordinator or task asks you to work with that
135
135
  team. Joining mints a new identity for you in that team.
136
136
  - You may join only `eligible[]` labels; anything else is `E_TEAM_NOT_ELIGIBLE`.
137
- - Your personal team cannot be left (`E_TEAM_PERSONAL`).
137
+ - The workspace's default team cannot be left (`E_TEAM_DEFAULT` when the label is `default`).
138
138
  - When the workspace stops mapping a team, your next session start leaves it.
139
139
  - Do not run native `aw team join|switch|leave|invite` for your identities; the
140
140
  provider keeps homes, broker registration and retire cleanup consistent.
@@ -165,7 +165,7 @@ Check your own state first:
165
165
  ```bash
166
166
  aw whoami # identity you act as here
167
167
  aw workspace status # connection of the primary identity
168
- oats aweb teams --json # personal/joined teams and receive modes
168
+ oats aweb teams --json # defaultTeam/joined teams and receive modes
169
169
  oats readiness --home "$PWD" --json # the provider's readiness answer for this home
170
170
  ```
171
171
 
@@ -173,8 +173,7 @@ oats readiness --home "$PWD" --json # the provider's readiness answer for this
173
173
 
174
174
  | Code | Meaning | Who fixes it |
175
175
  |---|---|---|
176
- | `personal-root-deferred` | the host set `settings.oats.aweb.roots.personal`; 1.15 ignores it (per-workspace enrollment arrives in 1.16) | nobody; the host may remove the setting |
177
- | `team-unmapped` | your soul's primary label is not mapped by the workspace; you are in the personal team | workspace owner, if a shared team was meant |
176
+ | `team-unmapped` | your soul's primary label is not mapped by the workspace; you are in the default team | workspace owner, if a shared team was meant |
178
177
  | `joined-team-receive` | a joined team receives live through the broker (informational) | nobody |
179
178
  | `joined-team-poll-only` | a joined team does not wake you; the message says why | poll that team at task boundaries; human may start the wake daemon |
180
179
  | `wake-daemon-not-running` / `-outdated` / `-version-unknown` | host wake broker is down or older than 1.36.5 | human: upgrade aw, restart the host wake daemon |
@@ -185,7 +184,7 @@ oats readiness --home "$PWD" --json # the provider's readiness answer for this
185
184
 
186
185
  - `E_TEAM_NOT_ELIGIBLE` — the label is not one of your eligible teams; the
187
186
  message lists them. Check the spelling against `oats aweb teams --json`.
188
- - `E_TEAM_PERSONAL` — the personal team cannot be left.
187
+ - `E_TEAM_DEFAULT` — the workspace's default team cannot be left.
189
188
  - `E_TEAM_GLOBAL_MODE` — this home acts as a resident identity through a
190
189
  session grant; joined teams need local identities. Report it.
191
190
  - `E_TEAM_AW_FLOOR` — the host `aw` is too old: joined teams need aw >= 1.36.12.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { fs, join, resolve, readJSON, safePath, oats, fail, unlock } from '../lib/io.mjs';
2
+ import { fs, join, resolve, readJSON, safePath, oats, fail, unlock, redactUrls } from '../lib/io.mjs';
3
3
  import { loadBindings } from '../lib/config.mjs';
4
- import { register, registerCaptured, loadInvocationSourceReceipt, homeSource, loadSource, loadStatus, saveStatus, updateStatus, capture, scheduleSource, settleRetiredSchedule, service, markerPath, harvestOffRecord } from '../lib/sources.mjs';
4
+ import { register, registerCaptured, loadInvocationSourceReceipt, homeSource, loadSource, loadStatus, saveStatus, updateStatus, capture, scheduleSource, settleRetiredSchedule, service, markerPath, harvestOffRecord, sourceSwitch, retireHarvestOff } from '../lib/sources.mjs';
5
5
  import { harvestStatus, setupHarvest } from '../lib/harvest-status.mjs';
6
6
  import { settings } from '../lib/config.mjs';
7
7
  import { CONSULT } from '../lib/consult.mjs';
@@ -151,7 +151,10 @@ else {
151
151
  if(['STATE.md','log.md','notes','.okf-harvest-record.json','.okf-harvest-record.next.json'].some(p=>fs.existsSync(join(home,p)))) fail('E_MIGRATION','unregistered/legacy source has memory; explicitly migrate/register before retirement');
152
152
  result={meta:{retired:true,reason:'nothing-to-delete'}};
153
153
  } else {
154
- const s=src();scheduleSource(s);const r=capture(s,{final:true});const schedule=settleRetiredSchedule(s);result={meta:{retired:r.complete===true,source:s.file,capture:r,schedule},brief:'Final input is in durable custody. Delivery remains asynchronous.'};
154
+ const s=src(),sw=sourceSwitch(s);
155
+ // okf 4.0.1 #6: harvest switched off since spawn (deployment or soul) → no final capture.
156
+ if(sw.effective!=='on') result={meta:retireHarvestOff(s,sw),brief:`Harvest is now off (${sw.reason}): no final capture was taken; earlier inputs stay in custody.`};
157
+ else {scheduleSource(s);const r=capture(s,{final:true});const schedule=settleRetiredSchedule(s);result={meta:{retired:r.complete===true,source:s.file,capture:r,schedule},brief:'Final input is in durable custody. Delivery remains asynchronous.'};}
155
158
  }
156
159
  } else if(event==='harvest') {
157
160
  if(capturedHarvest) {
@@ -169,8 +172,8 @@ else {
169
172
  // The deployment can switch harvest off after a source registered: the
170
173
  // job then captures and processes nothing (the soul's opt-out was already
171
174
  // applied at registration). Nothing drains when it is switched back on.
172
- const source=src();
173
- result=settings().harvest!=='on'?{status:'harvest-off',source:source.file,reason:'the deployment does not switch harvest on (settings.oats.okf.harvest); nothing was captured'}
175
+ const source=src(),sw=sourceSwitch(source);
176
+ result=sw.effective!=='on'?{status:'harvest-off',source:source.file,reason:`${sw.reason}; nothing was captured`}
174
177
  :runSource(source,{manual:!!flags.manual,noLaunch:!!flags['no-launch']});
175
178
  }
176
179
  else if(event==='complete') {const s=src();if(captured) retainedRun(s);result=complete(s,flags.run,flags.judgment && resolve(flags.judgment));}
@@ -196,7 +199,7 @@ else {
196
199
  } else if(event==='unlock') result=unlock(resolve(flags.lock),flags.token);
197
200
  else fail('E_USAGE',`unknown command ${event}; see --help`);
198
201
  answer=hook?result:{schemaVersion:1,ok:true,result};
199
- } 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}};}
202
+ } catch(e) {const code=e.code || 'E_OKF',message=redactUrls(e.message);exit=1;answer=hook?{meta:{...(event==='retire'?{retired:false,reason:message}:{})},warning:`oats-okf ${code}: ${message}`}:{schemaVersion:1,ok:false,error:{code,message}};}
200
203
  // Consult commands print text unless --json; every other answer is JSON.
201
204
  // Let Node drain the pipe; no process.exit after a possibly large answer.
202
205
  if(textMode && exit) process.stderr.write(`oats okf ${event}: ${answer.error.code}: ${answer.error.message}\n`);
@@ -1,5 +1,5 @@
1
1
  import { isAbsolute } from 'node:path';
2
- import { fs, join, resolve, dirname, fail, readJSON, safePath, relPath, identifier, overlaps, hash } from './io.mjs';
2
+ import { fs, join, resolve, dirname, fail, readJSON, safePath, relPath, identifier, overlaps, hash, embedsCredential } from './io.mjs';
3
3
  const obj = v => v && typeof v === 'object' && !Array.isArray(v);
4
4
  function keys(value, allowed, label, code='E_CONFIG') {
5
5
  if(!obj(value)) fail(code, `${label} must be an object`);
@@ -48,6 +48,7 @@ export function validateBindings(doc, file, { sourceHome, sourceWork } = {}) {
48
48
  keys(raw,['id','kind','repository','root','acceptedBranch','pr'],'git base');
49
49
  keys(raw.pr,['repository'],'git pr');
50
50
  if(typeof raw.repository !== 'string' || !raw.repository || raw.repository.startsWith('-') || /[\r\n\0]/.test(raw.repository) || 'path' in raw) fail('E_CONFIG', 'git base requires repository');
51
+ if(embedsCredential(raw.repository)) fail('E_CONFIG', `git base "${alias}": the repository locator embeds a credential (user:token@); bind the plain URL and let a Git credential helper or SSH key supply access`);
51
52
  const root = relPath(raw.root, true);
52
53
  if (typeof raw.acceptedBranch !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(raw.acceptedBranch) || raw.acceptedBranch.includes('..') || raw.acceptedBranch.endsWith('/') || raw.acceptedBranch.endsWith('.lock')) fail('E_CONFIG','invalid acceptedBranch');
53
54
  if (!obj(raw.pr) || typeof raw.pr.repository !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(raw.pr.repository)) fail('E_CONFIG','git pr requires repository: owner/repo (same-repository PRs)');
@@ -5,7 +5,7 @@
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { hostname } from 'node:os';
7
7
  import { spawnSync } from 'node:child_process';
8
- import { fs, join, dirname, safePath, readJSON, save, digest, tree, withLock, fail, syncDir, identifier } from './io.mjs';
8
+ import { fs, join, dirname, resolve, safePath, readJSON, save, digest, tree, withLock, fail, syncDir, identifier, relPath, within, displayRepo } from './io.mjs';
9
9
  import { noGit, gitTimeoutMs, consultMaxAgeMs, splitRef, resolveNodes } from './config.mjs';
10
10
  import { git, gitEnv, validateBase, baseLock, journalPath, preflightLocalRepository, requireNotShallow, verifyRemote, unavailable } from './stores.mjs';
11
11
 
@@ -248,6 +248,23 @@ export function withBase(bindings, alias, { fresh = false } = {}, fn) {
248
248
  }
249
249
 
250
250
  // ---------------------------------------------------------------- validation
251
+ /** A tree entry name, from an untrusted accepted tree, as a path under the base
252
+ * root. Git accepts literal `..`, absolute-looking and control-character entry
253
+ * names (`hash-object --literally`), and they survive a partial clone, so every
254
+ * name gets relPath's canonical rules (no empty, `.`, `..`, absolute, backslash,
255
+ * NUL or `.git` segment) plus no control characters or percent-escapes. */
256
+ export function treeEntryPath(name) {
257
+ const bad = () => fail('E_PATH', `invalid tree entry in knowledge base: ${JSON.stringify(String(name)).slice(0, 200)}`);
258
+ if (typeof name !== 'string' || /[\u0000-\u001f\u007f]/.test(name) || /%[0-9a-fA-F]{2}/.test(name)) bad();
259
+ try { return relPath(name); } catch { return bad(); }
260
+ }
261
+ /** Where a tree entry lands under `root`, refused unless it stays strictly
262
+ * inside it after resolution (the second, independent guard). */
263
+ export function containedTarget(root, name) {
264
+ const target = resolve(root, treeEntryPath(name));
265
+ if (target === resolve(root) || !within(root, target)) fail('E_PATH', `tree entry escapes its materialization root: ${JSON.stringify(name).slice(0, 200)}`);
266
+ return safePath(target);
267
+ }
251
268
  const VALIDATION_CODES = new Set(['E_VALIDATION', 'E_BASE', 'E_PATH', 'E_ID', 'E_CONFIG']);
252
269
  /** Whether the accepted state is a valid OKF base. Git verdicts are cached per
253
270
  * commit inside the host cache; computing one materializes the base root into
@@ -264,10 +281,15 @@ export function verdict(ctx) {
264
281
  try {
265
282
  const result = judge(() => {
266
283
  const rows = lsTree(ctx, '', { recursive: true });
267
- for (const r of rows) if (!['100644', '100755'].includes(r.mode) || r.type !== 'blob') fail('E_PATH', 'symlink or submodule in knowledge base is not allowed');
284
+ // Every entry is judged before anything is written: the first bad name or
285
+ // mode refuses the whole base, and nothing lands outside the scratch.
286
+ for (const r of rows) {
287
+ if (!['100644', '100755'].includes(r.mode) || r.type !== 'blob') fail('E_PATH', 'symlink or submodule in knowledge base is not allowed');
288
+ r.target = containedTarget(scratch, r.name);
289
+ }
268
290
  prefetch(ctx, '');
269
291
  for (const r of rows) {
270
- const target = safePath(join(scratch, r.name)); fs.mkdirSync(dirname(target), { recursive: true });
292
+ const target = r.target; fs.mkdirSync(dirname(target), { recursive: true });
271
293
  const fd = fs.openSync(target, 'wx', 0o600);
272
294
  let w; try { w = spawnSync('git', ['--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-C', ctx.cache, 'cat-file', 'blob', r.oid], { env: gitEnv(), timeout: gitTimeoutMs(), stdio: ['ignore', fd, 'pipe'] }); } finally { fs.closeSync(fd); }
273
295
  if (w.error || w.status !== 0) unavailable(ctx.base, ctx.alias, 'read', gitError(w));
@@ -346,7 +368,7 @@ export function bases(source, flags) {
346
368
  const rows = [], receipts = [];
347
369
  for (const [alias, base] of Object.entries(source.bindings.bases)) withBase(source.bindings, alias, { fresh: !!flags.fresh }, ctx => {
348
370
  const v = verdict(ctx), mine = relation => source.decl[relation].filter(ref => splitRef(ref)[0] === alias).map(ref => splitRef(ref)[1]);
349
- rows.push({ alias, id: base.id, kind: base.kind, ...(base.kind === 'git' ? { repository: base.repository, acceptedBranch: base.acceptedBranch, root: base.root, commit: ctx.commit } : { path: base.path, digest: ctx.receipt.digest }),
371
+ rows.push({ alias, id: base.id, kind: base.kind, ...(base.kind === 'git' ? { repository: displayRepo(base.repository), acceptedBranch: base.acceptedBranch, root: base.root, commit: ctx.commit } : { path: base.path, digest: ctx.receipt.digest }),
350
372
  fetchedAt: ctx.receipt.fetchedAt, stale: ctx.receipt.stale, ...(ctx.receipt.reason ? { reason: ctx.receipt.reason } : {}),
351
373
  validated: v.ok ? { ok: true } : { ok: false, error: v.error }, nodes: v.ok ? Object.keys(v.nodes) : null, owns: mine('owns'), reads: mine('reads'), receipt: ctx.receipt });
352
374
  receipts.push(ctx.receipt);
@@ -60,11 +60,24 @@ function shaped(value) {
60
60
  * A soul `on` is ignored and reported: a soul cannot switch a host on. It also
61
61
  * hides the host's value (the kernel's merged `harvest` is then the soul's),
62
62
  * so with a soul `on` the switch stays off until the soul drops the line. */
63
- export function harvestSwitch({ settings = {}, soulDir = process.env.OATS_SOUL } = {}) {
64
- const deployment = VALUES.includes(settings.harvest) ? settings.harvest : 'off';
63
+ /** OATS_SETTINGS_ORIGINS (kernel 0.29.0): JSON pointer → { kind, at } of the LAST
64
+ * layer that set each leaf. {} when absent or unreadable. */
65
+ export function parseOrigins(text = process.env.OATS_SETTINGS_ORIGINS) {
66
+ try { const o = JSON.parse(text || '{}'); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; } catch { return {}; }
67
+ }
68
+ /** okf 4.0.1: origins name the last layer only, and the host's settings merge
69
+ * AFTER the soul's slot, so a host `harvest: on` hides a soul's `off` there.
70
+ * soul.yaml therefore stays the authority for the absolute opt-out; origins
71
+ * are an extra signal: a merged value whose origin is the soul is never the
72
+ * deployment switching harvest on (a soul `on` is ignored, a soul `off` is off). */
73
+ export function harvestSwitch({ settings = {}, soulDir = process.env.OATS_SOUL, origins = parseOrigins() } = {}) {
74
+ const origin = origins?.['/harvest'] && typeof origins['/harvest'] === 'object' ? origins['/harvest'] : null;
75
+ const fromSoul = origin?.kind === 'soul';
76
+ const deployment = !fromSoul && VALUES.includes(settings.harvest) ? settings.harvest : 'off';
65
77
  const soul = soulHarvest(soulDir);
78
+ if (fromSoul && VALUES.includes(settings.harvest) && soul.value === null) Object.assign(soul, { value: settings.harvest, readable: true, why: `OATS_SETTINGS_ORIGINS: harvest: ${settings.harvest} came from the soul (${String(origin.at || 'soul.yaml')})` });
66
79
  const rows = [
67
- { layer: 'deployment', value: deployment, why: settings.harvest === undefined ? 'harvest is not set (default off)' : `settings.oats.okf.harvest: ${settings.harvest}` },
80
+ { layer: 'deployment', value: deployment, ...(origin ? { origin: origin.kind } : {}), why: fromSoul ? 'the merged harvest value came from the soul, so the deployment does not switch harvest on' : settings.harvest === undefined ? 'harvest is not set (default off)' : `settings.oats.okf.harvest: ${settings.harvest}` },
68
81
  { layer: 'soul', value: soul.value, readable: soul.readable, why: soul.why },
69
82
  ];
70
83
  const warnings = [];
@@ -8,6 +8,14 @@ export const fail = (code, message) => { throw Object.assign(new Error(message),
8
8
  export const hash = value => createHash('sha256').update(typeof value === 'string' || Buffer.isBuffer(value) ? value : JSON.stringify(value)).digest('hex');
9
9
  export const readJSON = path => JSON.parse(fs.readFileSync(path, 'utf8'));
10
10
  export const within = (root, path) => { const r = relative(root, path); return r === '' || (!r.startsWith('..' + sep) && r !== '..' && !isAbsolute(r)); };
11
+ // okf 4.0.1 #2: a URL's userinfo is a credential (https://user:token@host).
12
+ // Strip it from anything shown to agents or users; an SSH user alone
13
+ // (ssh://git@host) is not a secret and stays.
14
+ const USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)([^\s/@'"]+)@/gi;
15
+ export const redactUrls = text => String(text).replace(USERINFO, (m, scheme, info) => /^ssh:/i.test(scheme) && !info.includes(':') ? m : scheme);
16
+ export const displayRepo = repository => typeof repository === 'string' ? redactUrls(repository) : repository;
17
+ /** Whether a repository locator embeds a credential (any userinfo except a bare SSH user). */
18
+ export const embedsCredential = repository => { USERINFO.lastIndex = 0; return typeof repository === 'string' && redactUrls(repository) !== repository; };
11
19
  export const overlaps = (a, b) => within(a, b) || within(b, a);
12
20
  export function safePath(path) {
13
21
  path = resolve(path);
@@ -77,7 +85,7 @@ export function unlock(path, token) {
77
85
  try { process.kill(o.pid, 0); fail('E_LOCKED', 'lock owner is still alive'); } catch (e) { if(e.code !== 'ESRCH') throw e; }
78
86
  fs.rmSync(path, { recursive: true }); syncDir(dirname(path)); return { unlocked: path };
79
87
  }
80
- export const identityKeys = ['OATS_INSTANCE','OATS_INSTANCE_HOME','OATS_HOME','PI_AGENT_HOME','PI_AGENT_NAME','PI_AGENT_INSTANCE','PI_AGENTS_ROOT','OATS_ROOT','OATS_SOUL','OATS_SOUL_ID','OATS_AGENT','OATS_KIND','OATS_EVENT','OATS_CONTEXT','OATS_REPO','OATS_WORK','OATS_BRANCH','OATS_META','OATS_SETTINGS','OATS_BINDING_FILE','OATS_SOURCE_RECEIPT_FILE','OATS_INVOCATION_CONTEXT_FILE','OATS_DEPLOYMENT','OATS_RESOLUTION'];
88
+ export const identityKeys = ['OATS_INSTANCE','OATS_INSTANCE_HOME','OATS_HOME','PI_AGENT_HOME','PI_AGENT_NAME','PI_AGENT_INSTANCE','PI_AGENTS_ROOT','OATS_ROOT','OATS_SOUL','OATS_SOUL_ID','OATS_AGENT','OATS_KIND','OATS_EVENT','OATS_CONTEXT','OATS_REPO','OATS_WORK','OATS_BRANCH','OATS_META','OATS_SETTINGS','OATS_SETTINGS_ORIGINS','OATS_BINDING_FILE','OATS_SOURCE_RECEIPT_FILE','OATS_INVOCATION_CONTEXT_FILE','OATS_DEPLOYMENT','OATS_RESOLUTION'];
81
89
  export function cleanEnv(env = process.env) {
82
90
  return Object.fromEntries(Object.entries(env).filter(([k]) => !/^(OATS_(?!HOME_DIR$|PACKAGE_CATALOG$)|PI_AGENT|GIT_)/.test(k)));
83
91
  }