@bli-cockpit/cli 0.2.286 → 0.2.287

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 (78) 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/analyze.js +2 -0
  5. package/dist/commands/backfill-scan.js +16 -11
  6. package/dist/commands/backfill-scope.js +16 -4
  7. package/dist/commands/backfill-session-report.js +2 -0
  8. package/dist/commands/cli-io.js +1 -3
  9. package/dist/commands/doctor-access.js +7 -0
  10. package/dist/commands/doctor-registration.js +39 -0
  11. package/dist/commands/doctor-report.js +1 -0
  12. package/dist/commands/doctor-shelf.js +62 -0
  13. package/dist/commands/doctor-update.js +2 -0
  14. package/dist/commands/doctor-usage-telemetry.js +18 -2
  15. package/dist/commands/doctor.js +9 -0
  16. package/dist/commands/heartbeat-post.js +19 -0
  17. package/dist/commands/heartbeat.js +13 -2
  18. package/dist/commands/install-receipts.js +4 -0
  19. package/dist/commands/local-args-collector-setup.js +2 -0
  20. package/dist/commands/local-args-collector-status.js +7 -21
  21. package/dist/commands/local-args.js +2 -0
  22. package/dist/commands/local-discovery.js +2 -2
  23. package/dist/commands/local-help-commands-tower.js +6 -1
  24. package/dist/commands/local-help-commands.js +15 -2
  25. package/dist/commands/local-help.js +4 -2
  26. package/dist/commands/local.js +7 -0
  27. package/dist/commands/memory-capture-prompt.js +130 -0
  28. package/dist/commands/memory-install-claude.js +51 -4
  29. package/dist/commands/memory-install-report.js +19 -3
  30. package/dist/commands/memory-install-toml.js +22 -0
  31. package/dist/commands/memory-install.js +110 -11
  32. package/dist/commands/memory-store.js +5 -0
  33. package/dist/commands/memory-switch.js +165 -0
  34. package/dist/commands/memory-uninstall.js +187 -0
  35. package/dist/commands/memory-verb-list.js +6 -2
  36. package/dist/commands/onboard.js +5 -0
  37. package/dist/commands/public-root.js +1 -1
  38. package/dist/commands/roots-label.js +85 -0
  39. package/dist/commands/roots.js +28 -10
  40. package/dist/commands/session-sync-plan.js +8 -4
  41. package/dist/commands/session-sync-scan.js +25 -3
  42. package/dist/commands/session-sync-shelf.js +143 -0
  43. package/dist/commands/session-sync-upload.js +6 -2
  44. package/dist/commands/session-sync.js +68 -8
  45. package/dist/commands/shelf.js +165 -0
  46. package/dist/commands/sources-check.js +2 -2
  47. package/dist/commands/sources-reconciliation.js +2 -1
  48. package/dist/commands/sync-doctor.js +2 -1
  49. package/dist/commands/sync-receipt.js +1 -0
  50. package/dist/commands/sync-report.js +1 -0
  51. package/dist/commands/sync-stub-heal.js +2 -2
  52. package/dist/commands/sync.js +12 -0
  53. package/dist/error-message.js +11 -0
  54. package/dist/evidence-reconcile-client.js +2 -0
  55. package/dist/evidence-redelivery-plan.js +6 -0
  56. package/dist/evidence-redelivery.js +6 -1
  57. package/dist/evidence-stubs-client.js +2 -0
  58. package/dist/evidence-transcripts-client.js +2 -0
  59. package/dist/evidence-upload-transport.js +2 -0
  60. package/dist/founder-standing-file.js +52 -0
  61. package/dist/founder-standing.js +117 -0
  62. package/dist/image-describe-client.js +2 -1
  63. package/dist/live-founder-memo.js +57 -0
  64. package/dist/local-state-config.js +13 -7
  65. package/dist/memory-capture-choice.js +73 -0
  66. package/dist/root-work.js +55 -7
  67. package/dist/shelf-decision.js +52 -0
  68. package/dist/shelf-headers.js +15 -0
  69. package/dist/shelf-reports.js +167 -0
  70. package/dist/shelf-state.js +112 -0
  71. package/dist/source-opt-out.js +83 -6
  72. package/dist/state-dir-audit.js +2 -0
  73. package/dist/tower-client.js +4 -1
  74. package/dist/upload-agent-artifacts.js +2 -0
  75. package/dist/upload-envelope-build.js +2 -2
  76. package/dist/upload-ingest-receipt.js +2 -0
  77. package/dist/upload-session-reports-wire.js +2 -0
  78. package/package.json +4 -4
