ticketlens 0.38.28 → 0.38.30

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
@@ -670,6 +670,7 @@ ticketlens switch # Switch between configured profil
670
670
  ticketlens config # Edit the active profile
671
671
  ticketlens config --profile=acme # Edit a specific profile
672
672
  ticketlens config set aiProvider groq # Set default AI provider (groq|openai|anthropic)
673
+ ticketlens config set recallStrictness strict # Tune Recall-capture strictness (loose|balanced|strict)
673
674
  ticketlens profiles # List all configured profiles
674
675
  ticketlens ls # Alias for profiles
675
676
  ticketlens profiles --plain # Tab-separated (scripts / pipes)
@@ -16,7 +16,7 @@ import { runSwitch } from '../skills/jtb/scripts/lib/profile-switcher.mjs';
16
16
  import { runProfilesSetTeam } from '../skills/jtb/scripts/lib/profile-set-team.mjs';
17
17
  import { run as runConfig } from '../skills/jtb/scripts/lib/config-wizard.mjs';
18
18
  import { activateLicense, checkLicense, revalidateIfStale, isLicensed, showUpgradePrompt, readLicense } from '../skills/jtb/scripts/lib/license.mjs';
19
- import { deleteProfile, loadProfiles, saveCredentialKey } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
19
+ import { deleteProfile, loadProfiles, saveCredentialKey, resolveRecallStrictnessTarget, saveProfileRecallStrictness, RECALL_STRICTNESS_LEVELS } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
20
20
  import { run as runCache } from '../skills/jtb/scripts/lib/cache-manager.mjs';
21
21
  import { runDoctor } from '../skills/jtb/scripts/lib/doctor-command.mjs';
