@idleware/ringi 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idleware/ringi",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Distributed agent coordinator",
5
5
  "keywords": [
6
6
  "agents",
@@ -22,6 +22,7 @@ With no words: `ringi agent whoami` if this chat is joined; otherwise say how th
22
22
  Several chats may be open in a project. Take part only if the operator says so in this chat. Their words map to session commands:
23
23
 
24
24
  - *"join fix-badge as codex; review tasks from claude; no commits"* → `ringi join local fix-badge --as codex --scope "review tasks from claude; no commits"`. Record what they delegate in `--scope`.
25
+ - A room may have a quorum (`ringi room create … --quorum 2`; `--quorum 1`: start at once). While join or inbox says `waiting:`, do nothing and end the turn. When it says `started:`, take your first turn right away on what it shows (open work, inbox), without asking the operator to confirm anything: with no scope, the room's work for you is yours, and you agree the split with the other members yourselves. Still ask before commits or pushes, publishing, outside accounts or spending that the scope does not cover. The same after *"continue"*.
25
26
  - *"continue"* (after a restart, or a pause) → `ringi continue`. If it lists candidates, ask the operator which one, then `ringi continue <room>/<agent>`.
26
27
  - *"stop"* (the operator leaves for a while) → `ringi stop`. You keep your seat and react to nothing.
27
28
  - *"leave"* → `ringi leave`.
@@ -34,15 +35,17 @@ Everything you do as an agent goes through `ringi agent`, always in your own roo
34
35
 
35
36
  ```sh
36
37
  ringi agent inbox # what is new for you; marks it seen
37
- ringi agent reply <id> -m "Done: tests pass" --state completed --attach report.md
38
+ ringi agent add report.md patch.diff:files/fix.diff # stage files for your next message, like git add
39
+ ringi agent reply <id> -m "Done: tests pass" --state completed # carries what you staged (or --attach …)
38
40
  ringi agent send --to claude --task -m "Review the parser" -m "acceptance: …"
39
41
  ringi agent tasks | proposals | grants | signals | profile | thread <id> | read <id> | cat <id> <path>
40
42
  ringi agent whoami # room, scope, members
41
43
  ```
42
44
 
43
- - The first `-m` is the subject; the rest is the body; anything long goes into files with `--attach`.
45
+ - The first `-m` is the subject; the rest is the body; anything long goes into files.
46
+ - Results and state travel only as attachments: `ringi agent add <file>` takes the file's content now for your next message (`add` alone lists what is staged, `--drop <path>` takes one back), or `--attach` on the message. Read a peer's with `ringi agent cat` / `get`. Never leave drafts or results in the project directory or any shared disk for others to pick up, and never pick them up there: peers may be on other machines, and only the room carries them. Keep your own drafts outside the project.
44
47
  - Answer the message you act on (`reply <id>`): only these links order the room.
45
- - What a piece of work is in your room, its states and who may move it, is the room's **profile**: run `ringi agent profile` once per room before you set states. Do not assume the A2A states; a room may use any. `--task` opens a piece of work of that profile. Results travel as files.
48
+ - What a piece of work is in your room, its states and who may move it, is the room's **profile**: run `ringi agent profile` once per room before you set states. Do not assume the A2A states; a room may use any. `--task` opens a piece of work of that profile.
46
49
  - When you start on a piece of work, `ringi agent take <id>`: a mark others see, not a state change. Reading your inbox leaves read marks on what was delivered; `--peek` leaves none.
47
50
  - `--roles id=label` labels mean only what the profile's rules say. A label you give in a message authorizes nothing in that same message.
48
51
 
@@ -7,6 +7,11 @@ export type InboxOptions = {
7
7
  peek?: boolean;
8
8
  limit?: number;
9
9
  };
10
+ export declare const started: ({ R }: Actor) => {
11
+ quorum: number | null;
12
+ agents: number;
13
+ started: boolean;
14
+ };
10
15
  export declare const inbox: ({ R, me }: Actor, { peek, limit }?: InboxOptions) => {
11
16
  rest: number;
12
17
  cursors: {
@@ -3,6 +3,7 @@ import * as Signals from '../domain/signals.ts';
3
3
  import type { Room } from '../hub/index.ts';
4
4
  import type { Entry } from '../protocol/files.ts';
5
5
  import type { All, Message } from '../protocol/index.ts';
6
+ import type { Staged } from '../runtime/reader.ts';
6
7
  import type { TaskView } from './view.ts';
7
8
  export type Actor = {
8
9
  R: Room;
@@ -27,6 +28,9 @@ export type Sent = {
27
28
  to: string[];
28
29
  files: number;
29
30
  };
31
+ export declare const staged: ({ R, me }: Actor) => Staged[];
32
+ export declare const stage: ({ R, me }: Actor, files: Entry[]) => Staged[];
33
+ export declare const unstage: ({ R, me }: Actor, paths: string[] | null) => Staged[];
30
34
  export declare const send: ({ R, me }: Actor, x: SendInput) => Sent;
31
35
  export declare const marks: (R: Room, all: All) => Map<string, Record<string, string[]>>;
32
36
  export declare const message: ({ R, me }: Actor, ref: string) => {
@@ -12,8 +12,9 @@ export type RoomView = {
12
12
  export declare const rooms: (H: Hub, { archived }?: {
13
13
  archived?: boolean;
14
14
  }) => RoomView[];
15
- export declare const create: (H: Hub, name: string, { profile }?: {
15
+ export declare const create: (H: Hub, name: string, { profile, quorum }?: {
16
16
  profile?: string;
17
+ quorum?: number;
17
18
  }) => {
18
19
  profile: string;
19
20
  };
@@ -5,6 +5,11 @@ export type { Session } from '../runtime/session.ts';
5
5
  export declare const briefing: (s: Session) => {
6
6
  session: Session;
7
7
  actor: import("../runtime/session.ts").Bound;
8
+ start: {
9
+ quorum: number | null;
10
+ agents: number;
11
+ started: boolean;
12
+ };
8
13
  tasks: import("./view.ts").TaskView[];
9
14
  inbox: {
10
15
  rest: number;
@@ -35,7 +40,7 @@ export declare const briefing: (s: Session) => {
35
40
  hopEff: number;
36
41
  files: import("../protocol/index.ts").FileRef[];
37
42
  }[];
38
- };
43
+ } | null;
39
44
  };
40
45
  export type JoinInput = {
41
46
  hub: string;
@@ -1,6 +1,8 @@
1
1
  import type { TaskView } from '../app/view.ts';
2
2
  import type { Opts } from './args.ts';
3
3
  import type { Ctx } from './context.ts';
4
+ import type { Session } from '../app/session.ts';
5
+ export declare const add: (a: Ctx, o: Opts) => void;
4
6
  export declare const send: (a: Ctx, o: Opts) => void;
5
7
  export declare const reply: (a: Ctx, o: Opts) => void;
6
8
  export declare const read: (a: Ctx, o: Opts) => void;
@@ -18,3 +20,8 @@ export declare const stamp_: (a: Ctx, o: Opts) => void;
18
20
  export declare const decide: (a: Ctx, o: Opts) => void;
19
21
  export declare const proposals: (a: Ctx, o: Opts) => void;
20
22
  export declare const grants: (a: Ctx, o: Opts) => void;
23
+ export declare const scopeLine: (s: Session, quorum?: boolean) => string;
24
+ export declare const waiting: (room: string, st: {
25
+ quorum: number | null;
26
+ agents: number;
27
+ }) => string;
@@ -39,6 +39,12 @@ export type Room = {
39
39
  authorityAt: D.AuthorityAt;
40
40
  profile: Profile;
41
41
  members: Record<string, Member>;
42
+ quorum: number | null;
43
+ };
44
+ export declare const start: (R: Pick<Room, 'members' | 'quorum'>) => {
45
+ quorum: number | null;
46
+ agents: number;
47
+ started: boolean;
42
48
  };
43
49
  export declare const hubNames: () => string[];
44
50
  export declare const createHub: (name: string, { signed, url }?: {
@@ -3,6 +3,7 @@ export type Entry = {
3
3
  path: string;
4
4
  file?: string;
5
5
  content?: string;
6
+ blob?: string;
6
7
  mode: string;
7
8
  };
8
9
  export declare const checkPath: (p: string) => string;
@@ -15,6 +15,13 @@ export type ReaderState = {
15
15
  runs?: number[];
16
16
  exhausted?: Record<string, boolean>;
17
17
  signals?: Record<string, string>;
18
+ staged?: Staged[];
19
+ };
20
+ export type Staged = {
21
+ path: string;
22
+ blob: string;
23
+ mode: string;
24
+ size: number;
18
25
  };
19
26
  export declare const readerFile: (R: Room, reader: string) => string;
20
27
  export declare const readerLock: <T>(R: Room, reader: string, fn: () => T) => T;
@@ -57,6 +57,7 @@ export declare class GitStore implements Store {
57
57
  logIds(room: StoredRoom): string[];
58
58
  logHead(room: StoredRoom, id: string): string | null;
59
59
  messages(room: StoredRoom, id: string, head: string): StoredMessage[];
60
+ blob(file: string): string;
60
61
  append(room: StoredRoom, who: string, text: string, entries: Entry[]): string;
61
62
  private onTop;
62
63
  verifyLog(room: StoredRoom, id: string, head: string): string | null;
@@ -75,6 +75,7 @@ export interface Store {
75
75
  extends(older: Rev, newer: Rev): boolean;
76
76
  messages(room: StoredRoom, id: string, head: Rev): StoredMessage[];
77
77
  append(room: StoredRoom, who: string, text: string, entries: Entry[]): Rev;
78
+ blob(file: string): Rev;
78
79
  alarms?(room: StoredRoom): string[];
79
80
  verifyLog(room: StoredRoom, id: string, head: Rev): string | null;
80
81
  pin(room: StoredRoom, reader: string, id: string, rev: Rev): void;
@@ -1,5 +1,5 @@
1
1
  // Agent operations return data; the runtime owns readers, seats and headless turns.
2
- import { HIDDEN, append, pongText, withSizes } from '../hub/index.js';
2
+ import { HIDDEN, append, pongText, start, withSizes } from '../hub/index.js';
3
3
  import * as Signals from '../hub/signals.js';
4
4
  import { fail, sleep } from '../lib/index.js';
5
5
  import { ID_RE, checkOutgoing, compose, link, lookup, unread } from '../protocol/index.js';
@@ -8,6 +8,8 @@ import { bound } from '../runtime/session.js';
8
8
  import { isRef } from './room.js';
9
9
  export { bound } from '../runtime/session.js';
10
10
  export { watch } from '../runtime/watch.js';
11
+ // Whether the agent's room has its quorum and started its work; a room that has not is not read.
12
+ export const started = ({ R }) => start(R);
11
13
  export const inbox = ({ R, me }, { peek = false, limit = 20 } = {}) => {
12
14
  if (!Number.isSafeInteger(limit) || limit < 1)
13
15
  fail('inbox limit must be a positive integer');
@@ -1,5 +1,6 @@
1
1
  // room: what any participant (the operator or an agent) does in a room: write messages and read them, follow work,
2
2
  // check signals. Each operation takes who acts and typed input, and returns data; interfaces print it.
3
+ import fs from 'node:fs';
3
4
  import * as D from '../domain/decisions.js';
4
5
  import { isOpen, tasksIn } from '../domain/profiles.js';
5
6
  import * as Signals from '../domain/signals.js';
@@ -12,6 +13,33 @@ import { ID_RE, TOKEN_RE, checkOutgoing, compose, link, lookup, ordered, rootOf,
12
13
  import { linked, loadRoomUnlocked, loadState, readerLock, saveState } from '../runtime/reader.js';
13
14
  import { taskView } from './view.js';
14
15
  export const isRef = (R) => (x) => R.hub.store.isRef(x);
16
+ // Staging, like git's index: add takes a file's content into the store now, and the actor's next message carries it,
17
+ // whatever happens to the file meanwhile. Results and state travel only so, never through a shared directory.
18
+ export const staged = ({ R, me }) => loadState(R, me).staged ?? [];
19
+ export const stage = ({ R, me }, files) => {
20
+ const taken = files.map(e => ({ path: e.path, blob: R.hub.store.blob(e.file ?? fail(`${e.path}: add takes files`)), mode: e.mode,
21
+ size: fs.statSync(e.file).size }));
22
+ return readerLock(R, me, () => {
23
+ const st = loadState(R, me);
24
+ const next = new Map((st.staged ?? []).map(s => [s.path, s]));
25
+ for (const s of taken)
26
+ next.set(s.path, s);
27
+ const all = [...next.values()];
28
+ checkCollisions(all.map(s => s.path));
29
+ saveState(R, me, { ...st, staged: all });
30
+ return all;
31
+ });
32
+ };
33
+ // Unstages the given paths, or everything.
34
+ export const unstage = ({ R, me }, paths) => readerLock(R, me, () => {
35
+ const st = loadState(R, me);
36
+ const missing = (paths ?? []).filter(p => !(st.staged ?? []).some(s => s.path === p));
37
+ if (missing.length)
38
+ fail(`not staged: ${missing.join(', ')}`);
39
+ const left = paths ? (st.staged ?? []).filter(s => !paths.includes(s.path)) : [];
40
+ saveState(R, me, { ...st, staged: left });
41
+ return left;
42
+ });
15
43
  // A message from the actor. The operator may omit `to`: the room, or the assignees when there are any.
16
44
  export const send = ({ R, me }, x) => {
17
45
  const P = R.profile;
@@ -50,7 +78,10 @@ export const send = ({ R, me }, x) => {
50
78
  fail(`grant ${short(g.id)} was revoked`);
51
79
  grant = g.id;
52
80
  }
53
- const entries = x.files ?? [];
81
+ // What the actor staged goes along, an explicit attachment of the same path in its place.
82
+ const stagedNow = staged({ R, me }), explicit = x.files ?? [];
83
+ const named = new Set(explicit.map(e => e.path));
84
+ const entries = [...stagedNow.filter(s => !named.has(s.path)).map(s => ({ path: s.path, blob: s.blob, mode: s.mode })), ...explicit];
54
85
  checkCollisions(entries.map(e => e.path));
55
86
  const text = compose(x.text, [
56
87
  ['To', to.join(', ')], ['Re', re?.id], ['Kind', x.task && P.opens], ['State', x.state], ['Assign', assign?.join(', ')],
@@ -58,6 +89,11 @@ export const send = ({ R, me }, x) => {
58
89
  ]);
59
90
  checkOutgoing(text, isRef(R));
60
91
  const id = append(R, me, text, entries);
92
+ if (stagedNow.length)
93
+ readerLock(R, me, () => {
94
+ const st = loadState(R, me);
95
+ saveState(R, me, { ...st, staged: (st.staged ?? []).filter(s => !stagedNow.some(t => t.path === s.path && t.blob === s.blob)) });
96
+ });
61
97
  if (to.length === 1 && to[0] === '*')
62
98
  note(`addressed to the whole room: this ${x.task ? P.opens : 'message'} wakes nobody; name recipients (--to or --assign) to start work`);
63
99
  return { id, to, files: entries.length };
@@ -36,9 +36,12 @@ export const rooms = (H, { archived = false } = {}) => {
36
36
  return [...live, ...gone];
37
37
  };
38
38
  // A room carries its profile from the start: later changes to the hub's profiles do not reach it.
39
- export const create = (H, name, { profile } = {}) => {
39
+ // `quorum`: the room's work starts by itself once it has that many agents; until then a join waits.
40
+ export const create = (H, name, { profile, quorum } = {}) => {
40
41
  if (!ID_RE.test(name) || RESERVED_ROOMS.has(name))
41
- fail(`usage: ringi room create <id> [--profile <name>] (an id like fix-badge; not ${[...RESERVED_ROOMS].join(', ')})`);
42
+ fail(`usage: ringi room create <id> [--profile <name>] [--quorum <agents>] (an id like fix-badge; not ${[...RESERVED_ROOMS].join(', ')})`);
43
+ if (quorum !== undefined && !(Number.isSafeInteger(quorum) && quorum >= 1 && quorum <= 99))
44
+ fail('--quorum takes a number of agents, 1 to 99');
42
45
  const p = profile ?? H.contract.profile ?? DEFAULT_PROFILE;
43
46
  const text = profileText(H, p) ?? fail(`hub ${H.name} carries no profile ${p}; add it with ringi hub amend -m "<why>" --attach <file>:profiles/${p}.json`);
44
47
  parseProfile(text, `profile ${p}`);
@@ -48,7 +51,8 @@ export const create = (H, name, { profile } = {}) => {
48
51
  if (H.signed)
49
52
  files.allowed_signers = H.store.rootRoster?.() ?? fail(`hub ${H.name}: its store cannot start a signed roster`);
50
53
  // The genesis names the room; a random id makes it unique, so a room deleted and created again is a different room.
51
- H.store.createRoom(name, OPERATOR, compose(`room ${name}`, [['To', '*'], ['Kind', 'room'], ['Room-Id', crypto.randomBytes(16).toString('hex')]]), files);
54
+ H.store.createRoom(name, OPERATOR, compose(`room ${name}`, [['To', '*'], ['Kind', 'room'], ['Room-Id', crypto.randomBytes(16).toString('hex')],
55
+ ...(quorum ? [['Quorum', String(quorum)]] : [])]), files);
52
56
  });
53
57
  setConfig({ hub: H.name, room: name });
54
58
  return { profile: p };
@@ -1,15 +1,17 @@
1
1
  // Session use cases preserve the operator's identity, seat and delegated scope.
2
2
  import { OPERATOR, ensureHome, locked } from '../home/index.js';
3
- import { controlChange, openHub, room as roomOf } from '../hub/index.js';
3
+ import { controlChange, openHub, room as roomOf, start } from '../hub/index.js';
4
4
  import { AGENT_RE } from '../protocol/index.js';
5
5
  import { allSessions, bound, dropSession, liveness, ownSession, parseSelector, pidAlive, readSession, saveSession, seatHolder, whoAmI } from '../runtime/session.js';
6
6
  import { fail, note, stamp } from '../lib/index.js';
7
7
  import { inbox } from './agent.js';
8
8
  import { tasks } from './room.js';
9
9
  export { bound } from '../runtime/session.js';
10
+ // A room still waiting for its agents is not read: nothing is marked seen before its work starts.
10
11
  export const briefing = (s) => {
11
12
  const actor = bound();
12
- return { session: s, actor, tasks: tasks(actor).views, inbox: inbox(actor) };
13
+ const st = start(actor.R);
14
+ return { session: s, actor, start: st, tasks: st.started ? tasks(actor).views : [], inbox: st.started ? inbox(actor) : null };
13
15
  };
14
16
  export const join = ({ hub, room, agent: id, scope }) => {
15
17
  const me = whoAmI();
@@ -3,6 +3,7 @@ import * as Agent from '../app/agent.js';
3
3
  import { asJson, render } from '../app/view.js';
4
4
  import { fail, short, when } from '../lib/index.js';
5
5
  import { dump, flag, list, opt, out, textOf } from './args.js';
6
+ import { scopeLine, waiting } from './shared.js';
6
7
  const options = (o) => ({ peek: flag(o, 'peek'), limit: opt(o, 'limit') ? Number(opt(o, 'limit')) : undefined });
7
8
  export const showInbox = ({ R, me, s }, res, o) => {
8
9
  if (flag(o, 'json'))
@@ -15,14 +16,19 @@ export const showInbox = ({ R, me, s }, res, o) => {
15
16
  if (res.rest)
16
17
  out(`\n(${res.rest} more: run ringi agent inbox again)`);
17
18
  };
18
- export const inbox = (a, o) => showInbox(a, Agent.inbox(a, options(o)), o);
19
+ export const inbox = (a, o) => {
20
+ const st = Agent.started(a);
21
+ if (!st.started)
22
+ return out(waiting(a.R.name, st));
23
+ showInbox(a, Agent.inbox(a, options(o)), o);
24
+ };
19
25
  export const take = (a, o) => {
20
26
  const { m, made } = Agent.take(a, o._[0] ?? fail('usage: ringi agent take <work>'));
21
27
  out(`${made ? 'took' : 'already took'} ${short(m.id)}: ${m.subject}`);
22
28
  };
23
29
  export const whoami = (a) => {
24
30
  const { session: s, profile, members } = Agent.whoami(a);
25
- out(`${s.agent} in ${s.hub}/${s.room} (genesis ${short(s.genesis)}, profile ${profile}), session ${s.handle}, ${s.state}\nscope: ${s.scope ?? 'none recorded; ask the operator what to accept'}\nmembers: ${members.join(', ')}`);
31
+ out(`${s.agent} in ${s.hub}/${s.room} (genesis ${short(s.genesis)}, profile ${profile}), session ${s.handle}, ${s.state}\nscope: ${scopeLine(s, a.R.quorum !== null)}\nmembers: ${members.join(', ')}`);
26
32
  };
27
33
  export const card = (a, o) => out(`card ${short(Agent.card(a, textOf(o)))}`);
28
34
  export const ping = (a, o) => {
@@ -5,8 +5,8 @@ import { fail } from '../lib/index.js';
5
5
  import { checkPath } from '../app/room.js';
6
6
  export const out = (s = '') => { process.stdout.write(s.endsWith('\n') ? s : `${s}\n`); };
7
7
  export const dump = (v) => out(JSON.stringify(v, null, 2));
8
- const BOOLS = new Set(['task', 'peek', 'all', 'json', 'help', 'once', 'project', 'follow', 'short', 'yes', 'require-grant', 'md', 'archived', 'signed', 'repair', 'handoff']);
9
- const MULTI = new Set(['m', 'attach', 'set']);
8
+ const BOOLS = new Set(['task', 'peek', 'all', 'json', 'help', 'once', 'project', 'follow', 'short', 'yes', 'require-grant', 'md', 'archived', 'signed', 'repair', 'handoff', 'clear']);
9
+ const MULTI = new Set(['m', 'attach', 'set', 'drop']);
10
10
  const SHORT = { m: 'm', F: 'file', n: 'n', o: 'out', h: 'help' };
11
11
  export const parseArgs = (argv) => {
12
12
  const o = { _: [] };
@@ -8,7 +8,7 @@ import { card, inbox, ping, take, wait, whoami } from './agent.js';
8
8
  import { operatorCtx } from './context.js';
9
9
  import { forget, hub, roomCmd, sessions, where } from './operator.js';
10
10
  import { join, leave, resume, stop } from './session.js';
11
- import { cat, decide, get, grants, log, profileCmd, proposals, propose, read, reply, send, signals, stamp_, tasks, thread, who } from './shared.js';
11
+ import { add, cat, decide, get, grants, log, profileCmd, proposals, propose, read, reply, send, signals, stamp_, tasks, thread, who } from './shared.js';
12
12
  import { skill } from './skill.js';
13
13
  // --- commands ---
14
14
  // [command, who runs it, what it does]: the one table behind `ringi help` and COMMANDS.md.
@@ -19,13 +19,13 @@ export const TABLE = [
19
19
  ['hub switch <name>', 'operator', 'change the terminal\'s current hub; agents stay where they are'],
20
20
  ['hub amend -m <subject> [--set <key>=<value>]... [--authority <file>] [--attach <file>:profiles/<name>.json]...', 'operator', 'refine the shared contract: version, capabilities, default profile, authority table, profiles'],
21
21
  ['room list [--archived]', 'operator', 'rooms of the current hub'],
22
- ['room create <id> [--profile <name>]', 'operator', 'a room for one piece of work, following a profile; becomes the current room'],
22
+ ['room create <id> [--profile <name>] [--quorum <agents>]', 'operator', 'a room for one piece of work, following a profile; becomes the current room; --quorum: its work starts by itself once that many agents joined (1: at once)'],
23
23
  ['room migrate <id>', 'operator', 'local hubs: join the old room branches without changing message ids or sessions'],
24
24
  ['room switch <id>', 'operator', 'change the terminal\'s current room; agents stay where they are'],
25
25
  ['room admit <agent> <public key|file>', 'operator', 'signed hubs: admit the key an agent made on its own machine'],
26
26
  ['room remove <agent>', 'operator', 'local hubs: take an agent out of the current room'],
27
- ['room archive <id>', 'operator', 'local hubs: read-only from now on; wakes nobody; its sessions end'],
28
- ['room delete <id> --yes', 'operator', 'local hubs: erase a room and everything kept for it; its sessions leave on their own'],
27
+ ['room archive [<id>]', 'operator', 'local hubs: a room (default: the current one) is read-only from now on, wakes nobody; its sessions leave on their own'],
28
+ ['room delete [<id>] --yes', 'operator', 'local hubs: erase a room (default: the current one) and everything kept for it; its sessions leave on their own'],
29
29
  ['room rebase-contract <id>', 'operator', 'move a room onto the hub\'s current contract'],
30
30
  ['where [-n n] [--short] [--follow]', 'operator', 'hub, room, sessions (active, stopped, gone), open work and proposals, latest messages'],
31
31
  ['send -m <subject> [-m <body>]... [--to <ids>] [--task] [--re <msg>] [--state <s>] [--assign <ids>] [--roles <id>=<label>,...] [--grant <decision>] [--attach <src>[:<dest>]]...', 'operator', 'a message as operator; without --to it goes to the room and wakes nobody; --task opens work of the room\'s profile'],
@@ -33,6 +33,7 @@ export const TABLE = [
33
33
  ['stamp <proposal> approve|object|abstain -m <reason>', 'both', 'a named reviewer\'s stamp on that exact revision'],
34
34
  ['decide <proposal> approved|rejected|revoked -m <reason>', 'both', 'the named decider, with authority; an approval with a budget is a grant'],
35
35
  ['proposals [--all] | grants', 'both', 'open proposals with their stamps; grants with their usage'],
36
+ ['add <src>[:<dest>]... | add [--drop <path>]... [--clear]', 'both', 'stage files for your next message, like git add: their content is taken now; results and state travel only as attachments'],
36
37
  ['tasks [--all] | log [-n n] | read <msg> | thread <msg> | cat <msg> [<path>] | get <msg> [-o <dir>] | who', 'both', 'reading the room (agents: ringi agent …)'],
37
38
  ['signals [--all] [--repair]', 'both', 'what happened (signals) and where attention is (read and taken marks), each checked; --repair restores your missing signals'],
38
39
  ['profile', 'both', 'the room\'s profile: what a piece of work is, its states, who may move it'],
@@ -65,7 +66,7 @@ export const helpMarkdown = () => ['# ringi commands', '',
65
66
  '| command | who | what |', '|---|---|---|',
66
67
  ...TABLE.map(([cmd, w, what]) => `| \`ringi ${cmd.replace(/\|/g, '\\|')}\` | ${w} | ${what} |`), '',
67
68
  'Message ids are store revisions (git: commit shas), any unique prefix of 4+ characters. States and roles come from the room\'s profile (`ringi profile`).', ''].join('\n');
68
- const SHARED = { send, reply, read, thread, tasks, log, cat, get, who, signals, profile: profileCmd, propose, stamp: stamp_, decide, proposals, grants };
69
+ const SHARED = { add, send, reply, read, thread, tasks, log, cat, get, who, signals, profile: profileCmd, propose, stamp: stamp_, decide, proposals, grants };
69
70
  const AGENT = { ...SHARED, inbox, whoami, status: whoami, card, ping, wait, take };
70
71
  // A reader that stops early (`ringi log | head`) closes the pipe: that ends the output, not the command's work.
71
72
  const quietPipe = (e) => { if (e.code === 'EPIPE')
@@ -19,7 +19,9 @@ export const where = async (o) => {
19
19
  const R = w.room;
20
20
  if (!R)
21
21
  return out('no current room: ringi room create <id> | ringi room switch <id>');
22
- out(`room ${R.name}${R.archived ? ' (archived, read-only)' : ''}, genesis ${short(R.genesis)}, profile ${R.profile.name}; members: ${Object.entries(R.members).map(([id, i]) => i.role === 'root' ? `${id} (root)` : id).join(', ')}`);
22
+ const agents = Object.values(R.members).filter(m => m.role !== 'root').length;
23
+ out(`room ${R.name}${R.archived ? ' (archived, read-only)' : ''}, genesis ${short(R.genesis)}, profile ${R.profile.name}; members: ${Object.entries(R.members).map(([id, i]) => i.role === 'root' ? `${id} (root)` : id).join(', ')}`
24
+ + (R.quorum === null ? '' : agents >= R.quorum ? `; started at its quorum of ${R.quorum} agents` : `; waiting for a quorum of ${R.quorum} agents, has ${agents}`));
23
25
  out(w.sessions.length ? `sessions:\n${w.sessions.map(s => ` ${sessionLine(s)}`).join('\n')}` : 'sessions: none joined');
24
26
  out(w.work.length ? `open work:\n${w.work.map(t => ` ${taskLine(t)}`).join('\n')}` : 'open work: none');
25
27
  if (w.proposals.length)
@@ -108,7 +110,10 @@ export const roomCmd = (o) => {
108
110
  return;
109
111
  }
110
112
  if (sub === 'create') {
111
- const r = Rooms.create(H, arg ?? '', { profile: opt(o, 'profile') });
113
+ const quorum = opt(o, 'quorum');
114
+ const r = Rooms.create(H, arg ?? '', { profile: opt(o, 'profile'), quorum: quorum === undefined ? undefined : Number(quorum) });
115
+ if (quorum !== undefined)
116
+ return out(`created room ${arg} in hub ${H.name} with profile ${r.profile}; it is the current room. Its work starts by itself at a quorum of ${quorum} agent(s): in each chat that should take part, say "join ${arg} as <id>"`);
112
117
  return out(`created room ${arg} in hub ${H.name} with profile ${r.profile}; it is the current room. In each chat that should take part, say: "join ${arg} as <id>; <what to accept>"`);
113
118
  }
114
119
  if (sub === 'migrate') {
@@ -130,14 +135,15 @@ export const roomCmd = (o) => {
130
135
  return out(`removed ${arg} from ${R.name}: it can no longer write or be woken there; returning needs a fresh join`);
131
136
  }
132
137
  if (sub === 'archive') {
133
- const R = Rooms.roomIn(H, arg ?? fail('usage: ringi room archive <id>'));
138
+ // Without an id, the room of the current context.
139
+ const R = Rooms.roomIn(H, arg ?? opt(o, 'room'));
134
140
  const name = Rooms.archive(H, R);
135
141
  return out(`archived ${R.name} as ${name}: read-only, wakes nobody; its sessions end. Read it with --room ${name}`);
136
142
  }
137
143
  if (sub === 'delete') {
138
- const R = Rooms.roomIn(H, arg ?? fail('usage: ringi room delete <id> --yes'));
144
+ const R = Rooms.roomIn(H, arg ?? opt(o, 'room'));
139
145
  if (!flag(o, 'yes'))
140
- fail(`deleting ${R.name} removes its history from the hub for good. Run again with --yes, or archive it instead`);
146
+ fail(`deleting ${R.name} in hub ${H.name} erases it and everything kept for it, for good. Run again with --yes, or archive it instead`);
141
147
  Rooms.erase(H, R);
142
148
  return out(`deleted room ${R.name}: its branch, signals, pins, evidence and readers' state. Sessions bound to it leave it on their next turn`);
143
149
  }
@@ -3,11 +3,16 @@ import * as Session from '../app/session.js';
3
3
  import { fail } from '../lib/index.js';
4
4
  import { opt, out } from './args.js';
5
5
  import { showInbox } from './agent.js';
6
- import { taskLine } from './shared.js';
6
+ import { scopeLine, taskLine, waiting } from './shared.js';
7
7
  export const briefing = (s) => {
8
8
  const r = Session.briefing(s);
9
- out(`${s.agent} in ${s.hub}/${s.room}; scope: ${s.scope ?? 'none recorded; ask the operator what to accept'}`);
9
+ const auto = r.start.quorum !== null;
10
+ out(`${s.agent} in ${s.hub}/${s.room}; scope: ${scopeLine(s, auto)}`);
10
11
  out('agent commands: ringi agent inbox | reply <id> … | send … | tasks | proposals | whoami. A turn: inbox, your part, replies, end the turn. Never block on a peer.');
12
+ if (!r.start.started)
13
+ return out(waiting(s.room, r.start));
14
+ if (auto)
15
+ out(`started: room ${s.room} has its quorum (${r.start.agents} of ${r.start.quorum} agents); your first turn starts now, on the work and inbox below; nothing to confirm with the operator`);
11
16
  if (!r.tasks.length)
12
17
  out(`no open work (profile ${r.actor.R.profile.name}; tasks --all for everything)`);
13
18
  for (const task of r.tasks)
@@ -6,12 +6,22 @@ import * as Budget from '../app/budget.js';
6
6
  import * as Room from '../app/room.js';
7
7
  import { checkPath, withSizes } from '../app/room.js';
8
8
  import { asJson, oneLine, render } from '../app/view.js';
9
- import { fail, note, short, when, words } from '../lib/index.js';
9
+ import { bytes, fail, note, short, when, words } from '../lib/index.js';
10
10
  import { attachments, dump, flag, list, opt, out, textOf } from './args.js';
11
11
  const sendInput = (o) => ({ text: textOf(o), to: words(opt(o, 'to')), all: flag(o, 'all'), task: flag(o, 'task'),
12
12
  state: opt(o, 'state'), assign: opt(o, 'assign') ? words(opt(o, 'assign')) : undefined, roles: opt(o, 'roles'), grant: opt(o, 'grant'),
13
13
  files: list(o, 'attach').flatMap(attachments) });
14
14
  const sent = (r) => out(`sent ${short(r.id)} to ${r.to.join(', ')}${r.files ? ` with ${r.files} file(s)` : ''}`);
15
+ // Stages files for the actor's next message, like git add: their content is taken into the hub now. Without files it
16
+ // lists what is staged; --drop <path> and --clear take files back out.
17
+ export const add = (a, o) => {
18
+ if (flag(o, 'clear'))
19
+ Room.unstage(a, null);
20
+ else if (list(o, 'drop').length)
21
+ Room.unstage(a, list(o, 'drop'));
22
+ const staged = o._.length ? Room.stage(a, o._.flatMap(attachments)) : Room.staged(a);
23
+ out(staged.length ? `staged for your next message:\n${staged.map(s => ` ${s.path} (${bytes(s.size)})`).join('\n')}` : 'nothing staged');
24
+ };
15
25
  export const send = (a, o) => sent(Room.send(a, { ...sendInput(o), re: opt(o, 're') }));
16
26
  export const reply = (a, o) => sent(Room.send(a, { ...sendInput(o), re: o._[0] ?? fail('usage: reply <msg> -m <text> …'), reply: true }));
17
27
  export const read = (a, o) => {
@@ -140,4 +150,10 @@ export const grants = (a, o) => {
140
150
  out(`${short(g.grant)} ${g.revoked ? 'revoked ' : u.left ? 'active ' : 'spent '} runs ${u.runs}/${g.budget.runs}, open ${u.open}${u.uncertain ? `, uncertain ${u.uncertain}` : ''}, ${u.seconds}s used, ≤${g.budget['run-seconds']}s a run, concurrency ${g.budget.concurrency} ${g.subject}`);
141
151
  }
142
152
  };
153
+ // What the session may do on its own: the recorded scope; without one, in a room with a quorum the room's work for it,
154
+ // in any other what the operator accepts when asked.
155
+ export const scopeLine = (s, quorum = false) => s.scope
156
+ ?? (quorum ? 'none recorded; the room\'s work for you, split with the other members' : 'none recorded; ask the operator what to accept');
157
+ // The line that keeps an agent of a room waiting for its quorum from doing anything yet.
158
+ export const waiting = (room, st) => `waiting: room ${room} waits for a quorum of ${st.quorum} agents and has ${st.agents}; nothing to do yet, end the turn. Its work starts by itself at the quorum`;
143
159
  //# sourceMappingURL=shared.js.map
@@ -16,6 +16,11 @@ import { fail, short, strictJson } from '../lib/index.js';
16
16
  export const HUB_MAJOR = 1;
17
17
  export const CAPS = new Set(Object.keys(CAPABILITIES));
18
18
  export const RESERVED_ROOMS = new Set(['main', 'archive']);
19
+ // Whether the room's work has started: it has the quorum of agents its genesis asked for (`Quorum`), or asked for none.
20
+ export const start = (R) => {
21
+ const agents = Object.values(R.members).filter(m => m.role !== 'root').length;
22
+ return { quorum: R.quorum, agents, started: R.quorum === null || agents >= R.quorum };
23
+ };
19
24
  // --- hubs ---
20
25
  export const hubNames = () => {
21
26
  try {
@@ -151,8 +156,15 @@ export const openRoom = (H, name, { quiet = false } = {}) => {
151
156
  ?? (baseProfile === DEFAULT_PROFILE ? readProfileResource(DEFAULT_PROFILE) : fail(`room ${name}: unknown profile ${baseProfile}`));
152
157
  const roster = H.store.roster(s);
153
158
  const members = roster?.members ?? strictJson(H.store.controlFile(s, 'members.json') ?? '{}', `room ${name}: members.json`);
159
+ // The root's control records set the quorum; the latest one counts.
160
+ let quorum = null;
161
+ for (const rec of s.control) {
162
+ const m = /^Quorum: ([1-9][0-9]?)$/m.exec(rec.text);
163
+ if (m)
164
+ quorum = Number(m[1]);
165
+ }
154
166
  return { hub: H, s, name, archived: s.archived, head: s.head, genesis: s.genesis, contractRev: current, contract, authorityAt,
155
- profile: parseProfile(profileText, `room ${name}: profile`), members };
167
+ profile: parseProfile(profileText, `room ${name}: profile`), members, quorum };
156
168
  };
157
169
  export const room = (H, name) => openRoom(H, name);
158
170
  export const ROOM_MD = (name) => `# room ${name}\n\nA ringi room: members.json lists who takes part; profile.json is what a piece of work is here, fixed when the room was made.\n`;
@@ -4,7 +4,7 @@
4
4
  import { spawn } from 'node:child_process';
5
5
  import * as D from '../domain/decisions.js';
6
6
  import { HOME, OPERATOR, config, locked } from '../home/index.js';
7
- import { HIDDEN, append, openHub, openRoom, pongText } from '../hub/index.js';
7
+ import { HIDDEN, append, openHub, openRoom, pongText, start } from '../hub/index.js';
8
8
  import { loadRoomUnlocked, loadState, readerLock, saveState } from './reader.js';
9
9
  import { isOpen, taskFor, taskOf } from '../domain/profiles.js';
10
10
  import { compose, link, unread, wakes } from '../protocol/index.js';
@@ -137,6 +137,16 @@ export const watch = async (w, log = note) => {
137
137
  log(`watch: ${me} was removed from ${R0.name}; stopping`);
138
138
  return 0;
139
139
  }
140
+ // A room waiting for its quorum runs no turn yet.
141
+ const st = start(R);
142
+ if (!st.started) {
143
+ if (w.once) {
144
+ log(`watch: ${R0.name} waits for a quorum of ${st.quorum} agents and has ${st.agents}; nothing to do yet`);
145
+ return 0;
146
+ }
147
+ await sleep(interval);
148
+ continue;
149
+ }
140
150
  const P = R.profile;
141
151
  // Lock order throughout planning and dispatch: grant, reader, sessions (then the store's own writes).
142
152
  const plan = locked(`grant-${H.name}`, () => readerLock(R, me, () => {
@@ -97,7 +97,7 @@ export class GitStore {
97
97
  if (drop.length)
98
98
  this.git(['update-index', '--index-info'], { env, input: drop.map(p => `0 ${ZERO}\t${p}\n`).join('') });
99
99
  for (const e of entries) {
100
- const sha = this.git(['hash-object', '-w', ...(e.file ? ['--', e.file] : ['--stdin'])], { input: e.content }).trim();
100
+ const sha = e.blob ?? this.git(['hash-object', '-w', ...(e.file ? ['--', e.file] : ['--stdin'])], { input: e.content }).trim();
101
101
  this.git(['update-index', '--add', '--cacheinfo', `${e.mode},${sha},${e.path}`], { env });
102
102
  }
103
103
  return this.git(['write-tree'], { env }).trim();
@@ -514,6 +514,7 @@ export class GitStore {
514
514
  files: files.filter(f => REGULAR.has(f.mode)), refused: [...refused, ...files.filter(f => !REGULAR.has(f.mode))] }];
515
515
  });
516
516
  }
517
+ blob(file) { return this.git(['hash-object', '-w', '--', file]).trim(); }
517
518
  // One message on top of the room's branch.
518
519
  append(room, who, text, entries) {
519
520
  if (room.archived)