@bli-cockpit/cli 0.2.99 → 0.2.100

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,9 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { inspectBackfillLock } from "../backfill-lock.js";
4
- import { backfillCompletionCovers, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
4
+ import { readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
5
5
  import { savedDiscoveryLimitArgs } from "../discovery-limits.js";
6
- import { describeError } from "../health-detail.js";
7
6
  import { runStagingPrune } from "../disk-prune.js";
8
7
  import { retentionOptionsFromEnv } from "../disk-retention.js";
9
8
  import { readDiskFootprint } from "../disk-usage.js";
@@ -13,7 +12,8 @@ import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.
13
12
  import { runBackfillCommand } from "./backfill.js";
14
13
  import { diskRowMessage, mib, redeliveryLine } from "./doctor-disk-words.js";
15
14
  import { doctorRoots } from "./doctor-access.js";
16
- import { asRecord, fail, needsFix, ok, skipped } from "./doctor-report.js";
15
+ import { backfillCompletionStepState, backfillFixVerdict, jsonField, parseDoctorBackfillJson, parseDoctorSyncJson, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline-verdicts.js";
16
+ import { fail, needsFix, ok, skipped } from "./doctor-report.js";
17
17
  /**
18
18
  * The `backfill-complete`, `gc-checked`, `disk-bounded`, and `sync-fresh`
19
19
  * check family: does the collection pipeline itself have everything it
@@ -22,31 +22,15 @@ import { asRecord, fail, needsFix, ok, skipped } from "./doctor-report.js";
22
22
  * is committed, disk-bounded reads per-file against the upload ledger
23
23
  * (BLI-3619), and sync-fresh is the one check whose "fix" is a live proof
24
24
  * (`cockpit sync`) rather than a local computation.
25
- */
26
- const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
27
- /**
28
- * Pure so it can be unit-tested without touching the real machine's home
29
- * directory (`getCollectorRuntimePaths()` defaults to `os.homedir()` and
30
- * doctor never threads `--home` through the backfill steps). Returns `null`
31
- * when the marker does not cover the roots/sources — the caller falls
32
- * through to the lock/never-run diagnosis in that case.
33
25
  *
34
- * BLI-2727: a marker whose only outstanding entries are deterministic
35
- * oversized-file skips is still a completed backfill it reads green with a
36
- * named note, never a red `needs_fix`/`fail`, so an unliftable file cap never
37
- * reads as "backfill never completed" on repeat doctor runs.
26
+ * This module is the half that TOUCHES the machine: it runs the catch-up,
27
+ * prunes the disk, execs a sync. The verdicts those steps reach what a
28
+ * completion marker proves, what a stand-aside receipt means, when an
29
+ * unfinished backfill is busy rather than broken — are pure functions in
30
+ * `doctor-pipeline-verdicts.ts`, and every one of them is re-exported from
31
+ * here so callers keep one address.
38
32
  */
39
- export function backfillCompletionStepState(marker, roots) {
40
- if (!backfillCompletionCovers(marker, roots, ["codex", "claude_code"])) {
41
- return null;
42
- }
43
- const oversized = marker?.oversized_skips;
44
- if (oversized && oversized.count > 0) {
45
- return ok("backfill-complete", "complete_with_oversized_skips", `caught up on old Codex and Claude sessions in every saved folder ` +
46
- `(complete_with_oversized_skips · ${oversized.count} file${oversized.count === 1 ? "" : "s"} too big to upload)`);
47
- }
48
- return ok("backfill-complete", "complete", "caught up on old Codex and Claude sessions in every saved folder");
49
- }
33
+ const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
50
34
  export async function checkBackfillState(context) {
51
35
  const paths = getCollectorRuntimePaths();
52
36
  const roots = await doctorRoots(context);
@@ -70,7 +54,8 @@ export async function fixBackfillState(context) {
70
54
  yes: true,
71
55
  json: true,
72
56
  }, capture.io);
73
- const output = capture.stdout() + "\n" + capture.stderr();
57
+ const stdout = capture.stdout();
58
+ const output = `${stdout}\n${capture.stderr()}`;
74
59
  if (code === 0) {
75
60
  // Re-read the marker this run just wrote instead of hand-rolling a second
76
61
  // message: `checkBackfillState`'s pure core already knows how to say
@@ -81,11 +66,9 @@ export async function fixBackfillState(context) {
81
66
  return recheck;
82
67
  return ok("backfill-complete", "completed", "ran `cockpit backfill --all --yes`");
83
68
  }
84
- const reason = jsonField(output, "failure_reason");
85
- if (reason === "backfill_already_running") {
86
- return fail("backfill-complete", "backfill_already_running", "backfill lock is still held and no scope-valid completion marker exists");
87
- }
88
- return fail("backfill-complete", reason ?? "backfill_failed", "backfill did not complete");
69
+ const verdict = backfillFixVerdict(parseDoctorBackfillJson(stdout), jsonField(output, "failure_reason"));
70
+ console.error("[cockpit-doctor] catch-up run did not finish", JSON.stringify({ reason: verdict.code, row_status: verdict.status, exit_code: code }));
71
+ return verdict;
89
72
  }
90
73
  export async function checkGcState(context) {
91
74
  if (context.io.env["COCKPIT_DISABLE_GC"] === "1") {
@@ -196,11 +179,6 @@ function captureStream(target, chunks, forward) {
196
179
  },
197
180
  };
198
181
  }
199
- function jsonField(output, field) {
200
- const escaped = field.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
201
- const match = output.match(new RegExp(`"${escaped}"\\s*:\\s*"([^"]+)"`, "u"));
202
- return match?.[1] ?? null;
203
- }
204
182
  export async function checkSyncState(context) {
205
183
  const roots = await doctorRoots(context);
206
184
  if (roots.length === 0) {
@@ -211,75 +189,6 @@ export async function checkSyncState(context) {
211
189
  // and only turns green from those command receipts.
212
190
  return needsFix("sync-fresh", "per_root_verification_required", `fresh upload proof is required for ${roots.length} saved root${roots.length === 1 ? "" : "s"}`);
213
191
  }
214
- /**
215
- * `cockpit sync --json` prints exactly one JSON document to stdout (stderr is
216
- * for human text; see AGENTS.md logging conventions), so this is a real parse
217
- * rather than the doctor module's usual regex field-scrape — which cannot
218
- * disambiguate same-named fields nested under `codex_sessions.codex` vs
219
- * `codex_sessions.claude` (BLI-2728).
220
- */
221
- function parseDoctorSyncJson(stdout) {
222
- try {
223
- const parsed = JSON.parse(stdout.trim());
224
- return parsed && typeof parsed === "object" ? parsed : null;
225
- }
226
- catch (error) {
227
- // `null` sends doctor back to its regex field-scrape, quietly losing the
228
- // BLI-2728 disambiguation. Something wrote to stdout that was not the one
229
- // JSON document the contract promises — a stray console.log in the
230
- // collector would do exactly this and look like nothing at all.
231
- console.error("[cockpit-doctor] sync --json stdout was not one JSON document", JSON.stringify({
232
- reason: "sync_json_unparseable",
233
- byte_size: stdout.length,
234
- ...describeError(error),
235
- }));
236
- return null;
237
- }
238
- }
239
- /**
240
- * BLI-2728: a tick that only deferred objects past the per-tick raw-evidence
241
- * object budget (`RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET`, adapters/raw-evidence.ts)
242
- * is a backlog that is draining, not a failure — `attributedSyncRunStatus`
243
- * marks the run not-fully-`ok` (so `cockpit sync` exits non-zero and doctor's
244
- * exec sees `code !== 0`) purely because objects remain queued, with the
245
- * per-repo upload itself still having succeeded. A genuine failure (auth,
246
- * server rejection, network, a real upload_failed outcome, an unposted
247
- * session report) must still read red — this only fires when NOTHING else in
248
- * the tick's own summary looks wrong. Pure so it is unit-testable without a
249
- * live exec/fs harness; the remaining-object count is read straight from the
250
- * tick's own summary, never recomputed.
251
- */
252
- export function syncBacklogDrainingVerdict(parsed) {
253
- if (!parsed)
254
- return null;
255
- const deferredObjects = positiveNumberOrZero(parsed.raw_evidence_deferred_object_budget);
256
- if (deferredObjects <= 0)
257
- return null;
258
- const deferredBytes = positiveNumberOrZero(parsed.raw_evidence_deferred_byte_budget);
259
- const failedCount = positiveNumberOrZero(parsed.raw_evidence_failed_count);
260
- const retryReasons = Array.isArray(parsed.raw_evidence_retry_reasons)
261
- ? parsed.raw_evidence_retry_reasons.length
262
- : 0;
263
- const sessions = asRecord(parsed.codex_sessions);
264
- const reportPosted = sessions?.["report_posted"];
265
- const codexReadFailures = positiveNumberOrZero(asRecord(sessions?.["codex"])?.["read_failures"]);
266
- const claudeSessions = asRecord(sessions?.["claude"]);
267
- const claudeReadFailures = positiveNumberOrZero(claudeSessions?.["read_failures"]);
268
- const claudeSidecarsFailed = positiveNumberOrZero(claudeSessions?.["sidecars_failed"]);
269
- const onlyDeferredObjectBudget = deferredBytes === 0 &&
270
- failedCount === 0 &&
271
- retryReasons === 0 &&
272
- reportPosted === true &&
273
- codexReadFailures === 0 &&
274
- claudeReadFailures === 0 &&
275
- claudeSidecarsFailed === 0;
276
- return onlyDeferredObjectBudget ? { remainingObjects: deferredObjects } : null;
277
- }
278
- function positiveNumberOrZero(value) {
279
- return typeof value === "number" && Number.isFinite(value) && value > 0
280
- ? value
281
- : 0;
282
- }
283
192
  export async function fixSyncState(context) {
284
193
  const exec = context.io.exec;
285
194
  if (!exec)
@@ -301,6 +210,23 @@ export async function fixSyncState(context) {
301
210
  const parsed = parseDoctorSyncJson(result.stdout);
302
211
  const output = `${result.stdout}\n${result.stderr}`;
303
212
  const status = jsonField(output, "status");
213
+ // BLI-4303, before any verdict: a sync that stood aside for the collection
214
+ // lock never ran, so neither its exit code nor its missing `uploaded`
215
+ // receipt says anything about this machine's health.
216
+ const standAside = syncStandAsideVerdict(parsed, status);
217
+ if (standAside) {
218
+ console.error("[cockpit-doctor] sync stood aside for a running collection run", JSON.stringify({
219
+ reason: standAside.code,
220
+ owner_alive: standAside.ownerAlive,
221
+ held_since: standAside.heldSince,
222
+ }));
223
+ return standAside.ownerAlive
224
+ ? ok("sync-fresh", standAside.code, "a background sync is running right now and owns the collection lock " +
225
+ `(last heartbeat ${standAside.heldSince}); this machine is collecting`)
226
+ : needsFix("sync-fresh", standAside.code, "sync stood aside for a collection lock whose owner has stopped " +
227
+ `reporting (last heartbeat ${standAside.heldSince ?? "unknown"}); ` +
228
+ "rerun `cockpit doctor` — the next sync takes the lock over");
229
+ }
304
230
  if (result.code !== 0) {
305
231
  const draining = syncBacklogDrainingVerdict(parsed);
306
232
  if (draining) {
@@ -317,4 +243,8 @@ export async function fixSyncState(context) {
317
243
  return ok("sync-fresh", "synced", roots.length === 1
318
244
  ? "ran `cockpit sync` and received an uploaded receipt"
319
245
  : `received uploaded receipts for ${roots.length} saved roots`);
320
- }
246
+ }
247
+ // Re-exported so `doctor.ts`, the doctor tests and every other caller keep
248
+ // importing these from `./doctor-pipeline.js` regardless of which sibling
249
+ // computes them.
250
+ export { backfillCompletionStepState, backfillFixVerdict, isCollectionBusyReason, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline-verdicts.js";
@@ -1,6 +1,6 @@
1
1
  import { withStage } from "../crash-guard.js";
2
2
  import { checkSingleInstallState, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
3
- import { backfillCompletionStepState, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, } from "./doctor-pipeline.js";
3
+ import { backfillCompletionStepState, backfillFixVerdict, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline.js";
4
4
  import { checkMcpAnswersState } from "./doctor-mcp.js";
5
5
  import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
6
6
  import { checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
@@ -178,4 +178,4 @@ function defaultDoctorDeps(hooks) {
178
178
  }
179
179
  // Re-exported so every consumer keeps importing from `./doctor.js` regardless
180
180
  // of which sibling a symbol now lives in.
181
- export { backfillCompletionStepState, syncBacklogDrainingVerdict };
181
+ export { backfillCompletionStepState, backfillFixVerdict, syncBacklogDrainingVerdict, syncStandAsideVerdict, };
@@ -0,0 +1,20 @@
1
+ import { optionalNonEmpty, optionalUrl, parseNamedArgs } from './local-arg-values.js';
2
+ export function parseCareersArgs(args) {
3
+ const values = parseNamedArgs(args, { allowedFlags: ['--role', '--min-score', '--since', '--home', '--dashboard-url', '--json'], valueFlags: ['--role', '--min-score', '--since', '--home', '--dashboard-url'] });
4
+ const action = values.positionals[0] ?? 'list';
5
+ if (action !== 'list' && action !== 'show' && action !== 'rescreen')
6
+ throw new Error('careers takes list, show, or rescreen.');
7
+ const id = values.positionals[1];
8
+ if (action !== 'list' && !id)
9
+ throw new Error(`careers ${action} needs an application id.`);
10
+ if (values.positionals.length > (action === 'list' ? 1 : 2))
11
+ throw new Error('Unexpected careers argument.');
12
+ const rawScore = values.flags.get('--min-score');
13
+ const minScore = rawScore === undefined ? undefined : Number(rawScore);
14
+ if (minScore !== undefined && (!Number.isFinite(minScore) || minScore < 0 || minScore > 100))
15
+ throw new Error('--min-score must be 0 to 100.');
16
+ const since = optionalNonEmpty(values.flags.get('--since'));
17
+ if (since && !Number.isFinite(Date.parse(since)))
18
+ throw new Error('--since must be a date.');
19
+ return { kind: 'careers', action, id, role: optionalNonEmpty(values.flags.get('--role')), minScore, since: since ? new Date(since).toISOString() : undefined, homeDir: optionalNonEmpty(values.flags.get('--home')), dashboardUrl: optionalUrl(values.flags.get('--dashboard-url')), json: values.booleans.has('--json') };
20
+ }
@@ -153,6 +153,7 @@ export function parseBriefArgs(args) {
153
153
  };
154
154
  }
155
155
  const NOTES_ACTIONS = new Set([
156
+ "folders", "mkdir", "rmdir", "rename",
156
157
  "list",
157
158
  "show",
158
159
  "shelf",
@@ -175,12 +176,14 @@ const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
175
176
  export function parseNotesArgs(args) {
176
177
  const values = parseNamedArgs(args, {
177
178
  allowedFlags: [
179
+ "--wait",
178
180
  "--home",
179
181
  "--dashboard-url",
180
182
  "--file",
181
183
  "--name",
182
184
  "--exclude",
183
185
  "--to",
186
+ "--folder",
184
187
  "--clear-shelf",
185
188
  "--apply",
186
189
  "--series",
@@ -190,6 +193,7 @@ export function parseNotesArgs(args) {
190
193
  "--limit",
191
194
  "--yes",
192
195
  "--json",
196
+ "--tree",
193
197
  ],
194
198
  valueFlags: [
195
199
  "--home",
@@ -198,6 +202,7 @@ export function parseNotesArgs(args) {
198
202
  "--name",
199
203
  "--exclude",
200
204
  "--to",
205
+ "--folder",
201
206
  "--series",
202
207
  "--kind",
203
208
  "--since",
@@ -236,23 +241,33 @@ export function parseNotesArgs(args) {
236
241
  if (paths.length === 0)
237
242
  throw new Error("notes upload needs at least one file path.");
238
243
  }
244
+ else if (action === "mkdir" || action === "rmdir" || action === "rename") {
245
+ if (rest.length !== 1 || !rest[0]?.trim())
246
+ throw new Error(`notes ${action} needs one folder path.`);
247
+ }
239
248
  else if (action !== "paste" && !NOTES_ACTIONS_NEEDING_A_NOTE.has(action) && rest.length > 0) {
240
249
  throw new Error(`notes ${action} does not take "${rest[0]}".`);
241
250
  }
251
+ if (action === "rename" && !optionalNonEmpty(values.flags.get("--name")))
252
+ throw new Error("notes rename needs --name <name>.");
242
253
  const to = optionalNonEmpty(values.flags.get("--to"));
243
254
  const clearShelf = values.booleans.has("--clear-shelf");
244
255
  if (action === "move") {
245
256
  if (to && clearShelf) {
246
257
  throw new Error("notes move accepts either --to or --clear-shelf, not both.");
247
258
  }
248
- if (!to && !clearShelf) {
249
- throw new Error('notes move needs --to "<shelf>" or --clear-shelf.');
259
+ if (!to && !clearShelf && !values.flags.get("--folder")) {
260
+ throw new Error('notes move needs --to "<shelf>", --clear-shelf, or --folder "<path>".');
250
261
  }
251
262
  }
252
263
  const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
253
264
  return {
254
265
  kind: "notes",
266
+ ...(values.booleans.has("--wait") ? { wait: true } : {}),
255
267
  action,
268
+ ...(values.flags.has("--folder") ? { folder: values.flags.get("--folder") } : {}),
269
+ ...(action === "mkdir" || action === "rmdir" || action === "rename" ? { folder: rest[0] } : {}),
270
+ ...(values.booleans.has("--tree") ? { tree: true } : {}),
256
271
  homeDir: optionalNonEmpty(values.flags.get("--home")),
257
272
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
258
273
  ...(noteId ? { noteId } : {}),
@@ -36,4 +36,5 @@ export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
36
36
  export { parseModelsArgs } from "./local-args-tower-models.js";
37
37
  export { parseMailArgs } from "./local-args-tower-mail.js";
38
38
  export { parseCalArgs } from "./local-args-tower-cal.js";
39
- export { parseUsageArgs } from "./local-args-tower-usage.js";
39
+ export { parseUsageArgs } from "./local-args-tower-usage.js";
40
+ export { parseCareersArgs } from './local-args-tower-careers.js';
@@ -16,7 +16,7 @@
16
16
  * verbatim, no logic change.
17
17
  */
18
18
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
19
- import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, } from "./local-args-tower.js";
19
+ import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseCareersArgs, } from "./local-args-tower.js";
20
20
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
21
21
  // `local-auth.ts` import it from here — so it stays exported from this address
22
22
  // even though it now lives next door. The same goes for the four names the
@@ -115,6 +115,8 @@ export function parseLocalArgs(argv) {
115
115
  return parseSearchArgs(argv.slice(1));
116
116
  case "release":
117
117
  return parseReleaseArgs(argv.slice(1));
118
+ case "careers":
119
+ return parseCareersArgs(argv.slice(1));
118
120
  case "usage":
119
121
  return parseUsageArgs(argv.slice(1));
120
122
  default:
@@ -22,6 +22,7 @@ const SEARCH_KIND_LIST = SEARCH_KINDS.join(",");
22
22
  const SEARCH_CORPORA_COUNT = SEARCH_KINDS.length;
23
23
  /** One entry per Tower noun, in the order `cockpit --help` lists them. */
24
24
  export const TOWER_COMMAND_HELP = [
25
+ ["careers", ["Usage: cockpit careers [list|show <id>|rescreen <id>] [--role <slug>] [--min-score <n>] [--since <date>] [--json]", "Super-admin application review. Lists at most 100 matches with total and has_more; use filters to narrow."]],
25
26
  [
26
27
  "docs",
27
28
  [
@@ -450,8 +450,14 @@ export function localSubcommandHelp(command) {
450
450
  [
451
451
  "notes",
452
452
  [
453
- "Usage: cockpit notes [list|show <id>|shelf|shelves|upload <paths...>|paste|share <id>|unshare <id>|move <id>] [flags]",
454
- "",
453
+ "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>] [flags]",
454
+ "Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 4 MB. --wait polls transcription for at most 330 seconds.",
455
+ "",
456
+ "folders [--tree] [--json]: list folders and visible note counts.",
457
+ "mkdir <path> [--json]: create a folder and any missing ancestors.",
458
+ "rename <path> --name <name> [--json]: rename a folder and its descendant paths.",
459
+ "rmdir <path> [--json]: delete an empty folder; refuses notes or subfolders.",
460
+ "upload, paste and move accept --folder <path>; use / for Root. Shelf placement stays independent.",
455
461
  "The /meeting-notes surface, typed. Bare `cockpit notes` lists the library.",
456
462
  "list [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] — every note you may open, grouped by shelf, newest meeting first. Never a word of a note.",
457
463
  "show <id> — one note with its text, its shelf, and why you are allowed to see it.",
@@ -52,6 +52,7 @@ export const rootCommandNames = new Set([
52
52
  "project",
53
53
  "search",
54
54
  "release",
55
+ "careers",
55
56
  "usage",
56
57
  ]);
57
58
  export function localCommandHelp(command) {
@@ -83,7 +84,7 @@ export function localCommandHelp(command) {
83
84
  " 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]",
84
85
  " cockpit brief status [--who <person>] [--render] [--dashboard-url <url>] [--json]",
85
86
  " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
86
- " cockpit notes [list|show <id>|shelf|shelves|upload <paths...>|paste|share <id>|unshare <id>|move <id>|place <id>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
87
+ " cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>|place <id>] [--folder <path>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
87
88
  " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
88
89
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
89
90
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
@@ -100,6 +101,7 @@ export function localCommandHelp(command) {
100
101
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
101
102
  ` cockpit search "<words>" [--kind ${SEARCH_KINDS.join(",")}] [--limit <n>] [--dashboard-url <url>] [--json]`,
102
103
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
104
+ " cockpit careers [list|show <id>|rescreen <id>] [--role <slug>] [--min-score <n>] [--since <date>] [--json]",
103
105
  " cockpit usage people [--since 30d|<iso>] [--until <iso>] [--include-automated] [--dashboard-url <url>] [--json]",
104
106
  "",
105
107
  `Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
@@ -43,6 +43,7 @@ import { runCal } from "./cal.js";
43
43
  import { runMail } from "./mail.js";
44
44
  import { runProject } from "./project.js";
45
45
  import { runSearch } from "./search.js";
46
+ import { runCareers } from "./careers.js";
46
47
  import { runUsage } from "./usage.js";
47
48
  import { parseLocalArgs } from "./local-args.js";
48
49
  // `./local.js` is the published entry point for this command surface: the
@@ -180,6 +181,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
180
181
  return await runSearch(command, io);
181
182
  case "release":
182
183
  return await runRelease(command, io);
184
+ case "careers":
185
+ return await runCareers(command, io);
183
186
  case "usage":
184
187
  return await runUsage(command, io);
185
188
  }
@@ -34,6 +34,10 @@ import { errorMessage } from "./cli-io.js";
34
34
  * courtesy check to avoid uploading something the server is certain to refuse,
35
35
  * not a second source of truth.
36
36
  */
37
+ export function noteAudioMime(name) {
38
+ const mime = { mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav", webm: "audio/webm", ogg: "audio/ogg" };
39
+ return mime[name.split(".").at(-1)?.toLowerCase() ?? ""];
40
+ }
37
41
  export const NOTE_FILE_MAX_BYTES = 20 * 1024 * 1024;
38
42
  /**
39
43
  * Above this, the terminal says it is working before it starts. The route's own
@@ -52,7 +56,7 @@ export function noteFileRefusalSentence(refusal, filePath) {
52
56
  case "file_empty":
53
57
  return `There is nothing in that file: ${filePath}`;
54
58
  case "file_too_big":
55
- return "That file is too big to put in as a note keep it under 20 MB.";
59
+ return "That file is too big. Audio is limited to 4 MB; other notes to 20 MB.";
56
60
  case "looks_like_a_key_file":
57
61
  // The same rule the server's gate applies, said the same way: the name is
58
62
  // all it takes to decide, and looking inside to be sure would already be
@@ -88,6 +92,9 @@ export async function readNoteFile(filePath) {
88
92
  }
89
93
  if (size === 0)
90
94
  return { ok: false, refusal: "file_empty", detail: "byte_size_0" };
95
+ if (noteAudioMime(fileName) && size > 4_000_000) {
96
+ return { ok: false, refusal: "file_too_big", detail: "file_too_large: audio limit is 4 MB (4,000,000 bytes)" };
97
+ }
91
98
  if (size > NOTE_FILE_MAX_BYTES) {
92
99
  return { ok: false, refusal: "file_too_big", detail: `byte_size_${size}` };
93
100
  }
@@ -0,0 +1,35 @@
1
+ import { writeLine } from "./cli-io.js";
2
+ import { ask, emit, fail, READ_DEADLINE_MS } from "./notes-door.js";
3
+ export async function runFolderCommand(command, door) {
4
+ const path = command.folder?.split("/").map(part => part.trim()).join("/");
5
+ const answer = await ask(door, {
6
+ path: "/api/notes/folders", method: command.action === "mkdir" ? "POST" : "GET",
7
+ label: `notes ${command.action}`, timeoutMs: READ_DEADLINE_MS,
8
+ ...(command.action === "mkdir" ? { body: { path } } : {}),
9
+ });
10
+ if (!answer.ok)
11
+ return fail(door, answer.reason, answer.detail);
12
+ const body = answer.body;
13
+ if (command.action === "rmdir" || command.action === "rename") {
14
+ const folder = body.folders?.find(folder => folder.path === path);
15
+ if (!folder)
16
+ return fail(door, "folder_not_found", "That folder could not be found.");
17
+ const removed = await ask(door, { path: `/api/notes/folders/${encodeURIComponent(folder.id)}`, method: command.action === "rename" ? "PATCH" : "DELETE", label: `notes ${command.action}`, timeoutMs: READ_DEADLINE_MS, ...(command.action === "rename" ? { body: { name: command.name } } : {}) });
18
+ if (!removed.ok)
19
+ return fail(door, removed.reason, removed.detail);
20
+ if (door.json)
21
+ return emit(door, removed.body);
22
+ writeLine(door.io.stdout, removed.body.headline);
23
+ return 0;
24
+ }
25
+ if (door.json)
26
+ return emit(door, body);
27
+ if (command.action === "mkdir")
28
+ writeLine(door.io.stdout, body.headline ?? "Folder ready.");
29
+ else
30
+ for (const folder of body.folders ?? []) {
31
+ const label = command.tree ? `${" ".repeat(folder.path.split("/").length - 1)}${folder.name}` : folder.path;
32
+ writeLine(door.io.stdout, `${label} (${folder.note_count})`);
33
+ }
34
+ return 0;
35
+ }
@@ -4,7 +4,7 @@
4
4
  * `notes.ts`, named in its header.
5
5
  */
6
6
  import { isInteractiveStdin, readLine, readPipedText, writeLine, yesByDefault } from "./cli-io.js";
7
- import { NOTE_SLOW_UPLOAD_BYTES, decodeTextBytes, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
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
  /**
10
10
  * The upload route's own ceiling is `maxDuration = 300` — reading a note is one
@@ -36,9 +36,11 @@ export async function uploadNotes(command, door) {
36
36
  writeLine(door.io.stderr, `Reading ${read.fileName} (${Math.round(read.bytes.byteLength / 1024)} KB). This can take a couple of minutes.`);
37
37
  }
38
38
  const form = new FormData();
39
- form.set("file", new File([new Uint8Array(read.bytes)], read.fileName));
39
+ form.set("file", new File([new Uint8Array(read.bytes)], read.fileName, { type: noteAudioMime(read.fileName) ?? "" }));
40
40
  if (command.exclude)
41
41
  form.set("exclusions", command.exclude);
42
+ if (command.folder)
43
+ form.set("folder_path", command.folder);
42
44
  const answer = await ask(door, {
43
45
  path: "/api/notes/upload",
44
46
  method: "POST",
@@ -55,7 +57,14 @@ export async function uploadNotes(command, door) {
55
57
  continue;
56
58
  }
57
59
  const body = answer.body;
58
- results.push({ path: filePath, ok: body.stored === true, body });
60
+ if (command.wait && body.noteId && body.transcription?.status === "pending") {
61
+ body.transcription = await waitForTranscription(door, body.noteId);
62
+ }
63
+ if (body.transcription?.status === "failed")
64
+ worstExit = 1;
65
+ if (!door.json && body.transcription)
66
+ writeLine(door.io.stdout, `${body.noteId}: transcription ${body.transcription.status}${body.transcription.reason ? ": " + body.transcription.reason : ""}`);
67
+ results.push({ path: filePath, ok: body.stored === true && body.transcription?.status !== "failed", body });
59
68
  if (body.stored !== true)
60
69
  worstExit = 1;
61
70
  if (!door.json)
@@ -114,6 +123,8 @@ export async function pasteNote(command, door) {
114
123
  form.set("name", command.name);
115
124
  if (command.exclude)
116
125
  form.set("exclusions", command.exclude);
126
+ if (command.folder)
127
+ form.set("folder_path", command.folder);
117
128
  // Bytes, not `.length` (BLI-3482). `NOTE_SLOW_UPLOAD_BYTES` is a BYTE
118
129
  // threshold, and the file path above already compares `bytes.byteLength`
119
130
  // against it; `text.length` counts UTF-16 units, so a note in any non-Latin
@@ -225,7 +236,10 @@ export async function moveNote(command, door) {
225
236
  timeoutMs: READ_DEADLINE_MS,
226
237
  // `--clear-shelf` sends the empty string, which is what the browser's own
227
238
  // move box sends when a person empties it.
228
- body: { note_id: command.noteId, category: command.clearShelf ? "" : command.to },
239
+ body: { note_id: command.noteId,
240
+ ...(command.folder ? { folder_path: command.folder } : {}),
241
+ ...(command.to || command.clearShelf ? { category: command.clearShelf ? "" : command.to } : {}),
242
+ },
229
243
  });
230
244
  if (!answer.ok)
231
245
  return fail(door, answer.reason, answer.detail);
@@ -242,4 +256,23 @@ export async function moveNote(command, door) {
242
256
  for (const line of body.lines ?? [])
243
257
  writeLine(door.io.stdout, line);
244
258
  return body.ok === true ? 0 : 1;
259
+ }
260
+ /** Bounded polling of the same note read used by the browser and MCP. */
261
+ async function waitForTranscription(door, noteId) {
262
+ const deadline = Date.now() + 330_000;
263
+ for (let attempt = 0; attempt < 110 && Date.now() < deadline; attempt++) {
264
+ const answer = await ask(door, { path: `/api/notes/library/${encodeURIComponent(noteId)}`,
265
+ method: "GET", label: "notes transcription", timeoutMs: Math.min(10_000, deadline - Date.now()) });
266
+ if (!answer.ok)
267
+ return { status: "failed", reason: answer.reason };
268
+ const status = answer.body.audio?.transcription_status;
269
+ if (status === "done")
270
+ return { status: "done" };
271
+ if (status?.startsWith("failed:"))
272
+ return { status: "failed", reason: status.slice(7) };
273
+ if (status !== "pending")
274
+ return { status: "failed", reason: "transcription_status_unavailable" };
275
+ await new Promise((resolve) => setTimeout(resolve, Math.min(3000, Math.max(0, deadline - Date.now()))));
276
+ }
277
+ return { status: "failed", reason: "transcription_wait_timeout" };
245
278
  }
@@ -36,6 +36,7 @@
36
36
  import { loadPairedSession } from "../tower-client.js";
37
37
  import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
38
38
  import { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
39
+ import { runFolderCommand } from "./notes-folders.js";
39
40
  export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
40
41
  export { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
41
42
  export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
@@ -48,6 +49,11 @@ export async function runNotes(command, io) {
48
49
  json: command.json,
49
50
  };
50
51
  switch (command.action) {
52
+ case "folders":
53
+ case "mkdir":
54
+ case "rmdir":
55
+ case "rename":
56
+ return runFolderCommand(command, door);
51
57
  case "list":
52
58
  return listNotes(command, door);
53
59
  case "shelves":
@@ -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.99");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.100");
19
19
  return 0;
20
20
  }
21
21