@bli-cockpit/cli 0.2.48 → 0.2.49

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 (39) hide show
  1. package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
  2. package/dist/adapters/raw-evidence.js +360 -349
  3. package/dist/autostart-contract.js +79 -0
  4. package/dist/autostart-darwin-plist.js +265 -0
  5. package/dist/autostart-darwin.js +171 -0
  6. package/dist/autostart-windows-scripts.js +310 -0
  7. package/dist/autostart-windows-task-xml.js +260 -0
  8. package/dist/autostart-windows.js +237 -0
  9. package/dist/autostart-xml.js +23 -0
  10. package/dist/autostart.js +35 -1148
  11. package/dist/commands/agent-rules-command.js +55 -0
  12. package/dist/commands/agent-session-report.js +290 -0
  13. package/dist/commands/analyze.js +131 -0
  14. package/dist/commands/autostart-command.js +105 -0
  15. package/dist/commands/backfill.js +824 -551
  16. package/dist/commands/cli-io.js +13 -0
  17. package/dist/commands/jarvis.js +179 -3
  18. package/dist/commands/local-arg-values.js +169 -0
  19. package/dist/commands/local-args-collector.js +578 -0
  20. package/dist/commands/local-args-tower.js +870 -0
  21. package/dist/commands/local-args.js +8 -1549
  22. package/dist/commands/local-help.js +11 -3
  23. package/dist/commands/local.js +18 -1786
  24. package/dist/commands/login.js +53 -0
  25. package/dist/commands/logout.js +66 -0
  26. package/dist/commands/onboard-receipts.js +66 -0
  27. package/dist/commands/onboard-report.js +274 -0
  28. package/dist/commands/onboard.js +449 -0
  29. package/dist/commands/ops-render.js +36 -0
  30. package/dist/commands/public-root.js +1 -1
  31. package/dist/commands/serve.js +13 -0
  32. package/dist/commands/session-sync.js +513 -534
  33. package/dist/commands/settings-render.js +28 -0
  34. package/dist/commands/settings.js +66 -2
  35. package/dist/commands/start.js +47 -0
  36. package/dist/commands/sync-followups.js +203 -0
  37. package/dist/commands/sync.js +381 -0
  38. package/dist/tower-stream.js +20 -4
  39. package/package.json +1 -1
@@ -10,6 +10,19 @@ export function writeRaw(stream, text) {
10
10
  if (!text.endsWith("\n"))
11
11
  stream.write("\n");
12
12
  }
13
+ /**
14
+ * Part of a line, and nothing else (BLI-3517).
15
+ *
16
+ * `writeRaw` finishes the line for you, which is right for a whole block of
17
+ * captured output and wrong for a sentence still being written: a streamed
18
+ * answer arrives as a dozen fragments and each one would land on its own row.
19
+ * This adds nothing at all; the caller ends the line when the thought ends.
20
+ */
21
+ export function writeFragment(stream, text) {
22
+ if (!text)
23
+ return;
24
+ stream.write(text);
25
+ }
13
26
  export function errorMessage(error) {
14
27
  return error instanceof Error ? error.message : String(error);
15
28
  }
@@ -5,10 +5,67 @@
5
5
  * existing paired device session, and every turn is executed by the dashboard
6
6
  * through the same JARVIS runtime used by web chat and Slack.
7
7
  */
8
- import { colorEnabled, dim, isInteractiveStdin, readLine, readPipedText, writeLine, } from "./cli-io.js";
8
+ import { colorEnabled, dim, isInteractiveStdin, readLine, readPipedText, writeFragment, writeLine, } from "./cli-io.js";
9
9
  import { attachedFileRefusalSentence, readAttachedImage, } from "./jarvis-attachment.js";
10
10
  import { loadPairedSession, towerFailureDetail, towerJsonRequest, towerRequest, } from "../tower-client.js";
11
11
  import { readTowerTurn, streamFailureDetail, } from "../tower-stream.js";
