@bli-cockpit/cli 0.2.63 → 0.2.64
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/dist/commands/issue.js +9 -0
- package/dist/commands/local-help-commands.js +4 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/start-ticket-binding.js +233 -0
- package/dist/commands/start.js +32 -3
- package/dist/commands/status.js +6 -0
- package/dist/local-state-status.js +2 -0
- package/dist/local-state-work-context.js +13 -2
- package/package.json +4 -4
package/dist/commands/issue.js
CHANGED
|
@@ -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) {
|
|
@@ -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.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.64");
|
|
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
|
+
}
|
package/dist/commands/start.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.2.64",
|
|
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.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.9",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.7",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.30"
|
|
33
33
|
}
|
|
34
34
|
}
|