@bli-cockpit/cli 0.2.40 → 0.2.42

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.
@@ -2,6 +2,7 @@ import { collectCarState } from "./car-state.js";
2
2
  import fs from "node:fs/promises";
3
3
  import { makeSourceScan, makeUnavailableScan, } from "./common.js";
4
4
  import { collectGitState } from "./git-state.js";
5
+ import { describeError } from "../health-detail.js";
5
6
  import { collectRawEvidencePack, } from "./raw-evidence.js";
6
7
  import { generateDumbRiskFlags } from "./risk-flags.js";
7
8
  import { resolveTicketBinding, } from "./ticket-binding.js";
@@ -50,9 +51,11 @@ export async function runLocalSourceCollectors(options) {
50
51
  makeUnavailableScan(context, "claude_hooks", "claude-hooks", "claude_hooks_not_present"),
51
52
  ];
52
53
  // claude_jsonl availability (B.4): "ok" when ~/.claude/projects exists, else
53
- // skipped with claude_projects_dir_not_found so an operator on a non-Claude
54
- // machine sees why the claude funnel is empty. Collection itself is gated
55
- // separately by collect_claude_jsonl in the sync orchestrator.
54
+ // skipped with the reason it could not be read — absent, permission-denied,
55
+ // not a directory, or unreadable (BLI-3483) so an operator sees whether
56
+ // the empty claude funnel is normal or a wall they can take down. Collection
57
+ // itself is gated separately by collect_claude_jsonl in the sync
58
+ // orchestrator.
56
59
  const claudeJsonlScan = await makeClaudeJsonlScan(context, options.claudeProjectsDir);