12
+ /**
13
+ * A citation line's link, as the dashboard mints it (BLI-3570):
14
+ * `Source: BLI-1234 — Judge outage <https://linear.app/…>`. The words are the
15
+ * receipt; the link is not part of the sentence, so the terminal prints it
16
+ * underneath, dim, rather than dragging a 118-character URL through the answer.
17
+ */
18
+ const CITATION_LINK = /^(\s*(?:Source|Receipt)s?:.*?)\s*<(https?:\/\/[^>\s]+)>\s*$/i;
19
+ /**
20
+ * The answer, one line at a time, so a receipt's link gets its own dim line.
21
+ * Every other line is printed exactly as it arrived — this never rewrites the
22
+ * words, only where the link sits.
23
+ */
24
+ function writeReply(io, prefix, reply) {
25
+ const styled = colorEnabled(io);
26
+ const lines = reply.split("\n");
27
+ lines.forEach((line, index) => {
28
+ const head = index === 0 ? prefix : "";
29
+ const citation = line.match(CITATION_LINK);
30
+ if (!citation) {
31
+ writeLine(io.stdout, `${head}${line}`);
32
+ return;
33
+ }
34
+ writeLine(io.stdout, `${head}${citation[1]}`);
35
+ writeLine(io.stdout, dim(` ${citation[2]}`, styled));
36
+ });
37
+ }
38
+ /**
39
+ * The same link rule, applied to text that CONTINUES a line already on screen
40
+ * (BLI-3517 + BLI-3570).
41
+ *
42
+ * When the answer streamed, the citation block arrives after the last token,
43
+ * so `settle` has a tail to print rather than a whole reply. The first fragment
44
+ * is deliberately never tested against `CITATION_LINK`: it is the rest of a
45
+ * sentence the person is already reading, not a line of its own, and a regex
46
+ * anchored at `^` would be matching against a boundary that is not there.
47
+ * Every COMPLETE line after it gets the dim link treatment as usual.
48
+ *
49
+ * A citation that was already streamed inline cannot be split — a terminal
50
+ * cannot unprint — and that is the honest limit of this, not a bug to chase.
51
+ */
52
+ function writeReplyContinuation(io, tail) {
53
+ const styled = colorEnabled(io);
54
+ const [first = "", ...rest] = tail.split("\n");
55
+ writeFragment(io.stdout, first);
56
+ for (const line of rest) {
57
+ writeLine(io.stdout, "");
58
+ const citation = line.match(CITATION_LINK);
59
+ if (!citation) {
60
+ writeFragment(io.stdout, line);
61
+ continue;
62
+ }
63
+ writeFragment(io.stdout, citation[1]);
64
+ writeLine(io.stdout, "");
65
+ writeFragment(io.stdout, dim(` ${citation[2]}`, styled));
66
+ }
67
+ writeLine(io.stdout, "");
68
+ }
12
69
  /**
13
70
  * The dashboard route caps a turn at 120s (`maxDuration = 120`). The client
14
71
  * waits slightly longer so the server's own named failure wins the race
@@ -186,17 +243,22 @@ async function sendOneTurn(context, prompt, io) {
186
243
  }
187
244
  // Live trace lines go to a person as they land, never to a `--json`
188
245
  // consumer: that contract is exactly one object on stdout, so the events are
189
- // buffered and folded into the final payload instead.
246
+ // buffered and folded into the final payload instead. The same rule governs
247
+ // the answer's own words (BLI-3517): `--json` stays exactly one object.
190
248
  let liveTraceLines = 0;
249
+ const live = createLiveAnswer(context.command, io);
191
250
  const turn = await readTowerTurn(requested.response, {
192
251
  startedAt,
193
252
  log,
194
253
  onActivity: (event) => {
195
254
  if (context.command.json)
196
255
  return;
256
+ // A trace row must never land in the middle of a half-written sentence.
257
+ live.interrupt();
197
258
  if (writeActivityLine(io, event))
198
259
  liveTraceLines += 1;
199
260
  },
261
+ onToken: (event) => live.token(event),
200
262
  onNote: (reason, detail) => {
201
263
  log(`[jarvis cli] stream note ${JSON.stringify({
202
264
  reason,
@@ -205,12 +267,14 @@ async function sendOneTurn(context, prompt, io) {
205
267
  },
206
268
  });
207
269
  if (!turn.ok) {
270
+ live.abandon();
208
271
  writeFailure(context.command, io, turn.reason, streamFailureDetail(turn.reason, turn.detail));
209
272
  return 1;
210
273
  }
211
274
  const body = turn.final;
212
275
  const httpStatus = typeof turn.final.httpStatus === "number" ? turn.final.httpStatus : requested.response.status;
213
276
  if (httpStatus >= 400 || !body.ok || !body.reply) {
277
+ live.abandon();
214
278
  const reason = body.error ?? body.reply ?? `http_${httpStatus}`;
215
279
  writeFailure(context.command, io, "turn_failed", reason);
216
280
  return 1;
@@ -231,7 +295,12 @@ async function sendOneTurn(context, prompt, io) {
231
295
  }
232
296
  else {
233
297
  const subject = body.subject?.displayName ? ` (${body.subject.displayName})` : "";
234
- writeLine(io.stdout, `jarvis${subject}> ${body.reply}`);
298
+ // BLI-3517: when the answer streamed, `settle` prints only what the live
299
+ // words did not already say — and says so out loud when the grounding gate
300
+ // took some of them back. When nothing streamed it prints the whole reply
301
+ // through `writeReply`, which is byte-for-byte what this command printed
302
+ // before, BLI-3570's dim link line included.
303
+ live.settle(body.reply, subject, body.revised === true);
235
304
  // Only when nothing was drawn live — otherwise every tool would print twice.
236
305
  if (liveTraceLines === 0)
237
306
  writeTraceBlock(io, trace);
@@ -250,9 +319,116 @@ async function sendOneTurn(context, prompt, io) {
250
319
  image_byte_size: attachment?.bytes.byteLength ?? null,
251
320
  streamed: turn.streamed,
252
321
  live_trace_lines: liveTraceLines,
322
+ // BLI-3517: whether the words arrived live, and whether the settled
323
+ // answer superseded them. `streamed_chars: 0` against a streaming
324
+ // dashboard means the answer landed all at once.
325
+ streamed_chars: turn.draft.length,
326
+ revised: body.revised === true,
253
327
  })}`);
254
328
  return 0;
255
329
  }
330
+ /**
331
+ * The answer as it is written, in a terminal (BLI-3517).
332
+ *
333
+ * A terminal cannot unprint. That single fact decides everything here:
334
+ *
335
+ * - The words stream out under the usual `jarvis> ` prefix, printed the moment
336
+ * they arrive rather than after the whole turn (a measured 4-16 s of nothing
337
+ * before this ticket).
338
+ * - A `reset` — the server taking back a draft the model wrote before deciding
339
+ * to call a tool — cannot erase what is on screen, so it SAYS so on its own
340
+ * line and the replacement follows. Never a retracted sentence left standing
341
+ * with nothing marking it.
342
+ * - The settled reply is the authority. The grounding gate runs on the whole
343
+ * text once the stream ends, so the answer can grow (citations, a fallback
344
+ * notice) or change. A pure continuation is printed as the remainder; a
345
+ * genuine change is reprinted whole under a line saying it was revised.
346
+ * - `--json` never streams a fragment: that contract is exactly one object on
347
+ * stdout, and every method here is a no-op for it.
348
+ */
349
+ function createLiveAnswer(command, io) {
350
+ const styled = colorEnabled(io);
351
+ const quiet = command.json === true;
352
+ let printed = "";
353
+ let opened = false;
354
+ const open = (subject) => {
355
+ if (opened)
356
+ return;
357
+ opened = true;
358
+ writeFragment(io.stdout, `jarvis${subject}> `);
359
+ };
360
+ return {
361
+ token(event) {
362
+ if (quiet)
363
+ return;
364
+ const text = event.text ?? "";
365
+ if (event.reset) {
366
+ if (printed) {
367
+ writeLine(io.stdout, "");
368
+ writeLine(io.stdout, dim(" — that draft was replaced —", styled));
369
+ opened = false;
370
+ }
371
+ printed = "";
372
+ }
373
+ if (!text)
374
+ return;
375
+ open("");
376
+ printed += text;
377
+ writeFragment(io.stdout, text);
378
+ },
379
+ interrupt() {
380
+ if (quiet || !opened)
381
+ return;
382
+ writeLine(io.stdout, "");
383
+ opened = false;
384
+ },
385
+ settle(reply, subject, revised) {
386
+ if (quiet)
387
+ return;
388
+ if (!printed) {
389
+ // Nothing streamed: a non-streaming dashboard, `--no-stream`, or a
390
+ // turn whose words never arrived. Exactly the pre-ticket line, dim
391
+ // citation link and all (BLI-3570).
392
+ writeReply(io, `jarvis${subject}> `, reply);
393
+ return;
394
+ }
395
+ if (reply === printed) {
396
+ if (opened)
397
+ writeLine(io.stdout, "");
398
+ return;
399
+ }
400
+ if (reply.startsWith(printed)) {
401
+ // The gate only ADDED — citations going back under the answer, a
402
+ // fallback notice. Print the tail and leave the words alone; the
403
+ // citation block is exactly the thing BLI-3570 puts its link under, and
404
+ // it is arriving here rather than in the stream.
405
+ if (!opened)
406
+ writeFragment(io.stdout, `jarvis${subject}> `);
407
+ writeReplyContinuation(io, reply.slice(printed.length));
408
+ return;
409
+ }
410
+ if (opened)
411
+ writeLine(io.stdout, "");
412
+ writeLine(io.stdout, dim(revised ? ` — ${ANSWER_REVISED_LINE} —` : " — corrected —", styled));
413
+ writeReply(io, `jarvis${subject}> `, reply);
414
+ },
415
+ abandon() {
416
+ // The turn died with words already on screen. End the line so the
417
+ // failure sentence does not run on from a half-written answer.
418
+ if (!quiet && opened)
419
+ writeLine(io.stdout, "");
420
+ },
421
+ };
422
+ }
423
+ /**
424
+ * What the terminal says when the grounding gate changed an answer it had
425
+ * already printed. The browser's own mark says the same thing in one word
426
+ * (`ANSWER_REVISED_NOTE`, `apps/dashboard/src/lib/webchat/tool-trace.ts`);
427
+ * this package cannot import from the dashboard, so the sentence is written
428
+ * here in the same register rather than shared through a dependency that does
429
+ * not exist.
430
+ */
431
+ const ANSWER_REVISED_LINE = "revised: I checked that against its sources and changed what they did not back up";
256
432
  /**
257
433
  * The multipart body `/api/jarvis/cli` reads when a file is attached
258
434
  * (BLI-3414) — same field names the request handler parses, mirroring the
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The value readers every subcommand's decision table is written in terms of:
3
+ * one pass over `argv` into named flags and positionals, then one small
4
+ * question per value — is it non-empty, is it a URL, is it an email, is it a
5
+ * positive integer, is it a value this schema accepts.
6
+ *
7
+ * Split out of commands/local-args.ts (BLI-3578) so each parser reads as its
8
+ * own decision table rather than as parsing mixed with coercion. Moved
9
+ * verbatim: no logic changed, and `normalizeUrl` is still re-exported from
10
+ * `./local-args.js` for the callers that had it there.
11
+ *
12
+ * `rejectServiceRoleLikeArgument` runs on every token, flag or positional, and
13
+ * is why a service-role credential cannot enter a collector command from the
14
+ * command line at all.
15
+ */
16
+ const WORK_ROOT_FLAGS = ["--repo", "--workspace"];
17
+ export function parseNamedArgs(args, options) {
18
+ const allowed = new Set(options.allowedFlags);
19
+ const valueFlags = new Set(options.valueFlags);
20
+ const flags = new Map();
21
+ const flagValues = new Map();
22
+ const booleans = new Set();
23
+ const positionals = [];
24
+ for (let index = 0; index < args.length; index += 1) {
25
+ const arg = args[index] ?? "";
26
+ rejectServiceRoleLikeArgument(arg);
27
+ if (!arg.startsWith("--")) {
28
+ positionals.push(arg);
29
+ continue;
30
+ }
31
+ const [flag, inlineValue] = arg.split("=", 2);
32
+ if (!allowed.has(flag))
33
+ throw new Error(`Unknown flag: ${flag}`);
34
+ if (valueFlags.has(flag)) {
35
+ const value = inlineValue ?? args[index + 1];
36
+ if (!value || value.startsWith("--")) {
37
+ throw new Error(`${flag} requires a value.`);
38
+ }
39
+ rejectServiceRoleLikeArgument(value);
40
+ flags.set(flag, value);
41
+ const existing = flagValues.get(flag) ?? [];
42
+ existing.push(value);
43
+ flagValues.set(flag, existing);
44
+ if (inlineValue === undefined)
45
+ index += 1;
46
+ }
47
+ else {
48
+ if (inlineValue !== undefined)
49
+ throw new Error(`${flag} does not accept a value.`);
50
+ booleans.add(flag);
51
+ }
52
+ }
53
+ return { flags, flagValues, booleans, positionals };
54
+ }
55
+ export function workRootFlagValue(values) {
56
+ const provided = WORK_ROOT_FLAGS.filter((flag) => values.flags.has(flag));
57
+ if (provided.length === 0)
58
+ return undefined;
59
+ const uniqueValues = new Set(provided.map((flag) => values.flags.get(flag)).filter(Boolean));
60
+ if (uniqueValues.size > 1) {
61
+ throw new Error("--repo and --workspace must point to the same path.");
62
+ }
63
+ return values.flags.get("--workspace") ?? values.flags.get("--repo");
64
+ }
65
+ export function workRootFlagValues(values) {
66
+ const roots = [];
67
+ for (const flag of WORK_ROOT_FLAGS) {
68
+ roots.push(...(values.flagValues.get(flag) ?? []));
69
+ }
70
+ return roots;
71
+ }
72
+ export function optionalNonEmptyList(values) {
73
+ const filtered = values
74
+ .map((value) => optionalNonEmpty(value))
75
+ .filter((value) => Boolean(value));
76
+ return filtered.length > 0 ? filtered : undefined;
77
+ }
78
+ export function assertNoPositionals(positionals, command) {
79
+ if (positionals.length > 0) {
80
+ throw new Error(`${command} does not accept positional arguments.`);
81
+ }
82
+ }
83
+ export function optionalNonEmpty(value) {
84
+ const trimmed = value?.trim();
85
+ return trimmed ? trimmed : undefined;
86
+ }
87
+ export function optionalUrl(value) {
88
+ return value === undefined ? undefined : normalizeUrl(value);
89
+ }
90
+ export function optionalEmail(value) {
91
+ const trimmed = value?.trim().toLowerCase();
92
+ if (!trimmed)
93
+ return undefined;
94
+ if (!trimmed.includes("@")) {
95
+ throw new Error("--email must be a valid email address.");
96
+ }
97
+ return trimmed;
98
+ }
99
+ export function optionalPositiveInteger(value, flag) {
100
+ if (value === undefined)
101
+ return undefined;
102
+ const parsed = Number(value);
103
+ if (!Number.isInteger(parsed) || parsed < 1) {
104
+ throw new Error(`${flag} must be a positive integer.`);
105
+ }
106
+ return parsed;
107
+ }
108
+ export function optionalConfidence(value, flag) {
109
+ if (value === undefined)
110
+ return undefined;
111
+ const parsed = Number(value);
112
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
113
+ throw new Error(`${flag} must be a number between 0 and 1.`);
114
+ }
115
+ return parsed;
116
+ }
117
+ export function optionalSchemaValue(schema, value, flag) {
118
+ const trimmed = optionalNonEmpty(value);
119
+ if (!trimmed)
120
+ return undefined;
121
+ const parsed = schema.safeParse(trimmed);
122
+ if (!parsed.success || parsed.data === undefined) {
123
+ throw new Error(`${flag} has an unsupported value.`);
124
+ }
125
+ return parsed.data;
126
+ }
127
+ export function normalizeUrl(value) {
128
+ const trimmed = value.trim().replace(/\/+$/, "");
129
+ if (!trimmed)
130
+ throw new Error("URL value cannot be empty.");
131
+ return trimmed;
132
+ }
133
+ function rejectServiceRoleLikeArgument(value) {
134
+ if (!looksLikeServiceRoleSecret(value))
135
+ return;
136
+ throw new Error("Service-role credentials are not accepted by local collector commands.");
137
+ }
138
+ function looksLikeServiceRoleSecret(value) {
139
+ if (serviceCredentialNamePattern().test(value))
140
+ return true;
141
+ const parts = value.split(".");
142
+ if (parts.length !== 3)
143
+ return false;
144
+ try {
145
+ const payload = Buffer.from(base64UrlToBase64(parts[1] ?? ""), "base64").toString("utf8");
146
+ return serviceCredentialPayloadPattern().test(payload);
147
+ }
148
+ catch {
149
+ // Deliberately silent (BLI-3238), and it must stay silent: this is the
150
+ // "is this argument a service-role JWT?" test, so a value that will not
151
+ // decode is simply not one. Anything logged here would be a fragment of a
152
+ // credential.
153
+ return false;
154
+ }
155
+ }
156
+ function serviceCredentialNamePattern() {
157
+ return new RegExp([
158
+ ["SUPABASE", "SERVICE", "ROLE", "KEY"].join("[_-]?"),
159
+ ["service", "role"].join("[_-]?"),
160
+ ].join("|"), "i");
161
+ }
162
+ function serviceCredentialPayloadPattern() {
163
+ const privilegedRole = ["service", "role"].join("_");
164
+ return new RegExp(`"role"\\s*:\\s*"${privilegedRole}"`);
165
+ }
166
+ function base64UrlToBase64(value) {
167
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
168
+ return `${normalized}${"=".repeat((4 - (normalized.length % 4)) % 4)}`;
169
+ }