Compare commits

...
Author SHA1 Message Date
dev-driver bc36d9c810 wip(attention): a row parked on a person is resolved from the Feed in words, as the primary user; resolve accepts a park on a registered person's address
The Feed lists rows parked on a legacy human seat or a registered person's address; resolve accepted only the legacy shape, so a park on <id>@external showed and couldn't be resolved. The Feed now serves resolve on a parked row, answered with reply <your decision>.
2026-10-25 10:25:16 +00:00
dev-driver c36ed35a39 wip(tui): the Feed's queue command is whose, since v is select-text on an empty line; fixtures give asks an evidence ref 2026-10-25 10:22:36 +00:00
dev-driver b37906355d wip(attention): terminal acts carry the primary user's authority, recorded with the person asked; a registered person can't settle another's ask
TUI acts send authority primary-user. With no transport identity, Mission Control records the primary user (the default person; with several and none chosen, human@host; with no one registered, the claimed actor as before) as the actor, the person asked in the audit notes and the transition, and the asker's reply says who answered on whose ask. Without that authority, answer, approve, deny and resolve refuse a registered person settling another registered person's ask, pending or parked. Guidance in docs/reference/gateway-participants.md.
2026-10-25 10:18:52 +00:00
dev-driver ade2430a77 wip(tui): the Feed's view, from the participants default; view command, header, and opening on a chosen default's Feed
The primary user is #1992's default person (resolveDefaultPerson). /api/attention's perspective says where the default came from (defaultSource). The TUI reads the Feed with view=default unless a view is chosen (view <person|all|default>), names whose queue it shows and who acts, and opens on the Feed at startup only when the participants file chose a default and the person hasn't moved yet.
2026-10-25 10:11:47 +00:00
dev-driver f6ba7f46d2 Merge branch 'feat/participants-foundation-073' of https://github.com/mvschwarz/openrig into feat/queue-perspective-073 2026-10-25 10:07:54 +00:00
review50-r3andClaude Opus 5.5 d0e19d062c fix(gateway): a person can't take a shared channel's id; upgrade note for several people
People and shared channels share one address space (<id>@external). Adding a
channel already refused a person's id; adding a person now refuses a shared
channel's id too, in the registry's own add path and before any write, so the
participant file, its default and the channel stay intact. Before, the add
succeeded and the participant file stopped reading: the chosen default, every
placement and policy were lost, and every repair refused.

`human remove` now deletes only a person's entry from the participant file, and
`channel remove` only a channel's, so neither can remove the other kind.

The upgrade note now covers installs that already had several people: choose a
default with `rig gateway default <id>`; until then escalations report
undeliverable:no-default-person and the notice digest waits.

The ordering tests now expect the list sorted by id, people and channels
together, and pick the person by id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:58:11 +00:00
review50-r3 89c2bef395 Merge remote-tracking branch 'origin/main' into feat/participants-foundation-073 2026-10-25 09:54:46 +00:00
dev-driver ac921bbd28 wip(attention): the Feed's view-only perspective (whose queue it shows; acts keep the primary user's authority)
Not run yet: Build's disk hold. Tests in attention-perspective.test.ts.
2026-10-25 09:35:37 +00:00
review50-r3andClaude Opus 5.5 abe5b5689d feat(gateway): several people, shared channels, organizations, groups, readable policy and a default person
The gateway can now register several people and shared-channel participants
(one vetted Slack channel is one participant at <id>@external), group them into
organizations and groups, give each readable policy text, and name a default
person.

Storage keeps rollback safe. Person fragments keep the closed key set a v0.7.0
daemon reads, so an older daemon still loads every person. Everything new lives
in gateway/participants.yaml, which v0.7.0 and v0.7.1 never read. A broken side
file never disables the person registry; a person removed by hand leaves a
warning, not a broken file.

Policy is text agents read, shown organization first, then each group, then the
participant's own, each labelled with its source. Nothing enforces it or derives
it from names.

The default person serves needs that name nobody: escalations, the notice
digest, Mission Control's my-queue, the workflow fallback and pairing approval.
It's the chosen person, else the only one, else none with the reason, never the
first file by id. workspace.operator_seat_name keeps its existing meaning.

Verbs: `human add` accepts several people and takes --organization, --group,
--policy/--policy-file and --default; `human show` adds the placement; `human
set` takes organization, groups and policy; `human remove` also clears the
person's placement. New: `channel add|remove`, `org set`, `group set`,
`participants` and `default`.

A second registered person's Slack reply, typed or clicked, never answers,
closes or resumes another person's pending or parked ask. Main already guarded
this at two independent places; the new test pins both.

docs/reference/gateway-participants.md covers the commands, the default, upgrade
(nothing to do) and what a rollback to 0.7.1 or 0.7.0 changes. The
messaging-the-human skill points agents at `rig gateway participants`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:24:02 +00:00
39 changed files with 1869 additions and 136 deletions

No files matched your search

