ticketlens 0.38.15 → 0.38.17

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.15",
3
+ "version": "0.38.17",
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).
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Shared helpers for the Recall nudge hooks (recall-nudge-post-tool.mjs,
3
- * recall-nudge-stop.mjs). Both read the same Claude Code hook stdin JSON
4
- * and the same session transcript — kept in one place so the detection
5
- * logic can't drift between the two hooks.
2
+ * Shared helpers for the Recall nudge Stop hook (recall-nudge-stop.mjs).
3
+ * The retired PostToolUse mid-session nudge (recall-nudge-post-tool.mjs)
4
+ * used to share this module too — removed because it only ever matched
5
+ * Bash tool calls and went silently inert once ticket work moved to MCP
6
+ * tools, which SKILL.md tells Claude to prefer over Bash whenever available.
6
7
  */
7
8
 
8
9
  import fs from 'node:fs';
9
10
  import os from 'node:os';
10
11
  import path from 'node:path';
12
+ import crypto from 'node:crypto';
11
13
 
12
14
  export const TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/;
13
15
  export const RECALL_FLAG_RE = /🔖\s*Recall-flag:/;
@@ -44,6 +46,59 @@ export function writeState(sessionId, state) {
44
46
  } catch { /* best-effort — a lost nudge counter is not worth failing the hook over */ }
45
47
  }
46
48
 
49
+ // Two hours — how long a real capture in one directory counts as "recent
50
+ // enough" to skip the Stop hook's nag, even from a brand-new session_id.
51
+ export const CAPTURE_FRESHNESS_MS = 2 * 60 * 60 * 1000;
52
+
53
+ /**
54
+ * Cross-session capture marker, keyed by a hash of `cwd` rather than
55
+ * `session_id`. The per-session_id state file (readState/writeState above)
56
+ * cannot answer "was something captured recently" once a compaction/resume
57
+ * event rolls the session_id over — that starts both a blank dedup state
58
+ * AND a blank transcript file, so a genuine earlier capture becomes
59
+ * invisible to scanTranscript(). This marker survives that boundary because
60
+ * it's keyed by the (stable) working directory instead.
61
+ *
62
+ * Lives in a user-private, mode-0700 subdirectory rather than directly in
63
+ * (often world-writable, on Linux) os.tmpdir() — unlike statePath()'s
64
+ * session_id (an unguessable UUID), a hash of `cwd` is derived from a much
65
+ * smaller, guessable input space (common project directory names), so a
66
+ * predictable path directly in shared tmp could be pre-planted by another
67
+ * local user on a shared box.
68
+ */
69
+ function privateTmpDir() {
70
+ const owner = typeof process.getuid === 'function' ? process.getuid() : os.userInfo().username;
71
+ const dir = path.join(os.tmpdir(), `ticketlens-recall-nudge-${owner}`);
72
+ try {
73
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
74
+ } catch { /* best-effort — a write into it below will just fail safely too */ }
75
+ return dir;
76
+ }
77
+
78
+ export function lastCapturePath(cwd) {
79
+ const hash = crypto.createHash('sha256').update(cwd || 'unknown').digest('hex').slice(0, 16);
80
+ return path.join(privateTmpDir(), `lastcapture-${hash}.json`);
81
+ }
82
+
83
+ export function readLastCaptureAt(cwd) {
84
+ try {
85
+ return JSON.parse(fs.readFileSync(lastCapturePath(cwd), 'utf8')).lastCaptureAt ?? 0;
86
+ } catch {
87
+ return 0;
88
+ }
89
+ }
90
+
91
+ export function writeLastCaptureAt(cwd, timestamp) {
92
+ try {
93
+ fs.writeFileSync(lastCapturePath(cwd), JSON.stringify({ lastCaptureAt: timestamp }));
94
+ } catch { /* best-effort — losing this marker only costs one extra nag next rollover */ }
95
+ }
96
+
97
+ export function hasRecentCapture(cwd, now = Date.now()) {
98
+ const lastCaptureAt = readLastCaptureAt(cwd);
99
+ return lastCaptureAt > 0 && (now - lastCaptureAt) < CAPTURE_FRESHNESS_MS;
100
+ }
101
+
47
102
  /**
48
103
  * Reads the transcript (JSONL) and returns simple booleans about what
49
104
  * happened this session. Best-effort: any read/parse failure returns all
@@ -10,25 +10,41 @@
10
10
  * — the weaker "did anything ever get considered?" catch.
11
11
  * Anything else (no ticket work at all, or a note was already added) exits
12
12
  * clean — this must never be the reason a session can't end.
13
+ *
14
+ * The per-session_id "asked once" state (readState/writeState) cannot
15
+ * survive a compaction/resume event — that hands this hook a brand-new
16
+ * session_id, a blank dedup state, AND a blank transcript file, so a real
17
+ * earlier capture becomes invisible. The cross-session lastCapture marker
18
+ * (keyed by cwd, not session_id) is what actually bridges that boundary.
13
19
  */
