@bli-cockpit/cli 0.2.146 → 0.2.148

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.
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { CODEX_SESSION_ATTRIBUTION_STATE_RANK, NO_UPLOAD_ATTEMPT_RECORDED, notUploadableAttributionStateReason, } from "@bli-cockpit/telemetry-core";
12
12
  import { isLiveRawEvidenceSyncAttribution, isRawEvidenceUploadableAttributionState, } from "../raw-evidence-attribution-policy.js";
13
+ import { rootScopeForReason } from "./agent-session-root-scope.js";
13
14
  /** How many Codex sessions the scan could not read at all. */
14
15
  export function codexAttributionReadFailureCount(scan) {
15
16
  return scan.directory_read_failed_count + scan.stat_failed_count;
@@ -150,6 +151,7 @@ function sessionReportEntry(result, context) {
150
151
  attribution_reason: result.reason,
151
152
  attribution_score: result.attribution_score,
152
153
  path_score: result.path_score,
154
+ root_scope: rootScopeForReason(result.reason),
153
155
  signals: result.signals,
154
156
  ...(result.content_hash_sha256
155
157
  ? { session_file_hash_sha256: result.content_hash_sha256 }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * BLI-10734 (roots are labels, not gates): the one place that turns an
3
+ * attribution reason into the `root_scope` label a report row carries.
4
+ *
5
+ * Split out of `agent-session-report.ts` to keep that file under the
6
+ * readability ratchet's band floor — this helper has no dependency on the
7
+ * report-row shape, so it stands alone.
8
+ */
9
+ import { OUTSIDE_APPROVED_ROOT_REASONS } from "./session-sync-health.js";
10
+ /**
11
+ * Both scopes upload; this label is how a person tells them apart downstream
12
+ * without re-deriving it from `attribution_reason`. Uses the SAME closed
13
+ * reason set `session-sync-health.ts` and `session-sync-counters.ts` already
14
+ * trust as "outside every approved root" — one vocabulary, never a second
15
+ * guess at what "outside" means.
16
+ */
17
+ export function rootScopeForReason(reason) {
18
+ return OUTSIDE_APPROVED_ROOT_REASONS.has(reason) ? "outside" : "inside";
19
+ }
@@ -13,7 +13,7 @@
13
13
  * asked for, never who is asking — the same rule `cockpit jarvis --as` follows.
14
14
  */
15
15
  import { colorEnabled, dim, writeLine } from "./cli-io.js";
16
- import { renderBriefStatus } from "./ops-render.js";
16
+ import { renderBriefStatus, renderMissedBrief, } from "./ops-render.js";
17
17
  import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
18
18
  const REQUEST_DEADLINE_MS = 30_000;
19
19
  export async function runBrief(command, io) {
@@ -97,6 +97,8 @@ function logBriefRead(command, io, body, startedAt) {
97
97
  */
98
98
  async function runBriefStatus(command, io, dashboardUrl, deviceToken, startedAt) {
99
99
  const params = new URLSearchParams();
100
+ if (command.missed)
101
+ params.set("missed", "1");
100
102
  if (command.subject)
101
103
  params.set("who", command.subject);
102
104
  if (command.render)
@@ -116,6 +118,8 @@ async function runBriefStatus(command, io, dashboardUrl, deviceToken, startedAt)
116
118
  writeFailure(command, io, result.reason, result.detail);
117
119
  return 1;
118
120
  }
121
+ if (command.missed)
122
+ return runMissedBrief(command, io, result.body, startedAt);
119
123
  const payload = result.body;
120
124
  if (command.json) {
121
125
  writeLine(io.stdout, JSON.stringify(payload));
@@ -138,6 +142,33 @@ async function runBriefStatus(command, io, dashboardUrl, deviceToken, startedAt)
138
142
  })}`);
139
143
  return reason === "delivered" ? 0 : 1;
140
144
  }
145
+ /**
146
+ * `cockpit brief status --missed` (BLI-10728) — everybody, not one person.
147
+ * Exit 0 only when nobody owed a brief is missing one; a non-empty missed
148
+ * list, or an unreadable roster, is 1 — the same "a script can act on this"
149
+ * rule `runBriefStatus` follows for a single subject.
150
+ */
151
+ async function runMissedBrief(command, io, payload, startedAt) {
152
+ if (command.json) {
153
+ writeLine(io.stdout, JSON.stringify(payload));
154
+ }
155
+ else {
156
+ const styled = colorEnabled(io);
157
+ for (const line of renderMissedBrief(payload, (text) => dim(text, styled))) {
158
+ writeLine(io.stdout, line);
159
+ }
160
+ }
161
+ const coverage = payload.coverage ?? {};
162
+ writeLine(io.stderr, `[brief cli] missed read ${JSON.stringify({
163
+ verdict: coverage.verdict ?? "unknown",
164
+ delivered: coverage.delivered ?? null,
165
+ missed: coverage.missed?.length ?? null,
166
+ no_page_shape: coverage.noPageShape ?? null,
167
+ unreadable: coverage.unreadable ?? null,
168
+ elapsed_ms: Date.now() - startedAt,
169
+ })}`);
170
+ return (coverage.missed?.length ?? 0) === 0 && coverage.verdict !== "unreadable" ? 0 : 1;
171
+ }
141
172
  function queryFor(command) {
142
173
  const params = new URLSearchParams();
143
174
  if (command.subject)
@@ -0,0 +1,27 @@
1
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
2
+ import { writeLine } from "./cli-io.js";
3
+ const TAG = "[cal cli]";
4
+ const READ_DEADLINE_MS = 30_000;
5
+ export async function runCalSyncStatus(command, door) {
6
+ const path = command.subjectId
7
+ ? `/api/cal/sync-status?calendar_id=${encodeURIComponent(command.subjectId)}`
8
+ : "/api/cal/sync-status";
9
+ const answer = await askAgentDoor(door, { path, method: "GET", label: "cal sync-status", timeoutMs: READ_DEADLINE_MS });
10
+ if (!answer.ok)
11
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
12
+ const calendars = answer.body.calendars ?? [];
13
+ if (door.json)
14
+ return emitAgentDoor(door, { ok: true, calendars });
15
+ if (calendars.length === 0) {
16
+ writeLine(door.io.stdout, "No calendars attached yet.");
17
+ return 0;
18
+ }
19
+ for (const calendar of calendars) {
20
+ const lastRun = calendar.last_run
21
+ ? `${calendar.last_run.reason} @ ${calendar.last_run.started_at} (${calendar.last_run.items} item(s))`
22
+ : "never synced";
23
+ const lag = calendar.lag ? `${calendar.lag.lag_seconds}s` : "unknown";
24
+ writeLine(door.io.stdout, `${calendar.name} last_success=${calendar.last_success_at ?? "never"} last_run=${lastRun} failures_24h=${calendar.failures_24h} lag=${lag}${calendar.data_incomplete ? " (partial read)" : ""}`);
25
+ }
26
+ return 0;
27
+ }
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
24
24
  import { runCalDelete } from "./cal-delete.js";
25
+ import { runCalSyncStatus } from "./cal-sync-status.js";
25
26
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
26
27
  const TAG = "[cal cli]";
27
28
  const READ_DEADLINE_MS = 30_000;
@@ -50,6 +51,8 @@ export async function runCal(command, io) {
50
51
  return detach(command, door);
51
52
  case "sync":
52
53
  return sync(command, door);
54
+ case "sync-status":
55
+ return runCalSyncStatus(command, door);
53
56
  case "delete":
54
57
  return runCalDelete(command, door);
55
58
  }
@@ -19,6 +19,12 @@ import { resolveDocId } from "./docs.js";
19
19
  const TAG = "[docs cli]";
20
20
  const READ_DEADLINE_MS = 30_000;
21
21
  const WRITE_DEADLINE_MS = 60_000;
22
+ function runLine(direction, run) {
23
+ if (!run)
24
+ return `${direction}: never run`;
25
+ const moved = run.pushed || run.pulled;
26
+ return `${direction}: ${run.reason} (${moved} moved, ${run.failed} failed) at ${run.started_at}`;
27
+ }
22
28
  /**
23
29
  * `sync-status [<id|slug>]` → `GET /api/docs/notion-sync[?document_id=]`. Tower's
24
30
  * half of the answer only (synced / tower_changed / never_synced); the door
@@ -39,8 +45,14 @@ export async function syncStatusDocs(command, door) {
39
45
  if (!answer.ok)
40
46
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
41
47
  const body = answer.body;
42
- if (door.json)
43
- return emitAgentDoor(door, { ok: true, counts: body.counts, documents: body.documents ?? [] });
48
+ if (door.json) {
49
+ return emitAgentDoor(door, {
50
+ ok: true,
51
+ counts: body.counts,
52
+ documents: body.documents ?? [],
53
+ last_run: body.last_run ?? { push: null, pull: null },
54
+ });
55
+ }
44
56
  const documents = body.documents ?? [];
45
57
  for (const doc of documents) {
46
58
  writeLine(door.io.stdout, `${doc.id} ${doc.state.padEnd(13)} ${(doc.notion_synced_at ?? "never").padEnd(25)} ${doc.slug ?? "(no slug)"} ${doc.title}`);
@@ -50,6 +62,7 @@ export async function syncStatusDocs(command, door) {
50
62
  writeLine(door.io.stdout, `${body.counts?.linked ?? documents.length} linked: ${Object.entries(byState)
51
63
  .map(([state, count]) => `${count} ${state}`)
52
64
  .join(", ")}. A Notion edit shows up at the next pull, not here.`);
65
+ writeLine(door.io.stdout, `Last run — ${runLine("push", body.last_run?.push)} · ${runLine("pull", body.last_run?.pull)}`);
53
66
  return 0;
54
67
  }
55
68
  export async function linkDoc(command, door) {
@@ -1,5 +1,5 @@
1
1
  import os from "node:os";
2
- import { readRootWorkScan, outsideWorkDirectories, readRootApprovals, rootsAddCommand, rootPathApi } from "../root-work.js";
2
+ import { readRootWorkScan, outsideWorkDirectories, readRootApprovals, rootsAddCommand, rootPathApi, sessionCoverageLine } from "../root-work.js";
3
3
  import { writeJsonFile } from "../local-state-files.js";
4
4
  import { containsPath } from "../root-normalization.js";
5
5
  import { scanWorkDirectories } from "./local-discovery.js";
@@ -156,17 +156,33 @@ function shellQuote(value) {
156
156
  return '"$PWD"';
157
157
  return `'${value.replace(/'/gu, "'\\''")}'`;
158
158
  }
