ticketlens 0.38.51 → 0.38.53
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 +2 -0
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +7 -1
- package/skills/jtb/hooks/recall-auto-capture.mjs +117 -0
- package/skills/jtb/hooks/recall-nudge-lib.mjs +108 -3
- package/skills/jtb/hooks/recall-nudge-stop.mjs +47 -4
- package/skills/jtb/scripts/lib/license.mjs +27 -8
- package/skills/jtb/scripts/lib/summarizer.mjs +49 -0
package/README.md
CHANGED
|
@@ -458,6 +458,8 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
458
458
|
|
|
459
459
|
**Recall capture strictness:** your team manager can also set a team default at `console/admin/recall` (Settings tab). It only applies if you've never run `ticketlens config set recallStrictness` yourself — your own explicit choice always wins over the team default. `ticketlens recall settings` also reports the strictness currently in effect and where it came from.
|
|
460
460
|
|
|
461
|
+
**Autonomous background capture (Pro+):** inside a Claude Code session using the jtb skill, TicketLens can also judge and save a note on its own, after the session ends — no `note add` call, no user action, reusing your existing `ticketlens login` (no new key or setup). It only runs when real fetch and real mutating ticket work happened that session, applies the same capture criteria as a Claude-triggered note, and goes through the identical license/secret-scan/vault/team-sync pipeline. Throttled to at most once per project directory within a short window, so a long session doesn't trigger it repeatedly.
|
|
462
|
+
|
|
461
463
|
**Removing a note:** `ticketlens note delete --id="..." [--ticket=KEY]` removes a note from your local vault. Local only — if it was already pushed to a team, teammates who pulled it keep their copy; deleting it there too is a manager action from the Console (Admin > Recall).
|
|
462
464
|
|
|
463
465
|
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `stats`, `issue_types`, `history`, `collisions`, `ledger`, `doctor`, `recall_add`, `recall_update`, `recall_delete`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — every CLI action now has an MCP tool, so any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same license gate per tool, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. `fetch`, `doctor`, and `standup` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` and `pr` are Free, sharing a 3-checks/month cap on their requirements-coverage section, Pro unlimited; `compliance` additionally accepts `consensus: true` (Pro) to replace the local deterministic matcher with a multi-agent AI review, run server-side against a "consensus" role configured at Console > Admin > AI Roles (2+ providers from the team's shared pool), and always implies `--yes` under MCP since there's no TTY for the cost-confirmation prompt; `review` is Free for branch/files/ticket context, with its coverage/focus section requiring Pro as a plain license check — it does not draw from that same monthly counter; `stats` is Free with a 7-day lookback cap, Pro extends it to 30 days, same split as the CLI (`ticketlens stats --help`); `issue_types` is Free and Jira-only — pre-fetches and caches a profile's real creatable projects and issue types ahead of a `ticket_create` attempt, sharing its cache with that tool's own reactive enrichment; Linear/GitHub profiles get a clear "not available" instead of an empty result; `history` reads local triage history only (zero network) and requires Pro; `collisions` requires `ticketlens login` (Console access) plus a Team license; `ledger` exports the local, signed compliance audit trail (zero network) and requires Pro; every other tool needs Pro. `recall_update` overwrites an existing Recall note's body — internal plumbing for the note quality loop, not typically called directly; its `attachments` array appends new files to whatever the note already has, same as `recall_add`'s, never replacing existing ones. `recall_delete` is destructive and local-vault-only — requires `confirm: true` alongside `id` to actually execute; there is no interactive y/N prompt under MCP (no real terminal to prompt against), so omitting it always fails rather than silently blocking. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.43.
|
|
1
|
+
<!-- jtb-skill-version: 0.43.2 -->
|
|
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.
|
|
@@ -406,6 +406,12 @@ This never calls any external API or bills any tokens beyond the session you alr
|
|
|
406
406
|
|
|
407
407
|
**Known limitation:** `note patch` only updates the local vault copy. If `note add` already pushed the original draft to a team (Team Recall enabled), a later refinement from this loop is *not* re-pushed — teammates who already pulled the note keep the original draft until this is addressed in a future iteration.
|
|
408
408
|
|
|
409
|
+
### Autonomous background capture (Pro+, independent of you)
|
|
410
|
+
|
|
411
|
+
TicketLens also judges and captures on its own, in the background, after this session ends — a second, independent path alongside everything above. It reuses your existing `ticketlens login` token (no new key, no new setup) and applies the same three-part rule this section already describes. On a `capture` decision it saves through the exact same gate as a normal `note add` — license check, secret scan, structural/word-cap check, vault write, team push.
|
|
412
|
+
|
|
413
|
+
This means a note can appear in a future brief that you never called `note add` for — that's expected, not a bug. It also means you should keep capturing proactively as this section instructs: the background path is a safety net for what gets missed, not a reason to skip capturing something you've already confirmed is worth saving. It only runs when a real fetch plus real mutating ticket work happened this session (a pure read-only lookup produces nothing to capture either way) and is throttled to at most once per working directory within a short window, so it won't re-fire on every turn of a long session.
|
|
414
|
+
|
|
409
415
|
### Privacy
|
|
410
416
|
Recall notes are stored locally at `~/.ticketlens/recall/`. On a Pro account with no Team Recall entitlement, they never leave the machine — no network calls (Free tier can't use Recall at all). On Team/Enterprise, Recall's team sync is included by default (Pro accounts can get it too, as a separate add-on); notes also sync to the team's shared pool in the background so teammates can benefit from them too, and a team manager reviews and verifies each incoming note before it's marked trusted. If a team push fails for a transient reason (network error, timeout, 5xx), the note is queued locally and retried automatically in the background, or on demand with `ticketlens recall sync` [Team+] — a session-expired or not-entitled push is never queued, since retrying those can't succeed without the user acting first.
|
|
411
417
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Autonomous background Recall capture (backlog #24, 6th report — combined
|
|
4
|
+
* with the sawMutatingAction narrowing in recall-nudge-lib.mjs's shouldNag).
|
|
5
|
+
*
|
|
6
|
+
* Spawned detached+unref'd by recall-nudge-stop.mjs on every Stop check —
|
|
7
|
+
* independent of, and unable to see the outcome of, that hook's own
|
|
8
|
+
* blocking-nag decision. TL judges and captures here, not Claude: reads the
|
|
9
|
+
* session's own transcript, asks the backend's fixed capture-judgment
|
|
10
|
+
* prompt (same 3-part SKILL.md rule Claude is asked to apply in-session),
|
|
11
|
+
* and on a `capture` decision saves the note itself via the existing
|
|
12
|
+
* `runNoteAdd()` pipeline — same Pro-gate, secret-scan, structural-check,
|
|
13
|
+
* vault-write, team-push as a human- or Claude-typed `note add`.
|
|
14
|
+
*
|
|
15
|
+
* Pro+ / logged-in only, cloud mode only (uses the existing CLI login
|
|
16
|
+
* token, never a personally-configured API key — the whole point of this
|
|
17
|
+
* path is zero new user configuration). Free tier / logged-out silently
|
|
18
|
+
* no-ops; the CLI/MCP surface a user actually sees is entirely unaffected.
|
|
19
|
+
*
|
|
20
|
+
* Best-effort throughout, matching every other file in this hook pair:
|
|
21
|
+
* nothing here is allowed to throw past runAutoCapture(), write to real
|
|
22
|
+
* stdout/stderr (nothing is watching a detached background process), or
|
|
23
|
+
* take any action visible to the user beyond the note itself landing in
|
|
24
|
+
* their vault. Every outcome (skip/capture/error) is appended to a local,
|
|
25
|
+
* user-private debug log so a silent miss stays diagnosable.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { appendFileSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { isLicensed } from '../scripts/lib/license.mjs';
|
|
31
|
+
import { readCliToken } from '../scripts/lib/cli-auth.mjs';
|
|
32
|
+
import { buildCaptureExcerpt, privateTmpDir } from './recall-nudge-lib.mjs';
|
|
33
|
+
import { autoCapture } from '../scripts/lib/summarizer.mjs';
|
|
34
|
+
import { runNoteAdd } from '../scripts/lib/note-command.mjs';
|
|
35
|
+
import { DEFAULT_CONFIG_DIR } from '../scripts/lib/config.mjs';
|
|
36
|
+
|
|
37
|
+
/** Buffers stream.write() calls instead of touching a real stream — same shape mcp-server.mjs's own capturingStream() uses, this process has no real stdout/stderr worth writing to. */
|
|
38
|
+
function capturingStream() {
|
|
39
|
+
const parts = [];
|
|
40
|
+
return { write(s) { parts.push(s); return true; }, get text() { return parts.join(''); } };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function logLine(message) {
|
|
44
|
+
try {
|
|
45
|
+
const line = `${new Date().toISOString()} ${message}\n`;
|
|
46
|
+
appendFileSync(join(privateTmpDir(), 'auto-capture.log'), line);
|
|
47
|
+
} catch { /* best-effort — losing a debug log line is not worth failing over */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} opts
|
|
52
|
+
* @param {string} opts.transcriptPath
|
|
53
|
+
* @param {string} [opts.ticketKey]
|
|
54
|
+
* @param {string} [opts.configDir]
|
|
55
|
+
* @returns {Promise<{outcome: 'skipped'|'captured'|'not-written'|'error', reason?: string, title?: string, error?: string}>}
|
|
56
|
+
*/
|
|
57
|
+
export async function runAutoCapture({
|
|
58
|
+
transcriptPath,
|
|
59
|
+
ticketKey,
|
|
60
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
61
|
+
isLicensedFn = isLicensed,
|
|
62
|
+
readCliTokenFn = readCliToken,
|
|
63
|
+
buildCaptureExcerptFn = buildCaptureExcerpt,
|
|
64
|
+
autoCaptureFn = autoCapture,
|
|
65
|
+
runNoteAddFn = runNoteAdd,
|
|
66
|
+
} = {}) {
|
|
67
|
+
if (!isLicensedFn('pro', configDir)) {
|
|
68
|
+
logLine('skipped: not licensed');
|
|
69
|
+
return { outcome: 'skipped', reason: 'not licensed' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const cliToken = readCliTokenFn(configDir);
|
|
73
|
+
if (!cliToken) {
|
|
74
|
+
logLine('skipped: not logged in');
|
|
75
|
+
return { outcome: 'skipped', reason: 'not logged in' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const excerpt = buildCaptureExcerptFn(transcriptPath);
|
|
79
|
+
if (!excerpt) {
|
|
80
|
+
logLine('skipped: empty excerpt');
|
|
81
|
+
return { outcome: 'skipped', reason: 'empty excerpt' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let result;
|
|
85
|
+
try {
|
|
86
|
+
result = await autoCaptureFn({ excerpt, ticketKey, cliToken });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
logLine(`error: ${err.message}`);
|
|
89
|
+
return { outcome: 'error', error: err.message };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (result.decision !== 'capture') {
|
|
93
|
+
logLine('skipped: decision=skip');
|
|
94
|
+
return { outcome: 'skipped', reason: 'decision=skip' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const cmdArgs = [`--title=${result.title}`];
|
|
98
|
+
if (ticketKey) cmdArgs.push(`--ticket=${ticketKey}`);
|
|
99
|
+
// Strip any comma an AI-produced tag might contain — runNoteAdd re-splits
|
|
100
|
+
// --tags on ',', so an un-sanitized tag would silently fork into two.
|
|
101
|
+
const cleanTags = (result.tags ?? []).map(t => String(t).replace(/,/g, '')).filter(Boolean);
|
|
102
|
+
if (cleanTags.length > 0) cmdArgs.push(`--tags=${cleanTags.join(',')}`);
|
|
103
|
+
|
|
104
|
+
const { written } = await runNoteAddFn(cmdArgs, {
|
|
105
|
+
configDir,
|
|
106
|
+
stream: capturingStream(),
|
|
107
|
+
readStdin: () => Promise.resolve(result.body ?? ''),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
logLine(written ? `captured: ${result.title}` : `not-written: ${result.title}`);
|
|
111
|
+
return written ? { outcome: 'captured', title: result.title } : { outcome: 'not-written', title: result.title };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
115
|
+
const [, , transcriptPath, ticketKeyArg] = process.argv;
|
|
116
|
+
runAutoCapture({ transcriptPath, ticketKey: ticketKeyArg || undefined }).catch(() => { /* never escape — nothing is watching this process */ });
|
|
117
|
+
}
|
|
@@ -31,6 +31,15 @@ export const NOTE_ADD_MCP_RE = /^mcp__.+__recall_add$/;
|
|
|
31
31
|
// uppercase ticket-key class required immediately after the command name.
|
|
32
32
|
export const FETCH_RE = /\bticketlens\s+(?:get\s+)?[A-Z][A-Z0-9]{1,9}-\d+\b|\btl\s+(?:get\s+)?[A-Z][A-Z0-9]{1,9}-\d+\b|\/jtb\s+(?:get\s+)?[A-Z][A-Z0-9]{1,9}-\d+\b/;
|
|
33
33
|
export const FETCH_MCP_RE = /^mcp__.+__fetch$/;
|
|
34
|
+
// Matches a real ticket-mutating CLI subcommand (comment/transition/assign/
|
|
35
|
+
// update, each confirmed in cli.mjs's parseCommand() to take TICKET-KEY as
|
|
36
|
+
// the immediate next positional arg, same shape FETCH_RE already assumes),
|
|
37
|
+
// across the ticketlens/tl/jtb invocation forms. Distinguishes real ticket
|
|
38
|
+
// work from a read-only fetch — backlog #24's 6th report: a pure multi-
|
|
39
|
+
// ticket status listing (fetch only, no mutation) still nagged, even though
|
|
40
|
+
// nothing about it could ever satisfy SKILL.md's own capture rule.
|
|
41
|
+
export const MUTATING_ACTION_RE = /\bticketlens\s+(?:comment|transition|assign|update)\s+[A-Z][A-Z0-9]{1,9}-\d+\b|\btl\s+(?:comment|transition|assign|update)\s+[A-Z][A-Z0-9]{1,9}-\d+\b|\/jtb\s+(?:comment|transition|assign|update)\s+[A-Z][A-Z0-9]{1,9}-\d+\b/;
|
|
42
|
+
export const MUTATING_ACTION_MCP_RE = /^mcp__.+__(ticket_comment|ticket_transition|ticket_assign|ticket_update)$/;
|
|
34
43
|
|
|
35
44
|
export function readStdinJson() {
|
|
36
45
|
const raw = fs.readFileSync(0, 'utf8');
|
|
@@ -79,7 +88,7 @@ export const CAPTURE_FRESHNESS_MS = 2 * 60 * 60 * 1000;
|
|
|
79
88
|
* predictable path directly in shared tmp could be pre-planted by another
|
|
80
89
|
* local user on a shared box.
|
|
81
90
|
*/
|
|
82
|
-
function privateTmpDir() {
|
|
91
|
+
export function privateTmpDir() {
|
|
83
92
|
const owner = typeof process.getuid === 'function' ? process.getuid() : os.userInfo().username;
|
|
84
93
|
const dir = path.join(os.tmpdir(), `ticketlens-recall-nudge-${owner}`);
|
|
85
94
|
try {
|
|
@@ -156,6 +165,42 @@ export function hasRecentNag(cwd, now = Date.now()) {
|
|
|
156
165
|
return lastNagAt > 0 && (now - lastNagAt) < CAPTURE_FRESHNESS_MS;
|
|
157
166
|
}
|
|
158
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Cross-session/cross-turn AUTO-CAPTURE-ATTEMPT marker — same shape as
|
|
170
|
+
* lastNagPath/hasRecentNag above, but throttles the autonomous background
|
|
171
|
+
* spawn (recall-auto-capture.mjs) itself, independent of the sync nag below.
|
|
172
|
+
* Needed because Stop fires on every turn-end, not once per session (see
|
|
173
|
+
* recall-nudge-stop.mjs's own doc comment) — without this, a single
|
|
174
|
+
* multi-turn session would spawn the background judge, and its AI-provider
|
|
175
|
+
* spend, once per turn (code review, 2026-09-15). Written optimistically at
|
|
176
|
+
* spawn time, before the child's own outcome is known — a failed/skipped
|
|
177
|
+
* attempt still counts against the window, same accepted trade-off as
|
|
178
|
+
* hasRecentNag's own doc comment above.
|
|
179
|
+
*/
|
|
180
|
+
export function lastAutoCaptureAttemptPath(cwd) {
|
|
181
|
+
const hash = crypto.createHash('sha256').update(cwd || 'unknown').digest('hex').slice(0, 16);
|
|
182
|
+
return path.join(privateTmpDir(), `lastautocapture-${hash}.json`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function readLastAutoCaptureAttemptAt(cwd) {
|
|
186
|
+
try {
|
|
187
|
+
return JSON.parse(fs.readFileSync(lastAutoCaptureAttemptPath(cwd), 'utf8')).lastAttemptAt ?? 0;
|
|
188
|
+
} catch {
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function writeLastAutoCaptureAttemptAt(cwd, timestamp) {
|
|
194
|
+
try {
|
|
195
|
+
fs.writeFileSync(lastAutoCaptureAttemptPath(cwd), JSON.stringify({ lastAttemptAt: timestamp }));
|
|
196
|
+
} catch { /* best-effort — losing this marker only costs one extra spawn next window */ }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function hasRecentAutoCaptureAttempt(cwd, now = Date.now()) {
|
|
200
|
+
const lastAttemptAt = readLastAutoCaptureAttemptAt(cwd);
|
|
201
|
+
return lastAttemptAt > 0 && (now - lastAttemptAt) < CAPTURE_FRESHNESS_MS;
|
|
202
|
+
}
|
|
203
|
+
|
|
159
204
|
/**
|
|
160
205
|
* Reads the transcript (JSONL) and returns simple booleans about what
|
|
161
206
|
* happened this session. Best-effort: any read/parse failure returns all
|
|
@@ -195,7 +240,7 @@ export function hasRecentNag(cwd, now = Date.now()) {
|
|
|
195
240
|
* jtb's fetch was used," so the hook now checks the same thing it backstops.
|
|
196
241
|
*/
|
|
197
242
|
export function scanTranscript(transcriptPath) {
|
|
198
|
-
const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false, sawFetch: false, ticketKey: null };
|
|
243
|
+
const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false, sawFetch: false, sawMutatingAction: false, ticketKey: null };
|
|
199
244
|
let lines;
|
|
200
245
|
try {
|
|
201
246
|
lines = fs.readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
|
|
@@ -244,6 +289,11 @@ export function scanTranscript(transcriptPath) {
|
|
|
244
289
|
const isCliFetch = block.name === 'Bash' && FETCH_RE.test(block.input?.command ?? '');
|
|
245
290
|
const isMcpFetch = FETCH_MCP_RE.test(block.name ?? '');
|
|
246
291
|
if (isCliFetch || isMcpFetch) result.sawFetch = true;
|
|
292
|
+
|
|
293
|
+
const isCliMutation = block.name === 'Bash' && MUTATING_ACTION_RE.test(block.input?.command ?? '');
|
|
294
|
+
const isMcpMutation = MUTATING_ACTION_MCP_RE.test(block.name ?? '');
|
|
295
|
+
const isCodeEdit = block.name === 'Edit' || block.name === 'Write';
|
|
296
|
+
if (isCliMutation || isMcpMutation || isCodeEdit) result.sawMutatingAction = true;
|
|
247
297
|
}
|
|
248
298
|
}
|
|
249
299
|
}
|
|
@@ -270,9 +320,64 @@ export function scanTranscript(transcriptPath) {
|
|
|
270
320
|
* matching SKILL.md's own capture-guidance scope exactly ("unconditionally
|
|
271
321
|
* whenever jtb's fetch was used"), instead of firing on any incidental
|
|
272
322
|
* ticket-key-shaped string.
|
|
323
|
+
*
|
|
324
|
+
* Also gated on `sawMutatingAction` (backlog #24, 6th report) — a pure
|
|
325
|
+
* read-only session (multi-ticket status listing, a report, a lookup) can
|
|
326
|
+
* never satisfy SKILL.md's own 3-part capture rule ("generalizes beyond
|
|
327
|
+
* this diff", "cost real effort to discover") no matter how many fetches
|
|
328
|
+
* ran, so gating on `sawFetch` alone false-positived on exactly that shape.
|
|
329
|
+
* Applies uniformly across all three strictness levels, same blanket
|
|
330
|
+
* treatment as the `sawFetch` gate above — a session with zero mutation
|
|
331
|
+
* has nothing any strictness level would ever ask to capture.
|
|
273
332
|
*/
|
|
274
|
-
export function shouldNag({ sawFetch, sawRecallFlag, sawNoteAdd, recallStrictness = 'balanced' }) {
|
|
333
|
+
export function shouldNag({ sawFetch, sawMutatingAction, sawRecallFlag, sawNoteAdd, recallStrictness = 'balanced' }) {
|
|
275
334
|
if (!sawFetch || sawNoteAdd) return false;
|
|
335
|
+
if (!sawMutatingAction) return false;
|
|
276
336
|
if (recallStrictness === 'loose') return sawRecallFlag; // only the broken-promise case
|
|
277
337
|
return true; // balanced and strict: a fetch with no note is enough
|
|
278
338
|
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Builds a bounded excerpt of this session's assistant-authored text, for
|
|
342
|
+
* the autonomous background auto-capture path (recall-auto-capture.mjs) to
|
|
343
|
+
* hand to the server-side capture-judgment prompt — same assistant-text-only
|
|
344
|
+
* filter scanTranscript() already uses for sawRecallFlag, so this can never
|
|
345
|
+
* leak a user's own pasted content (secrets, unrelated files) that only
|
|
346
|
+
* ever appeared in a user-role transcript entry.
|
|
347
|
+
*
|
|
348
|
+
* Capped at 8000 chars, truncated from the START (keeps the END of the
|
|
349
|
+
* session) — the final synthesized insight is far more likely to be near
|
|
350
|
+
* the end of a session than the beginning, and an unbounded excerpt risks
|
|
351
|
+
* an oversized request body for no benefit.
|
|
352
|
+
*
|
|
353
|
+
* Best-effort: any read/parse failure, or a session with no assistant text
|
|
354
|
+
* at all, returns '' rather than throwing — this runs in a detached
|
|
355
|
+
* background process with nothing watching for an unhandled rejection.
|
|
356
|
+
*/
|
|
357
|
+
export function buildCaptureExcerpt(transcriptPath) {
|
|
358
|
+
let lines;
|
|
359
|
+
try {
|
|
360
|
+
lines = fs.readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
|
|
361
|
+
} catch {
|
|
362
|
+
return '';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const texts = [];
|
|
366
|
+
for (const line of lines) {
|
|
367
|
+
let entry;
|
|
368
|
+
try {
|
|
369
|
+
entry = JSON.parse(line);
|
|
370
|
+
} catch {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (entry.type !== 'assistant') continue;
|
|
374
|
+
const blocks = entry.message?.content;
|
|
375
|
+
if (!Array.isArray(blocks)) continue;
|
|
376
|
+
for (const block of blocks) {
|
|
377
|
+
if (block.type === 'text' && block.text) texts.push(block.text);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const joined = texts.join('\n\n');
|
|
382
|
+
return joined.length > 8000 ? joined.slice(-8000) : joined;
|
|
383
|
+
}
|
|
@@ -40,11 +40,42 @@
|
|
|
40
40
|
* multi-profile user. When no ticket key was seen, or it matches no profile's
|
|
41
41
|
* ticketPrefixes, this falls through to cwd/projectPaths then the default
|
|
42
42
|
* profile, same as before (backlog #12, design spec §6).
|
|
43
|
+
*
|
|
44
|
+
* Autonomous background auto-capture (D1): independent of the blocking nag
|
|
45
|
+
* logic below, a Stop check also fire-and-forgets a detached child
|
|
46
|
+
* (recall-auto-capture.mjs) that judges and captures without any Claude/user
|
|
47
|
+
* involvement — Pro+ only, silently no-ops otherwise (see that file). Spawned
|
|
48
|
+
* detached+unref'd so it survives this process exiting and never counts
|
|
49
|
+
* against this hook's own 5s timeout (hooks-setup.mjs). The two mechanisms
|
|
50
|
+
* can't see each other's outcome (the child hasn't run yet when the sync nag
|
|
51
|
+
* decision below is made) — an occasional double-fire (nag fires, then the
|
|
52
|
+
* background capture lands moments later anyway) is a known, accepted
|
|
53
|
+
* overlap, not a bug: suppressing the sync nag whenever a background attempt
|
|
54
|
+
* is merely *made* would remove the only safety net if that attempt silently
|
|
55
|
+
* fails (network down, no AI provider configured, malformed response).
|
|
56
|
+
*
|
|
57
|
+
* Gated on isLicensed('pro') + a real cliToken, checked HERE (cheap, local,
|
|
58
|
+
* synchronous) rather than only inside the child — avoids forking a Node
|
|
59
|
+
* process for every Stop check for the majority free-tier/logged-out
|
|
60
|
+
* population (code review, 2026-09-15). Also gated on
|
|
61
|
+
* !hasRecentAutoCaptureAttempt(cwd): Stop fires on every turn-end, not once
|
|
62
|
+
* per session, so without this a single multi-turn session would spawn the
|
|
63
|
+
* background judge — and its AI-provider spend — once per turn (same code
|
|
64
|
+
* review). The spawn's own `cwd` option is set to the real session cwd
|
|
65
|
+
* (distinct from this hook process's own cwd) so runNoteAdd()'s internal
|
|
66
|
+
* profile/team resolution — which always reads process.cwd(), no override
|
|
67
|
+
* param exists — resolves correctly inside the child, not just when the two
|
|
68
|
+
* happen to coincide.
|
|
43
69
|
*/
|
|
44
70
|
|
|
45
|
-
import {
|
|
71
|
+
import { spawn } from 'node:child_process';
|
|
72
|
+
import { fileURLToPath } from 'node:url';
|
|
73
|
+
import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, hasRecentNag, writeLastNagAt, hasRecentAutoCaptureAttempt, writeLastAutoCaptureAttemptAt, shouldNag } from './recall-nudge-lib.mjs';
|
|
46
74
|
import { resolveProfile, resolveEffectiveRecallStrictness } from '../scripts/lib/profile-resolver.mjs';
|
|
47
75
|
import { readCliToken } from '../scripts/lib/cli-auth.mjs';
|
|
76
|
+
import { isLicensed } from '../scripts/lib/license.mjs';
|
|
77
|
+
|
|
78
|
+
const AUTO_CAPTURE_SCRIPT = fileURLToPath(new URL('./recall-auto-capture.mjs', import.meta.url));
|
|
48
79
|
|
|
49
80
|
const input = readStdinJson();
|
|
50
81
|
const sessionId = input?.session_id;
|
|
@@ -53,7 +84,20 @@ const cwd = input?.cwd ?? process.cwd();
|
|
|
53
84
|
|
|
54
85
|
if (!sessionId || !transcriptPath) process.exit(0);
|
|
55
86
|
|
|
56
|
-
const { sawFetch, sawRecallFlag, sawNoteAdd, ticketKey } = scanTranscript(transcriptPath);
|
|
87
|
+
const { sawFetch, sawMutatingAction, sawRecallFlag, sawNoteAdd, ticketKey } = scanTranscript(transcriptPath);
|
|
88
|
+
const cliToken = readCliToken();
|
|
89
|
+
|
|
90
|
+
if (isLicensed('pro') && cliToken && !hasRecentAutoCaptureAttempt(cwd)) {
|
|
91
|
+
try {
|
|
92
|
+
writeLastAutoCaptureAttemptAt(cwd, Date.now());
|
|
93
|
+
const child = spawn(process.execPath, [AUTO_CAPTURE_SCRIPT, transcriptPath, ticketKey ?? ''], {
|
|
94
|
+
cwd,
|
|
95
|
+
detached: true,
|
|
96
|
+
stdio: 'ignore',
|
|
97
|
+
});
|
|
98
|
+
child.unref();
|
|
99
|
+
} catch { /* best-effort — never block Stop over this */ }
|
|
100
|
+
}
|
|
57
101
|
|
|
58
102
|
// Refreshed on every check, independent of the once-per-session gate below —
|
|
59
103
|
// a capture that happens AFTER this session already nagged once must still
|
|
@@ -64,10 +108,9 @@ const state = readState(sessionId);
|
|
|
64
108
|
if (state.stopChecked) process.exit(0); // already asked once this session — respect the answer
|
|
65
109
|
|
|
66
110
|
const profile = resolveProfile(ticketKey, { cwd });
|
|
67
|
-
const cliToken = readCliToken();
|
|
68
111
|
const recallStrictness = resolveEffectiveRecallStrictness({ profile, cliToken });
|
|
69
112
|
|
|
70
|
-
if (!shouldNag({ sawFetch, sawRecallFlag, sawNoteAdd, recallStrictness })) {
|
|
113
|
+
if (!shouldNag({ sawFetch, sawMutatingAction, sawRecallFlag, sawNoteAdd, recallStrictness })) {
|
|
71
114
|
process.exit(0); // nothing this strictness level requires a capture for
|
|
72
115
|
}
|
|
73
116
|
|
|
@@ -130,17 +130,23 @@ export function showUpgradePrompt(requiredTier, featureFlag, { stream = process.
|
|
|
130
130
|
const LEMONSQUEEZY_ACTIVATE_URL = 'https://api.lemonsqueezy.com/v1/licenses/activate';
|
|
131
131
|
const LEMONSQUEEZY_VALIDATE_URL = 'https://api.lemonsqueezy.com/v1/licenses/validate';
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
// TL- keys are issued by TicketLens itself (LicenseIssuanceService::KEY_PREFIX), never by
|
|
134
|
+
// LemonSqueezy — sending one to the LemonSqueezy fallback always fails, confusingly (backlog #30).
|
|
135
|
+
const TL_ISSUED_KEY_RE = /^TL-/i;
|
|
136
|
+
|
|
137
|
+
function resolveLicenseUrl(action, fallback, key) {
|
|
134
138
|
const base = process.env.TICKETLENS_API_URL;
|
|
135
|
-
|
|
139
|
+
if (base) return `${base.replace(/\/$/, '')}/v1/licenses/${action}`;
|
|
140
|
+
if (TL_ISSUED_KEY_RE.test(key)) return null;
|
|
141
|
+
return fallback;
|
|
136
142
|
}
|
|
137
143
|
|
|
138
|
-
function resolveActivateUrl() {
|
|
139
|
-
return resolveLicenseUrl('activate', LEMONSQUEEZY_ACTIVATE_URL);
|
|
144
|
+
function resolveActivateUrl(key) {
|
|
145
|
+
return resolveLicenseUrl('activate', LEMONSQUEEZY_ACTIVATE_URL, key);
|
|
140
146
|
}
|
|
141
147
|
|
|
142
|
-
function resolveValidateUrl() {
|
|
143
|
-
return resolveLicenseUrl('validate', LEMONSQUEEZY_VALIDATE_URL);
|
|
148
|
+
function resolveValidateUrl(key) {
|
|
149
|
+
return resolveLicenseUrl('validate', LEMONSQUEEZY_VALIDATE_URL, key);
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
function extractTier(meta) {
|
|
@@ -154,8 +160,16 @@ export async function activateLicense(key, opts = {}) {
|
|
|
154
160
|
const { configDir = DEFAULT_CONFIG_DIR, fetcher = globalThis.fetch, instanceName } = opts;
|
|
155
161
|
const instance = instanceName || os.hostname();
|
|
156
162
|
|
|
163
|
+
const url = resolveActivateUrl(key);
|
|
164
|
+
if (url === null) {
|
|
165
|
+
return {
|
|
166
|
+
success: false,
|
|
167
|
+
error: 'TICKETLENS_API_URL is not set — required to activate a TicketLens-issued (TL-) license key. Set it to your TicketLens backend URL and try again.',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
157
171
|
try {
|
|
158
|
-
const res = await fetcher(
|
|
172
|
+
const res = await fetcher(url, {
|
|
159
173
|
method: 'POST',
|
|
160
174
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
161
175
|
body: JSON.stringify({ license_key: key, instance_name: instance }),
|
|
@@ -194,8 +208,13 @@ export async function revalidateLicense(opts = {}) {
|
|
|
194
208
|
|
|
195
209
|
const instance = instanceName || os.hostname();
|
|
196
210
|
|
|
211
|
+
const url = resolveValidateUrl(license.key);
|
|
212
|
+
if (url === null) {
|
|
213
|
+
return { success: true, tier: license.tier, cached: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
197
216
|
try {
|
|
198
|
-
const res = await fetcher(
|
|
217
|
+
const res = await fetcher(url, {
|
|
199
218
|
method: 'POST',
|
|
200
219
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
201
220
|
body: JSON.stringify({ license_key: license.key, instance_name: instance }),
|
|
@@ -162,3 +162,52 @@ async function cloud({ brief, cliToken, fetcher, timeoutMs }) {
|
|
|
162
162
|
const data = await res.json();
|
|
163
163
|
return data.summary;
|
|
164
164
|
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Autonomous Recall-capture judgment (backlog #24, D1) — cloud-mode only, no
|
|
168
|
+
* BYOK equivalent: the whole point is using the account's existing login
|
|
169
|
+
* token, never a personally-configured API key. Server owns the judgment
|
|
170
|
+
* prompt (mirrors SKILL.md's 3-part capture rule) — this function never
|
|
171
|
+
* sends one, only the raw transcript excerpt, so there's no client-supplied-
|
|
172
|
+
* prompt injection surface on this endpoint.
|
|
173
|
+
*
|
|
174
|
+
* Unlike summarize()/cloud() above, the return value is the raw parsed
|
|
175
|
+
* `{decision, title?, body?, tags?}` object, not unwrapped to a single
|
|
176
|
+
* string — the caller (recall-auto-capture.mjs) needs the structured shape
|
|
177
|
+
* to either no-op (`skip`) or feed straight into runNoteAdd (`capture`).
|
|
178
|
+
*
|
|
179
|
+
* @param {object} opts
|
|
180
|
+
* @param {string} opts.excerpt - bounded assistant-text excerpt (buildCaptureExcerpt)
|
|
181
|
+
* @param {string} [opts.ticketKey]
|
|
182
|
+
* @param {string} opts.cliToken - required, cloud-mode only
|
|
183
|
+
* @param {Function} [opts.fetcher]
|
|
184
|
+
* @param {number} [opts.timeoutMs]
|
|
185
|
+
* @returns {Promise<{decision: 'capture'|'skip', title?: string, body?: string, tags?: string[]}>}
|
|
186
|
+
*/
|
|
187
|
+
export async function autoCapture({ excerpt, ticketKey, cliToken, fetcher = globalThis.fetch, timeoutMs = 30_000 }) {
|
|
188
|
+
if (!cliToken) throw new Error('Not logged in. Run `ticketlens login` first.');
|
|
189
|
+
const payload = { transcript_excerpt: excerpt };
|
|
190
|
+
if (ticketKey) payload.ticket_key = ticketKey;
|
|
191
|
+
|
|
192
|
+
const res = await fetcher(`${apiBase()}/v1/recall/auto-capture`, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
195
|
+
headers: {
|
|
196
|
+
'Content-Type': 'application/json',
|
|
197
|
+
'Authorization': `Bearer ${cliToken}`,
|
|
198
|
+
},
|
|
199
|
+
body: JSON.stringify(payload),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
let detail = '';
|
|
204
|
+
try { detail = (await res.json()).error ?? ''; } catch {}
|
|
205
|
+
if (!detail && res.status === 503) detail = 'No AI provider configured on the backend.';
|
|
206
|
+
if (!detail && res.status === 500) detail = 'Server error (the backend may need `php artisan migrate` after a recent update)';
|
|
207
|
+
const err = new Error(detail || `TicketLens API error ${res.status}`);
|
|
208
|
+
err.status = res.status;
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return res.json();
|
|
213
|
+
}
|