@bli-cockpit/cli 0.2.119 → 0.2.122

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 (42) hide show
  1. package/dist/commands/analyze.js +74 -54
  2. package/dist/commands/brief-rewrite.js +164 -101
  3. package/dist/commands/brief.js +38 -13
  4. package/dist/commands/careers.js +81 -9
  5. package/dist/commands/correct.js +38 -21
  6. package/dist/commands/docs.js +13 -10
  7. package/dist/commands/editor.js +59 -30
  8. package/dist/commands/install-receipts.js +106 -91
  9. package/dist/commands/local-args-tower-admin.js +4 -0
  10. package/dist/commands/local-args-tower-cal.js +28 -3
  11. package/dist/commands/local-args-tower-careers.js +56 -9
  12. package/dist/commands/local-args-tower-chat.js +55 -28
  13. package/dist/commands/local-args-tower-docs-msg.js +39 -8
  14. package/dist/commands/local-args-tower-mail.js +27 -1
  15. package/dist/commands/local-args-tower-models.js +14 -17
  16. package/dist/commands/local-args-tower-work.js +37 -6
  17. package/dist/commands/local-help-commands-tower.js +15 -1
  18. package/dist/commands/local-help-commands.js +2 -1
  19. package/dist/commands/local-help.js +1 -1
  20. package/dist/commands/mcp-stdio-probe.js +92 -73
  21. package/dist/commands/memory-hook-performance.js +135 -101
  22. package/dist/commands/memory-install-claude.js +15 -14
  23. package/dist/commands/memory-install-codex.js +10 -6
  24. package/dist/commands/memory-install-config.js +5 -4
  25. package/dist/commands/memory-install-contract.js +56 -10
  26. package/dist/commands/memory-install-report.js +16 -11
  27. package/dist/commands/memory-install-skills.js +11 -11
  28. package/dist/commands/memory-log.js +22 -5
  29. package/dist/commands/msg.js +11 -5
  30. package/dist/commands/onboard-setup.js +16 -1
  31. package/dist/commands/ops-sections.js +89 -0
  32. package/dist/commands/ops.js +117 -120
  33. package/dist/commands/public-root.js +1 -1
  34. package/dist/commands/scout.js +90 -68
  35. package/dist/commands/session-sync-failures.js +19 -13
  36. package/dist/commands/session-sync-record.js +53 -52
  37. package/dist/commands/session-sync-upload.js +15 -11
  38. package/dist/commands/sessions.js +61 -51
  39. package/dist/commands/slack.js +90 -61
  40. package/dist/commands/status.js +53 -41
  41. package/dist/commands/workbook.js +23 -20
  42. package/package.json +2 -2
@@ -35,6 +35,7 @@
35
35
  import { colorEnabled, dim, writeLine } from "./cli-io.js";
36
36
  import { renderOpsStatus } from "./ops-render.js";
37
37
  import { renderMemoryHooks, renderMemoryUsage, } from "./ops-render-memory.js";
38
+ import { readMemoryUsage, readModelBoardLines, readToolRouterBoard, toolRouterLine, turnAggregateLine, turnAggregateSection, } from "./ops-sections.js";
38
39
  import { asRecord, callTower, openTower, writeCommandFailure } from "./tower-command.js";
