@bli-cockpit/cli 0.2.112 → 0.2.114

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.
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { findStaleUnmanagedTicketBindingBlock, hasEquivalentUnmanagedTicketBinding, replaceLineSpan, } from "./agent-rules-unmanaged-block.js";
4
5
  import { describeError, isMissingFileFailure } from "./health-detail.js";
5
6
  const MANAGED_BLOCK_START = "<!-- BLI_COCKPIT_AGENT_RULES:START -->";
6
7
  const MANAGED_BLOCK_END = "<!-- BLI_COCKPIT_AGENT_RULES:END -->";
@@ -260,88 +261,6 @@ function managedBlockPattern() {
260
261
  function extractManagedBlock(contents) {
261
262
  return contents.match(managedBlockPattern())?.[0] ?? null;
262
263
  }
263
- function hasEquivalentUnmanagedTicketBinding(contents, scopePaths = []) {
264
- const text = normalizeRuleText(contents);
265
- if (!hasTicketBindingCues(text))
266
- return false;
267
- if (!hasRepoScopeGuard(text))
268
- return false;
269
- if (scopePaths.some((scopePath) => !hasScopePath(text, scopePath)))
270
- return false;
271
- if (!hasTicketLookupOrCreationCue(text))
272
- return false;
273
- // Contents without the QA-receipts rule are the older vocabulary and must
274
- // read as stale — otherwise machines with a hand-written ticket-binding
275
- // section never receive the definition-of-done rule.
276
- if (!hasQaReceiptsCue(text))
277
- return false;
278
- const signals = [
279
- /cockpit\s+start\s+--ticket\b/u,
280
- /before\s+(?:the\s+)?first\s+code\s+edit|before\s+ticketed\s+implementation/u,
281
- /general\s+ambient/u,
282
- /cockpit\s+sync\s+--repo|cockpit\s+sync\s+--workspace|fresh\s+ticket\/session\s+binding\s+metadata/u,
283
- /use\s+--ticket|the\s+flag\s+is\s+--ticket|do\s+not\s+invent\s+--ticketid/u,
284
- ];
285
- const score = signals.filter((signal) => signal.test(text)).length;
286
- return score >= 4;
287
- }
288
- function hasQaReceiptsCue(text) {
289
- return /computer-use\s+qa\s+pass/u.test(text)
290
- && /receipts/u.test(text);
291
- }
292
- function hasTicketLookupOrCreationCue(text) {
293
- return /search\s+linear|create\s+(?:a\s+)?(?:narrow\s+)?linear\s+ticket|new\s+linear\s+ticket/u.test(text);
294
- }
295
- function hasScopePath(text, scopePath) {
296
- return text.includes(normalizeRuleText(path.resolve(scopePath)));
297
- }
298
- function hasRepoScopeGuard(text) {
299
- return (/only\s+applies\s+when\s+the\s+current\s+working\s+directory\s+is\s+inside/u.test(text) ||
300
- /outside\s+that\s+(?:folder|workspace|repo).*(?:do\s+not|dont)\s+run\s+cockpit/u.test(text) ||
301
- /private\s+chats\s+or\s+unrelated\s+repos/u.test(text));
302
- }
303
- function findStaleUnmanagedTicketBindingBlock(contents, scopePaths = []) {
304
- const lines = contents.split("\n");
305
- for (let index = 0; index < lines.length; index += 1) {
306
- if (!/^#{1,6}\s+.*(?:cockpit\s+)?ticket\s+binding\b/iu.test(lines[index] ?? "")) {
307
- continue;
308
- }
309
- let endLine = lines.length;
310
- for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
311
- if (/^#{1,6}\s+\S/u.test(lines[cursor] ?? "")) {
312
- endLine = cursor;
313
- break;
314
- }
315
- }
316
- const candidate = lines.slice(index, endLine).join("\n");
317
- const normalized = normalizeRuleText(candidate);
318
- if (hasTicketBindingCues(normalized) &&
319
- !hasEquivalentUnmanagedTicketBinding(candidate, scopePaths)) {
320
- return { startLine: index, endLine };
321
- }
322
- }
323
- return null;
324
- }
325
- function replaceLineSpan(contents, startLine, endLine, replacement) {
326
- const lines = contents.split("\n");
327
- const before = lines.slice(0, startLine).join("\n").trimEnd();
328
- const after = lines.slice(endLine).join("\n").trimStart();
329
- return [before, replacement, after]
330
- .filter((part) => part.trim().length > 0)
331
- .join("\n\n")
332
- .replace(/\n{3,}/gu, "\n\n")
333
- .trimEnd() + "\n";
334
- }
335
- function hasTicketBindingCues(text) {
336
- return /\bcockpit\b/u.test(text) && /\bticket\b/u.test(text) && /binding|agent|linear/u.test(text);
337
- }
338
- function normalizeRuleText(contents) {
339
- return contents
340
- .toLowerCase()
341
- .replace(/[`"'<>]/gu, "")
342
- .replace(/\s+/gu, " ")
343
- .trim();
344
- }
345
264
  function escapeRegExp(value) {
346
265
  return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
347
266
  }
@@ -43,6 +43,7 @@ function normalizeCodexResult(result) {
43
43
  worktree: result.worktree,
44
44
  cwd_basename: result.cwd_basename,
45
45
  cwd_hash: result.cwd_hash,
46
+ session_facts: result.session_facts,
46
47
  };
47
48
  }
48
49
  function normalizeClaudeResult(result) {
@@ -61,6 +62,7 @@ function normalizeClaudeResult(result) {
61
62
  worktree: result.worktree,
62
63
  cwd_basename: result.cwd_basename,
63
64
  cwd_hash: result.cwd_hash,
65
+ session_facts: result.session_facts,
64
66
  };
65
67
  }
66
68
  /**
@@ -154,6 +156,10 @@ function sessionReportEntry(result, context) {
154
156
  : {}),
155
157
  session_file_byte_size: result.byte_size,
156
158
  session_file_mtime: result.session_file_mtime,
159
+ // Every session arrives counted (BLI-4341). Absent only when the file
160
+ // could not be read at all, which is the one case the server's extraction
161
+ // pass still has to cover.
162
+ ...(result.session_facts ? { session_facts: result.session_facts } : {}),
157
163
  ...(result.worktree
158
164
  ? {
159
165
  repo_fingerprint: result.worktree.repo_fingerprint,
@@ -2,7 +2,8 @@
2
2
  * The commands that manage this machine's account or watch the pipeline
3
3
  * rather than carry on a conversation: `cockpit scout`, `cockpit ops`,
4
4
  * `cockpit slack`, `cockpit settings`, `cockpit team`, and `cockpit model`.
5
- * Split out of local-args-tower.ts (BLI-3636).
5
+ * Split out of local-args-tower.ts (BLI-3636); the `settings` sections moved
6
+ * once more, into `./local-args-tower-settings.js`, and are re-exported below.
6
7
  *
7
8
  * Every parser moved verbatim: same flags, same defaults, same refusal
8
9
  * sentences. The two constants this family publishes
@@ -11,6 +12,12 @@
11
12
  */
12
13
  import { optionalEmail, optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
13
14
  import { isTeamDeviceRevokeReasonLabel, TEAM_DEVICE_REVOKE_REASON_LABELS, } from "./team-device-reasons.js";
15
+ /**
16
+ * `cockpit settings` has its own file: five sections, one set of rules each.
17
+ * It is re-exported here because this is the address every caller imports it
18
+ * from.
19
+ */
20
+ export { parseSettingsArgs } from "./local-args-tower-settings.js";
14
21
  /** The shortest prefix `cockpit scout start` will resolve. Below this, ids collide. */
15
22
  export const SCOUT_MIN_PREFIX_LENGTH = 6;
16
23
  /**
@@ -220,156 +227,6 @@ export function parseSlackArgs(args) {
220
227
  ...base,
221
228
  };
222
229
  }
223
- const SETTINGS_SECTIONS = ["personal", "switches", "models", "env", "cli-floor"];
224
- /**
225
- * `cockpit settings [section] [verb] [args]` (BLI-3461).
226
- *
227
- * The flag set is the UNION across sections and the combinations are checked
228
- * afterwards, per section — the same shape `parseDoctorArgs` uses. A flag that
229
- * belongs to another section is refused by name rather than silently ignored,
230
- * because a dropped `--project` on an env write is a change a person believes
231
- * they made.
232
- */
233
- export function parseSettingsArgs(args) {
234
- const values = parseNamedArgs(args, {
235
- allowedFlags: [
236
- "--home",
237
- "--dashboard-url",
238
- "--json",
239
- "--chat-model",
240
- "--brief-model",
241
- "--chat",
242
- "--memory",
243
- "--project",
244
- "--file",
245
- "--id",
246
- "--content-stdin",
247
- "--yes",
248
- ],
249
- valueFlags: [
250
- "--home",
251
- "--dashboard-url",
252
- "--chat-model",
253
- "--brief-model",
254
- "--chat",
255
- "--memory",
256
- "--project",
257
- "--file",
258
- "--id",
259
- ],
260
- });
261
- const [rawSection, ...rest] = values.positionals;
262
- const common = {
263
- homeDir: optionalNonEmpty(values.flags.get("--home")),
264
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
265
- yes: values.booleans.has("--yes"),
266
- json: values.booleans.has("--json"),
267
- };
268
- if (!rawSection) {
269
- if (rest.length > 0)
270
- throw new Error("settings does not accept that argument.");
271
- return { kind: "settings", section: "overview", action: "show", ...common };
272
- }
273
- if (!SETTINGS_SECTIONS.includes(rawSection)) {
274
- throw new Error(`settings section must be one of ${SETTINGS_SECTIONS.join(", ")} — or nothing, for all of them.`);
275
- }
276
- const section = rawSection;
277
- if (section === "personal") {
278
- if (rest.length > 0)
279
- throw new Error("settings personal does not accept positional arguments.");
280
- const chatModel = optionalNonEmpty(values.flags.get("--chat-model"));
281
- const briefModel = optionalNonEmpty(values.flags.get("--brief-model"));
282
- return {
283
- kind: "settings",
284
- section,
285
- action: chatModel || briefModel ? "set" : "show",
286
- chatModel,
287
- briefModel,
288
- ...common,
289
- };
290
- }
291
- if (section === "switches") {
292
- if (rest.length === 0) {
293
- return { kind: "settings", section, action: "show", ...common };
294
- }
295
- if (rest[0] !== "set") {
296
- throw new Error("settings switches takes no verb, or `set <key> <value>`.");
297
- }
298
- const [, key, value, ...extra] = rest;
299
- if (!key || !value || extra.length > 0) {
300
- throw new Error("settings switches set needs exactly a key and a value.");
301
- }
302
- return { kind: "settings", section, action: "set", switchKey: key, switchValue: value, ...common };
303
- }
304
- /**
305
- * BLI-3557: `settings cli-floor` shows the fleet forced-update floor,
306
- * `settings cli-floor <version>` raises it. There is no `set` verb because
307
- * there is nothing else to do to a floor, and no flag for the version because
308
- * a bare positional is how a release says it: `cockpit settings cli-floor
309
- * 0.2.48`. The server refuses a LOWER version; the terminal does not
310
- * second-guess that decision locally.
311
- */
312
- if (section === "cli-floor") {
313
- if (rest.length === 0) {
314
- return { kind: "settings", section, action: "show", ...common };
315
- }
316
- const [version, ...extra] = rest;
317
- if (!version || extra.length > 0) {
318
- throw new Error("settings cli-floor takes no argument, or one version: `cli-floor 0.2.48`.");
319
- }
320
- return { kind: "settings", section, action: "set", floorVersion: version, ...common };
321
- }
322
- if (section === "models") {
323
- if (rest.length === 0) {
324
- return { kind: "settings", section, action: "show", ...common };
325
- }
326
- if (rest[0] !== "set" || rest.length > 1) {
327
- throw new Error("settings models takes no verb, or `set --chat <key>` / `set --memory <id>`.");
328
- }
329
- const orgChatModel = optionalNonEmpty(values.flags.get("--chat"));
330
- const orgMemoryModel = optionalNonEmpty(values.flags.get("--memory"));
331
- if (!orgChatModel && !orgMemoryModel) {
332
- throw new Error("settings models set needs --chat <key>, --memory <id>, or both.");
333
- }
334
- return { kind: "settings", section, action: "set", orgChatModel, orgMemoryModel, ...common };
335
- }
336
- // env
337
- const verb = rest[0] ?? "list";
338
- if (rest.length > 1)
339
- throw new Error("settings env takes one verb: list, set, or delete.");
340
- if (verb === "list") {
341
- return { kind: "settings", section, action: "list", ...common };
342
- }
343
- if (verb === "set") {
344
- const envProject = optionalNonEmpty(values.flags.get("--project"));
345
- const envFile = optionalNonEmpty(values.flags.get("--file"));
346
- if (!envProject || !envFile) {
347
- throw new Error("settings env set needs --project <project> and --file <file name>.");
348
- }
349
- if (!values.booleans.has("--content-stdin")) {
350
- // Deliberate: there is no `--content <value>` flag and never will be. A
351
- // secret on a command line lands in shell history and in every process
352
- // listing on the machine.
353
- throw new Error("settings env set reads the file contents from stdin: add --content-stdin and pipe the file in.");
354
- }
355
- return {
356
- kind: "settings",
357
- section,
358
- action: "set",
359
- envProject,
360
- envFile,
361
- contentStdin: true,
362
- ...common,
363
- };
364
- }
365
- if (verb === "delete") {
366
- const envId = optionalNonEmpty(values.flags.get("--id"));
367
- if (!envId)
368
- throw new Error("settings env delete needs --id <uuid>.");
369
- return { kind: "settings", section, action: "delete", envId, ...common };
370
- }
371
- throw new Error("settings env takes one verb: list, set, or delete.");
372
- }
373
230
  /**
374
231
  * `cockpit team [members|invite <email>|role <userId>|device list|device
375
232
  * revoke <id|name>]` (BLI-3461; `device` added BLI-3559).
@@ -0,0 +1,186 @@
1
+ /**
2
+ * What a person may type at `cockpit settings` (BLI-3461), split out of
3
+ * local-args-tower-admin.ts by section so each section's rules fit on one
4
+ * screen. Every flag, default and refusal sentence moved verbatim;
5
+ * `parseSettingsArgs` is still exported from `./local-args-tower-admin.js`,
6
+ * the address it has always had.
7
+ *
8
+ * The flag set is the UNION across sections and the combinations are checked
9
+ * afterwards, per section — the same shape `parseDoctorArgs` uses. A flag that
10
+ * belongs to another section is refused by name rather than silently ignored,
11
+ * because a dropped `--project` on an env write is a change a person believes
12
+ * they made.
13
+ */
14
+ import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
15
+ const SETTINGS_SECTIONS = ["personal", "switches", "models", "env", "cli-floor"];
16
+ /** `cockpit settings [section] [verb] [args]`: the section picker. */
17
+ export function parseSettingsArgs(args) {
18
+ const values = parseNamedArgs(args, {
19
+ allowedFlags: [
20
+ "--home",
21
+ "--dashboard-url",
22
+ "--json",
23
+ "--chat-model",
24
+ "--brief-model",
25
+ "--chat",
26
+ "--memory",
27
+ "--project",
28
+ "--file",
29
+ "--id",
30
+ "--content-stdin",
31
+ "--yes",
32
+ ],
33
+ valueFlags: [
34
+ "--home",
35
+ "--dashboard-url",
36
+ "--chat-model",
37
+ "--brief-model",
38
+ "--chat",
39
+ "--memory",
40
+ "--project",
41
+ "--file",
42
+ "--id",
43
+ ],
44
+ });
45
+ const [rawSection, ...rest] = values.positionals;
46
+ const common = {
47
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
48
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
49
+ yes: values.booleans.has("--yes"),
50
+ json: values.booleans.has("--json"),
51
+ };
52
+ if (!rawSection) {
53
+ if (rest.length > 0)
54
+ throw new Error("settings does not accept that argument.");
55
+ return { kind: "settings", section: "overview", action: "show", ...common };
56
+ }
57
+ if (!SETTINGS_SECTIONS.includes(rawSection)) {
58
+ throw new Error(`settings section must be one of ${SETTINGS_SECTIONS.join(", ")} — or nothing, for all of them.`);
59
+ }
60
+ switch (rawSection) {
61
+ case "personal":
62
+ return parsePersonalSection(rest, values.flags, common);
63
+ case "switches":
64
+ return parseSwitchesSection(rest, common);
65
+ case "cli-floor":
66
+ return parseCliFloorSection(rest, common);
67
+ case "models":
68
+ return parseModelsSection(rest, values.flags, common);
69
+ case "env":
70
+ return parseEnvSection(rest, values.flags, values.booleans, common);
71
+ }
72
+ }
73
+ /** `settings personal [--chat-model <key>] [--brief-model <key>]`. */
74
+ function parsePersonalSection(rest, flags, common) {
75
+ if (rest.length > 0)
76
+ throw new Error("settings personal does not accept positional arguments.");
77
+ const chatModel = optionalNonEmpty(flags.get("--chat-model"));
78
+ const briefModel = optionalNonEmpty(flags.get("--brief-model"));
79
+ return {
80
+ kind: "settings",
81
+ section: "personal",
82
+ action: chatModel || briefModel ? "set" : "show",
83
+ chatModel,
84
+ briefModel,
85
+ ...common,
86
+ };
87
+ }
88
+ /** `settings switches` or `settings switches set <key> <value>`. */
89
+ function parseSwitchesSection(rest, common) {
90
+ if (rest.length === 0) {
91
+ return { kind: "settings", section: "switches", action: "show", ...common };
92
+ }
93
+ if (rest[0] !== "set") {
94
+ throw new Error("settings switches takes no verb, or `set <key> <value>`.");
95
+ }
96
+ const [, key, value, ...extra] = rest;
97
+ if (!key || !value || extra.length > 0) {
98
+ throw new Error("settings switches set needs exactly a key and a value.");
99
+ }
100
+ return {
101
+ kind: "settings",
102
+ section: "switches",
103
+ action: "set",
104
+ switchKey: key,
105
+ switchValue: value,
106
+ ...common,
107
+ };
108
+ }
109
+ /**
110
+ * BLI-3557: `settings cli-floor` shows the fleet forced-update floor,
111
+ * `settings cli-floor <version>` raises it. There is no `set` verb because
112
+ * there is nothing else to do to a floor, and no flag for the version because
113
+ * a bare positional is how a release says it: `cockpit settings cli-floor
114
+ * 0.2.48`. The server refuses a LOWER version; the terminal does not
115
+ * second-guess that decision locally.
116
+ */
117
+ function parseCliFloorSection(rest, common) {
118
+ if (rest.length === 0) {
119
+ return { kind: "settings", section: "cli-floor", action: "show", ...common };
120
+ }
121
+ const [version, ...extra] = rest;
122
+ if (!version || extra.length > 0) {
123
+ throw new Error("settings cli-floor takes no argument, or one version: `cli-floor 0.2.48`.");
124
+ }
125
+ return { kind: "settings", section: "cli-floor", action: "set", floorVersion: version, ...common };
126
+ }
127
+ /** `settings models` or `settings models set --chat <key> / --memory <id>`. */
128
+ function parseModelsSection(rest, flags, common) {
129
+ if (rest.length === 0) {
130
+ return { kind: "settings", section: "models", action: "show", ...common };
131
+ }
132
+ if (rest[0] !== "set" || rest.length > 1) {
133
+ throw new Error("settings models takes no verb, or `set --chat <key>` / `set --memory <id>`.");
134
+ }
135
+ const orgChatModel = optionalNonEmpty(flags.get("--chat"));
136
+ const orgMemoryModel = optionalNonEmpty(flags.get("--memory"));
137
+ if (!orgChatModel && !orgMemoryModel) {
138
+ throw new Error("settings models set needs --chat <key>, --memory <id>, or both.");
139
+ }
140
+ return {
141
+ kind: "settings",
142
+ section: "models",
143
+ action: "set",
144
+ orgChatModel,
145
+ orgMemoryModel,
146
+ ...common,
147
+ };
148
+ }
149
+ /** `settings env [list|set --project <p> --file <f> --content-stdin|delete --id <uuid>]`. */
150
+ function parseEnvSection(rest, flags, booleans, common) {
151
+ const verb = rest[0] ?? "list";
152
+ if (rest.length > 1)
153
+ throw new Error("settings env takes one verb: list, set, or delete.");
154
+ if (verb === "list") {
155
+ return { kind: "settings", section: "env", action: "list", ...common };
156
+ }
157
+ if (verb === "set") {
158
+ const envProject = optionalNonEmpty(flags.get("--project"));
159
+ const envFile = optionalNonEmpty(flags.get("--file"));
160
+ if (!envProject || !envFile) {
161
+ throw new Error("settings env set needs --project <project> and --file <file name>.");
162
+ }
163
+ if (!booleans.has("--content-stdin")) {
164
+ // Deliberate: there is no `--content <value>` flag and never will be. A
165
+ // secret on a command line lands in shell history and in every process
166
+ // listing on the machine.
167
+ throw new Error("settings env set reads the file contents from stdin: add --content-stdin and pipe the file in.");
168
+ }
169
+ return {
170
+ kind: "settings",
171
+ section: "env",
172
+ action: "set",
173
+ envProject,
174
+ envFile,
175
+ contentStdin: true,
176
+ ...common,
177
+ };
178
+ }
179
+ if (verb === "delete") {
180
+ const envId = optionalNonEmpty(flags.get("--id"));
181
+ if (!envId)
182
+ throw new Error("settings env delete needs --id <uuid>.");
183
+ return { kind: "settings", section: "env", action: "delete", envId, ...common };
184
+ }
185
+ throw new Error("settings env takes one verb: list, set, or delete.");
186
+ }
@@ -0,0 +1,58 @@
1
+ import { writeLine } from "./cli-io.js";
2
+ import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, } from "../onboarding-roots.js";
3
+ export function writeOnboardAutostartBlocker(io, result) {
4
+ writeLine(io.stderr, "BLOCKED: initial collection succeeded, but recurring background collection is not running.");
5
+ if (result?.message)
6
+ writeLine(io.stderr, `Failure: ${result.message}`);
7
+ writeLine(io.stderr, "Next: run `cockpit autostart install`, then require `cockpit autostart status --json` to exit 0.");
8
+ }
9
+ /**
10
+ * The blocked-backfill result, identical in both onboarding arms. `extra`
11
+ * carries the arm-specific keys and is spread where those keys appeared before,
12
+ * so the `--json` key order is unchanged.
13
+ */
14
+ export function writeOnboardBackfillBlockedResult(io, options) {
15
+ if (!options.json) {
16
+ writeOnboardBackfillBlocker(io, options.backfill);
17
+ return;
18
+ }
19
+ writeLine(io.stdout, JSON.stringify({
20
+ ...options.base,
21
+ ...options.extra,
22
+ backfill: onboardBackfillPayload(options.backfill),
23
+ blocker: "backfill_incomplete",
24
+ backfill_failure_reason: options.backfill.failureReason,
25
+ next_step: options.backfill.retryCommand,
26
+ }, null, 2));
27
+ }
28
+ function writeOnboardBackfillBlocker(io, outcome) {
29
+ writeLine(io.stderr, "BLOCKED: all-history Codex and Claude backfill is incomplete for the approved collection roots.");
30
+ writeLine(io.stderr, `Failure: ${outcome.failureReason ?? `backfill_${outcome.status}`}`);
31
+ writeLine(io.stderr, `Retry: ${outcome.retryCommand}`);
32
+ }
33
+ function onboardBackfillPayload(outcome) {
34
+ return (outcome.result ?? {
35
+ status: "blocked",
36
+ failure_reason: outcome.failureReason,
37
+ retry_command: outcome.retryCommand,
38
+ });
39
+ }
40
+ export function nextStepForOnboardBlocker(blocker, options = {}) {
41
+ switch (blocker) {
42
+ case COLLECTION_ROOT_REQUIRED:
43
+ return missingCollectionRootMessage(options);
44
+ case "ticket":
45
+ case "ticket_binding":
46
+ return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
47
+ case "device_pairing":
48
+ return "Ask Edward to approve this machine in the dashboard under Ambient -> Collector approvals, then run `cockpit doctor` again.";
49
+ case "network_or_ingest":
50
+ return "Check dashboard URL/network, then run `cockpit doctor`.";
51
+ case "install":
52
+ return "Rerun `cockpit doctor` from the repo root; it will reinstall local config.";
53
+ case "work_context":
54
+ return "Run `cockpit start --ticket <id> --workspace \"$PWD\"`, then retry `cockpit sync`.";
55
+ default:
56
+ return "Run `cockpit status --json` and report the blocker label plus last failure reason.";
57
+ }
58
+ }
@@ -1,6 +1,7 @@
1
1
  import { writeLine } from "./cli-io.js";
2
2
  import { attributedSyncRunStatus, displayTicketId, rawEvidenceSyncLine, writeAgentSessionSummary, } from "./collection-report.js";
3
- import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, } from "../onboarding-roots.js";
3
+ import { writeOnboardAutostartBlocker } from "./onboard-report-blockers.js";
4
+ export { nextStepForOnboardBlocker, writeOnboardAutostartBlocker, writeOnboardBackfillBlockedResult, } from "./onboard-report-blockers.js";
4
5
  export function writeOnboardBanner(command, io) {
5
6
  writeLine(io.stdout, "Setting up Tower");
6
7
  writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
@@ -54,43 +55,6 @@ export function onboardAgentRulesInstallLine(result) {
54
55
  return "not installed.";
55
56
  }
56
57
  }
