ticketlens 0.39.5 → 0.40.0

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
@@ -1121,6 +1121,7 @@ npm test
1121
1121
  See [ROADMAP.md](ROADMAP.md) for the full plan.
1122
1122
 
1123
1123
  Recently shipped:
1124
+ - **Console: Recall "select all N matching" bulk delete** — Gmail-style banner deletes every note matching your search/filters across all pages, not just the current one
1124
1125
  - **Recall Stop-hook fix** — the end-of-session Recall reminder no longer fires because of file writes; only real ticket writes (comment, transition, assign, update) arm it. Also hardened against malformed transcripts and a session-id path-collision bug found by adversarial testing
1125
1126
  - **Console: no repeat request on same-page menu clicks** — sidebar links, the header gear and Settings tabs skip the request when they point at the page you are on
1126
1127
  - **Worklog** (`ticketlens worklog`, `ticket_worklog` MCP) — log time on Jira tickets, preview first. Pro tier
@@ -16,7 +16,7 @@ import { runSwitch } from '../skills/jtb/scripts/lib/profile-switcher.mjs';
16
16
  import { runProfilesSetTeam } from '../skills/jtb/scripts/lib/profile-set-team.mjs';
17
17
  import { run as runConfig } from '../skills/jtb/scripts/lib/config-wizard.mjs';
18
18
  import { activateLicense, checkLicense, revalidateIfStale, isLicensed, showUpgradePrompt, readLicense } from '../skills/jtb/scripts/lib/license.mjs';
19
- import { deleteProfile, loadProfiles, saveCredentialKey, resolveRecallStrictnessTarget, saveProfileRecallStrictness, RECALL_STRICTNESS_LEVELS } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
19
+ import { deleteProfile, loadProfiles, saveCredentialKey, resolveRecallStrictnessTarget, saveProfileRecallStrictness, RECALL_STRICTNESS_LEVELS, saveErrorReportingConsent } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
20
20
  import { run as runCache } from '../skills/jtb/scripts/lib/cache-manager.mjs';
21
21
  import { runDoctor } from '../skills/jtb/scripts/lib/doctor-command.mjs';
22
22
  import {
@@ -43,6 +43,16 @@ import { incrementInvocation, incrementCommand } from '../skills/jtb/scripts/lib
43
43
  import { DEFAULT_CONFIG_DIR } from '../skills/jtb/scripts/lib/config.mjs';
44
44
  import { checkTeamJiraConfigUpdate } from '../skills/jtb/scripts/lib/team-jira-sync.mjs';
45
45
  import { maybeAutoFlush } from '../skills/jtb/scripts/lib/recall-queue.mjs';
46
+ import { maybeReportError } from '../skills/jtb/scripts/lib/error-reporter.mjs';
47
+
48
+ // Fire-and-forget (49e) — never awaited by a caller, mirrors checkForUpdate's
49
+ // own fire-and-forget style. maybeReportError already self-catches
50
+ // everything internally; the extra .catch(() => {}) here is a second,
51
+ // outermost safety net so this can never produce an unhandled rejection
52
+ // regardless of what changes inside that function later.
53
+ function reportError(err, command) {
54
+ maybeReportError(err, command).catch(() => {});
55
+ }
46
56
 
47
57
  const TRACKED_COMMANDS = new Set([
48
58
  'triage', 'fetch', 'get', 'compliance', 'review', 'standup',
@@ -121,6 +131,7 @@ switch (command) {
121
131
  }).catch(err => {
122
132
  process.stderr.write(`Error: ${err.message}\n`);
123
133
  process.exitCode = 1;
134
+ reportError(err, 'fetch');
124
135
  });
125
136
  break;
126
137
  }
@@ -129,6 +140,7 @@ switch (command) {
129
140
  runTriage(cmdArgs).catch(err => {
130
141
  process.stderr.write(`Error: ${err.message}\n`);
131
142
  process.exitCode = 1;
143
+ reportError(err, 'triage');
132
144
  });
133
145
  break;
134
146
 
@@ -138,6 +150,7 @@ switch (command) {
138
150
  runCollisions(cmdArgs).catch(err => {
139
151
  process.stderr.write(`Error: ${err.message}\n`);
140
152
  process.exitCode = 1;
153
+ reportError(err, 'collisions');
141
154
  });
142
155
  break;
143
156
  }
@@ -147,6 +160,7 @@ switch (command) {
147
160
  runHistory(cmdArgs).catch(err => {
148
161
  process.stderr.write(`Error: ${err.message}\n`);
149
162
  process.exitCode = 1;
163
+ reportError(err, 'history');
150
164
  });
151
165
  break;
152
166
  }
@@ -156,6 +170,7 @@ switch (command) {
156
170
  runStats(cmdArgs).catch(err => {
157
171
  process.stderr.write(`Error: ${err.message}\n`);
158
172
  process.exitCode = 1;
173
+ reportError(err, 'stats');
159
174
  });
160
175
  break;
161
176
  }
@@ -165,6 +180,7 @@ switch (command) {
165
180
  runIssueTypes(cmdArgs).catch(err => {
166
181
  process.stderr.write(`Error: ${err.message}\n`);
167
182
  process.exitCode = 1;
183
+ reportError(err, 'issue-types');
168
184
  });
169
185
  break;
170
186
  }
@@ -176,6 +192,7 @@ switch (command) {
176
192
  }).catch(err => {
177
193
  process.stderr.write(`Error: ${err.message}\n`);
178
194
  process.exitCode = 1;
195
+ reportError(err, 'doctor');
179
196
  });
180
197
  break;
181
198
  }
