ticketlens 0.30.0 → 0.31.0

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/README.md CHANGED
@@ -413,7 +413,9 @@ Every note is scanned before saving — anything shaped like a real secret (API
413
413
 
414
414
  **Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same Pro gate, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
415
415
 
416
- `note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead.
416
+ `note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead. Each result shows a relative time (`2h ago`, `3d ago`) rather than a bare date — the full-precision timestamp is always in the note file's own frontmatter.
417
+
418
+ **Tags matter for search relevance.** `--tags=a,b` accepts anything, but a generic tag (the project name, "gotcha", "bug") gives future search almost nothing to match on. Tag with what the note is actually *about* — the specific technology, error type, or root cause (`retry-backoff`, `null-pointer`, `auth-middleware`) — so it surfaces when someone else hits the same problem.
417
419
 
418
420
  **Gaps** — every `ticketlens PROJ-123` brief also diffs the ticket's own description against its linked tickets (from the depth traversal you already requested) and its own downloaded attachments, looking for requirements mentioned there but missing here. Anything uncovered shows up under a `## Gaps` section, citing exactly where it came from — a linked ticket key or an attachment filename — as evidence, never an instruction to act on. Nothing is saved anywhere; it's recomputed fresh on every fetch. Requires a Pro license, same as Recall. No network call beyond what the brief already made.
419
421
 
@@ -542,7 +542,7 @@ switch (command) {
542
542
  case 'cloud-keys': {
543
543
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printCloudKeysHelp(); break; }
544
544
 
545
- const { listCloudKeys, addCloudKey, removeCloudKey, setPriority, setTimeout_, testCloudKey } =
545
+ const { listCloudKeys, addCloudKey, runCloudKeysRemove, setPriority, setTimeout_, testCloudKey } =
546
546
  await import('../skills/jtb/scripts/lib/cloud-keys.mjs');
547
547
 
548
548
  const cliToken = readCliToken();
@@ -592,12 +592,14 @@ switch (command) {
592
592
  if (subCmd === 'remove') {
593
593
  const provider = cmdArgs[1];
594
594
  if (!provider) {
595
- process.stderr.write('Usage: ticketlens cloud-keys remove <provider>\n');
595
+ process.stderr.write('Usage: ticketlens cloud-keys remove <provider> [--yes|-y]\n');
596
596
  process.exitCode = 1;
597
597
  return;
598
598
  }
599
- await removeCloudKey(cfg, provider);
600
- process.stdout.write(`${s.brand('✓')} ${provider} key removed.\n`);
599
+ const forceYes = cmdArgs.includes('--yes') || cmdArgs.includes('-y');
600
+ const { removed } = await runCloudKeysRemove(cfg, provider, { forceYes });
601
+ if (removed) process.stdout.write(`${s.brand('✓')} ${provider} key removed.\n`);
602
+ else process.exitCode = 1;
601
603
  return;
602
604
  }
603
605
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.26.0 -->
1
+ <!-- jtb-skill-version: 0.27.0 -->
2
2
  ---
3
3
  name: jtb
4
4
  description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
@@ -66,6 +66,8 @@ Fetches a Jira ticket and produces a structured brief with code references, then
66
66
  /jtb assign PROD-1234 --to=me # assign the ticket to yourself (Pro)
67
67
  ```
68
68
 
69
+ **Destructive commands** (`note delete`, `cloud-keys remove`, `delete <profile>`) prompt for interactive y/N confirmation and refuse outright in a non-interactive shell unless `--yes` is passed — there is no way to silently skip this. Only pass `--yes` when the user has explicitly asked for that specific deletion in this conversation (their message *is* the confirmation); never add it to route around the prompt for a deletion you decided to make on your own.
70
+
69
71
  ## Prerequisites
70
72
 
71
73
  TicketLens supports two connection methods — check in this order:
@@ -91,7 +93,7 @@ If the first argument is `triage`:
91
93
 
92
94
  Run:
93
95
  ```bash
94
- node ~/.agents/skills/jtb/scripts/fetch-my-tickets.mjs $EXTRA_ARGS
96
+ ticketlens triage $EXTRA_ARGS
95
97
  ```
96
98
 
97
99
  Where `$EXTRA_ARGS` are any flags passed (e.g. `--stale=3 --status=QA --profile=acme`).
@@ -112,7 +114,7 @@ If the first argument is `collisions`:
112
114
 
113
115
  Run:
114
116
  ```bash
115
- node ~/.agents/skills/jtb/scripts/lib/run-collisions.mjs $EXTRA_ARGS
117
+ ticketlens collisions $EXTRA_ARGS
116
118
  ```
117
119
 
118
120
  Where `$EXTRA_ARGS` are any flags passed (e.g. `--json`, `--plain`).
@@ -136,7 +138,7 @@ Follow the Prerequisites section above:
136
138
 
137
139
  Run:
138
140
  ```bash
139
- node ~/.agents/skills/jtb/scripts/fetch-ticket.mjs "$TICKET_KEY" $EXTRA_ARGS
141
+ ticketlens "$TICKET_KEY" $EXTRA_ARGS
140
142
  ```
141
143
 
142
144
  Where `$TICKET_KEY` is the first argument (e.g. `PROD-1234`) and `$EXTRA_ARGS` are any flags passed (e.g. `--depth=0`).
@@ -234,9 +236,11 @@ echo "The body text of the note, one or more paragraphs." | \
234
236
  ticketlens note add --title="Short title" --ticket=TICKET-KEY --tags=a,b
235
237
  ```
236
238
 
239
+ **Choosing tags:** derive them from this note's actual content — the specific technology, error type, root cause, or affected component (e.g. `retry-backoff`, `null-pointer`, `auth-middleware`) — never the project name or a generic category word like `gotcha` or `bug`. A tag like `jtb` or `ticketlens` tells a future search nothing that the ticket/project context doesn't already say; a tag like `retry-backoff` is what actually surfaces this note when someone else hits the same problem. Same rule whether you're constructing the bash command above or calling `recall_add` directly — see its tool description for the same guidance.
240
+
237
241
  To search saved notes directly (outside of automatic brief injection): `ticketlens recall "<query>"`.
238
242
 
239
- **Pick exactly one path per capture — never both.** If this harness has TicketLens's MCP server configured (`ticketlens mcp` — `recall_add`/`recall_search` as native tools, see `ticketlens mcp --help`), prefer calling those tools directly over the bash commands above — same license gate, same secret scan, same vault, same team sync, just no shell command to construct. Fall back to the bash form only when the MCP tools aren't available. Calling both for the same insight creates two near-duplicate notes (no dedup exists between the two paths) and, with team sync on, two separate pushes for a manager to review.
243
+ **Pick exactly one path per capture — never both.** If this harness has TicketLens's MCP server configured (tools named `recall_add`/`recall_search` — often shown as `mcp__ticketlens__recall_add` — visible in your tool list), **use those tools, not the bash commands above** — same license gate, same secret scan, same vault, same team sync, just no shell command to construct. Only fall back to the bash form when the MCP tools are genuinely absent from your tool list. If they're absent because this project has never registered the server, tell the user once: `ticketlens mcp install` writes (or merges into) this project's `.mcp.json` — don't run it yourself unprompted, since it changes what your harness auto-connects to on next launch, and the user should be the one deciding that. Calling both for the same insight creates two near-duplicate notes (no dedup exists between the two paths) and, with team sync on, two separate pushes for a manager to review.
240
244
 
241
245
  ### Quality loop (Pro, in-session only)
242
246
 
@@ -284,7 +288,7 @@ ticketlens duplicates PROD-1234 # find likely dupl
284
288
 
285
289
  The three write actions (comment/transition/assign) have a short local debounce (10s) against an accidental double-fire, and every write is appended to a local audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — surface the failure to the user rather than silently re-attempting, since a ticket write isn't naturally idempotent the way a Recall note save is. `duplicates` has neither, since nothing is written.
286
290
 
287
- **Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (`ticketlens mcp` — `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates` as native tools, see `ticketlens mcp --help`), prefer calling those tools directly over the bash commands above — same license gate, same cooldown, same audit log. Fall back to the bash form only when the MCP tools aren't available.
291
+ **Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (tools named `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates`/`ticket_link`/`ticket_update`/`ticket_create` — often shown as `mcp__ticketlens__ticket_comment` etc. — visible in your tool list), **use those tools, not the bash commands above** — same license gate, same cooldown, same audit log. Only fall back to the bash form when the MCP tools are genuinely absent from your tool list; if that's because this project has never registered the server, see the `ticketlens mcp install` note above (Recall section) — same guidance applies here.
288
292
 
289
293
  Requires a Pro license — on Free, all four no-op with an upgrade hint on stderr.
290
294
 
@@ -130,6 +130,10 @@ export function parseCommand(args) {
130
130
  return { command: 'mcp', args: args.slice(1) };
131
131
  }
132
132
 
133
+ if (first === 'cloud-keys') {
134
+ return { command: 'cloud-keys', args: args.slice(1) };
135
+ }
136
+
133
137
  if (first === 'comment') {
134
138
  return { command: 'comment', args: args.slice(1) };
135
139
  }
@@ -4,6 +4,8 @@
4
4
  * All operations require a CLI token (set via `ticketlens login`).
5
5
  */
6
6
 
7
+ import { confirmDestructive } from './confirm.mjs';
8
+
7
9
  const SUPPORTED_PROVIDERS = ['groq', 'anthropic', 'openai'];
8
10
 
9
11
  function apiBase(config) {
@@ -84,6 +86,29 @@ export async function removeCloudKey(config, provider) {
84
86
  await fetchApi(config, `/v1/ai-providers/${target.id}`, 'DELETE');
85
87
  }
86
88
 
89
+ /**
90
+ * CLI-facing wrapper around removeCloudKey — gates the irreversible remote
91
+ * deletion behind a y/N confirmation (or --yes) before calling it.
92
+ */
93
+ export async function runCloudKeysRemove(config, provider, opts = {}) {
94
+ const {
95
+ stream = process.stderr,
96
+ stdin = process.stdin,
97
+ forceYes = false,
98
+ confirmFn = confirmDestructive,
99
+ removeCloudKeyFn = removeCloudKey,
100
+ } = opts;
101
+
102
+ const confirmed = await confirmFn(`Remove the ${provider} key`, { stdin, stream, forceYes });
103
+ if (!confirmed) {
104
+ stream.write(' Aborted — key was not removed.\n');
105
+ return { removed: false };
106
+ }
107
+
108
+ await removeCloudKeyFn(config, provider);
109
+ return { removed: true };
110
+ }
111
+
87
112
  export async function setPriority(config, provider, priority) {
88
113
  const target = await findProvider(config, provider);
89
114
  await fetchApi(config, `/v1/ai-providers/${target.id}`, 'PUT', { priority: Number(priority) });
@@ -42,10 +42,13 @@ export function getPackageMeta() {
42
42
  };
43
43
  }
44
44
 
45
- /** Human-readable relative time from an ISO date string. */
46
- export function timeAgo(dateStr) {
45
+ /**
46
+ * Human-readable relative time from an ISO date string.
47
+ * @param {{ now?: () => Date }} [opts] - injectable clock, for deterministic tests only
48
+ */
49
+ export function timeAgo(dateStr, { now = () => new Date() } = {}) {
47
50
  if (!dateStr) return '';
48
- const diff = Date.now() - new Date(dateStr).getTime();
51
+ const diff = now().getTime() - new Date(dateStr).getTime();
49
52
  const mins = Math.floor(diff / 60000);
50
53
  if (mins < 60) return `${mins}m ago`;
51
54
  const hours = Math.floor(mins / 60);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Interactive y/N confirmation gate for destructive, unrecoverable actions
3
+ * (deleting a Recall note, removing a stored cloud-provider key, etc).
4
+ * Enter with no input aborts — an accidental keystroke never destroys data.
5
+ * Non-interactive callers must pass forceYes explicitly (--yes/-y at the CLI).
6
+ */
7
+ export async function confirmDestructive(action, opts = {}) {
8
+ const { stdin = process.stdin, stream = process.stderr, forceYes = false } = opts;
9
+
10
+ if (forceYes) return true;
11
+
12
+ if (!stdin.isTTY || !stdin.setRawMode) {
13
+ stream.write(' ✖ Non-interactive mode: pass --yes to confirm without a prompt.\n');
14
+ return false;
15
+ }
16
+
17
+ stream.write(` ${action} — this cannot be restored. Continue? y/N `);
18
+ return new Promise(resolve => {
19
+ stdin.setRawMode(true);
20
+ stdin.resume();
21
+ stdin.once('data', buf => {
22
+ stdin.setRawMode(false);
23
+ stdin.pause();
24
+ const confirmed = buf.toString().toLowerCase() === 'y';
25
+ stream.write(confirmed ? 'y\n' : 'N\n');
26
+ resolve(confirmed);
27
+ });
28
+ });
29
+ }
@@ -574,11 +574,12 @@ export function printNoteHelp({ stream = process.stdout } = {}) {
574
574
  ` Code session — not typically invoked by hand. Every note it writes gets the`,
575
575
  ` same structural and secret-scan checks ${s.brand('note add')} applies to user input.`,
576
576
  '',
577
- ` ${s.bold('ticketlens note delete')} ${s.dim('--id="..." [--ticket=KEY]')} ${s.dim('[Pro]')}`,
577
+ ` ${s.bold('ticketlens note delete')} ${s.dim('--id="..." [--ticket=KEY] [--yes]')} ${s.dim('[Pro]')}`,
578
578
  '',
579
579
  ` Removes a note from your local vault. Local only — if this note was already`,
580
580
  ` pushed to a team, teammates who pulled it keep their copy; deleting it there`,
581
- ` too is a manager action from the Console (Admin > Recall).`,
581
+ ` too is a manager action from the Console (Admin > Recall). In TTY mode,`,
582
+ ` prompts for confirmation before deleting. Pass ${s.cyan('--yes')} (or ${s.cyan('-y')}) to skip the prompt.`,
582
583
  '',
583
584
  ];
584
585
  stream.write(lines.join('\n') + '\n');
@@ -1263,7 +1264,7 @@ export function printCloudKeysHelp({ stream = process.stdout } = {}) {
1263
1264
  '',
1264
1265
  ` ${s.brand('list')} List configured providers`,
1265
1266
  ` ${s.brand('add')} ${s.dim('<provider> <key>')} Add or replace an API key`,
1266
- ` ${s.brand('remove')} ${s.dim('<provider>')} Remove a provider's key`,
1267
+ ` ${s.brand('remove')} ${s.dim('<provider>')} ${s.dim('[--yes]')} Remove a provider's key — prompts for confirmation`,
1267
1268
  ` ${s.brand('test')} ${s.dim('<provider>')} Send a test request through the provider`,
1268
1269
  ` ${s.brand('priority')} ${s.dim('<provider> <N>')} Set priority (lower = tried first)`,
1269
1270
  ` ${s.brand('timeout')} ${s.dim('<provider> <seconds>')} Set per-request timeout`,
@@ -1271,6 +1272,7 @@ export function printCloudKeysHelp({ stream = process.stdout } = {}) {
1271
1272
  ` ${s.bold('OPTIONS')}`,
1272
1273
  '',
1273
1274
  ` ${s.brand('--timeout')}=${s.dim('N')} Timeout in seconds when adding a key ${s.dim('(default: 5)')}`,
1275
+ ` ${s.brand('--yes')}, ${s.brand('-y')} Skip the confirmation prompt when removing a key`,
1274
1276
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
1275
1277
  '',
1276
1278
  ` ${s.bold('PROVIDERS')}`,
@@ -36,7 +36,7 @@ const TOOLS = [
36
36
  properties: {
37
37
  title: { type: 'string', description: 'Short one-line title.' },
38
38
  ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
39
- tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags.' },
39
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags derived from this note\'s actual content — the specific technology, error type, root cause, or affected component (e.g. "retry-backoff", "null-pointer", "auth-middleware"). Never the project name or a generic category word like "gotcha" or "bug" — those provide no search signal to someone else looking for this note later.' },
40
40
  body: { type: 'string', description: 'The note body — one or more paragraphs.' },
41
41
  },
42
42
  required: ['title', 'body'],
@@ -19,6 +19,7 @@ import { incrementDraftKept, incrementDraftDeleted } from './activity-counter.mj
19
19
  import { extractText } from './attachment-text.mjs';
20
20
  import { TICKET_KEY_PATTERN } from './cli.mjs';
21
21
  import { createStyler } from './ansi.mjs';
22
+ import { confirmDestructive } from './confirm.mjs';
22
23
 
23
24
  function defaultListAttachments(configDir, ticketKey) {
24
25
  const cacheDir = path.join(configDir, 'cache', ticketKey);
@@ -226,8 +227,10 @@ export async function runNotePatch(cmdArgs, {
226
227
  export async function runNoteDelete(cmdArgs, {
227
228
  configDir = DEFAULT_CONFIG_DIR,
228
229
  stream = process.stderr,
230
+ stdin = process.stdin,
229
231
  isLicensedFn = isLicensed,
230
232
  deleteNoteFn = deleteNote,
233
+ confirmFn = confirmDestructive,
231
234
  } = {}) {
232
235
  if (!isLicensedFn('pro', configDir)) {
233
236
  showUpgradePrompt('pro', 'ticketlens note', { stream });
@@ -236,7 +239,7 @@ export async function runNoteDelete(cmdArgs, {
236
239
 
237
240
  const id = parseFlag(cmdArgs, 'id');
238
241
  if (!id) {
239
- stream.write('Usage: ticketlens note delete --id="..." [--ticket=KEY]\n');
242
+ stream.write('Usage: ticketlens note delete --id="..." [--ticket=KEY] [--yes|-y]\n');
240
243
  return { deleted: false };
241
244
  }
242
245
 
@@ -246,6 +249,13 @@ export async function runNoteDelete(cmdArgs, {
246
249
  return { deleted: false };
247
250
  }
248
251
 
252
+ const forceYes = cmdArgs.includes('--yes') || cmdArgs.includes('-y');
253
+ const confirmed = await confirmFn(`Delete note (${id})`, { stdin, stream, forceYes });
254
+ if (!confirmed) {
255
+ stream.write(' Aborted — note was not deleted.\n');
256
+ return { deleted: false };
257
+ }
258
+
249
259
  const { deleted } = deleteNoteFn({ external_id: id, tickets: ticketKey ? [ticketKey] : [] }, { configDir });
250
260
  stream.write(deleted
251
261
  ? ` Deleted note (${id}) — local vault only; see help for team-synced notes.\n`
@@ -14,7 +14,7 @@
14
14
  import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import { randomBytes } from 'node:crypto';
17
- import { DEFAULT_CONFIG_DIR, escapeLeadingHeading } from './config.mjs';
17
+ import { DEFAULT_CONFIG_DIR, escapeLeadingHeading, timeAgo } from './config.mjs';
18
18
  import { TICKET_KEY_PATTERN } from './cli.mjs';
19
19
  import { parseFrontmatter, serializeFrontmatter } from './frontmatter.mjs';
20
20
 
@@ -348,7 +348,7 @@ export function rebuildIndex(prefix, { configDir = DEFAULT_CONFIG_DIR } = {}) {
348
348
  // content is lower trust than a user's own local notes, so it needs the same
349
349
  // heading-injection guard already applied in brief-assembler.mjs/styled-assembler.mjs.
350
350
  const ticketList = n.tickets.length > 0 ? ` — ${escapeLeadingHeading(n.tickets.join(', '))}` : '';
351
- lines.push(`- [[${escapeLeadingHeading(n.title)}]]${ticketList} — ${n.created.split('T')[0]}`);
351
+ lines.push(`- [[${escapeLeadingHeading(n.title)}]]${ticketList} — ${timeAgo(n.created)}`);
352
352
  }
353
353
 
354
354
  writeFileAtomically(indexPath, lines.join('\n') + '\n');
@@ -71,3 +71,12 @@ export async function runCollisions(args = [], opts = {}) {
71
71
  return { ok: false };
72
72
  }
73
73
  }
74
+
75
+ // Run if invoked directly
76
+ const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/.*\//, ''));
77
+ if (isMain) {
78
+ runCollisions(process.argv.slice(2)).catch(err => {
79
+ process.stderr.write(`Error: ${err.message}\n`);
80
+ process.exitCode = 1;
81
+ });
82
+ }
@@ -115,7 +115,7 @@ export function styleRecallResults(digests, opts = {}) {
115
115
  if (!styled) {
116
116
  const entries = digests.map(d => {
117
117
  const ticketList = d.tickets?.length > 0 ? ` (${escapeLeadingHeading(d.tickets.join(', '))})` : '';
118
- const summary = `${escapeLeadingHeading(d.title)}${ticketList} — ${d.created.split('T')[0]} [${d.id}]`;
118
+ const summary = `${escapeLeadingHeading(d.title)}${ticketList} — ${timeAgo(d.created)} [${d.id}]`;
119
119
  return full ? `${summary}\n${escapeLeadingHeading(d.body)}` : summary;
120
120
  });
121
121
  return entries.join(full ? '\n\n' : '\n');
@@ -124,9 +124,9 @@ export function styleRecallResults(digests, opts = {}) {
124
124
  const s = createStyler({ forceColor: true });
125
125
  const entries = digests.map(d => {
126
126
  const ticketList = d.tickets?.length > 0 ? ` ${s.dim(`(${escapeLeadingHeading(d.tickets.join(', '))})`)}` : '';
127
- const date = s.dim(d.created.split('T')[0]);
127
+ const ago = s.dim(timeAgo(d.created));
128
128
  const id = s.dim(`[${d.id}]`);
129
- const summary = `${s.brand('●')} ${s.bold(escapeLeadingHeading(d.title))}${ticketList} ${s.dim('—')} ${date} ${id}`;
129
+ const summary = `${s.brand('●')} ${s.bold(escapeLeadingHeading(d.title))}${ticketList} ${s.dim('—')} ${ago} ${id}`;
130
130
  return full ? `${summary}\n${escapeLeadingHeading(d.body)}` : summary;
131
131
  });
132
132
  return entries.join(full ? '\n\n' : '\n');