ticketlens 0.38.16 → 0.38.18

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
@@ -44,6 +44,7 @@
44
44
  - [Recall](#recall)
45
45
  - [Comment, Transition, Assign, Duplicates, Link, Update & Create](#comment-transition-assign-duplicates-link-update--create)
46
46
  - [Response-Time Stats](#response-time-stats)
47
+ - [Doctor](#doctor)
47
48
  - [Custom Attention Rules](#custom-attention-rules)
48
49
  - [Login](#login)
49
50
  - [License](#license)
@@ -525,6 +526,20 @@ A one-line summary footer is also appended automatically to `ticketlens triage`
525
526
 
526
527
  ---
527
528
 
529
+ ### Doctor
530
+
531
+ ```bash
532
+ ticketlens doctor # Diagnose profile/license/connectivity/cache/queue problems
533
+ ticketlens doctor --fix # Attempt safe automatic fixes (license revalidation, corrupt cache cleanup, queue flush)
534
+ ticketlens doctor --profile=acme # Scope checks to a single profile
535
+ ticketlens doctor --format=json # JSON output for scripting/piping
536
+ ticketlens doctor --format=json | jq '.ok'
537
+ ```
538
+
539
+ Runs five checks — profile configuration, license freshness, tracker connectivity, attachment cache health, and the Recall sync queue — and reports pass/fail with an actionable hint per failure, instead of a raw stack trace. Free tier, fully unrestricted; no license required.
540
+
541
+ ---
542
+
528
543
  ### Custom Attention Rules
529
544
 
530
545
  Add an `attentionRules` array to any profile in `~/.ticketlens/profiles.json` to override how `ticketlens triage` scores specific tickets:
@@ -801,6 +816,12 @@ ticketlens stats --profile=acme # Metrics for a specific profile
801
816
  ticketlens stats --days=14 # Extend lookback window (Pro, max 30)
802
817
  ticketlens stats --format=json # JSON output for scripting
803
818
 
819
+ # ── Doctor ────────────────────────────────────────────────────────────────────
820
+ ticketlens doctor # Diagnose profile/license/connectivity/cache/queue problems
821
+ ticketlens doctor --fix # Attempt safe automatic fixes
822
+ ticketlens doctor --profile=acme # Scope checks to a single profile
823
+ ticketlens doctor --format=json # JSON output for scripting/piping
824
+
804
825
  # ── Compliance ────────────────────────────────────────────────────────────────
805
826
  ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
806
827
  ticketlens ledger # View local compliance audit ledger [Pro]
@@ -18,6 +18,7 @@ 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
19
  import { deleteProfile, loadProfiles, saveCredentialKey } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
20
20
  import { run as runCache } from '../skills/jtb/scripts/lib/cache-manager.mjs';
21
+ import { runDoctor } from '../skills/jtb/scripts/lib/doctor-command.mjs';
21
22
  import {
22
23
  printHelp, printProfiles, printHistoryHelp,
23
24
  printLoginHelp, printLogoutHelp, printSyncHelp,
@@ -26,7 +27,7 @@ import {
26
27
  printInitHelp, printSwitchHelp, printConfigHelp,
27
28
  printReviewHelp, printStandupHelp, printUpdateSkillHelp,
28
29
  printComplianceHelp, printLedgerHelp, printPrHelp, printInstallHooksHelp,
29
- printCollisionsHelp, printStatsHelp,
30
+ printCollisionsHelp, printStatsHelp, printDoctorHelp,
30
31
  printCloudKeysHelp,
31
32
  printNoteHelp, printRecallHelp, printMcpHelp,
32
33
  printCommentHelp, printTransitionHelp, printAssignHelp, printDuplicatesHelp, printLinkHelp, printUpdateHelp, printCreateHelp,
@@ -175,6 +176,17 @@ switch (command) {
175
176
  break;
176
177
  }
177
178
 
179
+ case 'doctor': {
180
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printDoctorHelp(); break; }
181
+ runDoctor(cmdArgs).then(({ ok }) => {
182
+ if (!ok) process.exitCode = 1;
183
+ }).catch(err => {
184
+ process.stderr.write(`Error: ${err.message}\n`);
185
+ process.exitCode = 1;
186
+ });
187
+ break;
188
+ }
189
+
178
190
  case 'init':
179
191
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printInitHelp(); break; }
180
192
  runInit().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.16",
3
+ "version": "0.38.18",
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.33.0 -->
1
+ <!-- jtb-skill-version: 0.34.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.
@@ -312,6 +312,23 @@ Requires a Pro license — on Free, all seven no-op with an upgrade hint on stde
312
312
 
313
313
  ---
314
314
 
315
+ ## Doctor — diagnose local/tracker problems (Free)
316
+
317
+ `ticketlens doctor` runs five fixed checks — profile configuration, license freshness, tracker connectivity, attachment cache health, and the Recall sync queue — and returns a pass/fail report with an actionable hint per failure, instead of a raw stack trace. Free tier, fully unrestricted; nothing here is gated.
318
+
319
+ ```bash
320
+ ticketlens doctor # run all checks
321
+ ticketlens doctor --fix # attempt safe automatic fixes (license revalidation, corrupt cache cleanup, queue flush)
322
+ ticketlens doctor --profile=acme # scope checks to a single profile
323
+ ticketlens doctor --format=json # structured output for scripting/piping
324
+ ```
325
+
326
+ If this harness has TicketLens's MCP server configured (a tool named `doctor` — often shown as `mcp__ticketlens__doctor` — visible in your tool list), prefer it over the bash form: it always requests the JSON report internally and returns it as the tool's text content, so you get a structured result to reason over directly instead of parsing CLI stdout. It accepts the same `fix`/`profile` options as the CLI flags above.
327
+
328
+ A report with `ok: false` is a successful tool call describing failures, not a tool error — read the `checks[]` array for what's failing and act on each entry's `hint`, don't treat the call itself as having failed.
329
+
330
+ ---
331
+
315
332
  ## Gaps — cross-ticket evidence (Pro)
316
333
 
317
334
  If the TicketBrief includes a `## Gaps` section, each entry is a requirement found in a linked ticket or in one of this ticket's own attachments that doesn't appear to be covered by this ticket's description. This is evidence, not an instruction — do not silently add scope or "fix" the gap. Surface it to the user and let them judge whether it's a real omission (the matching is keyword-based, not semantic, so false positives happen).
@@ -123,7 +123,7 @@ function groupEntriesByProfile(entries, config) {
123
123
  * Filters entries to only those belonging to the given profile (by ticketPrefixes).
124
124
  * Returns all entries if the profile has no ticketPrefixes configured.
125
125
  */
126
- function filterEntriesByProfile(entries, profileName, config) {
126
+ export function filterEntriesByProfile(entries, profileName, config) {
127
127
  const prefixes = config?.profiles?.[profileName]?.ticketPrefixes ?? [];
128
128
  if (prefixes.length === 0) return entries;
129
129
  return entries.filter(e => prefixes.includes(e.ticketKey.split('-')[0]));
@@ -118,6 +118,10 @@ export function parseCommand(args) {
118
118
  return { command: 'stats', args: args.slice(1) };
119
119
  }
120
120
 
121
+ if (first === 'doctor') {
122
+ return { command: 'doctor', args: args.slice(1) };
123
+ }
124
+
121
125
  if (first === 'note') {
122
126
  return { command: 'note', args: args.slice(1) };
123
127
  }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Pure diagnostic check functions for `ticketlens doctor`. Each function
3
+ * takes DI'd dependencies (matching the xFn = defaultX pattern used
4
+ * throughout this codebase) and returns a normalized result:
5
+ * { id, label, ok, message, hint, fixable }
6
+ * No stdout/stdin, no arg parsing — independently unit-testable in
7
+ * isolation from CLI/MCP concerns.
8
+ *
9
+ * checkCacheHealth returns a 7th, internal-only field beyond the six
10
+ * above — `corruptEntries` — consumed only by doctor-command.mjs's
11
+ * `--fix` step to know which local files to delete. It is stripped
12
+ * before any public (CLI plain/JSON or MCP) output.
13
+ */
14
+
15
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
16
+ import { resolveProfile, loadCredentials, loadProfiles } from './profile-resolver.mjs';
17
+ import { checkLicense, GRACE_DAYS } from './license.mjs';
18
+ import { resolveAdapter } from './resolve-adapter.mjs';
19
+ import { classifyError } from './error-classifier.mjs';
20
+ import { testConnections } from './connection-tester.mjs';
21
+ import { formatSize } from './attachment-downloader.mjs';
22
+ import { getCacheEntries, filterEntriesByProfile } from './cache-manager.mjs';
23
+ import { readQueue } from './recall-queue.mjs';
24
+
25
+ const NOOP_STREAM = { write: () => true };
26
+
27
+ export function checkProfileConfig({
28
+ configDir = DEFAULT_CONFIG_DIR,
29
+ profileName = null,
30
+ cwd = process.cwd(),
31
+ resolveProfileFn = resolveProfile,
32
+ loadCredentialsFn = loadCredentials,
33
+ } = {}) {
34
+ const profile = resolveProfileFn(null, { profileName, configDir, cwd });
35
+
36
+ if (!profile) {
37
+ return {
38
+ id: 'profile-config', label: 'Profile configuration', ok: false,
39
+ message: profileName ? `Profile "${profileName}" not found.` : 'No profile configured.',
40
+ hint: profileName ? 'Run `ticketlens profiles` to see available profiles.' : 'Run `ticketlens init` to set up a profile.',
41
+ fixable: false,
42
+ };
43
+ }
44
+
45
+ if (!profile.baseUrl) {
46
+ return {
47
+ id: 'profile-config', label: 'Profile configuration', ok: false,
48
+ message: `Profile "${profile.name}" has no baseUrl configured.`,
49
+ hint: `Run \`ticketlens config --profile=${profile.name}\` to fix it.`,
50
+ fixable: false,
51
+ };
52
+ }
53
+
54
+ const creds = loadCredentialsFn(configDir)[profile.name] || {};
55
+ if (!creds.apiToken && !creds.pat) {
56
+ return {
57
+ id: 'profile-config', label: 'Profile configuration', ok: false,
58
+ message: `Profile "${profile.name}" has no credentials stored.`,
59
+ hint: `Run \`ticketlens config --profile=${profile.name}\` to add an API token or PAT.`,
60
+ fixable: false,
61
+ };
62
+ }
63
+
64
+ return {
65
+ id: 'profile-config', label: 'Profile configuration', ok: true,
66
+ message: `Profile "${profile.name}" resolves with a baseUrl and stored credentials.`,
67
+ hint: null, fixable: false,
68
+ };
69
+ }
70
+
71
+ export function checkLicenseFreshness({
72
+ configDir = DEFAULT_CONFIG_DIR,
73
+ checkLicenseFn = checkLicense,
74
+ } = {}) {
75
+ const status = checkLicenseFn(configDir);
76
+
77
+ if (!status.key) {
78
+ return {
79
+ id: 'license-freshness', label: 'License freshness', ok: true,
80
+ message: 'Free tier — no license to validate.', hint: null, fixable: false,
81
+ };
82
+ }
83
+
84
+ if (status.expired) {
85
+ return {
86
+ id: 'license-freshness', label: 'License freshness', ok: false,
87
+ message: 'License expired.',
88
+ hint: 'Run `ticketlens activate <KEY>` to renew.', fixable: true,
89
+ };
90
+ }
91
+
92
+ const daysSinceVal = status.validatedAt
93
+ ? (Date.now() - new Date(status.validatedAt).getTime()) / 86400000
94
+ : Infinity;
95
+
96
+ if (daysSinceVal > GRACE_DAYS) {
97
+ return {
98
+ id: 'license-freshness', label: 'License freshness', ok: false,
99
+ message: `Not revalidated in over ${GRACE_DAYS} days.`,
100
+ hint: 'Run `ticketlens doctor --fix` to revalidate now.', fixable: true,
101
+ };
102
+ }
103
+
104
+ return {
105
+ id: 'license-freshness', label: 'License freshness', ok: true,
106
+ message: `${status.tier} license active, last validated ${Math.floor(daysSinceVal)} day(s) ago.`,
107
+ hint: null, fixable: false,
108
+ };
109
+ }
110
+
111
+ export async function checkConnectivity({
112
+ configDir = DEFAULT_CONFIG_DIR,
113
+ profileName = null,
114
+ cwd = process.cwd(),
115
+ resolveProfileFn = resolveProfile,
116
+ loadCredentialsFn = loadCredentials,
117
+ resolveAdapterFn = resolveAdapter,
118
+ classifyErrorFn = classifyError,
119
+ testConnectionsFn = testConnections,
120
+ } = {}) {
121
+ if (profileName) {
122
+ const profile = resolveProfileFn(null, { profileName, configDir, cwd });
123
+ if (!profile) {
124
+ return {
125
+ id: 'connectivity', label: 'Tracker connectivity', ok: false,
126
+ message: `Profile "${profileName}" not found.`, hint: null, fixable: false,
127
+ };
128
+ }
129
+ const creds = loadCredentialsFn(configDir)[profile.name] || {};
130
+ const conn = {
131
+ baseUrl: profile.baseUrl, auth: profile.auth, email: profile.email,
132
+ apiToken: creds.apiToken, pat: creds.pat, allowPrivateIp: profile.allowPrivateIp,
133
+ };
134
+ try {
135
+ await resolveAdapterFn(conn).fetchCurrentUser();
136
+ return {
137
+ id: 'connectivity', label: 'Tracker connectivity', ok: true,
138
+ message: `Profile "${profile.name}" connected successfully.`, hint: null, fixable: false,
139
+ };
140
+ } catch (err) {
141
+ const classified = classifyErrorFn(err, { baseUrl: conn.baseUrl, profileName: profile.name });
142
+ return {
143
+ id: 'connectivity', label: 'Tracker connectivity', ok: false,
144
+ message: classified.message, hint: classified.hint, fixable: false,
145
+ };
146
+ }
147
+ }
148
+
149
+ const { results, failedCount } = await testConnectionsFn({ configDir, stream: NOOP_STREAM, resolveAdapterFn });
150
+ if (results.length === 0) {
151
+ return {
152
+ id: 'connectivity', label: 'Tracker connectivity', ok: true,
153
+ message: 'No profiles configured — nothing to test.', hint: null, fixable: false,
154
+ };
155
+ }
156
+ const summary = results.map(r => r.ok ? `${r.name}: ok` : `${r.name}: ${r.error}`).join('; ');
157
+ return {
158
+ id: 'connectivity', label: 'Tracker connectivity',
159
+ ok: failedCount === 0,
160
+ message: failedCount === 0
161
+ ? `All ${results.length} profile(s) connected successfully.`
162
+ : `${failedCount}/${results.length} profile(s) failed to connect.`,
163
+ hint: failedCount === 0 ? null : summary,
164
+ fixable: false,
165
+ };
166
+ }
167
+
168
+ export function checkCacheHealth({
169
+ configDir = DEFAULT_CONFIG_DIR,
170
+ profileName = null,
171
+ getCacheEntriesFn = getCacheEntries,
172
+ loadProfilesFn = loadProfiles,
173
+ filterEntriesByProfileFn = filterEntriesByProfile,
174
+ } = {}) {
175
+ let entries = getCacheEntriesFn(configDir);
176
+ if (profileName) {
177
+ entries = filterEntriesByProfileFn(entries, profileName, loadProfilesFn(configDir));
178
+ }
179
+
180
+ const corrupt = entries.filter(e => e.size === 0);
181
+ if (corrupt.length === 0) {
182
+ const totalSize = entries.reduce((sum, e) => sum + e.size, 0);
183
+ return {
184
+ id: 'cache-health', label: 'Attachment cache', ok: true,
185
+ message: entries.length === 0
186
+ ? 'No cached files.'
187
+ : `${entries.length} cached file(s), ${formatSize(totalSize)}, none corrupt.`,
188
+ hint: null, fixable: false, corruptEntries: [],
189
+ };
190
+ }
191
+
192
+ return {
193
+ id: 'cache-health', label: 'Attachment cache', ok: false,
194
+ message: `${corrupt.length} corrupt (0-byte) cached file(s) found.`,
195
+ hint: 'Run `ticketlens doctor --fix` to remove them.',
196
+ fixable: true, corruptEntries: corrupt,
197
+ };
198
+ }
199
+
200
+ export function checkRecallQueue({
201
+ configDir = DEFAULT_CONFIG_DIR,
202
+ readQueueFn = readQueue,
203
+ } = {}) {
204
+ const entries = readQueueFn(configDir);
205
+ if (entries.length === 0) {
206
+ return {
207
+ id: 'recall-queue', label: 'Recall sync queue', ok: true,
208
+ message: 'No notes pending sync.', hint: null, fixable: false,
209
+ };
210
+ }
211
+ return {
212
+ id: 'recall-queue', label: 'Recall sync queue', ok: false,
213
+ message: `${entries.length} note(s) pending sync.`,
214
+ hint: 'Run `ticketlens doctor --fix` to retry now, or `ticketlens recall sync`.',
215
+ fixable: true,
216
+ };
217
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Implements `tl doctor`. Runs a fixed set of diagnostic checks
3
+ * (doctor-checks.mjs) and reports pass/fail with hints. Free tier,
4
+ * fully unrestricted.
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
9
+ import { handleUnknownFlags } from './arg-validator.mjs';
10
+ import { createStyler } from './ansi.mjs';
11
+ import {
12
+ checkProfileConfig, checkLicenseFreshness, checkConnectivity,
13
+ checkCacheHealth, checkRecallQueue,
14
+ } from './doctor-checks.mjs';
15
+ import { revalidateLicense } from './license.mjs';
16
+ import { flushQueue } from './recall-queue.mjs';
17
+ import { readCliToken } from './cli-auth.mjs';
18
+
19
+ const KNOWN_FLAGS = ['--format=', '--fix', '--profile=', '--help', '-h'];
20
+
21
+ function renderPlain(checks, { fixed, skipped, stream }) {
22
+ const s = createStyler({ isTTY: stream.isTTY });
23
+ stream.write('\n');
24
+ for (const check of checks) {
25
+ const icon = check.ok ? s.green('✔') : s.red('✖');
26
+ stream.write(` ${icon} ${check.label}: ${check.message}\n`);
27
+ if (!check.ok && check.hint) stream.write(` ${s.dim(check.hint)}\n`);
28
+ }
29
+ if (fixed.length > 0) {
30
+ stream.write(`\n ${s.green('Fixed:')} ${fixed.join(', ')}\n`);
31
+ }
32
+ if (skipped.length > 0) {
33
+ stream.write(`\n ${s.yellow('Skipped:')}\n`);
34
+ for (const sk of skipped) stream.write(` ${sk.id}: ${sk.reason}\n`);
35
+ }
36
+ stream.write('\n');
37
+ }
38
+
39
+ async function applyFixes(rawResults, {
40
+ configDir, profileName, format, stream,
41
+ revalidateLicenseFn, checkLicenseFreshnessFn,
42
+ unlinkFn, checkCacheHealthFn,
43
+ flushQueueFn, checkRecallQueueFn, readCliTokenFn,
44
+ }) {
45
+ const fixed = [];
46
+ const skipped = [];
47
+ const byId = Object.fromEntries(rawResults.map(r => [r.id, r]));
48
+
49
+ if (byId['license-freshness'] && !byId['license-freshness'].ok && byId['license-freshness'].fixable) {
50
+ if (format === 'plain') stream.write('Revalidating license...\n');
51
+ await revalidateLicenseFn({ configDir });
52
+ const recheck = checkLicenseFreshnessFn({ configDir });
53
+ byId['license-freshness'] = recheck;
54
+ if (recheck.ok) fixed.push('license-freshness');
55
+ }
56
+
57
+ if (byId['cache-health'] && !byId['cache-health'].ok && byId['cache-health'].fixable) {
58
+ if (format === 'plain') stream.write('Clearing corrupt cache entries...\n');
59
+ for (const entry of byId['cache-health'].corruptEntries ?? []) {
60
+ try { unlinkFn(entry.localPath); } catch { /* already gone */ }
61
+ }
62
+ const recheck = checkCacheHealthFn({ configDir, profileName });
63
+ byId['cache-health'] = recheck;
64
+ if (recheck.ok) fixed.push('cache-health');
65
+ }
66
+
67
+ if (byId['recall-queue'] && !byId['recall-queue'].ok && byId['recall-queue'].fixable) {
68
+ const cliToken = readCliTokenFn(configDir);
69
+ if (!cliToken) {
70
+ skipped.push({ id: 'recall-queue', reason: 'Not logged in — run `ticketlens login` first.' });
71
+ } else {
72
+ if (format === 'plain') stream.write('Flushing recall queue...\n');
73
+ await flushQueueFn({ cliToken, configDir });
74
+ const recheck = checkRecallQueueFn({ configDir });
75
+ byId['recall-queue'] = recheck;
76
+ if (recheck.ok) fixed.push('recall-queue');
77
+ }
78
+ }
79
+
80
+ return { results: Object.values(byId), fixed, skipped };
81
+ }
82
+
83
+ export async function runDoctor(args, {
84
+ configDir = DEFAULT_CONFIG_DIR,
85
+ stream = process.stderr,
86
+ out = process.stdout,
87
+ cwd = process.cwd(),
88
+ checkProfileConfigFn = checkProfileConfig,
89
+ checkLicenseFreshnessFn = checkLicenseFreshness,
90
+ checkConnectivityFn = checkConnectivity,
91
+ checkCacheHealthFn = checkCacheHealth,
92
+ checkRecallQueueFn = checkRecallQueue,
93
+ revalidateLicenseFn = revalidateLicense,
94
+ unlinkFn = (p) => fs.unlinkSync(p),
95
+ flushQueueFn = flushQueue,
96
+ readCliTokenFn = readCliToken,
97
+ } = {}) {
98
+ const validated = await handleUnknownFlags(args, KNOWN_FLAGS, { stream });
99
+ if (validated === null) return { ok: false };
100
+
101
+ const formatArg = validated.find(a => a.startsWith('--format='));
102
+ const format = formatArg ? formatArg.split('=')[1] : 'plain';
103
+ if (format !== 'plain' && format !== 'json') {
104
+ stream.write(`Error: --format must be plain or json, got: ${format}\n`);
105
+ return { ok: false };
106
+ }
107
+
108
+ const profileArg = validated.find(a => a.startsWith('--profile='));
109
+ const profileName = profileArg ? profileArg.split('=')[1] : null;
110
+ const shouldFix = validated.includes('--fix');
111
+
112
+ const rawResults = [
113
+ checkProfileConfigFn({ configDir, profileName, cwd }),
114
+ checkLicenseFreshnessFn({ configDir }),
115
+ await checkConnectivityFn({ configDir, profileName, cwd }),
116
+ checkCacheHealthFn({ configDir, profileName }),
117
+ checkRecallQueueFn({ configDir }),
118
+ ];
119
+
120
+ let fixed = [];
121
+ let skipped = [];
122
+ let finalResults = rawResults;
123
+ if (shouldFix) {
124
+ const applied = await applyFixes(rawResults, {
125
+ configDir, profileName, format, stream,
126
+ revalidateLicenseFn, checkLicenseFreshnessFn,
127
+ unlinkFn, checkCacheHealthFn,
128
+ flushQueueFn, checkRecallQueueFn, readCliTokenFn,
129
+ });
130
+ finalResults = applied.results;
131
+ fixed = applied.fixed;
132
+ skipped = applied.skipped;
133
+ }
134
+
135
+ const checks = finalResults.map(({ id, label, ok, message, hint, fixable }) => ({ id, label, ok, message, hint, fixable }));
136
+ const ok = checks.every(c => c.ok);
137
+
138
+ if (format === 'json') {
139
+ out.write(JSON.stringify({ schemaVersion: 1, ok, checks, fixed, skipped }, null, 2) + '\n');
140
+ return { ok };
141
+ }
142
+
143
+ renderPlain(checks, { fixed, skipped, stream: out });
144
+ return { ok };
145
+ }
@@ -48,6 +48,7 @@ export function printHelp({ stream = process.stdout } = {}) {
48
48
  ` ${s.brand('ticketlens')} ledger ${s.dim('[--format=json|csv]')} Export your signed usage ledger ${s.dim('[Pro]')}`,
49
49
  ` ${s.brand('ticketlens')} history ${s.dim('<TICKET-KEY>')} Urgency timeline for a ticket ${s.dim('[Pro]')}`,
50
50
  ` ${s.brand('ticketlens')} stats ${s.dim('[options]')} Personal response-time metrics from local history`,
51
+ ` ${s.brand('ticketlens')} doctor ${s.dim('[--fix] [options]')} Diagnose profile/license/connectivity/cache/queue problems`,
51
52
  ` ${s.brand('ticketlens')} note add ${s.dim('--title=... [--ticket=KEY]')} Save a Recall note ${s.dim('[Pro]')}`,
52
53
  ` ${s.brand('ticketlens')} note delete ${s.dim('--id=... [--ticket=KEY]')} Remove a note from your local vault ${s.dim('[Pro]')}`,
53
54
  ` ${s.brand('ticketlens')} recall ${s.dim('<query|TICKET-KEY>')} Search your saved Recall notes ${s.dim('[Pro]')}`,
@@ -1225,6 +1226,35 @@ export function printCollisionsHelp({ stream = process.stdout } = {}) {
1225
1226
  stream.write(lines.join('\n') + '\n');
1226
1227
  }
1227
1228
 
1229
+ export function printDoctorHelp({ stream = process.stdout } = {}) {
1230
+ const s = createStyler({ isTTY: stream.isTTY });
1231
+ const lines = [
1232
+ '',
1233
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('doctor')} ${s.dim('[--fix] [--format=plain|json] [--profile=NAME]')}`,
1234
+ '',
1235
+ ` Diagnose common TicketLens problems: profile configuration, license`,
1236
+ ` freshness, tracker connectivity, attachment cache health, and the Recall`,
1237
+ ` sync queue. Free tier, fully unrestricted.`,
1238
+ '',
1239
+ ` ${s.bold('OPTIONS')}`,
1240
+ '',
1241
+ ` ${s.brand('--fix')} Attempt safe, non-destructive repairs for failing checks`,
1242
+ ` ${s.brand('--format')}=${s.dim('plain')} Human-readable output ${s.dim('(default)')}`,
1243
+ ` ${s.brand('--format')}=${s.dim('json')} JSON output for scripting/piping`,
1244
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Scope profile/connectivity/cache checks to one profile`,
1245
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
1246
+ '',
1247
+ ` ${s.bold('EXAMPLES')}`,
1248
+ '',
1249
+ ` ${s.dim('$')} ticketlens doctor`,
1250
+ ` ${s.dim('$')} ticketlens doctor --fix`,
1251
+ ` ${s.dim('$')} ticketlens doctor --profile=work`,
1252
+ ` ${s.dim('$')} ticketlens doctor --format=json`,
1253
+ '',
1254
+ ];
1255
+ stream.write(lines.join('\n') + '\n');
1256
+ }
1257
+
1228
1258
  export function printStatsHelp({ stream = process.stdout } = {}) {
1229
1259
  const s = createStyler({ isTTY: stream.isTTY });
1230
1260
  const lines = [
@@ -14,7 +14,7 @@ export const LICENSE_TIERS = { free: 0, pro: 1, team: 2 };
14
14
  const LICENSE_FILE = 'license.json';
15
15
  const LICENSE_SECRET_FILE = 'license-hmac-secret.json';
16
16
  const REVALIDATION_DAYS = 7; // attempt background revalidation after this many days
17
- const GRACE_DAYS = 30; // treat license as invalid if not revalidated within this window
17
+ export const GRACE_DAYS = 30; // treat license as invalid if not revalidated within this window
18
18
  const MS_PER_DAY = 86400000;
19
19
  const upgradeUrl = () => `${siteBase()}/#pricing`;
20
20
 
@@ -21,6 +21,7 @@
21
21
 
22
22
  import readline from 'node:readline';
23
23
  import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
24
+ import { runDoctor } from './doctor-command.mjs';
24
25
  import { runNoteAdd } from './note-command.mjs';
25
26
  import { runRecall } from './recall-command.mjs';
26
27
  import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink, runTicketUpdate, runTicketCreate } from './ticket-command.mjs';
@@ -28,6 +29,17 @@ import { runTicketComment, runTicketTransitionList, runTicketTransition, runTick
28
29
  const PROTOCOL_VERSION = '2025-11-25';
29
30
 
30
31
  const TOOLS = [
32
+ {
33
+ name: 'doctor',
34
+ description: 'Diagnose common TicketLens problems: profile configuration, license freshness, tracker connectivity, attachment cache health, and the Recall sync queue. Always returns structured JSON. Free tier, fully unrestricted — including fix.',
35
+ inputSchema: {
36
+ type: 'object',
37
+ properties: {
38
+ fix: { type: 'boolean', description: 'Attempt safe, non-destructive repairs for failing checks.' },
39
+ profile: { type: 'string', description: 'Scope profile/connectivity/cache checks to one profile.' },
40
+ },
41
+ },
42
+ },
31
43
  {
32
44
  name: 'recall_add',
33
45
  description: 'Save a Recall note — a gotcha, root cause, or non-obvious decision learned this session. Requires a TicketLens Pro license.',
@@ -167,6 +179,25 @@ function capturingStream() {
167
179
  };
168
180
  }
169
181
 
182
+ function buildDoctorArgs({ fix, profile }) {
183
+ const args = ['--format=json'];
184
+ if (fix === true) args.push('--fix');
185
+ if (profile) args.push(`--profile=${profile}`);
186
+ return args;
187
+ }
188
+
189
+ async function callDoctor(args, { configDir, runDoctorFn }) {
190
+ const capture = capturingStream();
191
+ // runDoctor writes its final report to `out` (stdout by default) and only
192
+ // uses `stream` for --fix progress chatter — since this call always forces
193
+ // --format=json (see buildDoctorArgs), the report is what we need here.
194
+ // Both must be captured, not left to default: an uncaptured `out` would
195
+ // write the JSON report straight to this process's real stdout, which is
196
+ // the MCP JSON-RPC channel itself.
197
+ await runDoctorFn(buildDoctorArgs(args), { configDir, stream: capture, out: capture });
198
+ return { content: [{ type: 'text', text: capture.text }] };
199
+ }
200
+
170
201
  /**
171
202
  * Builds runNoteAdd's cmdArgs array. Each `--flag=value` MUST stay a single,
172
203
  * discrete array element — runNoteAdd's parseFlag matches per-element via
@@ -370,6 +401,7 @@ async function callTicketCreate(args, { configDir, runTicketCreateFn }) {
370
401
 
371
402
  async function handleToolsCall(params, deps) {
372
403
  const { name, arguments: args = {} } = params ?? {};
404
+ if (name === 'doctor') return callDoctor(args, deps);
373
405
  if (name === 'recall_add') return callRecallAdd(args, deps);
374
406
  if (name === 'recall_search') return callRecallSearch(args, deps);
375
407
  if (name === 'ticket_comment') return callTicketComment(args, deps);
@@ -382,7 +414,7 @@ async function handleToolsCall(params, deps) {
382
414
  return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
383
415
  }
384
416
 
385
- async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
417
+ async function handleMessage(raw, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
386
418
  let msg;
387
419
  try {
388
420
  msg = JSON.parse(raw);
@@ -412,7 +444,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTic
412
444
 
413
445
  if (method === 'tools/call') {
414
446
  try {
415
- const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
447
+ const result = await handleToolsCall(params, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
416
448
  return jsonRpcResult(id, result);
417
449
  } catch (err) {
418
450
  return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
@@ -433,6 +465,7 @@ export function runMcpServer({
433
465
  configDir = DEFAULT_CONFIG_DIR,
434
466
  stdin = process.stdin,
435
467
  stdout = process.stdout,
468
+ runDoctorFn = runDoctor,
436
469
  runNoteAddFn = runNoteAdd,
437
470
  runRecallFn = runRecall,
438
471
  runTicketCommentFn = runTicketComment,
@@ -463,7 +496,7 @@ export function runMcpServer({
463
496
  // never resolving (a dropped rejection isn't a resolution) — the
464
497
  // server would hang on shutdown instead of exiting.
465
498
  queue = queue.then(async () => {
466
- const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
499
+ const response = await handleMessage(line, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
467
500
  if (response) stdout.write(response);
468
501
  }).catch(() => {});
469
502
  });
@@ -13,7 +13,7 @@ const GIT_SHA_RE = /^[0-9a-f]{7,64}$/i;
13
13
  const TICKET_KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/;
14
14
  const GIT_REFERENCE_WORD_RE = /\b(commit|sha\d*|revision|rev|digest|checksum|md5(sum)?|hash|fingerprint)\b/i;
15
15
  const HASH_LABEL_PREFIX_RE = /^[a-z0-9]+:/i;
16
- const EDGE_PUNCTUATION_RE = /^[`'"(),.]+|[`'"(),.]+$/g;
16
+ const EDGE_PUNCTUATION_RE = /^[`'"(),.:]+|[`'"(),.:]+$/g;
17
17
  const MIN_RANDOM_TOKEN_LENGTH = 20;
18
18
  // Hex-alphabet strings (16 symbols) top out near 4.0 bits/char by definition, so a
19
19
  // threshold of 4.0 makes it nearly impossible to ever flag a hex-shaped secret.
@@ -37,6 +37,32 @@ const HARD_REJECT_PATTERNS = [
37
37
 
38
38
  const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
39
39
 
40
+ // A letters-only token ending in a recognized source-file extension reads as
41
+ // high-entropy the same way a real secret does — a class name doubling as its
42
+ // filename is the common case, but a deliberately-renamed or accidentally
43
+ // letters-only secret (no digits, so the hard-reject patterns above don't
44
+ // apply either) would match this shape too and MUST NOT be silently waved
45
+ // through: security review confirmed a full-exemption version of this check
46
+ // was a deterministic bypass (append ".php" to any 20+ char letters-only
47
+ // secret and it passed clean). So this only ever downgrades a match to a
48
+ // warning, never removes it from the reject reasons — see the two
49
+ // independent checks against looksRandomCandidates in scanForSecrets below,
50
+ // not a shortcut inside looksRandom itself. A bare identifier with no
51
+ // extension (a method name, not a filename) isn't covered by this at all —
52
+ // that shape is indistinguishable from a base64 secret fragment either way.
53
+ const CODE_FILENAME_RE = /^[A-Za-z]+\.(php|m?js|tsx?|jsx|py|rb|java|go|rs|vue|s?css|md|json|ya?ml|sh)$/i;
54
+
55
+ function looksLikeCodeFilename(rawToken) {
56
+ const stripped = stripEdgePunctuation(rawToken);
57
+ const match = stripped.match(CODE_FILENAME_RE);
58
+ // Requiring an internal case switch in the stem (the same signal used to
59
+ // detect base64 content elsewhere in this file) means a genuinely random
60
+ // single-case letter run plus a fake extension gets no special treatment
61
+ // at all — only tokens that already look like a real PascalCase/camelCase
62
+ // identifier reach the softer warning path below.
63
+ return match !== null && hasInternalCaseSwitch(match[0]);
64
+ }
65
+
40
66
  function shannonEntropy(token) {
41
67
  const counts = new Map();
42
68
  for (const ch of token) counts.set(ch, (counts.get(ch) ?? 0) + 1);
@@ -183,8 +209,19 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
183
209
  const reasons = [];
184
210
  const warnings = [];
185
211
 
186
- const tokens = combined.split(/\s+/).filter(Boolean);
187
- const candidates = [...tokens, ...joinedChunkRuns(tokens)];
212
+ // Each field is tokenized on its own, and joinedChunkRuns runs separately
213
+ // per field, so a trailing tag word can never glue onto the next tag or
214
+ // onto the body's first word (see Trigger 3 in
215
+ // recall-secret-scanner-false-positives.md — tags echoing a phrase already
216
+ // repeated in the body were false-positiving via exactly this cross-field
217
+ // join). The hard-reject pass below intentionally keeps using the flat,
218
+ // cross-field `tokens`/`combined`/`despacedCombined`: those patterns match
219
+ // an exact literal prefix (AKIA, sk-, ghp_, eyJ, -----BEGIN), so joining
220
+ // across a field boundary can't turn them into a false positive the way
221
+ // entropy can.
222
+ const fieldTokenGroups = [title, ...tags, body].map(field => field.split(/\s+/).filter(Boolean));
223
+ const tokens = fieldTokenGroups.flat();
224
+ const candidates = [...tokens, ...fieldTokenGroups.flatMap(group => joinedChunkRuns(group))];
188
225
 
189
226
  // A known secret shape (AWS key, API key prefix, JWT, PEM block...) is recognized
190
227
  // by a specific literal prefix. Checked three ways, each catching what the
@@ -216,9 +253,20 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
216
253
  // only split secrets), an email joined with an adjacent word can produce a long
217
254
  // mixed string with incidentally high entropy. Emails get their own warning
218
255
  // below — they're not secrets, so they shouldn't feed the random-string check.
219
- if (candidates.some(token => !EMAIL_RE.test(token) && looksRandom(token, combined))) {
256
+ //
257
+ // A code-filename-shaped candidate (looksLikeCodeFilename) is split out into
258
+ // its own warning instead of a reject reason: security review found that
259
+ // fully exempting this shape from the entropy check was a deterministic
260
+ // bypass (append ".php" to any letters-only secret and it passed clean).
261
+ // Downgrading to a warning — never silently dropping the signal — matches
262
+ // how an email address is already handled below.
263
+ const randomCandidates = candidates.filter(token => !EMAIL_RE.test(token) && looksRandom(token, combined));
264
+ if (randomCandidates.some(token => !looksLikeCodeFilename(token))) {
220
265
  reasons.push('Contains a long, random-looking string that could be a secret.');
221
266
  }
267
+ if (randomCandidates.some(token => looksLikeCodeFilename(token))) {
268
+ warnings.push('Contains a code-filename-shaped token that also reads as high-entropy — double-check it is not a credential.');
269
+ }
222
270
 
223
271
  if (EMAIL_RE.test(combined)) {
224
272
  warnings.push('Contains an email address.');