@@ -0,0 +1,57 @@
1
+ /**
2
+ * BLI-22640: what Tower said about this machine's person, remembered INSIDE
3
+ * THIS PROCESS ONLY, so one sync tick (which decides in a dozen places) asks
4
+ * Tower once and not a dozen times. Nothing here touches the disk: a new
5
+ * process always asks again, so no file a person can edit is ever a source.
6
+ *
7
+ * Definite answers live one minute (the window Tower's own lock caches its
8
+ * owners list for). A "could not ask" lives ten seconds: long enough that a
9
+ * tick whose Tower is down holds everywhere without ten timeouts in a row,
10
+ * short enough that a Tower that comes back is asked again.
11
+ *
12
+ * Keyed by state directory, so two homes in one test process never share.
13
+ * No network and no Tower client in here: the light readers
14
+ * (`source-opt-out.ts`, `memory-capture-choice.ts`) import it for free.
15
+ *
16
+ * A new sibling must also join `scripts/build-public-cli.mjs` `runtimeFiles`.
17
+ */
18
+ const ANSWER_MS = 60_000;
19
+ const COULD_NOT_ASK_MS = 10_000;
20
+ const memo = new Map();
21
+ export function rememberLiveFounderAnswer(stateDir, answer, now = Date.now()) {
22
+ memo.set(stateDir, { answer, at: now });
23
+ }
24
+ /** The answer this process got from Tower recently, or null when it must ask. */
25
+ export function recallLiveFounderAnswer(stateDir, now = Date.now()) {
26
+ const known = memo.get(stateDir);
27
+ if (!known)
28
+ return null;
29
+ const window = known.answer.standing === "unknown" ? COULD_NOT_ASK_MS : ANSWER_MS;
30
+ if (now - known.at >= window) {
31
+ memo.delete(stateDir);
32
+ return null;
33
+ }
34
+ return known.answer;
35
+ }
36
+ /** Tests only. */
37
+ export function forgetLiveFounderAnswers() {
38
+ memo.clear();
39
+ }
40
+ /**
41
+ * What every decider asks: Tower's word on this machine's person, from this
42
+ * process's last minute or a fresh ask. Never the saved file. `unknown` means
43
+ * Tower could not say, and the caller holds. The Tower client loads only on a
44
+ * miss, so a machine with no opt-out (it never calls this) and a tick that has
45
+ * already asked pay nothing.
46
+ */
47
+ export async function liveStandingForDecision(paths, ask = {}) {
48
+ const known = recallLiveFounderAnswer(paths.state_dir);
49
+ if (known)
50
+ return known;
51
+ const { liveFounderStanding } = await import("./founder-standing.js");
52
+ return liveFounderStanding({
53
+ homeDir: paths.home_dir,
54
+ ...(ask.fetch ? { fetch: ask.fetch } : {}),
55
+ ...(ask.dashboardUrl ? { dashboardUrl: ask.dashboardUrl } : {}),
56
+ });
57
+ }
@@ -5,7 +5,7 @@ import { readFileSync } from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { normalizeCollectionRoots } from "./root-normalization.js";
8
- import { configForDisk, resolveSourceSwitches } from "./source-opt-out.js";
8
+ import { configForDisk, configOptOutLabels } from "./source-opt-out.js";
9
9
  import { ensureRuntimeDirectories, getCollectorRuntimePaths, } from "./local-state-paths.js";
10
10
  import { isMissingFileError, readJsonFile, writeJsonFile, } from "./local-state-files.js";
11
11
  const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -68,17 +68,23 @@ export async function installLocalCollector(options = {}) {
68
68
  collect_claude_jsonl: existingConfig?.collect_claude_jsonl,
69
69
  // Same for Codex, and for `raw_evidence_upload: "disabled"` below (ticket 22557).
70
70
  collect_codex_jsonl: existingConfig?.collect_codex_jsonl,
71
+ // BLI-23590: a local `roots_only` request survives every rewrite too; whether it counts is Tower's call.
72
+ collection_scope: existingConfig?.collection_scope,
73
+ // BLI-23478 (three shelves): a founder's shelf choices survive every rewrite exactly as the opt-outs above
74
+ // do. Optional with no default, so they reach disk only when someone set them.
75
+ shelf_default: existingConfig?.shelf_default,
76
+ shelf_metrics_allow: existingConfig?.shelf_metrics_allow,
71
77
  session_file_path: paths.session_file,
72
78
  state_dir_path: paths.state_dir,
73
79
  });
74
80
  await recordRootApprovals(paths, defaultRepoPaths, options.replaceRepoRoots);
75
81
  await writeJsonFile(paths.config_file, configForDisk(config));