@@ -185,6 +202,7 @@ switch (command) {
185
202
  runInit().catch(err => {
186
203
  process.stderr.write(`Error: ${err.message}\n`);
187
204
  process.exitCode = 1;
205
+ reportError(err, 'init');
188
206
  });
189
207
  break;
190
208
 
@@ -193,6 +211,7 @@ switch (command) {
193
211
  runSwitch().catch(err => {
194
212
  process.stderr.write(`Error: ${err.message}\n`);
195
213
  process.exitCode = 1;
214
+ reportError(err, 'switch');
196
215
  });
197
216
  break;
198
217
 
@@ -219,6 +238,19 @@ switch (command) {
219
238
  break;
220
239
  }
221
240
 
241
+ if (cmdArgs[0] === 'set' && cmdArgs[1] === 'errorReporting') {
242
+ const s = createStyler({ isTTY: process.stdout.isTTY });
243
+ const value = cmdArgs[2];
244
+ if (value !== 'on' && value !== 'off') {
245
+ process.stderr.write(`${s.red('✖')} Missing or invalid value.\n Usage: ticketlens config set errorReporting <on|off>\n`);
246
+ process.exitCode = 1;
247
+ break;
248
+ }
249
+ saveErrorReportingConsent(value === 'on', DEFAULT_CONFIG_DIR);
250
+ process.stdout.write(` ${s.green('✔')} Error reporting ${s.bold(s.cyan(value))}\n`);
251
+ break;
252
+ }
253
+
222
254
  if (cmdArgs[0] === 'set' && cmdArgs[1] === 'recallStrictness') {
223
255
  const s = createStyler({ isTTY: process.stdout.isTTY });
224
256
  const value = cmdArgs[2];
@@ -265,6 +297,7 @@ switch (command) {
265
297
  })().catch(err => {
266
298
  process.stderr.write(`Error: ${err.message}\n`);
267
299
  process.exitCode = 1;
300
+ reportError(err, 'config');
268
301
  });
269
302
  break;
270
303
  }
@@ -290,6 +323,7 @@ switch (command) {
290
323
  }).catch(err => {
291
324
  process.stderr.write(`\n ${s.red('✖')} Activation error: ${err.message}\n\n`);
292
325
  process.exitCode = 1;
326
+ reportError(err, 'activate');
293
327
  });
294
328
  break;
295
329
  }
@@ -408,6 +442,7 @@ switch (command) {
408
442
  runProfilesSetTeam(profileName, teamName).catch(err => {
409
443
  process.stderr.write(`Error: ${err.message}\n`);
410
444
  process.exitCode = 1;
445
+ reportError(err, 'profiles');
411
446
  });
412
447
  break;
413
448
  }
@@ -422,6 +457,7 @@ switch (command) {
422
457
  runCache(cmdArgs).catch(err => {
423
458
  process.stderr.write(`Error: ${err.message}\n`);
424
459
  process.exitCode = 1;
460
+ reportError(err, 'cache');
425
461
  });
426
462
  break;
427
463
 
@@ -489,6 +525,7 @@ switch (command) {
489
525
  runFetch(['install-hooks', ...cmdArgs]).catch(err => {
490
526
  process.stderr.write(`Error: ${err.message}\n`);
491
527
  process.exitCode = 1;
528
+ reportError(err, 'install-hooks');
492
529
  });
493
530
  break;
494
531
 
@@ -497,6 +534,7 @@ switch (command) {
497
534
  runFetch(['pr', ...cmdArgs]).catch(err => {
498
535
  process.stderr.write(`Error: ${err.message}\n`);
499
536
  process.exitCode = 1;
537
+ reportError(err, 'pr');
500
538
  });
501
539
  break;
502
540
 
@@ -505,6 +543,7 @@ switch (command) {
505
543
  runFetch(['review', ...cmdArgs]).catch(err => {
506
544
  process.stderr.write(`Error: ${err.message}\n`);
507
545
  process.exitCode = 1;
546
+ reportError(err, 'review');
508
547
  });
509
548
  break;
510
549
 
@@ -513,6 +552,7 @@ switch (command) {
513
552
  runFetch(['standup', ...cmdArgs]).catch(err => {
514
553
  process.stderr.write(`Error: ${err.message}\n`);
515
554
  process.exitCode = 1;
555
+ reportError(err, 'standup');
516
556
  });
517
557
  break;
518
558
 
@@ -521,6 +561,7 @@ switch (command) {
521
561
  runFetch(['ledger', ...cmdArgs]).catch(err => {
522
562
  process.stderr.write(`Error: ${err.message}\n`);
523
563
  process.exitCode = 1;
564
+ reportError(err, 'ledger');
524
565
  });
525
566
  break;
526
567
 
@@ -529,6 +570,7 @@ switch (command) {
529
570
  runFetch(['compliance', ...cmdArgs]).catch(err => {
530
571
  process.stderr.write(`Error: ${err.message}\n`);
531
572
  process.exitCode = 1;
573
+ reportError(err, 'compliance');
532
574
  });
533
575
  break;
534
576
 
@@ -549,6 +591,7 @@ switch (command) {
549
591
  })().catch(err => {
550
592
  process.stderr.write(`Error: ${err.message}\n`);
551
593
  process.exitCode = 1;
594
+ reportError(err, 'login');
552
595
  });
553
596
  break;
554
597
  }
@@ -589,6 +632,7 @@ switch (command) {
589
632
  })().catch(err => {
590
633
  process.stderr.write(`Error: ${err.message}\n`);
591
634
  process.exitCode = 1;
635
+ reportError(err, 'sync');
592
636
  });
593
637
  break;
594
638
  }
@@ -707,6 +751,7 @@ switch (command) {
707
751
  })().catch(err => {
708
752
  process.stderr.write(`Error: ${err.message}\n`);
709
753
  process.exitCode = 1;
754
+ reportError(err, 'cloud-keys');
710
755
  });
711
756
  break;
712
757
  }
@@ -720,6 +765,7 @@ switch (command) {
720
765
  }).catch(err => {
721
766
  process.stderr.write(`Error: ${err.message}\n`);
722
767
  process.exitCode = 1;
768
+ reportError(err, 'note');
723
769
  });
724
770
  break;
725
771
  }
@@ -730,6 +776,7 @@ switch (command) {
730
776
  }).catch(err => {
731
777
  process.stderr.write(`Error: ${err.message}\n`);
732
778
  process.exitCode = 1;
779
+ reportError(err, 'note');
733
780
  });
734
781
  break;
735
782
  }
@@ -740,6 +787,7 @@ switch (command) {
740
787
  }).catch(err => {
741
788
  process.stderr.write(`Error: ${err.message}\n`);
742
789
  process.exitCode = 1;
790
+ reportError(err, 'note');
743
791
  });
744
792
  break;
745
793
  }
@@ -757,6 +805,7 @@ switch (command) {
757
805
  }).catch(err => {
758
806
  process.stderr.write(`Error: ${err.message}\n`);
759
807
  process.exitCode = 1;
808
+ reportError(err, 'recall');
760
809
  });
761
810
  break;
762
811
  }
@@ -767,6 +816,7 @@ switch (command) {
767
816
  }).catch(err => {
768
817
  process.stderr.write(`Error: ${err.message}\n`);
769
818
  process.exitCode = 1;
819
+ reportError(err, 'recall');
770
820
  });
771
821
  break;
772
822
  }
@@ -776,6 +826,7 @@ switch (command) {
776
826
  }).catch(err => {
777
827
  process.stderr.write(`Error: ${err.message}\n`);
778
828
  process.exitCode = 1;
829
+ reportError(err, 'recall');
779
830
  });
780
831
  break;
781
832
  }
