ticketlens 0.38.37 → 0.38.39

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
@@ -460,6 +460,8 @@ Every note is scanned before saving — anything shaped like a real secret (API
460
460
 
461
461
  **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.
462
462
 
463
+ **Local file attachments.** `note add --attach=path1,path2` (or the `recall_add` MCP tool's `attachments` array) saves a screenshot or file alongside the note, in your local vault only (`~/.ticketlens/recall/<PREFIX>/<note-id>/`) — same 10 MB/file, 50 MB/call, 20-file caps as ticket attachments. Unlike ticket attachments, this is never pushed to Team Recall sync — a teammate who pulls the note gets the text only, not the file.
464
+
463
465
  **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.
464
466
 
465
467
  ---
@@ -801,6 +803,7 @@ ticketlens history <TICKET-KEY> # Show urgency timeline for a tick
801
803
 
802
804
  # ── Recall ────────────────────────────────────────────────────────────────────
803
805
  echo "note body" | ticketlens note add --title="..." --ticket=CNV1-2 --tags=a,b # Save a note [Pro]
806
+ echo "note body" | ticketlens note add --title="..." --attach=shot.png,log.txt # Save a note with local file attachments [Pro]
804
807
  ticketlens note delete --id="..." --ticket=CNV1-2 # Remove a note from your local vault [Pro]
805
808
  ticketlens recall CNV1-2 # Search saved notes by ticket key [Pro]
806
809
  ticketlens recall "retry backoff" # Free-text search across all notes [Pro]
@@ -743,7 +743,7 @@ switch (command) {
743
743
  });
744
744
  break;
745
745
  }
746
- process.stderr.write('Usage: ticketlens note add --title="..." [--ticket=KEY] [--tags=a,b]\n');
746
+ process.stderr.write('Usage: ticketlens note add --title="..." [--ticket=KEY] [--tags=a,b] [--attach=path1,path2]\n');
747
747
  process.exitCode = 1;
748
748
  break;
749
749
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.37",
3
+ "version": "0.38.39",
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.40.0 -->
1
+ <!-- jtb-skill-version: 0.42.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.
@@ -59,6 +59,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
59
59
  /jtb cloud-keys priority groq 1 # set provider priority (lower = tried first)
60
60
  /jtb cloud-keys timeout anthropic 15 # set per-request timeout in seconds
61
61
  /jtb note add --title="gotcha text" --ticket=PROD-1234 # save a Recall note (Pro, body from stdin)
62
+ /jtb note add --title="..." --attach=./shot.png,./log.txt # attach local files to a Recall note (Pro, local vault only — see note below)
62
63
  /jtb recall PROD-1234 # search saved Recall notes (Pro)
63
64
  /jtb recall sync # retry any notes stuck in the local queue (Team+)
64
65
  /jtb recall settings # show effective retry-queue settings, fetched live (Team+)
@@ -370,6 +371,8 @@ echo "The body text of the note, one or more paragraphs." | \
370
371
 
371
372
  **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. A tag that just restates the title in different words, or one you can't trace to a specific sentence in the body, gives that same zero signal — if you can't point to the exact phrase that justifies it, drop it. Same rule whether you're constructing the bash command above or calling `recall_add` directly — see its tool description for the same guidance.
372
373
 
374
+ **Attaching local files.** `note add --attach=path1,path2` (Pro) saves a screenshot or file alongside the note, in the local vault only — same 10 MB/file, 50 MB/call, 20-file caps as `ticket_create`/`ticket_comment`'s `--attach`. Unlike ticket attachments, this never uploads anywhere: it is not pushed to the team Console backend even with Team Recall sync active, so a teammate who pulls this note gets the text only, not the file. The `recall_add` MCP tool has a matching `attachments` array parameter — prefer it over the bash form when available, same rule as the rest of this section.
375
+
373
376
  To search saved notes directly (outside of automatic brief injection): `ticketlens recall "<query>"`.
374
377
 
