@bli-cockpit/cli 0.2.62 → 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.
@@ -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) {
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `cockpit mail` argument parsing (BLI-3708) — the mail surface's half of
3
+ * `local-args-tower.ts`, a sibling of `local-args-tower-work.ts` for the same
4
+ * reason that file is one: mail is its own noun with its own vocabulary (a
5
+ * mailbox, a thread, an attachment, a recipient), and folding it into another
6
+ * family's parser would stretch that family's doc comment past the truth.
7
+ *
8
+ * TWO SECRETS NEVER TRAVEL ON ARGV, and both are refused here rather than at
9
+ * the door:
10
+ *
11
+ * - **The app password.** `cockpit mail add-imap --address <a>` reads it from
12
+ * STDIN, always. There is no `--password` flag to forget about: argv is
13
+ * world-readable on a shared machine (`ps`), lands in shell history, and is
14
+ * captured by this very product's own session harvester. That is the
15
+ * sourcing-secrets verdict applied to the one credential this product
16
+ * stores.
17
+ * - **The message body.** `cockpit mail send` takes it from stdin or
18
+ * `--file`, the same discipline `cockpit issue comment` and `cockpit docs
19
+ * update` keep.
20
+ */
21
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
22
+ const MAIL_ACTIONS = new Set([
23
+ "accounts",
24
+ "add-imap",
25
+ "detach",
26
+ "inbox",
27
+ "read",
28
+ "search",
29
+ "send",
30
+ "attachment",
31
+ "sync",
32
+ ]);
33
+ /** Every verb that names ONE thing first: a thread, an attachment, a mailbox. */
34
+ const MAIL_ACTIONS_NEEDING_A_SUBJECT = new Set(["read", "attachment", "detach", "sync"]);
35
+ export function parseMailArgs(args) {
36
+ const values = parseNamedArgs(args, {
37
+ allowedFlags: [
38
+ "--home",
39
+ "--dashboard-url",
40
+ "--account",
41
+ "--address",
42
+ "--name",
43
+ "--limit",
44
+ "--before",
45
+ "--unread",
46
+ "--label",
47
+ "--to",
48
+ "--cc",
49
+ "--subject",
50
+ "--reply-to",
51
+ "--file",
52
+ "--body-stdin",
53
+ "--out",
54
+ "--json",
55
+ ],
56
+ valueFlags: [
57
+ "--home",
58
+ "--dashboard-url",
59
+ "--account",
60
+ "--address",
61
+ "--name",
62
+ "--limit",
63
+ "--before",
64
+ "--label",
65
+ "--to",
66
+ "--cc",
67
+ "--subject",
68
+ "--reply-to",
69
+ "--file",
70
+ "--out",
71
+ ],
72
+ });
73
+ const first = values.positionals[0];
74
+ const action = (first === undefined ? "inbox" : first);
75
+ if (!MAIL_ACTIONS.has(action)) {
76
+ throw new Error(`Unknown mail command: ${first}. Try accounts, add-imap, detach, inbox, read, search, send, attachment, or sync.`);
77
+ }
78
+ const rest = values.positionals.slice(first === undefined ? 0 : 1);
79
+ let subject;
80
+ let query;
81
+ if (MAIL_ACTIONS_NEEDING_A_SUBJECT.has(action)) {
82
+ subject = optionalNonEmpty(rest[0]);
83
+ if (!subject) {
84
+ throw new Error(action === "read"
85
+ ? "mail read needs a thread id — take one from `cockpit mail inbox --json`."
86
+ : `mail ${action} needs an id.`);
87
+ }
88
+ if (rest.length > 1)
89
+ throw new Error(`mail ${action} takes one id, not ${rest.length}.`);
90
+ }
91
+ else if (action === "search") {
92
+ query = optionalNonEmpty(rest.join(" "));
93
+ if (!query)
94
+ throw new Error('mail search needs words: cockpit mail search "invoice from stripe".');
95
+ }
96
+ else if (rest.length > 0) {
97
+ throw new Error(`mail ${action} does not take "${rest[0]}".`);
98
+ }
99
+ const address = optionalNonEmpty(values.flags.get("--address"));
100
+ if (action === "add-imap" && !address) {
101
+ throw new Error("mail add-imap needs --address, and reads the app password from stdin.");
102
+ }
103
+ const to = splitAddresses(values.flags.get("--to"));
104
+ const cc = splitAddresses(values.flags.get("--cc"));
105
+ if (action === "send" && to.length === 0) {
106
+ throw new Error('mail send needs --to "someone@example.com" (comma-separated for several).');
107
+ }
108
+ if (action === "send" && !optionalNonEmpty(values.flags.get("--account"))) {
109
+ throw new Error("mail send needs --account <id>: with several mailboxes attached, which address this goes out from is yours to say. `cockpit mail accounts` lists them.");
110
+ }
111
+ const outPath = optionalNonEmpty(values.flags.get("--out"));
112
+ if (action === "attachment" && !outPath) {
113
+ throw new Error("mail attachment needs --out <path>: the file is written to disk, never to stdout.");
114
+ }
115
+ const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
116
+ if (limit !== undefined && action !== "inbox" && action !== "search") {
117
+ throw new Error("--limit belongs to `cockpit mail inbox` or `cockpit mail search`.");
118
+ }
119
+ return {
120
+ kind: "mail",
121
+ action,
122
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
123
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
124
+ ...(subject ? { subjectId: subject } : {}),
125
+ ...(query ? { query } : {}),
126
+ accountId: optionalNonEmpty(values.flags.get("--account")),
127
+ address,
128
+ displayName: optionalNonEmpty(values.flags.get("--name")),
129
+ ...(limit === undefined ? {} : { limit }),
130
+ before: optionalNonEmpty(values.flags.get("--before")),
131
+ unreadOnly: values.booleans.has("--unread"),
132
+ label: optionalNonEmpty(values.flags.get("--label")),
133
+ to,
134
+ cc,
135
+ subjectLine: optionalNonEmpty(values.flags.get("--subject")),
136
+ replyTo: optionalNonEmpty(values.flags.get("--reply-to")),
137
+ filePath: optionalNonEmpty(values.flags.get("--file")),
138
+ bodyStdin: values.booleans.has("--body-stdin"),
139
+ outPath,
140
+ json: values.booleans.has("--json"),
141
+ };
142
+ }
143
+ /** `a@x.com, b@y.com` → two recipients. Exact, never fuzzy; empties dropped. */
144
+ function splitAddresses(raw) {
145
+ if (!raw)
146
+ return [];
147
+ return raw
148
+ .split(",")
149
+ .map((value) => value.trim())
150
+ .filter((value) => value !== "");
151
+ }
@@ -20,6 +20,7 @@
20
20
  * channels/messages (BLI-3706)
21
21
  * local-args-tower-work.ts issue, project — the issue tracker
22
22
  * (BLI-3716)
23
+ * local-args-tower-mail.ts mail — the mailboxes (BLI-3708)
23
24
  * local-args-tower-search.ts search — one bar over all five
24
25
  * corpora (BLI-3728)
25
26
  *
@@ -30,4 +31,5 @@ export { parseBriefArgs, WORKBOOK_MIN_WIDTH, parseWorkbookArgs, parseNotesArgs,
30
31
  export { SCOUT_MIN_PREFIX_LENGTH, parseScoutArgs, parseOpsArgs, SLACK_WORKSPACE_KEYS, parseSlackArgs, parseSettingsArgs, parseTeamArgs, parseModelArgs, } from "./local-args-tower-admin.js";
31
32
  export { parseDocsArgs, parseMsgArgs, } from "./local-args-tower-docs-msg.js";
32
33
  export { ISSUE_STATES, parseIssueArgs, parseProjectArgs, } from "./local-args-tower-work.js";
33
- export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
34
+ export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
35
+ export { parseMailArgs } from "./local-args-tower-mail.js";
@@ -16,7 +16,7 @@
16
16
  * verbatim, no logic change.
17
17
  */
18
18
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
19
- import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
19
+ import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
20
20
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
21
21
  // `local-auth.ts` import it from here — so it stays exported from this address
22
22
  // even though it now lives next door. The same goes for the four names the
@@ -103,6 +103,8 @@ export function parseLocalArgs(argv) {
103
103
  return parseMsgArgs(argv.slice(1));
104
104
  case "issue":
105
105
  return parseIssueArgs(argv.slice(1));
106
+ case "mail":
107
+ return parseMailArgs(argv.slice(1));
106
108
  case "project":
107
109
  return parseProjectArgs(argv.slice(1));
108
110
  case "search":
@@ -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.",
@@ -596,6 +600,29 @@ export function localSubcommandHelp(command) {
596
600
  "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
597
601
  ],
598
602
  ],
603
+ [
604
+ "mail",
605
+ [
606
+ "Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|attachment <id>|sync <account>|detach <account>] [flags]",
607
+ "",
608
+ "Every mailbox you attached, in one place. One person, several addresses — nothing here has a \"current\" mailbox.",
609
+ "accounts — every mailbox, its provider (gmail_oauth or imap), its health and when it last synced. The ids other verbs take are on the last line.",
610
+ "add-imap --address <you@gmail.com> [--name \"<display name>\"] — attaches a personal Google account over IMAP + SMTP. The APP PASSWORD is read from stdin and there is no flag for it:",
611
+ " macOS: printf \"%s\" \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
612
+ " PowerShell: \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
613
+ " Make one at myaccount.google.com/apppasswords (2-Step Verification has to be on). A work @buildlaunchiterate.ca address connects in the browser instead.",
614
+ "inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment.",
615
+ "read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox --json`.",
616
+ "search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box.",
617
+ "send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
618
+ "attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
619
+ "sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
620
+ "detach <account> — removes the mailbox, its stored mail and its credential.",
621
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
622
+ "A refusal keeps Tower's own reason label — needs_rls_client, account_not_found_or_unreadable, account_not_active, google_oauth_not_configured, credential_unavailable, and so on.",
623
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
624
+ ],
625
+ ],
599
626
  [
600
627
  "project",
601
628
  [
@@ -45,6 +45,7 @@ export const rootCommandNames = new Set([
45
45
  "docs",
46
46
  "msg",
47
47
  "issue",
48
+ "mail",
48
49
  "project",
49
50
  "search",
50
51
  "release",
@@ -89,6 +90,7 @@ export function localCommandHelp(command) {
89
90
  " cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>] [--parent <id>|root|--clear-parent] [--query <text>] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
90
91
  " cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
91
92
  " cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
93
+ " cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--dashboard-url <url>] [--json]",
92
94
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
93
95
  " cockpit search \"<words>\" [--kind doc,msg,issue,note,memory] [--limit <n>] [--dashboard-url <url>] [--json]",
94
96
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
@@ -35,6 +35,7 @@ import { runClean } from "./clean.js";
35
35
  import { runDocs } from "./docs.js";
36
36
  import { runMsg } from "./msg.js";
37
37
  import { runIssue } from "./issue.js";
38
+ import { runMail } from "./mail.js";
38
39
  import { runProject } from "./project.js";
39
40
  import { runSearch } from "./search.js";
40
41
  import { parseLocalArgs } from "./local-args.js";
@@ -142,6 +143,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
142
143
  return await runMsg(command, io);
143
144
  case "issue":
144
145
  return await runIssue(command, io);
146
+ case "mail":
147
+ return await runMail(command, io);
145
148
  case "project":
146
149
  return await runProject(command, io);
147
150
  case "search":
@@ -0,0 +1,337 @@
1
+ /**
2
+ * `cockpit mail` — the mailboxes a person attached, from a terminal
3
+ * (BLI-3708).
4
+ *
5
+ * Nine verbs over `/api/mail/**`, the same doors the browser will use when the
6
+ * UI lands. Every one of those routes already accepts the collector device
7
+ * token (`resolveCaller({ allowDeviceToken: true })`), so this command is a
8
+ * terminal in front of an existing door and adds no authority of its own.
9
+ *
10
+ * The CLI is the FIRST surface for this wave, deliberately (AGENTS.md, "CLI is
11
+ * king"): a mailbox is easier to reason about as `--json` than as a rendered
12
+ * list, and every bug fixed here is fixed for the browser and MCP halves that
13
+ * share the same doors.
14
+ *
15
+ * TWO SECRETS NEVER TOUCH ARGV. `add-imap` reads the Google app password from
16
+ * STDIN and there is no flag that would take it; `send` reads the body from
17
+ * stdin or `--file`. `local-args-tower-mail.ts` says why in full.
18
+ *
19
+ * ONE MORE RULE, for the attachment verb: bytes go to a FILE, never to stdout.
20
+ * `--out` is required. A terminal that prints a PDF is a terminal somebody has
21
+ * to close, and `--json` output on stdout is a contract other tools parse.
22
+ */
23
+ import { writeFile } from "node:fs/promises";
24
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
25
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
26
+ import { readNoteFile } from "./notes-file.js";
27
+ import { towerRequest } from "../tower-client.js";
28
+ const TAG = "[mail cli]";
29
+ const READ_DEADLINE_MS = 30_000;
30
+ const WRITE_DEADLINE_MS = 60_000;
31
+ /** A sync pass reads a bounded page, but a cold mailbox can still take a while. */
32
+ const SYNC_DEADLINE_MS = 120_000;
33
+ const BODY_MAX_CHARS = 200_000;
34
+ const PASSWORD_MAX_CHARS = 200;
35
+ export async function runMail(command, io) {
36
+ const door = await openAgentDoor("mail", command, io);
37
+ switch (command.action) {
38
+ case "accounts":
39
+ return listAccounts(door);
40
+ case "add-imap":
41
+ return addImap(command, door);
42
+ case "detach":
43
+ return detach(command, door);
44
+ case "inbox":
45
+ return inbox(command, door);
46
+ case "read":
47
+ return readThread(command, door);
48
+ case "search":
49
+ return search(command, door);
50
+ case "send":
51
+ return send(command, door);
52
+ case "attachment":
53
+ return attachment(command, door);
54
+ case "sync":
55
+ return sync(command, door);
56
+ }
57
+ }
58
+ async function listAccounts(door) {
59
+ const answer = await askAgentDoor(door, {
60
+ path: "/api/mail/accounts",
61
+ method: "GET",
62
+ label: "mail accounts",
63
+ timeoutMs: READ_DEADLINE_MS,
64
+ });
65
+ if (!answer.ok)
66
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
67
+ const accounts = answer.body.accounts ?? [];
68
+ if (door.json)
69
+ return emitAgentDoor(door, { ok: true, accounts });
70
+ if (accounts.length === 0) {
71
+ writeLine(door.io.stdout, "No mailboxes attached yet.");
72
+ writeLine(door.io.stdout, "");
73
+ writeLine(door.io.stdout, "A personal address: cockpit mail add-imap --address you@gmail.com (app password on stdin)");
74
+ writeLine(door.io.stdout, "A work address: open Tower in a browser and connect Google.");
75
+ return 0;
76
+ }
77
+ for (const account of accounts) {
78
+ const health = account.status === "active" ? account.backfill_state : `${account.status}: ${account.status_reason ?? "no reason recorded"}`;
79
+ writeLine(door.io.stdout, `${account.address.padEnd(34)} ${account.provider.padEnd(12)} ${health.padEnd(18)} ${account.last_sync_at ?? "never synced"}`);
80
+ }
81
+ writeLine(door.io.stdout, "");
82
+ writeLine(door.io.stdout, `${accounts.length} mailbox(es). ids: ${accounts.map((account) => account.id).join(", ")}`);
83
+ return 0;
84
+ }
85
+ async function addImap(command, door) {
86
+ // The password comes from stdin and nowhere else. An interactive terminal
87
+ // with nothing piped in is told how, rather than left waiting on a prompt
88
+ // that would echo the secret into the scrollback.
89
+ if (isInteractiveStdin(door.io)) {
90
+ return failAgentDoor(door, TAG, "password_required_on_stdin", 'The app password is read from stdin, never from a flag. Try: printf "%s" "abcd efgh ijkl mnop" | cockpit mail add-imap --address you@gmail.com');
91
+ }
92
+ let appPassword;
93
+ try {
94
+ appPassword = (await readPipedText(door.io.stdin, {
95
+ maxChars: PASSWORD_MAX_CHARS,
96
+ overflowMessage: "That does not look like a Google app password (they are 16 characters).",
97
+ })).trim();
98
+ }
99
+ catch (error) {
100
+ return failAgentDoor(door, TAG, "password_unreadable", error instanceof Error ? error.message : String(error));
101
+ }
102
+ if (appPassword === "") {
103
+ return failAgentDoor(door, TAG, "password_required_on_stdin", "Nothing arrived on stdin, so nothing was attached.");
104
+ }
105
+ const answer = await askAgentDoor(door, {
106
+ path: "/api/mail/accounts",
107
+ method: "POST",
108
+ label: "mail add-imap",
109
+ timeoutMs: WRITE_DEADLINE_MS,
110
+ body: {
111
+ address: command.address,
112
+ display_name: command.displayName ?? null,
113
+ app_password: appPassword,
114
+ },
115
+ });
116
+ if (!answer.ok)
117
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
118
+ const account = answer.body.account;
119
+ if (door.json)
120
+ return emitAgentDoor(door, { ok: true, account });
121
+ writeLine(door.io.stdout, `Attached ${account?.address ?? command.address} (${account?.id ?? "no id returned"}).`);
122
+ writeLine(door.io.stdout, "Nothing is read until a sync runs: `cockpit mail sync <id>`, or wait for the cron.");
123
+ return 0;
124
+ }
125
+ async function detach(command, door) {
126
+ const answer = await askAgentDoor(door, {
127
+ path: `/api/mail/accounts?account_id=${encodeURIComponent(command.subjectId ?? "")}`,
128
+ method: "DELETE",
129
+ label: "mail detach",
130
+ timeoutMs: WRITE_DEADLINE_MS,
131
+ });
132
+ if (!answer.ok)
133
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
134
+ if (door.json)
135
+ return emitAgentDoor(door, { ok: true, detached: command.subjectId });
136
+ writeLine(door.io.stdout, `Detached ${command.subjectId}. Its stored mail and its credential went with it.`);
137
+ return 0;
138
+ }
139
+ async function inbox(command, door) {
140
+ const query = new URLSearchParams();
141
+ if (command.accountId)
142
+ query.set("account_id", command.accountId);
143
+ if (command.limit !== undefined)
144
+ query.set("limit", String(command.limit));
145
+ if (command.before)
146
+ query.set("before", command.before);
147
+ if (command.unreadOnly)
148
+ query.set("unread", "1");
149
+ if (command.label)
150
+ query.set("label", command.label);
151
+ const suffix = query.toString();
152
+ const answer = await askAgentDoor(door, {
153
+ path: `/api/mail/inbox${suffix ? `?${suffix}` : ""}`,
154
+ method: "GET",
155
+ label: "mail inbox",
156
+ timeoutMs: READ_DEADLINE_MS,
157
+ });
158
+ if (!answer.ok)
159
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
160
+ const body = answer.body;
161
+ if (door.json)
162
+ return emitAgentDoor(door, { ok: true, ...body });
163
+ const messages = body.messages ?? [];
164
+ if (messages.length === 0) {
165
+ writeLine(door.io.stdout, (body.accounts ?? []).length === 0 ? "No mailboxes attached yet." : "No mail matches that.");
166
+ return 0;
167
+ }
168
+ for (const message of messages) {
169
+ writeLine(door.io.stdout, messageLine(message));
170
+ }
171
+ writeLine(door.io.stdout, "");
172
+ writeLine(door.io.stdout, `${messages.length} message(s) across ${(body.accounts ?? []).length} mailbox(es).`);
173
+ return 0;
174
+ }
175
+ function messageLine(message) {
176
+ const flag = message.is_unread ? "*" : " ";
177
+ const paperclip = message.has_attachments ? "@" : " ";
178
+ const from = message.from_address?.address ?? "(no sender)";
179
+ const day = message.internal_date.slice(0, 16).replace("T", " ");
180
+ return `${flag}${paperclip} ${day} ${from.padEnd(30).slice(0, 30)} ${(message.subject ?? "(no subject)").slice(0, 60)}`;
181
+ }
182
+ async function readThread(command, door) {
183
+ const answer = await askAgentDoor(door, {
184
+ path: `/api/mail/threads/${encodeURIComponent(command.subjectId ?? "")}`,
185
+ method: "GET",
186
+ label: "mail read",
187
+ timeoutMs: READ_DEADLINE_MS,
188
+ });
189
+ if (!answer.ok)
190
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
191
+ const body = answer.body;
192
+ if (door.json)
193
+ return emitAgentDoor(door, { ok: true, ...body });
194
+ writeLine(door.io.stdout, body.thread?.subject ?? "(no subject)");
195
+ writeLine(door.io.stdout, `${body.thread?.account_address ?? "?"} — ${body.messages?.length ?? 0} message(s)`);
196
+ for (const message of body.messages ?? []) {
197
+ writeLine(door.io.stdout, "");
198
+ writeLine(door.io.stdout, `--- ${message.internal_date} ${message.from_address?.address ?? "(no sender)"} [${message.id}]`);
199
+ writeLine(door.io.stdout, (message.body_text ?? message.snippet ?? "(no text body — this message is HTML only)").trim());
200
+ }
201
+ return 0;
202
+ }
203
+ async function search(command, door) {
204
+ const query = new URLSearchParams({ q: command.query ?? "" });
205
+ if (command.accountId)
206
+ query.set("account_id", command.accountId);
207
+ if (command.limit !== undefined)
208
+ query.set("limit", String(command.limit));
209
+ const answer = await askAgentDoor(door, {
210
+ path: `/api/mail/search?${query.toString()}`,
211
+ method: "GET",
212
+ label: "mail search",
213
+ timeoutMs: READ_DEADLINE_MS,
214
+ });
215
+ if (!answer.ok)
216
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
217
+ const matches = answer.body.matches ?? [];
218
+ if (door.json)
219
+ return emitAgentDoor(door, { ok: true, query: command.query, matches });
220
+ if (matches.length === 0) {
221
+ writeLine(door.io.stdout, `Nothing in your mail matched "${command.query}".`);
222
+ return 0;
223
+ }
224
+ for (const message of matches)
225
+ writeLine(door.io.stdout, messageLine(message));
226
+ writeLine(door.io.stdout, "");
227
+ writeLine(door.io.stdout, `${matches.length} match(es).`);
228
+ return 0;
229
+ }
230
+ async function send(command, door) {
231
+ const body = await readBody(command, door);
232
+ if (!body.ok)
233
+ return failAgentDoor(door, TAG, body.reason, body.detail);
234
+ if (body.text.trim() === "") {
235
+ return failAgentDoor(door, TAG, "body_required", 'A message needs a body: `echo "on my way" | cockpit mail send --account <id> --to someone@example.com --subject "Re: lunch"`.');
236
+ }
237
+ const answer = await askAgentDoor(door, {
238
+ path: "/api/mail/send",
239
+ method: "POST",
240
+ label: "mail send",
241
+ timeoutMs: WRITE_DEADLINE_MS,
242
+ body: {
243
+ account_id: command.accountId,
244
+ to: command.to.map((address) => ({ address })),
245
+ ...(command.cc.length > 0 ? { cc: command.cc.map((address) => ({ address })) } : {}),
246
+ subject: command.subjectLine ?? "",
247
+ text: body.text,
248
+ ...(command.replyTo ? { in_reply_to_message_id: command.replyTo } : {}),
249
+ },
250
+ });
251
+ if (!answer.ok)
252
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
253
+ const sent = answer.body.sent;
254
+ if (door.json)
255
+ return emitAgentDoor(door, { ok: true, sent });
256
+ writeLine(door.io.stdout, `Sent from ${sent?.address ?? "that mailbox"} to ${command.to.join(", ")}${sent?.threaded ? " (as a reply)" : ""}.`);
257
+ return 0;
258
+ }
259
+ async function attachment(command, door) {
260
+ const outPath = command.outPath;
261
+ // The bytes do not go through `askAgentDoor` — it parses JSON, and this
262
+ // response is a file. Same door, same token, raw response.
263
+ const result = await towerRequest({
264
+ dashboardUrl: door.dashboardUrl,
265
+ path: `/api/mail/attachments/${encodeURIComponent(command.subjectId ?? "")}`,
266
+ deviceToken: door.deviceToken,
267
+ fetch: door.io.fetch,
268
+ method: "GET",
269
+ label: "mail attachment",
270
+ timeoutMs: WRITE_DEADLINE_MS,
271
+ log: (line) => writeLine(door.io.stderr, line),
272
+ });
273
+ if (!result.ok) {
274
+ return failAgentDoor(door, TAG, result.reason, result.detail);
275
+ }
276
+ if (!result.response.ok) {
277
+ return failAgentDoor(door, TAG, `http_${result.response.status}`, `Tower answered ${result.response.status} for that attachment. Nothing was written to ${outPath}.`);
278
+ }
279
+ const bytes = Buffer.from(await result.response.arrayBuffer());
280
+ try {
281
+ await writeFile(outPath, bytes);
282
+ }
283
+ catch (error) {
284
+ return failAgentDoor(door, TAG, "write_failed", `Downloaded ${bytes.length} bytes and could not write them to ${outPath}: ${error instanceof Error ? error.message : String(error)}`);
285
+ }
286
+ if (door.json)
287
+ return emitAgentDoor(door, { ok: true, path: outPath, bytes: bytes.length });
288
+ writeLine(door.io.stdout, `Wrote ${bytes.length} bytes to ${outPath}.`);
289
+ return 0;
290
+ }
291
+ async function sync(command, door) {
292
+ const answer = await askAgentDoor(door, {
293
+ path: `/api/mail/accounts/${encodeURIComponent(command.subjectId ?? "")}/sync`,
294
+ method: "POST",
295
+ label: "mail sync",
296
+ timeoutMs: SYNC_DEADLINE_MS,
297
+ });
298
+ if (!answer.ok)
299
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
300
+ const run = answer.body.run;
301
+ if (door.json)
302
+ return emitAgentDoor(door, { ok: true, run });
303
+ if (!run) {
304
+ writeLine(door.io.stdout, "Tower answered without a run. Nothing to report.");
305
+ return 0;
306
+ }
307
+ if (run.reason === "ok") {
308
+ writeLine(door.io.stdout, `Synced: ${run.messagesAdded} new, ${run.messagesUpdated} updated, ${run.messagesDeleted} gone.`);
309
+ return 0;
310
+ }
311
+ // A failure names itself; the exit code says it failed.
312
+ writeLine(door.io.stderr, `${TAG} sync ${JSON.stringify({ reason: run.reason, detail: run.detail })}`);
313
+ writeLine(door.io.stdout, `That mailbox did not sync: ${run.reason}${run.detail ? ` (${run.detail})` : ""}.`);
314
+ return 1;
315
+ }
316
+ /** Body on stdin, never argv — `--file` is the safest way on Windows. */
317
+ async function readBody(command, door) {
318
+ if (command.filePath) {
319
+ const read = await readNoteFile(command.filePath);
320
+ if (!read.ok) {
321
+ return { ok: false, reason: read.refusal, detail: `Could not read ${command.filePath}: ${read.detail}` };
322
+ }
323
+ return { ok: true, text: read.bytes.toString("utf8") };
324
+ }
325
+ if (isInteractiveStdin(door.io))
326
+ return { ok: true, text: "" };
327
+ try {
328
+ const text = await readPipedText(door.io.stdin, {
329
+ maxChars: BODY_MAX_CHARS,
330
+ overflowMessage: `A message body is limited to ${BODY_MAX_CHARS} characters here.`,
331
+ });
332
+ return { ok: true, text };
333
+ }
334
+ catch (error) {
335
+ return { ok: false, reason: "body_too_long", detail: error instanceof Error ? error.message : String(error) };
336
+ }
337
+ }
@@ -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.62");
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
+ }
@@ -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.62",
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.8",
31
- "@bli-cockpit/mcp": "0.1.5",
32
- "@bli-cockpit/telemetry-core": "0.1.29"
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
  }