@bli-cockpit/cli 0.2.194 → 0.2.195
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/commands/agent-setup-observed.js +131 -0
- package/dist/commands/agent-setup.js +90 -20
- package/dist/commands/local-args-tower-setup.js +1 -1
- package/dist/commands/local-args-tower-speed.js +24 -3
- package/dist/commands/local-help-commands-tower.js +7 -0
- package/dist/commands/local-help-commands.js +1 -1
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync.js +8 -2
- package/dist/commands/setup.js +6 -3
- package/dist/commands/speed-seen.js +119 -0
- package/dist/commands/speed.js +4 -0
- package/dist/session-commits.js +122 -0
- package/package.json +4 -4
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const TRANSCRIPT_FILES = 12;
|
|
4
|
+
export const TRANSCRIPT_WINDOW_MS = 30 * 86_400_000;
|
|
5
|
+
const CHUNK_BYTES = 200_000;
|
|
6
|
+
const MAX_STATTED = 3000;
|
|
7
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
|
|
8
|
+
/** Newer of two semver-looking strings by numeric core; a non-version never wins. */
|
|
9
|
+
export function newerVersion(a, b) {
|
|
10
|
+
if (!b || !SEMVER.test(b))
|
|
11
|
+
return a;
|
|
12
|
+
if (!a || !SEMVER.test(a))
|
|
13
|
+
return b;
|
|
14
|
+
const pa = a.split(/[.-]/).slice(0, 3).map(Number), pb = b.split(/[.-]/).slice(0, 3).map(Number);
|
|
15
|
+
for (let i = 0; i < 3; i++)
|
|
16
|
+
if (pa[i] !== pb[i])
|
|
17
|
+
return pb[i] > pa[i] ? b : a;
|
|
18
|
+
return a;
|
|
19
|
+
}
|
|
20
|
+
/** The most frequent key, ties to the one seen first. */
|
|
21
|
+
function top(counts) {
|
|
22
|
+
let best = null, n = 0;
|
|
23
|
+
for (const [key, count] of counts)
|
|
24
|
+
if (count > n) {
|
|
25
|
+
best = key;
|
|
26
|
+
n = count;
|
|
27
|
+
}
|
|
28
|
+
return best;
|
|
29
|
+
}
|
|
30
|
+
/** Fold sampled transcript text into one client reading. Pure, so tests feed it fixtures. */
|
|
31
|
+
export function observeTranscripts(tool, samples) {
|
|
32
|
+
const models = new Map(), efforts = new Map();
|
|
33
|
+
let version = null;
|
|
34
|
+
const bump = (map, value) => {
|
|
35
|
+
if (typeof value === "string" && value && !value.startsWith("<"))
|
|
36
|
+
map.set(value, (map.get(value) ?? 0) + 1);
|
|
37
|
+
};
|
|
38
|
+
for (const sample of samples)
|
|
39
|
+
for (const line of sample.split("\n")) {
|
|
40
|
+
if (!line.startsWith("{"))
|
|
41
|
+
continue;
|
|
42
|
+
let entry;
|
|
43
|
+
try {
|
|
44
|
+
entry = JSON.parse(line);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
} // a chunk edge can cut a line
|
|
49
|
+
const payload = entry.payload && typeof entry.payload === "object" ? entry.payload : {};
|
|
50
|
+
if (tool === "claude") {
|
|
51
|
+
if (typeof entry.version === "string")
|
|
52
|
+
version = newerVersion(version, entry.version);
|
|
53
|
+
const message = entry.message && typeof entry.message === "object" ? entry.message : {};
|
|
54
|
+
if (entry.type === "assistant")
|
|
55
|
+
bump(models, message.model);
|
|
56
|
+
}
|
|
57
|
+
else if (entry.type === "session_meta") {
|
|
58
|
+
if (typeof payload.cli_version === "string")
|
|
59
|
+
version = newerVersion(version, payload.cli_version);
|
|
60
|
+
}
|
|
61
|
+
else if (entry.type === "turn_context") {
|
|
62
|
+
bump(models, payload.model);
|
|
63
|
+
bump(efforts, payload.effort);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { version, model: top(models), effort: top(efforts), files: samples.length };
|
|
67
|
+
}
|
|
68
|
+
async function sampleFile(file) {
|
|
69
|
+
let handle;
|
|
70
|
+
try {
|
|
71
|
+
handle = await fs.open(file, "r");
|
|
72
|
+
const { size } = await handle.stat();
|
|
73
|
+
const read = async (position, length) => {
|
|
74
|
+
const buffer = Buffer.alloc(length);
|
|
75
|
+
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
76
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
77
|
+
};
|
|
78
|
+
if (size <= CHUNK_BYTES * 2)
|
|
79
|
+
return await read(0, size);
|
|
80
|
+
return `${await read(0, CHUNK_BYTES)}\n${await read(size - CHUNK_BYTES, CHUNK_BYTES)}`;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await handle?.close().catch(() => undefined);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const entries = (directory) => fs.readdir(directory).catch(() => []);
|
|
90
|
+
async function newestClaudeFiles(home, now) {
|
|
91
|
+
const root = path.join(home, ".claude", "projects");
|
|
92
|
+
const found = [];
|
|
93
|
+
let statted = 0;
|
|
94
|
+
for (const project of await entries(root)) {
|
|
95
|
+
for (const name of await entries(path.join(root, project))) {
|
|
96
|
+
if (!name.endsWith(".jsonl") || statted++ >= MAX_STATTED)
|
|
97
|
+
continue;
|
|
98
|
+
const file = path.join(root, project, name);
|
|
99
|
+
const stat = await fs.stat(file).catch(() => null);
|
|
100
|
+
if (stat && now - stat.mtimeMs <= TRANSCRIPT_WINDOW_MS)
|
|
101
|
+
found.push({ file, mtime: stat.mtimeMs });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return found.sort((a, b) => b.mtime - a.mtime).slice(0, TRANSCRIPT_FILES).map(entry => entry.file);
|
|
105
|
+
}
|
|
106
|
+
async function newestCodexFiles(home) {
|
|
107
|
+
const root = path.join(home, ".codex", "sessions");
|
|
108
|
+
const files = [];
|
|
109
|
+
const newestFirst = async (directory) => (await entries(directory)).sort().reverse();
|
|
110
|
+
for (const year of await newestFirst(root))
|
|
111
|
+
for (const month of await newestFirst(path.join(root, year)))
|
|
112
|
+
for (const day of await newestFirst(path.join(root, year, month))) {
|
|
113
|
+
for (const name of await newestFirst(path.join(root, year, month, day)))
|
|
114
|
+
if (name.endsWith(".jsonl") && files.length < TRANSCRIPT_FILES)
|
|
115
|
+
files.push(path.join(root, year, month, day, name));
|
|
116
|
+
if (files.length >= TRANSCRIPT_FILES)
|
|
117
|
+
return files;
|
|
118
|
+
}
|
|
119
|
+
return files;
|
|
120
|
+
}
|
|
121
|
+
/** Text samples of the newest transcripts of one tool in the last 30 days. Never throws. */
|
|
122
|
+
export async function readTranscriptSamples(tool, home, now = Date.now()) {
|
|
123
|
+
try {
|
|
124
|
+
const files = tool === "claude" ? await newestClaudeFiles(home, now) : await newestCodexFiles(home);
|
|
125
|
+
const samples = await Promise.all(files.map(sampleFile));
|
|
126
|
+
return samples.filter((sample) => sample !== null);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -5,22 +5,45 @@ import { execFile } from "node:child_process";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { AgentSetupSchema, SetupNameSchema } from "@bli-cockpit/telemetry-core";
|
|
7
7
|
import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
|
|
8
|
+
import { newerVersion, observeTranscripts, readTranscriptSamples } from "./agent-setup-observed.js";
|
|
9
|
+
/** `--version` of a cold Claude start under launchd routinely took over 750 ms (BLI-13055). */
|
|
10
|
+
export const VERSION_CHECK_TIMEOUT_MS = 5000;
|
|
8
11
|
const exec = promisify(execFile);
|
|
9
12
|
const files = {
|
|
10
13
|
read: file => fs.readFile(file, "utf8"), list: directory => fs.readdir(directory),
|
|
11
14
|
version: async (tool, platform) => {
|
|
12
15
|
try {
|
|
13
|
-
const { stdout } = await exec(platform === "win32" ? `${tool}.exe` : tool, ["--version"], { timeout:
|
|
16
|
+
const { stdout } = await exec(platform === "win32" ? `${tool}.exe` : tool, ["--version"], { timeout: VERSION_CHECK_TIMEOUT_MS, maxBuffer: 4096, windowsHide: true });
|
|
14
17
|
return /(?:^|\s)(\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?)(?:\s|$)/i.exec(stdout)?.[1] ?? null;
|
|
15
18
|
}
|
|
16
19
|
catch {
|
|
17
20
|
return null;
|
|
18
21
|
}
|
|
19
22
|
},
|
|
23
|
+
transcripts: (tool, home) => readTranscriptSamples(tool, home),
|
|
20
24
|
};
|
|
21
25
|
const safeName = (value) => SetupNameSchema.safeParse(value).success ? value : null;
|
|
22
26
|
const names = (values) => [...new Set(values.flatMap(value => safeName(value) ? [value] : []))].sort().slice(0, 500);
|
|
23
27
|
const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
28
|
+
/**
|
|
29
|
+
* Claude Code's default effort per model, from its own docs
|
|
30
|
+
* (https://code.claude.com/docs/en/model-config#adjust-effort-level, read
|
|
31
|
+
* 2026-10-08): `high` on every model that supports effort, except Opus 5.5,
|
|
32
|
+
* Sonnet 5.5 and Haiku 5.5 (`medium`) and Opus 4.7 (`xhigh`). A model this
|
|
33
|
+
* table does not know gets null, shown as "default" without a value.
|
|
34
|
+
*/
|
|
35
|
+
export function claudeDefaultEffort(model) {
|
|
36
|
+
if (!model)
|
|
37
|
+
return null;
|
|
38
|
+
if (/^claude-(opus|sonnet|haiku)-5-5/i.test(model))
|
|
39
|
+
return "medium";
|
|
40
|
+
if (/^claude-opus-4-7/i.test(model))
|
|
41
|
+
return "xhigh";
|
|
42
|
+
if (/^claude-(opus|sonnet)-(?:4-[68]|5)(?!-\d)/i.test(model))
|
|
43
|
+
return "high";
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const claudeFamily = (model) => /opus|sonnet|haiku/i.exec(model ?? "")?.[0].toLowerCase() ?? null;
|
|
24
47
|
/** Bounded, explicit locations only. Never recurse through a home or read env files. */
|
|
25
48
|
export async function collectAgentSetup(home, platform = process.platform, io = files, now = new Date()) {
|
|
26
49
|
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
@@ -53,25 +76,58 @@ export async function collectAgentSetup(home, platform = process.platform, io =
|
|
|
53
76
|
return null;
|
|
54
77
|
}
|
|
55
78
|
};
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
paths.join(paths.dirname(process.execPath), "..", "lib", "node_modules",
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
79
|
+
const packageName = (tool) => tool === "claude" ? "@anthropic-ai/claude-code" : "@openai/codex";
|
|
80
|
+
const versionFromPackage = async (tool) => {
|
|
81
|
+
const parts = packageName(tool).split("/");
|
|
82
|
+
// Where npm puts a global package: Windows keeps it under AppData, macOS
|
|
83
|
+
// under the prefix of the node that installed it (Homebrew, /usr/local,
|
|
84
|
+
// a user prefix) or Claude Code's own ~/.claude/local install.
|
|
85
|
+
const roots = platform === "win32"
|
|
86
|
+
? [join("AppData", "Roaming", "npm", "node_modules")]
|
|
87
|
+
: [paths.join(paths.dirname(process.execPath), "..", "lib", "node_modules"), "/opt/homebrew/lib/node_modules", "/usr/local/lib/node_modules", join(".npm-global", "lib", "node_modules")];
|
|
88
|
+
if (tool === "claude")
|
|
89
|
+
roots.push(join(".claude", "local", "node_modules"));
|
|
90
|
+
for (const root of roots) {
|
|
91
|
+
const text = await read(paths.join(root, ...parts, "package.json"));
|
|
92
|
+
try {
|
|
93
|
+
const version = safeName(object(JSON.parse(text ?? "null")).version);
|
|
94
|
+
if (version)
|
|
95
|
+
return version;
|
|
96
|
+
}
|
|
97
|
+
catch { /* unreadable metadata: try the next root */ }
|
|
68
98
|
}
|
|
69
|
-
|
|
70
|
-
|
|
99
|
+
if (tool === "claude") {
|
|
100
|
+
// Claude Code's native installer keeps one directory per version.
|
|
101
|
+
const native = platform === "win32" ? join("AppData", "Local", "claude", "versions") : join(".local", "share", "claude", "versions");
|
|
102
|
+
try {
|
|
103
|
+
return (await io.list(native)).reduce((newest, name) => newerVersion(newest, name.replace(/\.exe$/i, "")), null);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
71
108
|
}
|
|
109
|
+
return null;
|
|
110
|
+
};
|
|
111
|
+
const resolveVersion = async (tool, observing) => {
|
|
112
|
+
const viaCli = await io.version?.(tool, platform).catch(() => null);
|
|
113
|
+
if (safeName(viaCli))
|
|
114
|
+
return { value: viaCli, source: "cli" };
|
|
115
|
+
// npm shims on native Windows cannot be execFile'd without a shell. Read
|
|
116
|
+
// their installed package metadata instead, never the update-available cache.
|
|
117
|
+
const viaPackage = await versionFromPackage(tool);
|
|
118
|
+
if (viaPackage)
|
|
119
|
+
return { value: viaPackage, source: "package" };
|
|
120
|
+
const viaTranscript = safeName((await observing).version);
|
|
121
|
+
return viaTranscript ? { value: viaTranscript, source: "transcript" } : { value: null, source: "none" };
|
|
122
|
+
};
|
|
123
|
+
// Everything slow starts together: both `--version` checks (up to 5 s each) and
|
|
124
|
+
// both transcript samples run side by side, so the whole step costs one wait.
|
|
125
|
+
const observe = async (tool) => {
|
|
126
|
+
const samples = await io.transcripts?.(tool, home).catch(() => []) ?? [];
|
|
127
|
+
return observeTranscripts(tool, samples);
|
|
72
128
|
};
|
|
73
|
-
const
|
|
74
|
-
const codexVersion = await
|
|
129
|
+
const claudeObserving = observe("claude"), codexObserving = observe("codex");
|
|
130
|
+
const [claudeVersion, codexVersion, claudeObserved, codexObserved] = await Promise.all([resolveVersion("claude", claudeObserving), resolveVersion("codex", codexObserving), claudeObserving, codexObserving]);
|
|
75
131
|
const codexHooks = await json(".codex", "hooks.json");
|
|
76
132
|
const claudePlugins = await json(".claude", "plugins", "installed_plugins.json");
|
|
77
133
|
const pluginSkills = [], pluginAgents = [];
|
|
@@ -134,19 +190,33 @@ export async function collectAgentSetup(home, platform = process.platform, io =
|
|
|
134
190
|
return { name, exists: error.code === "ENOENT" ? false : null, sha256: null, bytes: null };
|
|
135
191
|
}
|
|
136
192
|
}));
|
|
193
|
+
// Model: what the person set, else what their sessions actually ran most.
|
|
194
|
+
const claudeSetModel = safeName(claudeSettings?.model), codexSetModel = scalar("model");
|
|
195
|
+
const claudeModel = claudeSetModel ?? claudeObserved.model;
|
|
196
|
+
// The default effort belongs to the model that really runs. A set alias such
|
|
197
|
+
// as "opus" has no version, so the observed full id of that family refines it.
|
|
198
|
+
const claudeDefaultFor = claudeSetModel && claudeFamily(claudeSetModel) !== claudeFamily(claudeObserved.model) ? claudeSetModel : claudeObserved.model ?? claudeSetModel;
|
|
199
|
+
const claudeSetEffort = safeName(claudeSettings?.effortLevel), codexSetEffort = scalar("model_reasoning_effort");
|
|
200
|
+
const claudeDefault = claudeSetEffort ? null : claudeDefaultEffort(claudeDefaultFor);
|
|
137
201
|
const result = AgentSetupSchema.parse({
|
|
138
202
|
checked_at: now.toISOString(), os: platform === "darwin" || platform === "win32" ? platform : "other", cli_version: LOCAL_COLLECTOR_VERSION,
|
|
139
|
-
claude: { version: claudeVersion,
|
|
203
|
+
claude: { version: claudeVersion.value, version_source: claudeVersion.source,
|
|
204
|
+
model: claudeModel, model_basis: claudeSetModel ? "set" : claudeModel ? "used" : "none",
|
|
205
|
+
effort: claudeSetEffort ?? claudeDefault, effort_basis: claudeSetEffort ? "set" : "default",
|
|
140
206
|
skills: claudeSkills === null || pluginInventoryUnknown ? null : names([...claudeSkills, ...pluginSkills]), agents: claudeAgents === null || pluginInventoryUnknown ? null : names([...claudeAgents, ...pluginAgents]),
|
|
141
207
|
plugins: claudePlugins === null ? null : names(Object.keys(object(claudePlugins.plugins))),
|
|
142
208
|
hooks: claudeSettings === null ? null : names(Object.keys(object(claudeSettings.hooks))), mcp: claudeMcp },
|
|
143
|
-
codex: { version: codexVersion,
|
|
209
|
+
codex: { version: codexVersion.value, version_source: codexVersion.source,
|
|
210
|
+
model: codexSetModel ?? codexObserved.model, model_basis: codexSetModel ? "set" : codexObserved.model ? "used" : "none",
|
|
211
|
+
effort: codexSetEffort ?? codexObserved.effort, effort_basis: codexSetEffort ? "set" : codexObserved.effort ? "observed" : "default",
|
|
144
212
|
skills: codexSkills === null || sharedSkills === null || systemSkills === null ? null : names([...(codexSkills ?? []), ...(sharedSkills ?? []), ...(systemSkills ?? [])]),
|
|
145
213
|
agents: toml === null ? null : names([...toml.matchAll(/^\s*\[agents\.([a-z0-9_-]+)\]\s*$/gim)].map(match => match[1])),
|
|
146
214
|
plugins: toml === null ? null : names([...toml.matchAll(/^\s*\[plugins\."([a-z0-9_@.+-]+)"\]\s*$/gim)].map(match => match[1])),
|
|
147
215
|
hooks: codexHooks === null && !/^notify\s*=/m.test(rootToml) ? null : names([...(codexHooks === null ? [] : Object.keys(object(codexHooks.hooks))), ...(/^notify\s*=/m.test(rootToml) ? ["notify"] : [])]), mcp: codexMcp }, instructions,
|
|
148
216
|
});
|
|
149
|
-
const
|
|
217
|
+
const evidence = (tool) => ({ version_source: result[tool].version_source, model_basis: result[tool].model_basis, effort_basis: result[tool].effort_basis });
|
|
218
|
+
console.error("[setup] versions read", JSON.stringify({ reason: "version_resolved", claude: evidence("claude"), codex: evidence("codex"), transcript_files: { claude: claudeObserved.files, codex: codexObserved.files } }));
|
|
219
|
+
const unknownFields = [result.claude, result.codex].reduce((count, tool) => count + ["skills", "plugins", "agents", "hooks", "mcp"].filter(key => tool[key] === null).length, 0);
|
|
150
220
|
console.error("[setup] inventory read", JSON.stringify({ reason: unknownFields ? "inventory_partial" : "inventory_observed", unknown_fields: unknownFields, os: result.os, mcp_count: (result.claude.mcp?.length ?? 0) + (result.codex.mcp?.length ?? 0) }));
|
|
151
221
|
return result;
|
|
152
222
|
}
|
|
@@ -5,7 +5,7 @@ export function parseSetupArgs(args) {
|
|
|
5
5
|
throw new Error("setup takes --person or --compare, not a subcommand.");
|
|
6
6
|
const compare = optionalNonEmpty(values.flags.get("--compare"));
|
|
7
7
|
if (compare && (compare.split(",").length !== 2 || compare.split(",").some(value => !value.trim())))
|
|
8
|
-
throw new Error("--compare takes two emails separated by a comma.");
|
|
8
|
+
throw new Error("--compare takes two people (first names or emails) separated by a comma.");
|
|
9
9
|
const since = optionalNonEmpty(values.flags.get("--since"));
|
|
10
10
|
if (since && since !== "7d" && since !== "30d" && (!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z)?$/.test(since) || !Number.isFinite(Date.parse(since)) || new Date(since).toISOString().slice(0, 10) !== since.slice(0, 10) || Date.parse(since) >= Date.now()))
|
|
11
11
|
throw new Error("--since takes 7d, 30d, or an ISO date before now.");
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
2
|
+
import { parseSeenWindow } from "./speed-seen.js";
|
|
2
3
|
import { SPEED_PAGE_NAMES } from "./speed-pages.js";
|
|
3
4
|
/** The most runs one page gets; a speed check is a handful of reads, not a load test. */
|
|
4
5
|
export const SPEED_MAX_RUNS = 50;
|
|
5
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* `cockpit speed [--page <name>]... [--runs <n>] [--json]` (BLI-13033), and
|
|
8
|
+
* `cockpit speed --seen [--since <n>m|h|d] [--everyone] [--page <name>]... [--json]`
|
|
9
|
+
* for real-visit timings (BLI-13054).
|
|
10
|
+
*/
|
|
6
11
|
export function parseSpeedArgs(args) {
|
|
7
12
|
const values = parseNamedArgs(args, {
|
|
8
|
-
allowedFlags: ["--page", "--runs", "--json", "--home", "--dashboard-url"],
|
|
9
|
-
valueFlags: ["--page", "--runs", "--home", "--dashboard-url"],
|
|
13
|
+
allowedFlags: ["--page", "--runs", "--json", "--home", "--dashboard-url", "--seen", "--since", "--everyone"],
|
|
14
|
+
valueFlags: ["--page", "--runs", "--home", "--dashboard-url", "--since"],
|
|
10
15
|
});
|
|
11
16
|
if (values.positionals.length) {
|
|
12
17
|
throw new Error("speed takes --page <name>, not a subcommand. Pages: " + SPEED_PAGE_NAMES.join(", ") + ".");
|
|
@@ -20,10 +25,26 @@ export function parseSpeedArgs(args) {
|
|
|
20
25
|
if (runs !== undefined && runs > SPEED_MAX_RUNS) {
|
|
21
26
|
throw new Error(`--runs takes at most ${SPEED_MAX_RUNS}.`);
|
|
22
27
|
}
|
|
28
|
+
const seen = values.booleans.has("--seen");
|
|
29
|
+
const sinceText = optionalNonEmpty(values.flags.get("--since"));
|
|
30
|
+
const everyone = values.booleans.has("--everyone");
|
|
31
|
+
if (!seen && (sinceText !== undefined || everyone)) {
|
|
32
|
+
throw new Error("--since and --everyone belong to `cockpit speed --seen`.");
|
|
33
|
+
}
|
|
34
|
+
if (seen && runs !== undefined)
|
|
35
|
+
throw new Error("--runs times the data doors; `--seen` reads real visits and takes no --runs.");
|
|
36
|
+
let since;
|
|
37
|
+
if (sinceText !== undefined) {
|
|
38
|
+
const window = parseSeenWindow(sinceText);
|
|
39
|
+
if (window === null)
|
|
40
|
+
throw new Error("--since takes <n>m, <n>h or <n>d, at most 30d (for example 24h).");
|
|
41
|
+
since = window;
|
|
42
|
+
}
|
|
23
43
|
return {
|
|
24
44
|
kind: "speed",
|
|
25
45
|
pages: [...new Set(pages)],
|
|
26
46
|
runs,
|
|
47
|
+
...(seen ? { seen: true, ...(since ? { since } : {}), ...(everyone ? { everyone: true } : {}) } : {}),
|
|
27
48
|
json: values.booleans.has("--json"),
|
|
28
49
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
29
50
|
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
@@ -179,6 +179,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
179
179
|
"speed",
|
|
180
180
|
[
|
|
181
181
|
"Usage: cockpit speed [--page <name>]... [--runs <n>] [--dashboard-url <url>] [--json]",
|
|
182
|
+
" cockpit speed --seen [--since <n>m|h|d] [--everyone] [--page <name>]... [--dashboard-url <url>] [--json]",
|
|
182
183
|
"",
|
|
183
184
|
"Times every Tower rail page's data door from this terminal, with this machine's device token.",
|
|
184
185
|
"Per page: the first run is reported as cold, the rest as warm; p50 and p95 are over the warm runs",
|
|
@@ -191,6 +192,12 @@ export const TOWER_COMMAND_HELP = [
|
|
|
191
192
|
"It times the DATA DOOR the page opens on, not the page's HTML or paint, and it never uses the local read cache.",
|
|
192
193
|
"Exits 1 when any page failed; a page that is not permitted does not fail the run.",
|
|
193
194
|
"The same measurement is the speed_read MCP tool.",
|
|
195
|
+
"",
|
|
196
|
+
"--seen reads what REAL visits saw instead: per page the visit count, p50 and p95 first-useful milliseconds (the moment",
|
|
197
|
+
"real content showed, from the last-known screen or the fresh one), the share painted from the snapshot, and median HTML",
|
|
198
|
+
"and script transfer. The browser sends one metadata-only beacon per page view; nothing is measured from this machine.",
|
|
199
|
+
"--since <n>m|h|d is the window (default 24h, at most 30d). --everyone reads every person's visits (super_admin only).",
|
|
200
|
+
"A member sees their own visits. --page limits the table to those pages. The same read is the speed_seen MCP tool.",
|
|
194
201
|
],
|
|
195
202
|
],
|
|
196
203
|
[
|
|
@@ -27,7 +27,7 @@ export function localSubcommandHelp(command) {
|
|
|
27
27
|
// BLI-3709: the Tower nouns live next door, split along the same seam the
|
|
28
28
|
// argument parsers already use — see `local-help-commands-tower.ts`.
|
|
29
29
|
...TOWER_COMMAND_HELP,
|
|
30
|
-
["setup", ["Usage: cockpit setup [--since <7d|30d|date>] [--details] [--person <email>] [--compare <
|
|
30
|
+
["setup", ["Usage: cockpit setup [--since <7d|30d|date>] [--details] [--person <name|email>] [--compare <name,name>] [--json]", "Read agent setups beside windowed outcomes (default 30 days), or compare two people by first name or email."]],
|
|
31
31
|
[
|
|
32
32
|
"onboard",
|
|
33
33
|
[
|
|
@@ -144,7 +144,7 @@ export function localCommandHelp(command) {
|
|
|
144
144
|
" cockpit rename [list|resolve <id> --scope <page|everywhere|undo>] [--dashboard-url <url>] [--json]",
|
|
145
145
|
" cockpit logs <collector|dashboard|railway|railway:<service>|-> [--since 15m|1h|2d|<iso>] [--raw <template-id> [--limit <n>]] [--all-levels] [--table <file>] [--json]",
|
|
146
146
|
" cockpit brain status [--project <id>] [--show open|running|needs_human|fixed|not_actionable|cleared] [--dashboard-url <url>] [--json]",
|
|
147
|
-
" cockpit setup [--since <7d|30d|date>] [--details] [--person <email>] [--compare <
|
|
147
|
+
" cockpit setup [--since <7d|30d|date>] [--details] [--person <name|email>] [--compare <name,name>] [--json]",
|
|
148
148
|
" cockpit usage sessions [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--since <window>] [--json]",
|
|
149
149
|
" cockpit usage sessions --session <id> [--from-window <n>] [--json]",
|
|
150
150
|
" --by-topic groups work types; --by-subject groups open subjects from session summaries.",
|
|
@@ -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.195");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -41,6 +41,7 @@ import { readLocalWorkContextForRepo } from "../local-state.js";
|
|
|
41
41
|
import os from "node:os";
|
|
42
42
|
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
43
43
|
import { buildAgentSessionReport, buildAgentSessionSummary, } from "./agent-session-report.js";
|
|
44
|
+
import { collectSessionCommits, withSessionCommits } from "../session-commits.js";
|
|
44
45
|
import { planSyncFromLocalState } from "./session-sync-plan.js";
|
|
45
46
|
import { scanAndAttributeBothSources } from "./session-sync-scan.js";
|
|
46
47
|
import { syncEveryTargetWorktree } from "./session-sync-upload.js";
|
|
@@ -100,14 +101,19 @@ export async function runAttributedWorktreeSync(options) {
|
|
|
100
101
|
ticketByWorktree.set(worktree.worktree_fingerprint, ticket);
|
|
101
102
|
}
|
|
102
103
|
console.error("[session-sync] bindings read", JSON.stringify({ bound_worktrees: ticketByWorktree.size, reason: "active_work_context" }));
|
|
103
|
-
|
|
104
|
+
// BLI-13028: which commits each session's working tree created while it ran.
|
|
105
|
+
const commitsBySession = await collectSessionCommits([
|
|
106
|
+
...scan.codexAttribution.results.map((result) => ({ key: `codex:${result.codex_session_id}`, result })),
|
|
107
|
+
...scan.claudeAttribution.results.map((result) => ({ key: `claude_code:${result.claude_session_id}`, result })),
|
|
108
|
+
]);
|
|
109
|
+
const sessions = withSessionCommits(buildAgentSessionReport({
|
|
104
110
|
ticketByWorktree,
|
|
105
111
|
codexResults: scan.codexAttribution.results,
|
|
106
112
|
claudeResults: scan.claudeAttribution.results,
|
|
107
113
|
outcomes: worktreePass.outcomes,
|
|
108
114
|
now,
|
|
109
115
|
claudePriorDurablePointers: worktreePass.claudePriorDurablePointers,
|
|
110
|
-
});
|
|
116
|
+
}), commitsBySession);
|
|
111
117
|
// BLI-4341: every session should arrive counted. Say how many did, and say
|
|
112
118
|
// it on the success path too: a silent drop from "all" to "some" is exactly
|
|
113
119
|
// the kind of decay nobody notices.
|
package/dist/commands/setup.js
CHANGED
|
@@ -24,12 +24,15 @@ export async function runSetup(command, io) {
|
|
|
24
24
|
writeLine(io.stdout, " No setup received.");
|
|
25
25
|
for (const machine of person.machines) {
|
|
26
26
|
if (!machine.setup) {
|
|
27
|
-
writeLine(io.stdout, "
|
|
27
|
+
writeLine(io.stdout, " No readable setup from this machine yet.");
|
|
28
28
|
continue;
|
|
29
29
|
}
|
|
30
30
|
writeLine(io.stdout, ` ${machine.setup.os} · TOWER CLI ${machine.setup.cli_version}${machine.stale ? " · stale setup" : ""}`);
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// The server words every model, effort and version (BLI-13055); an older server sends only the raw receipt.
|
|
32
|
+
for (const tool of ["claude", "codex"]) {
|
|
33
|
+
const words = machine.setup_labels?.[tool];
|
|
34
|
+
writeLine(io.stdout, words ? ` ${tool}: model ${words.model}, effort ${words.effort}, version ${words.version}` : ` ${tool}: model ${machine.setup[tool].model ?? "not reported by this machine yet"}, effort ${machine.setup[tool].effort ?? "default"}`);
|
|
35
|
+
}
|
|
33
36
|
}
|
|
34
37
|
}
|
|
35
38
|
for (const pair of body.comparison ?? []) {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit speed --seen [--since 24h] [--everyone]` (BLI-13054) — what real
|
|
3
|
+
* visits experienced on each Tower page, from the page-view beacons the browser
|
|
4
|
+
* sends (`apps/dashboard/src/lib/speed/first-useful.ts`).
|
|
5
|
+
*
|
|
6
|
+
* `cockpit speed` times a page's DATA DOOR from this terminal. It cannot see a
|
|
7
|
+
* page painted instantly from its last-known screen, or one that no longer
|
|
8
|
+
* waits to fetch after it loads. A visit's number is the moment real content
|
|
9
|
+
* first appeared, on that person's own network. Per page: visits, p50 and p95
|
|
10
|
+
* first-useful milliseconds, the share painted from the snapshot, and the
|
|
11
|
+
* median HTML and script transfer where the browser knew them.
|
|
12
|
+
*
|
|
13
|
+
* The door (`GET /api/speed/seen`) does the counting in the database, on the
|
|
14
|
+
* caller's own row policies: a person sees their own visits; `--everyone` needs
|
|
15
|
+
* a super_admin and is refused for anyone else. stdout is `--json`'s alone;
|
|
16
|
+
* every log line goes to stderr, metadata only (counts, a window, a reason).
|
|
17
|
+
*/
|
|
18
|
+
import { writeLine } from "./cli-io.js";
|
|
19
|
+
import { loadPairedSession, towerFailureDetail, towerJsonRequest } from "../tower-client.js";
|
|
20
|
+
export const SEEN_DEFAULT_SINCE = "24h";
|
|
21
|
+
const SEEN_MAX_DAYS = 30;
|
|
22
|
+
const UNIT_MS = { m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
23
|
+
const REQUEST_DEADLINE_MS = 60_000;
|
|
24
|
+
/** `90m`, `24h`, `7d`, at most 30 days (rows are pruned after that); null otherwise. */
|
|
25
|
+
export function parseSeenWindow(text) {
|
|
26
|
+
const match = /^(\d{1,4})([mhd])$/.exec(text.trim().toLowerCase());
|
|
27
|
+
if (!match)
|
|
28
|
+
return null;
|
|
29
|
+
const ms = Number(match[1]) * UNIT_MS[match[2]];
|
|
30
|
+
return ms > 0 && ms <= SEEN_MAX_DAYS * UNIT_MS.d ? `${match[1]}${match[2]}` : null;
|
|
31
|
+
}
|
|
32
|
+
const ms = (value) => (value === null ? "-" : `${Math.round(value)} ms`);
|
|
33
|
+
const percent = (share) => `${Math.round(share * 100)}%`;
|
|
34
|
+
function size(bytes) {
|
|
35
|
+
if (bytes === null)
|
|
36
|
+
return "-";
|
|
37
|
+
if (bytes < 1024)
|
|
38
|
+
return `${bytes} B`;
|
|
39
|
+
return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
40
|
+
}
|
|
41
|
+
/** Slowest median first, like `cockpit speed`; ties by page name. */
|
|
42
|
+
export function rankSeen(pages) {
|
|
43
|
+
return [...pages].sort((a, b) => b.p50_ms - a.p50_ms || a.page.localeCompare(b.page));
|
|
44
|
+
}
|
|
45
|
+
/** The table a person reads. Already ranked: this lays out, it does not sort. */
|
|
46
|
+
export function renderSeenTable(ranked, since, everyone) {
|
|
47
|
+
const head = ["#", "PAGE", "VISITS", "P50", "P95", "SNAPSHOT", "SNAP P50", "NET P50", "HTML", "JS"];
|
|
48
|
+
const rows = ranked.map((page, index) => [
|
|
49
|
+
String(index + 1),
|
|
50
|
+
page.page,
|
|
51
|
+
String(page.visits),
|
|
52
|
+
ms(page.p50_ms),
|
|
53
|
+
ms(page.p95_ms),
|
|
54
|
+
percent(page.snapshot_share),
|
|
55
|
+
ms(page.snapshot_p50_ms),
|
|
56
|
+
ms(page.network_p50_ms),
|
|
57
|
+
size(page.html_bytes_p50),
|
|
58
|
+
size(page.js_bytes_p50),
|
|
59
|
+
]);
|
|
60
|
+
const widths = head.map((title, column) => Math.max(title.length, ...rows.map((row) => (row[column] ?? "").length)));
|
|
61
|
+
const line = (cells) => cells.map((cell, column) => (column === 1 ? cell.padEnd(widths[column] ?? 0) : cell.padStart(widths[column] ?? 0))).join(" ").trimEnd();
|
|
62
|
+
const who = everyone ? "everyone's visits" : "your visits";
|
|
63
|
+
if (ranked.length === 0) {
|
|
64
|
+
return [`No page views recorded in the last ${since} (${who}). Open Tower in a browser, then ask again.`];
|
|
65
|
+
}
|
|
66
|
+
return [
|
|
67
|
+
line(head),
|
|
68
|
+
...rows.map(line),
|
|
69
|
+
"",
|
|
70
|
+
`${who}, last ${since}. Times are from the moment real content first showed (the last-known screen or the fresh one) `
|
|
71
|
+
+ "to the press on the link (soft) or the start of the load (hard). P50/P95 are nearest-rank. SNAPSHOT is the share "
|
|
72
|
+
+ "painted from the last-known screen. HTML and JS are median transfer sizes where the browser reported them (a cached copy reports none).",
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
export async function runSpeedSeen(command, io) {
|
|
76
|
+
const log = (line) => writeLine(io.stderr, line);
|
|
77
|
+
const session = await loadPairedSession("speed", command.homeDir);
|
|
78
|
+
const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
|
|
79
|
+
const since = command.since ?? SEEN_DEFAULT_SINCE;
|
|
80
|
+
const everyone = command.everyone === true;
|
|
81
|
+
const query = new URLSearchParams({ since });
|
|
82
|
+
if (everyone)
|
|
83
|
+
query.set("everyone", "1");
|
|
84
|
+
const answer = await towerJsonRequest({
|
|
85
|
+
dashboardUrl,
|
|
86
|
+
path: `/api/speed/seen?${query.toString()}`,
|
|
87
|
+
method: "GET",
|
|
88
|
+
deviceToken: session.device_token,
|
|
89
|
+
fetch: io.fetch,
|
|
90
|
+
label: "speed-seen",
|
|
91
|
+
timeoutMs: REQUEST_DEADLINE_MS,
|
|
92
|
+
log: (line) => {
|
|
93
|
+
if (!line.startsWith("[tower client] responded"))
|
|
94
|
+
log(line);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
if (!answer.ok) {
|
|
98
|
+
const reason = towerFailureDetail(answer.reason, answer.detail);
|
|
99
|
+
log(`[speed cli] seen refused ${JSON.stringify({ since, everyone, reason: answer.reason, http_status: answer.httpStatus ?? null })}`);
|
|
100
|
+
if (command.json)
|
|
101
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, reason: answer.reason, message: reason }));
|
|
102
|
+
else
|
|
103
|
+
writeLine(io.stderr, `cockpit speed --seen: ${reason}`);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
const body = (answer.body ?? {});
|
|
107
|
+
const wanted = command.pages;
|
|
108
|
+
const pages = rankSeen((body.pages ?? []).filter((page) => wanted.length === 0 || wanted.includes(page.page)));
|
|
109
|
+
const visits = pages.reduce((sum, page) => sum + page.visits, 0);
|
|
110
|
+
log(`[speed cli] seen read ${JSON.stringify({ since, everyone, pages: pages.length, visits })}`);
|
|
111
|
+
if (command.json) {
|
|
112
|
+
writeLine(io.stdout, JSON.stringify({ ok: true, since, everyone, dashboard_url: dashboardUrl, pages }));
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
for (const line of renderSeenTable(pages, since, everyone))
|
|
116
|
+
writeLine(io.stdout, line);
|
|
117
|
+
}
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
package/dist/commands/speed.js
CHANGED
|
@@ -21,11 +21,15 @@
|
|
|
21
21
|
import { resolveContainerTag } from "@bli-cockpit/memory-mcp/dist/container-tag.js";
|
|
22
22
|
import { writeLine } from "./cli-io.js";
|
|
23
23
|
import { classifyStatus, rankPages, renderSpeedTable, summarizeSamples, } from "./speed-math.js";
|
|
24
|
+
import { runSpeedSeen } from "./speed-seen.js";
|
|
24
25
|
import { speedPages } from "./speed-pages.js";
|
|
25
26
|
import { loadPairedSession, towerFailureDetail, towerRequest } from "../tower-client.js";
|
|
26
27
|
export const DEFAULT_SPEED_RUNS = 5;
|
|
27
28
|
const REQUEST_DEADLINE_MS = 60_000;
|
|
28
29
|
export async function runSpeed(command, io, deps = {}) {
|
|
30
|
+
// BLI-13054: `--seen` reads real visits (speed-seen.ts) instead of timing the data doors.
|
|
31
|
+
if (command.seen)
|
|
32
|
+
return runSpeedSeen(command, io);
|
|
29
33
|
const now = deps.now ?? (() => performance.now());
|
|
30
34
|
const log = (line) => writeLine(io.stderr, line);
|
|
31
35
|
const session = await loadPairedSession("speed", command.homeDir);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which commits a session made (BLI-13028).
|
|
3
|
+
*
|
|
4
|
+
* The Setup page asks "did this session's work ship in a merged pull request?".
|
|
5
|
+
* A session's branch answers that for only a minority of sessions (most sit on
|
|
6
|
+
* `main`), but the commits a session made always can: git remembers, in the
|
|
7
|
+
* working tree's own reflog, every commit created there and when. So the
|
|
8
|
+
* collector reads that reflog once per working tree and hands each session the
|
|
9
|
+
* commits created between its first and last transcript timestamps.
|
|
10
|
+
*
|
|
11
|
+
* Why the reflog and not `git log`: the reflog is per working tree, so a
|
|
12
|
+
* session in one worktree is never credited with a commit another worktree made
|
|
13
|
+
* at the same moment (a log of the shared history would be). It also holds the
|
|
14
|
+
* new SHAs a rebase writes, which are the ones the pull request ends up with.
|
|
15
|
+
*
|
|
16
|
+
* Metadata only: full commit SHAs, never messages, authors or diffs. One git
|
|
17
|
+
* call per working tree per sync, not per session, because a first run
|
|
18
|
+
* revisits thousands of sessions and a Windows `git` start costs ~100 ms.
|
|
19
|
+
* `execFile` with an argument array runs the same on macOS and native Windows
|
|
20
|
+
* (no shell, no quoting).
|
|
21
|
+
*/
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
import { describeError } from "./health-detail.js";
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
/** Newest reflog entries read per working tree. A busy tree makes a few hundred a day. */
|
|
27
|
+
const REFLOG_ENTRY_LIMIT = 20_000;
|
|
28
|
+
/** A commit made just after the last transcript line still belongs to the session that asked for it. */
|
|
29
|
+
export const SESSION_COMMIT_GRACE_MS = 120_000;
|
|
30
|
+
/** Same cap the wire schema enforces. */
|
|
31
|
+
export const SESSION_COMMIT_SHA_LIMIT = 100;
|
|
32
|
+
/** Reflog subjects that mean "this working tree just created this commit". */
|
|
33
|
+
const COMMIT_CREATING_SUBJECT = /^(commit\b|cherry-pick\b|revert\b|rebase(?: -i)? \((?:pick|reword|squash|fixup|continue|edit)\))/;
|
|
34
|
+
const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
35
|
+
export const runGitForCommits = async (args, cwd) => {
|
|
36
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
37
|
+
cwd,
|
|
38
|
+
timeout: 20_000,
|
|
39
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
42
|
+
});
|
|
43
|
+
return stdout;
|
|
44
|
+
};
|
|
45
|
+
/** Every commit-creating reflog entry of one working tree, or the reason it could not be read. */
|
|
46
|
+
export async function readCreatedCommits(repoRoot, git = runGitForCommits) {
|
|
47
|
+
let raw;
|
|
48
|
+
try {
|
|
49
|
+
raw = await git(["log", "-g", "--date=unix", `-n${REFLOG_ENTRY_LIMIT}`, "--format=%H%x1f%gd%x1f%gs", "HEAD"], repoRoot);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.error("[session-commits] reflog unreadable", JSON.stringify({ reason: "git_reflog_failed", ...describeError(error) }));
|
|
53
|
+
return { failure: "git_reflog_failed" };
|
|
54
|
+
}
|
|
55
|
+
return { commits: parseCreatedCommits(raw) };
|
|
56
|
+
}
|
|
57
|
+
export function parseCreatedCommits(raw) {
|
|
58
|
+
const commits = [];
|
|
59
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
60
|
+
const [sha, selector, subject] = line.split("\x1f");
|
|
61
|
+
if (!sha || !FULL_SHA.test(sha) || !subject || !COMMIT_CREATING_SUBJECT.test(subject))
|
|
62
|
+
continue;
|
|
63
|
+
const at = Number(/@\{(\d+)\}$/.exec(selector ?? "")?.[1]);
|
|
64
|
+
if (Number.isFinite(at))
|
|
65
|
+
commits.push({ sha, at });
|
|
66
|
+
}
|
|
67
|
+
return commits;
|
|
68
|
+
}
|
|
69
|
+
/** The commits created in `[startMs, endMs + grace]`, newest first, capped. */
|
|
70
|
+
export function commitsInSpan(commits, startMs, endMs) {
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
for (const commit of commits) {
|
|
73
|
+
const atMs = commit.at * 1000;
|
|
74
|
+
if (atMs >= startMs && atMs <= endMs + SESSION_COMMIT_GRACE_MS)
|
|
75
|
+
seen.add(commit.sha);
|
|
76
|
+
}
|
|
77
|
+
return [...seen].slice(0, SESSION_COMMIT_SHA_LIMIT);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Commit SHAs per session, keyed `${source}:${session_id}`. A session with no
|
|
81
|
+
* working tree, or no transcript timestamps, gets no entry: "unknown", not
|
|
82
|
+
* "made no commits". A session whose tree was read and made none gets `[]`.
|
|
83
|
+
*/
|
|
84
|
+
export async function collectSessionCommits(results, git = runGitForCommits) {
|
|
85
|
+
const ledgers = new Map();
|
|
86
|
+
const bySession = new Map();
|
|
87
|
+
const counts = { sessions_with_commits: 0, sessions_without_commits: 0, sessions_unknown: 0, trees_read: 0, trees_failed: 0 };
|
|
88
|
+
for (const { key, result } of results) {
|
|
89
|
+
const start = Date.parse(result.session_facts?.first_timestamp ?? "");
|
|
90
|
+
const end = Date.parse(result.session_facts?.last_timestamp ?? "");
|
|
91
|
+
if (!result.worktree || !Number.isFinite(start) || !Number.isFinite(end)) {
|
|
92
|
+
counts.sessions_unknown += 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const root = result.worktree.repo_root;
|
|
96
|
+
if (!ledgers.has(root))
|
|
97
|
+
ledgers.set(root, readCreatedCommits(root, git));
|
|
98
|
+
const ledger = await ledgers.get(root);
|
|
99
|
+
if ("failure" in ledger) {
|
|
100
|
+
counts.sessions_unknown += 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const shas = commitsInSpan(ledger.commits, start, end);
|
|
104
|
+
bySession.set(key, [...new Set([...(bySession.get(key) ?? []), ...shas])].slice(0, SESSION_COMMIT_SHA_LIMIT));
|
|
105
|
+
counts[shas.length > 0 ? "sessions_with_commits" : "sessions_without_commits"] += 1;
|
|
106
|
+
}
|
|
107
|
+
for (const ledger of ledgers.values())
|
|
108
|
+
counts["failure" in (await ledger) ? "trees_failed" : "trees_read"] += 1;
|
|
109
|
+
console.error("[session-commits] commits recorded per session", JSON.stringify({ reason: "session_commits_collected", ...counts }));
|
|
110
|
+
return bySession;
|
|
111
|
+
}
|
|
112
|
+
/** The wire field, only when the collector actually looked. */
|
|
113
|
+
export function commitShasField(shas) {
|
|
114
|
+
return shas ? { commit_shas: shas } : {};
|
|
115
|
+
}
|
|
116
|
+
/** Stamp each report row with the commits recorded for its session. */
|
|
117
|
+
export function withSessionCommits(rows, commitsBySession) {
|
|
118
|
+
return rows.map((row) => ({
|
|
119
|
+
...row,
|
|
120
|
+
...commitShasField(commitsBySession.get(`${row.source}:${row.codex_session_id}`)),
|
|
121
|
+
}));
|
|
122
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.195",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.58",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.107",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.67"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|