22
22
  import {
@@ -226,6 +226,30 @@ switch (command) {
226
226
  break;
227
227
  }
228
228
 
229
+ if (cmdArgs[0] === 'set' && cmdArgs[1] === 'recallStrictness') {
230
+ const s = createStyler({ isTTY: process.stdout.isTTY });
231
+ const value = cmdArgs[2];
232
+ const profileArgRS = cmdArgs.find(a => a.startsWith('--profile='));
233
+ const explicitProfileName = profileArgRS ? profileArgRS.split('=')[1] : undefined;
234
+ const result = resolveRecallStrictnessTarget({ value, explicitProfileName, configDir: DEFAULT_CONFIG_DIR });
235
+
236
+ if (!result.ok) {
237
+ if (result.reason === 'missing-value') {
238
+ process.stderr.write(`${s.red('✖')} Missing value.\n Usage: ticketlens config set recallStrictness <loose|balanced|strict> [--profile=NAME]\n`);
239
+ } else if (result.reason === 'invalid-level') {
240
+ process.stderr.write(`${s.red('✖')} Unknown level "${result.value}". Valid: ${RECALL_STRICTNESS_LEVELS.join(', ')}\n`);
241
+ } else {
242
+ process.stderr.write(`${s.red('✖')} No profile resolved. Pass --profile=NAME or set a default profile first.\n`);
243
+ }
244
+ process.exitCode = 1;
245
+ break;
246
+ }
247
+
248
+ saveProfileRecallStrictness(result.profileName, result.value, DEFAULT_CONFIG_DIR);
249
+ process.stdout.write(` ${s.green('✔')} Recall capture strictness set to ${s.bold(s.cyan(result.value))} for profile "${result.profileName}"\n`);
250
+ break;
251
+ }
252
+
229
253
  const profileArg = cmdArgs.find(a => a.startsWith('--profile='));
230
254
  const profileName = profileArg ? profileArg.split('=')[1] : undefined;
231
255
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !cmdArgs.includes('--no-input');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.28",
3
+ "version": "0.38.30",
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": {
@@ -213,12 +213,22 @@ If the TicketBrief includes a `## Recall` section, those are the user's own save
213
213
 
214
214
  ### When to capture a note
215
215
 
216
+ If the TicketBrief includes a `**Recall capture:**` line, calibrate against the matching level below. No such line (or `balanced`) uses the **Balanced** rule as-is — this is the default and requires no adjustment.
217
+
216
218
  Capture only when **all three** hold:
217
219
 
218
220
  1. **Not already written down** — not in the ticket description or comments, not already in an existing `## Recall` note shown in this brief.
219
221
  2. **Generalizes beyond this one diff** — useful to a future session on this ticket, this project, or a similar bug class. A fact only relevant to the exact change you just made doesn't qualify.
220
222
  3. **Cost real effort to discover** — required debugging, reading multiple files, trial and error, or a decision with a non-obvious rationale. Something obvious from reading the code once doesn't qualify.
221
223
 
224
+ Point 1 and the secret/credential exclusion below are correctness checks, not judgment calls — they hold at every strictness level, no exceptions.
225
+
226
+ **Balanced (default):** the three-part rule above, applied as written.
227
+
228
+ **Loose (`**Recall capture:** loose`):** raise the bar on points 2 and 3 — only capture when the effort was clearly substantial (real debugging or multiple files, not "read one file twice") and the generalization is concrete, not speculative ("useful to a similar bug class" needs a plausible next occurrence, not just theoretical reuse). When genuinely borderline, skip it.
229
+
230
+ **Strict (`**Recall capture:** strict`):** lower the bar on points 2 and 3 — capture when in doubt. A decision with a rationale that took even a moment of back-and-forth to land on, or a fact that plausibly helps a future session even if the connection isn't certain, qualifies. Still never capture something that fails point 1, or that's shaped like a credential/API key — the tool rejects those at save time regardless.
231
+
222
232
  Example that qualifies: an undocumented schema quirk found only by reading raw DDL (e.g. a table has no `name` column, it's on a related table instead). Example that doesn't: a one-off typo fix with no broader lesson.
223
233
 
224
234
  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.
@@ -118,9 +118,17 @@ export function hasRecentCapture(cwd, now = Date.now()) {
118
118
  * tool — for note-add. Missing the MCP path here was a real bug: it made
119
119
  * the Stop hook nudge even after a note was genuinely captured via MCP,
120
120
  * since only the Bash/CLI form was ever recognized.
121
+ *
122
+ * `ticketKey` carries the FIRST matched ticket key's literal text (or
123
+ * `null`), so callers can resolve a profile by ticket-key prefix the same
124
+ * way `resolveConnection()` does — see recall-nudge-stop.mjs. First match,
125
+ * not last: the primary ticket a session is about is normally established
126
+ * early, and picking one deterministic match keeps this function decoupled
127
+ * from profiles.json (it stays a pure transcript reader; profile lookup
128
+ * and its own fallback chain belong entirely to resolveProfile()).
121
129
  */
122
130
  export function scanTranscript(transcriptPath) {
123
- const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false };
131
+ const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false, ticketKey: null };
124
132
  let lines;
125
133
  try {
126
134
  lines = fs.readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
@@ -136,10 +144,22 @@ export function scanTranscript(transcriptPath) {
136
144
  continue;
137
145
  }
138
146
 
139
- // Ticket-key detection stays broad (whole entry, any role) — it's only
140
- // the weaker "did ticket work happen at all" signal, and a rare false
141
- // positive here just means an extra harmless once-per-session check.
142
- if (TICKET_KEY_RE.test(JSON.stringify(entry))) result.sawTicketKey = true;
147
+ // Ticket-key detection stays broad (whole entry, any role) — for
148
+ // sawTicketKey alone, it's only the weaker "did ticket work happen at
149
+ // all" signal, so a rare false positive just means an extra harmless
150
+ // once-per-session check. The captured ticketKey text carries a bit
151
+ // more weight (it also selects which profile's recallStrictness
152
+ // applies, see recall-nudge-stop.mjs), but the ceiling is still just
153
+ // "the wrong local settings value governs one Stop-hook decision" — no
154
+ // credentials or ticket data are read using this key, so a false
155
+ // positive here stays low-consequence, not narrowed further for now.
156
+ if (!result.ticketKey) {
157
+ const match = TICKET_KEY_RE.exec(JSON.stringify(entry));
158
+ if (match) {
159
+ result.sawTicketKey = true;
160
+ result.ticketKey = match[0];
161
+ }
162
+ }
143
163
 
144
164
  if (entry.type !== 'assistant') continue;
145
165
  const blocks = entry.message?.content;
@@ -159,3 +179,21 @@ export function scanTranscript(transcriptPath) {
159
179
 
160
180
  return result;
161
181
  }
182
+
183
+ /**
184
+ * Decides whether the Stop hook should block, calibrated by the active
185
+ * profile's recallStrictness. `strict` deliberately uses the exact same
186
+ * trigger as `balanced` — it does not additionally bypass
187
+ * hasRecentCapture()'s rollover bridge or recall-nudge-stop.mjs's
188
+ * once-per-session cap, both correctness invariants rather than
189
+ * calibration knobs. Bypassing either would reintroduce a real bug
190
+ * (re-nagging after a genuine capture that lands just before a
191
+ * compaction/session_id rollover, or nagging more than once per session).
192
+ * Strict's actual effect on capture volume comes from SKILL.md's lowered
193
+ * in-session capture bar, not from this function.
194
+ */
195
+ export function shouldNag({ sawTicketKey, sawRecallFlag, sawNoteAdd, recallStrictness = 'balanced' }) {
196
+ if (!sawTicketKey || sawNoteAdd) return false;
197
+ if (recallStrictness === 'loose') return sawRecallFlag; // only the broken-promise case
198
+ return true; // balanced and strict: ticket work with no note is enough
199
+ }
@@ -16,9 +16,22 @@
16
16
  * session_id, a blank dedup state, AND a blank transcript file, so a real
17
17
  * earlier capture becomes invisible. The cross-session lastCapture marker
18
18
  * (keyed by cwd, not session_id) is what actually bridges that boundary.
19
+ *
20
+ * Which of the two cases above actually blocks is governed by the active
21
+ * profile's recallStrictness — see recall-nudge-lib.mjs's shouldNag() doc
22
+ * comment for the calibration and why strict doesn't widen this further.
23
+ *
24
+ * resolveProfile() below is given scanTranscript()'s matched ticket key (not
25
+ * null), so it resolves by ticket-key prefix the same way the brief-injection
26
+ * lever (resolveConnection(ticketKey, ...) in profile-resolver.mjs) does —
27
+ * both levers now agree on which profile's recallStrictness applies for a
28
+ * multi-profile user. When no ticket key was seen, or it matches no profile's
29
+ * ticketPrefixes, this falls through to cwd/projectPaths then the default
30
+ * profile, same as before (backlog #12, design spec §6).
19
31
  */
20
32
 
21
- import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt } from './recall-nudge-lib.mjs';
33
+ import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, shouldNag } from './recall-nudge-lib.mjs';
34
+ import { resolveProfile, normalizeRecallStrictness } from '../scripts/lib/profile-resolver.mjs';
22
35
 
