@bli-cockpit/cli 0.2.49 → 0.2.51

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.
Files changed (57) hide show
  1. package/dist/adapters/raw-evidence-claude-reader.js +108 -0
  2. package/dist/adapters/raw-evidence-codex-reader.js +147 -0
  3. package/dist/adapters/raw-evidence-collection-state.js +199 -0
  4. package/dist/adapters/raw-evidence-facts.js +338 -0
  5. package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
  6. package/dist/adapters/raw-evidence-image-reader.js +107 -0
  7. package/dist/adapters/raw-evidence-sanitize.js +56 -0
  8. package/dist/adapters/raw-evidence-transcript-file.js +182 -0
  9. package/dist/adapters/raw-evidence.js +63 -1183
  10. package/dist/commands/backfill-batches.js +34 -0
  11. package/dist/commands/backfill-candidates.js +54 -0
  12. package/dist/commands/backfill-checkpoint.js +101 -0
  13. package/dist/commands/backfill-command-line.js +70 -0
  14. package/dist/commands/backfill-evidence-outcomes.js +104 -0
  15. package/dist/commands/backfill-issues.js +265 -0
  16. package/dist/commands/backfill-output.js +75 -0
  17. package/dist/commands/backfill-plan.js +71 -0
  18. package/dist/commands/backfill-reasons.js +107 -0
  19. package/dist/commands/backfill-report.js +298 -0
  20. package/dist/commands/backfill-result.js +150 -0
  21. package/dist/commands/backfill-scan.js +274 -0
  22. package/dist/commands/backfill-scope.js +114 -0
  23. package/dist/commands/backfill-session-report.js +145 -0
  24. package/dist/commands/backfill-types.js +1 -0
  25. package/dist/commands/backfill-upload.js +212 -0
  26. package/dist/commands/backfill.js +41 -1961
  27. package/dist/commands/doctor.js +57 -0
  28. package/dist/commands/jarvis-trace.js +184 -0
  29. package/dist/commands/jarvis.js +144 -4
  30. package/dist/commands/local-args-collector.js +26 -0
  31. package/dist/commands/local-args-tower.js +21 -0
  32. package/dist/commands/local-args.js +3 -1
  33. package/dist/commands/local-help.js +19 -2
  34. package/dist/commands/local.js +3 -0
  35. package/dist/commands/memory-install-claude.js +294 -0
  36. package/dist/commands/memory-install-codex.js +205 -0
  37. package/dist/commands/memory-install-contract.js +286 -0
  38. package/dist/commands/memory-install-files.js +63 -0
  39. package/dist/commands/memory-install-skills.js +121 -0
  40. package/dist/commands/memory-install-toml.js +265 -0
  41. package/dist/commands/memory-install.js +465 -0
  42. package/dist/commands/public-root.js +1 -1
  43. package/dist/commands/sync-followups.js +105 -0
  44. package/dist/commands/sync.js +7 -1
  45. package/dist/local-state-attributed-target.js +75 -0
  46. package/dist/local-state-config.js +147 -0
  47. package/dist/local-state-files.js +59 -0
  48. package/dist/local-state-identity.js +73 -0
  49. package/dist/local-state-pairing.js +263 -0
  50. package/dist/local-state-paths.js +61 -0
  51. package/dist/local-state-session.js +68 -0
  52. package/dist/local-state-status.js +163 -0
  53. package/dist/local-state-work-context.js +190 -0
  54. package/dist/local-state.js +34 -848
  55. package/dist/tower-client.js +3 -2
  56. package/dist/tower-stream.js +57 -3
  57. package/package.json +2 -1
