@bli-cockpit/cli 0.1.27 → 0.1.29

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/README.md CHANGED
@@ -45,11 +45,11 @@ cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI --workspace ~/side-pr
45
45
  The CLI defaults to the production dashboard. Normal intern/operator setup,
46
46
  updates, and syncs omit `--dashboard-url`. Pass `--dashboard-url` only for
47
47
  staging, a custom dashboard, or deliberately forcing a different dashboard
48
- pairing. Already-onboarded users update with
49
- `npm install -g @bli-cockpit/cli@latest`, then run
50
- `cockpit onboard` from anywhere to refresh pairing, agent rules, autostart, and
51
- an initial sync against saved roots. `--repo <path>` remains supported for older
52
- prompts and the agent ticket-binding guardrail.
48
+ pairing. Already-onboarded users update with `cockpit update` from anywhere. It
49
+ installs the latest public CLI from npm, then reruns onboarding checks to
50
+ refresh pairing, agent rules, autostart, and an initial sync against saved
51
+ roots. `cockpit upgrade` is a compatibility alias. `--repo <path>` remains
52
+ supported for older prompts and the agent ticket-binding guardrail.
53
53
 
54
54
  On machines where Codex or Claude agents will do ticketed work, `cockpit
55
55
  onboard` refreshes `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` after harvest
@@ -137,6 +137,9 @@ cockpit sync \
137
137
 
138
138
  No ticket is required for setup, chatting, planning, or general ambient capture.
139
139
  Only pass `--ticket` when the work really belongs to a visible ticket.
140
+ Omitting `--ticket` preserves any existing ticket binding; run
141
+ `cockpit start --clear-ticket --workspace "$PWD"` when you intentionally want
142
+ to return a previously bound work context to general ambient capture.
140
143
 
141
144
  When the work is important but ticketless, label it explicitly before syncing so
142
145
  later analysis does not have to guess the topic:
