ticketlens 0.39.6 → 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.6",
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.2 -->
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.
@@ -510,6 +510,19 @@ A report with `ok: false` is a successful tool call describing failures, not a t
510
510
 
511
511
  ---
512
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
+
513
526
  ## Gaps — cross-ticket evidence (Pro)
514
527
 
515
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).
@@ -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[] }}