ticketlens 0.12.0 → 0.16.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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Reads and writes the small metadata block at the top of a Recall note file.
3
+ * Hand-rolled — this project does not allow adding a YAML library.
4
+ * Only supports what Recall notes actually need: plain string fields and
5
+ * flat string-array fields. Not a general YAML parser.
6
+ */
7
+
8
+ const DELIMITER = '---';
9
+ const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
10
+
11
+ function needsQuoting(value) {
12
+ return value === '' || /[:#",\n[\]]|^\s|\s$/.test(value);
13
+ }
14
+
15
+ function quoteScalar(value) {
16
+ if (!needsQuoting(value)) return value;
17
+ const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
18
+ return `"${escaped}"`;
19
+ }
20
+
21
+ function unquoteScalar(raw) {
22
+ const trimmed = raw.trim();
23
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
24
+ const inner = trimmed.slice(1, -1);
25
+ return inner.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
26
+ }
27
+ return trimmed;
28
+ }
29
+
30
+ function serializeArray(items) {
31
+ return `[${items.map(quoteScalar).join(', ')}]`;
32
+ }
33
+
34
+ function parseArray(raw) {
35
+ const inner = raw.trim().replace(/^\[/, '').replace(/\]$/, '').trim();
36
+ if (inner === '') return [];
37
+ const items = [];
38
+ let current = '';
39
+ let inQuotes = false;
40
+ for (let i = 0; i < inner.length; i++) {
41
+ const ch = inner[i];
42
+ if (ch === '"' && inner[i - 1] !== '\\') inQuotes = !inQuotes;
43
+ if (ch === ',' && !inQuotes) {
44
+ items.push(current);
45
+ current = '';
46
+ continue;
47
+ }
48
+ current += ch;
49
+ }
50
+ items.push(current);
51
+ return items.map(unquoteScalar);
52
+ }
53
+
54
+ /**
55
+ * @param {object} data - flat object of string and string-array fields
56
+ * @param {string} body - the note's markdown body
57
+ * @returns {string} full note file text
58
+ */
59
+ export function serializeFrontmatter(data, body) {
60
+ const lines = [DELIMITER];
61
+ for (const [key, value] of Object.entries(data)) {
62
+ const rendered = Array.isArray(value) ? serializeArray(value) : quoteScalar(String(value));
63
+ lines.push(`${key}: ${rendered}`);
64
+ }
65
+ lines.push(DELIMITER, '', body);
66
+ return lines.join('\n');
67
+ }
68
+
69
+ /**
70
+ * @param {string} text - full note file text
71
+ * @returns {{ data: object, body: string }}
72
+ */
73
+ export function parseFrontmatter(text) {
74
+ const lines = text.split('\n');
75
+ if (lines[0] !== DELIMITER) {
76
+ return { data: {}, body: text };
77
+ }
78
+
79
+ const closingIndex = lines.indexOf(DELIMITER, 1);
80
+ if (closingIndex === -1) {
81
+ return { data: {}, body: text };
82
+ }
83
+
84
+ const data = {};
85
+ for (const line of lines.slice(1, closingIndex)) {
86
+ const sepIndex = line.indexOf(': ');
87
+ if (sepIndex === -1) continue;
88
+ const key = line.slice(0, sepIndex);
89
+ if (DANGEROUS_KEYS.has(key)) continue; // a hand-edited note can't repurpose this into a prototype-pollution vector
90
+ const rawValue = line.slice(sepIndex + 2);
91
+ data[key] = rawValue.trim().startsWith('[') ? parseArray(rawValue) : unquoteScalar(rawValue);
92
+ }
93
+
94
+ const bodyLines = lines.slice(closingIndex + 1);
95
+ if (bodyLines[0] === '') bodyLines.shift();
96
+ return { data, body: bodyLines.join('\n') };
97
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Ephemeral cross-ticket gap-diffing. At brief time, extracts candidate
3
+ * requirements from a ticket's linked tickets (existing depth-traversal,
4
+ * already capped at 15 by jira-client.mjs) and from the ticket's own
5
+ * downloaded attachments, then flags anything not already reflected in the
6
+ * ticket's own description. Nothing here is persisted — every call
7
+ * recomputes from data already attached to the ticket object.
8
+ */
9
+
10
+ import { extractRequirements } from './requirement-extractor.mjs';
11
+ import { analyzeDiff } from './diff-analyzer.mjs';
12
+ import { readTextAttachments } from './handoff-assembler.mjs';
13
+
14
+ function normalizeRequirement(requirement) {
15
+ return requirement.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
16
+ }
17
+
18
+ /**
19
+ * Flattens a ticket's linkedTicketDetails tree (any depth) into a single
20
+ * list. Guards against a cyclic or malformed graph — a linked ticket that
21
+ * points back into the tree (or at the root ticket itself) must not cause
22
+ * infinite recursion.
23
+ */
24
+ function flattenLinked(ticket, seen = new Set([ticket.key])) {
25
+ const out = [];
26
+ for (const linked of ticket.linkedTicketDetails ?? []) {
27
+ if (seen.has(linked.key)) continue;
28
+ seen.add(linked.key);
29
+ out.push(linked);
30
+ out.push(...flattenLinked(linked, seen));
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function isUncovered(requirement, currentDescription) {
36
+ const { results } = analyzeDiff([requirement], currentDescription);
37
+ return results[0]?.status === 'NOT_FOUND';
38
+ }
39
+
40
+ /**
41
+ * @param {object} ticket
42
+ * @returns {{ requirement: string, sourceType: 'ticket'|'attachment', sourceKey: string, sourceSummary?: string }[]}
43
+ */
44
+ export function computeGaps(ticket) {
45
+ const currentDescription = ticket.description ?? '';
46
+ const seenRequirements = new Set();
47
+ const gaps = [];
48
+
49
+ const addGap = (requirement, sourceType, sourceKey, sourceSummary) => {
50
+ const normalized = normalizeRequirement(requirement);
51
+ if (seenRequirements.has(normalized)) return;
52
+ if (!isUncovered(requirement, currentDescription)) return;
53
+ seenRequirements.add(normalized);
54
+ gaps.push({ requirement, sourceType, sourceKey, sourceSummary });
55
+ };
56
+
57
+ for (const linked of flattenLinked(ticket)) {
58
+ for (const requirement of extractRequirements(linked.description ?? '')) {
59
+ addGap(requirement, 'ticket', linked.key, linked.summary);
60
+ }
61
+ }
62
+
63
+ for (const { filename, content } of readTextAttachments(ticket.localAttachments)) {
64
+ for (const requirement of extractRequirements(content)) {
65
+ addGap(requirement, 'attachment', filename, undefined);
66
+ }
67
+ }
68
+
69
+ return gaps;
70
+ }
@@ -45,6 +45,8 @@ export function printHelp({ stream = process.stdout } = {}) {
45
45
  ` ${s.brand('ticketlens')} compliance ${s.dim('<TICKET-KEY>')} Check requirements coverage ${s.dim('[Pro/Free 3/mo]')}`,
46
46
  ` ${s.brand('ticketlens')} history ${s.dim('<TICKET-KEY>')} Urgency timeline for a ticket ${s.dim('[Pro]')}`,
47
47
  ` ${s.brand('ticketlens')} stats ${s.dim('[options]')} Personal response-time metrics from local history`,
48
+ ` ${s.brand('ticketlens')} note add ${s.dim('--title=... [--ticket=KEY]')} Save a Recall note ${s.dim('[Pro]')}`,
49
+ ` ${s.brand('ticketlens')} recall ${s.dim('<query|TICKET-KEY>')} Search your saved Recall notes ${s.dim('[Pro]')}`,
48
50
  '',
49
51
  ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
50
52
  ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
@@ -486,6 +488,58 @@ export function printInitHelp({ stream = process.stdout } = {}) {
486
488
  stream.write(lines.join('\n') + '\n');
487
489
  }
488
490
 
491
+ export function printNoteHelp({ stream = process.stdout } = {}) {
492
+ const s = createStyler({ isTTY: stream.isTTY });
493
+ const lines = [
494
+ '',
495
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('note add')} ${s.dim('--title="..." [--ticket=KEY] [--tags=a,b]')} ${s.dim('[Pro]')}`,
496
+ '',
497
+ ` Save a short note about a ticket to your local Recall vault. ${s.dim('[Pro]')}`,
498
+ ` Notes are saved at ${s.cyan('~/.ticketlens/recall/')} and matched into future ticket briefs.`,
499
+ ` The note body is read from stdin.`,
500
+ '',
501
+ ` ${s.bold('OPTIONS')}`,
502
+ '',
503
+ ` ${s.brand('--title')}=${s.dim('TEXT')} Note title ${s.dim('(required)')}`,
504
+ ` ${s.brand('--ticket')}=${s.dim('KEY')} Ticket this note is about ${s.dim('(optional — omit for a general note)')}`,
505
+ ` ${s.brand('--tags')}=${s.dim('a,b')} Comma-separated tags`,
506
+ ` ${s.brand('--include-attachments')} Seed the note with text from this ticket's cached attachments`,
507
+ ` ${s.brand('--plain')} Plain confirmation, no color (default when piped)`,
508
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
509
+ '',
510
+ ` ${s.bold('EXAMPLES')}`,
511
+ '',
512
+ ` ${s.dim('$')} echo "Retry needs exponential backoff" | ticketlens note add --title="Retry gotcha" --ticket=PROD-123 --tags=bug`,
513
+ '',
514
+ ];
515
+ stream.write(lines.join('\n') + '\n');
516
+ }
517
+
518
+ export function printRecallHelp({ stream = process.stdout } = {}) {
519
+ const s = createStyler({ isTTY: stream.isTTY });
520
+ const lines = [
521
+ '',
522
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('recall')} ${s.dim('<query|TICKET-KEY>')} ${s.dim('[Pro]')}`,
523
+ '',
524
+ ` Search your saved Recall notes. Local only — no network calls. ${s.dim('[Pro]')}`,
525
+ '',
526
+ ` ${s.bold('OPTIONS')}`,
527
+ '',
528
+ ` ${s.brand('--plain')} Plain output, no color (default when piped)`,
529
+ ` ${s.brand('--full')} Print each matching note's full body content`,
530
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
531
+ '',
532
+ ` ${s.bold('EXAMPLES')}`,
533
+ '',
534
+ ` ${s.dim('$')} ticketlens recall PROD-123`,
535
+ ` ${s.dim('$')} ticketlens recall "retry backoff"`,
536
+ ` ${s.dim('$')} ticketlens recall PROD-123 --plain`,
537
+ ` ${s.dim('$')} ticketlens recall PROD-123 --full`,
538
+ '',
539
+ ];
540
+ stream.write(lines.join('\n') + '\n');
541
+ }
542
+
489
543
  export function printSwitchHelp({ stream = process.stdout } = {}) {
490
544
  const s = createStyler({ isTTY: stream.isTTY });
491
545
  const lines = [
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Implements `tl note add`. Order matters: the license check runs before
3
+ * stdin is read or anything is written, so an unlicensed user never has
4
+ * their input touched at all.
5
+ */
6
+
7
+ import os from 'node:os';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
11
+ import { isLicensed, showUpgradePrompt } from './license.mjs';
12
+ import { scanForSecrets } from './secret-scanner.mjs';
13
+ import { writeNote } from './recall-vault.mjs';
14
+ import { readCliToken } from './cli-auth.mjs';
15
+ import { pushNote } from './recall-sync.mjs';
16
+ import { incrementDraftKept, incrementDraftDeleted } from './activity-counter.mjs';
17
+ import { extractText } from './attachment-text.mjs';
18
+ import { TICKET_KEY_PATTERN } from './cli.mjs';
19
+ import { createStyler } from './ansi.mjs';
20
+
21
+ function defaultListAttachments(configDir, ticketKey) {
22
+ const cacheDir = path.join(configDir, 'cache', ticketKey);
23
+ try {
24
+ return fs.readdirSync(cacheDir).map(name => path.join(cacheDir, name));
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Pulls plain text out of already-cached ticket attachments, for
32
+ * `note add --include-attachments`. Silently skips any file extractText
33
+ * can't read (images, PDFs, unsupported formats) — this is an optional
34
+ * assist, never a requirement.
35
+ */
36
+ function gatherAttachmentExcerpts(configDir, ticketKey, listAttachmentsFn, extractTextFn) {
37
+ const files = listAttachmentsFn(configDir, ticketKey);
38
+ const excerpts = files
39
+ .map(filePath => ({ name: path.basename(filePath), text: extractTextFn(filePath) }))
40
+ .filter(f => f.text);
41
+ if (excerpts.length === 0) return '';
42
+ const blocks = excerpts.map(f => `=== ${f.name} ===\n${f.text}`);
43
+ return `\n\n--- Attachment excerpts ---\n\n${blocks.join('\n\n')}`;
44
+ }
45
+
46
+ function defaultReadStdin() {
47
+ return new Promise((resolve, reject) => {
48
+ let data = '';
49
+ process.stdin.setEncoding('utf8');
50
+ process.stdin.on('data', chunk => { data += chunk; });
51
+ process.stdin.on('end', () => resolve(data.trim()));
52
+ process.stdin.on('error', reject);
53
+ });
54
+ }
55
+
56
+ function parseFlag(cmdArgs, name) {
57
+ return cmdArgs.find(a => a.startsWith(`--${name}=`))?.slice(name.length + 3);
58
+ }
59
+
60
+ /**
61
+ * @param {string[]} cmdArgs
62
+ * @returns {Promise<{ written: boolean }>}
63
+ */
64
+ export async function runNoteAdd(cmdArgs, {
65
+ configDir = DEFAULT_CONFIG_DIR,
66
+ stream = process.stderr,
67
+ readStdin = defaultReadStdin,
68
+ isLicensedFn = isLicensed,
69
+ scanForSecretsFn = scanForSecrets,
70
+ writeNoteFn = writeNote,
71
+ readCliTokenFn = readCliToken,
72
+ pushNoteFn = pushNote,
73
+ incrementDraftKeptFn = incrementDraftKept,
74
+ incrementDraftDeletedFn = incrementDraftDeleted,
75
+ listAttachmentsFn = defaultListAttachments,
76
+ extractTextFn = extractText,
77
+ author = os.userInfo().username,
78
+ } = {}) {
79
+ if (!isLicensedFn('pro', configDir)) {
80
+ showUpgradePrompt('pro', 'ticketlens note', { stream });
81
+ return { written: false };
82
+ }
83
+
84
+ const rawTitle = parseFlag(cmdArgs, 'title');
85
+ if (!rawTitle) {
86
+ stream.write('Usage: ticketlens note add --title="..." [--ticket=KEY] [--tags=a,b]\n');
87
+ return { written: false };
88
+ }
89
+ // A title is one line: collapse any embedded newline so it can never be used
90
+ // to forge a fake "## heading" line when the note is later injected into a brief.
91
+ const title = rawTitle.replace(/[\r\n]+/g, ' ');
92
+
93
+ const ticketKey = parseFlag(cmdArgs, 'ticket');
94
+ if (ticketKey && !TICKET_KEY_PATTERN.test(ticketKey)) {
95
+ stream.write(` Invalid --ticket value "${ticketKey}" — expected a ticket key like PROJ-123.\n`);
96
+ return { written: false };
97
+ }
98
+ const tagsArg = parseFlag(cmdArgs, 'tags');
99
+ const tags = tagsArg ? tagsArg.split(',').map(t => t.trim()).filter(Boolean) : [];
100
+
101
+ let body = await readStdin();
102
+ if (cmdArgs.includes('--include-attachments') && ticketKey) {
103
+ body += gatherAttachmentExcerpts(configDir, ticketKey, listAttachmentsFn, extractTextFn);
104
+ }
105
+
106
+ const scan = scanForSecretsFn({ title, tags, body });
107
+ if (scan.rejected) {
108
+ stream.write(` Note not saved — ${scan.reasons.join(' ')}\n`);
109
+ incrementDraftDeletedFn(configDir);
110
+ return { written: false };
111
+ }
112
+ for (const warning of scan.warnings) {
113
+ stream.write(` Warning: ${warning}\n`);
114
+ }
115
+
116
+ const ticketKeys = ticketKey ? [ticketKey] : [];
117
+ const { id } = writeNoteFn({ title, ticketKeys, tags, author, body }, { configDir });
118
+ incrementDraftKeptFn(configDir);
119
+ const styled = !cmdArgs.includes('--plain') && stream.isTTY;
120
+ const s = createStyler({ forceColor: styled, noColor: !styled });
121
+ stream.write(styled ? `\n ${s.green('✔')} Saved note "${title}" (${id})\n\n` : ` Saved note "${title}" (${id})\n`);
122
+
123
+ const cliToken = readCliTokenFn(configDir);
124
+ if (cliToken) {
125
+ // Field names match PushRequest's validation rules (external_id, tickets) —
126
+ // the backend wire contract, not the local vault's internal camelCase shape.
127
+ await pushNoteFn(
128
+ { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body },
129
+ { cliToken, configDir, warn: (s) => stream.write(s) },
130
+ );
131
+ }
132
+
133
+ return { written: true };
134
+ }
@@ -166,6 +166,34 @@ export async function promptScheduleAnswers(args = [], { stream = process.stderr
166
166
  return { time, email, timezone };
167
167
  }
168
168
 
169
+ /**
170
+ * Three-way single-keystroke prompt: y / n / anything else counts as skip.
171
+ * Used for the Recall "is this pulling its weight?" pulse check.
172
+ *
173
+ * @returns {Promise<'y'|'n'|'skip'>}
174
+ */
175
+ export function promptRecallPulse(question, { stream = process.stderr } = {}) {
176
+ const s = createStyler({ isTTY: stream.isTTY });
177
+ stream.write(`\n ${question} ${s.dim('y/n/skip')} `);
178
+ return flushStdin().then(() => new Promise(res => {
179
+ const stdin = process.stdin;
180
+ stdin.setRawMode(true);
181
+ stdin.resume();
182
+ stdin.setEncoding('utf8');
183
+ function onData(char) {
184
+ stdin.setRawMode(false);
185
+ stdin.pause();
186
+ stdin.removeListener('data', onData);
187
+ stream.write('\n');
188
+ if (char === '\x03') process.exit(0);
189
+ if (char === 'y' || char === 'Y') { res('y'); return; }
190
+ if (char === 'n' || char === 'N') { res('n'); return; }
191
+ res('skip');
192
+ }
193
+ stdin.on('data', onData);
194
+ }));
195
+ }
196
+
169
197
  export function promptYN(question, { stream = process.stderr } = {}) {
170
198
  const s = createStyler({ isTTY: stream.isTTY });
171
199
  stream.write(`\n ${question} ${s.dim('y/N')} `);
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Implements `tl recall <query|TICKET-KEY>` — a search over saved Recall
3
+ * notes, local-first. When logged in, pulls the team vault down before
4
+ * searching (the user is explicitly waiting on this command, so the full
5
+ * request timeout applies here — unlike the passive brief-fetch pull path).
6
+ * A pull failure never blocks the local search from returning results.
7
+ */
8
+
9
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
10
+ import { TICKET_KEY_PATTERN } from './cli.mjs';
11
+ import { isLicensed, showUpgradePrompt } from './license.mjs';
12
+ import { listNotes } from './recall-vault.mjs';
13
+ import { readCliToken } from './cli-auth.mjs';
14
+ import { pullNotes } from './recall-sync.mjs';
15
+ import { styleRecallResults } from './styled-assembler.mjs';
16
+
17
+ /**
18
+ * @param {string[]} cmdArgs
19
+ * @returns {Promise<{ ok: boolean }>}
20
+ */
21
+ export async function runRecall(cmdArgs, {
22
+ configDir = DEFAULT_CONFIG_DIR,
23
+ stream = process.stdout,
24
+ errorStream = process.stderr,
25
+ isLicensedFn = isLicensed,
26
+ listNotesFn = listNotes,
27
+ readCliTokenFn = readCliToken,
28
+ pullNotesFn = pullNotes,
29
+ } = {}) {
30
+ if (!isLicensedFn('pro', configDir)) {
31
+ showUpgradePrompt('pro', 'ticketlens recall', { stream: errorStream });
32
+ return { ok: false };
33
+ }
34
+
35
+ const arg = cmdArgs.find(a => !a.startsWith('-'));
36
+ if (!arg) {
37
+ stream.write('Usage: ticketlens recall <query|TICKET-KEY>\n');
38
+ return { ok: false };
39
+ }
40
+
41
+ const cliToken = readCliTokenFn(configDir);
42
+ if (cliToken) {
43
+ await pullNotesFn({
44
+ cliToken,
45
+ configDir,
46
+ ...(cmdArgs.includes('--no-cache') && { ttlMs: 0 }),
47
+ });
48
+ }
49
+
50
+ const filter = TICKET_KEY_PATTERN.test(arg) ? { ticketKey: arg } : { query: arg };
51
+ const results = listNotesFn(filter, { configDir });
52
+
53
+ const styled = !cmdArgs.includes('--plain') && stream.isTTY;
54
+ const full = cmdArgs.includes('--full');
55
+ stream.write(styleRecallResults(results, { styled, full }) + '\n');
56
+ return { ok: true };
57
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Decides which saved Recall notes are relevant to a given ticket.
3
+ * Pure function — no file access, no network calls. Takes a ticket and a
4
+ * list of notes already loaded into memory, and returns the relevant ones.
5
+ */
6
+
7
+ const KEYWORD_RE = /[a-z0-9]{4,}/g;
8
+ const EXACT_TICKET_SCORE = 10;
9
+ const TAG_OVERLAP_SCORE = 3;
10
+ const TITLE_WORD_OVERLAP_SCORE = 1;
11
+
12
+ function extractKeywords(text) {
13
+ return new Set((text ?? '').toLowerCase().match(KEYWORD_RE) ?? []);
14
+ }
15
+
16
+ /**
17
+ * @param {{ key: string, summary?: string, description?: string }} ticket
18
+ * @param {object[]} notes
19
+ * @returns {{ note: object, score: number }[]} sorted strongest match first
20
+ */
21
+ export function matchNotes(ticket, notes) {
22
+ const ticketKeywords = extractKeywords(`${ticket.summary ?? ''} ${ticket.description ?? ''}`);
23
+
24
+ return notes
25
+ .map(note => {
26
+ let score = 0;
27
+
28
+ if (note.tickets?.includes(ticket.key)) score += EXACT_TICKET_SCORE;
29
+
30
+ const tagOverlap = (note.tags ?? []).filter(tag => ticketKeywords.has(tag.toLowerCase())).length;
31
+ score += tagOverlap * TAG_OVERLAP_SCORE;
32
+
33
+ const titleKeywords = extractKeywords(note.title);
34
+ const titleOverlap = [...titleKeywords].filter(word => ticketKeywords.has(word)).length;
35
+ score += titleOverlap * TITLE_WORD_OVERLAP_SCORE;
36
+
37
+ return { note, score };
38
+ })
39
+ .filter(match => match.score > 0)
40
+ .sort((a, b) => b.score - a.score);
41
+ }