@bli-cockpit/cli 0.2.98 → 0.2.100

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 (34) hide show
  1. package/dist/cli.js +13 -0
  2. package/dist/commands/backfill-checkpoint.js +3 -1
  3. package/dist/commands/backfill-issues.js +8 -55
  4. package/dist/commands/backfill-report.js +22 -6
  5. package/dist/commands/backfill-scan.js +2 -1
  6. package/dist/commands/backfill-skip-policy.js +134 -0
  7. package/dist/commands/careers.js +16 -0
  8. package/dist/commands/doctor-pipeline-verdicts.js +238 -0
  9. package/dist/commands/doctor-pipeline.js +37 -107
  10. package/dist/commands/doctor.js +8 -4
  11. package/dist/commands/local-args-tower-admin.js +17 -6
  12. package/dist/commands/local-args-tower-careers.js +20 -0
  13. package/dist/commands/local-args-tower-pages.js +23 -3
  14. package/dist/commands/local-args-tower-usage.js +8 -0
  15. package/dist/commands/local-args-tower.js +3 -1
  16. package/dist/commands/local-args.js +5 -1
  17. package/dist/commands/local-help-commands-tower.js +32 -5
  18. package/dist/commands/local-help-commands.js +11 -2
  19. package/dist/commands/local-help.js +8 -3
  20. package/dist/commands/local.js +13 -0
  21. package/dist/commands/memory-hook-counts.js +29 -8
  22. package/dist/commands/notes-file.js +8 -1
  23. package/dist/commands/notes-folders.js +35 -0
  24. package/dist/commands/notes-writes.js +74 -4
  25. package/dist/commands/notes.js +11 -3
  26. package/dist/commands/ops-render.js +5 -1
  27. package/dist/commands/public-root.js +1 -1
  28. package/dist/commands/usage.js +23 -0
  29. package/dist/crash-guard.js +167 -0
  30. package/dist/cursors/backfill-completion-marker.js +135 -0
  31. package/dist/cursors/backfill-cursor.js +18 -99
  32. package/dist/process-runner.js +39 -1
  33. package/dist/sync-lock.js +10 -1
  34. package/package.json +5 -5
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The all-history completion marker: the one document that says an `--all`
3
+ * backfill finished, and the only proof `cockpit doctor` accepts for its
4
+ * `backfill-complete` row.
5
+ *
6
+ * It lives beside the cursor and not inside it because it answers a different
7
+ * question. The cursor is where the sweep GOT TO and is rewritten constantly;
8
+ * the marker is a claim about a finished scope, with its own schema version,
9
+ * its own 24-hour revalidation, and its own rule about which leftovers are
10
+ * allowed to ride along on a run that still counts as complete (a file over
11
+ * the upload cap, BLI-2727; helper transcripts over the per-session sidecar
12
+ * cap, BLI-4303). Both of those are recorded here, never silently dropped.
13
+ *
14
+ * Every name is re-exported from `./backfill-cursor.js`, the address its
15
+ * callers already know.
16
+ */
17
+ import fs from "node:fs/promises";
18
+ import path from "node:path";
19
+ import { describeError, isMissingFileFailure } from "../health-detail.js";
20
+ import { backfillCollectionScopeId, parseBackfillCursor, writePrivateBackfillJson, } from "./backfill-cursor.js";
21
+ export const BACKFILL_COMPLETION_MARKER_FILENAME = "backfill-complete.json";
22
+ export const BACKFILL_COVERAGE_VERSION = "redacted-session-backfill.v3";
23
+ export const BACKFILL_COMPLETION_RECHECK_MS = 24 * 60 * 60 * 1_000;
24
+ export async function writeBackfillCompletionMarker(paths, marker) {
25
+ await writePrivateBackfillJson(backfillCompletionMarkerPath(paths), marker);
26
+ }
27
+ export async function readBackfillCompletionMarker(paths) {
28
+ try {
29
+ const raw = JSON.parse(await fs.readFile(backfillCompletionMarkerPath(paths), "utf8"));
30
+ return parseBackfillCompletionMarker(raw);
31
+ }
32
+ catch (error) {
33
+ // No marker means backfill has not finished, which is the ordinary state.
34
+ // A marker that cannot be read means a finished backfill will be re-run,
35
+ // and the machine should say so rather than quietly redo a day of work.
36
+ if (!isMissingFileFailure(error)) {
37
+ console.error("[backfill-cursor] completion marker unreadable, treating backfill as unfinished", JSON.stringify({
38
+ reason: "backfill_marker_unreadable",
39
+ marker_file: BACKFILL_COMPLETION_MARKER_FILENAME,
40
+ ...describeError(error),
41
+ }));
42
+ }
43
+ return null;
44
+ }
45
+ }
46
+ export function backfillCompletionCovers(marker, collectionRoots, requiredSources, now = new Date()) {
47
+ if (!marker)
48
+ return false;
49
+ if (marker.coverage_version !== BACKFILL_COVERAGE_VERSION)
50
+ return false;
51
+ if (marker.collection_scope_id !== backfillCollectionScopeId(collectionRoots)) {
52
+ return false;
53
+ }
54
+ const revalidateAfter = Date.parse(marker.revalidate_after);
55
+ if (!Number.isFinite(revalidateAfter) || now.getTime() >= revalidateAfter) {
56
+ return false;
57
+ }
58
+ const completedSources = new Set(marker.sources);
59
+ return requiredSources.every((source) => completedSources.has(source));
60
+ }
61
+ export function backfillCompletionMarkerPath(paths) {
62
+ return path.join(paths.cursors_dir, BACKFILL_COMPLETION_MARKER_FILENAME);
63
+ }
64
+ function parseBackfillCompletionMarker(value) {
65
+ if (!value || typeof value !== "object")
66
+ return null;
67
+ const record = value;
68
+ if (record["schema_version"] !== "cockpit-backfill-complete.v2" ||
69
+ record["coverage_version"] !== BACKFILL_COVERAGE_VERSION) {
70
+ return null;
71
+ }
72
+ const collectionScopeId = optionalString(record["collection_scope_id"]);
73
+ const completedAt = optionalString(record["completed_at"]);
74
+ const revalidateAfter = optionalString(record["revalidate_after"]);
75
+ const rawSources = record["sources"];
76
+ if (!collectionScopeId ||
77
+ !completedAt ||
78
+ !revalidateAfter ||
79
+ !Number.isFinite(Date.parse(revalidateAfter)) ||
80
+ !Array.isArray(rawSources)) {
81
+ return null;
82
+ }
83
+ const sources = [
84
+ ...new Set(rawSources.filter((source) => source === "codex" || source === "claude_code")),
85
+ ];
86
+ if (sources.length === 0)
87
+ return null;
88
+ return {
89
+ schema_version: "cockpit-backfill-complete.v2",
90
+ coverage_version: BACKFILL_COVERAGE_VERSION,
91
+ collection_scope_id: collectionScopeId,
92
+ sources,
93
+ completed_at: completedAt,
94
+ revalidate_after: revalidateAfter,
95
+ cursor: parseBackfillCursor(record["cursor"]),
96
+ ...(parseOversizedSkips(record["oversized_skips"])
97
+ ? { oversized_skips: parseOversizedSkips(record["oversized_skips"]) }
98
+ : {}),
99
+ ...(parseSidecarCapSkips(record["sidecar_cap_skips"])
100
+ ? { sidecar_cap_skips: parseSidecarCapSkips(record["sidecar_cap_skips"]) }
101
+ : {}),
102
+ };
103
+ }
104
+ function parseSidecarCapSkips(value) {
105
+ if (!value || typeof value !== "object")
106
+ return null;
107
+ const record = value;
108
+ if (record["reason"] !== "claude_sidecar_limit_applied")
109
+ return null;
110
+ const count = optionalNumber(record["count"]);
111
+ if (count === null || count <= 0)
112
+ return null;
113
+ return { reason: "claude_sidecar_limit_applied", count };
114
+ }
115
+ function parseOversizedSkips(value) {
116
+ if (!value || typeof value !== "object")
117
+ return null;
118
+ const record = value;
119
+ if (record["reason"] !== "file_too_large")
120
+ return null;
121
+ const count = optionalNumber(record["count"]);
122
+ if (count === null || count <= 0)
123
+ return null;
124
+ const rawByteSizes = record["byte_sizes"];
125
+ const byteSizes = Array.isArray(rawByteSizes)
126
+ ? rawByteSizes.filter((entry) => typeof entry === "number" && Number.isFinite(entry) && entry >= 0)
127
+ : [];
128
+ return { reason: "file_too_large", count, byte_sizes: byteSizes };
129
+ }
130
+ function optionalString(value) {
131
+ return typeof value === "string" && value.trim() ? value : null;
132
+ }
133
+ function optionalNumber(value) {
134
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
135
+ }
@@ -1,11 +1,19 @@
1
+ /**
2
+ * Where the historical sweep got to, per source: the backfill cursor.
3
+ *
4
+ * Two things live in this folder and they are deliberately separate. This one
5
+ * is a moving position — a newest/oldest watermark, the identities sitting
6
+ * exactly on each boundary, and the census of what was seen — rewritten by
7
+ * every run. Its sibling `backfill-completion-marker.ts` is the claim that a
8
+ * whole scope is FINISHED, with its own schema and its own rules; every name
9
+ * it owns is re-exported at the bottom of this file, so callers keep one
10
+ * address.
11
+ */
1
12
  import crypto from "node:crypto";