@@ -803,6 +854,7 @@ switch (command) {
803
854
  }).catch(err => {
804
855
  process.stderr.write(`Error: ${err.message}\n`);
805
856
  process.exitCode = 1;
857
+ reportError(err, 'comment');
806
858
  });
807
859
  break;
808
860
  }
@@ -819,6 +871,7 @@ switch (command) {
819
871
  }).catch(err => {
820
872
  process.stderr.write(`Error: ${err.message}\n`);
821
873
  process.exitCode = 1;
874
+ reportError(err, 'transition');
822
875
  });
823
876
  break;
824
877
  }
@@ -831,6 +884,7 @@ switch (command) {
831
884
  }).catch(err => {
832
885
  process.stderr.write(`Error: ${err.message}\n`);
833
886
  process.exitCode = 1;
887
+ reportError(err, 'assign');
834
888
  });
835
889
  break;
836
890
  }
@@ -843,6 +897,7 @@ switch (command) {
843
897
  }).catch(err => {
844
898
  process.stderr.write(`Error: ${err.message}\n`);
845
899
  process.exitCode = 1;
900
+ reportError(err, 'worklog');
846
901
  });
847
902
  break;
848
903
  }
@@ -855,6 +910,7 @@ switch (command) {
855
910
  }).catch(err => {
856
911
  process.stderr.write(`Error: ${err.message}\n`);
857
912
  process.exitCode = 1;
913
+ reportError(err, 'duplicates');
858
914
  });
859
915
  break;
860
916
  }
@@ -871,6 +927,7 @@ switch (command) {
871
927
  }).catch(err => {
872
928
  process.stderr.write(`Error: ${err.message}\n`);
873
929
  process.exitCode = 1;
930
+ reportError(err, 'link');
874
931
  });
875
932
  break;
876
933
  }
@@ -883,6 +940,7 @@ switch (command) {
883
940
  }).catch(err => {
884
941
  process.stderr.write(`Error: ${err.message}\n`);
885
942
  process.exitCode = 1;
943
+ reportError(err, 'update');
886
944
  });
887
945
  break;
888
946
  }
@@ -895,6 +953,7 @@ switch (command) {
895
953
  }).catch(err => {
896
954
  process.stderr.write(`Error: ${err.message}\n`);
897
955
  process.exitCode = 1;
956
+ reportError(err, 'create');
898
957
  });
899
958
  break;
900
959
  }
@@ -916,6 +975,7 @@ switch (command) {
916
975
  })().catch(err => {
917
976
  process.stderr.write(`Error: ${err.message}\n`);
918
977
  process.exitCode = 1;
978
+ reportError(err, command);
919
979
  });
920
980
  break;
921
981
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.39.5",
3
+ "version": "0.40.0",
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.44.1 -->
1
+ <!-- jtb-skill-version: 0.45.0 -->
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.
@@ -376,6 +376,15 @@ echo "Retry client swallowed 429s silently; added backoff and a warning log on t
376
376
 
