ticketlens 0.38.43 → 0.38.45

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.43",
3
+ "version": "0.38.45",
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.42.3 -->
1
+ <!-- jtb-skill-version: 0.42.4 -->
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.
@@ -356,6 +356,8 @@ Example that qualifies: an undocumented schema quirk found only by reading raw D
356
356
 
357
357
  Do not capture: routine/obvious information, anything already fully covered by the ticket description or comments, or content shaped like a credential or API key — the tool rejects those outright at save time.
358
358
 
359
+ **Length:** keep both the title and body brief — a sharp one- or two-sentence takeaway, not a paragraph. The recommended max scales with the same strictness level as the section above: 20 words (strict), 30 words (balanced, default), 50 words (loose). Going over doesn't block the save — the CLI/MCP tool prints a non-blocking warning so you can tighten it, the same way it prints warnings for a detected email address or similar low-severity secret-scan hit.
360
+
359
361
  Capture **at the moment you confirm the insight**, not deferred to the end of the session — don't ask the user first, just do it. It requires a Pro license; on a Free account it will no-op with an upgrade hint on stderr, which is fine to ignore. It never blocks or fails the session.
360
362
 
361
363
  Immediately before dispatching the call, say one line in your response: `🔖 Recall-flag: <one-line reason>`. This is a fixed marker some harness setups scan for to confirm captures are actually happening — always emit it right when you decide to capture, even if you go on to call `note add` in the same breath.
@@ -365,7 +367,7 @@ Immediately before dispatching the call, say one line in your response: `🔖 Re
365
367
  If your environment lets you dispatch a tool call without waiting for its result — a background task, an async subagent, a fire-and-forget job — try it **once**. If it errors for any reason, don't retry the background path — fall back to calling it synchronously immediately. It's fast and never blocks or fails your session either way, so the synchronous fallback costs nothing; repeatedly retrying a broken background mechanism only adds visible noise for no benefit.
366
368
 
367
369
  ```bash
368
- echo "The body text of the note, one or more paragraphs." | \
370
+ echo "Retry client swallowed 429s silently; added backoff and a warning log on the third failure." | \
369
371
  ticketlens note add --title="Short title" --ticket=TICKET-KEY --tags=a,b