159
- /** The last scan names work missed by the saved boundary. Diagnosis never grants consent. */
159
+ /**
160
+ * The last scan names work missed by the saved boundary. Diagnosis never
161
+ * grants consent — and since BLI-10734 there is nothing left to consent TO
162
+ * for the session report itself, only for raw evidence: every session this
163
+ * scan saw now reaches the server (`sessionCoverageLine`), labelled
164
+ * `root_scope: "outside"` when its directory sits outside every approved
165
+ * root. Widening the boundary still matters for the redacted transcript
166
+ * BYTES, which stay root-gated, so this row keeps flagging the gap and
167
+ * offering `roots add` — it is just no longer offering to rescue a session
168
+ * that would otherwise vanish.
169
+ */
160
170
  export async function readRootsCoverWork(context) {
161
171
  const homeDir = context.command.homeDir ?? os.homedir();
162
172
  const paths = getCollectorRuntimePaths(homeDir);
163
173
  const scan = await readRootWorkScan(paths) ?? await scanWorkDirectories(homeDir, false);
164
174
  const outside = outsideWorkDirectories(scan, (await readLocalCollectorConfig(paths))?.default_repo_paths ?? []);
165
175
  const count = outside.reduce((sum, entry) => sum + entry.count, 0);
176
+ // ticket 10727 (ticket 4382 measured) then BLI-10734 (roots are labels, not
177
+ // gates): the coverage sentence a person asked doctor for, appended rather
178
+ // than replacing the existing per-directory breakdown below. Since
179
+ // BLI-10734, widening a root no longer rescues a session from oblivion —
180
+ // its metadata already reached the server either way — it buys the
181
+ // redacted transcript content instead, so the "needs fix" branch says so.
166
182
  if (!count)
167
- return ok("roots-cover-work", "work_covered", `${scan.observed} sessions seen; no known working directories outside approved roots.`);
183
+ return ok("roots-cover-work", "work_covered", `${scan.observed} sessions seen; no known working directories outside approved roots. ${sessionCoverageLine(scan, count)}`);
168
184
  return {
169
- ...needsFix("roots-cover-work", "work_outside_roots", `${count} of ${scan.observed} sessions have working directories outside every approved root (scan ${scan.generated_at}):\n${outside.map(entry => `${entry.count}: ${entry.directory}\n ${rootsAddCommand(entry.directory, process.platform, homeDir)}`).join("\n")}`),
185
+ ...needsFix("roots-cover-work", "work_outside_roots", `${count} of ${scan.observed} sessions have working directories outside every approved root — their redacted transcript content stays unuploaded until the root widens, though their metadata already reaches the server with an outside label (scan ${scan.generated_at}):\n${outside.map(entry => `${entry.count}: ${entry.directory}\n ${rootsAddCommand(entry.directory, process.platform, homeDir)}`).join("\n")}\n\n${sessionCoverageLine(scan, count)}`),
170
186
  nextAction: rootsAddCommand(outside[0].directory, process.platform, homeDir),
171
187
  };
172
188
  }