14
20
 
15
- import { readStdinJson, readState, writeState, scanTranscript } from './recall-nudge-lib.mjs';
21
+ import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt } from './recall-nudge-lib.mjs';
16
22
 
17
23
  const input = readStdinJson();
18
24
  const sessionId = input?.session_id;
19
25
  const transcriptPath = input?.transcript_path;
26
+ const cwd = input?.cwd ?? process.cwd();
20
27
 
21
28
  if (!sessionId || !transcriptPath) process.exit(0);
22
29
 
30
+ const { sawTicketKey, sawRecallFlag, sawNoteAdd } = scanTranscript(transcriptPath);
31
+
32
+ // Refreshed on every check, independent of the once-per-session gate below —
33
+ // a capture that happens AFTER this session already nagged once must still
34
+ // update the marker, or a later session_id rollover would find it stale.
35
+ if (sawNoteAdd) writeLastCaptureAt(cwd, Date.now());
36
+
23
37
  const state = readState(sessionId);
24
38
  if (state.stopChecked) process.exit(0); // already asked once this session — respect the answer
25
39
 
26
- const { sawTicketKey, sawRecallFlag, sawNoteAdd } = scanTranscript(transcriptPath);
27
-
28
40
  if (!sawTicketKey || sawNoteAdd) {
29
41
  process.exit(0); // no ticket work, or already captured — nothing to force
30
42
  }
31
43
 
44
+ if (hasRecentCapture(cwd)) {
45
+ process.exit(0); // a real capture landed recently in this same directory, just under a different session_id
46
+ }
47
+
32
48
  state.stopChecked = true;
33
49
  writeState(sessionId, state);
34
50
 