370
372
  ```
371
373
 
@@ -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 = 'http://api.ticketlens.test';
6
+ export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
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,
@@ -161,10 +161,10 @@ export const TOOLS = [
161
161
  inputSchema: {
162
162
  type: 'object',
163
163
  properties: {
164
- title: { type: 'string', description: 'Short one-line title.' },
164
+ title: { type: 'string', description: 'Short one-line title, ~20 (strict), 30 (balanced), or 50 (loose) words max — scales with recallStrictness — going over doesn\'t block the save, but prints a non-blocking warning.' },
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
- body: { type: 'string', description: 'The note body — one or more paragraphs.' },
167
+ body: { type: 'string', description: 'The note body — brief, not a paragraph. ~20 (strict), 30 (balanced), or 50 (loose) words max — scales with recallStrictness — going over doesn\'t block the save, but prints a non-blocking warning.' },
168
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 for the local save. If this account is entitled and the note syncs to a team, attachments sync too — visible and downloadable from Console > Admin > Recall — but the sync path has a lower 12MB/call cap than the local save (backend request-size limit). Going over it fails the WHOLE push (note text included), not just the attachment; the note stays saved locally either way.' },
169
169
  },
170
170
  required: ['title', 'body'],
@@ -177,7 +177,7 @@ export const TOOLS = [
177
177
  type: 'object',
178
178
  properties: {
179
179
  id: { type: 'string', description: 'Note id to patch, as printed by recall_add or recall_search.' },
180
- body: { type: 'string', description: 'The replacement note body.' },
180
+ body: { type: 'string', description: 'The replacement note body — brief, not a paragraph. ~20 (strict), 30 (balanced), or 50 (loose) words max — scales with recallStrictness — going over doesn\'t block the update, but prints a non-blocking warning.' },
181
181
  ticket: { type: 'string', description: 'Ticket key the note is about, e.g. PROJ-123. Optional — narrows the search when omitted, the vault is searched across all ticket prefixes.' },
182
182
  expectMtime: { type: 'number', description: 'The note file\'s mtime (ms) last observed by the caller. If the note changed since then, the patch is a no-op rather than an overwrite — optimistic concurrency, not a hard requirement.' },
183
183
  },
@@ -10,10 +10,10 @@ import path from 'node:path';
10
10
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
11
11
  import { isLicensed, showUpgradePrompt } from './license.mjs';
12
12
  import { scanForSecrets } from './secret-scanner.mjs';
13
- import { checkNoteStructure } from './note-structural-check.mjs';
13
+ import { checkNoteStructure, checkWordCount, WORD_LIMITS } from './note-structural-check.mjs';
14
14
  import { writeNote, patchNoteBody, deleteNote, deleteNoteAnyPrefix, rebuildIndex } from './recall-vault.mjs';
15
15
  import { readCliToken } from './cli-auth.mjs';
16
- import { resolveProfile, loadProfileRecallTeamId } from './profile-resolver.mjs';
16
+ import { resolveProfile, loadProfileRecallTeamId, normalizeRecallStrictness } from './profile-resolver.mjs';
17
17
  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';
@@ -103,6 +103,7 @@ export async function runNoteAdd(cmdArgs, {
103
103
  readStdin = defaultReadStdin,
104
104
  isLicensedFn = isLicensed,
105
105
  checkNoteStructureFn = checkNoteStructure,
106
+ checkWordCountFn = checkWordCount,
106
107
  scanForSecretsFn = scanForSecrets,
107
108
  writeNoteFn = writeNote,
108
109
  readCliTokenFn = readCliToken,
@@ -153,6 +154,16 @@ export async function runNoteAdd(cmdArgs, {
153
154
  return { written: false };
154
155
  }
155
156
 
157
+ // Resolved once here (not just inside the push block below) so the
158
+ // word-count warning below can read this profile's recallStrictness
159
+ // regardless of whether a CLI token is configured for pushing.
160
+ const profile = resolveProfileFn(ticketKey || null, { configDir, cwd: process.cwd() });
161
+ const strictness = normalizeRecallStrictness(profile?.recallStrictness);
162
+ const wordCount = checkWordCountFn({ title, body }, { maxWords: WORD_LIMITS[strictness] });
163
+ for (const warning of wordCount.warnings) {
164
+ stream.write(` Warning: ${warning}\n`);
165
+ }
166
+
156
167
  const scan = scanForSecretsFn({ title, tags, body });
157
168
  if (scan.rejected) {
158
169
  stream.write(` Note not saved — ${scan.reasons.join(' ')}\n`);
@@ -187,7 +198,7 @@ export async function runNoteAdd(cmdArgs, {
187
198
  const warn = (s) => stream.write(s);
188
199
  // Field names match PushRequest's validation rules (external_id, tickets) —
189
200
  // the backend wire contract, not the local vault's internal camelCase shape.
190
- const profile = resolveProfileFn(ticketKey || null, { configDir, cwd: process.cwd() });
201
+ // `profile` was already resolved above (needed there for the word-count check).
191
202
  const recallTeamId = profile ? loadProfileRecallTeamIdFn(profile.name, configDir) : null;
192
203
  const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body, captured_at: capturedAt.toISOString() };
193
204
  if (recallTeamId !== null) payload.group_id = recallTeamId;
@@ -225,6 +236,8 @@ export async function runNotePatch(cmdArgs, {
225
236
  readStdin = defaultReadStdin,
226
237
  isLicensedFn = isLicensed,
227
238
  checkNoteStructureFn = checkNoteStructure,
239
+ checkWordCountFn = checkWordCount,
240
+ resolveProfileFn = resolveProfile,
228
241
  scanForSecretsFn = scanForSecrets,
229
242
  patchNoteBodyFn = patchNoteBody,
230
243
  } = {}) {
@@ -253,6 +266,13 @@ export async function runNotePatch(cmdArgs, {
253
266
  return { patched: false };
254
267
  }
255
268
 
269
+ const profile = resolveProfileFn(ticketKey || null, { configDir, cwd: process.cwd() });
270
+ const strictness = normalizeRecallStrictness(profile?.recallStrictness);
271
+ const wordCount = checkWordCountFn({ body }, { maxWords: WORD_LIMITS[strictness] });
272
+ for (const warning of wordCount.warnings) {
273
+ stream.write(` Warning: ${warning}\n`);
274
+ }
275
+
256
276
  const scan = scanForSecretsFn({ title: '', tags: [], body });
257
277
  if (scan.rejected) {
258
278
  stream.write(` Note not updated — ${scan.reasons.join(' ')}\n`);
@@ -31,3 +31,39 @@ export function checkNoteStructure({ body = '' } = {}) {
31
31
 
32
32
  return { rejected: false, reason: null };
33
33
  }
34
+
35
+ // Backlog #18 — mandatory brevity cap, one entry per recallStrictness level.
36
+ // Warning-only: a rambling note still saves, it just gets flagged so the
37
+ // caller can tighten it, same non-blocking pattern scanForSecrets uses for
38
+ // its `warnings` array.
39
+ export const WORD_LIMITS = { strict: 20, balanced: 30, loose: 50 };
40
+
41
+ function countWords(text) {
42
+ const trimmed = text.trim();
43
+ return trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
44
+ }
45
+
46
+ /**
47
+ * Warning-only companion to checkNoteStructure: flags a title/body that runs
48
+ * long instead of rejecting it. `maxWords` is a plain number so this stays
49
+ * dependency-free — the caller maps recallStrictness to a number.
50
+ *
51
+ * @param {{ title?: string, body?: string }} note
52
+ * @param {{ maxWords?: number }} opts
53
+ * @returns {{ warnings: string[] }}
54
+ */
55
+ export function checkWordCount({ title = '', body = '' } = {}, { maxWords = WORD_LIMITS.balanced } = {}) {
56
+ const warnings = [];
57
+
58
+ const titleWords = countWords(title);
59
+ if (titleWords > maxWords) {
60
+ warnings.push(`Note title is ${titleWords} words (recommended max: ${maxWords}).`);
61
+ }
62
+
63
+ const bodyWords = countWords(body);
64
+ if (bodyWords > maxWords) {
65
+ warnings.push(`Note body is ${bodyWords} words (recommended max: ${maxWords}).`);
66
+ }
67
+
68
+ return { warnings };
69
+ }
@@ -32,9 +32,11 @@ 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.
35
+ // A candidate containing an unstripped '(', ')', '[', ']', '<', or '>' reads
36
+ // as code/document syntax (an array/list literal element, a function-call
37
+ // argument, or a markup/dictionary delimiter — e.g. "['compliance', '--help']",
38
+ // "matches(x)", or a PDF object's "<</Type/Font>>") rather than a secret
39
+ // fragment.
38
40
  // Consulted ONLY from looksLikeCodeSyntax below, which downgrades a matching
39
41
  // high-entropy candidate to a warning — deliberately NOT wired into
40
42
  // isLabelWord (backlog #14 residual, code review caught this on the first
@@ -54,7 +56,15 @@ const HYPHENATED_COMPOUND_RE = /^[A-Za-z]+(-[A-Za-z]+)+$/;
54
56
  // character too, looksLikeCodeSyntax downgrades it to a WARNING rather than
55
57
  // silently exempting it, unlike the fully-silent gap the hard-wall version
56
58
  // would have reopened.
57
- const CODE_SYNTAX_RE = /[()[\]]/;
59
+ //
60
+ // Angle brackets added 2026-08-17 (backlog #19 follow-up, ported from
61
+ // RecallSecretScanner.php): Recall attachment content-based classification
62
+ // means a simple/uncompressed PDF's own object syntax
63
+ // ("obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj") is now a
64
+ // realistic scan candidate — same "structured, not random" shape ()[]
65
+ // already cover, still downgrade-only, still never consulted by
66
+ // HARD_REJECT_PATTERNS.
67
+ const CODE_SYNTAX_RE = /[()[\]<>]/;
58
68
 
59
69
  // U+200B (ZERO WIDTH SPACE) is added explicitly: despite the name, it does
60
70
  // NOT carry the Unicode White_Space property (General_Category=Cf, not Zs),