@@ -12,6 +12,9 @@ export function parseLocalArgs(argv) {
12
12
  switch (command) {
13
13
  case "onboard":
14
14
  return parseOnboardArgs(argv.slice(1));
15
+ case "update":
16
+ case "upgrade":
17
+ return parseUpdateArgs(command, argv.slice(1));
15
18
  case "install":
16
19
  return parseInstallArgs(argv.slice(1));
17
20
  case "login":
@@ -33,11 +36,13 @@ export function parseLocalArgs(argv) {
33
36
  return parseAutostartArgs(argv.slice(1));
34
37
  case "agent-rules":
35
38
  return parseAgentRulesArgs(argv.slice(1));
39
+ case "release":
40
+ return parseReleaseArgs(argv.slice(1));
36
41
  default:
37
42
  throw new Error(`Unknown local command: ${command ?? ""}`);
38
43
  }
39
44
  }
40
- function parseOnboardArgs(args) {
45
+ function parseOnboardLikeArgs(args, command) {
41
46
  const values = parseNamedArgs(args, {
42
47
  allowedFlags: [
43
48
  "--home",
@@ -69,13 +74,13 @@ function parseOnboardArgs(args) {
69
74
  "--max-repos",
70
75
  ],
71
76
  });
72
- assertNoPositionals(values.positionals, "onboard");
77
+ assertNoPositionals(values.positionals, command);
73
78
  return {
74
- kind: "onboard",
75
79
  homeDir: optionalNonEmpty(values.flags.get("--home")),
76
80
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
77
81
  collectionRoots: optionalNonEmptyList(workRootFlagValues(values)),
78
82
  dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
83
+ dashboardUrlExplicit: values.flags.has("--dashboard-url"),
79
84
  claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
80
85
  deviceName: optionalNonEmpty(values.flags.get("--device-name")),
81
86
  activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
@@ -87,6 +92,16 @@ function parseOnboardArgs(args) {
87
92
  maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
88
93
  };
89
94
  }
95
+ function parseOnboardArgs(args) {
96
+ return { kind: "onboard", ...parseOnboardLikeArgs(args, "onboard") };
97
+ }
98
+ function parseUpdateArgs(alias, args) {
99
+ return {
100
+ kind: "update",
101
+ alias,
102
+ ...parseOnboardLikeArgs(args, alias),
103
+ };
104
+ }
90
105
  function parseInstallArgs(args) {
91
106
  const values = parseNamedArgs(args, {
92
107
  allowedFlags: [
@@ -115,6 +130,30 @@ function parseInstallArgs(args) {
115
130
  json: values.booleans.has("--json"),
116
131
  };
117
132
  }
133
+ function parseReleaseArgs(args) {
134
+ const values = parseNamedArgs(args, {
135
+ allowedFlags: [
136
+ "--dry-run",
137
+ "--tag",
138
+ "--access",
139
+ "--otp",
140
+ "--skip-checks",
141
+ ],
142
+ valueFlags: ["--tag", "--access", "--otp"],
143
+ });
144
+ assertNoPositionals(values.positionals, "release");
145
+ const releaseArgs = [];
146
+ if (values.booleans.has("--dry-run"))
147
+ releaseArgs.push("--dry-run");
148
+ if (values.booleans.has("--skip-checks"))
149
+ releaseArgs.push("--skip-checks");
150
+ for (const flag of ["--tag", "--access", "--otp"]) {
151
+ const value = values.flags.get(flag);
152
+ if (value !== undefined)
153
+ releaseArgs.push(flag, value);
154
+ }
155
+ return { kind: "release", args: releaseArgs };
156
+ }
118
157
  function parseLoginArgs(args) {
119
158
  const values = parseNamedArgs(args, {
120
159
  allowedFlags: [
@@ -167,6 +206,7 @@ function parseStartArgs(args) {
167
206
  "--workspace",
168
207
  "--branch",
169
208
  "--ticket",
209
+ "--clear-ticket",
170
210
  "--topic",
171
211
  "--topic-summary",
172
212
  "--intent",
@@ -198,6 +238,11 @@ function parseStartArgs(args) {
198
238
  ],
199
239
  });
200
240
  assertNoPositionals(values.positionals, "start");
241
+ const activeTicketId = optionalNonEmpty(values.flags.get("--ticket"));
242
+ const clearTicket = values.booleans.has("--clear-ticket");
243
+ if (activeTicketId && clearTicket) {
244
+ throw new Error("--ticket and --clear-ticket cannot be combined.");
245
+ }
201
246
  const topicLabel = optionalNonEmpty(values.flags.get("--topic"));
202
247
  const topicSummaryRedacted = optionalNonEmpty(values.flags.get("--topic-summary"));
203
248
  const workIntent = optionalSchemaValue(WorkIntentSchema, values.flags.get("--intent"), "--intent");
@@ -213,7 +258,8 @@ function parseStartArgs(args) {
213
258
  homeDir: optionalNonEmpty(values.flags.get("--home")),
214
259
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
215
260
  branch: optionalNonEmpty(values.flags.get("--branch")),
216
- activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
261
+ activeTicketId,
262
+ clearTicket,
217
263
  topicLabel,
218
264
  topicSummaryRedacted,
219
265
  workIntent,
@@ -1,4 +1,5 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { createCollectorServer } from "../server.js";
@@ -14,6 +15,8 @@ import { runAttributedWorktreeSync, } from "./session-sync.js";
14
15
  import { COLLECTION_ROOT_REQUIRED, resolveOnboardingRoots, } from "../onboarding-roots.js";
15
16
  export const rootCommandNames = new Set([
16
17
  "onboard",
18
+ "update",
19
+ "upgrade",
17
20
  "install",
18
21
  "login",
19
22
  "pair",
@@ -25,6 +28,7 @@ export const rootCommandNames = new Set([
25
28
  "serve",
26
29
  "autostart",
27
30
  "agent-rules",
31
+ "release",
28
32
  ]);
29
33
  export async function runLocalCockpitCli(argv, io = defaultIo()) {
30
34
  if (isLocalHelpRequest(argv)) {
@@ -47,6 +51,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
47
51
  return await runInstall(command, io);
48
52
  case "onboard":
49
53
  return await runOnboard(command, io);
54
+ case "update":
55
+ return await runUpdate(command, io);
50
56
  case "login":
51
57
  return await runLogin(command, io);
52
58
  case "logout":
@@ -65,6 +71,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
65
71
  return await runAutostart(command, io);
66
72
  case "agent-rules":
67
73
  return await runAgentRules(command, io);
74
+ case "release":
75
+ return await runRelease(command, io);
68
76
  }
69
77
  }
70
78
  catch (error) {
@@ -77,17 +85,20 @@ export function localCommandHelp(command) {
77
85
  return localSubcommandHelp(command);
78
86
  return [
79
87
  " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
88
+ " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--json]",
89
+ " cockpit upgrade [same flags as update]",
80
90
  " cockpit install [--dashboard-url <url>] [--workspace <path>] [--json]",
81
91
  " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
82
92
  " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
83
93
  " cockpit logout",
84
- " cockpit start [--ticket <id>] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
94
+ " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
85
95
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
86
96
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
87
97
  " cockpit sessions [--source codex|claude] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
88
98
  " cockpit serve [--port <port>] [--workspace <path>]",
89
99
  " cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
90
100
  " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
101
+ " cockpit release [--dry-run] [--skip-checks] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
91
102
  "",
92
103
  `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.`,
93
104
  ].join("\n");
@@ -118,6 +129,25 @@ function localSubcommandHelp(command) {
118
129
  "Omit --dashboard-url for the production dashboard; pass it only for staging/custom dashboards.",
119
130
  ],
120
131
  ],
132
+ [
133
+ "update",
134
+ [
135
+ "Usage: cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--json]",
136
+ "",
137
+ "Updates the global public CLI from npm, then reruns `cockpit onboard`",
138
+ "with the same setup flags so pairing, saved roots, agent rules,",
139
+ "autostart, and the initial sync are refreshed in one command.",
140
+ "`cockpit upgrade` is an alias.",
141
+ ],
142
+ ],
143
+ [
144
+ "upgrade",
145
+ [
146
+ "Usage: cockpit upgrade [same flags as cockpit update]",
147
+ "",
148
+ "Alias for `cockpit update`.",
149
+ ],
150
+ ],
121
151
  [
122
152
  "login",
123
153
  [
@@ -139,10 +169,11 @@ function localSubcommandHelp(command) {
139
169
  [
140
170
  "start",
141
171
  [
142
- "Usage: cockpit start [--ticket <id>] [--topic <label>] [--topic-summary <summary>] [--intent <intent>] [--phase <phase>] [--intent-confidence <0..1>] [--workspace <path>] [--branch <name>] [--json]",
172
+ "Usage: cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--topic-summary <summary>] [--intent <intent>] [--phase <phase>] [--intent-confidence <0..1>] [--workspace <path>] [--branch <name>] [--json]",
143
173
  "",
144
174
  "Starts local ambient capture. Parent folders start each child git worktree.",
145
- "Add --ticket only when the work already has a visible ticket.",
175
+ "Add --ticket only when the work already has a visible ticket; omit it to preserve an existing binding.",
176
+ "Use --clear-ticket to intentionally return the context to general ambient capture.",
146
177
  "Use --topic/--intent/--phase for planning, discovery, and learning work that has no ticket yet.",
147
178
  "Supported intents: implementation, bug_fix, root_cause_analysis, planning, discovery, review, testing, documentation, release, learning, coordination, maintenance, analysis, unknown, other.",
148
179
  "Supported phases: planning, discovery, implementation, debugging, review, testing, documentation, release, handoff, analysis, unknown, other.",
@@ -222,6 +253,17 @@ function localSubcommandHelp(command) {
222
253
  "Action defaults to `install`.",
223
254
  ],
224
255
  ],
256
+ [
257
+ "release",
258
+ [
259
+ "Usage: cockpit release [--dry-run] [--skip-checks] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
260
+ "",
261
+ "Maintainer-only helper. Run from inside the bli-cockpit repo checkout.",
262
+ "Requires a clean `main` branch and runs `git pull --ff-only` before publishing.",
263
+ "Delegates to `npm run publish:public -- ...` so public packages are",
264
+ "built, checked, and published in the safe telemetry-core then CLI order.",
265
+ ],
266
+ ],
225
267
  ]);
226
268
  return (helpByCommand.get(command) ?? [localCommandHelp()]).join("\n");
227
269
  }
@@ -244,6 +286,186 @@ async function runInstall(command, io) {
244
286
  writeLine(io.stdout, "Next: run `cockpit login`, then `cockpit start` inside the repo; add `--ticket <id>` only when ticket work begins.");
245
287
  return 0;
246
288
  }
289
+ async function runUpdate(command, io) {
290
+ const exec = io.exec ?? defaultExec();
291
+ const installArgs = [
292
+ "install",
293
+ "-g",
294
+ "@bli-cockpit/cli@latest",
295
+ "--prefer-online",
296
+ ];
297
+ if (!command.json) {
298
+ writeLine(io.stdout, "Updating Cockpit CLI from npm...");
299
+ }
300
+ const install = await exec("npm", installArgs);
301
+ writeExecOutput(io, install, { stdout: !command.json, stderr: true });
302
+ if (install.code !== 0) {
303
+ if (command.json) {
304
+ writeLine(io.stdout, JSON.stringify({
305
+ status: "blocked",
306
+ step: "npm_install",
307
+ command: `npm ${installArgs.join(" ")}`,
308
+ exit_code: install.code,
309
+ }, null, 2));
310
+ }
311
+ else {
312
+ writeLine(io.stderr, "BLOCKED: npm install failed; Cockpit CLI was not refreshed.");
313
+ }
314
+ return install.code || 1;
315
+ }
316
+ if (!command.json) {
317
+ writeLine(io.stdout, "Cockpit CLI updated. Rechecking onboarding...");
318
+ }
319
+ const onboard = await exec("cockpit", [
320
+ "onboard",
321
+ ...updateOnboardArgs(command),
322
+ ]);
323
+ writeExecOutput(io, onboard, { stdout: true, stderr: true });
324
+ return onboard.code;
325
+ }
326
+ async function runRelease(command, io) {
327
+ const releaseRoot = await findPublicReleaseRoot(process.cwd());
328
+ if (!releaseRoot) {
329
+ writeLine(io.stderr, "cockpit release must be run inside the bli-cockpit repo checkout (missing publish:public script).");
330
+ return 1;
331
+ }
332
+ const exec = io.exec ?? defaultExec();
333
+ const gitReady = await prepareReleaseMainBranch(releaseRoot, exec, io);
334
+ if (!gitReady)
335
+ return 1;
336
+ writeLine(io.stdout, "Running Cockpit public package release...");
337
+ const npmArgs = ["--prefix", releaseRoot, "run", "publish:public"];
338
+ if (command.args.length > 0)
339
+ npmArgs.push("--", ...command.args);
340
+ const releaseExec = io.interactiveExec ?? defaultInteractiveExec();
341
+ const result = await releaseExec("npm", npmArgs);
342
+ writeExecOutput(io, result, { stdout: true, stderr: true });
343
+ return result.code;
344
+ }
345
+ async function prepareReleaseMainBranch(releaseRoot, exec, io) {
346
+ const branch = await exec("git", [
347
+ "-C",
348
+ releaseRoot,
349
+ "rev-parse",
350
+ "--abbrev-ref",
351
+ "HEAD",
352
+ ]);
353
+ writeExecOutput(io, branch, { stdout: false, stderr: true });
354
+ if (branch.code !== 0) {
355
+ writeLine(io.stderr, "BLOCKED: cockpit release could not read the current git branch.");
356
+ return false;
357
+ }
358
+ const currentBranch = branch.stdout.trim();
359
+ if (currentBranch !== "main") {
360
+ writeLine(io.stderr, `BLOCKED: cockpit release only publishes from main. Current branch is ${currentBranch || "unknown"}.`);
361
+ writeLine(io.stderr, "Merge the release changes, switch to main, then rerun `cockpit release`.");
362
+ return false;
363
+ }
364
+ const status = await exec("git", [
365
+ "-C",
366
+ releaseRoot,
367
+ "status",
368
+ "--porcelain",
369
+ ]);
370
+ writeExecOutput(io, status, { stdout: false, stderr: true });
371
+ if (status.code !== 0) {
372
+ writeLine(io.stderr, "BLOCKED: cockpit release could not inspect git status.");
373
+ return false;
374
+ }
375
+ if (status.stdout.trim()) {
376
+ writeLine(io.stderr, "BLOCKED: cockpit release requires a clean main checkout.");
377
+ writeLine(io.stderr, "Commit or discard local changes, then rerun `cockpit release`.");
378
+ return false;
379
+ }
380
+ writeLine(io.stdout, "Syncing main with git pull --ff-only...");
381
+ const pull = await exec("git", ["-C", releaseRoot, "pull", "--ff-only"]);
382
+ writeExecOutput(io, pull, { stdout: true, stderr: true });
383
+ if (pull.code !== 0) {
384
+ writeLine(io.stderr, "BLOCKED: git pull --ff-only failed; main is not safely current.");
385
+ return false;
386
+ }
387
+ return true;
388
+ }
389
+ async function findPublicReleaseRoot(startDir) {
390
+ let current = path.resolve(startDir);
391
+ while (true) {
392
+ const packageJsonPath = path.join(current, "package.json");
393
+ try {
394
+ const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
395
+ if (packageJson.scripts?.["publish:public"] !== undefined)
396
+ return current;
397
+ }
398
+ catch {
399
+ // Keep walking: nested packages may be missing package.json or have one
400
+ // without the release script.
401
+ }
402
+ const parent = path.dirname(current);
403
+ if (parent === current)
404
+ return null;
405
+ current = parent;
406
+ }
407
+ }
408
+ function updateOnboardArgs(command) {
409
+ const args = [];
410
+ if (command.homeDir)
411
+ args.push("--home", command.homeDir);
412
+ for (const root of updateCollectionRoots(command)) {
413
+ args.push("--workspace", root);
414
+ }
415
+ if (command.dashboardUrlExplicit) {
416
+ args.push("--dashboard-url", command.dashboardUrl);
417
+ }
418
+ if (command.claimedOwnerEmail)
419
+ args.push("--email", command.claimedOwnerEmail);
420
+ if (command.deviceName)
421
+ args.push("--device-name", command.deviceName);
422
+ if (command.activeTicketId)
423
+ args.push("--ticket", command.activeTicketId);
424
+ if (command.branch)
425
+ args.push("--branch", command.branch);
426
+ if (command.pollIntervalMs !== undefined) {
427
+ args.push("--poll-interval-ms", String(command.pollIntervalMs));
428
+ }
429
+ if (command.timeoutMs !== undefined) {
430
+ args.push("--timeout-ms", String(command.timeoutMs));
431
+ }
432
+ if (command.maxDepth !== undefined)
433
+ args.push("--max-depth", String(command.maxDepth));
434
+ if (command.maxRepos !== undefined)
435
+ args.push("--max-repos", String(command.maxRepos));
436
+ if (command.json)
437
+ args.push("--json");
438
+ return args;
439
+ }
440
+ function updateCollectionRoots(command) {
441
+ const roots = command.collectionRoots?.length
442
+ ? command.collectionRoots
443
+ : command.repoRoot
444
+ ? [command.repoRoot]
445
+ : [];
446
+ const seen = new Set();
447
+ const deduped = [];
448
+ for (const root of roots) {
449
+ if (seen.has(root))
450
+ continue;
451
+ seen.add(root);
452
+ deduped.push(root);
453
+ }
454
+ return deduped;
455
+ }
456
+ function writeExecOutput(io, result, options) {
457
+ if (options.stdout)
458
+ writeRaw(io.stdout, result.stdout);
459
+ if (options.stderr)
460
+ writeRaw(io.stderr, result.stderr);
461
+ }
462
+ function writeRaw(stream, text) {
463
+ if (!text)
464
+ return;
465
+ stream.write(text);
466
+ if (!text.endsWith("\n"))
467
+ stream.write("\n");
468
+ }
247
469
  function isInteractiveStdin(io) {
248
470
  return Boolean(io.stdin.isTTY);
249
471
  }
@@ -965,6 +1187,7 @@ async function runStart(command, io) {
965
1187
  repoRoot: worktree.repo_root,
966
1188
  branch: command.branch,
967
1189
  activeTicketId: command.activeTicketId,
1190
+ clearTicket: command.clearTicket,
968
1191
  topicLabel: command.topicLabel,
969
1192
  topicSummaryRedacted: command.topicSummaryRedacted,
970
1193
  workIntent: command.workIntent,
@@ -1343,6 +1566,17 @@ function defaultExec() {
1343
1566
  });
1344
1567
  });
1345
1568
  }
1569
+ function defaultInteractiveExec() {
1570
+ return (cmd, args) => new Promise((resolve) => {
1571
+ const child = spawn(cmd, args, { stdio: "inherit" });
1572
+ child.on("error", (error) => {
1573
+ resolve({ code: 1, stdout: "", stderr: errorMessage(error) });
1574
+ });
1575
+ child.on("close", (code) => {
1576
+ resolve({ code: code ?? 1, stdout: "", stderr: "" });
1577
+ });
1578
+ });
1579
+ }
1346
1580
  function defaultIo() {
1347
1581
  if (!globalThis.fetch) {
1348
1582
  throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
@@ -1354,6 +1588,7 @@ function defaultIo() {
1354
1588
  env: process.env,
1355
1589
  fetch: globalThis.fetch.bind(globalThis),
1356
1590
  exec: defaultExec(),
1591
+ interactiveExec: defaultInteractiveExec(),
1357
1592
  };
1358
1593
  }
1359
1594
  function writeLine(stream, text) {
@@ -22,13 +22,15 @@ function cockpitHelp() {
22
22
  "Usage:",
23
23
  localCommandHelp(),
24
24
  "",
25
- "Install/update: `npm install -g @bli-cockpit/cli@latest`.",
25
+ "Install: `npm install -g @bli-cockpit/cli@latest`.",
26
+ "Update: run `cockpit update` to refresh the global CLI and rerun onboarding checks.",
26
27
  "Intern path: run `cockpit onboard`; it confirms a `/BLI` collection root before syncing.",
27
28
  "Headless/reused laptop path: `cockpit onboard --email <email> --workspace ~/BLI`.",
28
- "Already onboarded: rerun `cockpit onboard` from anywhere to refresh pairing, roots, agent rules, autostart, and sync.",
29
+ "Already onboarded: run `cockpit update` from anywhere to refresh pairing, roots, agent rules, autostart, and sync.",
29
30
  "Agent setup: `cockpit onboard` refreshes AGENTS.md/CLAUDE.md rules; use `cockpit agent-rules install --workspace ~/BLI` for repair.",
30
31
  "Dashboard URL is optional for normal production use; pass `--dashboard-url` only for staging/custom dashboards or forced re-pairing.",
31
32
  "Manual collector path: `install`, `login`, `start [--ticket <id>] [--topic <label>] [--intent <intent>] [--phase <phase>]`, `sync`, `status`, `agent-rules`.",
33
+ "Maintainer release path: merge to main first, then run `cockpit release --dry-run` and `cockpit release` from a clean main checkout.",
32
34
  ].join("\n");
33
35
  }
34
36
 
@@ -114,6 +114,7 @@ export async function runAttributedWorktreeSync(options) {
114
114
  homeDir: options.homeDir,
115
115
  repoRoot: worktree.repo_root,
116
116
  dashboardUrl: options.dashboardUrl,
117
+ worktreeInventory: worktreeInventoryForRepo(worktree, options.worktrees),
117
118
  codexSessionFiles: codexAttribution.results
118
119
  .filter((result) => result.state === "attributed" &&
119
120
  result.worktree?.worktree_fingerprint ===
@@ -510,6 +511,23 @@ function emptyClaudeScan() {
510
511
  },
511
512
  };
512
513
  }
514
+ function worktreeInventoryForRepo(current, worktrees) {
515
+ return worktrees
516
+ .filter((worktree) => worktree.repo_fingerprint
517
+ ? worktree.repo_fingerprint === current.repo_fingerprint
518
+ : worktree.repo_label === current.repo_label)
519
+ .map((worktree) => ({
520
+ repo: worktree.repo_root,
521
+ repo_label: worktree.repo_label,
522
+ repo_fingerprint: worktree.repo_fingerprint,
523
+ repo_origin_url: worktree.repo_origin_url ?? undefined,
524
+ head_sha: worktree.head_sha ?? undefined,
525
+ worktree_label: worktree.worktree_label,
526
+ worktree_fingerprint: worktree.worktree_fingerprint,
527
+ worktree_is_primary: worktree.worktree_is_primary,
528
+ branch: worktree.branch,
529
+ }));
530
+ }
513
531
  async function isClaudeCollectionEnabled(paths) {
514
532
  const config = await readLocalCollectorConfig(paths).catch(() => null);
515
533
  return config?.collect_claude_jsonl !== false;
@@ -4,7 +4,7 @@ import { readFileSync } from "node:fs";
4
4
  import fs from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
- import { resolveRepoWorktreeIdentity, } from "./repo-identity.js";
7
+ import { resolveRepoWorktreeIdentity, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity.js";
8
8
  import { normalizeCollectionRoots } from "./root-normalization.js";
9
9
  import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
10
10
  const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -138,6 +138,9 @@ export async function logoutLocalCollector(options = {}) {
138
138
  return { removed, session_file: paths.session_file };
139
139
  }
140
140
  export async function startLocalWorkContext(options = {}) {
141
+ if (options.activeTicketId && options.clearTicket) {
142
+ throw new Error("--ticket and --clear-ticket cannot be combined.");
143
+ }
141
144
  const now = options.now ?? new Date();
142
145
  const homeDir = options.homeDir ?? os.homedir();
143
146
  const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
@@ -161,6 +164,9 @@ export async function startLocalWorkContext(options = {}) {
161
164
  worktreeFingerprint: identity.worktree_fingerprint,
162
165
  });
163
166
  const existingContext = await readLocalWorkContextByFingerprint(paths, identity.worktree_fingerprint).catch(() => null);
167
+ const activeTicketId = options.clearTicket
168
+ ? undefined
169
+ : (options.activeTicketId ?? existingContext?.active_ticket_id);
164
170
  const ticketBindingCandidates = options.activeTicketId
165
171
  ? [
166
172
  {
@@ -170,7 +176,9 @@ export async function startLocalWorkContext(options = {}) {
170
176
  evidence_labels: ["cockpit_start_ticket"],
171
177
  },
172
178
  ]
173
- : [];
179
+ : options.clearTicket
180
+ ? []
181
+ : (existingContext?.ticket_binding_candidates ?? []);
174
182
  const context = LocalWorkContextSchema.parse({
175
183
  work_context_id: workContextId,
176
184
  repo: identity.repo_root,
@@ -186,7 +194,7 @@ export async function startLocalWorkContext(options = {}) {
186
194
  session_id: sessionId,
187
195
  started_at: existingContext?.started_at ?? now.toISOString(),
188
196
  updated_at: now.toISOString(),
189
- active_ticket_id: options.activeTicketId ?? undefined,
197
+ active_ticket_id: activeTicketId,
190
198
  ticket_binding_candidates: ticketBindingCandidates,
191
199
  topic_label: options.topicLabel,
192
200
  topic_summary_redacted: options.topicSummaryRedacted,
@@ -229,8 +237,8 @@ export async function inspectLocalCollectorStatus(options = {}) {
229
237
  const identity = await resolveIdentityOrFallback(repoRoot, options.branch);
230
238
  const context = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => null);
231
239
  const branch = options.branch ?? identity.branch;
232
- const freshness = classifyCollectorFreshness(context, now);
233
240
  const uploadSpool = await summarizeLocalUploadSpool(paths);
241
+ const freshness = classifyCollectorFreshness(context, uploadSpool.last_upload_success_at, now);
234
242
  const uploadState = !config
235
243
  ? "not_installed"
236
244
  : uploadSpool.pending_upload_count > 0
@@ -381,14 +389,14 @@ function workContextFile(paths, worktreeFingerprint) {
381
389
  return path.join(paths.work_contexts_dir, `${worktreeFingerprint}.json`);
382
390
  }
383
391
  async function resolveIdentityOrFallback(repoRoot, branchOverride) {
384
- const resolvedRoot = path.resolve(repoRoot);
392
+ const resolvedRoot = await stableWorktreeRoot(repoRoot);
385
393
  const identity = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
386
394
  if (identity) {
387
395
  return branchOverride ? { ...identity, branch: branchOverride } : identity;
388
396
  }
389
397
  const repoLabel = path.basename(resolvedRoot) || "workspace";
390
398
  const repoFingerprint = `repo-${sha256(`local:${resolvedRoot}`).slice(0, 24)}`;
391
- const worktreeFingerprint = `wt-${sha256(`${repoFingerprint}:${resolvedRoot}`).slice(0, 24)}`;
399
+ const worktreeFingerprint = stableWorktreeFingerprint(resolvedRoot);
392
400
  const branch = branchOverride ?? (await resolveGitBranch(resolvedRoot));
393
401
  return {
394
402
  requested_path: resolvedRoot,
@@ -426,13 +434,24 @@ async function writeJsonFile(filePath, value) {
426
434
  await fs.chmod(filePath, 0o600).catch(() => undefined);
427
435
  }
428
436
  }
429
- function classifyCollectorFreshness(context, now) {
437
+ export function classifyCollectorFreshness(context, lastUploadSuccessAt, now) {
430
438
  if (!context)
431
439
  return "missing";
432
- const updatedAt = Date.parse(context.updated_at ?? context.started_at);
433
- if (!Number.isFinite(updatedAt))
440
+ const latestActivityAt = latestTimestamp([
441
+ context.updated_at ?? context.started_at,
442
+ lastUploadSuccessAt,
443
+ ]);
444
+ if (latestActivityAt === null)
434
445
  return "stale";
435
- return now.getTime() - updatedAt <= 5 * 60 * 1000 ? "fresh" : "stale";
446
+ return now.getTime() - latestActivityAt <= 5 * 60 * 1000 ? "fresh" : "stale";
447
+ }
448
+ function latestTimestamp(values) {
449
+ const timestamps = values
450
+ .map((value) => Date.parse(value ?? ""))
451
+ .filter((value) => Number.isFinite(value));
452
+ if (timestamps.length === 0)
453
+ return null;
454
+ return Math.max(...timestamps);
436
455
  }
437
456
  function normalizeDashboardUrl(value) {
438
457
  const normalized = value.trim().replace(/\/+$/, "");
@@ -18,7 +18,7 @@ const SKIPPED_DIR_NAMES = new Set([
18
18
  export async function resolveRepoWorktreeIdentity(repoRoot) {
19
19
  const requestedPath = path.resolve(repoRoot);
20
20
  const gitRoot = await runGit(["rev-parse", "--show-toplevel"], requestedPath);
21
- const resolvedRoot = path.resolve(gitRoot.trim() || requestedPath);
21
+ const resolvedRoot = await stableWorktreeRoot(gitRoot.trim() || requestedPath);
22
22
  const branch = await resolveGitBranchWithGit(resolvedRoot);
23
23
  const headSha = await runGit(["rev-parse", "HEAD"], resolvedRoot).then((value) => value.trim() || null, () => null);
24
24
  const absoluteGitDir = await runGit(["rev-parse", "--absolute-git-dir"], resolvedRoot)
@@ -36,11 +36,6 @@ export async function resolveRepoWorktreeIdentity(repoRoot) {
36
36
  ? `origin:${repoOriginUrl}`
37
37
  : `local:${sha256(commonGitDir ?? resolvedRoot)}`;
38
38
  const repoFingerprint = `repo-${sha256(repoMaterial).slice(0, 24)}`;
39
- const worktreeMaterial = [
40
- repoFingerprint,
41
- resolvedRoot,
42
- commonGitDir ?? "",
43
- ].join("\n");
44
39
  const gitFilePath = path.join(resolvedRoot, ".git");
45
40
  const worktreeIsPrimary = await fs.stat(gitFilePath).then((stat) => stat.isDirectory(), () => false);
46
41
  return {
@@ -52,7 +47,7 @@ export async function resolveRepoWorktreeIdentity(repoRoot) {
52
47
  branch,
53
48
  head_sha: headSha,
54
49
  worktree_label: path.basename(resolvedRoot),
55
- worktree_fingerprint: `wt-${sha256(worktreeMaterial).slice(0, 24)}`,
50
+ worktree_fingerprint: stableWorktreeFingerprint(resolvedRoot),
56
51
  worktree_is_primary: worktreeIsPrimary,
57
52
  };
58
53
  }
@@ -144,10 +139,9 @@ async function hasGitMarker(dir) {
144
139
  return fs.stat(path.join(dir, ".git")).then((stat) => stat.isDirectory() || stat.isFile(), () => false);
145
140
  }
146
141
  async function fallbackFilesystemIdentity(repoRoot) {
147
- const resolvedRoot = path.resolve(repoRoot);
142
+ const resolvedRoot = await stableWorktreeRoot(repoRoot);
148
143
  const repoLabel = path.basename(resolvedRoot) || "repo";
149
144
  const repoFingerprint = `repo-${sha256(`local:${resolvedRoot}`).slice(0, 24)}`;
150
- const worktreeFingerprint = `wt-${sha256(`${repoFingerprint}:${resolvedRoot}`).slice(0, 24)}`;
151
145
  return {
152
146
  requested_path: resolvedRoot,
153
147
  repo_root: resolvedRoot,
@@ -157,10 +151,17 @@ async function fallbackFilesystemIdentity(repoRoot) {
157
151
  branch: await resolveBranchFromHead(resolvedRoot),
158
152
  head_sha: null,
159
153
  worktree_label: repoLabel,
160
- worktree_fingerprint: worktreeFingerprint,
154
+ worktree_fingerprint: stableWorktreeFingerprint(resolvedRoot),
161
155
  worktree_is_primary: true,
162
156
  };
163
157
  }
158
+ export async function stableWorktreeRoot(repoRoot) {
159
+ const resolvedRoot = path.resolve(repoRoot);
160
+ return fs.realpath(resolvedRoot).catch(() => resolvedRoot);
161
+ }
162
+ export function stableWorktreeFingerprint(repoRoot) {
163
+ return `wt-${sha256(`worktree:${normalizeFingerprintPath(repoRoot)}`).slice(0, 24)}`;
164
+ }
164
165
  async function resolveBranchFromHead(repoRoot) {
165
166
  try {
166
167
  const gitPath = path.join(repoRoot, ".git");
@@ -244,6 +245,10 @@ async function runGit(args, cwd) {
244
245
  });
245
246
  return stdout;
246
247
  }
248
+ function normalizeFingerprintPath(repoRoot) {
249
+ const resolved = path.resolve(repoRoot);
250
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
251
+ }
247
252
  function sha256(value) {
248
253
  return crypto.createHash("sha256").update(value, "utf8").digest("hex");
249
254
  }
package/dist/upload.js CHANGED
@@ -101,6 +101,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
101
101
  collector_version: LOCAL_COLLECTOR_VERSION,
102
102
  session_reference: sanitizeSessionReference(session),
103
103
  work_context: uploadWorkContext,
104
+ worktree_inventory: options.worktreeInventory ?? [],
104
105
  source_scan_results: sanitizeSourceScanResults(sourceCollection.scans, repoLabel),
105
106
  events,
106
107
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,6 @@
26
26
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
27
  },
28
28
  "dependencies": {
29
- "@bli-cockpit/telemetry-core": "0.1.8"
29
+ "@bli-cockpit/telemetry-core": "0.1.9"
30
30
  }
31
31
  }