@@ -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 = [
@@ -1,8 +1,12 @@
1
1
  /**
2
- * Installs the Recall nudge hooks (recall-nudge-post-tool.mjs,
3
- * recall-nudge-stop.mjs) into any detected Claude Code settings.json —
4
- * run from postinstall.mjs on every install/update so a user never has to
5
- * wire this by hand. Idempotent: safe to run on every `npm install`.
2
+ * Installs the Recall nudge Stop hook (recall-nudge-stop.mjs) into any
3
+ * detected Claude Code settings.json — run from postinstall.mjs on every
4
+ * install/update so a user never has to wire this by hand. Idempotent: safe
5
+ * to run on every `npm install`. Also cleans up the retired PostToolUse
6
+ * nudge (recall-nudge-post-tool.mjs) from any settings.json that still has
7
+ * it from a prior install — it only ever matched Bash tool calls, so it
8
+ * went silently inert once ticket work moved to MCP tools, and was removed
9
+ * rather than left as dead config.
6
10
  *
7
11
  * Never touches an existing settings.json's other content, and never
8
12
  * throws — a malformed or unreadable settings.json is skipped, not
@@ -16,12 +20,11 @@ import { homedir } from 'node:os';
16
20
 
17
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
22
  const HOOKS_DIR = join(__dirname, '..', '..', 'hooks');
19
- const POST_TOOL_SCRIPT = join(HOOKS_DIR, 'recall-nudge-post-tool.mjs');
20
- const STOP_SCRIPT = join(HOOKS_DIR, 'recall-nudge-stop.mjs');
23
+ const STOP_SCRIPT = join(HOOKS_DIR, 'recall-nudge-stop.mjs');
21
24
 
22
- // Distinctive substring used to find/replace our own entries on re-install —
25
+ // Distinctive substrings used to find our own entries on re-install/cleanup —
23
26
  // never matches anything a user could plausibly have written by hand.
24
- const MARKER = 'recall-nudge-post-tool.mjs';
27
+ const RETIRED_POST_TOOL_MARKER = 'recall-nudge-post-tool.mjs';
25
28
  const STOP_MARKER = 'recall-nudge-stop.mjs';
26
29
 
27
30
  const CLAUDE_DIRS = [
@@ -42,6 +45,11 @@ function makeHookEntry(scriptPath, matcher) {
42
45
  };
43
46
  }
44
47
 
48
+ // Both functions below locate our own hook by a marker substring inside a
49
+ // list entry's `hooks` array, but must never drop an unrelated hook a user
50
+ // happens to have colocated in that same array entry (e.g. sharing our
51
+ // matcher) — only our own matching inner hook is ever added/replaced/removed.
52
+
45
53
  function upsertHookEntry(list, marker, entry) {
46
54
  const idx = list.findIndex(h =>
47
55
  (h.hooks || []).some(inner => typeof inner.command === 'string' && inner.command.includes(marker)),
@@ -50,12 +58,32 @@ function upsertHookEntry(list, marker, entry) {
50
58
  list.push(entry);
51
59
  return 'added';
52
60
  }
53
- if (JSON.stringify(list[idx]) === JSON.stringify(entry)) return 'unchanged';
54
- list[idx] = entry;
61
+ const ownInner = entry.hooks[0]; // makeHookEntry always builds a single-command entry
62
+ const otherHooks = list[idx].hooks.filter(inner => !(typeof inner.command === 'string' && inner.command.includes(marker)));
63
+ // Our hook was the only thing in this entry — safe to fully replace
64
+ // (also picks up a matcher change, if any). Otherwise preserve the
65
+ // entry's existing matcher and other hooks, just swap our own inner hook.
66
+ const merged = otherHooks.length === 0 ? entry : { ...list[idx], hooks: [...otherHooks, ownInner] };
67
+ if (JSON.stringify(list[idx]) === JSON.stringify(merged)) return 'unchanged';
68
+ list[idx] = merged;
55
69
  return 'updated';
56
70
  }
57
71
 
58
- function installInto(settingsPath) {
72
+ function removeHookEntry(list, marker) {
73
+ const idx = list.findIndex(h =>
74
+ (h.hooks || []).some(inner => typeof inner.command === 'string' && inner.command.includes(marker)),
75
+ );
76
+ if (idx === -1) return 'absent';
77
+ const remaining = list[idx].hooks.filter(inner => !(typeof inner.command === 'string' && inner.command.includes(marker)));
78
+ if (remaining.length === 0) {
79
+ list.splice(idx, 1);
80
+ } else {
81
+ list[idx] = { ...list[idx], hooks: remaining };
82
+ }
83
+ return 'removed';
84
+ }
85
+
86
+ export function installInto(settingsPath) {
59
87
  let settings = {};
60
88
  if (existsSync(settingsPath)) {
61
89
  try {
@@ -69,18 +97,14 @@ function installInto(settingsPath) {
69
97
  settings.hooks.PostToolUse ??= [];
70
98
  settings.hooks.Stop ??= [];
71
99
 
72
- const postResult = upsertHookEntry(
73
- settings.hooks.PostToolUse,
74
- MARKER,
75
- makeHookEntry(POST_TOOL_SCRIPT, 'Bash'),
76
- );
100
+ const postCleanupResult = removeHookEntry(settings.hooks.PostToolUse, RETIRED_POST_TOOL_MARKER);
77
101
  const stopResult = upsertHookEntry(
78
102
  settings.hooks.Stop,
79
103
  STOP_MARKER,
80
104
  makeHookEntry(STOP_SCRIPT, '*'),
81
105
  );
82
106
 
83
- if (postResult === 'unchanged' && stopResult === 'unchanged') {
107
+ if (postCleanupResult === 'absent' && stopResult === 'unchanged') {
84
108
  return { status: 'unchanged' };
85
109
  }
86
110
 
@@ -89,7 +113,7 @@ function installInto(settingsPath) {
89
113
  // Atomic on POSIX — avoids ever leaving settings.json half-written.
90
114
  renameSync(tmpPath, settingsPath);
91
115
 
92
- return { status: 'installed', postResult, stopResult };
116
+ return { status: 'installed', postCleanupResult, stopResult };
93
117
  }
94
118
 
95
119
  /**
@@ -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
  });
@@ -262,6 +262,22 @@ export function resolveProfile(ticketKey, opts = {}) {
262
262
  return null;
263
263
  }
264
264
 
265
+ /**
266
+ * Names of every profile whose ticketPrefixes includes `prefix` — used to
267
+ * cross-check a resolved connection against the project/team key the caller
268
+ * actually asked for. `ticket_create` has no ticket key to prefix-match
269
+ * against (unlike every other ticket-write command), so this checks the raw
270
+ * project/team key directly instead, as a safety net against silently
271
+ * creating a ticket on the wrong tracker.
272
+ */
273
+ export function findProfilesByPrefix(prefix, configDir = DEFAULT_CONFIG_DIR) {
274
+ const config = loadProfiles(configDir);
275
+ if (!config) return [];
276
+ return Object.entries(config.profiles)
277
+ .filter(([, profile]) => profile.ticketPrefixes?.includes(prefix))
278
+ .map(([name]) => name);
279
+ }
280
+
265
281
  export function resolveConnection(ticketKey, opts = {}) {
266
282
  const { env = process.env, configDir = DEFAULT_CONFIG_DIR, profileName, onWarning, onProfileNotFound, cwd } = opts;
267
283
 
@@ -12,7 +12,7 @@
12
12
  import os from 'node:os';
13
13
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
14
14
  import { isLicensed, showUpgradePrompt } from './license.mjs';
15
- import { resolveConnection } from './profile-resolver.mjs';
15
+ import { resolveConnection, findProfilesByPrefix } from './profile-resolver.mjs';
16
16
  import { resolveAdapter } from './resolve-adapter.mjs';
17
17
  import { checkCooldown, recordAction } from './ticket-action-cooldown.mjs';
18
18
  import { logAction } from './ticket-action-log.mjs';
@@ -218,15 +218,24 @@ function requireTicketKey(cmdArgs, usage, stream) {
218
218
 
219
219
  /**
220
220
  * `ticketKey` is undefined for ticket_create — there is no existing ticket to
221
- * prefix-match a connection from, so resolution falls through to --profile
222
- * or the default profile (resolveConnectionFn already handles a falsy
223
- * ticketKey by skipping prefix matching, see profile-resolver.mjs).
221
+ * prefix-match a connection from, so resolution falls through to --profile,
222
+ * the folder-based `cwd` match, or the default profile (resolveConnectionFn
223
+ * already handles a falsy ticketKey by skipping prefix matching, see
224
+ * profile-resolver.mjs). `cwd` is always the running process's own — every
225
+ * ticket-write command runs synchronously within a single CLI/MCP-server
226
+ * invocation, so there is never a separate "caller's cwd" to thread through.
227
+ *
228
+ * Returns `{ adapter, conn }` (not just the adapter) so callers that need to
229
+ * cross-check the resolved connection's identity — currently only
230
+ * `runTicketCreate`'s profile/project mismatch safety net — have it without
231
+ * re-resolving.
224
232
  */
225
233
  function resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream }) {
226
234
  const profileName = parseFlag(cmdArgs, 'profile');
227
235
  const conn = resolveConnectionFn(ticketKey, {
228
236
  configDir,
229
237
  profileName,
238
+ cwd: process.cwd(),
230
239
  onWarning: (msg) => stream.write(` ⚠ ${msg}\n`),
231
240
  });
232
241
  if (!conn.baseUrl) {
@@ -235,7 +244,7 @@ function resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnection
235
244
  : ` No connection configured. Run \`ticketlens init\` or pass --profile=NAME.\n`);
236
245
  return null;
237
246
  }
238
- return resolveAdapterFn(conn);
247
+ return { adapter: resolveAdapterFn(conn), conn };
239
248
  }