375
378
  **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.
@@ -3,7 +3,7 @@
3
3
  * Centralised here to avoid triplicating the regex and warning logic.
4
4
  */
5
5
 
6
- export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
6
+ export const DEFAULT_API_BASE = 'http://api.ticketlens.test';
7
7
  export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
8
8
 
9
9
  // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
@@ -107,7 +107,11 @@ export function assembleBrief(ticket, codeRefs = null, templateSections = null,
107
107
  const ticketList = note.tickets?.length > 0 ? ` (${escapeLeadingHeading(note.tickets.join(', '))})` : '';
108
108
  const badge = note.status === 'unverified' ? ' _(unverified)_' : '';
109
109
  const tagsLine = note.tags?.length > 0 ? `\n Tags: ${escapeLeadingHeading(note.tags.join(', '))}` : '';
110
- return `- **${escapeLeadingHeading(note.title)}**${ticketList}${badge}${tagsLine}\n ${escapeLeadingHeading(note.body)}`;
110
+ // Filenames are already sanitized to [a-zA-Z0-9._-] before being saved
111
+ // (attachment-uploader.mjs's sanitizeFilename), so no heading-injection
112
+ // escaping is needed here — same trust chain as styled-assembler.mjs.
113
+ const attachmentsLine = note.attachments?.length > 0 ? `\n Attachments: ${note.attachments.join(', ')}` : '';
114
+ return `- **${escapeLeadingHeading(note.title)}**${ticketList}${badge}${tagsLine}${attachmentsLine}\n ${escapeLeadingHeading(note.body)}`;
111
115
  });
112
116
  const more = recallMoreCount > 0
113
117
  ? `\n\n**${recallMoreCount} more Recall note${recallMoreCount === 1 ? '' : 's'} linked to ${ticket.key} — run \`ticketlens recall ${ticket.key}\` for details.**`
