@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.
- package/dist/commands/agent-session-report.js +2 -0
- package/dist/commands/agent-session-root-scope.js +19 -0
- package/dist/commands/brief.js +32 -1
- package/dist/commands/cal-sync-status.js +27 -0
- package/dist/commands/cal.js +3 -0
- package/dist/commands/docs-notion.js +15 -2
- package/dist/commands/doctor-access.js +20 -4
- package/dist/commands/local-args-tower-cal.js +9 -1
- package/dist/commands/local-args-tower-mail.js +9 -1
- package/dist/commands/local-args-tower-pages.js +15 -2
- package/dist/commands/local-discovery.js +2 -2
- package/dist/commands/local-help-commands-tower.js +13 -4
- package/dist/commands/mail.js +29 -0
- package/dist/commands/notes-accounts.js +27 -0
- package/dist/commands/notes-granola-status.js +41 -0
- package/dist/commands/notes.js +11 -2
- package/dist/commands/ops-render-brief.js +56 -0
- package/dist/commands/ops-render.js +9 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-record.js +71 -16
- package/dist/root-work.js +24 -0
- package/package.json +4 -4
|
@@ -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
|
+
}
|
package/dist/commands/brief.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/cal.js
CHANGED
|
@@ -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, {
|
|
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
|
-
/**
|
|
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
|
|
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:
|
|
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.",
|
package/dist/commands/mail.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/notes.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
65
|
-
if (!firstUploaded || options.sessions.length === 0)
|
|
95
|
+
if (options.sessions.length === 0)
|
|
66
96
|
return false;
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
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.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
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
|
}
|