ticketlens 0.38.10 → 0.38.12

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.
@@ -262,11 +262,13 @@ switch (command) {
262
262
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printLicenseHelp(); break; }
263
263
  const s = createStyler({ isTTY: process.stdout.isTTY });
264
264
  const status = checkLicense();
265
+ const hasLicense = Boolean(status.key);
265
266
  const daysSinceVal = status.validatedAt
266
267
  ? (Date.now() - new Date(status.validatedAt)) / 86400000
267
268
  : Infinity;
268
- // Grace period: treat as inactive if not revalidated within 30 days
269
- const graceExpired = daysSinceVal > 30;
269
+ // Grace period: treat as inactive if not revalidated within 30 days — only applies
270
+ // once a license has actually been activated (never conflate "never had one" with "lapsed").
271
+ const graceExpired = hasLicense && daysSinceVal > 30;
270
272
  process.stdout.write('\n');
271
273
  if (status.active && !graceExpired) {
272
274
  process.stdout.write(` ${s.green('●')} ${s.bold('License active')}\n\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.10",
3
+ "version": "0.38.12",
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.32.0 -->
1
+ <!-- jtb-skill-version: 0.33.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.
@@ -236,7 +236,7 @@ echo "The body text of the note, one or more paragraphs." | \
236
236
  ticketlens note add --title="Short title" --ticket=TICKET-KEY --tags=a,b
237
237
  ```
238
238
 
239
- **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. Same rule whether you're constructing the bash command above or calling `recall_add` directly — see its tool description for the same guidance.
239
+ **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.
240
240
 
241
241
  To search saved notes directly (outside of automatic brief injection): `ticketlens recall "<query>"`.
242
242
 
@@ -440,7 +440,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
440
440
  } catch { /* non-fatal */ }
441
441
 
442
442
  // Track this triage run (best-effort, non-fatal)
443
- try { incrementTriageRun(configDir); } catch { /* non-fatal */ }
443
+ try { incrementTriageRun(configDir, resolvedProfileName); } catch { /* non-fatal */ }
444
444
 
445
445
  // --digest: POST scored results to the digest backend endpoint
446
446
  if (digestFlag) {
@@ -500,7 +500,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
500
500
  const cliToken = opts.cliToken ?? readCliToken(configDir) ?? null;
501
501
  const printFn = opts.print ?? ((s) => process.stdout.write(s));
502
502
  let cliActivity = null;
503
- try { cliActivity = readAndResetActivity(configDir ?? DEFAULT_CONFIG_DIR); } catch { /* non-fatal */ }
503
+ try { cliActivity = readAndResetActivity(configDir ?? DEFAULT_CONFIG_DIR, resolvedProfileName); } catch { /* non-fatal */ }
504
504
  // Server-side notify/schedule rules must see actively-worked ('clear') tickets too —
505
505
  // only a ticket the user explicitly told the CLI to ignore is excluded from push.
506
506
  const pushable = sortByUrgency(scored.filter(s => s.urgency !== 'ignore'));
@@ -2,6 +2,16 @@
2
2
  * Best-effort CLI activity counter — tracks fetch, triage, and invocation
3
3
  * counts between pushes. Stored in ~/.ticketlens/activity.json.
4
4
  *
5
+ * fetch_count/triage_run_count are tracked per profile (data.byProfile), since
6
+ * they're always incremented at a point where a resolved profile is known and
7
+ * a push (`readAndResetActivity`) reports them for one specific profile — a
8
+ * push under profile A must never bundle in runs that actually happened under
9
+ * profile B (L-6, 2026-08-01 audit). Every other field (invocations, commands,
10
+ * drafts_kept, drafts_deleted, briefs_with_recall_injection) stays flat/global:
11
+ * they're incremented from bin/ticketlens.mjs before any command has resolved a
12
+ * profile at all (many commands, e.g. `license`/`cache`, have no profile concept
13
+ * whatsoever), so there's no meaningful profile to bucket them under.
14
+ *
5
15
  * Intentional limitations (acceptable for a UX metric, NOT suitable for billing):
6
16
  *
7
17
  * - Not transactional: if a push succeeds server-side but the network returns
@@ -19,12 +29,13 @@ import fs from 'node:fs';
19
29
  import path from 'node:path';
20
30
 
21
31
  const ACTIVITY_FILE = 'activity.json';
32
+ const EMPTY_PROFILE_BUCKET = { fetch_count: 0, triage_run_count: 0 };
22
33
 
23
34
  function read(configDir) {
24
35
  try {
25
36
  return JSON.parse(fs.readFileSync(path.join(configDir, ACTIVITY_FILE), 'utf8'));
26
37
  } catch {
27
- return { fetch_count: 0, triage_run_count: 0, invocations: 0, commands: {}, drafts_kept: 0, drafts_deleted: 0, briefs_with_recall_injection: 0 };
38
+ return { byProfile: {}, invocations: 0, commands: {}, drafts_kept: 0, drafts_deleted: 0, briefs_with_recall_injection: 0 };
28
39
  }
29
40
  }
30
41
 
@@ -48,12 +59,26 @@ function increment(configDir, field) {
48
59
  return data[field];
49
60
  }
50
61
 
51
- export function incrementFetch(configDir) {
52
- increment(configDir, 'fetch_count');
62
+ /**
63
+ * @param {string} configDir
64
+ * @param {string} profile
65
+ * @param {'fetch_count'|'triage_run_count'} field
66
+ */
67
+ function incrementProfileField(configDir, profile, field) {
68
+ const data = read(configDir);
69
+ if (!data.byProfile) data.byProfile = {};
70
+ if (!data.byProfile[profile]) data.byProfile[profile] = { ...EMPTY_PROFILE_BUCKET };
71
+ const bucket = data.byProfile[profile];
72
+ bucket[field] = (bucket[field] ?? 0) + 1;
73
+ write(configDir, data);
74
+ }
75
+
76
+ export function incrementFetch(configDir, profile) {
77
+ incrementProfileField(configDir, profile, 'fetch_count');
53
78
  }
54
79
 
55
- export function incrementTriageRun(configDir) {
56
- increment(configDir, 'triage_run_count');
80
+ export function incrementTriageRun(configDir, profile) {
81
+ incrementProfileField(configDir, profile, 'triage_run_count');
57
82
  }
58
83
 
59
84
  export function incrementInvocation(configDir) {
@@ -147,25 +172,35 @@ export function recordTokensSaved(configDir, command, tokens) {
147
172
  }
148
173
 
149
174
  /**
150
- * Returns the current counters and resets them to zero.
151
- * Call only after a confirmed successful push.
175
+ * Returns the current counters and resets them to zero. Call only after a
176
+ * confirmed successful push, for the exact profile that push was for.
177
+ *
178
+ * fetch_count/triage_run_count are scoped to the given profile — only that
179
+ * profile's bucket is read and reset, so a push for profile A never bundles
180
+ * in (or clears) runs that happened under a different profile B. Every other
181
+ * field stays global, same as before: reset regardless of which profile
182
+ * triggered this push (see the module doc comment for why).
152
183
  *
153
184
  * @param {string} configDir
185
+ * @param {string} profile
154
186
  * @returns {{ fetch_count: number, triage_run_count: number, invocations: number, commands: object }}
155
187
  */
156
- export function readAndResetActivity(configDir) {
188
+ export function readAndResetActivity(configDir, profile) {
157
189
  const data = read(configDir);
190
+ const profileBucket = data.byProfile?.[profile] ?? EMPTY_PROFILE_BUCKET;
158
191
  const snapshot = {
159
- fetch_count: data.fetch_count ?? 0,
160
- triage_run_count: data.triage_run_count ?? 0,
192
+ fetch_count: profileBucket.fetch_count ?? 0,
193
+ triage_run_count: profileBucket.triage_run_count ?? 0,
161
194
  invocations: data.invocations ?? 0,
162
195
  commands: data.commands ?? {},
163
196
  drafts_kept: data.drafts_kept ?? 0,
164
197
  drafts_deleted: data.drafts_deleted ?? 0,
165
198
  briefs_with_recall_injection: data.briefs_with_recall_injection ?? 0,
166
199
  };
200
+ const byProfile = { ...(data.byProfile ?? {}) };
201
+ delete byProfile[profile];
167
202
  write(configDir, {
168
- fetch_count: 0, triage_run_count: 0, invocations: 0, commands: {},
203
+ byProfile, invocations: 0, commands: {},
169
204
  drafts_kept: 0, drafts_deleted: 0, briefs_with_recall_injection: 0,
170
205
  ...(data.pulses ? { pulses: data.pulses } : {}),
171
206
  });
@@ -54,13 +54,13 @@ export function printHelp({ stream = process.stdout } = {}) {
54
54
  ` ${s.brand('ticketlens')} recall sync Retry any notes stuck in the local queue ${s.dim('[Team+]')}`,
55
55
  ` ${s.brand('ticketlens')} recall settings Show effective retry-queue settings, fetched live ${s.dim('[Team+]')}`,
56
56
  ` ${s.brand('ticketlens')} mcp Start the MCP stdio server (Recall + ticket writes) ${s.dim('[Pro]')}`,
57
- ` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=...')} Post a comment to the tracker ${s.dim('[Pro]')}`,
57
+ ` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=... [--attach=...]')} Post a comment to the tracker ${s.dim('[Pro]')}`,
58
58
  ` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
59
59
  ` ${s.brand('ticketlens')} assign ${s.dim('<TICKET-KEY> --to=me')} Assign a ticket to yourself ${s.dim('[Pro]')}`,
60
60
  ` ${s.brand('ticketlens')} duplicates ${s.dim('<TICKET-KEY> [--threshold=N]')} Find likely duplicate tickets ${s.dim('[Pro]')}`,
61
61
  ` ${s.brand('ticketlens')} link ${s.dim('<SOURCE> <TARGET> [--type=... --confirm]')} Link two tickets ${s.dim('[Pro]')}`,
62
62
  ` ${s.brand('ticketlens')} update ${s.dim('<TICKET-KEY> [--title=... --description=... --priority=... --add-labels=... --remove-labels=...]')} Update fields ${s.dim('[Pro]')}`,
63
- ` ${s.brand('ticketlens')} create ${s.dim('--project=... [--type=...] --summary=... [--description=...]')} Create a new ticket ${s.dim('[Pro]')}`,
63
+ ` ${s.brand('ticketlens')} create ${s.dim('--project=... [--type=...] --summary=... [--description=...] [--attach=...]')} Create a new ticket ${s.dim('[Pro]')}`,
64
64
  '',
65
65
  ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
66
66
  ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
@@ -567,13 +567,20 @@ export function printNoteHelp({ stream = process.stdout } = {}) {
567
567
  '',
568
568
  ` ${s.dim('$')} echo "Retry needs exponential backoff" | ticketlens note add --title="Retry gotcha" --ticket=PROD-123 --tags=bug`,
569
569
  '',
570
- ` ${s.bold('ticketlens note patch')} ${s.dim('--id="..." [--ticket=KEY]')} ${s.dim('[Pro]')}`,
570
+ ` ${s.bold('ticketlens note patch')} ${s.dim('--id="..." [--ticket=KEY] [--expect-mtime=MS]')} ${s.dim('[Pro]')}`,
571
571
  '',
572
572
  ` Overwrites an existing note's body with a better draft, read from stdin.`,
573
573
  ` Internal mechanism used by the jtb skill's note quality loop inside a Claude`,
574
574
  ` Code session — not typically invoked by hand. Every note it writes gets the`,
575
575
  ` same structural and secret-scan checks ${s.brand('note add')} applies to user input.`,
576
576
  '',
577
+ ` ${s.bold('OPTIONS')}`,
578
+ '',
579
+ ` ${s.brand('--id')}=${s.dim('ID')} Note id to patch ${s.dim('(required)')}`,
580
+ ` ${s.brand('--ticket')}=${s.dim('KEY')} Ticket this note is about ${s.dim('(optional)')}`,
581
+ ` ${s.brand('--expect-mtime')}=${s.dim('MS')} File mtime (ms) the caller last observed ${s.dim('(optional)')} — a no-op,`,
582
+ ` not an overwrite, if the file changed since then`,
583
+ '',
577
584
  ` ${s.bold('ticketlens note delete')} ${s.dim('--id="..." [--ticket=KEY] [--yes]')} ${s.dim('[Pro]')}`,
578
585
  '',
579
586
  ` Removes a note from your local vault. Local only — if this note was already`,
@@ -646,6 +653,10 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
646
653
  ` ${s.dim('restart or reconnect the MCP client to pick up the new schema. The running')}`,
647
654
  ` ${s.dim('server is always current; only the client-side copy goes stale.')}`,
648
655
  '',
656
+ ` ${s.dim('The [Pro] badge above describes the tools this server exposes — starting the')}`,
657
+ ` ${s.dim('server itself is ungated, same as `mcp install`; each tool still enforces its')}`,
658
+ ` ${s.dim('own license check at call time.')}`,
659
+ '',
649
660
  ` ${s.bold('OPTIONS')}`,
650
661
  '',
651
662
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
@@ -976,6 +987,7 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
976
987
  ` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
977
988
  ` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
978
989
  ` ${s.brand('--plain')} Plain markdown output`,
990
+ ` ${s.brand('--styled')} Force ANSI-styled output ${s.dim('(even when piped)')}`,
979
991
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
980
992
  '',
981
993
  ` ${s.bold('EXAMPLES')}`,
@@ -36,7 +36,7 @@ const TOOLS = [
36
36
  properties: {
37
37
  title: { type: 'string', description: 'Short one-line title.' },
38
38
  ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
39
- 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.' },
39
+ 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.' },
40
40
  body: { type: 'string', description: 'The note body — one or more paragraphs.' },
41
41
  },
42
42
  required: ['title', 'body'],
@@ -119,7 +119,7 @@ const TOOLS = [
119
119
  },
120
120
  {
121
121
  name: 'ticket_update',
122
- description: 'Update a narrow, named field set on a ticket in its tracker (Jira/GitHub/Linear) — title, description, labels, priority. Labels are add/remove, never a wholesale replace: an unnamed existing label is left alone. No discovery step and no confirm required — these are reversible metadata edits, not workflow-state changes. GitHub has no priority field; passing `priority` for a GitHub-tracked ticket is refused. A call can partially succeed (e.g. title updates but a label does not resolve) — the result reports exactly what landed. Requires a TicketLens Pro license.',
122
+ description: 'Update a narrow, named field set on a ticket in its tracker (Jira/GitHub/Linear) — title, description, labels, priority. At least one field is required. Labels are add/remove, never a wholesale replace: an unnamed existing label is left alone. No discovery step and no confirm required — these are reversible metadata edits, not workflow-state changes. GitHub has no priority field; passing `priority` for a GitHub-tracked ticket is refused. A call can partially succeed (e.g. title updates but a label does not resolve) — the result reports exactly what landed. Requires a TicketLens Pro license.',
123
123
  inputSchema: {
124
124
  type: 'object',
125
125
  properties: {
@@ -245,13 +245,13 @@ async function callTicketTransition(args, { configDir, runTicketTransitionListFn
245
245
  }
246
246
  const capture = capturingStream();
247
247
  if (!args.target) {
248
- const { ok } = await runTicketTransitionListFn([args.ticket], { configDir, stream: capture });
248
+ const { ok } = await runTicketTransitionListFn([args.ticket], { configDir, stream: capture, cliHints: false });
249
249
  const content = [{ type: 'text', text: capture.text }];
250
250
  return ok ? { content } : { isError: true, content };
251
251
  }
252
252
  const cmdArgs = [args.ticket, `--target=${args.target}`];
253
253
  if (args.confirm === true) cmdArgs.push('--confirm');
254
- const { ok } = await runTicketTransitionFn(cmdArgs, { configDir, stream: capture });
254
+ const { ok } = await runTicketTransitionFn(cmdArgs, { configDir, stream: capture, cliHints: false });
255
255
  const content = [{ type: 'text', text: capture.text }];
256
256
  return ok ? { content } : { isError: true, content };
257
257
  }
@@ -296,13 +296,13 @@ async function callTicketLink(args, { configDir, runTicketLinkListFn, runTicketL
296
296
  }
297
297
  const capture = capturingStream();
298
298
  if (!args.type) {
299
- const { ok } = await runTicketLinkListFn([args.ticket, args.target], { configDir, stream: capture });
299
+ const { ok } = await runTicketLinkListFn([args.ticket, args.target], { configDir, stream: capture, cliHints: false });
300
300
  const content = [{ type: 'text', text: capture.text }];
301
301
  return ok ? { content } : { isError: true, content };
302
302
  }
303
303
  const cmdArgs = [args.ticket, args.target, `--type=${args.type}`];
304
304
  if (args.confirm === true) cmdArgs.push('--confirm');
305
- const { ok } = await runTicketLinkFn(cmdArgs, { configDir, stream: capture });
305
+ const { ok } = await runTicketLinkFn(cmdArgs, { configDir, stream: capture, cliHints: false });
306
306
  const content = [{ type: 'text', text: capture.text }];
307
307
  return ok ? { content } : { isError: true, content };
308
308
  }
@@ -11,7 +11,7 @@ import { DEFAULT_CONFIG_DIR } from './config.mjs';
11
11
  import { isLicensed, showUpgradePrompt } from './license.mjs';
12
12
  import { scanForSecrets } from './secret-scanner.mjs';
13
13
  import { checkNoteStructure } from './note-structural-check.mjs';
14
- import { writeNote, patchNoteBody, deleteNote, rebuildIndex } from './recall-vault.mjs';
14
+ import { writeNote, patchNoteBody, deleteNote, deleteNoteAnyPrefix, rebuildIndex } from './recall-vault.mjs';
15
15
  import { readCliToken } from './cli-auth.mjs';
16
16
  import { pushNote } from './recall-sync.mjs';
17
17
  import { enqueueNote, isRetryableFailure, maybeAutoFlush } from './recall-queue.mjs';
@@ -230,6 +230,7 @@ export async function runNoteDelete(cmdArgs, {
230
230
  stdin = process.stdin,
231
231
  isLicensedFn = isLicensed,
232
232
  deleteNoteFn = deleteNote,
233
+ deleteNoteAnyPrefixFn = deleteNoteAnyPrefix,
233
234
  rebuildIndexFn = rebuildIndex,
234
235
  confirmFn = confirmDestructive,
235
236
  } = {}) {
@@ -257,7 +258,9 @@ export async function runNoteDelete(cmdArgs, {
257
258
  return { deleted: false };
258
259
  }
259
260
 
260
- const { deleted, prefix } = deleteNoteFn({ external_id: id, tickets: ticketKey ? [ticketKey] : [] }, { configDir });
261
+ const { deleted, prefix } = ticketKey
262
+ ? deleteNoteFn({ external_id: id, tickets: [ticketKey] }, { configDir })
263
+ : deleteNoteAnyPrefixFn(id, { configDir });
261
264
  if (deleted) rebuildIndexFn(prefix, { configDir });
262
265
  stream.write(deleted
263
266
  ? ` Deleted note (${id}) — local vault only; see help for team-synced notes.\n`
@@ -17,7 +17,7 @@ import { listNotes } from './recall-vault.mjs';
17
17
  import { readCliToken } from './cli-auth.mjs';
18
18
  import { pullNotes } from './recall-sync.mjs';
19
19
  import { maybeAutoFlush, flushQueue, readQueue } from './recall-queue.mjs';
20
- import { getEffectiveRecallSettings } from './recall-settings-sync.mjs';
20
+ import { getEffectiveRecallSettingsWithSource } from './recall-settings-sync.mjs';
21
21
  import { styleRecallResults } from './styled-assembler.mjs';
22
22
 
23
23
  /**
@@ -98,6 +98,13 @@ export async function runRecallSync(cmdArgs, {
98
98
  return { ok: true };
99
99
  }
100
100
 
101
+ // Keyed by the `source` getEffectiveRecallSettingsWithSource reports.
102
+ const SOURCE_LABELS = {
103
+ 'no-token': 'platform default — log in to pick up a team override',
104
+ 'live': 'your team manager (or platform default if unset), fetched live just now',
105
+ 'cache-fallback': 'last known-good cached value — live fetch failed, could be stale',
106
+ };
107
+
101
108
  /**
102
109
  * Implements `tl recall settings` — read-only display of the effective
103
110
  * Recall queue settings (flush cooldown, per-request timeout, max queue
@@ -114,7 +121,7 @@ export async function runRecallSettings(cmdArgs, {
114
121
  stream = process.stdout,
115
122
  isLicensedFn = isLicensed,
116
123
  readCliTokenFn = readCliToken,
117
- getEffectiveRecallSettingsFn = getEffectiveRecallSettings,
124
+ getEffectiveRecallSettingsWithSourceFn = getEffectiveRecallSettingsWithSource,
118
125
  } = {}) {
119
126
  if (!isLicensedFn('pro', configDir)) {
120
127
  showUpgradePrompt('pro', 'ticketlens recall', { stream });
@@ -122,13 +129,12 @@ export async function runRecallSettings(cmdArgs, {
122
129
  }
123
130
 
124
131
  const cliToken = readCliTokenFn(configDir);
125
- const settings = await getEffectiveRecallSettingsFn({ cliToken, configDir });
126
- const source = cliToken ? 'your team manager (or platform default if unset)' : 'platform default — log in to pick up a team override';
132
+ const { values: settings, source } = await getEffectiveRecallSettingsWithSourceFn({ cliToken, configDir });
127
133
 
128
134
  stream.write(` Retry cooldown: ${settings.flush_cooldown_ms / 60_000} min\n`);
129
135
  stream.write(` Per-request timeout: ${settings.timeout_ms / 1_000} sec\n`);
130
136
  stream.write(` Max queued notes: ${settings.max_queue_size}\n`);
131
137
  stream.write(` Queued note expiry: ${settings.max_entry_age_ms / 86_400_000} days\n`);
132
- stream.write(` Source: ${source}\n`);
138
+ stream.write(` Source: ${SOURCE_LABELS[source]}\n`);
133
139
  return { ok: true };
134
140
  }
@@ -136,23 +136,29 @@ export async function fetchRecallSettings({
136
136
  * with a hash of cliToken so a later account switch never serves a stale
137
137
  * team's settings as a "fallback."
138
138
  *
139
+ * Also reports how the values were obtained, which `recall settings` needs
140
+ * for an accurate "Source:" line — cliToken presence alone can't tell those
141
+ * apart, since a live fetch and a silent cache fallback both had a token but
142
+ * only one is actually current. Callers that don't care use the values-only
143
+ * getEffectiveRecallSettings below.
144
+ *
139
145
  * @param {object} opts
140
146
  * @param {string} [opts.cliToken]
141
147
  * @param {string} [opts.configDir]
142
148
  * @param {() => number} [opts.now]
143
149
  * @param {Function} [opts.fetchRecallSettingsFn]
144
- * @returns {Promise<{flush_cooldown_ms: number, timeout_ms: number, max_queue_size: number, max_entry_age_ms: number}>}
150
+ * @returns {Promise<{values: object, source: 'no-token'|'live'|'cache-fallback'}>}
145
151
  */
146
- export async function getEffectiveRecallSettings({
152
+ export async function getEffectiveRecallSettingsWithSource({
147
153
  cliToken,
148
154
  configDir = DEFAULT_CONFIG_DIR,
149
155
  now = () => Date.now(),
150
156
  fetchRecallSettingsFn = fetchRecallSettings,
151
157
  } = {}) {
152
- if (!cliToken) return readEffectiveRecallSettings(configDir);
158
+ if (!cliToken) return { values: readEffectiveRecallSettings(configDir), source: 'no-token' };
153
159
 
154
160
  const result = await fetchRecallSettingsFn({ cliToken });
155
- if (!result.ok) return readEffectiveRecallSettings(configDir, { cliToken });
161
+ if (!result.ok) return { values: readEffectiveRecallSettings(configDir, { cliToken }), source: 'cache-fallback' };
156
162
 
157
163
  writeFileAtomically(cachePath(configDir), JSON.stringify({
158
164
  values: result.values,
@@ -164,5 +170,17 @@ export async function getEffectiveRecallSettings({
164
170
  for (const field of Object.keys(DEFAULT_RECALL_SETTINGS)) {
165
171
  effective[field] = clamp(field, result.values[field]);
166
172
  }
167
- return effective;
173
+ return { values: effective, source: 'live' };
174
+ }
175
+
176
+ /**
177
+ * Values-only view of the same resolution — the contract every caller other
178
+ * than `recall settings` (recall-queue.mjs) uses.
179
+ *
180
+ * @param {object} opts - same as getEffectiveRecallSettingsWithSource
181
+ * @returns {Promise<{flush_cooldown_ms: number, timeout_ms: number, max_queue_size: number, max_entry_age_ms: number}>}
182
+ */
183
+ export async function getEffectiveRecallSettings(opts = {}) {
184
+ const { values } = await getEffectiveRecallSettingsWithSource(opts);
185
+ return values;
168
186
  }
@@ -164,6 +164,36 @@ export function deleteNote({ external_id: externalId, tickets = [] }, { configDi
164
164
  return { deleted: true, prefix };
165
165
  }
166
166
 
167
+ /**
168
+ * Deletes a note when the caller doesn't know (or a CLI user didn't specify)
169
+ * which ticket-prefix folder it lives in — used only by `note delete` when
170
+ * --ticket is omitted. Unlike deleteNote, which resolves a single
171
+ * deterministic path from tickets[0] (required for tombstone processing to
172
+ * stay exact — a tombstone's tickets:[] genuinely means "this note has no
173
+ * ticket," not "unknown"), this searches every existing prefix folder, the
174
+ * same "no prefix given" fallback listNotes already uses below. _general is
175
+ * tried first since that's where a truly general note lives.
176
+ *
177
+ * @param {string} externalId
178
+ * @param {{ configDir?: string }} [opts]
179
+ * @returns {{ deleted: boolean, prefix: string|null }}
180
+ */
181
+ export function deleteNoteAnyPrefix(externalId, { configDir = DEFAULT_CONFIG_DIR } = {}) {
182
+ if (!EXTERNAL_ID_PATTERN.test(externalId)) {
183
+ throw new Error(`Invalid externalId: "${externalId}"`);
184
+ }
185
+
186
+ const prefixes = [GENERAL_BUCKET, ...allPrefixDirs(configDir).filter(p => p !== GENERAL_BUCKET)];
187
+ for (const prefix of prefixes) {
188
+ const notePath = path.join(prefixDir(configDir, prefix), externalId);
189
+ if (fs.existsSync(notePath)) {
190
+ fs.unlinkSync(notePath);
191
+ return { deleted: true, prefix };
192
+ }
193
+ }
194
+ return { deleted: false, prefix: null };
195
+ }
196
+
167
197
  /**
168
198
  * Overwrites an existing local note's body in place — used by the jtb skill's
169
199
  * generator/validator quality loop to swap in a better draft after `note add`
@@ -1,5 +1,5 @@
1
1
  import { computeResponseMetrics, DEFAULT_CONFIG_DIR } from './triage-history.mjs';
2
- import { loadProfiles } from './profile-resolver.mjs';
2
+ import { resolveConnection } from './profile-resolver.mjs';
3
3
  import { isLicensed as defaultIsLicensed } from './license.mjs';
4
4
  import { createStyler } from './ansi.mjs';
5
5
  import { handleUnknownFlags } from './arg-validator.mjs';
@@ -51,12 +51,11 @@ export async function runStats(args = [], opts = {}) {
51
51
  const isPro = isLic('pro', configDir);
52
52
  const days = (!isPro && parsedDays > FREE_DAYS_CAP) ? FREE_DAYS_CAP : parsedDays;
53
53
 
54
- // Resolve profile name
55
- let profile = profileArg ? profileArg.split('=')[1] : null;
56
- if (!profile) {
57
- const config = loadProfiles(configDir);
58
- profile = config?.default ?? 'default';
59
- }
54
+ // Resolve profile name (same chain as triage: --profile flag → cwd projectPaths → config.default)
55
+ const explicitProfile = profileArg ? profileArg.split('=')[1] : undefined;
56
+ const cwd = opts.cwd ?? process.cwd();
57
+ const conn = resolveConnection(null, { configDir, profileName: explicitProfile, cwd });
58
+ const profile = conn.profileName ?? 'default';
60
59
 
61
60
  let metrics;
62
61
  try {
@@ -142,19 +142,31 @@ function isLabelWord(token) {
142
142
  * Rejoins runs of adjacent whitespace-separated tokens (no separator) so a secret
143
143
  * broken up by whitespace — accidental soft-wrap, or a deliberate space/tab/
144
144
  * newline inserted to dodge the scanner — still reads as one contiguous string.
145
- * A run only stops at a label word (see isLabelWord), so a real context word
146
- * like "commit" can't glue onto an unrelated payload.
145
+ *
146
+ * `stopAtLabelWords` (the default) ends a run at a label word (see isLabelWord),
147
+ * so a real context word like "commit" can't glue onto an unrelated payload and
148
+ * trip the entropy heuristic. Pass false for the HARD_REJECT_PATTERNS pass: those
149
+ * match an exact literal prefix (AKIA, sk-, ghp_, eyJ, -----BEGIN) rather than
150
+ * guessing from shape, so ordinary prose can't turn into a false positive there —
151
+ * and a fixed secret prefix can itself look like an ordinary word to isLabelWord
152
+ * ("AKIA" is indistinguishable from an acronym by that heuristic), which would
153
+ * otherwise stop it from ever rejoining with a whitespace-split suffix.
154
+ *
155
+ * Either way a run only ever extends forward from its own start index, so a word
156
+ * standing immediately *before* a secret is never glued onto it — that's what
157
+ * keeps the leading \b anchor in the boundary-anchored patterns intact.
147
158
  *
148
159
  * @param {string[]} tokens
160
+ * @param {{ stopAtLabelWords?: boolean }} [opts]
149
161
  * @returns {string[]}
150
162
  */
151
- function joinedChunkRuns(tokens) {
163
+ function joinedChunkRuns(tokens, { stopAtLabelWords = true } = {}) {
152
164
  const runs = [];
153
165
  for (let i = 0; i < tokens.length; i++) {
154
- if (isLabelWord(tokens[i])) continue;
166
+ if (stopAtLabelWords && isLabelWord(tokens[i])) continue;
155
167
  let joined = tokens[i];
156
168
  for (let j = i + 1; j < Math.min(tokens.length, i + MAX_JOINED_CHUNKS); j++) {
157
- if (isLabelWord(tokens[j])) break;
169
+ if (stopAtLabelWords && isLabelWord(tokens[j])) break;
158
170
  joined += tokens[j];
159
171
  runs.push(joined);
160
172
  }
@@ -171,19 +183,34 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
171
183
  const reasons = [];
172
184
  const warnings = [];
173
185
 
186
+ const tokens = combined.split(/\s+/).filter(Boolean);
187
+ const candidates = [...tokens, ...joinedChunkRuns(tokens)];
188
+
174
189
  // A known secret shape (AWS key, API key prefix, JWT, PEM block...) is recognized
175
- // by a specific literal prefix, so it's safe to also check a fully whitespace-
176
- // stripped variant of the text — a stray space/tab/newline dropped into the
177
- // secret (soft-wrap or deliberate evasion) can't hide it from its own shape.
190
+ // by a specific literal prefix. Checked three ways, each catching what the
191
+ // others miss:
192
+ // 1. combined — the unsplit occurrence.
193
+ // 2. hardRejectRuns — each individually rejoined run (bounded to
194
+ // MAX_JOINED_CHUNKS tokens), which keeps the leading \b anchor intact
195
+ // for the two boundary-anchored patterns (API key, GitHub token) —
196
+ // stripping whitespace from the whole note at once would instead glue
197
+ // an unrelated preceding word onto the secret's first character and
198
+ // kill that anchor. This is the primary, anchor-safe check.
199
+ // 3. despacedCombined — the whole note with all whitespace stripped,
200
+ // unbounded in length. A fallback for fragmentation wider than
201
+ // MAX_JOINED_CHUNKS tokens (e.g. a secret typed one character per
202
+ // token) that #2's bounded window can't reach. Reintroduces the same
203
+ // anchor risk #2 was built to avoid, but only as an additional check
204
+ // alongside #2, never instead of it — it can only add a detection
205
+ // that #1/#2 missed, never remove one they already caught.
178
206
  const despacedCombined = combined.replace(/\s+/g, '');
207
+ const hardRejectRuns = joinedChunkRuns(tokens, { stopAtLabelWords: false });
179
208
  for (const { name, re } of HARD_REJECT_PATTERNS) {
180
- if (re.test(combined) || re.test(despacedCombined)) {
209
+ if (re.test(combined) || hardRejectRuns.some(c => re.test(c)) || re.test(despacedCombined)) {
181
210
  reasons.push(`Looks like a${/^[aeiou]/i.test(name) ? 'n' : ''} ${name}.`);
182
211
  }
183
212
  }
184
213
 
185
- const tokens = combined.split(/\s+/).filter(Boolean);
186
- const candidates = [...tokens, ...joinedChunkRuns(tokens)];
187
214
  // A candidate containing a real email address is skipped: with ordinary words
188
215
  // now eligible to join (needed to catch e.g. "wall"-separated or base64-letter-
189
216
  // only split secrets), an email joined with an adjacent word can produce a long
@@ -311,6 +311,9 @@ export async function runTicketComment(cmdArgs, {
311
311
  * transition options for the ticket.
312
312
  *
313
313
  * @param {string[]} cmdArgs - [ticketKey]
314
+ * @param {boolean} [cliHints] - true (default) prints CLI flag syntax
315
+ * (--target=, --confirm) in the hint; false prints MCP-shaped named-arg
316
+ * wording instead — set by mcp-server.mjs's ticket_transition call sites.
314
317
  * @returns {Promise<{ ok: boolean, options?: object[] }>}
315
318
  */
316
319
  export async function runTicketTransitionList(cmdArgs, {
@@ -319,6 +322,7 @@ export async function runTicketTransitionList(cmdArgs, {
319
322
  isLicensedFn = isLicensed,
320
323
  resolveConnectionFn = resolveConnection,
321
324
  resolveAdapterFn = resolveAdapter,
325
+ cliHints = true,
322
326
  } = {}) {
323
327
  const usage = 'Usage: ticketlens transition TICKET-KEY [--target="..." --confirm]\n';
324
328
  if (!requireLicense(isLicensedFn, configDir, 'ticketlens transition', stream)) return { ok: false };
@@ -338,7 +342,9 @@ export async function runTicketTransitionList(cmdArgs, {
338
342
  }
339
343
  stream.write(` Valid transitions for ${s.brand(s.bold(ticketKey))}:\n\n`);
340
344
  for (const o of options) stream.write(` ${s.brand('●')} ${o.name}\n`);
341
- stream.write(`\n Run again with --target="<name>" --confirm to execute.\n`);
345
+ stream.write(cliHints
346
+ ? `\n Run again with --target="<name>" --confirm to execute.\n`
347
+ : `\n Call again with target="<name>" and confirm: true to execute.\n`);
342
348
  return { ok: true, options };
343
349
  } catch (err) {
344
350
  stream.write(formatWriteFailure(ticketKey, err));
@@ -351,6 +357,7 @@ export async function runTicketTransitionList(cmdArgs, {
351
357
  * without --confirm is treated as incomplete input, never silently executed.
352
358
  *
353
359
  * @param {string[]} cmdArgs - [ticketKey, '--target=...', '--confirm']
360
+ * @param {boolean} [cliHints] - see runTicketTransitionList's cliHints doc
354
361
  * @returns {Promise<{ ok: boolean, reason?: string }>}
355
362
  */
356
363
  export async function runTicketTransition(cmdArgs, {
@@ -363,6 +370,7 @@ export async function runTicketTransition(cmdArgs, {
363
370
  recordActionFn = recordAction,
364
371
  logActionFn = logAction,
365
372
  actor = os.userInfo().username,
373
+ cliHints = true,
366
374
  } = {}) {
367
375
  const usage = 'Usage: ticketlens transition TICKET-KEY --target="..." --confirm\n';
368
376
  if (!requireLicense(isLicensedFn, configDir, 'ticketlens transition', stream)) return { ok: false };
@@ -376,7 +384,9 @@ export async function runTicketTransition(cmdArgs, {
376
384
  return { ok: false };
377
385
  }
378
386
  if (!cmdArgs.includes('--confirm')) {
379
- stream.write(` Refusing to transition ${ticketKey} to "${target}" without --confirm. Re-run with --confirm once you've reviewed the target.\n`);
387
+ stream.write(cliHints
388
+ ? ` Refusing to transition ${ticketKey} to "${target}" without --confirm. Re-run with --confirm once you've reviewed the target.\n`
389
+ : ` Refusing to transition ${ticketKey} to "${target}" without confirm: true. Call again with confirm: true once you've reviewed the target.\n`);
380
390
  return { ok: false };
381
391
  }
382
392
 
@@ -552,6 +562,7 @@ export async function runTicketDuplicates(cmdArgs, {
552
562
  * so that asymmetry is surfaced here before a caller ever reaches --confirm.
553
563
  *
554
564
  * @param {string[]} cmdArgs - [sourceKey, targetKey]
565
+ * @param {boolean} [cliHints] - see runTicketTransitionList's cliHints doc
555
566
  * @returns {Promise<{ ok: boolean, types?: string[] }>}
556
567
  */
557
568
  export async function runTicketLinkList(cmdArgs, {
@@ -560,6 +571,7 @@ export async function runTicketLinkList(cmdArgs, {
560
571
  isLicensedFn = isLicensed,
561
572
  resolveConnectionFn = resolveConnection,
562
573
  resolveAdapterFn = resolveAdapter,
574
+ cliHints = true,
563
575
  } = {}) {
564
576
  const usage = 'Usage: ticketlens link SOURCE-KEY TARGET-KEY [--type="..." --confirm]\n';
565
577
  if (!requireLicense(isLicensedFn, configDir, 'ticketlens link', stream)) return { ok: false };
@@ -585,7 +597,9 @@ export async function runTicketLinkList(cmdArgs, {
585
597
  if (adapter.type === 'github') {
586
598
  stream.write(` Note: GitHub has no generic link relationship — linking will CLOSE ${sourceKey} as a duplicate of ${targetKey}.\n`);
587
599
  }
588
- stream.write(` Run again with --type="<name>" --confirm to execute — ${sourceKey} will be recorded as the one that "types" ${targetKey}.\n`);
600
+ stream.write(cliHints
601
+ ? ` Run again with --type="<name>" --confirm to execute — ${sourceKey} will be recorded as the one that "types" ${targetKey}.\n`
602
+ : ` Call again with type="<name>" and confirm: true to execute — ${sourceKey} will be recorded as the one that "types" ${targetKey}.\n`);
589
603
  return { ok: true, types };
590
604
  } catch (err) {
591
605
  stream.write(formatLinkListFailure(sourceKey, err));
@@ -602,6 +616,7 @@ export async function runTicketLinkList(cmdArgs, {
602
616
  * anything else) with targetKey/type carried in detail instead.
603
617
  *
604
618
  * @param {string[]} cmdArgs - [sourceKey, targetKey, '--type=...', '--confirm']
619
+ * @param {boolean} [cliHints] - see runTicketTransitionList's cliHints doc
605
620
  * @returns {Promise<{ ok: boolean, reason?: string }>}
606
621
  */
607
622
  export async function runTicketLink(cmdArgs, {
@@ -614,6 +629,7 @@ export async function runTicketLink(cmdArgs, {
614
629
  recordActionFn = recordAction,
615
630
  logActionFn = logAction,
616
631
  actor = os.userInfo().username,
632
+ cliHints = true,
617
633
  } = {}) {
618
634
  const usage = 'Usage: ticketlens link SOURCE-KEY TARGET-KEY --type="..." --confirm\n';
619
635
  if (!requireLicense(isLicensedFn, configDir, 'ticketlens link', stream)) return { ok: false };
@@ -629,7 +645,9 @@ export async function runTicketLink(cmdArgs, {
629
645
  return { ok: false };
630
646
  }
631
647
  if (!cmdArgs.includes('--confirm')) {
632
- stream.write(` Refusing to link ${sourceKey} to ${targetKey} as "${type}" without --confirm. Re-run with --confirm once you've reviewed the target.\n`);
648
+ stream.write(cliHints
649
+ ? ` Refusing to link ${sourceKey} to ${targetKey} as "${type}" without --confirm. Re-run with --confirm once you've reviewed the target.\n`
650
+ : ` Refusing to link ${sourceKey} to ${targetKey} as "${type}" without confirm: true. Call again with confirm: true once you've reviewed the target.\n`);
633
651
  return { ok: false };
634
652
  }
635
653
 
@@ -70,7 +70,23 @@ export function saveTriageSnapshot(tickets, {
70
70
  const dir = join(configDir, 'triage-history', dateStr);
71
71
  fsModule.mkdirSync(dir, { recursive: true });
72
72
  const filePath = snapshotPath(configDir, dateStr, profile);
73
- const envelope = { captured_at: now.toISOString(), tickets };
73
+
74
+ // A same-day re-run (e.g. a narrower, filtered, or exploratory `triage`
75
+ // invocation after an earlier fuller one) must not silently discard
76
+ // tickets the earlier run already captured for today — stats/history read
77
+ // this file as "the" snapshot for the day. The current run's data always
78
+ // wins for any ticket it covers (freshest known state); a ticket only
79
+ // present in the earlier snapshot is carried forward untouched.
80
+ const earlier = loadSnapshotForDate(dateStr, profile, configDir, fsModule);
81
+ const earlierTickets = Array.isArray(earlier?.tickets) ? earlier.tickets : [];
82
+ const currentKeys = new Set(tickets.map(t => t.ticketKey));
83
+ const merged = [...tickets, ...earlierTickets.filter(t => !currentKeys.has(t.ticketKey))];
84
+
85
+ // `date` is the same local dateStr the file is stored under — captured_at
86
+ // stays UTC for precise ordering, but nothing should ever need to infer
87
+ // "which day" a snapshot belongs to by parsing captured_at, since that can
88
+ // disagree with the local-date directory name near midnight.
89
+ const envelope = { captured_at: now.toISOString(), date: dateStr, tickets: merged };
74
90
  fsModule.writeFileSync(filePath, JSON.stringify(envelope, null, 2), 'utf8');
75
91
  }
76
92
 
@@ -73,6 +73,9 @@ export async function pushTriageSnapshot({
73
73
  tickets = tickets.map(t => {
74
74
  const entry = latestByKey.get(t.key);
75
75
  if (!entry) return t;
76
+ if (entry.noCriteria) {
77
+ return { ...t, compliance_coverage: null, compliance_status: 'no-criteria' };
78
+ }
76
79
  return {
77
80
  ...t,
78
81
  compliance_coverage: entry.coverage ?? null,