57
60
  const candidates = collectBindingCandidates([
58
61
  git.scan,
@@ -93,9 +96,52 @@ async function makeClaudeJsonlScan(context, projectsDir) {
93
96
  if (!projectsDir) {
94
97
  return makeUnavailableScan(context, "claude_jsonl", "claude-jsonl", "claude_projects_dir_not_configured");
95
98
  }
96
- const present = await fs.stat(projectsDir).then((stat) => stat.isDirectory(), () => false);
97
- return makeSourceScan(context, "claude_jsonl", "claude-jsonl", present ? "ok" : "skipped", present ? "claude_projects_dir_present" : "claude_projects_dir_not_found");
99
+ // Until BLI-3483 every failure of this stat became `claude_projects_dir_
100
+ // not_found`, so a machine that HAS the folder and cannot open it — macOS
101
+ // TCC withholding Full Disk Access, a Windows ACL, a corporate MDM profile —
102
+ // reported the same word as a machine that has never run Claude Code. One
103
+ // repair is "nothing to do"; the other is "grant this app access, then every
104
+ // Claude session on this laptop starts collecting". Opposite meanings, one
105
+ // label, and the empty funnel looked normal either way.
106
+ const inspected = await fs.stat(projectsDir).then((stat) => ({ ok: true, isDirectory: stat.isDirectory() }), (error) => ({ ok: false, error }));
107
+ if (inspected.ok && inspected.isDirectory) {
108
+ return makeSourceScan(context, "claude_jsonl", "claude-jsonl", "ok", "claude_projects_dir_present");
109
+ }
110
+ const reason = inspected.ok
111
+ ? "claude_projects_dir_not_a_directory"
112
+ : claudeProjectsDirFailureReason(inspected.error);
113
+ // Never the path: it is under the operator's home directory.
114
+ console.error("[local-sources] claude projects directory unavailable", JSON.stringify({
115
+ reason,
116
+ fix_hint: CLAUDE_PROJECTS_FIX_HINTS[reason],
117
+ ...(inspected.ok ? {} : describeError(inspected.error)),
118
+ }));
119
+ return makeSourceScan(context, "claude_jsonl", "claude-jsonl", "skipped", reason);
120
+ }
121
+ /**
122
+ * `ENOENT`/`ENOTDIR` is the ordinary "this machine does not run Claude Code".
123
+ * `EACCES`/`EPERM` is a permission wall a person can take down. Anything else
124
+ * (`ELOOP`, `ENAMETOOLONG`, an I/O error) is named as unreadable rather than
125
+ * folded into either, because guessing between them is what created this bug.
126
+ */
127
+ function claudeProjectsDirFailureReason(error) {
128
+ const code = error && typeof error === "object"
129
+ ? error.code
130
+ : undefined;
131
+ if (code === "ENOENT" || code === "ENOTDIR") {
132
+ return "claude_projects_dir_not_found";
133
+ }
134
+ if (code === "EACCES" || code === "EPERM") {
135
+ return "claude_projects_dir_permission_denied";
136
+ }
137
+ return "claude_projects_dir_unreadable";
98
138
  }
139
+ const CLAUDE_PROJECTS_FIX_HINTS = {
140
+ claude_projects_dir_not_found: "nothing to repair unless this machine runs Claude Code",
141
+ claude_projects_dir_permission_denied: "macOS: grant the terminal/CLI Full Disk Access in System Settings > Privacy & Security; Windows: check the folder ACL on the user profile",
142
+ claude_projects_dir_not_a_directory: "a file sits where the projects directory should be; move or remove it",
143
+ claude_projects_dir_unreadable: "read the error_code beside this line; the folder exists but the stat failed",
144
+ };
99
145
  function isGitStateFacts(value) {
100
146
  return Boolean(value && typeof value === "object" && "repo_root" in value);
101
147
  }
package/dist/autostart.js CHANGED
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
5
5
  import { savedDiscoveryLimitArgs } from "./discovery-limits.js";
6
+ import { redactedHealthDetail } from "./health-detail.js";
6
7
  /** launchd LaunchAgent label; matches docs/runbooks/cockpit-launchd-sync.md. */
7
8
  export const AUTOSTART_LABEL = "com.bli.cockpit.sync";
8
9
  export const WINDOWS_AUTOSTART_TASK_NAME = "BLI Cockpit Sync";
@@ -100,6 +101,28 @@ export async function installAutostartAgent(options) {
100
101
  await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
101
102
  const load = await options.exec("launchctl", ["load", plistPath]);
102
103
  const loaded = load.code === 0;
104
+ // Both branches log, for the same reason the Windows path does: a line that
105
+ // only fires on failure cannot answer "did background collection get
106
+ // installed at all today?". Until BLI-3483 the macOS failure existed only in
107
+ // the returned `message`, which nothing on the sync path reads — a Mac could
108
+ // finish `cockpit onboard` with no scheduler and nothing in sync.err.log.
109
+ // Metadata only: launchctl's stderr can carry the state directory, so the
110
+ // exit code and the redacted first line travel, never the raw text.
111
+ if (loaded) {
112
+ console.error("[autostart] launchd agent loaded", JSON.stringify({
113
+ label: AUTOSTART_LABEL,
114
+ interval_seconds: intervalSeconds,
115
+ watch_path_count: watchPaths.length,
116
+ }));
117
+ }
118
+ else {
119
+ console.error("[autostart] launchctl load failed; this Mac has no background collection", JSON.stringify({
120
+ reason: "autostart_load_failed",
121
+ label: AUTOSTART_LABEL,
122
+ exit_code: load.code,
123
+ detail: launchctlDetail(load),
124
+ }));
125
+ }
103
126
  return {
104
127
  status: "installed",
105
128
  label: AUTOSTART_LABEL,
@@ -112,10 +135,27 @@ export async function installAutostartAgent(options) {
112
135
  ...(loaded
113
136
  ? {}
114
137
  : {
115
- message: `launchctl load exited ${load.code}: ${load.stderr.trim() || "unknown error"}`,
138
+ message: `launchctl load exited ${load.code}: ${launchctlDetail(load)}`,
116
139
  }),
117
140
  };
118
141
  }
142
+ /**
143
+ * What `launchctl` actually said, or an honest account of it saying nothing.
144
+ *
145
+ * "unknown error" was the old fallback, and it is the least useful string in
146
+ * the file: `launchctl load` exits nonzero with EMPTY stderr for the ordinary
147
+ * cases — the agent is already loaded, or the plist was rejected — so the
148
+ * message an operator saw named neither the exit code's meaning nor the
149
+ * silence itself (BLI-3483). Exit 1 with no output is a fact; say so, and say
150
+ * what to do next. stdout is consulted too because launchctl is inconsistent
151
+ * about which stream it uses across macOS versions.
152
+ */
153
+ function launchctlDetail(result) {
154
+ const spoken = result.stderr.trim() || result.stdout.trim();
155
+ if (spoken)
156
+ return redactedHealthDetail(spoken);
157
+ return `no output on either stream (exit ${result.code}); the agent may already be loaded, or launchd rejected the plist — check \`launchctl print gui/$(id -u)/${AUTOSTART_LABEL}\``;
158
+ }
119
159
  /**
120
160
  * Removes the LaunchAgent. Reports `absent` when there was nothing to remove so
121
161
  * the command is safe to run repeatedly.
@@ -379,8 +419,9 @@ async function windowsTaskStatus(options) {
379
419
  syncLogPath: windowsSyncLogPath(options.homeDir),
380
420
  })}`;
381
421
  const currentScript = await readFile(scriptPath, "utf8").catch(() => null);
382
- if (currentScript !== expectedScript) {
383
- registrationProblems.push("sync script does not match the current roots or Tower runtime");
422
+ const scriptDifference = renderedFileDifference(currentScript, expectedScript);
423
+ if (scriptDifference) {
424
+ registrationProblems.push(`sync script does not match the current roots or Tower runtime (${describeDifference(scriptDifference)})`);
384
425
  }
385
426
  }
386
427
  // Same shape as the sync-script checks: existence always, content only when
@@ -398,8 +439,9 @@ async function windowsTaskStatus(options) {
398
439
  scriptPath,
399
440
  });
400
441
  const currentLauncher = await readFile(launcherPath, "utf8").catch(() => null);
401
- if (currentLauncher !== expectedLauncher) {
402
- registrationProblems.push("sync launcher does not match the current Tower runtime");
442
+ const launcherDifference = renderedFileDifference(currentLauncher, expectedLauncher);
443
+ if (launcherDifference) {
444
+ registrationProblems.push(`sync launcher does not match the current Tower runtime (${describeDifference(launcherDifference)})`);
403
445
  }
404
446
  }
405
447
  // Both branches log. A line that only fires on failure cannot answer "did
@@ -431,6 +473,64 @@ async function windowsTaskStatus(options) {
431
473
  : {}),
432
474
  };
433
475
  }
476
+ function renderedFileDifference(current, expected) {
477
+ const expectedLines = normalizedScriptLines(expected);
478
+ if (current === null) {
479
+ return {
480
+ reason: "file_unreadable",
481
+ first_differing_line: null,
482
+ current_line_count: null,
483
+ expected_line_count: expectedLines.length,
484
+ };
485
+ }
486
+ if (hasByteOrderMark(current) !== hasByteOrderMark(expected)) {
487
+ return {
488
+ reason: "byte_order_mark",
489
+ first_differing_line: 0,
490
+ current_line_count: normalizedScriptLines(current).length,
491
+ expected_line_count: expectedLines.length,
492
+ };
493
+ }
494
+ const currentLines = normalizedScriptLines(current);
495
+ const shared = Math.min(currentLines.length, expectedLines.length);
496
+ for (let index = 0; index < shared; index += 1) {
497
+ if (currentLines[index] !== expectedLines[index]) {
498
+ return {
499
+ reason: "line_content",
500
+ first_differing_line: index,
501
+ current_line_count: currentLines.length,
502
+ expected_line_count: expectedLines.length,
503
+ };
504
+ }
505
+ }
506
+ if (currentLines.length !== expectedLines.length) {
507
+ return {
508
+ reason: "line_count",
509
+ first_differing_line: shared,
510
+ current_line_count: currentLines.length,
511
+ expected_line_count: expectedLines.length,
512
+ };
513
+ }
514
+ return null;
515
+ }
516
+ /** Safe to print: a reason label, a line index, two counts. No content. */
517
+ function describeDifference(difference) {
518
+ if (difference.reason === "file_unreadable")
519
+ return "file could not be read";
520
+ if (difference.reason === "byte_order_mark")
521
+ return "byte-order mark differs";
522
+ return `${difference.reason} at line ${difference.first_differing_line}, ${difference.current_line_count} lines on disk vs ${difference.expected_line_count} expected`;
523
+ }
524
+ function normalizedScriptLines(value) {
525
+ const withoutBom = hasByteOrderMark(value) ? value.slice(UTF8_BOM.length) : value;
526
+ return withoutBom
527
+ .replace(/\r\n/gu, "\n")
528
+ .replace(/\s+$/u, "")
529
+ .split("\n");
530
+ }
531
+ function hasByteOrderMark(value) {
532
+ return value.startsWith(UTF8_BOM);
533
+ }
434
534
  function windowsAutostartScriptPath(homeDir) {
435
535
  return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_SCRIPT_NAME);
436
536
  }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * `cockpit brief edit` — rewrite the page like a document (BLI-3458, S2.7).
3
+ *
4
+ * The browser has had this since BLI-3120: click into a line, fix the words,
5
+ * press save, and the edit IS the correction — the exact wrong words, the exact
6
+ * right words, and optionally why, which is strictly more than any complaint
7
+ * could carry. This is the same door from a terminal.
8
+ *
9
+ * Four steps, and the order matters:
10
+ *
11
+ * 1. `GET /api/jarvis/brief?editable=1` — the page as a file, one
12
+ * `[claimId] the prose` line per rewritable claim. The server builds it
13
+ * from the SAME `indexClaims` the edit door diffs against, so an untouched
14
+ * line comes back byte-identical instead of being filed as an edit.
15
+ * 2. Open `$VISUAL`/`$EDITOR` on a temp copy (`editor.ts` owns that, and it is
16
+ * the one place in this package that spawns a program a PERSON chose). No
17
+ * editor, or nothing to open one on — piped input, a script, CI — and the
18
+ * edited document is read from stdin instead.
19
+ * 3. Diff HERE, not on the server. Only lines that really changed are sent, so
20
+ * an accidental save writes nothing and publishes no version.
21
+ * 4. `POST /api/jarvis/edit` — the same door the browser's save posts to.
22
+ *
23
+ * Two refusals happen before anything is sent, both by name:
24
+ *
25
+ * - **A claim id that vanished.** The document is a set of lines, not a text
26
+ * file: deleting one is not "delete that sentence", it is "I have no opinion
27
+ * about it", and the two are indistinguishable to a diff. Sending it as an
28
+ * emptied line would publish a page with a hole in it.
29
+ * - **A claim id that was invented.** Filing a correction against a clause
30
+ * nobody can find is how a ledger fills with rows that mean nothing.
31
+ */
32
+ import { colorEnabled, dim, isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
33
+ import { editInEditor, resolveEditorCommand } from "./editor.js";
34
+ import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
35
+ const REQUEST_DEADLINE_MS = 120_000;
36
+ /** A page is long; a document that arrives bigger than this is not one of ours. */
37
+ const MAX_DOCUMENT_CHARS = 400_000;
38
+ export async function runBriefEdit(command, io) {
39
+ const session = await loadPairedSession("brief edit", command.homeDir);
40
+ const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
41
+ const log = (line) => writeLine(io.stderr, line);
42
+ const startedAt = Date.now();
43
+ // ── 1. The page, as a document ────────────────────────────────────────────
44
+ const read = await towerJsonRequest({
45
+ dashboardUrl,
46
+ path: `/api/jarvis/brief${briefQuery(command)}`,
47
+ deviceToken: session.device_token,
48
+ fetch: io.fetch,
49
+ method: "GET",
50
+ label: "brief:edit",
51
+ timeoutMs: REQUEST_DEADLINE_MS,
52
+ log,
53
+ });
54
+ if (!read.ok)
55
+ return fail(command, io, read.reason, read.detail);
56
+ const brief = read.body;
57
+ const personId = brief.page?.personId;
58
+ const original = brief.editable?.document;
59
+ const lines = (brief.editable?.lines ?? []).filter((line) => typeof line.claimId === "string" && typeof line.text === "string");
60
+ if (!brief.ok || !personId || typeof original !== "string" || lines.length === 0) {
61
+ return fail(command, io, brief.error ?? "no_page", brief.reply ?? "There is no page to edit.");
62
+ }
63
+ // ── 2. Their editor, or their stdin ───────────────────────────────────────
64
+ const edited = await collectEditedDocument(io, original, log);
65
+ if (!edited.ok)
66
+ return fail(command, io, edited.reason, edited.detail);
67
+ // ── 3. The diff, here ─────────────────────────────────────────────────────
68
+ const diff = diffDocument(lines, edited.text);
69
+ if (diff.invented.length > 0) {
70
+ return fail(command, io, "invented_claim", `The page has no line called “${diff.invented[0]}”. Change the words after an id, never the id ` +
71
+ "itself — nothing has been sent to Tower.");
72
+ }
73
+ if (diff.deleted.length > 0) {
74
+ return fail(command, io, "deleted_claim", `The line [${diff.deleted[0]}] is missing from what you saved. Put it back — a line you delete ` +
75
+ "reads as no opinion at all, not as a sentence to remove, and Tower cannot tell those apart. " +
76
+ "Nothing has been sent.");
77
+ }
78
+ if (diff.edits.length === 0) {
79
+ const styled = colorEnabled(io);
80
+ if (command.json) {
81
+ writeLine(io.stdout, JSON.stringify({ ok: true, saved: 0, reason: "nothing_changed" }));
82
+ }
83
+ else {
84
+ writeLine(io.stdout, dim("Nothing changed, so nothing was written.", styled));
85
+ }
86
+ writeLine(io.stderr, `[brief edit] nothing changed ${JSON.stringify({
87
+ lines: lines.length,
88
+ elapsed_ms: Date.now() - startedAt,
89
+ })}`);
90
+ return 0;
91
+ }
92
+ // ── 4. The same door the browser's save posts to ──────────────────────────
93
+ const saved = await towerJsonRequest({
94
+ dashboardUrl,
95
+ path: "/api/jarvis/edit",
96
+ deviceToken: session.device_token,
97
+ fetch: io.fetch,
98
+ label: "brief:edit:save",
99
+ timeoutMs: REQUEST_DEADLINE_MS,
100
+ log,
101
+ body: {
102
+ personId,
103
+ pageId: brief.page?.pageId ?? null,
104
+ edits: diff.edits,
105
+ ...(command.reason ? { reason: command.reason } : {}),
106
+ },
107
+ });
108
+ if (!saved.ok)
109
+ return fail(command, io, saved.reason, saved.detail);
110
+ const answer = saved.body;
111
+ writeOutcome(command, io, answer, diff.edits.length);
112
+ writeLine(io.stderr, `[brief edit] saved ${JSON.stringify({
113
+ sent: diff.edits.length,
114
+ saved: answer.saved ?? null,
115
+ versions: answer.versions ?? null,
116
+ skipped: answer.skipped?.length ?? 0,
117
+ overruled: answer.overruled?.length ?? 0,
118
+ reason: answer.reason ?? null,
119
+ had_reason: command.reason != null,
120
+ subject: command.subject ? "selected" : "caller",
121
+ elapsed_ms: Date.now() - startedAt,
122
+ })}`);
123
+ return 0;
124
+ }
125
+ function briefQuery(command) {
126
+ const params = new URLSearchParams({ editable: "1" });
127
+ if (command.subject)
128
+ params.set("p", command.subject);
129
+ if (command.version)
130
+ params.set("v", command.version);
131
+ return `?${params.toString()}`;
132
+ }
133
+ /**
134
+ * The edited document.
135
+ *
136
+ * Piped stdin wins, and deliberately: somebody who prepared a file has already
137
+ * done the editing, and opening an editor on top of it would be the command
138
+ * ignoring what it was handed. Otherwise the editor opens — and when there is
139
+ * neither, the refusal names both ways out rather than guessing `vi` at
140
+ * somebody who has never used it.
141
+ */
142
+ async function collectEditedDocument(io, original, log) {
143
+ if (!isInteractiveStdin(io)) {
144
+ let piped;
145
+ try {
146
+ piped = await readPipedText(io.stdin, {
147
+ maxChars: MAX_DOCUMENT_CHARS,
148
+ overflowMessage: `An edited page is limited to ${MAX_DOCUMENT_CHARS} characters.`,
149
+ });
150
+ }
151
+ catch (error) {
152
+ return {
153
+ ok: false,
154
+ reason: "stdin_unreadable",
155
+ detail: error instanceof Error ? error.message : String(error),
156
+ };
157
+ }
158
+ if (piped.trim() === "") {
159
+ return {
160
+ ok: false,
161
+ reason: "nothing_on_stdin",
162
+ detail: "Nothing arrived on stdin. Run `cockpit brief edit` in a terminal to open your editor, " +
163
+ "or pipe the edited document in: `cockpit brief --editable > page.md; … ; " +
164
+ "cockpit brief edit < page.md`.",
165
+ };
166
+ }
167
+ log(`[brief edit] read from stdin ${JSON.stringify({ chars: piped.length })}`);
168
+ return { ok: true, text: piped, via: "stdin" };
169
+ }
170
+ if (!resolveEditorCommand(io.env)) {
171
+ return {
172
+ ok: false,
173
+ reason: "no_editor_configured",
174
+ detail: "No editor is configured. Set EDITOR (or VISUAL) — `export EDITOR=nano` — or pipe the " +
175
+ "edited document in on stdin instead.",
176
+ };
177
+ }
178
+ const session = await editInEditor({
179
+ contents: original,
180
+ suffix: ".md",
181
+ env: io.env,
182
+ log,
183
+ });
184
+ if (!session.ok)
185
+ return { ok: false, reason: session.reason, detail: session.detail };
186
+ return { ok: true, text: session.text, via: "editor" };
187
+ }
188
+ /**
189
+ * What actually changed, decided here rather than on the server.
190
+ *
191
+ * The parse mirrors `apps/dashboard/src/lib/jarvis/serialize/editable-document.ts`,
192
+ * which produced the document: `#` is a comment, a `[id]` starts a line, a
193
+ * following un-prefixed line is that line wrapped rather than a new one. It only
194
+ * ever has to read what the server wrote, and the `unchanged` case proves it
195
+ * did — a parser that got this wrong would report every line as an edit, which
196
+ * is exactly the failure `editable-document.test.ts` holds the server side to.
197
+ */
198
+ export function diffDocument(original, edited) {
199
+ const before = new Map(original.filter((line) => line.rewritable).map((line) => [line.claimId, normalize(line.text)]));
200
+ const seen = new Set();
201
+ const diff = { edits: [], invented: [], deleted: [] };
202
+ for (const parsed of parseDocument(edited)) {
203
+ if (seen.has(parsed.claimId))
204
+ continue;
205
+ seen.add(parsed.claimId);
206
+ const stored = before.get(parsed.claimId);
207
+ if (stored === undefined) {
208
+ diff.invented.push(parsed.claimId);
209
+ continue;
210
+ }
211
+ const after = normalize(parsed.text);
212
+ if (after !== stored)
213
+ diff.edits.push({ claimId: parsed.claimId, after });
214
+ }
215
+ for (const claimId of before.keys()) {
216
+ if (!seen.has(claimId))
217
+ diff.deleted.push(claimId);
218
+ }
219
+ return diff;
220
+ }
221
+ /** The same collapse `normalizeEditedText` does server-side, so the two agree. */
222
+ function normalize(text) {
223
+ return text.replace(/ /g, " ").replace(/\s+/g, " ").trim();
224
+ }
225
+ const CLAIM_LINE = /^\[([^\]\s]+)\]\s?(.*)$/;
226
+ export function parseDocument(text) {
227
+ const out = [];
228
+ let current = null;
229
+ for (const raw of text.split(/\r?\n/)) {
230
+ const line = raw.trim();
231
+ if (line.startsWith("#")) {
232
+ current = null;
233
+ continue;
234
+ }
235
+ const match = CLAIM_LINE.exec(line);
236
+ if (match) {
237
+ current = { claimId: match[1], text: (match[2] ?? "").trim() };
238
+ out.push(current);
239
+ continue;
240
+ }
241
+ if (line === "") {
242
+ current = null;
243
+ continue;
244
+ }
245
+ if (current)
246
+ current.text = `${current.text} ${line}`.trim();
247
+ }
248
+ return out;
249
+ }
250
+ function writeOutcome(command, io, answer, sent) {
251
+ if (command.json) {
252
+ writeLine(io.stdout, JSON.stringify({ ok: true, sent, ...answer }));
253
+ return;
254
+ }
255
+ const styled = colorEnabled(io);
256
+ const saved = answer.saved ?? 0;
257
+ const versions = answer.versions;
258
+ writeLine(io.stdout, saved === 1
259
+ ? "One line rewritten, and Tower has it."
260
+ : `${saved} lines rewritten, and Tower has them.`);
261
+ if (typeof versions === "number") {
262
+ writeLine(io.stdout, dim(`Your page now has ${versions} versions.`, styled));
263
+ }
264
+ else if (answer.reason) {
265
+ // The edit is on the record and the page did not move. Never silent: the
266
+ // server named which of the four reasons it was and it goes straight out.
267
+ writeLine(io.stdout, dim(`The page itself was not republished (${answer.reason}); your words are on the record.`, styled));
268
+ }
269
+ for (const skip of answer.skipped ?? []) {
270
+ writeLine(io.stdout, dim(`Not applied: [${skip.claimId ?? "?"}] — ${skip.reason ?? "no reason given"}.`, styled));
271
+ }
272
+ // The push-back is the point, not an error path: the record disagreed with
273
+ // what they typed, their words were kept anyway, and the disagreement is said
274
+ // out loud rather than quietly stored.
275
+ for (const overruled of answer.overruled ?? []) {
276
+ if (overruled.pushBack)
277
+ writeLine(io.stdout, overruled.pushBack);
278
+ }
279
+ }
280
+ function fail(command, io, reason, detail) {
281
+ if (command.json) {
282
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
283
+ }
284
+ else {
285
+ writeLine(io.stderr, detail);
286
+ }
287
+ writeLine(io.stderr, `[brief edit] not saved ${JSON.stringify({ reason })}`);
288
+ return 1;
289
+ }