@bli-cockpit/cli 0.2.159 → 0.2.161

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.
@@ -1,4 +1,4 @@
1
- import { SourceScanResultSchema, parseTicketIdFromText, } from "@bli-cockpit/telemetry-core";
1
+ import { SourceScanResultSchema, parseTicketIdFromBranch, parseTicketIdFromText, } from "@bli-cockpit/telemetry-core";
2
2
  import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
3
3
  export function makeSourceAdapterIdentity(captureSource, adapterName) {
4
4
  return {
@@ -34,4 +34,4 @@ export function makeSourceScan(context, captureSource, adapterName, status, reas
34
34
  diagnostic_labels: [reasonLabel],
35
35
  });
36
36
  }
37
- export { parseTicketIdFromText };
37
+ export { parseTicketIdFromBranch, parseTicketIdFromText };
@@ -1,7 +1,7 @@
1
1
  import { SourceScanResultSchema, } from "@bli-cockpit/telemetry-core";
2
2
  import { execFile } from "node:child_process";
3
3
  import { promisify } from "node:util";
4
- import { makeSourceAdapterIdentity, parseTicketIdFromText, } from "./common.js";
4
+ import { makeSourceAdapterIdentity, parseTicketIdFromBranch, } from "./common.js";
5
5
  const execFileAsync = promisify(execFile);
6
6
  export async function collectGitState(context, runner = defaultGitCommandRunner()) {
7
7
  const startedAt = context.now.toISOString();
@@ -22,7 +22,7 @@ export async function collectGitState(context, runner = defaultGitCommandRunner(
22
22
  ...fileStats.map((file) => file.path),
23
23
  ]),
24
24
  ].sort();
25
- const ticketIdFromBranch = parseTicketIdFromText(branch);
25
+ const ticketIdFromBranch = parseTicketIdFromBranch(branch);
26
26
  const ticketBindingCandidates = ticketIdFromBranch
