@bli-cockpit/cli 0.2.122 → 0.2.124

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
+ }
@@ -2,7 +2,8 @@ import { readFile } from 'node:fs/promises';
2
2
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from './agent-door.js';
3
3
  /**
4
4
  * `cockpit careers` — application review (BLI-3706) and, since BLI-4460, the
5
- * pipeline: invite, decide, the role's take-home and the four settings.
5
+ * pipeline: invite, decide, the role's take-home and the settings. BLI-4461
6
+ * added `grade`, which puts one submission back in the grader's queue.
6
7
  *
7
8
  * Every verb goes through the SAME Tower door its MCP twin calls; nothing here
8
9
  * decides policy. The take-home body arrives from a FILE rather than a flag,
@@ -41,6 +42,10 @@ async function planCareersRequest(command) {
41
42
  case 'show': return { path: application, method: 'GET' };
42
43
  case 'rescreen': return { path: `${application}/rescreen`, method: 'POST' };
43
44
  case 'invite': return { path: `${application}/invite`, method: 'POST' };
45
+ // The grader itself runs on Railway, because it needs a clone and yt-dlp.
46
+ // This door re-queues the row; `--now` is passed through and the answer
47
+ // names where the run actually happens rather than implying it ran here.
48
+ case 'grade': return { path: `${application}/grade`, method: 'POST', body: { now: command.now === true } };
44
49
  case 'decide': return { path: `${application}/decide`, method: 'POST', body: { decision: command.decision } };
45
50
  case 'takehome-show': return { path: takehomePath(command), method: 'GET' };
46
51
  case 'takehome-set': return { path: takehomePath(command), method: 'PUT', body: await takehomeBody(command) };
@@ -81,8 +86,12 @@ function settingsBody(command) {
81
86
  body.attention_min_score = command.attentionMinScore;
82
87
  if (command.inviteAccountId !== undefined)
83
88
  body.invite_account_id = command.inviteAccountId;
89
+ if (command.bookingLink !== undefined)
90
+ body.booking_link = command.bookingLink;
91
+ if (command.graderModel !== undefined)
92
+ body.grader_model = command.graderModel;
84
93
  if (Object.keys(body).length === 0) {
85
- throw new Error('careers settings set needs at least one of --enabled, --auto-invite-min-score, --attention-min-score or --account.');
94
+ throw new Error('careers settings set needs at least one of --enabled, --auto-invite-min-score, --attention-min-score, --account, --booking-link or --grader-model.');
86
95
  }
87
96
  return body;
88
97
  }
@@ -1,12 +1,12 @@
1
1
  import { optionalNonEmpty, optionalUrl, parseNamedArgs } from './local-arg-values.js';
2
- const ACTIONS = ['list', 'show', 'rescreen', 'invite', 'decide', 'takehome-show', 'takehome-set', 'settings-show', 'settings-set'];
2
+ const ACTIONS = ['list', 'show', 'rescreen', 'invite', 'decide', 'grade', 'takehome-show', 'takehome-set', 'settings-show', 'settings-set'];
3
3
  const DECISIONS = ['passed_on', 'archived', 'reopen'];
4
4
  /** Verbs that name an application id, a role slug, or nothing at all. */
5
- const NEEDS_ID = ['show', 'rescreen', 'invite', 'decide'];
5
+ const NEEDS_ID = ['show', 'rescreen', 'invite', 'decide', 'grade'];
6
6
  const NEEDS_ROLE = ['takehome-show', 'takehome-set'];
