connection-centric 0.1.0 → 0.2.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/GUIDE.md CHANGED
@@ -8,7 +8,7 @@ Connection-Centric is where a construction firm, its site staff and its subcontr
8
8
 
9
9
  centric connect --name "Claude Code on the site laptop"
10
10
 
11
- This prints a link and an 8-character code. The person opens the link on their phone, or types the code under Me, Connected tools, and approves the tool by name, which they may change. The code works once, for 10 minutes. The credential is kept in the person's own config directory, readable by them alone. For a script on a server, `CENTRIC_CREDENTIAL` and `CENTRIC_SERVER` take its place; `--server` names another server for one command.
11
+ This prints an 8-character code. The person types it on their own phone under Me, Connected tools, Connect a tool, and approves the tool by name, which they may change. The code works once, for 10 minutes. There is no link to open instead: a link can be sent to anyone, and one tap would connect the sender's tool. The credential is kept in the person's own config directory, readable by them alone. For a script on a server, `CENTRIC_CREDENTIAL` and `CENTRIC_SERVER` take its place; `--server` names another server for one command.
12
12
 
13
13
  A tool lapses after 90 days unused. The person disconnects it from Me, or with `centric connected-tool disconnect --this`; Centric can too. Rotating the person's key, or putting them to sleep, disconnects every tool they have. A Company's admin cannot disconnect one.
14
14
 
@@ -20,7 +20,7 @@ A tool lapses after 90 days unused. The person disconnects it from Me, or with `
20
20
  - A target is an id, or an exact name that only one thing the person can see has, in any case. Two things with that name, and the command is refused, listing both with their ids. Output always carries ids: pass ids.
21
21
  - A person is named by identity id, by phone number in E.164 (`+` and the country code, then the number, no spaces: `+91` and ten digits in India), or by exact display name.
22
22
  - `--in <project|company>` says where, for anything inside a container, and narrows a name to that container.
23
- - The audience is never assumed. Making a channel, task or document in a Project needs `--externals visible` or `--externals hidden`. In a Company everyone sees it, and the flag is refused.
23
+ - The audience is never assumed. Making a channel or document in a Project needs `--externals visible` or `--externals hidden`. In a Company everyone sees it, and the flag is refused. A task takes its audience from where it is talked about: posted in a channel (`task make --channel`), that channel's; raised from a message (`--from-message`), that message's channel's, or staff only with `--externals hidden`.
24
24
  - A mention is only ever a flag: `--mention <identity>`, again for each, or `--mention-all`. Writing `@Name` in the text reaches nobody.
25
25
  - `--text -` reads the words from standard input.
26
26
  - `--path` names a file on this computer. Over MCP it is a data: URL instead: `data:<type>;name=<file name>;base64,<bytes>`.
@@ -32,8 +32,8 @@ A tool lapses after 90 days unused. The person disconnects it from Me, or with `
32
32
  - **company**: the one container a firm holds for work that belongs to no site. Never archived; nobody makes one but Centric, when an Operator approves its Application.
33
33
  - **channel**: a named conversation inside a container, and the only place a container's messages live. In a Project it is either visible to externals or staff-only. A Company channel may be private: read only by the people its admins name.
34
34
  - **message**: one message in a channel or a Direct. A reply quotes the message it answers. Files sent in a channel are kept as documents.
35
- - **direct**: a conversation between two people who share a container. Nothing in it is part of the record: no task, no issuance, no deleting, and the assistant never reads or corrects one.
36
- - **task**: an open piece of work in one container, which anyone who can see it may close or reopen. It may carry details, a due day, a priority and a zone. Open past its due day, or with an ask escalated, it is overdue.
35
+ - **direct**: a conversation between two people who share a container, or a person's own with themselves, for notes and what they keep to file later. Name your own `me` (`direct send me`); `direct list` marks it `you`. Nothing in a Direct is part of the record: no task, no issuance, no deleting, and the assistant never reads or corrects one.
36
+ - **task**: an open piece of work in one container, which anyone who can see it may close or reopen. Every task is part of one channel's conversation: made on its own, it is posted there as your message; what is said about it is a reply to that message (`message send --task`). It may carry details, a due day, a priority and a zone. Open past its due day, or with an ask escalated, it is overdue.
37
37
  - **ask**: one person's request to another, on a task, closed by closing the task. The person asked may answer not yet, saying why and when to be asked again.
