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 +1 -0
- package/bin/ticketlens.mjs +61 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +23 -1
- package/skills/jtb/hooks/recall-nudge-lib.mjs +243 -10
- package/skills/jtb/hooks/recall-nudge-stop.mjs +10 -7
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/error-reporter.mjs +110 -0
- package/skills/jtb/scripts/lib/profile-resolver.mjs +17 -0
- package/skills/jtb/scripts/lib/prompt-helpers.mjs +36 -5
- package/skills/jtb/scripts/lib/secret-scanner.mjs +22 -0
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
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -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
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
286
|
+
return { stopChecked: false };
|
|
73
287
|
}
|
|
74
288
|
}
|
|
75
289
|
|
|
76
|
-
|
|
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(
|
|
79
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
19
|
-
* survive a compaction/resume event — that
|
|
20
|
-
* session_id, a blank dedup state, AND a blank
|
|
21
|
-
* earlier capture becomes invisible. The
|
|
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,
|
|
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
|
-
|
|
140
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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[] }}
|