@@ -30,10 +30,13 @@ const CAL_ACTIONS = new Set([
30
30
  "share",
31
31
  "detach",
32
32
  "sync",
33
+ "sync-status",
33
34
  "delete",
34
35
  ]);
35
36
  /** Every verb that names ONE thing first — a calendar id, or (delete) an event id. */
36
37
  const CAL_ACTIONS_NEEDING_A_SUBJECT = new Set(["sync", "detach", "share", "delete"]);
38
+ /** `sync-status [<calendar id>]` — every calendar, or one's. Ticket 10730. */
39
+ const CAL_ACTIONS_WITH_OPTIONAL_SUBJECT = new Set(["sync-status"]);
37
40
  export function parseCalArgs(args) {
38
41
  const values = parseNamedArgs(args, {
39
42
  allowedFlags: [
@@ -94,7 +97,7 @@ function readCalAction(values) {
94
97
  const first = values.positionals[0];
95
98
  const action = (first === undefined ? "today" : first);
96
99
  if (!CAL_ACTIONS.has(action)) {
97
- throw new Error(`Unknown cal command: ${first}. Try today, week, next, find, calendars, add-ical, create, share, detach, sync, or delete.`);
100
+ throw new Error(`Unknown cal command: ${first}. Try today, week, next, find, calendars, add-ical, create, share, detach, sync, sync-status, or delete.`);
98
101
  }
99
102
  return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
100
103
  }
@@ -111,6 +114,11 @@ function readCalSubjectOrQuery(action, rest) {
111
114
  if (rest.length > 1)
112
115
  throw new Error(`cal ${action} takes one id, not ${rest.length}.`);
113
116
  }
117
+ else if (CAL_ACTIONS_WITH_OPTIONAL_SUBJECT.has(action)) {
118
+ subject = optionalNonEmpty(rest[0]);
119
+ if (rest.length > 1)
120
+ throw new Error(`cal ${action} takes at most one id, not ${rest.length}.`);
121
+ }
114
122
  else if (action === "find") {
115
123
  query = optionalNonEmpty(rest.join(" "));
116
124
  if (!query)
@@ -29,9 +29,12 @@ const MAIL_ACTIONS = new Set([
29
29
  "send",
30
30
  "attachment",
31
31
  "sync",
32
+ "sync-status",
32
33
  ]);
33
34
  /** Every verb that names ONE thing first: a thread, an attachment, a mailbox. */
34
35
  const MAIL_ACTIONS_NEEDING_A_SUBJECT = new Set(["read", "attachment", "detach", "sync"]);
36
+ /** `sync-status [<account id>]` — every mailbox, or one's. Ticket 10730. */
37
+ const MAIL_ACTIONS_WITH_OPTIONAL_SUBJECT = new Set(["sync-status"]);
35
38
  export function parseMailArgs(args) {
36
39
  const values = parseNamedArgs(args, {
37
40
  allowedFlags: [
@@ -82,7 +85,7 @@ function readMailAction(values) {
82
85
  const first = values.positionals[0];
83
86
  const action = (first === undefined ? "inbox" : first);
84
87
  if (!MAIL_ACTIONS.has(action)) {
85
- throw new Error(`Unknown mail command: ${first}. Try accounts, add-imap, detach, inbox, read, search, send, attachment, or sync.`);
88
+ throw new Error(`Unknown mail command: ${first}. Try accounts, add-imap, detach, inbox, read, search, send, attachment, sync, or sync-status.`);
86
89
  }
87
90
  return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
88
91
  }
@@ -99,6 +102,11 @@ function readMailSubjectOrQuery(action, rest) {
99
102
  if (rest.length > 1)
100
103
  throw new Error(`mail ${action} takes one id, not ${rest.length}.`);
101
104
  }
105
+ else if (MAIL_ACTIONS_WITH_OPTIONAL_SUBJECT.has(action)) {
106
+ subject = optionalNonEmpty(rest[0]);
107
+ if (rest.length > 1)
108
+ throw new Error(`mail ${action} takes at most one id, not ${rest.length}.`);
109
+ }
102
110
  else if (action === "search") {
103
111
  query = optionalNonEmpty(rest.join(" "));
104
112
  if (!query)
@@ -41,6 +41,7 @@ export function parseBriefArgs(args) {
41
41
  "--versions",
42
42
  "--claims",
43
43
  "--render",
44
+ "--missed",
44
45
  "--reason",
45
46
  "--wait",
46
47
  "--no-wait",
@@ -140,11 +141,19 @@ export function parseBriefArgs(args) {
140
141
  if (render && action !== "status") {
141
142
  throw new Error("brief --render belongs to `cockpit brief status`; the page is printed by default.");
142
143
  }
144
+ const missed = values.booleans.has("--missed");
145
+ if (missed && action !== "status") {
146
+ throw new Error("brief --missed belongs to `cockpit brief status`; the page is printed by default.");
147
+ }
148
+ if (missed && named.length > 0) {
149
+ throw new Error("brief status --missed answers for everybody; it does not take --for, --as or --who.");
150
+ }
143
151
  return {
144
152
  kind: "brief",
145
153
  action,
146
154
  ...(item ? { item } : {}),
147
155
  render,
156
+ missed,
148
157
  homeDir: optionalNonEmpty(values.flags.get("--home")),
149
158
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
150
159
  subject: forPerson ?? asPerson ?? whoPerson,
@@ -192,8 +201,11 @@ const NOTES_ACTIONS = new Set([
192
201
  "connect",
193
202
  "detach",
194
203
  "sync",
204
+ "sync-status",
195
205
  // BLI-4394: arm that notetaker's webhook with the vendor's signing secret.
196
206
  "webhook",
207
+ // Ticket 10718: the Granola cron's own last-several-runs health.
208
+ "granola-status",
197
209
  ]);
198
210
  /** Actions whose first positional is the note it acts on. */
199
211
  const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
@@ -265,7 +277,7 @@ export function parseNotesArgs(args) {
265
277
  const first = values.positionals[0];
266
278
  const action = (first === undefined ? "list" : first);
267
279
  if (!NOTES_ACTIONS.has(action)) {
268
- throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, move, place, accounts, connect, detach, sync, or webhook.`);
280
+ throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, move, place, accounts, connect, detach, sync, sync-status, webhook, or granola-status.`);
269
281
  }
270
282
  const rest = values.positionals.slice(first === undefined ? 0 : 1);
271
283
  const json = values.booleans.has("--json");
@@ -283,7 +295,7 @@ export function parseNotesArgs(args) {
283
295
  }
284
296
  }
285
297
  let accountId;
286
- if (NOTES_ACTIONS_NEEDING_AN_ACCOUNT.has(action) || action === "sync") {
298
+ if (NOTES_ACTIONS_NEEDING_AN_ACCOUNT.has(action) || action === "sync" || action === "sync-status") {
287
299
  accountId = optionalNonEmpty(rest[0]);
288
300
  if (!accountId && NOTES_ACTIONS_NEEDING_AN_ACCOUNT.has(action)) {
289
301
  throw new Error(`notes ${action} needs a notetaker account id.`);
@@ -352,6 +364,7 @@ export function parseNotesArgs(args) {
352
364
  else if (action !== "paste"
353
365
  && action !== "connect"
354
366
  && action !== "sync"
367
+ && action !== "sync-status"
355
368
  && !NOTES_ACTIONS_NEEDING_A_NOTE.has(action)
356
369
  && !NOTES_ACTIONS_NEEDING_AN_ACCOUNT.has(action)
357
370
  && rest.length > 0) {
@@ -169,14 +169,14 @@ function incompleteDiscoveryMessage(input) {
169
169
  }
170
170
  /** Reuse the session adapters for onboarding/doctor. No transcript bodies leave them. */
171
171
  export async function scanWorkDirectories(homeDir, persist = true) {
172
- const [{ scanAndAttributeCodexSessions, defaultCodexSessionDirs }, { scanAndAttributeClaudeSessions }, { readLocalCollectorConfig, getCollectorRuntimePaths }, { saveRootWorkScan, workDirectories }] = await Promise.all([
172
+ const [{ scanAndAttributeCodexSessions, defaultCodexSessionDirs }, { scanAndAttributeClaudeSessions }, { readLocalCollectorConfig, getCollectorRuntimePaths }, { saveRootWorkScan, workDirectories, ROOT_WORK_SCAN_WINDOW_DAYS }] = await Promise.all([
173
173
  import("../adapters/codex-attribution.js"), import("../adapters/claude-attribution.js"), import("../local-state.js"), import("../root-work.js"),
174
174
  ]);
175
175
  const paths = getCollectorRuntimePaths(homeDir);
176
176
  const config = await readLocalCollectorConfig(paths).catch(error => { if (error.code === "ENOENT")
177
177
  return null; throw error; });
178
178
  const now = new Date();
179
- const options = { worktrees: [], collectionRoots: config?.default_repo_paths ?? [], now, sinceMinutes: 14 * 24 * 60, limit: Number.MAX_SAFE_INTEGER };
179
+ const options = { worktrees: [], collectionRoots: config?.default_repo_paths ?? [], now, sinceMinutes: ROOT_WORK_SCAN_WINDOW_DAYS * 24 * 60, limit: Number.MAX_SAFE_INTEGER };
180
180
  const codex = await scanAndAttributeCodexSessions({ ...options, sessionsDirs: defaultCodexSessionDirs(homeDir) });
181
181
  const claude = config?.collect_claude_jsonl === false ? null : await scanAndAttributeClaudeSessions({ ...options, projectsDir: (await import("node:path")).default.join(homeDir, ".claude", "projects") });
182
182
  if (codex.directory_read_failed_count || codex.stat_failed_count || claude?.project_dir_read_failed_count || claude?.session_stat_failed_count) {
@@ -108,7 +108,7 @@ export const TOWER_COMMAND_HELP = [
108
108
  [
109
109
  "mail",
110
110
  [
111
- "Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|attachment <id>|sync <account>|detach <account>] [flags]",
111
+ "Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|attachment <id>|sync <account>|sync-status [<account>]|detach <account>] [flags]",
112
112
  "",
113
113
  "Every mailbox you attached, in one place. One person, several addresses — nothing here has a \"current\" mailbox.",
114
114
  "accounts — every mailbox, its provider (gmail_oauth or imap), its health and when it last synced. The ids other verbs take are on the last line.",
@@ -122,6 +122,7 @@ export const TOWER_COMMAND_HELP = [
122
122
  "send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
123
123
  "attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
124
124
  "sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
125
+ "sync-status [<account>] — every mailbox's health, or one's: last successful sync, the last pass's own reason, how many messages it carried, failures in the last 24h, and the lag between a message's own clock and when Tower stored it (ticket 10730).",
125
126
  "detach <account> — removes the mailbox, its stored mail and its credential.",
126
127
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr. Shapes differ by verb: inbox/read answer { messages: [...] }, search answers { matches: [...] } — same MessageRow shape either name, always carrying thread_id.",
127
128
  "A refusal keeps Tower's own reason label — needs_rls_client, account_not_found_or_unreadable, account_not_active, google_oauth_not_configured, credential_unavailable, and so on.",
@@ -131,7 +132,7 @@ export const TOWER_COMMAND_HELP = [
131
132
  [
132
133
  "cal",
133
134
  [
134
- 'Usage: cockpit cal [today|week|next|find "<words>"|calendars|add-ical|create|share <id>|sync <id>|detach <id>|delete <event id> --yes] [flags]',
135
+ 'Usage: cockpit cal [today|week|next|find "<words>"|calendars|add-ical|create|share <id>|sync <id>|sync-status [<id>]|detach <id>|delete <event id> --yes] [flags]',
135
136
  "",
136
137
  "Every calendar you attached, in one place — plus any calendar a colleague marked shared.",
137
138
  "today [--offset 1] [--tz <zone>] — what is on today, in YOUR zone (this machine's, unless --tz says otherwise). --offset 1 is tomorrow, -1 yesterday.",
@@ -148,6 +149,7 @@ export const TOWER_COMMAND_HELP = [
148
149
  " Only a Google calendar can be written to: an iCal secret address is a read address, and `write_not_supported_for_provider` says so by name.",
149
150
  "share <id> --org-visible|--private — makes one of YOUR calendars readable by everyone at BLI, or private again. Sharing never lets anybody else change it.",
150
151
  "sync <id> [--full] — reads that calendar now instead of waiting for the cron. --full ignores the sync token / ETag and re-reads everything.",
152
+ "sync-status [<id>] — every calendar's health, or one's: last successful sync, the last pass's own reason, how many events it carried, failures in the last 24h, and the lag between an event's own provider clock and when Tower stored it (ticket 10730).",
151
153
  "detach <id> — removes the calendar and its stored events from Tower. The calendar itself is untouched at Google.",
152
154
  "delete <event id> --yes — removes ONE event, AT GOOGLE first, then here. Refuses without --yes: a delete can email everybody on it, exactly like create's invitations do. Google already having it gone (404/410) is answered as success, not a refusal.",
153
155
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
@@ -269,7 +271,7 @@ export const TOWER_COMMAND_HELP = [
269
271
  [
270
272
  "brief",
271
273
  [
272
- "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]",
274
+ "Usage: cockpit brief [done <item>|undone <item>|edit|rewrite|history|status] [--for <person>] [--date <YYYY-MM-DD>] [--delta [--against <YYYY-MM-DD>]] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--days <n>] [--render] [--missed] [--reason \"<why>\"] [--wait|--no-wait] [--dashboard-url <url>] [--json]",
273
275
  "",
274
276
  "Prints the TODAY page — the same page the Tower website shows, rendered for a terminal.",
275
277
  "--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.",
@@ -295,6 +297,11 @@ export const TOWER_COMMAND_HELP = [
295
297
  " --render adds the TLDR the DM would have carried.",
296
298
  " --who <person> asks about somebody else and is for the people who read across",
297
299
  " the team. Exits 0 only on `delivered`.",
300
+ " cockpit brief status --missed",
301
+ " Everybody, not one person: every roster person owed a brief who has no delivered",
302
+ " receipt for their own most recently passed window, and why — compile_failed,",
303
+ " page_refused, dm_failed, or no_page_shape for a person with no page of their own.",
304
+ " Reads across the team, same gate as --who; refuses to combine with --for/--as/--who.",
298
305
  "edit — opens the page in $VISUAL/$EDITOR as a document: one [claimId] line per sentence you may rewrite, everything else a # comment. Change the words after an id, save, close. Only the lines that really changed are sent, so an accidental save writes nothing.",
299
306
  " An id you delete or invent is refused by name BEFORE anything is sent: a missing line reads as no opinion at all, not as a sentence to remove, and Tower cannot tell those apart.",
300
307
  " --reason \"<why>\" rides on every row, exactly like a commit message. Optional; the before-and-after already teaches on its own.",
@@ -317,7 +324,7 @@ export const TOWER_COMMAND_HELP = [
317
324
  [
318
325
  "notes",
319
326
  [
320
- "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]",
327
+ "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>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]] [flags]",
321
328
  "Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 4 MB. --wait polls transcription for at most 330 seconds.",
322
329
  "",
323
330
  "folders [--tree] [--json]: list folders and visible note counts.",
@@ -345,6 +352,8 @@ export const TOWER_COMMAND_HELP = [
345
352
  "connect --provider fellow|circleback [--workspace <subdomain>] --key-stdin [--name <n>] — connect one. Fellow needs --workspace, its API is per workspace. THE API KEY IS READ FROM STDIN, never a flag: printf \"%s\" \"<key>\" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin",
346
353
  "detach <account-id> — take it off. The stored key goes with it; the meetings it already filed stay.",
347
354
  "sync <account-id> — read that notetaker now. Prints the run's own counts and reason label, and exits 1 when the pass failed.",
355
+ "sync-status [<account-id>] — every notetaker's health, or one's: last successful sync, the last pass's own reason, how many meetings it carried, failures in the last 24h, and the lag between the newest meeting it has read and when it last ran (ticket 10730).",
356
+ "granola-status [--limit <n>] [--json] — the Granola cron's own last several runs (ticket 10718): reason, counts, model, spend and which lookback it used. Granola runs on a shared server-side key, not a connected account, so this is its own read rather than `accounts`.",
348
357
  "A large note is read by a model on the server and can take a couple of minutes; the terminal says so before it waits.",
349
358
  "--json writes one machine-readable object to stdout; every reason, receipt and progress line stays on stderr.",
350
359
  "Sharing and moving need a signed-in session the database can see. If this deployment cannot mint one, they are refused as needs_signed_in_session rather than done with no permission check — and reads say when they came back narrower than the browser's.",
@@ -53,6 +53,8 @@ export async function runMail(command, io) {
53
53
  return attachment(command, door);
54
54
  case "sync":
55
55
  return sync(command, door);
56
+ case "sync-status":
57
+ return syncStatus(command, door);
56
58
  }
57
59
  }
58
60
  async function listAccounts(door) {
@@ -317,6 +319,33 @@ async function sync(command, door) {
317
319
  writeLine(door.io.stdout, `That mailbox did not sync: ${run.reason}${run.detail ? ` (${run.detail})` : ""}.`);
318
320
  return 1;
319
321
  }
322
+ /**
323
+ * `sync-status [<account id>]` → `GET /api/mail/sync-status`. Ticket 10730:
324
+ * the read the integrations audit went to `mail_sync_runs` directly for.
325
+ */
326
+ async function syncStatus(command, door) {
327
+ const path = command.subjectId
328
+ ? `/api/mail/sync-status?account_id=${encodeURIComponent(command.subjectId)}`
329
+ : "/api/mail/sync-status";
330
+ const answer = await askAgentDoor(door, { path, method: "GET", label: "mail sync-status", timeoutMs: READ_DEADLINE_MS });
331
+ if (!answer.ok)
332
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
333
+ const accounts = answer.body.accounts ?? [];
334
+ if (door.json)
335
+ return emitAgentDoor(door, { ok: true, accounts });
336
+ if (accounts.length === 0) {
337
+ writeLine(door.io.stdout, "No mailboxes attached yet.");
338
+ return 0;
339
+ }
340
+ for (const account of accounts) {
341
+ const lastRun = account.last_run
342
+ ? `${account.last_run.reason} @ ${account.last_run.started_at} (${account.last_run.items} item(s))`
343
+ : "never synced";
344
+ const lag = account.lag ? `${account.lag.lag_seconds}s` : "unknown";
345
+ writeLine(door.io.stdout, `${account.address} last_success=${account.last_success_at ?? "never"} last_run=${lastRun} failures_24h=${account.failures_24h} lag=${lag}${account.data_incomplete ? " (partial read)" : ""}`);
346
+ }
347
+ return 0;
348
+ }
320
349
  /** Body on stdin, never argv — `--file` is the safest way on Windows. */
321
350
  async function readBody(command, door) {
322
351
  if (command.filePath) {
@@ -227,4 +227,31 @@ export async function syncNotetaker(command, door) {
227
227
  if (run.detail)
228
228
  writeLine(door.io.stdout, run.detail);
229
229
  return run.ok ? 0 : 1;
230
+ }
231
+ /**
232
+ * `sync-status [<account id>]` → `GET /api/notes/sync-status`. Ticket 10730:
233
+ * the read the integrations audit went to `notes_sync_runs` directly for.
234
+ */
235
+ export async function syncStatusNotetakers(command, door) {
236
+ const path = command.accountId
237
+ ? `/api/notes/sync-status?account_id=${encodeURIComponent(command.accountId)}`
238
+ : "/api/notes/sync-status";
239
+ const answer = await ask(door, { path, method: "GET", label: "notes sync-status", timeoutMs: READ_DEADLINE_MS });
240
+ if (!answer.ok)
241
+ return fail(door, answer.reason, answer.detail);
242
+ const accounts = answer.body.accounts ?? [];
243
+ if (door.json)
244
+ return emit(door, { ok: true, accounts });
245
+ if (accounts.length === 0) {
246
+ writeLine(door.io.stdout, "No notetaker is connected yet.");
247
+ return 0;
248
+ }
249
+ for (const account of accounts) {
250
+ const lastRun = account.last_run
251
+ ? `${account.last_run.reason} @ ${account.last_run.started_at} (${account.last_run.items} item(s))`
252
+ : "never synced";
253
+ const lag = account.lag ? `${account.lag.lag_seconds}s` : "unknown";
254
+ writeLine(door.io.stdout, `${account.label ?? account.provider} last_success=${account.last_success_at ?? "never"} last_run=${lastRun} failures_24h=${account.failures_24h} lag=${lag}${account.data_incomplete ? " (partial read)" : ""}`);
255
+ }
256
+ return 0;
230
257
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `cockpit notes granola-status` — the Granola cron's own health, at a
3
+ * terminal (ticket 10718). Sibling of `notes.ts`, named in its header.
4
+ *
5
+ * Granola is NOT a `notes_accounts` row: it runs on one shared server-side
6
+ * key, swept by its own Railway cron rather than a per-person connection, so
7
+ * this reads a different door (`GET /api/notes/granola-status`) over the same
8
+ * `granola_sync_runs` ledger the ops board's `granola-sync` entry watches.
9
+ * `--limit` bounds how many of the newest runs come back; the door itself
10
+ * caps it, so a bare `cockpit notes granola-status` is always cheap.
11
+ */
12
+ import { writeLine } from "./cli-io.js";
13
+ import { ask, emit, fail, READ_DEADLINE_MS } from "./notes-door.js";
14
+ export async function showGranolaStatus(command, door) {
15
+ const query = command.limit === undefined ? "" : `?limit=${command.limit}`;
16
+ const answer = await ask(door, {
17
+ path: `/api/notes/granola-status${query}`,
18
+ method: "GET",
19
+ label: "notes granola-status",
20
+ timeoutMs: READ_DEADLINE_MS,
21
+ });
22
+ if (!answer.ok)
23
+ return fail(door, answer.reason, answer.detail);
24
+ const body = answer.body;
25
+ const runs = body.runs ?? [];
26
+ if (door.json)
27
+ return emit(door, { ok: true, runs });
28
+ if (runs.length === 0) {
29
+ writeLine(door.io.stdout, "No granola:pull run has ever landed a row.");
30
+ return 0;
31
+ }
32
+ for (const run of runs) {
33
+ const spend = run.spend_usd === null ? "n/a" : `$${run.spend_usd.toFixed(4)}`;
34
+ writeLine(door.io.stdout, `${run.started_at} ${run.reason.padEnd(21)} seen=${run.notes_seen} stored=${run.notes_stored} `
35
+ + `failed=${run.notes_failed} model=${run.model ?? "n/a"} spend=${spend}`);
36
+ writeLine(door.io.stdout, ` since=${run.window_start ?? "n/a"} next-since=${run.window_end ?? "n/a"}`);
37
+ if (run.detail)
38
+ writeLine(door.io.stdout, ` ${run.detail}`);
39
+ }
40
+ return 0;
41
+ }
@@ -33,6 +33,9 @@
33
33
  * - `notes-accounts.ts` — BLI-4383: the notetaker a person connects, and the
34
34
  * meetings it records. `connect` reads the API key from STDIN and there is
35
35
  * no flag that would take one.
36
+ * - `notes-granola-status.ts` — ticket 10718: the Granola CRON's own health
37
+ * (it runs on a shared server-side key, not a `notes_accounts` row), read
38
+ * off its `granola_sync_runs` ledger.
36
39
  *
37
40
  * Every public name is still importable from this file.
38
41
  */
@@ -40,10 +43,12 @@ import { loadPairedSession } from "../tower-client.js";
40
43
  import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
41
44
  import { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
42
45
  import { runFolderCommand } from "./notes-folders.js";
43
- import { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, } from "./notes-accounts.js";
46
+ import { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, syncStatusNotetakers, } from "./notes-accounts.js";
47
+ import { showGranolaStatus } from "./notes-granola-status.js";
44
48
  export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
45
49
  export { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
46
- export { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, } from "./notes-accounts.js";
50
+ export { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, syncStatusNotetakers, } from "./notes-accounts.js";
51
+ export { showGranolaStatus } from "./notes-granola-status.js";
47
52
  export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
48
53
  export async function runNotes(command, io) {
49
54
  const session = await loadPairedSession("notes", command.homeDir);
@@ -91,7 +96,11 @@ export async function runNotes(command, io) {
91
96
  return detachNotetaker(command, door);
92
97
  case "sync":
93
98
  return syncNotetaker(command, door);
99
+ case "sync-status":
100
+ return syncStatusNotetakers(command, door);
94
101
  case "webhook":
95
102
  return armNotesWebhook(command, door);
103
+ case "granola-status":
104
+ return showGranolaStatus(command, door);
96
105
  }
97
106
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The daily-brief delivery sections `ops-render.ts` folds in (BLI-10728), its
3
+ * own module for the same reason `ops-render-spend.ts` and
4
+ * `ops-render-ladder.ts` have one: `ops-render.ts` sits at the repo's
5
+ * readability floor (`file-size-ratchet.test.ts`), and a board section is
6
+ * exactly the kind of thing that keeps growing.
7
+ *
8
+ * Two surfaces live here:
9
+ * - `renderBriefStreak` — the `BRIEF STREAK` line `cockpit ops status` folds
10
+ * into the whole-board render.
11
+ * - `renderMissedBrief` — the standalone answer `cockpit brief status
12
+ * --missed` prints: every roster person owed a brief who is missing one
13
+ * right now, and why.
14
+ *
15
+ * Both print sentences the SERVER wrote (`lib/ops/brief-delivery-streak.ts`,
16
+ * `lib/ops/brief-delivery-coverage.ts`); this file decides order and
17
+ * indentation and nothing else, the same rule every other `ops-render-*.ts`
18
+ * sibling keeps.
19
+ */
20
+ /**
21
+ * The daily-brief delivery streak (BLI-10728): consecutive most-recent
22
+ * cycles where every roster person owed a brief got a delivered receipt.
23
+ */
24
+ export function renderBriefStreak(streak, dim) {
25
+ const lines = ["", `BRIEF STREAK ${streak.streakDays ?? 0} day(s)`];
26
+ if (streak.detail)
27
+ lines.push(dim(` ${streak.detail}`));
28
+ return lines;
29
+ }
30
+ /**
31
+ * Everybody, not one person: who is missing a delivered receipt for their own
32
+ * window right now, and why. Mirrors `cockpit ops status`'s `jarvis-ic-dm`
33
+ * row, computed by the same `readBriefDeliveryCoverage`.
34
+ */
35
+ export function renderMissedBrief(payload, dim) {
36
+ const coverage = payload.coverage ?? {};
37
+ const lines = [`brief delivery — ${coverage.verdict ?? "unknown"}`];
38
+ if (coverage.detail)
39
+ lines.push(coverage.detail);
40
+ lines.push("");
41
+ const missed = coverage.missed ?? [];
42
+ if (missed.length === 0) {
43
+ lines.push("Nobody owed a brief is missing one right now.");
44
+ }
45
+ else {
46
+ lines.push(`${missed.length} missing:`);
47
+ for (const person of missed) {
48
+ lines.push(` ${(person.displayName ?? "?").padEnd(12)} ${person.missReason ?? "?"}`);
49
+ if (person.detail)
50
+ lines.push(dim(` ${person.detail}`));
51
+ }
52
+ }
53
+ lines.push("", dim(`delivered ${coverage.delivered ?? 0} · no page shape ${coverage.noPageShape ?? 0} · ` +
54
+ `not yet due ${coverage.notOwed ?? 0} · unreadable ${coverage.unreadable ?? 0}`));
55
+ return lines;
56
+ }
@@ -20,8 +20,15 @@ import { renderSpend } from "./ops-render-spend.js";
20
20
  // coverage table and the spend section have one: this file is at the repo's
21
21
  // readability floor.
22
22
  import { renderLadder } from "./ops-render-ladder.js";
23
+ // BLI-10728: the brief-delivery streak and the standalone `--missed` answer,
24
+ // their own module for the same reason — this file is at the repo's
25
+ // readability floor. Only `renderBriefStreak` is called from here;
26
+ // `renderMissedBrief` is re-exported below for `brief.ts` without a local
27
+ // import that `noUnusedLocals` would then refuse.
28
+ import { renderBriefStreak } from "./ops-render-brief.js";
23
29
  export { renderSpend } from "./ops-render-spend.js";
24
30
  export { renderLadder } from "./ops-render-ladder.js";
31
+ export { renderBriefStreak, renderMissedBrief } from "./ops-render-brief.js";
25
32
  export { renderMemoryHooks, renderMemoryUsage } from "./ops-render-memory.js";
26
33
  export { renderCoverageBuckets };
27
34
  /** The word a person reads. Short, fixed width, and never a bare colour. */
@@ -141,6 +148,8 @@ options = {}) {
141
148
  lines.push(...renderSpend(payload.spend, dim));
142
149
  if (payload.ladder)
143
150
  lines.push(...renderLadder(payload.ladder, dim));
151
+ if (payload.briefStreak)
152
+ lines.push(...renderBriefStreak(payload.briefStreak, dim));
144
153
  const slack = payload.skips?.slack;
145
154
  const external = payload.skips?.external;
146
155
  if (slack || external) {
@@ -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.146");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.148");
25
25
  return 0;
26
26
  }
27
27
 
@@ -7,6 +7,8 @@
7
7
  * unreported session out of the live window.
8
8
  */
9
9
  import { describeError } from "../health-detail.js";
10
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
11
+ import { normalizeDashboardUrl } from "../upload-http.js";
10
12
  import { flushPendingCodexSessionReports, queueCodexSessionReport, } from "../upload.js";
11
13
  import { CLAUDE_CURSOR_FILENAME, countStaleSessions, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
12
14
  import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
@@ -24,6 +26,7 @@ export async function reportSessionsAndAdvanceCursors(options) {
24
26
  const hadPendingSessionReports = (options.plan.uploadSpool?.pending_session_reports.length ?? 0) > 0;
25
27
  const queuedCurrentSessionReport = await queueSessionReportForDelivery({
26
28
  homeDir: options.run.homeDir,
29
+ dashboardUrl: options.run.dashboardUrl,
27
30
  outcomes: options.outcomes,
28
31
  sessions: options.sessions,
29
32
  now: options.now,
@@ -51,6 +54,29 @@ export async function reportSessionsAndAdvanceCursors(options) {
51
54
  ...staleCounts,
52
55
  };
53
56
  }
57
+ /**
58
+ * BLI-10734 (Edward, 2026-09-17: "roots are labels, not gates"). A machine
59
+ * whose observed sessions all sit outside every approved root never syncs a
60
+ * worktree, so there is no real repo to attach the report to — but the
61
+ * sessions were still observed, and withholding the whole report here (rather
62
+ * than the raw evidence, which stays root-gated) was the exact silent 100%
63
+ * gap the ruling named: on 2026-09-17, Vee's machine had 1,594 of 1,594 local
64
+ * sessions withheld and Savina's 283 of 285, both entirely by this branch
65
+ * never firing. This identity is deliberately synthetic and never borrows a
66
+ * real repo's fingerprint — a report for genuinely unrelated work must never
67
+ * land labelled as belonging to some other repo this machine happens to have
68
+ * approved.
69
+ */
70
+ const NO_WORKTREE_REPORT_IDENTITY = {
71
+ workContextId: "outside-approved-root",
72
+ repoLabel: "outside-approved-root",
73
+ branch: "unknown",
74
+ repoFingerprint: "outside-approved-root",
75
+ repoOriginUrl: null,
76
+ worktreeLabel: "outside-approved-root",
77
+ worktreeFingerprint: "outside-approved-root",
78
+ worktreeIsPrimary: false,
79
+ };
54
80
  /**
55
81
  * Persist the metadata-only session report to the spool, before either source
56
82
  * cursor advances.
@@ -59,27 +85,56 @@ export async function reportSessionsAndAdvanceCursors(options) {
59
85
  * If queueing itself fails, the cursors remain at their prior retryable
60
86
  * positions instead of aging an unreported session out of the live window.
61
87
  * Returns whether a report for THIS tick was queued.
88
+ *
89
+ * A report is owed whenever this tick observed ANY session, worktree upload
90
+ * or not (BLI-10734): a worktree that did upload still carries its real repo
91
+ * identity; a tick with no worktree outcome at all falls back to the
92
+ * synthetic one above rather than dropping every session it saw.
62
93
  */
63
94
  async function queueSessionReportForDelivery(options) {
64
- const firstUploaded = options.outcomes.find((outcome) => outcome.sync.status === "uploaded");
65
- if (!firstUploaded || options.sessions.length === 0)
95
+ if (options.sessions.length === 0)
66
96
  return false;
67
- const queued = await queueCodexSessionReport({
68
- homeDir: options.homeDir,
69
- dashboardUrl: firstUploaded.sync.dashboard_url,
70
- generatedAt: options.now.toISOString(),
71
- workContextId: firstUploaded.sync.work_context_id,
72
- repoLabel: firstUploaded.worktree.repo_label,
73
- branch: firstUploaded.worktree.branch,
74
- repoFingerprint: firstUploaded.worktree.repo_fingerprint,
75
- repoOriginUrl: firstUploaded.worktree.repo_origin_url,
76
- worktreeLabel: firstUploaded.worktree.worktree_label,
77
- worktreeFingerprint: firstUploaded.worktree.worktree_fingerprint,
78
- worktreeIsPrimary: firstUploaded.worktree.worktree_is_primary,
79
- sessions: options.sessions,
80
- });
97
+ const firstUploaded = options.outcomes.find((outcome) => outcome.sync.status === "uploaded");
98
+ const queued = await queueCodexSessionReport(firstUploaded
99
+ ? {
100
+ homeDir: options.homeDir,
101
+ dashboardUrl: firstUploaded.sync.dashboard_url,
102
+ generatedAt: options.now.toISOString(),
103
+ workContextId: firstUploaded.sync.work_context_id,
104
+ repoLabel: firstUploaded.worktree.repo_label,
105
+ branch: firstUploaded.worktree.branch,
106
+ repoFingerprint: firstUploaded.worktree.repo_fingerprint,
107
+ repoOriginUrl: firstUploaded.worktree.repo_origin_url,
108
+ worktreeLabel: firstUploaded.worktree.worktree_label,
109
+ worktreeFingerprint: firstUploaded.worktree.worktree_fingerprint,
110
+ worktreeIsPrimary: firstUploaded.worktree.worktree_is_primary,
111
+ sessions: options.sessions,
112
+ }
113
+ : {
114
+ homeDir: options.homeDir,
115
+ dashboardUrl: await resolveFallbackDashboardUrl(options.homeDir, options.dashboardUrl),
116
+ generatedAt: options.now.toISOString(),
117
+ ...NO_WORKTREE_REPORT_IDENTITY,
118
+ sessions: options.sessions,
119
+ });
81
120
  return Boolean(queued);
82
121
  }
122
+ /**
123
+ * Where to file a report with no worktree to read a dashboard URL off of: what
124
+ * the operator typed this tick, else the paired session's own dashboard, else
125
+ * the saved config, else the public default — the same fallback chain
126
+ * `upload-envelope-build.ts` already resolves for every other envelope.
127
+ */
128
+ async function resolveFallbackDashboardUrl(homeDir, explicit) {
129
+ if (explicit)
130
+ return normalizeDashboardUrl(explicit);
131
+ const paths = getCollectorRuntimePaths(homeDir);
132
+ const [sessionFile, config] = await Promise.all([
133
+ readLocalCollectorSessionFile(paths).catch(() => null),
134
+ readLocalCollectorConfig(paths).catch(() => null),
135
+ ]);
136
+ return normalizeDashboardUrl(sessionFile?.dashboard_url ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL);
137
+ }
83
138
  /**
84
139
  * Advance both source cursors to what this tick observed.
85
140
  *
package/dist/root-work.js CHANGED
@@ -23,6 +23,30 @@ export function workDirectories(results, platform = process.platform) {
23
23
  export function outsideWorkDirectories(scan, roots, platform = process.platform) {
24
24
  return scan.directories.filter(({ directory }) => !roots.some(root => containsPath(root, directory, rootPathApi(platform))));
25
25
  }
26
+ /**
27
+ * The window `commands/local-discovery.ts`'s `scanWorkDirectories` scans for
28
+ * this machine's own work-directory evidence, named once so the coverage
29
+ * sentence below can never drift from the window the scan it describes
30
+ * actually covers (ticket 10727).
31
+ */
32
+ export const ROOT_WORK_SCAN_WINDOW_DAYS = 14;
33
+ /**
34
+ * BLI-10734 (Edward, 2026-09-17: "roots are labels, not gates"), landed on
35
+ * top of the measurement ticket 10727 first shipped this function. The
36
+ * doctor sentence a person actually asked for: "N of M local sessions on the
37
+ * server". Every session this scan observed now reaches the server — inside
38
+ * an approved root unlabelled, outside one carrying `root_scope: "outside"`
39
+ * (session-first: approved roots are a label, never a gate,
40
+ * `docs/architecture/session-first-collection.md`) — so `outsideCount` here
41
+ * names how many carry that label, never how many were withheld. This is not
42
+ * a server-confirmed delivery receipt (that would need a network round trip);
43
+ * it is the honest local half of the claim, the same distinction
44
+ * `docs/runbooks/cockpit-launchd-sync.md` draws for every other coverage
45
+ * line.
46
+ */
47
+ export function sessionCoverageLine(scan, outsideCount) {
48
+ return `${scan.observed} of ${scan.observed} local sessions (last ${ROOT_WORK_SCAN_WINDOW_DAYS} days) reach the server; ${outsideCount} were outside an approved root and are uploaded with an outside label.`;
49
+ }
26
50
  export async function saveRootWorkScan(paths, results, now) {
27
51
  const scan = { generated_at: now.toISOString(), observed: results.length, directories: workDirectories(results) };
28
52
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.146",
3
+ "version": "0.2.148",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,8 +27,8 @@
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-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.36",
31
- "@bli-cockpit/mcp": "0.1.70",
32
- "@bli-cockpit/telemetry-core": "0.1.48"
30
+ "@bli-cockpit/memory-mcp": "0.1.37",
31
+ "@bli-cockpit/mcp": "0.1.71",
32
+ "@bli-cockpit/telemetry-core": "0.1.49"
33
33
  }
34
34
  }