76
- const switches = resolveSourceSwitches(config);
77
- if (!switches.claude) {
78
- console.error("[install] claude session upload stays off", JSON.stringify({ reason: switches.claudeReason }));
79
- }
80
- if (!switches.codex) {
81
- console.error("[install] codex session upload stays off", JSON.stringify({ reason: switches.codexReason }));
82
+ // Install decides nothing about what leaves the machine, so it asks Tower
83
+ // nothing: it names the opt-outs the config carries, and every run that
84
+ // collects asks Tower live whether they are obeyed (BLI-22640).
85
+ const carried = configOptOutLabels(config);
86
+ if (carried.length > 0) {
87
+ console.error("[install] config carries session-upload opt-outs; each run decides on Tower's live answer whether they hold", JSON.stringify({ reason: "opt_out_carried", labels: carried }));
82
88
  }
83
89
  return {
84
90
  config,
@@ -0,0 +1,73 @@
1
+ import { optOutVerdict, MEMORY_CAPTURE_CHOICE_FILE_NAME, parseMemoryCaptureChoice, renderMemoryCaptureChoice, } from "@bli-cockpit/telemetry-core";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { liveStandingForDecision } from "./live-founder-memo.js";
5
+ /**
6
+ * BLI-22640. This machine's BLI Memory choice: the collector's half of
7
+ * `memory-capture-contract.ts` (telemetry-core), which says why it is a file
8
+ * in the state directory and not a key in `config.json`.
9
+ *
10
+ * Only a person's own act writes it: `cockpit memory on|off|install|uninstall`,
11
+ * `cockpit doctor --no-memory`, or their answer to the one-time question in an
12
+ * interactive `doctor` / `onboard`. The unattended paths (the sync tick,
13
+ * doctor's repairs, onboard's closing install) only READ it.
14
+ *
15
+ * A new sibling must also join `scripts/build-public-cli.mjs` `runtimeFiles`.
16
+ */
17
+ export const MEMORY_CAPTURE_CHOICE_FILE = MEMORY_CAPTURE_CHOICE_FILE_NAME;
18
+ function choicePath(paths) {
19
+ return path.join(paths.state_dir, MEMORY_CAPTURE_CHOICE_FILE);
20
+ }
21
+ export async function readMemoryCaptureChoice(paths) {
22
+ let raw;
23
+ try {
24
+ raw = await fs.readFile(choicePath(paths), "utf8");
25
+ }
26
+ catch (error) {
27
+ const code = error?.code;
28
+ if (code === "ENOENT" || code === "ENOTDIR")
29
+ return { state: "unchosen" };
30
+ // A file we cannot read is somebody's choice we cannot see: off, named.
31
+ console.error("[memory-capture] choice file unreadable; treating BLI Memory as off", JSON.stringify({ reason: "choice_unreadable", error_code: code ?? "unknown" }));
32
+ return { state: "off", choice: null, reason: "choice_unreadable" };
33
+ }
34
+ const reading = parseMemoryCaptureChoice(raw);
35
+ if (reading.state === "off" && reading.reason === "choice_unreadable") {
36
+ console.error("[memory-capture] choice file does not parse; treating BLI Memory as off", JSON.stringify({ reason: "choice_unreadable", byte_size: raw.length }));
37
+ }
38
+ return reading;
39
+ }
40
+ export async function readEffectiveMemoryCapture(paths, ask = {}) {
41
+ const reading = await readMemoryCaptureChoice(paths);
42
+ if (reading.state !== "off") {
43
+ return { reading, off: false, ignored: false, held: false, standing: "unknown", verdict: null };
44
+ }
45
+ const answer = await liveStandingForDecision(paths, ask);
46
+ const verdict = optOutVerdict(answer.standing);
47
+ if (!verdict.obeyed) {
48
+ console.error("[memory-capture] off ignored; only a founder's off is obeyed", JSON.stringify({ reason: verdict.reason, file_reason: reading.reason, via: reading.choice?.via ?? null }));
49
+ }
50
+ else if (verdict.held) {
51
+ console.error("[memory-capture] off held; Tower could not say whether this person is a founder, so BLI Memory sends nothing this run", JSON.stringify({ reason: verdict.reason, detail: answer.standing === "unknown" ? answer.reason : null, via: reading.choice?.via ?? null }));
52
+ }
53
+ return { reading, off: verdict.obeyed, ignored: !verdict.obeyed, held: verdict.held, standing: answer.standing, verdict: verdict.reason };
54
+ }
55
+ export async function isMemoryCaptureOff(paths, ask = {}) {
56
+ return (await readEffectiveMemoryCapture(paths, ask)).off;
57
+ }
58
+ /**
59
+ * Records the person's choice. Throws when it cannot be written: a caller that
60
+ * told a person "BLI Memory is off" over a file that did not land would be
61
+ * lying about the one thing this switch exists for.
62
+ */
63
+ export async function writeMemoryCaptureChoice(paths, memoryCapture, via, now = new Date()) {
64
+ await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
65
+ await fs.writeFile(choicePath(paths), renderMemoryCaptureChoice({ memory_capture: memoryCapture, chosen_at: now.toISOString(), via }), { encoding: "utf8", mode: 0o600 });
66
+ const stored = parseMemoryCaptureChoice(await fs.readFile(choicePath(paths), "utf8"));
67
+ const expected = memoryCapture ? "on" : "off";
68
+ if (stored.state !== expected) {
69
+ console.error("[memory-capture] choice did not read back", JSON.stringify({ reason: "read_back_mismatch", expected, stored: stored.state }));
70
+ throw new Error("memory_capture_read_back_mismatch");
71
+ }
72
+ console.error("[memory-capture] choice recorded", JSON.stringify({ reason: memoryCapture ? "memory_on" : "explicit_opt_out", via }));
73
+ }
package/dist/root-work.js CHANGED
@@ -1,6 +1,8 @@
1
1
  /** Local directory evidence and explicit root approvals. Full paths stay here. */
2
+ import { realpathSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { z } from "zod";
5
+ import { ROOT_APPROVALS_FILENAME } from "@bli-cockpit/telemetry-core";
4
6
  import { readJsonFile, writeJsonFile, isMissingFileError } from "./local-state-files.js";
5
7
  import { containsPath, normalizeCollectionRoots } from "./root-normalization.js";
6
8
  const DirectorySchema = z.object({ directory: z.string(), count: z.number().int().nonnegative() });
@@ -69,22 +71,68 @@ export async function readRootWorkScan(paths) {
69
71
  throw error;
70
72
  }
71
73
  }
72
- const ApprovalsSchema = z.object({ approved_at: z.string(), roots: z.array(z.string()) });
73
- export async function readRootApprovals(paths) {
74
+ // BLI-23478 (three shelves): `labels` maps a folder to the shelf its sessions and memories go to. Read by the shared
75
+ // resolver (`@bli-cockpit/memory-mcp` `shelf-routing.ts`); an older CLI's parse strips the key it does not know.
76
+ const ApprovalsSchema = z.object({
77
+ approved_at: z.string(),
78
+ roots: z.array(z.string()),
79
+ // Any string is kept as read (a hand-edited `safe` must not break the approvals); the resolver treats a value
80
+ // other than `team` or `drawer` as the drawer.
81
+ labels: z.record(z.string(), z.string()).default({}),
82
+ });
83
+ const approvalsFile = (paths) => path.join(paths.config_dir, ROOT_APPROVALS_FILENAME);
84
+ async function readApprovalsFile(paths) {
74
85
  try {
75
- return ApprovalsSchema.parse(await readJsonFile(path.join(paths.config_dir, "root-approvals.json"))).roots;
86
+ return ApprovalsSchema.parse(await readJsonFile(approvalsFile(paths)));
76
87
  }
77
88
  catch (error) {
78
89
  if (isMissingFileError(error))
79
- return [];
90
+ return null;
80
91
  throw error;
81
92
  }
82
93
  }
94
+ export async function readRootApprovals(paths) {
95
+ return (await readApprovalsFile(paths))?.roots ?? [];
96
+ }
97
+ /** BLI-23478: the folder labels on this laptop. Full paths; they never leave the machine. */
98
+ export async function readRootLabels(paths) {
99
+ return (await readApprovalsFile(paths))?.labels ?? {};
100
+ }
83
101
  export async function recordRootApprovals(paths, roots, replace = false) {
84
- const previous = replace ? [] : await readRootApprovals(paths);
102
+ const existing = await readApprovalsFile(paths);
103
+ const previous = replace ? [] : existing?.roots ?? [];
85
104
  const approved = normalizeCollectionRoots([...previous, ...roots]);
86
- await writeJsonFile(path.join(paths.config_dir, "root-approvals.json"), { approved_at: new Date().toISOString(), roots: approved });
87
- console.error("[roots] approval recorded", JSON.stringify({ roots: approved.length, replaced: replace }));
105
+ // Labels survive every rewrite of the approvals, including a replace: only `cockpit roots label` changes them.
106
+ const labels = existing?.labels ?? {};
107
+ await writeJsonFile(approvalsFile(paths), {
108
+ approved_at: new Date().toISOString(),
109
+ roots: approved,
110
+ ...(Object.keys(labels).length > 0 ? { labels } : {}),
111
+ });
112
+ console.error("[roots] approval recorded", JSON.stringify({ roots: approved.length, replaced: replace, labels: Object.keys(labels).length }));
113
+ }
114
+ /**
115
+ * BLI-23478: label one folder `team` or `drawer`. The folder is stored normalized (the same spelling the approvals
116
+ * use) so the resolver compares like with like on both platforms. Returns the label count after the write.
117
+ */
118
+ export async function recordRootLabel(paths, root, label) {
119
+ const existing = await readApprovalsFile(paths);
120
+ if (!root.trim())
121
+ throw new Error("Choose a folder to label.");
122
+ // The case the filesystem stores (case-insensitive APFS keeps whatever was typed otherwise).
123
+ let normalized = path.resolve(root);
124
+ try {
125
+ normalized = realpathSync.native(normalized);
126
+ }
127
+ catch { /* not on disk: keep the typed spelling */ }
128
+ const labels = { ...(existing?.labels ?? {}), [normalized]: label };
129
+ await writeJsonFile(approvalsFile(paths), {
130
+ approved_at: existing?.approved_at ?? new Date().toISOString(),
131
+ roots: existing?.roots ?? [],
132
+ labels,
133
+ });
134
+ console.error("[roots] label recorded", JSON.stringify({ reason: "root_label_recorded", shelf: label, labels: Object.keys(labels).length }));
135
+ return Object.keys(labels).length;
88
136
  }
89
137
  export function rootsAddCommand(directory, platform = process.platform, homeDir) {
90
138
  // Native Windows command is for PowerShell; POSIX quoting is for zsh/bash.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The collector's one shelf decision per run (BLI-23478, Jeetesh's review of PR 2819).
3
+ *
4
+ * "Is this laptop's person a founder?" is Tower's LIVE answer (`@bli-cockpit/memory-mcp` `founder-check.ts`),
5
+ * never the saved heartbeat file a teammate could edit. Four outcomes:
6
+ *
7
+ * - `inactive` no labels and no `shelf_default` on this laptop (every teammate's): nothing is asked, nothing
8
+ * changes, no extra request on the sync path.
9
+ * - `founder` Tower says founder: the labels apply.
10
+ * - `not_founder` Tower says not a founder: the labels are IGNORED, the laptop collects exactly as a teammate's
11
+ * does (`shelf_labels_ignored_not_owner`).
12
+ * - `hold` Tower could not be asked: the run sends nothing and keeps nothing in the drawer, and the next
13
+ * run asks again (`owner_check_unavailable`).
14
+ *
15
+ * Logs: labels only (`safeLog`).
16
+ */
17
+ import { safeLog } from "@bli-cockpit/telemetry-core";
18
+ import { askTowerFounder, OWNER_CHECK_UNAVAILABLE } from "@bli-cockpit/memory-mcp/dist/founder-check.js";
19
+ import { loadShelfRules } from "@bli-cockpit/memory-mcp/dist/shelf-routing.js";
20
+ import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile } from "./local-state.js";
21
+ export { OWNER_CHECK_UNAVAILABLE };
22
+ /**
23
+ * Tower's live answer alone, for the verbs that act on the founder question whatever the labels say (`cockpit roots
24
+ * label … drawer`, `cockpit shelf default drawer`, the doctor row, the usage-telemetry opt-out, the memory verbs).
25
+ */
26
+ export async function askFounderLive(options) {
27
+ const paths = getCollectorRuntimePaths(options.homeDir);
28
+ const [session, config] = await Promise.all([
29
+ readLocalCollectorSessionFile(paths).catch(() => null),
30
+ options.dashboardUrl ? Promise.resolve(null) : readLocalCollectorConfig(paths).catch(() => null),
31
+ ]);
32
+ return askTowerFounder({
33
+ dashboardUrl: options.dashboardUrl ?? session?.dashboard_url ?? config?.dashboard_url ?? null,
34
+ deviceToken: session?.device_token ?? null,
35
+ fetchImpl: options.fetchImpl,
36
+ caller: "collector",
37
+ });
38
+ }
39
+ export async function decideShelfMode(options) {
40
+ const rules = options.rules ?? loadShelfRules(options.homeDir ? { homeDir: options.homeDir } : {});
41
+ if (!rules.active)
42
+ return { mode: "inactive", rules, reason: "routing_inactive" };
43
+ const answer = await askFounderLive(options);
44
+ if (answer.standing === "founder")
45
+ return { mode: "founder", rules, reason: answer.reason };
46
+ if (answer.standing === "not_founder") {
47
+ safeLog("shelf", "shelf_labels_ignored_not_owner", {});
48
+ return { mode: "not_founder", rules, reason: "shelf_labels_ignored_not_owner" };
49
+ }
50
+ safeLog("shelf", "shelf_held", { detail: answer.reason });
51
+ return { mode: "hold", rules, reason: OWNER_CHECK_UNAVAILABLE };
52
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * BLI-23478 (three shelves, design 9.1 item 11): the three headers on every device-token request the collector
3
+ * makes. The collector only ever sends team-shelf content (a drawer session never leaves the laptop; its bare
4
+ * numbers are team-shelf data), so the set is constant: `x-tower-shelf: team`, `x-tower-caller: collector` and this
5
+ * CLI's release. On a teammate's laptop Tower ignores them; on a founder's, Tower's lock requires them once founder
6
+ * routing is on, and refuses any request from an older CLI that does not send them.
7
+ *
8
+ * `LOCAL_COLLECTOR_VERSION` is the `@bli-cockpit/cli` release in the published CLI: its `dist/` reads the CLI
9
+ * package's own manifest (`local-state-config.ts`).
10
+ */
11
+ import { shelfRequestHeaders } from "@bli-cockpit/telemetry-core";
12
+ import { LOCAL_COLLECTOR_VERSION } from "./local-state-config.js";
13
+ export function collectorShelfHeaders() {
14
+ return shelfRequestHeaders({ caller: "collector", cliVersion: LOCAL_COLLECTOR_VERSION });
15
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The numbers-only bare record for every drawer session (BLI-23478, three-shelves design 3.2 and 9.1 item 10).
3
+ *
4
+ * A drawer session's words never leave the laptop. What Tower gets instead is one strict `shelf.v1` record per
5
+ * session, under a random `report_id`: how long, how many turns, how many tokens, how big. No session id, folder,
6
+ * branch, commit, tool or model (the last two only if the founder allowed them with `cockpit shelf metrics`).
7
+ *
8
+ * Two rules keep the sums honest:
9
+ *
10
+ * - **One record per session, once it has settled.** A session is reported after its transcript has been quiet
11
+ * for {@link SHELF_REPORT_SETTLE_MS}; Tower's insert ignores a repeated `report_id`, so a record sent half-way
12
+ * through a session would freeze its numbers there. A session resumed after it was reported is not re-counted
13
+ * (stated residual).
14
+ * - **The `report_id` is kept in a local cursor** (`<state dir>/shelf-reports.json`), keyed by a local-only
15
+ * session key, so a retry after a failed POST sends the SAME id and Tower counts it once.
16
+ *
17
+ * The POST goes to `/api/ambient/ingest` as its own `shelf-reports.v1` envelope with the collector's three shelf
18
+ * headers. The header says `team` because the numbers are team-shelf data; Tower's lock refuses a founder's request
19
+ * whose header says anything else.
20
+ *
21
+ * Logs: counts and labels only (`safeLog`).
22
+ */
23
+ import crypto from "node:crypto";
24
+ import path from "node:path";
25
+ import { errorNameOnly, projectShelfMetrics, safeLog, SHELF_REPORTS_ENVELOPE_VERSION, SHELF_REPORTS_MAX_PER_REQUEST, } from "@bli-cockpit/telemetry-core";
26
+ import { readJsonFile, writeJsonFileAtomic } from "./local-state-files.js";
27
+ import { collectorShelfHeaders } from "./shelf-headers.js";
28
+ const TAG = "shelf";
29
+ export const SHELF_REPORT_CURSOR_FILENAME = "shelf-reports.json";
30
+ export const SHELF_REPORT_SETTLE_MS = 6 * 60 * 60 * 1000;
31
+ /** A cursor entry is forgotten this long after it was sent; by then the session is far outside every scan window. */
32
+ const CURSOR_RETENTION_MS = 120 * 24 * 60 * 60 * 1000;
33
+ function cursorFile(paths) {
34
+ return path.join(paths.state_dir, SHELF_REPORT_CURSOR_FILENAME);
35
+ }
36
+ async function readCursor(paths) {
37
+ const value = (await readJsonFile(cursorFile(paths)).catch(() => null));
38
+ if (value?.schema_version !== "shelf-report-cursor.v1" || !value.sessions || typeof value.sessions !== "object") {
39
+ return { schema_version: "shelf-report-cursor.v1", sessions: {} };
40
+ }
41
+ return { schema_version: "shelf-report-cursor.v1", sessions: value.sessions };
42
+ }
43
+ /** How many drawer sessions this laptop has recorded, for `cockpit shelf status`. */
44
+ export async function countShelvedSessions(paths) {
45
+ const cursor = await readCursor(paths);
46
+ const firstSeen = Object.values(cursor.sessions).map((entry) => entry.first_seen_at).sort();
47
+ return { sessions: firstSeen.length, oldest: firstSeen[0] ?? null };
48
+ }
49
+ /** Send what is owed. Never throws; every branch logs one line with counts. */
50
+ export async function reportShelvedSessions(options) {
51
+ const now = options.now ?? new Date();
52
+ const outcome = { shelved: options.shelved.length, sent: 0, already_sent: 0, unsettled: 0, not_counted: 0, failed: 0, refused: 0, reason: "nothing_owed" };
53
+ if (options.shelved.length === 0)
54
+ return outcome;
55
+ const cursor = await readCursor(options.paths);
56
+ const owed = [];
57
+ for (const session of options.shelved) {
58
+ const entry = cursor.sessions[session.key] ??= {
59
+ report_id: crypto.randomUUID(),
60
+ sent_at: null,
61
+ first_seen_at: now.toISOString(),
62
+ };
63
+ if (entry.sent_at) {
64
+ outcome.already_sent += 1;
65
+ continue;
66
+ }
67
+ if (entry.refused_reason) {
68
+ outcome.refused += 1;
69
+ continue;
70
+ }
71
+ if (!session.facts) {
72
+ outcome.not_counted += 1;
73
+ continue;
74
+ }
75
+ if (now.getTime() - session.recencyMs < SHELF_REPORT_SETTLE_MS) {
76
+ outcome.unsettled += 1;
77
+ continue;
78
+ }
79
+ try {
80
+ owed.push({ key: session.key, report: { report_id: entry.report_id, shelf: "drawer", metrics: projectShelfMetrics(session.facts, options.allow ?? []) } });
81
+ }
82
+ catch (error) {
83
+ outcome.not_counted += 1;
84
+ safeLog(TAG, "shelf_report_projection_failed", { error_name: errorNameOnly(error) });
85
+ }
86
+ }
87
+ if (owed.length > 0) {
88
+ if (!options.dashboardUrl || !options.deviceToken) {
89
+ outcome.failed = owed.length;
90
+ outcome.reason = "not_signed_in";
91
+ }
92
+ else {
93
+ await sendOwed(owed, cursor, outcome, { ...options, dashboardUrl: options.dashboardUrl, deviceToken: options.deviceToken, now });
94
+ }
95
+ }
96
+ else if (outcome.unsettled > 0) {
97
+ outcome.reason = "waiting_to_settle";
98
+ }
99
+ else {
100
+ outcome.reason = "nothing_owed";
101
+ }
102
+ pruneCursor(cursor, now);
103
+ try {
104
+ await writeJsonFileAtomic(cursorFile(options.paths), cursor);
105
+ }
106
+ catch (error) {
107
+ // The ids stay in memory only; the next tick makes new ones for anything not yet sent, and Tower has none of
108
+ // those yet, so nothing is double-counted. Said out loud because the cursor is what makes retries idempotent.
109
+ safeLog(TAG, "shelf_report_cursor_not_written", { error_name: errorNameOnly(error) });
110
+ }
111
+ safeLog(TAG, outcome.failed > 0 ? "shelf_reports_owed" : outcome.sent > 0 ? "shelf_reports_sent" : outcome.reason, { ...outcome, reason: undefined });
112
+ return outcome;
113
+ }
114
+ async function sendOwed(owed, cursor, outcome, options) {
115
+ for (let start = 0; start < owed.length; start += SHELF_REPORTS_MAX_PER_REQUEST) {
116
+ const chunk = owed.slice(start, start + SHELF_REPORTS_MAX_PER_REQUEST);
117
+ let status = 0;
118
+ let serverReason = null;
119
+ try {
120
+ const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/ingest`, {
121
+ method: "POST",
122
+ headers: {
123
+ Authorization: `Bearer ${options.deviceToken}`,
124
+ "Content-Type": "application/json",
125
+ ...collectorShelfHeaders(),
126
+ },
127
+ body: JSON.stringify({
128
+ envelope_version: SHELF_REPORTS_ENVELOPE_VERSION,
129
+ generated_at: options.now.toISOString(),
130
+ shelf_reports: chunk.map((item) => item.report),
131
+ }),
132
+ signal: AbortSignal.timeout(30_000),
133
+ });
134
+ status = response.status;
135
+ const body = (await response.json().catch(() => null));
136
+ serverReason = typeof body?.reason === "string" && /^[a-z0-9_:-]{1,80}$/u.test(body.reason) ? body.reason : null;
137
+ }
138
+ catch (error) {
139
+ safeLog(TAG, "shelf_reports_transport_failed", { reports: chunk.length, error_name: errorNameOnly(error) });
140
+ }
141
+ if (status >= 200 && status < 300) {
142
+ for (const item of chunk)
143
+ cursor.sessions[item.key].sent_at = options.now.toISOString();
144
+ outcome.sent += chunk.length;
145
+ outcome.reason = "shelf_reports_sent";
146
+ continue;
147
+ }
148
+ // Only an owner's laptop sends bare records; a refusal that says so is final for these ids.
149
+ if (status === 403 && serverReason === "shelf_reports_owner_only") {
150
+ for (const item of chunk)
151
+ cursor.sessions[item.key].refused_reason = serverReason;
152
+ outcome.refused += chunk.length;
153
+ outcome.reason = serverReason;
154
+ continue;
155
+ }
156
+ outcome.failed += chunk.length;
157
+ outcome.reason = serverReason ?? (status === 0 ? "shelf_reports_transport_failed" : `shelf_reports_http_${status}`);
158
+ safeLog(TAG, "shelf_reports_refused", { reports: chunk.length, http_status: status, server_reason: serverReason });
159
+ }
160
+ }
161
+ function pruneCursor(cursor, now) {
162
+ for (const [key, entry] of Object.entries(cursor.sessions)) {
163
+ const sent = entry.sent_at ? Date.parse(entry.sent_at) : NaN;
164
+ if (Number.isFinite(sent) && now.getTime() - sent > CURSOR_RETENTION_MS)
165
+ delete cursor.sessions[key];
166
+ }
167
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * What the heartbeat learns about Tower's founder lock, kept on the laptop (BLI-23478, three-shelves design 9.1
3
+ * item 12), and the heartbeat's own shelf rule for roots.
4
+ *
5
+ * The heartbeat answer carries `{ shelf_enforced, min_cli_version, is_owner, founder_routing_enabled }` (and
6
+ * `cli_too_old` for an owner on an old CLI). This module writes it to `<state dir>/shelf-state.json`, where the
7
+ * memory hooks, the Memory MCP server and `cockpit doctor` read it without a network call. Only the heartbeat
8
+ * writes it; an answer without the fields (a Tower that predates the lock) leaves the last file as it was.
9
+ *
10
+ * Logs: labels only (`safeLog`).
11
+ */
12
+ import path from "node:path";
13
+ import os from "node:os";
14
+ import { errorNameOnly, safeLog, SHELF_STATE_FILENAME, SHELF_STATE_SCHEMA_VERSION, ShelfStateFileSchema, } from "@bli-cockpit/telemetry-core";
15
+ import { readShelfState, resolveShelfWith } from "@bli-cockpit/memory-mcp/dist/shelf-routing.js";
16
+ import { normalizeMachinePath } from "@bli-cockpit/memory-mcp/dist/recorded-outside-roots.js";
17
+ import { getCollectorRuntimePaths } from "./local-state-paths.js";
18
+ import { decideShelfMode, OWNER_CHECK_UNAVAILABLE } from "./shelf-decision.js";
19
+ import { writeJsonFileAtomic } from "./local-state-files.js";
20
+ const TAG = "shelf";
21
+ function bool(value) {
22
+ return typeof value === "boolean" ? value : null;
23
+ }
24
+ /** Store the lock state from one heartbeat answer. Never throws. */
25
+ export async function recordShelfState(homeDir, reply, now = new Date()) {
26
+ if (!reply || typeof reply["shelf_enforced"] !== "boolean") {
27
+ safeLog(TAG, "shelf_state_not_in_answer", {});
28
+ return null;
29
+ }
30
+ // A null means Tower could not read its own state this time (heartbeat `shelf_state_unreadable`). It never
31
+ // overwrites a known answer: one blip must not turn a founder's laptop back into a teammate's (fail closed).
32
+ const previous = readShelfState(homeDir ?? os.homedir());
33
+ const isOwner = bool(reply["is_owner"]) ?? previous?.is_owner ?? null;
34
+ const routing = bool(reply["founder_routing_enabled"]) ?? previous?.founder_routing_enabled ?? null;
35
+ if (bool(reply["is_owner"]) === null || bool(reply["founder_routing_enabled"]) === null) {
36
+ safeLog(TAG, "shelf_state_partly_unknown", { kept_is_owner: isOwner, kept_routing: routing });
37
+ }
38
+ const parsed = ShelfStateFileSchema.safeParse({
39
+ schema_version: SHELF_STATE_SCHEMA_VERSION,
40
+ checked_at: now.toISOString(),
41
+ shelf_enforced: reply["shelf_enforced"],
42
+ is_owner: isOwner,
43
+ founder_routing_enabled: routing,
44
+ min_cli_version: typeof reply["min_cli_version"] === "string" ? reply["min_cli_version"].slice(0, 40) : null,
45
+ cli_too_old: reply["cli_too_old"] === true,
46
+ });
47
+ if (!parsed.success) {
48
+ safeLog(TAG, "shelf_state_answer_unreadable", { field: parsed.error.issues[0]?.path.join(".") ?? null });
49
+ return null;
50
+ }
51
+ try {
52
+ await writeJsonFileAtomic(path.join(getCollectorRuntimePaths(homeDir).state_dir, SHELF_STATE_FILENAME), parsed.data);
53
+ }
54
+ catch (error) {
55
+ safeLog(TAG, "shelf_state_not_written", { error_name: errorNameOnly(error) });
56
+ return null;
57
+ }
58
+ const state = parsed.data;
59
+ const changed = previous?.is_owner !== state.is_owner ||
60
+ previous?.founder_routing_enabled !== state.founder_routing_enabled ||
61
+ previous?.cli_too_old !== state.cli_too_old;
62
+ safeLog(TAG, changed ? "shelf_state_changed" : "shelf_state_recorded", {
63
+ is_owner: state.is_owner,
64
+ founder_routing_enabled: state.founder_routing_enabled,
65
+ shelf_enforced: state.shelf_enforced,
66
+ cli_too_old: state.cli_too_old,
67
+ });
68
+ return state;
69
+ }
70
+ /**
71
+ * The approved roots that may travel as labels, and how many stay home as a bare count (drawer-labelled). Asks
72
+ * Tower live on a laptop with labels (`shelf-decision.ts`): a non-founder's labels are ignored and every root
73
+ * travels as today; with no answer the drawer roots stay hidden this tick (fail closed; the heartbeat still goes).
74
+ */
75
+ export async function teamRootsForHeartbeat(roots, homeDir, fetchImpl) {
76
+ const decision = await decideShelfMode({ ...(homeDir ? { homeDir } : {}), fetchImpl });
77
+ const rules = decision.rules;
78
+ if (decision.mode === "inactive" || decision.mode === "not_founder")
79
+ return { team: [...roots], shelved: 0 };
80
+ const team = rules.failed ? [] : roots.filter((root) => resolveShelfWith(rules, root).shelf === "team");
81
+ // Distinct folders: a drawer label on a folder that is also an approved root is one folder, counted once.
82
+ const offTeam = new Set([
83
+ ...roots.filter((root) => !team.includes(root)).map((root) => normalizeMachinePath(root, rules.platform)),
84
+ ...rules.folders.filter((folder) => folder.labelled && folder.shelf !== "team").map((folder) => folder.root),
85
+ ]);
86
+ const shelved = offTeam.size;
87
+ safeLog(TAG, "heartbeat_roots_split", { team_roots: team.length, shelved_roots: shelved });
88
+ return { team, shelved };
89
+ }
90
+ export { readShelfState };
91
+ /**
92
+ * BLI-23478: `cockpit memory search|save|update|forget` carry text an agent wrote, from a folder the CLI cannot
93
+ * prove, exactly like the Memory MCP. On a laptop with shelf settings Tower is asked LIVE (never the saved copy):
94
+ * a founder's laptop refuses locally by the same name as the MCP, and no answer refuses too
95
+ * (`owner_check_unavailable`). A laptop with no shelf settings asks nothing; Tower's lock refuses a founder's
96
+ * `collector` caller on a memory door anyway.
97
+ */
98
+ export const FOUNDER_MCP_DISABLED = "founder_mcp_disabled_until_host_workspace";
99
+ export const FOUNDER_TEXT_VERBS = new Set(["search", "save", "update", "forget"]);
100
+ export const FOUNDER_MEMORY_VERB_DETAIL = "On a founder's laptop this memory command is off until it can prove which folder it runs in. Nothing was sent. " +
101
+ "The automatic memory hooks still save and recall from team folders.";
102
+ export async function founderMemoryVerbRefusal(homeDir, fetchImpl) {
103
+ const decision = await decideShelfMode({ ...(homeDir ? { homeDir } : {}), fetchImpl });
104
+ const refusal = decision.mode === "founder"
105
+ ? { reason: FOUNDER_MCP_DISABLED, detail: FOUNDER_MEMORY_VERB_DETAIL }
106
+ : decision.mode === "hold"
107
+ ? { reason: OWNER_CHECK_UNAVAILABLE, detail: "Tower could not be asked whether this is a founder's laptop, so nothing was sent. Try again shortly." }
108
+ : null;
109
+ if (refusal)
110
+ safeLog(TAG, "memory_verb_refused", { refused: refusal.reason });
111
+ return refusal;
112
+ }