ticketlens 0.38.14 → 0.38.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.14",
3
+ "version": "0.38.16",
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,17 +1,23 @@
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:/;
14
16
  export const NOTE_ADD_RE = /\bticketlens\s+note\s+add\b|\/jtb\s+note\b/;
17
+ // Matches the MCP tool_use name Claude Code gives an MCP server's tool call
18
+ // (mcp__<server-alias>__<tool-name>) — the server alias is whatever the user
19
+ // named it in their own .mcp.json, so only the tool-name suffix is fixed.
20
+ export const NOTE_ADD_MCP_RE = /^mcp__.+__recall_add$/;
15
21
 
16
22
  export function readStdinJson() {
17
23
  const raw = fs.readFileSync(0, 'utf8');
@@ -40,6 +46,59 @@ export function writeState(sessionId, state) {
40
46
  } catch { /* best-effort — a lost nudge counter is not worth failing the hook over */ }
41
47
  }
42
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
+
43
102
  /**
44
103
  * Reads the transcript (JSONL) and returns simple booleans about what
45
104
  * happened this session. Best-effort: any read/parse failure returns all
@@ -54,7 +113,11 @@ export function writeState(sessionId, state) {
54
113
  * Claude just flagged something every time) and sawNoteAdd gets stuck true
55
114
  * (silently disabling the Stop-hook check, since it thinks a note was
56
115
  * already added). Only count a real assistant-authored text block for the
57
- * flag, and only a real executed Bash command for note-add.
116
+ * flag, and only a real executed note-add — either the CLI (`ticketlens
117
+ * note add`/`/jtb note` via Bash) or the ticketlens MCP server's recall_add
118
+ * tool — for note-add. Missing the MCP path here was a real bug: it made
119
+ * the Stop hook nudge even after a note was genuinely captured via MCP,
120
+ * since only the Bash/CLI form was ever recognized.
58
121
  */
59
122
  export function scanTranscript(transcriptPath) {
60
123
  const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false };
@@ -86,8 +149,10 @@ export function scanTranscript(transcriptPath) {
86
149
  if (block.type === 'text' && RECALL_FLAG_RE.test(block.text ?? '')) {
87
150
  result.sawRecallFlag = true;
88
151
  }
89
- if (block.type === 'tool_use' && block.name === 'Bash' && NOTE_ADD_RE.test(block.input?.command ?? '')) {
90
- result.sawNoteAdd = true;
152
+ if (block.type === 'tool_use') {
153
+ const isCliNoteAdd = block.name === 'Bash' && NOTE_ADD_RE.test(block.input?.command ?? '');
154
+ const isMcpNoteAdd = NOTE_ADD_MCP_RE.test(block.name ?? '');
155
+ if (isCliNoteAdd || isMcpNoteAdd) result.sawNoteAdd = true;
91
156
  }
92
157
  }
93
158
  }
@@ -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
 
@@ -471,10 +471,11 @@ export function printProfilesHelp({ stream = process.stdout } = {}) {
471
471
  '',
472
472
  ` ${s.bold('SUBCOMMANDS')}`,
473
473
  '',
474
- ` ${s.brand('set-team')} ${s.dim('<profile> <team name>')}`,
474
+ ` ${s.brand('set-team')} ${s.dim('<profile> [team name]')}`,
475
475
  ` Route this profile's Recall notes to a specific Console team —`,
476
- ` for accounts that belong to more than one team. Validated against`,
477
- ` teams synced by ${s.cyan('ticketlens login')}/${s.cyan('ticketlens sync')}, but never blocked by a stale list.`,
476
+ ` for accounts that belong to more than one team. Omit the team name`,
477
+ ` to pick one interactively from your synced teams. Stored by id,`,
478
+ ` refreshed by ${s.cyan('ticketlens login')}/${s.cyan('ticketlens sync')}.`,
478
479
  '',
479
480
  ` ${s.bold('OPTIONS')}`,
480
481
  '',
@@ -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
  /**
@@ -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);