ticketlens 0.21.1 → 0.21.3
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 +2 -1
- package/scripts/postinstall.mjs +12 -0
- package/skills/jtb/SKILL.md +2 -0
- package/skills/jtb/hooks/recall-nudge-lib.mjs +96 -0
- package/skills/jtb/hooks/recall-nudge-post-tool.mjs +52 -0
- package/skills/jtb/hooks/recall-nudge-stop.mjs +48 -0
- package/skills/jtb/scripts/lib/hooks-setup.mjs +115 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ticketlens",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.3",
|
|
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": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"bin/",
|
|
12
12
|
"scripts/",
|
|
13
13
|
"skills/jtb/SKILL.md",
|
|
14
|
+
"skills/jtb/hooks/",
|
|
14
15
|
"skills/jtb/scripts/lib/",
|
|
15
16
|
"skills/jtb/scripts/fetch-ticket.mjs",
|
|
16
17
|
"skills/jtb/scripts/fetch-my-tickets.mjs"
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -62,6 +62,18 @@ if (updated === 0 && skipped === TARGETS.length) {
|
|
|
62
62
|
console.log(' To install: ticketlens update-skill');
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Recall nudge hooks — best-effort, never breaks the install.
|
|
66
|
+
try {
|
|
67
|
+
const { setupRecallHooks } = await import('../skills/jtb/scripts/lib/hooks-setup.mjs');
|
|
68
|
+
for (const r of setupRecallHooks()) {
|
|
69
|
+
if (r.status === 'installed') console.log(` ✔ Recall nudge hooks installed in ${r.label}`);
|
|
70
|
+
else if (r.status === 'error') console.warn(` ⚠ Recall nudge hooks: ${r.label} — ${r.reason}`);
|
|
71
|
+
// 'unchanged' and 'skipped' are silent — nothing changed, nothing to report.
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// Silent on failure — never breaks the install.
|
|
75
|
+
}
|
|
76
|
+
|
|
65
77
|
// First-run banner + tl-alias status. Best-effort: npm >=7 hides this output
|
|
66
78
|
// unless the user passes --foreground-scripts, so the guaranteed channel is
|
|
67
79
|
// the first bare `tl`/`ticketlens` run added in a later phase. Never throws —
|
package/skills/jtb/SKILL.md
CHANGED
|
@@ -210,6 +210,8 @@ Do not capture: routine/obvious information, anything already fully covered by t
|
|
|
210
210
|
|
|
211
211
|
Capture **at the moment you confirm the insight**, not deferred to the end of the session — don't ask the user first, just do it. It requires a Pro license; on a Free account it will no-op with an upgrade hint on stderr, which is fine to ignore. It never blocks or fails the session.
|
|
212
212
|
|
|
213
|
+
Immediately before dispatching the call, say one line in your response: `🔖 Recall-flag: <one-line reason>`. This is a fixed marker some harness setups scan for to confirm captures are actually happening — always emit it right when you decide to capture, even if you go on to call `note add` in the same breath.
|
|
214
|
+
|
|
213
215
|
### How to dispatch the call
|
|
214
216
|
|
|
215
217
|
If your environment lets you dispatch a tool call without waiting for its result — a background task, an async subagent, a fire-and-forget job — use that mechanism for this call, so it doesn't block your primary work while the note syncs to the team. If you have no such mechanism, call it synchronously; it's fast, and it never blocks or fails your session either way, so there's no harm in the fallback.
|
|
@@ -0,0 +1,96 @@
|
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
export const TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/;
|
|
13
|
+
export const RECALL_FLAG_RE = /🔖\s*Recall-flag:/;
|
|
14
|
+
export const NOTE_ADD_RE = /\bticketlens\s+note\s+add\b|\/jtb\s+note\b/;
|
|
15
|
+
|
|
16
|
+
export function readStdinJson() {
|
|
17
|
+
const raw = fs.readFileSync(0, 'utf8');
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(raw);
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function statePath(sessionId) {
|
|
26
|
+
return path.join(os.tmpdir(), `ticketlens-recall-nudge-${sessionId || 'unknown'}.json`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readState(sessionId) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(fs.readFileSync(statePath(sessionId), 'utf8'));
|
|
32
|
+
} catch {
|
|
33
|
+
return { ticketToolCalls: 0, lastNudgeAt: 0 };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function writeState(sessionId, state) {
|
|
38
|
+
try {
|
|
39
|
+
fs.writeFileSync(statePath(sessionId), JSON.stringify(state));
|
|
40
|
+
} catch { /* best-effort — a lost nudge counter is not worth failing the hook over */ }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reads the transcript (JSONL) and returns simple booleans about what
|
|
45
|
+
* happened this session. Best-effort: any read/parse failure returns all
|
|
46
|
+
* false rather than throwing — a broken transcript must never block Claude.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately narrow about WHERE each pattern is allowed to match — jtb's
|
|
49
|
+
* own SKILL.md instructions contain the literal strings "🔖 Recall-flag:"
|
|
50
|
+
* and "ticketlens note add" as examples. Matching against the whole raw
|
|
51
|
+
* entry (as an earlier version of this function did) means loading the
|
|
52
|
+
* skill at all permanently false-positives both checks: sawRecallFlag gets
|
|
53
|
+
* stuck true (silently disabling the mid-session nudge, since it thinks
|
|
54
|
+
* Claude just flagged something every time) and sawNoteAdd gets stuck true
|
|
55
|
+
* (silently disabling the Stop-hook check, since it thinks a note was
|
|
56
|
+
* already added). Only count a real assistant-authored text block for the
|
|
57
|
+
* flag, and only a real executed Bash command for note-add.
|
|
58
|
+
*/
|
|
59
|
+
export function scanTranscript(transcriptPath) {
|
|
60
|
+
const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false };
|
|
61
|
+
let lines;
|
|
62
|
+
try {
|
|
63
|
+
lines = fs.readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
|
|
64
|
+
} catch {
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
let entry;
|
|
70
|
+
try {
|
|
71
|
+
entry = JSON.parse(line);
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Ticket-key detection stays broad (whole entry, any role) — it's only
|
|
77
|
+
// the weaker "did ticket work happen at all" signal, and a rare false
|
|
78
|
+
// positive here just means an extra harmless once-per-session check.
|
|
79
|
+
if (TICKET_KEY_RE.test(JSON.stringify(entry))) result.sawTicketKey = true;
|
|
80
|
+
|
|
81
|
+
if (entry.type !== 'assistant') continue;
|
|
82
|
+
const blocks = entry.message?.content;
|
|
83
|
+
if (!Array.isArray(blocks)) continue;
|
|
84
|
+
|
|
85
|
+
for (const block of blocks) {
|
|
86
|
+
if (block.type === 'text' && RECALL_FLAG_RE.test(block.text ?? '')) {
|
|
87
|
+
result.sawRecallFlag = true;
|
|
88
|
+
}
|
|
89
|
+
if (block.type === 'tool_use' && block.name === 'Bash' && NOTE_ADD_RE.test(block.input?.command ?? '')) {
|
|
90
|
+
result.sawNoteAdd = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Stop hook — end-of-session Recall check.
|
|
4
|
+
*
|
|
5
|
+
* Blocks (exit 2) at most ONCE per session — never traps the user in a
|
|
6
|
+
* loop regardless of how Claude responds. Two cases force a check:
|
|
7
|
+
* 1. Claude flagged something (🔖 Recall-flag:) but never called note add
|
|
8
|
+
* — a broken promise, the strongest signal something was missed.
|
|
9
|
+
* 2. Ticket work happened all session with zero flags and zero notes
|
|
10
|
+
* — the weaker "did anything ever get considered?" catch.
|
|
11
|
+
* Anything else (no ticket work at all, or a note was already added) exits
|
|
12
|
+
* clean — this must never be the reason a session can't end.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readStdinJson, readState, writeState, scanTranscript } from './recall-nudge-lib.mjs';
|
|
16
|
+
|
|
17
|
+
const input = readStdinJson();
|
|
18
|
+
const sessionId = input?.session_id;
|
|
19
|
+
const transcriptPath = input?.transcript_path;
|
|
20
|
+
|
|
21
|
+
if (!sessionId || !transcriptPath) process.exit(0);
|
|
22
|
+
|
|
23
|
+
const state = readState(sessionId);
|
|
24
|
+
if (state.stopChecked) process.exit(0); // already asked once this session — respect the answer
|
|
25
|
+
|
|
26
|
+
const { sawTicketKey, sawRecallFlag, sawNoteAdd } = scanTranscript(transcriptPath);
|
|
27
|
+
|
|
28
|
+
if (!sawTicketKey || sawNoteAdd) {
|
|
29
|
+
process.exit(0); // no ticket work, or already captured — nothing to force
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
state.stopChecked = true;
|
|
33
|
+
writeState(sessionId, state);
|
|
34
|
+
|
|
35
|
+
if (sawRecallFlag) {
|
|
36
|
+
process.stderr.write(
|
|
37
|
+
'You flagged something as Recall-worthy (🔖 Recall-flag:) earlier this session but ' +
|
|
38
|
+
'never called `ticketlens note add`. Do that now, or say explicitly why it turned ' +
|
|
39
|
+
'out not to qualify — then you can finish.\n',
|
|
40
|
+
);
|
|
41
|
+
} else {
|
|
42
|
+
process.stderr.write(
|
|
43
|
+
'This session touched ticket work but nothing was ever captured to Recall. If a ' +
|
|
44
|
+
'non-obvious insight, gotcha, or decision came up, capture it now via ' +
|
|
45
|
+
'`ticketlens note add`. If genuinely nothing qualified, just say so — then finish.\n',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
process.exit(2);
|
|
@@ -0,0 +1,115 @@
|
|
|
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`.
|
|
6
|
+
*
|
|
7
|
+
* Never touches an existing settings.json's other content, and never
|
|
8
|
+
* throws — a malformed or unreadable settings.json is skipped, not
|
|
9
|
+
* overwritten, so a broken install can never corrupt the user's config.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
13
|
+
import { join, dirname } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
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');
|
|
21
|
+
|
|
22
|
+
// Distinctive substring used to find/replace our own entries on re-install —
|
|
23
|
+
// never matches anything a user could plausibly have written by hand.
|
|
24
|
+
const MARKER = 'recall-nudge-post-tool.mjs';
|
|
25
|
+
const STOP_MARKER = 'recall-nudge-stop.mjs';
|
|
26
|
+
|
|
27
|
+
const CLAUDE_DIRS = [
|
|
28
|
+
join(homedir(), '.claude'),
|
|
29
|
+
join(homedir(), '.claude-work'),
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function makeHookEntry(scriptPath, matcher) {
|
|
33
|
+
return {
|
|
34
|
+
matcher,
|
|
35
|
+
hooks: [
|
|
36
|
+
{
|
|
37
|
+
type: 'command',
|
|
38
|
+
command: `node "${scriptPath}"`,
|
|
39
|
+
timeout: 5,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function upsertHookEntry(list, marker, entry) {
|
|
46
|
+
const idx = list.findIndex(h =>
|
|
47
|
+
(h.hooks || []).some(inner => typeof inner.command === 'string' && inner.command.includes(marker)),
|
|
48
|
+
);
|
|
49
|
+
if (idx === -1) {
|
|
50
|
+
list.push(entry);
|
|
51
|
+
return 'added';
|
|
52
|
+
}
|
|
53
|
+
if (JSON.stringify(list[idx]) === JSON.stringify(entry)) return 'unchanged';
|
|
54
|
+
list[idx] = entry;
|
|
55
|
+
return 'updated';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function installInto(settingsPath) {
|
|
59
|
+
let settings = {};
|
|
60
|
+
if (existsSync(settingsPath)) {
|
|
61
|
+
try {
|
|
62
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
63
|
+
} catch {
|
|
64
|
+
return { status: 'skipped', reason: 'malformed settings.json — left untouched' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
settings.hooks ??= {};
|
|
69
|
+
settings.hooks.PostToolUse ??= [];
|
|
70
|
+
settings.hooks.Stop ??= [];
|
|
71
|
+
|
|
72
|
+
const postResult = upsertHookEntry(
|
|
73
|
+
settings.hooks.PostToolUse,
|
|
74
|
+
MARKER,
|
|
75
|
+
makeHookEntry(POST_TOOL_SCRIPT, 'Bash'),
|
|
76
|
+
);
|
|
77
|
+
const stopResult = upsertHookEntry(
|
|
78
|
+
settings.hooks.Stop,
|
|
79
|
+
STOP_MARKER,
|
|
80
|
+
makeHookEntry(STOP_SCRIPT, '*'),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (postResult === 'unchanged' && stopResult === 'unchanged') {
|
|
84
|
+
return { status: 'unchanged' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const tmpPath = `${settingsPath}.${process.pid}.tmp`;
|
|
88
|
+
writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
89
|
+
// Atomic on POSIX — avoids ever leaving settings.json half-written.
|
|
90
|
+
renameSync(tmpPath, settingsPath);
|
|
91
|
+
|
|
92
|
+
return { status: 'installed', postResult, stopResult };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @returns {{ label: string, status: string }[]} one entry per Claude
|
|
97
|
+
* directory found on this machine, for the caller to log.
|
|
98
|
+
*/
|
|
99
|
+
export function setupRecallHooks() {
|
|
100
|
+
const results = [];
|
|
101
|
+
|
|
102
|
+
for (const dir of CLAUDE_DIRS) {
|
|
103
|
+
if (!existsSync(dir)) continue;
|
|
104
|
+
const settingsPath = join(dir, 'settings.json');
|
|
105
|
+
try {
|
|
106
|
+
mkdirSync(dir, { recursive: true });
|
|
107
|
+
const result = installInto(settingsPath);
|
|
108
|
+
results.push({ label: dir, ...result });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
results.push({ label: dir, status: 'error', reason: err.message });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return results;
|
|
115
|
+
}
|