+17 -1
View File
@@ -188,7 +188,7 @@ the sections that were not read are `null`.
|---|---|---|
| `rig gateway` | — | — |
| `rig gateway human` | — | — |
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>` |
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
| `rig gateway human list` | — | `--json` |
| `rig gateway human quiet` | — | — |
| `rig gateway human quiet set <entityId>` | — | `--daily <window>`<br>`--until <time>`<br>`--tz <zone>`<br>`--actor <actor>`<br>`--json` |
@@ -208,7 +208,23 @@ the sections that were not read are `null`.
| `rig gateway human show <entityId>` | — | `--json` |
| `rig gateway human set <entityId> <field> <value>` | — | `--reason <reason>`<br>`--actor <actor>` |
| `rig gateway human remove <entityId>` | — | `--force` |
| `rig gateway channel` | — | — |
| `rig gateway channel add <id>` | — | `--display-name <name>` **required**<br>`--binding <slack:connectorRef:secretsRef:channelId>` **required**<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--replace` |
| `rig gateway channel remove <id>` | — | `--force` |
| `rig gateway org` | — | — |
| `rig gateway org set <id>` | — | `--name <name>`<br>`--policy <text>`<br>`--policy-file <path>` |
| `rig gateway group` | — | — |
| `rig gateway group set <id>` | — | `--organization <id>`<br>`--name <name>`<br>`--policy <text>`<br>`--policy-file <path>` |
| `rig gateway participants` | — | `--organization <id>`<br>`--json` |
| `rig gateway default [entityId]` | — | `--clear` |
- Several people may be registered. Organizations, groups, readable policy, shared channels and the default person are
kept in `$OPENRIG_HOME/gateway/participants.yaml`, never in a person's fragment, so an older daemon still loads every
person after a rollback. `human set <entityId> organization|groups|policy <value>` writes there (`unset` clears;
`@<path>` reads a policy file). `participants --json` carries each participant's `kind`, `address`, `organization`,
`groups`, `default` and `policy` (a list of `{ source, text }`, organization first), plus `default`, `organizations`,
`groups`, `warnings` and `participantFile`. `default` with no argument prints the current default and its source
(`chosen` or `only-person`), or `null` with the reason. See `docs/reference/gateway-participants.md`.
- `human set <entityId> slack-subscription <value>` — sets the person's Slack subscription: `every-ask` (the default) posts every ask to them to Slack; `urgent-only` posts only asks created with `--alert`, and other asks wait on Steering and the TUI; `unset` removes the authored subscription so the default applies. `human show` prints the value with its origin, `authored` or `default` (`slack-subscription: every-ask (default)`); `--json` carries it as `record.slackSubscription` `{ value, source }`.
### parked
+113
View File
@@ -0,0 +1,113 @@
# Gateway participants: several people, shared channels, organizations and policy
OpenRig's gateway can register **several people** and **shared channels**, group them into **organizations** and
**groups**, give each readable **policy** text, and name a **default person**. Agents read the policy and apply it;
OpenRig doesn't enforce it. This page covers the commands, what the default is for, and what upgrading and rolling back
do.
## Participants
A participant is either:
- **a person**, registered with `rig gateway human add` as before, with their own Slack binding; or
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
sender.
People and channels share one address space: a channel can't take a person's id.
```bash
# An organization and a group inside it (one command each, create or update)
rig gateway org set acme --name "Acme" --policy "Answer Acme's customers within a working day. Never promise a date."
rig gateway group set support --organization acme --name "Support" --policy-file ./support-policy.md
# People: the second and later ones are allowed now
rig gateway human add sarah --display-name "Sarah" \
--binding "slack:main:file:/etc/openrig/slack.env:primary:handle=U0SARAH" --delivery-class B \
--organization acme --group support --policy "Sarah approves refunds up to 500." --default
# A shared channel
rig gateway channel add acme-customers --display-name "Acme customers" \
--binding "slack:main:file:/etc/openrig/slack.env:C0ACME" --organization acme
# See everyone, with organization, groups, the default and each participant's policy
rig gateway participants
rig gateway participants --organization acme --json
rig gateway human show sarah
```
Change a person's placement with `rig gateway human set <id> organization|groups|policy <value>` (`unset` clears it;
for policy, `@<path>` reads a file). Remove a channel with `rig gateway channel remove <id>`, which refuses while
non-terminal rows are addressed to it unless you pass `--force`.
## Policy is context, not enforcement
A participant's **effective policy** is its organization's text, then each of its groups', then its own, each labelled
with where it came from. Agents read it (`rig gateway participants --json`, `rig gateway human show --json`) and apply
it with judgment. It doesn't grant or block anything by itself, and a participant's name never implies a policy.
## The default person
Some things need a person without naming one: an escalation of stuck work, the digest of notices, Mission Control's
my-queue, a workflow's human step when no person is configured for it, and a pairing approval without `--human`. They
go to:
1. the default person, when one is chosen (`rig gateway default <id>`, or `--default` on `human add`);
2. otherwise the only registered person, so a one-person install needs no choice;
3. otherwise nobody, and each says so: an escalation reports `undeliverable:no-default-person`, the notice digest
waits, and the others name the command to run.
`rig gateway default` shows the current default; `rig gateway default --clear` clears the choice. Choosing a default
never changes `workspace.operator_seat_name`, which keeps its own meaning.
## In the TUI: whose queue, and who acts
The person at the terminal is the **primary user**: the default person, or, with several people and none chosen, the
person at the terminal unnamed. Whatever the TUI shows, its acts are theirs.
- **Opening:** with a chosen default, the TUI opens on that person's Feed. With none chosen, including a one-person
install that never chose one, it opens on the topology, as before.
- **Whose queue:** `whose <person>` shows one person's asks, `whose all` everyone's, and `whose default` the default
person's. The Feed's first line says whose queue it shows and who acts.
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the primary user as the actor,
and the person asked in the audit notes and the transition. The asking seat reads, for example, "Answered by
mike@external, the primary user at the terminal, on sarah@external's ask". With several people and none chosen, the
actor is `human@host`.
- **Viewing Sarah isn't acting as Sarah.** Only Sarah, from her own channel, answers as Sarah. Without the primary
user's authority, one registered person can't answer, approve, deny or resolve what was asked of another; that's
refused (`answer_not_recorded`) and nothing changes.
- With no one registered, the TUI and its acts are as before.
## Where it's stored
- **People** stay exactly as before: one file per person under `$OPENRIG_HOME/gateway/humans/`, plus the generated
`humans.generated.yaml`. Nothing new is written into them.
- **Everything else** (the default, organizations, groups, each participant's placement and policy, and shared
channels) is in `$OPENRIG_HOME/gateway/participants.yaml`, written by the commands above.
- **A problem in `participants.yaml` never disables the person registry.** People still receive and answer their asks.
The commands above report the problem, and with several people no default is chosen until it's fixed.
- **A person removed by hand** leaves a warning in `rig gateway participants`, not a broken file. `rig gateway human
remove` also removes the person's placement, and the default if it was them.
## Upgrading
For a one-person install, nothing to do: it keeps working as before, that person stays the default, pending rows keep
their addresses, and no new file is written until you use one of the commands above.
A TUI older than the daemon still works: it sends the person asked as the actor, which is still their own act.
**If several people are already registered** (earlier versions listed hand-added people with an advisory), choose a
default with `rig gateway default <id>` after upgrading. Until you do, escalations report
`undeliverable:no-default-person` instead of reaching whoever's id sorted first, and the notice digest waits.
## Rolling back to 0.7.1 or 0.7.0
Older versions don't read `participants.yaml`, and the person files carry nothing they don't understand, so:
- **every registered person still loads**, and each can still answer their own asks from Slack;
- **another person's reply is guarded less:** on an older version, a different person's Slack reply in the thread of a
question parked on someone else can resume that question, and 0.7.0 can record such a reply to a pending ask as an
answer row (it still can't close the ask). Those protections are newer than 0.7.1;
- **there's no default:** escalations and the notice digest go to the first person by id, Mission Control's my-queue
has no default person, and pairing needs `--human`;
- **the TUI** opens on the topology and records a terminal answer as the person asked, as before. Rows already
answered by the primary user keep their notes;
- **shared channels are unknown:** rows addressed to one become undeliverable on the older version;
- **organizations, groups and policy aren't shown.**
Upgrading again restores all of it, because the older version never changes `participants.yaml`.
+219 -28
View File
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { Command } from "commander";
import { resolveSenderSession, SENDER_FALLBACK } from "../sender-identity.js";
// The human registry is home-state owned by the daemon; the verb LAZY-imports the narrow
@@ -182,32 +183,30 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
.option("--replace", "Explicitly replace an existing human (no silent overwrite)")
.option("--reason <reason>", "Reason recorded with connector binding changes", "register human delivery")
.option("--actor <actor>", "Named operator when outside a managed seat")
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string }) => {
.option("--organization <id>", "The person's organization (create it first with rig gateway org set)")
.option("--group <id>", "A group the person belongs to (repeatable; create it first with rig gateway group set)", (v: string, acc: string[] = []) => { acc.push(v); return acc; })
.option("--policy <text>", "Readable policy for agents working with this person (text, not enforced)")
.option("--policy-file <path>", "Read the policy text from a file")
.option("--default", "Make this person the default: the one escalations, the notice digest and other unaddressed needs go to")
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => {
// LAZY import the narrow daemon surface at invocation (dep rail 2).
const registry = await import("@openrig/daemon/gateway-human-registry");
const { addHumanFragment, parseBindingSpec } = registry as unknown as {
addHumanFragment: typeof AddHumanFragment;
parseBindingSpec: (spec: string) => { ok: true; binding: Record<string, unknown> } | { ok: false; error: string };
};
// Fix-r1 F1 (R2 blocking, A1/R5): the add VERB is the single-human boundary. A second
// DISTINCT human is refused BEFORE any parse or write — the several-fragment state A1
// describes as hand-authored must never be produced by the product's own surface.
// Fail closed: an unverifiable registry refuses too (never add into an unknown state).
const existing = registry.listHumans();
if (!existing.ok) {
console.error(`refused: cannot verify the single-human boundary — ${existing.error}`);
process.exitCode = 1;
return;
}
if (existing.humans.length > 0 && !existing.humans.some((h) => h.entityId === entityId)) {
const ids = existing.humans.map((h) => h.entityId).join(", ");
console.error(
`refused: a human is already configured (${ids}). OpenRig supports one configured human, so \`rig gateway human add\` manages one; ` +
`to register someone else, remove the existing one first (\`rig gateway human remove <entityId>\`). Fragments hand-authored under ${registry.humansDir()} ` +
`are displayed as found, with an advisory, but no command manages several.`,
);
process.exitCode = 1;
return;
// Several people may be registered. Their organization, groups, policy and the default go in the participant side
// file, never the fragment, so an older daemon still loads every person after a rollback.
const policy = readPolicyOption(opts);
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
const placed = opts.organization !== undefined || opts.group !== undefined || policy.value !== undefined;
if (placed) {
// Check the organization and groups exist before writing anything, so a typo doesn't leave a half-placed person.
const side = registry.readParticipantsFile();
if (!side.ok) { console.error(`refused: ${side.error}`); process.exitCode = 1; return; }
if (opts.organization && !side.file.organizations[opts.organization]) { console.error(`refused: no organization "${opts.organization}" — create it with rig gateway org set ${opts.organization} --name …`); process.exitCode = 1; return; }
const unknownGroup = (opts.group ?? []).find((group) => !side.file.groups[group]);
if (unknownGroup) { console.error(`refused: no group "${unknownGroup}" — create it with rig gateway group set ${unknownGroup} --organization … --name …`); process.exitCode = 1; return; }
}
const bindings: Record<string, unknown>[] = [];
for (const spec of opts.binding) {
@@ -236,7 +235,19 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
return { value: res, after: { digest }, effect: before.ok && registry.channelStateDigest(before.record.connectorBindings.map(({ inboundResolvable: _derived, ...binding }) => binding)) === digest ? "no-op" : "applied" };
},
});
console.log(JSON.stringify({ ok: true, entityId, path: result.value.path, receipt: result.receipt }));
// The person is registered; their placement and the default are a second, separate write.
const followUps: Record<string, unknown> = {};
if (placed) {
const res = registry.setPersonPlacement(entityId, { organization: opts.organization, groups: opts.group, policy: policy.value });
followUps.placement = res.ok ? { path: res.path } : { error: res.error };
}
if (opts.default) {
const res = registry.setDefaultPerson(entityId);
followUps.default = res.ok ? { path: res.path } : { error: res.error };
}
const failed = Object.values(followUps).some((value) => "error" in (value as object));
console.log(JSON.stringify({ ok: !failed, entityId, path: result.value.path, receipt: result.receipt, ...followUps }));
if (failed) { console.error(`the person was registered, but part of their placement wasn't saved; fix it with rig gateway human set ${entityId} … or rig gateway default ${entityId}`); process.exitCode = 1; }
} catch (error) { console.error(`refused: ${(error as Error).message}`); process.exitCode = 1; }
})
.addHelpText("after", `
@@ -259,7 +270,7 @@ Example:
human
.command("list")
.description("Show the configured human (OpenRig supports one; extra hand-authored fragments are listed with an advisory)")
.description("List the registered people, with delivery readiness and quiet hours (rig gateway participants adds organizations, groups, policy and shared channels)")
.option("--json", "Complete record(s) as JSON")
.action(async (opts: { json?: boolean }) => {
const { listHumans } = await import("@openrig/daemon/gateway-human-registry");
@@ -270,7 +281,7 @@ Example:
return { ...record, deliveryReadiness: await humanReadiness(record.entityId),
quiet: quiet.ok ? quiet.quiet : { unknown: `quiet hours could not be read from the daemon: ${quiet.reason}` } };
}));
if (opts.json) { console.log(JSON.stringify({ ok: true, humans, ...(res.advisory ? { advisory: res.advisory } : {}) })); return; }
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); return; }
if (res.humans.length === 0) { console.log("no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"); return; }
for (const h of humans) {
const inbound = h.bindings.inboundResolvable ? "" : " [outbound-only]";
@@ -278,7 +289,6 @@ Example:
if (!h.deliveryReadiness.ready) console.log(` delivery: ${h.deliveryReadiness.reason}${h.deliveryReadiness.nextAction ? `; next: ${h.deliveryReadiness.nextAction}` : ""}`);
console.log(` ${"unknown" in h.quiet ? `quiet: unknown (${h.quiet.unknown})` : describeQuiet(h.quiet)}`);
}
if (res.advisory) console.log(`advisory: ${res.advisory}`);
});
const quiet = human.command("quiet").description("Quiet hours: while on, routine asks to this person are held, not posted; urgent ones (tagged escalation) still reach them. Held asks post once, each on its own, when quiet ends");
@@ -520,13 +530,22 @@ Examples:
.description("Show the effective record: fragment values + which defaults filled the rest, with provenance")
.option("--json", "Complete record as JSON")
.action(async (entityId: string, opts: { json?: boolean }) => {
const { showHuman } = await import("@openrig/daemon/gateway-human-registry");
const { showHuman, participantsView } = await import("@openrig/daemon/gateway-human-registry");
const res = showHuman(entityId);
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
const r = { ...res.record, deliveryReadiness: await humanReadiness(entityId) };
// The person's organization, groups, policy and whether they're the default come from the participant side file.
const view = participantsView();
const placement = view.ok ? view.participants.find((p) => p.id === entityId) : undefined;
const participant = placement
? { organization: placement.organization, groups: placement.groups, default: placement.default, policy: placement.policy }
: { unavailable: view.ok ? "not found" : view.error };
const sideFileError = view.ok ? view.sideFile.error : undefined;
const r = { ...res.record, participant, ...(sideFileError ? { participantFileError: sideFileError } : {}), deliveryReadiness: await humanReadiness(entityId) };
if (opts.json) { console.log(JSON.stringify({ ok: true, record: r })); return; }
console.log(`${r.entityId} (${r.address}) — "${r.displayName}"`);
console.log(` fragment: ${r.fragmentPath}`);
if (placement) for (const line of describePlacement(placement)) console.log(line);
if (sideFileError) console.log(` participant file: ${sideFileError}`);
console.log(` delivery-class: ${r.prefs.deliveryClass.value} (${r.prefs.deliveryClass.source})`);
console.log(` away: ${r.prefs.away.value} (${r.prefs.away.source})`);
console.log(` slack-subscription: ${r.slackSubscription.value} (${r.slackSubscription.source})`);
@@ -539,11 +558,26 @@ Examples:
human
.command("set <entityId> <field> <value>")
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), binding.<n>")
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), binding.<n>; and, kept in the participant side file: organization (an id, or unset), groups (comma-separated ids, or unset), policy (text, @<path> to read a file, or unset)")
.option("--reason <reason>", "Reason recorded with connector binding changes", "update human binding")
.option("--actor <actor>", "Named operator when outside a managed seat")
.action(async (entityId: string, field: string, value: string, opts: { reason: string; actor?: string }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
if (field === "organization" || field === "groups" || field === "policy") {
const clear = value === "unset";
let input: Record<string, unknown>;
if (field === "organization") input = { organization: clear ? null : value };
else if (field === "groups") input = { groups: clear ? null : value.split(",").map((g) => g.trim()).filter(Boolean) };
else {
const policy = clear ? { ok: true as const, value: null } : value.startsWith("@") ? readPolicyOption({ policyFile: value.slice(1) }) : { ok: true as const, value };
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
input = { policy: policy.value };
}
const res = registry.setPersonPlacement(entityId, input);
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify({ ok: true, entityId, field, path: res.path }));
return;
}
if (field.startsWith("binding.")) {
const before = registry.showHuman(entityId);
if (!before.ok) { console.error(`refused: ${before.error}`); process.exitCode = 1; return; }
@@ -600,7 +634,141 @@ Examples:
];
const res = removeHumanFragment(entityId, { force: !!opts.force, inflight });
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify({ ok: true, removed: res.removed, archivedPath: res.archivedPath, ...(res.orphanRecordPath ? { orphanRecordPath: res.orphanRecordPath } : {}) }));
// Their placement and, if it was them, the default go too; a later read would ignore them anyway, with a warning.
const { removeParticipantEntry } = await import("@openrig/daemon/gateway-human-registry");
const side = removeParticipantEntry(entityId, undefined, { kind: "person" });
console.log(JSON.stringify({ ok: true, removed: res.removed, archivedPath: res.archivedPath, ...(res.orphanRecordPath ? { orphanRecordPath: res.orphanRecordPath } : {}),
participantFile: side.ok ? { path: side.path } : { error: side.error } }));
});
// ── Participants beyond people (0.7.3): shared channels, organizations, groups, readable policy and the default. All of
// it lives in gateway/participants.yaml, which older daemons don't read, so a rollback still loads every person.
const channel = cmd.command("channel").description("Shared-channel participants: one vetted Slack channel is one participant (<id>@external), not a person per sender");
channel
.command("add <id>")
.description("Register a shared channel")
.requiredOption("--display-name <name>", "Human-readable name, e.g. \"Acme customers\"")
.requiredOption("--binding <slack:connectorRef:secretsRef:channelId>", "The channel: connector kind (slack), connector label, vault pointer for its secrets (never the secret), and the Slack channel id")
.option("--organization <id>", "Its organization (create it first with rig gateway org set)")
.option("--group <id>", "A group it belongs to (repeatable)", (v: string, acc: string[] = []) => { acc.push(v); return acc; })
.option("--policy <text>", "Readable policy for agents answering this channel (text, not enforced)")
.option("--policy-file <path>", "Read the policy text from a file")
.option("--replace", "Explicitly replace an existing channel (no silent overwrite)")
.action(async (id: string, opts: { displayName: string; binding: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; replace?: boolean }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
const policy = readPolicyOption(opts);
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
const parts = opts.binding.split(":");
if (parts.length < 4 || parts[0] !== "slack") { console.error("refused: --binding must be slack:<connectorRef>:<secretsRef>:<channelId>"); process.exitCode = 1; return; }
const res = registry.setSharedChannel(id, {
displayName: opts.displayName,
channel: { connector: "slack", connectorRef: parts[1]!, secretsRef: parts.slice(2, -1).join(":"), channelId: parts[parts.length - 1]! },
organization: opts.organization, groups: opts.group, policy: policy.value,
}, { replace: !!opts.replace });
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify({ ok: true, id, address: `${id}@${registry.ADDRESS_DOMAIN}`, path: res.path }));
});
channel
.command("remove <id>")
.description("Remove a shared channel: refuses while non-terminal rows are addressed to it (--force removes anyway)")
.option("--force", "Remove despite KNOWN non-terminal rows")
.action(async (id: string, opts: { force?: boolean }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
const side = registry.readParticipantsFile();
if (!side.ok) { console.error(`refused: ${side.error}`); process.exitCode = 1; return; }
if (side.file.participants[id]?.kind !== "shared-channel") { console.error(`refused: no shared channel "${id}"`); process.exitCode = 1; return; }
const address = `${id}@${registry.ADDRESS_DOMAIN}`;
const rowsRes = await queueRows(address);
if (!rowsRes.ok) { console.error(`refused: the queue could not be checked for rows addressed to ${address} (${rowsRes.error}); --force doesn't override an unchecked board`); process.exitCode = 1; return; }
if (rowsRes.rows.length && !opts.force) {
console.error(`refused: ${rowsRes.rows.length} non-terminal row(s) are addressed to ${address} (${rowsRes.rows.map((r) => r.id).join(", ")}); pass --force to remove anyway`);
process.exitCode = 1;
return;
}
const res = registry.removeParticipantEntry(id, undefined, { kind: "shared-channel" });
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify({ ok: true, removed: id, path: res.path, ...(rowsRes.rows.length ? { orphanedRows: rowsRes.rows.map((r) => r.id) } : {}) }));
});
const org = cmd.command("org").description("Organizations: the outer layer of participants, with readable policy agents apply");
org
.command("set <id>")
.description("Create or update an organization")
.option("--name <name>", "Display name (required when new)")
.option("--policy <text>", "Readable policy for agents working with this organization (text, not enforced; \"\" clears it)")
.option("--policy-file <path>", "Read the policy text from a file")
.action(async (id: string, opts: { name?: string; policy?: string; policyFile?: string }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
const policy = readPolicyOption(opts);
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
const res = registry.setOrganization(id, { name: opts.name, ...(policy.value !== undefined ? { policy: policy.value || null } : {}) });
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify({ ok: true, organization: id, path: res.path }));
});
const group = cmd.command("group").description("Groups inside an organization, with readable policy agents apply");
group
.command("set <id>")
.description("Create or update a group")
.option("--organization <id>", "Its organization (required when new)")
.option("--name <name>", "Display name (required when new)")
.option("--policy <text>", "Readable policy for agents working with this group (text, not enforced; \"\" clears it)")
.option("--policy-file <path>", "Read the policy text from a file")
.action(async (id: string, opts: { organization?: string; name?: string; policy?: string; policyFile?: string }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
const policy = readPolicyOption(opts);
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
const res = registry.setGroup(id, { organization: opts.organization, name: opts.name, ...(policy.value !== undefined ? { policy: policy.value || null } : {}) });
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify({ ok: true, group: id, path: res.path }));
});
cmd
.command("participants")
.description("List people and shared channels, with organization, groups, policy and the default")
.option("--organization <id>", "Only this organization's participants")
.option("--json", "Complete records as JSON, including organizations and groups")
.action(async (opts: { organization?: string; json?: boolean }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
const view = registry.participantsView();
if (!view.ok) { console.error(`refused: ${view.error}`); process.exitCode = 1; return; }
const shown = opts.organization ? view.participants.filter((p) => p.organization?.id === opts.organization) : view.participants;
const defaultPerson = view.default.person ? { id: view.default.person.entityId, source: view.default.source } : { id: null, reason: view.default.source === "none" ? view.default.reason : null };
if (opts.json) {
const side = registry.readParticipantsFile();
console.log(JSON.stringify({ ok: true, participants: shown, default: defaultPerson,
organizations: side.ok ? side.file.organizations : null, groups: side.ok ? side.file.groups : null,
warnings: view.warnings, participantFile: view.sideFile }));
return;
}
if (!shown.length) console.log(opts.organization ? `no participants in organization "${opts.organization}"` : "no participants yet: rig gateway human add … registers a person, rig gateway channel add … a shared channel");
for (const p of shown) {
console.log(`${p.id} ${p.kind} "${p.displayName}" ${p.address}${p.channel ? ` channel ${p.channel.channelId}` : ""}`);
for (const line of describePlacement(p)) console.log(line);
}
console.log(defaultPerson.id ? `default: ${defaultPerson.id} (${defaultPerson.source === "chosen" ? "chosen" : "the only person"})` : `default: none — ${defaultPerson.reason}`);
for (const warning of view.warnings) console.log(`warning: ${warning}`);
if (view.sideFile.error) console.log(`participant file: ${view.sideFile.error}`);
});
cmd
.command("default [entityId]")
.description("Show or choose the default person: the one escalations, the notice digest, Mission Control's my-queue and pairing go to when no person is named")
.option("--clear", "Clear the chosen default (with one person, they stay the default)")
.action(async (entityId: string | undefined, opts: { clear?: boolean }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
if (entityId || opts.clear) {
if (entityId && opts.clear) { console.error("refused: pass an id or --clear, not both"); process.exitCode = 1; return; }
const res = registry.setDefaultPerson(opts.clear ? null : entityId!);
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
}
const people = registry.projectHumans();
if (!people.ok) { console.error(`refused: ${people.error}`); process.exitCode = 1; return; }
const chosen = registry.resolveDefaultPerson(people.entities);
console.log(JSON.stringify(chosen.person
? { ok: true, default: chosen.person.entityId, address: chosen.person.address, source: chosen.source }
: { ok: true, default: null, reason: chosen.source === "none" ? chosen.reason : null }));
});
return cmd;
@@ -623,3 +791,26 @@ function describeApplication(a: Record<string, any>): string {
const effects = (a.effects as Array<Record<string, any>>).map((e) => ` ${e.consumer}: ${e.state}${e.detail ? ` (${e.detail})` : ""}`);
return [head, ...(effects.length ? effects : [" no effects"])].join("\n");
}
/** --policy or --policy-file (never both): the readable policy text agents read. Absent is undefined. */
function readPolicyOption(opts: { policy?: string; policyFile?: string }): { ok: true; value: string | undefined } | { ok: false; error: string } {
if (opts.policy !== undefined && opts.policyFile !== undefined) return { ok: false, error: "pass --policy or --policy-file, not both" };
if (opts.policyFile === undefined) return { ok: true, value: opts.policy };
try {
return { ok: true, value: readFileSync(opts.policyFile, "utf8") };
} catch (error) {
return { ok: false, error: `--policy-file: ${(error as Error).message}` };
}
}
/** One participant's organization, groups, default and policy, in plain lines. */
function describePlacement(p: { organization: { id: string; name: string } | null; groups: Array<{ id: string; name: string }>; default: boolean; policy: Array<{ source: string; text: string }> }): string[] {
const lines = [
` organization: ${p.organization ? `${p.organization.id} (${p.organization.name})` : "none"}`,
` groups: ${p.groups.length ? p.groups.map((g) => `${g.id} (${g.name})`).join(", ") : "none"}`,
];
if (p.default) lines.push(" default: yes (escalations, the notice digest and other unaddressed needs go to this person)");
if (!p.policy.length) lines.push(" policy: none");
for (const part of p.policy) lines.push(` policy, from ${part.source}:`, ...part.text.trimEnd().split("\n").map((line) => ` ${line}`));
return lines;
}
@@ -7,6 +7,7 @@ import { readdirSync, readFileSync } from "node:fs";
import { createProgram } from "../src/index.js";
import { gatewayCommand, daemonQueueRows, type HumanRowsLookup } from "../src/commands/gateway.js";
import { humansDir, addHumanFragment, loadHumanRegistry } from "@openrig/daemon/gateway-human-registry";
import { parse as parseYaml } from "yaml";
// OPR.0.5.5.12 — `rig gateway human list|show|set|remove` verb wiring. Same harness as the
// add-verb test: temp OPENRIG_HOME, real command end-to-end, fs effects asserted at source.
@@ -25,8 +26,7 @@ async function runAdd(name: string, extra: string[] = []): Promise<void> {
]);
}
/** The hand-authoring path (registry surface, not the verb) — how a second fragment
* legitimately comes to exist under A1. */
/** A second person through the registry surface, without the verb. */
function seedSecondHuman(name: string): void {
const res = addHumanFragment({
entityId: name,
@@ -188,9 +188,7 @@ describe("rig gateway human lifecycle verbs (S12)", () => {
}
});
it("A1 advisory receipt: with several hand-authored fragments list --json renders all + the one-human advisory", async () => {
// Fix-r1 F1: the SECOND human arrives by hand-authoring (the registry surface), never
// through the add verb — the verb is the single-human boundary.
it("with several people, list --json renders all of them, with no single-person advisory", async () => {
seedSecondHuman("ana");
logSpy.mockClear();
const p = program();
@@ -198,8 +196,8 @@ describe("rig gateway human lifecycle verbs (S12)", () => {
await p.parseAsync(["node", "rig", "gateway", "human", "list", "--json"]);
const out = JSON.parse(logSpy.mock.calls.at(-1)![0] as string) as { ok: boolean; humans: unknown[]; advisory?: string };
expect(out.ok).toBe(true);
expect(out.humans).toHaveLength(2); // honest display
expect(out.advisory).toContain("one configured human"); // never a management surface
expect(out.humans).toHaveLength(2);
expect(out).not.toHaveProperty("advisory");
});
it("show --json carries authored-vs-default provenance and the fragment path", async () => {
@@ -261,28 +259,41 @@ describe("rig gateway human lifecycle verbs (S12)", () => {
expect(existsSync(join(humansDir(home), "mike.yaml"))).toBe(true);
});
// ── fix-r1 F1: the add verb IS the single-human boundary ──
// ── 0.7.3 participants: several people, organizations, groups, policy, shared channels and the default ──
it("F1: a second DISTINCT add REFUSES with teaching (existing human named, remove and hand-authoring pointed at) and writes ZERO fragment bytes", async () => {
const dirBefore = readdirSync(humansDir(home)).sort();
it("registers a second person in an organization and group as the default, and a shared channel; fragments keep v0.7.0's keys", async () => {
const mikeBytes = readFileSync(join(humansDir(home), "mike.yaml"), "utf8");
const run = async (...args: string[]) => { const p = program(); p.exitOverride(); await p.parseAsync(["node", "rig", "gateway", ...args]); };
await run("org", "set", "acme", "--name", "Acme", "--policy", "Answer within a working day.");
await run("group", "set", "support", "--organization", "acme", "--name", "Support");
await run("human", "add", "ana", "--display-name", "Ana", "--binding", "slack:main:vault://slack/ana:primary",
"--delivery-class", "A", "--organization", "acme", "--group", "support", "--policy", "Ana approves refunds.", "--default");
await run("channel", "add", "acme-customers", "--display-name", "Acme customers", "--binding", "slack:main:vault://slack/bot:C0ACME",
"--organization", "acme");
expect(process.exitCode).toBeUndefined();
logSpy.mockClear();
await run("participants", "--json");
const out = JSON.parse(logSpy.mock.calls.at(-1)![0] as string) as { participants: Array<Record<string, any>>; default: { id: string } };
// Sorted by id, people and channels together.
expect(out.participants.map((p) => [p.id, p.kind])).toEqual([["acme-customers", "shared-channel"], ["ana", "person"], ["mike", "person"]]);
expect(out.default).toMatchObject({ id: "ana", source: "chosen" });
const ana = out.participants.find((p) => p.id === "ana")!;
expect(ana.policy.map((part: { source: string }) => part.source)).toEqual(["organization acme (Acme)", "participant"]);
// Nothing new went into a person's fragment: mike's is byte-identical, ana's carries v0.7.0's keys only.
expect(readFileSync(join(humansDir(home), "mike.yaml"), "utf8")).toBe(mikeBytes);
expect(Object.keys(parseYaml(readFileSync(join(humansDir(home), "ana.yaml"), "utf8")) as object).sort())
.toEqual(["address", "class", "connectorBindings", "displayName", "entityId", "prefs"]);
});
it("refuses a person in an organization that doesn't exist, before writing their fragment", async () => {
const dirBefore = readdirSync(humansDir(home)).sort();
const p = program();
p.exitOverride();
try { await p.parseAsync([
"node", "rig", "gateway", "human", "add", "ana",
"--display-name", "Ana",
"--binding", "slack:main:vault://slack/ana:primary",
"--delivery-class", "A",
]); } catch { /* exitCode path */ }
try { await p.parseAsync(["node", "rig", "gateway", "human", "add", "ana", "--display-name", "Ana",
"--binding", "slack:main:vault://slack/ana:primary", "--delivery-class", "A", "--organization", "nope"]); } catch { /* exitCode path */ }
expect(process.exitCode).toBe(1);
const err = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(err).toContain("mike"); // the existing human, named
expect(err).toContain("gateway human remove"); // how to register someone else
expect(err).not.toMatch(/\d+\.\d+\.\d+/); // no promise of a release that manages several
expect(err).toContain("hand-author"); // the sanctioned several-fragment path
// Zero new fragment bytes: directory unchanged, existing fragment byte-identical.
expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain('no organization "nope"');
expect(readdirSync(humansDir(home)).sort()).toEqual(dirBefore);
expect(readFileSync(join(humansDir(home), "mike.yaml"), "utf8")).toBe(mikeBytes);
});
it("F1 companion: re-add of the SAME human with --replace stays allowed (boundary blocks distinct humans only)", async () => {
@@ -4,6 +4,8 @@ description: "Use when project policy calls for a human decision or update, a hu
metadata:
cli_surfaces_referenced:
- gateway human list
- gateway participants
- gateway default
- gateway human show
- gateway human mode room
- gateway human mode decide
@@ -32,13 +34,22 @@ Discover the registered participants and inspect the chosen human:
```bash
rig gateway human list --json
rig gateway participants --json
rig gateway human show <entityId> --json
```
`participants` lists people and shared channels (one vetted channel is one
participant), each with its organization, groups, whether it's the default, and
its readable policy: the organization's, each group's and its own text, each
labelled with where it came from. Read the policy of whoever you contact and
apply it; it's context for your judgment, not a permission system, and a name
is never a reason to assume a policy.
Use the returned `address` (`<entityId>@external`), not a username, remembered
seat, connector handle, or guessed kernel address. Where several humans exist,
use the decision ownership in Project World. An absent or ambiguous registration
needs a named registration correction, not a fallback address.
seat, connector handle, or guessed kernel address. Where several people exist,
use the decision ownership in Project World; the default person (`rig gateway
default`) is only for needs that name nobody. An absent or ambiguous
registration needs a named registration correction, not a fallback address.
Check readiness: configured, enabled, active, ready, reason, and next action.
`indeterminate` is not ready. Follow the reported next inspection; do not enable
@@ -4,6 +4,8 @@ description: "Use when project policy calls for a human decision or update, a hu
metadata:
cli_surfaces_referenced:
- gateway human list
- gateway participants
- gateway default
- gateway human show
- gateway human mode room
- gateway human mode decide
@@ -32,13 +34,22 @@ Discover the registered participants and inspect the chosen human:
```bash
rig gateway human list --json
rig gateway participants --json
rig gateway human show <entityId> --json
```
`participants` lists people and shared channels (one vetted channel is one
participant), each with its organization, groups, whether it's the default, and
its readable policy: the organization's, each group's and its own text, each
labelled with where it came from. Read the policy of whoever you contact and
apply it; it's context for your judgment, not a permission system, and a name
is never a reason to assume a policy.
Use the returned `address` (`<entityId>@external`), not a username, remembered
seat, connector handle, or guessed kernel address. Where several humans exist,
use the decision ownership in Project World. An absent or ambiguous registration
needs a named registration correction, not a fallback address.
seat, connector handle, or guessed kernel address. Where several people exist,
use the decision ownership in Project World; the default person (`rig gateway
default`) is only for needs that name nobody. An absent or ambiguous
registration needs a named registration correction, not a fallback address.
Check readiness: configured, enabled, active, ready, reason, and next action.
`indeterminate` is not ready. Follow the reported next inspection; do not enable
+13 -1
View File
@@ -16,7 +16,7 @@ export interface AttentionItem {
* one-tap Approve), or `answer` for an ask with questions, which Approve would close without returning their answers.
* `answer` takes a choice or their own words, or sends a complete set's recorded answers back, and the answer goes back
* to the asker as Slack's answers do. */
answer?: { verb: "approve" | "answer"; actorSession: string };
answer?: { verb: "approve" | "answer" | "resolve"; actorSession: string };
/** The seat that asked, which the person can open to answer in their own words (the Feed card's Chat). */
asker?: string;
/** The row's questions and the answers recorded so far, as the queue row holds them. */
@@ -35,6 +35,18 @@ export interface AttentionRead {
sources: Array<{ source: string; state: "available" | "unavailable" | "partial"; detail: string }>;
detail: AttentionDetail | null;
detailError: string | null;
/** Whose queue this Feed shows (`view`), and who acts on it. A view, never an identity: acts on what it shows carry the
* primary user's authority, the person at the terminal, whoever's queue it is. `requested` names a view that matched no
* registered person. Absent when no person is registered. */
perspective?: {
viewing: string | null;
basis: "all" | "default" | "chosen-here" | "unknown";
requested?: string;
/** Where the default person came from: the participants file (`chosen`), the only registered person, or nowhere. The TUI
* opens on the default's Feed only when one was chosen. */
defaultSource: "chosen" | "only-person" | "none";
actingAs: { person: string | null; authority: "primary-user" };
};
}
// Consumers must use the same lexical human classification as queue selection.
@@ -273,6 +273,23 @@ export function humansDir(home: string = getOpenRigHome()): string {
export function projectionPath(home: string = getOpenRigHome()): string {
return join(home, "gateway", "humans.generated.yaml");
}
/** The participant side file (participants.ts owns its contents). Older daemons never read it. */
export function participantsPath(home: string = getOpenRigHome()): string {
return join(home, "gateway", "participants.yaml");
}
/** People and shared channels share one address space (<id>@external): the shared channel registered under `entityId`,
* if the participant file names one. A file that doesn't parse names none here — it never blocks registering a person,
* and its own error is reported where it's read. */
function sharedChannelWithId(entityId: string, home: string): string | null {
const path = participantsPath(home);
if (!existsSync(path)) return null;
let raw: unknown;
try { raw = parseYaml(readFileSync(path, "utf8")); } catch { return null; }
const entry = isObj(raw) && isObj(raw.participants) ? raw.participants[entityId] : undefined;
if (!isObj(entry) || entry.kind !== "shared-channel") return null;
return typeof entry.displayName === "string" ? entry.displayName : entityId;
}
/** @deprecated Historical spelling retained for readers of old records only.
* Never a routing default: an address must resolve to an actual registry entity. */
@@ -384,7 +401,7 @@ export function resolveSlackHandle(
};
}
function atomicWrite(path: string, body: string): { ok: true } | { ok: false; error: string } {
export function atomicWrite(path: string, body: string): { ok: true } | { ok: false; error: string } {
try {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp-${process.pid}`;
@@ -425,6 +442,11 @@ export function addHumanFragment(
if (!opts.replace && existsSync(file)) {
return { ok: false, error: `human "${v.fragment.entityId}" already exists at ${file} — pass an explicit replace to update it (no silent overwrite)` };
}
// A person can't take a shared channel's id: they'd share <id>@external, and the participant file would stop reading.
const channel = sharedChannelWithId(v.fragment.entityId, home);
if (channel !== null) {
return { ok: false, error: `"${v.fragment.entityId}" is already the shared channel "${channel}" (${v.fragment.entityId}@${ADDRESS_DOMAIN}); choose another id for the person, or remove the channel first with rig gateway channel remove ${v.fragment.entityId}` };
}
// A6 v3 pin-1: reject a handle already claimed by a DIFFERENT human BEFORE writing (never leave a
// conflicting fragment on disk with a failed re-projection). projectHumans is the load-time backstop.
const existing = projectHumans(home);
@@ -487,14 +509,9 @@ export interface HumanSummary {
}
export type ListHumansResult =
| { ok: true; humans: HumanSummary[]; advisory?: string }
| { ok: true; humans: HumanSummary[] }
| { ok: false; error: string };
/** Amendment A1 (founder R5): the human surface is SINGLE-HUMAN. Several fragments render
* honestly, but enumeration is display, never management — the advisory names the boundary. */
export const MULTI_HUMAN_ADVISORY =
"several human fragments exist; OpenRig supports one configured human, so they are displayed as found and no command manages several";
/** A field value with its provenance: authored in the fragment, or filled by a default. */
export interface ProvenancedValue<T> {
value: T;
@@ -591,7 +608,8 @@ export function listHumans(home: string = getOpenRigHome()): ListHumansResult {
fragmentPath: fragmentPathFor(e.entityId, home),
};
});
return humans.length > 1 ? { ok: true, humans, advisory: MULTI_HUMAN_ADVISORY } : { ok: true, humans };
// Several people are a supported registry (0.7.3): the default and their placement live in gateway/participants.yaml.
return { ok: true, humans };
}
/** Read one fragment RAW (for authored-vs-default provenance) + validated. */
@@ -10,6 +10,7 @@ import { DispatchBuffer } from "./dispatch-buffer.js";
import type { DispatchResult } from "./dispatcher.js";
import type { LoadResult } from "./human-registry.js";
import { loadHumanRegistry } from "./human-registry.js";
import { resolveDefaultPerson } from "./participants.js";
import { loadConfig } from "./slack/config.js";
import { quietHoldFor } from "./human-quiet-windows.js";
import { OUTBOUND_OP } from "./slack/outbound-driver.js";
@@ -81,9 +82,11 @@ export function makeOperatorDeliveryEngine(deps: {
return { decision: "unavailable:human-registry", resolved: false, dispatched: false };
}
if (!reg.ok) return { decision: "unavailable:human-registry", resolved: false, dispatched: false };
const human = reg.entities[0];
// The escalation goes to the default person, never simply the first file: with several people and none chosen,
// it's undeliverable and says so.
const human = resolveDefaultPerson(reg.entities, deps.home).person;
if (!human) {
return { decision: "undeliverable:no-registered-human", resolved: false, dispatched: false };
return { decision: reg.entities.length ? "undeliverable:no-default-person" : "undeliverable:no-registered-human", resolved: false, dispatched: false };
}
const cfg = loadConfig(deps.home);
const decision = decideDelivery({
@@ -0,0 +1,426 @@
// The gateway's participant side file (0.7.3 participant foundation).
//
// Person fragments under gateway/humans/ keep their closed key set exactly as v0.7.0 reads it, so after a rollback an
// older daemon still loads every person. Everything newer lives here, in <home>/gateway/participants.yaml, which v0.7.0
// and v0.7.1 never read: the default person, organizations and their groups, each participant's organization, groups
// and readable policy, and shared-channel participants (one vetted channel is one participant, not a person per sender).
//
// Policy is text agents read and apply. Nothing here enforces it, and nothing derives it from names.
//
// A broken side file never disables the person registry: asks, answers and inbound admission keep working from the
// fragments, this file's own features report the error, and with several people no default is chosen. A reference to a
// person whose fragment is gone is a warning and is ignored, never a reason to refuse the whole file.
import { existsSync, readFileSync } from "node:fs";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { getOpenRigHome } from "../../openrig-compat.js";
import { ADDRESS_DOMAIN, ENTITY_ID_PATTERN, HANDLE_PATTERN, atomicWrite, participantsPath, projectHumans, type HumanFragment } from "./human-registry.js";
// The path lives beside the person registry, which reads it to keep a person off a shared channel's id.
export { participantsPath };
export const PARTICIPANTS_FILE_VERSION = 1;
export interface ParticipantOrganization { name: string; policy?: string }
export interface ParticipantGroup { organization: string; name: string; policy?: string }
export interface SharedChannelBinding { connector: "slack"; connectorRef: string; secretsRef: string; channelId: string }
interface Placement { organization?: string; groups?: string[]; policy?: string }
export type PersonEntry = Placement & { kind: "person" };
export type SharedChannelEntry = Placement & { kind: "shared-channel"; displayName: string; channel: SharedChannelBinding };
export type ParticipantEntry = PersonEntry | SharedChannelEntry;
export interface ParticipantsFile {
version: typeof PARTICIPANTS_FILE_VERSION;
/** The person for anything that needs one without naming one. Never a shared channel. */
default?: string;
organizations: Record<string, ParticipantOrganization>;
groups: Record<string, ParticipantGroup>;
participants: Record<string, ParticipantEntry>;
}
export const emptyParticipantsFile = (): ParticipantsFile => ({ version: PARTICIPANTS_FILE_VERSION, organizations: {}, groups: {}, participants: {} });
const FILE_KEYS = new Set(["version", "default", "organizations", "groups", "participants"]);
const ORGANIZATION_KEYS = new Set(["name", "policy"]);
const GROUP_KEYS = new Set(["organization", "name", "policy"]);
const PERSON_KEYS = new Set(["kind", "organization", "groups", "policy"]);
const CHANNEL_KEYS = new Set(["kind", "displayName", "channel", "organization", "groups", "policy"]);
const CHANNEL_BINDING_KEYS = new Set(["connector", "connectorRef", "secretsRef", "channelId"]);
const isObj = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const unknownKey = (obj: Record<string, unknown>, allowed: Set<string>) => Object.keys(obj).find((key) => !allowed.has(key));
const nonEmpty = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;
export type ValidateParticipantsResult =
| { ok: true; file: ParticipantsFile; warnings: string[] }
| { ok: false; error: string };
/** Structure is checked strictly: a typo must not silently change what agents read. References to people are checked
* against `personIds` (the registered fragments) when given: a person entry or default whose fragment is gone is
* dropped with a warning; a shared channel that takes a person's id is an error (one address space). */
export function validateParticipantsFile(raw: unknown, personIds?: ReadonlySet<string>): ValidateParticipantsResult {
if (!isObj(raw)) return { ok: false, error: "the participants file must be a mapping" };
const top = unknownKey(raw, FILE_KEYS);
if (top) return { ok: false, error: `unknown key "${top}" — allowed: ${[...FILE_KEYS].join(", ")}` };
if (raw.version !== PARTICIPANTS_FILE_VERSION) return { ok: false, error: `version must be ${PARTICIPANTS_FILE_VERSION} (found ${String(raw.version)})` };
const warnings: string[] = [];
const mapping = (key: "organizations" | "groups" | "participants") => {
const value = raw[key];
if (value === undefined || value === null) return {};
return isObj(value) ? value : null;
};
const organizations: Record<string, ParticipantOrganization> = {};
const rawOrganizations = mapping("organizations");
if (!rawOrganizations) return { ok: false, error: "organizations must be a mapping of <id>: { name, policy? }" };
for (const [id, value] of Object.entries(rawOrganizations)) {
const where = `organizations.${id}`;
if (!ENTITY_ID_PATTERN.test(id)) return { ok: false, error: `${where}: the id must be a lowercase slug (a-z0-9._-)` };
if (!isObj(value)) return { ok: false, error: `${where} must be a mapping { name, policy? }` };
const extra = unknownKey(value, ORGANIZATION_KEYS);
if (extra) return { ok: false, error: `${where} has unknown key "${extra}" — allowed: name, policy` };
if (!nonEmpty(value.name)) return { ok: false, error: `${where}.name must be a non-empty string` };
if (value.policy !== undefined && typeof value.policy !== "string") return { ok: false, error: `${where}.policy must be text` };
organizations[id] = { name: value.name, ...(value.policy !== undefined ? { policy: value.policy as string } : {}) };
}
const groups: Record<string, ParticipantGroup> = {};
const rawGroups = mapping("groups");
if (!rawGroups) return { ok: false, error: "groups must be a mapping of <id>: { organization, name, policy? }" };
for (const [id, value] of Object.entries(rawGroups)) {
const where = `groups.${id}`;
if (!ENTITY_ID_PATTERN.test(id)) return { ok: false, error: `${where}: the id must be a lowercase slug (a-z0-9._-)` };
if (!isObj(value)) return { ok: false, error: `${where} must be a mapping { organization, name, policy? }` };
const extra = unknownKey(value, GROUP_KEYS);
if (extra) return { ok: false, error: `${where} has unknown key "${extra}" — allowed: organization, name, policy` };
if (typeof value.organization !== "string" || !organizations[value.organization]) {
return { ok: false, error: `${where}.organization "${String(value.organization)}" isn't a known organization` };
}
if (!nonEmpty(value.name)) return { ok: false, error: `${where}.name must be a non-empty string` };
if (value.policy !== undefined && typeof value.policy !== "string") return { ok: false, error: `${where}.policy must be text` };
groups[id] = { organization: value.organization, name: value.name, ...(value.policy !== undefined ? { policy: value.policy as string } : {}) };
}
const participants: Record<string, ParticipantEntry> = {};
const rawParticipants = mapping("participants");
if (!rawParticipants) return { ok: false, error: "participants must be a mapping of <id>: { kind, ... }" };
const channelOwner = new Map<string, string>();
for (const [id, value] of Object.entries(rawParticipants)) {
const where = `participants.${id}`;
if (!ENTITY_ID_PATTERN.test(id)) return { ok: false, error: `${where}: the id must be a lowercase slug (a-z0-9._-)` };
if (!isObj(value)) return { ok: false, error: `${where} must be a mapping { kind, ... }` };
if (value.kind !== "person" && value.kind !== "shared-channel") {
return { ok: false, error: `${where}.kind "${String(value.kind)}" must be person or shared-channel` };
}
const extra = unknownKey(value, value.kind === "person" ? PERSON_KEYS : CHANNEL_KEYS);
if (extra) return { ok: false, error: `${where} has unknown key "${extra}" for a ${value.kind}` };
const placement: Placement = {};
if (value.organization !== undefined) {
if (typeof value.organization !== "string" || !organizations[value.organization]) {
return { ok: false, error: `${where}.organization "${String(value.organization)}" isn't a known organization` };
}
placement.organization = value.organization;
}
if (value.groups !== undefined) {
if (!Array.isArray(value.groups)) return { ok: false, error: `${where}.groups must be a list of group ids` };
const seen = new Set<string>();
for (const group of value.groups) {
if (typeof group !== "string" || !groups[group]) return { ok: false, error: `${where}.groups: "${String(group)}" isn't a known group` };
if (seen.has(group)) return { ok: false, error: `${where}.groups lists "${group}" twice` };
seen.add(group);
}
placement.groups = [...seen];
}
if (value.policy !== undefined) {
if (typeof value.policy !== "string") return { ok: false, error: `${where}.policy must be text` };
placement.policy = value.policy;
}
if (value.kind === "person") {
if (personIds && !personIds.has(id)) {
warnings.push(`${where}: no person "${id}" is registered, so this entry is ignored`);
continue;
}
participants[id] = { kind: "person", ...placement };
continue;
}
if (personIds?.has(id)) return { ok: false, error: `${where}: "${id}" is already a person's id; a shared channel needs its own (they share ${id}@${ADDRESS_DOMAIN})` };
if (!nonEmpty(value.displayName)) return { ok: false, error: `${where}.displayName must be a non-empty string` };
const channel = value.channel;
if (!isObj(channel)) return { ok: false, error: `${where}.channel must be { connector: slack, connectorRef, secretsRef, channelId }` };
const channelExtra = unknownKey(channel, CHANNEL_BINDING_KEYS);
if (channelExtra) return { ok: false, error: `${where}.channel has unknown key "${channelExtra}"` };
if (channel.connector !== "slack") return { ok: false, error: `${where}.channel.connector must be slack` };
if (!nonEmpty(channel.connectorRef)) return { ok: false, error: `${where}.channel.connectorRef must be a non-empty string` };
if (!nonEmpty(channel.secretsRef)) return { ok: false, error: `${where}.channel.secretsRef must be a vault pointer, never the secret` };
if (typeof channel.channelId !== "string" || !HANDLE_PATTERN.test(channel.channelId)) {
return { ok: false, error: `${where}.channel.channelId "${String(channel.channelId)}" must be a Slack channel id (letters and digits)` };
}
const prior = channelOwner.get(channel.channelId);
if (prior) return { ok: false, error: `${where}: channel ${channel.channelId} is already the participant "${prior}"` };
channelOwner.set(channel.channelId, id);
participants[id] = {
kind: "shared-channel", displayName: value.displayName,
channel: { connector: "slack", connectorRef: channel.connectorRef, secretsRef: channel.secretsRef, channelId: channel.channelId },
...placement,
};
}
const file: ParticipantsFile = { version: PARTICIPANTS_FILE_VERSION, organizations, groups, participants };
if (raw.default !== undefined && raw.default !== null) {
if (typeof raw.default !== "string") return { ok: false, error: "default must be a person's id" };
if (participants[raw.default]?.kind === "shared-channel") return { ok: false, error: `default "${raw.default}" is a shared channel; the default is a person` };
if (personIds && !personIds.has(raw.default)) warnings.push(`default: no person "${raw.default}" is registered, so no default is chosen`);
else file.default = raw.default;
}
return { ok: true, file, warnings };
}
export type ReadParticipantsResult =
| { ok: true; file: ParticipantsFile; exists: boolean; warnings: string[] }
| { ok: false; error: string };
/** Read and validate the side file. Absent is an empty file (a one-person install needs none). */
export function readParticipantsFile(home: string = getOpenRigHome(), personIds?: ReadonlySet<string>): ReadParticipantsResult {
const path = participantsPath(home);
if (!existsSync(path)) return { ok: true, file: emptyParticipantsFile(), exists: false, warnings: [] };
let raw: unknown;
try {
raw = parseYaml(readFileSync(path, "utf8"));
} catch (err) {
return { ok: false, error: `the participants file at ${path} doesn't parse: ${(err as Error).message}` };
}
const validated = validateParticipantsFile(raw, personIds);
if (!validated.ok) return { ok: false, error: `the participants file at ${path} is invalid: ${validated.error}` };
return { ok: true, file: validated.file, exists: true, warnings: validated.warnings };
}
const FILE_HEADER =
"# OpenRig gateway participants: the default person, organizations, groups, readable policy and shared channels.\n" +
"# Written by `rig gateway` verbs and read by OpenRig 0.7.3 and later. Older daemons don't read this file, and\n" +
"# person records stay in gateway/humans/, so a rollback still loads every person.\n";
const sortedRecord = <T>(record: Record<string, T>): Record<string, T> =>
Object.fromEntries(Object.keys(record).sort().map((key) => [key, record[key]!]));
/** The canonical bytes: sections in a fixed order, ids sorted, so a write is stable and diffable. */
export function participantsFileBody(file: ParticipantsFile): string {
const body: Record<string, unknown> = { version: file.version };
if (file.default) body.default = file.default;
body.organizations = sortedRecord(file.organizations);
body.groups = sortedRecord(file.groups);
body.participants = sortedRecord(file.participants);
return FILE_HEADER + stringifyYaml(body);
}
export type UpdateParticipantsResult =
| { ok: true; path: string; file: ParticipantsFile; warnings: string[] }
| { ok: false; error: string };
/** The one writer: read the current file against the registered people, apply `change`, validate the result the same
* way a read does, then write it atomically. `change` returns an error string to refuse. */
export function updateParticipantsFile(
change: (file: ParticipantsFile, people: readonly HumanFragment[]) => ParticipantsFile | string,
home: string = getOpenRigHome(),
): UpdateParticipantsResult {
const people = projectHumans(home);
if (!people.ok) return { ok: false, error: `can't check participants against the person registry, which is invalid: ${people.error}` };
const personIds = new Set(people.entities.map((entity) => entity.entityId));
const current = readParticipantsFile(home, personIds);
if (!current.ok) return current;
const next = change(structuredClone(current.file), people.entities);
if (typeof next === "string") return { ok: false, error: next };
const validated = validateParticipantsFile(JSON.parse(JSON.stringify(next)), personIds);
if (!validated.ok) return { ok: false, error: validated.error };
// The read already dropped entries for people removed by hand, so a reference to an unregistered person here is this
// change's own mistake: refuse it rather than write an entry nobody can use.
if (validated.warnings.length > 0) return { ok: false, error: validated.warnings.join("; ") };
const path = participantsPath(home);
const written = atomicWrite(path, participantsFileBody(validated.file));
// The warnings name the stale entries this write left out.
return written.ok ? { ok: true, path, file: validated.file, warnings: current.warnings } : written;
}
export type DefaultPerson<T extends Pick<HumanFragment, "entityId"> = HumanFragment> =
| { person: T; source: "chosen" | "only-person" }
| { person: null; source: "none"; reason: string };
/** The person for anything that needs one without naming one (an escalation, the notice digest, Mission Control's
* my-queue, a workflow's human step, a pairing approval): the side file's default, else the only registered person,
* else none, with the reason. Never the first of several, and never workspace.operator_seat_name. */
export function resolveDefaultPerson<T extends Pick<HumanFragment, "entityId">>(entities: readonly T[], home: string = getOpenRigHome()): DefaultPerson<T> {
const side = readParticipantsFile(home, new Set(entities.map((entity) => entity.entityId)));
const chosen = side.ok && side.file.default ? entities.find((entity) => entity.entityId === side.file.default) : undefined;
if (chosen) return { person: chosen, source: "chosen" };
if (entities.length === 1) return { person: entities[0]!, source: "only-person" };
if (entities.length === 0) return { person: null, source: "none", reason: "no person is registered" };
return {
person: null, source: "none",
reason: side.ok
? "several people are registered and none is the default; choose one with `rig gateway default <id>`"
: `several people are registered and the default can't be read (${side.error})`,
};
}
export interface PolicyPart { source: string; text: string }
/** A participant's readable policy, outermost first: its organization's, each of its groups', then its own. Each part
* says where it came from, so an agent can weigh and quote it. */
export function effectivePolicy(id: string, file: ParticipantsFile): PolicyPart[] {
const entry = file.participants[id];
if (!entry) return [];
const parts: PolicyPart[] = [];
const organization = entry.organization ? file.organizations[entry.organization] : undefined;
if (organization?.policy) parts.push({ source: `organization ${entry.organization} (${organization.name})`, text: organization.policy });
for (const groupId of entry.groups ?? []) {
const group = file.groups[groupId];
if (group?.policy) parts.push({ source: `group ${groupId} (${group.name})`, text: group.policy });
}
if (entry.policy) parts.push({ source: "participant", text: entry.policy });
return parts;
}
export interface ParticipantView {
id: string;
address: string;
kind: "person" | "shared-channel";
displayName: string;
organization: { id: string; name: string } | null;
groups: Array<{ id: string; name: string }>;
default: boolean;
policy: PolicyPart[];
channel?: { connector: "slack"; channelId: string };
}
export type ParticipantsViewResult =
| { ok: true; participants: ParticipantView[]; default: DefaultPerson; warnings: string[]; sideFile: { path: string; exists: boolean; error?: string } }
| { ok: false; error: string };
/** Every person and shared channel, with organization, groups, the default and effective policy. An unreadable side
* file still lists the people (from their fragments), and says why the rest is missing. */
export function participantsView(home: string = getOpenRigHome()): ParticipantsViewResult {
const people = projectHumans(home);
if (!people.ok) return { ok: false, error: people.error };
const personIds = new Set(people.entities.map((entity) => entity.entityId));
const side = readParticipantsFile(home, personIds);
const file = side.ok ? side.file : emptyParticipantsFile();
const chosen = resolveDefaultPerson(people.entities, home);
const placementOf = (id: string) => {
const entry = file.participants[id];
const organization = entry?.organization ? { id: entry.organization, name: file.organizations[entry.organization]!.name } : null;
const groups = (entry?.groups ?? []).map((groupId) => ({ id: groupId, name: file.groups[groupId]!.name }));
return { organization, groups, policy: effectivePolicy(id, file) };
};
const views: ParticipantView[] = people.entities.map((entity) => ({
id: entity.entityId, address: entity.address, kind: "person", displayName: entity.displayName,
...placementOf(entity.entityId), default: chosen.person?.entityId === entity.entityId,
}));
for (const [id, entry] of Object.entries(file.participants)) {
if (entry.kind !== "shared-channel") continue;
views.push({
id, address: `${id}@${ADDRESS_DOMAIN}`, kind: "shared-channel", displayName: entry.displayName,
...placementOf(id), default: false, channel: { connector: entry.channel.connector, channelId: entry.channel.channelId },
});
}
views.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
return {
ok: true, participants: views, default: chosen, warnings: side.ok ? side.warnings : [],
sideFile: { path: participantsPath(home), exists: side.ok ? side.exists : true, ...(side.ok ? {} : { error: side.error }) },
};
}
// ── The writes behind the `rig gateway` verbs. Each goes through updateParticipantsFile, so each is validated the same
// way a read is and written atomically.
export interface PlacementInput { organization?: string | null; groups?: string[] | null; policy?: string | null }
const applyPlacement = (entry: Placement, input: PlacementInput): void => {
if (input.organization !== undefined) { if (input.organization === null) delete entry.organization; else entry.organization = input.organization; }
if (input.groups !== undefined) { if (input.groups === null || input.groups.length === 0) delete entry.groups; else entry.groups = input.groups; }
if (input.policy !== undefined) { if (input.policy === null || input.policy === "") delete entry.policy; else entry.policy = input.policy; }
};
/** A person's organization, groups and policy (null clears one). The person must already be registered. */
export function setPersonPlacement(entityId: string, input: PlacementInput, home: string = getOpenRigHome()): UpdateParticipantsResult {
return updateParticipantsFile((file, people) => {
if (!people.some((person) => person.entityId === entityId)) return `no person "${entityId}" is registered`;
const entry: PersonEntry = file.participants[entityId]?.kind === "person" ? file.participants[entityId] as PersonEntry : { kind: "person" };
applyPlacement(entry, input);
// A person with nothing placed needs no entry: the fragment already makes them a participant.
if (entry.organization || entry.groups || entry.policy) file.participants[entityId] = entry;
else delete file.participants[entityId];
return file;
}, home);
}
/** Choose the default person, or clear it (null). */
export function setDefaultPerson(entityId: string | null, home: string = getOpenRigHome()): UpdateParticipantsResult {
return updateParticipantsFile((file, people) => {
if (entityId === null) { delete file.default; return file; }
if (!people.some((person) => person.entityId === entityId)) return `no person "${entityId}" is registered; the default is a registered person`;
file.default = entityId;
return file;
}, home);
}
/** Register or replace a shared-channel participant. `replace` is explicit: an existing channel is never overwritten
* silently. */
export function setSharedChannel(
id: string,
input: PlacementInput & { displayName: string; channel: SharedChannelBinding },
opts: { replace?: boolean } = {},
home: string = getOpenRigHome(),
): UpdateParticipantsResult {
return updateParticipantsFile((file) => {
const existing = file.participants[id];
if (existing && existing.kind !== "shared-channel") return `"${id}" is a person`;
if (existing && !opts.replace) return `shared channel "${id}" already exists — pass an explicit replace to update it (no silent overwrite)`;
const entry: SharedChannelEntry = { kind: "shared-channel", displayName: input.displayName, channel: input.channel };
applyPlacement(entry, input);
file.participants[id] = entry;
return file;
}, home);
}
/** Remove a participant's side-file entry (a shared channel, or a person's placement), and the default if it named
* them. With `kind`, only an entry of that kind is removed: `human remove` passes "person", so it never deletes a
* shared channel that happens to share the id. */
export function removeParticipantEntry(
id: string,
home: string = getOpenRigHome(),
opts: { kind?: ParticipantEntry["kind"] } = {},
): UpdateParticipantsResult {
return updateParticipantsFile((file) => {
if (!opts.kind || file.participants[id]?.kind === opts.kind) delete file.participants[id];
if (file.default === id && opts.kind !== "shared-channel") delete file.default;
return file;
}, home);
}
/** Create or update an organization. Its policy is text agents read. */
export function setOrganization(id: string, input: { name?: string; policy?: string | null }, home: string = getOpenRigHome()): UpdateParticipantsResult {
return updateParticipantsFile((file) => {
const current = file.organizations[id];
const name = input.name ?? current?.name;
if (!name) return `organization "${id}" is new, so it needs a name`;
const next: ParticipantOrganization = { name };
const policy = input.policy === undefined ? current?.policy : input.policy;
if (policy) next.policy = policy;
file.organizations[id] = next;
return file;
}, home);
}
/** Create or update a group inside an organization. */
export function setGroup(id: string, input: { organization?: string; name?: string; policy?: string | null }, home: string = getOpenRigHome()): UpdateParticipantsResult {
return updateParticipantsFile((file) => {
const current = file.groups[id];
const organization = input.organization ?? current?.organization;
const name = input.name ?? current?.name;
if (!organization || !name) return `group "${id}" is new, so it needs an organization and a name`;
const next: ParticipantGroup = { organization, name };
const policy = input.policy === undefined ? current?.policy : input.policy;
if (policy) next.policy = policy;
file.groups[id] = next;
return file;
}, home);
}
@@ -22,6 +22,7 @@
// degradation only.
import { loadHumanRegistry } from "../gateway/human-registry.js";
import { resolveDefaultPerson } from "../gateway/participants.js";
import type Database from "better-sqlite3";
import type { QueueRepository, QueueItem, QueueState } from "../queue-repository.js";
import type { ViewProjector } from "../view-projector.js";
@@ -136,8 +137,9 @@ export class MissionControlReadLayer {
* operator seat even when the kernel rig isn't booted yet. */
getDefaultOperatorSession(): string {
if (this.defaultOperatorSession) return this.defaultOperatorSession;
// The configured operator wins above; otherwise the default person (chosen, or the only one), never the first of several.
const registry = loadHumanRegistry();
return registry.ok && registry.entities.length === 1 ? registry.entities[0]!.address : "";
return registry.ok ? resolveDefaultPerson(registry.entities).person?.address ?? "" : "";
}
async readView(
@@ -48,8 +48,15 @@ import {
MissionControlActionLogError,
type MissionControlVerb,
} from "./mission-control-action-log.js";
import { isHumanSeatSession } from "../human-route-enforcer.js";
import { isHumanSeatSessionRef } from "../session-name.js";
import { humanAnswerText, formatHumanAnswers, unansweredQuestions, type HumanAnswers } from "../human-questions.js";
import { resolveDefaultPerson } from "../gateway/participants.js";
/** The primary user when several people are registered and none is the default: the generic human seat the web UI also
* records. */
const UNNAMED_PRIMARY_USER = "human@host";
/** Verbs that settle what a person was asked. */
const SETTLING_VERBS = new Set<MissionControlVerb>(["approve", "deny", "resolve", "answer"]);
export class MissionControlWriteContractError extends Error {
constructor(
@@ -85,6 +92,10 @@ export interface MissionControlActionInput {
optionId?: string;
/** Operator-supplied audit context. */
auditNotes?: Record<string, unknown>;
/** `primary-user`: the person at the terminal acts (the TUI), whoever was asked. The actor recorded is the primary user
* (the default person), never the claimed actor, and the audit notes say whose ask it was. Only a request with no
* transport identity carries it. */
authority?: "primary-user";
/**
* For handoff: opt-in best-effort wake. Default true (PL-004 Phase A R1
* pattern: durable + waking by default; operators opt out for cold queues).
@@ -146,6 +157,8 @@ export class MissionControlWriteContract {
}
const source = this.requireMutableQitem(input.qitemId);
const acting = this.asActor(input, this.queueRepo.isPersonAddress(source.destinationSession) ? source.destinationSession : null);
input = acting.input;
if ((input.verb === "route" || input.verb === "handoff") && !input.destinationSession) {
throw new MissionControlWriteContractError(
@@ -173,7 +186,7 @@ export class MissionControlWriteContract {
closureTarget: closure.closureTarget ?? undefined,
handedOffTo: closure.handedOffTo,
blockedOn: closure.blockedOn,
transitionNote: `mission-control:${input.verb}${input.reason ? ` (${input.reason})` : ""}`,
transitionNote: `mission-control:${input.verb}${input.reason ? ` (${input.reason})` : ""}${acting.note}`,
});
register(closeResult.persistedEvent);
@@ -392,7 +405,8 @@ export class MissionControlWriteContract {
{ qitemId: input.qitemId },
);
}
if (source.state !== "blocked" || !isHumanSeatSession(source.blockedOn)) {
// Parked on a person: a legacy human seat or a registered person's address, as the Feed lists it.
if (source.state !== "blocked" || !source.blockedOn || !isHumanSeatSessionRef(source.blockedOn)) {
throw new MissionControlWriteContractError(
"qitem_not_leg1_parked",
`verb=resolve requires a leg-1 parked qitem (state=blocked with a human-seat blocked_on); ` +
@@ -401,6 +415,8 @@ export class MissionControlWriteContract {
{ qitemId: input.qitemId, state: source.state, blockedOn: source.blockedOn },
);
}
const acting = this.asActor(input, source.blockedOn);
input = acting.input;
const evaluatedAt = this.now().toISOString();
const beforeSnapshot = snapshotQitem(source);
@@ -417,7 +433,7 @@ export class MissionControlWriteContract {
qitemId: input.qitemId,
actorSession: input.actorSession,
state: "in-progress",
transitionNote: decision,
transitionNote: `${decision}${acting.note}`,
ownerNotificationKind: "human-decision-resolved",
});
register(updateResult.persistedEvent);
@@ -501,17 +517,20 @@ export class MissionControlWriteContract {
if (!ask) {
throw new MissionControlWriteContractError("qitem_not_found", `qitem ${input.qitemId} not found`, { qitemId: input.qitemId });
}
if (ask.destinationSession !== input.actorSession || ask.humanIntent === "update" || !this.queueRepo.isPersonAddress(ask.destinationSession)) {
const primaryUser = input.authority === "primary-user";
input = this.asActor(input, ask.destinationSession).input;
// The asked person answers, or the primary user does on anyone's ask.
if ((ask.destinationSession !== input.actorSession && !primaryUser) || ask.humanIntent === "update" || !this.queueRepo.isPersonAddress(ask.destinationSession)) {
throw new MissionControlWriteContractError("answer_not_recorded",
`qitem ${input.qitemId} isn't an ask to ${input.actorSession}, so ${input.actorSession} can't answer it.`, { qitemId: input.qitemId });
}
const before = snapshotQitem(ask);
const questions = ask.humanQuestions ?? [];
const existing = this.queueRepo.getHumanAnswer(input.qitemId, input.actorSession);
const existing = this.queueRepo.getHumanAnswer(input.qitemId, input.actorSession, { primaryUser });
if (existing && (ask.state === "pending" || ask.state === "done")) {
const decision = questions.length && unansweredQuestions(questions, ask.humanAnswers ?? {}).length === 0
? formatHumanAnswers(questions, ask.humanAnswers ?? {}).join("; ") : humanAnswerText(existing.body);
if (ask.state === "pending" && !this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision })) {
if (ask.state === "pending" && !this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision, primaryUser })) {
throw new MissionControlWriteContractError("answer_not_closed", `The answer is already in ${existing.qitemId}, but ${input.qitemId} didn't close.`, { qitemId: input.qitemId });
}
return this.recordAnswer(input, before, decision, { outcome: "answered", unanswered: [],
@@ -531,7 +550,7 @@ export class MissionControlWriteContract {
let reply: string;
if (chose) {
const recorded = this.queueRepo.recordHumanAnswer({ qitemId: input.qitemId, actorSession: input.actorSession,
questionId: input.questionId ?? "", optionId: input.optionId ?? "" });
questionId: input.questionId ?? "", optionId: input.optionId ?? "", primaryUser });
if (recorded.status !== "recorded") {
throw new MissionControlWriteContractError("answer_not_recorded", `The answer wasn't recorded: ${recorded.reason}.`,
{ qitemId: input.qitemId, reason: recorded.reason });
@@ -552,12 +571,14 @@ export class MissionControlWriteContract {
const earlier = formatHumanAnswers(questions, ask.humanAnswers ?? {});
reply = `${words}\n\n---\n${earlier.length ? `Earlier answers: ${earlier.join("; ")}\n` : ""}In reply to: ${input.qitemId}${questions.length ? " (its humanAnswers field holds these words under its first unanswered question)" : ""}`;
}
// The asker reads who answered, and whose ask it was: never the asked person when someone else answered.
const forAsked = ask.destinationSession !== input.actorSession ? ask.destinationSession : null;
let returned: QueueItem;
try {
returned = await this.queueRepo.createHumanAnswer({
sourceSession: input.actorSession, destinationSession: ask.sourceSession,
body: `${reply}\nAnswered by ${input.actorSession} in OpenRig.`,
summary: `${input.actorSession} answered: ${ask.summary ?? input.qitemId}`,
body: `${reply}\nAnswered by ${input.actorSession}${forAsked ? `, the primary user at the terminal, on ${forAsked}'s ask` : ""} in OpenRig.`,
summary: `${input.actorSession} answered${forAsked ? ` ${forAsked}'s ask` : ""}: ${ask.summary ?? input.qitemId}`,
tags: ["human-answer", ...(ask.tags ?? []).filter((t) => /^(project|mission|slice):/.test(t))],
inboundReplyTo: input.qitemId, ...(input.notify === false ? { nudge: false } : {}),
});
@@ -570,7 +591,7 @@ export class MissionControlWriteContract {
const replyId = returned.qitemId;
const decision = chose || resume
? formatHumanAnswers(questions, this.queueRepo.getById(input.qitemId)?.humanAnswers ?? {}).join("; ") : humanAnswerText(returned.body);
if (!this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision })
if (!this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision, primaryUser })
&& this.queueRepo.getById(input.qitemId)?.state !== "done") {
throw new MissionControlWriteContractError("answer_not_closed",
`The answer went back to ${ask.sourceSession} as ${replyId}, but qitem ${input.qitemId} didn't close: it changed meanwhile.`,
@@ -598,6 +619,30 @@ export class MissionControlWriteContract {
notifyAttempted: false, notifyResult: null, auditedAt: evaluatedAt, answer };
}
/**
* Who acts, and on whose ask. With primary-user authority the actor is the primary user: the default person, or, with
* several people and none chosen, the generic human seat. With no one registered it's the claimed actor, as before. The
* audit notes record the authority and the person asked, and the transition says so when they differ. Without it, a
* registered person can't settle what was asked of another registered person: that's someone else's answer.
*/
private asActor(input: MissionControlActionInput, asked: string | null): { input: MissionControlActionInput; note: string } {
if (input.authority !== "primary-user") {
const someoneElse = SETTLING_VERBS.has(input.verb) ? this.queueRepo.askedSomeoneElse(input.actorSession, asked) : null;
if (someoneElse) {
throw new MissionControlWriteContractError("answer_not_recorded",
`qitem ${input.qitemId} was asked of ${someoneElse}, not ${input.actorSession}, so ${input.actorSession} can't settle it.`,
{ qitemId: input.qitemId, asked: someoneElse });
}
return { input, note: "" };
}
const registry = this.queueRepo.humanRegistry();
const people = registry.ok ? registry.entities : [];
const actor = people.length ? resolveDefaultPerson(people).person?.address ?? UNNAMED_PRIMARY_USER : input.actorSession;
const auditNotes = { ...input.auditNotes, authority: "primary-user", asked };
const note = asked && asked !== actor ? `; by ${actor}, the primary user at the terminal, on ${asked}'s ask` : "";
return { input: { ...input, actorSession: actor, auditNotes }, note };
}
private requireMutableQitem(qitemId: string): QueueItem {
const source = this.queueRepo.getById(qitemId);
if (!source) {
@@ -16,6 +16,7 @@ import { makeQueuePorts } from "../gateway/slack/queue-access.js";
import type { OwnerNotificationLevel } from "../queue-transition-log.js";
import { OUTBOUND_OP } from "../gateway/slack/outbound-driver.js";
import { DispatchBuffer } from "../gateway/dispatch-buffer.js";
import { resolveDefaultPerson } from "../gateway/participants.js";
import type { Policy, PolicyJob, PolicyEvaluation } from "./types.js";
import { quietHoldFor } from "../gateway/human-quiet-windows.js";
import { WatchdogJobsRepository } from "../watchdog-jobs-repository.js";
@@ -43,7 +44,8 @@ export interface RunDeliveryDigestFlushInput {
export async function runDeliveryDigestFlush(input: RunDeliveryDigestFlushInput): Promise<{ dispatched: number; members: number }> {
const reg = input.registry.loadHumanRegistry(input.home);
if (!reg.ok || !reg.entities || reg.entities.length === 0) return { dispatched: 0, members: 0 };
const human = reg.entities[0]!;
// Asks and updates below go to their own recipients; the digest of notices goes to the default person.
const human = resolveDefaultPerson(reg.entities, input.home).person;
const ports = makeQueuePorts(input.queueRepo, {
loadHumanRegistry: () => reg,
@@ -110,6 +112,8 @@ export async function runDeliveryDigestFlush(input: RunDeliveryDigestFlushInput)
if (result.ok) completeDispatched++;
}
if (!members.length) return { dispatched: completeDispatched, members: selected.length };
// Several people and none chosen: the notices wait (they stay selectable) rather than going to whoever sorts first.
if (!human) return { dispatched: completeDispatched, members: selected.length };
const memberReceipts = members.map((m) => ({
qitemId: m.qitemId,
+16 -9
View File
@@ -1696,11 +1696,12 @@ export class QueueRepository {
}
}
/** First committed return of a direct ask, including pre-shared-ID Slack replies. */
getHumanAnswer(qitemId: string, actorSession: string): QueueItem | null {
/** First committed return of a direct ask, including pre-shared-ID Slack replies. `primaryUser`: the actor is the
* primary user, who may answer anyone's ask. */
getHumanAnswer(qitemId: string, actorSession: string, opts: { primaryUser?: boolean } = {}): QueueItem | null {
const ask = this.getById(qitemId);
if (!this.hasReplyToColumn || !ask || ask.destinationSession !== actorSession
|| ask.humanIntent === "update" || !this.isPersonAddress(actorSession)) return null;
if (!this.hasReplyToColumn || !ask || (ask.destinationSession !== actorSession && !opts.primaryUser)
|| ask.humanIntent === "update" || !this.isPersonAddress(ask.destinationSession)) return null;
const row = this.db.prepare(`SELECT qitem_id FROM queue_items
WHERE reply_to = ? AND source_session = ? AND destination_session = ?
AND (EXISTS (SELECT 1 FROM json_each(queue_items.tags) WHERE value = 'human-answer')
@@ -1997,6 +1998,11 @@ export class QueueRepository {
return registry.ok && resolveRegisteredHumanAddress(destination, registry.entities) !== null;
}
/** The registered people this queue resolves addresses against. */
humanRegistry(): LoadResult {
return this.loadHumanRegistryFn();
}
/** The asked person, when a different registered person is acting on what was asked of them; else null. An agent, the
* asked person under another spelling, and an ask to no registered person aren't someone else. */
askedSomeoneElse(actorSession: string, askedSession: string | null | undefined): string | null {
@@ -3591,11 +3597,11 @@ export class QueueRepository {
* preserving earlier clicks and leaving the other questions unanswered. A completed
* button set is already final; its resolve continuation must not overwrite those answers.
*/
resolveDirectHumanReply(input: { qitemId: string; actorSession: string; decision: string }): boolean {
resolveDirectHumanReply(input: { qitemId: string; actorSession: string; decision: string; primaryUser?: boolean }): boolean {
const result = this.db.transaction(() => {
const item = this.getById(input.qitemId);
if (item?.state !== "pending" || item.humanIntent === "update"
|| item.destinationSession !== input.actorSession
|| (item.destinationSession !== input.actorSession && !input.primaryUser)
|| !this.isPersonAddress(item.destinationSession)) return null;
const text = input.decision.trim();
@@ -3603,7 +3609,8 @@ export class QueueRepository {
? unansweredQuestions(item.humanQuestions ?? [], item.humanAnswers ?? {})
: [];
const question = unanswered[0];
let transitionNote = "direct human reply received";
let transitionNote = item.destinationSession === input.actorSession ? "direct human reply received"
: `reply from ${input.actorSession}, the primary user at the terminal, on ${item.destinationSession}'s ask; not answered by ${item.destinationSession}`;
if (question) {
const typedReply: TypedHumanReply = {
kind: "typed-reply", text, placement: "first-unanswered", unansweredCount: unanswered.length - 1,
@@ -3635,7 +3642,7 @@ export class QueueRepository {
* resolve) with exactly what the seat will read. Anything else is not-applicable (a forged
* id, a stranger, a decision already resolved), never an error the socket must retry.
*/
recordHumanAnswer(input: { qitemId: string; actorSession: string; questionId: string; optionId: string }): RecordHumanAnswerResult {
recordHumanAnswer(input: { qitemId: string; actorSession: string; questionId: string; optionId: string; primaryUser?: boolean }): RecordHumanAnswerResult {
if (!this.hasHumanQuestionsColumn) return { status: "not-applicable", reason: "schema" };
return this.db.transaction(() => {
const item = this.getById(input.qitemId);
@@ -3649,7 +3656,7 @@ export class QueueRepository {
return { status: "not-applicable" as const, reason: `state-${item.state}`, ...(onRecord ? { answerOnRecord: true as const } : {}),
...(decidedForThem ? { decidedForThem } : {}) };
}
if (item.destinationSession !== input.actorSession) return { status: "not-applicable" as const, reason: "not-the-asked-human" };
if (item.destinationSession !== input.actorSession && !input.primaryUser) return { status: "not-applicable" as const, reason: "not-the-asked-human" };
const question = item.humanQuestions.find((q) => q.id === input.questionId);
if (!question?.options.some((o) => o.id === input.optionId)) return { status: "not-applicable" as const, reason: "unknown-option" };
const recorded = item.humanAnswers ?? {};
@@ -1,4 +1,5 @@
import { loadHumanRegistry, resolveRegisteredHumanAddress, type LoadResult } from "./gateway/human-registry.js";
import { resolveDefaultPerson } from "./gateway/participants.js";
import { SettingsStore } from "./user-settings/settings-store.js";
export class WorkflowHumanDestinationError extends Error {
@@ -33,9 +34,10 @@ export function resolveWorkflowHumanDestination(
throw new WorkflowHumanDestinationError({ state: "unregistered", selected, addresses },
"workspace.operator_seat_name does not select a registered human. Inspect rig gateway human list --json and select the intended registered address.");
}
if (addresses.length === 1) return addresses[0]!;
const fallback = resolveDefaultPerson(loaded.entities).person;
if (fallback) return fallback.address;
throw new WorkflowHumanDestinationError({ state: addresses.length ? "ambiguous" : "missing", addresses },
"Workflow human fallback needs one registered human. Inspect rig gateway human list --json; when several exist, explicitly select workspace.operator_seat_name. No human destination was invented.");
"Workflow human fallback needs one registered human, or a default when several exist. Inspect rig gateway participants --json; choose a default with rig gateway default <id>. No human destination was invented.");
}
/** Explicit injected destinations remain available to embedders and isolated fixtures. */
@@ -5,3 +5,4 @@
// surface (dep rail 2) instead of carrying a second copy — ONE source, no twin-parity pin.
export * from "./domain/gateway/human-registry.js";
export { runChannelOperation, channelStateDigest } from "./domain/gateway/channel-operations.js";
export * from "./domain/gateway/participants.js";
+33 -2
View File
@@ -11,14 +11,18 @@ import { listProjects, insideProject, projectMission, type ProjectRead } from ".
import { readMissionReadiness } from "../domain/proof/judgments.js";
import { WorkflowInstanceStore } from "../domain/workflow-instance-store.js";
import { WorkflowStepTrailLog } from "../domain/workflow-step-trail-log.js";
import { resolveDefaultPerson } from "../domain/gateway/participants.js";
import { resolveRegisteredHumanAddress } from "../domain/gateway/human-registry.js";
export function attentionRoutes(): Hono {
const app = new Hono();
app.get("/", c => {
const wanted = c.req.query("item");
const result: AttentionRead = { scope: "instance", readAt: new Date().toISOString(), items: [], sources: [], detail: null, detailError: null };
// Whether an item is in the queue this Feed shows; an item opened by id is shown in full whatever the view.
let inView = (_recipient: string | null | undefined): boolean => true;
const add = (item: AttentionItem, lines: () => string[], files: () => Array<{ label: string; path: string }> = () => []) => {
if (!result.items.some(i => i.id === item.id)) result.items.push(item);
if (!result.items.some(i => i.id === item.id) && inView(item.recipient)) result.items.push(item);
if (wanted === item.id) result.detail = { item, lines: lines(), files: files() };
};
const unavailable = (source: string, error: unknown) => result.sources.push({ source, state: "unavailable", detail: error instanceof Error ? error.message : String(error) });
@@ -29,6 +33,32 @@ export function attentionRoutes(): Hono {
} catch (e) { unavailable("project catalog", e); }
const queue = c.get("queueRepo" as never) as QueueRepository | undefined;
const posture = c.get("operatingPosture" as never) as OperatingPostureService | undefined;
// The Feed's perspective, whose queue it shows (`view`: a person, `default` or `all`). A view, never an identity: acts on
// what it shows carry the primary user's authority, the person at the terminal, whoever's queue it is. With no person
// registered the Feed is as before.
const registry = queue?.humanRegistry();
const entities = registry?.ok ? registry.entities : [];
if (entities.length) {
const person = (session: string | null | undefined) => resolveRegisteredHumanAddress(session, entities);
const requested = c.req.query("view")?.trim() || undefined;
// The primary user is the default person: the participants file's `default`, else the only registered person.
const primaryUser = resolveDefaultPerson(entities);
const primary = primaryUser.person?.address ?? null;
const named = requested !== undefined && requested !== "all" && requested !== "default";
const chosen = named && requested ? person(requested) ?? person(`${requested}@external`) : null;
const viewing = requested === "default" ? primary : chosen;
const basis: NonNullable<AttentionRead["perspective"]>["basis"] = requested === "default" ? (primary ? "default" : "all")
: named ? (chosen ? "chosen-here" : "unknown") : "all";
result.perspective = { viewing, basis, ...(basis === "unknown" ? { requested } : {}), defaultSource: primaryUser.source,
actingAs: { person: primary, authority: "primary-user" } };
// A person's items are in their view. Items for no registered person (instance facts, a bare human seat) are the
// primary user's, so they're in theirs.
if (basis === "unknown") inView = () => false;
else if (basis !== "all") inView = recipient => {
const owner = person(recipient);
return owner ? owner === viewing : viewing === primary;
};
}
try {
if (!queue) throw new Error("Queue repository unavailable");
const rows = queue.listAttention({ limit: 1001 });
@@ -60,8 +90,9 @@ export function attentionRoutes(): Hono {
// An ask addressed to the person is answered on this row, as them: Steering's Approve, or, for an ask with questions,
// mission control's answer (a choice, their own words, or sending a complete set's recorded answers back), which
// Approve would close without returning. They can also open the asker's terminal (Chat). A row parked on them is
// resolved another way, so it carries neither.
// resolved with their decision in words, which unparks it for its owner.
...(toPerson && q.state === "pending" ? { answer: { verb: questions ? "answer" as const : "approve" as const, actorSession: q.destinationSession } } : {}),
...(!toPerson && q.state === "blocked" && q.blockedOn && isHumanSeatSessionRef(q.blockedOn) ? { answer: { verb: "resolve" as const, actorSession: q.blockedOn } } : {}),
...(toPerson ? { asker: q.sourceSession } : {}),
...(questions ? { humanQuestions: questions, humanAnswers: q.humanAnswers ?? {} } : {}),
summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || "Request summary unavailable", urgency: q.priority, unblocks, at: q.tsUpdated, scope: p ? `project ${p.id}` : "instance · project unknown", project: p ? { id: p.id, root: p.root } : null, source: `/api/queue/${encodeURIComponent(q.qitemId)}` };
+5 -2
View File
@@ -47,6 +47,7 @@ import type { SettingsStore } from "../domain/user-settings/settings-store.js";
import type { QueueRepository } from "../domain/queue-repository.js";
import { existsSync } from "node:fs";
import { loadHumanRegistry, type LoadResult } from "../domain/gateway/human-registry.js";
import { resolveDefaultPerson } from "../domain/gateway/participants.js";
const PAIR_TTL_MS = 10 * 60 * 1000;
const PAIR_PRUNE_GRACE_MS = 60 * 60 * 1000;
@@ -163,10 +164,12 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
const body = (await c.req.json<{ requester?: string; human?: string }>().catch(() => ({}))) as { requester?: string; human?: string };
const registry = (opts?.humanRegistry ?? loadHumanRegistry)();
if (!registry.ok) return c.json({ error: "pair_human_registry_unavailable", message: registry.error }, 409);
const humans = body.human ? registry.entities.filter((human) => human.address === body.human) : registry.entities;
// Named, or else the default person (chosen, or the only one); never the first of several.
const fallback = body.human ? null : resolveDefaultPerson(registry.entities).person;
const humans = body.human ? registry.entities.filter((human) => human.address === body.human) : fallback ? [fallback] : [];
if (humans.length !== 1) return c.json({
error: "pair_human_required", addresses: registry.entities.map((human) => human.address),
message: "Pair approval needs one registered human. Inspect rig gateway human list --json; select --human <entityId>@external when several exist. No approval row was created.",
message: "Pair approval needs one registered human, or a default when several exist. Inspect rig gateway participants --json; select --human <entityId>@external, or choose a default with rig gateway default <id>. No approval row was created.",
}, 409);
// Prune stale issued pairs past the grace window (keep recently expired entries
@@ -328,6 +328,7 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono {
optionId?: string;
notify?: boolean;
auditNotes?: Record<string, unknown>;
authority?: string;
hostId?: string;
}>()
.catch(() => ({} as never));
@@ -434,6 +435,9 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono {
...(typeof body.optionId === "string" ? { optionId: body.optionId } : {}),
notify: body.notify,
auditNotes: body.auditNotes,
// The person at the terminal (the TUI sends no seat header): the actor is the primary user, whoever was asked.
// A seat's own transport identity is never replaced by it.
...(body.authority === "primary-user" && identity.provenance === "claimed:v1" ? { authority: "primary-user" as const } : {}),
});
return c.json(result);
} catch (err) {
@@ -0,0 +1,134 @@
// The Feed's perspective: whose queue it shows. Viewing a colleague's queue is a view, never an identity: whatever is shown,
// acts on it carry the primary user's authority, the person at the terminal (the default person). Real SQLite (all
// migrations), people registered in a temporary home, the real queue and the real attention route.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type Database from "better-sqlite3";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Hono } from "hono";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { addHumanFragment } from "../src/domain/gateway/human-registry.js";
import { setDefaultPerson } from "../src/domain/gateway/participants.js";
import { attentionRoutes } from "../src/routes/attention.js";
import type { AttentionRead } from "../src/attention-surface.js";
describe("the Feed shows one person's queue, or everyone's, and acts with the primary user's authority whichever it shows", () => {
let db: Database.Database;
let repo: QueueRepository;
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "attention-perspective-"));
vi.stubEnv("OPENRIG_HOME", home);
db = createDb();
migrate(db, ALL_MIGRATIONS);
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
});
afterEach(() => { vi.unstubAllEnvs(); db.close(); rmSync(home, { recursive: true, force: true }); });
const register = (...ids: string[]) => {
for (const entityId of ids) expect(addHumanFragment({
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: `vault://slack/${entityId}`, role: "primary", handle: `U${entityId.toUpperCase()}` }],
prefs: { deliveryClass: "B" },
}, home)).toMatchObject({ ok: true });
};
const askTo = (destinationSession: string, summary: string) =>
repo.create({ sourceSession: "orch-lead@r", destinationSession, body: `${summary} body`, summary, evidenceRef: "/fixture/ask", nudge: false });
async function read(query = ""): Promise<AttentionRead> {
const app = new Hono();
app.use("*", async (c, next) => { c.set("queueRepo" as never, repo as never); await next(); });
app.route("/api/attention", attentionRoutes());
return await (await app.request(`/api/attention${query}`)).json() as AttentionRead;
}
const asked = (r: AttentionRead) => r.items.filter((i) => i.id.startsWith("queue:")).map((i) => i.summary).sort();
it("with no view it shows every person's asks, as before, and says who acts", async () => {
register("mike", "sarah");
await askTo("mike@external", "Mike's ask");
await askTo("sarah@external", "Sarah's ask");
const all = await read();
expect(asked(all)).toEqual(["Mike's ask", "Sarah's ask"]);
// Two people and no default: the primary user acts, unnamed.
expect(all.perspective).toEqual({ viewing: null, basis: "all", defaultSource: "none", actingAs: { person: null, authority: "primary-user" } });
});
it("viewing one person shows only their asks, and doesn't change who acts or how", async () => {
register("mike", "sarah");
expect(setDefaultPerson("mike", home).ok).toBe(true);
const mike = await askTo("mike@external", "Mike's ask");
const sarah = await askTo("sarah@external", "Sarah's ask");
const all = await read("?view=all");
for (const view of ["sarah", "sarah@external"]) {
const r = await read(`?view=${encodeURIComponent(view)}`);
expect(asked(r)).toEqual(["Sarah's ask"]);
// Viewing Sarah is not acting as Sarah: Mike, the default, acts.
expect(r.perspective).toEqual({ viewing: "sarah@external", basis: "chosen-here", defaultSource: "chosen",
actingAs: { person: "mike@external", authority: "primary-user" } });
// The act on Sarah's ask is the same one the unfiltered Feed offers.
expect(r.items.find((i) => i.id === `queue:${sarah.qitemId}`)).toEqual(all.items.find((i) => i.id === `queue:${sarah.qitemId}`));
}
// An item opened by id is still shown in full, whichever queue is viewed.
const opened = await read(`?view=sarah&item=${encodeURIComponent(`queue:${mike.qitemId}`)}`);
expect(opened.detail?.item.id).toBe(`queue:${mike.qitemId}`);
expect(asked(all)).toEqual(["Mike's ask", "Sarah's ask"]);
});
it("with a chosen default, the default view is that person's, and says it was chosen", async () => {
register("mike", "sarah");
expect(setDefaultPerson("sarah", home).ok).toBe(true);
await askTo("mike@external", "Mike's ask");
await askTo("sarah@external", "Sarah's ask");
const r = await read("?view=default");
expect(asked(r)).toEqual(["Sarah's ask"]);
expect(r.perspective).toEqual({ viewing: "sarah@external", basis: "default", defaultSource: "chosen",
actingAs: { person: "sarah@external", authority: "primary-user" } });
});
it("with one person and no chosen default, they're the default and the primary user, and it says no one chose them", async () => {
register("mike");
await askTo("mike@external", "Mike's ask");
const r = await read("?view=default");
expect(asked(r)).toEqual(["Mike's ask"]);
expect(r.perspective).toEqual({ viewing: "mike@external", basis: "default", defaultSource: "only-person",
actingAs: { person: "mike@external", authority: "primary-user" } });
});
it("with several people and no default, the default view is everyone's, and says so", async () => {
register("mike", "sarah");
await askTo("mike@external", "Mike's ask");
await askTo("sarah@external", "Sarah's ask");
const r = await read("?view=default");
expect(asked(r)).toEqual(["Mike's ask", "Sarah's ask"]);
expect(r.perspective).toMatchObject({ viewing: null, basis: "all", defaultSource: "none" });
});
it("a view naming no registered person shows no one's asks, and names what was asked for", async () => {
register("mike");
await askTo("mike@external", "Mike's ask");
const r = await read("?view=ana");
expect(asked(r)).toEqual([]);
expect(r.perspective).toEqual({ viewing: null, basis: "unknown", requested: "ana", defaultSource: "only-person",
actingAs: { person: "mike@external", authority: "primary-user" } });
});
it("a row parked on a person is resolved from the Feed in words, as an ask to them is answered", async () => {
register("mike", "sarah");
const work = await repo.create({ sourceSession: "orch-lead@r", destinationSession: "designer@rig", body: "Layout", summary: "Layout", nudge: false });
await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "sarah@external" } as never);
const item = (await read("?view=sarah")).items.find((i) => i.id === `queue:${work.qitemId}`);
expect(item).toMatchObject({ recipient: "sarah@external", answer: { verb: "resolve", actorSession: "sarah@external" } });
});
it("with no registered person there is no perspective, and the Feed is as before", async () => {
await askTo("human@host", "A seat's ask");
const r = await read("?view=default");
expect(r.perspective).toBeUndefined();
expect(asked(r)).toEqual(["A seat's ask"]);
});
});
@@ -100,19 +100,18 @@ describe("gateway human lifecycle (S12, amended A1: single-human surface): list
}
});
it("A1 advisory receipt: several fragments render HONESTLY plus the one-human advisory (display, never management)", () => {
seedAna(home); // hand-authored second human — the amendment's multi-fragment case
it("several people list as a supported registry, sorted, with no single-person advisory (0.7.3 participants)", () => {
seedAna(home);
const res = listHumans(home);
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.humans.map((h) => h.entityId)).toEqual(["ana", "mike"]); // honest, sorted display
expect(res.humans.map((h) => h.entityId)).toEqual(["ana", "mike"]);
const ana = res.humans[0]!;
expect(ana.deliveryClass).toBe("A");
expect(ana.away).toBe(true);
expect(ana.bindings.count).toBe(2);
expect(ana.bindings.inboundResolvable).toBe(true);
expect(res.advisory).toContain("one configured human");
expect(res.advisory).not.toMatch(/\d+\.\d+\.\d+/); // no promise of a release that manages several
expect(res).not.toHaveProperty("advisory");
});
it("list is a READ: it does not touch the projection file (byte receipt)", () => {
@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parse as parseYaml } from "yaml";
import { addHumanFragment, humansDir, projectionPath } from "../src/domain/gateway/human-registry.js";
import {
participantsPath, participantsView, readParticipantsFile, removeParticipantEntry, resolveDefaultPerson, updateParticipantsFile,
} from "../src/domain/gateway/participants.js";
import { projectHumans } from "../src/domain/gateway/human-registry.js";
// The participant side file: organizations, groups, readable policy, shared channels and the default person live in
// gateway/participants.yaml, never in a person's fragment, so a v0.7.0 daemon (which refuses a fragment with any key it
// doesn't know) still loads every person after a rollback.
const V070_FRAGMENT_KEYS = ["address", "class", "connectorBindings", "displayName", "entityId", "prefs"];
const addPerson = (home: string, entityId: string, handle: string) => {
const added = addHumanFragment({
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: `vault://slack/${entityId}`, role: "primary", handle }],
prefs: { deliveryClass: "B" },
}, home);
expect(added.ok, added.ok ? "" : added.error).toBe(true);
};
const ORG_POLICY = "Answer Acme's customers within a working day. Never promise a date.";
describe("the participant side file", () => {
let home: string;
beforeEach(() => { home = mkdtempSync(join(tmpdir(), "participants-")); });
afterEach(() => rmSync(home, { recursive: true, force: true }));
const organize = () => updateParticipantsFile((file) => {
file.organizations.acme = { name: "Acme", policy: ORG_POLICY };
file.groups.support = { organization: "acme", name: "Support", policy: "Support answers billing questions." };
file.participants.sarah = { kind: "person", organization: "acme", groups: ["support"], policy: "Sarah approves refunds." };
file.participants["acme-customers"] = {
kind: "shared-channel", displayName: "Acme customers", organization: "acme", groups: ["support"],
channel: { connector: "slack", connectorRef: "primary", secretsRef: "vault://slack/bot", channelId: "C0ACME" },
};
file.default = "sarah";
return file;
}, home);
it("registers people and a shared channel with organization, groups, policy and a default, and lists and shows them", () => {
addPerson(home, "mike", "UMIKE");
addPerson(home, "sarah", "USARAH");
expect(organize()).toMatchObject({ ok: true, warnings: [] });
const view = participantsView(home);
if (!view.ok) throw new Error(view.error);
// Sorted by id, people and channels together.
expect(view.participants.map((p) => [p.id, p.kind, p.address, p.default])).toEqual([
["acme-customers", "shared-channel", "acme-customers@external", false],
["mike", "person", "mike@external", false],
["sarah", "person", "sarah@external", true],
]);
const sarah = view.participants.find((p) => p.id === "sarah")!;
expect(sarah).toMatchObject({ organization: { id: "acme", name: "Acme" }, groups: [{ id: "support", name: "Support" }] });
// Policy is text, outermost first, each part saying where it came from.
expect(sarah.policy).toEqual([
{ source: "organization acme (Acme)", text: ORG_POLICY },
{ source: "group support (Support)", text: "Support answers billing questions." },
{ source: "participant", text: "Sarah approves refunds." },
]);
expect(view.participants.find((p) => p.id === "acme-customers")).toMatchObject({ channel: { connector: "slack", channelId: "C0ACME" } });
});
it("never touches a person's fragment or the generated projection: both stay byte-identical, with v0.7.0's keys only", () => {
addPerson(home, "mike", "UMIKE");
addPerson(home, "sarah", "USARAH");
const fragmentsBefore = readdirSync(humansDir(home)).filter((f) => f.endsWith(".yaml")).map((f) => readFileSync(join(humansDir(home), f), "utf8"));
const projectionBefore = readFileSync(projectionPath(home), "utf8");
expect(organize().ok).toBe(true);
expect(readdirSync(humansDir(home)).filter((f) => f.endsWith(".yaml")).map((f) => readFileSync(join(humansDir(home), f), "utf8"))).toEqual(fragmentsBefore);
expect(readFileSync(projectionPath(home), "utf8")).toBe(projectionBefore);
for (const body of fragmentsBefore) expect(Object.keys(parseYaml(body) as object).sort()).toEqual(V070_FRAGMENT_KEYS);
// The new facts are all in the side file, outside the fragment folder.
expect(participantsPath(home)).toBe(join(home, "gateway", "participants.yaml"));
});
it("picks the default: the chosen person, else the only person, else none with the reason, never the first of several", () => {
addPerson(home, "mike", "UMIKE");
const people = () => { const p = projectHumans(home); if (!p.ok) throw new Error(p.error); return p.entities; };
expect(resolveDefaultPerson(people(), home)).toMatchObject({ source: "only-person", person: { entityId: "mike" } });
addPerson(home, "sarah", "USARAH");
const none = resolveDefaultPerson(people(), home);
expect(none).toMatchObject({ source: "none", person: null });
expect(none.source === "none" && none.reason).toContain("rig gateway default");
expect(updateParticipantsFile((file) => ({ ...file, default: "sarah" }), home).ok).toBe(true);
expect(resolveDefaultPerson(people(), home)).toMatchObject({ source: "chosen", person: { entityId: "sarah" } });
});
it("refuses a shared channel as the default, a channel that takes a person's id, an unknown group, and an unknown key", () => {
addPerson(home, "mike", "UMIKE");
expect(organize()).toMatchObject({ ok: false });
addPerson(home, "sarah", "USARAH");
expect(organize().ok).toBe(true);
const refuse = (change: Parameters<typeof updateParticipantsFile>[0]) => {
const result = updateParticipantsFile(change, home);
expect(result.ok).toBe(false);
return result.ok ? "" : result.error;
};
expect(refuse((file) => ({ ...file, default: "acme-customers" }))).toContain("shared channel");
expect(refuse((file) => { file.participants.mike = { kind: "shared-channel", displayName: "x", channel: { connector: "slack", connectorRef: "p", secretsRef: "v", channelId: "C1" } }; return file; }))
.toContain("already a person's id");
expect(refuse((file) => { file.participants.mike = { kind: "person", groups: ["nope"] }; return file; })).toContain("isn't a known group");
expect(refuse((file) => ({ ...file, role: "admin" } as never))).toContain('unknown key "role"');
});
it("a person removed by hand leaves a warning, not a broken file; the rest still reads", () => {
addPerson(home, "mike", "UMIKE");
addPerson(home, "sarah", "USARAH");
expect(organize().ok).toBe(true);
unlinkSync(join(humansDir(home), "sarah.yaml"));
const read = readParticipantsFile(home, new Set(["mike"]));
if (!read.ok) throw new Error(read.error);
expect(read.file.default).toBeUndefined();
expect(read.file.participants.sarah).toBeUndefined();
expect(read.file.participants["acme-customers"]).toBeDefined();
expect(read.warnings).toHaveLength(2);
});
it("an unreadable side file still lists every person and says why the rest is missing; the person registry keeps working", () => {
addPerson(home, "mike", "UMIKE");
addPerson(home, "sarah", "USARAH");
writeFileSync(participantsPath(home), "version: 1\norganizations: [not, a, mapping]\n");
const view = participantsView(home);
if (!view.ok) throw new Error(view.error);
expect(view.participants.map((p) => p.id)).toEqual(["mike", "sarah"]);
expect(view.sideFile.error).toContain("organizations must be a mapping");
expect(view.default).toMatchObject({ source: "none" });
expect(projectHumans(home).ok).toBe(true);
});
it("refuses a person who'd take a shared channel's id, before any write: the channel, the default and the file stay intact", () => {
addPerson(home, "mike", "UMIKE");
addPerson(home, "sarah", "USARAH");
expect(organize().ok).toBe(true);
const sideBefore = readFileSync(participantsPath(home), "utf8");
const peopleBefore = readdirSync(humansDir(home)).sort();
const added = addHumanFragment({
entityId: "acme-customers", class: "human", displayName: "Someone", address: "acme-customers@external",
connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: "vault://slack/someone", role: "primary", handle: "USOMEONE" }],
prefs: { deliveryClass: "B" },
}, home);
expect(added.ok).toBe(false);
expect(added.ok ? "" : added.error).toContain('already the shared channel "Acme customers"');
expect(readdirSync(humansDir(home)).sort()).toEqual(peopleBefore);
expect(readFileSync(participantsPath(home), "utf8")).toBe(sideBefore);
const view = participantsView(home);
if (!view.ok) throw new Error(view.error);
expect(view.sideFile.error).toBeUndefined();
expect(view.default).toMatchObject({ source: "chosen", person: { entityId: "sarah" } });
expect(view.participants.find((p) => p.id === "acme-customers")?.kind).toBe("shared-channel");
});
it("removing a person's entry never removes a shared channel with that id", () => {
addPerson(home, "mike", "UMIKE");
addPerson(home, "sarah", "USARAH");
expect(organize().ok).toBe(true);
expect(removeParticipantEntry("acme-customers", home, { kind: "person" }).ok).toBe(true);
const read = readParticipantsFile(home, new Set(["mike", "sarah"]));
if (!read.ok) throw new Error(read.error);
expect(read.file.participants["acme-customers"]?.kind).toBe("shared-channel");
expect(read.file.default).toBe("sarah");
});
});
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import Database from "better-sqlite3";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { addHumanFragment } from "../src/domain/gateway/human-registry.js";
import { setDefaultPerson } from "../src/domain/gateway/participants.js";
import { makeOperatorDeliveryEngine } from "../src/domain/gateway/operator-delivery-engine.js";
import { MissionControlReadLayer } from "../src/domain/mission-control/mission-control-read-layer.js";
// With several people registered, anything that needs a person without naming one goes to the default person, never
// to whoever's fragment sorts first; with none chosen, it says so. One person stays the default without any choice.
const person = (home: string, entityId: string) => expect(addHumanFragment({
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: `vault://slack/${entityId}`, role: "primary", handle: `U${entityId.toUpperCase()}` }],
prefs: { deliveryClass: "B" },
}, home)).toMatchObject({ ok: true });
describe("the default person, for escalations and Mission Control's my-queue", () => {
let home: string;
let db: Database.Database;
let repo: QueueRepository;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "default-consumers-"));
vi.stubEnv("OPENRIG_HOME", home);
db = new Database(":memory:"); migrate(db, ALL_MIGRATIONS);
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
});
afterEach(() => { vi.unstubAllEnvs(); db.close(); rmSync(home, { recursive: true, force: true }); });
const escalate = async () => {
const sentTo: string[] = [];
const engine = makeOperatorDeliveryEngine({ home, queueRepo: repo,
dispatch: (_op, ref, _payload, opts) => { sentTo.push(ref); return { ok: true, decisionId: opts?.decisionId ?? "d" }; } });
const row = await repo.create({ sourceSession: "sender@fixture", destinationSession: "worker@fixture", body: "stuck work", summary: "stuck", nudge: false });
const result = await engine.dispatchEscalation(row as never, "blocked");
return { result, sentTo };
};
const myQueueDefault = () =>
new MissionControlReadLayer({ queueRepo: repo, viewProjector: {}, fleetCliCapability: {} } as never).getDefaultOperatorSession();
it("one person: the escalation and my-queue go to them, with nothing chosen", async () => {
person(home, "mike");
expect((await escalate()).sentTo).toEqual(["mike@external"]);
expect(myQueueDefault()).toBe("mike@external");
});
it("several people and none chosen: the escalation is undeliverable and says why; my-queue has no default", async () => {
person(home, "mike"); person(home, "sarah");
const { result, sentTo } = await escalate();
expect(result).toMatchObject({ decision: "undeliverable:no-default-person", dispatched: false });
expect(sentTo).toEqual([]);
expect(myQueueDefault()).toBe("");
});
it("several people with a default: both go to the default, not the first by id", async () => {
person(home, "mike"); person(home, "sarah");
expect(setDefaultPerson("sarah", home).ok).toBe(true);
expect((await escalate()).sentTo).toEqual(["sarah@external"]);
expect(myQueueDefault()).toBe("sarah@external");
});
});
@@ -0,0 +1,154 @@
// Primary-user actions: the person at the terminal acts on anyone's ask, and the record says both who acted and who was
// asked. A registered person without that authority can't settle another person's ask. Real SQLite (all migrations),
// people registered in a temporary home, and the real Mission Control route and write contract, called with no seat
// header, as the TUI calls it.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type Database from "better-sqlite3";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Hono } from "hono";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
import { missionControlRoutes } from "../src/routes/mission-control.js";
import { addHumanFragment } from "../src/domain/gateway/human-registry.js";
import { setDefaultPerson } from "../src/domain/gateway/participants.js";
const ASKER = "writer@rig";
describe("the primary user acts on anyone's ask, recorded as themselves on that person's ask", () => {
let db: Database.Database;
let repo: QueueRepository;
let home: string;
let app: Hono;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "primary-user-actions-"));
vi.stubEnv("OPENRIG_HOME", home);
db = createDb();
migrate(db, ALL_MIGRATIONS);
const bus = new EventBus(db);
repo = new QueueRepository(db, bus, { validateRig: () => true });
const contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
app = new Hono();
app.use("*", async (c, next) => { c.set("queueRepo" as never, repo as never); c.set("eventBus" as never, bus as never); c.set("missionControlWriteContract" as never, contract as never); await next(); });
app.route("/api/mission-control", missionControlRoutes());
});
afterEach(() => { vi.unstubAllEnvs(); db.close(); rmSync(home, { recursive: true, force: true }); });
const register = (...ids: string[]) => {
for (const entityId of ids) expect(addHumanFragment({
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: `vault://slack/${entityId}`, role: "primary", handle: `U${entityId.toUpperCase()}` }],
prefs: { deliveryClass: "B" },
}, home)).toMatchObject({ ok: true });
};
const ask = (to: string, extra: Record<string, unknown> = {}) => repo.create({ sourceSession: ASKER, destinationSession: to, body: "Ship the draft?",
summary: "Ship the draft?", humanIntent: "decision", evidenceRef: "/fixture/ask", nudge: false, ...extra } as never);
async function act(body: Record<string, unknown>, headers: Record<string, string> = {}) {
const response = await app.request("/api/mission-control/action", { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(body) });
return { status: response.status, body: await response.json() as Record<string, unknown> };
}
const lastTransition = (qitemId: string) => db.prepare("SELECT state, actor_session, transition_note FROM queue_transitions WHERE qitem_id = ? ORDER BY transition_id DESC LIMIT 1")
.get(qitemId) as { state: string; actor_session: string; transition_note: string };
const audit = (qitemId: string) => {
const row = db.prepare("SELECT action_verb, actor_session, audit_notes_json FROM mission_control_actions WHERE qitem_id = ? ORDER BY rowid DESC LIMIT 1")
.get(qitemId) as { action_verb: string; actor_session: string; audit_notes_json: string | null };
return { verb: row.action_verb, actor: row.actor_session, notes: row.audit_notes_json ? JSON.parse(row.audit_notes_json) : null };
};
it("answers Sarah's ask in the primary user's words: Mike answered, on Sarah's ask, and the asker reads exactly that", async () => {
register("mike", "sarah"); expect(setDefaultPerson("mike", home).ok).toBe(true);
const sarahs = await ask("sarah@external");
// The TUI claims the actor it shows; the daemon records the primary user whatever the claim.
const result = await act({ verb: "answer", qitemId: sarahs.qitemId, actorSession: "sarah@external", authority: "primary-user", decision: "Ship it" });
expect(result.status).toBe(200);
expect(repo.getById(sarahs.qitemId)?.state).toBe("done");
expect(lastTransition(sarahs.qitemId)).toMatchObject({ state: "done", actor_session: "mike@external" });
expect(lastTransition(sarahs.qitemId).transition_note).toContain("mike@external, the primary user at the terminal, on sarah@external's ask; not answered by sarah@external");
expect(audit(sarahs.qitemId)).toEqual({ verb: "answer", actor: "mike@external", notes: { authority: "primary-user", asked: "sarah@external" } });
const reply = repo.list({ limit: 50 }).find((item) => item.replyTo === sarahs.qitemId)!;
expect(reply).toMatchObject({ sourceSession: "mike@external", destinationSession: ASKER, summary: "mike@external answered sarah@external's ask: Ship the draft?" });
expect(reply.body).toContain("Answered by mike@external, the primary user at the terminal, on sarah@external's ask in OpenRig.");
});
it("records a choice on a question ask the same way", async () => {
register("mike", "sarah"); expect(setDefaultPerson("mike", home).ok).toBe(true);
const sarahs = await ask("sarah@external", { humanQuestions: [{ id: "go", question: "Ship?", options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] }] });
const result = await act({ verb: "answer", qitemId: sarahs.qitemId, actorSession: "mike@external", authority: "primary-user", questionId: "go", optionId: "yes" });
expect(result.status).toBe(200);
expect(repo.getById(sarahs.qitemId)).toMatchObject({ state: "done", humanAnswers: { go: "yes" } });
expect(audit(sarahs.qitemId).notes).toEqual({ authority: "primary-user", asked: "sarah@external" });
expect(repo.list({ limit: 50 }).find((item) => item.replyTo === sarahs.qitemId)?.sourceSession).toBe("mike@external");
});
it("approves Sarah's pending ask and resolves a row parked on her, recorded as Mike on her ask", async () => {
register("mike", "sarah"); expect(setDefaultPerson("mike", home).ok).toBe(true);
const pending = await ask("sarah@external");
expect((await act({ verb: "approve", qitemId: pending.qitemId, actorSession: "mike@external", authority: "primary-user" })).status).toBe(200);
expect(lastTransition(pending.qitemId)).toMatchObject({ state: "done", actor_session: "mike@external" });
expect(lastTransition(pending.qitemId).transition_note).toBe("mission-control:approve; by mike@external, the primary user at the terminal, on sarah@external's ask");
expect(audit(pending.qitemId)).toEqual({ verb: "approve", actor: "mike@external", notes: { authority: "primary-user", asked: "sarah@external" } });
const work = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: "Layout", summary: "Layout", nudge: false });
await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "human-sarah@host" } as never);
expect((await act({ verb: "resolve", qitemId: work.qitemId, actorSession: "mike@external", authority: "primary-user", decision: "Use the wide layout" })).status).toBe(200);
expect(lastTransition(work.qitemId)).toMatchObject({ state: "in-progress", actor_session: "mike@external",
transition_note: "Use the wide layout; by mike@external, the primary user at the terminal, on human-sarah@host's ask" });
expect(audit(work.qitemId).notes).toEqual({ authority: "primary-user", asked: "human-sarah@host" });
});
it("resolves a row parked on a registered person's address, which the Feed lists, and only with the authority", async () => {
register("mike", "sarah"); expect(setDefaultPerson("mike", home).ok).toBe(true);
const work = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: "Layout", summary: "Layout", nudge: false });
await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "sarah@external" } as never);
expect(await act({ verb: "resolve", qitemId: work.qitemId, actorSession: "mike@external", decision: "Wide" })).toMatchObject({ status: 400, body: { error: "answer_not_recorded" } });
expect((await act({ verb: "resolve", qitemId: work.qitemId, actorSession: "mike@external", authority: "primary-user", decision: "Wide" })).status).toBe(200);
expect(lastTransition(work.qitemId)).toMatchObject({ state: "in-progress", actor_session: "mike@external",
transition_note: "Wide; by mike@external, the primary user at the terminal, on sarah@external's ask" });
});
it("with several people and no default, the primary user is unnamed and recorded as the generic human seat", async () => {
register("mike", "sarah");
const sarahs = await ask("sarah@external");
expect((await act({ verb: "approve", qitemId: sarahs.qitemId, actorSession: "sarah@external", authority: "primary-user" })).status).toBe(200);
expect(audit(sarahs.qitemId)).toEqual({ verb: "approve", actor: "human@host", notes: { authority: "primary-user", asked: "sarah@external" } });
});
it("a registered person without that authority can't settle another person's ask, pending or parked", async () => {
register("mike", "sarah");
const pending = await ask("sarah@external");
for (const body of [{ verb: "approve" }, { verb: "deny", reason: "no" }, { verb: "answer", decision: "Ship it" }]) {
const refused = await act({ qitemId: pending.qitemId, actorSession: "mike@external", ...body });
expect(refused).toMatchObject({ status: 400, body: { error: "answer_not_recorded" } });
}
expect(repo.getById(pending.qitemId)?.state).toBe("pending");
const work = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: "Layout", summary: "Layout", nudge: false });
await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "human-sarah@host" } as never);
expect(await act({ verb: "resolve", qitemId: work.qitemId, actorSession: "mike@external", decision: "Wide" })).toMatchObject({ status: 400, body: { error: "answer_not_recorded" } });
expect(repo.getById(work.qitemId)?.state).toBe("blocked");
// Sarah settles her own.
expect((await act({ verb: "approve", qitemId: pending.qitemId, actorSession: "sarah@external" })).status).toBe(200);
expect(audit(pending.qitemId)).toEqual({ verb: "approve", actor: "sarah@external", notes: null });
});
it("a seat's own transport identity is never replaced by the authority", async () => {
register("mike", "sarah"); expect(setDefaultPerson("mike", home).ok).toBe(true);
const sarahs = await ask("sarah@external");
expect((await act({ verb: "approve", qitemId: sarahs.qitemId, actorSession: "mike@external", authority: "primary-user" }, { "X-OpenRig-Session": "orch@rig" })).status).toBe(200);
expect(audit(sarahs.qitemId)).toEqual({ verb: "approve", actor: "orch@rig", notes: null });
});
it("with no one registered, acts are recorded as the claimed actor, as before", async () => {
const legacy = await ask("human@host");
expect((await act({ verb: "answer", qitemId: legacy.qitemId, actorSession: "human@host", authority: "primary-user", decision: "Yes" })).status).toBe(200);
expect(audit(legacy.qitemId)).toEqual({ verb: "answer", actor: "human@host", notes: { authority: "primary-user", asked: "human@host" } });
expect(repo.list({ limit: 50 }).find((item) => item.replyTo === legacy.qitemId)?.summary).toBe("human@host answered: Ship the draft?");
});
});
@@ -0,0 +1,118 @@
// Test-only: the real inbound router, queue ports, reply resolver and write contract over migrated SQLite, with two
// registered people. Slack itself isn't involved; events are handed to the router as Slack would deliver them.
import { afterEach, describe, expect, it } from "vitest";
import Database from "better-sqlite3";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
import { InboundRouter } from "../src/domain/gateway/slack/inbound.js";
import { makeQueuePorts } from "../src/domain/gateway/slack/queue-access.js";
import { makeHumanReplyResolver } from "../src/domain/gateway/slack/slack-subsystem.js";
import { SeenStore, DeadLetterStore } from "../src/domain/gateway/slack/state-store.js";
import type { HumanFragment } from "../src/domain/gateway/human-registry.js";
// With several registered people, a person who wasn't asked can still write in the asked person's Slack thread. Their
// words reach the agent as their own message, and never become the asked person's answer: not on a pending ask, typed
// or clicked, and not on a question parked on someone else.
const person = (entityId: string, handle: string): HumanFragment => ({
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: "unused", role: "primary", handle }],
prefs: { deliveryClass: "A" },
});
const ANA = "human-ana@external", MIKE = "human-mike@external", ASKER = "requester@test-rig";
const registry = { ok: true as const, entities: [person("human-ana", "UANA"), person("human-mike", "UMIKE")] };
const sources: Record<string, string> = { UANA: ANA, UMIKE: MIKE };
const questions = [{ id: "choice", question: "Proceed?", options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] }];
const closes: Array<() => void> = [];
afterEach(() => { for (const close of closes.splice(0).reverse()) close(); });
async function fixture(kind: "pending" | "parked") {
const home = mkdtempSync(join(tmpdir(), "wrong-answerer-"));
const db = new Database(join(home, "queue.sqlite")); migrate(db, ALL_MIGRATIONS); const bus = new EventBus(db);
const repo = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => registry,
transport: { send: async () => ({ ok: true, verified: true, sent: true }) } });
const contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
let ask: { qitemId: string };
if (kind === "pending") {
// A direct ask to Ana, waiting for her answer.
ask = await repo.create({ sourceSession: ASKER, destinationSession: ANA, body: "Synthetic ask", summary: "Proceed?",
humanIntent: "decision", humanQuestions: questions, evidenceRef: "/fixture/decision", nudge: false });
} else {
// Work parked on Ana, and posted to her in Slack, as a real post records it.
ask = await repo.create({ sourceSession: ASKER, destinationSession: "dev@test-rig", body: "work", nudge: false });
repo.update({ qitemId: ask.qitemId, actorSession: "dev@test-rig", state: "blocked", blockedOn: "human-ana@host", summary: "Parked",
evidenceRef: "/proof/parked.md", transitionNote: "parked on Ana" });
const episode = repo.listTransitions(ask.qitemId).filter((t) => t.ownerNotificationKind === "human-required").at(-1)!;
repo.update({ qitemId: ask.qitemId, actorSession: "openrig-slack",
transitionNote: `slack-owner-notification-posted notification_key=${ask.qitemId}:${episode.transitionId} kind=human-required thread_ts=1.1 message_ts=1.5` });
}
const router = new InboundRouter({ queue: makeQueuePorts(repo), destination: ASKER, seen: new SeenStore(join(home, "seen.jsonl")),
deadLetter: new DeadLetterStore(join(home, "dead.jsonl")),
resolveSender: (user: string) => (sources[user] ? { admitted: true, source: sources[user]! } : { admitted: false, teaching: "unregistered" }),
resolveRoute: () => ({ destination: ASKER, correlationQitemId: ask.qitemId }),
acknowledgeAnswer: async () => {}, recordHumanAnswer: (input) => repo.recordHumanAnswer(input),
resolveHumanReply: makeHumanReplyResolver(repo, contract) });
closes.push(() => { db.close(); rmSync(home, { recursive: true, force: true }); });
const typed = (user: string, text: string, ts: string) =>
router.route({ type: "message", user, channel: "CTEST", thread_ts: "1.1", ts, text });
const clicked = (user: string, actionTs: string) => router.routeAction({ type: "block_actions", user: { id: user }, channel: { id: "CTEST" },
container: { type: "message", message_ts: "1.1", channel_id: "CTEST" }, message: { ts: "1.1" },
actions: [{ type: "button", block_id: "or-q:choice", action_id: "or-opt:yes", value: "yes", action_ts: actionTs }] });
const state = () => {
const row = repo.getById(ask.qitemId)!;
const replies = repo.list({ limit: 100 }).filter((item) => item.replyTo === ask.qitemId);
return {
state: row.state, blockedOn: row.blockedOn ?? null, answers: row.humanAnswers ?? null,
resolvedBy: repo.listTransitions(ask.qitemId).filter((t) => t.ownerNotificationKind === "human-decision-resolved").map((t) => t.actorSession),
replies: replies.map((item) => ({ source: item.sourceSession, destination: item.destinationSession, humanAnswer: (item.tags ?? []).includes("human-answer") })),
};
};
return { typed, clicked, state };
}
describe("a reply from a registered person who wasn't asked", () => {
it("typed in a pending ask's thread: reaches the agent as their own message; the ask stays open and unanswered", async () => {
const f = await fixture("pending");
await f.typed("UMIKE", "I'd say yes", "3.3");
expect(f.state()).toEqual({ state: "pending", blockedOn: null, answers: null, resolvedBy: [],
replies: [{ source: MIKE, destination: ASKER, humanAnswer: false }] });
});
it("clicked on a pending ask's button: records nothing as the asked person's answer", async () => {
const f = await fixture("pending");
await f.clicked("UMIKE", "2.2");
expect(f.state()).toMatchObject({ state: "pending", answers: null, resolvedBy: [] });
expect(f.state().replies.filter((reply) => reply.humanAnswer)).toEqual([]);
// The same click from the person asked does record their answer, so the refusal above is about who clicked.
await f.clicked("UANA", "2.3");
expect(f.state()).toMatchObject({ state: "done", answers: { choice: "yes" }, resolvedBy: [ANA] });
});
it("typed on a question parked on someone else: reaches the agent and doesn't resume the question", async () => {
const f = await fixture("parked");
await f.typed("UMIKE", "go ahead", "3.3");
expect(f.state()).toMatchObject({ state: "blocked", blockedOn: "human-ana@host", resolvedBy: [] });
expect(f.state().replies).toEqual([{ source: MIKE, destination: ASKER, humanAnswer: false }]);
});
it("the asked person still answers, after the other person wrote, and the answer is theirs", async () => {
const pending = await fixture("pending");
await pending.typed("UMIKE", "I'd say yes", "3.3");
await pending.typed("UANA", "Yes, go ahead", "4.4");
expect(pending.state()).toMatchObject({ state: "done", resolvedBy: [ANA] });
expect(pending.state().replies.filter((reply) => reply.humanAnswer)).toEqual([{ source: ANA, destination: ASKER, humanAnswer: true }]);
const parked = await fixture("parked");
await parked.typed("UMIKE", "go ahead", "3.3");
await parked.typed("UANA", "go ahead", "4.4");
expect(parked.state()).toMatchObject({ state: "in-progress", resolvedBy: [ANA] });
});
});
@@ -278,7 +278,8 @@ describe("an ask to a person is on Steering and the TUI; Slack gets it by their
const read = await feed(app, row);
const item = read.items.find((entry) => entry.id === `queue:${row.qitemId}`)!;
expect(item.answer).toEqual({ verb: "approve", actorSession: `${HUMAN}@kernel` });
expect(read.items.find((entry) => entry.id === `queue:${parked.qitemId}`)?.answer).toBeUndefined();
// A row parked on the person is resolved in words, not approved.
expect(read.items.find((entry) => entry.id === `queue:${parked.qitemId}`)?.answer).toEqual({ verb: "resolve", actorSession: `${HUMAN}@kernel` });
const act = (body: Record<string, unknown>) => app.request("/api/mission-control/action", { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
// The TUI posts the served answer; Steering's Approve posts its own card's body for the twin.
@@ -14,6 +14,7 @@ import { PodRepository } from "../src/domain/pod-repository.js";
import { WorkflowRuntime } from "../src/domain/workflow-runtime.js";
import { resolveWorkflowHumanDestination } from "../src/domain/workflow-human-destination.js";
import { addHumanFragment, writeProjection, projectionPath, type HumanFragment } from "../src/domain/gateway/human-registry.js";
import { setDefaultPerson } from "../src/domain/gateway/participants.js";
import { makeEnsureStuckExceptionItem } from "../src/domain/workflow-exception-escalation.js";
import { workflowRoutes } from "../src/routes/workflow.js";
@@ -96,7 +97,10 @@ describe.each(["serial", "dependency"])("workflow registered-human selection at
it("an explicit existing setting selects among humans and is re-read for the next episode", () => {
add("owner-one"); add("owner-two");
expect(() => resolveWorkflowHumanDestination()).toThrow(/explicitly select/);
expect(() => resolveWorkflowHumanDestination()).toThrow(/choose a default with rig gateway default/);
// The default person, chosen in the participant side file, is the fallback; the explicit setting still wins over it.
expect(setDefaultPerson("owner-one")).toMatchObject({ ok: true });
expect(resolveWorkflowHumanDestination()).toBe("owner-one@external");
vi.stubEnv("OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME", "owner-two@external");
expect(resolveWorkflowHumanDestination()).toBe("owner-two@external");
vi.stubEnv("OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME", "owner-one@external");
+38 -6
View File
@@ -43,7 +43,8 @@ export function composeHumanUpdates(attention: AttentionRead | null, updates: De
export function attentionLines(state: ViewState, snap: FleetSnapshot, width: number): ContentLine[] {
const read = snap.attentionRead;
const lines: ContentLine[] = [{ text: "FEED · Instance / All humans" }];
const lines: ContentLine[] = [{ text: feedTitle(read?.perspective) }];
if (read?.perspective?.defaultSource === "none") lines.push({ text: "No default person is chosen: rig gateway default <id> chooses one." });
if (!read) return wrapDetailLines([...lines, { text: "Unavailable: Feed sources have not answered." }, ...snap.readErrors.map(text => ({ text }))], width);
if (state.attentionOpen) {
const d = read.detail;
@@ -65,9 +66,14 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
const qitemId = d.item.id.startsWith("queue:") ? d.item.id.slice("queue:".length) : null;
if (d.item.answer && qitemId) {
const { verb, actorSession } = d.item.answer;
// Whoever's queue is shown, the act is the primary user's; it says whose ask it settles when that's someone else.
const actor = actingAs(read, actorSession);
const as = `as ${actor}${d.item.recipient && d.item.recipient !== actor && read.perspective ? ` on ${d.item.recipient}'s ask` : ""}`;
const open = (d.item.humanQuestions ?? []).filter(q => answerOf(d.item, q.id) == null);
if (verb === "approve") {
lines.push(listItem(`Approve as ${actorSession} (closes this request, as Steering's Approve does)`, { type: "act", act: "answer", qitemId, verb, actorSession }));
if (verb === "resolve") {
lines.push({ text: `Resolve it in your own words: reply <your decision>, ${as} (it unparks the row for its owner).` });
} else if (verb === "approve") {
lines.push(listItem(`Approve ${as} (closes this request, as Steering's Approve does)`, { type: "act", act: "answer", qitemId, verb, actorSession }));
} else if (!open.length) {
lines.push(listItem(`Send the recorded answers back to ${d.item.asker ?? "the asker"} and close this request`, { type: "act", act: "answer", qitemId, verb, actorSession }));
} else {
@@ -75,8 +81,8 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
for (const o of q.options) lines.push(listItem(`Answer "${q.question}": ${o.label}${o.recommended ? " (recommended)" : ""}`,
{ type: "act", act: "answer", qitemId, verb, actorSession, questionId: q.id, optionId: o.id }));
}
if (verb === "approve" || open.length) {
lines.push({ text: `Or answer in your own words: reply <text>, as ${actorSession}${verb === "answer" ? " (it fills the first unanswered question and closes the request)" : ""}.` });
if (verb !== "resolve" && (verb === "approve" || open.length)) {
lines.push({ text: `Or answer in your own words: reply <text>, ${as}${verb === "answer" ? " (it fills the first unanswered question and closes the request)" : ""}.` });
}
}
// Chat, as on the Feed card: open the asker's terminal (its pod's view) to answer in your own words.
@@ -110,13 +116,39 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
return wrapDetailLines(lines, width);
}
/** The mission-control write for an answer act. Terminal acts carry the primary user's authority; the daemon records who
* that is and whose ask it was. */
export function answerRequest(action: Extract<Action, { type: "act"; act: "answer" }>) {
const { act: _act, type: _type, ...input } = action;
return { ...input, authority: "primary-user" as const };
}
/** Who an act on the Feed is recorded as: the primary user once anyone is registered, else the served actor, as before. */
export function actingAs(read: AttentionRead | null | undefined, served: string): string {
return read?.perspective ? read.perspective.actingAs.person ?? "the primary user" : served;
}
/** The TUI opens on the default person's Feed only when the participants file chose one, and only while the person hasn't
* moved off the opening view. With none chosen it stays on the topology, as before. */
export function opensOnDefaultFeed(read: AttentionRead | null | undefined, state: ViewState, untouched: boolean): boolean {
return untouched && state.section === "topology" && read?.perspective?.defaultSource === "chosen";
}
/** Whose queue the Feed shows, and who acts on it: the primary user, whichever queue is shown. */
function feedTitle(p: AttentionRead["perspective"]): string {
if (!p) return "FEED · Instance / All humans";
const viewing = p.basis === "unknown" ? `no registered person "${p.requested}"` : p.viewing ?? "everyone";
return `FEED · Viewing ${viewing} · you act as ${p.actingAs.person ?? "the primary user"}`;
}
/** The `reply` command on the open request: the person's own words, answered on that row as the person the Feed served. */
export function replyAct(read: AttentionRead | null | undefined, openId: string | null | undefined, text: string): Action {
const item = read?.detail?.item;
if (!item?.answer || item.id !== openId || !item.id.startsWith("queue:") || !text.trim()) {
return { type: "notice", message: "reply answers the open request addressed to a person: open it in the Feed, then reply <your words>." };
}
return { type: "act", act: "answer", qitemId: item.id.slice("queue:".length), verb: "answer", actorSession: item.answer.actorSession, decision: text.trim() };
return { type: "act", act: "answer", qitemId: item.id.slice("queue:".length), verb: item.answer.verb === "resolve" ? "resolve" : "answer",
actorSession: item.answer.actorSession, decision: text.trim() };
}
/** A question's recorded answer, by own key only (a question id like "constructor" isn't an answer). */
+3
View File
@@ -96,6 +96,9 @@ export const COMMAND_REGISTRY: readonly CommandEntry[] = [
build: view => view ? ({ type: "act", act: "open-terminal", view }) : ({ type: "error", message: "terminal needs a view, for example rig:<name>" }) },
{ name: "reply", aliases: [], args: "<your words>", description: "answer the open Feed request in your own words, as the person it's addressed to; it goes back to the asker", context: "standard", sample: "reply Ship it on Friday",
build: text => text.trim() ? ({ type: "attention-reply", text }) : ({ type: "error", message: "reply needs your answer, for example reply Ship it on Friday" }) },
{ name: "whose", aliases: [], args: "<person|all|default>", description: "choose whose queue the Feed shows: one person's, everyone's, or the default person's; you still act as the primary user", context: "standard", sample: "whose default",
complete: () => ["default", "all"],
build: view => view ? ({ type: "attention-view", view }) : ({ type: "error", message: 'whose needs a person, "all" or "default"' }) },
{ name: "attention", aliases: ["needs", "feed"], args: "", description: "inspect human requests and outcome/health updates", context: "standard", sample: "attention", build: () => ({ type: "jump", section: "needs" }) },
{ name: "read", aliases: [], args: "<root>/<path>[#heading]", description: "read a current file within an explicitly configured root", context: "standard", sample: "read workspace/README.md", complete: ({ snapshot }) => (snapshot.fileRoots ?? []).map((root) => `${root.name}/`), build: (value) => {
const slash = value.indexOf("/");
+4 -3
View File
@@ -389,8 +389,9 @@ export class DaemonClient {
* returns the QueueItem or 404). Used for the BLOCKED ON AGENTS bounded per-row
* lookup: blockedOn is a qitem POINTER, so the blocking agent is that qitem's
* owner (destinationSession). NOT a new endpoint. */
humanAttention(item?: string | null) {
return this.get(`/api/attention${item ? `?item=${encodeURIComponent(item)}` : ""}`);
humanAttention(item?: string | null, view?: string | null) {
const query = [item ? `item=${encodeURIComponent(item)}` : null, view ? `view=${encodeURIComponent(view)}` : null].filter(Boolean).join("&");
return this.get(`/api/attention${query ? `?${query}` : ""}`);
}
queueItem(qitemId: string, options: { optional?: boolean } = {}) {
return this.get(`/api/queue/${encodeURIComponent(qitemId)}`, options.optional ? this.optionalFetchImpl : this.fetchImpl);
@@ -402,7 +403,7 @@ export class DaemonClient {
return this.get(`/api/review/fleet`);
}
/** The mission-control write Steering uses to answer a request (POST /api/mission-control/action). */
missionControlAction(input: { verb: "approve" | "answer"; qitemId: string; actorSession: string; questionId?: string; optionId?: string; decision?: string }) {
missionControlAction(input: { verb: "approve" | "answer" | "resolve"; qitemId: string; actorSession: string; questionId?: string; optionId?: string; decision?: string; authority?: "primary-user" }) {
return this.post("/api/mission-control/action", input) as Promise<{ verb: string; qitemId: string; closedQitem: unknown;
answer?: { outcome: "recorded" | "answered"; unanswered: string[]; message?: string } }>;
}
+2 -2
View File
@@ -367,7 +367,7 @@ function agentSpecTruth(raw?: string): { runtime?: string; skills: string[] } {
* avoids re-reading every spec every refresh; the key rolls when the library
* entry's updatedAt changes. Owned by the caller (instance-scoped, no module state). */
export type SpecReviewCache = Map<string, SpecLibraryReviewRead>;
export type HydrateViewContext = Pick<ViewState, "project" | "section" | "viewTab" | "drill" | "file" | "externalUrl" | "terminalView" | "attentionOpen" | "pendingDrill">;
export type HydrateViewContext = Pick<ViewState, "project" | "section" | "viewTab" | "drill" | "file" | "externalUrl" | "terminalView" | "attentionOpen" | "attentionView" | "pendingDrill">;
export async function hydrateSnapshot(
client: DaemonClient,
@@ -400,7 +400,7 @@ export async function hydrateSnapshot(
if (viewContext?.section === "needs") {
const [attention, updates, roots] = await Promise.all([
safe<NonNullable<FleetSnapshot["attentionRead"]>>("Feed", () => client.humanAttention(viewContext.attentionOpen)),
safe<NonNullable<FleetSnapshot["attentionRead"]>>("Feed", () => client.humanAttention(viewContext.attentionOpen, viewContext.attentionView ?? "default")),
safe<DeliveredHumanUpdates>("delivered updates", () => client.humanUpdates()),
safe<Awaited<ReturnType<DaemonClient["fileRoots"]>>>("file-roots", () => client.fileRoots()),
]);
+22 -7
View File
@@ -33,7 +33,8 @@ import { restoreKeyAction, type RestoreInputEvent } from "./crash-cart/restore-i
import { evaluateOneClickGate, restoreConfirmMessage } from "./crash-cart/one-click-gate.js";
import { daemonStartArgs } from "./crash-cart/start-daemon.js";
import { readLocal } from "./local-reading.js";
import { replyAct } from "./attention/attention-model.js";
import { replyAct, opensOnDefaultFeed, actingAs, answerRequest } from "./attention/attention-model.js";
import type { AttentionRead } from "@openrig/daemon/attention";
import { StartupController } from "./startup.js";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
@@ -168,6 +169,19 @@ async function run(): Promise<void> {
} });
return true;
}
// Once, at startup: the chosen default person's Feed opens first (see opensOnDefaultFeed).
let defaultFeedChecked = false;
function openChosenDefaultFeed(): void {
if (defaultFeedChecked || !client) return;
defaultFeedChecked = true;
const revision = inputRevision;
void client.humanAttention(null, "default").then((read) => {
if (!opensOnDefaultFeed(read as AttentionRead | null, view.get(), revision === inputRevision)) return;
view.dispatch({ type: "jump", section: "needs" });
draw();
void live?.refresh();
}, () => {});
}
function commandContext() {
return currentCommandContext(startup && startup.state.connection !== "up" ? "unverified" : crashCartOpts.daemonState ?? null);
}
@@ -235,7 +249,7 @@ async function run(): Promise<void> {
draw();
// Skip during the probe must remain skipped, but can start reads when
// the connection later answers. This never changes the chosen page.
if (!startup?.state.open && startup?.state.connection === "up" && !liveEnabled && enableLive()) void live?.refresh();
if (!startup?.state.open && startup?.state.connection === "up" && !liveEnabled && enableLive()) { void live?.refresh(); openChosenDefaultFeed(); }
},
onHelp: () => { view.dispatch({ type: "palette-open" }); draw(); },
readLocal: (request) => readLocal(cliEntry, request),
@@ -441,12 +455,13 @@ async function run(): Promise<void> {
const result = await client.launchNode(action.rigId, action.agent);
view.dispatch({ type: "notice", message: launchNodeNotice(action.agent, result) });
} else if (action.act === "answer") {
const { act: _act, type: _type, ...input } = action;
const result = await client.missionControlAction(input);
view.dispatch({ type: "notice", message: action.verb === "approve"
? `Approved ${action.qitemId} as ${action.actorSession}: the request is closed on its own row.`
const result = await client.missionControlAction(answerRequest(action));
const actor = actingAs(snapshot.attentionRead, action.actorSession);
view.dispatch({ type: "notice", message: action.verb === "resolve" ? `Resolved ${action.qitemId} as ${actor}: it's unparked for its owner, with your decision.`
: action.verb === "approve"
? `Approved ${action.qitemId} as ${actor}: the request is closed on its own row.`
: result.answer?.message ?? (result.answer?.outcome === "recorded" ? `Recorded on ${action.qitemId}. Still to answer: ${result.answer.unanswered.join("; ")}`
: `Answered ${action.qitemId} as ${action.actorSession}: the answer went back to the asker, and the request is closed.`) });
: `Answered ${action.qitemId} as ${actor}: the answer went back to the asker, and the request is closed.`) });
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
+1 -1
View File
@@ -74,5 +74,5 @@ export class PageRead {
export function pageReadKey(s: ViewState): string {
return JSON.stringify([s.section, s.viewTab, s.drill, s.project, s.scopesMission,
s.scopesSelected, s.executionOpen, s.file && [s.file.root, s.file.path],
s.externalUrl, s.terminalView, s.attentionOpen, s.pendingDrill]);
s.externalUrl, s.terminalView, s.attentionOpen, s.pendingDrill, s.attentionView ?? null]);
}
+5 -1
View File
@@ -86,12 +86,13 @@ export function createViewState(options: CreateViewStateOptions): ViewStateStore
executionOpen: null,
healthOpen: null,
attentionOpen: null,
attentionView: null,
};
const listeners = new Set<(s: ViewState) => void>();
function dispatch(action: Action): ViewState {
const previous = state;
if (["attention-category", "attention-open", "terminal-preview", "project-select", "jump", "drill", "cross", "tab", "scopes-mission-open", "scopes-open", "health-open", "execution-open", "recent-open", "timezone", "config-category", "config-setting"].includes(action.type)) state = { ...state, file: null, externalUrl: null, recentOpen: null, timeZoneHelp: false, attentionOpen: null, specLaunch: null, ...(state.pendingDrill ? { pendingDrill: null } : {}) };
if (["attention-category", "attention-open", "attention-view", "terminal-preview", "project-select", "jump", "drill", "cross", "tab", "scopes-mission-open", "scopes-open", "health-open", "execution-open", "recent-open", "timezone", "config-category", "config-setting"].includes(action.type)) state = { ...state, file: null, externalUrl: null, recentOpen: null, timeZoneHelp: false, attentionOpen: null, specLaunch: null, ...(state.pendingDrill ? { pendingDrill: null } : {}) };
state = reduce(state, action, getSnapshot());
// A pending address belongs to the Topology landing it opened; leaving it cancels the address.
if (state.pendingDrill && (state.section !== "topology" || state.drill.length > 0)) state = { ...state, pendingDrill: null };
@@ -142,6 +143,9 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
return resetContent({ ...next, terminalPage: Math.max(0, Math.min(action.page, (snap.terminals?.preview?.composed.pages.length ?? 1) - 1)) });
case "attention-category":
return syncSelection({ ...resetContent({ ...next, section: "needs", attentionCategory: action.category, attentionOpen: null }), focusedPane: "content" }, snap);
case "attention-view":
// Whose queue the Feed shows; acts on it are still the primary user's.
return syncSelection({ ...resetContent({ ...next, section: "needs", attentionView: action.view === "default" ? null : action.view, attentionCategory: null, attentionOpen: null }), focusedPane: "content" }, snap);
case "attention-open":
return syncSelection({ ...resetContent({ ...next, section: "needs", attentionOpen: action.id, file: null, externalUrl: null, healthOpen: null }), focusedPane: "content" }, snap);
case "attention-source": {
+5 -1
View File
@@ -518,6 +518,8 @@ export type Action =
| { type: "attention-category"; category: "action" | "update" }
| { type: "attention-open"; id: string }
| { type: "attention-source"; path: string }
/** Whose Feed queue to show: a person, "all" or "default". A view, never who acts. */
| { type: "attention-view"; view: string }
| { type: "file-open"; target: import("./reading.js").FileTarget }
| { type: "external-open"; url: string }
/** Leave the TUI briefly and print `value` as one unbroken line for exact copying; Enter returns. */
@@ -574,7 +576,7 @@ export type Action =
| { type: "act"; act: "run"; rigId: string; agent: string }
/** Answer a request on its own row, as the person the Feed served: Steering's Approve, or a choice
* (questionId + optionId) or their own words (decision) through mission control's answer. */
| { type: "act"; act: "answer"; qitemId: string; verb: "approve" | "answer"; actorSession: string; questionId?: string; optionId?: string; decision?: string }
| { type: "act"; act: "answer"; qitemId: string; verb: "approve" | "answer" | "resolve"; actorSession: string; questionId?: string; optionId?: string; decision?: string }
/** Answer the open request in your own words (the `reply` command); main resolves it to the served answer act. */
| { type: "attention-reply"; text: string }
| { type: "notice"; message: string };
@@ -594,6 +596,8 @@ export interface ViewState {
terminalView?: string | null;
terminalPage?: number;
attentionOpen?: string | null;
/** Whose Feed queue is shown: a person, or "all"; null is the default person's (or everyone's when there is none). */
attentionView?: string | null;
attentionCategory?: "action" | "update" | null;
file?: import("./reading.js").FileTarget | null;
externalUrl?: string | null;
@@ -0,0 +1,108 @@
import { expect, it } from "vitest";
import { DaemonClient } from "../src/daemon-client.js";
import { hydrateSnapshot } from "../src/hydrate.js";
import { createViewState, emptySnapshot } from "../src/state.js";
import { attentionLines, opensOnDefaultFeed, answerRequest, replyAct } from "../src/attention/attention-model.js";
import { parseCommand } from "../src/grammar.js";
import type { AttentionRead } from "@openrig/daemon/attention";
import type { Action, FleetSnapshot } from "../src/types.js";
// Whose queue the Feed shows is a view, never who acts: the header names both, and acts stay the primary user's.
const perspective = (viewing: string | null, basis: NonNullable<AttentionRead["perspective"]>["basis"], defaultSource: NonNullable<AttentionRead["perspective"]>["defaultSource"] = "chosen") =>
({ viewing, basis, defaultSource, actingAs: { person: defaultSource === "none" ? null : "mike@external", authority: "primary-user" as const } });
const read = (p?: AttentionRead["perspective"]): AttentionRead =>
({ scope: "instance", readAt: "2026-10-25T10:00:00Z", items: [], sources: [{ source: "queue", state: "available", detail: "fixture" }], detail: null, detailError: null, ...(p ? { perspective: p } : {}) });
it("the whose command chooses whose queue the Feed reads, and the Feed says who acts", async () => {
const views: Array<string | null> = [];
const client = new DaemonClient({ baseUrl: "http://fixture", fetchImpl: (async (url) => {
const u = new URL(String(url));
if (u.pathname === "/api/attention") {
const view = u.searchParams.get("view"); views.push(view);
return Response.json(read(view === "sarah" ? perspective("sarah@external", "chosen-here") : view === "all" ? perspective(null, "all") : perspective("mike@external", "default")));
}
if (u.pathname === "/api/queue/human-updates") return Response.json({ items: [], limit: 20, truncated: false });
if (u.pathname === "/api/files/roots") return Response.json({ roots: [] });
throw new Error(`Unexpected request: ${u}`);
}) as typeof fetch });
let snap: FleetSnapshot = emptySnapshot();
const view = createViewState({ instanceId: "perspective", getSnapshot: () => snap });
const refresh = async () => { snap = await hydrateSnapshot(client, undefined, null, null, null, view.get()); };
const title = () => attentionLines(view.get(), snap, 120)[0]!.text;
view.dispatch(parseCommand("attention")); await refresh();
expect(views.at(-1)).toBe("default");
expect(title()).toBe("FEED · Viewing mike@external · you act as mike@external");
view.dispatch(parseCommand("whose sarah")); await refresh();
expect(view.get()).toMatchObject({ section: "needs", attentionView: "sarah" });
expect(views.at(-1)).toBe("sarah");
// Viewing Sarah is not acting as Sarah.
expect(title()).toBe("FEED · Viewing sarah@external · you act as mike@external");
// Leaving the Feed and coming back keeps the chosen view.
view.dispatch(parseCommand(":topology")); view.dispatch(parseCommand("attention")); await refresh();
expect(views.at(-1)).toBe("sarah");
view.dispatch(parseCommand("whose all")); await refresh();
expect(views.at(-1)).toBe("all");
expect(title()).toBe("FEED · Viewing everyone · you act as mike@external");
view.dispatch(parseCommand("whose default")); await refresh();
expect(view.get().attentionView).toBeNull();
expect(views.at(-1)).toBe("default");
});
it("the header says when the view names no one, when no default is chosen, and is as before with no one registered", () => {
const state = createViewState({ instanceId: "perspective" }).get();
const lines = (r: AttentionRead) => attentionLines({ ...state, section: "needs" }, { ...emptySnapshot(), attentionRead: r }, 120).map(l => l.text);
expect(lines(read({ ...perspective(null, "unknown"), requested: "ana" }))[0]).toBe('FEED · Viewing no registered person "ana" · you act as mike@external');
expect(lines(read(perspective(null, "all", "none"))).slice(0, 2)).toEqual([
"FEED · Viewing everyone · you act as the primary user",
"No default person is chosen: rig gateway default <id> chooses one.",
]);
expect(lines(read())[0]).toBe("FEED · Instance / All humans");
});
it("opens on the default person's Feed only when one was chosen, and only before the person has moved", () => {
const opening = createViewState({ instanceId: "perspective" }).get();
expect(opening.section).toBe("topology");
expect(opensOnDefaultFeed(read(perspective("mike@external", "default", "chosen")), opening, true)).toBe(true);
// One person with no chosen default, or several and none chosen, stays on the topology.
expect(opensOnDefaultFeed(read(perspective("mike@external", "default", "only-person")), opening, true)).toBe(false);
expect(opensOnDefaultFeed(read(perspective(null, "all", "none")), opening, true)).toBe(false);
expect(opensOnDefaultFeed(read(), opening, true)).toBe(false);
expect(opensOnDefaultFeed(null, opening, true)).toBe(false);
// Once the person has typed or moved, the TUI doesn't move them.
expect(opensOnDefaultFeed(read(perspective("mike@external", "default", "chosen")), opening, false)).toBe(false);
expect(opensOnDefaultFeed(read(perspective("mike@external", "default", "chosen")), { ...opening, section: "specs" }, true)).toBe(false);
});
it("an act on someone else's ask says who acts and whose ask it is, and carries the primary user's authority", () => {
const item = { id: "queue:sarahs", kind: "action" as const, summary: "Ship the draft?", unblocks: null, urgency: "routine", at: "2026-10-25T10:00:00Z",
scope: "instance · project unknown", project: null, source: "/api/queue/sarahs", recipient: "sarah@external",
answer: { verb: "approve" as const, actorSession: "sarah@external" } };
const served = (p?: AttentionRead["perspective"]): FleetSnapshot => ({ ...emptySnapshot(), attentionRead: { ...read(p), items: [item], detail: { item, lines: [], files: [] } } });
const state = { ...createViewState({ instanceId: "perspective" }).get(), section: "needs", attentionOpen: item.id };
const approve = (snap: FleetSnapshot) => attentionLines(state, snap, 160).find(l => l.action?.type === "act")!;
const viewingSarah = approve(served(perspective("sarah@external", "chosen-here")));
expect(viewingSarah.text).toContain("Approve as mike@external on sarah@external's ask");
expect(answerRequest(viewingSarah.action as Extract<Action, { type: "act"; act: "answer" }>)).toEqual(
{ verb: "approve", qitemId: "sarahs", actorSession: "sarah@external", authority: "primary-user" });
// With no one registered, the label is as before.
expect(approve(served()).text).toContain("Approve as sarah@external (closes");
});
it("a row parked on someone is resolved with reply, in words, as the primary user", () => {
const item = { id: "queue:parked", kind: "action" as const, summary: "Layout", unblocks: "Layout", urgency: "routine", at: "2026-10-25T10:00:00Z",
scope: "instance · project unknown", project: null, source: "/api/queue/parked", recipient: "sarah@external",
answer: { verb: "resolve" as const, actorSession: "sarah@external" } };
const snap: FleetSnapshot = { ...emptySnapshot(), attentionRead: { ...read(perspective("sarah@external", "chosen-here")), items: [item], detail: { item, lines: [], files: [] } } };
const state = { ...createViewState({ instanceId: "perspective" }).get(), section: "needs", attentionOpen: item.id };
const text = attentionLines(state, snap, 160).map(l => l.text).join("\n");
expect(text).toContain("Resolve it in your own words: reply <your decision>, as mike@external on sarah@external's ask");
const act = replyAct(snap.attentionRead, item.id, "Use the wide layout");
expect(act).toEqual({ type: "act", act: "answer", qitemId: "parked", verb: "resolve", actorSession: "sarah@external", decision: "Use the wide layout" });
expect(answerRequest(act as Extract<Action, { type: "act"; act: "answer" }>)).toMatchObject({ verb: "resolve", decision: "Use the wide layout", authority: "primary-user" });
});
+5 -5
View File
@@ -2,10 +2,10 @@ import { expect, it } from "vitest";
import { DaemonClient } from "../src/daemon-client.js";
import { hydrateSnapshot } from "../src/hydrate.js";
import { createViewState, emptySnapshot, computeExplorerRows } from "../src/state.js";
import { attentionLines, replyAct } from "../src/attention/attention-model.js";
import { attentionLines, replyAct, answerRequest } from "../src/attention/attention-model.js";
import { parseCommand } from "../src/grammar.js";
import type { AttentionRead } from "@openrig/daemon/attention";
import type { FleetSnapshot } from "../src/types.js";
import type { Action, FleetSnapshot } from "../src/types.js";
it.each([140, 80])("keeps readable summaries, passive exact source navigation and Back at width %i", async width => {
const requests: string[] = [];
@@ -85,10 +85,10 @@ it("answers an ask from its request view as Steering does: the served Approve, a
wanted = parked;
view.dispatch({ type: "back" }); view.dispatch({ type: "attention-open", id: parked.id }); await refresh();
expect(attentionLines(view.get(), snap, 140).some(l => l.action?.type === "act")).toBe(false);
// What the driver loop sends for that act: Steering's own write, nothing else.
await client.missionControlAction({ verb: "approve", qitemId: "quiet-ask", actorSession: "human-founder@kernel" });
// What the driver loop sends for that act: Steering's own write, with the primary user's authority, nothing else.
await client.missionControlAction(answerRequest(approve!.action as Extract<Action, { type: "act"; act: "answer" }>));
expect(requests.filter(r => r.method === "POST")).toEqual([
{ method: "POST", path: "/api/mission-control/action", body: { verb: "approve", qitemId: "quiet-ask", actorSession: "human-founder@kernel" } },
{ method: "POST", path: "/api/mission-control/action", body: { verb: "approve", qitemId: "quiet-ask", actorSession: "human-founder@kernel", authority: "primary-user" } },
]);
});
+3 -3
View File
@@ -6,7 +6,7 @@
"core/agent-startup-and-context-ingestion/SKILL.md": "347f28a9567b4b8d8669aff6946229f53144e0c139aca2cbe77e0eb93a87f1b1",
"core/cross-host-rig-commands/SKILL.md": "79ee21fa7da143aa4c3defa8fbb78c0a87ae822c69f1a99cd1acf50a9931ce4d",
"core/human-in-the-loop/SKILL.md": "14cf0c4a0dc89179121465d4b8d81d40406427351adcdc34f875097d1e6e9ab2",
"core/messaging-the-human/SKILL.md": "e94095e61cec0d32d96fb8c60df008d0c3ac3bd2fa789a5eb625f46f35945456",
"core/messaging-the-human/SKILL.md": "1febb284327b6f0abb33277f486cdad145d66f73be6b8aea776867174a5b96d4",
"core/openrig-architect/SKILL.md": "51b65d2e878eeaf2af2f23974677d6a3272728024f3a6854bf3a646567fd3ae6",
"core/openrig-cmux/SKILL.md": "73a86eac45d68b16a38fc0a4438713c634d86061073dac96a4d9e656ea5d34dc",
"core/openrig-herdr/SKILL.md": "20eb1f0eb40bdd7b471531aacf2ab2a596963e8b5b8583c88c29bdb0258f38aa",
@@ -70,7 +70,7 @@
"forming-an-openrig-mental-model/SKILL.md": "cd7bfdcc5a87485140cfcac94bc2c703aec5c5b220a3397e139c4fd6db4339e6",
"loading-addressable-markdown/SKILL.md": "104dc6a02ae6e2d0eff519387c6839cb09803945b9ee35ec3498af8feaaa8d1e",
"loading-addressable-markdown/scripts/resolve-markdown.mjs": "61a6d52c6c94fb1878361d19bdfaeb4524062fc4c483f557ab2f140e516d15f9",
"messaging-the-human/SKILL.md": "e94095e61cec0d32d96fb8c60df008d0c3ac3bd2fa789a5eb625f46f35945456",
"messaging-the-human/SKILL.md": "1febb284327b6f0abb33277f486cdad145d66f73be6b8aea776867174a5b96d4",
"mission-slice-sop/SKILL.md": "a01e72a526c975a86f0754af206505efbafcbbc3583bea1394c50759dc5a584f",
"openrig-operating-model/SKILL.md": "8364530a6199f6e2df42b773c35434cb4ecade0ede48e439cd9fc03df4469896",
"openrig-operating-model/scripts/compose.py": "f71065de92b1ef4ce3ca78a8bc0cfe07bcf54ed84bb8ec5359d3889674fe385d",
@@ -105,7 +105,7 @@
"core/agent-startup-and-context-ingestion/SKILL.md": "347f28a9567b4b8d8669aff6946229f53144e0c139aca2cbe77e0eb93a87f1b1",
"core/cross-host-rig-commands/SKILL.md": "79ee21fa7da143aa4c3defa8fbb78c0a87ae822c69f1a99cd1acf50a9931ce4d",
"core/human-in-the-loop/SKILL.md": "14cf0c4a0dc89179121465d4b8d81d40406427351adcdc34f875097d1e6e9ab2",
"core/messaging-the-human/SKILL.md": "e94095e61cec0d32d96fb8c60df008d0c3ac3bd2fa789a5eb625f46f35945456",
"core/messaging-the-human/SKILL.md": "1febb284327b6f0abb33277f486cdad145d66f73be6b8aea776867174a5b96d4",
"core/openrig-architect/SKILL.md": "51b65d2e878eeaf2af2f23974677d6a3272728024f3a6854bf3a646567fd3ae6",
"core/openrig-cmux/SKILL.md": "73a86eac45d68b16a38fc0a4438713c634d86061073dac96a4d9e656ea5d34dc",
"core/openrig-herdr/SKILL.md": "20eb1f0eb40bdd7b471531aacf2ab2a596963e8b5b8583c88c29bdb0258f38aa",
@@ -4,6 +4,8 @@ description: "Use when project policy calls for a human decision or update, a hu
metadata:
cli_surfaces_referenced:
- gateway human list
- gateway participants
- gateway default
- gateway human show
- gateway human mode room
- gateway human mode decide
@@ -32,13 +34,22 @@ Discover the registered participants and inspect the chosen human:
```bash
rig gateway human list --json
rig gateway participants --json
rig gateway human show <entityId> --json
```
`participants` lists people and shared channels (one vetted channel is one
participant), each with its organization, groups, whether it's the default, and
its readable policy: the organization's, each group's and its own text, each
labelled with where it came from. Read the policy of whoever you contact and
apply it; it's context for your judgment, not a permission system, and a name
is never a reason to assume a policy.
Use the returned `address` (`<entityId>@external`), not a username, remembered
seat, connector handle, or guessed kernel address. Where several humans exist,
use the decision ownership in Project World. An absent or ambiguous registration
needs a named registration correction, not a fallback address.
seat, connector handle, or guessed kernel address. Where several people exist,
use the decision ownership in Project World; the default person (`rig gateway
default`) is only for needs that name nobody. An absent or ambiguous
registration needs a named registration correction, not a fallback address.
Check readiness: configured, enabled, active, ready, reason, and next action.
`indeterminate` is not ready. Follow the reported next inspection; do not enable