57
- export function writeOnboardAutostartBlocker(io, result) {
58
- writeLine(io.stderr, "BLOCKED: initial collection succeeded, but recurring background collection is not running.");
59
- if (result?.message)
60
- writeLine(io.stderr, `Failure: ${result.message}`);
61
- writeLine(io.stderr, "Next: run `cockpit autostart install`, then require `cockpit autostart status --json` to exit 0.");
62
- }
63
- /**
64
- * The blocked-backfill result, identical in both onboarding arms. `extra`
65
- * carries the arm-specific keys and is spread where those keys appeared before,
66
- * so the `--json` key order is unchanged.
67
- */
68
- export function writeOnboardBackfillBlockedResult(io, options) {
69
- if (!options.json) {
70
- writeOnboardBackfillBlocker(io, options.backfill);
71
- return;
72
- }
73
- writeLine(io.stdout, JSON.stringify({
74
- ...options.base,
75
- ...options.extra,
76
- backfill: onboardBackfillPayload(options.backfill),
77
- blocker: "backfill_incomplete",
78
- backfill_failure_reason: options.backfill.failureReason,
79
- next_step: options.backfill.retryCommand,
80
- }, null, 2));
81
- }
82
- function writeOnboardBackfillBlocker(io, outcome) {
83
- writeLine(io.stderr, "BLOCKED: all-history Codex and Claude backfill is incomplete for the approved collection roots.");
84
- writeLine(io.stderr, `Failure: ${outcome.failureReason ?? `backfill_${outcome.status}`}`);
85
- writeLine(io.stderr, `Retry: ${outcome.retryCommand}`);
86
- }
87
- function onboardBackfillPayload(outcome) {
88
- return (outcome.result ?? {
89
- status: "blocked",
90
- failure_reason: outcome.failureReason,
91
- retry_command: outcome.retryCommand,
92
- });
93
- }
94
58
  /**
95
59
  * Every onboard `--json` payload is `onboardResult` plus the roots this pass
96
60
  * resolved plus whatever extra keys that arm of onboarding adds. Assembling it
@@ -129,25 +93,6 @@ function onboardResult(resultStatus, command, install, pair, sync, status) {
129
93
  next_dashboard_path: `${command.dashboardUrl}/my-work`,
130
94
  };
131
95
  }
132
- export function nextStepForOnboardBlocker(blocker, options = {}) {
133
- switch (blocker) {
134
- case COLLECTION_ROOT_REQUIRED:
135
- return missingCollectionRootMessage(options);
136
- case "ticket":
137
- case "ticket_binding":
138
- return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
139
- case "device_pairing":
140
- return "Ask Edward to approve this machine in the dashboard under Ambient -> Collector approvals, then run `cockpit doctor` again.";
141
- case "network_or_ingest":
142
- return "Check dashboard URL/network, then run `cockpit doctor`.";
143
- case "install":
144
- return "Rerun `cockpit doctor` from the repo root; it will reinstall local config.";
145
- case "work_context":
146
- return "Run `cockpit start --ticket <id> --workspace \"$PWD\"`, then retry `cockpit sync`.";
147
- default:
148
- return "Run `cockpit status --json` and report the blocker label plus last failure reason.";
149
- }
150
- }
151
96
  /** Step 3 of a single-repo onboard: the work context this machine just bound. */
