@bli-cockpit/cli 0.2.144 → 0.2.145

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.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Whether CLAUDE.md already gets the Tower agent rules for free through its
3
+ * `@~/.codex/AGENTS.md` include line, so the installer should not duplicate
4
+ * the managed block there too.
5
+ *
6
+ * BLI-10574: `~/.claude/CLAUDE.md` commonly reads
7
+ * "Shared rules for every agent: `~/.codex/AGENTS.md` ... @~/.codex/AGENTS.md"
8
+ * — Claude loads the codex file wholesale. When that include line is present
9
+ * AND codex is part of the same install/inspect call, writing our block into
10
+ * CLAUDE.md too is pure duplication: Claude already reads it via the include,
11
+ * and a second copy only goes stale on its own schedule.
12
+ */
13
+ import path from "node:path";
14
+ export function claudeFoldsIntoCodexInclude(host, hostsInCall, claudeFileContents, homeDir) {
15
+ if (host !== "claude")
16
+ return false;
17
+ if (!hostsInCall.includes("codex"))
18
+ return false;
19
+ return includesCodexAgentsInclude(claudeFileContents, homeDir);
20
+ }
21
+ function includesCodexAgentsInclude(contents, homeDir) {
22
+ if (!contents.trim())
23
+ return false;
24
+ const absoluteCodexAgentsFile = path.join(homeDir, ".codex", "AGENTS.md");
25
+ const pattern = new RegExp(`^\\s*@(?:~/\\.codex/AGENTS\\.md|${escapeRegExp(absoluteCodexAgentsFile)})\\s*$`, "mu");
26
+ return pattern.test(contents);
27
+ }
28
+ function escapeRegExp(value) {
29
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
30
+ }
@@ -1,12 +1,19 @@
1
1
  /**
2
- * A ticket-binding section somebody wrote by hand, and whether it already says
3
- * what our managed block says.
2
+ * A Tower-agent-rules section somebody wrote by hand, and whether it already
3
+ * says what our managed block says.
4
4
  *
5
5
  * Split out of agent-rules.ts (BLI-10108), which writes the block; this file
6
6
  * only READS what is already in a person's rules file and answers two
7
7
  * questions: is this good enough to leave alone (`equivalent`), or is it the
8
8
  * older vocabulary we should replace (`stale`)?
9
9
  *
10
+ * BLI-10574 replaced the block's ticket-binding-only text with a "Tower
11
+ * first" mentality (in-house tools before Linear/Notion/Google) folded into
12
+ * one scope+binding line. The equivalence check below reads for THAT shape;
13
+ * a hand-typed section still carrying the older "search Linear"/"cockpit
14
+ * sync" vocabulary no longer counts as equivalent — it is stale and gets
15
+ * replaced, same as any other superseded wording.
16
+ *
10
17
  * The matching is deliberately loose. These sections were typed by people and
11
18
  * by other agents over months, so the test is a handful of cues plus a score,
12
19
  * not equality, and every cue is lowercased, unquoted and whitespace-flattened
@@ -17,12 +24,12 @@ export function hasEquivalentUnmanagedTicketBinding(contents, scopePaths = []) {
17
24
  const text = normalizeRuleText(contents);
18
25
  if (!hasTicketBindingCues(text))
19
26
  return false;
27
+ if (!hasTowerFirstCue(text))
28
+ return false;
20
29
  if (!hasRepoScopeGuard(text))
21
30
  return false;
22
31
  if (scopePaths.some((scopePath) => !hasScopePath(text, scopePath)))
23
32
  return false;
24
- if (!hasTicketLookupOrCreationCue(text))
25
- return false;
26
33
  // Contents without the QA-receipts rule are the older vocabulary and must
27
34
  // read as stale — otherwise machines with a hand-written ticket-binding
28
35
  // section never receive the definition-of-done rule.
@@ -30,33 +37,30 @@ export function hasEquivalentUnmanagedTicketBinding(contents, scopePaths = []) {
30
37
  return false;
31
38
  const signals = [
32
39
  /cockpit\s+start\s+--ticket\b/u,
33
- /before\s+(?:the\s+)?first\s+code\s+edit|before\s+ticketed\s+implementation/u,
34
- /general\s+ambient/u,
35
- /cockpit\s+sync\s+--repo|cockpit\s+sync\s+--workspace|fresh\s+ticket\/session\s+binding\s+metadata/u,
36
- /use\s+--ticket|the\s+flag\s+is\s+--ticket|do\s+not\s+invent\s+--ticketid/u,
40
+ /before\s+the\s+first\s+edit\b/u,
41
+ /cockpit\s+doctor\s+repairs\s+the\s+machine\b/u,
42
+ /file\s+a\s+tower\s+ticket\s+and\s+get\s+it\s+fixed\b/u,
37
43
  ];
38
44
  const score = signals.filter((signal) => signal.test(text)).length;
39
- return score >= 4;
45
+ return score >= 3;
40
46
  }
41
47
  function hasQaReceiptsCue(text) {
42
48
  return /computer-use\s+qa\s+pass/u.test(text)
43
49
  && /receipts/u.test(text);
44
50
  }
45
- function hasTicketLookupOrCreationCue(text) {
46
- return /search\s+linear|create\s+(?:a\s+)?(?:narrow\s+)?linear\s+ticket|new\s+linear\s+ticket/u.test(text);
51
+ function hasTowerFirstCue(text) {
52
+ return /we\s+build\s+tower.*use\s+it\s+first/u.test(text) || /tower\s+first/u.test(text);
47
53
  }
48
54
  function hasScopePath(text, scopePath) {
49
55
  return text.includes(normalizeRuleText(path.resolve(scopePath)));
50
56
  }
51
57
  function hasRepoScopeGuard(text) {
52
- return (/only\s+applies\s+when\s+the\s+current\s+working\s+directory\s+is\s+inside/u.test(text) ||
53
- /outside\s+that\s+(?:folder|workspace|repo).*(?:do\s+not|dont)\s+run\s+cockpit/u.test(text) ||
54
- /private\s+chats\s+or\s+unrelated\s+repos/u.test(text));
58
+ return /only:\s*bind\s+ticketed\s+work\s+before\s+the\s+first\s+edit\b/u.test(text);
55
59
  }
56
60
  export function findStaleUnmanagedTicketBindingBlock(contents, scopePaths = []) {
57
61
  const lines = contents.split("\n");
58
62
  for (let index = 0; index < lines.length; index += 1) {
59
- if (!/^#{1,6}\s+.*(?:cockpit\s+)?ticket\s+binding\b/iu.test(lines[index] ?? "")) {
63
+ if (!/^#{1,6}\s+.*(?:(?:cockpit\s+)?ticket\s+binding|tower\s+first)\b/iu.test(lines[index] ?? "")) {
60
64
  continue;
61
65
  }
62
66
  let endLine = lines.length;
@@ -86,7 +90,7 @@ export function replaceLineSpan(contents, startLine, endLine, replacement) {
86
90
  .trimEnd() + "\n";
87
91
  }
88
92
  function hasTicketBindingCues(text) {
89
- return /\bcockpit\b/u.test(text) && /\bticket\b/u.test(text) && /binding|agent|linear/u.test(text);
93
+ return /\bcockpit\b/u.test(text) && /\bticket\b/u.test(text) && /binding|agent|linear|tower|doctor/u.test(text);
90
94
  }
91
95
  function normalizeRuleText(contents) {
92
96
  return contents
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { claudeFoldsIntoCodexInclude } from "./agent-rules-claude-fold.js";
4
5
  import { findStaleUnmanagedTicketBindingBlock, hasEquivalentUnmanagedTicketBinding, replaceLineSpan, } from "./agent-rules-unmanaged-block.js";
5
6
  import { describeError, isMissingFileFailure } from "./health-detail.js";
6
7
  const MANAGED_BLOCK_START = "<!-- BLI_COCKPIT_AGENT_RULES:START -->";
@@ -12,11 +13,13 @@ export async function installClaudeAgentRules(options = {}) {
12
13
  return installAgentRulesForHost("claude", options);
13
14
  }
14
15
  export async function installAgentRules(options = {}) {
15
- const targets = await Promise.all(targetHosts(options.hosts).map((host) => installAgentRulesForHost(host, options)));
16
+ const hosts = targetHosts(options.hosts);
17
+ const targets = await Promise.all(hosts.map((host) => installAgentRulesForHost(host, options, hosts)));
16
18
  return aggregateAgentRulesResult(targets);
17
19
  }
18
- async function installAgentRulesForHost(host, options = {}) {
19
- const rulesFile = agentRulesFile(host, options.homeDir);
20
+ async function installAgentRulesForHost(host, options = {}, hostsInCall = [host]) {
21
+ const homeDir = options.homeDir ?? os.homedir();
22
+ const rulesFile = agentRulesFile(host, homeDir);
20
23
  const block = cockpitAgentRulesBlock({
21
24
  scopePath: options.scopePath,
22
25
  scopePaths: options.scopePaths,
@@ -41,6 +44,9 @@ async function installAgentRulesForHost(host, options = {}) {
41
44
  }
42
45
  existed = false;
43
46
  }
47
+ if (claudeFoldsIntoCodexInclude(host, hostsInCall, existing, homeDir)) {
48
+ return foldClaudeIntoCodexInclude(host, rulesFile, existing, block);
49
+ }
44
50
  const prepared = prepareManagedBlockInstall(existing, block, scopePaths);
45
51
  if (!prepared.next) {
46
52
  return agentRulesResult(host, rulesFile, "unchanged", block, prepared.state);
@@ -53,6 +59,15 @@ async function installAgentRulesForHost(host, options = {}) {
53
59
  await writeFile(rulesFile, next, "utf8");
54
60
  return agentRulesResult(host, rulesFile, existed ? "updated" : "created", block, "managed", prepared.state === "stale");
55
61
  }
62
+ async function foldClaudeIntoCodexInclude(host, rulesFile, existing, block) {
63
+ if (!hasManagedBlock(existing)) {
64
+ console.info("[agent-rules] claude rules already covered by codex include, skipping duplicate write", JSON.stringify({ reason: "claude_includes_codex_agents_file", host }));
65
+ return agentRulesResult(host, rulesFile, "unchanged", block, "included");
66
+ }
67
+ await writeFile(rulesFile, removeManagedBlock(existing), "utf8");
68
+ console.info("[agent-rules] removed a duplicate managed block now covered by the codex include", JSON.stringify({ reason: "claude_includes_codex_agents_file", host }));
69
+ return agentRulesResult(host, rulesFile, "updated", block, "included");
70
+ }
56
71
  export async function uninstallCodexAgentRules(options = {}) {
57
72
  return uninstallAgentRulesForHost("codex", options);
58
73
  }
@@ -96,14 +111,16 @@ export async function inspectClaudeAgentRules(options = {}) {
96
111
  return inspectAgentRulesForHost("claude", options);
97
112
  }
98
113
  export async function inspectAgentRules(options = {}) {
99
- const targets = await Promise.all(targetHosts(options.hosts).map((host) => inspectAgentRulesForHost(host, options)));
114
+ const hosts = targetHosts(options.hosts);
115
+ const targets = await Promise.all(hosts.map((host) => inspectAgentRulesForHost(host, options, hosts)));
100
116
  return {
101
117
  ...aggregateAgentRulesResult(targets),
102
118
  installed: targets.every((target) => target.installed),
103
119
  };
104
120
  }
105
- async function inspectAgentRulesForHost(host, options = {}) {
106
- const rulesFile = agentRulesFile(host, options.homeDir);
121
+ async function inspectAgentRulesForHost(host, options = {}, hostsInCall = [host]) {
122
+ const homeDir = options.homeDir ?? os.homedir();
123
+ const rulesFile = agentRulesFile(host, homeDir);
107
124
  const block = cockpitAgentRulesBlock({
108
125
  scopePath: options.scopePath,
109
126
  scopePaths: options.scopePaths,
@@ -129,8 +146,15 @@ async function inspectAgentRulesForHost(host, options = {}) {
129
146
  installed: false,
130
147
  };
131
148
  }
149
+ if (claudeFoldsIntoCodexInclude(host, hostsInCall, existing, homeDir)) {
150
+ console.info("[agent-rules] claude rules read as installed via the codex include", JSON.stringify({ reason: "claude_includes_codex_agents_file", host }));
151
+ return {
152
+ ...agentRulesResult(host, rulesFile, "unchanged", block, "included"),
153
+ installed: true,
154
+ };
155
+ }
132
156
  const state = inspectAgentRulesContents(existing, block, scopePaths);
133
- const installed = state === "managed" || state === "equivalent";
157
+ const installed = state === "managed" || state === "equivalent" || state === "included";
134
158
  return {
135
159
  ...agentRulesResult(host, rulesFile, installed ? "unchanged" : "missing", block, state),
136
160
  installed,
@@ -138,20 +162,17 @@ async function inspectAgentRulesForHost(host, options = {}) {
138
162
  }
139
163
  export function cockpitAgentRulesBlock(options = {}) {
140
164
  const scopePaths = normalizeScopePaths(options);
141
- const scopeLine = scopePaths.length === 1
142
- ? `- Only applies when the current working directory is inside the Tower-onboarded workspace/repo: \`${scopePaths[0]}\`. Outside that folder, do not run Tower ticket binding or sync commands for private chats or unrelated repos.`
165
+ const scopeDescription = scopePaths.length === 1
166
+ ? `the Tower-onboarded workspace/repo \`${scopePaths[0]}\``
143
167
  : scopePaths.length > 1
144
- ? `- Only applies when the current working directory is inside one of these Tower-onboarded workspace roots: ${scopePaths.map((scopePath) => `\`${scopePath}\``).join(", ")}. Outside those folders, do not run Tower ticket binding or sync commands for private chats or unrelated repos.`
145
- : "- Only applies when the current working directory is inside the workspace/repo that ran `cockpit doctor` or `cockpit agent-rules install`. Outside that folder, do not run Tower ticket binding or sync commands for private chats or unrelated repos.";
168
+ ? `one of these Tower-onboarded workspace roots: ${scopePaths.map((scopePath) => `\`${scopePath}\``).join(", ")}`
169
+ : "the workspace/repo that ran `cockpit doctor` or `cockpit agent-rules install`";
146
170
  return [
147
171
  MANAGED_BLOCK_START,
148
- "## Tower Ticket Binding",
172
+ "## Tower first",
149
173
  "",
150
- scopeLine,
151
- "- Ticketed work (implement / debug / review / PR / ship): run `cockpit start --ticket <ticket-id> --workspace \"$PWD\"` before the first code edit (the flag is `--ticket`). No ticket ID visible → search Linear first; none exists and the work is ticket-worthy → create a narrow Linear ticket, then bind.",
152
- "- If setup or collection needs repair, run `cockpit doctor`. It repairs this machine and prints any remaining action.",
153
- "- Truly no ticket → say the session stays in general ambient capture; never invent one.",
154
- "- After the first meaningful checkpoint, run `cockpit sync --workspace \"$PWD\" --json`.",
174
+ "- We build Tower, so we use it first. Tickets (`cockpit work`), docs (`cockpit docs`), meetings (`cockpit cal`), mail, messages and search go through Tower before Linear, Notion or Google. If a Tower tool is missing, broken or clumsy, file a Tower ticket and get it fixed.",
175
+ `- In ${scopeDescription} only: bind ticketed work before the first edit with \`cockpit start --ticket <id> --workspace "$PWD"\`; \`cockpit doctor\` repairs the machine.`,
155
176
  "",
156
177
  "## Definition of done — QA receipts",
157
178
  "",
@@ -15,6 +15,53 @@ export const TAG = "[issue cli]";
15
15
  export const READ_DEADLINE_MS = 30_000;
16
16
  export const WRITE_DEADLINE_MS = 60_000;
17
17
  export const BODY_MAX_CHARS = 200_000;
18
+ /**
19
+ * `--size S|M|L` (BLI-10575) maps onto the existing `work_issues.estimate`
20
+ * numeric column — no migration, since a raw estimate already round-trips
21
+ * through every door untouched (an imported Linear estimate travels through
22
+ * unmodified, `docs/runbooks/linear-sync.md`). These three numbers are
23
+ * Tower's OWN convention for a person who wants to size a ticket without
24
+ * picking a raw number, not a scale Linear enforces: S=1, M=3, L=5. A stored
25
+ * estimate outside this set (an imported Linear point value, or a number set
26
+ * some other way) simply has no size label and prints as its plain number.
27
+ */
28
+ export const SIZE_ESTIMATES = { S: 1, M: 3, L: 5 };
29
+ /** `--size` on the command line, case-insensitive; anything else is refused here rather than reaching the door as a mystery number. */
30
+ export function estimateForSize(value) {
31
+ const key = value.trim().toUpperCase();
32
+ if (key !== "S" && key !== "M" && key !== "L") {
33
+ throw new Error("--size must be S, M, or L.");
34
+ }
35
+ return SIZE_ESTIMATES[key];
36
+ }
37
+ /** The reverse: an estimate the door returned, back to a size letter — or null when it does not match Tower's own scale. */
38
+ export function sizeForEstimate(estimate) {
39
+ if (estimate === null || estimate === undefined)
40
+ return null;
41
+ for (const key of Object.keys(SIZE_ESTIMATES)) {
42
+ if (SIZE_ESTIMATES[key] === estimate)
43
+ return key;
44
+ }
45
+ return null;
46
+ }
47
+ /**
48
+ * `size` alongside the raw `estimate`, for JSON output — a convenience
49
+ * label, never a second source of truth.
50
+ *
51
+ * `T extends object` rather than `T extends { estimate?: ... }`: TypeScript
52
+ * refuses an all-optional ("weak") object type when the value shares NO
53
+ * property with it at all (TS2559), which is exactly what a `{}` fallback
54
+ * elsewhere in this file looks like. Reading `estimate` off an `object` cast
55
+ * keeps the same runtime behaviour without that false refusal.
56
+ */
57
+ export function withSizeLabel(row) {
58
+ const estimate = row.estimate;
59
+ return { ...row, size: sizeForEstimate(estimate ?? null) };
60
+ }
61
+ /** The same, when the row itself might be absent (a door answer with no `issue`). */
62
+ export function maybeWithSizeLabel(row) {
63
+ return row === undefined ? undefined : withSizeLabel(row);
64
+ }
18
65
  /** A project id from a project NAME or a uuid — exact after case-folding. */