38
38
  - **issuance**: a document revision or an instruction put to a named person, who must acknowledge it. Acknowledging is deliberate and is the record; reading or downloading is never one.
39
39
  - **document**: a file kept as a series of revisions; a new revision never replaces an old one. Every photo or file sent in a channel is one.
@@ -54,7 +54,7 @@ A tool lapses after 90 days unused. The person disconnects it from Me, or with `
54
54
  ## What is refused, and why
55
55
 
56
56
  - Anything the person could not do in the app. An external never sees a staff-only channel, task or document; a private channel is read only by the people named in it; only an admin changes who belongs, and only staff delete.
57
- - **The cap.** What reaches someone else's phone (asks, issuances, mentions, `@all`, Directs) is capped at 30 an hour for each tool. Past it, the command is refused with `retry_after`, the seconds until it may try again. Reading and closing are never capped.
57
+ - **The cap.** What reaches someone else's phone (asks, issuances, mentions, `@all`, Directs with someone else) is capped at 30 an hour for each tool. Past it, the command is refused with `retry_after`, the seconds until it may try again. Reading and closing are never capped.
58
58
  - A tool is never connected from a tool: approving one is the person's own act, on their phone.
59
59
  - `--dry-run`, on the acts the app warns about before doing them, says what would change and changes nothing: `channel edit --externals` (how many messages change who can see them), `channel private` and `channel open-to-company` (who gains it and who loses it), `membership revoke` (what they lose), and `identity dormant` and `identity rotate-key` (every firm they belong to, and every tool that would disconnect).
60
60
 
@@ -74,7 +74,8 @@ Fetching never marks anything read. A sender told their message was read must ha
74
74
 
75
75
  - Text for a person by default. With `--json`, exactly one JSON value on stdout: the object for `show`, `make`, `edit` and every act, or `{"items": […], "more": <cursor or null>}` for `list` and the places. Field names are the API's own.
76
76
  - Days are `YYYY-MM-DD` in India's time zone; moments are ISO 8601 with `+05:30`.
77
- - `0` done. `1` refused: not allowed, not valid, over a limit, or an ambiguous target. `2` broken: the network, the server, or a command line older than the server accepts, which says how to update.
77
+ - A message has `id`, `at`, `sender`, `text` and where it was said, `channel` or `direct`, among its fields. One that shows a photo or file has `document` too, its `id`, `name` and `revision_n`: `document download <id> --revision <n>` fetches what the message showed. Among a channel's messages, a deleted one stands where it was as `id` and `tombstone` (`deleted_at`, `deleted_by_name`), with no `at`, `sender` or `text`.
78
+ - `0` done. `1` refused: not allowed, not valid, over a limit, or an ambiguous target. `2` broken: the network, the server, or a command line older than the server accepts, which says how to update. The command line asks a read again once before calling it broken; over MCP, that is the client's to do. A write is never asked again; broken on the way, it says it may have been done, so look before doing it twice.
78
79
  - A refusal under `--json` prints `{"error": {"kind": "refused" | "broken", "message": …, "retry_after"?: …, "candidates"?: […]}}` on stderr.
79
80
 
80
81
  ## Over MCP
package/README.md CHANGED
@@ -5,11 +5,11 @@ The command line for [Connection-Centric](https://app.centric.business): what on
5
5
  npm install --global connection-centric
6
6
  centric connect --name "Claude Code on the site laptop"
7
7
 
8
- `connect` prints a link and a code. Open the link on your phone, or type the code in the app under Me, Connected tools, and approve the tool. From then on:
8
+ `connect` prints a code. Type it on your own phone, in the app under Me, Connected tools, Connect a tool, and approve the tool. Never type a code someone sent you: it would connect their tool as you. From then on:
9
9
 
10
10
  centric home # what needs you now
11
11
  centric round # what has something unread, addressed to you first
12
- centric task make --in "Shilaj Tower B" --title "Check level 5 props" --externals visible
12
+ centric task make --channel "Structural works" --title "Check level 5 props"
13
13
  centric guide # everything else: every command, what is refused and why
14
14
 
15
15
  Everything the tool does carries its name, for everyone who sees it. It is disconnected from Me in the app, and lapses after 90 days unused. Node 22 or later; no dependencies. The same commands are tools of the MCP server at `https://app.centric.business/api/mcp`.
