@bli-cockpit/cli 0.2.285 → 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.
- package/dist/capture-opt-outs.js +129 -0
- package/dist/collection-scope.js +201 -0
- package/dist/commands/agent-session-report.js +3 -0
- package/dist/commands/analyze.js +2 -0
- package/dist/commands/backfill-scan.js +16 -11
- package/dist/commands/backfill-scope.js +16 -4
- package/dist/commands/backfill-session-report.js +2 -0
- package/dist/commands/cli-io.js +1 -3
- package/dist/commands/doctor-access.js +7 -0
- package/dist/commands/doctor-registration.js +39 -0
- package/dist/commands/doctor-report.js +1 -0
- package/dist/commands/doctor-shelf.js +62 -0
- package/dist/commands/doctor-update.js +2 -0
- package/dist/commands/doctor-usage-telemetry.js +18 -2
- package/dist/commands/doctor.js +9 -0
- package/dist/commands/heartbeat-post.js +19 -0
- package/dist/commands/heartbeat.js +13 -2
- package/dist/commands/install-receipts.js +4 -0
- package/dist/commands/local-args-collector-setup.js +2 -0
- package/dist/commands/local-args-collector-status.js +7 -21
- package/dist/commands/local-args.js +2 -0
- package/dist/commands/local-discovery.js +2 -2
- package/dist/commands/local-help-commands-tower.js +6 -1
- package/dist/commands/local-help-commands.js +15 -2
- package/dist/commands/local-help.js +4 -2
- package/dist/commands/local.js +7 -0
- package/dist/commands/memory-capture-prompt.js +130 -0
- package/dist/commands/memory-install-claude.js +51 -4
- package/dist/commands/memory-install-report.js +19 -3
- package/dist/commands/memory-install-toml.js +22 -0
- package/dist/commands/memory-install.js +110 -11
- package/dist/commands/memory-store.js +5 -0
- package/dist/commands/memory-switch.js +165 -0
- package/dist/commands/memory-uninstall.js +187 -0
- package/dist/commands/memory-verb-list.js +6 -2
- package/dist/commands/onboard.js +5 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/roots-label.js +85 -0
- package/dist/commands/roots.js +28 -10
- package/dist/commands/session-sync-plan.js +8 -4
- package/dist/commands/session-sync-scan.js +25 -3
- package/dist/commands/session-sync-shelf.js +143 -0
- package/dist/commands/session-sync-upload.js +6 -2
- package/dist/commands/session-sync.js +68 -8
- package/dist/commands/shelf.js +165 -0
- package/dist/commands/sources-check.js +2 -2
- package/dist/commands/sources-reconciliation.js +2 -1
- package/dist/commands/sync-doctor.js +2 -1
- package/dist/commands/sync-receipt.js +1 -0
- package/dist/commands/sync-report.js +1 -0
- package/dist/commands/sync-stub-heal.js +2 -2
- package/dist/commands/sync.js +12 -0
- package/dist/error-message.js +11 -0
- package/dist/evidence-reconcile-client.js +2 -0
- package/dist/evidence-redelivery-plan.js +6 -0
- package/dist/evidence-redelivery.js +6 -1
- package/dist/evidence-stubs-client.js +2 -0
- package/dist/evidence-transcripts-client.js +2 -0
- package/dist/evidence-upload-transport.js +2 -0
- package/dist/founder-standing-file.js +52 -0
- package/dist/founder-standing.js +117 -0
- package/dist/image-describe-client.js +2 -1
- package/dist/live-founder-memo.js +57 -0
- package/dist/local-state-config.js +13 -7
- package/dist/memory-capture-choice.js +73 -0
- package/dist/root-work.js +55 -7
- package/dist/shelf-decision.js +52 -0
- package/dist/shelf-headers.js +15 -0
- package/dist/shelf-reports.js +167 -0
- package/dist/shelf-state.js +112 -0
- package/dist/source-opt-out.js +83 -6
- package/dist/staging-gate.js +11 -3
- package/dist/state-dir-audit.js +2 -0
- package/dist/tower-client.js +4 -1
- package/dist/upload-agent-artifacts.js +2 -0
- package/dist/upload-envelope-build.js +2 -2
- package/dist/upload-ingest-receipt.js +2 -0
- package/dist/upload-session-reports-wire.js +2 -0
- package/package.json +4 -4
|
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
24
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.287");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit roots label <dir> team|drawer` and `cockpit roots list` (BLI-23478, three-shelves design 9.1 item 7).
|
|
3
|
+
*
|
|
4
|
+
* A founder labels a folder to say which shelf its sessions and memories belong on: `team` (Tower, as today) or
|
|
5
|
+
* `drawer` (stays on this laptop; only bare numbers leave). `safe` is refused until the safe is live on this laptop
|
|
6
|
+
* (`safe_not_live`, design 4.5). A `drawer` label is refused on a laptop Tower has not named an owner's
|
|
7
|
+
* (`shelf_owner_only`): on a teammate's laptop nothing changes, by construction.
|
|
8
|
+
*
|
|
9
|
+
* The full folder path is printed back to the person who typed it (it is their own machine); it is never logged
|
|
10
|
+
* and never sent. Logs carry labels and counts only.
|
|
11
|
+
*/
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import { stat } from "node:fs/promises";
|
|
14
|
+
import { safeLog } from "@bli-cockpit/telemetry-core";
|
|
15
|
+
import { loadShelfRules, resolveShelfWith } from "@bli-cockpit/memory-mcp/dist/shelf-routing.js";
|
|
16
|
+
import { getCollectorRuntimePaths, readLocalCollectorConfig } from "../local-state.js";
|
|
17
|
+
import { readRootApprovals, readRootLabels, recordRootLabel } from "../root-work.js";
|
|
18
|
+
import { askFounderLive, OWNER_CHECK_UNAVAILABLE } from "../shelf-decision.js";
|
|
19
|
+
import { writeLine } from "./cli-io.js";
|
|
20
|
+
const TAG = "shelf";
|
|
21
|
+
export async function runRootsLabel(command, io) {
|
|
22
|
+
const homeDir = command.homeDir ?? os.homedir();
|
|
23
|
+
const refuse = (reason, message) => {
|
|
24
|
+
safeLog(TAG, "root_label_refused", { refused: reason, shelf: command.shelf === "safe" || command.shelf === "team" || command.shelf === "drawer" ? command.shelf : "other" });
|
|
25
|
+
writeLine(command.json ? io.stdout : io.stderr, command.json ? JSON.stringify({ ok: false, reason, message }) : message);
|
|
26
|
+
return 1;
|
|
27
|
+
};
|
|
28
|
+
if (command.shelf === "safe") {
|
|
29
|
+
return refuse("safe_not_live", "The safe is not live on this laptop yet, so a folder cannot be labelled safe. Use drawer: it stays on this laptop.");
|
|
30
|
+
}
|
|
31
|
+
if (command.shelf !== "team" && command.shelf !== "drawer") {
|
|
32
|
+
return refuse("unknown_shelf", "Label a folder team or drawer.");
|
|
33
|
+
}
|
|
34
|
+
// Tower is asked live (never the saved heartbeat copy): only a founder may label a folder drawer.
|
|
35
|
+
const founder = command.shelf === "drawer" ? await askFounderLive({ homeDir, fetchImpl: io.fetch }) : null;
|
|
36
|
+
if (founder && founder.standing === "unknown") {
|
|
37
|
+
return refuse(OWNER_CHECK_UNAVAILABLE, "Tower could not be asked whether this is a founder's laptop, so nothing was labelled. Try again shortly.");
|
|
38
|
+
}
|
|
39
|
+
if (founder && founder.standing !== "founder") {
|
|
40
|
+
return refuse("shelf_owner_only", "Drawer labels are for founders' laptops, and Tower says this is not one.");
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
if (!(await stat(command.directory)).isDirectory())
|
|
44
|
+
return refuse("not_a_directory", "Label a folder, not a file.");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return refuse("folder_not_found", "That folder does not exist on this laptop.");
|
|
48
|
+
}
|
|
49
|
+
const labels = await recordRootLabel(getCollectorRuntimePaths(homeDir), command.directory, command.shelf);
|
|
50
|
+
safeLog(TAG, "root_label_recorded", { shelf: command.shelf, labels });
|
|
51
|
+
const message = command.shelf === "drawer"
|
|
52
|
+
? `Labelled drawer: sessions and memories in ${command.directory} stay on this laptop; only bare numbers reach Tower.`
|
|
53
|
+
: `Labelled team: sessions and memories in ${command.directory} go to Tower as today.`;
|
|
54
|
+
writeLine(io.stdout, command.json ? JSON.stringify({ ok: true, shelf: command.shelf, labels }) : message);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
export async function runRootsList(command, io) {
|
|
58
|
+
const homeDir = command.homeDir ?? os.homedir();
|
|
59
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
60
|
+
const [approved, labels, config] = await Promise.all([
|
|
61
|
+
readRootApprovals(paths),
|
|
62
|
+
readRootLabels(paths),
|
|
63
|
+
readLocalCollectorConfig(paths).catch(() => null),
|
|
64
|
+
]);
|
|
65
|
+
const rules = loadShelfRules({ homeDir });
|
|
66
|
+
const folders = [...new Set([...approved, ...Object.keys(labels)])].sort();
|
|
67
|
+
const rows = folders.map((folder) => ({
|
|
68
|
+
folder,
|
|
69
|
+
approved: approved.includes(folder),
|
|
70
|
+
label: labels[folder] ?? null,
|
|
71
|
+
shelf: resolveShelfWith(rules, folder).shelf,
|
|
72
|
+
}));
|
|
73
|
+
const shelfDefault = config?.shelf_default ?? "team";
|
|
74
|
+
safeLog(TAG, "roots_listed", { folders: rows.length, labelled: Object.keys(labels).length, routing_active: rules.active });
|
|
75
|
+
if (command.json) {
|
|
76
|
+
writeLine(io.stdout, JSON.stringify({ ok: true, shelf_default: shelfDefault, routing_active: rules.active, roots: rows }));
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
writeLine(io.stdout, rows.length === 0 ? "No approved or labelled folders on this laptop." : "Folders on this laptop:");
|
|
80
|
+
for (const row of rows) {
|
|
81
|
+
writeLine(io.stdout, ` ${row.shelf.padEnd(6)} ${row.folder}${row.label ? ` (labelled ${row.label})` : ""}${row.approved ? "" : " (not an approved root)"}`);
|
|
82
|
+
}
|
|
83
|
+
writeLine(io.stdout, `Everything outside these folders: ${shelfDefault}.`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
package/dist/commands/roots.js
CHANGED
|
@@ -7,23 +7,41 @@ import { recordRootApprovals } from "../root-work.js";
|
|
|
7
7
|
import { writeJsonFile } from "../local-state-files.js";
|
|
8
8
|
import { configForDisk } from "../source-opt-out.js";
|
|
9
9
|
import { writeLine } from "./cli-io.js";
|
|
10
|
+
export const ROOTS_USAGE = "Usage: cockpit roots add <dir> [--allow-home-root] [--json] | label <dir> team|drawer [--json] | list [--json]";
|
|
10
11
|
export function parseRootsArgs(args) {
|
|
11
|
-
const [action,
|
|
12
|
-
|
|
13
|
-
throw new Error("Usage: cockpit roots add <dir> [--allow-home-root] [--json]");
|
|
12
|
+
const [action, ...rest] = args;
|
|
13
|
+
const positional = [];
|
|
14
14
|
let homeDir;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const allowed = action === "add" ? new Set(["--json", "--allow-home-root"]) : new Set(["--json"]);
|
|
16
|
+
for (let index = 0; index < rest.length; index++) {
|
|
17
|
+
const token = rest[index];
|
|
18
|
+
if (token === "--home-dir" && rest[index + 1]) {
|
|
19
|
+
homeDir = rest[++index];
|
|
19
20
|
continue;
|
|
20
21
|
}
|
|
21
|
-
if (
|
|
22
|
-
|
|
22
|
+
if (token.startsWith("--")) {
|
|
23
|
+
if (!allowed.has(token))
|
|
24
|
+
throw new Error(`Unknown roots flag: ${token}`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
positional.push(token);
|
|
28
|
+
}
|
|
29
|
+
const json = rest.includes("--json");
|
|
30
|
+
if (action === "add" && positional.length === 1) {
|
|
31
|
+
return { kind: "roots", action, directory: positional[0], homeDir, json, allowHomeRoot: rest.includes("--allow-home-root") };
|
|
32
|
+
}
|
|
33
|
+
if (action === "label" && positional.length === 2) {
|
|
34
|
+
return { kind: "roots", action, directory: positional[0], shelf: positional[1], homeDir, json };
|
|
23
35
|
}
|
|
24
|
-
|
|
36
|
+
if (action === "list" && positional.length === 0)
|
|
37
|
+
return { kind: "roots", action, homeDir, json };
|
|
38
|
+
throw new Error(ROOTS_USAGE);
|
|
25
39
|
}
|
|
26
40
|
export async function runRoots(command, io) {
|
|
41
|
+
if (command.action === "label")
|
|
42
|
+
return (await import("./roots-label.js")).runRootsLabel(command, io);
|
|
43
|
+
if (command.action === "list")
|
|
44
|
+
return (await import("./roots-label.js")).runRootsList(command, io);
|
|
27
45
|
const homeDir = command.homeDir ?? os.homedir();
|
|
28
46
|
const { roots, rejected } = normalizeRootsDetailed([command.directory], { homeDir, allowHomeRoot: command.allowHomeRoot });
|
|
29
47
|
if (!roots.length || rejected.length)
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* no per-file record), {@link RETRY_WINDOW_FALLBACK_MINUTES} is the bound
|
|
19
19
|
* instead — generous, but still a bound, never "since epoch".
|
|
20
20
|
*/
|
|
21
|
-
import {
|
|
21
|
+
import { settleCollectionScope } from "../collection-scope.js";
|
|
22
|
+
import { readSourceSwitches } from "../source-opt-out.js";
|
|
22
23
|
import { readLocalCollectorConfig, } from "../local-state.js";
|
|
23
24
|
import { CLAUDE_CURSOR_FILENAME, emptyRawEvidenceCursorState, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
24
25
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
@@ -46,14 +47,14 @@ export const RETRY_WINDOW_BUFFER_MINUTES = 24 * 60;
|
|
|
46
47
|
*/
|
|
47
48
|
export async function planSyncFromLocalState(options) {
|
|
48
49
|
const config = await readLocalCollectorConfig(options.paths).catch(() => null);
|
|
49
|
-
const switches =
|
|
50
|
+
const switches = await readSourceSwitches(config, options.paths, "session_sync");
|
|
50
51
|
const claudeEnabled = switches.claude;
|
|
51
52
|
const codexEnabled = switches.codex;
|
|
52
53
|
if (!codexEnabled) {
|
|
53
|
-
console.error("[session-sync] codex source withheld by operator opt-out", JSON.stringify({ reason: switches.codexReason }));
|
|
54
|
+
console.error("[session-sync] codex source withheld by operator opt-out", JSON.stringify({ reason: switches.codexReason, verdict: switches.verdict }));
|
|
54
55
|
}
|
|
55
56
|
if (!claudeEnabled) {
|
|
56
|
-
console.error("[session-sync] claude source withheld by operator opt-out", JSON.stringify({ reason: switches.claudeReason }));
|
|
57
|
+
console.error("[session-sync] claude source withheld by operator opt-out", JSON.stringify({ reason: switches.claudeReason, verdict: switches.verdict }));
|
|
57
58
|
}
|
|
58
59
|
const collectionRoots = normalizeCollectionRoots(options.approvedCollectionRoots ?? config?.default_repo_paths ?? []);
|
|
59
60
|
const [codexCursorBefore, claudeCursorBefore, uploadSpool, outsideRootCatchUpPending] = await readSyncRetryState(options.paths, claudeEnabled);
|
|
@@ -62,9 +63,12 @@ export async function planSyncFromLocalState(options) {
|
|
|
62
63
|
const legacyRetryPending = Boolean(uploadSpool?.pending_uploads.some((entry) => entry.raw_evidence_file_count > 0 && entry.retry_sources.length === 0));
|
|
63
64
|
const codexSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "codex");
|
|
64
65
|
const claudeSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "claude_code");
|
|
66
|
+
// BLI-23590: Tower's answer on file, plus the local request only if Tower cleared this person.
|
|
67
|
+
const collectionScope = await settleCollectionScope(options.paths.state_dir, config?.collection_scope);
|
|
65
68
|
return {
|
|
66
69
|
claudeEnabled,
|
|
67
70
|
codexEnabled,
|
|
71
|
+
collectionScope,
|
|
68
72
|
collectionRoots,
|
|
69
73
|
codexCursorBefore,
|
|
70
74
|
claudeCursorBefore,
|
|
@@ -16,6 +16,8 @@ import { isRedactionStubObjectKey } from "../cursors/raw-evidence-stub-entry.js"
|
|
|
16
16
|
import { clearSourceRetryFailure, recordSourceRetryFailure, } from "../spool/local-spool.js";
|
|
17
17
|
import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
|
|
18
18
|
import { widenForCatchUp } from "./session-sync-outside-roots.js";
|
|
19
|
+
import { COLLECTION_SCOPE_ROOTS_ONLY_REASON } from "@bli-cockpit/telemetry-core";
|
|
20
|
+
import { logScopeWithhold, scopeScanResult } from "../collection-scope.js";
|
|
19
21
|
import { openTranscriptReadMemo } from "../adapters/transcript-read-memo.js";
|
|
20
22
|
const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
|
|
21
23
|
/** The Claude scan's own default window when nothing widens it: the last 24 hours. */
|
|
@@ -29,7 +31,7 @@ const CLAUDE_DEFAULT_WINDOW_MINUTES = 24 * 60;
|
|
|
29
31
|
*/
|
|
30
32
|
export async function scanAndAttributeBothSources(options) {
|
|
31
33
|
const { plan } = options;
|
|
32
|
-
const
|
|
34
|
+
const codexScanned = await scanCodexSessionsForSync({
|
|
33
35
|
codexEnabled: plan.codexEnabled,
|
|
34
36
|
homeDir: options.homeDir,
|
|
35
37
|
worktrees: options.worktrees,
|
|
@@ -44,7 +46,7 @@ export async function scanAndAttributeBothSources(options) {
|
|
|
44
46
|
// days so the first sync captures retroactive history instead of only 24h.
|
|
45
47
|
const claudeCursorExists = await fileExists(path.join(options.paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
|
|
46
48
|
const firstRunBackfill = plan.claudeEnabled && !claudeCursorExists;
|
|
47
|
-
const
|
|
49
|
+
const claudeScanned = await scanClaudeSessionsForSync({
|
|
48
50
|
claudeEnabled: plan.claudeEnabled,
|
|
49
51
|
homeDir: options.homeDir,
|
|
50
52
|
worktrees: options.worktrees,
|
|
@@ -56,6 +58,26 @@ export async function scanAndAttributeBothSources(options) {
|
|
|
56
58
|
outsideRootCatchUpPending: plan.outsideRootCatchUpPending,
|
|
57
59
|
stateDir: options.paths.state_dir,
|
|
58
60
|
});
|
|
61
|
+
// BLI-23590: under `roots_only` a session outside every approved root leaves the result HERE, before the retry
|
|
62
|
+
// bookkeeping, the local work scan, the upload, the report or the git scan can see it.
|
|
63
|
+
let codexAttribution = codexScanned;
|
|
64
|
+
let claudeAttribution = claudeScanned;
|
|
65
|
+
let scopeWithheld;
|
|
66
|
+
if (plan.collectionScope?.scope === "roots_only") {
|
|
67
|
+
const codex = scopeScanResult(codexScanned, plan.collectionRoots);
|
|
68
|
+
const claude = scopeScanResult(claudeScanned, plan.collectionRoots);
|
|
69
|
+
codexAttribution = codex.scan;
|
|
70
|
+
claudeAttribution = claude.scan;
|
|
71
|
+
const withheld = codex.withheld + claude.withheld;
|
|
72
|
+
scopeWithheld = { sessions: withheld, reason: COLLECTION_SCOPE_ROOTS_ONLY_REASON };
|
|
73
|
+
logScopeWithhold("session-sync", {
|
|
74
|
+
source: "codex+claude_code",
|
|
75
|
+
kept: codexAttribution.results.length + claudeAttribution.results.length,
|
|
76
|
+
withheld,
|
|
77
|
+
withheld_without_cwd: codex.withheld_without_cwd + claude.withheld_without_cwd,
|
|
78
|
+
roots: plan.collectionRoots.length,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
59
81
|
if (plan.codexEnabled) {
|
|
60
82
|
await reconcileSourceScanRetry({
|
|
61
83
|
paths: options.paths,
|
|
@@ -75,7 +97,7 @@ export async function scanAndAttributeBothSources(options) {
|
|
|
75
97
|
});
|
|
76
98
|
}
|
|
77
99
|
await saveRootWorkScan(options.paths, [...codexAttribution.results, ...claudeAttribution.results], options.now);
|
|
78
|
-
return { codexAttribution, claudeAttribution, firstRunBackfill };
|
|
100
|
+
return { codexAttribution, claudeAttribution, firstRunBackfill, ...(scopeWithheld ? { scopeWithheld } : {}) };
|
|
79
101
|
}
|
|
80
102
|
/**
|
|
81
103
|
* Scan and attribute Codex sessions for this sync, widening the window to all
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which shelf each session and each worktree belongs to, decided right after the scan and BEFORE anything is read
|
|
3
|
+
* for upload (BLI-23478, three-shelves design 4.2).
|
|
4
|
+
*
|
|
5
|
+
* The scan has already counted every transcript in process (`session_facts`, metadata only). This step splits its
|
|
6
|
+
* results in two:
|
|
7
|
+
*
|
|
8
|
+
* - team sessions and team worktrees continue down the ordinary pass, unchanged;
|
|
9
|
+
* - drawer sessions leave the pass here. No pack is opened for them, nothing is staged, no sidecar, image or diff
|
|
10
|
+
* is read, no git is spawned for them, and no session row is reported. Only their counted facts travel on, to
|
|
11
|
+
* become a numbers-only bare record (`shelf-reports.ts`).
|
|
12
|
+
*
|
|
13
|
+
* A session's shelf comes from its ATTRIBUTION identity, never from where its transcript file lives: the worktree
|
|
14
|
+
* root it was attributed to, and the working folder it recorded. Both must say `team` for the session to be team;
|
|
15
|
+
* either saying `drawer`, the two disagreeing, an `ambiguous` attribution, or no identity at all puts it in the
|
|
16
|
+
* drawer (fail closed). A worktree whose own root resolves to the drawer is dropped from the targets, so its pack
|
|
17
|
+
* (and its diff) is never built.
|
|
18
|
+
*
|
|
19
|
+
* The split runs only on a laptop Tower says, live, is a founder's (`shelf-decision.ts`; never the saved heartbeat
|
|
20
|
+
* file). Anywhere else (no labels, or Tower says not a founder) it returns its input untouched without resolving a
|
|
21
|
+
* single path; when Tower cannot be asked the caller holds the whole run before this step.
|
|
22
|
+
*
|
|
23
|
+
* Logs: counts and labels only, through `safeLog`.
|
|
24
|
+
*/
|
|
25
|
+
import { safeLog } from "@bli-cockpit/telemetry-core";
|
|
26
|
+
// Re-exported for `cockpit backfill`, whose scan step sits at the readability ceiling.
|
|
27
|
+
export { decideShelfMode, OWNER_CHECK_UNAVAILABLE } from "../shelf-decision.js";
|
|
28
|
+
import { resolveShelfWith, } from "@bli-cockpit/memory-mcp/dist/shelf-routing.js";
|
|
29
|
+
import { isOutsideRootsWorkspace } from "./backfill-outside-roots.js";
|
|
30
|
+
const TAG = "shelf";
|
|
31
|
+
/** Why one session resolved where it did. Exported for the tests. */
|
|
32
|
+
export function sessionShelf(result, rules) {
|
|
33
|
+
if (!rules.active)
|
|
34
|
+
return { shelf: "team", reason: "routing_inactive" };
|
|
35
|
+
if (rules.failed)
|
|
36
|
+
return { shelf: "drawer", reason: "shelf_resolve_failed" };
|
|
37
|
+
if (result.state === "ambiguous")
|
|
38
|
+
return { shelf: "drawer", reason: "ambiguous_attribution" };
|
|
39
|
+
const byWorktree = result.worktree && !isOutsideRootsWorkspace(result.worktree)
|
|
40
|
+
? resolveShelfWith(rules, result.worktree.repo_root)
|
|
41
|
+
: null;
|
|
42
|
+
const byCwd = result.local_cwd ? resolveShelfWith(rules, result.local_cwd) : null;
|
|
43
|
+
if (!byWorktree && !byCwd)
|
|
44
|
+
return { shelf: "drawer", reason: "no_attribution_identity" };
|
|
45
|
+
if (byWorktree && byCwd && byWorktree.shelf !== byCwd.shelf)
|
|
46
|
+
return { shelf: "drawer", reason: "shelf_identity_mismatch" };
|
|
47
|
+
const decided = byWorktree ?? byCwd;
|
|
48
|
+
return decided.shelf === "team" ? { shelf: "team", reason: decided.reason } : { shelf: "drawer", reason: decided.reason };
|
|
49
|
+
}
|
|
50
|
+
/** Split a scan by shelf. Returns the input objects themselves when routing is inactive on this laptop. */
|
|
51
|
+
export function splitScanByShelf(options) {
|
|
52
|
+
const rules = options.decision.rules;
|
|
53
|
+
if (options.decision.mode !== "founder" || !rules.active) {
|
|
54
|
+
return { scan: options.scan, worktrees: options.worktrees, shelved: [], worktreesShelved: 0, rules };
|
|
55
|
+
}
|
|
56
|
+
const shelved = [];
|
|
57
|
+
const reasons = new Map();
|
|
58
|
+
const keep = (result, shelve) => {
|
|
59
|
+
const decision = sessionShelf(result, rules);
|
|
60
|
+
if (decision.shelf === "team")
|
|
61
|
+
return true;
|
|
62
|
+
reasons.set(decision.reason, (reasons.get(decision.reason) ?? 0) + 1);
|
|
63
|
+
shelved.push(shelve(decision.reason));
|
|
64
|
+
return false;
|
|
65
|
+
};
|
|
66
|
+
const codexResults = options.scan.codexAttribution.results.filter((result) => keep(result, (reason) => ({
|
|
67
|
+
key: `codex:${result.codex_session_id}`, source: "codex", reason, facts: result.session_facts, recencyMs: result.session_file_mtime_ms,
|
|
68
|
+
})));
|
|
69
|
+
const claudeResults = options.scan.claudeAttribution.results.filter((result) => keep(result, (reason) => ({
|
|
70
|
+
key: `claude_code:${result.claude_session_id}`, source: "claude_code", reason, facts: result.session_facts, recencyMs: result.session_recency_ms,
|
|
71
|
+
})));
|
|
72
|
+
const worktrees = options.worktrees.filter((worktree) => isOutsideRootsWorkspace(worktree) || resolveShelfWith(rules, worktree.repo_root).shelf === "team");
|
|
73
|
+
const worktreesShelved = options.worktrees.length - worktrees.length;
|
|
74
|
+
safeLog(TAG, shelved.length > 0 || worktreesShelved > 0 ? "sessions_shelved" : "nothing_shelved", {
|
|
75
|
+
sessions_team: codexResults.length + claudeResults.length,
|
|
76
|
+
sessions_shelved: shelved.length,
|
|
77
|
+
worktrees_team: worktrees.length,
|
|
78
|
+
worktrees_shelved: worktreesShelved,
|
|
79
|
+
ambiguous_attribution: reasons.get("ambiguous_attribution") ?? 0,
|
|
80
|
+
labelled_root: reasons.get("labelled_root") ?? 0,
|
|
81
|
+
outside_roots_default: reasons.get("outside_roots_default") ?? 0,
|
|
82
|
+
shelf_identity_mismatch: reasons.get("shelf_identity_mismatch") ?? 0,
|
|
83
|
+
no_attribution_identity: reasons.get("no_attribution_identity") ?? 0,
|
|
84
|
+
shelf_resolve_failed: reasons.get("shelf_resolve_failed") ?? 0,
|
|
85
|
+
});
|
|
86
|
+
return {
|
|
87
|
+
// The same objects with fewer results: each filter keeps the element type of the array it filtered.
|
|
88
|
+
scan: {
|
|
89
|
+
...options.scan,
|
|
90
|
+
codexAttribution: { ...options.scan.codexAttribution, results: codexResults },
|
|
91
|
+
claudeAttribution: { ...options.scan.claudeAttribution, results: claudeResults },
|
|
92
|
+
},
|
|
93
|
+
worktrees,
|
|
94
|
+
shelved,
|
|
95
|
+
worktreesShelved,
|
|
96
|
+
rules,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* `cockpit backfill`'s branch (design 4.2): either store may be absent (a source not selected), and only the team
|
|
101
|
+
* results go on to become candidates. Returns the inputs untouched on a laptop with no labels.
|
|
102
|
+
*/
|
|
103
|
+
export function shelveBackfillScan(codexAttribution, claudeAttribution, decision) {
|
|
104
|
+
const split = splitScanByShelf({
|
|
105
|
+
scan: { codexAttribution: codexAttribution ?? { results: [] }, claudeAttribution: claudeAttribution ?? { results: [] } },
|
|
106
|
+
worktrees: [],
|
|
107
|
+
decision,
|
|
108
|
+
});
|
|
109
|
+
if (split.shelved.length === 0)
|
|
110
|
+
return { codexAttribution, claudeAttribution, shelved: 0 };
|
|
111
|
+
return {
|
|
112
|
+
codexAttribution: codexAttribution ? { ...codexAttribution, results: split.scan.codexAttribution.results } : null,
|
|
113
|
+
claudeAttribution: claudeAttribution ? { ...claudeAttribution, results: split.scan.claudeAttribution.results } : null,
|
|
114
|
+
shelved: split.shelved.length,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Only the worktrees whose root resolves to the team shelf: `cockpit backfill` uses this both for its batches and
|
|
119
|
+
* for the sibling-worktree inventory a team pack carries (labels, branches, head SHAs), so a drawer worktree's
|
|
120
|
+
* metadata never rides a team repo's envelope. Returns the input untouched on a laptop with no labels.
|
|
121
|
+
*/
|
|
122
|
+
export function teamWorktreesOnly(worktrees, decision) {
|
|
123
|
+
return splitScanByShelf({
|
|
124
|
+
scan: { codexAttribution: { results: [] }, claudeAttribution: { results: [] } },
|
|
125
|
+
worktrees,
|
|
126
|
+
decision,
|
|
127
|
+
}).worktrees;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The pack-level assertion (design 4.2, "mixed-shelf pack"): immediately before a worktree's pack is built, its root
|
|
131
|
+
* must still resolve to `team`. The split above makes a mismatch impossible by construction; this belt makes a bug
|
|
132
|
+
* that broke the split fail closed. The collector's own outside-roots workspace is a transport folder whose
|
|
133
|
+
* sessions were each decided above, so it is checked by its sessions, not its folder.
|
|
134
|
+
*/
|
|
135
|
+
export function refuseNonTeamPacks(worktrees, rules) {
|
|
136
|
+
if (!rules?.active)
|
|
137
|
+
return { allowed: worktrees, refused: 0 };
|
|
138
|
+
const allowed = worktrees.filter((worktree) => !rules.failed && (isOutsideRootsWorkspace(worktree) || resolveShelfWith(rules, worktree.repo_root).shelf === "team"));
|
|
139
|
+
const refused = worktrees.length - allowed.length;
|
|
140
|
+
if (refused > 0)
|
|
141
|
+
safeLog(TAG, "mixed_shelf_pack", { packs_refused: refused, packs_allowed: allowed.length });
|
|
142
|
+
return { allowed, refused };
|
|
143
|
+
}
|
|
@@ -15,6 +15,7 @@ import { isOutsideRootsWorkspace } from "./backfill-outside-roots.js";
|
|
|
15
15
|
import { giveOutsideRootsTheirOwnAllowance, outsideRootsLast, } from "./session-sync-outside-roots.js";
|
|
16
16
|
import { runYieldingAtBoundaries } from "./sync-yield.js";
|
|
17
17
|
import { budgetUseOf } from "./session-sync-hold.js";
|
|
18
|
+
import { refuseNonTeamPacks } from "./session-sync-shelf.js";
|
|
18
19
|
const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
|
|
19
20
|
const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
20
21
|
/**
|
|
@@ -28,10 +29,12 @@ export async function syncEveryTargetWorktree(options) {
|
|
|
28
29
|
const { run, scan } = options;
|
|
29
30
|
// BLI-17916: the outside-roots workspace goes last, so in-root evidence is
|
|
30
31
|
// never held up behind it.
|
|
31
|
-
|
|
32
|
+
// BLI-23478: and no pack whose root resolves off the team shelf is ever opened (`mixed_shelf_pack`).
|
|
33
|
+
const packGate = refuseNonTeamPacks(liveSyncTargetWorktrees(run.worktrees, [
|
|
32
34
|
...scan.codexAttribution.results,
|
|
33
35
|
...scan.claudeAttribution.results,
|
|
34
|
-
]));
|
|
36
|
+
]), options.shelfRules);
|
|
37
|
+
const syncWorktrees = outsideRootsLast(packGate.allowed);
|
|
35
38
|
const discoveredWorktreeKeys = new Set(run.worktrees.map(liveSyncTargetKey));
|
|
36
39
|
// sessionId -> prior durable pointer for damped sessions (drives the
|
|
37
40
|
// growth_damped count + the skip_main decision).
|
|
@@ -59,6 +62,7 @@ export async function syncEveryTargetWorktree(options) {
|
|
|
59
62
|
growthDampedSessionCount: dampedClaudePointers.size,
|
|
60
63
|
yieldedWorktreesLeft: loop.left,
|
|
61
64
|
budgetUse: budgetUseOf(rawEvidenceBudget),
|
|
65
|
+
packsRefused: packGate.refused,
|
|
62
66
|
};
|
|
63
67
|
}
|
|
64
68
|
function syncTargetWithAllowance(options, worktree, syncWorktrees, rawEvidenceBudget, discoveredWorktreeKeys, dampedClaudePointers) {
|
|
@@ -39,7 +39,10 @@ import { readLocalWorkContextForRepo } from "../local-state.js";
|
|
|
39
39
|
* `runtimeFiles`, or the repo tests stay green while the packed CLI breaks.
|
|
40
40
|
*/
|
|
41
41
|
import os from "node:os";
|
|
42
|
-
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
42
|
+
import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile } from "../local-state.js";
|
|
43
|
+
import { reportShelvedSessions } from "../shelf-reports.js";
|
|
44
|
+
import { splitScanByShelf } from "./session-sync-shelf.js";
|
|
45
|
+
import { decideShelfMode, OWNER_CHECK_UNAVAILABLE } from "../shelf-decision.js";
|
|
43
46
|
import { buildAgentSessionReport, buildAgentSessionSummary, } from "./agent-session-report.js";
|
|
44
47
|
import { collectSessionCommits, withSessionCommits } from "../session-commits.js";
|
|
45
48
|
import { planSyncFromLocalState } from "./session-sync-plan.js";
|
|
@@ -78,22 +81,32 @@ function scanHadReadFailure(scan) {
|
|
|
78
81
|
* run is judged last, after everything that could have a reason has recorded
|
|
79
82
|
* one.
|
|
80
83
|
*/
|
|
81
|
-
export async function runAttributedWorktreeSync(
|
|
84
|
+
export async function runAttributedWorktreeSync(requested) {
|
|
82
85
|
const now = new Date();
|
|
83
|
-
const homeDir =
|
|
84
|
-
const paths = getCollectorRuntimePaths(
|
|
86
|
+
const homeDir = requested.homeDir ?? os.homedir();
|
|
87
|
+
const paths = getCollectorRuntimePaths(requested.homeDir);
|
|
85
88
|
const plan = await planSyncFromLocalState({
|
|
86
89
|
paths,
|
|
87
90
|
now,
|
|
88
|
-
approvedCollectionRoots:
|
|
91
|
+
approvedCollectionRoots: requested.collectionRoots,
|
|
89
92
|
});
|
|
90
|
-
const
|
|
93
|
+
const scannedAll = await scanAndAttributeBothSources({
|
|
91
94
|
paths,
|
|
92
95
|
homeDir,
|
|
93
96
|
now,
|
|
94
|
-
worktrees:
|
|
97
|
+
worktrees: requested.worktrees,
|
|
95
98
|
plan,
|
|
96
99
|
});
|
|
100
|
+
// BLI-23478 (three shelves): on a laptop with shelf settings, Tower is asked LIVE whether its person is a founder
|
|
101
|
+
// (never the saved copy). No answer holds the whole tick: nothing uploaded, nothing reported, nothing in the
|
|
102
|
+
// drawer, no cursor moved. A founder's drawer sessions and worktrees then leave the pass HERE, before any pack is
|
|
103
|
+
// built, any file staged or any git spawned for them. A teammate's laptop asks nothing and is untouched.
|
|
104
|
+
const shelfDecision = await decideShelfMode({ homeDir, dashboardUrl: requested.dashboardUrl ?? null, fetchImpl: requested.fetchImpl });
|
|
105
|
+
if (shelfDecision.mode === "hold")
|
|
106
|
+
return heldSyncRun(scannedAll);
|
|
107
|
+
const shelfSplit = splitScanByShelf({ scan: scannedAll, worktrees: requested.worktrees, decision: shelfDecision });
|
|
108
|
+
const scanned = shelfSplit.scan;
|
|
109
|
+
const options = { ...requested, worktrees: shelfSplit.worktrees };
|
|
97
110
|
// BLI-17916: roots are a label, never a gate. Outside-root sessions ride this
|
|
98
111
|
// tick's upload under the synthetic workspace, a few per tick, newest first.
|
|
99
112
|
const scan = await admitOutsideRootSessions({
|
|
@@ -107,6 +120,7 @@ export async function runAttributedWorktreeSync(options) {
|
|
|
107
120
|
now,
|
|
108
121
|
plan,
|
|
109
122
|
scan,
|
|
123
|
+
shelfRules: shelfDecision.mode === "founder" ? shelfDecision.rules : null,
|
|
110
124
|
});
|
|
111
125
|
const ticketByWorktree = await readWorktreeTickets(paths, options.worktrees);
|
|
112
126
|
// BLI-13028: which commits each session's working tree created while it ran.
|
|
@@ -125,7 +139,50 @@ export async function runAttributedWorktreeSync(options) {
|
|
|
125
139
|
claudePriorDurablePointers: worktreePass.claudePriorDurablePointers,
|
|
126
140
|
}), commitsBySession), scan.outsideRoots?.holds);
|
|
127
141
|
reportCountedSessionFacts(sessions);
|
|
128
|
-
|
|
142
|
+
const shelfReports = await sendShelfReports(options, paths, shelfSplit, now);
|
|
143
|
+
const run = await finishAttributedSync(options, { paths, now, plan, scan, sessions, worktreePass });
|
|
144
|
+
return {
|
|
145
|
+
...run,
|
|
146
|
+
sessions_shelved: shelfSplit.shelved.length,
|
|
147
|
+
packs_refused: worktreePass.packsRefused ?? 0,
|
|
148
|
+
shelf_reports: shelfReports,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* BLI-23478: the tick Tower could not be asked about. Everything it observed waits for the next tick: no upload,
|
|
153
|
+
* no session row, no bare record, no drawer write and no cursor movement, so nothing is lost and nothing is decided
|
|
154
|
+
* on a guess. Said on the receipt (`owner_check_unavailable`), never as a success with no reason.
|
|
155
|
+
*/
|
|
156
|
+
function heldSyncRun(scan) {
|
|
157
|
+
return {
|
|
158
|
+
ok: true, failure_reasons: [], failure_records: [], notice: OWNER_CHECK_UNAVAILABLE, hold: null, budgetHold: null,
|
|
159
|
+
yielded_worktrees_left: 0, sessions_observed: 0, sessions_outside_root: 0, outside_root_directories: [],
|
|
160
|
+
sessions_new_this_tick: 0, sessions_pending_upload: 0, outcomes: [],
|
|
161
|
+
codexAttribution: scan.codexAttribution, claudeAttribution: scan.claudeAttribution,
|
|
162
|
+
summary: buildAgentSessionSummary({
|
|
163
|
+
codexAttribution: scan.codexAttribution, claudeAttribution: scan.claudeAttribution, outcomes: [], codexStaleCount: 0,
|
|
164
|
+
claudeStaleCount: 0, firstRunBackfill: scan.firstRunBackfill, growthDamped: 0, report: { posted: false, reason: OWNER_CHECK_UNAVAILABLE },
|
|
165
|
+
}),
|
|
166
|
+
sessions_shelved: 0, packs_refused: 0, shelf_reports: null, shelf_hold: OWNER_CHECK_UNAVAILABLE,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** BLI-23478: the drawer sessions' bare records. Skipped entirely when nothing was shelved. */
|
|
170
|
+
async function sendShelfReports(options, paths, split, now) {
|
|
171
|
+
if (split.shelved.length === 0)
|
|
172
|
+
return null;
|
|
173
|
+
const [session, config] = await Promise.all([
|
|
174
|
+
readLocalCollectorSessionFile(paths).catch(() => null),
|
|
175
|
+
readLocalCollectorConfig(paths).catch(() => null),
|
|
176
|
+
]);
|
|
177
|
+
return reportShelvedSessions({
|
|
178
|
+
paths,
|
|
179
|
+
shelved: split.shelved,
|
|
180
|
+
dashboardUrl: options.dashboardUrl ?? config?.dashboard_url ?? null,
|
|
181
|
+
deviceToken: session?.device_token ?? null,
|
|
182
|
+
fetchImpl: options.fetchImpl,
|
|
183
|
+
allow: config?.shelf_metrics_allow ?? [],
|
|
184
|
+
now,
|
|
185
|
+
});
|
|
129
186
|
}
|
|
130
187
|
async function readWorktreeTickets(paths, worktrees) {
|
|
131
188
|
const ticketByWorktree = new Map();
|
|
@@ -197,6 +254,9 @@ async function finishAttributedSync(options, context) {
|
|
|
197
254
|
outside_root_directories: health.outside_root_directories,
|
|
198
255
|
sessions_new_this_tick: health.sessions_new_this_tick,
|
|
199
256
|
sessions_pending_upload: health.sessions_pending_upload,
|
|
257
|
+
...(plan.collectionScope?.scope === "roots_only"
|
|
258
|
+
? { collection_scope: { scope: "roots_only", withheld_sessions: scan.scopeWithheld?.sessions ?? 0, reason: scan.scopeWithheld?.reason ?? "collection_scope_roots_only" } }
|
|
259
|
+
: {}),
|
|
200
260
|
outcomes: worktreePass.outcomes,
|
|
201
261
|
codexAttribution: scan.codexAttribution,
|
|
202
262
|
claudeAttribution: scan.claudeAttribution,
|