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 +1 -0
- package/bin/ticketlens.mjs +25 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +10 -0
- package/skills/jtb/hooks/recall-nudge-lib.mjs +18 -0
- package/skills/jtb/hooks/recall-nudge-stop.mjs +20 -3
- package/skills/jtb/scripts/fetch-ticket.mjs +4 -4
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/brief-assembler.mjs +5 -1
- package/skills/jtb/scripts/lib/cli-auth.mjs +57 -10
- package/skills/jtb/scripts/lib/help.mjs +7 -0
- package/skills/jtb/scripts/lib/profile-resolver.mjs +28 -0
- package/skills/jtb/scripts/lib/styled-assembler.mjs +5 -1
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)
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -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
package/skills/jtb/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
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 = '
|
|
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
|
|
43
|
-
// the file to escalate or extend entitlement. A missing or
|
|
44
|
-
// treated as no tier at all (falls back to free) rather
|
|
45
|
-
// on the next `tl login`/`tl sync`, both of which
|
|
46
|
-
// with no `syncedAt` (written before this
|
|
47
|
-
// outright for the same reason,
|
|
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 (
|
|
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
|
-
|
|
87
|
-
|
|
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)}`);
|