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.
- package/README.md +33 -2
- package/bin/ticketlens.mjs +31 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +43 -1
- package/skills/jtb/scripts/fetch-ticket.mjs +98 -4
- package/skills/jtb/scripts/lib/activity-counter.mjs +59 -6
- package/skills/jtb/scripts/lib/attachment-text.mjs +36 -0
- package/skills/jtb/scripts/lib/brief-assembler.mjs +33 -2
- package/skills/jtb/scripts/lib/budget-pruner.mjs +34 -6
- package/skills/jtb/scripts/lib/cli.mjs +8 -0
- package/skills/jtb/scripts/lib/config.mjs +11 -0
- package/skills/jtb/scripts/lib/frontmatter.mjs +97 -0
- package/skills/jtb/scripts/lib/gap-diff.mjs +70 -0
- package/skills/jtb/scripts/lib/help.mjs +54 -0
- package/skills/jtb/scripts/lib/note-command.mjs +134 -0
- package/skills/jtb/scripts/lib/prompt-helpers.mjs +28 -0
- package/skills/jtb/scripts/lib/recall-command.mjs +57 -0
- package/skills/jtb/scripts/lib/recall-matcher.mjs +41 -0
- package/skills/jtb/scripts/lib/recall-sync.mjs +212 -0
- package/skills/jtb/scripts/lib/recall-vault.mjs +334 -0
- package/skills/jtb/scripts/lib/secret-scanner.mjs +185 -0
- package/skills/jtb/scripts/lib/styled-assembler.mjs +56 -2
|
@@ -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
|
+
}
|