@bli-cockpit/cli 0.2.286 → 0.2.288

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 (92) hide show
  1. package/dist/capture-opt-outs.js +129 -0
  2. package/dist/collection-scope.js +201 -0
  3. package/dist/commands/agent-session-report.js +3 -0
  4. package/dist/commands/agreements-render.js +61 -0
  5. package/dist/commands/agreements.js +196 -0
  6. package/dist/commands/analyze.js +2 -0
  7. package/dist/commands/backfill-scan.js +16 -11
  8. package/dist/commands/backfill-scope.js +16 -4
  9. package/dist/commands/backfill-session-report.js +2 -0
  10. package/dist/commands/books-files.js +159 -0
  11. package/dist/commands/books-usage.js +14 -0
  12. package/dist/commands/books.js +214 -0
  13. package/dist/commands/cli-io.js +1 -3
  14. package/dist/commands/docs-share.js +21 -11
  15. package/dist/commands/doctor-access.js +7 -0
  16. package/dist/commands/doctor-registration.js +39 -0
  17. package/dist/commands/doctor-report.js +1 -0
  18. package/dist/commands/doctor-shelf.js +62 -0
  19. package/dist/commands/doctor-update.js +2 -0
  20. package/dist/commands/doctor-usage-telemetry.js +18 -2
  21. package/dist/commands/doctor.js +9 -0
  22. package/dist/commands/heartbeat-post.js +19 -0
  23. package/dist/commands/heartbeat.js +13 -2
  24. package/dist/commands/install-receipts.js +4 -0
  25. package/dist/commands/local-args-collector-setup.js +2 -0
  26. package/dist/commands/local-args-collector-status.js +7 -21
  27. package/dist/commands/local-args-tower-agreements.js +80 -0
  28. package/dist/commands/local-args-tower-docs-msg.js +17 -5
  29. package/dist/commands/local-args-tower.js +2 -0
  30. package/dist/commands/local-args.js +6 -1
  31. package/dist/commands/local-command-shapes-agreements.js +5 -0
  32. package/dist/commands/local-discovery.js +2 -2
  33. package/dist/commands/local-help-agreements.js +24 -0
  34. package/dist/commands/local-help-commands-tower-ops.js +3 -3
  35. package/dist/commands/local-help-commands-tower.js +13 -6
  36. package/dist/commands/local-help-commands.js +20 -5
  37. package/dist/commands/local-help.js +8 -2
  38. package/dist/commands/local.js +9 -0
  39. package/dist/commands/memory-capture-prompt.js +130 -0
  40. package/dist/commands/memory-install-claude.js +51 -4
  41. package/dist/commands/memory-install-report.js +19 -3
  42. package/dist/commands/memory-install-toml.js +22 -0
  43. package/dist/commands/memory-install.js +110 -11
  44. package/dist/commands/memory-store.js +5 -0
  45. package/dist/commands/memory-switch.js +165 -0
  46. package/dist/commands/memory-uninstall.js +187 -0
  47. package/dist/commands/memory-verb-list.js +6 -2
  48. package/dist/commands/onboard.js +5 -0
  49. package/dist/commands/public-root.js +1 -1
  50. package/dist/commands/roots-label.js +85 -0
  51. package/dist/commands/roots.js +28 -10
  52. package/dist/commands/session-sync-plan.js +8 -4
  53. package/dist/commands/session-sync-scan.js +25 -3
  54. package/dist/commands/session-sync-shelf.js +143 -0
  55. package/dist/commands/session-sync-upload.js +6 -2
  56. package/dist/commands/session-sync.js +68 -8
  57. package/dist/commands/settings.js +1 -1
  58. package/dist/commands/shelf.js +165 -0
  59. package/dist/commands/sources-check.js +2 -2
  60. package/dist/commands/sources-reconciliation.js +2 -1
  61. package/dist/commands/speed-seen.js +2 -2
  62. package/dist/commands/sync-doctor.js +2 -1
  63. package/dist/commands/sync-receipt.js +1 -0
  64. package/dist/commands/sync-report.js +1 -0
  65. package/dist/commands/sync-stub-heal.js +2 -2
  66. package/dist/commands/sync.js +12 -0
  67. package/dist/error-message.js +11 -0
  68. package/dist/evidence-reconcile-client.js +2 -0
  69. package/dist/evidence-redelivery-plan.js +6 -0
  70. package/dist/evidence-redelivery.js +6 -1
  71. package/dist/evidence-stubs-client.js +2 -0
  72. package/dist/evidence-transcripts-client.js +2 -0
  73. package/dist/evidence-upload-transport.js +2 -0
  74. package/dist/founder-standing-file.js +52 -0
  75. package/dist/founder-standing.js +117 -0
  76. package/dist/image-describe-client.js +2 -1
  77. package/dist/live-founder-memo.js +57 -0
  78. package/dist/local-state-config.js +13 -7
  79. package/dist/memory-capture-choice.js +73 -0
  80. package/dist/root-work.js +55 -7
  81. package/dist/shelf-decision.js +52 -0
  82. package/dist/shelf-headers.js +15 -0
  83. package/dist/shelf-reports.js +167 -0
  84. package/dist/shelf-state.js +112 -0
  85. package/dist/source-opt-out.js +83 -6
  86. package/dist/state-dir-audit.js +2 -0
  87. package/dist/tower-client.js +4 -1
  88. package/dist/upload-agent-artifacts.js +2 -0
  89. package/dist/upload-envelope-build.js +2 -2
  90. package/dist/upload-ingest-receipt.js +2 -0
  91. package/dist/upload-session-reports-wire.js +2 -0
  92. package/package.json +4 -4
@@ -2,6 +2,7 @@ import { inspectAgentRules, installAgentRules } from "../agent-rules.js";
2
2
  import { doctorRoots } from "./doctor-access.js";
3
3
  import { installAutostartAgent } from "../autostart.js";
4
4
  import { clearAutostartOptOut } from "../autostart-opt-out.js";