23
36
  const input = readStdinJson();
24
37
  const sessionId = input?.session_id;
@@ -27,7 +40,7 @@ const cwd = input?.cwd ?? process.cwd();
27
40
 
28
41
  if (!sessionId || !transcriptPath) process.exit(0);
29
42
 
30
- const { sawTicketKey, sawRecallFlag, sawNoteAdd } = scanTranscript(transcriptPath);
43
+ const { sawTicketKey, sawRecallFlag, sawNoteAdd, ticketKey } = scanTranscript(transcriptPath);
31
44
 
32
45
  // Refreshed on every check, independent of the once-per-session gate below —
33
46
  // a capture that happens AFTER this session already nagged once must still
@@ -37,8 +50,11 @@ if (sawNoteAdd) writeLastCaptureAt(cwd, Date.now());
37
50
  const state = readState(sessionId);
38
51
  if (state.stopChecked) process.exit(0); // already asked once this session — respect the answer
39
52
 
40
- if (!sawTicketKey || sawNoteAdd) {
41
- process.exit(0); // no ticket work, or already captured — nothing to force
53
+ const profile = resolveProfile(ticketKey, { cwd });
54
+ const recallStrictness = normalizeRecallStrictness(profile?.recallStrictness);
55
+
56
+ if (!shouldNag({ sawTicketKey, sawRecallFlag, sawNoteAdd, recallStrictness })) {
57
+ process.exit(0); // nothing this strictness level requires a capture for
42
58
  }
43
59
 
44
60
  if (hasRecentCapture(cwd)) {
@@ -1124,7 +1124,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1124
1124
 
1125
1125
  // Apply --budget pruning on the plain brief before styling (Pro only).
1126
1126
  // When --budget is active, always output plain text (pruning operates on unescaped chars).
1127
- let plainBrief = assembleBrief(cached.ticket, codeRefs, templateSections, recallNotes, recallMoreCount, gaps);
1127
+ let plainBrief = assembleBrief(cached.ticket, codeRefs, templateSections, recallNotes, recallMoreCount, gaps, conn.recallStrictness);
1128
1128
  const budgetArgCached = args.find(a => a.startsWith('--budget='));
1129
1129
  if (budgetArgCached) {
1130
1130
  const budgetN = parseInt(budgetArgCached.split('=')[1], 10);
@@ -1138,7 +1138,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1138
1138
  }
1139
1139
  let brief = (budgetArgCached && licensedFn('pro', configDir))
1140
1140
  ? plainBrief
1141
- : (useStyled ? styleBrief(cached.ticket, codeRefs, { styled: true, templateSections, recallNotes, recallMoreCount, gaps }) : plainBrief);
1141
+ : (useStyled ? styleBrief(cached.ticket, codeRefs, { styled: true, templateSections, recallNotes, recallMoreCount, gaps, recallStrictness: conn.recallStrictness }) : plainBrief);
1142
1142
 
1143
1143
  if (args.includes('--handoff')) {
1144
1144
  const handoffResult = await applyHandoff(cached.ticket, args, opts, configDir, licensedFn, upgradeFn);
@@ -1317,7 +1317,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1317
1317
 
1318
1318
  // Apply --budget pruning on the plain brief before styling (Pro only).
1319
1319
  // When --budget is active, always output plain text (pruning operates on unescaped chars).
1320
- let plainOutput = assembleBrief(ticket, codeRefs, templateSections, recallNotes, recallMoreCount, gaps);
1320
+ let plainOutput = assembleBrief(ticket, codeRefs, templateSections, recallNotes, recallMoreCount, gaps, conn.recallStrictness);
1321
1321
  const budgetArg = args.find(a => a.startsWith('--budget='));
1322
1322
  if (budgetArg) {
1323
1323
  const budgetN = parseInt(budgetArg.split('=')[1], 10);
@@ -1331,7 +1331,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1331
1331
  }
1332
1332
  let output = (budgetArg && licensedFn('pro', configDir))
1333
1333
  ? plainOutput
1334
- : (useStyled ? styleBrief(ticket, codeRefs, { styled: true, templateSections, recallNotes, recallMoreCount, gaps }) : plainOutput);
1334
+ : (useStyled ? styleBrief(ticket, codeRefs, { styled: true, templateSections, recallNotes, recallMoreCount, gaps, recallStrictness: conn.recallStrictness }) : plainOutput);
1335
1335
 
1336
1336
  if (args.includes('--handoff')) {
1337
1337
  const handoffResult = await applyHandoff(ticket, args, opts, configDir, licensedFn, upgradeFn);
@@ -6,7 +6,7 @@ import { formatTable } from './table-formatter.mjs';
6
6
  import { formatSize } from './attachment-downloader.mjs';
7
7
  import { timeAgo, truncate, stripCr, escapeLeadingHeading } from './config.mjs';
8
8
 
9
- export function assembleBrief(ticket, codeRefs = null, templateSections = null, recallNotes = null, recallMoreCount = 0, gaps = null) {
9
+ export function assembleBrief(ticket, codeRefs = null, templateSections = null, recallNotes = null, recallMoreCount = 0, gaps = null, recallStrictness = 'balanced') {
10
10
  const s = templateSections;
11
11
  const sections = [];
12
12
  sections.push(`# ${ticket.key}: ${ticket.summary}`);
@@ -17,6 +17,10 @@ export function assembleBrief(ticket, codeRefs = null, templateSections = null,
17
17
  if (ticket.updated) meta.push(`**Updated:** ${ticket.updated.split('T')[0]}`);
18
18
  sections.push(meta.join(' | '));
19
19
 
20
+ if (recallStrictness !== 'balanced') {
21
+ sections.push(`**Recall capture:** ${recallStrictness}`);
22
+ }
23
+
20
24
  if (ticket.description && (s === null || s.description !== false)) {
21
25
  sections.push(`## Description\n\n${stripCr(ticket.description)}`);
22
26
  }
@@ -937,6 +937,7 @@ export function printConfigHelp({ stream = process.stdout } = {}) {
937
937
  '',
938
938
  ` ${s.bold(s.brand('ticketlens'))} ${s.bold('config')} ${s.dim('[--profile=NAME]')}`,
939
939
  ` ${s.bold(s.brand('ticketlens'))} ${s.bold('config set aiProvider')} ${s.dim('<anthropic|openai|groq>')}`,
940
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('config set recallStrictness')} ${s.dim('<loose|balanced|strict>')}`,
940
941
  '',
941
942
  ` Edit settings for an existing profile using an interactive wizard.`,
942
943
  ` Without ${s.cyan('--profile')}, edits the currently active profile.`,
@@ -951,6 +952,11 @@ export function printConfigHelp({ stream = process.stdout } = {}) {
951
952
  ` ${s.dim('openai')} = GPT-4o mini ${s.dim('(paid)')}`,
952
953
  ` ${s.dim('groq')} = Llama 3.1 ${s.dim('(free tier)')}`,
953
954
  '',
955
+ ` ${s.brand('set recallStrictness')} ${s.dim('<LEVEL>')} Tune Recall-capture strictness`,
956
+ ` ${s.dim('loose')} = capture only strong, clear signals`,
957
+ ` ${s.dim('balanced')} = today's default behavior`,
958
+ ` ${s.dim('strict')} = capture more liberally, when in doubt`,
959
+ '',
954
960
  ` ${s.bold('OPTIONS')}`,
955
961
  '',
956
962
  ` ${s.brand('--profile')}=${s.dim('NAME')} Profile to configure`,
@@ -962,6 +968,7 @@ export function printConfigHelp({ stream = process.stdout } = {}) {
962
968
  ` ${s.dim('$')} ticketlens config --profile=work`,
963
969
  ` ${s.dim('$')} ticketlens config set aiProvider groq`,
964
970
  ` ${s.dim('$')} ticketlens config set aiProvider anthropic`,
971
+ ` ${s.dim('$')} ticketlens config set recallStrictness strict`,
965
972
  '',
966
973
  ];
967
974
  stream.write(lines.join('\n') + '\n');
@@ -100,6 +100,33 @@ export function saveProfile(name, profileData, credData, configDir = DEFAULT_CON
100
100
  invalidateProfilesCache(configDir);
101
101
  }
102
102
 
103
+ export const RECALL_STRICTNESS_LEVELS = ['loose', 'balanced', 'strict'];
104
+ export const DEFAULT_RECALL_STRICTNESS = 'balanced';
105
+
106
+ export function normalizeRecallStrictness(value) {
107
+ return RECALL_STRICTNESS_LEVELS.includes(value) ? value : DEFAULT_RECALL_STRICTNESS;
108
+ }
109
+
110
+ export function saveProfileRecallStrictness(name, level, configDir = DEFAULT_CONFIG_DIR) {
111
+ const config = loadProfiles(configDir) || { profiles: {} };
112
+ if (!config.profiles[name]) throw new Error(`Unknown profile "${name}"`);
113
+ config.profiles[name] = { ...config.profiles[name], recallStrictness: normalizeRecallStrictness(level) };
114
+ writeFileSync(join(configDir, 'profiles.json'), JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
115
+ invalidateProfilesCache(configDir);
116
+ }
117
+
118
+ export function resolveRecallStrictnessTarget({ value, explicitProfileName, configDir = DEFAULT_CONFIG_DIR } = {}) {
119
+ if (!value) return { ok: false, reason: 'missing-value' };
120
+ if (!RECALL_STRICTNESS_LEVELS.includes(value)) return { ok: false, reason: 'invalid-level', value };
121
+
122
+ const profilesConfig = loadProfiles(configDir);
123
+ const targetProfile = explicitProfileName ?? profilesConfig?.default;
124
+ if (!targetProfile || !profilesConfig?.profiles?.[targetProfile]) {
125
+ return { ok: false, reason: 'no-profile' };
126
+ }
127
+ return { ok: true, profileName: targetProfile, value };
128
+ }
129
+
103
130
  // recallTeamId lives in credentials.json (not profiles.json) — it's a
104
131
  // sensitive association (which specific Console team a specific local
105
132
  // profile/folder routes Recall notes to), not the non-secret profile shape
@@ -298,6 +325,7 @@ export function resolveConnection(ticketKey, opts = {}) {
298
325
  staleRule: profile.staleRule ?? null,
299
326
  sortBy: profile.sortBy ?? null,
300
327
  allowPrivateIp: profile.allowPrivateIp || false,
328
+ recallStrictness: normalizeRecallStrictness(profile.recallStrictness),
301
329
  source: 'profile',
302
330
  profileName: profile.name,
303
331
  };
@@ -133,7 +133,7 @@ export function styleRecallResults(digests, opts = {}) {
133
133
  }
134
134
 
135
135
  export function styleBrief(ticket, codeRefs = null, opts = {}) {
136
- const { styled = true, templateSections = null, recallNotes = null, recallMoreCount = 0, gaps = null } = opts;
136
+ const { styled = true, templateSections = null, recallNotes = null, recallMoreCount = 0, gaps = null, recallStrictness = 'balanced' } = opts;
137
137
  const ts = templateSections;
138
138
  const s = createStyler({ forceColor: styled, noColor: !styled });
139
139
 
@@ -154,6 +154,10 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
154
154
  if (ticket.updated) meta.push(`${s.dim('Updated:')} ${ticket.updated.split('T')[0]}`);
155
155
  sections.push(meta.join(s.dim(' · ')));
156
156
 
157
+ if (recallStrictness !== 'balanced') {
158
+ sections.push(`${s.dim('Recall capture:')} ${recallStrictness}`);
159
+ }
160
+
157
161
  // Description
158
162
  if (ticket.description && (ts === null || ts.description !== false)) {
159
163
  sections.push(`${s.bold(s.brand('Description'))}\n${s.dim('─'.repeat(divWidth()))}\n${stripCr(ticket.description)}`);