19
66
  export async function resolveProjectId(door, ref) {
20
67
  const answer = await askAgentDoor(door, {
@@ -15,7 +15,7 @@ import { mirroredTicketIdInTitle } from "@bli-cockpit/telemetry-core";
15
15
  import { askAgentDoor, emitAgentDoor, failAgentDoor, } from "./agent-door.js";
16
16
  import { writeLine } from "./cli-io.js";
17
17
  import { assigneeLabel, resolveAssigneeDirectory, withAssigneeIdentity } from "./issue-directory.js";
18
- import { READ_DEADLINE_MS, TAG } from "./issue-contracts.js";
18
+ import { READ_DEADLINE_MS, TAG, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
19
19
  async function fetchOneIssue(door, ref) {
20
20
  const answer = await askAgentDoor(door, {
21
21
  path: `/api/work/issues/${encodeURIComponent(ref)}`,
@@ -61,7 +61,8 @@ async function fetchOneIssue(door, ref) {
61
61
  function renderOneIssue(door, result, directory) {
62
62
  const { issue, comments, linearTicketId } = result;
63
63
  writeLine(door.io.stdout, `${issue.identifier} ${issue.title}`);
64
- writeLine(door.io.stdout, `${issue.state} · priority ${issue.priority ?? 0} · assignee ${assigneeLabel(issue.assignee_id, directory)} · updated ${issue.updated_at}`);
64
+ const size = sizeForEstimate(issue.estimate ?? null);
65
+ writeLine(door.io.stdout, `${issue.state} · priority ${issue.priority ?? 0}${size ? ` · size ${size}` : ""} · assignee ${assigneeLabel(issue.assignee_id, directory)} · updated ${issue.updated_at}`);
65
66
  writeLine(door.io.stdout, `id ${issue.id}${issue.parent_id ? ` · parent ${issue.parent_id}` : ""}`);
66
67
  if (linearTicketId)
67
68
  writeLine(door.io.stdout, `Linear: ${linearTicketId}`);
@@ -99,7 +100,7 @@ export async function showIssue(command, door) {
99
100
  if (door.json) {
100
101
  return emitAgentDoor(door, {
101
102
  ok: true,
102
- issue: withAssigneeIdentity(result.issue, directory),
103
+ issue: withSizeLabel(withAssigneeIdentity(result.issue, directory)),
103
104
  comments: result.comments,
104
105
  ...(result.linearTicketId ? { linear_ticket_id: result.linearTicketId } : {}),
105
106
  ...(result.commentsUnreadReason ? { comments_unread_reason: result.commentsUnreadReason } : {}),
@@ -119,7 +120,7 @@ export async function showIssue(command, door) {
119
120
  ? {
120
121
  ref: result.ref,
121
122
  ok: true,
122
- issue: withAssigneeIdentity(result.issue, directory),
123
+ issue: withSizeLabel(withAssigneeIdentity(result.issue, directory)),
123
124
  comments: result.comments,
124
125
  ...(result.linearTicketId ? { linear_ticket_id: result.linearTicketId } : {}),
125
126
  ...(result.commentsUnreadReason ? { comments_unread_reason: result.commentsUnreadReason } : {}),
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { askAgentDoor, emitAgentDoor, failAgentDoor, } from "./agent-door.js";
11
11
  import { writeLine } from "./cli-io.js";
12
- import { TAG, WRITE_DEADLINE_MS, readBody, resolveRefs, } from "./issue-contracts.js";
12
+ import { TAG, WRITE_DEADLINE_MS, maybeWithSizeLabel, readBody, resolveRefs, } from "./issue-contracts.js";
13
13
  export async function createIssue(command, door) {
14
14
  if (!command.title)
15
15
  return failAgentDoor(door, TAG, "invalid_body", "issue create needs --title.");
@@ -30,12 +30,13 @@ export async function createIssue(command, door) {
30
30
  ...(refs.projectId ? { project_id: refs.projectId } : {}),
31
31
  ...(refs.parentId ? { parent_id: refs.parentId } : {}),
32
32
  ...(command.priority === undefined ? {} : { priority: command.priority }),
33
+ ...(command.estimate === undefined ? {} : { estimate: command.estimate }),
33
34
  ...(command.assignee ? { assignee_id: command.assignee } : {}),
34
35
  },
35
36
  });
36
37
  if (!answer.ok)
37
38
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
38
- const issue = answer.body.issue;
39
+ const issue = maybeWithSizeLabel(answer.body.issue);
39
40
  writeLine(door.io.stderr, `${TAG} created ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null, description_bytes: Buffer.byteLength(body.text, "utf8") })}`);
40
41
  if (door.json)
41
42
  return emitAgentDoor(door, { ok: true, issue });
@@ -57,10 +58,11 @@ export async function updateIssue(command, door) {
57
58
  if (description === undefined
58
59
  && command.title === undefined
59
60
  && command.priority === undefined
61
+ && command.estimate === undefined
60
62
  && command.assignee === undefined
61
63
  && refs.projectId === undefined
62
64
  && refs.parentId === undefined) {
63
- return failAgentDoor(door, TAG, "invalid_body", "issue update needs at least one of --title, --priority, --assignee, --project, --parent, or a description on --body-stdin/--file.");
65
+ return failAgentDoor(door, TAG, "invalid_body", "issue update needs at least one of --title, --priority, --size, --assignee, --project, --parent, or a description on --body-stdin/--file.");
64
66
  }
65
67
  const answer = await askAgentDoor(door, {
66
68
  path: `/api/work/issues/${encodeURIComponent(ref)}`,
@@ -71,6 +73,7 @@ export async function updateIssue(command, door) {
71
73
  ...(command.title !== undefined ? { title: command.title } : {}),
72
74
  ...(description !== undefined ? { description } : {}),
73
75
  ...(command.priority === undefined ? {} : { priority: command.priority }),
76
+ ...(command.estimate === undefined ? {} : { estimate: command.estimate }),
74
77
  ...(command.assignee ? { assignee_id: command.assignee } : {}),
75
78
  ...(refs.projectId ? { project_id: refs.projectId } : {}),
76
79
  ...(refs.parentId ? { parent_id: refs.parentId } : {}),
@@ -78,7 +81,7 @@ export async function updateIssue(command, door) {
78
81
  });
79
82
  if (!answer.ok)
80
83
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
81
- const issue = answer.body.issue;
84
+ const issue = maybeWithSizeLabel(answer.body.issue);
82
85
  writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null })}`);
83
86
  if (door.json)
84
87
  return emitAgentDoor(door, { ok: true, issue });
@@ -29,7 +29,7 @@ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./ag
29
29
  import { writeLine } from "./cli-io.js";
30
30
  import { assigneeLabel, resolveAssigneeDirectory, withAssigneeIdentity } from "./issue-directory.js";
31
31
  import { showIssue } from "./issue-show.js";
32
- import { READ_DEADLINE_MS, TAG, resolveProjectId, } from "./issue-contracts.js";
32
+ import { READ_DEADLINE_MS, TAG, resolveProjectId, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
33
33
  import { commentIssue, createIssue, moveIssue, updateIssue } from "./issue-write.js";
34
34
  export async function runIssue(command, io) {
35
35
  const door = await openAgentDoor("issue", command, io);
@@ -52,7 +52,9 @@ export async function runIssue(command, io) {
52
52
  }
53
53
  function issueLine(issue, directory) {
54
54
  const assignee = issue.assignee_id ? ` [${assigneeLabel(issue.assignee_id, directory)}]` : "";
55
- return `${issue.identifier.padEnd(9)} ${issue.state.padEnd(11)} ${issue.title}${assignee}`;
55
+ const size = sizeForEstimate(issue.estimate ?? null);
56
+ const sizeTag = size ? ` (${size})` : "";
57
+ return `${issue.identifier.padEnd(9)} ${issue.state.padEnd(11)} ${issue.title}${sizeTag}${assignee}`;
56
58
  }
57
59
  /**
58
60
  * What the list did not show (BLI-10447): a capped window of a larger set,
@@ -123,7 +125,7 @@ async function listIssues(command, door) {
123
125
  if (door.json) {
124
126
  return emitAgentDoor(door, {
125
127
  ok: true,
126
- issues: issues.map((issue) => withAssigneeIdentity(issue, directory)),
128
+ issues: issues.map((issue) => withSizeLabel(withAssigneeIdentity(issue, directory))),
127
129
  ...(body.completeness ? { completeness: body.completeness } : {}),
128
130
  ...(body.outside_project ? { outside_project: body.outside_project } : {}),
129
131
  });
@@ -16,6 +16,7 @@
16
16
  * shared machine and lands in shell history).
17
17
  */
18
18
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
19
+ import { estimateForSize } from "./issue-contracts.js";
19
20
  const ISSUE_ACTIONS = new Set([
20
21
  "list",
21
22
  "show",
@@ -54,6 +55,7 @@ export function parseIssueArgs(args) {
54
55
  "--limit",
55
56
  "--title",
56
57
  "--priority",
58
+ "--size",
57
59
  "--parent",
58
60
  "--file",
59
61
  "--body-stdin",
@@ -68,6 +70,7 @@ export function parseIssueArgs(args) {
68
70
  "--limit",
69
71
  "--title",
70
72
  "--priority",
73
+ "--size",
71
74
  "--parent",
72
75
  "--file",
73
76
  ],
@@ -78,9 +81,10 @@ export function parseIssueArgs(args) {
78
81
  const stateFilter = readIssueStateFilter(action, values);
79
82
  const title = readIssueTitle(action, values);
80
83
  const priority = readIssuePriority(values);
84
+ const estimate = readIssueSize(values);
81
85
  const assignee = readIssueAssignee(action, values);
82
86
  const limit = readIssueLimit(action, values);
83
- return buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, assignee, limit);
87
+ return buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit);
84
88
  }
85
89
  function readIssueAction(values) {
86
90
  const first = values.positionals[0];
@@ -157,6 +161,10 @@ function readIssuePriority(values) {
157
161
  }
158
162
  return priority;
159
163
  }
164
+ function readIssueSize(values) {
165
+ const raw = optionalNonEmpty(values.flags.get("--size"));
166
+ return raw === undefined ? undefined : estimateForSize(raw);
167
+ }
160
168
  function readIssueAssignee(action, values) {
161
169
  // An assignee is `me`, `unassigned`, or a person's uuid. A name or an
162
170
  // email is refused HERE rather than travelling to Postgres as a malformed
@@ -177,7 +185,7 @@ function readIssueLimit(action, values) {
177
185
  }
178
186
  return limit;
179
187
  }
180
- function buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, assignee, limit) {
188
+ function buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit) {
181
189
  return {
182
190
  kind: "issue",
183
191
  action,
@@ -192,6 +200,7 @@ function buildIssueCommand(values, action, issueRef, issueRefs, moveState, state
192
200
  ...(limit === undefined ? {} : { limit }),
193
201
  title,
194
202
  ...(priority === undefined ? {} : { priority }),
203
+ ...(estimate === undefined ? {} : { estimate }),
195
204
  parentRef: optionalNonEmpty(values.flags.get("--parent")),
196
205
  filePath: optionalNonEmpty(values.flags.get("--file")),
197
206
  bodyStdin: values.booleans.has("--body-stdin"),
@@ -91,11 +91,11 @@ export const TOWER_COMMAND_HELP = [
91
91
  "Usage: cockpit issue [list|show <id> [<id>...]|create|update <id>|move <id> <state>|comment <id>|history <id>] [flags]",
92
92
  "",
93
93
  "Tower's issue tracker. <id> is a BLI-#### identifier (BLI-3654) or an issue's uuid — both work everywhere an issue is named.",
94
- "list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] — the issues you may see, most recently updated first. Each row carries assignee_name/assignee_email alongside the bare assignee_id (BLI-10450, one extra `cockpit team members` call for the whole page, never per row). --json shape: { issues: [{ id, identifier, title, state, priority, assignee_id, assignee_name, assignee_email, project_id, parent_id, updated_at }] }.",
95
- "show <id> [<id>...] — one or more issues: title, state, priority, assignee (name and email, not just its id), description, and every comment on each. Several ids read in one call — one bad id never loses the rest.",
96
- " --json shape: one id answers { issue, comments }, unchanged from before BLI-10450. Several ids answer { issues: [{ ref, ok, issue, comments } | { ref, ok: false, error, detail }] }.",
97
- "create --title \"<title>\" [--project <name|id>] [--priority 0-4] [--assignee me|<uuid>] [--parent <id>] [--file <path>] — the DESCRIPTION comes from --file or stdin (`cat plan.md | cockpit issue create --title \"...\"`); neither means no description. --json shape: { issue }.",
98
- "update <id> [--title \"<t>\"] [--priority 0-4] [--assignee me|<uuid>] [--project <name|id>] [--parent <id>] [--file <path>|--body-stdin] — only the fields you pass change. State does NOT move here; use move. --json shape: { issue }.",
94
+ "list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] — the issues you may see, most recently updated first. Each row carries assignee_name/assignee_email alongside the bare assignee_id (BLI-10450, one extra `cockpit team members` call for the whole page, never per row), and a `size` label (S/M/L) alongside the raw `estimate` when it matches Tower's own scale (BLI-10575). --json shape: { issues: [{ id, identifier, title, state, priority, estimate, size, assignee_id, assignee_name, assignee_email, project_id, parent_id, updated_at }] }.",
95
+ "show <id> [<id>...] — one or more issues: title, state, priority, size, assignee (name and email, not just its id), description, and every comment on each. Several ids read in one call — one bad id never loses the rest.",
96
+ " --json shape: one id answers { issue, comments }, unchanged from before BLI-10450 (plus `estimate`/`size`, BLI-10575). Several ids answer { issues: [{ ref, ok, issue, comments } | { ref, ok: false, error, detail }] }.",
97
+ "create --title \"<title>\" [--project <name|id>] [--priority 0-4] [--size S|M|L] [--assignee me|<uuid>] [--parent <id>] [--file <path>] — the DESCRIPTION comes from --file or stdin (`cat plan.md | cockpit issue create --title \"...\"`); neither means no description. --size maps onto the existing `estimate` column, Tower's own scale: S=1, M=3, L=5 (BLI-10575) — an estimate outside that scale (an imported Linear point value, say) just has no size label on a read. --json shape: { issue }.",
98
+ "update <id> [--title \"<t>\"] [--priority 0-4] [--size S|M|L] [--assignee me|<uuid>] [--project <name|id>] [--parent <id>] [--file <path>|--body-stdin] — only the fields you pass change. State does NOT move here; use move. --json shape: { issue }.",
99
99
  "move <id> <state> — moves an issue and records the move. States: backlog, todo, in_progress, in_review, done, canceled.",
100
100
  "comment <id> — posts a comment. The body is never taken on the command line: `echo \"shipped\" | cockpit issue comment BLI-3654`.",
101
101
  "history <id> [--limit <n>] — every recorded state move and reassignment, oldest first.",
@@ -330,10 +330,12 @@ export function localSubcommandHelp(command) {
330
330
  [
331
331
  "Usage: cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
332
332
  "",
333
- "Installs a managed Tower Ticket Binding block into ~/.codex/AGENTS.md",
333
+ "Installs a managed Tower-first block into ~/.codex/AGENTS.md",
334
334
  "and ~/.claude/CLAUDE.md by default. Pass --host to manage only one.",
335
335
  "The block is scoped to --workspace, or the current directory when omitted,",
336
- "so Codex and Claude only run Tower ticket binding inside that onboarded folder.",
336
+ "so ticketed work only binds inside that onboarded folder.",
337
+ "If CLAUDE.md already includes ~/.codex/AGENTS.md and both hosts are",
338
+ "installed together, CLAUDE.md is left to the include instead of duplicating it.",
337
339
  "Action defaults to `install`.",
338
340
  ],
339
341
  ],
@@ -119,7 +119,7 @@ export function localCommandHelp(command) {
119
119
  " cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
120
120
  " cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>|delete <id|slug>] [--parent <id>|root|--clear-parent] [--query <text> [--word]] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
121
121
  " 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]",
122
- " 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]",
122
+ " 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] [--size S|M|L] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
123
123
  " 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]",
124
124
  " cockpit cal [today|week|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
125
125
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.144");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.145");
25
25
  return 0;
26
26
  }
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.144",
3
+ "version": "0.2.145",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.36",
31
- "@bli-cockpit/mcp": "0.1.69",
31
+ "@bli-cockpit/mcp": "0.1.70",
32
32
  "@bli-cockpit/telemetry-core": "0.1.48"
33
33
  }
34
34
  }