@bli-cockpit/cli 0.2.123 → 0.2.125

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.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `cockpit brief done <item>` / `cockpit brief undone <item>` (BLI-3605).
3
+ *
4
+ * The website's board has a checkbox; this is that checkbox at a terminal,
5
+ * over the same `POST /api/jarvis/ticks` door with the same device token.
6
+ *
7
+ * The one judgement this file makes is REFUSING BEFORE WRITING. A tick is
8
+ * keyed on a board row — its ticket identifier, or its claim id for a row with
9
+ * no ticket — and a key that names nothing would sit in the table forever,
10
+ * ticked against an item nobody can find. So the page is read first, the item
11
+ * is looked for in it, and an item that is not on the board is named in the
12
+ * person's own words with nothing sent. That read costs one request and buys
13
+ * the only error message worth having.
14
+ *
15
+ * Whose page: your own. There is deliberately no `--for` here. Every other
16
+ * brief verb lets an admin work on somebody else's page; a tick is private
17
+ * working state, and the door has no way to accept one on another person's
18
+ * behalf even if this command asked.
19
+ */
20
+ import { colorEnabled, dim, writeLine } from "./cli-io.js";
21
+ import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
22
+ const REQUEST_DEADLINE_MS = 30_000;
23
+ /** A ticket identifier as Linear writes it, and as a board row carries it. */
24
+ const TICKET_IDENTIFIER = /^[A-Z][A-Z0-9]{1,9}-\d+$/;
25
+ export async function runBriefDone(command, io) {
26
+ const done = command.action === "done";
27
+ const item = (command.item ?? "").trim();
28
+ const session = await loadPairedSession(`brief ${command.action}`, command.homeDir);
29
+ const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
30
+ const log = (line) => writeLine(io.stderr, line);
31
+ const page = await readThePage(dashboardUrl, session.device_token, io, log);
32
+ if (!page)
33
+ return 1;
34
+ const found = findTheItem(page, item);
35
+ if (!found) {
36
+ // Named rather than sent. See the module header.
37
+ writeFailure(command, io, "unknown_item", notOnTheBoard(item));
38
+ return 1;
39
+ }
40
+ const result = await towerJsonRequest({
41
+ dashboardUrl,
42
+ path: "/api/jarvis/ticks",
43
+ deviceToken: session.device_token,
44
+ fetch: io.fetch,
45
+ method: "POST",
46
+ label: `brief ${command.action}`,
47
+ timeoutMs: REQUEST_DEADLINE_MS,
48
+ log,
49
+ body: {
50
+ item_key: found.itemKey,
51
+ ticket_identifier: found.ticketIdentifier,
52
+ claim_id: found.claimId,
53
+ page_id: page.page?.pageId ?? null,
54
+ done,
55
+ },
56
+ });
57
+ if (!result.ok) {
58
+ writeFailure(command, io, result.reason, result.detail);
59
+ return 1;
60
+ }
61
+ const body = result.body;
62
+ if (!body.ok) {
63
+ writeFailure(command, io, body.error ?? "not_saved", body.reply ?? body.error ?? "It did not save.");
64
+ return 1;
65
+ }
66
+ if (command.json) {
67
+ writeLine(io.stdout, JSON.stringify(body));
68
+ }
69
+ else {
70
+ const styled = colorEnabled(io);
71
+ const box = done ? "[x]" : "[ ]";
72
+ writeLine(io.stdout, `${box} ${found.itemKey} ${done ? "done" : "back on the board"}`);
73
+ writeLine(io.stdout, dim(found.text, styled));
74
+ }
75
+ writeLine(io.stderr, `[brief tick cli] ${done ? "ticked" : "unticked"} ${JSON.stringify({
76
+ item_key: found.itemKey,
77
+ has_ticket: found.ticketIdentifier != null,
78
+ })}`);
79
+ return 0;
80
+ }
81
+ /** The page, with its claim ids, so an item can be checked before anything is sent. */
82
+ async function readThePage(dashboardUrl, deviceToken, io, log) {
83
+ const result = await towerJsonRequest({
84
+ dashboardUrl,
85
+ path: "/api/jarvis/brief?claims=1",
86
+ deviceToken,
87
+ fetch: io.fetch,
88
+ method: "GET",
89
+ label: "brief",
90
+ timeoutMs: REQUEST_DEADLINE_MS,
91
+ log,
92
+ });
93
+ if (!result.ok) {
94
+ writeLine(io.stderr, `[brief tick cli] page unread ${JSON.stringify({ reason: result.reason })}`);
95
+ writeLine(io.stdout, result.detail);
96
+ return undefined;
97
+ }
98
+ const body = result.body;
99
+ if (!body.ok) {
100
+ writeLine(io.stdout, body.reply ?? body.error ?? "There is no page here yet.");
101
+ return undefined;
102
+ }
103
+ return body;
104
+ }
105
+ /**
106
+ * The board row the person named, found in the page they can actually see.
107
+ *
108
+ * Two spellings, because those are the two keys a row can have: a ticket
109
+ * identifier (matched inside the rendered line, which is where the board puts
110
+ * it) and a claim id (matched exactly, as `cockpit brief --claims` prints it).
111
+ * A ticket identifier wins when both could match, because that is the key the
112
+ * tick is stored under and the one that survives tomorrow's compile.
113
+ */
114
+ export function findTheItem(page, item) {
115
+ const claims = page.claims ?? [];
116
+ if (TICKET_IDENTIFIER.test(item)) {
117
+ const line = claims.find((claim) => (claim.text ?? "").includes(item));
118
+ if (!line)
119
+ return null;
120
+ return {
121
+ itemKey: item,
122
+ ticketIdentifier: item,
123
+ claimId: line.claimId ?? null,
124
+ text: line.text ?? "",
125
+ };
126
+ }
127
+ const line = claims.find((claim) => claim.claimId === item);
128
+ if (!line)
129
+ return null;
130
+ return { itemKey: item, ticketIdentifier: null, claimId: item, text: line.text ?? "" };
131
+ }
132
+ function notOnTheBoard(item) {
133
+ return (`Nothing on your page is called "${item}". Run \`cockpit brief --claims\` to see the ids, ` +
134
+ "or name the ticket exactly as the board writes it. Nothing was recorded.");
135
+ }
136
+ function writeFailure(command, io, reason, detail) {
137
+ if (command.json) {
138
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, reply: detail }));
139
+ }
140
+ else {
141
+ writeLine(io.stdout, detail);
142
+ }
143
+ writeLine(io.stderr, `[brief tick cli] refused ${JSON.stringify({ reason })}`);
144
+ }
@@ -1,5 +1,7 @@
1
1
  import { optionalNonEmpty, optionalUrl, parseNamedArgs } from './local-arg-values.js';
