ticketlens 0.43.4 → 0.43.6

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
@@ -164,7 +164,7 @@ After the first fetch, ticket data is cached to `~/.ticketlens/cache/PROFILE/TIC
164
164
  ○ CNV1-2 · from cache (12m ago) · --no-cache to refresh
165
165
  ```
166
166
 
167
- Attachments download to `~/.ticketlens/cache/TICKET-KEY/` (10 MB per-file cap). Claude Code reads images multimodally, extracts PDF text, and reads plain text files as context.
167
+ Attachments download to `~/.ticketlens/cache/TICKET-KEY/` (10 MB per-file cap; 10 files per ticket on Free, 50 on Pro, Team and Enterprise — extras are listed as skipped, with a notice). Claude Code reads images multimodally, extracts PDF text, and reads plain text files as context.
168
168
 
169
169
  Confluence pages linked to the ticket via Jira Remote Links are fetched automatically and included as plain text in the brief (Jira profiles only, same-origin). Use `--no-attachments` to skip both attachments and Confluence pages.
170
170
 
@@ -469,7 +469,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
469
469
 
470
470
  **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.
471
471
 
472
- **Local file attachments.** `note add --attach=path1,path2` (or the `recall_add` MCP tool's `attachments` array) saves a screenshot or file alongside the note, in your local vault (`~/.ticketlens/recall/<PREFIX>/<note-id>/`) — same 10 MB/file, 50 MB/call, 20-file caps as ticket attachments for the local save. `note patch --attach=path1,path2` (or `recall_update`'s `attachments` array) attaches more files to an existing note later — appended to what's already there, never replacing it; patch is local-vault only, so these are never pushed even if the note's original attachments were. With Team Recall sync active, the attachment syncs too — visible and downloadable from Console > Admin > Recall — but the sync path caps at 12 MB/call (the backend's request-size limit, lower than the 50 MB local-save cap). Going over it fails the whole push, not just the attachment — the note stays saved locally, but neither its text nor the attachment reaches the team until it's pushed within the cap. Attachments with plain-text content (detected from the actual bytes, not the filename) go through the same secret scan as the note body before syncing; a rejected scan blocks the whole push the same way.
472
+ **Local file attachments.** `note add --attach=path1,path2` (or the `recall_add` MCP tool's `attachments` array) saves a screenshot or file alongside the note, in your local vault (`~/.ticketlens/recall/<PREFIX>/<note-id>/`) — same 10 MB/file and 50 MB/call caps as ticket attachments, with a per-call file cap of 10 on Free and 50 on Pro, Team, and Enterprise (also applied to `fetch` attachment downloads). `note patch --attach=path1,path2` (or `recall_update`'s `attachments` array) attaches more files to an existing note later — appended to what's already there, never replacing it; patch is local-vault only, so these are never pushed even if the note's original attachments were. With Team Recall sync active, the attachment syncs too — visible and downloadable from Console > Admin > Recall — but the sync path caps at 12 MB/call (the backend's request-size limit, lower than the 50 MB local-save cap). Going over it fails the whole push, not just the attachment — the note stays saved locally, but neither its text nor the attachment reaches the team until it's pushed within the cap. Attachments with plain-text content (detected from the actual bytes, not the filename) go through the same secret scan as the note body before syncing; a rejected scan blocks the whole push the same way.
473
473
 
474
474
  **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.
475
475
 
@@ -524,7 +524,7 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
524
524
 
525
525
  `ticketlens create` creates a new ticket with a fixed minimal field set — no arbitrary custom fields. Unlike every other write command, there's no existing ticket to target, so `--profile` (or your default profile) picks the tracker instead of a ticket key. `--project` is the Jira project key or Linear team key — required for both, ignored on GitHub since its target repo is already fixed by the profile. `--type` is Jira's issue type (e.g. `"Task"`, `"Bug"`) — required for Jira, ignored elsewhere. No `--confirm` gate, same risk tier as `update`/`assign` — but this is the highest-blast-radius command in the whole family: a bad `--project`/`--type` fabricates a real, hard-to-walk-back item in a live tracker, so an invalid value surfaces the tracker's own error rather than a silent guess.
526
526
 
527
- `--attach=path1,path2` (comma-separated local file paths) is available on `comment` and `create` only. Images render as an inline thumbnail on Jira and Linear; GitHub has no attachment upload API, so `--attach` is unsupported there.
527
+ `--attach=path1,path2` (comma-separated local file paths) is available on `comment` and `create` only, up to 50 files per call (Pro, Team, Enterprise). Images render as an inline thumbnail on Jira and Linear; GitHub has no attachment upload API, so `--attach` is unsupported there.
528
528
 
529
529
  **A bad `--project`/`--type` gets a better error, automatically.** If create fails because the project or issue type doesn't exist, TicketLens fetches your tracker's real, current project list (and, for Jira, the real issue types for that project) and shows them alongside the failure — e.g. `Known creatable projects: CNV1, CNV2.` — rather than a bare tracker error. This is reactive only: it never runs on a successful create, never auto-retries the write, and is cached locally per profile for 3 days (a targeted, single-project lookup — same bar `issue-types --project=KEY` uses) so a burst of failed attempts doesn't re-fetch every time. `ticketlens issue-types` (below) is the proactive counterpart — pre-fetches and caches the same data ahead of time, so a `create` right after it is a pure cache hit.
530
530
 
@@ -700,7 +700,7 @@ ticketlens update-skill --path=~/.gemini/commands # sync to a different AI assi
700
700
  /jtb triage # Scan your assigned tickets
701
701
  ```
702
702
 
703
- Attachments are listed in the brief as absolute paths. Claude Code reads images (multimodal), PDFs, and text files before planning. Files over 10 MB are skipped.
703
+ Attachments are listed in the brief as absolute paths. Claude Code reads images (multimodal), PDFs, and text files before planning. Files over 10 MB are skipped, and so are files past the per-ticket cap (10 on Free, 50 on Pro, Team and Enterprise).
704
704
 
705
705
  ---
706
706
 
@@ -1122,6 +1122,7 @@ npm test
1122
1122
  See [ROADMAP.md](ROADMAP.md) for the full plan.
1123
1123
 
1124
1124
  Recently shipped:
1125
+ - **Tiered attachment cap** — per-call attachment count is now 10 on Free and 50 on Pro, Team and Enterprise (was a flat 20), for uploads, `fetch` downloads and Recall sync. Excess files are reported, not silently dropped
1125
1126
  - **`ticketlens pr --open`** — opens a real GitHub PR compare page prefilled with the same description `pr` already prints, instead of just printing it. GitHub only, no stored write-credential. Pro tier
1126
1127
  - **`review`/`compliance` fixes** — branch diff now uses merge-base semantics, no longer picks up unrelated unmerged-main files; AC parser now recognizes Jira wiki `Requirements`/`How to test` headers, not just literal "Acceptance Criteria"
1127
1128
  - **`ticketlens update --priority`** — a bad priority name now tells you the project's real, current valid options instead of a bare tracker error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.43.4",
3
+ "version": "0.43.6",
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.48.0 -->
1
+ <!-- jtb-skill-version: 0.49.1 -->
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.
@@ -376,7 +376,7 @@ echo "Retry client swallowed 429s silently; added backoff and a warning log on t
376
376
 
377
377
  **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.
378
378
 
379
- **Attaching local files.** `note add --attach=path1,path2` (Pro) saves a screenshot or file alongside the note — same 10 MB/file, 50 MB/call, 20-file caps as `ticket_create`/`ticket_comment`'s `--attach` for the local save. If this account is entitled and the note syncs to a team (Team Recall sync active), the attachment syncs with it — visible and downloadable from Console > Admin > Recall, not just text-only. The sync path has a lower 12 MB/call cap than the local save (bounded by the backend's request-size limit, not the CLI). Going over it fails the whole push, not just the attachment — the note stays saved locally, but neither its text nor the attachment reaches the team until pushed within the cap. Attachments with plain-text content (checked by actually decoding the bytes, not by filename extension — renaming a text file to `.png` doesn't skip this) go through the same secret scan as the note body before syncing; a rejected scan blocks the whole push the same way. The `recall_add` MCP tool has a matching `attachments` array parameter — prefer it over the bash form when available, same rule as the rest of this section.
379
+ **Attaching local files.** `note add --attach=path1,path2` (Pro) saves a screenshot or file alongside the note — same 10 MB/file and 50 MB/call caps as `ticket_create`/`ticket_comment`'s `--attach` for the local save. File-count cap per call: 10 on Free, 50 on Pro, Team, and Enterprise (same for `fetch` attachment downloads; excess files are reported, not silently dropped). If this account is entitled and the note syncs to a team (Team Recall sync active), the attachment syncs with it — visible and downloadable from Console > Admin > Recall, not just text-only. The sync path has a lower 12 MB/call cap than the local save (bounded by the backend's request-size limit, not the CLI). Going over it fails the whole push, not just the attachment — the note stays saved locally, but neither its text nor the attachment reaches the team until pushed within the cap. Attachments with plain-text content (checked by actually decoding the bytes, not by filename extension — renaming a text file to `.png` doesn't skip this) go through the same secret scan as the note body before syncing; a rejected scan blocks the whole push the same way. The `recall_add` MCP tool has a matching `attachments` array parameter — prefer it over the bash form when available, same rule as the rest of this section.
380
380
 
381
381
  **Attaching a browser screenshot.** No separate capture path exists — `--attach` already takes any local file path, and a browser-automation tool's screenshot action (e.g. Chrome DevTools MCP's `take_screenshot`, Playwright's `browser_take_screenshot`) writes to a path you choose when you pass it a `filePath`/output-path argument. Save there, then attach it like any other file:
382
382
  ```bash
@@ -484,7 +484,7 @@ ticketlens worklog PROD-1234=1h30m PROD-5678=45m --comment="Sprint work" --confi
484
484
  - The MCP tool takes `entries[]` with per-ticket `comment`/`started`.
485
485
  - The CLI applies one `--comment`/`--started` to every ticket.
486
486
 
487
- `--attach=path1,path2` (comma-separated local file paths) is available on `comment` and `create` only. Images render as an inline thumbnail on Jira and Linear; GitHub has no attachment upload API, so `--attach` is unsupported there.
487
+ `--attach=path1,path2` (comma-separated local file paths) is available on `comment` and `create` only, up to 50 files per call (Pro, Team, Enterprise). Images render as an inline thumbnail on Jira and Linear; GitHub has no attachment upload API, so `--attach` is unsupported there.
488
488
 
489
489
  The write actions (comment/transition/assign/link/update/create/worklog) have a short local debounce (10s) against an accidental double-fire, and every write is appended to a local audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — surface the failure to the user rather than silently re-attempting, since a ticket write isn't naturally idempotent the way a Recall note save is. `duplicates` has neither, since nothing is written.
490
490
 
@@ -668,7 +668,7 @@ Forces a fresh fetch from Jira, bypassing the local brief cache (4-hour TTL by d
668
668
 
669
669
  ### --no-attachments
670
670
 
671
- Skip downloading and reading ticket attachments. Speeds up the fetch for tickets with large or irrelevant file attachments.
671
+ Skip downloading and reading ticket attachments. Speeds up the fetch for tickets with large or irrelevant file attachments. Without it, downloads stop at 10 files per ticket on Free and 50 on Pro, Team and Enterprise; the excess is listed as skipped and a notice is printed.
672
672
 
673
673
  ### Team Jira config auto-sync (Pro/Team)
674
674
 
@@ -23,6 +23,7 @@ import { readTextAttachments } from './lib/handoff-assembler.mjs';
23
23
  import { handleUnknownFlags } from './lib/arg-validator.mjs';
24
24
  import { TICKET_KEY_PATTERN, normalizeTicketKey } from './lib/cli.mjs';
25
25
  import { downloadAttachments } from './lib/attachment-downloader.mjs';
26
+ import { attachmentCapFor, attachmentLimitNotice } from './lib/attachment-caps.mjs';
26
27
  import { readBriefCache, writeBriefCache, readSummaryCache, writeSummaryCache, briefCacheAge, BRIEF_TTL_MS } from './lib/brief-cache.mjs';
27
28
  import { recordTokensSaved } from './lib/activity-counter.mjs';
28
29
  import { readCliToken } from './lib/cli-auth.mjs';
@@ -1372,7 +1373,9 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1372
1373
  printErrFn(`Downloading ${downloadable.length} ${noun}…\n`);
1373
1374
  // attachment-downloader is Jira-specific — it needs raw auth headers from buildJiraEnv
1374
1375
  const jiraEnv = buildJiraEnv(conn);
1376
+ const maxAttachments = attachmentCapFor(configDir);
1375
1377
  ticket.localAttachments = await downloadAttachments(ticket, {
1378
+ maxAttachments,
1376
1379
  env: jiraEnv,
1377
1380
  fetcher,
1378
1381
  noCache: args.includes('--no-cache'),
@@ -1385,6 +1388,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1385
1388
  if (downloaded > 0) parts.push(`${downloaded} downloaded`);
1386
1389
  if (cached > 0) parts.push(`${cached} cached`);
1387
1390
  if (parts.length > 0) printErrFn(` ✓ ${parts.join(', ')}\n`);
1391
+ const overCap = ticket.localAttachments.filter(r => r.skipReason === 'limit').length;
1392
+ if (overCap > 0) printErrFn(` ${attachmentLimitNotice(overCap, maxAttachments, { verb: 'not downloaded', scope: 'ticket' })}\n`);
1388
1393
  printErrFn('\n');
1389
1394
  }
1390
1395
  }
@@ -263,7 +263,7 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
263
263
  * Both are image-only; non-image files get neither.
264
264
  */
265
265
  async attachFiles(key, filePaths, opts = {}) {
266
- const { files, droppedCount } = readAttachments(filePaths);
266
+ const { files, droppedCount, cap } = readAttachments(filePaths, opts.maxAttachments);
267
267
  const uploaded = [];
268
268
  const errors = [];
269
269
  for (const f of files) {
@@ -291,7 +291,7 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
291
291
  errors.push({ path: f.path, message: err.message });
292
292
  }
293
293
  }
294
- return { uploaded, errors, droppedCount };
294
+ return { uploaded, errors, droppedCount, cap };
295
295
  },
296
296
  };
297
297
  }
