ticketlens 0.38.37 → 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 +3 -0
- package/bin/ticketlens.mjs +1 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +4 -1
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/brief-assembler.mjs +5 -1
- package/skills/jtb/scripts/lib/note-command.mjs +44 -3
- package/skills/jtb/scripts/lib/recall-vault.mjs +58 -4
- package/skills/jtb/scripts/lib/styled-assembler.mjs +12 -5
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` 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]
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -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
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 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.
|
|
@@ -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 = '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
130
|
-
|
|
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
|
-
|
|
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.'))}`
|