@bli-cockpit/cli 0.2.236 → 0.2.238

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.
Files changed (143) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +15 -11
  2. package/dist/adapters/attribution-core-score.js +111 -87
  3. package/dist/adapters/car-state.js +38 -35
  4. package/dist/adapters/claude-attribution-discovery.js +81 -56
  5. package/dist/adapters/claude-attribution-score.js +108 -94
  6. package/dist/adapters/claude-attribution-signals.js +52 -42
  7. package/dist/adapters/codex-attribution.js +42 -33
  8. package/dist/adapters/codex-session-signals.js +11 -9
  9. package/dist/adapters/git-state.js +56 -50
  10. package/dist/adapters/local-sources.js +52 -43
  11. package/dist/adapters/raw-evidence-git-diff.js +4 -2
  12. package/dist/adapters/raw-evidence-sanitize.js +15 -12
  13. package/dist/adapters/raw-evidence-transcript-file.js +32 -31
  14. package/dist/autostart-self-heal.js +18 -2
  15. package/dist/commands/agent-session-report.js +51 -59
  16. package/dist/commands/agent-setup-observed.js +30 -23
  17. package/dist/commands/agent-setup.js +96 -45
  18. package/dist/commands/autostart-heal.js +8 -2
  19. package/dist/commands/autostart-reading.js +3 -0
  20. package/dist/commands/backfill-evidence-outcomes.js +9 -6
  21. package/dist/commands/backfill-issues.js +3 -0
  22. package/dist/commands/backfill-scan.js +27 -22
  23. package/dist/commands/backfill-upload.js +69 -60
  24. package/dist/commands/backfill.js +26 -21
  25. package/dist/commands/brief-done.js +4 -1
  26. package/dist/commands/brief-edit.js +71 -46
  27. package/dist/commands/cal-detail.js +35 -0
  28. package/dist/commands/cal-overlay.js +106 -0
  29. package/dist/commands/cal.js +40 -23
  30. package/dist/commands/clean.js +4 -1
  31. package/dist/commands/docs-read.js +5 -2
  32. package/dist/commands/doctor-backfill.js +88 -71
  33. package/dist/commands/doctor-pipeline.js +59 -46
  34. package/dist/commands/doctor-usage-telemetry.js +2 -1
  35. package/dist/commands/heartbeat-post.js +116 -0
  36. package/dist/commands/heartbeat-token-lock.js +46 -33
  37. package/dist/commands/heartbeat.js +43 -125
  38. package/dist/commands/install-update.js +17 -15
  39. package/dist/commands/issue-show.js +27 -28
  40. package/dist/commands/issue-write.js +70 -23
  41. package/dist/commands/issue.js +68 -37
  42. package/dist/commands/jarvis-live-answer.js +99 -0
  43. package/dist/commands/jarvis-render.js +1 -94
  44. package/dist/commands/jarvis-trace.js +30 -22
  45. package/dist/commands/jarvis-turn-report.js +124 -0
  46. package/dist/commands/jarvis-turn.js +108 -182
  47. package/dist/commands/jarvis.js +26 -17
  48. package/dist/commands/local-args-collector-setup.js +82 -84
  49. package/dist/commands/local-args-collector-work.js +37 -38
  50. package/dist/commands/local-args-tower-admin.js +155 -142
  51. package/dist/commands/local-args-tower-cal.js +54 -48
  52. package/dist/commands/local-args-tower-careers.js +73 -54
  53. package/dist/commands/local-args-tower-docs-msg.js +56 -188
  54. package/dist/commands/local-args-tower-github.js +26 -20
  55. package/dist/commands/local-args-tower-hits.js +32 -20
  56. package/dist/commands/local-args-tower-manager.js +85 -4
  57. package/dist/commands/local-args-tower-memory.js +139 -117
  58. package/dist/commands/local-args-tower-msg.js +149 -0
  59. package/dist/commands/local-args-tower-notes.js +346 -0
  60. package/dist/commands/local-args-tower-pages.js +100 -378
  61. package/dist/commands/local-args-tower-settings.js +28 -29
  62. package/dist/commands/local-args-tower-work.js +52 -33
  63. package/dist/commands/local-args.js +80 -129
  64. package/dist/commands/local-help-commands-tower-ops.js +15 -4
  65. package/dist/commands/local-help-commands-tower.js +11 -4
  66. package/dist/commands/local-help-commands.js +342 -341
  67. package/dist/commands/local-help.js +87 -83
  68. package/dist/commands/local-run-guards.js +100 -0
  69. package/dist/commands/local.js +137 -206
  70. package/dist/commands/login.js +13 -9
  71. package/dist/commands/logs.js +55 -38
  72. package/dist/commands/mail-attachments.js +44 -26
  73. package/dist/commands/mail.js +6 -4
  74. package/dist/commands/manager-render-home.js +208 -0
  75. package/dist/commands/manager-render.js +7 -3
  76. package/dist/commands/manager-requests.js +33 -0
  77. package/dist/commands/manager.js +39 -5
  78. package/dist/commands/memory-consolidation.js +18 -9
  79. package/dist/commands/memory-daemon-probe.js +37 -28
  80. package/dist/commands/memory-install-config.js +64 -60
  81. package/dist/commands/memory-install.js +21 -41
  82. package/dist/commands/memory-requeue.js +26 -19
  83. package/dist/commands/memory-rules.js +26 -18
  84. package/dist/commands/memory-search-print.js +26 -0
  85. package/dist/commands/memory-store.js +14 -30
  86. package/dist/commands/memory-verb-list.js +49 -48
  87. package/dist/commands/msg.js +51 -9
  88. package/dist/commands/notes-accounts.js +49 -32
  89. package/dist/commands/notes-sync-loop.js +115 -89
  90. package/dist/commands/notes-wisprflow-login.js +44 -23
  91. package/dist/commands/notes-writes.js +115 -100
  92. package/dist/commands/notes.js +33 -55
  93. package/dist/commands/onboard-flows.js +69 -51
  94. package/dist/commands/onboard.js +11 -9
  95. package/dist/commands/ops-render-memory.js +10 -7
  96. package/dist/commands/ops-render.js +33 -30
  97. package/dist/commands/ops.js +3 -0
  98. package/dist/commands/public-root.js +1 -1
  99. package/dist/commands/session-sync-outside-roots.js +40 -36
  100. package/dist/commands/session-sync-plan.js +14 -10
  101. package/dist/commands/session-sync-upload.js +24 -22
  102. package/dist/commands/session-sync.js +19 -8
  103. package/dist/commands/slack.js +25 -16
  104. package/dist/commands/sources-check.js +13 -7
  105. package/dist/commands/sources-discovery.js +25 -17
  106. package/dist/commands/sources-verdict.js +19 -18
  107. package/dist/commands/speed.js +23 -19
  108. package/dist/commands/start.js +20 -16
  109. package/dist/commands/sync-doctor.js +52 -48
  110. package/dist/commands/sync.js +22 -16
  111. package/dist/commands/usage-telemetry-toml.js +71 -45
  112. package/dist/commands/usage.js +91 -67
  113. package/dist/commands/workbook-render.js +10 -7
  114. package/dist/evidence-reconcile-client.js +75 -64
  115. package/dist/evidence-redelivery-plan.js +37 -34
  116. package/dist/evidence-redelivery.js +32 -29
  117. package/dist/evidence-transcripts-client.js +20 -20
  118. package/dist/evidence-upload-object.js +86 -89
  119. package/dist/evidence-upload-plan.js +94 -83
  120. package/dist/evidence-upload-terminal.js +45 -50
  121. package/dist/image-description-session.js +156 -159
  122. package/dist/local-state-pairing-code.js +39 -31
  123. package/dist/local-state-status.js +9 -2
  124. package/dist/onboarding-roots.js +5 -8
  125. package/dist/process-runner.js +8 -8
  126. package/dist/raw-evidence-gc.js +33 -38
  127. package/dist/raw-evidence-stuck-summary.js +7 -14
  128. package/dist/repo-identity.js +26 -23
  129. package/dist/scheduled-self-update.js +11 -15
  130. package/dist/second-install.js +3 -0
  131. package/dist/slim/codex-rules.js +23 -36
  132. package/dist/sync-lock.js +5 -15
  133. package/dist/tower-client.js +36 -53
  134. package/dist/tower-stream.js +7 -24
  135. package/dist/upload-agent-artifacts.js +58 -64
  136. package/dist/upload-envelope-build.js +60 -59
  137. package/dist/upload-evidence-delivery-reconcile.js +58 -56
  138. package/dist/upload-evidence-delivery-summary.js +32 -34
  139. package/dist/upload-ingest-receipt.js +20 -23
  140. package/dist/upload-session-reports-queue.js +4 -1
  141. package/dist/upload-session-reports-wire.js +64 -68
  142. package/dist/upload-sync.js +171 -157
  143. package/package.json +2 -2
