@bli-cockpit/cli 0.2.63 → 0.2.65

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.
@@ -25,6 +25,7 @@
25
25
  * (`agent-door.ts`) — `issue_not_found_or_unreadable`, `invalid_state`,
26
26
  * `needs_rls_client`, `comment_too_long`, and so on.
27
27
  */
28
+ import { mirroredTicketIdInTitle } from "@bli-cockpit/telemetry-core";
28
29
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
29
30
  import { writeLine } from "./cli-io.js";
30
31
  import { READ_DEADLINE_MS, TAG, resolveProjectId, } from "./issue-contracts.js";
@@ -118,17 +119,25 @@ async function showIssue(command, door) {
118
119
  if (!commentsAnswer.ok) {
119
120
  writeLine(door.io.stderr, `${TAG} comments unread ${JSON.stringify({ reason: commentsAnswer.reason, issue_id: issue.id })}`);
120
121
  }
122
+ // BLI-3779: during the dual-run a Tower-native row stands for a Linear
123
+ // ticket, and says so in its own title (`… [BLI-3779]`). Read it out rather
124
+ // than making every caller parse the title — `cockpit start --ticket` binds
125
+ // a session to both ids, and this is where a person checks which they are.
126
+ const linearTicketId = mirroredTicketIdInTitle(issue.title ?? "");
121
127
  if (door.json) {
122
128
  return emitAgentDoor(door, {
123
129
  ok: true,
124
130
  issue,
125
131
  comments,
132
+ ...(linearTicketId ? { linear_ticket_id: linearTicketId } : {}),
126
133
  ...(commentsAnswer.ok ? {} : { comments_unread_reason: commentsAnswer.reason }),
127
134
  });
128
135
  }
129
136
  writeLine(door.io.stdout, `${issue.identifier} ${issue.title}`);
130
137
  writeLine(door.io.stdout, `${issue.state} · priority ${issue.priority ?? 0} · assignee ${issue.assignee_id ?? "(nobody)"} · updated ${issue.updated_at}`);
131
138
  writeLine(door.io.stdout, `id ${issue.id}${issue.parent_id ? ` · parent ${issue.parent_id}` : ""}`);
139
+ if (linearTicketId)
140
+ writeLine(door.io.stdout, `Linear: ${linearTicketId}`);
132
141
  writeLine(door.io.stdout, "");
133
142
  writeLine(door.io.stdout, issue.description ?? "(no description)");
134
143
  if (comments.length > 0) {
@@ -27,6 +27,25 @@
27
27
  * older `reply` / `thread` / `traceId` / `model` / `trace` / `latency` /
28
28
  * `clientLatency` keys are untouched, because scripts already read them and a
29
29
  * one-contract ticket that broke the contract would be a joke.
30
+ *
31
+ * ## Where `sources` comes from (BLI-3770)
32
+ *
33
+ * From the DOOR, as objects, when the door sends them: `POST /api/jarvis/cli`
34
+ * carries `sources` built from the citations the grounding layer minted, and
35
+ * the `Source:` lines inside `answer` are rendered from that same array. The
36
+ * older derivation — a line-anchored regex over the finished prose — could
37
+ * only find a citation the renderer happened to put on its own line, so QA
38
+ * tick 16's c9, c9b and n9 (all off `searchEverything`) ended *"…Source:
39
+ * issue, at /work/BLI-3706, by …"* INSIDE the final sentence and returned
40
+ * `"sources": []`: the person saw a citation, the machine did not.
41
+ *
42
+ * The prose derivation survives as the FALLBACK, for a dashboard deployed
43
+ * before that door field existed, and it is wider than it was: a whole
44
+ * `Sources:` line (plural, any case, bulleted) counts — that is what the
45
+ * server itself treats as provenance — and so does a citation that begins a
46
+ * sentence inside a paragraph. A `Source:` in the MIDDLE of a sentence ("the
47
+ * Source: field on that row was blank") is prose about a source, not a
48
+ * source, and is deliberately not matched.
30
49
  */
31
50
  /**
32
51
  * Every key of the envelope, in the order it is written. The literal list IS
@@ -43,8 +62,20 @@ export const JARVIS_ANSWER_ENVELOPE_KEYS = [
43
62
  "degraded",
44
63
  "degraded_reasons",
45
64
  ];
46
- /** A `Source:` line, as the grounding gate renders it into the answer. */
47
- const SOURCE_LINE = /^\s*Source:\s*\S/;
65
+ /**
66
+ * A citation line, in every shape the server itself treats as one.
67
+ *
68
+ * BLI-3770 widened this from `/^\s*Source:\s*\S/` to mirror
69
+ * `apps/dashboard/src/lib/jarvis/chat-v2/provenance-lines.ts`, which matches
70
+ * `sources?` case-insensitively with an optional bullet. The narrow version
71
+ * meant a `Sources: …` line the server had already lifted out of the body as
72
+ * provenance, and put straight back under the answer, was not a source to
73
+ * this side: one thing, two definitions, in two packages.
74
+ */
75
+ const SOURCE_LINE = /^\s*(?:[-*\u2022]\s+)?sources?\s*:\s*\S/i;
76
+ /** The receipt's link, as `appendCitations` lays it out: `<url>` after the words. */
77
+ const BRACKETED_LINK = /\s*<(https?:\/\/[^>\s]+)>\s*$/;
78
+ const TRAILING_LINK = /\s+(https?:\/\/\S+)\s*$/;
48
79
  /**
49
80
  * The `Source:` lines inside an answer. Pure string work on what the server
50
81
  * already sent — this side never decides what a source IS, it only finds the
@@ -56,6 +87,90 @@ export function extractSourceLines(answer) {
56
87
  .map((line) => line.trim())
57
88
  .filter((line) => SOURCE_LINE.test(line));
58
89
  }
90
+ /** The words and the link, kept apart, the way every surface renders them. */
91
+ function splitLink(line) {
92
+ const trimmed = line.trim().replace(/^[-*\u2022]\s+/, "");
93
+ const bracketed = trimmed.match(BRACKETED_LINK);
94
+ if (bracketed)
95
+ return { label: trimmed.slice(0, bracketed.index).trim(), href: bracketed[1] ?? null };
96
+ const trailing = trimmed.match(TRAILING_LINK);
97
+ if (trailing)
98
+ return { label: trimmed.slice(0, trailing.index).trim(), href: trailing[1] ?? null };
99
+ return { label: trimmed, href: null };
100
+ }
101
+ /**
102
+ * A citation the model wrote INSIDE a paragraph (BLI-3770).
103
+ *
104
+ * It counts when it begins a sentence, which is the shape QA tick 16 caught
105
+ * three times. It deliberately does not count in the middle of one — "the
106
+ * Source: field on that row was blank" is a sentence about a field, and
107
+ * treating it as a receipt would put prose in a contract that is supposed to
108
+ * carry evidence.
109
+ */
110
+ function inlineSourceFragments(line) {
111
+ return line
112
+ .split(/(?<=[.!?])\s+/)
113
+ .map((part) => part.trim())
114
+ .filter((part) => SOURCE_LINE.test(part));
115
+ }
116
+ /**
117
+ * The fallback derivation: what this side can tell from the answer alone.
118
+ *
119
+ * Only reached when the door sent no structured `sources` — a dashboard
120
+ * deployed before BLI-3770, or a turn whose citation nothing minted. Every
121
+ * row it produces is labelled `kind: "prose"` so a consumer can tell a
122
+ * recovered line from a minted receipt.
123
+ */
124
+ export function sourcesFromAnswer(answer) {
125
+ const found = [];
126
+ const seen = new Set();
127
+ const add = (text) => {
128
+ const { label, href } = splitLink(text);
129
+ if (!label || seen.has(label))
130
+ return;
131
+ seen.add(label);
132
+ found.push({ kind: "prose", label, href, id: null, tool: null });
133
+ };
134
+ for (const raw of answer.split("\n")) {
135
+ const line = raw.trim();
136
+ if (!line)
137
+ continue;
138
+ if (SOURCE_LINE.test(line)) {
139
+ add(line);
140
+ continue;
141
+ }
142
+ for (const fragment of inlineSourceFragments(line))
143
+ add(fragment);
144
+ }
145
+ return found;
146
+ }
147
+ /**
148
+ * The door's own `sources`, checked rather than trusted. A field that arrives
149
+ * as something other than a list of labelled objects is treated as absent, so
150
+ * a malformed body degrades to the prose fallback instead of putting
151
+ * `[object Object]` in front of an agent.
152
+ */
153
+ function doorSources(sent) {
154
+ if (!Array.isArray(sent))
155
+ return [];
156
+ const rows = [];
157
+ for (const entry of sent) {
158
+ if (!entry || typeof entry !== "object")
159
+ continue;
160
+ const row = entry;
161
+ const label = typeof row.label === "string" ? row.label.trim() : "";
162
+ if (!label)
163
+ continue;
164
+ rows.push({
165
+ kind: typeof row.kind === "string" && row.kind ? row.kind : "unknown",
166
+ label,
167
+ href: typeof row.href === "string" ? row.href : null,
168
+ id: typeof row.id === "string" ? row.id : null,
169
+ tool: typeof row.tool === "string" ? row.tool : null,
170
+ });
171
+ }
172
+ return rows;
173
+ }
59
174
  export function buildJarvisAnswerEnvelope(input) {
60
175
  const reasons = [];
61
176
  if (input.modelFallback === true)
@@ -68,10 +183,14 @@ export function buildJarvisAnswerEnvelope(input) {
68
183
  const turnId = input.traceId ?? null;
69
184
  if (!turnId)
70
185
  reasons.push("no_turn_id");
186
+ const sent = doorSources(input.sources);
71
187
  return {
72
188
  ok: true,
73
189
  answer: input.reply,
74
- sources: extractSourceLines(input.reply),
190
+ // BLI-3770: the door's own citations when it sent them, and only then the
191
+ // prose fallback. Never both — a turn's receipts have one origin, and
192
+ // merging the two would double-count the lines the door already described.
193
+ sources: sent.length > 0 ? sent : sourcesFromAnswer(input.reply),
75
194
  turn_id: turnId,
76
195
  thread_id: input.thread ?? null,
77
196
  trace_thread_id: input.traceThread ?? null,
@@ -158,6 +158,10 @@ export async function sendOneTurn(context, prompt, io) {
158
158
  revised: body.revised,
159
159
  trace,
160
160
  proposalId: body.proposalId,
161
+ // BLI-3770: the door's own structured citations. Absent from a
162
+ // dashboard that predates it, and the envelope falls back to
163
+ // reading the answer's own `Source:` lines when it is.
164
+ sources: body.sources,
161
165
  }),
162
166
  reply: body.reply,
163
167
  thread: body.thread ?? context.command.thread,
@@ -195,6 +195,10 @@ export function localSubcommandHelp(command) {
195
195
  "",
196
196
  "Starts collecting your work in the background. If you point it at a parent folder it covers every repo inside.",
197
197
  "Add --ticket only when the work already has a visible ticket; omit it to preserve an existing binding.",
198
+ "--ticket takes EITHER tracker's id while both are running: a Tower issue (BLI-10000 and up, `cockpit issue list`)",
199
+ "or a Linear ticket. Tower is asked which row the id names, and the context records both ids when a row proves the pair —",
200
+ "a Tower-native issue whose title ends in `[BLI-3779]` mirrors that Linear ticket. An id Tower cannot place still binds:",
201
+ "the reason is printed, and the session is never held up by a tracker lookup.",
198
202
  "Use --clear-ticket to go back to collecting general work with no ticket attached.",
199
203
  "Use --topic/--intent/--phase for planning, discovery, and learning work that has no ticket yet.",
200
204
  "Supported intents: implementation, bug_fix, root_cause_analysis, planning, discovery, review, testing, documentation, release, learning, coordination, maintenance, analysis, unknown, other.",
@@ -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.63");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.65");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,233 @@
1
+ /**
2
+ * WHICH TICKET IS THIS, IN BOTH TRACKERS (BLI-3779).
3
+ *
4
+ * Tower runs its own issue tracker beside Linear during the dual-run, and one
5
+ * piece of work has an id in each: a Tower-native row (`BLI-10019`) whose
6
+ * title ends with the Linear id it mirrors (`… [BLI-3779]`). Before this
7
+ * module `cockpit start --ticket` recorded the string a person typed and
8
+ * nothing else, so a session bound to `BLI-3779` was invisible to every reader
9
+ * that keys on `work_issues`, and one bound to a Tower-native id resolved to
10
+ * nothing at all.
11
+ *
12
+ * This asks TOWER — through `GET /api/work/issues/<id>` and its `mirrors=`
13
+ * filter, the same doors `cockpit issue` uses — and hands `start.ts` both ids
14
+ * to record beside the one that was typed.
15
+ *
16
+ * Three rules it does not break:
17
+ *
18
+ * - **Nothing blocks the session.** The session-first commandment applies to
19
+ * a ticket lookup exactly as it does to attribution: an unpaired machine,
20
+ * an unreachable Tower, a typo, an id no tracker has — every one of them
21
+ * returns a NAMED outcome and `cockpit start` binds the context anyway.
22
+ * This module never throws.
23
+ * - **Only what a row proved is recorded.** A Tower id comes from a row that
24
+ * was read; a Linear id comes from that row's title or from the imported
25
+ * row's own identifier. Nothing is inferred from the number alone, and two
26
+ * Tower rows mirroring one Linear id record NEITHER — `ambiguous` is the
27
+ * `person-identity.ts` answer, and guessing binds a session to the wrong
28
+ * ticket.
29
+ * - **`active_ticket_id` stays exactly what was typed.** Rewriting it to the
30
+ * resolved id would move every downstream attribution — the ambient
31
+ * envelope, the ladder's per-ticket rung — onto an id nobody bound.
32
+ *
33
+ * One deployment hazard is handled on purpose: a laptop on a NEW CLI may talk
34
+ * to an OLDER dashboard that does not know the `mirrors=` filter and would
35
+ * answer the unfiltered list. Every returned row is therefore re-checked
36
+ * against the title convention here, and the request carries a small `limit`,
37
+ * so an old server can never make this bind to a row that mirrors something
38
+ * else. What it CAN cost is completeness, and the live probe on 2026-09-05
39
+ * showed both halves: against production-before-this-route the page happened
40
+ * to contain the right mirror (bound correctly), while a SECOND mirror of the
41
+ * same ticket sat outside the page and the ambiguity went unseen. Once the
42
+ * door ships, the filter is the server's and the answer is the whole set.
43
+ */
44
+ import { isTowerNativeIssueId, mirroredTicketIdInTitle, normalizeIssueIdentifier, } from "@bli-cockpit/telemetry-core";
45
+ import { askAgentDoor, openAgentDoor } from "./agent-door.js";
46
+ import { writeLine } from "./cli-io.js";
47
+ const TAG = "[start ticket]";
48
+ /** `cockpit start` must not stall on a tracker: two short reads at most. */
49
+ export const TICKET_LOOKUP_DEADLINE_MS = 8_000;
50
+ /** How many mirror candidates are worth naming before the answer is "several". */
51
+ const MIRROR_CANDIDATE_LIMIT = 5;
52
+ /**
53
+ * Asks Tower which issue a `--ticket` value names (named for the question it
54
+ * asks, not for the field it fills — `local-state-work-context.ts` has its own
55
+ * private `resolveTicketBinding`, which decides bind-vs-clear-vs-carry).
56
+ * Never throws; the caller binds the work context whatever comes back.
57
+ */
58
+ export async function lookUpTicketInTower(ticketId, options) {
59
+ const identifier = normalizeIssueIdentifier(ticketId);
60
+ if (!identifier) {
61
+ return log({
62
+ ticket_id: ticketId,
63
+ status: "not_checked",
64
+ reason: "not_an_issue_identifier",
65
+ detail: `"${ticketId}" is not an identifier like BLI-3779, so no tracker was asked. The work context is bound to it as typed.`,
66
+ }, options.io);
67
+ }
68
+ let door;
69
+ try {
70
+ door = await openAgentDoor("start", { homeDir: options.homeDir, dashboardUrl: options.dashboardUrl, json: true }, options.io);
71
+ }
72
+ catch {
73
+ return log({
74
+ ticket_id: ticketId,
75
+ status: "not_checked",
76
+ reason: "collector_not_paired",
77
+ detail: `This machine has no Tower session, so ${identifier} was not looked up. Run \`cockpit login\` to bind tickets to Tower issues; the work context is bound either way.`,
78
+ }, options.io);
79
+ }
80
+ try {
81
+ const binding = isTowerNativeIssueId(identifier)
82
+ ? await resolveTowerNative(door, ticketId, identifier)
83
+ : await resolveLinearShaped(door, ticketId, identifier);
84
+ return log(binding, options.io);
85
+ }
86
+ catch (error) {
87
+ // A lookup must never take the session down with it.
88
+ return log({
89
+ ticket_id: ticketId,
90
+ status: "not_checked",
91
+ reason: "tower_unreachable",
92
+ detail: `Tower could not be asked about ${identifier} (${error instanceof Error ? error.name : "unknown error"}). The work context is bound to it as typed.`,
93
+ }, options.io);
94
+ }
95
+ }
96
+ /**
97
+ * An id at or above the Tower floor: only Tower's own tracker could have
98
+ * minted it, so one read decides it and Linear is not in the question.
99
+ */
100
+ async function resolveTowerNative(door, ticketId, identifier) {
101
+ const found = await readIssue(door, identifier);
102
+ if (found.status === "failed")
103
+ return doorRefusal(ticketId, identifier, found.reason);
104
+ if (found.status === "missing") {
105
+ return {
106
+ ticket_id: ticketId,
107
+ status: "unresolved",
108
+ reason: "ticket_not_found_in_tower",
109
+ detail: `Tower has no issue ${identifier} (or you cannot read it). That number is in the range Tower mints, so Linear cannot hold it either. The work context is bound to it as typed.`,
110
+ };
111
+ }
112
+ const linear = mirroredTicketIdInTitle(found.issue.title);
113
+ return {
114
+ ticket_id: ticketId,
115
+ tower_issue_id: found.issue.identifier,
116
+ ...(linear ? { linear_ticket_id: linear } : {}),
117
+ status: linear ? "tower_mirrors_linear" : "tower_native",
118
+ detail: linear
119
+ ? `Bound to Tower issue ${found.issue.identifier}, which mirrors ${linear}.`
120
+ : `Bound to Tower issue ${found.issue.identifier}.`,
121
+ };
122
+ }
123
+ /**
124
+ * An id below the Tower floor belongs to Linear's range. Two reads, in this
125
+ * order:
126
+ *
127
+ * 1. the Tower-NATIVE row that mirrors it (`… [BLI-3779]`) — the row a person
128
+ * actually works in during the dual-run, per Edward's 2026-09-05 order;
129
+ * 2. failing that, a row carrying the identifier itself, which is what the
130
+ * Linear import writes (`work_issues.source = 'linear'`).
131
+ */
132
+ async function resolveLinearShaped(door, ticketId, identifier) {
133
+ const mirrors = await readMirrors(door, identifier);
134
+ if (mirrors.status === "failed")
135
+ return doorRefusal(ticketId, identifier, mirrors.reason);
136
+ if (mirrors.issues.length === 1) {
137
+ const mirror = mirrors.issues[0];
138
+ return {
139
+ ticket_id: ticketId,
140
+ tower_issue_id: mirror.identifier,
141
+ linear_ticket_id: identifier,
142
+ status: "tower_mirrors_linear",
143
+ detail: `Bound to ${identifier} and to Tower issue ${mirror.identifier}, which mirrors it.`,
144
+ };
145
+ }
146
+ if (mirrors.issues.length > 1) {
147
+ const candidates = mirrors.issues.map((issue) => issue.identifier);
148
+ return {
149
+ ticket_id: ticketId,
150
+ linear_ticket_id: identifier,
151
+ status: "linear_only",
152
+ reason: "ambiguous_tower_mirror",
153
+ candidates,
154
+ detail: `${candidates.length} Tower issues mirror ${identifier} (${candidates.join(", ")}), so none was chosen — pass the one you mean to \`cockpit start --ticket\`. The context is bound to ${identifier}.`,
155
+ };
156
+ }
157
+ const imported = await readIssue(door, identifier);
158
+ if (imported.status === "failed")
159
+ return doorRefusal(ticketId, identifier, imported.reason);
160
+ if (imported.status === "found") {
161
+ return {
162
+ ticket_id: ticketId,
163
+ tower_issue_id: imported.issue.identifier,
164
+ linear_ticket_id: identifier,
165
+ status: "tower_mirrors_linear",
166
+ detail: `Bound to ${identifier}, which Tower holds under the same identifier.`,
167
+ };
168
+ }
169
+ return {
170
+ ticket_id: ticketId,
171
+ status: "unresolved",
172
+ reason: "ticket_not_found_in_linear",
173
+ detail: `Tower has no row for ${identifier} and no Tower issue mirrors it — Linear itself was not asked, so this says the mirror is missing, not that the ticket is. The work context is bound to it as typed.`,
174
+ };
175
+ }
176
+ function doorRefusal(ticketId, identifier, reason) {
177
+ return {
178
+ ticket_id: ticketId,
179
+ status: "not_checked",
180
+ reason: "tower_unreachable",
181
+ door_reason: reason,
182
+ detail: `Tower could not answer about ${identifier} (${reason}). The work context is bound to it as typed.`,
183
+ };
184
+ }
185
+ /** One issue by identifier. A 404 is an ANSWER, not a failure. */
186
+ async function readIssue(door, identifier) {
187
+ const answer = await askAgentDoor(door, {
188
+ path: `/api/work/issues/${encodeURIComponent(identifier)}`,
189
+ method: "GET",
190
+ label: "start ticket read",
191
+ timeoutMs: TICKET_LOOKUP_DEADLINE_MS,
192
+ });
193
+ if (!answer.ok) {
194
+ if (answer.httpStatus === 404)
195
+ return { status: "missing" };
196
+ return { status: "failed", reason: answer.reason };
197
+ }
198
+ const issue = answer.body.issue;
199
+ return issue ? { status: "found", issue } : { status: "missing" };
200
+ }
201
+ /**
202
+ * The Tower rows whose title carries `[<identifier>]`. The server filters, and
203
+ * the answer is checked against the same convention here — an older dashboard
204
+ * that ignores `mirrors=` returns an unfiltered page, and accepting it would
205
+ * bind the session to a row that mirrors nothing.
206
+ */
207
+ async function readMirrors(door, identifier) {
208
+ const answer = await askAgentDoor(door, {
209
+ path: `/api/work/issues?mirrors=${encodeURIComponent(identifier)}&limit=${MIRROR_CANDIDATE_LIMIT}`,
210
+ method: "GET",
211
+ label: "start ticket mirrors",
212
+ timeoutMs: TICKET_LOOKUP_DEADLINE_MS,
213
+ });
214
+ if (!answer.ok)
215
+ return { status: "failed", reason: answer.reason };
216
+ const issues = answer.body.issues ?? [];
217
+ return {
218
+ status: "ok",
219
+ issues: issues.filter((issue) => mirroredTicketIdInTitle(issue.title ?? "") === identifier),
220
+ };
221
+ }
222
+ /** Every outcome, success included — ids and labels only, never a title. */
223
+ function log(binding, io) {
224
+ writeLine(io.stderr, `${TAG} resolved ${JSON.stringify({
225
+ status: binding.status,
226
+ reason: binding.reason ?? null,
227
+ door_reason: binding.door_reason ?? null,
228
+ tower_issue_id: binding.tower_issue_id ?? null,
229
+ linear_ticket_id: binding.linear_ticket_id ?? null,
230
+ candidates: binding.candidates?.length ?? 0,
231
+ })}`);
232
+ return binding;
233
+ }
@@ -1,8 +1,16 @@
1
1
  import { writeLine } from "./cli-io.js";
2
2
  import { displayTicketId } from "./collection-report.js";
3
3
  import { discoverCommandWorktrees } from "./local-discovery.js";
4
+ import { lookUpTicketInTower } from "./start-ticket-binding.js";
4
5
  import { startLocalWorkContext } from "../local-state.js";
5
6
  export async function runStart(command, io) {
7
+ const binding = command.activeTicketId
8
+ ? await lookUpTicketInTower(command.activeTicketId, { homeDir: command.homeDir, io })
9
+ : null;
10
+ const ticketIds = {
11
+ towerIssueId: binding?.tower_issue_id,
12
+ linearTicketId: binding?.linear_ticket_id,
13
+ };
6
14
  const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
7
15
  if (worktrees.length > 1) {
8
16
  const contexts = await Promise.all(worktrees.map((worktree) => startLocalWorkContext({
@@ -10,6 +18,7 @@ export async function runStart(command, io) {
10
18
  repoRoot: worktree.repo_root,
11
19
  branch: command.branch,
12
20
  activeTicketId: command.activeTicketId,
21
+ ...ticketIds,
13
22
  clearTicket: command.clearTicket,
14
23
  topicLabel: command.topicLabel,
15
24
  topicSummaryRedacted: command.topicSummaryRedacted,
@@ -21,27 +30,47 @@ export async function runStart(command, io) {
21
30
  sessionId: command.sessionId,
22
31
  })));
23
32
  if (command.json) {
24
- writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", contexts }, null, 2));
33
+ writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", contexts, ...(binding ? { ticket_binding: binding } : {}) }, null, 2));
25
34
  return 0;
26
35
  }
27
36
  writeLine(io.stdout, `Tower parent work context active for ${contexts.length} worktree(s).`);
28
37
  for (const context of contexts) {
29
38
  writeLine(io.stdout, `- ${context.repo_label ?? context.repo}/${context.worktree_label ?? "worktree"} · ${context.branch} · ${context.work_context_id}`);
30
39
  }
40
+ writeTicketLines(io, binding);
31
41
  return 0;
32
42
  }
33
- const context = await startLocalWorkContext(command);
43
+ const context = await startLocalWorkContext({ ...command, ...ticketIds });
34
44
  if (command.json) {
35
- writeLine(io.stdout, JSON.stringify(context, null, 2));
45
+ writeLine(io.stdout, JSON.stringify({ ...context, ...(binding ? { ticket_binding: binding } : {}) }, null, 2));
36
46
  return 0;
37
47
  }
38
48
  writeLine(io.stdout, "Tower work context active.");
39
49
  writeLine(io.stdout, `Repo: ${context.repo}`);
40
50
  writeLine(io.stdout, `Branch: ${context.branch}`);
41
51
  writeLine(io.stdout, `Ticket: ${displayTicketId(context.active_ticket_id)}`);
52
+ writeTicketLines(io, binding, context.tower_issue_id, context.linear_ticket_id);
42
53
  if (context.topic_label || context.work_intent || context.work_phase) {
43
54
  writeLine(io.stdout, `Topic: ${context.topic_label ?? "unlabeled"} · ${context.work_intent ?? "unknown"} · ${context.work_phase ?? "unknown"}`);
44
55
  }
45
56
  writeLine(io.stdout, `Context: ${context.work_context_id}`);
46
57
  return 0;
58
+ }
59
+ /**
60
+ * Both trackers' ids when they are known, and the reason when one is not. A
61
+ * lookup that resolved nothing still prints its sentence: a person who typed
62
+ * a ticket id deserves to know Tower could not place it, and silence there is
63
+ * exactly the "green status hiding a gap" this repo forbids.
64
+ */
65
+ function writeTicketLines(io, binding, towerIssueId, linearTicketId) {
66
+ if (!binding)
67
+ return;
68
+ const tower = towerIssueId ?? binding.tower_issue_id;
69
+ const linear = linearTicketId ?? binding.linear_ticket_id;
70
+ if (tower)
71
+ writeLine(io.stdout, `Tower issue: ${tower}`);
72
+ if (linear)
73
+ writeLine(io.stdout, `Linear: ${linear}`);
74
+ if (!tower || !linear)
75
+ writeLine(io.stdout, binding.detail);
47
76
  }
@@ -66,6 +66,12 @@ export async function runStatus(command, io) {
66
66
  writeLine(io.stdout, `Repo: ${status.repo}`);
67
67
  writeLine(io.stdout, `Branch: ${status.branch}`);
68
68
  writeLine(io.stdout, `Ticket: ${displayTicketId(status.active_ticket_id)}`);
69
+ // BLI-3779: one ticket, two trackers. Each line appears only when a row
70
+ // proved it — an absent line means "not resolved", never "does not exist".
71
+ if (status.tower_issue_id)
72
+ writeLine(io.stdout, `Tower issue: ${status.tower_issue_id}`);
73
+ if (status.linear_ticket_id)
74
+ writeLine(io.stdout, `Linear: ${status.linear_ticket_id}`);
69
75
  writeLine(io.stdout, `Work: ${displayWorkLabel(status)}`);
70
76
  writeLine(io.stdout, `Last collected: ${lastCollectedLine(status.collector_freshness)}`);
71
77
  writeLine(io.stdout, `Version: ${status.collector_version}`);
@@ -30,6 +30,8 @@ export async function inspectLocalCollectorStatus(options = {}) {
30
30
  repo: context?.repo ?? identity.repo_root,
31
31
  branch,
32
32
  active_ticket_id: context?.active_ticket_id ?? null,
33
+ tower_issue_id: context?.tower_issue_id ?? null,
34
+ linear_ticket_id: context?.linear_ticket_id ?? null,
33
35
  work_label: context ? workDisplayLabel(context) : null,
34
36
  work_id: context?.work_context_id ?? null,
35
37
  work_context_id: context?.work_context_id ?? null,
@@ -73,7 +73,7 @@ async function writeLocalWorkContext(options, attributedIdentity) {
73
73
  */
74
74
  function buildWorkContext(input) {
75
75
  const { options, now, identity, branch, operatorId, sessionId, workContextId, existingContext } = input;
76
- const { activeTicketId, ticketBindingCandidates } = resolveTicketBinding(options, existingContext);
76
+ const { activeTicketId, towerIssueId, linearTicketId, ticketBindingCandidates } = resolveTicketBinding(options, existingContext);
77
77
  return LocalWorkContextSchema.parse({
78
78
  work_context_id: workContextId,
79
79
  repo: identity.repo_root,
@@ -90,6 +90,8 @@ function buildWorkContext(input) {
90
90
  started_at: existingContext?.started_at ?? now.toISOString(),
91
91
  updated_at: now.toISOString(),
92
92
  active_ticket_id: activeTicketId,
93
+ tower_issue_id: towerIssueId,
94
+ linear_ticket_id: linearTicketId,
93
95
  ticket_binding_candidates: ticketBindingCandidates,
94
96
  topic_label: options.topicLabel,
95
97
  topic_summary_redacted: options.topicSummaryRedacted,
@@ -119,6 +121,12 @@ function buildWorkContext(input) {
119
121
  /**
120
122
  * Three answers, not two: a named ticket binds, `--clear-ticket` unbinds, and
121
123
  * saying neither leaves whatever the previous `cockpit start` bound in place.
124
+ *
125
+ * The other tracker's ids (BLI-3779) follow the SAME three answers, and they
126
+ * follow the TYPED ticket rather than each other: naming a ticket replaces
127
+ * them with whatever this run resolved — including with nothing, when the
128
+ * lookup came back unresolved — because carrying the previous ticket's Tower
129
+ * id onto a new one would attribute this session's work to the last ticket.
122
130
  */
123
131
  // The return type is inferred deliberately: naming it would mean indexing the
124
132
  // context schema by field name, and this module keeps its literals to the ones
@@ -127,6 +135,9 @@ function resolveTicketBinding(options, existingContext) {
127
135
  const activeTicketId = options.clearTicket
128
136
  ? undefined
129
137
  : (options.activeTicketId ?? existingContext?.active_ticket_id);
138
+ const rebinding = Boolean(options.clearTicket || options.activeTicketId);
139
+ const towerIssueId = rebinding ? options.towerIssueId : existingContext?.tower_issue_id;
140
+ const linearTicketId = rebinding ? options.linearTicketId : existingContext?.linear_ticket_id;
130
141
  const ticketBindingCandidates = options.activeTicketId
131
142
  ? [
132
143
  {
@@ -139,7 +150,7 @@ function resolveTicketBinding(options, existingContext) {
139
150
  : options.clearTicket
140
151
  ? []
141
152
  : (existingContext?.ticket_binding_candidates ?? []);
142
- return { activeTicketId, ticketBindingCandidates };
153
+ return { activeTicketId, towerIssueId, linearTicketId, ticketBindingCandidates };
143
154
  }
144
155
  /** The active context: what the last `cockpit start` on this machine bound, whatever the folder. */
145
156
  export async function readLocalWorkContext(paths) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.63",
3
+ "version": "0.2.65",
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-runtime-files.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.8",
31
- "@bli-cockpit/mcp": "0.1.6",
32
- "@bli-cockpit/telemetry-core": "0.1.29"
30
+ "@bli-cockpit/memory-mcp": "0.1.9",
31
+ "@bli-cockpit/mcp": "0.1.8",
32
+ "@bli-cockpit/telemetry-core": "0.1.30"
33
33
  }
34
34
  }