377
377
  **Attaching local files.** `note add --attach=path1,path2` (Pro) saves a screenshot or file alongside the note — same 10 MB/file, 50 MB/call, 20-file caps as `ticket_create`/`ticket_comment`'s `--attach` for the local save. If this account is entitled and the note syncs to a team (Team Recall sync active), the attachment syncs with it — visible and downloadable from Console > Admin > Recall, not just text-only. The sync path has a lower 12 MB/call cap than the local save (bounded by the backend's request-size limit, not the CLI). Going over it fails the whole push, not just the attachment — the note stays saved locally, but neither its text nor the attachment reaches the team until pushed within the cap. Attachments with plain-text content (checked by actually decoding the bytes, not by filename extension — renaming a text file to `.png` doesn't skip this) go through the same secret scan as the note body before syncing; a rejected scan blocks the whole push the same way. The `recall_add` MCP tool has a matching `attachments` array parameter — prefer it over the bash form when available, same rule as the rest of this section.
378
378
 
379
+ **Attaching a browser screenshot.** No separate capture path exists — `--attach` already takes any local file path, and a browser-automation tool's screenshot action (e.g. Chrome DevTools MCP's `take_screenshot`, Playwright's `browser_take_screenshot`) writes to a path you choose when you pass it a `filePath`/output-path argument. Save there, then attach it like any other file:
380
+ ```bash
381
+ # 1. take_screenshot(filePath="/tmp/bug.png") via your browser-automation tool
382
+ # 2. attach the saved file to a Recall note
383
+ echo "Layout breaks below 768px — sidebar overlaps content." | \
384
+ ticketlens note add --title="Responsive layout bug" --ticket=TICKET-KEY --attach=/tmp/bug.png
385
+ ```
386
+ Same caps, same secret scan, same team-sync behavior as any other `--attach` file above — there is nothing screenshot-specific to configure.
387
+
379
388
  To search saved notes directly (outside of automatic brief injection): `ticketlens recall "<query>"`.
380
389
 
381
390
  **Pick exactly one path per capture — never both.** If this harness has TicketLens's MCP server configured (tools named `recall_add`/`recall_search` — often shown as `mcp__ticketlens__recall_add` — visible in your tool list), **use those tools, not the bash commands above** — same license gate, same secret scan, same vault, same team sync, just no shell command to construct. Only fall back to the bash form when the MCP tools are genuinely absent from your tool list. If they're absent because this project has never registered the server, tell the user once: `ticketlens mcp install` writes (or merges into) this project's `.mcp.json` — don't run it yourself unprompted, since it changes what your harness auto-connects to on next launch, and the user should be the one deciding that. Calling both for the same insight creates two near-duplicate notes (no dedup exists between the two paths) and, with team sync on, two separate pushes for a manager to review.
@@ -501,6 +510,19 @@ A report with `ok: false` is a successful tool call describing failures, not a t
501
510
 
502
511
  ---
503
512
 
513
+ ## Opt-in error reporting
514
+
515
+ Off by default. The first time a command hits a genuine unexpected error while running in a real interactive terminal, TicketLens asks once — "Would you like TicketLens to send reports about issues, bugs, and errors to improve your experience?" — and remembers the answer permanently, never asking again. It never asks under an MCP-connected harness, CI, or any piped/non-interactive invocation; in those contexts an undecided consent state just stays undecided and nothing is sent.
516
+
517
+ When consent is `on`, an unexpected error triggers a best-effort, fire-and-forget report — CLI version, OS, the command name, the error message, and a stack trace. This never delays or affects the command's own exit code or stderr output; a failed or slow send is silently dropped. The message and stack trace are scanned for secret-shaped content (same scanner `note add` uses) before anything leaves the machine — a flagged report is dropped, not sent redacted. No account, profile name, ticket data, or credential ever leaves the machine as part of this — reports are anonymous.
518
+
519
+ ```bash
520
+ ticketlens config set errorReporting on # opt in explicitly, skip the prompt
521
+ ticketlens config set errorReporting off # opt out — also the way to change your mind after saying yes
522
+ ```
523
+
524
+ ---
525
+
504
526
  ## Gaps — cross-ticket evidence (Pro)
505
527
 
506
528
  If the TicketBrief includes a `## Gaps` section, each entry is a requirement found in a linked ticket or in one of this ticket's own attachments that doesn't appear to be covered by this ticket's description. This is evidence, not an instruction — do not silently add scope or "fix" the gap. Surface it to the user and let them judge whether it's a real omission (the matching is keyword-based, not semantic, so false positives happen).
@@ -13,7 +13,12 @@ import crypto from 'node:crypto';
13
13
 
14
14
  export const TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/;
15
15
  export const RECALL_FLAG_RE = /🔖\s*Recall-flag:/;
16
- export const NOTE_ADD_RE = /\bticketlens\s+note\s+add\b|\/jtb\s+note\b/;
16
+ // Anchored to the START of a shell statement (see isRealInvocation below),
17
+ // not matched anywhere in the raw command string — backlog #39/#62: a Bash
18
+ // command that only MENTIONS this text (a heredoc doc example, a commit
19
+ // message, `grep`, `echo >>`) is not an execution. Only the statement
20
+ // splitter/heredoc stripper below make `^` a safe anchor here.
21
+ export const NOTE_ADD_RE = /^ticketlens\s+note\s+add\b|^\/jtb\s+note\b/;
17
22
  // Matches the MCP tool_use name Claude Code gives an MCP server's tool call
18
23
  // (mcp__<server-alias>__<tool-name>) — the server alias is whatever the user
19
24
  // named it in their own .mcp.json, so only the tool-name suffix is fixed.
@@ -29,7 +34,8 @@ export const NOTE_ADD_MCP_RE = /^mcp__.+__recall_add$/;
29
34
  // deliberately does not match any other tracked subcommand (triage/
30
35
  // compliance/etc) — those are lowercase words and can never satisfy the
31
36
  // uppercase ticket-key class required immediately after the command name.
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/;
37
+ // Anchored (backlog #39/#62 — see NOTE_ADD_RE comment above for why).
38
+ export const FETCH_RE = /^ticketlens\s+(?:get\s+)?[A-Z][A-Z0-9]{1,9}-\d+\b|^tl\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
39
  export const FETCH_MCP_RE = /^mcp__.+__fetch$/;
34
40
  // Matches a real ticket-mutating CLI subcommand (comment/transition/assign/
35
41
  // update, each confirmed in cli.mjs's parseCommand() to take TICKET-KEY as
@@ -43,9 +49,211 @@ export const FETCH_MCP_RE = /^mcp__.+__fetch$/;
43
49
  // nags were armed solely by the assistant writing memory files or scratch
44
50
  // comment drafts, which are not ticket writes and can live anywhere, so no
45
51
  // path filter can tell them apart from real source edits reliably.
46
- 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/;
52
+ // Anchored (backlog #39/#62 — see NOTE_ADD_RE comment above for why).
53
+ export const MUTATING_ACTION_RE = /^ticketlens\s+(?:comment|transition|assign|update)\s+[A-Z][A-Z0-9]{1,9}-\d+\b|^tl\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/;
47
54
  export const MUTATING_ACTION_MCP_RE = /^mcp__.+__(ticket_comment|ticket_transition|ticket_assign|ticket_update)$/;
48
55
 
56
+ // Real heredoc introducer only: `(?<!<)` / `(?!<)` reject a `<<` that's part
57
+ // of a `<<<` here-string (code review finding — `diff <<<foo <<<bar &&
58
+ // ticketlens comment KEY` was misread as a heredoc start, swallowing the
59
+ // real trailing `&&` command into a fake "body" that ran to end-of-string).
60
+ // Group 1 captures a literal `-` (the `<<-DELIM` variant, whose terminator
61
+ // line may be tab-indented) vs. plain `<<DELIM` (terminator must be exact,
62
+ // 2nd-round review finding). Group 2 is the optional quote, group 3 the
63
+ // delimiter word, `\2` closes the same quote.
64
+ const HEREDOC_MARKER_RE = /(?<!<)<<(-)?~?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/g;
65
+
66
+ // Crude but sufficient: counts unmatched literal "((" before `index`. A real
67
+ // heredoc redirect is never nested inside arithmetic evaluation, so a `<<`
68
+ // found while this is > 0 is `$((1 << FOO))`-style bit-shift, not a heredoc
69
+ // (code review finding). Single-paren subshells (`(cmd <<EOF ...)`) don't
70
+ // register here — only a literal "((" pair does — so a real heredoc inside
71
+ // a plain subshell is unaffected. Quote-aware (2nd-round review finding):
72
+ // `echo "((" && cat <<EOF` must not count the quoted "((" as arithmetic —
73
+ // doing so skipped a REAL heredoc, leaving its body unstripped and readable
74
+ // as a fake statement (the exact false-positive class this whole fix set
75
+ // out to close).
76
+ function isInsideArithmeticContext(command, index) {
77
+ let depth = 0;
78
+ let quote = null;
79
+ for (let i = 0; i < index - 1; i++) {
80
+ const ch = command[i];
81
+ if (quote) {
82
+ if (ch === quote && command[i - 1] !== '\\') quote = null;
83
+ continue;
84
+ }
85
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
86
+ if (ch === '(' && command[i + 1] === '(') { depth++; i++; }
87
+ else if (ch === ')' && command[i + 1] === ')') { depth = Math.max(0, depth - 1); i++; }
88
+ }
89
+ return depth > 0;
90
+ }
91
+
92
+ /**
93
+ * Strips heredoc bodies (`<<EOF ... EOF`, `<<-EOF`, `<<'EOF'`, `<<"EOF"`)
94
+ * out of a shell command string, keeping everything else. Runs BEFORE
95
+ * splitShellStatements() — without this, a doc-example heredoc line that
96
+ * itself starts with "ticketlens ..." is indistinguishable from a real
97
+ * invocation once newline-split into its own statement (backlog #39/#62).
98
+ * Only the heredoc's introducer (`<<...DELIM`) and body are removed; text
99
+ * before/after is untouched. Best-effort: an unterminated heredoc marker
100
+ * strips to end-of-string rather than throwing.
101
+ *
102
+ * Handles multiple `<<DELIM` markers on one command line (`cat <<A <<B`) —
103
+ * bash fills their bodies in the order the markers appear, both AFTER the
104
+ * line's newline (code review finding: a naive single-marker-at-a-time scan
105
+ * left the second body's text, e.g. a "ticketlens comment KEY" doc line,
106
+ * un-stripped and readable as a fake statement).
107
+ *
108
+ * Accepted scope limit (2nd-round review finding, not fixed): a delimiter
109
+ * with a hyphen or other non-identifier character (`<<'MY-DELIM'`) is not
110
+ * recognized as a heredoc marker at all, so that body stays unstripped.
111
+ * Real-world Bash tool calls essentially never use such a delimiter — a
112
+ * plain `EOF`/`END`/`SCRIPT`-shaped word is standard practice — so this is
113
+ * left as-is rather than widening the character class for marginal benefit.
114
+ */
115
+ export function stripHeredocs(command) {
116
+ let result = '';
117
+ let cursor = 0;
118
+
119
+ while (cursor < command.length) {
120
+ HEREDOC_MARKER_RE.lastIndex = cursor;
121
+ const match = HEREDOC_MARKER_RE.exec(command);
122
+ if (!match) {
123
+ result += command.slice(cursor);
124
+ break;
125
+ }
126
+
127
+ if (isInsideArithmeticContext(command, match.index)) {
128
+ // Not a real heredoc marker — keep it as-is, keep scanning after it.
129
+ result += command.slice(cursor, match.index + match[0].length);
130
+ cursor = match.index + match[0].length;
131
+ continue;
132
+ }
133
+
134
+ // Collect every further <<DELIM marker on this SAME command line —
135
+ // their bodies are filled in order, right after the line ends.
136
+ const lineEnd = command.indexOf('\n', match.index + match[0].length);
137
+ const lineBoundary = lineEnd === -1 ? command.length : lineEnd;
138
+ const markers = [{ delim: match[3], dash: Boolean(match[1]) }];
139
+ let scanPos = match.index + match[0].length;
140
+ HEREDOC_MARKER_RE.lastIndex = scanPos;
141
+ let next;
142
+ while (scanPos < lineBoundary && (next = HEREDOC_MARKER_RE.exec(command)) && next.index < lineBoundary) {
143
+ if (!isInsideArithmeticContext(command, next.index)) markers.push({ delim: next[3], dash: Boolean(next[1]) });
144
+ scanPos = next.index + next[0].length;
145
+ HEREDOC_MARKER_RE.lastIndex = scanPos;
146
+ }
147
+
148
+ // Keep the command line itself (through its newline) — only the bodies
149
+ // that follow are stripped.
150
+ const afterLine = lineEnd === -1 ? command.length : lineEnd + 1;
151
+ result += command.slice(cursor, afterLine);
152
+
153
+ let bodyPos = afterLine;
154
+ for (const { delim, dash } of markers) {
155
+ // Delimiter is always [A-Za-z_][A-Za-z0-9_]* (HEREDOC_MARKER_RE's own
156
+ // capture class) — never a regex metacharacter. Escaped anyway, purely
157
+ // defensive, in case that class is ever widened (2nd-round review
158
+ // finding: this is deliberately a no-op today, not dead code to trim).
159
+ const escapedDelim = delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
160
+ // Real bash terminator rules (2nd-round review finding — the previous
161
+ // `^[ \t]*delim[ \t]*$` was too lenient both directions): plain
162
+ // `<<DELIM` requires the line to be EXACTLY the delimiter, no leading
163
+ // or trailing whitespace; `<<-DELIM` allows leading TABS only to be
164
+ // stripped, never spaces, and still no trailing whitespace.
165
+ const terminatorRe = dash
166
+ ? new RegExp(`^\\t*${escapedDelim}$`, 'm')
167
+ : new RegExp(`^${escapedDelim}$`, 'm');
168
+ const rest = command.slice(bodyPos);
169
+ const termMatch = terminatorRe.exec(rest);
170
+ bodyPos = termMatch ? bodyPos + termMatch.index + termMatch[0].length : command.length;
171
+ }
172
+
173
+ cursor = bodyPos;
174
+ }
175
+
176
+ return result;
177
+ }
178
+
179
+ /**
180
+ * Splits a shell command into top-level statements on &&, ||, ;, |, and
181
+ * newline — but NOT when those separators appear inside a single- or
182
+ * double-quoted string. This is what makes anchoring FETCH_RE/MUTATING_
183
+ * ACTION_RE/NOTE_ADD_RE with `^` safe: a mention inside a quoted argument to
184
+ * `echo`/`grep`/`git commit -m` never becomes its own statement, because the
185
+ * quote keeps it attached to the statement's real leading command (echo/
186
+ * grep/git), which does not match. Not a full shell parser — no backslash-
187
+ * escape handling inside quotes beyond a trailing-quote check, no command
188
+ * substitution awareness — deliberately minimal for this narrow, low-stakes
189
+ * use (see scanTranscript()'s doc comment on the ceiling of a false match
190
+ * here: one local Stop-hook decision, never credentials or ticket data).
191
+ */
192
+ export function splitShellStatements(command) {
193
+ const statements = [];
194
+ let current = '';
195
+ let quote = null;
196
+ for (let i = 0; i < command.length; i++) {
197
+ const ch = command[i];
198
+ if (quote) {
199
+ current += ch;
200
+ if (ch === quote && command[i - 1] !== '\\') quote = null;
201
+ continue;
202
+ }
203
+ if (ch === "'" || ch === '"') {
204
+ quote = ch;
205
+ current += ch;
206
+ continue;
207
+ }
208
+ if ((ch === '&' && command[i + 1] === '&') || (ch === '|' && command[i + 1] === '|')) {
209
+ statements.push(current);
210
+ current = '';
211
+ i++;
212
+ continue;
213
+ }
214
+ if (ch === ';' || ch === '|' || ch === '\n') {
215
+ statements.push(current);
216
+ current = '';
217
+ continue;
218
+ }
219
+ current += ch;
220
+ }
221
+ if (current) statements.push(current);
222
+ return statements.map((s) => s.trim()).filter(Boolean);
223
+ }
224
+
225
+ /**
226
+ * Strips a leading subshell paren, env-var assignment(s), and a
227
+ * sudo/exec/command prefix from a single statement, so `cd x && FOO=bar
228
+ * sudo ticketlens comment KEY` still anchors correctly on `ticketlens`.
229
+ */
230
+ export function stripLeadingNoise(statement) {
231
+ let s = statement.replace(/^\(+\s*/, '');
232
+ s = s.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '');
233
+ s = s.replace(/^(?:sudo|exec|command)\s+/, '');
234
+ return s;
235
+ }
236
+
237
+ /**
238
+ * The command's real shell statements, heredoc-stripped and leading-noise-
239
+ * stripped — the shared prep step behind isRealInvocation(). scanTranscript()
240
+ * computes this ONCE per Bash block and reuses it for all three regex checks
241
+ * (NOTE_ADD/FETCH/MUTATING) instead of re-parsing the same command string
242
+ * three times (code review finding — DRY/perf).
243
+ */
244
+ export function realInvocationStatements(command) {
245
+ return splitShellStatements(stripHeredocs(command)).map(stripLeadingNoise);
246
+ }
247
+
248
+ /**
249
+ * True if `command` contains a real shell statement whose start matches
250
+ * `anchoredRe` (one of FETCH_RE/MUTATING_ACTION_RE/NOTE_ADD_RE above) — not
251
+ * merely a substring mention anywhere in the raw string (backlog #39/#62).
252
+ */
253
+ export function isRealInvocation(command, anchoredRe) {
254
+ return realInvocationStatements(command).some((stmt) => anchoredRe.test(stmt));
255
+ }
256
+
49
257
  export function readStdinJson() {
50
258
  const raw = fs.readFileSync(0, 'utf8');
51
259
  try {
@@ -65,18 +273,39 @@ export function statePath(sessionId) {
65
273
  return path.join(os.tmpdir(), `ticketlens-recall-nudge-${safe}.json`);
66
274
  }
67
275
 
276
+ // Cheap, non-atomic fast-path read — safe only as an optimization (skip the
277
+ // rest of the hook's work once a session has already nagged), never as the
278
+ // sole gate: readState()-then-decide-then-write left a TOCTOU race where
279
+ // concurrent Stop hooks for the same session_id could both read "not yet
280
+ // checked" before either wrote (backlog #40). claimStopNag() below is the
281
+ // actual correctness gate for the nag decision itself.
68
282
  export function readState(sessionId) {
69
283
  try {
70
284
  return JSON.parse(fs.readFileSync(statePath(sessionId), 'utf8'));
71
285
  } catch {
72
- return { ticketToolCalls: 0, lastNudgeAt: 0 };
286
+ return { stopChecked: false };
73
287
  }
74
288
  }
75
289
 
76
- export function writeState(sessionId, state) {
290
+ /**
291
+ * Atomically claims the one-time "we are nagging this session" slot.
292
+ * Returns true only for the single caller that wins; every other caller —
293
+ * a concurrent Stop hook for the same session_id (backlog #40), or a later
294
+ * Stop event in the same session that already nagged — gets false and must
295
+ * not nag. Uses `wx` (O_CREAT|O_EXCL): a single atomic syscall, so there is
296
+ * no read-then-write window for two processes to both see "unclaimed".
297
+ * Fails open (returns true) on anything other than EEXIST — matches the
298
+ * rest of this file's best-effort philosophy: a filesystem error here must
299
+ * never be the reason the Stop hook silently stops nagging.
300
+ */
301
+ export function claimStopNag(sessionId) {
77
302
  try {
78
- fs.writeFileSync(statePath(sessionId), JSON.stringify(state));
79
- } catch { /* best-effort — a lost nudge counter is not worth failing the hook over */ }
303
+ fs.writeFileSync(statePath(sessionId), JSON.stringify({ stopChecked: true }), { flag: 'wx' });
304
+ return true;
305
+ } catch (err) {
306
+ if (err && err.code === 'EEXIST') return false;
307
+ return true;
308
+ }
80
309
  }
81
310
 
82
311
  // Two hours of IDLE time — how long a real capture in one directory counts as
@@ -296,15 +525,19 @@ export function scanTranscript(transcriptPath) {
296
525
  result.sawRecallFlag = true;
297
526
  }
298
527
  if (block.type === 'tool_use') {
299
- const isCliNoteAdd = block.name === 'Bash' && NOTE_ADD_RE.test(block.input?.command ?? '');
528
+ // Parsed once per Bash block, reused for all three checks below
529
+ // (code review finding — was re-parsing the same command 3x).
530
+ const cliStatements = block.name === 'Bash' ? realInvocationStatements(block.input?.command ?? '') : null;
531
+
532
+ const isCliNoteAdd = cliStatements && cliStatements.some((s) => NOTE_ADD_RE.test(s));
300
533
  const isMcpNoteAdd = NOTE_ADD_MCP_RE.test(block.name ?? '');
301
534
  if (isCliNoteAdd || isMcpNoteAdd) result.sawNoteAdd = true;
302
535
 
303
- const isCliFetch = block.name === 'Bash' && FETCH_RE.test(block.input?.command ?? '');
536
+ const isCliFetch = cliStatements && cliStatements.some((s) => FETCH_RE.test(s));
304
537
  const isMcpFetch = FETCH_MCP_RE.test(block.name ?? '');
305
538
  if (isCliFetch || isMcpFetch) result.sawFetch = true;
306
539
 
307
- const isCliMutation = block.name === 'Bash' && MUTATING_ACTION_RE.test(block.input?.command ?? '');
540
+ const isCliMutation = cliStatements && cliStatements.some((s) => MUTATING_ACTION_RE.test(s));
308
541
  const isMcpMutation = MUTATING_ACTION_MCP_RE.test(block.name ?? '');
309
542
  if (isCliMutation || isMcpMutation) result.sawMutatingAction = true;
310
543
  }
@@ -15,10 +15,11 @@
15
15
  * Anything else (no fetch this session, or a note was already added) exits
16
16
  * clean — this must never be the reason a session can't end.
17
17
  *
18
- * The per-session_id "asked once" state (readState/writeState) cannot
19
- * survive a compaction/resume event — that hands this hook a brand-new
20
- * session_id, a blank dedup state, AND a blank transcript file, so a real
21
- * earlier capture becomes invisible. The cross-session lastCapture marker
18
+ * The per-session_id "asked once" state (readState fast-path + claimStopNag
19
+ * atomic gate, backlog #40) cannot survive a compaction/resume event — that
20
+ * hands this hook a brand-new session_id, a blank dedup state, AND a blank
21
+ * transcript file, so a real earlier capture becomes invisible. The
22
+ * cross-session lastCapture marker
22
23
  * (keyed by cwd, not session_id) is what actually bridges that boundary
23
24
  * for a genuine capture. The parallel lastNag marker (backlog #14) bridges
24
25
  * the same boundary for a DISMISSED nag: without it, a session that already
@@ -72,7 +73,7 @@
72
73
 
73
74
  import { spawn } from 'node:child_process';
74
75
  import { fileURLToPath } from 'node:url';
75
- import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, hasRecentNag, writeLastNagAt, hasRecentAutoCaptureAttempt, writeLastAutoCaptureAttemptAt, shouldNag } from './recall-nudge-lib.mjs';
76
+ import { readStdinJson, readState, claimStopNag, scanTranscript, hasRecentCapture, writeLastCaptureAt, hasRecentNag, writeLastNagAt, hasRecentAutoCaptureAttempt, writeLastAutoCaptureAttemptAt, shouldNag } from './recall-nudge-lib.mjs';
76
77
  import { resolveProfile, resolveEffectiveRecallStrictness } from '../scripts/lib/profile-resolver.mjs';
77
78
  import { readCliToken } from '../scripts/lib/cli-auth.mjs';
78
79
  import { isLicensed } from '../scripts/lib/license.mjs';
@@ -136,8 +137,10 @@ if (hasRecentNag(cwd)) {
136
137
  process.exit(0); // already nagged recently in this same directory, just under a different session_id — a compaction/resume rollover, not a fresh session (backlog #14)
137
138
  }
138
139
 
139
- state.stopChecked = true;
140
- writeState(sessionId, state);
140
+ // Atomic claim (backlog #40) — the correctness gate, not the readState
141
+ // fast-path above. Only the single winner among concurrent Stop hooks for
142
+ // this session_id reaches the nag below; every other one exits 0 here.
143
+ if (!claimStopNag(sessionId)) process.exit(0);
141
144
  writeLastNagAt(cwd, Date.now());
142
145
 
143
146
  if (sawRecallFlag) {
@@ -3,7 +3,7 @@
3
3
  * Centralised here to avoid triplicating the regex and warning logic.
4
4
  */
5
5
 
6
- export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
6
+ export const DEFAULT_API_BASE = 'http://api.ticketlens.test';
7
7
  export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
8
8
 
9
9
  // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Opt-in CLI error/diagnostic reporting (49e). Wraps every command's
3
+ * top-level .catch() in bin/ticketlens.mjs via handleCommandError() there.
4
+ *
5
+ * Consent is asked at most once, ever, and only when a real error just
6
+ * happened and the process's stdin+stderr are both a real terminal, outside
7
+ * CI — never under MCP stdio (that path has no wiring to this function at
8
+ * all) or CI. isInteractive is a TTY check, not an output-format check: a
9
+ * human can be genuinely present and able to answer a stderr prompt
10
+ * regardless of what flags the command itself was given.
11
+ *
12
+ * The prompt is still bounded (PROMPT_TIMEOUT_MS, via promptYN's timeoutMs —
13
+ * see prompt-helpers.mjs) so an unanswered question can never hang the
14
+ * process indefinitely (security review finding, 2026-09-23: the first
15
+ * version had no timeout here — an orphaned raw-mode stdin listener keeps
16
+ * Node's event loop alive regardless of what a caller does with the
17
+ * returned promise, so the timeout has to live inside promptYN itself, not
18
+ * be raced against externally). A timeout resolves to `null`, distinct from
19
+ * a real `false` answer — it is never persisted, so an AFK/non-responsive
20
+ * user is asked again on the next real error, not permanently opted out.
21
+ *
22
+ * Once a real (non-null) answer is given, it's never asked again
23
+ * (loadErrorReportingConsent/saveErrorReportingConsent, profile-resolver.mjs).
24
+ *
25
+ * Sending is entirely best-effort: a broken network, a rejected promptFn, or
26
+ * a scan false-positive must never surface past this function, since it
27
+ * runs inside an error path that already has its own real error to report
28
+ * to the user via stderr.
29
+ */
30
+
31
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
32
+ import { getVersion } from './config.mjs';
33
+ import { apiBase } from './api-utils.mjs';
34
+ import { promptYN } from './prompt-helpers.mjs';
35
+ import { loadErrorReportingConsent, saveErrorReportingConsent } from './profile-resolver.mjs';
36
+ import { readLicense } from './license.mjs';
37
+ import { scanForSecrets, containsKnownSecretPattern } from './secret-scanner.mjs';
38
+
39
+ const CONSENT_QUESTION = 'Would you like TicketLens to send reports about issues, bugs, and errors to improve your experience?';
40
+ // Human reaction time, not a network call — generous, but still a hard
41
+ // upper bound on how long an unanswered prompt can keep the process open.
42
+ const PROMPT_TIMEOUT_MS = 15_000;
43
+
44
+ function defaultIsInteractive() {
45
+ return Boolean(process.stdin.isTTY && process.stderr.isTTY && !process.env.CI);
46
+ }
47
+
48
+ export async function maybeReportError(err, command, {
49
+ configDir = DEFAULT_CONFIG_DIR,
50
+ isInteractive = defaultIsInteractive(),
51
+ promptFn = promptYN,
52
+ fetcher = globalThis.fetch,
53
+ stream = process.stderr,
54
+ promptTimeoutMs = PROMPT_TIMEOUT_MS,
55
+ } = {}) {
56
+ try {
57
+ let consent = loadErrorReportingConsent(configDir);
58
+
59
+ if (consent === undefined) {
60
+ // Never ask outside a real terminal — undecided stays undecided until
61
+ // a real interactive session hits an error and can actually answer.
62
+ if (!isInteractive) return;
63
+ consent = await promptFn(CONSENT_QUESTION, { stream, timeoutMs: promptTimeoutMs });
64
+ // null = timed out with no answer — stays undecided, ask again next
65
+ // time, never persisted as a real (permanent) decision.
66
+ if (consent === null) return;
67
+ saveErrorReportingConsent(consent, configDir);
68
+ }
69
+
70
+ if (!consent) return;
71
+
72
+ const message = err?.message ?? String(err);
73
+ const stackTrace = err?.stack;
74
+
75
+ // Client-side pre-check only — the backend re-scans independently
76
+ // (RecallSecretScanner, defense in depth), so this just avoids a
77
+ // pointless network call when the server would reject it anyway.
78
+ //
79
+ // message uses the full entropy-aware scan (real free text — a user- or
80
+ // library-authored string, same as a Recall note body). stack_trace
81
+ // uses the narrower known-pattern-only check: a V8 stack trace's own
82
+ // "at fn (path:line:col)" frames are machine-generated and false-positive
83
+ // almost every line on the entropy heuristic (found 2026-09-23) — a real
84
+ // secret embedded in one still has a literal known prefix and is still
85
+ // caught by containsKnownSecretPattern.
86
+ const messageScan = scanForSecrets({ title: command ?? '', body: message });
87
+ if (messageScan.rejected) return;
88
+ if (stackTrace && containsKnownSecretPattern(stackTrace)) return;
89
+
90
+ const license = readLicense(configDir);
91
+ const payload = {
92
+ cli_version: getVersion(),
93
+ os: process.platform,
94
+ command: command || undefined,
95
+ message,
96
+ stack_trace: stackTrace || undefined,
97
+ profile_tier: license?.tier || undefined,
98
+ };
99
+
100
+ await fetcher(`${apiBase()}/v1/reports`, {
101
+ method: 'POST',
102
+ headers: { 'Content-Type': 'application/json' },
103
+ body: JSON.stringify(payload),
104
+ signal: AbortSignal.timeout(5000),
105
+ });
106
+ } catch {
107
+ // Best-effort — never let a broken reporting path affect the real error
108
+ // the caller is already handling.
109
+ }
110
+ }
@@ -80,6 +80,23 @@ export function loadTeams(configDir = DEFAULT_CONFIG_DIR) {
80
80
  return loadProfiles(configDir)?.teams ?? [];
81
81
  }
82
82
 
83
+ // Opt-in CLI error/diagnostic reporting (49e) — global, not per-profile: one
84
+ // machine, one consent decision, unlike recallStrictness. Tri-state via
85
+ // `undefined` (never asked) vs. `true`/`false` (asked once, answer
86
+ // persisted) — the consent prompt fires at most once per install.
87
+ export function saveErrorReportingConsent(consent, configDir = DEFAULT_CONFIG_DIR) {
88
+ mkdirSync(configDir, { recursive: true });
89
+ const profilesPath = join(configDir, 'profiles.json');
90
+ const config = loadProfiles(configDir) || { profiles: {} };
91
+ config.errorReporting = consent;
92
+ writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
93
+ invalidateProfilesCache(configDir);
94
+ }
95
+
96
+ export function loadErrorReportingConsent(configDir = DEFAULT_CONFIG_DIR) {
97
+ return loadProfiles(configDir)?.errorReporting;
98
+ }
99
+
83
100
  export function saveProfile(name, profileData, credData, configDir = DEFAULT_CONFIG_DIR) {
84
101
  mkdirSync(configDir, { recursive: true });
85
102
  const profilesPath = join(configDir, 'profiles.json');
@@ -194,22 +194,53 @@ export function promptRecallPulse(question, { stream = process.stderr } = {}) {
194
194
  }));
195
195
  }
196
196
 
197
- export function promptYN(question, { stream = process.stderr } = {}) {
197
+ /**
198
+ * @param {string} question
199
+ * @param {{ stream?: NodeJS.WriteStream, timeoutMs?: number }} [opts]
200
+ * @param {number} [opts.timeoutMs] - Optional. When set, an unanswered
201
+ * prompt resolves to `null` (not `false`) after this many ms, cleanly
202
+ * releasing raw mode and removing the listener first — this is what
203
+ * actually lets the process exit; merely racing the returned promise at
204
+ * the call site does nothing, since an orphaned raw-mode stdin listener
205
+ * keeps the event loop alive regardless. `null` (not `false`), so a
206
+ * caller can tell "timed out" apart from a real "no" answer and, e.g.,
207
+ * avoid permanently persisting a decision the user never actually made.
208
+ * Omitted entirely for the setup-wizard callers (config-wizard.mjs,
209
+ * init-wizard.mjs, onboarding.mjs) — an unbounded wait is the right
210
+ * behavior for a prompt the user is actively mid-flow on.
211
+ */
212
+ export function promptYN(question, { stream = process.stderr, timeoutMs } = {}) {
198
213
  const s = createStyler({ isTTY: stream.isTTY });
199
214
  stream.write(`\n ${question} ${s.dim('y/N')} `);
200
215
  return flushStdin().then(() => new Promise(res => {
201
216
  const stdin = process.stdin;
202
- stdin.setRawMode(true);
203
- stdin.resume();
204
- stdin.setEncoding('utf8');
205
- function onData(char) {
217
+ let timer = null;
218
+ function cleanup() {
206
219
  stdin.setRawMode(false);
207
220
  stdin.pause();
208
221
  stdin.removeListener('data', onData);
222
+ if (timer) clearTimeout(timer);
223
+ }
224
+ function onData(char) {
225
+ cleanup();
209
226
  stream.write('\n');
210
227
  if (char === '\x03') process.exit(0);
211
228
  res(char === 'y' || char === 'Y');
212
229
  }
230
+ stdin.setRawMode(true);
231
+ stdin.resume();
232
+ stdin.setEncoding('utf8');
213
233
  stdin.on('data', onData);
234
+ if (timeoutMs) {
235
+ // Deliberately NOT unref'd: this timer is what bounds the wait — an
236
+ // unref'd timer doesn't count toward Node keeping the process alive,
237
+ // so if nothing else were holding the loop open, the process (or a
238
+ // test runner's own loop-empty detection) could end before the timer
239
+ // ever fires, leaving the promise permanently pending. A live TTY's
240
+ // own stdin.resume() already keeps the process alive regardless; this
241
+ // timer only needs to fire and clean up before that natural lifetime
242
+ // runs out, which is the entire point of having it.
243
+ timer = setTimeout(() => { cleanup(); stream.write('\n'); res(null); }, timeoutMs);
244
+ }
214
245
  }));
215
246
  }
@@ -475,6 +475,28 @@ function joinedChunkRuns(tokens, { stopAtLabelWords = true } = {}) {
475
475
  return runs;
476
476
  }
477
477
 
478
+ /**
479
+ * HARD_REJECT_PATTERNS only — no tokenization, no entropy heuristic. For
480
+ * machine-generated text that is never free-form user prose (a V8 stack
481
+ * trace's "at fn (path:line:col)" frames, in practice), where the entropy
482
+ * heuristic false-positives on nearly every line: short, punctuation-dense,
483
+ * path-heavy tokens read as "random" to it even though none of it is a
484
+ * secret. A real secret embedded in such text (e.g. a token baked into an
485
+ * error message that then appears in the trace's own first line) still has
486
+ * a literal prefix (AKIA/eyJ/sk-/-----BEGIN/etc.) and is still caught here —
487
+ * only the generic "long random-looking string" class of rejection is
488
+ * skipped, deliberately, for this one call site.
489
+ *
490
+ * @param {string} text
491
+ * @returns {boolean}
492
+ */
493
+ export function containsKnownSecretPattern(text) {
494
+ const tokens = text.split(WHITESPACE_SPLIT_RE).filter(Boolean);
495
+ const despaced = text.replace(WHITESPACE_STRIP_RE, '');
496
+ const runs = joinedChunkRuns(tokens, { stopAtLabelWords: false });
497
+ return HARD_REJECT_PATTERNS.some(({ re }) => re.test(text) || runs.some(c => re.test(c)) || re.test(despaced));
498
+ }
499
+
478
500
  /**
479
501
  * @param {{ title?: string, tags?: string[], body?: string }} note
480
502
  * @returns {{ rejected: boolean, reasons: string[], warnings: string[] }}