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 +5 -2
- package/bin/ticketlens.mjs +1 -1
- package/package.json +2 -2
- package/skills/jtb/SKILL.md +4 -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/secret-scanner.mjs +51 -1
- package/skills/jtb/scripts/lib/styled-assembler.mjs +12 -5
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%
|
|
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 >=
|
|
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]
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ticketlens",
|
|
3
|
-
"version": "0.38.
|
|
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": ">=
|
|
43
|
+
"node": ">=22.6.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
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.
|
|
@@ -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
|
}
|
|
@@ -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
|
|
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.'))}`
|