ticketlens 0.38.27 → 0.38.29

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.27",
3
+ "version": "0.38.29",
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.
@@ -159,3 +159,21 @@ export function scanTranscript(transcriptPath) {
159
159
 
160
160
  return result;
161
161
  }
162
+
163
+ /**
164
+ * Decides whether the Stop hook should block, calibrated by the active
165
+ * profile's recallStrictness. `strict` deliberately uses the exact same
166
+ * trigger as `balanced` — it does not additionally bypass
167
+ * hasRecentCapture()'s rollover bridge or recall-nudge-stop.mjs's
168
+ * once-per-session cap, both correctness invariants rather than
169
+ * calibration knobs. Bypassing either would reintroduce a real bug
170
+ * (re-nagging after a genuine capture that lands just before a
171
+ * compaction/session_id rollover, or nagging more than once per session).
172
+ * Strict's actual effect on capture volume comes from SKILL.md's lowered
173
+ * in-session capture bar, not from this function.
174
+ */
175
+ export function shouldNag({ sawTicketKey, sawRecallFlag, sawNoteAdd, recallStrictness = 'balanced' }) {
176
+ if (!sawTicketKey || sawNoteAdd) return false;
177
+ if (recallStrictness === 'loose') return sawRecallFlag; // only the broken-promise case
178
+ return true; // balanced and strict: ticket work with no note is enough
179
+ }
@@ -16,9 +16,23 @@
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
+ * Known limitation: resolveProfile(null, { cwd }) below has no ticket key to
25
+ * match against, so it falls through to cwd/projectPaths then the default
26
+ * profile — unlike the brief-injection lever (resolveConnection(ticketKey, ...)
27
+ * in profile-resolver.mjs), which resolves by ticket-key prefix when one is
28
+ * available. For a multi-profile user these two levers can genuinely resolve
29
+ * different profiles in the same session, so the recallStrictness setting
30
+ * doesn't reliably reweight both together outside the single-profile case.
31
+ * Not fixed here — see design spec §6 for the follow-up.
19
32
  */
20
33
 
21
- import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt } from './recall-nudge-lib.mjs';
34
+ import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, shouldNag } from './recall-nudge-lib.mjs';
35
+ import { resolveProfile, normalizeRecallStrictness } from '../scripts/lib/profile-resolver.mjs';
22
36
 
23
37
  const input = readStdinJson();
24
38
  const sessionId = input?.session_id;
@@ -37,8 +51,11 @@ if (sawNoteAdd) writeLastCaptureAt(cwd, Date.now());
37
51
  const state = readState(sessionId);
38
52
  if (state.stopChecked) process.exit(0); // already asked once this session — respect the answer
39
53
 
40
- if (!sawTicketKey || sawNoteAdd) {
41
- process.exit(0); // no ticket work, or already captured — nothing to force
54
+ const profile = resolveProfile(null, { cwd });
55
+ const recallStrictness = normalizeRecallStrictness(profile?.recallStrictness);
56
+
57
+ if (!shouldNag({ sawTicketKey, sawRecallFlag, sawNoteAdd, recallStrictness })) {
58
+ process.exit(0); // nothing this strictness level requires a capture for
42
59
  }
43
60
 
44
61
  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);
@@ -3,7 +3,7 @@
3
3
  * Centralised here to avoid triplicating the regex and warning logic.
4
4
  */
5
5
 
6
- export const DEFAULT_API_BASE = 'http://api.ticketlens.test';
6
+ export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
7
7
  export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
8
8
 
9
9
  // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
@@ -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
  }
