run402 4.26.0 → 4.27.0

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.
@@ -126,6 +126,52 @@ export function clearActiveProjectId(projectId, path, scope = {}) {
126
126
  saveProfileState(state, p);
127
127
  });
128
128
  }
129
+ // ---------------------------------------------------------------------------
130
+ // Selected organization (add-cli-current-org, design D3).
131
+ //
132
+ // Scoped exactly like the active project id — by api_base, profile, and
133
+ // principal — and stored in the SAME per-profile state.json, never in the
134
+ // base-level config.json beside `active_wallet`. The chain is
135
+ // wallet -> principal -> memberships, so a globally-selected org survives
136
+ // `wallets use other` and then either 403s or, worse, silently resolves to a
137
+ // valid-but-wrong org when both principals are members. Per-profile makes the
138
+ // failure mode "this profile has no current org", which the ORG_REQUIRED
139
+ // envelope already knows how to explain.
140
+ //
141
+ // There is deliberately no top-level `active_org_id` mirror: the project one
142
+ // exists only to carry pre-scoping installs forward, and this key is new.
143
+ // ---------------------------------------------------------------------------
144
+ export function getActiveOrgId(path, scope = {}) {
145
+ const state = loadProfileState(path);
146
+ return state.active_orgs?.[activeProjectScopeKey(scope)]?.org_id;
147
+ }
148
+ export function setActiveOrgId(orgId, path, scope = {}) {
149
+ const p = path ?? getProfileStatePath();
150
+ withFileLock(p, () => {
151
+ const state = loadProfileState(p);
152
+ const key = activeProjectScopeKey(scope);
153
+ const resolved = defaultActiveProjectScope(scope);
154
+ state.active_orgs = state.active_orgs ?? {};
155
+ state.active_orgs[key] = {
156
+ org_id: orgId,
157
+ api_base: resolved.api_base,
158
+ principal: resolved.principal ?? null,
159
+ profile: resolved.profile,
160
+ updated_at: new Date().toISOString(),
161
+ };
162
+ saveProfileState(state, p);
163
+ });
164
+ }
165
+ export function clearActiveOrgId(path, scope = {}) {
166
+ const p = path ?? getProfileStatePath();
167
+ withFileLock(p, () => {
168
+ const state = loadProfileState(p);
169
+ const key = activeProjectScopeKey(scope);
170
+ if (state.active_orgs?.[key])
171
+ delete state.active_orgs[key];
172
+ saveProfileState(state, p);
173
+ });
174
+ }
129
175
  export function recordMigration(marker, value, path) {
130
176
  const p = path ?? getProfileStatePath();
131
177
  withFileLock(p, () => {
package/lib/claims.mjs CHANGED
@@ -51,7 +51,9 @@ Advisory, always:
51
51
  Room addressing:
52
52
  Default room of the active project with no flags; --project <id> for another
53
53
  project; --org <org_id> --room <key> (or RUN402_ROOM=<org_id>/<key>) for a
54
- named org room.
54
+ named org room. --room <key> alone works when the org resolves on its own
55
+ (--org / RUN402_ORG / the 'org' key of the nearest .run402.json / 'org use'),
56
+ and {"org":"...","room":"..."} in .run402.json binds a whole checkout.
55
57
 
56
58
  Options:
57
59
  --mode <m> exclusive (default) — one worker; shared — parallel-safe.
@@ -199,6 +199,9 @@ export const COMMAND_MANIFEST = [
199
199
  { path: ["org", "payout-wallet"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1", "--wallet", "0x1111111111111111111111111111111111111111"] },
200
200
  { path: ["org", "whoami"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
201
201
  { path: ["org", "audit"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1"] },
202
+ { path: ["org", "use"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["11111111-2222-3333-4444-555555555555"] },
203
+ { path: ["org", "current"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
204
+ { path: ["org", "clear"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
202
205
  { path: ["org", "member", "list"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1"] },
203
206
  { path: ["org", "member", "add"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1", "--wallet", "0x1111111111111111111111111111111111111111"] },
204
207
  { path: ["org", "member", "role"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1", "--principal", "prn_gate1", "--role", "viewer"] },
@@ -1,29 +1,305 @@
1
1
  /**
2
2
  * Shared organization resolution for org-scoped CLI families
3
- * (attention-architecture Wave E — one resolver, not one per family).
3
+ * (attention-architecture Wave E — one resolver, not one per family;
4
+ * extended by add-cli-current-org with the local-context classes).
4
5
  *
5
- * Precedence: explicit `--org` flag `RUN402_ORG` env the active project's
6
- * owning org (the zero-config checkout path, one SDK lookup). Every org-scoped
7
- * family (`escalations`, and whatever ships next) resolves through here so a
8
- * checkout needs no flags and the precedence cannot drift per family.
6
+ * The org is the ownership root it owns projects, holds memberships, and is
7
+ * the scope for rooms, escalations, members, grants, and audit. This is the one
8
+ * place org precedence is expressed.
9
9
  *
10
- * `rooms`/`claims` predate this and resolve a PAIR ({orgId, roomKey}) with
11
- * their own `RUN402_ROOM` env form see `rooms-context.mjs`; forcing them
12
- * through a single-value resolver would contort both. One resolver per SHAPE.
10
+ * Precedence is four INTENT CLASSES, highest first, and inside each class an
11
+ * organization named directly outranks one derived from a project named in that
12
+ * same class:
13
+ *
14
+ * 1. flag --org <org_id> else --project <id> -> its org
15
+ * 2. environment RUN402_ORG, else the org else RUN402_PROJECT_ID -> its org
16
+ * half of RUN402_ROOM
17
+ * 3. binding the `org` key of the nearest .run402(.local).json
18
+ * 4. profile state the profile's selected org else its active project -> org
19
+ *
20
+ * The class rule is what makes `--project X` behave: naming a project IS naming
21
+ * its organization, so a stale profile selection must not outrank it. Classes 3
22
+ * and 4 are what let an org that owns NO project be addressed at all — every
23
+ * earlier rung needed an argument, a variable, or a deployed project.
24
+ *
25
+ * `rooms`/`claims` resolve a PAIR ({orgId, roomKey}) with their own
26
+ * `RUN402_ROOM` env form — see `rooms-context.mjs`. They are not forced through
27
+ * this single-value resolver: the pair keeps its own shape and its own
28
+ * precedence, and only the ORG HALF of its fallback delegates here. One
29
+ * resolver per shape, composed for the half they share.
30
+ *
31
+ * Two things this deliberately does NOT do:
32
+ * - Infer the org from the caller's memberships, at any count. Membership is
33
+ * server state that changes without the caller acting, so a heuristic that is
34
+ * right today silently changes meaning the day they are invited elsewhere.
35
+ * - Validate membership locally. A well-formed id goes to the server and the
36
+ * server's answer is surfaced as returned — never rewritten into a local
37
+ * not-found (that would invent the existence oracle the gateway refuses to
38
+ * provide) and never retried against a lower class (that would act on a
39
+ * different organization than the caller named).
13
40
  */
14
41
  import { getSdk } from "./sdk.mjs";
15
42
  import { flagValue } from "./argparse.mjs";
43
+ import { findBindingKey } from "./wallet-context.mjs";
44
+ import { fail } from "./sdk-errors.mjs";
45
+ import { nextAction } from "./next-actions.mjs";
46
+ import {
47
+ getActiveOrgId as coreGetActiveOrgId,
48
+ setActiveOrgId as coreSetActiveOrgId,
49
+ clearActiveOrgId as coreClearActiveOrgId,
50
+ } from "../core-dist/profile-state.js";
51
+ import { getActiveProjectId } from "../core-dist/keystore.js";
16
52
  import { getProject, resolveProjectId, updateProject } from "./config.mjs";
17
53
 
18
- /** Resolve the addressed organization from normalized argv (one SDK lookup at most). */
19
- export async function resolveOrgId(a) {
20
- const explicit = flagValue(a, "--org");
21
- if (explicit) return explicit;
22
- const envOrg = (process.env.RUN402_ORG ?? "").trim();
23
- if (envOrg) return envOrg;
24
- const projectId = resolveProjectId(flagValue(a, "--project"));
25
- const scoped = await getSdk().rooms.forProject(projectId);
26
- return scoped.orgId;
54
+ export const ORG_ENV = "RUN402_ORG";
55
+ export const ROOM_ENV = "RUN402_ROOM";
56
+ export const PROJECT_ENV = "RUN402_PROJECT_ID";
57
+
58
+ /** Command groups that must stay usable while selection is ambiguous. */
59
+ const CONFLICT_EXEMPT = new Set(["org", "wallets", "doctor"]);
60
+
61
+ /** `org_id` is a UUID at every API boundary (`uuidParam` on every route). */
62
+ const ORG_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
63
+
64
+ const trimmed = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
65
+
66
+ /**
67
+ * Shape-validate an organization id supplied by a local source. Membership is
68
+ * NEVER checked here — only that the value could be an org id at all.
69
+ */
70
+ function assertOrgIdShape(orgId, origin) {
71
+ if (ORG_ID_RE.test(orgId)) return orgId;
72
+ fail({
73
+ code: "BAD_ORG_ID",
74
+ message: `Invalid organization id ${JSON.stringify(orgId)} (from ${origin}).`,
75
+ hint: "An org_id is a UUID. Run 'run402 org list' to see the organizations you belong to.",
76
+ details: { org_id: orgId, origin },
77
+ next_actions: [listOrgsAction()],
78
+ });
79
+ }
80
+
81
+ /** The org half of `RUN402_ROOM=<org_id>/<room_key>`, or null. */
82
+ function orgFromRoomEnv(env) {
83
+ const raw = trimmed(env[ROOM_ENV]);
84
+ if (!raw) return null;
85
+ const slash = raw.indexOf("/");
86
+ if (slash <= 0 || slash === raw.length - 1) return null; // rooms-context reports the malformed form
87
+ return raw.slice(0, slash);
88
+ }
89
+
90
+ function listOrgsAction() {
91
+ return nextAction("edit_request", {
92
+ command: "run402 org list",
93
+ why: "List the organizations this wallet belongs to.",
94
+ });
95
+ }
96
+
97
+ function orgRequiredActions() {
98
+ return [
99
+ nextAction("edit_request", {
100
+ command: "run402 org use <org_id>",
101
+ why: "Select a current organization for this wallet profile.",
102
+ }),
103
+ listOrgsAction(),
104
+ nextAction("edit_request", {
105
+ command: "run402 <command> --org <org_id>",
106
+ why: "Name the organization on this one call.",
107
+ }),
108
+ nextAction("edit_request", {
109
+ command: "export RUN402_ORG=<org_id>",
110
+ why: "Name the organization for every call in this shell (harness wiring).",
111
+ }),
112
+ nextAction("edit_request", {
113
+ command: `echo '{"org":"<org_id>"}' > .run402.json`,
114
+ why: "Bind this checkout to an organization so every agent in it inherits the same one.",
115
+ }),
116
+ nextAction("edit_request", {
117
+ command: "run402 projects use <project_id>",
118
+ why: "Select a project; its owning organization becomes the current one.",
119
+ }),
120
+ ];
121
+ }
122
+
123
+ /** Resolve a project's owning org (one GET). Returns null when unresolvable. */
124
+ async function orgOfProject(projectId, { required }) {
125
+ // Upstream's per-project cache answers the same question for free when it is
126
+ // warm. It is NOT used as the whole implementation: it returns null on
127
+ // failure, and an explicitly named project must be able to HARD STOP rather
128
+ // than fall through to a lower class.
129
+ const cached = getProject(projectId)?.org_id;
130
+ if (typeof cached === "string" && cached.length > 0) return cached;
131
+ try {
132
+ const scoped = await getSdk().rooms.forProject(projectId);
133
+ return scoped.orgId;
134
+ } catch (err) {
135
+ // An EXPLICITLY named project that cannot be resolved is a hard stop — the
136
+ // caller named it, so falling to a lower class would act on a different
137
+ // organization than the one they asked for. An implicitly selected project
138
+ // (profile state) may simply be stale; skip the class.
139
+ if (!required) return null;
140
+ throw err;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Resolve the organization an org-scoped command acts on.
146
+ *
147
+ * Accepts either normalized argv (the Wave E call shape, still used by
148
+ * `escalations`) or an options object for callers that already hold values.
149
+ *
150
+ * @param {string[]|object} input normalized argv, or { org, project }
151
+ * @param {object} [opts]
152
+ * @param {string} [opts.cmd] command group, for the conflict exemption
153
+ * @param {object} [opts.env] environment (injectable for tests)
154
+ * @param {string} [opts.cwd] directory to walk up from (injectable)
155
+ * @param {boolean} [opts.optional] return null instead of failing when absent
156
+ * @returns {Promise<{orgId: string, source: string, sourceDetail: string}|null>}
157
+ */
158
+ export async function resolveOrg(input = {}, opts = {}) {
159
+ const { cmd, env = process.env, cwd = process.cwd(), optional = false } = opts;
160
+ const org = Array.isArray(input) ? flagValue(input, "--org") : input.org;
161
+ const project = Array.isArray(input) ? flagValue(input, "--project") : input.project;
162
+
163
+ // --- Class 1: flag -------------------------------------------------------
164
+ const orgFlag = trimmed(org);
165
+ if (orgFlag) {
166
+ return { orgId: assertOrgIdShape(orgFlag, "--org"), source: "flag", sourceDetail: "--org" };
167
+ }
168
+ const projectFlag = trimmed(project);
169
+ if (projectFlag) {
170
+ const orgId = await orgOfProject(projectFlag, { required: true });
171
+ if (orgId) return { orgId, source: "flag", sourceDetail: "--project" };
172
+ }
173
+
174
+ // --- Class 2 and 3: environment vs binding (the conflict pair) ------------
175
+ const bindingHit = findBindingKey(cwd, "org");
176
+ const bindingOrg = bindingHit ? assertOrgIdShape(bindingHit.value, bindingHit.file) : null;
177
+
178
+ const envDirect = trimmed(env[ORG_ENV]);
179
+ const envRoomOrg = envDirect ? null : orgFromRoomEnv(env);
180
+ const envProject = envDirect || envRoomOrg ? null : trimmed(env[PROJECT_ENV]);
181
+
182
+ let envOrg = null;
183
+ let envDetail = null;
184
+ if (envDirect) {
185
+ envOrg = assertOrgIdShape(envDirect, ORG_ENV);
186
+ envDetail = ORG_ENV;
187
+ } else if (envRoomOrg) {
188
+ envOrg = assertOrgIdShape(envRoomOrg, ROOM_ENV);
189
+ envDetail = ROOM_ENV;
190
+ } else if (envProject && bindingOrg) {
191
+ // Only worth a lookup when a binding exists to disagree with; otherwise the
192
+ // env class wins uncontested and the lookup happens once, below.
193
+ envOrg = await orgOfProject(envProject, { required: false });
194
+ envDetail = PROJECT_ENV;
195
+ }
196
+
197
+ if (envOrg && bindingOrg && envOrg !== bindingOrg && !CONFLICT_EXEMPT.has(cmd)) {
198
+ // The wallet tier's rule, scoped to the same pair. A binding deliberately
199
+ // declared by a checkout outranks the profile's selection SILENTLY — that
200
+ // is what a binding is for — but an ambient env var disagreeing with a
201
+ // committed file is exactly the surprise worth stopping on.
202
+ fail({
203
+ code: "AMBIGUOUS_ORG",
204
+ message: `Ambiguous organization: ${envDetail}=${envOrg} but ${bindingHit.file} binds ${bindingOrg}.`,
205
+ hint: `Resolve with one of: pass --org <org_id>, unset ${envDetail}, or edit the binding file.`,
206
+ details: {
207
+ candidates: [
208
+ { org_id: envOrg, source: "env", source_detail: envDetail },
209
+ { org_id: bindingOrg, source: "binding", source_detail: bindingHit.file },
210
+ ],
211
+ },
212
+ next_actions: [
213
+ nextAction("edit_request", {
214
+ command: "run402 <command> --org <org_id>",
215
+ why: "The flag resolves the conflict for this call.",
216
+ }),
217
+ ],
218
+ });
219
+ }
220
+
221
+ if (envOrg) return { orgId: envOrg, source: "env", sourceDetail: envDetail };
222
+ if (envProject) {
223
+ const orgId = await orgOfProject(envProject, { required: false });
224
+ if (orgId) return { orgId, source: "env", sourceDetail: PROJECT_ENV };
225
+ }
226
+ if (bindingOrg) return { orgId: bindingOrg, source: "binding", sourceDetail: bindingHit.file };
227
+
228
+ // --- Class 4: profile state ----------------------------------------------
229
+ const selected = trimmed(coreGetActiveOrgId());
230
+ if (selected) {
231
+ return { orgId: assertOrgIdShape(selected, "org use"), source: "profile", sourceDetail: "org use" };
232
+ }
233
+ const activeProject = trimmed(getActiveProjectId());
234
+ if (activeProject) {
235
+ const orgId = await orgOfProject(activeProject, { required: false });
236
+ if (orgId) return { orgId, source: "profile", sourceDetail: "projects use" };
237
+ }
238
+
239
+ if (optional) return null;
240
+ fail({
241
+ code: "ORG_REQUIRED",
242
+ message: "No organization specified and no current organization set.",
243
+ hint: `Pass --org <org_id>, set ${ORG_ENV}, bind this directory in .run402.json, or run: run402 org use <org_id>`,
244
+ next_actions: orgRequiredActions(),
245
+ });
246
+ return null; // unreachable — fail() exits
247
+ }
248
+
249
+ /**
250
+ * Resolve the addressed organization id from normalized argv.
251
+ *
252
+ * The Wave E signature, preserved: callers that only need the id keep passing
253
+ * argv and getting a string back. Use {@link resolveOrg} when the provenance
254
+ * pair is wanted too.
255
+ */
256
+ export async function resolveOrgId(a, opts = {}) {
257
+ const resolved = await resolveOrg(a, opts);
258
+ return resolved ? resolved.orgId : null;
259
+ }
260
+
261
+ /** The profile's selected organization, without running the chain. */
262
+ export function getSelectedOrgId() {
263
+ return trimmed(coreGetActiveOrgId());
264
+ }
265
+
266
+ /** Record the profile's selected organization. */
267
+ export function setSelectedOrgId(orgId, origin = "org use") {
268
+ coreSetActiveOrgId(assertOrgIdShape(orgId, origin));
269
+ }
270
+
271
+ /** Clear the profile's selected organization. */
272
+ export function clearSelectedOrgId() {
273
+ coreClearActiveOrgId();
274
+ }
275
+
276
+ /**
277
+ * Stamp a project's owning organization as the profile selection.
278
+ *
279
+ * Called by `projects use`: a project determines its organization
280
+ * unambiguously, so selecting one keeps the two lowest classes from ever
281
+ * disagreeing in practice — and gives an existing user a current org without
282
+ * reading a changelog. Best-effort: a failure here must never fail the project
283
+ * selection the caller actually asked for.
284
+ */
285
+ export async function stampOrgFromProject(projectId) {
286
+ try {
287
+ const orgId = await orgOfProject(projectId, { required: false });
288
+ if (orgId) {
289
+ coreSetActiveOrgId(orgId);
290
+ return orgId;
291
+ }
292
+ } catch {
293
+ /* best-effort */
294
+ }
295
+ return null;
296
+ }
297
+
298
+ /** Provenance pair for command output: bounded, never a resolution trace. */
299
+ export function orgProvenance(resolved) {
300
+ return resolved
301
+ ? { org_id: resolved.orgId, org_source: resolved.source, org_source_detail: resolved.sourceDetail }
302
+ : { org_id: null, org_source: null, org_source_detail: null };
27
303
  }
28
304
 
29
305
  /**
package/lib/org.mjs CHANGED
@@ -1,9 +1,17 @@
1
1
  import { getSdk } from "./sdk.mjs";
2
2
  import { reportSdkError, fail } from "./sdk-errors.mjs";
3
+ import {
4
+ resolveOrg,
5
+ orgProvenance,
6
+ getSelectedOrgId,
7
+ setSelectedOrgId,
8
+ clearSelectedOrgId,
9
+ } from "./org-context.mjs";
3
10
  import {
4
11
  normalizeArgv,
5
12
  assertKnownFlags,
6
13
  flagValue,
14
+ positionalArgs,
7
15
  parseIntegerFlag,
8
16
  requirePositionalCount,
9
17
  failUnknownSubcommand,
@@ -20,6 +28,9 @@ Usage:
20
28
  run402 org rename <org_id> --name <display_name> (or: --clear to remove the label)
21
29
  run402 org payout-wallet <org_id> --wallet <wallet_address> (or: --clear to remove the explicit default)
22
30
  run402 org whoami
31
+ run402 org use <org_id>
32
+ run402 org current
33
+ run402 org clear
23
34
  run402 org audit <org_id> [--limit N] [--after <cursor>] [--before <cursor>]
24
35
  run402 org member list <org_id>
25
36
  run402 org member add <org_id> --wallet <wallet_address> [--role <role>]
@@ -37,6 +48,9 @@ Subcommands:
37
48
  list Orgs you are a member of
38
49
  get Read one org (label + tier/lease + your role)
39
50
  rename Set or clear an org's display label (owner-only)
51
+ use Select the current org for this wallet profile
52
+ current Report the resolved current org and where it came from
53
+ clear Clear this wallet profile's org selection
40
54
  payout-wallet Set or clear the tenant route payout wallet (admin+)
41
55
  whoami Resolved principal + org memberships (GET /agent/v1/whoami)
42
56
  member Manage members (list, add, role, rm) — mutations require owner
@@ -198,6 +212,52 @@ async function whoami(args) {
198
212
  }
199
213
  }
200
214
 
215
+ // ── Current organization (add-cli-current-org) ─────────────────────────────────
216
+ //
217
+ // The selection is per WALLET PROFILE, not global: the chain is
218
+ // wallet -> principal -> memberships, so a global selection survives
219
+ // `wallets use other` and then either 403s or silently resolves to a
220
+ // valid-but-wrong org when both principals are members.
221
+
222
+ async function use(args) {
223
+ const a = normalizeArgv(args);
224
+ assertKnownFlags(a, ["--help", "-h"]);
225
+ requirePositionalCount(a, [], {
226
+ min: 1, max: 1, command: "run402 org use <org_id>", missing: "<org_id>",
227
+ });
228
+ const orgId = positionalArgs(a, [])[0];
229
+ setSelectedOrgId(orgId);
230
+ console.log(JSON.stringify({ org_id: orgId, selected: true, scope: "wallet_profile" }, null, 2));
231
+ }
232
+
233
+ async function clear(args) {
234
+ const a = normalizeArgv(args);
235
+ assertKnownFlags(a, ["--help", "-h"]);
236
+ requirePositionalCount(a, [], { min: 0, max: 0, command: "run402 org clear" });
237
+ const previous = getSelectedOrgId();
238
+ clearSelectedOrgId();
239
+ console.log(JSON.stringify({ org_id: null, selected: false, previous_org_id: previous ?? null }, null, 2));
240
+ }
241
+
242
+ async function current(args) {
243
+ const a = normalizeArgv(args);
244
+ assertKnownFlags(a, ["--help", "-h"]);
245
+ requirePositionalCount(a, [], { min: 0, max: 0, command: "run402 org current" });
246
+ try {
247
+ // `cmd: "org"` exempts this from the ambiguity error on purpose: the
248
+ // command that reports the selection must stay usable while it is ambiguous.
249
+ // `optional` keeps an empty selection an explicit null state rather than a
250
+ // failure — reporting is not acting.
251
+ const resolved = await resolveOrg([], { cmd: "org", optional: true });
252
+ console.log(JSON.stringify({
253
+ ...orgProvenance(resolved),
254
+ selected_org_id: getSelectedOrgId() ?? null,
255
+ }, null, 2));
256
+ } catch (err) {
257
+ reportSdkError(err);
258
+ }
259
+ }
260
+
201
261
  async function get(args) {
202
262
  const a = normalizeArgv(args);
203
263
  assertKnownFlags(a, ["--help", "-h"]);
@@ -471,6 +531,9 @@ export async function run(sub, args) {
471
531
  case "rename": await rename(args); break;
472
532
  case "payout-wallet": await payoutWallet(args); break;
473
533
  case "whoami": await whoami(args); break;
534
+ case "use": await use(args); break;
535
+ case "current": await current(args); break;
536
+ case "clear": await clear(args); break;
474
537
  case "audit": await audit(args); break;
475
538
  default:
476
539
  failUnknownSubcommand("org", sub);
package/lib/projects.mjs CHANGED
@@ -5,6 +5,7 @@ import { loadLiveControlPlaneSession } from "../core-dist/control-plane-session.
5
5
  import { withAutoApprove } from "./operator.mjs";
6
6
  import { getSdk } from "./sdk.mjs";
7
7
  import { reportSdkError, fail, parseFlagJson } from "./sdk-errors.mjs";
8
+ import { stampOrgFromProject } from "./org-context.mjs";
8
9
  import { assertKnownFlags, failBadProjectId, flagValue, hasHelp, normalizeArgv, positionalArgs, resolveProjectSelector, validateRegularFile, failUnknownSubcommand } from "./argparse.mjs";
9
10
 
10
11
  const HELP = `run402 projects — Manage your deployed Run402 projects
@@ -802,7 +803,11 @@ async function use(projectId) {
802
803
  }
803
804
  try {
804
805
  await getSdk().projects.use(projectId);
805
- console.log(JSON.stringify({ active_project_id: projectId, set: true }));
806
+ // A project determines its org unambiguously, so selecting one also makes
807
+ // that org current (add-cli-current-org D4). Best-effort by construction:
808
+ // the project selection the caller asked for must not fail on this.
809
+ const orgId = await stampOrgFromProject(projectId);
810
+ console.log(JSON.stringify({ active_project_id: projectId, set: true, org_id: orgId ?? null }));
806
811
  } catch (err) {
807
812
  reportSdkError(err);
808
813
  }
@@ -5,6 +5,10 @@
5
5
  * Room addressing (precedence):
6
6
  * --org <org_id> + --room <key> explicit (named org rooms)
7
7
  * RUN402_ROOM=<org_id>/<key> env form of the same
8
+ * <room key> + the shared chain a room key from --room or the binding
9
+ * file's `room`, with the ORG resolved by
10
+ * `org-context.mjs` — this is what reaches a
11
+ * named room in a checkout with no project
8
12
  * (default) the project's DEFAULT room — the room key
9
13
  * IS the project id; org resolved via the
10
14
  * project overview (`rooms.forProject`).
@@ -21,8 +25,11 @@
21
25
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
26
  import { join } from "node:path";
23
27
  import { fail } from "./sdk-errors.mjs";
24
- import { resolveProjectId } from "./config.mjs";
28
+ import { getActiveProjectId } from "./config.mjs";
25
29
  import { getSdk } from "./sdk.mjs";
30
+ import { resolveOrg } from "./org-context.mjs";
31
+ import { findBindingKey } from "./wallet-context.mjs";
32
+ import { nextAction } from "./next-actions.mjs";
26
33
 
27
34
  export const ROOM_ENV = "RUN402_ROOM";
28
35
  export const PRESENCE_ENV = "RUN402_PRESENCE_ID";
@@ -30,16 +37,22 @@ export const PRESENCE_ENV = "RUN402_PRESENCE_ID";
30
37
  const STATE_DIR = ".run402";
31
38
  const STATE_FILE = "messaging.json";
32
39
 
33
- /** Resolve the addressed room to { orgId, roomKey } (one SDK lookup at most). */
40
+ /**
41
+ * Resolve the addressed room to { orgId, roomKey } (one SDK lookup at most).
42
+ *
43
+ * A room is a PAIR, and this function owns the pair. What it no longer owns is
44
+ * the org half of its own fallback: below the explicit forms, the org comes
45
+ * from the shared chain in `org-context.mjs` (flag → env → binding → profile
46
+ * selection → active project) while the room key comes from `--room`, the
47
+ * binding file's `room` key, or the project's default room. That is what makes
48
+ * a named room reachable in a checkout with no project link at all.
49
+ */
34
50
  export async function resolveRoom({ org, room, project } = {}) {
35
- if (room && !org) {
36
- fail({
37
- code: "BAD_USAGE",
38
- message: "--room names an org room and needs --org <org_id> beside it.",
39
- hint: "For the project's default room, omit both (or pass --project). For a named room: --org <org_id> --room <key>.",
40
- });
41
- }
42
- if (org && room) return { orgId: org, roomKey: room };
51
+ // 1. Both halves named explicitly.
52
+ if (org && room) return { orgId: org, roomKey: room, orgSource: "flag", orgSourceDetail: "--org" };
53
+
54
+ // 2. The compound env form names both, and keeps outranking everything below
55
+ // it a harness wired with RUN402_ROOM selects exactly the room it names.
43
56
  const envRoom = (process.env[ROOM_ENV] ?? "").trim();
44
57
  if (envRoom) {
45
58
  const slash = envRoom.indexOf("/");
@@ -50,11 +63,61 @@ export async function resolveRoom({ org, room, project } = {}) {
50
63
  details: { value: envRoom },
51
64
  });
52
65
  }
53
- return { orgId: envRoom.slice(0, slash), roomKey: envRoom.slice(slash + 1) };
66
+ return {
67
+ orgId: envRoom.slice(0, slash),
68
+ roomKey: envRoom.slice(slash + 1),
69
+ orgSource: "env",
70
+ orgSourceDetail: ROOM_ENV,
71
+ };
72
+ }
73
+
74
+ // 3. A room key without an org: the org resolves through the shared chain.
75
+ // (`--room` alone used to be a hard error; it now works whenever the org
76
+ // resolves, and fails with ORG_REQUIRED — naming every way to supply one —
77
+ // when it does not.)
78
+ const bindingRoom = findBindingKey(process.cwd(), "room");
79
+ const roomKey = room ?? bindingRoom?.value ?? null;
80
+ if (roomKey) {
81
+ const resolved = await resolveOrg({ org, project }, { cmd: "rooms" });
82
+ return {
83
+ orgId: resolved.orgId,
84
+ roomKey,
85
+ orgSource: resolved.source,
86
+ orgSourceDetail: resolved.sourceDetail,
87
+ };
88
+ }
89
+
90
+ // 4. No room key anywhere: the project's DEFAULT room, whose key IS the
91
+ // project id — the zero-config rendezvous carried by run402.config.json.
92
+ const effectiveProject = project || (process.env.RUN402_PROJECT_ID ?? "").trim() || getActiveProjectId();
93
+ if (!effectiveProject) {
94
+ fail({
95
+ code: "ROOM_REQUIRED",
96
+ message: "No room addressed and no current project to take a default room from.",
97
+ hint: `Pass --org <org_id> --room <key>, set ${ROOM_ENV}="<org_id>/<room_key>", add a "room" key to .run402.json, or select a project with: run402 projects use <project_id>`,
98
+ next_actions: [
99
+ nextAction("edit_request", {
100
+ command: "run402 rooms who --org <org_id> --room <key>",
101
+ why: "Name the organization and room on this one call.",
102
+ }),
103
+ nextAction("edit_request", {
104
+ command: `echo '{"org":"<org_id>","room":"<key>"}' > .run402.json`,
105
+ why: "Bind this checkout so every agent in it lands in the same room with no flags.",
106
+ }),
107
+ nextAction("edit_request", {
108
+ command: "run402 projects use <project_id>",
109
+ why: "Select a project to use its default room (the room key IS the project id).",
110
+ }),
111
+ ],
112
+ });
54
113
  }
55
- const projectId = resolveProjectId(project);
56
- const scoped = await getSdk().rooms.forProject(projectId);
57
- return { orgId: scoped.orgId, roomKey: scoped.roomKey };
114
+ const scoped = await getSdk().rooms.forProject(effectiveProject);
115
+ return {
116
+ orgId: scoped.orgId,
117
+ roomKey: scoped.roomKey,
118
+ orgSource: "profile",
119
+ orgSourceDetail: "projects use",
120
+ };
58
121
  }
59
122
 
60
123
  function statePath() {
package/lib/rooms.mjs CHANGED
@@ -50,6 +50,12 @@ Room addressing (all subcommands):
50
50
  --org <org_id> --room <key>
51
51
  A named org room (multi-repo products); also
52
52
  RUN402_ROOM=<org_id>/<key>.
53
+ --room <key> A named org room whose ORG comes from the current-org
54
+ chain: --org / RUN402_ORG / the 'org' key of the nearest
55
+ .run402.json / 'run402 org use'. A checkout that hosts
56
+ nothing on run402 reaches a room this way.
57
+ .run402.json {"org":"<org_id>","room":"<key>"} binds a checkout, so
58
+ every agent in it lands in the same room with no flags.
53
59
 
54
60
  Subcommands:
55
61
  who Who is live in the room (name, task, active claims). Registers your
@@ -155,6 +161,8 @@ async function who(args) {
155
161
  });
156
162
  console.log(JSON.stringify({
157
163
  org_id: room.orgId,
164
+ org_source: room.orgSource ?? null,
165
+ org_source_detail: room.orgSourceDetail ?? null,
158
166
  room_key: room.roomKey,
159
167
  you: me,
160
168
  ...page,
@@ -69,14 +69,14 @@ export function splitWalletFlag(rawArgv = []) {
69
69
  return { argv, walletFlag: flag };
70
70
  }
71
71
 
72
- function readBindingFrom(dir) {
72
+ function readBindingKeyFrom(dir, key) {
73
73
  // .run402.local.json (gitignored personal override) beats .run402.json.
74
74
  for (const fname of [".run402.local.json", ".run402.json"]) {
75
75
  const p = join(dir, fname);
76
76
  try {
77
77
  const parsed = JSON.parse(readFileSync(p, "utf8"));
78
- const w = parsed?.wallet;
79
- if (typeof w === "string" && w.trim()) return { wallet: w.trim(), file: p };
78
+ const v = parsed?.[key];
79
+ if (typeof v === "string" && v.trim()) return { value: v.trim(), file: p };
80
80
  } catch {
81
81
  /* missing / unreadable / malformed → skip */
82
82
  }
@@ -84,18 +84,32 @@ function readBindingFrom(dir) {
84
84
  return null;
85
85
  }
86
86
 
87
- /** Nearest binding walking up from `startDir` to the filesystem root. */
88
- export function findBinding(startDir) {
87
+ /**
88
+ * Nearest binding carrying `key`, walking up from `startDir` to the root.
89
+ *
90
+ * Keys bind INDEPENDENTLY (add-cli-current-org, design D7): a file declaring
91
+ * only `org` leaves wallet resolution untouched, and each key resolves at the
92
+ * nearest file that carries it — so `/work/.run402.json` may supply the org
93
+ * while `/work/api/.run402.json` supplies the wallet. Unknown keys are ignored,
94
+ * which is what makes older CLIs forward-compatible with these files.
95
+ */
96
+ export function findBindingKey(startDir, key) {
89
97
  let dir = resolve(startDir);
90
98
  for (;;) {
91
- const b = readBindingFrom(dir);
92
- if (b) return b;
99
+ const hit = readBindingKeyFrom(dir, key);
100
+ if (hit) return hit;
93
101
  const parent = dirname(dir);
94
102
  if (parent === dir) return null;
95
103
  dir = parent;
96
104
  }
97
105
  }
98
106
 
107
+ /** Nearest wallet binding walking up from `startDir` to the filesystem root. */
108
+ export function findBinding(startDir) {
109
+ const hit = findBindingKey(startDir, "wallet");
110
+ return hit ? { wallet: hit.value, file: hit.file } : null;
111
+ }
112
+
99
113
  function assertValidName(name, origin) {
100
114
  if (name === DEFAULT || isValidProfileName(name)) return;
101
115
  fail({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "4.26.0",
3
+ "version": "4.27.0",
4
4
  "description": "CLI for Run402 — provision Postgres databases, deploy static sites, generate images, and manage wallets via x402 and MPP micropayments.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -126,6 +126,52 @@ export function clearActiveProjectId(projectId, path, scope = {}) {
126
126
  saveProfileState(state, p);
127
127
  });
128
128
  }
129
+ // ---------------------------------------------------------------------------
130
+ // Selected organization (add-cli-current-org, design D3).
131
+ //
132
+ // Scoped exactly like the active project id — by api_base, profile, and
133
+ // principal — and stored in the SAME per-profile state.json, never in the
134
+ // base-level config.json beside `active_wallet`. The chain is
135
+ // wallet -> principal -> memberships, so a globally-selected org survives
136
+ // `wallets use other` and then either 403s or, worse, silently resolves to a
137
+ // valid-but-wrong org when both principals are members. Per-profile makes the
138
+ // failure mode "this profile has no current org", which the ORG_REQUIRED
139
+ // envelope already knows how to explain.
140
+ //
141
+ // There is deliberately no top-level `active_org_id` mirror: the project one
142
+ // exists only to carry pre-scoping installs forward, and this key is new.
143
+ // ---------------------------------------------------------------------------
144
+ export function getActiveOrgId(path, scope = {}) {
145
+ const state = loadProfileState(path);
146
+ return state.active_orgs?.[activeProjectScopeKey(scope)]?.org_id;
147
+ }
148
+ export function setActiveOrgId(orgId, path, scope = {}) {
149
+ const p = path ?? getProfileStatePath();
150
+ withFileLock(p, () => {
151
+ const state = loadProfileState(p);
152
+ const key = activeProjectScopeKey(scope);
153
+ const resolved = defaultActiveProjectScope(scope);
154
+ state.active_orgs = state.active_orgs ?? {};
155
+ state.active_orgs[key] = {
156
+ org_id: orgId,
157
+ api_base: resolved.api_base,
158
+ principal: resolved.principal ?? null,
159
+ profile: resolved.profile,
160
+ updated_at: new Date().toISOString(),
161
+ };
162
+ saveProfileState(state, p);
163
+ });
164
+ }
165
+ export function clearActiveOrgId(path, scope = {}) {
166
+ const p = path ?? getProfileStatePath();
167
+ withFileLock(p, () => {
168
+ const state = loadProfileState(p);
169
+ const key = activeProjectScopeKey(scope);
170
+ if (state.active_orgs?.[key])
171
+ delete state.active_orgs[key];
172
+ saveProfileState(state, p);
173
+ });
174
+ }
129
175
  export function recordMigration(marker, value, path) {
130
176
  const p = path ?? getProfileStatePath();
131
177
  withFileLock(p, () => {