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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.51",
3
+ "version": "0.38.53",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.43.1 -->
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 { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, hasRecentNag, writeLastNagAt, shouldNag } from './recall-nudge-lib.mjs';
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
- function resolveLicenseUrl(action, fallback) {
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
- return base ? `${base.replace(/\/$/, '')}/v1/licenses/${action}` : fallback;
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(resolveActivateUrl(), {
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(resolveValidateUrl(), {
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
+ }