@bli-cockpit/cli 0.2.119 → 0.2.121

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 (38) 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/correct.js +38 -21
  5. package/dist/commands/docs.js +13 -10
  6. package/dist/commands/editor.js +59 -30
  7. package/dist/commands/install-receipts.js +106 -91
  8. package/dist/commands/local-args-tower-admin.js +4 -0
  9. package/dist/commands/local-args-tower-cal.js +28 -3
  10. package/dist/commands/local-args-tower-chat.js +55 -28
  11. package/dist/commands/local-args-tower-docs-msg.js +39 -8
  12. package/dist/commands/local-args-tower-mail.js +27 -1
  13. package/dist/commands/local-args-tower-models.js +14 -17
  14. package/dist/commands/local-args-tower-work.js +37 -6
  15. package/dist/commands/local-help-commands.js +2 -1
  16. package/dist/commands/mcp-stdio-probe.js +92 -73
  17. package/dist/commands/memory-hook-performance.js +135 -101
  18. package/dist/commands/memory-install-claude.js +15 -14
  19. package/dist/commands/memory-install-codex.js +10 -6
  20. package/dist/commands/memory-install-config.js +5 -4
  21. package/dist/commands/memory-install-contract.js +56 -10
  22. package/dist/commands/memory-install-report.js +16 -11
  23. package/dist/commands/memory-install-skills.js +11 -11
  24. package/dist/commands/memory-log.js +22 -5
  25. package/dist/commands/msg.js +11 -5
  26. package/dist/commands/onboard-setup.js +16 -1
  27. package/dist/commands/ops-sections.js +89 -0
  28. package/dist/commands/ops.js +117 -120
  29. package/dist/commands/public-root.js +1 -1
  30. package/dist/commands/scout.js +90 -68
  31. package/dist/commands/session-sync-failures.js +19 -13
  32. package/dist/commands/session-sync-record.js +53 -52
  33. package/dist/commands/session-sync-upload.js +15 -11
  34. package/dist/commands/sessions.js +61 -51
  35. package/dist/commands/slack.js +90 -61
  36. package/dist/commands/status.js +53 -41
  37. package/dist/commands/workbook.js +23 -20
  38. package/package.json +2 -2
@@ -57,6 +57,13 @@ export async function pairForOnboarding(command, input, installEvents, io) {
57
57
  repoRoot: input.primaryRoot,
58
58
  branch: command.branch,
59
59
  });
60
+ if (await reuseInstalledOnboardSession(command, input, installedStatus, installEvents, io)) {
61
+ return null;
62
+ }
63
+ await logOutMismatchedOnboardSession(command, installedStatus, io);
64
+ return await pairOnboardDevice(command, input, installEvents, io);
65
+ }
66
+ async function reuseInstalledOnboardSession(command, input, installedStatus, installEvents, io) {
60
67
  const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
61
68
  const canReuseInstalledSession = canReuseOnboardSession(installedSession, input.claimedOwnerEmail, command.dashboardUrl);
62
69
  if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
@@ -65,14 +72,19 @@ export async function pairForOnboarding(command, input, installEvents, io) {
65
72
  if (!command.json) {
66
73
  writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
67
74
  }
68
- return null;
75
+ return true;
69
76
  }
77
+ return false;
78
+ }
79
+ async function logOutMismatchedOnboardSession(command, installedStatus, io) {
70
80
  if (installedStatus.session_state === "valid") {
71
81
  await logoutLocalCollector({ homeDir: command.homeDir });
72
82
  if (!command.json) {
73
83
  writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
74
84
  }
75
85
  }
86
+ }
87
+ async function pairOnboardDevice(command, input, installEvents, io) {
76
88
  // BLI-3731: one sign-in first. The link both signs the browser in and
77
89
  // finishes the pairing, so nobody reads a second email. Every other arm
78
90
  // below is the fallback, and it runs unchanged when this one cannot finish.
@@ -89,6 +101,9 @@ export async function pairForOnboarding(command, input, installEvents, io) {
89
101
  return linked;
90
102
  }
91
103
  }