@@ -39,27 +39,44 @@ export function readCliToken(configDir = DEFAULT_CONFIG_DIR) {
39
39
  // Lets team-invited members get Pro-equivalent CLI access without a personal
40
40
  // TL- key, mirroring what the manager already pays for.
41
41
  //
42
- // Signed with a machine-bound HMAC so `tier`/`syncedAt` can't be hand-edited in
43
- // the file to escalate or extend entitlement. A missing or invalid signature is
44
- // treated as no tier at all (falls back to free) rather than trusted — self-heals
45
- // on the next `tl login`/`tl sync`, both of which re-write and re-sign it. A file
46
- // with no `syncedAt` (written before this grace-period check existed) is rejected
47
- // outright for the same reason, rather than treated as permanently fresh.
42
+ // Signed with a machine-bound HMAC so `tier`/`syncedAt`/`freshMark` can't be
43
+ // hand-edited in the file to escalate or extend entitlement. A missing or
44
+ // invalid signature is treated as no tier at all (falls back to free) rather
45
+ // than trusted — self-heals on the next `tl login`/`tl sync`, both of which
46
+ // re-write and re-sign it. A file with no `syncedAt` (written before this
47
+ // grace-period check existed) is rejected outright for the same reason,
48
+ // rather than treated as permanently fresh.
48
49
  //
49
50
  // TEAM_TIER_GRACE_DAYS bounds how long a synced team-seat tier stays valid without
50
51
  // a fresh login/sync — mirrors license.mjs's offline GRACE_DAYS so a team member
51
52
  // removed server-side can't keep local access indefinitely just by never syncing.
53
+ //
54
+ // `freshMark` is a monotonic high-water mark of the latest wall-clock time this
55
+ // file has ever been read at without a detected rollback. It is ratcheted forward
56
+ // (never backward) on every honest read and is rejected if the clock now reads
57
+ // earlier than it — closing the realistic attack of rolling the OS clock back
58
+ // once, after a team seat is revoked, to keep `syncedAt` looking fresh forever.
59
+ // It does NOT close every variant: an attacker who fakes the clock from the very
60
+ // first read after a real sync, before any honest freshMark is ever recorded past
61
+ // that point, is not caught — that is an inherent limit of any offline,
62
+ // network-free grace period (the same residual gap license.json's GRACE_DAYS has
63
+ // always had), not something this mechanism claims to solve.
52
64
  export function readCliTokenTier(configDir = DEFAULT_CONFIG_DIR) {
53
65
  const p = cliTokenPath(configDir);
54
66
  if (!existsSync(p)) return null;
55
67
  try {
56
68
  const data = JSON.parse(readFileSync(p, 'utf8'));
57
- if (typeof data.tier !== 'string' || typeof data.tierSig !== 'string' || typeof data.syncedAt !== 'string') {
69
+ if (
70
+ typeof data.tier !== 'string' ||
71
+ typeof data.tierSig !== 'string' ||
72
+ typeof data.syncedAt !== 'string' ||
73
+ typeof data.freshMark !== 'string'
74
+ ) {
58
75
  return null;
59
76
  }
60
77
  const secret = readMachineSecret(configDir);
61
78
  if (!secret) return null;
62
- const signed = { tier: data.tier, syncedAt: data.syncedAt };
79
+ const signed = { tier: data.tier, syncedAt: data.syncedAt, freshMark: data.freshMark };
63
80
  if (!verifyHmac(data.tierSig, signed, `${CLI_TOKEN_HMAC_SALT}:${secret}`)) return null;
64
81
 
65
82
  // An unparseable syncedAt gives NaN — rejected on its own line rather than
@@ -70,12 +87,38 @@ export function readCliTokenTier(configDir = DEFAULT_CONFIG_DIR) {
70
87
  const daysSinceSync = (Date.now() - syncedAtMs) / MS_PER_DAY;
71
88
  if (daysSinceSync > TEAM_TIER_GRACE_DAYS) return null;
72
89
 
90
+ const freshMarkMs = new Date(data.freshMark).getTime();
91
+ if (Number.isNaN(freshMarkMs)) return null;
92
+
93
+ const nowMs = Date.now();
94
+ if (nowMs < freshMarkMs) return null;
95
+
96
+ if (nowMs > freshMarkMs) {
97
+ // Best-effort ratchet — wrapped so a disk-write failure (full disk,
98
+ // permissions changed mid-session) can't turn an otherwise-valid read
99
+ // into a null; the mark simply stays where it was for next time.
100
+ try {
101
+ writeFreshMark(p, data, secret, nowMs);
102
+ } catch {
103
+ // ignore — ratcheting forward is an optimization, not a correctness requirement
104
+ }
105
+ }
106
+
73
107
  return data.tier;
74
108
  } catch {
75
109
  return null;
76
110
  }
77
111
  }
78
112
 
113
+ function writeFreshMark(p, data, secret, nowMs) {
114
+ const freshMark = new Date(nowMs).toISOString();
115
+ const signed = { tier: data.tier, syncedAt: data.syncedAt, freshMark };
116
+ const tierSig = signHmac(signed, `${CLI_TOKEN_HMAC_SALT}:${secret}`);
117
+ const payload = { token: data.token, ...signed, tierSig };
118
+ writeFileSync(p, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
119
+ chmodSync(p, 0o600);
120
+ }
121
+
79
122
  export function saveCliToken(token, configDir = DEFAULT_CONFIG_DIR, tier = undefined) {
80
123
  mkdirSync(configDir, { recursive: true });
81
124
  const p = cliTokenPath(configDir);
@@ -83,8 +126,12 @@ export function saveCliToken(token, configDir = DEFAULT_CONFIG_DIR, tier = undef
83
126
  if (tier !== undefined) {
84
127
  const secret = readOrCreateMachineSecret(configDir);
85
128
  const syncedAt = new Date().toISOString();
86
- const tierSig = signHmac({ tier, syncedAt }, `${CLI_TOKEN_HMAC_SALT}:${secret}`);
87
- payload = { token, tier, syncedAt, tierSig };
129
+ // A real login/sync is itself a trusted checkpoint (it round-tripped to the
130
+ // server), so freshMark resets to "now" here rather than only ratcheting —
131
+ // unlike a local read, this is not attacker-reachable without valid credentials.
132
+ const freshMark = syncedAt;
133
+ const tierSig = signHmac({ tier, syncedAt, freshMark }, `${CLI_TOKEN_HMAC_SALT}:${secret}`);
134
+ payload = { token, tier, syncedAt, freshMark, tierSig };
88
135
  }
89
136
  writeFileSync(p, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
90
137
  chmodSync(p, 0o600);
@@ -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)}`);