2
13
  import fs from "node:fs/promises";
3
14
  import path from "node:path";
4
15
  import { describeError, isMissingFileFailure } from "../health-detail.js";
5
16
  export const BACKFILL_CURSOR_FILENAME = "backfill.json";
6
- export const BACKFILL_COMPLETION_MARKER_FILENAME = "backfill-complete.json";
7
- export const BACKFILL_COVERAGE_VERSION = "redacted-session-backfill.v3";
8
- export const BACKFILL_COMPLETION_RECHECK_MS = 24 * 60 * 60 * 1_000;
9
17
  export function emptyBackfillCursorState() {
10
18
  return {
11
19
  schema_version: "cockpit-backfill-cursor.v1",
@@ -37,29 +45,7 @@ export async function readBackfillCursor(paths) {
37
45
  }
38
46
  export async function writeBackfillCursor(paths, state) {
39
47
  const filePath = backfillCursorPath(paths);
40
- await writePrivateJson(filePath, state);
41
- }
42
- export async function writeBackfillCompletionMarker(paths, marker) {
43
- await writePrivateJson(backfillCompletionMarkerPath(paths), marker);
44
- }
45
- export async function readBackfillCompletionMarker(paths) {
46
- try {
47
- const raw = JSON.parse(await fs.readFile(backfillCompletionMarkerPath(paths), "utf8"));
48
- return parseBackfillCompletionMarker(raw);
49
- }
50
- catch (error) {
51
- // No marker means backfill has not finished, which is the ordinary state.
52
- // A marker that cannot be read means a finished backfill will be re-run,
53
- // and the machine should say so rather than quietly redo a day of work.
54
- if (!isMissingFileFailure(error)) {
55
- console.error("[backfill-cursor] completion marker unreadable, treating backfill as unfinished", JSON.stringify({
56
- reason: "backfill_marker_unreadable",
57
- marker_file: BACKFILL_COMPLETION_MARKER_FILENAME,
58
- ...describeError(error),
59
- }));
60
- }
61
- return null;
62
- }
48
+ await writePrivateBackfillJson(filePath, state);
63
49
  }
64
50
  /**
65
51
  * The cursor is only reusable inside the exact approved-root scope that
@@ -83,21 +69,6 @@ export function prepareBackfillCursorForScope(cursor, collectionRoots, sources)
83
69
  reset,
84
70
  };
85
71
  }
86
- export function backfillCompletionCovers(marker, collectionRoots, requiredSources, now = new Date()) {
87
- if (!marker)
88
- return false;
89
- if (marker.coverage_version !== BACKFILL_COVERAGE_VERSION)
90
- return false;
91
- if (marker.collection_scope_id !== backfillCollectionScopeId(collectionRoots)) {
92
- return false;
93
- }
94
- const revalidateAfter = Date.parse(marker.revalidate_after);
95
- if (!Number.isFinite(revalidateAfter) || now.getTime() >= revalidateAfter) {
96
- return false;
97
- }
98
- const completedSources = new Set(marker.sources);
99
- return requiredSources.every((source) => completedSources.has(source));
100
- }
101
72
  export function backfillCollectionScopeId(collectionRoots) {
102
73
  const normalized = [...new Set(collectionRoots.map(normalizeScopeRoot))].sort();
103
74
  return `scope-${crypto
@@ -178,9 +149,6 @@ export function recordBackfillScanCoverage(cursor, sources, coveredThrough, now)
178
149
  export function backfillCursorPath(paths) {
179
150
  return path.join(paths.cursors_dir, BACKFILL_CURSOR_FILENAME);
180
151
  }
181
- export function backfillCompletionMarkerPath(paths) {
182
- return path.join(paths.cursors_dir, BACKFILL_COMPLETION_MARKER_FILENAME);
183
- }
184
152
  function emptySourceCursor(collectionScopeId = null) {
185
153
  return {
186
154
  collection_scope_id: collectionScopeId,
@@ -194,7 +162,7 @@ function emptySourceCursor(collectionScopeId = null) {
194
162
  reason_counts: {},
195
163
  };
196
164
  }
197
- function parseBackfillCursor(value) {
165
+ export function parseBackfillCursor(value) {
198
166
  if (!value || typeof value !== "object")
199
167
  return emptyBackfillCursorState();
200
168
  const record = value;
@@ -228,58 +196,6 @@ function parseSourceCursor(value) {
228
196
  reason_counts: parseNumberRecord(record["reason_counts"]),
229
197
  };
230
198
  }
231
- function parseBackfillCompletionMarker(value) {
232
- if (!value || typeof value !== "object")
233
- return null;
234
- const record = value;
235
- if (record["schema_version"] !== "cockpit-backfill-complete.v2" ||
236
- record["coverage_version"] !== BACKFILL_COVERAGE_VERSION) {
237
- return null;
238
- }
239
- const collectionScopeId = optionalString(record["collection_scope_id"]);
240
- const completedAt = optionalString(record["completed_at"]);
241
- const revalidateAfter = optionalString(record["revalidate_after"]);
242
- const rawSources = record["sources"];
243
- if (!collectionScopeId ||
244
- !completedAt ||
245
- !revalidateAfter ||
246
- !Number.isFinite(Date.parse(revalidateAfter)) ||
247
- !Array.isArray(rawSources)) {
248
- return null;
249
- }
250
- const sources = [
251
- ...new Set(rawSources.filter((source) => source === "codex" || source === "claude_code")),
252
- ];
253
- if (sources.length === 0)
254
- return null;
255
- return {
256
- schema_version: "cockpit-backfill-complete.v2",
257
- coverage_version: BACKFILL_COVERAGE_VERSION,
258
- collection_scope_id: collectionScopeId,
259
- sources,
260
- completed_at: completedAt,
261
- revalidate_after: revalidateAfter,
262
- cursor: parseBackfillCursor(record["cursor"]),
263
- ...(parseOversizedSkips(record["oversized_skips"])
264
- ? { oversized_skips: parseOversizedSkips(record["oversized_skips"]) }
265
- : {}),
266
- };
267
- }
268
- function parseOversizedSkips(value) {
269
- if (!value || typeof value !== "object")
270
- return null;
271
- const record = value;
272
- if (record["reason"] !== "file_too_large")
273
- return null;
274
- const count = optionalNumber(record["count"]);
275
- if (count === null || count <= 0)
276
- return null;
277
- const rawByteSizes = record["byte_sizes"];
278
- const byteSizes = Array.isArray(rawByteSizes)
279
- ? rawByteSizes.filter((entry) => typeof entry === "number" && Number.isFinite(entry) && entry >= 0)
280
- : [];
281
- return { reason: "file_too_large", count, byte_sizes: byteSizes };
282
- }
283
199
  function normalizeScopeRoot(value) {
284
200
  const windowsStyle = path.win32.isAbsolute(value) && !path.posix.isAbsolute(value);
285
201
  if (windowsStyle)
@@ -305,7 +221,7 @@ function parseStringArray(value) {
305
221
  ...new Set(value.filter((entry) => typeof entry === "string" && entry.trim().length > 0)),
306
222
  ].sort();
307
223
  }
308
- async function writePrivateJson(filePath, value) {
224
+ export async function writePrivateBackfillJson(filePath, value) {
309
225
  await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
310
226
  const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
311
227
  const serialized = `${JSON.stringify(value, null, 2)}\n`;
@@ -334,4 +250,7 @@ function optionalString(value) {
334
250
  }
335
251
  function optionalNumber(value) {
336
252
  return typeof value === "number" && Number.isFinite(value) ? value : null;
337
- }
253
+ }
254
+ // Re-exported so every caller keeps importing the completion marker from
255
+ // `./backfill-cursor.js`, the address it has always had.
256
+ export { BACKFILL_COMPLETION_MARKER_FILENAME, BACKFILL_COMPLETION_RECHECK_MS, BACKFILL_COVERAGE_VERSION, backfillCompletionCovers, backfillCompletionMarkerPath, readBackfillCompletionMarker, writeBackfillCompletionMarker, } from "./backfill-completion-marker.js";
@@ -49,6 +49,34 @@ export function createCapturedExecRunner(options = {}) {
49
49
  });
50
50
  });
51
51
  }
52
+ /**
53
+ * Which stdio a re-exec may inherit (BLI-4110).
54
+ *
55
+ * `stdio: "inherit"` hands the child all three of the parent's handles. Under
56
+ * the Windows Task Scheduler — and any other non-interactive host — the
57
+ * parent's stdin is not a console: it can be a pipe nobody is writing to, or a
58
+ * socket that was never connected, and reading it raises `read ENOTCONN` from
59
+ * inside `child_process.spawn`. That is the error `cockpit do-everything`
60
+ * printed on the founder's box on 2026-09-09, from `reexecDoctor`, in a
61
+ * non-interactive shell.
62
+ *
63
+ * **stdout and stderr are still inherited, always.** They are what makes a
64
+ * scheduled run's output land in the log a person later reads, and dropping
65
+ * them to fix stdin would trade a crash for a silence — which is the failure
66
+ * this whole ticket is about.
67
+ *
68
+ * **stdin is inherited only when there is a console to read from.** A
69
+ * re-execed `do-everything` under a scheduler has nothing to type at it; the
70
+ * handle was pure liability. `"ignore"` gives the child a real, closed stdin
71
+ * rather than a broken one, so a child that does read it gets EOF instead of
72
+ * an error.
73
+ *
74
+ * Pure and exported so both host families are testable without a real TTY,
75
+ * per the supported-fleet contract in AGENTS.md.
76
+ */
77
+ export function interactiveStdio(input) {
78
+ return [input.stdinIsTty === true ? "inherit" : "ignore", "inherit", "inherit"];
79
+ }
52
80
  export function createInteractiveExecRunner(options = {}) {
53
81
  return (command, args, runOptions) => new Promise((resolve) => {
54
82
  const env = runOptions?.env ?? options.env ?? process.env;
@@ -56,8 +84,9 @@ export function createInteractiveExecRunner(options = {}) {
56
84
  platform: options.platform,
57
85
  env,
58
86
  });
87
+ const stdio = interactiveStdio({ stdinIsTty: process.stdin.isTTY });
59
88
  const child = spawn(invocation.command, invocation.args, {
60
- stdio: "inherit",
89
+ stdio,
61
90
  env,
62
91
  windowsVerbatimArguments: invocation.windowsVerbatimArguments,
63
92
  });
@@ -66,6 +95,15 @@ export function createInteractiveExecRunner(options = {}) {
66
95
  if (settled)
67
96
  return;
68
97
  settled = true;
98
+ // Named, because a spawn that never started and a child that ran and
99
+ // failed both used to arrive here as a bare code 1. Metadata only.
100
+ const record = error;
101
+ console.error("[process-runner] spawn failed", JSON.stringify({
102
+ reason: "spawn_failed",
103
+ error_code: record.code ?? null,
104
+ error_syscall: record.syscall ?? null,
105
+ stdin: stdio[0],
106
+ }));
69
107
  resolve({ code: 1, stdout: "", stderr: error.message });
70
108
  });
71
109
  child.on("close", (code) => {
package/dist/sync-lock.js CHANGED
@@ -15,7 +15,16 @@ import { describeError } from "./health-detail.js";
15
15
  */
16
16
  const LOCK_FILENAME = "sync.lock";
17
17
  const HEARTBEAT_INTERVAL_MS = 30_000;
18
- const STALE_TAKEOVER_MS = 2 * 60_000;
18
+ /**
19
+ * How long a lock may go without a heartbeat before the next sync takes it
20
+ * over. Exported because it is also what "the owner is still alive" MEANS on
21
+ * this machine: a receipt that says `sync_already_running` and carries a
22
+ * heartbeat inside this window was refused by a process that is still running
23
+ * (BLI-4303). Doctor reads it that way instead of calling a contended lock a
24
+ * failure.
25
+ */
26
+ export const SYNC_LOCK_STALE_TAKEOVER_MS = 2 * 60_000;
27
+ const STALE_TAKEOVER_MS = SYNC_LOCK_STALE_TAKEOVER_MS;
19
28
  export async function acquireSyncLock(paths, now = new Date()) {
20
29
  const lockPath = path.join(paths.state_dir, LOCK_FILENAME);
21
30
  await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.98",
3
+ "version": "0.2.100",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,11 +24,11 @@
24
24
  "pretypecheck": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/cli",
25
25
  "typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
26
26
  "pretest": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/cli",
27
- "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
+ "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.25",
31
- "@bli-cockpit/mcp": "0.1.29",
32
- "@bli-cockpit/telemetry-core": "0.1.42"
30
+ "@bli-cockpit/memory-mcp": "0.1.26",
31
+ "@bli-cockpit/mcp": "0.1.31",
32
+ "@bli-cockpit/telemetry-core": "0.1.43"
33
33
  }
34
34
  }