240
249
 
241
250
  /**
@@ -272,8 +281,9 @@ export async function runTicketComment(cmdArgs, {
272
281
  return { ok: false };
273
282
  }
274
283
 
275
- const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
276
- if (!adapter) return { ok: false };
284
+ const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
285
+ if (!resolved) return { ok: false };
286
+ const { adapter } = resolved;
277
287
  const s = createStyler({ isTTY: stream.isTTY });
278
288
 
279
289
  // Uploaded BEFORE the comment write so a tracker capable of inline
@@ -330,8 +340,9 @@ export async function runTicketTransitionList(cmdArgs, {
330
340
  const ticketKey = requireTicketKey(cmdArgs, usage, stream);
331
341
  if (!ticketKey) return { ok: false };
332
342
 
333
- const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
334
- if (!adapter) return { ok: false };
343
+ const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
344
+ if (!resolved) return { ok: false };
345
+ const { adapter } = resolved;
335
346
 
336
347
  try {
337
348
  const options = await adapter.getTransitions(ticketKey);
@@ -396,8 +407,9 @@ export async function runTicketTransition(cmdArgs, {
396
407
  return { ok: false };
397
408
  }
398
409
 
399
- const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
400
- if (!adapter) return { ok: false };
410
+ const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
411
+ if (!resolved) return { ok: false };
412
+ const { adapter } = resolved;
401
413
 
402
414
  try {
403
415
  const result = await adapter.transition(ticketKey, target);
@@ -456,8 +468,9 @@ export async function runTicketAssign(cmdArgs, {
456
468
  return { ok: false };
457
469
  }
458
470
 
459
- const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
460
- if (!adapter) return { ok: false };
471
+ const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
472
+ if (!resolved) return { ok: false };
473
+ const { adapter } = resolved;
461
474
 
462
475
  try {
463
476
  const result = await adapter.assignToSelf(ticketKey);
@@ -502,8 +515,9 @@ export async function runTicketDuplicates(cmdArgs, {
502
515
  }
503
516
  }
504
517
 
505
- const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
506
- if (!adapter) return { ok: false };
518
+ const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
519
+ if (!resolved) return { ok: false };
520
+ const { adapter } = resolved;
507
521
 
508
522
  try {
509
523
  // depth: 0 — only the shallow linkedIssues list is needed (for explicit
@@ -581,8 +595,9 @@ export async function runTicketLinkList(cmdArgs, {
581
595
  const targetKey = requireTicketKey(cmdArgs.slice(1), usage, stream);
582
596
  if (!targetKey) return { ok: false };
583
597
 
584
- const adapter = resolveTicketAdapter(sourceKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
585
- if (!adapter) return { ok: false };
598
+ const resolved = resolveTicketAdapter(sourceKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
599
+ if (!resolved) return { ok: false };
600
+ const { adapter } = resolved;
586
601
 
587
602
  try {
588
603
  const types = await adapter.getLinkTypes();
@@ -658,8 +673,9 @@ export async function runTicketLink(cmdArgs, {
658
673
  return { ok: false };
659
674
  }
660
675
 
661
- const adapter = resolveTicketAdapter(sourceKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
662
- if (!adapter) return { ok: false };
676
+ const resolved = resolveTicketAdapter(sourceKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
677
+ if (!resolved) return { ok: false };
678
+ const { adapter } = resolved;
663
679
 
664
680
  if (adapter.type === 'github' && type.toLowerCase() !== 'duplicate') {
665
681
  stream.write(` GitHub only supports linking as a duplicate — no generic link types. Got type "${type}".\n`);
@@ -748,8 +764,9 @@ export async function runTicketUpdate(cmdArgs, {
748
764
  return { ok: false };
749
765
  }
750
766
 
751
- const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
752
- if (!adapter) return { ok: false };
767
+ const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
768
+ if (!resolved) return { ok: false };
769
+ const { adapter } = resolved;
753
770
 
754
771
  if (adapter.type === 'github' && priority !== undefined) {
755
772
  stream.write(` GitHub Issues have no native priority field — cannot update priority on ${ticketKey}. Remove --priority and retry.\n`);
@@ -808,6 +825,7 @@ export async function runTicketCreate(cmdArgs, {
808
825
  logActionFn = logAction,
809
826
  readMetadataCacheFn = readMetadataCache,
810
827
  writeMetadataCacheFn = writeMetadataCache,
828
+ findProfilesByPrefixFn = findProfilesByPrefix,
811
829
  actor = os.userInfo().username,
812
830
  } = {}) {
813
831
  const usage = 'Usage: ticketlens create --project=KEY --type="Task" --summary="..." [--description="..."] [--profile=NAME]\n';
@@ -824,8 +842,9 @@ export async function runTicketCreate(cmdArgs, {
824
842
  const description = parseFlag(cmdArgs, 'description');
825
843
  const attachPaths = parseAttachPaths(cmdArgs);
826
844
 
827
- const adapter = resolveTicketAdapter(undefined, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
828
- if (!adapter) return { ok: false };
845
+ const resolved = resolveTicketAdapter(undefined, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
846
+ if (!resolved) return { ok: false };
847
+ const { adapter, conn } = resolved;
829
848
  const s = createStyler({ isTTY: stream.isTTY });
830
849
  const attachRefused = refuseGithubAttachments(adapter, attachPaths, stream);
831
850
 
@@ -841,6 +860,28 @@ export async function runTicketCreate(cmdArgs, {
841
860
  stream.write(` Note: --type is ignored by ${adapter.type} — issue created without it.\n`);
842
861
  }
843
862
 
863
+ // Highest-blast-radius write in the family: no ticket key exists yet to
864
+ // prefix-match against, so a resolution that quietly lands on the wrong
865
+ // profile (e.g. an unconfigured cwd falling to the default) fabricates a
866
+ // real ticket on the wrong tracker before anyone notices. Only refuses
867
+ // when a DIFFERENT profile is a known, better owner of this exact project
868
+ // key — a genuinely new, not-yet-registered project proceeds untouched.
869
+ // Skipped when the caller explicitly passed --profile=NAME: that is
870
+ // deliberate, informed intent (the same trust resolveProfile() itself
871
+ // already gives an explicit flag over every other signal), and this guard
872
+ // must never be a dead end with no way to force a legitimate create through.
873
+ if (adapter.type !== 'github' && project && conn.profileName && !parseFlag(cmdArgs, 'profile')) {
874
+ const owningProfiles = findProfilesByPrefixFn(project, configDir);
875
+ if (owningProfiles.length > 0 && !owningProfiles.includes(conn.profileName)) {
876
+ stream.write(
877
+ ` Project "${project}" is registered under profile "${owningProfiles[0]}", not the resolved profile ` +
878
+ `"${conn.profileName}". Pass --profile=${owningProfiles[0]} to target the right tracker, or ` +
879
+ `--profile=${conn.profileName} to confirm this is intentional.\n`,
880
+ );
881
+ return { ok: false };
882
+ }
883
+ }
884
+
844
885
  // JSON-encoded, not naively colon-joined — project/type/summary are free
845
886
  // text that can themselves contain ":", which would let two genuinely
846
887
  // different tuples collide onto the same cooldown key.
@@ -1,52 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * PostToolUse hook (matcher: Bash) — mid-session Recall nudge.
4
- *
5
- * Non-blocking by design: this only prints an advisory reminder to stdout
6
- * every NUDGE_EVERY ticket-related Bash calls, and only if Claude hasn't
7
- * self-flagged a Recall-worthy insight (🔖 Recall-flag:) since the last one.
8
- * Never exits non-zero — a mid-session nudge must never interrupt real work.
9
- */
10
-
11
- import { readStdinJson, statePath, readState, writeState, TICKET_KEY_RE, scanTranscript } from './recall-nudge-lib.mjs';
12
-
13
- const NUDGE_EVERY = 8; // ticket-related Bash calls between nudges
14
-
15
- const input = readStdinJson();
16
- const command = input?.tool_input?.command ?? '';
17
- const sessionId = input?.session_id;
18
- const transcriptPath = input?.transcript_path;
19
-
20
- if (!TICKET_KEY_RE.test(command) || !sessionId) {
21
- process.exit(0);
22
- }
23
-
24
- const state = readState(sessionId);
25
- state.ticketToolCalls = (state.ticketToolCalls ?? 0) + 1;
26
-
27
- // If Claude already self-flagged since we last reset, don't nag — reset the
28
- // counter so the next nudge only fires after another full quiet stretch.
29
- if (transcriptPath) {
30
- const { sawRecallFlag } = scanTranscript(transcriptPath);
31
- if (sawRecallFlag) {
32
- state.ticketToolCalls = 0;
33
- writeState(sessionId, state);
34
- process.exit(0);
35
- }
36
- }
37
-
38
- if (state.ticketToolCalls >= NUDGE_EVERY) {
39
- state.ticketToolCalls = 0;
40
- state.lastNudgeAt = Date.now();
41
- writeState(sessionId, state);
42
- process.stdout.write(
43
- 'Reminder (not a request for action right now): if something non-obvious was ' +
44
- 'confirmed in the last stretch of ticket work — a gotcha, a root cause, a ' +
45
- 'decision with non-obvious rationale — capture it now via `ticketlens note add` ' +
46
- 'per the jtb skill\'s Recall guidance, then keep going.\n',
47
- );
48
- process.exit(0);
49
- }
50
-
51
- writeState(sessionId, state);
52
- process.exit(0);