2
2
  const ACTIONS = ['list', 'show', 'rescreen', 'invite', 'decide', 'grade', 'takehome-show', 'takehome-set', 'settings-show', 'settings-set'];
3
+ /** Typed word → the one action it means. The left side is what a person reads on the button. */
4
+ const ALSO_SPELLED = { 'send-takehome': 'invite' };
3
5
  const DECISIONS = ['passed_on', 'archived', 'reopen'];
4
6
  /** Verbs that name an application id, a role slug, or nothing at all. */
5
7
  const NEEDS_ID = ['show', 'rescreen', 'invite', 'decide', 'grade'];
@@ -14,15 +16,18 @@ export function parseCareersArgs(args) {
14
16
  // `team device` reads as `team device list`: the noun alone is a question.
15
17
  const first = positionals[0] ?? 'list';
16
18
  const paired = first === 'takehome' || first === 'settings';
17
- const action = paired
19
+ const typed = paired
18
20
  ? `${first}-${positionals.splice(0, positionals[1] === undefined ? 1 : 2)[1] ?? 'show'}`
19
21
  : (positionals.shift() ?? 'list');
22
+ // `send-takehome` is the button's own words; `invite` is the older spelling
23
+ // of the same act. One action from here on, the typed word only for errors.
24
+ const action = ALSO_SPELLED[typed] ?? typed;
20
25
  if (!ACTIONS.includes(action))
21
- throw new Error(`careers takes ${ACTIONS.join(', ')}.`);
26
+ throw new Error(`careers takes ${[...ACTIONS, ...Object.keys(ALSO_SPELLED)].join(', ')}.`);
22
27
  const verb = action;
23
28
  const target = positionals.shift();
24
29
  if (NEEDS_ID.includes(verb) && !target)
25
- throw new Error(`careers ${verb} needs an application id.`);
30
+ throw new Error(`careers ${typed} needs an application id.`);
26
31
  if (NEEDS_ROLE.includes(verb) && !target)
27
32
  throw new Error(`careers ${verb.replace('-', ' ')} needs a role slug.`);
28
33
  if (positionals.length > 0)
@@ -64,11 +64,21 @@ export function parseBriefArgs(args) {
64
64
  // `status` (BLI-3462) answers why a brief was or was not delivered.
65
65
  const first = values.positionals[0];
66
66
  const action = (first === undefined ? "read" : first);
67
- if (!["read", "edit", "rewrite", "history", "status"].includes(action)) {
68
- throw new Error(`Unknown brief command: ${first}. Try edit, rewrite, history or status, or nothing to read it.`);
67
+ if (!["read", "edit", "rewrite", "history", "status", "done", "undone"].includes(action)) {
68
+ throw new Error(`Unknown brief command: ${first}. Try done, undone, edit, rewrite, history or status, or nothing to read it.`);
69
69
  }
70
- if (values.positionals.length > (first === undefined ? 0 : 1)) {
71
- throw new Error(`brief ${action} does not take "${values.positionals[1]}".`);
70
+ // BLI-3605: `done` and `undone` are the only brief verbs that take a
71
+ // positional, and they REQUIRE one, "which item" is the whole command, and
72
+ // a bare `cockpit brief done` that guessed at the top row would tick off
73
+ // somebody's work for them.
74
+ const ticksAnItem = action === "done" || action === "undone";
75
+ const item = ticksAnItem ? values.positionals[1] : undefined;
76
+ if (ticksAnItem && !item) {
77
+ throw new Error(`brief ${action} needs the item: a ticket like BLI-3511, or a claim id from \`cockpit brief --claims\`.`);
78
+ }
79
+ const positionalsAllowed = first === undefined ? 0 : ticksAnItem ? 2 : 1;
80
+ if (values.positionals.length > positionalsAllowed) {
81
+ throw new Error(`brief ${action} does not take "${values.positionals[positionalsAllowed]}".`);
72
82
  }
73
83
  const tldr = values.booleans.has("--tldr");
74
84
  const full = values.booleans.has("--full");
@@ -133,6 +143,7 @@ export function parseBriefArgs(args) {
133
143
  return {
134
144
  kind: "brief",
135
145
  action,
146
+ ...(item ? { item } : {}),
136
147
  render,
137
148
  homeDir: optionalNonEmpty(values.flags.get("--home")),
138
149
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
@@ -25,11 +25,11 @@ export const TOWER_COMMAND_HELP = [
25
25
  [
26
26
  "careers",
27
27
  [
28
- "Usage: cockpit careers [list|show <id>|rescreen <id>|invite <id>|decide <id> --decision <d>|grade <id>|takehome show <role>|takehome set <role>|settings show|settings set] [flags]",
28
+ "Usage: cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <d>|grade <id>|takehome show <role>|takehome set <role>|settings show|settings set] [flags]",
29
29
  "",
30
30
  "Super-admin application review and pipeline. Lists at most 100 matches with total and has_more; use filters to narrow.",
31
31
  "list [--role <slug>] [--min-score <n>] [--since <date>] [--stage <screened|invited|submitted|graded|booking_sent|passed_on|archived>] — the board, newest first, with the two thresholds in force.",
32
- "invite <id> sends that role's take-home from the configured mailbox, whatever the screening scored. Only a row still at `screened`.",
32
+ "send-takehome <id> (also `invite <id>`): sends that role's take-home from the configured mailbox, whatever the screening scored. The same act as Tower's Send take-home button, and only a row still at `screened`.",
33
33
  "decide <id> --decision <passed_on|archived|reopen> — your own call on a row; `reopen` puts it back to `screened`.",
34
34
  "grade <id> [--now]: puts one submission back in the take-home grader's queue. The grader runs on Railway every 15 minutes; --now says so rather than pretending the run happened here.",
35
35
  "takehome show <role> — the subject, body, link and the reviewer's rubric for that role.",
@@ -410,7 +410,7 @@ export function localSubcommandHelp(command) {
410
410
  [
411
411
  "brief",
412
412
  [
413
- "Usage: cockpit brief [edit|rewrite|history] [--for <person>] [--date <YYYY-MM-DD>] [--delta [--against <YYYY-MM-DD>]] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--days <n>] [--reason \"<why>\"] [--wait|--no-wait] [--dashboard-url <url>] [--json]",
413
+ "Usage: cockpit brief [done <item>|undone <item>|edit|rewrite|history] [--for <person>] [--date <YYYY-MM-DD>] [--delta [--against <YYYY-MM-DD>]] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--days <n>] [--reason \"<why>\"] [--wait|--no-wait] [--dashboard-url <url>] [--json]",
414
414
  "",
415
415
  "Prints the TODAY page — the same page the Tower website shows, rendered for a terminal.",
416
416
  "--for opens somebody else's page; the website's own rule decides whether you may, and it refuses in plain words when you may not.",
@@ -442,6 +442,12 @@ export function localSubcommandHelp(command) {
442
442
  " With no editor, or with something piped in, the edited document is read from stdin instead — `cockpit brief edit < page.md`.",
443
443
  " The edit IS the correction: each changed line is filed in the ledger before the page is republished, so a publish that falls over never costs you the edit.",
444
444
  "",
445
+ "done <item> / undone <item>, tick one DO THIS FIRST row off, or put it back. The same checkbox the website's board has, on the same record.",
446
+ " <item> is the ticket exactly as the board writes it (BLI-3511), or a claim id from `cockpit brief --claims` for a row with no ticket behind it.",
447
+ " A ticked row prints as [x] and says `done`; everything still open prints as [ ]. A ticket keeps its tick across every recompile, so what you finished stays finished.",
448
+ " An item that is not on your page is named before anything is sent, and nothing is recorded.",
449
+ " Your own board only: a tick is your working state, so there is no --for here.",
450
+ "",
445
451
  "rewrite — asks Tower to compile the page again and waits for the new version. Writing a page takes minutes, so this queues the work and watches your page rather than holding one long request open.",
446
452
  " --no-wait returns as soon as the ask is on the record. Waiting is the default.",
447
453
  " Running out of patience is not a failure and says so: the work is still going, and `cockpit brief` will show it when it lands.",
@@ -104,7 +104,7 @@ export function localCommandHelp(command) {
104
104
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
105
105
  ` cockpit search "<words>" [--kind ${SEARCH_KINDS.join(",")}] [--limit <n>] [--dashboard-url <url>] [--json]`,
106
106
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
107
- " cockpit careers [list|show <id>|rescreen <id>|invite <id>|decide <id> --decision <passed_on|archived|reopen>|grade <id> [--now]|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--json]",
107
+ " cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <passed_on|archived|reopen>|grade <id> [--now]|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--json]",
108
108
  " cockpit usage sessions [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--since <window>] [--json]",
109
109
  " cockpit usage sessions --session <id> [--from-window <n>] [--json]",
110
110
  " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
@@ -27,6 +27,7 @@ import { runWorkbook } from "./workbook.js";
27
27
  import { runBrief } from "./brief.js";
28
28
  import { runBriefEdit } from "./brief-edit.js";
29
29
  import { runBriefRewrite } from "./brief-rewrite.js";
30
+ import { runBriefDone } from "./brief-done.js";
30
31
  import { runCorrect } from "./correct.js";
31
32
  import { runNotes } from "./notes.js";
32
33
  import { runServe } from "./serve.js";
@@ -137,6 +138,10 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
137
138
  return await runBriefEdit(command, io);
138
139
  if (command.action === "rewrite")
139
140
  return await runBriefRewrite(command, io);
141
+ // BLI-3605: the board's done checkbox, at a terminal.
142
+ if (command.action === "done" || command.action === "undone") {
143
+ return await runBriefDone(command, io);
144
+ }
140
145
  return await runBrief(command, io);
141
146
  case "correct":
142
147
  return await runCorrect(command, io);
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.123");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.125");
19
19
  return 0;
20
20
  }
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.123",
3
+ "version": "0.2.125",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.32",
31
- "@bli-cockpit/mcp": "0.1.50",
31
+ "@bli-cockpit/mcp": "0.1.52",
32
32
  "@bli-cockpit/telemetry-core": "0.1.48"
33
33
  }
34
34
  }