@@ -538,7 +538,7 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
538
538
  async attachFiles(key, filePaths, opts = {}) {
539
539
  const signal = AbortSignal.timeout(opts.timeoutMs ?? 30_000);
540
540
  const { lookup = defaultLookupFor(fetcher), allowPrivateIp = false } = opts;
541
- const { files, droppedCount } = readAttachments(filePaths);
541
+ const { files, droppedCount, cap } = readAttachments(filePaths, opts.maxAttachments);
542
542
  const uploaded = [];
543
543
  const errors = [];
544
544
  for (const f of files) {
@@ -592,7 +592,7 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
592
592
  errors.push({ path: f.path, message: err.message });
593
593
  }
594
594
  }
595
- return { uploaded, errors, droppedCount };
595
+ return { uploaded, errors, droppedCount, cap };
596
596
  },
597
597
  };
598
598
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Per-call attachment count caps, by tier. Byte caps (10 MB/file, 50 MB/call)
3
+ * are unchanged and live next to the code that enforces them.
4
+ * Mirrors ticketlens-api's RecallAttachmentStorage::maxFilesFor().
5
+ */
6
+
7
+ import { isLicensed } from './license.mjs';
8
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
9
+
10
+ export const FREE_MAX_ATTACHMENTS = 10;
11
+ export const PAID_MAX_ATTACHMENTS = 50;
12
+
13
+ export function attachmentCapFor(configDir = DEFAULT_CONFIG_DIR, isLicensedFn = isLicensed) {
14
+ return isLicensedFn('pro', configDir) ? PAID_MAX_ATTACHMENTS : FREE_MAX_ATTACHMENTS;
15
+ }
16
+
17
+ export function attachmentLimitNotice(count, cap, { verb = 'dropped', scope = 'call' } = {}) {
18
+ const base = `${count} attachment(s) ${verb} — exceeds the ${cap}-file limit per ${scope}.`;
19
+ return cap < PAID_MAX_ATTACHMENTS
20
+ ? `${base} Upgrade to Pro for up to ${PAID_MAX_ATTACHMENTS}.`
21
+ : base;
22
+ }
@@ -8,7 +8,7 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { buildAuthHeader, isSafeRedirectUrl, validateResolvedHost, defaultLookupFor } from './jira-client.mjs';
10
10
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
11
- const MAX_ATTACHMENTS = 20;
11
+ import { attachmentCapFor } from './attachment-caps.mjs';
12
12
  const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
