connection-centric 0.1.0 → 0.3.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,9 @@ 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
+ - **pinned-message**: a message kept at the top of its channel, Direct or conversation, for everyone in it, at most 50 in one place. In a Project only staff pin; anyone who may pin may unpin. A channel's pins are on the record; a Direct's and a conversation's are not.
36
+ - **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.
37
+ - **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
38
  - **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
39
  - **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
40
  - **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 +55,7 @@ A tool lapses after 90 days unused. The person disconnects it from Me, or with `
54
55
  ## What is refused, and why
55
56
 
56
57
  - 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.
58
+ - **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
59
  - A tool is never connected from a tool: approving one is the person's own act, on their phone.
59
60
  - `--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
61
 
@@ -74,7 +75,8 @@ Fetching never marks anything read. A sender told their message was read must ha
74
75
 
75
76
  - 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
77
  - 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.
78
+ - 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`.
79
+ - `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
80
  - A refusal under `--json` prints `{"error": {"kind": "refused" | "broken", "message": …, "retry_after"?: …, "candidates"?: […]}}` on stderr.
79
81
 
80
82
  ## 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; }
@@ -96,17 +105,18 @@ async function connect(c, a, server) {
96
105
  }
97
106
  }
98
107
  mkdirSync(dir(), { recursive: true, mode: 0o700 });
99
- writeFileSync(file(), JSON.stringify({ server, credential: got.credential, tool: got.tool, identity: got.identity }, null, 2), { mode: 0o600 });
108
+ writeFileSync(file(), JSON.stringify({ server, credential: got.credential }, null, 2), { mode: 0o600 });
100
109
  chmodSync(file(), 0o600); // a file there already keeps its old mode otherwise
101
110
  return { tool: got.tool, identity: got.identity, server };
102
111
  }
103
112
 
104
113
  // ── printing ──
