@bli-cockpit/cli 0.2.240 → 0.2.241

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.
@@ -210,53 +210,67 @@ export const TOWER_OPS_COMMAND_HELP = [
210
210
  [
211
211
  "manager",
212
212
  [
213
- "Usage: cockpit manager <clients|client|home|summary|run|list|queue|show|add|edit|done|reopen|link|approve|dismiss|proposal|archive|unarchive> ... [--dashboard-url <url>] [--json]",
213
+ "Usage: cockpit manager <clients|client|home|summary|run|list|queue|show|add|edit|done|drop|reopen|link|approve|dismiss|proposal|archive|unarchive> ... [--dashboard-url <url>] [--json]",
214
214
  "",
215
215
  "Tower Manager's clients, from the terminal: each client's Home and \"Where we are\", its actions and its timeline items, the same things the manager page does.",
216
216
  " cockpit manager clients every client, with its id",
217
- " cockpit manager client <client> one client: state, last read, pages waiting; run <run-id> one run's receipt",
217
+ " cockpit manager client <client> one client: state, last read, pages waiting; run <run-id> one run's receipt (a summary check says what it did to the card, not \"0 pages seen\"). A run id is a UUID from `queue <client> --json` (a row's changedByRunId), `show <id>` (a suggested change's runId) or `client <client> --json` (lastBriefRebuild.runId); a read's workflow id (lastRead.workflowRunId, like sched_wf_…) is not one",
218
218
  " cockpit manager home <client> the client's Home: how fresh its data is (last read, when Notion last changed), \"Where we are\" (health, headline, up to 5 dated bullets,",
219
- " next step, blocker, who wrote it and when), live counts, what needs attention by owner (overdue and due within 7 days), what is coming up,",
220
- " what the newest read that changed anything did, and how many sync changes wait for review (`queue <client>` lists them).",
221
- " cockpit manager summary <client> --version <v>|none [--health on_track|at_risk|blocked] [--headline <text>] [--bullet \"YYYY-MM-DD: text\"]... [--next <text>|none] [--blocker <text>|none]",
219
+ " next step, blocker, who wrote it and when), live counts, what needs attention by owner in two parts, \"Due this week\" first (today to six days ahead) then \"Overdue\" (rows sit under their owners, oldest first under each; a line \"Overdue longest: <title>, due <day>\" names the one overdue longest), each with its own total and, when cut, \"Showing X of N\" and the `list` command for the rest, what is coming up,",
220
+ " what the newest read that changed anything did (proposals first), \"In Tower, last 7 days\" (what people and agents added, closed, dropped and dismissed, and who, by name; a count of none is left out, and when all are none: \"nothing added, closed, dropped or dismissed by people or agents\"), and a What changed tally that likewise leaves out a kind with none (\"12 added, 2 proposed\"; \"nothing changed\" when none), and the review line (\"N suggested changes need a decision · M new items to check\"; `queue <client>` lists them), and how many sync changes are \"new from syncs\" (`queue <client>` lists them; `list` counts every unreviewed item as \"need review\", the first read's included).",
221
+ " Home also says how old the last check of the card is (\"Last checked 2 h ago\"), \"The last check failed; showing the card from Oct 5\" when it did, \"Last checked\" only once a check has looked at the card (a card a person wrote has none yet), a failed read's reason in the page\'s own sentence (\"Why the latest read failed: ...\"), and \"First read not done yet\" for a client no read has finished (\"First read failed\" when it failed: the manager page shows only that failure until a read succeeds, though writes made here are saved); a card computed from counts reads \"AI unavailable · computed from counts\".",
222
+ " cockpit manager summary <client> --version <v>|none [--health on_track|at_risk|blocked] [--headline <text>] [--bullet 'YYYY-MM-DD: text']... [--next <text>|none] [--blocker <text>|none]",
222
223
  " edit \"Where we are\": any field alone, or several at once. --bullet is given 1 to 5 times and REPLACES the whole list; a leading",
223
224
  " \"YYYY-MM-DD: \" dates a bullet, without it the bullet is undated. none clears --next or --blocker. blocked needs a blocker (given, or already on the card),",
224
- " and a blocker is kept only while the card is Blocked: with any other health it is dropped.",
225
+ " and a blocker goes only on a Blocked card: sent with any other health it is refused (summary_blocker_needs_blocked); sending only a new health drops the stored one.",
225
226
  " --version is summary.updatedAt from `home <client> --json`, or none when the client has no card yet (then --health, --headline and at least one --bullet are all needed).",
226
- " Limits: headline 140 characters, each bullet 160, next and blocker 200. The edit is marked as yours and stands until the client's",
227
- " data changes or an action goes overdue; the next check after that writes the card again (newest wins).",
227
+ " Limits: headline 140 characters, each bullet 160, next and blocker 200; a longer text is refused here with its length. A day spelled with a month name at the start of a bullet (\"Oct 3: x\") is refused, never saved undated. --version none is for a client with no card: on one that has a card the write is refused with \"This client already has a card: pass its version\". The edit is marked as yours and stands until the client's",
228
+ " data changes, an action goes overdue or an open action passes 14 days overdue; the next check after that writes the card again (newest wins).",
229
+ " A saved card that says On track while an open action is more than 14 days overdue is saved anyway and prints a \"Warning: ...\" line naming the action overdue longest.",
230
+ " Write the card after your item writes: your own later item writes count as data changes, and the next check rewrites the card.",
231
+ " Health rule: an open action more than 14 days overdue, as `cockpit manager home` shows it (Tower's today, America/Vancouver), means the card is at_risk (or blocked, with a named blocker).",
232
+ " Do not set on_track over one; tell the person instead. The headline or a bullet must name the action overdue longest, with the day it was due.",
228
233
  " cockpit manager list <client> [--timeline] [--needs-review] [--status open|done|dropped|closed|all] [--owner <name>|--no-owner] [--overdue] [--kind <kind>] [--search <text>] [--archived] [--review dismissed|all]",
229
234
  " actions (default), or with --timeline the timeline; --status, --owner, --no-owner and --overdue (open actions due before today) are for actions, --kind for the timeline.",
230
235
  " The timeline reads top down: undated items (\"Needs a date\"), then upcoming, a Today line, then the past, newest first (today is Vancouver's day).",
231
- " Dismissed and archived items are hidden: --review dismissed (or all) and --archived find them. A list holds at most 500 rows and says when it is cut.",
232
- " cockpit manager queue <client> changes from syncs after onboarding that nobody has looked at yet (the onboarding's own items are not in it)",
236
+ " Dismissed and archived items are hidden: --review dismissed (or all) and --archived find them. A list holds at most 500 rows and says when it is cut. With --owner or --no-owner the counts under the heading are that filter's (\"N open actions match these filters (M overdue)\"), not the client's. Times print as Tower's day and time (America/Vancouver, \"Oct 16, 1:03 AM PDT\"); --json keeps the raw ISO.",
237
+ " cockpit manager queue <client> changes from syncs after onboarding that nobody has looked at yet (the onboarding's own items are not in it): \"Needs your decision\" first (a suggested change waits on each), then \"New items to check\"",
233
238
  " an empty queue does not mean everything is reviewed: list <client> --needs-review --status all, and list <client> --timeline --needs-review",
234
- " cockpit manager show <id> [--timeline] one item in full: detail, sources, suggested changes with their ids, what a sync changed",
235
- " cockpit manager add <client> --title <text> [--owners \"A, B\"] [--due YYYY-MM-DD] [--meeting <timeline-id>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]",
239
+ " cockpit manager show <id> [--timeline] one item in full: detail, sources, suggested changes with their ids and, beneath each, the page and quote the AI cited for it (or \"not found in the page\"), what a sync changed, and who added, edited, reviewed, closed or archived it (by name)",
240
+ " cockpit manager add <client> --title <text> [--owners 'A, B'] [--due YYYY-MM-DD] [--meeting <timeline-id>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]",
236
241
  " cockpit manager add <client> --timeline --kind meeting|document|milestone|internal --title <text> [--date YYYY-MM-DD] [--owner <one name>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]",
237
242
  " no --date makes an undated timeline item, which lands in \"Needs a date\"",
238
243
  " --source-url (an http(s) link) and --source-note say where it came from, as the web form's Source does; show prints them",
239
- " cockpit manager edit <id> --version <v> [--timeline] [--title <text>] [--detail <text>] [--due <date>|none] [--owners \"A, B\"|none]",
244
+ " cockpit manager edit <id> --version <v> [--timeline] [--title <text>] [--detail <text>] [--due <date>|none] [--owners 'A, B'|none]",
240
245
  " on a timeline item: [--kind <kind>] [--date <date>|none] [--owner <one name>|none]",
241
- " cockpit manager done <action> --version <v> tick an action off (an AI action you tick off counts as reviewed by you, and suggested changes it makes moot close); reopen <action> --version <v> open it again",
242
- " cockpit manager link <action> --version <v> --meeting <timeline-id> link an action to the meeting it came from; --none unlinks it",
246
+ " an edit marks the item reviewed by you (an AI item's [needs review] becomes [reviewed], you the reviewer), even a detail-only one; the AI can only propose changes to it afterwards",
247
+ " cockpit manager done <action> --version <v> tick an action off (it marks the action reviewed by you, an AI action too, and suggested changes it makes moot close; on an action already done it writes nothing); reopen <action> --version <v> open it again",
248
+ " cockpit manager drop <action> --version <v> drop an action: the work no longer applies and was NOT done (a dropped action is not a done one: summaries and the page tell them apart). Add a dated line saying why to its detail first (`edit <id> --version <v> --detail <text>` replaces the detail: keep what `show <id>` printed, add \"YYYY-MM-DD: dropped because ...\"). Prints \"Dropped: <title>.\", or \"No change; nothing was written.\" when it was already dropped; reopen undoes it. Marks the action reviewed by you when the status changes. On an older Tower server it says it can\'t drop yet; mark it done with a dated line saying why instead.",
249
+ " cockpit manager link <action> --version <v> --meeting <timeline-id> link an action to the meeting it came from; --none unlinks it (either marks the action reviewed by you)",
243
250
  " cockpit manager approve <id> --version <v> [--timeline] [--restore] the AI's item is right: mark it reviewed; --restore brings a DISMISSED item back into the lists, marked reviewed by you (without it, a dismissed item is refused)",
244
251
  " cockpit manager dismiss <id> --version <v> [--timeline] the AI's item is wrong: dismiss it (kept, hidden from the lists)",
245
252
  " cockpit manager proposal <id> <proposal-id> accept|dismiss|add-new --version <v> [--timeline] decide one suggested change; add-new makes it an item of its own",
246
253
  " cockpit manager archive <id> --version <v> [--timeline] delete an item: it moves to the Deleted list (list --archived), kept; unarchive <id> --version <v> brings it back",
247
254
  "approve, dismiss and proposal decide the AI's suggestion as YOU: your name is recorded as the reviewer, and the item leaves the review queue unless a suggested change still waits on it.",
255
+ "Marking reviewed sets the review state to reviewed, with you as the reviewer and the time of the write: link always does it, and edit does whenever it changes something, even a detail-only one (an edit whose fields all equal the stored ones prints \"No change; nothing was written.\", with no new version and no reviewed mark); done, drop and reopen do it only when the status changes (on an action already in that status nothing is written); approve and an accepted proposal do it too. Dismiss sets it to dismissed with you as the reviewer; archive, unarchive and a proposal you dismiss or add-new leave the item's review state as it was.",
256
+ "Free text without shell quoting: add, edit and summary take --fields-file <path> (or - for stdin), one JSON object whose keys are the flags in snake_case, so a $12k, an apostrophe, a double quote or an = reaches Tower exactly as written. The file is UTF-8 (a UTF-16 file with a BOM is read too; any other encoding is refused): in Windows PowerShell write it with `-Encoding utf8` (a `>` redirect makes UTF-16 and Set-Content makes the ANSI code page, which breaks a ’), and for stdin on 5.1 set `$OutputEncoding = [System.Text.UTF8Encoding]::new()` first.",
257
+ " add: title, detail, owners, owner, due, date, kind, meeting, source_url, source_note, request_id; edit: title, detail, owners, owner, due, date, kind; summary: health, headline, bullets, next, blocker.",
258
+ " owners is a list or \"A, B\"; bullets is a list of \"YYYY-MM-DD: text\" strings or {\"date\": \"YYYY-MM-DD\" or null, \"text\": ...} objects; null (or none) clears owners, owner, due, date, next or blocker.",
259
+ " --version and --timeline stay flags. A field given as a flag and in the file is refused, so is an unknown key. The file gets the flags' own checks. Example: cat fields.json | cockpit manager summary Acme --version none --fields-file -",
260
+ "Free text (the flags above and every value in a fields file) that holds a provider key (an sk-, rk_, sb_secret_, ghp_, github_pat_, AKIA/ASIA, xoxb- key, any JWT, a PEM private key) or a password given a value (\"password: x\") is refused, naming the flag or key; ordinary words, a commit sha, a URL and the words \"service-role key\" are plain text.",
261
+ "On a flag, free text (--headline, --bullet, --title, --detail, --next, --blocker, --owners, --source-note) goes in SINGLE quotes so the shell leaves $ alone: \"$12k\" in double quotes loses the $12k (zsh and bash read it as a variable). In bash and zsh write an apostrophe inside single quotes as '\\'' (end the quote, a backslash-escaped apostrophe, reopen); in PowerShell double the apostrophe ('Rowan''s deck'). The command prints what it saved: check it is your words.",
248
262
  "<client> is the client's id, or its exact name (any case; `clients` lists them); a name that matches no client, or several, is refused with the names. Ids and versions are printed by list, queue and show. Items you add are reviewed already.",
249
263
  "--version is the item's version exactly as list or show prints it, copied as it is. Every change to an existing item names it: if someone or a sync changed the item",
250
264
  "since, the write is refused with a sentence saying so and nothing is written; run show again for the new version and retry.",
251
265
  "--owners on an action takes, as the web form says, \"One or more people, separated by commas\" (\"Rowan Ash, Juniper Twig\"), and it REPLACES the whole list. Leave it out and the owners stay;",
252
266
  "--owners none clears them. A timeline item has ONE owner: --owner <name> or --owner none. The same pattern clears a date: --due none, --date none.",
253
267
  "Owner names are kept as written, not matched to accounts: use the spelling `list` shows, so one person stays one name. A flag you leave out is never sent, and a flag given twice is refused (all but summary's --bullet). A date is YYYY-MM-DD. `cockpit manager`, `manager help` and `manager --help` print this and change nothing.",
254
- "add always sends a request id (--request-id <uuid>, else a fresh one) and prints it. If Tower does not answer, nothing was confirmed: rerun the same command with",
255
- "--request-id <id> and it retries without making a duplicate.",
268
+ "add always sends a request id and prints it: leave --request-id out on a first add (a fresh one is made). If Tower does not answer, nothing was confirmed: rerun the same command with the",
269
+ "--request-id the CLI printed and it retries without making a duplicate (\"Already added earlier under this request id; nothing new was written.\"). Never make an id up: reusing one for a different item is refused (request_reused): the request id IS the item's id, so `show <request-id>` shows it, a retry already made it, and a different item is added without --request-id. An add prints the detail (cut to 200 characters) and the source it saved, so you can check they are your words.",
256
270
  "--json writes the door's body to stdout, verbatim, and nothing else (an add's body also carries requestId); a refusal is { ok: false, error, detail, refusal? } and exits 1.",
257
271
  "Progress lines on stderr start with [tag]; stdout is the answer (2>/dev/null hides them). Creating a client, syncing, Rebuild brief and merging owners are on the manager page only. Teaching: docs/architecture/tower-manager.md.",
258
272
  "The bli-tower MCP server has the twins: manager_clients, manager_client, manager_home, manager_set_summary (summary), manager_run, manager_list, manager_queue, manager_show, manager_add, manager_edit,",
259
- "manager_done, manager_reopen, manager_link, manager_approve, manager_dismiss, manager_proposal, manager_archive, manager_unarchive.",
273
+ "manager_done, manager_drop, manager_reopen, manager_link, manager_approve, manager_dismiss, manager_proposal, manager_archive, manager_unarchive.",
260
274
  ],
261
275
  ],
262
276
  [
@@ -153,7 +153,7 @@ export const TOWER_COMMAND_HELP = [
153
153
  [
154
154
  "cal",
155
155
  [
156
- 'Usage: cockpit cal [event <id>|export <id>|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|next|find "<words>"|calendars|add-ical|create|duplicate <event id>|batch|undo [<change id>]|rsvp <event id> --response <accepted|declined|tentative>|change <event id>|cancel <event id> --yes|share <id>|sync <id>|sync-status [<id>]|detach <id>|delete <event id> --yes] [flags]',
156
+ 'Usage: cockpit cal [event <id>|export <id>|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|view-settings|view-settings-change|next|find "<words>"|calendars|add-ical|create|duplicate <event id>|batch|undo [<change id>]|rsvp <event id> --response <accepted|declined|tentative>|change <event id>|cancel <event id> --yes|share <id>|sync <id>|sync-status [<id>]|detach <id>|delete <event id> --yes] [flags]',
157
157
  "",
158
158
  "Every calendar you attached, in one place — plus any calendar a colleague marked shared.",
159
159
  "today [--offset 1] [--tz <zone>] — what is on today, in YOUR zone (this machine's, unless --tz says otherwise). --offset 1 is tomorrow, -1 yesterday.",
@@ -163,7 +163,7 @@ export const TOWER_COMMAND_HELP = [
163
163
  "conflicts [--from <iso> --to <iso>] [--tz <IANA>] — own-account double-bookings with duplicate invitation merging, checked calendars and age. Visible calendars only by default; --all includes hidden calendars. All-day and free events never conflict.",
164
164
  "event <id> [--occurrence-start <iso>] [--tz <zone>] [--json] — exact occurrence details, human recurrence, named guests/RSVP, organizer, source zone, HTTPS Join and edit capability. The selector must match a real occurrence; cancelled/missing instances are refused.",
165
165
  "export <id> [--occurrence-start <iso>] [--json] — iCalendar text to stdout; redirect to event.ics. Recurring exports include the complete series and exceptions, keeping source zone/recurrence. No calendar credentials.",
166
- "calendar-change <id> --color <hex|provider> — persistent owner colour override, or reset to provider colour. Also accepts stdin JSON {color_override, selected}. History is retained.",
166
+ "calendar-change <id> --color <hex|provider> — persistent owner colour override, or reset to provider colour. Also accepts stdin JSON {color_override, selected}. History is retained. view-settings [--json] reads your saved view settings (default view, N days, week start, weekends, density, up to 3 extra time zones) plus the calendar actions and keyboard shortcuts; never saved means the defaults, said so. view-settings-change [--view day|week|days|month] [--days 2-9] [--week-start monday|sunday] [--hide-weekends on|off] [--density normal|compact] [--zone <IANA>]... changes only what you name; --zone repeats (up to 3), --zone none clears; stdin JSON like {\"day_count\":4} works too.",
167
167
  "calendars — every calendar, its provider (google_oauth or ical_url), whether it is shared, and when it last synced. The ids other verbs take are on the last line.",
168
168
  "add-ical [--name \"<what to call it>\"] — attaches a personal calendar by its SECRET iCal ADDRESS, read from stdin. There is no flag for it:",
169
169
  ' macOS: printf "%s" "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"',
@@ -144,7 +144,7 @@ const COMMAND_SUMMARY_LINES = [
144
144
  " cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>|show <message-id>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--before <cursor>] [--dashboard-url <url>] [--json]",
145
145
  " 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] [--size S|M|L] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
146
146
  " cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|draft --account <id> --to <a> --subject <s> [--reply-to <message id>]|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--attach <path>]... [--dashboard-url <url>] [--json]",
147
- " cockpit cal [event <id> [--occurrence-start <iso>]|export <id> [--occurrence-start <iso>]|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|duplicate <event id> [--keep-invitees]|batch|undo [<change id>]|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
147
+ " cockpit cal [event <id> [--occurrence-start <iso>]|export <id> [--occurrence-start <iso>]|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|view-settings|view-settings-change|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|duplicate <event id> [--keep-invitees]|batch|undo [<change id>]|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
148
148
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
149
149
  " cockpit github <pr|merge|retitle|rerun> <owner/repo> <n> [--claim <state>] [--method squash|merge|rebase] [--title \"<text>\"] [--run <id>] [--confirm <code>] [--dashboard-url <url>] [--json]",
150
150
  ` cockpit search "<words>" [--kind <category,...>] [--limit <n>] [--dashboard-url <url>] [--json]`,
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `cockpit manager add|edit|summary --fields-file <path>|-` (BLI-5777, findings Q-10 and Q-33): the free text of a write
3
+ * as one JSON object, read from a file or from stdin, so no shell quoting touches it (`$12k`, an apostrophe, a double
4
+ * quote on Windows PowerShell 5.1, an `=`).
5
+ *
6
+ * The keys are the flag names in snake_case. This module only READS: it checks the JSON's shape, refuses an unknown key
7
+ * or a key also given as a flag, runs the secret guard on every string, and turns each key into the same value the flag
8
+ * would have carried. The parser (`local-args-tower-manager.ts`) then runs the one set of field readers over the merged
9
+ * values, so a file value gets exactly the flag's validation (`none` clears, blank is refused, dates are real days).
10
+ *
11
+ * `--version` and `--timeline` stay flags: the version guards a write against a stale read, and `--timeline` picks the table.
12
+ */
13
+ import { readFile, stat } from "node:fs/promises";
14
+ import { isInteractiveStdin } from "./cli-io.js";
15
+ import { rejectSecretLikeValue } from "./local-arg-values.js";
16
+ /** The keys a fields file may hold, per verb: every value flag the verb owns except `--version`. */
17
+ export const FIELDS_FILE_KEYS = {
18
+ add: ["title", "detail", "owners", "owner", "due", "date", "kind", "meeting", "source_url", "source_note", "request_id"],
19
+ edit: ["title", "detail", "owners", "owner", "due", "date", "kind"],
20
+ summary: ["health", "headline", "bullets", "next", "blocker"],
21
+ };
22
+ /** Keys whose JSON `null` means "clear it", the same as the flag's `none`. */
23
+ const NULLABLE = new Set(["owners", "owner", "due", "date", "next", "blocker"]);
24
+ /** A file is a few fields of text; this stops a wrong file (a log, a dump) being read whole. */
25
+ export const FIELDS_FILE_MAX_BYTES = 256 * 1024;
26
+ const flagOf = (key) => `--${key.replace(/_/g, "-")}`;
27
+ /** A key is the person's own spelling: cut and cleaned before it goes into a sentence. */
28
+ const shown = (key) => key.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").slice(0, 40);
29
+ /** The sentence for bytes that are not UTF-8 text (a Windows PowerShell 5.1 `>` file is UTF-16, a `Set-Content` file is the ANSI code page). */
30
+ export const NOT_UTF8 = "The file is not UTF-8. Save the file as UTF-8 (PowerShell: -Encoding utf8) and run it again.";
31
+ /**
32
+ * The bytes of a fields file as text: a UTF-8 BOM is dropped, a UTF-16 LE or BE BOM is decoded (PowerShell 5.1's `>` and Out-File write UTF-16 LE with a
33
+ * BOM), and anything else must be well-formed UTF-8: a file in the ANSI code page (a `’` saved by Set-Content) is refused, never read as "�".
34
+ */
35
+ export function decodeFieldsBytes(bytes) {
36
+ // UTF-16 without a BOM (a program that wrote it raw) is full of NUL bytes, which no UTF-8 text holds: say what is wrong, not "not valid JSON (near character 1)".
37
+ const utf16Bom = bytes.length >= 2 && ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff));
38
+ if (!utf16Bom && bytes.includes(0))
39
+ throw new Error(NOT_UTF8);
40
+ try {
41
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe)
42
+ return new TextDecoder("utf-16le", { fatal: true }).decode(bytes.subarray(2));
43
+ if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff)
44
+ return new TextDecoder("utf-16be", { fatal: true }).decode(bytes.subarray(2));
45
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes.subarray(bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0));
46
+ }
47
+ catch {
48
+ throw new Error(NOT_UTF8);
49
+ }
50
+ }
51
+ /** Everything the stream holds, as bytes (a string chunk, as a test gives, is its UTF-8), refused over `max` bytes. */
52
+ async function readStreamBytes(stream, max, overflow) {
53
+ const chunks = [];
54
+ let total = 0;
55
+ for await (const chunk of stream) {
56
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
57
+ total += bytes.length;
58
+ if (total > max)
59
+ throw new Error(overflow);
60
+ chunks.push(bytes);
61
+ }
62
+ return Buffer.concat(chunks);
63
+ }
64
+ /** The text of the fields file: `-` is stdin (piped), anything else a path. Every failure is a sentence that says what to do. */
65
+ export async function readFieldsText(source, io) {
66
+ if (source === "-") {
67
+ if (isInteractiveStdin(io)) {
68
+ throw new Error("--fields-file - reads the JSON from stdin, but stdin is a terminal. Pipe it in (`cat fields.json | cockpit manager ... --fields-file -`) or give a file path.");
69
+ }
70
+ const bytes = await readStreamBytes(io.stdin, FIELDS_FILE_MAX_BYTES, `The fields JSON on stdin is longer than ${FIELDS_FILE_MAX_BYTES / 1024} KB. It holds a few fields of text; check you piped the right file.`);
71
+ let text;
72
+ try {
73
+ text = decodeFieldsBytes(bytes);
74
+ }
75
+ catch {
76
+ // From a pipe, Windows PowerShell 5.1 encodes with $OutputEncoding (ASCII by default): the way out is its setting, not the file.
77
+ throw new Error("The JSON on stdin is not UTF-8. In Windows PowerShell 5.1 set `$OutputEncoding = [System.Text.UTF8Encoding]::new()` before piping, or save the JSON as UTF-8 (PowerShell: -Encoding utf8) and pass its path.");
78
+ }
79
+ if (text.trim() === "")
80
+ throw new Error("--fields-file - got nothing on stdin. Pipe in a JSON object, like {\"title\": \"Send the SOW\"}.");
81
+ return text;
82
+ }
83
+ let info;
84
+ try {
85
+ info = await stat(source);
86
+ }
87
+ catch (error) {
88
+ const code = error.code;
89
+ throw new Error(code === "ENOENT" ? `--fields-file ${source}: no such file.` : `--fields-file ${source}: cannot be read (${code ?? "unknown error"}).`);
90
+ }
91
+ if (!info.isFile())
92
+ throw new Error(`--fields-file ${source} is not a file.`);
93
+ if (info.size > FIELDS_FILE_MAX_BYTES) {
94
+ throw new Error(`--fields-file ${source} is ${Math.ceil(info.size / 1024)} KB; the limit is ${FIELDS_FILE_MAX_BYTES / 1024} KB. It holds a few fields of text; check it is the right file.`);
95
+ }
96
+ let bytes;
97
+ try {
98
+ bytes = await readFile(source);
99
+ }
100
+ catch (error) {
101
+ throw new Error(`--fields-file ${source}: cannot be read (${error.code ?? "unknown error"}).`);
102
+ }
103
+ const text = decodeFieldsBytes(bytes);
104
+ if (text.trim() === "")
105
+ throw new Error(`--fields-file ${source} is empty. Put a JSON object in it, like {"title": "Send the SOW"}.`);
106
+ return text;
107
+ }
108
+ function parseObject(raw) {
109
+ let parsed;
110
+ try {
111
+ parsed = JSON.parse(raw);
112
+ }
113
+ catch (error) {
114
+ // The engine's message quotes the file's own words: only the position is kept.
115
+ const at = /position (\d+)/.exec(error instanceof Error ? error.message : "")?.[1];
116
+ throw new Error(`The fields file is not valid JSON${at ? ` (near character ${at})` : ""}. It is one object: {"title": "Send the SOW", "detail": "Mo said \\"ok\\""}. A quote inside a value is written \\", a new line \\n.`);
117
+ }
118
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
119
+ throw new Error("The fields file must be one JSON object, like {\"title\": \"Send the SOW\"}, not a list or a single value.");
120
+ }
121
+ return parsed;
122
+ }
123
+ /** A string value: of the right type, and not a secret. */
124
+ function text(key, value) {
125
+ if (typeof value !== "string")
126
+ throw new Error(`The fields file's "${key}" must be text${NULLABLE.has(key) ? " (or null to clear it)" : ""}.`);
127
+ rejectSecretLikeValue(value, `the fields file's "${key}"`);
128
+ return value;
129
+ }
130
+ /**
131
+ * Folds the file's fields into `values` as the flags they stand for, and returns the file's bullets (they have a
132
+ * shape of their own). Throws a sentence for: bad JSON, an unknown key, a key also given as a flag, a wrong type, a secret.
133
+ */
134
+ export function mergeFieldsFile(values, verb, fileText) {
135
+ const fields = parseObject(fileText);
136
+ const allowed = FIELDS_FILE_KEYS[verb];
137
+ const keys = Object.keys(fields);
138
+ for (const key of keys) {
139
+ if (key === "version" || key === "timeline") {
140
+ throw new Error(`"${key}" is not a field: give ${flagOf(key)} as a flag. It is not read from the fields file.`);
141
+ }
142
+ if (!allowed.includes(key)) {
143
+ throw new Error(`The fields file has a key "${shown(key)}" that \`cockpit manager ${verb}\` does not take. It takes: ${allowed.join(", ")}.`);
144
+ }
145
+ const flag = key === "bullets" ? "--bullet" : flagOf(key);
146
+ if (values.flags.has(flag) || values.flagValues.has(flag)) {
147
+ throw new Error(`${key} is given both as ${flag} and in the fields file. Give it once, in one place.`);
148
+ }
149
+ }
150
+ const out = { keys: keys.length };
151
+ for (const key of keys) {
152
+ const value = fields[key];
153
+ if (key === "bullets") {
154
+ out.bullets = readBulletList(value);
155
+ continue;
156
+ }
157
+ const flag = flagOf(key);
158
+ if (value === null) {
159
+ if (!NULLABLE.has(key))
160
+ throw new Error(`The fields file's "${key}" must be text; null clears only ${[...NULLABLE].join(", ")}.`);
161
+ values.flags.set(flag, "none");
162
+ }
163
+ else if (key === "owners" && Array.isArray(value)) {
164
+ values.flags.set(flag, ownersText(value));
165
+ }
166
+ else {
167
+ // An empty string is allowed from a file (it can clear a detail); the shell parser refuses an empty flag value.
168
+ values.flags.set(flag, text(key, value));
169
+ }
170
+ values.flagValues.set(flag, [values.flags.get(flag)]);
171
+ }
172
+ return out;
173
+ }
174
+ /** `["Pip Moss", "Rowan Ash"]` as the flag's `Pip Moss, Rowan Ash`; `[]` clears (`none`), like the flag's `none`. */
175
+ function ownersText(list) {
176
+ if (list.length === 0)
177
+ return "none";
178
+ const names = list.map((entry) => {
179
+ const name = text("owners", entry).trim();
180
+ if (name === "")
181
+ throw new Error("The fields file's \"owners\" has an empty name. Give names, or [] or null to clear them.");
182
+ if (name.includes(","))
183
+ throw new Error("A name in the fields file's \"owners\" cannot hold a comma: Tower splits people on commas. Write the name without it.");
184
+ return name;
185
+ });
186
+ return names.join(", ");
187
+ }
188
+ function readBulletList(value) {
189
+ if (!Array.isArray(value))
190
+ throw new Error("The fields file's \"bullets\" must be a list: [\"2026-10-14: Signed the SOW\", {\"date\": null, \"text\": \"Export still missing\"}].");
191
+ return value.map((entry, index) => {
192
+ if (typeof entry === "string")
193
+ return text("bullets", entry);
194
+ if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {
195
+ const { date, text: words, ...rest } = entry;
196
+ const extra = Object.keys(rest)[0];
197
+ if (extra !== undefined)
198
+ throw new Error(`Bullet ${index + 1} in the fields file has a key "${shown(extra)}"; a bullet is {"date": "YYYY-MM-DD" or null, "text": "..."}.`);
199
+ if (date !== null && date !== undefined && typeof date !== "string")
200
+ throw new Error(`Bullet ${index + 1}'s "date" must be "YYYY-MM-DD" or null.`);
201
+ if (typeof words !== "string")
202
+ throw new Error(`Bullet ${index + 1}'s "text" must be text.`);
203
+ rejectSecretLikeValue(words, `the fields file's "bullets"`);
204
+ return { date: date ?? null, text: words };
205
+ }
206
+ throw new Error(`Bullet ${index + 1} in the fields file must be a "YYYY-MM-DD: text" string or a {"date", "text"} object.`);
207
+ });
208
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * What `cockpit manager` tells a person after a refusal, and the limits it tells them (BLI-5777).
3
+ *
4
+ * A leaf module (it imports nothing) so the repo-shape tests can read it without pulling in the collector. Everything here
5
+ * is a COPY of something the manager server owns, and the CLI cannot import the server:
6
+ *
7
+ * - `MANAGER_LIMITS_COPY` and `LONG_OVERDUE_DAYS_COPY`: the server's `TOWER_MANAGER_SUMMARY_LIMITS` and `SUMMARY_LONG_OVERDUE_DAYS`;
8
+ * - `SUMMARY_REFUSAL_NAMES`: the server's `TOWER_MANAGER_SUMMARY_REFUSALS`, same names in the same order;
9
+ * - `ITEM_REFUSAL_NAMES`: the server's `ReviewRefusal` (`db/review.ts`) and `item_busy`, less `not_found` (the `not_found` reason answers it);
10
+ * - `READ_ONLY_REFUSAL_NAMES`: the door's `ReadOnlyRefusalReason`.
11
+ *
12
+ * `apps/dashboard/src/repo-shape/manager-copies.test.ts` fails the moment a copy differs from the server's, and each hint map
13
+ * is typed against its own name list (`satisfies Record<…>`), so a name added to the list without a hint fails `tsc`.
14
+ * A new module like this one joins `scripts/build-public-cli.mjs` `runtimeFiles` or the packed CLI breaks while the tests stay green.
15
+ */
16
+ export const MANAGER_LIMITS_COPY = { headlineMax: 140, bulletsMinPerson: 1, bulletsMax: 5, bulletMax: 160, nextMax: 200, blockerMax: 200 };
17
+ /** More than this many days past due, an open action makes the card at risk: a copy of the server's `SUMMARY_LONG_OVERDUE_DAYS`. */
18
+ export const LONG_OVERDUE_DAYS_COPY = 14;
19
+ const L = MANAGER_LIMITS_COPY;
20
+ export const SUMMARY_REFUSAL_NAMES = [
21
+ "summary_changed", "summary_empty", "summary_incomplete", "summary_health_invalid", "summary_headline_empty", "summary_headline_too_long",
22
+ "summary_bullets_count", "summary_bullet_empty", "summary_bullet_too_long", "summary_bullet_date_invalid", "summary_next_too_long",
23
+ "summary_blocker_too_long", "summary_blocked_needs_blocker", "summary_blocker_needs_blocked",
24
+ ];
25
+ /** The way forward after each named refusal of a "Where we are" edit; `home` is the ready-to-run `cockpit manager home <client> --json`. */
26
+ export function summaryHints(home) {
27
+ // A hint is the next step in this terminal's own words (the flag to pass, the command to run). It never says the server's sentence again.
28
+ return {
29
+ summary_changed: `Read the card again with ${home} and retry with its summary.updatedAt as --version (none only when it has no card).`,
30
+ summary_empty: "Pass at least one of --health, --headline, --bullet, --next, --blocker.",
31
+ summary_incomplete: "Pass --health, --headline and --bullet together, with --version none.",
32
+ summary_health_invalid: "Pass --health with one of those words, in lower case with underscores.",
33
+ summary_headline_empty: "A blank or invisible-only --headline counts as empty.",
34
+ summary_headline_too_long: `Shorten --headline: the limit (${L.headlineMax}) counts characters, not bytes.`,
35
+ summary_bullets_count: "Each --bullet is one bullet, and together they replace the whole list.",
36
+ summary_bullet_empty: "Put words after a --bullet's date, when it has one.",
37
+ summary_bullet_too_long: `Shorten the long --bullet (the limit is ${L.bulletMax}).`,
38
+ summary_bullet_date_invalid: "Start a dated --bullet with \"YYYY-MM-DD: \" and a real day.",
39
+ summary_next_too_long: `Shorten --next (the limit is ${L.nextMax}).`,
40
+ summary_blocker_too_long: `Shorten --blocker (the limit is ${L.blockerMax}).`,
41
+ summary_blocked_needs_blocker: "Add --blocker <what blocks it>, or pass another --health.",
42
+ summary_blocker_needs_blocked: "Add --health blocked, or leave --blocker out (`--blocker none` clears the stored one).",
43
+ };
44
+ }
45
+ export const ITEM_REFUSAL_NAMES = [
46
+ "item_changed", "item_busy", "item_archived", "item_not_archived", "item_dismissed", "person_item_not_dismissable",
47
+ "no_proposal", "proposal_replaced", "timeline_not_live", "request_reused", "proposal_decided", "proposal_not_addable",
48
+ ];
49
+ /** Refusals that mean "the item is not as you last saw it": reading it again is the way forward. */
50
+ export const RESEEN_REFUSALS = new Set(["item_changed", "item_busy", "proposal_decided", "no_proposal"]);
51
+ /** The way forward after each named refusal of an item write. `flag` is " --timeline" for a timeline item, else "". */
52
+ export function itemHints(id, flag) {
53
+ const reread = `Run \`cockpit manager show ${id}${flag}\` for the current version.`;
54
+ return {
55
+ item_changed: reread,
56
+ item_busy: reread,
57
+ no_proposal: reread,
58
+ proposal_decided: reread,
59
+ // The request id IS the item's id (an add made it): show it; a retry already made it, and a different item needs no request id.
60
+ request_reused: "The request id is that item's id: `cockpit manager show <request-id>` shows it. If you were retrying an add, it is already there: don't add it again. To add a different item, run the add without --request-id.",
61
+ proposal_replaced: `Run \`cockpit manager show ${id}${flag}\` to see the suggested changes waiting and their ids.`,
62
+ proposal_not_addable: `Run \`cockpit manager proposal ${id} <proposal-id> accept --version <v>\` (or dismiss).`,
63
+ item_archived: `Bring it back with \`cockpit manager unarchive ${id}${flag} --version <v>\` (\`list <client>${flag} --archived\` prints its version).`,
64
+ item_dismissed: `Run \`cockpit manager approve ${id}${flag} --version <v> --restore\` (\`list <client>${flag} --review dismissed\` prints its version).`,
65
+ person_item_not_dismissable: `Run \`cockpit manager archive ${id}${flag} --version <v>\`.`,
66
+ timeline_not_live: "`cockpit manager list <client> --timeline` lists the client's live timeline items.",
67
+ item_not_archived: "Nothing to bring back: it is in the lists already.",
68
+ };
69
+ }
70
+ /**
71
+ * The door's own refusals of a read-only device token (`ReadOnlyRefusalReason`): they reach the terminal as the `reason`
72
+ * (BLI-5777; before, the 403 carried no name and the CLI printed `http_403`).
73
+ */
74
+ export const READ_ONLY_REFUSAL_NAMES = ["read_only_device_cannot_write", "read_only_device_door_not_listed", "read_only_device_on_admin"];
75
+ export const READ_ONLY_HINTS = {
76
+ read_only_device_cannot_write: " Sign in as the person who is asking with `cockpit login`.",
77
+ read_only_device_door_not_listed: " A brain session's key may not read Tower Manager: sign in as the person who is asking with `cockpit login`.",
78
+ read_only_device_on_admin: " Mint the read-only key for a member instead.",
79
+ };
80
+ /** A read's workflow id (`sched_wf_…`, `wf-…`): what `lastRead.workflowRunId` holds in `client --json` and `home --json`. It is not a run's receipt id. */
81
+ export const looksLikeWorkflowId = (value) => /^(?:[a-z]+_)?wf[-_]/i.test(value);
82
+ /** Where a run's receipt id really comes from, for the refusal of anything else (`run <run-id>`). */
83
+ export function runIdRefusal(value) {
84
+ const where = "A run id looks like 8f14e45f-ceea-467a-9575-1d3a4e7b2c10. It comes from `cockpit manager queue <client> --json` (a row's changedByRunId), `cockpit manager show <id>` (a suggested change's runId) or `cockpit manager client <client> --json` (lastBriefRebuild.runId); `list` does not print run ids.";
85
+ return looksLikeWorkflowId(value)
86
+ ? `That is a read's workflow id (the lastRead.workflowRunId that \`client --json\` and \`home --json\` print), not a run's receipt id. ${where}`
87
+ : where;
88
+ }