104
+ return await pairOnboardDeviceWithAuthFallback(command, input, installEvents, io);
105
+ }
106
+ async function pairOnboardDeviceWithAuthFallback(command, input, installEvents, io) {
92
107
  const authResult = await requestPairingAccessTokenDetailed({
93
108
  dashboardUrl: command.dashboardUrl,
94
109
  email: input.claimedOwnerEmail,
@@ -0,0 +1,89 @@
1
+ import { asRecord, callTower } from "./tower-command.js";
2
+ import { writeLine } from "./cli-io.js";
3
+ export function turnAggregateSection(payload) {
4
+ const section = payload.turns ?? null;
5
+ return { section, reason: section ? "ok" : "turns_section_absent" };
6
+ }
7
+ export async function readToolRouterBoard(io, tower) {
8
+ const result = await callTower(tower, { path: "/api/ops/tool-router", label: "ops-tool-router" });
9
+ if (!result.ok) {
10
+ writeLine(io.stderr, `[ops cli] tool router not read ${JSON.stringify({ reason: result.reason, http_status: result.httpStatus ?? null })}`);
11
+ return { board: null, reason: result.reason };
12
+ }
13
+ const board = asRecord(result.body).board ?? null;
14
+ return { board, reason: board ? "ok" : "tool_router_section_absent" };
15
+ }
16
+ export function turnAggregateLine(section) {
17
+ return `TURNS ${section.turns ?? 0} turns / ${section.windowDays ?? 30}d · wall p50 ${section.wallTimeMs?.p50 ?? "n/a"}ms · p95 ${section.wallTimeMs?.p95 ?? "n/a"}ms · ${section.completion ?? "unknown"}`;
18
+ }
19
+ export function toolRouterLine(board) {
20
+ const rate = board.agreementRate === null || board.agreementRate === undefined
21
+ ? "n/a"
22
+ : `${(board.agreementRate * 100).toFixed(1)}%`;
23
+ return `TOOL ROUTER ${board.turnsRouted ?? 0} routed · agreement ${rate} · p50 ${board.p50LatencyMs ?? "n/a"}ms${board.capped ? " · capped" : ""}${board.readError ? ` · ${board.readError}` : ""}`;
24
+ }
25
+ /**
26
+ * The adoption gauge, read through its own door.
27
+ *
28
+ * Never fails the board: a `cockpit ops --memory` whose memory half is
29
+ * unreadable still prints the pipelines, the fleet and the coverage, and says
30
+ * in one line which half is missing and why. The status board's exit code is
31
+ * about collection health, and adoption is not that.
32
+ */
33
+ export async function readMemoryUsage(command, io, tower) {
34
+ const params = new URLSearchParams();
35
+ if (command.memoryDays !== undefined)
36
+ params.set("days", String(command.memoryDays));
37
+ const query = params.toString();
38
+ const result = await callTower(tower, {
39
+ path: `/api/ops/memory-usage${query ? `?${query}` : ""}`,
40
+ label: "ops-memory-usage",
41
+ });
42
+ if (!result.ok) {
43
+ writeLine(io.stderr, `[ops cli] memory usage not read ${JSON.stringify({
44
+ reason: result.reason,
45
+ http_status: result.httpStatus ?? null,
46
+ })}`);
47
+ return { section: null, hooks: null, experience: null, reason: result.reason };
48
+ }
49
+ const body = asRecord(result.body);
50
+ // BLI-3788: the hook counts ride the same answer, and their absence is a
51
+ // fact about the SERVER's version rather than about this fleet — an older
52
+ // dashboard sends no `hooks` key, and printing nothing is right there.
53
+ if (!body.memory)
54
+ return {
55
+ section: null,
56
+ hooks: body.hooks ?? null,
57
+ experience: body.experience ?? null,
58
+ reason: "memory_section_absent",
59
+ };
60
+ return {
61
+ section: body.memory,
62
+ hooks: body.hooks ?? null,
63
+ experience: body.experience ?? null,
64
+ reason: "ok",
65
+ };
66
+ }
67
+ /**
68
+ * The per-model board, read through its own door (BLI-3912).
69
+ *
70
+ * Same posture as the adoption gauge next door: it never fails the status
71
+ * board, and when it cannot be read the reason is printed where the section
72
+ * would have been. The LINES come from the server, so a terminal and the
73
+ * dashboard cannot disagree about what a model cost today.
74
+ */
75
+ export async function readModelBoardLines(io, tower) {
76
+ const result = await callTower(tower, { path: "/api/ops/models", label: "ops-models" });
77
+ if (!result.ok) {
78
+ writeLine(io.stderr, `[ops cli] model board not read ${JSON.stringify({
79
+ reason: result.reason,
80
+ http_status: result.httpStatus ?? null,
81
+ })}`);
82
+ return { lines: [], board: null, reason: result.reason };
83
+ }
84
+ const body = asRecord(result.body);
85
+ if (!body.lines || body.lines.length === 0) {
86
+ return { lines: [], board: body.board ?? null, reason: "models_section_absent" };
87
+ }
88
+ return { lines: body.lines, board: body.board ?? null, reason: "ok" };
89
+ }
@@ -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.121");
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);