@bli-cockpit/cli 0.2.109 → 0.2.111

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.
@@ -88,7 +88,8 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
88
88
  }
89
89
  const primaryCwd = signals.cwds[0] ?? null;
90
90
  if (primaryCwd) {
91
- base.cwd_basename = path.basename(primaryCwd) || null;
91
+ base.local_cwd = primaryCwd;
92
+ base.cwd_basename = (path.win32.isAbsolute(primaryCwd) ? path.win32 : path.posix).basename(primaryCwd) || null;
92
93
  base.cwd_hash = shortHash(primaryCwd);
93
94
  }
94
95
  if (signals.line_count === 0 || signals.parse_error_count === signals.line_count) {
@@ -199,7 +199,8 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
199
199
  }
200
200
  const primaryCwd = signals.cwds[0] ?? null;
201
201
  if (primaryCwd) {
202
- base.cwd_basename = path.basename(primaryCwd) || null;
202
+ base.local_cwd = primaryCwd;
203
+ base.cwd_basename = (path.win32.isAbsolute(primaryCwd) ? path.win32 : path.posix).basename(primaryCwd) || null;
203
204
  base.cwd_hash = shortHash(primaryCwd);
204
205
  }
205
206
  if (signals.line_count === 0 || signals.parse_error_count === signals.line_count) {
@@ -8,6 +8,7 @@
8
8
  * Split out of commands/local.ts (BLI-3104); moved verbatim.
9
9
  */
10
10
  import os from "node:os";
11
+ import { scanWorkDirectories } from "./local-discovery.js";
11
12
  import path from "node:path";
12
13
  import { stat } from "node:fs/promises";
13
14
  import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
@@ -26,7 +27,10 @@ export async function resolveOnboardingRootsForCommand(command, io) {
26
27
  const paths = getCollectorRuntimePaths(command.homeDir);
27
28
  const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
28
29
  const interactive = !command.json && isInteractiveStdin(io);
30
+ const discovered = interactive && !command.collectionRoots?.length && !command.repoRoot && !existingConfig?.default_repo_paths.length
31
+ ? await scanWorkDirectories(command.homeDir ?? os.homedir()) : null;
29
32
  const rootsResult = await resolveOnboardingRoots({
33
+ suggestedRoots: discovered?.directories.map(entry => entry.directory),
30
34
  homeDir: command.homeDir,
31
35
  explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
32
36
  config: existingConfig,
@@ -1,3 +1,8 @@
1
+ import os from "node:os";
2
+ import { readRootWorkScan, outsideWorkDirectories, readRootApprovals, rootsAddCommand, rootPathApi } from "../root-work.js";
3
+ import { writeJsonFile } from "../local-state-files.js";
4
+ import { containsPath } from "../root-normalization.js";
5
+ import { scanWorkDirectories } from "./local-discovery.js";
1
6
  import { sendCollectorHeartbeatBestEffort } from "./heartbeat.js";
2
7
  import { isInteractiveDoctorFix } from "./doctor-report.js";
3
8
  import { describeError } from "../health-detail.js";
@@ -150,4 +155,41 @@ function shellQuote(value) {
150
155
  if (value === "$PWD")
151
156
  return '"$PWD"';
152
157
  return `'${value.replace(/'/gu, "'\\''")}'`;
158
+ }
159
+ /** The last scan names work missed by the saved boundary. Diagnosis never grants consent. */
160
+ export async function readRootsCoverWork(context) {
161
+ const homeDir = context.command.homeDir ?? os.homedir();
162
+ const paths = getCollectorRuntimePaths(homeDir);
163
+ const scan = await readRootWorkScan(paths) ?? await scanWorkDirectories(homeDir, false);
164
+ const outside = outsideWorkDirectories(scan, (await readLocalCollectorConfig(paths))?.default_repo_paths ?? []);
165
+ const count = outside.reduce((sum, entry) => sum + entry.count, 0);
166
+ if (!count)
167
+ return ok("roots-cover-work", "work_covered", `${scan.observed} sessions seen; no known working directories outside approved roots.`);
168
+ return {
169
+ ...needsFix("roots-cover-work", "work_outside_roots", `${count} of ${scan.observed} sessions have working directories outside every approved root (scan ${scan.generated_at}):\n${outside.map(entry => `${entry.count}: ${entry.directory}\n ${rootsAddCommand(entry.directory, process.platform, homeDir)}`).join("\n")}`),
170
+ nextAction: rootsAddCommand(outside[0].directory, process.platform, homeDir),
171
+ };
172
+ }
173
+ export async function fixRootsCoverWork(context) {
174
+ const homeDir = context.command.homeDir ?? os.homedir();
175
+ const paths = getCollectorRuntimePaths(homeDir);
176
+ const config = await readLocalCollectorConfig(paths);
177
+ if (!config)
178
+ return readRootsCoverWork(context);
179
+ const scan = await readRootWorkScan(paths) ?? await scanWorkDirectories(homeDir);
180
+ const approvals = await readRootApprovals(paths);
181
+ const outside = outsideWorkDirectories(scan, config.default_repo_paths);
182
+ const additions = outside.filter(entry => approvals.some(root => containsPath(root, entry.directory, rootPathApi())));
183
+ if (additions.length) {
184
+ // Restore the precise approved directories, preserving every other config option.
185
+ const roots = normalizeCollectionRoots([...config.default_repo_paths, ...additions.map(entry => entry.directory)], rootPathApi());
186
+ await writeJsonFile(paths.config_file, { ...config, default_repo_paths: roots });
187
+ console.error("[doctor] roots-cover-work repaired", JSON.stringify({ added: additions.length }));
188
+ await scanWorkDirectories(homeDir);
189
+ }
190
+ else {
191
+ console.error("[doctor] roots-cover-work needs approval", JSON.stringify({ outside: outside.length, added: 0 }));
192
+ }
193
+ const checked = await readRootsCoverWork(context);
194
+ return { ...checked, fixed: additions.length > 0 && checked.status === "ok" };
153
195
  }
@@ -137,6 +137,7 @@ export function doctorNeedsPerson(rows, receipt) {
137
137
  "cli-latest": "npm i -g @bli-cockpit/cli && cockpit doctor",
138
138
  "authed": "cockpit doctor",
139
139
  "roots-ok": "cockpit doctor",
140
+ "roots-cover-work": "cockpit doctor",
140
141
  "single-install": "Run npm uninstall -g @bli-cockpit/cli with the Node installation that owns the extra CLI listed above.",
141
142
  "autostart-alive": "cockpit doctor",
142
143
  "memory-registered": "cockpit doctor",
@@ -2,7 +2,7 @@ import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
2
2
  import { isSemverBelow } from "../scheduled-self-update.js";
3
3
  import { describeError } from "../health-detail.js";
4
4
  import { withStage } from "../crash-guard.js";
5
- import { checkSingleInstallState, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
5
+ import { checkSingleInstallState, readRootsCoverWork, fixRootsCoverWork, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
6
6
  import { backfillCompletionStepState, backfillFixVerdict, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline.js";
7
7
  import { checkMcpAnswersState } from "./doctor-mcp.js";
8
8
  import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
@@ -119,6 +119,7 @@ function doctorInvariants() {
119
119
  fix: fixRootState,
120
120
  requiresInteractiveFix: true,
121
121
  },
122
+ { id: "roots-cover-work", check: context => context.deps.readRootsCoverWork(context), fix: context => context.deps.fixRootsCoverWork(context) },
122
123
  // Deliberately has NO fix: uninstalling software the operator did not ask
123
124
  // to have uninstalled is not a repair (BLI-3218/BLI-3553). Doctor names the
124
125
  // other install and the exact command; the person decides.
@@ -192,6 +193,8 @@ function defaultDoctorDeps(hooks) {
192
193
  readAuth: readAuthState,
193
194
  runLogin: (context) => hooks.runLogin(context.command, context.io),
194
195
  readRoots: readRootState,
196
+ readRootsCoverWork,
197
+ fixRootsCoverWork,
195
198
  resolveAndSaveRoots: (context) => hooks.resolveAndSaveRoots(context.command, context.io),
196
199
  checkSingleInstall: checkSingleInstallState,
197
200
  checkAutostart: checkAutostartState,
@@ -102,6 +102,7 @@ export function buildCollectorHeartbeat(options) {
102
102
  os_platform: platform,
103
103
  roots: collectionRootLabels(options.roots, platform),
104
104
  last_sync_status: options.facts.status,
105
+ ...(options.facts.outsideRootDirectories ? { outside_root_directories: options.facts.outsideRootDirectories } : {}),
105
106
  ...(options.facts.reason ? { last_sync_reason: options.facts.reason } : {}),
106
107
  ...(typeof options.facts.sessionsObserved === "number"
107
108
  ? { sessions_observed: options.facts.sessionsObserved }
@@ -1,13 +1,15 @@
1
1
  import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
2
2
  export function parseUsageArgs(args) {
3
- const values = parseNamedArgs(args, { allowedFlags: ["--min-confidence", "--by-topic", "--by-repo", "--person", "--all", "--since", "--until", "--include-automated", "--detail", "--home", "--dashboard-url", "--json"], valueFlags: ["--min-confidence", "--person", "--since", "--until", "--home", "--dashboard-url"] });
4
- if (values.booleans.has("--by-repo") && values.booleans.has("--by-topic"))
5
- throw new Error("--by-repo and --by-topic cannot be used together.");
3
+ const values = parseNamedArgs(args, { allowedFlags: ["--by-subject", "--subject", "--repo", "--session", "--from-window", "--min-confidence", "--by-topic", "--by-repo", "--person", "--all", "--since", "--until", "--include-automated", "--detail", "--home", "--dashboard-url", "--json"], valueFlags: ["--subject", "--repo", "--session", "--from-window", "--min-confidence", "--person", "--since", "--until", "--home", "--dashboard-url"] });
4
+ if (["--by-repo", "--by-topic", "--by-subject"].filter(flag => values.booleans.has(flag)).length > 1)
5
+ throw new Error("--by-repo and --by-topic cannot be used together or with --by-subject.");
6
6
  const minConfidence = values.flags.get("--min-confidence");
7
7
  if (minConfidence !== undefined && !["high", "medium", "low"].includes(minConfidence))
8
8
  throw new Error("--min-confidence accepts high, medium or low.");
9
9
  const action = values.positionals[0] ?? "people";
10
- if (action !== "people" || values.positionals.length > 1)
11
- throw new Error("usage takes one verb: people.");
12
- return { kind: "usage", ...(minConfidence ? { minConfidence: minConfidence } : {}), byTopic: values.booleans.has("--by-topic"), byRepo: values.booleans.has("--by-repo"), person: optionalNonEmpty(values.flags.get("--person")), all: values.booleans.has("--all"), action: "people", since: optionalNonEmpty(values.flags.get("--since")) ?? "30d", until: optionalNonEmpty(values.flags.get("--until")), detail: values.booleans.has("--detail"), includeAutomated: values.booleans.has("--include-automated"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
10
+ if (!["people", "sessions"].includes(action) || values.positionals.length > 1)
11
+ throw new Error("usage takes people or sessions.");
12
+ if (action === "sessions" && !values.flags.get("--subject") && !values.flags.get("--session"))
13
+ throw new Error("usage sessions requires --subject or --session.");
14
+ return { bySubject: values.booleans.has("--by-subject"), subject: optionalNonEmpty(values.flags.get("--subject")), repo: optionalNonEmpty(values.flags.get("--repo")), session: optionalNonEmpty(values.flags.get("--session")), fromWindow: optionalNonEmpty(values.flags.get("--from-window")), kind: "usage", ...(minConfidence ? { minConfidence: minConfidence } : {}), byTopic: values.booleans.has("--by-topic"), byRepo: values.booleans.has("--by-repo"), person: optionalNonEmpty(values.flags.get("--person")), all: values.booleans.has("--all"), action: action, since: optionalNonEmpty(values.flags.get("--since")) ?? "30d", until: optionalNonEmpty(values.flags.get("--until")), detail: values.booleans.has("--detail"), includeAutomated: values.booleans.has("--include-automated"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
13
15
  }
@@ -15,6 +15,7 @@
15
15
  * its own rate. Behavior-preserving extraction throughout: functions moved
16
16
  * verbatim, no logic change.
17
17
  */
18
+ import { parseRootsArgs } from "./roots.js";
18
19
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
19
20
  import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseCareersArgs, } from "./local-args-tower.js";
20
21
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
@@ -30,6 +31,7 @@ export { SCOUT_MIN_PREFIX_LENGTH, SLACK_WORKSPACE_KEYS, WORKBOOK_MIN_WIDTH, ISSU
30
31
  export function parseLocalArgs(argv) {
31
32
  const command = argv[0];
32
33
  switch (command) {
34
+ case "roots": return parseRootsArgs(argv.slice(1));
33
35
  case "onboard":
34
36
  return parseOnboardArgs(argv.slice(1));
35
37
  case "update":
@@ -112,4 +112,22 @@ function incompleteDiscoveryMessage(input) {
112
112
  "",
113
113
  "If the scan is still partial, raise the numbers again, or point --workspace at the specific project folders instead of a parent.",
114
114
  ].join("\n");
115
+ }
116
+ /** Reuse the session adapters for onboarding/doctor. No transcript bodies leave them. */
117
+ export async function scanWorkDirectories(homeDir, persist = true) {
118
+ const [{ scanAndAttributeCodexSessions, defaultCodexSessionDirs }, { scanAndAttributeClaudeSessions }, { readLocalCollectorConfig, getCollectorRuntimePaths }, { saveRootWorkScan, workDirectories }] = await Promise.all([
119
+ import("../adapters/codex-attribution.js"), import("../adapters/claude-attribution.js"), import("../local-state.js"), import("../root-work.js"),
120
+ ]);
121
+ const paths = getCollectorRuntimePaths(homeDir);
122
+ const config = await readLocalCollectorConfig(paths).catch(error => { if (error.code === "ENOENT")
123
+ return null; throw error; });
124
+ const now = new Date();
125
+ const options = { worktrees: [], collectionRoots: config?.default_repo_paths ?? [], now, sinceMinutes: 14 * 24 * 60, limit: Number.MAX_SAFE_INTEGER };
126
+ const codex = await scanAndAttributeCodexSessions({ ...options, sessionsDirs: defaultCodexSessionDirs(homeDir) });
127
+ const claude = config?.collect_claude_jsonl === false ? null : await scanAndAttributeClaudeSessions({ ...options, projectsDir: (await import("node:path")).default.join(homeDir, ".claude", "projects") });
128
+ if (codex.directory_read_failed_count || codex.stat_failed_count || claude?.project_dir_read_failed_count || claude?.session_stat_failed_count) {
129
+ throw new Error("work_directory_scan_incomplete");
130
+ }
131
+ const results = [...codex.results, ...(claude?.results ?? [])];
132
+ return persist ? saveRootWorkScan(paths, results, now) : { generated_at: now.toISOString(), observed: results.length, directories: workDirectories(results) };
115
133
  }
@@ -143,7 +143,10 @@ export const TOWER_COMMAND_HELP = [
143
143
  [
144
144
  "usage",
145
145
  [
146
- "Usage: cockpit usage people [--by-repo | --by-topic] [--min-confidence high|medium|low] [--person <email|me>] [--all] [--since <n>d|<n>h|<iso>] [--detail] [--until <iso>] [--include-automated] [--json]",
146
+ "Usage: cockpit usage people [--by-repo | --by-topic | --by-subject] [--min-confidence high|medium|low] [--person <email|me>] [--all] [--since <n>d|<n>h|<iso>] [--detail] [--until <iso>] [--include-automated] [--json]",
147
+ " cockpit usage sessions --subject <name> [--repo <label>] [--since <window>] [--json]",
148
+ " cockpit usage sessions --session <id> [--from-window <n>] [--json]",
149
+ " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
147
150
  "",
148
151
  "--by-topic adds topic rows, including (unlabelled). Choose only one grouping. --by-repo adds project rows under each person (top 10; --all shows every repo). --person me uses your signed-in email.",
149
152
  "Claude Code and Codex usage per person: sessions observed and extracted, tokens (total, output,",
@@ -21,6 +21,7 @@ import { localCommandHelp } from "./local-help.js";
21
21
  import { TOWER_COMMAND_HELP } from "./local-help-commands-tower.js";
22
22
  export function localSubcommandHelp(command) {
23
23
  const helpByCommand = new Map([
24
+ ["roots", ["Usage: cockpit roots add <dir> [--allow-home-root] [--json]", "Approves a work directory, saves an approval receipt, and adds it to collection. Run cockpit doctor afterward."]],
24
25
  // BLI-3709: the Tower nouns live next door, split along the same seam the
25
26
  // argument parsers already use — see `local-help-commands-tower.ts`.
26
27
  ...TOWER_COMMAND_HELP,
@@ -89,6 +90,7 @@ export function localSubcommandHelp(command) {
89
90
  "",
90
91
  "Diagnose, repair, and verify this machine.",
91
92
  "Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
93
+ "roots-cover-work names sessions outside saved roots and gives cockpit roots add commands. Previously approved directories are restored automatically.",
92
94
  "--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
93
95
  "--backfill-budget defaults to 900 seconds; catch-up repeats chunks until complete, with progress every 30 seconds.",
94
96
  "--lock-wait defaults to 600 seconds; progress prints every 30 seconds.",
@@ -11,6 +11,7 @@ import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
11
11
  import { SEARCH_KINDS } from "./local-args-tower-search.js";
12
12
  import { localSubcommandHelp } from "./local-help-commands.js";
13
13
  export const rootCommandNames = new Set([
14
+ "roots",
14
15
  "onboard",
15
16
  "update",
16
17
  "upgrade",
@@ -61,6 +62,7 @@ export function localCommandHelp(command) {
61
62
  if (command)
62
63
  return localSubcommandHelp(command);
63
64
  return [
65
+ " cockpit roots add <dir> [--allow-home-root] [--json]",
64
66
  " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
65
67
  " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
66
68
  " cockpit upgrade [same flags as update]",
@@ -102,7 +104,10 @@ export function localCommandHelp(command) {
102
104
  ` cockpit search "<words>" [--kind ${SEARCH_KINDS.join(",")}] [--limit <n>] [--dashboard-url <url>] [--json]`,
103
105
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
104
106
  " cockpit careers [list|show <id>|rescreen <id>] [--role <slug>] [--min-score <n>] [--since <date>] [--json]",
105
- " cockpit usage people [--by-repo | --by-topic] [--min-confidence high|medium|low] [--person <email|me>] [--all] [--detail] [--since <n>d|<n>h|<iso>] [--until <iso>] [--include-automated] [--dashboard-url <url>] [--json]",
107
+ " cockpit usage sessions --subject <name> [--repo <label>] [--since <window>] [--json]",
108
+ " cockpit usage sessions --session <id> [--from-window <n>] [--json]",
109
+ " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
110
+ " cockpit usage people [--by-repo | --by-topic | --by-subject] [--min-confidence high|medium|low] [--person <email|me>] [--all] [--detail] [--since <n>d|<n>h|<iso>] [--until <iso>] [--include-automated] [--dashboard-url <url>] [--json]",
106
111
  "",
107
112
  `Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
108
113
  ].join("\n");
@@ -54,6 +54,7 @@ export { buildCollectorHeartbeat, collectionRootLabel, collectionRootLabels, sen
54
54
  export { classifySyncFailureRecords, classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, SYNC_ERROR_DETAIL_MAX_CHARS, } from "./install-receipts.js";
55
55
  export { assertCollectionRootPersisted } from "./collection-roots.js";
56
56
  export { runSelfUpdate, SelfUpdateError, } from "./install-update.js";
57
+ import { runRoots } from "./roots.js";
57
58
  export async function runLocalCockpitCli(argv, io = defaultIo()) {
58
59
  if (isLocalHelpRequest(argv)) {
59
60
  writeLine(io.stdout, localCommandHelp(argv[0]));
@@ -85,6 +86,7 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
85
86
  beginStage(`command:${command.kind}`);
86
87
  try {
87
88
  switch (command.kind) {
89
+ case "roots": return await runRoots(command, io);
88
90
  case "install":
89
91
  return await runInstall(command, io);
90
92
  case "onboard":
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.109");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.111");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,41 @@
1
+ import os from "node:os";
2
+ import { stat } from "node:fs/promises";
3
+ import { getCollectorRuntimePaths, ensureLocalCollectorConfig } from "../local-state.js";
4
+ import { normalizeRootsDetailed } from "../onboarding-roots.js";
5
+ import { normalizeCollectionRoots } from "../root-normalization.js";
6
+ import { recordRootApprovals } from "../root-work.js";
7
+ import { writeJsonFile } from "../local-state-files.js";
8
+ import { writeLine } from "./cli-io.js";
9
+ export function parseRootsArgs(args) {
10
+ const [action, directory, ...flags] = args;
11
+ if (action !== "add" || !directory || directory.startsWith("--"))
12
+ throw new Error("Usage: cockpit roots add <dir> [--allow-home-root] [--json]");
13
+ let homeDir;
14
+ for (let index = 0; index < flags.length; index++) {
15
+ const flag = flags[index];
16
+ if (flag === "--home-dir" && flags[index + 1]) {
17
+ homeDir = flags[++index];
18
+ continue;
19
+ }
20
+ if (flag !== "--json" && flag !== "--allow-home-root")
21
+ throw new Error(`Unknown roots flag: ${flag}`);
22
+ }
23
+ return { kind: "roots", action, directory, homeDir, json: flags.includes("--json"), allowHomeRoot: flags.includes("--allow-home-root") };
24
+ }
25
+ export async function runRoots(command, io) {
26
+ const homeDir = command.homeDir ?? os.homedir();
27
+ const { roots, rejected } = normalizeRootsDetailed([command.directory], { homeDir, allowHomeRoot: command.allowHomeRoot });
28
+ if (!roots.length || rejected.length)
29
+ throw new Error("Choose a work directory, or pass --allow-home-root to approve your home folder.");
30
+ for (const root of roots)
31
+ if (!(await stat(root)).isDirectory())
32
+ throw new Error("Collection root must be a directory.");
33
+ const paths = getCollectorRuntimePaths(homeDir);
34
+ const config = (await ensureLocalCollectorConfig({ homeDir })).config;
35
+ const updated = { ...config, default_repo_paths: normalizeCollectionRoots([...config.default_repo_paths, ...roots]) };
36
+ await recordRootApprovals(paths, [...config.default_repo_paths, ...roots]);
37
+ await writeJsonFile(paths.config_file, updated);
38
+ console.error("[roots] added", JSON.stringify({ added: roots.length, roots: updated.default_repo_paths.length }));
39
+ writeLine(io.stdout, command.json ? JSON.stringify({ ok: true, roots: updated.default_repo_paths }) : `Approved roots: ${updated.default_repo_paths.join(", ")}. Run cockpit doctor to collect the work there.`);
40
+ return 0;
41
+ }
@@ -9,6 +9,8 @@
9
9
  * that writes the closed-registry LABEL the health receipt is classified by.
10
10
  * Nothing here parses a rendered sentence back apart.
11
11
  */
12
+ import { collectionRootLabel } from "./heartbeat.js";
13
+ import { workDirectories } from "../root-work.js";
12
14
  import { createSyncFailureLedger, recordSourceScanFailures, recordUnexplainedFailure, recordUnpostedSessionReportFailure, recordWorktreeDeliveryFailures, } from "./session-sync-failures.js";
13
15
  import { countSessionsNewThisTick, countSessionsPendingUpload, } from "./session-sync-counters.js";
14
16
  import { logDeliveryHold, summarizeDeliveryHold } from "./session-sync-hold.js";
@@ -60,6 +62,9 @@ export function decideSyncHealth(options) {
60
62
  notice,
61
63
  hold,
62
64
  sessions_outside_root: sessionsOutsideRoot,
65
+ outside_root_directories: workDirectories([...(options.scan.codexAttribution.results ?? []), ...(options.scan.claudeAttribution.results ?? [])].filter(result => OUTSIDE_APPROVED_ROOT_REASONS.has(result.reason)))
66
+ .map(entry => ({ ...collectionRootLabel(entry.directory), count: entry.count }))
67
+ .filter(entry => entry.basename.length > 0 && !/[\\/]/u.test(entry.basename)).slice(0, 40),
63
68
  sessions_new_this_tick: countSessionsNewThisTick({
64
69
  sessions: options.sessions,
65
70
  codexCursorBefore: options.plan.codexCursorBefore,
@@ -8,6 +8,7 @@
8
8
  * `repo_not_on_disk` widens the next window and never fails a sync.
9
9
  */
10
10
  import path from "node:path";
11
+ import { saveRootWorkScan } from "../root-work.js";
11
12
  import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
12
13
  import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.js";
13
14
  import { CLAUDE_CURSOR_FILENAME } from "../cursors/raw-evidence-cursor.js";
@@ -61,6 +62,7 @@ export async function scanAndAttributeBothSources(options) {
61
62
  reason: sourceScanRetryReason("claude_code", claudeAttribution),
62
63
  });
63
64
  }
65
+ await saveRootWorkScan(options.paths, [...codexAttribution.results, ...claudeAttribution.results], options.now);
64
66
  return { codexAttribution, claudeAttribution, firstRunBackfill };
65
67
  }
66
68
  /**
@@ -133,6 +133,7 @@ export async function runAttributedWorktreeSync(options) {
133
133
  hold: health.hold,
134
134
  sessions_observed: sessions.length,
135
135
  sessions_outside_root: health.sessions_outside_root,
136
+ outside_root_directories: health.outside_root_directories,
136
137
  sessions_new_this_tick: health.sessions_new_this_tick,
137
138
  sessions_pending_upload: health.sessions_pending_upload,
138
139
  outcomes: worktreePass.outcomes,
@@ -147,6 +147,7 @@ function heartbeatCounts(run) {
147
147
  return {
148
148
  sessionsObserved: run.sessionsObserved,
149
149
  sessionsOutsideRoot: run.sessionsOutsideRoot,
150
+ outsideRootDirectories: run.outsideRootDirectories,
150
151
  sessionsNewThisTick: run.sessionsNewThisTick,
151
152
  sessionsPendingUpload: run.sessionsPendingUpload,
152
153
  };
@@ -155,6 +155,7 @@ function syncResult(run) {
155
155
  holdNotice: run.hold?.notice ?? null,
156
156
  sessionsObserved: run.sessions_observed,
157
157
  sessionsOutsideRoot: run.sessions_outside_root,
158
+ outsideRootDirectories: run.outside_root_directories,
158
159
  sessionsNewThisTick: run.sessions_new_this_tick,
159
160
  sessionsPendingUpload: run.sessions_pending_upload,
160
161
  };
@@ -5,6 +5,8 @@ import { writeLine } from "./cli-io.js";
5
5
  export async function runUsage(command, io) {
6
6
  const door = await openAgentDoor("usage", command, io);
7
7
  const query = new URLSearchParams({ since: command.since });
8
+ if (command.bySubject)
9
+ query.set("groupBy", "topic");
8
10
  if (command.minConfidence)
9
11
  query.set("minConfidence", command.minConfidence);
10
12
  if (command.byTopic)
@@ -15,6 +17,21 @@ export async function runUsage(command, io) {
15
17
  query.set("until", command.until);
16
18
  if (command.includeAutomated)
17
19
  query.set("include_automated", "1");
20
+ if (command.action === "sessions") {
21
+ for (const [key, value] of [["subject", command.subject], ["repo", command.repo], ["session", command.session], ["fromWindow", command.fromWindow]])
22
+ if (value)
23
+ query.set(key, value);
24
+ const response = await askAgentDoor(door, { path: `/api/usage/sessions?${query}`, method: "GET", label: "usage sessions", timeoutMs: 30_000 });
25
+ if (!response.ok)
26
+ return failAgentDoor(door, "[usage]", response.reason, response.detail);
27
+ if (door.json || command.session)
28
+ return emitAgentDoor(door, response.body);
29
+ const sessions = response.body.sessions ?? [];
30
+ for (const session of sessions)
31
+ writeLine(io.stdout, `${session.session_id} ${formatCount(session.tokens)} ${session.task_type} ${session.title}`);
32
+ writeLine(io.stdout, `${sessions.length} sessions`);
33
+ return 0;
34
+ }
18
35
  const answer = await askAgentDoor(door, { path: `/api/usage/people?${query}`, method: "GET", label: "usage people", timeoutMs: 30_000 });
19
36
  if (!answer.ok)
20
37
  return failAgentDoor(door, "[usage]", answer.reason, answer.detail);
@@ -31,6 +48,8 @@ export async function runUsage(command, io) {
31
48
  }
32
49
  if (command.byTopic && body.people?.some((person) => !Array.isArray(person.task_types)))
33
50
  return failAgentDoor(door, "[usage]", "topic_grouping_unavailable", "The dashboard returned person totals without topic rows. Deploy the dashboard topic split before using --by-topic.");
51
+ if (command.bySubject && body.people?.some(person => !Array.isArray(person.subjects)))
52
+ return failAgentDoor(door, "[usage]", "subject_grouping_unavailable", "Deploy the dashboard subject split before using --by-subject.");
34
53
  if (door.json)
35
54
  return emitAgentDoor(door, body);
36
55
  let nameWidth = 20;
@@ -41,6 +60,13 @@ export async function runUsage(command, io) {
41
60
  nameWidth = Math.max(nameWidth, repo.repo_label.length + 2);
42
61
  }
43
62
  }
63
+ if (command.bySubject)
64
+ for (const person of body.people ?? [])
65
+ for (const subject of person.subjects ?? []) {
66
+ nameWidth = Math.max(nameWidth, subject.repo_label.length + subject.topic.length + 5);
67
+ for (const sub of subject.children ?? [])
68
+ nameWidth = Math.max(nameWidth, sub.subtopic.length + 4);
69
+ }
44
70
  const widths = [nameWidth, 8, 15, 12, 10, 10, 12, 14];
45
71
  const printRow = (cells) => writeLine(io.stdout, cells.map((cell, index) => index === 0 ? cell.padEnd(widths[index]) : cell.padStart(widths[index])).join(" ").trimEnd());
46
72
  printRow(["Person", "Tokens", "List equivalent", "Coverage", ...(command.detail ? ["Output", "Input", "Cache read", "Cache creation"] : [])]);
@@ -52,6 +78,12 @@ export async function runUsage(command, io) {
52
78
  `${row.sessions_extracted}/${row.sessions_observed}`,
53
79
  ...(command.detail ? [row.output_tokens, row.input_tokens, row.cache_read_input_tokens, row.cache_creation_input_tokens].map(formatCount) : []),
54
80
  ]);
81
+ if (command.bySubject)
82
+ for (const subject of row.subjects ?? []) {
83
+ printRow([` ${subject.repo_label} / ${subject.topic}`, formatCount(subject.tokens), formatUsageDollars(subject.api_list_price_equivalent_usd), `${subject.extracted_sessions}/${subject.sessions}`, ...(command.detail ? [subject.output, subject.input, subject.cache_read, subject.cache_creation].map(formatCount) : [])]);
84
+ for (const sub of subject.children ?? [])
85
+ printRow([` ${sub.subtopic}`, formatCount(sub.tokens), formatUsageDollars(sub.api_list_price_equivalent_usd), `${sub.extracted_sessions}/${sub.sessions}`, ...(command.detail ? [sub.output, sub.input, sub.cache_read, sub.cache_creation].map(formatCount) : [])]);
86
+ }
55
87
  if (command.byRepo || command.byTopic) {
56
88
  const repos = (command.byTopic ? row.task_types : row.repos) ?? [];
57
89
  for (const repo of command.all ? repos : repos.slice(0, 10)) {
@@ -66,6 +98,8 @@ export async function runUsage(command, io) {
66
98
  writeLine(io.stdout, "");
67
99
  writeLine(io.stdout, body.api_list_price_equivalent_label ?? "API list-price equivalent (not actual spend)");
68
100
  writeLine(io.stdout, `${body.coverage?.sessions_extracted ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions extracted`);
101
+ if (command.bySubject)
102
+ writeLine(io.stdout, `${(body.people ?? []).reduce((n, person) => n + (person.sessions_summarized ?? 0), 0)} of ${body.coverage?.sessions_observed ?? 0} sessions summarized`);
69
103
  if (command.byTopic)
70
104
  writeLine(io.stdout, `${body.coverage?.sessions_labelled ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions labelled by topic`);
71
105
  return 0;
@@ -1,5 +1,6 @@
1
1
  import { LocalCollectorConfigSchema, } from "@bli-cockpit/telemetry-core";
2
2
  import crypto from "node:crypto";
3
+ import { recordRootApprovals } from "./root-work.js";
3
4
  import { readFileSync } from "node:fs";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
@@ -44,6 +45,7 @@ export async function installLocalCollector(options = {}) {
44
45
  session_file_path: paths.session_file,
45
46
  state_dir_path: paths.state_dir,
46
47
  });
48
+ await recordRootApprovals(paths, defaultRepoPaths, options.replaceRepoRoots);
47
49
  await writeJsonFile(paths.config_file, config);
48
50
  return {
49
51
  config,
@@ -64,6 +64,10 @@ export async function resolveOnboardingRoots(options) {
64
64
  }
65
65
  return promptForRoots(options, "Collection root(s), comma-separated: ");
66
66
  }
67
+ const suggestions = normalizeRootsDetailed(options.suggestedRoots ?? [], { homeDir: options.homeDir, allowHomeRoot: options.allowHomeRoot }).roots;
68
+ if (options.interactive && suggestions.length > 0 && await requirePrompt(options).confirm(`Sessions were found in:\n${suggestions.join("\n")}\nCollect from these folders? [Y/n] `)) {
69
+ return resolvedRoots(options, suggestions, "prompt", true);
70
+ }
67
71
  const cwd = path.resolve(options.cwd ?? process.cwd());
68
72
  if (isHomeRoot(cwd, options.homeDir)) {
69
73
  const homeDir = path.resolve(options.homeDir ?? os.homedir());
@@ -0,0 +1,70 @@
1
+ /** Local directory evidence and explicit root approvals. Full paths stay here. */
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { readJsonFile, writeJsonFile, isMissingFileError } from "./local-state-files.js";
5
+ import { containsPath, normalizeCollectionRoots } from "./root-normalization.js";
6
+ const DirectorySchema = z.object({ directory: z.string(), count: z.number().int().nonnegative() });
7
+ const ScanSchema = z.object({ generated_at: z.string(), observed: z.number(), directories: z.array(DirectorySchema) });
8
+ export const rootPathApi = (platform = process.platform) => platform === "win32" ? path.win32 : path.posix;
9
+ export function workDirectories(results, platform = process.platform) {
10
+ const api = rootPathApi(platform);
11
+ const counts = new Map();
12
+ for (const result of results) {
13
+ if (!result.local_cwd || !api.isAbsolute(result.local_cwd))
14
+ continue;
15
+ const directory = api.resolve(result.local_cwd);
16
+ const key = platform === "win32" ? directory.toLowerCase() : directory;
17
+ const entry = counts.get(key) ?? { directory, count: 0 };
18
+ entry.count += 1;
19
+ counts.set(key, entry);
20
+ }
21
+ return [...counts.values()].sort((a, b) => b.count - a.count || a.directory.localeCompare(b.directory));
22
+ }
23
+ export function outsideWorkDirectories(scan, roots, platform = process.platform) {
24
+ return scan.directories.filter(({ directory }) => !roots.some(root => containsPath(root, directory, rootPathApi(platform))));
25
+ }
26
+ export async function saveRootWorkScan(paths, results, now) {
27
+ const scan = { generated_at: now.toISOString(), observed: results.length, directories: workDirectories(results) };
28
+ try {
29
+ await writeJsonFile(path.join(paths.state_dir, "root-work-scan.json"), scan);
30
+ }
31
+ catch (error) {
32
+ console.error("[roots] work scan cache failed", JSON.stringify({ reason: "scan_cache_write_failed", error_name: error instanceof Error ? error.name : "unknown" }));
33
+ return scan;
34
+ }
35
+ console.error("[roots] work scan recorded", JSON.stringify({ observed: scan.observed, directories: scan.directories.length }));
36
+ return scan;
37
+ }
38
+ export async function readRootWorkScan(paths) {
39
+ try {
40
+ return ScanSchema.parse(await readJsonFile(path.join(paths.state_dir, "root-work-scan.json")));
41
+ }
42
+ catch (error) {
43
+ if (isMissingFileError(error))
44
+ return null;
45
+ throw error;
46
+ }
47
+ }
48
+ const ApprovalsSchema = z.object({ approved_at: z.string(), roots: z.array(z.string()) });
49
+ export async function readRootApprovals(paths) {
50
+ try {
51
+ return ApprovalsSchema.parse(await readJsonFile(path.join(paths.config_dir, "root-approvals.json"))).roots;
52
+ }
53
+ catch (error) {
54
+ if (isMissingFileError(error))
55
+ return [];
56
+ throw error;
57
+ }
58
+ }
59
+ export async function recordRootApprovals(paths, roots, replace = false) {
60
+ const previous = replace ? [] : await readRootApprovals(paths);
61
+ const approved = normalizeCollectionRoots([...previous, ...roots]);
62
+ await writeJsonFile(path.join(paths.config_dir, "root-approvals.json"), { approved_at: new Date().toISOString(), roots: approved });
63
+ console.error("[roots] approval recorded", JSON.stringify({ roots: approved.length, replaced: replace }));
64
+ }
65
+ export function rootsAddCommand(directory, platform = process.platform, homeDir) {
66
+ // Native Windows command is for PowerShell; POSIX quoting is for zsh/bash.
67
+ const escaped = platform === "win32" ? directory.replace(/'/gu, "''") : directory.replace(/'/gu, "'\\''");
68
+ const homeFlag = homeDir && rootPathApi(platform).relative(homeDir, directory) === "" ? " --allow-home-root" : "";
69
+ return `cockpit roots add '${escaped}'${homeFlag}`;
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.109",
3
+ "version": "0.2.111",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,8 +27,8 @@
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-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.28",
31
- "@bli-cockpit/mcp": "0.1.37",
32
- "@bli-cockpit/telemetry-core": "0.1.44"
30
+ "@bli-cockpit/memory-mcp": "0.1.29",
31
+ "@bli-cockpit/mcp": "0.1.39",
32
+ "@bli-cockpit/telemetry-core": "0.1.45"
33
33
  }
34
34
  }