@@ -362,10 +362,11 @@ async function callCollisions(args, { configDir, runCollisionsFn }) {
362
362
  * `--ticket=EVIL-999` stays inert precisely because it's never anything
363
363
  * but one opaque array element.
364
364
  */
365
- function buildNoteAddArgs({ title, ticket, tags }) {
365
+ function buildNoteAddArgs({ title, ticket, tags, attachments }) {
366
366
  const args = [`--title=${title}`];
367
367
  if (ticket) args.push(`--ticket=${ticket}`);
368
368
  if (Array.isArray(tags) && tags.length > 0) args.push(`--tags=${tags.join(',')}`);
369
+ if (Array.isArray(attachments) && attachments.length > 0) args.push(`--attach=${attachments.join(',')}`);
369
370
  return args;
370
371
  }
371
372
 
@@ -165,6 +165,7 @@ export const TOOLS = [
165
165
  ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
166
166
  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. A tag that just restates the title in different words, or one you cannot trace to a specific sentence in the body, gives that same zero signal — if you cannot point to the exact phrase that justifies it, drop it.' },
167
167
  body: { type: 'string', description: 'The note body — one or more paragraphs.' },
168
+ attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach (screenshots, logs, etc.), saved next to the note in the local vault. Same 10MB/file, 50MB/call, 20-file caps as ticket_comment/ticket_create. Unlike ticket attachments, these are never pushed to Team Recall sync — a teammate who pulls this note gets the text only, not the file.' },
168
169
  },
169
170
  required: ['title', 'body'],
170
171
  },
@@ -18,10 +18,42 @@ import { pushNote } from './recall-sync.mjs';
18
18
  import { enqueueNote, isRetryableFailure, maybeAutoFlush } from './recall-queue.mjs';
19
19
  import { incrementDraftKept, incrementDraftDeleted } from './activity-counter.mjs';
20
20
  import { extractText } from './attachment-text.mjs';
21
+ import { readAttachments, MAX_ATTACHMENTS } from './attachment-uploader.mjs';
21
22
  import { TICKET_KEY_PATTERN } from './cli.mjs';
22
23
  import { createStyler } from './ansi.mjs';
23
24
  import { confirmDestructive } from './confirm.mjs';
24
25
 
26
+ const ATTACHMENT_ERROR_MESSAGES = {
27
+ 'not-found': 'file not found',
28
+ 'not-a-file': 'not a file',
29
+ 'empty': 'file is empty',
30
+ 'too-large': 'exceeds 10 MB limit',
31
+ 'total-size-exceeded': 'exceeds the 50 MB total limit for this note',
32
+ };
33
+
34
+ function parseAttachPaths(cmdArgs) {
35
+ const raw = parseFlag(cmdArgs, 'attach');
36
+ return raw ? raw.split(',').map(p => p.trim()).filter(Boolean) : [];
37
+ }
38
+
39
+ /**
40
+ * Splits a readAttachments() batch into what's usable for writeNote (plain
41
+ * {filename, buffer} pairs — the vault doesn't care about size/mimeType)
42
+ * and human-readable warning lines for anything that failed, same
43
+ * best-effort philosophy as ticket_create/comment's --attach: one bad path
44
+ * never blocks the note from being saved.
45
+ */
46
+ function summarizeAttachments({ files, droppedCount }) {
47
+ const saved = [];
48
+ const warnings = [];
49
+ for (const f of files) {
50
+ if (f.ok) saved.push({ filename: f.filename, buffer: f.buffer });
51
+ else warnings.push(` Skipped attachment ${f.path}: ${ATTACHMENT_ERROR_MESSAGES[f.error] ?? f.error}\n`);
52
+ }
53
+ if (droppedCount > 0) warnings.push(` ${droppedCount} attachment(s) dropped — exceeds the ${MAX_ATTACHMENTS}-file limit per call.\n`);
54
+ return { saved, warnings };
55
+ }
56
+
25
57
  function defaultListAttachments(configDir, ticketKey) {
26
58
  const cacheDir = path.join(configDir, 'cache', ticketKey);
27
59
  try {
@@ -84,6 +116,7 @@ export async function runNoteAdd(cmdArgs, {
84
116
  incrementDraftDeletedFn = incrementDraftDeleted,
85
117
  listAttachmentsFn = defaultListAttachments,
86
118
  extractTextFn = extractText,
119
+ readAttachmentsFn = readAttachments,
87
120
  author = os.userInfo().username,
88
121
  } = {}) {
89
122
  if (!isLicensedFn('pro', configDir)) {
@@ -93,7 +126,7 @@ export async function runNoteAdd(cmdArgs, {
93
126
 
94
127
  const rawTitle = parseFlag(cmdArgs, 'title');
95
128
  if (!rawTitle) {
96
- stream.write('Usage: ticketlens note add --title="..." [--ticket=KEY] [--tags=a,b]\n');
129
+ stream.write('Usage: ticketlens note add --title="..." [--ticket=KEY] [--tags=a,b] [--attach=path1,path2]\n');
97
130
  return { written: false };
98
131
  }
99
132
  // A title is one line: collapse any embedded newline so it can never be used
@@ -130,16 +163,24 @@ export async function runNoteAdd(cmdArgs, {
130
163
  stream.write(` Warning: ${warning}\n`);
131
164
  }
132
165
 
166
+ const attachPaths = parseAttachPaths(cmdArgs);
167
+ const { saved: attachments, warnings: attachWarnings } = attachPaths.length > 0
168
+ ? summarizeAttachments(readAttachmentsFn(attachPaths))
169
+ : { saved: [], warnings: [] };
170
+ for (const warning of attachWarnings) stream.write(warning);
171
+
133
172
  const ticketKeys = ticketKey ? [ticketKey] : [];
134
173
  // Captured once and threaded into writeNoteFn's now() override so the local
135
174
  // vault file's `created` and the pushed payload's captured_at can never skew
136
175
  // by the (short but real) gap between the local write and the push below.
137
176
  const capturedAt = new Date();
138
- const { id } = writeNoteFn({ title, ticketKeys, tags, author, body }, { configDir, now: () => capturedAt });
177
+ const { id } = writeNoteFn({ title, ticketKeys, tags, author, body, attachments }, { configDir, now: () => capturedAt });
139
178
  incrementDraftKeptFn(configDir);
140
179
  const styled = !cmdArgs.includes('--plain') && stream.isTTY;
141
180
  const s = createStyler({ forceColor: styled, noColor: !styled });
142
- stream.write(styled ? `\n ${s.green('✔')} Saved note "${title}" (${id})\n\n` : ` Saved note "${title}" (${id})\n`);
181
+ const attachSuffix = attachments.length > 0 ? ` + ${attachments.length} attachment(s)` : '';
182
+ const savedLine = `Saved note "${title}" (${id})${attachSuffix}`;
183
+ stream.write(styled ? `\n ${s.green('✔')} ${savedLine}\n\n` : ` ${savedLine}\n`);
143
184
 
144
185
  const cliToken = readCliTokenFn(configDir);
145
186
  if (cliToken) {
@@ -49,24 +49,70 @@ function generateNoteId() {
49
49
  return `${Date.now()}-${randomBytes(3).toString('hex')}.md`;
50
50
  }
51
51
 
52
+ // Also used for attachment bytes: Node ignores the encoding argument when
53
+ // contents is a Buffer, so binary is written verbatim.
52
54
  export function writeFileAtomically(filePath, contents) {
53
55
  const tmpPath = `${filePath}.${process.pid}.tmp`;
54
56
  fs.writeFileSync(tmpPath, contents, 'utf8');
55
57
  fs.renameSync(tmpPath, filePath);
56
58
  }
57
59
 
60
+ // A note's attachment folder is always named after its own id (minus the
61
+ // .md extension) — same "derived, never trusted from user text" rule as
62
+ // the note filename itself, so it needs no separate path-safety guard.
63
+ function attachmentsDirFor(noteDir, id) {
64
+ return path.join(noteDir, id.replace(/\.md$/, ''));
65
+ }
66
+
67
+ // Two attachments in one note can share a basename (e.g. two screenshots
68
+ // both named "screenshot.png" from different source folders) — dedupe so
69
+ // the second write can't silently clobber the first.
70
+ function uniquifyFilename(filename, used) {
71
+ const ext = path.extname(filename);
72
+ const stem = path.basename(filename, ext);
73
+ let candidate = filename;
74
+ let n = 1;
75
+ while (used.has(candidate)) {
76
+ n += 1;
77
+ candidate = `${stem}-${n}${ext}`;
78
+ }
79
+ used.add(candidate);
80
+ return candidate;
81
+ }
82
+
83
+ /**
84
+ * @param {string} noteDir
85
+ * @param {string} id
86
+ * @param {Array<{ filename: string, buffer: Buffer }>} attachments
87
+ * @returns {string[]} the filenames actually saved, in order
88
+ */
89
+ function writeAttachments(noteDir, id, attachments) {
90
+ if (attachments.length === 0) return [];
91
+ const attachDir = attachmentsDirFor(noteDir, id);
92
+ fs.mkdirSync(attachDir, { recursive: true });
93
+ const used = new Set();
94
+ const saved = [];
95
+ for (const { filename, buffer } of attachments) {
96
+ const uniqueName = uniquifyFilename(filename, used);
97
+ writeFileAtomically(path.join(attachDir, uniqueName), buffer);
98
+ saved.push(uniqueName);
99
+ }
100
+ return saved;
101
+ }
102
+
58
103
  /**
59
- * @param {{ title: string, ticketKeys?: string[], tags?: string[], author: string, sources?: string[], body: string }} note
104
+ * @param {{ title: string, ticketKeys?: string[], tags?: string[], author: string, sources?: string[], body: string, attachments?: Array<{ filename: string, buffer: Buffer }> }} note
60
105
  * @param {{ configDir?: string, now?: () => Date }} [opts]
61
106
  * @returns {{ id: string, path: string }}
62
107
  */
63
- export function writeNote({ title, ticketKeys = [], tags = [], author, sources = [], body }, { configDir = DEFAULT_CONFIG_DIR, now = () => new Date() } = {}) {
108
+ export function writeNote({ title, ticketKeys = [], tags = [], author, sources = [], body, attachments = [] }, { configDir = DEFAULT_CONFIG_DIR, now = () => new Date() } = {}) {
64
109
  const prefix = resolvePrefix(ticketKeys[0]);
65
110
  const dir = prefixDir(configDir, prefix);
66
111
  fs.mkdirSync(dir, { recursive: true });
67
112
 
68
113
  const id = generateNoteId();
69
114
  const notePath = path.join(dir, id);
115
+ const savedAttachments = writeAttachments(dir, id, attachments);
70
116
 
71
117
  const data = {
72
118
  title,
@@ -80,6 +126,7 @@ export function writeNote({ title, ticketKeys = [], tags = [], author, sources =
80
126
  // A locally-authored note's own filename doubles as its push idempotency
81
127
  // key — pushing the same file twice must upsert one backend row, not two.
82
128
  externalId: id,
129
+ ...(savedAttachments.length > 0 ? { attachments: savedAttachments } : {}),
83
130
  };
84
131
 
85
132
  writeFileAtomically(notePath, serializeFrontmatter(data, body));
@@ -161,6 +208,7 @@ export function deleteNote({ external_id: externalId, tickets = [] }, { configDi
161
208
  }
162
209
 
163
210
  fs.unlinkSync(notePath);
211
+ fs.rmSync(attachmentsDirFor(path.dirname(notePath), externalId), { recursive: true, force: true });
164
212
  return { deleted: true, prefix };
165
213
  }
166
214
 
@@ -185,9 +233,11 @@ export function deleteNoteAnyPrefix(externalId, { configDir = DEFAULT_CONFIG_DIR
185
233
 
186
234
  const prefixes = [GENERAL_BUCKET, ...allPrefixDirs(configDir).filter(p => p !== GENERAL_BUCKET)];
187
235
  for (const prefix of prefixes) {
188
- const notePath = path.join(prefixDir(configDir, prefix), externalId);
236
+ const dir = prefixDir(configDir, prefix);
237
+ const notePath = path.join(dir, externalId);
189
238
  if (fs.existsSync(notePath)) {
190
239
  fs.unlinkSync(notePath);
240
+ fs.rmSync(attachmentsDirFor(dir, externalId), { recursive: true, force: true });
191
241
  return { deleted: true, prefix };
192
242
  }
193
243
  }
@@ -268,8 +318,10 @@ function readNote(filePath) {
268
318
  // place every consumer (search display, index rebuild, brief injection) reads
269
319
  // a note through, so nothing downstream has to remember to guard against it.
270
320
  const title = (data.title ?? '(untitled note)').replace(/[\r\n]+/g, ' ');
321
+ const id = path.basename(filePath);
322
+ const attachments = data.attachments ?? [];
271
323
  return {
272
- id: path.basename(filePath),
324
+ id,
273
325
  path: filePath,
274
326
  title,
275
327
  aliases: data.aliases ?? [],
@@ -280,6 +332,8 @@ function readNote(filePath) {
280
332
  status: data.status ?? 'unverified',
281
333
  sources: data.sources ?? [],
282
334
  externalId: data.externalId ?? null,
335
+ attachments,
336
+ attachmentsDir: attachments.length > 0 ? attachmentsDirFor(path.dirname(filePath), id) : null,
283
337
  body,
284
338
  };
285
339
  }
@@ -112,11 +112,14 @@ export function styleRecallResults(digests, opts = {}) {
112
112
  return 'No matching notes found.';
113
113
  }
114
114
 
115
+ const attachmentBadge = d => d.attachments?.length > 0 ? ` (${d.attachments.length} attachment${d.attachments.length === 1 ? '' : 's'})` : '';
116
+ const attachmentLine = d => d.attachments?.length > 0 ? `\nAttachments: ${d.attachments.join(', ')}` : '';
117
+
115
118
  if (!styled) {
116
119
  const entries = digests.map(d => {
117
120
  const ticketList = d.tickets?.length > 0 ? ` (${escapeLeadingHeading(d.tickets.join(', '))})` : '';
118
- const summary = `${escapeLeadingHeading(d.title)}${ticketList} — ${timeAgo(d.created)} [${d.id}]`;
119
- return full ? `${summary}\n${escapeLeadingHeading(d.body)}` : summary;
121
+ const summary = `${escapeLeadingHeading(d.title)}${ticketList} — ${timeAgo(d.created)} [${d.id}]${attachmentBadge(d)}`;
122
+ return full ? `${summary}${attachmentLine(d)}\n${escapeLeadingHeading(d.body)}` : summary;
120
123
  });
121
124
  return entries.join(full ? '\n\n' : '\n');
122
125
  }
@@ -126,8 +129,9 @@ export function styleRecallResults(digests, opts = {}) {
126
129
  const ticketList = d.tickets?.length > 0 ? ` ${s.dim(`(${escapeLeadingHeading(d.tickets.join(', '))})`)}` : '';
127
130
  const ago = s.dim(timeAgo(d.created));
128
131
  const id = s.dim(`[${d.id}]`);
129
- const summary = `${s.brand('●')} ${s.bold(escapeLeadingHeading(d.title))}${ticketList} ${s.dim('—')} ${ago} ${id}`;
130
- return full ? `${summary}\n${escapeLeadingHeading(d.body)}` : summary;
132
+ const badge = d.attachments?.length > 0 ? s.dim(attachmentBadge(d)) : '';
133
+ const summary = `${s.brand('●')} ${s.bold(escapeLeadingHeading(d.title))}${ticketList} ${s.dim('—')} ${ago} ${id}${badge}`;
134
+ return full ? `${summary}${attachmentLine(d)}\n${escapeLeadingHeading(d.body)}` : summary;
131
135
  });
132
136
  return entries.join(full ? '\n\n' : '\n');
133
137
  }
@@ -245,7 +249,10 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
245
249
  const ticketList = note.tickets?.length > 0 ? ` ${s.dim(`(${escapeLeadingHeading(note.tickets.join(', '))})`)}` : '';
246
250
  const badge = note.status === 'unverified' ? ` ${s.dim('(unverified)')}` : '';
247
251
  const tagsLine = note.tags?.length > 0 ? `\n ${s.dim(`Tags: ${escapeLeadingHeading(note.tags.join(', '))}`)}` : '';
248
- return `${s.brand('●')} ${s.bold(escapeLeadingHeading(note.title))}${ticketList}${badge}${tagsLine}\n ${escapeLeadingHeading(note.body)}`;
252
+ // Filenames are already sanitized to [a-zA-Z0-9._-] before being saved
253
+ // (attachment-uploader.mjs), so no heading-injection escaping is needed here.
254
+ const attachmentsLine = note.attachments?.length > 0 ? `\n ${s.dim(`Attachments: ${note.attachments.join(', ')}`)}` : '';
255
+ return `${s.brand('●')} ${s.bold(escapeLeadingHeading(note.title))}${ticketList}${badge}${tagsLine}${attachmentsLine}\n ${escapeLeadingHeading(note.body)}`;
249
256
  });
250
257
  const more = recallMoreCount > 0
251
258
  ? `\n\n${s.bold(s.yellow(`${recallMoreCount} more Recall note${recallMoreCount === 1 ? '' : 's'} linked to ${ticket.key} — run`))} ${s.bold(s.brand(`ticketlens recall ${ticket.key}`))} ${s.bold(s.yellow('for details.'))}`