152
97
  export function writeOnboardWorkContextStarted(io, context) {
153
98
  writeLine(io.stdout, "3/5 Work context active.");
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The LADDER block: the queue every session joins at ingest, and what the
3
+ * Railway drainer has done with it. Failures are grouped by the reason the
4
+ * rung named itself with, because "12 failed" sends nobody anywhere.
5
+ */
6
+ export function renderLadder(ladder, dim) {
7
+ const lines = ["", `LADDER ${ladder.summary ?? "(no summary)"}`];
8
+ if (ladder.readError) {
9
+ lines.push(` queue not read (${ladder.readError})`);
10
+ return lines;
11
+ }
12
+ const reasons = Object.entries(ladder.failed_by_reason ?? {}).sort((left, right) => right[1] - left[1]);
13
+ for (const [reason, count] of reasons.slice(0, 8)) {
14
+ lines.push(dim(` ${String(count).padStart(5)} ${reason}`));
15
+ }
16
+ if (reasons.length > 8)
17
+ lines.push(dim(` ${reasons.length - 8} more reasons not shown`));
18
+ if ((ladder.stale_leases ?? 0) > 0) {
19
+ lines.push(dim(` ${ladder.stale_leases} stale lease(s) waiting to be taken over`));
20
+ }
21
+ if (ladder.oldest_pending_session_id) {
22
+ lines.push(dim(` oldest pending session ${ladder.oldest_pending_session_id}`));
23
+ }
24
+ return lines;
25
+ }
@@ -16,7 +16,12 @@ import { renderCoverageBuckets, } from "./ops-render-coverage.js";
16
16
  // BLI-3909: today's spend by model, its own renderer for the same reason the
17
17
  // coverage table has one — this file is at the repo's readability floor.
18
18
  import { renderSpend } from "./ops-render-spend.js";
19
+ // BLI-4341: the ladder queue block, its own renderer for the same reason the
20
+ // coverage table and the spend section have one: this file is at the repo's
21
+ // readability floor.
22
+ import { renderLadder } from "./ops-render-ladder.js";
19
23
  export { renderSpend } from "./ops-render-spend.js";
24
+ export { renderLadder } from "./ops-render-ladder.js";
20
25
  export { renderMemoryHooks, renderMemoryUsage } from "./ops-render-memory.js";
21
26
  export { renderCoverageBuckets };
22
27
  /** The word a person reads. Short, fixed width, and never a bare colour. */
@@ -134,6 +139,8 @@ options = {}) {
134
139
  }
135
140
  if (payload.spend)
136
141
  lines.push(...renderSpend(payload.spend, dim));
142
+ if (payload.ladder)
143
+ lines.push(...renderLadder(payload.ladder, dim));
137
144
  const slack = payload.skips?.slack;
138
145
  const external = payload.skips?.external;
139
146
  if (slack || external) {