7
7
  export function parseCareersArgs(args) {
8
- const valueFlags = ['--role', '--min-score', '--since', '--home', '--dashboard-url', '--decision', '--stage', '--subject', '--body-file', '--link', '--pass-score', '--account', '--auto-invite-min-score', '--attention-min-score', '--enabled'];
9
- const values = parseNamedArgs(args, { allowedFlags: [...valueFlags, '--json'], valueFlags });
8
+ const valueFlags = ['--role', '--min-score', '--since', '--home', '--dashboard-url', '--decision', '--stage', '--subject', '--body-file', '--link', '--pass-score', '--account', '--auto-invite-min-score', '--attention-min-score', '--enabled', '--booking-link', '--grader-model'];
9
+ const values = parseNamedArgs(args, { allowedFlags: [...valueFlags, '--json', '--now'], valueFlags });
10
10
  const positionals = [...values.positionals];
11
11
  // `takehome show` and `settings set` are two words a person types and one
12
12
  // action everything downstream reads.
@@ -52,11 +52,34 @@ export function parseCareersArgs(args) {
52
52
  subject: optionalNonEmpty(values.flags.get('--subject')), bodyFile: optionalNonEmpty(values.flags.get('--body-file')),
53
53
  link: optionalNonEmpty(values.flags.get('--link')), passScore,
54
54
  inviteAccountId: optionalNonEmpty(values.flags.get('--account')),
55
+ bookingLink: httpsFlag(values.flags.get('--booking-link')),
56
+ graderModel: optionalNonEmpty(values.flags.get('--grader-model')),
57
+ now: values.booleans.has('--now'),
55
58
  autoInviteMinScore, attentionMinScore,
56
59
  autoInviteEnabled: enabledRaw === undefined ? undefined : enabledRaw === 'true',
57
60
  homeDir: optionalNonEmpty(values.flags.get('--home')), dashboardUrl: optionalUrl(values.flags.get('--dashboard-url')), json: values.booleans.has('--json'),
58
61
  };
59
62
  }
63
+ /**
64
+ * BLI-4461: a booking link goes into an email a stranger clicks, so the
65
+ * terminal refuses anything but https here as well as on the server. Two
66
+ * checks, one rule, and the local one costs no round trip.
67
+ */
68
+ function httpsFlag(raw) {
69
+ const value = optionalUrl(raw);
70
+ if (value === undefined)
71
+ return undefined;
72
+ let parsed;
73
+ try {
74
+ parsed = new URL(value);
75
+ }
76
+ catch {
77
+ throw new Error('--booking-link must be an https URL.');
78
+ }
79
+ if (parsed.protocol !== 'https:')
80
+ throw new Error('--booking-link must be an https URL.');
81
+ return value;
82
+ }
60
83
  function numberFlag(raw, flag, min, max) {
61
84
  if (raw === undefined)
62
85
  return undefined;
@@ -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,16 +25,17 @@ 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>|takehome show <role>|takehome set <role>|settings show|settings set] [flags]",
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]",
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
32
  "invite <id> — sends that role's take-home from the configured mailbox, whatever the screening scored. 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
+ "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.",
34
35
  "takehome show <role> — the subject, body, link and the reviewer's rubric for that role.",
35
36
  "takehome set <role> [--subject <s>] [--body-file <path>] [--link <url>] [--pass-score <0-10>] — the body comes from a FILE and must keep {first_name} and {link}.",
36
37
  "settings show — the bar, the attention threshold, whether automatic sending is on, and which mailbox it goes out from.",
37
- "settings set [--enabled true|false] [--auto-invite-min-score <0-100>] [--attention-min-score <0-100>] [--account <mailbox uuid>] each takes effect on the next screening, no deploy.",
38
+ "settings set [--enabled true|false] [--auto-invite-min-score <0-100>] [--attention-min-score <0-100>] [--account <mailbox uuid>] [--booking-link <https url>] [--grader-model <provider:model>]: each takes effect on the next run, no deploy.",
38
39
  ],
39
40
  ],
40
41
  [
@@ -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>|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>|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]",
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.122");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.124");
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.122",
3
+ "version": "0.2.124",
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.49",
31
+ "@bli-cockpit/mcp": "0.1.51",
32
32
  "@bli-cockpit/telemetry-core": "0.1.48"
33
33
  }
34
34
  }