@@ -0,0 +1,68 @@
1
+ import { LocalCollectorSessionFileSchema, LocalUserSessionReferenceSchema, } from "@bli-cockpit/telemetry-core";
2
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
3
+ import { readJsonFile } from "./local-state-files.js";
4
+ /**
5
+ * Who this machine is signed in as, in the shape every caller wants: a
6
+ * reference that always exists, so nothing downstream has to handle "there is
7
+ * no session file yet" as an error.
8
+ */
9
+ export async function readLocalSessionReference(paths, fallback = {}) {
10
+ try {
11
+ const rawSession = await readJsonFile(paths.session_file);
12
+ const collectorSession = LocalCollectorSessionFileSchema.safeParse(rawSession);
13
+ if (collectorSession.success) {
14
+ return toSessionReference(collectorSession.data);
15
+ }
16
+ return LocalUserSessionReferenceSchema.parse(rawSession);
17
+ }
18
+ catch (error) {
19
+ // `session_state: "missing"` is correct before login and says so loudly
20
+ // enough on its own. It is also what a session file that EXISTS but is
21
+ // corrupt, truncated or unreadable collapses to — a paired machine that
22
+ // silently reads as never-signed-in, which is indistinguishable in every
23
+ // downstream receipt (BLI-3238).
24
+ if (!isMissingFileFailure(error)) {
25
+ console.error("[local-state] session file present but unusable, reading as missing", JSON.stringify({
26
+ reason: "session_file_unusable",
27
+ ...describeError(error),
28
+ }));
29
+ }
30
+ return LocalUserSessionReferenceSchema.parse({
31
+ operator_id: fallback.operatorId ?? "unknown",
32
+ auth_subject_id: "unknown",
33
+ session_id: fallback.sessionId ?? "missing",
34
+ session_file_path: paths.session_file,
35
+ session_state: "missing",
36
+ });
37
+ }
38
+ }
39
+ /**
40
+ * The strict read, for the callers that need the device token itself and
41
+ * cannot proceed on a "missing" reference.
42
+ */
43
+ export async function readLocalCollectorSessionFile(paths) {
44
+ return LocalCollectorSessionFileSchema.parse(await readJsonFile(paths.session_file));
45
+ }
46
+ /**
47
+ * Drops the device token and re-states expiry as a session state, so an
48
+ * expired session cannot travel as `valid` just because the file says so.
49
+ */
50
+ export function toSessionReference(session) {
51
+ const now = Date.now();
52
+ const expiresAt = Date.parse(session.expires_at ?? "");
53
+ const sessionState = Number.isFinite(expiresAt) && expiresAt <= now ? "expired" : session.session_state;
54
+ return LocalUserSessionReferenceSchema.parse({
55
+ operator_id: session.operator_id,
56
+ auth_subject_id: session.auth_subject_id,
57
+ email: session.email,
58
+ team_id: session.team_id,
59
+ device_id: session.device_id,
60
+ device_name: session.device_name,
61
+ session_id: session.session_id,
62
+ session_file_path: session.session_file_path,
63
+ session_state: sessionState,
64
+ auth_method: session.auth_method,
65
+ issued_at: session.issued_at,
66
+ expires_at: session.expires_at,
67
+ });
68
+ }
@@ -0,0 +1,163 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
4
+ import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
5
+ import { readRawEvidenceStagingState, summarizeStuckEvidence, } from "./raw-evidence-staging.js";
6
+ import { getCollectorRuntimePaths, } from "./local-state-paths.js";
7
+ import { LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, } from "./local-state-config.js";
8
+ import { readLocalSessionReference } from "./local-state-session.js";
9
+ import { resolveIdentityOrFallback } from "./local-state-identity.js";
10
+ import { readLocalWorkContextForRepo, workDisplayLabel, } from "./local-state-work-context.js";
11
+ /**
12
+ * `cockpit status` — the one answer to "is this machine collecting, and is
13
+ * anything landing?". A green status must never be able to hide a session that
14
+ * never arrived, so every queue and every stuck object is reported here.
15
+ */
16
+ export async function inspectLocalCollectorStatus(options = {}) {
17
+ const now = options.now ?? new Date();
18
+ const reading = await readCollectorStatus(options, now);
19
+ const { paths, config, session, identity, context, branch } = reading;
20
+ const { uploadSpool, healthOutbox, stuckEvidence } = reading;
21
+ const freshness = classifyCollectorFreshness(context, uploadSpool.last_upload_success_at, now);
22
+ const uploadState = classifyUploadState(config, uploadSpool, session);
23
+ return {
24
+ installed: Boolean(config),
25
+ config_file: paths.config_file,
26
+ session_file: paths.session_file,
27
+ collector_version: LOCAL_COLLECTOR_VERSION,
28
+ config_collector_version: config?.collector_version ?? null,
29
+ session_state: session.session_state,
30
+ repo: context?.repo ?? identity.repo_root,
31
+ branch,
32
+ active_ticket_id: context?.active_ticket_id ?? null,
33
+ work_label: context ? workDisplayLabel(context) : null,
34
+ work_id: context?.work_context_id ?? null,
35
+ work_context_id: context?.work_context_id ?? null,
36
+ repo_label: context?.repo_label ?? identity.repo_label,
37
+ repo_fingerprint: context?.repo_fingerprint ?? identity.repo_fingerprint,
38
+ worktree_label: context?.worktree_label ?? identity.worktree_label,
39
+ worktree_fingerprint: context?.worktree_fingerprint ?? identity.worktree_fingerprint,
40
+ worktree_is_primary: context?.worktree_is_primary ?? identity.worktree_is_primary,
41
+ collector_freshness: freshness,
42
+ upload_state: uploadState,
43
+ last_upload_attempt_at: uploadSpool.last_upload_attempt_at,
44
+ last_upload_success_at: uploadSpool.last_upload_success_at,
45
+ last_upload_failure_reason: uploadSpool.last_upload_failure_reason,
46
+ pending_upload_count: uploadSpool.pending_upload_count,
47
+ upload_retry_command: uploadSpool.retry_command,
48
+ pending_health_receipt_count: healthOutbox.pending_count,
49
+ oldest_pending_health_receipt_at: healthOutbox.oldest_created_at,
50
+ last_health_receipt_failure_reason: healthOutbox.last_failure_reason,
51
+ stuck_evidence_object_count: stuckEvidence.stuck_object_count,
52
+ stuck_evidence_held_count: stuckEvidence.held_object_count,
53
+ stuck_evidence_max_attempts: stuckEvidence.max_attempts,
54
+ stuck_evidence_oldest_failure_at: stuckEvidence.oldest_first_failed_at,
55
+ stuck_evidence_reasons: stuckEvidence.reasons,
56
+ details: describeCollectorStatus(reading),
57
+ };
58
+ }
59
+ async function readCollectorStatus(options, now) {
60
+ const homeDir = options.homeDir ?? os.homedir();
61
+ const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
62
+ const paths = getCollectorRuntimePaths(homeDir);
63
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
64
+ const session = await readLocalSessionReference(paths, {
65
+ operatorId: options.operatorId,
66
+ sessionId: options.sessionId,
67
+ });
68
+ const identity = await resolveIdentityOrFallback(repoRoot, options.branch);
69
+ const context = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => null);
70
+ const branch = options.branch ?? identity.branch;
71
+ const [uploadSpool, healthOutbox, stagingState] = await Promise.all([
72
+ summarizeLocalUploadSpool(paths),
73
+ summarizeInstallEventOutbox(paths),
74
+ readRawEvidenceStagingState(paths.state_dir),
75
+ ]);
76
+ const stuckEvidence = summarizeStuckEvidence(stagingState, now);
77
+ return {
78
+ paths,
79
+ config,
80
+ session,
81
+ identity,
82
+ context,
83
+ branch,
84
+ uploadSpool,
85
+ healthOutbox,
86
+ stuckEvidence,
87
+ };
88
+ }
89
+ // Not installed outranks everything, and a pending retry outranks a valid
90
+ // session. The return type is inferred: the four labels are already written
91
+ // once in `LocalCollectorStatus` and once here, and a third copy would be a
92
+ // third place to forget one.
93
+ function classifyUploadState(config, uploadSpool, session) {
94
+ return !config
95
+ ? "not_installed"
96
+ : uploadSpool.pending_upload_count > 0
97
+ ? "retry_pending"
98
+ : session.session_state === "valid"
99
+ ? "ready"
100
+ : "local_only_missing_auth";
101
+ }
102
+ /**
103
+ * The sentences an operator actually reads. Each of the first five is stated
104
+ * either way round, because a line that only appears on failure cannot answer
105
+ * "did anything land at all today?"; the last four appear only when there is a
106
+ * queue, and each names its own reason and count.
107
+ */
108
+ function describeCollectorStatus(reading) {
109
+ const { paths, config, session, context, uploadSpool, healthOutbox, stuckEvidence } = reading;
110
+ const details = [];
111
+ details.push(config
112
+ ? `Config exists at ${paths.config_file}.`
113
+ : "Local config missing. Run `cockpit install`.");
114
+ details.push(session.session_state === "valid"
115
+ ? "Local user session is valid."
116
+ : "Local user session missing or not paired; upload remains local-only.");
117
+ details.push(context
118
+ ? `Active work ${workDisplayLabel(context)} (${context.work_context_id}) last updated ${context.updated_at ?? context.started_at}.`
119
+ : "Active work context missing. Run `cockpit start`.");
120
+ details.push(uploadSpool.last_upload_attempt_at
121
+ ? `Last upload attempt: ${uploadSpool.last_upload_attempt_at}.`
122
+ : "No upload has been attempted yet.");
123
+ details.push(uploadSpool.last_upload_success_at
124
+ ? `Last upload success: ${uploadSpool.last_upload_success_at}.`
125
+ : "No successful upload recorded yet.");
126
+ if (uploadSpool.last_upload_failure_reason) {
127
+ details.push(`Last upload failure: ${uploadSpool.last_upload_failure_reason}.`);
128
+ }
129
+ if (uploadSpool.pending_upload_count > 0) {
130
+ details.push(`Upload retry pending: ${uploadSpool.pending_upload_count} safe metadata record(s) spooled. Run \`${uploadSpool.retry_command ?? "cockpit sync"}\` to retry.`);
131
+ }
132
+ if (healthOutbox.pending_count > 0) {
133
+ details.push(`Collector health retry pending: ${healthOutbox.pending_count} sanitized receipt(s) queued since ${healthOutbox.oldest_created_at ?? "unknown"}.`);
134
+ }
135
+ if (stuckEvidence.stuck_object_count > 0) {
136
+ details.push(`Raw evidence stuck: ${stuckEvidence.stuck_object_count} object(s) have never been accepted (${stuckEvidence.held_object_count} waiting on backoff, worst ${stuckEvidence.max_attempts} attempt(s) since ${stuckEvidence.oldest_first_failed_at ?? "unknown"}) — reasons: ${stuckEvidence.reasons.join(", ") || "unknown"}.`);
137
+ }
138
+ return details;
139
+ }
140
+ /**
141
+ * Whether this machine looks alive right now. A successful upload counts as
142
+ * activity as much as a context update does, so a long session that is still
143
+ * shipping data does not read as stale.
144
+ */
145
+ export function classifyCollectorFreshness(context, lastUploadSuccessAt, now) {
146
+ if (!context)
147
+ return "missing";
148
+ const latestActivityAt = latestTimestamp([
149
+ context.updated_at ?? context.started_at,
150
+ lastUploadSuccessAt,
151
+ ]);
152
+ if (latestActivityAt === null)
153
+ return "stale";
154
+ return now.getTime() - latestActivityAt <= 5 * 60 * 1000 ? "fresh" : "stale";
155
+ }
156
+ function latestTimestamp(values) {
157
+ const timestamps = values
158
+ .map((value) => Date.parse(value ?? ""))
159
+ .filter((value) => Number.isFinite(value));
160
+ if (timestamps.length === 0)
161
+ return null;
162
+ return Math.max(...timestamps);
163
+ }
@@ -0,0 +1,190 @@
1
+ import { LocalWorkContextSchema, } from "@bli-cockpit/telemetry-core";
2
+ import crypto from "node:crypto";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { ensureRuntimeDirectories, getCollectorRuntimePaths, workContextFile, } from "./local-state-paths.js";
6
+ import { readJsonFile, writeJsonFile } from "./local-state-files.js";
7
+ import { LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, } from "./local-state-config.js";
8
+ import { readLocalSessionReference } from "./local-state-session.js";
9
+ import { resolveIdentityOrFallback } from "./local-state-identity.js";
10
+ import { validateAttributedTargetIdentity } from "./local-state-attributed-target.js";
11
+ /** What `cockpit start` binds: the repo, branch and ticket this session's work belongs to. */
12
+ export async function startLocalWorkContext(options = {}) {
13
+ return writeLocalWorkContext(options);
14
+ }
15
+ /**
16
+ * Starts a context for a transcript-attributed sync target that does not exist
17
+ * in the current git inventory, such as an approved wrapper folder or a
18
+ * deleted repo reconstructed from transcript provenance. This internal path
19
+ * validates the exact target identity before writing any context state.
20
+ */
21
+ export async function startLocalWorkContextForAttributedTarget(options, attributedIdentity) {
22
+ const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
23
+ const identity = await validateAttributedTargetIdentity(repoRoot, attributedIdentity);
24
+ return writeLocalWorkContext(options, identity);
25
+ }
26
+ async function writeLocalWorkContext(options, attributedIdentity) {
27
+ if (options.activeTicketId && options.clearTicket) {
28
+ throw new Error("--ticket and --clear-ticket cannot be combined.");
29
+ }
30
+ const now = options.now ?? new Date();
31
+ const homeDir = options.homeDir ?? os.homedir();
32
+ const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
33
+ const paths = getCollectorRuntimePaths(homeDir);
34
+ await ensureRuntimeDirectories(paths);
35
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
36
+ const session = await readLocalSessionReference(paths, {
37
+ operatorId: options.operatorId,
38
+ sessionId: options.sessionId,
39
+ });
40
+ const identity = attributedIdentity ??
41
+ (await resolveIdentityOrFallback(repoRoot, options.branch));
42
+ const branch = attributedIdentity
43
+ ? attributedIdentity.branch
44
+ : (options.branch ?? identity.branch);
45
+ const sessionId = options.sessionId ??
46
+ (session.session_state === "missing" ? `local-${crypto.randomUUID()}` : session.session_id);
47
+ const operatorId = options.operatorId ?? session.operator_id;
48
+ const deviceId = session.device_id ?? config?.device_id ?? "device-unknown";
49
+ const workContextId = stableWorkContextId({
50
+ operatorId,
51
+ deviceId,
52
+ repoFingerprint: identity.repo_fingerprint,
53
+ worktreeFingerprint: identity.worktree_fingerprint,
54
+ });
55
+ const existingContext = await readLocalWorkContextByFingerprint(paths, identity.worktree_fingerprint).catch(() => null);
56
+ const context = buildWorkContext({
57
+ options,
58
+ now,
59
+ identity,
60
+ branch,
61
+ operatorId,
62
+ sessionId,
63
+ workContextId,
64
+ existingContext,
65
+ });
66
+ await writeJsonFile(workContextFile(paths, identity.worktree_fingerprint), context);
67
+ await writeJsonFile(paths.active_work_context_file, context);
68
+ return context;
69
+ }
70
+ /**
71
+ * The context record itself, kept in one piece because it is a schema: every
72
+ * field an ambient envelope will later carry as provenance is decided here.
73
+ */
74
+ function buildWorkContext(input) {
75
+ const { options, now, identity, branch, operatorId, sessionId, workContextId, existingContext } = input;
76
+ const { activeTicketId, ticketBindingCandidates } = resolveTicketBinding(options, existingContext);
77
+ return LocalWorkContextSchema.parse({
78
+ work_context_id: workContextId,
79
+ repo: identity.repo_root,
80
+ branch,
81
+ repo_label: identity.repo_label,
82
+ repo_fingerprint: identity.repo_fingerprint,
83
+ repo_origin_url: identity.repo_origin_url ?? undefined,
84
+ head_sha: identity.head_sha ?? undefined,
85
+ worktree_label: identity.worktree_label,
86
+ worktree_fingerprint: identity.worktree_fingerprint,
87
+ worktree_is_primary: identity.worktree_is_primary,
88
+ operator_id: operatorId,
89
+ session_id: sessionId,
90
+ started_at: existingContext?.started_at ?? now.toISOString(),
91
+ updated_at: now.toISOString(),
92
+ active_ticket_id: activeTicketId,
93
+ ticket_binding_candidates: ticketBindingCandidates,
94
+ topic_label: options.topicLabel,
95
+ topic_summary_redacted: options.topicSummaryRedacted,
96
+ work_intent: options.workIntent,
97
+ work_phase: options.workPhase,
98
+ intent_source: options.intentSource,
99
+ intent_confidence: options.intentConfidence,
100
+ pull_request_url: existingContext?.pull_request_url,
101
+ provenance: {
102
+ capture_source: "collector_runtime",
103
+ capture_adapter_version: LOCAL_COLLECTOR_VERSION,
104
+ collector_version: LOCAL_COLLECTOR_VERSION,
105
+ repo: identity.repo_root,
106
+ branch,
107
+ repo_label: identity.repo_label,
108
+ repo_fingerprint: identity.repo_fingerprint,
109
+ repo_origin_url: identity.repo_origin_url ?? undefined,
110
+ worktree_label: identity.worktree_label,
111
+ worktree_fingerprint: identity.worktree_fingerprint,
112
+ worktree_is_primary: identity.worktree_is_primary,
113
+ operator_id: operatorId,
114
+ session_id: sessionId,
115
+ work_context_id: workContextId,
116
+ },
117
+ });
118
+ }
119
+ /**
120
+ * Three answers, not two: a named ticket binds, `--clear-ticket` unbinds, and
121
+ * saying neither leaves whatever the previous `cockpit start` bound in place.
122
+ */
123
+ // The return type is inferred deliberately: naming it would mean indexing the
124
+ // context schema by field name, and this module keeps its literals to the ones
125
+ // that reach disk.
126
+ function resolveTicketBinding(options, existingContext) {
127
+ const activeTicketId = options.clearTicket
128
+ ? undefined
129
+ : (options.activeTicketId ?? existingContext?.active_ticket_id);
130
+ const ticketBindingCandidates = options.activeTicketId
131
+ ? [
132
+ {
133
+ ticket_id: options.activeTicketId,
134
+ binding_source: "active_work_context",
135
+ confidence: 1,
136
+ evidence_labels: ["cockpit_start_ticket"],
137
+ },
138
+ ]
139
+ : options.clearTicket
140
+ ? []
141
+ : (existingContext?.ticket_binding_candidates ?? []);
142
+ return { activeTicketId, ticketBindingCandidates };
143
+ }
144
+ /** The active context: what the last `cockpit start` on this machine bound, whatever the folder. */
145
+ export async function readLocalWorkContext(paths) {
146
+ return LocalWorkContextSchema.parse(await readJsonFile(paths.active_work_context_file));
147
+ }
148
+ /**
149
+ * The context for ONE repo, because a machine running two checkouts at once
150
+ * would otherwise attribute both to whichever was started last.
151
+ */
152
+ export async function readLocalWorkContextForRepo(paths, repoRoot) {
153
+ const identity = await resolveIdentityOrFallback(repoRoot);
154
+ const context = await readLocalWorkContextByFingerprint(paths, identity.worktree_fingerprint).catch(() => null);
155
+ if (context)
156
+ return context;
157
+ const active = await readLocalWorkContext(paths);
158
+ if (active.worktree_fingerprint === identity.worktree_fingerprint ||
159
+ path.resolve(active.repo) === identity.repo_root) {
160
+ return active;
161
+ }
162
+ throw new Error(`Active work context missing for ${identity.worktree_label}. Run \`cockpit start --workspace "${identity.repo_root}"\`.`);
163
+ }
164
+ async function readLocalWorkContextByFingerprint(paths, worktreeFingerprint) {
165
+ return LocalWorkContextSchema.parse(await readJsonFile(workContextFile(paths, worktreeFingerprint)));
166
+ }
167
+ /**
168
+ * The same operator, device, repo and worktree must produce the same id on
169
+ * every run, because the ambient envelope joins sessions on it.
170
+ */
171
+ function stableWorkContextId(input) {
172
+ return `work-${sha256([
173
+ input.operatorId,
174
+ input.deviceId,
175
+ input.repoFingerprint,
176
+ input.worktreeFingerprint,
177
+ ].join(":")).slice(0, 32)}`;
178
+ }
179
+ function sha256(value) {
180
+ return crypto.createHash("sha256").update(value, "utf8").digest("hex");
181
+ }
182
+ /**
183
+ * The label a person reads for a work context — repo over worktree, so two
184
+ * checkouts of one repo are told apart at a glance.
185
+ */
186
+ export function workDisplayLabel(context) {
187
+ const repoLabel = (context.repo_label ?? path.basename(context.repo)) || "workspace";
188
+ const worktreeLabel = context.worktree_label ?? repoLabel;
189
+ return `${repoLabel}/${worktreeLabel}`;
190
+ }