ticketlens 0.38.15 → 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 +1 -1
- package/skills/jtb/hooks/recall-nudge-lib.mjs +59 -4
- package/skills/jtb/hooks/recall-nudge-stop.mjs +19 -3
- package/skills/jtb/scripts/lib/hooks-setup.mjs +42 -18
- package/skills/jtb/scripts/lib/profile-resolver.mjs +16 -0
- package/skills/jtb/scripts/lib/ticket-command.mjs +64 -23
- package/skills/jtb/hooks/recall-nudge-post-tool.mjs +0 -52
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared helpers for the Recall nudge
|
|
3
|
-
* recall-nudge-
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Installs the Recall nudge
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
54
|
-
list[idx]
|
|
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
|
|
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
|
|
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 (
|
|
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',
|
|
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
|
|
223
|
-
* ticketKey by skipping prefix matching, see
|
|
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
|
|
276
|
-
if (!
|
|
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
|
|
334
|
-
if (!
|
|
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
|
|
400
|
-
if (!
|
|
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
|
|
460
|
-
if (!
|
|
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
|
|
506
|
-
if (!
|
|
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
|
|
585
|
-
if (!
|
|
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
|
|
662
|
-
if (!
|
|
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
|
|
752
|
-
if (!
|
|
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
|
|
828
|
-
if (!
|
|
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);
|