@@ -19,115 +19,92 @@ export async function uploadNotes(command, door) {
19
19
  const results = [];
20
20
  let worstExit = 0;
21
21
  for (const filePath of paths) {
22
- // Read and screen locally BEFORE any network call — a refusal here never
23
- // reaches the dashboard, same discipline as `cockpit jarvis --image`.
24
- const read = await readNoteFile(filePath, { allowDirectAudio: true });
25
- if (!read.ok) {
26
- const sentence = noteFileRefusalSentence(read.refusal, filePath);
27
- writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: read.refusal, detail: read.detail })}`);
28
- if (!door.json)
29
- writeLine(door.io.stderr, sentence);
30
- results.push({ path: filePath, ok: false, reason: read.refusal });
31
- worstExit = 1;
32
- continue;
33
- }
34
- if (!read.direct && read.bytes.byteLength >= NOTE_SLOW_UPLOAD_BYTES) {
35
- // The route reads the whole note with one model call and may take
36
- // minutes. A still cursor reads as a hang, so say what is happening.
37
- writeLine(door.io.stderr, `Reading ${read.fileName} (${Math.round(read.bytes.byteLength / 1024)} KB). This can take a couple of minutes.`);
38
- }
39
- // A whole-meeting recording (over 4 MB) goes straight to storage and is
40
- // transcribed in the background (BLI-12963); everything else keeps the
41
- // upload route.
42
- let answer;
43
- if (read.direct) {
44
- const direct = await uploadAudioDirect(door, {
45
- filePath, fileName: read.fileName, size: read.size, exclude: command.exclude, folder: command.folder,
46
- });
47
- answer = direct.ok ? { ok: true, body: direct.body } : { ok: false, reason: direct.reason, detail: direct.detail };
48
- }
49
- else {
50
- const form = new FormData();
51
- form.set("file", new File([new Uint8Array(read.bytes)], read.fileName, { type: noteAudioMime(read.fileName) ?? "" }));
52
- if (command.exclude)
53
- form.set("exclusions", command.exclude);
54
- if (command.folder)
55
- form.set("folder_path", command.folder);
56
- answer = await ask(door, {
57
- path: "/api/notes/upload",
58
- method: "POST",
59
- label: "notes upload",
60
- timeoutMs: UPLOAD_DEADLINE_MS,
61
- body: form,
62
- });
63
- }
64
- if (!answer.ok) {
65
- writeLine(door.io.stderr, `${TAG} upload failed ${JSON.stringify({ reason: answer.reason })}`);
66
- if (!door.json)
67
- writeLine(door.io.stderr, `${read.fileName}: ${answer.detail}`);
68
- results.push({ path: filePath, ok: false, reason: answer.reason });
22
+ const uploaded = await uploadOneNote(command, door, filePath);
23
+ results.push(uploaded.result);
24
+ if (uploaded.failed)
69
25
  worstExit = 1;
70
- continue;
71
- }
72
- const body = answer.body;
73
- if (command.wait && body.noteId && body.transcription?.status === "pending") {
74
- body.transcription = await waitForTranscription(door, body.noteId, read.direct);
75
- }
76
- if (body.transcription?.status === "failed")
77
- worstExit = 1;
78
- if (!door.json && body.transcription)
79
- writeLine(door.io.stdout, `${body.noteId}: transcription ${body.transcription.status}${body.transcription.reason ? ": " + body.transcription.reason : ""}`);
80
- results.push({ path: filePath, ok: body.stored === true && body.transcription?.status !== "failed", body });
81
- if (body.stored !== true)
82
- worstExit = 1;
83
- if (!door.json)
84
- sayUpload(door, body);
85
- writeLine(door.io.stderr, `${TAG} upload answered ${JSON.stringify({
86
- stored: body.stored === true,
87
- note_id: body.noteId ?? null,
88
- scope: body.scope ?? null,
89
- byte_size: read.size,
90
- direct: read.direct,
91
- extension: read.extension,
92
- })}`);
93
26
  }
94
27
  if (door.json) {
95
28
  emit(door, { ok: worstExit === 0, uploaded: results.length, results });
96
29
  }
97
30
  return worstExit;
98
31
  }
99
- export async function pasteNote(command, door) {
100
- let text;
101
- if (command.filePath) {
102
- const read = await readNoteFile(command.filePath);
103
- if (!read.ok) {
104
- const sentence = noteFileRefusalSentence(read.refusal, command.filePath);
105
- writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: read.refusal, detail: read.detail })}`);
106
- return fail(door, read.refusal, sentence);
107
- }
108
- const decoded = decodeTextBytes(read.bytes);
109
- if (!decoded.ok) {
110
- writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: decoded.reason })}`);
111
- return fail(door, decoded.reason, "That file is not text this command can decode (it may be a binary or an unusual encoding). Nothing was sent.");
112
- }
113
- text = decoded.text;
32
+ async function uploadOneNote(command, door, filePath) {
33
+ // Read and screen locally BEFORE any network call — a refusal here never
34
+ // reaches the dashboard, same discipline as `cockpit jarvis --image`.
35
+ const read = await readNoteFile(filePath, { allowDirectAudio: true });
36
+ if (!read.ok) {
37
+ const sentence = noteFileRefusalSentence(read.refusal, filePath);
38
+ writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: read.refusal, detail: read.detail })}`);
39
+ if (!door.json)
40
+ writeLine(door.io.stderr, sentence);
41
+ return { result: { path: filePath, ok: false, reason: read.refusal }, failed: true };
114
42
  }
