@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.
- package/dist/agent-rules-claude-fold.js +30 -0
- package/dist/agent-rules-unmanaged-block.js +20 -16
- package/dist/agent-rules.js +38 -17
- package/dist/commands/issue-contracts.js +47 -0
- package/dist/commands/issue-show.js +5 -4
- package/dist/commands/issue-write.js +7 -4
- package/dist/commands/issue.js +5 -3
- package/dist/commands/local-args-tower-work.js +11 -2
- package/dist/commands/local-help-commands-tower.js +5 -5
- package/dist/commands/local-help-commands.js +4 -2
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/public-root.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
|
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+
|
|
34
|
-
/
|
|
35
|
-
/
|
|
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 >=
|
|
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
|
|
46
|
-
return /
|
|
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
|
|
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
|
package/dist/agent-rules.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
142
|
-
?
|
|
165
|
+
const scopeDescription = scopePaths.length === 1
|
|
166
|
+
? `the Tower-onboarded workspace/repo \`${scopePaths[0]}\``
|
|
143
167
|
: scopePaths.length > 1
|
|
144
|
-
?
|
|
145
|
-
: "
|
|
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
|
|
172
|
+
"## Tower first",
|
|
149
173
|
"",
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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 });
|
package/dist/commands/issue.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.70",
|
|
32
32
|
"@bli-cockpit/telemetry-core": "0.1.48"
|
|
33
33
|
}
|
|
34
34
|
}
|