27
27
  ? [
28
28
  {
@@ -5,7 +5,7 @@ export function generateDumbRiskFlags(input) {
5
5
  const flags = [];
6
6
  const git = input.git ?? null;
7
7
  const changedLines = (git?.added_lines ?? 0) + (git?.deleted_lines ?? 0);
8
- if (git && changedLines >= 800 && !input.hasPlanSignal) {
8
+ if (git && changedLines >= 800 && input.hasPlanSignal === false) {
9
9
  flags.push(makeRiskFlag(input, "no_plan_signal_before_large_edit", [
10
10
  "missing_plan_signal",
11
11
  "large_edit",
@@ -17,27 +17,13 @@ export function generateDumbRiskFlags(input) {
17
17
  git.deleted_lines >= 200) {
18
18
  flags.push(makeRiskFlag(input, "many_add_revert_loops", ["add_revert_loop"]));
19
19
  }
20
- if (input.reviewFindingPriorities?.some((priority) => ["p1", "p2", "P1", "P2"].includes(priority))) {
21
- flags.push(makeRiskFlag(input, "p1_or_p2_review_finding", [
22
- input.reviewFindingPriorities.some((priority) => priority.toLowerCase() === "p1")
23
- ? "p1_review_finding"
24
- : "p2_review_finding",
25
- ]));
26
- }
27
- if ((input.repeatedFailureCount ?? 0) >= 3) {
28
- flags.push(makeRiskFlag(input, "repeated_same_failing_command", [
29
- "same_failing_command",
30
- ]));
31
- }
32
- if ((input.permissionDeniedCount ?? 0) >= 2) {
33
- flags.push(makeRiskFlag(input, "permission_denied_spike", ["permission_denied"]));
34
- }
35
- if ((input.tokenCount ?? 0) >= 1_000_000 && !input.hasPullRequest) {
36
- flags.push(makeRiskFlag(input, "high_token_or_time_burn_no_pr", [
37
- "token_pressure",
38
- "no_pr_after_work",
39
- ]));
40
- }
20
+ // BLI-12189: p1_or_p2_review_finding, repeated_same_failing_command,
21
+ // permission_denied_spike and high_token_or_time_burn_no_pr were removed.
22
+ // Their inputs (review priorities, failure and denial counts, token count,
23
+ // PR presence) were never passed by the one caller (local-sources.ts), so the
24
+ // flags could not fire. The risk_type values stay in the telemetry schema
25
+ // for a producer that really measures them; wire the input first, then add
26
+ // the branch back with a test that drives it from a real fixture.
41
27
  const branchTicket = git?.ticket_id_from_branch;
42
28
  if (branchTicket &&
43
29
  input.binding.selected_ticket_id &&
@@ -56,9 +42,7 @@ function makeRiskFlag(input, riskType, evidenceLabels) {
56
42
  .digest("hex")
57
43
  .slice(0, 16)}`,
58
44
  risk_type: riskType,
59
- severity: riskType === "p1_or_p2_review_finding" || riskType === "branch_ticket_mismatch"
60
- ? "high"
61
- : "warning",
45
+ severity: riskType === "branch_ticket_mismatch" ? "high" : "warning",
62
46
  observed_at: input.context.now.toISOString(),
63
47
  evidence_labels: evidenceLabels,
64
48
  evidence_pointer_ids: [],
@@ -52,6 +52,11 @@ async function planCareersRequest(command) {
52
52
  // The grader itself runs on Railway, because it needs a clone and yt-dlp.
53
53
  // This door re-queues the row; `--now` is passed through and the answer
54
54
  // names where the run actually happens rather than implying it ran here.
55
+ // BLI-12183: `{}` sends the second-chance email now; `--record-sent-at`
56
+ // records one sent outside Tower and sends nothing.
57
+ case 'followup': return { path: `${application}/followup`, method: 'POST', body: command.recordSentAt ? { record_sent_at: command.recordSentAt } : {} };
58
+ // BLI-12198: `--at` books or reschedules an interview Edward booked himself; `--clear` undoes it.
59
+ case 'interview': return { path: `${application}/interview`, method: 'POST', body: command.clear ? { clear: true } : { at: command.interviewAt } };
55
60
  case 'grade': return { path: `${application}/grade`, method: 'POST', body: { now: command.now === true } };
56
61
  case 'decide': return { path: `${application}/decide`, method: 'POST', body: { decision: command.decision } };
57
62
  // `resume` never arrives here: `runCareers` hands it to `careers-resume.ts`
@@ -101,8 +106,14 @@ function settingsBody(command) {
101
106
  body.booking_link = command.bookingLink;
102
107
  if (command.graderModel !== undefined)
103
108
  body.grader_model = command.graderModel;
109
+ if (command.followupEnabled !== undefined)
110
+ body.followup_enabled = command.followupEnabled;
111
+ if (command.followupAfterDays !== undefined)
112
+ body.followup_after_days = command.followupAfterDays;
113
+ if (command.takehomeWindowHours !== undefined)
114
+ body.takehome_window_hours = command.takehomeWindowHours;
104
115
  if (Object.keys(body).length === 0) {
105
- 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.');
116
+ throw new Error('careers settings set needs at least one of --enabled, --auto-invite-min-score, --attention-min-score, --account, --booking-link, --grader-model, --followup-enabled, --followup-after-days or --takehome-window-hours.');
106
117
  }
107
118
  return body;
108
119
  }
@@ -1,3 +1,4 @@
1
+ import { parseYesNo } from "../yes-no.js";
1
2
  import { createCapturedExecRunner, createInteractiveExecRunner, } from "../process-runner.js";
2
3
  export function writeLine(stream, text) {
3
4
  stream.write(`${text}\n`);
@@ -96,6 +97,10 @@ export async function readPipedText(stream, options) {
96
97
  }
97
98
  return stripLeadingBom(text);
98
99
  }
100
+ // Lines that arrived in the same chunk as an earlier answer (a paste, type-ahead,
101
+ // or a slow event loop that let the stream coalesce its buffer). Per stream, so
102
+ // the next prompt reads them instead of waiting for input that already came.
103
+ const queuedLines = new WeakMap();
99
104
  /**
100
105
  * Reads one line from stdin after writing a prompt. Shared by the onboard email
101
106
  * prompt and the autostart prompt; callers gate on `isInteractiveStdin` first so
@@ -103,21 +108,51 @@ export async function readPipedText(stream, options) {
103
108
  */
104
109
  export async function readLine(io, prompt) {
105
110
  io.stdout.write(prompt);
111
+ const queue = queuedLines.get(io.stdin) ?? [];
112
+ queuedLines.set(io.stdin, queue);
113
+ const queued = queue.shift();
114
+ if (queued !== undefined)
115
+ return queued;
106
116
  io.stdin.setEncoding("utf8");
107
117
  return new Promise((resolve) => {
108
118
  const onData = (chunk) => {
109
119
  io.stdin.removeListener("data", onData);
110
120
  io.stdin.pause();
111
- resolve(chunk);
121
+ const lines = chunk.match(/[^\n]*\n|[^\n]+$/gu) ?? [chunk];
122
+ const [first = chunk, ...rest] = lines;
123
+ queue.push(...rest);
124
+ if (rest.length > 0) {
125
+ console.error(`[cli-io] stdin chunk held ${lines.length} lines {"queued_lines":${rest.length}}`);
126
+ }
127
+ resolve(first);
112
128
  };
113
129
  io.stdin.resume();
114
130
  io.stdin.on("data", onData);
115
131
  });
116
132
  }
117
- /** Enter means yes: only an explicit `n`/`no` declines. */
118
- export function yesByDefault(raw) {
119
- const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
120
- return answer !== "n" && answer !== "no";
133
+ /**
134
+ * Settles a typed answer to a yes/no prompt (BLI-12189). `unclear` asks once
135
+ * more when `reask` is set, then declines; a destructive prompt passes
136
+ * `emptyIsYes: false` and no `reask`, so anything unclear is a No.
137
+ */
138
+ export async function settleYesNo(io, firstAnswer, prompt, options) {
139
+ let answer = firstAnswer;
140
+ const attempts = options.reask ? 2 : 1;
141
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
142
+ const verdict = parseYesNo(answer, options);
143
+ if (verdict !== "unclear") {
144
+ console.error(`[cli-io] yes/no settled ${JSON.stringify({ verdict, attempt })}`);
145
+ return verdict === "yes";
146
+ }
147
+ console.error(`[cli-io] yes/no unclear ${JSON.stringify({ attempt, will_reask: attempt < attempts })}`);
148
+ if (attempt < attempts)
149
+ answer = await readLine(io, `Please answer y or n. ${prompt}`);
150
+ }
151
+ return false;
152
+ }
153
+ /** Reads one answer and settles it. Non-destructive: Enter is yes, unclear asks once more. */
154
+ export async function askYesNo(io, prompt, options = { emptyIsYes: true, reask: true }) {
155
+ return settleYesNo(io, await readLine(io, prompt), prompt, options);
121
156
  }
122
157
  /** Collects what a nested command writes instead of letting it reach the user. */
123
158
  export function bufferedWritable(chunks) {
@@ -11,14 +11,14 @@ import os from "node:os";
11
11
  import { scanWorkDirectories } from "./local-discovery.js";
12
12
  import path from "node:path";
13
13
  import { stat } from "node:fs/promises";
14
- import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
14
+ import { isInteractiveStdin, readLine, writeLine, askYesNo } from "./cli-io.js";
15
15
  import { CollectionRootRequiredError, resolveOnboardingRoots, } from "../onboarding-roots.js";
16
16
  import { getCollectorRuntimePaths, installLocalCollector, readLocalCollectorConfig, } from "../local-state.js";
17
17
  import { collectionRootPathAliases } from "../repo-identity.js";
18
18
  import { normalizeCollectionRoots } from "../root-normalization.js";
19
19
  export function onboardingRootPrompt(io) {
20
20
  return {
21
- confirm: async (message) => yesByDefault(await readLine(io, message)),
21
+ confirm: async (message) => askYesNo(io, message),
22
22
  input: (message) => readLine(io, message),
23
23
  message: (message) => writeLine(io.stdout, message),
24
24
  };
@@ -14,7 +14,7 @@ import { defaultExec, defaultInteractiveExec, writeExecOutput, writeLine } from
14
14
  import { assertCollectionRootPersisted } from "./collection-roots.js";
15
15
  import { addInstallEvent, reportInstallEventsBestEffort } from "./install-receipts.js";
16
16
  import { installLocalCollector, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
17
- import { COLLECTION_ROOT_REQUIRED, CollectionRootRequiredError, missingCollectionRootMessage, normalizeRootsDetailed, rootRejectionExplanation, } from "../onboarding-roots.js";
17
+ import { CollectionRootRequiredError, missingCollectionRootMessage, normalizeRootsDetailed, rootRejectionExplanation, } from "../onboarding-roots.js";
18
18
  export class SelfUpdateError extends Error {
19
19
  result;
20
20
  eacces;
@@ -192,16 +192,35 @@ function updateOnboardArgs(command) {
192
192
  function isNpmEaccesFailure(stderr) {
193
193
  return /EACCES|permission denied/i.test(stderr);
194
194
  }
195
- function updateOnboardFailureCode(result) {
196
- const output = `${result.stdout}\n${result.stderr}`;
197
- if (output.includes(COLLECTION_ROOT_REQUIRED))
198
- return COLLECTION_ROOT_REQUIRED;
199
- if (/pairing|approval|device_pairing/i.test(output))
200
- return "pairing_timeout";
201
- if (/sync_blocked|spooled|upload failed|network_or_ingest/i.test(output)) {
202
- return "sync_blocked";
195
+ /**
196
+ * The reason a failed onboard rerun carries (BLI-12189). Never scraped from
197
+ * prose: every rerun prints "pairing skipped", so matching words labelled any
198
+ * failed update `pairing_timeout`. A `--json` rerun hands back the typed
199
+ * `blocker` the onboard step itself computed; otherwise the exit code is the
200
+ * only honest fact, and it is the label.
201
+ */
202
+ export function updateOnboardFailureCode(result) {
203
+ const blocker = typedOnboardBlocker(result.stdout);
204
+ const label = blocker ?? `onboard_exit_${result.code}`;
205
+ console.error(`[install-update] onboard rerun failed ${JSON.stringify({
206
+ exit_code: result.code,
207
+ reason: label,
208
+ source: blocker ? "typed_blocker" : "exit_code",
209
+ })}`);
210
+ return label;
211
+ }
212
+ function typedOnboardBlocker(stdout) {
213
+ const text = stdout.trim();
214
+ if (!text.startsWith("{"))
215
+ return null;
216
+ try {
217
+ const parsed = JSON.parse(text);
218
+ const blocker = parsed?.blocker;
219
+ return typeof blocker === "string" && blocker.length > 0 ? blocker : null;
220
+ }
221
+ catch {
222
+ return null;
203
223
  }
204
- return "onboard_rerun_failed";
205
224
  }
206
225
  function updateCollectionRoots(command) {
207
226
  const roots = command.collectionRoots?.length
@@ -1,14 +1,14 @@
1
1
  import { optionalNonEmpty, optionalUrl, parseNamedArgs } from './local-arg-values.js';
2
- const ACTIONS = ['list', 'show', 'rescreen', 'invite', 'decide', 'grade', 'resume', 'takehome-show', 'takehome-set', 'settings-show', 'settings-set'];
2
+ const ACTIONS = ['list', 'show', 'rescreen', 'invite', 'decide', 'followup', 'interview', 'grade', 'resume', 'takehome-show', 'takehome-set', 'settings-show', 'settings-set'];
3
3
  /** Typed word → the one action it means. The left side is what a person reads on the button. */
4
4
  const ALSO_SPELLED = { 'send-takehome': 'invite' };
5
5
  const DECISIONS = ['passed_on', 'archived', 'reopen'];
6
6
  /** Verbs that name an application id, a role slug, or nothing at all. */
7
- const NEEDS_ID = ['show', 'rescreen', 'invite', 'decide', 'grade', 'resume'];
7
+ const NEEDS_ID = ['show', 'rescreen', 'invite', 'decide', 'followup', 'interview', 'grade', 'resume'];
8
8
  const NEEDS_ROLE = ['takehome-show', 'takehome-set'];
9
9
  export function parseCareersArgs(args) {
10
- 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', '--out'];
11
- const values = parseNamedArgs(args, { allowedFlags: [...valueFlags, '--json', '--now', '--force'], valueFlags });
10
+ 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', '--out', '--record-sent-at', '--at', '--followup-enabled', '--followup-after-days', '--takehome-window-hours'];
11
+ const values = parseNamedArgs(args, { allowedFlags: [...valueFlags, '--json', '--now', '--force', '--clear'], valueFlags });
12
12
  const positionals = [...values.positionals];
13
13
  // `takehome show` and `settings set` are two words a person types and one
14
14
  // action everything downstream reads.
@@ -48,6 +48,34 @@ export function parseCareersArgs(args) {
48
48
  const enabledRaw = optionalNonEmpty(values.flags.get('--enabled'));
49
49
  if (enabledRaw !== undefined && enabledRaw !== 'true' && enabledRaw !== 'false')
50
50
  throw new Error('--enabled must be true or false.');
51
+ // BLI-12183: the second-chance follow-up. `--record-sent-at` records one sent
52
+ // outside Tower and sends nothing; the three settings flags are whole numbers
53
+ // and a boolean, refused here as well as on the door.
54
+ const recordSentAt = optionalNonEmpty(values.flags.get('--record-sent-at'));
55
+ if (recordSentAt !== undefined && verb !== 'followup')
56
+ throw new Error('--record-sent-at belongs to `cockpit careers followup <id>`.');
57
+ if (recordSentAt !== undefined && !Number.isFinite(Date.parse(recordSentAt)))
58
+ throw new Error('--record-sent-at must be an ISO date-time, e.g. 2026-10-03T08:00:00Z.');
59
+ // BLI-12198: an interview Edward booked himself. Exactly one of `--at <iso>`
60
+ // (book or reschedule) or `--clear`, refused here as well as on the door.
61
+ const interviewAt = optionalNonEmpty(values.flags.get('--at'));
62
+ const clear = values.booleans.has('--clear');
63
+ if (interviewAt !== undefined && verb !== 'interview')
64
+ throw new Error('--at belongs to `cockpit careers interview <id>`.');
65
+ if (clear && verb !== 'interview')
66
+ throw new Error('--clear belongs to `cockpit careers interview <id>`.');
67
+ if (verb === 'interview') {
68
+ if ((interviewAt === undefined) === !clear)
69
+ throw new Error('careers interview needs exactly one of --at <iso with offset> or --clear.');
70
+ if (interviewAt !== undefined && (!/(Z|[+-]\d{2}:\d{2})$/i.test(interviewAt) || !Number.isFinite(Date.parse(interviewAt)))) {
71
+ throw new Error('--at must be an ISO date-time with an offset, e.g. 2026-10-05T10:00:00-07:00.');
72
+ }
73
+ }
74
+ const followupEnabledRaw = optionalNonEmpty(values.flags.get('--followup-enabled'));
75
+ if (followupEnabledRaw !== undefined && followupEnabledRaw !== 'true' && followupEnabledRaw !== 'false')
76
+ throw new Error('--followup-enabled must be true or false.');
77
+ const followupAfterDays = wholeFlag(values.flags.get('--followup-after-days'), '--followup-after-days', 1, 60);
78
+ const takehomeWindowHours = wholeFlag(values.flags.get('--takehome-window-hours'), '--takehome-window-hours', 1, 720);
51
79
  // The resume is the only thing this noun writes to disk, so both file flags
52
80
  // belong to it alone and say so rather than being accepted and ignored.
53
81
  const outPath = optionalNonEmpty(values.flags.get('--out'));
@@ -71,6 +99,10 @@ export function parseCareersArgs(args) {
71
99
  graderModel: optionalNonEmpty(values.flags.get('--grader-model')),
72
100
  now: values.booleans.has('--now'),
73
101
  outPath, force,
102
+ interviewAt: interviewAt ? new Date(interviewAt).toISOString() : undefined, clear: clear || undefined,
103
+ recordSentAt: recordSentAt ? new Date(recordSentAt).toISOString() : undefined,
104
+ followupEnabled: followupEnabledRaw === undefined ? undefined : followupEnabledRaw === 'true',
105
+ followupAfterDays, takehomeWindowHours,
74
106
  autoInviteMinScore, attentionMinScore,
75
107
  autoInviteEnabled: enabledRaw === undefined ? undefined : enabledRaw === 'true',
76
108
  homeDir: optionalNonEmpty(values.flags.get('--home')), dashboardUrl: optionalUrl(values.flags.get('--dashboard-url')), json: values.booleans.has('--json'),
@@ -96,6 +128,14 @@ function httpsFlag(raw) {
96
128
  throw new Error('--booking-link must be an https URL.');
97
129
  return value;
98
130
  }
131
+ function wholeFlag(raw, flag, min, max) {
132
+ if (raw === undefined)
133
+ return undefined;
134
+ const value = Number(raw);
135
+ if (!Number.isInteger(value) || value < min || value > max)
136
+ throw new Error(`${flag} must be a whole number, ${min} to ${max}.`);
137
+ return value;
138
+ }
99
139
  function numberFlag(raw, flag, min, max) {
100
140
  if (raw === undefined)
101
141
  return undefined;
@@ -8,7 +8,7 @@
8
8
  * onboard. Split out of commands/local.ts (BLI-3104); moved verbatim.
9
9
  */
10
10
  import { execFile } from "node:child_process";
11
- import { errorMessage, isInteractiveStdin, readLine, writeLine, yesByDefault, } from "./cli-io.js";
11
+ import { errorMessage, isInteractiveStdin, readLine, writeLine, settleYesNo, } from "./cli-io.js";
12
12
  import { normalizeUrl } from "./local-args.js";
13
13
  import { getCollectorRuntimePaths, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, } from "../local-state.js";
14
14
  const OTP_CODE_PROMPT = [
@@ -63,7 +63,8 @@ export async function resolveOnboardEmail(command, roots, config, io) {
63
63
  const typedEmail = answer.split(/\s+/u).find((part) => part.includes("@"));
64
64
  if (typedEmail)
65
65
  return typedEmail;
66
- if (yesByDefault(answer)) {
66
+ const pairPrompt = `Use ${inferred.email} for Tower pairing? [Y/n] `;
67
+ if (await settleYesNo(io, answer, pairPrompt, { emptyIsYes: true, reask: true })) {
67
68
  return inferred.email;
68
69
  }
69
70
  return promptOnboardEmail(io);
@@ -25,12 +25,15 @@ export const TOWER_COMMAND_HELP = [
25
25
  [
26
26
  "careers",
27
27
  [
28
- "Usage: cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <d>|grade <id>|resume <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>|followup <id>|interview <id>|grade <id>|resume <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
- "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.",
31
+ "list [--role <slug>] [--min-score <n>] [--since <date>] [--stage <screened|invited|submitted|graded|booking_sent|interview|passed_on|archived>] — the board, newest first, with the two thresholds in force.",
32
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
+ "followup <id> [--record-sent-at <iso>]: sends the one second-chance email now, in the take-home thread (the Send follow-up button), or with --record-sent-at records one you sent by hand and sends nothing (Already followed up). A second follow-up is refused. list and show carry each row's deadline label: overdue, second_chance, no_submission or incomplete.",
35
+ "interview <id> --at <iso with offset> [--clear]: records an interview you booked yourself (the Interview booked control). The row moves to the interview stage and its chip reads Interview plus the time; booking again reschedules; --clear (instead of --at) returns the row to where it stood. An archived row is refused. list --stage interview lists them.",
36
+ "settings set also takes --followup-enabled <true|false> (default false), --followup-after-days <1-60> (default 7) and --takehome-window-hours <1-720> (default 72).",
34
37
  "grade <id> [--now]: puts one submission back in the take-home grader's queue. The grader runs on Railway every 5 minutes; --now says so rather than pretending the run happened here.",
35
38
  "resume <id> [--out <path>] [--force]: the candidate's resume, the same file the drawer's Open resume button shows. Bare, it prints a signed link that dies in 60 seconds; --out downloads the file to that path and refuses to overwrite one already there unless you pass --force.",
36
39
  "takehome show <role> — the subject, body, link and the reviewer's rubric for that role.",
@@ -127,7 +127,7 @@ export function localCommandHelp(command) {
127
127
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
128
128
  ` cockpit search "<words>" [--kind ${SEARCH_KINDS.join(",")}] [--limit <n>] [--dashboard-url <url>] [--json]`,
129
129
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
130
- " cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <passed_on|archived|reopen>|grade <id> [--now]|resume <id> [--out <path>]|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>] [--force] [--json]",
130
+ " cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <passed_on|archived|reopen>|followup <id> [--record-sent-at <iso>]|interview <id> <--at <iso>|--clear>|grade <id> [--now]|resume <id> [--out <path>]|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false> --followup-enabled <true|false> --followup-after-days <n> --takehome-window-hours <n>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--force] [--json]",
131
131
  " cockpit usage sessions [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--since <window>] [--json]",
132
132
  " cockpit usage sessions --session <id> [--from-window <n>] [--json]",
133
133
  " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
@@ -3,7 +3,7 @@
3
3
  * sharing it and taking it back, and moving it to another shelf. Sibling of
4
4
  * `notes.ts`, named in its header.
5
5
  */
6
- import { isInteractiveStdin, readLine, readPipedText, writeLine, yesByDefault } from "./cli-io.js";
6
+ import { isInteractiveStdin, readPipedText, writeLine, askYesNo } from "./cli-io.js";
7
7
  import { NOTE_SLOW_UPLOAD_BYTES, noteAudioMime, decodeTextBytes, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
8
8
  import { ask, emit, fail, sayUpload, errorText, TAG, READ_DEADLINE_MS } from "./notes-door.js";
9
9
  /**
@@ -165,8 +165,8 @@ export async function shareNote(command, door) {
165
165
  if (!isInteractiveStdin(door.io)) {
166
166
  return fail(door, "confirmation_required", "Sharing a note lets everyone signed in read it. Pass --yes to do it without being asked.");
167
167
  }
168
- const answer = await readLine(door.io, "Share this note with everyone signed in? [Y/n] ");
169
- if (!yesByDefault(answer)) {
168
+ const shareYes = await askYesNo(door.io, "Share this note with everyone signed in? [Y/n] ");
169
+ if (!shareYes) {
170
170
  writeLine(door.io.stderr, `${TAG} share declined ${JSON.stringify({ reason: "operator_said_no" })}`);
171
171
  writeLine(door.io.stdout, "Left alone. Nothing changed.");
172
172
  return 0;
@@ -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.159");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.161");
25
25
  return 0;
26
26
  }
27
27
 
@@ -19,7 +19,7 @@
19
19
  * that takes one. New content arrives on stdin and is forwarded without ever
20
20
  * being written to stdout, stderr, or an argument vector.
21
21
  */
22
- import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
22
+ import { isInteractiveStdin, readLine, settleYesNo, writeLine } from "./cli-io.js";
23
23
  import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
24
24
  import { asList, asRecord, callTower, isForbidden, openTower, parseModelKey, readAllStdin, writeCommandFailure, } from "./tower-command.js";
25
25
  import { showOverview } from "./settings-overview.js";
@@ -374,8 +374,9 @@ export async function confirmDestructive(command, io, prompt, label) {
374
374
  };
375
375
  }
376
376
  const answer = await readLine(io, prompt);
377
- if (yesByDefault(answer))
377
+ if (await settleYesNo(io, answer, prompt, { emptyIsYes: false, reask: false })) {
378
378
  return { ok: true };
379
+ }
379
380
  return {
380
381
  ok: false,
381
382
  reason: "declined",
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs/promises";
2
+ import { parseYesNo } from "./yes-no.js";
2
3
  import { realpathSync } from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
@@ -235,12 +236,11 @@ async function promptForHomeRootOptIn(options, rejections) {
235
236
  // Anything that isn't recognisably yes still routes to the decline prompt,
236
237
  // where a typed path is recoverable rather than lost.
237
238
  function isHomeRootYes(raw) {
238
- const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
239
- if (answer === "")
240
- return true;
241
- if (answer.startsWith("n"))
242
- return false;
243
- return answer.startsWith("y");
239
+ // BLI-12189: "you know what, no" used to pass on its leading "y". Unclear
240
+ // routes to the decline prompt, where a typed path is recoverable.
241
+ const verdict = parseYesNo(raw, { emptyIsYes: true });
242
+ console.error(`[onboarding-roots] home root consent ${JSON.stringify({ verdict })}`);
243
+ return verdict === "yes";
244
244
  }
245
245
  async function promptForDeclinedHomeRoot(options, homeDir) {
246
246
  const prompt = requirePrompt(options);
package/dist/yes-no.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The ONE reading of a typed yes/no answer (BLI-12189).
3
+ *
4
+ * The old `yesByDefault` returned true unless the first word was exactly
5
+ * `n`/`no`, so "nope", "nah", "cancel", "don't" and "No thanks" confirmed a
6
+ * device revoke. Now only a recognised yes confirms; a no word or a leading
7
+ * "n" declines; everything else is `unclear` and the caller decides (a
8
+ * destructive prompt declines, a harmless one asks once more).
9
+ *
10
+ * Pure: no I/O, no logging. Callers log the outcome label.
11
+ */
12
+ const YES_WORDS = new Set(["y", "yes", "yeah", "yep", "sure", "ok", "okay"]);
13
+ const NO_WORDS = new Set(["cancel", "stop", "don't", "dont", "never", "abort", "quit"]);
14
+ /**
15
+ * `emptyIsYes`: bare Enter means yes where the prompt shows `[Y/n]`; on a
16
+ * `[y/N]` (destructive) prompt it means no.
17
+ */
18
+ export function parseYesNo(raw, options) {
19
+ const first = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
20
+ if (first === "")
21
+ return options.emptyIsYes ? "yes" : "no";
22
+ const word = first.replace(/[^\p{L}\p{N}']+$/gu, "");
23
+ if (YES_WORDS.has(word))
24
+ return "yes";
25
+ if (NO_WORDS.has(word) || word.startsWith("n"))
26
+ return "no";
27
+ return "unclear";
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.159",
3
+ "version": "0.2.161",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,8 +27,13 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.43",
31
- "@bli-cockpit/mcp": "0.1.77",
32
- "@bli-cockpit/telemetry-core": "0.1.55"
30
+ "@bli-cockpit/memory-mcp": "0.1.45",
31
+ "@bli-cockpit/mcp": "0.1.79",
32
+ "@bli-cockpit/telemetry-core": "0.1.57"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/BLI-INC/bli-cockpit.git",
37
+ "directory": "packages/cockpit-cli"
33
38
  }
34
39
  }