@bli-cockpit/cli 0.2.137 → 0.2.140

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.
@@ -157,6 +157,7 @@ export const SLACK_WORKSPACE_KEYS = ["bli", "blue_pearl"];
157
157
  * `cockpit slack read [--person|--channel|--query|--since|--until|--limit]`
158
158
  * (BLI-3462).
159
159
  */
160
+ const SLACK_READ_FORMATS = ["compact", "full"];
160
161
  export function parseSlackArgs(args) {
161
162
  const values = parseNamedArgs(args, {
162
163
  allowedFlags: [
@@ -164,12 +165,15 @@ export function parseSlackArgs(args) {
164
165
  "--dashboard-url",
165
166
  "--workspace",
166
167
  "--stale-only",
168
+ "--list",
167
169
  "--person",
168
170
  "--channel",
169
171
  "--query",
172
+ "--word",
170
173
  "--since",
171
174
  "--until",
172
175
  "--limit",
176
+ "--format",
173
177
  "--json",
174
178
  ],
175
179
  valueFlags: [
@@ -182,6 +186,7 @@ export function parseSlackArgs(args) {
182
186
  "--since",
183
187
  "--until",
184
188
  "--limit",
189
+ "--format",
185
190
  ],
186
191
  });
187
192
  if (values.positionals.length > 1) {
@@ -196,6 +201,12 @@ export function parseSlackArgs(args) {
196
201
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
197
202
  json: values.booleans.has("--json"),
198
203
  };
204
+ if (values.flags.has("--format") && rawAction !== "read") {
205
+ throw new Error("--format belongs to `cockpit slack read`.");
206
+ }
207
+ if (values.booleans.has("--list") && rawAction !== "coverage") {
208
+ throw new Error("--list belongs to `cockpit slack coverage`.");
209
+ }
199
210
  if (rawAction === "coverage") {
200
211
  // `--workspace` here is a Slack workspace key, NOT the collection-root
201
212
  // `--workspace <path>` every setup command takes. Named at the point of use
@@ -209,6 +220,8 @@ export function parseSlackArgs(args) {
209
220
  action: "coverage",
210
221
  workspace,
211
222
  staleOnly: values.booleans.has("--stale-only"),
223
+ // BLI-10450: counts alone could not answer "is there a Kaiben channel?".
224
+ list: values.booleans.has("--list"),
212
225
  ...base,
213
226
  };
214
227
  }
@@ -219,15 +232,27 @@ export function parseSlackArgs(args) {
219
232
  throw new Error("slack read needs at least one of --person, --channel or --query. An unfiltered dump of every " +
220
233
  "message is not something this command offers.");
221
234
  }
235
+ const word = values.booleans.has("--word");
236
+ if (word && !query) {
237
+ throw new Error("--word needs --query: it says how the query text is matched.");
238
+ }
239
+ const format = optionalNonEmpty(values.flags.get("--format"));
240
+ if (format && !SLACK_READ_FORMATS.includes(format)) {
241
+ throw new Error(`slack read --format must be one of ${SLACK_READ_FORMATS.join(", ")}; got ${format}.`);
242
+ }
222
243
  return {
223
244
  kind: "slack",
224
245
  action: "read",
225
246
  person,
226
247
  channel,
227
248
  query,
249
+ ...(word ? { word } : {}),
228
250
  since: optionalNonEmpty(values.flags.get("--since")),
229
251
  until: optionalNonEmpty(values.flags.get("--until")),
230
252
  limit: optionalPositiveInteger(values.flags.get("--limit"), "--limit"),
253
+ // BLI-10450: every result printed twice (a summary, then blocks) — one
254
+ // rendering now, never both. `full` (default) keeps the detailed blocks.
255
+ format: format ?? "full",
231
256
  ...base,
232
257
  };
233
258
  }
@@ -8,7 +8,10 @@
8
8
  */
9
9
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
10
10
  const DOCS_ACTIONS = new Set(["list", "tree", "read", "create", "update", "link", "sync-status"]);
11
- const DOCS_ACTIONS_NEEDING_A_DOC = new Set(["read", "update", "link"]);
11
+ // BLI-10450: `read` moved to its own branch in `parseDocsDocumentReference` —
12
+ // it takes SEVERAL references now, so it no longer shares the "exactly one"
13
+ // rule `update`/`link` still enforce.
14
+ const DOCS_ACTIONS_NEEDING_A_DOC = new Set(["update", "link"]);
12
15
  export function parseDocsArgs(args) {
13
16
  const values = parseNamedArgs(args, {
14
17
  allowedFlags: [
@@ -22,17 +25,56 @@ export function parseDocsArgs(args) {
22
25
  "--file",
23
26
  "--body-stdin",
24
27
  "--query",
28
+ "--word",
25
29
  "--limit",
26
30
  "--clear",
31
+ "--bodies",
32
+ "--section",
33
+ "--offset",
27
34
  "--json",
28
35
  ],
29
- valueFlags: ["--home", "--dashboard-url", "--title", "--parent", "--visibility", "--file", "--query", "--limit"],
36
+ valueFlags: [
37
+ "--home",
38
+ "--dashboard-url",
39
+ "--title",
40
+ "--parent",
41
+ "--visibility",
42
+ "--file",
43
+ "--query",
44
+ "--limit",
45
+ "--section",
46
+ "--offset",
47
+ ],
30
48
  });
31
49
  const { action, rest } = parseDocsAction(values.positionals);
32
50
  const link = parseDocsLink(action, rest, values.booleans.has("--clear"));
33
- const docRef = parseDocsDocumentReference(action, link.rest);
34
- const { visibility, clearParent, parentId, allowEmpty, title, query, limit } = parseDocsFlags(action, values);
35
- return buildDocsCommand(values, action, docRef, visibility, clearParent, parentId, allowEmpty, title, query, limit, link);
51
+ const { docRef, docRefs } = parseDocsDocumentReference(action, link.rest);
52
+ const { visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset } = parseDocsFlags(action, values);
53
+ if (action === "read")
54
+ validateDocsReadTargets(docRefs, parentId, bodies);
55
+ return buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link);
56
+ }
57
+ /**
58
+ * BLI-10450: `read` takes either several document references OR
59
+ * `--parent <id> --bodies` (every document filed directly under that
60
+ * parent, with bodies) — never both, and never neither. Checked once flags
61
+ * are known, not in `parseDocsDocumentReference`, because "was --bodies
62
+ * given" decides which of the two empty-input refusals is the right one.
63
+ */
64
+ function validateDocsReadTargets(docRefs, parentId, bodies) {
65
+ const hasRefs = Boolean(docRefs && docRefs.length > 0);
66
+ if (hasRefs && parentId) {
67
+ throw new Error("docs read takes either document references or --parent --bodies, not both.");
68
+ }
69
+ if (bodies && !parentId) {
70
+ throw new Error("--bodies needs --parent, e.g. `cockpit docs read --parent <id> --bodies`.");
71
+ }
72
+ if (parentId && !bodies) {
73
+ throw new Error("docs read --parent needs --bodies too (or use `cockpit docs list --parent <id>` for metadata only).");
74
+ }
75
+ if (!hasRefs && !(parentId && bodies)) {
76
+ throw new Error("docs read needs one or more document ids/slugs, or --parent <id> --bodies.");
77
+ }
36
78
  }
37
79
  /**
38
80
  * BLI-10430: `docs link <id|slug> <notion-url|page-id>` ties a document to a
@@ -67,23 +109,31 @@ function parseDocsAction(positionals) {
67
109
  return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
68
110
  }
69
111
  function parseDocsDocumentReference(action, rest) {
112
+ // BLI-10450: `read` alone takes several references in one call — an agent
113
+ // reading a folder's worth of documents used to pay a resolve-then-read
114
+ // pair PER document. Emptiness is validated later, once `--parent`/
115
+ // `--bodies` are known (`validateDocsReadTargets`).
116
+ if (action === "read") {
117
+ const refs = rest.map((token) => optionalNonEmpty(token)).filter((token) => Boolean(token));
118
+ return { docRef: refs[0], docRefs: refs.length > 0 ? refs : undefined };
119
+ }
70
120
  if (DOCS_ACTIONS_NEEDING_A_DOC.has(action)) {
71
121
  const docRef = optionalNonEmpty(rest[0]);
72
122
  if (!docRef)
73
123
  throw new Error(`docs ${action} needs a document id or slug.`);
74
124
  if (rest.length > 1)
75
125
  throw new Error(`docs ${action} takes one document reference, not ${rest.length}.`);
76
- return docRef;
126
+ return { docRef, docRefs: undefined };
77
127
  }
78
128
  // BLI-10430: `sync-status` reports the whole linked library, or one document.
79
129
  if (action === "sync-status") {
80
130
  if (rest.length > 1)
81
131
  throw new Error(`docs sync-status takes at most one document reference, not ${rest.length}.`);
82
- return optionalNonEmpty(rest[0]);
132
+ return { docRef: optionalNonEmpty(rest[0]), docRefs: undefined };
83
133
  }
84
134
  if (rest.length > 0)
85
135
  throw new Error(`docs ${action} does not take "${rest[0]}".`);
86
- return undefined;
136
+ return { docRef: undefined, docRefs: undefined };
87
137
  }
88
138
  function parseDocsFlags(action, values) {
89
139
  const visibility = optionalNonEmpty(values.flags.get("--visibility"));
@@ -118,19 +168,48 @@ function parseDocsFlags(action, values) {
118
168
  if (query && action !== "list") {
119
169
  throw new Error("--query belongs to `cockpit docs list`.");
120
170
  }
171
+ const wholeWord = values.booleans.has("--word");
172
+ if (wholeWord && !query) {
173
+ throw new Error("--word needs --query: it says how the query text is matched.");
174
+ }
175
+ // BLI-10450: `--limit` now also belongs to `read`, where it means two
176
+ // different things depending on the mode it is used in — a row cap in
177
+ // `--parent --bodies` mode (matching `list`'s own meaning), or a character
178
+ // cap when slicing an explicit reference's body. The two modes are mutually
179
+ // exclusive (`validateDocsReadTargets`), so the flag is never ambiguous in
180
+ // any one call, only overloaded across the command's two shapes.
121
181
  const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
122
- if (limit !== undefined && action !== "list") {
123
- throw new Error("--limit belongs to `cockpit docs list`.");
182
+ if (limit !== undefined && action !== "list" && action !== "read") {
183
+ throw new Error("--limit belongs to `cockpit docs list` or `cockpit docs read`.");
184
+ }
185
+ // BLI-10450: `--bodies` pairs with `--parent` to bulk-read a folder;
186
+ // `--section`/`--offset` slice an explicit reference's body, mirroring
187
+ // `cockpit workbook --section`.
188
+ const bodies = values.booleans.has("--bodies");
189
+ if (bodies && action !== "read")
190
+ throw new Error("--bodies belongs to `cockpit docs read`.");
191
+ const section = optionalNonEmpty(values.flags.get("--section"));
192
+ if (section && action !== "read")
193
+ throw new Error("--section belongs to `cockpit docs read`.");
194
+ const offsetRaw = optionalNonEmpty(values.flags.get("--offset"));
195
+ let offset;
196
+ if (offsetRaw !== undefined) {
197
+ offset = Number(offsetRaw);
198
+ if (!Number.isInteger(offset) || offset < 0)
199
+ throw new Error("--offset must be a whole number 0 or greater.");
124
200
  }
125
- return { visibility, clearParent, parentId, allowEmpty, title, query, limit };
201
+ if (offset !== undefined && action !== "read")
202
+ throw new Error("--offset belongs to `cockpit docs read`.");
203
+ return { visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset };
126
204
  }
127
- function buildDocsCommand(values, action, docRef, visibility, clearParent, parentId, allowEmpty, title, query, limit, link) {
205
+ function buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link) {
128
206
  return {
129
207
  kind: "docs",
130
208
  action,
131
209
  homeDir: optionalNonEmpty(values.flags.get("--home")),
132
210
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
133
211
  ...(docRef ? { docRef } : {}),
212
+ ...(docRefs ? { docRefs } : {}),
134
213
  title,
135
214
  ...(parentId ? { parentId } : {}),
136
215
  clearParent,
@@ -139,7 +218,11 @@ function buildDocsCommand(values, action, docRef, visibility, clearParent, paren
139
218
  filePath: optionalNonEmpty(values.flags.get("--file")),
140
219
  bodyStdin: values.booleans.has("--body-stdin"),
141
220
  ...(query ? { query } : {}),
221
+ ...(wholeWord ? { wholeWord } : {}),
142
222
  ...(limit === undefined ? {} : { limit }),
223
+ ...(bodies ? { bodies } : {}),
224
+ ...(section ? { section } : {}),
225
+ ...(offset === undefined ? {} : { offset }),
143
226
  ...(link.notionPage ? { notionPage: link.notionPage } : {}),
144
227
  ...(link.clearLink ? { clearLink: true } : {}),
145
228
  json: values.booleans.has("--json"),
@@ -73,14 +73,14 @@ export function parseIssueArgs(args) {
73
73
  ],
74
74
  });
75
75
  const { action, rest } = readIssueAction(values);
76
- const { issueRef, moveState } = readIssueReference(action, rest);
76
+ const { issueRef, issueRefs, moveState } = readIssueReference(action, rest);
77
77
  validateIssueState(moveState, "issue move state must be one of: ");
78
78
  const stateFilter = readIssueStateFilter(action, values);
79
79
  const title = readIssueTitle(action, values);
80
80
  const priority = readIssuePriority(values);
81
81
  const assignee = readIssueAssignee(action, values);
82
82
  const limit = readIssueLimit(action, values);
83
- return buildIssueCommand(values, action, issueRef, moveState, stateFilter, title, priority, assignee, limit);
83
+ return buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, assignee, limit);
84
84
  }
85
85
  function readIssueAction(values) {
86
86
  const first = values.positionals[0];
@@ -92,8 +92,19 @@ function readIssueAction(values) {
92
92
  }
93
93
  function readIssueReference(action, rest) {
94
94
  let issueRef;
95
+ let issueRefs;
95
96
  let moveState;
96
- if (ISSUE_ACTIONS_NEEDING_AN_ISSUE.has(action)) {
97
+ // BLI-10450: `show` alone takes several references in one call — an agent
98
+ // reading a sprint's worth of tickets used to pay one process per issue.
99
+ // Every other verb here still names exactly one issue.
100
+ if (action === "show") {
101
+ const refs = rest.map((token) => optionalNonEmpty(token)).filter((token) => Boolean(token));
102
+ if (refs.length === 0)
103
+ throw new Error("issue show needs one or more issue ids or identifiers, e.g. BLI-3654.");
104
+ issueRefs = refs;
105
+ issueRef = refs[0];
106
+ }
107
+ else if (ISSUE_ACTIONS_NEEDING_AN_ISSUE.has(action)) {
97
108
  issueRef = optionalNonEmpty(rest[0]);
98
109
  if (!issueRef)
99
110
  throw new Error(`issue ${action} needs an issue id or identifier, e.g. BLI-3654.`);
@@ -112,7 +123,7 @@ function readIssueReference(action, rest) {
112
123
  else if (rest.length > 0) {
113
124
  throw new Error(`issue ${action} does not take "${rest[0]}".`);
114
125
  }
115
- return { issueRef, moveState };
126
+ return { issueRef, issueRefs, moveState };
116
127
  }
117
128
  function validateIssueState(state, errorPrefix) {
118
129
  if (state && !ISSUE_STATES.includes(state)) {
@@ -166,13 +177,14 @@ function readIssueLimit(action, values) {
166
177
  }
167
178
  return limit;
168
179
  }
169
- function buildIssueCommand(values, action, issueRef, moveState, stateFilter, title, priority, assignee, limit) {
180
+ function buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, assignee, limit) {
170
181
  return {
171
182
  kind: "issue",
172
183
  action,
173
184
  homeDir: optionalNonEmpty(values.flags.get("--home")),
174
185
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
175
186
  ...(issueRef ? { issueRef } : {}),
187
+ ...(issueRefs ? { issueRefs } : {}),
176
188
  ...(moveState ? { moveState } : {}),
177
189
  ...(stateFilter ? { stateFilter } : {}),
178
190
  ...(assignee ? { assignee } : {}),
@@ -42,17 +42,22 @@ export const TOWER_COMMAND_HELP = [
42
42
  [
43
43
  "docs",
44
44
  [
45
- "Usage: cockpit docs [list|tree|read <id|slug>|create|update <id>|link <id|slug> <notion-page>|sync-status [<id|slug>]] [flags]",
45
+ "Usage: cockpit docs [list|tree|read <id|slug> [<id|slug>...]|read --parent <id> --bodies|create|update <id>|link <id|slug> <notion-page>|sync-status [<id|slug>]] [flags]",
46
46
  "",
47
47
  "The Tower document library, typed. Bare `cockpit docs` lists every document you may read.",
48
- "list [--parent <id>|root] [--query <text>] [--limit <n>] — every document you may see: id, visibility, body size, slug, title. Metadata only — a list never carries a body, so it stays cheap enough for an agent to call (--json was 604 KB for 155 documents before BLI-3737, and is now a few tens of KB).",
48
+ "list [--parent <id>|root] [--query <text> [--word]] [--limit <n>] — every document you may see: id, visibility, body size, slug, title. Metadata only — a list never carries a body, so it stays cheap enough for an agent to call (--json was 604 KB for 155 documents before BLI-3737, and is now a few tens of KB). --json shape: { documents: [{ id, slug, title, visibility, parent_id, source, updated_at, body_chars }] }.",
49
49
  " --parent <id> lists the documents filed directly under that document; --parent root lists the top of the tree.",
50
50
  " --query <text> keeps the documents whose title OR body contains that text, matched case-insensitively in the database — never fuzzy, and the body still never comes back.",
51
+ " --word matches --query as whole words: `--query ott --word` finds \"Ott\" but not \"bottom\" or \"Otter\".",
51
52
  " --limit <n> caps the rows (1-1000). Narrow with these before reading: `cockpit docs list --query onboarding --json` then `cockpit docs read <slug>`.",
52
53
  "tree — the same documents nested under their parent, for a sidebar-shaped view.",
53
- "read <id|slug> — one document's title, slug, visibility, and its body. This is the ONLY verb that returns a body.",
54
- "create --title \"<title>\" [--parent <id>] [--visibility org|private] [--file <path>|--body-stdin] — the body comes from --file, or stdin (`cat body.md | cockpit docs create --title \"...\"`); neither means an empty body, matching the browser's own default.",
55
- "update <id|slug> [--title \"<t>\"] [--visibility org|private] [--parent <id>|--clear-parent] [--file <path>|--body-stdin] [--allow-empty] — only the fields you pass change; a `--parent` change IS a move, there is no separate move verb.",
54
+ "read <id|slug> [<id|slug>...] — one or more documents' title, slug, visibility, and body. This is the ONLY verb that returns a body. Several refs resolve against ONE shared list call, then one GET each — one bad ref never loses the rest.",
55
+ " --section \"<heading>\" — one section only, from that heading (any # level, case-insensitive) to the next heading at the same or shallower level. Mirrors `cockpit workbook --section`. A miss refuses (document_section_not_found) and names the headings that ARE there.",
56
+ " --offset <n> --limit <n> — a character range of the body, instead of the whole thing.",
57
+ " --json shape: one ref answers { document }, unchanged from before BLI-10450. Several refs answer { documents: [{ ref, ok, document } | { ref, ok: false, error, detail }] }.",
58
+ "read --parent <id>|root --bodies [--limit <n>] — every document filed directly under that parent, bodies included, in ONE call (GET /api/docs/documents?parent_id=...&bodies=true). --limit here caps the DOCUMENT COUNT (1-50), not characters. --json shape: { documents: [{ id, ..., body_markdown }] }.",
59
+ "create --title \"<title>\" [--parent <id>] [--visibility org|private] [--file <path>|--body-stdin] — the body comes from --file, or stdin (`cat body.md | cockpit docs create --title \"...\"`); neither means an empty body, matching the browser's own default. --json shape: { document }.",
60
+ "update <id|slug> [--title \"<t>\"] [--visibility org|private] [--parent <id>|--clear-parent] [--file <path>|--body-stdin] [--allow-empty] — only the fields you pass change; a `--parent` change IS a move, there is no separate move verb. --json shape: { document }.",
56
61
  " --allow-empty clears the page on purpose. A body that would empty a document that holds text is refused (refused_empty_body) unless you say so, because far more often it is a surface that lost the content than a person who meant it.",
57
62
  "link <id|slug> <notion-url|page-id> — ties the document to a Notion page so the two-way sync moves it both ways. The page is the truth from then on: the next sync pulls it over this copy and keeps the copy as a version. `link <id|slug> --clear` unties it. Refusals: invalid_notion_page, notion_page_already_linked, document_not_writable.",
58
63
  "sync-status [<id|slug>] — every linked document's Notion sync state, or one's: synced (the body is what both sides last agreed on), tower_changed (edited here since; the next write-back pushes it), never_synced (linked, no baseline yet). Tower's half only: a Notion edit shows up at the next pull, not here. Metadata only, never a body.",
@@ -82,13 +87,14 @@ export const TOWER_COMMAND_HELP = [
82
87
  [
83
88
  "issue",
84
89
  [
85
- "Usage: cockpit issue [list|show <id>|create|update <id>|move <id> <state>|comment <id>|history <id>] [flags]",
90
+ "Usage: cockpit issue [list|show <id> [<id>...]|create|update <id>|move <id> <state>|comment <id>|history <id>] [flags]",
86
91
  "",
87
92
  "Tower's issue tracker. <id> is a BLI-#### identifier (BLI-3654) or an issue's uuid — both work everywhere an issue is named.",
88
- "list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] — the issues you may see, most recently updated first.",
89
- "show <id> — one issue: title, state, priority, assignee, description, and every comment on it.",
90
- "create --title \"<title>\" [--project <name|id>] [--priority 0-4] [--assignee me|<uuid>] [--parent <id>] [--file <path>] — the DESCRIPTION comes from --file or stdin (`cat plan.md | cockpit issue create --title \"...\"`); neither means no description.",
91
- "update <id> [--title \"<t>\"] [--priority 0-4] [--assignee me|<uuid>] [--project <name|id>] [--parent <id>] [--file <path>|--body-stdin] — only the fields you pass change. State does NOT move here; use move.",
93
+ "list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] — the issues you may see, most recently updated first. Each row carries assignee_name/assignee_email alongside the bare assignee_id (BLI-10450, one extra `cockpit team members` call for the whole page, never per row). --json shape: { issues: [{ id, identifier, title, state, priority, assignee_id, assignee_name, assignee_email, project_id, parent_id, updated_at }] }.",
94
+ "show <id> [<id>...] — one or more issues: title, state, priority, assignee (name and email, not just its id), description, and every comment on each. Several ids read in one call — one bad id never loses the rest.",
95
+ " --json shape: one id answers { issue, comments }, unchanged from before BLI-10450. Several ids answer { issues: [{ ref, ok, issue, comments } | { ref, ok: false, error, detail }] }.",
96
+ "create --title \"<title>\" [--project <name|id>] [--priority 0-4] [--assignee me|<uuid>] [--parent <id>] [--file <path>] — the DESCRIPTION comes from --file or stdin (`cat plan.md | cockpit issue create --title \"...\"`); neither means no description. --json shape: { issue }.",
97
+ "update <id> [--title \"<t>\"] [--priority 0-4] [--assignee me|<uuid>] [--project <name|id>] [--parent <id>] [--file <path>|--body-stdin] — only the fields you pass change. State does NOT move here; use move. --json shape: { issue }.",
92
98
  "move <id> <state> — moves an issue and records the move. States: backlog, todo, in_progress, in_review, done, canceled.",
93
99
  "comment <id> — posts a comment. The body is never taken on the command line: `echo \"shipped\" | cockpit issue comment BLI-3654`.",
94
100
  "history <id> [--limit <n>] — every recorded state move and reassignment, oldest first.",
@@ -109,14 +115,14 @@ export const TOWER_COMMAND_HELP = [
109
115
  " macOS: printf \"%s\" \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
110
116
  " PowerShell: \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
111
117
  " Make one at myaccount.google.com/apppasswords (2-Step Verification has to be on). A work @buildlaunchiterate.ca address connects in the browser instead.",
112
- "inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment.",
113
- "read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox --json`.",
114
- "search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box.",
118
+ "inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment. Every row (text or --json) carries the thread id — `[thread <id>]` in text — so `mail read <thread>` never costs a second --json call to learn it (BLI-10450).",
119
+ "read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox` or `cockpit mail search`, in either --json or plain text.",
120
+ "search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box. Every row (text or --json) carries the thread id.",
115
121
  "send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
116
122
  "attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
117
123
  "sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
118
124
  "detach <account> — removes the mailbox, its stored mail and its credential.",
119
- "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
125
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr. Shapes differ by verb: inbox/read answer { messages: [...] }, search answers { matches: [...] } — same MessageRow shape either name, always carrying thread_id.",
120
126
  "A refusal keeps Tower's own reason label — needs_rls_client, account_not_found_or_unreadable, account_not_active, google_oauth_not_configured, credential_unavailable, and so on.",
121
127
  "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
122
128
  ],
@@ -372,7 +372,7 @@ export function localSubcommandHelp(command) {
372
372
  [
373
373
  "slack",
374
374
  [
375
- "Usage: cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] | read [filters]] [--json]",
375
+ "Usage: cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [filters] [--format compact|full]] [--json]",
376
376
  "",
377
377
  " cockpit slack coverage",
378
378
  " What Cockpit's Slack bot can see: how many channels it is in, what each",
@@ -380,13 +380,23 @@ export function localSubcommandHelp(command) {
380
380
  " are open. No message text at all, so anybody signed in can run it.",
381
381
  " This is the command that tells an empty search apart from a coverage gap.",
382
382
  " --stale-only drops the per-state breakdown and shows just what is behind.",
383
- " cockpit slack read --person <p> | --channel <c> | --query <text>",
383
+ " --list names every channel and its readable state — counts alone could not",
384
+ " answer \"is there a Kaiben channel?\". --json's shape: { coverage: { workspaces: [",
385
+ " { workspace, channelsKnown, covered, byVisibility, staleChannels, openSkips,",
386
+ " channels? } ] } } — channels is present only with --list.",
387
+ " cockpit slack read --person <p> | --channel <c> | --query <text> [--word]",
384
388
  " The messages themselves, through the same reader JARVIS uses. At least one",
385
389
  " filter is required. --since/--until are YYYY-MM-DD; --limit caps the rows.",
390
+ " --query matches a substring; add --word to match whole words, so `excel`",
391
+ " stops matching \"Excellent\".",
386
392
  " Reading Slack reads across the team, so this is for the people who do that",
387
393
  " job; being refused it never costs you `cockpit slack coverage`.",
388
394
  " An empty or partial answer prints Tower's own sentence about WHY, word for",
389
395
  " word — none of those reasons means nothing was said.",
396
+ " --format full (default): each message as its own block (channel/author/time,",
397
+ " text, permalink). --format compact: one line per message. Always one rendering,",
398
+ " never both — a result used to print as a summary AND as blocks (BLI-10450).",
399
+ " --json's shape: { result: { status, reason, messages: [...], coverage, note } }.",
390
400
  ],
391
401
  ],
392
402
  [
@@ -585,6 +595,8 @@ export function localSubcommandHelp(command) {
585
595
  "The STORE, over the same /api/memory/* doors the bli-memory MCP server calls:",
586
596
  " search \"<question>\" [--container <tag>] [--limit 1..20] [--no-recent]",
587
597
  " repeat --container to search more than one space; omit it for this repository's own.",
598
+ " --limit caps the memories (default 5); up to 3 Tower pages and tickets ride beside",
599
+ " them whatever it says. For more Tower rows, use `cockpit search`.",
588
600
  " save [--container <tag>] [--custom-id <id>] [--extract] [--file <path>]",
589
601
  " the memory comes from stdin or --file, never from the command line:",
590
602
  " `echo \"Edward decided X on 2026-09-10\" | cockpit memory save`.",
@@ -100,7 +100,7 @@ export function localCommandHelp(command) {
100
100
  " cockpit models [list|show <provider:model>|compare <provider:model> <provider:model> [...]] [--highlight] [--dashboard-url <url>] [--json]",
101
101
  " cockpit scout [board|start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
102
102
  " cockpit ops [status [--job <id>] [--skips] [--memory] [--models] | recompile --person <email|name|id> [--dry-run]] [--dashboard-url <url>] [--json]",
103
- " cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] | read [--person <p>] [--channel <c>] [--query <text>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>]] [--json]",
103
+ " cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [--person <p>] [--channel <c>] [--query <text> [--word]] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--format compact|full]] [--json]",
104
104
  " cockpit settings [personal [--chat-model <key>] [--brief-model <key>] | switches [set <key> <value>] | models [set --chat <key>] [--memory <id>] | cli-floor [<version>] | env list|set --project <p> --file <f> --content-stdin|delete --id <uuid> [--yes]] [--json]",
105
105
  " cockpit team [members | invite <email> --role <role> [--team-id <uuid>] | role <userId> --role <role> [--yes] | device [list] | device revoke <id|name> --reason <label> [--note \"<text>\"] [--yes]] [--json]",
106
106
  " cockpit workbook [<project> [<doc>]] [--section <id>] [--markdown] [--width <n>] [--dashboard-url <url>] [--json]",
@@ -117,7 +117,7 @@ export function localCommandHelp(command) {
117
117
  " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
118
118
  " cockpit memory [search \"<question>\"|save|update <id>|forget <id>|profile|install|status|log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"] [--container <tag>] [--limit <n>] [--no-recent] [--custom-id <id>] [--extract] [--content-stdin] [--reason <label>] [--file <path>] [--dashboard-url <url>] [--dry-run] [--json]",
119
119
  " cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
120
- " cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>] [--parent <id>|root|--clear-parent] [--query <text>] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
120
+ " cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>] [--parent <id>|root|--clear-parent] [--query <text> [--word]] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
121
121
  " cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
122
122
  " cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
123
123
  " cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--dashboard-url <url>] [--json]",
@@ -136,6 +136,7 @@ export function localCommandHelp(command) {
136
136
  " cockpit usage people [--by-repo | --by-topic | --by-subject | --by-task] [--min-confidence high|medium|low] [--person <email|me>] [--all] [--detail] [--since <n>d|<n>h|<iso>] [--until <iso>] [--include-automated] [--dashboard-url <url>] [--json]",
137
137
  "",
138
138
  `Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
139
+ "--quiet on any command (or COCKPIT_QUIET=1) drops the `[tower client] responded {...}` trace line from stderr; --json already implies it. A failure still logs — only that success-path trace goes quiet (BLI-10450).",
139
140
  ].join("\n");
140
141
  }
141
142
  /**
@@ -57,6 +57,14 @@ export { assertCollectionRootPersisted } from "./collection-roots.js";
57
57
  export { runSelfUpdate, SelfUpdateError, } from "./install-update.js";
58
58
  import { runRoots } from "./roots.js";
59
59
  export async function runLocalCockpitCli(argv, io = defaultIo()) {
60
+ // BLI-10450: `--quiet` is a global toggle, stripped here so no per-command
61
+ // parser's `allowedFlags` needs to learn it, and mirrored onto
62
+ // `COCKPIT_QUIET` so `tower-client.ts` can react without every call site
63
+ // threading a flag through. `--json` already implies it (tower-client.ts).
64
+ if (argv.includes("--quiet")) {
65
+ argv = argv.filter((token) => token !== "--quiet");
66
+ process.env.COCKPIT_QUIET = "1";
67
+ }
60
68
  if (isLocalHelpRequest(argv)) {
61
69
  writeLine(io.stdout, localCommandHelp(argv[0]));
62
70
  return 0;
@@ -177,7 +177,11 @@ function messageLine(message) {
177
177
  const paperclip = message.has_attachments ? "@" : " ";
178
178
  const from = message.from_address?.address ?? "(no sender)";
179
179
  const day = message.internal_date.slice(0, 16).replace("T", " ");
180
- return `${flag}${paperclip} ${day} ${from.padEnd(30).slice(0, 30)} ${(message.subject ?? "(no subject)").slice(0, 60)}`;
180
+ // BLI-10450: a text row carried no thread id, so `mail read` needed a
181
+ // second call with `--json` just to learn what `search`/`inbox` already
182
+ // had in hand. `mail read <thread>` takes this verbatim.
183
+ const thread = message.thread_id ? ` [thread ${message.thread_id}]` : "";
184
+ return `${flag}${paperclip} ${day} ${from.padEnd(30).slice(0, 30)} ${(message.subject ?? "(no subject)").slice(0, 60)}${thread}`;
181
185
  }
182
186
  async function readThread(command, door) {
183
187
  const answer = await askAgentDoor(door, {
@@ -175,6 +175,8 @@ export function runMemoryVerbList(io, json) {
175
175
  "The experience log:",
176
176
  " cockpit memory log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"",
177
177
  "",
178
+ "Search: --limit caps the MEMORIES (default 5, at most 20). Up to 3 Tower pages and",
179
+ "tickets ride beside them whatever --limit says; for more, use `cockpit search`.",
178
180
  "Save accepts repeated --alias names (up to 16 names of 64 characters).",
179
181
  "Omit --container and this repository's own memory space is used — the same",
180
182
  "space the bli-memory MCP server and its hooks read and write.",
@@ -222,7 +224,15 @@ async function searchMemory(command, door) {
222
224
  })}`);
223
225
  if (door.json)
224
226
  return emitAgentDoor(door, answer.body);
225
- if (results.length === 0) {
227
+ if (results.length === 0 && body.degraded) {
228
+ // BLI-10448: an empty answer from a search that lost a channel is NOT the
229
+ // record being silent. `semantic_timed_out` is the store answering on
230
+ // keywords alone after its vector search ran out of time, and saying
231
+ // "the record is silent" over it sends a person away from memories that
232
+ // exist.
233
+ writeLine(door.io.stdout, `No memories matched on the channels that answered, and one did not (${body.degraded}). Do not read this as the record being silent; ask again.`);
234
+ }
235
+ else if (results.length === 0) {
226
236
  // The distinction the whole store is built on: silence is not an outage.
227
237
  // A failure took the `!answer.ok` branch above and named itself there.
228
238
  writeLine(door.io.stdout, "No memories matched. The record is silent on this — it does not mean the store is down.");
@@ -104,15 +104,26 @@ async function readChannel(command, door) {
104
104
  });
105
105
  if (!answer.ok)
106
106
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
107
- const messages = answer.body.messages ?? [];
107
+ const body = answer.body;
108
+ const messages = body.messages ?? [];
109
+ // BLI-10449: whether this channel is Tower's own or a Slack snapshot, and
110
+ // how fresh — the door's own sentence, printed as sent.
111
+ const provenance = body.provenance ?? null;
112
+ const limitApplied = body.limitApplied ?? null;
108
113
  if (door.json)
109
- return emitAgentDoor(door, { ok: true, channelId: resolved.id, messages });
114
+ return emitAgentDoor(door, { ok: true, channelId: resolved.id, provenance, limitApplied, messages });
115
+ if (provenance?.sentence)
116
+ writeLine(door.io.stdout, provenance.sentence);
117
+ if (limitApplied !== null) {
118
+ writeLine(door.io.stdout, `Showing the newest ${limitApplied}: one read returns at most ${limitApplied} messages.`);
119
+ }
110
120
  if (messages.length === 0) {
111
121
  writeLine(door.io.stdout, "No messages.");
112
122
  return 0;
113
123
  }
114
124
  for (const message of [...messages].reverse()) {
115
- const who = message.agent_label ?? message.user_id;
125
+ // An imported Slack message has no Tower user; its Slack name is the label.
126
+ const who = message.agent_label ?? message.user_id ?? message.slack_author_label ?? "someone";
116
127
  writeLine(door.io.stdout, `${message.created_at} ${who} ${message.content ?? ""}`);
117
128
  }
118
129
  return 0;
@@ -4,6 +4,30 @@
4
4
  */
5
5
  import { writeLine } from "./cli-io.js";
6
6
  import { ask, fail, emit, sayScope, READ_DEADLINE_MS } from "./notes-door.js";
7
+ /**
8
+ * The lines `notes list` prints under the notes: every meeting record the
9
+ * docs door would show this caller, with the ref `docs read` takes, and which
10
+ * copy is canonical. Empty when there are none.
11
+ */
12
+ export function meetingDocumentLines(meeting, readVerb) {
13
+ if (!meeting || meeting.count === 0)
14
+ return [];
15
+ const refOf = new Map(meeting.items.map((item) => [item.id, item.slug ?? item.id]));
16
+ const copies = meeting.copies > 0 ? `, ${meeting.copies} of them ${meeting.copies === 1 ? "a copy" : "copies"}` : "";
17
+ const lines = [
18
+ `Meeting records in Docs, not in Notes (${meeting.count}${copies}). Read one with \`${readVerb} <ref>\`:`,
19
+ ];
20
+ for (const item of meeting.items) {
21
+ const size = `${Math.max(1, Math.round(item.bodyChars / 1000))} KB${item.parts.length > 0 ? ` in ${item.parts.length + 1} parts` : ""}`;
22
+ const mark = item.canonical
23
+ ? item.canonicalReason && item.canonicalReason !== "only_copy" ? ` · canonical (${item.canonicalReason})` : ""
24
+ : ` · copy of ${refOf.get(item.duplicateOf ?? "") ?? item.duplicateOf}`;
25
+ lines.push(` ${item.meetingDate ?? "undated "} ${refOf.get(item.id)} ${item.title} ${item.visibility} · ${item.source} · ${size}${mark}`);
26
+ }
27
+ if (meeting.truncated)
28
+ lines.push(" (the read hit its cap, so there may be more)");
29
+ return lines;
30
+ }
7
31
  function libraryQuery(command) {
8
32
  const query = new URLSearchParams();
9
33
  if (command.series)
@@ -33,8 +57,11 @@ export async function listNotes(command, door) {
33
57
  return emit(door, body);
34
58
  sayScope(door, body);
35
59
  const series = body.series ?? [];
60
+ const inDocs = meetingDocumentLines(body.meetingDocuments, "cockpit docs read");
36
61
  if (series.length === 0) {
37
62
  writeLine(door.io.stdout, "No meeting notes matched.");
63
+ if (inDocs.length > 0)
64
+ writeLine(door.io.stdout, ["", ...inDocs].join("\n"));
38
65
  return 0;
39
66
  }
40
67
  for (const one of series) {
@@ -47,6 +74,8 @@ export async function listNotes(command, door) {
47
74
  }
48
75
  writeLine(door.io.stdout, "");
49
76
  writeLine(door.io.stdout, `${body.count ?? 0} note(s)${body.more ? ", and more exist beyond the limit" : ""}.`);
77
+ if (inDocs.length > 0)
78
+ writeLine(door.io.stdout, ["", ...inDocs].join("\n"));
50
79
  return 0;
51
80
  }
52
81
  export async function listShelves(command, door) {
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.137");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.140");
25
25
  return 0;
26
26
  }
27
27