5
+ import { isMemoryCaptureOff } from "../memory-capture-choice.js";
5
6
  import { getCollectorRuntimePaths } from "../local-state.js";
6
7
  import { fail, needsFix, ok, skipped } from "./doctor-report.js";
7
8
  import { autostartDoctorStatus, autostartRegistrationRoots, readAutostartRegistration, } from "./autostart-reading.js";
@@ -73,10 +74,21 @@ export async function fixAutostartState(context) {
73
74
  * a hook failure, which is worse than waiting a day.
74
75
  */
75
76
  export async function checkMemoryState(context) {
77
+ if (await memoryOptedOut(context)) {
78
+ // BLI-22640: a dry run of the opted-out install writes nothing and says
79
+ // whether any of our entries (or `bli-tower`) still needs the fix.
80
+ const preview = await installMemoryIntegration({ ...memoryCommandFor(context), dryRun: true }, context.io);
81
+ return optedOutStepState(preview, "check");
82
+ }
76
83
  const outcome = await inspectMemoryIntegration(memoryCommandFor(context), context.io);
77
84
  return memoryStepState(outcome, "check");
78
85
  }
79
86
  export async function fixMemoryState(context) {
87
+ if (await memoryOptedOut(context)) {
88
+ // Never re-adds BLI Memory: the opted-out install only takes our entries
89
+ // back off and keeps `bli-tower`, which `mcp-answers`' repair relies on.
90
+ return optedOutStepState(await installMemoryIntegration(memoryCommandFor(context), context.io), "fix");
91
+ }
80
92
  let outcome = await installMemoryIntegration(memoryCommandFor(context), context.io);
81
93
  if (!outcome.bin_found) {
82
94
  // The public CLI declares the memory and Tower MCP packages as dependencies.
@@ -86,6 +98,33 @@ export async function fixMemoryState(context) {
86
98
  }
87
99
  return memoryStepState(outcome, "fix");
88
100
  }
101
+ async function memoryOptedOut(context) {
102
+ return isMemoryCaptureOff(getCollectorRuntimePaths(context.command.homeDir), { fetch: context.io.fetch });
103
+ }
104
+ /**
105
+ * BLI-22640. The row on a machine whose owner turned BLI Memory off: never
106
+ * red for the absent registration (that is the choice), `skipped
107
+ * explicit_opt_out` once nothing of ours is left, and a fix only when an
108
+ * entry of ours is still on disk or `bli-tower` is missing.
109
+ */
110
+ function optedOutStepState(outcome, phase) {
111
+ const failed = outcome.targets.find((target) => target.status === "failed");
112
+ if (failed) {
113
+ const message = `BLI Memory is off on this machine, but ${failed.target} could not be cleaned: ${failed.reason}`;
114
+ return phase === "fix"
115
+ ? fail("memory-registered", failed.reason, message)
116
+ : needsFix("memory-registered", failed.reason, message);
117
+ }
118
+ const leftover = outcome.removed_count ?? 0;
119
+ const towerPending = outcome.targets.some((target) => target.status === "would_install");
120
+ if (phase === "check" && (leftover > 0 || towerPending)) {
121
+ return needsFix("memory-registered", leftover > 0 ? "opted_out_entries_left" : "registration_incomplete", leftover > 0
122
+ ? `BLI Memory is off on this machine, but ${leftover} of its entries are still registered; doctor removes them`
123
+ : "BLI Memory is off on this machine; bli-tower is not registered yet");
124
+ }
125
+ const removed = phase === "fix" && leftover > 0 ? ` Removed ${leftover} of its entries.` : "";
126
+ return skipped("memory-registered", outcome.reason === "choice_unreadable" ? "choice_unreadable" : "explicit_opt_out", `BLI Memory is off on this machine (your choice); nothing of it is registered.${removed} \`cockpit memory on\` turns it back on.`);
127
+ }
89
128
  function memoryCommandFor(context) {
90
129
  return {
91
130
  kind: "memory",
@@ -169,6 +169,7 @@ export function doctorNeedsPerson(rows, receipt) {
169
169
  "authed": "cockpit doctor",
170
170
  "roots-ok": "cockpit doctor",
171
171
  "roots-cover-work": "cockpit doctor",
172
+ "shelf-routing": "cockpit doctor",
172
173
  "single-install": "Run npm uninstall -g @bli-cockpit/cli with the Node installation that owns the extra CLI listed above.",
173
174
  "autostart-alive": "cockpit doctor",
174
175
  "usage-telemetry": "cockpit doctor",
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The `shelf-routing` doctor row (BLI-23478, three-shelves design 9.1 item 13).
3
+ *
4
+ * Reads what the last heartbeat stored about Tower's founder lock (`shelf-state.json`) and this laptop's default.
5
+ * On a founder's laptop, once Tower enforces the lock AND an owner has switched founder routing on, the repair sets
6
+ * `shelf_default: drawer`, so a founder's work outside every labelled folder stays on the laptop from then on. It
7
+ * never overrides a default the founder chose (`cockpit shelf default team`), and it never runs before Tower says
8
+ * the lock is on: labels and the default do nothing until both locks exist (design 4.1).
9
+ *
10
+ * Every other laptop: one `ok` line, nothing written.
11
+ */
12
+ import os from "node:os";
13
+ import { safeLog } from "@bli-cockpit/telemetry-core";
14
+ import { ensureLocalCollectorConfig } from "../local-state.js";
15
+ import { writeJsonFile } from "../local-state-files.js";
16
+ import { readShelfState } from "../shelf-state.js";
17
+ import { askFounderLive } from "../shelf-decision.js";
18
+ import { configForDisk } from "../source-opt-out.js";
19
+ import { needsFix, ok, skipped } from "./doctor-report.js";
20
+ const ID = "shelf-routing";
21
+ /**
22
+ * Whether this laptop's founder should have the drawer default now. Shared with the usage-telemetry row. The
23
+ * switch's state comes from the saved heartbeat answer; WHO is a founder is Tower's live answer, never the saved
24
+ * copy (`shelf-decision.ts`): a forged `is_owner` in the file changes nothing here.
25
+ */
26
+ export async function founderRoutingOn(homeDir, fetchImpl) {
27
+ const state = readShelfState(homeDir);
28
+ if (!state?.shelf_enforced || state.founder_routing_enabled !== true)
29
+ return false;
30
+ return (await askFounderLive({ homeDir, fetchImpl })).standing === "founder";
31
+ }
32
+ export async function checkShelfRouting(context) {
33
+ const homeDir = context.command.homeDir ?? os.homedir();
34
+ const state = readShelfState(homeDir);
35
+ if (!state)
36
+ return skipped(ID, "not_enforced", "shelf routing: Tower has not answered this laptop yet; nothing changes until it does");
37
+ if (state.is_owner !== true)
38
+ return ok(ID, "enforced", "shelf routing: not a founder's laptop; nothing changes here");
39
+ if (state.cli_too_old) {
40
+ return { ...needsFix(ID, "cli_too_old", "shelf routing: this CLI is older than Tower's founder lock accepts; run `cockpit update`"), nextAction: "cockpit update" };
41
+ }
42
+ if (state.founder_routing_enabled !== true || !state.shelf_enforced) {
43
+ return ok(ID, "routing_disabled_by_owner", "shelf routing: founder routing is off in Tower; labels wait until an owner switches it on");
44
+ }
45
+ const config = await ensureLocalCollectorConfig({ homeDir }).then((result) => result.config).catch(() => null);
46
+ if (config?.shelf_default === "drawer")
47
+ return ok(ID, "drawer_default_set", "shelf routing: on; work outside team-labelled folders stays in the drawer on this laptop");
48
+ if (config?.shelf_default === "team")
49
+ return ok(ID, "team_default_chosen", "shelf routing: on; you chose team as the default (`cockpit shelf default drawer` to change it)");
50
+ return needsFix(ID, "drawer_default_missing", "shelf routing: on, but this laptop has no default yet; doctor sets it to drawer");
51
+ }
52
+ export async function fixShelfRouting(context) {
53
+ const homeDir = context.command.homeDir ?? os.homedir();
54
+ if (!(await founderRoutingOn(homeDir, context.io.fetch)))
55
+ return checkShelfRouting(context);
56
+ const { config, paths } = await ensureLocalCollectorConfig({ homeDir });
57
+ if (config.shelf_default)
58
+ return checkShelfRouting(context);
59
+ await writeJsonFile(paths.config_file, configForDisk({ ...config, shelf_default: "drawer" }));
60
+ safeLog("shelf", "drawer_default_set", { by: "doctor" });
61
+ return { ...ok(ID, "drawer_default_set", "shelf routing: on; work outside team-labelled folders now stays in the drawer on this laptop"), fixed: true };
62
+ }
@@ -78,6 +78,8 @@ export function reexecDoctor(command, io) {
78
78
  const args = ["doctor"];
79
79
  if (command.noUsageTelemetry)
80
80
  args.push("--no-usage-telemetry");
81
+ if (command.noMemory)
82
+ args.push("--no-memory");
81
83
  if (command.homeDir)
82
84
  args.push("--home", command.homeDir);
83
85
  if (command.backfillBudgetSeconds)
@@ -1,6 +1,7 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
- import { getCollectorRuntimePaths, readLocalCollectorSessionFile } from "../local-state.js";
3
+ import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile } from "../local-state.js";
4
+ import { founderRoutingOn } from "./doctor-shelf.js";
4
5
  import { claudeSettingsFile } from "./memory-install-claude.js";
5
6
  import { codexConfigFile } from "./tower-mcp-codex.js";
6
7
  import { defaultMemoryFileIo } from "./memory-install-files.js";
@@ -83,7 +84,7 @@ async function usageTelemetryStep(context, checkOnly) {
83
84
  if (!session?.device_token)
84
85
  return needsFix("usage-telemetry", "device_token_missing", "usage telemetry: needs Tower sign-in");
85
86
  const result = await configureUsageTelemetry({ homeDir: home, origin: context.command.dashboardUrl, token: session.device_token,
86
- optOut: context.command.noUsageTelemetry, checkOnly, io: defaultMemoryFileIo({ mode: 0o600 }) });
87
+ optOut: context.command.noUsageTelemetry || (await founderWithholdsModels(home, context.io.fetch)), checkOnly, io: defaultMemoryFileIo({ mode: 0o600 }) });
87
88
  const message = result.off ? "usage telemetry: off (explicit opt-out)" : "usage telemetry: on (claude code, codex)";
88
89
  return checkOnly && result.changed ? needsFix("usage-telemetry", "settings_differ", message) : ok("usage-telemetry", result.off ? "explicit_opt_out" : "already_current", message);
89
90
  }
@@ -93,5 +94,20 @@ async function usageTelemetryStep(context, checkOnly) {
93
94
  return fail("usage-telemetry", reason, "usage telemetry: settings could not be safely merged");
94
95
  }
95
96
  }
97
+ /**
98
+ * BLI-23478 (three shelves, design 3.2 category C): usage telemetry names the model and tool of every session, so
99
+ * on a founder's laptop, once founder routing is on, it is opted out unless the founder allowed `model` with
100
+ * `cockpit shelf metrics --allow`. The opt-out persists like any other (the marker), so allowing `model` later
101
+ * needs the marker removed by hand: deliberate, since turning a channel back on is a person's call.
102
+ */
103
+ async function founderWithholdsModels(homeDir, fetchImpl) {
104
+ if (!(await founderRoutingOn(homeDir, fetchImpl)))
105
+ return false;
106
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
107
+ const withheld = !(config?.shelf_metrics_allow ?? []).includes("model");
108
+ if (withheld)
109
+ console.error("[usage telemetry] founder opt-out", JSON.stringify({ reason: "shelf_model_category_off" }));
110
+ return withheld;
111
+ }
96
112
  export const checkUsageTelemetry = (context) => usageTelemetryStep(context, true);
97
113
  export const fixUsageTelemetry = (context) => usageTelemetryStep(context, false);
@@ -1,5 +1,8 @@
1
1
  import { checkSlackConnected } from "./doctor-slack.js";
2
2
  import { checkUsageTelemetry, fixUsageTelemetry } from "./doctor-usage-telemetry.js";
3
+ import { settleDoctorMemoryChoice } from "./memory-capture-prompt.js";
4
+ import { reportIgnoredOptOuts } from "../capture-opt-outs.js";
5
+ import { checkShelfRouting, fixShelfRouting } from "./doctor-shelf.js";
3
6
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION } from "../local-state.js";
4
7
  import { withCommandGuard } from "../command-guard.js";
5
8
  import { installHttp1GlobalFetch } from "../http1-fetch.js";
@@ -31,6 +34,10 @@ export async function runDoctorWithDeps(command, io, deps) {
31
34
  const deadlineMs = doctorRunDeadline(command, runStarted);
32
35
  const context = { command, io, deps, deadlineMs };
33
36
  console.error("[doctor] run started", JSON.stringify({ json: command.json, check_only: Boolean(command.dryRun || command.checkOnly), budget_seconds: deadlineMs === null ? null : DOCTOR_JSON_BUDGET_SECONDS }));
37
+ // BLI-22640: `--no-memory`, or the one-time question, before any row reads the choice.
38
+ await settleDoctorMemoryChoice(command, io);
39
+ // Founders only: a non-founder's opt-outs are ignored, and doctor says so in one line.
40
+ await reportIgnoredOptOuts({ ...(command.homeDir ? { homeDir: command.homeDir } : {}), json: command.json }, io);
34
41
  const rows = [];
35
42
  const repairs = [];
36
43
  const diagnoseOnly = command.dryRun || command.checkOnly;
@@ -178,6 +185,8 @@ function doctorInvariants() {
178
185
  // no fix because a session outside an approved root is not a defect
179
186
  // doctor can repair; it names the counts and moves on.
180
187
  { id: "roots-cover-work", check: context => context.deps.readRootsCoverWork(context) },
188
+ // BLI-23478: a founder's drawer default once Tower's lock is on (`doctor-shelf.ts`); everyone else: one ok line.
189
+ { id: "shelf-routing", check: (c) => (c.deps.checkShelfRouting ?? checkShelfRouting)(c), fix: (c) => (c.deps.fixShelfRouting ?? fixShelfRouting)(c) },
181
190
  // Deliberately has NO fix: uninstalling software the operator did not ask
182
191
  // to have uninstalled is not a repair (BLI-3218/BLI-3553). Doctor names the
183
192
  // other install and the exact command; the person decides.
@@ -11,6 +11,10 @@ import { describeError } from "../health-detail.js";
11
11
  import { classifySyncHealthError } from "../sync-health-class.js";
12
12
  import { noteHeartbeatRefusal, recordHeartbeatPendingToken, recordHeartbeatTokenExpiry, TOKEN_ROTATION_HEADER, } from "./heartbeat-token.js";
13
13
  import { noteDoctorRequest } from "./sync-doctor-request.js";
14
+ import { recordFounderStanding } from "../founder-standing-file.js";
15
+ import { getCollectorRuntimePaths } from "../local-state.js";
16
+ import { collectorShelfHeaders } from "../shelf-headers.js";
17
+ import { recordShelfState } from "../shelf-state.js";
14
18
  const HEARTBEAT_TIMEOUT_MS = 5_000;
15
19
  export async function postHeartbeat(options, deviceToken, heartbeat, memory) {
16
20
  const controller = new AbortController();
@@ -21,6 +25,8 @@ export async function postHeartbeat(options, deviceToken, heartbeat, memory) {
21
25
  headers: {
22
26
  "Content-Type": "application/json",
23
27
  Authorization: `Bearer ${deviceToken}`,
28
+ // BLI-23478: the CLI release, so Tower records `cli_too_old` on an owner's old laptop.
29
+ ...collectorShelfHeaders(),
24
30
  // BLI-17994. "I store a renewed token if you send one": the dashboard
25
31
  // only rotates for a collector that says so.
26
32
  [TOKEN_ROTATION_HEADER]: "1",
@@ -84,6 +90,19 @@ async function recordAcceptedHeartbeat(options, response, heartbeat, memory) {
84
90
  // door reports, which moves only when this request promoted a token.
85
91
  await recordHeartbeatPendingToken(options.homeDir, reply);
86
92
  await recordHeartbeatTokenExpiry(options.homeDir, response);
93
+ // BLI-22640: Tower's answer to "is this machine's person a founder?", kept
94
+ // for DISPLAY (doctor, support). No decision reads this file: every reader
95
+ // that decides what leaves the machine asks Tower live. Only a Tower reply
96
+ // writes it. A reply that could not read the owners list says null, and the
97
+ // last displayed answer stands.
98
+ if (typeof reply?.["is_owner"] === "boolean") {
99
+ await recordFounderStanding(getCollectorRuntimePaths(options.homeDir), reply["is_owner"], "heartbeat");
100
+ }
101
+ else {
102
+ console.error("[heartbeat] no founder answer in this reply; the last one stands", JSON.stringify({ reason: "is_owner_absent" }));
103
+ }
104
+ // BLI-23478 (three shelves): what the lock is for this laptop's person, for the hooks, the MCP server and doctor.
105
+ await recordShelfState(options.homeDir, reply);
87
106
  console.error("[heartbeat] checked in", JSON.stringify({
88
107
  reason: "heartbeat_recorded",
89
108
  http_status: response.status,
@@ -24,7 +24,7 @@ import { collectAgentSetup } from "./agent-setup.js";
24
24
  import fs from "node:fs";
25
25
  import os from "node:os";
26
26
  import path from "node:path";
27
- import { COLLECTOR_HEARTBEAT_SCHEMA_VERSION, MemoryInstallReceiptSchema, setupReceiptGaps, } from "@bli-cockpit/telemetry-core";
27
+ import { COLLECTOR_HEARTBEAT_MAX_ROOTS, COLLECTOR_HEARTBEAT_SCHEMA_VERSION, MemoryInstallReceiptSchema, setupReceiptGaps, } from "@bli-cockpit/telemetry-core";
28
28
  import { readCachedSetupReceipt } from "./setup-receipt.js";
29
29
  import { readStagingInventory } from "../disk-usage.js";
30
30
  import { readStagingGate } from "../staging-gate.js";
@@ -32,6 +32,8 @@ import { describeError } from "../health-detail.js";
32
32
  import { shouldSuppressFleetReceipts, } from "../dev-build.js";
33
33
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
34
34
  import { postHeartbeat } from "./heartbeat-post.js";
35
+ import { readCaptureOptOuts } from "../capture-opt-outs.js";
36
+ import { teamRootsForHeartbeat } from "../shelf-state.js";
35
37
  import { readHeartbeatSession } from "./heartbeat-token.js";
36
38
  import { withTokenRotationLock } from "./heartbeat-token-lock.js";
37
39
  import { readMemoryReceiptFile } from "./memory-install-receipt.js";
@@ -103,6 +105,9 @@ export function buildCollectorHeartbeat(options) {
103
105
  collector_version: options.collectorVersion ?? LOCAL_COLLECTOR_VERSION,
104
106
  os_platform: platform,
105
107
  roots: collectionRootLabels(options.roots, platform),
108
+ ...(options.shelvedRoots
109
+ ? { shelved_roots: Array.from({ length: Math.min(options.shelvedRoots, COLLECTOR_HEARTBEAT_MAX_ROOTS) }, () => ({ shelf: "drawer" })) }
110
+ : {}),
106
111
  last_sync_status: options.facts.status,
107
112
  ...(options.facts.outsideRootDirectories ? { outside_root_directories: options.facts.outsideRootDirectories } : {}),
108
113
  ...(options.facts.reason ? { last_sync_reason: options.facts.reason } : {}),
@@ -118,6 +123,7 @@ export function buildCollectorHeartbeat(options) {
118
123
  ...(typeof options.facts.sessionsPendingUpload === "number"
119
124
  ? { sessions_pending_upload: options.facts.sessionsPendingUpload }
120
125
  : {}),
126
+ ...(options.facts.collectionScope ? { collection_scope: options.facts.collectionScope } : {}),
121
127
  ...(typeof options.facts.stagingUncommittedBytes === "number"
122
128
  ? { staging_uncommitted_bytes: options.facts.stagingUncommittedBytes }
123
129
  : {}),
@@ -127,6 +133,7 @@ export function buildCollectorHeartbeat(options) {
127
133
  ...(options.facts.disk ? { disk: options.facts.disk } : {}),
128
134
  ...(options.memoryInstall ? { memory_install: options.memoryInstall } : {}),
129
135
  ...(options.setupReceipt ? { setup_receipt: options.setupReceipt } : {}),
136
+ ...(options.captureOptOuts ? { capture_opt_outs: options.captureOptOuts } : {}),
130
137
  };
131
138
  }
132
139
  /**
@@ -280,9 +287,13 @@ async function sendCollectorHeartbeatLocked(options) {
280
287
  }).catch(() => ({ receipt: null, reason: "receipt_read_threw" }));
281
288
  const setupReceipt = await readCachedSetupReceipt(options.homeDir, options.now ?? new Date());
282
289
  logSetupReceiptCarried(setupReceipt);
290
+ // BLI-23478: a drawer-labelled folder's name and path hash never travel; only its shelf, counted.
291
+ const shelfRoots = await teamRootsForHeartbeat(options.roots, options.homeDir, options.io.fetch);
283
292
  const heartbeat = buildCollectorHeartbeat({
284
- roots: options.roots,
293
+ roots: shelfRoots.team,
294
+ shelvedRoots: shelfRoots.shelved,
285
295
  facts: options.facts,
296
+ captureOptOuts: await readCaptureOptOuts(options.homeDir),
286
297
  setupReceipt: setupReceipt ? { ...setupReceipt, agent_setup: await collectAgentSetup(options.homeDir ?? os.homedir()) } : null,
287
298
  ...(options.now ? { now: options.now } : {}),
288
299
  memoryInstall: memory.receipt,
@@ -144,6 +144,9 @@ async function deliverPendingInstallEvents(options, paths, deviceToken) {
144
144
  if (receipt.imageHandlingMode !== undefined && options.observed) {
145
145
  options.observed.imageHandlingMode = receipt.imageHandlingMode;
146
146
  }
147
+ if (receipt.collectionScope !== undefined && options.observed) {
148
+ options.observed.collectionScope = receipt.collectionScope;
149
+ }
147
150
  delivered += 1;
148
151
  })));
149
152
  }
@@ -215,6 +218,7 @@ async function readReceiptReply(response) {
215
218
  ? receipt.min_cli_version.trim()
216
219
  : null,
217
220
  imageHandlingMode: receipt?.image_handling_mode,
221
+ collectionScope: receipt?.collection_scope,
218
222
  };
219
223
  }
220
224
  async function keepFailedInstallEventEntry(paths, entry, error, failures) {
@@ -95,6 +95,7 @@ const DOCTOR_ALLOWED_FLAGS = [
95
95
  "--check",
96
96
  "--no-repair",
97
97
  "--no-usage-telemetry",
98
+ "--no-memory",
98
99
  "--lock-wait",
99
100
  "--backfill-budget",
100
101
  "--json",
@@ -141,6 +142,7 @@ export function parseDoctorArgs(alias, args) {
141
142
  return {
142
143
  kind: "doctor",
143
144
  noUsageTelemetry: values.booleans.has("--no-usage-telemetry"),
145
+ noMemory: values.booleans.has("--no-memory"),
144
146
  checkOnly: values.booleans.has("--check") || values.booleans.has("--no-repair"),
145
147
  // Absent stays absent (BLI-11101): `doctor-budget.ts` owns the defaults,
146
148
  // and `--json` without either flag runs under one bounded budget.
@@ -168,23 +168,9 @@ export function parseAgentRulesArgs(args) {
168
168
  json: values.booleans.has("--json"),
169
169
  };
170
170
  }
171
- /**
172
- * BLI-3580, amended by BLI-4047.
173
- *
174
- * `cockpit memory` is now SIX verbs behind one noun: the two that configure
175
- * this machine (`install`, `status`), the experience log (`log`), and the four
176
- * that read and write the memory store itself (`search`, `save`, `update`,
177
- * `forget`, parsed next door in `local-args-tower-memory.ts`).
178
- *
179
- * **A bare `cockpit memory` prints the verb list; it no longer installs.**
180
- * BLI-3580 made `install` the default because the command is run unasked by
181
- * `doctor` and by the sync tick, but those callers build
182
- * `{ kind: "memory", action: "install" }` in code (`doctor-registration.ts`,
183
- * `sync-followups-memory.ts`, `onboard-completion.ts`) and never pass through
184
- * this parser. The only caller the default ever served was a PERSON typing the
185
- * noun to find out what it does, and what they got was an MCP server and three
186
- * hooks registered on their machine. Installing stays one word away.
187
- */
171
+ const MEMORY_MACHINE_ACTIONS = new Set([
172
+ "install", "uninstall", "on", "off", "status", "help",
173
+ ]);
188
174
  export function parseMemoryArgs(args) {
189
175
  // The verb word first, always: reading it off `args[0]` rather than "the
190
176
  // first token without a leading dash" is what keeps `cockpit memory --home
@@ -214,16 +200,16 @@ export function parseMemoryArgs(args) {
214
200
  if (values.flags.has("--store") || values.booleans.has("--reason-stdin"))
215
201
  throw new Error("--store and --reason-stdin require memory log");
216
202
  if (values.positionals.length > 1) {
217
- throw new Error("memory accepts at most one action (install|status).");
203
+ throw new Error("memory accepts at most one action (on|off|install|uninstall|status).");
218
204
  }
219
205
  // BLI-4047: no positional means "tell me what this noun does", not "install".
220
206
  const action = values.positionals[0] ?? "help";
221
- if (action !== "install" && action !== "status" && action !== "help") {
222
- throw new Error("memory action must be install, status, help, log, search, save, update, or forget.");
207
+ if (!MEMORY_MACHINE_ACTIONS.has(action)) {
208
+ throw new Error("memory action must be on, off, install, uninstall, status, help, log, search, save, update, or forget.");
223
209
  }
224
210
  return {
225
211
  kind: "memory",
226
- action,
212
+ action: action,
227
213
  homeDir: optionalNonEmpty(values.flags.get("--home")),
228
214
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
229
215
  dryRun: values.booleans.has("--dry-run"),
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `cockpit agreements` argument parsing (docs/architecture/agreements.md).
3
+ *
4
+ * cockpit agreements [list] your own requests to sign: open, signed, cancelled
5
+ * cockpit agreements show <request-id> the text, its boxes, and the record once signed
6
+ * cockpit agreements copy <request-id> [--out <file>] your signed copy as plain text
7
+ * cockpit agreements documents founders: every document, its versions and who was asked
8
+ * cockpit agreements draft founders: save a draft (the DraftInput JSON on stdin)
9
+ * cockpit agreements request --version <id> --person <user-id> [--due <date>] founders: ask a person to sign
10
+ * cockpit agreements cancel <request-id> founders: withdraw a request that is not signed
11
+ *
12
+ * Every verb is one `/api/agreements` door on this machine's device token, the same doors the web page, the MCP twins
13
+ * and JARVIS use. Asking for a signing code, signing and publishing have no verb: they are a person's own act in the
14
+ * browser, and those doors refuse a device token.
15
+ */
16
+ import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
17
+ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
+ const ID_EXAMPLE = "8f14e45f-ceea-467a-9575-1d3a4e7b2c10";
19
+ const ONE_VERB = "agreements takes one verb: list, show, copy, documents, draft, request or cancel. Example: `cockpit agreements` or `cockpit agreements show <request-id>`.";
20
+ /** Which flags each verb owns, so a flag on the wrong verb is refused by name rather than ignored. */
21
+ const FLAGS_BY_ACTION = {
22
+ list: [],
23
+ show: [],
24
+ copy: ["--out"],
25
+ documents: [],
26
+ draft: [],
27
+ request: ["--version", "--person", "--due"],
28
+ cancel: [],
29
+ };
30
+ const OWN_FLAGS = ["--out", "--version", "--person", "--due"];
31
+ const COMMON_FLAGS = ["--home", "--dashboard-url", "--json"];
32
+ const NEEDS_REQUEST_ID = ["show", "copy", "cancel"];
33
+ export function parseAgreementsArgs(args) {
34
+ const values = parseNamedArgs(args, {
35
+ allowedFlags: [...OWN_FLAGS, ...COMMON_FLAGS],
36
+ valueFlags: [...OWN_FLAGS, "--home", "--dashboard-url"],
37
+ });
38
+ const first = values.positionals[0];
39
+ if (first !== undefined && !Object.keys(FLAGS_BY_ACTION).includes(first))
40
+ throw new Error(ONE_VERB);
41
+ const action = (first ?? "list");
42
+ const given = [...values.flags.keys()].filter((flag) => OWN_FLAGS.includes(flag));
43
+ const owned = FLAGS_BY_ACTION[action];
44
+ const stray = given.find((flag) => !owned.includes(flag));
45
+ if (stray)
46
+ throw new Error(`${stray} does not belong to \`cockpit agreements ${action}\`.`);
47
+ const rest = values.positionals.slice(first === undefined ? 0 : 1);
48
+ const needsId = NEEDS_REQUEST_ID.includes(action);
49
+ if (needsId ? rest.length !== 1 : rest.length > 0) {
50
+ throw new Error(needsId ? `\`cockpit agreements ${action}\` takes exactly one request id.` : `\`cockpit agreements ${action}\` takes no positional arguments.`);
51
+ }
52
+ const requestId = needsId ? rest[0] : undefined;
53
+ if (requestId !== undefined && !ID.test(requestId))
54
+ throw new Error(`A request id looks like ${ID_EXAMPLE} (\`cockpit agreements\` lists yours).`);
55
+ const versionId = optionalNonEmpty(values.flags.get("--version"));
56
+ const personId = optionalNonEmpty(values.flags.get("--person"));
57
+ const dueAt = optionalNonEmpty(values.flags.get("--due"));
58
+ if (action === "request") {
59
+ if (!versionId || !personId)
60
+ throw new Error("`cockpit agreements request` needs --version <version-id> and --person <user-id> (`cockpit agreements documents` lists both).");
61
+ if (!ID.test(versionId))
62
+ throw new Error(`--version looks like ${ID_EXAMPLE} (\`cockpit agreements documents\` lists the versions).`);
63
+ if (!ID.test(personId))
64
+ throw new Error(`--person is a user id like ${ID_EXAMPLE} (\`cockpit team members\` lists them).`);
65
+ if (dueAt !== undefined && Number.isNaN(Date.parse(dueAt)))
66
+ throw new Error("--due wants a date, like 2026-11-15.");
67
+ }
68
+ return {
69
+ kind: "agreements",
70
+ action,
71
+ ...(requestId ? { requestId } : {}),
72
+ ...(optionalNonEmpty(values.flags.get("--out")) ? { outPath: optionalNonEmpty(values.flags.get("--out")) } : {}),
73
+ ...(versionId ? { versionId } : {}),
74
+ ...(personId ? { personId } : {}),
75
+ ...(dueAt ? { dueAt } : {}),
76
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
77
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
78
+ json: values.booleans.has("--json"),
79
+ };
80
+ }
@@ -47,6 +47,7 @@ const DOCS_ALLOWED_FLAGS = [
47
47
  "--out",
48
48
  "--add",
49
49
  "--remove",
50
+ "--role",
50
51
  "--reason",
51
52
  "--cursor",
52
53
  "--before",
@@ -70,6 +71,7 @@ const DOCS_VALUE_FLAGS = [
70
71
  "--out",
71
72
  "--add",
72
73
  "--remove",
74
+ "--role",
73
75
  "--reason",
74
76
  "--cursor",
75
77
  "--before",
@@ -296,24 +298,34 @@ function parseDocsReadFlags(action, values) {
296
298
  return { query, wholeWord, limit, bodies, section, offset };
297
299
  }
298
300
  /**
299
- * BLI-21313: `docs share <doc> --add <email> | --remove <email> [--reason <text>]` names or removes a page editor;
300
- * `docs access <doc>` reads who may edit and why. A person is an exact email address, never a fuzzy name.
301
+ * BLI-21313, BLI-23558: `docs share <doc> --add <email> [--role viewer|editor] | --remove <email> [--reason <text>]` names
302
+ * or removes a person on a page (a private page too); `docs access <doc>` reads who may read and edit and why. A person
303
+ * is an exact email address, never a fuzzy name. `--role` is for `--add` only; absent means editor (what share did
304
+ * before viewers existed).
301
305
  */
302
306
  function parseDocsShareFlags(action, values) {
303
307
  const addPerson = optionalNonEmpty(values.flags.get("--add"));
304
308
  const removePerson = optionalNonEmpty(values.flags.get("--remove"));
309
+ const role = optionalNonEmpty(values.flags.get("--role"));
305
310
  const reason = optionalNonEmpty(values.flags.get("--reason"));
306
- if ((addPerson || removePerson || reason) && action !== "share") {
307
- throw new Error("--add, --remove and --reason belong to `cockpit docs share`.");
311
+ if ((addPerson || removePerson || reason || role) && action !== "share") {
312
+ throw new Error("--add, --remove, --role and --reason belong to `cockpit docs share`.");
308
313
  }
314
+ if (role !== undefined && role !== "viewer" && role !== "editor")
315
+ throw new Error('docs share --role must be "viewer" or "editor".');
309
316
  if (action === "share") {
310
317
  if (Boolean(addPerson) === Boolean(removePerson)) {
311
318
  throw new Error("docs share needs exactly one of --add <email> or --remove <email>.");
312
319
  }
320
+ if (role && !addPerson)
321
+ throw new Error("--role goes with --add; --remove takes the person off whatever role they hold.");
313
322
  if (reason && reason.length > 200)
314
323
  throw new Error("--reason can be at most 200 characters.");
315
324
  }
316
- return { ...(addPerson ? { addPerson } : {}), ...(removePerson ? { removePerson } : {}), ...(reason ? { reason } : {}) };
325
+ return {
326
+ ...(addPerson ? { addPerson } : {}), ...(removePerson ? { removePerson } : {}),
327
+ ...(role ? { shareRole: role } : {}), ...(reason ? { reason } : {}),
328
+ };
317
329
  }
318
330
  /**
319
331
  * BLI-21413: `docs trash [--cursor C] [--limit N]`, `docs versions <doc> [--before N] [--limit N] [--version V]`,
@@ -50,11 +50,13 @@ export { parseWhoArgs } from "./local-args-tower-who.js";
50
50
  export { parsePeopleArgs } from "./local-args-tower-people.js";
51
51
  export { parseApprovalsArgs } from "./local-args-tower-approvals.js";
52
52
  export { parseDrillsArgs } from "./local-args-tower-drills.js";
53
+ export { parseAgreementsArgs } from "./local-args-tower-agreements.js";
53
54
  export { parseManagerArgs } from "./local-args-tower-manager.js";
54
55
  export { parseImageArgs } from "./local-args-tower-image.js";
55
56
  export { parseCareersArgs } from './local-args-tower-careers.js';
56
57
  export { parseRenameArgs } from './local-args-tower-rename.js';
57
58
  export { parseGlossaryArgs } from "./glossary.js";
59
+ export { parseBooksArgs } from "./books.js";
58
60
  export { parseSkillsArgs } from "./skills.js";
59
61
  export { parseSpendArgs } from "./spend.js";
60
62
  export { parsePushArgs } from "./push.js";
@@ -16,15 +16,17 @@
16
16
  * verbatim, no logic change.
17
17
  */
18
18
  import { parseGlossaryArgs } from "./glossary.js";
19
+ import { parseBooksArgs } from "./books.js";
19
20
  import { parseSkillsArgs } from "./skills.js";
20
21
  import { parseSpendArgs } from "./spend.js";
21
22
  import { parsePushArgs } from "./push.js";
22
23
  import { parseRootsArgs } from "./roots.js";
24
+ import { parseShelfArgs } from "./shelf.js";
23
25
  import { parseSlackPrivacyArgs } from "./slack-privacy-args.js";
24
26
  import { parseMilestoneArgs } from "./local-args-tower-milestone.js";
25
27
  import { SURFACE_ALIASES } from "./local-help.js";
26
28
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
27
- import { parseCiArgs, parseDeliveryArgs, parsePrOutputArgs, parseAiSpendArgs, parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseSpeedArgs, parseLogsArgs, parseBrainArgs, parseHitsArgs, parseAiSkillsArgs, parseIntentionsArgs, parseWhoArgs, parsePeopleArgs, parseApprovalsArgs, parseDrillsArgs, parseManagerArgs, parseImageArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
29
+ import { parseCiArgs, parseDeliveryArgs, parsePrOutputArgs, parseAiSpendArgs, parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseSpeedArgs, parseLogsArgs, parseBrainArgs, parseHitsArgs, parseAiSkillsArgs, parseIntentionsArgs, parseWhoArgs, parsePeopleArgs, parseApprovalsArgs, parseDrillsArgs, parseAgreementsArgs, parseManagerArgs, parseImageArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
28
30
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
29
31
  // `local-auth.ts` import it from here — so it stays exported from this address
30
32
  // even though it now lives next door. The same goes for the four names the
@@ -42,6 +44,7 @@ export { SCOUT_MIN_PREFIX_LENGTH, SLACK_WORKSPACE_KEYS, WORKBOOK_MIN_WIDTH, ISSU
42
44
  */
43
45
  const COMMAND_PARSERS = new Map([
44
46
  ["roots", (_command, rest) => parseRootsArgs(rest)],
47
+ ["shelf", (_command, rest) => parseShelfArgs(rest)],
45
48
  ["onboard", (_command, rest) => parseOnboardArgs(rest)],
46
49
  ["update", (command, rest) => parseUpdateArgs(command, rest)],
47
50
  ["upgrade", (command, rest) => parseUpdateArgs(command, rest)],
@@ -89,6 +92,7 @@ const COMMAND_PARSERS = new Map([
89
92
  ["clean", (_command, rest) => parseCleanArgs(rest)],
90
93
  ["sources", (_command, rest) => parseSourcesArgs(rest)],
91
94
  ["glossary", (_command, rest) => parseGlossaryArgs(rest)],
95
+ ["books", (_command, rest) => parseBooksArgs(rest)], // BLI-23711: Client Books
92
96
  ["skills", (_command, rest) => parseSkillsArgs(rest)], // BLI-23364 D: the Skill Library
93
97
  ["spend", (_command, rest) => parseSpendArgs(rest)],
94
98
  ["push", (_command, rest) => parsePushArgs(rest)],
@@ -116,6 +120,7 @@ const COMMAND_PARSERS = new Map([
116
120
  ["people", (_command, rest) => parsePeopleArgs(rest)],
117
121
  ["approvals", (_command, rest) => parseApprovalsArgs(rest)],
118
122
  ["drills", (_command, rest) => parseDrillsArgs(rest)],
123
+ ["agreements", (_command, rest) => parseAgreementsArgs(rest)],
119
124
  ["manager", (_command, rest) => parseManagerArgs(rest)],
120
125
  ["image", (_command, rest) => parseImageArgs(rest)],
121
126
  ]);
@@ -0,0 +1,5 @@
1
+ /**
2
+ * `cockpit agreements` command shape (docs/architecture/agreements.md), beside `local-command-shapes-approvals.ts` for
3
+ * the same reason: `local-command-shapes.ts` sits at the readability ceiling.
4
+ */
5
+ export {};
@@ -16,7 +16,7 @@
16
16
  * binding a ticket from a scratch folder to every approved root on the machine
17
17
  * is far wider than the operator asked for, so it keeps the old refusal.
18
18
  */
19
- import { resolveSourceSwitches } from "../source-opt-out.js";
19
+ import { readSourceSwitches } from "../source-opt-out.js";
20
20
  import { writeLine } from "./cli-io.js";
21
21
  import { resolveDiscoveryLimits, saveDiscoveryLimits, } from "../discovery-limits.js";
22
22
  import { getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
@@ -196,7 +196,7 @@ export async function scanWorkDirectories(homeDir, persist = true) {
196
196
  return null; throw error; });
197
197
  const now = new Date();
198
198
  const options = { worktrees: [], collectionRoots: config?.default_repo_paths ?? [], now, sinceMinutes: ROOT_WORK_SCAN_WINDOW_DAYS * 24 * 60, limit: Number.MAX_SAFE_INTEGER };
199
- const switches = resolveSourceSwitches(config);
199
+ const switches = await readSourceSwitches(config, paths, "discovery");
200
200
  if (!switches.codex)
201
201
  console.error("[discovery] codex scan withheld by operator opt-out", JSON.stringify({ reason: switches.codexReason }));
202
202
  const codex = switches.codex ? await scanAndAttributeCodexSessions({ ...options, sessionsDirs: defaultCodexSessionDirs(homeDir) }) : null;