13
13
  const MAX_REDIRECT_HOPS = 2;
14
14
 
@@ -57,6 +57,7 @@ async function followValidatedRedirects(initialUrl, fetcher, lookup, headers, al
57
57
  * @param {Function} opts.fetcher fetch-compatible function (default: globalThis.fetch)
58
58
  * @param {string} opts.configDir Base config dir (default: ~/.ticketlens)
59
59
  * @param {boolean} opts.noCache Force re-download even if cached (default: false)
60
+ * @param {number} opts.maxAttachments File-count cap (default: the tier cap for configDir)
60
61
  * @param {Function} opts.onProgress Optional callback(msg: string) for progress lines
61
62
  * @param {boolean} opts.allowPrivateIp Skip the private-IP DNS check on the initial hop only (default: false)
62
63
  *
@@ -71,6 +72,7 @@ export async function downloadAttachments(ticket, opts = {}) {
71
72
  noCache = false,
72
73
  onProgress = null,
73
74
  allowPrivateIp = false,
75
+ maxAttachments = attachmentCapFor(configDir),
74
76
  } = opts;
75
77
 
76
78
  const attachments = (ticket.attachments ?? []).filter(a => a.content);
@@ -82,8 +84,8 @@ export async function downloadAttachments(ticket, opts = {}) {
82
84
  const jiraOrigin = env.JIRA_BASE_URL ? new URL(env.JIRA_BASE_URL).origin : null;
83
85
 
84
86
  // Attachments beyond the limit are bulk-skipped
85
- const capped = attachments.slice(0, MAX_ATTACHMENTS);
86
- const limited = attachments.slice(MAX_ATTACHMENTS).map(a => makeResult(a, null, 'limit', null));
87
+ const capped = attachments.slice(0, maxAttachments);
88
+ const limited = attachments.slice(maxAttachments).map(a => makeResult(a, null, 'limit', null));
87
89
 
88
90
  // Pre-classify: resolve immediate results, collect pending downloads
89
91
  const results = new Array(capped.length);
@@ -14,8 +14,8 @@
14
14
  import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import { sanitizeFilename } from './attachment-downloader.mjs';
17
+ import { attachmentCapFor } from './attachment-caps.mjs';
17
18
 
18
- export const MAX_ATTACHMENTS = 20; // mirrors attachment-downloader.mjs's download-side cap
19
19
  export const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB — same
20
20
  export const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB aggregate per call — bounds worst-case memory use across a whole batch
21
21
 
@@ -67,7 +67,7 @@ export function readAttachmentFile(filePath) {
67
67
 
68
68
  /**
69
69
  * Reads a batch of paths, best-effort — one bad path never blocks the rest.
70
- * Paths beyond MAX_ATTACHMENTS are dropped and counted, not silently read.
70
+ * Paths beyond the tier's file cap are dropped and counted, not silently read.
71
71
  * A cheap pre-stat tracks the running total so a file that would push the
72
72
  * batch over MAX_TOTAL_BYTES is rejected without ever being buffered — same
73
73
  * "reject before read" principle as the per-file size cap. The pre-stat's
@@ -75,10 +75,11 @@ export function readAttachmentFile(filePath) {
75
75
  * specific error (not-found/not-a-file/etc.) for those paths.
76
76
  *
77
77
  * @param {string[]} paths
78
- * @returns {{ files: Array<{ok: boolean} & (ReturnType<typeof readAttachmentFile>)>, droppedCount: number }}
78
+ * @param {number} [maxAttachments] file-count cap; defaults to the caller's tier cap
79
+ * @returns {{ files: Array<{ok: boolean} & (ReturnType<typeof readAttachmentFile>)>, droppedCount: number, cap: number }}
79
80
  */
80
- export function readAttachments(paths) {
81
- const capped = paths.slice(0, MAX_ATTACHMENTS);
81
+ export function readAttachments(paths, maxAttachments = attachmentCapFor()) {
82
+ const capped = paths.slice(0, maxAttachments);
82
83
  const files = [];
83
84
  let totalBytes = 0;
84
85
  for (const p of capped) {
@@ -92,5 +93,5 @@ export function readAttachments(paths) {
92
93
  if (!result.error) totalBytes += result.size;
93
94
  files.push({ ok: !result.error, ...result });
94
95
  }
95
- return { files, droppedCount: paths.length - capped.length };
96
+ return { files, droppedCount: paths.length - capped.length, cap: maxAttachments };
96
97
  }
@@ -735,7 +735,7 @@ export function printCommentHelp({ stream = process.stdout } = {}) {
735
735
  ` ${s.bold('OPTIONS')}`,
736
736
  '',
737
737
  ` ${s.brand('--body')}=${s.dim('TEXT')} Comment body ${s.dim('(required)')}`,
738
- ` ${s.brand('--attach')}=${s.dim('PATHS')} Comma-separated local file paths to attach ${s.dim('(optional)')}`,
738
+ ` ${s.brand('--attach')}=${s.dim('PATHS')} Comma-separated local file paths to attach ${s.dim('(optional, up to 50 files)')}`,
739
739
  ` Images render as an inline thumbnail on Jira and Linear.`,
740
740
  ` Not supported on GitHub — no attachment upload API exists there.`,
741
741
  ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
@@ -973,7 +973,7 @@ export function printCreateHelp({ stream = process.stdout } = {}) {
973
973
  ` ${s.brand('--summary')}=${s.dim('TEXT')} Ticket title/summary ${s.dim('(required)')}`,
974
974
  ` ${s.brand('--description')}=${s.dim('TEXT')} Ticket description`,
975
975
  ` ${s.brand('--attach')}=${s.dim('PATHS')} Comma-separated local file paths to attach, uploaded after creation`,
976
- ` ${s.dim('(optional)')}. Not supported on GitHub — no attachment upload API exists there.`,
976
+ ` ${s.dim('(optional, up to 50 files)')}. Not supported on GitHub — no attachment upload API exists there.`,
977
977
  ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
978
978
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
979
979
  '',
@@ -14,7 +14,7 @@ import {
14
14
  // Mixed into the HMAC key so that knowing the license key alone is not sufficient
15
15
  // to forge a valid signature — an attacker also needs this constant from the source.
16
16
  export const LICENSE_HMAC_SALT = 'tl-lic-v1';
17
- export const LICENSE_TIERS = { free: 0, pro: 1, team: 2 };
17
+ export const LICENSE_TIERS = { free: 0, pro: 1, team: 2, enterprise: 3 };
18
18
  const LICENSE_FILE = 'license.json';
19
19
  const REVALIDATION_DAYS = 7; // attempt background revalidation after this many days
20
20
  export const GRACE_DAYS = 30; // treat license as invalid if not revalidated within this window
@@ -168,7 +168,7 @@ export const TOOLS = [
168
168
  ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
169
169
  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.' },
170
170
  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.' },
171
- 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.' },
171
+ 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 and 50MB/call caps as ticket_comment/ticket_create for the local save; file count is capped at 10 on Free, 50 on Pro/Team/Enterprise. 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.' },
172
172
  },
173
173
  required: ['title', 'body'],
174
174
  },
@@ -183,7 +183,7 @@ export const TOOLS = [
183
183
  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.' },
184
184
  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.' },
185
185
  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.' },
186
- attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach (screenshots, logs, etc.) — appended to whatever the note already has, never replacing existing attachments. Same 10MB/file, 50MB/call, 20-file caps as recall_add. Local vault only — recall_update never syncs to a team backend, so this never pushes.' },
186
+ attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach (screenshots, logs, etc.) — appended to whatever the note already has, never replacing existing attachments. Same 10MB/file and 50MB/call caps as recall_add; file count is capped at 10 on Free, 50 on Pro/Team/Enterprise. Local vault only — recall_update never syncs to a team backend, so this never pushes.' },
187
187
  },
188
188
  required: ['id', 'body'],
189
189
  },
@@ -18,7 +18,8 @@ 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
+ import { readAttachments } from './attachment-uploader.mjs';
22
+ import { attachmentCapFor, attachmentLimitNotice, PAID_MAX_ATTACHMENTS } from './attachment-caps.mjs';
22
23
  import { TICKET_KEY_PATTERN, normalizeTicketKey } from './cli.mjs';
23
24
  import { createStyler } from './ansi.mjs';
24
25
  import { confirmDestructive } from './confirm.mjs';
@@ -43,14 +44,14 @@ function parseAttachPaths(cmdArgs) {
43
44
  * best-effort philosophy as ticket_create/comment's --attach: one bad path
44
45
  * never blocks the note from being saved.
45
46
  */
46
- function summarizeAttachments({ files, droppedCount }) {
47
+ function summarizeAttachments({ files, droppedCount, cap = PAID_MAX_ATTACHMENTS }) {
47
48
  const saved = [];
48
49
  const warnings = [];
49
50
  for (const f of files) {
50
51
  if (f.ok) saved.push({ filename: f.filename, buffer: f.buffer });
51
52
  else warnings.push(` Skipped attachment ${f.path}: ${ATTACHMENT_ERROR_MESSAGES[f.error] ?? f.error}\n`);
52
53
  }
53
- if (droppedCount > 0) warnings.push(` ${droppedCount} attachment(s) dropped — exceeds the ${MAX_ATTACHMENTS}-file limit per call.\n`);
54
+ if (droppedCount > 0) warnings.push(` ${attachmentLimitNotice(droppedCount, cap)}\n`);
54
55
  return { saved, warnings };
55
56
  }
56
57
 
@@ -185,7 +186,7 @@ export async function runNoteAdd(cmdArgs, {
185
186
 
186
187
  const attachPaths = parseAttachPaths(cmdArgs);
187
188
  const { saved: attachments, warnings: attachWarnings } = attachPaths.length > 0
188
- ? summarizeAttachments(readAttachmentsFn(attachPaths))
189
+ ? summarizeAttachments(readAttachmentsFn(attachPaths, attachmentCapFor(configDir, isLicensedFn)))
189
190
  : { saved: [], warnings: [] };
190
191
  for (const warning of attachWarnings) stream.write(warning);
191
192
 
@@ -303,7 +304,7 @@ export async function runNotePatch(cmdArgs, {
303
304
 
304
305
  const attachPaths = parseAttachPaths(cmdArgs);
305
306
  const { saved: attachments, warnings: attachWarnings } = attachPaths.length > 0
306
- ? summarizeAttachments(readAttachmentsFn(attachPaths))
307
+ ? summarizeAttachments(readAttachmentsFn(attachPaths, attachmentCapFor(configDir, isLicensedFn)))
307
308
  : { saved: [], warnings: [] };
308
309
  for (const warning of attachWarnings) stream.write(warning);
309
310
 
@@ -21,7 +21,7 @@ import { detectProjectOrTypeError, enrichCreateFailure } from './ticket-create-e
21
21
  import { enrichUpdateFailure } from './ticket-update-enrichment.mjs';
22
22
  import { TICKET_KEY_PATTERN, normalizeTicketKey } from './cli.mjs';
23
23
  import { scoreCandidates } from './duplicate-scorer.mjs';
24
- import { MAX_ATTACHMENTS } from './attachment-uploader.mjs';
24
+ import { attachmentCapFor, attachmentLimitNotice, PAID_MAX_ATTACHMENTS } from './attachment-caps.mjs';
25
25
  import { createStyler } from './ansi.mjs';
26
26
 
27
27
  export function parseFlag(cmdArgs, name) {
@@ -90,7 +90,7 @@ function formatAttachSummary(attachResult, s) {
90
90
  const lines = [];
91
91
  for (const u of attachResult.uploaded) lines.push(` ${s.green('✔')} Attached ${s.bold(u.filename)}${u.url ? ` (${u.url})` : ''}\n`);
92
92
  for (const e of attachResult.errors) lines.push(` Failed to attach ${e.path}: ${e.message}\n`);
93
- if (attachResult.droppedCount > 0) lines.push(` ${attachResult.droppedCount} attachment(s) dropped — exceeds the ${MAX_ATTACHMENTS}-file limit per call.\n`);
93
+ if (attachResult.droppedCount > 0) lines.push(` ${attachmentLimitNotice(attachResult.droppedCount, attachResult.cap ?? PAID_MAX_ATTACHMENTS)}\n`);
94
94
  return lines.join('');
95
95
  }
96
96
 
@@ -345,7 +345,7 @@ export async function runTicketComment(cmdArgs, {
345
345
  let attachResult = null;
346
346
  try {
347
347
  if (attachPaths.length && !refuseGithubAttachments(adapter, attachPaths, stream)) {
348
- attachResult = await adapter.attachFiles(ticketKey, attachPaths);
348
+ attachResult = await adapter.attachFiles(ticketKey, attachPaths, { maxAttachments: attachmentCapFor(configDir, isLicensedFn) });
349
349
  }
350
350
  const inlineSnippets = (attachResult?.uploaded ?? []).filter(a => a.inlineMarkup).map(a => a.inlineMarkup).join('\n\n');
351
351
  const finalBody = inlineSnippets ? `${body}\n\n${inlineSnippets}` : body;
@@ -1135,7 +1135,7 @@ export async function runTicketCreate(cmdArgs, {
1135
1135
  let attachResult = null;
1136
1136
  if (attachPaths.length && !attachRefused) {
1137
1137
  try {
1138
- attachResult = await adapter.attachFiles(result.key, attachPaths);
1138
+ attachResult = await adapter.attachFiles(result.key, attachPaths, { maxAttachments: attachmentCapFor(configDir, isLicensedFn) });
1139
1139
 
1140
1140
  // Linear has no separate attachment list on an issue — unlike Jira's
1141
1141
  // classic attachment (real regardless of description text), an