115
- else {
116
- if (isInteractiveStdin(door.io)) {
117
- return fail(door, "nothing_piped", "Pass --file <path> (safest on Windows), or pipe the note in "
118
- + "(`pbpaste | cockpit notes paste` on macOS; on Windows use PowerShell 7 — "
119
- + "Windows PowerShell 5.1 turns non-ASCII into `?` on pipes).");
120
- }
121
- try {
122
- text = await readPipedText(door.io.stdin, {
123
- maxChars: PASTE_MAX_CHARS,
124
- overflowMessage: `A pasted note is limited to ${PASTE_MAX_CHARS} characters. Save it to a file and use --file instead.`,
125
- });
126
- }
127
- catch (error) {
128
- return fail(door, "paste_too_long", errorText(error));
129
- }
43
+ if (!read.direct && read.bytes.byteLength >= NOTE_SLOW_UPLOAD_BYTES) {
44
+ // The route reads the whole note with one model call and may take
45
+ // minutes. A still cursor reads as a hang, so say what is happening.
46
+ writeLine(door.io.stderr, `Reading ${read.fileName} (${Math.round(read.bytes.byteLength / 1024)} KB). This can take a couple of minutes.`);
47
+ }
48
+ const answer = await sendNoteFile(command, door, filePath, read);
49
+ if (!answer.ok) {
50
+ writeLine(door.io.stderr, `${TAG} upload failed ${JSON.stringify({ reason: answer.reason })}`);
51
+ if (!door.json)
52
+ writeLine(door.io.stderr, `${read.fileName}: ${answer.detail}`);
53
+ return { result: { path: filePath, ok: false, reason: answer.reason }, failed: true };
54
+ }
55
+ const body = answer.body;
56
+ if (command.wait && body.noteId && body.transcription?.status === "pending") {
57
+ body.transcription = await waitForTranscription(door, body.noteId, read.direct);
58
+ }
59
+ let failed = body.transcription?.status === "failed";
60
+ if (!door.json && body.transcription)
61
+ writeLine(door.io.stdout, `${body.noteId}: transcription ${body.transcription.status}${body.transcription.reason ? ": " + body.transcription.reason : ""}`);
62
+ const result = { path: filePath, ok: body.stored === true && body.transcription?.status !== "failed", body };
63
+ if (body.stored !== true)
64
+ failed = true;
65
+ if (!door.json)
66
+ sayUpload(door, body);
67
+ writeLine(door.io.stderr, `${TAG} upload answered ${JSON.stringify({
68
+ stored: body.stored === true,
69
+ note_id: body.noteId ?? null,
70
+ scope: body.scope ?? null,
71
+ byte_size: read.size,
72
+ direct: read.direct,
73
+ extension: read.extension,
74
+ })}`);
75
+ return { result, failed };
76
+ }
77
+ /**
78
+ * A whole-meeting recording (over 4 MB) goes straight to storage and is
79
+ * transcribed in the background (BLI-12963); everything else keeps the
80
+ * upload route.
81
+ */
82
+ async function sendNoteFile(command, door, filePath, read) {
83
+ if (read.direct) {
84
+ const direct = await uploadAudioDirect(door, {
85
+ filePath, fileName: read.fileName, size: read.size, exclude: command.exclude, folder: command.folder,
86
+ });
87
+ return direct.ok ? { ok: true, body: direct.body } : { ok: false, reason: direct.reason, detail: direct.detail };
130
88
  }
89
+ const form = new FormData();
90
+ form.set("file", new File([new Uint8Array(read.bytes)], read.fileName, { type: noteAudioMime(read.fileName) ?? "" }));
91
+ if (command.exclude)
92
+ form.set("exclusions", command.exclude);
93
+ if (command.folder)
94
+ form.set("folder_path", command.folder);
95
+ return await ask(door, {
96
+ path: "/api/notes/upload",
97
+ method: "POST",
98
+ label: "notes upload",
99
+ timeoutMs: UPLOAD_DEADLINE_MS,
100
+ body: form,
101
+ });
102
+ }
103
+ export async function pasteNote(command, door) {
104
+ const pasted = command.filePath ? await readPasteFromFile(door, command.filePath) : await readPasteFromStdin(door);
105
+ if ("exitCode" in pasted)
106
+ return pasted.exitCode;
107
+ const text = pasted.text;
131
108
  if (text.trim() === "") {
132
109
  return fail(door, "empty_paste", "There was nothing to paste. Nothing was sent.");
133
110
  }
@@ -171,6 +148,44 @@ export async function pasteNote(command, door) {
171
148
  sayUpload(door, body);
172
149
  return body.stored === true ? 0 : 1;
173
150
  }
151
+ /** The note text from `--file`, or the exit code of the refusal already written. */
152
+ async function readPasteFromFile(door, filePath) {
153
+ const read = await readNoteFile(filePath);
154
+ if (!read.ok) {
155
+ const sentence = noteFileRefusalSentence(read.refusal, filePath);
156
+ writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: read.refusal, detail: read.detail })}`);
157
+ return { exitCode: fail(door, read.refusal, sentence) };
158
+ }
159
+ const decoded = decodeTextBytes(read.bytes);
160
+ if (!decoded.ok) {
161
+ writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: decoded.reason })}`);
162
+ return {
163
+ exitCode: fail(door, decoded.reason, "That file is not text this command can decode (it may be a binary or an unusual encoding). Nothing was sent."),
164
+ };
165
+ }
166
+ return { text: decoded.text };
167
+ }
168
+ /** The note text piped on stdin, or the exit code of the refusal already written. */
169
+ async function readPasteFromStdin(door) {
170
+ if (isInteractiveStdin(door.io)) {
171
+ return {
172
+ exitCode: fail(door, "nothing_piped", "Pass --file <path> (safest on Windows), or pipe the note in "
173
+ + "(`pbpaste | cockpit notes paste` on macOS; on Windows use PowerShell 7 — "
174
+ + "Windows PowerShell 5.1 turns non-ASCII into `?` on pipes)."),
175
+ };
176
+ }
177
+ try {
178
+ return {
179
+ text: await readPipedText(door.io.stdin, {
180
+ maxChars: PASTE_MAX_CHARS,
181
+ overflowMessage: `A pasted note is limited to ${PASTE_MAX_CHARS} characters. Save it to a file and use --file instead.`,
182
+ }),
183
+ };
184
+ }
185
+ catch (error) {
186
+ return { exitCode: fail(door, "paste_too_long", errorText(error)) };
187
+ }
188
+ }
174
189
  export async function shareNote(command, door) {
175
190
  const wantsToShare = command.action === "share";
176
191
  // Only sharing asks. Taking a note back narrows who can read it, and nobody
@@ -57,6 +57,38 @@ export { showGranolaStatus } from "./notes-granola-status.js";
57
57
  export { showTranscriptionStats } from "./notes-transcription-stats.js";
58
58
  export { listRecordings, showRecording } from "./notes-recordings.js";
59
59
  export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
60
+ /** Which function answers each `cockpit notes <verb>`; typed over every action so a new one cannot be forgotten. */
61
+ const NOTES_VERBS = {
62
+ folders: runFolderCommand,
63
+ mkdir: runFolderCommand,
64
+ rmdir: runFolderCommand,
65
+ rename: runFolderCommand,
66
+ list: listNotes,
67
+ shelves: listShelves,
68
+ show: showNote,
69
+ shelf: showShelf,
70
+ upload: uploadNotes,
71
+ paste: pasteNote,
72
+ share: shareNote,
73
+ unshare: shareNote,
74
+ summarize: summarizeNote,
75
+ move: moveNote,
76
+ place: placeNote,
77
+ // BLI-4394. `--show-webhook <id>` turns the list into one account's
78
+ // webhook URL, because that is the question a person asks WHILE looking
79
+ // at the list rather than a verb of its own.
80
+ accounts: (command, door) => (command.showWebhook ? showNotesWebhook(command, door) : listNotesAccounts(command, door)),
81
+ connect: connectNotetaker,
82
+ detach: detachNotetaker,
83
+ sync: syncNotetaker,
84
+ "sync-status": syncStatusNotetakers,
85
+ recordings: listRecordings,
86
+ recording: showRecording,
87
+ webhook: armNotesWebhook,
88
+ "bot-upcoming": showBotUpcoming,
89
+ "granola-status": showGranolaStatus,
90
+ "transcription-stats": showTranscriptionStats,
91
+ };
60
92
  export async function runNotes(command, io) {
61
93
  const session = await loadPairedSession("notes", command.homeDir);
62
94
  const door = {
@@ -65,59 +97,5 @@ export async function runNotes(command, io) {
65
97
  io,
66
98
  json: command.json,
67
99
  };
68
- switch (command.action) {
69
- case "folders":
70
- case "mkdir":
71
- case "rmdir":
72
- case "rename":
73
- return runFolderCommand(command, door);
74
- case "list":
75
- return listNotes(command, door);
76
- case "shelves":
77
- return listShelves(command, door);
78
- case "show":
79
- return showNote(command, door);
80
- case "shelf":
81
- return showShelf(command, door);
82
- case "upload":
83
- return uploadNotes(command, door);
84
- case "paste":
85
- return pasteNote(command, door);
86
- case "share":
87
- case "unshare":
88
- return shareNote(command, door);
89
- case "summarize":
90
- return summarizeNote(command, door);
91
- case "move":
92
- return moveNote(command, door);
93
- case "place":
94
- return placeNote(command, door);
95
- case "accounts":
96
- // BLI-4394. `--show-webhook <id>` turns the list into one account's
97
- // webhook URL, because that is the question a person asks WHILE looking
98
- // at the list rather than a verb of its own.
99
- return command.showWebhook
100
- ? showNotesWebhook(command, door)
101
- : listNotesAccounts(command, door);
102
- case "connect":
103
- return connectNotetaker(command, door);
104
- case "detach":
105
- return detachNotetaker(command, door);
106
- case "sync":
107
- return syncNotetaker(command, door);
108
- case "sync-status":
109
- return syncStatusNotetakers(command, door);
110
- case "recordings":
111
- return listRecordings(command, door);
112
- case "recording":
113
- return showRecording(command, door);
114
- case "webhook":
115
- return armNotesWebhook(command, door);
116
- case "bot-upcoming":
117
- return showBotUpcoming(command, door);
118
- case "granola-status":
119
- return showGranolaStatus(command, door);
120
- case "transcription-stats":
121
- return showTranscriptionStats(command, door);
122
- }
100
+ return NOTES_VERBS[command.action](command, door);
123
101
  }
@@ -8,7 +8,7 @@ import { runAttributedWorktreeSync, } from "./session-sync.js";
8
8
  import { inspectLocalCollectorStatus, startLocalWorkContext, } from "../local-state.js";
9
9
  /** The parent-folder arm: sync every discovered worktree, then backfill and finish setup once. */
10
10
  export async function runOnboardMultiRepoFlow(ctx) {
11
- const { command, resolvedCommand, collectionRoots, rootsResult, install, pair, installEvents, io, worktrees, } = ctx;
11
+ const { command, resolvedCommand, collectionRoots, installEvents, io, worktrees, } = ctx;
12
12
  const multi = await runMultiRepoOnboard(resolvedCommand, io, worktrees);
13
13
  addInstallEvent(installEvents, "work_context", "ok");
14
14
  addInstallEvent(installEvents, "sync", multi.ok ? "ok" : "fail", multi.ok ? undefined : "sync_blocked");
@@ -23,16 +23,7 @@ export async function runOnboardMultiRepoFlow(ctx) {
23
23
  ? undefined
24
24
  : backfill.failureReason ?? "backfill_incomplete");
25
25
  if (!completedBackfill) {
26
- writeOnboardBackfillBlockedResult(io, {
27
- json: command.json,
28
- base: onboardJsonPayload("blocked", resolvedCommand, install, pair, null, null, rootsResult),
29
- extra: {
30
- mode: "multi_repo",
31
- repos: multi.results,
32
- codex_sessions: multi.codex_sessions,
33
- },
34
- backfill,
35
- });
26
+ reportMultiRepoBackfillBlocked(ctx, multi, backfill);
36
27
  return finishOnboardRun(command, installEvents, io, 1);
37
28
  }
38
29
  const multiSetup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
@@ -43,9 +34,48 @@ export async function runOnboardMultiRepoFlow(ctx) {
43
34
  });
44
35
  return finishOnboardRun(command, installEvents, io, multiSetup.ok ? 0 : 1);
45
36
  }
37
+ function reportMultiRepoBackfillBlocked(ctx, multi, backfill) {
38
+ const { command, resolvedCommand, rootsResult, install, pair, io } = ctx;
39
+ writeOnboardBackfillBlockedResult(io, {
40
+ json: command.json,
41
+ base: onboardJsonPayload("blocked", resolvedCommand, install, pair, null, null, rootsResult),
42
+ extra: {
43
+ mode: "multi_repo",
44
+ repos: multi.results,
45
+ codex_sessions: multi.codex_sessions,
46
+ },
47
+ backfill,
48
+ });
49
+ }
46
50
  /** The single-repo arm: start the work context, sync it, backfill, then finish setup once. */
47
51
  export async function runOnboardSingleRepoFlow(ctx, single) {
48
- const { command, resolvedCommand, collectionRoots, rootsResult, install, pair, installEvents, io, worktrees, } = ctx;
52
+ const { command, resolvedCommand, rootsResult, install, pair, installEvents, io } = ctx;
53
+ const { run, sync, status, worktreeRoot } = await startContextAndSync(ctx, single);
54
+ if (!run.ok || sync.status !== "uploaded") {
55
+ addInstallEvent(installEvents, "sync", "fail", "sync_blocked");
56
+ reportOnboardSyncBlocked(ctx, { run, sync, status, worktreeRoot });
57
+ return finishOnboardRun(command, installEvents, io, 1);
58
+ }
59
+ addInstallEvent(installEvents, "sync", "ok");
60
+ const backfill = await runRequiredOnboardBackfill(resolvedCommand, io);
61
+ const completedBackfill = backfill.status === "complete" ? backfill.result : null;
62
+ addInstallEvent(installEvents, "backfill", completedBackfill ? "ok" : "fail", completedBackfill
63
+ ? undefined
64
+ : backfill.failureReason ?? "backfill_incomplete");
65
+ if (!completedBackfill) {
66
+ writeOnboardBackfillBlockedResult(io, {
67
+ json: command.json,
68
+ base: onboardJsonPayload("blocked", resolvedCommand, install, pair, sync, status, rootsResult),
69
+ extra: { codex_sessions: run.summary },
70
+ backfill,
71
+ });
72
+ return finishOnboardRun(command, installEvents, io, 1);
73
+ }
74
+ return await finishSingleRepoSetup(ctx, { run, sync, status, completedBackfill });
75
+ }
76
+ /** Work context started, the one worktree synced, and the collector's own reading of the repo. */
77
+ async function startContextAndSync(ctx, single) {
78
+ const { command, installEvents, io, worktrees } = ctx;
49
79
  const worktreeRoot = worktrees[0]?.repo_root ?? single.primaryRoot;
50
80
  const context = await startLocalWorkContext({
51
81
  homeDir: command.homeDir,
@@ -74,26 +104,12 @@ export async function runOnboardSingleRepoFlow(ctx, single) {
74
104
  repoRoot: worktreeRoot,
75
105
  branch: command.branch,
76
106
  });
77
- if (!run.ok || sync.status !== "uploaded") {
78
- addInstallEvent(installEvents, "sync", "fail", "sync_blocked");
79
- reportOnboardSyncBlocked(ctx, { run, sync, status, worktreeRoot });
80
- return finishOnboardRun(command, installEvents, io, 1);
81
- }
82
- addInstallEvent(installEvents, "sync", "ok");
83
- const backfill = await runRequiredOnboardBackfill(resolvedCommand, io);
84
- const completedBackfill = backfill.status === "complete" ? backfill.result : null;
85
- addInstallEvent(installEvents, "backfill", completedBackfill ? "ok" : "fail", completedBackfill
86
- ? undefined
87
- : backfill.failureReason ?? "backfill_incomplete");
88
- if (!completedBackfill) {
89
- writeOnboardBackfillBlockedResult(io, {
90
- json: command.json,
91
- base: onboardJsonPayload("blocked", resolvedCommand, install, pair, sync, status, rootsResult),
92
- extra: { codex_sessions: run.summary },
93
- backfill,
94
- });
95
- return finishOnboardRun(command, installEvents, io, 1);
96
- }
107
+ return { run, sync, status, worktreeRoot };
108
+ }
109
+ /** Backfill is complete: finish setup once, and say it as JSON or as the narrative. */
110
+ async function finishSingleRepoSetup(ctx, facts) {
111
+ const { command, resolvedCommand, collectionRoots, pair, installEvents, io } = ctx;
112
+ const { run, sync, status, completedBackfill } = facts;
97
113
  if (command.json) {
98
114
  const jsonSetup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
99
115
  writeOnboardSingleRepoResultJson(ctx, {
@@ -141,25 +157,27 @@ async function runMultiRepoOnboard(command, io, worktrees) {
141
157
  fetchImpl: io.fetch,
142
158
  });
143
159
  const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
160
+ if (!command.json)
161
+ writeMultiRepoSyncReport(io, command, run, results);
162
+ return { ok: run.ok, results, codex_sessions: run.summary };
163
+ }
164
+ function writeMultiRepoSyncReport(io, command, run, results) {
144
165
  const collectionRunStatus = attributedSyncRunStatus(run);
145
- if (!command.json) {
146
- for (const [index, outcome] of run.outcomes.entries()) {
147
- const row = results[index];
148
- if (!row)
149
- continue;
150
- const uploaded = outcome.sync.status === "uploaded";
151
- writeLine(uploaded ? io.stdout : io.stderr, `${uploaded ? "SYNCED" : "BLOCKED"} ${row["repo_label"]}/${row["worktree_label"]} (${row["branch"]}) head:${shortSha(outcome.sync.head_sha ?? outcome.worktree.head_sha)} ${outcome.sync.status} objects:${outcome.sync.raw_evidence_uploaded_object_count} chunks:${outcome.sync.raw_evidence_uploaded_chunk_count} reused:${outcome.sync.raw_evidence_reused_count} failed:${outcome.sync.raw_evidence_failed_count} sessions:${row["attributed_session_count"]}${row["failure_reason"] ? ` reason:${row["failure_reason"]}` : ""}`);
152
- }
153
- writeLine(io.stdout, "4/5 Parent worktree sync complete.");
154
- writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
155
- writeAgentSessionSummary(io, run.summary);
156
- if (run.ok) {
157
- writeLine(io.stdout, "Live sync receipts complete; verifying all-history Codex and Claude backfill.");
158
- }
159
- else {
160
- writeLine(io.stderr, `BLOCKED: Tower collection is ${collectionRunStatus}; retry until every eligible session has a durable receipt or explicit terminal reason.`);
161
- }
162
- writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
166
+ for (const [index, outcome] of run.outcomes.entries()) {
167
+ const row = results[index];
168
+ if (!row)
169
+ continue;
170
+ const uploaded = outcome.sync.status === "uploaded";
171
+ writeLine(uploaded ? io.stdout : io.stderr, `${uploaded ? "SYNCED" : "BLOCKED"} ${row["repo_label"]}/${row["worktree_label"]} (${row["branch"]}) head:${shortSha(outcome.sync.head_sha ?? outcome.worktree.head_sha)} ${outcome.sync.status} objects:${outcome.sync.raw_evidence_uploaded_object_count} chunks:${outcome.sync.raw_evidence_uploaded_chunk_count} reused:${outcome.sync.raw_evidence_reused_count} failed:${outcome.sync.raw_evidence_failed_count} sessions:${row["attributed_session_count"]}${row["failure_reason"] ? ` reason:${row["failure_reason"]}` : ""}`);
163
172
  }
164
- return { ok: run.ok, results, codex_sessions: run.summary };
173
+ writeLine(io.stdout, "4/5 Parent worktree sync complete.");
174
+ writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
175
+ writeAgentSessionSummary(io, run.summary);
176
+ if (run.ok) {
177
+ writeLine(io.stdout, "Live sync receipts complete; verifying all-history Codex and Claude backfill.");
178
+ }
179
+ else {
180
+ writeLine(io.stderr, `BLOCKED: Tower collection is ${collectionRunStatus}; retry until every eligible session has a durable receipt or explicit terminal reason.`);
181
+ }
182
+ writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
165
183
  }
@@ -16,9 +16,7 @@ export async function runOnboard(command, io) {
16
16
  let install = null;
17
17
  let pair = null;
18
18
  let rootsResult = null;
19
- // `sync` has to survive a throw from inside either flow function, so it is
20
- // mutated through this holder rather than returned — a return only happens
21
- // on the success path, and the failure path needs the value too.
19
+ // Both failure paths need the last sync value, even when a flow throws.
22
20
  const syncHolder = {
23
21
  current: null,
24
22
  };
@@ -30,12 +28,7 @@ export async function runOnboard(command, io) {
30
28
  const claimedOwnerEmail = await resolveOnboardEmail(roots.resolvedCommand, roots.collectionRoots, roots.existingConfig, io);
31
29
  install = await installOnboardConfig(command, roots.resolvedRoots, installEvents, io);
32
30
  pair = await pairForOnboarding(command, { primaryRoot: roots.primaryRoot, claimedOwnerEmail }, installEvents, io);
33
- const worktrees = await discoverCommandWorktrees(roots.collectionRoots, {
34
- maxDepth: command.maxDepth,
35
- maxRepos: command.maxRepos,
36
- homeDir: command.homeDir,
37
- allowEmpty: true,
38
- }, io);
31
+ const worktrees = await discoverOnboardWorktrees(command, roots, io);
39
32
  const ctx = {
40
33
  command,
41
34
  resolvedCommand: roots.resolvedCommand,
@@ -59,4 +52,13 @@ export async function runOnboard(command, io) {
59
52
  catch (error) {
60
53
  return await handleOnboardFailure(error, command, rootsResult, install, pair, syncHolder.current, installEvents, io);
61
54
  }
55
+ }
56
+ async function discoverOnboardWorktrees(command, roots, io) {
57
+ const worktrees = await discoverCommandWorktrees(roots.collectionRoots, {
58
+ maxDepth: command.maxDepth,
59
+ maxRepos: command.maxRepos,
60
+ homeDir: command.homeDir,
61
+ allowEmpty: true,
62
+ }, io);
63
+ return worktrees;
62
64
  }
@@ -49,6 +49,16 @@ export function renderMemoryUsage(memory, dim) {
49
49
  return leftOrphan - rightOrphan;
50
50
  return agentSaves(right.counts) - agentSaves(left.counts);
51
51
  });
52
+ appendMemoryPeople(lines, ranked, days, nameWidth, dim);
53
+ if (memory.truncated) {
54
+ lines.push(dim(" this read stopped at its page cap, so every number above is a FLOOR — it can understate use and never invent it"));
55
+ }
56
+ if (memory.searches?.counted === false) {
57
+ lines.push(dim(` searches are not counted (${memory.searches.reason ?? "no ledger"}): nothing in Tower records one, so this is saves only`));
58
+ }
59
+ return lines;
60
+ }
61
+ function appendMemoryPeople(lines, ranked, days, nameWidth, dim) {
52
62
  for (const person of ranked) {
53
63
  const byDay = new Map((person.days ?? []).map((entry) => [entry.day ?? "", entry]));
54
64
  const cells = days
@@ -66,13 +76,6 @@ export function renderMemoryUsage(memory, dim) {
66
76
  `${String(counts.import ?? 0).padStart(5)}`;
67
77
  lines.push(agentSaves(counts) > 0 ? row : dim(row));
68
78
  }
69
- if (memory.truncated) {
70
- lines.push(dim(" this read stopped at its page cap, so every number above is a FLOOR — it can understate use and never invent it"));
71
- }
72
- if (memory.searches?.counted === false) {
73
- lines.push(dim(` searches are not counted (${memory.searches.reason ?? "no ledger"}): nothing in Tower records one, so this is saves only`));
74
- }
75
- return lines;
76
79
  }
77
80
  /**
78
81
  * The hook half: one line per machine, the server's own words.
@@ -102,36 +102,7 @@ options = {}) {
102
102
  .join(" · ");
103
103
  lines.push(`PIPELINES ${rows.length} job${rows.length === 1 ? "" : "s"}${summary ? ` · ${summary}` : ""}`);
104
104
  lines.push("");
105
- const idWidth = Math.max(4, ...rows.map((row) => (row.id ?? "").length));
106
- for (const row of rows) {
107
- const verdict = verdictWord(row.verdict).padEnd(6);
108
- const id = (row.id ?? "?").padEnd(idWidth);
109
- // BLI-3723: a row that has no age BY DESIGN says `n/a`, never `—`. The two
110
- // look alike and mean opposite things — `—` is "the table is empty".
111
- const age = (row.ageAbsence ? "n/a" : ageWord(row.ageHours)).padStart(7);
112
- // BLI-3722: name the device this age belongs to right in the row — a bare
113
- // `STALE collector-fleet 11h` was already misread once (BLI-3699) as "the
114
- // fleet", not "one machine in it", and the name is otherwise buried in
115
- // `detail` below.
116
- // BLI-4057 generalised it from `stale` alone: `memory-hooks` names the
117
- // machine whose hooks stopped and reads `failing`, because nothing about
118
- // it is late. A row that took the trouble to name a device is naming it
119
- // for the reader, whatever word it printed beside it.
120
- const staleName = row.staleDeviceName ? ` ${dim(`(${row.staleDeviceName})`)}` : "";
121
- lines.push(`${verdict} ${id} ${age}${staleName} ${dim(intervalWord(row))}`);
122
- if (row.verdict !== "healthy") {
123
- // The absence of an age is explained BEFORE the detail, because it is
124
- // the first thing a reader's eye stopped on.
125
- if (row.ageAbsence)
126
- lines.push(dim(` no age: ${row.ageAbsence}`));
127
- if (row.detail)
128
- lines.push(dim(` ${row.detail}`));
129
- if (row.caveat)
130
- lines.push(dim(` note: ${row.caveat}`));
131
- if (row.configFile)
132
- lines.push(dim(` scheduled by ${row.configFile} (${row.cron ?? "?"})`));
133
- }
134
- }
105
+ appendPipelineRows(lines, rows, dim);
135
106
  if (payload.fleet)
136
107
  lines.push(...renderFleet(payload.fleet, dim));
137
108
  if (payload.coverage) {
@@ -163,6 +134,38 @@ options = {}) {
163
134
  }
164
135
  return lines;
165
136
  }
137
+ function appendPipelineRows(lines, rows, dim) {
138
+ const idWidth = Math.max(4, ...rows.map((row) => (row.id ?? "").length));
139
+ for (const row of rows) {
140
+ const verdict = verdictWord(row.verdict).padEnd(6);
141
+ const id = (row.id ?? "?").padEnd(idWidth);
142
+ // BLI-3723: a row that has no age BY DESIGN says `n/a`, never `—`. The two
143
+ // look alike and mean opposite things — `—` is "the table is empty".
144
+ const age = (row.ageAbsence ? "n/a" : ageWord(row.ageHours)).padStart(7);
145
+ // BLI-3722: name the device this age belongs to right in the row — a bare
146
+ // `STALE collector-fleet 11h` was already misread once (BLI-3699) as "the
147
+ // fleet", not "one machine in it", and the name is otherwise buried in
148
+ // `detail` below.
149
+ // BLI-4057 generalised it from `stale` alone: `memory-hooks` names the
150
+ // machine whose hooks stopped and reads `failing`, because nothing about
151
+ // it is late. A row that took the trouble to name a device is naming it
152
+ // for the reader, whatever word it printed beside it.
153
+ const staleName = row.staleDeviceName ? ` ${dim(`(${row.staleDeviceName})`)}` : "";
154
+ lines.push(`${verdict} ${id} ${age}${staleName} ${dim(intervalWord(row))}`);
155
+ if (row.verdict !== "healthy") {
156
+ // The absence of an age is explained BEFORE the detail, because it is
157
+ // the first thing a reader's eye stopped on.
158
+ if (row.ageAbsence)
159
+ lines.push(dim(` no age: ${row.ageAbsence}`));
160
+ if (row.detail)
161
+ lines.push(dim(` ${row.detail}`));
162
+ if (row.caveat)
163
+ lines.push(dim(` note: ${row.caveat}`));
164
+ if (row.configFile)
165
+ lines.push(dim(` scheduled by ${row.configFile} (${row.cron ?? "?"})`));
166
+ }
167
+ }
168
+ }
166
169
  /**
167
170
  * The laptops. Red first, because a person reading this at 9am should not have
168
171
  * to scroll past nine healthy machines to find the dead one.
@@ -170,6 +170,9 @@ function writeHumanOpsStatus(command, io, payload, memory, models, turns, toolRo
170
170
  for (const line of memory.experience.lines)
171
171
  writeLine(io.stdout, ` ${line}`);
172
172
  }
173
+ writeOptionalOpsSections(io, memory, models, turns, toolRouter);
174
+ }
175
+ function writeOptionalOpsSections(io, memory, models, turns, toolRouter) {
173
176
  if (models) {
174
177
  writeLine(io.stdout, "");
175
178
  if (models.lines.length > 0) {