@bli-cockpit/cli 0.1.28 → 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
@@ -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:
@@ -206,6 +206,7 @@ function parseStartArgs(args) {
206
206
  "--workspace",
207
207
  "--branch",
208
208
  "--ticket",
209
+ "--clear-ticket",
209
210
  "--topic",
210
211
  "--topic-summary",
211
212
  "--intent",
@@ -237,6 +238,11 @@ function parseStartArgs(args) {
237
238
  ],
238
239
  });
239
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
+ }
240
246
  const topicLabel = optionalNonEmpty(values.flags.get("--topic"));
241
247
  const topicSummaryRedacted = optionalNonEmpty(values.flags.get("--topic-summary"));
242
248
  const workIntent = optionalSchemaValue(WorkIntentSchema, values.flags.get("--intent"), "--intent");
@@ -252,7 +258,8 @@ function parseStartArgs(args) {
252
258
  homeDir: optionalNonEmpty(values.flags.get("--home")),
253
259
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
254
260
  branch: optionalNonEmpty(values.flags.get("--branch")),
255
- activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
261
+ activeTicketId,
262
+ clearTicket,
256
263
  topicLabel,
257
264
  topicSummaryRedacted,
258
265
  workIntent,
@@ -91,7 +91,7 @@ export function localCommandHelp(command) {
91
91
  " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
92
92
  " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
93
93
  " cockpit logout",
94
- " 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]",
95
95
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
96
96
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
97
97
  " cockpit sessions [--source codex|claude] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
@@ -169,10 +169,11 @@ function localSubcommandHelp(command) {
169
169
  [
170
170
  "start",
171
171
  [
172
- "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]",
173
173
  "",
174
174
  "Starts local ambient capture. Parent folders start each child git worktree.",
175
- "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.",
176
177
  "Use --topic/--intent/--phase for planning, discovery, and learning work that has no ticket yet.",
177
178
  "Supported intents: implementation, bug_fix, root_cause_analysis, planning, discovery, review, testing, documentation, release, learning, coordination, maintenance, analysis, unknown, other.",
178
179
  "Supported phases: planning, discovery, implementation, debugging, review, testing, documentation, release, handoff, analysis, unknown, other.",
@@ -1186,6 +1187,7 @@ async function runStart(command, io) {
1186
1187
  repoRoot: worktree.repo_root,
1187
1188
  branch: command.branch,
1188
1189
  activeTicketId: command.activeTicketId,
1190
+ clearTicket: command.clearTicket,
1189
1191
  topicLabel: command.topicLabel,
1190
1192
  topicSummaryRedacted: command.topicSummaryRedacted,
1191
1193
  workIntent: command.workIntent,
@@ -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.28",
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
  }