ticketlens 0.1.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +457 -0
  3. package/bin/ticketlens.mjs +376 -0
  4. package/package.json +37 -0
  5. package/skills/jtb/scripts/fetch-my-tickets.mjs +377 -0
  6. package/skills/jtb/scripts/fetch-ticket.mjs +682 -0
  7. package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +112 -0
  8. package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +19 -0
  9. package/skills/jtb/scripts/lib/adf-converter.mjs +67 -0
  10. package/skills/jtb/scripts/lib/ansi.mjs +87 -0
  11. package/skills/jtb/scripts/lib/arg-validator.mjs +178 -0
  12. package/skills/jtb/scripts/lib/attachment-downloader.mjs +123 -0
  13. package/skills/jtb/scripts/lib/attention-scorer.mjs +152 -0
  14. package/skills/jtb/scripts/lib/banner.mjs +201 -0
  15. package/skills/jtb/scripts/lib/brief-assembler.mjs +137 -0
  16. package/skills/jtb/scripts/lib/brief-cache.mjs +137 -0
  17. package/skills/jtb/scripts/lib/budget-pruner.mjs +242 -0
  18. package/skills/jtb/scripts/lib/cache-manager.mjs +499 -0
  19. package/skills/jtb/scripts/lib/cli-auth.mjs +40 -0
  20. package/skills/jtb/scripts/lib/cli.mjs +87 -0
  21. package/skills/jtb/scripts/lib/code-ref-parser.mjs +113 -0
  22. package/skills/jtb/scripts/lib/commit-linker.mjs +42 -0
  23. package/skills/jtb/scripts/lib/compliance-checker.mjs +92 -0
  24. package/skills/jtb/scripts/lib/config-wizard.mjs +392 -0
  25. package/skills/jtb/scripts/lib/config.mjs +63 -0
  26. package/skills/jtb/scripts/lib/diff-analyzer.mjs +66 -0
  27. package/skills/jtb/scripts/lib/drift-tracker.mjs +120 -0
  28. package/skills/jtb/scripts/lib/error-classifier.mjs +119 -0
  29. package/skills/jtb/scripts/lib/help.mjs +253 -0
  30. package/skills/jtb/scripts/lib/hook-installer.mjs +81 -0
  31. package/skills/jtb/scripts/lib/init-wizard.mjs +508 -0
  32. package/skills/jtb/scripts/lib/interactive-list.mjs +257 -0
  33. package/skills/jtb/scripts/lib/jira-client.mjs +169 -0
  34. package/skills/jtb/scripts/lib/ledger.mjs +96 -0
  35. package/skills/jtb/scripts/lib/license.mjs +195 -0
  36. package/skills/jtb/scripts/lib/pr-assembler.mjs +186 -0
  37. package/skills/jtb/scripts/lib/profile-picker.mjs +216 -0
  38. package/skills/jtb/scripts/lib/profile-resolver.mjs +236 -0
  39. package/skills/jtb/scripts/lib/profile-switcher.mjs +147 -0
  40. package/skills/jtb/scripts/lib/prompt-helpers.mjs +122 -0
  41. package/skills/jtb/scripts/lib/requirement-extractor.mjs +52 -0
  42. package/skills/jtb/scripts/lib/resolve-adapter.mjs +28 -0
  43. package/skills/jtb/scripts/lib/schedule-wizard.mjs +153 -0
  44. package/skills/jtb/scripts/lib/select-prompt.mjs +106 -0
  45. package/skills/jtb/scripts/lib/spinner.mjs +44 -0
  46. package/skills/jtb/scripts/lib/styled-assembler.mjs +183 -0
  47. package/skills/jtb/scripts/lib/summarizer.mjs +109 -0
  48. package/skills/jtb/scripts/lib/sync.mjs +119 -0
  49. package/skills/jtb/scripts/lib/table-formatter.mjs +48 -0
  50. package/skills/jtb/scripts/lib/triage-exporter.mjs +93 -0
  51. package/skills/jtb/scripts/lib/triage-history.mjs +166 -0
  52. package/skills/jtb/scripts/lib/triage-push.mjs +98 -0
  53. package/skills/jtb/scripts/lib/usage-tracker.mjs +54 -0
  54. package/skills/jtb/scripts/lib/vcs-detector.mjs +12 -0
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Token Budget Optimizer — prune a TicketBrief markdown string to fit within
3
+ * a token budget. Operates on the plain (non-ANSI) brief string.
4
+ *
5
+ * Pruning priority order:
6
+ * 1. Remove individual comment blocks older than 30 days
7
+ * 2. Remove the entire ## Attachments section
8
+ * 3. Truncate ## Description to first 500 chars
9
+ * 4. Remove comment bodies from ## Linked Tickets, keep key+summary lines
10
+ */
11
+
12
+ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
13
+
14
+ /**
15
+ * Estimate token count for a string using the 4-chars-per-token heuristic.
16
+ * @param {string} text
17
+ * @returns {number}
18
+ */
19
+ export function estimateTokens(text) {
20
+ if (!text) return 0;
21
+ return Math.ceil(text.length / 4);
22
+ }
23
+
24
+ /**
25
+ * Split a brief string into sections keyed by their `## Heading` marker.
26
+ * Returns an array of { heading: string|null, content: string } objects.
27
+ * The first element (before the first ## heading) has heading === null.
28
+ *
29
+ * @param {string} brief
30
+ * @returns {Array<{ heading: string|null, content: string }>}
31
+ */
32
+ function splitSections(brief) {
33
+ const lines = brief.split('\n');
34
+ const sections = [];
35
+ let current = { heading: null, lines: [] };
36
+
37
+ for (const line of lines) {
38
+ if (line.startsWith('## ')) {
39
+ sections.push(current);
40
+ current = { heading: line.slice(3).trim(), lines: [line] };
41
+ } else {
42
+ current.lines.push(line);
43
+ }
44
+ }
45
+ sections.push(current);
46
+ return sections.map(s => ({ heading: s.heading, content: s.lines.join('\n') }));
47
+ }
48
+
49
+ /**
50
+ * Join sections back into a brief string.
51
+ * @param {Array<{ heading: string|null, content: string }>} sections
52
+ * @returns {string}
53
+ */
54
+ function joinSections(sections) {
55
+ return sections.map(s => s.content).join('\n');
56
+ }
57
+
58
+ /**
59
+ * Parse an individual comment block's date from the heading line.
60
+ * Expects format: `### **Author Name** (YYYY-MM-DD)`
61
+ * Returns a Date or null if unparseable.
62
+ *
63
+ * @param {string} block
64
+ * @returns {Date|null}
65
+ */
66
+ function parseCommentDate(block) {
67
+ const match = block.match(/###\s+\*\*[^*]+\*\*\s+\((\d{4}-\d{2}-\d{2})/);
68
+ if (!match) return null;
69
+ return new Date(match[1] + 'T00:00:00.000Z');
70
+ }
71
+
72
+ /**
73
+ * Split the Comments section content into individual comment blocks.
74
+ * Blocks are separated by `---`.
75
+ *
76
+ * @param {string} sectionContent - full section content including the `## Comments` heading line
77
+ * @returns {string[]} array of individual comment block strings (may include leading ---\n\n)
78
+ */
79
+ function splitCommentBlocks(sectionContent) {
80
+ // Strip the heading line (first line: "## Comments")
81
+ const withoutHeading = sectionContent.replace(/^## Comments\n\n?/, '');
82
+ return withoutHeading.split(/\n\n---\n\n/);
83
+ }
84
+
85
+ /**
86
+ * Determine the bare minimum token count for a brief (key + summary line only).
87
+ * This is the first two non-empty lines of the brief.
88
+ *
89
+ * @param {string} brief
90
+ * @returns {number}
91
+ */
92
+ function bareMinimumTokens(brief) {
93
+ const nonEmpty = brief.split('\n').filter(l => l.trim().length > 0);
94
+ const minText = nonEmpty.slice(0, 2).join('\n');
95
+ return estimateTokens(minText);
96
+ }
97
+
98
+ /**
99
+ * Prune a TicketBrief markdown string to fit within a token budget.
100
+ *
101
+ * @param {string} brief - assembled brief string (plain text, no ANSI)
102
+ * @param {object} [opts]
103
+ * @param {number} [opts.budget] - token limit (integer)
104
+ * @param {object} [opts.stream] - writable stream (default process.stderr)
105
+ * @param {Date} [opts.now] - reference Date for age calculations (default new Date())
106
+ * @returns {{ pruned: string, dropped: string[], finalTokens: number }}
107
+ */
108
+ export function pruneBrief(brief, { budget, stream, now } = {}) {
109
+ const streamOut = stream ?? process.stderr;
110
+ const nowDate = now ?? new Date();
111
+
112
+ // No budget specified — return unchanged
113
+ if (budget == null) {
114
+ return { pruned: brief, dropped: [], finalTokens: estimateTokens(brief) };
115
+ }
116
+
117
+ const currentTokens = estimateTokens(brief);
118
+
119
+ // Already within budget — return unchanged
120
+ if (currentTokens <= budget) {
121
+ return { pruned: brief, dropped: [], finalTokens: currentTokens };
122
+ }
123
+
124
+ // Check bare minimum guard
125
+ const minTokens = bareMinimumTokens(brief);
126
+ if (budget < minTokens) {
127
+ streamOut.write(` \u26a0 Budget ${budget} too small \u2014 returning full brief. Minimum is ~${minTokens} tokens.\n`);
128
+ return { pruned: brief, dropped: [], finalTokens: currentTokens };
129
+ }
130
+
131
+ const dropped = [];
132
+ let sections = splitSections(brief);
133
+
134
+ // ── Priority 1: Remove old comments (> 30 days) ────────────────────────────
135
+ const commentIdx = sections.findIndex(s => s.heading === 'Comments');
136
+ if (commentIdx !== -1 && estimateTokens(joinSections(sections)) > budget) {
137
+ const commentsSection = sections[commentIdx];
138
+ const blocks = splitCommentBlocks(commentsSection.content);
139
+ const keptBlocks = [];
140
+ let removedCount = 0;
141
+ let removedChars = 0;
142
+
143
+ for (const block of blocks) {
144
+ if (!block.trim()) continue;
145
+ const date = parseCommentDate(block);
146
+ const isOld = date && (nowDate - date) > THIRTY_DAYS_MS;
147
+ if (isOld) {
148
+ removedCount++;
149
+ removedChars += block.length;
150
+ } else {
151
+ keptBlocks.push(block);
152
+ }
153
+ }
154
+
155
+ if (removedCount > 0) {
156
+ dropped.push(`${removedCount} old comment${removedCount !== 1 ? 's' : ''} (\u2212${estimateTokens(' '.repeat(removedChars))}t)`);
157
+ if (keptBlocks.length > 0) {
158
+ commentsSection.content = `## Comments\n\n${keptBlocks.join('\n\n---\n\n')}`;
159
+ } else {
160
+ // Remove the entire Comments section
161
+ sections.splice(commentIdx, 1);
162
+ }
163
+ }
164
+ }
165
+
166
+ // ── Priority 2: Remove Attachments section ─────────────────────────────────
167
+ if (estimateTokens(joinSections(sections)) > budget) {
168
+ const attIdx = sections.findIndex(s => s.heading === 'Attachments');
169
+ if (attIdx !== -1) {
170
+ const attContent = sections[attIdx].content;
171
+ const attTokens = estimateTokens(attContent);
172
+ // Count attachment lines (lines starting with "- ")
173
+ const attLines = attContent.split('\n').filter(l => l.trim().startsWith('- ')).length;
174
+ dropped.push(`${attLines} attachment${attLines !== 1 ? 's' : ''} (\u2212${attTokens}t)`);
175
+ sections.splice(attIdx, 1);
176
+ }
177
+ }
178
+
179
+ // ── Priority 3: Truncate Description to 500 chars ─────────────────────────
180
+ if (estimateTokens(joinSections(sections)) > budget) {
181
+ const descIdx = sections.findIndex(s => s.heading === 'Description');
182
+ if (descIdx !== -1) {
183
+ const descSection = sections[descIdx];
184
+ // Content after the heading line + blank line
185
+ const headingPart = '## Description\n\n';
186
+ const body = descSection.content.slice(headingPart.length);
187
+ if (body.length > 500) {
188
+ const oldTokens = estimateTokens(body);
189
+ const truncated = body.slice(0, 500);
190
+ const newBody = truncated + '\n\u2026[truncated]';
191
+ descSection.content = headingPart + newBody;
192
+ const savedTokens = oldTokens - estimateTokens(newBody);
193
+ dropped.push(`description truncated (\u2212${savedTokens}t)`);
194
+ }
195
+ }
196
+ }
197
+
198
+ // ── Priority 4: Remove linked ticket comment bodies ────────────────────────
199
+ if (estimateTokens(joinSections(sections)) > budget) {
200
+ const ltIdx = sections.findIndex(s => s.heading === 'Linked Tickets');
201
+ if (ltIdx !== -1) {
202
+ const ltSection = sections[ltIdx];
203
+ const oldTokens = estimateTokens(ltSection.content);
204
+
205
+ // For each linked ticket block, keep only the ### heading line and the
206
+ // **Type:** | **Status:** meta line (first two non-empty lines per block).
207
+ const headingLine = '## Linked Tickets';
208
+ const withoutHeading = ltSection.content.slice(ltSection.content.indexOf('\n') + 1).replace(/^\n/, '');
209
+ const ltBlocks = withoutHeading.split(/\n\n---\n\n/);
210
+
211
+ const prunedBlocks = ltBlocks.map(block => {
212
+ if (!block.trim()) return block;
213
+ const lines = block.split('\n');
214
+ // Keep the ### KEY: summary heading line + the meta line
215
+ const kept = lines.filter((l, i) => {
216
+ if (i === 0) return true; // ### heading
217
+ if (l.startsWith('**Type:**')) return true; // meta line
218
+ return false;
219
+ });
220
+ return kept.join('\n');
221
+ });
222
+
223
+ ltSection.content = `${headingLine}\n\n${prunedBlocks.join('\n\n---\n\n')}`;
224
+ const saved = oldTokens - estimateTokens(ltSection.content);
225
+ if (saved > 0) {
226
+ dropped.push(`linked ticket comment bodies removed (\u2212${saved}t)`);
227
+ }
228
+ }
229
+ }
230
+
231
+ const finalBrief = joinSections(sections);
232
+ const finalTokens = estimateTokens(finalBrief);
233
+
234
+ // ── Write drop report to stream ────────────────────────────────────────────
235
+ if (dropped.length > 0) {
236
+ const pruneList = dropped.join(', ');
237
+ streamOut.write(` \u25cb Budget: ${budget} tokens. Pruned: ${pruneList}\n`);
238
+ streamOut.write(` \u25cb Final estimate: ${finalTokens} tokens\n`);
239
+ }
240
+
241
+ return { pruned: finalBrief, dropped, finalTokens };
242
+ }