105
- const clip = (s, n = 90) => { s = String(s).replace(/\s+/g, " "); return s.length > n ? `${s.slice(0, n - 1)}…` : s; };
114
+ const clip = (s) => { s = String(s).replace(/\s+/g, " "); return s.length > 70 ? `${s.slice(0, 69)}…` : 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)}` : (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.
@@ -127,12 +137,14 @@ async function main(argv) {
127
137
  let json = argv.includes("--json");
128
138
  try {
129
139
  const { cmd, given, opts, lead } = parse(argv);
130
- json = !!opts.json || json;
140
+ json ||= opts.json === true || opts.json === "true"; // --json=true too, which argv alone does not show
141
+ // Help is one JSON value too under --json (rule 9): a program reading stdout gets what it asked for, or an error.
142
+ const say = (h) => { process.stdout.write(`${json ? asJson({ help: h }) : h}\n`); return 0; };
131
143
  if (!cmd) {
132
- if (!lead.length || lead[0] === "help") { process.stdout.write(`${help(lead[0] === "help" && (commandOf(lead[1], null) ?? commandOf(lead[1], lead[2])))}\n`); return 0; }
144
+ if (!lead.length || lead[0] === "help") return say(help(lead[0] === "help" && (commandOf(lead[1], null) ?? commandOf(lead[1], lead[2]))));
133
145
  throw new Refused(`no command "${lead.slice(0, 2).join(" ")}"; centric help lists them`);
134
146
  }
135
- if (opts.help) { process.stdout.write(`${help(cmd)}\n`); return 0; }
147
+ if (opts.help) return say(help(cmd));
136
148
  const file = saved(), server = opts.server || process.env.CENTRIC_SERVER || file.server || DEFAULT_SERVER; // set but empty is not set
137
149
  // The credential crosses a network only encrypted: an http:// server is this machine's, for development.
138
150
  if (!/^https:\/\//i.test(server) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i.test(server)) throw new Refused(`${server} is not https; a credential is sent only over https, or to this machine`);
package/commands.mjs CHANGED
@@ -16,7 +16,7 @@ export const CLI_VERSION = JSON.parse(readFileSync(new URL("./package.json", imp
16
16
 
17
17
  // Rule 2: the verbs, each meaning one thing on every noun. A new verb is added here, and to the spec, before it is used.
18
18
  export const VERBS = ["list", "show", "make", "edit", "delete", "archive", "unarchive", "close", "reopen", "not-yet", "grant", "revoke", "appoint", "remove",
19
- "issue", "acknowledge", "add", "revise", "restore", "download", "messages", "mark-read", "send", "react", "unreact", "history", "readers",
19
+ "issue", "acknowledge", "add", "revise", "restore", "download", "messages", "mark-read", "send", "forward", "react", "unreact", "history", "readers",
20
20
  "pin", "unpin", "mute", "unmute", "private", "open-to-company", "set-picture", "remove-picture", "set-photo", "remove-photo",
21
21
  "approve", "reject", "withdraw", "rotate-key", "dormant", "reactivate", "report", "record-pack", "audit", "question", "correct", "disconnect"];
22
22
 
@@ -96,13 +96,14 @@ 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" };
103
103
  const PATH = { kind: "path", need: true, about: "a file on this computer" };
104
104
  const NOTE = { kind: "text", about: "what it is, or what changed" };
105
105
  const DRY = { kind: "bool", about: "say what would change, and change nothing" };
106
+ const SEARCH = { kind: "words" }; // only the messages holding these words (Features 523)
106
107
  const PAGE = { kind: "path", about: "write it as a page, a file ending .html" };
107
108
  // A page the server draws (a report, a record pack), written to this computer: never over a file already there.
108
109
  const page = async (c, path, out) => { if (!/\.html?$/i.test(out)) refuse("--out is a page: name it something.html"); const b = await c.bytes(path); await c.save(out, b); return { path: out, bytes: b.length }; };
@@ -130,11 +131,24 @@ export const COMMANDS = [
130
131
  ...named.items.map((x) => ({ kind: "mention", ...x })), ...directs.items.map((x) => ({ kind: "direct", ...x })),
131
132
  ]);
132
133
  } },
133
- { noun: "search", verb: null, target: { name: "words", kind: "words" }, flags: {}, about: "Channels, tasks, documents, zones, people and messages, by a word or two.",
134
+ // Search (CONTEXT.md Search, ADR 0009): the record by its words, every version of a message, the words inside a PDF, and
135
+ // your own Directs; narrowed by who, where, when, a document's kind and a state.
136
+ { noun: "search", verb: null, target: { name: "words", kind: "words", optional: true },
137
+ flags: { from: { kind: "identity", about: "only what this person did" },
138
+ in: { kind: "container" }, channel: { kind: "channel" }, task: { kind: "task", about: "only in its conversation" },
139
+ after: { kind: "day", about: "from this day" }, before: { kind: "day", about: "to this day" },
140
+ kind: { kind: "choice", of: ["photo", "pdf", "video", "voice", "spreadsheet", "other"] },
141
+ state: { kind: "choice", of: ["open", "closed", "unacknowledged"], about: "tasks open or closed; issuances not acknowledged" },
142
+ oldest: { kind: "bool", about: "oldest first, not best first" } },
143
+ about: "The record by its words: messages (earlier versions too), documents and their text, tasks, issuances, deletions, channels, people, your own Directs.",
134
144
  async run(c, a) {
135
- const [r, said] = await Promise.all([c.get(`/api/search?q=${enc(a.words)}`), c.get(`/api/messages?search=${enc(a.words)}&limit=20`)]);
136
- return listed([...r.channels.map((x) => ({ kind: "channel", ...x })), ...r.tasks.map((x) => ({ kind: "task", ...x })), ...r.documents.map((x) => ({ kind: "document", ...x })),
137
- ...r.zones.map((x) => ({ kind: "zone", ...x })), ...r.people.map((x) => ({ kind: "identity", ...x })), ...said.items.map((x) => ({ kind: "message", ...x }))]);
145
+ if (a.channel && a.task) refuse("--channel or --task, not both");
146
+ const by = { from: a.from, in: a.channel ?? a.task ?? a.in, after: a.after, before: a.before, kind: a.kind, state: a.state };
147
+ if (!a.words && !Object.values(by).some(Boolean)) refuse("search needs the words, or one of --from, --in, --channel, --task, --after, --before, --kind and --state");
148
+ const r = await c.get(`/api/search${qs({ q: a.words, ...by, order: a.oldest && "oldest" })}`), revised = new Set(r.revisions.map((x) => x.document_id));
149
+ return listed([...r.messages.map((x) => ({ kind: "message", ...x })), ...r.directs.map((x) => ({ kind: "direct", ...x })), ...r.revisions.map((x) => ({ kind: "revision", ...x })),
150
+ ...r.documents.filter((x) => !revised.has(x.id)).map((x) => ({ kind: "document", ...x })), ...r.tasks.map((x) => ({ kind: "task", ...x })), ...r.issuances.map((x) => ({ kind: "issuance", ...x })),
151
+ ...r.tombstones.map((x) => ({ kind: "tombstone", ...x })), ...r.channels.map((x) => ({ kind: "channel", ...x })), ...r.zones.map((x) => ({ kind: "zone", ...x })), ...r.people.map((x) => ({ kind: "identity", ...x }))]);
138
152
  } },
139
153
  { noun: "guide", verb: null, flags: { as: { kind: "choice", of: ["skill", "agents"], about: "as a Claude Code skill (a SKILL.md), or as a section an AGENTS.md can include" } },
140
154
  about: "The instructions for a program using this command: what each noun means, what is refused and why, the mark, the cap, --json, the exit codes, and every command.",
@@ -153,9 +167,13 @@ export const COMMANDS = [
153
167
  } },
154
168
 
155
169
  // ── work ──
156
- { noun: "task", verb: "list", flags: { in: IN, state: { kind: "choice", of: ["open", "closed"] }, zone: { kind: "zone" } },
170
+ { noun: "task", verb: "list", flags: { in: IN, state: { kind: "choice", of: ["open", "closed"] }, zone: { kind: "zone" }, before: { kind: "words", about: "with --state closed: the next page, from the more: of the one before" } },
157
171
  about: "A container's tasks: open ones first, soonest due, then the most important; done ones newest first, a page at a time.",
158
- run: async (c, a) => listed(await c.get(`/api/containers/${a.in}/tasks${qs({ state: a.state, zone_id: a.zone })}`)) },
172
+ run: async (c, a) => {
173
+ if (a.before && a.state !== "closed") refuse("--before pages done tasks: with --state closed");
174
+ const l = await c.get(`/api/containers/${a.in}/tasks${qs({ state: a.state, zone_id: a.zone, before: a.before })}`);
175
+ return listed(l, a.state === "closed" && l.length >= 100 ? l.at(-1).done_key : null); // the server's page of done ones (DONE_PAGE)
176
+ } },
159
177
  { noun: "task", verb: "show", target: { name: "task", kind: "task" }, flags: {}, about: "One task in full: its state, details, due day, priority, zone, and every ask on it with what happened since.",
160
178
  run: (c, a) => c.get(`/api/tasks/${a.task}`) },
161
179
  { noun: "ask", verb: "list", flags: { "to-me": { kind: "bool", about: "only asks of you" }, "from-me": { kind: "bool", about: "only your own asks" }, state: { kind: "choice", of: ["open", "closed"], about: "open (the default) or done" } },
@@ -163,9 +181,9 @@ export const COMMANDS = [
163
181
  run: (c, a) => (a["to-me"] && a["from-me"] ? refuse("--to-me or --from-me, not both") : c.get(`/api/asks${qs({ to_me: a["to-me"], from_me: a["from-me"], state: a.state })}`).then((x) => listed(x))) },
164
182
  { noun: "direct", verb: "list", flags: {}, about: "Your Directs with something in them, newest first.",
165
183
  run: async (c) => listed((await c.get("/api/places")).filter((p) => p.kind === "direct").sort(newest)) },
166
- { noun: "direct", verb: "messages", target: { name: "identity", kind: "identity" }, flags: { before: { kind: "message", about: "older than this message" }, limit: { kind: "int", about: "how many, up to 100 (50 if left out)" } },
184
+ { noun: "direct", verb: "messages", target: { name: "identity", kind: "identity" }, flags: { before: { kind: "message", about: "older than this message" }, limit: { kind: "int", about: "how many, up to 100 (50 if left out)" }, search: SEARCH },
167
185
  about: "A Direct's messages, newest last. Marks nothing read.",
168
- run: (c, a) => c.get(`/api/messages${qs({ direct: a.identity, before: a.before, limit: a.limit })}`) },
186
+ run: (c, a) => c.get(`/api/messages${qs({ direct: a.identity, before: a.before, limit: a.limit, search: a.search })}`) },
169
187
  { noun: "issuance", verb: "list", flags: { "to-me": { kind: "bool", about: "only what was issued to you" }, "by-me": { kind: "bool", about: "only what you issued" }, in: { kind: "container" }, state: { kind: "choice", of: ["open", "acknowledged"] } },
170
188
  about: "What was issued to you or by you, newest first.",
171
189
  run: (c, a) => (a["to-me"] && a["by-me"] ? refuse("--to-me or --by-me, not both") : c.get(`/api/issuances${qs({ to_me: a["to-me"], by_me: a["by-me"], in: a.in, state: a.state })}`).then((x) => listed(x))) },
@@ -186,21 +204,21 @@ export const COMMANDS = [
186
204
 
187
205
  // ── places in a container ──
188
206
  { noun: "channel", verb: "list", flags: { in: IN, archived: { kind: "bool", about: "the archived ones instead" } }, about: "A container's channels that you read.",
189
- run: async (c, a) => listed((await c.get(`/api/containers/${a.in}`)).channels.filter((ch) => ch.reads && !ch.archived_at === !a.archived)) },
207
+ run: async (c, a) => listed((await c.get(`/api/containers/${a.in}?documents=0`)).channels.filter((ch) => ch.reads && !ch.archived_at === !a.archived)) },
190
208
  { noun: "channel", verb: "show", target: { name: "channel", kind: "channel" }, flags: {}, about: "One channel: its brief, flag, readers if private, and tombstones.",
191
209
  run: (c, a) => c.get(`/api/channels/${a.channel}`) },
192
210
  { noun: "channel", verb: "messages", target: { name: "channel", kind: "channel" },
193
- flags: { before: { kind: "message", about: "older than this message" }, after: { kind: "message", about: "newer than this message" }, limit: { kind: "int", about: "how many, up to 100 (50 if left out)" }, unread: { kind: "bool", about: "only what you have not read" } },
211
+ flags: { before: { kind: "message", about: "older than this message" }, after: { kind: "message", about: "newer than this message" }, limit: { kind: "int", about: "how many, up to 100 (50 if left out)" }, unread: { kind: "bool", about: "only what you have not read" }, search: SEARCH },
194
212
  about: "A channel's messages, newest last, with tombstones where messages were deleted. Marks nothing read.",
195
- run: (c, a) => (a.before && a.after ? refuse("--before or --after, not both") : c.get(`/api/messages${qs({ channel: a.channel, before: a.before, after: a.after, limit: a.limit, unread: a.unread })}`)) },
213
+ run: (c, a) => (a.before && a.after ? refuse("--before or --after, not both") : c.get(`/api/messages${qs({ channel: a.channel, before: a.before, after: a.after, limit: a.limit, unread: a.unread, search: a.search })}`)) },
196
214
  { noun: "zone", verb: "list", flags: { in: { ...IN, kind: "project" } }, about: "A Project's zones.",
197
- run: async (c, a) => listed((await c.get(`/api/containers/${a.in}`)).zones) },
215
+ run: async (c, a) => listed((await c.get(`/api/containers/${a.in}?documents=0`)).zones) },
198
216
  { noun: "document", verb: "list", flags: { in: IN, folder: { kind: "folder", about: "only what is in this folder" } }, about: "A container's documents, newest revision first.",
199
- run: async (c, a) => listed((await c.get(`/api/containers/${a.in}`)).documents.filter((d) => !a.folder || d.folder_id === a.folder)) },
217
+ run: async (c, a) => listed((await c.get(`/api/containers/${a.in}/documents`)).filter((d) => !a.folder || d.folder_id === a.folder)) },
200
218
  { noun: "document", verb: "show", target: { name: "document", kind: "document" }, flags: {}, about: "One document: its revisions, issuances and acknowledgements.",
201
219
  run: (c, a) => c.get(`/api/documents/${a.document}`) },
202
220
  { noun: "folder", verb: "list", flags: { in: IN }, about: "A container's folders that you can see.",
203
- run: async (c, a) => listed((await c.get(`/api/containers/${a.in}`)).folders) },
221
+ run: async (c, a) => listed((await c.get(`/api/containers/${a.in}?documents=0`)).folders) },
204
222
 
205
223
  // ── containers, people and Centric ──
206
224
  { noun: "project", verb: "list", flags: {}, about: "The Projects you belong to.", run: async (c) => listed((await c.get("/api/me")).containers.filter((x) => x.kind === "project")) },
@@ -210,9 +228,9 @@ export const COMMANDS = [
210
228
  { noun: "company", verb: "show", target: { name: "company", kind: "company" }, flags: {}, about: "One Company in full: its brief, people, channels and documents.",
211
229
  run: (c, a) => c.get(`/api/containers/${a.company}`) },
212
230
  { noun: "membership", verb: "list", flags: { in: IN }, about: "Who belongs to a container, with each one's designation.",
213
- run: async (c, a) => listed((await c.get(`/api/containers/${a.in}`)).members.map((m) => ({ id: m.membership_id, ...m }))) },
231
+ run: async (c, a) => listed((await c.get(`/api/containers/${a.in}?documents=0`)).members.map((m) => ({ id: m.membership_id, ...m }))) },
214
232
  { noun: "admin", verb: "list", flags: { company: { kind: "company", need: true } }, about: "A Company's admins.",
215
- run: async (c, a) => listed((await c.get(`/api/containers/${a.company}`)).members.filter((m) => m.is_admin).map((m) => ({ id: m.admin_id, identity_id: m.identity_id, display_name: m.display_name, designation: m.designation }))) },
233
+ run: async (c, a) => listed((await c.get(`/api/containers/${a.company}?documents=0`)).members.filter((m) => m.is_admin).map((m) => ({ id: m.admin_id, identity_id: m.identity_id, display_name: m.display_name, designation: m.designation }))) },
216
234
  { noun: "identity", verb: "show", target: { name: "identity", kind: "identity" }, flags: {}, about: "One person: their name, and the containers you both belong to with their designation in each.",
217
235
  run: (c, a) => c.get(`/api/identities/${a.identity}`) },
218
236
  { noun: "access-link", verb: "list", flags: { in: { ...IN, kind: "project" } }, about: "A Project's access links: who each is for, and whether it was used, withdrawn or ran out.",
@@ -225,9 +243,19 @@ export const COMMANDS = [
225
243
  async run(c) { const m = await c.get("/api/me"); return listed(m.operator ? await c.get("/api/applications") : m.application ? [m.application] : []); } },
226
244
 
227
245
  // ── 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"] }) },
246
+ // Every task is part of one channel's conversation (CONTEXT.md Task): posted there as your message, or raised from one.
247
+ { 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" },
248
+ 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" },
249
+ details: { kind: "text" }, due: { kind: "day" }, priority: PRIORITY, zone: { kind: "zone" }, document: { kind: "document", about: "a document it points at" } },
250
+ 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.",
251
+ async run(c, a) {
252
+ if (!a.channel === !a["from-message"]) refuse("say where it is talked about: --channel to post it there, or --from-message with --in");
253
+ 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");
254
+ if (a["from-message"] && !a.in) refuse("--from-message needs --in, the Project or Company the message is in");
255
+ const fields = { title: a.title, details: a.details, due_on: a.due, priority: a.priority, zone_id: a.zone, document_id: a.document };
256
+ if (a.channel) return c.post(`/api/containers/${(await c.get(`/api/channels/${a.channel}`)).container_id}/tasks`, { ...fields, channel_id: a.channel });
257
+ return c.post(`/api/containers/${a.in}/tasks`, { ...fields, source_message_id: a["from-message"], ...(a.externals && { externals_visible: false }) });
258
+ } },
231
259
  { 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
260
  about: "Changes the named fields of a task; only what is sent changes. none clears the details, due day or zone.",
233
261
  async run(c, a) {
@@ -237,11 +265,11 @@ export const COMMANDS = [
237
265
  ...[["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
266
  { noun: "task", verb, target: { name: "task", kind: "task" }, flags: {}, about, run: (c, a) => c.post(`/api/tasks/${a.task}/${verb}`) })),
239
267
  { 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.",
268
+ { 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" } },
269
+ 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
270
  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;
271
+ 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");
272
+ 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
273
  return c.post(`/api/tasks/${task}/asks`, { to_id: a.to });
246
274
  } },
247
275
  { 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,15 +286,18 @@ export const COMMANDS = [
258
286
  } },
259
287
  { 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
288
  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" },
289
+ { 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
290
  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.",
291
+ 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
292
  async run(c, a) {
265
293
  if (!a.channel === !a.task) refuse("say where: --channel or --task");
266
294
  if (!a.text && !a.path) refuse("say something: --text, or --path for a file");
267
295
  const uploads = a.path && (await Promise.all(a.path.map((p) => c.upload(p)))).map((u) => ({ path: u.path, name: u.name }));
268
296
  return c.post("/api/messages", { channel: a.channel, task: a.task, text: a.text, reply_to: a["reply-to"], mention: a.mention, mention_all: a["mention-all"], uploads });
269
297
  } },
298
+ { noun: "message", verb: "forward", target: { name: "message", kind: "message" }, flags: { channel: { kind: "channel" }, direct: { kind: "identity" }, "dry-run": { ...DRY, about: "who would newly read it" } },
299
+ about: "Sends a message on, marked with who wrote it and the day, never where; a file as itself.",
300
+ run: (c, a) => c.post(`/api/messages/${a.message}/forward`, { channel: a.channel, direct: a.direct, dry_run: a["dry-run"] }) },
270
301
  { noun: "message", verb: "edit", target: { name: "message", kind: "message" }, flags: { text: TEXT }, about: "Changes the words of a message you sent; Zulip keeps every earlier version.",
271
302
  run: (c, a) => c.patch(`/api/messages/${a.message}`, { text: a.text }) },
272
303
  { noun: "message", verb: "delete", target: { name: "message", kind: "message" }, flags: {}, about: "Deletes a channel message, leaving a tombstone where it stood: staff only.",
@@ -275,7 +306,19 @@ export const COMMANDS = [
275
306
  run: (c, a) => c.post(`/api/messages/${a.message}/reactions`, { emoji: a.emoji }) },
276
307
  { noun: "message", verb: "unreact", target: { name: "message", kind: "message" }, flags: { emoji: EMOJI }, about: "Takes your reaction back.",
277
308
  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.",
309
+ // A pinned message (CONTEXT.md Pinned message, #146): kept at the top of its conversation, for everyone in it. Given
310
+ // --conversation, the id is one of the assistant's answers there, as conversation show lists them.
311
+ ...["pin", "unpin"].map((verb) => ({ noun: "message", verb, target: { name: "message", kind: "message" }, flags: { conversation: { kind: "id", about: "an answer in this conversation of yours" } },
312
+ about: verb === "pin" ? "Pins a message for everyone in its conversation. In a Project, staff pin; 50 at most." : "Takes a message's pin off.",
313
+ run: (c, a) => c[verb === "pin" ? "post" : "del"]("/api/pins", a.conversation ? { conversation: a.conversation, turn: a.message } : { message: a.message }) })),
314
+ { noun: "pinned-message", verb: "list", flags: { channel: { kind: "channel" }, direct: { kind: "identity" }, conversation: { kind: "id" } },
315
+ about: "A channel's, Direct's or conversation's pinned messages, newest pinned first.",
316
+ run: async (c, a) => {
317
+ const where = ["channel", "direct", "conversation"].filter((k) => a[k]);
318
+ if (where.length !== 1) refuse("say where: --channel, --direct or --conversation");
319
+ return listed((await c.get(`/api/pins${qs({ [where[0]]: a[where[0]] })}`)).pins);
320
+ } },
321
+ { 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
322
  run: (c, a) => c.post("/api/messages", { direct: a.identity, text: a.text }) },
280
323
  { 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
324
  run: (c, a) => c.post("/api/messages/read", { channel: a.channel, through: a.through }) },
@@ -388,8 +431,8 @@ export const COMMANDS = [
388
431
  { 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
432
  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
433
  { 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.",
434
+ 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"] }) },
435
+ { 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
436
  run: (c, a) => c.post(`/api/identities/${a.identity}/reactivate`) },
394
437
  { 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
438
  about: "Makes an access link for someone with no account: what externals may see, and one reply. Staff only.",
@@ -423,9 +466,9 @@ export const COMMANDS = [
423
466
  { noun: "assistant", verb: "question", target: { name: "text", kind: "words" }, flags: { conversation: { kind: "id", about: "to go on from one" }, focus: { kind: "focus", about: "a Project, Company or channel for a new conversation to keep to" } },
424
467
  about: "Asks Centric's assistant, which answers from what you can see. Only while your own switch is on; it counts against the day's share.",
425
468
  run: (c, a) => c.post("/api/assist", { q: a.text, conversation: a.conversation, focus: a.focus }) },
426
- { noun: "assistant", verb: "correct", target: { name: "text", kind: "words" }, flags: { direct: { kind: "identity", about: "the person a Direct it is for: always refused, as the app never corrects one (ADR 0006)" } },
469
+ { noun: "assistant", verb: "correct", target: { name: "text", kind: "words" }, flags: {},
427
470
  about: "The assistant puts a message's spelling, grammar and punctuation right; it is still yours to send.",
428
- run: (c, a) => c.post("/api/assist/correct", { text: a.text, direct: a.direct }) },
471
+ run: (c, a) => c.post("/api/assist/correct", { text: a.text }) },
429
472
  { noun: "conversation", verb: "delete", target: { name: "conversation", kind: "id" }, flags: {}, about: "Deletes a conversation with the assistant outright; it leaves no tombstone.",
430
473
  run: (c, a) => c.del(`/api/assistant/conversations/${a.conversation}`) },
431
474
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "connection-centric",
3
- "version": "0.1.0",
3
+ "version": "0.3.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": {