package/centric.mjs CHANGED
@@ -54,14 +54,22 @@ function caller(server, credential) {
54
54
  // JSON both ways; or a file's bytes up (file), or down (bytes: true).
55
55
  const call = async (method, path, body, { anon = false, raw = false, file = null, bytes = false } = {}) => {
56
56
  if (!anon && !credential) throw new Refused("this machine is not connected; run centric connect");
57
+ const headers = { "x-centric-cli": CLI_VERSION, ...(anon ? {} : { authorization: `Bearer ${credential}` }), ...(file ? { "content-type": TYPES[extname(file.name).toLowerCase()] ?? "application/octet-stream", "x-centric-name": encodeURIComponent(file.name) } : body ? { "content-type": "application/json" } : {}) };
58
+ // A read lost to a restart (a deploy's is under a second: the connection drops, or the proxy answers 502 to 504) is
59
+ // asked again once; a write never is, since it may have been done, and an agent told so looks before doing it twice.
60
+ const lost = (status) => status >= 502 && status <= 504, unsure = method === "GET" ? "" : "; whether it was done is not known, so look before doing it again";
57
61
  let r;
58
- try {
59
- const headers = { "x-centric-cli": CLI_VERSION, ...(anon ? {} : { authorization: `Bearer ${credential}` }), ...(file ? { "content-type": TYPES[extname(file.name).toLowerCase()] ?? "application/octet-stream", "x-centric-name": encodeURIComponent(file.name) } : body ? { "content-type": "application/json" } : {}) };
60
- r = await fetch(new URL(path, server), { method, headers, body: file ? file.bytes : body ? JSON.stringify(body) : undefined });
61
- } catch (e) { throw new Broken(`could not reach ${server}: ${e.cause?.code ?? e.message}`); }
62
+ for (let tries = method === "GET" ? 2 : 1; ; await sleep(2000)) {
63
+ try { r = await fetch(new URL(path, server), { method, headers, body: file ? file.bytes : body ? JSON.stringify(body) : undefined }); } catch (e) {
64
+ if (--tries) continue;
65
+ throw new Broken(`could not reach ${server}: ${e.cause?.code ?? e.message}${unsure}`);
66
+ }
67
+ if (!lost(r.status) || !--tries) break;
68
+ await r.body?.cancel(); // its connection goes back, not held for an answer nobody reads
69
+ }
62
70
  if (bytes && r.ok) return Buffer.from(await r.arrayBuffer());
63
71
  const j = await r.json().catch(() => null);
64
- if (r.status === 426 || r.status >= 500) throw new Broken(j?.error ?? `the server answered ${r.status}`);
72
+ if (r.status === 426 || r.status >= 500) throw new Broken(`${j?.error ?? `the server answered ${r.status}`}${lost(r.status) ? unsure : ""}`);
65
73
  if (r.status >= 400) throw new Refused(j?.error ?? `refused (${r.status})`, { ...(j?.retry_after != null && { retry_after: j.retry_after }) });
66
74
  return raw ? { status: r.status, body: j } : j;
67
75
  };
@@ -81,14 +89,15 @@ function caller(server, credential) {
81
89
  return c;
82
90
  }
83
91
 
84
- // The pairing (ADR 0008): a link and a code for the person's phone, then the credential once they approve.
92
+ // The pairing (ADR 0008): a code the person types on their phone, then the credential once they approve. Never a
93
+ // link: one can be sent to anyone, and a tap would connect the sender's tool.
85
94
  async function connect(c, a, server) {
86
95
  const name = a.name ?? hostname().slice(0, 60);
87
96
  let got;
88
97
  if (a["dev-phone"]) got = await c.call("POST", "/api/connected-tools/dev", { phone: a["dev-phone"], name }, { anon: true });
89
98
  else {
90
99
  const p = await c.call("POST", "/api/connected-tools", { name }, { anon: true });
91
- process.stderr.write(`To connect "${p.name}", open this on your phone:\n ${p.link}\nor type ${p.code} in the app, under Me, Connected tools.\nThe code works once, for ${Math.round(p.expires_in / 60)} minutes.\n`);
100
+ process.stderr.write(`To connect "${p.name}", type ${p.code} in the app, under Me, Connected tools, Connect a tool.\nThe code works once, for ${Math.round(p.expires_in / 60)} minutes. Type it on your own phone; never send it to anyone.\n`);
92
101
  for (;;) {
93
102
  const r = await c.call("POST", `/api/connected-tools/${p.id}/collect`, { poll: p.poll }, { anon: true, raw: true });
94
103
  if (r.status !== 202) { got = r.body; break; }
@@ -104,9 +113,10 @@ async function connect(c, a, server) {
104
113
  // ── printing ──
105
114
  const clip = (s, n = 90) => { s = String(s).replace(/\s+/g, " "); return s.length > n ? `${s.slice(0, n - 1)}…` : s; };
106
115
  const label = (x) => x.name ?? x.title ?? x.topic_name ?? x.task_title ?? x.document_name ?? x.display_name ?? x.firm_name
107
- ?? (x.text != null ? `${x.sender?.display_name ?? "?"}${x.via ? ` (via ${x.via.name})` : ""}: ${clip(x.text, 70)}` : x.tombstone ? `deleted by ${x.tombstone.deleted_by_name}` : null);
116
+ ?? (x.text != null ? `${x.sender?.display_name ?? "?"}${x.via ? ` (via ${x.via.name})` : ""}: ${clip(x.text, 70)}` : (x.tombstone ?? x).deleted_by_name ? `deleted by ${(x.tombstone ?? x).deleted_by_name}` : null);
108
117
  const where = (x) => x.container_name ?? (x.channel ? `${x.channel.name} · ${x.channel.container_name}` : x.direct ? `Direct with ${x.direct.display_name}` : null);
109
- const row = (x) => (x && typeof x === "object" ? [x.kind && `[${x.kind}]`, x.id, label(x), where(x), x.due_on && `due ${x.due_on}`, x.this && "(this one)"].filter((v) => v != null && v !== "").join(" ") : String(x));
118
+ // A tombstone in channel show is named by the message it stands for; a message with a file, by the document to download.
119
+ const row = (x) => (x && typeof x === "object" ? [x.kind && `[${x.kind}]`, x.id ?? x.zulip_message_id, label(x), where(x), x.document && `document ${x.document.id}`, x.due_on && `due ${x.due_on}`, x.this && "(this one)"].filter((v) => v != null && v !== "").join(" ") : String(x));
110
120
  // Words others wrote, on a terminal: control characters (escape sequences among them) go, but a line and a tab stay.
111
121
  const plain = (s) => s.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
112
122
  // JSON escapes the first 32 itself; the C1 ones a terminal may also obey are escaped too, so nothing is lost.
package/commands.mjs CHANGED
@@ -96,7 +96,7 @@ export async function findTarget(get, kind, ref, within) {
96
96
  refuse(rows.length ? `${rows.length} things you can see are called "${ref}"; name one by its id` : `nothing called "${ref}" that you can see is a ${kind}${within ? " there" : ""}`, rows.length ? { candidates: rows } : {});
97
97
  }
98
98
 
99
- const IN = { kind: "container", need: true, about: "the Project or Company, by id or exact name" };
99
+ const IN = { kind: "container", need: true, about: "the Project or Company" };
100
100
  const EXTERNALS = { kind: "choice", of: ["visible", "hidden"], about: "in a Project, whether its externals see it; never in a Company" };
101
101
  const PRIORITY = { kind: "choice", of: ["high", "normal", "low"] };
102
102
  const TEXT = { kind: "text", need: true, about: "the words, or - to read them from standard input" };
@@ -225,9 +225,19 @@ export const COMMANDS = [
225
225
  async run(c) { const m = await c.get("/api/me"); return listed(m.operator ? await c.get("/api/applications") : m.application ? [m.application] : []); } },
226
226
 
227
227
  // ── the day's work ──
228
- { noun: "task", verb: "make", flags: { in: IN, title: { kind: "words", need: true }, externals: EXTERNALS, details: { kind: "text" }, due: { kind: "day" }, priority: PRIORITY, zone: { kind: "zone" }, document: { kind: "document", about: "a document it points at" }, "from-message": { kind: "message", about: "the message it is raised from" } },
229
- about: "Makes a task in a container.",
230
- run: async (c, a) => c.post(`/api/containers/${a.in}/tasks`, { title: a.title, externals_visible: await audience(c, a.in, a.externals), details: a.details, due_on: a.due, priority: a.priority, zone_id: a.zone, document_id: a.document, source_message_id: a["from-message"] }) },
228
+ // Every task is part of one channel's conversation (CONTEXT.md Task): posted there as your message, or raised from one.
229
+ { noun: "task", verb: "make", flags: { channel: { kind: "channel", about: "the channel it is posted in, as your message; its audience is the channel's" }, "from-message": { kind: "message", about: "the message it is raised from, instead" },
230
+ in: { kind: "container", about: "with --from-message: the Project or Company it is in" }, title: { kind: "words", need: true }, externals: { kind: "choice", of: ["hidden"], about: "with --from-message: staff only, narrower than the message's channel" },
231
+ details: { kind: "text" }, due: { kind: "day" }, priority: PRIORITY, zone: { kind: "zone" }, document: { kind: "document", about: "a document it points at" } },
232
+ about: "Makes a task: posted in a channel as your message, which is where it is talked about; or raised from a message already said.",
233
+ async run(c, a) {
234
+ if (!a.channel === !a["from-message"]) refuse("say where it is talked about: --channel to post it there, or --from-message with --in");
235
+ if (a.channel && (a.in || a.externals)) refuse("a task posted in a channel is that channel's container and audience: leave --in and --externals off");
236
+ if (a["from-message"] && !a.in) refuse("--from-message needs --in, the Project or Company the message is in");
237
+ const fields = { title: a.title, details: a.details, due_on: a.due, priority: a.priority, zone_id: a.zone, document_id: a.document };
238
+ if (a.channel) return c.post(`/api/containers/${(await c.get(`/api/channels/${a.channel}`)).container_id}/tasks`, { ...fields, channel_id: a.channel });
239
+ return c.post(`/api/containers/${a.in}/tasks`, { ...fields, source_message_id: a["from-message"], ...(a.externals && { externals_visible: false }) });
240
+ } },
231
241
  { noun: "task", verb: "edit", target: { name: "task", kind: "task" }, flags: { title: { kind: "words" }, details: { kind: "text", none: true }, due: { kind: "day", none: true }, priority: PRIORITY, zone: { kind: "zone", none: true } },
232
242
  about: "Changes the named fields of a task; only what is sent changes. none clears the details, due day or zone.",
233
243
  async run(c, a) {
@@ -237,11 +247,11 @@ export const COMMANDS = [
237
247
  ...[["close", "Marks a task done: whoever can see a task may close it, and the asks on it close with it."], ["reopen", "Opens a done task again."]].map(([verb, about]) => (
238
248
  { noun: "task", verb, target: { name: "task", kind: "task" }, flags: {}, about, run: (c, a) => c.post(`/api/tasks/${a.task}/${verb}`) })),
239
249
  { noun: "task", verb: "delete", target: { name: "task", kind: "task" }, flags: {}, about: "Deletes a task, leaving a tombstone: staff, or whoever made it.", run: (c, a) => c.del(`/api/tasks/${a.task}`) },
240
- { noun: "ask", verb: "make", flags: { task: { kind: "task", about: "the task to ask about" }, to: { kind: "identity", need: true }, in: { kind: "container" }, title: { kind: "words", about: "for an ask with a task of its own" }, externals: EXTERNALS, due: { kind: "day" } },
241
- about: "Asks someone to do a task; or, given --in and --title instead of --task, makes the task too, as the app does.",
250
+ { noun: "ask", verb: "make", flags: { task: { kind: "task", about: "the task to ask about" }, to: { kind: "identity", need: true }, channel: { kind: "channel", about: "for an ask with a task of its own: the channel the task is posted in" }, title: { kind: "words", about: "for an ask with a task of its own" }, due: { kind: "day" } },
251
+ about: "Asks someone to do a task; or, given --channel and --title instead of --task, makes the task too, posted in that channel, as the app does.",
242
252
  async run(c, a) {
243
- if (a.task ? a.title || a.externals || a.due : !(a.in && a.title)) refuse("either --task, or --in and --title (with --externals in a Project) for an ask with a task of its own");
244
- const task = a.task ?? (await c.post(`/api/containers/${a.in}/tasks`, { title: a.title, externals_visible: await audience(c, a.in, a.externals), due_on: a.due })).id;
253
+ if (a.task ? a.title || a.channel || a.due : !(a.channel && a.title)) refuse("either --task, or --channel and --title for an ask with a task of its own");
254
+ const task = a.task ?? (await c.post(`/api/containers/${(await c.get(`/api/channels/${a.channel}`)).container_id}/tasks`, { title: a.title, channel_id: a.channel, due_on: a.due })).id;
245
255
  return c.post(`/api/tasks/${task}/asks`, { to_id: a.to });
246
256
  } },
247
257
  { noun: "ask", verb: "not-yet", target: { name: "ask", kind: "id" }, flags: { reason: { kind: "choice", of: Object.keys(REASONS), need: true }, again: { kind: "choice", of: ["today", "tomorrow", "due", "week"], about: "when to be asked again; tomorrow if left out" } },
@@ -258,9 +268,9 @@ export const COMMANDS = [
258
268
  } },
259
269
  { noun: "message", verb: "issue", target: { name: "message", kind: "message" }, flags: { to: { kind: "identity", need: true } }, about: "Issues a channel message as an instruction to someone.",
260
270
  run: (c, a) => c.post("/api/issuances", { zulip_message_id: a.message, issued_to: a.to }) },
261
- { noun: "message", verb: "send", flags: { channel: { kind: "channel" }, task: { kind: "task", about: "in the task's own channel, opened for it if it has none" }, text: { ...TEXT, need: false }, "reply-to": { kind: "message", about: "a message of the same place to answer" },
271
+ { noun: "message", verb: "send", flags: { channel: { kind: "channel" }, task: { kind: "task", about: "a reply to the task's message, in its channel" }, text: { ...TEXT, need: false }, "reply-to": { kind: "message", about: "a message of the same place to answer" },
262
272
  mention: { kind: "identity", many: true, about: "someone who reads the channel" }, "mention-all": { kind: "bool", about: "everyone who reads the channel" }, path: { kind: "path", many: true, about: "a photo or file on this computer; each is kept as a document" } },
263
- about: "Sends a message to a channel, or to a task's own channel. A mention is only ever a flag: @Name in the text reaches nobody. Files sent with --path are kept as documents, the words the last one's caption.",
273
+ about: "Sends a message to a channel, or a reply to a task's message where it is talked about. A mention is only ever a flag: @Name in the text reaches nobody. Files sent with --path are kept as documents, the words the last one's caption.",
264
274
  async run(c, a) {
265
275
  if (!a.channel === !a.task) refuse("say where: --channel or --task");
266
276
  if (!a.text && !a.path) refuse("say something: --text, or --path for a file");
@@ -275,7 +285,7 @@ export const COMMANDS = [
275
285
  run: (c, a) => c.post(`/api/messages/${a.message}/reactions`, { emoji: a.emoji }) },
276
286
  { noun: "message", verb: "unreact", target: { name: "message", kind: "message" }, flags: { emoji: EMOJI }, about: "Takes your reaction back.",
277
287
  run: (c, a) => c.del(`/api/messages/${a.message}/reactions`, { emoji: a.emoji }) },
278
- { noun: "direct", verb: "send", target: { name: "identity", kind: "identity" }, flags: { text: TEXT }, about: "Writes to someone in your Direct with them. It passes through and is kept nowhere.",
288
+ { noun: "direct", verb: "send", target: { name: "identity", kind: "identity" }, flags: { text: TEXT }, about: "Writes to someone in your Direct with them, or to yourself as me. It passes through and is kept nowhere.",
279
289
  run: (c, a) => c.post("/api/messages", { direct: a.identity, text: a.text }) },
280
290
  { noun: "channel", verb: "mark-read", target: { name: "channel", kind: "channel" }, flags: { through: { kind: "message", about: "up to and including this message" } }, about: "Read, on purpose: tells its senders their messages were read.",
281
291
  run: (c, a) => c.post("/api/messages/read", { channel: a.channel, through: a.through }) },
@@ -388,8 +398,8 @@ export const COMMANDS = [
388
398
  { noun: "identity", verb: "rotate-key", target: { name: "identity", kind: "identity" }, flags: { "dry-run": { ...DRY, about: "every firm they belong to, and every tool of theirs that would disconnect" } },
389
399
  about: "Signs a person out everywhere: their phone signs in again, and every tool of theirs is disconnected.", run: (c, a) => c.post(`/api/identities/${a.identity}/rotate-key`, { dry_run: a["dry-run"] }) },
390
400
  { noun: "identity", verb: "dormant", target: { name: "identity", kind: "identity" }, flags: { in: { kind: "container", about: "where to look for their name" }, "dry-run": { ...DRY, about: "every firm they belong to, and every tool of theirs that would disconnect" } },
391
- about: "Puts a person to sleep, as after their number changed hands: every membership ends, and every tool of theirs is disconnected.", run: (c, a) => c.post(`/api/identities/${a.identity}/dormant`, { dry_run: a["dry-run"] }) },
392
- { noun: "identity", verb: "reactivate", target: { name: "identity", kind: "identity" }, flags: {}, about: "Brings a person put to sleep back; an admin names them on containers again.",
401
+ about: "Puts a person to sleep, as after their number changed hands: every membership ends, and every tool of theirs is disconnected. Centric's Operators only, by the id a firm's admin gives them from identity show; an admin revokes the membership.", run: (c, a) => c.post(`/api/identities/${a.identity}/dormant`, { dry_run: a["dry-run"] }) },
402
+ { noun: "identity", verb: "reactivate", target: { name: "identity", kind: "identity" }, flags: {}, about: "Brings a person put to sleep back; an admin names them on containers again. Centric's Operators only.",
393
403
  run: (c, a) => c.post(`/api/identities/${a.identity}/reactivate`) },
394
404
  { noun: "access-link", verb: "make", flags: { in: { ...IN, kind: "project" }, phone: { kind: "phone", need: true }, designation: { kind: "words", need: true }, task: { kind: "task", about: "an ask's task, shown when the link opens" } },
395
405
  about: "Makes an access link for someone with no account: what externals may see, and one reply. Staff only.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "connection-centric",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The centric command: what a person does in Connection-Centric, done as them from a terminal, a script, or a program such as Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {