ticketlens 0.38.36 → 0.38.38

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
@@ -7,7 +7,7 @@
7
7
  <img src="https://img.shields.io/npm/dm/ticketlens?style=flat-square&color=06b6d4&label=downloads" />
8
8
  <img src="https://github.com/ralphmoran/ticket-lens/actions/workflows/test.yml/badge.svg?style=flat-square" />
9
9
  <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" />
10
- <img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen?style=flat-square" />
10
+ <img src="https://img.shields.io/badge/node-%3E%3D22-brightgreen?style=flat-square" />
11
11
  </div>
12
12
 
13
13
  </div>
@@ -100,7 +100,7 @@ npx ticketlens CNV1-2
100
100
 
101
101
  Tip: `tl` works everywhere `ticketlens` does — running `tl`/`ticketlens config` before anything is configured also launches guided setup, no dead end. Pass `--no-input` to force non-interactive behavior even in a terminal (scripts, CI).
102
102
 
103
- **Prerequisites:** Node.js >=20
103
+ **Prerequisites:** Node.js >=22.6
104
104
 
105
105
  ---
106
106
 
@@ -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` 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.36",
3
+ "version": "0.38.38",
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": {
@@ -40,6 +40,6 @@
40
40
  },
41
41
  "homepage": "https://github.com/ralphmoran/ticket-lens",
42
42
  "engines": {
43
- "node": ">=20.0.0"
43
+ "node": ">=22.6.0"
44
44
  }
45
45
  }
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.40.0 -->
1
+ <!-- jtb-skill-version: 0.41.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 does not yet support attachments — this is CLI-only today; use the bash form above when a file needs to be attached.
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.
@@ -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.**`
@@ -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
  }
@@ -32,6 +32,30 @@ const MAX_JOINED_CHUNKS = 4;
32
32
  const MAX_COMPOUND_SEGMENT_LENGTH = 15;
33
33
  const HYPHENATED_COMPOUND_RE = /^[A-Za-z]+(-[A-Za-z]+)+$/;
34
34
 
35
+ // A candidate containing an unstripped '(', ')', '[', or ']' reads as code
36
+ // syntax (an array/list literal element, or a function-call argument — e.g.
37
+ // "['compliance', '--help']" or "matches(x)") rather than a secret fragment.
38
+ // Consulted ONLY from looksLikeCodeSyntax below, which downgrades a matching
39
+ // high-entropy candidate to a warning — deliberately NOT wired into
40
+ // isLabelWord (backlog #14 residual, code review caught this on the first
41
+ // pass): making a bracket-bearing token a hard isLabelWord stop would end a
42
+ // joinedChunkRuns run there unconditionally, the same way GIT_REFERENCE_WORD_RE/
43
+ // isHyphenatedWordCompound/looksLikeFilenameReference already do — but unlike
44
+ // those, a bracket character is trivial for an attacker to insert anywhere
45
+ // ("a(b"), and doing so would fully and SILENTLY stop a genuine fragmented
46
+ // secret split around it from ever being reassembled for the entropy check
47
+ // (confirmed live: a real 36-char secret split into two 18-char halves around
48
+ // a bare "a(b" separator went from rejected:true to a fully silent
49
+ // rejected:false/warnings:[] once isLabelWord treated brackets as a stop).
50
+ // The downgrade-only design below avoids that: the join still happens (a
51
+ // bracket-bearing token is ordinary, never a label word), so any joined
52
+ // candidate spanning real secret content still trips the entropy check —
53
+ // and since that joined candidate necessarily still contains the bracket
54
+ // character too, looksLikeCodeSyntax downgrades it to a WARNING rather than
55
+ // silently exempting it, unlike the fully-silent gap the hard-wall version
56
+ // would have reopened.
57
+ const CODE_SYNTAX_RE = /[()[\]]/;
58
+
35
59
  // U+200B (ZERO WIDTH SPACE) is added explicitly: despite the name, it does
36
60
  // NOT carry the Unicode White_Space property (General_Category=Cf, not Zs),
37
61
  // so it's excluded from JS's native \s (ECMA-262 WhiteSpace production) —
@@ -147,6 +171,29 @@ function looksLikeFilenameReference(strippedToken) {
147
171
  return FILENAME_REFERENCE_RE.test(strippedToken);
148
172
  }
149
173
 
174
+ /**
175
+ * True for a candidate — a raw token OR a joinedChunkRuns result — that
176
+ * itself contains an unstripped '(', ')', '[', or ']': code syntax (a
177
+ * function-call argument or array/list-literal element) rather than a secret
178
+ * fragment. Deliberately NOT wired into isLabelWord/joinedChunkRuns (see
179
+ * CODE_SYNTAX_RE's own comment for why that hard-wall approach reopened a
180
+ * silent reassembly bypass) — instead this only downgrades a candidate that
181
+ * ALREADY tripped looksRandom, same never-exempt treatment as
182
+ * looksLikeCodeFilename below. Covers both backlog #14 residual shapes: a
183
+ * token already 20+ chars on its own with no join needed (e.g.
184
+ * "matches(['compliance'," — whitespace-split with no space after '(' or
185
+ * '[', so it's one raw token from the very first split), and a joined run
186
+ * that only crosses the entropy threshold once several bracket-literal
187
+ * tokens glue together (e.g. "['compliance','--help','-h','debug']") — the
188
+ * bracket character survives into the joined string either way, so this
189
+ * still catches it. Fully exempting this shape would let a 20+ char secret
190
+ * dodge rejection just by wrapping it in a fake "f(" / "[" — see the
191
+ * security regression test alongside looksLikeCodeFilename's.
192
+ */
193
+ function looksLikeCodeSyntax(rawToken) {
194
+ return CODE_SYNTAX_RE.test(stripEdgePunctuation(rawToken));
195
+ }
196
+
150
197
  function shannonEntropy(token) {
151
198
  const counts = new Map();
152
199
  for (const ch of token) counts.set(ch, (counts.get(ch) ?? 0) + 1);
@@ -396,12 +443,15 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
396
443
  // Downgrading to a warning — never silently dropping the signal — matches
397
444
  // how an email address is already handled below.
398
445
  const randomCandidates = candidates.filter(token => !EMAIL_RE.test(token) && looksRandom(token, combined));
399
- if (randomCandidates.some(token => !looksLikeCodeFilename(token))) {
446
+ if (randomCandidates.some(token => !looksLikeCodeFilename(token) && !looksLikeCodeSyntax(token))) {
400
447
  reasons.push('Contains a long, random-looking string that could be a secret.');
401
448
  }
402
449
  if (randomCandidates.some(token => looksLikeCodeFilename(token))) {
403
450
  warnings.push('Contains a code-filename-shaped token that also reads as high-entropy — double-check it is not a credential.');
404
451
  }
452
+ if (randomCandidates.some(token => looksLikeCodeSyntax(token))) {
453
+ warnings.push('Contains a code-syntax-shaped token (brackets or parentheses) that also reads as high-entropy — double-check it is not a credential.');
454
+ }
405
455
 
406
456
  if (EMAIL_RE.test(combined)) {
407
457
  warnings.push('Contains an email address.');
@@ -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.'))}`