39
40
  /**
40
41
  * A whole compile, plus a little. `maxDuration` on `/api/ops/recompile` is 800
@@ -51,16 +52,7 @@ export async function runOps(command, io) {
51
52
  : runOpsRecompile(command, io, tower);
52
53
  }
53
54
  async function runOpsStatus(command, io, tower) {
54
- const params = new URLSearchParams();
55
- if (command.job)
56
- params.set("job", command.job);
57
- if (command.skips)
58
- params.set("skips", "1");
59
- const query = params.toString();
60
- const result = await callTower(tower, {
61
- path: `/api/ops/status${query ? `?${query}` : ""}`,
62
- label: "ops-status",
63
- });
55
+ const result = await readOpsStatus(command, tower);
64
56
  if (!result.ok) {
65
57
  writeLine(io.stderr, `[ops cli] status not read ${JSON.stringify({
66
58
  reason: result.reason,
@@ -82,72 +74,122 @@ async function runOpsStatus(command, io, tower) {
82
74
  // different question from "is the pipeline healthy", and a card shelf that
83
75
  // could not be read is not a collection outage.
84
76
  const models = command.models ? await readModelBoardLines(io, tower) : null;
77
+ const turns = command.turns ? turnAggregateSection(payload) : null;
78
+ const toolRouter = command.toolRouter ? await readToolRouterBoard(io, tower) : null;
79
+ const unhealthy = rows.filter(isUnhealthyPipeline);
80
+ writeOpsStatusOutput(command, io, payload, memory, models, turns, toolRouter);
81
+ writeOpsStatusReadLog(command, io, payload, rows, unhealthy, memory, models, turns, toolRouter);
82
+ return unhealthy.length > 0 ? 1 : 0;
83
+ }
84
+ async function readOpsStatus(command, tower) {
85
+ const params = new URLSearchParams();
86
+ if (command.job)
87
+ params.set("job", command.job);
88
+ if (command.skips)
89
+ params.set("skips", "1");
90
+ // The turn aggregates ride on the same status read (BLI-10136): one round
91
+ // trip, one board, never a second read of the same door for one section.
92
+ if (command.turns)
93
+ params.set("turns", "1");
94
+ const query = params.toString();
95
+ return callTower(tower, {
96
+ path: `/api/ops/status${query ? `?${query}` : ""}`,
97
+ label: "ops-status",
98
+ });
99
+ }
100
+ function isUnhealthyPipeline(row) {
85
101
  // BLI-3723: `failing` joins the list. An older CLI that has never heard of it
86
102
  // simply does not count it — which is why the server keeps the sentence in
87
103
  // `detail`, so an un-upgraded terminal still PRINTS the outage even when it
88
104
  // exits 0 over it.
89
- const unhealthy = rows.filter((row) => row.verdict === "stale" ||
105
+ return (row.verdict === "stale" ||
90
106
  row.verdict === "never_produced" ||
91
107
  row.verdict === "unreadable" ||
92
108
  row.verdict === "failing" ||
93
109
  // BLI-3762: a job that ran and wrote less than it owed is not healthy.
94
110
  row.verdict === "degraded");
111
+ }
112
+ function writeOpsStatusOutput(command, io, payload, memory, models, turns, toolRouter) {
95
113
  if (command.json) {
96
- writeLine(io.stdout, JSON.stringify({
97
- ...payload,
98
- ...(memory
99
- ? { memory: memory.section, hooks: memory.hooks, experience: memory.experience }
100
- : {}),
101
- ...(models ? { models: models.board, modelsReason: models.reason } : {}),
102
- }));
114
+ writeJsonOpsStatus(io, payload, memory, models, turns, toolRouter);
115
+ return;
103
116
  }
104
- else {
105
- const styled = colorEnabled(io);
106
- for (const line of renderOpsStatus(payload, (text) => dim(text, styled), {
107
- alwaysShowBuckets: command.coverage === true,
108
- })) {
117
+ writeHumanOpsStatus(command, io, payload, memory, models, turns, toolRouter);
118
+ }
119
+ function writeJsonOpsStatus(io, payload, memory, models, turns, toolRouter) {
120
+ writeLine(io.stdout, JSON.stringify({
121
+ ...payload,
122
+ ...(memory
123
+ ? { memory: memory.section, hooks: memory.hooks, experience: memory.experience }
124
+ : {}),
125
+ ...(models ? { models: models.board, modelsReason: models.reason } : {}),
126
+ ...(turns ? { turns: turns.section, turnsReason: turns.reason } : {}),
127
+ ...(toolRouter ? { toolRouter: toolRouter.board, toolRouterReason: toolRouter.reason } : {}),
128
+ }));
129
+ }
130
+ function writeHumanOpsStatus(command, io, payload, memory, models, turns, toolRouter) {
131
+ const styled = colorEnabled(io);
132
+ for (const line of renderOpsStatus(payload, (text) => dim(text, styled), {
133
+ alwaysShowBuckets: command.coverage === true,
134
+ })) {
135
+ writeLine(io.stdout, line);
136
+ }
137
+ if (memory?.section) {
138
+ for (const line of renderMemoryUsage(memory.section, (text) => dim(text, styled))) {
109
139
  writeLine(io.stdout, line);
110
140
  }
111
- if (memory?.section) {
112
- for (const line of renderMemoryUsage(memory.section, (text) => dim(text, styled))) {
113
- writeLine(io.stdout, line);
114
- }
141
+ }
142
+ // BLI-3788. Printed whenever the door sent it, including when the usage
143
+ // half could not be read: "are the recalls arriving" and "is anybody
144
+ // saving" are two questions, and one being unreadable does not silence
145
+ // the other.
146
+ if (memory?.hooks) {
147
+ for (const line of renderMemoryHooks(memory.hooks, (text) => dim(text, styled))) {
148
+ writeLine(io.stdout, line);
115
149
  }
116
- // BLI-3788. Printed whenever the door sent it, including when the usage
117
- // half could not be read: "are the recalls arriving" and "is anybody
118
- // saving" are two questions, and one being unreadable does not silence
119
- // the other.
120
- if (memory?.hooks) {
121
- for (const line of renderMemoryHooks(memory.hooks, (text) => dim(text, styled))) {
150
+ }
151
+ if (memory?.experience) {
152
+ writeLine(io.stdout, `\nEXPERIENCE ${memory.experience.summary}`);
153
+ for (const line of memory.experience.lines)
154
+ writeLine(io.stdout, ` ${line}`);
155
+ }
156
+ if (models) {
157
+ writeLine(io.stdout, "");
158
+ if (models.lines.length > 0) {
159
+ for (const line of models.lines)
122
160
  writeLine(io.stdout, line);
123
- }
124
- }
125
- if (memory?.experience) {
126
- writeLine(io.stdout, `\nEXPERIENCE ${memory.experience.summary}`);
127
- for (const line of memory.experience.lines)
128
- writeLine(io.stdout, ` ${line}`);
129
- }
130
- if (models) {
131
- writeLine(io.stdout, "");
132
- if (models.lines.length > 0) {
133
- for (const line of models.lines)
134
- writeLine(io.stdout, line);
135
- }
136
- else {
137
- // Never a silent gap where a section was asked for.
138
- writeLine(io.stdout, `MODELS not read (${models.reason})`);
139
- }
140
161
  }
141
- if (memory && !memory.section) {
142
- // Never a silent gap where a section was asked for: the reason the gauge
143
- // could not be read is printed where the gauge would have been.
144
- writeLine(io.stdout, "");
145
- writeLine(io.stdout, `MEMORY not read (${memory.reason})`);
162
+ else {
163
+ // Never a silent gap where a section was asked for.
164
+ writeLine(io.stdout, `MODELS not read (${models.reason})`);
146
165
  }
147
166
  }
167
+ if (turns) {
168
+ writeLine(io.stdout, "");
169
+ writeLine(io.stdout, turns.section ? turnAggregateLine(turns.section) : `TURNS not read (${turns.reason})`);
170
+ }
171
+ if (toolRouter) {
172
+ writeLine(io.stdout, "");
173
+ writeLine(io.stdout, toolRouter.board
174
+ ? toolRouterLine(toolRouter.board)
175
+ : `TOOL ROUTER not read (${toolRouter.reason})`);
176
+ }
177
+ if (memory && !memory.section) {
178
+ // Never a silent gap where a section was asked for: the reason the gauge
179
+ // could not be read is printed where the gauge would have been.
180
+ writeLine(io.stdout, "");
181
+ writeLine(io.stdout, `MEMORY not read (${memory.reason})`);
182
+ }
183
+ }
184
+ function writeOpsStatusReadLog(command, io, payload, rows, unhealthy, memory, models, turns, toolRouter) {
148
185
  // Both branches log — an all-green board that says nothing cannot answer
149
186
  // "did anybody look today?".
150
187
  writeLine(io.stderr, `[ops cli] status read ${JSON.stringify({
188
+ ...createOpsStatusReadLog(command, payload, rows, unhealthy, memory, models, turns, toolRouter),
189
+ })}`);
190
+ }
191
+ function createOpsStatusReadLog(command, payload, rows, unhealthy, memory, models, turns, toolRouter) {
192
+ return {
151
193
  job: command.job ?? "all",
152
194
  pipelines: rows.length,
153
195
  unhealthy: unhealthy.length,
@@ -187,63 +229,13 @@ async function runOpsStatus(command, io, tower) {
187
229
  models_reason: models?.reason ?? null,
188
230
  models_counted: models?.board?.rows?.length ?? null,
189
231
  models_cost_today_usd: models?.board?.total_cost_today_usd ?? null,
190
- })}`);
191
- return unhealthy.length > 0 ? 1 : 0;
192
- }
193
- /**
194
- * The adoption gauge, read through its own door.
195
- *
196
- * Never fails the board: a `cockpit ops --memory` whose memory half is
197
- * unreadable still prints the pipelines, the fleet and the coverage, and says
198
- * in one line which half is missing and why. The status board's exit code is
199
- * about collection health, and adoption is not that.
200
- */
201
- async function readMemoryUsage(command, io, tower) {
202
- const params = new URLSearchParams();
203
- if (command.memoryDays !== undefined)
204
- params.set("days", String(command.memoryDays));
205
- const query = params.toString();
206
- const result = await callTower(tower, {
207
- path: `/api/ops/memory-usage${query ? `?${query}` : ""}`,
208
- label: "ops-memory-usage",
209
- });
210
- if (!result.ok) {
211
- writeLine(io.stderr, `[ops cli] memory usage not read ${JSON.stringify({
212
- reason: result.reason,
213
- http_status: result.httpStatus ?? null,
214
- })}`);
215
- return { section: null, hooks: null, experience: null, reason: result.reason };
216
- }
217
- const body = asRecord(result.body);
218
- // BLI-3788: the hook counts ride the same answer, and their absence is a
219
- // fact about the SERVER's version rather than about this fleet — an older
220
- // dashboard sends no `hooks` key, and printing nothing is right there.
221
- if (!body.memory)
222
- return { section: null, hooks: body.hooks ?? null, experience: body.experience ?? null, reason: "memory_section_absent" };
223
- return { section: body.memory, hooks: body.hooks ?? null, experience: body.experience ?? null, reason: "ok" };
224
- }
225
- /**
226
- * The per-model board, read through its own door (BLI-3912).
227
- *
228
- * Same posture as the adoption gauge next door: it never fails the status
229
- * board, and when it cannot be read the reason is printed where the section
230
- * would have been. The LINES come from the server, so a terminal and the
231
- * dashboard cannot disagree about what a model cost today.
232
- */
233
- async function readModelBoardLines(io, tower) {
234
- const result = await callTower(tower, { path: "/api/ops/models", label: "ops-models" });
235
- if (!result.ok) {
236
- writeLine(io.stderr, `[ops cli] model board not read ${JSON.stringify({
237
- reason: result.reason,
238
- http_status: result.httpStatus ?? null,
239
- })}`);
240
- return { lines: [], board: null, reason: result.reason };
241
- }
242
- const body = asRecord(result.body);
243
- if (!body.lines || body.lines.length === 0) {
244
- return { lines: [], board: body.board ?? null, reason: "models_section_absent" };
245
- }
246
- return { lines: body.lines, board: body.board ?? null, reason: "ok" };
232
+ turns_asked: Boolean(command.turns),
233
+ turns_reason: turns?.reason ?? null,
234
+ turns_count: turns?.section?.turns ?? null,
235
+ tool_router_asked: Boolean(command.toolRouter),
236
+ tool_router_reason: toolRouter?.reason ?? null,
237
+ tool_router_turns: toolRouter?.board?.turnsRouted ?? null,
238
+ };
247
239
  }
248
240
  async function runOpsRecompile(command, io, tower) {
249
241
  const person = command.person ?? "";
@@ -278,11 +270,23 @@ async function runOpsRecompile(command, io, tower) {
278
270
  })}`);
279
271
  return writeCommandFailure(io, command.json, failure);
280
272
  }
281
- const body = asRecord(result.body);
273
+ return writeOpsRecompileResult(command, io, asRecord(result.body), person);
274
+ }
275
+ function writeOpsRecompileResult(command, io, body, person) {
282
276
  const status = typeof body["status"] === "string" ? body["status"] : "compiled";
283
277
  const displayName = typeof body["displayName"] === "string" ? body["displayName"] : person;
284
278
  const pageId = typeof body["pageId"] === "string" ? body["pageId"] : null;
285
279
  const isNowLive = body["isNowLive"] === true;
280
+ writeRecompileOutput(command, io, body, status, displayName, pageId, isNowLive);
281
+ writeLine(io.stderr, `[ops cli] recompile done ${JSON.stringify({
282
+ status,
283
+ page_id: pageId,
284
+ is_now_live: status === "compiled" ? isNowLive : null,
285
+ dry_run: command.dryRun === true,
286
+ })}`);
287
+ return 0;
288
+ }
289
+ function writeRecompileOutput(command, io, body, status, displayName, pageId, isNowLive) {
286
290
  if (command.json) {
287
291
  writeLine(io.stdout, JSON.stringify(body));
288
292
  }
@@ -301,11 +305,4 @@ async function runOpsRecompile(command, io, tower) {
301
305
  : "It is NOT the page being served: a scheduled compile is stamped for a later delivery window, " +
302
306
  "so that one still wins. Nothing was lost — this is a new version alongside it.");
303
307
  }
304
- writeLine(io.stderr, `[ops cli] recompile done ${JSON.stringify({
305
- status,
306
- page_id: pageId,
307
- is_now_live: status === "compiled" ? isNowLive : null,
308
- dry_run: command.dryRun === true,
309
- })}`);
310
- return 0;
311
308
  }
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.119");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.122");
19
19
  return 0;
20
20
  }
21
21
 
@@ -25,9 +25,20 @@ export async function runScout(command, io) {
25
25
  const session = await loadPairedSession("scout", command.homeDir);
26
26
  const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
27
27
  const log = (line) => writeLine(io.stderr, line);
28
+ const read = await readScoutBoard(command, io, session, dashboardUrl, log);
29
+ if (!read.ok) {
30
+ writeFailure(command, io, read);
31
+ return 1;
32
+ }
33
+ if (command.action === "board") {
34
+ return writeScoutBoard(command, io, read.body);
35
+ }
36
+ return runScoutAction(command, command.action, io, read.body, session, dashboardUrl, log);
37
+ }
38
+ async function readScoutBoard(command, io, session, dashboardUrl, log) {
28
39
  // The board is read first even for an action: the id prefix is resolved
29
40
  // against it locally, and a refused action still leaves a readable board.
30
- const read = await towerJsonRequest({
41
+ return towerJsonRequest({
31
42
  dashboardUrl,
32
43
  path: command.days ? `/api/cockpit/scout?days=${command.days}` : "/api/cockpit/scout",
33
44
  method: "GET",
@@ -37,53 +48,41 @@ export async function runScout(command, io) {
37
48
  timeoutMs: READ_DEADLINE_MS,
38
49
  log,
39
50
  });
40
- if (!read.ok) {
41
- writeFailure(command, io, read);
42
- return 1;
43
- }
44
- const payload = read.body;
51
+ }
52
+ function writeScoutBoard(command, io, payload) {
45
53
  const board = payload.board ?? {};
46
- const lines = payload.lines ?? {};
47
- if (command.action === "board") {
48
- if (command.json) {
49
- writeLine(io.stdout, JSON.stringify({ ok: true, audience: payload.audience ?? null, windowDays: payload.windowDays ?? board.windowDays ?? null, board, lines }));
50
- }
51
- else {
52
- for (const line of renderScoutBoard(payload, colorEnabled(io)))
53
- writeLine(io.stdout, line);
54
- }
55
- writeLine(io.stderr, `[scout cli] board read ${JSON.stringify({
54
+ if (command.json) {
55
+ writeLine(io.stdout, JSON.stringify({
56
+ ok: true,
56
57
  audience: payload.audience ?? null,
57
- window_days: payload.windowDays ?? board.windowDays ?? null,
58
- experiments: board.experiments?.length ?? 0,
59
- settled: board.settled?.length ?? 0,
60
- signals: board.signals?.length ?? 0,
61
- truncated: truncationLines(board).length > 0,
62
- })}`);
63
- return 0;
58
+ windowDays: payload.windowDays ?? board.windowDays ?? null,
59
+ board,
60
+ lines: payload.lines ?? {},
61
+ }));
64
62
  }
63
+ else {
64
+ for (const line of renderScoutBoard(payload, colorEnabled(io)))
65
+ writeLine(io.stdout, line);
66
+ }
67
+ writeLine(io.stderr, `[scout cli] board read ${JSON.stringify({
68
+ audience: payload.audience ?? null,
69
+ window_days: payload.windowDays ?? board.windowDays ?? null,
70
+ experiments: board.experiments?.length ?? 0,
71
+ settled: board.settled?.length ?? 0,
72
+ signals: board.signals?.length ?? 0,
73
+ truncated: truncationLines(board).length > 0,
74
+ })}`);
75
+ return 0;
76
+ }
77
+ async function runScoutAction(command, action, io, payload, session, dashboardUrl, log) {
78
+ const board = payload.board ?? {};
79
+ const lines = payload.lines ?? {};
65
80
  const ref = command.experimentRef ?? "";
66
81
  const resolution = resolveExperimentRef(board, ref);
67
82
  if (resolution.status !== "ok") {
68
- const sentence = refusalSentence(resolution, ref);
69
- writeLine(io.stderr, `[scout cli] action refused ${JSON.stringify({
70
- reason: resolution.status,
71
- action: command.action,
72
- // The typed reference is a person's own input; only its shape is logged.
73
- ref_length: ref.length,
74
- candidates: resolution.status === "ambiguous_prefix" ? resolution.candidates.length : 0,
75
- })}`);
76
- if (command.json) {
77
- writeLine(io.stdout, JSON.stringify({ ok: false, error: resolution.status, detail: sentence }));
78
- }
79
- else {
80
- writeLine(io.stderr, sentence);
81
- for (const line of renderScoutBoard(payload, colorEnabled(io)))
82
- writeLine(io.stdout, line);
83
- }
84
- return 1;
83
+ return writeScoutRefusal(command, action, io, payload, ref, resolution);
85
84
  }
86
- const serverAction = command.action === "undo" ? "undo_dismiss" : command.action;
85
+ const serverAction = action === "undo" ? "undo_dismiss" : action;
87
86
  const applied = await towerJsonRequest({
88
87
  dashboardUrl,
89
88
  path: "/api/cockpit/scout",
@@ -96,32 +95,7 @@ export async function runScout(command, io) {
96
95
  log,
97
96
  });
98
97
  if (!applied.ok) {
99
- writeLine(io.stderr, `[scout cli] action failed ${JSON.stringify({
100
- reason: applied.reason,
101
- action: serverAction,
102
- http_status: applied.httpStatus ?? null,
103
- })}`);
104
- if (command.json) {
105
- writeLine(io.stdout, JSON.stringify({
106
- ok: false,
107
- error: applied.reason,
108
- detail: failureDetail(applied),
109
- httpStatus: applied.httpStatus ?? null,
110
- board,
111
- lines,
112
- }));
113
- return 1;
114
- }
115
- // The server's own refusal first, then one line saying which gate it was —
116
- // and the board underneath, because being refused an action never costs a
117
- // person the read they already had.
118
- writeLine(io.stderr, `Tower refused that action: ${failureDetail(applied)}`);
119
- if (applied.httpStatus === 403) {
120
- writeLine(io.stderr, "Deciding a Scout card is a super_admin action; reading the board is not.");
121
- }
122
- for (const line of renderScoutBoard(payload, colorEnabled(io)))
123
- writeLine(io.stdout, line);
124
- return 1;
98
+ return writeScoutActionFailure(command, io, payload, board, lines, serverAction, applied);
125
99
  }
126
100
  const card = resolution.card;
127
101
  writeLine(io.stderr, `[scout cli] action applied ${JSON.stringify({ action: serverAction, http_status: applied.httpStatus })}`);
@@ -129,10 +103,58 @@ export async function runScout(command, io) {
129
103
  writeLine(io.stdout, JSON.stringify({ ok: true, action: serverAction, experiment: applied.body?.experiment ?? null }));
130
104
  }
131
105
  else {
132
- writeLine(io.stdout, `${pastTense(command.action)} ${shortId(resolution.id)} · ${card.claimSummary ?? card.title ?? "that card"}`);
106
+ writeLine(io.stdout, `${pastTense(action)} ${shortId(resolution.id)} · ${card.claimSummary ?? card.title ?? "that card"}`);
133
107
  }
134
108
  return 0;
135
109
  }
110
+ function writeScoutRefusal(command, action, io, payload, ref, resolution) {
111
+ const sentence = refusalSentence(resolution, ref);
112
+ writeLine(io.stderr, `[scout cli] action refused ${JSON.stringify({
113
+ reason: resolution.status,
114
+ action,
115
+ // The typed reference is a person's own input; only its shape is logged.
116
+ ref_length: ref.length,
117
+ candidates: resolution.status === "ambiguous_prefix" ? resolution.candidates.length : 0,
118
+ })}`);
119
+ if (command.json) {
120
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: resolution.status, detail: sentence }));
121
+ }
122
+ else {
123
+ writeLine(io.stderr, sentence);
124
+ for (const line of renderScoutBoard(payload, colorEnabled(io)))
125
+ writeLine(io.stdout, line);
126
+ }
127
+ return 1;
128
+ }
129
+ function writeScoutActionFailure(command, io, payload, board, lines, serverAction, applied) {
130
+ writeLine(io.stderr, `[scout cli] action failed ${JSON.stringify({
131
+ reason: applied.reason,
132
+ action: serverAction,
133
+ http_status: applied.httpStatus ?? null,
134
+ })}`);
135
+ if (command.json) {
136
+ writeLine(io.stdout, JSON.stringify({
137
+ ok: false,
138
+ error: applied.reason,
139
+ detail: failureDetail(applied),
140
+ httpStatus: applied.httpStatus ?? null,
141
+ board,
142
+ lines,
143
+ }));
144
+ return 1;
145
+ }
146
+ // The server's own refusal first, then one line saying which gate it was —
147
+ // and the board underneath, because being refused an action never costs a
148
+ // person the read they already had.
149
+ writeLine(io.stderr, `Tower refused that action: ${failureDetail(applied)}`);
150
+ if (applied.httpStatus === 403) {
151
+ writeLine(io.stderr, "Deciding a Scout card is a super_admin action; reading the board is not.");
152
+ }
153
+ for (const line of renderScoutBoard(payload, colorEnabled(io))) {
154
+ writeLine(io.stdout, line);
155
+ }
156
+ return 1;
157
+ }
136
158
  // ----------------------------------------------------------------- small parts
137
159
  function writeFailure(command, io, failure) {
138
160
  const detail = failureDetail(failure);
@@ -57,19 +57,9 @@ export function createSyncFailureLedger() {
57
57
  * silenced; only the verdict changed.
58
58
  */
59
59
  export function recordWorktreeDeliveryFailures(ledger, outcomes) {
60
- for (const { worktree, sync } of outcomes) {
61
- if (sync.status !== "uploaded") {
62
- ledger.add(sync.status === "spooled" && sync.failure_reason
63
- ? {
64
- label: sync.failure_class,
65
- rendered: `${worktree.worktree_label}:${sync.failure_reason}`,
66
- http_status: sync.failure_http_status,
67
- }
68
- : {
69
- label: "upload_not_completed",
70
- rendered: `${worktree.worktree_label}:upload_${sync.status}`,
71
- });
72
- }
60
+ for (const outcome of outcomes) {
61
+ recordWorktreeSyncFailure(ledger, outcome);
62
+ const { sync } = outcome;
73
63
  for (const reason of sync.raw_evidence_failure_reasons ?? []) {
74
64
  if (isDeliveryHoldReason(reason))
75
65
  continue;
@@ -90,6 +80,22 @@ export function recordWorktreeDeliveryFailures(ledger, outcomes) {
90
80
  ledger.fail(sync.raw_evidence_deferred_object_budget > 0, "deferred_object_budget");
91
81
  }
92
82
  }
83
+ function recordWorktreeSyncFailure(ledger, { worktree, sync }) {
84
+ if (sync.status === "uploaded")
85
+ return;
86
+ if (sync.status === "spooled" && sync.failure_reason) {
87
+ ledger.add({
88
+ label: sync.failure_class,
89
+ rendered: `${worktree.worktree_label}:${sync.failure_reason}`,
90
+ http_status: sync.failure_http_status,
91
+ });
92
+ return;
93
+ }
94
+ ledger.add({
95
+ label: "upload_not_completed",
96
+ rendered: `${worktree.worktree_label}:upload_${sync.status}`,
97
+ });
98
+ }
93
99
  /**
94
100
  * What the scan itself got wrong: a window that hit its cap, sessions that
95
101
  * could not be read, or a session store that could not be read at all.
@@ -220,60 +220,61 @@ async function recordClaudeSessionObservationsForSync(options) {
220
220
  */
221
221
  function recordSourceObservations(options) {
222
222
  const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
223
- if (options.terminalizeMissingUndurable) {
224
- for (const [sessionId, entry] of Object.entries(options.cursor.sessions)) {
225
- if (seen.has(sessionId) ||
226
- !liveSyncCursorEntryRequiresRetry(entry)) {
227
- continue;
228
- }
229
- options.cursor.sessions[sessionId] = {
230
- ...entry,
231
- state: "skipped",
232
- reason: "retry_source_missing",
233
- last_seen_at: options.now.toISOString(),
234
- };
235
- }
236
- }
223
+ markMissingRetrySourcesSkipped(options.cursor, seen, options.now, options.terminalizeMissingUndurable);
237
224
  const stale = countStaleSessions(options.cursor, seen);
238
225
  for (const result of options.results) {
239
- const sessionId = options.sessionIdOf(result);
240
- const reported = options.sessions.find((session) => session.source === options.source &&
241
- session.codex_session_id === sessionId);
242
- const uploadedThisSync = reported?.upload_state === "uploaded";
243
- const durableThisSync = reported?.upload_state === "uploaded" ||
244
- reported?.upload_state === "reused_existing";
245
- const prior = options.priorCursor?.sessions[sessionId];
246
- // D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
247
- // upload-failed for a session that was ALREADY durable must NOT wipe the
248
- // prior durable state — otherwise damping is forfeited forever and the
249
- // store row oscillates uploaded -> not_uploaded hourly. Carry the prior
250
- // durable pointer/timestamp/size forward unless we durably uploaded anew.
251
- const uploadedObjectKey = durableThisSync
252
- ? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
253
- : (prior?.uploaded_object_key ?? null);
254
- const uploadedAt = uploadedThisSync
255
- ? options.now.toISOString()
256
- : (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
257
- const uploadedByteSize = uploadedThisSync
258
- ? result.byte_size
259
- : (prior?.uploaded_byte_size ??
260
- (durableThisSync ? result.byte_size : null));
261
- const entry = {
262
- file_hash_sha256: result.content_hash_sha256,
263
- file_mtime_ms: result.session_file_mtime_ms,
264
- byte_size: result.byte_size,
265
- // Durable byte offset reflects how many bytes are durable remotely (the
266
- // last uploaded size), not the current file size.
267
- byte_offset: uploadedByteSize ?? 0,
268
- state: result.state,
269
- reason: result.reason,
270
- worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
271
- uploaded_object_key: uploadedObjectKey,
272
- uploaded_at: uploadedAt,
273
- uploaded_byte_size: uploadedByteSize,
274
- last_seen_at: options.now.toISOString(),
275
- };
276
- recordSessionObservation(options.cursor, sessionId, entry);
226
+ recordSourceObservation(options, result);
277
227
  }
278
228
  return stale;
229
+ }
230
+ function markMissingRetrySourcesSkipped(cursor, seen, now, terminalizeMissingUndurable) {
231
+ if (!terminalizeMissingUndurable)
232
+ return;
233
+ for (const [sessionId, entry] of Object.entries(cursor.sessions)) {
234
+ if (seen.has(sessionId) || !liveSyncCursorEntryRequiresRetry(entry))
235
+ continue;
236
+ cursor.sessions[sessionId] = {
237
+ ...entry,
238
+ state: "skipped",
239
+ reason: "retry_source_missing",
240
+ last_seen_at: now.toISOString(),
241
+ };
242
+ }
243
+ }
244
+ function recordSourceObservation(options, result) {
245
+ const sessionId = options.sessionIdOf(result);
246
+ const reported = options.sessions.find((session) => session.source === options.source && session.codex_session_id === sessionId);
247
+ const uploadedThisSync = reported?.upload_state === "uploaded";
248
+ const durableThisSync = reported?.upload_state === "uploaded" || reported?.upload_state === "reused_existing";
249
+ const prior = options.priorCursor?.sessions[sessionId];
250
+ // D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
251
+ // upload-failed for a session that was ALREADY durable must NOT wipe the
252
+ // prior durable state — otherwise damping is forfeited forever and the
253
+ // store row oscillates uploaded -> not_uploaded hourly. Carry the prior
254
+ // durable pointer/timestamp/size forward unless we durably uploaded anew.
255
+ const uploadedObjectKey = durableThisSync
256
+ ? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
257
+ : (prior?.uploaded_object_key ?? null);
258
+ const uploadedAt = uploadedThisSync
259
+ ? options.now.toISOString()
260
+ : (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
261
+ const uploadedByteSize = uploadedThisSync
262
+ ? result.byte_size
263
+ : (prior?.uploaded_byte_size ?? (durableThisSync ? result.byte_size : null));
264
+ const entry = {
265
+ file_hash_sha256: result.content_hash_sha256,
266
+ file_mtime_ms: result.session_file_mtime_ms,
267
+ byte_size: result.byte_size,
268
+ // Durable byte offset reflects how many bytes are durable remotely (the
269
+ // last uploaded size), not the current file size.
270
+ byte_offset: uploadedByteSize ?? 0,
271
+ state: result.state,
272
+ reason: result.reason,
273
+ worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
274
+ uploaded_object_key: uploadedObjectKey,
275
+ uploaded_at: uploadedAt,
276
+ uploaded_byte_size: uploadedByteSize,
277
+ last_seen_at: options.now.toISOString(),
278
+ };
279
+ recordSessionObservation(options.cursor, sessionId, entry);
279
280
  }